背景 链接到标题

一本 A4 尺寸的扫描版 PDF,A4 尺寸的扫描版 PDF。每页底部固定出现水印图样,并包含一个可点击的矩形区域,点击后跳转到外部站点。原始 PDF 使用 Adobe Acrobat Pro DC 17 Paper Capture 生成,具备完整的 OCR 文字层。

目标:彻底移除水印,保留清晰的纯阅读版 PDF。

诊断:水印的四层结构 链接到标题

解剖 PDF 内部结构后发现,水印并非单一图层,而是同时嵌入了四个层面:

flowchart TB P["一本扫描版 PDF
多页"] --> L1["Layer 1: Resources/XObject
图片对象引用 /Im1"] P --> L2["Layer 2: 内容流
渲染命令 /Im1 Do"] P --> L3["Layer 3: 文本层
OCR 可选的水印文字"] P --> L4["Layer 4: /Annots
Link 注释 /URI 外部链接"] L1 --> R1["绝大多数页含此引用"] L2 --> R2["绝大多数页含此命令"] L3 --> R3["约 绝大多数页含此文本"] L4 --> R4["绝大多数页含此注释"] style L1 fill:#e3f2fd style L2 fill:#e3f2fd style L3 fill:#e3f2fd style L4 fill:#e3f2fd
  • Layer 1: 每页的 Resources 字典引用了名为 /Im1 的 XObject 图片(xreference → 文件尾部存储的 PNG 图像)
  • Layer 2: 页面内容流中插入了 q cm /Im1 Do Q 指令序列,负责将图片渲染到页面上
  • Layer 3: OCR 引擎将水印图片上的文字识别为可选文本,嵌入到内容流中
  • Layer 4: 页面的 /Annots 数组中定义了一个 Link 注释,框选范围覆盖水印区域,URI 指向外部站点

只有四层全部清除,才算真正移除水印。

走过的弯路 链接到标题

弯路 1:单一工具只能处理单一层面 链接到标题

首先尝试了 pdf-redactor 的文本替换功能,它能够高效处理 Layer 3(文本层),但对 Layer 1、2、4 完全无效。效果是:肉眼仍能看见水印图片,点击仍能跳转,只是在文字搜索时不再出现水印文字。

弯路 2:视觉覆盖留下矩形边框 链接到标题

尝试 page.draw_rect() 用白色矩形覆盖水印区域。问题有二:(1)部分页面背景不是纯白色,白块显得突兀;(2)部分版本留下细不可见的矩形边框,像素检测仍能检出。

弯路 3:delete_image API 行为不一致 链接到标题

PyMuPDF 的 page.delete_images() 在某些页面未能生效,且没有一致的错误提示,排查困难。

弯路 4:正则适配不够全面 链接到标题

绝大多数页中有 绝大多数的 /Im1 Do 命令格式一致,但有 2 页多了一行 /GS1 gs 命令,导致初始写死的单行正则漏匹配。此外,Layer 4 的 /Annots 有内联数组 [11 0 R] 和间接引用 22 0 R 两种表示法,需分别处理。

最终方案:逐层定位与清理 链接到标题

从最内层的引用关系开始,由底层向表层逐步处理。

处理顺序 链接到标题

Layer 1 (Resources) + Layer 2 (内容流)
   ↓
Layer 3 (文本层)
   ↓
Layer 4 (注释)
   ↓
garbage 回收

Layer 1 + 2:移除图片引用与渲染命令 链接到标题

通过解析原始 PDF 对象,用正则表达式匹配并删除内容流中的 /Im1 Do 渲染序列,同时从页对象的 Resources 字典中删除 /Im1 的引用。

关键操作为两处正则替换:

  • 内容流:匹配 q … /Im1 Do Q(兼容 2~3 行长度差异)
  • Resources:匹配 /Im1 \d+ 0 R 并移除

Layer 3:清理 OCR 文本层 链接到标题

使用 pdf-redactor 的正则内容过滤器扫描每页内容流,将水印文字替换为空字符串。此步骤仅影响文本搜索与复制,不影响已有图片。

解析每页的 /Annots 对象,删除类型为 /Link 且包含 /URI 的注释条目。同时兼容两种语法格式:

  • 内联数组:/Annots [11 0 R]
  • 间接引用:/Annots 22 0 R

收尾:垃圾回收 链接到标题

全部清理后在 save() 中启用 garbage=4,让 PyMuPDF 自动回收所有不再被引用的 xref 对象,最终文件不带冗余数据。

验证方法 链接到标题

采用四种互补手段确认清理结果:

  1. 像素采样:随机抽取 10 页,读取水印区域的 RGB 像素值,全部为 (255,255,255),且无任何杂色边框
  2. xref 扫描:调用 page.get_images() 确认 /Im1 图片引用已从所有页面消失
  3. 文本搜索:用 pdftotext + grep 确认水印文字在整个文档中不再出现
  4. 注释枚举:遍历 page.annots() 确认无 Link 注释残留

总结 链接到标题

本问题的核心难点不在于某一层的移除,而在于全面认识水印的散布范围。四个层面中任何一个遗漏,都会被视为"没清干净"。

层面 清除手段 覆盖页数
Resources/XObject 正则移除对象引用 全部
内容流渲染命令 正则删除 /Im1 Do 全部
OCR 文本层 pdf-redactor 正则替换 全部
Link 注释 正则删除 /Annots 条目 全部

关键教训:

  • 先诊断、后治疗:解剖 PDF 内部结构,搞清楚"敌人"长什么样、分布在哪,比反复试错更高效
  • 格式差异是最大的坑:PDF 作为一种表现层格式,同样的信息可以有完全不同的语法表达,需覆盖主要变体
  • 验证必须多维度:肉眼看不到 ≠ 已清除,像素、对象、文本、注释各维度独立验证