Files
SignatureSystem/image_test/auto_size_test.py
2026-07-20 16:43:08 +08:00

738 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""验证 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():
"""段落无 <w:sz> 时应走文档默认字号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 图片的 <w:p>。"""
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():
"""签名默认走 inlineis_signature=True + 不传 mode → <wp:inline>。"""
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():
"""签名切到 anchoris_signature=True + signature_mode='anchor' → <wp: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
# 断言1anchor 元素存在
has_anchor = len(anchors) == 1 and len(inlines) == 0
# 断言2anchor 不触发 relax —— spacing 应保持原 exact/283
not_relaxed = (line_rule == 'exact' and line_val == '283')
# 断言3anchor 专属属性
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():
"""盖章默认走 anchoris_signature=False + 不传 mode → <wp:anchor>。"""
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():
"""盖章切到 inlineis_signature=False + stamp_mode='inline' → <wp: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()