# -*- coding: utf-8 -*- """DokoGen — Генератор документов из шаблонов DOCX""" import os import re import io from datetime import datetime from typing import Dict, List, Optional, Callable try: from docx import Document from docx.oxml.ns import qn from docx.oxml import OxmlElement from lxml import etree except ImportError: Document = None from .variables import OLD_VAR_MAP, OLD_VAR_PATTERN, VAR_PATTERN # Стандартный шаблон для незаполненных переменных EMPTY_MARKER = '—' def process_template( template_path: str, replacements: Dict[str, str], output_path: Optional[str] = None, log_func: Optional[Callable] = None, ) -> str: """Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение.""" if Document is None: raise ImportError("python-docx не установлен. Установите: pip install python-docx") if not os.path.exists(template_path): raise FileNotFoundError(f"Шаблон не найден: {template_path}") if output_path is None: base, ext = os.path.splitext(template_path) output_path = f"{base}_result{ext}" doc = Document(template_path) # 1. Миграция старых переменных <...> → {{...}} _migrate_old_vars(doc) # 2. Удаление proofErr _strip_prooferr(doc) # 3. Обработка циклов (loop/loop_end) loops = replacements.pop('_loops', {}) if loops: _merge_runs_for_loops(doc) _process_loops(doc, loops) # 4. Перезагрузка после циклов buf = io.BytesIO() doc.save(buf) buf.seek(0) doc = Document(buf) # 5. Плоская замена переменных (3 прохода) for _ in range(3): _replace_in_paragraphs(doc.paragraphs, replacements) _replace_in_tables(doc.tables, replacements) _replace_in_headers_footers(doc, replacements) # 6. Логирование незаменённых переменных _log_unmatched(doc, replacements, log_func if log_func else lambda x: None) # 7. Подсветка незаполненных _highlight_unmatched(doc) # 8. Сохранение doc.save(output_path) if log_func: log_func(f"✅ {os.path.basename(output_path)}") return output_path def _log_unmatched(doc, replacements=None, log_func=None): """Логирует незаменённые переменные в документе.""" import re unmatched = [] seen = set() for para in doc.paragraphs: for m in re.finditer(r'\{\{[^}]+\}\}|<[A-Za-z0-9_]+>', para.text): var = m.group(0) if var not in seen: seen.add(var) unmatched.append(var) if log_func and unmatched: log_func(f"\u26a0\ufe0f Незаменённые переменные ({len(unmatched)}): {', '.join(unmatched[:20])}{' ...' if len(unmatched) > 20 else ''}") if log_func and not unmatched: log_func("\u2705 Все переменные успешно заменены") return len(unmatched) == 0 # ==================== МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ ==================== def _migrate_old_vars(doc): """Заменяет на {{new_var}} в документе.""" def _migrate_text(text): return OLD_VAR_PATTERN.sub(lambda m: OLD_VAR_MAP.get(m.group(0), m.group(0)), text) for para in doc.paragraphs: new_text = _migrate_text(para.text) if new_text != para.text: if para.runs: first = para.runs[0] para.clear() run = para.add_run(new_text) _copy_run_style(first, run) else: para.clear() para.add_run(new_text) for table in doc.tables: for row in table.rows: for cell in row.cells: for para in cell.paragraphs: new_text = _migrate_text(para.text) if new_text != para.text: if para.runs: first = para.runs[0] para.clear() run = para.add_run(new_text) _copy_run_style(first, run) else: para.clear() para.add_run(new_text) for section in doc.sections: for hf in [section.header, section.footer, section.first_page_header, section.first_page_footer]: if hf: for para in hf.paragraphs: new_text = _migrate_text(para.text) if new_text != para.text: if para.runs: first = para.runs[0] para.clear() run = para.add_run(new_text) _copy_run_style(first, run) else: para.clear() para.add_run(new_text) # ==================== ЗАМЕНА В ЭЛЕМЕНТАХ ==================== def _replace_in_paragraphs(paragraphs, replacements): for para in paragraphs: full = para.text # Пропускаем строки циклов if 'loop:' in full or 'loop_end' in full: continue matches = VAR_PATTERN.findall(full) if not matches: continue new_text = full for var in matches: val = replacements.get(var) if val is not None: new_text = new_text.replace(var, str(val)) if new_text != full: if para.runs: first = para.runs[0] para.clear() run = para.add_run(new_text) _copy_run_style(first, run) else: para.clear() para.add_run(new_text) def _replace_in_tables(tables, replacements): for table in tables: for row in table.rows: for cell in row.cells: for para in cell.paragraphs: full = para.text matches = VAR_PATTERN.findall(full) if not matches: continue new_text = full for var in matches: val = replacements.get(var) if val is not None: new_text = new_text.replace(var, str(val)) if new_text != full: if para.runs: first = para.runs[0] para.clear() run = para.add_run(new_text) _copy_run_style(first, run) else: para.clear() para.add_run(new_text) def _replace_in_headers_footers(doc, replacements): for section in doc.sections: for hf in [section.header, section.footer, section.first_page_header, section.first_page_footer]: if hf: _replace_in_paragraphs(hf.paragraphs, replacements) def _copy_run_style(source, target): """Копирует форматирование из одного run в другой.""" try: target.bold = source.bold target.italic = source.italic target.underline = source.underline if source.font: target.font.size = source.font.size target.font.name = source.font.name except Exception: pass def _strip_prooferr(doc): ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' for elem in doc.element.iter(): for child in list(elem): if child.tag == f'{{{ns}}}proofErr': elem.remove(child) # ==================== ОБРАБОТКА ЦИКЛОВ ==================== LOOP_START = re.compile(r'(?:<!--|))?') LOOP_END = re.compile(r'(?:<!--|))?') def _merge_runs_for_loops(doc): """Склеивает текст в runs, чтобы не был разбит на части.""" ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' for para in doc.paragraphs: full = para.text if 'loop:' not in full and 'loop_end' not in full: continue # В этом параграфе есть маркер цикла — склеиваем все runs в один if len(para.runs) <= 1: continue # Проверяем, не разбит ли маркер for run in para.runs: if 'loop:' in run.text or 'loop_end' in run.text: # Маркер есть, но возможно разбит — склеиваем combined = para.text para.clear() para.add_run(combined) break LOOP_FINDER = re.compile(r'') LOOP_END_FINDER = re.compile(r'') def _expand_loops_in_template(template_path): """Обрабатывает циклы на уровне XML документа до загрузки python-docx. Работает напрямую с word/document.xml в ZIP-архиве.""" import tempfile # Временная копия tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx') tmp.close() shutil.copy2(template_path, tmp.name) # Читаем document.xml import zipfile as zf with zf.ZipFile(tmp.name, 'r') as zin: doc_xml = zin.read('word/document.xml').decode('utf-8') max_passes = 20 for _ in range(max_passes): # Ищем все маркеры циклов starts = list(LOOP_FINDER.finditer(doc_xml)) ends = list(LOOP_END_FINDER.finditer(doc_xml)) if not starts: break # Внешний цикл = первый start outer = starts[0] key = outer.group(1) # Соответствующий end = последний end, который после outer outer_end = None for e in reversed(ends): if e.start() > outer.start(): outer_end = e break if outer_end is None: break # Извлекаем шаблон (текст между маркерами) template_start = outer.end() template_end = outer_end.start() template = doc_xml[template_start:template_end] # Заменяем в шаблоне маркеры ключа (но не трогаем вложенные loop:) # Удаляем только start и end маркеры этого цикла # loop_end убираем только последний (он принадлежит внешнему циклу) # loop_start этого ключа убираем все вхождения # Найдём и удалим ТОЛЬКО start маркер для этого ключа template = template.replace(f'', '', 1) # только первый # Найдём и удалим ТОЛЬКО последний loop_end last_end = template.rfind('') if last_end >= 0: template = template[:last_end] + template[last_end + len(''):] # Данные для цикла берём из replacements (передаются позже) # Пока просто помечаем позицию для последующей обработки # Вставляем placeholder для замены marker = f'__LOOP_{key}_EXPANDED__' # Размножаем шаблон (1 копия, потом замена в process_template) expanded = '' for i in range(10): # макс 10, реально заменится позже expanded += template # Собираем новый XML new_xml = doc_xml[:outer.start()] + '__LOOP_BLOCK__' + key + '__' + doc_xml[outer_end.end():] doc_xml = new_xml # Сохраняем with zf.ZipFile(tmp.name, 'w', zf.ZIP_DEFLATED) as zout: zout.writestr('word/document.xml', doc_xml.encode('utf-8')) return tmp.name def _process_loops(doc, loops): # loops passed directly LOOP_START_LOOSE = re.compile(r'(?:<!--|))?') LOOP_END_LOOSE = re.compile(r'(?:<!--|))?') body = doc.element.body max_passes = 20 for _ in range(max_passes): body_str = etree.tostring(body, encoding='unicode') found_keys = set() for m in LOOP_START_LOOSE.finditer(body_str): found_keys.add(m.group(1).replace(' ', '')) if not found_keys: break any_change = False for key in sorted(found_keys): items = loops.get(key, []) if not items: _remove_loop_block(body, key) any_change = True else: _expand_loop(body, key, items) any_change = True body = doc.element.body if not any_change: break def _expand_loop(body, key, items): LOOP_START_LOOSE = re.compile(r'(?:<!--|))?') LOOP_END_LOOSE = re.compile(r'(?:<!--|))?') body_str = etree.tostring(body, encoding='unicode') markers = [] start_marker_end = None for m in LOOP_START_LOOSE.finditer(body_str): if m.group(1).replace(' ', '') == key: markers.append((m.start(), True)) if start_marker_end is None: start_marker_end = m.end() for m in LOOP_END_LOOSE.finditer(body_str): markers.append((m.start(), False)) if len(markers) < 2: return markers.sort() start_pos = end_pos = None for pos, is_start in markers: if is_start and start_pos is None: start_pos = pos elif not is_start and start_pos is not None: end_pos = pos break if start_pos is None or end_pos is None: return start_elem_start, start_elem_end, start_type = _find_struct_element(body_str, start_pos) end_elem_start, end_elem_end, end_type = _find_struct_element(body_str, end_pos) if start_type is None or end_type is None: return if start_type != end_type: if start_type == 'tr': p_s = body_str.rfind('', 0, start_pos) p_e = body_str.find('', start_pos) if p_s != -1 and p_e != -1: start_elem_start, start_elem_end, start_type = p_s, p_e + len(''), 'p' if end_type == 'tr': p_s = body_str.rfind('', 0, end_pos) p_e = body_str.find('', end_pos) if p_s != -1 and p_e != -1: end_elem_start, end_elem_end, end_type = p_s, p_e + len(''), 'p' if start_type != end_type: return is_inline = (start_elem_start == end_elem_start) p_open_wrap = p_close_wrap = '' if is_inline and start_marker_end is not None: template_xml = body_str[start_marker_end:end_pos] p_elem_str = body_str[start_elem_start:start_elem_end] gt_pos = p_elem_str.find('>') p_open_wrap = p_elem_str[:gt_pos+1] if 'xmlns' in p_elem_str[:gt_pos] else '' p_close_wrap = '' else: template_xml = body_str[start_elem_end:end_elem_start] inner_start_pattern = re.compile(r'(?:<!--|))?') template_xml = inner_start_pattern.sub('', template_xml) # Удаляем только последний loop_end (наружный цикл) last_le = template_xml.rfind('loop_end') if last_le >= 0: ms = template_xml.rfind('', last_le) + 3 if ms >= 0: template_xml = template_xml[:ms] + template_xml[me:] expanded_parts = [] for idx, item in enumerate(items, 1): clone = template_xml for field, value in item.items(): clone = clone.replace(f'{{{{item.{field}}}}}', str(value) if value else '—') clone = clone.replace(f'{{{{{field}}}}}', str(value) if value else '—') clone = clone.replace('{{item.number}}', str(idx)) clone = clone.replace('{{loop.number}}', str(idx)) clone = clone.replace('{{number}}', str(idx)) clone = clone.replace('⟪BR⟫', '') if is_inline: clone = p_open_wrap + '' + clone + '' + p_close_wrap expanded_parts.append(clone) new_body_str = body_str[:start_elem_start] + ''.join(expanded_parts) + body_str[end_elem_end:] try: new_body = etree.fromstring(new_body_str.encode('utf-8')) parent = body.getparent() if parent is not None: parent.replace(body, new_body) except Exception as e: pass def _remove_loop_block(body, key): LOOP_START_LOOSE = re.compile(r'(?:<!--|))?') LOOP_END_LOOSE = re.compile(r'(?:<!--|))?') body_str = etree.tostring(body, encoding='unicode') start_pos = -1 for m in LOOP_START_LOOSE.finditer(body_str): if m.group(1).replace(' ', '') == key: start_pos = m.start() break if start_pos == -1: return start_elem_start, start_elem_end, _ = _find_struct_element(body_str, start_pos) end_match = LOOP_END_LOOSE.search(body_str, start_pos) if not end_match: new_body_str = body_str[:start_elem_start] + body_str[start_elem_end:] else: end_pos = end_match.start() _, end_elem_end, _ = _find_struct_element(body_str, end_pos) new_body_str = body_str[:start_elem_start] + body_str[end_elem_end:] try: new_body = etree.fromstring(new_body_str.encode('utf-8')) parent = body.getparent() if parent is not None: parent.replace(body, new_body) except Exception: pass def _find_struct_element(xml_str, pos): tr_open = xml_str.rfind('', 0, pos) if tr_open != -1: tr_close = xml_str.find('', pos) if tr_close != -1 and tr_close > pos: return (tr_open, tr_close + len(''), 'tr') p_open = xml_str.rfind('', 0, pos) if p_open2 > p_open: p_open = p_open2 if p_open != -1: p_close = xml_str.find('', pos) if p_close != -1 and p_close > pos: return (p_open, p_close + len(''), 'p') return (pos, pos, None) def _remove_paras_range(doc, start_idx, end_idx): """Удаляет параграфы от start_idx до end_idx включительно.""" for idx in range(end_idx, start_idx - 1, -1): if idx < len(doc.paragraphs): el = doc.paragraphs[idx]._element el.getparent().remove(el) UNMATCHED_PATTERN = re.compile(r"\{\{[^}]+\}\}|<[A-Za-z0-9_]+>") def _highlight_unmatched(doc): """Подсвечивает незаполненные переменные красным ({{...}} и старые <...>).""" ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' def _has_unmatched(text): return bool(UNMATCHED_PATTERN.search(text)) def _highlight_run(run): rpr = run._element.find(f'{{{ns}}}rPr') if rpr is None: rpr = OxmlElement('w:rPr') run._element.insert(0, rpr) color = OxmlElement('w:color') color.set(f'{{{ns}}}val', 'FF0000') rpr.append(color) sz = OxmlElement('w:sz') sz.set(f'{{{ns}}}val', '22') rpr.append(sz) for para in doc.paragraphs: for run in para.runs: if _has_unmatched(run.text): _highlight_run(run) for table in doc.tables: for row in table.rows: for cell in row.cells: for para in cell.paragraphs: for run in para.runs: if _has_unmatched(run.text): _highlight_run(run) for section in doc.sections: for hf in [section.header, section.footer]: if hf: for para in hf.paragraphs: for run in para.runs: if _has_unmatched(run.text): _highlight_run(run)