前言
「查找并替换」在记事本里是十行代码的事,在 Word 里却会让很多人栽跟头。最常见的现象是:代码明明跑通了、也没有报错,打开文档却发现该换的地方只换了一半,或者该换的全没换。
根因是 Word 的文本模型和纯文本文件完全不同。Word 里的一段文字不是一个字符串,而是被切成一串文本块(run),切分的依据是格式边界——一处加粗、一处改字体、一处插入的超链接,都会把文字切开。你肉眼看到的「一句话」,在文件里可能是五六个 run 拼起来的。所以按 run 做替换,只要目标文本跨了边界就会漏。
本文把这个问题彻底讲清楚:先解释 run 是怎么切的,再说清文字到底藏在文档的哪些角落(这一步最容易被漏),然后给出两种可行的替换方案。涉及第三方库的内容只讲用途、流程与典型调用骨架,具体参数与返回值以 python-docx 官方文档为准。
一、为什么「一句话」在文件里是好几块
python-docx 官方文档在解释超链接的文本时给了一个很直白的例子:超链接的文本通常放在单个 run 里,但如果其中一部分是加粗的、或者文字在文档保存之后被修改过,它就会被切分成多个 run。
把这句话翻译成日常场景:文档里写着「合同编号:HT-2026-001」,如果「HT-2026-001」被人加了高亮或者改了字体,那么这段文字在文件里就是两块:合同编号:和HT-2026-001。此时你去搜编号:HT,它在任何单个 run 里都找不到,逐 run 替换自然全部落空。
| 你看到的 | 文件里的 run 切分 | 搜「编号:HT」能否命中 |
|---|
| 合同编号:HT-2026-001(格式统一) | 一个 run | 能 |
| 合同编号:HT-2026-001(后半加粗) | 两个 run | 不能 |
| 合同编号:HT-2026-001(前半加粗) | 两个 run | 不能 |
| 逐字符改过颜色的编号 | 很多个 run | 不能 |
所以一个稳健的替换实现必须做到:先把整段的 run 文本拼起来做匹配,再把替换结果按字符位置回填到各个 run。
二、文字藏在哪些地方
这是第二个高频漏点。用 python-docx 打开文档后,document.paragraphs只给你文档主体里顶层的段落。以下这些地方的文字都不在里面:
| 位置 | 归属对象 | 是否需要单独处理 |
|---|
| 正文段落 | document.paragraphs | 默认会处理 |
| 表格单元格内的段落 | document.tables里的单元格 | 需要单独遍历 |
| 页眉 | 每个节的 header | 需要单独遍历 |
| 页脚 | 每个节的 footer | 需要单独遍历 |
| 首页/偶数页页眉页脚 | 节上的对应属性 | 需要单独遍历 |
| 文本框、形状里的文字 | 属于图形对象 | 默认接口覆盖不到 |
官方文档对表格也有一句提醒:只有出现在文档顶层的表格才会列在表格集合里。至于页眉页脚,它们挂在「节(section)」上,而节提供了默认页眉页脚、首页页眉页脚、偶数页页眉页脚这样几套独立定义。
结论很明确:想覆盖全,就要自己写一个「把所有文本容器收集起来」的遍历函数,而不是只遍历document.paragraphs。
三、基础方案:逐 run 替换
先看最简单也最常见的写法,理解它的局限:
# 适用于 Python 3.9+
# 需要先安装:pip install python-docx
# 具体参数与返回值以 python-docx 官方文档为准
from docx import Document
doc = Document("合同.docx")
OLD = "HT-2026-001"
NEW = "HT-2026-002"
count = 0
for para in doc.paragraphs:
for run in para.runs:
if OLD in run.text:
run.text = run.text.replace(OLD, NEW)
count += 1
doc.save("合同_已替换.docx")
print("替换了", count, "处")给run.text赋值是官方支持的写法:文档说明这个赋值会替换该 run 的全部内容、并保留 run 的格式。对格式统一的短文档,这个方案简单有效。
它的失效场景也正是上面说的:只要匹配文本跨了两个及以上 run,就一处都命中不了。
四、稳健方案:拼接匹配 + 按字符回填
思路分三步:把整段的 run 文本拼成一个字符串;在整段字符串上做匹配;把结果按字符归属回填到各个 run。
# 适用于 Python 3.9+
# 需要先安装:pip install python-docx
# 具体参数与返回值以 python-docx 官方文档为准
import re
from docx import Document
def replace_in_paragraph(paragraph, old, new):
"""在段落内替换文本,跨 run 的匹配也能处理。
替换文本会写入匹配起点所在的 run,被匹配到的字符一律移除。
返回替换次数。
"""
runs = paragraph.runs
if not runs or not old:
return 0
# 1. 拼接整段文本,并记录每个字符属于哪个 run
full = "".join(r.text for r in runs)
if old not in full:
return 0
owner = [] # owner[k] = 第 k 个字符所属 run 的下标
for i, r in enumerate(runs):
owner.extend([i] * len(r.text))
# 2. 一次性找出所有匹配。escape 保证目标里的特殊字符不被当正则
matches = list(re.finditer(re.escape(old), full))
if not matches:
return 0
# 3. 标记要删掉的字符下标,以及要在哪些位置插入新文本
removed = set()
insert_before = {}
for m in matches:
removed.update(range(m.start(), m.end()))
insert_before[m.start()] = insert_before.get(m.start(), "") + new
# 4. 逐字符重建每个 run 的文本
rebuilt = [""] * len(runs)
for k, ch in enumerate(full):
if k in insert_before:
rebuilt[owner[k]] += insert_before[k]
if k in removed:
continue # 被匹配到的字符丢掉
rebuilt[owner[k]] += ch
# 匹配若落在整段末尾,插入内容归最后一个 run
if len(full) in insert_before:
rebuilt[owner[-1]] += insert_before[len(full)]
# 5. 写回。给 run.text 赋值会保留该 run 的格式
for r, text in zip(runs, rebuilt):
r.text = text
return len(matches)
def collect_paragraphs(doc):
"""收集文档里所有能承载文字段落的容器。"""
paragraphs = list(doc.paragraphs)
# 表格:逐表、逐行、逐单元格收集
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
paragraphs.extend(cell.paragraphs)
# 页眉与页脚:挂在每一个节上
for section in doc.sections:
for area in (section.header, section.footer):
paragraphs.extend(area.paragraphs)
return paragraphs
if __name__ == "__main__":
doc = Document("合同.docx")
total = 0
for para in collect_paragraphs(doc):
total += replace_in_paragraph(para, "HT-2026-001", "HT-2026-002")
doc.save("合同_已替换.docx")
print("共替换", total, "处")这个实现有几点需要说明。第一,替换文本会写进「匹配起点所属的那个 run」,因此格式取的是第一个 run 的格式——这对绝大多数场景足够,但如果原文本本身横跨了不同格式,视觉上会有细微变化。第二,re.escape不能省,否则目标文本里出现正则元字符时会匹配到意想不到的位置。第三,owner[-1]只在整段文本非空时有效,而前面old in full的判断已经保证了这一点。
五、另一种思路:直接改 XML
因为 .docx 本质上是 zip 包,内部是 XML,理论上可以解开压缩包、直接改 XML 再打包回去。这条路能绕开 run 切分问题,但代价是你必须手动处理 XML 转义:替换文本里出现尖括号、与号这类字符时,不转义就会做出一个打不开的文档。
# 适用于 Python 3.8+
# 仅用标准库,无需安装第三方包
# 仅为演示思路:直接改 XML 风险高,务必先备份
import shutil
import zipfile
# 先备份源文件,这个顺序不能反
shutil.copy("合同.docx", "合同_备份.docx")
with zipfile.ZipFile("合同.docx") as zf:
# .docx 的内部结构就是一个 zip,正文在 word/document.xml
names = zf.namelist()
print("内部条目数:", len(names))
print([n for n in names if n.endswith(".xml")][:5])要真正改写,还得把 XML 解出来、做替换(会踩到「同一段文字被标签拆开」的同一个问题)、再写回压缩包。除非有非常特殊的需求,否则不建议走这条路——用库提供的对象模型安全得多,代价只是要自己处理 run 切分。这里列出来,只为说明「.docx 是 zip 包」这个事实,让你在排查问题时多一个视角。
常见坑点
- 直接把整段文字赋给 paragraph.text
❌para.text = para.text.replace(OLD, NEW),替换是成功了,但这一段里所有的加粗、字号、颜色全部丢失。 ✅ 在 run 层面替换;只有在完全不在意格式时才动整段文本。
- 逐 run 替换却遇到跨 run 的匹配
❌for run in para.runs: run.text = run.text.replace(...),跨格式边界的目标一处都命中不了。 ✅ 先把整段 run 文本拼起来匹配,再按字符位置回填到各个 run。
- 只遍历 document.paragraphs 就以为覆盖全了
❌ 表格、页眉、页脚里的目标文本原封不动,用户以为脚本没跑。 ✅ 单独遍历表格单元格与各节的页眉页脚,把它们一起放进替换队列。
- 忘了页眉页脚的三套定义
❌ 只处理了section.header和section.footer,首页或偶数页的页眉页脚没被改到。 ✅ 记住节上还有首页页眉页脚、偶数页页眉页脚等独立定义,按文档实际配置决定处理范围。
- 目标文本里的正则元字符没转义
❌ 用re.sub(OLD, NEW, text)时 OLD 里含点号、括号、加号,匹配到一堆不该匹配的地方。 ✅ 对字面量搜索一律先做转义,或者干脆用字符串查找而不是正则。
- 直接改 XML 时不做转义
❌ 把含尖括号或与号的替换文本原样写进 XML,生成的文件 Word 打不开。 ✅ 改 XML 必须做实体转义;更推荐改用库的对象模型。
- 直接覆盖原文件
❌doc.save("合同.docx")覆盖源文件,替换出问题后无法回退。 ✅ 输出到新文件名并先备份;批量任务尤其要先在副本上跑一遍。
总结
| 环节 | 要点 |
|---|
| 文本模型 | 一段话被切成多个 run,切分依据是格式边界 |
| 匹配方式 | 拼接整段后匹配,按字符位置回填 |
| 覆盖范围 | 正文、表格、页眉页脚都要遍历 |
| 格式保留 | 赋值 run.text 会保留该 run 的格式 |
| 输出 | 写到新文件,先备份 |
Word 里的查找替换,难点从来不在「替换」两个字,而在「一段文字到底被切成了几块、又藏在哪个容器里」。把 run 切分和容器遍历这两件事处理好,剩下的就是常规的字符串操作。各接口的确切参数与返回值,请以 python-docx 官方文档为准。