# docx 版式核验（不依赖 vision 工具）

审核他人文书时常需确认「自动编号实际渲染成什么」「字号/字体是否统一」。当 vision 工具不可用时，直接解析 OOXML 比肉眼看渲染图更精确、可复现。

## 一、确认自动编号实际渲染格式（核心技巧）

`w:numPr` 只记录 `numId`，真正决定显示成「一、二、」还是「1. 2.」的是 `numbering.xml` 里的 `numFmt`。必须三级映射：`段落 numId → num.xml 的 abstractNumId → abstractNum 的 numFmt/lvlText`。

```python
import zipfile
from lxml import etree
W='{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
ns={'w':W[1:-1]}
z = zipfile.ZipFile('file.docx')

# 1) numId -> abstractNumId
num = etree.fromstring(z.read('word/numbering.xml'))
nummap = {n.get(W+'numId'): n.find('w:abstractNumId', ns).get(W+'val')
          for n in num.findall('w:num', ns)}

# 2) abstractNumId -> (numFmt, lvlText)  仅取 ilvl=0
abfmt = {}
for an in num.findall('w:abstractNum', ns):
    lvl0 = an.find('w:lvl', ns)
    if lvl0 is not None:
        abfmt[an.get(W+'abstractNumId')] = (
            lvl0.find('w:numFmt', ns).get(W+'val'),
            lvl0.find('w:lvlText', ns).get(W+'val'))

# 3) 遍历正文带编号的段落
doc = etree.fromstring(z.read('word/document.xml'))
for p in doc.findall('.//w:p', ns):
    texts = ''.join(t.text or '' for t in p.findall('.//w:t', ns))
    numPr = p.find('.//w:numPr', ns)
    if numPr is not None and numPr.find('w:numId', ns) is not None:
        nid = numPr.find('w:numId', ns).get(W+'val')
        ab  = nummap.get(nid, '?')
        fmt = abfmt.get(ab, ('?', '?'))
        print(f'numId={nid} numFmt={fmt[0]} lvlText=[{fmt[1]}] | {texts[:28]}')
```

### numFmt 取值对照（常见）
| numFmt | lvlText | 渲染 | 适用 |
|--------|---------|------|------|
| `chineseCountingThousand` | `%1、` | 一、二、三、 | 法律文书一级论点（答辩状/起诉状惯例） |
| `japaneseCounting` | `%1、` | 一、二、三、 | 同上（部分模板用此） |
| `decimal` | `%1.` | 1. 2. 3. | 英文/普通编号 |
| `lowerLetter` | `%2)` | a) b) c) | 子层级 |
| `bullet` | （符号） | • | 无序列表 |

⚠️ 同一个 docx 的 `numbering.xml` 里通常定义了多套 abstractNum（decimal、bullet、中文计数并存），**不能假设第一套就是正文用的那套**——必须从目标段落的 numId 反查。本会话实测：三个加粗论点标题套用的是 `chineseCountingThousand`（渲染为「一、二、三、」），而文件里同时还存在多套 decimal/bullet 定义是干扰项。

## 二、字号/字体/对齐一致性巡检

```python
from docx import Document
from docx.oxml.ns import qn
doc = Document('file.docx')
for i, p in enumerate(doc.paragraphs):
    if not p.text.strip():
        continue
    align = str(p.alignment)
    has_num = (p._p.find(qn('w:pPr')) is not None
               and p._p.find(qn('w:pPr')).find(qn('w:numPr')) is not None)
    sz = bold = font = None
    for r in p.runs:                      # 取首个有字的 run
        if r.text.strip():
            sz   = r.font.size.pt if r.font.size else None
            bold = r.font.bold
            font = r.font.name
            break
    print(f'[{i:02d}] align={align[:6]} num={has_num} sz={sz} bold={bold} font={font} | {p.text[:26]}')
```

- `sz=None` 表示该 run 继承 Normal 样式的字号——不是 bug，但若要确认实际磅值需读 styles.xml 的 Normal 定义。
- **西文字体回退陷阱**：`font.name` 显示 `Calibri` 而中文正常显示，说明文档正文字体是 Calibri（西文字体），中文靠系统回退渲染。正式法律文书定稿前应统一为仿宋/宋体，符合法院惯例。提醒制作人即可，不必擅改（除非指示人要求）。

## 三、标题孤行（orphan heading）检测 + 修复（格式洁癖用户必查，2026-06-22 邹家案实测）

长文书插入新章节后，**二级/三级标题可能被挤到页尾，正文翻到下一页**——标题与其正文分离（orphan heading）。Doro/Maggie 有格式洁癖，这种排版会被退回。vision 工具不可用时，用「逐页首末行文本提取」程序化检测：

```python
import fitz, re
d = fitz.open("rendered.pdf")          # 必须用 OnlyOffice 口径渲染的 PDF（见 memory: x2t）
for i in range(d.page_count):
    lines = []
    for b in d[i].get_text("dict")["blocks"]:
        if b.get("type") != 0:         # 跳过图片块
            continue
        for l in b["lines"]:
            txt = "".join(s["text"] for s in l["spans"]).strip()
            if txt:
                lines.append(txt)
    tail = lines[-1] if lines else ""
    # 末行若是「（X）」或「X、」开头的短标题 → 疑似孤行
    if re.match(r'^[（(]?[一二三四五六七八九十]', tail) and len(tail) < 22:
        print(f"⚠️ 第{i+1}页末行疑似标题孤行: {tail}")
```

**判读**：末行是「（三）混淆举证期限……」这类编号小标题且很短 = 孤行；末行是正文中途自然断句 = 正常。结构性分页（「此致」「落款」前）也正常。

**修复**：给该标题段的 `w:pPr` **最前面**插入 `<w:pageBreakBefore/>`，把标题压到下一页与正文同页。不动任何文字，纯版式调整：

```python
pPr = target_para.find(W+"pPr")
if pPr.find(W+"pageBreakBefore") is None:
    pPr.insert(0, etree.Element(W+"pageBreakBefore"))   # 必须在 pPr 子元素最前
```

修完**重渲一次复跑上面检测，确认孤行清零且未制造新孤行**。⚠️ 用 OnlyOffice(x2t) 渲染核验，不信 LibreOffice 页数（同 docx 常差一页，本案 LO=7 页 / OO=8 页）。

## 四、克隆模板段插入新条款 + 自动编号续号（2026-06-22 实测）

向已有文书插入新条款/请求项时，**不手搓 pPr/rPr，而是 `copy.deepcopy` 一个同类型的既有段落**，只改文字——格式、缩进、字体、加粗全部继承，最稳。配合 Word 自动编号（`numPr/numId`），插入后**编号自动续号**，无需手写「（五）」「三、」：

```python
import copy
def clone(template_para, text):
    np = copy.deepcopy(template_para)
    runs = np.findall(W+"r"); first = runs[0]
    for r in runs[1:]: np.remove(r)             # 只留首 run
    for t in first.findall(W+"t"): first.remove(t)
    t = etree.SubElement(first, W+"t")
    t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve"); t.text = text
    return np
# 克隆「请求项」模板 [08]（挂 numId=10）插到其后 → 自动渲染为「三、」
req = clone(p08, "督促……依法予以释明。"); p08.addnext(req)
# 克隆「违法子条标题」模板 [23]（挂 numId=13）→ 自动渲染为「（五）」
```

**实战要点**：
- **空占位段**：v3 里「标题有、正文空」的段落（如 [22][24]）就是预留正文位——`addprevious()` 把正文段插在它前面，再 `body.remove()` 删掉空段。
- **编号三级映射先摸清**：插入前用「第一节」三级映射确认每套 numId 渲染成什么（请求项 numId=10→「一二三」，子条 numId=13→「（一）（二）」），克隆对应模板才会续对号。
- **手敲硬编号要顺手修**：本案「事实与理由」下小标题用 numId=12 自动编到「二、」，但下一节「本申请符合受理条件」是**手敲的「四、」**（跳号笔误），插入后一并改回「三、」。自动编号段和手敲编号段混排时，手敲的那个最易跳号，交付前核一遍。
- **改完必查未接受修订残留**：编辑前先 `findall(w:ins)/findall(w:del)` 确认为 0（纯新增不留修订痕迹），编辑后再核一遍字号/字体全量无异常（唯一允许的「异常」是大标题 sz=30/小二，那是标题本就该大）。

## 五、PDF 渲染兜底（目检版面）

XML 巡检确认结构后，仍可生成渲染图供人目检：

```bash
libreoffice --headless --convert-to pdf --outdir /tmp "file.docx"
pdftoppm -png -r 110 /tmp/file.pdf /tmp/page    # 生成 page-1.png, page-2.png ...
```

- LibreOffice 首次转换可能报 `failed to launch javaldx` 警告，不影响 PDF 生成。
- vision 工具若返回 `No LLM provider configured for task=vision`，是环境未配置，**不是文档问题**——改用本文上述 XML 解析法核验，并把 PNG 作为 MEDIA 发给指示人自行目检。
