ARTICLE · 1074990
Python办公——批量合并 PDF,还自动生成书签
📎 pdf_merge —— 批量合并 PDF,还自动生成书签
报销单、合同、报告、扫描件……十几个 PDF 要拼成一份交上去。在线合并网站怕泄密,Adobe 收费,那就用 20 行 Python 自己搞定。
适用人群
• 财务 / 行政:报销凭证、发票、审批单拼成一份归档 • 老师:多份练习卷、讲义合成一本,方便打印和分发 • HR:应聘材料(简历 + 证书 + 成绩单)合成一份完整档案 • 销售 / 咨询:方案正文 + 报价单 + 案例附件 = 一份交付物 • 任何人:手机扫描出来的一堆单页 PDF,想拼成一个文件 • 在意隐私的人:合同、工资单这类文件,绝对不该上传到在线合并网站
功能简介
| 自动生成书签 | |
| 自然排序 | part2part10 前面(普通排序会搞反) |
--sort mtime) | |
--files a.pdf b.pdf c.pdf | |
生成结果:output/merged.pdf


环境准备
pip install PyPDF2国内镜像:
pip install PyPDF2 -i https://pypi.tuna.tsinghua.edu.cn/simple验证:
python -c "import PyPDF2; print(PyPDF2.__version__)"• Python 3.8+(本脚本在 Python 3.13 + PyPDF2 3.0.1 实测通过) • 纯 Python 库,不需要装任何外部程序
关于 PyPDF2 与 pypdf
PyPDF2 已经改名为 pypdf(3.0.1 是 PyPDF2 的最后一个版本,功能等同)。本脚本用 PyPDF2 是因为它名气大、教程多。想用新名字只改导入行:
from pypdf import PdfReader, PdfWriter # pip install pypdffrom pypdf.errors import PdfReadError其余代码一行都不用改,API 完全一致。
使用方法
cd pdf_mergepython pdf_merge.py实测输出:
========================================================PDF 合并 —— pdf_merge========================================================[输入] ...\pdf_merge\testdata\pdfs[输入] 找到 3 个 PDF,排序方式:name[1/3] 01_封面与目录.pdf —— 2 页(合并后第 1 页起)[2/3] 02_数据明细.pdf —— 3 页(合并后第 3 页起)[3/3] 03_附件与说明.pdf —— 2 页(合并后第 6 页起)--------------------------------------------------------[完成] 合并 3 个文件,共 7 页[完成] 已生成 3 个书签(阅读器左侧目录可点击跳转)[完成] 输出文件:...\output\merged.pdf(38.1 KB)用 Chrome 或 Edge 打开 output/merged.pdf,点左侧的书签图标,能看到「01_封面与目录 / 02_数据明细 / 03_附件与说明」三个可点击条目。
常用参数
# 合并指定目录python pdf_merge.py --indir "D:/报销单" --out "D:/2026年3月报销.pdf"# 按修改时间排序(适合扫描件:按扫描先后顺序拼)python pdf_merge.py --sort mtime# 完全手动控制顺序python pdf_merge.py --files 封面.pdf 正文.pdf 附件.pdf --out 完整报告.pdf# 不要书签(想让合并痕迹不明显时)python pdf_merge.py --no-bookmark--indir | ./testdata/pdfs | |
--out | ./output/merged.pdf | |
--sort | name | namemtime 按修改时间 |
--files | --indir) | |
--no-bookmark |
顺序怎么保证?(实战经验)
最稳的办法是给文件名加数字前缀,像测试素材那样:
01_封面与目录.pdf02_数据明细.pdf03_附件与说明.pdf补零很重要(01 而不是 1),虽然本脚本做了自然排序、1 也能排对,但补零后在资源管理器里看着也是对的,交接给别人不会出错。
核心代码讲解
1. 合并的本质:把页面搬过去
writer = PdfWriter()for path in files: reader = PdfReader(str(path))for page in reader.pages: writer.add_page(page) # 一页一页搬with out_path.open("wb") as f: writer.write(f)就这么简单。PDF 合并不需要重新排版(和 PPT 转 PDF 完全不同),页面对象直接搬过去就行,所以纯 Python 库完全够用,而且无损。
2. 书签:关键是算对起始页
start_index = len(writer.pages) # 搬之前先记住已有多少页for page in reader.pages: writer.add_page(page)writer.add_outline_item(path.stem, start_index) # 指向这份文件的第一页len(writer.pages) 在添加前取值,正好等于新文件第一页的索引(从 0 开始)。所以:第 1 个文件 start_index=0,第 2 个文件(前面有 2 页)start_index=2……打印时 +1 转成人类页码,就是输出里的「合并后第 3 页起」。
path.stem 是不含扩展名的文件名(01_封面与目录.pdf → 01_封面与目录),用它当书签标题最自然。
3. 自然排序:part2 为什么会排到 part10 后面
defnatural_key(path: Path): parts = re.split(r"(\d+)", path.name.lower()) # 按数字段切开,并保留数字return [int(p) if p.isdigit() else p for p in parts]字符串排序是逐字符比的:"part10" 和 "part2" 比到第 5 个字符,'1' < '2',所以 part10 会排在前面 —— 合并出来页序就乱了。
re.split(r"(\d+)", "part10.pdf") 得到 ['part', '10', '.pdf'],把数字段转成 int 后变成 ['part', 10, '.pdf'],再和 ['part', 2, '.pdf'] 比较时就是 10 > 2,顺序正确。
⚠️ 小注意:正则里的括号 (\d+) 必须有,否则 re.split 会把数字丢掉。
4. 加密 PDF:先试试空密码
if reader.is_encrypted:if reader.decrypt("") == 0: # 返回 0 表示密码不对print("有密码保护,无法合并")returnNoneprint("带空密码加密,已自动解开")现实中大量 PDF 是「有加密标记但密码为空」的——比如某些系统导出的发票、设了「禁止编辑」但不禁止打开的文件。decrypt("") 能直接解开这类文件。真有密码的就跳过并提示,不要让整批任务失败。
如果你知道密码,改成 reader.decrypt("你的密码") 即可。
5. 单个文件坏掉不能拖垮整批
for path in files: reader = open_reader(path) # 内部捕获异常,失败返回 Noneif reader isNone:continue# 跳过这个,继续下一个这是批量脚本的基本素养:一个坏苹果不能毁掉一整筐。open_reader() 里把四种常见意外都拦下来了:
PdfReadError | |
PermissionError | |
OSError | |
is_encrypted |
另外还检查了 len(reader.pages) == 0(0 页空 PDF),这种文件合并了也没意义。
6. writer.close() 与 finally
try:with out_path.open("wb") as f: writer.write(f)except PermissionError: ...finally: writer.close() # 无论成功失败都释放资源PdfWriter 持有对源文件的引用,用 finally 确保释放,否则批量处理大量文件时可能出现「文件句柄用尽」或文件被锁住无法删除。
7. 也可以用 PdfMerger(但不推荐了)
老教程常见这种写法:
from PyPDF2 import PdfMergermerger = PdfMerger()for f in files: merger.append(f) # 会自动带上原文件的书签merger.write("merged.pdf")区别:
• PdfMerger会继承原文件内部已有的书签结构,更省事• 但它在 PyPDF2 3.x 里已标记为过时(deprecated),未来的 pypdf里会移除• 用 PdfWriter+add_outline_item对书签的控制更精确(比如书签标题可以自定义、加日期、加页数)
本脚本选 PdfWriter,兼容未来的 pypdf。
测试素材说明
testdata/pdfs/├── 01_封面与目录.pdf # 2 页:报告封面 + 目录├── 02_数据明细.pdf # 3 页:数据总览 + 分区域明细 + 渠道构成└── 03_附件与说明.pdf # 2 页:口径说明 + 联系人三份都是用 fpdf2 生成的真实中文 PDF(嵌入黑体字体,每页带页脚和页码),模拟一份被拆成三块的「月度经营分析报告」,共 11–14 KB 一份。
为什么这样设计素材?
• 文件名带 01/02/03前缀 → 验证排序正确• 每份页数不同(2 / 3 / 2)→ 验证书签起始页计算正确(应为第 1 / 3 / 6 页) • 内容有前后逻辑(封面→数据→附件)→ 合并后一眼能看出顺序对不对 • 中文文件名 + 中文内容 → 验证中文路径和编码处理
实测结果:3 个文件合并成 7 页,书签起始页分别是 1 / 3 / 6,输出 38.1 KB。
想验证容错,可以做这些实验:
# 造一个假 PDF(其实是文本文件),看脚本会不会跳过echo"这不是PDF" > testdata/pdfs/04_坏文件.pdfpython pdf_merge.py # 应提示「无法解析(文件可能损坏)」并继续合并其余 3 个# 记得测完删掉rm testdata/pdfs/04_坏文件.pdf常见问题
Q1:合并后的文件为什么比几个原文件加起来还小 / 还大?PDF 内部会共享资源(字体、图片)。合并时同一个字体可能被复用,所以可能更小;但如果每份文件嵌入的字体子集不同,也可能略大。都是正常的。
Q2:合并后能压缩体积吗?PyPDF2 能力有限,可以试试:
for page in writer.pages: page.compress_content_streams() # 压缩内容流,对文字型 PDF 有效图片型 PDF 想大幅压缩,需要 pikepdf 或 Ghostscript,那是另一个话题。
Q3:书签是中文,在某些阅读器里乱码?PyPDF2 写书签用 UTF-16 编码,主流阅读器(Adobe、Chrome、Edge、福昕)都正常。个别老旧国产阅读器可能有问题,可以把书签标题改成英文或加序号规避。
Q4:想给每一页加页码怎么办?PyPDF2 不擅长「画东西」。标准做法是用 reportlab 生成一个只有页码的透明 PDF,再用 page.merge_page() 叠上去。属于进阶玩法。
Q5:只想合并每个文件的第 1 页?改一行:
for page in reader.pages[:1]: # 只取第 1 页 writer.add_page(page)Q6:报 PermissionError 写不进去output/merged.pdf 正被 PDF 阅读器打开。关掉再运行(Chrome / Edge 打开过 PDF 也会占用,注意关标签页)。
Q7:合并出来顺序不对
1. 先看运行日志里的文件顺序(脚本会逐个打印) 2. 用 --sort mtime试试(扫描件适用)3. 最稳的办法:文件名加 01_ 02_ 03_前缀,或直接用--files手动指定
Q8:能合并成千上万个 PDF 吗?可以,但注意内存:PdfWriter 会把所有页面留在内存里。上千个文件建议分批合并(先合成 10 个中间文件,再合并这 10 个)。
Q9:加密 PDF 提示无法合并,但我知道密码把脚本里的 reader.decrypt("") 改成 reader.decrypt("你的密码"),或者加个 --password 参数传进去。
打包成 exe
第 0 步:安装 PyInstaller
pip install pyinstallerpyinstaller --version第 1 步:基础打包
cd pdf_mergepyinstaller -F pdf_merge.py• -F(--onefile):单文件 exe,产物dist/pdf_merge.exe• PyPDF2是纯 Python 库、无二进制扩展,打包非常顺利,体积约 12 MB
分发结构:
PDF合并工具/├── pdf_merge.exe├── testdata/│ └── pdfs/ ← 同事把要合并的 PDF 丢进来└── output/ └── merged.pdf ← 自动生成想让路径更直观,可以打包时改默认值,比如让 exe 直接读旁边的 待合并 文件夹:
parser.add_argument("--indir", default=str(here / "待合并"), ...)parser.add_argument("--out", default=str(here / "合并结果.pdf"), ...)这依赖 base_dir():
defbase_dir() -> Path:ifgetattr(sys, "frozen", False): # 打包后为 Truereturn Path(sys.executable).resolve().parent # exe 所在目录return Path(__file__).resolve().parent第 2 步:-w 参数的取舍
pyinstaller -F -w pdf_merge.py• -w(--noconsole/--windowed):不弹黑窗口
⚠️ 本脚本用 print 汇报「哪个文件几页、从第几页起、哪个跳过了」,加 -w 后全部看不见,出问题无法定位。
-F-w | |
-Fmain() 末尾 input("按回车键退出...") | |
-F -w |
用 -w 一定要留日志:
import sysifgetattr(sys, "frozen", False): sys.stdout = sys.stderr = open(base_dir() / "run.log", "w", encoding="utf-8")第 3 步:--add-data 打包资源
# Windows 分号,macOS/Linux 冒号pyinstaller -F --add-data "testdata;testdata" pdf_merge.py⚠️ -F 模式下这些资源会解压到 sys._MEIPASS(随机临时目录),只读,程序退出即删除。读取必须用:
defresource_path(rel: str) -> Path:ifgetattr(sys, "frozen", False) andhasattr(sys, "_MEIPASS"):return Path(sys._MEIPASS) / relreturn Path(__file__).resolve().parent / rel本脚本不需要 --add-data:待合并的 PDF 是用户文件,输出的 merged.pdf 必须可写,都该在 exe 旁边。
真正会用到 --add-data 的例子:内置一份「使用说明.txt」
pyinstaller -F --add-data "使用说明.txt;." pdf_merge.pybase_dir() | |
--add-dataresource_path()(只读) |
第 4 步:加图标
pyinstaller -F --icon=app.ico pdf_merge.py• 必须是真 .ico(png 改后缀不行)• 转换:
from PIL import ImageImage.open("app.png").save("app.ico", sizes=[(256,256),(64,64),(32,32),(16,16)])• 图标不生效:删 build/dist/重打;Windows 图标缓存也可能骗你
第 5 步:瘦身
pyinstaller -F --clean ^ --exclude-module matplotlib ^ --exclude-module numpy ^ --exclude-module pandas ^ --exclude-module PIL ^ --exclude-module tkinter ^ --exclude-module PyQt5 ^ --icon=app.ico pdf_merge.py头号有效手段:干净虚拟环境
python -m venv build_envbuild_env\Scripts\activatepip install PyPDF2 pyinstallerpyinstaller -F pdf_merge.py本脚本只需 PyPDF2,干净环境下 12 MB 以内。特别注意排除 PIL:如果全局装了 Pillow,PyPDF2 的可选图片功能可能把它带进来,一下多出 10 MB 以上。
UPX(可选,再省 30%,但增加杀软误报):
pyinstaller -F --upx-dir "D:/tools/upx" pdf_merge.py第 6 步:完整推荐命令(本脚本)
pyinstaller -F --clean --name "PDF合并工具" --icon=app.ico ^ --exclude-module matplotlib --exclude-module numpy --exclude-module pandas ^ --exclude-module PIL --exclude-module tkinter ^ pdf_merge.py常见坑
坑 1:exe 提示「没有找到任何 .pdf 文件」exe 旁边没有 testdata/pdfs 目录,或者 PDF 放错层级了。交付时把目录结构一起打包,或用 --indir 指定:
pdf_merge.exe --indir "D:/我的PDF" --out "D:/合并.pdf"坑 2:ModuleNotFoundError: No module named 'PyPDF2'打包环境没装库,或多 Python 版本混淆。用同一解释器:
D:/python13/python.exe -m PyInstaller -F pdf_merge.py坑 3:双击 exe 一闪而过CMD 里运行 / 加 input() / pdf_merge.exe > log.txt 2>&1,三选一。
坑 4:中文路径与中文文件名运行阶段没问题(全程 pathlib.Path,测试素材就是中文名)。但打包时项目路径建议放纯英文目录,PyInstaller 对中文路径偶有问题。
坑 5:杀毒软件报毒PDF 工具类 exe 被误报较常见。别用 UPX、加白名单、或做代码签名。
坑 6:exe 体积比预期大很多八成是全局环境里的 pandas / numpy / matplotlib / PIL 被连带打包。用 --clean + 干净虚拟环境重打,能立刻从 60 MB 降到 12 MB。
坑 7:Windows 打的 exe 给不了 Mac 同事不支持交叉编译,需在 Mac 上再打一次。
坑 8:-F 启动慢单文件每次运行都解压。追求启动速度就不加 -F,改成文件夹分发。
小结
• 一句话价值:把「在线合并网站 + 手动排序」换成一条本地命令,快且不泄密 • 核心 API: PdfReader读 →writer.add_page(page)搬 →writer.add_outline_item(标题, 起始索引)加书签 →writer.write(f)写• 三个实用细节: 1. start_index = len(writer.pages)(添加前取值)就是书签该指向的页2. 自然排序 re.split(r"(\d+)", name),否则part10会跑到part2前面3. 加密 PDF 先试 decrypt(""),很多「假加密」文件能直接解开• 批量脚本的素养:单个文件坏掉要跳过并说明原因,绝不能让整批任务崩掉 • 打包要点: -F不加-w、排除PIL/pandas 显著瘦身、干净虚拟环境
配套使用:
1. 用 ppt_to_pdf把课件转成 PDF,再用本脚本合成一本完整讲义2. 合并后想拆开单页发送,用 pdf_split3. 想知道合并结果里都写了什么,用本系列的 pdf_extract_text
本脚本属于「Python 办公自动化 30 例 · PPT 与 PDF 处理」分组 · 作者:小庄