ZhuoAIStage头像
舞台工艺文档自动化实战:python-docx 遍历 140 张表格时 id() 映射为何全部落空,以及不破坏黑边框的安全改写方案封面图

舞台工艺文档自动化实战:python-docx 遍历 140 张表格时 id() 映射为何全部落空,以及不破坏黑边框的安全改写方案

舞台工艺文档自动化实战: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。

于是失败链条完整了:

  1. {id(x._tbl): i for i, x in enumerate(doc.tables)} 这一行里,Table 对象只在推导过程中存活。
  2. 推导结束,Table 对象释放,它持有的 _tbl proxy 引用计数归零,proxy 被销毁。
  3. 字典里只剩下一串已经失效的地址数字。
  4. 随后 body.iterchildren() 新建一批 proxy,地址与上一步那批不同。
  5. 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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--