Files
SignatureSystem/lib/word_signer.py

382 lines
19 KiB
Python
Raw Normal View History

2026-07-20 13:16:17 +08:00
import io
import os
import re
import base64
import logging
import tempfile
from datetime import datetime
from docx import Document
from docx.shared import Cm
import lib.db as db
from lib.image_processor import process_image
from lib import field_codes
logger = logging.getLogger(__name__)
def _new_trace_id():
return datetime.now().strftime('%Y%m%d%H%M%S%f') + f'_{os.getpid()}'
def sign_word(docx_data, params, log_source='前端操作', trace_id=None, client_ip=None):
if trace_id is None:
trace_id = _new_trace_id()
logger.info("[签章流程] ====== 开始处理 ====== trace_id=%s", trace_id,
extra={'log_source': log_source, 'trace_id': trace_id})
try:
docx_name = params.get('docx_name', '')
logger.info("[签章流程] 文件名: %s", docx_name,
extra={'log_source': log_source, 'trace_id': trace_id})
if not docx_name.lower().endswith('.docx'):
logger.error("[参数校验] 文件类型错误: %s, 仅支持.docx", docx_name,
extra={'log_source': log_source, 'trace_id': trace_id})
return None, {'success': False, 'code': 'INVALID_FILE_TYPE',
'message': f'仅支持.docx格式当前: {docx_name}',
'trace_id': trace_id}
match_mode = params.get('match_mode', '')
if match_mode not in ('upload', 'id', 'name', 'auto'):
logger.error("[参数校验] 匹配模式无效: %s", match_mode,
extra={'log_source': log_source, 'trace_id': trace_id})
return None, {'success': False, 'code': 'INVALID_MATCH_MODE',
'message': f'匹配模式无效: {match_mode}', 'trace_id': trace_id}
logger.info("[签章流程] 匹配模式: %s", match_mode,
extra={'log_source': log_source, 'trace_id': trace_id})
try:
doc = Document(io.BytesIO(docx_data))
except Exception as e:
logger.error("[参数校验] Word文档解析失败: %s", str(e),
extra={'log_source': log_source, 'trace_id': trace_id})
return None, {'success': False, 'code': 'INVALID_DOCX',
'message': f'Word文档解析失败或已损坏: {e}', 'trace_id': trace_id}
logger.info("[签章流程] Word文档加载成功",
extra={'log_source': log_source, 'trace_id': trace_id})
stamps = params.get('stamps', [])
use_global_align = params.get('use_global_align', True)
align_h = params.get('align_h', 'left')
align_v = params.get('align_v', 'center')
use_global_height = params.get('use_global_height', True)
global_height = params.get('height')
use_global_noise = params.get('use_global_noise', True)
global_noise = params.get('noise_level', 0)
use_global_rotate = params.get('use_global_rotate', True)
global_rotate_min = params.get('rotate_min', 0)
global_rotate_max = params.get('rotate_max', 0)
relax_layout = params.get('relax_layout', True)
if match_mode == 'auto':
stamps = [s for s in stamps if (s.get('marker') or '').strip()]
if not stamps:
scanned = field_codes.scan_user_markers(doc, log_source=log_source)
stamps = [{'marker': item['marker']} for item in scanned]
logger.info("[签章流程] auto模式扫描到 %d 个标记", len(stamps),
extra={'log_source': log_source, 'trace_id': trace_id})
if len(stamps) > 100:
logger.error("[参数校验] 盖章项超过100个限制: %d", len(stamps),
extra={'log_source': log_source, 'trace_id': trace_id})
return None, {'success': False, 'code': 'TOO_MANY_STAMPS',
'message': '盖章项超过100个限制', 'trace_id': trace_id}
warnings = []
success_count = 0
for i, stamp in enumerate(stamps):
logger.info("[签章流程] ---- 处理盖章项 %d/%d ----", i + 1, len(stamps),
extra={'log_source': log_source, 'trace_id': trace_id})
try:
marker = stamp.get('marker', '')
image_data = None
stamp_is_signature = False
if match_mode == 'upload':
image_b64 = stamp.get('image_base64', '')
if image_b64:
image_data = base64.b64decode(image_b64)
logger.info("[图片匹配] 使用上传图片: %s", stamp.get('image_filename', ''),
extra={'log_source': log_source, 'trace_id': trace_id})
else:
warnings.append(f"盖章项{i + 1}: 缺少图片数据")
logger.warning("[图片匹配] 盖章项%d缺少图片数据", i + 1,
extra={'log_source': log_source, 'trace_id': trace_id})
continue
elif match_mode == 'id':
image_id = stamp.get('image_id', '')
if not image_id:
# SRS 3.1.3:空字符串改用 image_name 匹配
image_name = stamp.get('image_name', '')
if image_name:
logger.info("[图片匹配] image_id 为空,改用姓名查询: %s", image_name,
extra={'log_source': log_source, 'trace_id': trace_id})
records = db.get_image_by_user_name(image_name)
if records:
if len(records) > 1:
warnings.append(f"盖章项{i + 1}: 姓名={image_name} 匹配到 {len(records)} 条记录,使用第一条")
logger.warning("[图片匹配] 姓名匹配多条: %s -> %d 条,使用第一条",
image_name, len(records),
extra={'log_source': log_source, 'trace_id': trace_id})
image_data = records[0]['sign_image']
stamp_is_signature = bool(records[0]['is_signature'])
else:
warnings.append(f"盖章项{i + 1}: 未找到姓名={image_name} 的记录")
logger.warning("[图片匹配] 未找到姓名=%s", image_name,
extra={'log_source': log_source, 'trace_id': trace_id})
continue
else:
warnings.append(f"盖章项{i + 1}: 缺少人员ID和姓名")
logger.warning("[图片匹配] 盖章项%d缺少人员ID和姓名", i + 1,
extra={'log_source': log_source, 'trace_id': trace_id})
continue
else:
logger.info("[图片匹配] 按ID查询: %s", image_id,
extra={'log_source': log_source, 'trace_id': trace_id})
record = db.get_image_by_user_id(image_id)
if record:
image_data = record['sign_image']
stamp_is_signature = bool(record['is_signature'])
logger.info("[图片匹配] ID匹配成功: %s", image_id,
extra={'log_source': log_source, 'trace_id': trace_id})
else:
warnings.append(f"盖章项{i + 1}: 未找到ID={image_id} 的记录")
logger.warning("[图片匹配] 未找到ID=%s", image_id,
extra={'log_source': log_source, 'trace_id': trace_id})
continue
elif match_mode == 'name':
image_name = stamp.get('image_name', '')
if not image_name:
warnings.append(f"盖章项{i + 1}: 缺少人员姓名")
logger.warning("[图片匹配] 盖章项%d缺少人员姓名", i + 1,
extra={'log_source': log_source, 'trace_id': trace_id})
continue
logger.info("[图片匹配] 按姓名查询: %s", image_name,
extra={'log_source': log_source, 'trace_id': trace_id})
records = db.get_image_by_user_name(image_name)
if records:
if len(records) > 1:
warnings.append(f"盖章项{i + 1}: 姓名={image_name} 匹配到 {len(records)} 条记录,使用第一条")
logger.warning("[图片匹配] 姓名匹配多条: %s -> %d 条,使用第一条",
image_name, len(records),
extra={'log_source': log_source, 'trace_id': trace_id})
image_data = records[0]['sign_image']
stamp_is_signature = bool(records[0]['is_signature'])
logger.info("[图片匹配] 姓名匹配成功: %s", image_name,
extra={'log_source': log_source, 'trace_id': trace_id})
else:
warnings.append(f"盖章项{i + 1}: 未找到姓名={image_name} 的记录")
logger.warning("[图片匹配] 未找到姓名=%s", image_name,
extra={'log_source': log_source, 'trace_id': trace_id})
continue
elif match_mode == 'auto':
name_from_marker = marker
if name_from_marker.startswith('USER_'):
name_from_marker = name_from_marker[5:]
name_from_marker = field_codes._strip_switches(name_from_marker)
logger.info("[图片匹配] auto模式, 标记=%s, 查询姓名=%s", marker, name_from_marker,
extra={'log_source': log_source, 'trace_id': trace_id})
records = db.get_image_by_user_name(name_from_marker)
if records:
if len(records) > 1:
warnings.append(f"盖章项{i + 1}: 姓名={name_from_marker} 匹配多条,使用第一条")
logger.warning("[图片匹配] auto 姓名匹配多条: %s -> %d", name_from_marker, len(records),
extra={'log_source': log_source, 'trace_id': trace_id})
image_data = records[0]['sign_image']
stamp_is_signature = bool(records[0]['is_signature'])
logger.info("[图片匹配] auto匹配成功: %s", name_from_marker,
extra={'log_source': log_source, 'trace_id': trace_id})
else:
warnings.append(f"盖章项{i + 1}: auto模式未找到姓名={name_from_marker} 的记录")
logger.warning("[图片匹配] auto未找到姓名=%s", name_from_marker,
extra={'log_source': log_source, 'trace_id': trace_id})
continue
if not image_data:
warnings.append(f"盖章项{i + 1}: 无有效图片数据")
continue
is_sig = stamp.get('is_signature', stamp_is_signature)
h = _resolve_height(stamp, use_global_height, global_height)
noise = _resolve_noise(stamp, use_global_noise, global_noise)
rot_min, rot_max = _resolve_rotate(stamp, use_global_rotate, global_rotate_min, global_rotate_max)
s_align_h = stamp.get('align_h') if not use_global_align else align_h
s_align_v = stamp.get('align_v') if not use_global_align else align_v
processed = process_image(
image_data,
is_signature=is_sig,
noise_level=noise,
rotate_min=rot_min,
rotate_max=rot_max,
log_source=log_source,
)
# ============= 调试:把"即将插入文档的图"落到磁盘 =============
# 目的:直接对比项目实际产出的图 vs 测试脚本image_test/process.py的输出
# 落盘位置: image_test/output/project_run/<trace_id>_<stamp_idx>_*.png
try:
import os as _os
_debug_dir = _os.path.join(
_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
'image_test', 'output', 'project_run'
)
_os.makedirs(_debug_dir, exist_ok=True)
_tag = f"{trace_id or 'no_trace'}_stamp{i + 1}"
with open(_os.path.join(_debug_dir, f"{_tag}_1_input.png"), 'wb') as _f:
_f.write(image_data)
with open(_os.path.join(_debug_dir, f"{_tag}_2_processed.png"), 'wb') as _f:
_f.write(processed)
logger.info("[签章流程][DEBUG] 已保存处理前后对比图到 %s (tag=%s)",
_debug_dir, _tag,
extra={'log_source': log_source, 'trace_id': trace_id})
except Exception as _e:
logger.warning("[签章流程][DEBUG] 保存调试图失败: %s", str(_e),
extra={'log_source': log_source, 'trace_id': trace_id})
# ============= 调试结束 =============
inserted = _insert_signature_image(doc, marker, processed, s_align_h, s_align_v,
height_cm=h,
relax_layout=relax_layout,
log_source=log_source, trace_id=trace_id)
if inserted > 0:
success_count += inserted
logger.info("[签章流程] 盖章项%d签章成功: marker=%s, 插入%d",
i + 1, marker, inserted,
extra={'log_source': log_source, 'trace_id': trace_id})
else:
warnings.append(f"盖章项{i + 1}: 未找到标记 '{marker}'")
logger.warning("[签章流程] 未找到标记: %s", marker,
extra={'log_source': log_source, 'trace_id': trace_id})
except Exception as e:
warnings.append(f"盖章项{i + 1}: 处理异常 - {str(e)}")
logger.error("[签章流程] 盖章项%d处理异常: %s", i + 1, str(e),
extra={'log_source': log_source, 'trace_id': trace_id})
output_buf = io.BytesIO()
doc.save(output_buf)
output_buf.seek(0)
result_data = output_buf.read()
base_name = os.path.splitext(docx_name)[0]
output_name = f"{base_name}_signed.docx"
logger.info("[签章流程] ====== 处理完成 ====== 成功%d项, 告警%d条, trace_id=%s",
success_count, len(warnings), trace_id,
extra={'log_source': log_source, 'trace_id': trace_id})
return {'data': result_data, 'filename': output_name,
'warnings': warnings, 'trace_id': trace_id,
'success_count': success_count}, None
except Exception as e:
logger.error("[签章流程] 处理失败: %s", str(e),
extra={'log_source': log_source, 'trace_id': trace_id})
return None, {'success': False, 'code': 'INTERNAL_ERROR',
'message': str(e), 'trace_id': trace_id}
def _insert_signature_image(doc, marker, image_data, align_h, align_v, height_cm=None,
relax_layout=True,
log_source='前端操作', trace_id=None):
"""插入签名图片到 marker 对应的 SET 域后。
``height_cm`` 为用户配置的目标高度厘米若为 None**透传 None
insert_image_after_field**由后者根据插入位置的段落行高 + 字号自动推算
`_auto_size_height_cm`
``relax_layout`` 透传给 :func:`field_codes.insert_image_after_field`
控制是否自动放宽段落 / 表格行的固定高度约束以避免签名被裁剪
"""
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
tmp.write(image_data)
tmp_path = tmp.name
try:
width_cm = None
if height_cm and height_cm > 0:
from PIL import Image as PILImage
img = PILImage.open(io.BytesIO(image_data))
img_w, img_h = img.size
width_cm = img_w / img_h * height_cm if img_h else height_cm
count = field_codes.insert_image_after_field(
doc, marker, tmp_path, width_cm, height_cm,
log_source=log_source, relax_layout=relax_layout,
)
return count
finally:
try:
os.unlink(tmp_path)
except Exception:
pass
def _resolve_height(stamp, use_global, global_val):
if use_global:
if global_val in (None, ""):
return None
try:
h = float(global_val)
if 0.1 <= h <= 10.0:
return round(h, 1)
except (ValueError, TypeError):
return None
return None
h = stamp.get('height')
if h not in (None, ""):
try:
h = float(h)
if 0.1 <= h <= 10.0:
return round(h, 1)
except (ValueError, TypeError):
pass
return None
def _resolve_noise(stamp, use_global, global_val):
if use_global:
try:
return int(global_val) if global_val not in (None, "") else 0
except (ValueError, TypeError):
return 0
n = stamp.get('noise_level')
if n not in (None, ""):
try:
n = int(n)
if 0 <= n <= 10:
return n
except (ValueError, TypeError):
pass
return 0
def _resolve_rotate(stamp, use_global, global_min, global_max):
if use_global:
try:
rmin = int(global_min) if global_min not in (None, "") else 0
except (ValueError, TypeError):
rmin = 0
try:
rmax = int(global_max) if global_max not in (None, "") else 0
except (ValueError, TypeError):
rmax = 0
return max(-180, min(180, rmin)), max(-180, min(180, rmax))
r_min = stamp.get('rotate_min', 0)
r_max = stamp.get('rotate_max', 0)
try:
r_min = max(-180, min(180, int(r_min)))
except (ValueError, TypeError):
r_min = 0
try:
r_max = max(-180, min(180, int(r_max)))
except (ValueError, TypeError):
r_max = 0
return r_min, r_max