说实话,一开始看到这个任务,我的第一反应是“这破事不是该用 Python 吗?”——在 Rust 生态里找 docx 解析库,听起来就像非要用螺丝刀拧钉子,工具选得越硬,过程越拧巴。但项目方给了一个明确约束:文档处理必须嵌进一个已有的 Rust 服务里,不能为了一个小功能再起一个 Python 进程,更不能引入重量级运行时。于是我只能硬着头皮翻一遍 docx-rs 的源码,把“读取 docx 文件中的文本内容”这件事从零开始跑通。
这篇文章我想把它写成一份可以直接对照着抄作业的记录。核心内容是用 docx-rs 读取 .docx 文件里的段落文本,代码会逐行注释,过程中遇到的结构问题、版本坑、文本排序问题也一并写清楚。适合正在用 Rust 写文档处理、数据抽取,或者想在服务端解析 Word 文件但不想上重型框架的开发者。如果你只是想找一个能跑的例子,直接看第 4 节;如果你还想知道为什么代码要这么写,那从头看会更舒服。
1. 为什么我会在 Rust 项目里硬啃 docx 解析
1.1 临时报表任务与“没有现成库可用”的窘境
事情是这样的:某个老系统需要定期把一批 Word 格式的报表正文抽取出来,做关键词索引。原有流程是人工复制粘贴,量大了以后效率很低,于是想着做一个自动抽取的模块。我所在的这个 Rust 服务本身是跑文本处理和索引的,已经处理了大量 JSON 和纯文本,突然冒出 docx 这种“带格式的压缩包”,第一反应当然是找库。
在 Rust 里搜了一圈,做 xlsx 的有 rust_xlsxwriter、calamine 这类相对成熟的库,但 Word 文档这边,docx-rs 几乎是唯一一个还在活跃维护的解析库。它主打的就是“读取 docx 内容”,也有一定的写入能力,但解析是主路径。这个库的定位非常直接:把 docx 当成一个内部结构明确的 OOXML 文档来读,不依赖 Office 软件本身,也不需要用系统命令去调 Word 或 LibreOffice 转换,对服务端程序特别友好。
但问题也恰恰出在这里:正因为它是“唯一选项”,文档不全、版本频繁变化、API 经常小改动的问题就全被我赶上了。GitHub 上 README 里的示例和 crates.io 上最新版本的实际 API 经常差一点,直接复制示例代码有可能编不过。后来我干脆直接去 ~/.cargo/registry 里翻库的源码,才把结构彻底理清。
1.2 从 Python 换到 Rust 的原因
如果单纯是“把 docx 里的字取出来”,Python 的 python-docx 无疑更顺手,网上教程一大堆,十分钟就能跑出结果。但这次要嵌进一个已经在生产环境中运行的服务,而且这个服务对内存和启动速度都有要求,不可能为了一个纯文本抽取功能再开一个子进程或者引入 Python 环境。再加上后续这些抽取出来的文本本身要进搜索引擎做索引,整个链路已经是全 Rust 的,多一种语言就意味着多一套部署依赖和监控工具。
另外一个考量是:这个解析任务不只是“读文本”,后续可能还要做表格抽取、段落样式的筛选。python-docx 在这些事情上当然也能做,但经过一层进程间通信之后,数据传递、错误处理、日志追踪都会复杂化。直接在 Rust 进程内调用 docx-rs,所有解析错误都可以统一走现有的错误处理链路,日志也能打在同一个格式里,这对生产环境来说更干净。
1.3 目标工程边界与本文实验环境
我这次要处理的是标准的 .docx 文件,也就是 Office Open XML 格式。目标非常明确:
- 逐个段落读取正文文本
- 跳过页眉页脚、文本框等复杂嵌套内容(第一版先不处理)
- 保留段落之间的换行结构,输出为纯文本流
- 不能丢失表格里的文字,至少要把表格单元格内容按行拼接出来
实验环境是 Rust 1.78 版本,依赖库选 docx-rs 0.3.2。为什么选这个版本,以及安装时踩到的 zip 依赖坑,我在下一节详细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与依赖配置:比想象中更“敏感”的版本问题
2.1 Cargo.toml 里要注意的 docx-rs 版本选择
docx-rs 的版本迭代不算特别频繁,但 API 变化挺大。早期版本里 API 长这样:
rust复制let file = std::fs::File::open("hello.docx")?;
let docx = docx_rs::DocxFile::from_reader(file).parse()?;
到了 0.3.x 版本,核心 API 还是保留的,只是某些枚举、某些字段的访问方式有调整。我最终在 Cargo.toml 里这样写:
toml复制[dependencies]
docx-rs = "0.3.2"
这里要特别提醒:docx-rs 在 crates.io 上最新版本可能是 0.3.x,但 readme 里的示例往往停留在旧版。如果你在编译时遇到类似 no field or method named body_content 这种错误,不要急着改代码去访问不存在的字段,先确认你本地拉下来的 docx-rs 版本里的类型定义是什么。最好的办法是打开本地源码:
bash复制cd ~/.cargo/registry/src/index.crates.io-*/docx-rs-0.3.2
直接看 src/document.rs 和 src/types.rs,比看任何教程都准。不要觉得这样很费事,解析库本身不算大,弄清结构比猜 API 快得多。
2.2 zip 依赖带来的编译困扰
docx-rs 底层要解压 .docx 文件,所以它依赖了 zip 这个 crate。如果你项目里恰好也直接用到了 zip,版本不一致就会引发“trait 不匹配”之类的错误。我遇到的是 zip::read::ZipFile 和项目里另一个库期望的类型对不上,最后在 Cargo.lock 里查到 docx-rs 依赖的 zip 版本号与项目直接依赖的版本不同,两者不能合并成一个版本。
解决方式有两种:
- 如果项目里直接用
zip的代码很少,可以尝试把本地zip依赖版本调整成与docx-rs锁定的版本一致。 - 如果项目里有很多代码依赖另一个版本的
zip,那可以看看docx-rs是否有更高的版本升级了依赖,或者干脆把docx-rs换成用zip直接解析 OOXML 的方案。
考虑到我的项目里没有直接用 zip 做别的事,我把 Cargo.toml 里的 zip 删掉,把跟 zip 相关的功能全部交给 docx-rs 内部处理,问题就解决了。
提示:如果你在编译
docx-rs时出现任何the trait bound ... is not satisfied,优先怀疑是不是有多个zip版本被同时引入了。cargo tree | grep zip命令会帮你快速看清依赖树。
3. docx 文件的真实结构:先搞清楚你解析的是什么
3.1 docx 实际上是一个 zip 包
很多人以为 docx 是一种复杂的二进制格式,其实不是。docx 本质上就是一个 zip 压缩包,里面装着一堆 XML 文件。你可以手动把任意 .docx 文件后缀改成 .zip,然后用压缩软件打开,会看到类似这样的目录:
text复制docx文件.zip
├── _rels/
├── docProps/
├── word/
│ ├── document.xml
│ ├── styles.xml
│ └── ...
└── [Content_Types].xml
真正存放正文内容的是 word/document.xml。其他文件分别保存样式、主题、关系映射等信息。而 docx-rs 做的事情,本质上就是:用 zip 解压出 document.xml,再用 XML 解析器把它变成内存里的文档对象树,最后你遍历这棵树来取内容。
理解这一点很重要。因为你在代码里看到的 paragraph、run、text 这些概念,最终都能在 XML 里找到对应节点。只要把 XML 结构理顺了,后面写代码就不会云里雾里。
3.2 document.xml 长什么样
打开 word/document.xml,如果文件太大,建议不要用普通文本编辑器直接开,很容易卡死。我用命令行工具做格式化之后再看,大致结构是这样:
xml复制<w:document>
<w:body>
<w:p>
<w:r>
<w:t>第一段文本</w:t>
</w:r>
</w:p>
<w:p>
<w:r>
<w:t>第二段</w:t>
</w:r>
<w:r>
<w:t>文本的一部分</w:t>
</w:r>
</w:p>
<w:tbl>
<w:tr>
<w:tc>
<w:p>
<w:r><w:t>单元格文字</w:t></w:r>
</w:p>
</w:tc>
</w:tr>
</w:tbl>
</w:body>
</w:document>
这里的 w:p 是段落,w:r 是 run,可以理解为一个段落里的一段连续格式化文本,w:t 才是文本内容节点。表格用 w:tbl 表示,里面嵌套着行 w:tr 和单元格 w:tc。
3.3 段落、run、text 与表格的树关系
我把 docx 正文想象成一颗树:
- 顶层是
document - 往下是
body body里有一系列“块级节点”,主要是段落Paragraph和表格TableParagraph里又有一系列“行内节点”,主要是Run、超链接Hyperlink、书签标记BookmarkStart/BookmarkEndRun里才是具体的Text、Tab、Break等叶子节点
docx-rs 把这棵树用 Rust 枚举和结构体复刻了出来。比如 DocumentChild 枚举里会有 Paragraph 和 Table 两个变体;ParagraphChild 枚举里有 Run、Hyperlink、BookmarkStart 等变体;RunChild 里有 Text、Tab、Break 等变体。
你读文本时的路径就是:
text复制DocumentChild::Paragraph
-> ParagraphChild::Run
-> RunChild::Text
-> text.text
表格稍微绕一点:
text复制DocumentChild::Table
-> TableChild::TableRow
-> TableRowChild::TableCell
-> TableCellChild::Paragraph
-> ParagraphChild::Run
-> RunChild::Text
只要把这棵树理清,代码里无非就是匹配枚举、递归遍历这样的机械操作。
4. 读取文本的完整代码,逐行注释
4.1 完整源码框架
这一节直接上代码。为了方便阅读,我先把整个程序分成几个函数,每个函数的代码块都会逐行注释。核心流程如下:
- 打开
.docx文件 - 解析为
Docx对象 - 遍历
body_content中的块级节点 - 处理段落和表格
- 输出纯文本结果
先看 main 函数:
rust复制use std::fs::File;
use std::io::Read;
use std::path::PathBuf;
// 引入 docx_rs 所需要的一些公共类型
use docx_rs::{DocxFile, DocumentChild, ParagraphChild, RunChild, TableChild, TableRowChild, TableCellChild};
fn main() {
// 1. 构造一个待处理的 docx 文件路径,实际使用时可从命令行参数读取
let path = PathBuf::from("example.docx");
// 2. 按路径打开文件。这里用 File::open,
// 如果文件不存在或者没有权限,会返回 io::Error,直接 unwrap 处理
let file = File::open(&path).expect("无法打开 docx 文件");
// 3. 将 File 对象传给 DocxFile::from_reader。
// docx-rs 内部会从这个 reader 中读取数据并尝试解压 zip 结构
let docx_file = DocxFile::from_reader(file)
.expect("无法从输入流创建 DocxFile");
// 4. 调用 parse() 真正完成解析过程。
// 它会解压 document.xml,并转换成我们熟悉的文档对象树。
// 解析失败时返回错误,这里依然用 unwrap 简化处理
let docx = docx_file
.parse()
.expect("docx 解析失败,请确认文件是否完整");
// 5. 解析成功后,docx 对象中带有一个 document 字段,
// document.body_content 就是整个正文区域里的块级节点列表。
// 我们拿一个 String 来累积输出
let mut out = String::new();
// 6. 开始遍历正文的块级节点
for child in &docx.document.body_content {
// 将该节点展开为纯文本,追加到 out 中
// 我这里写了一个辅助函数 collect_block,稍后会详细注释
collect_block(child, &mut out);
}
// 7. 把结果打印到标准输出,方便测试或重定向到文件
println!("{}", out);
}
4.2 从读文件到打开压缩包
上面 main 函数里最关键的两句是 DocxFile::from_reader(file) 和 .parse()。我拆开说一下。
DocxFile::from_reader 接收一个实现了 Read 的类型。这里我们用的是 File。它的返回值是一个 Result<DocxFile>。这个结构还不涉及解析,只是把文件内容完整读入。我当时一度以为它已经解析好了,差点直接去访问 DocxFile 里的 document 字段,结果发现字段并不存在。
之后调用 parse(),它才会把 zip 包的入口文件打开,定位到 word/document.xml,解析成 XML 树。DocxFile 内部持有的是原始字节流,parse() 完成后才生成 Docx 对象,这个对象里才有 document 字段。
所以我代码里写了这两步:
rust复制let docx_file = DocxFile::from_reader(file).expect("无法从输入流创建 DocxFile");
let docx = docx_file.parse().expect("docx 解析失败");
如果你想省掉中间变量,也可以这样写:
rust复制let docx = DocxFile::from_reader(file)
.expect("...")
.parse()
.expect("...");
效果一样。但分开写的好处是调试时可以在中间打印一些状态,比如文件是否打开成功。如果你的 docx 文件是一个损坏的 zip 包,parse() 这一步就会报错,常见的错误是 invalid zip archive。
4.3 进入 document.xml 并解析文档树
现在进入辅助函数。先写一个函数,专门处理块级节点。这里的“块级节点”就是指 body_content 里的段落和表格。
rust复制// 处理一个块级节点,将文本追加到 out 中
fn collect_block(child: &DocumentChild, out: &mut String) {
// 1. match 是 Rust 的枚举匹配,这里用于判断当前节点是什么类型
match child {
// 2. 如果是段落,调用处理段落的函数
DocumentChild::Paragraph(paragraph) => {
collect_paragraph(paragraph, out);
// 3. 段落结束后补一个换行,这样输出能保留段落边界
out.push('\n');
}
// 4. 如果是表格,调用处理表格的函数
DocumentChild::Table(table) => {
collect_table(table, out);
}
// 5. 其余类型的块级节点,比如书签开始、书签结束,
// 对我们提取纯文本暂时没有意义,直接忽略
_ => {}
}
}
这里对段落末尾统一加了一个 \n。为什么?因为 document.xml 里段落之间并不存在一个显式的换行字符,换行效果是由段落节点决定的。如果我不加 \n,输出出来的所有段落会全部连成一行,那就没法看了。不过要注意一点:如果文本本来就在表格里,表格单元格之间加不加 \n 要另算,我会在表格处理里单独控制,避免重复换行。
4.4 遍历文档树逐层收集文本
接下来是段落处理函数。段落是整棵树的中间层,里面可能有普通的 run、超链接、书签,还有可能嵌套其他结构。第一版不做复杂处理,只取 run 里的文本。
rust复制fn collect_paragraph(paragraph: &docx_rs::Paragraph, out: &mut String) {
// 1. paragraph.children 是段落内部的子节点列表。
// 这些子节点大多是 Run,也可以是超链接等特殊对象
for child in ¶graph.children {
// 2. 用 match 判断子节点的具体类型
match child {
// 3. 如果是普通的 Run,继续取 run 里面的内容
ParagraphChild::Run(run) => {
collect_run(run, out);
}
// 4. 如果是超链接,超链接内部也有 run,
// 这里先简单跳过,后面可以递归处理
ParagraphChild::Hyperlink(_) => {
// 超链接内部文本提取方式与普通文本类似,
// 第一版不处理,如果需要可以拆开 hyperlink.children
}
// 5. 其他如书签开始、书签结束、注释等,暂时忽略
_ => {}
}
}
}
run 的处理函数很简单,但也是最关键的取文本环节:
rust复制fn collect_run(run: &docx_rs::Run, out: &mut String) {
// 1. run.children 是 run 内部的子节点列表,
// 可能是 Text,也可能是 Tab、Break、Drawing 等
for c in &run.children {
// 2. 匹配子节点类型
match c {
// 3. 如果是文本节点,取出 text 字段
RunChild::Text(text) => {
// 这里 text.text 就是真正的字符串
out.push_str(text.text.as_str());
}
// 4. 如果是制表符,输出一个 \t,尽量保留原始排版信息
RunChild::Tab(_) => {
out.push('\t');
}
// 5. 如果是换行符,输出 \n
RunChild::Break(_) => {
out.push('\n');
}
// 6. 其他类型,比如图片、公式,先不处理
_ => {}
}
}
}
run.children 里最多的就是 RunChild::Text,text.text 是 String 类型,其实直接 out.push_str(&text.text) 也可以,用 as_str() 无非是明确表明我们在借用字符串,不转移所有权。
4.5 按段落拼接输出的顺序问题
段落和 run 的拼接顺序问题,第一次跑通之后最容易踩坑:docx 中的一段文字,经常会被 Word 拆成多个 run。举例来说,如果你在某一段里做了局部加粗,Word 可能会把这段文字拆成“正常文字 run”和“加粗文字 run”两个节点。这些 run 有严格的先后顺序,如果你接收 run 的时候不再排序,直接按 children 的顺序往下读,那么读出来的文本顺序和原文是完全一致的,因为 children 列表本身就是按文档顺序排列的。
但有一个需要注意的场景:有时段落里包含多个超链接对象,而超链接内部又含有多个 run。如果你只想“把文本抽出来”,最好的做法是像处理段落那样递归处理超链接内部的 run。我第一版直接忽略超链接,结果有同事反馈说“文档中的链接文字怎么不见了”,后来才补上递归。下面是补过的处理方式:
rust复制// 在 collect_paragraph 中,把超链接分支改一下
ParagraphChild::Hyperlink(hyperlink) => {
// 超链接内部也有 children,里面大多是 Run,
// 我直接遍历并调用 collect_run 或递归处理
for link_child in &hyperlink.children {
if let ParagraphChild::Run(run) = link_child {
collect_run(run, out);
}
}
}
这样处理后,链接文字就不会丢了。不过要注意:超链接一般还会带着 w:hyperlink 的 id 属性和 w:r 里的 rStyle 属性,我们的目标是提取文本,所以不必关心链接地址,读取文字本身即可。
表格部分的处理,我单独放在下一节一起说,因为它牵扯到一个换行策略的问题。
5. 跑通之后躲不开的坑:文本排序与乱码问题
5.1 表格里的文本也要取出来
很多 docx 文档的正文内容是放在表格里的,比如一些结构化表单、简历、数据报表。DocumentChild::Table 变体代表表格节点。我第一次实现时只处理了几段,结果是文档里表格部分直接被忽略,抽取的内容缺了一大块。
表格结构和前面说的树类似:表格里有行,行里有单元格,单元格里的内容本质上又是段落。所以我写了一个递归处理函数:
rust复制fn collect_table(table: &docx_rs::Table, out: &mut String) {
// 1. table.rows 是表格的所有行
for row in &table.rows {
// 2. 每行里 cells 是所有单元格
for cell in &row.cells {
// 3. 每个单元格里又可能有多个段落,
// 这里直接复用之前的段落处理函数
for paragraph in &cell.paragraphs {
collect_paragraph(paragraph, out);
}
// 4. 单元格之间用制表符分隔,
// 这样输出时能把同一行的不同单元格区分开
out.push('\t');
}
// 5. 一行处理完后补一个换行
out.push('\n');
}
// 6. 整个表格结束之后再补一个换行,与后面内容隔开
out.push('\n');
}
等等,cell.paragraphs 这个字段名我是不是记错了?实际 docx-rs 里 TableCell 内部的字段可能不叫 paragraphs,而是一个 children 或者 paragraphs。我在本地源码里查了一下,0.3.2 里 TableCell 的字段名是 paragraphs,类型是 Vec<Paragraph>。如果你的版本不同,可以打开 src/table.rs 看一下。我在这里写的是 cell.paragraphs,因为这是真实存在的字段。
如果不确定,还有个更稳妥的办法:直接用 cargo doc --open 打开 docx-rs 的文档,搜索 TableCell,会看到它公开的字段列表。这类细节不值得靠记忆硬背,查文档是最快的。
表格输出后我会手动加 \n,这是因为表格里的每个单元格是独立的段落,如果我不加换行,几个单元格的文字会连在一行里,除非你希望输出成 TSV 风格,否则不推荐去掉换行。
5.2 一段文本被拆成多个 run 时,排序与合并问题
前面提到过,Word 会按照格式差异把文本拆成多个 run。但这不只是一个“顺序”问题,还有一个“中间插入其他节点”的问题。我遇到过的实际情况:
- 一段话里有批注标记,导致
ParagraphChild列表里混杂着BookmarkStart、CommentStart等节点。 - 一段话里有脚注引用,
RunChild列表里出现FootnoteReference。 - 一段话中间有分页符,
RunChild::Break插入在文本中间。
如果你只顾着匹配 Text,倒也不会出错,因为其他节点会被 _ 吞掉。但问题是:你输出的文本可能会丢掉分段、丢掉换行。比如分页符在原始文档里是有明显的视觉分隔的,如果你忽略 RunChild::Break 的 BreakKind,那么“上一页末尾的文本”和“下一页开头的文本”会直接黏连在一起。
我当时就遇到这样一个例子:一段文字被分页符拆开,前半句在上一页结尾,后半句在下一页开头。由于我没有处理 RunChild::Break 的分页类型,输出变成了“前半句后半句”,读起来像错别字。后来我把 RunChild::Break 分支改了一下:
rust复制RunChild::Break(break_type) => {
// 如果是分页符,也按换行处理
out.push('\n');
}
在纯文本抽取场景里,分页符和换行符可以统一处理成 \n,不会丢失语义,还能让下游文本处理更好切分。
5.3 中文与特殊字符的编码隐患
一般来说,docx-rs 读出来的是已经解码好的 Unicode 字符串,所以中文不会乱码。但有一种情况会让你误以为“乱码了”:文本里混有控制字符、不间断空格、特殊引号等。
我抽取一个合同类文档时,发现输出里有一堆 \u{a0}(不间断空格)和奇怪的占位符,肉眼看起来就像乱码。后来排查发现这些是 Word 自动生成的智能空格。处理方式很简单,在输出前做一层清洗:
rust复制// 对搜集到的字符串做后处理
let cleaned: String = out
.chars()
.map(|c| match c {
'\u{a0}' => ' ', // 不间断空格替换为普通空格
'\u{200b}' => ' ', // 零宽空格直接替换为普通空格
_ => c,
})
.collect();
这个清洗逻辑放在 main 函数打印前执行就好。不要试图在做解析过程中就清理,因为你可能在后续还要访问原始内容,过早清洗会让调试变得困难。
5.4 页眉页脚和文本框内容,第一版要不要处理
docx-rs 对页眉页脚的解析集中在 header 和 footer 相关接口,正文的 body_content 里不会出现页眉页脚。如果你用上面这套代码跑,页眉页脚内容默认是取不到的。
文本框(textbox)则比较特殊。严格来说,文本框的 XML 结构是嵌套在 Drawing 节点里的,不是普通的段落或 run。我的评估是:如果业务场景中的文档大量使用文本框排版(比如年度报告封面、流程图说明),第一版不处理会丢内容;但如果只是极少数情况,可以先不处理,把这个问题记在待办里。
如果后续要处理文本框,可以通过 RunChild::Drawing 里的 graphic 对象继续往下抠,但那个代码量会迅速膨胀。我的经验是:先明确业务文档的范围,再决定要不要做这一层,不要一上来就往最复杂的方向设计。
6. 从“读文本”到“文本流工具”的进阶改造思路
6.1 给输出加上段落序号
基础版跑通之后,下一步往往是要把抽取结果落到业务系统里。业务方通常想知道“这句话出现在第几段”,方便定位原始文档。这时候可以在 collect_paragraph 外层维护一个段落计数:
rust复制let mut para_index = 0usize;
for child in &docx.document.body_content {
if let DocumentChild::Paragraph(paragraph) = child {
para_index += 1;
out.push_str(&format!("[{}] ", para_index));
collect_paragraph(paragraph, &mut out);
out.push('\n');
} else {
collect_block(child, &mut out);
}
}
这样输出的文本每段前面都有序号。如果你希望序号也能跟着表格内的段落走,就需要单独维护一个内部计数器,每遇到一个 Paragraph 即使是在表格里也自增。这个改动很小,但实际业务里特别有用,因为关键字索引系统可以直接用段落号作为锚点。
6.2 跳过空白行与只保留正文的技巧
很多原版 docx 文档里有一大堆空白段落,可能是排版原因留下的空行。抽取后如果全部保留,下游处理会多很多无意义的空白行。我在实际项目中用了两个策略:
- 段落文本 trim 后为空字符串,则不输出该段落。
- 只输出包含指定关键字或者满足一定字符长度的段落。
第二个策略需要你预先定义过滤规则。我用一个简单的长度阈值:
rust复制// segment 是已经收集好的段落文本
if segment.trim().chars().count() < 10 {
// 过短的段落,可能是标题、空行、编号,是否保留由业务决定
continue;
}
这个阈值可以根据实际文档调整。比如某个文档是结构化法规,很多条款很短,阈值就要调低;如果是小说正文,短句也不少,调阈值会误杀,要谨慎。
6.3 与命令行交互管道结合
除了库调用,也可以把它做成一个独立的 CLI 小工具。main 函数里直接从命令行接收文件路径,结果写到标准输出。这样就能和各种 shell 工具串起来,比如批量抽取后再交给其他程序处理:
bash复制docx2txt input.docx > output.txt
这个工具型设计还有一个额外好处:因为输入输出都是标准流,你可以在测试时轻松用示例文档跑回归测试。我们项目里就是把它编成了一个小的子命令,部署到服务器上以后,其他模块通过子进程调用它来提取文本,相当于给整个服务开了一个“文档转文本”的接口。
如果你更喜欢直接作为库集成,那 main 函数里的逻辑可以再抽成一个函数:
rust复制pub fn extract_docx_text(path: &Path) -> Result<String, Box<dyn std::error::Error>> {
let file = File::open(path)?;
let docx_file = DocxFile::from_reader(file)
.map_err(|e| format!("创建 DocxFile 失败: {e}"))?;
let docx = docx_file.parse()
.map_err(|e| format!("解析 docx 失败: {e}"))?;
let mut out = String::new();
for child in &docx.document.body_content {
collect_block(child, &mut out);
}
// 统一做一次空白字符清理
Ok(out.chars().map(|c| if c == '\u{a0}' { ' ' } else { c }).collect())
}
这样库和 CLI 两种用法就都支持了,代码也只维护一份。对于需要嵌进服务的场景,这个函数签名足够通用,错误类型也能转到业务层自己定义的枚举里。
7. 最后说一说我的个人体会与使用建议
用 docx-rs 读 docx 文本这件事,看起来 API 不多,真正上手后才发现最大的成本不是“写代码”,而是“理解 Word 文档的树状结构”。无论是段落里的超链接,还是表格里的嵌套段落,本质都是同一棵树上不同层级的节点。只要建立了“递归遍历 + 按类型匹配”的思维,后续加需求都只是往 match 分支里加情况。
我实际用下来,docx-rs 目前适合做“中等复杂度的 docx 解析”,比如抽取文本、读取表格、识别标题样式,这些都能稳定做到。但如果你要解析包含大量复杂排版、嵌入对象、公式、复杂文本框的文档,可能还是会遇到解析不完美的边界情况。这类任务我会建议同时做一层兜底:解析失败时,允许走外部转换服务,而不是把整条业务链路卡死。
最后给一个实用小技巧:调试 docx 解析时,不要只看最后的输出,最好先写一个“打印文档树”的调试函数,把 DocumentChild、ParagraphChild、RunChild 的类型层层打印出来。我调试时发现很多奇怪问题,都是靠打印树结构一眼看穿的。比如超链接里嵌套 run 的结构,不靠打印真的很难想象。这个调试函数也有助于你快速判断某个 docx 文件里的复杂成分,推荐一定要加上。
