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

515 lines
19 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 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()