Files
SignatureSystem/image_test/auto_size_test.py

515 lines
19 KiB
Python
Raw Normal View History

2026-07-20 13:16:17 +08:00
"""验证 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 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()
if __name__ == '__main__':
main()