尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
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]) # level1一级书签 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 或 UnicodePyMuPDF 的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, errorsignore).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, garbage4, deflateTrue)garbage4会删除无效对象并合并重复对象deflateTrue会压缩流对象。实测对图片多、重复引用多的 PDF 合并场景文件体积可以缩减 30% 以上。代价是保存时间变长但对脚本任务可以接受。如果整个合并文件有 2GB 以上的需求save可能要配合deflate_compression_level9同时留意磁盘空间。大批量任务建议分批处理并输出日志。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(keynatural_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, garbage4, deflateTrue) total_pages doc.page_count doc.close() # 验证输出书签数量与前几项 check fitz.open(output_path) bookmarks check.get_toc() print(fmerged 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 的小坑。把上面几个避坑点记住浪费时间就能大幅减少。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

enabling-independent-research引用与许可指南:CC BY 4.0怎么用、论文如何引用、如何申请下一轮外部研究合作

enabling-independent-research引用与许可指南:CC BY 4.0怎么用、论文如何引用、如何申请下一轮外部研究合作

【免费下载链接】enabling-independent-research 项目地址: https://ai.gitcode.com/hf_mirrors/Anthropic/enabling-independent-research 点击查看 免费下载 enabling-independent-research 是 Anthropic 开源的 Claude 使用行为研究数据集(Partner C…

📅 2026/10/16 14:14:41
llama.cpp 与 GGUF 本地推理实战:从模型加载到性能调优

llama.cpp 与 GGUF 本地推理实战:从模型加载到性能调优

1. 为什么“裸引擎”这个说法值得认真对待 很多人第一次接触本地大模型,脑子里想的都是“下载一个模型文件,双击运行,然后就能对话”。这个预期本身没错,但中间少了一个关键环节:模型文件不是可执行程序,它…

📅 2026/10/16 14:09:41
NSGA-Ⅲ算法下梯级水电与火电联合多目标优化调度及Matlab实现

NSGA-Ⅲ算法下梯级水电与火电联合多目标优化调度及Matlab实现

赶了很多次 Matlab 多目标优化的活儿之后,我越来越觉得,"基于NSGA-Ⅲ优化算法的梯级水电和火电机组联合多目标调度研究"这类题目,真正难的不是把 NSGA-Ⅲ 的代码跑通,而是把电力系统的物理逻辑塞进进化算法的框架里。这…

📅 2026/10/16 14:09:41
MORE NEWS

更多资讯

📰

Linux磁盘与LVM管理:从分区到扩容备份的实战练习

第2章的内容学完之后,很多人的状态都是“看明白了但手还是生的”。磁盘、LVM、文件系统与扩容备份这几个主题,光靠看是看不出经验的,必须落到命令行上,手指跟着敲一遍,概念才会真正变成自己的。这套配套练习题就是把第…

📰

Python开发环境搭建指南:编辑器与IDE选用与安装全解析

我把话先说在前面:这一章的标题看着平平无奇,但在实际教学和项目带新人的过程里,环境搭建这一关卡住的概率比后面写代码高得多。很多人不是学不会Python,是第一步工具装得不舒服,导致后面每个练习都别扭。所以这篇文章…

📰

2026年IDE使用简记:AI、远程开发与工作流优化

从2023年到现在,我保持着一个谈不上多正式的习惯:每隔半年,把当前在用的IDE、编辑器、配置文件和一些踩坑记录整理成一份“简记”。最开始是怕自己忘,后来发现这套笔记比很多教程都管用,因为它完全是按我的真实工作流写…

📰

Windows Server 2008 R2 RAID驱动集成实战:用DISM注入boot.wim与install.wim

简介:本资源是一份面向Windows Server系统管理员与IT运维工程师的实战型驱动集成指南,专为解决Windows Server 2008 R2在新型服务器上安装时无法识别RAID硬盘的典型问题而设计。文档详细拆解了使用系统原生dism.exe工具将RAID卡驱动无缝集成至install.wi…

📰

Delphi项目迁移实战:TMS Component Pack从D7到XE10.3的编译与踩坑

简介:Delphi TMS Component Pack v8.3.4.0 是一套面向 Delphi 开发者的知名控件全集,覆盖从 D7 到 XE10.3 的版本需求,可帮助使用不同 IDE 版本的技术人员在同一套控件体系下完成界面、数据库、网络等常见业务功能开发。内含全部源代码&#…

📰

AD2S1210驱动例程怎么选?五大例程对比与移植避坑指南

简介:这份驱动代码包面向使用AD2S1210旋转变压器-数字转换器芯片的嵌入式工程师,汇集网上收集的五个示例工程,包含两个官方例程,每个示例置于独立文件夹,便于对照学习芯片的初始化、数据读取、错误检测及状态监控等底层…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬