拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python批量实现PDF水印添加与去除:从脚本到工程化实践

Python批量实现PDF水印添加与去除:从脚本到工程化实践

前阵子接了个批量交付的活儿:三百多份结业证书要按人加水印,每份内容还不一样——姓名、证书编号、签发日期全部动态生成。我一开始想着用编辑器里的“批量水印”功能顶上,结果光是整理名单、逐文件改名就花了两个多小时,最后果断改成脚本方案。处理完加水印,又接到内部同事的求助:一批合同扫描件被误加了调试水印,要去掉再归档。两轮折腾下来,我算是把 PDF 加水印和去水印这两条路的原理、工具、坑位都摸了一遍。这篇文章就把这两套实践完整拆开讲,从怎么选方案到怎么写脚本,再到批量处理时那些容易翻车的工程细节,一次说透。

先说清楚一个前提:加水印这件事,本质不是为了“好看”,而是为了溯源。你发出的每一份带不同标记的 PDF,一旦流出去被截图、转发甚至改个名冒充原创,你都能通过水印内容倒推出是哪条渠道、哪个接收者出的问题。所以我下面的所有方案,也都顺着“每一份文件水印不同”这个诉求来设计,而不是简单地往整批文件上盖同一个章。至于去水印,这个能力更应该谨慎使用,我只讲技术原理和实现思路,仅限处理你自己拥有权限的文档,别往盗版、侵权那条路上走。

1. 先搞清楚水印的几种形态,不然做出来的东西一删就没了

1.1 水印不是一个东西,是三类完全不同的实现

我见过太多人把“加水印”理解成“用绘图软件在 PDF 上画一行字”,结果文件转了一圈水印就没了,或者导出的文件没法编辑。真相是,PDF 里的水印可能以三种完全不同的形态存在,你用什么方式加,决定了别人用什么方式才能去掉,也决定了你生成的 PDF 会不会一打印就失效。

  • 文本水印 / 动态文本水印:直接在当前页面对象的内容流里写入文本绘制指令。这种水印的优势是文件体积小、文字清晰,缺点是如果对方有 PDF 编辑软件,定位到文字对象就能删掉。
  • 图片水印 / Logo 水印:把一张透明的 PNG 或矢量图作为水印叠加到每一页。常见于企业文件的 Logo 水印,或者那种半透明的“内部资料”印章。图片水印比文本水印更难被直接编辑删除,但也更吃文件体积。
  • 页面装饰对象型水印:很多专业软件在加水印时,会把水印作为独立的 XObject 或注释对象挂到页面上,而不是直接写进内容流。这种水印在解析器里看得到独立的“图层”,删起来其实也不难,关键要看有没有做对象保护。

更深一层说,水印还有一个“动态”的维度:批量分发时,每份文件的接收者、时间、编号都不相同,水印内容必须跟着人走。这就要求水印方案本身支持参数化生成,而不是在 GUI 里一个个手动改。

1.2 为什么我推荐“水印层合并”而不是直接画在页面上

传统编辑器加文本水印时,是把文字直接画到当前页面的内容流里。这么做的问题在于:如果想换字体、换透明度、换水印角度,就得重新生成整份 PDF,而且不同阅读器对内容流里字体资源的解析经常不一致——同一个水印,在 WPS 里看着正常,用 Chrome 打开可能直接不渲染。

我推荐的方案是:先生成一个独立的、透明水印页面作为“水印层”,再用合并工具把这个层叠加到目标 PDF 的每一页上。

这个方案有三个实打实的好处:

  1. 水印样式和文档内容解耦:水印层用专业的 PDF 生成库(如 ReportLab)单独绘制,支持精确的坐标、旋转、透明度和字体控制。改水印只需要重新生成图层,不用动原文件。
  2. 合并过程只做页面对象操作:PikePDF / PyPDF2 这类库的体积很小,合并时不会去重编码整份 PDF,速度快,文件质量不劣化。
  3. 反删除能力更强:因为水印是作为新对象合入内容流的,普通文本编辑器抠字很难完整抠掉,常见做法只能整块覆盖,这个下面去水印章节会细聊。

我到现在处理过的量级里,对几百份证书、上千页合同用这个思路都扛得住,不会因为水印层导致内存爆掉。

2. 手写脚本批量加水印:完整可复用的这套方案

2.1 选型:为什么我最终留下了这 4 个库

市面上能处理 PDF 的库很多,但就“批量加水印”这个场景,值得认真对比的也就这几个:

库语言适合场景注意点
ReportLabPython生成水印层、报表、证书创建 PDF 很强,但不擅长修改已有 PDF
PikePDFPython合并水印层、页面操作、轻量处理底层是 qpdf,流式解析,内存占用小
PyMuPDF (fitz)Python需要分析/渲染 PDF、处理内容流、去水印功能最全,但偏向文档分析,API 风格比较底层
iText7Java企业级处理、批量打印、复杂水印功能强,但注意 AGPL 协议问题

我个人的固定组合是ReportLab 生成水印层 + PikePDF 做合并。ReportLab 画文字、画图形非常精细,能控制透明度和旋转角;PikePDF 合并页面又快又稳。如果你团队里只有 Java 工程师,那 iText7 也能完成同样的事,别在两个语言里反复横跳就行。

说到这得提一句热词里出现的 kkfileview。它本身不是用来“改 PDF”的,而是文档在线预览组件,它加水印是在预览渲染阶段动态画上去的,不会改变磁盘上的原始文件。这个思路反而很适合做“防预览截图泄露”的场景,和本文讲的落盘式水印是两条不同的路,各有各的用处。

2.2 第一步:用 ReportLab 生成动态水印层

假设你手里有一批certificate_001.pdf到certificate_300.pdf,每份对应一个人名。你要做的第一步不是去改这些 PDF,而是为每个接收人生成一份独立的水印层,里面写上“张三 2024-06-18”之类的内容,并让它半透明、倾斜 45 度铺满整页。

from reportlab.pdfgen import canvas from reportlab.lib.colors import Color def create_watermark_layer(output_pdf: str, text: str, page_width: float = 595.27, # A4 宽(pt) page_height: float = 841.89, # A4 高(pt) font_size: int = 36, alpha: float = 0.15, angle: float = 45): c = canvas.Canvas(output_pdf, pagesize=(page_width, page_height)) c.saveState() c.setFillColor(Color(0, 0, 0, alpha=alpha)) # 纯黑 + 透明度 c.setFont("Helvetica-Bold", font_size) # 把画布中心移到页面中心,旋转后再画,这样水印就不会偏出页面 c.translate(page_width / 2, page_height / 2) c.rotate(angle) c.drawCentredString(0, 0, text) c.restoreState() c.save()

这段代码没什么高深的,核心就两个点:setFillColor里的 alpha 参数控制透明度,rotate控制角度。水印我通常设 0.12~0.2 透明度,既能看清又不影响正文阅读;角度设 40~45 度,比水平水印更难被简单裁掉,视觉上也更专业。

2.3 第二步:用 PikePDF 把水印层叠加到每一页

水印层生成后,下一步是把这一层铺到原 PDF 的每一页上。这里用 PikePDF 的page.add_overlay()方法,它会生成一个新的内容流对象,把水印层绘制在页面之上。

import pikepdf def apply_watermark(input_pdf: str, layer_pdf: str, output_pdf: str): with pikepdf.open(input_pdf) as pdf: with pikepdf.open(layer_pdf) as wm: layer = wm.pages[0] for page in pdf.pages: page.add_overlay(layer) # 叠加在页面内容之上 pdf.save(output_pdf)

注意,add_overlay是把水印叠在页面内容的上层。如果你需要水印在正文底下,用add_underlay()。这个区别在企业场景里很关键:盖在文字上方的水印被截图后依然可见,藏在下层的水印则容易被其他白色块遮住。做溯源水印我建议一律用add_overlay()。

另外,有些 PDF 的页面尺寸不全是 A4,尤其是扫描件和混合来源的合同。这时候直接叠加 A4 水印层会把坐标放错位置。更稳妥的做法是以目标页的尺寸为准动态生成水印层,而不是生成一个固定 A4 层到处套:

def apply_watermark_dynamic(input_pdf, output_dir, watermark_text_func): with pikepdf.open(input_pdf) as pdf: for idx, page in enumerate(pdf.pages): # 读取当前页尺寸,生成对应大小的水印层 width = float(page.mediabox.width) height = float(page.mediabox.height) layer_path = f"/tmp/wm_{idx}.pdf" create_watermark_layer(layer_path, watermark_text_func(idx), page_width=width, page_height=height) with pikepdf.open(layer_path) as wm: page.add_overlay(wm.pages[0]) pdf.save(f"{output_dir}/watermarked_{pdf.filename}")

2.4 批量生成时如何做到“一份文件一个水印”

现在到了真正体现“批量动态”的部分。通常我会在跑脚本前先准备一个名单文件:

文件名,接收人,签发日期 certificate_001.pdf,张三,2024-06-18 certificate_002.pdf,李四,2024-06-18

用 Python 读这个 CSV,逐行走上面的水印合并流程,输出文件统一放到output/目录。这样每份证书的水印内容都不一样,分发后被泄露,你立刻能锁定是谁的问题。

import csv from pathlib import Path def batch_watermark(csv_path: str, src_dir: str, out_dir: str): src_dir = Path(src_dir) out_dir = Path(out_dir) out_dir.mkdir(exist_ok=True) with open(csv_path, encoding="utf-8") as f: for row in csv.DictReader(f): src = src_dir / row["文件名"] out = out_dir / row["文件名"] text = f"{row['接收人']} {row['签发日期']}" layer = f"/tmp/wm_layer_{row['文件名']}.pdf" create_watermark_layer(layer, text) apply_watermark(str(src), layer, str(out))

这里有一个批量处理容易踩的坑:临时水印层文件用完没清理,几百份文件跑完,/tmp下堆了几百个垃圾 PDF。我在脚本里会用tempfile.TemporaryDirectory()包住整个循环,结束自动清理,省心很多。

3. 不想写代码时,工具流也能批量搞定

3.1 工具选择:Adobe Acrobat 和国产 PDF 软件

不是所有场景都值得写脚本。如果只需要给十个八个文件盖同一个“内部资料”水印,开个编辑器点几下就行,没必要引入 Python 环境。

  • Adobe Acrobat Pro:工具栏里有“动作向导”,可以新建一个“添加水印”动作,然后对文件夹批量运行。它能处理文本、图片、动态时间戳,也能设置页码范围。缺点是要钱,且需要手动维护动作。
  • WPS PDF:自带“批量添加水印”和“提取/删除水印”功能,界面直观,处理速度尚可。适合临时小批量。
  • 在线工具:iLovePDF、Smallpdf 之类,胜在便捷,劣势是文件要上传到别人的服务器,涉及合同、证书这类敏感文件时我不太敢用。如果只是去自己电脑上生成的临时演示文件,倒无所谓。

我的判断标准很简单:文件越敏感,越倾向本地脚本;需求越复杂(动态内容、按名单生成),越只能上脚本;只有“一次性、内容全相同、量不大”时,才用工具。

3.2 工具处理时容易忽略的几个细节

用工具加水印,最容易出现三个问题:

  1. 页码范围没设置好:封面和封底通常不需要水印,很多添加水印的界面默认“所有页面”,结果封面上一大条半透明文字,观感很糟。装订页、留白页也可以排除。
  2. 字体和渲染差异:工具生成的水印通常把文字转成轮廓,避免字体缺失,但也导致文件体积增大。如果文件要打印,务必检查打印预览里的水印浓度是否正常——屏幕上看很合适,打印出来可能特别深。
  3. 原始文件被覆盖:部分软件默认“保存并替换原文件”,一旦水印设置错了,还得重新找原始文档。我建议所有批量操作都先复制到一个新目录,再在新目录上操作,出错了还能重来。

3.3 kkfileview 的在线预览水印,可以作为上面方案的补充

接着说热词里的 kkfileview。它的水印机制很讨巧:预览服务端在把 PDF 渲染成图片流时,动态把水印叠上去,用户在线看到的是带水印的页面,但下载下来的原始 PDF 是干净的。这个机制特别适合那种“文档需要外发给客户预览、但不想客户直接拿到无水印源文件”的场景。

如果你的业务系统已经在用 kkfileview,那“加水印”这件事根本不该动 PDF 文件本身,配置一个 Preview 参数就行,速度和性能比批量写文件高一整个量级。这件事给我的启发是:加不加水印、加哪种水印,先想清楚威胁模型——防截图,用预览水印;防二次传播追溯,用落盘式动态水印;防打印复印,才需要考虑更复杂的物理防伪手段。

4. 去除水印:先定位水印类型,再决定从哪下手

去水印比加水印更依赖“分类”思维。拿到一份 PDF,你首先得判断水印属于哪一类,因为不同实现方式的删除策略完全不同。我总结下来,常见的水印基本逃不出下面这 4 种情况。

4.1 四种水印,四种不同的处理策略

类型特征删除策略难度
页面装饰对象型水印是独立 XObject 或注释对象,可以在对象树里看到用脚本删除该对象引用低
内容流文本型水印直接写在页面内容流的BT...ET文本块里解析内容流,删除对应文本块中
内容流图像型水印以图片 XObject 形式被页面引用删除 XObject 引用中
扫描件位图型水印和文档完全融合成图像像素图像处理覆盖/修复高

拿到文件后,我通常先用 PyMuPDF 快速判断:

import fitz doc = fitz.open("problem.pdf") page = doc[0] # 看页面对象结构:水印可能是独立 XObject print(page.get_xobjects()) # 看文本块里的文字特征 for block in page.get_text("dict")["blocks"]: for line in block.get("lines", []): for span in line["spans"]: if "内部资料" in span["text"] or "Internal" in span["text"]: bbox = span["bbox"] print("疑似水印文本:", span["text"], "位置:", bbox)

这一步很重要,它直接决定你后面的方案:少数水印是对象级的,删引用即可;多数水印是嵌在内容流里的,得走下面更稳妥的覆盖法或内容流清理法。

4.2 最稳妥的“覆盖法”:适合固定位置水印

所谓覆盖法,就是用白色(或当前页面背景色)的矩形把水印区域盖掉。技术上完全没有难度,且不破坏内容流结构。适合水印位置固定、不透明的场景。

import fitz def cover_watermark(input_pdf, output_pdf, bbox_list): doc = fitz.open(input_pdf) for page in doc: for rect in bbox_list: page.draw_rect(fitz.Rect(rect), color=fitz.utils.getColor("white"), fill=fitz.utils.getColor("white"), overlay=True) doc.save(output_pdf)

但覆盖法有个致命的适用条件:页面必须是白底。如果文档有彩色底纹、有背景图,盖一块白色上去反而更显眼。这种情况就得用更精确的内容流级处理,或者干脆用图像修复的方式来补背景。

4.3 更彻底的“内容流级处理”:删除水印文本

如果水印是文本对象,且你能准确认出它的特征,可以直接从页面内容流中删除对应文本。PyMuPDF 没有直接暴露“删除某块文本”的 API,但你可以用page.get_text("rawdict")定位字符,再把整个内容流拿出来,用正则/字符串处理删掉对应的BT ... ET块,最后写回。

import fitz import re def remove_watermark_text(input_pdf, output_pdf, watermark_text): doc = fitz.open(input_pdf) for page in doc: content = bytearray(page.read_contents()) # 读取原始内容流 # 这里只做演示:实际内容流可能被 FlateDecode 压缩,要先解压 if watermark_text.encode() in content: content = content.replace(watermark_text.encode(), b"") page.set_contents(bytes(content)) doc.save(output_pdf)

这段代码只适用于内容流未压缩的情况,但实际生产中的 PDF 内容流十有八九是压缩的。所以更可靠的方式是用 PyMuPDF 的xref接口,定位到内容流对象,用zlib.decompress解压后做替换,再压缩写回。流程不算复杂,但每一步都要小心,我踩过的坑是:替换完没重新生成对象编号,导致页面引用失效,整份 PDF 打不开。所以每次改完内容流,都必须做完整性校验。

4.4 扫描件水印:图像修复的思路可以参考

如果水印和内容已经在扫描图像里融为一体,任何“删对象”的做法都失效了。这时候的思路是把它当图像问题处理,而不是 PDF 问题。常见手段是定位出水印区域后,用图像修复算法(inpaint)把水印部分用周围纹理填充掉。热词里那句“pdf歪斜校正纠偏、漂白加深清晰”,其实就是在扫描件预处理时常用的操作:先对页面做轮廓检测、校正倾斜,再通过二值化或对比度调整把水印区域分离出来,最后针对分离出的区域做修复。

这类处理放到 OpenCV 里并不复杂:先用阈值分割提取可能的半透明水印区域,再用cv2.inpaint()修复。但坦白讲,对于水印恰好压在人脸、签名或表格线上的扫描件,修复效果是有限的,过度处理反而会把正文细节一起抹掉。我的经验是:扫描件水印,能接受“部分淡化”就尽量别追求“完全去除”,处理目标是让人辨识不出水印文字,而不是让文档像原封未动一样。

4.5 去水印的合规边界,必须划清楚

这一点我必须单拎出来说:去水印的能力从来都是双刃剑。技术上再怎么熟练,也别接那些来历不明的文件。我自己的原则是——只处理以下三类文件:

  1. 自己创建的文档,误加了水印需要重新生成;
  2. 公司或团队内部明确有处理授权的文档;
  3. 从合法渠道获取、且版权方允许修改的资料。

但凡涉及版权不明的 PDF、他人付费文档、内部监控截图之类的东西,一律不碰,也不教具体操作。这不是玄学,是保护你自己。

5. 批量工程化:性能、异常和产物校验才是真正的分水岭

能处理一份 PDF 不叫会,能稳定处理一千份且不丢文件、不损坏文件,才叫真正落地。我在这块吃过的亏不少,总结成三个模块。

5.1 性能:大文件别整读整存,能流式处理的就别全量渲染

加了水印的 PDF 文件体积会变大,但更重要的是处理过程中的峰值内存。动不动几百 MB 的图纸、上千页的合同,如果用 PyMuPDF 渲染整页再做合成,内存会直接飙到几个 GB。PikePDF 底层基于 qpdf,采用流式解析,对超大文件更友好。我的建议是:

  • 只做对象级操作:优先 PikePDF,内存占用小。
  • 需要渲染/分析内容:用 PyMuPDF,但处理完及时doc.close(),别在一个进程里长期持有多个文档对象。
  • 如果文件过多,按批处理,一批 50 份,处理完确认输出目录文件数无误后,清空临时状态再处理下一批。

5.2 异常处理:把“出错的文件”全部捞出来

批量脚本最容易翻车的地方不是逻辑,而是异常。我实际遇到的异常清单,随便一数就有:

  • PDF 加密,无密码打不开;
  • 文件损坏,PikePDF 打开时直接抛异常;
  • 文件名包含中文/特殊字符,跨平台路径解析出错;
  • 页面尺寸极端,如 0 宽 0 高,或者超大尺寸;
  • 子目录嵌套,文件查找漏掉。

正确的做法是:脚本里每个文件都包 try/except,捕获后把文件名和错误信息写进error.log,继续处理后面的文件。全部结束后人工看日志,再单独处理错误文件。千万不要让一个坏文件中断整批任务,否则后半夜起来看到脚本停了,心态直接爆炸。

5.3 产物校验:处理完一定要抽样预览

最后一步很多人会漏:水印加完之后,不去确认效果。一份文件加了水印但内容重叠、旋转坐标偏移,这种问题光靠代码测试是测不出来的,必须真实预览。我会在批量流程里加一个“抽样预览”环节:从输出目录随机挑 5~10 份文件,用 PyMuPDF 渲染第一页为 PNG,统一拼成长图人工扫一眼。

import fitz, random from pathlib import Path def sample_preview(out_dir: str, sample_count: int = 5): files = list(Path(out_dir).glob("*.pdf")) chosen = random.sample(files, min(sample_count, len(files))) for f in chosen: doc = fitz.open(f) pix = doc[0].get_pixmap(dpi=80) pix.save(f.with_suffix(".png")) doc.close()

看完抽样图再决定整批是否通过。这一步能筛掉绝大多数“坐标偏了”“文字糊了”“透明度失效”的问题,值得养成习惯。

6. 最终落地方案:一个配置化的批量水印工具随手能用

整套方法跑顺之后,我把加水印的流程收敛成了一个配置脚本,下次再遇到任何“批量加水印”需求,直接改配置就能跑。下面把这个模板贴出来,你可以直接改来用。

import csv import tempfile from pathlib import Path def run(csv_path: str, src_dir: str, out_dir: str): src_dir, out_dir = Path(src_dir), Path(out_dir) out_dir.mkdir(exist_ok=True) with tempfile.TemporaryDirectory() as tmpdir, open(csv_path, encoding="utf-8") as f: for row in csv.DictReader(f): src = src_dir / row["文件名"] out = out_dir / row["文件名"] if not src.exists(): print(f"[跳过] 源文件不存在: {src}") continue try: with pikepdf.open(src) as pdf: for page in pdf.pages: create_watermark_layer(f"{tmpdir}/wm.pdf", row["水印内容"], page_width=float(page.mediabox.width), page_height=float(page.mediabox.height)) with pikepdf.open(f"{tmpdir}/wm.pdf") as wm: page.add_overlay(wm.pages[0]) pdf.save(out) print(f"[完成] {row['文件名']}") except Exception as e: print(f"[失败] {row['文件名']}: {e}")

这里我把“水印内容”抽成了 CSV 里的一个字段,所以你可以根据接收人、时间、编号自由拼装水印文字。

再分享几个我在实际使用中反复验证过的细节:

  • 水印颜色不要用纯黑,纯黑在扫描/复印后痕迹太重,影响阅读。深灰(0.3, 0.3, 0.3)配 0.15 透明度是比较稳的经验值。
  • 水印字体要在 PDF 阅读器里普遍可用,用 Helvetica 或系统标准字体最稳,自定义字体要确认已嵌入,否则别人打开显示乱码。
  • 多页 PDF 的第一页和最后一页通常是封面、版权页、附录,这类页面的水印要么不加,要么加得轻一些,保证重点信息的可读性。
  • 去掉水印时如果用了覆盖法,统一先另存一份副本。万一覆盖位置选错,还能从副本重新来,别一上来就覆盖原文件。

这套模板我前前后后用了大半年,处理过证书、合同、培训材料、扫描归档件,数量和格式都经得住考验。你把它当起点去改,结合你自己业务的名单来源、水印内容拼接规则和输出命名习惯,很快能跑出一套顺手流程。还是那句话:水印是手段,不是目的,想清楚你要防的到底是什么,再决定怎么加水印、要不要去水印,这样才能真正把工具用在点子上。

返回列表