Files
SignatureSystem/lib/field_codes.py

992 lines
37 KiB
Python
Raw Normal View History

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
- 其他 ASCII0.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
其他情况 trHeightatLeast neededauto 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