feat: export core Hermes skills
This commit is contained in:
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