尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

用Rust实现跨平台OneNote Viewer:解析.one二进制格式

用Rust实现跨平台OneNote Viewer:解析.one二进制格式 打开一份.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使用 OPCOpen 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 sparsehttps://rsproxy.cn/index/ [registries.rsproxy] index https://rsproxy.cn/index [net] git-fetch-with-cli true同时可以给rustup设置镜像环境变量用于加速工具链本身的下载export RUSTUP_DIST_SERVERhttps://rsproxy.cn export RUSTUP_UPDATE_ROOThttps://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 库的 crateGNU 工具链有时需要额外配置 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_rsanyhow用于统一错误处理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 trueguifeature 默认关闭。这样做是为了让核心解析代码不依赖图形库刚好匹配“命令行优先GUI 后续再加”的迭代路线。如果一开始就把 eframe 放进默认依赖每次cargo build都要编译大量 UI 相关代码影响开发速度。3.3 定义中间数据模型model是连接解析和渲染的桥梁。一个最小但够用的模型如下pub struct Notebook { pub id: String, pub pages: VecPage, } pub struct Page { pub id: String, pub title: String, pub children: VecPage, pub blocks: VecBlock, } pub enum Block { Paragraph { text: String }, Heading { level: u8, text: String }, Image { data: Vecu8, mime: String }, Table { rows: VecVecString }, List { ordered: bool, items: VecString }, }这里有一个关键设计决定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_headerR: Read(reader: mut R) - std::io::ResultFragmentHeader { 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: Vecu8, } 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会把解码失败的位置替换为UFFFD保证程序不会因个别字符异常而整体崩溃。如果某个版本里的字符串不是 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, OptionString)) - VecPage { let mut by_id: HashMapString, (Page, OptionString) 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和Blockfn render_html(notebook: Notebook) - ResultString { let mut html String::from( !doctype htmlhtmlheadmeta charset\utf-8\titleOneNote/title/headbody, ); 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在guifeature 开启后可以单独提供图形界面入口#[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观察页面标题、子页面层级和文本内容是否符合预期。确认无误后再导出 HTMLcargo 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 [bH, 0, bi, 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后仍然很慢优先检查是否有不必要的克隆和重复分配。内存占用不要一次性把整个文件读进Vecu8后反复复制。首版可以简单生产环境建议使用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 Toolsrustup 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 高频问题。可以按以下顺序排查先用 OneNote 官方客户端打开同一文件确认原文正常排除样本损坏。用十六进制编辑器查看标题文本附近字节。如果是E4 B8 AD之类说明可能是 UTF-8如果是2D 4E这样每两个字节一组、且高字节为 0 或中文字符编码则大概率是 UTF-16LE。确认你的decode_utf16_le是否真的按小端序读取而不是用u16::from_le_bytes拼错。检查文本前面是否带有长度前缀或编码标记。有些版本会在属性集中显式声明编码不能默认全部按 UTF-16 处理。增加一段调试代码打印文本字段前的 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格式。可以先按顺序做几个小练习写一个命令把任意文件的前 16 字节以十六进制打印出来。解析一个自定义的二进制头字段包含长度和版本号。把解析结果输出成 JSON。用测试框架覆盖正常和截断两种情况。再回来实现 OneNote 的 Fragment 和 FileNode 解析。把 OneNote Viewer 当作一个长期维护的格式解析项目来对待比当作一次性脚本更有价值。第一步不用追求完整 GUI先让单个.one文件在命令行里稳定输出文本和页面层级再逐步覆盖图片、表格和旧版本格式。对于想深入 Rust 的开发者这也是训练字节级解析、内存安全和错误处理的好项目。真正遇到过count item_len导致越界、又靠测试救回来的人才会明白为什么解析器里每个长度字段都值得多写几行防御代码。
返回列表