报告审签走完最后一道关在用印检测专用章盖首页资质认定章盖末页几十页的还要逐页骑缝。一天几十份全靠人在 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(maskauto) 保住 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, widthSTAMP_W, heightSTAMP_W, maskauto) c.save() buf.seek(0) return PdfReader(buf).pages[0]块里那三行 import 在这个函数里不在文件顶部。maskauto不能省章图是透明底 PNG叠页要把 alpha 带过去少了它透明处会变成实色盖住报告上的字。本机实测不传是SMaskFalse加上才SMaskTrue。出处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(stylethin, colorD0D0D0) border Border(leftthin, rightthin, topthin, bottomthin) for row in rows: ws.append(row) for c in ws[ws.max_row]: c.border border c.font Font(name微软雅黑, size10) if c.column 1: c.number_format c.alignment Alignment(horizontalleft) hs wb[挂起清单] for row in holds: hs.append(row) for c in hs[hs.max_row]: c.font Font(name微软雅黑, size10) OUT_DIR.mkdir(parentsTrue, exist_okTrue) wb.save(LEDGER_XLSX)台账粒度是「报告 × 章」不是「报告数」审核来查「这份报告盖了哪几枚章」一行行对得上。挂起的进第二个工作表。总结一先想清楚规则和判断再动手章的组合按类型查表够不够格按业务顺序判挂起理由指名缺什么。二章图不是「贴上去」而是「合过去」先按目标页尺寸做一张透明覆盖页maskauto保住 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 逐条核对原始数据与记录是否对得上手里有这类活儿卡着或者只是想问问能不能自动化都欢迎评论区聊先把问题说清楚再谈怎么做。