| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- # -*- 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()
|