Files
hermes-skills/skills/legal/legal-document-review/references/tracked-changes-clean-markup.md
T

18 KiB

修订态 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 挂到实例上)

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 一次性全改最干净(不要去正文里找,找不到):

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 要"把追回权抽成单独第五条,囊括所有情形")时,绝不能假设"四、""五、"这些中文编号是自动编号。判错会让新条款不显示编号、且原"五、其他说明"不顺延。

判别(动手前必做)

# 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。

  • 纯引号 / 引号+中文的 runascii=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 回去。
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。

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. 接受态(成稿)→ 程序剥离修订后渲染,核对编号/括号

# 接受所有修订: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 自己把"不得少于十五日"写成证据规定第五十一条第二款,实际在第一款(第二款是补正不受期间限制)。审核职责含纠正委托人的法条款项笔误,逐条核到款。