## 批注注入(comments.xml 机制) ContractEditor 库**不支持批注**。批注需要直接操作 docx 的 OOXML 批注三件套。验证可用(2026-06-17 培训合同实战)。 ### 批注的三个组成部分 1. **word/comments.xml**:批注内容本体(每条 ``,含 id/author/date/initials)。 2. **word/document.xml**:在被批注的段落里插入锚点: - `` —— 放在段落第一个 run/ins 之前 - `` —— 放在段落末尾 - 一个含 `` 的 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 = [''] parts.append(f'') for c in comments: parts.append(f'') # 批注文字字号建议比正文小(sz=18=9pt),字体跟随文档(宋体+hint=eastAsia) parts.append('') parts.append(f'{c["text"]}') parts.append('') parts.append('') 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 渲染确认批注气泡显示。