Files
dokogen152/dokogen/generator.py
T

545 lines
21 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 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):
"""Заменяет <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 = re.compile(r'<!--\s*loop:(\w+)\s*-->')
LOOP_END_FINDER = re.compile(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 _process_loops(doc, loops):
# loops passed directly
LOOP_START_LOOSE = re.compile(r'(?:&lt;!--|<!--)?\s*loop:([\w]+)(?:\s*(?:--&gt;|-->))?')
LOOP_END_LOOSE = re.compile(r'(?:&lt;!--|<!--)?\s*loop_end(?:\s*(?:--&gt;|-->))?')
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'(?:&lt;!--|<!--)?\s*loop:([\w]+)(?:\s*(?:--&gt;|-->))?')
LOOP_END_LOOSE = re.compile(r'(?:&lt;!--|<!--)?\s*loop_end(?:\s*(?:--&gt;|-->))?')
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('<w:p ', 0, start_pos)
if p_s == -1: p_s = body_str.rfind('<w:p>', 0, start_pos)
p_e = body_str.find('</w:p>', start_pos)
if p_s != -1 and p_e != -1:
start_elem_start, start_elem_end, start_type = p_s, p_e + len('</w:p>'), 'p'
if end_type == 'tr':
p_s = body_str.rfind('<w:p ', 0, end_pos)
if p_s == -1: p_s = body_str.rfind('<w:p>', 0, end_pos)
p_e = body_str.find('</w:p>', end_pos)
if p_s != -1 and p_e != -1:
end_elem_start, end_elem_end, end_type = p_s, p_e + len('</w:p>'), '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 '<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
p_close_wrap = '</w:p>'
else:
template_xml = body_str[start_elem_end:end_elem_start]
inner_start_pattern = re.compile(r'(?:&lt;!--|<!--)?\s*' + re.escape('loop:' + key) + r'(?:\s*(?:--&gt;|-->))?')
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('<!--', 0, last_le)
me = template_xml.find('-->', 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⟫', '</w:t><w:br/><w:t xml:space="preserve">')
if is_inline:
clone = p_open_wrap + '<w:r><w:t xml:space="preserve">' + clone + '</w:t></w:r>' + 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'(?:&lt;!--|<!--)?\s*loop:([\w]+)(?:\s*(?:--&gt;|-->))?')
LOOP_END_LOOSE = re.compile(r'(?:&lt;!--|<!--)?\s*loop_end(?:\s*(?:--&gt;|-->))?')
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('<w:tr ', 0, pos)
if tr_open == -1:
tr_open = xml_str.rfind('<w:tr>', 0, pos)
if tr_open != -1:
tr_close = xml_str.find('</w:tr>', pos)
if tr_close != -1 and tr_close > pos:
return (tr_open, tr_close + len('</w:tr>'), 'tr')
p_open = xml_str.rfind('<w:p ', 0, pos)
p_open2 = xml_str.rfind('<w:p>', 0, pos)
if p_open2 > p_open:
p_open = p_open2
if p_open != -1:
p_close = xml_str.find('</w:p>', pos)
if p_close != -1 and p_close > pos:
return (p_open, p_close + len('</w:p>'), '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)
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)