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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
mysql/
.claude
*.zip
### VS Code ###
.vscode/

Binary file not shown.

264
_download_mysql.py Normal file
View File

@@ -0,0 +1,264 @@
import urllib.request
import os
import sys
import zipfile
import subprocess
import shutil
import configparser
MYSQL_VERSION = "5.7.44"
MYSQL_ZIP_NAME = f"mysql-{MYSQL_VERSION}-winx64.zip"
MYSQL_DIR_NAME = f"mysql-{MYSQL_VERSION}-winx64"
MYSQL_DOWNLOAD_URL = f"https://downloads.mysql.com/archives/get/p/23/file/{MYSQL_ZIP_NAME}"
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
MYSQL_ZIP_PATH = os.path.join(PROJECT_ROOT, MYSQL_ZIP_NAME)
MYSQL_HOME = os.path.join(PROJECT_ROOT, "mysql")
MYSQL_DATA = os.path.join(MYSQL_HOME, "data")
MYSQL_BIN = os.path.join(MYSQL_HOME, "bin")
def download():
print(f"下载 MySQL {MYSQL_VERSION} 到: {MYSQL_ZIP_PATH}")
print(f"URL: {MYSQL_DOWNLOAD_URL}")
if os.path.exists(MYSQL_ZIP_PATH):
size = os.path.getsize(MYSQL_ZIP_PATH)
if size > 100 * 1024 * 1024:
print(f"文件已存在, 大小: {size / 1024 / 1024:.1f} MB, 跳过下载")
return True
else:
print(f"文件已存在但大小异常 ({size / 1024 / 1024:.1f} MB), 重新下载")
os.remove(MYSQL_ZIP_PATH)
def progress(block_num, block_size, total_size):
downloaded = block_num * block_size
percent = min(downloaded / total_size * 100, 100) if total_size > 0 else 0
mb = downloaded / 1024 / 1024
total_mb = total_size / 1024 / 1024
sys.stdout.write(f"\r下载进度: {percent:.1f}% ({mb:.1f}/{total_mb:.1f} MB)")
sys.stdout.flush()
try:
urllib.request.urlretrieve(MYSQL_DOWNLOAD_URL, MYSQL_ZIP_PATH, progress)
print(f"\n下载完成! 文件大小: {os.path.getsize(MYSQL_ZIP_PATH) / 1024 / 1024:.1f} MB")
return True
except Exception as e:
print(f"\n下载失败: {e}")
return False
def extract():
if os.path.exists(MYSQL_HOME) and os.path.exists(MYSQL_BIN):
print(f"MySQL 目录已存在: {MYSQL_HOME}, 跳过解压")
return True
if not os.path.exists(MYSQL_ZIP_PATH):
print("MySQL zip 文件不存在, 请先下载")
return False
print(f"正在解压 {MYSQL_ZIP_NAME} ...")
temp_extract = os.path.join(PROJECT_ROOT, MYSQL_DIR_NAME)
try:
with zipfile.ZipFile(MYSQL_ZIP_PATH, 'r') as zf:
zf.extractall(PROJECT_ROOT)
print("解压完成")
if os.path.exists(temp_extract):
if os.path.exists(MYSQL_HOME):
shutil.rmtree(MYSQL_HOME)
os.rename(temp_extract, MYSQL_HOME)
print(f"重命名为: {MYSQL_HOME}")
return True
except Exception as e:
print(f"解压失败: {e}")
return False
def _read_mysql_port_from_config():
config_path = os.path.join(PROJECT_ROOT, "config.ini")
if os.path.exists(config_path):
cfg = configparser.ConfigParser()
cfg.read(config_path, encoding='utf-8')
return cfg.getint('mysql', 'port', fallback=33366)
return 33366
def init_mysql():
if os.path.exists(MYSQL_DATA):
print(f"MySQL 数据目录已存在: {MYSQL_DATA}, 跳过初始化")
return True
mysqld = os.path.join(MYSQL_BIN, "mysqld.exe")
if not os.path.exists(mysqld):
print(f"mysqld.exe 不存在: {mysqld}")
return False
print("正在初始化 MySQL 数据目录...")
try:
result = subprocess.run(
[mysqld, "--initialize-insecure", "--basedir=" + MYSQL_HOME, "--datadir=" + MYSQL_DATA],
capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
print(f"初始化输出: {result.stderr}")
else:
print("MySQL 数据目录初始化完成")
return True
except Exception as e:
print(f"MySQL 初始化失败: {e}")
return False
def write_my_ini():
my_ini_path = os.path.join(MYSQL_HOME, "my.ini")
port = _read_mysql_port_from_config()
ini_content = f"""[mysqld]
basedir={MYSQL_HOME}
datadir={MYSQL_DATA}
port={port}
max_connections=200
max_allowed_packet=64M
character-set-server=utf8mb4
collation-server=utf8mb4_general_ci
default-storage-engine=InnoDB
sql_mode=NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES
[client]
port={port}
default-character-set=utf8mb4
[mysql]
default-character-set=utf8mb4
"""
with open(my_ini_path, 'w', encoding='utf-8') as f:
f.write(ini_content)
print(f"my.ini 已写入: {my_ini_path} (port={port})")
return True
def start_mysql_temp():
mysqld = os.path.join(MYSQL_BIN, "mysqld.exe")
if not os.path.exists(mysqld):
print(f"mysqld.exe 不存在: {mysqld}")
return False
print("临时启动 MySQL 以创建用户和数据库...")
try:
proc = subprocess.Popen(
[mysqld, "--standalone", "--console"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
import time
time.sleep(5)
if proc.poll() is not None:
print("MySQL 启动失败")
return False
print("MySQL 已临时启动, 正在配置用户和数据库...")
_setup_users_and_db()
print("正在停止临时 MySQL...")
proc.terminate()
proc.wait(timeout=10)
print("临时 MySQL 已停止")
return True
except Exception as e:
print(f"临时启动 MySQL 失败: {e}")
return False
def _setup_users_and_db():
mysql_exe = os.path.join(MYSQL_BIN, "mysql.exe")
port = _read_mysql_port_from_config()
config_path = os.path.join(PROJECT_ROOT, "config.ini")
cfg = configparser.ConfigParser()
cfg.read(config_path, encoding='utf-8')
db_user = cfg.get('mysql', 'user', fallback='admin')
db_password = cfg.get('mysql', 'password', fallback='admin')
db_name = cfg.get('mysql', 'database', fallback='word_sign')
sql_commands = f"""
CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER IF NOT EXISTS '{db_user}'@'127.0.0.1' IDENTIFIED BY '{db_password}';
CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{db_password}';
CREATE USER IF NOT EXISTS '{db_user}'@'%%' IDENTIFIED BY '{db_password}';
GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'127.0.0.1';
GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'localhost';
GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'%%';
GRANT ALL PRIVILEGES ON *.* TO '{db_user}'@'127.0.0.1' WITH GRANT OPTION;
GRANT ALL PRIVILEGES ON *.* TO '{db_user}'@'localhost' WITH GRANT OPTION;
GRANT ALL PRIVILEGES ON *.* TO '{db_user}'@'%%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
"""
sql_file = os.path.join(PROJECT_ROOT, "_temp_init.sql")
with open(sql_file, 'w', encoding='utf-8') as f:
f.write(sql_commands)
try:
result = subprocess.run(
[mysql_exe, "-u", "root", "--skip-password", f"--port={port}", "-e", f"source {sql_file}"],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
print(f"用户 '{db_user}' 和数据库 '{db_name}' 创建成功")
else:
print(f"创建用户/数据库输出: {result.stderr}")
print("尝试使用 mysql 命令行...")
result2 = subprocess.run(
[mysql_exe, "-u", "root", f"--port={port}", f"--execute={sql_commands.strip()}"],
capture_output=True, text=True, timeout=30
)
if result2.returncode == 0:
print(f"用户 '{db_user}' 和数据库 '{db_name}' 创建成功 (方式2)")
else:
print(f"创建用户/数据库失败: {result2.stderr}")
except Exception as e:
print(f"创建用户/数据库异常: {e}")
finally:
if os.path.exists(sql_file):
os.remove(sql_file)
def main():
print("=" * 60)
print(f" MySQL {MYSQL_VERSION} 绿色版 - 下载与初始化")
print("=" * 60)
print()
if not download():
print("下载失败, 终止")
return
if not extract():
print("解压失败, 终止")
return
if not init_mysql():
print("初始化数据目录失败, 终止")
return
write_my_ini()
if not start_mysql_temp():
print("MySQL 临时启动配置失败, 但基本安装已完成")
print("首次启动 start.bat 时会自动完成剩余配置")
print()
print("=" * 60)
print(" MySQL 安装完成!")
print(f" 目录: {MYSQL_HOME}")
print(f" 数据: {MYSQL_DATA}")
print(" 使用 start.bat 启动服务")
print("=" * 60)
if __name__ == '__main__':
main()

89
app.py Normal file
View File

@@ -0,0 +1,89 @@
import os
import sys
import logging
from flask import Flask
from lib.config import load_config, get_web_config, get_limits_config
from lib.log_handler import setup_logging
from lib.db import init_database
app = Flask(__name__,
template_folder=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates'),
static_folder=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static'))
# MAX_CONTENT_LENGTHSRS 9.1 要求 docx ≤1MB、image ≤256KB。
# JSON 请求体的 Base64 编码会让 docx 膨胀约 33%,叠加多图章项后取 4MB 比较保守。
# 真正的硬限制由 lib/validators.validate_docx_size 在解码后强制。
app.config['MAX_CONTENT_LENGTH'] = 8 * 1024 * 1024
from routes.pages import pages_bp
from routes.api import api_bp
from routes.db_api import db_api_bp
from routes.batch_api import batch_api_bp
from routes.monitor_api import monitor_api_bp
app.register_blueprint(pages_bp)
app.register_blueprint(api_bp)
app.register_blueprint(db_api_bp)
app.register_blueprint(batch_api_bp)
app.register_blueprint(monitor_api_bp)
def ensure_dirs():
base = os.path.dirname(os.path.abspath(__file__))
for d in ['temp', 'logs', 'output', 'data', 'static', 'static/css', 'static/js']:
os.makedirs(os.path.join(base, d), exist_ok=True)
def main():
load_config()
setup_logging()
ensure_dirs()
logger = logging.getLogger(__name__)
logger.info("===== 自动签名系统启动 =====")
limits = get_limits_config()
logger.info("限制配置: docx_max=%d bytes, image_max=%d bytes, stamp_max=%d, log_max=%d",
limits['docx_max_bytes'], limits['image_max_bytes'],
limits['stamp_max_count'], limits['log_max_entries'])
try:
ok = init_database(strict_mysql=False)
if not ok:
logger.error("数据库初始化失败MySQL 不可用且 strict 模式)")
else:
from lib.db import get_active_db_type
db_type = get_active_db_type()
logger.info("数据库初始化完成, 当前使用: %s", db_type.upper())
except Exception as e:
logger.error("数据库初始化失败: %s", str(e))
web_cfg = get_web_config()
host = web_cfg['host']
port = web_cfg['port']
debug = web_cfg['debug']
logger.info("Web服务: %s:%d, debug=%s", host, port, debug)
if debug:
app.run(host=host, port=port, debug=True)
else:
try:
from waitress import serve
logger.info("使用 Waitress 生产服务器")
try:
serve(app, host=host, port=port, threads=4)
except OSError:
if port == 5001:
logger.warning("端口 %d 被占用或无权限, 尝试端口 5002", port)
serve(app, host=host, port=5002, threads=4)
else:
raise
except ImportError:
logger.warning("Waitress 未安装, 使用 Flask 开发服务器")
app.run(host=host, port=port, debug=False)
if __name__ == '__main__':
main()

30
config.ini Normal file
View File

@@ -0,0 +1,30 @@
[database]
type=mysql
[mysql]
host=127.0.0.1
port=33366
user=admin
password=admin
database=word_sign
[sqlite]
path=./data/word_sign.db
[web]
host=0.0.0.0
port=5001
debug=false
[log]
level=INFO
file_path=./logs/
[limits]
docx_max_bytes=1048576
image_max_bytes=262144
stamp_max_count=100
log_max_entries=1000
[oa]
action_timeout_seconds=30

BIN
data/word_sign.db Normal file

Binary file not shown.

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

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