Files
hermes-skills/skills/legal/contract-editor/references/split-merged-title-body-paragraph.md
T

6.6 KiB

拆分合并的标题+正文段落为两个独立INS段落

场景

Reviewer发现新增条款的标题和正文被合并在一个<w:p>段落中(通过<w:t>内的换行符分隔),要求拆分为两个独立段落——标题段和正文段,各有独立的格式。

判别

  • 目标段落是一个<w:p>,内含一个<w:ins author="WB"><w:ins>内只有一个<w:r><w:t>文本包含换行符(\n)分隔标题和正文
  • 标题格式要求:参照原文同级标题段落(如"第四条"或"第六条")
  • 正文格式要求:参照原文同层级正文段落(如"一、施工期限")

操作步骤

1. 读取原文并定位目标段落

import zipfile
from lxml import etree
import copy

W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'

with zipfile.ZipFile(docx_path, 'r') as zf:
    doc_xml = etree.parse(zf.open('word/document.xml'))
    all_files = {name: zf.read(name) for name in zf.namelist()}

body = doc_xml.getroot().find(f'{{{W}}}body')
paragraphs = list(body.findall(f'{{{W}}}p'))

# 找到目标段落
for i, p in enumerate(paragraphs):
    texts = []
    for elem in p.iter(f'{{{W}}}t'):
        texts.append(elem.text or '')
    full_text = ''.join(texts)
    if '第五条' in full_text and '转包' in full_text:
        target_idx = i
        break

2. 提取标题和正文文本

full_text = ''
for elem in target_p.iter(f'{{{W}}}t'):
    full_text += elem.text or ''

lines = full_text.split('\n')
title_text = lines[0].strip()   # "第五条 转包与分包"
body_text = '\n'.join(lines[1:]).strip()  # 正文内容

3. 找到参照段落并克隆pPr

标题段pPr从紧邻的同级标题段落克隆(如"第六条"),正文段pPr从同层级正文段落克隆(如"一、施工期限")。

# 标题参照段落(如"第六条")
ref_title_p = paragraphs[24]  # 原文"第六条"的索引
ref_title_pPr = ref_title_p.find(f'{{{W}}}pPr')
title_pPr = copy.deepcopy(ref_title_pPr)

# 正文参照段落(如"一、施工期限")
ref_body_p = paragraphs[13]  # 原文"一、施工期限"的索引
ref_body_pPr = ref_body_p.find(f'{{{W}}}pPr')
body_pPr = copy.deepcopy(ref_body_pPr)

4. 构建标题段落

title_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
title_p.append(title_pPr)

# 标题rPr:黑体四属性 + hint=eastAsia + sz=24 + bold
title_rPr = etree.Element(f'{{{W}}}rPr')
rFonts = etree.SubElement(title_rPr, f'{{{W}}}rFonts')
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
    rFonts.set(f'{{{W}}}{attr}', '黑体')
rFonts.set(f'{{{W}}}hint', 'eastAsia')
etree.SubElement(title_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-6')
etree.SubElement(title_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '24')
etree.SubElement(title_rPr, f'{{{W}}}szCs').set(f'{{{W}}}val', '24')
etree.SubElement(title_rPr, f'{{{W}}}b')  # 加粗

title_ins = etree.SubElement(title_p, f'{{{W}}}ins')
title_ins.set(f'{{{W}}}id', str(new_ins_id))
title_ins.set(f'{{{W}}}author', 'WB')
title_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')

title_r = etree.SubElement(title_ins, f'{{{W}}}r')
title_r.set(f'{{{W}}}rsidR', '00AA0001')
title_r.append(copy.deepcopy(title_rPr))
title_t = etree.SubElement(title_r, f'{{{W}}}t')
title_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
title_t.text = title_text

5. 构建正文段落

body_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
body_p.append(body_pPr)

# 正文rPr:宋体四属性 + hint=eastAsia + sz=21
body_rPr = etree.Element(f'{{{W}}}rPr')
body_rFonts = etree.SubElement(body_rPr, f'{{{W}}}rFonts')
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
    body_rFonts.set(f'{{{W}}}{attr}', '宋体')
body_rFonts.set(f'{{{W}}}hint', 'eastAsia')
etree.SubElement(body_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-4')
etree.SubElement(body_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '21')

body_ins = etree.SubElement(body_p, f'{{{W}}}ins')
body_ins.set(f'{{{W}}}id', str(new_ins_id + 1))
body_ins.set(f'{{{W}}}author', 'WB')
body_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')

body_r = etree.SubElement(body_ins, f'{{{W}}}r')
body_r.set(f'{{{W}}}rsidR', '00AA0001')
body_r.append(copy.deepcopy(body_rPr))
body_t = etree.SubElement(body_r, f'{{{W}}}t')
body_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
body_t.text = body_text

6. 插入并删除原段落(⚠️ addprevious顺序陷阱)

关键addprevious将元素插入到目标元素的紧邻前一个位置。要得到 [title, body, old_p] 的顺序,必须:

old_p = paragraphs[target_idx]
old_p.addprevious(body_p)   # 先插入body → 顺序: body, old_p
body_p.addprevious(title_p)  # 再在body前插入title → 顺序: title, body, old_p
body.remove(old_p)           # 删除原段落 → 顺序: title, body, ...

错误做法(会导致顺序反转):

# ❌ 错误:title在body之后
old_p.addprevious(title_p)  # title, old_p
old_p.addprevious(body_p)   # title, body, old_p  ← 看起来对但实际是 body, title, old_p

原理:addprevious始终插入到目标元素的紧邻前一个位置。old_p.addprevious(body_p) 后 body_p 是 old_p 的前一个兄弟;old_p.addprevious(title_p) 后 title_p 成为 old_p 的前一个兄弟,body_p 被推到 title_p 之前。

7. 保存

new_doc_xml = etree.tostring(doc_xml.getroot(), xml_declaration=True, encoding='UTF-8', standalone=True)

with zipfile.ZipFile(docx_path, 'w', zipfile.ZIP_DEFLATED) as zf_out:
    for name, data in all_files.items():
        if name == 'word/document.xml':
            zf_out.writestr(name, new_doc_xml)
        else:
            zf_out.writestr(name, data)

验证

  1. 段落顺序:确认 [title_idx] 是标题文本,[title_idx+1] 是正文文本
  2. 字体属性:标题 rPr 含 rFonts四属性(黑体) + hint=eastAsia + sz=24 + bold;正文 rPr 含 rFonts四属性(宋体) + hint=eastAsia + sz=21
  3. INS属性:author=WB, 有 rsidR, 有唯一id
  4. OnlyOffice渲染:x2t渲染为PDF,pdftotext确认标题和正文各占一行,正文有缩进
  5. validate():运行ContractEditor的validate(),区分预存误报和本轮新增问题

注意事项

  • 标题和正文的pPr应从紧邻的原文同级段落克隆,而非从目标段落自身克隆
  • rFonts必须设置四属性(ascii, hAnsi, eastAsia, cs),仅设hint=eastAsia是不够的
  • 标题的bold属性按照reviewer的指令设置(注意:原文标题可能不加粗,但reviewer可能要求加粗)
  • 两个INS段落使用不同的id(从文档中max_ins_id+1开始递增)
  • 操作前先备份原文件