Files

6.6 KiB

批注注入(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 注入批注)

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),与上面代码一致。

验证(终审必做)

# 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 渲染确认批注气泡显示。