"""Word SET 域代码处理工具。 支持两类域代码定位: 1. ``w:fldSimple`` 简单域,``w:instr`` 属性内含 ``SET USER_xxx``。 2. ``w:instrText`` + ``w:fldChar`` 组合的复杂域。 提供: - :func:`scan_user_markers` 扫描文档中所有 ``SET USER_`` 标记。 - :func:`insert_image_after_field` 在指定标记的域之后插入签名图片。 - :func:`build_set_field_xml` 构造一段 ``SET USER_ \\* MERGEFORMAT`` 域代码 XML。 - :func:`replace_text_with_set_field` 将段落中命中的可见文本替换为 SET 域 + 宽度补偿空格。 """ import re import logging from copy import deepcopy from docx.oxml.ns import qn from docx.oxml import OxmlElement from docx.shared import Cm, Emu logger = logging.getLogger(__name__) USER_MARKER_RE = re.compile(r'\bSET\s+USER_(\S+)', re.IGNORECASE) USER_MARKER_RE_CLEAN = re.compile(r'^USER_(\S+?)\s*(?:\\.*)?$') W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' WP_NS = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing' A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main' PIC_NS = 'http://schemas.openxmlformats.org/drawingml/2006/picture' R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' def _strip_switches(name): """去除 ``\\* MERGEFORMAT`` 等 Word 开关,并 trim 末尾空白。 输入示例: - ``USER_zhangsan`` -> ``zhangsan`` - ``USER_zhangsan \\* MERGEFORMAT`` -> ``zhangsan`` """ if name is None: return '' txt = name.strip() if txt.startswith('USER_'): txt = txt[5:] txt = txt.split('\\', 1)[0] return txt.strip() def _iter_all_paragraphs(doc): """递归遍历文档中所有段落:正文、表格(含嵌套)、文本框、页眉页脚。 返回 ``(paragraph_element, part)`` 元组,``part`` 用于 ``add_picture`` 关系绑定。 """ body = doc.element.body doc_part = doc.part for para in body.iter(qn('w:p')): yield para, doc_part for section in doc.sections: for hdr in (section.header, section.first_page_header, section.even_page_header): if hdr is not None: hdr_part = hdr.part for para in hdr._element.iter(qn('w:p')): yield para, hdr_part for ftr in (section.footer, section.first_page_footer, section.even_page_footer): if ftr is not None: ftr_part = ftr.part for para in ftr._element.iter(qn('w:p')): yield para, ftr_part def _iter_all_paragraph_elements(doc): """仅迭代段落元素(不带 part)。""" for para, _part in _iter_all_paragraphs(doc): yield para def _iter_all_tables(doc): """递归遍历所有表格,包括正文、文本框、页眉页脚中的嵌套表格。""" body = doc.element.body for tbl in body.iter(qn('w:tbl')): yield tbl for section in doc.sections: for hdr in (section.header, section.first_page_header, section.even_page_header): if hdr is not None: for tbl in hdr._element.iter(qn('w:tbl')): yield tbl for ftr in (section.footer, section.first_page_footer, section.even_page_footer): if ftr is not None: for tbl in ftr._element.iter(qn('w:tbl')): yield tbl def scan_user_markers(doc, log_source='前端操作'): """扫描文档中所有 ``SET USER_`` 标记,返回去重后的 ``[{marker, name}]`` 列表。""" found = [] seen_keys = set() for para, _part in _iter_all_paragraphs(doc): for elem in para.iter(): tag = elem.tag if tag == qn('w:fldSimple'): instr = elem.get(qn('w:instr')) or '' m = USER_MARKER_RE.search(instr) if m: name = _strip_switches(m.group(1)) if not name: continue key = name.lower() if key in seen_keys: continue seen_keys.add(key) found.append({'marker': f'USER_{name}', 'name': name, 'kind': 'simple'}) elif tag == qn('w:instrText'): text = ''.join(elem.itertext()) if elem.text is None else elem.text if not text: continue m = USER_MARKER_RE.search(text) if m: name = _strip_switches(m.group(1)) if not name: continue key = name.lower() if key in seen_keys: continue seen_keys.add(key) found.append({'marker': f'USER_{name}', 'name': name, 'kind': 'complex'}) logger.info("[auto扫描] 发现 %d 个 SET USER_ 标记", len(found), extra={'log_source': log_source}) return found def _find_field_elements_for_marker(doc, marker_name): """根据 marker 名称(如 ``USER_zhangsan`` 或 ``zhangsan``)查找域代码宿主元素。 返回 ``(kind, parent_paragraph, fld_element_or_run, part)`` 列表,供插入图片使用。 """ target = marker_name if not target.startswith('USER_'): target_full = f'USER_{target}' else: target_full = target target_name = target_full[5:] if target_full.startswith('USER_') else target_full target_lc = target_name.lower() results = [] for para, part in _iter_all_paragraphs(doc): for elem in para.iter(): tag = elem.tag if tag == qn('w:fldSimple'): instr = elem.get(qn('w:instr')) or '' m = USER_MARKER_RE.search(instr) if m: name = _strip_switches(m.group(1)) if name.lower() == target_lc: results.append(('simple', para, elem, part)) elif tag == qn('w:instrText'): text = elem.text or '' m = USER_MARKER_RE.search(text) if m: name = _strip_switches(m.group(1)) if name.lower() == target_lc: results.append(('complex', para, elem, part)) return results def _build_run_with_drawing(image_path, width_cm, height_cm): """构造一个包含图片 drawing 的 ```` 元素。 保留作为低层 helper;正式插入使用 :func:`_insert_picture_into_paragraph_after_anchor`, 通过 python-docx 的 ``Run.add_picture`` 完成关系绑定与 drawing XML。 """ run = OxmlElement('w:r') return run def _add_picture_run_to_paragraph(paragraph_element, doc_part, image_path, width_cm, height_cm): """在指定段落元素的末尾添加一个图片 run。 使用 python-docx 的 Paragraph/Run 高层 API,避免手动维护 rels。 """ from docx.text.paragraph import Paragraph from docx.shared import Cm as _Cm parent = paragraph_element.getparent() para_obj = Paragraph(paragraph_element, parent) run_obj = para_obj.add_run() run_obj.add_picture(image_path, width=_Cm(width_cm), height=_Cm(height_cm)) return run_obj _PT_PER_EMU = 12700 # 1 pt = 12700 EMU def _estimate_text_width_emu_before(para_xml, target_elem): """估算段落起点到 target_elem 之间所有可见文字的水平宽度(EMU)。 用于浮动图片(anchor 模式)相对段落起点的水平 posOffset 估算, 让图片左边缘接近 SET 域在段落中的位置。 算法: - 遍历段落所有后代,遇到 target_elem 停止; - 对每个 文本,按所在 run 的字号(无字号用段落默认)估算字符宽度: - CJK / 全角 / 中文标点:1.0 × 字号 pt - 空格:0.5 × 字号 pt - 其他 ASCII:0.55 × 字号 pt - 加段落左缩进(,twips → EMU)。 返回 EMU 整数。Word 实际渲染会有偏差(字体度量、字距差异),但通常 ±10% 内。 """ # 段落默认字号(已有函数返回 pt 或 None) default_pt = _detect_paragraph_font_size_pt(para_xml) or 10.5 # 段落左缩进 pPr = para_xml.find(qn('w:pPr')) left_twips = 0 if pPr is not None: ind = pPr.find(qn('w:ind')) if ind is not None: left_str = ind.get(qn('w:left')) or ind.get(qn('w:start')) or '0' try: left_twips = int(left_str) except (ValueError, TypeError): pass total_emu = int(left_twips * 635.0) # 1 twip = 635 EMU # 遍历段落子元素,遇到 target_elem 停止 for elem in para_xml.iter(): if elem is target_elem: break if elem.tag != qn('w:t'): continue text = elem.text or '' if not text: continue # 找到所在 run 的字号 run = elem.getparent() while run is not None and run.tag != qn('w:r'): run = run.getparent() font_pt = default_pt if run is not None: rPr = run.find(qn('w:rPr')) if rPr is not None: sz = rPr.find(qn('w:sz')) if sz is not None and sz.get(qn('w:val')): try: pt = int(sz.get(qn('w:val'))) / 2.0 if 4.0 <= pt <= 96.0: font_pt = pt except (ValueError, TypeError): pass # 估算每个字符宽度 for ch in text: code = ord(ch) if (0x4E00 <= code <= 0x9FFF # CJK 统一汉字 or 0x3000 <= code <= 0x303F # CJK 标点 or 0xFF00 <= code <= 0xFFEF # 全角字符 or 0x3400 <= code <= 0x4DBF): # CJK 扩展A total_emu += int(font_pt * 1.0 * _PT_PER_EMU) elif ch.isspace(): total_emu += int(font_pt * 0.5 * _PT_PER_EMU) else: total_emu += int(font_pt * 0.55 * _PT_PER_EMU) return total_emu def _convert_inline_to_anchor(inline_elem, h_offset_emu=0): """把 python-docx 生成的 原地改造成 。 固定参数:behindDoc=0(盖在文字前)/ wrapNone(不环绕)/ 水平 paragraph 模式 + posOffset=h_offset_emu(默认 0=贴段落起点;可传入 SET 域估算偏移让图片对齐域位置) / 垂直 paragraph + posOffset=0(贴段落顶部)。 保留 子树(含 r:embed)。 """ # 1. 改 tag inline_elem.tag = qn('wp:anchor') # 2. 加 anchor 专属属性 inline_elem.set('simplePos', '0') inline_elem.set('relativeHeight', '25') inline_elem.set('behindDoc', '0') # 在文字前面 inline_elem.set('locked', '0') inline_elem.set('layoutInCell', '1') inline_elem.set('allowOverlap', '1') # 3. 在 前插入 simplePos / positionH / positionV extent = inline_elem.find(qn('wp:extent')) simple_pos = OxmlElement('wp:simplePos') simple_pos.set('x', '0') simple_pos.set('y', '0') pos_h = OxmlElement('wp:positionH') pos_h.set('relativeFrom', 'paragraph') h_off = OxmlElement('wp:posOffset') h_off.text = str(int(h_offset_emu)) pos_h.append(h_off) pos_v = OxmlElement('wp:positionV') pos_v.set('relativeFrom', 'paragraph') # 垂直相对段落(贴段落顶部) v_off = OxmlElement('wp:posOffset') v_off.text = '0' pos_v.append(v_off) # 反序 insert,保证最终顺序:simplePos, positionH, positionV, extent for elem in (pos_v, pos_h, simple_pos): extent.addprevious(elem) # 4. 在 后插入 effectExtent 和 wrapNone effect = OxmlElement('wp:effectExtent') for k in ('l', 't', 'r', 'b'): effect.set(qn(f'wp:{k}'), '0') extent.addnext(effect) wrap_none = OxmlElement('wp:wrapNone') effect.addnext(wrap_none) def insert_image_after_field(doc, marker, image_path, width_cm, height_cm, log_source='前端操作', relax_layout=True, mode='inline'): """在指定 ``USER_`` 域之后插入签名图片。 返回插入的图片数量。 ``mode``: - ``'inline'``(默认):嵌入式,受段落行高约束,需 ``relax_layout`` 放宽 + 缩放兜底 - ``'anchor'``:浮动覆盖,跳过 relax_layout 与缩放兜底,图片浮在文字上方 ``relax_layout=True``(默认)时,若标记段落或所在表格行有固定高度约束 (``lineRule="exact"`` / ``hRule="exact"``)小于签名需要的高度, 自动把约束放宽为 ``atLeast`` 并上调数值,避免 Word 硬裁剪图片。 设为 ``False`` 时严格保留原文档布局,仅靠等比例缩放把图片压进约束内。 仅在 ``mode='inline'`` 时生效,``anchor`` 模式跳过。 """ target_name = marker if target_name.startswith('USER_'): target_name = target_name[5:] target_name = _strip_switches(target_name) matches = _find_field_elements_for_marker(doc, target_name) if not matches: logger.warning("[Word处理] 未找到标记 %s 的域代码", marker, extra={'log_source': log_source}) return 0 # 提前读取文档级默认字号(styles.xml docDefaults),用于段落无 sz 时的 fallback doc_default_pt = _get_doc_default_font_size_pt(doc) inserted = 0 from docx.text.paragraph import Paragraph from docx.shared import Cm as _Cm for kind, para_xml, host_elem, part in matches: try: if kind == 'simple': anchor_elem = host_elem else: end_elem = _find_complex_field_end(para_xml, host_elem) anchor_elem = end_elem if end_elem is not None else host_elem # 1. 计算目标高度(用户指定或 auto 推算) cur_h = height_cm cur_w = width_cm if not cur_h or cur_h <= 0: max_h_cm_initial = _detect_paragraph_max_height_cm(para_xml) font_pt = _detect_paragraph_font_size_pt(para_xml) if font_pt is None: font_pt = doc_default_pt logger.info("[Word处理] 段落无显式字号,使用文档默认字号 %spt", f"{font_pt}" if font_pt else "无", extra={'log_source': log_source}) cur_h = _auto_size_height_cm(max_h_cm_initial, font_pt) # 按 image 实际宽高比反推 width_cm,保持比例 try: from PIL import Image as _PILImage with _PILImage.open(image_path) as _im: _iw, _ih = _im.size if _ih and cur_h: cur_w = cur_h * (_iw / _ih) except Exception: cur_w = None logger.info("[Word处理] 自动尺寸: 字号=%spt → 推算 height=%.2f cm", f"{font_pt}" if font_pt else "无", cur_h, extra={'log_source': log_source}) # 2. inline 模式:放宽段落/表格行约束 + 缩放兜底 # anchor 模式:跳过——浮动图片不受段落约束,可溢出 if mode == 'inline': if relax_layout and cur_h and cur_h > 0: _relax_paragraph_for_image(para_xml, cur_h, log_source) _relax_table_row_for_image(para_xml, cur_h, log_source) # 3. 重新检测约束(放宽后 max_h_cm 可能变成 None) max_h_cm = _detect_paragraph_max_height_cm(para_xml) max_w_cm = _detect_table_cell_max_width_cm(para_xml) left_cm, right_cm = _detect_paragraph_indent_cm(para_xml) if max_w_cm and (left_cm or right_cm): max_w_cm = max(0.5, max_w_cm - left_cm - right_cm) # 4. 兜底缩放(处理 atLeast/缩进等软约束;或 relax_layout=False 时走原逻辑) final_w, final_h = _scale_to_fit(cur_w, cur_h, max_w_cm, max_h_cm) if (final_w, final_h) != (cur_w, cur_h): logger.info("[Word处理] 图片缩放以适配插入位置: %.2fx%.2f cm -> %.2fx%.2f cm " "(段落行高上限=%s, 单元格宽上限=%s, 左右缩进=%.2f+%.2fcm)", cur_w or 0, cur_h or 0, final_w or 0, final_h or 0, f"{max_h_cm:.2f}cm" if max_h_cm else "无", f"{max_w_cm:.2f}cm" if max_w_cm else "无", left_cm, right_cm, extra={'log_source': log_source}) else: # mode == 'anchor' final_w, final_h = cur_w, cur_h # 浮动模式不缩放 logger.info("[Word处理] 浮动覆盖模式(anchor): 使用图片原始尺寸 %.2fx%.2f cm," "跳过段落约束放宽与缩放兜底", final_w or 0, final_h or 0, extra={'log_source': log_source}) parent_xml = para_xml.getparent() para_obj = Paragraph(para_xml, part) run_obj = para_obj.add_run() run_obj.add_picture(image_path, width=_Cm(final_w), height=_Cm(final_h)) # anchor 模式:把生成的 原地改造成 if mode == 'anchor': drawing = run_obj._element.find(qn('w:drawing')) if drawing is not None: inline = drawing.find(qn('wp:inline')) if inline is not None: # 估算 SET 域在段落中的水平偏移(前面文字宽度 + 左缩进) h_offset_emu = _estimate_text_width_emu_before(para_xml, anchor_elem) _convert_inline_to_anchor(inline, h_offset_emu=h_offset_emu) new_run_xml = run_obj._element anchor_elem.addnext(new_run_xml) inserted += 1 logger.info("[Word处理] 在 %s 域后插入图片成功 (mode=%s)", marker, mode, extra={'log_source': log_source}) except Exception as e: logger.error("[Word处理] 插入图片失败 %s: %s", marker, str(e), extra={'log_source': log_source}) return inserted _TWIPS_PER_CM = 1440.0 / 2.54 # 1 cm ≈ 566.93 twips def _detect_paragraph_max_height_cm(para_xml): """从段落属性读取固定行高(lineRule="exact"),返回 cm 或 None。 Word 段落 ```` 中, line 字段在 exact 模式下单位为 1/20 pt (twips)。其他模式(auto/atLeast)不视为硬约束。 """ pPr = para_xml.find(qn('w:pPr')) if pPr is None: return None spacing = pPr.find(qn('w:spacing')) if spacing is None: return None line = spacing.get(qn('w:line')) line_rule = spacing.get(qn('w:lineRule')) if not line or line_rule != 'exact': return None try: return int(line) / _TWIPS_PER_CM except (ValueError, TypeError): return None def _detect_table_row_height_rule(para_xml): """读取段落所在表格行的固定高度约束。 向上查找最近的 ````,读 ````。 返回 ``(val_cm, rule)`` —— ``rule ∈ {'exact', 'atLeast', None}``。 按 OOXML 规范,``hRule`` 缺省为 ``atLeast``(不裁剪),仅 ``exact`` 视为硬约束。 """ parent = para_xml.getparent() while parent is not None and parent.tag != qn('w:tr'): parent = parent.getparent() if parent is None or parent.tag != qn('w:tr'): return None, None trPr = parent.find(qn('w:trPr')) if trPr is None: return None, None trHeight = trPr.find(qn('w:trHeight')) if trHeight is None: return None, None val = trHeight.get(qn('w:val')) rule = trHeight.get(qn('w:hRule')) or 'atLeast' if not val: return None, rule try: val_cm = int(val) / _TWIPS_PER_CM except (ValueError, TypeError): return None, rule return val_cm, rule def _detect_paragraph_indent_cm(para_xml): """读段落左右缩进 ````,返回 ``(left_cm, right_cm)``。""" pPr = para_xml.find(qn('w:pPr')) if pPr is None: return 0.0, 0.0 ind = pPr.find(qn('w:ind')) if ind is None: return 0.0, 0.0 left = ind.get(qn('w:left')) or ind.get(qn('w:start')) or '0' right = ind.get(qn('w:right')) or ind.get(qn('w:end')) or '0' try: return int(left) / _TWIPS_PER_CM, int(right) / _TWIPS_PER_CM except (ValueError, TypeError): return 0.0, 0.0 def _relax_paragraph_for_image(para_xml, needed_h_cm, log_source): """确保段落行高至少能容纳签名高度,避免 inline 图片被行盒子裁切。 inline 图片默认骑在文字 baseline 上方,所需高度 = 图片高度。 若段落行高(由 spacing/@line + lineRule 决定,或基于字号默认推算)小于该值, baseline 上方会溢出并被 Word 裁掉(典型表现:图片上方被切)。 处理所有 spacing 状态: - 无 pPr / 无 spacing → 主动创建 ``spacing line=needed atLeast`` - ``lineRule='exact'`` 且 line < needed → 改 ``atLeast`` + 上调 line - ``lineRule='atLeast'`` 且 line < needed → 上调 line - ``lineRule='auto'`` / 缺省 → 改 ``atLeast`` + 设 line = needed (默认行高基于字号,对 inline 图片通常不够) - 已 ≥ needed → no-op 返回是否发生过改动。 """ import math if not needed_h_cm or needed_h_cm <= 0: return False needed_twips = int(math.ceil(needed_h_cm * _TWIPS_PER_CM)) pPr = para_xml.find(qn('w:pPr')) if pPr is None: pPr = OxmlElement('w:pPr') para_xml.insert(0, pPr) spacing = pPr.find(qn('w:spacing')) if spacing is None: spacing = OxmlElement('w:spacing') spacing.set(qn('w:line'), str(needed_twips)) spacing.set(qn('w:lineRule'), 'atLeast') pPr.append(spacing) logger.info("[Word处理] 段落无显式行高,新增 spacing atLeast/%d twips (%.2fcm) " "以容纳 %.2fcm 高签名", needed_twips, needed_twips / _TWIPS_PER_CM, needed_h_cm, extra={'log_source': log_source}) return True line = spacing.get(qn('w:line')) line_rule = spacing.get(qn('w:lineRule')) or 'auto' if line: try: line_twips = int(line) if line_rule in ('exact', 'atLeast') and line_twips >= needed_twips: return False except (ValueError, TypeError): pass old_rule = line_rule old_line = line or 'auto' spacing.set(qn('w:lineRule'), 'atLeast') spacing.set(qn('w:line'), str(needed_twips)) logger.info("[Word处理] 放宽段落行高: lineRule %s→atLeast, line %s→%d twips " "(%.2fcm) 以容纳 %.2fcm 高签名", old_rule, old_line, needed_twips, needed_twips / _TWIPS_PER_CM, needed_h_cm, extra={'log_source': log_source}) return True def _relax_table_row_for_image(para_xml, needed_h_cm, log_source): """放宽表格行**硬高度约束**,避免图片被表格行盒子裁切。 OOXML 规范下表格行高规则: - ``hRule='exact'``:行高**精确**为 val,超过部分被裁切 ← 硬约束 - ``hRule='atLeast'`` 或缺省:行高**至少**为 val,可自动撑高 ← 软约束,不裁切 因此本函数仅在 ``hRule='exact'`` 且 ``val < needed_h_cm`` 时介入: - 改 ``hRule`` 为 ``atLeast`` + 上调 ``val`` 到 needed_h_cm 其他情况(无 trHeight、atLeast、已 ≥ needed、auto) → no-op。 """ import math if not needed_h_cm or needed_h_cm <= 0: return False parent = para_xml.getparent() while parent is not None and parent.tag != qn('w:tr'): parent = parent.getparent() if parent is None or parent.tag != qn('w:tr'): return False trPr = parent.find(qn('w:trPr')) if trPr is None: return False trHeight = trPr.find(qn('w:trHeight')) if trHeight is None: return False val = trHeight.get(qn('w:val')) rule = trHeight.get(qn('w:hRule')) or 'atLeast' if rule != 'exact' or not val: return False try: val_cm = int(val) / _TWIPS_PER_CM except (ValueError, TypeError): return False if val_cm >= needed_h_cm: return False needed_twips = int(math.ceil(needed_h_cm * _TWIPS_PER_CM)) trHeight.set(qn('w:hRule'), 'atLeast') trHeight.set(qn('w:val'), str(needed_twips)) logger.info("[Word处理] 放宽表格行高: hRule exact→atLeast, val %s→%d twips " "(%.2fcm → %.2fcm) 以容纳 %.2fcm 高签名", val, needed_twips, val_cm, needed_twips / _TWIPS_PER_CM, needed_h_cm, extra={'log_source': log_source}) return True def _detect_table_cell_max_width_cm(para_xml): """若段落位于表格单元格内,读取单元格宽度,返回 cm 或 None。""" parent = para_xml.getparent() while parent is not None and parent.tag != qn('w:tc'): parent = parent.getparent() if parent is None or parent.tag != qn('w:tc'): return None tcPr = parent.find(qn('w:tcPr')) if tcPr is None: return None tcW = tcPr.find(qn('w:tcW')) if tcW is None: return None w_val = tcW.get(qn('w:w')) w_type = tcW.get(qn('w:type')) if not w_val or w_type == 'pct': return None try: return int(w_val) / _TWIPS_PER_CM except (ValueError, TypeError): return None def _scale_to_fit(width_cm, height_cm, max_w_cm, max_h_cm): """等比例缩放图片,使其宽高都不超过约束。返回 (final_w, final_h)。""" if not width_cm or not height_cm: return width_cm, height_cm ratio = 1.0 if max_w_cm and width_cm > max_w_cm: ratio = min(ratio, max_w_cm / width_cm) if max_h_cm and height_cm > max_h_cm: ratio = min(ratio, max_h_cm / height_cm) return width_cm * ratio, height_cm * ratio def _detect_paragraph_font_size_pt(para_xml): """读取段落字体大小(point)。 优先级: 1. 段落第一个有 ```` 的 run 2. 段落标记的 rPr(````) ``w:sz`` 的 val 是 half-points(如 21 = 10.5pt)。 找不到返回 None(由调用方决定 fallback)。 """ for r in para_xml.iter(qn('w:r')): rPr = r.find(qn('w:rPr')) if rPr is None: continue sz = rPr.find(qn('w:sz')) if sz is not None: val = sz.get(qn('w:val')) if val: try: pt = int(val) / 2.0 if 4.0 <= pt <= 96.0: return pt except (ValueError, TypeError): pass break pPr = para_xml.find(qn('w:pPr')) if pPr is not None: rPr = pPr.find(qn('w:rPr')) if rPr is not None: sz = rPr.find(qn('w:sz')) if sz is not None: val = sz.get(qn('w:val')) if val: try: pt = int(val) / 2.0 if 4.0 <= pt <= 96.0: return pt except (ValueError, TypeError): pass return None def _get_doc_default_font_size_pt(doc): """读文档级默认字号,按 Word 实际渲染优先级。 优先级: 1. ``Normal`` 样式(默认段落样式)的 ```` —— Word 渲染段落时实际使用 2. ```` —— 全局兜底 找不到返回 None(由调用方做最终 fallback)。 """ try: styles_element = doc.styles.element except Exception: return None # 1. 先读 Normal 样式 for style in styles_element.iter(qn('w:style')): if style.get(qn('w:styleId')) != 'Normal': continue rPr = style.find(qn('w:rPr')) if rPr is not None: sz = rPr.find(qn('w:sz')) if sz is not None: val = sz.get(qn('w:val')) if val: try: pt = int(val) / 2.0 if 4.0 <= pt <= 96.0: return pt except (ValueError, TypeError): pass break # 2. 再读 docDefaults doc_defaults = styles_element.find(qn('w:docDefaults')) if doc_defaults is None: return None rPr_default = doc_defaults.find(qn('w:rPrDefault')) if rPr_default is None: return None rPr = rPr_default.find(qn('w:rPr')) if rPr is None: return None sz = rPr.find(qn('w:sz')) if sz is None: return None val = sz.get(qn('w:val')) if not val: return None try: pt = int(val) / 2.0 if 4.0 <= pt <= 96.0: return pt except (ValueError, TypeError): return None return None _CM_PER_PT = 2.54 / 72.0 def _auto_size_height_cm(line_height_cm, font_pt, default_cm=2.0): """根据段落行高和字号自动推算签名高度(cm)。 优先级: 1. 段落 exact line height → 用其 95%(留 5% 安全裕量,避免顶满行) 2. 字号 → 用 2 倍字号高度(中文文档常见签名比例:四号 14pt → 0.99cm, 视觉自然不抢戏,又能看清笔画) 3. 字号缺失 → 用 10.5pt(中文 Word 通用默认)按 2 倍推算兜底, 避免 fallback 到 default_cm 导致过大 4. 默认 default_cm 范围约束:返回值 clamp 到 [0.5, 5.0] cm,避免极端值。 """ if line_height_cm and 0.3 <= line_height_cm <= 10.0: return round(min(max(line_height_cm * 0.95, 0.5), 5.0), 2) effective_pt = font_pt if (font_pt and 6.0 <= font_pt <= 72.0) else 10.5 cm = effective_pt * 2 * _CM_PER_PT return round(min(max(cm, 0.5), 5.0), 2) def _find_complex_field_end(paragraph, instr_text_elem): """从 instrText 开始向后查找同段落的 fldChar end 元素。""" current = instr_text_elem found_sep = False for elem in paragraph.iter(): if elem is current: continue if elem.tag == qn('w:fldChar'): ftype = elem.get(qn('w:fldCharType')) if ftype == 'separate': found_sep = True elif ftype == 'end' and found_sep: return elem elif ftype == 'end': return elem return None def _attach_image_relationship(doc, run_elem, image_path): """通过 python-docx 的 part 接口建立图片关系并补充 blip。""" try: import os from docx.image.image import Image from docx.parts.image import ImagePart from docx.opc.constants import RELATIONSHIP_TYPE as RT with open(image_path, 'rb') as f: image_data = f.read() image = Image.from_blob(image_data) image_part = doc.part.related_part_lookup_by_image(image) if hasattr(doc.part, 'related_part_lookup_by_image') else None if image_part is None: image_part = ImagePart.from_image(image, doc.part.package) rId = doc.part.relate_to(image_part, RT.IMAGE) else: rId = None for rel in doc.part.rels.values(): if rel.target_part is image_part: rId = rel.rId break drawing = run_elem.find(qn('w:drawing')) if drawing is None: return inline = drawing.find(qn('wp:inline')) if inline is None: return graphic = inline.find(qn('a:graphic')) if graphic is None: return graphic_data = graphic.find(qn('a:graphicData')) if graphic_data is None: return pic = graphic_data.find(qn('pic:pic')) if pic is None: return blip_fill = OxmlElement('pic:blipFill') blip = OxmlElement('a:blip') blip.set(qn('r:embed'), rId) blip_fill.append(blip) pic.insert(0, blip_fill) except Exception as e: logger.error("[Word处理] 绑定图片关系失败: %s", str(e)) def build_set_field_runs(name): """构造一段复杂域 XML,表示 ``SET USER_ \\* MERGEFORMAT``。 返回三个 ```` 元素:begin / instrText / separate / 空白 result / end。 合并为一个 list 返回,调用方依次追加到段落。 """ runs = [] r1 = OxmlElement('w:r') fld_begin = OxmlElement('w:fldChar') fld_begin.set(qn('w:fldCharType'), 'begin') r1.append(fld_begin) runs.append(r1) r2 = OxmlElement('w:r') instr = OxmlElement('w:instrText') instr.set(qn('xml:space'), 'preserve') instr.text = f' SET USER_{name} \\* MERGEFORMAT ' r2.append(instr) runs.append(r2) r3 = OxmlElement('w:r') fld_sep = OxmlElement('w:fldChar') fld_sep.set(qn('w:fldCharType'), 'separate') r3.append(fld_sep) runs.append(r3) r4 = OxmlElement('w:r') t4 = OxmlElement('w:t') t4.set(qn('xml:space'), 'preserve') t4.text = '' r4.append(t4) runs.append(r4) r5 = OxmlElement('w:r') fld_end = OxmlElement('w:fldChar') fld_end.set(qn('w:fldCharType'), 'end') r5.append(fld_end) runs.append(r5) return runs def _approx_char_width_emu(font_size_pt=None): """估算单字符宽度(EMU),用于补偿空格宽度。 Word 默认正文字号 10.5pt(小四),半角空格宽度约为字号的 0.25 倍。 """ pt = font_size_pt or 10.5 return int(pt * 0.25 * 12700) def replace_text_with_set_field(paragraph_element, start_run_idx, start_offset, end_run_idx, end_offset, captured_text, name, compensate_spaces=True): """在段落 XML 中将指定 Run 区间内的字符替换为 SET 域 + 可选补偿空格。 参数: - ``paragraph_element``: ```` lxml 元素。 - ``start_run_idx``/``end_run_idx``: 起止 ```` 在段落中的索引。 - ``start_offset``/``end_offset``: 起止 ```` 内字符偏移。 - ``captured_text``: 被替换的原始可见文本(用于估算补偿宽度)。 - ``name``: 用于 ``USER_`` 的标识。 - ``compensate_spaces``: 是否插入普通空格补偿原宽度。 """ runs = paragraph_element.findall(qn('w:r')) if start_run_idx >= len(runs): return False start_run = runs[start_run_idx] start_t = start_run.find(qn('w:t')) if start_t is None: return False if start_run_idx == end_run_idx: original_text = start_t.text or '' head = original_text[:start_offset] tail = original_text[end_offset:] head_run = _make_text_run(head) tail_run = _make_text_run(tail) field_runs = build_set_field_runs(name) compensate_run = _make_text_run(' ' * len(captured_text)) if compensate_spaces else None parent = start_run.getparent() idx = list(parent).index(start_run) parent.remove(start_run) insert_idx = idx for elem in [head_run] + field_runs + ([compensate_run] if compensate_spaces else []) + [tail_run]: if elem is None: continue parent.insert(insert_idx, elem) insert_idx += 1 return True # 跨多个 run:简化处理,清掉中间 run,仅在首 run 处插入域 original_text_parts = [] for ridx in range(start_run_idx, end_run_idx + 1): r = runs[ridx] t = r.find(qn('w:t')) original_text_parts.append(t.text if t is not None and t.text else '') first_text = original_text_parts[0] head = first_text[:start_offset] last_text = original_text_parts[-1] tail = last_text[end_offset:] head_run = _make_text_run(head) tail_run = _make_text_run(tail) field_runs = build_set_field_runs(name) full_text = ''.join(original_text_parts) captured = full_text[start_offset:( sum(len(p) for p in original_text_parts[:-1]) + end_offset )] compensate_run = _make_text_run(' ' * len(captured)) if compensate_spaces else None parent = paragraph_element to_remove = runs[start_run_idx:end_run_idx + 1] anchor = to_remove[0] idx = list(parent).index(anchor) for r in to_remove: parent.remove(r) insert_idx = idx for elem in [head_run] + field_runs + ([compensate_run] if compensate_spaces else []) + [tail_run]: if elem is None: continue parent.insert(insert_idx, elem) insert_idx += 1 return True def _make_text_run(text): if text is None: return None if text == '': return None r = OxmlElement('w:r') t = OxmlElement('w:t') t.set(qn('xml:space'), 'preserve') t.text = text r.append(t) return r