#!/usr/bin/env python3 """编号链诊断探针 — 一次性看清 docx 的自动编号/手动编号全貌。 用途:合同编号疑似错乱(重复/跳号/双号)时,动手改之前必跑此脚本。 它把三件事一次性摊开,让你判断「是我们WB改错的 / 他人修订重排的 / 还是源文件自带的潜伏自动编号」: 1. 每个段落:是否带 (自动编号)、numId、ilvl、是否整段ins/del 2. numbering.xml 解析:numId→abstractNum→(numFmt, lvlText, start) —— ⚠️ start≠1 的 decimal 列表会渲染出「6、」之类的可见编号,但 run 里没有这个字! 这是最隐蔽的坑:源文件起草人给某段挂了 numId(start=6),OnlyOffice 自动显示「6、售后服务」, 而你在末尾新增条款时只数了手打的「1 2 3 4 5」,顺手编成「6」→ 与潜伏的自动6撞号。 3. 每段「接受所有修订后」的可见文本(去w:del、保w:ins),近似 OnlyOffice 接受后视图 用法: python numbering-diagnose.py .doc 先转换: soffice --headless --convert-to docx .doc """ import sys, zipfile from lxml import etree W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}' def text_mode(p, mode): """mode='final': 接受所有修订后(去del,保ins). mode='orig': 修订前(去ins,保del).""" parts = [] for node in p.iter(): if node.tag == W + 't': anc, skip = node, False while anc is not None: if mode == 'final' and anc.tag == W + 'del': skip = True; break if mode == 'orig' and anc.tag == W + 'ins': skip = True; break anc = anc.getparent() if not skip: parts.append(node.text or '') elif node.tag == W + 'delText' and mode == 'orig': parts.append(node.text or '') return ''.join(parts).strip() def parse_numbering(z): """返回 numId -> (numFmt, lvlText, start) 仅 lvl0(够用于条款标题层).""" out = {} if 'word/numbering.xml' not in z.namelist(): return out num = etree.fromstring(z.read('word/numbering.xml')) n2a = {} for n in num.findall(W + 'num'): ab = n.find(W + 'abstractNumId') if ab is not None: n2a[n.get(W + 'numId')] = ab.get(W + 'val') a2fmt = {} for ab in num.findall(W + 'abstractNum'): l0 = ab.find(W + 'lvl') if l0 is not None: fmt = l0.find(W + 'numFmt') txt = l0.find(W + 'lvlText') st = l0.find(W + 'start') a2fmt[ab.get(W + 'abstractNumId')] = ( fmt.get(W + 'val') if fmt is not None else '?', txt.get(W + 'val') if txt is not None else '', st.get(W + 'val') if st is not None else '1', ) for nid, aid in n2a.items(): out[nid] = a2fmt.get(aid, ('?', '', '1')) return out def main(path): z = zipfile.ZipFile(path) root = etree.fromstring(z.read('word/document.xml')) numinfo = parse_numbering(z) print(f"### {path}\n") print("=== numbering.xml: numId -> (numFmt, lvlText, start) ===") if not numinfo: print(" (无 numbering.xml — 全文应为手动文本编号)") for nid, (fmt, txt, st) in sorted(numinfo.items()): warn = ' ⚠️start≠1 会渲染潜伏编号!' if (fmt == 'decimal' and st != '1') else '' print(f" numId={nid}: fmt={fmt}, lvlText='{txt}', start={st}{warn}") print() print("idx | numPr(自动) | rendered | ins/del | 文本(接受修订后)") print("-" * 92) for i, p in enumerate(root.findall('.//' + W + 'p')): tf = text_mode(p, 'final') if not tf: continue npr = p.find('.//' + W + 'numPr') npinfo, rendered = '—', '' if npr is not None: nid_el = npr.find(W + 'numId') il_el = npr.find(W + 'ilvl') nid = nid_el.get(W + 'val') if nid_el is not None else '?' il = il_el.get(W + 'val') if il_el is not None else '0' npinfo = f"numId={nid},lvl={il}" fmt, txt, st = numinfo.get(nid, ('?', '', '1')) if fmt == 'decimal': rendered = (txt or '%1、').replace('%1', st) # 该项首个渲染值(近似) elif fmt == 'bullet': rendered = '•' elif fmt == 'none': rendered = '(无)' has_ins = p.find('.//' + W + 'ins') is not None has_del = p.find('.//' + W + 'del') is not None mk = ('INS' if has_ins else '') + ('/' if has_ins and has_del else '') + ('DEL' if has_del else '') print(f"{i:3d} | {npinfo:18s} | {rendered:8s} | {mk:7s} | {tf[:46]}") print() print("判读要点:") print(" - rendered 列非空 = OnlyOffice 会自动加这个编号(run里没有这串字)") print(" - 手动编号: rendered='—' 且文本以「N、」开头 = 编号是写死的文字") print(" - 若末尾新增条款(INS)的手打编号 与 上方某段 rendered 自动编号 相同 → 撞号") print(" 正确做法: 新增手打编号应接续【rendered 自动值】往下编, 不是接续最后一个手打数字") if __name__ == '__main__': if len(sys.argv) < 2: print(__doc__); sys.exit(1) main(sys.argv[1])