Files

121 lines
6.8 KiB
Markdown

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