Files
hermes-skills/skills/legal/contract-editor/references/auto-numbered-list-clause-insert.md
T

6.8 KiB

在「自动编号的顶层列表」中新增条款(保留numPr)

2026-06-18 华新慢病运维合同实战。一次返工换来的教训。

适用判定(动手前先分清两类合同)

合同的「条款」分两种承载方式,新增条款的手法完全不同:

类型 特征 新增条款手法
A. 第X条 文本标题 条款标题是 run 里的文字「第七条 …」/「7. …」,段落 numPr add_clause()(库会剥 numPr,正确)
B. 自动编号列表项 条款本身是自动编号列表项:段落 pPr 带 <w:numPr>,编号由 numbering.xml 的 start+lvlText(如 一、/1./(1))自动渲染,run 里没有编号文字 不能用 add_clause();按下方「numbered-insert」手法

判定脚本:对要插入位置附近的条款段落跑 numbering-diagnose.py,或直接看锚点段 pPr/numPr 是否存在且其 numId 的 lvlText 是序号格式。本案锚点「五、违约责任」段 pPr = pStyle=12 + numPr(numId=1,ilvl=0) + ind,numId=1→abstractNum start=1 lvlText=%1、(japaneseCounting 一、二、三)。

为什么 add_clause 在 B 类会坏

add_clause()(contract_docx_lib.py 第408-411行)无条件剥离新段的 numPr:

numpr = new_ppr.find(qn('numPr'))
if numpr is not None:
    new_ppr.remove(numpr)   # ← B类灾难

后果(本案实测):5 个新增条款全部丢失自动编号,且因 pPr 缺 numPr/缩进与列表项不一致,渲染时堆到了文档最末尾(签署页之前),既无编号又错位——违反「新增条款插在逻辑对应位置、不堆到最后」+「自动编号保留numPr」两条规则。

add_clause 第二个缺陷:它只把文本 run 包进 w:ins,没有把段落标记(¶)标记为插入。B 类里 ¶ 承载着自动编号,¶ 不是 tracked-insert,则接受/拒绝修订时这一项的编号增减不随修订走。

正确手法:numbered tracked-insert 段落

克隆锚点段的 pPr(保留 numPr,让新段成为同一自动编号序列的一员),并把段落标记本身也标成 w:ins:

import sys, copy
sys.path.insert(0, '/home/maggie/contract-work')
from contract_docx_lib import ContractEditor, qn
from lxml import etree

ed = ContractEditor(src)

# 1) 定位锚点段(要插在它之后的那条原文条款)
anchor = None
for p in ed.body.findall(qn('p')):
    if '违约责任:按照中华人民共和国民法典' in ed.get_para_text(p):
        anchor = p; break
anchor_ppr = anchor.find(qn('pPr'))
assert anchor_ppr.find(qn('numPr')) is not None, "锚点不是自动编号项,确认是否B类"

def make_numbered_ins_para(text):
    new_p = etree.Element(qn('p'))
    new_ppr = copy.deepcopy(anchor_ppr)          # 含 pStyle + numPr(同numId/ilvl) + ind → 入同一自动编号序列
    # 关键:把段落标记(¶)标成插入,整段(含自动编号)作为 tracked insertion
    rpr_mark = new_ppr.find(qn('rPr'))
    if rpr_mark is None:
        rpr_mark = etree.SubElement(new_ppr, qn('rPr'))
    ins_mark = etree.SubElement(rpr_mark, qn('ins'))
    ins_mark.set(qn('id'), ed._next_id())
    ins_mark.set(qn('author'), 'WB')
    ins_mark.set(qn('date'), ed._revision_date)
    new_p.append(new_ppr)
    # 文本作为 tracked-ins run,用规范化 _body_rpr(完整rFonts四属性+hint=eastAsia+显式sz)
    new_p.append(ed._mk_ins(text, ed._body_rpr))
    return new_p

clauses = [  # 期望的最终正序 六~十
    "保密与数据:……",
    "知识产权与系统交接:……",
    "转包与分包:……",
    "第三方侵权:……",
    "违约赔偿:……",
]

# 2) 全部插在 anchor 之后;倒序 insert 使最终正序
parent = ed.body
anchor_idx = list(parent).index(anchor)
for txt in reversed(clauses):
    parent.insert(anchor_idx + 1, make_numbered_ins_para(txt))

assert ed.validate() == []
ed.save(out)

要点:

  • 倒序插入:每条都插在 anchor_idx+1,倒序遍历 → 最终正序。
  • 同一 numId/ilvl:克隆锚点 pPr 即自动继承,新条款自动续编(本案锚点是五 → 新条款渲染为六、七、八、九、十,后续原文自动顺延为十一、十二…,无需手动改任何原文编号)。
  • ¶ 标插入 + 文本 run 标插入,两者都要,缺一不可。
  • 文本 run 用 ed._body_rpr(库已规整:四属性 rFonts + hint=eastAsia + 显式 sz),不要手搓 rPr。

交付前验证(B 类专项)

  1. 接受所有修订后渲染(删 w:del + 删带 pPr/rPr/del 的整段 + 解包 w:ins)→ 确认新条款编号与锚点连续、原文顺延正确、无错位到末尾。
  2. 字体核对走「同段原文」标准:本案原文正文 run = <w:rFonts hint="eastAsia"/><w:szCs val="21"/>显式 eastAsia 名,继承 docDefaults 宋体)。新 INS run 与之等效即合格——ea=None hint=eastAsia正确的,wb-ins-font-verify.py 若按绝对属性报 ea=None 是假阳性(见 contract-reviewer 的 2026-06-17 培训合同条)。唯一差异是 INS 多了显式 <w:sz val="21">(w:ins 必需),渲染一致。
  3. LibreOffice 渲染假象:用 libreoffice→pdftotext 自查时,被顺延的自动编号项会显示 十二、[七、] 这种方括号叠加(recomputed 新号 + cached 旧号),这是 LibreOffice markup 渲染产物,不是错误,XML 里没有字面方括号。判真实编号一律以「接受所有修订后」或 OnlyOffice 渲染为准(OnlyOffice 是 Maggie/Doro 实际所用引擎)。

锚点选择铁律:插在 body text 之后,不是 heading 之后

这是一个极易犯的错误(2026-06-26 朱家角环保袋合同实证):

当Reviewer要求"在违约责任条款之后、争议解决条款之前新增XX条款"时,合同结构通常是:

P74: 八.违约责任        ← heading(numId=1)
P75: 若乙方未按本合同... ← body text(无 numPr)
P76: 九.合同金额        ← 下一个 heading(numId=1)

错误做法:锚点 = P74(heading),插入后 → 新条款夹在 heading 和它的 body text 之间,结构错乱。

正确做法:锚点 = P75(body text),插入后 → 新条款在 body text 之后、下一个 heading 之前,结构正确。

判据numbering-diagnose.py 确认锚点段的 numPr 状态——heading 有 numPr,body text 无 numPr。新条款应克隆下一个 heading(如 P76 合同金额)的 pPr(含 numPr),插入在前一个 body text(如 P75)之后。

一句话

锚点是自动编号列表项(pPr 有 numPr)→ 别用 add_clause,克隆锚点 pPr(留 numPr)+ ¶ 标 w:ins + 文本标 w:ins,倒序插入,新条款自动续编、原文自动顺延。插在 body text 之后,不是 heading 之后。