#!/usr/bin/env python3
"""编号链诊断探针 — 一次性看清 docx 的自动编号/手动编号全貌。

用途：合同编号疑似错乱（重复/跳号/双号）时，动手改之前必跑此脚本。
它把三件事一次性摊开，让你判断「是我们WB改错的 / 他人修订重排的 / 还是源文件自带的潜伏自动编号」：
  1. 每个段落：是否带 <w:numPr>(自动编号)、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 <contract.docx>
.doc 先转换:  soffice --headless --convert-to docx <file>.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])
