first commit

This commit is contained in:
lihansani
2026-07-20 13:16:17 +08:00
commit 7affeaa253
235 changed files with 42351 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,514 @@
"""验证 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()

View File

@@ -0,0 +1,82 @@
"""对比"项目实际产出的处理图" vs "测试脚本产出的 final.png"
使用前置条件:
1. 已经在前端跑过一次签章image_test/output/project_run/ 下有 _2_processed.png
2. 已经跑过 image_test/process.pyimage_test/output/test1/final.png 存在
输出:
逐文件对比尺寸/字节/SHA256给出"完全一致 / 仅尺寸相同 / 完全不同"的判定。
"""
import sys
import hashlib
from pathlib import Path
from PIL import Image, ImageChops
BASE_DIR = Path(__file__).resolve().parent
PROJECT_RUN_DIR = BASE_DIR / "output" / "project_run"
TEST_FINAL = BASE_DIR / "output" / "test1" / "final.png"
def stats(data):
img = Image.open(__import__('io').BytesIO(data))
if img.mode != 'RGBA':
img = img.convert('RGBA')
r, g, b, a = img.split()
luma = Image.merge('RGB', (r, g, b)).convert('L')
am = a.point(lambda p: 255 if p > 30 else 0)
lm = luma.point(lambda p: 255 if p < 210 else 0)
combined = ImageChops.darker(am, lm)
total = img.size[0] * img.size[1] or 1
return {
'size': img.size,
'bbox': combined.getbbox(),
'content_pct': 100.0 * sum(combined.histogram()[1:]) / total,
'sha': hashlib.sha256(data).hexdigest()[:12],
'bytes': len(data),
}
def main():
sys.stdout.reconfigure(encoding='utf-8')
if not TEST_FINAL.exists():
print(f"缺少基准文件: {TEST_FINAL}")
print("请先运行: python image_test/process.py")
sys.exit(1)
if not PROJECT_RUN_DIR.exists():
print(f"缺少项目运行输出目录: {PROJECT_RUN_DIR}")
print("请先在前端跑一次签章(已重启服务)")
sys.exit(1)
processed_files = sorted(PROJECT_RUN_DIR.glob("*_2_processed.png"))
if not processed_files:
print(f"{PROJECT_RUN_DIR} 下未找到 *_2_processed.png 文件")
sys.exit(1)
final_data = TEST_FINAL.read_bytes()
final_s = stats(final_data)
print(f"=== 基准: {TEST_FINAL.name} ===")
print(f" 尺寸={final_s['size']} bbox={final_s['bbox']} "
f"内容={final_s['content_pct']:.1f}% bytes={final_s['bytes']} sha={final_s['sha']}")
print()
print(f"=== 找到 {len(processed_files)} 个项目输出,逐个对比 ===\n")
for p in processed_files:
data = p.read_bytes()
s = stats(data)
identical = (data == final_data)
same_size = (s['size'] == final_s['size'])
print(f"[{p.name}]")
print(f" 尺寸={s['size']} bbox={s['bbox']} 内容={s['content_pct']:.1f}% "
f"bytes={s['bytes']} sha={s['sha']}")
if identical:
print(f" 判定: ✓ 与 final.png 逐字节完全一致")
elif same_size:
print(f" 判定: △ 尺寸相同但字节不同(可能只是 PNG 编码差异,目视应该一样)")
else:
print(f" 判定: ✗ 与 final.png 不同!尺寸={s['size']} vs {final_s['size']}")
print()
if __name__ == '__main__':
main()

150
image_test/e2e_test.py Normal file
View File

@@ -0,0 +1,150 @@
"""端到端验证process_image 输出 vs 实际写入 docx 的图片字节。
目的:
用户反馈"从文档复制出来的图底部有 1/4 空白",但独立测试 process_image
输出已是紧致裁剪。本脚本走完整 sign_word 链路,把生成的 docx 解开来看
word/media/ 下真实的图片字节,对比 process_image 的输出,定位空白来源:
- 若字节完全一致 → 空白来自 Word 段落渲染(行高/对齐),不是图片本身
- 若字节不一致 → 我们的链路在某个环节给图片加了 padding需进一步定位
使用:
python image_test/e2e_test.py
"""
import io
import sys
import zipfile
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" / "e2e"
DOCX_OUT = OUT_DIR / "signed.docx"
def _ensure_project_on_path():
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
def _make_test_docx(path):
"""构造一份带 SET USER_test 域代码的 docx正文段落使用偏大行高。
段落行高 2cmlineRule=exact模拟用户文档里的固定行高场景。
"""
from docx import Document
from docx.shared import Cm, Pt
from docx.enum.text import WD_LINE_SPACING
from lib import field_codes
doc = Document()
p = doc.add_paragraph()
p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
p.paragraph_format.line_spacing = Cm(2.0)
p.add_run("正文前 ")
runs = field_codes.build_set_field_runs("test")
for r in runs:
p._element.append(r)
p.add_run(" 正文中 ")
p.add_run("正文后")
doc.save(str(path))
def _image_stats(data):
from PIL import Image, ImageChops
img = Image.open(io.BytesIO(data))
if img.mode != 'RGBA':
img = img.convert('RGBA')
r, g, b, a = img.split()
luma = Image.merge('RGB', (r, g, b)).convert('L')
alpha_mask = a.point(lambda p: 255 if p > 30 else 0)
luma_mask = luma.point(lambda p: 255 if p < 210 else 0)
combined = ImageChops.darker(alpha_mask, luma_mask)
total = img.size[0] * img.size[1] or 1
return {
'size': img.size,
'mode': img.mode,
'content_pct': 100.0 * sum(combined.histogram()[1:]) / total,
'alpha_pct': 100.0 * sum(alpha_mask.histogram()[1:]) / total,
'bbox': combined.getbbox(),
'sha256': __import__('hashlib').sha256(data).hexdigest()[:16],
}
def main():
_ensure_project_on_path()
if not INPUT_IMAGE.exists():
print(f"测试图片不存在: {INPUT_IMAGE}")
sys.exit(1)
OUT_DIR.mkdir(parents=True, exist_ok=True)
src_docx = OUT_DIR / "input.docx"
_make_test_docx(src_docx)
with open(src_docx, 'rb') as f:
docx_data = f.read()
with open(INPUT_IMAGE, 'rb') as f:
image_bytes = f.read()
from lib.image_processor import process_image
from lib.word_signer import sign_word
processed = process_image(
image_bytes, is_signature=True,
noise_level=0, rotate_min=0, rotate_max=0,
log_source='e2e测试',
)
params = {
'docx_name': 'input.docx',
'match_mode': 'upload',
'stamps': [{
'marker': 'USER_test',
'image_base64': __import__('base64').b64encode(image_bytes).decode(),
'image_filename': 'test1.jpg',
'is_signature': True,
}],
'use_global_height': True,
'height': 2.5,
}
result, err = sign_word(docx_data, params, log_source='e2e测试', trace_id='e2e')
if err:
print(f"sign_word 失败: {err}")
sys.exit(1)
with open(DOCX_OUT, 'wb') as f:
f.write(result['data'])
print(f"已生成: {DOCX_OUT}")
print(f"warnings: {result.get('warnings', [])}")
print(f"success_count: {result.get('success_count', 0)}")
print()
print("=== process_image 直出 ===")
s1 = _image_stats(processed)
print(f" 尺寸={s1['size']} 内容={s1['content_pct']:.1f}% "
f"alpha={s1['alpha_pct']:.1f}% bbox={s1['bbox']} sha={s1['sha256']}")
print()
print("=== docx 内 word/media/ 实际图片 ===")
with zipfile.ZipFile(DOCX_OUT, 'r') as z:
medias = sorted(n for n in z.namelist() if n.startswith('word/media/'))
if not medias:
print(" 未找到任何图片!")
return
for name in medias:
data = z.read(name)
s2 = _image_stats(data)
same = "一致" if data == processed else "不一致"
print(f" [{name}] 尺寸={s2['size']} 内容={s2['content_pct']:.1f}% "
f"alpha={s2['alpha_pct']:.1f}% bbox={s2['bbox']} sha={s2['sha256']} 字节={same}")
print(f" 原始 {len(processed)} bytes vs docx {len(data)} bytes")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,81 @@
"""从 docx 提取所有内嵌图片,方便定位实际插入到文档里的图片字节。
用法:
python image_test/extract_docx_images.py <signed.docx>
输出:
image_test/output/docx_images/<图片名>.png (按 docx 内顺序编号)
每张图打印尺寸 + 紧致度诊断
"""
import io
import sys
import zipfile
from pathlib import Path
from PIL import Image, ImageChops
BASE_DIR = Path(__file__).resolve().parent
OUT_DIR = BASE_DIR / "output" / "docx_images"
def analyze_image(data):
img = Image.open(io.BytesIO(data))
if img.mode != 'RGBA':
img = img.convert('RGBA')
r, g, b, a = img.split()
luma = Image.merge('RGB', (r, g, b)).convert('L')
alpha_mask = a.point(lambda p: 255 if p > 30 else 0)
luma_mask = luma.point(lambda p: 255 if p < 210 else 0)
combined = ImageChops.darker(alpha_mask, luma_mask)
total = img.size[0] * img.size[1] or 1
return {
'size': img.size,
'mode': img.mode,
'content_pct': 100.0 * sum(combined.histogram()[1:]) / total,
'bbox': combined.getbbox(),
'alpha_pct': 100.0 * sum(alpha_mask.histogram()[1:]) / total,
}
def main():
if len(sys.argv) < 2:
print("用法: python image_test/extract_docx_images.py <signed.docx>")
sys.exit(1)
docx_path = Path(sys.argv[1])
if not docx_path.exists():
print(f"文件不存在: {docx_path}")
sys.exit(1)
OUT_DIR.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(docx_path, 'r') as z:
media_files = sorted([n for n in z.namelist()
if n.startswith('word/media/')])
if not media_files:
print("docx 内未找到 word/media/ 下的图片")
return
print(f"找到 {len(media_files)} 张图片\n")
for i, name in enumerate(media_files, 1):
data = z.read(name)
ext = Path(name).suffix or '.png'
out_path = OUT_DIR / f"image_{i:02d}{ext}"
with open(out_path, 'wb') as f:
f.write(data)
try:
stats = analyze_image(data)
print(f"[{i:02d}] {name}")
print(f" 尺寸={stats['size']} 模式={stats['mode']} "
f"alpha像素={stats['alpha_pct']:.1f}% "
f"内容={stats['content_pct']:.1f}% bbox={stats['bbox']}")
print(f" 已保存: {out_path}")
except Exception as e:
print(f"[{i:02d}] {name}: 解析失败 {e}")
print()
if __name__ == '__main__':
main()

97
image_test/find_bug.py Normal file
View File

@@ -0,0 +1,97 @@
"""穷举 process_image 的参数组合,找出哪种组合会产出"未紧致裁剪"的输出。
逻辑:
final.png (153x74, bbox=(0,0,153,74)) 是已知正确的紧致输出。
遍历 is_signature/height_cm 等参数,跑 process_image对比输出:
- 尺寸 == (153, 74)?
- bbox 是否贴满?
- 字节内容是否与 final.png 完全一致?
任何一项不一致 -> 找到 bug 触发条件。
输入图: image_test/input/test1.jpg
"""
import io
import sys
import hashlib
from pathlib import Path
from PIL import Image, ImageChops
BASE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BASE_DIR.parent
INPUT_IMAGE = BASE_DIR / "input" / "test1.jpg"
FINAL_REF = BASE_DIR / "output" / "test1" / "final.png"
def _ensure_path():
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
def _stats(data):
img = Image.open(io.BytesIO(data))
if img.mode != 'RGBA':
img = img.convert('RGBA')
r, g, b, a = img.split()
luma = Image.merge('RGB', (r, g, b)).convert('L')
am = a.point(lambda p: 255 if p > 30 else 0)
lm = luma.point(lambda p: 255 if p < 210 else 0)
combined = ImageChops.darker(am, lm)
total = img.size[0] * img.size[1] or 1
return {
'size': img.size,
'bbox': combined.getbbox(),
'content_pct': 100.0 * sum(combined.histogram()[1:]) / total,
'sha': hashlib.sha256(data).hexdigest()[:12],
'bytes': len(data),
}
def main():
_ensure_path()
sys.stdout.reconfigure(encoding='utf-8')
if not INPUT_IMAGE.exists():
print(f"缺少: {INPUT_IMAGE}")
sys.exit(1)
if not FINAL_REF.exists():
print(f"缺少: {FINAL_REF},请先运行 image_test/process.py 生成")
sys.exit(1)
with open(INPUT_IMAGE, 'rb') as f:
image_bytes = f.read()
with open(FINAL_REF, 'rb') as f:
final_bytes = f.read()
final_stats = _stats(final_bytes)
print(f"=== 参考基准 final.png ===")
print(f" 尺寸={final_stats['size']} bbox={final_stats['bbox']} "
f"内容={final_stats['content_pct']:.1f}% 字节={final_stats['bytes']} "
f"sha={final_stats['sha']}")
print()
from lib.image_processor import process_image
cases = [
# (label, kwargs)
("is_sig=True noise=0 rot=0", {'is_signature': True}),
("is_sig=False noise=0 rot=0", {'is_signature': False}),
("is_sig=True noise=3 rot=0", {'is_signature': True, 'noise_level': 3}),
("is_sig=True noise=0 rot=-5,5",{'is_signature': True, 'rotate_min': -5, 'rotate_max': 5}),
("is_sig=True noise=3 rot=-5,5",{'is_signature': True, 'noise_level': 3, 'rotate_min': -5, 'rotate_max': 5}),
]
print(f"=== 穷举 {len(cases)} 种参数组合 ===\n")
for label, kw in cases:
out = process_image(image_bytes, log_source='测试', **kw)
s = _stats(out)
tight = (s['bbox'] == (0, 0, s['size'][0], s['size'][1]))
same_as_final = (out == final_bytes)
flag = "✓紧致" if tight else "✗有padding"
flag2 = "=final" if same_as_final else "≠final"
print(f"[{label}]")
print(f" 尺寸={s['size']} bbox={s['bbox']} 内容={s['content_pct']:.1f}% "
f"字节={s['bytes']} {flag} {flag2}")
if __name__ == '__main__':
main()

BIN
image_test/input/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
image_test/input/test1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
image_test/input/下载.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

100
image_test/inspect_xml.py Normal file
View File

@@ -0,0 +1,100 @@
"""检查 signed.docx 中签名图片对应的 drawing XML看实际显示尺寸EMU
目的:
字节已经证明一致e2e_test.py但用户仍看到"空白"
本脚本读 document.xml找到图片所在段落的:
- wp:extent (图片显示尺寸 EMU)
- 段落 pPr/spacing (line 行高)
- 段落所在表格单元格 tcW (单元格宽度)
对比图片实际像素 vs 显示尺寸 vs 段落行高,定位空白的视觉来源。
EMU 换算:
1 inch = 914400 EMU = 2.54 cm
1 cm = 360000 EMU
"""
import sys
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
DOCX_PATH = BASE_DIR / "output" / "e2e" / "signed.docx"
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
A = 'http://schemas.openxmlformats.org/drawingml/2006/main'
def emu_to_cm(emu):
try:
return int(emu) / 360000.0
except (TypeError, ValueError):
return None
def main():
if not DOCX_PATH.exists():
print(f"docx 不存在: {DOCX_PATH}")
print("请先运行: python image_test/e2e_test.py")
sys.exit(1)
with zipfile.ZipFile(DOCX_PATH) as z:
xml_bytes = z.read('word/document.xml')
root = ET.fromstring(xml_bytes)
print("=== 段落列表(含 spacing / drawing===\n")
for i, p in enumerate(root.iter(f'{{{W}}}p'), 1):
pPr = p.find(f'{{{W}}}pPr')
spacing_info = ""
if pPr is not None:
sp = pPr.find(f'{{{W}}}spacing')
if sp is not None:
line = sp.get(f'{{{W}}}line')
rule = sp.get(f'{{{W}}}lineRule')
before = sp.get(f'{{{W}}}before')
after = sp.get(f'{{{W}}}after')
if line or before or after:
parts = []
if line:
cm = emu_to_cm(int(line) / 20 * 12700) if rule == 'exact' else None
parts.append(f"line={line}({rule})->{cm:.2f}cm" if cm else f"line={line}({rule})")
if before:
parts.append(f"before={before}({int(before)/20:.1f}pt)")
if after:
parts.append(f"after={after}({int(after)/20:.1f}pt)")
spacing_info = " | ".join(parts)
text = "".join(t.text or '' for t in p.iter(f'{{{W}}}t'))
has_drawing = len(list(p.iter(f'{{{WP}}}inline'))) > 0
marker = "[图]" if has_drawing else " "
print(f"[段{i:02d}] {marker} text={text[:40]!r}")
if spacing_info:
print(f" spacing: {spacing_info}")
for inline in p.iter(f'{{{WP}}}inline'):
extent = inline.find(f'{{{WP}}}extent')
if extent is not None:
cx = extent.get('cx')
cy = extent.get('cy')
print(f" drawing extent: cx={cx}({emu_to_cm(cx):.2f}cm) cy={cy}({emu_to_cm(cy):.2f}cm)")
tc_parent = None
parent = p
while parent is not None:
parent_iter = list(root.iter())
break
for tc in root.iter(f'{{{W}}}tc'):
if p in list(tc.iter(f'{{{W}}}p')):
tcPr = tc.find(f'{{{W}}}tcPr')
if tcPr is not None:
tcW = tcPr.find(f'{{{W}}}tcW')
if tcW is not None:
w = tcW.get(f'{{{W}}}w')
t = tcW.get(f'{{{W}}}type')
print(f" cell width: w={w} type={t}")
if __name__ == '__main__':
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

View File

@@ -0,0 +1,15 @@
输入: 1.jpg
模式: RGB 尺寸: (2048, 1382)
参数: is_sig=True height_cm=1 noise=0 rotate=[0,0] dpi=96
阈值: alpha>30 亮度<210 二值化>128 白底>240
--------------------------------------------------------------------------------
[00] original size=(2048, 1382) alpha=100.0% dark= 3.2% content= 3.2% bbox=(0, 0, 2048, 1382)
[01] binarized size=(2048, 1382) alpha=100.0% dark= 3.1% content= 3.1% bbox=(0, 0, 2044, 1381)
[02] white_removed size=(2048, 1382) alpha= 3.1% dark= 3.1% content= 3.1% bbox=(0, 0, 2044, 1381)
[03] border_cropped size=(2044, 1381) alpha= 3.1% dark= 3.1% content= 3.1% bbox=(0, 0, 2044, 1381)
[04] scaled size=(54, 37) alpha= 8.9% dark=100.0% content= 8.9% bbox=(10, 12, 42, 32)
[05] noised SKIPPED
[06] rotated SKIPPED
[07] final_cropped size=(32, 20) alpha= 27.8% dark=100.0% content= 27.8% bbox=(0, 0, 32, 20)
--------------------------------------------------------------------------------
最终输出: final.png size=(32, 20)

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

View File

@@ -0,0 +1,15 @@
输入: 81aa-icapxph6774222.jpg
模式: RGB 尺寸: (2048, 1382)
参数: is_sig=True height_cm=1 noise=0 rotate=[0,0] dpi=96
阈值: alpha>30 亮度<210 二值化>128 白底>240
--------------------------------------------------------------------------------
[00] original size=(2048, 1382) alpha=100.0% dark= 4.1% content= 4.1% bbox=(0, 0, 1743, 1374)
[01] binarized size=(2048, 1382) alpha=100.0% dark= 3.9% content= 3.9% bbox=(0, 0, 1686, 1222)
[02] white_removed size=(2048, 1382) alpha= 3.9% dark= 3.9% content= 3.9% bbox=(0, 0, 1686, 1222)
[03] border_cropped size=(1686, 1222) alpha= 5.4% dark= 5.4% content= 5.4% bbox=(0, 0, 1686, 1222)
[04] scaled size=(51, 37) alpha= 12.8% dark=100.0% content= 12.8% bbox=(13, 17, 51, 36)
[05] noised SKIPPED
[06] rotated SKIPPED
[07] final_cropped size=(38, 19) alpha= 33.4% dark=100.0% content= 33.4% bbox=(0, 0, 38, 19)
--------------------------------------------------------------------------------
最终输出: final.png size=(38, 19)

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,15 @@
输入: a463-icapxph6773606.jpg
模式: RGB 尺寸: (2048, 1382)
参数: is_sig=True height_cm=1 noise=0 rotate=[0,0] dpi=96
阈值: alpha>30 亮度<210 二值化>128 白底>240
--------------------------------------------------------------------------------
[00] original size=(2048, 1382) alpha=100.0% dark= 3.0% content= 3.0% bbox=(501, 471, 1742, 1063)
[01] binarized size=(2048, 1382) alpha=100.0% dark= 2.8% content= 2.8% bbox=(502, 471, 1741, 1063)
[02] white_removed size=(2048, 1382) alpha= 2.8% dark= 2.8% content= 2.8% bbox=(502, 471, 1741, 1063)
[03] border_cropped size=(1239, 592) alpha= 11.0% dark= 11.0% content= 11.0% bbox=(0, 0, 1239, 592)
[04] scaled size=(77, 37) alpha= 19.6% dark=100.0% content= 19.6% bbox=(0, 0, 77, 37)
[05] noised SKIPPED
[06] rotated SKIPPED
[07] final_cropped size=(77, 37) alpha= 19.6% dark=100.0% content= 19.6% bbox=(0, 0, 77, 37)
--------------------------------------------------------------------------------
最终输出: final.png size=(77, 37)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

View File

@@ -0,0 +1,15 @@
输入: a8b6-icapxph6774373.jpg
模式: RGB 尺寸: (2048, 1382)
参数: is_sig=True height_cm=1 noise=0 rotate=[0,0] dpi=96
阈值: alpha>30 亮度<210 二值化>128 白底>240
--------------------------------------------------------------------------------
[00] original size=(2048, 1382) alpha=100.0% dark= 4.4% content= 4.4% bbox=(0, 0, 2048, 1382)
[01] binarized size=(2048, 1382) alpha=100.0% dark= 4.2% content= 4.2% bbox=(0, 0, 2048, 1382)
[02] white_removed size=(2048, 1382) alpha= 4.2% dark= 4.2% content= 4.2% bbox=(0, 0, 2048, 1382)
[03] border_cropped size=(2048, 1382) alpha= 4.2% dark= 4.2% content= 4.2% bbox=(0, 0, 2048, 1382)
[04] scaled size=(54, 37) alpha= 8.6% dark=100.0% content= 8.6% bbox=(16, 13, 44, 37)
[05] noised SKIPPED
[06] rotated SKIPPED
[07] final_cropped size=(28, 24) alpha= 25.4% dark=100.0% content= 25.4% bbox=(0, 0, 28, 24)
--------------------------------------------------------------------------------
最终输出: final.png size=(28, 24)

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show More