如何让 AI 对 Word 文档进行修订(Track Changes)
日期:2026-06-29 | 场景:标书编写 —— 报价函填报
一、目标
用 AI 在 Word 文档中以**修订模式(Track Changes)**填写模板占位符,效果为:
- 原文占位符保留不动(不删除)
- 新填入的内容以红色下划线(
<w:ins>)标注 - 人工复核后可一键「接受所有修订」完成填写
二、尝试过程与踩坑记录
尝试 1:字符串替换 + <w:del> + <w:ins>
做法:解压 docx → 在 document.xml 中找到占位符文本(如 (采购人全称)),用正则替换为 <w:del> + <w:ins> 包裹的内容。
结果:❌ 失败。占位符被删除标记覆盖,破坏了原文格式。用户反馈:"不要删除格式上的任何东西"。
教训:修订的真正含义是插入新内容 + 保留旧内容,不是替换。用 <w:del> 删原文再 <w:ins> 加新文,等于"覆盖",不是"修订"。
尝试 2:字符串替换文本内容(保留占位符在同一个 <w:t> 之后)
做法:用字符串替换,在原始 <w:t> 标签内部,在占位符文本之前插入 <w:ins> 块。例如将 <w:t>(供应商全称)授权</w:t> 替换为 <w:ins>山西金辉</w:ins><w:t>(供应商全称)授权</w:t>。
结果:❌ 部分失败。公司名插入未生效。
根因:Word OOXML 中 <w:t> 是叶节点,不能在 <w:t> 内部嵌套 <w:del> 或 <w:ins> 元素。修订标记必须是 <w:r>(run)级别的兄弟节点。实际文本是 <w:t>(供应商全称)授权</w:t>,我的正则只匹配到 <w:t>(供应商全称)</w:t>(不包含"授权"),所以没替换成功。
尝试 3:在 <w:r> 级别插入 <w:ins> 兄弟节点
做法:找到包含占位符文本的 <w:r> 行,在该行之前插入一个 <w:ins> 块(内含包含新文本的 <w:r>)。
结果:❌ 失败。格式混乱。
根因:直接在第 N 行插入新内容导致行号偏移,后续操作的索引全部错乱。而且 <w:ins> 插入后破坏了 <w:p> 内部格式属性的继承关系。
三、正确的技术方案(未验证的理想做法)
核心原则
- 修订标记是段落级操作:
<w:ins>/<w:del>放在<w:p>内部、<w:r>外部 - 只增不删:填充模板时只做
<w:ins>(插入),不做<w:del>(删除) - 保留全部原有
<w:r>元素:包含占位符的原 run 不能删、不能改 - 插入的新文本必须复制原 run 的
<w:rPr>格式:字体、字号、下划线、颜色等
推荐操作流程
Step 1: 解压 docx
python scripts/office/unpack.py input.docx unpacked/
Step 2: 解析 document.xml 的 DOM 树(非正则)
使用 lxml 或 ElementTree 解析 XML,定位到目标 <w:p>
Step 3: 为每个需要填写的位置,在 DOM 中操作
- 找到包含占位符文本的 <w:r> 元素
- clone 该 <w:r> 的 <w:rPr>(格式属性)
- 创建新的 <w:ins> 元素,内含新的 <w:r>(带格式 + 新文本)
- 将 <w:ins> 插入为原 <w:r> 的前一个兄弟节点
- 保持原 <w:r> 元素完全不变
Step 4: 序列化回 XML 并重新打包
python scripts/office/pack.py unpacked/ output.docx --original input.docx
为什么不能用字符串替换
| 方法 | 问题 |
|---|---|
| 字符串 replace | 无法感知 XML 层级结构,容易破坏嵌套 |
正则匹配 <w:t>xxx</w:t> |
叶节点内容可能跨越多行、包含空格和实体 |
在 <w:t> 内部插入 |
违反 OOXML Schema,Word 会忽略或报错 |
| 按行号插入 | 一次插入后所有后续行号失效 |
为什么必须解析 DOM
- OOXML 文档中,一个占位符可能分散在多个
<w:r>中(如(采购人+全称)各在独立 run) <w:rPr>格式属性必须精确复制,否则新插入文本会丢失字体、加粗、下划线等<w:ins>和<w:del>的w:id必须全局唯一且从 0 递增<w:ins>必须包含完整的<w:r>结构,内部有自己的<w:rPr>和<w:t>
最佳实践:用脚本批量处理
比人肉改 XML 更可靠的方式是写 Python 脚本,使用 lxml:
from lxml import etree
NSMAP = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
tree = etree.parse('unpacked/word/document.xml')
root = tree.getroot()
# 遍历所有段落
for p in root.iter('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'):
# 找到包含占位符的 run
for r in p.findall('.//w:r', NSMAP):
t = r.find('w:t', NSMAP)
if t is not None and '采购人全称' in (t.text or ''):
# 复制格式
rpr = r.find('w:rPr', NSMAP)
# 创建 ins 元素
ins = etree.SubElement(p, '{http://...}ins',
{'{http://...}id': '1',
'{http://...}author': '标工',
'{http://...}date': '2026-06-29T00:00:00Z'})
new_r = etree.SubElement(ins, '{http://...}r')
if rpr is not None:
new_r.append(copy.deepcopy(rpr))
new_t = etree.SubElement(new_r, '{http://...}t')
new_t.text = '河津市自然资源局'
# 将 ins 移到原 r 之前
p.insert(list(p).index(r), ins)
break
tree.write('unpacked/word/document.xml', encoding='utf-8', xml_declaration=True)
四、当前能力边界
| 能力 | 状态 |
|---|---|
| 解压/打包 docx | ✅ |
| 提取 docx 文本 | ✅ |
| 批注(comment) | ✅ |
| 修订标记(用 XML 字符串替换简单文本) | ⚠️ 单项勉强可行 |
修订标记(占位符跨 <w:r> / 需保留原格式) |
❌ 待实现 |
| 修订标记(批量、可靠) | ❌ 需开发脚本 |
五、后续方向
- 写一个 lxml 版本的
insert_revision.py脚本,封装"在指定文本前插入<w:ins>"的可靠操作 - 将脚本纳入
bid-tech-proposal技能的scripts/目录 - 对于纯文本替换(不涉及占位符保留)的场景,字符串替换 +
<w:ins>仍然可行,但限制条件为:目标文本是独立<w:r>→ 直接在该<w:r>前插入<w:ins>兄弟节点