报告审签走完,最后一道关在用印:检测专用章盖首页,资质认定章盖末页,几十页的还要逐页骑缝。一天几十份全靠人在 PDF 阅读器里拖,漏一份就得重出。
更费神的是盖之前的判断:审签完了没有?作废的有没有混在待办里?同一份报了两遍,会不会把盖好的覆盖掉?都得在动手前拦住。
为了解决这个问题,Python 提供了 pypdf 与 reportlab 这一对组合:reportlab 把印章图排成一张透明覆盖页,pypdf 把它合到原件的指定页码上——位置、页数和落章顺序都能算。
本文按四步走:读申请单与用章规则 → 判够不够格 → 逐页盖到位 → 出盖章台账,代码取自配套示例的run.py。
一、章随报告类型而变
用印这件事的难点不在「把图贴上去」,在盖哪几枚章、盖之前够不够格。
章的组合因报告类型而变:委托检测报告三枚(检测专用章、资质认定章、骑缝章),内部质控报告只盖一枚。规则写在制度里,落到动作上却是人记——换个班就可能漏一枚。
落位也分三种:首页右下、末页右下,还有骑缝。骑缝最麻烦:侧面盖一整枚章,展开看每页只剩一小块;电子件就是每页沿右边缘贴半个章、垂直逐页错开。
所以先要做的不是「盖章」,是按类型查章的组合、按落位算每一页坐标。
二、第一步:路径与尺寸
出处:run.py顶部的路径与字段常量区
REQ_CSV = RAW_DIR / "盖章申请单.csv" RULE_CSV = RAW_DIR / "用章规则表.csv" STAMP_DIR = RAW_DIR / "印章图" PDF_DIR = RAW_DIR / "报告原件" TPL_XLSX = TPL_DIR / "盖章台账模板.xlsx"所有路径从Path(__file__).resolve().parent往下接,换目录、换机器都不用改。申请单天天变、规则表几个月才动一次,分开放,改规则不用碰代码。
出处:run.py顶部的路径与字段常量区(状态那一段)
# 只有「已审签」的报告能进用印室;其它状态一律挂起,不猜、不替人放行。 READY_STATUS = "已审签"出处:run.py顶部的路径与字段常量区(章图尺寸那一段)
STAMP_W = 110 MARGIN = 56 BOTTOM_OFFSET = 16 # 右下角那两枚章再往上抬一点,免得压住页脚那行小字章图边长 110pt、离页边 56pt:只写一次,全篇一起变,比在每份报告里手调可靠;章图跟着页面尺寸走,夹着横向页也不用另写坐标。
三、第二步:读申请单与规则表
出处:run.py的read_rules()(紧跟read_requests())
def read_rules(): """用章规则表:报告类型 → 要盖的章(含落位与骑缝起始页),一张类型可以配多行。""" rules = {} for row in read_csv_rows(RULE_CSV, RULE_HEAD): kind = norm(row["报告类型"]) rules.setdefault(kind, []).append({ "章名": row["章名"].strip(), "落位": row["落位"].strip(), "骑缝起始页": row["骑缝起始页"].strip(), }) return rules规则表是「一行一枚章」的长表,rules["委托检测报告"]拿到的就是那三枚章的清单。为什么不写成三个字段?因为章会加——加一枚认证章,长表加一行就行,改字段就得动代码。
两个细节都在read_csv_rows()里:CSV 一律按文本读(编号0000122一转int()就变122,跟报告封面、台账对不上);表尾说明行靠判空列跳过。
四、第三步:判够不够格
出处:run.py的make_plan()(文件中部,判定那一段)
status = norm(req["审签状态"]) if status != READY_STATUS: return None, f"审签状态是「{req['审签状态'].strip()}」,用印室只收 {READY_STATUS} 的报告" if kind not in rules: return None, f"用章规则表里没有「{req['报告类型'].strip()}」这个类型,先定规则" missing = [r["章名"] for r in rules[kind] if not (STAMP_DIR / f"{r['章名']}.png").exists()] if missing: return None, "章图没有:" + "、".join(missing) + "(去印章图目录补上)"判定顺序就是业务顺序:顺序反了,理由会指向错误的动作——拿着「原件缺失」去补材料,补完才发现这单没审签。
| 判定(按顺序) | 卡住什么 |
|---|---|
| 报告编号 | 编号空,台账上没法标识 |
| 审签状态 | 不是「已审签」不放行,没审签就盖章是事故 |
| 用章规则 | 类型没进规则表,不许猜章的组合 |
| 章图 | 缺哪枚指名道姓,别盖到一半才发现 |
| 原件 | 文件没到,后面都做不了 |
| 页数 | 骑缝起始页大于总页数 |
| 重复申请 | 本次已盖过,第二次不盖,否则把盖好的覆盖一遍 |
排章还有两处容易错:
出处:run.py的make_plan()(文件中部,排章那一段)
for rule in rules[kind]: place = rule["落位"] if place == "首页右下": codes = "1" elif place == "末页右下": codes = str(pages) else: start = int(rule["骑缝起始页"] or 1) if start > pages: return None, f"骑缝章要从第 {start} 页起,这份只有 {pages} 页,页数对不上" codes = f"{start}-{pages}" plan["章"].append({"章名": rule["章名"], "落位": place, "页码": codes}) return plan, None末页章按实际页数算,不能写死页码;骑缝章的页码记成区间(2-6)——台账写区间,落图时再逐页展开。页数对不上整份挂起。
五、把章盖到位
一个容易忽略的点:合页只认页面对象,图片不能直接叠上去,顺序是「章图 → 只有章的小页 → 合到原件那一页」。落位坐标先算清楚,PDF 原点在左下角。
出处:run.py的stamp_xy()(紧跟read_pdf_info())
def stamp_xy(place, width, height, seq, total): """算章图左下角坐标(PDF 原点在左下角)。 · 首页右下 / 末页右下:按页边留白定位,页面是横是竖都不影响。 · 右侧骑缝:x 取「页宽 − 半个章宽」,**章图一半被放到页面外**——PDF 只画页面内的部分, 天然就是半个章,不用去裁图;y 按页码阶梯下移,装订起来侧面才是完整的一枚。 """ if place in ("首页右下", "末页右下"): return width - MARGIN - STAMP_W, MARGIN + BOTTOM_OFFSET x = width - STAMP_W / 2 top = height - MARGIN - STAMP_W step = (top - MARGIN) / max(total - 1, 1) return x, top - seq * step骑缝的「半个章」不用裁图:横向放到页面外一半,超出的部分 PDF 不画。垂直按骑缝页序阶梯下移,装订起来才拼得成一整枚。
出处:run.py的build_page_map()(紧跟make_plan())
def build_page_map(plan): """章 → 页:把「一张章」展开成「要落在哪几页」,顺手算好它在骑缝序列里的第几格。 台账里记的是页码区间(如 2-6),落图要逐页落;骑缝章的垂直位置按**骑缝页序**排, 所以 seq 从骑缝起始页起数 0、total 是骑缝页数——用整份报告的页数去算,最后几页会挤在一起。 """ page_map = {} for stamp in plan["章"]: start, _, end = stamp["页码"].partition("-") start, end = int(start), int(end or start) for p in range(start, end + 1): page_map.setdefault(p, []).append({**stamp, "seq": p - start, "total": end - start + 1}) return page_map阶梯步长按骑缝页数算(末页减起始页加一)。按整份页数算,最后几页的章会挤在底边。
出处:run.py的build_overlay()(在stamp_pdf()前面)
def build_overlay(width, height, stamps_on_page): """给一页生成一张只有章的透明覆盖页。 为什么要单独生成一张"覆盖页"再合过去:PDF 里合页只认 PageObject, PNG 图片得先由 reportlab 画成一页,才能叠到原件上——`drawImage(mask="auto")` 保住 alpha, 否则章图会带一块白底,压住报告上的字。 """ from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas from pypdf import PdfReader buf = io.BytesIO() c = canvas.Canvas(buf, pagesize=(width, height)) for stamp in stamps_on_page: x, y = stamp_xy(stamp["落位"], width, height, stamp["seq"], stamp["total"]) c.drawImage(ImageReader(str(STAMP_DIR / f"{stamp['章名']}.png")), x, y, width=STAMP_W, height=STAMP_W, mask="auto") c.save() buf.seek(0) return PdfReader(buf).pages[0](块里那三行 import 在这个函数里,不在文件顶部。)
mask="auto"不能省:章图是透明底 PNG,叠页要把 alpha 带过去;少了它,透明处会变成实色盖住报告上的字。本机实测:不传是SMask=False,加上才SMask=True。
出处:run.py的stamp_pdf()(紧跟build_overlay())
def stamp_pdf(plan): """逐页合章,输出盖章件。原件一张不动——它是归档凭证,盖过的另存一份。""" from pypdf import PdfReader, PdfWriter reader = PdfReader(str(plan["原件"])) writer = PdfWriter() page_map = build_page_map(plan) for i, page in enumerate(reader.pages, 1): stamps = page_map.get(i) if stamps: w = float(page.mediabox.width) h = float(page.mediabox.height) page.merge_page(build_overlay(w, h, stamps)) writer.add_page(page) dest = STAMPED_DIR / f"已盖章_{plan['报告编号']}.pdf" with open(dest, "wb") as f: writer.write(f) return dest还有三条:原件只读、盖好的另存;覆盖页尺寸现算(否则章被缩放);一页只合一次(几枚章收进一张覆盖页)。
六、最后:台账按「报告 × 章」
出处:run.py的write_ledger()(文件后半)
def write_ledger(rows, holds): """台账按「报告 × 章」登记:审核来问"这份报告盖了哪几枚章",一行行对得上。""" from openpyxl import load_workbook from openpyxl.styles import Alignment, Border, Font, Side wb = load_workbook(TPL_XLSX) ws = wb["盖章台账"] thin = Side(style="thin", color="D0D0D0") border = Border(left=thin, right=thin, top=thin, bottom=thin) for row in rows: ws.append(row) for c in ws[ws.max_row]: c.border = border c.font = Font(name="微软雅黑", size=10) if c.column == 1: c.number_format = "@" c.alignment = Alignment(horizontal="left") hs = wb["挂起清单"] for row in holds: hs.append(row) for c in hs[hs.max_row]: c.font = Font(name="微软雅黑", size=10) OUT_DIR.mkdir(parents=True, exist_ok=True) wb.save(LEDGER_XLSX)台账粒度是「报告 × 章」,不是「报告数」:审核来查「这份报告盖了哪几枚章」,一行行对得上。挂起的进第二个工作表。
总结
一,先想清楚规则和判断,再动手:章的组合按类型查表,够不够格按业务顺序判,挂起理由指名缺什么。
二,章图不是「贴上去」而是「合过去」:先按目标页尺寸做一张透明覆盖页,mask="auto"保住 alpha,再merge_page()合页。
三,骑缝章不用裁图:横向放到页面外一半,PDF 自然只画一半;垂直按骑缝页序阶梯下移,装订起来才拼得成一枚。
示例回读校验 31 项全绿,换掉01_raw_data/里的输入就能直接用。
完整源码
本文配套的可运行示例已开源,换掉01_raw_data/里的申请单、用章规则表、章图和报告原件就能直接复跑:
huang_jianhua0101/examples - Gitee.com
关于我
在实验室一线待了 13 年(9 年制药 + 4 年第三方检测),做的一直是实验室信息化。做过 STARLIMS 的甲方 PM——一期、二期两轮上线都由我主导(招标到 3Q 验证到验收全流程);也在系统上自己做过二次开发——把纸质的账号申请流程搬到线上跑;在 STARLIMS 之前还有 6 年多 CS 架构 LIMS 的使用与运维经验(其中一段经 Citrix 远程接入)。现在专做实验室里那些重复劳动:报表自动生成、仪器数据对接、合规文档批量处理。
本科物理化学、硕士计算机化学,既听得懂 QA 说的变更控制,也看得懂仪器导出的原始数据长什么样。SOP、偏差、OOS、样本流转这些词,不用你解释。
现在主要做这几类:
- 检验报告与台账批量生成:模板不动,数据自动填,格式一步不错
- 仪器数据对接:色谱、光谱、酶标仪导出的原始文件,解析、清洗、入库、转成报表
- 合规文档自动化:SOP、验证方案、批记录这类重复文档的批量生成与核对
- 数据完整性核查:按 ALCOA+ 逐条核对原始数据与记录是否对得上
手里有这类活儿卡着,或者只是想问问能不能自动化,都欢迎评论区聊,先把问题说清楚再谈怎么做。