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

资讯详情

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

Python批量PDF加水印实战:reportlab+pikepdf全流程解析

Python批量PDF加水印实战:reportlab+pikepdf全流程解析 我们做技术的人手里多少都有过这种活儿几十份合同要盖公章页、上百页产品手册要打“内部资料”、一批设计图纸要加“审阅稿”标识。手动用编辑器一页页加又慢又容易漏关键是你还不能把原始文件弄坏。我之前处理过一次三百多页的标书每个 PDF 还得打不同的水印内容当时第一反应是用 WPS 或搜狗 PDF 编辑器手动加试了两份就放弃了——效率太低了。后来我写了个 Python 脚本用reportlab生成透明水印页再用pikepdf批量合并配合命令行参数几十份文件几分钟就处理完而且原始 PDF 就算加密也能兼容处理。这篇文章就把这套方案从设计到踩坑完整拆开讲适合两类人看一是正在给 PDF 加水印但不想一个个手动操作的朋友二是想在 Python 里处理文档批处理、顺手了解一下 PDF 底层页面合并原理的人。最终你拿到的是一份可以直接抄走改用的自动化脚本以及一堆文档里不会写的实战经验。1. 需求拆解与方案选型这两年 PDF 加水印的需求明显变多了而且场景越来越细。大家搜的“去水印”是一类但“加水印”是另一拨刚需。我在做之前先梳理了一下这个需求到底要解决什么问题为什么不用现成工具以及为什么最终选了 Python。1.1 这类需求的三种典型场景接触到的加水印需求主要分三类。第一类是企业内部的归档文件比如标书、合同扫描件、财务报表这些文件量大、页数多而且不同部门要打不同的字像“市场部归档”、“财务审核”、“仅供内部使用”搞个工具能批量处理就很省事。第二类是设计稿和图纸CAD 导出的 PDF、产品渲染图、UI 设计稿这种通常要平铺整个页面的水印而且要半透明不能挡住主要画面。第三类是电子文档分发给外部人员比如技术文档、测试报告需要加用户 ID、公司名、日期这些动态信息防止外传后追不到源头。搜索热词里还有“批量重命名”、“批量调优”这一类其实它们都和批处理的思路相通——核心在于程序化地遍历文件、按规则处理、再输出到指定目录。学完 PDF 水印这套改成批量重命名、批量给图片加文字、批量转换格式都只是换换处理函数的事。1.2 为什么选 Python 而不是现成编辑器我试过几款 PDF 编辑器比如搜狗 PDF 编辑器、Adobe Acrobat它们加水印功能其实挺全的能调透明度、旋转、平铺也能批量处理。但问题是批量处理的授权门槛不低而且水印内容和文件名挂钩时操作特别繁琐。比如“第三页以后才加水印”、“奇数页加‘草稿’、偶数页加‘内部’”这种规则编辑器里设置起来能让人崩溃。Python 在批处理上天然就是对的工具。它有完善的 PDF 处理生态底层能控制到每一页的图形对象而且可以和文件名、Excel 表、数据库联动生成动态水印。更重要的是脚本可以重复使用下次再遇到类似需求改改参数就能跑。所以我的结论是一次性偶尔处理用编辑器就行但凡你预估以后还会遇到第三次就值得写脚本。1.3 主流 Python PDF 库怎么选Python 处理 PDF 的库大致分三类我分别对比了一下。第一类是PyPDF2/pypdf这俩是纯 Python 实现优点是安装简单、跨平台适合做页面提取、合并、旋转这类基础操作。但它不支持生成复杂图形和文字排版你要画一个带旋转角度的平铺水印它做不了得配合其他库。第二类是reportlab它本身就是做 PDF 生成的可以精确控制文字、图形、颜色、透明度、坐标是最适合用来“画”水印页的库。它的短板是读取和合并已有 PDF 的能力不行你得再配一个合并库。第三类是pikepdf基于 C 的 QPDF 封装性能非常好处理几百 MB 的大文件也不虚。它支持加密 PDF有密码的话可以传密码、保留书签、压缩优化。合并页面时它直接用底层对象拷贝速度比 PyPDF2 快很多。最终我的方案是reportlab pikepdf组合reportlab 负责生成水印图层pikepdf 负责把水印图层合并进原始 PDF。如果你更习惯 PyPDF2 的 API也可以只把合并部分换成 PyPDF2但性能上 pikepdf 明显更稳。2. 水印 PDF 的制作与参数设计水印的核心是“不能喧宾夺主”。我见过有人做完水印文字又黑又实直接把正文盖住了那基本等于毁文件。所以水印的设计要从透明度、字号、位置、旋转角度多个维度一起考虑。2.1 用 reportlab 生成一个可复用的水印页reportlab 的基本工作流是先创建画布设定页面尺寸然后在指定坐标画文字或图形最后保存成单页 PDF。我这里用一个函数来生成水印层方便批量调用from reportlab.pdfgen import canvas from reportlab.lib.units import cm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont def create_watermark_pdf(output_path, text, page_size(595, 842), font_pathNone, font_size40, angle45, opacity0.15, rows6, cols3): pdfmetrics.registerFont(TTFont(WatermarkFont, font_path)) c canvas.Canvas(output_path, pagesizepage_size) c.setFont(WatermarkFont, font_size) c.setFillAlpha(opacity) c.setFillColorRGB(0, 0, 0) c.saveState() c.translate(page_size[0] / 2, page_size[1] / 2) c.rotate(angle) for i in range(rows): for j in range(cols): x (j - (cols - 1) / 2) * 220 y (i - (rows - 1) / 2) * 140 c.drawCentredString(x, y, text) c.restoreState() c.showPage() c.save()这段代码的思路是把画布原点移动到页面中心统一旋转 45 度然后按行列循环绘制文字。为什么要平移再旋转因为 reportlab 的坐标原点默认在左下角如果你直接旋转所有文字会绕着左下角转位置全乱。先平移到中心旋转角度就围绕中心生效这在视觉上是最稳的。2.2 字体选择里的中文坑第一次跑脚本的时候我用系统默认字体结果水印文字全是方块。这个坑我估计九成新手都会踩——reportlab 默认的 Helvetica 不支持中文。解决办法是注册一个中文字体文件。Windows 上可以直接用C:/Windows/Fonts/msyh.ttc微软雅黑macOS 用/System/Library/Fonts/PingFang.ttcLinux 上可以装fonts-wqy-zenhei然后指定路径。需要注意.ttc是字体集合文件reportlab 对 ttc 的支持要看版本新版一般能直接注册如果你遇到“无法识别字体格式”的报错可以先用工具把 ttc 转成 ttf或者改用思源黑体的 ttf 版本。字体这块想省心的话我建议直接下载思源黑体的SourceHanSansCN-Regular.otf或 TTF 版本跨平台不会有兼容问题。字体文件路径可以用argparse做成可选参数默认自动探测系统里的中文字体这样换台机器也能跑。2.3 平铺密度和旋转角度怎么定平铺水印的行列数、旋转角度直接关系到观感。我试过几组参数最后总结的经验是文字较大的话行距要拉开不然整个页面都是黑糊糊的一片影响阅读文字较小的话可以密一些起到“背景纹理”的效果。我常用的模板大致是A4 页面纵向595 x 842 磅字号 40 到 60旋转 45 度行列数根据水印长度调整。短文字比如“内部资料”用 4 行 3 列长文字比如“仅供项目评审使用”用 3 行 2 列。透明度 0.1 到 0.2 之间比较合适0.15 是经验中庸值既能看清又不伤正文阅读。你要细究的话行列间距也可以用页面尺寸动态算import math col_spacing 2.2 * font_size row_spacing 1.4 * font_size cols max(1, int(page_size[0] / (col_spacing * math.cos(math.radians(angle)))) 1) rows max(1, int(page_size[1] / (row_spacing * math.sin(math.radians(angle)))) 1)这个计算方法考虑到了旋转后的投影宽度不然斜着排的时候靠近边缘的区域容易留白太多。记住一个原则水印要覆盖全页但边缘留白不要超过一个字符的高度视觉上才均匀。3. 批量合并的实操流程与代码实现水印层做好之后剩下就是逐页合并。这一块要处理的细节不少比如原始 PDF 可能有多个页面尺寸、可能有加密、输出文件不能覆盖原文件还要支持只对部分页加水印。3.1 合并的核心原理overlay 与 underlaypikepdf 合并水印有两种方式overlay 是把水印层叠加在原有页面之上underlay 是垫在下面。我们的水印层是半透明的所以用 overlay这样水印浮在正文上方视觉效果更清晰。核心逻辑不复杂import pikepdf def apply_watermark_to_pdf(src_path, watermark_path, dst_path, passwordNone, pagesNone): pdf pikepdf.open(src_path, passwordpassword) wm_pdf pikepdf.open(watermark_path) wm_page wm_pdf.pages[0] if pages is None: target_indices range(len(pdf.pages)) else: target_indices pages # 0-based list, e.g. [0, 2, 4] for i in target_indices: page pdf.pages[i] page.overlay(wm_page, matrixNone, copy_graphicsFalse) pdf.save(dst_path, compress_streamsTrue, object_stream_modepikepdf.ObjectStreamMode.generate) pdf.close() wm_pdf.close()这段代码里面overlay方法会把水印页的图形对象复制到目标页上。matrixNone表示不缩放、不做矩阵变换保持原始坐标不变。因为我们的水印页尺寸和原始 PDF 相同直接叠加不会有偏移。有个细节值得注意如果原始 PDF 的页面尺寸不统一有的 A4有的 A3直接叠可能错位。解决办法是在叠加前比较页面尺寸如果不同就重新生成对应尺寸的水印页。批处理时可以先扫一遍所有页面的尺寸按尺寸分组生成水印页这样效率最高。3.2 支持动态水印内容很多场景水印内容不是固定不变的而是和文件名相关。比如你有一批 PDF 名为“张三-报告.pdf”、“李四-方案.pdf”希望水印自动变成“张三-内部资料”和“李四-内部资料”。这个用代码很容易实现import os import argparse def get_watermark_text_from_filename(filename, prefix, suffix内部资料): base os.path.splitext(os.path.basename(filename))[0] name_part base.split(-)[0] if - in base else base return f{prefix}{name_part}-{suffix}这里用文件名前缀作为用户名只是一种常见例子。你也可以从 Excel 里读或者从命令行参数传一个映射表。我实际做过一个需求是水印内容来自数据库导出的 CSV几百个文件每个内容都不一样用脚本循环读取然后逐文件生成水印页再合并整体流程依然很快瓶颈只在磁盘 IO。3.3 命令行批处理脚本完整版接下来是完整可运行的脚本。我把上面几个函数拼到一起加上了命令行参数支持自定义字体、透明度、行列数、页码范围、输出目录以及密码输入。这个脚本我实测在 Windows 11 和 Ubuntu 22.04 上都能跑Python 3.9 没问题。import argparse import os import glob from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4 from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont import pikepdf def find_chinese_font(): candidates [ C:/Windows/Fonts/msyh.ttc, C:/Windows/Fonts/simhei.ttf, /System/Library/Fonts/PingFang.ttc, /usr/share/fonts/truetype/wqy/wqy-zenhei.ttc, ] for path in candidates: if os.path.exists(path): return path return None def create_watermark_pdf(output_path, text, page_sizeA4, font_pathNone, font_size40, angle45, opacity0.15, rows4, cols3): pdfmetrics.registerFont(TTFont(wmfont, font_path)) c canvas.Canvas(output_path, pagesizepage_size) c.setFont(wmfont, font_size) c.setFillAlpha(opacity) c.setFillColorRGB(0, 0, 0) w, h page_size c.saveState() c.translate(w / 2, h / 2) c.rotate(angle) for i in range(rows): for j in range(cols): x (j - (cols - 1) / 2) * (font_size * 5.5) y (i - (rows - 1) / 2) * (font_size * 3.5) c.drawCentredString(x, y, text) c.restoreState() c.showPage() c.save() def apply_watermark(src_path, wm_path, dst_path, passwordNone, page_indicesNone): with pikepdf.open(src_path, passwordpassword) as pdf: with pikepdf.open(wm_path) as wm: target range(len(pdf.pages)) if page_indices is None else page_indices for idx in target: pdf.pages[idx].overlay(wm.pages[0]) pdf.save(dst_path, compress_streamsTrue) def main(): parser argparse.ArgumentParser(description批量给PDF添加平铺文字水印) parser.add_argument(input_dir, help输入PDF所在目录) parser.add_argument(output_dir, help输出目录) parser.add_argument(--text, default内部资料, help水印文字) parser.add_argument(--font, defaultNone, help字体文件路径) parser.add_argument(--font-size, typeint, default45, help字号) parser.add_argument(--angle, typefloat, default45, help旋转角度) parser.add_argument(--opacity, typefloat, default0.15, help透明度 0~1) parser.add_argument(--rows, typeint, default4, help平铺行数) parser.add_argument(--cols, typeint, default3, help平铺列数) parser.add_argument(--password, defaultNone, helpPDF密码) args parser.parse_args() os.makedirs(args.output_dir, exist_okTrue) font args.font or find_chinese_font() if not font: raise RuntimeError(未找到中文字体请用 --font 指定) pdf_files glob.glob(os.path.join(args.input_dir, *.pdf)) if not pdf_files: print(f目录下没有找到 PDF 文件: {args.input_dir}) return temp_wm os.path.join(args.output_dir, _temp_watermark.pdf) for src in pdf_files: base os.path.basename(src) dst os.path.join(args.output_dir, base) if os.path.abspath(src) os.path.abspath(dst): print(f跳过 {base}: 输出路径与源文件相同) continue create_watermark_pdf(temp_wm, args.text, font_pathfont, font_sizeargs.font_size, angleargs.angle, opacityargs.opacity, rowsargs.rows, colsargs.cols) apply_watermark(src, temp_wm, dst, passwordargs.password) print(f已处理: {base} - {dst}) if os.path.exists(temp_wm): os.remove(temp_wm) print(全部完成) if __name__ __main__: main()用法示例# 基本用法给 input 目录下所有 PDF 加水印 python pdf_watermark.py ./input ./output --text 内部资料 # 指定字体和透明度 python pdf_watermark.py ./input ./output --text 审阅稿 --font C:/Windows/Fonts/msyh.ttc --opacity 0.2 # 处理加密 PDF python pdf_watermark.py ./input ./output --password 123456 # 调整平铺密度 python pdf_watermark.py ./input ./output --rows 5 --cols 4 --font-size 353.4 处理速度和性能优化pikepdf 的速度优势在大文件上特别明显。我用一份 300 页、80MB 的 PDF 做过测试单文件加水印大约用时 4.6 秒用 PyPDF2 可能要 15 秒以上。如果你的文件特别多想进一步提速可以加multiprocessing并行处理——每个 PDF 文件之间没有依赖关系天然适合多进程。一个简单的并行改造思路是先用os.scandir收集文件列表然后用multiprocessing.Pool映射到process_one_file函数。我自己测试过8 核 CPU 处理 60 个文件从串行的 4 分钟降到 55 秒提升非常明显。要注意的是reportlab 注册字体这步在子进程里也需要执行别把全局注册放在if __name__ __main__外面就完事子进程不会继承。4. 常见问题与排查技巧实录写脚本三天排查问题倒是花了整整一天。这里把我遇到过的、以及群里朋友问过最多的坑统一整理成速查表。现象可能原因解决方法水印文字全是方块没有注册中文字体指定系统中文字体文件或下载思源黑体 TTF水印偏了不在页面中心原始 PDF 页面尺寸与生成的水印页不一致读取 PDF 页面实际尺寸动态生成水印页右下角提示或不显示水印透明度设置太低或文字颜色过浅检查 opacity 是否为 0.05 以下设置 FillColorRGB加密 PDF 打开报错文件有所有者密码打开时限制编辑用pikepdf.open(..., password...)传密码输出文件比原文件大很多没有启用压缩pdf.save(dst_path, compress_streamsTrue)水印只出现在第一页代码里只循环了pages[0]确认 target_indices 覆盖range(len(pdf.pages))同一目录输出覆盖了原文件输入输出目录相同检查输入输出路径加跳过逻辑4.1 水印位置偏移的排查思路位置偏移是最坑的问题之一因为你肉眼看到偏了但不知道偏多少、为什么偏。我的排查方法是写一段临时代码直接把 PDF 每一页的尺寸打印出来with pikepdf.open(src) as pdf: for i, page in enumerate(pdf.pages): box page.mediabox print(i, float(box.width), float(box.height))对比水印页尺寸后我发现了问题来源部分 PDF 是 A3 扫描件部分是 A4还有极少数是自定义尺寸。统一用一个 A4 水印页去叠加自然就错位了。解决方案是先扫描所有页面把相同尺寸的页面分组同一组生成一个水印页然后逐个叠加。这样既避免重复生成水印页又能保证每页都对齐。4.2 加密 PDF 的兼容处理很多客户发来的 PDF 带密码有的是打开密码有的是编辑限制密码。pikepdf 的open()支持传password参数但如果密码是空的只有编辑权限限制可以传password打开然后正常处理。另外保存时默认会去掉原本的加密属性所以输出文件是不带密码的普通 PDF——这在大多数场景下是好事但如果你希望输出文件保持加密需要重新加密保存pdf.save(dst_path, encryptionpikepdf.Encryption(owneryour_password, useryour_password))4.3 字体兼容的常见坑reportlab 的TTFont在注册.ttc字体集合文件时部分版本会报错。解决办法有两个方向一是用最新版 reportlab4.x 基本没问题二是在使用前先检查字体文件扩展名遇到.ttc时用fonttools库提取出 ttf 子字体再注册。提取代码很短但多数人用不到所以脚本里我干脆优先匹配.ttf找不到再退而求其次用.ttc。4.4 批量任务中断的恢复处理处理 100 个文件跑到第 57 个时报错了前面的 56 个已生成。如果你重新跑一遍浪费前面的时间不重新跑又不知道哪些漏了。我后来给脚本加了个简单的“跳过已存在输出文件”的功能if os.path.exists(dst) and os.path.getsize(dst) 0: print(f跳过 {base}: 文件已存在) continue这样中断后重跑已经处理好的文件会直接跳过只补漏处理的部分。对于动辄几百个文件的生产环境来说这个优化非常实用。4.5 水印防伪的延伸思考搜索热词里有“水印鲁棒性攻击”和“小波变换与 LSB 性能比较”这说明一些读者关注的是更硬核的防伪方案。简单文字水印容易被裁剪或覆盖如果要防止别人用自动化工具去除水印可以用斜向重复细密排布增加去除难度或者把水印做成矢量对象嵌到页面内容流里而不是直接叠一个整页图片。更高阶的可以往 PDF 里嵌入隐藏的元数据信息或者用局部像素扰动类似 LSB 隐写在图像区域藏信息。这些方案各有好坏鲁棒性强的通常对文件体积影响也大要按实际场景取舍。5. 实操心得与后续扩展跑完整个项目我最大的体会是凡是“文件多、规则杂、重复度高”的活都值得用脚本解决而不要相信自己的耐心。最后分享一个小技巧水印内容不一定只能放文字。用 reportlab 也可以画图片、二维码、Logo。比如给合同加水印时把公司 Logo 和“内部资料”四个字一起放上去视觉上更像正规发布物。此外reportlab 还能在页面四角分别画不同的水印字中间画一个大字号的斜向水印这种多层水印结构在防截屏转发时特别有效。这个脚本后续还可以扩展的方向不少比如接入 PDF 转换服务、把命令行改成简单的 Web 界面、或者把水印内容做成读取 Excel 表动态生成。真遇到需求的时候再改也来得及因为核心流程已经稳定了。如果你照着这篇文章写了一套自己的脚本跑通之后记得留一份在本地——下次同事找你处理 PDF 的时候就能从“我手动加一下”变成“发给我一秒给你”。
返回列表