简介:pdf-unstamper 是一款基于 Java 与 PDFBox 开发的命令行文本水印清除工具,面向需要处理 PDF 水印的开发者、文档编辑人员及技术爱好者。它支持删除任意字体、任意编码、任意语言的文本水印,可通过关键词匹配批量处理单个文件或整个目录,并提供直接修改原文件或另存输出的灵活选项,适合日常文档整理与二次开发场景。资源包共 24 个文件,约 115KB,其中 8 个 Java 源文件构成核心处理逻辑,8 张 PNG 图片展示处理前后对比效果,另有 README、LICENSE、pom.xml 及 Maven 配置等辅助文件,便于快速编译与理解项目结构。目前已有 186 人学习下载。借助该工具,读者可掌握基于 PDFBox 定位并移除水印文本的实现思路,参考前后对比图验证效果,并结合命令行参数说明完成批量处理,同时了解 Maven 项目的组织方式与打包流程,为 PDF 文档处理提供可复用的脚本与排错参考。
1. 文本水印为什么比图片水印难删十倍
很多人第一次处理 PDF 水印,脑子里想的是「把那张半透明图片抠掉」。图片水印确实好办,定位到 XObject 直接删引用就行。但真正让人头疼的是文本水印——它不是画上去的,是「写」上去的。水印文字和正文一样,都是 PDF 内容流里的字符绘制指令,共享同一套字体资源、同一套编码表。你没法靠「找图片」把它挑出来。
pdf-unstamper这个方向要解决的就是这件事:删除任意字体、任意编码、任意语言的文本水印。注意这三个「任意」不是修辞。字体可能是嵌入子集,编码可能是 Identity-H 这种 CID 映射,语言可能是阿拉伯语、泰语这种连字和组合字符一堆的体系。水印还可能被旋转、倾斜、调透明度、拆成单字符分散绘制。常规的「按字符串匹配删除」在这种场景下基本翻车。
这篇面向的是需要批量清洗 PDF 的从业者:做文档归档的、做数据预处理的、做内容分发的。目标很明确——搞清楚文本水印在 PDF 内部长什么样,怎么把它和正文区分开,怎么删干净还不破坏正文排版。下面按「先看懂结构 → 再动手删 → 再避坑」的顺序推。
2. 拆开 PDF 内容流:文本水印到底藏在哪一层
要删水印,先得知道它在文件结构里的位置。PDF 不是一层,是好几层套娃。水印可能出现在其中任意一层,甚至多层同时出现。
2.1 从页面对象到内容流的四层结构
一个 PDF 页面从外到内的结构大致是:
| 层级 | 对象类型 | 水印可能出现的位置 |
|---|---|---|
| 页面树 | /Pages → /Page | 页面级 /Annots 注释水印 |
| 资源字典 | /Resources | /XObject 表单、/Font 字体 |
| 内容流 | /Contents | 直接绘制的文本指令 |
| 可选内容 | /OCProperties | OCG 图层控制的水印 |
最常见的是第三层:水印文字直接写在/Contents流里,和正文指令混在一起。第二层的/XObject里如果是个 Form XObject,里面也可能包着文本水印,这种叫「表单水印」,删的时候要连引用一起处理。第四层是 OCG(可选内容组),水印被放在一个可开关的图层里,这种其实最好删——把 OCG 关掉或删掉对应 BDC/EMC 块就行。
我一般排查的顺序是:先看/Annots有没有异常注释,再看/Resources里的 XObject,最后解压/Contents流看原始指令。多数文本水印在第三步现形。
2.2 文本绘制指令的解剖:Tj、TJ 和字体切换
解压后的内容流是一串操作符。文本水印的核心指令就几个:
# 用 qpdf 解压内容流,方便肉眼查看 qpdf --qdf --object-streams=disable input.pdf decompressed.pdf # 解压后内容流变成明文,可以直接 grep 文本操作符 grep -n "Tj\|TJ\|Tf\|BT\|ET" decompressed.pdf | head -50BT/ET是文本块的开始和结束,Tf切换字体和字号,Tj画一个字符串,TJ画一个带间距调整的字符串数组。水印通常表现为:一个独立的BT...ET块,里面Tf选了一个和正文不同的字体或超大字号,Tj画的就是水印文字。
关键判断依据是图形状态。水印为了不遮挡正文,往往带这些特征:
gs设置了低透明度(ExtGState 里/ca或/CA小于 1)cm做了旋转或缩放矩阵变换- 填充色是浅灰(
rg或g设了 0.8 左右的值) - 字号异常大,或者位置在页面正中/对角
# 用 pikepdf 读取内容流,定位可疑的文本块 import pikepdf from pikepdf import Pdf pdf = Pdf.open("input.pdf") page = pdf.pages[0] # 拿到内容流原始字节 content = page.Contents.read_bytes() # 按 BT/ET 切分文本块,逐个检查 blocks = content.split(b"BT") for i, blk in enumerate(blocks): if b"Tj" in blk or b"TJ" in blk: # 打印前 200 字节看字体和内容 print(i, blk[:200])这段代码的逻辑是:把内容流按BT切开,每个块里如果有Tj或TJ就是一段文本绘制。参数上read_bytes()拿到的是解压后的流数据,如果流还被 Flate 压缩着,得先解压。实际排查时我会把每个块的Tf字体名和Tj内容都打出来,人工扫一遍,水印块通常一眼就能认出来——字体名往往是F1这种通用名,内容是重复的字符串。
2.3 字体编码:为什么按字符串匹配会失效
这是文本水印最难的地方。PDF 里的文本不是直接存 Unicode,而是存字形索引(glyph index),通过字体的 CMap 映射回字符。同一个「机密」两个字,在不同字体子集里的字节码可能完全不同。
常见编码方式:
- Simple Font:单字节编码,
/Encoding指定 WinAnsi 或 MacRoman,字节基本能对上字符 - Type0 / CID Font:多字节编码,
/Encoding是 Identity-H,字节是字形 ID,必须查/ToUnicodeCMap 才能还原字符 - 自定义 CMap:字体子集里嵌了自定义映射,字节和字符的关系只有字体自己知道
所以「搜字符串删水印」只在 Simple Font 且编码标准时有效。一旦遇到 Identity-H 的 CID 字体,你搜「机密」的 UTF-8 字节根本匹配不到——文件里存的是字形 ID。
# 提取字体的 ToUnicode 映射,把字形 ID 还原成字符 import pikepdf from pikepdf import Pdf, Name pdf = Pdf.open("input.pdf") page = pdf.pages[0] fonts = page.Resources.Font for font_name, font in fonts.items(): # 检查是否有 ToUnicode CMap if "/ToUnicode" in font: cmap_stream = font.ToUnicode.read_bytes() print(f"字体 {font_name} 的 CMap:") print(cmap_stream[:500]) else: print(f"字体 {font_name} 无 ToUnicode,需查编码字典")拿到 CMap 后,就能建立「字形 ID → Unicode」的反查表。删水印时反过来用:先确定水印文字的 Unicode,查表得到字形 ID,再在内容流里匹配这些 ID 字节。参数上要注意,CMap 里的bfchar和bfrange两种映射格式都要解析,bfrange是区间映射,处理起来容易漏。
3. 动手删:从定位到重写内容流的完整流程
原理清楚了,接下来是实操。整个流程分四步:解压、定位、重写、回填。每一步都有坑,我按顺序讲。
3.1 用 pikepdf 解压并解析内容流
第一步是把内容流拿到手。PDF 的内容流通常是 Flate 压缩的,pikepdf 的read_bytes()会自动解压,但如果你用底层库就得自己处理。
import pikepdf from pikepdf import Pdf def get_content_streams(pdf_path): """返回每页的内容流字节列表""" pdf = Pdf.open(pdf_path) result = [] for page in pdf.pages: contents = page.Contents # Contents 可能是单个流,也可能是流数组 if isinstance(contents, pikepdf.Array): data = b"".join(c.read_bytes() for c in contents) else: data = contents.read_bytes() result.append(data) return pdf, result pdf, streams = get_content_streams("input.pdf") print(f"共 {len(streams)} 页,第一页内容流 {len(streams[0])} 字节")逻辑说明:page.Contents在 PDF 规范里可以是单个流对象,也可以是流的数组(多个流按顺序拼接)。很多库只处理单流情况,遇到数组就出错。这里做了判断,数组时把所有流拼起来。参数上read_bytes()返回的是解压后的原始字节,如果流本身没压缩就直接返回。
3.2 按图形状态特征筛出水印文本块
拿到内容流后,要把它切成独立的绘制块,逐个判断是不是水印。切分的依据是q/Q(图形状态保存/恢复)和BT/ET(文本块)。
import re def split_blocks(content: bytes): """按 q/Q 和 BT/ET 切分内容流为独立块""" # 先按 BT/ET 切文本块,保留非文本部分 tokens = re.split(rb'(BT|ET)', content) blocks = [] buf = b"" in_text = False for tok in tokens: if tok == b"BT": if buf.strip(): blocks.append(("graphics", buf)) buf = b"BT" in_text = True elif tok == b"ET": buf += b"ET" blocks.append(("text", buf)) buf = b"" in_text = False else: buf += tok if buf.strip(): blocks.append(("graphics", buf)) return blocks def is_watermark(block: bytes) -> bool: """根据图形状态特征判断是否水印""" score = 0 # 低透明度 if b"/GS" in block or b"gs" in block: score += 1 # 旋转矩阵(cm 带非零 b/c 分量) if re.search(rb'[\d.]+\s+[\d.]+\s+[\d.]+\s+[\d.]+\s+[\d.]+\s+[\d.]+\s+cm', block): score += 1 # 浅色填充 if re.search(rb'0\.[6-9]\d*\s+g', block): score += 1 # 超大字号 if re.search(rb'/\w+\s+([5-9]\d|\d{3,})\s+Tf', block): score += 1 return score >= 2 blocks = split_blocks(streams[0]) for kind, blk in blocks: if kind == "text" and is_watermark(blk): print("疑似水印块:", blk[:150])逻辑说明:split_blocks把内容流切成文本块和图形块两类。is_watermark用四个特征打分,命中两个以上就判为水印。参数上阈值score >= 2是经验值,太松会误删正文,太紧会漏。实际用的时候建议先把判定结果打出来人工确认,再批量跑。
提示:透明度特征不一定在文本块内部,
gs操作符可能在BT之前就设置了。所以更稳的做法是维护一个图形状态栈,跟踪当前的透明度、变换矩阵、填充色,文本块出现时用当前状态判断。
3.3 重写内容流:删除块并保持指令平衡
定位到水印块后,直接删掉它,但要保证剩下的指令仍然平衡——q和Q必须配对,BT和ET必须配对,否则 PDF 渲染会出错。
def remove_watermark_blocks(content: bytes) -> bytes: """删除水印文本块,保持 q/Q 平衡""" blocks = split_blocks(content) out = [] for kind, blk in blocks: if kind == "text" and is_watermark(blk): # 跳过水印块,但要补上它可能消耗的 q q_count = blk.count(b"q") - blk.count(b"Q") if q_count > 0: out.append(b"Q " * q_count) continue out.append(blk) return b" ".join(out) new_content = remove_watermark_blocks(streams[0]) # 写回 PDF pdf.pages[0].Contents = pdf.make_stream(new_content) pdf.save("output.pdf")逻辑说明:删除水印块时,如果这个块内部有未闭合的q,要补上对应的Q,否则图形状态栈会错乱,导致后续正文的变换矩阵、颜色全部错位。参数上make_stream会自动处理压缩,不用手动 Flate。写回后建议用渲染器(如 pdftoppm)转成图片肉眼比对,确认正文没被破坏。
3.4 处理表单水印和 OCG 图层水印
如果水印不在/Contents里,而是在 Form XObject 或 OCG 图层里,处理方式不同。
# 处理 Form XObject 里的水印 def clean_xobject_watermarks(pdf): for page in pdf.pages: resources = page.Resources if "/XObject" not in resources: continue xobjects = resources.XObject for name, xobj in list(xobjects.items()): if xobj.get("/Subtype") == "/Form": inner = xobj.read_bytes() if is_watermark(inner): # 从资源字典里删掉这个 XObject 引用 del xobjects[name] print(f"删除表单水印 {name}") # 处理 OCG 图层水印 def remove_ocg_watermark(pdf): if "/OCProperties" in pdf.Root: ocp = pdf.Root.OCProperties if "/OCGs" in ocp: for ocg in ocp.OCGs: name = ocg.get("/Name", "") if "watermark" in str(name).lower() or "水印" in str(name): # 从 OCGs 列表移除 ocp.OCGs.remove(ocg) print(f"删除 OCG 图层 {name}")逻辑说明:Form XObject 的水印要连资源字典里的引用一起删,只删内容流里的Do指令不够,因为 XObject 本身还在文件里占空间。OCG 图层水印删掉/OCGs里的条目后,对应的BDC/EMC块会失去引用,渲染时自动不显示。参数上xobj.get("/Subtype")判断对象类型,/Form才是表单,/Image是图片,别搞混。
4. 避坑指南:文本水印删除的五个血泪教训
这一章全是踩过的坑,每条按「现象 → 原因 → 解决」写。新手照着做能少走弯路,熟手可以对照检查自己的实现。
4.1 删完水印正文变乱码
现象:水印删掉了,但正文部分文字变成方块或乱码。
原因:水印和正文共用了同一个字体资源。你删水印块时如果顺手把字体引用也删了,正文的Tf就找不到字体,渲染器回退到默认字体,CID 编码的字形 ID 对不上就成乱码。
解决:只删文本绘制指令(Tj/TJ),绝对不要动/Resources里的/Font字典,除非确认该字体只被水印使用。判断方法:统计整个内容流里每个字体被Tf引用的次数,只被引用一次的才可能是水印专用字体。
4.2 水印删了但打印时又出现
现象:屏幕上水印没了,打印或转 PDF 时水印又冒出来。
原因:水印可能同时存在于多个层。你删了/Contents里的,但/Annots里还有个注释水印,或者/OCProperties里有个默认开启的图层。不同渲染器对层的处理优先级不同,屏幕预览和打印走的路径可能不一样。
解决:排查时把所有层都过一遍。/Annots里的注释看/Subtype是不是/Watermark或/Stamp;/OCProperties看/Default里哪些 OCG 是默认开启的。删干净后,用两种以上渲染器(如 pdftoppm 和 mutool)分别验证。
4.3 Identity-H 字体匹配不到水印文字
现象:明明看到水印是「内部资料」,但按字符串搜内容流搜不到。
原因:字体是 Identity-H 编码的 CID 字体,内容流里存的是字形 ID 不是 Unicode。你搜 UTF-8 字节当然搜不到。
解决:先解析字体的/ToUnicodeCMap,建立字形 ID 到 Unicode 的映射,然后反查「内部资料」四个字对应的字形 ID,用这些 ID 的字节序列去匹配。注意bfrange区间映射要展开,别只处理bfchar。
4.4 删除后文件体积暴涨
现象:删了水印,输出 PDF 反而比原来大好几倍。
原因:写回内容流时没有重新压缩,或者删掉的 XObject 虽然没了引用但对象还在文件里,pikepdf 保存时把未引用对象也写进去了。
解决:保存时加compress_streams=True和object_stream_mode=generate。对于未引用对象,用pdf.remove_unreferenced_resources()清理。如果还大,用 qpdf 做一次线性化压缩。
pdf.save("output.pdf", compress_streams=True, object_stream_mode=pikepdf.ObjectStreamMode.generate) # 再用 qpdf 二次压缩 import subprocess subprocess.run(["qpdf", "--linearize", "--compress-streams=y", "output.pdf", "output_final.pdf"])4.5 旋转水印删不干净留下残影
现象:水印文字删了,但页面上还有淡淡的斜线或色块。
原因:水印的旋转是通过cm矩阵实现的,但有些实现会把水印拆成「背景色块 + 文字」两部分。你只删了文字块,背景色块还在。或者水印用了 clipping path(W/W n),裁剪区域还在。
解决:删除水印块时,要连带它前面的q...cm...re...W n这一串图形状态设置一起删。判断方法是看BT之前最近的q到BT之间的指令,如果包含cm和W,整段一起删。
5. 进阶:用 ToUnicode 反查做通用水印识别
前面讲的都是「按特征筛」,但特征筛有局限——水印制作者只要把透明度调高、字号调正常,特征就不明显了。更通用的做法是从字符内容反查:不管水印怎么变换,它总得画出有意义的文字。把内容流里所有文本块的字符还原出来,再判断哪些是水印语义。
5.1 构建字形 ID 到 Unicode 的完整映射
核心是解析/ToUnicodeCMap。这个 CMap 是 PDF 里的一段 PostScript 风格文本,格式如下:
begincmap 1 beginbfchar <0003> <0020> endbfchar 2 beginbfrange <0004> <0006> <0041> <0010> <0012> <0061> endbfrange endcmapbfchar是单字符映射,bfrange是区间映射。解析时两种都要处理。
import re def parse_tounicode(cmap_bytes: bytes) -> dict: """解析 ToUnicode CMap,返回 {字形ID: Unicode字符}""" mapping = {} text = cmap_bytes.decode("latin-1") # 解析 bfchar for m in re.finditer(r'beginbfchar(.*?)endbfchar', text, re.S): for line in m.group(1).strip().splitlines(): parts = line.split() if len(parts) == 2: gid = int(parts[0].strip('<>'), 16) uni = int(parts[1].strip('<>'), 16) mapping[gid] = chr(uni) # 解析 bfrange for m in re.finditer(r'beginbfrange(.*?)endbfrange', text, re.S): for line in m.group(1).strip().splitlines(): parts = line.split() if len(parts) == 3: start = int(parts[0].strip('<>'), 16) end = int(parts[1].strip('<>'), 16) dst = int(parts[2].strip('<>'), 16) for i, gid in enumerate(range(start, end + 1)): mapping[gid] = chr(dst + i) return mapping逻辑说明:bfchar每行两个十六进制数,前者是字形 ID,后者是 Unicode 码点。bfrange每行三个,前两个是字形 ID 区间,第三个是起始 Unicode,区间内依次递增。参数上latin-1解码是为了不丢字节,CMap 里可能有非 ASCII 字符。解析完得到完整映射表,就能把内容流里的字形 ID 还原成可读文字。
5.2 还原文本内容并做语义判定
有了映射表,就能把每个文本块的Tj/TJ参数还原成字符串,再做语义判断。
def decode_text_block(block: bytes, cmap: dict) -> str: """把文本块里的 Tj/TJ 参数还原成字符串""" result = [] # 匹配 Tj 的字符串参数 for m in re.finditer(rb'\((.*?)\)\s*Tj', block, re.S): raw = m.group(1) result.append(decode_glyphs(raw, cmap)) # 匹配 TJ 的数组参数 for m in re.finditer(rb'\[(.*?)\]\s*TJ', block, re.S): arr = m.group(1) for sm in re.finditer(rb'\((.*?)\)', arr, re.S): result.append(decode_glyphs(sm.group(1), cmap)) return "".join(result) def decode_glyphs(raw: bytes, cmap: dict) -> str: """按 2 字节字形 ID 解码(Identity-H 场景)""" chars = [] for i in range(0, len(raw) - 1, 2): gid = (raw[i] << 8) | raw[i + 1] chars.append(cmap.get(gid, "?")) return "".join(chars)逻辑说明:decode_text_block分别处理Tj(单字符串)和TJ(字符串数组)两种形式。decode_glyphs按 2 字节切分字形 ID,这是 Identity-H 的标准做法。参数上cmap.get(gid, "?")对未映射的字形返回问号,方便发现映射缺失。
还原出文字后,语义判定就简单了:水印文字通常是「机密」「内部」「样本」「请勿外传」这类词,或者重复的公司名、网址。维护一个关键词表,命中就判为水印。这比图形状态特征稳得多,因为水印制作者很难在不改变文字内容的前提下绕过语义判定。
5.3 一个可复用的判定函数
把特征判定和语义判定结合起来,给每个文本块打分:
| 判定维度 | 权重 | 说明 |
|---|---|---|
| 低透明度 | 2 | ExtGState 的 ca/CA < 0.5 |
| 旋转矩阵 | 2 | cm 的 b/c 分量非零 |
| 超大字号 | 1 | 字号 > 正文平均字号 2 倍 |
| 语义命中 | 3 | 文字匹配水印关键词表 |
| 位置居中 | 1 | 文本矩阵位置在页面中心区域 |
总分超过 5 判为水印。这个阈值可以调,误删正文的代价比漏删水印高,所以宁可保守一点。实际跑的时候,我会先把所有文本块的得分和还原文字打出来,人工扫一遍确认阈值合理,再批量处理。
这套方法我在几个批量归档的场景里用过,对 Identity-H 编码的中文、日文水印效果稳定。唯一要注意的是,有些 PDF 的 ToUnicode CMap 本身就不完整(生成工具偷懒),这时候字形 ID 还原会出问号,得回退到图形状态特征判定。两种方法互补,别只依赖一种。
希望帮到你。
本文还有配套的精品资源,点击获取