685 lines
28 KiB
Python
685 lines
28 KiB
Python
#!/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
|