# -*- 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 _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_START_RE = re.compile(r'') LOOP_END_RE = re.compile(r'') def _process_loops(doc, loops: Dict[str, List[Dict]]): """Обработка циклов через python-docx API (работает с вложенными циклами).""" max_passes = 20 for _ in range(max_passes): # Находим все маркеры циклов markers = {} # idx -> (type, key) for i, para in enumerate(doc.paragraphs): text = para.text.strip() m = LOOP_START_RE.search(text) if m: markers[i] = ('start', m.group(1)) elif LOOP_END_RE.search(text): markers[i] = ('end', None) if not markers: break # Сортируем по позиции sorted_positions = sorted(markers.keys()) # Находим внешний цикл (самый первый start) outer_start = None outer_key = None for pos in sorted_positions: t, k = markers[pos] if t == 'start': outer_start = pos outer_key = k break if outer_start is None: break # Находим соответствующий end (последний end после outer_start) outer_end = None for pos in reversed(sorted_positions): t, k = markers[pos] if t == 'end' and pos > outer_start: outer_end = pos break if outer_end is None: break items = loops.get(outer_key, []) if not items: _remove_paras_range(doc, outer_start, outer_end) continue # Параграфы-шаблон (между start и end) template_indices = list(range(outer_start + 1, outer_end)) if not template_indices: continue # Собираем XML шаблона для клонирования body = doc.element.body from lxml import etree as _etree # Определяем, в какой элемент вставлять ref_element = doc.paragraphs[outer_end]._element # Для каждого экземпляра данных for item_idx, item in enumerate(items, 1): for tpl_idx in template_indices: src_para = doc.paragraphs[tpl_idx] # Клонируем XML-элемент параграфа src_xml = _etree.tostring(src_para._element) new_elem = _etree.fromstring(src_xml) # Заменяем поля в тексте for field, value in item.items(): search = '{{item.%s}}' % field replace = str(value) if value else EMPTY_MARKER _replace_in_xml(new_elem, search, replace) _replace_in_xml(new_elem, '{{item.number}}', str(item_idx)) _replace_in_xml(new_elem, '{{number}}', str(item_idx)) # Вставляем после ref_element ref_element.addnext(new_elem) ref_element = new_elem # Удаляем старый блок цикла _remove_paras_range(doc, outer_start, outer_end) 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) def _replace_in_xml(element, old, new): """Рекурсивно заменяет текст в XML-элементе (внутри ).""" ns = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}' if element.tag == ns + 't' or element.tag == 'w:t': if old in (element.text or ''): element.text = element.text.replace(old, new) for child in element: _replace_in_xml(child, old, new) 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)