用 python-docx 安全改写舞台工艺审图意见 docx:run 级替换不破格式 + 合并单元格去重实战
做舞台工艺、工程图纸类文档的自动化处理时,一个高频需求是:把一份审图意见模板 / 体系文档里的某些词批量替换掉(比如把"待修改"统一改成"需复核"),而且必须"按原文替换"——加粗、字体、黑边框一个都不能动。很多人第一反应是 paragraph.text = … 或者直接对全文做字符串 replace,结果跑完发现格式全没了、合并单元格的内容还被写了两遍。本文用 python-docx 1.1.x / 1.2.0 实测,把三个最坑的点讲透,并给出两个开箱即用的函数。
一、背景:批量改审图意见 / 体系 docx 为什么总"破相"
舞台工艺审图意见、第三方检测体系文档这类 docx,有两个特征特别容易在自动化替换时翻车:
- 格式密集:关键词往往是加粗、变色、特定字体的,标题和表格里还带黑边框(w:tcBorders)。模板由多人维护,同一句话可能被拆成好几个 run。
- 表格多合并单元格:审图意见表、参数对照表大量使用横向 gridSpan 或纵向 vMerge 合并。合并单元格在遍历时会出现"同一格被访问多次"或"某格改不到"的怪现象。
两种典型"破相":
- 直接 paragraph.text = "新文本" 或对整个文档字符串 replace,加粗、字体、黑边框全部丢失,等于重新生成了一版没格式的纯文本。
- 遍历表格时,合并单元格在迭代里重复出现,导致你以为改了一处,实际同一个 cell 被写了多次(内容翻倍),或者反过来漏改。
根本原因在于:docx 的最小可见格式单元不是"段落文本",而是"run(文本块)“和"单元格对象”。任何绕过 run / 忽略合并单元格的去重,都会破坏格式或写错位置。
二、环境准备
本文代码在以下环境实测通过,建议对齐版本以免 API 行为差异:
- Python 3.8 及以上(实测 3.13.14 正常)
- python-docx 1.1.x 或 1.2.0(本文现象基于 1.2.0 验证,1.1.x 行为一致)
安装与版本确认:
pip install -U python-docx
python -c "import docx; print(docx.__version__)"
核心依赖说明:
- docx.text.paragraph.Paragraph:段落对象,.runs 是它的 run 列表。
- docx.text.run.Run:文本块,.text 是带格式的文本,.font / 底层 w:rPr 存格式。
- docx.table.Table / _Cell:表格与单元格,table.rows → row.cells 是网格感知的逐格迭代。
前置建议:处理前先对原文件做一份副本(shutil.copy),避免脚本逻辑有误把模板改坏,这是工程文档自动化的最低防线。
三、步骤:三大陷阱与安全改写范式
3.1 Pitfall 1 —— run 级编辑:别碰 paragraph.text
paragraph.text 是一个"只读聚合"属性。它的 getter 把所有 run 的文本拼起来返回;但如果你用它的 setter(paragraph.text = "…"),python-docx 会清空该段落下所有 run,再用一个全新的 run 承载整段文本。这一重建过程把每个 run 原有的 w:rPr(加粗、字体、颜色)全丢了。
正确做法是在 run 级编辑:遍历 paragraph.runs,只对单个 run 改 run.text。run.text 的 setter 只改写这一个文本块的内容,不会动它的 w:rPr,于是格式被原样保留。
但这里有个进阶坑:跨 run 的词语。例如"审查意见"在文档里其实是 “审查”(run1)+ “意见”(run2)两个 run 拼出来的,你直接对单个 run 做 run.text.replace("审查意见", …) 会找不到,因为每个 run 里都不含完整词。
处理跨 run 的办法是:在边界 run 处把文本"切分"开,让目标词完整落进单个 run,再做替换。切分用 deepcopy 复制一个带相同 rPr 的新 run 接到后面即可(见第四节 split_run)。
3.2 Pitfall 2 —— 合并单元格去重:同一格会被访问多次
实测 python-docx 1.2.0:table.rows → row.cells 是网格感知的——它会把横向 gridSpan / 纵向 vMerge 的合并区域,按"每个网格列都占一格"展开。于是合并单元格在逐行迭代里,会以**同一个 _Cell 对象(同一个 id())**重复出现。
我们做过一个 2 行 3 列、第 0 行前两格横向合并的表:用 row.cells 逐格遍历得到 6 个 cell 对象,但按 id() 去重后只有 5 个唯一对象。那个合并格在 row0 里出现了两次(同一 id)。如果你写 for cell in row.cells: cell.text = "X",这个合并格就会被写两次——在复杂模板里表现为"合并格内容翻倍"或"部分区域被重复覆盖"。
正确做法:用 set(id(cell)) 记录已访问对象,只处理唯一 cell。注意不要用 table.cell(r, c) 按网格坐标去索引——它不是网格感知的,合并后列索引会错位,反而拿不到正确对象。
3.3 Pitfall 3 —— id() 不一致(关联篇,轻提)
另一个隐藏雷区:doc.tables[i]._tbl 与 body.iterchildren() 遍历得到的表格元素,id() 并不一致。这会导致"明明改了 0 处,脚本却报成功"——你拿到的是另一批对象引用,改的根本不是文档里真正渲染出来的那张表。这个问题根因和完整解法已在我另一篇(#19)详述,本文只点一句关联:凡是"遍历文档结构改内容却像没生效",先怀疑对象引用是不是同一批。
3.4 安全改写范式(四步)
把上面三点收敛成一套可复用的改写纪律:
- ① 按 id() 建立唯一 cell / paragraph 映射,绝不重复处理同一个对象。
- ② 只在 run 级做替换(run.text),保留每个 run 的 rPr。
- ③ 黑边框 w:tcBorders 一律不动,python-docx 默认也不会改它,关键是别用会重建单元格的写法。
- ④ 合并单元格先去重再改,遍历统一走 iter_unique_cells。
四、代码 / 命令:两个可直接用的函数
下面四个函数是本文实测通过的版本(python-docx 1.2.0),可直接拷进工程使用。
replace_in_runs:在单个 run 内做替换,覆盖"目标词完整落在某个 run 里"的情况,返回替换次数:
def replace_in_runs(paragraph, old, new):
if not old:
return 0
count = 0
for run in list(paragraph.runs):
if old in run.text:
count += run.text.count(old)
run.text = run.text.replace(old, new)
return count
split_run:在 run 的第 split_index 个字符处切分,新 run 复制原 run 的 rPr(深拷贝 w:r 元素),用于把跨 run 的词边界拆开:
from copy import deepcopy
from docx.text.run import Run
def split_run(run, split_index):
text = run.text
left, right = text[:split_index], text[split_index:]
run.text = left
new_r = deepcopy(run._element)
run._element.addnext(new_r)
new_run = Run(new_r, run._parent)
new_run.text = right
return new_run
replace_in_paragraph:通用替换,覆盖"目标词跨 run"的情况。它先按字符偏移在边界 run 切分,再把被覆盖的 run 合并进首 run 并写入 new(新文本沿用首 run 的 rPr),并且用 search_from 跳过已写入区域,避免 new 含 old 时陷入死循环:
def replace_in_paragraph(paragraph, old, new):
if not old:
return 0
count = 0
search_from = 0
while True:
runs = paragraph.runs
full = "".join(r.text for r in runs)
idx = full.find(old, search_from)
if idx == –1:
break
old_end = idx + len(old)
boundaries = []
pos = 0
for r in runs:
boundaries.append((pos, pos + len(r.text)))
pos += len(r.text)
first = last = None
for i, (s, e) in enumerate(boundaries):
if s <= idx < e:
first = i
if s < old_end <= e:
last = i
if idx > boundaries[first][0]:
split_run(runs[first], idx – boundaries[first][0])
runs = paragraph.runs
boundaries = []
pos = 0
for r in runs:
boundaries.append((pos, pos + len(r.text)))
pos += len(r.text)
first = last = None
for i, (s, e) in enumerate(boundaries):
if s <= idx < e:
first = i
if s < old_end <= e:
last = i
if old_end < boundaries[last][1]:
split_run(runs[last], old_end – boundaries[last][0])
runs = paragraph.runs
boundaries = []
pos = 0
for r in runs:
boundaries.append((pos, pos + len(r.text)))
pos += len(r.text)
last = None
for i, (s, e) in enumerate(boundaries):
if s < old_end <= e:
last = i
target = runs[first]
target.text = new
for i in range(last, first, –1):
runs[i]._element.getparent().remove(runs[i]._element)
count += 1
search_from = idx + len(new)
return count
iter_unique_cells:按 id(cell) 去重遍历表格单元格,专治合并单元格重复访问:
def iter_unique_cells(table):
seen = set()
for row in table.rows:
for cell in row.cells:
if id(cell) in seen:
continue
seen.add(id(cell))
yield cell
一个完整的"批量替换审图意见文档"示例(注意 doc.paragraphs 不含表格内的段落,表格要单独遍历单元格):
from docx import Document
import shutil
def batch_replace(doc_path, old, new, out_path):
shutil.copy(doc_path, out_path) # 先留底稿
doc = Document(out_path)
# 1) 正文段落
for para in doc.paragraphs:
replace_in_paragraph(para, old, new)
# 2) 表格:先去重再改,每个单元格内再走 run 级
for table in doc.tables:
for cell in iter_unique_cells(table):
for para in cell.paragraphs:
replace_in_paragraph(para, old, new)
doc.save(out_path)
return out_path
batch_replace("审图意见模板.docx", "待修改", "需复核", "审图意见_已替换.docx")
五、踩坑:现象 / 成因 / 解法
坑 1:跑完替换后所有加粗、字体全部消失
- 现象:替换后的文档关键词不再加粗,颜色、字体也还原成默认,像是重新生成了一版纯文本。
- 成因:用了 paragraph.text = "…"。它的 setter 会清空段落下所有 run 并重建单个 run,原 w:rPr 一并丢失。
- 解法:永远在 run 级改,用 run.text = run.text.replace(…)(见 replace_in_runs);需要跨 run 时先用 split_run 拆边界。
坑 2:合并单元格内容翻倍 / 部分单元格没改到
- 现象:合并的大单元格里出现两遍相同内容,或某个合并区域始终没被替换。
- 成因:row.cells 是网格感知的,合并格在迭代里以同一 id() 的对象重复出现;直接逐格改写会把合并格写多次,且 table.cell(r,c) 按网格坐标索引在合并后会列错位。
- 解法:统一用 iter_unique_cells 按 id() 去重遍历;不要再用 table.cell(r,c) 做列索引式遍历。
坑 3:关键词明明在文档里却 replace 不到
- 现象:调用替换后文本没变化,但肉眼能看到那个词。
- 成因:该词被拆到了多个 run("审查"一个 run、"意见"一个 run),单 run 内 in 判断永远为假。
- 解法:用 replace_in_paragraph 兜底——它会在边界 run 切分后再合并替换,跨 run 词语也能命中。
坑 4:替换时脚本疑似卡死(实则死循环)
- 现象:当 new 字符串里包含 old(例如把"审查意见"换成"【审查意见】"),脚本长时间无响应。
- 成因:若每轮都从头全量扫描全文,刚写入的 new 里又含有 old,会无限重复匹配。
- 解法:replace_in_paragraph 里用 search_from = idx + len(new) 跳过已写入区域,保证每轮只向后找,必然终止。这也是 str.replace 单次扫描的语义一致性来源。
坑 5(关联):明明改了 0 处却报成功
- 现象:脚本跑完报"处理完成",但文档一点没变。
- 成因:doc.tables[i]._tbl 与 body.iterchildren() 的 id() 不一致,你改的是另一批对象引用,不是真正渲染的表格。
- 解法:确保遍历与改写用的是同一批对象;根因与完整方案见 #19。
六、总结
用 python-docx 做"按原文替换"的底层纪律只有一句话:以 run 为最小改写单元、以 id() 为唯一性判据。
落地就三步:正文段落和表格单元格内的段落都走 replace_in_paragraph(它内部已处理 run 级与跨 run);表格遍历统一套 iter_unique_cells 去重;改之前先 shutil.copy 留底。这样加粗、字体、黑边框原样保留,合并单元格也只写一次。
两个函数(replace_in_paragraph + iter_unique_cells)已在 python-docx 1.2.0 下实测:单 run、跨 run、合并单元格三种场景全部通过,格式零丢失。工程文档自动化最怕"跑了但改坏了",把这套范式固化进你的工具函数,比每次手写字符串替换稳得多。

