简介:PDFPatcher 是一款面向办公与文档管理场景的 PDF 实用工具,版本 0.3.4.101,主要解决多文件 PDF 合并、书签自动生成与编辑等常见需求,适用于需要整理合同、报告、电子书等多章节文档的普通办公用户与文档管理者,无需编程即可上手。压缩包共含 10 个文件,包括 exe 主程序、dll 运行库、PDF 使用手册、txt 更新历史与授权协议,以及 xml/xsd 配置架构文件,整体大小约 6.96MB,轻量易部署。已有 3938 人学习下载。该工具在合并 PDF 时可智能整合原始书签,也支持手动添加、删除或修改书签;此外还涵盖 PDF 转换、拆分、加密等辅助功能,并能保持原页面顺序与格式,方便用户完整管理文档生命周期。随包附带的官方使用手册和更新历史,能帮助新手快速上手,也让有经验的用户了解版本变化,是一份实用且完整的 PDF 处理工具包。
1. 从“合并后书签全丢”说起:PDF合并与自动生成PDF书签到底解决了什么
经常跟 PDF 打交道的人都有过这种经历:把几个章节的 PDF 拼成一个完整文件,内容倒是合并了,可左侧导航栏里空荡荡的,想看第五章还要一页页翻。另一类场景是扫描件合并后根本没有书签,每次查阅都得靠记忆。这里说的“非常好用的 PDF 工具,PDF合并,自动生成PDF书签”,解决的就是这两件事:把分散 PDF 按顺序合并,同时自动生成一份能点击跳转的书签目录。适合文档处理、电子书归档、技术资料汇总、标书和论文附件整合这类场景。凡是每个月都要做几轮 PDF 整合的从业者,都能从这个方向里省下大量手工拖拽的时间。
2. PDF合并后书签丢失的根因:为什么现成软件和手写脚本结果不同
2.1 书签不是页面上的字,而是藏在 PDF 内部的目录树
PDF 的书签在文件里不叫“书签”,标准名称是 Outlines(大纲)。它和页面是两套独立结构:页面负责显示内容,大纲负责导航。打开 PDF 时,阅读器把大纲读取出来,渲染成侧边栏的树。
所以,合并 PDF 时如果只是把页面内容串起来,没有处理大纲,书签自然全部丢失。很多人在 Word 或浏览器里“打印为 PDF”时,由于页面生成器根本没写大纲,合并后当然也什么都没有。
PDF 目录树大致这样组织:文档 Catalog 指向页树(PageTree)和 Outlines。创建新区记录时,页面可以从多个源文档里复制,但大纲需要逐条读取、换页、再写入新文档。工具做不到这一步,用户看到的结果就是“文件合了,书签没了”。
常见的现成 PDF 合并工具中,有的商业套件能保留源文档书签,但多数免费软件并不支持;而对于“合并后自动生成书签”这种需求,成熟 GUI 工具也往往要手动编辑大纲。真正的落地路径,是写脚本逐页复制,再构造新的大纲树。
2.2 工具选型:什么时候用 GUI,什么时候值得上脚本
先说结论:一次性合并三五个文件,打开现成 GUI 工具拖进去点“合并”就够;如果每个月都要处理几十个文件,或者合并之后还要按文件名重新生成书签,GUI 工具的效率就太低,应该用脚本。
三个典型场景里的选择逻辑:
- 标书或技术附件整合:文件多、命名有规律,要保证最终目录与各章标题一致。每轮都是重复劳动,适合脚本。
- 论文附件或历史档案归档:原 PDF 自带书签,希望合并后所有书签保留,同时新生成“文件名”层级。这种混合需求,GUI 需要手动建目录,脚本可以自动完成。
- 扫描版 PDF:原文件大多没有内部书签,只能靠文件名生成目录。脚本按文件名规则生成多级书签,比手工快一个数量级。
脚本方案我一般用 Python 的 PyMuPDF(模块名 fitz)来做,它直接支持从源 PDF 读取大纲、读取页数、插入页面、写入新大纲,一套流程不需要中间转文本。下面从最小合并脚本开始写起。
3. Python实现PDF合并+自动生成书签:一套能跑的最小脚本
3.1 装环境与第一个能用的合并脚本
先安装依赖。命令行窗口里执行:
pip install PyMuPDF装完后,下面这段代码就是最简单的“合并全部 PDF 并保存”:
import fitz # PyMuPDF def merge_pdfs(file_list, output_path): """把 file_list 里的 PDF 按顺序合并成一个新 PDF。""" output = fitz.open() # 创建一个空白文档 for pdf_path in file_list: with fitz.open(pdf_path) as src: # 只读方式打开源文件 output.insert_pdf(src) # 将源文档全部页面插入 output output.save(output_path) output.close()代码逻辑:fitz.open()创建的 output 是一个空文档;insert_pdf(src)会把 src 里的全部页面复制过来。循环完所有文件后保存。
这个方法有个明显短板:insert_pdf只复制页面,不复制大纲。合并后文件页数正确,但书签全空。
参数说明:
file_list是 PDF 文件路径的列表,顺序就是合并顺序。output.save()的默认参数通常够用,但如果源文件里有无用的字体或重复资源对象,结果文件会偏大,后面会讲优化参数。- 如果某个源文件加密导致打开失败,
fitz.open(pdf_path)会抛异常,这时需要密码参数,代码在第 5 章。
运行前确认 file_list 里的路径真实存在,且不是文件夹。第一次跑时我建议只放两个测试 PDF,别一上来就处理上百个文件。
3.2 用文件名自动生成一级书签:目录从哪里来
既然书签不会自动产生,那就从每个输入文件名里生成一级书签,指向该 PDF 的第一页。这是“自动生成 PDF 书签”最直观的做法,不需要源文件自带任何大纲。
改进后的代码:
import fitz from pathlib import Path def merge_with_file_toc(file_list, output_path): output = fitz.open() toc = [] # 新书签树,每项是 [level, title, page] page_offset = 0 # 当前已插入的页数,用于给书签定位 for pdf_path in file_list: with fitz.open(pdf_path) as src: output.insert_pdf(src) # 先插入页面 title = Path(pdf_path).stem # 去扩展名,如 "第三章" page = page_offset + 1 # PDF 页号从 1 开始 toc.append([1, title, page]) # level=1,一级书签 page_offset += src.page_count # 累加偏移 output.set_toc(toc) # 把书签树写入输出文档 output.save(output_path) output.close()逻辑说明:page_offset记录 output 已经有多少页。每一个新插入的 PDF,它的第一页在 output 里的页码就是page_offset + 1。src.page_count是源文件页数,插入完要加进偏移量。
参数说明:
- 书签条目
[level, title, page]中的 page 以“第 1 页”为基准,也就是src.page_count里页面的编号加 1。PyMuPDF 内部页面索引从 0 开始,但书签页号约定从 1 开始,千万别弄混。 Path(pdf_path).stem只接收文件名主体。例如03-技术方案.pdf会取成03-技术方案,如果想去掉数字前缀,需要额外清理,下一节会讲。set_toc(toc)对 toc 顺序没有强制要求,但推荐保持层级连续、深度的顺序。如果层级乱掉,有些阅读器会显示异常。
跑完这版,合并文件里已经能看到“一级目录”。如果文件名是01 绪论.pdf、02 方法.pdf,点击导航栏里的“01 绪论”,会跳到合并文件里的第 1 页,点击“02 方法”,跳转到第 9 页。
这已经覆盖了“PDF合并 + 自动生成PDF书签”的核心需求。但真正做到好用,还差两步:一是保留源 PDF 内部自带的多级书签;二是处理那些没有内部书签的扫描件。
4. 批量保留原书签和处理扫描件:层级书签的生成方法
4.1 把原始 PDF 自带书签全部搬进合并文件
很多资料 PDF 本身就是一本“书”,已经编辑好章节大纲。如果只是按文件名生成一条一级书签,把内部章节都丢掉了,很可惜。
PyMuPDF 的get_toc()能一次性读出全部书签,返回格式类似[1, "第一章 绪论", 3]或[2, "1.1 背景", 4]。我们要做的事是:读取每条书签,再把它的页码加上当前偏移。
代码如下:
import fitz from pathlib import Path def merge_keep_original_toc(file_list, output_path): output = fitz.open() toc = [] page_offset = 0 for pdf_path in file_list: with fitz.open(pdf_path) as src: output.insert_pdf(src) # 先插入全部页面 src_toc = src.get_toc() # 读取源文件的书签树 for level, title, page in src_toc: toc.append([level, title, page + page_offset]) page_offset += src.page_count output.set_toc(toc) output.save(output_path) output.close()逻辑与 3.2 的唯一区别:书签来源从“文件名”换成“源文件自带大纲”,并且页码做了整体偏移。
这段代码能处理绝大多数场景,但有三个细节你要注意:
get_toc()返回的 page 一定是 1 起的。如果某个源 PDF 书签指向第 10 页,插入后实际页是page_offset + 10。直接相加即可,不需要再减 1。- 如果源文件书签里有无效目的地,比如指向“已删除的页面”,
get_toc()通常会把 page 置为 -1。对这类条目,建议直接过滤,不然写入后点击书签会报错。 src_toc可能相当大。例如一本 500 页的电子书有几百条书签,合并 20 本就会产生几千条。这会显著增加保存文件的大小,但不会拖慢合并速度太多。
建议的过滤逻辑是“遇到 page 小于 1 就跳过”,避免生成坏链。同时,可以在get_toc()前先检查src.is_encrypted,下面的避坑章会讲。
如果你希望合并后同时保留“文件名一级书签 + 文件内原有子书签”,常见做法是在插入每个源文件后,先追加一层一级书签指向第一页,再追加src_toc。但这样层级会出现分支,部分阅读器对“没有父级的二级书签”支持不一。更稳妥的是用level + 1把原有书签统一降一级,放在文件名书签下面:
toc.append([1, Path(pdf_path).stem, page_offset + 1]) for level, title, page in src_toc: toc.append([level + 1, title, page + page_offset])降到第二级后,阅读器显示为可折叠的目录,结构更清晰。但要注意level原始最大值不能超过 3,否则加 1 后会超过很多阅读器支持的 5 级限制。
4.2 扫描版 PDF 没有内部书签:按文件名自动生成多级书签
扫描件是另一个常见大坑:内容全是图片,内部看不到大纲,文件名反而成了唯一信息来源。
这时候我会用正则把文件名拆成层级。约定常见命名规则是“数字序号 + 标题”。比如:
01 绪论.pdf02 总体设计.pdf03 前景.pdf
稍微复杂一点的是二级目录:
02-1 需求分析.pdf02-2 架构设计.pdf
这时可以把02-1 需求分析.pdf解释成“第 2 章下面的第 1 小节”。
下面这段代码演示怎么从文件名生成两级书签:
import fitz import re from pathlib import Path pattern = re.compile(r'^(\d+)[-—_.]?(\d+)?[^\d]*?[\s_]*(.+?)$') def build_toc_from_filename(pdf_path, page): """用文件名生成 [level, title, page] 列表。""" text = Path(pdf_path).stem.strip() m = pattern.match(text) if not m: return [[1, text, page]] if m.group(2): # 带二级序号,如 02-1 level1 = m.group(1) level2 = m.group(2) title = m.group(3).strip() return [[1, f"第{level1}章", page], [2, f"{level2} {title}", page]] else: return [[1, f"第{m.group(1)}章 {m.group(3).strip()}", page]]然后合并时对每个文件,生成书签并追加:
def merge_scanned_with_toc(file_list, output_path): output = fitz.open() toc = [] page_offset = 0 for pdf_path in file_list: with fitz.open(pdf_path) as src: output.insert_pdf(src) toc.extend(build_toc_from_filename(pdf_path, page_offset + 1)) page_offset += src.page_count output.set_toc(toc) output.save(output_path) output.close()正则说明:^(\d+)[-—_.]?(\d+)?[^\d]*?[\s_]*(.+?)$匹配“02-1 需求分析”这类文本。(\d+)是第一级序号,(\d+)是可选第二级,(.+?)是标题。如果你的命名规范不同,替换这段正则即可,核心思路不变。
这套方案也存在边界情况:部分书名是“第3章 数据平台设计”,开头没有纯数字序号,正则就匹配不了。应急预案是前后两套规则各写一个函数,优先匹配“第X章”,匹配失败再走数字规则。
不管哪种规则,生成后都建议用output.set_toc(toc)之前先打印前 20 条书签,确认标题和页码符合预期,再批量执行。
5. PDF书签生成避坑:5个高频翻车点与排查手法
5.1 页码偏移一位:点击书签跳到了前一页或后一页
现象:合并后的书签标题完全正确,但点击“第 3 章”却跳到了实际第 3 章的前一页;或者反过来,每个书签都偏后一页。
原因:PDF 页面的“零基准”和“一基准”混用。PyMuPDF 的get_toc()返回的页码从 1 开始;页面内部索引src.page_count和insert_pdf的相关逻辑从 0 开始。如果代码里多做了一次page - 1或page_offset + page - 1,就会出现统一偏移一位。
解决:统一书签页号用“第 1 页起”的规则。插入前记录page_offset,插入后目标页就是page_offset + src_toc_page。不要在最后保存前再做减一操作。调试时我习惯先拿一个三页的 PDF 做样本,给它的书签写在第二页上,跑完看跳转结果。
另一个隐蔽原因是封面页。有的 PDF 第一页是空白或封面,源文件里的书签页码是“逻辑页码”,会跳过一个未编号页。处理方式是在源 PDF 里先查看第一页内容是否空白,如果空白,把该文件的书签页全部加 1。这一步只能靠业务约定来定,脚本很难自动判断。
5.2 中文书名或书签变乱码,合并文件在阅读器里显示问号
现象:脚本跑完,在命令行打印get_toc()时中文正常,但阅读器打开合并文件,书签标题变成一堆乱码;有的阅读器里正常,另一些全是方块。
原因:书签标题在 PDF 内部用的是 PDF DocEncoding 或 Unicode,PyMuPDF 的set_toc会把 Python 字符串转成 Unicode,一般不会出问题。真正出问题的是源文件本身的书签就是乱码,常见于早期制作的非嵌入字体 PDF,或者由某些国产软件导出的 PDF。
解决:先检查源文件书签是否正常,用下面这段代码单独打印:
with fitz.open("source.pdf") as doc: print(doc.get_toc())如果输出里已经乱码,说明源头坏了。此时不能指望合并时修好,只能改从文件名生成书签,或者用 PDF 文本提取的方式从页面内容里取标题。很多扫描版 PDF 根本没有文本层,这一步基本无解,只能靠文件名。
对“合并后乱码”的情况,还可以在保存时把书签标题做一次 UTF-8 规范化:
title = title.encode('utf-8', errors='ignore').decode('utf-8')但这不是根治,因为 PDF 阅读器对 DocEncoding 的处理并不统一。我的经验是:只要源 PDF 是从 Word 或 LaTeX 正常导出的,中文书签都能保留;要防的是老扫描软件生成的“伪书签”。
5.3 加密 PDF 打开失败或书签读不出来
现象:脚本跑到某个文件时抛RuntimeError: cannot open encrypted document,或者打开成功但get_toc()返回空数组。
原因:PDF 有两种密码,打开密码和权限密码。fitz.open()不带 password 参数时默认以受限模式打开,如果文件有打开密码,需要先解密才能读取页面;如果只有权限密码,某些版本则无法读取大纲对象。
解决:先捕获异常并提示输入密码:
try: doc = fitz.open(pdf_path) except Exception: doc = fitz.open(pdf_path, password="已知密码")注意,PyMuPDF 对安全程度高的加密支持有限,有些新版 AES-256 加密 PDF 即使密码正确也会抛错误。这种情况常见做法是先用某开源 PDF 处理库做一个解密中间文件,再继续合并。解密生成的临时文件不保留,合并结束后删除。
5.4 合并后文件巨大,书签越多文件越慢
现象:几十个 PDF 合并后,文件体积比原文件总和还大,甚至打开时侧边栏要卡几秒才显示书签。
原因:insert_pdf默认把原始页面对象直接复制,不做资源清理。每个源文档的字体子集、图片压缩参数、无用对象都被保留,重复的资源叠加后文件膨胀。书签本身是结构化数据,体积不大,但几千条书签会让 PDF 阅读器构建目录树时增加开销。
解决:保存时启用 PyMuPDF 的垃圾回收和压缩参数:
output.save(output_path, garbage=4, deflate=True)garbage=4会删除无效对象并合并重复对象,deflate=True会压缩流对象。实测对图片多、重复引用多的 PDF 合并场景,文件体积可以缩减 30% 以上。代价是保存时间变长,但对脚本任务可以接受。
如果整个合并文件有 2GB 以上的需求,save可能要配合deflate_compression_level=9,同时留意磁盘空间。大批量任务建议分批处理并输出日志。
5.5 书签层级不连续,某些阅读器折叠异常
现象:合并后的书签树在 Windows 自带阅读器里正常,但在某免费阅读器里二级书签直接顶到最外层,或者点击二级书签所有子级全部展开。
原因:PDF 大纲要求层级严格按“父级先出现再出现子级”的深度优先顺序,而且一级大纲不能只有二级而没有一级。混合多个源文件书签时,如果原文件的第一个书签恰好是二级(例如源文件首页没有一级书签直接进入 1.1),合并后新 doc 的大纲树里就会出现“孤儿结点”。
解决:生成 toc 时做一次规范化。常见做法是自动补一级书签:如果一个源文件的第一条书签level大于 1,先插入一条一级书签指向该文件首项所在页,然后把所有书签合并后统一修正层级。
Protect 思路是统一把源文件内部书签整体降级,放到“文件名”一级之下,见 4.1。这个方法有一个例外:如果源文件本身没有文件名可用,就还是得补齐一级书签。伪代码:
last_level = 1 normalized = [] for level, title, page in toc: level = max(1, min(level, 5)) if level > last_level + 1: level = last_level + 1 normalized.append([level, title, page]) last_level = level然后把normalized传给set_toc。这个修正能避免大多数阅读器显示混乱,但治标不治本,根本解决是在源文件大纲生成时就保证完整。
6. 进阶:一键脚本模板与书签正确性验证
当我手里文件变成几十个时,我不会再一行行写函数,而是用一个带命令行参数的脚本模板。它能把指定文件夹下的 PDF 按文件名排序,合并后按文件名生成一级书签,同时保留每个文件自带的子级书签,最后自动打印书签统计。
import fitz import re import sys from pathlib import Path def natural_sort_key(path): return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', path.stem)] def merge_dir(source_dir, output_path): files = list(Path(source_dir).glob('*.pdf')) files.sort(key=natural_sort_key) doc = fitz.open() toc = [] offset = 0 for path in files: with fitz.open(str(path)) as src: doc.insert_pdf(src) file_toc = src.get_toc() if file_toc: first_level = file_toc[0][0] if first_level > 1: toc.append([1, path.stem, offset + 1]) for level, title, page in file_toc: if page > 0: toc.append([level + 1, title, page + offset]) offset += src.page_count doc.set_toc(toc) doc.save(output_path, garbage=4, deflate=True) total_pages = doc.page_count doc.close() # 验证:输出书签数量与前几项 check = fitz.open(output_path) bookmarks = check.get_toc() print(f"merged pages: {total_pages}, bookmarks: {len(bookmarks)}") print(bookmarks[:10]) check.close() if __name__ == "__main__": merge_dir(sys.argv[1], sys.argv[2])运行方式:python merge_tool.py ./input_dir ./merged.pdf
这个模板把“排序”“合并”“继承原有书签”“文件级兜底书签”“压缩保存”“验证”集中在一起。natural_sort_key保证 “2 引言” 排在 “10 结论” 前面,不会按字符串顺序乱排。
验证这一步很容易被忽略,但恰恰最关键。我会先看打印出的书签数量,再抽第一条和最后一条,手动跳到对应页面对比标题。如果数量为 0,优先检查加密问题;如果最后一条指向页码超出总页数,说明某处偏移没算对。
经验上,这类自动化至少要跑两遍:第一遍用三五个小文件做测试,打开输出文件点一圈书签;第二遍再跑全部文件。我第一遍测试时曾发现某个文件里藏着一张乱码书名书签,导致整棵目录树里多出一个层级断层,那时已经生成了完整的目录蓝本,如果没有抽查,发出去就闹笑话了。
你自己的文件命名规则可能和我的模板不完全一致,切记先改正则,再跑批量。书签生成不是玄学,但第一版脚本几乎总要踩一遍 5.2 或 5.5 的小坑。把上面几个避坑点记住,浪费时间就能大幅减少。希望帮到你。
本文还有配套的精品资源,点击获取