484 lines
18 KiB
Python
484 lines
18 KiB
Python
# -*- 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 _expand_loops_in_zip(template_path, loops):
|
||
"""Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML.
|
||
Работает до загрузки python-docx — гарантирует корректную вложенность."""
|
||
if not loops:
|
||
return template_path
|
||
|
||
# Временная копия
|
||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx')
|
||
tmp.close()
|
||
shutil.copy2(template_path, tmp.name)
|
||
|
||
max_passes = 30
|
||
for pass_num in range(max_passes):
|
||
# Читаем document.xml
|
||
try:
|
||
with zipfile.ZipFile(tmp.name, 'r') as z:
|
||
doc_xml = z.read('word/document.xml').decode('utf-8')
|
||
except Exception:
|
||
break
|
||
|
||
# Находим ВСЕ маркеры циклов в XML
|
||
# Ищем как обычный текст (в DOCX xml текст может быть сырым или экранированным)
|
||
start_markers = list(re.finditer(r'(?:<!--|<!--)\s*loop:(\w+)\s*(?:-->|-->)', doc_xml))
|
||
end_markers = list(re.finditer(r'(?:<!--|<!--)\s*loop_end\s*(?:-->|-->)', doc_xml))
|
||
|
||
if not start_markers:
|
||
break # нет больше циклов
|
||
|
||
# Внешний цикл = первый start (самый ранний в документе)
|
||
outer = start_markers[0]
|
||
outer_key = outer.group(1)
|
||
|
||
# Соответствующий end = последний end после outer
|
||
outer_end = None
|
||
for m in reversed(end_markers):
|
||
if m.start() > outer.start():
|
||
outer_end = m
|
||
break
|
||
|
||
if outer_end is None:
|
||
break
|
||
|
||
items = loops.get(outer_key, [])
|
||
if not items:
|
||
# Удаляем блок цикла
|
||
doc_xml = doc_xml[:outer.start()] + doc_xml[outer_end.end():]
|
||
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
|
||
z.writestr('word/document.xml', doc_xml.encode('utf-8'))
|
||
continue
|
||
|
||
# Извлекаем шаблон (всё между start и end маркерами)
|
||
template = doc_xml[outer.end():outer_end.start()]
|
||
|
||
# Удаляем только маркер start этого цикла (первое вхождение)
|
||
start_tag = doc_xml[outer.start():outer.end()]
|
||
# Создаём шаблон без start маркера
|
||
template_clean = template
|
||
|
||
# Найдём и удалим последний loop_end (принадлежит внешнему циклу)
|
||
# Ищем последний <!-- loop_end --> или <!-- loop_end -->
|
||
last_end_tag = None
|
||
for m in reversed(list(re.finditer(r'(?:<!--|<!--)\s*loop_end\s*(?:-->|-->)', template_clean))):
|
||
last_end_tag = m
|
||
break
|
||
|
||
if last_end_tag:
|
||
template_clean = template_clean[:last_end_tag.start()] + template_clean[last_end_tag.end():]
|
||
|
||
# Размножаем шаблон для каждого элемента данных
|
||
expanded_parts = []
|
||
for idx, item in enumerate(items, 1):
|
||
clone = template_clean
|
||
# Заменяем {{item.*}} только для полей, которые есть в item
|
||
for field, value in item.items():
|
||
if field == 'number':
|
||
continue
|
||
clone = clone.replace('{{item.%s}}' % field, str(value) if value else '\u2014')
|
||
clone = clone.replace('{{item.number}}', str(idx))
|
||
clone = clone.replace('{{number}}', str(idx))
|
||
expanded_parts.append(clone)
|
||
|
||
# Собираем новый XML: удаляем старый блок цикла + вставляем размноженный
|
||
new_xml = doc_xml[:outer.start()] + ''.join(expanded_parts) + doc_xml[outer_end.end():]
|
||
|
||
# Сохраняем
|
||
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
|
||
z.writestr('word/document.xml', new_xml.encode('utf-8'))
|
||
|
||
return tmp.name
|
||
|
||
|
||
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}"
|
||
|
||
# Сначала обрабатываем циклы на уровне ZIP/XML (до загрузки python-docx)
|
||
loops = replacements.pop('_loops', {})
|
||
if loops:
|
||
template_path = _expand_loops_in_zip(template_path, loops)
|
||
# Возвращаем _loops обратно, т.к. дальше он может еще пригодиться
|
||
replacements['_loops'] = loops
|
||
|
||
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):
|
||
"""Заменяет <OldVar> на {{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'(?:<!--|<!--)?\s*loop:(\w+)(?:\s*(?:-->|-->))?')
|
||
LOOP_END = re.compile(r'(?:<!--|<!--)?\s*loop_end(?:\s*(?:-->|-->))?')
|
||
|
||
|
||
|
||
|
||
def _merge_runs_for_loops(doc):
|
||
"""Склеивает текст в runs, чтобы <!-- loop:x --> не был разбит на части."""
|
||
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 (removed) = (r'<!--\s*loop:(\w+)\s*-->')
|
||
# LOOP_END_FINDER (removed) = (r'<!--\s*loop_end\s*-->')
|
||
|
||
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'<!-- loop:{key} -->', '', 1) # только первый
|
||
|
||
# Найдём и удалим ТОЛЬКО последний loop_end
|
||
last_end = template.rfind('<!-- loop_end -->')
|
||
if last_end >= 0:
|
||
template = template[:last_end] + template[last_end + len('<!-- loop_end -->'):]
|
||
|
||
# Данные для цикла берём из 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 _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)
|