一个 specification-driven 的纯 Python Word 97–2003.doc→.docx转换器不依赖 Word、LibreOffice、COM、Java——只用标准库。一、为什么要造这个轮子如果你曾经需要在服务器端批量把.doc转成.docx大概率经历过这样的绝望方案 A调win32com驱动本机 Word——需要 Windows 正版 Office服务器部署噩梦。方案 Blibreoffice --headless --convert-to docx——需要装 LibreOffice启动慢、并发差、偶发崩溃。方案 Cantiword/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:bufbytearray()sectorstartfor_inrange(max_sectors):# ← 硬上限ifsectorENDOFCHAIN:breakifsector0orsectorlen(fat):raiseCorruptCFB(finvalid sector{sector})bufread_sector(sector)sectorfat[sector]returnbytes(buf)四、第二阶段Word Binary 格式解析——真正的硬骨头打开WordDocument流迎面而来的是FIBFile Information Block—— 一个巨大的、版本交叠的结构体。从 Word 97 到 Word 2003FIB 不断追加字段形成了一种地质层式的布局FibBase (32 bytes) ├── wIdent (0xA5EC) ├── nFib ├── ... FibRgW97 FibRgLw97 FibRgFcLcb97 ← Word 97 引入的偏移/长度对 FibRgFcLcb2000 ← Word 2000 追加 FibRgFcLcb2002 ← Word 2002 追加 FibRgFcLcb2003 ← Word 2003 追加核心策略Specification-Drivendoc2docx的解析器不是通过逆向工程或试错写出来的。每一个结构体的字段偏移、位域含义、枚举值都直接对照微软公开的规范文档[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 可能是 ANSICP1252也可能是 UnicodeUTF-16LE而且物理顺序和逻辑顺序不一定一致# 伪代码Piece Table 遍历forpieceinpiece_table:cp_start,cp_endpiece.cp_range fcpiece.fc is_compressed(fc0x40000000)!0# fCompressed 位real_fcfc0x3FFFFFFFifis_compressed:real_fc//2# ANSI: 1 byte/charrawstream[real_fc:real_fc(cp_end-cp_start)]textraw.decode(cp1252,errorsreplace)else:rawstream[real_fc:real_fc2*(cp_end-cp_start)]textraw.decode(utf-16-le,errorsreplace)五、第三阶段中间表示IR与语义映射解析完二进制结构后并不直接生成 XML。中间引入了一层文档对象树作为 Word Binary 语义和 WordprocessingML 语义之间的桥梁。这一步的核心挑战是语义对齐Word Binary 的段落属性是一个扁平的grpprl列表WordprocessingML 的w:pPr是一个有 schema 约束的 XML 元素。Word Binary 的脚注/尾注通过PlcfAtn 特殊字符\x02定位WordprocessingML 用w:footnoteReferencefootnotes.xmlpart。Word Binary 的列表LST/LFO/LVLF是一套独立的编号引擎WordprocessingML 用numbering.xml中的w:abstractNumw:num。Word Binary IR (Python objects) WordprocessingML ───────────── ────────────────── ───────────────── grpprl [sprmPJc1] ──▶ Paragraph(alignCENTER) ──▶ w:pPrw:jc w:valcenter/ PlcfAtn \x02 ──▶ Footnote(id1, runs[...])──▶ footnotes.xml w:footnoteReference/ LST/LFO/LVLF ──▶ ListDef(levels[...]) ──▶ numbering.xml w:abstractNum诊断报告让没转成的部分可见doc2docx的一个设计原则是不支持的内容绝不静默丢弃而是显式报告。每次转换都会生成一份结构化诊断报告可导出为 JSON列出哪些特性被完整转换哪些特性被近似处理以及近似的方式哪些特性被跳过以及原因fromdoc2docximportconvert resultconvert(input.doc,output.docx)reportresult.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本质上是一个OPCOpen Packaging Conventions包——一个遵循特定约定的 ZIP 文件。doc2docx的写入器有几个刻意的设计1. 仅标准库运行时零第三方依赖。ZIP 写入用zipfileXML 生成用xml.etree.ElementTree或手工字符串拼接以获得更精确的控制。这意味着在任何有 Python 3.11 的环境——Alpine 容器、AWS Lambda、离线服务器——都能直接运行。2. 确定性输出同样的输入.doc无论何时何地运行产出的.docx字节完全一致ZIP 条目的时间戳固定XML 属性顺序固定不引入随机 ID这让diff和回归测试变得可行。3. 原子写入# 写入流程简化withopen(source,rb)asf:# 源文件只读...tmpdest.tmpwrite_opc_package(tmp,document)validate_opc(tmp)# 写入后验证os.replace(tmp,dest)# 原子替换先写临时文件验证通过后再os.replace原子替换。如果转换中途崩溃目标路径上不会出现半个损坏的.docx。同时源文件始终以只读模式打开转换器绝不会覆盖输入文件。七、图片恢复从 Data 流到word/media/Word Binary 中的图片存储在Data流或WordDocument流中通过FBSEFile BLIP Store Entry索引。doc2docx支持恢复以下格式格式处理方式PNG / JPEG直接提取原样嵌入BMP / DIB提取可选转 PNGTIFF直接嵌入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 APIfromdoc2docximportconvert resultconvert(input.doc,output.docx)print(result.report.to_dict())三行代码没有 COM 初始化没有子进程没有临时目录清理。九、当前边界doc2docx已经能处理一大批真实文档但它还不是Word 97–2003 全部特性的完整实现。下面这些是尚未覆盖、会在后续版本逐步补全的部分——转换时它们不会被静默吞掉而是逐项写进第七节那份诊断报告让你清楚知道当前覆盖到了哪里⏳密码保护文档当前直接拒绝打开解密流程待实现。⏳嵌入的 OLE 对象Excel 表格、Visio 图等内嵌对象尚未解析。⏳Macintosh PICT 格式图片。⏳高级绘图效果渐变、阴影、3D 等。⏳非矩形文字环绕多边形。⏳若干边角情形罕见的列表续接、条件表格样式、不常见的次要 story以及一部分专用字段。十、开发工作流# 运行测试纯标准库无 pytest 依赖PYTHONPATHsrc 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