first commit
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
mysql/
|
||||
|
||||
.claude
|
||||
*.zip
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
BIN
__pycache__/app.cpython-39.pyc
Normal file
264
_download_mysql.py
Normal 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
@@ -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_LENGTH:SRS 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
@@ -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
BIN
image_test/__pycache__/process.cpython-39.pyc
Normal file
514
image_test/auto_size_test.py
Normal 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()
|
||||
82
image_test/compare_project_vs_test.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""对比"项目实际产出的处理图" vs "测试脚本产出的 final.png"。
|
||||
|
||||
使用前置条件:
|
||||
1. 已经在前端跑过一次签章,image_test/output/project_run/ 下有 _2_processed.png
|
||||
2. 已经跑过 image_test/process.py,image_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
@@ -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,正文段落使用偏大行高。
|
||||
|
||||
段落行高 2cm(lineRule=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()
|
||||
81
image_test/extract_docx_images.py
Normal 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
@@ -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
|
After Width: | Height: | Size: 184 KiB |
BIN
image_test/input/81aa-icapxph6774222.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
image_test/input/a463-icapxph6773606.jpg
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
image_test/input/a8b6-icapxph6774373.jpg
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
image_test/input/signature.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
image_test/input/test1.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
image_test/input/下载.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
100
image_test/inspect_xml.py
Normal 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()
|
||||
BIN
image_test/output/1/00_original.png
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
image_test/output/1/01_binarized.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
image_test/output/1/02_white_removed.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
image_test/output/1/03_border_cropped.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
image_test/output/1/04_scaled.png
Normal file
|
After Width: | Height: | Size: 863 B |
BIN
image_test/output/1/07_final_cropped.png
Normal file
|
After Width: | Height: | Size: 737 B |
15
image_test/output/1/diagnostics.txt
Normal 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)
|
||||
BIN
image_test/output/1/final.png
Normal file
|
After Width: | Height: | Size: 737 B |
BIN
image_test/output/81aa-icapxph6774222/00_original.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
image_test/output/81aa-icapxph6774222/01_binarized.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
image_test/output/81aa-icapxph6774222/02_white_removed.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
image_test/output/81aa-icapxph6774222/03_border_cropped.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
image_test/output/81aa-icapxph6774222/04_scaled.png
Normal file
|
After Width: | Height: | Size: 995 B |
BIN
image_test/output/81aa-icapxph6774222/07_final_cropped.png
Normal file
|
After Width: | Height: | Size: 896 B |
15
image_test/output/81aa-icapxph6774222/diagnostics.txt
Normal 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)
|
||||
BIN
image_test/output/81aa-icapxph6774222/final.png
Normal file
|
After Width: | Height: | Size: 896 B |
BIN
image_test/output/a463-icapxph6773606/00_original.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
image_test/output/a463-icapxph6773606/01_binarized.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
image_test/output/a463-icapxph6773606/02_white_removed.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
image_test/output/a463-icapxph6773606/03_border_cropped.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
image_test/output/a463-icapxph6773606/04_scaled.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
image_test/output/a463-icapxph6773606/07_final_cropped.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
15
image_test/output/a463-icapxph6773606/diagnostics.txt
Normal 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)
|
||||
BIN
image_test/output/a463-icapxph6773606/final.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
image_test/output/a8b6-icapxph6774373/00_original.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
image_test/output/a8b6-icapxph6774373/01_binarized.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
image_test/output/a8b6-icapxph6774373/02_white_removed.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
image_test/output/a8b6-icapxph6774373/03_border_cropped.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
image_test/output/a8b6-icapxph6774373/04_scaled.png
Normal file
|
After Width: | Height: | Size: 893 B |
BIN
image_test/output/a8b6-icapxph6774373/07_final_cropped.png
Normal file
|
After Width: | Height: | Size: 661 B |
15
image_test/output/a8b6-icapxph6774373/diagnostics.txt
Normal 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)
|
||||
BIN
image_test/output/a8b6-icapxph6774373/final.png
Normal file
|
After Width: | Height: | Size: 661 B |
BIN
image_test/output/auto_size/input.docx
Normal file
BIN
image_test/output/auto_size/no_spacing_atleast.docx
Normal file
BIN
image_test/output/auto_size/no_sz.docx
Normal file
BIN
image_test/output/auto_size/para_283.docx
Normal file
BIN
image_test/output/auto_size/para_2835.docx
Normal file
BIN
image_test/output/auto_size/para_strict.docx
Normal file
BIN
image_test/output/auto_size/signed.docx
Normal file
BIN
image_test/output/auto_size/signed_auto.docx
Normal file
BIN
image_test/output/auto_size/table_atLeast_567.docx
Normal file
BIN
image_test/output/auto_size/table_exact_567.docx
Normal file
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 101 B |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 19 KiB |