news 2026/10/16 14:14:37

Python实现PDF合并与自动生成书签:从原理到脚本实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python实现PDF合并与自动生成书签:从原理到脚本实践

简介: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 绪论.pdf
  • 02 总体设计.pdf
  • 03 前景.pdf

稍微复杂一点的是二级目录:

  • 02-1 需求分析.pdf
  • 02-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 的小坑。把上面几个避坑点记住,浪费时间就能大幅减少。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 14:11:43

SimpleBGC32开源云台控制器:原理、固件烧录与PID调参实战

简介:面向步进电机控制开发者,SimpleBGC32固件完全解析资料包收录了完整工程源码、项目配置与编译产物。资源共429个文件,总大小10.78MB,文件构成十分典型:以96个C源文件与89个H头文件为核心逻辑,配合众多目…

作者头像 李华
网站建设 2026/10/16 14:08:16

多模态模型架构全解析:从自注意力到微调避坑指南

简介:这是一份关于DeepSeek深度学习模型的完整技术文档,面向NLP、CV及多模态学习方向的研究者与开发者。内容从Transformer架构与多模态融合原理讲起,覆盖环境配置、预训练模型加载、文本生成、图像识别分类及模型微调等实操环节,…

作者头像 李华
网站建设 2026/10/16 13:59:12

C++基础术语第五轮拆解:RAII、SFINAE、RVO等核心概念实战解析

1. C基础术语的第五轮拆解:为什么这些概念总在关键时刻卡住你写C的人大概都有过这种体验:代码编译通过了,运行也没崩,但性能就是上不去;或者某个模板报错信息刷了满屏,盯着看半天不知道从哪下手。这些问题往…

作者头像 李华
网站建设 2026/10/16 13:58:52

多路RTSP拉流工具:多窗口并发拉流与解码渲染实战

简介:这是一款面向视频监控、网络直播及多路视频监看场景的RTSP拉流播放工具,适合需要同时查看多个实时视频源的运维人员、安防从业者与流媒体开发者使用。它支持在单一界面内自由组合1x1、1x2、2x3、2x4等多种窗口布局,每个窗口独立拉取不同…

作者头像 李华
网站建设 2026/10/16 13:57:50

学生证补办管理系统开发实战:Vue+Spring Boot+Node.js

学生证丢了,在不少高校至今还是一件跑断腿的事。我认识的一位辅导员朋友,每周都要在纸质申请表堆里翻来找去,哪个学生卡在哪个环节,得打电话一个个问。学生那边更窝火,上午跑教务处交表,下午被告知缺照片&a…

作者头像 李华
网站建设 2026/10/16 13:56:11

DRN深度重照明原理与飞桨实战:从物理建模到可微分渲染

1. 项目概述:为什么重照明不是“调亮度”,而是让图像自己学会“打光”最近在某高校实验室带一个图像生成方向的模拟项目X,有位A同学拿着一张在昏暗走廊里拍的手机照片来问:“老师,能不能让这张图看起来像开了补光灯一样…

作者头像 李华