本地PDF转换工具实战:Python与PySide6实现图片转PDF和PDF转Word
发布时间:2026/10/20 16:57:05 锦皓数字建站

“得赶紧把这个合同扫描件转成 Word领导十分钟后要”这种话不知道你们有没有听过。反正我听完之后的第一反应不是去打开在线转换网站而是冷静地在本地跑起了一个自己写的小工具。今天想分享的正是这个基于 Python 和 PySide6 开发的本地复合文件工具主要干两件事图片转 PDF、PDF 转 Word。它不依赖网络不需要上传云端所有处理都在本地完成很适合那些对隐私敏感、或者网络环境不太方便的场景。如果你是 Python 开发者或者正想找一个练手项目来熟悉 PySide6、打包发布、文件处理这些流程这篇文章可以给你一个完整且能落地的参考。1. 为什么不用在线工具而要自己写一个本地工具1.1 一个真实的需求场景材料整理让人崩溃事情是这样的某个项目验收阶段需要把一堆照片、截图、签字的纸质扫描件汇总成一个 PDF 文件还要把供应商发来的 PDF 版技术文档转成 Word 以便批注修改。我当时第一个念头也是去用在线转换服务但往下想了几个问题就犹豫了。首先是隐私问题项目材料里包含合同编号、内部技术参数这些东西上传到第三方服务器总归心里不踏实。其次是效率问题在线工具普遍限制文件大小和页数一份上百页的 PDF 要开通会员才能转换体验非常割裂。最后是稳定性网络一旦波动传了一半的文件就得重新来过。所以干脆自己写一个本地工具把这两项高频需求统一到一个界面里面。数据不出本机、没有大小限制、转换速度快这才是我要的效果。1.2 技术选型为什么是 Python 加 PySide6 而不是其他组合确定要本地实现之后面临一个技术选型问题。市面上的 GUI 框架不算少但我斟酌了一圈还是选了 Python 配 PySide6理由可以一条条说清楚。Tkinter 虽然内置在 Python 里不用额外安装但它的控件外观停留在上个时代做一个文件拖拽区、进度条、多线程交互的现代界面写法繁琐且效果粗糙。Electron 那套对前端技能要求太高打包出来的体积动辄一两百兆对一个小工具来说太笨重。而 PySide6 是 Qt 6 的官方 Python 绑定控件丰富、外观原生、文档齐全LGPL 协议对它这种闭源内部分发的小工具也很友好。更重要的是PySide6 的信号槽机制做后台任务和界面刷新非常顺手配合 QThread 能完美避免文件处理时界面假死的问题。这正好是文件转换工具的核心痛点所以选它基本没有悬念。2. 图片转 PDF从多张图片到一份规范文档2.1 图片拼接 PDF 的核心思路别用 Pillow 硬干图片转 PDF 这件事最直觉的做法是直接用 Pillow 打开图片然后保存成 PDF但这个方案有几个隐藏问题。Pillow 保存 PDF 时会经过图像重新编码如果原图是 JPEG再编码一次就有二次压缩的损耗白底文字类的截图很容易出现边缘模糊。另外多张图片合成一个 PDF 时Pillow 的 save 方法虽然可以传 list但对每张图的尺寸处理比较粗糙生成的 PDF 页面大小不一致打印的时候非常难看。我踩过一次这个坑之后换成了 img2pdf 这个库。它的原理是把 JPEG 数据以原始字节形式直接嵌入 PDF 容器不重新编码所以图片质量零损失转换速度也快得离谱。配合 layout_fun 参数还可以自动把所有图片统一摆放到一张 A4 版面上页面整齐统一。import img2pdf from pathlib import Path def images_to_pdf(image_paths, output_pdf): # 将所有图片以无损方式合入一个 PDF with open(output_pdf, wb) as f: f.write(img2pdf.convert(image_paths, layout_funimg2pdf.get_layout_fun((img2pdf.mm(210), img2pdf.mm(297)))))2.2 文件排序被忽略的坑自然排序和字典序是两回事写多图片合并功能的时候最先遇到的坑其实不是格式问题而是排序。用户选了一堆图片程序需要对它们排序后输出。问题就出在“排序”两个字上如果你直接用 Path.glob 返回的默认顺序或者普通 sorted会出现 1.jpg、10.jpg、2.jpg 这种不符合直觉的排法PDF 里页面顺序完全是乱的属于特别隐蔽又特别恼人的 bug。解决思路是提取文件名中的数字部分转成整数做排序也就是所谓的自然排序。如果文件名里没有数字就退化成按字符串排序。import re def natural_sort_key(path): name path.stem return [int(text) if text.isdigit() else text.lower() for text in re.split(r(\d), name)]打个比方这就像整理纸质材料时你希望章节按 1、2、3...10 的顺序排列而不是按 1、10、2、3 这种字典序摆细节决定体验。2.3 尺寸适配问题小票扫描件和照片混排时如何处理另一个实际问题是不同尺寸图片混在一个 PDF 里时的版面协调。img2pdf 的默认行为是每张图片自适应独立页面图片多大页面就多大。这在一般场景下没毛病但如果一份 PDF 里既有横版照片又有竖版文档截图阅读体验就很跳跃。我最后选了按 A4 等比例缩放的方案让所有图片都适配到 A4 版面上图片短边居中留白。代码上就是传入 layout_fun 参数指定目标页面尺寸为 A4img2pdf 会按比例计算缩放。实测下来不管是手机拍的票据还是电脑截图出来的 PDF 页面统一、打印方便观感远好于原始尺寸页面。这里顺带提一下如果原图本身是 PDF 页面扫描件的低分辨率版本等比缩放会暴露细节不足的问题建议用户在前期尽量提供 150 DPI 以上的图片工具层面可以通过 Pillow 做一次轻微的锐化作为补救但不是核心方案。3. PDF 转 Word版式文档变可编辑文档的实现原理与限制3.1 核心库的选择pdf2docx 解决的是页面重构问题PDF 转 Word 是很多同事第一反应觉得“不靠谱”的功能因为 PDF 本质上是版式文档它记录的是文字、图片在页面上的绝对位置而 Word 是流式文档内容按段落顺序流动。从版式转流式天然需要做版面分析和重构。我调研了一圈选定了 pdf2docx 这个库它做的事情可以这样理解先解析 PDF 每一页的文字块、图片块、表格线框识别出哪些内容属于同一个段落、同一个表格然后把这些版面区块重新映射成 Word 的段落和表格结构。对常见的文本型 PDF转换效果相当不错标题层级、表格边框、图片位置都能保留个七八成。from pdf2docx import Converter def pdf_to_word(pdf_path, docx_path): cv Converter(pdf_path) cv.convert(docx_path, start0, endNone) cv.close()使用上并不复杂核心就是这一小段代码。pdf2docx 底层依赖 PyMuPDF 来读取 PDF 内容和布局数据再用 python-docx 来构建新的 Word 文档。所以安装这个库的时候会连带安装一堆依赖这是正常现象。3.2 转换效果的边界卷积式复刻与真实编辑之间存在距离坦白讲如果你期待 PDF 转出的 Word 能和原始排版一模一样那现阶段没有哪个开源库能做到都做不到这不是实现好坏的问题而是转换的根本逻辑决定的。PDF 里的文字间距、缩进、换行是按视觉位置固定的转成 Word 后生成的是带大量制表符和空格的近似结构视觉上接近但结构性可能不够“原生”。实测下来纯文本型的 PDF 转成 Word 后文字准确率很高段落也能基本保留带复杂表格的 PDF 转出来表格的边框和单元格划分大体正确但合并单元格多的复杂表格会有错位扫描版 PDF 更不用提没有 OCR 的情况下转出来只能得到空白页因为里面根本没有文字层。所以我在工具界面上做了一个明显提示转换前确认 PDF 可以选中文字如果是扫描件需要先做 OCR 识别不在本工具的处理范围内。这个诚实的边界设定反而让工具口碑更好大家知道它能干什么、不能干什么不会产生错误预期。3.3 异常输入的处理加密 PDF 和超长文档的防御实际使用中 PDF 文件千奇百怪最常见的两类坑是加密保护和超大页数。加密 PDF 在打开时需要密码pdf2docx 会直接抛异常程序如果不做拦截就会弹出一个难看的报错框。我的处理方式是先用 PyMuPDF 探测文档是否加密再决定是否提示用户输入密码。import fitz def check_pdf_encrypted(pdf_path): doc fitz.open(pdf_path) is_encrypted doc.is_encrypted doc.close() return is_encrypted页数方面五六百页的 PDF 转换时间可能长达几分钟如果不做任何反馈用户会以为程序卡住了。我在转换逻辑里加了页面进度回调通过信号机制实时更新进度条和日志区让用户知道“正在转换第 123/450 页”体验完全不一样。4. 界面搭建与交互体验拖拽、进度反馈和线程分离4.1 主窗口布局两种功能平铺还是 Tab 切换界面设计上我认真思考过几种方案。最初想做成上下两部分上边放图片转 PDF下边放 PDF 转 Word一屏全见但实际排布后发现问题很明显两个功能的操作区挤在一起文件列表框容易混淆用户搞不清当前操作的是哪个区域。后来改成 QTabWidget 两个独立标签页各管各的界面清爽很多。每个标签页的核心控件包括一个文件拖拽区、一个文件列表、一个转换按钮、一个进度条和一个日志文本框。文件拖拽区用 QLabel 实现设置固定高度和虚线边框视觉上告诉用户“把文件丢到这里”。文件列表用 QListWidget支持删除选中项方便误选时调整。整体结构不复杂但符合这类工具软件的使用习惯第一眼就知道怎么用。4.2 线程模型为什么必须用 QThread 而不是直接在界面线程跑这里是我自认为做得最正确的一个设计决策也是很多初学者最容易忽略的。文件转换是 CPU 密集和 IO 密集混合的操作直接在按钮的 clicked 信号里调用转换函数界面会整个卡死拖动窗口都没反应遇到大文件用户第一反应是“程序死了”然后强行结束进程。正确方案是把转换任务放入 QThread 子线程执行通过 Signal 和主线程通信。PySide6 里用标准的继承式写法即可。from PySide6.QtCore import QThread, Signal class ConvertWorker(QThread): progress Signal(int) log Signal(str) finished_ok Signal(str) failed Signal(str) def __init__(self, func, *args): super().__init__() self.func func self.args args def run(self): try: self.func(*self.args, progress_callbackself._progress, log_callbackself._log) self.finished_ok.emit(处理完成) except Exception as e: self.failed.emit(str(e)) def _progress(self, value): self.progress.emit(value) def _log(self, text): self.log.emit(text)信号槽机制天然是线程安全的子线程 emit 信号后界面会自动在事件循环里更新不会出现多线程写控件的崩溃问题。转换过程中所有交互控件设成禁用状态避免用户重复触发任务。注意一个细节QThread 对象本身要在主线程持有引用如果在函数里创建局部变量线程跑完前可能被垃圾回收导致程序异常退出。我在窗口类里用一个成员变量 self.worker 保存当前线程对象任务结束后再释放。4.3 拖拽文件的实现细节接受哪些格式、怎么过滤非法文件PySide6 的拖拽功能实现起来很直接在窗口构造函数里启用 setAcceptDrops(True)然后重写拖拽进入事件、拖拽移动事件、放下事件三个方法。放下事件是最关键的需要从 event.mimeData() 里取出 url 列表再逐个判断扩展名。def dropEvent(self, event): files [u.toLocalFile() for u in event.mimeData().urls()] valid_ext {.jpg, .jpeg, .png, .bmp, .webp} if self.mode img2pdf else {.pdf} valid_files [f for f in files if Path(f).suffix.lower() in valid_ext] if valid_files: self.add_files(valid_files) event.accept()拖拽进拖拽区的时候界面会改变边框颜色给用户一个视觉反馈这个效果通过重写 dragEnterEvent 实现配合 setStyleSheet 动态调整 QLabel 的样式技术含量不高但很提升体验。另外文件如果重复添加要做过去重处理避免同一个文件在列表里出现两次。4.4 进度反馈从“干等着”到“心里有数”进度反馈这块图片转 PDF 相对简单因为 img2pdf 转换速度极快几十张图片也就一两秒进度条可以按图片数量均匀推进。PDF 转 Word 则必须要按页面推进因为真正耗时的是逐页解析和重排。通过 pdf2docx 的 Converter 对象我可以在转换循环中控制起始页和结束页然后按每页计算进度百分比。pdf2docx 也支持传入一个自定义的转换进度回调但我测试下来更稳定的方式还是自己分批调用 convert 方法每次只转 10 页然后更新进度。日志区的输出也是点状的信息比如“已读取 PDF 文件共 320 页”“正在转换第 40 页当前表格识别中”这些运行时信息极大缓解了等待焦虑。5. 打包分发把 Python 程序变成朋友也能用的双击应用5.1 PyInstaller 配置一次打包成功的关键选项工具写完之后最后一个核心环节是打包分发。PySide6 程序用 PyInstaller 打包如果直接裸敲 pyinstaller main.py大概率会得到一个能运行但异常臃肿、甚至缺依赖的文件。这里有几个必须注意的配置项。首先是 --windowed 参数避免终端黑窗口弹出对图形界面程序来说这是标配。其次要用 --noconfirm 覆盖上次产物。虚拟环境是必须的最好新建一个干净的 venv只装运行所需的几个库这样可以避免把开发环境里的乱七八糟的包都打进去。我个人更推荐 onedir 模式而不是 onefile 模式因为 onefile 每次启动都要解压到临时目录启动慢且容易被杀毒软件误报而 onedir 模式启动及时、排错直观。对外分发时把整个文件夹压缩为 zip 即可。如果实在追求单文件体验可以保留 onefile 构建作为备选。pyinstaller --noconfirm --windowed --name FileTool --clean main.py5.2 体积和依赖的精简从 300MB 到 80MB 的优化PySide6 天然是个大块头直接打包出来的体积动辄两三百兆其中一大部分是各种用不到的 Qt 模块。优化瘦身主要做两件事。第一是在 spec 文件里排除不需要的 Qt 模块。PyInstaller 的 Analysis 调用中可以添加 excludes 参数把 QtWebEngine、QtMultimedia、Qt3D 这些和本工具无关的模块排除掉。第二是删除 PySide6 自带的翻译文件和不需要的插件尤其是 platforms 里只需要 windows 平台插件imageformats 里也只需保留常用的几种其他格式插件统统移除。瘦身之后我的最终版本从三百多兆降到了八十多兆对于自带 GUI 框架的 Python 程序来说这个体积已经很理想了。如果你基础库安装得更精简还能再压一些但没必要过度追求尺寸稳定可靠才是第一位。5.3 运行环境的隐性依赖VC 运行库和 Windows 平台细节打包完还有一个容易忽略的细节目标机器上如果没有安装对应的 VC 运行库程序启动时会提示“找不到 VCRUNTIME140.dll”体验大打折扣。虽然大部分 Windows 系统默认都有这个库但干净环境下依然可能缺失。处理方式是顺手在分发文件夹里附上一个说明文档标注如果启动报错先安装 VC 运行库。更省心的方案是打包时把 vcruntime140.dll 等文件一起放进 dist 目录不过这个操作需要小心对应架构避免 x64 和 x86 混淆如果打包机器的 Python 是 64 位就放 64 位版本的运行库文件。还有一个与路径相关的坑程序名和输出文件路径如果包含中文PySide6 在某些旧版上可能会因为编码解析问题出现异常。我在代码里统一用 pathlib.Path 处理路径并对输出文件名做了安全过滤替换掉 Windows 不允许的字符这样即使用户把工具放在“下载”目录也不会出问题。6. 回顾与随想这类小工具值得做但要把边界划清楚整个项目从动手到最后打包完成大概花了一个周末的时间量不大但把 PySide6 的线程、信号槽、拖拽、打包这些最常见的实战点全过了一遍。回头看最有价值的反而不是代码本身而是提前想清楚了产品边界这个工具只做图片转 PDF 和 PDF 转 Word 两件事扫一眼界面就知道怎么用不追求万能转换反而更容易被信任。如果你也想做一个类似的工具我的建议是先把核心功能跑通界面放在第二位。先写好一个命令行版本确认转换效果符合预期再套上 PySide6 的壳。遇到问题优先查官方文档和源码网上很多博客是基于旧版本写的API 已经变了照抄反而会耽误时间。另外一个小技巧是代码里有很多可复用的函数比如文件排序、路径处理、格式验证我全部独立成模块。后续如果再扩展 PDF 合并、PDF 拆分或者图片压缩新代码能直接复用推倒重来的成本很低。工具类应用最重要的品质是可维护因为你永远不知道下次需求会加什么。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。