PDF 水印删不掉?不是工具不行,是这 3 种水印藏在底层
做 PDF 处理的时候,最容易翻车的一句结论是:"我已经把内容流扫完了,没有水印。"
这句话在很多 PDF 上是错的。原因不复杂:你扫的那一层里根本没有水印。
问题现场
页面内容流 dump 出来只有这么几行:
q
1 0 0 1 0 0 cm
/Fm0 Do
Q
没有 Tj,没有文字,没有图像绘制指令。但打开 PDF,页面上明晃晃一个"仅供内部使用"的水印。
/Fm0 Do 的意思是:调用名为 Fm0 的 Form XObject。水印的所有绘制指令,都被打包进了这个独立对象里。
Form XObject 是什么
Form XObject 是 PDF 里一种可被重复调用的内容容器。它自己有独立的 BBox 和 Resources,通过 Do 操作符在页面或其他 Form 里被"实例化"。用它做水印很自然:写一次,多个页面复用,改样式只改一处。
所以处理顺序应该是:
页面内容流 → 发现 Do → 找到 /Fm0 → 递归进 Fm0 的内容流 → 再扫
而且递归不止一层。Fm0 里面还可以 Do 另一个 Fm1。我见过嵌套三层的。
真正的难点:坐标
就算你递归进去找到了水印文字,还有第二个坑——位置。
Form XObject 内部用的坐标系是它自己的 BBox 空间,不是页面空间。页面调用它的时候,会先设置 CTM(Current Transformation Matrix):
q
0.5 0 0 0.5 100 200 cm ← 缩放 0.5,平移到 (100,200)
/Fm0 Do
Q
Form 内部的 (0,0) 落到页面上其实是 (100,200),尺寸还缩了一半。如果你要判断"这个水印在页面的哪个区域",或者"这个水印是否覆盖在正文上",就必须把外层 cm 矩阵和 Do 时的 CTM 复合相乘,得到真实坐标。
实现上就是一个矩阵复合:
function mul(a, b) {
return [
a[0]*b[0] + a[1]*b[2], a[0]*b[1] + a[1]*b[3],
a[2]*b[0] + a[3]*b[2], a[2]*b[1] + a[3]*b[3],
a[4]*b[0] + a[5]*b[2] + b[4], a[4]*b[1] + a[5]*b[3] + b[5],
];
}
沿着递归路径把每一层的矩阵累乘下去,最后再乘上 Form 自己的 /Matrix(如果有的话)。漏乘任何一层,坐标都是错的。
中文水印:第三层
假设你顺利地在 Form 内部找到了文字指令,开心地去匹配"内部使用"四个字——大概率还是匹配不上。
因为中文字体在 PDF 里通常是 Identity-H 的 CID 字体,内容流里长这样:
<0031 0032 0033> Tj
这是字形 ID(GID),不是 Unicode。要把它还原成文字,必须查字体的 ToUnicode CMap,把 GID 映射回 Unicode 码点。
GID 0031 → U+5185 「内」
GID 0032 → U+90E8 「部」
没有这一步,任何基于关键词的水印识别对中文都是失效的。这也是为什么很多工具处理英文水印好好的,一碰到中文就"识别不到"。
删除比识别更难
识别到了,怎么删?
最直接的想法:把内容流里那几行指令删掉。可以,但要注意回写方式。我用的是 pdf-lib 的底层对象操作:
const newStream = PDFRawStream.of(cleanedDict, cleanedBytes);
ctx.assign(ref, newStream);
关键在于,替换流对象时必须把旧的解码相关键清掉:
['Filter', 'DecodeParms', 'F', 'FFilter', 'FDecodeParms', 'DL'].forEach(k => {
if (dict.has(PDFName.of(k))) dict.delete(PDFName.of(k));
});
原因是:新的字节是未编码的原始内容,而旧字典里还写着"这个流用 FlateDecode 压缩过"。解析器会照着旧参数去解压一段根本没压缩的数据,结果就是文件损坏、打不开。我第一次就是漏了这一步,生成的 PDF 在 Chrome 里直接报错。
别忘了注释形态的水印
还有一种水印压根不在内容流里,它是页面上的注释对象:
/Annots [ 12 0 R ]
% 12 0 obj: /Subtype /Watermark
这种情况下,你在内容流里翻到底也找不到它。而且它只能从对象层删除——试图在页面上盖一个白矩形是盖不住的,注释永远绘制在页面内容之上。
验证:别相信"我删了"
最后一个建议,也是我踩得最疼的一个:清理完必须重新解析一遍。
我早期的实现是扫到 N 个匹配就报"已清除 N 处水印",听上去合理,实际经常是错的——匹配逻辑在 CID 场景下会误判,递归层级没到底也会漏。改成"清理后重新 load 一次 PDF,检查目标对象引用是否还在",结论才站得住。
不要按数量生成结论,要按"引用是否还存在"生成结论。
写在最后
上面这套逻辑,我在做 PDFnoted 的 PDF 清理时逐个踩过:Form XObject 递归、CID 还原、注释水印三层都要覆盖,扫描结果按类型和页码列出,清理完会再解析一遍,确认目标对象引用确实已经不存在。
如果你打算自己实现,矩阵复合、ToUnicode、清 Filter 键、重新解析验证这四个点建议先写测试再写实现,否则调试成本很高——尤其 ToUnicode 那层,错了以后现象是"明明扫到了却匹配不上",非常难定位。
浙公网安备 33010602011771号