# 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行）用启发式认"条款标题"：
```python
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=True`。`validate()` 查不出（它不比 bold），只有逐段 WB INS 与"同段/同级原文"对比才抓得到。

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

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

```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)   # 已先做完所有 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）：
```python
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` 缩进。
   ```python
   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。
