docx-field-editing | 让ai编辑word文档的skill.md
如下:
---
name: docx-field-editing
description: 编辑「已存在」的 .docx 文件(尤其含 Word 域自动编号:图/表 SEQ 编号、REF 交叉引用、参考文献自动编号)时,通过 document.xml 原始 XML 手术安全插入/改写段落、图片与参考文献,保持原有样式与域结构不被破坏;并用 Word COM 导出 PDF 做版式校验。编辑前先用附带脚本按指纹(size/mtime/sha256)判断文档是否被改动过——没动过就复用上次解析结果、不再把大段 XML 读进来;改动过则自动失效重解析。当用户要求"修改/续写/插入内容到某个 Word 文档"且该文档结构复杂(有图题注、交叉引用、内嵌对象)时使用。
agent_created: true
---
# Word (.docx) 域感知编辑
**适用场景**:给已存在的、结构复杂的 .docx 增删内容——典型是中文期刊论文/报告,带
`SEQ 图`/`SEQ 表` 自动编号、`REF <书签>` 交叉引用、`SEQ RdrBibliography` 参考文献自动编号,
还可能内嵌 Visio/Excel OLE 对象、EMF 图片、页眉页脚。
**核心原则**:`python-docx` 只能读、不适合做这类精细写入(会丢域、易破坏 OLE)。正确做法是
**直接对 `word/document.xml` 做外科手术式字符串替换**,其余 zip 条目原样回写。
**上下文原则**:把 XML 整个读进上下文是最贵的操作。**动手前先查指纹,只取要用的那一片。**
---
## 0. 环境
```bash
pip install python-docx pypdf pymupdf Pillow matplotlib # 按需(缓存脚本零依赖,只用标准库)
```
- 本机字体:`C:\Windows\Fonts\simhei.ttf`(黑体) / `simsun.ttc`(宋体) / `msyh.ttc`(雅黑)。matplotlib 画中文图要 `font_manager.addfont()` + `rcParams['font.sans-serif']`。
- 本 skill 自带 `scripts/docx_cache.py`(纯标准库)。下文用 `$SK` 代指其所在目录:
```bash
SK="C:/Users/ngc13/.workbuddy/skills/docx-field-editing/scripts"
```
## 0.5 先查指纹,再决定要不要重新解析(省上下文)
**不要一上手就 `z.read('word/document.xml')` 打印全文。** 每次编辑前先跑一次:
```bash
python "$SK/docx_cache.py" status "<docx>"
```
`status` 输出很小,四态一目了然:
| verdict | 含义 | 该做什么 |
|---|---|---|
| `HIT` | 自上次解析后**没人动过**(我们自己的写入写完后已刷新指纹) | **不要重新解析,也不要读缓存 JSON**;用 `summary/paras/find/cat` 只取需要的那一小块 |
| `TOUCHED_IDENTICAL` | size/mtime 变了但字节完全相同(复制/同步/touch) | 缓存仍有效,照 HIT 处理(脚本会自动刷新指纹) |
| `STALE` | 内容真的变了 | `ensure` 重解析后再动手;读 `evidence` / `changed_since` 判断是谁改的 |
| `MISS` | 第一次见到这个文件 | `ensure` 建缓存 |
```bash
python "$SK/docx_cache.py" ensure "<docx>" # 只在不一致时重解析,然后回写缓存
python "$SK/docx_cache.py" ensure "<docx>" --by-us # 我们刚写完这个文件 → 刷新指纹并标记"自己写的"
```
> **改完并回写 zip 之后必须 `ensure --by-us`。** 漏了这一步,下次会被误判成"外部改动",白付一次重解析——不会出错,只是浪费。
`STALE` 的 `evidence` 专门用来回答"是不是用户在 Word 里存过":
| 字段 | 读法 |
|---|---|
| `structure_changed: true` | 整包被重写(namelist / styles.xml / rels 都变了),几乎就是 Word 保存过 |
| `word_signature.rsid_attrs` / `lastRenderedPageBreak` 由 0 变正,`instr_per_field` 由 1 涨到 2+ | Word 规范化留下的痕迹(域指令被拆 run) |
| `word_open: true` | 同目录存在 `~$xxx.docx` 锁文件 = **Word 正开着这个文件**;这时写文件会冲突,先让用户关掉 Word |
取信息用这些"按需取片",别把 XML 整个读进上下文:
```bash
python "$SK/docx_cache.py" summary "<docx>" # 段落数/种类分布/书签→序号/样式名→ID/rels/feature 计数
python "$SK/docx_cache.py" paras "<docx>" --kind bib,caption_fig --width 70
python "$SK/docx_cache.py" find "<docx>" "图\s*\d+|参考文献" --with-fields
python "$SK/docx_cache.py" cat "<docx>" --para-id 1A2B3C4D # 单段原始 XML(抄模板用,永远读当前文件)
python "$SK/docx_cache.py" get "<docx>" rels # 单个字段:meta|styles|bookmarks|rels|features|paras|refs
```
- 缓存落在 `~/.workbuddy/cache/docx-cache/`(`--cache-dir` 或 `$DOCX_CACHE_DIR` 可改),按 **文件绝对路径** 做键。
- **缓存是纯缓存**:删掉只会多花一次解析,没有别的后果。但**永远不要用 Read 直接读缓存 JSON**——那等于把省下的上下文又花回去。
- 换路径 = 新文件(`MISS`);内容变了必然是 `STALE`。指纹只证明"和上次解析时一模一样",不证明"用户要我等的东西就是它"。
## 1. 需要重新解析时(MISS / STALE),怎么摸清结构
```bash
python "$SK/docx_cache.py" ensure "<docx>" # 重解析 + 落缓存
python "$SK/docx_cache.py" summary "<docx>" # 看结构摘要,而不是看 XML
```
`summary` 给到的结构事实:
- `paras` 总数、`kinds` 分布(`body` / `image` / `caption_fig` / `caption_tbl` / `bib`);
- `bookmarks`:**书签名 → 显示序号**(直接从域缓存值里读出来的,`BibRef216 → 95` 这种不对应关系已经算好);
- `styles.by_name`:样式名 → `w:styleId`(Word 存过之后 ID 会改名,必须每次按名字反查);
- `rels`:`max_rid`(下一个可用 rId)、`images`、`objects`;
- `features`:是否含 `embeddings/`、修订/批注计数、`fields_total`、`instr_per_field`、`rsid_attrs`。
再往下钻用 `paras` / `find`;要抄某个段落的 XML 当模板,用
`cat --para-id XXXX`(读的是**当前文件**,不会拿到过期内容)。
### 1.1 搞清"编号机制"(决定能不能安全插入)
| 现象 | 含义 | 影响 |
|---|---|---|
| 题注含 `SEQ 图 \* ARABIC` + `bookmarkStart` | 图号是**域自动生成** | 新图插在中间会让后面所有图号 +1 |
| 正文含 `REF <书签名> \h \* CHARFORMAT` | 交叉引用是**域**,显示的是缓存值 | 图号变了必须同步改缓存值 |
| 参考文献含 `SEQ RdrBibliography` | 文献号自动生成 | **新文献一律追加到末尾**,否则正文所有 `REF` 缓存失配 |
**关键**:域有"缓存值"(`separate` 与 `end` 之间那个 `<w:t>`)。Word 打开时直接显示缓存值,
只有用户按 F9/打印才重算。所以你要**按文档顺序手工算好编号,把缓存值写对**;这样无论用户
是否更新域,显示都正确(且与重算结果一致,永远自洽)。
⚠️ 书签名与显示序号**不对应**(`BibRef216` 可能是 [95])。先看 `summary.bookmarks` 里的
`num`;只有 `num` 为 `null`(脚本猜不出)时才手工遍历核对。
### 1.2 模板化:用现有段落的 XML 当模板
不要凭空造 XML。`cat --para-id` 取一个现有同类段落的完整 XML,把文本换掉,样式自然继承。
常见段落类型:
```python
def run_text(t): # 正文run:hint="eastAsia" + vertAlign baseline
return ('<w:r><w:rPr><w:rFonts w:hint="eastAsia"/><w:vertAlign w:val="baseline"/></w:rPr>'
f'<w:t xml:space="preserve">{esc(t)}</w:t></w:r>')
def run_cite(bm, num): # 上标 [n] 交叉引用域
r0 = '<w:rPr><w:spacing w:val="0"/><w:vertAlign w:val="superscript"/></w:rPr>'
r1 = '<w:rPr><w:rFonts w:hint="eastAsia"/><w:spacing w:val="0"/><w:vertAlign w:val="superscript"/></w:rPr>'
return (f'<w:r>{r1}<w:t>[</w:t></w:r>'
f'<w:r>{r0}<w:fldChar w:fldCharType="begin"/></w:r>'
f'<w:r>{r0}<w:instrText xml:space="preserve"> REF {bm} \\h \\* CHARFORMAT </w:instrText></w:r>'
f'<w:r>{r0}<w:fldChar w:fldCharType="separate"/></w:r>'
f'<w:r>{r0}<w:t>{num}</w:t></w:r>'
f'<w:r>{r0}<w:fldChar w:fldCharType="end"/></w:r>'
f'<w:r>{r1}<w:t>]</w:t></w:r>')
def body_para(runs):
return ('<w:p><w:pPr><w:widowControl/><w:autoSpaceDE w:val="0"/><w:autoSpaceDN w:val="0"/>'
f'<w:ind w:firstLine="420"/></w:pPr>{runs}</w:p>')
```
- 图片段:`pStyle` 用**已有图片段的样式**(用 `paras --has img` 找),`<w:drawing>` 抄现成结构,改 `r:embed`。
- 题注段:`Caption` 样式(按名反查 ID),`图` + `bookmarkStart` + SEQ 域 + 书名 + `bookmarkEnd` + 题注文字 (+ REF)。
- 文献段:`Bibliography` 样式,`[` + bookmarkStart + SEQ 域 + bookmarkEnd + `] 著录项`。
## 2. 增量写入(最小侵入)
```python
def get_para(pid):
return re.search(r'<w:p w14:paraId="'+pid+r'">.*?</w:p>', xml, re.S).group(0)
assert xml.count(old) == 1 # 锚点唯一才替换
xml = xml.replace(old, new) # new = old + 追加内容
```
**能追加就不删改**;必须刷新号时,只动"缓存值"那一个 `<w:t>`,用带上下文的 `re.sub` 精确替换。
(`get_para` 这种非贪婪正则遇到段落里嵌文本框会截断——只在确定该段无嵌套时用,否则用 `cat`。)
### 2.1 加图片:四件事
1. `word/media/imageN.png` 加入 zip;
2. `word/_rels/document.xml.rels` 加 `<Relationship Id="rIdN" Type=".../image" Target="media/imageN.png"/>`
(**rId 直接用 `summary.rels.max_rid`**,别自己猜);
3. 段落里 `<a:blip r:embed="rIdN">`;`<wp:docPr id>` 全局唯一;
4. `[Content_Types].xml` —— `summary.features.content_types_defaults` 里已有该扩展名就不用加。
尺寸:`cx = 正文宽EMU`,`cy = cx*img_h/img_w`。正文宽 = `(pgSz.w - pgMar.left - pgMar.right)` twips,
EMU = twips/1440*914400。图太大会撑页,建议 cx 取正文宽的 90%~100%;同时把大 PNG 降采样到
目标 dpi(`宽px ≈ cm/2.54*300`)再 `save(optimize=True)`,否则 docx 体积暴涨。
### 2.2 回写 zip:原样搬运
```python
with zipfile.ZipFile(DST,'w',zipfile.ZIP_DEFLATED) as zo:
for it in z.infolist():
data = z.read(it.filename)
if it.filename == 'word/document.xml': data = xml.encode('utf-8')
elif it.filename == 'word/_rels/document.xml.rels': data = rels.encode('utf-8')
zo.writestr(it, data) # 其它条目字节级保留(OLE/EMF 不会坏)
zo.writestr('word/media/image4.png', open('a.png','rb').read())
```
写完必查:`zipfile.testzip()`、`xml.dom.minidom.parseString(xml)`、`docx.Document(DST)` 三段校验。
**然后立刻刷新指纹**(这一步是下一轮判断的依据):
```bash
python "$SK/docx_cache.py" ensure "<docx>" --by-us
```
### 2.3 往「按首引顺序编号」的文献表里插一条新文献(整表重排)
中文期刊综述的参考文献常按**正文首引顺序**编号。**先问用户**要「按序插入并重排」还是
「追加到末尾」——前者正确但牵动全表,后者改动最小但不合规。
判定既有表是否真按首引序:把正文按文档顺序扫出每个 `REF BibRef*` 域的目标书签,
取首次出现顺序,和文献表顺序 1:1 比对。**落在区间引用 `[a-b]` 内的文献没有独立引用域**
(如 `[6]`、`[11]`),比对时会"缺号",属正常,别当成错误。
重排配方(假设新文献插在旧 `[27]` 之后,成为 `[28]`,旧 28…N 顺延为 29…N+1):
1. **只改"缓存值"**:每条文献段 `SEQ RdrBibliography` 域的缓存 `<w:t>`、每处正文
`REF BibRef*` 域的缓存 `<w:t>`,只要旧值 ≥ 28 就 +1。区间引用由两个独立 REF 域拼成,
端点各自 +1 即可,**无需特判**(除非插入点正好落在区间中间)。
2. 在旧 `[27]` 那条文献段**之后**插入新文献段(照抄模板,换 `bookmarkStart` 名/id、SEQ 缓存值、
著录项文字);书签 `w:id` 要全局唯一(取现有最大值 +1)。
3. 正文插入点用**现有同类 run 的 XML 当模板**(上标 `[` + `REF` 域 + `]`),别自己造。
4. 为保证段落偏移不失效,**按段落倒序**处理、每段内部也用倒序替换。
⚠️ **替换"缓存值"必须按域定位**,不能全表 `replace('<w:t>28</w:t>', '<w:t>29</w:t>')`——
正文里 `28` 可能同时是图号、页码或普通文字。写成:遍历 run → 记录 `fldChar begin/separate/end`
之间的 `<w:t>` 作为"缓存 span"→ 只在 `separate` 与 `end` 之间的那个 `<w:t>` 上做替换。
(Word 规范化后 `instrText` 会被拆成多段,判域类型要**拼接同一域内所有 `instrText`** 再判断,
不能靠单条 run 的整串匹配。)
**验收(缺一不可)**:
- 文献表缓存值 == 1,2,3,…,N+1 严格递增;
- 每个正文 `REF` 域的缓存值 == 它目标书签所在文献段的编号(**必须逐条相等,一处不符都是 bug**);
- 正文首次引用顺序仍严格递增;
- 导 PDF 后 `pymupdf` 提文本,确认新句、新条目**真的出现**,旧措辞/旧编号消失。
## 3. 版式校验 + 预览(强烈建议)
沙箱默认拦截 COM(报 "COM object instantiation can run arbitrary code"),需带
`dangerouslyDisableSandbox` 运行 PowerShell:
```powershell
$w = New-Object -ComObject Word.Application; $w.Visible = $false; $w.DisplayAlerts = 0
$d = $w.Documents.Open($path, [ref]$false, [ref]$true) # 只读打开,不保存,避免改动原文件
$d.ExportAsFixedFormat($pdf, 17, $false) # 17 = wdExportFormatPDF
$d.Close([ref]$false); $w.Quit()
```
导出 PDF 后:① 用 `pymupdf` 提取全文,正则列 `图\s*\d+` 核对题注与正文引用是否自洽;
② 把相关页渲染成 png 用视觉逐页确认排版(图片大小、题注位置、有无重叠);
③ PDF 本身可当预览件交付。
只读打开 + 不保存 → 指纹不变;但用户手动在 Word 里存过一次就会 `STALE`,届时按 §5 重新判断结构。
⚠️ **COM 是按 ProgID 分别放行的**:本机实测 `Word.Application` 可用,但
`New-Object -ComObject Visio.Application` 即使带 `dangerouslyDisableSandbox` 也照样报
"COM object instantiation can run arbitrary code"。所以**别指望用 Visio COM 重排内嵌图**——
要改 Visio OLE 的显示内容,走 §5.5 的 EMF 路线。
## 4. 交付与版本管理
- 目标文件常是只读(`-r--r--r--`):先 `chmod +w`。
- 若用户有版本备份约定(如 `old/` 目录 + `【说明】原名+YYMMDDHHmm.ext`),严格遵守:
每次完成一版就 `cp` 一份进备份目录,说明里写清改了什么。
- 草稿目录放 `.workbuddy/work/`,中间产物(脚本/中间 docx)不要污染用户目录;缓存目录另有约定(见 §0.5)。
- 若从 `old/` 里的备份**覆盖回**当前文件,指纹会变 → 正常走 `STALE` 重解析,不会用到错的结构。
## 5. ⚠️ 文件被 Word 打开并保存过之后,结构会变(务必先重新解析)
只要用户在 Word 里打开并保存过一次,同一份文档的 XML 会被**规范化**,上一轮记下的结构事实可能全部失效:
| 变化 | 表现 | 应对 |
|---|---|---|
| 域指令被拆成多个 run | `SEQ 图 \* ARABIC` 变成 `' SEQ '` / `'图'` / `' \* ARABIC '` 三段 `instrText` | **别用整串检索**(`'SEQ 图'` 会搜到 0 条);按 `<w:instrText>` 列表或单关键字 `SEQ` 判断(脚本给的 `instr` 已拼好) |
| 样式 ID 被改名 | 图片段 `29→af2`、题注 `6→a3`、批注 `→a4` | 从**当前文件**重新读 `styles.xml` 的 `w:name` 反查 ID(脚本给的 `styles.by_name`);或直接复制现有同类段落的 `pPr` |
| 关系 ID 重排 | `rId` 整体重新分配 | 每次按当前 `rels` 取 `max_rid` |
| 内嵌对象改名 | `Microsoft_Visio___1.vsdx → Microsoft_Visio_Drawing.vsdx` | 不要按文件名硬编码 |
| 属性被清理/追加 | `vertAlign`、`w:rsid*`、`w14:textId`、`<w:lastRenderedPageBreak/>`、`<a:srcRect/>`(裁剪) | 新段落按当前文件的 run 模板构造;`w14:paraId` 可省略或另生成不冲突的值 |
**铁律:每次动手前先 `status`,让指纹替你决定要不要重解析。**
- `HIT` / `TOUCHED_IDENTICAL` → 复用缓存里的段落列表、书签名→序号、样式 ID、rels、`paraId`——缓存就是"上一轮的记忆",而且已经被指纹证明仍然成立,**没有理由再花一次上下文**。
- `STALE` / `MISS` → **必须重新解析当前文件**(`ensure` 一条命令搞定),不得沿用上一轮记忆。
判据是**指纹是否变化**,不是"距上次多久"、也不是"感觉好像改过"。构造新段落时,**先 `cat` 一个现有同类段落的完整 XML 当模板**。
## 5.5 内嵌 Visio/OLE「图」的框内文字怎么改(Word 只认缓存 EMF)
**症状**:论文的"总体框架图"常是 Visio 嵌入对象(`<w:object>` + `o:OLEObject ProgID="Visio.Drawing.15"`)。
Word 正文里**显示的是缓存图片**(`<v:imagedata r:id="rIdX">` → `word/media/imageN.emf`),
改 `word/embeddings/*.vsdx` 里的文字**不会改变显示**(只有用户双击进 Visio 重存才会刷新)。
要改"看得见"的文字,必须同时改 **EMF 缓存** 和 **vsdx 源**。
**定位哪一个 media 是那张图**:`document.xml` 里该图段落的
`<v:imagedata r:id>` 与 `<o:OLEObject r:id>` → 查 `word/_rels/document.xml.rels`:
前者指 `media/imageN.emf`(显示用),后者指 `embeddings/….vsdx`(源)。
**读 EMF 文本**(也用于核对图文一致性):
```python
# EMF 记录流:0 iType(4) 4 nSize(4) …
# EMR_EXTTEXTOUTW = 84(ANSI 版是 83):
# 8 rclBounds(16) | 24 iGraphicsMode | 28 exScale | 32 eyScale
# 36 ptlReference(8) | 44 nChars | 48 offString(=76) | 52 fOptions | 56 rcl(16) | 72 offDx
# 字符串在 76(UTF-16LE),步进数组 offDx 处 nChars 个 int32
# 不变量:offString = 76; offDx = 76 + align4(2*nChars); nSize = offDx + nChars*4
```
`<Text>…</Text>`(vsdx 的 `visio/pages/page1.xml`)才是可靠的**文字清单**,先读它建对照表。
**改文字**(可以变长变短,不必等长):
1. 按上面的公式重建整条记录:`rec[0:36]=旧[0:36]`、`rec[4]=新 nSize`、
`rec[36:76]=旧[36:76]`、再写 `nChars / offString=76 / offDx`、字符串、`dx=[旧 dx[0]]*nChars`;
2. **同步改 EMF 表头 offset 48 的 `nBytes`**(`+= 新 nSize - 旧 nSize`),否则渲染器可能截断;
3. 文本变长时把记录里 `rclBounds.right`(记录 offset 16)加宽 `(nChars新-nChars旧)*字符步进`,防裁切;
4. 按 seq 顺序滚动记录边界时,每次替换后要**从 0 重新扫描**定位下一条(长度变了、偏移全变);
5. vsdx 源里同样替换 `<Text><cp IX='0'/>旧文字\r\n</Text>` 中的文字。
⚠️ 位置语义:Visio 导出的 EMF 里 `ptlReference=(0,0)`、`EMR_SETTEXTALIGN` 是
**TA_LEFT|TA_BASELINE**,真实坐标由每条文本前的 `SETVIEWPORTORGEX/SETWINDOWORGEX` 给出 →
改长度只向右伸缩,不会漂移;但也**不要**顺手把 `ptl` 当坐标改。
**验收**(必做):Word COM 导 PDF → `pymupdf` 提取文本确认新措辞出现、旧措辞消失 →
再把该页渲染成 PNG 人眼看一遍(有无溢出、换行位置是否合理、文字是否被裁)。
中文字数变化会改变自动换行,`图` 里框小的标题可能多出一行——必须眼看。
## 6. 常见坑
- 段落里**没有 `<w:pStyle>`** = 用 `Normal`,靠 `w:ind` 控制缩进;套模板时别自己加 pStyle。
- 中文字体靠 `w:hint="eastAsia"`;漏了会走西文字体,字形不对。
- `instrText` 里是 `\h \* CHARFORMAT`,Python 源码要写 `\\h \\* CHARFORMAT`。
- 文本含 `&`/`<`/`>` 必须转义(`esc()`);中文全角引号、破折号无需转义。
- 矢量描边的 PDF(`pypdf` 提不出字)只能渲染成图片后视觉阅读。
- 别动 `settings.xml` 的 `updateFields`:保持"不自动更新",缓存值才是唯一真相。
- 正则切段落别用 `<w:p\b.*?</w:p>`:段落里嵌文本框(`w:txbxContent`)时会截断,把一段切成两段。脚本用的是深度计数切分;自己写就照抄。
- 缓存三坑:① 别用 Read 工具读缓存 JSON(用子命令);② 改完文件别忘 `ensure --by-us`;③ 缓存按**路径**做键——用户另存到新路径就是新文件,别拿旧路径的缓存当它。
- **找"包裹某 `<w:t>` 的 run 起点"千万不要 `xml.rfind('<w:r', 0, pos)`**:`<w:rPr>` 的前缀恰好就是
`<w:r`,会误命中 rPr,把新块插进 run 内部、形成 `<w:r>` 嵌套 `<w:r>` 的**非法结构**。
正解:`for m in re.finditer(r'<w:r[ >]', xml[:pos]): run_start = m.start()`(只匹配 `<w:r>` / `<w:r `)。
- **非法嵌套不一定让 `minidom` 报错**:文档照样能被 `minidom.parseString` 和 `python-docx` 读开,
但 run 被切碎、文字在真正的渲染里可能丢失或错位。→ **任何写入后都必须 Word COM 导 PDF 复核**,
不能只凭"三段校验通过"就宣布完成。
- 直接对整篇 XML 做"按段落倒序 + 段内倒序"替换时,段落偏移才始终有效;正序替换会让后面所有偏移失效。
- 判域类型要**先拼接同一域内所有 `instrText`**:Word 规范化后一条域指令可能被拆成
`'REF BibRef11'` + `'7 \h \* CHARFORMAT '` 这种两段,单看一条 run 会得出错误的书签名。
2026年10月5日
