# 修订态 markup 清洁度 + 双视图核验

来源：2026-06-23 邹家民事诉讼监督申请书审核（Doro 给 7 点修改指示）。教训：第一遍用 `tracked_replace`（字符级 diff）做整句改写和称谓替换，接受态文本正确，但**修订态被 difflib 咬成半字脏标记**（`莲都法~~庭~~院`、`未经~~及~~法庭审理`、`一百二十八条~~切~~国家机关`）。Doro 用 OnlyOffice 看的是修订态且有格式洁癖 → 必须重做。

## 核心规则

| 改动类型 | 用什么 | 为什么 |
|---|---|---|
| 单字错别字、补一个字、删一个字 | `tracked_replace`（字符级，库自带） | 改动点孤立，diff 干净 |
| 整词替换（尤其新旧共享字，如 法**庭**→莲都法**院**） | `tracked_block_replace`（整块 del+ins） | 阻止 difflib 咬共享字产生半字 |
| 整句/整段改写 | `tracked_block_replace` | 同上，markup 是清爽的[删旧句][插新句] |
| 删整段（合并条款、删一个自动编号列表项） | `tracked_delete_paragraph`（段落级标删） | 让自动编号重排（删请求三→请求四自动续成三） |

判据：**只要新旧文本有共享字符且改动跨多字，就别用 tracked_replace，改用 block。**

## 两个补充方法（库 contract_docx_lib.py 没有，用 types.MethodType 挂到实例上）

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

ed = ContractEditor(SRC); ed._author='小Maggie'   # 署文书归属人

# ---- 整块替换：[del 整段旧][ins 整段新]，markup 干净 ----
def tracked_block_replace(self, old_text, new_text):
    for p in self.body.findall(qn('p')):
        runs=p.findall(qn('r'))
        if not runs: continue
        full=''.join(''.join(t.text or '' for t in r.findall(qn('t'))) for r in runs)
        if old_text not in full: continue
        start=full.index(old_text); end=start+len(old_text)
        rpr=None; pos=0
        for r in runs:
            rt=''.join(t.text or '' for t in r.findall(qn('t')))
            if pos+len(rt)>start:
                rpr=r.find(qn('rPr'))
                if rpr is not None:
                    rpr=copy.deepcopy(rpr); self._ensure_rfonts_complete(rpr)
                    if rpr.find(qn('sz')) is None and self._body_rpr is not None:
                        bsz=self._body_rpr.find(qn('sz'))
                        if bsz is not None:
                            etree.SubElement(rpr,qn('sz')).set(qn('val'),bsz.get(qn('val')))
                break
            pos+=len(rt)
        pos=0; first=last=None; prefix=suffix=''
        for idx,r in enumerate(runs):
            rt=''.join(t.text or '' for t in r.findall(qn('t')))
            re_=pos+len(rt)
            if re_>start and pos<end:
                if first is None: first=idx; prefix=full[pos:start]
                last=idx; suffix=full[end:re_] if re_>end else ''
            pos=re_
        ref=runs[first]; parent=ref.getparent(); ip=list(parent).index(ref)
        for idx in range(last,first-1,-1): parent.remove(runs[idx])
        i=ip
        if prefix: parent.insert(i,self._mk_run(prefix,rpr)); i+=1
        parent.insert(i,self._mk_del(old_text,rpr)); i+=1
        parent.insert(i,self._mk_ins(new_text,rpr)); i+=1
        if suffix: parent.insert(i,self._mk_run(suffix,rpr))
        return True
    return False
ed.tracked_block_replace=types.MethodType(tracked_block_replace,ed)

# ---- 段落级修订删除：整段(含段落标记)标删，自动编号重排 ----
def tracked_delete_paragraph(self, search_text):
    p=self.find_para(search_text)
    if p is None: return False
    for r in list(p.findall(qn('r'))):
        rpr=r.find(qn('rPr')); texts=r.findall(qn('t'))
        d=etree.Element(qn('del')); d.set(qn('id'),self._next_id())
        d.set(qn('author'),self._author); d.set(qn('date'),self._revision_date)
        nr=etree.SubElement(d,qn('r')); nr.set(qn('rsidDel'),self._rsid)
        if rpr is not None: nr.append(copy.deepcopy(rpr))
        for t in texts:
            dt=etree.SubElement(nr,qn('delText')); dt.set(XML_SPACE,'preserve'); dt.text=t.text
        idx=list(p).index(r); p.remove(r); p.insert(idx,d)
    ppr=p.find(qn('pPr'))
    if ppr is None: ppr=etree.Element(qn('pPr')); p.insert(0,ppr)
    rpr=ppr.find(qn('rPr'))
    if rpr is None: rpr=etree.SubElement(ppr,qn('rPr'))
    dem=etree.Element(qn('del')); dem.set(qn('id'),self._next_id())
    dem.set(qn('author'),self._author); dem.set(qn('date'),self._revision_date)
    rpr.insert(0,dem)            # 段落标记标删 → 接受后整段消失，编号重排
    return True
ed.tracked_delete_paragraph=types.MethodType(tracked_delete_paragraph,ed)
```

执行顺序：小改动(tracked_replace) → 称谓/整句(block) → 大改写(block) → 删段(delete_paragraph) → save。

## 半角括号 → 全角（自动编号子标题 (一)→（一）)

子标题 `(一)(二)` 是自动编号，半角括号根在 numbering.xml 的 lvlText `(%1)`，不在正文。改 numbering.xml 一次性全改最干净（不要去正文里找，找不到）：

```python
import zipfile, io
from lxml import etree
W='{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
with zipfile.ZipFile(SRC) as z:
    num=etree.fromstring(z.read('word/numbering.xml'))
for lt in num.iter(W+'lvlText'):
    v=lt.get(W+'val')
    if v and ('(' in v or ')' in v):
        lt.set(W+'val', v.replace('(','（').replace(')','）'))
new=etree.tostring(num,xml_declaration=True,encoding='UTF-8',standalone=True)
buf=io.BytesIO()
with zipfile.ZipFile(SRC) as zin, zipfile.ZipFile(buf,'w',zipfile.ZIP_DEFLATED) as zout:
    for it in zin.infolist():
        zout.writestr(it, new if it.filename=='word/numbering.xml' else zin.read(it.filename))
open(OUT,'wb').write(buf.getvalue())
```
注意：`、`（如 一、二、 和 %1、）不受影响，只动含半角圆括号的 lvlText。

## 🔴 插入新编号条款前：先判"中文编号是自动编号 还是 手动文字"——`numId=0` = 无编号（2026-06-24 平和外教通知实测，第一版栽了）

要在文件中**插入一个新的带编号大条款**（如 Doro 要"把追回权抽成单独第五条，囊括所有情形"）时，**绝不能假设"四、""五、"这些中文编号是自动编号**。判错会让新条款不显示编号、且原"五、其他说明"不顺延。

**判别（动手前必做）**：
```python
# 1. 看标题段的 numPr
ppr=p.find(W+'pPr'); numpr=ppr.find(W+'numPr') if ppr is not None else None
nid=numpr.find(W+'numId').get(W+'val') if numpr is not None else None
# 2. 看 numbering.xml 里这个 numId 到底定义了没
#    list 出所有 <w:num w:numId=...> 。若标题段挂的 numId 不在其中 → 不是真自动编号
```
**关键陷阱**：`<w:numId w:val="0"/>` 是 OOXML **特殊值＝取消编号/无编号**，numbering.xml 里**根本不会有 numId=0 的定义**。本会话"四、违规处理细则""五、其他说明"段都挂 `numId=0`，我误判成"自动编号、插同级会自动续号"，构造了 numId=0 的新段落——结果 OO 渲染里新条款**完全不显示编号**、"五、其他说明"也没变"六、"。一查 numbering.xml：只有 numId=1/2/3，**没有 0**。再查标题段 run 文本：直接是 `"四、违规处理细则"`、`"五、其他说明"`——**"四""五"是手打进正文的文字字符，不是自动编号生成的**。

**正确判据**：
- run 文本里**肉眼能看到**"四、""五、"这些字 + 段落 numId=0/无 numPr/numId 不在 numbering.xml → **手动文字编号**。
- run 文本里**看不到**编号字（只有"违规处理细则"无"四、"）+ 段落挂了真实存在的 numId → **自动编号**（numFmt=chineseCounting,lvlText='%1、'）。

**手动文字编号的插入做法**（本会话最终正确版）：
1. 新条款文本**自己写上编号字**："五、无论发生上述何种违规情形，……追回……全部免税住房补贴。"（复制相邻标题段的 pPr+rPr 做格式模板，但不靠 numPr 出号）。
2. 新段落作为整段插入修订：pPr/rPr 里塞 `<w:ins>`（段落标记标插），正文 run 用 `_mk_ins` 包裹，author=自己。
3. **手动把后续编号往后顺延**：原"五、其他说明"用 tracked_block_replace 把"五、其他说明"→"六、其他说明"（手动改字，留痕）。
4. 渲染**接受态**用 vision 核对："四、…→ 五、(新条款) → 六、其他说明" 连续无重号——本会话改对后 OO 实测通过。

**自动编号的插入做法**（对照，仅当确属自动编号时）：插一个挂同 numId 的同级段落，编号会自动续；原后续条款自动顺延，**无需手动改编号字**（见本文件"结构性改动技巧"段的 numPr 自动重排）。

⚠️ 顺带的衔接坑：抽条款时若要删的原文句与他人(bittersweet) ins 用逗号"咬合"（"…索赔主张【bittersweet逗号】同时…追回全部【原文】"），**只标删原文那半句、不碰他人 ins 的逗号**（铁律：他人修订不动）；接受态以逗号收尾虽不完美，但独立新条款紧随其后能化解观感，宁可这样也不改他人标点。

## 仿宋全角引号修复（OnlyOffice 把弯引号渲染成 Times）

**症状**：正文是仿宋，但中文弯引号 “ ”（U+201C/U+201D）显示成又粗又重的西文 Times New Roman 引号，和仿宋正文不协调。Doro 2026-06-23 要求"引号统一为仿宋的全角"。

**根因**：弯引号是"中西文模糊字符"。这些引号所在 run 的 rFonts 往往 `ascii=Times New Roman, eastAsia=None`（eastAsia 继承样式里的仿宋）。OnlyOffice 对 **eastAsia 未显式设定**的模糊字符按 **ascii 字体**渲染 → 走了 Times。诊断：遍历所有含 U+201C/U+201D 的 run，打印 `rFonts/@ascii` 与 `@eastAsia`，会看到 ascii=Times、eastAsia=None。

**修法**：给所有含引号的 run 显式设 rFonts。
- **纯引号 / 引号+中文的 run**：`ascii=eastAsia=hAnsi=cs=仿宋, hint=eastAsia`（ascii 也设仿宋，彻底消除模糊字符歧义——只设 eastAsia 在某些 OO 版本仍可能走 ascii）。
- **引号+拉丁数字混排的 run**（如 `“2026浙1102…`）：**拆 run**——引号片段走仿宋，数字片段保留 `ascii=Times New Roman`。否则给整 run 设仿宋会把"2026"也变仿宋。拆法：按字符是否∈`{U+201C,U+201D}`分组，逐组生成新 run（引号组设仿宋、非引号组设 Times），原 run 删除、按序 insert 回去。

```python
W='{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
def qn(t): return W+t
FANG='仿宋'
def has_latin(s): return any(c.isascii() and c.isalnum() for c in s)
for p in list(root.iter(qn('p'))):
    for r in list(p.findall(qn('r'))):
        txt=''.join(t.text or '' for t in r.findall(qn('t')))
        if '\u201c' not in txt and '\u201d' not in txt: continue
        rpr=r.find(qn('rPr')); rf=rpr.find(qn('rFonts')) if rpr is not None else None
        if not has_latin(txt):                       # 纯引号/中文：整 run 设仿宋
            if rf is not None:
                for a in ('ascii','eastAsia','hAnsi','cs'): rf.set(qn(a),FANG)
                rf.set(qn('hint'),'eastAsia')
        else:                                          # 含数字：按引号/非引号拆 run
            import copy
            segs=[]; cur=''; cur_q=None
            for c in txt:
                isq=c in '\u201c\u201d'
                if cur_q is None: cur_q=isq; cur=c
                elif isq==cur_q: cur+=c
                else: segs.append((cur,cur_q)); cur=c; cur_q=isq
            if cur: segs.append((cur,cur_q))
            parent=r.getparent(); idx=list(parent).index(r); parent.remove(r)
            for seg,isq in segs:
                nr=etree.Element(qn('r'))
                if rpr is not None:
                    nrpr=copy.deepcopy(rpr); nr.append(nrpr)
                    nrf=nrpr.find(qn('rFonts'))
                    if nrf is None: nrf=etree.SubElement(nrpr,qn('rFonts')); nrpr.insert(0,nrf)
                    if isq:
                        for a in ('ascii','eastAsia','hAnsi','cs'): nrf.set(qn(a),FANG)
                        nrf.set(qn('hint'),'eastAsia')
                    else:
                        nrf.set(qn('ascii'),'Times New Roman'); nrf.set(qn('hAnsi'),'Times New Roman')
                nt=etree.SubElement(nr,qn('t')); nt.set('{http://www.w3.org/XML/1998/namespace}space','preserve'); nt.text=seg
                parent.insert(idx,nr); idx+=1
```
**验证**：遍历所有含引号字符的 run，断言 `rFonts/@eastAsia=='仿宋'`，计数应等于全文引号字符数。**OO 渲染确认**：x2t 转 PDF 裁剪含引号区域，目视引号是否纤细、与仿宋协调（不再是重弯钩 Times）——`hint=eastAsia` 不一定够，必须 OO 实渲染验。
**字体诊断通法**：正文中文是不是仿宋？查 styles.xml 的正文样式 rFonts（本会话正文 3333 字 eastAsia=None 全继承样式里的"仿宋"）。引号异常往往是局部 run 覆盖了样式字体。

## 全局字体规范化（中文仿宋 / 英数 Times New Roman）

来源：2026-06-23 邹家 v8→v9，Doro 要求"引号统一仿宋 + 中文仿宋 + 英文/数字 Times New Roman"。这是 Doro/Maggie 的**通用排版规范**，不止引号——整篇都要中文走仿宋、拉丁字母与阿拉伯数字走 Times。引号修法见上一节，本节是**全篇 run 的一次性归一**（上一节是引号专项，本节覆盖全文）。

**做法**：遍历每个 run，按字符类别分组，混排 run 拆开，逐组设 rFonts。分类口径：
- CJK 汉字 + 中文标点（。，、；：）→ `eastAsia=ascii=hAnsi=cs=仿宋, hint=eastAsia`
- 拉丁字母 + 阿拉伯数字 → `ascii=hAnsi=Times New Roman`（eastAsia 保留仿宋兜底）
- 弯引号/中文全角符号 → 仿宋（同 CJK，见上一节拆法）

混排 run（如 `浙1102民初2078号`、`HUAXIN XU 徐华鑫`）必须**拆 run**——给整 run 设一种字体会污染另一类字符。拆法同引号节：按字符类别切片，逐片 deepcopy rPr 后改 rFonts，原 run 删除按序 insert 回去。

**验证（Doro 口径，必做）**：改完统计全文 run 字体属性分布，按四类断言计数：
```
最终文件字体属性:
  NNNN CJK   仿宋
  NNN  LATIN Times New Roman
  NNN  PUNCT 仿宋
  NN   QUOTE 仿宋
规范达标: ✅ 中文全仿宋 + 英数全 Times
```
读 word/document.xml 所有 run，逐字符按类别归到该 run 的 rFonts 目标字体；发现"中文 run 的 eastAsia≠仿宋"或"纯拉丁 run 的 ascii≠Times"即为漏网，回去补。

### ⚠ OnlyOffice 容器没有真仿宋字体——验字体看属性，不看字形
用 OO 容器 x2t 渲染来**目视**核验时要警惕：**生产 OO 容器通常没装真·仿宋**，会把"仿宋"字体名回退映射到 Noto Serif（字形偏宋体）。所以渲染图里中文看着像宋体 **≠ 文件错了**——文件 rFonts 的字体名属性是"仿宋"才是关键，Doro 本机有真仿宋会正确显示。
- 验"中文是不是仿宋"：**查 docx 的 rFonts/@eastAsia 属性值**（程序断言），**不靠 OO 渲染图字形**判断。
- OO 渲染图只用来验**布局**（无缺字方框/无字体回退 tofu/无文字重叠/英数确为 Times 衬线）——vision_analyze 看。
- **别污染生产容器**：若为渲染临时往 OO 容器塞了字体映射，验完必须移除恢复，不留临时字体污染生产环境。

## 双视图核验

### A. 修订态（Doro/莎莎用 OnlyOffice 看痕迹）→ 用 OO 容器 x2t，不用 LibreOffice
LibreOffice 与 OnlyOffice 不同源，必须用 OO 口径验 markup。
```bash
docker cp v_final.docx nextcloud-onlyoffice-1:/tmp/z.docx
docker exec nextcloud-onlyoffice-1 bash -lc '
cat >/tmp/zx.xml <<XML
<?xml version="1.0" encoding="utf-8"?>
<TaskQueueDataConvert xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<m_sFileFrom>/tmp/z.docx</m_sFileFrom><m_sFileTo>/tmp/z.pdf</m_sFileTo>
<m_nFormatTo>513</m_nFormatTo><m_bIsNoBase64>false</m_bIsNoBase64>
</TaskQueueDataConvert>
XML
cd /var/www/onlyoffice/documentserver/server/FileConverter/bin && ./x2t /tmp/zx.xml'
docker cp nextcloud-onlyoffice-1:/tmp/z.pdf /tmp/markup.pdf
pdftoppm -png -r 150 /tmp/markup.pdf /tmp/mk   # 转图 vision_analyze 查脏标记
```
查点：每个 del/ins 是整段配对、无半字、无文字重叠。

### B. 接受态（成稿）→ 程序剥离修订后渲染，核对编号/括号
```python
# 接受所有修订：unwrap ins(留子元素)、删 del、删被标删的空列表段
for ins in list(doc.iter(W+'ins')):
    par=ins.getparent(); idx=list(par).index(ins)
    for ch in list(ins): par.insert(idx,ch); idx+=1
    par.remove(ins)
for d in list(doc.iter(W+'del')):
    par=d.getparent()
    if par is not None: par.remove(d)
for p in list(body.findall(W+'p')):           # 删 tracked_delete_paragraph 留下的空列表项
    txt=''.join((t.text or '') for t in p.iter(W+'t'))
    ppr=p.find(W+'pPr'); has_num=ppr is not None and ppr.find(W+'numPr') is not None
    if has_num and txt.strip()=='': body.remove(p)
```
核对：删条款后自动编号一二三连续无断号、全角括号生效、U+FFFD=0。

## 自查清单（交付前）
- [ ] 整词/整句改写用了 block，不是 tracked_replace（修订态无半字）
- [ ] 删段用 delete_paragraph，接受后编号重排无断号
- [ ] 全角括号改在 numbering.xml
- [ ] validate() 过（请求项/标题加粗误报已豁免核对）
- [ ] ins author = 文书归属人、字体无缺失
- [ ] OO 渲染修订态 markup 干净（vision 查）
- [ ] 程序剥离后接受态编号/括号正确
- [ ] 引号统一仿宋全角（含引号 run eastAsia=仿宋，OO 渲染确认不再是 Times；含数字的拆 run）
- [ ] 全局字体规范：全篇中文 run eastAsia=仿宋、拉丁/数字 run ascii=Times（混排已拆 run）；用属性断言四类计数核验，**不靠 OO 字形**（容器无真仿宋，会回退宋体；OO 图只验布局）
- [ ] 所有法条核权威原文（宪法 2018 修正、民诉法 2023 修正——条号会变，不凭记忆）。**精确到正确款项**：2026-06-23 Doro 自己把"不得少于十五日"写成证据规定第五十一条**第二款**，实际在**第一款**（第二款是补正不受期间限制）。审核职责含纠正委托人的法条款项笔误，逐条核到款。
