前阵子接了个批量交付的活儿三百多份结业证书要按人加水印每份内容还不一样——姓名、证书编号、签发日期全部动态生成。我一开始想着用编辑器里的“批量水印”功能顶上结果光是整理名单、逐文件改名就花了两个多小时最后果断改成脚本方案。处理完加水印又接到内部同事的求助一批合同扫描件被误加了调试水印要去掉再归档。两轮折腾下来我算是把 PDF 加水印和去水印这两条路的原理、工具、坑位都摸了一遍。这篇文章就把这两套实践完整拆开讲从怎么选方案到怎么写脚本再到批量处理时那些容易翻车的工程细节一次说透。先说清楚一个前提加水印这件事本质不是为了“好看”而是为了溯源。你发出的每一份带不同标记的 PDF一旦流出去被截图、转发甚至改个名冒充原创你都能通过水印内容倒推出是哪条渠道、哪个接收者出的问题。所以我下面的所有方案也都顺着“每一份文件水印不同”这个诉求来设计而不是简单地往整批文件上盖同一个章。至于去水印这个能力更应该谨慎使用我只讲技术原理和实现思路仅限处理你自己拥有权限的文档别往盗版、侵权那条路上走。1. 先搞清楚水印的几种形态不然做出来的东西一删就没了1.1 水印不是一个东西是三类完全不同的实现我见过太多人把“加水印”理解成“用绘图软件在 PDF 上画一行字”结果文件转了一圈水印就没了或者导出的文件没法编辑。真相是PDF 里的水印可能以三种完全不同的形态存在你用什么方式加决定了别人用什么方式才能去掉也决定了你生成的 PDF 会不会一打印就失效。文本水印 / 动态文本水印直接在当前页面对象的内容流里写入文本绘制指令。这种水印的优势是文件体积小、文字清晰缺点是如果对方有 PDF 编辑软件定位到文字对象就能删掉。图片水印 / Logo 水印把一张透明的 PNG 或矢量图作为水印叠加到每一页。常见于企业文件的 Logo 水印或者那种半透明的“内部资料”印章。图片水印比文本水印更难被直接编辑删除但也更吃文件体积。页面装饰对象型水印很多专业软件在加水印时会把水印作为独立的 XObject 或注释对象挂到页面上而不是直接写进内容流。这种水印在解析器里看得到独立的“图层”删起来其实也不难关键要看有没有做对象保护。更深一层说水印还有一个“动态”的维度批量分发时每份文件的接收者、时间、编号都不相同水印内容必须跟着人走。这就要求水印方案本身支持参数化生成而不是在 GUI 里一个个手动改。1.2 为什么我推荐“水印层合并”而不是直接画在页面上传统编辑器加文本水印时是把文字直接画到当前页面的内容流里。这么做的问题在于如果想换字体、换透明度、换水印角度就得重新生成整份 PDF而且不同阅读器对内容流里字体资源的解析经常不一致——同一个水印在 WPS 里看着正常用 Chrome 打开可能直接不渲染。我推荐的方案是先生成一个独立的、透明水印页面作为“水印层”再用合并工具把这个层叠加到目标 PDF 的每一页上。这个方案有三个实打实的好处水印样式和文档内容解耦水印层用专业的 PDF 生成库如 ReportLab单独绘制支持精确的坐标、旋转、透明度和字体控制。改水印只需要重新生成图层不用动原文件。合并过程只做页面对象操作PikePDF / PyPDF2 这类库的体积很小合并时不会去重编码整份 PDF速度快文件质量不劣化。反删除能力更强因为水印是作为新对象合入内容流的普通文本编辑器抠字很难完整抠掉常见做法只能整块覆盖这个下面去水印章节会细聊。我到现在处理过的量级里对几百份证书、上千页合同用这个思路都扛得住不会因为水印层导致内存爆掉。2. 手写脚本批量加水印完整可复用的这套方案2.1 选型为什么我最终留下了这 4 个库市面上能处理 PDF 的库很多但就“批量加水印”这个场景值得认真对比的也就这几个库语言适合场景注意点ReportLabPython生成水印层、报表、证书创建 PDF 很强但不擅长修改已有 PDFPikePDFPython合并水印层、页面操作、轻量处理底层是 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, alphaalpha)) # 纯黑 透明度 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_widthwidth, page_heightheight) 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_okTrue) with open(csv_path, encodingutf-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 工具处理时容易忽略的几个细节用工具加水印最容易出现三个问题页码范围没设置好封面和封底通常不需要水印很多添加水印的界面默认“所有页面”结果封面上一大条半透明文字观感很糟。装订页、留白页也可以排除。字体和渲染差异工具生成的水印通常把文字转成轮廓避免字体缺失但也导致文件体积增大。如果文件要打印务必检查打印预览里的水印浓度是否正常——屏幕上看很合适打印出来可能特别深。原始文件被覆盖部分软件默认“保存并替换原文件”一旦水印设置错了还得重新找原始文档。我建议所有批量操作都先复制到一个新目录再在新目录上操作出错了还能重来。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), colorfitz.utils.getColor(white), fillfitz.utils.getColor(white), overlayTrue) 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 去水印的合规边界必须划清楚这一点我必须单拎出来说去水印的能力从来都是双刃剑。技术上再怎么熟练也别接那些来历不明的文件。我自己的原则是——只处理以下三类文件自己创建的文档误加了水印需要重新生成公司或团队内部明确有处理授权的文档从合法渠道获取、且版权方允许修改的资料。但凡涉及版权不明的 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(dpi80) 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_okTrue) with tempfile.TemporaryDirectory() as tmpdir, open(csv_path, encodingutf-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_widthfloat(page.mediabox.width), page_heightfloat(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 的第一页和最后一页通常是封面、版权页、附录这类页面的水印要么不加要么加得轻一些保证重点信息的可读性。去掉水印时如果用了覆盖法统一先另存一份副本。万一覆盖位置选错还能从副本重新来别一上来就覆盖原文件。这套模板我前前后后用了大半年处理过证书、合同、培训材料、扫描归档件数量和格式都经得住考验。你把它当起点去改结合你自己业务的名单来源、水印内容拼接规则和输出命名习惯很快能跑出一套顺手流程。还是那句话水印是手段不是目的想清楚你要防的到底是什么再决定怎么加水印、要不要去水印这样才能真正把工具用在点子上。