在整理六七百页技术文档时,目录页没有书签,阅读器左侧书签栏空白,手工逐份添加不现实。原文用Python的pypdf(旧版本为PyPDF2)完成两件事:读取已有Outline结构,再按规则批量写回。下面按底层结构、读取、写入、批量注入和排查校验重组,代码保留原文关键逻辑。
一、PDF书签的底层结构
PDF书签叫Outline,是由字典对象组成的树,不是简单的“标题+页码”键值对。节点至少有/Title显示文字、/Parent父节点、/Dest或/A跳转目标;跳转目标通常是包含页面对象引用和视图模式的目标数组,例如/XYZ跳到坐标、/Fit整页适配。层级和顺序依靠/First、/Last、/Next、/Prev指针串成链表,所以读取要递归遍历,写入要把父子兄弟指针接对,否则阅读器可能不显示或显示成平铺。pypdf(旧版PyPDF2)对Outline的封装较贴近原始结构,读和写都能控制细节。
二、读取书签:递归遍历与目标页反查
reader.outline返回混合结构:嵌套list表示该层还有子节点,字典节点才是书签项;遇到list递归并把depth加一。get_destination_page_number把目标引用翻译成页码,拿不到就记-1,便于排查。不同pypdf版本对outline返回结构略有差异,老PyPDF2的嵌套表现不完全一致,读之前可先打印原始结构确认。
- from pypdf import PdfReader
- def walk_outline(reader, outline, depth=0, result=None):
- if result is None:
- result = []
- if outline is None:
- return result
- for item in outline:
- if isinstance(item, list):
- walk_outline(reader, item, depth + 1, result)
- continue
- title = item.get('/Title', '')
- try:
- 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')
- bookmarks = walk_outline(reader, reader.outline)
- for bm in bookmarks:
- print(' ' * bm['depth'], bm['title'], '->', bm['page'] + 1)
复制代码
三、写入书签:parent_stack控制层级
写入比读取难,需要自己接/First、/Last、/Next、/Prev、/Parent。pypdf的add_outline_item能省掉大部分指针拼接,但默认平铺,要做多级层级得控制parent参数。做法是先把书签整理成带层级的扁平列表,按阅读顺序逐条添加;parent_stack栈顶始终是当前深度的父节点,遇到更深层级把上一节点压栈当父,遇到更浅层级先弹栈再取父。page_number从0开始,与读取页码口径一致,parent传None表示挂在根Outline下。
- from pypdf import PdfReader, PdfWriter
- def add_bookmarks(writer, bookmarks):
- 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
- 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)
复制代码
注意writer.append(reader)会保留原页面,但不会自动保留原书签;原文档已有书签且要追加而非覆盖时,需要先把旧书签读出来合并进列表,否则新写入会顶掉旧的。原文件也不直接修改,而是另存输出。
四、批量注入:CSV、页码偏移与异常隔离
实际项目里书签数据可来自目录页OCR或文本提取、外部Excel/CSV目录表,或按固定规则生成。原文优先用CSV,至少三列title、page、depth,页码按1起始读入后减一。解析目录文本时,点线和页码可用正则兼容中英文及不同点线数量,再按编号点号数量或前导空格推断depth,规则要按文档样式调。CSV用utf-8-sig读,避免Excel导出的BOM头污染第一列列名。page_offset是印刷页码到物理页码的补偿量,例如封面加目录共2页,正文第1页实际是物理第3页,偏移填2。
- import csv
- def load_bookmarks_from_csv(csv_path, page_offset=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
复制代码
批量流程按文件名匹配CSV:遍历源目录PDF,读取同名CSV注入书签并另存到输出目录;缺目录文件打印跳过,不中断整批;单文件注入用try/except捕获异常并打印文件名和错误,失败文件记录后继续跑。加载CSV后校验相邻两条depth差值不能大于1,超过就报错让人工修数据,避免parent_stack弹栈逻辑把层级搞乱。
五、五类常见现象与排查
1. 写入后书签栏空白:常见是writer没有正确挂载Outline根,或写入时用了append但没触发Outline重建;也可能是原PDF加密,读取时没解密,Outline读出来为空。先确认reader.is_encrypted,加密的先reader.decrypt('')试空密码;写入后立刻用PdfReader回读outline,为空就说明没写进去。
2. 书签全部指向第1页:page_number传成了1起始,而pypdf要0起始;或CSV页码列被当字符串处理,int转换出岔子。统一在加载阶段减一,写入函数不再偏移,并加断言assert 0 <= bm['page'] < len(reader.pages),越界直接抛错。
3. 层级显示成平铺、子书签跑到根目录:parent_stack弹栈条件写错,或CSV里depth不连续;也可能add_outline_item返回的节点对象后续被覆盖导致父引用失效。加载后校验depth连续性,写入循环每次拿到new_item后立刻压栈或替换。
4. 中文标题乱码或显示成方框:CSV编码不是UTF-8,或PDF内嵌字体不支持中文。CSV统一utf-8-sig读写;若回读标题正常但阅读器显示方框,是原PDF字体缺中文字形,换带中文字体的阅读器或重新嵌入字体,脚本层面无解。
5. 批量处理到一半报错中断:某份PDF结构异常,如Outline循环引用或页面对象损坏,导致遍历或写入抛异常。batch_process里对单文件加try/except,失败文件记录到日志继续跑,跑完统一看失败清单。
六、回读与交叉验证
批量跑完最怕书签整体偏移和层级错乱。有效办法是写回读脚本,把输出PDF书签重新读出来,与输入CSV逐条比对标题和页码;页码偏移优先怀疑page_offset和CSV页码口径。先用一份已知结构文档单跑,打印前五条和最后五条人工核对,再放开批量。进一步可把书签标题与目标页附近文本交叉验证,在目标页前后window页范围内搜索标题前8个字,搜不到标记为可疑书签。window默认2到3,调大召回高但误报也多;extract_text对扫描版PDF返回空字符串,这类文档只能人工校验。
- 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
- 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:
- found = True
- break
- if not found:
- suspicious.append((bm['title'], f'第{page_no+1}页附近未找到'))
- return suspicious
复制代码
书签还能反查文档结构完整性:600页文档书签只有5条且全在根层级,基本可判断目录结构残缺;子书签页码跨度异常大,例如某章从第3页直接跳到第500页,往往意味着中间内容缺失或合并错误。批量验收时配合回读和交叉验证,能提前发现页码错位和标题笔误。 |