Files

6.8 KiB

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
    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。

三、可复用代码骨架

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/截图分析工具可能不可用,改用程序化核验,逐项确认:

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') 目检版面(批注气泡、修订删除线/下划线是否到位)。