做这个工具之前,难点看起来在「怎么把水印删掉」。

做完之后会发现,真正难的是怎么确认它真的删干净了、而且没顺手删掉别的东西。

这里记录一次完整的算法移植:把一个 Python 脚本变成桌面应用里一个能让用户放心点的按钮。中间踩到的坑,大多不在算法本身,而在「验证」这一层。


一个看起来成功了、其实什么都没干的陷阱

原始实现来自工作中需要处理文件但是wps的去水印没有效果,只能一页一页手动手动删除太浪费时间,于是随手写的 Python 小项目,当时思路很直接,只需要针对那一批文件就行了,全部文件水印都是一样的:解析 PDF 页面内容流,找出倾斜的文本显示操作(水印通常是 45° 或 30° 斜排的),把它的操作数替换成空——(水印文字) Tj 变成 () Tj。

删是删掉了。问题出在怎么判断删掉了。

一开始想复用同一个分析函数:处理完之后再跑一遍 analyze(),如果倾斜文本操作数变成 0,就算成功。跑下来永远不为 0。

原因是改错了地方。一段水印的完整指令长这样:

q
  0.707107 0.707107 -0.707107 0.707107 tx ty cm   % 旋转矩阵
  BT
    /F1 20 Tf
    1 0 0 1 0 0 Tm                                % 文字矩阵
    (YX-XTKJ-2509698X) Tj                         % ← 只有这一行被清空
  ET
Q

只把 Tj 的操作数换成了 (),旋转矩阵和图形状态是故意保留的——它们被正文和页面里其他元素共用,动它们风险太大。

于是 analyze() 再跑一遍,它数的还是「带旋转矩阵的文本操作」,数量一点没变。工具报告「没删掉」,但视觉上水印明明没了。

这里的关键是:

探测和移除必须解耦,而且是两套完全不同的视图。

用探测的方法去校验移除,等于让嫌疑人自己给自己做不在场证明。

三个真实样本,三种完全不同的水印形态

脱敏后,手头正好有三个形态迥异的真实文件。它们的差异直接把最初的算法打回了重写。

样本一:扫描件 + 编号水印。 13.75MB / 29 页,HP 扫描仪出的扫描图像,水印是合同编号 HT-SBKJ-260922NX,45° 斜排,每页 4 次。正文是图像,水平文字 0 字符。倾斜操作数 116 个,全是 Tj。

样本二:中文排版 + 人名水印。 0.93MB / 100 页,真实中文排版,水平文字 119620 字符,还有 28 个书签。水印是一长串部门加姓名,30° 斜排,每页 15 次,共 1500 个操作数。

这两个样本让第一版算法表现完美:角度识别 100% 准确(45.0° / 30.0°,没有杂角度),正文一个字都没误判。移除后倾斜字符 1856→0、32300→0,水平正文逐页完全一致。看起来可以收工了。

然后第三个样本来了,第一版算法命中 0 处。

样本三是 1.23MB / 6 页的分镜文档,水印是 SMARTUED.VIP 加一串编号,45° 斜排,每页 9 处,全文档 54 处。肉眼可见、浓淡均匀。

但算法找不到它。把页面内容流的文字角度分布打出来:p1 433、p2 375、p3 373、p4 426、p5 243、p6 353——全部 0°,倾斜 0 处。

因为这份文件的水印根本不在页面内容流里:

页面内容流:
  /Artifact <</Subtype/Watermark/Type/Pagination>> BDC
    q
      /KSPE2 gs                                  % 不透明度 7%
      0.707107 0.707107 -0.707107 0.707107 cm    % ← 旋转在这里
      /KSPXn Do                                  % 调用共享表单
    Q
  EMC

表单对象 81 0 R:
  PieceInfo << /ADBE_CompoundType << /Private /Watermark >> >>
  /OC 78 0 R                                     % 可选内容组
  内容流里:1 0 0 1 0 0 Tm  (水印文字) Tj        % ← 文字本身是水平的

54 处水印全部来自同一个共享 Form XObject。页面只是用 /KSPXn Do 调用它,旋转发生在文字块之外的 cm 矩阵上。

这个结构直接否定了整个第一版思路:任何基于「文字操作数角度」的启发式,都看不到这份文件的水印。 观测维度不对。

从「角度启发式」升级为「结构化探测」

补上来的检测信号有四条,全部来自 PDF 规范里的结构性标记:

  1. /Artifact <</Subtype/Watermark>> BDC ... Do ... EMC —— 内容流显式把某段标为水印图元
  2. PieceInfo /ADBE_CompoundType /Private /Watermark —— Adobe 系工具留下的水印标记
  3. 可选内容组 /OCG 的 /Name 含 Watermark,配合 /OCMD 使用
  4. ExtGState 的 CA / ca 小于 1 —— 半透明叠加,水印的典型特征

这四条信号里,前三条是强信号(足以判定为水印并移除),第四条只报告不自动移除——因为半透明图形也可能是正文里的装饰元素,误删代价太高。

代码里这个区分是显式的:

const strong =
  signals.has('水印标记(Artifact)') ||
  hasWatermarkPieceInfo(form.dict) ||
  !!watermarkLayer;

forms.push({
  ref: form.ref,
  signals: [...signals],
  remove: options.removeForms && strong,   // ← 非强信号一律不删
  stream: form.stream,
});

三条移除策略,各自独立

最终落地的移除路径有三条,互不依赖:

策略 A:倾斜文字置空。 把水印文本的显示操作数换成 () / [],保留矩阵与图形状态指令。对应样本一、样本二。

策略 B:Form XObject 中和。 把共享表单的内容流整体替换成空操作 q\nQ\n,一次对象编辑就让所有引用它的位置同时失效。对应样本三——54 处水印,一处改动全部干掉。

这里有个细节:空流写成 q\nQ\n 而不是真正的空,是为了保持图形状态的压栈/出栈配对,避免后续解析器对不配对的空流报错。

策略 C:内联可选内容组。 写在页面内容流里、由 /OC ... BDC 包裹的行内水印。

策略 C 列为待实现。 它需要重写内容流的可选内容结构,而收益是覆盖一个手上没有真实样本的形态。与其先写一段没验证过的代码,不如在界面上标明「这类水印待实现」。这是这个项目里最重要的取舍之一。

校验:为什么单靠文本层不够

校验分两层,成本递增。

第一层:文本画像。 用 pdfjs-dist 把原稿和输出各打开一遍,逐页统计水平正文字符数、倾斜文本字符数、残留倾斜文本样例。判据是:

const integrityOk = pageCountOk && horizontalMismatchPages.length === 0;
const watermarkClear =
  options.keywords.length > 0
    ? keywordResidual.length === 0
    : tiltedCharsAfter === 0;

return { passed: integrityOk && watermarkClear, ... };

第二层:渲染比对。 把原稿与输出各渲染成灰度图逐像素比较,要求「白底区域不得变暗」(没有引入新墨迹),并统计被清掉的水印像素比例。

关键点在于:这两层不能互相替代,而且第一层对样本三无效。

因为由 Form XObject + 可选内容组承载的水印,它的文字不进入文本抽取结果——文本抽取拿到的是逻辑文字,而这类水印在结构上被标成了 Artifact。所以样本三在第一层的表现是「倾斜字符本来就是 0」,它无法作为「清除与否」的证据。

代码里对此有显式的诚实标注:

if (options.structuralRemoval && tiltedCharsBefore === 0) {
  warnings.push('本文件的水印不在文本层(由表单对象/图层承载),文本层校验无法作为清除证据');
}

返回结果里也带了 textLayerLimited 字段,让界面能明确告诉用户:这份文件的校验是靠渲染比对完成的。

7% 不透明度带来的阈值问题

样本三的不透明度只有 7%(/CA 0.07 /ca 0.07)。

换算到像素,在纯白 255 的底色上,水印最深的像素大约只有 249,平均值 251.9。整个水印只占约 3 个灰阶。

第一次做像素比对时,阈值设的是 12。结果:水印像素一个都没被识别出来,工具会告诉你「没检测到水印变化」。

阈值必须降到 2。

/** 灰度差阈值:7% 透明度的水印在纯白背景上只有约 3 级灰度差,阈值必须取得足够小 */
const PIXEL_DELTA = 2;

比阈值更隐蔽的是视觉核对的方法。把渲染图直接并排看,第一轮结论是「看不出任何差异」——因为 249 和 255 并排放在屏幕上,人眼确实分辨不出。必须把 246–255 这段灰度拉伸到全动态范围再看,水印才浮现出来。

所以「肉眼看不到差异」不等于「没有差异」,也不等于「没有残留」。量化阈值和可视化的对比度增强,两者缺一不可。

最终样本三的数字证据是这样的:

指标结果
页数6 → 6
水平字符数2399 → 2399,逐页完全一致(490/399/392/470/266/382)
水印独有像素(原 240–254、输出变 255)p1 23147 / p2 21667 / p5 18974,100% 变纯白
正文深色像素(<200)p1 77760→77760、p2 138577→138577、p5 186145→186145,差 0
书签 / Producer保留

还有一个容易被忽略的对照实验:空转重存——用 pdf-lib 只做 load + save、不改任何内容,然后比对像素。结果是 0 像素差异。这证明了那 955 个像素变化确实来自水印移除,而不是重新序列化带来的噪声。

没有这个对照,就分不清「差异」和「噪声」。

headless 验证:验证交付的引擎,而不是算法

这是整个项目里最有分享价值的一段。

调研阶段写过一个 Node 版的 spike harness,用来验证算法可行。但它是 Python 代码的直译——它证明的是算法对,不是交付的那个 TypeScript 引擎对。

这两件事差别很大。交付的引擎跑在 Electron Renderer 里,依赖 pdfjs-dist 的 worker、依赖 DOM 的 Canvas、依赖 Vite 的 ?url 导入。这些在 Node 里全都不存在。

于是用 esbuild 把真正交付的引擎入口打包,然后在 Node 里跑。踩了三个坑,每个都会让验证失败但报错信息看起来毫无关联:

坑一:pdfjs-dist 必须 external。

直接 bundle pdfjs-dist 会破坏它探测可选依赖 @napi-rs/canvas 的逻辑,运行时抛 DOMMatrix is not defined。必须用 esbuild 插件把它标记为 external,并把导入路径改写成绝对路径 client/node_modules/pdfjs-dist/legacy/build/pdf.mjs。

坑二:?url 导入必须 stub。

引擎里有一行 import pdfjsWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'——这是 Vite 特有的语法,Node 不认。stub 成空字符串会得到 No "GlobalWorkerOptions.workerSrc" specified;必须 stub 成那个 worker 文件的 file:// 绝对 URL。

坑三:import.meta.url 在 CJS 互操作里要补。

banner 注入 createRequire(import.meta.url),否则打包产物里任何走 require 的路径都会崩。

坑四(算半个):关掉渲染比对。

depthVerify: false → pixelCheck 为 false → 不触发 canvas 渲染。Node 里没有 canvas,开了必崩。

跑通之后的结果:三个真实样本与直译 harness 逐项一致(29 流 / 116 操作数 / 1856→0;100 流 / 1500 操作数 / 32300→0 且水平 119620 不变;样本三中和 1 个表单且双信号命中)。

更重要的是负向对照也过了:

一个只能证明「能删水印」的工具是不可信的。能证明「不该删的时候不删」才是。

待实现功能

当前版本还有部分类型的文件不能实现去水印,暂时测试样本不足尚未覆盖,待后续实现。主要有以下问题:

签名保护。 pdf-lib 的重新序列化会让已有数字签名失效。现阶段会主动检测签名(/Perms 的 DocMDP、AcroForm 的 SigFlags & 1、页面注释里的 /Sig),检出后单独提示。后续待实现:在移除水印的同时保留或重新挂接文档签名。

// 未发现任何可移除的水印时不会生成新文件(changed: false、output: null),
// 避免无谓地重新序列化并破坏签名。

加密文件支持。 现阶段无法在不解密的前提下改写内容流,会直接拒绝并说明原因。后续待实现:识别加密类型、引导解除加密,或在可写回的加密场景下直接处理。

超大文件。 引擎跑在 Renderer 侧(原因见下一篇文章),超大 PDF 会受 UI 进程的内存上限约束。后续待实现:分块解析与流式写出,降低峰值内存。

更广的水印形态。 WPS / Word 导出的 ObjStm + 交叉引用流样本(手头样本主要是经典 xref 表,虽然用 pdf-lib 自己合成的 ObjStm 测试通过,但是不能保证所有的都行)、注释型水印、签名 PDF 的实测、策略 C(内联可选内容组)。

小结

一个工具的可信度,不只来自它当前能做什么,也来自它能不能证明自己做对了、以及有没有把待实现项写清楚。

具体到这次移植,三个可复用的结论:

  1. 探测与校验必须是两套独立视图。 用探测的方法校验移除,会得到系统性的假阴性——它看起来像 bug,其实是方法错了。
  2. 验证要针对交付物,不是针对算法。 直译版跑通不代表交付版跑通;用 esbuild 打包真实入口在 Node 里跑,是成本最低的端到端验证。

最后,从工程化的角度,增加整个功能没有引入任何新依赖。pdf-lib 和 pdfjs-dist 都是项目里已经有的——这大概是「选对边界」带来的最直接的好处。