python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python批量处理PDF书签

Python批量读取、解析与写入PDF书签的完整指南

作者:菩提风

本文将详解使用Python(基于pypdf)读取、解析与批量写入PDF书签的完整流程并提供五类典型故障的定位与解决方案,文中的示例代码讲解详细,有需要的小伙伴可以了解下

简介:这份资源面向需要处理PDF文档的Python开发者与办公自动化爱好者,聚焦PDF书签的读取与批量写入这一常见需求。包内共2个文件,以1个py脚本和1个gz压缩包为主,前者是可直接运行的书签处理源码,后者为配套依赖库,整体约40KB,轻量易部署。源码演示了借助PyPDF2读取现有书签的层级、标题与页码,并将JSON格式的书签数据批量写入新PDF的完整流程,同时提示了页码从0开始、原文件不可直接修改等易踩的坑。已有2516人学习下载,适合想快速掌握PDF书签管理、为文档批量生成目录的读者参考复用。

1. 从一份 800 页 PDF 说起:为什么我要自己写书签读写工具

前阵子帮某实验室整理一批技术文档,单个 PDF 动辄六七百页,目录页扫进去全是散的,阅读器左侧书签栏一片空白。想跳转到「第 7 章 参数标定」得靠 Ctrl+F 一页页翻,翻到怀疑人生。更麻烦的是,这批文档有几十份,结构高度相似,手工加书签等于给自己判了无期徒刑。市面上确实有现成的 PDF 编辑器能加书签,但要么收费、要么批量能力弱、要么改完把原有元数据搞乱。于是我用 Python 写了一套 PDF 书签读取与批量写入的源码,核心就两件事:把已有书签结构读出来看清楚,再按规则批量写回去。它适合经常处理长文档的工程师、做文档流水线的同学,以及想搞懂 PDF 书签底层结构的人。下面把我拆这套源码的过程和踩过的坑完整讲一遍。

2. PDF 书签的底层结构:从 Outline 树到目标页映射

2.1 书签不是「页码」,而是一棵带目标的对象树

很多人第一次接触 PDF 书签,会下意识以为书签就是「标题 + 页码」的键值对。真去读原始结构就会发现完全不是这么回事。PDF 里书签叫 Outline(大纲),它是一棵由字典对象组成的树,每个节点至少包含三个关键字段: /Title 是显示文字, /Parent 指回父节点, /Dest 或 /A 描述「点了之后跳去哪」。跳转目标不是直接写页码,而是一个目标数组,里面通常包含页面对象引用和视图模式,比如 /XYZ 表示跳到某坐标、 /Fit 表示整页适配。

这就带来第一个认知门槛:书签的「层级」和「顺序」是靠 /First 、 /Last 、 /Next 、 /Prev 这几个指针串起来的链表结构,而不是数组下标。你读的时候必须顺着指针递归遍历,写的时候也必须把父子兄弟指针全部接对,否则阅读器要么不显示,要么显示成平铺的一坨。我一般用 pypdf (老版本叫 PyPDF2 )来操作,它对 Outline 的封装比较贴近原始结构,读和写都能控到细节。

2.2 读取书签:递归遍历与目标页反查

读取的核心逻辑是「从根 Outline 出发,深度优先遍历,同时把每个节点的目标页反查成页码」。反查这一步是新手最容易忽略的: /Dest 里拿到的是页面对象引用,得先建立「页面对象 → 页码」的映射表,才能知道书签到底指向第几页。下面是我常用的读取脚本。

from pypdf import PdfReader

def build_page_index(reader):
    """建立 页面对象id -> 页码(从0开始) 的映射"""
    page_map = {}
    for i, page in enumerate(reader.pages):
        # id() 拿到的是 Python 对象身份,这里用间接引用更稳
        page_map[page.indirect_reference.idnum] = i
    return page_map

def walk_outline(reader, outline, page_map, depth=0, result=None):
    """递归遍历 Outline 树,收集标题/层级/目标页码"""
    if result is None:
        result = []
    if outline is None:
        return result
    for item in outline:
        # item 可能是嵌套 list(表示子节点),也可能是字典节点
        if isinstance(item, list):
            walk_outline(reader, item, page_map, depth + 1, result)
            continue
        title = item.get('/Title', '')
        page_no = -1
        try:
            # get_destination_page_number 是 pypdf 提供的便捷方法
            page_no = reader.get_destination_page_number(item)
        except Exception:
            page_no = -1
        result.append({
            'title': title,
            'depth': depth,
            'page': page_no
        })
    return result

reader = PdfReader("sample.pdf")
page_map = build_page_index(reader)
outline = reader.outline
bookmarks = walk_outline(reader, outline, page_map)
for bm in bookmarks:
    print("  " * bm['depth'], bm['title'], "->", bm['page'] + 1)

逻辑说明: reader.outline 返回的是一个混合结构,嵌套 list 代表「这一层还有子节点」,字典节点才是真正的书签项。所以遍历时要先判断类型,遇到 list 就递归下去并把 depth 加一。 get_destination_page_number 负责把目标引用翻译成页码,拿不到就记 -1,方便后面排查。参数上, depth 控制缩进层级, page_map 目前只是备用,真正反查靠的是 pypdf 的方法,但保留映射表在写回时校验页码很有用。

提示:不同版本的 pypdf 对 outline 的返回结构略有差异,老版本 PyPDF2 里嵌套层级的表现不完全一致,读之前先打印一次原始结构确认。

2.3 写入书签:构造节点字典与指针拼接

写入比读取难,因为你要自己把 /First 、 /Last 、 /Next 、 /Prev 、 /Parent 全部接对。pypdf 提供了 add_outline_item 方法,能省掉大部分指针拼接的活,但它默认是平铺添加,要做多级层级得自己控制 parent 参数。我的做法是先把书签数据整理成「带层级的扁平列表」,再按层级关系逐条添加。

from pypdf import PdfReader, PdfWriter

def add_bookmarks(writer, bookmarks):
    """bookmarks: [{'title','page','depth'}, ...] 按阅读顺序排列"""
    # 用栈保存每一层的父节点,栈的深度即当前层级
    parent_stack = [None]
    for bm in bookmarks:
        depth = bm['depth']
        # 如果当前层级比栈浅,弹出多余的父节点
        while len(parent_stack) > depth + 1:
            parent_stack.pop()
        parent = parent_stack[-1]
        # 添加书签,parent 为 None 时挂在根上
        new_item = writer.add_outline_item(
            title=bm['title'],
            page_number=bm['page'],
            parent=parent
        )
        # 如果下一项是子节点,当前项要成为它的父
        if len(parent_stack) == depth + 1:
            parent_stack.append(new_item)
        else:
            parent_stack[depth + 1] = new_item
    return writer

reader = PdfReader("sample.pdf")
writer = PdfWriter()
writer.append(reader)  # 保留原文档全部页面
bookmarks = [
    {'title': '第 1 章 概述', 'page': 0, 'depth': 0},
    {'title': '1.1 背景', 'page': 1, 'depth': 1},
    {'title': '1.2 目标', 'page': 3, 'depth': 1},
    {'title': '第 2 章 方法', 'page': 5, 'depth': 0},
]
add_bookmarks(writer, bookmarks)
with open("output.pdf", "wb") as f:
    writer.write(f)

逻辑说明: parent_stack 是这套写法的灵魂。栈顶永远是「当前深度的父节点」,当遇到更深层级的书签时,把上一个节点压栈当父;遇到更浅层级时,先弹栈再取父。 add_outline_item 的 page_number 是从 0 开始的页码,和读取时拿到的页码口径一致,这点务必对齐,否则书签会整体偏移一页。参数 parent 传 None 表示挂在根 Outline 下,传具体节点则成为其子项。

3. 批量处理实战:从目录文本到多文件书签注入

3.1 批量场景的数据来源与解析策略

单文件读写跑通后,真正的价值在批量。实际项目里书签数据来源无非几种:一是从文档自带的目录页 OCR 或文本提取出来,二是从外部 Excel/CSV 维护的目录表读入,三是按固定规则生成(比如每份文档都有「封面、目录、正文、附录」四段)。我一般优先用 CSV,因为结构清晰、人工可校对。CSV 里至少要有三列: title 、 page 、 depth ,页码用 1 起始,读进来减一。

解析目录文本时有个高频坑:PDF 提取出来的目录行经常带点线( ...... )和页码,正则要同时兼容中英文和不同点线数量。常见做法是用 re.split(r'\.{2,}|\s{2,}', line) 把标题和页码切开,再判断缩进或编号前缀来推断层级。编号形如 1.2.3 的,按点号数量定 depth;没有编号的,靠前导空格数估算。这一步没有银弹,规则要按你的文档实际样式调。

3.2 批量注入脚本与参数说明

下面这个脚本把「遍历目录下所有 PDF → 读取对应 CSV → 注入书签 → 另存」串起来。关键参数是 page_offset ,因为有些文档封面和目录页不计入正文页码,CSV 里记的是印刷页码,需要加偏移量才能对上物理页。

import os
import csv
from pypdf import PdfReader, PdfWriter

def load_bookmarks_from_csv(csv_path, page_offset=0):
    """读取 CSV,返回书签列表;页码统一转成 0 起始"""
    bookmarks = []
    with open(csv_path, newline='', encoding='utf-8-sig') as f:
        reader = csv.DictReader(f)
        for row in reader:
            bookmarks.append({
                'title': row['title'].strip(),
                'page': int(row['page']) - 1 + page_offset,
                'depth': int(row.get('depth', 0))
            })
    return bookmarks

def inject_bookmarks(pdf_path, bookmarks, out_path):
    reader = PdfReader(pdf_path)
    writer = PdfWriter()
    writer.append(reader)
    parent_stack = [None]
    for bm in bookmarks:
        depth = bm['depth']
        while len(parent_stack) > depth + 1:
            parent_stack.pop()
        parent = parent_stack[-1]
        new_item = writer.add_outline_item(
            title=bm['title'],
            page_number=bm['page'],
            parent=parent
        )
        if len(parent_stack) == depth + 1:
            parent_stack.append(new_item)
        else:
            parent_stack[depth + 1] = new_item
    with open(out_path, "wb") as f:
        writer.write(f)

def batch_process(src_dir, csv_dir, dst_dir, page_offset=0):
    os.makedirs(dst_dir, exist_ok=True)
    for name in os.listdir(src_dir):
        if not name.lower().endswith('.pdf'):
            continue
        pdf_path = os.path.join(src_dir, name)
        csv_path = os.path.join(csv_dir, os.path.splitext(name)[0] + '.csv')
        if not os.path.exists(csv_path):
            print(f"[跳过] 缺少目录文件: {name}")
            continue
        bookmarks = load_bookmarks_from_csv(csv_path, page_offset)
        out_path = os.path.join(dst_dir, name)
        try:
            inject_bookmarks(pdf_path, bookmarks, out_path)
            print(f"[完成] {name} 注入 {len(bookmarks)} 条书签")
        except Exception as e:
            print(f"[失败] {name}: {e}")

batch_process("./pdfs", "./csvs", "./output", page_offset=2)

逻辑说明: load_bookmarks_from_csv 用 utf-8-sig 编码读,避免 Excel 导出的 BOM 头把第一列列名搞脏。 page_offset 是印刷页码到物理页码的补偿量,比如封面加目录共 2 页,正文第 1 页实际是物理第 3 页,偏移就填 2。 batch_process 按文件名匹配 CSV,缺目录的直接跳过并打印,不中断整批任务。异常被捕获后打印文件名和错误,方便定位是哪份文档结构特殊。

注意: writer.append(reader) 会保留原页面,但不会自动保留原书签。如果原文档已有书签且你想追加而非覆盖,得先把旧书签读出来合并进列表,否则新写入会顶掉旧的。

3.3 页码偏移与层级错乱的校验方法

批量跑完最怕两件事:书签整体偏移、层级显示错乱。校验方法很土但有效——写一个回读脚本,把输出 PDF 的书签重新读出来,和输入 CSV 逐条比对标题和页码。层级错乱通常表现为「子节点跑到根上」或「本该平级的缩进了」,根因多半是 CSV 里 depth 跳变(比如从 0 直接到 2),而 parent_stack 的弹栈逻辑假设层级是连续的。稳妥做法是在加载 CSV 后加一道校验:相邻两条的 depth 差值不能大于 1,超过就报错让人工修数据,别让脚本硬扛。

页码偏移则优先怀疑 page_offset 和 CSV 页码口径。我的习惯是拿一份已知结构的文档先单跑,打印每条书签的标题和目标页,人工核对前五条和最后五条,确认无误再放开批量。这一步花两分钟,能省掉后面整批返工。

4. 避坑与排查:书签读写里最容易翻车的五件事

4.1 现象:写入后阅读器书签栏空白

原因:最常见的是 writer 没有正确挂载 Outline 根,或者写入时用了 append 但没触发 Outline 重建。另一个隐蔽原因是原 PDF 是加密文档,读取时没解密,Outline 结构读出来是空的,写进去自然也是空的。

解决:先确认 reader.is_encrypted ,加密的先 reader.decrypt("") 试空密码。写入后立刻用 PdfReader 回读一次 outline ,为空就说明没写进去,别等打开阅读器才发现。

4.2 现象:书签全部指向第 1 页

原因: page_number 传成了 1 起始的页码,而 pypdf 要的是 0 起始。或者 CSV 里页码列被当字符串处理, int() 转换时出了岔子。

解决:统一在加载阶段减一,写入函数里不再做任何偏移。加一行断言 assert 0 <= bm['page'] < len(reader.pages) ,越界直接抛错,比默默指向第一页强。

4.3 现象:层级显示成平铺,子书签跑到根目录

原因: parent_stack 的弹栈条件写错,或者 CSV 里 depth 不连续。还有一种情况是 add_outline_item 返回的节点对象在后续被覆盖,导致父引用失效。

解决:在加载 CSV 后校验 depth 连续性,相邻差值大于 1 直接报错。写入循环里每次拿到 new_item 后立刻压栈或替换,不要延迟处理。

4.4 现象:中文标题乱码或显示成方框

原因:CSV 编码不是 UTF-8,或者 PDF 内嵌字体不支持中文。前者是数据问题,后者是文档问题。

解决:CSV 统一用 utf-8-sig 读写。如果回读标题正常但阅读器显示方框,那是原 PDF 字体缺中文字形,换一个带中文字体的阅读器或重新嵌入字体,脚本层面无解。

4.5 现象:批量处理到一半报错中断

原因:某份 PDF 结构异常,比如 Outline 里有循环引用,或者页面对象损坏,导致遍历或写入抛异常。

解决: batch_process 里对单文件加 try/except,失败的文件记录到日志继续跑,别让一份坏文档拖垮整批。跑完统一看失败清单,单独处理。

5. 进阶技巧:用书签反查文档结构完整性

书签不只是导航工具,它还能当文档质量的「体检指标」。我后来养成了一个习惯:拿到任何长文档,先跑一遍读取脚本,把书签的层级分布和页码跨度打印出来。如果一份 600 页的文档书签只有 5 条且全在根层级,基本可以判断它的目录结构是残缺的;如果子书签页码跨度异常大(比如某章从第 3 页直接跳到第 500 页),往往意味着中间有内容缺失或合并错误。这套判断在批量验收时特别管用。

更进一步,可以把书签数据和页面文本做交叉验证。比如读取每条书签的标题,去目标页附近搜索这个标题字符串,搜不到就标记为「可疑书签」。下面这个小函数就是干这个的。

def verify_bookmarks(reader, bookmarks, window=2):
    """校验书签标题是否能在目标页附近找到,返回可疑列表"""
    suspicious = []
    for bm in bookmarks:
        page_no = bm['page']
        if page_no < 0 or page_no >= len(reader.pages):
            suspicious.append((bm['title'], '页码越界'))
            continue
        # 在目标页前后 window 页范围内找标题
        found = False
        for offset in range(-window, window + 1):
            idx = page_no + offset
            if 0 <= idx < len(reader.pages):
                text = reader.pages[idx].extract_text() or ''
                if bm['title'][:8] in text:  # 取前8字降低匹配门槛
                    found = True
                    break
        if not found:
            suspicious.append((bm['title'], f'第{page_no+1}页附近未找到'))
    return suspicious

逻辑说明: window 控制搜索半径,默认前后 2 页,因为标题文字有时会因分页落在相邻页。取标题前 8 个字匹配是为了避开长标题里的标点差异。返回的可疑列表人工过一遍,能揪出不少页码错位和标题笔误。参数上, window 调大召回高但误报也多,一般 2 到 3 够用。

提示: extract_text() 对扫描版 PDF 返回空字符串,这类文档的书签校验只能靠人工,别指望脚本。

从那以后我每次批量注入书签,都强制走一遍「回读 + 交叉验证」,宁可多花十分钟,也不想到头来被一份页码全错的书签坑到重做。

到此这篇关于Python批量读取、解析与写入PDF书签的完整指南的文章就介绍到这了,更多相关Python批量处理PDF书签内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文