PDF 去水印开发实录:Form XObject、Identity-H 字体与 6 个对象层深坑

15 阅读5分钟

做 PDF 去水印这个功能之前,我以为是个两周的活。结果在 PDF 对象层里泡了一个多月,踩的坑比预想的多得多。这篇把真正难的地方记下来,给后来要碰这块的人省点时间。

先说结论:PDF 里没有"水印"这个对象类型。所谓水印,只是内容被组织成某种结构之后,人眼看起来像水印。这个认知一旦建立,后面所有问题都能归位。

坑一:水印可能根本不在页面上

最先遇到的是"检测不到"。

用常规方式遍历页面内容流,什么水印都找不到,但打开文件明明有水印。

原因:制作者把水印做成了一个独立的 Form XObject,然后每页只写一句引用:

/Fm0 Do

这等于页面说"把 0 号资源画上去",水印实体在另一个对象里。页面级扫描当然扫不到。

解法是递归。Form 里面还能套 Form,得一直往下走:

function collectContentStreams(page, ctx, depth = 0, ctm = identity) {
  if (depth > MAX_FORM_DEPTH) return [];
  // 遍历页面 /Resources /XObject
  // 遇到 /Subtype /Form 就递归,并且复合变换
}

坑二:矩阵要复合,不能只看一层

这是最隐蔽的一个。Form 有自己的 /Matrix,父级调用它的时候还有 Do 操作带来的 CTM。两者相乘才是水印在页面坐标系里的真实位置。

只算一层,坐标就偏。偏多少取决于 Form 自己的缩放——我遇到的案例里偏了将近 40%,导致定位框完全错位,预览里看着"检测到了",实际位置在页面外面。

复合顺序也不能反:先应用父级 CTM,再应用 Form 的 Matrix。

坑三:回写时不清这六个键,文件会直接打不开

这条是最容易埋雷的。

改完内容流要回写。如果你把内容流改成了未压缩的形式,却还留着原来的 Filter 声明,Acrobat 打开时会直接报"文件已损坏"。

必须清掉的键:

Filter  DecodeParms  F  FFilter  FDecodeParms  DL

漏一个都不行。而且报错信息完全看不出跟水印操作有关——它会说文件损坏,你会去怀疑交叉引用表、怀疑 xref stream,绕一大圈。

我的做法是回写时新建一个干净的 stream 对象,只带必要字段,而不是在原对象上改:

const clean = PDFRawStream.of(dict, bytes);
ctx.assign(ref, clean);

坑四:中文水印搜不到字

这个坑只有中文文档会遇到。

中文字体嵌入 PDF 通常用 Identity-H 编码。页面里存的不是"草稿"这两个字的 Unicode,而是一串字形索引(GID):

<00120034> Tj

你在内容流里 grep "草稿",永远 grep 不到。

要按语义删除,必须反过来查字体的 ToUnicode CMap:从 GID 反查出 Unicode,才知道这一段画的到底是不是"草稿"。

没有这一步,只剩两条路:按位置盲删(容易误伤正文),或者放弃。很多工具宣称"支持中文水印",实际只能处理英文,原因就在这里。

坑五:位图水印根本删不掉

扫描件、手机拍照、带条带水印的下载版论文——整页就是一张图。

水印的像素和正文的像素是同一批像素。不存在"删掉水印露出下面的字",因为下面没有字。

这类只能重建:OCR 建文字层 → 定位覆盖区域 → 按周边版式推断补回。

推断这一步不可靠,所以必须加护栏:重建结果强制人工核对数字。表格线连错无所谓,金额、日期、证件号、条款编号错了就是事故。

坑六:自动判断"这条是不是水印"比删更难

前面五个坑都是"能不能删"。真正难的是"该不该删"。

页面上一条横线,可能是水印,也可能是表格边框。删错了比不删严重得多。

我的做法是打分,不是二值判断:

_watermarkScore = 透明度权重 + 旋转角度 + 重复出现次数
                + 位置居中程度 + 字号异常度 + ...

超过阈值才建议清理,低于阈值列出来让用户自己勾。阈值我定在 30 分——这个值调了很久,太高漏删,太低误删。

关键是不要替用户做最终决定。检测和清理必须分开:先出候选列表(含页码、类型、置信度),用户核对,再执行。

结果

现在这套逻辑跑下来,四类水印的处理路径是这样的:

类型判断依据处理
矢量文字能选中删文本对象
Form 包装Acrobat 找不到递归 + 矩阵复合
中文 CID搜不到字ToUnicode 反查
位图整页是图OCR + 重建 + 人工核对

这个能力最后做进了 PDFnoted,基础的部分在浏览器本地跑,不传服务器。

两点反思

第一,"AI 一键搞定"这个说法在这个场景里是有害的。 用户会以为可以把带法律效力的合同甩进去点一下就完事。实际情况是位图那一类必须人工核对。把能力边界说清楚,比宣称全自动更能留住用户。

第二,PDF 规范给了太多种"实现同一件事"的方式。 同样一个水印,至少有六种存法。做这类工具,与其写更多功能,不如把"检测"这一层做扎实——检测对了,清理是顺带的事;检测错了,清理再漂亮也是错的。

如果你也在做类似的 PDF 处理,欢迎交流踩坑经验。