Files
SignatureSystem/lib/word_signer.py
2026-07-20 16:43:08 +08:00

392 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
signature_mode = params.get('signature_mode', 'inline') # 'inline' | 'anchor'
stamp_mode = params.get('stamp_mode', 'anchor') # 'inline' | 'anchor'
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)
# 按图片类型选模式:签名用 signature_mode盖章用 stamp_mode
image_mode = signature_mode if is_sig else stamp_mode
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,
image_mode=image_mode,
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, image_mode='inline',
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`
控制是否自动放宽段落 / 表格行的固定高度约束以避免签名被裁剪。
``image_mode`` 透传给 :func:`field_codes.insert_image_after_field`
``'inline'`` 嵌入式(默认,撑大当前行)或 ``'anchor'`` 浮动覆盖(不撑大,
浮在文字上方)。
"""
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,
mode=image_mode,
)
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