## 批注注入（comments.xml 机制）

ContractEditor 库**不支持批注**。批注需要直接操作 docx 的 OOXML 批注三件套。验证可用（2026-06-17 培训合同实战）。

### 批注的三个组成部分
1. **word/comments.xml**：批注内容本体（每条 `<w:comment>`，含 id/author/date/initials）。
2. **word/document.xml**：在被批注的段落里插入锚点：
   - `<w:commentRangeStart w:id="X"/>` —— 放在段落第一个 run/ins 之前
   - `<w:commentRangeEnd w:id="X"/>` —— 放在段落末尾
   - 一个含 `<w:commentReference w:id="X"/>` 的 run —— 放在 rangeEnd 之后
3. **关系声明**：
   - `[Content_Types].xml` 加 Override（comments.xml 的 ContentType）
   - `word/_rels/document.xml.rels` 加 Relationship（指向 comments.xml）

### 可复用代码（在 ContractEditor 修订并 save 之后，对成品 docx 注入批注）

```python
import zipfile, io
from lxml import etree

W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
Wq = '{' + W + '}'

# 批注清单：anchor 是"接受修订后能精确匹配（唯一）"的段落片段
comments = [
    {"id": "201", "anchor": "甲方扣除相应服务费后", "text": "建议……"},
    {"id": "202", "anchor": "向甲方住所地人民法院提起诉讼", "text": "建议……"},
]
DATE = "2026-06-17T10:00:00Z"   # ISO格式

def build_comments_xml(comments):
    parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
    parts.append(f'<w:comments xmlns:w="{W}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">')
    for c in comments:
        parts.append(f'<w:comment w:id="{c["id"]}" w:author="WB" w:date="{DATE}" w:initials="WB">')
        # 批注文字字号建议比正文小（sz=18=9pt），字体跟随文档（宋体+hint=eastAsia）
        parts.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>')
        parts.append(f'<w:t xml:space="preserve">{c["text"]}</w:t></w:r></w:p>')
        parts.append('</w:comment>')
    parts.append('</w:comments>')
    return ''.join(parts)

def inject_comments(in_path, out_path, comments):
    comments_xml = build_comments_xml(comments)
    with open(in_path, 'rb') as f:
        data = f.read()
    buf_in, buf_out = io.BytesIO(data), io.BytesIO()
    inserted = {c["id"]: False for c in comments}
    with zipfile.ZipFile(buf_in, 'r') as zin, zipfile.ZipFile(buf_out, 'w', zipfile.ZIP_DEFLATED) as zout:
        for item in zin.infolist():
            raw = zin.read(item.filename)
            if item.filename == 'word/document.xml':
                tree = etree.fromstring(raw)
                body = tree.find(f'{Wq}body')
                for para in body.findall(f'.//{Wq}p'):
                    # 接受修订后文本（跳过 w:del 内的 t）做锚点匹配
                    ptext = ''
                    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"]
                            # 第一个挂靠点：段落第一个 r 或 ins
                            first_child = None
                            for child in para:
                                if child.tag in (f'{Wq}r', f'{Wq}ins'):
                                    first_child = child; break
                            if first_child is None:
                                continue
                            crs = etree.Element(f'{Wq}commentRangeStart'); crs.set(f'{Wq}id', cid)
                            first_child.addprevious(crs)
                            cre = etree.Element(f'{Wq}commentRangeEnd'); cre.set(f'{Wq}id', cid)
                            para.append(cre)
                            rr = etree.SubElement(para, f'{Wq}r')
                            rrp = etree.SubElement(rr, f'{Wq}rPr')
                            rs = etree.SubElement(rrp, 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 item.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 item.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(item, raw)
        zout.writestr('word/comments.xml', comments_xml.encode('utf-8'))
    with open(out_path, 'wb') as f:
        f.write(buf_out.getvalue())
    return inserted   # 检查是否全部 True
```

### 锚点选择要点
- anchor 必须是**接受修订后文本里唯一**的片段（先用脚本验证 hits==1，多处命中会挂错段）。
- 避免选被修订（w:del/w:ins）切割的文字做anchor——优先选未被改动的稳定片段。
- 若 anchor 落在被修订段，匹配用"接受修订后文本"（跳过 w:del），与上面代码一致。

### 验证（终审必做）
```python
# commentRangeStart/End/Reference 与 comments.xml 的 id 必须全部对应
crs = set(e.get(Wq+'id') for e in doc_root.iter(Wq+'commentRangeStart'))
cre = set(e.get(Wq+'id') for e in doc_root.iter(Wq+'commentRangeEnd'))
cref = set(e.get(Wq+'id') for e in doc_root.iter(Wq+'commentReference'))
com = set(c.get(Wq+'id') for c in com_root.iter(Wq+'comment'))
assert crs == cre == cref == com
```
末了用 python-docx `Document(out)` 能打开（XML合法）+ OnlyOffice 渲染确认批注气泡显示。
