ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Python批量读写PDF书签:PyMuPDF实现目录跳转与避坑指南

用Python批量读写PDF书签:PyMuPDF实现目录跳转与避坑指南 简介面向需要批量管理PDF书签的Python开发者这套源码演示了基于PyPDF2完成PDF书签读取与批量写入的完整流程。资源共包含2个文件核心是一个可直接修改运行的Python脚本.py另附一个依赖库压缩包.gz压缩包整体仅40KB携带方便。已有2513人学习下载。脚本不仅能够读取现有PDF中的书签层级、标题及对应页码还能将JSON格式整理好的书签数据批量写入新的PDF文件生成带目录结构的文档。实现中覆盖了getOutlines、Destination、OutlineItem等关键API的典型用法并处理了PyPDF2页码从0开始等易错细节由于PyPDF2不支持直接修改原文件脚本会输出新的书签版PDF避免破坏原始内容。适用于电子书目录整理、长文档导航生成等自动化场景尤其适合刚接触PDF处理的开发者快速上手。1. pdf书签读取与批量写入一次解决三百页手册的跳转问题接手过一份三百多页的产品说明书没有任何书签客户要求在 PDF 里点目录能直接跳转。手工在 Adobe 里“新建书签”一条条加加到一半就发现这是个体力活而且页码稍微标错就得返工。用 python 做 PDF 书签读取、批量写入本质是操作 PDF 内部的大纲树明文结构PyMuPDF 的get_toc()和set_toc()两个接口就能把这件事压缩成几十行源码。这篇文章不讲空泛概念直接给出可复现的读取脚本、写入脚本、批量处理脚本以及一个我从实战里踩出来的避坑清单。适合文档工程师、电子书处理脚本编写者以及所有需要批量给 PDF 补书签的从业者。2. 用 PyMuPDF 读取 PDF 书签get_toc() 的返回结构与页码约定2.1 为什么选 PyMuPDFpypdf 的局限与 fitz 的取舍处理 PDF 书签社区里最常见的三个库是 pypdf原 PyPDF2、pdfrw 和 PyMuPDF。pypdf 能读书签但它在处理嵌套层级和中文标题时经常返回奇怪的元组结构而且写入侧支持一直不算完善老版本甚至只读不写。pdfrw 更偏 PDF 底层对象操作合并拆分 PDF 是强项但大纲树的支持同样薄弱。PyMuPDF导入名是fitz把书签能力封装成了两个非常直觉的接口get_toc()读出全部书签set_toc()一次性写入全部书签。它不要求你理解 PDF 规范里 Outlines、Destination 这些底层概念直接面向“层级、标题、页码”三个要素操作这是选它的核心理由。另一个选型考量是速度和稳定性。批量处理几百个 PDF 文件时PyMuPDF 的解析速度明显快于纯 Python 实现处理一万页以上的文档也不容易内存溢出。它不是一个纯 python 实现的库底层是 C 扩展安装时需要注意是否匹配当前 Python 版本与操作系统架构。日常使用中我一般直接用 pip 安装遇到 wheel 安装失败再降级到源码安装这类问题在它的文档里有说明这里不过多展开。2.2 get_toc() 返回什么三级结构、嵌套层级与页码从 1 开始PyMuPDF 的get_toc()返回的是一个列表列表里每个元素是一个三元组(层级, 标题, 页码)。层级从 1 开始1 表示一级书签2 表示二级书签以此类推标题是普通字符串页码是 PDF 逻辑页码从 1 开始计数而不是从 0 开始。这一点非常关键因为fitz.open()之后用doc[i]取页时索引从 0 开始但书签里的页码约定从 1 开始两套计数方式混用是新手最容易踩的坑。下面是最小读取脚本可以直接拷贝运行import fitz doc fitz.open(bookmark_sample.pdf) toc doc.get_toc() for lvl, title, page in toc: indent * (lvl - 1) print(f{indent}[L{lvl}] {title} - 第 {page} 页) doc.close()这段代码的逻辑很简单get_toc()拿到全部书签后用lvl控制缩进title直接打印标题page打印目标页码。运行结果里能看到清晰的树形结构一级书签顶格二级书签缩两格三级书签再缩两格。参数说明get_toc()在大多数版本里只有一个常用参数simple默认是True返回上面这种三元组。如果你用simpleFalse调用返回的每条记录会带有更丰富的信息包括目标类型、颜色、是否粗体等样式数据这个在 2.3 节单独讲。页码必须从 1 开始这个约定和 Adobe Acrobat 书签面板里显示的页码一致但和 PDF 内部页面对象的索引不是一回事。2.3 读取更多字段simpleFalse 拿到颜色、粗体和目标类型默认的三元组够用但如果你需要把一份带样式的书签完整迁移到另一个 PDF就得考虑simpleFalse。它返回的每条记录大致形式是(层级, 标题, 页码, 样式信息)样式信息里包含目标类型是跳转到页面还是跳转到外部链接、标题颜色、是否加粗、是否斜体等字段。import fitz doc fitz.open(styled_bookmark.pdf) toc_detail doc.get_toc(simpleFalse) for item in toc_detail: # 不同 PyMuPDF 版本返回结构略有差异这里统一取前三个字段 lvl, title, page item[0], item[1], item[2] style item[3] if len(item) 3 else {} bold style.get(bold, unknown) color style.get(color, unknown) print(f[L{lvl}] {title} - {page} 加粗{bold} 颜色{color}) doc.close()这段代码展示了如何把样式字段取出来打印。实际项目里我一般只读取前三个字段样式信息主要用于备份和迁移场景——比如甲方给了一份带红色高亮书签的 PDF要求你批量改成黑色加粗这时simpleFalse就能派上用场。需要提醒的是simpleFalse的返回结构在不同 PyMuPDF 版本里确实有微调有的版本第四个元素是字典有的版本是一个包含多个子元素的元组。我在脚本里做了兼容处理用len(item) 3判断后取值这样跨版本跑也不容易翻车。如果你拿到的返回结构和这里描述的不完全一样先用print(item)打印一条原始数据观察再下手别盲写解析逻辑。3. 写入 PDF 书签set_toc() 的层级规则与保存选项3.1 set_toc() 的输入格式层级严格、页面必须存在set_toc()是写入书签的核心接口它的输入参数和get_toc()的返回值格式完全一致一个列表每个元素是[层级, 标题, 页码]。层级必须是正整数标题必须是字符串页码必须是 PDF 逻辑页码且不能超过文档总页数。这个接口不会显式报错告诉你“页码越界”但写进去后点击书签可能跳到一个空白页所以写入前自己做边界检查是必须的。层级规则比初看起来更严格父级必须先于子级出现且层级的跳变不能超过 1。也就是说你不能在第一条就写[2, 二级书签, 3]因为它没有父级你也不能从[1, 一级书签, 3]直接跳到[3, 三级书签, 5]因为中间缺了二级。违反这个规则时set_toc()在部分版本里会抛异常在另一些版本里会静默忽略异常条目这是很危险的行为。层级检查可以写成一个极简函数放在正式写入之前def validate_toc(toc): prev 0 for lvl, title, page in toc: if lvl 1 or lvl prev 1: raise ValueError(f层级校验失败: [{lvl}] {title}) if page 1: raise ValueError(f页码必须从1开始: {title}) prev lvl return True这个函数的检查逻辑只有两条层级不能跳级页码不能小于 1。第一版脚本跑批的时候我吃过一次亏Excel 里有一行层级填成了 4写入后那一条书签和它后面的所有子书签在阅读器里全部消失而set_toc()竟然没有抛错。从那以后层级校验就成了我写书签脚本的固定前置步骤。3.2 写入的标准动作清空旧树、重建树、选择全量保存写入一份带书签的 PDF标准流程是三步先打开 PDF再调用set_toc()最后保存。这里有两个细节值得展开要不要先清空旧书签以及用哪种保存方式。清空旧书签的做法是调用doc.set_toc([])传一个空列表进去。如果目标 PDF 原本就有书签而你传入的新书签只是部分更新不清空的后果是旧书签和新书签混在一起形成两条并排的大纲树。PDF 规范允许一个文档里有多套大纲吗技术上可以但绝大多数阅读器只显示第一套最终效果就是“旧书签还在新书签不见了”极其迷惑。所以我的习惯是无论目标 PDF 有没有书签先set_toc([])清一次再写新数据。保存方式是另一个容易翻车的点。doc.save()默认做全量保存会重写整个文件doc.save(path, incrementalTrue)则是增量保存只把改动追加到文件尾部速度快很多。但增量保存对书签这种结构性改动不够干净——旧大纲树的废弃节点可能残留在文件里于是出现“明明只写了 3 个书签阅读器里却显示 6 个”的灵异现象。处理书签变更我建议用全量保存并开启垃圾回收doc.save(output.pdf, garbage3, deflateTrue)garbage3表示做最大程度的垃圾回收重写时移除未引用对象deflateTrue对流对象做压缩文件体积更小。代价是保存耗时略长但书签写入通常是一次性操作批量场景下这个时间成本完全可接受。3.3 一个可直接复用的写入函数源码把上面的步骤合并成一个函数方便直接抄进自己的工具脚本import fitz def set_bookmarks(pdf_path, toc, output_path): # 打开原文件 doc fitz.open(pdf_path) # 先清空旧书签避免新旧混写 doc.set_toc([]) # 写入新书签toc 格式: [[lvl, title, page], ...] doc.set_toc(toc) # 全量保存清除废弃对象 doc.save(output_path, garbage3, deflateTrue) doc.close() if __name__ __main__: toc [ [1, 第一章 项目背景, 1], [2, 1.1 问题定义, 1], [2, 1.2 目标与范围, 3], [1, 第二章 方案设计, 5], [2, 2.1 总体架构, 5], [3, 2.1.1 数据层, 6], [1, 附录, 20], ] set_bookmarks(no_toc.pdf, toc, with_toc.pdf)函数逻辑分四步打开 PDF、清空旧书签、写入新书签、全量保存。示例数据演示了三级嵌套的正确写法——先有[2, 2.1 总体架构]才能在其后跟[3, 2.1.1 数据层]层级跳变是合法的因为prev逐步递增。参数说明pdf_path是输入文件路径也可以是bytes对象toc的每个元素可以是列表也可以是元组内部会统一处理output_path建议与pdf_path不同避免读取和保存同时打开同一个文件导致锁定问题。如果一定要覆盖原文件先doc.close()再删原文件改名不要直接在打开状态下保存到同一路径。4. 批量写入场景从 CSV/JSON 生成书签一次跑完整个目录4.1 从 CSV 读目录并写入的最小源码实际工作里书签数据很少手写在代码里通常来自出版社给的目录表、客户给的 Excel 整理稿或者你自己从排版文件里导出的清单。最通用的交换格式是 CSV每行三列层级、标题、页码。注意从 Excel 导出的 CSV 默认带 BOM 头用 Python 读取时如果不处理第一列的标题会变成\ufeff第一章这种带隐形字符的字符串。import csv import fitz doc fitz.open(book.pdf) toc [] with open(toc.csv, r, encodingutf-8-sig) as f: reader csv.reader(f) for row in reader: if len(row) 3: continue # 跳过空行和格式不完整的行 lvl int(row[0]) title row[1].strip() page int(row[2]) if title and lvl 0: toc.append([lvl, title, page]) doc.set_toc(toc) doc.save(book_with_toc.pdf, garbage3, deflateTrue) doc.close()这段代码做的事情很直接用csv.reader逐行读取过滤掉字段不足的行和空标题行拼接成[lvl, title, page]列表最后一次性写入。encodingutf-8-sig是这里最容易忽视的细节它会在读取时自动剥离 BOM 头保证第一个标题不带隐形字符。参数说明row[1].strip()是为了去掉标题两侧的空白字符Excel 里常见的全角空格也会被一并去掉但如果目录里真的需要保留首尾空格这行要改。int()转换失败时脚本会抛异常建议在外层包一个try把脏数据所在的行号打印出来而不是让整个批处理中断。这个脚本可以原样保存为csv_to_toc.py以后每次拿到新的目录表改一下文件名就能复用。4.2 批量处理整个文件夹glob 匹配同名清单单文件写入只是第一步真实场景往往是一个文件夹下几十个 PDF每个文件配一个同名的.txt或.csv目录清单要求跑一次脚本全部处理完。这个批量逻辑用glob就可以实现不需要引入复杂的任务调度框架。import glob import fitz for pdf_path in glob.glob(books/*.pdf): list_file pdf_path.replace(.pdf, .txt) if not os.path.exists(list_file): print(f跳过: {pdf_path} 缺少 {list_file}) continue doc fitz.open(pdf_path) toc [] with open(list_file, encodingutf-8-sig) as f: for line in f: line line.strip() if not line: continue parts line.split(,) if len(parts) 3: continue lvl, title, page int(parts[0]), parts[1].strip(), int(parts[2]) toc.append([lvl, title, page]) doc.set_toc(toc) out_path pdf_path.replace(.pdf, _with_toc.pdf) doc.save(out_path, garbage3, deflateTrue) doc.close() print(f已处理: {pdf_path} - {out_path})这里有个取舍问题为什么清单格式用简单的.txt而不是.csv因为当目录数据来源于人工整理时.txt里每行层级,标题,页码的格式最容易手写和维护直接用split(,)就能解析不需要额外引入csv库。缺点是不能处理标题里的逗号但实际书签标题里出现逗号的情况极少真遇到了就手动改那一行或者把分隔符换成|再调整split参数。批量脚本的执行路径是glob匹配所有.pdf文件 → 检查同名.txt是否存在 → 逐行解析 → 写入书签 → 保存为_with_toc.pdf。保留原文件、输出新文件的做法是故意设计的批量操作一旦出错原文件还在不会因为脚本缺陷毁了原始资料。跑完抽查结果没问题后再决定是否用新文件覆盖旧文件。4.3 印刷页码转 PDF 页码目录页与正文的偏移问题前面所有写入脚本都默认“PDF 逻辑页码”就是目录里标的页码但现实中几乎总是有偏移。出版社给的目录表标的是“第 1 页”可 PDF 里前面还有封面、版权页、目录页正文真正从第 5 个 PDF 页面开始。如果不做转换所有书签都会偏到错误的位置而且偏得一致——从第四章开始每一章都差同一个常数。处理办法是定义一个偏移函数先人工确认正文第一页的印刷页码和 PDF 页码然后整体换算def print_to_pdf_page(print_page, first_content_print_page, first_content_pdf_page): 印刷页码转 PDF 逻辑页码。 例如正文第 1 页在 PDF 中位于第 5 页: offset 5 - 1 4 印刷第 10 页 - PDF 第 14 页 offset first_content_pdf_page - first_content_print_page return print_page offset # 示例: 正文第 1 页对应 PDF 第 5 页目录里写的是第 12 页 pdf_page print_to_pdf_page(12, first_content_print_page1, first_content_pdf_page5) print(pdf_page) # 16这个函数的逻辑不复杂offset是印刷页码和 PDF 页码之间的固定差值正文第一页印刷页码是 1PDF 里是 5那么差值就是 4任何印刷页码加 4 就是目标 PDF 页码。但实际工作里有两类文档需要额外小心一类是目录自身也编入印刷页码的那么目录页出现在书签里的页码也可能是错的需要先核对另一类是 PDF 里删除了某些页面导致偏移不是常数这种情况建议放弃公式用正则从目录页文本里直接抽取“目标页码 实际跳转页码”的对应关系。批量场景里我一般在写入前先取前三章做一次人为核对。具体做法是脚本生成书签后用 PyMuPDF 重新打开输出文件读一遍get_toc()的页码再对照原目录表的目录页码如果三章都对得上再全量跑完。这个过程可以用代码自动化但我的经验是前三章的核对人工看 30 秒就够了比写自动化验证脚本快得多。5. 避坑4 个让书签翻车的场景与对应修法5.1 现象一书签页码和 PDF 阅读器显示的页数差一页现象点击书签跳过去Adobe 或 PDF 阅读器左下角显示的是第 5 页但这一章实际在第 6 页。整份 PDF 的所有书签都系统性偏移一页。原因PDF 阅读器有“封面单独显示”或“封面算第 1 页”的显示设置而 PDF 内部逻辑页码从 1 到总页数。PyMuPDF 的get_toc()返回的 page 字段是 PDF 页面对象的逻辑编号加 1和阅读器状态栏显示的页码通常一致但碰到开了“从封面算起”的阅读器显示页码会整体加 1 或减 1。另一个常见原因是印刷页码本身没从 1 开始比如正文前有 4 页罗马数字目录正文第一页标页码 1在 PDF 里却是第 5 页。解决写书签前先明确“目标 PDF 的逻辑页码”而不是“阅读器显示的页码”。我的做法是用 PyMuPDF 自己验证写入后重新打开doc[page - 1]取出那一页的前几个字符和目录标题对一下能对上就说明页码没错。如果阅读器显示仍有偏差那属于阅读器显示设置问题不一定是写入脚本的锅。5.2 现象二层级跳跃子书签挂到错误的父级下现象set_toc()没有抛任何异常书签数量也对但打开 PDF 后有一个二级书签和它的所有下级书签全部跑到了前一个一级书签底下或干脆消失。原因输入的 toc 列表里出现了层级跳变比如从[1, 第一章, 3]直接跳到[3, 1.1.1 细节点, 4]中间缺了二级。PyMuPDF 在某些版本中不校验这种错误而是按自己的策略把三级书签挂到最近的父级上造成混乱。解决写入前跑一遍校验函数不合法就直接中断。前面 3.1 节里的validate_toc()就是为了这个场景写的。还有一个隐蔽情况是 Excel 里某个合并单元格导致导出的 CSV 层级列值为空int()转换直接抛ValueError也会中断整个批量任务。批量脚本里需要对这种脏数据做兼容打印出错的行号和内容跳过该文件而不是中断所有文件。5.3 现象三中文标题写入后在阅读器里乱码现象脚本跑完没报错书签也在但标题里的中文全部变成了问号或乱码字符英文和数字正常。原因多数情况下这不是 PyMuPDF 的问题而是读取阶段数据就没对——CSV 文件不是 UTF-8 编码或带了 BOM 没有被正确识别。比如用 Windows 记事本另存的 CSV 默认可能是 GBKPython 用utf-8读会出现UnicodeDecodeError但有时候恰好读了一部分不报错产生乱码字符。用encodingutf-8-sig能解决 BOM 问题用encodinggbk或encodinggb18030才能解决 GBK 问题。解决批量处理时不要硬编码编码名先写一个自动探测用utf-8-sig尝试读取抛异常就换gbk再试。更省事的做法是让数据来源统一——如果是 Excel 导出的 CSV先用 WPS 或 Excel 把文件另存为“CSV UTF-8”格式再交给脚本。写入端你不需要做什么特殊编码设置PyMuPDF 的书签标题就是普通 Python 字符串内部按 Unicode 处理。5.4 现象四增量保存后旧书签还在新书签混入了废数据现象用doc.save(out, incrementalTrue)保存打开 PDF 后发现旧书签没消失新书签和旧书签同时存在甚至出现重复的顶层节点。原因增量保存只把改动追加到文件末尾PDF 里原有的老大纲树对象没有从物理文件里清除。阅读器读取大纲时可能会同时解析到新旧两棵树表现就是“怎么都删不干净”。解决书签这种结构性修改不要用增量保存改用garbage3全量保存。如果文件特别大全量保存耗时太长可以接受因为处理 PDF 书签本来就是一次性操作。另一个后悔药方案是保存前先备份原文件万一新书签有问题还能用备份重跑批量脚本里加一行shutil.copy2成本很低但遇到故障时能省一晚上重跑时间。6. 进阶从目录页文本直接生成书签附两个实用技巧如果连目录表都没有只有一份扫描版 PDF目录页在第二页内容是“第一章 …… 3”“第二章 …… 12”这种格式可以先把目录页的文字抽出来再用正则解析生成书签列表。这一招在批量处理老旧资料时非常管用前提是 PDF 至少有一页带文本层扫描件则需要先做 OCR。import re import fitz def parse_toc_text(text): toc [] for line in text.splitlines(): line line.strip() if not line: continue m re.match(r^(第[一二三四五六七八九十百零\d][章篇].*?)([.:\s]*)(\d{1,3})$, line) if m: toc.append([1, m.group(1).strip(), int(m.group(3))]) return toc doc fitz.open(manual.pdf) page doc[1] # 假设目录在第 2 页 text page.get_text(text) toc parse_toc_text(text) print(toc) doc.set_toc(toc) doc.save(manual_with_toc.pdf, garbage3, deflateTrue) doc.close()这个解析函数只做一件事从每行文本里提取“标题”和“结尾的数字页码”。正则里的(第[一二三四五六七八九十百零\d][章篇])匹配中文数字或阿拉伯数字开头的章节标题(\d{1,3})$匹配行尾的页码。如果目录页的行格式是“第一章……3”中间用点线连接这段也能匹配因为.*?会吃掉中间的点和空格。这个脚本对格式整齐的目录页准确率很高格式乱的还是要人工介入。最后分享两个工作中积累的实用技巧。第一个是书签备份与回填工作流先用get_toc()把现有书签导出成 CSV交给业务方人工核对、修改页码改完再用第 4 章的 CSV 写入脚本回填形成一个完整的“读取 → 修改 → 写入”闭环正好对应标题里的两个动作做成一个工具就能长期复用。第二个是写入前抽样自检批量脚本里加一个参数只处理前两个文件、只写前五条书签人工确认后再全量跑。这两个技巧不复杂但能避免很多批量返工。我现在的习惯是每批书签写入前都先做一次旧书签备份输出文件一律放到独立目录确认无误后再覆盖原文件。这套流程跑了几十个项目翻车概率极低。希望帮到你。本文还有配套的精品资源点击获取
返回列表