# Tracked Changes for Text Replacement (python-docx + lxml)

For **simple text replacements** (fix typos, swap terms, correct punctuation) in reviewed documents,
python-docx load + lxml XML manipulation + python-docx save works well. This is **simpler** than the
pure zipfile+lxml approach used for complex format-sensitive edits (Section 四 of the main skill).

## When to Use This vs Pure zipfile+lxml

| Scenario | Method |
|---|---|
| Text replacement (swap words, fix typos, correct brackets) | python-docx + lxml (this reference) |
| New paragraphs, format-sensitive edits, number-heavy legal analysis | Pure zipfile + lxml (Section 四) |
| Contract review (合同审查) | contract_docx_lib.py |

## Implementation Pattern

```python
import copy
from docx import Document
from docx.oxml.ns import qn
from lxml import etree

doc = Document('input.docx')
AUTHOR = "WB"
DATE = "2026-06-11T21:30:00Z"

def tracked_replace_in_paragraph(para, old_text, new_text, author=AUTHOR, date=DATE):
    """Replace old_text with new_text using w:del + w:ins tracked changes."""
    full_text = para.text
    if old_text not in full_text:
        return False
    
    # Find which runs contain old_text
    runs = para.runs
    accumulated = ""
    start_run = end_run = -1
    start_offset = end_offset = 0
    idx = full_text.find(old_text)
    
    for i, run in enumerate(runs):
        prev_len = len(accumulated)
        accumulated += run.text
        if start_run == -1 and idx >= prev_len and idx < prev_len + len(run.text):
            start_run = i
            start_offset = idx - prev_len
        if start_run != -1 and len(accumulated) >= idx + len(old_text):
            end_run = i
            end_offset = idx + len(old_text) - prev_len
            break
    
    if start_run == -1:
        return False
    
    # Get rPr from first affected run (preserves font/size/bold)
    rpr = runs[start_run]._element.find(qn('w:rPr'))
    rpr_xml = copy.deepcopy(rpr) if rpr is not None else None
    
    # Single-run case
    if start_run == end_run:
        run_elem = runs[start_run]._element
        parent = run_elem.getparent()
        before_text = runs[start_run].text[:start_offset]
        after_text = runs[start_run].text[end_offset:]
        insert_pos = list(parent).index(run_elem)
        
        # Before-text run
        if before_text:
            br = copy.deepcopy(run_elem)
            br.find(qn('w:t')).text = before_text
            parent.insert(insert_pos, br)
            insert_pos += 1
        
        # w:del
        del_elem = etree.SubElement(parent, qn('w:del'))
        del_elem.set(qn('w:id'), str(hash(old_text) % 10000))
        del_elem.set(qn('w:author'), author)
        del_elem.set(qn('w:date'), date)
        del_run = etree.SubElement(del_elem, qn('w:r'))
        if rpr_xml: del_run.append(copy.deepcopy(rpr_xml))
        dt = etree.SubElement(del_run, qn('w:delText'))
        dt.set(qn('xml:space'), 'preserve')
        dt.text = old_text
        parent.insert(insert_pos, del_elem)
        insert_pos += 1
        
        # w:ins
        ins_elem = etree.SubElement(parent, qn('w:ins'))
        ins_elem.set(qn('w:id'), str((hash(new_text) + 1) % 10000))
        ins_elem.set(qn('w:author'), author)
        ins_elem.set(qn('w:date'), date)
        ins_run = etree.SubElement(ins_elem, qn('w:r'))
        if rpr_xml: ins_run.append(copy.deepcopy(rpr_xml))
        it = etree.SubElement(ins_run, qn('w:t'))
        it.set(qn('xml:space'), 'preserve')
        it.text = new_text
        parent.insert(insert_pos, ins_elem)
        insert_pos += 1
        
        # After-text run
        if after_text:
            ar = copy.deepcopy(run_elem)
            ar.find(qn('w:t')).text = after_text
            parent.insert(insert_pos, ar)
        
        parent.remove(run_elem)
        return True
    
    # Multi-run case: merge affected runs, then split
    # (same logic but operates across run boundaries)
    # ... see session 20260611 for full implementation
```

## Key Notes

- **w:id must be unique** across all ins/del in the document. Using `hash() % 10000` works for small batches but can collide — use a counter for production.
- **rPr must be copied** from the original run to preserve font/size/bold in both del and ins elements.
- **xml:space='preserve'** is required on both w:delText and w:t, or Word strips leading/trailing whitespace.
- **Paragraph alignment fix** (e.g. missing JUSTIFY): manipulate `w:pPr/w:jc` directly — this isn't a tracked change, just a format correction.

## Whole-Paragraph Delete & Full-Paragraph Replace (pure zipfile+lxml)

For **structural restructures** — deleting an entire paragraph, or replacing a whole
paragraph's text — operate on document.xml directly and rewrite the zip. Cleaner than
python-docx save for multi-paragraph edits (2026-06-16 上海喆航 VS AMOS 管辖异议重构, 5 处改动).

```python
import zipfile, copy, os
from lxml import etree
W='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def w(t): return f'{{{W}}}{t}'
XMLSPACE='{http://www.w3.org/XML/1998/namespace}space'
AUTHOR="苌莎莎"; DATE="2026-06-16T12:00:00Z"      # author 跟随指示人，见下
_id=[3000]
def nid(): _id[0]+=1; return str(_id[0])          # 全文唯一计数器，别用 hash()

def del_para(p):
    """整段删除：每个 run 包 w:del + w:t→w:delText，并标记段落标记删除。"""
    for r in p.findall(w('r')):
        idx=list(p).index(r); p.remove(r)
        for t in r.findall(w('t')):
            t.tag=w('delText'); t.set(XMLSPACE,'preserve')
        d=etree.Element(w('del')); d.set(w('id'),nid()); d.set(w('author'),AUTHOR); d.set(w('date'),DATE)
        d.append(r); p.insert(idx,d)
    # 关键：标记段落标记(paragraph mark)删除，否则接受修订后会留空行
    ppr=p.find(w('pPr')) or etree.SubElement(p,w('pPr'))
    rpr=ppr.find(w('rPr')) or etree.SubElement(ppr,w('rPr'))
    pmd=etree.SubElement(rpr,w('del')); pmd.set(w('id'),nid()); pmd.set(w('author'),AUTHOR); pmd.set(w('date'),DATE)

def repl_para(p, new_text):
    """整段文字替换：旧 run 全部 w:del，新文字一个 w:ins，rPr 取自首个旧 run。"""
    rpr_t=None
    for r in p.findall(w('r')):
        rr=r.find(w('rPr'))
        if rr is not None: rpr_t=copy.deepcopy(rr); break
    for r in p.findall(w('r')):
        idx=list(p).index(r); p.remove(r)
        for t in r.findall(w('t')):
            t.tag=w('delText'); t.set(XMLSPACE,'preserve')
        d=etree.Element(w('del')); d.set(w('id'),nid()); d.set(w('author'),AUTHOR); d.set(w('date'),DATE)
        d.append(r); p.insert(idx,d)
    ins=etree.SubElement(p,w('ins')); ins.set(w('id'),nid()); ins.set(w('author'),AUTHOR); ins.set(w('date'),DATE)
    nr=etree.SubElement(ins,w('r'))
    if rpr_t is not None: nr.append(rpr_t)
    nt=etree.SubElement(nr,w('t')); nt.set(XMLSPACE,'preserve'); nt.text=new_text

# 定位用「接受所有修订后的最终文本」匹配，不要用 run 内的碎片
def ptext(p): return ''.join(t.text or '' for t in p.findall('.//'+w('t')))

# 写回：逐项复制 zip，只替换 document.xml
newxml=etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
tmp=out+'.tmp'
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(tmp,'w',zipfile.ZIP_DEFLATED) as zout:
    for it in zin.namelist():
        zout.writestr(it, newxml if it=='word/document.xml' else zin.read(it))
os.replace(tmp,out)
```

⚠️ **整段删除的 w:del 计数会偏高**：一段 N 个 run → N 个 w:del（每 run 单独包）。
5 处改动可能产生 35+ 个 w:del，属正常，不是 bug。

## 修订 author 跟随指示人（铁律）

author 署谁 = **谁指示你改这份文书**，不是你自己（"小Maggie"）：
- ShaSha（苌莎莎）发来审核/修订 → `author="苌莎莎"`
- Doro 发来 → `author="WB"`
- 2026-06-16 教训：先误用 `author="小Maggie"`，被规范纠正为 `"苌莎莎"`。生成前先确认指示人是谁，别用机器人自己的名字。

## 自动编号链完整性（删段后必查）

删除带 `numPr` 的段落，或删除其相邻段落，可能打断一级标题的中文自动编号（一、二、三）。
删段后用三级映射核验保留下来的 `numId` 项仍正确：numId→abstractNumId→`numFmt`
（`chineseCountingThousand` + lvlText `(%1)` = 渲染「（一）（二）（三）」）。详见
`docx-format-verification.md`。本会话删的三段均为无 numPr 的正文段，编号链未受影响。

## Verification After Save

```python
from zipfile import ZipFile
from lxml import etree

with ZipFile('output.docx', 'r') as z:
    xml = z.read('word/document.xml')
    root = etree.fromstring(xml)
    ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
    ins = root.findall('.//w:ins', ns)
    dels = root.findall('.//w:del', ns)
    print(f"{len(ins)} insertions, {len(dels)} deletions")
    for i in ins:
        for t in i.findall('.//w:t', ns):
            print(f"  INS: '{t.text}'")
```

### 模拟「接受所有修订」验证最终成稿（vision 工具不可用时的兜底）

当 vision 工具报 `No LLM provider configured` 时，不靠肉眼看 PDF，改用程序化核验：
1. **模拟接受全部修订**：遍历段落，收集所有 `w:t`（含 `w:ins` 内的），跳过 `w:delText`；
   `pPr/rPr/del` 标记的整删段且接受后无文字的，丢弃。打印最终文本逐段读一遍逻辑。
2. **PDF 文字层零乱码检测**：LibreOffice 转 PDF → PyMuPDF `get_text()` →
   `re.findall(r'[\ufffd]', full)` 数量应为 0。
3. **关键内容 in 核查**：用 `"关键短语" in full_text` 逐项断言删的删了、留的留了、新写的在。

⚠️ **连续字符串核查必须打在「接受修订后」文本上，不能打在 PDF 文字层上（2026-06-16 实测陷阱）**：
- 修订模式下 PDF 把删除文本（带删除线的旧字）和新增文本**交织渲染**在一起。对 PDF 文字层做
  `"新论证短语" in pdf_text` 会**假阴性**——本会话 5 处改动里有 3 处在 PDF 核查中报 ✗，
  实则全部改对了。
- 正确做法：核查 1（in 断言）的语料 = 上面第 1 步生成的「跳过 delText、保留 ins」的最终文本，
  **不是** `pdftotext` 的输出。PDF 文字层只用于核查 2（零乱码 / 页数）。
- 报 ✗ 时先换语料重核，别急着改文件——很可能文件是对的，是验证方法用错了层。
