103 lines
7.0 KiB
Markdown
103 lines
7.0 KiB
Markdown
# DOCX 批注(Word 原生 comment)插入 — 纯 zipfile+lxml
|
|
|
|
实战来源:南通新东方校外培训服务合同独立审查(2026-06-17)。Maggie 要求"用修订**和批注**的形式"。`ContractEditor` 没有批注方法(`dir()` 确认无 comment/annot/note),批注必须手写 OOXML。已验证可在 OnlyOffice 正常显示。
|
|
|
|
## 何时用 DOCX 批注 vs PDF 批注
|
|
- **docx 合同** → 用本文方法(Word 原生 comment,OnlyOffice 显示为右侧批注气泡)。
|
|
- **PDF 合同** → 用 pymupdf(fitz) 高亮+comment annotation(见 SKILL.md「PDF合同直接批注」)。两者不通用。
|
|
|
|
## 批注内容铁律(与 SKILL.md 一致,复述强调)
|
|
- 只写"建议……",给方案;**不写理由/原因/因为**;**不加【新增】【建议】等标签**。
|
|
- 批注仅限两类:①需客户确认(名称空白、标准未定义需明示);②建议增加条款且内容较长。**选择题/勾选项不处理(2026-07-08废止)。**
|
|
- 能直接修订的一律修订,批注是最后手段。
|
|
|
|
## 五个改动点(缺一不可,否则 Word 报"无法打开/需修复")
|
|
1. **新增 `word/comments.xml`**:定义每条批注的 id/author/date/initials + 内容。
|
|
2. **`word/document.xml`**:在锚点文本范围**前**插 `w:commentRangeStart`、**后**插 `w:commentRangeEnd` + 一个带 `w:commentReference` 的 run。
|
|
3. **`[Content_Types].xml`**:加 `Override` 声明 comments.xml 的 content-type。
|
|
4. **`word/_rels/document.xml.rels`**:加 `Relationship` 指向 comments.xml。
|
|
5. 三处 id(rangeStart/rangeEnd/commentReference)与 comments.xml 的 `w:comment/@w:id` **必须全部一致**。
|
|
|
|
## 锚点定位(关键陷阱)
|
|
- 锚点文本要按**接受修订后**的文本匹配(遍历 w:t 时**跳过 w:del 内的**),否则被删字符会让匹配错位。
|
|
- commentRangeStart 必须插在段落第一个 `w:r` **或 `w:ins`** 之前(不能只找 w:r——修订后段首可能是 ins)。
|
|
- 先验证每个锚点在全文**唯一命中**(命中数==1)再插,多处命中会挂错段落。
|
|
|
|
## 可复用代码
|
|
```python
|
|
import zipfile, io
|
|
from lxml import etree
|
|
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
|
Wq = '{' + W + '}'
|
|
DATE = "2026-06-17T10:00:00Z"
|
|
|
|
comments = [ # 站顾问单位立场,需确认/建议增加内容
|
|
{"id":"201","anchor":"甲方扣除相应服务费后","text":"建议在合同或退费管理制度中明确“服务费”的扣费比例或计算方式,并在签约时向乙方明示。"},
|
|
{"id":"202","anchor":"向甲方住所地人民法院提起诉讼","text":"本条约定甲方住所地法院管辖,建议签约时以加粗或单独提示方式向乙方说明,尽到格式条款提示义务。"},
|
|
]
|
|
|
|
def build_comments_xml(comments):
|
|
p = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
|
f'<w:comments xmlns:w="{W}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">']
|
|
for c in comments:
|
|
p.append(f'<w:comment w:id="{c["id"]}" w:author="WB" w:date="{DATE}" w:initials="WB">')
|
|
# 批注文字字体随原文(本例宋体sz=18小一号),hint=eastAsia 必带
|
|
p.append('<w:p><w:r><w:rPr><w:rFonts w:ascii="宋体" w:hAnsi="宋体" w:eastAsia="宋体" w:cs="宋体" w:hint="eastAsia"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>')
|
|
p.append(f'<w:t xml:space="preserve">{c["text"]}</w:t></w:r></w:p></w:comment>')
|
|
p.append('</w:comments>')
|
|
return ''.join(p)
|
|
|
|
comments_xml = build_comments_xml(comments)
|
|
with open('IN.docx','rb') as f: data=f.read()
|
|
bi, bo = io.BytesIO(data), io.BytesIO()
|
|
inserted = {c["id"]: False for c in comments}
|
|
with zipfile.ZipFile(bi) as zin, zipfile.ZipFile(bo,'w',zipfile.ZIP_DEFLATED) as zout:
|
|
for it in zin.infolist():
|
|
raw = zin.read(it.filename)
|
|
if it.filename == 'word/document.xml':
|
|
tree = etree.fromstring(raw); body = tree.find(f'{Wq}body')
|
|
for para in body.findall(f'.//{Wq}p'):
|
|
ptext = '' # 接受修订后文本:跳过 del
|
|
for t in para.findall(f'.//{Wq}t'):
|
|
if not any(a.tag==f'{Wq}del' for a in t.iterancestors()):
|
|
ptext += (t.text or '')
|
|
for c in comments:
|
|
if not inserted[c["id"]] and c["anchor"] in ptext:
|
|
cid = c["id"]
|
|
first = next((ch for ch in para if ch.tag in (f'{Wq}r',f'{Wq}ins')), None)
|
|
if first is None: continue
|
|
crs = etree.Element(f'{Wq}commentRangeStart'); crs.set(f'{Wq}id',cid); first.addprevious(crs)
|
|
cre = etree.Element(f'{Wq}commentRangeEnd'); cre.set(f'{Wq}id',cid); para.append(cre)
|
|
rr = etree.SubElement(para,f'{Wq}r'); rp=etree.SubElement(rr,f'{Wq}rPr')
|
|
rs = etree.SubElement(rp,f'{Wq}rStyle'); rs.set(f'{Wq}val','CommentReference')
|
|
cref = etree.SubElement(rr,f'{Wq}commentReference'); cref.set(f'{Wq}id',cid)
|
|
inserted[cid] = True
|
|
raw = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
|
|
elif it.filename == '[Content_Types].xml':
|
|
ct = etree.fromstring(raw); NS='http://schemas.openxmlformats.org/package/2006/content-types'
|
|
ov = etree.SubElement(ct,f'{{{NS}}}Override')
|
|
ov.set('PartName','/word/comments.xml')
|
|
ov.set('ContentType','application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml')
|
|
raw = etree.tostring(ct, xml_declaration=True, encoding='UTF-8', standalone=True)
|
|
elif it.filename == 'word/_rels/document.xml.rels':
|
|
rt = etree.fromstring(raw); RNS='http://schemas.openxmlformats.org/package/2006/relationships'
|
|
r = etree.SubElement(rt,f'{{{RNS}}}Relationship')
|
|
r.set('Id','rIdComments1')
|
|
r.set('Type','http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments')
|
|
r.set('Target','comments.xml')
|
|
raw = etree.tostring(rt, xml_declaration=True, encoding='UTF-8', standalone=True)
|
|
zout.writestr(it, raw)
|
|
zout.writestr('word/comments.xml', comments_xml.encode('utf-8'))
|
|
with open('OUT.docx','wb') as f: f.write(bo.getvalue())
|
|
assert all(inserted.values()), f"未全部挂靠: {inserted}"
|
|
```
|
|
|
|
## 交付前验证(缺一不可)
|
|
1. **id 四向一致**:`commentRangeStart` / `commentRangeEnd` / `commentReference` 三组 id 集合 == comments.xml 的 `w:comment/@w:id` 集合。
|
|
2. **python-docx 能打开**(XML 合法)。
|
|
3. **接受修订后锚点存在**:批注挂靠的文本在去 del 后仍在。
|
|
4. **OnlyOffice 渲染**(onlyoffice-render.sh)确认批注气泡正常显示,不破坏修订标记。
|
|
|
|
## 修订与批注可共存
|
|
同一份 docx 先用 ContractEditor 做完 tracked_replace/add_clause 并 save,再在产物上跑本脚本加批注。批注的 commentRangeStart 会落在修订后的段落结构里(段首可能是 w:ins),代码已用 `(w:r, w:ins)` 兼容。
|