# -*- coding: utf-8 -*- """把单位公章、法人印鉴与手写签名盖到总册的各处署名栏,只加浮动图片,不改文字。 用法: python doc/_bid_apply_seals.py 生成「(已签章)」副本 python doc/_bid_apply_seals.py --dry 只列出识别到的署名栏,不动文档 """ import io import os import random import re import sys import shutil import time import numpy as np from PIL import Image sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") HERE = os.path.dirname(os.path.abspath(__file__)) ONEDRIVE = r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目招投标" NAME = "响应文件(完整版)-2026年河南产互联制造业数据智能运营平台研发项目.docx" SRC = os.path.join(ONEDRIVE, NAME) OUT = os.path.join(ONEDRIVE, NAME[:-5] + "(已签章).docx") SEAL_DIR = os.path.join(HERE, "_bid_assets", "seal") GZ_P = os.path.join(SEAL_DIR, "gongzhang.png") # 单位公章(圆) YJ_P = os.path.join(SEAL_DIR, "yinjian.png") # 法定代表人印鉴(方) SG_P = os.path.join(SEAL_DIR, "signature.png") # 手写签名 GZ = YJ = SG = None # main() 里按实测墨迹构造 MM = 2.8346457 # 毫米 → 磅 # 实测尺寸:授权书扫描件是整幅 A4(横向 1px=0.0981mm),公章墨迹 440px→43.2mm, # 含油墨外溢,标称即常规企业公章 42mm;方形法人名章 217px→21mm。签名按自然 # 书写大小给 27mm(扫描件里那处签在小表格格子内,只有 13mm,照搬会小得失真)。 GZ_MM, YJ_MM, SG_MM = 42.0, 21.0, 27.0 COMPANY = "北京智造易科技有限公司" # Word 常量 WD_PAGE = 3 # wdActiveEndPageNumber WD_HPOS = 5 # wdHorizontalPositionRelativeToPage WD_VPOS = 6 # wdVerticalPositionRelativeToPage WD_REL_PAGE = 1 WD_WRAP_NONE = 3 MSO_TRUE, MSO_FALSE = -1, 0 MSO_IN_FRONT_OF_TEXT = 4 MSO_BRING_TO_FRONT = 0 WD_FORMAT_DOCX = 16 # 盖章行:带盖章括注且写有单位名称 RE_SEAL = re.compile(r"[((](?:盖公章|盖章|盖单位章|签章)[))]|加盖单位公章") # 签字行:带签字括注 RE_SIGN = re.compile(r"[((]签字(?:或盖章)?[))]|签字或盖章") # 签名落笔处:优先下划线,其次「:」与「(签」之间的空档,最后「:」之后 RE_UNDER = re.compile(r"[__]{2,}") class Asset: """透明 PNG 四周留了空白,按「墨迹」而非整图定尺寸与中心,摆放才准。""" def __init__(self, path, ink_mm): al = np.asarray(Image.open(path))[..., 3] ys, xs = np.nonzero(al > 8) ih, iw = al.shape kw, kh = xs.max() - xs.min() + 1, ys.max() - ys.min() + 1 self.path = path self.ink = ink_mm * MM # 墨迹目标宽(磅) self.w = self.ink * iw / kw # 整图宽 self.h = self.w * ih / iw self.ink_h = self.h * kh / ih self.fx = (xs.min() + kw / 2) / iw # 墨迹中心在整图中的相对位置 self.fy = (ys.min() + kh / 2) / ih def box(self, cx, cy, scale=1.0): """给定墨迹中心,返回整图左上角与整图宽。""" w, h = self.w * scale, self.h * scale return cx - w * self.fx, cy - h * self.fy, w def rnd(kind, i): """按「用途 + 序号」取稳定随机源:同一处每次运行结果一致,各处互不相同。""" return random.Random(f"{kind}|{i}|aidop-20260804") def wobble(kind, i, ang_lo, ang_hi, scale_amp): """真章是手工按下去的:角度、大小、落点都略有出入,且不重复。""" r = rnd(kind, i) ang = r.choice((-1, 1)) * r.uniform(ang_lo, ang_hi) return round(ang, 2), 1.0 + r.uniform(-scale_amp, scale_amp), r def main(): dry = "--dry" in sys.argv for p in (GZ_P, YJ_P, SG_P): if not os.path.exists(p): raise SystemExit(f"缺少印鉴素材:{p}(先跑 _bid_make_seals.py)") if not os.path.exists(SRC): raise SystemExit(f"找不到总册:{SRC}") global GZ, YJ, SG GZ = Asset(GZ_P, GZ_MM) YJ = Asset(YJ_P, YJ_MM) SG = Asset(SG_P, SG_MM) print(f"印鉴实际尺寸:公章 {GZ_MM}mm 法人印鉴 {YJ_MM}mm 签名宽 {SG_MM}mm") target = SRC if dry else OUT if not dry: if os.path.exists(OUT): os.remove(OUT) shutil.copy2(SRC, OUT) import win32com.client as win32 word = None for i in range(4): try: word = win32.gencache.EnsureDispatch("Word.Application") word.Visible = False word.DisplayAlerts = 0 break except Exception as e: # Word COM 偶发拒绝调用 print(f" 启动 Word 重试 {i + 1}/4:{e}") time.sleep(4) if word is None: raise SystemExit("无法启动 Word") doc = word.Documents.Open(target, ReadOnly=dry) try: run(doc, dry) finally: if not dry: doc.SaveAs2(OUT, FileFormat=WD_FORMAT_DOCX) doc.Close(False) word.Quit() if not dry: print(f"[完成] {OUT}") def pos(doc, a, b): """取字符区间 [a,b) 的页码与页内左上坐标(磅)。""" r = doc.Range(a, b) return (int(r.Information(WD_PAGE)), float(r.Information(WD_HPOS)), float(r.Information(WD_VPOS))) def x_at(doc, i): r = doc.Range(i, i) return float(r.Information(WD_HPOS)) def stamp(doc, path, anchor_a, anchor_b, left, top, width, angle): shp = doc.Shapes.AddPicture(FileName=path, LinkToFile=False, SaveWithDocument=True, Anchor=doc.Range(anchor_a, anchor_b)) shp.LockAspectRatio = MSO_TRUE shp.Width = width shp.WrapFormat.Type = WD_WRAP_NONE shp.WrapFormat.AllowOverlap = MSO_TRUE shp.RelativeHorizontalPosition = WD_REL_PAGE shp.RelativeVerticalPosition = WD_REL_PAGE shp.Left = left shp.Top = top shp.Rotation = angle shp.ZOrder(MSO_IN_FRONT_OF_TEXT) return shp def run(doc, dry): # 不能用 Content.Text 的字符下标当 Range 位置:目录域、页码域等隐藏字符 # 会让两者错位,必须逐段取真实 Range.Start。 t0 = time.time() seals, signs = [], [] for p in doc.Paragraphs: # 用枚举器单遍扫,避免按下标取段 txt = p.Range.Text if len(txt) > 82 or not txt.strip("\r\x07  \t"): continue if RE_SEAL.search(txt) and COMPANY in txt: seals.append((p.Range.Start, txt)) elif RE_SIGN.search(txt): signs.append((p.Range.Start, txt)) print(f"遍历段落用时 {time.time() - t0:.0f}s") print(f"识别:盖章行 {len(seals)} 处,签字行 {len(signs)} 处\n") # AddPicture 会在正文插入一个锚点字符,使其后所有 Range.Start 后移。 # 因此先只读地算完全部坐标,再按文档倒序插入,避免锚点漂移。 plan = [] ps = doc.Sections(1).PageSetup right_edge = float(ps.PageWidth) - float(ps.RightMargin) # --- 单位公章:盖在单位名称上,略偏右下,每枚角度/大小/落点各不相同 n, boxes = 0, {} for start, txt in seals: i = txt.find(COMPANY) a, b = start + i, start + i + len(COMPANY) got = doc.Range(a, b).Text if got != COMPANY: print(f" !定位偏移,跳过:期望「{COMPANY}」实得「{got}」") continue page, x0, y = pos(doc, a, b) x1 = x_at(doc, b) if x1 <= x0: # 跨行等异常,退回按名称估宽 x1 = x0 + len(COMPANY) * 12 ang, sc, r = wobble("gz", n, 1.8, 8.5, 0.018) cx = (x0 + x1) / 2 + r.uniform(-5, 10) cy = y + r.uniform(5, 9) left, top, w = GZ.box(cx, cy, sc) print(f" 公章 P{page:>3} x{left:6.1f} y{top:6.1f} " f"{GZ.ink * sc / MM:4.1f}mm {ang:+5.1f}° {txt.strip()[:34]}") plan.append((a, b, GZ.path, left, top, w, ang, False)) boxes.setdefault(page, []).append((left, top, left + w, top + w)) n += 1 # --- 签名 + 法人印鉴:落在签字行的空档处 m = 0 for start, txt in signs: u = RE_UNDER.search(txt) if u: # 有下划线:写在下划线上 a, b = start + u.start(), start + u.end() else: c = txt.rfind(":") k = txt.find("(签") if k > c >= 0: # 「:」与「(签」之间的空档 a, b = start + c + 1, start + k elif c >= 0: # 「:」之后 a, b = start + c + 1, start + len(txt) else: continue page, x0, y = pos(doc, a, b) x1 = x_at(doc, b) sg_ang, sg_sc, r = wobble("sg", m, 0.6, 3.2, 0.04) yj_ang, yj_sc, r2 = wobble("yj", m, 1.2, 5.5, 0.015) sg_w, yj_w = SG.ink * sg_sc, YJ.ink * yj_sc need = sg_w + yj_w * 0.85 span = max(x1 - x0, need) sg_left = x0 + max(6.0, (span - need) / 2) + r.uniform(-2, 4) # 公章 42mm 高,必然压到下一行的签字栏。签名叠在章上层(真实标书就是 # 这样),右边还有余量时再把签名挪出章外,让两者都看得清。 for bl, bt, br, bb in boxes.get(page, ()): if bt < y + sg_w * 0.3 and bb > y - sg_w * 0.3 and br > sg_left: sg_left = min(max(sg_left, br - 8.0), right_edge - need) cy = y + 3 + r.uniform(-1.5, 2.5) sl, st, sw = SG.box(sg_left + sg_w / 2, cy, sg_sc) yl, yt, yw = YJ.box(sg_left + sg_w * 0.82 + yj_w / 2, y + 6 + r2.uniform(-1, 2), yj_sc) print(f" 签名 P{page:>3} x{sl:6.1f} y{st:6.1f} " f"{sg_w / MM:4.1f}mm {sg_ang:+5.1f}° 印鉴 {yj_w / MM:4.1f}mm " f"{yj_ang:+5.1f}° {txt.strip()[:26]}") plan.append((a, b, SG.path, sl, st, sw, sg_ang, True)) plan.append((a, b, YJ.path, yl, yt, yw, yj_ang, True)) m += 1 # --- 身份证复印件页:原文明文「加盖单位公章」,无文字锚点,按书签定位 try: bm = doc.Bookmarks("SEALA1").Range ang, sc, r = wobble("gz", 99, 1.8, 8.5, 0.018) left, top, w = GZ.box(360.0 + r.uniform(-6, 6), 320.0 + r.uniform(-8, 8), sc) print(f" 公章 P{int(bm.Information(WD_PAGE)):>3}" f" x{left:6.1f} y{top:6.1f} {GZ.ink * sc / MM:4.1f}mm {ang:+5.1f}°" f" (法定代表人身份证复印件·加盖单位公章)") plan.append((bm.Start, bm.End, GZ.path, left, top, w, ang, False)) n += 1 except Exception as e: print(f" !身份证复印件页未盖:{type(e).__name__} {e}") print(f"\n合计盖章 {n} 处,签名 {m} 处,法人印鉴 {m} 处") if dry: return # 倒序插入:靠后的锚点先用,前面的锚点位置不受影响 front = [] for a, b, path, left, top, w, ang, on_top in sorted(plan, key=lambda t: -t[0]): shp = stamp(doc, path, a, b, left, top, w, ang) if on_top: front.append(shp) for shp in front: # 签名与印鉴压在红章上层 shp.ZOrder(MSO_BRING_TO_FRONT) print(f"已插入 {len(plan)} 个图形") if __name__ == "__main__": main()