一个 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 的流水线
objectivec
┌─────────────────────────────────────────────────────────────────┐
│ 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 结构化存储。你可以把它理解为一个"文件系统装在单个文件里":
sql
Root Entry
├── WordDocument ← 主文档流(FIB、正文、格式属性)
├── 1Table / 0Table ← 表格流(样式表、字段、书签、批注......)
├── Data ← 嵌入数据(图片、OLE 对象)
├── ObjectPool ← OLE 对象池
└── ...
设计决策:确定性、有界解析器
CFB 规范(MS-CFB)本身不复杂,但现实中的 .doc 文件可能损坏、截断、甚至恶意构造。因此解析器遵循两条铁律:
-
有界(Bounded):所有读取操作都带有显式的长度上限,绝不允许"读到 EOF 为止"这种开放式读取。扇区链表(FAT / MiniFAT)的遍历设置了最大迭代次数,防止循环引用导致死循环。
-
确定性(Deterministic):同样的输入字节,永远产生同样的输出。不依赖字典遍历顺序、不依赖文件系统时间戳。这对回归测试至关重要。
python
# 伪代码:有界 FAT 链遍历
def read_chain(fat: list[int], start: int, max_sectors: int) -> bytes:
buf = bytearray()
sector = start
for _ in range(max_sectors): # ← 硬上限
if sector == ENDOFCHAIN:
break
if sector < 0 or sector >= len(fat):
raise CorruptCFB(f"invalid sector {sector}")
buf += read_sector(sector)
sector = fat[sector]
return bytes(buf)
四、第二阶段:Word Binary 格式解析------真正的硬骨头
打开 WordDocument 流,迎面而来的是 FIB(File Information Block)------ 一个巨大的、版本交叠的结构体。从 Word 97 到 Word 2003,FIB 不断追加字段,形成了一种"地质层"式的布局:
yaml
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),而且物理顺序和逻辑顺序不一定一致:
python
# 伪代码:Piece Table 遍历
for piece in piece_table:
cp_start, cp_end = piece.cp_range
fc = piece.fc
is_compressed = (fc & 0x40000000) != 0 # fCompressed 位
real_fc = fc & 0x3FFFFFFF
if is_compressed:
real_fc //= 2 # ANSI: 1 byte/char
raw = 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>。
ini
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),列出:
- 哪些特性被完整转换
- 哪些特性被近似处理(以及近似的方式)
- 哪些特性被跳过(以及原因)
python
from doc2docx import convert
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. 原子写入
python
# 写入流程(简化)
with open(source, "rb") as f: # 源文件只读
...
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 设计
命令行
bash
# 最简用法:在 input.doc 旁边生成 input.docx
doc2docx input.doc
# 指定输出路径 + 保存诊断报告
doc2docx input.doc -o output.docx --report report.json
# 只检查不转换:查看文件内部结构
doc2docx inspect input.doc --json
inspect 子命令在调试时非常有用------它 dump 出 FIB、Piece Table、样式表等内部结构,不需要真正执行转换。
Python API
python
from doc2docx import convert
result = convert("input.doc", "output.docx")
print(result.report.to_dict())
三行代码,没有 COM 初始化,没有子进程,没有临时目录清理。
九、当前边界
doc2docx 已经能处理一大批真实文档,但它还不是 Word 97--2003 全部特性的完整实现。下面这些是尚未覆盖、会在后续版本逐步补全的部分------转换时它们不会被静默吞掉,而是逐项写进第七节那份诊断报告,让你清楚知道当前覆盖到了哪里:
- ⏳ 密码保护文档:当前直接拒绝打开,解密流程待实现。
- ⏳ 嵌入的 OLE 对象:Excel 表格、Visio 图等内嵌对象尚未解析。
- ⏳ Macintosh PICT 格式图片。
- ⏳ 高级绘图效果:渐变、阴影、3D 等。
- ⏳ 非矩形文字环绕多边形。
- ⏳ 若干边角情形:罕见的列表续接、条件表格样式、不常见的次要 story,以及一部分专用字段。
十、开发工作流
bash
# 运行测试(纯标准库,无 pytest 依赖)
PYTHONPATH=src python -m unittest discover -v
# 构建分发包
python -m build
回归测试中可以使用 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,不妨试试:
bash
pip install msdoc2docx
doc2docx your_legacy_doc.doc
然后打开那份 report.json,看看你的文档里到底藏了些什么。
PyPi地址:pypi.org/project/msd... · 需要 Python 3.11+