2026-07-20 13:16:17 +08:00
|
|
|
|
"""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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 16:43:08 +08:00
|
|
|
|
_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 停止;
|
|
|
|
|
|
- 对每个 <w:t> 文本,按所在 run 的字号(无字号用段落默认)估算字符宽度:
|
|
|
|
|
|
- CJK / 全角 / 中文标点:1.0 × 字号 pt
|
|
|
|
|
|
- 空格:0.5 × 字号 pt
|
|
|
|
|
|
- 其他 ASCII:0.55 × 字号 pt
|
|
|
|
|
|
- 加段落左缩进(<w:ind w:left>,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 生成的 <wp:inline> 原地改造成 <wp:anchor>。
|
|
|
|
|
|
|
|
|
|
|
|
固定参数:behindDoc=0(盖在文字前)/ wrapNone(不环绕)/ 水平 paragraph 模式
|
|
|
|
|
|
+ posOffset=h_offset_emu(默认 0=贴段落起点;可传入 SET 域估算偏移让图片对齐域位置)
|
|
|
|
|
|
/ 垂直 paragraph + posOffset=0(贴段落顶部)。
|
|
|
|
|
|
保留 <a:graphic> 子树(含 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. 在 <wp:extent> 前插入 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. 在 <wp:extent> 后插入 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 13:16:17 +08:00
|
|
|
|
def insert_image_after_field(doc, marker, image_path, width_cm, height_cm,
|
2026-07-20 16:43:08 +08:00
|
|
|
|
log_source='前端操作', relax_layout=True,
|
|
|
|
|
|
mode='inline'):
|
2026-07-20 13:16:17 +08:00
|
|
|
|
"""在指定 ``USER_<name>`` 域之后插入签名图片。
|
|
|
|
|
|
|
|
|
|
|
|
返回插入的图片数量。
|
|
|
|
|
|
|
2026-07-20 16:43:08 +08:00
|
|
|
|
``mode``:
|
|
|
|
|
|
- ``'inline'``(默认):嵌入式,受段落行高约束,需 ``relax_layout`` 放宽 + 缩放兜底
|
|
|
|
|
|
- ``'anchor'``:浮动覆盖,跳过 relax_layout 与缩放兜底,图片浮在文字上方
|
|
|
|
|
|
|
2026-07-20 13:16:17 +08:00
|
|
|
|
``relax_layout=True``(默认)时,若标记段落或所在表格行有固定高度约束
|
|
|
|
|
|
(``lineRule="exact"`` / ``hRule="exact"``)小于签名需要的高度,
|
|
|
|
|
|
自动把约束放宽为 ``atLeast`` 并上调数值,避免 Word 硬裁剪图片。
|
|
|
|
|
|
设为 ``False`` 时严格保留原文档布局,仅靠等比例缩放把图片压进约束内。
|
2026-07-20 16:43:08 +08:00
|
|
|
|
仅在 ``mode='inline'`` 时生效,``anchor`` 模式跳过。
|
2026-07-20 13:16:17 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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})
|
|
|
|
|
|
|
2026-07-20 16:43:08 +08:00
|
|
|
|
# 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,
|
2026-07-20 13:16:17 +08:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-07-20 16:43:08 +08:00
|
|
|
|
# anchor 模式:把生成的 <wp:inline> 原地改造成 <wp: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)
|
|
|
|
|
|
|
2026-07-20 13:16:17 +08:00
|
|
|
|
new_run_xml = run_obj._element
|
|
|
|
|
|
anchor_elem.addnext(new_run_xml)
|
|
|
|
|
|
inserted += 1
|
2026-07-20 16:43:08 +08:00
|
|
|
|
logger.info("[Word处理] 在 %s 域后插入图片成功 (mode=%s)", marker, mode,
|
|
|
|
|
|
extra={'log_source': log_source})
|
2026-07-20 13:16:17 +08:00
|
|
|
|
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
|