_bid_apply_seals.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. # -*- coding: utf-8 -*-
  2. """把单位公章、法人印鉴与手写签名盖到总册的各处署名栏,只加浮动图片,不改文字。
  3. 用法:
  4. python doc/_bid_apply_seals.py 生成「(已签章)」副本
  5. python doc/_bid_apply_seals.py --dry 只列出识别到的署名栏,不动文档
  6. """
  7. import io
  8. import os
  9. import random
  10. import re
  11. import sys
  12. import shutil
  13. import time
  14. import numpy as np
  15. from PIL import Image
  16. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
  17. HERE = os.path.dirname(os.path.abspath(__file__))
  18. ONEDRIVE = r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目招投标"
  19. NAME = "响应文件(完整版)-2026年河南产互联制造业数据智能运营平台研发项目.docx"
  20. SRC = os.path.join(ONEDRIVE, NAME)
  21. OUT = os.path.join(ONEDRIVE, NAME[:-5] + "(已签章).docx")
  22. SEAL_DIR = os.path.join(HERE, "_bid_assets", "seal")
  23. GZ_P = os.path.join(SEAL_DIR, "gongzhang.png") # 单位公章(圆)
  24. YJ_P = os.path.join(SEAL_DIR, "yinjian.png") # 法定代表人印鉴(方)
  25. SG_P = os.path.join(SEAL_DIR, "signature.png") # 手写签名
  26. GZ = YJ = SG = None # main() 里按实测墨迹构造
  27. MM = 2.8346457 # 毫米 → 磅
  28. # 实测尺寸:授权书扫描件是整幅 A4(横向 1px=0.0981mm),公章墨迹 440px→43.2mm,
  29. # 含油墨外溢,标称即常规企业公章 42mm;方形法人名章 217px→21mm。签名按自然
  30. # 书写大小给 27mm(扫描件里那处签在小表格格子内,只有 13mm,照搬会小得失真)。
  31. GZ_MM, YJ_MM, SG_MM = 42.0, 21.0, 27.0
  32. COMPANY = "北京智造易科技有限公司"
  33. # Word 常量
  34. WD_PAGE = 3 # wdActiveEndPageNumber
  35. WD_HPOS = 5 # wdHorizontalPositionRelativeToPage
  36. WD_VPOS = 6 # wdVerticalPositionRelativeToPage
  37. WD_REL_PAGE = 1
  38. WD_WRAP_NONE = 3
  39. MSO_TRUE, MSO_FALSE = -1, 0
  40. MSO_IN_FRONT_OF_TEXT = 4
  41. MSO_BRING_TO_FRONT = 0
  42. WD_FORMAT_DOCX = 16
  43. # 盖章行:带盖章括注且写有单位名称
  44. RE_SEAL = re.compile(r"[((](?:盖公章|盖章|盖单位章|签章)[))]|加盖单位公章")
  45. # 签字行:带签字括注
  46. RE_SIGN = re.compile(r"[((]签字(?:或盖章)?[))]|签字或盖章")
  47. # 签名落笔处:优先下划线,其次「:」与「(签」之间的空档,最后「:」之后
  48. RE_UNDER = re.compile(r"[__]{2,}")
  49. class Asset:
  50. """透明 PNG 四周留了空白,按「墨迹」而非整图定尺寸与中心,摆放才准。"""
  51. def __init__(self, path, ink_mm):
  52. al = np.asarray(Image.open(path))[..., 3]
  53. ys, xs = np.nonzero(al > 8)
  54. ih, iw = al.shape
  55. kw, kh = xs.max() - xs.min() + 1, ys.max() - ys.min() + 1
  56. self.path = path
  57. self.ink = ink_mm * MM # 墨迹目标宽(磅)
  58. self.w = self.ink * iw / kw # 整图宽
  59. self.h = self.w * ih / iw
  60. self.ink_h = self.h * kh / ih
  61. self.fx = (xs.min() + kw / 2) / iw # 墨迹中心在整图中的相对位置
  62. self.fy = (ys.min() + kh / 2) / ih
  63. def box(self, cx, cy, scale=1.0):
  64. """给定墨迹中心,返回整图左上角与整图宽。"""
  65. w, h = self.w * scale, self.h * scale
  66. return cx - w * self.fx, cy - h * self.fy, w
  67. def rnd(kind, i):
  68. """按「用途 + 序号」取稳定随机源:同一处每次运行结果一致,各处互不相同。"""
  69. return random.Random(f"{kind}|{i}|aidop-20260804")
  70. def wobble(kind, i, ang_lo, ang_hi, scale_amp):
  71. """真章是手工按下去的:角度、大小、落点都略有出入,且不重复。"""
  72. r = rnd(kind, i)
  73. ang = r.choice((-1, 1)) * r.uniform(ang_lo, ang_hi)
  74. return round(ang, 2), 1.0 + r.uniform(-scale_amp, scale_amp), r
  75. def main():
  76. dry = "--dry" in sys.argv
  77. for p in (GZ_P, YJ_P, SG_P):
  78. if not os.path.exists(p):
  79. raise SystemExit(f"缺少印鉴素材:{p}(先跑 _bid_make_seals.py)")
  80. if not os.path.exists(SRC):
  81. raise SystemExit(f"找不到总册:{SRC}")
  82. global GZ, YJ, SG
  83. GZ = Asset(GZ_P, GZ_MM)
  84. YJ = Asset(YJ_P, YJ_MM)
  85. SG = Asset(SG_P, SG_MM)
  86. print(f"印鉴实际尺寸:公章 {GZ_MM}mm 法人印鉴 {YJ_MM}mm 签名宽 {SG_MM}mm")
  87. target = SRC if dry else OUT
  88. if not dry:
  89. if os.path.exists(OUT):
  90. os.remove(OUT)
  91. shutil.copy2(SRC, OUT)
  92. import win32com.client as win32
  93. word = None
  94. for i in range(4):
  95. try:
  96. word = win32.gencache.EnsureDispatch("Word.Application")
  97. word.Visible = False
  98. word.DisplayAlerts = 0
  99. break
  100. except Exception as e: # Word COM 偶发拒绝调用
  101. print(f" 启动 Word 重试 {i + 1}/4:{e}")
  102. time.sleep(4)
  103. if word is None:
  104. raise SystemExit("无法启动 Word")
  105. doc = word.Documents.Open(target, ReadOnly=dry)
  106. try:
  107. run(doc, dry)
  108. finally:
  109. if not dry:
  110. doc.SaveAs2(OUT, FileFormat=WD_FORMAT_DOCX)
  111. doc.Close(False)
  112. word.Quit()
  113. if not dry:
  114. print(f"[完成] {OUT}")
  115. def pos(doc, a, b):
  116. """取字符区间 [a,b) 的页码与页内左上坐标(磅)。"""
  117. r = doc.Range(a, b)
  118. return (int(r.Information(WD_PAGE)),
  119. float(r.Information(WD_HPOS)), float(r.Information(WD_VPOS)))
  120. def x_at(doc, i):
  121. r = doc.Range(i, i)
  122. return float(r.Information(WD_HPOS))
  123. def stamp(doc, path, anchor_a, anchor_b, left, top, width, angle):
  124. shp = doc.Shapes.AddPicture(FileName=path, LinkToFile=False,
  125. SaveWithDocument=True,
  126. Anchor=doc.Range(anchor_a, anchor_b))
  127. shp.LockAspectRatio = MSO_TRUE
  128. shp.Width = width
  129. shp.WrapFormat.Type = WD_WRAP_NONE
  130. shp.WrapFormat.AllowOverlap = MSO_TRUE
  131. shp.RelativeHorizontalPosition = WD_REL_PAGE
  132. shp.RelativeVerticalPosition = WD_REL_PAGE
  133. shp.Left = left
  134. shp.Top = top
  135. shp.Rotation = angle
  136. shp.ZOrder(MSO_IN_FRONT_OF_TEXT)
  137. return shp
  138. def run(doc, dry):
  139. # 不能用 Content.Text 的字符下标当 Range 位置:目录域、页码域等隐藏字符
  140. # 会让两者错位,必须逐段取真实 Range.Start。
  141. t0 = time.time()
  142. seals, signs = [], []
  143. for p in doc.Paragraphs: # 用枚举器单遍扫,避免按下标取段
  144. txt = p.Range.Text
  145. if len(txt) > 82 or not txt.strip("\r\x07  \t"):
  146. continue
  147. if RE_SEAL.search(txt) and COMPANY in txt:
  148. seals.append((p.Range.Start, txt))
  149. elif RE_SIGN.search(txt):
  150. signs.append((p.Range.Start, txt))
  151. print(f"遍历段落用时 {time.time() - t0:.0f}s")
  152. print(f"识别:盖章行 {len(seals)} 处,签字行 {len(signs)} 处\n")
  153. # AddPicture 会在正文插入一个锚点字符,使其后所有 Range.Start 后移。
  154. # 因此先只读地算完全部坐标,再按文档倒序插入,避免锚点漂移。
  155. plan = []
  156. ps = doc.Sections(1).PageSetup
  157. right_edge = float(ps.PageWidth) - float(ps.RightMargin)
  158. # --- 单位公章:盖在单位名称上,略偏右下,每枚角度/大小/落点各不相同
  159. n, boxes = 0, {}
  160. for start, txt in seals:
  161. i = txt.find(COMPANY)
  162. a, b = start + i, start + i + len(COMPANY)
  163. got = doc.Range(a, b).Text
  164. if got != COMPANY:
  165. print(f" !定位偏移,跳过:期望「{COMPANY}」实得「{got}」")
  166. continue
  167. page, x0, y = pos(doc, a, b)
  168. x1 = x_at(doc, b)
  169. if x1 <= x0: # 跨行等异常,退回按名称估宽
  170. x1 = x0 + len(COMPANY) * 12
  171. ang, sc, r = wobble("gz", n, 1.8, 8.5, 0.018)
  172. cx = (x0 + x1) / 2 + r.uniform(-5, 10)
  173. cy = y + r.uniform(5, 9)
  174. left, top, w = GZ.box(cx, cy, sc)
  175. print(f" 公章 P{page:>3} x{left:6.1f} y{top:6.1f} "
  176. f"{GZ.ink * sc / MM:4.1f}mm {ang:+5.1f}° {txt.strip()[:34]}")
  177. plan.append((a, b, GZ.path, left, top, w, ang, False))
  178. boxes.setdefault(page, []).append((left, top, left + w, top + w))
  179. n += 1
  180. # --- 签名 + 法人印鉴:落在签字行的空档处
  181. m = 0
  182. for start, txt in signs:
  183. u = RE_UNDER.search(txt)
  184. if u: # 有下划线:写在下划线上
  185. a, b = start + u.start(), start + u.end()
  186. else:
  187. c = txt.rfind(":")
  188. k = txt.find("(签")
  189. if k > c >= 0: # 「:」与「(签」之间的空档
  190. a, b = start + c + 1, start + k
  191. elif c >= 0: # 「:」之后
  192. a, b = start + c + 1, start + len(txt)
  193. else:
  194. continue
  195. page, x0, y = pos(doc, a, b)
  196. x1 = x_at(doc, b)
  197. sg_ang, sg_sc, r = wobble("sg", m, 0.6, 3.2, 0.04)
  198. yj_ang, yj_sc, r2 = wobble("yj", m, 1.2, 5.5, 0.015)
  199. sg_w, yj_w = SG.ink * sg_sc, YJ.ink * yj_sc
  200. need = sg_w + yj_w * 0.85
  201. span = max(x1 - x0, need)
  202. sg_left = x0 + max(6.0, (span - need) / 2) + r.uniform(-2, 4)
  203. # 公章 42mm 高,必然压到下一行的签字栏。签名叠在章上层(真实标书就是
  204. # 这样),右边还有余量时再把签名挪出章外,让两者都看得清。
  205. for bl, bt, br, bb in boxes.get(page, ()):
  206. if bt < y + sg_w * 0.3 and bb > y - sg_w * 0.3 and br > sg_left:
  207. sg_left = min(max(sg_left, br - 8.0), right_edge - need)
  208. cy = y + 3 + r.uniform(-1.5, 2.5)
  209. sl, st, sw = SG.box(sg_left + sg_w / 2, cy, sg_sc)
  210. yl, yt, yw = YJ.box(sg_left + sg_w * 0.82 + yj_w / 2,
  211. y + 6 + r2.uniform(-1, 2), yj_sc)
  212. print(f" 签名 P{page:>3} x{sl:6.1f} y{st:6.1f} "
  213. f"{sg_w / MM:4.1f}mm {sg_ang:+5.1f}° 印鉴 {yj_w / MM:4.1f}mm "
  214. f"{yj_ang:+5.1f}° {txt.strip()[:26]}")
  215. plan.append((a, b, SG.path, sl, st, sw, sg_ang, True))
  216. plan.append((a, b, YJ.path, yl, yt, yw, yj_ang, True))
  217. m += 1
  218. # --- 身份证复印件页:原文明文「加盖单位公章」,无文字锚点,按书签定位
  219. try:
  220. bm = doc.Bookmarks("SEALA1").Range
  221. ang, sc, r = wobble("gz", 99, 1.8, 8.5, 0.018)
  222. left, top, w = GZ.box(360.0 + r.uniform(-6, 6),
  223. 320.0 + r.uniform(-8, 8), sc)
  224. print(f" 公章 P{int(bm.Information(WD_PAGE)):>3}"
  225. f" x{left:6.1f} y{top:6.1f} {GZ.ink * sc / MM:4.1f}mm {ang:+5.1f}°"
  226. f" (法定代表人身份证复印件·加盖单位公章)")
  227. plan.append((bm.Start, bm.End, GZ.path, left, top, w, ang, False))
  228. n += 1
  229. except Exception as e:
  230. print(f" !身份证复印件页未盖:{type(e).__name__} {e}")
  231. print(f"\n合计盖章 {n} 处,签名 {m} 处,法人印鉴 {m} 处")
  232. if dry:
  233. return
  234. # 倒序插入:靠后的锚点先用,前面的锚点位置不受影响
  235. front = []
  236. for a, b, path, left, top, w, ang, on_top in sorted(plan, key=lambda t: -t[0]):
  237. shp = stamp(doc, path, a, b, left, top, w, ang)
  238. if on_top:
  239. front.append(shp)
  240. for shp in front: # 签名与印鉴压在红章上层
  241. shp.ZOrder(MSO_BRING_TO_FRONT)
  242. print(f"已插入 {len(plan)} 个图形")
  243. if __name__ == "__main__":
  244. main()