年初整理合同归档,我对着整整三个文件夹的旧 PDF 犯过愁:一半是扫描件,页脚压着当年的内部水印;另一半是新版合同,甲方要求发出去之前统一加上“内部文件,禁止外传”的批注。折腾了差不多两个晚上,加水印、去水印、批量处理、保留可编辑文本这些事算是彻底摸透了。这篇就把这轮实践完整写出来,涵盖水印的分类原理、两种最常见的批量加水印玩法(文本水印和图片水印)、去水印的几条实操路径,以及我踩过的坑。无论你是文档管理、技术支持,还是经常跟 PDF 打交道的普通打工人,照着做基本都能解决问题。
1. 先搞清楚水印到底是什么,再去动手
很多人一上来就找工具,结果加出来的水印要么歪歪扭扭,要么去的时候把正文也抹了。原因就一个:没搞清楚水印在 PDF 里的存在形式。PDF 不是一张“纸”,它更像一个“指令集合”,每一页由若干绘制指令组成,水印只是这些指令里的一小撮。判断水印的形态,决定了后续用哪条路处理。
1.1 常见水印的三种形态
文本水印。这是最普遍的一种,Word、WPS 导出的 PDF 水印基本都是这种。它由文字对象构成,页面内容流里能找到对应的字符编码。在阅读器里可以直接选中、复制,识别为文字。这种水印最好去除,因为位置精确、内容可查,只要定位到文字块就能精准清除。
图片/位图水印。常见于扫描件,或者别人直接把水印“画”在图片上再转成 PDF。整页是一张大图,水印是图像的一部分。这种水印没法靠“搜索文字”删除,只能走像素处理路线,也就是把水印覆盖的区域用相邻像素“补”回来,相当于图像修复。
矢量曲线水印。表面看着是字,实际上是贝塞尔曲线组成的图形对象。这种情况不常见,但一旦遇到就很头疼,因为搜索功能找不到它,编辑删对象时也无法按文本筛选。遇到它,我一般直接按图像处理来搞。
1.2 PDF 页面的“指令层”结构
PDF 页面不是一张静态图片,它的内容保存在 Content Stream(内容流)里。说人话就是:页面先执行一堆绘制指令,比如“在某处用 12 号字写‘内部资料’”、“在左上角画一个矩形”,然后把所有叠加结果渲染到屏幕上。水印就是这些指令之一,可能是在正文之后绘制(覆盖在上面),也可能在正文之前(被挡住),甚至设置在较深的层级。
知道这点有什么用?用处很大。当你尝试去除水印时,本质上是“从指令流里删掉那部分指令”,或者“用新的指令覆盖掉旧指令的结果”。知道了这一点,就不会指望一个傻瓜工具解决所有情况。
1.3 添加与去除的本质逻辑
添加水印,就是在页面指令流里追加一组绘制指令,让水印叠在正文上层。去除水印,就是反向操作:要么定位指令并删除,要么把渲染结果里的水印像素恢复成无水印状态。这两种逻辑平时都有用,看场景选。
2. 批量加水印,从选型到落地
加过水印的人都知道,一张张加能累死人。批量加水印的正确姿势是用代码跑,而不是打开 WPS 一页页弄。我的主力方案是 Python + PyMuPDF,偶尔也会碰到 Java 后端伙伴用 iText7,各有各的适用场景,下面逐个展开。
2.1 工具选型对比:别只盯着桌面软件
| 工具 | 语言/平台 | 批量能力 | 中文字体 | 适用场景 |
|---|---|---|---|---|
| PyMuPDF | Python | 强 | 需指定中文字体文件 | 脚本批量处理,灵活、免费 |
| iText7 | Java / C# | 强 | 需配置中文字体资源 | 企业级系统集成、服务端生成 |
| kkfileview | Java Web 组件 | 在线动态叠加 | 支持 | 在线预览时防截图泄露,并不修改文件 |
| WPS / Adobe Acrobat | 桌面 | 弱 | 支持 | 少量文档手动操作 |
如果你只是处理几十个文件,PyMuPDF 最合适。不需要安装 PDF 阅读器,不需要手动操作,一条命令行跑完所有文件,还能顺便转格式、压缩文件大小。如果是公司内部系统需要自动生成带水印的 PDF,那选 iText7 更靠谱,尤其是后端服务上用 C# 或 Java,可以和业务逻辑无缝集成。
2.2 Python + PyMuPDF 批量加文本水印
先装好依赖:
pip install PyMuPDF下面是批量添加文本水印的核心脚本。我刻意把参数都写在函数里,方便你按需调节。这个版本会打开 PDF,遍历所有页面,在页面上绘制一条居中的文字水印,支持旋转角度、透明度和颜色设置。
import fitz # PyMuPDF def add_text_watermark( input_pdf: str, output_pdf: str, text: str = "内部资料", fontfile: str = r"C:\Windows\Fonts\msyh.ttc", fontsize: float = 44, opacity: float = 0.35, angle: int = 45, ): doc = fitz.open(input_pdf) for page in doc: page_w = page.rect.width page_h = page.rect.height page.insert_textbox( fitz.Rect(0, 0, page_w, page_h), text, fontsize=fontsize, fontname="F0", fontfile=fontfile, rotate=angle, color=(0.65, 0.65, 0.65), fill_opacity=opacity, stroke_opacity=opacity, overlay=True, align=1, # 水平居中 ) doc.save(output_pdf, garbage=4, deflate=True) if __name__ == "__main__": add_text_watermark("input.pdf", "watermarked.pdf", text="内部资料")几个关键点必须说清楚:
- fontname 和 fontfile 要搭配使用。PyMuPDF 自带的 Base-14 字体不包含中文字符集,如果直接填
fontname="hebo"或fontname="china-s"也可能在某些阅读器里显示异常。最稳妥的办法是显式指定一个中文 TTF 字体文件,比如微软雅黑(msyh.ttc)或思源黑体。“F0” 只是内部资源名,你可以随意命名,但要和 fontfile 参数对应上。 insert_textbox的返回值有变化。旧版返回未用掉的块内剩余高度,新版返回剩余行数。判断整页水印是否都画得下,多留意下返回值就行,一般不会影响功能。- rotate 参数单位是度,不是弧度。默认 0 是水平方向,45 就是常见斜 45 度水印。注意旋转中心是文本框中心,所以即使你的文本只有一个词,也会以文本块中心为轴旋转。
2.3 让水印平铺整页,而不是只出现一处
insert_textbox适合“在页面中间放一个水印”的场景,但很多业务要求水印铺满全页,防截图效果好。这时需要自己写循环,在不同位置重复插入文字。注意控制间距,否则生成的 PDF 会变得很大,打开还卡。
import fitz def add_repeat_text_watermark( input_pdf: str, output_pdf: str, text: str, fontfile: str, fontsize: float = 40, opacity: float = 0.3, step_ratio: float = 5.0, # 间距倍数,越大越稀疏 ): doc = fitz.open(input_pdf) for page in doc: pr = page.rect step = fontsize * step_ratio # 在超出页面范围外开始和结束,避免边缘漏白 for y in range(-int(pr.height), int(pr.height * 2), int(step)): for x in range(-int(pr.width), int(pr.width * 2), int(step * 1.2)): page.insert_text( fitz.Point(x, y), text, fontsize=fontsize, fontname="F0", fontfile=fontfile, color=(0.65, 0.65, 0.65), opacity=opacity, rotate=45, overlay=True, ) doc.save(output_pdf, garbage=4, deflate=True)这里用insert_text而不是insert_textbox,因为位置坐标控制更直接。step_ratio控制水印密度,我实测下来 5 左右比较合适,既不显得拥挤,也不至于太稀。数值越小密度越大,文件体积也会明显增加。
2.4 图片水印:把 Logo 批量盖到每个角落
除了文字,给 PDF 加图片水印也很常见,比如公司 Logo、授权证书。原理和文本水印一样,只是把“画字”换成“贴图”。
import fitz def add_image_watermark( input_pdf: str, output_pdf: str, img_path: str, position: str = "bottom_right", margin: float = 20, width: float = 140, opacity: float = 0.6, ): doc = fitz.open(input_pdf) for page in doc: pr = page.rect h = width * 0.3 # 根据你的 Logo 宽高比手动调,这里按 10:3 估算 if position == "bottom_right": rect = fitz.Rect( pr.width - width - margin, pr.height - h - margin, pr.width - margin, pr.height - margin, ) elif position == "top_left": rect = fitz.Rect(margin, margin, margin + width, margin + h) else: rect = fitz.Rect( (pr.width - width) / 2, (pr.height - h) / 2, (pr.width + width) / 2, (pr.height + h) / 2, ) page.insert_image(rect, filename=img_path, overlay=True, keep_proportion=True, opacity=opacity) doc.save(output_pdf, garbage=4, deflate=True)实测经验:insert_image的opacity参数在老版本 PyMuPDF 里可能不生效,如果你用的版本低于 1.18,建议先把图片透明度处理好再传入,或者升级版本。另外图片水印会让 PDF 体积明显变大,建议用压缩过的 PNG,不要直接用没处理的截图。
2.5 在线预览场景的 kkfileview 动态水印
如果你的需求是“别人在网页上看文件,但截图出去会带水印”,那落盘水印方案就不合适了,因为这个场景下文件本身不能变脏。这时可以用 kkfileview 这类在线预览组件,在预览页面渲染水印层,用户看到的水印只是显示效果,原始 PDF 文件干净无损。
kkfileview 常见于 OA 系统、合同审批流、知识库预览模块。部署后,用户打开 PDF 时,客户端预览组件会动态绘制水印,包括当前用户名、工号、时间戳等,截图外传时能追溯到人。这里要注意,它并不会修改 PDF 文件内容,所以如果需求是“导出的文件也要带水印”,那就回到前面 PyMuPDF 的落盘方案,两条路互相补充,别选错。
3. 批量去水印的几条路,我全试了一遍
去水印比加水印麻烦得多,网上很多工具号称“一键去水印”,实际上要么是覆盖式白块,要么是像素模糊,效果很随机。我做过的去水印场景大致有三类,分别对应不同解法,按成功率从高到低排。
3.1 动手之前,先判断水印类型
判断方法很简单:用 PDF 阅读器打开,通过“选择文本”工具点击水印位置,如果能选中文字、能复制出来,说明它是文本水印。如果完全选不中,那就要看看整页是不是一张图片,如果是图片,只能按图像修复来处理。还有一类是软件界面截图或用虚拟打印机输出时水印被“焊死”在页面上,那也必须走像素路线。
提示:如果是公司电脑屏幕监控水印,属于企业安全防护措施,不是文档内容,个人不应尝试去除,遇到相关需求请走公司 IT 管理流程。软件试用版界面上的 demo 水印(比如 Origin 软件界面的 demo 字样),属于授权提示,正确做法是申请授权,而不是从技术层面绕过。
3.2 方法一:文本水印精准清除
文本水印最适合精准清除。在 PyMuPDF 里用page.search_for(text)可以拿到文字在页面上的矩形坐标,然后用白色矩形把这段区域覆盖掉,或者直接把对应的内容流对象删掉。对于白色背景的文档,覆盖法简单粗暴,效果好。
import fitz def remove_text_watermark(input_pdf: str, output_pdf: str, watermark_text: str): doc = fitz.open(input_pdf) for page in doc: rects = page.search_for(watermark_text) for rect in rects: # 用白色填充覆盖,width=0 表示无描边 page.draw_rect(rect, color=(1, 1, 1), fill=(1, 1, 1), width=0) doc.save(output_pdf, garbage=4, deflate=True) if __name__ == "__main__": remove_text_watermark("watermarked.pdf", "cleaned.pdf", "内部资料")这个脚本有个局限:水印文字被旋转、被拆分、或字体颜色复杂时,search_for可能匹配不到。如果匹配不到,就用下面的“内容流重写”思路:
import fitz def remove_text_watermark_by_stream(input_pdf: str, output_pdf: str, watermark_text: str): doc = fitz.open(input_pdf) for page in doc: for content in page.get_contents(): stream = doc.xref_stream(content) if stream: new_stream = stream # 这段是示意,实际可能存在 Tj/TJ 等多种写法,需要细心处理 # 安全起见建议对比 get_text("rawdict") 的结果,定位水印字符位置再删 pass doc.save(output_pdf, garbage=4, deflate=True)真正重写内容流比较复杂,因为 PDF 里文本可能被拆分成多个片段,每个片段还有字体、缩放、位移等状态参数。如果是正式项目,建议先把页面转成rawdict结构,遍历文本片段定位水印字符,再回到内容流做精确裁剪。单次处理几份文件,用 search_for 的覆盖法就够了。
3.3 方法二:位图水印用 OpenCV 图像修复
遇到扫描件或整页图片型 PDF,水印是像素的一部分,无法“删除对象”。我的做法是把页面渲染成高分辨率图片,用 OpenCV 的 inpaint 功能修复水印覆盖区域,最后把处理后的图片再拼成新 PDF。这个流程会丢失文字层,但视觉结果通常不错。
import fitz import cv2 import numpy as np def dewatermark_pdf(input_pdf: str, output_pdf: str, dpi: int = 150, threshold: int = 230, dilate_iter: int = 2): doc = fitz.open(input_pdf) new_doc = fitz.open() for page in doc: pix = page.get_pixmap(dpi=dpi) img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n) bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) # 半透明浅色水印在灰图上通常接近白色,阈值提取 mask _, mask = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY) kernel = np.ones((3, 3), np.uint8) mask = cv2.dilate(mask, kernel, iterations=dilate_iter) # 用周围像素修复水印区域 result = cv2.inpaint(bgr, mask, 3, cv2.INPAINT_TELEA) result_rgb = cv2.cvtColor(result, cv2.COLOR_BGR2RGB) # 编码成 PNG,塞进新 PDF 页面 ok, buf = cv2.imencode(".png", cv2.cvtColor(result_rgb, cv2.COLOR_RGB2BGR)) if not ok: continue rect = page.rect new_page = new_doc.new_page(width=rect.width, height=rect.height) new_page.insert_image(rect, stream=buf.tobytes()) new_doc.save(output_pdf, garbage=4, deflate=True) if __name__ == "__main__": dewatermark_pdf("scanned_watermarked.pdf", "cleaned_scanned.pdf", dpi=200)这个方法有几个调参关键点:
- threshold控制哪些像素被当成水印候选。230 表示“亮度超过 230”的像素进入 mask,适合水印颜色较浅、只在浅色背景上出现的情况。如果水印较深,调低 threshold(比如 200),但小心把正文浅色文字也带进 mask。
- dilate_iter控制水印区域的扩展范围。水印边缘常有抗锯齿,dilate 几次可以把边缘残影也一起覆盖,修复效果更干净。但别太大,否则会把正文边缘也抹掉。
- dpi太低会导致修复细节粗糙,太高会拖慢处理速度。我一般 150 起步,关键文档用 200,再高收益不大。
这个方案适合“水印是半透明色带、浅色文字、居中大面积”的场景。如果水印颜色很深、面积过大,inpaint 推断不出原始内容,效果会很惨,那就需要配合人工选取 ROI 或手动打码。
3.4 方法三:固定角落水印直接裁剪或打码
如果水印只在固定角落,比如每页右下角一行小字,最简单高效的办法是直接在白背景上覆盖掉,或者在导出时只保留中间有效区域。覆盖法上面已经给了代码,裁剪法又分两种:
- 渲染后裁剪:
page.get_pixmap(clip=fitz.Rect(...)),只把中间区域渲染成图,再生成新 PDF。 - 页面重排:用 PyMuPDF 建一个新页面,把原页面内容通过 Transform 放大或偏移,让水印落在页面外。
裁剪法适合“水印位置固定且边缘页面空白足够大”的文档,比如公司内部机要文件的页眉页脚。它最大的优势是不用处理图像修复,完全避开内容被破坏的风险。缺点也很明显:如果水印在正文区域内,裁剪会丢失正文。
3.5 扫描件去水印前,先处理好图像质量
扫描件往往存在歪斜、偏色、亮度不均,直接去水印容易误伤。如果你的水印在扫描件上,我的建议是先去图像预处理:先用 OpenCV 找文本行的倾斜角做纠偏(minAreaRect或霍夫变换),再做亮度和对比度均衡,最后才去水印。网上常说的“pdf歪斜校正纠偏”“漂白加深清晰”本质上都是这批预处理操作。顺序不要反,先修正几何,再处理像素,否则 mask 定位会整片偏移。这一步做完,去水印的成功率能提升一截。
4. 常见问题与避坑指南
我整理了几个高频问题,基本覆盖了加水印和去水印时的典型坑。
| 问题 | 原因 | 解决建议 |
|---|---|---|
| 加水印后 PDF 文件变大 | 水印重复文本对象过多或未压缩 | 保存时加garbage=4, deflate=True;调大水印间距 |
| 中文水印显示成乱码 | 未指定中文字体或字体文件路径不对 | 显式传入fontfile,指向系统安装的中文字体 |
| 水印在部分阅读器不显示 | 透明度设置或渲染兼容性问题 | 提高opacity到 0.5 以上,颜色用深灰 |
| 去水印时搜不到文字 | 水印是曲线矢量,或文字被拆散 | 改用图像修复方案 |
| 图像去水印后背景发灰 | inpaint 推断区域过大,或阈值过高 | 降低threshold,缩小 mask;可先做背景均衡 |
| 去水印后文字层消失 | 渲染成图片后重排 PDF | 接受这个结果,或用覆盖法保留文字层 |
补充几个实操心得:
- 加水印后立刻用阅读器打开检查,别只看脚本输出。透明度、旋转、字体在部分阅读器里和 PyMuPDF 的渲染结果不完全一致,一定要实测。
- 去文本水印时,如果页面背景不是纯白,直接用白色覆盖会留下白斑。最好先用
page.get_pixmap()提取页面背景色,或者用get_drawings()查看背景矩形,替换成对应颜色。 - 批量处理大量文件时,建议先拿 3 到 5 个样本跑一版输出,确认效果再全量跑。我第一版就吃过亏,用
threshold=230一次性跑 200 多个文件,结果三分之一页面把正文淡色文字也抹了,只好返工。 - 对于既有文本水印又有图片水印的混合型 PDF,分两步处理:先走文本覆盖,再走图像修复。顺序反了会很难受,因为渲染成图片后文本水印和内容融为一体,损失更大。
去水印如果需要保留文字层,还有一个偏门但有效的方案:先用pdf2image把页面渲染成图片,完成修复后再用 OCR 生成一个带隐藏文字层的新 PDF。这样做文件会大不少,但视觉和可搜索性都保留了。具体选不选,看你的场景。
5. 结个尾,说点实在话
这轮实践下来,我最大的感受是:加水印的关键是会选工具、会写循环;去水印的关键是先判型、再动手。别一听“一键去水印”就激动,真正靠谱的路径无非就是上面几条。如果你只是偶尔处理几十份,用 PyMuPDF 脚本足够;如果你部署的是在线预览系统,kkfileview 那套动态水印也够用。先把判断做对,再谈操作,比什么工具都管用。最后再啰嗦一句:动手之前确认自己有权修改这份文件,会帮你免掉很多不必要的麻烦。