AIGC标识 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 那层,错了以后现象是"明明扫到了却匹配不上",非常难定位。

posted @ 2026-10-24 16:46  PDFnoted  阅读(5)  评论(0)    收藏  举报