382 lines
19 KiB
Python
382 lines
19 KiB
Python
|
|
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
|