pdf如何去水印?

pdf如何去水印?

通过前一篇文章《分享一个PDF去水印的Skill,适用于Claude Code和OpenClaw》,我做了一款 PDF 去水印 Skill。那篇文章里讲过,水印通常藏在一种叫 XObject 的嵌套结构里——说白了就是套娃,我们的做法是递归拆开套娃,找到特征字体,把水印连根拔掉。当时我们以为这一步已经稳了,直到某一天,一个新文档被扔进了 input/ 目录。跑完脚本,打开这个"没有水印"的 PDF,水印就在那里,斜斜地、平铺在每一页上,从头到尾没认过输。

这就尴尬了:为什么扫描逻辑找不到它?水印到底藏在了哪里?我们遇到的,究竟是检测代码的 Bug,还是"找不到"这件事本身,另有隐情?

为什么"没有水印"的报告,水印却清清楚楚地存在?

要说清楚这件事,得先回忆一下我们当时是怎么找水印的。PDF 文档的页面内容是一段"命令流",水印这类东西通常被打包成一个 Form XObject——可以理解成一个独立的小画布——页面再通过一条 q ... cm /Name Do Q 命令把它"盖"到自己上面。所以我们当时沿着 Do 命令回溯,递归扫描所有 XObject 的资源树,在里面找水印特征字体:找到了,就把画布的 Do 命令连根拔掉。这套逻辑应对我们第一批文档时非常好使,一个字都没伤到正文。

但是,PDF 规范从来没规定页面内容只能通过 XObject 画。完全合法的另一种做法是:直接把水印文字写进页面内容流本身,跟正文一样用 BT、Tf、Tj 命令一笔一笔画出来。这时候水印字体挂在页面级的 /Font 资源下,根本不在 XObject 资源树里——我们的扫描逻辑当然永远碰不到它。所以严格来说,程序日志输出 No watermark found ,是指"在 XObject 树里没有找到"。

怎么下手?先看指纹,再下刀

这次升级的核心,是把"识别类型"提前成了规定动作。新流程不再直接钻 XObject 树,而是先对页面做字体分布分析,让指纹说话:有没有一个"每页字符数恒定、且与正文不相干的子集字体"?有,就走文本型水印的移除分支;没有,才回到原来的 XObject 扫描。两个分支都不命中,才真正输出"没有水印"——现在这句话的含义才配得上它字面的样子。

文本型分支的移除逻辑,说起来只有三步。先扫内容流,找到特征字体的所有 Tf 命令位置;再沿着位置,找到覆盖全部水印文本块的最外层 `q ... Q` 段;接着把这整段文本从内容流里摘掉。

说到这里你大概会问:一个文本块一个文本块地删,不也行吗?动手之前,这个问题的答案很关键。因为这些文本块不是独行侠:旋转矩阵、颜色、透明度状态,都包在段首的 q 前缀里。只删中间的 BT...ET 块,会留下一副"换了三十度坐标系、改完颜色、设了透明度"的空壳状态,渲染器还得为它工作,后面跟着的命令可能在莫名其妙的地方踩到它。整段 q ... Q 一起删,等于连状态包装一起扔掉,干净利落,不留残余。要是碰到水印裸奔、没有 q ... Q 包裹的场景,程序也留了一手:退一步删除单个特征字体所在的 BT ... ET 块。

删正文的刀,怎么保证不伤手?

把文本当字符串切来切去,最怕的就是切坏了还没发现。所以我们给自己上了三道保险。删完立刻核验剩余内容流里的 q/Q 与 BT/ET 数量是否各自相等——水印段删掉的是整段配对结构,删除打破了任何平衡,断言当场爆炸,这一页宁可跳过也不产出残缺结果。编码上,内容流读写一律用 latin-1 配对 errors='ignore',这是处理任意 PDF 字节流的既有纪律,没它就会在奇怪的中文字符上翻车。正则上,字体资源名里是带 + 号的(比如 /F2+0),+ 在正则里是量词,不转义,"一行命令"就偷偷变成了"零个或多个字符",看起来很美的匹配可能什么都匹配不到。

下载链接

原本 Skill 的仓库已经更新好了:github.com/MikeWuPing/pdf-watermark-removal,SKILL.md 与 README.md 双双同步,直接替换旧版即可使用。顺带说一句,另一个项目 pdf2md(整套 PDF 转 Markdown 的流水线,https://github.com/MikeWuPing/pdf2md)里的去水印步骤和文档,也一并跟进了这套逻辑——那边只多了一个可以直接跑脚本的 remove_watermark.py。

用法和一般的Skill一样,代码仓库里面的readme.md里面也有介绍。

最后是个小技巧,可以让Claude Code用它批量去掉水印,如在Claude Code CLI里面输入:

这样就能批量吧input里面的PDF文件水印去掉了。最后,技术本身是中性的,大家还是得遵守保密规定,别把不该传的东西传出去。