feat: export core Hermes skills
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# 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 发给指示人自行目检。
|
||||
Reference in New Issue
Block a user