feat: export core Hermes skills
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
# add_clause after_search Fails After tracked_replace — Use Direct lxml Insertion
|
||||
|
||||
## Problem (2026-07-01 凤雅幼儿园劳务派遣协议)
|
||||
|
||||
After calling `ed.tracked_replace(old, new)` on multiple paragraphs, subsequent `ed.add_clause(text, after_search="...")` calls silently fail — the new paragraph doesn't appear in the output. The function returns without error but the clause is not inserted.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`add_clause`'s `after_search` parameter searches paragraph text by concatenating all `<w:t>` elements. After `tracked_replace`, the paragraph's XML contains interleaved `<w:del>` and `<w:ins>` elements. The `after_search` text-matching logic may:
|
||||
|
||||
1. Include both old (del) and new (ins) text in the concatenation, so neither the old NOR new text matches cleanly
|
||||
2. Match the wrong paragraph if the search string appears in unexpected combinations of del+ins text
|
||||
|
||||
## Solution: Direct lxml `addnext` Insertion
|
||||
|
||||
After all `tracked_replace` calls, insert new clauses directly using lxml:
|
||||
|
||||
```python
|
||||
# Find reference paragraph by index or by scanning accepted-view text
|
||||
paras = ed.body.findall(f'{WNS}p')
|
||||
ref_para = paras[target_index] # e.g., P68
|
||||
|
||||
# Build INS paragraph
|
||||
new_p = etree.Element(f'{WNS}p')
|
||||
new_p.append(copy.deepcopy(ref_ppr)) # Clone paragraph formatting
|
||||
|
||||
ins = etree.SubElement(new_p, f'{WNS}ins')
|
||||
ins.set(f'{WNS}id', next_rev_id())
|
||||
ins.set(f'{WNS}author', 'WB')
|
||||
ins.set(f'{WNS}date', rev_date)
|
||||
|
||||
r = etree.SubElement(ins, f'{WNS}r')
|
||||
r.set(f'{WNS}rsidR', rsid)
|
||||
r.insert(0, copy.deepcopy(ref_rpr))
|
||||
|
||||
t = etree.SubElement(r, f'{WNS}t')
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = clause_text
|
||||
|
||||
# Mark paragraph itself as inserted (pPr/rPr/ins)
|
||||
ppr = new_p.find(f'{WNS}pPr')
|
||||
ppr_rpr = etree.SubElement(ppr, f'{WNS}rPr')
|
||||
ppr_ins = etree.SubElement(ppr_rpr, f'{WNS}ins')
|
||||
ppr_ins.set(f'{WNS}id', next_rev_id())
|
||||
ppr_ins.set(f'{WNS}author', 'WB')
|
||||
ppr_ins.set(f'{WNS}date', rev_date)
|
||||
|
||||
# Insert after reference
|
||||
ref_para.addnext(new_p)
|
||||
ref_para = new_p # Chain subsequent inserts
|
||||
```
|
||||
|
||||
## When This Applies
|
||||
|
||||
- You need to both modify existing clauses (tracked_replace) AND add new clauses in the same editing session
|
||||
- The `after_search` text has been altered by prior tracked_replace calls
|
||||
|
||||
## Correct Operation Order
|
||||
|
||||
1. All `ed.tracked_replace(...)` calls first
|
||||
2. Then find target paragraphs by scanning the body with accepted-view text extraction
|
||||
3. Insert new paragraphs directly via `addnext`
|
||||
4. `ed.validate()` + `ed.save()`
|
||||
|
||||
## Verification
|
||||
|
||||
After save, scan paragraphs and confirm new clauses appear in accepted-view text at the expected positions.
|
||||
@@ -0,0 +1,55 @@
|
||||
# auto_notify_new_file.sh 架构:入队模式 vs 直接执行模式
|
||||
|
||||
## 根因(2026-06-30)
|
||||
|
||||
`auto_notify_new_file.sh` 原本自己启动 `uwf thread exec -c 5`(前台模式)来执行合同审查 workflow。
|
||||
但 hermes ACP 适配器在前台模式下有 asyncio stdin 注册 bug(`KeyError: '0 is not registered'`),
|
||||
导致每次 spawn `hermes acp` 子进程都立即失败,日志中全是 `agent command failed (uwf-hermes)`。
|
||||
|
||||
**对比**:`contract-queue-runner.sh` 使用 `uwf thread exec --background` 模式,正常工作。
|
||||
|
||||
## 当前架构(2026-06-30 修复后)
|
||||
|
||||
```
|
||||
企微收到文件
|
||||
→ auto_notify_new_file.sh (inotifywait 监控)
|
||||
→ 识别 sender (邱律师=QiuTing)
|
||||
→ 上传 Nextcloud 待审查/
|
||||
→ 入队 /tmp/contract-queue/manifest.txt
|
||||
→ 检查 contract-queue-runner.sh 是否在运行,不在则启动
|
||||
→ contract-queue-runner.sh 串行执行(--background 模式)
|
||||
→ uwf thread start + thread exec --background
|
||||
→ uwf-hermes → hermes acp(正常)
|
||||
```
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 路径 | 职责 |
|
||||
|------|------|------|
|
||||
| auto_notify | `~/.hermes/scripts/auto_notify_new_file.sh` | 监控文件到达、上传、入队 |
|
||||
| queue runner | `~/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh` | 串行执行 workflow |
|
||||
| watchdog | `~/.hermes/scripts/contract-queue-watchdog.sh` | 每20分钟检查卡住的 thread |
|
||||
| auto_notify watchdog | `~/.hermes/scripts/auto_notify_watchdog.sh` | 每5分钟检查 auto_notify 进程 |
|
||||
|
||||
## 禁止回退
|
||||
|
||||
**绝不可把 auto_notify 改回自己启动 `uwf thread exec` 的模式**。前台模式有 ACP stdin bug,
|
||||
只有 `--background` 模式能正常工作。如果未来需要修改 auto_notify 的 workflow 启动逻辑,
|
||||
必须通过 contract-queue-runner 间接执行。
|
||||
|
||||
## 入队逻辑
|
||||
|
||||
```bash
|
||||
# 入队到 contract-queue
|
||||
cp "$filepath" "$QUEUE_DIR/${orig_name}"
|
||||
|
||||
# 追加到 manifest(去重)
|
||||
if ! grep -qFx "$orig_name" "$QUEUE_DIR/manifest.txt" 2>/dev/null; then
|
||||
echo "$orig_name" >> "$QUEUE_DIR/manifest.txt"
|
||||
fi
|
||||
|
||||
# 检查 queue runner 是否在运行,不在则启动
|
||||
if ! ps aux | grep -q "[c]ontract-queue-runner"; then
|
||||
nohup bash "$HOME/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh" >> "$QUEUE_DIR/queue.log" 2>&1 &
|
||||
fi
|
||||
```
|
||||
@@ -0,0 +1,98 @@
|
||||
# 自动编号 → 手动固定编号修复(删段重排根治)
|
||||
|
||||
## 适用场景
|
||||
他人(如屠佳青)用修订模式整段删除了一个**自动编号列表项**(段落 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 验证通过)
|
||||
```python
|
||||
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-data` → `occ files:scan --path` → 清 OnlyOffice 缓存(`rm -rf .../App_Data/cache/files/*`)让 Maggie 打开看到新版。`md5sum` 比对容器内==本地确认上传成功。
|
||||
@@ -0,0 +1,113 @@
|
||||
# 在「自动编号的顶层列表」中新增条款(保留numPr)
|
||||
|
||||
2026-06-18 华新慢病运维合同实战。一次返工换来的教训。
|
||||
|
||||
## 适用判定(动手前先分清两类合同)
|
||||
|
||||
合同的「条款」分两种承载方式,新增条款的手法完全不同:
|
||||
|
||||
| 类型 | 特征 | 新增条款手法 |
|
||||
|------|------|-------------|
|
||||
| **A. 第X条 文本标题** | 条款标题是 run 里的文字「第七条 …」/「7. …」,段落**无** numPr | `add_clause()`(库会剥 numPr,正确)|
|
||||
| **B. 自动编号列表项** | 条款本身是自动编号列表项:段落 pPr 带 `<w:numPr>`,编号由 numbering.xml 的 `start`+`lvlText`(如 `一、`/`1.`/`(1)`)自动渲染,run 里**没有**编号文字 | ❌ 不能用 `add_clause()`;按下方「numbered-insert」手法 |
|
||||
|
||||
**判定脚本**:对要插入位置附近的条款段落跑 `numbering-diagnose.py`,或直接看锚点段 `pPr/numPr` 是否存在且其 numId 的 lvlText 是序号格式。本案锚点「五、违约责任」段 `pPr` = `pStyle=12 + numPr(numId=1,ilvl=0) + ind`,numId=1→abstractNum start=1 lvlText=`%1、`(japaneseCounting 一、二、三)。
|
||||
|
||||
## 为什么 add_clause 在 B 类会坏
|
||||
|
||||
`add_clause()`(contract_docx_lib.py 第408-411行)**无条件**剥离新段的 numPr:
|
||||
```python
|
||||
numpr = new_ppr.find(qn('numPr'))
|
||||
if numpr is not None:
|
||||
new_ppr.remove(numpr) # ← B类灾难
|
||||
```
|
||||
后果(本案实测):5 个新增条款全部**丢失自动编号**,且因 pPr 缺 numPr/缩进与列表项不一致,渲染时**堆到了文档最末尾**(签署页之前),既无编号又错位——违反「新增条款插在逻辑对应位置、不堆到最后」+「自动编号保留numPr」两条规则。
|
||||
|
||||
`add_clause` 第二个缺陷:它只把**文本** run 包进 w:ins,**没有把段落标记(¶)标记为插入**。B 类里 ¶ 承载着自动编号,¶ 不是 tracked-insert,则接受/拒绝修订时这一项的编号增减不随修订走。
|
||||
|
||||
## 正确手法:numbered tracked-insert 段落
|
||||
|
||||
克隆锚点段的 pPr(**保留** numPr,让新段成为同一自动编号序列的一员),并把**段落标记本身**也标成 w:ins:
|
||||
|
||||
```python
|
||||
import sys, copy
|
||||
sys.path.insert(0, '/home/maggie/contract-work')
|
||||
from contract_docx_lib import ContractEditor, qn
|
||||
from lxml import etree
|
||||
|
||||
ed = ContractEditor(src)
|
||||
|
||||
# 1) 定位锚点段(要插在它之后的那条原文条款)
|
||||
anchor = None
|
||||
for p in ed.body.findall(qn('p')):
|
||||
if '违约责任:按照中华人民共和国民法典' in ed.get_para_text(p):
|
||||
anchor = p; break
|
||||
anchor_ppr = anchor.find(qn('pPr'))
|
||||
assert anchor_ppr.find(qn('numPr')) is not None, "锚点不是自动编号项,确认是否B类"
|
||||
|
||||
def make_numbered_ins_para(text):
|
||||
new_p = etree.Element(qn('p'))
|
||||
new_ppr = copy.deepcopy(anchor_ppr) # 含 pStyle + numPr(同numId/ilvl) + ind → 入同一自动编号序列
|
||||
# 关键:把段落标记(¶)标成插入,整段(含自动编号)作为 tracked insertion
|
||||
rpr_mark = new_ppr.find(qn('rPr'))
|
||||
if rpr_mark is None:
|
||||
rpr_mark = etree.SubElement(new_ppr, qn('rPr'))
|
||||
ins_mark = etree.SubElement(rpr_mark, qn('ins'))
|
||||
ins_mark.set(qn('id'), ed._next_id())
|
||||
ins_mark.set(qn('author'), 'WB')
|
||||
ins_mark.set(qn('date'), ed._revision_date)
|
||||
new_p.append(new_ppr)
|
||||
# 文本作为 tracked-ins run,用规范化 _body_rpr(完整rFonts四属性+hint=eastAsia+显式sz)
|
||||
new_p.append(ed._mk_ins(text, ed._body_rpr))
|
||||
return new_p
|
||||
|
||||
clauses = [ # 期望的最终正序 六~十
|
||||
"保密与数据:……",
|
||||
"知识产权与系统交接:……",
|
||||
"转包与分包:……",
|
||||
"第三方侵权:……",
|
||||
"违约赔偿:……",
|
||||
]
|
||||
|
||||
# 2) 全部插在 anchor 之后;倒序 insert 使最终正序
|
||||
parent = ed.body
|
||||
anchor_idx = list(parent).index(anchor)
|
||||
for txt in reversed(clauses):
|
||||
parent.insert(anchor_idx + 1, make_numbered_ins_para(txt))
|
||||
|
||||
assert ed.validate() == []
|
||||
ed.save(out)
|
||||
```
|
||||
|
||||
要点:
|
||||
- **倒序插入**:每条都插在 `anchor_idx+1`,倒序遍历 → 最终正序。
|
||||
- **同一 numId/ilvl**:克隆锚点 pPr 即自动继承,新条款自动续编(本案锚点是五 → 新条款渲染为六、七、八、九、十,后续原文自动顺延为十一、十二…,**无需手动改任何原文编号**)。
|
||||
- **¶ 标插入** + **文本 run 标插入**,两者都要,缺一不可。
|
||||
- 文本 run 用 `ed._body_rpr`(库已规整:四属性 rFonts + hint=eastAsia + 显式 sz),不要手搓 rPr。
|
||||
|
||||
## 交付前验证(B 类专项)
|
||||
|
||||
1. **接受所有修订后**渲染(删 w:del + 删带 `pPr/rPr/del` 的整段 + 解包 w:ins)→ 确认新条款编号与锚点连续、原文顺延正确、无错位到末尾。
|
||||
2. **字体核对走「同段原文」标准**:本案原文正文 run = `<w:rFonts hint="eastAsia"/><w:szCs val="21"/>`(**无**显式 eastAsia 名,继承 docDefaults 宋体)。新 INS run 与之等效即合格——`ea=None hint=eastAsia` 是**正确**的,`wb-ins-font-verify.py` 若按绝对属性报 `ea=None` 是假阳性(见 contract-reviewer 的 2026-06-17 培训合同条)。唯一差异是 INS 多了显式 `<w:sz val="21">`(w:ins 必需),渲染一致。
|
||||
3. **LibreOffice 渲染假象**:用 `libreoffice→pdftotext` 自查时,被顺延的自动编号项会显示 `十二、[七、]` 这种**方括号叠加**(recomputed 新号 + cached 旧号),这是 LibreOffice markup 渲染产物,**不是错误**,XML 里没有字面方括号。判真实编号一律以「接受所有修订后」或 OnlyOffice 渲染为准(OnlyOffice 是 Maggie/Doro 实际所用引擎)。
|
||||
|
||||
## 锚点选择铁律:插在 body text 之后,不是 heading 之后
|
||||
|
||||
**这是一个极易犯的错误**(2026-06-26 朱家角环保袋合同实证):
|
||||
|
||||
当Reviewer要求"在违约责任条款之后、争议解决条款之前新增XX条款"时,合同结构通常是:
|
||||
```
|
||||
P74: 八.违约责任 ← heading(numId=1)
|
||||
P75: 若乙方未按本合同... ← body text(无 numPr)
|
||||
P76: 九.合同金额 ← 下一个 heading(numId=1)
|
||||
```
|
||||
|
||||
**错误做法**:锚点 = P74(heading),插入后 → 新条款夹在 heading 和它的 body text 之间,结构错乱。
|
||||
|
||||
**正确做法**:锚点 = P75(body text),插入后 → 新条款在 body text 之后、下一个 heading 之前,结构正确。
|
||||
|
||||
**判据**:`numbering-diagnose.py` 确认锚点段的 `numPr` 状态——heading 有 numPr,body text 无 numPr。新条款应克隆**下一个 heading**(如 P76 合同金额)的 pPr(含 numPr),插入在**前一个 body text**(如 P75)之后。
|
||||
|
||||
## 一句话
|
||||
|
||||
锚点是自动编号列表项(pPr 有 numPr)→ 别用 add_clause,克隆锚点 pPr(留 numPr)+ ¶ 标 w:ins + 文本标 w:ins,倒序插入,新条款自动续编、原文自动顺延。**插在 body text 之后,不是 heading 之后。**
|
||||
@@ -0,0 +1,95 @@
|
||||
# A类(手动文本编号)新增条款:克隆"真实邻居段落"而非信任库的 _title_rpr / _body_rpr
|
||||
|
||||
2026-06-18 赵巷镇 X线设备采购合同实战。终审字体核验抓出"新标题不加粗",根因是库提取的标题格式丢了 bold。
|
||||
|
||||
## 何时用这套手法
|
||||
|
||||
- 合同是 **A 类**:条款标题是 run 里的**手动文本编号**(如 `8.争端的解决`、`第七条 索赔`),段落**无** numPr。
|
||||
- 要新增一个带标题的条款(标题段 + 正文段),并希望格式与兄弟条款 100% 一致。
|
||||
- (B 类自动编号列表项见 `auto-numbered-list-clause-insert.md`,手法不同。)
|
||||
|
||||
## 为什么不直接用库的 `_title_rpr` / `_body_rpr`
|
||||
|
||||
`ContractEditor._extract_formats()`(contract_docx_lib.py ~第86-175行)用启发式认"条款标题":
|
||||
```python
|
||||
is_clause_title = (re.match(r'^\d+[..、]\s*\S', p_text) or
|
||||
re.match(r'^第[一二三四五六七八九十百千\d]+条\s*\S', p_text)) and len(p_text) < 30
|
||||
# 且 _is_title_style() 要 <w:b/> 或标题字体(黑体/SimHei…) 才算 title
|
||||
```
|
||||
**坑**:当标题就是"8.争端的解决"(宋体 + `<w:b/>`,无特殊标题字体),若该段在扫描中**没被 `_is_title_style` 命中**(例如 b 标记在 bCs 旁、或正则边界),`clause_title_rpr` / `first_bold_rpr` 取空 → `_title_rpr` **回退到 `_body_rpr`(不含 bold)**。
|
||||
|
||||
实测后果:新标题 `8.转包与分包` 的 INS run `bold=False`,而原文兄弟标题 `9.争端的解决` `bold=True`。`validate()` 查不出(它不比 bold),只有逐段 WB INS 与"同段/同级原文"对比才抓得到。
|
||||
|
||||
## 稳健手法:克隆紧邻同级原文段落
|
||||
|
||||
不取库的 `_title_rpr`/`_body_rpr`,改为**直接深拷贝隔壁真实条款段**的 pPr 和首个 run 的 rPr:
|
||||
|
||||
```python
|
||||
import sys, copy
|
||||
sys.path.insert(0, '/home/maggie/contract-work')
|
||||
from contract_docx_lib import ContractEditor, qn
|
||||
from lxml import etree
|
||||
|
||||
ed = ContractEditor(src) # 已先做完所有 tracked_replace
|
||||
|
||||
def find(kw):
|
||||
for p in ed.body.findall(qn('p')):
|
||||
if kw in ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')):
|
||||
return p
|
||||
return None
|
||||
|
||||
# 克隆来源:插入点后面那条原文条款的【标题段】和它的【正文段】
|
||||
title_src = find('8.争端的解决') # 兄弟条款标题(自带 <w:b/> + 宋体四属性 + 标题pPr缩进)
|
||||
body_src = find('双方如在履行合同中发生纠纷') # 兄弟条款正文(无bold + firstLine=420 缩进)
|
||||
|
||||
def clone_as_ins(src_para, new_text):
|
||||
"""深拷贝 src_para 的 pPr + 首run rPr,替换文本,整段(含¶)标 w:ins(author=WB)"""
|
||||
np = etree.Element(qn('p'))
|
||||
ppr = copy.deepcopy(src_para.find(qn('pPr')))
|
||||
# ¶ 段落标记标插入
|
||||
rprm = ppr.find(qn('rPr'))
|
||||
if rprm is None:
|
||||
rprm = etree.SubElement(ppr, qn('rPr'))
|
||||
insm = etree.SubElement(rprm, qn('ins'))
|
||||
insm.set(qn('id'), ed._next_id()); insm.set(qn('author'), 'WB'); insm.set(qn('date'), ed._revision_date)
|
||||
np.append(ppr)
|
||||
# run rPr 直接克隆兄弟段首 run(bold/字体/字号全继承,不碰库的默认值)
|
||||
src_r = src_para.find(qn('r'))
|
||||
src_rpr = copy.deepcopy(src_r.find(qn('rPr'))) if (src_r is not None and src_r.find(qn('rPr')) is not None) else None
|
||||
np.append(ed._mk_ins(new_text, src_rpr))
|
||||
return np
|
||||
|
||||
title_p = clone_as_ins(title_src, "8.转包与分包")
|
||||
body_p = clone_as_ins(body_src, "未经甲方书面同意,乙方不得将本合同项下的…连带责任。")
|
||||
|
||||
idx = list(ed.body).index(title_src)
|
||||
ed.body.insert(idx, title_p) # 标题插在兄弟条款标题之前 → 成为新的"8.",兄弟顺延为"9."
|
||||
ed.body.insert(idx + 1, body_p)
|
||||
```
|
||||
|
||||
## A类手动编号的顺延(与 B 类自动顺延不同!)
|
||||
|
||||
A 类编号是 run 里的字面文字,**不会自动顺延**。插入新"8."后,必须手动把后续所有手动编号 DEL 旧号+INS 新号(用 tracked_replace):
|
||||
```python
|
||||
for old, new in [("8.争端的解决","9.争端的解决"), ("9.合同生效","10.合同生效"),
|
||||
("9.1 本合同在…","10.1 本合同在…"), ("10.合同附件","11.合同附件"),
|
||||
("10.1 配置清单","11.1 配置清单"), ..., ("12.特别约定","13.特别约定")]:
|
||||
ed.tracked_replace(old, new)
|
||||
```
|
||||
- **子编号一并顺延**(9.1/9.2→10.1/10.2,11.1-11.7→12.1-12.7)。
|
||||
- 匹配串要够长以避免短串误命中(见 SKILL.md「tracked_replace 短字符串误命中」)。
|
||||
|
||||
## 交付前验证(必做)
|
||||
|
||||
1. **bold 对照**:新标题 INS run `bold==True` 且 ==兄弟标题;新正文 INS run `bold==False` 且有正确 `firstLine` 缩进。
|
||||
```python
|
||||
r = p.find('.//w:ins/w:r', ns); b = r.find('w:rPr/w:b', ns)
|
||||
# 标题段 b is not None == 兄弟标题段 b is not None
|
||||
```
|
||||
2. **WB INS 字体逐段核验(相对同段/同级原文)**:异常应为 0。原文 run 有显式宋体四属性时,克隆来的 INS 也带四属性——与原文一致即合格。
|
||||
3. **接受所有修订后渲染**,确认手动编号链连续(…7、**8.转包**、9、10、10.1、10.2、11…13),无重号/跳号。
|
||||
4. python-docx 能打开(XML 合法)。
|
||||
|
||||
## 一句话
|
||||
|
||||
A 类手动编号合同新增带标题条款:**别用库的 `_title_rpr`/`_body_rpr`(启发式可能丢 bold)**,直接 `copy.deepcopy` 紧邻兄弟条款的【标题段】和【正文段】的 pPr+首run rPr,文本替换+整段标 w:ins;编号不会自动顺延,手动 tracked_replace 把后续主/子编号全部 +1。
|
||||
@@ -0,0 +1,146 @@
|
||||
# Comment Restoration from Original File
|
||||
|
||||
When comments are lost during docx editing (e.g., paragraph clear operations that remove commentRangeStart/End/Reference elements), restore them from the original file.
|
||||
|
||||
## Scenario
|
||||
- Original file has N comments (e.g., Alice×2, 法务, 杜律 = 4 comments, ids 0-3)
|
||||
- Edited file lost some/all original comments and may have added new ones (e.g., 华诚-Z comment id=0, Alice id=2)
|
||||
- Goal: merge all comments — original ones preserved + new ones added, with non-conflicting IDs
|
||||
|
||||
## Recovery Technique
|
||||
|
||||
### Step 1: Extract original comments
|
||||
```python
|
||||
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
|
||||
z_orig = zipfile.ZipFile('original.docx')
|
||||
with z_orig.open('word/comments.xml') as f:
|
||||
ctree_orig = etree.parse(f)
|
||||
orig_comments = []
|
||||
for c in ctree_orig.getroot().findall(f'{WNS}comment'):
|
||||
orig_comments.append({
|
||||
'id': c.get(f'{WNS}id'),
|
||||
'author': c.get(f'{WNS}author'),
|
||||
'date': c.get(f'{WNS}date'),
|
||||
'text': ''.join(t.text for t in c.iter(f'{WNS}t') if t.text),
|
||||
'element': copy.deepcopy(c)
|
||||
})
|
||||
z_orig.close()
|
||||
```
|
||||
|
||||
### Step 2: Identify which comments survived in the edited file
|
||||
```python
|
||||
z_edit = zipfile.ZipFile('edited.docx')
|
||||
with z_edit.open('word/comments.xml') as f:
|
||||
ctree_edit = etree.parse(f)
|
||||
edit_comment_ids = set()
|
||||
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
|
||||
edit_comment_ids.add(c.get(f'{WNS}id'))
|
||||
```
|
||||
|
||||
### Step 3: Find new comments (non-original authors)
|
||||
```python
|
||||
new_comments = []
|
||||
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
|
||||
if c.get(f'{WNS}author') not in [oc['author'] for oc in orig_comments]:
|
||||
new_comments.append({
|
||||
'old_id': c.get(f'{WNS}id'),
|
||||
'author': c.get(f'{WNS}author'),
|
||||
'element': copy.deepcopy(c)
|
||||
})
|
||||
```
|
||||
|
||||
### Step 4: Rebuild comments.xml with all comments
|
||||
Assign non-conflicting IDs:
|
||||
- Original comments keep their original IDs (0, 1, 2, 3)
|
||||
- New comments get IDs starting from max(original_ids) + 1
|
||||
|
||||
```python
|
||||
new_comments_xml = etree.Element(f'{WNS}comments')
|
||||
new_comments_xml.set('xmlns:w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
|
||||
# ... add other namespaces as needed
|
||||
|
||||
max_id = max(int(oc['id']) for oc in orig_comments)
|
||||
|
||||
# Add original comments
|
||||
for oc in orig_comments:
|
||||
new_comments_xml.append(oc['element'])
|
||||
|
||||
# Add new comments with renumbered IDs
|
||||
for nc in new_comments:
|
||||
max_id += 1
|
||||
nc['new_id'] = str(max_id)
|
||||
nc['element'].set(f'{WNS}id', nc['new_id'])
|
||||
new_comments_xml.append(nc['element'])
|
||||
```
|
||||
|
||||
### Step 5: Update document.xml comment references
|
||||
For each new comment, find its commentRangeStart, commentRangeEnd, and commentReference in document.xml and update the ID from old to new:
|
||||
|
||||
```python
|
||||
for nc in new_comments:
|
||||
old_id = nc['old_id']
|
||||
new_id = nc['new_id']
|
||||
|
||||
# Update commentRangeStart
|
||||
for elem in root.iter(f'{WNS}commentRangeStart'):
|
||||
if elem.get(f'{WNS}id') == old_id:
|
||||
elem.set(f'{WNS}id', new_id)
|
||||
|
||||
# Update commentRangeEnd
|
||||
for elem in root.iter(f'{WNS}commentRangeEnd'):
|
||||
if elem.get(f'{WNS}id') == old_id:
|
||||
elem.set(f'{WNS}id', new_id)
|
||||
|
||||
# Update commentReference (inside w:r)
|
||||
for elem in root.iter(f'{WNS}commentReference'):
|
||||
if elem.get(f'{WNS}id') == old_id:
|
||||
elem.set(f'{WNS}id', new_id)
|
||||
```
|
||||
|
||||
### Step 6: Write back to docx
|
||||
```python
|
||||
z_out = zipfile.ZipFile('output.docx', 'w')
|
||||
# Copy all files from edited.docx except comments.xml and document.xml
|
||||
for item in z_edit.namelist():
|
||||
if item not in ('word/comments.xml', 'word/document.xml'):
|
||||
z_out.writestr(item, z_edit.read(item))
|
||||
|
||||
# Write updated comments.xml
|
||||
z_out.writestr('word/comments.xml',
|
||||
etree.tostring(new_comments_xml, encoding='UTF-8', xml_declaration=True, standalone=True))
|
||||
|
||||
# Write updated document.xml
|
||||
z_out.writestr('word/document.xml',
|
||||
etree.tostring(tree, encoding='UTF-8', xml_declaration=True, standalone=True))
|
||||
|
||||
z_edit.close()
|
||||
z_out.close()
|
||||
```
|
||||
|
||||
## Verification
|
||||
```python
|
||||
z = zipfile.ZipFile('output.docx')
|
||||
with z.open('word/comments.xml') as f:
|
||||
ctree = etree.parse(f)
|
||||
for c in ctree.getroot().findall(f'{WNS}comment'):
|
||||
print(f" id={c.get(f'{WNS}id')} author={c.get(f'{WNS}author')}: {text[:80]}")
|
||||
|
||||
# Check all IDs referenced in document.xml exist in comments.xml
|
||||
content = z.read('word/document.xml').decode('utf-8')
|
||||
doc_ids = set(re.findall(r'commentRangeStart[^>]*w:id="(\d+)"', content))
|
||||
doc_ids |= set(re.findall(r'commentRangeEnd[^>]*w:id="(\d+)"', content))
|
||||
doc_ids |= set(re.findall(r'commentReference[^>]*w:id="(\d+)"', content))
|
||||
comment_ids = set(c.get(f'{WNS}id') for c in ctree.getroot().findall(f'{WNS}comment'))
|
||||
assert doc_ids == comment_ids, f"ID mismatch: doc={doc_ids} comments={comment_ids}"
|
||||
```
|
||||
|
||||
## Key Pitfall: Comment Text Extraction
|
||||
When extracting comment text for comparison, comments may have nested `<w:p>` elements (multi-paragraph comments). Use `.iter()` not `.findall()` to get all text nodes.
|
||||
|
||||
## Empirical Case (2026-07-01 反委托代发工资协议)
|
||||
- Original: 4 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3)
|
||||
- v1_doro_updated: 2 comments (华诚-Z id=0, Alice id=2) — lost Alice id=0/1, 法务, 杜律
|
||||
- Final: 5 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3, 华诚-Z id=4)
|
||||
- 华诚-Z's comment was id=0 in v1_doro_updated, renumbered to id=4 in final
|
||||
- All commentRangeStart/End/Reference IDs updated in document.xml accordingly
|
||||
@@ -0,0 +1,94 @@
|
||||
# 合同模板修订工作流(非workflow场景)
|
||||
|
||||
## 触发条件
|
||||
用户要求参考一份新合同模板(保护甲方),将有利内容用修订模式改进原合同(乙方模板)。
|
||||
|
||||
## 与标准 review-contract workflow 的区别
|
||||
- 不涉及 classifier/reviewer/editor/deliverer 角色链
|
||||
- 不使用 review-rules.md
|
||||
- 不需要 pass 流程(不写 tracker/xlsx)
|
||||
- 直接用 ContractEditor 库手动修订
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 1. 读取两份合同
|
||||
```python
|
||||
from contract_docx_lib import ContractEditor
|
||||
editor = ContractEditor('原合同.docx') # 乙方模板,作为修订基底
|
||||
```
|
||||
同时用 python-docx 或 zipfile+lxml 读取新合同全文,逐条对比差异。
|
||||
|
||||
### 2. 识别差异并分类
|
||||
- **可直接移植**:新合同中明确有利于甲方的条款(如违约金降低、管辖权、解除权限制)
|
||||
- **需要调整**:新合同有利但需适配原合同结构/编号的条款
|
||||
- **需要补充**:新合同仍未覆盖的保护甲方的内容(根据法律法规判断)
|
||||
|
||||
### 3. 执行修订(最小化修改原则)
|
||||
- 整体格式、编号逻辑按**原合同**来
|
||||
- 用 `tracked_replace` 修改既有条款
|
||||
- 用 `add_clause` 新增条款(插在合同逻辑对应位置)
|
||||
- author=WB
|
||||
|
||||
### 4. 法律研究(严禁凭记忆)
|
||||
每次修订前必须查证:
|
||||
- 最新法律法规(民法典、劳动合同法、劳务派遣暂行规定等)
|
||||
- 上海地区地方规定和司法实践
|
||||
- 行业惯例
|
||||
|
||||
常见需要查证的点:
|
||||
- 违约金比例上限(司法实践中过高会被调整)
|
||||
- 管辖权约定(甲方所在地法院 vs 仲裁)
|
||||
- 劳务派遣的法定退回情形(劳动合同法第65条)
|
||||
- 雇主责任险要求(上海地区实务惯例)
|
||||
- 经济补偿金的法定标准
|
||||
|
||||
### 5. 修订说明
|
||||
完成后向用户汇报:
|
||||
- 修订数量(insertions/deletions)
|
||||
- 每项修订的法律依据
|
||||
- 标注哪些是根据新合同移植、哪些是独立判断补充
|
||||
|
||||
## 违约后果公式(核心原则,2026-06-29 Doro纠正)
|
||||
|
||||
**"权利是法律给的,关键在违约后果"**——当法律已赋予甲方某项权利时,合同中简单写入"甲方有权XX"只是重复法律,没有实质保护价值。审查/修订的重点是**违约后果条款**:
|
||||
|
||||
### 标准违约后果公式
|
||||
```
|
||||
甲方因此支付的一切费用、承担的赔偿或补偿金、损失等由乙方全额赔偿,
|
||||
乙方另向甲方支付违约金人民币 元。
|
||||
如对甲方造成其他不良影响的,乙方还应当消除一切影响。
|
||||
```
|
||||
|
||||
### 三要素
|
||||
1. **赔偿范围**:一切费用、承担的赔偿或补偿金、损失等(括注具体类型如重新招聘费用、行政罚款、律师费、诉讼费等)
|
||||
2. **违约金**:金额留空(6个空格),由甲方根据实际用工规模和风险自行填写
|
||||
3. **消除影响**:兜底,覆盖名誉损害、商誉损失等非经济损失
|
||||
|
||||
### 适用场景
|
||||
所有"乙方违反法定义务→甲方有权XX"类条款:
|
||||
- 资质丧失 → 不止"甲方有权解除",要追加完整后果公式
|
||||
- 克扣工资/欠缴社保 → 不止"暂停付款",要追加连带后果公式
|
||||
- 一般违约追偿 → 不止"有权追偿",要写清赔偿范围+违约金+消除影响
|
||||
|
||||
### 劳务派遣协议实证(2026-06-29)
|
||||
| 条款 | 原写法(弱) | 改后(含后果公式) |
|
||||
|------|------------|-------------------|
|
||||
| 资质丧失 | "甲方有权解除,乙方赔偿全部损失" | "乙方赔偿一切费用/赔偿或补偿金/损失(含重新招聘费、劳动者赔偿金、行政罚款、律师费等)+违约金___元+消除一切影响" |
|
||||
| 审核权 | "暂停支付相关费用直至整改完成" | 追加:因乙方违法行为导致甲方承担连带责任的,一切费用由乙方赔偿+违约金+消除影响 |
|
||||
| 一般追偿 | "甲方有权依法向乙方追偿" | "一切费用由乙方赔偿+违约金+消除一切影响" |
|
||||
|
||||
## 2026-06-29 劳务派遣协议案修订清单
|
||||
|
||||
| 修订 | 类型 | 法律依据 |
|
||||
|------|------|----------|
|
||||
| 乙方资质持续保证 | 新增 | 《劳务派遣暂行规定》第17条 |
|
||||
| 甲方监督检查权扩展 | 修改 | 《劳动合同法》第62条 |
|
||||
| 甲方调整岗位权 | 新增 | 《劳动合同法》第62条 |
|
||||
| 甲方随时退回权 | 新增 | 《劳动合同法》第65条、《劳务派遣暂行规定》第12条 |
|
||||
| 雇主责任险要求 | 新增 | 上海司法实践惯例 |
|
||||
| 乙方解除权限制 | 修改 | 《民法典》第563条(催告程序) |
|
||||
| 付款期限延长 | 修改 | 商业条款(甲方资金调度) |
|
||||
| 甲方违约金降低 | 修改 | 上海法院对过高违约金的司法调整 |
|
||||
| 乙方根本违约情形 | 新增 | 《民法典》第563条 |
|
||||
| 争议解决管辖 | 新增 | 《民事诉讼法》第35条(协议管辖) |
|
||||
| 附件和补充协议 | 新增 | 标准合同条款 |
|
||||
@@ -0,0 +1,33 @@
|
||||
# 跨境并购费用参考(5000万人民币交易规模)
|
||||
|
||||
> 来源:行业公开数据与市场实践,2026年6月。具体费用因交易复杂度、目标法域、各方谈判能力而异。
|
||||
|
||||
## 各角色费用区间
|
||||
|
||||
| 角色 | 费用(人民币) | 收费模式 |
|
||||
|---|---|---|
|
||||
| 财务顾问(FA) | 150万–250万 | 成功费,交易对价3%–5%;分期收取(签约10–20%,签约后40%,交割后40–50%) |
|
||||
| 法律顾问(中国律所) | 50万–100万 | 固定费,含法律尽调15–30万、交易文件20–40万、监管审批10–20万、境外律师协调5–10万 |
|
||||
| 境外律师 | 30万–80万 | 按小时(300–800美元/小时),目标法域决定 |
|
||||
| 会计师(财务尽调) | 20万–40万 | 固定费 |
|
||||
| 税务师 | 15万–35万 | 固定费,含税务尽调10–20万、结构优化5–15万 |
|
||||
| **合计** | **265万–505万** | 占交易额约5%–10% |
|
||||
|
||||
## FA 费率惯例
|
||||
|
||||
- 中国市场:中端交易3%–5%,大型交易费率递减
|
||||
- 海外莱曼公式(Lehman Formula):累退费率,5000万人民币≈680万欧元→约15万欧元(约118万人民币),但中国市场费率通常高于莱曼
|
||||
- 中国FA实操中常用"一口价"或协商费率,少见纯莱曼公式
|
||||
|
||||
## 交易协调人(律师兼任)收费参考
|
||||
|
||||
- 固定项目管理费:5万–10万/月,或每项目15万–30万
|
||||
- 从FA成功费分成:10%–15%
|
||||
- 最优组合:固定费(保底)+ FA分成(激励)+ 法律费独立收取(不混)
|
||||
|
||||
## 第三方机构管理原则
|
||||
|
||||
- FA负责整体协调,但不代替第三方出具报告
|
||||
- 第三方费用由客户直接支付
|
||||
- 各机构独立承担专业责任
|
||||
- 律师(作为交易协调人)可帮FA管理第三方机构,但不能替第三方机构的工作成果背书
|
||||
@@ -0,0 +1,102 @@
|
||||
# DOCX 批注(Word 原生 comment)插入 — 纯 zipfile+lxml
|
||||
|
||||
实战来源:南通新东方校外培训服务合同独立审查(2026-06-17)。Maggie 要求"用修订**和批注**的形式"。`ContractEditor` 没有批注方法(`dir()` 确认无 comment/annot/note),批注必须手写 OOXML。已验证可在 OnlyOffice 正常显示。
|
||||
|
||||
## 何时用 DOCX 批注 vs PDF 批注
|
||||
- **docx 合同** → 用本文方法(Word 原生 comment,OnlyOffice 显示为右侧批注气泡)。
|
||||
- **PDF 合同** → 用 pymupdf(fitz) 高亮+comment annotation(见 SKILL.md「PDF合同直接批注」)。两者不通用。
|
||||
|
||||
## 批注内容铁律(与 SKILL.md 一致,复述强调)
|
||||
- 只写"建议……",给方案;**不写理由/原因/因为**;**不加【新增】【建议】等标签**。
|
||||
- 批注仅限两类:①需客户确认(名称空白、标准未定义需明示);②建议增加条款且内容较长。**选择题/勾选项不处理(2026-07-08废止)。**
|
||||
- 能直接修订的一律修订,批注是最后手段。
|
||||
|
||||
## 五个改动点(缺一不可,否则 Word 报"无法打开/需修复")
|
||||
1. **新增 `word/comments.xml`**:定义每条批注的 id/author/date/initials + 内容。
|
||||
2. **`word/document.xml`**:在锚点文本范围**前**插 `w:commentRangeStart`、**后**插 `w:commentRangeEnd` + 一个带 `w:commentReference` 的 run。
|
||||
3. **`[Content_Types].xml`**:加 `Override` 声明 comments.xml 的 content-type。
|
||||
4. **`word/_rels/document.xml.rels`**:加 `Relationship` 指向 comments.xml。
|
||||
5. 三处 id(rangeStart/rangeEnd/commentReference)与 comments.xml 的 `w:comment/@w:id` **必须全部一致**。
|
||||
|
||||
## 锚点定位(关键陷阱)
|
||||
- 锚点文本要按**接受修订后**的文本匹配(遍历 w:t 时**跳过 w:del 内的**),否则被删字符会让匹配错位。
|
||||
- commentRangeStart 必须插在段落第一个 `w:r` **或 `w:ins`** 之前(不能只找 w:r——修订后段首可能是 ins)。
|
||||
- 先验证每个锚点在全文**唯一命中**(命中数==1)再插,多处命中会挂错段落。
|
||||
|
||||
## 可复用代码
|
||||
```python
|
||||
import zipfile, io
|
||||
from lxml import etree
|
||||
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
Wq = '{' + W + '}'
|
||||
DATE = "2026-06-17T10:00:00Z"
|
||||
|
||||
comments = [ # 站顾问单位立场,需确认/建议增加内容
|
||||
{"id":"201","anchor":"甲方扣除相应服务费后","text":"建议在合同或退费管理制度中明确“服务费”的扣费比例或计算方式,并在签约时向乙方明示。"},
|
||||
{"id":"202","anchor":"向甲方住所地人民法院提起诉讼","text":"本条约定甲方住所地法院管辖,建议签约时以加粗或单独提示方式向乙方说明,尽到格式条款提示义务。"},
|
||||
]
|
||||
|
||||
def build_comments_xml(comments):
|
||||
p = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||
f'<w:comments xmlns:w="{W}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">']
|
||||
for c in comments:
|
||||
p.append(f'<w:comment w:id="{c["id"]}" w:author="WB" w:date="{DATE}" w:initials="WB">')
|
||||
# 批注文字字体随原文(本例宋体sz=18小一号),hint=eastAsia 必带
|
||||
p.append('<w:p><w:r><w:rPr><w:rFonts w:ascii="宋体" w:hAnsi="宋体" w:eastAsia="宋体" w:cs="宋体" w:hint="eastAsia"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>')
|
||||
p.append(f'<w:t xml:space="preserve">{c["text"]}</w:t></w:r></w:p></w:comment>')
|
||||
p.append('</w:comments>')
|
||||
return ''.join(p)
|
||||
|
||||
comments_xml = build_comments_xml(comments)
|
||||
with open('IN.docx','rb') as f: data=f.read()
|
||||
bi, bo = io.BytesIO(data), io.BytesIO()
|
||||
inserted = {c["id"]: False for c in comments}
|
||||
with zipfile.ZipFile(bi) as zin, zipfile.ZipFile(bo,'w',zipfile.ZIP_DEFLATED) as zout:
|
||||
for it in zin.infolist():
|
||||
raw = zin.read(it.filename)
|
||||
if it.filename == 'word/document.xml':
|
||||
tree = etree.fromstring(raw); body = tree.find(f'{Wq}body')
|
||||
for para in body.findall(f'.//{Wq}p'):
|
||||
ptext = '' # 接受修订后文本:跳过 del
|
||||
for t in para.findall(f'.//{Wq}t'):
|
||||
if not any(a.tag==f'{Wq}del' for a in t.iterancestors()):
|
||||
ptext += (t.text or '')
|
||||
for c in comments:
|
||||
if not inserted[c["id"]] and c["anchor"] in ptext:
|
||||
cid = c["id"]
|
||||
first = next((ch for ch in para if ch.tag in (f'{Wq}r',f'{Wq}ins')), None)
|
||||
if first is None: continue
|
||||
crs = etree.Element(f'{Wq}commentRangeStart'); crs.set(f'{Wq}id',cid); first.addprevious(crs)
|
||||
cre = etree.Element(f'{Wq}commentRangeEnd'); cre.set(f'{Wq}id',cid); para.append(cre)
|
||||
rr = etree.SubElement(para,f'{Wq}r'); rp=etree.SubElement(rr,f'{Wq}rPr')
|
||||
rs = etree.SubElement(rp,f'{Wq}rStyle'); rs.set(f'{Wq}val','CommentReference')
|
||||
cref = etree.SubElement(rr,f'{Wq}commentReference'); cref.set(f'{Wq}id',cid)
|
||||
inserted[cid] = True
|
||||
raw = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
elif it.filename == '[Content_Types].xml':
|
||||
ct = etree.fromstring(raw); NS='http://schemas.openxmlformats.org/package/2006/content-types'
|
||||
ov = etree.SubElement(ct,f'{{{NS}}}Override')
|
||||
ov.set('PartName','/word/comments.xml')
|
||||
ov.set('ContentType','application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml')
|
||||
raw = etree.tostring(ct, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
elif it.filename == 'word/_rels/document.xml.rels':
|
||||
rt = etree.fromstring(raw); RNS='http://schemas.openxmlformats.org/package/2006/relationships'
|
||||
r = etree.SubElement(rt,f'{{{RNS}}}Relationship')
|
||||
r.set('Id','rIdComments1')
|
||||
r.set('Type','http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments')
|
||||
r.set('Target','comments.xml')
|
||||
raw = etree.tostring(rt, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
zout.writestr(it, raw)
|
||||
zout.writestr('word/comments.xml', comments_xml.encode('utf-8'))
|
||||
with open('OUT.docx','wb') as f: f.write(bo.getvalue())
|
||||
assert all(inserted.values()), f"未全部挂靠: {inserted}"
|
||||
```
|
||||
|
||||
## 交付前验证(缺一不可)
|
||||
1. **id 四向一致**:`commentRangeStart` / `commentRangeEnd` / `commentReference` 三组 id 集合 == comments.xml 的 `w:comment/@w:id` 集合。
|
||||
2. **python-docx 能打开**(XML 合法)。
|
||||
3. **接受修订后锚点存在**:批注挂靠的文本在去 del 后仍在。
|
||||
4. **OnlyOffice 渲染**(onlyoffice-render.sh)确认批注气泡正常显示,不破坏修订标记。
|
||||
|
||||
## 修订与批注可共存
|
||||
同一份 docx 先用 ContractEditor 做完 tracked_replace/add_clause 并 save,再在产物上跑本脚本加批注。批注的 commentRangeStart 会落在修订后的段落结构里(段首可能是 w:ins),代码已用 `(w:r, w:ins)` 兼容。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 检测已审查文件重复处理(Workflow产出双版本问题)
|
||||
|
||||
## 2026-07-13 消防设施检测合同教训
|
||||
|
||||
### 现象
|
||||
同一份合同在任务交付目录出现两个文件:
|
||||
- v1: 有WB tracked changes(正确交付物)
|
||||
- v2: 无tracked changes + 有WB批注(纯批注版)
|
||||
|
||||
### 诊断方法
|
||||
```python
|
||||
# 快速判断文件性质
|
||||
with zipfile.ZipFile(filepath, 'r') as z:
|
||||
doc_xml = z.read('word/document.xml')
|
||||
# 检查tracked changes
|
||||
ins_count = doc_xml.count(b'w:ins')
|
||||
del_count = doc_xml.count(b'w:del')
|
||||
# 检查批注
|
||||
has_comments = 'word/comments.xml' in z.namelist()
|
||||
if has_comments:
|
||||
comments = z.read('word/comments.xml')
|
||||
comment_count = comments.count(b'w:comment ')
|
||||
|
||||
print(f"INS: {ins_count}, DEL: {del_count}, Comments: {comment_count}")
|
||||
```
|
||||
|
||||
### 判断标准
|
||||
| 文件状态 | 性质 | 应否保留 |
|
||||
|----------|------|----------|
|
||||
| 有INS/DEL + 无comments | 标准修订版 | ✅ 正确交付物 |
|
||||
| 有INS/DEL + 有comments | 修订+批注版 | ✅ 正确 |
|
||||
| 无INS/DEL + 有comments | 纯批注版 | ⚠️ 需审查批注合规性 |
|
||||
| 无INS/DEL + 无comments | 原文副本 | ❌ 不应在交付目录 |
|
||||
|
||||
### 纯批注版的审查要点
|
||||
- 是否违反"能改就不批注"原则
|
||||
- 批注立场是否正确(站甲方)
|
||||
- 是否属于"提醒性批注"(禁止)
|
||||
- **严重错误示例**:Comment 203建议"违约金偏高,建议设上限"——这是在帮乙方限制甲方的违约金权利,立场完全反了
|
||||
|
||||
### python-docx的.text陷阱
|
||||
`paragraph.text`不反映批注内容。两份文件的`.text`可能100%相同但实际一份有6条批注。**判断文件是否相同必须检查comments.xml**。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 文件版本管理纪律(2026-07-01 总结多次返工教训)
|
||||
|
||||
## 铁律:操作前备份,操作后验证,不覆盖不重做
|
||||
|
||||
### 1. 操作前必须备份
|
||||
任何对 docx 文件的修改操作前,先 `cp` 一份到 `/tmp/contract-backup/` 并带时间戳:
|
||||
```bash
|
||||
cp /tmp/反委托_版本1.docx /tmp/contract-backup/反委托_版本1_$(date +%H%M).docx
|
||||
```
|
||||
|
||||
2026-07-01教训:反委托代发工资协议做了7-8个版本,每次覆盖前一版,最终华诚-Z的修订痕迹差点不可恢复(在v1_doro_updated.docx中找到最后一份)。
|
||||
|
||||
### 2. 增量修复,不从头重做
|
||||
出问题时修补当前版本,不从原文件重新做一遍。重做=覆盖=丢失中间状态。
|
||||
|
||||
### 3. 操作后验证完整性
|
||||
每次修改 docx 后必须验证:
|
||||
- comments.xml:批注数量、作者、ID 是否完整(与修改前对比)
|
||||
- document.xml:tracked changes 的 author 集合是否正确
|
||||
- 文件大小:是否合理(不应比修改前小太多)
|
||||
|
||||
### 4. 中间版本命名规范
|
||||
```
|
||||
反委托_版本1_v1.docx → 第一版
|
||||
反委托_版本1_v2.docx → 第二版(不覆盖v1)
|
||||
反委托_版本1_v3.docx → 第三版
|
||||
反委托_版本1_final.docx → 确认后的最终版(覆盖上传到Nextcloud)
|
||||
```
|
||||
|
||||
### 5. Subagent 输出必须验证
|
||||
delegate_task 返回后:
|
||||
- 检查 result.status 是否 "completed"
|
||||
- 对文件类结果:用 zipfile 打开验证 comments/tracked changes 完整性
|
||||
- 不能假设 subagent 正确——它可能丢批注、改错 author、漏条款
|
||||
|
||||
## 常见覆盖事故
|
||||
|
||||
| 事故 | 根因 | 预防 |
|
||||
|------|------|------|
|
||||
| 华诚-Z修订被全部改成WB | 多次重做时每次都"统一author=WB" | 备份原始含华诚-Z的版本 |
|
||||
| 批注丢失(4条变2条) | 从头重建时没对比原文件的comments.xml | 修改后立即验证批注数量 |
|
||||
| 字体覆盖(仿宋_GB2312→仿宋) | 重做时用了错误的字体名 | 从原文件克隆rPr,不手写 |
|
||||
@@ -0,0 +1,144 @@
|
||||
# Layering WB Revisions on High-Density Tracked Changes Documents
|
||||
|
||||
## Problem
|
||||
When a document already has extensive tracked changes from another author (e.g., 华诚-Z with 170+ INS and 90+ DEL), ContractEditor's `tracked_replace` frequently fails with `ValueError: Element is not a child of this node` because the paragraph structure is heavily fragmented with interleaved `w:ins`/`w:del`/`w:r` elements.
|
||||
|
||||
## Solution: Direct lxml Operations
|
||||
|
||||
### Strategy
|
||||
Use zipfile + lxml to directly manipulate the XML instead of ContractEditor library. Three operation types:
|
||||
|
||||
### 1. Append text to existing paragraph end
|
||||
Find the paragraph, locate the last content element, and append a `w:ins` after it.
|
||||
|
||||
```python
|
||||
# Find the last non-pPr child element in the paragraph
|
||||
last_content = None
|
||||
for child in p:
|
||||
if child.tag != f'{WNS}pPr':
|
||||
last_content = child
|
||||
|
||||
# Create INS element
|
||||
ins = etree.SubElement(p, f'{WNS}ins')
|
||||
ins.set(f'{WNS}id', str(next_id))
|
||||
ins.set(f'{WNS}author', 'WB')
|
||||
ins.set(f'{WNS}date', '2026-07-02T00:00:00Z')
|
||||
|
||||
r = etree.SubElement(ins, f'{WNS}r')
|
||||
# Clone rPr from nearby run
|
||||
rpr = get_reference_rpr(p) # see below
|
||||
if rpr is not None:
|
||||
r.insert(0, copy.deepcopy(rpr))
|
||||
|
||||
t = etree.SubElement(r, f'{WNS}t')
|
||||
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
t.text = "追加的文字内容"
|
||||
```
|
||||
|
||||
### 2. Insert new paragraph (全段INS)
|
||||
Clone neighboring paragraph's pPr, create a new `w:p` with all content inside `w:ins`.
|
||||
|
||||
```python
|
||||
# Clone pPr from reference paragraph
|
||||
ref_p = paras[target_idx] # the paragraph after which to insert
|
||||
new_p = etree.Element(f'{WNS}p')
|
||||
|
||||
# Clone pPr
|
||||
ref_ppr = ref_p.find(f'{WNS}pPr')
|
||||
if ref_ppr is not None:
|
||||
new_p.append(copy.deepcopy(ref_ppr))
|
||||
|
||||
# Create INS wrapping all content
|
||||
ins = etree.SubElement(new_p, f'{WNS}ins')
|
||||
ins.set(f'{WNS}id', str(next_id))
|
||||
ins.set(f'{WNS}author', 'WB')
|
||||
ins.set(f'{WNS}date', '2026-07-02T00:00:00Z')
|
||||
|
||||
r = etree.SubElement(ins, f'{WNS}r')
|
||||
rpr = get_reference_rpr(ref_p)
|
||||
if rpr is not None:
|
||||
r.insert(0, copy.deepcopy(rpr))
|
||||
t = etree.SubElement(r, f'{WNS}t')
|
||||
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
t.text = "新增条款全文"
|
||||
|
||||
# Insert after reference paragraph
|
||||
ref_p.addnext(new_p)
|
||||
```
|
||||
|
||||
### 3. Character-level replacement within high-density paragraph
|
||||
When text to replace is inside an existing `w:ins` from another author (e.g., 华诚-Z), you need to split that ins element.
|
||||
|
||||
```python
|
||||
# Find the ins element containing target text
|
||||
for ins_elem in p.findall(f'{WNS}ins'):
|
||||
for r in ins_elem.findall(f'{WNS}r'):
|
||||
t = r.find(f'{WNS}t')
|
||||
if t is not None and t.text and old_text in t.text:
|
||||
# Split: keep text before, add WB del+ins for changed part, keep text after
|
||||
pos = t.text.index(old_text)
|
||||
before = t.text[:pos]
|
||||
after = t.text[pos + len(old_text):]
|
||||
|
||||
# Modify existing t to keep only 'before'
|
||||
t.text = before + after.replace(old_text, new_text) # simplified
|
||||
# Or split into multiple elements...
|
||||
```
|
||||
|
||||
### Getting reference rPr
|
||||
```python
|
||||
def get_reference_rpr(p):
|
||||
"""Get rPr from first non-del run in paragraph, or from 华诚-Z ins"""
|
||||
# Try plain runs first
|
||||
for r in p.findall(f'{WNS}r'):
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is not None:
|
||||
return rpr
|
||||
# Try non-WB ins elements
|
||||
for ins in p.findall(f'{WNS}ins'):
|
||||
if ins.get(f'{WNS}author') != 'WB':
|
||||
for r in ins.findall(f'{WNS}r'):
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is not None:
|
||||
return rpr
|
||||
# Try previous paragraph
|
||||
prev = p.getprevious()
|
||||
if prev is not None:
|
||||
return get_reference_rpr(prev)
|
||||
return None
|
||||
```
|
||||
|
||||
## Critical: Post-save sz fix
|
||||
|
||||
When INS runs clone rPr from paragraphs that lack explicit `w:sz` (relying on style inheritance), the INS will render at wrong size. **Always run a post-save sweep:**
|
||||
|
||||
```python
|
||||
# Determine dominant body sz from neighboring paragraphs
|
||||
# Then fix all WB INS runs missing sz
|
||||
for ins in body.iter(f'{WNS}ins'):
|
||||
if ins.get(f'{WNS}author') != 'WB':
|
||||
continue
|
||||
for r in ins.findall(f'{WNS}r'):
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is not None:
|
||||
sz = rpr.find(f'{WNS}sz')
|
||||
if sz is None:
|
||||
sz = etree.SubElement(rpr, f'{WNS}sz')
|
||||
sz.set(f'{WNS}val', dominant_sz) # e.g., '24' for 12pt
|
||||
szCs = etree.SubElement(rpr, f'{WNS}szCs')
|
||||
szCs.set(f'{WNS}val', dominant_sz)
|
||||
```
|
||||
|
||||
## Author Unification
|
||||
|
||||
After Doro reviews and confirms, unify all authors to WB:
|
||||
```bash
|
||||
python scripts/unify-author-wb.py input.docx [output.docx]
|
||||
```
|
||||
|
||||
## Lesson Learned (2026-07-02)
|
||||
- Doro will edit the files in OnlyOffice after upload. Always download Doro's version before doing further work.
|
||||
- "你自己要满意再给我" = self-verify before delivery, don't ask user to check.
|
||||
- "认真做" = thoroughness signal. Read full contract text, verify each modification landed correctly.
|
||||
- When Doro says "看看是否还有需要调整的" = compare your version vs Doro's, identify what Doro changed, assess if further work needed.
|
||||
- Unifying author is a standard final step — use the script, don't hand-code each time.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: lawyer-letter-formatting
|
||||
description: 律师函制作格式要点。基于Watson&Band模板,logo在正文段落anchor中而非header XML。
|
||||
tags: [legal, lawyer-letter, docx, formatting]
|
||||
---
|
||||
|
||||
# 律师函制作
|
||||
|
||||
## 关键格式(参考_律师函模板)
|
||||
- **字体**:仿宋 12pt,西文Times New Roman
|
||||
- **首行缩进**:304800 EMU
|
||||
- **行距**:1.25倍
|
||||
- **对齐**:两端对齐(JUSTIFY)
|
||||
- **列表编号**:numbering.xml中japaneseCounting格式(第一、第二、第三、)
|
||||
- **送达信息**:9pt
|
||||
|
||||
## 关键陷阱
|
||||
- **Logo不在header XML中**!是作为浮动锚点(anchor drawing)嵌在正文第一段落的run中
|
||||
- 用python-docx重建段落会丢失drawing元素,必须从模板段落提取保留
|
||||
- 复制模板时要保留原始段落的XML结构,不能只复制文字
|
||||
|
||||
## 参考文件位置
|
||||
- 模板:Doro诉讼案件任务/参考文件/_律师函
|
||||
|
||||
## 交付位置
|
||||
- 放到 Doro其他任务/交付文件/(不是待处理任务)
|
||||
- 交付后@doro通知
|
||||
@@ -0,0 +1,68 @@
|
||||
# 在「用户已自行修订过」的合同上叠加我方修订
|
||||
|
||||
实战来源:金信大厦5层东部租赁合同(2026-06-25)。Maggie 本人已用修订模式改了 6 处(author="maggie jia"),要求小Maggie 在此基础上**再补几处**(模版比对后补不可抗力对等、装修残值公式、抵押救济),**保留她的全部修订一字不动**。
|
||||
|
||||
## 何时用本配方
|
||||
- 收到的 docx **已带 track changes**(settings.xml 有 `<w:trackRevisions/>`,文中有 author≠WB/小Maggie 的 w:ins/w:del)。
|
||||
- 任务是**在用户既有修订之上追加几处**,不是重审、不是从干净稿做。
|
||||
- **不重跑 workflow,也不用 ContractEditor 库**——库的字符级 diff 引擎会把用户既有 w:ins/w:del 卷进来重算,破坏其修订。一律 zipfile+lxml 直接追加节点。
|
||||
|
||||
## 五步配方
|
||||
|
||||
### 1. 新修订 id 从 `maxid+1000` 起,防撞 + 便于事后过滤
|
||||
```python
|
||||
maxid = 0
|
||||
for el in root.iter():
|
||||
if el.tag in (Wq+"ins", Wq+"del"):
|
||||
v = el.get(Wq+"id")
|
||||
if v and v.isdigit(): maxid = max(maxid, int(v))
|
||||
nextid = [maxid + 1000] # 1000 间隔:本次新增 id 全 >1000,过滤/核验时一眼区分
|
||||
def newid(): nextid[0]+=1; return str(nextid[0])
|
||||
```
|
||||
为什么 +1000 不是 +1:核验「我的修订」与「用户的修订」时,`int(id)>1000` 直接切分两批,不必记具体数字。
|
||||
|
||||
### 2. 作者:沿用文档既有修订线,不强行套 WB
|
||||
金信大厦案文档既有修订 author="maggie jia",本次追加**沿用同一 author**(保持修订线一致、Maggie 看就是「她那条线的延续」)。
|
||||
> 注意与「author 铁律=WB」的边界:WB 是 Doro 体系合同审查的署名;当**文档已有用户自己的修订线**、任务是「在她的修订上接着改」时,沿用她的 author 让修订归并到同一作者更自然。归属按文档既有线定,不是无脑套 WB。拿不准就问。
|
||||
|
||||
### 3. rPr:克隆「用户已渲染正确的 INS」当样板,预防中文字体回退坑
|
||||
不要自己造 rPr。找一个用户已有的、**中文显示正常的** w:ins run,读它的 rPr 当模板:
|
||||
```python
|
||||
# 金信大厦案模板:<w:rFonts w:ascii="Times New Roman" w:eastAsiaTheme="minorEastAsia"
|
||||
# w:hAnsi="Times New Roman" w:cs="Times New Roman" w:hint="eastAsia"/>
|
||||
# <w:sz w:val="21"/><w:szCs w:val="21"/>
|
||||
def make_rpr():
|
||||
rpr = etree.Element(Wq+"rPr")
|
||||
rf = etree.SubElement(rpr, Wq+"rFonts")
|
||||
rf.set(Wq+"ascii","Times New Roman"); rf.set(Wq+"eastAsiaTheme","minorEastAsia")
|
||||
rf.set(Wq+"hAnsi","Times New Roman"); rf.set(Wq+"cs","Times New Roman"); rf.set(Wq+"hint","eastAsia")
|
||||
sz = etree.SubElement(rpr, Wq+"sz"); sz.set(Wq+"val","21")
|
||||
etree.SubElement(rpr, Wq+"szCs").set(Wq+"val","21")
|
||||
return rpr
|
||||
```
|
||||
`eastAsiaTheme="minorEastAsia"+hint="eastAsia"` 让中文走主题回退(金信大厦回退到宋体),西文 Times New Roman——这是这类合同 INS 中文正常显示的关键,详见 SKILL.md「INS 中文字体」节。
|
||||
|
||||
### 4. 三种插入机制(按改动类型选)
|
||||
- **纯追加**(句末补一句救济/公式):定位段落最后一个 normal run / 最后一个 ins,`last.addnext(make_ins(text, rpr))`。
|
||||
- **删一段换对等表述**(单向条款改双向):split 原 run → 原 run 文本保留前半、`addnext(make_del(后半, 原rpr))` → 再 `del.addnext(make_ins(对等表述, rpr))`。
|
||||
- **替换数值/词**(6→12 个月、percent→百分之):字符级定位,DEL 旧 + INS 新。
|
||||
|
||||
`make_del` 用 `<w:del><w:r><w:delText>`、克隆原 run 的 rPr 加 `rsidDel`;`make_ins` 用 `<w:ins><w:r><w:t xml:space="preserve">`。
|
||||
|
||||
### 5. 只换 document.xml(settings 已开 trackRevisions 就不动它)
|
||||
```python
|
||||
with zipfile.ZipFile(SRC) as zin, zipfile.ZipFile(tmp,"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))
|
||||
```
|
||||
|
||||
## 四查验证(缺一不可)
|
||||
1. **python-docx 能打开**(`Document(out)`)——XML 合法。
|
||||
2. **接受所有修订后文本正确**——抽出「保留 ins 内容、丢弃 del 内容」的纯文本,逐处核我改的几段语句通顺、内容对。
|
||||
3. **本次新增 INS(id>1000)含中文 run 字体非 Times New Roman**——`eastAsiaTheme=="minorEastAsia" or (ea and ea!="Times New Roman")` 应全真。
|
||||
4. **🔴 用户原有修订逐 id 比对一字未动**——把源文件与产物里 `id<=maxid` 的所有 ins/del 提成 `(id, tag, author, 文本)` 排序比对,必须**完全相等**。这是本配方的核心安全验证:证明我只追加、没碰用户的任何一处。
|
||||
|
||||
## 收口(与库路径相同)
|
||||
`scripts/accept-revisions-preview.py` 生成干净版 → OnlyOffice 渲染 → vision 视觉验收。
|
||||
- **vision 报「页底某句截断」先分清 PDF 分页 vs 真丢数据**:金信大厦案 vision 报抵押救济句在 P7 底部截断,实为该句跨页接到 P8 开头——①数据层读该 INS 完整内容在;②P7+P8 拍平后 grep 完整句存在 → 确认是 PDF 分页跨页,OnlyOffice 滚动查看正常,**不返工**。判据同 contract-portfolio-analysis Pitfall:数据层完整+跨页搜得到=分页现象。
|
||||
- vision 对字体/‰%/小符号的误判同样适用——回数据层核,别据像素返工(见 SKILL.md「交付前视觉验收的两个已知误判」)。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 法律文书:脚注、同源模板成稿、Doro 编辑后字体修复
|
||||
|
||||
contract-editor 库(zipfile+lxml)在**新成稿文书**(非修订态)上的复用。2026-06-23-24 邹家《情况反映》制作中验证。配套 `litigation-doc-tracked-changes.md`(那篇讲修订态;本篇讲脚注+新建文书+字体规范化,都不是 tracked-changes)。
|
||||
|
||||
## 1. 法条原文脚注——从同案"姊妹文书"克隆脚注样式(铁律:脚注样式不要凭空造)
|
||||
|
||||
需求场景:Doro 把正文里的法条引用("《民事诉讼法》第七十一条之规定")要求改成**脚注呈现法条全文**,且"脚注格式和申请书一样"。
|
||||
|
||||
正确做法是从**同案已有带脚注的文书**(如同目录的《民事诉讼监督申请书》v9)克隆脚注体例,而不是自己拼 footnotes.xml:
|
||||
|
||||
**脚注的两个组成**(先从姊妹文书读出样式模板):
|
||||
- 正文里的**引用标**:一个 `<w:r>`,rPr 带 `<w:rStyle w:val="affb"/>` + Times New Roman + 与正文同字号(sz24),内含 `<w:footnoteReference w:id="N"/>`。`affb` 是 Word 默认的 FootnoteReference 字符样式 id(不同文档可能不同,**从姊妹文书正文的 footnoteReference 承载 run 实测,别硬编码**)。
|
||||
- footnotes.xml 里的**脚注正文**:separator/continuationSeparator 两个特殊脚注(id=-1/0,原样复制)+ 内容脚注(id≥1)。内容脚注段落 spacing line=240,run 字号是**脚注体例 sz18(9pt,小于正文)**,法名加粗(`<w:b/>`)、条号与原文不加粗。
|
||||
|
||||
```python
|
||||
# 从姊妹文书 sqs_v9.docx 取模板
|
||||
fn_sqs = etree.fromstring(z_sqs.read('word/footnotes.xml'))
|
||||
special = {ft: deepcopy(f) for f in fn_sqs.iter(qn('footnote'))
|
||||
for ft in [f.get(qn('type'))] if ft in ('separator','continuationSeparator')}
|
||||
content_tmpl_p = deepcopy([f.find(qn('p')) for f in fn_sqs.iter(qn('footnote'))
|
||||
if f.get(qn('id'))=='1'][0])
|
||||
# 从模板段落抽三种 rPr:mark(带rStyle affb)、bold(法名)、plain(原文)
|
||||
# 正文引用标 rPr 则从姊妹文书 document.xml 里 footnoteReference 承载 run 抓
|
||||
```
|
||||
|
||||
**目标 docx 必须已支持脚注**:`word/_rels/document.xml.rels` 有 footnotes 关系、`[Content_Types].xml` 有 `footnotes+xml`、styles.xml 有 `affb` 样式。若目标是从同源文书演化来的(本例情况反映以申请书为母版),这三样天然齐全;若从零新建则要补。
|
||||
|
||||
## 2. 脚注标定位的坑:锚点跨 run 时 footnoteReference 会插错位置(本会话实犯)
|
||||
|
||||
把脚注标插在"第五十一条第二款"之后时,第一版用"找锚点子串→定位锚点所在 run→run 后插标",结果 ³ 插到了下游的"承办部门"后面——因为锚点文字**跨多个 run**,按 run 粒度定位会落到错误的 run。
|
||||
|
||||
**正解:字符流定位 + 必要时拆 run**。把全段所有 `w:t` 拼成字符流,建立 `每个字符→(t元素, 字符在t内的索引)` 映射,找到锚点**结束字符**的精确位置;若结束字符在某 run 中间,**split 该 run**(head 留原 run,tail 进新 run),脚注引用 run 插在 head 和 tail 之间。这样标精确落在"…第二款【标】所定…"。
|
||||
|
||||
验证只能靠 OnlyOffice x2t 渲染后看页脚——vision 一眼就抓出"³ 标在承办部门后",肉眼读 XML 容易漏。体例统一:四个脚注一律"法条号正后方"挂注(不要有的挂句末有的挂条号后)。
|
||||
|
||||
## 3. 用同案文书做"母版"新建文书——保证两份同源同体例
|
||||
|
||||
新建《情况反映》时,以同案《民事诉讼监督申请书》v9 为母版克隆,确保字体/字号/页边距/样式完全一致(Doro/Maggie 两份并排看不会有体例差):
|
||||
- 从母版抽各类段落模板:title(居中bold sz30)、body(首行缩进480 sz24)、recip(顶格bold 机关名)、sign(右对齐)、date、attachment-title、attachment-item。`mk(tmpl_key, text, bold=, no_indent=)` 克隆模板段→清空 run/numPr/ins/del→重设仿宋+Times→填文字。
|
||||
- 用母版的整个 docx 做容器(保留 sectPr 页面设置、styles、numbering),只重写 body 的段落序列 + 清掉 `<w:trackRevisions/>`。
|
||||
- **清掉母版页眉**:母版页眉可能是另一种文书的抬头(本例申请书页眉"申请监督案号/受理法院"套在情况反映上不对)。清页眉要两步:①删页眉段所有 run 文字;②**删页眉段 pPr 的 `<w:pBdr>`**(页眉那条横线来自段落下边框,只删文字会留一条孤线)。OnlyOffice 渲染确认顶部纯白到标题。
|
||||
|
||||
## 4. ⚠️Doro 用编辑器改过的 docx 会丢显式 eastAsia 字体属性——每轮都要补(本会话两轮各犯一次)
|
||||
|
||||
**现象**:Doro 在他本机编辑器改过 docx 后回传,正文中文 run 的 `rFonts` **没有显式 eastAsia 字体名**(本会话两轮分别 1283、1243 个中文字符 `eastAsia=None`,docDefaults 也空)。OnlyOffice 靠底层回退仍渲染成仿宋、肉眼看正常,但**显式字体属性缺失不符合交付标准**(我们要求中文显式仿宋)。
|
||||
|
||||
**判别**:交付前扫一遍——
|
||||
```python
|
||||
for r in root.iter(qn('r')):
|
||||
rf = r.find(qn('rPr/rFonts'))
|
||||
ea = rf.get(qn('eastAsia')) if rf is not None else None
|
||||
# 统计含中文 run 里 ea is None 的数量;>0 就要补
|
||||
```
|
||||
|
||||
**修复(格式规范化,不改字形/文字/Doro 内容)**:每个含文字的 run,rFonts 显式设 `eastAsia=仿宋`,缺 ascii/hAnsi 的补 `Times New Roman`;再给 `docDefaults/rPrDefault/rPr/rFonts` 补 `eastAsia=仿宋` 兜底。改完目标:CJK 全仿宋、英数全 Times。**这是和合同字体规范化同类的操作,但要点在"每次 Doro 回传都要重做一遍"**——他的编辑器每改一次就再剥一次,不是一次性问题。改完必须 OnlyOffice 重渲染确认无字形回退(字体属性动过就要重验)。
|
||||
|
||||
## 5. 附件/正文 Doro 自己加的内容:修错别字但不擅改实质
|
||||
|
||||
Doro 自己在附件加了"检查监督申请书"——①错别字"检**查**"→"检**察**"院的监督,规范名应是与正式文件名一致的《民事诉讼监督申请书》(改);②但若附件项之间有实质区分缺失(如两份《质证通知书》一份标了"3日期限版"另一份没标"15日期限版"),那是 Doro 定的内容,**只提示不擅改**。附件清单常是自动编号(numId),Doro 删手敲序号是对的,自动会续 1-6。
|
||||
|
||||
## 一句话
|
||||
脚注从同案姊妹文书克隆样式(rStyle affb + sz18 脚注体、法名加粗)、标位置用字符流+拆run精确落在条号后;新建文书拿同案文书做母版保同源(清页眉含删 pBdr);**Doro 编辑器回传的 docx 每轮都丢显式 eastAsia,每次交付前都要全局补仿宋再渲染**。
|
||||
@@ -0,0 +1,73 @@
|
||||
# 诉讼文书:法条原文脚注 + 母版克隆建新文书 + 字体规范化
|
||||
|
||||
contract-editor 库在诉讼文书上的三组技法,2026-06-23/24 邹家「情况反映」(给法院监督部门的程序违法反映材料)制作中验证,全部 OnlyOffice x2t 渲染逐页核对过。与 `litigation-doc-tracked-changes.md`(修订态技法)互补——本文是**脚注 + 新建文书 + 字体**层面。
|
||||
|
||||
---
|
||||
|
||||
## 一、法条原文脚注(Doro 偏好:引用法律规定一律用脚注呈现原文,不删改不概括)
|
||||
|
||||
Doro 对引用法条的文书要求:**法条原文(一字不改、不归纳)用脚注方式写进去**。监督申请书 v9 已是这个体例,情况反映照搬。这是可复用的整套做法。
|
||||
|
||||
### 1. 脚注格式从同族已有文书克隆,不自造
|
||||
申请书 v9 的 `word/footnotes.xml` 是现成模板。提取三样:
|
||||
- **两个特殊脚注** `type=separator` / `type=continuationSeparator`(id=-1/0)——分隔线,照搬。
|
||||
- **一个内容脚注的段落骨架**(id=1 的 `<w:p>`)——拿它的 `pPr`(脚注段 `spacing line=240`)和三种 run 的 `rPr` 模板:
|
||||
- **mark rPr**:带 `<w:rStyle w:val="affb"/>` + Times New Roman + **sz18**(9pt 脚注体,不是正文 sz24)——脚注区那个序号。
|
||||
- **bold rPr**:`<w:b/>` + sz18——法名加粗用。
|
||||
- **plain rPr**:sz18 无 rStyle 无 b——条号+原文用。
|
||||
- 正文里的脚注引用标(`footnoteReference` 承载 run)的 rPr 另取:从 v9 **正文** 里找 `r/footnoteReference` 那个 run 的 rPr(`rStyle=affb` + Times + **sz24**,跟正文同号,上标由 affb 样式控制)。
|
||||
|
||||
每条脚注 `<w:footnote id=N>` 段落结构:`[footnoteRef(mark rPr)][空格(plain)][法名(bold rPr)][条号+原文(plain rPr)]`。条号与原文之间用**全角空格**(如「第七十一条 证据应当…」)。
|
||||
|
||||
### 2. 目标 docx 已支持脚注则零配置
|
||||
情况反映是从 v9 编辑来的,本就带 `word/footnotes.xml`、rels 里有 footnotes 关系、`[Content_Types].xml` 有 `footnotes+xml`、styles.xml 有 `affb` 样式——直接覆盖 footnotes.xml + 在 document.xml 插引用标即可,**不用补 rels/CT/style**。动手前先 grep 确认这四样齐全;若是从无脚注的 docx 起步,才需要补全四处。
|
||||
|
||||
### 3. 插入引用标的位置铁律 + 多 run 锚点陷阱(本次踩坑)
|
||||
脚注上标要紧贴**法条号正后方**(如「第七十一条¹之规定」「第五十一条第二款³所定」),不要落在句末或下游词上。统一体例:四个脚注全部「条号后挂注」最整齐。
|
||||
|
||||
**陷阱**:`第五十一条第二款` 这种锚点在 docx XML 里常**跨多个 w:r**(编号、款号被拆在不同 run)。若按"找到 anchor 所在 run、在该 run 后插引用"的粗定位,会把上标插到 anchor **下游某个 run 后**——本次 ³ 错插到了「承办部门」后(隔了好几个词)。OnlyOffice 渲染出来才发现,vision 核对抓到的。
|
||||
|
||||
**正解:字符流 + split run 精确定位**:
|
||||
```python
|
||||
# 1) 拼接段落所有 w:t 成 full,建 map: full每个字符 -> (t_element, idx_in_t)
|
||||
# 2) end = full.find(anchor) + len(anchor) - 1 # 锚点最后一个字符
|
||||
# 3) t_end, k_end = map[end];把 t_end 文本 split:head=s[:k_end+1], tail=s[k_end+1:]
|
||||
# 4) t_end.text=head;在 t_end 所在 run 之后 addnext 一个新 run(脚注引用);
|
||||
# 若 tail 非空,再 addnext 一个同 rPr 的 run 承载 tail
|
||||
```
|
||||
这样上标精确落在锚点最后一字之后,不受 run 边界影响。容错:`第五十一条第二款` 找不到时退化找 `第五十一条`。
|
||||
|
||||
### 4. 款数存疑时,脚注放全条原文
|
||||
Doro 引「第五十一条**第二款**」,但权威原文里"普通程序不少于十五日"实际在**第一款**。**不擅改他的款数**——脚注内容放该条**全文(含两款)**,无论款数对错,原文都完整覆盖、不断章;款数是否要改回原文里报给 Doro 定,不自己动。
|
||||
|
||||
---
|
||||
|
||||
## 二、母版克隆建新诉讼文书(保证与同案既有文书同源)
|
||||
|
||||
新建一份配套文书(情况反映 vs 已有的监督申请书),要让字体/字号/页边距/样式与同案既有文书**完全同源**——直接拿那份已交付的 docx 当母版。
|
||||
|
||||
- **段落模板克隆**:从母版 body 抓代表性段落各一份 deepcopy 当模板——title(居中 bold sz30)、body(首行缩进 fl480 sz24)、recip(机关名顶格 bold sz24)、sign(右对齐 sz24)、date(右对齐)、attt(附件标题顶格 bold)、att(附件项 fl480)。`mk(模板, 文本, bold, no_indent)`:克隆模板→清空其 run/ins/del→(按需删 numPr/ind)→`force_font`(eastAsia=仿宋, ascii/hAnsi=Times)→写新 run。
|
||||
- **清空原 body 段落**,把新段落 insert 到 `sectPr` 之前(保页边距/分节设置不变)。
|
||||
- **关 trackRevisions**:新建文书是全新成稿、非修订态,settings.xml 删 `<w:trackRevisions/>`。
|
||||
- **页眉错配必须清**(本次踩坑):母版(监督申请书)的 `header1.xml` 带"申请监督民事诉讼案号/受理法院"这种**本文书类型专属抬头**,套到情况反映上不对路。处理:清空 header 所有 run 的文字。
|
||||
- **页眉横线 = pBdr,单清文字不够**:清了页眉文字后 OnlyOffice 仍渲出一条横线——来自页眉段落的 `<w:pBdr>`(段落下边框)。遍历 header 所有 `pPr` 删 `pBdr`(本例 2 段),并去掉可能带边框的 `pStyle` 引用。styles.xml 里的 Header 样式若也挂 pBdr 一并清。重渲确认顶部纯白到标题。
|
||||
|
||||
---
|
||||
|
||||
## 三、字体规范化:源文档丢了显式 eastAsia 字体
|
||||
|
||||
**症状**:用户编辑过的 docx,正文中文 run 的 `rFonts` **没有 eastAsia 属性**(eastAsia=None),docDefaults 也没设。OnlyOffice 靠底层回退仍渲成仿宋,但**显式字体属性缺失**不符合"中文必须显式仿宋"的交付标准。本次 Doro 改的情况反映 1283 个中文字符全是 eastAsia=None。
|
||||
|
||||
**判断边界(重要)**:先比对**用户原版**——若原版本就是 eastAsia=None(不是你的编辑引入的),补齐属于**格式规范化(不改字形、不改一个文字、不动他的内容编辑)**,与历史上的字体规范化同类,可做。若是你的操作把字体搞丢的,那是 bug 要修源头。
|
||||
|
||||
**修法**:遍历所有含文字的 run,`rFonts` 设 `eastAsia=仿宋`,缺 ascii/hAnsi 则补 Times New Roman;再给 `docDefaults/rPrDefault/rPr/rFonts` 补 eastAsia=仿宋 兜底。改完 OnlyOffice **重渲**确认无字形回退(字体改动必重渲,vision 核"全文仿宋、无方框、无回退乱码")。核验:zipfile 统计 CJK→仿宋、LATIN→Times New Roman 计数全覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 验收三件套(脚注版)
|
||||
1. **正文脚注引用数** == 预期(`sum(r.find(footnoteReference) for r in runs)`)。
|
||||
2. **footnotes.xml 内容数** == 引用数,逐条 print 前 50 字核法名+条号+原文。
|
||||
3. **OnlyOffice x2t 渲染逐页 vision 核**:每个上标在**正确法条号正后方**(重点查多 run 锚点那条没错位)、页脚脚注区原文完整无截断、脚注字号 < 正文、法名加粗、无乱码。脚注主要落在前两页,逐页都要看。
|
||||
|
||||
## 一句话
|
||||
法条脚注:格式克隆同族文书的 footnotes.xml(separator+内容模板,mark/bold/plain 三 rPr),引用标用 split-run 精确插在条号后(多 run 锚点必踩坑),款数存疑放全条原文不擅改。建新文书:克隆母版段落模板保同源,清错配页眉+pBdr 横线。字体:源档丢 eastAsia 时补齐属于规范化(先确认是原档状态不是自己搞丢的),改完必重渲。
|
||||
@@ -0,0 +1,74 @@
|
||||
# 诉讼文书的修订态技法(contract-editor 库在合同以外文书上的复用)
|
||||
|
||||
ContractEditor 库不止用于合同——审/改诉讼文书(监督申请书、起诉状、答辩状等)同样适用。本文记录 2026-06-23 邹家民事诉讼监督申请书审改中验证过的几招,都用 OnlyOffice x2t(Doro/Maggie 实际引擎)渲染核对过。
|
||||
|
||||
## 1. 大段改写用「整块 del+ins」,不用字符级 diff(markup 可读性铁律)
|
||||
`tracked_replace` 是字符级 diff(CJK 每字一 token + difflib)——**补字/小改**(错别字、补一个"在"字、称谓换词)用它,markup 干净。
|
||||
但**大段改写**(整句重写、换论证)若用字符级 diff,新旧文本大量字符重合,markup 会交错成一团("未经~~及~~法庭审理""一百二十八条~~切~~国家机关"),Doro 在 OnlyOffice 看修订态根本读不下去。**接受修订后的最终文本虽正确,但修订态不可读 = 不合格交付**(Doro 有格式洁癖,看的就是 markup)。
|
||||
- **正解**:对整句/整段改写,做「整块删 + 整块插」——`[<w:del>旧整句</w:del>][<w:ins>新整句</w:ins>]`,markup 显示为一条删除线旧句紧跟一条下划线新句,清清楚楚。
|
||||
- 实现:复制 `tracked_replace` 的定位逻辑,但不跑 difflib,直接 `_mk_del(old_text)` + `_mk_ins(new_text)` 整块插。判据:**新旧文本相似度高、改动跨度大 → 整块;纯增删几个字 → 字符级**。
|
||||
|
||||
## 2. 称谓/词替换也要整词块替换,别让共享字符碎裂
|
||||
把"法**庭**"改"莲都法**院**"时,"法"字共享,字符级 diff 会渲染成"莲都法~~庭~~院"(接受后对,markup 脏)。
|
||||
- **正解**:整词 `tracked_block_replace("本案法庭向申请人送达", "莲都法院向申请人送达")` → markup 是干净的[删旧短语][插新短语]。
|
||||
- 同理坑:替换前先分类全文每处目标词——actor 指代(要改)vs 法条/术语原文(如"法庭审理""在法庭上出示"=不能动)。grep 出所有命中,逐个判,别一刀切 replace_all。
|
||||
|
||||
## 3. 整段删除(让自动编号重排)——库没有,需自加 `tracked_delete_paragraph`
|
||||
合并两个自动编号请求项(删一项、后项自动续号)时,要的是**段落级修订删除**:段内每个 run 包进 `<w:del>`,**且段落标记也要标删**——在 `pPr/rPr` 里插一个 `<w:del>`。这样接受修订后整段连段落标记一起消失,自动编号从 一二三四 重排成 一二三。
|
||||
```python
|
||||
def tracked_delete_paragraph(self, search_text):
|
||||
p = self.find_para(search_text)
|
||||
for r in list(p.findall(qn('r'))):
|
||||
# 每个run的w:t搬进新建<w:del><w:r><w:delText>
|
||||
...
|
||||
ppr = p.find(qn('pPr')) or 新建
|
||||
rpr = ppr.find(qn('rPr')) or 新建
|
||||
rpr.insert(0, <w:del author=... date=...>) # 段落标记删除标记
|
||||
```
|
||||
缺了"段落标记删除"那一步,接受后会残留一个空的编号项。
|
||||
|
||||
## 4. 半角括号→全角:改 numbering.xml 的 lvlText,一次性根治
|
||||
子标题 `(一)(二)…` 是自动编号时,半角括号来自 `word/numbering.xml` 里 `<w:lvlText w:val="(%1)">`。逐段改文档没用(那是渲染出来的)。
|
||||
- **正解**:遍历 numbering.xml 所有 `<w:lvlText>`,`val` 里的 `(`→`(`、`)`→`)`,一次改全文所有同源编号。本例 37 处 lvlText 一次改完。
|
||||
- 注意只动含 `()` 的 lvlText,`、`分隔的(如请求"一、二、三"用 `%1、`)不受影响。
|
||||
|
||||
## 5. 引号"统一为仿宋全角"——根因是引号 run 的字体不是中文字体
|
||||
现象:正文中文是仿宋(继承样式),但弯引号 `“”`(U+201C/U+201D) 的 run `ascii=Times New Roman, eastAsia=None`。因为弯引号是**中西文模糊字符**,OnlyOffice 对没有 eastAsia 设定的字符按 ascii 字体渲染 → 引号显示成西文 Times 的粗重样式,和仿宋正文不协调。
|
||||
- **正解(彻底版)**:对**纯引号/中文 run**,把 rFonts 的 `ascii/eastAsia/hAnsi/cs` 全设为「仿宋」+ `hint="eastAsia"`,消除歧义。对**引号+数字混排 run**(如 `“2026…`),按字符**拆 run**:引号段走仿宋、数字段保留 Times New Roman。
|
||||
- 只设 eastAsia 不够稳——某些渲染下仍可能按 ascii 走 Times。纯引号 run 连 ascii 一起设仿宋最保险(数字 run 才需要保留 Times)。
|
||||
- 核对:OnlyOffice x2t 渲染后裁剪含引号区域,确认引号纤细、与仿宋协调(不是又粗又重的衬线引号)。
|
||||
|
||||
## 6. 验证三件套(同合同终审,文书一样适用)
|
||||
- `ed.validate()` 返回空。**注意**:诉讼文书的「请求项」原文常是加粗的(与合同正文不加粗体例不同),validate 的"不应加粗"规则会**误报**——先读原文该段普通 run 的 `<w:b>` 状态,若原文请求项本就加粗、INS 继承同样加粗=格式一致=误报,可放行。
|
||||
- 逐个 `w:ins` 核 author 正确(诉讼文书署当前文书归属人,如本例 Doro 文书上署"小Maggie"修订;合同历史署"WB"——按文书归属定)、eastAsia 字体不缺。
|
||||
- OnlyOffice x2t 渲两版:**修订态**(看 markup 干净)+ **接受态**(删 w:del、解包 w:ins、删段落标记被删的空段后重渲,看编号连续、全角括号生效、无乱码)。接受态自己生成:解包所有 ins、删所有 del、删 numPr 空段。
|
||||
|
||||
## 一句话
|
||||
合同库的修订能力对所有 docx 文书通用;诉讼文书审改的差异点是:①大改写要整块 del+ins 保 markup 可读 ②引号/括号这类「字体/编号源」问题改 styles/numbering 层不改文档层 ③validate 加粗规则对加粗请求项会误报。
|
||||
|
||||
## 7. 接受所有修订 → 干净版 docx(反向操作,2026-06-24 徐函任务验证)
|
||||
用户给一份**带修订痕迹+批注**的 docx,要「先接受现有修订、让我看干净版本」时——不是用 Word 手点"接受全部",用 zipfile+lxml 一次处理:
|
||||
```python
|
||||
W='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
def w(t): return f'{{{W}}}'+t
|
||||
# 1) w:del → 整个元素删掉(连 delText 一起没)
|
||||
for d in root.findall('.//'+w('del')): d.getparent().remove(d)
|
||||
# 2) w:ins → 解包:用其子元素替换它本身(保留插入内容,去掉ins包裹)
|
||||
for ins in root.findall('.//'+w('ins')):
|
||||
parent=ins.getparent(); idx=list(parent).index(ins)
|
||||
for child in reversed(list(ins)): parent.insert(idx, child)
|
||||
parent.remove(ins)
|
||||
# 3) 属性变更追踪一并清:pPrChange/rPrChange/sectPrChange/tblPrChange/tcPrChange/trPrChange
|
||||
for tag in ('pPrChange','rPrChange','sectPrChange','tblPrChange','tcPrChange','trPrChange'):
|
||||
for el in root.findall('.//'+w(tag)): el.getparent().remove(el)
|
||||
# 4) 批注三处一起拆(用户要"干净版"= 连批注也清):
|
||||
# document.xml: 删 commentRangeStart/End,删含 commentReference 的整个 run
|
||||
# settings.xml: 删 <w:trackRevisions/>(让文件退出跟踪模式)
|
||||
# 打包时跳过 word/comments*.xml,并从 [Content_Types].xml 和 document.xml.rels 删 comments 的 Override/Relationship
|
||||
```
|
||||
要点:
|
||||
- **w:del 删整块、w:ins 解包**——方向别搞反(del 是要丢弃的,ins 是要保留的)。
|
||||
- **务必清 settings.xml 的 trackRevisions**,否则文件仍处于"跟踪修订"模式,用户继续编辑会又开始记修订。
|
||||
- **批注要三处协同删**(comments.xml 本体 + document.xml 的 range/reference 锚点 + Content_Types/rels 注册),漏一处 OnlyOffice/Word 打开可能报损坏。
|
||||
- 验证:解包后 `root.findall('.//w:ins')`/`w:del`/`w:commentReference` 全为 0;`'word/comments.xml' in zip.namelist()` 为 False;python-docx 能打开;OnlyOffice x2t 渲染核对无修订痕迹无批注无错位。
|
||||
- vision 核干净版时顺带抓**残留内部标记**:黄色高亮(内部校对标记)、留白占位(编号"第 号"、日期" 日")、标题英文双连字符`--`应为中文破折号`——`——这些不是修订痕迹但属"未清的内部审核稿"特征,正式交付前要清。
|
||||
@@ -0,0 +1,126 @@
|
||||
# lxml XML Declaration Fix for docx Files
|
||||
|
||||
## Problem (2026-07-01, 劳务派遣协议案)
|
||||
|
||||
When lxml serializes XML (via `etree.tostring()` or python-docx's `Document.save()`), it outputs:
|
||||
- **Single-quote** XML declaration: `<?xml version='1.0' encoding='UTF-8' standalone='yes'?>`
|
||||
- **LF** line endings (`\n`)
|
||||
|
||||
Original docx files (created by Word/WPS/OnlyOffice) use:
|
||||
- **Double-quote** XML declaration: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
|
||||
- **CRLF** line endings (`\r\n`)
|
||||
|
||||
**OnlyOffice cannot open docx files with single-quote XML declarations.** The file appears structurally valid (ZIP ok, XML parses fine, python-docx loads it, even x2t can convert it to PDF), but the OnlyOffice web editor refuses to open it.
|
||||
|
||||
## Affected Files
|
||||
|
||||
Only XML files that were **re-serialized by lxml** are affected. In a typical ContractEditor workflow:
|
||||
- `word/document.xml` — always re-serialized (main editing target)
|
||||
- `word/settings.xml` — re-serialized if trackRevisions was added/modified
|
||||
|
||||
Other XML files (styles.xml, fontTable.xml, theme1.xml, etc.) that were read and written back unchanged via `zipfile` retain their original format.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
```python
|
||||
import zipfile
|
||||
|
||||
def check_docx_xml_format(docx_path):
|
||||
"""Check if any XML files have problematic single-quote declarations."""
|
||||
issues = []
|
||||
with zipfile.ZipFile(docx_path) as z:
|
||||
for name in z.namelist():
|
||||
if name.endswith('.xml') or name.endswith('.rels'):
|
||||
data = z.read(name).decode('utf-8')
|
||||
first_line = data.split('\n')[0]
|
||||
has_single_quotes = "version='1.0'" in first_line
|
||||
has_lf_only = '\r\n' not in data[:200]
|
||||
if has_single_quotes or has_lf_only:
|
||||
issues.append((name, has_single_quotes, has_lf_only))
|
||||
return issues
|
||||
```
|
||||
|
||||
## Fix Script
|
||||
|
||||
```python
|
||||
import zipfile
|
||||
import re
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
def fix_xml_declarations(docx_path, output_path=None):
|
||||
"""
|
||||
Fix lxml-serialized XML files inside a docx:
|
||||
1. Single quotes -> double quotes in XML declaration
|
||||
2. LF -> CRLF line endings (only if file has no CRLF)
|
||||
|
||||
If output_path is None, fixes in-place (via temp file + rename).
|
||||
"""
|
||||
if output_path is None:
|
||||
output_path = docx_path
|
||||
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix='.docx')
|
||||
os.close(tmp_fd)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(docx_path, 'r') as zin:
|
||||
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
data = zin.read(item.filename)
|
||||
|
||||
if item.filename.endswith('.xml') or item.filename.endswith('.rels'):
|
||||
text = data.decode('utf-8')
|
||||
|
||||
# Fix 1: Single quotes -> double quotes in XML declaration
|
||||
text = re.sub(
|
||||
r"<\?xml version='1\.0' encoding='UTF-8' standalone='yes'\?>",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||
text
|
||||
)
|
||||
|
||||
# Fix 2: LF -> CRLF (only if no CRLF present)
|
||||
if '\r\n' not in text and '\n' in text:
|
||||
text = text.replace('\n', '\r\n')
|
||||
|
||||
data = text.encode('utf-8')
|
||||
|
||||
zout.writestr(item, data)
|
||||
|
||||
os.replace(tmp_path, output_path)
|
||||
except:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
# Usage after ContractEditor.save() or manual zipfile write:
|
||||
# fix_xml_declarations('/tmp/【修】contract.docx')
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### After ContractEditor.save()
|
||||
```python
|
||||
ed = ContractEditor(src)
|
||||
# ... edits ...
|
||||
ed.save(output_path)
|
||||
fix_xml_declarations(output_path) # Must run after every save
|
||||
```
|
||||
|
||||
### After manual zipfile+lxml write
|
||||
```python
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
# ... write files ...
|
||||
pass
|
||||
|
||||
fix_xml_declarations(output_path) # Must run after ZIP is closed
|
||||
```
|
||||
|
||||
## Key Insight
|
||||
|
||||
- `x2t` (OnlyOffice converter CLI) tolerates single-quote declarations — it can convert the "broken" file to PDF successfully
|
||||
- The **OnlyOffice web editor** (WOPI-based document editing) does NOT tolerate single-quote declarations
|
||||
- `python-docx Document()` opens the file fine (lxml parses both formats)
|
||||
- Standard validation tools (zipfile.testzip(), etree.fromstring()) all pass
|
||||
|
||||
This makes the issue hard to diagnose — everything looks valid except OnlyOffice refuses to open it. The **only reliable test** is checking the raw bytes of the XML declaration in the ZIP.
|
||||
@@ -0,0 +1,66 @@
|
||||
# A类手动编号顺延 — 段落级 DEL/INS 模式
|
||||
|
||||
实战来源:CT维保合同-香花桥(2026-06-26)。新增"9. 第三方侵权"条款后,需将原9→10、10→11、11→12、12→13、13→14 顺延。所有条款编号均为手动文本(A类,run内w:t文字,无numPr)。
|
||||
|
||||
## 核心模式
|
||||
|
||||
对每个需顺延的段落,找到包含旧编号的 run,用**段落级** DEL/INS 替换:
|
||||
|
||||
```python
|
||||
for old_num, new_num in renumber_map.items():
|
||||
for r in p.findall(f'{{{W}}}r'):
|
||||
t = r.find(f'{{{W}}}t')
|
||||
if t is None or t.text is None: continue
|
||||
if t.text.strip().startswith(str(old_num)):
|
||||
# 1. DEL run: 旧编号
|
||||
del_run = deepcopy(r)
|
||||
del_run.set(f'{{{W}}}rsidDel', rsid)
|
||||
del_t = del_run.find(f'{{{W}}}t')
|
||||
del_t.tag = f'{{{W}}}delText'
|
||||
del_t.text = str(old_num)
|
||||
|
||||
del_w = etree.Element(f'{{{W}}}del')
|
||||
del_w.set(f'{{{W}}}id', str(nid)); nid += 1
|
||||
del_w.set(f'{{{W}}}author', 'WB')
|
||||
del_w.set(f'{{{W}}}date', rev_date)
|
||||
del_w.append(del_run)
|
||||
|
||||
# 2. INS run: 新编号
|
||||
ins_run = deepcopy(r)
|
||||
ins_run.set(f'{{{W}}}rsidR', rsid)
|
||||
ins_t = ins_run.find(f'{{{W}}}t')
|
||||
ins_t.text = str(new_num)
|
||||
|
||||
ins_w = etree.Element(f'{{{W}}}ins')
|
||||
ins_w.set(f'{{{W}}}id', str(nid)); nid += 1
|
||||
ins_w.set(f'{{{W}}}author', 'WB')
|
||||
ins_w.set(f'{{{W}}}date', rev_date)
|
||||
ins_w.append(ins_run)
|
||||
|
||||
# 3. 原 run 去掉编号前缀
|
||||
t.text = t.text[len(str(old_num)):]
|
||||
|
||||
# 4. DEL + INS 插入在原 run 之前
|
||||
r.addprevious(ins_w)
|
||||
r.addprevious(del_w)
|
||||
break
|
||||
break # 每个段落只改一个编号
|
||||
```
|
||||
|
||||
## 关键点
|
||||
|
||||
1. **DEL/INS 在段落级**(`w:p` 的直接子元素),不是 run 内
|
||||
2. **从后往前处理**:如果用索引遍历,从后往前避免 offset 漂移
|
||||
3. **只匹配run开头**:`t.text.strip().startswith(str(old_num))` 确保只匹配编号前缀
|
||||
4. **原 run 保留剩余文本**:`t.text = t.text[len(str(old_num)):]` 去掉编号后保留标题文字
|
||||
5. **ID 递增**:每个 DEL/INS 用独立 id,从 `max_id + 1` 起
|
||||
|
||||
## 与 add_clause 的区别
|
||||
|
||||
- `add_clause` / `add_clause_before`:创建**全新段落**(整段 w:ins)
|
||||
- 本模式:修改**已有段落**的第一个 run 的编号,其余内容不动
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 新增条款后,后续**手动编号**(A类)的条款需要顺延
|
||||
- 不适用于自动编号(B类)——自动编号由 number.xml 引擎处理,修改 run 内文字无效
|
||||
@@ -0,0 +1,60 @@
|
||||
# 手动合同审查:具体修改规则(非角色约束)
|
||||
|
||||
> Doro 2026-07-02 明确:"我需要你遵守的是具体修改规则,不是角色。"
|
||||
> 这些规则不因"手动操作"还是"workflow执行"而有任何区别。
|
||||
|
||||
## 十条硬规则
|
||||
|
||||
1. **修订精准到字,不整段 del+ins**
|
||||
- 改一个字只标记一个字的 del+ins
|
||||
- 不允许为了方便把整句/整段删掉重写
|
||||
|
||||
2. **INS run 字体/字号与原文同段落一致**
|
||||
- 每个 INS run 的 rPr(sz/bold/rFonts)必须与同段落其他非INS run一致
|
||||
- 签署页特别注意:"甲方:""乙方:"标签和名称可能原文字号不同,INS必须匹配标签字号
|
||||
|
||||
3. **格式、大小与原文保持一致**
|
||||
- 段落缩进(firstLine)、行距(spacing)、段落样式(pStyle)全部与原文同级段落一致
|
||||
- 新增条款标题必须继承原文条款标题的样式
|
||||
|
||||
4. **编号顺延要通读全文确认**
|
||||
- 插入新条款后,后续条款编号必须顺延
|
||||
- 必须通读全文确认编号链连续无跳号
|
||||
|
||||
5. **不擅自填写合同空白内容**
|
||||
- 空白的商业条款(金额、期限、数量、质量标准等)不动
|
||||
- 空白 = 留给签约双方自行填写,不是让审查人补充
|
||||
|
||||
6. **不做独立法律判断**
|
||||
- 不在审查中做"这个条款合不合法"的独立判断
|
||||
- 只按reviewer的issue清单执行修改
|
||||
|
||||
7. **不站自己的立场改客户的商业安排**
|
||||
- 客户已经做出的商业决策不否定
|
||||
- 例:客户选择"反委托代发工资",不能改成"乙方直接发"
|
||||
- 只能在客户选择的框架内加保护条款
|
||||
|
||||
8. **批注只写修改方案,不写理由**
|
||||
- ❌ "建议修改为……,因为……"
|
||||
- ✅ "建议修改为……"
|
||||
- 不加【新增】【修改】等标签前缀
|
||||
|
||||
9. **金额是商业条款不动**
|
||||
- 无论金额看起来是否"合理",绝对不改
|
||||
- 金额矛盾也只批注提示,不做修改
|
||||
|
||||
10. **原文批注/修订不动**
|
||||
- 其他人(华诚-Z、法务、屠佳青等)的修订和批注保留原样
|
||||
- 不删除、不修改、不合并他人的批注
|
||||
- 除非Doro明确指示合并(如"华诚-Z的修订人改为WB")
|
||||
|
||||
## 核心原则
|
||||
|
||||
**遵守的是规则本身,不是"我现在扮演什么角色"。** 不管是workflow的editor角色执行、还是Doro直接让我手动改合同,这十条规则完全一样,不打折扣。
|
||||
|
||||
## 反面教材(2026-07-01)
|
||||
|
||||
- 反委托代发工资协议:站自己立场否定客户的反委托安排(版本1直接取消反委托)→ 违反第7条
|
||||
- 填写空白的"质量保证期___个月" → 违反第5条
|
||||
- 批注写理由 → 违反第8条
|
||||
- 劳务派遣协议整段del+ins → 违反第1条
|
||||
@@ -0,0 +1,125 @@
|
||||
# Merge Layered Revisions with Priority (Accept Inner Author's Edits)
|
||||
|
||||
## Scenario (2026-07-03 模特合作协议案)
|
||||
|
||||
File has two layers of tracked changes:
|
||||
- **Layer 1 (WB)**: Original review modifications
|
||||
- **Layer 2 (华诚-Z)**: User edited on top of WB's tracked changes
|
||||
|
||||
Result: 华诚-Z's `w:del` elements are **nested inside** WB's `w:ins` elements — meaning 华诚-Z deleted portions of what WB had inserted.
|
||||
|
||||
User instruction: "以华诚-Z为准" (prioritize 华诚-Z), then unify all author names to WB.
|
||||
|
||||
## Three-Step Algorithm
|
||||
|
||||
### Step 1: Accept nested deletions (inner author wins)
|
||||
|
||||
Find all `w:del[author=华诚-Z]` nested inside `w:ins[author=WB]` and remove them (= accept the deletion):
|
||||
|
||||
```python
|
||||
def accept_nested_deletions(body, inner_author='华诚-Z', outer_author='WB'):
|
||||
for ins_elem in body.findall(f'.//{W}ins'):
|
||||
if ins_elem.get(f'{W}author') != outer_author:
|
||||
continue
|
||||
for del_elem in ins_elem.findall(f'.//{W}del'):
|
||||
if del_elem.get(f'{W}author') == inner_author:
|
||||
parent = del_elem.getparent()
|
||||
parent.remove(del_elem)
|
||||
```
|
||||
|
||||
### Step 2: Remove empty outer elements
|
||||
|
||||
After accepting nested deletions, some WB ins elements may be empty (all their content was deleted by 华诚-Z):
|
||||
|
||||
```python
|
||||
def remove_empty_ins(body):
|
||||
for ins_elem in body.findall(f'.//{W}ins'):
|
||||
has_text = False
|
||||
for t in ins_elem.findall(f'.//{W}t'):
|
||||
if t.text and t.text.strip():
|
||||
has_text = True
|
||||
break
|
||||
if not has_text:
|
||||
parent = ins_elem.getparent()
|
||||
if parent is not None:
|
||||
parent.remove(ins_elem)
|
||||
```
|
||||
|
||||
### Step 3: Unify author names
|
||||
|
||||
```python
|
||||
def rename_author(body, old_author, new_author):
|
||||
count = 0
|
||||
for elem in body.iter():
|
||||
author = elem.get(f'{W}author')
|
||||
if author == old_author:
|
||||
elem.set(f'{W}author', new_author)
|
||||
count += 1
|
||||
return count
|
||||
```
|
||||
|
||||
## Complete Flow
|
||||
|
||||
```python
|
||||
from docx import Document
|
||||
from lxml import etree
|
||||
|
||||
doc = Document('input.docx')
|
||||
body = doc.element.body
|
||||
|
||||
# Step 1: Accept 华诚-Z deletions of WB content
|
||||
accept_nested_deletions(body, inner_author='华诚-Z', outer_author='WB')
|
||||
|
||||
# Step 2: Clean up empty WB ins elements
|
||||
remove_empty_ins(body)
|
||||
|
||||
# Step 3: Rename 华诚-Z → WB
|
||||
rename_author(body, '华诚-Z', 'WB')
|
||||
|
||||
doc.save('output.docx')
|
||||
```
|
||||
|
||||
## After Merge: Additional Modifications
|
||||
|
||||
After merging, you can continue adding new WB tracked changes on the unified file (e.g., reverting specific clauses to template wording). Use standard tracked change creation:
|
||||
|
||||
```python
|
||||
def make_del(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
|
||||
d = etree.Element(f'{W}del')
|
||||
d.set(f'{W}id', str(abs(hash(text)) % 100000))
|
||||
d.set(f'{W}author', author)
|
||||
d.set(f'{W}date', date)
|
||||
r = etree.SubElement(d, f'{W}r')
|
||||
if rPr is not None:
|
||||
r.append(deepcopy(rPr))
|
||||
dt = etree.SubElement(r, f'{W}delText')
|
||||
dt.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
dt.text = text
|
||||
return d
|
||||
|
||||
def make_ins(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
|
||||
ins = etree.Element(f'{W}ins')
|
||||
ins.set(f'{W}id', str(abs(hash(text + 'ins')) % 100000))
|
||||
ins.set(f'{W}author', author)
|
||||
ins.set(f'{W}date', date)
|
||||
r = etree.SubElement(ins, f'{W}r')
|
||||
if rPr is not None:
|
||||
r.append(deepcopy(rPr))
|
||||
t = etree.SubElement(r, f'{W}t')
|
||||
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
t.text = text
|
||||
return ins
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After merge:
|
||||
- `set(elem.get(W+'author') for elem in body.iter() if elem.get(W+'author'))` should return `{'WB'}` only
|
||||
- Count ins/del elements to confirm reasonable numbers
|
||||
- Verify key clauses read correctly in "accepted" view
|
||||
|
||||
## Key Distinction from `unify-author-wb.py`
|
||||
|
||||
The `scripts/unify-author-wb.py` script **only renames authors** — it does NOT handle nested deletions. If 华诚-Z has `w:del` inside WB's `w:ins`, just running unify will rename the del to WB but **leave the deleted content still marked as deleted inside the insertion** — creating a confusing state where WB appears to both insert and delete the same text.
|
||||
|
||||
**Always run the three-step algorithm** when inner author has modified outer author's tracked changes.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Mixed Inherited/Explicit Font Size Fix (Document-Wide)
|
||||
|
||||
## Problem (2026-07-01 生育友好宣传阵地建设协议)
|
||||
|
||||
Source document has **mixed font sizing** in body text:
|
||||
- Some runs have explicit `sz=24` (12pt) — e.g., section headings, specific clauses
|
||||
- Other runs have **no explicit sz** — inherit from Normal style (`sz=21` / 10.5pt)
|
||||
- WB INS runs mostly got `sz=24` correctly, but the mix of explicit + inherited in **original** runs creates visual inconsistency
|
||||
|
||||
Doro complaint: "文字大小不一致,修改" — the rendered result shows mixed sizes.
|
||||
|
||||
## Root Cause
|
||||
|
||||
- `docDefaults` / Normal style = 10.5pt (sz=21)
|
||||
- Many body runs (P12+) have explicit sz=24 (from original author or conversion)
|
||||
- ~72 original runs have NO explicit sz → inherit 10.5pt → render smaller
|
||||
- OnlyOffice renders the mix faithfully → visible inconsistency
|
||||
|
||||
## Diagnosis
|
||||
|
||||
```python
|
||||
from docx import Document
|
||||
from collections import Counter
|
||||
|
||||
doc = Document('file.docx')
|
||||
print(f'Normal style sz: {doc.styles["Normal"].font.size}') # If 133350 EMU = 10.5pt
|
||||
|
||||
sizes = Counter()
|
||||
for p in doc.paragraphs[BODY_START:BODY_END]:
|
||||
for run in p.runs:
|
||||
if run.text.strip():
|
||||
sizes[run.font.size.pt if run.font.size else 'inherited'] += 1
|
||||
|
||||
# If both 'inherited' and explicit size (e.g. 12.0) appear → mixed problem
|
||||
print(sizes.most_common())
|
||||
```
|
||||
|
||||
## Fix Pattern (Full Body Range)
|
||||
|
||||
Unlike the INS-only sweep, this fix targets ALL runs in the body text range:
|
||||
|
||||
```python
|
||||
import zipfile, re
|
||||
from lxml import etree
|
||||
|
||||
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
|
||||
# 1. Identify body range (skip title/preamble and signature)
|
||||
BODY_START = 12 # First body content paragraph index
|
||||
BODY_END = 56 # Last body paragraph (exclusive)
|
||||
TARGET_SZ = '24' # From explicit runs in body (majority value)
|
||||
|
||||
# 2. Fix ALL runs in body range
|
||||
for pidx in range(BODY_START, min(BODY_END, len(paras))):
|
||||
p = paras[pidx]
|
||||
|
||||
# Plain runs
|
||||
for r in p.findall(f'{WNS}r'):
|
||||
t_elem = r.find(f'{WNS}t')
|
||||
if t_elem is None or not (t_elem.text or '').strip():
|
||||
continue
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is None:
|
||||
rpr = etree.SubElement(r, f'{WNS}rPr')
|
||||
r.insert(0, rpr)
|
||||
sz = rpr.find(f'{WNS}sz')
|
||||
if sz is None:
|
||||
sz = etree.SubElement(rpr, f'{WNS}sz')
|
||||
sz.set(f'{WNS}val', TARGET_SZ)
|
||||
szCs = rpr.find(f'{WNS}szCs')
|
||||
if szCs is None:
|
||||
szCs = etree.SubElement(rpr, f'{WNS}szCs')
|
||||
szCs.set(f'{WNS}val', TARGET_SZ)
|
||||
|
||||
# INS runs
|
||||
for ins in p.findall(f'{WNS}ins'):
|
||||
for r in ins.findall(f'{WNS}r'):
|
||||
# same logic as above
|
||||
...
|
||||
|
||||
# DEL runs (for visual consistency in markup view)
|
||||
for d in p.findall(f'{WNS}del'):
|
||||
for r in d.findall(f'{WNS}r'):
|
||||
# same logic
|
||||
...
|
||||
```
|
||||
|
||||
## Key Distinctions from INS-Only Fix
|
||||
|
||||
| Aspect | INS-only sweep | Full body range fix |
|
||||
|--------|---------------|---------------------|
|
||||
| Scope | Only WB INS runs | ALL runs (plain + INS + DEL) |
|
||||
| Trigger | INS runs missing sz | Doro reports "文字大小不一致" |
|
||||
| Root cause | add_clause/tracked_replace gaps | Source document mixed inheritance |
|
||||
| Target sz | From neighboring runs | From majority explicit sz in body |
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Doro says "文字大小不一致" on a delivered file
|
||||
- `wb-ins-font-verify.py` passes (INS runs OK) but rendered output still shows mixed sizes
|
||||
- Diagnostic shows body runs split between `inherited` and explicit sz
|
||||
|
||||
## Important: Don't Change Preamble/Signature
|
||||
|
||||
- Title/header (e.g., P0-P2): larger sz by design (22pt/sz=44) — don't touch
|
||||
- Party info (P3-P10): may use different sz — don't touch unless in body range
|
||||
- Signature area (P56+): often sz=21 (10.5pt) — don't touch
|
||||
- Only fix the **body text range** where sz should be uniform
|
||||
|
||||
## Relationship to 格式保留铁律
|
||||
|
||||
This fix does NOT violate "格式保留铁律" (don't change original formatting) because:
|
||||
- The original document's **intent** is uniform 12pt body text (evidenced by majority explicit sz=24)
|
||||
- The missing sz is a **formatting omission** (author forgot to set explicit sz on some runs)
|
||||
- The fix makes the document render as the original author intended
|
||||
- This is different from "changing 仿宋_GB2312 to 仿宋" (that changes the actual format choice)
|
||||
|
||||
BUT: if the original document intentionally uses different sizes in body (e.g., smaller text for notes, larger for headings), don't blindly unify. Check the pattern first.
|
||||
@@ -0,0 +1,204 @@
|
||||
# 修改已有tracked changes的作者和文本内容
|
||||
|
||||
## 场景
|
||||
- 合并用户在OnlyOffice中的修订(author如"华诚-Z"改为"WB")
|
||||
- 修改INS元素中的文本内容(如更新法律措辞)
|
||||
- 修改批注作者(comments.xml中的w:comment author属性)
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 修改tracked change作者
|
||||
```python
|
||||
from lxml import etree
|
||||
import zipfile
|
||||
|
||||
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
|
||||
# 打开docx,修改document.xml
|
||||
z_in = zipfile.ZipFile('input.docx', 'r')
|
||||
z_out = zipfile.ZipFile('output.docx', 'w')
|
||||
|
||||
# 复制非document.xml的文件
|
||||
for item in z_in.namelist():
|
||||
if item != 'word/document.xml':
|
||||
z_out.writestr(item, z_in.read(item))
|
||||
|
||||
# 修改tracked change作者
|
||||
with z_in.open('word/document.xml') as f:
|
||||
tree = etree.parse(f)
|
||||
root = tree.getroot()
|
||||
|
||||
for elem in root.iter():
|
||||
tag = etree.QName(elem.tag).localname
|
||||
if tag in ('ins', 'del'):
|
||||
old_author = elem.get(f'{WNS}author', '')
|
||||
if old_author == '旧作者名':
|
||||
elem.set(f'{WNS}author', 'WB')
|
||||
|
||||
z_out.writestr('word/document.xml', etree.tostring(tree, encoding='UTF-8', xml_declaration=True, standalone=True))
|
||||
z_in.close()
|
||||
z_out.close()
|
||||
```
|
||||
|
||||
### 修改INS文本内容
|
||||
```python
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
# 定位特定段落中的INS元素
|
||||
body = root.find(f'{WNS}body')
|
||||
paras = body.findall(f'{WNS}p')
|
||||
target_para = paras[12] # 按索引定位
|
||||
|
||||
# 删除旧的INS元素(按作者筛选)
|
||||
for ins in list(target_para.findall(f'{WNS}ins')):
|
||||
author = ins.get(f'{WNS}author', '')
|
||||
if author == '目标作者':
|
||||
target_para.remove(ins)
|
||||
|
||||
# 添加新的INS元素
|
||||
new_ins = etree.SubElement(target_para, f'{WNS}ins')
|
||||
new_ins.set(f'{WNS}author', 'WB')
|
||||
new_ins.set(f'{WNS}date', now)
|
||||
new_r = etree.SubElement(new_ins, f'{WNS}r')
|
||||
|
||||
# 从同段落的原文run复制格式
|
||||
orig_runs = target_para.findall(f'{WNS}r')
|
||||
if orig_runs:
|
||||
orig_rpr = orig_runs[0].find(f'{WNS}rPr')
|
||||
if orig_rpr is not None:
|
||||
new_r.append(deepcopy(orig_rpr))
|
||||
|
||||
new_t = etree.SubElement(new_r, f'{WNS}t')
|
||||
new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
new_t.text = '新的插入文本'
|
||||
```
|
||||
|
||||
### 修改批注作者
|
||||
```python
|
||||
# 修改comments.xml中的作者
|
||||
if 'word/comments.xml' in z_in.namelist():
|
||||
with z_in.open('word/comments.xml') as f:
|
||||
ctree = etree.parse(f)
|
||||
croot = ctree.getroot()
|
||||
for c in croot.findall(f'{WNS}comment'):
|
||||
if c.get(f'{WNS}author', '') == '旧作者名':
|
||||
c.set(f'{WNS}author', 'WB')
|
||||
z_out.writestr('word/comments.xml', etree.tostring(ctree, encoding='UTF-8', xml_declaration=True, standalone=True))
|
||||
```
|
||||
|
||||
### 在已有WB INS元素内修改部分文本(2026-07-01 反委托代发工资协议)
|
||||
|
||||
当段落文本全部是WB INS(无普通w:r),需要替换其中某一句时,**不能删除整个INS重建**(会丢失该INS中其他文本的修订标记)。正确手法:**trim原INS的w:t + addnext插入DEL/INS**。
|
||||
|
||||
```python
|
||||
old_sentence = "退回派遣员工由乙方依法自行安置处理,与甲方无涉。"
|
||||
new_sentence = "派遣员工退回后由乙方依法负责安置处理。因乙方安置不当导致甲方被追究责任的,乙方应赔偿甲方因此遭受的全部损失。"
|
||||
|
||||
for child in list(p):
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag == 'ins' and child.get(f'{WNS}author') == 'WB':
|
||||
for r in child.findall(f'{WNS}r'):
|
||||
for t in r.findall(f'{WNS}t'):
|
||||
if t.text and old_sentence in t.text:
|
||||
rpr_copy = copy.deepcopy(r.find(f'{WNS}rPr')) if r.find(f'{WNS}rPr') is not None else None
|
||||
|
||||
# 1. Trim原INS文本(去掉被替换的句子)
|
||||
t.text = t.text.replace(old_sentence, "")
|
||||
|
||||
# 2. 创建DEL
|
||||
del_elem = etree.Element(f'{WNS}del')
|
||||
del_elem.set(f'{WNS}id', next_id())
|
||||
del_elem.set(f'{WNS}author', 'WB')
|
||||
del_elem.set(f'{WNS}date', rev_date)
|
||||
del_r = etree.SubElement(del_elem, f'{WNS}r')
|
||||
if rpr_copy: del_r.insert(0, copy.deepcopy(rpr_copy))
|
||||
del_r.set(f'{WNS}rsidDel', rsid)
|
||||
del_t = etree.SubElement(del_r, f'{WNS}delText')
|
||||
del_t.set(XML_SPACE, 'preserve')
|
||||
del_t.text = old_sentence
|
||||
|
||||
# 3. 创建INS
|
||||
ins_elem = etree.Element(f'{WNS}ins')
|
||||
ins_elem.set(f'{WNS}id', next_id())
|
||||
ins_elem.set(f'{WNS}author', 'WB')
|
||||
ins_elem.set(f'{WNS}date', rev_date)
|
||||
ins_r = etree.SubElement(ins_elem, f'{WNS}r')
|
||||
if rpr_copy: ins_r.insert(0, copy.deepcopy(rpr_copy))
|
||||
ins_r.set(f'{WNS}rsidR', rsid)
|
||||
ins_t = etree.SubElement(ins_r, f'{WNS}t')
|
||||
ins_t.set(XML_SPACE, 'preserve')
|
||||
ins_t.text = new_sentence
|
||||
|
||||
# 4. 插入到原INS之后(addnext保证顺序)
|
||||
child.addnext(ins_elem) # 后插的在后面
|
||||
child.addnext(del_elem) # 后插的在前面 → 最终: [原INS] [DEL] [INS]
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- `addnext` 两次:先插INS再插DEL,后插的排前面,最终顺序:`[原INS(trimmed)] [DEL旧句] [INS新句]`
|
||||
- 绝不能 `p.remove(child)` 再重建——会丢失INS中其他未改动的文本
|
||||
- rPr必须从原INS的run深拷贝,不要从全文body_rpr取(字号可能不同)
|
||||
|
||||
### 给全INS段落补充条款编号(2026-07-01)
|
||||
|
||||
段落所有文本都是WB INS时,编号INS插到pPr之后:
|
||||
|
||||
```python
|
||||
ins_num = etree.Element(f'{WNS}ins')
|
||||
ins_num.set(f'{WNS}id', next_id()); ins_num.set(f'{WNS}author', 'WB'); ins_num.set(f'{WNS}date', rev_date)
|
||||
ins_r = etree.SubElement(ins_num, f'{WNS}r')
|
||||
ins_r.insert(0, copy.deepcopy(existing_rpr)) # 从同段落INS run深拷贝
|
||||
ins_r.set(f'{WNS}rsidR', rsid)
|
||||
ins_t = etree.SubElement(ins_r, f'{WNS}t')
|
||||
ins_t.set(XML_SPACE, 'preserve'); ins_t.text = "第X条 "
|
||||
|
||||
ppr = p.find(f'{WNS}pPr')
|
||||
if ppr is not None: ppr.addnext(ins_num)
|
||||
else: p.insert(0, ins_num)
|
||||
```
|
||||
|
||||
### 新INS元素eastAsia字体显式补齐
|
||||
|
||||
原文WB INS run可能**没有显式eastAsia属性**(靠docDefaults回退),但新INS run**必须显式设置eastAsia=宋体**,否则修订上下文中可能丢失回退。post-save sweep:
|
||||
|
||||
```python
|
||||
for p in body.findall(f'{WNS}p'):
|
||||
for ins in p.findall(f'{WNS}ins'):
|
||||
for r in ins.findall(f'{WNS}r'):
|
||||
text = ''.join(t.text for t in r.findall(f'{WNS}t') if t.text)
|
||||
if not any('\u4e00' <= c <= '\u9fff' for c in text): continue
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is None:
|
||||
rpr = etree.Element(f'{WNS}rPr'); r.insert(0, rpr)
|
||||
rf = rpr.find(f'{WNS}rFonts')
|
||||
if rf is None: rf = etree.SubElement(rpr, f'{WNS}rFonts')
|
||||
if not rf.get(f'{WNS}eastAsia'): rf.set(f'{WNS}eastAsia', '宋体')
|
||||
if not rf.get(f'{WNS}ascii'): rf.set(f'{WNS}ascii', '宋体')
|
||||
```
|
||||
|
||||
## 验证方法
|
||||
```python
|
||||
# 验证所有作者已更改
|
||||
content = z_out.read('word/document.xml').decode('utf-8', 'ignore')
|
||||
authors = set(re.findall(r'w:author="([^"]+)"', content))
|
||||
assert '旧作者名' not in authors, f"仍有旧作者: {authors}"
|
||||
|
||||
# 验证批注作者
|
||||
if 'word/comments.xml' in z_out.namelist():
|
||||
with z_out.open('word/comments.xml') as f:
|
||||
ctree = etree.parse(f)
|
||||
for c in ctree.getroot().findall(f'{WNS}comment'):
|
||||
assert c.get(f'{WNS}author') != '旧作者名'
|
||||
```
|
||||
|
||||
## ⚠️ 铁律
|
||||
1. **修改前必须备份原文件**:覆盖含第三方修订的文件 = 不可逆丢失
|
||||
2. **只改作者名,不改文本**:除非明确要求修改INS内容
|
||||
3. **zipfile不能原地读写**:必须先读后写临时文件,再用os.replace
|
||||
4. **保留comments.xml中的批注锚点**:只改author属性,不改id/content/anchor
|
||||
|
||||
## 实证(2026-07-01 反委托代发工资协议)
|
||||
华诚-Z在OnlyOffice中做了3处修订(第六条去法条引用、第七条简化纠正流程、第八条加退回员工安置)。后续制作版本时覆盖了所有中间文件,导致华诚-Z修订痕迹丢失。最终通过系统化文件扫描在/tmp/v1_doro_updated.docx中找到仍含华诚-Z作者的文件,提取修订内容后在最终版本中恢复。
|
||||
@@ -0,0 +1,93 @@
|
||||
# Multi-Version Contract Comparison Table (三版对比表)
|
||||
|
||||
## When to Use
|
||||
When Maggie/Doro asks to compare multiple versions of a contract (typically: template / counterparty revision / our revision), produce a structured docx comparison table.
|
||||
|
||||
## Pattern (2026-07-03 模特合作协议 session)
|
||||
|
||||
### Document Setup
|
||||
- **Landscape orientation** for 4-5 columns: `section.orientation = 1; page_width=Cm(29.7); page_height=Cm(21.0)`
|
||||
- Narrow margins: 1.2-1.5cm all sides
|
||||
- Font size 8.5-9pt for table cells (fits more content)
|
||||
|
||||
### Table Structure
|
||||
| 条款 | 【模版】 | 版本A(对方修订) | 版本B(我方修订) | 双方协商一致 |
|
||||
|------|---------|------------------|-----------------|-------------|
|
||||
|
||||
### Red Font for Differences
|
||||
- Column N is red when its content differs from other versions
|
||||
- Use `RGBColor(0xFF, 0x00, 0x00)` on the run
|
||||
- "协商一致" column: red = current text doesn't match consensus → needs modification
|
||||
|
||||
### Yellow Background for Consensus Column
|
||||
```python
|
||||
def set_cell_shading(cell, color):
|
||||
tc = cell._element
|
||||
tcPr = tc.find(qn('w:tcPr'))
|
||||
if tcPr is None:
|
||||
tcPr = OxmlElement('w:tcPr')
|
||||
tc.insert(0, tcPr)
|
||||
shading = OxmlElement('w:shd')
|
||||
shading.set(qn('w:fill'), color) # e.g. 'FFF8E1' for light yellow
|
||||
shading.set(qn('w:val'), 'clear')
|
||||
tcPr.append(shading)
|
||||
```
|
||||
|
||||
### Header Row Styling
|
||||
- Blue background (`D9E2F3`)
|
||||
- Bold, centered, font size 8.5pt
|
||||
|
||||
### Data Structure in Code
|
||||
```python
|
||||
# Each row: (clause_name, col1_text, col2_text, col3_text, col4_text, col2_red, col3_red, col4_red)
|
||||
rows = [
|
||||
('条款名',
|
||||
'模版内容',
|
||||
'对方修订内容',
|
||||
'我方修订内容',
|
||||
'协商一致内容',
|
||||
True, # col2 red? (differs from others)
|
||||
False, # col3 red?
|
||||
True), # col4 red? (doesn't match consensus)
|
||||
]
|
||||
```
|
||||
|
||||
### Legend at Bottom
|
||||
Include a legend explaining what red means in each column:
|
||||
- 版本A列红色 = 与模版/我方版不一致(对方的修改)
|
||||
- 版本B列红色 = 与模版不一致(我方的修改)
|
||||
- 协商一致列红色 = 当前文本与协商一致不符,需要修改
|
||||
|
||||
## Key Lessons
|
||||
1. **Read all three files from Nextcloud** using `sudo find ~/nextcloud/data/data/...` path
|
||||
2. **Extract paragraph text** using python-docx: `[(i, p.text.strip()) for i, p in enumerate(doc.paragraphs) if p.text.strip()]`
|
||||
3. **Check tables** separately: `doc.tables` — contracts often have signature blocks and SNS account tables
|
||||
4. **Align comparison by clause semantics**, not paragraph index — different versions may have different paragraph counts
|
||||
5. Also upload to Nextcloud for viewing in OnlyOffice
|
||||
|
||||
## Per-Run Precision for Tracked Changes (Maggie's correction)
|
||||
|
||||
When applying tracked changes based on comparison results, **never replace entire paragraphs**. Instead:
|
||||
|
||||
1. Identify the specific runs containing text to change
|
||||
2. For each run: create w:del wrapping a deepcopy (converting w:t → w:delText), create w:ins with new text and cloned rPr, swap in place
|
||||
3. All surrounding runs remain untouched
|
||||
|
||||
```python
|
||||
# Find specific run by text content
|
||||
for r in para_element.findall(qn('w:r')):
|
||||
text = ''.join(t.text or '' for t in r.findall(qn('w:t')))
|
||||
if text == '¥700,000': # exact match on this run
|
||||
r_parent = r.getparent()
|
||||
r_idx = list(r_parent).index(r)
|
||||
# Create del wrapping copy of this run
|
||||
del_elem = make_del_run_from_existing(r)
|
||||
# Create ins with new value, same rPr
|
||||
ins_elem = make_ins_run('¥600,000', r.find(qn('w:rPr')))
|
||||
r_parent.remove(r)
|
||||
r_parent.insert(r_idx, ins_elem)
|
||||
r_parent.insert(r_idx, del_elem)
|
||||
break
|
||||
```
|
||||
|
||||
This produces clean tracked changes where Word/OnlyOffice shows exactly which characters changed (e.g., ~~700,000~~ → 600,000) rather than entire-paragraph replacements.
|
||||
@@ -0,0 +1,232 @@
|
||||
# Multi-Version Creation Pattern
|
||||
|
||||
## When to Use
|
||||
When creating multiple versions of the same contract (e.g., 版本1法定安排 vs 版本2反委托保护) or when needing to redo a version from scratch.
|
||||
|
||||
## Critical Rule
|
||||
**ALWAYS start from the original source file for each version. Never modify a previously modified version.**
|
||||
|
||||
## Step-by-Step Pattern
|
||||
|
||||
### 1. Preserve Original Source
|
||||
```python
|
||||
# First time: copy original to safe location
|
||||
shutil.copy('/path/to/original.docx', '/tmp/original_backup.docx')
|
||||
```
|
||||
|
||||
### 2. For Each Version, Start Fresh
|
||||
```python
|
||||
# Always reload from original
|
||||
with zipfile.ZipFile('/tmp/original_backup.docx', 'r') as zin:
|
||||
all_data = {n: zin.read(n) for n in zin.namelist()}
|
||||
|
||||
doc_xml = all_data['word/document.xml']
|
||||
root = etree.fromstring(doc_xml)
|
||||
body = root.find(f'{W}body')
|
||||
paras = body.findall(f'{W}p')
|
||||
|
||||
# Get font template from original
|
||||
rpr_template = None
|
||||
for p in paras:
|
||||
for r in p.findall(f'{W}r'):
|
||||
t = r.find(f'{W}t')
|
||||
if t is not None and t.text and t.text.strip():
|
||||
rpr_elem = r.find(f'{W}rPr')
|
||||
if rpr_elem is not None:
|
||||
rpr_template = copy.deepcopy(rpr_elem)
|
||||
break
|
||||
if rpr_template:
|
||||
break
|
||||
```
|
||||
|
||||
### 3. Apply All Modifications in One Pass
|
||||
```python
|
||||
rev_id = 1000 # Start fresh revision ID counter
|
||||
|
||||
# Batch all replacements
|
||||
replacements = [
|
||||
(0, "old text", "new text"),
|
||||
(5, "old text", "new text"),
|
||||
# ... more replacements
|
||||
]
|
||||
|
||||
for idx, old_text, new_text in replacements:
|
||||
p = paras[idx]
|
||||
# Clear runs (but NOT comment anchors!)
|
||||
for child in list(p):
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag == 'r': # Only remove regular runs
|
||||
p.remove(child)
|
||||
d, i = make_tracked_replace(old_text, new_text, rpr_template, rev_id)
|
||||
rev_id += 2
|
||||
p.append(d)
|
||||
p.append(i)
|
||||
|
||||
# Insert new clauses
|
||||
insert_after = paras[10]
|
||||
for clause_text in new_clauses:
|
||||
new_p = make_ins_paragraph(clause_text, rpr_template, rev_id)
|
||||
rev_id += 1
|
||||
insert_after.addnext(new_p)
|
||||
insert_after = new_p
|
||||
|
||||
# Mark deletions (e.g., 承诺书)
|
||||
for idx in range(21, 30):
|
||||
p = paras[idx]
|
||||
text_parts = []
|
||||
for r in p.findall(f'{W}r'):
|
||||
t = r.find(f'{W}t')
|
||||
if t is not None and t.text:
|
||||
text_parts.append(t.text)
|
||||
full_text = ''.join(text_parts)
|
||||
if not full_text.strip():
|
||||
continue
|
||||
for child in list(p):
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag == 'r':
|
||||
p.remove(child)
|
||||
del_elem = make_tracked_delete(full_text, rpr_template, rev_id)
|
||||
rev_id += 1
|
||||
p.append(del_elem)
|
||||
|
||||
# Add comments LAST (after all structural changes)
|
||||
# Comment anchors are fragile - add them at the end
|
||||
```
|
||||
|
||||
### 4. Add Comments Carefully
|
||||
```python
|
||||
# Check if paragraph already has comment anchors
|
||||
existing = p.find(f'{W}commentRangeStart')
|
||||
if existing is None:
|
||||
# Add new comment anchors
|
||||
comment_start = etree.Element(f'{W}commentRangeStart')
|
||||
comment_start.set(f'{W}id', str(comment_id))
|
||||
p.insert(0, comment_start)
|
||||
|
||||
comment_end = etree.Element(f'{W}commentRangeEnd')
|
||||
comment_end.set(f'{W}id', str(comment_id))
|
||||
p.append(comment_end)
|
||||
|
||||
comment_ref_run = etree.SubElement(p, f'{W}r')
|
||||
comment_ref = etree.SubElement(comment_ref_run, f'{W}commentReference')
|
||||
comment_ref.set(f'{W}id', str(comment_id))
|
||||
|
||||
# Update comments.xml
|
||||
if 'word/comments.xml' in all_data:
|
||||
croot = etree.fromstring(all_data['word/comments.xml'])
|
||||
else:
|
||||
croot = etree.Element(f'{W}comments', nsmap={'w': W_NS})
|
||||
|
||||
# Add or update comment
|
||||
new_comment = etree.SubElement(croot, f'{W}comment')
|
||||
new_comment.set(f'{W}id', str(comment_id))
|
||||
new_comment.set(f'{W}author', author)
|
||||
new_comment.set(f'{W}date', datetime.now().isoformat())
|
||||
|
||||
p = etree.SubElement(new_comment, f'{W}p')
|
||||
r = etree.SubElement(p, f'{W}r')
|
||||
t = etree.SubElement(r, f'{W}t')
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = comment_text
|
||||
```
|
||||
|
||||
### 5. Save and Verify
|
||||
```python
|
||||
all_data['word/document.xml'] = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
all_data['word/comments.xml'] = etree.tostring(croot, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
|
||||
with zipfile.ZipFile('/tmp/version1.docx', 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for name, data in all_data.items():
|
||||
zout.writestr(name, data)
|
||||
|
||||
# Verify immediately
|
||||
doc = Document('/tmp/version1.docx')
|
||||
print(f"OK: {len(doc.paragraphs)} paragraphs")
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### ❌ Don't Do This
|
||||
```python
|
||||
# WRONG: Modifying v1 to create v2
|
||||
shutil.copy('/tmp/v1.docx', '/tmp/v2.docx')
|
||||
with zipfile.ZipFile('/tmp/v2.docx', 'r') as zin:
|
||||
# ... load v1's modified structure
|
||||
# This will have v1's tracked changes, comments, etc.
|
||||
```
|
||||
|
||||
### ❌ Don't Clear Everything When Modifying
|
||||
```python
|
||||
# WRONG: Clears comment anchors too!
|
||||
for child in list(p):
|
||||
if child.tag not in (f'{W}pPr',):
|
||||
p.remove(child) # Removes commentRangeStart/End!
|
||||
```
|
||||
|
||||
### ✅ Do This Instead
|
||||
```python
|
||||
# RIGHT: Only clear regular runs, preserve comment anchors
|
||||
for child in list(p):
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag == 'r': # Only regular runs
|
||||
p.remove(child)
|
||||
# commentRangeStart, commentRangeEnd are preserved
|
||||
```
|
||||
|
||||
## Preserving Original Comments
|
||||
When the original document has comments (e.g., Alice, 法务, 杜律), the workflow must:
|
||||
1. Read original comments.xml to get all comment IDs and content
|
||||
2. Check which paragraphs have comment anchors (commentRangeStart/End)
|
||||
3. When clearing runs, preserve comment anchors (they're not `w:r` elements)
|
||||
4. Add new comments with NEW IDs (don't reuse original IDs)
|
||||
5. Original comments remain unchanged in comments.xml
|
||||
|
||||
## Preserving Third-Party Tracked Changes (2026-07-01 华诚-Z案)
|
||||
|
||||
When a contract file contains tracked changes from someone other than WB (e.g., 华诚-Z, Crystall, or any third-party reviewer), **those files must never be overwritten**. The tracked changes represent real editorial work that cannot be reconstructed from session notes alone.
|
||||
|
||||
### Backup Protocol
|
||||
```python
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
# BEFORE any modification to a file with third-party tracked changes:
|
||||
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = f'/tmp/{filename}.bak_{ts}'
|
||||
shutil.copy(source_path, backup_path)
|
||||
print(f"Backed up to {backup_path}")
|
||||
```
|
||||
|
||||
### Detection: Does This File Have Third-Party Changes?
|
||||
```python
|
||||
import zipfile, re
|
||||
with zipfile.ZipFile(filepath) as z:
|
||||
content = z.read('word/document.xml').decode('utf-8', errors='ignore')
|
||||
authors = set(re.findall(r'w:author="([^"]+)"', content))
|
||||
third_party = authors - {'WB'}
|
||||
if third_party:
|
||||
print(f"⚠️ Third-party authors found: {third_party} — BACKUP REQUIRED")
|
||||
```
|
||||
|
||||
### Multi-Version with Third-Party Edits
|
||||
When creating v1 and v2 from a file that has both original content AND third-party edits:
|
||||
|
||||
1. **Backup the file with third-party edits** (e.g., `华诚-Z版.bak_20260701`)
|
||||
2. **Backup the pristine original** (no tracked changes at all)
|
||||
3. **For each version**: start from the pristine original, then layer on:
|
||||
- WB's own tracked changes
|
||||
- Third-party's tracked changes (with author renamed to WB)
|
||||
4. **Never modify the backup files** — they are your insurance
|
||||
|
||||
### What Was Lost (华诚-Z案)
|
||||
- 华诚-Z made 3 tracked changes in OnlyOffice: 第六条 (removed specific legal citations), 第七条 (simplified correction process), 第八条 (added employee return placement clause)
|
||||
- These intermediate files in /tmp/ were overwritten during v1/v2 creation
|
||||
- Only session notes preserved the *content* of changes, not the actual tracked change markup (ids, timestamps, exact XML positions)
|
||||
- **Recovery was impossible** — Doro had to accept reconstructed versions
|
||||
|
||||
## Real Example from This Session
|
||||
- Original: 4 comments (Alice×2, 法务, 杜律)
|
||||
- Version 1: 5 comments (original 4 + WB legal risk)
|
||||
- Version 2: 5 comments (original 4 + WB legal risk)
|
||||
|
||||
Both versions created independently from original, each with their own WB comment (different content for each version).
|
||||
@@ -0,0 +1,34 @@
|
||||
# 新增条款pPr完整克隆铁律(2026-07-13 盈浦健康科普合同教训)
|
||||
|
||||
## 问题
|
||||
|
||||
新增条款(如"八、转包与分包"的正文P75)插入后,只考虑了numPr是否正确,但遗漏了其他pPr子元素(如`ind`首行缩进)。导致新增段落与原文同类段落格式不一致。
|
||||
|
||||
## 教训链(同一份合同被Doro纠正3次)
|
||||
|
||||
1. **第一次**:P75挂了错误的numId=11(不可抗力的序列)→ 渲染为"3."
|
||||
2. **第二次**:去掉numId后,加了新的numId=14 → 单段正文不该有编号("有2才有1"规则延伸到numPr)
|
||||
3. **第三次**:去掉numId后仍缺首行缩进ind=420 → 与原文"一、合作背景"(同为单段无编号正文)格式不一致
|
||||
|
||||
## 铁律:新增段落pPr必须完整比对原文参照段
|
||||
|
||||
动手前必须:
|
||||
1. **找到原文中的参照段落**——格式相同的段落(同层级、同类型)
|
||||
2. **逐子元素列出参照段的pPr**:spacing、ind、numPr、jc、每一个子元素
|
||||
3. **逐一对比新增段落的pPr**:缺什么补什么,多什么删什么
|
||||
4. 不能只看一个属性(如numPr)就认为"格式正确了"
|
||||
|
||||
## "有2才有1"规则在numPr上的延伸
|
||||
|
||||
原规则:新增条款如果下一级只有一条内容,不加子编号。
|
||||
|
||||
延伸到numPr:如果某章节下只有一个正文段落,且原文中同类单段正文没有numPr(如"一、合作背景"的P12无numPr),则新增的单段正文也不加numPr。
|
||||
|
||||
判断方法:看原文中有没有"章节标题+单段正文+无numPr"的先例。有→新增单段也不加。
|
||||
|
||||
## 检查清单(操作后必过)
|
||||
|
||||
- [ ] 新增段落的spacing与参照段一致
|
||||
- [ ] 新增段落的ind与参照段一致(特别是firstLine/firstLineChars)
|
||||
- [ ] 新增段落的numPr:单段→不加(参照原文同类);多段→加入对应序列
|
||||
- [ ] 新增段落INS run的rPr与参照段原文run的rPr一致(无多余sz、无缺失属性)
|
||||
@@ -0,0 +1,147 @@
|
||||
# Strip numPr When Adding Manual Numbering to Auto-Numbered Paragraphs
|
||||
|
||||
## Problem (2026-07-01 反委托代发工资协议)
|
||||
|
||||
Original contract paragraphs have `<w:numPr>` with actual auto-numbering (e.g., `numId=3 → abstractNum decimal "%1." start=1`). When you insert manual "第X条" numbering as `w:ins` at paragraph start, OnlyOffice renders BOTH:
|
||||
|
||||
```
|
||||
1. 第一条 乙方应严格按照... ← "1." is auto-numbering, "第一条" is your INS
|
||||
```
|
||||
|
||||
This looks broken — two different numbering systems stacked.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The paragraph's `pPr/numPr` tells the rendering engine to prepend an automatic decimal number. Your INS adds a second, manual number. They coexist independently.
|
||||
|
||||
Additionally, if the paragraph has `pPr/pPrChange` (tracking the old paragraph formatting), the old `numPr` inside `pPrChange` can ALSO render in markup view.
|
||||
|
||||
## Fix (two-step)
|
||||
|
||||
After inserting manual numbering INS elements, strip auto-numbering from ALL affected paragraphs:
|
||||
|
||||
```python
|
||||
for idx in target_paragraph_indices:
|
||||
p = paras[idx]
|
||||
ppr = p.find(f'{WNS}pPr')
|
||||
if ppr is not None:
|
||||
# Step 1: Remove direct numPr
|
||||
num_pr = ppr.find(f'{WNS}numPr')
|
||||
if num_pr is not None:
|
||||
ppr.remove(num_pr)
|
||||
|
||||
# Step 2: Remove numPr inside pPrChange (old formatting record)
|
||||
ppr_change = ppr.find(f'{WNS}pPrChange')
|
||||
if ppr_change is not None:
|
||||
inner_ppr = ppr_change.find(f'{WNS}pPr')
|
||||
if inner_ppr is not None:
|
||||
inner_num = inner_ppr.find(f'{WNS}numPr')
|
||||
if inner_num is not None:
|
||||
inner_ppr.remove(inner_num)
|
||||
```
|
||||
|
||||
## When This Applies
|
||||
|
||||
- You're converting a contract from auto-numbered clauses to manual "第X条" heading-style numbering
|
||||
- The original .doc/.docx used Word's list numbering for clause structure
|
||||
- You're adding "第一条 " etc. as INS at paragraph start
|
||||
|
||||
## Verification
|
||||
|
||||
After fix:
|
||||
1. `pdftotext -layout` of OnlyOffice render should show NO stray "1." / "2." / "3." before your "第X条"
|
||||
2. Accept-revisions preview should also be clean (no residual auto-numbers)
|
||||
|
||||
## Scenario B: Auto-Numbering Resets Across Tracked-Deleted Paragraphs (2026-07-01 反委托代发工资协议)
|
||||
|
||||
### Problem
|
||||
|
||||
When paragraphs with `numPr` auto-numbering are interspersed with **entirely deleted paragraphs** (all content in `w:del`), OnlyOffice's auto-number counter **resets to 1** after the deleted block. This makes continuous numbering impossible with `numPr` alone.
|
||||
|
||||
Example structure:
|
||||
```
|
||||
P6: numPr=1 INS content (clause 1) → renders "1."
|
||||
P7: numPr=1 continuation → renders "2." (wrong if P7 shouldn't be numbered)
|
||||
P8: numPr=1 ALL w:del → renders "3." with strikethrough
|
||||
P9: numPr=1 ALL w:del → renders "4." with strikethrough
|
||||
P10: numPr=1 INS content (clause 2) → renders "1." ← RESETS! Should be "2."
|
||||
```
|
||||
|
||||
The auto-numbering engine counts visible (non-deleted) items in the `numId` sequence, but deleted paragraphs **break the continuity** in OnlyOffice's rendering.
|
||||
|
||||
### Solution: Convert to Manual Text Numbering
|
||||
|
||||
Strip `numPr` from ALL paragraphs and insert "N. " as `w:ins` text at paragraph start. This gives identical visual output ("1. 2. 3. ...") without depending on the broken auto-number counter.
|
||||
|
||||
```python
|
||||
# Step 1: Strip ALL numPr (including inside pPrChange)
|
||||
for i, p in enumerate(paragraphs):
|
||||
pPr = p.find(f'{{{W}}}pPr')
|
||||
if pPr is not None:
|
||||
numPr = pPr.find(f'{{{W}}}numPr')
|
||||
if numPr is not None:
|
||||
pPr.remove(numPr)
|
||||
for pPrChange in pPr.findall(f'{{{W}}}pPrChange'):
|
||||
old_pPr = pPrChange.find(f'{{{W}}}pPr')
|
||||
if old_pPr is not None:
|
||||
old_numPr = old_pPr.find(f'{{{W}}}numPr')
|
||||
if old_numPr is not None:
|
||||
old_pPr.remove(old_numPr)
|
||||
|
||||
# Step 2: Insert "N. " as w:ins text for each clause paragraph
|
||||
# Only number paragraphs that have VISIBLE content (not entirely w:del)
|
||||
clause_map = {6: 1, 10: 2, 11: 3, ...} # para_index: clause_number
|
||||
|
||||
for para_idx, clause_num in clause_map.items():
|
||||
p = paragraphs[para_idx]
|
||||
# Build INS element with "N. " text
|
||||
ins_elem = ET.Element(f'{{{W}}}ins')
|
||||
ins_elem.set(f'{{{W}}}id', str(next_rev_id()))
|
||||
ins_elem.set(f'{{{W}}}author', rev_author) # from existing INS in doc
|
||||
ins_elem.set(f'{{{W}}}date', rev_date)
|
||||
|
||||
r_elem = ET.SubElement(ins_elem, f'{{{W}}}r')
|
||||
# Clone rPr from existing runs for font consistency
|
||||
rPr = get_run_rPr_from_paragraph(p)
|
||||
if rPr is not None:
|
||||
r_elem.append(copy.deepcopy(rPr))
|
||||
|
||||
t_elem = ET.SubElement(r_elem, f'{{{W}}}t')
|
||||
t_elem.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
t_elem.text = f"{clause_num}. "
|
||||
|
||||
# Insert after pPr
|
||||
pPr = p.find(f'{{{W}}}pPr')
|
||||
if pPr is not None:
|
||||
p.insert(list(p).index(pPr) + 1, ins_elem)
|
||||
else:
|
||||
p.insert(0, ins_elem)
|
||||
```
|
||||
|
||||
### When to Use This (vs Scenario A)
|
||||
|
||||
- **Scenario A** (above): You're CHANGING the numbering scheme (auto "1." → manual "第一条")
|
||||
- **Scenario B** (this): You're KEEPING the same format ("1. 2. 3.") but converting from auto to manual because auto-numbering resets across w:del paragraphs
|
||||
- **Trigger**: Original uses numPr auto-numbering + your edits create entirely-deleted paragraphs between numbered items → auto counter resets → switch to manual text
|
||||
|
||||
### Key Decision: Which Paragraphs to Number
|
||||
|
||||
Only number paragraphs that will be visible after accepting revisions:
|
||||
- Paragraphs with ONLY `w:del` content → skip (they're deleted)
|
||||
- Paragraphs that are continuations of the previous clause (no independent number) → skip
|
||||
- New INS-only paragraphs (new clauses) → number them
|
||||
- Rewritten paragraphs (mixed INS+DEL, first clause in sequence) → number them
|
||||
|
||||
### Verification
|
||||
|
||||
After conversion:
|
||||
1. OnlyOffice render (x2t → PDF) should show continuous "1. 2. 3. ... 11." without resets
|
||||
2. No stray auto-numbers from numPr remnants
|
||||
3. Deleted paragraphs (entirely w:del) should NOT show any number
|
||||
|
||||
## Distinction from Existing Rules
|
||||
|
||||
- Rule 5 (A類 vs B類) talks about NEW clauses inheriting/stripping numPr
|
||||
- Scenario A is EXISTING paragraphs where you're REPLACING their numbering scheme with a different format via INS
|
||||
- Scenario B is EXISTING paragraphs where auto-numbering BREAKS due to tracked-deleted paragraphs, requiring conversion to same-format manual text
|
||||
- numId=0 trap (Rule 5 sub-note) is about fake auto-numbering; BOTH scenarios here are about REAL auto-numbering that renders visible numbers
|
||||
@@ -0,0 +1,54 @@
|
||||
# 一页纸 docx 排版压缩配方
|
||||
|
||||
适用场景:创建必须严格一页的 Word 文档(合作框架、报价单、一页摘要等),通过 OnlyOffice x2t 渲染验证。
|
||||
|
||||
## 迭代压缩流程
|
||||
|
||||
x2t 渲染的行高/间距比 python-docx 估算的略宽松,不能靠"调好参数直接交付"。必须走渲染验证循环。
|
||||
|
||||
### 第一轮:合理起点
|
||||
| 参数 | 值 |
|
||||
|---|---|
|
||||
| 上下边距 | 1.5–2.0 cm |
|
||||
| 左右边距 | 1.8–2.0 cm |
|
||||
| 正文字号 | 9–10 pt |
|
||||
| 表格字号 | 8–9 pt |
|
||||
| 行距 | 1.05–1.15 |
|
||||
|
||||
### 验证循环
|
||||
```bash
|
||||
bash ~/.hermes/skills/legal/contract-editor/scripts/onlyoffice-render.sh <docx> <pdf>
|
||||
python3 -c "
|
||||
import subprocess
|
||||
r=subprocess.run(['pdftotext','<pdf>','-'],capture_output=True,text=True)
|
||||
pages=r.stdout.split('\f')
|
||||
print(f'页数: {len(pages)}')
|
||||
"
|
||||
```
|
||||
- 页数=1 → 交付
|
||||
- 页数=2 且末页空白 → 内容刚好溢出,微调即可
|
||||
- 页数=2 且有内容 → 需要大幅压缩或精简文字
|
||||
|
||||
### 逐级压缩(按优先级)
|
||||
1. 底部边距:1.5→1.0→0.8→0.5 cm(先砍底部,顶部保阅读感)
|
||||
2. 表格字号:8→7.5→7 pt
|
||||
3. 行距:1.05→1.0
|
||||
4. 左右边距:1.8→1.5 cm
|
||||
5. 段落间距:Pt(2)→Pt(1)→Pt(0)
|
||||
6. 精简文字(最后手段)
|
||||
|
||||
### 已实证的可用参数(5000字内一页A4)
|
||||
| 参数 | 值 |
|
||||
|---|---|
|
||||
| 上下边距 | 1.2 / 0.5 cm |
|
||||
| 左右边距 | 1.5 cm |
|
||||
| 正文字号 | 8 pt |
|
||||
| 表格字号 | 7 pt |
|
||||
| 行距 | 1.0 |
|
||||
| 段落间距 | 0 |
|
||||
|
||||
## 陷阱
|
||||
- x2t 对表格行高估算偏大,表内文字多时尤其明显
|
||||
- 分隔线(`—`*N)占用空间,一页紧张时去掉
|
||||
- 表格 `Table Grid` 样式自带内边距,无法通过 python-docx 参数完全消除
|
||||
- 第二页空白但无文字 = 内容刚好溢出几像素,再砍 0.2cm 底部边距或减 0.5pt 字号即可
|
||||
@@ -0,0 +1,40 @@
|
||||
# ContractEditor Operation Ordering: Add Clauses Before Renumbering
|
||||
|
||||
## 2026-07-13 练塘硬件购销合同教训
|
||||
|
||||
### Problem
|
||||
When interleaving `add_clause_before()` and `tracked_replace()` for renumbering, lxml throws:
|
||||
```
|
||||
ValueError: Element is not a child of this node.
|
||||
```
|
||||
in `tracked_replace()` → `parent.remove(runs[idx])`.
|
||||
|
||||
### Root Cause
|
||||
`add_clause_before()` / `add_clause()` mutate the XML tree (insert new `<w:p>` elements). After insertion, previously-found element references held by later `tracked_replace()` calls may point to nodes whose parent relationship has shifted. The `parent.remove()` call inside `tracked_replace` fails because the run's parent `<w:p>` is no longer the same object the code expects.
|
||||
|
||||
### Fix: Two-Phase Approach (铁律)
|
||||
|
||||
**Phase 1 — All structural additions:**
|
||||
- `add_clause()` / `add_clause_before()` for new clauses
|
||||
- Content-level `tracked_replace()` that don't touch clause titles being renumbered
|
||||
|
||||
**Phase 2 — Renumbering (after all additions are done):**
|
||||
- `tracked_replace('第七条不可抗力', '第九条不可抗力')` etc.
|
||||
|
||||
### Example (correct order)
|
||||
```python
|
||||
# Phase 1: add new clauses
|
||||
editor.add_clause_before('第七条 转包与分包\n...', before_search='第七条不可抗力')
|
||||
editor.add_clause_before('第八条 第三方侵权\n...', before_search='第七条不可抗力')
|
||||
|
||||
# Phase 2: renumber old clauses (all additions done)
|
||||
editor.tracked_replace('第七条不可抗力', '第九条不可抗力')
|
||||
editor.tracked_replace('第八条争议解决', '第十条争议解决')
|
||||
```
|
||||
|
||||
### WPS/DOC File Handling
|
||||
WPS `.wps` and `.doc` files must be converted to `.docx` before ContractEditor can process them:
|
||||
```bash
|
||||
libreoffice --headless --convert-to docx input.wps --outdir /tmp/contract-review/
|
||||
```
|
||||
Then copy to a simple ASCII filename to avoid python-docx path issues with Chinese characters.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Paragraph Deletion via Tracked Changes (WB)
|
||||
|
||||
When a reviewer instructs you to delete an entire clause/paragraph, use **paragraph-level deletion** — not just deleting the text but marking the entire paragraph as removed in tracked changes.
|
||||
|
||||
## Two-Part Deletion
|
||||
|
||||
### Part 1: Paragraph Mark Deletion
|
||||
|
||||
Add a `w:del` element inside the paragraph's `w:pPr/w:rPr`:
|
||||
|
||||
```xml
|
||||
<w:pPr>
|
||||
<w:rPr>
|
||||
<w:del w:id="7777" w:author="WB" w:date="2026-06-26T00:00:00Z"/>
|
||||
</w:rPr>
|
||||
</w:pPr>
|
||||
```
|
||||
|
||||
This marks the paragraph marker (¶) as deleted, so the paragraph doesn't leave an empty line.
|
||||
|
||||
### Part 2: Content Deletion
|
||||
|
||||
Wrap every text run in the paragraph inside `w:del` elements, converting `w:t` to `w:delText`:
|
||||
|
||||
```python
|
||||
for child in list(paragraph):
|
||||
tag = child.tag.split('}')[-1]
|
||||
if tag in ('r', 'ins'):
|
||||
paragraph.remove(child)
|
||||
del_elem = etree.SubElement(paragraph, f'{{{W}}}del')
|
||||
del_elem.set(f'{{{W}}}id', '7777')
|
||||
del_elem.set(f'{{{W}}}author', 'WB')
|
||||
del_elem.set(f'{{{W}}}date', '2026-06-26T00:00:00Z')
|
||||
|
||||
target_runs = child.findall(f'{{{W}}}r') if tag == 'ins' else [child]
|
||||
for r in target_runs:
|
||||
t = r.find(f'{{{W}}}t')
|
||||
if t is not None:
|
||||
r.remove(t)
|
||||
dt = etree.SubElement(r, f'{{{W}}}delText')
|
||||
dt.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
dt.text = t.text
|
||||
r.set(f'{{{W}}}rsidDel', new_rsid())
|
||||
del_elem.append(r)
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- **Same del id** for both the paragraph mark and content dels (e.g., `7777`) — they're part of the same deletion operation
|
||||
- **Handle existing INS runs**: If the paragraph has runs inside `w:ins` (from previous revisions), extract them and wrap in `w:del` too
|
||||
- **Leave existing DEL runs untouched** — they're already deleted
|
||||
- **Copy rPr**: If the original run had `rPr`, copy it into the del run so the strikethrough text renders with correct font/size
|
||||
- **Use unique ids**: Pick an id that doesn't collide with existing del/ins ids in the document. Check `max(del_ids) + 1000` if unsure
|
||||
|
||||
### Verification
|
||||
|
||||
After deletion, render with OnlyOffice and check:
|
||||
1. The deleted paragraph appears with strikethrough in markup view
|
||||
2. Accepting all revisions removes the paragraph entirely (no empty line)
|
||||
3. The paragraph mark (¶) is also deleted — no gap between surrounding paragraphs
|
||||
@@ -0,0 +1,50 @@
|
||||
# Per-Paragraph Font Matching(2026-07-13 消防设施检测合同教训)
|
||||
|
||||
## 问题
|
||||
同一份合同中,不同段落的原文runs可能有完全不同的字体属性方案:
|
||||
- P20 runs: `ascii=宋体, hAnsi=宋体, cs=宋体, sz=24, eastAsia=None, hint=None`
|
||||
- P36/P41 runs: `rPr=None`(完全无字体属性,靠docDefaults/style继承)
|
||||
|
||||
如果对所有WB INS runs统一设置一种字体属性,必然导致某些段落mismatch。
|
||||
|
||||
## 错误做法
|
||||
```python
|
||||
# ❌ 全局统一设置
|
||||
for ins in all_wb_ins:
|
||||
rf.set('ascii', '宋体')
|
||||
rf.set('sz', '24')
|
||||
```
|
||||
|
||||
## 正确做法
|
||||
```python
|
||||
# ✅ 按段落匹配原文第一个非INS/非DEL run的rPr
|
||||
for p in paras:
|
||||
# 找到同段落的第一个orig run
|
||||
orig_run = None
|
||||
for child in p:
|
||||
if child.tag == f'{WNS}r':
|
||||
orig_run = child
|
||||
break
|
||||
|
||||
if orig_run is None:
|
||||
continue # 整段INS,无参照
|
||||
|
||||
orig_rpr = orig_run.find(f'{WNS}rPr')
|
||||
orig_rf = orig_rpr.find(f'{WNS}rFonts') if orig_rpr is not None else None
|
||||
|
||||
# INS的rPr应该与orig_run的rPr完全匹配
|
||||
# 如果orig没有rFonts → INS也不该有
|
||||
# 如果orig有ascii=宋体但没有eastAsia → INS也是这样
|
||||
```
|
||||
|
||||
## 整段INS段落(无同段原文可比)
|
||||
- wb-ins-font-verify.py会报"MISSING HINT (无同段原文可比)"
|
||||
- 这是**已知假阳性**,不算真问题
|
||||
- 整段INS段落的字体应参照**相邻段落**(前后各2段)的原文run格式
|
||||
- 如果相邻原文runs有explicit属性(ascii=宋体 sz=24),INS也设
|
||||
- 如果相邻原文runs无rPr,INS也不设
|
||||
|
||||
## 2026-07-13 消防设施检测合同实证
|
||||
- 第一次修复:全局strip eastAsia/hint → P20报ASCII MISMATCH(原文有ascii=宋体)
|
||||
- 第二次修复:全局设ascii=宋体 → P36/P41报ASCII MISMATCH(原文无rFonts)
|
||||
- 正确修复:per-paragraph检查orig_run是否有ascii → 有则INS也设,无则INS也不设
|
||||
@@ -0,0 +1,46 @@
|
||||
# 反委托代发工资法律风险
|
||||
|
||||
## 核心法律规定
|
||||
|
||||
| 法律依据 | 条文 | 效力 |
|
||||
|----------|------|------|
|
||||
| 《劳务派遣暂行规定》第8条第(三)项 | 派遣单位应当依法支付被派遣劳动者的劳动报酬 | 强制性规定 |
|
||||
| 《劳动合同法》第58条 | 派遣单位是用人单位,应履行用人单位义务 | 法律 |
|
||||
| 《劳动合同法》第92条第2款 | 用工单位给被派遣劳动者造成损害的,派遣单位与用工单位承担连带赔偿责任 | 法律 |
|
||||
| 《劳务派遣暂行规定》第24条 | 用工单位违法退回的,按劳动合同法第92条第2款执行 | 部门规章 |
|
||||
| 劳社部发〔2005〕12号第2条 | 工资支付凭证是认定事实劳动关系的首要证据 | 规范性文件 |
|
||||
|
||||
## 核心结论
|
||||
|
||||
1. **代发工资 = 事实劳动关系首要证据**:用工单位直接向派遣员工发工资,违反《劳务派遣暂行规定》第8条强制性规定
|
||||
2. **协议不能免除法定责任**:甲乙之间的内部追偿条款不能对抗劳动者和行政机关
|
||||
3. **退回条款限制**:用工单位只能在法定三种情形下退回(客观情况重大变化/经济性裁员、破产/解散、协议期满)
|
||||
4. **"与甲方无涉"条款有法律风险**:可能因违反《劳动合同法》第26条第2款(免除法定责任)被认定无效
|
||||
|
||||
## 关键判例
|
||||
|
||||
- **广东高院(2022)粤民再30号**:汽车公司以咨询公司名义签劳动合同,工资由汽车公司直接发放。认定汽车公司与劳动者存在事实劳动关系。
|
||||
- **(2019)沪0109民初12453号**:用工单位违法退回导致派遣公司违法解除的,用工单位承担连带赔偿责任。
|
||||
- **(2022)鲁0322民初834号**:甲公司将工资计算后交乙公司发放,法院认定甲公司存在经济依附性,构成事实劳动关系。
|
||||
|
||||
## 审查建议
|
||||
|
||||
### 推荐方案(版本1:回归法定安排)
|
||||
- 删除代发工资条款,由乙方(派遣公司)直接支付工资
|
||||
- 添加甲方监督权、扣款权、违约金条款
|
||||
- 添加乙方资质维持、用工管理义务、退回权、保密条款
|
||||
|
||||
### 替代方案(版本2:反委托保护)
|
||||
如甲方坚持代发,最大化保护措施:
|
||||
1. 鉴于条款定性为"委托代发",明确甲方仅为代理人
|
||||
2. 三方签署要求(甲方、乙方、派遣员工)
|
||||
3. 事实劳动关系兜底:乙方十日内赔偿甲方全部损失
|
||||
4. 履约保证金(金额留空)或银行保函
|
||||
5. 税务责任限定:甲方仅承担自身原因导致的差额
|
||||
6. 社保义务对等
|
||||
7. 劳动关系确认条款
|
||||
8. 乙方资质维持 + 用工管理义务
|
||||
|
||||
### 退回条款措辞
|
||||
❌ "退回派遣员工由乙方依法自行安置处理,与甲方无涉"(有法律风险)
|
||||
✅ "派遣员工退回后由乙方依法负责安置处理。因乙方安置不当导致甲方被追究责任的,乙方应赔偿甲方因此遭受的全部损失。"
|
||||
@@ -0,0 +1,120 @@
|
||||
# 审查意见文档字体强制设置
|
||||
|
||||
## 背景(2026-07-02 肃言+恭兴合同返工)
|
||||
|
||||
review-rules.md 规定审查意见文档:中文统一仿宋体,英文Times New Roman。
|
||||
|
||||
**问题**:模板文件(`朱家角 审查意见【模板】.docx`)的表头行有显式eastAsia=仿宋,但新建的数据行字体设置不一致:
|
||||
- 恭兴审查意见:editor给数据行设了 ascii=仿宋 hAnsi=仿宋(错:英文也变仿宋了)
|
||||
- 肃言审查意见:editor压根没给数据行设ascii/hAnsi(只有eastAsia=仿宋)
|
||||
|
||||
**根因**:LLM每次独立session生成代码,字体设置逻辑不稳定。
|
||||
|
||||
## 强制修复代码(生成审查意见后必跑)
|
||||
|
||||
```python
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
|
||||
def enforce_review_opinion_fonts(doc_path, save=True):
|
||||
"""审查意见文档生成后强制设置所有run的字体。
|
||||
中文=仿宋, 英文=Times New Roman
|
||||
"""
|
||||
doc = Document(doc_path)
|
||||
fixed = 0
|
||||
|
||||
# Fix all table cells
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for p in cell.paragraphs:
|
||||
for run in p.runs:
|
||||
fixed += _fix_run_font(run._element)
|
||||
|
||||
# Fix all paragraphs outside tables
|
||||
for p in doc.paragraphs:
|
||||
for run in p.runs:
|
||||
fixed += _fix_run_font(run._element)
|
||||
|
||||
if save:
|
||||
doc.save(doc_path)
|
||||
return fixed
|
||||
|
||||
def _fix_run_font(run_element):
|
||||
"""Ensure run has eastAsia=仿宋, ascii/hAnsi=Times New Roman"""
|
||||
rPr = run_element.find(qn('w:rPr'))
|
||||
if rPr is None:
|
||||
rPr = OxmlElement('w:rPr')
|
||||
run_element.insert(0, rPr)
|
||||
|
||||
rFonts = rPr.find(qn('w:rFonts'))
|
||||
if rFonts is None:
|
||||
rFonts = OxmlElement('w:rFonts')
|
||||
rPr.insert(0, rFonts)
|
||||
|
||||
changed = False
|
||||
|
||||
# eastAsia must be 仿宋
|
||||
if rFonts.get(qn('w:eastAsia')) != '仿宋':
|
||||
rFonts.set(qn('w:eastAsia'), '仿宋')
|
||||
changed = True
|
||||
|
||||
# ascii must be Times New Roman (NOT 仿宋)
|
||||
if rFonts.get(qn('w:ascii')) != 'Times New Roman':
|
||||
rFonts.set(qn('w:ascii'), 'Times New Roman')
|
||||
changed = True
|
||||
|
||||
# hAnsi must be Times New Roman (NOT 仿宋)
|
||||
if rFonts.get(qn('w:hAnsi')) != 'Times New Roman':
|
||||
rFonts.set(qn('w:hAnsi'), 'Times New Roman')
|
||||
changed = True
|
||||
|
||||
return 1 if changed else 0
|
||||
```
|
||||
|
||||
## 验证方法
|
||||
|
||||
```python
|
||||
def verify_review_opinion_fonts(doc_path):
|
||||
"""验证审查意见文档字体全部正确"""
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
doc = Document(doc_path)
|
||||
errors = []
|
||||
for table in doc.tables:
|
||||
for i, row in enumerate(table.rows):
|
||||
for j, cell in enumerate(row.cells):
|
||||
for p in cell.paragraphs:
|
||||
for run in p.runs:
|
||||
rpr = run._element.find(qn('w:rPr'))
|
||||
if rpr is None:
|
||||
errors.append(f'Row{i}Col{j}: no rPr')
|
||||
continue
|
||||
rf = rpr.find(qn('w:rFonts'))
|
||||
if rf is None:
|
||||
errors.append(f'Row{i}Col{j}: no rFonts')
|
||||
continue
|
||||
ea = rf.get(qn('w:eastAsia'))
|
||||
ascii_f = rf.get(qn('w:ascii'))
|
||||
hAnsi = rf.get(qn('w:hAnsi'))
|
||||
if ea != '仿宋':
|
||||
errors.append(f'Row{i}Col{j}: eastAsia={ea} (should be 仿宋)')
|
||||
if ascii_f != 'Times New Roman':
|
||||
errors.append(f'Row{i}Col{j}: ascii={ascii_f} (should be TNR)')
|
||||
if hAnsi != 'Times New Roman':
|
||||
errors.append(f'Row{i}Col{j}: hAnsi={hAnsi} (should be TNR)')
|
||||
return errors
|
||||
```
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 错误 | 后果 | 根因 |
|
||||
|------|------|------|
|
||||
| ascii/hAnsi=仿宋 | 英文/数字渲染用仿宋(无西文字形,显示异常) | LLM把"统一仿宋"理解为所有属性都设仿宋 |
|
||||
| 数据行无ascii/hAnsi | 英文回退系统默认字体(可能是宋体/黑体) | 只设了eastAsia,忘设西文字体 |
|
||||
| 只有表头有字体 | 数据行全部回退默认 | 模板限制:只有表头行有显式字体 |
|
||||
|
||||
## 预防方案
|
||||
|
||||
最佳方案:修改模板文件的**Normal样式或Table Grid样式**定义,预设完整字体。但模板可能被多场景共用,最稳妥还是生成后强制设置。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 审查意见文档格式检查清单 (2026-07-13 Doro纠正)
|
||||
|
||||
## 生成后必做三项格式修复
|
||||
|
||||
### 1. 删除表格中的空白行
|
||||
- "空白行"指**表格中**三列全为空的row(模板占位行)
|
||||
- 不是文档段落的空行
|
||||
- 代码:遍历table rows,检查所有cells文本为空的row,删除
|
||||
|
||||
### 2. 页眉日期改为修订当日
|
||||
- 读取 header*.xml,找到日期文本(如"2019/3")
|
||||
- ⚠️ 日期经常被拆成多个run(如"201"+"9"+"/"+"3")
|
||||
- 需逐run处理:拼出完整日期字符串 → 替换为当日(如"2026/7")
|
||||
- 格式:YYYY/M(不补零)
|
||||
|
||||
### 3. 标题必须用合同正文全称
|
||||
- 读合同docx正文P0(或前几段)获取合同标题全称
|
||||
- 填入审查意见文档的《》内
|
||||
- ❌ 不能用文件名(文件名可能是简称、带前缀、有(1)后缀)
|
||||
- ✅ 必须是合同正文中出现的完整合同名称
|
||||
|
||||
## 内容规则(不变)
|
||||
- 只写原文和修订后内容,不做理由说明
|
||||
- 不写(注:...)
|
||||
- 行顺序按条款号排列
|
||||
- 有修改意见时删除"无法律修改意见。"段落
|
||||
- 批注内容也要体现在表格中
|
||||
|
||||
## 字体硬规则
|
||||
| 位置 | eastAsia | ascii | hAnsi | sz | bold | hint |
|
||||
|------|----------|-------|-------|-----|------|------|
|
||||
| 标题 | 仿宋 | - | - | 32(16pt) | True | eastAsia |
|
||||
| 表头 | 仿宋 | TNR | TNR | 24(12pt) | True | eastAsia |
|
||||
| 数据行 | 仿宋 | TNR | TNR | 24(12pt) | False | eastAsia |
|
||||
| 签名 | 仿宋 | TNR | TNR | 24(12pt) | False | eastAsia |
|
||||
|
||||
## 同模板合同审查意见一致性
|
||||
- 共有修订行内容完全一致
|
||||
- 行顺序统一(按条款号)
|
||||
- 个案差异行按各合同实际情况(如金额批注)
|
||||
@@ -0,0 +1,44 @@
|
||||
# 审查意见文档格式规则 (2026-07-13 Doro纠正)
|
||||
|
||||
## 规则来源
|
||||
朱家角 review-rules.md "审查意见格式要求" 章节 (2026-07-13 更新)
|
||||
|
||||
## 规则内容
|
||||
|
||||
### 1. 删除表格中的空白行
|
||||
- "空白行"指的是审查意见表格中**三列全空**的行(条文/原文/修订后都没有内容)
|
||||
- 不是正文段落的空白行——正文段落空行是排版问题,表格空行才是Doro说的"删除空白行"
|
||||
- 2026-07-13教训:Doro说"删除空白行",小Maggie误解为删正文段落空行,被纠正后才看到是表格Row1-Row4全空
|
||||
|
||||
### 2. 页眉日期改为修订当日
|
||||
- 审查意见模板页眉中有日期(如 header2.xml 中 "2019/3")
|
||||
- 生成审查意见时必须更新为**修订当日**的年/月(如"2026/7")
|
||||
- 注意:页眉中的日期可能拆分在多个run中(如"201"+"9"+"/"+"3"),需逐run定位修改
|
||||
|
||||
### 3. 标题必须用合同正文全称
|
||||
- 规则:"标题《》内填写所审查的合同名称"
|
||||
- "合同名称" = 合同正文第一段的标题(如"医疗设备器械购销合同"),**不是文件名**
|
||||
- 文件名可能是简写(如"医疗合同(2).doc"),但审查意见标题必须写全称
|
||||
- 2026-07-13教训:文件名"医疗合同(2)",合同正文标题是"医疗设备器械购销合同",审查意见标题应为"关于《医疗设备器械购销合同》的审查意见"
|
||||
|
||||
## Workflow重复处理检测(2026-07-13 香花桥安全生产合同教训)
|
||||
|
||||
### 问题
|
||||
待审查目录的文件可能**已含WB tracked changes**(上一轮workflow产出被放回了待审查)。workflow不做去重检测,会在已有修订上再跑一遍,导致:
|
||||
- 文字重复(如"全部损失全部损失")
|
||||
- 相同内容被双重标记为INS(冗余修订痕迹)
|
||||
|
||||
### 检测方法
|
||||
修复/审查前**第一步**:检查待审查文件是否已有author=WB的tracked changes
|
||||
```python
|
||||
for ins in body.iter(f'{WNS}ins'):
|
||||
if ins.get(f'{WNS}author') == 'WB':
|
||||
# 文件已被处理过!
|
||||
```
|
||||
|
||||
### 正确做法
|
||||
如果待审查文件已有WB修订:
|
||||
1. **以待审查版为基底**(它的第一轮修订是正确的)
|
||||
2. 只在此基础上补充缺失的修订(如名称统一)
|
||||
3. **不使用任务交付目录的二次处理版本**(它有重复)
|
||||
4. 排查是否是auto_notify重复触发或手动误操作导致
|
||||
@@ -0,0 +1,83 @@
|
||||
# 审查意见文档生成模式(2026-07-02 确立)
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **只体现差异,不做理由说明** — 表格三列(条文|原文|修订后)只写文字差异
|
||||
2. **字体必须显式设置** — 不依赖模板继承,每个run四属性齐全
|
||||
3. **同模板合同内容必须一致** — 行顺序按条款号,模板级修订表述相同
|
||||
|
||||
## 字体规则
|
||||
|
||||
```python
|
||||
def set_cell_font(cell, text, east_asia='仿宋', ascii_font='Times New Roman', h_ansi='Times New Roman', bold=False):
|
||||
"""Set cell text with proper font - every run must have explicit rFonts"""
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
|
||||
# Clear existing content
|
||||
for p in cell.paragraphs[1:]:
|
||||
cell._element.remove(p._element)
|
||||
p = cell.paragraphs[0]
|
||||
for r in p._element.findall(qn('w:r')):
|
||||
p._element.remove(r)
|
||||
|
||||
# Set paragraph alignment to justify
|
||||
pPr = p._element.find(qn('w:pPr'))
|
||||
if pPr is None:
|
||||
pPr = OxmlElement('w:pPr')
|
||||
p._element.insert(0, pPr)
|
||||
jc = pPr.find(qn('w:jc'))
|
||||
if jc is None:
|
||||
jc = OxmlElement('w:jc')
|
||||
pPr.append(jc)
|
||||
jc.set(qn('w:val'), 'both')
|
||||
|
||||
# Add run with explicit font settings
|
||||
run = p.add_run(text)
|
||||
rPr = run._element.find(qn('w:rPr'))
|
||||
if rPr is None:
|
||||
rPr = OxmlElement('w:rPr')
|
||||
run._element.insert(0, rPr)
|
||||
|
||||
rFonts = OxmlElement('w:rFonts')
|
||||
rFonts.set(qn('w:eastAsia'), east_asia)
|
||||
rFonts.set(qn('w:ascii'), ascii_font)
|
||||
rFonts.set(qn('w:hAnsi'), h_ansi)
|
||||
rPr.insert(0, rFonts)
|
||||
|
||||
if bold:
|
||||
b = OxmlElement('w:b')
|
||||
rPr.append(b)
|
||||
```
|
||||
|
||||
## 内容格式
|
||||
|
||||
### ✅ 正确(只体现差异)
|
||||
| 条文 | 原文 | 修订后 |
|
||||
|------|------|--------|
|
||||
| 第1条 | 买方同意向卖方购买,同时卖方同意授予买方以下器械 | 甲方同意向乙方购买,同时乙方同意向甲方出售以下器械 |
|
||||
| 第7.1.2条 | 按照器械的疵劣程度 | 按照器械的瑕疵程度 |
|
||||
|
||||
### ❌ 错误(带理由说明)
|
||||
| 条文 | 原文 | 修订后 |
|
||||
|------|------|--------|
|
||||
| 第1条 | ... | 甲方同意向乙方购买……(注:统一称谓为甲方/乙方,"授予"修改为"出售"以准确反映买卖关系) |
|
||||
|
||||
## 同模板合同一致性保证
|
||||
|
||||
当同一顾问单位有多份同模板合同时:
|
||||
|
||||
1. 先确定模板级修订点列表(所有同模板合同共享的问题)
|
||||
2. 每份合同的审查意见必须包含**全部**模板级修订点
|
||||
3. 行顺序统一按条款号排列
|
||||
4. 个案差异(如金额问题)在统一行之外单独加行
|
||||
5. 修订后列的文字必须完全一致(逐字对比)
|
||||
|
||||
## 验证清单
|
||||
|
||||
生成完成后必须验证:
|
||||
- [ ] 所有数据行的每个run都有eastAsia=仿宋 + ascii/hAnsi=Times New Roman
|
||||
- [ ] 修订后列无(注:...)、无理由解释
|
||||
- [ ] 行顺序按条款号排列
|
||||
- [ ] 同模板合同的审查意见行数一致(除个案差异行外)
|
||||
- [ ] 标题包含合同全称
|
||||
@@ -0,0 +1,64 @@
|
||||
# 审查意见文档生成规则(朱家角模板)
|
||||
|
||||
## 2026-07-02 Doro多次纠正后确立
|
||||
|
||||
### 模板结构
|
||||
路径:`~/.hermes/shared/模版库/朱家角 审查意见【模板】.docx`
|
||||
(新模板参考:`Doro合同审查任务/参考文件/朱家角 审查意见【新模板】.docx`)
|
||||
|
||||
1. 空行(P0, 居中)
|
||||
2. 标题:`关于《XX》的审查意见`(居中、仿宋 **16pt**(sz=32) **加粗**)
|
||||
3. `无法律修改意见。`(有审查意见时**删除此行**)
|
||||
4. `审查意见:`(仿宋 12pt)
|
||||
5. 表格(条文|原文|修订后)
|
||||
6. 空行
|
||||
7. 签名:`邱庭 律师`(右对齐、仿宋 12pt)
|
||||
|
||||
### 字体规格(从模板XML实际读取,非推测)
|
||||
- 标题:eastAsia=仿宋, sz=32(16pt), bold=True, **ascii=None**(模板未设)
|
||||
- 表头行:eastAsia=仿宋, sz=24(12pt), bold=True
|
||||
- 数据行:eastAsia=仿宋, ascii=Times New Roman, hAnsi=Times New Roman, sz=24(12pt), bold=False, hint=eastAsia
|
||||
- 正文段:eastAsia=仿宋, sz=24(12pt)
|
||||
|
||||
⚠️ 模板本身只设了`eastAsia=仿宋`没有设`ascii`。按review-rules.md要求英文用TNR,生成时应显式设ascii/hAnsi=Times New Roman。
|
||||
|
||||
### 文档格式处理(2026-07-12 Doro要求)
|
||||
- **删除空白行**:文档中所有无内容的空段落必须删除(模板自带的空行也删)
|
||||
- **页眉日期改为修订当日**:header*.xml中如有日期(如"2019/3"),改为当日日期(如"2026/7")。注意日期可能被拆分为多个run(如"201"+"9"+"/"+"3"),需逐run处理
|
||||
- **标题用合同正文中的实际标题**:从合同docx正文提取合同全称(如"医疗设备器械购销合同"),填入《》内。绝不用文件名代替(文件名可能是简称如"医疗合同(2)")
|
||||
- **标题格式**:`关于《XX合同全称》的审查意见`,确保无多余占位符残留
|
||||
|
||||
### 内容规则(2026-07-02 Doro明确)
|
||||
- **只写原文和修订后的内容(包括批注内容),不做理由说明**
|
||||
- ❌ 不写 (注:统一称谓…)
|
||||
- ❌ 不写 (注:原引用法规…)
|
||||
- ✅ 条文列:第X条 / 第X.X条 / 新增X.X条(主题)
|
||||
- ✅ 原文列:合同原文
|
||||
- ✅ 修订后列:修订后文字 / 批注内容(如"请注意确认金额")
|
||||
- 行顺序按条款号排列
|
||||
- 新增条款:条文栏写"新增X.X条(主题)",修订后栏直接写条文内容
|
||||
|
||||
### 同模板合同的审查意见统一规则
|
||||
- 格式、字体、行顺序统一
|
||||
- 共有修订行内容完全相同
|
||||
- 个案差异行(如金额批注)按各合同实际情况处理
|
||||
- 没有问题的合同不要强加批注行
|
||||
|
||||
### 生成代码要点
|
||||
```python
|
||||
# 字体设置(数据行)
|
||||
def set_run_font(run_elem, east_asia='仿宋', ascii_font='Times New Roman', h_ansi='Times New Roman', sz_val=24):
|
||||
rFonts.set(qn('w:eastAsia'), east_asia)
|
||||
rFonts.set(qn('w:ascii'), ascii_font)
|
||||
rFonts.set(qn('w:hAnsi'), h_ansi)
|
||||
rFonts.set(qn('w:hint'), 'eastAsia')
|
||||
sz.set(qn('w:val'), str(sz_val)) # 24 = 12pt
|
||||
szCs.set(qn('w:val'), str(sz_val))
|
||||
```
|
||||
|
||||
### 常见错误(本session犯过的)
|
||||
1. 未设sz_val → 回退到默认字号
|
||||
2. ascii设成仿宋 → 英文也变仿宋
|
||||
3. 用bytes literal写中文 → unicode转义不解析显示乱码
|
||||
4. 忘删"无法律修改意见。" → 矛盾
|
||||
5. 写(注:...)理由 → 规则禁止
|
||||
@@ -0,0 +1,32 @@
|
||||
# 同模板合同审查一致性规则
|
||||
|
||||
## 2026-07-02 朱家角恭兴+肃言合同教训
|
||||
|
||||
### 问题
|
||||
两份同模板购销合同(结构完全一致,仅乙方名称和设备清单不同),workflow串行审查后修订不一致:
|
||||
- 恭兴发现了6.4条(药监局法规过时)但遗漏7.3条(侵权兜底)
|
||||
- 肃言发现了7.3条但遗漏6.4条
|
||||
|
||||
### 根因
|
||||
workflow是逐份串行处理(relay-runner),每份合同完全独立走reviewer→editor→deliverer,各session之间零状态共享。LLM每次独立推理,对同一段文字的优先级判断有随机性。
|
||||
|
||||
### 解决办法(已实施)
|
||||
|
||||
1. **review-rules.md增加同模板一致性规则**(已做)
|
||||
2. **reviewer skill增加前置检查**:审查前检查同目录是否有同模板已审查的合同,对齐修订点
|
||||
3. **手动审查时的铁律**:同批同模板合同必须先审完一份→确认修订点→后续合同按同样标准执行
|
||||
|
||||
### 判断"同模板"的方法
|
||||
- 合同标题完全相同
|
||||
- 正文条款结构一致(前20行匹配度>80%)
|
||||
- 甲方相同,乙方不同
|
||||
- 区别仅在商业条款(金额、设备清单、乙方信息等)
|
||||
|
||||
### 审查意见的统一要求
|
||||
- 共有修订行:内容必须完全一致
|
||||
- 行顺序:按条款号排列
|
||||
- 个案差异:只有真实存在的问题才加行(如恭兴金额有误加批注行,肃言金额正确则不加)
|
||||
- 格式:字体/字号/对齐统一
|
||||
|
||||
### 批注的统一原则
|
||||
"统一"是审查逻辑统一,不是机械复制。金额有问题的合同加批注,没问题的不加——逻辑一致即可。不能给正确的合同强加问题批注。
|
||||
@@ -0,0 +1,189 @@
|
||||
# Same-Template Revision Transfer (同模板修订参照)
|
||||
|
||||
When Doro says "参照X合同的修订进行修订" — apply the same WB revisions from a reference contract to another contract using the same template.
|
||||
|
||||
## ⚠️ Doro 强制验证纪律(2026-07-08 明确要求)
|
||||
|
||||
Doro 明确要求做同模板修订参照时必须走完以下步骤,缺一不可:
|
||||
|
||||
1. **先确认模板一致性**:逐段对比两份合同原文(去掉修订后的文本),确认段落数一致、差异仅限业务内容(项目名称、单价等),其余结构完全相同。打印差异段数/总段数(如"9/62段有差异")。
|
||||
2. **参照修订**:提取参照合同的WB修订→适配目标合同业务语境→应用
|
||||
3. **格式/字体/编号全检**:所有INS的rPr必须与前后邻居run一致(逐个检查sz/rFonts/bold)。遵守workflow规则(author=WB、精准到字、不整段del+ins)
|
||||
4. **全文阅读审查合理性**:渲染accept后全文,逐段通读确认修订逻辑合理、不破坏上下文语义
|
||||
5. **交付前检查**:python-docx可打开、无异常字符、修订数与参照合同一致、他人修订保持不动
|
||||
|
||||
**不能跳步直接做修订然后上传。** Doro原话:"你先确认:两个合同是不是模板一样,内容一样;如果一样,参照修改;你修改的格式、字体、编号等,都要遵守workflow的规则;全文阅读,修订是否合理。最后检查交付。"
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Extract revisions from reference contract
|
||||
|
||||
```python
|
||||
import zipfile
|
||||
from lxml import etree
|
||||
|
||||
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
|
||||
def extract_wb_revisions(filepath):
|
||||
"""Extract all WB-authored INS and DEL from a contract."""
|
||||
with zipfile.ZipFile(filepath, 'r') as z:
|
||||
content = z.read('word/document.xml')
|
||||
tree = etree.fromstring(content)
|
||||
|
||||
revisions = []
|
||||
for ins in tree.iter(f'{{{ns}}}ins'):
|
||||
if ins.get(f'{{{ns}}}author') != 'WB':
|
||||
continue
|
||||
texts = [t.text for t in ins.iter(f'{{{ns}}}t') if t.text]
|
||||
# Get parent paragraph for context
|
||||
parent_p = ins
|
||||
while parent_p is not None and parent_p.tag != f'{{{ns}}}p':
|
||||
parent_p = parent_p.getparent()
|
||||
p_texts = [t.text for t in parent_p.iter(f'{{{ns}}}t') if t.text] if parent_p is not None else []
|
||||
revisions.append({
|
||||
'type': 'INS', 'text': ''.join(texts),
|
||||
'para_context': ''.join(p_texts)[:120]
|
||||
})
|
||||
|
||||
for d in tree.iter(f'{{{ns}}}del'):
|
||||
if d.get(f'{{{ns}}}author') != 'WB':
|
||||
continue
|
||||
texts = [t.text for t in d.iter(f'{{{ns}}}delText') if t.text]
|
||||
parent_p = d
|
||||
while parent_p is not None and parent_p.tag != f'{{{ns}}}p':
|
||||
parent_p = parent_p.getparent()
|
||||
p_texts = [t.text for t in parent_p.iter(f'{{{ns}}}t') if t.text] if parent_p is not None else []
|
||||
revisions.append({
|
||||
'type': 'DEL', 'text': ''.join(texts),
|
||||
'para_context': ''.join(p_texts)[:120]
|
||||
})
|
||||
return revisions
|
||||
```
|
||||
|
||||
### Step 2: Identify modification patterns
|
||||
|
||||
Group INS/DEL pairs by paragraph context to understand what was changed:
|
||||
- Simple text replacement: DEL "协议" + INS "合同" in same paragraph
|
||||
- Text insertion: INS without corresponding DEL (e.g., data ownership sentence)
|
||||
- Prefix insertion: INS "上海市" before existing text
|
||||
|
||||
### Step 3: Context adaptation
|
||||
|
||||
When the template is shared but service content differs, adapt context-specific terms:
|
||||
- "体检服务" → "口腔检查服务"
|
||||
- "学生个人信息、健康检查结果" → "个人信息、检查结果"
|
||||
- Keep legal boilerplate identical (e.g., "归甲方所有", "合同期满")
|
||||
|
||||
### Step 4: Apply to target contract (zipfile+lxml)
|
||||
|
||||
Use three operations:
|
||||
|
||||
#### A. Tracked replace (DEL old + INS new)
|
||||
```python
|
||||
def do_tracked_replace(para, find_text, replace_text):
|
||||
"""Find text in paragraph runs, create DEL + INS."""
|
||||
# Build character map from runs (skip ins/del elements)
|
||||
char_map = []
|
||||
for elem in para:
|
||||
if elem.tag == f'{{{ns}}}r':
|
||||
t = elem.find(f'{{{ns}}}t')
|
||||
if t is not None and t.text:
|
||||
for ci in range(len(t.text)):
|
||||
char_map.append((elem, t, ci))
|
||||
|
||||
full_text = ''.join(cm[1].text[cm[2]] for cm in char_map)
|
||||
pos = full_text.find(find_text)
|
||||
if pos == -1:
|
||||
return False
|
||||
|
||||
# Verify single-run containment, then split into before/DEL/INS/after
|
||||
# ... (see session code for full implementation)
|
||||
```
|
||||
|
||||
#### B. Insert before text
|
||||
```python
|
||||
def do_tracked_insert_before(para, anchor_text, insert_text):
|
||||
"""Insert INS element right before anchor_text."""
|
||||
for elem in para:
|
||||
if elem.tag == f'{{{ns}}}r':
|
||||
t = elem.find(f'{{{ns}}}t')
|
||||
if t is not None and t.text and anchor_text in t.text:
|
||||
# Split run, insert INS before anchor portion
|
||||
...
|
||||
```
|
||||
|
||||
#### C. Insert after text
|
||||
```python
|
||||
def do_tracked_insert_after(para, anchor_text, insert_text):
|
||||
"""Insert INS element right after anchor_text."""
|
||||
for elem in para:
|
||||
if elem.tag == f'{{{ns}}}r':
|
||||
t = elem.find(f'{{{ns}}}t')
|
||||
if t is not None and t.text and anchor_text in t.text:
|
||||
# Split run, insert INS after anchor portion
|
||||
...
|
||||
```
|
||||
|
||||
## Data Attribution Rule (2026-07-08 Doro clarification)
|
||||
|
||||
When writing data ownership/attribution clauses across same-template contracts:
|
||||
|
||||
**LOCKED (identical across all contracts)**: 归属表述 = "归甲方或相关权利方所有"
|
||||
- NOT "归甲方所有" (excludes data subjects' rights under PIPL)
|
||||
- The phrase "甲方或相关权利方" covers both: data甲方 owns (aggregated stats, service outputs) AND personal info that belongs to data subjects
|
||||
|
||||
**NOT LOCKED (varies by contract)**: The descriptive content before the attribution phrase
|
||||
- 体检合同: "乙方在提供体检服务过程中获取和产生的全部数据(包括但不限于学生个人信息、健康检查结果等)"
|
||||
- 口腔检查合同: "乙方在提供口腔检查服务过程中获取和产生的全部数据(包括但不限于个人信息、检查结果等)"
|
||||
- Other contracts: adapt to the specific service/data context
|
||||
|
||||
**Doro原话**: "我只需要涉及到数据权利的归属时,把归属谁改成'归甲方或相关权利方所有',其他的内容不同合同会不同,所以你不能写死。"
|
||||
|
||||
**Rule source**: `review-rules.md` §4 保密/数据 (updated 2026-07-08)
|
||||
|
||||
## Pitfalls
|
||||
|
||||
### 1. `<w:proofErr>` splits runs
|
||||
Text like "青浦区练塘镇" may be split into multiple runs separated by `<w:proofErr>` elements:
|
||||
```xml
|
||||
<w:r><w:t>青浦区练塘</w:t></w:r>
|
||||
<w:proofErr w:type="gramStart"/>
|
||||
<w:r><w:t>镇社区卫生服务中心</w:t></w:r>
|
||||
```
|
||||
|
||||
**Fix**: Search at individual run level (`for elem in para: if elem.tag == w:r`), not at full paragraph text level. Insert INS before the run containing the anchor, not at a text position within full paragraph text.
|
||||
|
||||
### 2. "达成如下协议" — not all "协议" should be replaced
|
||||
In the reference contract, "达成如下协议:" was NOT changed (it's a formulaic expression meaning "reached the following agreement"). Only contextual uses of "协议" meaning "this agreement/contract" were changed to "合同"/"本合同".
|
||||
|
||||
**Rule**: Compare reference contract's accepted text to determine which instances were changed and which were left alone.
|
||||
|
||||
### 3. INS rPr must clone from target run (not reference)
|
||||
The target contract's runs may have different formatting than the reference. Always clone rPr from the **target paragraph's existing run**, not from the reference contract.
|
||||
|
||||
### 4. Order of operations matters
|
||||
Do replacements BEFORE insertions. Insertions change paragraph structure and character positions, which can break subsequent text searches.
|
||||
|
||||
Recommended order:
|
||||
1. All `do_tracked_replace` calls (these only split existing runs)
|
||||
2. All `do_tracked_insert_after` / `do_tracked_insert_before` calls (these add new elements)
|
||||
|
||||
### 5. Verify with accept-all view
|
||||
After applying all revisions, verify by building accepted text (skip DEL, include INS) for key paragraphs and comparing against the reference contract's accepted text.
|
||||
|
||||
## 2026-07-08 实证:练塘口腔检查合同
|
||||
|
||||
Reference: 【修】2026年学生体检外包合同--练塘(1).docx
|
||||
Target: 2026年口腔检查外包合同--练塘.docx
|
||||
|
||||
Modifications applied:
|
||||
| # | Type | Content | Adaptation |
|
||||
|---|------|---------|------------|
|
||||
| 1 | INS before "青浦区" | "上海市" | None (identical) |
|
||||
| 2a | INS after "保密义务。" | Data ownership sentence | "体检服务"→"口腔检查服务", "学生个人信息、健康检查结果"→"个人信息、检查结果" |
|
||||
| 2b | DEL "协议" + INS "合同" | "协议期满"→"合同期满" | None |
|
||||
| 2c | DEL "服务协议" + INS "本合同" | "服务协议解除"→"本合同解除" | None |
|
||||
| 2d | DEL "本协议" + INS "本合同" | "本协议的履行"→"本合同的履行" | None |
|
||||
| 3 | DEL "本协议" + INS "本合同" | "本协议一式"→"本合同一式" | None |
|
||||
|
||||
proofErr pitfall encountered: "青浦区练塘" split by `<w:proofErr>` — had to insert at run level rather than text-position level.
|
||||
@@ -0,0 +1,32 @@
|
||||
# 单段正文章节不加numPr(2026-07-12 盈浦健康科普合同)
|
||||
|
||||
## 规则
|
||||
当新增的章节(如"八、转包与分包")下只有**一段**正文时,该段落**不设numPr**。
|
||||
|
||||
## 判断方法
|
||||
1. 看原文中同样只有一段正文的章节是否有numPr
|
||||
2. 如果原文单段章节无numPr(如"一、合作背景"P12无numPr),新增也不加
|
||||
3. 多段正文章节有numPr(如"七、不可抗力"两段都有numId=11)
|
||||
4. 这是"有2才有1"规则在numPr层面的体现
|
||||
|
||||
## 实证
|
||||
盈浦健康科普服务合同:
|
||||
- 原文"一、合作背景": 1段正文 → 无numPr
|
||||
- 原文"七、不可抗力": 2段正文 → numId=11
|
||||
- 新增"八、转包与分包": 1段正文 → 不应有numPr
|
||||
|
||||
错误地加了numId=14(新建abstractNum),导致OnlyOffice渲染出孤零零的"1."
|
||||
|
||||
## 同时要检查的段落格式
|
||||
新增段落的pPr必须与原文同类型段落**完整匹配**:
|
||||
- `w:ind`(firstLine/firstLineChars)—— 首行缩进
|
||||
- `w:spacing`(line/lineRule)
|
||||
- 不能只有spacing没有ind
|
||||
|
||||
实证:原文正文段有 `ind firstLine=420 firstLineChars=200`,workflow新增P75只有spacing缺ind → 渲染无首行缩进。
|
||||
|
||||
## heading run的sz继承陷阱
|
||||
原文Heading 1样式定义sz=48(24pt),heading段落的plain run**没有显式sz**(靠样式继承)。
|
||||
workflow/ContractEditor操作后可能给某个run添加spurious `sz=20`(从szCs误取),导致该run从24pt变成10pt。
|
||||
|
||||
检查:修订后Heading段落的所有plain run不应有新增的显式sz。
|
||||
@@ -0,0 +1,175 @@
|
||||
# 拆分合并的标题+正文段落为两个独立INS段落
|
||||
|
||||
## 场景
|
||||
|
||||
Reviewer发现新增条款的标题和正文被合并在一个`<w:p>`段落中(通过`<w:t>`内的换行符分隔),要求拆分为两个独立段落——标题段和正文段,各有独立的格式。
|
||||
|
||||
## 判别
|
||||
|
||||
- 目标段落是一个`<w:p>`,内含一个`<w:ins author="WB">`,`<w:ins>`内只有一个`<w:r>`,`<w:t>`文本包含换行符(`\n`)分隔标题和正文
|
||||
- 标题格式要求:参照原文同级标题段落(如"第四条"或"第六条")
|
||||
- 正文格式要求:参照原文同层级正文段落(如"一、施工期限")
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 1. 读取原文并定位目标段落
|
||||
|
||||
```python
|
||||
import zipfile
|
||||
from lxml import etree
|
||||
import copy
|
||||
|
||||
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
|
||||
with zipfile.ZipFile(docx_path, 'r') as zf:
|
||||
doc_xml = etree.parse(zf.open('word/document.xml'))
|
||||
all_files = {name: zf.read(name) for name in zf.namelist()}
|
||||
|
||||
body = doc_xml.getroot().find(f'{{{W}}}body')
|
||||
paragraphs = list(body.findall(f'{{{W}}}p'))
|
||||
|
||||
# 找到目标段落
|
||||
for i, p in enumerate(paragraphs):
|
||||
texts = []
|
||||
for elem in p.iter(f'{{{W}}}t'):
|
||||
texts.append(elem.text or '')
|
||||
full_text = ''.join(texts)
|
||||
if '第五条' in full_text and '转包' in full_text:
|
||||
target_idx = i
|
||||
break
|
||||
```
|
||||
|
||||
### 2. 提取标题和正文文本
|
||||
|
||||
```python
|
||||
full_text = ''
|
||||
for elem in target_p.iter(f'{{{W}}}t'):
|
||||
full_text += elem.text or ''
|
||||
|
||||
lines = full_text.split('\n')
|
||||
title_text = lines[0].strip() # "第五条 转包与分包"
|
||||
body_text = '\n'.join(lines[1:]).strip() # 正文内容
|
||||
```
|
||||
|
||||
### 3. 找到参照段落并克隆pPr
|
||||
|
||||
标题段pPr从紧邻的同级标题段落克隆(如"第六条"),正文段pPr从同层级正文段落克隆(如"一、施工期限")。
|
||||
|
||||
```python
|
||||
# 标题参照段落(如"第六条")
|
||||
ref_title_p = paragraphs[24] # 原文"第六条"的索引
|
||||
ref_title_pPr = ref_title_p.find(f'{{{W}}}pPr')
|
||||
title_pPr = copy.deepcopy(ref_title_pPr)
|
||||
|
||||
# 正文参照段落(如"一、施工期限")
|
||||
ref_body_p = paragraphs[13] # 原文"一、施工期限"的索引
|
||||
ref_body_pPr = ref_body_p.find(f'{{{W}}}pPr')
|
||||
body_pPr = copy.deepcopy(ref_body_pPr)
|
||||
```
|
||||
|
||||
### 4. 构建标题段落
|
||||
|
||||
```python
|
||||
title_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
|
||||
title_p.append(title_pPr)
|
||||
|
||||
# 标题rPr:黑体四属性 + hint=eastAsia + sz=24 + bold
|
||||
title_rPr = etree.Element(f'{{{W}}}rPr')
|
||||
rFonts = etree.SubElement(title_rPr, f'{{{W}}}rFonts')
|
||||
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
|
||||
rFonts.set(f'{{{W}}}{attr}', '黑体')
|
||||
rFonts.set(f'{{{W}}}hint', 'eastAsia')
|
||||
etree.SubElement(title_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-6')
|
||||
etree.SubElement(title_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '24')
|
||||
etree.SubElement(title_rPr, f'{{{W}}}szCs').set(f'{{{W}}}val', '24')
|
||||
etree.SubElement(title_rPr, f'{{{W}}}b') # 加粗
|
||||
|
||||
title_ins = etree.SubElement(title_p, f'{{{W}}}ins')
|
||||
title_ins.set(f'{{{W}}}id', str(new_ins_id))
|
||||
title_ins.set(f'{{{W}}}author', 'WB')
|
||||
title_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')
|
||||
|
||||
title_r = etree.SubElement(title_ins, f'{{{W}}}r')
|
||||
title_r.set(f'{{{W}}}rsidR', '00AA0001')
|
||||
title_r.append(copy.deepcopy(title_rPr))
|
||||
title_t = etree.SubElement(title_r, f'{{{W}}}t')
|
||||
title_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
title_t.text = title_text
|
||||
```
|
||||
|
||||
### 5. 构建正文段落
|
||||
|
||||
```python
|
||||
body_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
|
||||
body_p.append(body_pPr)
|
||||
|
||||
# 正文rPr:宋体四属性 + hint=eastAsia + sz=21
|
||||
body_rPr = etree.Element(f'{{{W}}}rPr')
|
||||
body_rFonts = etree.SubElement(body_rPr, f'{{{W}}}rFonts')
|
||||
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
|
||||
body_rFonts.set(f'{{{W}}}{attr}', '宋体')
|
||||
body_rFonts.set(f'{{{W}}}hint', 'eastAsia')
|
||||
etree.SubElement(body_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-4')
|
||||
etree.SubElement(body_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '21')
|
||||
|
||||
body_ins = etree.SubElement(body_p, f'{{{W}}}ins')
|
||||
body_ins.set(f'{{{W}}}id', str(new_ins_id + 1))
|
||||
body_ins.set(f'{{{W}}}author', 'WB')
|
||||
body_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')
|
||||
|
||||
body_r = etree.SubElement(body_ins, f'{{{W}}}r')
|
||||
body_r.set(f'{{{W}}}rsidR', '00AA0001')
|
||||
body_r.append(copy.deepcopy(body_rPr))
|
||||
body_t = etree.SubElement(body_r, f'{{{W}}}t')
|
||||
body_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
body_t.text = body_text
|
||||
```
|
||||
|
||||
### 6. 插入并删除原段落(⚠️ addprevious顺序陷阱)
|
||||
|
||||
**关键**:`addprevious`将元素插入到目标元素的**紧邻前一个**位置。要得到 [title, body, old_p] 的顺序,必须:
|
||||
|
||||
```python
|
||||
old_p = paragraphs[target_idx]
|
||||
old_p.addprevious(body_p) # 先插入body → 顺序: body, old_p
|
||||
body_p.addprevious(title_p) # 再在body前插入title → 顺序: title, body, old_p
|
||||
body.remove(old_p) # 删除原段落 → 顺序: title, body, ...
|
||||
```
|
||||
|
||||
**错误做法**(会导致顺序反转):
|
||||
```python
|
||||
# ❌ 错误:title在body之后
|
||||
old_p.addprevious(title_p) # title, old_p
|
||||
old_p.addprevious(body_p) # title, body, old_p ← 看起来对但实际是 body, title, old_p
|
||||
```
|
||||
|
||||
原理:`addprevious`始终插入到目标元素的紧邻前一个位置。`old_p.addprevious(body_p)` 后 body_p 是 old_p 的前一个兄弟;`old_p.addprevious(title_p)` 后 title_p 成为 old_p 的前一个兄弟,body_p 被推到 title_p 之前。
|
||||
|
||||
### 7. 保存
|
||||
|
||||
```python
|
||||
new_doc_xml = etree.tostring(doc_xml.getroot(), xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
|
||||
with zipfile.ZipFile(docx_path, 'w', zipfile.ZIP_DEFLATED) as zf_out:
|
||||
for name, data in all_files.items():
|
||||
if name == 'word/document.xml':
|
||||
zf_out.writestr(name, new_doc_xml)
|
||||
else:
|
||||
zf_out.writestr(name, data)
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
1. **段落顺序**:确认 [title_idx] 是标题文本,[title_idx+1] 是正文文本
|
||||
2. **字体属性**:标题 rPr 含 rFonts四属性(黑体) + hint=eastAsia + sz=24 + bold;正文 rPr 含 rFonts四属性(宋体) + hint=eastAsia + sz=21
|
||||
3. **INS属性**:author=WB, 有 rsidR, 有唯一id
|
||||
4. **OnlyOffice渲染**:x2t渲染为PDF,pdftotext确认标题和正文各占一行,正文有缩进
|
||||
5. **validate()**:运行ContractEditor的validate(),区分预存误报和本轮新增问题
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 标题和正文的pPr应从**紧邻的原文同级段落**克隆,而非从目标段落自身克隆
|
||||
- rFonts必须设置四属性(ascii, hAnsi, eastAsia, cs),仅设hint=eastAsia是不够的
|
||||
- 标题的bold属性按照reviewer的指令设置(注意:原文标题可能不加粗,但reviewer可能要求加粗)
|
||||
- 两个INS段落使用不同的id(从文档中max_ins_id+1开始递增)
|
||||
- 操作前先备份原文件
|
||||
@@ -0,0 +1,60 @@
|
||||
# Split-Run Numbering in docx XML
|
||||
|
||||
## Problem
|
||||
Contract numbering like `(5)` is often split across multiple `<w:r>` runs in the XML:
|
||||
```xml
|
||||
<w:r><w:t>(</w:t></w:r>
|
||||
<w:r><w:t>5</w:t></w:r>
|
||||
<w:r><w:t>)委托方</w:t></w:r>
|
||||
```
|
||||
|
||||
A naive `tracked_replace("(5)", "(6)")` searching for the complete string in a single `<w:t>` will **silently fail** — no match, no error, no renumbering.
|
||||
|
||||
## Solution: Multi-run concatenation + split
|
||||
|
||||
### Algorithm
|
||||
```python
|
||||
def tracked_replace_split_number(p, old_num, new_num):
|
||||
"""Handle (old_num) spread across multiple runs."""
|
||||
target = f'({old_num})'
|
||||
new_target = f'({new_num})'
|
||||
|
||||
# 1. Collect all plain runs (not inside w:ins or w:del)
|
||||
plain_runs = [(index, run, text) for each child of p]
|
||||
|
||||
# 2. Slide a window: concatenate adjacent run texts until target is found
|
||||
for start in range(len(plain_runs)):
|
||||
concat = ""
|
||||
for end in range(start, start+4): # max 4 runs for a number
|
||||
concat += plain_runs[end].text
|
||||
if target in concat:
|
||||
# Found! Extract before/after text around the number
|
||||
runs_to_wrap = plain_runs[start:end+1]
|
||||
# ...proceed to replace
|
||||
|
||||
# 3. Remove original runs, insert:
|
||||
# - [before_run if text before number]
|
||||
# - DEL element with delText=target
|
||||
# - INS element with t=new_target
|
||||
# - [after_run if text after number, e.g. "委托方"]
|
||||
|
||||
# 4. Set rsid attributes: rsidDel on DEL runs, rsidR on INS runs
|
||||
```
|
||||
|
||||
### Critical: Process order
|
||||
**Always renumber from bottom to top** (last paragraph first) to avoid index shifting:
|
||||
```python
|
||||
# CORRECT
|
||||
renumber = [(P113, '10', '11'), (P112, '9', '10'), (P111, '7', '8')]
|
||||
|
||||
# WRONG - P112 was already renumbered when we get to it
|
||||
renumber = [(P111, '7', '8'), (P112, '9', '10'), (P113, '10', '11')]
|
||||
```
|
||||
|
||||
### Edge cases encountered (2026-06-08)
|
||||
- `(` + `10)` (two runs, not three) — the closing `)` merged with the digit
|
||||
- `(` + `5` + `)委托方` — closing `)` merged with following text, must split run to preserve "委托方"
|
||||
- Copy `w:rPr` from original runs to all new DEL/INS runs to preserve font/size
|
||||
|
||||
## Lesson
|
||||
This was the root cause of a terminal review failure where 3 new clauses were inserted without numbering, and subsequent numbering was not renumbered. The `tracked_replace` function matched nothing because it expected `(5)` as a single text node.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Standalone Char-Level Tracked Changes + Comments (non-workflow)
|
||||
|
||||
When modifying contracts **outside** the Doro/邱律师 workflow (e.g. Maggie directly asks to revise a client's agreement), the full `ContractEditor` library + review-rules machinery is overkill. Use this lightweight pattern instead.
|
||||
|
||||
## When to use
|
||||
- Maggie sends a contract and says "帮我改一下" / "修改这份协议"
|
||||
- No workflow, no reviewer, no deliverer — just direct revision
|
||||
- Still must produce Word-native tracked changes (del/ins) + comments
|
||||
|
||||
## Core technique: `difflib.SequenceMatcher` char-level diff
|
||||
|
||||
```python
|
||||
import difflib
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
|
||||
def char_level_replace(para, new_text, author="WB", date="2026-07-07T10:00:00Z"):
|
||||
"""Replace paragraph text with char-level tracked changes.
|
||||
Unchanged chars → normal w:r (preserved).
|
||||
Deleted chars → w:del + w:delText.
|
||||
Inserted chars → w:ins + w:t.
|
||||
"""
|
||||
p = para._element
|
||||
old_text = para.text
|
||||
if old_text == new_text:
|
||||
return
|
||||
|
||||
# Remove existing runs (preserve pPr)
|
||||
for child in list(p):
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag in ('r', 'ins', 'del', 'hyperlink'):
|
||||
p.remove(child)
|
||||
|
||||
sm = difflib.SequenceMatcher(None, old_text, new_text)
|
||||
for op, i1, i2, j1, j2 in sm.get_opcodes():
|
||||
if op == 'equal':
|
||||
p.append(make_run(old_text[i1:i2]))
|
||||
elif op == 'delete':
|
||||
p.append(make_del_run(old_text[i1:i2], author, date))
|
||||
elif op == 'insert':
|
||||
p.append(make_ins_run(new_text[j1:j2], author, date))
|
||||
elif op == 'replace':
|
||||
p.append(make_del_run(old_text[i1:i2], author, date))
|
||||
p.append(make_ins_run(new_text[j1:j2], author, date))
|
||||
```
|
||||
|
||||
## Comments injection (bypassing python-docx limitations)
|
||||
|
||||
python-docx has no native comment support. Inject manually:
|
||||
|
||||
1. Add `commentRangeStart` + `commentRangeEnd` + `commentReference` run to target paragraph
|
||||
2. Build `word/comments.xml` as a plain string (proper namespace, no lxml serialization quirks)
|
||||
3. Inject into the docx ZIP: update `[Content_Types].xml` + `word/_rels/document.xml.rels`
|
||||
|
||||
### Critical: comments.xml namespace
|
||||
|
||||
**Wrong** (causes "reuse of xmlns" error):
|
||||
```python
|
||||
comments_xml = etree.Element(qn('w:comments'))
|
||||
comments_xml.set(qn('xmlns:w'), WNS) # ❌ double declaration
|
||||
```
|
||||
|
||||
**Right** (build as plain string):
|
||||
```python
|
||||
def build_comments_xml(comments_list):
|
||||
lines = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
|
||||
lines.append('<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
|
||||
' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">')
|
||||
for cid, text in comments_list:
|
||||
safe = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
lines.append(f' <w:comment w:id="{cid}" w:author="WB" w:date="..." w:initials="WB">')
|
||||
lines.append(f' <w:p><w:r><w:t>{safe}</w:t></w:r></w:p>')
|
||||
lines.append(f' </w:comment>')
|
||||
lines.append('</w:comments>')
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
## Pitfalls learned (2026-07-07 退休返聘案)
|
||||
|
||||
1. **lxml etree serialization breaks Word**: `etree.tostring()` produces `xmlns:ns0=...` prefix notation that Word/OnlyOffice cannot parse. Always build comments.xml as a plain string.
|
||||
2. **Entire-paragraph del+ins is unacceptable**: Maggie and Doro both require char-level precision. "原文相同的部分保留,不一样的用修订" — this is non-negotiable.
|
||||
3. **New paragraphs (fully inserted)**: Use `pPr/rPr/ins` mark to flag the ¶ itself as inserted, plus `w:ins` wrapping the text run. Both are needed for Word to show the full paragraph as tracked insertion.
|
||||
4. **Verify files open correctly**: After save, always `Document(path)` to confirm no XML parse errors.
|
||||
|
||||
## Template (full working script structure)
|
||||
|
||||
See `/tmp/modify_v3_charlevel.py` from the 2026-07-07 session — processes two contracts (full-time + part-time) with char-level diff + comments injection. Pattern: `process_contract(input, output, is_fulltime=bool)`.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Strip numPr Before Inserting Manual Numbering (2026-07-01)
|
||||
|
||||
## Problem
|
||||
|
||||
When adding manual numbering (e.g. "第一条 ") as `w:ins` at the beginning of a paragraph that already has `<w:numPr>` (automatic numbering like "%1." decimal format), OnlyOffice renders BOTH:
|
||||
- The automatic number: "1."
|
||||
- The manual INS text: "第一条"
|
||||
|
||||
Result: "1. 第一条 乙方应严格按照..."
|
||||
|
||||
## Root Cause
|
||||
|
||||
`<w:numPr>` in pPr tells the rendering engine to prepend an auto-generated number. The `w:ins` text is just another run in the paragraph — it doesn't suppress the auto-numbering.
|
||||
|
||||
Additionally, `<w:pPrChange>` records the pre-revision pPr state. If pPrChange still contains `<w:numPr>`, some renderers will show the old numbering in markup view.
|
||||
|
||||
## Affected Scenarios
|
||||
|
||||
1. **反委托代发工资协议 (2026-07-01)**: Original paragraphs P6-P12, P19 had `numId=1` or `numId=3` (decimal "%1." format). After adding "第一条" through "第十一条" as INS, OnlyOffice showed "1. 第一条", "2. 第二条", etc.
|
||||
|
||||
2. **Any contract where the original used auto-numbering**: Check `numbering.xml` for active numId references with `numFmt=decimal` or `numFmt=chineseCounting`.
|
||||
|
||||
## Fix Pattern
|
||||
|
||||
```python
|
||||
import zipfile
|
||||
from lxml import etree
|
||||
|
||||
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
|
||||
with zipfile.ZipFile(docx_path, 'r') as z:
|
||||
all_files = {name: z.read(name) for name in z.namelist()}
|
||||
|
||||
doc = etree.fromstring(all_files['word/document.xml'])
|
||||
body = doc.find(f'{WNS}body')
|
||||
paras = body.findall(f'{WNS}p')
|
||||
|
||||
# Identify paragraphs where we added manual numbering INS
|
||||
# These are paragraphs that have both:
|
||||
# 1. A w:ins with author=WB containing "第X条" text
|
||||
# 2. A pPr with numPr
|
||||
|
||||
for i, p in enumerate(paras):
|
||||
ppr = p.find(f'{WNS}pPr')
|
||||
if ppr is None:
|
||||
continue
|
||||
|
||||
# Check if this paragraph has our manual numbering INS
|
||||
has_manual_numbering = False
|
||||
for child in p:
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag == 'ins' and child.get(f'{WNS}author') == 'WB':
|
||||
text = ''.join(t.text for t in child.iter(f'{WNS}t') if t.text)
|
||||
if '第' in text and '条' in text:
|
||||
has_manual_numbering = True
|
||||
break
|
||||
|
||||
if not has_manual_numbering:
|
||||
continue
|
||||
|
||||
# Strip numPr from pPr
|
||||
num_pr = ppr.find(f'{WNS}numPr')
|
||||
if num_pr is not None:
|
||||
ppr.remove(num_pr)
|
||||
print(f"P{i}: Stripped numPr from pPr")
|
||||
|
||||
# Strip numPr from pPrChange
|
||||
ppc = ppr.find(f'{WNS}pPrChange')
|
||||
if ppc is not None:
|
||||
inner_ppr = ppc.find(f'{WNS}pPr')
|
||||
if inner_ppr is not None:
|
||||
inner_num = inner_ppr.find(f'{WNS}numPr')
|
||||
if inner_num is not None:
|
||||
inner_ppr.remove(inner_num)
|
||||
print(f"P{i}: Stripped numPr from pPrChange")
|
||||
|
||||
# Save back
|
||||
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
# Write to temp file then replace (never write to same zip you're reading)
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After stripping, render with OnlyOffice and confirm:
|
||||
1. Markup view shows only the manual numbering (no "1." prefix)
|
||||
2. Accepted-revisions view shows clean "第一条" through "第十一条"
|
||||
3. python-docx can still open the file without errors
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **DEL-only empty paragraphs** (e.g. P8, P9 where all content is w:del): These may still have numPr. If they render a visible "3." or "4." in the gap, strip those too.
|
||||
- **Cross-paragraph clauses** (P6+P7 = one clause): P7 may have its own independent numPr even though it's a continuation paragraph. Strip it.
|
||||
- **numId=0 (disabled numbering)**: `numId=0` in OOXML means "numbering OFF" — it doesn't render anything. Only strip numPr where `numId > 0` and the corresponding abstractNum has a visible numFmt (decimal, chineseCounting, etc.).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Systematic File Recovery for Lost Author Markers
|
||||
|
||||
When intermediate files have been overwritten during iterative editing (e.g., multiple versions of a contract revision), and you need to find a specific version that contains tracked changes by a particular author (e.g., "华诚-Z"), use this systematic scan approach.
|
||||
|
||||
## Scenario
|
||||
- You made multiple intermediate files (v1, v2, v3...) in `/tmp/` during contract editing
|
||||
- You overwrote files, losing the version with a specific author's tracked changes
|
||||
- You need to find ANY surviving file that still has that author's `w:author` attribute
|
||||
|
||||
## Recovery Technique
|
||||
|
||||
### Step 1: List all candidate files
|
||||
Find all `.docx` files in the working directory that are newer than the original source file:
|
||||
```bash
|
||||
find /tmp -name '*.docx' -newer /tmp/original_file.docx 2>/dev/null | sort
|
||||
```
|
||||
|
||||
### Step 2: Check each file for the target author
|
||||
```python
|
||||
import zipfile, re, os
|
||||
from datetime import datetime
|
||||
|
||||
target_author = '华诚-Z' # or whatever author you're looking for
|
||||
|
||||
files = [
|
||||
"/tmp/v1_clean.docx",
|
||||
"/tmp/v1_final.docx",
|
||||
# ... list all candidate files from Step 1
|
||||
]
|
||||
|
||||
for f in files:
|
||||
if not os.path.exists(f):
|
||||
continue
|
||||
try:
|
||||
z = zipfile.ZipFile(f)
|
||||
content = z.read('word/document.xml').decode('utf-8', 'ignore')
|
||||
authors = set(re.findall(r'w:author="([^"]+)"', content))
|
||||
mt = datetime.fromtimestamp(os.path.getmtime(f)).strftime('%m-%d %H:%M')
|
||||
has_target = target_author in authors
|
||||
marker = '★' if has_target else ' '
|
||||
print(f"{marker} {os.path.basename(f):35s} {mt} authors={sorted(authors)}")
|
||||
z.close()
|
||||
except Exception as e:
|
||||
print(f" ERROR {f}: {e}")
|
||||
```
|
||||
|
||||
### Step 3: Extract the target author's changes
|
||||
Once you find the file with the target author, extract their specific tracked changes:
|
||||
```python
|
||||
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
|
||||
z = zipfile.ZipFile('/tmp/file_with_target_author.docx')
|
||||
with z.open('word/document.xml') as f:
|
||||
tree = etree.parse(f)
|
||||
root = tree.getroot()
|
||||
body = root.find(f'{WNS}body')
|
||||
paras = body.findall(f'{WNS}p')
|
||||
|
||||
for i, p in enumerate(paras):
|
||||
has_target = False
|
||||
parts = []
|
||||
for child in p:
|
||||
tag = etree.QName(child.tag).localname
|
||||
if tag == 'r':
|
||||
t = child.find(f'{WNS}t')
|
||||
if t is not None and t.text:
|
||||
parts.append(('RUN', t.text, None))
|
||||
elif tag == 'ins':
|
||||
author = child.get(f'{WNS}author', '?')
|
||||
if target_author in author:
|
||||
has_target = True
|
||||
ins_texts = []
|
||||
for r in child.findall(f'{WNS}r'):
|
||||
t = r.find(f'{WNS}t')
|
||||
if t is not None and t.text:
|
||||
ins_texts.append(t.text)
|
||||
if ins_texts:
|
||||
parts.append(('INS', ''.join(ins_texts), author))
|
||||
elif tag == 'del':
|
||||
author = child.get(f'{WNS}author', '?')
|
||||
if target_author in author:
|
||||
has_target = True
|
||||
del_texts = []
|
||||
for r in child.findall(f'{WNS}r'):
|
||||
t = r.find(f'{WNS}delText')
|
||||
if t is not None and t.text:
|
||||
del_texts.append(t.text)
|
||||
if del_texts:
|
||||
parts.append(('DEL', ''.join(del_texts), author))
|
||||
|
||||
if has_target:
|
||||
print(f"\n★ P{i}:")
|
||||
for kind, text, author in parts:
|
||||
if kind == 'RUN':
|
||||
print(f" [原文] {repr(text)}")
|
||||
else:
|
||||
print(f" [{kind} by {author}] {repr(text)}")
|
||||
|
||||
z.close()
|
||||
```
|
||||
|
||||
## Empirical Case (2026-07-01 反委托代发工资协议)
|
||||
- Made ~15 intermediate files in `/tmp/` during iterative editing
|
||||
- Overwrote all files, changing all `w:author` attributes to "WB"
|
||||
- User (Doro) demanded recovery of 华诚-Z's tracked changes
|
||||
- Systematic scan found `/tmp/v1_doro_updated.docx` with `authors=['WB', '华诚-Z']`
|
||||
- Extracted 华诚-Z's 3 specific changes:
|
||||
- P6: INS "等" (between WB's "《劳务派遣暂行规定》" and "规定,")
|
||||
- P12: INS "退回派遣员工" + INS "由乙方依法自行安置处理,与甲方无涉。"
|
||||
|
||||
## Key Pitfalls
|
||||
1. **Don't assume the file is gone** — check ALL intermediate files, not just the ones you expect
|
||||
2. **Check timestamps** — the file you need might be an early intermediate, not the latest
|
||||
3. **Use `w:author` attribute** — this is the definitive marker, not file content or naming
|
||||
4. **Comments may also be lost** — the recovered file might have lost some original comments (see `references/comment-restoration-from-original.md`)
|
||||
|
||||
## Prevention (Better Than Recovery)
|
||||
The existing skill already covers this, but worth repeating: **改前必备份** — before modifying any file with third-party tracked changes, save a timestamped backup:
|
||||
```bash
|
||||
cp file_with_third_party.docx file_with_third_party.bak_$(date +%Y%m%d_%H%M%S).docx
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
# 表格单元格编辑:保持格式不被破坏
|
||||
|
||||
## 问题
|
||||
|
||||
编辑 docx 表格单元格中的文字时,两种常见错误做法都会破坏格式:
|
||||
|
||||
1. **`cell.paragraphs[0].clear()` + `add_run()`**:把多段结构压成一段,丢失加粗、字号、字体
|
||||
2. **XML 层全 cell 文字重分片**:把修改后的文字均匀分配到所有 `w:t` 元素,破坏段落边界和编号
|
||||
|
||||
## 正确做法:段落级精确定位 + 只改目标段
|
||||
|
||||
```python
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
|
||||
doc = Document('file.docx')
|
||||
table = doc.tables[0]
|
||||
cell = table.rows[7].cells[1]
|
||||
|
||||
# 1. 定位目标段落(按索引)
|
||||
paras = cell.paragraphs
|
||||
target_p = paras[4] # 例如 P4 是你要改的段落
|
||||
|
||||
# 2. 保存首 run 格式
|
||||
first_run = target_p.runs[0]
|
||||
saved = {
|
||||
'name': first_run.font.name,
|
||||
'size': first_run.font.size,
|
||||
'bold': first_run.font.bold,
|
||||
'italic': first_run.font.italic,
|
||||
}
|
||||
|
||||
# 3. 文字替换
|
||||
old_text = target_p.text
|
||||
new_text = old_text.replace('要被替换的文字', '新文字')
|
||||
|
||||
# 4. 清空该段 → 重写(保留格式)
|
||||
target_p.clear()
|
||||
run = target_p.add_run(new_text)
|
||||
run.font.name = saved['name']
|
||||
run.font.size = saved['size']
|
||||
run.font.bold = saved['bold']
|
||||
run.font.italic = saved['italic']
|
||||
|
||||
# 5. 合并单元格:同步更新同行其他 cell
|
||||
for col_idx in [2, 3]:
|
||||
cell2 = table.rows[7].cells[col_idx]
|
||||
p = cell2.paragraphs[4]
|
||||
p.clear()
|
||||
run = p.add_run(new_text)
|
||||
run.font.name = saved['name']
|
||||
run.font.size = saved['size']
|
||||
|
||||
doc.save('output.docx')
|
||||
```
|
||||
|
||||
## 关键原则
|
||||
|
||||
- **不碰其他段落**:只改目标索引的段落,其余段落原封不动
|
||||
- **不压多段为一段**:每个段落独立处理,保持 `P0/P1/P2/P3/P4` 结构不变
|
||||
- **合并单元格全同步**:`row[7].cells[1]` 改了什么,`cells[2]`、`cells[3]` 也要同步
|
||||
- **先读后改**:改前用 `cell.paragraphs[i].text` 确认内容,用 `cell.paragraphs[i].runs[0].font` 确认格式
|
||||
|
||||
## 本次教训
|
||||
|
||||
2026-06-26 预算绩效分析表:两轮都搞坏格式。
|
||||
- 第一轮:`clear()` + `add_run()` 把 Row 7 的 P0-P10 多段结构压成一段
|
||||
- 第二轮:XML 全 cell 文字重分片把 "2.成本核算分析" 变成 ".成本核算分析"(编号丢失)
|
||||
- 第三轮(正确):定位到 P4(成本优化段),只改它,保留 P0-P3 不动
|
||||
@@ -0,0 +1,65 @@
|
||||
# tracked_replace 被 DEL 元素打断(2026-06-26 CT维保合同-香花桥实证)
|
||||
|
||||
## 症状
|
||||
|
||||
`tracked_replace(old, new)` 对跨 DEL 元素的文本静默失败(不报错但也不修改)。
|
||||
|
||||
## 根因
|
||||
|
||||
当匹配文本被拆成多个 run,且中间夹着 `<w:del>` 元素时,`tracked_replace` 无法跨元素边界匹配完整字符串。
|
||||
|
||||
## 实证
|
||||
|
||||
**场景**:原文"一 年"(中间有空格),需改为"一年"。
|
||||
|
||||
实际 XML 结构:
|
||||
```xml
|
||||
<w:r><w:t>一</w:t></w:r>
|
||||
<w:del><w:r><w:delText> </w:delText></w:r></w:del>
|
||||
<w:r><w:t>年</w:t></w:r>
|
||||
```
|
||||
|
||||
`tracked_replace("一 年", "一年")` 无法匹配("一 年" 不连续存在于任何单一 run 中)。
|
||||
|
||||
## 修法
|
||||
|
||||
直接 zipfile+lxml 操作,移除 DEL 元素:
|
||||
|
||||
```python
|
||||
for elem in list(paragraph):
|
||||
if elem.tag.split('}')[-1] == 'del':
|
||||
for t in elem.iter():
|
||||
if t.tag == f'{{{W}}}delText' and t.text == ' ':
|
||||
paragraph.remove(elem)
|
||||
break
|
||||
```
|
||||
|
||||
## 同类变体:INS 需插入在 DEL 之后
|
||||
|
||||
**场景**:原文"与济损失"("与"为错字),上一轮已将"与"包进 DEL,但未补 INS "经"。
|
||||
|
||||
实际 XML 结构:
|
||||
```xml
|
||||
<w:del><w:r><w:delText>与</w:delText></w:r></w:del>
|
||||
<w:r><w:t>济损失的...</w:t></w:r>
|
||||
```
|
||||
|
||||
`tracked_replace("与济损失", "经济损失")` 静默失败("与"在 DEL 内)。
|
||||
|
||||
**修法**:zipfile+lxml 在 DEL 元素后插入 INS:
|
||||
```python
|
||||
ins = etree.SubElement(paragraph, f'{{{W}}}ins')
|
||||
ins.set(f'{{{W}}}id', str(new_id))
|
||||
ins.set(f'{{{W}}}author', 'WB')
|
||||
ins.set(f'{{{W}}}date', date_str)
|
||||
del_elem.addnext(ins) # INS 紧跟在 DEL 之后
|
||||
|
||||
r = etree.SubElement(ins, f'{{{W}}}r')
|
||||
r.append(copy.deepcopy(ref_rPr)) # 从同级 run 克隆 rPr
|
||||
t = etree.SubElement(r, f'{{{W}}}t')
|
||||
t.text = '经'
|
||||
```
|
||||
|
||||
## 判别
|
||||
|
||||
改前先遍历目标段落子元素,看是否有 `w:del` 或 `w:ins` 元素分割了匹配文本。有则不用 `tracked_replace`,改用 zipfile+lxml 直接操作。
|
||||
@@ -0,0 +1,127 @@
|
||||
# tracked_replace 跨 w:ins 元素失败的处理
|
||||
|
||||
## 症状
|
||||
|
||||
`tracked_replace(old, new)` 抛出 `ValueError: Element is not a child of this node`,
|
||||
发生在 `contract_docx_lib.py` 第368行 `parent.remove(runs[idx])`。
|
||||
|
||||
## 根因
|
||||
|
||||
合同已经过上一轮 workflow 修订,原文段落中插入了 `w:ins(author="WB")` 元素。
|
||||
目标匹配文本跨越了 `w:r` 和 `w:ins` 边界:
|
||||
|
||||
```
|
||||
w:r: "...若甲方在双"
|
||||
w:ins(author="WB"): "方"
|
||||
w:r: "核对消费金额时未提出异议的..."
|
||||
```
|
||||
|
||||
`tracked_replace` 把 `w:r` 和 `w:ins` 下的 `w:r` 都收集到 `runs` 列表,
|
||||
但 `parent.remove(runs[idx])` 时,`w:ins` 内的 `w:r` 的 parent 是 `w:ins`,不是 `w:p` → 报错。
|
||||
|
||||
## 判别
|
||||
|
||||
修改前先遍历目标段落的子元素,看是否有 `w:ins` 分割了匹配文本:
|
||||
|
||||
```python
|
||||
for elem in paragraph:
|
||||
tag = elem.tag.split('}')[-1]
|
||||
if tag in ('r', 'ins', 'del'):
|
||||
print(f" {tag}: '{''.join(t.text or '' for t in elem.findall('.//{W}t'))}'")
|
||||
```
|
||||
|
||||
## 修法:zipfile+lxml 直接操作
|
||||
|
||||
不用 `tracked_replace`,改用 zipfile+lxml 四步操作:
|
||||
|
||||
### 步骤1:裁掉第一段 run 中跨越的部分
|
||||
|
||||
```python
|
||||
# 如:w:r 末尾是 "...核对确认,若甲方在双" → 裁掉 "若甲方在双"
|
||||
assert r1_t.text.endswith('若甲方在双')
|
||||
r1_t.text = r1_t.text[:-6] # 移除6个字符
|
||||
```
|
||||
|
||||
### 步骤2:创建 DEL 包裹被裁掉的文字
|
||||
|
||||
```python
|
||||
del_elem = etree.Element(qn('del'))
|
||||
del_elem.set(qn('id'), str(next_del_id))
|
||||
del_elem.set(qn('author'), 'WB')
|
||||
del_elem.set(qn('date'), revision_date)
|
||||
|
||||
del_run = etree.SubElement(del_elem, qn('r'))
|
||||
del_rpr = etree.SubElement(del_run, qn('rPr'))
|
||||
# 从被裁 run 复制 rPr
|
||||
for child in r1_elem.find(qn('rPr')):
|
||||
del_rpr.append(copy.deepcopy(child))
|
||||
del_text = etree.SubElement(del_run, qn('delText'))
|
||||
del_text.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
del_text.text = '若甲方在双方' # 被裁文字 + w:ins 内容
|
||||
```
|
||||
|
||||
### 步骤3:移除原来的 w:ins 元素
|
||||
|
||||
```python
|
||||
p.remove(ins_elem)
|
||||
```
|
||||
|
||||
### 步骤4:DEL旧文字 + INS新文字
|
||||
|
||||
```python
|
||||
# DEL 旧文字(第三段 run 的完整内容)
|
||||
del_elem2 = ... # 同上模式,delText = r3_t.text
|
||||
# INS 新文字
|
||||
ins_elem = etree.Element(qn('ins'))
|
||||
ins_elem.set(qn('id'), str(next_ins_id))
|
||||
ins_elem.set(qn('author'), 'WB')
|
||||
ins_elem.set(qn('date'), revision_date)
|
||||
ins_run = etree.SubElement(ins_elem, qn('r'))
|
||||
# 从原 run 复制 rPr
|
||||
ins_rpr = etree.SubElement(ins_run, qn('rPr'))
|
||||
for child in r3_elem.find(qn('rPr')):
|
||||
ins_rpr.append(copy.deepcopy(child))
|
||||
ins_t = etree.SubElement(ins_run, qn('t'))
|
||||
ins_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
ins_t.text = new_text
|
||||
|
||||
# 替换:在 r3 位置前插入 DEL 和 INS,再删除 r3
|
||||
r3_pos = list(p).index(r3_elem)
|
||||
p.insert(r3_pos, del_elem2)
|
||||
p.insert(r3_pos + 1, ins_elem)
|
||||
p.remove(r3_elem)
|
||||
```
|
||||
|
||||
### 步骤5:写回
|
||||
|
||||
```python
|
||||
doc_xml_modified = etree.tostring(tree, encoding='UTF-8', xml_declaration=True)
|
||||
with zipfile.ZipFile(src, 'r') as zf:
|
||||
file_data = {f: zf.read(f) for f in zf.namelist()}
|
||||
with zipfile.ZipFile(src, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for f, data in file_data.items():
|
||||
zf.writestr(f, doc_xml_modified if f == 'word/document.xml' else data)
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
### 1. 中文切片长度
|
||||
`[:-3]` 移除3个**字符**(不是字节)。"但本" = 2个中文字符 → 用 `[:-2]`。
|
||||
用 `[:-3]` 会多切一个字(如把";"也切掉)。
|
||||
|
||||
**⚠️ 全角标点也是1个字符(2026-06-26 健康积分兑换协议教训)**:`(四)` 是3个字符——`(`(U+FF08全角左括号=1字)、`四`(1字)、`)`(U+FF09全角右括号=1字)。用 `[4:]` 切片会多切掉1个中文字符,导致 DEL 文本缺字("甲方"→"方")。**判别**:数切片偏移时,全角括号/标点(()、【】、《》、。,!?等)每符1字,不因"看起来宽"就计为多个。切片前 `print(repr(text[:10]))` 确认边界。
|
||||
|
||||
### 2. 旧文末尾与新文开头重复
|
||||
原文 run 裁掉部分后,末尾可能与 INS 新文字开头重复。
|
||||
如:原文末尾是"核对确认",新文字开头也是"核对确认" → 出现"核对确认核对确认"。
|
||||
**修法**:从 INS 新文字中去掉重复前缀。
|
||||
|
||||
### 3. 标点符号归属
|
||||
裁掉 run 末尾文字时,确保标点符号(;。等)留在正确位置。
|
||||
如原文"费用;但本"裁掉"但本"后应为"费用;"(保留分号)。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `python-docx` 能打开(`Document(out)` 不抛异常)
|
||||
2. `wb-ins-font-verify.py` 所有 WB INS 字体一致
|
||||
3. 用 `ContractEditor.get_para_text()` 读段落文本,确认无重复/缺字
|
||||
@@ -0,0 +1,67 @@
|
||||
# 移植workflow修订到同名不同版本合同 (2026-07-08)
|
||||
|
||||
## 场景
|
||||
邱律师同日发了两份同名文件(如"2026年华新镇公立中小学生健康体检服务合同.docx"),内容有实质差异(不同版本/条款)。第一份被workflow正常审查交付,第二份因queue-runner同名跳过逻辑被遗漏。Doro要求"把workflow第1份的修订内容直接修订到第2份里,但要注意相关修订在第2份里是否合理"。
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 1. 提取第1份的WB修订清单
|
||||
从已交付文件提取所有 `author=WB` 的 `w:ins` 和 `w:del`:
|
||||
```python
|
||||
from zipfile import ZipFile
|
||||
from lxml import etree
|
||||
|
||||
with ZipFile(delivered_path, 'r') as z:
|
||||
content = z.read('word/document.xml').decode('utf-8')
|
||||
root = etree.fromstring(content.encode('utf-8'))
|
||||
# 遍历所有 WB INS/DEL,记录:段落索引、INS文本、DEL文本、上下文
|
||||
```
|
||||
|
||||
### 2. 对比两份合同差异
|
||||
用 `difflib` 对比两版全文,定位哪些段落内容不同。特别关注:
|
||||
- 修订涉及的段落在第2份中是否存在
|
||||
- 如果存在,文本是否与第1份中的"修订前"文本一致
|
||||
|
||||
### 3. 逐条判断修订是否适用于第2份
|
||||
| 修订类型 | 判断方法 |
|
||||
|---------|---------|
|
||||
| 术语统一(如"协议"→"合同") | 在第2份中搜索同一术语,存在则同样修改 |
|
||||
| 新增保护条款(如数据归属、转包连带责任) | 检查第2份对应位置是否缺同样的保护,缺则加 |
|
||||
| 金额/支付相关修订 | 第2份的支付条款可能完全不同(如本案),需独立判断是否需要新的修订 |
|
||||
| 合同期限相关修订 | 第2份期限可能不同,独立判断 |
|
||||
|
||||
### 4. 对第2份执行修订
|
||||
使用ContractEditor库,与正常审查相同流程:
|
||||
```python
|
||||
ed = ContractEditor(second_file)
|
||||
ed.tracked_replace(old, new) # 逐条适用的修订
|
||||
errors = ed.validate()
|
||||
ed.save(output)
|
||||
```
|
||||
|
||||
### 5. 字体验证 + 上传
|
||||
- `wb-ins-font-verify.py` 必须PASS
|
||||
- 上传替换NC任务交付目录中的同名文件
|
||||
- 同时确保第2份原始文件在待审查目录
|
||||
|
||||
## 2026-07-08 华新镇体检合同实证
|
||||
|
||||
**两版差异:**
|
||||
| 条款 | 第1份 (11:05) | 第2份 (16:04) |
|
||||
|------|--------------|--------------|
|
||||
| 项目内容 | "公立中小学生健康检查工作" | "华新镇公立中小学生健康体检工作" |
|
||||
| 支付方式 | 按实际人数结算,无金额上限 | 按实际完成人数+考核表结算,费用上限17万 |
|
||||
| 合同期限 | 9月10日起 | 9月1日起 |
|
||||
|
||||
**移植的修订(全部适用):**
|
||||
1. 保密条款:数据归属+合同期满扩大+协议→合同统一 ✅ 第2份保密条款内容相同
|
||||
2. 转包限制:增加甲方书面同意+连带责任 ✅ 第2份P44文本相同
|
||||
3. 效力条款:本协议→本合同 ✅ 第2份P51文本相同
|
||||
|
||||
**不需要额外修订的原因:**
|
||||
- 第2份的支付条款已更完善(有上限、有考核、有一次性付清约定)
|
||||
- 违约责任、争议解决条款相同且已足够
|
||||
|
||||
## 注意事项
|
||||
- 第2份被上传后会**替换**第1份的交付文件(同名),tracker中seq=262的记录对应的实际内容变了
|
||||
- hint mismatch 在 tracked_replace 生成的长INS文本中常见(库不自动加hint到多段INS),需post-fix
|
||||
@@ -0,0 +1,59 @@
|
||||
# 版本管理反模式(2026-07-01 反委托代发工资协议惨痛教训)
|
||||
|
||||
## 事件回顾
|
||||
|
||||
反委托代发工资协议需要制作两个版本(法定安排 vs 反委托保护),同时保留华诚-Z的修订痕迹。
|
||||
|
||||
### 灾难链条
|
||||
|
||||
1. 原始文件有华诚-Z的修订(author="华诚-Z")+ 批注
|
||||
2. 我制作WB版本时,把所有author改成了WB
|
||||
3. 又做了一版合并版本,再次覆盖
|
||||
4. 之后Doro说"你把华诚-Z修订痕迹的版本放进去"
|
||||
5. 发现/tmp里所有文件都只有WB作为author
|
||||
6. Nextcloud版本历史也没有(只保留了一个.v文件,也是WB)
|
||||
7. 最终在 `/tmp/v1_doro_updated.docx` 找到——这是一个中间版本,纯属侥幸
|
||||
|
||||
### 反模式清单
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|--------|------|
|
||||
| 修改author前不备份 | 原始修订痕迹不可逆丢失 |
|
||||
| 覆盖式保存(同文件名) | 中间版本消失 |
|
||||
| 从头重做而非增量修补 | 每次重做都覆盖上一版 |
|
||||
| 不验证就交付 | 批注丢了3条没发现 |
|
||||
| 多轮操作共用/tmp目录 | 后续操作的文件名与前面冲突 |
|
||||
|
||||
### 正确做法
|
||||
|
||||
```python
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
# 操作前备份
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
shutil.copy(source, f"{source}.bak_{timestamp}")
|
||||
|
||||
# 操作后验证
|
||||
import zipfile, re
|
||||
z = zipfile.ZipFile(output)
|
||||
content = z.read('word/document.xml').decode('utf-8')
|
||||
authors = set(re.findall(r'w:author="([^"]+)"', content))
|
||||
assert '华诚-Z' in authors, "华诚-Z author LOST!"
|
||||
|
||||
# 批注验证
|
||||
if 'word/comments.xml' in z.namelist():
|
||||
comments_xml = z.read('word/comments.xml').decode('utf-8')
|
||||
comment_count = len(re.findall(r'<w:comment ', comments_xml))
|
||||
assert comment_count >= expected_count, f"Comments lost: {comment_count} < {expected_count}"
|
||||
```
|
||||
|
||||
### 文件命名规范(防覆盖)
|
||||
|
||||
不要用 `_v2.docx` `_v3.docx` 这种递增命名——容易忘记当前版本是几。用语义+时间戳:
|
||||
|
||||
```
|
||||
反委托_华诚Z原版_20260701_0320.docx # 带华诚-Z修订的版本
|
||||
反委托_WB合并版_20260701_0341.docx # WB+华诚-Z合并后
|
||||
反委托_V1法定安排_FINAL_20260701.docx # 最终交付
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
# WB自加手动编号与前序自动编号撞号 — 诊断与修复
|
||||
|
||||
实战来源:端午节福利品采购合同(2026-06-16)。Maggie:"转包责任应该是7,上一个编号是6。手动修复上传。"
|
||||
|
||||
## 场景识别
|
||||
- 我们(WB)用 `w:ins` 新增了若干尾部条款(转包/违约/争议…),编号是**手动文字**写在 run 文本开头("6、转包限制…")。
|
||||
- 紧邻的前一条是**原文自带的自动编号**条款(pPr 有 `<w:numPr>`,编号由 numbering.xml 的 `<w:start>` 生成,文字里**没有**编号)。
|
||||
- 二者渲染数字撞号:自动编号末值=6,我方手动也从6起 → 接受修订后出现两个6。
|
||||
|
||||
## 诊断步骤(顺序不可颠倒,OnlyOffice为准)
|
||||
1. 取交付版(任务交付/【修】…docx)+原文(待审查/…doc),各用 `scripts/onlyoffice-render.sh` 渲染 PDF。
|
||||
2. `pdftotext -layout x.pdf - | grep -E "^\s*\f?[0-9]+、"` 数出**完整可见编号链**(注意"5、结算"可能挤在第4条段内、自动编号条款文字里无编号——肉眼易漏)。
|
||||
3. 读 numbering.xml 确认前序自动条款的 numId→abstractNumId→lvl0 的 `start` 值,得知它渲染成几(端午节:numId=3, start=6 →"6")。
|
||||
4. 读 document.xml,确认我方各条是 `w:ins author=WB`,编号"6、""7、""8、"在 ins 首个 w:r 的 w:t 开头。
|
||||
|
||||
## 判定
|
||||
前序自动编号末值 = N → 我方手动编号应从 **N+1** 起顺延。端午节:售后=6 → 转包=7、违约=8、争议=9。
|
||||
**有Maggie明确指示 + 完整核对 → 执行。** 不要因"擅改编号"的旧教训而拒绝正确修复(区别在:当初错在没核对没确认,不在方向)。
|
||||
|
||||
## 修复(纯 zipfile+lxml,最干净)
|
||||
只改 ins 首个 w:t 的编号前缀,不拆 run、不碰 rPr、不转 numbering:
|
||||
|
||||
```python
|
||||
import zipfile, os
|
||||
from lxml import etree
|
||||
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
src='deliver.docx'; out='deliver_FIXED.docx'
|
||||
root = etree.fromstring(zipfile.ZipFile(src).read('word/document.xml'))
|
||||
paras = root.find(f'{W}body').findall(f'{W}p')
|
||||
changes = {19:('6、','7、','转包'), 20:('7、','8、','违约'), 21:('8、','9、','争议')} # 段索引→(旧号,新号,关键词)
|
||||
for idx,(old,new,kw) in changes.items():
|
||||
ins = paras[idx].find(f'{W}ins')
|
||||
assert ins is not None and ins.get(f'{W}author')=='WB', f"段{idx}非WB的ins!" # 铁律:绝不改他人ins
|
||||
t = ins.find(f'{W}r').find(f'{W}t')
|
||||
assert t.text.startswith(old) and kw in t.text[:6]
|
||||
t.text = new + t.text[len(old):]
|
||||
new_doc = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
tmp=out+'.tmp'
|
||||
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(tmp,'w',zipfile.ZIP_DEFLATED) as zout:
|
||||
for it in zin.infolist():
|
||||
zout.writestr(it, new_doc if it.filename=='word/document.xml' else zin.read(it.filename))
|
||||
os.replace(tmp,out)
|
||||
```
|
||||
|
||||
## 交付前四查(vision不可用时的强制验证,缺一不可)
|
||||
1. **逐段markup diff vs交付源**:提取两版每个 w:p 的 markup 文本(含 delText),断言**只有目标N段不同**、其余全部零改动(端午节33段只动3段)。
|
||||
2. **OnlyOffice渲染PDF编号链**:pdftotext 数出 1,2,3,4,(5),6,7,8,9 连续无双号。
|
||||
3. **INS run rPr 改前==改后**:`etree.tostring(rpr)` 逐段比对,确认字体/字号一字未动。
|
||||
4. **python-docx 能打开** + 接受修订后(去 del、解包 ins)编号链连续,证明 XML 合法、WB 修订标记完整保留。
|
||||
|
||||
## 交付(Editor到此为止则交deliverer;本例Maggie直接要"上传"故一并做)
|
||||
- 文件名**一字不动**:覆盖 `Doro合同审查任务/任务交付/【修】<原名>.docx`。
|
||||
- `docker cp` 进 nextcloud-nextcloud-1 → `chown www-data` → `occ files:scan --path=...`。
|
||||
- 落盘 md5 == 修复版 md5 才算成功。
|
||||
- 清 OnlyOffice 缓存:`docker exec nextcloud-onlyoffice-1 rm -rf .../App_Data/cache/files/*`。
|
||||
- 已 pass 登记过的合同仅编号订正:tracker/xlsx 记录不变动。
|
||||
@@ -0,0 +1,127 @@
|
||||
# ContractEditor 原文Run属性污染诊断与修复
|
||||
|
||||
## 2026-07-13 洋励合同实证
|
||||
|
||||
### 问题描述
|
||||
|
||||
ContractEditor(contract_docx_lib.py)在处理文档时,不仅给WB INS runs添加多余属性,还会**修改原文runs**的rPr——给本来靠docDefaults/style继承的orig runs添加显式eastAsia/cs/sz。
|
||||
|
||||
### 典型污染模式
|
||||
|
||||
| 属性 | 原文(待审查) | 被污染后(交付物中的orig run) | WB INS run |
|
||||
|------|--------------|-------------------------------|-----------|
|
||||
| eastAsia | None (继承minorEastAsia) | **宋体** (被加) | None |
|
||||
| cs | None | **宋体** (被加) | None |
|
||||
| sz | None (继承docDefaults=22) | **21** (被加且值错) | None |
|
||||
| ascii | 宋体 | 宋体 | None |
|
||||
| hint | eastAsia (部分有) | eastAsia | None |
|
||||
|
||||
### 后果
|
||||
|
||||
1. 原文所有文字从11pt(docDefaults sz=22)变成10.5pt(显式sz=21) — 整体缩小0.5pt
|
||||
2. INS文字没有任何属性 → 走docDefaults 11pt → 与被改小的原文不一致
|
||||
3. 字体验证脚本(wb-ins-font-verify.py)报INS缺属性,但实际问题是orig被污染
|
||||
|
||||
### 诊断步骤
|
||||
|
||||
```bash
|
||||
# 1. 读原文代表性段落run rPr
|
||||
python3 -c "
|
||||
import zipfile
|
||||
from lxml import etree
|
||||
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
with zipfile.ZipFile('原文.docx', 'r') as z:
|
||||
...
|
||||
# 检查: eastAsia=None? sz=None?
|
||||
# 如果是 → 原文靠继承
|
||||
|
||||
# 2. 读交付物同段落orig run rPr
|
||||
# 检查: 是否多了eastAsia/cs/sz?
|
||||
# 如果是 → 被污染
|
||||
```
|
||||
|
||||
### 修复代码模板
|
||||
|
||||
```python
|
||||
import zipfile, tempfile, shutil
|
||||
from lxml import etree
|
||||
|
||||
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
|
||||
with zipfile.ZipFile(filepath, 'r') as z:
|
||||
all_files = {n: z.read(n) for n in z.namelist()}
|
||||
|
||||
tree = etree.fromstring(all_files['word/document.xml'])
|
||||
body = tree.find(f'{WNS}body')
|
||||
|
||||
# Step 1: Strip contaminated attributes from ALL orig runs
|
||||
for p in body.findall(f'{WNS}p'):
|
||||
for r in p.findall(f'{WNS}r'): # Only direct child runs (not inside ins/del)
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is None:
|
||||
continue
|
||||
rf = rpr.find(f'{WNS}rFonts')
|
||||
if rf is not None:
|
||||
# Strip eastAsia (original didn't have it)
|
||||
if f'{WNS}eastAsia' in rf.attrib:
|
||||
del rf.attrib[f'{WNS}eastAsia']
|
||||
# Strip cs (original didn't have it)
|
||||
if f'{WNS}cs' in rf.attrib:
|
||||
del rf.attrib[f'{WNS}cs']
|
||||
# Strip sz (original relies on docDefaults)
|
||||
sz = rpr.find(f'{WNS}sz')
|
||||
if sz is not None:
|
||||
rpr.remove(sz)
|
||||
|
||||
# Step 2: Fix INS runs to match REAL original format
|
||||
for ins in body.findall(f'.//{WNS}ins'):
|
||||
if ins.get(f'{WNS}author') != 'WB':
|
||||
continue
|
||||
for r in ins.findall(f'{WNS}r'):
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is None:
|
||||
continue
|
||||
rf = rpr.find(f'{WNS}rFonts')
|
||||
if rf is None:
|
||||
rf = etree.SubElement(rpr, f'{WNS}rFonts')
|
||||
# Match real original: ascii=宋体, hAnsi=宋体, NO eastAsia
|
||||
rf.set(f'{WNS}ascii', '宋体')
|
||||
rf.set(f'{WNS}hAnsi', '宋体')
|
||||
if f'{WNS}eastAsia' in rf.attrib:
|
||||
del rf.attrib[f'{WNS}eastAsia']
|
||||
# Remove sz (let it inherit)
|
||||
sz = rpr.find(f'{WNS}sz')
|
||||
if sz is not None:
|
||||
rpr.remove(sz)
|
||||
|
||||
# Step 3: Per-paragraph hint matching
|
||||
for p in body.findall(f'{WNS}p'):
|
||||
# Get orig run's hint
|
||||
orig_hint = None
|
||||
for r in p.findall(f'{WNS}r'):
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is not None:
|
||||
rf = rpr.find(f'{WNS}rFonts')
|
||||
orig_hint = rf.get(f'{WNS}hint') if rf is not None else None
|
||||
break
|
||||
# Apply to INS runs in same paragraph
|
||||
for ins in p.findall(f'.//{WNS}ins'):
|
||||
if ins.get(f'{WNS}author') != 'WB':
|
||||
continue
|
||||
for r in ins.findall(f'{WNS}r'):
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is None: continue
|
||||
rf = rpr.find(f'{WNS}rFonts')
|
||||
if rf is None: continue
|
||||
if orig_hint:
|
||||
rf.set(f'{WNS}hint', orig_hint)
|
||||
elif f'{WNS}hint' in rf.attrib:
|
||||
del rf.attrib[f'{WNS}hint']
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **必须对比原文确定被污染了哪些属性** — 不同合同模板的原文属性不同
|
||||
2. **不是所有合同都有此问题** — 取决于原文是否靠继承(有显式属性的不会被"污染",因为值相同)
|
||||
3. **Step 1必须在Step 2之前** — 否则wb-ins-font-verify仍会报INS与(被污染的)orig不一致
|
||||
4. **hint要逐段处理** — 同一文档不同段落的orig runs可能有的有hint有的没有
|
||||
@@ -0,0 +1,70 @@
|
||||
# Workflow INS Format Repair — ContractEditor Font Contamination Pattern
|
||||
|
||||
## 2026-07-13 洋励/安全生产/消防设施检测 连续验证
|
||||
|
||||
### 问题根因
|
||||
|
||||
ContractEditor库在处理文档时会**污染原文runs**——给原本没有显式属性的runs添加`eastAsia`、`cs`、`sz`。
|
||||
|
||||
典型对比:
|
||||
```
|
||||
原文(待审查): rFonts={ascii=宋体, hAnsi=宋体, hint=eastAsia}, szCs=21, NO sz, NO eastAsia
|
||||
v1中orig runs: rFonts={ascii=宋体, hAnsi=宋体, hint=eastAsia, cs=宋体, eastAsia=宋体}, szCs=21, sz=21
|
||||
v1中WB INS: rPr=空 (什么属性都没有)
|
||||
```
|
||||
|
||||
**后果**:
|
||||
1. 原文字号从继承docDefaults(如sz=22=11pt)变为显式sz=21(10.5pt)——整体缩小0.5pt
|
||||
2. INS runs无属性→走docDefaults继承→11pt,与被改小的orig runs(10.5pt)不一致
|
||||
3. wb-ins-font-verify报"orig=宋体/21 wb=None"——但这个"orig"已被污染,不是真实原文
|
||||
|
||||
### 诊断铁律
|
||||
|
||||
**永远对比待审查目录的原文,不信v1中的orig runs**:
|
||||
|
||||
```python
|
||||
# 对比同一段落的run属性
|
||||
for label, path in [('待审查原文', orig_path), ('交付v1', v1_path)]:
|
||||
# 读P4 first run rPr的所有子元素
|
||||
# 如果v1比原文多了eastAsia/cs/sz → 被污染
|
||||
```
|
||||
|
||||
### 修复方法(三步)
|
||||
|
||||
**Step 1:清除orig runs的污染属性**
|
||||
```python
|
||||
for r in p.findall(f'{WNS}r'): # 只处理原文runs(不在ins/del内的)
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
rf = rpr.find(f'{WNS}rFonts')
|
||||
if rf is not None:
|
||||
# 如果原文没有eastAsia,strip之
|
||||
if f'{WNS}eastAsia' in rf.attrib:
|
||||
del rf.attrib[f'{WNS}eastAsia']
|
||||
if f'{WNS}cs' in rf.attrib:
|
||||
del rf.attrib[f'{WNS}cs']
|
||||
# 如果原文没有sz(靠继承),strip之
|
||||
sz = rpr.find(f'{WNS}sz')
|
||||
if sz is not None:
|
||||
rpr.remove(sz)
|
||||
```
|
||||
|
||||
**Step 2:设INS runs匹配真实原文**
|
||||
```python
|
||||
for ins in p.findall(f'.//{WNS}ins'):
|
||||
if ins.get(f'{WNS}author') != 'WB': continue
|
||||
for r in ins.findall(f'{WNS}r'):
|
||||
rf = rpr.find(f'{WNS}rFonts')
|
||||
# 设置为原文实际有的属性(如ascii=宋体, hAnsi=宋体)
|
||||
# 不设原文没有的(如eastAsia, cs)
|
||||
# hint按同段orig run的值设
|
||||
```
|
||||
|
||||
**Step 3:逐段匹配hint**
|
||||
不同段落的orig runs hint状态不同(有的有hint=eastAsia,有的没有)。必须逐段检查并匹配。
|
||||
|
||||
### 注意事项
|
||||
|
||||
- **不能一刀切**:同一文档不同段落的orig run属性可能不同(P4有hint, P19没hint; P20有完整rFonts, P36完全没rFonts)
|
||||
- **docDefaults是真正的参考基准**:检查`word/styles.xml`的`docDefaults/rPrDefault`了解继承值
|
||||
- **"MISSING HINT (无同段原文可比)"是已知限制**:整段WB INS的新增段落没有orig run对比,脚本报MISSING不是真实错误
|
||||
- **洋励案实证**:120处orig runs被污染,修复后INS只剩14个MISSING HINT(全是新增段落)
|
||||
@@ -0,0 +1,92 @@
|
||||
# Workflow交付件逐份审查检查清单 (2026-07-13 Doro要求)
|
||||
|
||||
## 触发条件
|
||||
Doro说"逐一审查已交付合同的修订有哪些问题"或类似指令。
|
||||
|
||||
## 操作流程
|
||||
1. 全文阅读通用review-rules.md + 对应顾问单位特殊规则
|
||||
2. 列出今天交付的全部文件(`sudo find ... -newermt`)
|
||||
3. 一份一份审查,报告问题,等Doro说pass再做下一份
|
||||
|
||||
## 每份检查项
|
||||
|
||||
### A0. 文件完整性(先于内容)
|
||||
- `zipfile`读`word/comments.xml`看有无批注
|
||||
- 统计`w:ins`/`w:del`数量确认有修订痕迹
|
||||
- 如果有多版本(v1/v2),每个都要独立检查性质
|
||||
|
||||
### A. 格式验证
|
||||
- `wb-ins-font-verify.py` — 必须PASS
|
||||
- 原文同段run属性 vs INS run属性逐一比对
|
||||
- 新增段落pPr(ind/spacing/numPr)vs原文邻近段落
|
||||
|
||||
### B. 审查清单覆盖(10条逐条)
|
||||
1. 主体条款
|
||||
2. 违约责任(含赔偿上限删除、维权费用)
|
||||
3. 争议解决/管辖(甲方所在地法院)
|
||||
4. 保密/数据(归属+存续+泄露赔偿 三要素)
|
||||
5. 知识产权/系统
|
||||
6. 第三方侵权(全责+赔偿甲方损失)
|
||||
7. 转包/分包(限制+连带)
|
||||
8. 价款条款
|
||||
9. 服务成果持续使用权(仅持续性服务适用)
|
||||
10. 条款逻辑
|
||||
|
||||
### C. 同模板一致性
|
||||
- 同批同模板合同的修订是否完全统一
|
||||
- 特别检查:编号顺延方式、章节结构、措辞、天数
|
||||
|
||||
### D. 特殊交付物
|
||||
- 读该顾问单位review-rules.md确认是否要求审查意见文档
|
||||
- 缺失则标记
|
||||
|
||||
### E. 批注审查
|
||||
- 每条WB批注逐一比对规则
|
||||
- 立场是否正确(站甲方)
|
||||
- 是否违反"能改就不批注"
|
||||
- 是否属于提醒性批注(禁止)
|
||||
|
||||
## 报告格式
|
||||
```
|
||||
## 【修】合同名称
|
||||
|
||||
**字体验证:** PASS/FAIL
|
||||
**修订内容:** 逐条列出WB INS/DEL
|
||||
**问题:**
|
||||
1. [严重/一般] 具体问题描述
|
||||
2. ...
|
||||
**结论:** pass建议/需修复
|
||||
```
|
||||
|
||||
### F. 编号顺延完整性(2026-07-13 璞石合同教训)
|
||||
- 章节标题编号顺延后(如七→八),**子编号也必须顺延**(7.1→8.1, 7.2→8.2...)
|
||||
- Workflow常见遗漏:只改了章节标题的汉字编号(第七条→第八条),但内部子条款的阿拉伯数字编号(7.1/7.2/7.3/7.4)原封不动
|
||||
- **检查方法**:accepted text中搜索所有"X.Y"格式编号,确认X与所属章节标题的序号一致
|
||||
- 修复方法:子编号通常拆为两个run(如"7" + ".1 "),只需DEL第一个run("7")+INS新数字("8")
|
||||
|
||||
### G. 内容去重(2026-07-13 璞石合同教训)
|
||||
- 新增的保密存续条款是否与原文已有的类似表述重复
|
||||
- 典型:原文已有"乙方的保密义务不因合同解除或终止而免除",WB又插入"本条保密义务不因本合同的终止或解除而终止"——语义完全重复
|
||||
|
||||
### H. 赔偿上限全面检查(2026-07-13 璞石合同教训)
|
||||
- 规则"赔偿上限能删就删"不仅适用于乙方赔偿甲方的上限
|
||||
- **双向条款中的上限也要删**:如"任何一方违约,违约金额为合同总金额的20%"——此上限同时限制了甲方可获赔偿
|
||||
- P43甲方自身违约金上限保留是正确的(保护甲方),但P49双向上限应删除
|
||||
- **判断方法**:上限是否限制了对方向甲方赔偿?是→删;上限是否限制了甲方向对方赔偿?是→保留
|
||||
|
||||
### I. 新增标题段落样式(2026-07-13 璞石合同教训)
|
||||
- WB新增的章节标题段(如"第七条 转包与分包")的pStyle必须与原文标题段一致
|
||||
- 原文标题用Heading4→新增也用Heading4,不能用Style15(正文首行缩进)
|
||||
- 标题文字中"第X条"与名称之间是否有空格?原文无空格("第六条违约责任")则新增也不加空格
|
||||
|
||||
### J. 原文已有修订保持不动
|
||||
- 对照原文确认:WB-1/86187/杨丽/富强等原文修订人的INS/DEL/批注是否全部原样保留
|
||||
- WB的修改不能意外覆盖或嵌套进原文修订
|
||||
- P29金额"27900"的sz=22是原文86187的修订→不是workflow问题
|
||||
|
||||
## 铁律
|
||||
- 先tool call读文件再下结论(验证指令铁律)
|
||||
- 不凭上一份的印象判断下一份
|
||||
- comments.xml必须检查(2026-07-13教训)
|
||||
- **原文自带【修】前缀的文件**:按命名规则应为【修】【修】...,workflow通常不做双重前缀——记录为已知缺陷
|
||||
- **Doro说"看清楚前后文再回复"**:意思是你漏了问题或误判了严重性,必须重新逐属性检查
|
||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成"接受所有修订后"的干净 docx,用于 OnlyOffice 渲染做字体/排版的决定性视觉验证。
|
||||
|
||||
为什么需要:OnlyOffice 渲染修订态文字(w:ins,紫色+下划线)时视觉上常显示为类无衬线、
|
||||
看起来字体/粗细与正文不同——这是 track-changes 的渲染特性,不是真实字体差异。vision 工具
|
||||
会据此误报"字体不一致",导致无谓返工。把所有修订接受、批注去掉后再渲染,才能在无修订
|
||||
颜色干扰下看到插入文字与正文的真实字体一致性。
|
||||
|
||||
用法: python accept-revisions-preview.py <in.docx> <out.docx>
|
||||
处理: 解包所有 w:ins(保留内容)+ 删除所有 w:del(连内容)+ 移除批注锚点标记。
|
||||
注意: 产物仅供"渲染核对",不是正式交付物(交付的是带修订痕迹的版本)。
|
||||
"""
|
||||
import sys, zipfile, io
|
||||
from lxml import etree
|
||||
|
||||
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
Wq = '{' + W + '}'
|
||||
|
||||
|
||||
def accept_revisions(in_path, out_path):
|
||||
with open(in_path, 'rb') as f:
|
||||
data = f.read()
|
||||
bin_, bout = io.BytesIO(data), io.BytesIO()
|
||||
with zipfile.ZipFile(bin_) as zin, zipfile.ZipFile(bout, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
raw = zin.read(item.filename)
|
||||
if item.filename == 'word/document.xml':
|
||||
root = etree.fromstring(raw)
|
||||
# 删除所有 w:del(含内容)
|
||||
for d in [e for e in root.iter(Wq + 'del')]:
|
||||
d.getparent().remove(d)
|
||||
# 解包所有 w:ins:把子元素提到 ins 的位置后删除 ins 壳
|
||||
for ins in [e for e in root.iter(Wq + 'ins')]:
|
||||
parent = ins.getparent()
|
||||
idx = list(parent).index(ins)
|
||||
for child in reversed(list(ins)):
|
||||
parent.insert(idx, child)
|
||||
parent.remove(ins)
|
||||
# 移除批注锚点标记
|
||||
for tag in ('commentRangeStart', 'commentRangeEnd'):
|
||||
for e in [x for x in root.iter(Wq + tag)]:
|
||||
e.getparent().remove(e)
|
||||
for r in [x for x in root.iter(Wq + 'r')]:
|
||||
if r.find(Wq + 'commentReference') is not None:
|
||||
r.getparent().remove(r)
|
||||
raw = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
zout.writestr(item, raw)
|
||||
with open(out_path, 'wb') as f:
|
||||
f.write(bout.getvalue())
|
||||
print(f'接受修订版已生成: {out_path}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
print('用法: python accept-revisions-preview.py <in.docx> <out.docx>')
|
||||
sys.exit(1)
|
||||
accept_revisions(sys.argv[1], sys.argv[2])
|
||||
@@ -0,0 +1,684 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
contract_docx_lib.py — 合同修订核心库
|
||||
固化验证通过的docx XML操作,不再每次重写。
|
||||
|
||||
用法:
|
||||
from contract_docx_lib import ContractEditor
|
||||
|
||||
editor = ContractEditor("原文件.docx")
|
||||
editor.tracked_replace("原文片段", "新文片段")
|
||||
editor.add_clause("19.服务成果持续使用权", "条款内容...", after_clause=18)
|
||||
editor.renumber(19, 20) # 原19→20
|
||||
errors = editor.validate()
|
||||
if not errors:
|
||||
editor.save("【修】原文件.docx")
|
||||
|
||||
关键操作顺序(renumber和新增条款):
|
||||
1. 先做所有 tracked_replace(文本修改)
|
||||
2. 再做 add_clause(新增子条款,如15.4)
|
||||
3. 再做 renumber_range(先腾出编号空间)
|
||||
4. 最后做 add_clause_before(插入新主条款,用已腾出的编号)
|
||||
5. validate() 验证
|
||||
6. save() 保存
|
||||
"""
|
||||
|
||||
import zipfile, io, copy, re, difflib
|
||||
from lxml import etree
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
|
||||
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'
|
||||
WNS = '{' + W + '}'
|
||||
|
||||
def qn(tag):
|
||||
return f'{WNS}{tag}'
|
||||
|
||||
|
||||
def cjk_tokenize(text):
|
||||
"""CJK每字一token,ASCII连续一token,标点单独token。
|
||||
经验证的分词策略,不要改。"""
|
||||
tokens = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or ch in ',。、;:!?""''()【】《》—…·[]%%':
|
||||
tokens.append(ch)
|
||||
i += 1
|
||||
elif ch.isascii() and ch.isalnum():
|
||||
j = i
|
||||
while j < len(text) and text[j].isascii() and text[j].isalnum():
|
||||
j += 1
|
||||
tokens.append(text[i:j])
|
||||
i = j
|
||||
else:
|
||||
tokens.append(ch)
|
||||
i += 1
|
||||
return tokens
|
||||
|
||||
|
||||
class ContractEditor:
|
||||
"""合同修订编辑器。一个实例对应一份合同文件。"""
|
||||
|
||||
def __init__(self, filepath):
|
||||
self.filepath = Path(filepath)
|
||||
with open(filepath, 'rb') as f:
|
||||
self.original_bytes = f.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
|
||||
self.doc_xml = z.read('word/document.xml')
|
||||
|
||||
self.tree = etree.fromstring(self.doc_xml)
|
||||
self.body = self.tree.find(qn('body'))
|
||||
self._rev_id = 100
|
||||
self._revision_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
self._author = 'WB'
|
||||
self._rsid = '00AA0001'
|
||||
|
||||
# 提取原文格式(核心:避免每次猜错格式)
|
||||
self._body_rpr = None # 正文格式(最常见的非加粗rPr)
|
||||
self._title_rpr = None # 条款标题格式(加粗的rPr)
|
||||
self._body_ppr = None
|
||||
self._extract_formats()
|
||||
|
||||
def _extract_formats(self):
|
||||
"""从原文提取正文和标题的rPr。
|
||||
策略:
|
||||
- 正文格式:统计所有run的rPr,取出现最多的非加粗rPr
|
||||
- 标题格式:优先从条款编号标题段落(如"7.索赔条款")提取rPr,
|
||||
而非简单取第一个加粗run(可能是合同大标题,字号不同)
|
||||
- 如果条款标题不加粗,标题格式回退到正文格式"""
|
||||
import re
|
||||
rpr_map = {} # serialized_rpr -> (count, rpr_element)
|
||||
clause_title_rpr = None # 从条款编号标题提取的格式
|
||||
first_bold_rpr = None # 第一个加粗run的格式(fallback)
|
||||
|
||||
for p in self.body.findall(qn('p')):
|
||||
# 获取段落全文,判断是否是条款编号标题(如 "7.索赔条款" "5.伴随服务")
|
||||
p_text = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')).strip()
|
||||
is_clause_title = bool(re.match(r'^\d+[..、]\s*\S', p_text)) and len(p_text) < 30
|
||||
|
||||
for r in p.findall(qn('r')):
|
||||
rpr = r.find(qn('rPr'))
|
||||
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||
if not txt.strip() or len(txt) < 3:
|
||||
continue
|
||||
|
||||
if rpr is not None:
|
||||
is_bold = rpr.find(qn('b')) is not None
|
||||
key = etree.tostring(rpr, encoding='unicode')
|
||||
|
||||
if is_bold and first_bold_rpr is None:
|
||||
first_bold_rpr = rpr
|
||||
|
||||
# 优先从条款标题段落提取标题格式
|
||||
if is_clause_title and clause_title_rpr is None:
|
||||
clause_title_rpr = rpr
|
||||
|
||||
if not is_bold:
|
||||
if key not in rpr_map:
|
||||
rpr_map[key] = [0, rpr]
|
||||
rpr_map[key][0] += 1
|
||||
|
||||
if self._body_ppr is None:
|
||||
ppr = p.find(qn('pPr'))
|
||||
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||
if ppr is not None and len(txt) > 20:
|
||||
self._body_ppr = ppr
|
||||
|
||||
if rpr_map:
|
||||
best = max(rpr_map.values(), key=lambda x: x[0])
|
||||
self._body_rpr = best[1]
|
||||
|
||||
# 标题格式优先级:条款编号标题 > 第一个加粗run > 正文格式
|
||||
self._title_rpr = clause_title_rpr or first_bold_rpr or self._body_rpr
|
||||
|
||||
if self._title_rpr is None and self._body_rpr is not None:
|
||||
self._title_rpr = copy.deepcopy(self._body_rpr)
|
||||
etree.SubElement(self._title_rpr, qn('b'))
|
||||
|
||||
def _next_id(self):
|
||||
self._rev_id += 1
|
||||
return str(self._rev_id)
|
||||
|
||||
def _mk_del(self, text, rpr=None):
|
||||
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)
|
||||
r = etree.SubElement(d, qn('r'))
|
||||
r.set(qn('rsidDel'), self._rsid)
|
||||
if rpr is not None:
|
||||
r.append(copy.deepcopy(rpr))
|
||||
t = etree.SubElement(r, qn('delText'))
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = text
|
||||
return d
|
||||
|
||||
def _mk_ins(self, text, rpr=None):
|
||||
i = etree.Element(qn('ins'))
|
||||
i.set(qn('id'), self._next_id())
|
||||
i.set(qn('author'), self._author)
|
||||
i.set(qn('date'), self._revision_date)
|
||||
r = etree.SubElement(i, qn('r'))
|
||||
r.set(qn('rsidR'), self._rsid)
|
||||
if rpr is not None:
|
||||
r.append(copy.deepcopy(rpr))
|
||||
t = etree.SubElement(r, qn('t'))
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = text
|
||||
return i
|
||||
|
||||
def _mk_run(self, text, rpr=None):
|
||||
r = etree.Element(qn('r'))
|
||||
if rpr is not None:
|
||||
r.append(copy.deepcopy(rpr))
|
||||
t = etree.SubElement(r, qn('t'))
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = text
|
||||
return r
|
||||
|
||||
def get_para_text(self, p):
|
||||
"""获取段落的原始文本(不含删除标记中的文本)"""
|
||||
return ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||
|
||||
def find_para(self, search_text):
|
||||
"""查找包含指定文本的段落"""
|
||||
for p in self.body.findall(qn('p')):
|
||||
if search_text in self.get_para_text(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
def tracked_replace(self, old_text, new_text):
|
||||
"""在整个文档中查找old_text并用修订模式替换为new_text。
|
||||
使用字符级tokenizer+difflib实现精准修订。
|
||||
返回True如果成功。"""
|
||||
for p in self.body.findall(qn('p')):
|
||||
runs = p.findall(f'.//{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
|
||||
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'))
|
||||
break
|
||||
pos += len(rt)
|
||||
|
||||
# 生成diff元素
|
||||
if new_text == '':
|
||||
elems = [self._mk_del(old_text, rpr)]
|
||||
else:
|
||||
ot = cjk_tokenize(old_text)
|
||||
nt = cjk_tokenize(new_text)
|
||||
matcher = difflib.SequenceMatcher(None, ot, nt)
|
||||
elems = []
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == 'equal':
|
||||
elems.append(self._mk_run(''.join(ot[i1:i2]), rpr))
|
||||
elif tag == 'delete':
|
||||
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
|
||||
elif tag == 'insert':
|
||||
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
|
||||
elif tag == 'replace':
|
||||
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
|
||||
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
|
||||
|
||||
# 定位受影响的runs并替换
|
||||
pos = 0
|
||||
first = last = None
|
||||
prefix_text = suffix_text = ""
|
||||
for idx, r in enumerate(runs):
|
||||
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||
run_end = pos + len(rt)
|
||||
if run_end > start and pos < end:
|
||||
if first is None:
|
||||
first = idx
|
||||
prefix_text = full[pos:start]
|
||||
last = idx
|
||||
suffix_text = full[end:run_end] if run_end > end else ""
|
||||
pos = run_end
|
||||
|
||||
if first is None:
|
||||
continue
|
||||
|
||||
ref = runs[first]
|
||||
# Find the actual paragraph (w:p) element to insert into
|
||||
para_elem = p
|
||||
# Determine insert position: find ref or its ancestor that is a direct child of p
|
||||
ref_ancestor = ref
|
||||
while ref_ancestor.getparent() is not para_elem and ref_ancestor.getparent() is not None:
|
||||
ref_ancestor = ref_ancestor.getparent()
|
||||
insert_pos = list(para_elem).index(ref_ancestor)
|
||||
|
||||
# Remove runs (each from its own parent)
|
||||
for idx in range(last, first - 1, -1):
|
||||
r = runs[idx]
|
||||
r_parent = r.getparent()
|
||||
r_parent.remove(r)
|
||||
# If parent (e.g. w:ins) is now empty, remove it too
|
||||
if r_parent is not para_elem and len(r_parent) == 0:
|
||||
gp = r_parent.getparent()
|
||||
if gp is not None:
|
||||
gp.remove(r_parent)
|
||||
|
||||
ip = insert_pos
|
||||
if prefix_text:
|
||||
para_elem.insert(ip, self._mk_run(prefix_text, rpr))
|
||||
ip += 1
|
||||
for e in elems:
|
||||
para_elem.insert(ip, e)
|
||||
ip += 1
|
||||
if suffix_text:
|
||||
para_elem.insert(ip, self._mk_run(suffix_text, rpr))
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_leading_whitespace(self, para):
|
||||
"""从段落中提取前导空格/tab模式。
|
||||
很多中文文档的缩进不是通过w:ind实现的,而是通过文本中的空格字符。"""
|
||||
for r in para.findall(qn('r')):
|
||||
# Skip deleted runs
|
||||
if r.getparent().tag == qn('del'):
|
||||
continue
|
||||
for t in r.findall(qn('t')):
|
||||
if t.text:
|
||||
# Extract leading whitespace
|
||||
stripped = t.text.lstrip()
|
||||
if stripped: # Has actual content after whitespace
|
||||
return t.text[:len(t.text) - len(stripped)]
|
||||
elif t.text.isspace(): # Entire run is whitespace
|
||||
return t.text
|
||||
return ''
|
||||
|
||||
def add_clause(self, full_text, after_search, use_title_format=False):
|
||||
"""在包含after_search的段落之后插入新条款段落。
|
||||
|
||||
full_text: 新条款全文
|
||||
after_search: 在包含此文本的段落之后插入
|
||||
use_title_format: True=标题格式(加粗),False=正文格式
|
||||
"""
|
||||
ref_para = self.find_para(after_search)
|
||||
if ref_para is None:
|
||||
return False
|
||||
|
||||
rpr = self._title_rpr if use_title_format else self._body_rpr
|
||||
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||
|
||||
# 复制相邻段落的前导空格模式
|
||||
leading_ws = self._get_leading_whitespace(ref_para)
|
||||
if leading_ws and not full_text.startswith(leading_ws):
|
||||
full_text = leading_ws + full_text
|
||||
|
||||
new_p = etree.Element(qn('p'))
|
||||
if ppr is not None:
|
||||
new_p.append(copy.deepcopy(ppr))
|
||||
new_p.append(self._mk_ins(full_text, rpr))
|
||||
|
||||
idx = list(self.body).index(ref_para)
|
||||
self.body.insert(idx + 1, new_p)
|
||||
return True
|
||||
|
||||
def add_clause_before(self, full_text, before_search, use_title_format=False):
|
||||
"""在包含before_search的段落之前插入新条款段落。"""
|
||||
ref_para = self.find_para(before_search)
|
||||
if ref_para is None:
|
||||
return False
|
||||
|
||||
rpr = self._title_rpr if use_title_format else self._body_rpr
|
||||
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||
|
||||
# 复制相邻段落的前导空格模式
|
||||
leading_ws = self._get_leading_whitespace(ref_para)
|
||||
if leading_ws and not full_text.startswith(leading_ws):
|
||||
full_text = leading_ws + full_text
|
||||
|
||||
new_p = etree.Element(qn('p'))
|
||||
if ppr is not None:
|
||||
new_p.append(copy.deepcopy(ppr))
|
||||
new_p.append(self._mk_ins(full_text, rpr))
|
||||
|
||||
idx = list(self.body).index(ref_para)
|
||||
self.body.insert(idx, new_p)
|
||||
return True
|
||||
|
||||
def add_mixed_clause(self, title_text, content_text, after_search):
|
||||
"""插入标题加粗+内容不加粗的新条款(两个段落)。
|
||||
用于原文标题和内容分行的合同格式。"""
|
||||
ref_para = self.find_para(after_search)
|
||||
if ref_para is None:
|
||||
return False
|
||||
|
||||
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||
idx = list(self.body).index(ref_para)
|
||||
|
||||
# 复制相邻段落的前导空格模式
|
||||
leading_ws = self._get_leading_whitespace(ref_para)
|
||||
if leading_ws:
|
||||
if not title_text.startswith(leading_ws):
|
||||
title_text = leading_ws + title_text
|
||||
if not content_text.startswith(leading_ws):
|
||||
content_text = leading_ws + content_text
|
||||
|
||||
p_title = etree.Element(qn('p'))
|
||||
if ppr: p_title.append(copy.deepcopy(ppr))
|
||||
p_title.append(self._mk_ins(title_text, self._title_rpr))
|
||||
self.body.insert(idx + 1, p_title)
|
||||
|
||||
p_content = etree.Element(qn('p'))
|
||||
if ppr: p_content.append(copy.deepcopy(ppr))
|
||||
p_content.append(self._mk_ins(content_text, self._body_rpr))
|
||||
self.body.insert(idx + 2, p_content)
|
||||
|
||||
return True
|
||||
|
||||
def renumber_clause(self, old_num, new_num):
|
||||
"""把条款编号从old_num改为new_num(修订模式)。
|
||||
从后往前扫描,避免重复修改。"""
|
||||
changed = 0
|
||||
for p in reversed(self.body.findall(qn('p'))):
|
||||
runs = p.findall(f'.//{qn("r")}')
|
||||
for r in runs:
|
||||
for t in r.findall(qn('t')):
|
||||
if t.text and old_num in t.text:
|
||||
rpr_e = r.find(qn('rPr'))
|
||||
parent = r.getparent()
|
||||
idx_r = list(parent).index(r)
|
||||
|
||||
pos = t.text.index(old_num)
|
||||
prefix = t.text[:pos]
|
||||
suffix = t.text[pos + len(old_num):]
|
||||
|
||||
parent.remove(r)
|
||||
ip = idx_r
|
||||
if prefix:
|
||||
parent.insert(ip, self._mk_run(prefix, rpr_e))
|
||||
ip += 1
|
||||
parent.insert(ip, self._mk_del(old_num, rpr_e))
|
||||
ip += 1
|
||||
parent.insert(ip, self._mk_ins(new_num, rpr_e))
|
||||
ip += 1
|
||||
if suffix:
|
||||
parent.insert(ip, self._mk_run(suffix, rpr_e))
|
||||
|
||||
changed += 1
|
||||
break
|
||||
return changed
|
||||
|
||||
def renumber_range(self, start, shift=1):
|
||||
"""从start开始,所有现有条款编号+shift。从后往前处理。
|
||||
|
||||
注意:先调用此方法腾出编号空间,再插入新条款。
|
||||
例:要在18后插入新19条:
|
||||
editor.renumber_range(19, 1) # 19→20, 20→21, 21→22
|
||||
editor.add_clause_before("19.新条款内容", before_search="20.合同生效")
|
||||
"""
|
||||
max_num = 0
|
||||
for p in self.body.findall(qn('p')):
|
||||
txt = self.get_para_text(p)
|
||||
for m in re.finditer(r'(\d+)[..]', txt):
|
||||
n = int(m.group(1))
|
||||
if n > max_num:
|
||||
max_num = n
|
||||
|
||||
for n in range(max_num, start - 1, -1):
|
||||
self.renumber_clause(f'{n}.', f'{n + shift}.')
|
||||
self.renumber_clause(f'{n}.', f'{n + shift}.')
|
||||
|
||||
def renumber_chinese(self, old_cn, new_cn):
|
||||
"""中文编号顺延,如 "第十三条" → "第十四条"。"""
|
||||
return self.renumber_clause(old_cn, new_cn)
|
||||
|
||||
def validate(self):
|
||||
"""交付前验证。返回错误列表,空列表=通过。"""
|
||||
errors = []
|
||||
|
||||
# 1. 编号连续性
|
||||
clause_nums = []
|
||||
for p in self.body.findall(qn('p')):
|
||||
accepted = ''
|
||||
for child in p:
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag == 'r':
|
||||
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
|
||||
elif tag == 'ins':
|
||||
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
|
||||
m = re.match(r'^(\d+)[..]', accepted.strip())
|
||||
if m:
|
||||
clause_nums.append(int(m.group(1)))
|
||||
|
||||
main_clauses = sorted(set(clause_nums))
|
||||
for i in range(1, len(main_clauses)):
|
||||
if main_clauses[i] - main_clauses[i-1] > 1:
|
||||
errors.append(f"编号跳跃: {main_clauses[i-1]}→{main_clauses[i]},缺少{main_clauses[i-1]+1}")
|
||||
|
||||
# 2. 字号一致性(WB的ins内容 vs 原文正文)
|
||||
if self._body_rpr is not None:
|
||||
body_sz = None
|
||||
sz_elem = self._body_rpr.find(qn('sz'))
|
||||
if sz_elem is not None:
|
||||
body_sz = sz_elem.get(qn('val'))
|
||||
|
||||
if body_sz:
|
||||
for ins in self.tree.findall(f'.//{qn("ins")}'):
|
||||
if ins.get(qn('author')) != self._author:
|
||||
continue
|
||||
for r in ins.findall(qn('r')):
|
||||
rpr = r.find(qn('rPr'))
|
||||
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||
if not txt.strip():
|
||||
continue
|
||||
if rpr is not None:
|
||||
ins_sz = rpr.find(qn('sz'))
|
||||
if ins_sz is not None:
|
||||
val = ins_sz.get(qn('val'))
|
||||
is_bold = rpr.find(qn('b')) is not None
|
||||
if val != body_sz and not is_bold:
|
||||
errors.append(f"字号不一致: ins sz={val} vs 原文sz={body_sz},'{txt[:30]}'")
|
||||
|
||||
# 3. 加粗规则(内容不应加粗)
|
||||
for ins in self.tree.findall(f'.//{qn("ins")}'):
|
||||
if ins.get(qn('author')) != self._author:
|
||||
continue
|
||||
for r in ins.findall(qn('r')):
|
||||
rpr = r.find(qn('rPr'))
|
||||
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||
if not txt.strip() or len(txt.strip()) < 5:
|
||||
continue
|
||||
is_bold = rpr is not None and rpr.find(qn('b')) is not None
|
||||
is_clause_title = bool(re.match(r'^\d+[..]\S', txt.strip())) or bool(re.match(r'^第.{1,3}条', txt.strip())) or bool(re.match(r'^[一二三四五六七八九十]{1,3}、', txt.strip()))
|
||||
if is_bold and not is_clause_title:
|
||||
errors.append(f"不应加粗: '{txt[:40]}'")
|
||||
|
||||
return errors
|
||||
|
||||
def dump_numbering(self):
|
||||
"""输出accepted view的编号序列,用于人工确认"""
|
||||
result = []
|
||||
for p in self.body.findall(qn('p')):
|
||||
accepted = ''
|
||||
for child in p:
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag == 'r':
|
||||
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
|
||||
elif tag == 'ins':
|
||||
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
|
||||
m = re.match(r'^(\d+)[..]', accepted.strip())
|
||||
if m:
|
||||
result.append(f"{m.group(1)}. {accepted.strip()[:60]}")
|
||||
return result
|
||||
|
||||
def save(self, output_path):
|
||||
"""保存修订后的文件"""
|
||||
new_doc_xml = etree.tostring(self.tree, xml_declaration=True,
|
||||
encoding='UTF-8', standalone=True)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
|
||||
settings = z.read('word/settings.xml')
|
||||
stree = etree.fromstring(settings)
|
||||
if stree.find(f'.//{qn("trackRevisions")}') is None:
|
||||
stree.append(etree.Element(qn('trackRevisions')))
|
||||
new_settings = etree.tostring(stree, xml_declaration=True,
|
||||
encoding='UTF-8', standalone=True)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as zin:
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
if item.filename == 'word/document.xml':
|
||||
zout.writestr(item, new_doc_xml)
|
||||
elif item.filename == 'word/settings.xml':
|
||||
zout.writestr(item, new_settings)
|
||||
else:
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(buf.getvalue())
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
class ZhujiajaoOpinion:
|
||||
"""朱家角审查意见表格填写器。严格使用模板结构,不自创格式。"""
|
||||
|
||||
TEMPLATE_PATH = Path.home() / ".hermes/shared/模版库/朱家角 审查意见【模板】.docx"
|
||||
|
||||
def __init__(self, template_path=None):
|
||||
tpath = Path(template_path) if template_path else self.TEMPLATE_PATH
|
||||
with open(tpath, 'rb') as f:
|
||||
self.tmpl_bytes = f.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as z:
|
||||
self.doc_xml = z.read('word/document.xml')
|
||||
|
||||
self.tree = etree.fromstring(self.doc_xml)
|
||||
self.body = self.tree.find(qn('body'))
|
||||
|
||||
def fill(self, contract_name, items, has_modifications=True):
|
||||
"""填写审查意见。
|
||||
|
||||
contract_name: 合同名称(填入标题《》中间)
|
||||
items: [(条文位置, 原文, 修订后), ...]
|
||||
has_modifications: False则保留"无法律修改意见"
|
||||
"""
|
||||
# 1. 填标题——找到空格run替换
|
||||
for p in self.body.findall(qn('p')):
|
||||
runs = p.findall(f'.//{qn("r")}')
|
||||
for r in runs:
|
||||
for t in r.findall(qn('t')):
|
||||
if t.text and t.text.strip() == '' and len(t.text) >= 2:
|
||||
parent_txt = ''.join(
|
||||
tt.text or '' for rr in runs for tt in rr.findall(qn('t'))
|
||||
)
|
||||
if '关于《' in parent_txt:
|
||||
t.text = contract_name
|
||||
|
||||
# 2. 处理"无法律修改意见"
|
||||
if has_modifications:
|
||||
for p in self.body.findall(qn('p')):
|
||||
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||
if '无法律修改意见' in txt:
|
||||
for r in p.findall(f'.//{qn("r")}'):
|
||||
for t in r.findall(qn('t')):
|
||||
if '无法律修改意见' in (t.text or ''):
|
||||
t.text = ''
|
||||
|
||||
# 3. 填表格
|
||||
if not items:
|
||||
return
|
||||
|
||||
tbl = self.body.find(qn('tbl'))
|
||||
if tbl is None:
|
||||
return
|
||||
|
||||
rows = tbl.findall(qn('tr'))
|
||||
# Row 0 = header, Row 1+ = data rows
|
||||
|
||||
# 获取表头rPr
|
||||
header_rpr = None
|
||||
for hc in rows[0].findall(qn('tc')):
|
||||
for hr in hc.findall(f'.//{qn("r")}'):
|
||||
rr = hr.find(qn('rPr'))
|
||||
if rr:
|
||||
header_rpr = rr
|
||||
break
|
||||
if header_rpr:
|
||||
break
|
||||
|
||||
# 确保有足够数据行
|
||||
template_row = rows[1] if len(rows) > 1 else None
|
||||
while len(tbl.findall(qn('tr'))) - 1 < len(items):
|
||||
if template_row is not None:
|
||||
tbl.append(copy.deepcopy(template_row))
|
||||
|
||||
rows = tbl.findall(qn('tr'))
|
||||
|
||||
# 填写数据
|
||||
for i, (clause, orig_text, modified_text) in enumerate(items):
|
||||
if i + 1 >= len(rows):
|
||||
break
|
||||
row = rows[i + 1]
|
||||
cells = row.findall(qn('tc'))
|
||||
if len(cells) < 3:
|
||||
continue
|
||||
|
||||
for ci, text in enumerate([clause, orig_text, modified_text]):
|
||||
cell = cells[ci]
|
||||
p = cell.find(qn('p'))
|
||||
if p is None:
|
||||
p = etree.SubElement(cell, qn('p'))
|
||||
for r in p.findall(qn('r')):
|
||||
p.remove(r)
|
||||
|
||||
r = etree.SubElement(p, qn('r'))
|
||||
if header_rpr:
|
||||
new_rpr = copy.deepcopy(header_rpr)
|
||||
b = new_rpr.find(qn('b'))
|
||||
if b is not None:
|
||||
new_rpr.remove(b)
|
||||
if '注:' in text:
|
||||
color = new_rpr.find(qn('color'))
|
||||
if color is None:
|
||||
color = etree.SubElement(new_rpr, qn('color'))
|
||||
color.set(qn('val'), 'FF0000')
|
||||
r.append(new_rpr)
|
||||
|
||||
t = etree.SubElement(r, qn('t'))
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = text
|
||||
|
||||
# 删除多余空行
|
||||
rows = tbl.findall(qn('tr'))
|
||||
for i in range(len(rows) - 1, len(items), -1):
|
||||
tbl.remove(rows[i])
|
||||
|
||||
def save(self, output_path):
|
||||
new_doc = etree.tostring(self.tree, xml_declaration=True,
|
||||
encoding='UTF-8', standalone=True)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as zin:
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
if item.filename == 'word/document.xml':
|
||||
zout.writestr(item, new_doc)
|
||||
else:
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(buf.getvalue())
|
||||
return output_path
|
||||
@@ -0,0 +1,684 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
contract_docx_lib.py — 合同修订核心库
|
||||
固化验证通过的docx XML操作,不再每次重写。
|
||||
|
||||
用法:
|
||||
from contract_docx_lib import ContractEditor
|
||||
|
||||
editor = ContractEditor("原文件.docx")
|
||||
editor.tracked_replace("原文片段", "新文片段")
|
||||
editor.add_clause("19.服务成果持续使用权", "条款内容...", after_clause=18)
|
||||
editor.renumber(19, 20) # 原19→20
|
||||
errors = editor.validate()
|
||||
if not errors:
|
||||
editor.save("【修】原文件.docx")
|
||||
|
||||
关键操作顺序(renumber和新增条款):
|
||||
1. 先做所有 tracked_replace(文本修改)
|
||||
2. 再做 add_clause(新增子条款,如15.4)
|
||||
3. 再做 renumber_range(先腾出编号空间)
|
||||
4. 最后做 add_clause_before(插入新主条款,用已腾出的编号)
|
||||
5. validate() 验证
|
||||
6. save() 保存
|
||||
"""
|
||||
|
||||
import zipfile, io, copy, re, difflib
|
||||
from lxml import etree
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
|
||||
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'
|
||||
WNS = '{' + W + '}'
|
||||
|
||||
def qn(tag):
|
||||
return f'{WNS}{tag}'
|
||||
|
||||
|
||||
def cjk_tokenize(text):
|
||||
"""CJK每字一token,ASCII连续一token,标点单独token。
|
||||
经验证的分词策略,不要改。"""
|
||||
tokens = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or ch in ',。、;:!?""''()【】《》—…·[]%%':
|
||||
tokens.append(ch)
|
||||
i += 1
|
||||
elif ch.isascii() and ch.isalnum():
|
||||
j = i
|
||||
while j < len(text) and text[j].isascii() and text[j].isalnum():
|
||||
j += 1
|
||||
tokens.append(text[i:j])
|
||||
i = j
|
||||
else:
|
||||
tokens.append(ch)
|
||||
i += 1
|
||||
return tokens
|
||||
|
||||
|
||||
class ContractEditor:
|
||||
"""合同修订编辑器。一个实例对应一份合同文件。"""
|
||||
|
||||
def __init__(self, filepath):
|
||||
self.filepath = Path(filepath)
|
||||
with open(filepath, 'rb') as f:
|
||||
self.original_bytes = f.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
|
||||
self.doc_xml = z.read('word/document.xml')
|
||||
|
||||
self.tree = etree.fromstring(self.doc_xml)
|
||||
self.body = self.tree.find(qn('body'))
|
||||
self._rev_id = 100
|
||||
self._revision_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
self._author = 'WB'
|
||||
self._rsid = '00AA0001'
|
||||
|
||||
# 提取原文格式(核心:避免每次猜错格式)
|
||||
self._body_rpr = None # 正文格式(最常见的非加粗rPr)
|
||||
self._title_rpr = None # 条款标题格式(加粗的rPr)
|
||||
self._body_ppr = None
|
||||
self._extract_formats()
|
||||
|
||||
def _extract_formats(self):
|
||||
"""从原文提取正文和标题的rPr。
|
||||
策略:
|
||||
- 正文格式:统计所有run的rPr,取出现最多的非加粗rPr
|
||||
- 标题格式:优先从条款编号标题段落(如"7.索赔条款")提取rPr,
|
||||
而非简单取第一个加粗run(可能是合同大标题,字号不同)
|
||||
- 如果条款标题不加粗,标题格式回退到正文格式"""
|
||||
import re
|
||||
rpr_map = {} # serialized_rpr -> (count, rpr_element)
|
||||
clause_title_rpr = None # 从条款编号标题提取的格式
|
||||
first_bold_rpr = None # 第一个加粗run的格式(fallback)
|
||||
|
||||
for p in self.body.findall(qn('p')):
|
||||
# 获取段落全文,判断是否是条款编号标题(如 "7.索赔条款" "5.伴随服务")
|
||||
p_text = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')).strip()
|
||||
is_clause_title = bool(re.match(r'^\d+[..、]\s*\S', p_text)) and len(p_text) < 30
|
||||
|
||||
for r in p.findall(qn('r')):
|
||||
rpr = r.find(qn('rPr'))
|
||||
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||
if not txt.strip() or len(txt) < 3:
|
||||
continue
|
||||
|
||||
if rpr is not None:
|
||||
is_bold = rpr.find(qn('b')) is not None
|
||||
key = etree.tostring(rpr, encoding='unicode')
|
||||
|
||||
if is_bold and first_bold_rpr is None:
|
||||
first_bold_rpr = rpr
|
||||
|
||||
# 优先从条款标题段落提取标题格式
|
||||
if is_clause_title and clause_title_rpr is None:
|
||||
clause_title_rpr = rpr
|
||||
|
||||
if not is_bold:
|
||||
if key not in rpr_map:
|
||||
rpr_map[key] = [0, rpr]
|
||||
rpr_map[key][0] += 1
|
||||
|
||||
if self._body_ppr is None:
|
||||
ppr = p.find(qn('pPr'))
|
||||
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||
if ppr is not None and len(txt) > 20:
|
||||
self._body_ppr = ppr
|
||||
|
||||
if rpr_map:
|
||||
best = max(rpr_map.values(), key=lambda x: x[0])
|
||||
self._body_rpr = best[1]
|
||||
|
||||
# 标题格式优先级:条款编号标题 > 第一个加粗run > 正文格式
|
||||
self._title_rpr = clause_title_rpr or first_bold_rpr or self._body_rpr
|
||||
|
||||
if self._title_rpr is None and self._body_rpr is not None:
|
||||
self._title_rpr = copy.deepcopy(self._body_rpr)
|
||||
etree.SubElement(self._title_rpr, qn('b'))
|
||||
|
||||
def _next_id(self):
|
||||
self._rev_id += 1
|
||||
return str(self._rev_id)
|
||||
|
||||
def _mk_del(self, text, rpr=None):
|
||||
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)
|
||||
r = etree.SubElement(d, qn('r'))
|
||||
r.set(qn('rsidDel'), self._rsid)
|
||||
if rpr is not None:
|
||||
r.append(copy.deepcopy(rpr))
|
||||
t = etree.SubElement(r, qn('delText'))
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = text
|
||||
return d
|
||||
|
||||
def _mk_ins(self, text, rpr=None):
|
||||
i = etree.Element(qn('ins'))
|
||||
i.set(qn('id'), self._next_id())
|
||||
i.set(qn('author'), self._author)
|
||||
i.set(qn('date'), self._revision_date)
|
||||
r = etree.SubElement(i, qn('r'))
|
||||
r.set(qn('rsidR'), self._rsid)
|
||||
if rpr is not None:
|
||||
r.append(copy.deepcopy(rpr))
|
||||
t = etree.SubElement(r, qn('t'))
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = text
|
||||
return i
|
||||
|
||||
def _mk_run(self, text, rpr=None):
|
||||
r = etree.Element(qn('r'))
|
||||
if rpr is not None:
|
||||
r.append(copy.deepcopy(rpr))
|
||||
t = etree.SubElement(r, qn('t'))
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = text
|
||||
return r
|
||||
|
||||
def get_para_text(self, p):
|
||||
"""获取段落的原始文本(不含删除标记中的文本)"""
|
||||
return ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||
|
||||
def find_para(self, search_text):
|
||||
"""查找包含指定文本的段落"""
|
||||
for p in self.body.findall(qn('p')):
|
||||
if search_text in self.get_para_text(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
def tracked_replace(self, old_text, new_text):
|
||||
"""在整个文档中查找old_text并用修订模式替换为new_text。
|
||||
使用字符级tokenizer+difflib实现精准修订。
|
||||
返回True如果成功。"""
|
||||
for p in self.body.findall(qn('p')):
|
||||
runs = p.findall(f'.//{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
|
||||
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'))
|
||||
break
|
||||
pos += len(rt)
|
||||
|
||||
# 生成diff元素
|
||||
if new_text == '':
|
||||
elems = [self._mk_del(old_text, rpr)]
|
||||
else:
|
||||
ot = cjk_tokenize(old_text)
|
||||
nt = cjk_tokenize(new_text)
|
||||
matcher = difflib.SequenceMatcher(None, ot, nt)
|
||||
elems = []
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == 'equal':
|
||||
elems.append(self._mk_run(''.join(ot[i1:i2]), rpr))
|
||||
elif tag == 'delete':
|
||||
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
|
||||
elif tag == 'insert':
|
||||
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
|
||||
elif tag == 'replace':
|
||||
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
|
||||
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
|
||||
|
||||
# 定位受影响的runs并替换
|
||||
pos = 0
|
||||
first = last = None
|
||||
prefix_text = suffix_text = ""
|
||||
for idx, r in enumerate(runs):
|
||||
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||
run_end = pos + len(rt)
|
||||
if run_end > start and pos < end:
|
||||
if first is None:
|
||||
first = idx
|
||||
prefix_text = full[pos:start]
|
||||
last = idx
|
||||
suffix_text = full[end:run_end] if run_end > end else ""
|
||||
pos = run_end
|
||||
|
||||
if first is None:
|
||||
continue
|
||||
|
||||
ref = runs[first]
|
||||
# Find the actual paragraph (w:p) element to insert into
|
||||
para_elem = p
|
||||
# Determine insert position: find ref or its ancestor that is a direct child of p
|
||||
ref_ancestor = ref
|
||||
while ref_ancestor.getparent() is not para_elem and ref_ancestor.getparent() is not None:
|
||||
ref_ancestor = ref_ancestor.getparent()
|
||||
insert_pos = list(para_elem).index(ref_ancestor)
|
||||
|
||||
# Remove runs (each from its own parent)
|
||||
for idx in range(last, first - 1, -1):
|
||||
r = runs[idx]
|
||||
r_parent = r.getparent()
|
||||
r_parent.remove(r)
|
||||
# If parent (e.g. w:ins) is now empty, remove it too
|
||||
if r_parent is not para_elem and len(r_parent) == 0:
|
||||
gp = r_parent.getparent()
|
||||
if gp is not None:
|
||||
gp.remove(r_parent)
|
||||
|
||||
ip = insert_pos
|
||||
if prefix_text:
|
||||
para_elem.insert(ip, self._mk_run(prefix_text, rpr))
|
||||
ip += 1
|
||||
for e in elems:
|
||||
para_elem.insert(ip, e)
|
||||
ip += 1
|
||||
if suffix_text:
|
||||
para_elem.insert(ip, self._mk_run(suffix_text, rpr))
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_leading_whitespace(self, para):
|
||||
"""从段落中提取前导空格/tab模式。
|
||||
很多中文文档的缩进不是通过w:ind实现的,而是通过文本中的空格字符。"""
|
||||
for r in para.findall(qn('r')):
|
||||
# Skip deleted runs
|
||||
if r.getparent().tag == qn('del'):
|
||||
continue
|
||||
for t in r.findall(qn('t')):
|
||||
if t.text:
|
||||
# Extract leading whitespace
|
||||
stripped = t.text.lstrip()
|
||||
if stripped: # Has actual content after whitespace
|
||||
return t.text[:len(t.text) - len(stripped)]
|
||||
elif t.text.isspace(): # Entire run is whitespace
|
||||
return t.text
|
||||
return ''
|
||||
|
||||
def add_clause(self, full_text, after_search, use_title_format=False):
|
||||
"""在包含after_search的段落之后插入新条款段落。
|
||||
|
||||
full_text: 新条款全文
|
||||
after_search: 在包含此文本的段落之后插入
|
||||
use_title_format: True=标题格式(加粗),False=正文格式
|
||||
"""
|
||||
ref_para = self.find_para(after_search)
|
||||
if ref_para is None:
|
||||
return False
|
||||
|
||||
rpr = self._title_rpr if use_title_format else self._body_rpr
|
||||
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||
|
||||
# 复制相邻段落的前导空格模式
|
||||
leading_ws = self._get_leading_whitespace(ref_para)
|
||||
if leading_ws and not full_text.startswith(leading_ws):
|
||||
full_text = leading_ws + full_text
|
||||
|
||||
new_p = etree.Element(qn('p'))
|
||||
if ppr is not None:
|
||||
new_p.append(copy.deepcopy(ppr))
|
||||
new_p.append(self._mk_ins(full_text, rpr))
|
||||
|
||||
idx = list(self.body).index(ref_para)
|
||||
self.body.insert(idx + 1, new_p)
|
||||
return True
|
||||
|
||||
def add_clause_before(self, full_text, before_search, use_title_format=False):
|
||||
"""在包含before_search的段落之前插入新条款段落。"""
|
||||
ref_para = self.find_para(before_search)
|
||||
if ref_para is None:
|
||||
return False
|
||||
|
||||
rpr = self._title_rpr if use_title_format else self._body_rpr
|
||||
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||
|
||||
# 复制相邻段落的前导空格模式
|
||||
leading_ws = self._get_leading_whitespace(ref_para)
|
||||
if leading_ws and not full_text.startswith(leading_ws):
|
||||
full_text = leading_ws + full_text
|
||||
|
||||
new_p = etree.Element(qn('p'))
|
||||
if ppr is not None:
|
||||
new_p.append(copy.deepcopy(ppr))
|
||||
new_p.append(self._mk_ins(full_text, rpr))
|
||||
|
||||
idx = list(self.body).index(ref_para)
|
||||
self.body.insert(idx, new_p)
|
||||
return True
|
||||
|
||||
def add_mixed_clause(self, title_text, content_text, after_search):
|
||||
"""插入标题加粗+内容不加粗的新条款(两个段落)。
|
||||
用于原文标题和内容分行的合同格式。"""
|
||||
ref_para = self.find_para(after_search)
|
||||
if ref_para is None:
|
||||
return False
|
||||
|
||||
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||
idx = list(self.body).index(ref_para)
|
||||
|
||||
# 复制相邻段落的前导空格模式
|
||||
leading_ws = self._get_leading_whitespace(ref_para)
|
||||
if leading_ws:
|
||||
if not title_text.startswith(leading_ws):
|
||||
title_text = leading_ws + title_text
|
||||
if not content_text.startswith(leading_ws):
|
||||
content_text = leading_ws + content_text
|
||||
|
||||
p_title = etree.Element(qn('p'))
|
||||
if ppr: p_title.append(copy.deepcopy(ppr))
|
||||
p_title.append(self._mk_ins(title_text, self._title_rpr))
|
||||
self.body.insert(idx + 1, p_title)
|
||||
|
||||
p_content = etree.Element(qn('p'))
|
||||
if ppr: p_content.append(copy.deepcopy(ppr))
|
||||
p_content.append(self._mk_ins(content_text, self._body_rpr))
|
||||
self.body.insert(idx + 2, p_content)
|
||||
|
||||
return True
|
||||
|
||||
def renumber_clause(self, old_num, new_num):
|
||||
"""把条款编号从old_num改为new_num(修订模式)。
|
||||
从后往前扫描,避免重复修改。"""
|
||||
changed = 0
|
||||
for p in reversed(self.body.findall(qn('p'))):
|
||||
runs = p.findall(f'.//{qn("r")}')
|
||||
for r in runs:
|
||||
for t in r.findall(qn('t')):
|
||||
if t.text and old_num in t.text:
|
||||
rpr_e = r.find(qn('rPr'))
|
||||
parent = r.getparent()
|
||||
idx_r = list(parent).index(r)
|
||||
|
||||
pos = t.text.index(old_num)
|
||||
prefix = t.text[:pos]
|
||||
suffix = t.text[pos + len(old_num):]
|
||||
|
||||
parent.remove(r)
|
||||
ip = idx_r
|
||||
if prefix:
|
||||
parent.insert(ip, self._mk_run(prefix, rpr_e))
|
||||
ip += 1
|
||||
parent.insert(ip, self._mk_del(old_num, rpr_e))
|
||||
ip += 1
|
||||
parent.insert(ip, self._mk_ins(new_num, rpr_e))
|
||||
ip += 1
|
||||
if suffix:
|
||||
parent.insert(ip, self._mk_run(suffix, rpr_e))
|
||||
|
||||
changed += 1
|
||||
break
|
||||
return changed
|
||||
|
||||
def renumber_range(self, start, shift=1):
|
||||
"""从start开始,所有现有条款编号+shift。从后往前处理。
|
||||
|
||||
注意:先调用此方法腾出编号空间,再插入新条款。
|
||||
例:要在18后插入新19条:
|
||||
editor.renumber_range(19, 1) # 19→20, 20→21, 21→22
|
||||
editor.add_clause_before("19.新条款内容", before_search="20.合同生效")
|
||||
"""
|
||||
max_num = 0
|
||||
for p in self.body.findall(qn('p')):
|
||||
txt = self.get_para_text(p)
|
||||
for m in re.finditer(r'(\d+)[..]', txt):
|
||||
n = int(m.group(1))
|
||||
if n > max_num:
|
||||
max_num = n
|
||||
|
||||
for n in range(max_num, start - 1, -1):
|
||||
self.renumber_clause(f'{n}.', f'{n + shift}.')
|
||||
self.renumber_clause(f'{n}.', f'{n + shift}.')
|
||||
|
||||
def renumber_chinese(self, old_cn, new_cn):
|
||||
"""中文编号顺延,如 "第十三条" → "第十四条"。"""
|
||||
return self.renumber_clause(old_cn, new_cn)
|
||||
|
||||
def validate(self):
|
||||
"""交付前验证。返回错误列表,空列表=通过。"""
|
||||
errors = []
|
||||
|
||||
# 1. 编号连续性
|
||||
clause_nums = []
|
||||
for p in self.body.findall(qn('p')):
|
||||
accepted = ''
|
||||
for child in p:
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag == 'r':
|
||||
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
|
||||
elif tag == 'ins':
|
||||
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
|
||||
m = re.match(r'^(\d+)[..]', accepted.strip())
|
||||
if m:
|
||||
clause_nums.append(int(m.group(1)))
|
||||
|
||||
main_clauses = sorted(set(clause_nums))
|
||||
for i in range(1, len(main_clauses)):
|
||||
if main_clauses[i] - main_clauses[i-1] > 1:
|
||||
errors.append(f"编号跳跃: {main_clauses[i-1]}→{main_clauses[i]},缺少{main_clauses[i-1]+1}")
|
||||
|
||||
# 2. 字号一致性(WB的ins内容 vs 原文正文)
|
||||
if self._body_rpr is not None:
|
||||
body_sz = None
|
||||
sz_elem = self._body_rpr.find(qn('sz'))
|
||||
if sz_elem is not None:
|
||||
body_sz = sz_elem.get(qn('val'))
|
||||
|
||||
if body_sz:
|
||||
for ins in self.tree.findall(f'.//{qn("ins")}'):
|
||||
if ins.get(qn('author')) != self._author:
|
||||
continue
|
||||
for r in ins.findall(qn('r')):
|
||||
rpr = r.find(qn('rPr'))
|
||||
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||
if not txt.strip():
|
||||
continue
|
||||
if rpr is not None:
|
||||
ins_sz = rpr.find(qn('sz'))
|
||||
if ins_sz is not None:
|
||||
val = ins_sz.get(qn('val'))
|
||||
is_bold = rpr.find(qn('b')) is not None
|
||||
if val != body_sz and not is_bold:
|
||||
errors.append(f"字号不一致: ins sz={val} vs 原文sz={body_sz},'{txt[:30]}'")
|
||||
|
||||
# 3. 加粗规则(内容不应加粗)
|
||||
for ins in self.tree.findall(f'.//{qn("ins")}'):
|
||||
if ins.get(qn('author')) != self._author:
|
||||
continue
|
||||
for r in ins.findall(qn('r')):
|
||||
rpr = r.find(qn('rPr'))
|
||||
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||
if not txt.strip() or len(txt.strip()) < 5:
|
||||
continue
|
||||
is_bold = rpr is not None and rpr.find(qn('b')) is not None
|
||||
is_clause_title = bool(re.match(r'^\d+[..]\S', txt.strip())) or bool(re.match(r'^第.{1,3}条', txt.strip()))
|
||||
if is_bold and not is_clause_title:
|
||||
errors.append(f"不应加粗: '{txt[:40]}'")
|
||||
|
||||
return errors
|
||||
|
||||
def dump_numbering(self):
|
||||
"""输出accepted view的编号序列,用于人工确认"""
|
||||
result = []
|
||||
for p in self.body.findall(qn('p')):
|
||||
accepted = ''
|
||||
for child in p:
|
||||
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||
if tag == 'r':
|
||||
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
|
||||
elif tag == 'ins':
|
||||
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
|
||||
m = re.match(r'^(\d+)[..]', accepted.strip())
|
||||
if m:
|
||||
result.append(f"{m.group(1)}. {accepted.strip()[:60]}")
|
||||
return result
|
||||
|
||||
def save(self, output_path):
|
||||
"""保存修订后的文件"""
|
||||
new_doc_xml = etree.tostring(self.tree, xml_declaration=True,
|
||||
encoding='UTF-8', standalone=True)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
|
||||
settings = z.read('word/settings.xml')
|
||||
stree = etree.fromstring(settings)
|
||||
if stree.find(f'.//{qn("trackRevisions")}') is None:
|
||||
stree.append(etree.Element(qn('trackRevisions')))
|
||||
new_settings = etree.tostring(stree, xml_declaration=True,
|
||||
encoding='UTF-8', standalone=True)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as zin:
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
if item.filename == 'word/document.xml':
|
||||
zout.writestr(item, new_doc_xml)
|
||||
elif item.filename == 'word/settings.xml':
|
||||
zout.writestr(item, new_settings)
|
||||
else:
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(buf.getvalue())
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
class ZhujiajaoOpinion:
|
||||
"""朱家角审查意见表格填写器。严格使用模板结构,不自创格式。"""
|
||||
|
||||
TEMPLATE_PATH = Path.home() / ".hermes/shared/模版库/朱家角 审查意见【模板】.docx"
|
||||
|
||||
def __init__(self, template_path=None):
|
||||
tpath = Path(template_path) if template_path else self.TEMPLATE_PATH
|
||||
with open(tpath, 'rb') as f:
|
||||
self.tmpl_bytes = f.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as z:
|
||||
self.doc_xml = z.read('word/document.xml')
|
||||
|
||||
self.tree = etree.fromstring(self.doc_xml)
|
||||
self.body = self.tree.find(qn('body'))
|
||||
|
||||
def fill(self, contract_name, items, has_modifications=True):
|
||||
"""填写审查意见。
|
||||
|
||||
contract_name: 合同名称(填入标题《》中间)
|
||||
items: [(条文位置, 原文, 修订后), ...]
|
||||
has_modifications: False则保留"无法律修改意见"
|
||||
"""
|
||||
# 1. 填标题——找到空格run替换
|
||||
for p in self.body.findall(qn('p')):
|
||||
runs = p.findall(f'.//{qn("r")}')
|
||||
for r in runs:
|
||||
for t in r.findall(qn('t')):
|
||||
if t.text and t.text.strip() == '' and len(t.text) >= 2:
|
||||
parent_txt = ''.join(
|
||||
tt.text or '' for rr in runs for tt in rr.findall(qn('t'))
|
||||
)
|
||||
if '关于《' in parent_txt:
|
||||
t.text = contract_name
|
||||
|
||||
# 2. 处理"无法律修改意见"
|
||||
if has_modifications:
|
||||
for p in self.body.findall(qn('p')):
|
||||
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||
if '无法律修改意见' in txt:
|
||||
for r in p.findall(f'.//{qn("r")}'):
|
||||
for t in r.findall(qn('t')):
|
||||
if '无法律修改意见' in (t.text or ''):
|
||||
t.text = ''
|
||||
|
||||
# 3. 填表格
|
||||
if not items:
|
||||
return
|
||||
|
||||
tbl = self.body.find(qn('tbl'))
|
||||
if tbl is None:
|
||||
return
|
||||
|
||||
rows = tbl.findall(qn('tr'))
|
||||
# Row 0 = header, Row 1+ = data rows
|
||||
|
||||
# 获取表头rPr
|
||||
header_rpr = None
|
||||
for hc in rows[0].findall(qn('tc')):
|
||||
for hr in hc.findall(f'.//{qn("r")}'):
|
||||
rr = hr.find(qn('rPr'))
|
||||
if rr:
|
||||
header_rpr = rr
|
||||
break
|
||||
if header_rpr:
|
||||
break
|
||||
|
||||
# 确保有足够数据行
|
||||
template_row = rows[1] if len(rows) > 1 else None
|
||||
while len(tbl.findall(qn('tr'))) - 1 < len(items):
|
||||
if template_row is not None:
|
||||
tbl.append(copy.deepcopy(template_row))
|
||||
|
||||
rows = tbl.findall(qn('tr'))
|
||||
|
||||
# 填写数据
|
||||
for i, (clause, orig_text, modified_text) in enumerate(items):
|
||||
if i + 1 >= len(rows):
|
||||
break
|
||||
row = rows[i + 1]
|
||||
cells = row.findall(qn('tc'))
|
||||
if len(cells) < 3:
|
||||
continue
|
||||
|
||||
for ci, text in enumerate([clause, orig_text, modified_text]):
|
||||
cell = cells[ci]
|
||||
p = cell.find(qn('p'))
|
||||
if p is None:
|
||||
p = etree.SubElement(cell, qn('p'))
|
||||
for r in p.findall(qn('r')):
|
||||
p.remove(r)
|
||||
|
||||
r = etree.SubElement(p, qn('r'))
|
||||
if header_rpr:
|
||||
new_rpr = copy.deepcopy(header_rpr)
|
||||
b = new_rpr.find(qn('b'))
|
||||
if b is not None:
|
||||
new_rpr.remove(b)
|
||||
if '注:' in text:
|
||||
color = new_rpr.find(qn('color'))
|
||||
if color is None:
|
||||
color = etree.SubElement(new_rpr, qn('color'))
|
||||
color.set(qn('val'), 'FF0000')
|
||||
r.append(new_rpr)
|
||||
|
||||
t = etree.SubElement(r, qn('t'))
|
||||
t.set(XML_SPACE, 'preserve')
|
||||
t.text = text
|
||||
|
||||
# 删除多余空行
|
||||
rows = tbl.findall(qn('tr'))
|
||||
for i in range(len(rows) - 1, len(items), -1):
|
||||
tbl.remove(rows[i])
|
||||
|
||||
def save(self, output_path):
|
||||
new_doc = etree.tostring(self.tree, xml_declaration=True,
|
||||
encoding='UTF-8', standalone=True)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as zin:
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
if item.filename == 'word/document.xml':
|
||||
zout.writestr(item, new_doc)
|
||||
else:
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(buf.getvalue())
|
||||
return output_path
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
contract_preprocess.py — 合同预处理:检测并切割非审查图片内容
|
||||
|
||||
用途:在workflow审查前,检测合同末尾的纯图片附件(如招标公告截图、中标通知书等),
|
||||
切割出来保存,审查完后再还原。
|
||||
|
||||
判断逻辑:
|
||||
1. 扫描文件结构:文字段落数 vs 图片段落数
|
||||
2. 全文/大部分是图片(扫描件合同)→ 不切割,标记需OCR
|
||||
3. 正文文字+末尾图片附件 → 切割末尾图片区域
|
||||
4. 切割点:从最后一个"纯文字附件"结束后,到第一个"纯图片附件"开始
|
||||
|
||||
输出:
|
||||
- {basename}_stripped.docx — 去掉图片附件的版本(供workflow处理)
|
||||
- {basename}_cutdata.json — 切割信息(供还原用)
|
||||
"""
|
||||
|
||||
import zipfile, json, os, sys, re
|
||||
from lxml import etree
|
||||
|
||||
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
|
||||
A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
|
||||
|
||||
|
||||
def analyze_contract(docx_path):
|
||||
"""Analyze contract structure, return analysis dict"""
|
||||
with zipfile.ZipFile(docx_path) as z:
|
||||
doc = etree.fromstring(z.read('word/document.xml'))
|
||||
media_files = {n: z.getinfo(n).file_size for n in z.namelist() if n.startswith('word/media/')}
|
||||
|
||||
body = doc.find(f'{{{W}}}body')
|
||||
paras = body.findall(f'{{{W}}}p')
|
||||
|
||||
paragraphs = []
|
||||
total_text_chars = 0
|
||||
total_img_paras = 0
|
||||
|
||||
for i, p in enumerate(paras):
|
||||
texts = p.findall(f'.//{{{W}}}t')
|
||||
text = ''.join(t.text or '' for t in texts).strip()
|
||||
|
||||
has_img = any('drawing' in (e.tag if isinstance(e.tag, str) else '') for e in p.iter())
|
||||
|
||||
blips = list(p.iter(f'{{{A_NS}}}blip'))
|
||||
img_rids = [b.get(f'{{{R_NS}}}embed', '') for b in blips]
|
||||
|
||||
total_text_chars += len(text)
|
||||
if has_img:
|
||||
total_img_paras += 1
|
||||
|
||||
paragraphs.append({
|
||||
'idx': i,
|
||||
'text': text,
|
||||
'text_len': len(text),
|
||||
'has_img': has_img,
|
||||
'img_rids': img_rids,
|
||||
'is_appendix_heading': bool(re.match(r'^附件[一二三四五六七八九十\d]+[::、]', text)),
|
||||
})
|
||||
|
||||
return {
|
||||
'total_paras': len(paras),
|
||||
'total_text_chars': total_text_chars,
|
||||
'total_img_paras': total_img_paras,
|
||||
'media_files': media_files,
|
||||
'total_media_bytes': sum(media_files.values()),
|
||||
'paragraphs': paragraphs,
|
||||
}
|
||||
|
||||
|
||||
def detect_cut_zone(analysis):
|
||||
"""Detect if there's a tail image zone to cut."""
|
||||
paras = analysis['paragraphs']
|
||||
total = analysis['total_paras']
|
||||
|
||||
text_paras = sum(1 for p in paras if p['text_len'] > 0 and not p['has_img'])
|
||||
img_paras = analysis['total_img_paras']
|
||||
|
||||
if text_paras == 0 and img_paras > 0:
|
||||
return {'action': 'ocr', 'reason': '全文无文字段落,疑似扫描件合同'}
|
||||
|
||||
if img_paras == 0:
|
||||
return None
|
||||
|
||||
img_ratio = img_paras / max(1, text_paras + img_paras)
|
||||
if img_ratio > 0.5:
|
||||
return {'action': 'ocr', 'reason': f'图片段落占比{img_ratio:.0%},疑似扫描件合同'}
|
||||
|
||||
# Find tail image zones
|
||||
image_zones = []
|
||||
i = 0
|
||||
while i < total:
|
||||
p = paras[i]
|
||||
if p['is_appendix_heading']:
|
||||
zone_start = i
|
||||
zone_has_images = False
|
||||
zone_has_text_content = False
|
||||
j = i + 1
|
||||
|
||||
while j < total:
|
||||
next_p = paras[j]
|
||||
if next_p['is_appendix_heading']:
|
||||
break
|
||||
if next_p['has_img']:
|
||||
zone_has_images = True
|
||||
if next_p['text_len'] > 20 and not next_p['has_img']:
|
||||
zone_has_text_content = True
|
||||
j += 1
|
||||
|
||||
image_zones.append({
|
||||
'start_idx': zone_start,
|
||||
'end_idx': j - 1,
|
||||
'heading': p['text'],
|
||||
'has_images': zone_has_images,
|
||||
'has_text': zone_has_text_content,
|
||||
'is_image_only': zone_has_images and not zone_has_text_content,
|
||||
})
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Find consecutive image-only appendices at the tail
|
||||
tail_cut_zones = []
|
||||
for zone in reversed(image_zones):
|
||||
if zone['is_image_only']:
|
||||
tail_cut_zones.insert(0, zone)
|
||||
else:
|
||||
break
|
||||
|
||||
if not tail_cut_zones:
|
||||
return None
|
||||
|
||||
cut_start = tail_cut_zones[0]['start_idx']
|
||||
cut_headings = [z['heading'] for z in tail_cut_zones]
|
||||
|
||||
return {
|
||||
'action': 'cut',
|
||||
'cut_start_idx': cut_start,
|
||||
'cut_end_idx': total - 1,
|
||||
'cut_headings': cut_headings,
|
||||
'reason': f'末尾{len(tail_cut_zones)}个附件为纯图片:{", ".join(cut_headings)}',
|
||||
}
|
||||
|
||||
|
||||
def preprocess_contract(docx_path, output_dir=None):
|
||||
"""Main entry: analyze and optionally strip tail images."""
|
||||
if output_dir is None:
|
||||
output_dir = os.path.dirname(docx_path) or '.'
|
||||
|
||||
basename = os.path.splitext(os.path.basename(docx_path))[0]
|
||||
|
||||
analysis = analyze_contract(docx_path)
|
||||
cut_info = detect_cut_zone(analysis)
|
||||
|
||||
print(f"\n=== 合同预处理分析 ===")
|
||||
print(f"文件: {os.path.basename(docx_path)}")
|
||||
print(f"段落数: {analysis['total_paras']}")
|
||||
print(f"文字字符: {analysis['total_text_chars']}")
|
||||
print(f"图片段落: {analysis['total_img_paras']}")
|
||||
print(f"媒体文件: {len(analysis['media_files'])} ({analysis['total_media_bytes']:,} bytes)")
|
||||
|
||||
if cut_info is None:
|
||||
print(f"结论: 无需切割")
|
||||
return {'action': 'none', 'analysis': analysis}
|
||||
|
||||
if cut_info['action'] == 'ocr':
|
||||
print(f"结论: {cut_info['reason']},需OCR处理")
|
||||
return {'action': 'ocr', 'reason': cut_info['reason'], 'analysis': analysis}
|
||||
|
||||
cut_start = cut_info['cut_start_idx']
|
||||
print(f"结论: 需切割 — {cut_info['reason']}")
|
||||
print(f"切割点: 段落 #{cut_start}")
|
||||
|
||||
with zipfile.ZipFile(docx_path) as z:
|
||||
doc = etree.fromstring(z.read('word/document.xml'))
|
||||
all_files = {}
|
||||
for name in z.namelist():
|
||||
all_files[name] = z.read(name)
|
||||
|
||||
body = doc.find(f'{{{W}}}body')
|
||||
paras = body.findall(f'{{{W}}}p')
|
||||
|
||||
cut_paras_xml = []
|
||||
for i in range(cut_start, len(paras)):
|
||||
cut_paras_xml.append(etree.tostring(paras[i], encoding='unicode'))
|
||||
|
||||
for i in range(len(paras) - 1, cut_start - 1, -1):
|
||||
body.remove(paras[i])
|
||||
|
||||
cut_rids = set()
|
||||
for p_info in analysis['paragraphs'][cut_start:]:
|
||||
cut_rids.update(p_info['img_rids'])
|
||||
|
||||
rels_xml = all_files.get('word/_rels/document.xml.rels', b'')
|
||||
if isinstance(rels_xml, bytes):
|
||||
rels_xml = rels_xml.decode()
|
||||
rid_to_media = {}
|
||||
for m in re.finditer(r'Id="(rId\d+)"[^/]*Target="(media/[^"]+)"', rels_xml):
|
||||
rid_to_media[m.group(1)] = f'word/{m.group(2)}'
|
||||
|
||||
cut_media = {}
|
||||
for rid in cut_rids:
|
||||
media_path = rid_to_media.get(rid)
|
||||
if media_path and media_path in all_files:
|
||||
cut_media[media_path] = len(all_files[media_path])
|
||||
|
||||
stripped_path = os.path.join(output_dir, f'{basename}_stripped.docx')
|
||||
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
|
||||
with zipfile.ZipFile(stripped_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for name, data in all_files.items():
|
||||
zout.writestr(name, data)
|
||||
|
||||
cutdata = {
|
||||
'original_file': os.path.basename(docx_path),
|
||||
'cut_start_idx': cut_start,
|
||||
'total_paras_original': len(paras) + len(cut_paras_xml),
|
||||
'cut_paragraphs_xml': cut_paras_xml,
|
||||
'cut_headings': cut_info['cut_headings'],
|
||||
'cut_media_files': list(cut_media.keys()),
|
||||
'reason': cut_info['reason'],
|
||||
}
|
||||
|
||||
cutdata_path = os.path.join(output_dir, f'{basename}_cutdata.json')
|
||||
with open(cutdata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cutdata, f, ensure_ascii=False, indent=2)
|
||||
|
||||
stripped_size = os.path.getsize(stripped_path)
|
||||
original_size = os.path.getsize(docx_path)
|
||||
|
||||
print(f"\n输出:")
|
||||
print(f" stripped: {stripped_path} ({stripped_size:,} bytes)")
|
||||
print(f" cutdata: {cutdata_path}")
|
||||
print(f" 大小变化: {original_size:,} → {stripped_size:,} bytes ({stripped_size/original_size:.0%})")
|
||||
|
||||
return {
|
||||
'action': 'cut',
|
||||
'stripped_path': stripped_path,
|
||||
'cutdata_path': cutdata_path,
|
||||
'cut_info': cut_info,
|
||||
'analysis': analysis,
|
||||
}
|
||||
|
||||
|
||||
def restore_contract(reviewed_path, cutdata_path, output_path):
|
||||
"""Restore cut content back into the reviewed file."""
|
||||
with open(cutdata_path, 'r', encoding='utf-8') as f:
|
||||
cutdata = json.load(f)
|
||||
|
||||
with zipfile.ZipFile(reviewed_path) as z:
|
||||
doc = etree.fromstring(z.read('word/document.xml'))
|
||||
all_files = {}
|
||||
for name in z.namelist():
|
||||
all_files[name] = z.read(name)
|
||||
|
||||
body = doc.find(f'{{{W}}}body')
|
||||
sect_pr = body.find(f'{{{W}}}sectPr')
|
||||
|
||||
for para_xml in cutdata['cut_paragraphs_xml']:
|
||||
para_elem = etree.fromstring(para_xml)
|
||||
if sect_pr is not None:
|
||||
sect_pr.addprevious(para_elem)
|
||||
else:
|
||||
body.append(para_elem)
|
||||
|
||||
original_dir = os.path.dirname(cutdata_path)
|
||||
original_name = cutdata['original_file']
|
||||
original_path = os.path.join(original_dir, original_name)
|
||||
|
||||
if os.path.exists(original_path):
|
||||
with zipfile.ZipFile(original_path) as z_orig:
|
||||
for media_file in cutdata.get('cut_media_files', []):
|
||||
if media_file not in all_files and media_file in z_orig.namelist():
|
||||
all_files[media_file] = z_orig.read(media_file)
|
||||
print(f" 还原媒体文件: {media_file}")
|
||||
|
||||
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for name, data in all_files.items():
|
||||
zout.writestr(name, data)
|
||||
|
||||
restored_size = os.path.getsize(output_path)
|
||||
print(f"\n=== 合同还原完成 ===")
|
||||
print(f"还原文件: {output_path} ({restored_size:,} bytes)")
|
||||
print(f"还原段落: {len(cutdata['cut_paragraphs_xml'])} 个")
|
||||
print(f"还原附件: {', '.join(cutdata['cut_headings'])}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage:")
|
||||
print(" 预处理: python contract_preprocess.py preprocess <input.docx> [output_dir]")
|
||||
print(" 还原: python contract_preprocess.py restore <reviewed.docx> <cutdata.json> <output.docx>")
|
||||
sys.exit(1)
|
||||
|
||||
action = sys.argv[1]
|
||||
|
||||
if action == 'preprocess':
|
||||
docx_path = sys.argv[2]
|
||||
output_dir = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
result = preprocess_contract(docx_path, output_dir)
|
||||
print(f"\nResult: {json.dumps({k: v for k, v in result.items() if k != 'analysis'}, ensure_ascii=False, indent=2)}")
|
||||
|
||||
elif action == 'restore':
|
||||
reviewed_path = sys.argv[2]
|
||||
cutdata_path = sys.argv[3]
|
||||
output_path = sys.argv[4]
|
||||
restore_contract(reviewed_path, cutdata_path, output_path)
|
||||
|
||||
else:
|
||||
print(f"Unknown action: {action}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""编号链诊断探针 — 一次性看清 docx 的自动编号/手动编号全貌。
|
||||
|
||||
用途:合同编号疑似错乱(重复/跳号/双号)时,动手改之前必跑此脚本。
|
||||
它把三件事一次性摊开,让你判断「是我们WB改错的 / 他人修订重排的 / 还是源文件自带的潜伏自动编号」:
|
||||
1. 每个段落:是否带 <w:numPr>(自动编号)、numId、ilvl、是否整段ins/del
|
||||
2. numbering.xml 解析:numId→abstractNum→(numFmt, lvlText, start) ——
|
||||
⚠️ start≠1 的 decimal 列表会渲染出「6、」之类的可见编号,但 run 里没有这个字!
|
||||
这是最隐蔽的坑:源文件起草人给某段挂了 numId(start=6),OnlyOffice 自动显示「6、售后服务」,
|
||||
而你在末尾新增条款时只数了手打的「1 2 3 4 5」,顺手编成「6」→ 与潜伏的自动6撞号。
|
||||
3. 每段「接受所有修订后」的可见文本(去w:del、保w:ins),近似 OnlyOffice 接受后视图
|
||||
|
||||
用法: python numbering-diagnose.py <contract.docx>
|
||||
.doc 先转换: soffice --headless --convert-to docx <file>.doc
|
||||
"""
|
||||
import sys, zipfile
|
||||
from lxml import etree
|
||||
|
||||
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
|
||||
def text_mode(p, mode):
|
||||
"""mode='final': 接受所有修订后(去del,保ins). mode='orig': 修订前(去ins,保del)."""
|
||||
parts = []
|
||||
for node in p.iter():
|
||||
if node.tag == W + 't':
|
||||
anc, skip = node, False
|
||||
while anc is not None:
|
||||
if mode == 'final' and anc.tag == W + 'del':
|
||||
skip = True; break
|
||||
if mode == 'orig' and anc.tag == W + 'ins':
|
||||
skip = True; break
|
||||
anc = anc.getparent()
|
||||
if not skip:
|
||||
parts.append(node.text or '')
|
||||
elif node.tag == W + 'delText' and mode == 'orig':
|
||||
parts.append(node.text or '')
|
||||
return ''.join(parts).strip()
|
||||
|
||||
def parse_numbering(z):
|
||||
"""返回 numId -> (numFmt, lvlText, start) 仅 lvl0(够用于条款标题层)."""
|
||||
out = {}
|
||||
if 'word/numbering.xml' not in z.namelist():
|
||||
return out
|
||||
num = etree.fromstring(z.read('word/numbering.xml'))
|
||||
n2a = {}
|
||||
for n in num.findall(W + 'num'):
|
||||
ab = n.find(W + 'abstractNumId')
|
||||
if ab is not None:
|
||||
n2a[n.get(W + 'numId')] = ab.get(W + 'val')
|
||||
a2fmt = {}
|
||||
for ab in num.findall(W + 'abstractNum'):
|
||||
l0 = ab.find(W + 'lvl')
|
||||
if l0 is not None:
|
||||
fmt = l0.find(W + 'numFmt')
|
||||
txt = l0.find(W + 'lvlText')
|
||||
st = l0.find(W + 'start')
|
||||
a2fmt[ab.get(W + 'abstractNumId')] = (
|
||||
fmt.get(W + 'val') if fmt is not None else '?',
|
||||
txt.get(W + 'val') if txt is not None else '',
|
||||
st.get(W + 'val') if st is not None else '1',
|
||||
)
|
||||
for nid, aid in n2a.items():
|
||||
out[nid] = a2fmt.get(aid, ('?', '', '1'))
|
||||
return out
|
||||
|
||||
def main(path):
|
||||
z = zipfile.ZipFile(path)
|
||||
root = etree.fromstring(z.read('word/document.xml'))
|
||||
numinfo = parse_numbering(z)
|
||||
|
||||
print(f"### {path}\n")
|
||||
print("=== numbering.xml: numId -> (numFmt, lvlText, start) ===")
|
||||
if not numinfo:
|
||||
print(" (无 numbering.xml — 全文应为手动文本编号)")
|
||||
for nid, (fmt, txt, st) in sorted(numinfo.items()):
|
||||
warn = ' ⚠️start≠1 会渲染潜伏编号!' if (fmt == 'decimal' and st != '1') else ''
|
||||
print(f" numId={nid}: fmt={fmt}, lvlText='{txt}', start={st}{warn}")
|
||||
print()
|
||||
print("idx | numPr(自动) | rendered | ins/del | 文本(接受修订后)")
|
||||
print("-" * 92)
|
||||
for i, p in enumerate(root.findall('.//' + W + 'p')):
|
||||
tf = text_mode(p, 'final')
|
||||
if not tf:
|
||||
continue
|
||||
npr = p.find('.//' + W + 'numPr')
|
||||
npinfo, rendered = '—', ''
|
||||
if npr is not None:
|
||||
nid_el = npr.find(W + 'numId')
|
||||
il_el = npr.find(W + 'ilvl')
|
||||
nid = nid_el.get(W + 'val') if nid_el is not None else '?'
|
||||
il = il_el.get(W + 'val') if il_el is not None else '0'
|
||||
npinfo = f"numId={nid},lvl={il}"
|
||||
fmt, txt, st = numinfo.get(nid, ('?', '', '1'))
|
||||
if fmt == 'decimal':
|
||||
rendered = (txt or '%1、').replace('%1', st) # 该项首个渲染值(近似)
|
||||
elif fmt == 'bullet':
|
||||
rendered = '•'
|
||||
elif fmt == 'none':
|
||||
rendered = '(无)'
|
||||
has_ins = p.find('.//' + W + 'ins') is not None
|
||||
has_del = p.find('.//' + W + 'del') is not None
|
||||
mk = ('INS' if has_ins else '') + ('/' if has_ins and has_del else '') + ('DEL' if has_del else '')
|
||||
print(f"{i:3d} | {npinfo:18s} | {rendered:8s} | {mk:7s} | {tf[:46]}")
|
||||
print()
|
||||
print("判读要点:")
|
||||
print(" - rendered 列非空 = OnlyOffice 会自动加这个编号(run里没有这串字)")
|
||||
print(" - 手动编号: rendered='—' 且文本以「N、」开头 = 编号是写死的文字")
|
||||
print(" - 若末尾新增条款(INS)的手打编号 与 上方某段 rendered 自动编号 相同 → 撞号")
|
||||
print(" 正确做法: 新增手打编号应接续【rendered 自动值】往下编, 不是接续最后一个手打数字")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__); sys.exit(1)
|
||||
main(sys.argv[1])
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# OnlyOffice x2t 渲染 docx → PDF
|
||||
# 用途:用Maggie/Doro实际使用的渲染引擎(OnlyOffice)把合同docx渲染成PDF,
|
||||
# 核对编号/格式的真实显示效果(与LibreOffice/python模拟可能不同,核对一律以此为准)。
|
||||
# 用法: ./onlyoffice-render.sh /path/to/合同.docx [输出PDF路径]
|
||||
# 不给输出路径时,默认输出到 同目录/同名.pdf
|
||||
# 依赖: OnlyOffice容器 nextcloud-onlyoffice-1 在运行;x2t在容器内
|
||||
# /var/www/onlyoffice/documentserver/server/FileConverter/bin/x2t
|
||||
# 之后用: pdftotext -layout out.pdf - | grep -nE "^\s*[0-9]+、" 逐条数编号链
|
||||
# pdftoppm -png -r 140 -f 1 -l 1 out.pdf prefix 转图发给Maggie确认
|
||||
|
||||
set -e
|
||||
SRC="$1"
|
||||
[ -z "$SRC" ] && { echo "用法: $0 <docx路径> [输出PDF]"; exit 1; }
|
||||
OUT="${2:-${SRC%.docx}.pdf}"
|
||||
CONTAINER=nextcloud-onlyoffice-1
|
||||
TS=$(date +%s%N)
|
||||
INNAME="/tmp/render_${TS}.docx"
|
||||
OUTNAME="/tmp/render_${TS}.pdf"
|
||||
CONVXML="/tmp/conv_${TS}.xml"
|
||||
|
||||
docker cp "$SRC" "${CONTAINER}:${INNAME}"
|
||||
docker exec "$CONTAINER" bash -c "cat > ${CONVXML} << 'EOF'
|
||||
<?xml version=\"1.0\" encoding=\"utf-8\"?>
|
||||
<TaskQueueDataConvert xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">
|
||||
<m_sFileFrom>${INNAME}</m_sFileFrom>
|
||||
<m_sFileTo>${OUTNAME}</m_sFileTo>
|
||||
<m_bIsNoBase64>true</m_bIsNoBase64>
|
||||
</TaskQueueDataConvert>
|
||||
EOF
|
||||
cd /var/www/onlyoffice/documentserver/server/FileConverter/bin && ./x2t ${CONVXML} > /dev/null 2>&1 && echo x2t_done"
|
||||
docker cp "${CONTAINER}:${OUTNAME}" "$OUT"
|
||||
docker exec "$CONTAINER" rm -f "$INNAME" "$OUTNAME" "$CONVXML" 2>/dev/null || true
|
||||
echo "渲染完成: $OUT"
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post-save sweep: strip explicit attributes from WB INS runs when
|
||||
the same-paragraph original runs rely on inheritance (ea=None, hint=None, sz=None).
|
||||
|
||||
Usage: python3 strip-inherited-ins-attrs.py <docx_path>
|
||||
|
||||
Modifies the file in place. Run AFTER ContractEditor.save() and BEFORE
|
||||
wb-ins-font-verify.py to fix the known "ContractEditor默认sz=21与docDefaults继承冲突".
|
||||
|
||||
The pattern: for each paragraph containing WB INS, find the first plain w:r
|
||||
(non-INS, non-DEL) as reference. If that reference run has no explicit
|
||||
eastAsia/hint/sz, strip those from all WB INS runs in the same paragraph.
|
||||
"""
|
||||
import sys
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
from lxml import etree
|
||||
|
||||
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
|
||||
|
||||
def strip_inherited_attrs(filepath):
|
||||
with zipfile.ZipFile(filepath, 'r') as z:
|
||||
doc_xml = z.read('word/document.xml')
|
||||
all_files = {n: z.read(n) for n in z.namelist()}
|
||||
|
||||
tree = etree.fromstring(doc_xml)
|
||||
body = tree.find(f'{WNS}body')
|
||||
paras = body.findall(f'{WNS}p')
|
||||
|
||||
fixed = 0
|
||||
for p in paras:
|
||||
# Find first plain run as reference
|
||||
orig_run = None
|
||||
for child in p:
|
||||
if child.tag == f'{WNS}r':
|
||||
orig_run = child
|
||||
break
|
||||
if orig_run is None:
|
||||
continue
|
||||
|
||||
orig_rpr = orig_run.find(f'{WNS}rPr')
|
||||
orig_rf = orig_rpr.find(f'{WNS}rFonts') if orig_rpr is not None else None
|
||||
orig_sz = orig_rpr.find(f'{WNS}sz') if orig_rpr is not None else None
|
||||
orig_ea = orig_rf.get(f'{WNS}eastAsia') if orig_rf is not None else None
|
||||
orig_hint = orig_rf.get(f'{WNS}hint') if orig_rf is not None else None
|
||||
orig_sz_val = orig_sz.get(f'{WNS}val') if orig_sz is not None else None
|
||||
|
||||
for ins in p.findall(f'.//{WNS}ins'):
|
||||
if ins.get(f'{WNS}author') != 'WB':
|
||||
continue
|
||||
for r in ins.findall(f'{WNS}r'):
|
||||
rpr = r.find(f'{WNS}rPr')
|
||||
if rpr is None:
|
||||
continue
|
||||
rf = rpr.find(f'{WNS}rFonts')
|
||||
sz = rpr.find(f'{WNS}sz')
|
||||
|
||||
if orig_ea is None and rf is not None:
|
||||
for attr in ['eastAsia', 'ascii', 'hAnsi']:
|
||||
key = f'{WNS}{attr}'
|
||||
if key in rf.attrib:
|
||||
if orig_rf is None or orig_rf.get(key) is None:
|
||||
del rf.attrib[key]
|
||||
fixed += 1
|
||||
|
||||
if orig_hint is None and rf is not None and f'{WNS}hint' in rf.attrib:
|
||||
del rf.attrib[f'{WNS}hint']
|
||||
fixed += 1
|
||||
|
||||
if orig_sz_val is None and sz is not None:
|
||||
rpr.remove(sz)
|
||||
fixed += 1
|
||||
|
||||
# Save
|
||||
tmp = tempfile.mktemp(suffix='.docx')
|
||||
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for name in all_files:
|
||||
if name == 'word/document.xml':
|
||||
new_xml = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
new_str = new_xml.decode('utf-8')
|
||||
new_str = new_str.replace(
|
||||
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
|
||||
new_str = new_str.replace('\n', '\r\n')
|
||||
zout.writestr(name, new_str.encode('utf-8'))
|
||||
else:
|
||||
zout.writestr(name, all_files[name])
|
||||
shutil.move(tmp, filepath)
|
||||
return fixed
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <docx_path>")
|
||||
sys.exit(1)
|
||||
n = strip_inherited_attrs(sys.argv[1])
|
||||
print(f"Fixed {n} inherited attribute issues in {sys.argv[1]}")
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unify all tracked change authors in a docx to 'WB'.
|
||||
|
||||
Usage: python unify-author-wb.py <input.docx> [output.docx]
|
||||
If output is omitted, overwrites input.
|
||||
|
||||
Covers: w:ins, w:del, rPrChange, pPrChange, sectPrChange,
|
||||
tblPrChange, trPrChange, tcPrChange.
|
||||
Also fixes XML declaration (single→double quotes) for OnlyOffice compatibility.
|
||||
"""
|
||||
import sys, os, zipfile, re
|
||||
from lxml import etree
|
||||
|
||||
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
|
||||
CHANGE_TAGS = ('ins', 'del', 'rPrChange', 'pPrChange',
|
||||
'sectPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange')
|
||||
|
||||
def unify_author(src_path, out_path=None):
|
||||
if out_path is None:
|
||||
out_path = src_path
|
||||
tmp_path = out_path + '.tmp'
|
||||
|
||||
zin = zipfile.ZipFile(src_path, 'r')
|
||||
doc_xml = zin.read('word/document.xml')
|
||||
tree = etree.fromstring(doc_xml)
|
||||
body = tree.find(f'{WNS}body')
|
||||
|
||||
changed = 0
|
||||
for tag_suffix in CHANGE_TAGS:
|
||||
for elem in body.iter(f'{WNS}{tag_suffix}'):
|
||||
author = elem.get(f'{WNS}author')
|
||||
if author and author != 'WB':
|
||||
elem.set(f'{WNS}author', 'WB')
|
||||
changed += 1
|
||||
|
||||
# Serialize + fix XML declaration
|
||||
doc_bytes = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||
doc_str = doc_bytes.decode('utf-8')
|
||||
doc_str = doc_str.replace(
|
||||
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
|
||||
|
||||
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.namelist():
|
||||
if item == 'word/document.xml':
|
||||
zout.writestr(item, doc_str.encode('utf-8'))
|
||||
else:
|
||||
zout.writestr(item, zin.read(item))
|
||||
zin.close()
|
||||
os.replace(tmp_path, out_path)
|
||||
|
||||
# Verify
|
||||
z = zipfile.ZipFile(out_path)
|
||||
vdoc = z.read('word/document.xml')
|
||||
vtree = etree.fromstring(vdoc)
|
||||
vbody = vtree.find(f'{WNS}body')
|
||||
remaining = set()
|
||||
for tag_suffix in CHANGE_TAGS:
|
||||
for elem in vbody.iter(f'{WNS}{tag_suffix}'):
|
||||
a = elem.get(f'{WNS}author', '')
|
||||
if a != 'WB':
|
||||
remaining.add(a)
|
||||
z.close()
|
||||
|
||||
print(f"✅ {changed} author attributes → WB")
|
||||
if remaining:
|
||||
print(f"⚠️ Remaining non-WB authors: {remaining}")
|
||||
else:
|
||||
print(f" All authors = WB")
|
||||
print(f" Output: {out_path} ({os.path.getsize(out_path):,} bytes)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
src = sys.argv[1]
|
||||
out = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
unify_author(src, out)
|
||||
Reference in New Issue
Block a user