|
@@ -0,0 +1,289 @@
|
|
|
|
|
+# -*- 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()
|