有一批pdf文档需要批量去水印。处理过程中,遇到一个看似简单但实际并不直接的问题:PDF 阅读器或编辑器里可以看到水印,输入编辑密码后(如有)可以通过“删除水印”功能去掉;但由于手动在 PDF 阅读器编辑效率太低,考虑用脚本扫描 Annotation、OCG 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 /Watermark、BDC ... 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 形式,便于 grep 和 diff。
安装:
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 内容流,并做精确删除。
核心逻辑如下:
- 找到
/Artifact << ... /Subtype /Watermark ... >> BDC; - 从
BDC之后开始匹配对应的EMC; - 支持嵌套
BMC/BDC ... EMC,避免错误匹配; - 检查候选块中是否包含
/FXX1 Do、/FXX2 Do; - 只删除到 matching
EMC; - 不删除
EMC后面的正常内容; - 默认 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 散落在深目录中时,不建议直接原地覆盖。更稳妥的方式是:
- 递归扫描 PDF;
- 分批列出路径给用户确认;
- 清理后写入临时 output 目录;
- 保留原始目录层级和文件名;
- 用户抽查清理结果;
- 最后用
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. 经验总结
这次问题的核心经验是:
- PDF 编辑器能删水印,不代表水印是 Annotation;
- 脚本检测不到 Annotation / OCG / Image,不代表没有可删除水印结构;
qpdf --qdf是分析 PDF 内部结构非常有效的工具;- 水印可能是
/Artifact ... /Subtype /Watermark ... BDC ... EMC; - 删除时必须精确停在 matching
EMC,不能按行删; - 如果水印通过 Form XObject 绘制,可以用
/FXX1 Do、/FXX2 Do作为二次约束; - 批量处理不要直接覆盖原文件,先输出到临时目录;
- 回写前必须人工抽查,再用
rsync --dry-run确认。
对 PDF 这类结构复杂的文件,最重要的不是“找到 Watermark 字样就删”,而是先确认可见水印和 PDF 对象结构之间的真实关系。只有明确对象边界之后,自动化脚本才是安全的。拥有了去水印的技能,那么批量加水印也不成问题,后续博主将分享批量加水印的经验。