# docx 批注（Word Comments）+ 修订痕迹 联合写入

适用：审核他人文书时，**既要插入批注（comment / 批注气泡）又要直接修订（tracked changes）**，author 跟随指示人（莎莎的诉讼文书 author=苌莎莎；Doro 的合同 author=WB；按谁审核定，不要写死）。

> 与 `references/tracked-changes-text-replacement.md` 的区别：那份只讲 DEL/INS 文字替换；本份补全**原生批注**所需的 4 处 OOXML 接线，这是 python-docx 不直接支持、必须手写 XML 的部分。

## 一、原生批注需要改动的 4 个地方（缺一不可）

插入一条批注，不是只在正文加个标记，而是要同时改 4 个部件：

1. **word/document.xml** — 在被批注文字两端插入 `w:commentRangeStart` / `w:commentRangeEnd`，并在范围后加一个带 `w:commentReference` 的 run
2. **word/comments.xml** — 新建该部件，每条批注一个 `w:comment`（含 id/author/date/initials + 段落内容）
3. **word/_rels/document.xml.rels** — 加一条 Relationship 指向 comments.xml（Type 结尾 `/comments`）
4. **[Content_Types].xml** — 加一条 Override 声明 comments.xml 的 content-type

漏掉 3 或 4，Word/OnlyOffice 打开时批注不显示或报文件损坏。

## 二、关键陷阱（本会话实际踩到）

- **`comments_root.set('xmlns:w', W)` 会抛 `ValueError: Invalid attribute name 'xmlns:w'`**。lxml 不允许手动 set 命名空间属性。正确做法是在创建根元素时用 `nsmap`：
  ```python
  nsmap = {'w': W, 'r': R}
  comments_root = etree.Element(f'{{{W}}}comments', nsmap=nsmap)
  ```
- **id 唯一性**：批注 id、修订(w:ins/w:del) id 各自独立递增，全文不重复。本会话用 comment 从 101 起、revision 从 200 起两个独立计数器，避免撞号。
- **`xml:space="preserve"`**：批注文字和 ins/del 文字的 `w:t`/`w:delText` 都要设 `{http://www.w3.org/XML/1998/namespace}space=preserve`，否则首尾空格被吃掉。
- **批注 rPr 用宋体小四**：批注内容 run 显式设 `w:rFonts`（ascii/hAnsi/eastAsia=宋体）+ `w:sz`（如 18=9pt），不靠继承。
- **多行批注**：一条批注要分多段时，`w:comment` 下放多个 `w:p`，每段一个 run；用 `\n` split 文本逐段建 p。

## 三、可复用代码骨架

```python
import zipfile, shutil, copy
from lxml import etree

W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
AUTHOR = '苌莎莎'          # 跟随指示人，不写死 WB
DATE   = '2026-06-15T12:00:00Z'

# 读取四个部件
with zipfile.ZipFile(SRC) as z:
    all_files = {n: z.read(n) for n in z.namelist()}
doc   = etree.fromstring(all_files['word/document.xml'])
rels  = etree.fromstring(all_files['word/_rels/document.xml.rels'])
ct    = etree.fromstring(all_files['[Content_Types].xml'])
body  = doc.find(f'{{{W}}}body')

# --- 批注：包裹某段落 ---
def add_comment_to_para(para, cid):
    rs = etree.Element(f'{{{W}}}commentRangeStart'); rs.set(f'{{{W}}}id', cid)
    para.insert(0, rs)
    re_ = etree.SubElement(para, f'{{{W}}}commentRangeEnd'); re_.set(f'{{{W}}}id', cid)
    r  = etree.SubElement(para, f'{{{W}}}r')
    rpr= etree.SubElement(r, f'{{{W}}}rPr')
    rst= etree.SubElement(rpr, f'{{{W}}}rStyle'); rst.set(f'{{{W}}}val','CommentReference')
    cr = etree.SubElement(r, f'{{{W}}}commentReference'); cr.set(f'{{{W}}}id', cid)

def make_comment(cid, text):
    c = etree.Element(f'{{{W}}}comment')
    c.set(f'{{{W}}}id', cid); c.set(f'{{{W}}}author', AUTHOR)
    c.set(f'{{{W}}}date', DATE); c.set(f'{{{W}}}initials','CSS')
    for line in text.split('\n'):
        p = etree.SubElement(c, f'{{{W}}}p')
        if line.strip():
            r = etree.SubElement(p, f'{{{W}}}r')
            rpr = etree.SubElement(r, f'{{{W}}}rPr')
            rf = etree.SubElement(rpr, f'{{{W}}}rFonts')
            for a in ('ascii','hAnsi','eastAsia'): rf.set(f'{{{W}}}{a}','宋体')
            etree.SubElement(rpr, f'{{{W}}}sz').set(f'{{{W}}}val','18')
            t = etree.SubElement(r, f'{{{W}}}t')
            t.set('{http://www.w3.org/XML/1998/namespace}space','preserve'); t.text = line
    return c

# --- 修订：在一个 run 内 DEL 旧 + INS 新（拆 before/old/after）---
# 详见 references/tracked-changes-text-replacement.md，本份重点在批注接线

# --- 组装 comments.xml ---
comments_root = etree.Element(f'{{{W}}}comments', nsmap={'w':W,'r':R})
for c in comment_elems: comments_root.append(c)
comments_xml = etree.tostring(comments_root, xml_declaration=True, encoding='UTF-8', standalone=True)

# --- rels 加关系（先查重，避免重复）---
if not any(r.get('Target')=='comments.xml' for r in rels):
    maxid = max((int(r.get('Id')[3:]) for r in rels if r.get('Id','').startswith('rId')), default=0)
    nr = etree.SubElement(rels, 'Relationship')
    nr.set('Id', f'rId{maxid+1}')
    nr.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments')
    nr.set('Target', 'comments.xml')

# --- content-types 加 Override ---
ctns = ct.nsmap.get(None)
if not any(o.get('PartName')=='/word/comments.xml' for o in ct):
    ov = etree.SubElement(ct, f'{{{ctns}}}Override')
    ov.set('PartName','/word/comments.xml')
    ov.set('ContentType','application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml')

# --- 写回 zip（document/rels/ct 覆盖，comments.xml 新增）---
with zipfile.ZipFile(DST,'w',zipfile.ZIP_DEFLATED) as zout:
    for name, data in all_files.items():
        if   name=='word/document.xml':            zout.writestr(name, etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True))
        elif name=='word/_rels/document.xml.rels': zout.writestr(name, etree.tostring(rels, xml_declaration=True, encoding='UTF-8', standalone=True))
        elif name=='[Content_Types].xml':          zout.writestr(name, etree.tostring(ct, xml_declaration=True, encoding='UTF-8', standalone=True))
        else:                                       zout.writestr(name, data)
    zout.writestr('word/comments.xml', comments_xml)
```

## 四、交付前自检（程序化，vision 不可用时的硬验证）

vision/截图分析工具可能不可用，改用程序化核验，逐项确认：
```python
with zipfile.ZipFile(DST) as z:
    cm = etree.fromstring(z.read('word/comments.xml'))
    print('批注数', len(cm.findall(f'{{{W}}}comment')))       # 与预期条数一致
    dx = etree.fromstring(z.read('word/document.xml'))
    print('DEL', len(dx.findall(f'.//{{{W}}}del')), 'INS', len(dx.findall(f'.//{{{W}}}ins')))
    print('rels ok', b'comments.xml' in z.read('word/_rels/document.xml.rels'))
    print('ct ok',   b'comments' in z.read('[Content_Types].xml'))
```
再用 LibreOffice 转 PDF → pdftoppm 转 PNG → `browser_navigate('file:///...png')` 目检版面（批注气泡、修订删除线/下划线是否到位）。
