PySide6 + win32com 实现 Word 批量转 PDF 桌面工具实践 简介面向日常办公中需要将Word转成PDF的用户以及想学习PySide6桌面应用的Python开发者这份资源用PySide6搭建了一个简洁直观的图形界面通过docx2pdf库完成格式转换操作起来不需要编写复杂命令。整个压缩包仅有1个Python脚本、体积约1KB实现了从选择Word文档、设置PDF保存路径到触发转换并在界面中实时反馈状态的完整链路代码结构清晰、注释得当适合作为桌面办公小工具直接使用或二次开发。资源上线后已有165人学习对不熟悉编程的用户来说图形化操作极大地降低了使用门槛对开发者而言则是理解PySide6窗口布局、事件处理与docx2pdf集成的实用范例。无论是希望快速完成日常单份文件转换还是想在此基础上扩展批量处理功能这份精简脚本都能提供很好的起点也便于后续维护和功能扩充。 前阵子做一个内部办公工具需求听起来特别简单把一批 Word 文档批量转成 PDF。我一开始也想偷懒让用户自己去网上找转换器结果被业务部门一顿吐槽。在线工具一次只能传几个文件排队慢是一回事上传的合同文件谁都担心中途泄露最关键的是转换出来的排版经常跟原稿对不上页码、表格、字体全乱了。行吧老老实实用 PySide6 写一个本地转换工具。这篇文章把我踩过的坑、写过的代码、优化过的地方都整理出来给同样要处理 Word 转 PDF 桌面工具的朋友一份能直接参考的实践记录。这个工具解决的核心问题有三个批量转换几十个文件丢进去一次跑完本地转换文档不离开内网排版保真界面化操作非技术同事点按钮就能用。目标读者是正在做桌面自动化、办公辅助软件的开发者或者想在项目里集成文档转换能力的人。下面从技术选型、界面设计、核心代码到坑点排查一条线讲透。1. 整体思路为什么是桌面工具加 COM 自动化1.1 先想明白这件事的本质Word 转 PDF 的核心逻辑其实不是“把文档内容重新画成 PDF”而是“让 Word 自己把文档导出成 PDF”。这俩路线差别非常大。很多人写的转换工具还原度不行就是因为走了解析 docx 内容再渲染的路线样式、字体、分页、页眉页脚全要自己处理工作量大效果还不稳定。正确做法是调用本机已安装的 Word 程序以自动化方式打开文档再触发 Word 内置的导出能力直接生成 PDF。这样得到的 PDF 和用户在 Word 里手动“另存为 PDF”完全一致排版还原度是最高的。我做过对比基于 COM 的方案在处理含复杂表格、公式、插图的长文档时效果碾压一切纯解析方案。所以项目的技术路线从第一天就定为PySide6 做壳COM 做芯。1.2 Python 生态里的方案对比确定路线之后我把 Python 里能用的几个方案拉出来对比了一下直接看表方案原理平台排版保真度依赖win32com 调 WordCOM 自动化驱动本机 WordWindows最高pywin32docx2pdfwin32com 的上层封装Windows、macOS最高docx2pdfLibreOffice headlesssoffice 命令行无头转换Windows、Linux、macOS较高复杂版式略有差异LibreOffice纯 Python 解析渲染解析 docx 后自行绘制 PDF跨平台低仅适合极简文档python-docx、reportlab实际选型我推荐优先考虑 win32com。原因有两个一是绝大多数办公电脑本来就装了 Microsoft Office不用额外引入软件二是排版还原度是这类工具的第一生命线。docx2pdf 本质是把 win32com 那套封装了一下API 更简单但可控性差一点比如 Word 进程的启停、异常恢复都由库内部决定出了问题排查起来反而绕弯子。所以我直接写 pywin32代码量没多多少故障点却能自己把握。如果你的运行环境是 Linux 服务器或者目标机器没有 Office那就要转到 LibreOffice headless 方案通过 subprocess 调用soffice --headless --convert-to pdf同样能完成任务这个后面扩展部分会详细说。2. 环境准备与项目骨架2.1 依赖安装与版本选择开发环境是 Windows 10/11Python 3.10PySide6 6.x。安装就两条命令pip install PySide6 pywin32PySide6 是 Qt6 的官方 Python 绑定组件全、文档好社区活跃度也高。pywin32 提供 Windows 平台的 COM 调用能力Word 自动化全靠它。这里多说一句如果你之前用过 PySide2 或者 PyQt5信号槽那套写法在 PySide6 里基本通用迁移成本很低。另外很多新手会问“PySide6 没有 Designer 怎么办”。就这种工具类小程序来说直接用代码写布局反而更可控。靠代码布局不依赖 Designer 生成的 .ui 文件少一层加载转换的麻烦窗口尺寸、控件顺序、伸缩策略全都自己说了算。本项目完全采用纯代码布局这也是我推荐的做法。2.2 项目文件结构word2pdf/ ├── main.py # 程序入口主窗口与信号槽对接 ├── worker.py # 转换线程封装 COM 调用 └── requirements.txt # 依赖清单分文件的目的很明确界面和耗时逻辑必须分开。Word 转换是典型的耗时操作如果放在主线程里用户一点“开始转换”界面立刻卡死体验会非常糟糕。把转换封装成独立线程主界面只负责展示和交互两边通过信号通信这是整个项目的结构基础。3. 核心功能逐步实现3.1 文件选择与任务列表主窗口的布局用代码写顶部一排按钮中间是文件列表下方依次是输出目录输入框、进度条、日志区。文件选择支持多选一次能加几十个 doc、docx 文件。files, _ QFileDialog.getOpenFileNames( self, 选择 Word 文件, , Word 文件 (*.doc *.docx);;所有文件 (*.*) ) if files: self.file_list.extend([f for f in files if f not in self.file_list]) self.list_files.clear() for f in self.file_list: self.list_files.addItem(f)这里注意做了去重防止同一个文件被重复添加。输出目录留空时默认 PDF 输出到源文件同目录用户也可以手动指定公共目录方便统一收集转好的文件。列表控件支持显示完整路径即使文件在不同文件夹用户也能一眼看出当前任务有哪些。3.2 用 QThread 封装转换任务转换必须放到后台线程这是桌面开发的铁律。我用 QThread 子类来实现通过 Signal 把进度和结果回传给主界面。# worker.py import os import win32com.client from PySide6.QtCore import QThread, Signal WD_FORMAT_PDF 17 class ConvertWorker(QThread): progress Signal(int, str) # 进度百分比, 当前文件名 one_done Signal(str, bool, str) # 源文件, 是否成功, 结果信息 def __init__(self, files, outdir, parentNone): super().__init__(parent) self.files files self.outdir outdir def run(self): word win32com.client.DispatchEx(Word.Application) word.Visible False word.DisplayAlerts 0 total len(self.files) try: for index, src in enumerate(self.files, start1): stem os.path.splitext(os.path.basename(src))[0] dst self.build_output_path(src, stem) try: doc word.Documents.Open( src, ReadOnlyTrue, AddToRecentFilesFalse ) doc.SaveAs(dst, FileFormatWD_FORMAT_PDF) doc.Close(False) self.one_done.emit(src, True, dst) except Exception as exc: self.one_done.emit(src, False, str(exc)) self.progress.emit(int(index / total * 100), stem) finally: word.Quit() def build_output_path(self, src, stem): if self.outdir: folder self.outdir else: folder os.path.dirname(src) candidate os.path.join(folder, stem .pdf) seq 1 while os.path.exists(candidate): candidate os.path.join(folder, f{stem}_{seq}.pdf) seq 1 return candidate代码不复杂但里边的细节全是项目上线后补出来的逐个拆开说。DispatchEx而不是Dispatch。Dispatch 会复用系统里已有的 Word 实例如果用户正开着一个 Word 窗口你的代码会去抢控制权轻则弹窗重则直接把用户正在编辑的文档带偏。DispatchEx 每次都新建独立实例互不干扰这在批量转换场景里尤其重要我是吃过亏以后才换过来的。word.Visible False让 Word 彻底后台运行不弹界面DisplayAlerts 0关掉弹窗提示否则转换中遇到兼容性确认框任务会卡在那里等人手动点确定线程直接挂起。FileFormat17是 Word 内置的 PDF 格式常量wdFormatPDF这个数字是固定的记住即可。ReadOnlyTrue打开文件既保护原始文档不被改动也能兼容“源文件正被其他人打开”的场景。AddToRecentFilesFalse则是避免每次转换都污染用户的最近打开列表属于体验细节。build_output_path里做了重名检测输出目录已有同名 PDF 时自动追加_1、_2不会默默覆盖旧文件。这个功能是上线后被用户反馈“我之前的文件怎么没了”才补上的做工具类软件的人应该都懂。3.3 把进度和日志安全地推回界面主窗口里接收信号更新进度条和日志区。这一层是界面和线程的桥。def start_convert(self): if not self.file_list: return outdir self.edit_outdir.text().strip() self.worker ConvertWorker(self.file_list, outdir) self.worker.progress.connect(self.on_progress) self.worker.one_done.connect(self.on_one_done) self.worker.finished.connect(self.on_finished) self.btn_start.setEnabled(False) self.worker.start() def on_progress(self, value, name): self.progress.setValue(value) self.log_view.append(f[{value}%] 正在转换{name}) def on_one_done(self, src, ok, info): if ok: self.log_view.append(f[成功] {src} - {info}) else: self.log_view.append(f[失败] {src}原因{info}) def on_finished(self): self.btn_start.setEnabled(True) self.log_view.append(全部任务处理完毕)这里的信号是跨线程投递的PySide6 的队列连接机制会保证主界面在自己的事件循环里安全执行槽函数。你在线程里不要直接操作 QListWidget、QTextEdit 这些控件跨线程操作 UI 轻则不生效重则崩溃。通过信号槽通信是标准做法逻辑清晰也没有竞态问题。转换结束后把“开始转换”按钮恢复可用日志区给出明确提示用户就知道流程跑完了。4. 常见问题与排错记录4.1 Word 没装或者 COM 注册异常在一台没装 Office 的电脑上运行程序会直接抛出 COM 相关错误最常见的是Class not registered或者错误码-2147221164。原因很简单COM 组件没注册系统里根本没有 Word 这个程序可以调用。解决方法是程序启动转换前先检测环境用 try 包住 Dispatch失败就弹提示框别让用户对着堆栈发呆。try: word win32com.client.DispatchEx(Word.Application) except Exception: QMessageBox.critical(self, 错误, 未检测到 Microsoft Word请先安装后再使用本工具。) return还有一种容易忽视的情况机器上装的是 WPS用户以为能转但 WPS 有自己的 COM 接口Word 的 COM 没有注册照样会失败。这种情况要么装 Office要么在代码里做 WPS 分支。WPS 的 COM ProgID 一般是KWPS.Application调用方式类似把接口名替换一下就能兼容一部分场景但格式细节需要逐个版本实测。4.2 WINWORD.EXE 进程残留批量转换中间如果某个文件出现异常崩溃Word 可能没来得及退出任务管理器里就会累积多个 WINWORD.EXE内存越占越多后续转换越来越慢最后干脆失败。核心防御手段是finally: word.Quit()保证正常路径一定能退出进程。但遇到崩溃路径finally 也拦不住。我的处理方案是转换结束后对比当前系统里的 WINWORD.EXE 进程数如果异常增多就清理多出来的进程。import subprocess def kill_leftover_word(): cmd powershell -Command \Get-Process WINWORD -ErrorAction SilentlyContinue | Stop-Process -Force\ subprocess.run(cmd, shellTrue)这个命令会把用户正在编辑的 Word 文档也一并杀掉所以只能用于异常恢复最好加确认弹窗不要随意执行。实际项目里我更多是提示“检测到异常残留进程建议重启工具”而不是粗暴强杀。在这种桌面工具里安全性永远排在第一位宁可多给用户提示也不能误伤正常文档。4.3 只读打开、加密文档与特殊路径这组问题在真实使用中遇到频率很高逐个列出。源文件被其他同事用 Word 打开时直接 Open 可能报权限错误ReadOnlyTrue能解决大部分场景。加密文档不带密码打开会弹窗卡住在线程里的表现就是“卡死但没报错”。这种情况下升级的太慢。解决思路是 Open 时传PasswordDocument参数拿不到密码就提前给用户标记失败而不是让用户等一个永远不出现的对话框。路径带特殊字符或者超长路径时win32com 偶尔会出问题。我的习惯是转换前先os.path.abspath转成绝对路径再检查一遍文件是否存在src os.path.abspath(src) if not os.path.exists(src): self.one_done.emit(src, False, 源文件不存在) continue这步虽然简单但能挡掉一大批莫名其妙的 COM 报错。很多异常根本不是转换本身的问题而是前面的路径、文件状态已经不对了。4.4 界面假死与进度不准如果图省事把转换逻辑直接写在按钮的槽函数里一点“开始转换”界面立刻无响应。用 QThread 能解决假死但进度条还有一个新问题Word 的 SaveAs 是一次性调用大文件转换那几秒内进度条不动用户容易以为程序挂了。我的应对方案是每个文件转换前打一条日志把当前处理的文件名显示出来进度条按文件数跳格子至少让用户知道程序正在处理第几个文件。如果想更精细可以给 Word 设置定时任务定期刷新状态但实际运营下来按文件粒度更新已经完全够用也不会给 COM 调用增加额外负担。实测一个 30 页、带大量图片的 docx 转 PDF 大约需要 2 到 4 秒批量 50 个文件大概两三分钟跑完。如果碰到单文件超过 200M 的“怪兽文档”SaveAs 阶段卡上十几秒很正常不用慌等它完成就好。5. 可以继续扩展的方向5.1 把 PDF 转 Word 也做进去PDF 转 Word 不是单纯的反向操作Word 自身有打开 PDF 并转换的能力COM 里可以用doc word.Documents.Open(pdf_path)打开再用SaveAs2保存成 docx。但这个能力在不同 Office 版本里表现差异很大对扫描版 PDF 基本无能为力。效果好的方案还是借助专业解析库Python 生态里pdf2docx对文本型 PDF 的效果不错可以直接集成到同一个工具里作为一个独立的转换方向。5.2 跨平台与无人值守如果部署环境没有 Office比如跑在 Linux 服务器上就把转换后端切换到 LibreOfficesoffice --headless --convert-to pdf --outdir /output /input.docxPython 里用 subprocess 调用即可。这样界面层由 PySide6 负责转换后端按环境切换核心逻辑不用大改。无人值守场景还可以加文件监视新文件进入指定目录就自动触发转换这属于业务需求层面的扩展技术实现并不复杂。代码部分就到这。最后说点个人实操体会这类“小工具”最容易被低估的模块是异常处理而不是界面好不好看。我第一次交付时只顾着主流程结果在真实用户手里半小时内就连续撞出“文件被占用”“目录没权限”“重名覆盖”三个问题。后来我把能想到的边界情况都写成 try/except并给用户输出明确的日志提示这个工具才算真正稳定下来。如果你也在做类似的桌面工具建议写完第一版能跑通主流程之后专门花一轮时间做“破坏性测试”把你想象得到的所有错误场景都亲手触发一遍。这个时间花得非常值。本文还有配套的精品资源点击获取