Files

5.4 KiB

A类(手动文本编号)新增条款:克隆"真实邻居段落"而非信任库的 _title_rpr / _body_rpr

2026-06-18 赵巷镇 X线设备采购合同实战。终审字体核验抓出"新标题不加粗",根因是库提取的标题格式丢了 bold。

何时用这套手法

  • 合同是 A 类:条款标题是 run 里的手动文本编号(如 8.争端的解决第七条 索赔),段落 numPr。
  • 要新增一个带标题的条款(标题段 + 正文段),并希望格式与兄弟条款 100% 一致。
  • (B 类自动编号列表项见 auto-numbered-list-clause-insert.md,手法不同。)

为什么不直接用库的 _title_rpr / _body_rpr

ContractEditor._extract_formats()(contract_docx_lib.py ~第86-175行)用启发式认"条款标题":

is_clause_title = (re.match(r'^\d+[..、]\s*\S', p_text) or
                   re.match(r'^第[一二三四五六七八九十百千\d]+条\s*\S', p_text)) and len(p_text) < 30
# 且 _is_title_style() 要 <w:b/> 或标题字体(黑体/SimHei…) 才算 title

:当标题就是"8.争端的解决"(宋体 + <w:b/>,无特殊标题字体),若该段在扫描中没被 _is_title_style 命中(例如 b 标记在 bCs 旁、或正则边界),clause_title_rpr / first_bold_rpr 取空 → _title_rpr 回退到 _body_rpr(不含 bold)

实测后果:新标题 8.转包与分包 的 INS run bold=False,而原文兄弟标题 9.争端的解决 bold=Truevalidate() 查不出(它不比 bold),只有逐段 WB INS 与"同段/同级原文"对比才抓得到。

稳健手法:克隆紧邻同级原文段落

不取库的 _title_rpr/_body_rpr,改为直接深拷贝隔壁真实条款段的 pPr 和首个 run 的 rPr:

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)   # 已先做完所有 tracked_replace

def find(kw):
    for p in ed.body.findall(qn('p')):
        if kw in ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')):
            return p
    return None

# 克隆来源:插入点后面那条原文条款的【标题段】和它的【正文段】
title_src = find('8.争端的解决')          # 兄弟条款标题(自带 <w:b/> + 宋体四属性 + 标题pPr缩进)
body_src  = find('双方如在履行合同中发生纠纷')  # 兄弟条款正文(无bold + firstLine=420 缩进)

def clone_as_ins(src_para, new_text):
    """深拷贝 src_para 的 pPr + 首run rPr,替换文本,整段(含¶)标 w:ins(author=WB)"""
    np = etree.Element(qn('p'))
    ppr = copy.deepcopy(src_para.find(qn('pPr')))
    # ¶ 段落标记标插入
    rprm = ppr.find(qn('rPr'))
    if rprm is None:
        rprm = etree.SubElement(ppr, qn('rPr'))
    insm = etree.SubElement(rprm, qn('ins'))
    insm.set(qn('id'), ed._next_id()); insm.set(qn('author'), 'WB'); insm.set(qn('date'), ed._revision_date)
    np.append(ppr)
    # run rPr 直接克隆兄弟段首 run(bold/字体/字号全继承,不碰库的默认值)
    src_r = src_para.find(qn('r'))
    src_rpr = copy.deepcopy(src_r.find(qn('rPr'))) if (src_r is not None and src_r.find(qn('rPr')) is not None) else None
    np.append(ed._mk_ins(new_text, src_rpr))
    return np

title_p = clone_as_ins(title_src, "8.转包与分包")
body_p  = clone_as_ins(body_src,  "未经甲方书面同意,乙方不得将本合同项下的…连带责任。")

idx = list(ed.body).index(title_src)
ed.body.insert(idx,     title_p)   # 标题插在兄弟条款标题之前 → 成为新的"8.",兄弟顺延为"9."
ed.body.insert(idx + 1, body_p)

A类手动编号的顺延(与 B 类自动顺延不同!)

A 类编号是 run 里的字面文字,不会自动顺延。插入新"8."后,必须手动把后续所有手动编号 DEL 旧号+INS 新号(用 tracked_replace):

for old, new in [("8.争端的解决","9.争端的解决"), ("9.合同生效","10.合同生效"),
                 ("9.1 本合同在…","10.1 本合同在…"), ("10.合同附件","11.合同附件"),
                 ("10.1 配置清单","11.1 配置清单"), ...,  ("12.特别约定","13.特别约定")]:
    ed.tracked_replace(old, new)
  • 子编号一并顺延(9.1/9.2→10.1/10.2,11.1-11.7→12.1-12.7)。
  • 匹配串要够长以避免短串误命中(见 SKILL.md「tracked_replace 短字符串误命中」)。

交付前验证(必做)

  1. bold 对照:新标题 INS run bold==True 且 ==兄弟标题;新正文 INS run bold==False 且有正确 firstLine 缩进。
    r = p.find('.//w:ins/w:r', ns); b = r.find('w:rPr/w:b', ns)
    # 标题段 b is not None == 兄弟标题段 b is not None
    
  2. WB INS 字体逐段核验(相对同段/同级原文):异常应为 0。原文 run 有显式宋体四属性时,克隆来的 INS 也带四属性——与原文一致即合格。
  3. 接受所有修订后渲染,确认手动编号链连续(…7、8.转包、9、10、10.1、10.2、11…13),无重号/跳号。
  4. python-docx 能打开(XML 合法)。

一句话

A 类手动编号合同新增带标题条款:别用库的 _title_rpr/_body_rpr(启发式可能丢 bold),直接 copy.deepcopy 紧邻兄弟条款的【标题段】和【正文段】的 pPr+首run rPr,文本替换+整段标 w:ins;编号不会自动顺延,手动 tracked_replace 把后续主/子编号全部 +1。