GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
一个 specification-driven 的纯 Python Word 97–2003
.doc→.docx转换器,不依赖 Word、LibreOffice、COM、Java——只用标准库。
一、为什么要造这个轮子?
如果你曾经需要在服务器端批量把.doc转成.docx,大概率经历过这样的绝望:
- 方案 A:调
win32com驱动本机 Word——需要 Windows + 正版 Office,服务器部署噩梦。 - 方案 B:
libreoffice --headless --convert-to docx——需要装 LibreOffice,启动慢、并发差、偶发崩溃。 - 方案 C:
antiword/catdoc/unoconv——要么只提取纯文本,要么本质还是套壳 LibreOffice。
这些方案的共同问题是:它们把"格式转换"这件事外包给了一个庞大的外部进程。你无法控制转换行为,无法拿到结构化的诊断信息,无法在受限环境(容器、Serverless、离线机器)中运行。
doc2docx的目标很简单:
在 Python 进程内,依据微软公开的二进制格式规范,把
.doc的每一个字节翻译成.docx的 WordprocessingML XML——不多不少,不黑箱。
二、整体架构:一条从字节到 XML 的流水线
┌─────────────────────────────────────────────────────────────────┐ │ doc2docx Pipeline │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────┐ │ │ │ CFB/OLE │──▶│ Word Binary │──▶│ Intermediate│──▶│ OPC │ │ │ │ Reader │ │ Parser │ │ Model (IR) │ │Writer│ │ │ └──────────┘ └──────────────┘ └──────────────┘ └──────┘ │ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ │ │ 结构化存储流 FIB / CLX / STSH 统一文档对象树 确定性 ZIP │ │ 提取 / SEP / FKP ... 与诊断报告 原子写入 │ └─────────────────────────────────────────────────────────────────┘整条流水线分为四个阶段,下面逐一拆解。
三、第一阶段:CFB/OLE 容器解析
.doc文件的外壳是Compound File Binary Format (CFB),也就是 OLE 结构化存储。你可以把它理解为一个"文件系统装在单个文件里":
Root Entry ├── WordDocument ← 主文档流(FIB、正文、格式属性) ├── 1Table / 0Table ← 表格流(样式表、字段、书签、批注……) ├── Data ← 嵌入数据(图片、OLE 对象) ├── ObjectPool ← OLE 对象池 └── ...设计决策:确定性、有界解析器
CFB 规范([MS-CFB])本身不复杂,但现实中的.doc文件可能损坏、截断、甚至恶意构造。因此解析器遵循两条铁律:
有界(Bounded):所有读取操作都带有显式的长度上限,绝不允许"读到 EOF 为止"这种开放式读取。扇区链表(FAT / MiniFAT)的遍历设置了最大迭代次数,防止循环引用导致死循环。
确定性(Deterministic):同样的输入字节,永远产生同样的输出。不依赖字典遍历顺序、不依赖文件系统时间戳。这对回归测试至关重要。
# 伪代码:有界 FAT 链遍历defread_chain(fat:list[int],start:int,max_sectors:int)->bytes:buf=bytearray()sector=startfor_inrange(max_sectors):# ← 硬上限ifsector==ENDOFCHAIN:breakifsector<0orsector>=len(fat):raiseCorruptCFB(f"invalid sector{sector}")buf+=read_sector(sector)sector=fat[sector]returnbytes(buf)四、第二阶段:Word Binary 格式解析——真正的硬骨头
打开WordDocument流,迎面而来的是FIB(File Information Block)—— 一个巨大的、版本交叠的结构体。从 Word 97 到 Word 2003,FIB 不断追加字段,形成了一种"地质层"式的布局:
FibBase (32 bytes) ├── wIdent (0xA5EC) ├── nFib ├── ... FibRgW97 FibRgLw97 FibRgFcLcb97 ← Word 97 引入的偏移/长度对 FibRgFcLcb2000 ← Word 2000 追加 FibRgFcLcb2002 ← Word 2002 追加 FibRgFcLcb2003 ← Word 2003 追加核心策略:Specification-Driven
doc2docx的解析器不是通过逆向工程或"试错"写出来的。每一个结构体的字段偏移、位域含义、枚举值,都直接对照微软公开的规范文档:
- [MS-DOC]:Word (.doc) Binary File Format
- [MS-ODRAW]:Office Drawing Binary Format
- [MS-OSHARED]:Office Shared Data
- [MS-CFB]:Compound File Binary Format
这意味着:当遇到一个不认识的字段时,代码里会留下明确的# [MS-DOC] §2.5.x注释,而不是一个# TODO: figure out what this is。
关键子结构
| 结构 | 作用 | 难点 |
|---|---|---|
| CLX(Complex Part) | 描述正文的 Piece Table,将逻辑文本映射到物理字节 | Unicode/ANSI 混合编码,piece 可能乱序 |
| STSH(Stylesheet) | 样式表:段落样式、字符样式、样式继承链 | 多层 basedOn 继承,需要拓扑排序 |
| FKP(Formatted disK Page) | 字符/段落属性(CHP / PAP)的压缩存储 | 位域打包,grpprl 变长属性组 |
| SEP(Section Properties) | 节属性:页面大小、页边距、页眉页脚、行号 | 与 FIB 中的 offset 交叉引用 |
| PlcfBkm / PlcfAtn | 书签 / 批注的位置表 | CP(字符位置)到 Piece Table 的二次映射 |
Piece Table 的解析是整个项目中最精巧也最容易出错的部分。一段.doc的正文可能由十几个 piece 拼成,每个 piece 可能是 ANSI(CP1252)也可能是 Unicode(UTF-16LE),而且物理顺序和逻辑顺序不一定一致:
# 伪代码:Piece Table 遍历forpieceinpiece_table:cp_start,cp_end=piece.cp_range fc=piece.fc is_compressed=(fc&0x40000000)!=0# fCompressed 位real_fc=fc&0x3FFFFFFFifis_compressed:real_fc//=2# ANSI: 1 byte/charraw=stream[real_fc:real_fc+(cp_end-cp_start)]text=raw.decode("cp1252",errors="replace")else:raw=stream[real_fc:real_fc+2*(cp_end-cp_start)]text=raw.decode("utf-16-le",errors="replace")五、第三阶段:中间表示(IR)与语义映射
解析完二进制结构后,并不直接生成 XML。中间引入了一层文档对象树,作为 Word Binary 语义和 WordprocessingML 语义之间的桥梁。
这一步的核心挑战是语义对齐:
- Word Binary 的"段落属性"是一个扁平的
grpprl列表;WordprocessingML 的<w:pPr>是一个有 schema 约束的 XML 元素。 - Word Binary 的脚注/尾注通过
PlcfAtn+ 特殊字符(\x02)定位;WordprocessingML 用<w:footnoteReference>+footnotes.xmlpart。 - Word Binary 的列表(
LST/LFO/LVLF)是一套独立的编号引擎;WordprocessingML 用numbering.xml中的<w:abstractNum>+<w:num>。
Word Binary IR (Python objects) WordprocessingML ───────────── ────────────────── ───────────────── grpprl [sprmPJc=1] ──▶ Paragraph(align=CENTER) ──▶ <w:pPr><w:jc w:val="center"/> PlcfAtn + \x02 ──▶ Footnote(id=1, runs=[...])──▶ footnotes.xml + <w:footnoteReference/> LST/LFO/LVLF ──▶ ListDef(levels=[...]) ──▶ numbering.xml <w:abstractNum>诊断报告:让"没转成的部分"可见
doc2docx的一个设计原则是:不支持的内容绝不静默丢弃,而是显式报告。
每次转换都会生成一份结构化诊断报告(可导出为 JSON),列出:
- 哪些特性被完整转换
- 哪些特性被近似处理(以及近似的方式)
- 哪些特性被跳过(以及原因)
fromdoc2docximportconvert result=convert("input.doc","output.docx")report=result.report.to_dict()# {# "converted": {"paragraphs": 142, "tables": 3, "images": 7, ...},# "approximated": [# {"type": "field", "field": "ADVANCE", "note": "kept as cached text"}# ],# "unsupported": [# {"type": "ole_object", "clsid": "...", "note": "embedded OLE not supported"}# ]# }这比"看起来转完了,打开发现少了一半内容"要好得多。
六、第四阶段:确定性 OPC 包写入
.docx本质上是一个OPC(Open Packaging Conventions)包——一个遵循特定约定的 ZIP 文件。doc2docx的写入器有几个刻意的设计:
1. 仅标准库
运行时零第三方依赖。ZIP 写入用zipfile,XML 生成用xml.etree.ElementTree(或手工字符串拼接以获得更精确的控制)。这意味着在任何有 Python 3.11+ 的环境——Alpine 容器、AWS Lambda、离线服务器——都能直接运行。
2. 确定性输出
同样的输入.doc,无论何时何地运行,产出的.docx字节完全一致:
- ZIP 条目的时间戳固定
- XML 属性顺序固定
- 不引入随机 ID
这让diff和回归测试变得可行。
3. 原子写入
# 写入流程(简化)withopen(source,"rb")asf:# 源文件只读...tmp=dest+".tmp"write_opc_package(tmp,document)validate_opc(tmp)# 写入后验证os.replace(tmp,dest)# 原子替换先写临时文件,验证通过后再os.replace原子替换。如果转换中途崩溃,目标路径上不会出现半个损坏的.docx。同时,源文件始终以只读模式打开,转换器绝不会覆盖输入文件。
七、图片恢复:从 Data 流到word/media/
Word Binary 中的图片存储在Data流或WordDocument流中,通过FBSE(File BLIP Store Entry)索引。doc2docx支持恢复以下格式:
| 格式 | 处理方式 |
|---|---|
| PNG / JPEG | 直接提取,原样嵌入 |
| BMP / DIB | 提取,可选转 PNG |
| TIFF | 直接嵌入(Word 2007+ 支持) |
| EMF / WMF | 直接嵌入为矢量图 |
图片可能出现在主文档、页眉、页脚三个 story 中,需要分别处理其定位关系(inline vs. floating)。浮动图片还涉及MS-ODRAW中的 OfficeArt 记录解析——锚点、偏移、环绕方式——这是另一个深坑。
八、CLI 与 API 设计
命令行
# 最简用法:在 input.doc 旁边生成 input.docxdoc2docx input.doc# 指定输出路径 + 保存诊断报告doc2docx input.doc-ooutput.docx--reportreport.json# 只检查不转换:查看文件内部结构doc2docx inspect input.doc--jsoninspect子命令在调试时非常有用——它 dump 出 FIB、Piece Table、样式表等内部结构,不需要真正执行转换。
Python API
fromdoc2docximportconvert result=convert("input.doc","output.docx")print(result.report.to_dict())三行代码,没有 COM 初始化,没有子进程,没有临时目录清理。
九、当前边界
doc2docx已经能处理一大批真实文档,但它还不是Word 97–2003 全部特性的完整实现。下面这些是尚未覆盖、会在后续版本逐步补全的部分——转换时它们不会被静默吞掉,而是逐项写进第七节那份诊断报告,让你清楚知道当前覆盖到了哪里:
- ⏳密码保护文档:当前直接拒绝打开,解密流程待实现。
- ⏳嵌入的 OLE 对象:Excel 表格、Visio 图等内嵌对象尚未解析。
- ⏳Macintosh PICT 格式图片。
- ⏳高级绘图效果:渐变、阴影、3D 等。
- ⏳非矩形文字环绕多边形。
- ⏳若干边角情形:罕见的列表续接、条件表格样式、不常见的次要 story,以及一部分专用字段。
十、开发工作流
# 运行测试(纯标准库,无 pytest 依赖)PYTHONPATH=src python-munittest discover-v# 构建分发包python-mbuild回归测试中可以使用 LibreOffice 来生成测试用的.doc文件或渲染结果用于视觉对比,但转换器本身绝不调用 LibreOffice。这条边界在架构上是硬隔离的。
十一、写在最后
初版是 GPT 5.6 连写了大概十个小时弄出来的。AI 把规范翻成代码确实快,但哪个坑得自己踩、哪一行该停下来拿真实文件验一遍,终究还是人拿主意——这点体会,可能比代码本身更值得记一笔。doc2docx不是一个"万能转换器"。就是一件事:对着 [MS-DOC] 那几千页规范,把 Word 97 到 2003 的二进制格式一段一段翻译成 XML。没有捷径,也谈不上什么巧妙算法,大部分时间是在跟位域、字节偏移、还有一份写得并不怎么友好的规范较劲。doc2docx还未触达 Word 97 到 2003 的每一个边角特性。但它做到了:
- 透明:每一行解析代码都能追溯到规范条款。
- 诚实:不支持的就说"不支持",不静默吞掉。
- 自包含:
pip install msdoc2docx,完事。没有 COM,没有子进程,没有"请先安装 LibreOffice"。 - 可测试:确定性输出 + 结构化报告,让自动化回归测试成为可能。
如果你有一个需要批量处理.doc的 Python 服务,或者你只是受够了在 Docker 里装 LibreOffice,不妨试试:
pipinstallmsdoc2docx doc2docx your_legacy_doc.doc然后打开那份report.json,看看你的文档里到底藏了些什么。
PyPi地址:pypi.org/project/msdoc2docx · 需要 Python 3.11+
项目地址:https://github.com/HuiTurn/doc2docx
