查看: 264|回复: 0

Python pypdf批量读写PDF书签与层级偏移排查

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
在整理六七百页技术文档时,目录页没有书签,阅读器左侧书签栏空白,手工逐份添加不现实。原文用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的嵌套表现不完全一致,读之前可先打印原始结构确认。
  1. from pypdf import PdfReader
  2. def walk_outline(reader, outline, depth=0, result=None):
  3.     if result is None:
  4.         result = []
  5.     if outline is None:
  6.         return result
  7.     for item in outline:
  8.         if isinstance(item, list):
  9.             walk_outline(reader, item, depth + 1, result)
  10.             continue
  11.         title = item.get('/Title', '')
  12.         try:
  13.             page_no = reader.get_destination_page_number(item)
  14.         except Exception:
  15.             page_no = -1
  16.         result.append({'title': title, 'depth': depth, 'page': page_no})
  17.     return result
  18. reader = PdfReader('sample.pdf')
  19. bookmarks = walk_outline(reader, reader.outline)
  20. for bm in bookmarks:
  21.     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下。
  1. from pypdf import PdfReader, PdfWriter
  2. def add_bookmarks(writer, bookmarks):
  3.     parent_stack = [None]
  4.     for bm in bookmarks:
  5.         depth = bm['depth']
  6.         while len(parent_stack) > depth + 1:
  7.             parent_stack.pop()
  8.         parent = parent_stack[-1]
  9.         new_item = writer.add_outline_item(
  10.             title=bm['title'],
  11.             page_number=bm['page'],
  12.             parent=parent
  13.         )
  14.         if len(parent_stack) == depth + 1:
  15.             parent_stack.append(new_item)
  16.         else:
  17.             parent_stack[depth + 1] = new_item
  18.     return writer
  19. reader = PdfReader('sample.pdf')
  20. writer = PdfWriter()
  21. writer.append(reader)
  22. bookmarks = [
  23.     {'title': '第 1 章 概述', 'page': 0, 'depth': 0},
  24.     {'title': '1.1 背景', 'page': 1, 'depth': 1},
  25.     {'title': '1.2 目标', 'page': 3, 'depth': 1},
  26.     {'title': '第 2 章 方法', 'page': 5, 'depth': 0},
  27. ]
  28. add_bookmarks(writer, bookmarks)
  29. with open('output.pdf', 'wb') as f:
  30.     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。
  1. import csv
  2. def load_bookmarks_from_csv(csv_path, page_offset=0):
  3.     bookmarks = []
  4.     with open(csv_path, newline='', encoding='utf-8-sig') as f:
  5.         reader = csv.DictReader(f)
  6.         for row in reader:
  7.             bookmarks.append({
  8.                 'title': row['title'].strip(),
  9.                 'page': int(row['page']) - 1 + page_offset,
  10.                 'depth': int(row.get('depth', 0))
  11.             })
  12.     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返回空字符串,这类文档只能人工校验。
  1. def verify_bookmarks(reader, bookmarks, window=2):
  2.     suspicious = []
  3.     for bm in bookmarks:
  4.         page_no = bm['page']
  5.         if page_no < 0 or page_no >= len(reader.pages):
  6.             suspicious.append((bm['title'], '页码越界'))
  7.             continue
  8.         found = False
  9.         for offset in range(-window, window + 1):
  10.             idx = page_no + offset
  11.             if 0 <= idx < len(reader.pages):
  12.                 text = reader.pages[idx].extract_text() or ''
  13.                 if bm['title'][:8] in text:
  14.                     found = True
  15.                     break
  16.         if not found:
  17.             suspicious.append((bm['title'], f'第{page_no+1}页附近未找到'))
  18.     return suspicious
复制代码

书签还能反查文档结构完整性:600页文档书签只有5条且全在根层级,基本可判断目录结构残缺;子书签页码跨度异常大,例如某章从第3页直接跳到第500页,往往意味着中间内容缺失或合并错误。批量验收时配合回读和交叉验证,能提前发现页码错位和标题笔误。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-20 13:49 , Processed in 0.023696 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部