"""验证 auto-size 和 relax_layout: 1. auto-size:在不同字号/段落行高的段落里插入签名(height_cm=None), 检查最终插入尺寸是否符合预期。 2. relax_layout:当段落/表格行有固定高度约束(lineRule=exact / hRule=exact) 小于签名需要的高度时,自动放宽约束;以及 relax_layout=False 时缩放到约束内。 """ import io import sys import zipfile from lxml import etree as ET from pathlib import Path BASE_DIR = Path(__file__).resolve().parent PROJECT_ROOT = BASE_DIR.parent INPUT_IMAGE = BASE_DIR / "input" / "test1.jpg" OUT_DIR = BASE_DIR / "output" / "auto_size" W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing' def _ensure_path(): if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) def emu_to_cm(emu): return int(emu) / 360000.0 def make_docx(path, scenarios): """scenarios: [(label, font_half_pt, line_twips_or_None), ...] 生成一份 docx,每个 scenario 一段(带 SET USER_xxx),字号和行高按参数设置。 """ from docx import Document from docx.enum.text import WD_LINE_SPACING from lib import field_codes from docx.oxml.ns import qn from docx.oxml import OxmlElement doc = Document() for i, (label, font_half_pt, line_twips) in enumerate(scenarios): p = doc.add_paragraph() # 字号 run = p.add_run(f"") rPr = run._element.get_or_add_rPr() sz = OxmlElement('w:sz') sz.set(qn('w:val'), str(font_half_pt)) rPr.append(sz) sz_cs = OxmlElement('w:szCs') sz_cs.set(qn('w:val'), str(font_half_pt)) rPr.append(sz_cs) # 段落标记也设字号(pPr/rPr/sz) pPr = p._element.get_or_add_pPr() p_rPr = OxmlElement('w:rPr') p_sz = OxmlElement('w:sz') p_sz.set(qn('w:val'), str(font_half_pt)) p_rPr.append(p_sz) pPr.append(p_rPr) # 行高 if line_twips: spacing = OxmlElement('w:spacing') spacing.set(qn('w:line'), str(line_twips)) spacing.set(qn('w:lineRule'), 'exact') pPr.append(spacing) marker_name = f"u{i}" for r in field_codes.build_set_field_runs(marker_name): p._element.append(r) doc.save(str(path)) def make_table_docx(path, scenarios): """scenarios: [(label, font_half_pt, row_height_twips_or_None, hRule, indent_twips_or_None)] 生成一份 docx,每个 scenario 是一个独立的 1×1 表格,单元格内含 SET USER_txxx。 row_height + hRule 控制行高;indent 控制单元格内段落左右缩进。 """ from docx import Document from lib import field_codes from docx.oxml.ns import qn from docx.oxml import OxmlElement doc = Document() for i, (label, font_half_pt, row_h_twips, h_rule, indent_twips) in enumerate(scenarios): table = doc.add_table(rows=1, cols=1) cell = table.rows[0].cells[0] # 行高 if row_h_twips: trPr = table.rows[0]._tr.get_or_add_trPr() trHeight = OxmlElement('w:trHeight') trHeight.set(qn('w:val'), str(row_h_twips)) trHeight.set(qn('w:hRule'), h_rule or 'atLeast') trPr.append(trHeight) # 单元格宽度(固定 5cm 便于断言) tcPr = cell._tc.get_or_add_tcPr() tcW = OxmlElement('w:tcW') tcW.set(qn('w:w'), str(int(5 * 566.93))) tcW.set(qn('w:type'), 'dxa') tcPr.append(tcW) p = cell.paragraphs[0] # 字号 run = p.add_run(f"") rPr = run._element.get_or_add_rPr() sz = OxmlElement('w:sz') sz.set(qn('w:val'), str(font_half_pt)) rPr.append(sz) # 段落缩进 if indent_twips: ppPr = p._element.get_or_add_pPr() ind = OxmlElement('w:ind') ind.set(qn('w:left'), str(indent_twips)) ind.set(qn('w:right'), str(indent_twips)) ppPr.append(ind) marker_name = f"u0" if len(scenarios) == 1 else f"t{i}" for r in field_codes.build_set_field_runs(marker_name): p._element.append(r) doc.add_paragraph() # 表格之间留空段 doc.save(str(path)) def _sign(docx_path, height, relax_layout=True): """调用 sign_word 签出 docx,返回 signed bytes。""" import base64 from lib.word_signer import sign_word with open(docx_path, 'rb') as f: docx_data = f.read() with open(INPUT_IMAGE, 'rb') as f: image_bytes = f.read() stamps = [{'marker': 'USER_u0', 'image_base64': base64.b64encode(image_bytes).decode(), 'image_filename': 'test1.jpg', 'is_signature': True}] params = { 'docx_name': 'input.docx', 'match_mode': 'upload', 'stamps': stamps, 'use_global_height': True, 'height': height, 'relax_layout': relax_layout, } result, err = sign_word(docx_data, params, log_source='auto测试', trace_id=f'relax_{int(relax_layout)}') if err: print(f"sign_word 失败: {err}") sys.exit(1) return result['data'] def _read_xml(signed_bytes, member='word/document.xml'): with zipfile.ZipFile(io.BytesIO(signed_bytes)) as z: return z.read(member) def _ns(tag, ns): return f'{{{ns}}}{tag}' def test_auto_size(src_docx, scenarios): """原 auto-size 测试:height=None,按字号/行高推算。""" import base64 from lib.word_signer import sign_word with open(src_docx, 'rb') as f: docx_data = f.read() with open(INPUT_IMAGE, 'rb') as f: image_bytes = f.read() params = { 'docx_name': 'input.docx', 'match_mode': 'upload', 'stamps': [{'marker': f'USER_u{i}', 'image_base64': base64.b64encode(image_bytes).decode(), 'image_filename': 'test1.jpg', 'is_signature': True} for i in range(len(scenarios))], 'use_global_height': True, 'height': None, } result, err = sign_word(docx_data, params, log_source='auto测试', trace_id='auto') if err: print(f"sign_word 失败: {err}") sys.exit(1) signed = OUT_DIR / "signed_auto.docx" with open(signed, 'wb') as f: f.write(result['data']) root = ET.fromstring(_read_xml(result['data'])) print(f"\n=== auto-size 测试 (height=None) ===\n") print(f"{'场景':<22} {'字号':<8} {'行高':<10} {'图片显示尺寸':<20} {'判定'}") print("-" * 80) para_idx = 0 pass_cnt = 0 for p in root.iter(_ns('p', W)): inlines = list(p.iter(_ns('inline', WP))) if not inlines: continue if para_idx >= len(scenarios): break label, font_half, line_twips = scenarios[para_idx] font_pt = font_half / 2.0 line_cm = (line_twips / 567.0) if line_twips else None extent = inlines[0].find(_ns('extent', WP)) cx = emu_to_cm(extent.get('cx')) cy = emu_to_cm(extent.get('cy')) if line_cm: expected = round(line_cm * 0.95, 2) ok = abs(cy - expected) < 0.05 verdict = f"{'✓' if ok else '✗'} ≈行高95%(期望{expected}cm)" else: expected = round(font_pt * 2 * 2.54 / 72, 2) ok = abs(cy - expected) < 0.05 verdict = f"{'✓' if ok else '✗'} ≈2倍字号(期望{expected}cm)" if ok: pass_cnt += 1 print(f"{label:<22} {font_pt:<8.1f} {f'{line_cm:.2f}cm' if line_cm else '无':<10} " f"{cx:.2f}×{cy:.2f}cm{'':<10} {verdict}") para_idx += 1 print(f"\nauto-size: {pass_cnt}/{len(scenarios)} 通过") def test_relax_paragraph(): """段落行高约束 + 用户指定 height 大于行高 → 应放宽到 height。""" scenarios = [ # (label, line_twips, expected_relax_to_height_cm) ("行高exact 0.5cm + 目标2cm(应放宽)", 283, 2.0), ("行高exact 5cm + 目标2cm(无需放宽)", 2835, 2.0), ] print(f"\n=== relax_layout 段落行高测试 (height=2cm) ===\n") print(f"{'场景':<40} {'lineRule':<10} {'line(twips)':<12} {'图片高':<10} {'判定'}") print("-" * 90) pass_cnt = 0 for label, line_twips, target_h in scenarios: src = OUT_DIR / f"para_{line_twips}.docx" make_docx(src, [(label, 21, line_twips)]) signed = _sign(src, height=target_h, relax_layout=True) root = ET.fromstring(_read_xml(signed)) # 找到 SET 域所在段落的 pPr/spacing p_with_image = None for p in root.iter(_ns('p', W)): if list(p.iter(_ns('inline', WP))): p_with_image = p break pPr = p_with_image.find(_ns('pPr', W)) if p_with_image is not None else None spacing = pPr.find(_ns('spacing', W)) if pPr is not None else None line_rule = spacing.get(_ns('lineRule', W)) if spacing is not None else None line_val = spacing.get(_ns('line', W)) if spacing is not None else None extent = list(p_with_image.iter(_ns('extent', WP)))[0] img_h = emu_to_cm(extent.get('cy')) line_cm_input = line_twips / 567.0 if line_cm_input < target_h: expected_rule = 'atLeast' expected_line = int(target_h * 566.93 + 0.999) ok = (line_rule == expected_rule and abs(int(line_val) - expected_line) <= 1 and abs(img_h - target_h) < 0.05) verdict = f"{'✓' if ok else '✗'} 期望 atLeast/{expected_line}" else: expected_rule = 'exact' ok = (line_rule == expected_rule and abs(img_h - target_h) < 0.05) verdict = f"{'✓' if ok else '✗'} 期望保持 exact" if ok: pass_cnt += 1 print(f"{label:<40} {line_rule or '无':<10} {line_val or '无':<12} " f"{img_h:.2f}cm {verdict}") print(f"\nrelax 段落行高: {pass_cnt}/{len(scenarios)} 通过") def test_relax_table_row(): """表格行固定高度 + 用户指定 height 大于行高 → 应放宽。""" scenarios = [ # (label, row_h_twips, hRule, expected_action) ("表格行 exact 1cm + 目标2cm", 567, 'exact', 'relax'), ("表格行 atLeast 1cm + 目标2cm", 567, 'atLeast', 'keep'), ] print(f"\n=== relax_layout 表格行高测试 (height=2cm) ===\n") print(f"{'场景':<35} {'hRule':<10} {'val(twips)':<12} {'图片高':<10} {'判定'}") print("-" * 85) pass_cnt = 0 for label, row_h, h_rule_in, expected in scenarios: src = OUT_DIR / f"table_{h_rule_in}_{row_h}.docx" make_table_docx(src, [(label, 21, row_h, h_rule_in, None)]) signed = _sign(src, height=2.0, relax_layout=True) root = ET.fromstring(_read_xml(signed)) # 找到含图片的表格行 target_tr = None for p in root.iter(_ns('p', W)): if list(p.iter(_ns('inline', WP))): parent = p.getparent() while parent is not None and parent.tag != _ns('tr', W): parent = parent.getparent() target_tr = parent break trPr = target_tr.find(_ns('trPr', W)) if target_tr is not None else None trHeight = trPr.find(_ns('trHeight', W)) if trPr is not None else None h_rule_out = trHeight.get(_ns('hRule', W)) if trHeight is not None else None val_out = trHeight.get(_ns('val', W)) if trHeight is not None else None extent = list(target_tr.iter(_ns('extent', WP)))[0] img_h = emu_to_cm(extent.get('cy')) if expected == 'relax': expected_rule = 'atLeast' expected_val = int(2.0 * 566.93 + 0.999) ok = (h_rule_out == expected_rule and abs(int(val_out) - expected_val) <= 1 and abs(img_h - 2.0) < 0.05) verdict = f"{'✓' if ok else '✗'} 期望 atLeast/{expected_val}" else: ok = (h_rule_out == h_rule_in and abs(int(val_out) - row_h) <= 1 and abs(img_h - 2.0) < 0.05) verdict = f"{'✓' if ok else '✗'} 期望保持 {h_rule_in}/{row_h}" if ok: pass_cnt += 1 print(f"{label:<35} {h_rule_out or '无':<10} {val_out or '无':<12} " f"{img_h:.2f}cm {verdict}") print(f"\nrelax 表格行高: {pass_cnt}/{len(scenarios)} 通过") def test_relax_off(): """relax_layout=False + line=exact 0.5cm + height=2cm → 缩放到 ≤0.5cm,不放宽。""" print(f"\n=== relax_layout=false 严格模式测试 ===\n") src = OUT_DIR / "para_strict.docx" make_docx(src, [("strict", 21, 283)]) # 行高 0.5cm exact signed = _sign(src, height=2.0, relax_layout=False) root = ET.fromstring(_read_xml(signed)) p_with_image = None for p in root.iter(_ns('p', W)): if list(p.iter(_ns('inline', WP))): p_with_image = p break pPr = p_with_image.find(_ns('pPr', W)) spacing = pPr.find(_ns('spacing', W)) line_rule = spacing.get(_ns('lineRule', W)) line_val = spacing.get(_ns('line', W)) extent = list(p_with_image.iter(_ns('extent', WP)))[0] img_h = emu_to_cm(extent.get('cy')) ok = (line_rule == 'exact' and line_val == '283' and img_h <= 0.5 + 0.01) verdict = f"{'✓' if ok else '✗'} lineRule={line_rule}/line={line_val}/img_h={img_h:.2f}cm(期望 exact/283/≤0.5cm)" print(f" {verdict}") print(f"\nrelax off: {'1/1' if ok else '0/1'} 通过") def test_no_paragraph_font_size(): """段落无 时,应走文档默认字号(Normal 样式 → docDefaults → 10.5pt), 验证不会回到 default_cm=2.0cm。 """ from docx import Document from lib import field_codes print(f"\n=== 段落无 sz 字号 fallback 测试 ===\n") src = OUT_DIR / "no_sz.docx" doc = Document() p = doc.add_paragraph() for r in field_codes.build_set_field_runs('u0'): p._element.append(r) doc.save(str(src)) signed = _sign(src, height=None, relax_layout=True) root = ET.fromstring(_read_xml(signed)) p_with_image = None for p in root.iter(_ns('p', W)): if list(p.iter(_ns('inline', WP))): p_with_image = p break extent = list(p_with_image.iter(_ns('extent', WP)))[0] img_h = emu_to_cm(extent.get('cy')) # 模拟 _get_doc_default_font_size_pt 的优先级读 styles.xml styles_root = ET.fromstring(_read_xml(signed, 'word/styles.xml')) default_pt = None for style in styles_root.iter(_ns('style', W)): if style.get(_ns('styleId', W)) != 'Normal': continue rPr = style.find(_ns('rPr', W)) if rPr is not None: sz = rPr.find(_ns('sz', W)) if sz is not None: val = sz.get(_ns('val', W)) if val: default_pt = int(val) / 2.0 break if default_pt is None: doc_defaults = styles_root.find(_ns('docDefaults', W)) if doc_defaults is not None: rpr_default = doc_defaults.find(_ns('rPrDefault', W)) if rpr_default is not None: rpr = rpr_default.find(_ns('rPr', W)) if rpr is not None: sz = rpr.find(_ns('sz', W)) if sz is not None: val = sz.get(_ns('val', W)) if val: default_pt = int(val) / 2.0 if default_pt: expected = round(default_pt * 2 * 2.54 / 72, 2) expected_src = f"文档默认{default_pt}pt×2" else: expected = round(10.5 * 2 * 2.54 / 72, 2) expected_src = "10.5pt fallback×2" # 关键断言:不能是 default_cm=2.0 not_default = abs(img_h - 2.0) > 0.05 ok = not_default and abs(img_h - expected) < 0.05 verdict = (f"{'✓' if ok else '✗'} 段落无 sz, 默认={default_pt}pt, " f"期望 {expected}cm ({expected_src}), 实际 {img_h:.2f}cm " f"(必须 ≠ 2.0cm default_cm)") print(f" {verdict}") print(f"\nno_font_size: {'1/1' if ok else '0/1'} 通过") def test_no_spacing_with_table_atleast(): """复刻真实 bug:段落无 spacing + 表格行 atLeast 过小 + 字号 14pt + height=2cm 旧版 relax 函数(只处理 exact)在这种情况下是 no-op,图片被行盒子裁。 修复后应:段落主动加 spacing line=2cm atLeast;表格行 atLeast 保持不动(atLeast 是软约束)。 """ print(f"\n=== 无 spacing + 表格 atLeast 过小 (复刻用户 bug) ===\n") src = OUT_DIR / "no_spacing_atleast.docx" # 567 twips ≈ 1cm atLeast 表格行(小于目标 2cm) make_table_docx(src, [("表格atLeast1cm+无段落spacing", 28, 567, 'atLeast', None)]) signed = _sign(src, height=2.0, relax_layout=True) root = ET.fromstring(_read_xml(signed)) p_with_image = None for p in root.iter(_ns('p', W)): if list(p.iter(_ns('inline', WP))): p_with_image = p break pPr = p_with_image.find(_ns('pPr', W)) spacing = pPr.find(_ns('spacing', W)) line_rule = spacing.get(_ns('lineRule', W)) if spacing is not None else None line_val = spacing.get(_ns('line', W)) if spacing is not None else None # 找到所在表格行 parent = p_with_image.getparent() while parent is not None and parent.tag != _ns('tr', W): parent = parent.getparent() target_tr = parent trPr = target_tr.find(_ns('trPr', W)) if target_tr is not None else None trHeight = trPr.find(_ns('trHeight', W)) if trPr is not None else None h_rule_out = trHeight.get(_ns('hRule', W)) if trHeight is not None else None val_out = trHeight.get(_ns('val', W)) if trHeight is not None else None extent = list(p_with_image.iter(_ns('extent', WP)))[0] img_h = emu_to_cm(extent.get('cy')) expected_line = int(2.0 * 566.93 + 0.999) # 段落 spacing 应被主动创建为 atLeast/expected_line; # 表格行 atLeast 是软约束(不裁切),应保持原值 567 ok = (spacing is not None and line_rule == 'atLeast' and abs(int(line_val) - expected_line) <= 1 and h_rule_out == 'atLeast' and int(val_out) == 567 and abs(img_h - 2.0) < 0.05) verdict = (f"{'✓' if ok else '✗'} " f"段落 spacing={'有' if spacing is not None else '无'}/lineRule={line_rule}/line={line_val} " f"(期望 atLeast/{expected_line});" f"表格 hRule={h_rule_out}/val={val_out}(期望保持 atLeast/567);" f"img_h={img_h:.2f}cm") print(f" {verdict}") print(f"\nno_spacing_atleast: {'1/1' if ok else '0/1'} 通过") def _sign_with_mode(docx_path, height, relax_layout, signature_mode, stamp_mode, is_signature): """扩展 _sign:支持 signature_mode/stamp_mode 透传 + 控制 is_signature。""" import base64 from lib.word_signer import sign_word with open(docx_path, 'rb') as f: docx_data = f.read() with open(INPUT_IMAGE, 'rb') as f: image_bytes = f.read() stamps = [{'marker': 'USER_u0', 'image_base64': base64.b64encode(image_bytes).decode(), 'image_filename': 'test1.jpg', 'is_signature': is_signature}] params = { 'docx_name': 'input.docx', 'match_mode': 'upload', 'stamps': stamps, 'use_global_height': True, 'height': height, 'relax_layout': relax_layout, 'signature_mode': signature_mode, 'stamp_mode': stamp_mode, } result, err = sign_word(docx_data, params, log_source='mode测试', trace_id='mode') if err: print(f"sign_word 失败: {err}") sys.exit(1) return result['data'] def _find_image_root(root): """找到含 inline 或 anchor 图片的 。""" for p in root.iter(_ns('p', W)): if list(p.iter(_ns('inline', WP))) or list(p.iter(_ns('anchor', WP))): return p return None def test_signature_inline_default(): """签名默认走 inline:is_signature=True + 不传 mode → 。""" print(f"\n=== 签名默认 inline 模式(回归)===\n") src = OUT_DIR / "sig_inline.docx" make_docx(src, [("签名默认inline", 21, 283)]) # 行高 exact 0.5cm # 不传 signature_mode/stamp_mode → 应走默认值(signature_mode='inline') signed = _sign_with_mode(src, height=2.0, relax_layout=True, signature_mode='inline', stamp_mode='anchor', is_signature=True) root = ET.fromstring(_read_xml(signed)) p = _find_image_root(root) inlines = list(p.iter(_ns('inline', WP))) if p is not None else [] anchors = list(p.iter(_ns('anchor', WP))) if p is not None else [] ok = len(inlines) == 1 and len(anchors) == 0 verdict = (f"{'✓' if ok else '✗'} inline={len(inlines)}, anchor={len(anchors)} " f"(期望 inline=1, anchor=0)") print(f" {verdict}") print(f"\nsignature_inline_default: {'1/1' if ok else '0/1'} 通过") def test_signature_anchor_mode(): """签名切到 anchor:is_signature=True + signature_mode='anchor' → 。 anchor 不触发 relax_layout,段落 spacing 应保持原状(exact/283)。""" print(f"\n=== 签名 anchor 模式(浮动覆盖)===\n") src = OUT_DIR / "sig_anchor.docx" make_docx(src, [("签名anchor", 21, 283)]) # 行高 exact 0.5cm signed = _sign_with_mode(src, height=2.0, relax_layout=True, signature_mode='anchor', stamp_mode='anchor', is_signature=True) root = ET.fromstring(_read_xml(signed)) p = _find_image_root(root) inlines = list(p.iter(_ns('inline', WP))) if p is not None else [] anchors = list(p.iter(_ns('anchor', WP))) if p is not None else [] pPr = p.find(_ns('pPr', W)) if p is not None else None spacing = pPr.find(_ns('spacing', W)) if pPr is not None else None line_rule = spacing.get(_ns('lineRule', W)) if spacing is not None else None line_val = spacing.get(_ns('line', W)) if spacing is not None else None # 断言1:anchor 元素存在 has_anchor = len(anchors) == 1 and len(inlines) == 0 # 断言2:anchor 不触发 relax —— spacing 应保持原 exact/283 not_relaxed = (line_rule == 'exact' and line_val == '283') # 断言3:anchor 专属属性 anchor_elem = anchors[0] if anchors else None behind_doc = anchor_elem.get('behindDoc') if anchor_elem is not None else None allow_overlap = anchor_elem.get('allowOverlap') if anchor_elem is not None else None has_wrap_none = anchor_elem is not None and anchor_elem.find(_ns('wrapNone', WP)) is not None pos_h = anchor_elem.find(_ns('positionH', WP)) if anchor_elem is not None else None pos_v = anchor_elem.find(_ns('positionV', WP)) if anchor_elem is not None else None # 期望:posH relativeFrom='paragraph' + posOffset=0(贴段落起点,char 模式不可靠) # posV relativeFrom='paragraph' + posOffset=0 h_off_el = pos_h.find(_ns('posOffset', WP)) if pos_h is not None else None h_off_val = int(h_off_el.text) if h_off_el is not None and h_off_el.text else None has_pos = (pos_h is not None and pos_v is not None and pos_h.get('relativeFrom') == 'paragraph' and pos_v.get('relativeFrom') == 'paragraph' and h_off_val == 0) ok = has_anchor and not_relaxed and behind_doc == '0' and allow_overlap == '1' and has_wrap_none and has_pos verdict = (f"{'✓' if ok else '✗'} anchor={len(anchors)}, inline={len(inlines)}; " f"spacing lineRule={line_rule}/line={line_val}(期望保持 exact/283 未放宽); " f"behindDoc={behind_doc}, allowOverlap={allow_overlap}, " f"wrapNone={'有' if has_wrap_none else '无'}, " f"posH relativeFrom=paragraph/posOffset={h_off_val}, " f"posV relativeFrom=paragraph") print(f" {verdict}") print(f"\nsignature_anchor_mode: {'1/1' if ok else '0/1'} 通过") def test_stamp_anchor_default(): """盖章默认走 anchor:is_signature=False + 不传 mode → 。""" print(f"\n=== 盖章默认 anchor 模式(浮动覆盖)===\n") src = OUT_DIR / "stamp_anchor.docx" make_docx(src, [("盖章默认anchor", 21, 283)]) signed = _sign_with_mode(src, height=2.0, relax_layout=True, signature_mode='inline', stamp_mode='anchor', is_signature=False) root = ET.fromstring(_read_xml(signed)) p = _find_image_root(root) inlines = list(p.iter(_ns('inline', WP))) if p is not None else [] anchors = list(p.iter(_ns('anchor', WP))) if p is not None else [] anchor_elem = anchors[0] if anchors else None behind_doc = anchor_elem.get('behindDoc') if anchor_elem is not None else None has_wrap_none = anchor_elem is not None and anchor_elem.find(_ns('wrapNone', WP)) is not None ok = len(anchors) == 1 and len(inlines) == 0 and behind_doc == '0' and has_wrap_none verdict = (f"{'✓' if ok else '✗'} anchor={len(anchors)}, inline={len(inlines)}, " f"behindDoc={behind_doc}, wrapNone={'有' if has_wrap_none else '无'} " f"(期望 anchor=1, inline=0, behindDoc=0)") print(f" {verdict}") print(f"\nstamp_anchor_default: {'1/1' if ok else '0/1'} 通过") def test_stamp_inline_override(): """盖章切到 inline:is_signature=False + stamp_mode='inline' → 。""" print(f"\n=== 盖章切到 inline(强制嵌入)===\n") src = OUT_DIR / "stamp_inline.docx" make_docx(src, [("盖章强制inline", 21, 283)]) signed = _sign_with_mode(src, height=2.0, relax_layout=True, signature_mode='inline', stamp_mode='inline', is_signature=False) root = ET.fromstring(_read_xml(signed)) p = _find_image_root(root) inlines = list(p.iter(_ns('inline', WP))) if p is not None else [] anchors = list(p.iter(_ns('anchor', WP))) if p is not None else [] # inline 模式下 spacing 应被放宽为 atLeast pPr = p.find(_ns('pPr', W)) if p is not None else None spacing = pPr.find(_ns('spacing', W)) if pPr is not None else None line_rule = spacing.get(_ns('lineRule', W)) if spacing is not None else None ok = len(inlines) == 1 and len(anchors) == 0 and line_rule == 'atLeast' verdict = (f"{'✓' if ok else '✗'} inline={len(inlines)}, anchor={len(anchors)}, " f"spacing lineRule={line_rule}(期望 inline=1, anchor=0, lineRule=atLeast 被放宽)") print(f" {verdict}") print(f"\nstamp_inline_override: {'1/1' if ok else '0/1'} 通过") def test_anchor_offset_estimation(): """SET 域前有 CJK 文字时,anchor 模式 posOffset 应反映前文字宽度估算。 场景:段落 "签字人:" + SET 域(4 个 CJK + 1 全角冒号 = 5 个 CJK 宽度字符) 字号 14pt(四号,half_pt=28)→ 期望偏移 ≈ 5 × 14pt × 12700 EMU/pt = 889000 EMU """ from docx import Document from lib import field_codes from docx.oxml.ns import qn from docx.oxml import OxmlElement print(f"\n=== anchor 模式 posOffset 估算测试 ===\n") src = OUT_DIR / "anchor_offset.docx" doc = Document() p = doc.add_paragraph() # 前缀文字 "签字人:" run = p.add_run("签字人:") rPr = run._element.get_or_add_rPr() sz = OxmlElement('w:sz') sz.set(qn('w:val'), '28') # 14pt rPr.append(sz) for r in field_codes.build_set_field_runs('u0'): p._element.append(r) doc.save(str(src)) signed = _sign_with_mode(src, height=2.0, relax_layout=True, signature_mode='anchor', stamp_mode='anchor', is_signature=True) root = ET.fromstring(_read_xml(signed)) p_with_image = None for para in root.iter(_ns('p', W)): if list(para.iter(_ns('anchor', WP))): p_with_image = para break anchor = list(p_with_image.iter(_ns('anchor', WP)))[0] pos_h = anchor.find(_ns('positionH', WP)) h_off_el = pos_h.find(_ns('posOffset', WP)) h_off_val = int(h_off_el.text) if h_off_el is not None and h_off_el.text else 0 # "签字人:" = 3 个 CJK 字符 + 1 个全角冒号 = 4 个 CJK 宽度 # 期望偏移 = 4 × 14pt × 12700 EMU/pt = 711200 EMU(容差 ±15%) expected_emu = 4 * 14 * 12700 tolerance = int(expected_emu * 0.15) ok = (pos_h.get('relativeFrom') == 'paragraph' and abs(h_off_val - expected_emu) <= tolerance and h_off_val > 0) verdict = (f"{'✓' if ok else '✗'} posH relativeFrom={pos_h.get('relativeFrom')}, " f"posOffset={h_off_val} EMU (≈{h_off_val / 360000:.2f}cm), " f"期望 ≈{expected_emu} EMU (≈{expected_emu / 360000:.2f}cm, 容差 ±15%)") print(f" {verdict}") print(f"\nanchor_offset_estimation: {'1/1' if ok else '0/1'} 通过") def main(): _ensure_path() sys.stdout.reconfigure(encoding='utf-8') OUT_DIR.mkdir(parents=True, exist_ok=True) src_docx = OUT_DIR / "input.docx" # 原 auto-size 场景 auto_scenarios = [ ("小四+行高1cm", 21, 567), ("小四+行高2cm", 21, 1134), ("小四+无行高", 21, None), ("四号+无行高", 28, None), ("小二+无行高", 36, None), ] make_docx(src_docx, auto_scenarios) test_auto_size(src_docx, auto_scenarios) # 新增 relax 场景 test_relax_paragraph() test_relax_table_row() test_relax_off() test_no_spacing_with_table_atleast() test_no_paragraph_font_size() # 新增 anchor/inline 模式切换场景 test_signature_inline_default() test_signature_anchor_mode() test_stamp_anchor_default() test_stamp_inline_override() test_anchor_offset_estimation() if __name__ == '__main__': main()