8.2 KiB
docx 版式核验(不依赖 vision 工具)
审核他人文书时常需确认「自动编号实际渲染成什么」「字号/字体是否统一」。当 vision 工具不可用时,直接解析 OOXML 比肉眼看渲染图更精确、可复现。
一、确认自动编号实际渲染格式(核心技巧)
w:numPr 只记录 numId,真正决定显示成「一、二、」还是「1. 2.」的是 numbering.xml 里的 numFmt。必须三级映射:段落 numId → num.xml 的 abstractNumId → abstractNum 的 numFmt/lvlText。
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 定义是干扰项。
二、字号/字体/对齐一致性巡检
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 工具不可用时,用「逐页首末行文本提取」程序化检测:
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/>,把标题压到下一页与正文同页。不动任何文字,纯版式调整:
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),插入后编号自动续号,无需手写「(五)」「三、」:
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 巡检确认结构后,仍可生成渲染图供人目检:
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 发给指示人自行目检。