
简介面向Python开发者的PDF书签处理源码包主要解决PDF文档书签的读取与批量写入问题。资源基于PyPDF2库提供了书签信息读取、层级遍历以及通过JSON配置批量写入书签的完整实现适用于需要自动化整理PDF目录、批量生成书签或构建电子书导航的开发者。资源包共2个文件包含1个可直接运行的Python脚本和1个依赖库压缩包整体约40KB解压即可对照使用。已有2515人学习/下载。脚本代码结构清晰演示了如何调用getOutlines()提取书签标题、层级与页码以及如何创建Destination和OutlineItem写入新PDF同时附带依赖库免去手动安装特定版本的麻烦。学习后可掌握PyPDF2操作书签的核心思路并能根据实际需求扩展为批量处理工具。1. Python PDF 书签批量处理从手工点击到脚本一键解决拿到一份几百页的 PDF最头疼的不是阅读而是侧边栏空空如也——没有书签想跳转只能靠滚动条硬翻。如果你经常处理合同扫描件、技术文档合并包或电子教材一定会遇到这种场景要么书签缺失要么书签层级混乱、页码对不上要么需要给大量文件批量写入统一格式的书签。这个项目就是干这个的用 Python 读取 PDF 已有书签、按目录文件批量写入新书签把原本需要在 Adobe Acrobat 里逐个手工操作的事变成一条命令。适合经常和 PDF 打交道的开发、文档管理员、图书制作人员不需要懂 PDF 内部格式也能直接跑起来用。2. 项目定位与技术选型为什么是 Python而不是 Acrobat 或在线工具2.1 项目解决的核心痛点PDF 书签在专业术语里叫 PDF Outline它是一棵树形结构每个节点包含标题、目标页码、子节点和跳转方式。手工维护这棵树非常痛苦尤其是批量场景某个出版团队一次性拿到 20 本电子书每本 400 页目录里有三级标题如果人工在 PDF 阅读器里逐个添加书签按平均每页 10 个书签计算工作量是 8000 次点击起步而且极易点错页面。这个项目抽取了两类最常见的书签操作一是读取已有 PDF 的书签结构并导出为可读格式二是根据外部目录文件TXT / CSV / Excel批量写入新书签。前者用于逆向已有文档的书签规范后者用于从零构建书签导航。两者结合就覆盖了「拆别人的书签」和「建自己的书签」两条完整链路。2.2 技术栈拆解PyPDF2 与 pdfrw 的分工实现 PDF 书签读写可选的 Python 库并不少但各有短板。PyPDF2是最老牌的库之一对书签outline的读取支持相对成熟能遍历出完整的父子层级但在写入书签时对中文字符的支持一直存在坑pdfrw是小巧的底层库胜在能直接操作 PDF 的 COS 对象写入可靠性高但接口比较原始没有现成的书签树 API。pikepdf是另一个常用选择基于 C 底层封装读写性能好但对生产环境的版本要求偏高。在实际工程中最稳妥的做法不是只押注某一个库而是按操作类型选型各取所长。读取书签用 PyPDF2写入书签用 pdfrw 配合深度拷贝方式规避单库的功能盲区。这个项目源码也正是采用了多库整合的思路把两类操作封装成独立模块互不污染。2.3 源码包目录结构总览拿到源码后建议先看目录结构再动手跑整体划分非常清晰pdf_outline_tool/ ├── reader/ │ ├── __init__.py │ ├── bookmark_parser.py # 书签读取解析器 │ └── output_formatter.py # 导出格式转换JSON/Markdown ├── writer/ │ ├── __init__.py │ ├── bookmark_builder.py # 目录文件解析与书签树构建 │ ├── bookmark_inserter.py # 写入 PDF 的核心封装 │ └── page_mapper.py # 页码映射与偏移修正 ├── cli.py # 命令行入口 ├── requirements.txt └── examples/ ├── sample_input.txt # 书签目录样本 └── sample_output.pdf # 输出示例每个模块的命名都直接对应职责reader 负责「读出来」writer 负责「写进去」cli 负责「串起来」。后面第三、四章会逐块拆解核心代码把读取链路和写入链路分别走一遍。3. 核心模块拆解书签读取链路从解析到导出的完整实现3.1 书签解析器PyPDF2 的 Outline 对象遍历读取 PDF 书签的核心逻辑在bookmark_parser.py核心思路是递归遍历 PyPDF2 的 outline 树。PyPDF2 会把书签节点暴露为两种类型Destination表示叶子节点OutlineItem表示包含子节点的中间节点。初学者最容易犯的错误是只按 list 结构迭代结果拿不到嵌套层级必须用递归或栈来处理。import PyPDF2 def extract_bookmarks(pdf_path): 递归读取 PDF 全量书签 返回结构: [{title: str, page: int, children: [...]}] result [] with open(pdf_path, rb) as f: reader PyPDF2.PdfReader(f) outline reader.outline def walk(nodes, depth0): items [] for node in nodes: if isinstance(node, list): # 子书签列表递归进入下一层 items.extend(walk(node, depth 1)) else: title node.title try: page reader.get_destination_page_number(node) 1 except Exception: page 0 items.append({ title: title, page: page, depth: depth, children: walk(node.children, depth 1) if hasattr(node, children) else [] }) return items result walk(outline) return result这段代码里最关键的是reader.outline这个属性PyPDF2 已经帮我们完成了对 PDF 目录字典的解析拿到的节点会天然混有OutlineItem和list两种类型所以在walk函数里专门做了一个类型判断。get_destination_page_number是 PyPDF2 新版本的 API老版本2.x 之前用的是reader.getDestinationPageNumber如果你跑起来报AttributeError优先检查 PyPDF2 版本而不是代码本身。代码第 15 行的depth参数记录了当前节点的层级这为后续导出 Markdown 目录时缩进所需的数据做好了铺垫。page字段统一加 1是因为 PDF 内部页码从 0 开始而用户眼里第一页是 1。3.2 导出转换器JSON 与 Markdown 两种输出格式output_formatter.py做的事是把上面解析出的嵌套列表转成两种实际可用的格式JSON 用于程序对接或二次处理Markdown 用于人工阅读和校对。导出逻辑不复杂但有个细节很容易被忽略页码偏移。同一份 PDF 在不同阅读器里显示的页码可能相差一个封面页所以导出时保留「物理页逻辑页」两个字段会实用得多。import json def to_json(bookmarks, output_path, page_offset0): clean [] for bm in bookmarks: clean.append({ title: bm[title], page: bm[page] page_offset, level: bm[depth], children: to_json(bm[children], output_pathNone, page_offsetpage_offset)[children] }) if output_path: with open(output_path, w, encodingutf-8) as f: json.dump(clean, f, ensure_asciiFalse, indent2) return {children: clean} def to_markdown(bookmarks, output_pathNone, indent_unit ): lines [] def walk(items): for bm in items: lines.append(f{indent_unit * bm[depth]}- {bm[title]} ..... {bm[page]}) walk(bm[children]) walk(bookmarks) text \n.join(lines) if output_path: with open(output_path, w, encodingutf-8) as f: f.write(text) return textto_json里嵌套转储有个小技巧递归函数里如果 output_path 传了 None就不会在中间层重复写文件只在最外层落盘。这是省去临时变量的常见做法但副作用是函数返回值结构有点绕读者看代码时注意children字段的拼接方式。to_markdown的indent_unit参数允许调用方自定义缩进单位默认两个空格也可以传\t变成制表符。对于二级三级标题混排的文献建议用两个空格而不是 Tab因为不同编辑器的 Tab 展示宽度不一致导出后放到 Git 仓库里对比 diff 时会出现大量无意义变更。3.3 读取链路的常见翻车点读取链路最典型的翻车场景是加密 PDF。PyPDF2 遇到带打开密码的 PDF 会在PdfReader初始化时抛FileNotDecryptedError但往往在运行几天后换了一批文件才暴露。处理方式是在入口处统一做一次判断if reader.is_encrypted: reader.decrypt()先尝试空密码解锁失败再提示用户输入。另一类坑是 PDF 使用非标准目录结构例如 WPS 导出的一些文件 outline 根节点不是数组而是空对象遍历时会直接返回None需要在walk入口补一个if nodes is None: return []兜底。3.4 命令行入口是如何把读取链路串起来的cli.py中读取相关的命令很简单python cli.py read -i input.pdf -o output.json --format json。参数解析用的是 argparse 标准库没有引入额外依赖。--format选项在内部就是判断分支分别调用to_json或to_markdown。设计上刻意把读取和写入拆成两个子命令避免后续扩展时因参数混用造成解析混乱这个拆分习惯也适用于其他批量文档处理工具的设计。4. 批量写入链路从目录文本到 PDF Outline 的分步实现4.1 目录文件的解析规则设计批量写入书签之前首先要确定外部目录的结构。源码中的sample_input.txt使用的是一种极简标记格式行首缩进表示层级标题与页码之间用逗号分隔。这种格式的好处是任何文本编辑器都能直接编辑不依赖 Excel 环境。第一章 Python 基础, 1 1.1 环境搭建, 3 1.1.1 安装步骤, 4 1.2 语法要点, 10 第二章 数据处理, 30解析逻辑在bookmark_builder.py中通过正则匹配行首空格数来确定层级深度再按逗号拆分标题和页码。缩进的计数单位是「每 2 个空格视为一级」但实际项目里传空格数量不定所以更稳妥的做法是把缩进量除以固定步长再取整。import re def parse_bookmark_file(txt_path): 解析带缩进的目录文本 返回: [{title: str, page: int, level: int}] entries [] with open(txt_path, r, encodingutf-8) as f: for line in f: line line.rstrip(\n) if not line.strip(): continue indent_match re.match(r^(\s*), line) indent_len len(indent_match.group(1)) level indent_len // 2 content line.strip() # 分割标题和页码兼容中英文逗号 parts content.rsplit(,, 1) title parts[0].strip() page int(parts[1].strip()) if len(parts) 1 else 1 entries.append({title: title, page: page, level: level}) return entries这里有个细节值得留意content.rsplit(,, 1)从右侧开始分割而不是通常的split(,)。这样做的原因是标题本身可能包含逗号比如「第一章Python 基础」如果从前向后分割会把页码之前的逗号也切进去。rsplit确保只有最后一个逗号被当作分隔符这个写法在解析 CSV 时也是个好习惯。缩进解析时用level indent_len // 2意味着一个 Tab 或多个连续空格会被折算成对应层级。你完全可以把// 2改成// 4来适配自己的排版习惯这个参数应当暴露成配置项而不是写死在函数里——源码包里预留了config.py的位置改造时挪过去即可。4.2 书签树的构建扁平列表如何变成嵌套结构解析出的entries是扁平列表但 PDF 的书签树是嵌套结构所以需要根据level字段把节点挂到对应的父节点下。核心思路是维护一个「上一层节点指针栈」遇到level递进时入栈回退时出栈。def build_tree(entries): 将扁平目录列表转为嵌套树 参数 entries: [{title: ..., page: ..., level: ...}] root [] stack [] # 栈中保存 (level, node_list) for entry in entries: node {title: entry[title], page: entry[page], children: []} while stack and stack[-1][0] entry[level]: stack.pop() if stack: parent_list stack[-1][1] parent_list[children].append(node) else: root.append(node) stack.append((entry[level], node)) return root这个实现比起递归构造要直观得多出栈条件stack[-1][0] entry[level]处理了跨级回退的情况比如从三级标题直接跳到一级标题中间的两级栈都会被弹出。stack里既存了层级数字又存了节点引用避免为了找父节点再遍历一遍整个树。这里有一个新手必踩的坑构建树时如果节点里没有保留level后续写入 PDF 时会丢失层级关系因为 PyPDF2 / pdfrw 写入书签时只认嵌套关系不认深度数字。所以node里刻意存了page与children但没有存level层级信息已经通过树的嵌套表达出来了不需要重复冗余。4.3 写入器核心pdfrw 深度拷贝引用的对象操作写入 PDF 是最容易踩深坑的地方。网上很多教程推荐用PyPDF2.PdfWriter.add_outline_item但在实际批量场景下会发现两个问题一是写入后的书签在 WPS 或 Preview 里显示乱码二是页码总是偏差一到两页。为了绕开这些问题项目选择用pdfrw直接构造 outline 对象。pdfrw 的思路是把 PDF 的 root 字典里的/Outlines节点替换成自己构造的字典。每个书签节点就是一个间接引用对象父节点通过/First和/Last指向第一个/最后一个子节点同级节点通过/Next和/Prev串联结构非常像链表。import pdfrw def map_page_numbers(pdf, bookmarks): # 先统计 PDF 每页的对象索引构建页码到实际 Page 引用的映射 page_map {} for i, page in enumerate(pdf.pages): page_map[i 1] page return page_map def create_outline_item(title, page_ref, childrenNone): # 用 pdfrw 构造单个书签节点 item pdfrw.IndirectPdfDict( Titlepdfrw.objects.pdfstring.PdfString.from_unicode(title), Parentpdfrw.IndirectPdfDict(), Countlen(children) if children else 0 ) item.Dest pdfrw.PdfArray([page_ref, /FitH, pdfrw.PdfObject(800)]) if children: first children[0] last children[-1] item.First first item.Last last for prev_node, next_node in zip(children, children[1:]): prev_node.Next next_node next_node.Prev prev_node return item def write_bookmarks(pdf_path, output_path, tree): pdf pdfrw.PdfReader(pdf_path) page_map map_page_numbers(pdf, tree) def build_nodes(items): nodes [] for bm in items: page_ref page_map[bm[page]] child_nodes build_nodes(bm[children]) if bm[children] else [] node create_outline_item(bm[title], page_ref, child_nodes) for child in child_nodes: child.Parent node nodes.append(node) return nodes outline_root build_nodes(tree) if outline_root: pdf.Root.Outlines pdfrw.IndirectPdfDict( Firstoutline_root[0], Lastoutline_root[-1], Countlen(tree) ) pdf.Root.PageMode /UseOutlines pdfrw.PdfWriter().write(output_path, pdf)这一段是整个源码最核心的部分。create_outline_item里Title用了pdfrw.objects.pdfstring.PdfString.from_unicode这是把中文标题正确编码进 PDF 的关键很多人直接用字符串赋值结果书签变成乱码。Dest数组里第一项是目标页面引用后面的/FitH表示跳转后按窗口宽度适应并定位到距顶部 800 个像素的位置这个数值在真实文档里建议改成pdfrw.PdfObject(0)或直接用/XYZ坐标数组因为不同 PDF 视图器的窗口高度不同固定值 800 不一定最佳。write阶段务必注意先设置pdf.Root.Outlines再执行PdfWriter.write顺序不能反。如果在读取 PDF 时就尝试写 Outlines 属性部分非规范 PDF 会抛出无法保存的异常。4.4 页码映射的修正机制写入书签常见的字母页如封面 i, ii与数字页正文 1, 2混排场景下目录里的「第 3 页」到底是指物理 PDF 第 3 帧还是书页上的罗马数字 iiipage_mapper.py提供了一个简单但有效的修正机制调用入口时接受一个physical_to_logical字典把逻辑页码转换为物理页码未在字典中的页默认按一一对应处理。def remap_pages(entries, mappingNone): if mapping is None: return entries for entry in entries: if entry[page] in mapping: entry[page] mapping[entry[page]] if children in entry: remap_pages(entry[children], mapping) return entries这个设计在批量处理前先人工抽样确认一次映射关系然后应用映射到全部条目能够有效避免「正文前有 10 页封面目录但目录文件从正文开始编号」的情况。如果你处理的是没有前置页的普通 PDF直接用默认映射即可不需要额外配置。4.5 写入后的验证与常见问题排查批量写入完成后很多人直接打开 PDF 草草翻两页这是不够的。建议写一个自动化验证脚本重新用 PyPDF2 把输出 PDF 的书签读取出来逐条比对标题和页码是否与原始目录一致。这个验证脚本恰好可以复用第三章实现的extract_bookmarks函数形成一个读写闭环。自动比对能发现两类典型问题页码偏移写入逻辑里 target page 少加或多加了 1和标题乱码PDF 字符串编码问题。如果验证发现写入后书签能显示但点击跳转不对优先检查page_map的键是否从 1 开始。如果 PDF 页面首帧其实是封面但你从正文编号需要构造一个{1: 3, 2: 4}之类的映射调整。这个排查路径适用于绝大多数写入异常场景。5. 部署运行环境准备、命令示例与避坑记录5.1 环境准备与依赖安装该项目依赖的第三方库非常克制requirements.txt里只有三个PyPDF2、pdfrw、pytest。前两者是功能主体pytest用于跑单元测试。安装前建议先确认 Python 版本为 3.8 至 3.11 之间PyPDF2 3.x 对 3.11 的适配较好如果用的 3.12 及以上部分旧的 PyPDF2 版本会触发命名空间变更导致的告警但通常不影响运行。cd pdf_outline_tool python -m venv venv source venv/bin/activate # Windows 上为 venv\Scripts\activate pip install -r requirements.txt安装完成后先跑一遍项目自带的测试用例验证环境没问题再处理自己的文件python -m pytest tests/ -v5.2 读取与写入的完整命令示例读取现有 PDF 书签并导出为 JSONpython cli.py read -i docs/manual.pdf -o bookmarks.json --format json若想直观查看层级导出为 Markdownpython cli.py read -i docs/manual.pdf -o bookmarks.md --format markdown将toc.txt中的目录批量写入新 PDFpython cli.py write -i source.pdf -o output.pdf -t toc.txt如果需要修正前置页偏移例如正文页码从第 5 帧开始python cli.py write -i source.pdf -o output.pdf -t toc.txt --page-map 1:5,2:6--page-map参数在内部就是传给remap_pages的字典格式为逻辑页码:物理页码逗号分隔。真实使用时建议先看一眼 PDF 首页帧再决定是否需要配置偏移。5.3 避坑记录现象、原因与解决现象书签写入了但打开后全部显示为「未标题」或英文乱码。 原因直接给Title赋了 Python 字符串pdfrw 不会自动转成 PDF 字符串编码格式。 解决统一改用pdfrw.objects.pdfstring.PdfString.from_unicode(title)所有标题都经由这个包装器处理。现象PyPDF2 读取某些扫描版 PDF 时返回空的书签列表但 Adobe 里能看到。 原因这类 PDF 的书签是存放在「文档级命名目标」而非标准 Outline 树中PyPDF2 只解析了标准 outline。 解决先用 Adobe 或在线工具把书签转为标准 outline 结构或者改用pikepdf的pdf.doc.viewer_preferences相关接口读取源码里预留了 reader 扩展点可以在这里接入第二解析器。现象批量写入后文件大小暴涨好几倍。 原因每写一个书签节点时把整个 PDF 页面对象都复制了一遍引入了大量冗余间接引用。 解决写入时不要重新构造页面对象直接用原始pdf.pages的引用如果使用 PyPDF2 的 add_outline_item需要开启update_page_data为 False 以禁止整页复制。现象目录文本里包含「第 1 章」的页码为 1但实际正文前有 10 页封面书签全部偏移。 原因没有配置--page-map默认按物理帧页对应。 解决先抽取 PDF 前三页缩略图确认正文起始位置再传入--page-map 1:11,2:12这样偏移或者把目录文件里的页码直接改成物理页。现象写入后文件在 Chrome 和 WPS 中能正常显示书签在 Preview 中全部消失。 原因Preview 对/Outlines节点的/PageMode属性要求严格如果写入时没有设置Root.PageMode /UseOutlines部分阅读器默认不展开侧边栏。 解决写入时保留设置pdf.Root.PageMode /UseOutlines这一步并确认/Outlines节点的/Count等于顶层书签数量。6. 二次开发实战自定义目录格式、调整书签属性和接入批处理管线拿到源码后最常见的改造需求是把目录文件的格式换成自己团队的模板。比如你已经有一张 Excel 目录表列分别是「标题、页码、层级」只需要把parse_bookmark_file换成读取 Excel 的版本。源码中bookmark_builder.py的parse_bookmark_file被设计成返回统一的entries字典列表所以替换解析器不会影响后面的建树和写入逻辑。我在改造自己的文档管线时会额外加一个「层级修正」步骤。人工维护 Excel 时经常出现「同一级标题有的缩进 2 空格、有的缩进 4 空格」这类混乱情况建树时会导致同级节点被拆成父子关系。我的习惯是在build_tree之前加一道归一化操作统计所有出现过的缩进量按从小到大排序映射为连续的 0、1、2… 层级。这样原本用不同空格数的文件也能被正确识别。另外一个值得扩展的点是书签的打开动作。create_outline_item里硬编码了/FitH 800这在宽屏显示器上会产生很大的顶部留白。我一般会把它改成/XYZ null null 0表示跳转后保持原缩放比例、定位到页面左上角更符合阅读习惯。改造时只需要把参数提取为open_action配置项在cli.py里通过--view-mode传入即可。接入批处理管线时最实用的做法是把read和write两个子命令封装成可以被其他脚本 import 的函数而不是每次用 subprocess 调用。比如在cli.py中增加两个公开函数def process_batch(pdf_list, toc_dir, output_dir, page_map_generatorNone): for pdf_path in pdf_list: toc_path os.path.join(toc_dir, os.path.basename(pdf_path).replace(.pdf, .txt)) out_path os.path.join(output_dir, os.path.basename(pdf_path)) if page_map_generator: mapping page_map_generator(pdf_path) args [pdf_path, out_path, toc_path, mapping] # 调用 write 子逻辑 else: args [pdf_path, out_path, toc_path, None] run_write(*args)这里page_map_generator接收 PDF 路径返回映射字典实现了「每个文件可以有不同的前置页偏移」的批处理需求。批量生产环境下建议每次处理完一个文件就立刻用验证脚本比对书签标题和跳转页不要把验证推迟到最后不然等 20 个文件都写完才发现页码偏差还得重新生成一遍。生成后的文件命名也建议加上时间戳或版本号避免覆盖原始文件无法回退这是我在多次批量操作后养成的习惯——每次跑写入之前都先看一眼原始 PDF 是否已备份。从拿到源码到真正用顺手最重要的一件事是把验证脚本放到你的持续流程里。从那以后我每次写完书签都会强制走一遍「读回验签」的环节它在一开始会显得多此一举但当你遇到一次因页码映射错误导致的批量返工就会理解这一步的含金量。希望这份源码和这篇解析能帮你省下几小时的手工点击时间。本文还有配套的精品资源点击获取