Files
SignatureSystem/lib/field_codes.py
2026-07-20 13:16:17 +08:00

838 lines
30 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.
"""Word SET 域代码处理工具。
支持两类域代码定位:
1. ``w:fldSimple`` 简单域,``w:instr`` 属性内含 ``SET USER_xxx``。
2. ``w:instrText`` + ``w:fldChar`` 组合的复杂域。
提供:
- :func:`scan_user_markers` 扫描文档中所有 ``SET USER_<name>`` 标记。
- :func:`insert_image_after_field` 在指定标记的域之后插入签名图片。
- :func:`build_set_field_xml` 构造一段 ``SET USER_<name> \\* 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_<name>`` 标记,返回去重后的 ``[{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_<name> 标记", 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 的 ``<w:r>`` 元素。
保留作为低层 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
def insert_image_after_field(doc, marker, image_path, width_cm, height_cm,
log_source='前端操作', relax_layout=True):
"""在指定 ``USER_<name>`` 域之后插入签名图片。
返回插入的图片数量。
``relax_layout=True``(默认)时,若标记段落或所在表格行有固定高度约束
``lineRule="exact"`` / ``hRule="exact"``)小于签名需要的高度,
自动把约束放宽为 ``atLeast`` 并上调数值,避免 Word 硬裁剪图片。
设为 ``False`` 时严格保留原文档布局,仅靠等比例缩放把图片压进约束内。
"""
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. 放宽约束(默认开启):把小于签名高度的 exact 约束改成 atLeast
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})
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))
new_run_xml = run_obj._element
anchor_elem.addnext(new_run_xml)
inserted += 1
logger.info("[Word处理] 在 %s 域后插入图片成功", marker, 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 段落 ``<w:pPr><w:spacing w:line="..." w:lineRule="exact"/></w:pPr>`` 中,
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):
"""读取段落所在表格行的固定高度约束。
向上查找最近的 ``<w:tr>``,读 ``<w:trPr><w:trHeight w:val w:hRule/></w:trPr>``。
返回 ``(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):
"""读段落左右缩进 ``<w:ind w:left w:right>``,返回 ``(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. 段落第一个有 ``<w:rPr><w:sz>`` 的 run
2. 段落标记的 rPr``<w:pPr><w:rPr><w:sz>``
``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`` 样式(默认段落样式)的 ``<w:sz>`` —— Word 渲染段落时实际使用
2. ``<w:docDefaults><w:rPrDefault><w:rPr><w:sz>`` —— 全局兜底
找不到返回 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_<name> \\* MERGEFORMAT``。
返回三个 ``<w:r>`` 元素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``: ``<w:p>`` lxml 元素。
- ``start_run_idx``/``end_run_idx``: 起止 ``<w:r>`` 在段落中的索引。
- ``start_offset``/``end_offset``: 起止 ``<w:t>`` 内字符偏移。
- ``captured_text``: 被替换的原始可见文本(用于估算补偿宽度)。
- ``name``: 用于 ``USER_<name>`` 的标识。
- ``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