news 2026/9/25 13:38:54

Python批量处理PDF书签:pypdf读写与页码换算实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python批量处理PDF书签:pypdf读写与页码换算实战

简介:Python实现PDF书签读取与批量写入的源码包,面向需要使用PyPDF2处理PDF文档目录的Python开发者。压缩包共2个文件,核心是一个.py脚本,另有1个gz格式的依赖库压缩包,整体体积约40KB;脚本主逻辑清晰,从读取现有书签的层级、标题和页码,到将JSON文件中的自定义书签数据批量写入并生成新PDF,均有可直接运行的示例。通过学习这段源码,可快速掌握getOutlines()、Destination、OutlineItem等PyPDF2关键接口的实际用法,并理解书签在PDF内部的组织方式,适合用于电子书目录维护、批量整理技术文档等场景。已有2513人学习或下载该资源,配套代码精简,稍作修改即可接入自己的文档处理流程,甚至扩展出多级书签自动生成、目录导出等功能。

1. 从“PDF 没目录”说起:批量为 PDF 补书签的 Python 实现值不值得做

手头几十本扫描版教材、会议 PPT 转出的 PDF,侧边栏点开一片空白,几百页只能靠滚动条硬拖。用 Python 实现 PDF 书签读取、批量写入源码,本质就两件事:把 PDF 内部已有的大纲树完整读出来,再把整理好的目录批量写回 PDF。它解决的不只是“找章节”,而是让一批没目录的资料一次性具备可用导航。适合整理电子书库、沉淀文献、做内部资料归档的人,不需要多深的 PDF 规范基础,但得愿意先把页码和层级关系搞清楚。读写本身不难,真正让新手翻车的是页号换算和父子层级,这两点搞明白,这套源码才落地得稳。

2. 先搞懂 PDF 书签的结构:大纲树、页内定位和 3 个关键对象

2.1 书签不是“文本层”:PDF 大纲树的父子层级逻辑

做 PDF 解析的都知道,PDF 文件不是一个连续文本块,而是一堆对象的集合。顶层有个 Catalog(根目录),根目录里有一个可选的 Outlines 入口,这个入口指向一棵“大纲树”。阅读器左侧栏里那些可折叠的目录,规范名称叫 outline,每个可点开的条目叫 outline item。换句话说,我们常说的 PDF 书签,在文件内部不是文字层,而是结构化对象。

这棵树的组织方式有点老派:每个 outline item 用 Parent、First、Last、Next、Prev 这几个指针互相串起来,Parent 指向父节点,First/Last 指向第一个和最后一个子节点,Next/Prev 指向前后兄弟节点,Count 统计子树里有多少条目。树的高度和缩进不靠数字记录,而是靠“挂在哪个父节点下面”来体现。这就是为什么写书签时,明确 parent 是谁比传一个层级数字更重要。

我用 pypdf 先把一棵现成的大纲树原样打出来:

from pypdf import PdfReader reader = PdfReader("sample.pdf") def walk_outline(items, level=0): if items is None: print("[no outline]") return if not isinstance(items, list): items = [items] # pypdf 有时返回单个条目,先归一化 for it in items: if isinstance(it, list): # 嵌套 list 表示一组子书签 walk_outline(it, level + 1) continue if it is None: continue title = getattr(it, "title", str(it)) print(" " * level + title) walk_outline(reader.outline)

这段代码的关键点是:reader.outline拿到的不是一棵规整树,而是靠嵌套 list 表达层级的结构。单个顶层书签就是一个Destination对象,多个顶层书签才是 list;书签有子节点时,子节点又是一个 list 套进来。不先归一化,直接用for it in items很容易在递归时把对象当成列表或反过来。pypdf 里 outline item 没有暴露 level 属性,层级只能靠递归深度推算。

2.2 读取和写入的库怎么选:pypdf、PyMuPDF 和 pdfplumber

选库之前先明确一个立场:书签读写是“结构操作”,不是“文本提取”,不要用 pdfplumber 干这件事。pdfplumber 的强项是抽取正文文字、表格坐标,对大纲树的支持很弱。真正常用的读写入库就两个主流选项,它们的关系用一张表说清楚:

库读取大纲写入大纲依赖形态适合场景
pypdf完整支持 add_outline_item纯 Python批量脚本、精细控制、跨平台部署
PyMuPDF完整支持 set_toc/get_toc本地二进制扩展单文件快速处理、整体重建大纲
pdfplumber弱不支持偏文本提取正文文字与表格抽取

我的选型结论很直接:批量处理一律用 pypdf。原因也简单,它纯 Python 实现,pip install pypdf装完就完事,不依赖本地编译产物。工作机从 Windows 切到 Linux 再切到 macOS,同一套代码不用重新折腾环境。PyMuPDF 单文件处理确实快,它的get_toc和set_toc接口短平快,但它带本地二进制,离线和跨平台安装偶尔会卡住,而且set_toc是“整体替换”语义,后面第 4 章会专门讲这个坑。

这里顺带提一句:如果你手里还留着 PyPDF2,新项目别再用它了。PyPDF2 维护停滞后,社区把代码迁到了 pypdf,很多书签相关的解析 bug 是在 pypdf 里修掉的,用旧库复现同样的功能会白踩一堆已经没人再报的坑。装环境的时候多花一分钟确认装的是 pypdf,后面写代码能少两小时。

2.3 页内定位:为什么书签页码总差 1 或差 N

书签的落点最终要对应到某一页。PDF 内部对页的计数是从 0 开始的物理位置索引:Pages 树里第 0 个 page 对象就是“第 0 页”。阅读器状态栏显示给用户看的页码从 1 开始,这是第一层差异。更麻烦的是第二层:很多 PDF 的正文前面塞了封面、扉页、版权页、目录页,这些页面不参与书上的页码。目录里印着“第 5 页”的内容,实际落在 PDF 的第 8 张纸甚至第 10 张纸上。

这个差值我最开始没当回事,直到写完书签在阅读器里点开,跳转位置全是偏的,才意识到页码换算是整个方案里最需要单独处理的部分。差 1 的情况用下面这个基础换算能解决:读取侧get_destination_page_number返回 0-based 物理索引,写入侧要求 1-based 物理页号。差 N 的情况则需要先探测这本书的偏移量,常见做法是打印前 10 页的首行文本,肉眼确认封面、目录、正文各占了哪几页:

from pypdf import PdfReader reader = PdfReader("book.pdf") print(f"total pages: {len(reader.pages)}") for i in range(10): text = reader.pages[i].extract_text() or "" first_line = text.strip().splitlines()[0] if text.strip() else "(empty)" print(i, first_line[:40])

运行后你会看到类似这样的输出:第 0 页是封面书名,第 1~3 页是版权和序言,第 4 页是目录,第 5 页才是正文第一章。“第一章”在目录里印着第 1 页,但对应 PDF 物理页第 5 页,此时 offset = 5 - 1 = 4。这个 offset 不是全局固定值,每本书都要单独算一次。很多开源脚本只处理差 1,不处理差 N,本质上是把 offset 写死成了 1。

3. 读取 PDF 书签:递归遍历 + 页码换算 + 导出清单的完整源码

3.1 健壮的书签读取函数:list、Destination 和命名目的地一网打尽

读取侧最容易出的问题是把reader.outline当成规则树。上一步的打印脚本可以人工看,但要做批量处理就得写一个能自动递归、能处理各种杂鱼结构的收集函数。我一般这样写:

from pypdf import PdfReader def page_of(reader, item): try: # pypdf 新版本提供的快捷方法,返回 0-based 物理页索引 return reader.get_destination_page_number(item) + 1 except AttributeError: # 老版本或命名目的地对象,退回用页面对象定位 return reader.pages.index(item.page) + 1 def collect_outline(reader, items, level, out): if items is None: return if not isinstance(items, list): items = [items] for it in items: if isinstance(it, list): # 一组子书签,层级加一,用循环主体继续递归 collect_outline(reader, it, level, out) continue if it is None: continue title = getattr(it, "title", getattr(it, "name", str(it))) page = page_of(reader, it) out.append([level, title, page]) reader = PdfReader("book.pdf") items = [] collect_outline(reader, reader.outline, 1, items)

这段代码解决三个实际问题。第一,isinstance(items, list)归一化:pypdf 对单个顶层书签返回Destination对象,对多个返回 list,不归一化会在第一个文件就炸。第二,page_of做了双保险,新版本 pypdf 有专用方法,老版本只能通过reader.pages.index(item.page)去查,注意后者的复杂度是 O(n),几百页的 PDF 无所谓,上万页的巨型 PDF 建议还是升级库版本。第三,title取不到时降级到name,这是为了兼容命名目的地(named destination)类型的书签,它的对象结构里没有 title 属性,只有 name。

3.2 页码换算:0-based 索引、1-based 页号和书中印的页码

拿到书签之后,大多数导出场景要的是“目录里印的页码”,而不是 PDF 物理页号。物理页号是绝对坐标,印的页码是逻辑坐标,两者差一个 offset。这个 offset 在第 2.3 节已经算过,这里把它做成两个显式函数,避免在每个地方手写加减:

# 下面是写入侧与读取侧的统一换算入口 # 读取侧:物理第 0 页 = 状态栏第 1 页 # 写入侧:add_outline_item 要求的 page_number 必须是 1-based 物理页号 def physical_to_visual(p, offset=1): return p + offset def visual_to_physical(p, offset=1): return p - offset # 例:书中印“第 12 页”,offset=4 表示前 4 页不参与印页码 # 则传给 add_outline_item 的物理页号是 12 - 4 = 8 print(visual_to_physical(12, offset=4)) # 8

我特意把两个函数都写出来,是因为读和写方向相反:从 PDF 里读到的书签是物理页号,转成目录页码要用physical_to_visual;反过来把外部整理好的目录写进 PDF 时用visual_to_physical。不少人在同一个脚本里混用这两个方向,最后交给阅读器一个偏了 offset 的页码。先把换算单独拎出来做单元测试,再进批量流程,能省大量排错时间。

3.3 导出成 Markdown 和 CSV:给批量写入当输入文件

读取的成果最终要落盘。Markdown 适合人眼检查和贴进笔记软件,CSV 适合给后续脚本当输入。两个都要的话,一个函数搞定:

import csv def export_md(items, path): with open(path, "w", encoding="utf-8") as f: for level, title, page in items: indent = " " * (level - 1) f.write(f"{indent}- {title} ... 第{page}页\n") def export_csv(items, path): with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["level", "title", "page"]) writer.writerows(items) export_md(items, "bookmarks.md") export_csv(items, "bookmarks.csv")

CSV 用utf-8-sig而不是utf-8,这算一个陈年经验:Windows 上的 Excel 对无 BOM 的 UTF-8 会按本地代码页解读,中文标题会乱成一团,加 BOM 后双击打开就是正常中文。Markdown 里的“第几页”我故意写成中文自然语言,它本来就是给人看的,保持可读性比格式严格更重要。到这一步,读取侧闭环完成:PDF 进来,结构化的书签清单和页码清单出去。

4. 批量写入 PDF 书签:add_outline_item 最小代码和批量脚本

4.1 写入侧选型:为什么批量场景不用 set_toc 整体替换

PyMuPDF 的set_toc接口确实简单,传一个(level, title, page)的列表进去就完事,单文件处理时很爽。但批量场景我会避开它,原因有两个。第一,set_toc是整体替换语义,它把整个大纲树重新写一遍,如果原 PDF 本来有书签,必须先get_toc再手工合并,这个合并逻辑最后还是要自己写,省掉的功夫全得还回去。第二,PyMuPDF 对单个文件的异常是整体性抛出,一个加密或损坏文件会让整个批量脚本中断,后面的几十本书都不写了。

pypdf 的add_outline_item是增量语义,一次加一个节点,parent 挂在哪个节点上完全由代码控制,每一个文件的异常都能用 try/except 隔离。批量处理时,我要的就是“坏一个文件不影响其他文件”,所以这条选型理由在我这里比性能分值高得多。一百本书一批跑下来,慢个几分钟完全无所谓,中断一次手动重跑才是真的费时。

4.2 单文件写入最小代码:parent 参数决定父子关系

先跑通单个 PDF,确认写入逻辑正确再谈批量。最简代码如下:

from pypdf import PdfReader, PdfWriter reader = PdfReader("input.pdf") writer = PdfWriter(clone_from=reader) # 顶层书签,page_number 为 1-based 物理页号 ch1 = writer.add_outline_item("第一章 环境准备", 5) # 子书签挂在 ch1 下面 writer.add_outline_item("1.1 python 安装", 6, parent=ch1) writer.add_outline_item("1.2 依赖安装", 8, parent=ch1) # 第二个顶层书签 ch2 = writer.add_outline_item("第二章 开始实战", 20) with open("output.pdf", "wb") as f: writer.write(f)

这里有两个参数必须说清楚。add_outline_item的第二个参数是 1-based 物理页号,不是阅读器状态栏页码,也不是目录上印的页码,差一个 offset 点开就会跳错位置。parent参数必须传writer.add_outline_item返回的对象,不能传从 reader 那边拿来的旧节点,两个对象体系不互通,传错时 pypdf 不会立刻报错,而是把这个书签静默变成顶层书签。这个“不报错的失败”是写入侧最阴险的坑,后面第 5 章专门展开。

4.3 批量写入脚本:目录文件驱动 + 错误隔离

批量场景我定了一套最简单的目录文件约定:每个 PDF 对应一个同名.toc.txt,文件里每行一个书签,Tab 缩进表达层级,行尾用 Tab 分隔页码。这样标题内部可以含空格,不会被切错。格式示例:

toc.txt 行内容含义
第一章 环境准备 5一级书签,第 5 页
1.1 python 安装 6二级书签,挂在上一行下
1.1.1 环境变量 7三级书签
第二章 开始实战 20新的顶层书签

对应的批量脚本如下:

from pathlib import Path from pypdf import PdfReader, PdfWriter def parse_toc(toc_path): """解析目录文件,返回 [(level, title, page), ...]""" items = [] with open(toc_path, encoding="utf-8") as f: for line in f: line = line.rstrip("\n") if not line.strip(): continue indent = len(line) - len(line.lstrip("\t")) parts = line.strip().rsplit("\t", 1) if len(parts) != 2: continue title, page = parts[0], int(parts[1]) items.append((indent + 1, title, page)) return items def write_toc(pdf_path, toc_path, out_path): try: reader = PdfReader(pdf_path) if reader.is_encrypted: print(f"[skip] encrypted: {pdf_path}") return False writer = PdfWriter(clone_from=reader) stack = {} for level, title, page in parse_toc(toc_path): # 当前层级的书签要挂到上一层的节点下面 parent = stack.get(level - 1) node = writer.add_outline_item(title, page, parent=parent) stack[level] = node with open(out_path, "wb") as f: writer.write(f) return True except Exception as exc: print(f"[fail] {pdf_path}: {exc}") return False def main(): with open("pdf_list.txt", encoding="utf-8") as f: pdf_files = [line.strip() for line in f if line.strip()] for pdf in pdf_files: toc = Path(pdf).with_suffix(".toc.txt") out = Path(pdf).with_suffix(".with_bookmarks.pdf") ok = write_toc(pdf, toc, out) print(f"{pdf}: {'OK' if ok else 'FAIL'}") if __name__ == "__main__": main()

写这个脚本时我踩过一个具体坑:最初用rsplit(" ", 1)切分标题和页码,结果所有带空格的标题都被拦腰截断,比如“1.1 python 安装”被切成“1.1 python”和“安装 8”。改成 Tab 分隔之后,标题随便带空格都不影响。stack = {}这个字典是整个层级逻辑的枢纽:每一层的节点引用存下来,下一层的parent直接去字典里取,层级数字乱了会自动退化成顶层书签,不会抛异常,但你要知道它发生了什么。输出文件我习惯写成_with_bookmarks.pdf,保留原始 PDF 不动,跑完校验通过再决定删不删原件。

5. 书签读写常见问题排查:页码偏移、中文乱码和层级丢失 5 例

5.1 所有书签都偏了:逻辑页码和物理页号没换算

现象:按照目录文件写进去的书签,点开后全都落在同一片偏移区域,比如所有书签都往前跳了 4 页,或者都往后跳了 1 页。

原因:写入时把目录上印的“第 N 页”直接当成了物理页号。书的正文前面有封面、版权页、目录页,这部分页面没有印页码,所以目录里的“第 1 页”实际是 PDF 里的第 5 张纸。

解决:先用第 2.3 节的探测脚本算出 offset,在目录文件里统一换算成物理页号再进入批量流程。我习惯把换算留在生成 toc.txt 的环节,而不是塞在批量脚本里,这样 toc.txt 里存的是绝对坐标,脚本逻辑保持简单。

补充一条只差 1 页的情况:读取侧page_of返回的时候已经做了 +1,如果你在写入侧又加了一次 1,就会整体差 1。方向搞混和 offset 算错是两类问题,分开排查。

5.2 中文书签写入后乱码或阅读器侧边栏空白

现象:中文标题写入后,用 Chrome 或 Foxit 打开 PDF,侧边栏一堆乱码,有的干脆一个书签都不显示。

原因:pypdf 写入 title 时会做 PDF 字符串编码,如果标题里混入全角空格、控制字符或非法代理项,生成的大纲树在个别阅读器里解析失败。这类问题不是字体缺失,而是字符串本身带了让解析器提前终止的字符。

解决:写入前清洗标题,常见做法是替换全角空格和控制字符:

def clean_title(title): return title.replace("\u3000", " ").replace("\x00", "").strip()

这个clean_title放在parse_toc读取每一行之后执行。全角空格\u3000是从 Word 文档复制目录时最容易混进来的字符,显示上接近空格,但在 PDF 字符串里会渲染成乱码方块。\x00就更危险,它是字符串终止符,直接截断后面的内容。清洗不止做一次,每次从外部文件读入标题都必须过一遍。

5.3 写入后原来的书签丢了

现象:原 PDF 本来有 20 个书签,用PdfWriter(clone_from=reader)加了 5 个新书签后,侧边栏只剩下新加的 5 个旧的全没了。

原因:clone_from 会把页面内容原样带过来,但大纲树是单独挂在 Catalog 根目录上的结构。add_outline_item在 writer 内部默认新建一棵树去挂节点,它不会自动读取并保留旧树。PyMuPDF 的set_toc也是整体替换语义,行为一样。

解决:写入前先探测原 PDF 有没有旧书签,有就先把旧的读取出来,合并进新目录列表再统一写入。合并是业务逻辑,取决于你想新旧共存还是新的完全覆盖旧的:

if reader.outline: old_items = [] collect_outline(reader, reader.outline, 1, old_items) # 用第3章的函数 # 这里根据你的策略合并 old_items 和外部 toc 列表

我在实战里的策略是:旧书签和新目录不冲突就保留旧书签追加新的;冲突则以新目录为准。但无论哪种,前提都是先把旧书签读出来,不能默认 writer 会帮你留。

5.4 批量写到一半中断,后面的文件全军覆没

现象:批量处理 40 个 PDF,跑到第 7 个时脚本报错退出,剩下 33 个一个没写。

原因:没有做单文件错误隔离。某个 PDF 是加密的,或者页面对象有问题,之前打开时不出声,写到一半才炸。主循环不做 try/except 时,一个异常直接终止整个 Python 进程。

解决:每个文件都包一层 try/except,失败只打日志,继续跑下一个。第 4.3 节的脚本已经这么做了,两个细节再强调一下:reader.is_encrypted要提前判断,加密 PDF 在写入阶段才炸最坑;日志别只 print,批量跑几十本书的体量下,重定向到batch_log.txt里才能事后回溯。

5.5 层级塌平:所有子书签都变成顶级书签

现象:目录文件里明明有 Tab 缩进,写入后所有书签平铺在顶层,父子关系全部消失。

原因:parent参数传错了。最常见的是把旧 reader 里的节点传给 writer 的add_outline_item,或者传成了父书签的标题字符串。pypdf 不会校验这个参数的类型和归属,传错就静默当 None 处理。

解决:用stack字典缓存 writer 自己返回的节点引用,每层从字典取 parent,不要自己构造。写完后随机挑一个子书签,点开确认它在父书签下面而不是顶层。这个检查用阅读器做最快,30 秒就能发现问题。

6. 写完别急着收工:重读校验、只补空白书签和备份习惯

写完一批 PDF,第一步永远是重读校验,不是打开阅读器抽查两三个就完事。我用一个很土的脚本做全量比对:把写入后的 PDF 重新跑一遍读取流程,生成一份新的书签清单,和写入用的 toc.txt 逐条对比。只要前 5 条、中间 5 条、最后 5 条的级别和页码都对得上,整体基本可信:

reader_check = PdfReader(out_path) check_items = [] collect_outline(reader_check, reader_check.outline, 1, check_items) expect = parse_toc(toc_path) assert expect[:5] == check_items[:5][:len(expect[:5])] assert expect[-5:] == check_items[-5:][:len(expect[-5:])] print("校验通过")

比对时注意顺序:pypdf 的 outline 返回顺序和写入顺序一致,层级和页码完全对齐才算通过。校验通过后再抽查三个书签,一个在开头,一个在中间,一个在末尾,确认阅读器能正常解析。

还有一个使用频率很高的进阶场景:一批 PDF 里只有一部分没有书签,想只给空白的补。判断条件就是if not reader.outline:,为空才走写入逻辑。有书签的要么跳过,要么走第 5.3 节的合并策略,不能无脑覆盖。

备份习惯我现在做得比较死板:批量写之前,先把整个输入目录用rsync -a --backup复制一份带时间戳的副本。这套脚本跑了接近半年,真正用到备份恢复的次数只有一次,但那次恰好是目录文件里 offset 写错,整批 20 本书的书签全偏了两页。有备份,重跑一遍就完事;没备份,还得先从成品反向恢复原始 PDF。希望帮到你。

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

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

1000条数据蒸馏出领域专家模型:法律问答实战复盘

“大模型蒸馏”这四个字,最近在圈子里出现的频率实在太高了。朋友圈、技术群、开源社区,隔三差五就有人晒出同款标题的分享:1000条数据,蒸馏出一个领域专家模型。说实话,第一次看到这种帖子我也心动过——不需要几十万…

作者头像 李华
网站建设 2026/9/25 13:35:59

Atlas 300V 24G推理加速卡与YOLO部署全链路解析

“Atlas部署YOLO”“Atlas 300V 24G是不是运算加速卡”,这两个问题放在一起,基本就是冲着华为昇腾推理卡来的。我自己从Atlas 300I到300V都折腾过一段时间,中间踩过不少坑,正好借这个机会把Atlas 300V 24G的身份、选型逻辑、部署Y…

作者头像 李华
网站建设 2026/9/25 13:35:56

Atlas 300V 24G昇腾推理卡:YOLO模型部署与优化实战

1. 先说清楚:Atlas 300V 24G到底是一张什么卡很多人在群里问“Atlas 300V 24G是运算加速卡吗”,我直接给结论:它是加速卡,但准确说是AI推理加速卡,不是训练卡,更不是图形卡。这个区别如果不弄清楚&#xff…

作者头像 李华
网站建设 2026/9/25 13:34:40

北京科华净化工程安全措施到位吗,专业程度如何

随着我国科研事业、生物医药产业与半导体产业的快速发展,国内对洁净空间工程的需求从基础洁净环境搭建逐步转向高精度、高合规、全周期服务的专业化方向发展。在北京这片汇聚了全国科研资源、高端制造产业的沃土,洁净工程行业也从早期的零散分包、重施工…

作者头像 李华