ARTICLE · 1147612
用 Python 做了个 PDF 工具箱:合并、拆分、水印、加密,一个 exe 全搞定
合并能拖排序、能保留书签;水印支持中文平铺;加密用 AES-256;还有无损压缩和文字提取。打包成 45 MB 的单 exe,不装 Acrobat、不上传文件,双击就能用。
一、为什么自己写?
日常办公里 PDF 的需求特别集中:把几个扫描件合并成一个、把合同拆分成单页、给稿件加水印、给敏感文件加密、从 PDF 里抠文字……在线工具要上传文件、有页数限制、非会员排队;Acrobat 又大又贵。
这些操作的底层其实都不复杂,pypdf、reportlab、pdfplumber、pikepdf 几个库就能覆盖。于是我把它们整合成了一个三标签页的桌面工具。这篇文章分享几个真正写起来有讲究的点。
二、功能一览

合并:多选 / 整个文件夹加入,拖拽调整顺序,保留书签目录;
拆分:每页一个文件 / 按范围(1-5, 6-10)/ 提取指定页(1,3,5-8);
工具箱:加密(AES-256)、解除加密、旋转页面、中文平铺水印、提取文字(UTF-8 TXT)、无损压缩;
加密 PDF 任何操作前弹窗输密码(最多 3 次);
输出同名冲突自动加
_1/_2,绝不静默覆盖;页码越界给中文提示。
三、技术点 1:合并保留书签,一个参数的差别
新手最常见的合并写法是逐页 add_page,但这样书签大纲全丢。pypdf 的 PdfWriter.append() 专门处理这件事:
writer = PdfWriter()forpath, pwinitems:reader = open_reader(path, pw)writer.append(reader, import_outline=keep_outline) # 关键:导入书签大纲withopen(out_path, "wb") asf:writer.write(f)
import_outline=True 会把每个 PDF 的书签(目录)一并合并进新文件,多个文档的书签还会自动按文件名分组成父节点。就这一个参数,决定了合并出来的 PDF 有没有"灵魂目录"。
四、技术点 2:纯 Canvas 手绘可拖拽排序的文件列表
合并 PDF 时调整顺序是刚需。tkinter 的 Treeview 不支持行拖拽排序,我的做法是用 Canvas 手绘每一行卡片,自己实现命中检测和拖拽换位:
每行是一个圆角矩形 + 文件名 + 页数徽标 + 右侧 ✕;
按下时记录命中的行索引,拖动时该行半透明跟随鼠标,其余行实时上下腾挪;
关键细节:✕ 关闭按钮用独立的 tag 绘制,点击命中检测时先判断是否点在 ✕ 区域——否则点删除会同时触发拖拽。
ifhit_close_icon(x, y):remove_row(idx) # 点 ✕:删除,不启动拖拽returnself._drag_index = hit_row# 点行体:才开始拖拽
交互细节决定了一个工具"像不像正经软件"。
五、技术点 3:加密 PDF 的密码必须在主线程收
这是多线程 GUI 里非常经典的坑。文件处理在工作线程跑,但工作线程不能弹 tkinter 对话框(tkinter 不是线程安全的,跨线程弹窗可能死锁或在某些 Windows 版本上直接无响应)。
正确姿势:工作线程开始前,先在主线程把所有需要的密码收集完,再把密码字符串传给工作线程:
# 主线程:逐个检查待处理文件是否加密,加密就弹窗(最多重试 3 次)passwords = {}forpathinfiles:reader = PdfReader(str(path))ifreader.is_encrypted:forattemptinrange(3):pw = simpledialog.askstring("PDF 已加密", f"请输入 {path.name} 的密码:",show="●", parent=self)try:reader.decrypt(pw)passwords[path] = pwbreakexceptException:continue# 密码错,再来一次# 工作线程:只使用已收集好的密码,全程不碰任何窗口控件threading.Thread(target=merge_worker, args=(files, passwords)).start()
加密/解密本身用 pypdf 一行完成,AES-256:
writer.encrypt(user_password=pw, owner_password=pw, algorithm="AES-256")六、技术点 4:中文平铺水印——PIL 贴片 + reportlab 叠加
水印的需求看似简单,坑不少:reportlab 默认字体不支持中文、水印要平铺整页、要能调角度和透明度、一个 PDF 里可能混着不同尺寸的页面。
我的方案是三层管线:
PIL 画单个水印贴片:用
msyh.ttc(微软雅黑)渲染文字,旋转,再通过 alpha 通道乘以不透明度;贴片平铺成整页 PNG:2 倍超采样保证打印清晰,奇偶行错位排列更美观;
reportlab 生成透明覆盖页,再用 pypdf 逐页合并。
def_watermark_tile(text, font_px, angle, opacity):font = ImageFont.truetype("msyh.ttc", font_px)tile = Image.new("RGBA", size, (0, 0, 0, 0))ImageDraw.Draw(tile).text((0, 0), text, font=font, fill=(110, 120, 138, 255))tile = tile.rotate(angle, expand=True, resample=Image.BICUBIC)tile.putalpha(tile.getchannel("A").point(lambdav: int(v*opacity))) # 透明度returntile
还有一个容易忽略的点:按页面尺寸分组生成覆盖页——同一个 PDF 里横版竖版、A4/A3 混排时,一种尺寸的水印图不能硬套到所有页上,否则边缘会错位。
因为水印文字是以图片形式嵌入的,别人拿到 PDF 也无法直接选中、复制或删除水印文字。
七、技术点 5:页码输入的"中文输入法友好"解析
用户拆分 PDF 时经常输入全角数字 1-5 或中文逗号 1-5,6-10(中文输入法没切回来)。严格解析会一直报错,体验很差。做一次归一化就能兼容:
def_norm_page_text(text):s = (textor"").translate(_FULLWIDTH) # 全角数字 123 → 123forchin",、;":s = s.replace(ch, ",") # 中文逗号/顿号/分号 → ,forchin"-–—〜~~":s = s.replace(ch, "-") # 各种破折号/波浪线 → -returnre.sub(r"\s+", "", s) # 去掉所有空白
解析时再做越界检查、区间颠倒检查、去重排序,错误信息全部中文化("页码 8 超出范围(该文件共 6 页)"),用户一眼就知道怎么改。
八、打包瘦身:45 MB 是怎么抠出来的
PyInstaller 默认会把依赖链上的东西全塞进去,这个项目踩了三个典型的"打包发胖"陷阱:
| 陷阱 | 原因 | 处理 |
|---|---|---|
| 凭空多出约 30 MB | pyperclip 声明了对 PyQt5 的可选依赖,被 hook 误打包 | --exclude-module PyQt5/PySide*,剪贴板改用 tkinter 自带能力 |
| 多出 pygame | pdfminer 的某个模块懒加载 pygame | --exclude-module pygame,提取文字根本用不到 |
| 多出 numpy | Pillow 的类型提示文件里 import numpy(仅类型注解) | --exclude-module numpy |
而 pikepdf 依赖的 lxml、加密用的 cryptography 必须保留,不能乱砍。最终 exe 45 MB,功能完整。
九、写在最后
这个工具箱的代码里,真正调库的部分都很短——大部分工作量花在了"人"的一侧:拖拽排序的手感、密码弹窗的线程归属、水印的中文和混排页面、中文输入法下的页码容错。工具类软件的价值,恰恰在这些库文档不会教你的地方。
完整源码、exe 和使用说明获取方式:公众号后台回复「PDF工具箱」。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连,我们下一个工具见。