Files
dokogen152/dokogen/generator.py
T

484 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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'(?:&lt;!--|<!--)\s*loop:(\w+)\s*(?:--&gt;|-->)', doc_xml))
end_markers = list(re.finditer(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)', 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 --> или &lt;!-- loop_end --&gt;
last_end_tag = None
for m in reversed(list(re.finditer(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)', 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'(?:&lt;!--|<!--)?\s*loop:(\w+)(?:\s*(?:--&gt;|-->))?')
LOOP_END = re.compile(r'(?:&lt;!--|<!--)?\s*loop_end(?:\s*(?:--&gt;|-->))?')
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)