Files

738 lines
30 KiB
Python
Raw Permalink 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'} 通过")
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():
"""签名默认走 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'} 通过")
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()