修复签章浮动靠左问题
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
- 其他 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)
|
||||
|
||||
|
||||
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})
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user