515 lines
19 KiB
Python
515 lines
19 KiB
Python
|
|
"""验证 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()
|