# 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` **最前面**插入 ``,把标题压到下一页与正文同页。不动任何文字,纯版式调整: ```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 发给指示人自行目检。