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'} 通过")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 16:43:08 +08:00
|
|
|
|
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():
|
|
|
|
|
|
"""签名默认走 inline:is_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():
|
|
|
|
|
|
"""签名切到 anchor:is_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
|
|
|
|
|
|
|
|
|
|
|
|
# 断言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 → <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():
|
|
|
|
|
|
"""盖章切到 inline:is_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'} 通过")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 13:16:17 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-07-20 16:43:08 +08:00
|
|
|
|
# 新增 anchor/inline 模式切换场景
|
|
|
|
|
|
test_signature_inline_default()
|
|
|
|
|
|
test_signature_anchor_mode()
|
|
|
|
|
|
test_stamp_anchor_default()
|
|
|
|
|
|
test_stamp_inline_override()
|
|
|
|
|
|
test_anchor_offset_estimation()
|
|
|
|
|
|
|
2026-07-20 13:16:17 +08:00
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
|
main()
|