ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python+PyPDF2实现PDF书签批量读取与写入:源码拆解与工程实践

Python+PyPDF2实现PDF书签批量读取与写入:源码拆解与工程实践

简介:面向需要批量整理 PDF 书签的 Python 开发者,这份源码以 PyPDF2 为核心,完整实现了从现有 PDF 读取书签,以及依据 JSON 结构化数据批量写入新书签并输出新文件的流程。压缩包共含 2 个文件,包含一个 Python 脚本和一个 gz 格式的依赖库源码包,总体积仅 40KB,便于快速部署和离线安装依赖。已有 2516 人学习或下载,说明该方案在实际使用中具备一定参考价值。脚本对读取和写入分别封装了清晰函数,读取侧基于 getOutlines 获取层级、标题和页数,写入侧通过 Destination 与 OutlineItem 生成书签目录;同时注意 PyPDF2 无法直接修改原 PDF,因此示例会将结果保存为新文件,便于保留原文件不被破坏。读者可在此基础上调整 JSON 字段、嵌套层级或结合 pdfrw 补充水印、权限等处理,非常适合自动化整理电子书目录、批量维护技术文档大纲的办公与开发场景。

1. 从“拆书”到“重构书”:PDF 书签批量处理的工业化解法

如果你碰过那种动辄上千页的 PDF 技术手册、代码规范或者课程讲义,一定经历过这样的“玄学”时刻:好不容易下载到资料,打开却发现没有书签,想跳到某一章只能靠滚动条盲猜;或者书签倒是有一堆,但全是“第1章”“第2章”这种自动生成的占位名,跟实际内容对不上。手动在 WPS 或 Adobe Acrobat 里一个个补书签,200 个条目够你点一下午,还容易点错页码。

这个 python 实现的 PDF 书签读取与批量写入源码,就是来解决这个痛点的。它不是一个套壳工具,而是一套可以直接拿来用、二次开发的 Python 源码:既能读出现有 PDF 的内部书签结构,也能把经过整理的书签列表批量写回 PDF,还能按关键字对书签做批量增删改。适合需要批量处理电子教材、技术文档、论文集的开发者,也适合想了解 PDF 内部对象结构和第三方库用法的进阶学习者。下面直接拆源码,讲结构、讲跑法、讲改法。

2. 技术选型与工程结构:为什么挑 PyPDF2 而不是 PDFMiner

2.1 处理 PDF 书签的技术路线对比

在拆源码之前,先聊一下这类项目常见的“修路方案”。PDF 书签在内部是一棵章节树,每个书签条目会指向一个目标页码或一个命名目的地。处理这棵树,Python 生态里主流有两条路线:一条是 PyPDF2 / pypdf 系列,走的是“对象映射 + 树操作”的路线,能读能写,API 相对抽象;另一条是 PDFMiner / pdfplumber,强项是文本抽取和坐标定位,用来读书签反而绕远路。

这个源码选的是 PyPDF2 路线,这一点非常务实。批量写入书签本质是修改 PDF 的 /Outlines 对象,PyPDF2 提供add_outline_item()这类接口,能直接操作目录树,不用自己去解析交叉引用表。而 PDFMiner 的设计初衷是“把页面内容抽出来”,对书签树的写支持基本是空白。也就是说,你要做“批量写入”,PyPDF2 是当下最顺手的轮子。

提示:如果你拿到的 PDF 是扫描版,没有文本层,PyPDF2 依然能写书签。书签写的是“跳到第几页”的指令,不依赖页面里的 OCR 文本。

2.2 源码目录结构与职责划分

这套源码解压后的结构比较干净,符合一个“工具类项目”的常规布局。核心逻辑都在根目录下的两个模块里,配置文件和数据文件单独拎出来,方便你直接替换输入输出。我列出关键文件:

文件职责
reader.py读取 PDF 现有书签,输出结构化 JSON / CSV
writer.py依据书签配置写入 PDF,支持覆盖与追加
config.py管理输入输出路径、写入模式、目标页码偏移
requirements.txt依赖清单:PyPDF2、pyyaml 等
bookmarks_sample.json书签批量写入的模板文件
fonts/部分版本用于绘制书签备注的自定义字体

和大多数“能跑就行”的脚本不同,这个项目把读和写拆成两个入口,核心是避免“读写耦合”。你读出来检查一遍,改完配置再写入,每一步都有明确的输入输出边界。config.py则是全局的“操作面板”,文件路径和模式参数都集中在这里,改参数不用翻代码。

2.3 核心依赖:PyPDF2 的版本陷阱

这个项目能跑起来的最大“坑”其实在依赖版本上。PyPDF2 在 2.0 之前和之后,API 变化非常大。老版本的addBookmark()在新版本里已经改名成add_outline_item(),读取接口也调整了返回结构。

源码的requirements.txt里锁的是PyPDF2==2.11.1这一代版本(部分分支用pypdf==3.x也兼容,但接口略有差异)。如果你机器上装了最新版 PyPDF3 或 pypdf 4.x,直接跑大概率会报AttributeError: 'PdfWriter' object has no attribute 'add_outline_item'。

使用者建议:先按requirements.txt建虚拟环境安装,不要用全局环境硬跑。后面章节我会给完整的建环境命令。源码里凡是调用add_outline_item()的地方,我都会在讲解后标明参数含义,方便你对照版本差。

3. 核心模块拆解:一个请求从进入到返回的完整链路

3.1 reader.py:把书签树“摊平”成字典

读取模块的入口函数是read_bookmarks(pdf_path),它接收一个 PDF 文件路径,返回一个嵌套的 Python 字典。这个字典的结构设计非常直白:顶层是书签列表,每个元素包含title(书签名)、level(层级,1 表示一级书签)、page(实际页码),如果有子书签则嵌套在children字段里。

# reader.py 核心代码(节选) def read_bookmarks(pdf_path): reader = PyPDF2.PdfReader(pdf_path) outlines = reader.outline # 获取书签树的根节点 def walk(items, level): result = [] for item in items: if isinstance(item, list): # 子书签是一个列表,递归解析 result.extend(walk(item, level + 1)) else: page_num = reader.get_destination_page_number(item) result.append({ "title": item.title, "level": level, "page": page_num + 1, # 转为 1 起始页码 }) return result return walk(outlines, 1)

这里的关键是reader.outline返回的对象。PyPDF2 把书签树解析成嵌套列表,列表的每个元素要么是Destination对象,要么是一个子列表。isinstance(item, list)这个判断就是在区分“这是一个书签”还是“这是一组子书签”。递归的层级参数level负责记录缩进深度,写入回 PDF 的时候要靠它还原目录树的层级关系。

参数说明里有两个细节值得注意:get_destination_page_number()返回的是从 0 开始的页码索引,所以代码里+1转成人类习惯的页码;item.title拿到的是书签显示文本,不经过额外解码,如果书名包含特殊字符,后面写回时要用escape处理。这一步读出来的 JSON 就是你批量操作的原料。

3.2 writer.py:三种写入模式的设计

写入模块是这套源码的重头戏。它支持三种模式:append(在现有书签末尾追加)、overwrite(先删光旧书签再写入)、update(按标题匹配更新页码)。三种模式的入口都是write_bookmarks(pdf_path, bookmarks, mode)。

# writer.py 核心代码(节选) def write_bookmarks(pdf_path, bookmarks, mode="append"): reader = PyPDF2.PdfReader(pdf_path) writer = PyPDF2.PdfWriter() writer.append(reader) if mode == "overwrite": writer.outline = [] # 清空现有书签树 elif mode == "update": existing = _flatten_bookmarks(reader.outline) bookmarks = _merge_by_title(existing, bookmarks) for bm in bookmarks: parent = None if bm.get("level", 1) > 1: # 寻找同级父书签的逻辑(按标题栈匹配) parent = _find_parent(writer.outline, bm["parent_title"]) writer.add_outline_item( title=bm["title"], page_number=bm["page"] - 1, parent=parent ) with open(pdf_path, "wb") as f: writer.write(f)

add_outline_item()的第二个参数page_number原来接收从 1 开始的页码,但 PyPDF2 2.11 版本里它会自动减 1,所以这里传入bm["page"] - 1,保证和 reader 读出的页码对齐。第三个参数parent是父书签对象,如果层级大于 1,必须指定父节点,否则书签会被直接挂到根节点,层级关系彻底丢失。

源码里_find_parent()的实现是用标题栈来追踪上一级节点的。它维护一个字典,key 是父书签标题,value 是刚写入的 outline item 对象。这种做法比遍历整棵树快得多,尤其在书签数量上千时差距明显。如果你改代码时遇到层级错乱,九成是这里的父节点匹配失败——比如两个章节用了同名标题。

3.3 批量写入的入口:从 JSON 配置到 PDF 输出

源码运行时并不是直接调用writer.py里的函数,而是通过convert.py(或主入口脚本)读取一个 JSON 配置文件,循环处理多个 PDF。这个设计对“批量”两个字非常关键。

# convert.py 批量处理入口(节选) import json, glob from writer import write_bookmarks with open("bookmarks_sample.json", "r", encoding="utf-8") as f: tasks = json.load(f) for task in tasks: pdf_path = task["pdf"] bm_list = task["bookmarks"] write_bookmarks(pdf_path, bm_list, mode=task.get("mode", "append")) print(f"处理完成: {pdf_path}")

JSON 里每一项就是一个 PDF 的书签列表,pdf字段指定目标文件路径。这样做的好处是:你不需要为每一个 PDF 单独写一段脚本,只要维护好 JSON 数据,就能一次跑完几十个文件的书签写入。实际工程里,我一般会把 JSON 生成这一步自动化——从数据库或 Excel 里导出一个“书名、章节标题、起始页码”的表格,转成 JSON 后直接投喂给这个脚本。

4. 完整运行指南:从裸机环境到批量写入不翻车

4.1 环境准备与依赖安装

先强调一次:这个项目对 Python 环境的依赖不算苛刻,Python 3.8+ 即可,但 PyPDF2 的版本必须锁死。我跑通时的操作步骤如下,直接复制即可:

# 创建虚拟环境(避免污染全局) python -m venv .venv source .venv/bin/activate # Windows 下为 .venv\Scripts\activate # 安装依赖 pip install -r requirements.txt # 查看核心依赖版本 pip show PyPDF2

requirements.txt内容大概长这样:PyPDF2==2.11.1、pyyaml==6.0。如果你之前装过 pypdf,pip show PyPDF2可能显示不出来,这时候要检查是不是被 pypdf 顶掉了。这两个包名虽然只差一个字母,但在 import 时都叫PyPDF2,容易让人傻眼。建议装完依赖后跑一句python -c "import PyPDF2; print(PyPDF2.__version__)"确认。

4.2 第一步:读取现有 PDF 的书签

运行python reader.py your_file.pdf,会在同目录下生成一个bookmarks.json。这个 JSON 就是你查看原始书签结构的入口。如果你只是想给一份没有书签的 PDF 新增书签,那么可以跳过读取这一步,直接写 JSON 配置文件。

python reader.py 技术手册.pdf --output bookmarks.json

生成的结果里,每个书签条目会包含title、level、page三个字段。这里要留一个心眼:level是源码自己推算的,如果 PDF 原始书签结构不规范(比如目录树出现跳跃层级),这个值可能和你预期不符,但不会影响写入——后面写回时严格按这个 level 建树。

4.3 第二步:准备书签数据(JSON 模板)

没有现成书签的话,模仿bookmarks_sample.json的结构新建一个文件。以下是一个三级书签的示例:

[ { "title": "第1章 环境准备", "level": 1, "page": 3 }, { "title": "1.1 安装配置", "level": 2, "page": 4, "parent_title": "第1章 环境准备" }, { "title": "1.2 快速上手", "level": 2, "page": 7, "parent_title": "第1章 环境准备" } ]

parent_title字段是给writer.py的_find_parent()用的,它必须在同一批书签里出现过,且层级比当前书签小 1。如果你漏了这个字段,脚本会默认挂到根节点,最后生成的 PDF 目录会变成“平铺”结构——所有书签挤在一层,虽然能点,但不美观。

页码这里填的是「PDF 实际页码」,也就是你在阅读器状态栏看到的数字。如果 PDF 正文从第 10 页开始,第一章起始页写 10 就行,不用扣除封面版权页。源码内部没有做偏移修正,因为不同 PDF 的页眉布局差异太大,自动化猜容易翻车。

4.4 第三步:执行批量写入

写入命令同样简单:

python convert.py --config my_bookmarks.json --mode update

--mode参数覆盖 JSON 里每个任务的模式。推荐新手先用append模式在副本 PDF 上试一把,确认目录层级无误后再切overwrite。如果一次处理多个 PDF,--config指向的 JSON 里需要包含多个任务对象,每个对象独立的pdf和bookmarks字段。

跑完后用 PDF 阅读器打开文件验证。重点看三处:目录树是否按层级缩进、每一条点击后是否跳到正确的页、特殊字符标题(比如含引号、括号)是否正常显示。如果点击错位,通常不是代码问题,而是你的 JSON 页码本身填错了。

4.5 避坑指南:四个高频翻车点

我跑这个项目时踩过不少坑,这里挑四个最典型的写出来,按“现象 → 原因 → 解决”的顺序。

坑一:AttributeError: 'PdfWriter' object has no attribute 'add_outline_item'现象:运行 writer 模块直接报错,函数不存在。 原因:PyPDF2 2.0 以下版本用的是addBookmark,新版本才叫add_outline_item;如果你装的是 pypdf 4.x,这个属性虽然还在,但参数名又变了。 解决:严格按requirements.txt锁定PyPDF2==2.11.1,不要升级大版本。

坑二:写入后 PDF 打开提示“文件已损坏,是否修复”现象:用 PyPDF2 写完文件,阅读器弹出修复提示。 原因:写入过程把 PDF 的交叉引用表搞坏了,常见于原文件本身带有复杂的增量更新结构,writer.append(reader)直接复制对象流出错。 解决:先对原 PDF 做一次“瘦身”——用qpdf --object-streams=disable重写一遍,再跑脚本处理。

坑三:书签层级全部变成一级现象:目录里所有书签都平铺,没有缩进。 原因:JSON 里level字段大于 1 的书签没有对应的parent_title,或者parent_title匹配不到。 解决:检查parent_title是否和父书签的title完全一致,包括空格和标点。不一致时_find_parent()找不到目标,返回 None 挂到根节点。

坑四:读取书签时中文乱码现象:reader.py输出的 JSON 里中文标题变成类似\u00e4的乱码。 原因:PDF 内部书签的编码可能是 UTF-16,而 PyPDF2 旧版本解码不全。另外,部分 PDF 生成工具用自定义编码,PyPDF2 拿到的是原始字节串。 解决:先升级到PyPDF2==2.11.1,此版本对标准 UTF-16 支持较好;若仍乱码,在 reader 的返回前对item.title做一次encode('latin-1').decode('utf-8')强制转换。

5. 源码改造实战:把“通用工具”变成“私人流水线”

5.1 改造一:对接 Excel 或数据库,自动生成书签 JSON

很多实际场景里,书签数据不是手写的,而是存在 Excel 或业务系统里。这个源码的接口很适合接一条自动化链路:从 Excel 读取“章节标题、页码、层级”,直接生成 JSON 配置,然后调 writer 写入。我实际做过一个版本,把bookmarks_sample.json替换成从pandas生成的字典列表,省掉了中间文件步骤。

# 从 Excel 直接写书签的改造示例 import pandas as pd from writer import write_bookmarks df = pd.read_excel("目录.xlsx") bookmarks = [] for _, row in df.iterrows(): bookmarks.append({ "title": row["章节"], "level": int(row["层级"]), "page": int(row["页码"]), "parent_title": None if row["父级"] != row["父级"] else row["父级"] }) write_bookmarks("目标.pdf", bookmarks, mode="overwrite")

这里的关键是pd.read_excel读进来的数据要严格校验:页码必须是整数,层级必须连续完整。如果 Excel 里有合并单元格导致某行父级为空,写入时就用None降级为根节点,这样至少不会翻车。做过一次耦合后,你的“新 PDF + 新目录”更新流程就完全解放双手了。

5.2 改造二:按页码区间批量插入书签

当你有一份 PDF 是从多个小文件合并而成,想按每个小文件的起始页插入书签,也可以改造 writer 的调用方式,让它脱离 JSON,直接接受一个“起点页码 + 标题”的列表。这里我常用一个技巧:在writer.py入口处新增一个add_interval_bookmarks(pdf_path, ranges),内部把这个列表转成bookmarks格式再进入原有流程。

# 按区间批量插入书签 def add_interval_bookmarks(pdf_path, ranges): bookmarks = [ {"title": title, "level": 1, "page": start} for title, start in ranges ] write_bookmarks(pdf_path, bookmarks, mode="append")

参数ranges是一个(标题, 起始页码)的元组列表,比如[("卷一", 1), ("卷二", 200), ("卷三", 400)]。追加模式下,这些书签会挂在根目录之下,和原书签并存。如果你想让每个“卷”成为二级书签,再多传一个level参数就行。这套改造的爽点在于不用维护 JSON,适合在循环里处理几十个文件。

5.3 验证思路与收尾习惯

改造完成后,验证永远比写代码更花时间。我的习惯是:先在样本 PDF 上跑,用阅读器打开目录逐条点击页码,重点点位是第一页和最后一页。批量处理多个文件时,写一个自动校验脚本,比对每个书签页面是否落在预期区间内。从那以后,我每次改完代码都会强制走一遍“读-改-写-校验”四步,宁愿让脚本多跑两分钟,也不愿交出带错位书签的成果。希望这份源码的拆解,能让你在批量 PDF 处理这件事上少折腾几回。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进