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