feat: export core Hermes skills

This commit is contained in:
2026-07-15 02:45:56 +00:00
parent a028b63eda
commit 54711fee2a
308 changed files with 41310 additions and 1 deletions
@@ -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)