
用 python-docx 安全改写舞台工艺审图意见 docxrun 级替换不破格式 合并单元格去重实战做舞台工艺、工程图纸类文档的自动化处理时一个高频需求是把一份审图意见模板 / 体系文档里的某些词批量替换掉比如把待修改统一改成需复核而且必须按原文替换——加粗、字体、黑边框一个都不能动。很多人第一反应是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 行为一致安装与版本确认pipinstall-Upython-docx python-cimport 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.textparagraph.text是一个只读聚合属性。它的 getter 把所有 run 的文本拼起来返回但如果你用它的 setterparagraph.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.0table.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 里的情况返回替换次数defreplace_in_runs(paragraph,old,new):ifnotold:return0count0forruninlist(paragraph.runs):ifoldinrun.text:countrun.text.count(old)run.textrun.text.replace(old,new)returncountsplit_run在 run 的第split_index个字符处切分新 run 复制原 run 的rPr深拷贝w:r元素用于把跨 run 的词边界拆开fromcopyimportdeepcopyfromdocx.text.runimportRundefsplit_run(run,split_index):textrun.text left,righttext[:split_index],text[split_index:]run.textleft new_rdeepcopy(run._element)run._element.addnext(new_r)new_runRun(new_r,run._parent)new_run.textrightreturnnew_runreplace_in_paragraph通用替换覆盖目标词跨 run的情况。它先按字符偏移在边界 run 切分再把被覆盖的 run 合并进首 run 并写入new新文本沿用首 run 的rPr并且用search_from跳过已写入区域避免new含old时陷入死循环defreplace_in_paragraph(paragraph,old,new):ifnotold:return0count0search_from0whileTrue:runsparagraph.runs full.join(r.textforrinruns)idxfull.find(old,search_from)ifidx-1:breakold_endidxlen(old)boundaries[]pos0forrinruns:boundaries.append((pos,poslen(r.text)))poslen(r.text)firstlastNonefori,(s,e)inenumerate(boundaries):ifsidxe:firstiifsold_ende:lastiifidxboundaries[first][0]:split_run(runs[first],idx-boundaries[first][0])runsparagraph.runs boundaries[]pos0forrinruns:boundaries.append((pos,poslen(r.text)))poslen(r.text)firstlastNonefori,(s,e)inenumerate(boundaries):ifsidxe:firstiifsold_ende:lastiifold_endboundaries[last][1]:split_run(runs[last],old_end-boundaries[last][0])runsparagraph.runs boundaries[]pos0forrinruns:boundaries.append((pos,poslen(r.text)))poslen(r.text)lastNonefori,(s,e)inenumerate(boundaries):ifsold_ende:lasti targetruns[first]target.textnewforiinrange(last,first,-1):runs[i]._element.getparent().remove(runs[i]._element)count1search_fromidxlen(new)returncountiter_unique_cells按id(cell)去重遍历表格单元格专治合并单元格重复访问defiter_unique_cells(table):seenset()forrowintable.rows:forcellinrow.cells:ifid(cell)inseen:continueseen.add(id(cell))yieldcell一个完整的批量替换审图意见文档示例注意doc.paragraphs不含表格内的段落表格要单独遍历单元格fromdocximportDocumentimportshutildefbatch_replace(doc_path,old,new,out_path):shutil.copy(doc_path,out_path)# 先留底稿docDocument(out_path)# 1) 正文段落forparaindoc.paragraphs:replace_in_paragraph(para,old,new)# 2) 表格先去重再改每个单元格内再走 run 级fortableindoc.tables:forcelliniter_unique_cells(table):forparaincell.paragraphs:replace_in_paragraph(para,old,new)doc.save(out_path)returnout_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_paragraphiter_unique_cells已在 python-docx 1.2.0 下实测单 run、跨 run、合并单元格三种场景全部通过格式零丢失。工程文档自动化最怕跑了但改坏了把这套范式固化进你的工具函数比每次手写字符串替换稳得多。