打开一份 .one 文件,在没有安装 Microsoft OneNote 的机器上是一件很麻烦的事。OneNote 的存储格式不是普通文本,也不是 ZIP 包,而是一套基于 FileNode 的二进制复合结构,Windows 上依赖组件对象模型承载,格式细节由微软在 MS-ONE 协议中公开。用 Rust 实现一个 Open OneNote Viewer,核心价值不是做一个炫耀性的 GUI,而是把 .one 的解析、展示和导出做成跨平台、可嵌入、可测试的工程组件。本文从一个 Rust 命令行工具出发,拆解如何搭建 OneNote Viewer:先讲格式与解析路线,再给环境和项目结构,然后落地核心解析代码、渲染输出和运行验证,最后整理常见报错和扩展方向。即使你已经写过不少 Rust 代码,本文的重点也不是语法本身,而是“一个二进制文档解析器”应该如何组织代码。

1. 先理解 .one 文件格式:OneNote Viewer 的解析主线

1.1 OneNote 文件不是“文档”,而是一棵节点树

很多第一次接触 OneNote 的人会拿 .docx .xlsx 来类比,认为 .one 也是某种容器压缩包。这个类比是错的。 .docx 使用 OPC(Open Packaging Conventions),本质是 ZIP 包,解压后能看到 word/document.xml ;而 .one 走的是“OLE 复合文档 + FileNodeListFragment”的路线。简单理解就是: .one 文件里不是一个可读的 XML 正文,而是一系列二进制节点组成的数据流。

在 MS-ONE 协议中,文件的最小单元是 FileNode,多个 FileNode 组成 FileNodeListFragment。页面、段落、文本、图片这些用户能看到的对象,在文件里表现为“对象声明”(ObjectDeclaration)和“对象声明父子引用”。父页面通过 GUID 引用子页面,段落通过属性集描述样式,文本内容大多以 UTF-16 小端序保存。这个设计决定了解析器的工作方式:不是按行读文本,而是按二进制节点遍历,再通过 GUID 重建页面树。

有一种常见误解,认为 .one 可以像 TXT 一样直接读取文本。实际上第一步必须是处理二进制结构,先定位 Fragment,再解析 FileNode,最后在属性集中找到字符串数据。理解了这一点,才不会在写代码时走错方向。

1.2 三条解析路线对比

做 OneNote Viewer 之前,先要决定解析路线。常见选择有三条:

路线 实现方式 优点 缺点 适用场景
调用 OneNote COM API 通过 OLE 自动化访问 OneNote 应用 开发快,格式由官方兜底 必须安装 OneNote,仅限 Windows 企业内部工具
复用第三方解析库 使用内网 Git 仓库或社区库 减少格式工作量 维护质量参差,跨平台支持不确定 验证概念
自研 Rust 解析器 按 MS-ONE 协议读取二进制节点 跨平台、可控、可测试、可嵌入 工作量大,版本差异需要长期维护 开源 viewer、文档迁移工具

“Show HN: Open OneNote Viewer in Rust”这类项目选择自研路线,原因很直接:COM API 绑定 Windows 和 OneNote 安装,与“Open Viewer”的目标冲突;第三方库在大文件、多版本场景下不可控。自研虽然慢,但每解析出一个字段,后续功能都能复用。

1.3 Rust 做这件事的边界

Rust 在二进制解析上的优势是内存安全和性能。解析 .one 文件经常要处理不可信输入,C/C++ 写解析器容易出现越界读写;Rust 在编译期检查索引和借用,配合 Result 错误传播,很多崩溃问题能被提前拦截。性能上,Rust 没有 GC,解析大文件时不会突然停顿,适合做成库被其他语言调用。

边界也要讲清楚:Rust 生态里目前没有“开箱即用”的完整 .one 解析库,所以核心解析代码基本要自己写。另外 OneNote 各版本文件结构存在差异,例如 2010 与 2016 生成的 .one 在部分字段上有兼容性差别,这个不是 Rust 能解决的,而是格式协议问题,需要靠测试样本不断补充兼容分支。

2. 环境准备:Rust 工具链、国内镜像与项目初始化

2.1 安装 Rust 工具链

无论目标是 Windows、Linux 还是 macOS,推荐方式都是使用 rustup 安装工具链。在一个新环境里,按下面顺序执行:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

安装完成后,确认三个命令都能正常输出版本:

rustup --version
cargo --version
rustc --version

这里要区分两个概念: cargo 是构建系统和依赖管理器, rustc 是编译器。OneNote Viewer 项目会大量使用 cargo 拉取第三方 crate,所以后文提到的“镜像配置”主要针对 cargo 的 crates.io 源。

2.2 配置国内镜像源

如果你在更新 crates.io 索引时经常超时,或下载依赖很慢,建议直接配置国内镜像。常见做法是在用户目录下创建或修改 ~/.cargo/config.toml

[source.crates-io]
replace-with = 'rsproxy-sparse'

[source.rsproxy-sparse]
registry = "sparse+https://rsproxy.cn/index/"

[registries.rsproxy]
index = "https://rsproxy.cn/index"

[net]
git-fetch-with-cli = true

同时可以给 rustup 设置镜像环境变量,用于加速工具链本身的下载:

export RUSTUP_DIST_SERVER=https://rsproxy.cn
export RUSTUP_UPDATE_ROOT=https://rsproxy.cn/rustup

配置完成后,重新执行任意 cargo build cargo fetch ,观察日志中是否已经走 rsproxy-sparse 。这样可以确认源是否生效。注意,镜像配置属于环境基础工作,不要放到项目代码里,避免其他协作者被强制使用你的私有配置。

2.3 Windows 上不想安装 MSVC 时的处理

Rust 在 Windows 上默认使用 MSVC 工具链,要求系统里有 Visual Studio Build Tools,否则编译时会报 link.exe 找不到。如果你的机器上没有安装 VS Build Tools,或者只是写一个纯 Rust 解析器,可以切换到 GNU 工具链:

rustup toolchain install stable-x86_64-pc-windows-gnu
rustup default stable-x86_64-pc-windows-gnu

切换后,纯 Rust 的二进制解析项目通常可以正常编译。但要注意,如果后续引入依赖原生 C 库的 crate,GNU 工具链有时需要额外配置 MinGW-w64。因此对于 OneNote Viewer 这样以二进制解析为主的纯 Rust 项目,GNU 工具链足够;如果计划接入 Tauri 或系统级图形库,建议还是安装 VS Build Tools,使用 MSVC 工具链更省心。

下面这个表可以快速对照:

方案 优点 缺点 适合场景
MSVC 工具链 兼容性最好,原生依赖支持多 需要安装 VS Build Tools 使用 Tauri、系统 GUI 或 C 库
GNU 工具链 无需 VS Build Tools,安装快 部分原生 crate 可能需要额外配置 纯 Rust 解析、CLI 工具

2.4 用 Cargo 初始化项目

在一个干净的目录里创建项目:

cargo new onenote-viewer --bin
cd onenote-viewer

然后用 cargo add 添加第一批依赖:

cargo add anyhow
cargo add clap --features derive
cargo add serde --features derive
cargo add serde_json
cargo add encoding_rs

anyhow 用于统一错误处理, clap 用于命令行参数, serde serde_json 用于把解析结果导出为 JSON 做调试, encoding_rs 用于处理非 UTF-16 编码的文本。第一次添加后运行 cargo build ,会一次性拉取这些依赖,正好用来验证上一节的镜像配置是否生效。

3. 项目结构与模块设计:从一开始就为可测试性拆分

3.1 目录结构

二进制解析项目最忌讳把“读取文件、解析节点、渲染输出”全写在 main.rs 里。推荐按下面结构拆分:

onenote-viewer/
├── Cargo.toml
├── src/
│   ├── main.rs
│   ├── cli.rs
│   ├── reader/
│   │   ├── mod.rs
│   │   ├── fragment.rs
│   │   └── node.rs
│   ├── model/
│   │   ├── mod.rs
│   │   ├── page.rs
│   │   └── content.rs
│   ├── parser/
│   │   ├── mod.rs
│   │   └── extract.rs
│   └── render/
│       ├── mod.rs
│       ├── html.rs
│       └── markdown.rs
└── tests/
    └── parse_sample.rs

目录职责:

  • reader :负责从字节流中读出 Fragment 和 FileNode,只做二进制切割,不做业务解释。
  • model :定义页面、段落、表格、图片等中间数据结构,和文件格式完全解耦。
  • parser :把 reader 的原始节点翻译成 model 中的结构,是整个项目的核心业务逻辑。
  • render :把 model 输出为 HTML、Markdown、JSON 或 GUI 数据,可以随时替换。

这样拆分最大的好处是: render 不关心文件是 2010 还是 2016 版本, parser 不关心最终是命令行还是 GUI。任何一层报错,都能单独写测试。

3.2 依赖清单与用途

Cargo.toml 最终类似这样:

[package]
name = "onenote-viewer"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
encoding_rs = "0.8"

[features]
default = ["cli"]
cli = []
gui = ["dep:eframe"]

[dependencies.eframe]
version = "0.27"
optional = true

gui feature 默认关闭。这样做是为了让核心解析代码不依赖图形库,刚好匹配“命令行优先,GUI 后续再加”的迭代路线。如果一开始就把 eframe 放进默认依赖,每次 cargo build 都要编译大量 UI 相关代码,影响开发速度。

3.3 定义中间数据模型

model 是连接解析和渲染的桥梁。一个最小但够用的模型如下:

pub struct Notebook {
    pub id: String,
    pub pages: Vec<Page>,
}

pub struct Page {
    pub id: String,
    pub title: String,
    pub children: Vec<Page>,
    pub blocks: Vec<Block>,
}

pub enum Block {
    Paragraph { text: String },
    Heading { level: u8, text: String },
    Image { data: Vec<u8>, mime: String },
    Table { rows: Vec<Vec<String>> },
    List { ordered: bool, items: Vec<String> },
}

这里有一个关键设计决定: Block 里的文本必须是普通的 String ,而不是原始字节。这样 HTML 渲染器不需要知道自己面对的是 UTF-16 还是别的编码。所有编码转换都在 parser 层完成,后续新增 PDF 渲染时,直接复用 model 即可。

4. 核心解析代码:从文件头到文本内容

4.1 读取 Fragment 文件头

根据 MS-ONE 协议, .one 文件会以 FileNodeListFragment 的形式组织数据。解析的第一个动作是读取 Fragment 的 8 字节头部,然后按小端序解释字段。下面是一个带注释的示例:

use std::io::Read;

#[derive(Debug, Clone, Copy)]
struct FragmentHeader {
    base_type: u8,
    fragment_sequence: u8,
    fragment_data_size: u16,
    fragment_padding: u32,
}

fn read_fragment_header<R: Read>(reader: &mut R) -> std::io::Result<FragmentHeader> {
    let mut bytes = [0u8; 8];
    reader.read_exact(&mut bytes)?;

    let base_type = bytes[0];
    let fragment_sequence = bytes[1];
    let fragment_data_size = u16::from_le_bytes([bytes[2], bytes[3]]);
    let fragment_padding = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);

    Ok(FragmentHeader {
        base_type,
        fragment_sequence,
        fragment_data_size,
        fragment_padding,
    })
}

这里统一使用 from_le_bytes 而不是直接 as u16 ,是因为 .one 文件采用小端序。如果在解析过程中使用宿主机器字节序,在大端平台上会出现字段值整体颠倒的问题。Rust 的显示类型转换在这里反而是隐患,显式指定字节序是更稳妥的写法。

注意:这里展示的头部字段布局用于说明思路,实际支持某个 OneNote 版本前,要以你准备兼容的那个版本对应文档为准。版本不同,字段长度和含义都可能变化。

4.2 遍历 FileNode 列表

Fragment 头之后是连续的 FileNode。每个 FileNode 都有自己的头部和扩展数据长度,解析时按“当前偏移 + 头部大小 + 扩展大小”推进:

#[derive(Debug, Clone)]
pub struct FileNode {
    pub base_type: u8,
    pub extension_size: u32,
    pub body: Vec<u8>,
}

impl FileNode {
    pub fn parse(data: &[u8]) -> Result<(Self, usize), ParseError> {
        if data.len() < 4 {
            return Err(ParseError::UnexpectedEof);
        }
        let base_type = data[0];
        // 字段布局需要按实际文档确认
        let extension_size = u32::from_le_bytes([data[1], data[2], data[3], 0]);
        let body_start = 4;
        let body_end = body_start
            .checked_add(extension_size as usize)
            .ok_or(ParseError::Overflow)?;
        if body_end > data.len() {
            return Err(ParseError::Truncated);
        }
        let body = data[body_start..body_end].to_vec();
        Ok((
            FileNode {
                base_type,
                extension_size,
                body,
            },
            body_end,
        ))
    }
}

checked_add 在这里不是多余的防御。外部文件里的长度字段可能是损坏的,直接用 usize 加法可能溢出或越界。使用 checked_add 并返回显式错误,是二进制解析器的基本安全习惯。

4.3 从属性集中提取文本

文本数据在属性集或节点体中,通常以 UTF-16 小端序保存。提取方法如下:

fn decode_utf16_le(data: &[u8]) -> String {
    let units = data
        .chunks_exact(2)
        .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
        .collect::<Vec<_>>();
    String::from_utf16_lossy(&units)
}

这里故意使用 chunks_exact 而不是 chunks ,因为当字节长度是奇数时,说明文件字段可能被截断,剩余的单字节不应被当成一个不完整的 UTF-16 单元。 from_utf16_lossy 会把解码失败的位置替换为 U+FFFD ,保证程序不会因个别字符异常而整体崩溃。

如果某个版本里的字符串不是 UTF-16,而是其他编码,可以用 encoding_rs 解码:

use encoding_rs::WINDOWS_1252;

fn decode_with_fallback(data: &[u8]) -> String {
    let (text, _, _) = WINDOWS_1252.decode(data);
    text.into_owned()
}

实际项目中,文本提取逻辑往往需要根据属性集中的编码标记决定使用哪个解码器。先把 decode_utf16_le 跑通,再逐步补齐其他分支。

4.4 页面树的递归重建

OneNote 页面有层级关系,子页面通过 GUID 引用父页面。解析时把所有页面先放进一个 HashMap ,再按引用关系挂到父节点下,最后输出树形结构:

use std::collections::HashMap;

pub fn build_page_tree(raw_pages: Vec<(String, String, Option<String>)>) -> Vec<Page> {
    let mut by_id: HashMap<String, (Page, Option<String>)> = HashMap::new();

    for (id, title, parent_id) in raw_pages {
        let page = Page {
            id: id.clone(),
            title,
            children: Vec::new(),
            blocks: Vec::new(),
        };
        by_id.insert(id, (page, parent_id));
    }

    let mut roots = Vec::new();
    for (_, (page, parent_id)) in &mut by_id {
        match parent_id {
            Some(pid) if pid != &page.id => {
                if let Some((parent, _)) = by_id.get_mut(pid) {
                    parent.children.push(page.clone());
                    continue;
                }
            }
            _ => {}
        }
        roots.push(page.clone());
    }

    roots
}

这里 raw_pages 的三元组是“页面 ID、标题、父页面 ID”。这个示例的 clone 较多,实际项目可以改用索引或引用计数来优化内存,但结构思路是一样的:先平铺,再挂树。相比边解析边插入树,这种两阶段方式更容易调试,也更容易生成测试数据。

5. 三种输出方式:命令行导出、HTML 渲染、GUI 预览

5.1 命令行参数与导出入口

cli.rs 使用 clap 定义参数:

use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "onenote-viewer", version)]
pub struct Cli {
    /// .one 文件路径
    #[arg(short, long)]
    pub input: String,

    /// 输出目录
    #[arg(short, long, default_value = "out")]
    pub output: String,

    /// 导出格式:html / markdown / json
    #[arg(short, long, default_value = "html")]
    pub format: String,
}

main.rs 按格式分发到不同渲染器:

use anyhow::Result;
use clap::Parser;

use cli::Cli;
use parser::extract::extract_notebook;
use render::html::render_html;

fn main() -> Result<()> {
    let args = Cli::parse();
    let notebook = extract_notebook(&args.input)?;
    std::fs::create_dir_all(&args.output)?;

    match args.format.as_str() {
        "html" => {
            let html = render_html(&notebook)?;
            std::fs::write(format!("{}/index.html", args.output), html)?;
            println!("已导出: {}/index.html", args.output);
        }
        "markdown" => {
            let md = render::markdown::render_markdown(&notebook)?;
            std::fs::write(format!("{}/notebook.md", args.output), md)?;
            println!("已导出: {}/notebook.md", args.output);
        }
        "json" => {
            let json = serde_json::to_string_pretty(&notebook)?;
            std::fs::write(format!("{}/notebook.json", args.output), json)?;
            println!("已导出: {}/notebook.json", args.output);
        }
        other => anyhow::bail!("不支持的格式: {}", other),
    }

    Ok(())
}

json 格式非常适合调试。解析器改一个字段, cargo run -- --format json 就能把中间模型完整打出来,比肉眼盯二进制快很多。

5.2 HTML 渲染

HTML 渲染器直接消费 model 中的 Page Block

fn render_html(notebook: &Notebook) -> Result<String> {
    let mut html = String::from(
        "<!doctype html><html><head><meta charset=\"utf-8\"><title>OneNote</title></head><body>",
    );
    for page in &notebook.pages {
        render_page(&mut html, page, 1);
    }
    html.push_str("</body></html>");
    Ok(html)
}

fn render_page(out: &mut String, page: &Page, depth: u8) {
    out.push_str(&format!("<h{}>{}</h{}>", depth + 1, escape_html(&page.title), depth + 1));
    for block in &page.blocks {
        match block {
            Block::Paragraph { text } => {
                out.push_str(&format!("<p>{}</p>", escape_html(text)));
            }
            Block::Heading { level, text } => {
                out.push_str(&format!("<h{}>{}</h{}>", level.saturating_add(1), escape_html(text), level.saturating_add(1)));
            }
            Block::Image { .. } => {
                out.push_str("<img src=\"attachment.png\" alt=\"image\" />");
            }
            _ => {}
        }
    }
    for child in &page.children {
        render_page(out, child, depth.saturating_add(1));
    }
}

escape_html 这一步不能省。OneNote 笔记内容来自用户输入,直接拼进 HTML 会有被注入脚本的风险。即使这个 viewer 只做本地展示,也要把外部内容当作不可信数据对待:

fn escape_html(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

5.3 用 egui 做一个轻量 GUI

gui feature 开启后,可以单独提供图形界面入口:

#[cfg(feature = "gui")]
mod gui;

#[cfg(feature = "gui")]
fn run_gui(source: &str) -> eframe::Result<()> {
    let notebook = extract_notebook(source)?;
    eframe::run_native(
        "OneNote Viewer",
        eframe::NativeOptions::default(),
        Box::new(move |_cc| Ok(Box::new(gui::ViewerApp::new(notebook)))),
    )
}

ViewerApp 内部只需要实现左树右文的布局:左侧显示页面树,右侧根据选中的页面渲染段落和图片。因为渲染逻辑已经建立在 model 上,GUI 层不需要再做任何文件格式相关工作。

三种输出方式的定位差异如下:

输出方式 主要用途 适合阶段
JSON 调试解析结果,对比字段差异 开发期
HTML / Markdown 快速预览、文档迁移 可用期
egui GUI 给普通用户使用的桌面查看器 成熟期

6. 运行与验证:如何判断解析结果是正确的

6.1 准备测试样本

解析器写得再漂亮,没有标准样本就无法验证。建议准备以下测试文件:

  • 只包含一个英文页面的空笔记本。
  • 包含中文标题和中文正文的笔记本。
  • 包含二级子页面的多层笔记本。
  • 包含表格、勾选清单的笔记本。
  • 包含一张或多张图片附件的笔记本。

每个样本都要能用 OneNote 官方客户端正常打开,这样一旦你解析后的内容和官方展示不一致,就能明确是解析器问题而不是样本损坏。

6.2 运行命令

先验证调试输出:

cargo run -- --input samples/chinese.one --format json --output out

输出到 out/notebook.json ,观察页面标题、子页面层级和文本内容是否符合预期。确认无误后再导出 HTML:

cargo run --release -- --input samples/chinese.one --format html --output out

预期目录结构:

out/
└── index.html

用浏览器打开 out/index.html ,检查页面标题是否出现在 <h2> 或其他层级标签中,中文是否正常显示,图片是否被正确导出。

6.3 自动化测试的思路

不要只靠手工验证。把小样本文件放进 tests/fixtures/ ,然后写解析测试:

#[test]
fn decode_utf16_le_handles_basic_text() {
    let bytes = [b'H', 0, b'i', 0];
    assert_eq!(decode_utf16_le(&bytes), "Hi");
}

#[test]
fn parse_sample_extracts_title() {
    let notebook = extract_notebook("tests/fixtures/title_only.one").expect("解析不应失败");
    assert_eq!(notebook.pages.len(), 1);
    assert_eq!(notebook.pages[0].title, "Hello");
}

对于页面树这种结构化输出,可以考虑使用快照测试 crate,例如 insta 。第一次跑出正确结果后保存快照,之后每次修改解析逻辑,只需要 cargo insta review 检查差异,能大幅降低回归风险。

6.4 性能与内存检查

当样本文件到达几十 MB 时,需要关注两个指标:

  • 解析时长:纯 Rust 解析器处理单个 .one 文件通常应在秒级完成,如果用 --release 后仍然很慢,优先检查是否有不必要的克隆和重复分配。
  • 内存占用:不要一次性把整个文件读进 Vec<u8> 后反复复制。首版可以简单,生产环境建议使用 BufReader + 按需读取。

在开发机上可以这样观察:

/usr/bin/time -v cargo run --release -- --input large.one --format html --output out

如果内存增长异常,多数问题出现在 build_page_tree 之类的函数中,注意把 Page 移动到树里而不是克隆多份。

7. 常见问题排查:从编译失败到中文乱码

7.1 快速定位表

问题现象 可能原因 检查方式 处理建议
编译时报 link.exe not found 默认 MSVC 工具链但未装 VS Build Tools rustup show 查看默认工具链 安装 VS Build Tools,或切换 GNU 工具链
cargo 拉取依赖极慢或超时 未配置国内镜像源 查看 ~/.cargo/config.toml 按 2.2 节配置镜像
解析结果全是乱码 编码判断错误或用错字节序 用十六进制工具查看文本字段前后字节 确认是否 UTF-16LE,检查字段偏移
页面数量比 OneNote 客户端少 多层 Fragment 未递归遍历 在日志中打印 Fragment 数量 补全引用链遍历
GUI feature 编译报错 eframe 版本与 Rust 版本不兼容 cargo tree -d 查看依赖冲突 调整版本或先关闭 gui feature
使用 VSCode 无法调试 未安装 rust-analyzer 或未配置 task 安装扩展并配置 tasks.json .vscode/tasks.json 中写 cargo run 任务

7.2 中文乱码的完整排查链路

中文乱码是 OneNote Viewer 高频问题。可以按以下顺序排查:

  1. 先用 OneNote 官方客户端打开同一文件,确认原文正常,排除样本损坏。
  2. 用十六进制编辑器查看标题文本附近字节。如果是 E4 B8 AD 之类,说明可能是 UTF-8;如果是 2D 4E 这样每两个字节一组、且高字节为 0 或中文字符编码,则大概率是 UTF-16LE。
  3. 确认你的 decode_utf16_le 是否真的按小端序读取,而不是用 u16::from_le_bytes 拼错。
  4. 检查文本前面是否带有长度前缀或编码标记。有些版本会在属性集中显式声明编码,不能默认全部按 UTF-16 处理。
  5. 增加一段调试代码,打印文本字段前的 16 个字节,观察结构是否和预期一致。

7.3 大文件截断与字段长度异常

解析大文件时,如果程序在中途报 UnexpectedEof Truncated ,通常是某个节点的长度字段不正确。可能原因有两个:

  • 文件的某一段在同步时被截断,文件本身损坏。
  • 解析器对这个节点类型的长度计算错误,导致偏移跳到了错误位置。

排查时,先打印当前偏移、节点头部字段和期望长度,再对照官方客户端是否也能打开。如果官方客户端能正常打开,基本可以断定是解析器长度计算有误;如果官方也打不开,说明文件损坏,程序需要给出明确错误信息而不是 panic。

建议在解析器中统一使用自定义错误类型,并在错误信息中包含字节偏移:

pub enum ParseError {
    UnexpectedEof { offset: u64 },
    Truncated { offset: u64, need: usize, have: usize },
    Overflow { offset: u64 },
}

这样用户报 bug 时,只需要提供偏移量,你就能快速定位到具体节点。

8. 最佳实践与可扩展方向

8.1 二进制解析器的工程化习惯

第一,任何对字节数组的下标访问都要先做长度检查,不要对不可信二进制直接使用 data[i] 。第二,错误必须携带偏移信息, unwrap 只允许出现在测试代码中。第三,解析函数尽量保持纯函数风格:输入字节切片,输出结构化结果,不依赖全局状态。第四,把解析性能留给 --release 构建,开发时不必过早优化。

一个适合所有解析项目的检查清单:

  • 是否对每个长度字段做 checked_add
  • 是否显式指定字节序?
  • 是否在错误中包含偏移量?
  • 是否对不可信字符串做转义?
  • 是否用 fixture 文件覆盖正常、空文件、损坏文件三种情况?
  • 是否在 CI 中运行 fmt、clippy 和测试?

8.2 把解析器作为库暴露给其他语言

OneNote Viewer 的解析核心如果做成库,可以被 Go、Python 等语言调用。在 Cargo.toml 中声明动态库类型:

[lib]
crate-type = ["cdylib", "rlib"]

然后在 Go 中通过 cgo 调用,前提是导出 C ABI:

/*
#cgo LDFLAGS: -L./target/release -lonenote_parser
#include <stdlib.h>
*/
import "C"

更轻量的方案是编译成 WebAssembly,用 wasm-bindgen 暴露给前端,这样浏览器里也能预览 .one 文件。不过这些都属于后面要考虑的工程问题,首版先让解析结果稳定再说。

8.3 后续扩展方向

  • 支持 .onetoc2 目录文件,让多个分区笔记本能完整展示。
  • 图片、PDF 附件导出,把二进制 blob 从属性集中抽取出来写入独立文件。
  • 与全文搜索引擎集成,把页面文本导入后做本地检索。
  • 导出 PDF,可以复用 HTML 渲染结果再调用打印工具。
  • 支持旧版本 .one 文件,前提是准备对应版本的协议细节和测试样本。

如果你对网络服务有需求,也可以用 actix-web 包一层 HTTP API,把解析结果用 JSON 返回给前端。但这和核心 viewer 是两件事,建议保持解析库和 HTTP 服务分离。

8.4 学习路径建议

对刚接触 Rust 的读者,不建议直接挑战完整 .one 格式。可以先按顺序做几个小练习:

  1. 写一个命令,把任意文件的前 16 字节以十六进制打印出来。
  2. 解析一个自定义的二进制头,字段包含长度和版本号。
  3. 把解析结果输出成 JSON。
  4. 用测试框架覆盖正常和截断两种情况。
  5. 再回来实现 OneNote 的 Fragment 和 FileNode 解析。

把 OneNote Viewer 当作一个长期维护的格式解析项目来对待,比当作一次性脚本更有价值。第一步不用追求完整 GUI,先让单个 .one 文件在命令行里稳定输出文本和页面层级,再逐步覆盖图片、表格和旧版本格式。对于想深入 Rust 的开发者,这也是训练字节级解析、内存安全和错误处理的好项目。真正遇到过 count += item_len 导致越界、又靠测试救回来的人,才会明白为什么解析器里每个长度字段都值得多写几行防御代码。

Logo

更多推荐