Files
hermes-skills/skills/legal/litigation-document-preparation/references/tracked-changes-text-replacement.md
T

10 KiB

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

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 处改动).

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→numFmtchineseCountingThousand + lvlText (%1) = 渲染「(一)(二)(三)」)。详见 docx-format-verification.md。本会话删的三段均为无 numPr 的正文段,编号链未受影响。

Verification After Save

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:delTextpPr/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(零乱码 / 页数)。
  • 报 ✗ 时先换语料重核,别急着改文件——很可能文件是对的,是验证方法用错了层。