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