修复签章浮动靠左问题

This commit is contained in:
lihansani
2026-07-20 16:43:08 +08:00
parent 7affeaa253
commit bc8e564aa7
701 changed files with 71380 additions and 25 deletions

View File

@@ -192,16 +192,151 @@ def _add_picture_run_to_paragraph(paragraph_element, doc_part, image_path, width
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 停止;
- 对每个 <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)
def insert_image_after_field(doc, marker, image_path, width_cm, height_cm,
log_source='前端操作', relax_layout=True):
log_source='前端操作', relax_layout=True,
mode='inline'):
"""在指定 ``USER_<name>`` 域之后插入签名图片。
返回插入的图片数量。
``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_'):
@@ -253,27 +388,35 @@ def insert_image_after_field(doc, marker, image_path, width_cm, height_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)
# 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)
# 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,
# 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()
@@ -281,10 +424,21 @@ def insert_image_after_field(doc, marker, image_path, width_cm, height_cm,
run_obj = para_obj.add_run()
run_obj.add_picture(image_path, width=_Cm(final_w), height=_Cm(final_h))
# 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)
new_run_xml = run_obj._element
anchor_elem.addnext(new_run_xml)
inserted += 1
logger.info("[Word处理] 在 %s 域后插入图片成功", marker, extra={'log_source': log_source})
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})

View File

@@ -68,6 +68,8 @@ def sign_word(docx_data, params, log_source='前端操作', trace_id=None, clien
global_rotate_min = params.get('rotate_min', 0)
global_rotate_max = params.get('rotate_max', 0)
relax_layout = params.get('relax_layout', True)
signature_mode = params.get('signature_mode', 'inline') # 'inline' | 'anchor'
stamp_mode = params.get('stamp_mode', 'anchor') # 'inline' | 'anchor'
if match_mode == 'auto':
stamps = [s for s in stamps if (s.get('marker') or '').strip()]
@@ -202,6 +204,8 @@ def sign_word(docx_data, params, log_source='前端操作', trace_id=None, clien
continue
is_sig = stamp.get('is_signature', stamp_is_signature)
# 按图片类型选模式:签名用 signature_mode盖章用 stamp_mode
image_mode = signature_mode if is_sig else stamp_mode
h = _resolve_height(stamp, use_global_height, global_height)
noise = _resolve_noise(stamp, use_global_noise, global_noise)
rot_min, rot_max = _resolve_rotate(stamp, use_global_rotate, global_rotate_min, global_rotate_max)
@@ -243,6 +247,7 @@ def sign_word(docx_data, params, log_source='前端操作', trace_id=None, clien
inserted = _insert_signature_image(doc, marker, processed, s_align_h, s_align_v,
height_cm=h,
relax_layout=relax_layout,
image_mode=image_mode,
log_source=log_source, trace_id=trace_id)
if inserted > 0:
success_count += inserted
@@ -283,7 +288,7 @@ def sign_word(docx_data, params, log_source='前端操作', trace_id=None, clien
def _insert_signature_image(doc, marker, image_data, align_h, align_v, height_cm=None,
relax_layout=True,
relax_layout=True, image_mode='inline',
log_source='前端操作', trace_id=None):
"""插入签名图片到 marker 对应的 SET 域后。
@@ -293,6 +298,10 @@ def _insert_signature_image(doc, marker, image_data, align_h, align_v, height_cm
``relax_layout`` 透传给 :func:`field_codes.insert_image_after_field`
控制是否自动放宽段落 / 表格行的固定高度约束以避免签名被裁剪。
``image_mode`` 透传给 :func:`field_codes.insert_image_after_field`
``'inline'`` 嵌入式(默认,撑大当前行)或 ``'anchor'`` 浮动覆盖(不撑大,
浮在文字上方)。
"""
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
tmp.write(image_data)
@@ -309,6 +318,7 @@ def _insert_signature_image(doc, marker, image_data, align_h, align_v, height_cm
count = field_codes.insert_image_after_field(
doc, marker, tmp_path, width_cm, height_cm,
log_source=log_source, relax_layout=relax_layout,
mode=image_mode,
)
return count
finally: