fix: оригинальные циклы из main152fz.py + фикс вложенности

This commit is contained in:
2026-07-30 17:51:46 +04:00
commit 2e98751d91
15 changed files with 3429 additions and 0 deletions
+544
View File
@@ -0,0 +1,544 @@
# -*- 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)