# ContractEditor 原文Run属性污染诊断与修复

## 2026-07-13 洋励合同实证

### 问题描述

ContractEditor（contract_docx_lib.py）在处理文档时，不仅给WB INS runs添加多余属性，还会**修改原文runs**的rPr——给本来靠docDefaults/style继承的orig runs添加显式eastAsia/cs/sz。

### 典型污染模式

| 属性 | 原文（待审查） | 被污染后（交付物中的orig run） | WB INS run |
|------|--------------|-------------------------------|-----------|
| eastAsia | None (继承minorEastAsia) | **宋体** (被加) | None |
| cs | None | **宋体** (被加) | None |
| sz | None (继承docDefaults=22) | **21** (被加且值错) | None |
| ascii | 宋体 | 宋体 | None |
| hint | eastAsia (部分有) | eastAsia | None |

### 后果

1. 原文所有文字从11pt(docDefaults sz=22)变成10.5pt(显式sz=21) — 整体缩小0.5pt
2. INS文字没有任何属性 → 走docDefaults 11pt → 与被改小的原文不一致
3. 字体验证脚本(wb-ins-font-verify.py)报INS缺属性，但实际问题是orig被污染

### 诊断步骤

```bash
# 1. 读原文代表性段落run rPr
python3 -c "
import zipfile
from lxml import etree
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
with zipfile.ZipFile('原文.docx', 'r') as z:
    ...
# 检查: eastAsia=None? sz=None? 
# 如果是 → 原文靠继承

# 2. 读交付物同段落orig run rPr
# 检查: 是否多了eastAsia/cs/sz?
# 如果是 → 被污染
```

### 修复代码模板

```python
import zipfile, tempfile, shutil
from lxml import etree

WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'

with zipfile.ZipFile(filepath, 'r') as z:
    all_files = {n: z.read(n) for n in z.namelist()}

tree = etree.fromstring(all_files['word/document.xml'])
body = tree.find(f'{WNS}body')

# Step 1: Strip contaminated attributes from ALL orig runs
for p in body.findall(f'{WNS}p'):
    for r in p.findall(f'{WNS}r'):  # Only direct child runs (not inside ins/del)
        rpr = r.find(f'{WNS}rPr')
        if rpr is None:
            continue
        rf = rpr.find(f'{WNS}rFonts')
        if rf is not None:
            # Strip eastAsia (original didn't have it)
            if f'{WNS}eastAsia' in rf.attrib:
                del rf.attrib[f'{WNS}eastAsia']
            # Strip cs (original didn't have it)
            if f'{WNS}cs' in rf.attrib:
                del rf.attrib[f'{WNS}cs']
        # Strip sz (original relies on docDefaults)
        sz = rpr.find(f'{WNS}sz')
        if sz is not None:
            rpr.remove(sz)

# Step 2: Fix INS runs to match REAL original format
for ins in body.findall(f'.//{WNS}ins'):
    if ins.get(f'{WNS}author') != 'WB':
        continue
    for r in ins.findall(f'{WNS}r'):
        rpr = r.find(f'{WNS}rPr')
        if rpr is None:
            continue
        rf = rpr.find(f'{WNS}rFonts')
        if rf is None:
            rf = etree.SubElement(rpr, f'{WNS}rFonts')
        # Match real original: ascii=宋体, hAnsi=宋体, NO eastAsia
        rf.set(f'{WNS}ascii', '宋体')
        rf.set(f'{WNS}hAnsi', '宋体')
        if f'{WNS}eastAsia' in rf.attrib:
            del rf.attrib[f'{WNS}eastAsia']
        # Remove sz (let it inherit)
        sz = rpr.find(f'{WNS}sz')
        if sz is not None:
            rpr.remove(sz)

# Step 3: Per-paragraph hint matching
for p in body.findall(f'{WNS}p'):
    # Get orig run's hint
    orig_hint = None
    for r in p.findall(f'{WNS}r'):
        rpr = r.find(f'{WNS}rPr')
        if rpr is not None:
            rf = rpr.find(f'{WNS}rFonts')
            orig_hint = rf.get(f'{WNS}hint') if rf is not None else None
        break
    # Apply to INS runs in same paragraph
    for ins in p.findall(f'.//{WNS}ins'):
        if ins.get(f'{WNS}author') != 'WB':
            continue
        for r in ins.findall(f'{WNS}r'):
            rpr = r.find(f'{WNS}rPr')
            if rpr is None: continue
            rf = rpr.find(f'{WNS}rFonts')
            if rf is None: continue
            if orig_hint:
                rf.set(f'{WNS}hint', orig_hint)
            elif f'{WNS}hint' in rf.attrib:
                del rf.attrib[f'{WNS}hint']
```

### 注意事项

1. **必须对比原文确定被污染了哪些属性** — 不同合同模板的原文属性不同
2. **不是所有合同都有此问题** — 取决于原文是否靠继承（有显式属性的不会被"污染"，因为值相同）
3. **Step 1必须在Step 2之前** — 否则wb-ins-font-verify仍会报INS与(被污染的)orig不一致
4. **hint要逐段处理** — 同一文档不同段落的orig runs可能有的有hint有的没有
