舞台工艺审核交付物别被脚本重生成覆盖:用 Python 建一套 docx 手改痕迹守卫

简介: 一次脚本重生成,让业主手填的复核意见全部归零。本篇从交付事故切入,给出守卫四层——快照、文本指纹、差异分类、门禁退出码,并治理索引碰撞导致的误报,把卡点做成不可跳过的一步。

交付前那天晚上,脚本又跑了一次。跑得很顺,退出码 0,docx 也出来了,排版、黑边、页眉页脚都对。唯一的问题是:业主在复核意见列里填的那些字,全没了。
不是乱码,不是格式错位,是整列变成空白。十几条"已按意见调整""资料后补""保留原设计,理由见附件",一夜回到解放前,只能靠人拿 PDF 一个字一个字敲回去。
这篇文章不讨论"要不要小心一点",只讲一件事:怎么在交付链路里加一道不可跳过的卡点,让这种丢失在发生的那一秒就被拦下来。 全文代码在 Python 3.13 + python-docx 1.1.2 上实测跑通,六个场景的实测数据我贴在对应小节,全部标注为示例文档结果,不是真实项目统计。
一、事故现场:一次重生成,业主填的复核意见全归零
1.1 交付链路的形状
舞台工艺审图意见的交付链路,一般是这个形状:

  1. 专家按专业(机械、灯光、音响、视频、电气、消防)写审核意见,源文件是 Markdown;
  2. 生成脚本把 md 转成 docx,套上格式铁律:字体、字号、表格黑边、页眉页脚、目录域;
  3. docx 发到业主、设计院、总包手上,对方在表格里手填回复;
  4. 专家收到回复后补一轮意见,md 更新,脚本再跑一次;
  5. 出终稿、打包、发送。
    事故发生在第 4 步和第 5 步之间。这是整条链路里唯一一个"机器重写整份文档"的动作,也是唯一一个有削减效果的动作。
    1.2 意见表的六列
    审图意见表一般是六列(不同模板列名略有出入,机制不受影响):
  • 序号:条目编号,机器生成;
  • 图纸 / 部位:指向哪张图、哪个机房、哪道吊杆,机器生成;
  • 审核要点:专家的专业判断,机器生成;
  • 规范依据:引用的标准条款,机器生成;
  • 复核意见:业主 / 设计院手填;
  • 处理结论:业主 / 设计院手填,闭环状态。
    前四列机器写,后两列人写。生成脚本的视角里只有前四列——它不知道后两列存在,所以"重生成"这个动作对它来说不是"覆盖",而是"它本来就没写过这两列"。
    1.3 归零的到底是什么
    复核意见列里装的不是文字,是责任。一句"已按意见调整,见变更单 03",包含了谁拍的板、什么时候拍的、依据是什么。脚本写的那四列随时可以再生成一遍;人写的那两列一旦没了,就真的没了——你没法让业主凭记忆再签一次。
    所以这次事故的成本不在"重敲一遍字",在于那份责任链的证据断了一截。这也是为什么我后来把它做成了卡点而不是提醒:提醒可以被忽略,卡点不行。
    1.4 为什么"记得看一眼"一定会失效
    事故之后的第一反应通常是"以后跑脚本之前记得看一眼"。这条路我走过,结论是必失效,原因有三:
  • 重生成往往不是单独一步:它嵌在"改一批意见 → 重新出稿"的流程里,人关注的是意见改没改对,不会顺带核对别人填的格子;
  • 间隔长:手填发生在两周前,重生成发生在交付当天,中间没有任何东西提醒你"这份上面有别人的字";
  • 代价不对称:看一眼要花十分钟,不看的话大概率也没事——直到出事那一次。
    真正可靠的只有一种形态:把校验做成生成动作的一部分,生成成功不等于任务完成,守卫通过才算完成。
    二、根因定位:按位置比对为什么必然误报
    要做守卫,先得搞清楚"怎么比"。最直觉的做法是把每个格子的位置记下来,重生成后再按同一个位置取出来比。这条路必然误报,因为位置坐标会动,内容不会动。
    2.1 三种位移
  • 插一张表:后面所有表的下标 +1。原来第 2 张表变成第 3 张,按下标配对的话从第 2 张起全部误报;
  • 插一行:该表后续所有行的下标 +1。在中间插一条新意见,后面几十条复核意见全部误报丢失;
  • 插一列:手填列的下标 +1。拿"第 4 列"去比"第 4 列",比到的其实是隔壁列。
    这三种都不是偶发,是交付过程中必然发生的:专家补意见就会插行,业主要"整改期限"就会插列,专业拆细就会插表。
    2.2 合并单元格让位置彻底失真
    审图意见表的标题行通常跨六列合并。在 OOXML 里,横向合并是一个 带 ;但 python-docx 的 row.cells 按网格展开,会把同一个 tc 返回两次。实测(示例文档,一张 4 行 6 列、标题行合并的表):
    row.cells 计数 = 24
    物理 计数 = 19
    按 tc 去重后 = 19
    同一份文档两种数法差 5 个。按"格子序号"配对的话,第一行的列对齐就全乱了,后面的列下标整体错位。
    2.3 本文的选择:用"行"做主键
    既然表下标、行下标、列下标都会动,那就换一个不会动的坐标。本文用的是行锚点:
    行锚点 = 序号列指纹 + "||" + 审核要点列指纹
    理由是:在审图意见里,一行就是一条完整意见,"序号 + 审核要点"这个组合天然唯一标识一条意见,而且这两个字段都是机器写的、内容稳定。业主手填也是按行填的——保护和比对都以"行"为单位,语义上最贴合。
    更重要的是它天然免疫三种位移:
  • 插表:行锚点不含表下标,表怎么插都不影响;
  • 插行:行锚点不含行下标,插多少行都不影响;
  • 插列:行锚点只依赖锚点列的内容,只要序号和审核要点这两列的文字没变,插多少列都不影响。
    实测(示例文档,两张表共四条意见):
    行锚点数 = 4
    锚点 = "1||栅顶检修马道" 位置 t0/r2 链长 4
    锚点 = "2||主舞台吊杆" 位置 t0/r3 链长 4
    锚点 = "1||面光桥" 位置 t1/r2 链长 4
    锚点 = "2||耳光室" 位置 t1/r3 链长 4
    注意锚点里完全不含 t0/r2 这类信息——位置只作为"告诉人在哪儿"的展示字段挂在旁边,不参与配对。
    2.4 为什么不用"表"或"列"做键
    换掉位置坐标之后,还有两个候选主键,我最后都没选:
    用表做键(把整张表的内容摘要当身份):能解决插表问题,但粒度太粗。一张意见表十几行,只要其中一行的手填内容被吞,表级摘要会整体变化,你只知道"这张表有问题",还得再人工定位到行。而且表头一旦被改(比如加个"(第二轮)"后缀),表身份就失效,整张表全部掉进待确认。
    用列做键(把列表头的文字当身份):能解决插列问题,但它假设"列名不变"。实际交付里列名经常被改措辞——"复核意见"改成"复核意见(业主填)"、"处理结论"改成"闭环状态",一改就断。更麻烦的是同一个列名在多张表里重复出现(每张意见表都有"复核意见"),列级键天然要再套一层表级键才唯一,等于把两个会失效的东西叠在一起。
    行锚点的粒度刚好:一条意见 = 一行 = 一个业主填写的单位。保护目标是"某条意见的回复别丢",行锚点正好是这个语义单元的身份证,不需要再叠任何一层。它对插表、插行、插列都免疫,唯一的风险是锚点列本身被改写——这个风险我会在 5.3 里给出显式处理,而不是假装它不存在。
    三、守卫四层:快照 / 指纹 / 分类 / 门禁
    整套守卫分四层,每层只做一件事。分层的好处是任何一层可以单独替换,比如你想把指纹换成别的归一化策略,不影响上下游。
    3.1 第一层:快照
    快照的任务是把重生成之前的完整状态存下来。四条硬要求:
  • 按物理 XML 元素遍历:不走 row.cells,直接走 的 tr_lst / tc_lst,天然去重(见 2.2);
  • 软换行、制表符要读到:Paragraph.text 读不到 和 ,得自己拼;
  • 修订痕迹要读到:对方开着修订模式填表时,文字在 容器里,只扫 w:r 会整段漏掉——恰恰是手填内容最容易漏;
  • 单元格里可能嵌表:要用递归处理,否则内嵌表格的段落会串进外层单元格的文本。
    3.2 第二层:指纹与单元格哈希
    指纹要"同一句话两次写出来得到同一个键",需要收敛零宽字符、全半角、连续空格、CRLF。但不要用 unicodedata.normalize("NFKC", s) 全量归一——NFKC 会把 ① 变成 1、把 ㎡ 拆成 m2。审图意见里条号常用 ①②③,规范条文里常见 kN/㎡,一旦全量归一,不同内容会撞成同一个指纹,丢没丢就再也分不清了。
    只收敛全角 ASCII 区间(FF01–FF5E)和全角空格,其余原样保留。指纹之上再算一层短哈希,是为了让报告里不用打印整段原文:
    fingerprint("① 复核") -> "① 复核" # 没有被归一成 "1 复核"
    fingerprint("ABC 复核") -> "ABC 复核" # 全角字母与全角空格被收敛
    sha1_of("已按意见调整") -> "b00d24c7e8f6"
    3.3 第三层:分类
    比对结果不能直接当结论,要分成三类:
  • 真丢失:这段文本在 md 源里从来没出现过(说明是人写的),重生成后没了 → 阻断;
  • 预期变更:指纹能在 md 源里找到(说明是机器写的),md 这一轮改了,它消失是预期内的 → 不阻断,进人工确认;
  • 假阳性:坐标失效(行锚点对不上、列结构变化)而不是内容丢失 → 不阻断,进人工确认。
    方向不能反:只有拿得准的丢失才阻断,所有拿不准的都进人工确认。 反过来的话,一有风吹草动就红,守卫三天之内一定被人关掉。
    3.4 第四层:门禁
    门禁是报告驱动的:先产出一份完整的 report 对象(含每一条 finding 的状态、位置、原文、判据),再由 report 决定整体状态,最后把状态翻成退出码。
    这么绕一步是有意义的——report 是给人看的证据,也是给流水线用的输入。人看到的是"哪一行哪一列的哪句话没了、凭什么判定它是人写的";流水线看到的只是一个退出码。两者必须来自同一份数据,否则"人确认过了"和"机器判定"会各说各话。
    四层之间的接口也很简单,每层只往下游交一件东西:
  • 快照层交出 {稳定路径: 文本} 的字典;
  • 指纹层把文本变成指纹与短哈希;
  • 分类层交出 findings 列表,每条带状态、锚点、列、原文、判据;
  • 门禁层把 findings 变成 report,再把 report.state 翻成退出码。
    任何一层都可以单独替换而不动上下游:想换归一化策略就改指纹层,想换判据就改分类层。这一点在真实交付里很值钱——模板改版、字段改名、流程调整,你只需要动一层。
    三态与退出码:
  • PASS(0):无差异,放行;
  • REVIEW(3):有待人工确认项,不阻断,但必须有人看过报告;
  • BLOCK(2):检测到真丢失,阻断交付。
    四、代码实现:从快照到退出码
    下面是一个完整可跑的守卫脚本,按顺序拼起来存成 docx_anchor_guard.py 即可。
    4.1 环境与基础函数

    -- coding: utf-8 --

    """docx_anchor_guard.py —— 行锚点 + 单元格哈希链 的手改痕迹守卫
    环境:Python 3.11+ / python-docx 1.1.2 / lxml(python-docx 自带)
    """
    from future import annotations

import json
import re
import hashlib
from collections import Counter, defaultdict
from pathlib import Path

from docx import Document
from docx.oxml.ns import qn

_ZERO_WIDTH = dict.fromkeys(map(ord, "\u200b\u200c\u200d\ufeff\u00ad"), None)

def _build_fullwidth_table() -> dict:
"""只收敛全角 ASCII(FF01–FF5E)与全角空格。
不动 ①②③ / ㎡ / 罗马数字,避免把不同内容归一成同一指纹。
"""
tbl = {code: code - 0xFEE0 for code in range(0xFF01, 0xFF5F)}
tbl[0x3000] = 0x20
return tbl

_FW = _build_fullwidth_table()

def fingerprint(text: str) -> str:
"""文本指纹:去零宽 -> 全半角收敛 -> 空白折叠 -> 去首尾空白。"""
s = (text or "").translate(_ZERO_WIDTH).translate(_FW)
s = s.replace("\r\n", "\n").replace("\r", "\n")
s = re.sub(r"[ \t]+", " ", s)
s = re.sub(r"\s\n\s", "\n", s)
return s.strip()

def sha1_of(text: str) -> str:
"""单元格哈希:只用于报告与比对,不用于定位。"""
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:12]
4.2 快照:按物理元素走
def _p_text(p_el) -> str:
"""段落可见文字:补回 /,并覆盖修订痕迹与超链接。"""
buf = []
for el in p_el.iterchildren():
if el.tag == qn("w:r"):
for child in el.iterchildren():
if child.tag == qn("w:t"):
buf.append(child.text or "")
elif child.tag == qn("w:tab"):
buf.append("\t")
elif child.tag == qn("w:br"):
buf.append("\n")
elif el.tag in (qn("w:hyperlink"), qn("w:ins"), qn("w:smartTag")):

        # 修订模式写入的文字、超链接文字都在这些容器里
        for t in el.iter(qn("w:t")):
            buf.append(t.text or "")
return "".join(buf)

def _tc_text(tc_el) -> str:
parts = []
for el in tc_el.iterchildren():
if el.tag == qn("w:p"):
parts.append(_p_text(el))
elif el.tag == qn("w:tbl"):
parts.append(_tbl_text(el)) # 单元格里嵌表,递归
return "\n".join(parts)

def _tbl_text(tbl_el) -> str:
rows = []
for tr in tbl_el.findall(qn("w:tr")):
rows.append("\t".join(_tc_text(tc) for tc in tr.findall(qn("w:tc"))))
return "\n".join(rows)

def snapshot(docx_path) -> dict:
"""稳定路径 -> 文本 的全量快照。路径只用于展示,不用于配对。"""
doc = Document(str(docx_path))
paragraphs = {f"p:{i}": _p_text(p._p) for i, p in enumerate(doc.paragraphs)}
tables = {}
for t_idx, table in enumerate(doc.tables):
cells = {}
for r_idx, tr in enumerate(table._tbl.tr_lst): # 物理
for c_idx, tc in enumerate(tr.tc_lst): # 物理 ,天然去重
cells[f"r:{r_idx}/c:{c_idx}"] = _tc_text(tc)
tables[f"t:{t_idx}"] = cells
return {"file": str(docx_path), "paragraphs": paragraphs, "tables": tables}
4.3 行锚点与单元格哈希链
ANCHOR_COLS = (0, 1) # 示例模板:序号 + 审核要点
SEQ_COL = 0 # 示例模板:序号列
_SEQ_RE = re.compile(r"^\d+(.\d+)*$") # 示例模板:序号形如 1 / 1.5 / 2.3.1

def is_data_row(row: dict, seq_col: int = SEQ_COL) -> bool:
"""用内容判定数据行:序号列匹配编号格式。
表头行("序号")与合并标题行(空)会被排除,不依赖行下标。
"""
return bool(_SEQ_RE.match(fingerprint(row.get(seq_col, "")).strip()))

def row_anchor(row: dict, anchor_cols=ANCHOR_COLS) -> str:
"""行锚点:锚点列指纹拼接。不含表下标、不含行下标。"""
return "||".join(fingerprint(row.get(c, "")) for c in anchor_cols)

def build_row_chain(snap: dict, anchor_cols=ANCHOR_COLS, seq_col: int = SEQ_COL):
"""把每个数据行建成一条链:行锚点 -> [(列下标, 哈希, 原文)] + 行内哈希多集。

多集(Counter)用于行内比对,不认列、不认顺序;
links 里的 col 只用于报告定位与回填,不参与配对判定。
"""
rows = {}
for tpath, cells in snap["tables"].items():
    t_idx = int(tpath.split(":")[1])
    grid = defaultdict(dict)
    for cpath, text in cells.items():
        r = int(cpath.split("/")[0].split(":")[1])
        c = int(cpath.split("/")[1].split(":")[1])
        grid[r][c] = text
    for r in sorted(grid):
        row = grid[r]
        if not is_data_row(row, seq_col):
            continue
        anchor = row_anchor(row, anchor_cols)
        rec = rows.setdefault(anchor, {"links": [], "multi": Counter(),
                                       "where": f"t{t_idx}/r{r}"})
        for c in sorted(row):
            if c in anchor_cols:
                continue  # 锚点列自己不进链
            fp = fingerprint(row[c])
            if not fp:
                continue  # 空单元格不进链
            h = sha1_of(fp)
            rec["links"].append({"col": c, "hash": h, "text": row[c]})
            rec["multi"][h] += 1
return rows

这里有两个设计细节值得单独说:
一、链里存多集而不只是列表。 一行里出现两次相同回复(比如两个部位都是"已出变更单")时,多集能表达"有 2 份",列表配 Counter 才能做配额扣减,避免同行内的索引碰撞。
二、col 不参与配对。 它只用于报告里告诉人"第 4 列"和回填时定位格子。这样插列就不会误报——第三节实测会验证这一点。
4.4 逐环校验
def verify_chain(before_chain: dict, after_chain: dict):
"""逐环校验:先比行内哈希多集,再用多集配额定位到具体格子。"""
findings = []
for anchor, b in before_chain.items():
a = after_chain.get(anchor)
if a is None:

        # 整行都没了:链上每一环都记为 ROW_MISSING
        for lk in b["links"]:
            findings.append({"anchor": anchor, "status": "ROW_MISSING", **lk})
        continue
    missing = b["multi"] - a["multi"]   # Counter 减法:保留正数部分
    if not missing:
        continue                        # 这一行完整
    for lk in b["links"]:
        if missing.get(lk["hash"], 0) > 0:
            missing[lk["hash"]] -= 1    # 配额扣减,避免同行重复文本重复报
            findings.append({"anchor": anchor, "status": "CELL_LOST", **lk})
return findings

missing[lk["hash"]] -= 1 这一行是防索引碰撞的关键:同一份哈希在一行里出现两次、只丢了一份时,配额扣减保证只报一条,不会把另一条也误伤。
4.5 三态门禁与退出码
def md_cell_fingerprints(md_path) -> Counter:
"""md 源的单元格指纹多集:用来区分"机器写的"与"人写的"。"""
text = Path(md_path).read_text(encoding="utf-8")
cnt = Counter()
for line in text.splitlines():
s = line.strip()
if s.startswith("|") and s.endswith("|") and s.count("|") >= 2:
for cell in s.strip("|").split("|"):
fp = fingerprint(cell)
if fp:
cnt[fp] += 1
else:
fp = fingerprint(s)
if fp:
cnt[fp] += 1
return cnt

def gate(findings, md_cnt):
"""报告驱动的三态门禁:先出完整 report,再由 report 定状态。"""
report = {"state": "PASS", "block": [], "review": [],
"stats": {"findings": len(findings)}}
for f in findings:
fp = fingerprint(f["text"])
if fp in md_cnt:
f["verdict"] = "指纹来自 md 源,判定为 md 改动引入的预期变更"
report["review"].append(f)
else:
f["verdict"] = "md 中不存在的手填痕迹,判定为真丢失"
report["block"].append(f)
if report["block"]:
report["state"] = "BLOCK"
elif report["review"]:
report["state"] = "REVIEW"
return report

EXIT_OF = {"PASS": 0, "REVIEW": 3, "BLOCK": 2}

def main(before_json, after_docx, md_path, report_path="guard_report.json"):
before_snap = json.loads(Path(before_json).read_text(encoding="utf-8"))
after_snap = snapshot(after_docx)

findings = verify_chain(build_row_chain(before_snap), build_row_chain(after_snap))
report = gate(findings, md_cell_fingerprints(md_path))
report["before_file"] = str(before_json)
report["after_file"] = str(after_docx)
Path(report_path).write_text(
    json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")

print(f"[{report['state']}] findings={len(findings)} "
      f"block={len(report['block'])} review={len(report['review'])}")
for f in report["block"][:10]:
    print(f"  BLOCK  行 {f['anchor']} 第 {f['col']} 列:{f['text'][:40]}")
return EXIT_OF[report["state"]]

if name == "main":
import sys
sys.exit(main("snapshotbefore.json", "审核意见重生成.docx", "审核意见.md"))
纪律:快照必须在重生成之前落盘,且和 docx 一起进版本库。事后补的快照等于没装闸门。
4.6 实测数据
示例文档(两张六列意见表、四条意见、复核意见与处理结论由人工填写)上的六组结果:
原样重生成一遍 -> PASS rc=0 findings=0
同一回复只丢一份(索引碰撞) -> BLOCK rc=2 findings=1
BLOCK 行 "1||面光桥" 第 4 列:已按意见调整
中间插入一列(新增"整改期限") -> PASS rc=0 findings=0
表内插入一行(新增一条意见) -> PASS rc=0 findings=0
整行被删除 -> BLOCK rc=2 findings=4
BLOCK 行 "1||面光桥" 第 2 列:面光桥护栏高度不足
BLOCK 行 "1||面光桥" 第 4 列:已按意见调整
BLOCK 行 "1||面光桥" 第 5 列:闭环
REVIEW 行 "1||面光桥" 第 3 列:JGJ 57 相关条文
最后一组很能说明分诊的作用:整行没了会带来 4 条 finding,其中"规范依据"那条的指纹在示例 md 里能找到,被判为预期变更(REVIEW);其余三条 md 里没有,判为手填痕迹真丢失(BLOCK)。(示例 md 只覆盖机械表,所以灯光表整行删除时除"规范依据"外都落在 BLOCK——真实场景下 md 覆盖完整时,机器写的那几列都会正确落进 REVIEW。)
五、误报治理:索引碰撞与全表兜底的陷阱
守卫能不能活下来,全看误报治理。这一节讲三个最容易把守卫做废的地方。
5.1 索引碰撞:同一段文本在多行出现
"已按意见调整"这种回复,业主复制粘贴是常态。假设机械表第 1 行和灯光表第 1 行都填了这句话,重生成时只吞掉灯光表那一份:

  • 用集合判断"这个文本还在不在" → 在(机械表那份还在)→ 完全漏报;
  • 用多集判断"份数对不对" → 少了一份 → 正确报警。
    行锚点方案在这里有天然优势:机械表和灯光表的行锚点不同("1||栅顶检修马道" vs "1||面光桥"),两条链完全独立,互不掩盖。实测 BLOCK rc=2 findings=1,精确定位到"1||面光桥"第 4 列,没有被同名文本掩盖。
    行内还有一层保护:即便同一行里出现两次相同回复、只丢一份,verify_chain 里的配额扣减也保证只报一条。
    5.2 全表兜底的陷阱
    发现"丢了"之后,一个非常诱人的补丁是:去全文档范围再搜一遍这个文本,搜到了就认定"没丢,只是换位置了"。
    这条捷径会把索引碰撞原封不动地请回来——机械表那份还在,全文档搜索必然命中,灯光表的丢失就被彻底掩盖。不要用全表兜底去降级已经配对成功的行。 全表搜索只能在"行锚点本身对不上"时作为辅助定位手段(比如告诉你这段文本跑到哪一行去了),不能用来推翻一次成功的配对结论。
    5.3 行锚点自己也有三种失效
    行锚点不是万能的,它有三种失效方式,每一种都要显式处理,不能静默吞掉:
  • 锚点列被改写:专家把"审核要点"改了一个字,行锚点就变了,整行会被判 ROW_MISSING。处理办法:把 ROW_MISSING 单独分诊——如果这一行的原文在 md 里能找到,说明是内容改动导致的锚点漂移,判 REVIEW;找不到才判 BLOCK。
  • 锚点重复:两条意见的"序号 + 审核要点"完全一样(比如分部位拆成两行但要点文字相同),两个锚点会撞车。处理办法:建链时统计锚点重复次数,重复的行打 ambiguous 标记进 REVIEW,绝不静默合并。
  • 数据行识别失败:is_data_row 依赖序号列的格式。示例模板的序号是 1 / 1.5 / 2.3.1 这类点分数字;如果模板用 ①②③ 或"第 3 条",正则要跟着改。识别失败会让表头行混进来,产生一堆噪音 finding——上线前必须先用真实模板验一遍识别率。
    5.4 其他必踩的坑
  1. row.cells 重复计数:合并单元格被网格展开。直接走 tr.tc_lst(见 4.2)。
  2. Paragraph.text 丢信息:、、修订模式的 全部读不到,自己写提取函数。
  3. 全量 NFKC:把 ① 变 1、㎡ 变 m2,制造跨条目指纹碰撞。只收敛全角 ASCII。
  4. 嵌套表格串味:tc.iter(qn("w:p")) 会把内嵌表格的段落也算进外层单元格,必须递归。
  5. 自动编号读不到:Word 自动编号由 w:numPr 渲染,编号文字不在 里。若序号依赖自动编号,指纹里就没有序号——这与 5.3 的第三条是同一个隐患的两面。
  6. 页眉页脚 / 文本框没进快照:doc.paragraphs 和 doc.tables 只覆盖正文流,手填痕迹若可能出现在别处要额外遍历。审图意见场景一般不涉及,但机制上要留口子。
    5.5 丢失之后:按行锚点回填
    拦下来之后要把字找回来。回填用行锚点定位行(不看行号),用链里的 col 定位格子:
    def set_tc_text(tc_el, text: str, keep_first_rpr: bool = True) -> None:
    """把文本写进 ,只写第一段并保留首个 run 的字体格式。
    多行文本需按 \n 拆成多个 run 并插入 ,这里演示单行。
    """
    paras = tc_el.findall(qn("w:p"))
    if not paras:
     return
    
    for extra in paras[1:]:
     tc_el.remove(extra)
    
    p = paras[0]
    runs = p.findall(qn("w:r"))
    if runs and keep_first_rpr:
     first = runs[0]
     for r in runs[1:]:
         p.remove(r)
     for t in first.findall(qn("w:t")):
         first.remove(t)
    
    else:
     for r in runs:
         p.remove(r)
     first = p.makeelement(qn("w:r"), {})
     p.append(first)
    
    t = p.makeelement(qn("w:t"), {})
    t.text = text
    t.set("{ http://www.w3.org/XML/1998/namespace}space", "preserve")
    first.append(t)

def restore_by_anchor(after_docx, findings, after_chain) -> int:
"""按行锚点定位行、按链上的 col 定位格,把原文写回。返回回填条数。"""
doc = Document(str(after_docx))
restored = 0
for f in findings:
if f["status"] == "ROW_MISSING":
continue # 整行都没了,无处回填,必须人工处理
a = after_chain.get(f["anchor"])
if a is None:
continue
t_idx = int(a["where"].split("/")[0][1:])
r_idx = int(a["where"].split("/")[1][1:])
try:
tc = doc.tables[t_idx]._tbl.tr_lst[r_idx].tc_lst[f["col"]]
except IndexError:
continue # 列数变了,交给人工
set_tc_text(tc, f["text"])
restored += 1
doc.save(str(after_docx))
return restored
实测:4.6 里那条被吞掉的"已按意见调整",回填返回 restored = 1,回填后再跑一遍守卫,状态从 BLOCK 变回 PASS。
纪律:回填是抢救,不是赦免。回填完之后仍然要走人工确认——列结构变化导致的 IndexError 会被跳过,这些漏网之鱼只有人能发现。
六、上线检查表
按这 10 条自查,前 6 条是示例文档上的实测结果:

  1. 原样重生成一遍 → 期望 PASS / 0;实测 rc=0,findings=0;
  2. 同一回复只丢一份、另一份保留(索引碰撞)→ 期望 BLOCK / 2 且定位到行和列;实测 rc=2,findings=1,锚点 1||面光桥 第 4 列;
  3. 中间插入一列 → 期望不误报;实测 rc=0(行内多集不认列);
  4. 表内插入一行 → 期望不误报;实测 rc=0(行锚点不认行号);
  5. 整行被删除 → 期望 BLOCK,且机器写的那几列落 REVIEW;实测 rc=2,findings=4(3 条 BLOCK + 1 条 REVIEW);
  6. 回填后复跑 → 期望不再阻断;实测 restored=1,状态回到 PASS;
  7. 合并单元格计数核对:row.cells 计数应大于物理 计数(实测 24 vs 19);
  8. 数据行识别率:用真实模板跑一遍,确认表头行与合并标题行没被误纳进链;
  9. 开着修订模式填的批注,快照必须读到( 覆盖生效);
  10. 覆盖前 .bak 备份存在,.snapshot.json 与 docx 同目录同版本,守卫挂在"打包 / 发送"动作上而不是"生成"动作上。
    最后一条最容易被忽略,也最致命:守卫挂在生成上,人可以在生成之后手工修一下绕过;挂在打包上,任何最终产物都必须先过闸。
    整套东西说到底是四层:快照留证据、指纹做归一、分类定责任、门禁给结论。真正难的从来不是这几段代码,而是承认一件事——脚本写的那部分随时可以再来一遍,人写的那部分只有一份。把这个前提立住了,卡点自然就该长在交付链路上,而不是长在某个人"记得看一眼"的自觉里。
相关实践学习
机器学习概览及常见算法
机器学习(Machine Learning, ML)是人工智能的核心,专门研究计算机怎样模拟或实现人类的学习行为,以获取新的知识或技能,重新组织已有的知识结构使之不断改善自身的性能,它是使计算机具有智能的根本途径,其应用遍及人工智能的各个领域。 本课程将带你入门机器学习,掌握机器学习的概念和常用的算法。
目录
相关文章
|
28天前
|
存储 人工智能 安全
千问办公官网入口网页版登录:qwenwork.cn 注册送2000积分、登录送100积分
阿里云千问办公(QwenWork)是AI智能工作平台,支持一句话生成PPT、表格、网页、视频及数据分析。个人免费版送2000积分+每日登录赠100分,含1GB存储、5个发布页;企业版198元/席/月起。阿里千问办公QwenWork官网:https://t.aliyun.com/U/0VCTGt 阿里AI工作平台,一句话完成数据分析、PPT 生成、视频剪辑、网页搭建等复杂任务
|
2月前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
11181 34
|
缓存 API Android开发
第142篇 RecyclerView 缓存机制:四层缓存各自干什么
RecyclerView 缓存非单池,而是四层协同机制:`mAttachedScrap`(屏内重排免绑定)、`mCacheViews`(屏外按位置复用,免创建免绑定)、`mViewCacheExtension`(业务预创建扩展)、`mRecycledViewPool`(跨列表按 type 共享,免 inflate)。核心在于理解各层设计意图,而非死记字段。
79 0
|
28天前
|
传感器 编解码 物联网
基于ESP32的玫瑰大棚智能系统
本系统基于ESP32-S3设计玫瑰大棚智能监测与控制系统,集成温湿度、光照、土壤湿度、CO₂等多传感器,实现环境数据实时采集;通过WiFi+MQTT上传ThingsCloud平台,支持阈值告警与灌溉、补光、散热、开窗等自动调控,兼具高效性、稳定性和实用性。
|
传感器 缓存 安全
第150篇 Lifecycle 组件:生命周期感知的标准化
Lifecycle本质是“状态机+观察者”,含`LifecycleOwner`(状态持有者)、`Lifecycle`(状态管理与事件分发)和`LifecycleObserver`(响应者)三大核心。事件**同步、主线程分发**,严格遵循状态迁移时序(如`ON_PAUSE`后状态才变`STARTED`),`DESTROYED`不可逆。推荐用`DefaultLifecycleObserver`与`repeatOnLifecycle`,避坑`lifecycleScope`误用与泄漏。
93 0
|
消息中间件 安全 Java
第145篇 Handler 内存泄漏:为什么匿名内部类最危险
Handler内存泄漏本质是“引用链未断”:GC Root → MessageQueue(永不销毁)→ Message → Handler → Activity。关键在Message.target强引用Handler,而内部类Handler又隐式持有Activity。清理核心是移除队列中消息,而非仅置空Handler。
106 0
|
28天前
|
编解码 算法 JavaScript
【Web3D图形学实战】户外单立柱 LED 大屏的参数化阵列解算与 HDRI 真实环境光照渲染
博视锐科技推出DIAIDI商显架构,融合WebGL 3D仿真与物理阵列算法,实时推导户外擎天柱大屏尺寸、分辨率(如11.52m×5.76m/2880×1440)及2:1黄金比例;支持HDRI环境光照(沙漠/城市等),实现影视级光影预览;并一键导出符合国标的CAD施工图,打通“视觉提案—工程落地”全链路。(239字)
|
28天前
|
消息中间件 监控 小程序
基于云服务的景区小程序高并发实践:从预约到限流的架构设计
本文以青海景区小程序为例,针对“平时没流量、旺季挤爆”的极端波峰波谷特征,提出基于Serverless、消息队列、云数据库和对象存储的弹性架构方案,覆盖预约、支付、核销、限流四大高并发环节,并兼顾高原弱网优化与成本控制。(239字)
121 1
|
28天前
|
域名解析 存储 人工智能
阿里云企业邮箱网页版入口登录:附企业邮箱费用价格及开通教程
阿里云企业邮箱官网入口:qiye.aliyun.com。注册需先选购版本(标准版600元/年起),再设置管理员密码、分配员工账号、完成域名MX/CNAME解析,全程支持自助开通与钉钉集成。阿里企业邮箱官网:https://t.aliyun.com/U/gNeTEB
350 1
|
28天前
|
JavaScript 网络协议 Shell
Claude Code 国内一键安装脚本:不装 Node.js、不手配环境变量,一条命令跑起来(2026 年 9 月)
本文介绍专为国内用户优化的Claude Code一键安装脚本:支持macOS/Linux/WSL(curl)和Windows(PowerShell),自动安装Node.js(便携版,免权限)、Claude Code客户端、浏览器配对码授权(免密钥复制)、配置环境变量,并真请求验证连通性。仅适用于接入国内API服务商,直连Anthropic官方API请用原生脚本。

热门文章

最新文章