2026-10-05 · 喵窝资源铺
场景:几百份合同/报价单要换公司抬头、改价格、改日期。Word 自带的「查找替换」一个文档一个文档开太慢,而且很多人不知道——它默认不替换页眉页脚里的内容(要进页眉再单独替换一遍)。这篇给一个整目录批量替换的 Python 脚本(python-docx),正文、表格、页眉页脚一次全替换,坏文件自动跳过。文中代码实跑验证,真实输出贴在下面。
| 输入 | 脚本的处理 |
|---|---|
| 正文/表格里的替换词 | 全文遍历,表格单元格逐个进 |
| 页眉页脚 | 默认页眉页脚 + 首页不同/奇偶页不同的三套都覆盖 |
| 替换规则 | JSON 列表,支持多条规则一次跑(换抬头+改公司名+改日期) |
| 嵌套目录 | os.walk 递归,输出目录保持原层级 |
| 空文档 | 正常处理(0 处替换),输出合法 docx 副本 |
| 坏文件 | 截断损坏的 .docx 报 Package not found 跳过,不崩整批、不残留空壳目录 |
| Word 锁文件 | ~$xxx.docx 临时文件自动排除 |
| 原文件 | 只读输入,结果写输出目录——源文件绝不被改 |
docx 里一段文字不是整段存的,是拆成一串 run(每段连续同格式的文字一个 run)。拼写检查标记、中英文混排、修改痕迹都会把一个词从中间劈开——比如「上海喵喵科技有限公司」会变成 run1「上海喵喵」+ run2「科技有限公司」。这时候逐 run 做run.text.replace(...)永远命不中,因为没有任何一个 run 里包含完整的词。
我们实测:同一段文档,naive 逐 run 替换只命中 1 次(实际出现 2 次),漏掉的就是被拆开的那处。解法:把段落内所有 run 的 text 拼成全文,在全文上找和替换,再把结果回写——整个词跨几个 run 都能命中。代价是段落内格式不一致时会统一成第一个 run 的格式,对合同/报告类文档通常可接受(真要保格式得逐字符映射 run 边界,复杂一个量级,有这需求的属于另一个报价档)。
第一版脚本「原地替换」,第一轮测试就发现:第二次跑同一批文件时替换数全是 0——不是脚本坏了,是第一轮已经把输入文件原地改掉了,旧词早没了。批量替换这种「重复跑验证」极常用的场景必须读输入目录、写输出目录,源文件只读不动。这个 bug 在单文件手动跑的时候很难发现,批量复跑才暴露。
# -*- coding: utf-8 -*-
"""批量 Word(.docx) 查找替换:整目录处理,表格/页眉页脚全覆盖,坏文件不崩。
用法:
python3 word_replace.py <输入目录> <输出目录> 替换表.json
替换表格式: [{"find": "旧公司名", "replace": "新公司名"}, ...]
"""
import json, os, shutil, sys
from docx import Document
from docx.oxml.ns import qn
def replace_in_runs(paragraph, find, replace):
"""段落内替换。坑:一个词常被 Word 拆进多个 run(拼写检查/中英混排都会拆),
所以不能只遍历 run.text,要把段落全文拼起来找位置,再回写到 runs 上。"""
runs = paragraph.runs
if not runs:
return 0
full = "".join(r.text for r in runs)
if find not in full:
return 0
count = full.count(find)
# 拼接后替换,结果放第一个 run,其余 run 清空
runs[0].text = full.replace(find, replace)
for r in runs[1:]:
r.text = ""
return count
def iter_paragraphs(doc):
"""正文段落 + 表格单元格段落 + 页眉页脚段落 + 文本框(txbxContent)段落。"""
for p in doc.paragraphs:
yield p
for t in doc.tables:
for row in t.rows:
for cell in row.cells:
for p in cell.paragraphs:
yield p
for sec in doc.sections:
for hf in (sec.header, sec.footer,
sec.first_page_header, sec.first_page_footer,
sec.even_page_header, sec.even_page_footer):
for p in hf.paragraphs:
yield p
for t in hf.tables:
for row in t.rows:
for cell in row.cells:
for p in cell.paragraphs:
yield p
# 文本框:正文 XML 里搜 txbxContent,包一层段落对象
from docx.text.paragraph import Paragraph
for tx in doc.element.body.iter(qn("w:txbxContent")):
for p in tx.iter(qn("w:p")):
yield Paragraph(p, None)
def process(path, out_path, rules):
doc = Document(path)
total = 0
detail = {}
for rule in rules:
n = 0
for p in iter_paragraphs(doc):
n += replace_in_runs(p, rule["find"], rule["replace"])
detail[f"{rule['find']}→{rule['replace']}"] = n
total += n
doc.save(out_path)
return total, detail
def main():
if len(sys.argv) != 4:
print(__doc__); sys.exit(1)
src, dst, rules_file = sys.argv[1:4]
rules = json.load(open(rules_file, encoding="utf-8"))
os.makedirs(dst, exist_ok=True)
ok = skipped = 0
for root, dirs, files in os.walk(src):
for name in files:
if name.startswith("~$"): # Word 锁文件
continue
if not name.lower().endswith(".docx"):
continue
s = os.path.join(root, name)
rel = os.path.relpath(s, src)
d = os.path.join(dst, rel)
os.makedirs(os.path.dirname(d) or dst, exist_ok=True)
try:
total, detail = process(s, d, rules)
if total == 0:
# 无替换也输出原文件副本,保持输出目录结构与输入一致
shutil.copy(s, d)
ok += 1
print(f"OK\t{rel}\t替换 {total} 处\t{detail}")
except Exception as e:
skipped += 1
print(f"[跳过] {rel}\t{type(e).__name__}: {e}")
print(f"\n处理 {ok + skipped} 个,成功 {ok},跳过 {skipped}")
if __name__ == "__main__":
main()
测试集:跨 run 拆词的合同(正文 2 处+表格 1 处同词)+ 表格另一家公司名、空文档、截断坏 docx(子目录里)、带公司名页眉页脚的文档:
OK empty.docx 替换 0 处 {'上海喵喵科技有限公司→深圳喵呜网络有限公司': 0, ...}
OK 合同A.docx 替换 4 处 {'上海喵喵科技有限公司→深圳喵呜网络有限公司': 3, '北京汪汪→广州旺旺': 1}
[跳过] 子目录/broken.docx PackageNotFoundError: Package not found at 'testdata_w2r/子目录/broken.docx'
OK 子目录/hf.docx 替换 2 处 {'上海喵喵科技有限公司→深圳喵呜网络有限公司': 2, ...}
处理 4 个,成功 3,跳过 1
产物复核(读回输出文件逐项检查):
深圳喵呜网络有限公司 -> True # 跨 run 拆开的词替换成功 甲方:深圳喵呜网络有限公司 -> True # 段落拼接回写正确 120 万元 -> True # 非目标文字不受影响 广州旺旺 -> True # 表格内替换成功 残留旧词 -> False # 正文+表格无遗漏 页眉: 深圳喵呜网络有限公司 保密 -> 替换成功 页脚: 第 1 页 · 深圳喵呜网络有限公司 -> 替换成功 empty 输出可正常打开 · 输入文件未被污染(仍含旧词) -> True