ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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, level0): 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(ftotal 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, offset1): return p offset def visual_to_physical(p, offset1): return p - offset # 例书中印“第 12 页”offset4 表示前 4 页不参与印页码 # 则传给 add_outline_item 的物理页号是 12 - 4 8 print(visual_to_physical(12, offset4)) # 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, encodingutf-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, encodingutf-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_fromreader) # 顶层书签page_number 为 1-based 物理页号 ch1 writer.add_outline_item(第一章 环境准备, 5) # 子书签挂在 ch1 下面 writer.add_outline_item(1.1 python 安装, 6, parentch1) writer.add_outline_item(1.2 依赖安装, 8, parentch1) # 第二个顶层书签 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, encodingutf-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_fromreader) stack {} for level, title, page in parse_toc(toc_path): # 当前层级的书签要挂到上一层的节点下面 parent stack.get(level - 1) node writer.add_outline_item(title, page, parentparent) 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, encodingutf-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_fromreader)加了 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。希望帮到你。本文还有配套的精品资源点击获取
返回列表