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/__*.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