# 在「自动编号的顶层列表」中新增条款(保留numPr) 2026-06-18 华新慢病运维合同实战。一次返工换来的教训。 ## 适用判定(动手前先分清两类合同) 合同的「条款」分两种承载方式,新增条款的手法完全不同: | 类型 | 特征 | 新增条款手法 | |------|------|-------------| | **A. 第X条 文本标题** | 条款标题是 run 里的文字「第七条 …」/「7. …」,段落**无** numPr | `add_clause()`(库会剥 numPr,正确)| | **B. 自动编号列表项** | 条款本身是自动编号列表项:段落 pPr 带 ``,编号由 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: ```python 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: ```python 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 = ``(**无**显式 eastAsia 名,继承 docDefaults 宋体)。新 INS run 与之等效即合格——`ea=None hint=eastAsia` 是**正确**的,`wb-ins-font-verify.py` 若按绝对属性报 `ea=None` 是假阳性(见 contract-reviewer 的 2026-06-17 培训合同条)。唯一差异是 INS 多了显式 ``(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 之后。**