☰
Python批量处理PDF书签:读取大纲树并写入导航目录的完整实践
2026/10/17 4:20:49 网站建设 项目流程

简介:一个针对 Python 处理 PDF 书签场景的源代码包,面向需要批量管理 PDF 目录的开发者,解决从 PDF 中读取书签以及将自定义书签批量写入 PDF 的重复性操作问题。包内共 2 个文件,包含一个可直接运行的 Python 脚本(.py)和一个打包的第三方依赖库压缩包(.gz),总大小约 40KB,轻量实用。脚本基于 PyPDF2 实现书签读取,并通过 JSON 配置书签层级、标题与页码,快速生成带书签的新 PDF,适合处理电子书目录整理、工程文档索引批量生成等任务。压缩包中还附带 pyPdf 库源码,可作为备选方案或版本对照。目前已有 2515 人学习使用,代码结构清晰,稍作调整即可嵌入到自己的文档处理流程中。

1. 把 PDF 书签当作可编程数据来读来写:这份源码包的批处理逻辑

处理 PDF 的人大多撞过同一堵墙:某些系统导出的报告有几百页,封面目录印得分毫不差,但阅读器侧边栏的书签一片空白。书签不会自动生成,很多生成器也懒得写大纲。这份 Python 源码包解决的是最朴素也最耗时的两个需求——把已有 PDF 的书签树完整读出来(层级、标题、页码全保留),再按你给的目录清单批量写回去,几十个文件一码跑完。适合需要归档电子文档、整理教材、做报告后处理的工程师和从业者。书签在 PDF 内部叫大纲(Outline),本质是一棵节点树,源码包把树读出来、按目录重写进去,你要做的只是准备目录数据和文件路径。

2. 读侧解析:理解大纲树结构,把层级与页码映射关系抠出来

2.1 PDF 书签不是附件,是一棵有向树

拿到一份 PDF,书签在文件内部对应的结构是大纲(Outline),从根节点开始往下挂多个大纲条目,每个条目又可能带有子条目。这棵树在阅读器里呈现为可折叠的侧边栏,在数据层面则是一组相互关联的条目对象,每个条目保存了标题文本、目标动作(通常是跳转到某一页的 GoTo 动作)以及若干修饰参数。层级关系不是靠字段名标记的,而是靠条目的父子嵌套关系表达的。

拿 PyPDF 系的库举例,reader.outline返回的要么是空列表(无书签),要么是一个嵌套列表。每个基础条目是一个Destination对象,如果该条目还有子节点,子节点列表会作为同类元素出现在当前层级里。遍历的时候要区分两种类型:一种是非列表的条目,可以直接取标题和页码;另一种是列表,说明它是某个父条目的子节点集合,需要递归进去处理。

写代码之前先弄清楚一件事:PDF 里页面编号有逻辑编号和物理编号的区别。阅读器底栏显示的是物理页码还是逻辑页码,取决于文档是否设置了页面偏移量。常见的陷阱是,提取出来的目标页码是物理位置索引,而打印目录上标注的是逻辑页码,两者可能错开几页。源码包里统一用get_destination_page_number先取物理页码,再做一次偏移修正,避免定位落到错页。

2.2 用 pypdf 遍历书签树,输出层级、页码与标题

读取逻辑的核心是递归遍历,我一般在源代码里保留一个独立的函数,不混在批处理主循环里,这样出问题时可以单独调试:

import sys from pypdf import PdfReader def extract_bookmarks(pdf_path): reader = PdfReader(pdf_path) lines = [] # 递归遍历大纲树,depth 表示当前层级,0 为顶级 def walk(nodes, depth): for node in nodes: # pypdf 把子节点列表放在父节点之后,需要区分 if isinstance(node, list): walk(node, depth + 1) continue # 取标题文字,可能与原始编码不同但可读 title = node.title # 页码从 0 开始计数,加 1 转成阅读器显示页 try: page_no = reader.get_destination_page_number(node) + 1 except Exception: # 有些条目指向外部 URL 或命名目标,没有页码 page_no = -1 # 用制表符分隔,方便导入表格软件二次清洗 lines.append(f"{depth}\t{page_no}\t{title}") # reader.outline 根节点为空列表说明文档无书签 walk(reader.outline or [], 0) return lines if __name__ == "__main__": result = extract_bookmarks(sys.argv[1]) print(f"共 {len(result)} 个书签条目") for line in result: print(line)

这个函数有一个设计细节值得注意:把子节点列表判定放在处理单个节点的前面,因为node本身可能是列表。node.title是 PDF 大纲条目的标题文本,官方文档叫 Title 字段,正常情况下是字符串;有些 PDF 会把标题存成 UTF-16 或带引号转义,pypdf 在解析时已经做了解码,基本不会乱码。页码通过get_destination_page_number取的是大纲条目指向页面对象的序号,底层是 0 开始,加上 1 是为了和读者看到的页码对齐。

捕获异常那行是踩过坑之后补上的:某些扫描版或者从 WPS 转出来的 PDF,大纲条目没有标准 GoTo 目标,而是指向命名目的地(named destination),直接取页码会抛出异常。把这种情况标记为 -1,后续写入阶段要么跳过,要么手动补列。

2.3 用 PyMuPDF 交叉验证,避免被单个库的解析差异骗了

pypdf 在读取书签时对某些非法或畸形结构的容忍度偏保守,同一个 PDF 用 PyMuPDF 打开再导出大纲,很可能看到的结果不一样。我的习惯是除了 pypdf 读取,再用 PyMuPDF 拉一份toc列表出来对照,两边页码一致才跑批处理。

import fitz # PyMuPDF def cross_check(pdf_path): doc = fitz.open(pdf_path) toc = doc.get_toc() # 返回 [(level, title, page), ...] doc.close() return toc if __name__ == "__main__": toc = cross_check("report.pdf") for level, title, page in toc: print(f"L{level} p{page} {title}")

注意这里的区别:doc.get_toc()返回的第一位数字是层级编号(1 表示顶级,2 表示二级,以此类推),页码已经换算成 1 开始,标题则是整理过的纯文本。和 pypdf 的嵌套列表结构比起来,这种平铺元组列表更容易比对。两个库读出来的条目数相差几个时,优先相信 PyMuPDF 的结果,更接近阅读器实际渲染的行为。交叉验证需要写成独立脚本跑一遍,不要在批处理内部去做,否则日志会很乱。

3. 写侧实现:用 PyMuPDF 批量写入大纲

3.1 写书签为什么不继续用 pypdf

读用 pypdf 顺手,写到同版本的库就有点费劲。PdfWriter虽然有add_outline_item这样的 API,但实际使用中,给已有 PDF 注入大纲后再保存,容易碰到两个问题:一是写入后大纲和页面之间的关联偶发丢失,二是嵌套层级多了之后文件体积明显膨胀。PyMuPDF 的set_toc在底层直接把大纲条目编译进文档对象树,保存时保留结构更完整,代码也短。

另一个选型理由是字符编码。中文标题在 pypdf 的写入路径下偶尔会变成乱码或出现异常偏移,PyMuPDF 对 UTF-8 和 UTF-16 的处理更直接,标题写入后阅读器显示正常。我的建议是读取用 pypdf(可读性好、依赖轻),写入统一用 PyMuPDF,两边各用所长,不必在一个库里死磕。

3.2 清空旧大纲再插入新目录:两种模式的取舍

批处理场景里最怕新旧书签混在一起。有些 PDF 自带一套旧书签,层级乱、页码还过期,直接写新的大纲会把旧数据覆盖吗?不一定,取决于实现。PyMuPDF 的doc.set_toc(toc)在传入非空列表时会替换整棵大纲树,所以旧书签自然被清理掉。但为了明确意图,我一般会传入空列表先清一次:

import fitz def write_bookmarks(pdf_path, output_path, toc_list): doc = fitz.open(pdf_path) # 先清空原有大纲,防止新旧嵌套或残留 doc.set_toc([]) # toc_list 必须是 [(level, title, page_no), ...] # page_no 是用户页,内部会自动转成物理页索引 doc.set_toc(toc_list) # garbage=4 清理无用对象,deflate 压缩流,减小体积 doc.save(output_path, garbage=4, deflate=True) doc.close() if __name__ == "__main__": new_toc = [ (1, "封面", 1), (1, "摘要", 2), (2, "研究背景", 2), (2, "本文贡献", 3), (1, "第一章 绪论", 4), ] write_bookmarks("raw.pdf", "bookmarked.pdf", new_toc)

set_toc([])这一行看着多余,其实对应两种场景:如果旧书签层级和新的目录有部分同名条目,PyMuPDF 的替换逻辑可能保留旧条目而不是覆盖同名条目,导致点击后跳转页码还是旧的。先清空再写是拿不准现状时最稳的做法。garbage=4是增量垃圾回收级别,会移除所有未引用对象;批量处理上百个文件时,这能有效压缩输出体积。

toc_list的三元组中,页码是阅读器显示的页码,PyMuPDF 内部会根据文档分页偏移自动换算成对象索引,不需要手动加减 1。这个行为和 pypdf 的读取端是互为镜像的,正好抵消了页码偏移误差。

3.3 保留局部旧书签:合并读取结果与新目录

有时候不能全部覆盖。对应真实的项目场景:某个 PDF 已经有一部分正确书签,只想把缺失的章节插进去。源码包中单独提供了一个merge_toc路径,先读取旧大纲,在内存里合并,再整体写回:

import fitz def merge_and_write(pdf_path, output_path, append_toc): doc = fitz.open(pdf_path) existing = doc.get_toc() # 先校验是否已有相同标题,避免重复 existing_titles = {item[1] for item in existing} merged = list(existing) for level, title, page in append_toc: if title not in existing_titles: merged.append((level, title, page)) # 按页码先后排一次,阅读器侧边栏才不乱 merged.sort(key=lambda x: (x[2], x[0])) doc.set_toc(merged) doc.save(output_path, garbage=4, deflate=True) doc.close()

这里的关键参数是排序键:(x[2], x[0])先按页码、再按层级排序。原因在于 PDF 大纲条目是按文档顺序排列的,如果插入的新条目页码比旧条目靠前,不重新排序会导致侧边栏里后写入的书签显示在最后。用集合推导去重标题,适合标题完全不重复的小文件;几千条书签的大文件,建议改用遍历列表去重。

4. 批处理实战:目录清单驱动,一次跑完整个文件夹

4.1 输入约定:目录放哪、清单长什么样、怎么对应文件

批处理的第一步是定义输入输出结构。我一般会准备两个目录:input_pdfs/放原始 PDF,output_pdfs/放写好书签的产物。目录清单用 CSV 或 JSON,每一行对应一个 PDF 的完整书签列表。CSV 格式适合在 Excel 里手工维护,JSON 适合从其他系统导入导出。源码包里默认读 JSON,因为 JSON 可以无损表达嵌套结构,而 CSV 只能用level字段表达层级。

一个典型的清单文件:

[ { "file": "report_2025.pdf", "bookmarks": [ {"level": 1, "title": "封面", "page": 1}, {"level": 1, "title": "目录", "page": 3}, {"level": 1, "title": "第一章", "page": 5}, {"level": 2, "title": "1.1 背景", "page": 5} ] }, { "file": "report_2026.pdf", "bookmarks": [ {"level": 1, "title": "封面", "page": 1} ] } ]

设计这个结构时有一个隐性约定:page必须对应阅读器显示页码,不是 0 开始的对象索引。这个约定如果和源码包内部不一致,写入时会出现整体偏移,而且很难从代码里直接看出来。建议在清单文件头部加一个注释字段说明页码约定,尤其是同一批文件可能来自不同系统时。

4.2 主循环脚本:读清单、调写入函数、记录日志

主循环没有玄学,就是逐条读清单、逐文件调函数。但有一个参数值得留意:遇到错误是继续还是中断。批处理几十个文件时,某个文件损坏导致整批停下来是很恼火的,所以我会在循环里加异常捕获,并把失败文件记到日志里:

import json import logging import fitz logging.basicConfig(level=logging.INFO, filename="batch.log", format="%(asctime)s %(levelname)s %(message)s") def run_batch(manifest_path, input_dir, output_dir): with open(manifest_path, "r", encoding="utf-8") as f: manifest = json.load(f) success, failed = 0, [] for item in manifest: src = f"{input_dir}/{item['file']}" dst = f"{output_dir}/{item['file']}" # 把 JSON 字典列表转成 set_toc 需要的元组列表 toc = [(b["level"], b["title"], b["page"]) for b in item["bookmarks"]] try: doc = fitz.open(src) doc.set_toc(toc) # 会完全替换旧书签 doc.save(dst, garbage=4, deflate=True) doc.close() success += 1 logging.info(f"OK {item['file']} {len(toc)} 条书签") except Exception as exc: failed.append((item["file"], str(exc))) logging.error(f"FAIL {item['file']} {exc}") # 打印完失败清单再退出,方便直接看到结果 print(f"成功 {success} 个文件,失败 {len(failed)} 个") for fname, err in failed: print(f" {fname}: {err}") if __name__ == "__main__": run_batch("manifest.json", "input_pdfs", "output_pdfs")

这个脚本的处理逻辑里值得注意的地方有三处:doc.set_toc(toc)传入的是元组列表,JSON 里读出来的是字典列表,所以要先做一次结构转换,不然函数会直接报类型错误;garbage=4在保存大文件时可能耗时,但对后续链条是值得的;日志里记录成功文件的书签条数,回头核对时可以人工比对。

4.3 运行检查点:别等到全部跑完才验证

批处理跑完不验收等于白跑。跑完第一批 10 个文件后,我会立刻抽查其中 2 个输出,用浏览器自带的 PDF 阅读器打开看看侧边栏。这里不推荐用命令行工具判读,因为命令行输出只能说明书签写进去了,不能说明层级显示正确。检查时重点看三点:顶级条目是否覆盖了标题页和章节页,二级条目是否缩进在正确的父级下方,点击书签后实际跳到的页面是否和预期一致。

5. 避坑排查:处理书签时遇过的 5 个翻车点

5.1 整个文件读出来是空大纲,但阅读器里明明有书签

现象:用reader.outline或doc.get_toc()读一个 PDF,结果全是空列表,但用阅读器打开侧边栏却能看到书签。

原因:这类 PDF 的大纲存在方式不是标准的文档大纲树,而是通过 JavaScript 或 OpenAction 动态生成的,或者书签被放在了带权限加密的对象里,普通 API 读取不到。

解决:先用 PyMuPDF 强制解密读取,再判断能否取到大纲数据;如果还是空,换用读取侧库的强制模式,打开时传入doc = fitz.open(path)后检查doc.is_encrypted,如果为 True 先调用doc.authenticate("")按空口令处理。

5.2 写入后整体页码偏移了 1 到 2 页

现象:所有书签都能显示,点击第一章跳到了第 6 页,预期是第 5 页;或者全部书签统一往前错一页。

原因:清单页码按阅读器显示页码写的,但 PDF 内部设置了page_offset或包含封面页逻辑页,set_toc内部做转换时把第一页当成了逻辑页 0,导致每个页码都差一截。

解决:在跑批处理之前先检查目标 PDF 的页面标签偏移,用doc.get_page_labels()看起始页是从 0 还是从 1 开始。如果文档里看不到标签设置,直接手动减 1 或加 1 后试跑一次,对比阅读器实际跳转结果。

5.3 中文书签写入后乱码

现象:英文书签正常,中文书签在阅读器里显示成问号或乱码字符,复制出来也是错码。

原因:PyMuPDF 对文本编码有自动检测逻辑,但部分 PDF 内部页面的字体子集编码不完整,导致写入的标题引用了任务。

解决:写入之前先把标题字符串做一次 normalize,调用unicodedata.normalize('NFC', title)统一码点格式,再在set_toc里传入时显式编码为 UTF-8。仍然乱码的文件,检查该 PDF 是否嵌入了字体子集,缺字体的话,建议换 PDF 生成源头,而不是在书签上继续折腾。

5.4 文件有权限锁定,写入后无法保存

现象:doc.save抛出异常提示文件已被加密或不允许编辑。

原因:PDF 带作者级权限控制,只允许打印不允许修改,PyMuPDF 默认按只读方式打开,写入保存时触发了权限校验。

解决:打开时传入修改意图参数,doc = fitz.open(src)后检查doc.is_encrypted,用doc.authenticate("")处理空密码权限,仍然不通过就把该文件单独列入黑名单,批量外置处理。

5.5 批处理中途卡死,排查后发现是大文件的 PDF 用低配 CPU 跑不动

现象:批量脚本处理到某个 500MB 的扫描版 PDF 时长时间无响应,看起来像死锁。

原因:garbage=4对大文件做全量垃圾回收时,CPU 跑满,不是死锁而是回收耗时过长。

解决:给大文件单独配置不同的 GC 级别,超过 200MB 的文件先用garbage=3保存,减小单文件耗时;或者先检测文件大小再决定 GC 参数,比如大于 100MB 的文件跳过deflate。

6. 收尾技巧:用双阅读器核对大纲树,从生成端反查页码规则

写完书签不叫完事,叫开始验收。我会用两种不同内核的 PDF 阅读器打开同一个输出文件:一个用浏览器的内置 PDF 查看器,一个用桌面阅读器。两个阅读器对大括号解析的容错能力不同,如果其中一个侧边栏显示正常而另一个显示折叠异常,说明大纲树某个节点的层级标记有误,通常出现在连续两个同级条目之后突然出现子级时。

另一个实用习惯是把生成端的原因查一查。如果这批 PDF 来自某套硬件扫描工具或某个电子文档转换服务,它们对页码的起始定义往往有内建规则。比如扫描工具生成的 PDF 第一页大多是封面但页码从 1 开始,而某个公司的报表系统页码从 0 开始。查清生成端规则后,在批处理脚本里加一行全局偏移常量,比每个清单里手工改页码高效得多。

最后留一个我在某次交付任务里学到的教训:批量写入书签这类操作,“先清空再写入”这个动作无论在代码里多明确,也要在验收清单里核一遍。那次我没有做交叉验证,结果某批文件旧书签标注了 A 版本页码,写入后侧边栏混着两套页面索引,花了一整天才定位到问题。从那以后我每次批处理都会强制走一遍:先清空旧大纲、再检查生成端页码规则、最后双阅读器抽查输出。希望这个验证顺序也能帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询