Files

270 lines
18 KiB
Markdown

# 修订态 markup 清洁度 + 双视图核验
来源:2026-06-23 邹家民事诉讼监督申请书审核(Doro 给 7 点修改指示)。教训:第一遍用 `tracked_replace`(字符级 diff)做整句改写和称谓替换,接受态文本正确,但**修订态被 difflib 咬成半字脏标记**(`莲都法~~庭~~院``未经~~及~~法庭审理``一百二十八条~~切~~国家机关`)。Doro 用 OnlyOffice 看的是修订态且有格式洁癖 → 必须重做。
## 核心规则
| 改动类型 | 用什么 | 为什么 |
|---|---|---|
| 单字错别字、补一个字、删一个字 | `tracked_replace`(字符级,库自带) | 改动点孤立,diff 干净 |
| 整词替换(尤其新旧共享字,如 法**庭**→莲都法**院**) | `tracked_block_replace`(整块 del+ins) | 阻止 difflib 咬共享字产生半字 |
| 整句/整段改写 | `tracked_block_replace` | 同上,markup 是清爽的[删旧句][插新句] |
| 删整段(合并条款、删一个自动编号列表项) | `tracked_delete_paragraph`(段落级标删) | 让自动编号重排(删请求三→请求四自动续成三) |
判据:**只要新旧文本有共享字符且改动跨多字,就别用 tracked_replace,改用 block。**
## 两个补充方法(库 contract_docx_lib.py 没有,用 types.MethodType 挂到实例上)
```python
import sys, copy, types
sys.path.insert(0,'/home/maggie/contract-work')
from contract_docx_lib import ContractEditor, qn, XML_SPACE
from lxml import etree
ed = ContractEditor(SRC); ed._author='小Maggie' # 署文书归属人
# ---- 整块替换:[del 整段旧][ins 整段新],markup 干净 ----
def tracked_block_replace(self, old_text, new_text):
for p in self.body.findall(qn('p')):
runs=p.findall(qn('r'))
if not runs: continue
full=''.join(''.join(t.text or '' for t in r.findall(qn('t'))) for r in runs)
if old_text not in full: continue
start=full.index(old_text); end=start+len(old_text)
rpr=None; pos=0
for r in runs:
rt=''.join(t.text or '' for t in r.findall(qn('t')))
if pos+len(rt)>start:
rpr=r.find(qn('rPr'))
if rpr is not None:
rpr=copy.deepcopy(rpr); self._ensure_rfonts_complete(rpr)
if rpr.find(qn('sz')) is None and self._body_rpr is not None:
bsz=self._body_rpr.find(qn('sz'))
if bsz is not None:
etree.SubElement(rpr,qn('sz')).set(qn('val'),bsz.get(qn('val')))
break
pos+=len(rt)
pos=0; first=last=None; prefix=suffix=''
for idx,r in enumerate(runs):
rt=''.join(t.text or '' for t in r.findall(qn('t')))
re_=pos+len(rt)
if re_>start and pos<end:
if first is None: first=idx; prefix=full[pos:start]
last=idx; suffix=full[end:re_] if re_>end else ''
pos=re_
ref=runs[first]; parent=ref.getparent(); ip=list(parent).index(ref)
for idx in range(last,first-1,-1): parent.remove(runs[idx])
i=ip
if prefix: parent.insert(i,self._mk_run(prefix,rpr)); i+=1
parent.insert(i,self._mk_del(old_text,rpr)); i+=1
parent.insert(i,self._mk_ins(new_text,rpr)); i+=1
if suffix: parent.insert(i,self._mk_run(suffix,rpr))
return True
return False
ed.tracked_block_replace=types.MethodType(tracked_block_replace,ed)
# ---- 段落级修订删除:整段(含段落标记)标删,自动编号重排 ----
def tracked_delete_paragraph(self, search_text):
p=self.find_para(search_text)
if p is None: return False
for r in list(p.findall(qn('r'))):
rpr=r.find(qn('rPr')); texts=r.findall(qn('t'))
d=etree.Element(qn('del')); d.set(qn('id'),self._next_id())
d.set(qn('author'),self._author); d.set(qn('date'),self._revision_date)
nr=etree.SubElement(d,qn('r')); nr.set(qn('rsidDel'),self._rsid)
if rpr is not None: nr.append(copy.deepcopy(rpr))
for t in texts:
dt=etree.SubElement(nr,qn('delText')); dt.set(XML_SPACE,'preserve'); dt.text=t.text
idx=list(p).index(r); p.remove(r); p.insert(idx,d)
ppr=p.find(qn('pPr'))
if ppr is None: ppr=etree.Element(qn('pPr')); p.insert(0,ppr)
rpr=ppr.find(qn('rPr'))
if rpr is None: rpr=etree.SubElement(ppr,qn('rPr'))
dem=etree.Element(qn('del')); dem.set(qn('id'),self._next_id())
dem.set(qn('author'),self._author); dem.set(qn('date'),self._revision_date)
rpr.insert(0,dem) # 段落标记标删 → 接受后整段消失,编号重排
return True
ed.tracked_delete_paragraph=types.MethodType(tracked_delete_paragraph,ed)
```
执行顺序:小改动(tracked_replace) → 称谓/整句(block) → 大改写(block) → 删段(delete_paragraph) → save。
## 半角括号 → 全角(自动编号子标题 (一)→(一))
子标题 `(一)(二)` 是自动编号,半角括号根在 numbering.xml 的 lvlText `(%1)`,不在正文。改 numbering.xml 一次性全改最干净(不要去正文里找,找不到):
```python
import zipfile, io
from lxml import etree
W='{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
with zipfile.ZipFile(SRC) as z:
num=etree.fromstring(z.read('word/numbering.xml'))
for lt in num.iter(W+'lvlText'):
v=lt.get(W+'val')
if v and ('(' in v or ')' in v):
lt.set(W+'val', v.replace('(','').replace(')',''))
new=etree.tostring(num,xml_declaration=True,encoding='UTF-8',standalone=True)
buf=io.BytesIO()
with zipfile.ZipFile(SRC) as zin, zipfile.ZipFile(buf,'w',zipfile.ZIP_DEFLATED) as zout:
for it in zin.infolist():
zout.writestr(it, new if it.filename=='word/numbering.xml' else zin.read(it.filename))
open(OUT,'wb').write(buf.getvalue())
```
注意:`、`(如 一、二、 和 %1、)不受影响,只动含半角圆括号的 lvlText。
## 🔴 插入新编号条款前:先判"中文编号是自动编号 还是 手动文字"——`numId=0` = 无编号(2026-06-24 平和外教通知实测,第一版栽了)
要在文件中**插入一个新的带编号大条款**(如 Doro 要"把追回权抽成单独第五条,囊括所有情形")时,**绝不能假设"四、""五、"这些中文编号是自动编号**。判错会让新条款不显示编号、且原"五、其他说明"不顺延。
**判别(动手前必做)**
```python
# 1. 看标题段的 numPr
ppr=p.find(W+'pPr'); numpr=ppr.find(W+'numPr') if ppr is not None else None
nid=numpr.find(W+'numId').get(W+'val') if numpr is not None else None
# 2. 看 numbering.xml 里这个 numId 到底定义了没
# list 出所有 <w:num w:numId=...> 。若标题段挂的 numId 不在其中 → 不是真自动编号
```
**关键陷阱**`<w:numId w:val="0"/>` 是 OOXML **特殊值=取消编号/无编号**,numbering.xml 里**根本不会有 numId=0 的定义**。本会话"四、违规处理细则""五、其他说明"段都挂 `numId=0`,我误判成"自动编号、插同级会自动续号",构造了 numId=0 的新段落——结果 OO 渲染里新条款**完全不显示编号**、"五、其他说明"也没变"六、"。一查 numbering.xml:只有 numId=1/2/3,**没有 0**。再查标题段 run 文本:直接是 `"四、违规处理细则"``"五、其他说明"`——**"四""五"是手打进正文的文字字符,不是自动编号生成的**。
**正确判据**
- run 文本里**肉眼能看到**"四、""五、"这些字 + 段落 numId=0/无 numPr/numId 不在 numbering.xml → **手动文字编号**
- run 文本里**看不到**编号字(只有"违规处理细则"无"四、")+ 段落挂了真实存在的 numId → **自动编号**(numFmt=chineseCounting,lvlText='%1、')。
**手动文字编号的插入做法**(本会话最终正确版):
1. 新条款文本**自己写上编号字**:"五、无论发生上述何种违规情形,……追回……全部免税住房补贴。"(复制相邻标题段的 pPr+rPr 做格式模板,但不靠 numPr 出号)。
2. 新段落作为整段插入修订:pPr/rPr 里塞 `<w:ins>`(段落标记标插),正文 run 用 `_mk_ins` 包裹,author=自己。
3. **手动把后续编号往后顺延**:原"五、其他说明"用 tracked_block_replace 把"五、其他说明"→"六、其他说明"(手动改字,留痕)。
4. 渲染**接受态**用 vision 核对:"四、…→ 五、(新条款) → 六、其他说明" 连续无重号——本会话改对后 OO 实测通过。
**自动编号的插入做法**(对照,仅当确属自动编号时):插一个挂同 numId 的同级段落,编号会自动续;原后续条款自动顺延,**无需手动改编号字**(见本文件"结构性改动技巧"段的 numPr 自动重排)。
⚠️ 顺带的衔接坑:抽条款时若要删的原文句与他人(bittersweet) ins 用逗号"咬合"("…索赔主张【bittersweet逗号】同时…追回全部【原文】"),**只标删原文那半句、不碰他人 ins 的逗号**(铁律:他人修订不动);接受态以逗号收尾虽不完美,但独立新条款紧随其后能化解观感,宁可这样也不改他人标点。
## 仿宋全角引号修复(OnlyOffice 把弯引号渲染成 Times)
**症状**:正文是仿宋,但中文弯引号 “ ”(U+201C/U+201D)显示成又粗又重的西文 Times New Roman 引号,和仿宋正文不协调。Doro 2026-06-23 要求"引号统一为仿宋的全角"。
**根因**:弯引号是"中西文模糊字符"。这些引号所在 run 的 rFonts 往往 `ascii=Times New Roman, eastAsia=None`(eastAsia 继承样式里的仿宋)。OnlyOffice 对 **eastAsia 未显式设定**的模糊字符按 **ascii 字体**渲染 → 走了 Times。诊断:遍历所有含 U+201C/U+201D 的 run,打印 `rFonts/@ascii``@eastAsia`,会看到 ascii=Times、eastAsia=None。
**修法**:给所有含引号的 run 显式设 rFonts。
- **纯引号 / 引号+中文的 run**:`ascii=eastAsia=hAnsi=cs=仿宋, hint=eastAsia`(ascii 也设仿宋,彻底消除模糊字符歧义——只设 eastAsia 在某些 OO 版本仍可能走 ascii)。
- **引号+拉丁数字混排的 run**(如 `“2026浙1102…`):**拆 run**——引号片段走仿宋,数字片段保留 `ascii=Times New Roman`。否则给整 run 设仿宋会把"2026"也变仿宋。拆法:按字符是否∈`{U+201C,U+201D}`分组,逐组生成新 run(引号组设仿宋、非引号组设 Times),原 run 删除、按序 insert 回去。
```python
W='{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
def qn(t): return W+t
FANG='仿宋'
def has_latin(s): return any(c.isascii() and c.isalnum() for c in s)
for p in list(root.iter(qn('p'))):
for r in list(p.findall(qn('r'))):
txt=''.join(t.text or '' for t in r.findall(qn('t')))
if '\u201c' not in txt and '\u201d' not in txt: continue
rpr=r.find(qn('rPr')); rf=rpr.find(qn('rFonts')) if rpr is not None else None
if not has_latin(txt): # 纯引号/中文:整 run 设仿宋
if rf is not None:
for a in ('ascii','eastAsia','hAnsi','cs'): rf.set(qn(a),FANG)
rf.set(qn('hint'),'eastAsia')
else: # 含数字:按引号/非引号拆 run
import copy
segs=[]; cur=''; cur_q=None
for c in txt:
isq=c in '\u201c\u201d'
if cur_q is None: cur_q=isq; cur=c
elif isq==cur_q: cur+=c
else: segs.append((cur,cur_q)); cur=c; cur_q=isq
if cur: segs.append((cur,cur_q))
parent=r.getparent(); idx=list(parent).index(r); parent.remove(r)
for seg,isq in segs:
nr=etree.Element(qn('r'))
if rpr is not None:
nrpr=copy.deepcopy(rpr); nr.append(nrpr)
nrf=nrpr.find(qn('rFonts'))
if nrf is None: nrf=etree.SubElement(nrpr,qn('rFonts')); nrpr.insert(0,nrf)
if isq:
for a in ('ascii','eastAsia','hAnsi','cs'): nrf.set(qn(a),FANG)
nrf.set(qn('hint'),'eastAsia')
else:
nrf.set(qn('ascii'),'Times New Roman'); nrf.set(qn('hAnsi'),'Times New Roman')
nt=etree.SubElement(nr,qn('t')); nt.set('{http://www.w3.org/XML/1998/namespace}space','preserve'); nt.text=seg
parent.insert(idx,nr); idx+=1
```
**验证**:遍历所有含引号字符的 run,断言 `rFonts/@eastAsia=='仿宋'`,计数应等于全文引号字符数。**OO 渲染确认**:x2t 转 PDF 裁剪含引号区域,目视引号是否纤细、与仿宋协调(不再是重弯钩 Times)——`hint=eastAsia` 不一定够,必须 OO 实渲染验。
**字体诊断通法**:正文中文是不是仿宋?查 styles.xml 的正文样式 rFonts(本会话正文 3333 字 eastAsia=None 全继承样式里的"仿宋")。引号异常往往是局部 run 覆盖了样式字体。
## 全局字体规范化(中文仿宋 / 英数 Times New Roman)
来源:2026-06-23 邹家 v8→v9,Doro 要求"引号统一仿宋 + 中文仿宋 + 英文/数字 Times New Roman"。这是 Doro/Maggie 的**通用排版规范**,不止引号——整篇都要中文走仿宋、拉丁字母与阿拉伯数字走 Times。引号修法见上一节,本节是**全篇 run 的一次性归一**(上一节是引号专项,本节覆盖全文)。
**做法**:遍历每个 run,按字符类别分组,混排 run 拆开,逐组设 rFonts。分类口径:
- CJK 汉字 + 中文标点(。,、;:)→ `eastAsia=ascii=hAnsi=cs=仿宋, hint=eastAsia`
- 拉丁字母 + 阿拉伯数字 → `ascii=hAnsi=Times New Roman`(eastAsia 保留仿宋兜底)
- 弯引号/中文全角符号 → 仿宋(同 CJK,见上一节拆法)
混排 run(如 `浙1102民初2078号``HUAXIN XU 徐华鑫`)必须**拆 run**——给整 run 设一种字体会污染另一类字符。拆法同引号节:按字符类别切片,逐片 deepcopy rPr 后改 rFonts,原 run 删除按序 insert 回去。
**验证(Doro 口径,必做)**:改完统计全文 run 字体属性分布,按四类断言计数:
```
最终文件字体属性:
NNNN CJK 仿宋
NNN LATIN Times New Roman
NNN PUNCT 仿宋
NN QUOTE 仿宋
规范达标: ✅ 中文全仿宋 + 英数全 Times
```
读 word/document.xml 所有 run,逐字符按类别归到该 run 的 rFonts 目标字体;发现"中文 run 的 eastAsia≠仿宋"或"纯拉丁 run 的 ascii≠Times"即为漏网,回去补。
### ⚠ OnlyOffice 容器没有真仿宋字体——验字体看属性,不看字形
用 OO 容器 x2t 渲染来**目视**核验时要警惕:**生产 OO 容器通常没装真·仿宋**,会把"仿宋"字体名回退映射到 Noto Serif(字形偏宋体)。所以渲染图里中文看着像宋体 **≠ 文件错了**——文件 rFonts 的字体名属性是"仿宋"才是关键,Doro 本机有真仿宋会正确显示。
- 验"中文是不是仿宋":**查 docx 的 rFonts/@eastAsia 属性值**(程序断言),**不靠 OO 渲染图字形**判断。
- OO 渲染图只用来验**布局**(无缺字方框/无字体回退 tofu/无文字重叠/英数确为 Times 衬线)——vision_analyze 看。
- **别污染生产容器**:若为渲染临时往 OO 容器塞了字体映射,验完必须移除恢复,不留临时字体污染生产环境。
## 双视图核验
### A. 修订态(Doro/莎莎用 OnlyOffice 看痕迹)→ 用 OO 容器 x2t,不用 LibreOffice
LibreOffice 与 OnlyOffice 不同源,必须用 OO 口径验 markup。
```bash
docker cp v_final.docx nextcloud-onlyoffice-1:/tmp/z.docx
docker exec nextcloud-onlyoffice-1 bash -lc '
cat >/tmp/zx.xml <<XML
<?xml version="1.0" encoding="utf-8"?>
<TaskQueueDataConvert xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<m_sFileFrom>/tmp/z.docx</m_sFileFrom><m_sFileTo>/tmp/z.pdf</m_sFileTo>
<m_nFormatTo>513</m_nFormatTo><m_bIsNoBase64>false</m_bIsNoBase64>
</TaskQueueDataConvert>
XML
cd /var/www/onlyoffice/documentserver/server/FileConverter/bin && ./x2t /tmp/zx.xml'
docker cp nextcloud-onlyoffice-1:/tmp/z.pdf /tmp/markup.pdf
pdftoppm -png -r 150 /tmp/markup.pdf /tmp/mk # 转图 vision_analyze 查脏标记
```
查点:每个 del/ins 是整段配对、无半字、无文字重叠。
### B. 接受态(成稿)→ 程序剥离修订后渲染,核对编号/括号
```python
# 接受所有修订:unwrap ins(留子元素)、删 del、删被标删的空列表段
for ins in list(doc.iter(W+'ins')):
par=ins.getparent(); idx=list(par).index(ins)
for ch in list(ins): par.insert(idx,ch); idx+=1
par.remove(ins)
for d in list(doc.iter(W+'del')):
par=d.getparent()
if par is not None: par.remove(d)
for p in list(body.findall(W+'p')): # 删 tracked_delete_paragraph 留下的空列表项
txt=''.join((t.text or '') for t in p.iter(W+'t'))
ppr=p.find(W+'pPr'); has_num=ppr is not None and ppr.find(W+'numPr') is not None
if has_num and txt.strip()=='': body.remove(p)
```
核对:删条款后自动编号一二三连续无断号、全角括号生效、U+FFFD=0。
## 自查清单(交付前)
- [ ] 整词/整句改写用了 block,不是 tracked_replace(修订态无半字)
- [ ] 删段用 delete_paragraph,接受后编号重排无断号
- [ ] 全角括号改在 numbering.xml
- [ ] validate() 过(请求项/标题加粗误报已豁免核对)
- [ ] ins author = 文书归属人、字体无缺失
- [ ] OO 渲染修订态 markup 干净(vision 查)
- [ ] 程序剥离后接受态编号/括号正确
- [ ] 引号统一仿宋全角(含引号 run eastAsia=仿宋,OO 渲染确认不再是 Times;含数字的拆 run)
- [ ] 全局字体规范:全篇中文 run eastAsia=仿宋、拉丁/数字 run ascii=Times(混排已拆 run);用属性断言四类计数核验,**不靠 OO 字形**(容器无真仿宋,会回退宋体;OO 图只验布局)
- [ ] 所有法条核权威原文(宪法 2018 修正、民诉法 2023 修正——条号会变,不凭记忆)。**精确到正确款项**:2026-06-23 Doro 自己把"不得少于十五日"写成证据规定第五十一条**第二款**,实际在**第一款**(第二款是补正不受期间限制)。审核职责含纠正委托人的法条款项笔误,逐条核到款。