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