#!/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
