Files
hermes-skills/skills/legal/contract-editor/references/auto-number-to-manual-fix.md
T

5.2 KiB

自动编号 → 手动固定编号修复(删段重排根治)

适用场景

他人(如屠佳青)用修订模式整段删除了一个自动编号列表项(段落 pPr 含 <w:numPr>,且段落标记 del=True),导致 OnlyOffice markup 修订视图把后续列表项渲染成"旧号新号"双编号:

(1)报名服务          ← 正常
(2)笔试服务[删除线]   ← 被删,仍占编号位
(3)(2)面试服务      ← 双号!自动引擎按"接受后会变(2)"提前显示
(4)(3)项目管理      ← 双号!

Maggie/Doro 平时看 markup 视图,要求"修订视图下编号稳定显示 (1)(2)(3)(4)"。 根治办法:把这一组列表项从自动编号转成手动文本编号——文本是字面量,渲染器原样输出,不再经过自动编号引擎重排。

关键认知(动手前必须确认)

  1. 被删项的删除是他人修订 → 绝不动其正文(只在段首加编号 run,不碰 del 内容)。
  2. 全文先确认没有对这些子项编号的交叉引用(如"按上述第3项""见(4)")。本例"具体服务内容详见 附件一:服务报价单"是文字列举,非编号引用 → 安全。若存在引用,转手动编号后引用文字需同步核对。
  3. docker exec <nc容器> find ... 从 Nextcloud 拉当前交付版作修复源,md5sum 比对确认本地副本没过时。

可复用代码(2026-06-16 验证通过)

import zipfile, shutil, os
from lxml import etree
NS='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def q(t): return f'{{{NS}}}{t}'
def ln(el): return etree.QName(el).localname

src='交付版.docx'; out='FIXED.docx'
work='work.docx'; shutil.copy(src, work)
root=etree.fromstring(zipfile.ZipFile(work).read('word/document.xml'))

# 1. 按正文开头定位目标段(按你的合同改这些前缀)
segs={}
for p in root.iter(q('p')):
    t=''.join((x.text or '') for x in p.iter() if ln(x) in ('t','delText'))
    if   t.startswith('报名服务:'):       segs['报名']=p
    elif t.startswith('笔试服务:提供'):   segs['笔试']=p   # 被删的那项
    elif t.startswith('面试服务:'):       segs['面试']=p
    elif t.startswith('项目管理:整个项目'): segs['项目管理']=p

def make_rpr():  # 字体/字号照搬本段原文(本例宋体sz=24)。务必与目标段一致
    rpr=etree.SubElement(etree.Element(q('tmp')), q('rPr'))
    rf=etree.SubElement(rpr, q('rFonts'))
    for a in ('ascii','hAnsi','cs'): rf.set(q(a),'宋体;SimSun')
    etree.SubElement(rpr, q('sz')).set(q('val'),'24')
    etree.SubElement(rpr, q('szCs')).set(q('val'),'24')
    return rpr

def make_run(text):
    r=etree.Element(q('r')); r.append(make_rpr())
    t=etree.SubElement(r, q('t')); t.text=text
    t.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
    return r

def remove_numpr(p):
    ppr=p.find(q('pPr'))
    if ppr is not None:
        np=ppr.find(q('numPr'))
        if np is not None: ppr.remove(np)

def insert_first(p, node):  # 插到 pPr 之后、第一个内容元素之前
    idx=len(p)
    for i,c in enumerate(p):
        if ln(c) in ('r','ins','del','hyperlink'): idx=i; break
    p.insert(idx, node)

# 2. 普通项:明文编号 run
for key,label in [('报名','(1)'),('面试','(3)'),('项目管理','(4)')]:
    remove_numpr(segs[key]); insert_first(segs[key], make_run(label))

# 3. 被删项:编号 run 必须包进【他人的】<w:del>(复制其 author/date)
p=segs['笔试']; remove_numpr(p)
ex=p.find(q('del'))                       # 已有的他人 del(屠佳青)
nd=etree.Element(q('del'))
nd.set(q('author'), ex.get(q('author')))  # 照抄他人 author
nd.set(q('date'),   ex.get(q('date')))
nd.set(q('id'),'99001')                   # 不冲突的大 id
r=etree.SubElement(nd, q('r')); r.append(make_rpr())
dt=etree.SubElement(r, q('delText')); dt.text='(2)'
dt.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
insert_first(p, nd)

# 4. 写回(只换 document.xml,其余条目原样复制)
new_doc=etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(work) as zin, zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as zout:
    for it in zin.namelist():
        zout.writestr(it, new_doc if it=='word/document.xml' else zin.read(it))

交付前必验(vision 不可用时降级四查,缺一不可)

  1. 逐段 markup diff vs 交付源 → 只有目标 N 段不同,其余零改动(本例 346 段只动 4 段)。
  2. pdftotext 渲染层数编号链onlyoffice-render.sh out.docx && pdftotext -f1 -l1 out.pdf - 确认 1.2 下严格 (1)(2)(3)(4) 无双号。
  3. 编号 run rPr == 本段正文 run rPr(字体宋体、sz=24 一致)。
  4. 接受修订后视图编号链连续 + python-docx Document(out) 可打开(XML 合法)。验证被删项的编号确实在 <del author=他人> 里、他人原 del 内容(id 不变)一字未动。

上传

docker cp 覆盖 任务交付/ 同名文件 → chown www-dataocc files:scan --path → 清 OnlyOffice 缓存(rm -rf .../App_Data/cache/files/*)让 Maggie 打开看到新版。md5sum 比对容器内==本地确认上传成功。