乐于分享
好东西不私藏

自动化处理 Word 文档的踩坑实记

自动化处理 Word 文档的踩坑实记

最近在研究一批历史 Word 文档的自动化迁移。我原来以为这件事很直接:把 .doc 转成 .docx,后面的脚本统一处理新格式就行。可是真正开始写脚本查文件时,我发现问题并不简单。

先搞懂两者的差别

.doc 通常指 Word 97–2003 二进制文档,内容分布在 OLE/CFB 的多个流里。 .docx 是 Office Open XML 文档,里面有 XML、关系文件和媒体资源,正常未加密时通常使用 ZIP 容器。微软对 Open XML 的说明也是按这种包、部件和关系来组织的。

对比项
DOC
正常未加密 DOCX
自动化上的含义
容器
OLE/CFB 二进制复合文件
ZIP + OOXML
DOCX 可以先用标准 ZIP/XML 工具检查,DOC 需要专用解析器或 Office 引擎
Python 标准库
没有面向 DOC 的完整解析能力
zipfile、xml.etree.ElementTree
DOCX 可以先做基础识别和部件读取
常用第三方工具
olefile、antiword、Apache POI HWPF
python-docx、lxml、Open XML SDK、docx4j
DOC 更适合提取或转换;DOCX 适合结构化读写
保真处理
Word、LibreOffice
Word、LibreOffice 或 OOXML 工具链
分页、域、修订、字体和嵌入对象还是要靠真实排版引擎
推荐路径
保留原件,真实转换后再处理
识别、解析、修改、验证
不要把改扩展名当转换

DOCX 的好处在于,包里的各个部分能拆开看。脚本可以先确认里面有什么,再决定只取正文,还是继续处理样式、关系、图片、批注和页眉页脚。

DOC 转 DOCX的常见方法

旧 DOC 的常见做法还是先转换,再统一处理,但转换器要按环境和保真要求选。Windows 上如果有 Word COM,通常最接近 Word 自己保存的结果;macOS 或 Linux 批处理一般会用 LibreOffice headless。只想提取纯文本时,antiword、Apache Tika 或 Apache POI HWPF 也能派上用场,但它们不能替代真正的版式转换。

宏和嵌入对象要单独看。普通 .docx 不能保存 VBA 宏;如果旧文档的宏必须保留,目标格式就要重新评估 .docm,不能硬塞进 .docx。公式、OLE 对象、域、修订和字体也可能在转换时变化。

虽然都是.docx 后缀,但可能是三种不同的文件

有些 .docx 打开后是正常 ZIP 包,有些仍是 OLE/CFB 文件头,还有些只是旧 .doc 换了个名字。麻烦的是,看到二进制外壳也不能马上判定转换失败,因为密码加密的 .docx 仍然可能使用 OLE/CFB 容器。总之,就是两者傻傻分不清楚。

第一种是正常 OOXML。它通常能被 ZIP 工具识别,包内应有 [Content_Types].xml、_rels/.rels,Word 文档还要能沿关系找到主文档部件。仅仅“是 ZIP”还不够,因为任意 ZIP 文件都可以改名为 .docx。

第二种是加密或受保护的 OOXML。微软的 MS-OFFCRYPTO 规范要求相关 Data Spaces 结构使用 OLE compound file,并通过 EncryptionInfo、EncryptedPackage 等流保存加密信息与内容。这类文件是合法的 Office 文档,只是自动化处理前需要密码和解密步骤。

第三种是“伪转换”:旧 DOC 只改成了 .docx 后缀。它仍可能包含 WordDocument 与 0Table / 1Table 等旧二进制流。脚本如果直接交给 python-docx 或 zipfile,得到的只会是格式错误。

还有一个容易混淆的点:OOXML 确实存在 Strict 与 Transitional 两种一致性类别。ISO/IEC 29500 同时定义了这两类概念,Transitional 还包含帮助旧二进制文档高质量迁移的兼容特性。Strict/Transitional 说的是 OOXML 的一致性,不是两种不同的 DOCX 容器。

利用脚本判断文件类型

Python 标准库可以先做分流:判断文件是不是 ZIP,再检查 OOXML 的必要部件;如果不是 ZIP,就读取前八个字节,看看是不是 OLE/CFB。
...python

from pathlib import Path

from posixpath import normpath

from zipfile import ZipFile, is_zipfile

from xml.etree import ElementTree as ET

CFB_MAGIC = bytes.fromhex("D0 CF 11 E0 A1 B1 1A E1")

def has_word_main_part(package):

  names = set(package.namelist())

  if not {"[Content_Types].xml", "_rels/.rels"} <= names:

    return False

  root_rels = ET.fromstring(package.read("_rels/.rels"))

  for rel in root_rels:

    if rel.attrib.get("Type", "").endswith("/officeDocument"):

      target = normpath(rel.attrib.get("Target", "").lstrip("/"))

      return target.startswith("word/") and target in names

  return False

def probe_word_file(path):

  path = Path(path)

  if is_zipfile(path):

    with ZipFile(path) as package:

      return "docx_ooxml" if has_word_main_part(package) else "zip_not_word_docx"

  with path.open("rb") as file:

    header = file.read(8)

  if header == CFB_MAGIC:

    return "ole_cfb_needs_stream_inspection"

  return "unknown"

这段代码只负责把正常 ZIP 包、OLE/CFB 容器和其他文件分开,不会一次解决所有情况。进入 OLE/CFB 分支后,还要继续看内部流:有 EncryptedPackage 和 EncryptionInfo,先走解密;有 WordDocument 和 Table 流,走 DOC 转换;两组特征都没有,再放进异常队列。Apache POI 的 FileMagic 也是按文件头区分 OLE2 与 OOXML/ZIP,并不相信文件名。

DOCX 自动化处理 Tips

确认它是正常 OOXML 后,脚本才知道该去哪些部件里拿内容。正文通常在 word/document.xml,样式在 word/styles.xml,编号在 word/numbering.xml,图片在 word/media/。超链接、图片和嵌入对象通过 .rels 文件串起来。

而且还有最常见的两个坑,一个是把 document.xml 当成整份文档,另一个是直接对 XML 做字符串替换。页眉、页脚、脚注、尾注、批注和文本框可能都在别的部件里。肉眼看到的一个词,也可能被拆进多个 w:r / w:t 节点。微软的 Open XML SDK 文档也是按 package、parts 和 relationships 这套结构来讲 Word 文档的,段落内部再由 Run 和 Text 组成。

工具可以按需求往上加:只做包检查和简单 XML,就用 zipfile 加 xml.etree.ElementTree;常规段落、表格、样式和图片读写,用 python-docx;需要精细操作 OOXML、处理大文件或特殊节点,就换 lxml、Open XML SDK、docx4j。需要接受修订、更新域、验证分页或尽量保持版式,还是 Word 或 LibreOffice 更稳。

相关学习资料