舞台工艺文档自动化实战:python-docx 遍历 140 张表格时 id() 映射为何全部落空,以及不破坏黑边框的安全改写方案
一、背景与结论先行
在舞台工艺、演出场馆建设这类工程文档的生产链路里,交付物往往是上百页的规范类或体系类 docx,正文里嵌着一百多张表格。这类文档有一个硬约束:表格黑边框、字体、合并单元格这些既有格式一条都不能动,但单元格里的文字可能需要按原文子串做批量订正。一旦脚本把格式写坏,整份文档就要重排,返工成本远高于写脚本本身。
于是最常见的实现思路是这样:先用 doc.tables 拿到所有表,用 id(t._tbl) 做键建一张「表元素 → 表号」的映射;再遍历 doc.element.body.iterchildren() 去定位每张表在文档流中的真实位置,靠查这张映射把表号回填回去。逻辑看上去严丝合缝,跑起来却是命中 0——140 张表一张都没对上。
本文只解决两件事:
- 怎么正确遍历到文档里的每一张表:为什么按
id()建的映射会全部落空,以及可以稳定复现的替代写法。 - 怎么改单元格文本而不破坏既有格式:逐 run 替换
w:t,以及如何绕过合并单元格带来的两次误判。
结论先给出来:
- 不要用
id()给 lxml 元素建索引。doc.tables[i]._tbl与body.iterchildren()出来的w:tbl元素,id()不保证相等。实测在 140 表规模下,映射命中数为 0。 - 正确做法是就地构造:对每个
w:tbl元素执行Table(child, doc),用一个自增计数器当顺序号,不依赖任何跨元素的对象身份比较。 - 不要对
row.cells做id(cell._tc)去重。 去重会误跳过真正要改的那个tc,实测改写数为 0。重复替换是幂等的,第二次会自动因为OLD not in text跳过。 - 只改
w:t的.text,不碰w:rPr、w:tcPr、w:tblBorders,格式就不会被破坏。 - 改前先
shutil.copy留.bak备份,改完按固定门禁验收。
下面所有结论都在本机实测过,环境是 Windows 11 / Python 3.13.14 / python-docx 1.2.0。
二、环境准备
依赖只有一个,lxml 会作为 python-docx 的底层依赖一起装上:
python -m pip install python-docx==1.2.0
python -c "import docx, sys; print(sys.version); print(docx.__version__)"
期望输出类似:
3.13.14 (main, Jun 11 2026, 04:04:46) [MSC v.1944 64 bit (AMD64)]
1.2.0
需要提前理解的三个 API 事实,后面每一步都会用到:
doc.element.body是CT_Body,即w:body元素,它本身就是 lxml 元素,支持.iterchildren()与.iter()。Table(tbl, parent)是 python-docx 的公开类,第二个参数是「父容器」,传doc即可满足它对.part的访问需求。这意味着拿到任意一个w:tbl元素,都能就地包成一个Table对象,不必经过doc.tables。qn()用来把w:tbl这种带前缀的名字展开成带命名空间的完整标签名。qn('themeColor')会抛ValueError: not enough values to unpack (expected 2, got 1),必须写成qn('w:' + 'themeColor')。这个坑在第四节还会再遇到。
先跑一段自检,确认环境里 qn 的行为与后面脚本的假设一致:
from docx.oxml.ns import qn
print(qn('w:tbl')) # {http://.../main}tbl
try:
qn('themeColor')
except ValueError as e:
print('ValueError:', e) # not enough values to unpack (expected 2, got 1)
print(qn('w:' + 'themeColor'))
三、操作步骤:从「映射全部落空」到「就地构造」
3.1 现象:命中 0
把失败代码原样复现。为了不依赖任何外部文件,这里用内存里的 BytesIO 构造一份 140 张表的文档:
import io, gc
from docx import Document
from docx.oxml.ns import qn
def build(n=140):
doc = Document()
for i in range(n):
t = doc.add_table(rows=2, cols=3)
t.cell(0, 0).text = f"T{i}"
buf = io.BytesIO(); doc.save(buf); buf.seek(0)
return Document(buf)
d = build()
# 失败写法:字典推导结束后,Table 对象与它持有的 lxml 代理一起被回收
m = {id(x._tbl): i + 1 for i, x in enumerate(d.tables)}
gc.collect()
seq = hit = 0
for child in d.element.body.iterchildren():
if child.tag != qn('w:tbl'):
continue
seq += 1
if m.get(id(child)) == seq:
hit += 1
print(f"命中 {hit}/{seq}")
输出:
命中 0/140
只改一行——把 Table 对象用列表持有住,不让它被回收——结果立刻反转:
kept = list(d.tables) # 关键:持有引用
m = {id(x._tbl): i + 1 for i, x in enumerate(kept)}
gc.collect()
seq = hit = 0
for child in d.element.body.iterchildren():
if child.tag != qn('w:tbl'):
continue
seq += 1
if m.get(id(child)) == seq:
hit += 1
print(f"命中 {hit}/{seq}") # 命中 140/140
同样的两份代码,一个 0/140,一个 140/140。差别不在表数量,也不在遍历方式,只在于中间有没有人活着持有那些 lxml 代理对象。
3.2 根因:lxml 代理对象的生命周期
python-docx 1.2.0 的 BlockItemContainer.tables 源码是这样的:
@property
def tables(self):
from docx.table import Table
return [Table(tbl, self) for tbl in self._element.tbl_lst]
注意两点:
- 每次访问
doc.tables都会新建一批Table对象;doc.tables不是缓存。 tbl是self._element.tbl_lst取出的元素,也就是 lxml 的 element proxy。proxy 是 Python 层的包装对象,底层才是指向 XML 节点的 C 指针。lxml 只在有人引用时保持这个 proxy 存活;一旦引用计数归零,proxy 被销毁,下次通过任意路径再访问同一个节点,会新建一个 proxy。
于是失败链条完整了:
{id(x._tbl): i for i, x in enumerate(doc.tables)}这一行里,Table对象只在推导过程中存活。- 推导结束,
Table对象释放,它持有的_tblproxy 引用计数归零,proxy 被销毁。 - 字典里只剩下一串已经失效的地址数字。
- 随后
body.iterchildren()新建一批 proxy,地址与上一步那批不同。 id(child)查不到键,全部落空。
这也是这个坑最危险的地方:它不取决于表数量,而取决于对象是否被回收。写脚本时用一两张表做冒烟测试,很容易因为某个临时变量恰好还持有引用而通过;一旦接上真实的百表文档、中间又多了几层函数调用,就整片失效。
还有一种更坏的情况值得警惕:id() 的返回值在对象释放后会被新对象复用。如果映射没有落空而是命中到了错误的键,脚本不会报错,只会把 A 表的表号安到 B 表头上,输出一份看起来正常、实际全错的日志。所以这条规则要记死:lxml 元素的身份不能靠 id() 比较,也不要存 id() 当键。
3.3 正确做法:就地构造 + 计数器
放弃「先建映射、后查映射」,改成「边遍历、边构造、边编号」:
from docx.table import Table
from docx.oxml.ns import qn
def iter_tables(doc, nested=False):
"""按文档顺序产出 (顺序号, Table)。就地构造,不依赖 doc.tables。"""
body = doc.element.body
src = body.iter(qn('w:tbl')) if nested else body.iterchildren()
n = 0
for child in src:
if child.tag != qn('w:tbl'):
continue
n += 1
yield n, Table(child, doc) # 就地构造;n 即该表的顺序号
顺序号完全由遍历次序决定,不依赖任何对象身份,不存在失效问题。
关于 nested 这个开关,有一个必须知道的事实。doc.tables 只返回顶层表——python-docx 源码里 Document.tables 的 docstring 明确写着:只有出现在文档顶层的表会出现在这个列表里,嵌在单元格内的表不出现,被 w:ins / w:del 这类修订标记包住的表也不出现。
实测三种取法的差异:
from docx import Document
from docx.oxml.ns import qn
from docx.table import Table
doc = Document()
outer = doc.add_table(rows=1, cols=1)
outer.cell(0, 0).text = "OUTER"
nested = outer.cell(0, 0).add_table(rows=1, cols=2) # 单元格内嵌一张表
nested.cell(0, 0).text = "NESTED-A"
doc.add_paragraph("段落")
buf = io.BytesIO(); doc.save(buf); buf.seek(0)
d2 = Document(buf)
print(len(d2.tables)) # 1
print(len([c for c in d2.element.body.iterchildren()
if c.tag == qn('w:tbl')])) # 1
print(len(list(d2.element.body.iter(qn('w:tbl'))))) # 2
doc.tables 与 iterchildren() 都是 1,只有 .iter() 是 2。如果你的文档里存在嵌套表,前两种取法会静默漏表,而且漏掉的表不会体现在任何报错里。
选哪个取决于目的:
- 要和 Word 里人工数的「第 N 张表」对齐:用
nested=False,语义与doc.tables一致。 - 要做全文无遗漏的抽取或校核:用
nested=True,但要接受下面这个副作用。
副作用是:cell._tc.iter(qn('w:r')) 是递归的,外层单元格会把它内部嵌套表的 run 也一起遍历出来。实测外层 tc.iter(qn('w:r')) 同时返回了 'OUTER' 和 'NESTED-HIT' 两个 run。也就是说在 nested=True 模式下,嵌套表里的同一个 run 会被访问两次。好在替换是幂等的(第二次 OLD not in text 自动跳过),changed 计数仍然等于真实 run 数,不会多算——但日志里可能会出现重复的命中记录,需要按 run 去重再输出。
四、代码:一次完整的安全改写
4.1 第一步:备份,永远先备份
import shutil, sys
SRC = sys.argv[1]
BAK = SRC.replace(".docx", ".bak_before_rewrite.docx")
shutil.copy(SRC, BAK)
print("backup ->", BAK)
这一步没有任何技术含量,但它是后面所有操作的兜底。改写脚本跑在真实交付物上时,shutil.copy 的两行代码是唯一能让你放心执行的东西。
4.2 第二步:枚举并抽样核对顺序号
不要相信任何自动编号,先打几张表的首行文本出来人工对一眼:
from docx import Document
from docx.oxml.ns import qn
from docx.table import Table
doc = Document(SRC)
body = doc.element.body
for idx, t in iter_tables(doc, nested=False):
if idx <= 3 or idx % 20 == 0:
head = " | ".join(c.text.strip() for c in t.rows[0].cells)
print(f"#{idx:>3} rows={len(t.rows):<3} cols={len(t.columns):<3} {head[:60]}")
total = sum(1 for _ in iter_tables(doc, nested=False))
print("总表数 =", total)
把输出的前三张和每 20 张抽一张,与 Word 里翻到的位置对一遍。顺序号对上了,后面所有基于表号的定位才成立。
4.3 第三步:抽取待改单元格
抽取阶段只做只读扫描,不写任何东西:
def cell_text(tc):
"""取一个 tc 下所有 w:t 的拼接文本。"""
return "".join(x.text for x in tc.iter(qn('w:t')) if x.text)
KEYWORDS = ("静载荷", "动载荷", "安全系数")
found = []
for idx, t in iter_tables(doc, nested=False):
for ri, row in enumerate(t.rows, 1):
for cell in row.cells:
txt = cell_text(cell._tc)
if any(k in txt for k in KEYWORDS):
found.append((idx, ri, txt))
print("命中单元格 =", len(found))
for idx, ri, txt in found[:20]:
print(f" 表#{idx} 行{ri}: {txt[:70]}")
这里有一个易错点:Paragraph 对象没有 .iter()。实测 hasattr(paragraph, 'iter') 为 False。要遍历段落里的文本节点,必须走它底层的 CT_P:
for p in doc.paragraphs:
for wt in p._p.iter(qn('w:t')): # 正确
...
# p.iter(qn('w:t')) # AttributeError
4.4 第四步:逐 run 改写,只动 w:t
这是保证格式不被破坏的核心。不调用 cell.text = ...,因为那会重建整个单元格的段落与 run,把 w:rPr 里的字体、字号、颜色设置一并抹掉。正确做法是下沉到 run 层,只替换 w:t 元素的文本:
OLD = "静载荷试验"
NEW = "静载荷试验(含吊具自重)"
changed = 0
log = []
for idx, t in iter_tables(doc, nested=False):
for ri, row in enumerate(t.rows, 1):
for cell in row.cells: # 不做 id() 去重,原因见第五节
for r in cell._tc.iter(qn('w:r')):
wt = r.find(qn('w:t'))
if wt is None or not wt.text:
continue
if OLD in wt.text:
log.append((idx, ri, wt.text))
wt.text = wt.text.replace(OLD, NEW)
changed += 1
doc.save(SRC)
print(f"runs changed = {changed}")
这段代码为什么不会破坏格式,逐条说明:
w:rPr不动:w:rPr是w:r的子元素,存字体、字号、粗斜体、颜色。代码只写wt.text,即w:t的文本节点,w:r的其他子元素原样保留。w:tcPr不动:w:tcPr存列宽、底纹、垂直对齐、以及单元格级的w:tcBorders。代码根本没进入这一层。w:tblBorders不动:表级边框在w:tblPr/w:tblBorders,全程未被访问。黑边框必然保留。- 合并单元格的
gridSpan/vMerge不动:这两个属性写在w:tcPr里,同样未被访问。
如果需要吃掉空格变体(原文里 ≥ 2 圈 与 ≥2 圈 混排),把子串判断换成正则即可:
import re
PAT = re.compile(r"静载荷\s*试验")
if PAT.search(wt.text):
wt.text = PAT.sub(NEW, wt.text)
4.5 第五步:验收
改写不是跑完就结束。下面这个脚本是准入闸门,五项全绿才算交付:
WANTED = {"top", "left", "bottom", "right", "insideH", "insideV"}
border_fail = theme_color = 0
leftover = 0
n_tables = 0
for idx, t in iter_tables(doc, nested=False):
n_tables += 1
# 表级边框闸门
tblPr = t._tbl.find(qn('w:tblPr'))
borders = None if tblPr is None else tblPr.find(qn('w:tblBorders'))
if borders is not None:
for b in borders:
tag = b.tag.split('}')[-1]
if tag not in WANTED:
continue
if (b.get(qn('w:val')) != 'single'
or b.get(qn('w:color')) != '000000'
or b.get(qn('w:sz')) != '6'):
border_fail += 1
if b.get(qn('w:' + 'themeColor')) is not None:
theme_color += 1
# 旧串残留扫描
for row in t.rows:
for cell in row.cells:
if OLD in cell_text(cell._tc):
leftover += 1
print(f"表数 = {n_tables}")
print(f"BORDER_FAIL = {border_fail}")
print(f"THEME_COLOR = {theme_color}")
print(f"残留 = {leftover}")
五项判据:
- 表数 = 修改前一致。改写不应增删表,数字变了说明遍历或保存环节出问题。
- BORDER_FAIL = 0。所有六个方向的边框都是
val=single、color=000000、sz=6。 - THEME_COLOR = 0。任何一条边框上都不允许出现
w:themeColor。 - 目标子串残留 = 0。全表扫一遍
OLD,还有残留说明有 run 没被覆盖到。 .bak备份留档未删。
五、踩坑与排查
5.1 踩坑一:对 cell 做 id() 去重,导致改写数为 0
row.cells 对合并单元格会按列数展开返回。一个横向合并占三列的表头行,row.cells 返回 3 个 Cell 对象,但它们的 _tc 是同一个对象:
row = t.rows[0]
print(len(row.cells)) # 3
print([id(c._tc) for c in row.cells]) # 三个相同的 id
print([c.text for c in row.cells]) # 三份完全相同的文本
于是很自然会想「既然重复了就去重吧」,写出下面这段:
# 错误写法
seen = set()
for cell in row.cells:
if id(cell._tc) in seen:
continue
seen.add(id(cell._tc))
...
问题在于 seen 是空的,第一个 cell 的 _tc 会被加入 seen——而这个 _tc 恰恰就是唯一装着那段文本的 tc。去重逻辑把唯一需要处理的那个 tc 当成了重复项跳过,实测改写数为 0。
正确做法只有一个字:不去重。让同一个 tc 被处理三遍,代价是两次无谓的字符串比较,收益是永远不会漏:
# 正确写法
for cell in row.cells:
for r in cell._tc.iter(qn('w:r')):
wt = r.find(qn('w:t'))
if wt is None or not wt.text:
continue
if OLD in wt.text:
wt.text = wt.text.replace(OLD, NEW)
幂等性由 if OLD in wt.text 这一行保证:第一次替换后文本已经不含 OLD,第二、第三次自动跳过。
这一条与 3.2 节是同一个教训的两面:凡是 lxml 元素,都不要用 id() 做身份判定。 表级用计数器,单元格级不去重。
5.2 踩坑二:把「runs changed: 1」当成漏改
承接上一节。横向合并(gridSpan)的一行返回 3 个 Cell,但 _tc 是同一个,文本只存在于一个 run 里。所以改一次,三列同步生效,日志里打印的是:
runs changed = 1
这不是漏改,这是正确结果。三列的文本会同时变成新值:
['静载荷试验(含吊具自重)', '静载荷试验(含吊具自重)', '静载荷试验(含吊具自重)']
判定标准要换成「目标子串残留 = 0」,而不是「changed 次数 = 列数」。用 run 数去反推单元格数,在存在合并单元格的文档里永远对不上。
5.3 踩坑三:qn('themeColor') 抛 ValueError
qn() 的实现是把传入字符串按冒号拆成前缀与标签名两段。传入不带冒号的 'themeColor' 时,split(':') 只得到一段,解包失败:
qn('themeColor')
# ValueError: not enough values to unpack (expected 2, got 1)
必须显式带上命名空间前缀:
qn('w:' + 'themeColor')
# {http://schemas.openxmlformats.org/wordprocessingml/2006/main}themeColor
写 'w:themeColor' 直接传也一样,但用字符串拼接的写法更能提醒自己「这里必须有前缀」。凡是检查 w:themeColor、w:themeShade、w:themeTint 这类属性,都要这样写。
5.4 踩坑四:w:tblBorders 可能是 None,闸门脚本自己崩了
验收脚本里这一行很危险:
borders = t._tbl.find(qn('w:tblPr')).find(qn('w:tblBorders'))
for b in borders: # borders 为 None 时抛 TypeError
...
实测 python-docx 用 add_table 新建的表,默认没有直接的 w:tblBorders 节点——边框来自 Table Grid 样式,find() 返回 None,紧接着 for b in None 抛 TypeError。
更麻烦的是 w:tblPr 本身也可能为 None。所以要做两级判空,并且明确区分两种状态:
tblPr = t._tbl.find(qn('w:tblPr'))
borders = None if tblPr is None else tblPr.find(qn('w:tblBorders'))
if borders is None:
print(f"表#{idx} 无直接 tblBorders,边框来自样式定义,需回溯样式链")
continue
「没有直接边框定义」与「有定义但不合规」是两件事。前者需要去样式链上查,后者才是真正的 BORDER_FAIL。把两者混为一谈,会让闸门输出一堆假失败,最后没人再信这份报告。
5.5 踩坑五:row.cells 的长度不等于 w:tc 的个数
row.cells 的长度等于表格的列数,不是该行 w:tc 子元素的个数。有合并单元格时前者大于后者。任何「按 w:tc 下标定位单元格」的写法在合并单元格面前都会错位:
# 不可靠:下标与视觉列号不一致
tc_lst = row._tr.findall(qn('w:tc'))
tc_lst[2] # 第 3 个 tc 未必是视觉上的第 3 列
# 可靠:cells 已按视觉列展开
row.cells[2] # 视觉第 3 列
需要视觉列号就用 row.cells,需要原始 XML 结构才碰 _tr。
六、总结
回到开头那两个问题。
第一个问题——怎么遍历到每一张表。 答案是:不要用 id() 建任何跨元素的映射,改用「就地构造 + 计数器」。
from docx.table import Table
from docx.oxml.ns import qn
def iter_tables(doc, nested=False):
body = doc.element.body
src = body.iter(qn('w:tbl')) if nested else body.iterchildren()
n = 0
for child in src:
if child.tag != qn('w:tbl'):
continue
n += 1
yield n, Table(child, doc)
这十行代码背后是三个必须记住的事实:
doc.tables每次访问都新建Table对象,它持有的 lxml 代理在没有引用时会被销毁并重建,id()因此不可靠。doc.tables与body.iterchildren()都只覆盖顶层表,嵌套表要用body.iter(qn('w:tbl'))才捞得到。- 顺序号必须由遍历次序产生,不能由对象身份产生。
第二个问题——怎么改文本又不破坏格式。 答案是:下沉到 run,只写 w:t 的文本。
for r in cell._tc.iter(qn('w:r')):
wt = r.find(qn('w:t'))
if wt is None or not wt.text:
continue
if OLD in wt.text:
wt.text = wt.text.replace(OLD, NEW)
changed += 1
配合三条纪律:不对 cell 做 id() 去重;把 runs changed = 1 视为合并单元格下的正确结果;判定标准用「残留 = 0」而不是「changed = 列数」。
最后把它固化成一份每次改写前后都跑的门禁,输出四个数字加一个备份文件:
print(f"表数 = {n_tables} (改前应一致)")
print(f"BORDER_FAIL = {border_fail} (须为 0)")
print(f"THEME_COLOR = {theme_color} (须为 0)")
print(f"残留 = {leftover} (须为 0)")
print(f"备份 = {BAK} (须存在)")
脚本跑通不等于格式合规。真正让一份上百表的工程文档可以放心交付的,是这四行输出全部为零,以及磁盘上那个随时能回滚的 .bak。
把这两个动作做对之后,剩下的事情就简单了:换 OLD / NEW、换关键词集合、换一份文档,同一套骨架可以一直跑下去。而在这个过程中最容易被忽略、也最值得先写进代码规范的,其实只有一句话——在 python-docx 里操作 lxml 元素时,永远不要用 id() 判断它们是不是同一个。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/ZhuoAIStage/article/details/166936361




