
先用一个我猜不少人遇到过的场景开场你从某个老同事手里接过一批.doc文件习惯性地打开 Python写上from docx import Document去怼那个文件结果啪的一声直接PackageNotFoundError。如果你因此怀疑是自己装错了库我可以先告诉你答案——python-docx根本不认识.doc这种老格式它能处理的只有.docx。这个差别不只是后缀名多一个x的问题它决定了你在做批量归档、全文检索、文档自动化时是不是得先把.doc统一处理成.docx。这篇文章就把这件事讲透为什么必须转、有哪些成熟的转换路线、怎么写一个能直接拿去跑批量的 Python 脚本以及我在实际转换上千个文件过程中踩过的坑和在排查后的解决方案。1. 为什么 doc 和 docx 天生不对付1.1 一个像老式文件柜一个像 Zip 压缩包很多人在第一次查.doc转.docx的资料时会默认它们只是两个相近的格式找个库一调就能互转。实际上这俩在文件结构层面几乎是两个物种。.doc是 OLE2 复合文档格式也就是常说的 CFBCompound File Binary。你可以把它理解成一个小型文件系统一个.doc文件内部被切成了很多“扇区”里面有 WordDocument 流、Table 流、Data 流等等正文、样式、修订记录分散存放在不同区域靠一大堆偏移量和指针串起来。它的二进制结构非常复杂而且相当一部分字段是微软内部实现细节公开文档都没写全。.docx则完全不同它本质是一个 Zip 压缩包里面装着word/document.xml、word/styles.xml、word/media/等一堆 XML 和资源文件。由于 XML 是纯文本、结构公开任何语言都能用现成的 zip 库把它拆开看个清清楚楚。我经常用一个类比.doc相当于档案馆里一整柜纸质卷宗想找到某一条记录得先翻目录、再凭手写编号去架子上找.docx则像是把所有卷宗扫描后做了统一的电子索引任何一个系统拿到这个压缩包都能按固定规则读取。1.2 这种结构差异直接决定了工具选型的边界先说结论目前没有纯 Python 实现的高保真.doc转.docx方案。这不是 Python 不行是工作量不划算。常见的处理.doc的 Python 系工具基本都停留在“文本提取”层面antiword能从.doc里抽文本但排版、图片、表格基本拿不回来catdoc走的也是文本抽取路线输出的是纯文本或 CSVolefile能解析 OLE2 容器的目录结构但你要自己从 WordDocument 流里定位正文等于自己写一个不完整的 Word 解码器网上有些教程会把.doc读到 bytes 后用正则去抓字符串这种方案只能应付纯文本老文档遇到带格式的就会乱。而python-docx、mammoth、docx2txt这类工具全部只认.docx。所以我必须把话说明白如果你只是想把文字捞出来用antiword或catdoc就够了如果你要的是保留格式、可二次编辑、能继续跑python-docx管道的.docx那就别指望纯 Python 自己解码现实做法是“借工具”——在 Windows 上借 Word在 Linux 和 macOS 上借 LibreOffice。2. 三条路线实测后的选型建议2.1 Windows 上有 Office 时Win32COM 是保真度的天花板如果你手边的电脑是 Windows而且装了 Microsoft Office那最稳的一条路就是用pywin32调用 Word 的 COM 接口。原理很简单让 Word 自己把.doc打开再用SaveAs2把它另存为.docx。这就好比你想把一本旧书换成新版装帧最保险的办法不是自己重新排版而是让出版社重新印一版。这段代码是核心逻辑import win32com.client as win32 word win32.DispatchEx(Word.Application) word.Visible False word.DisplayAlerts 0 doc word.Documents.Open(rC:\work\老文档.doc, False, True) doc.SaveAs2(rC:\work\老文档.docx, FileFormat16) doc.Close(False) word.Quit()这里有几个参数值得说明DispatchEx开一个新的 Word 实例比Dispatch干净能避免复用已经存在的 Word 进程VisibleFalse让 Word 在后台运行不会在屏幕上弹窗DisplayAlerts0屏蔽所有对话框否则碰到损坏文档时 Word 会弹窗卡住整个脚本SaveAs2的FileFormat16是wdFormatDocumentDefault就是 Word 默认的.docx格式。如果你用的是 Office 2007/2010 这类老版本也可以改成12wdFormatXMLDocument两种数字产出的都是.docx16 更符合新版本 Word 的默认行为。这条路线最大的优势就是格式保真度最高。因为文件本来就是 Word 亲自打开的字体、分页、页眉页脚、修订记录都会按 Word 的逻辑重新保存一遍几乎不会出现解不开的情况。代价是必须装 Office而且只能在 Windows 上跑。2.2 服务器和 Linux 环境LibreOffice 命令行转换如果你的目标是跑在 Linux 服务器上或者公司不允许每台机器都装 Office那 LibreOffice 的 headless 模式就是主力方案。LibreOffice 提供了一个soffice命令行程序可以用--convert-to参数批量做格式转换。Python 这边不需要额外库直接用subprocess调命令就行。soffice --headless --convert-to docx --outdir /data/output /data/input/老文档.doc基础用法看着简单但实际用起来有几个细节我会在后面的脚本章节展开。先说结论在无 Office 环境里LibreOffice 的转换质量已经足够应付 95% 的办公文档复杂表格、文本框、图片浮动的还原度虽然偶有小瑕疵但总比没有强。2.3 纯 Python 方案只能做文本抢救我也见过一些人抱着“不装 Office、不装 LibreOffice”的执念问我能不能用纯 Python 把.doc转成.docx。答案是能转但转出来的基本只是个壳。一种常见做法是把.doc当二进制流解析出所有文本然后用python-docx重新生成一个.docx。听起来可行但实际跑几个老文档你就会发现分页没了、段落样式全乱、表格顺序错乱、图片全部丢失。如果这个.doc是带修订留痕的合同文本那丢的东西可能让你直接想砸键盘。所以我把这条路归为“文本抢救”不是“格式转换”。适用场景只有一个你不关心样式只需要把文本从.doc里捞出来进全文检索系统。这种情况直接用antiword或catdoc输出纯文本分分钟搞定完全用不着 Python 包一层。2.4 选型对照表方案平台依赖保真度运行速度适合场景Win32COM 调 WordWindows Office最高Word 亲自重存慢单文件要秒级本地桌面、Windows 服务器、校验严格LibreOffice headless跨平台需装 LO高偶有小瑕疵中批量一次启动快Linux 服务器、批量流水线antiword/catdoc跨平台小工具低仅文本快全文检索、纯文本抽取纯 Python 自解析无极低样式全丢取决于实现几乎不推荐对我来说能跑 Windows 就用 Word COM能接受装 LibreOffice 就用 LO纯 Python 方案除非是临时救急否则不要投产。3. 可直接抄作业的批量转换脚本3.1 环境准备先在目标机器上确认两件事Python 版本和转换工具是否就位。Windows安装 Office然后pip install pywin32。注意win32com是 Windows 专属在 Linux 上装不了Linux安装 LibreOffice。Debian/Ubuntu 上执行sudo apt install libreofficeCentOS 上执行sudo yum install libreoffice装完后确认soffice命令存在如果你用 VSCode 或 PyCharm注意项目解释器别选错Windows 上容易出现“pip 装到了 A 环境脚本用 B 环境跑”的诡异问题。环境这块没有太多可说的真正磨人的是下面的脚本设计。3.2 Win32COM 批量脚本和它的设计理由我先给一份 Windows 上能直接用的批量脚本import os import time import win32com.client as win32 def doc2docx_by_word(src_dir, dst_dir): if not os.path.isabs(src_dir): src_dir os.path.abspath(src_dir) if not os.path.isabs(dst_dir): dst_dir os.path.abspath(dst_dir) os.makedirs(dst_dir, exist_okTrue) word win32.DispatchEx(Word.Application) word.Visible False word.DisplayAlerts 0 total 0 ok 0 fail 0 for root, _, files in os.walk(src_dir): for name in files: if not name.lower().endswith(.doc) or name.lower().endswith(.docx): continue src_path os.path.join(root, name) base_name os.path.splitext(name)[0] dst_path os.path.join(dst_dir, base_name .docx) if os.path.exists(dst_path): print(fSKIP: {dst_path} 已存在) continue total 1 try: doc word.Documents.Open(src_path, False, True) doc.SaveAs2(dst_path, FileFormat16) doc.Close(False) ok 1 print(fOK: {src_path} - {dst_path}) except Exception as exc: fail 1 print(fFAIL: {src_path} - {exc}) time.sleep(0.5) word.Quit() print(f完成共 {total} 个文件成功 {ok} 个失败 {fail} 个)这段脚本有四个我特别留过的设计路径强制转绝对路径。COM 接口对相对路径的处理很迷曾经让我吃过亏后来养成习惯所有路径先abspath跳过已存在的目标文件。批量任务续跑时很有用不然每次都要重新转一遍time.sleep(0.5)。Word 批量转换不是一个瞬时动作连续快速打开文档容易让 COM 队列卡死加一个小间隔能明显提高稳定性异常按文件捕获而不是整个目录跑崩。一个加密文档不会拖垮剩下几百个文件。3.3 LibreOffice 批量脚本和参数细节服务器场景我推荐用这条import os import subprocess SOFFICE soffice # Windows 上通常要写完整路径例如 # SOFFICE rC:\Program Files\LibreOffice\program\soffice.exe def doc2docx_by_lo(src_dir, dst_dir, profile_dir/tmp/lo_profile_doc2docx): src_dir os.path.abspath(src_dir) dst_dir os.path.abspath(dst_dir) os.makedirs(dst_dir, exist_okTrue) files [] for root, _, names in os.walk(src_dir): for name in names: if name.lower().endswith(.doc) and not name.lower().endswith(.docx): files.append(os.path.join(root, name)) if not files: print(没有 .doc 文件) return # 指定独立的 LibreOffice 用户配置目录避免多个进程互抢锁 cmd [ SOFFICE, f-env:UserInstallationfile://{profile_dir}, --headless, --convert-to, docx, --outdir, dst_dir, ] files proc subprocess.run(cmd, capture_outputTrue, textTrue, encodingutf-8, errorsignore) print(proc.stdout) if proc.returncode ! 0: print(proc.stderr)参数细节是重点-env:UserInstallationfile://{profile_dir}是为了给每次转换一个独立的 LibreOffice 配置目录。因为 LibreOffice 的 headless 进程如果并发或连续运行可能因为用户配置文件锁直接退出常见的报错就是no suitable user profile或者another instance is already running。指定独立目录后这个问题基本消失--convert-to docx可以简写LibreOffice 会根据目标扩展名自动选择过滤器。如果你发现某些版本转出来的文件打不开再改成--convert-to docx:MS Word 2007 XML显式指定过滤器我把所有文件拼成一条命令而不是循环调soffice。LibreOffice 启动一次的开销不小一次处理完一批文件能省大量时间。实测下来300 个文件一次性传给--convert-to比循环 300 次调用soffice快几倍。还有一个坑要提醒subprocess里的命令参数不要用 shell 字符串拼接尤其是路径里带空格时直接传列表给subprocess.run就不会有转义问题。3.4 把脚本升级成带日志的批处理批量跑文件最怕什么不是报错而是不知道哪些成功、哪些失败、跑到哪一步挂了。所以我一般会在脚本里加一个结果落盘逻辑。最简单的做法是把 print 的内容同时写到日志文件并且最后生成一个result.csv每行三列源文件路径、目标文件路径、状态ok/fail/skip。import csv def write_result(results, dst_dir): csv_path os.path.join(dst_dir, conversion_result.csv) with open(csv_path, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([source, target, status, message]) writer.writerows(results)utf-8-sig编码是故意选的这样生成的 CSV 用 Excel 打开不会出现中文乱码。跑完几百个文件后直接筛选状态列失败的一批重点复转成功的一批抽查质量。这个习惯帮我省下了大量核对时间。4. 转完一圈之后踩过的坑排查记录4.1 Word 进程残留导致文件被锁第一次跑 Win32COM 脚本时跑了三百多个文件后突然报PermissionError提示目标文件被占用。我下意识去任务管理器看了一眼发现十几个WINWORD.EXE堆在那里。原因是我在脚本异常分支里没有做进程清理有些文档打开失败后 COM 对象没释放Word 进程就一直驻留。后来我做了两个改动异常分支里确保doc对象被释放可以在except里尝试doc.Close(False)整个脚本跑完后多一步保险把所有残留的WINWORD.EXE杀干净。杀进程这段谨慎一点只删文档转换期间出现的 Word 进程import subprocess def clean_word_process(): subprocess.run([taskkill, /F, /IM, WINWORD.EXE], capture_outputTrue)当然你要确定这台机器上没有别人在正常用 Word否则会误杀同事的编辑窗口。这个函数只在运维窗口执行或者加一个交互确认。被这个问题折腾过一次之后我现在跑批转换的机器都会单独准备一个干净的 Windows 虚拟机。4.2 中文路径、空格路径报错第二个高频坑是路径。Documents.Open对中文路径和带空格路径的动作在不同 Office 版本上表现不一致。有时候你看到报错“找不到文件”但用资源管理器打开那个路径明明就在。解决思路就是前面脚本里写的统一转成绝对路径并且不要手动拼\或/一律用os.path.join。LibreOffice 方案则要格外注意subprocess传参直接传列表不要把路径先拼成一个 shell 命令字符串。一个带空格的路径拼进 shell 字符串后很容易被拆成两段。我实测时还遇到过 Windows 上中文文件名被编码成乱码传给 COM 的情况后来用os.path.abspath配合 Python 3 的默认 UTF-8 文件系统编码没有再出现过乱码。4.3 加密文档和只读文档卡住进度带密码的.doc会是个大麻烦。Word COM 打开加密文档时如果没给密码会弹出输入密码的对话框就算你把DisplayAlerts0设为 0对话框依然可能冒出来脚本就卡在那里。LibreOffice 的命令行模式遇到加密文档一般会直接报错返回非零 exit code相对安全但 Win32COM 的弹窗问题必须主动规避。最稳的防御是提前用只读模式打开并且在Open请求中加入错误处理try: doc word.Documents.Open(src_path, False, True) # 第三个参数 True 表示只读 except Exception as exc: print(f打开失败可能是加密或损坏: {exc}) continue只读打开不等于不能另存为转出的.docx不会带上源文件的只读属性。如果文档本身加密且你拿不到密码那就放弃这台机器上的转换让业务方先解密再跑。破解密码这种事不在本文讨论范围内也不应该成为自动化的目标。4.4 转出来的 docx 格式看起来不对怎么办LibreOffice 的转换质量虽然高但也不是万能的。我在项目里碰到过三种典型错乱老文档里 Art 字体的艺术字变成了普通文本表格的列宽在转换后出现微调跨页表格的标题行重复设置丢失嵌入的 OLE 对象或 ActiveX 控件无法完整迁移。这类问题不是改几个参数能解决的我总结的排查路径是先分文件粒度做对比确认是单个文件的问题还是这一类文件的问题单独在装有 Office 的机器上用 Word 打开源文件手动另存为.docx如果手动转换结果正常说明是工具兼容性问题把问题文件挑出来单独跑 LibreOffice 新版本。LibreOffice 小版本迭代时对 doc 解析的改进很大升级到最新稳定版能解决大部分历史遗留问题如果升级后还是不行再考虑源文件本身就是由 WPS 或其他第三方软件生成的.doc这类非标准实现的格式在 Word 和 LO 里的解读都可能出现偏差。4.5 宏和域代码在转换中被静默丢弃.doc转.docx有一个很多文档不会提的默认行为VBA 宏会被移除。因为.docx不能包含宏只有.docm才能带宏。如果你的业务文档里写了自动化宏直接在 Word 里另存为.docx时就会在文件里丢掉这部分。LibreOffice 的--convert-to docx也会执行类似操作。遇到这类文件如果你还需要宏就得改成转成.docm如果不需要宏转换前最好确认一下这些文档有没有宏依赖我就见过有团队转完 docx 后宏功能消失导致下游模板系统跑不动的案例。域代码比如目录域、页码域同理。转换后域还在但值可能需要重新计算。打开.docx后按CtrlAF9更新域或者在python-docx里做不了域更新只能在 Word 里处理。这个细节适合在最终交付前做一轮抽查。5. 转完 docx 之后还能干哪些实事5.1 用 python-docx 做内容抽取和文档体检转成.docx之后python-docx不再是摆设了。文档里的段落、表格、图片都能被统一读取。批量文档体检的脚本思路大概是from docx import Document def inspect_docx(path): doc Document(path) para_count len(doc.paragraphs) table_count len(doc.tables) image_count 0 for rel in doc.part.rels.values(): if image in rel.reltype: image_count 1 return {paragraphs: para_count, tables: table_count, images: image_count}用这个inspect_docx跑一遍几百个新转出来的.docx能快速发现哪些文档存在异常比如段落数为 0、表格数量和源文件对不上等。这比人工一个个打开核对高效得多。如果你的下游是全文检索系统还可以直接抽取正文def extract_text(path): doc Document(path) return \n.join(p.text for p in doc.paragraphs)这一步做完就能把内容喂给 Elasticsearch、OpenSearch 或者任何内部搜索引擎。很多人纠结的“docx 能不能被 Windows 搜索出正文”这个问题其实转换后本身就解了因为.docx是 OOXMLWindows Search 自带索引器就能解析正文内容而老的.doc在部分新系统里反而容易出现索引不到正文的问题。所以统一转成.docx既解决了程序读取问题也顺手解决了搜索索引问题。5.2 接入文档管理系统和自动化管道企业里处理文档通常不是一次转换就结束而是接入一条流水线接收.doc→ 转.docx→ 抽取文本 → 打标签 → 入数据库 → 定期全量更新。我项目里的做法是把转换脚本封装成一个convert_batch()函数输出目录固定日志结果落盘然后由调度工具定时执行。比如 Linux 服务器上配置一个 cron 任务每天凌晨扫描指定目录0 3 * * * cd /opt/doc_pipeline python convert_pipeline.py /var/log/doc_convert.log 21Windows 服务器则可以用任务计划程序。定时任务跑起来后目录里新增的.doc会在下一个周期自动变成.docx下游系统只认.docx整个链路就通了。5.3 再往后扩展异常文件人工复核机制自动化跑久了我发现了新的问题总有几个文件因为加密、损坏、非标准实现等原因转不过去。脚本里虽然有失败日志但如果没人去复核问题文件就会一直躺在那里。我后来加了一个人工复核机制失败列表汇总后自动发一封邮件给文档责任人或管理员附上失败原因和文件路径。处理完一批重新跑一次脚本已存在的一律跳过直到所有文件都被成功转换。这样整个流程才真正闭环。写在最后的实操体会如果让我给一句话我会说.doc转.docx这活儿七分在工具选型三分在异常处理。别纠结纯 Python 能不能一根针捅破天用好 Word COM 和 LibreOffice 这两条路已经能解决几乎所有真实场景。再往后把转换脚本封装成带日志、带跳过的管道你会感谢自己当初多写的那些防御逻辑。至少我在跑了上千个文件之后最大的体会就是稳定的批量转换靠的是对异常路径的预判而不是赌工具不出错。