做文档归档的同行大概都遇到过这种场景:一批 PDF 要交给上游,每份都要在末页右下角补一个部门声明章,几十份文件,一份一份打开、截图、拖进去、调整大小、导出。用在线工具更麻烦,文件要上传到别人的服务器,网络一卡就得重来,敏感一点的合同根本不敢往上放。我前后被这件事折腾过三四轮,最后干脆自己写了个小工具,本地跑,拖进去、选印章、点一下,批量出结果。这篇就把"pdf盖章软件"从零到能交付使用的完整思路拆开讲,包括坐标系怎么算、印章白底怎么抠、骑缝章怎么切、界面怎么搭、打包成可执行文件之后又会在哪儿翻车。
先说清楚定位:这里讲的是给 PDF 页面叠加一张印章图片(或一组合成图形)的落地实现,覆盖单页盖章、批量盖章、骑缝章三类需求,技术栈以 Python 为主,兼顾桌面界面与命令行。适合三类人看:经常处理文档归档的行政、法务、项目岗,想用脚本替代手工的;写过一点 Python、想把脚本做成小工具给自己或同事用的;以及被旋转页面、裁切框这类 PDF 历史包袱折磨过、想搞清楚底层逻辑的。需要提醒一句,印章图片本身要按所在单位的管理要求妥善保管,工具只对你确认有权处理的文档动手,别拿它去碰任何来路不明的文件。
1. 一枚章贴进PDF,首先得把这套坐标账算明白
1.1 页面上有四个"盒子",印章落点由它们共同决定
很多人第一次写盖章脚本,思路是"页面宽 595、高 842,那我把章放在 (400, 600) 就行",在 A4 竖版上确实能跑通,然后换一份横版扫描件立刻全乱。原因在于 PDF 页面并不是一个简单的宽高矩形,它至少牵扯四个概念:MediaBox是物理纸张的边界,CropBox是最终显示和打印时被裁出来的可见区域,BleedBox/TrimBox在印刷场景里用得多,日常文档基本等于 CropBox;再叠加一个页面的/Rotate 旋转角,它不改变页面内容的实际绘制坐标,只改变阅读器"怎么看"。
这会带来一个非常具体的后果:你在阅读器里截图量出来的像素位置,和脚本里用的 PDF 点坐标,可能差了一个 90 度旋转。PyMuPDF 里page.rect拿到的是已经考虑过 /Rotate 的显示矩形,而page.insert_image()接收的矩形是页面自身的坐标系,对旋转页来说这两者并不重合。我最早就是直接拿page.rect的宽高去算偏移,横版文件盖出来章跑到页面外面去了,肉眼看着像"没盖成功",其实是落在可见区域之外。
单位也要记住:PDF 内部长度单位是 point,1 point = 1/72 英寸,A4 就是 595 × 842 point 左右(不同来源会有 ±1 的差异)。所以别想着用像素,老老实实按 point 算,或者干脆用相对比例——这一点后面单独讲。
1.2 图片章、矢量章、纯文字章,三条路的代价完全不同
"印章"在实现上有三种形态,选错了后面全是返工。
第一种是图片章,一张 PNG 或 JPG,透明度、字体、做旧纹理都在图里。实现最简单,一行insert_image就能贴上去。代价是放大后会糊,尤其是位图章在 300dpi 打印时边缘发虚;另外如果原图是白底不透明,贴上去就是一个白方块。
第二种是矢量章,用绘图指令画圆、画边框、画文字。清晰度无损、任意缩放都锐利,还能动态改日期和编号。代价是要处理字体嵌入、文字沿圆弧排列、坐标微调,工作量大概是图片章的三到五倍。
第三种是文字章,只插入一段带边框的文字,比如"内部资料 请勿外传"。这种最轻,用insert_textbox加一个draw_rect就完事,适合水印性质的标注。
实际项目里我建议先做图片章,把定位、批量、界面跑通,等真有高清打印需求再补矢量方案。绝大多数内部流转场景,一张 600×600 的 PNG 在 100% 缩放下完全够看。
1.3 视觉坐标和内部坐标必须分开管理
这里有个我踩过两次的教训:永远不要在代码里混用"看到的坐标"和"写入的坐标"。我的做法是定一层约定——界面上用户拖拽得到的都是视觉坐标(基于page.rect,和屏幕预览一致),真正调insert_image之前,统一乘一次page.derotation_matrix转成页面坐标。
import fitz def visual_rect_to_page(page, rect_view): """把基于 page.rect 的视觉矩形,换算成页面自身坐标系""" return rect_view * page.derotation_matrixderotation_matrix是 PyMuPDF 提供的一个现成矩阵,作用是把"旋转后显示出来的坐标"映射回"页面内容本身的坐标"。旋转角为 0 时它基本是单位矩阵,等于没做事,所以这套写法在正常页面上也不会有额外成本。养成这个习惯之后,横版、竖版、旋转 180 度的扫描件就都不用单独写分支了。
顺带说一句坐标查错的土办法:盖完之后用page.get_pixmap(dpi=72)渲一张小图出来看一眼,比反复改猜数值快得多。
2. 三条技术路线:直插、合成、重排,各自的真实代价
2.1 PyMuPDF 直接插图:最省事的路线,也是我最终选的
PyMuPDF(导入名fitz)操作 PDF 是"打开—改—存"的模型,插入图片就是一个方法调用:
import fitz def stamp_one(src, dst, png, rel_x=0.62, rel_y=0.70, rel_w=0.20): doc = fitz.open(src) for page in doc: pr = page.rect stamp_w = pr.width * rel_w pix = fitz.Pixmap(png) stamp_h = stamp_w * pix.height / pix.width x0 = pr.x0 + pr.width * rel_x y0 = pr.y0 + pr.height * rel_y rect_view = fitz.Rect(x0, y0, x0 + stamp_w, y0 + stamp_h) rect_page = rect_view * page.derotation_matrix page.insert_image( rect_page, filename=png, overlay=True, # 盖在原有内容之上 keep_proportion=True, alpha=-1, # 使用图片自带的透明通道 ) doc.save(dst, garbage=3, deflate=True) doc.close()这套写法有几个好处:不需要中间文件、不需要重新排版、原文档的字体和书签基本原样保留,处理速度也够看——普通办公本上几百页的合同,单页插入耗时在毫秒级。garbage=3和deflate=True是顺手做的清理与压缩,能把反复编辑产生的冗余对象和交叉引用表收拾干净,输出体积往往比原始文件还小一点。
要注意alpha参数的含义容易理解错:它是固定透明度,传 0.5 就是整张图半透明;传默认的 -1 表示"按图片自己的 alpha 通道来"。想让印章看起来像压上去的,正确做法是把图的白底抠成透明(下一节讲),而不是把整张图调到半透明——后者会让印章的红色线条也一起变淡,颜色发灰。
2.2 pypdf 合层与 reportlab 重排:可控但绕远路
另一条常见路线是reportlab生成一张只含印章的空白页,再用pypdf的merge_page把两张页面叠起来。它的优势是完全由你控制绘制过程:想要矢量章、想要精确到 0.1 point 的位置、想要在印章旁边补一行小字,都可以在这张"印章页"上画出来。
代价是三层:一是要为每个页面尺寸分别建画布,横竖混排的文档要写分支;二是merge_page处理有表单域或者加密的 PDF 时容易出状况;三是内存占用比直插高,因为要先把整个合并结果拼在内存里。
from pypdf import PdfReader, PdfWriter from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import A4 import io def make_stamp_layer(w, h): buf = io.BytesIO() c = canvas.Canvas(buf, pagesize=(w, h)) c.drawImage("stamp_clean.png", w * 0.62, h * 0.18, width=w * 0.2, height=w * 0.2, mask="auto") c.save() buf.seek(0) return PdfReader(buf).pages[0] reader = PdfReader("in.pdf") writer = PdfWriter() for page in reader.pages: w = float(page.mediabox.width) h = float(page.mediabox.height) page.merge_page(make_stamp_layer(w, h)) writer.add_page(page) with open("out.pdf", "wb") as f: writer.write(f)注意reportlab的坐标原点在左下角,而 PyMuPDF 的page.rect原点在左上角,两套习惯混着写必然出错。这也是我后来放弃这条路的主要原因——不是它不好,是我脑子不够用来回切换。
2.3 路线对照与我的取舍
| 维度 | PyMuPDF 直插 | pypdf + reportlab | 纯矢量绘制 |
|---|---|---|---|
| 上手成本 | 低 | 中 | 高 |
| 输出清晰度 | 取决于原图分辨率 | 取决于原图分辨率 | 无损 |
| 旋转页兼容 | 需一次矩阵换算 | 需自行判断 | 需自行判断 |
| 内存占用 | 低 | 中高 | 低 |
| 适合场景 | 内部流转、批量盖章 | 需要精细绘制 | 对外正式文档 |
我的结论很直白:图片章 + PyMuPDF 直插作为默认方案,覆盖八成需求;等出现高清打印或者需要动态生成编号的诉求,再针对那一条业务线单独做矢量版,不必一上来就把架构做重。
3. 位置、比例、白底:让印章看起来是"盖"上去的
3.1 用相对坐标,换纸型换扫描件都不用改代码
写死(400, 600)的脚本,遇到非标准尺寸就废。我把所有位置参数都定义成相对page.rect的比例:rel_x是印章左边缘距页面左边缘的比例,rel_y是上边缘距页面顶部的比例,rel_w是印章宽度占页面宽度的比例。A4 竖版上rel_w=0.20大概是 119 point,接近 4 厘米,属于常见的声明章尺寸。
为什么用宽度做基准而不是高度?因为横竖版切换时页面高度差异大,宽度相对稳定;而且印章的视觉大小主要由宽度决定。缩放逻辑是"先定宽,再按图片原始宽高比推高",配上keep_proportion=True双保险,不会出现拉扁的情况。
几个常用的落点配置,我整理成了一张表,直接抄改就行:
| 用途 | rel_x | rel_y | rel_w | 说明 |
|---|---|---|---|---|
| 末页右下角声明章 | 0.62 | 0.70 | 0.20 | 最常见,避开页码区和签名线 |
| 首页右上角密级标 | 0.72 | 0.06 | 0.16 | 需要先确认抬头文字有没有占位 |
| 全页居中水印 | 0.30 | 0.40 | 0.40 | 配合低透明度,别压住正文阅读 |
| 骑缝章(右侧切口) | 0.94 | 0.46 | 按页数切分 | 见第 4 节 |
3.2 白底不抠,一切都是白说
这是新手最常卡住的一步。手机上拍下来的印章照片、扫描件截出来的红章图,默认都是白底。你直接插进去,它就变成一个白色方块死死压住正文。修这个问题有三种做法,可靠性依次上升:
做法一,靠mask参数。reportlab的drawImage(..., mask="auto")或insert_image的透明通道,只对本来就有 alpha 的图有效。白底 JPG 没有 alpha,怎么传都没用。
做法二,手动调低整体透明度。观感很差,红色会变成脏粉色,而且白底依然存在,只是变淡。我试过一次就放弃了。
做法三,预处理把接近白色的像素 alpha 置 0。这是我用的方案,用 Pillow + NumPy 向量化,一张 800×800 的图几毫秒就处理完:
import numpy as np from PIL import Image def strip_white(src, dst, thresh=238, soft=215): im = Image.open(src).convert("RGBA") arr = np.array(im) rgb = arr[:, :, :3].astype(np.int16) mn = rgb.min(axis=2) # 纯白直接全透明 hard = mn > thresh arr[hard, 3] = 0 # 浅灰边缘做渐变,避免锯齿 edge = (mn > soft) & (mn <= thresh) rate = (thresh - mn[edge]) / float(thresh - soft) arr[edge, 3] = (arr[edge, 3] * rate).astype(np.uint8) Image.fromarray(arr, "RGBA").save(dst) return dstthresh和soft两个阈值是关键。只做硬阈值(大于 238 直接透明)会留下一圈灰色毛边,打印出来像印章周围有一层脏东西;加上软阈值做渐变过渡,边缘就自然了。阈值具体取值要看你的图:纸面偏黄的老扫描件,白色分量本来就不到 238,硬阈值可能连章体一起打掉,这时候先做一次白平衡,或者把thresh降到 220 左右再试。
还有一个隐蔽的坑:输入图别用调色板模式(P 模式)或者带 tRNS 的 PNG。不同库对这类图的解析行为不一致,有时候能在预览里正常显示,插进 PDF 就变成黑底。统一convert("RGBA")一次,能省掉大量莫名其妙的排查时间。
3.3 盖完检查一下有没有压到正文
自动盖章最怕的是"盖成功了,但盖在签字栏上"。一个成本很低的护栏是:在插章之前,用page.get_text("blocks")拿到所有文本块的边界,判断印章矩形和文本块是否有明显重叠,有就记录下来给用户确认。
def find_conflicts(page, rect_page, ratio=0.12): hits = [] for b in page.get_text("blocks"): b_rect = fitz.Rect(b[:4]) inter = b_rect & rect_page if not inter.is_empty: if inter.get_area() / rect_page.get_area() > ratio: hits.append((b[4][:20], inter.get_area())) return hits这里要注意两边的坐标系必须统一。稳妥做法是把文本块矩形也过一遍旋转矩阵,和印章矩形放在同一套坐标下比较。ratio设成 0.12 是我调了几次的结果——设太小会把正常压住的页眉页脚也算成冲突,设太大又漏掉真问题。
对扫描版 PDF,get_text返回空列表,这套检测直接失效。这时候只能退回"固定相对坐标 + 缩略图人工确认"的路子,别指望全自动。我在工具里给这种情况加了个预览面板,逐页缩略图带印章位置的红色虚线框,扫一眼就知道有没有压到东西。
4. 骑缝章:一枚章切N片,切法和页序才是难点
4.1 先想清楚装订方式,再决定切片的排列顺序
骑缝章的原理不复杂:一枚完整的章盖在叠好的纸张切口上,翻开后每一页各保留一条窄带。实现上就是把这枚章按页数纵向切成 N 片,每页贴一片。
真正容易错的是顺序。假设文档 10 页,印章切成 10 片,第 1 片贴第 1 页、第 2 片贴第 2 页……听起来天经地义,但实际装订后,第 1 页在最上面,你从切口方向看过去,看到的第 1 页那条窄带是在印章的最右侧还是最左侧,取决于装订边在哪一边、印章是从正面还是反面盖的。方向搞反了,打印出来叠在一起是镜像的,等于白盖。
我的处理办法是给切分函数加一个reverse开关,默认按"文字方向从左到右、页序从前往后"排列,实际用之前先打两页出来对一下:
from PIL import Image def split_stamp(png, n, out_dir, reverse=False): im = Image.open(png).convert("RGBA") W, H = im.size step = W / float(n) if step < 2: raise ValueError("页数过多,印章宽度不足以切分") pieces = [] for i in range(n): left = int(round(i * step)) right = W if i == n - 1 else int(round((i + 1) * step)) piece = im.crop((left, 0, right, H)) pieces.append(piece) if reverse: pieces.reverse() paths = [] for i, p in enumerate(pieces): path = f"{out_dir}/qifeng_{i:03d}.png" p.save(path) paths.append(path) return pathsstep < 2这个校验很必要。我遇到过一份 60 多页的文档,用户直接套用骑缝章,印章图宽度只有 300 像素,每片不到 5 像素,贴上去就是一条噪点线,肉眼完全看不出是个章。
4.2 每片落点要固定,只有内容在变
这里有个概念必须掰清楚:骑缝章的每一页,贴的位置是同一个区域,宽度是整章宽度的 1/N,变化的只有切片内容。因为纸张叠起来的时候,每页的切口边是对齐的,只有位置完全一致,展开才会拼成一枚完整的章。
计算方式:设整章在页面上应该占的宽度为full_w(按页面宽度的比例给,比如 0.10),页数 N,那么每片宽piece_w = full_w / N。落点的垂直位置(rel_y)和高度full_h所有页保持相同。
def apply_qifeng(doc, pieces, rel_right=0.97, rel_y=0.46, full_w_ratio=0.10): n = len(pieces) if len(doc) != n: raise ValueError("页数与印章切片数不一致") for i, page in enumerate(doc): pr = page.rect full_w = pr.width * full_w_ratio piece_w = full_w / n x0 = pr.x0 + pr.width * rel_right - piece_w # 高度按切片本身比例推算,所有页一致 im = Image.open(pieces[i]) h = piece_w * im.height / im.width y0 = pr.y0 + pr.height * rel_y rect_view = fitz.Rect(x0, y0, x0 + piece_w, y0 + h) page.insert_image(rect_view * page.derotation_matrix, filename=pieces[i], overlay=True, alpha=-1) return docrel_right=0.97的意思是切片右边缘离页面右边缘留 3% 的余量。这个留白是为了避开打印机不可打印区——很多办公设备四周有 3 到 5 毫米打不出来,贴得太靠边会被裁掉一截,叠起来就拼不上。想更保险可以留到 0.94。
4.3 拼合预览比什么都重要
骑缝章做错方向,光看单页是看不出来的,非得叠起来才知道。所以我在工具里做了一个"拼合预览":把每页贴完之后的对应切片区域裁出来,横向拼成一张长图,屏幕上就是最终叠起来的效果。检查这一步花十秒,能避免整批重打。
实现思路很土但够用:处理过程中把每片落点区域从渲染结果里裁出来,按页序横向拼接。用 Pillow 拼一张预览图,几百毫秒的事。
5. 批量化与界面:把脚本打磨到能交给同事用
5.1 批量任务的关键是失败隔离
单文件脚本一旦扩展到批量,最大的变化不是速度,而是出错之后怎么办。一个目录两百个文件,中间某个文件加密了或者损坏了,整批任务直接抛异常退出,前面处理完的也白干。
我的做法是逐个文件独立 try,记录结果,最后汇总:
import os, traceback def batch_stamp(files, out_dir, png, cfg): report = [] for src in files: name = os.path.basename(src) dst = os.path.join(out_dir, name) try: stamp_one(src, dst, png, **cfg) report.append((name, "ok", os.path.getsize(dst))) except Exception as e: report.append((name, "fail", str(e))) return report输出目录和源目录必须分开,别图省事原地覆盖。原因后面第 6 节会讲到——覆盖原文件在 Windows 上有一堆坑。命名规则也建议保留原名,不要加一堆后缀,否则同事拿到一批文件还得手动回改名字。
5.2 一个够用的界面,核心就是拖拽 + 预览
脚本自己用没问题,交给同事就得有界面。我用 PySide6 搭的,结构很简单:左边文件列表,中间页面预览,右边参数面板。预览用QGraphicsView显示页面渲染图,印章做成一个可以拖动、可以拉缩放的图元,用户松手之后把图元的矩形换算回 PDF 坐标存起来。
渲染页面的那段代码要小心内存引用:
pix = page.get_pixmap(dpi=110, alpha=False) img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888).copy()最后那个.copy()不能省。QImage用外部缓冲区构造时不接管内存所有权,pix一旦被回收,界面上就会出现花屏或者直接崩。我在这上面浪费了一个晚上,表现是"预览刚开始正常,翻两页就变雪花",第一反应还以为是显卡驱动的问题。
dpi=110是画质和速度的折中。150 以上拖起来就有明显延迟,72 又看不清小字。如果文件特别大,可以只渲染当前页,翻页时再按需渲染,别一次性把几十页都转成位图塞内存。
5.3 打包成可执行文件,路径问题排第一位
用 PyInstaller 打包,最容易翻车的两处:资源路径和依赖收集。
资源路径的问题在于,打成单文件之后,程序运行时会先解压到一个临时目录,__file__指向的地方跟你开发时完全不一样。统一用一个函数拿路径:
import sys, os def res_path(rel): base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, rel)打包命令大概长这样:
pyinstaller -F -w --name StampTool ^ --add-data "assets;assets" ^ --collect-submodules pymupdf ^ main.py依赖方面,PyMuPDF 和 Pillow 都带编译好的二进制扩展,一般能被自动收集进去;但如果你用了reportlab的字体或者PIL的插件模块,最好用--collect-submodules显式带上,否则会出现"开发环境正常、双击 exe 报找不到模块"的经典现象。
还有一个细节:打包后的程序在别人电脑上运行时,工作目录可能是桌面或者 C 盘根目录,任何用相对路径读写的操作都会出问题。所有用户数据的路径统一用os.path.expanduser("~")拼,或者干脆让用户在界面上自己选。
6. 调试日志:旋转页、裁切框、覆盖原文件这三个坑
6.1 旋转页盖歪,问题出在"你看到的不是你以为的"
现象:竖版文件正常,横版扫描件盖出来的章整体偏移,偏移量刚好等于页面宽高差。排查链路是这样的——先打印page.rect和page.mediabox对比,发现两者宽高互换,page.rotation是 90;再用page.get_pixmap()渲染出来确认视觉方向和page.rect一致;最后确认insert_image用的是页面自身坐标系。结论就清楚了,必须补一次derotation_matrix换算。这个坑一旦踩过,往后所有涉及坐标的操作我都会先打印一遍page.rotation。
6.2 印章被裁掉一半,是 CropBox 在作怪
有的扫描件 MediaBox 比 CropBox 大一圈,你按 MediaBox 算出来的位置,视觉上已经跑到可见区域外面了。page.rect一般是基于 CropBox 的,所以用相对比例算位置基本安全;但如果代码里手动读了mediabox去算尺寸,就会出问题。修法是统一以page.rect为准,别去碰原始盒子,除非你明确知道自己在做什么。
还有一种情况是印章超出页面边界。insert_image不会自动裁剪,超出的部分在阅读器里看不见,但对象还在,某些工具处理时会报"内容越界"。加一个和页面矩形的交集判断,超了就缩放或者报警。
6.3 原地覆盖写不进去,以及大文件把内存吃满
Windows 上,doc.save(同一个路径)经常直接报权限错误,因为原文件还被当前进程持有。稳妥流程是:写到同目录的临时文件 → 关闭文档对象 →os.replace原子替换。这样即使中途失败,原文件也不会变成半截。
tmp = src + ".tmp" doc.save(tmp, garbage=3, deflate=True) doc.close() os.replace(tmp, src)大文件的问题是另一个方向。一份三百页、带大量扫描图的 PDF,如果一次性把所有页面都转成位图做预览,内存轻松上几个 G。解决办法是按页处理、按需渲染,处理完一页就把 pixmap 释放掉;不要把所有 Pixmap 攒在列表里。另外garbage=3在超大文件上会明显拖慢保存速度,可以降成garbage=1,效果和速度取个平衡。
我自己的使用习惯是:批量任务永远输出到新目录,检查无误之后再由人工决定要不要覆盖原件。多一步操作,省掉的是"整批文件被写坏"这种不可逆事故。
这套工具从最早的二十行脚本到现在的界面版,前后改了小半年,真正花时间的不是核心的插图逻辑,而是那些边角情况——旋转、裁切、白底毛边、骑缝方向、打包路径。如果你准备动手,我的建议是先跑通单页盖章,用一份横版和一份竖版文件各测一遍,确认坐标换算没问题再往上堆功能。骑缝章这类"看起来简单、拼起来才知道对不对"的功能,一定要先把预览做出来,别凭想象写代码。印章素材和成品的保管也按规矩来,工具本身没有判断力,能不能盖、盖在哪儿,还是得人来定。