有一批pdf文档需要批量去水印。处理过程中,遇到一个看似简单但实际并不直接的问题:PDF 阅读器或编辑器里可以看到水印,输入编辑密码后(如有)可以通过“删除水印”功能去掉;但由于手动在 PDF 阅读器编辑效率太低,考虑用脚本扫描 AnnotationOCG Layer、重复图片对象,但却完全检测不到水印。

这个问题的关键在于:PDF 里的水印并不一定是独立控件。有些水印不是批注,不是图层,也不是独立图片,而是被写进页面内容流中的一段绘制命令。要稳定清理这类水印,不能只从“看起来像水印”入手,而要从 PDF 内部结构入手。

本文记录一次从问题分析、结构定位、脚本验证,到批量处理的完整过程。

1. 问题现象

最初使用脚本扫描 PDF,结果如下:

1
./pdf_watermark_cleaner.py CDS_Downloader_Suite_with_watermark.pdf

输出显示:

1
2
3
4
5
6
7
8
9
10
11
Input : CDS_Downloader_Suite_watermarked.pdf
Pages : 6
Mode  : DRY-RUN

=== Watermark cleanup candidate report ===

[Annotations] candidates: 0
[OCG/Layers] candidates: 0
[Repeated images] candidates: 0

Dry-run only. No output PDF written.

但同一个 PDF 在 PDF 编辑器中可以看到水印,并且可以通过编辑器的“删除水印”功能清除。

这说明水印并不是下面这些常见形态:

  • /Annots 里的 Watermark / Stamp / FreeText;
  • /OCProperties 里的可选内容图层;
  • 每页重复出现的 Image XObject;
  • 简单可枚举的普通水印控件。

2. 为什么 PDF 编辑器能删除水印?

PDF 编辑器能删除水印,通常有几种原因:

类型 PDF 内部形态 编辑器删除方式
标准水印 /Artifact/Subtype /WatermarkBDC ... EMC 删除 marked-content 块
批注水印 /Annots 中的 /Watermark/Stamp/FreeText 删除 annotation 对象
图层水印 /OCProperties / OCG Layer 关闭或删除图层
Form XObject 水印 /XObject 中的 Form 对象 删除调用或删除资源
图片水印 Image XObject 删除图片引用
页面内容流水印 普通绘图命令、文字命令、透明度状态 分析内容流后删除
编辑器私有水印 /PieceInfo、XMP、私有 metadata 编辑器根据私有信息删除

在本案例中,编辑器能删,并不是因为它做了 OCR,也不是因为它根据视觉相似度猜测,而是因为 PDF 内部已经有明确的水印结构标记。

3. 使用 qpdf 展开 PDF 内部结构

PDF 文件内部通常包含压缩对象和压缩内容流,直接用文本工具打开很难分析。可以使用 qpdf 将 PDF 展开为 QDF 形式,便于 grepdiff

安装:

1
sudo dnf install -y qpdf

展开删除水印前后的 PDF:

1
2
3
4
5
6
7
qpdf --qdf --object-streams=disable \
  CDS_Downloader_Suite_with_watermark.pdf \
  before_qdf.pdf

qpdf --qdf --object-streams=disable \
  CDS_Downloader_Suite_without_watermark.pdf \
  after_qdf.pdf

生成结构差异:

1
diff -u before_qdf.pdf after_qdf.pdf > pdf_diff.txt

提取关键标记:

1
2
3
4
5
6
7
grep -a -n -i -E "Watermark|Artifact|PieceInfo|Private|XObject|ExtGState|ca |CA |BDC|EMC|Do |Tm|cm|gs" \
  before_qdf.pdf > before_marks.txt

grep -a -n -i -E "Watermark|Artifact|PieceInfo|Private|XObject|ExtGState|ca |CA |BDC|EMC|Do |Tm|cm|gs" \
  after_qdf.pdf > after_marks.txt

diff -u before_marks.txt after_marks.txt > marks_diff.txt

4. 关键发现:水印在页面内容流中

before_marks.txt 里可以看到类似结构:

/FXE1 gs /Artifact <</Type/Pagination/Subtype/Watermark>> BDC
q 0.707107 -0.707107 0.707107 0.707107 67.9818 535.846 cm /FXX1 Do Q
...
EMC

另一类水印块类似:

/RelativeColorimetric ri 1 i /FXE4 gs /Artifact <</Type/Pagination/Subtype/Watermark>> BDC
q ... /FXX2 Do Q
EMC

核心识别点是:

1
2
3
4
5
/Artifact
/Subtype/Watermark
BDC
/FXX1 Do 或 /FXX2 Do
EMC

这说明水印不是普通批注,也不是普通图片,而是页面内容流中的 marked content。真正绘制水印的是 /FXX1 Do/FXX2 Do 这类 Form XObject 调用。

也就是说,PDF 编辑器所谓的“删除水印”,本质上是在页面内容流中删除这类水印 marked-content 块

5. 为什么脚本会误删正文?

不完备的脚本,错误在于:它识别到了 /Artifact ... /Subtype /Watermark ... BDC,但删除范围不够精确。

在实际 PDF 内容流中,水印结束标记 EMC 后面,往往紧跟正常正文绘制命令,例如:

... /FXX1 Do Q ... EMC q 0.96 0 0 -0.96 18 755.6 cm ...

如果脚本采用“按行删除”或“从 /Artifact 删除到行尾”的方式,就会把 EMC 后面的正常正文也删掉。

正确做法必须是:

1
2
只删除从水印 prelude 到 matching EMC 为止的内容;
EMC 后面的任何内容都必须保留。

错误做法是:

1
2
3
4
删除整行;
删除整个 content stream;
删除整个 BDC 所在行;
删除所有带 Watermark 字样的对象。

6. 单文件清理方案

针对这个 PDF 的结构,可以使用 pikepdf 解析 PDF 内容流,并做精确删除。

核心逻辑如下:

  1. 找到 /Artifact << ... /Subtype /Watermark ... >> BDC
  2. BDC 之后开始匹配对应的 EMC
  3. 支持嵌套 BMC/BDC ... EMC,避免错误匹配;
  4. 检查候选块中是否包含 /FXX1 Do/FXX2 Do
  5. 只删除到 matching EMC
  6. 不删除 EMC 后面的正常内容;
  7. 默认 dry-run,确认后再写输出文件。

安装依赖:

1
python3 -m pip install --user pikepdf

单文件测试:

1
2
3
4
5
chmod +x pdf_watermark_exact_cleaner.py

./pdf_watermark_exact_cleaner.py CDS_Downloader_Suite_with_watermark.pdf \
  --xobject-names FXX1,FXX2 \
  --debug-dump wm_debug

如果输出显示:

1
Active candidates : 12

说明这 6 页 PDF 中,每页检测到 2 个水印块。

确认后执行:

1
2
3
4
./pdf_watermark_exact_cleaner.py CDS_Downloader_Suite_with_watermark.pdf \
  -o CDS_Downloader_Suite_without_watermark.pdf \
  --xobject-names FXX1,FXX2 \
  --apply

7. 批量处理深目录中的 PDF

当 PDF 散落在深目录中时,不建议直接原地覆盖。更稳妥的方式是:

  1. 递归扫描 PDF;
  2. 分批列出路径给用户确认;
  3. 清理后写入临时 output 目录;
  4. 保留原始目录层级和文件名;
  5. 用户抽查清理结果;
  6. 最后用 rsync -av 同步回原目录。

例如原始文件:

1
/data/CDS_Downloader_Suite/a/b/c/test.pdf

输出到:

1
/tmp/CDS_Downloader_Suite_pdf_clean_output/a/b/c/test.pdf

批量脚本调用示例:

1
2
3
4
5
6
7
chmod +x batch_pdf_watermark_exact_cleaner.py

./batch_pdf_watermark_exact_cleaner.py \
  /data/CDS_Downloader_Suite \
  -o /tmp/CDS_Downloader_Suite_pdf_clean_output \
  --xobject-names FXX1,FXX2 \
  --batch-size 30

交互选项:

1
2
3
4
[y]es             处理当前批次
[s]kip            跳过当前批次
[a]ll remaining   处理剩余全部批次
[q]uit            退出

只扫描、不处理:

1
2
3
4
./batch_pdf_watermark_exact_cleaner.py \
  /data/CDS_Downloader_Suite \
  -o /tmp/CDS_Downloader_Suite_pdf_clean_output \
  --list-only

全自动处理:

1
2
3
4
5
./batch_pdf_watermark_exact_cleaner_v2.py \
  /data/CDS_Downloader_Suite \
  -o /tmp/CDS_Downloader_Suite_pdf_clean_output \
  --xobject-names FXX1,FXX2 \
  --yes

8. 批量处理后的人工检查

批量清理完成后,不要立即覆盖原文件。先抽查不同目录下的 PDF:

1
find /tmp/CDS_Downloader_Suite_pdf_clean_output -type f -iname '*.pdf' | head -20

建议至少检查:

  • 首页;
  • 中间页;
  • 最后一页;
  • 表格密集页;
  • 图形密集页;
  • 不同子目录下的 PDF;
  • 文件大小明显变化的 PDF。

处理日志位于:

1
/tmp/CDS_Downloader_Suite_pdf_clean_output/pdf_watermark_batch_log.csv

PDF 清单位于:

1
/tmp/CDS_Downloader_Suite_pdf_clean_output/pdf_found_manifest.txt

9. 使用 rsync 同步回原目录

由于临时 output 目录里还有日志文件和清单文件,回写时应排除它们。

先 dry-run:

1
2
3
4
5
rsync -av --dry-run \
  --exclude='pdf_found_manifest.txt' \
  --exclude='pdf_watermark_batch_log.csv' \
  /tmp/CDS_Downloader_Suite_pdf_clean_output/ \
  /data/CDS_Downloader_Suite/

确认输出路径和覆盖列表正确后,再真正同步:

1
2
3
4
5
rsync -av \
  --exclude='pdf_found_manifest.txt' \
  --exclude='pdf_watermark_batch_log.csv' \
  /tmp/CDS_Downloader_Suite_pdf_clean_output/ \
  /data/CDS_Downloader_Suite/

为了安全,建议同步前备份原目录:

1
rsync -av /data/CDS_Downloader_Suite/ /data/CDS_Downloader_Suite_backup_before_pdf_watermark_clean/

10. 打包与分发建议

这个脚本依赖 pikepdf,而 pikepdf 背后有二进制依赖。对于 Rocky Linux 8.10 或内网环境,更稳妥的方式是:

1
Python 脚本 + requirements.txt + wheelhouse + venv

准备离线依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
mkdir -p pdf_watermark_batch_pkg
cd pdf_watermark_batch_pkg

cp /path/to/batch_pdf_watermark_exact_cleaner.py .

cat > requirements.txt <<'EOF'
pikepdf
EOF

python3 -m pip download \
  -r requirements.txt \
  -d wheelhouse \
  --only-binary=:all:

打包:

1
2
cd ..
tar czf pdf_watermark_batch_pkg.tar.gz pdf_watermark_batch_pkg

目标机器安装:

1
2
3
4
5
6
7
8
9
10
tar xzf pdf_watermark_batch_pkg.tar.gz
cd pdf_watermark_batch_pkg

python3 -m venv venv
source venv/bin/activate

python3 -m pip install \
  --no-index \
  --find-links=wheelhouse \
  -r requirements.txt

然后运行:

1
2
3
4
5
python3 batch_pdf_watermark_exact_cleaner.py \
  /data/CDS_Downloader_Suite \
  -o /tmp/CDS_Downloader_Suite_pdf_clean_output \
  --xobject-names FXX1,FXX2 \
  --batch-size 30

如果要做成单文件可执行程序,可以考虑 PyInstaller,但 pikepdf 这类二进制依赖需要特别处理,且最好在目标系统同版本环境中构建和验证。对于生产或红区环境,源码脚本加离线 wheelhouse 通常更可控。

11. 经验总结

这次问题的核心经验是:

  1. PDF 编辑器能删水印,不代表水印是 Annotation;
  2. 脚本检测不到 Annotation / OCG / Image,不代表没有可删除水印结构;
  3. qpdf --qdf 是分析 PDF 内部结构非常有效的工具;
  4. 水印可能是 /Artifact ... /Subtype /Watermark ... BDC ... EMC
  5. 删除时必须精确停在 matching EMC,不能按行删;
  6. 如果水印通过 Form XObject 绘制,可以用 /FXX1 Do/FXX2 Do 作为二次约束;
  7. 批量处理不要直接覆盖原文件,先输出到临时目录;
  8. 回写前必须人工抽查,再用 rsync --dry-run 确认。

对 PDF 这类结构复杂的文件,最重要的不是“找到 Watermark 字样就删”,而是先确认可见水印和 PDF 对象结构之间的真实关系。只有明确对象边界之后,自动化脚本才是安全的。拥有了去水印的技能,那么批量加水印也不成问题,后续博主将分享批量加水印的经验。