大家电脑里肯定都囤着一堆PDF文件:下载的电子书、扫描的合同、从网页导出的资料、截图拼成的长图……真到要用的时候,合并、拆分、转格式的需求就冒出来了。我之前也都是临时找在线工具,但用多了之后发现两个问题:一是广告和页数限制让人抓狂,二是把文件传到别人的服务器上,心里总是没底。所以去年下半年,我干脆自己写了一个PDF工具箱,覆盖合并、拆分、图片转PDF这三个最高频的场景,从零到能用花了大概一个周末,迭代半年多之后,它已经成了我电脑里的常驻工具。
这篇文章就把这个工具箱从选型、实现、踩坑到打包分享的全过程聊一遍。如果你也想自己搞一个PDF小工具,或者对PDF处理背后的原理感兴趣,可以按这篇文章的思路来,代码量不大,但每一段都经过实际使用验证,直接抄作业就行。
1. 为什么需要自建一个PDF工具箱:个人使用场景复盘
1.1 从一次帮家人整理老照片说起
前年家里大扫除,翻出一大堆纸质相册、老合同、手写笔记,我弟提议全部拍照存档发给亲戚。手机拍了差不多两百张照片,问题来了:怎么把这些JPG快速变成几个PDF文件?
当时第一反应是搜在线工具。试了两三个,问题立刻暴露出来:免费版限制上传数量,照片超过20张就得开会员;有的工具给输出PDF加了满屏水印,清晰度也被压缩得厉害;最关键的是,这些文件要在网页上先上传,而里面有不少证件、合同这类敏感材料,传上去之后总让人不踏实。
折腾了一晚上,最后我用Python在本地把两百多张照片转成了三个PDF,整个过程也就十几秒。从那天起我就意识到,很多"临时需求"其实用一个小脚本就能解决,只是大多数时候我们习惯了去网上找现成工具,没想过自己写一个。
1.2 在线工具的隐性问题:页数限制、广告和文件去了哪
后来用在线工具的次数越多,越觉得不对劲。这里不是说在线工具一无是处,偶尔救急确实方便,但长期用下来有几个绕不开的坎:
- 限制多:免费版通常有页数限制、文件大小限制、转换次数限制,高峰期还要排队。
- 广告和诱导:很多工具页面塞满广告,下载按钮和真实入口长得一模一样,点错就是全家桶。
- 隐私风险:PDF上传到别人服务器,服务商到底存不存储、用不用来训练模型,你完全不知道。
- 转换质量不稳定:同一张图,不同网站的压缩算法效果差异很大,有的能明显看到锯齿和色块。
所以我的结论是:本地处理是刚需。尤其对于经常处理合同、扫描件、资料归档的人,文件不出本机这个特性本身就价值极高。
1.3 工具边界:只做合并、拆分、图片转PDF这三件事
做工具最忌讳一上来就想做成万能的瑞士军刀。我当时定的边界非常明确:第一版只做合并、拆分、图片转PDF,这三件事是我和身边朋友最高频的需求,而且逻辑简单、不容易出幺蛾子。
合并:把几十个零散的PDF合成一个,用于资料归档、材料提交。 拆分:从一本电子书里提取某一章,从合同扫描件里抽出一页发给对方。 图片转PDF:把一堆照片、扫描件变成PDF,保持顺序和画质。
没做OCR、没做加密解密、没做水印,这些后面再说。把高频场景做顺手,比做大而全重要得多,这个原则一直保留到现在。
2. 技术选型:PyMuPDF为什么是我的最终选择
2.1 四个主流库的横向对比
先别急着开写,我把为什么选PyMuPDF这件事说清楚,因为这个决定会直接影响后面所有代码的写法。目前Python生态里处理PDF的库,常用的有这么几个:
| 库 | 合并/拆分 | 图片渲染能力 | API上手难度 | 依赖体积 | 维护状况 |
|---|---|---|---|---|---|
| pypdf | 强 | 弱,基本不做渲染 | 低,接口直观 | 很小 | 活跃 |
| pypdfium2 | 中 | 强,基于PDFium | 中高,偏底层 | 中等 | 活跃 |
| PyMuPDF | 强 | 强,基于MuPDF | 低,文档丰富 | 较大 | 非常活跃 |
| PyPDF4等 | 中 | 无 | 低 | 小 | 基本不维护 |
pypdf是很多教程的首选,纯Python实现,安装包只有几百KB,合并拆分几十行就能跑通。但它的短板在于不擅长渲染,如果你要做图片转PDF、提取页面缩略图、按页面尺寸精确控制这种活,它就会比较别扭。pypdfium2渲染能力不错,不过API风格偏底层,合并拆分这种操作要写的样板代码不少。PyMuPDF是二者之间的平衡点:合并拆分简单直接,渲染图像又快又稳定,一个库覆盖了我所有的需求。
2.2 "一个库搞定所有"比"多个库各管一段"好在哪
我最怕的项目是那种"合并用一个库、渲染用另一个库、OCR再挂一个库"的组合,每个库都有自己的版本依赖和API风格,每次升级都可能踩雷。PyMuPDF吸引我的地方在于,它底层是C语言写的MuPDF,渲染引擎非常成熟,对PDF规范的支持相当完整,同时上层API又做得简洁,页面操作基本围绕doc和page这两个核心对象展开。
包体积大是它唯一的痛点,pip安装时下载几十MB是常态,打包成exe之后也会明显变大。但换个角度看,程序体积大总比代码逻辑复杂、依赖链混乱要好。个人工具,稳定优先,这几MB的差距完全可以接受。
2.3 环境准备:安装PyMuPDF时的几个注意点
安装本身很简单:
pip install PyMuPDF有几个容易被坑的点值得单独提一下:
- 包名是PyMuPDF,但代码里导入的是
import fitz。这是历史原因,PyMuPDF早期直接用MuPDF的捆绑名fitz,沿用至今。很多人在这一步卡住,装了包却找不到fitz模块,其实只是还没import。 - 建议在虚拟环境里安装,不要直接往系统Python里塞。我自己吃过亏,系统Python的site-packages被各种版本搞乱了,后来一律用venv。
- Python版本不要太旧,PyMuPDF官方会跟上新版Python,老版本Python装不上新版本PyMuPDF。用3.9以上基本没坑。
3. 三个核心模块的实现思路与关键代码
3.1 合并PDF:一个insert_pdf就能完成,但排序和清理不能省
合并PDF核心就是一个方法:insert_pdf。它的作用是把另一个PDF文档的页面整体插入到当前文档中,支持指定页码范围from_page和to_page,也支持指定插入位置start_at。整个合并代码其实很短:
import fitz from pathlib import Path import re def natural_sort_key(name: str) -> list: """自然排序:'10.pdf' 应该排在 '2.pdf' 后面, 而不是按字符串顺序排到前面。""" return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', name)] def merge_pdfs(pdf_paths: list[str], output_path: str): doc = fitz.open() # 空文档,作为合并后的容器 for path in pdf_paths: p = Path(path) if not p.exists(): print(f"跳过:{path} 不存在") continue try: src = fitz.open(str(p)) except Exception as e: print(f"打开失败:{path},错误:{e}") continue doc.insert_pdf(src) src.close() doc.save(output_path, garbage=3, deflate=True) doc.close()代码看着简单,但里面有两个容易翻车的细节。第一是排序,文件管理器里的排序是按字符串来的,第1章.pdf、第10章.pdf、第2章.pdf这三个文件按字符串排序会变成第1章、第10章、第2章,合并出来的PDF顺序完全错乱。所以我在合并前强制做一次自然排序,把文件名里的数字提取出来转成整型再比较。数字章节、带编号的扫描件这种场景特别容易踩这个坑。
第二个是保存参数,garbage=3会清理合并过程中产生的孤立对象和未引用数据,deflate=True会压缩内部的数据流。这两个参数组合下来,能避免合并后的文件莫名膨胀,也能让某些原本有很多冗余资源的PDF瘦身。实测合并20个左右的文件,输出体积基本等于所有源文件体积之和,不会有意外放大。
3.2 拆分PDF:按页提取和按厚度切分的两种写法
拆分比合并稍微灵活一点,实际场景一般分三种:提取指定页码、提取一个连续页码范围、把整本PDF按固定厚度切成多份。
提取指定页面:
def extract_pages(input_pdf: str, start: int, end: int | None, output_pdf: str): """从第 start 页到第 end 页提取并保存。 start/end 都按人类习惯从 1 计数。""" src = fitz.open(input_pdf) total = len(src) if start < 1 or start > total: print(f"起始页码无效:{start},文档共 {total} 页") return if end is None or end > total: end = total out = fitz.open() # PyMuPDF 内部页码从 0 开始,所以这里要 -1 out.insert_pdf(src, from_page=start - 1, to_page=end - 1) out.save(output_pdf, garbage=3, deflate=True) out.close() src.close()按固定厚度切分,比如每10页生成一个文件:
def split_by_chunk(input_pdf: str, chunk_size: int, output_dir: str): src = fitz.open(input_pdf) total = len(src) stem = Path(input_pdf).stem os.makedirs(output_dir, exist_ok=True) index = 1 for start in range(1, total + 1, chunk_size): end = min(start + chunk_size - 1, total) out = fitz.open() out.insert_pdf(src, from_page=start - 1, to_page=end - 1) out_name = os.path.join(output_dir, f"{stem}_part_{index:02d}.pdf") out.save(out_name, garbage=3, deflate=True) out.close() index += 1 src.close()拆分这块要强调页码偏移问题。人类眼里的第1页通常是文档正文第一页,但很多电子书前面还带着封面、扉页、目录页,实际页和PDF内部页可能会差好几页。所以我做界面的时候特意把文档总页数显示出来,并在拆分前提醒用户确认页码范围,这个交互细节后面还会再提。
3.3 图片转PDF:两种方案,一套代码解决透明通道和EXIF方向
图片转PDF是三个功能里最容易低估难度的一个。如果只是"把图片塞进PDF",用PIL几行就完事:
from PIL import Image, ImageOps def images_to_pdf_simple(image_paths: list[str], output_pdf: str): imgs = [] for path in image_paths: img = Image.open(path) img = ImageOps.exif_transpose(img) # 解决手机拍摄照片方向错乱 if img.mode in ("RGBA", "LA", "P"): rgb = Image.new("RGB", img.size, "white") if img.mode == "P": img = img.convert("RGB") else: rgb.paste(img, mask=img.split()[-1]) img = rgb else: img = img.convert("RGB") imgs.append(img) if imgs: imgs[0].save(output_pdf, save_all=True, append_images=imgs[1:], resolution=150.0)这条路线的优点是简单,PIL的save方法原生支持多页PDF输出,完全不用碰PDF底层API。缺点是控制力弱:页面尺寸、图片摆放位置、每页对应多大物理尺寸,你说了不算。
所以我的工具箱实际用的是PyMuPDF方案:
import fitz from PIL import Image, ImageOps A4_WIDTH = 595 # 72dpi 下 A4 宽度,单位是点 A4_HEIGHT = 842 # 72dpi 下 A4 高度 def images_to_pdf_pymupdf(image_paths: list[str], output_pdf: str, dpi=150, force_a4=False): doc = fitz.open() for path in image_paths: img = Image.open(path) # 方向修正必须在转 RGB 之前做,顺序不能反 img = ImageOps.exif_transpose(img) # 统一转成 RGB,避免 RGBA 出现黑底 if img.mode in ("RGBA", "LA", "P"): background = Image.new("RGB", img.size, "white") if "A" in img.mode: background.paste(img, mask=img.split()[-1]) else: background.paste(img) img = background else: img = img.convert("RGB") if force_a4: # 等比缩放,留 5% 边距,居中放置 scale = min(A4_WIDTH / img.width, A4_HEIGHT / img.height) * 0.95 w = img.width * scale h = img.height * scale rect = fitz.Rect((A4_WIDTH - w) / 2, (A4_HEIGHT - h) / 2, (A4_WIDTH + w) / 2, (A4_HEIGHT + h) / 2) page = doc.new_page(width=A4_WIDTH, height=A4_HEIGHT) page.insert_image(rect, filename=path, keep_proportion=True) else: # 按图片实际尺寸和指定 DPI 换算页面大小 width_pt = img.width * 72.0 / dpi height_pt = img.height * 72.0 / dpi page = doc.new_page(width=width_pt, height=height_pt) page.insert_image(page.rect, filename=path, keep_proportion=True) doc.save(output_pdf, garbage=3, deflate=True) doc.close()这里我解释一下几个关键点:
EXIF方向问题。手机拍的JPG会内置一个旋转标记,比如"拍摄时手机是横着的,但照片实际应该竖着显示"。如果用Image.open直接读,拿到的像素还是原始的,直接塞进PDF就会看到照片横着。ImageOps.exif_transpose()会把旋转应用到像素本身上,这一步必须在模式转换之前做。
透明通道黑底问题。PDF本身不支持透明度,如果你直接把带Alpha通道的PNG插进PDF,透明区域渲染出来通常是黑色。解决办法是把透明像素先贴到白色背景上。我见过很多人卡在这里,怎么调都调不好,其实就是没做这层预处理。
页面尺寸换算。PDF的内部单位是点(point),1英寸等于72点。一张3000像素宽的图片,按150DPI显示,物理宽度就是3000乘以72再除以150,等于1440点。这个换算逻辑决定了图片在PDF里的实际打印尺寸。工具里我默认提供两个模式,一个是按图片原始尺寸输出,一个是统一压成A4,扫描件和资料归档强烈建议用A4模式,输出物打印出来规格统一,比每页尺寸都不一样舒服得多。
3.4 输出参数为什么建议带garbage和deflate
细心的读者应该注意到了,我每个save操作都带着garbage=3, deflate=True。这里单独说下背后的机制。
PDF文件内部是一个对象系统,页面、字体、图像、内容流都以对象形式存在,对象之间互相引用。合并多个PDF时,insert_pdf会把所有对象都复制进新文档,但其中有不少是未被引用的孤立对象,比如某些编辑软件留下的废弃资源。garbage参数就负责清理这类对象,取值从0到4,数字越大清理越激进,其中garbage=3是一个性能和效果平衡的选择,garbage=4除了清理对象还会做更多重构,但速度会变慢。
deflate=True则是对文档内部的数据流做压缩,包括内容流和图像数据。需要说明的是,一些已经压缩过的内容流不会重复压缩,所以它不会明显降低文件体积,但能保证合并过程不意外产生冗余。
在实际使用中,我默认都用这两个参数,除非遇到体积超大、保存耗时明显变长的情况,才会把garbage降级到1或2。
4. 界面设计:先用命令行跑通,再套GUI壳
4.1 为什么GUI只用tkinter而不是Electron之类
我的开发顺序是先写命令行版本,把所有功能参数验证通过之后,再考虑界面。这个习惯让我少走了很多弯路,因为GUI调试会引入大量和核心逻辑无关的问题,比如布局、事件循环、线程阻塞。
选tkinter很简单:Python自带,不需要额外装包,写出来的界面虽然朴素但是能在Windows、macOS、Linux上直接跑。对比一下其他方案,Electron要拉一个Node.js的依赖链,PyQt的学习成本和许可证问题也需要考虑,我做的是给自己和朋友用的工具,目的是解决问题而不是追求界面的炫酷。
4.2 界面布局与操作流程
界面布局我走了最朴素的三段式:
- 顶部:模式选择,用下拉框切换"合并PDF""拆分PDF""图片转PDF"。
- 中部:文件列表,支持多选添加、单选删除、清空列表。列表上显示文件名和页面数,方便确认顺序。
- 底部:输出路径选择、开始按钮、状态栏和日志区。
合并模式下,输出路径用asksaveasfilename让用户选;拆分模式需要额外输入页码范围或每份页数;图片转PDF需要选择是否强制A4。功能按钮根据当前模式动态切换,避免用户填错参数。
代码骨架长这样:
import tkinter as tk from tkinter import ttk, filedialog, messagebox import threading class PDFToolApp(tk.Tk): def __init__(self): super().__init__() self.title("PDF 工具箱") self.geometry("720x520") self.mode_var = tk.StringVar(value="merge") self.file_listbox = tk.Listbox(self, selectmode=tk.EXTENDED) # ... 省略布局代码 def add_files(self): files = filedialog.askopenfilenames( filetypes=[("PDF 和图片", "*.pdf *.jpg *.jpeg *.png *.bmp *.tif *.tiff")]) for f in files: self.file_listbox.insert(tk.END, f) def on_start(self): mode = self.mode_var.get() files = list(self.file_listbox.get(0, tk.END)) if mode == "merge": if len(files) < 2: messagebox.showwarning("提示", "请至少选择两个 PDF 文件") return output = filedialog.asksaveasfilename( defaultextension=".pdf", filetypes=[("PDF 文件", "*.pdf")]) if not output: return self.set_busy(True) threading.Thread( target=self.run_task, args=(merge_pdfs, files, output), daemon=True).start()这里有个交互细节值得说:开始任务后立刻禁用所有按钮,防止用户在后台运行时再次点击触发重复任务。日志区负责显示处理进度,每个文件处理完成打印一行结果。
4.3 多线程防卡死:一个容易被新手忽略的问题
tkinter是单线程事件模型,所有UI更新必须在主线程完成。如果你在按钮回调里执行耗时操作,界面会完全卡住,Windows甚至会弹"程序无响应"的提示。解决办法是把耗时任务丢到后台线程,任务完成后通过队列把结果传回主线程更新UI:
import queue class PDFToolApp(tk.Tk): def __init__(self): self.msg_queue = queue.Queue() # 每 100ms 检查一次队列 self.after(100, self.process_queue) def process_queue(self): try: while True: msg = self.msg_queue.get_nowait() self.status_label.config(text=msg) except queue.Empty: pass self.after(100, self.process_queue) def run_task(self, func, *args): try: func(*args) self.msg_queue.put("任务完成") except Exception as e: self.msg_queue.put(f"任务失败:{e}") finally: self.after(0, lambda: self.set_busy(False))这种轮询队列的方式比直接跨线程调用UI方法安全得多,也不会因为操作完就立刻self.destroy()这种操作引发奇怪的异常。
界面这块我的原则是够用就好。文件拖拽我没有用tkinterdnd2之类的库,因为增加了额外依赖,还会在某些系统上遇到兼容问题,现阶段的"添加文件"按钮已经完全满足需求。
5. 实操踩坑记录:编码、内存、损坏PDF和黑底图片
5.1 中文路径和文件名:现代库的表现与历史遗留坑
中文路径是很多PDF工具翻车的高发区。PyMuPDF在这方面做得相当好,在Windows上用中文路径打开、保存基本不会有问题,因为底层接收的是Unicode字符串,不再经过本地代码页转换。
但有一个情况需要注意:如果PDF是某些老软件生成的,文件内部metadata可能还是GBK编码,用PyMuPDF读取时能正常打开,但打印文件名或标题会出现乱码。这属于PDF内部metadata不规范导致的问题,不影响页面内容。我的处理方案是显示文件名时只取Path(path).name,不依赖PDF内部标题;如果确实需要metadata,就包一层try/except,读取失败时退回文件名。
5.2 合并几百MB大文件时的内存控制
合并多个大PDF是内存大户。insert_pdf会把源文档的所有对象复制到目标doc的内存空间,合并几百MB的文件时,内存占用轻松超过1GB。这个问题在32位Python下尤其致命,直接MemoryError崩溃。
我实测之后给自己定了几条经验规则:
- 一次合并的文件数控制在合理范围内,四十个以内的普通PDF没什么问题。
- 如果遇到单个文件特别大(几百MB),先单独处理这个文件,不要和一堆小文件混在一起。
- 保存时用
garbage=3在合并过程中同步清理孤儿对象,能在一定程度上降低内存峰值。 - 真遇到几千个文件的极端场景,程序内做分批合并,每批几十个,生成临时文件,最后再把临时文件合并成最终结果。逻辑不复杂,但能显著降低峰值内存。
日常使用中,最稳妥的合并方式是"边合并边保存",不要把所有文件全部加载完再保存,PyMuPDF的API设计就是逐文件insert_pdf,天然支持这种流式处理,你只需要注意别在doc里囤太多无用对象就行。
5.3 损坏PDF的"抢救"思路
损坏PDF是个躲不开的话题。下载了一半的PDF、某些国产软件导出的"伪PDF"、扫描仪生成的结构残缺文件,这三种我都遇到过。
PyMuPDF基于MuPDF引擎,对容错性处理得不错,很多pypdf打不开的文件它能打开。但真遇到打开就抛异常的情况,我的"抢救"流程是这样的:
- 先尝试正常打开,抓到异常后打印文件名,不直接中断整个任务。
- 如果打开成功但页面数据异常,试试
doc.tobytes(garbage=4, clean=True),这会触发MuPDF的完整解析和重写,等于把文件重新构建一遍,有时候能救回来。 - 如果PyMuPDF完全打不开,最简单的保底方案是用Chrome之类的浏览器打开,再通过打印功能生成一个新PDF。这个方法看起来很土,但可靠性非常高,因为浏览器渲染引擎对损坏PDF的容忍度通常高于PDF解析库。
这个"抢救"逻辑我强烈建议做进工具里:批量合并时遇到一个坏文件,不要直接崩掉整个任务,而是记录下来、继续处理其他文件,最后单独提示用户哪个文件出了问题。用户感知会好很多。
5.4 图片转PDF黑底问题的根源
前面代码里已经写了解决方案,这里再详细说下根源。PNG或者某些TIFF图片可能带Alpha通道,表现为RGBA或LA模式。PDF格式不支持透明度,插入图片时会有一个底色填充,PyMuPDF默认填充黑色。黑色底在证件扫描件上极其难看,看起来像反色一样。
我的处理方案是把透明像素贴到白色背景上。具体逻辑是:新建一张白色RGB图,再用原图的Alpha通道作为mask把原图贴上去。这个操作要在PIL层面完成,不要想着传到PDF里再处理。另外一个相关坑是调色板模式的PNG,如果包含透明色,先convert("RGBA")然后再走同一套白底流程,顺序一定不能乱。
5.5 页码错位的排查方法
拆分功能遇到最多的用户反馈是"拆出来页码不对"。排查之后发现绝大多数情况不是程序bug,而是对PDF页数理解有偏差。
一本电子书通常包含封面页、扉页、版权页、目录页,这些都会占页码。用户以为的第5页和PDF内部第5页可能差了两三页。我的解决方案是做拆分前先显示总页数,让用户直接输入PDF内部页码,并加了一行提示文字"页码从1开始,输入的是PDF内部页数,不是书页上的印刷页码"。同时在日志区输出实际提取的页范围,方便用户核对。
这一点不算技术难点,但体现了工具设计的细心程度。很多工具不是不好用,是没把用户容易误解的地方handle好。
6. 打包成exe分享给朋友:PyInstaller使用经验
6.1 打包命令与模式选择
功能稳定之后,我开始把它打包成exe发给朋友用。用的PyInstaller,命令很简单:
pip install pyinstaller pyinstaller --onefile --windowed --name PDFTool --icon=app.ico main.py--onefile会生成单个exe文件,方便分发;--windowed在Windows下隐藏控制台窗口,不然每次运行都会弹一个黑色cmd框;--name指定输出名称;--icon是可选的应用图标。
这里有个取舍要清楚:--onefile每次启动都要把程序解压到临时目录,exe体积越大,启动越慢。PyMuPDF的DLL本身就大,加上PIL和tkinter,打包出来常常在80MB到100MB之间,双击之后要等一两秒才会弹出窗口。如果实在无法接受这种启动延迟,可以改用--onedir模式,生成一个目录,启动速度快很多,缺点是分发时要压缩成zip包。
6.2 体积、杀毒误报和启动速度的取舍
打包完成后最头疼的就是杀毒软件误报。PyInstaller生成的exe有自解压机制,启发式杀毒引擎经常把它当木马处理,这是PyInstaller生态的老问题,不是你的程序有问题。
我的处理经验:
- 优先用
--onedir模式,误报概率相对低一些,还能加速启动。 - 发布前用
--clean参数重新构建,清掉缓存,能减少一部分奇怪问题。 - 如果还需要onefile,建议把exe压缩成zip发布,并附带SHA256校验值,方便用户核对文件完整性。
- 尽量用一个简单的数字签名工具签个名,虽然个人签名不一定有效,但能降低部分杀毒软件的拦截概率。
另外一个细节是360之类国内杀毒软件对Python打包程序更敏感,我自己打包的程序也被误报过好几次,只能跟朋友解释加白名单。这是个人免费工具通用的命运,不用太纠结。
6.3 发布前的自测清单
打包之后的自测一定不能省,我分享一份自己的测试清单:
- 合并功能:选择三个PDF,文件名分别带中文和数字序号,确认输出顺序正确。
- 拆分功能:输入页码范围,确认输出文件页数正确、内容完整。
- 图片转PDF:用一张手机拍照的JPG(注意选有EXIF方向信息的),确认方向正确;用一张带透明通道的PNG,确认底色是白色而不是黑色。
- 路径测试:把PDF放到一个包含中文和空格的路径下,确认能正常打开保存。
- 双击测试:在全新环境双击exe运行,不做任何额外配置,确认功能完整。
这套流程我每次打包后都会跑一遍,耗时不长,但能避免把明显的问题发给朋友。
7. 用了半年后的真实体会与下一个版本想加的功能
7.1 哪个功能用得最多
工具稳定跑了半年多,我自己统计了一下使用频率:合并用得最多,几乎占了一半,主要是整理电子书章节和材料归档;图片转PDF大概占三成,处理扫描件和照片的时候特别好用,尤其是A4统一模式,输出效果比很多在线工具都干净;拆分用得最少,但每次都是挺紧急的需求,比如从合同里抽出一页发出去,从资料里提取指定章节打印。
这个数据和我最初设想的高频场景基本一致,也验证了"先做三个核心功能"的方向是对的。工具不需要大而全,只要解决真正高频的痛点,自己就会愿意天天用它。
7.2 用户反馈里最频繁的需求
把工具给几个朋友用之后,收集到的需求反馈集中在几类:
一是加密PDF的处理。朋友经常收到带打开密码的PDF文件,希望工具能解密后合并或拆分。这个技术上不难,PyMuPDF的doc.authenticate(password)就能处理。我计划在下一版里加一个"打开密码"输入框,如果检测到PDF需要密码就让用户输入。
二是水印需求。有人想把内部资料加上"仅供预览"水印再发出去。用PyMuPDF的page.insert_text就能实现,逻辑也不复杂,但要注意水印位置、角度、透明度这些细节。
三是压缩PDF。这个需求比预想的更强烈,很多人拍完照片转成PDF之后体积很大,发微信不方便。压缩的思路是重新渲染低分辨率图片再生成新PDF,但这样会丢失文字层,更适合扫描件。文字型PDF不建议用这种方式压缩,我有专门的处理方向,后面再聊。
7.3 基于实际需求的功能扩展计划
我的规划是先解决加密PDF和水印这两个明确的高频需求,因为实现成本低、收益直观。压缩功能会更谨慎,打算做成独立模块,提供"扫描件模式"和"文字模式"两种选项,避免一刀切破坏文档质量。
OCR是远期计划,真正要做的话我倾向于用Tesseract或者接一个本地OCR引擎,让扫描件可以全文搜索。不过这属于较大的工程,短期不会动手,因为合并、拆分、图片转PDF这些基础操作已经能覆盖绝大多数日常场景了。
最后再分享一个小技巧:如果你把这个工具箱的习惯扩展到其他常用软件上,可以用Everything这类全局搜索工具快速定位文件,然后把操作流程固定下来——我现在的日常路径是Everything找到文件、拖进收藏的快捷方式、运行脚本、收工。工具的价值不在于功能多炫,而在于它切切实实节省了每天反复操作的时间,这个PDF工具箱从写下第一行代码到现在,已经帮我省了不知道多少个下午。