anydoc 深度拆解:当文档解析决定「不上 AI」——从 OLE 复合文档到 GFM 序列化,一条 4.4ms 中位数的纯 Rust 管线如何把 14 种格式收敛成一个模型
2026 年,你想让一个 Agent「读一下这个附件」,主流答案已经变成了:丢给多模态模型。DeepSeek-OCR 之后是百度的 Unlimited-OCR,一张图进去,Markdown 出来,端到端,省心。
然后你上了生产,账单来了。
一份 200 页的合同扫描件,按页切图、按视觉 token 计费、跑一遍 VLM,几毛到几块钱不等,延迟以秒甚至分钟计。更要命的是——这份文档根本不是扫描件,它是一个规规矩矩的 .docx,里面的每一个字符、每一个样式 ID、每一个表格的合并信息,都以结构化的形式明明白白躺在 XML 里。你花钱让一个 700 亿参数的模型,去「看」一张由这些结构化数据渲染出来的图片,再把它「猜」回结构化数据。
这就是 firecrawl/anydoc 出现的语境。它的立场极其鲜明:能确定性解析的,绝不交给概率模型。纯 Rust、零 ML、零外部服务,14 种格式全覆盖,单文档转换中位数 4.4 毫秒。
仓库创建于 2026-08-03,四天后 star 数冲到 1.2 万+、fork 626,MIT 协议,整个仓库 275 个文件。这篇文章我们把它彻底拆开:它的架构为什么能「修一次 bug 全格式受益」,Word 97 的二进制地狱它是怎么趟过去的,那张 4.4ms 的性能表有多少水分,以及最重要的——在 2026 年,你的文档管线到底应该怎么分层。
一、背景:文档解析是 AI 工程里最脏、最被低估的一段路
1.1 「读文件」这件事,从来没有被真正解决过
做过 RAG 的人都知道一个残酷的事实:检索质量的天花板,在切块之前就被文档解析定死了。
你的向量库里存的如果是这样的东西:
第一季度营收
2026
Q1
Q2
Q3
1,200
1,450
1,380
增长率
12%
8%
-5%
那不管你用多好的 embedding 模型、多花哨的 rerank,检索出来的都是一堆语义粉尘。原文本来是一张三行四列的表格,解析器把它拍平成了一列,行列关系全丢。用户问「Q2 的增长率是多少」,模型只能瞎猜。
反过来,如果解析出来的是:
## 第一季度营收
| 2026 | Q1 | Q2 | Q3 |
| --- | --- | --- | --- |
| 营收 | 1,200 | 1,450 | 1,380 |
| 增长率 | 12% | 8% | -5% |
同一个 embedding 模型,同一个 chunk 大小,检索准确率能差出一个数量级。结构就是语义。
1.2 现有工具的三种失败模式
在 anydoc 之前,Python 生态的主力选手大致分三类,每一类都有结构性缺陷:
第一类:包壳子的 LibreOffice。 你 subprocess 起一个 soffice --headless --convert-to,等它启动 JVM 级别的庞然大物,转完再读结果。功能是全的,但代价是:进程启动 + 文档转换,中位数 1129.5 毫秒。一份文档一秒多,一万份文档三个多小时。而且 LibreOffice 的 Markdown/HTML 导出是给人看的,不是给机器吃的——满屏 <span style="...">,清洁度得分只有 24 分(满分 100)。
第二类:per-format 的 Python 库拼盘。 markitdown 是这个流派的代表:docx 走 mammoth,xlsx 走 openpyxl,pptx 走 python-pptx,PDF 走 pdfminer。问题在于每个格式的输出风格完全不一致。docx 的表格用一套转义规则,xlsx 的表格用另一套;docx 的标题带 anchor,pptx 的标题不带。你在下游写的清洗逻辑,得为每种格式写一遍。更糟的是,修一个 bug 只能修一个格式——docx 的表格里出现 | 没转义导致 Markdown 表格错位,你修好了 docx,rtf 还是错的。
第三类:ML 优先。 docling、unstructured 这类工具引入了版面分析模型。理论上更强,实际上:装依赖要下 GB 级模型权重,冷启动几十秒,中位数 513.6ms / 572.9ms,还得有 GPU 才能跑得动。对于一个「用户上传了个 .docx,我要在 HTTP 请求里同步转换」的场景,这是完全不可接受的。
1.3 anydoc 的赌注
anydoc 的判断是:上面三类工具解决的是同一个问题的不同侧面,而这个问题本身其实是确定性的。
.docx 是 ZIP + XML,规范是公开的 ECMA-376。.doc 是 OLE 复合文档,规范是公开的 MS-DOC。.rtf 是纯文本控制字语法,规范公开。.odt 是 OASIS ODF,规范公开。除了扫描件 PDF,其他所有格式的文档结构都是可以被精确读出来的,不需要任何猜测。
既然如此,为什么不写一个真正的解析器?
答案很简单:因为太累了。MS-DOC 规范有 600 多页,光是 SPRM(Single Property Modifier)就有几百个操作码。ECMA-376 更是上万页。写这么一个东西,是那种「明知道正确但没人愿意干」的活。
anydoc 干了。而且用 Rust 干的。
二、项目概览:一个 275 文件的 Rust 单体,五种分发形态
2.1 基本盘
| 项目 | 值 |
|---|---|
| 仓库 | firecrawl/anydoc |
| 语言 | Rust(100%,无 ML 依赖) |
| 协议 | MIT |
| 创建时间 | 2026-08-03 |
| Star / Fork | 12,655 / 626(截至 2026-08-07) |
| 仓库文件数 | 275 |
| 支持格式 | 14 种扩展名,8 大格式族 |
| 中位转换耗时 | 4.4ms |
出品方是 Firecrawl——做网页抓取转 Markdown 那家。这个背景很重要:anydoc 是他们 Firecrawl Parse 商业产品的开源内核。这意味着两件事,一是这代码是被真实流量喂过的,不是玩具;二是开源部分故意留了一个缺口——扫描件 OCR 在托管 API 里,本地版没有。
2.2 支持矩阵
| 格式族 | 扩展名 |
|---|---|
| Word | .doc .docx .docm |
| PowerPoint | .ppt .pps .pot .pptx .pptm .ppsx .ppsm |
| Excel | .xls .xlsx .xlsm .xlsb |
| OpenDocument | .odt .ods .odp |
| RTF | .rtf |
| EPUB | .epub |
| CSV | .csv |
.pdf(文本型) |
注意 .doc .ppt .xls .xlsb 这几个——这是二进制格式,不是 ZIP+XML。绝大多数现代工具直接放弃了它们(markitdown、pandoc、docling 在基准里对 .doc 一栏全是横杠)。anydoc 把它们全啃了,而且 .doc 拿了 87 分,是所有格式里第二高的。
对于国内团队,这一点的意义比看上去大得多:政企、金融、法务场景里,2003 版 .doc 和 .xls 的存量大得惊人。一个不支持 .doc 的文档管线,在这些场景里就是不能用。
2.3 五种分发形态
这是 anydoc 工程化上做得最舒服的地方——一份 Rust 内核,五个入口:
# 1. CLI(npx 首次运行会下载对应平台预编译二进制)
npx @firecrawl/anydoc report.docx # 输出到 stdout
npx @firecrawl/anydoc slides.pptx -o slides.md # 输出到文件
npx @firecrawl/anydoc - --format csv < data.csv # 从 stdin 读
# 2. Rust crate
cargo add anydoc
# 3. Node.js(napi 绑定)
npm install @firecrawl/anydoc
# 4. Python(maturin / PyO3 轮子)
pip install firecrawl-anydoc
# 5. 浏览器(WebAssembly)
npm install @firecrawl/anydoc-wasm
外加第六种,很 2026 的一种:
# 6. Agent Skill
npx skills add firecrawl/anydoc
装完之后,Claude Code / Codex / Cursor / OpenCode 这类 Agent 就直接会用 anydoc CLI 读文档了。仓库里 skills/convert-documents-to-markdown/SKILL.md 就是那份技能定义。
这个分发策略值得所有做基础库的人抄一遍。 内核用系统语言写一次,然后 FFI 到脚本语言、编译到 WASM、包一层 CLI、再写一份 Agent Skill。四条路覆盖了从「浏览器里前端本地转换」到「Python 数据管线」到「AI Agent 自主调用」的全部消费场景,维护成本却只有一份解析逻辑。
三、架构分析:一个模型,一个序列化器,十个解析器
3.1 核心管线
README 里给出的管线图,是理解整个项目的钥匙:
document bytes
│
├─► format detection → 看内容标记,不看扩展名
│
├─► format parser → 每种格式一个(doc, docx, ppt, pptx, xls,
│ xlsx, odt/ods/odp, rtf, epub, csv)
│ │
│ └─► Document → 共享模型:blocks, inlines, tables,
│ footnotes, assets
│ │
│ └─► GFM serializer → Markdown
│
└─► PDF → pdf-inspector → 直接产出 Markdown
这个「沙漏形」架构是 anydoc 相对于 markitdown 那一类工具的根本性优势。 十个解析器在上面,一个 Document 模型在腰,一个 GFM 序列化器在下面。
它带来的直接后果,README 里说得很直白:
一个 docx 的表格转义修复,自动就是 rtf、odt 以及所有其他格式的表格转义修复。
这在软件工程上叫「收敛点」。markitdown 那种架构,N 种格式就有 N 条输出路径,bug 修复是 O(N) 的;anydoc 是 N 条输入路径汇聚到 1 条输出路径,输出层的 bug 修复是 O(1) 的。
而在文档转换这个领域,绝大多数难缠的 bug 恰恰都在输出层:Markdown 转义、表格对齐、列表嵌套编号、链接锚点、脚注编号。把这些集中到一个地方修,是巨大的杠杆。
3.2 目录结构反推架构
让我们把 src/ 摊开看。这是从仓库 git tree 拿到的真实文件清单:
src/
├── lib.rs
├── error.rs
├── formats/
│ ├── detect.rs # 内容嗅探
│ ├── mod.rs
│ ├── csv.rs
│ ├── pdf.rs # 旁路:转发给 pdf-inspector
│ ├── doc/
│ │ ├── mod.rs
│ │ ├── sprm.rs # Single Property Modifier 解码
│ │ ├── stsh.rs # 样式表(Style Sheet)
│ │ └── lists.rs
│ ├── docx/
│ │ ├── mod.rs
│ │ ├── content.rs
│ │ ├── numbering.rs
│ │ └── styles.rs
│ ├── ppt/
│ │ ├── mod.rs
│ │ └── styletext.rs
│ ├── pptx/
│ │ ├── mod.rs
│ │ └── cascade.rs # 占位符样式级联
│ ├── sheet/
│ │ └── mod.rs # xls / xlsx 共用
│ ├── odf/
│ │ ├── mod.rs
│ │ ├── styles.rs
│ │ ├── table.rs
│ │ └── text.rs
│ ├── rtf/
│ │ ├── mod.rs
│ │ ├── lexer.rs # 控制字词法分析
│ │ ├── table.rs
│ │ └── tables.rs
│ └── epub/
│ └── mod.rs
├── model/
│ ├── mod.rs
│ ├── block.rs # 块级:段落、标题、列表、表格、引用
│ ├── inline.rs # 行内:粗斜体、代码、链接
│ ├── table.rs
│ ├── list.rs
│ ├── link.rs
│ ├── style.rs
│ └── asset.rs # 嵌入资源
├── package/
│ ├── mod.rs
│ ├── archive.rs # ZIP 容器
│ ├── limits.rs # 资源上限(防炸弹)
│ ├── path.rs # 包内路径规范化
│ ├── relationships.rs # OPC .rels 关系解析
│ └── xml.rs
├── render/
│ └── mod.rs # GFM 序列化器
└── shared/
├── binary.rs blockstyle.rs chain.rs delta.rs
├── drawingml.rs fields.rs grid.rs header.rs
├── html.rs list.rs mc.rs numbering.rs
├── officeart.rs text.rs uri.rs
这个结构里有几个模块,只要你写过 Office 文档解析器就会会心一笑:
package/——OPC 容器抽象。 docx / pptx / xlsx / odt / epub 本质上都是 ZIP 包,都有 [Content_Types].xml 或 mimetype,都有一套包内相对路径和关系文件。anydoc 把这一层抽出来共用,而不是让每个格式各自 unzip 一遍。relationships.rs 处理的是 OOXML 的 .rels 文件——图片、超链接、外部引用全靠它把 rId3 这种符号引用解析成真实目标。
shared/mc.rs——Markup Compatibility。 这个文件的存在说明 anydoc 处理了 OOXML 的 mc:AlternateContent。这是个坑:Office 在写文件时,同一块内容会写两份,一份是新版渲染器用的 mc:Choice,一份是老版兼容用的 mc:Fallback。天真的解析器会把两份都读出来,导致内容重复。这个 bug 在 python-docx 生态里被踩了无数次。
shared/grid.rs——表格网格。 OOXML 的表格合并单元格不是用 rowspan/colspan 表达的,而是用 gridSpan(横向)+ vMerge(纵向,restart/continue 状态机)。要把它还原成一个逻辑网格,你得自己维护一个二维占位表。这个文件就是干这个的。
shared/numbering.rs + shared/list.rs + shared/chain.rs——列表编号地狱。 Word 的列表编号是三层间接:段落引用 numId → num 引用 abstractNumId → abstractNum 定义 9 级 lvl,每级有自己的 numFmt、lvlText(形如 %1.%2.)、start 和重启规则。还要处理 lvlOverride。chain.rs 大概率就是处理这条引用链的。
shared/fields.rs——域代码。 Word 里的超链接、交叉引用、页码、目录,很多时候不是标签,而是 HYPERLINK "http://..." 这样的域代码字符串,夹在 fldChar begin/separate/end 之间。不解析域代码,链接就全丢了。
shared/drawingml.rs + shared/officeart.rs——两代图形格式。 DrawingML 是 OOXML 的,OfficeArt(Escher)是 97-2003 二进制格式的。anydoc 两代都实现了。
formats/doc/sprm.rs + stsh.rs——Word 97 二进制的核心。 这两个文件是整个项目最硬核的地方。简单说一下 .doc 有多变态:
- 文件是 OLE 复合文档(CFB),本质是一个「文件系统中的文件系统」,有 FAT、有目录项、有 512 字节扇区
- 正文在
WordDocument流里,但文本不是连续存放的——你得先读 FIB(File Information Block),从里面拿到 CLX,解析出 piece table,才知道哪段字符在哪个偏移,以及那段是 CP1252 单字节还是 UTF-16 双字节 - 格式属性不是内联的,而是用 SPRM 编码的差分指令流,存在
1Table/0Table流里的 PLCF 结构中,通过字符位置区间应用到文本上 - 样式继承在 STSH(Style Sheet)里,同样是 SPRM 编码
这套设计的每一步都在告诉你:它是为 1997 年的内存和磁盘优化的,不是为了让你读的。 anydoc 把它啃下来并拿到 87 分,这是真功夫。
3.3 PDF:架构上的那个「例外」
注意管线图里 PDF 是一条独立支线,不进 Document 模型,由 pdf-inspector(同为 Firecrawl 出品的独立 crate)直接产出 Markdown。
这是一处清醒的架构妥协,但也是整个设计里最脆弱的一环。
原因很实际:PDF 根本不是文档格式,它是打印机指令格式。PDF 里没有「段落」这个概念,只有「在坐标 (x, y) 用字体 F 画字符串 S」。要从中还原出段落、标题、表格,本质上是一个版面重建问题——你得做行聚类、列检测、阅读顺序推断,这跟解析 XML 是完全不同的问题域。
把它塞进 Document 模型只会污染模型。所以 anydoc 选择让它走旁路。
代价是:PDF 的输出不享受统一序列化器带来的一致性红利。表格转义、列表编号这些在 GFM serializer 里修的 bug,PDF 路径吃不到。如果你的语料以 PDF 为主,anydoc 的架构优势就被削掉一大半。
而且 README 说得很明白,图片型 PDF(扫描件)直接返回 Unsupported。这不是 bug,这是产品设计:扫描件 OCR 是 Firecrawl 托管 API 的付费功能。
3.4 格式检测:不信扩展名
Format::from_bytes(&bytes); // Some(Format::Docx) 或 None
Format::from_extension("pptm"); // Some(Format::Pptx)
Format::from_path(Path::new("report.odt")); // Some(Format::Odt)
anydoc 默认走 from_bytes,读的是规范指定的内容标记:
| 格式 | 标记 |
|---|---|
文件头 %PDF- | |
| RTF | 起始控制组 {\rtf |
| doc/ppt/xls | OLE 复合文档签名 + 内部流名(WordDocument / PowerPoint Document / Workbook) |
| docx/pptx/xlsx | ZIP 包内 [Content_Types].xml |
| odt/ods/odp/epub | ZIP 包内 mimetype 条目 |
| CSV | 无标记,只能靠扩展名或显式指定 |
注意 OLE 那一行:.doc、.ppt、.xls 文件头是完全一样的(D0 CF 11 E0 A1 B1 1A E1)。光看 magic number 区分不了,必须打开复合文档的目录,看里面有哪个流。这是很多简单 sniff 库会错的地方。
为什么这个设计重要?因为真实世界的文件扩展名满嘴跑火车。用户上传的 report.pdf 可能是个 docx,data.xlsx 可能是个 CSV,邮件附件的扩展名经常在转发过程中被吃掉。内容嗅探是唯一可靠的路。
3.5 错误分类:可操作的错误
这是 anydoc 一个被低估的设计。它的 ConvertError 不是一个笼统的字符串,而是六个语义明确的变体:
| 变体 | 含义 | 你该怎么办 |
|---|---|---|
Unsupported | 未知格式,或无法转换(图片型 PDF) | 转 OCR 管线 |
Malformed | 结构损坏,提不出有意义内容 | 记录,人工介入 |
Encrypted | 加密或密码保护 | 找用户要密码 |
ResourceLimit | 触发安全上限(解压比、嵌套深度、节点数) | 可能是攻击,告警 |
MissingPart | 缺少必需的包内部件 | 文件不完整,重新上传 |
Io | 文件读不出来 | 基础设施问题 |
README 里给的用法示例,一眼就能看出它的设计意图:
match anydoc::to_markdown(path) {
Ok(markdown) => Some(markdown),
// 这两种情况文档本身就出不来内容,记一笔,处理下一个
Err(error @ (ConvertError::Encrypted | ConvertError::Unsupported(_))) => {
unconverted.push((path, error));
None
}
Err(error) => return Err(error),
}
「批量转换时哪些错误可以跳过、哪些必须中断」——这个区分是所有批处理管线的核心决策,anydoc 把它编码进了类型系统。 Node 和 WASM 把变体名放在 error.code 上,Python 给每个变体一个异常子类,这就让上层语言也能做同样的分流。
ResourceLimit 这个变体尤其值得点名。src/package/limits.rs 的存在意味着 anydoc 认真对待了这几类攻击:
- ZIP 炸弹:一个 42KB 的 zip 解压出 4.5PB。限制解压比和绝对大小。
- XML 实体炸弹(billion laughs):递归实体引用指数级膨胀。限制实体展开。
- 深度嵌套:一万层嵌套表格把递归下降解析器的栈干爆。限制嵌套深度。
- 路径穿越(zip slip):包内条目名写成
../../etc/passwd。src/package/path.rs负责规范化。
如果你的服务接受用户上传的文档,这一层不是「加分项」,是上线前置条件。很多 Python 解析库在这方面是裸奔的。
四、代码实战:把 anydoc 塞进真实管线
理论说完,上代码。以下都是可以直接跑的。
4.1 最小可用:三行
// Cargo.toml: anydoc = "*"
fn main() -> Result<(), Box<dyn std::error::Error>> {
let markdown = anydoc::to_markdown("report.docx")?;
println!("{markdown}");
Ok(())
}
从字节流转(HTTP 上传场景):
use anydoc::{to_markdown_bytes, to_document, Format};
// 内容嗅探,格式自动识别
let md = to_markdown_bytes(&bytes, None)?;
// CSV 没有内容标记,必须显式告知
let md = to_markdown_bytes(&bytes, Format::Csv)?;
// 停在文档模型这一层,可以拿到嵌入资源
let doc = to_document(&bytes, None)?;
to_document 这个 API 很关键。它让你在 Markdown 序列化之前拿到结构化模型,可以自己决定怎么渲染。想输出 JSON?想只提取表格?想把图片单独抽出来传对象存储?都从这里下手。
4.2 批量转换:rayon + 错误分流
真实场景很少是单文档。下面这个是我认为的标准写法——并行、错误分类、可观测:
use anydoc::{to_markdown, ConvertError};
use rayon::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Default)]
struct Stats {
ok: AtomicUsize,
need_ocr: AtomicUsize,
encrypted: AtomicUsize,
malformed: AtomicUsize,
suspicious: AtomicUsize,
}
enum Outcome {
Converted(PathBuf, String),
NeedsOcr(PathBuf),
Skipped(PathBuf, String),
}
fn convert_all(paths: &[PathBuf], stats: &Stats) -> Vec<Outcome> {
paths
.par_iter()
.map(|p| match to_markdown(p) {
Ok(md) => {
stats.ok.fetch_add(1, Ordering::Relaxed);
Outcome::Converted(p.clone(), md)
}
// 图片型 PDF / 未知格式 → 降级到 OCR 慢路径
Err(ConvertError::Unsupported(_)) => {
stats.need_ocr.fetch_add(1, Ordering::Relaxed);
Outcome::NeedsOcr(p.clone())
}
Err(ConvertError::Encrypted) => {
stats.encrypted.fetch_add(1, Ordering::Relaxed);
Outcome::Skipped(p.clone(), "encrypted".into())
}
// 触发资源上限:可能是恶意文件,单独告警
Err(ConvertError::ResourceLimit) => {
stats.suspicious.fetch_add(1, Ordering::Relaxed);
tracing::warn!(path = %p.display(), "resource limit tripped");
Outcome::Skipped(p.clone(), "resource-limit".into())
}
Err(e) => {
stats.malformed.fetch_add(1, Ordering::Relaxed);
Outcome::Skipped(p.clone(), format!("{e:?}"))
}
})
.collect()
}
关键点:
par_iter()直接并行。 anydoc 是纯 CPU 计算、无全局状态、无外部进程,天然线程安全,并行扩展是线性的。这一点跟包 LibreOffice 的方案形成鲜明对比——后者你并行 8 个进程,内存直接爆炸。Unsupported不是失败,是路由信号。 它告诉你「这个文件需要走 OCR」。ResourceLimit单独告警。 正常业务文档几乎不可能触发,一旦触发就该查。
在一台 16 核机器上,按 4.4ms 中位数算,理论吞吐大约是每秒 3600 份文档。实际会低一些(IO、大文件长尾),但这个量级意味着你可以在 HTTP 请求生命周期内同步转换,不需要队列。这是一个架构级别的简化。
4.3 Node.js:不阻塞事件循环
import { toMarkdown, toMarkdownBytes, toDocument } from '@firecrawl/anydoc';
import express from 'express';
import multer from 'multer';
const app = express();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
});
app.post('/parse', upload.single('file'), async (req, res) => {
try {
// 转换跑在 libuv 线程池,不阻塞 event loop
const markdown = await toMarkdownBytes(req.file.buffer);
res.json({ ok: true, markdown });
} catch (err) {
// 变体名在 error.code 上
switch (err.code) {
case 'Unsupported':
return res.status(422).json({ ok: false, reason: 'needs_ocr' });
case 'Encrypted':
return res.status(422).json({ ok: false, reason: 'password_protected' });
case 'ResourceLimit':
return res.status(400).json({ ok: false, reason: 'file_rejected' });
default:
return res.status(500).json({ ok: false, reason: 'parse_failed' });
}
}
});
app.listen(3000);
README 里那句「Node.js conversion runs on the libuv thread pool and never blocks the event loop」是有分量的。
对比一下:如果你用 markitdown,你得起一个 Python 子进程,序列化文件过去,等结果回来——一次转换至少多 100ms 的进程开销,还得管进程池。如果你用纯 JS 的 mammoth,它只支持 docx,而且解析是同步的,一个 50MB 的文档能把 event loop 卡死好几百毫秒。
napi 绑定 + libuv 线程池,是这个问题的正确解。
注意一个运维细节: libuv 默认线程池只有 4 个线程。如果你的服务是文档转换密集型,记得调:
UV_THREADPOOL_SIZE=16 node server.js
不调的话,你的 16 核机器只会用 4 个核。
4.4 Python:GIL 真的被释放了
import anydoc
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
def convert(path: Path) -> tuple[Path, str | None, str | None]:
try:
return path, anydoc.to_markdown(str(path)), None
except anydoc.Unsupported:
return path, None, "needs_ocr"
except anydoc.Encrypted:
return path, None, "encrypted"
except anydoc.ConvertError as e:
return path, None, type(e).__name__
except OSError as e:
return path, None, f"io:{e}"
paths = list(Path("corpus").rglob("*"))
# 线程池,不是进程池——因为 anydoc 在转换期间释放 GIL
with ThreadPoolExecutor(max_workers=16) as pool:
for path, md, err in pool.map(convert, paths):
if md:
(Path("out") / f"{path.stem}.md").write_text(md, encoding="utf-8")
else:
print(f"skip {path}: {err}")
ThreadPoolExecutor 而不是 ProcessPoolExecutor——这个区别在 Python 里是天大的事。
因为 anydoc 的转换全程在 Rust 侧,PyO3 绑定里用 py.allow_threads() 释放了 GIL,所以多线程是真并行。你不需要付进程池那份代价:不用 pickle 序列化数据、不用每个进程重新 import 一遍依赖、内存不用翻 N 倍。
这在 Python 3.15 自由线程还没完全普及的过渡期,是一条相当实用的路子:把 CPU 密集的部分推到 Rust 扩展里,然后用普通线程池就能吃满 CPU。
4.5 浏览器 WASM:文件不出本地
这是我觉得最有想象力的一个用法。
import init, { toMarkdownBytes, formatFromBytes } from '@firecrawl/anydoc-wasm';
await init();
document.querySelector('#file').addEventListener('change', async (e) => {
const file = e.target.files[0];
const bytes = new Uint8Array(await file.arrayBuffer());
const fmt = formatFromBytes(bytes);
if (!fmt) {
alert('无法识别的格式');
return;
}
const t0 = performance.now();
const markdown = toMarkdownBytes(bytes);
console.log(`${fmt} 转换耗时 ${(performance.now() - t0).toFixed(1)}ms`);
document.querySelector('#out').textContent = markdown;
});
文件一个字节都没离开浏览器。
想想这个能力值多少钱:法务、医疗、金融场景下,用户要把合同/病历/财报转成 Markdown 喂给 AI,但公司合规不允许原始文件上传到第三方。传统方案要么部署私有化、要么走复杂的脱敏流程。WASM 方案里,你的服务器只收到用户已经确认过的 Markdown 文本,原始文件从来没上过网。
这不是「性能优化」,这是合规边界的重新划分。
4.6 接进 RAG:结构化切块
前面说了「结构就是语义」。有了干净的 Markdown,切块策略立刻可以从「按字符数硬切」升级到「按语义边界切」:
import re
import anydoc
from dataclasses import dataclass, field
@dataclass
class Chunk:
heading_path: list[str] # ["第三章", "3.2 违约责任"]
text: str
kind: str = "prose" # prose | table | code
@property
def contextualized(self) -> str:
"""把标题路径拼进正文,让 embedding 带上上下文"""
prefix = " > ".join(self.heading_path)
return f"[{prefix}]\n{self.text}" if prefix else self.text
HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")
def chunk_markdown(md: str, max_chars: int = 1200) -> list[Chunk]:
chunks: list[Chunk] = []
stack: list[str] = []
buf: list[str] = []
in_table = False
def flush(kind="prose"):
nonlocal buf
text = "\n".join(buf).strip()
if text:
chunks.append(Chunk(heading_path=list(stack), text=text, kind=kind))
buf = []
for line in md.splitlines():
if m := HEADING.match(line):
flush()
level = len(m.group(1))
stack[:] = stack[: level - 1]
while len(stack) < level - 1:
stack.append("")
stack.append(m.group(2).strip())
continue
is_row = bool(TABLE_ROW.match(line))
# 表格整块保留,绝不从中间切开
if is_row and not in_table:
flush()
in_table = True
elif not is_row and in_table:
flush("table")
in_table = False
buf.append(line)
if not in_table and sum(len(x) for x in buf) > max_chars:
flush()
flush("table" if in_table else "prose")
return chunks
md = anydoc.to_markdown("contract.docx")
for c in chunk_markdown(md):
embed(c.contextualized) # 你的 embedding 调用
三个要点:
heading_path注入 embedding。 一个 chunk 写着「乙方应在 30 日内支付」,单看毫无信息量;带上[第三章 > 3.2 违约责任]之后,检索命中率完全不是一个量级。这个信息只有在解析器正确还原了标题层级时才存在——这就是解析质量直接换算成检索质量的地方。- 表格绝不从中间切。 一张表被切成两半,两半都是垃圾。上面的状态机在检测到表格行时先 flush 前面的内容,把整张表作为一个 chunk。
- 超长表格要特殊处理。 上面的代码对超过 max_chars 的表格是「原样放行」,实际生产中你应该给大表做「表头 + N 行」的滑窗切分,每个窗口都重复表头。
4.7 双层架构:快路径 + OCR 慢路径
这是我认为 2026 年文档管线的正确形态:
import anydoc
from pathlib import Path
def parse_document(path: Path) -> tuple[str, str]:
"""返回 (markdown, 使用的路径)"""
try:
# 快路径:确定性解析,4.4ms,成本 ≈ 0
return anydoc.to_markdown(str(path)), "deterministic"
except anydoc.Unsupported:
# 慢路径:扫描件 / 图片型 PDF → VLM OCR
return ocr_pipeline(path), "vlm-ocr"
except anydoc.Encrypted:
raise
except anydoc.ConvertError:
# 结构损坏的文件,有时候渲染成图再 OCR 反而能救回来
return ocr_pipeline(path), "vlm-ocr-fallback"
def ocr_pipeline(path: Path) -> str:
"""把页面渲染成图,走 VLM。
可以是 Unlimited-OCR / DeepSeek-OCR 自建,也可以是托管 API。"""
import fitz # PyMuPDF
doc = fitz.open(path)
pages = []
mat = fitz.Matrix(300 / 72, 300 / 72) # 300 DPI
for page in doc:
pages.append(page.get_pixmap(matrix=mat).tobytes("png"))
return vlm_parse(pages)
为什么这个分层是对的?
因为真实语料的分布是长尾的。在大多数企业场景里,70%~90% 的文档是原生电子格式(Office 文档、文本型 PDF、网页导出),剩下的才是扫描件和拍照件。
- 如果你全走 VLM:90% 的文档在为 10% 的困难情况买单,成本和延迟都是无谓损失,而且原生文档过 VLM 反而更容易出错——模型会「看错」表格边界,会漏掉浅色文字,会把页眉页脚混进正文。
- 如果你全走确定性解析:那 10% 直接挂了。
- 分层之后:90% 的路径 4.4ms、零成本、可复现、可审计;10% 的路径慢但可用。综合成本降一个数量级,P50 延迟降三个数量级。
而且分层还有一个隐藏收益:可审计性。确定性解析的输出是可复现的——同样的字节进去,永远是同样的 Markdown 出来。VLM 不是,temperature 设成 0 也不是(batch 组合、kernel 非确定性都会导致漂移)。在金融、法务这类需要留痕的场景,「同一份合同两次解析结果不一样」是个严重的合规问题。
五、性能与基准:4.4ms 是怎么来的,又有多少水分
5.1 数字本身
| 工具 | 支持格式 | 中位耗时 | 总分 | 完整性 | 结构 | 格式 | 清洁度 |
|---|---|---|---|---|---|---|---|
| anydoc | 14/14 | 4.4ms | 81 | 87 | 79 | 78 | 81 |
| libreoffice | 12/14 | 1129.5ms | 40 | 59 | 42 | 40 | 24 |
| unstructured | 8/14 | 572.9ms | 63 | 76 | 59 | 51 | 63 |
| markitdown | 6/14 | 134.8ms | 65 | 78 | 66 | 60 | 52 |
| pandoc | 5/14 | 102.1ms | 56 | 74 | 57 | 56 | 38 |
| docling | 4/14 | 513.6ms | 57 | 60 | 60 | 57 | 51 |
| mammoth | 1/14 | 52.5ms | 70 | 84 | 71 | 75 | 51 |
速度差距:
- vs LibreOffice:256 倍
- vs unstructured:130 倍
- vs docling:117 倍
- vs markitdown:31 倍
- vs mammoth(只支持 docx 的最快对手):12 倍
5.2 快在哪里——四个来源
第一,没有进程启动。 LibreOffice、pandoc 这类是 CLI 工具,每次转换都要付一次进程创建 + 动态链接 + 初始化的成本。LibreOffice 尤其重,它初始化的是一整套办公套件运行时。README 里明确说了:Python 库和 anydoc 的计时排除了进程启动,CLI 工具包含了——因为那才是它们真实的使用方式。这个测法是公允的。
第二,没有 ML 模型。 docling / unstructured 的时间大头在版面分析模型的前向推理。没有模型,就没有这部分开销,也没有 GB 级权重下载和显存占用。
第三,Rust 的零成本抽象。 这里最关键的是避免不必要的字符串分配。文档解析的本质是「从字节流里切出无数小片段」,Python 的做法是每切一片就 str 一个新对象,一份 docx 转换下来产生几十万个临时字符串对象,GC 压力巨大。Rust 可以用 &str 借用原始 buffer,只在真正需要拥有权时才分配。
第四,一遍流式遍历。 从模块结构看(shared/delta.rs 这种名字暗示了增量/差分处理),anydoc 大概率是单遍 SAX 式遍历 XML,边走边构建 Document,而不是先 DOM 化再遍历。DOM 化一个 50MB 的 document.xml 光内存分配就够喝一壶。
5.3 质量分是怎么评的——以及它的问题
README 对评测方法交代得非常坦诚,这点值得表扬:
一个 LLM 评审(Claude Sonnet 5)盲测对比两个工具的输出,对照 ground truth:该文档前六页由 LibreOffice 渲染成的图片。每份输出按完整性、结构、格式、清洁度打分。每一对都判两次、输出位置对调以消除位置偏差,总共 482 次判定。
以及:
每个工具的
score是它支持的那些格式的分数平均,所以某种格式占比过高的语料不会歪曲结果。这也意味着每一行平均的是不同的格式集合(mammoth 的分数只来自 docx,anydoc 的横跨全部十四种),所以按格式对比的那张表才是公平的比较。
但这套方法有三个必须指出的问题:
问题一:ground truth 是 LibreOffice 渲染的。
这里有个尴尬的循环:LibreOffice 既是被评测对象之一,又是 ground truth 的生产者。如果 LibreOffice 对某个 .doc 的渲染本身就是错的(这在老格式上完全可能),那么「正确」解析出真实内容的工具反而会被判低分,因为它跟错误的参考答案不一致。
不过反过来说,LibreOffice 自己在这个基准里只拿了 40 分,说明这个循环并没有帮到它——它的渲染可能是对的,但它的 Markdown/HTML 导出很差。所以这个偏差主要影响的是「解析正确性」的判定,而不是给 LibreOffice 送分。
问题二:只看前六页。
对于长文档,前六页往往是封面、目录、前言——结构相对简单。真正难的东西(跨页表格、复杂嵌套列表、大量交叉引用)通常在正文深处。这个采样策略会系统性地低估长文档的解析难度。
问题三:LLM 评审的固有偏好。
LLM 评审倾向于喜欢「看起来整洁」的输出。一个工具如果激进地丢弃它不确定的内容,输出会显得干净,清洁度得分高;另一个工具保守地把所有内容都保留下来,可能显得杂乱,但信息更完整。这两种策略在「完整性」和「清洁度」两个维度上是对冲的,而总分是它们的平均——一个激进丢弃的工具可能在总分上不吃亏。
判断这一点的方法是看 anydoc 的分数分布:完整性 87 / 结构 79 / 格式 78 / 清洁度 81。完整性是最高分,说明它没有靠丢内容来刷清洁度。这个分布是健康的。
另外,语料不可再分发,不在仓库里。 这意味着这套基准不可独立复现。你只能自己拿自己的文档跑。README 把 harness 放在 bench/ 下(convert.py、judge.py、metrics.py、render_truth.py、report.py),代码是开的,语料是闭的。
所以我的建议是:把这张表当作「量级参考」而不是「精确排名」。 速度那一列的量级差距(256 倍 / 31 倍)是结构性的,方法学问题动摇不了;质量那一列,你应该拿自己的真实语料重跑一遍。
5.4 工程质量:测试策略
一个解析库的可靠性,看它的测试策略就够了。anydoc 这块相当扎实:
tests/
├── common/mod.rs
├── snapshots.rs # 快照测试:固定语料 → 固定输出
├── robustness.rs # 变异测试:对每个 fixture 做随机字节变异
└── fixture-src/book.md
fuzz/
└── fuzz_targets/
├── csv.rs doc.rs docx.rs epub.rs
├── odf.rs pdf.rs ppt.rs rtf.rs xlsx.rs
九个 cargo-fuzz 目标,每种格式一个。 这是解析器该有的样子——解析器面对的是不可信输入,模糊测试不是可选项。
robustness.rs 的变异测试尤其对路:拿正常文件随机翻转字节,然后要求解析器「要么正确处理,要么优雅报错,绝不 panic、绝不无限循环、绝不 OOM」。Rust 在这里的价值就体现出来了——内存安全是编译期保证的,剩下要防的只有逻辑层面的 panic(数组越界、unwrap、整数溢出)和资源耗尽,而后者由 package/limits.rs 兜着。
写过 C/C++ 文档解析器的人应该都懂,同样的东西用 C 写,光是 .doc 那个 piece table 的偏移计算就能写出十几个可利用的堆溢出。
5.5 一处工程债
README 自己承认了:版本号在三个地方,发版时要一起改。
Cargo.toml(crate)node/package.json(npm 包)python/Cargo.toml(wheel,pyproject.toml从这里读)
这是多语言分发的经典问题。三处手动同步,迟早有一次会漏。合理的做法是加一个 cargo-release 的 pre-release hook,或者干脆写个小脚本:
#!/usr/bin/env bash
# bump.sh <version>
set -euo pipefail
V="$1"
sed -i.bak "0,/^version = /s//version = \"$V\"/" Cargo.toml
sed -i.bak "0,/^version = /s//version = \"$V\"/" python/Cargo.toml
node -e "
const fs=require('fs'), p='node/package.json';
const j=JSON.parse(fs.readFileSync(p));
j.version='$V';
fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
"
find . -name '*.bak' -delete
git add -A && git commit -m "chore: release v$V" && git tag "v$V"
echo "tagged v$V"
小事,但这种小事在多语言仓库里会一直咬人。
六、局限性:它做不到什么
夸完了,说说边界。这一节比前面几节都重要,因为选型的关键从来不是「它能干什么」,而是「它不能干什么,而我恰好需要」。
6.1 扫描件:彻底不行
图片型 PDF 直接 Unsupported。没有内置 OCR,也不会有——这是 Firecrawl 的商业分界线。
如果你的语料主要是扫描件、拍照件、传真件,anydoc 对你的价值接近于零。你需要的是 Unlimited-OCR / DeepSeek-OCR 这类 VLM 方案,或者 PaddleOCR 这类传统 OCR 管线。
6.2 Markdown 是有损的——尤其是表格
这是格式本身的天花板,不是 anydoc 的问题,但你必须知道。
GFM 表格语法不支持合并单元格。而真实的 Office 文档里,合并单元格随处可见——尤其是中文文档,多级表头几乎是标配:
┌─────────┬───────────────────┬───────────────────┐
│ │ 2025 年 │ 2026 年 │
│ 项目 ├─────────┬─────────┼─────────┬─────────┤
│ │ 上半年 │ 下半年 │ 上半年 │ 下半年 │
├─────────┼─────────┼─────────┼─────────┼─────────┤
│ 营收 │ 1,200 │ 1,450 │ 1,380 │ 1,600 │
└─────────┴───────────────────┴───────────────────┘
这张表在 GFM 里没有任何办法无损表达。anydoc 内部的 shared/grid.rs 肯定是正确还原了逻辑网格的(否则拿不到那个分数),但序列化到 GFM 时必然降级——通常是把合并单元格的值复制到每个被覆盖的格子,或者留空。
这意味着:如果你的下游需要精确的表格结构,不要用 to_markdown,用 to_document,自己从 Document 模型里拿表格,序列化成 HTML <table>(支持 rowspan/colspan)或者 JSON。 这也是 to_document 这个 API 存在的最大意义。
同类的有损项还有:公式(OMML/MathML → 无处安放)、图表(chart part → 只剩标题)、批注和修订(trackchanges → 通常直接丢)、文本框和艺术字的定位关系、页眉页脚(shared/header.rs 说明处理了,但 Markdown 里没有对应概念)。
6.3 中文编码:需要实测
.doc 和 .rtf 都是有 codepage 概念的格式。中文 Word 97 文档里的文本,可能是 CP936(GBK)单字节存储,也可能是 UTF-16。RTF 里则是 \ansicpg936 加上 \'d6\'d0 这样的十六进制转义。
anydoc 的 shared/text.rs 应该处理了编码转换,formats/rtf/lexer.rs 也必然要处理 \'hh 转义。但基准语料的构成没有公开,我们不知道里面有多少中文文档。
如果你的场景是中文,上生产前务必自己跑一批测试,重点测:
- 中文
.doc(Word 97-2003 简体中文版生成的) - 中文
.rtf(尤其是从 WPS 导出的) - GB18030 四字节字符和生僻字
- 繁体 / Big5 编码的老文档
- 中英文混排时的空格处理
我建议的验证脚本:
import anydoc
from pathlib import Path
import unicodedata
def audit(path: Path):
md = anydoc.to_markdown(str(path))
total = len(md)
# 替换字符 = 编码解码失败的信号
replacement = md.count("\ufffd")
cjk = sum(1 for c in md if "\u4e00" <= c <= "\u9fff")
# 控制字符残留 = 解析器漏处理的信号
ctrl = sum(1 for c in md if unicodedata.category(c) == "Cc" and c not in "\n\t")
print(f"{path.name:40s} len={total:7d} cjk={cjk:6d} "
f"replacement={replacement:4d} ctrl={ctrl:4d}")
if replacement > 0:
print(f" ⚠ 存在 {replacement} 个替换字符,编码可能有问题")
if ctrl > 0:
print(f" ⚠ 存在 {ctrl} 个控制字符残留")
for p in Path("cn-corpus").rglob("*"):
if p.is_file():
audit(p)
\ufffd(U+FFFD REPLACEMENT CHARACTER)的数量是最直接的编码健康度指标。
6.4 PDF 支线的短板
前面说过了,再强调一次:PDF 走 pdf-inspector 旁路,不进统一模型。这意味着:
- PDF 的输出一致性不如其他格式
- 复杂版面(多栏、图文混排、脚注密集)的阅读顺序还原,纯几何方法一定不如版面分析模型
- 你不能对 PDF 用
to_document拿结构化模型(至少不能拿到跟其他格式一致的模型)
如果你的语料以复杂版面 PDF 为主,docling 那类带版面模型的工具在质量上仍有优势,代价是慢 100 倍。
6.5 项目太新
2026-08-03 创建,写这篇文章时刚满四天。1.2 万 star 说明关注度极高,但也意味着:
- 生产验证时间短
- API 可能还会变(虽然核心 API 看起来已经很收敛了)
- 长尾格式 bug 还没被充分暴露
建议:锁死版本号,自己建一套回归语料,升级前跑一遍 diff。 对于解析器这类「输出会直接影响下游一切」的组件,这个纪律是必须的。
七、选型决策树
把上面所有讨论压缩成一个可执行的决策流程:
你的语料里扫描件占比 > 50%?
├─ 是 → 走 VLM OCR 为主(Unlimited-OCR / DeepSeek-OCR),
│ anydoc 只做原生格式的快路径分流
└─ 否 ↓
你需要精确的表格结构(合并单元格、多级表头)?
├─ 是 → anydoc + to_document,自己序列化成 HTML/JSON,
│ 不要用 to_markdown
└─ 否 ↓
你的语料以复杂版面 PDF 为主?
├─ 是 → anydoc 优势不明显,考虑 docling 或托管 Parse API
└─ 否 ↓
你需要在 HTTP 请求里同步转换 / 需要浏览器端转换 /
需要每天处理十万级文档?
├─ 是 → anydoc 是目前唯一合理选择(4.4ms + WASM + 线性并行)
└─ 否 ↓
你已经在用 markitdown 且只处理 docx?
├─ 是 → 收益主要是速度和格式覆盖,可以观望
└─ 否 → 上 anydoc,架构上是净收益
再给一条实操建议:不要 all-in 替换,先并行跑。
import anydoc
from markitdown import MarkItDown
import difflib
md_tool = MarkItDown()
def compare(path):
try:
a = anydoc.to_markdown(str(path))
except Exception as e:
a = f"<<ERROR {type(e).__name__}>>"
try:
b = md_tool.convert(str(path)).text_content
except Exception as e:
b = f"<<ERROR {type(e).__name__}>>"
ratio = difflib.SequenceMatcher(None, a, b).quick_ratio()
# 长度差异是最粗但最有效的信号:谁丢内容了?
print(f"{path.name:40s} anydoc={len(a):7d} markitdown={len(b):7d} "
f"sim={ratio:.2f}")
if ratio < 0.6:
# 差异大的样本,人工看
Path(f"diff/{path.stem}.a.md").write_text(a)
Path(f"diff/{path.stem}.b.md").write_text(b)
跑一周,把差异大的样本人工过一遍。这一周的成本,远小于「换了解析器结果 RAG 效果掉了但不知道为什么」的成本。
八、总结:确定性回归,不是复古,是分层
anydoc 这个项目最有价值的地方,其实不是那 4.4 毫秒,也不是那 14 种格式。
是它在一个所有人都往 AI 上堆的时刻,提出了一个非常朴素的反问:这个问题真的需要智能吗?
.docx 里的标题层级,是一个明确的 w:pStyle 值。表格的合并信息,是明确的 gridSpan 和 vMerge 状态。列表的编号规则,是明确的 numFmt 和 lvlText 模板。这些不是需要"理解"的东西,这些是需要"读取"的东西。 用概率模型去读确定性数据,不只是浪费,而且是主动引入了本来不存在的错误率。
这个判断可以推广到 AI 工程的很多角落:
- 结构化数据的抽取,先问问有没有 schema
- 格式转换,先问问有没有规范
- 分类任务,先问问有没有确定的规则能覆盖 80%
- 任何时候,先把确定性的部分榨干,再让模型处理剩下的不确定性
这不是反 AI,恰恰相反——这是让 AI 用在刀刃上。你省下的那 90% 的调用额度和延迟预算,可以拿去做真正需要智能的事:让模型处理那 10% 的扫描件,或者把省下的延迟花在更好的 reranking 上。
2026 年的文档管线,正确的形态是双层的:
┌─────────────────────┐
文档字节 ───► │ 格式检测(内容嗅探) │
└──────────┬──────────┘
│
┌──────────────┴──────────────┐
│ │
┌────────▼────────┐ ┌─────────▼─────────┐
│ 确定性快路径 │ │ VLM 慢路径 │
│ anydoc │ │ OCR / 版面模型 │
│ 4.4ms · ¥0 │ │ 数秒 · 按 token │
│ 可复现·可审计 │ │ 概率性·不可复现 │
│ 覆盖 ~90% │ │ 覆盖剩余 ~10% │
└────────┬────────┘ └─────────┬─────────┘
│ │
└──────────────┬──────────────┘
│
┌──────────▼──────────┐
│ 统一 Markdown 输出 │
│ → 结构化切块 → RAG │
└─────────────────────┘
anydoc 把左边那条路径做到了目前的工程上限。右边那条路径,交给 VLM。
至于这个项目本身能走多远,我的判断是:它的天花板取决于 Firecrawl 愿意开源多少。目前的开源/商业分界画在 OCR 上,这条线是合理的。如果未来这条线往上挪(比如把表格结构还原也做成付费功能),社区会 fork;如果保持现状,anydoc 有机会成为文档解析领域的 libxml2——一个所有人都在用、但没人需要关心的基础设施。
对基础库来说,「没人需要关心」就是最高的赞美。
附:五分钟上手清单
# 1. 命令行试一下,不用装任何东西
npx @firecrawl/anydoc 你的文档.docx | head -50
# 2. 装到项目里
cargo add anydoc # Rust
npm install @firecrawl/anydoc # Node
pip install firecrawl-anydoc # Python
npm install @firecrawl/anydoc-wasm # 浏览器
# 3. 给你的 AI Agent 装上读文档的能力
npx skills add firecrawl/anydoc
# 4. Node 服务记得调线程池
export UV_THREADPOOL_SIZE=16
# 5. 拿自己的语料跑一遍质量审计(编码 + 控制字符)
python audit.py cn-corpus/
项目地址: https://github.com/firecrawl/anydoc · MIT · Rust
基准 harness: 仓库 bench/ 目录(语料不可再分发,需自备)