批量 Word 查找替换:表格、页眉页脚全覆盖,跨 run 拆词的坑一次讲清

2026-10-05 · 喵窝资源铺

场景:几百份合同/报价单要换公司抬头、改价格、改日期。Word 自带的「查找替换」一个文档一个文档开太慢,而且很多人不知道——它默认不替换页眉页脚里的内容(要进页眉再单独替换一遍)。这篇给一个整目录批量替换的 Python 脚本(python-docx),正文、表格、页眉页脚一次全替换,坏文件自动跳过。文中代码实跑验证,真实输出贴在下面。

它做的几件事

输入脚本的处理
正文/表格里的替换词全文遍历,表格单元格逐个进
页眉页脚默认页眉页脚 + 首页不同/奇偶页不同的三套都覆盖
替换规则JSON 列表,支持多条规则一次跑(换抬头+改公司名+改日期)
嵌套目录os.walk 递归,输出目录保持原层级
空文档正常处理(0 处替换),输出合法 docx 副本
坏文件截断损坏的 .docx 报 Package not found 跳过,不崩整批、不残留空壳目录
Word 锁文件~$xxx.docx 临时文件自动排除
原文件只读输入,结果写输出目录——源文件绝不被改

实跑抓到的最大的坑:Word 把一个词拆进多个 run

docx 里一段文字不是整段存的,是拆成一串 run(每段连续同格式的文字一个 run)。拼写检查标记、中英文混排、修改痕迹都会把一个词从中间劈开——比如「上海喵喵科技有限公司」会变成 run1「上海喵喵」+ run2「科技有限公司」。这时候逐 run 做 run.text.replace(...) 永远命不中,因为没有任何一个 run 里包含完整的词。

我们实测:同一段文档,naive 逐 run 替换只命中 1 次(实际出现 2 次),漏掉的就是被拆开的那处。解法:把段落内所有 run 的 text 拼成全文,在全文上找和替换,再把结果回写——整个词跨几个 run 都能命中。代价是段落内格式不一致时会统一成第一个 run 的格式,对合同/报告类文档通常可接受(真要保格式得逐字符映射 run 边界,复杂一个量级,有这需求的属于另一个报价档)。

实跑抓到的坑二:doc.save(输入路径) 会静默污染源文件

第一版脚本「原地替换」,第一轮测试就发现:第二次跑同一批文件时替换数全是 0——不是脚本坏了,是第一轮已经把输入文件原地改掉了,旧词早没了。批量替换这种「重复跑验证」极常用的场景必须读输入目录、写输出目录,源文件只读不动。这个 bug 在单文件手动跑的时候很难发现,批量复跑才暴露。

脚本全文(word_replace.py)

# -*- 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

边界说明(如实)

  • 支持 .docx;老 .doc 二进制格式不在范围(先批量转 docx,见上一篇 Word 转 PDF 文里的方案思路)
  • 段落内格式不一致时,替换后的文字统一成该段第一个 run 的格式(跨 run 拼接替换的代价);正文/表格类合同文档基本无感
  • 替换计数按「全文出现次数」统计,包含页眉页脚和文本框;嵌套表格的合并单元格在 python-docx 里会重复出现同一段落对象——替换是幂等的,不会重复替换出问题
  • 不想自己跑脚本?喵窝资源铺提供人工代跑+交付服务(miaowoo.900890.xyz):把你的 Word 文件和替换对照表发来,交付替换后的全套文件(逐份复核替换处数和残留)。由 AI 完成脚本与初验、人工复核后交付,9.9 元起。虚拟物品,交付后不退款,介意勿拍。

    ← 返回喵窝资源铺 · 相关:批量 Word 转 PDF · 相关:批量 CSV 合并进 Excel