6.6 KiB
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)
验证
- 段落顺序:确认 [title_idx] 是标题文本,[title_idx+1] 是正文文本
- 字体属性:标题 rPr 含 rFonts四属性(黑体) + hint=eastAsia + sz=24 + bold;正文 rPr 含 rFonts四属性(宋体) + hint=eastAsia + sz=21
- INS属性:author=WB, 有 rsidR, 有唯一id
- OnlyOffice渲染:x2t渲染为PDF,pdftotext确认标题和正文各占一行,正文有缩进
- validate():运行ContractEditor的validate(),区分预存误报和本轮新增问题
注意事项
- 标题和正文的pPr应从紧邻的原文同级段落克隆,而非从目标段落自身克隆
- rFonts必须设置四属性(ascii, hAnsi, eastAsia, cs),仅设hint=eastAsia是不够的
- 标题的bold属性按照reviewer的指令设置(注意:原文标题可能不加粗,但reviewer可能要求加粗)
- 两个INS段落使用不同的id(从文档中max_ins_id+1开始递增)
- 操作前先备份原文件