1) нормализация адреса: «Р-Н Крымский» → «р-н. Крымский», «Г Крымск» → «г. Крымск», «УЛ» → «ул.», «Д.» → «д.» 2) убрана строчка «ПО» на вкладке ИС 3) СЗИ без категорий: «Kaspersky, КриптоПро» (через запятую) 4) кнопка «Выбрать из списка» в подвкладке «Пользователи ИС» 5) бумажные документы: «хранятся в сейфе» (авто-предлог+падеж) 6) незаполненные данные «—» подсвечиваются жёлтым (незаменённые переменные — красным, как было)
752 lines
30 KiB
Python
752 lines
30 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""DokoGen — Генератор документов из шаблонов DOCX (чистая версия)"""
|
||
|
||
import os
|
||
import re
|
||
import io
|
||
import shutil
|
||
import tempfile
|
||
import zipfile
|
||
from typing import Dict, List, Optional, Callable, Any
|
||
from datetime import datetime
|
||
from functools import lru_cache
|
||
|
||
try:
|
||
from docx import Document
|
||
from docx.oxml.ns import qn
|
||
from docx.oxml import OxmlElement
|
||
from lxml import etree
|
||
DOCX_AVAILABLE = True
|
||
except ImportError:
|
||
Document = None
|
||
DOCX_AVAILABLE = False
|
||
|
||
# ============================================================
|
||
# 1. ПЕРЕМЕННЫЕ ШАБЛОНОВ И РЕГЕКСЫ
|
||
# ============================================================
|
||
|
||
# Паттерны для поиска переменных и маркеров циклов
|
||
VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}')
|
||
OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>')
|
||
UNMATCHED_PATTERN = re.compile(r'\{\{[^}]+\}\}|<[A-Za-z0-9_]+>')
|
||
|
||
# «Переменные», которые на самом деле не переменные, а клавиши в тексте
|
||
# инструкций (Ctrl+Alt+Del и т.п.) — их не считаем незаменёнными
|
||
IGNORED_OLD_VARS = {
|
||
'<Ctrl>', '<Alt>', '<Del>', '<Delete>', '<Shift>', '<Enter>', '<Tab>',
|
||
'<Esc>', '<Escape>', '<Insert>', '<Home>', '<End>', '<PageUp>', '<PageDown>',
|
||
'<Backspace>', '<Space>', '<CapsLock>', '<NumLock>', '<ScrollLock>',
|
||
'<PrintScreen>', '<Pause>', '<Break>', '<Up>', '<Down>', '<Left>', '<Right>',
|
||
'<F1>', '<F2>', '<F3>', '<F4>', '<F5>', '<F6>', '<F7>', '<F8>', '<F9>',
|
||
'<F10>', '<F11>', '<F12>',
|
||
}
|
||
|
||
LOOP_START = re.compile(r'(?:<!--|<!--)\s*loop:(\w+)\s*(?:-->|-->)')
|
||
LOOP_END = re.compile(r'(?:<!--|<!--)\s*loop_end\s*(?:-->|-->)')
|
||
|
||
# Маппинг старых переменных <...> → {{...}}
|
||
OLD_VAR_MAP = {
|
||
'<CompanyName1>': '{{company_full_name}}',
|
||
'<CompanyName2>': '{{company_short_name}}',
|
||
'<CompanyName1Gent>': '{{company_full_name_genitive}}',
|
||
'<CompanyName2Gent>': '{{company_short_name_genitive}}',
|
||
'<CompanyName1Datv>': '{{company_full_name_dative}}',
|
||
'<CompanyName2Datv>': '{{company_short_name_dative}}',
|
||
'<CompanyName1Accs>': '{{company_full_name_accs}}',
|
||
'<CompanyName2Accs>': '{{company_short_name_accs}}',
|
||
'<CompanyName1Ablt>': '{{company_full_name_ablt}}',
|
||
'<CompanyName2Ablt>': '{{company_short_name_ablt}}',
|
||
'<CompanyName1Loct>': '{{company_full_name_loct}}',
|
||
'<CompanyName2Loct>': '{{company_short_name_loct}}',
|
||
'<Address>': '{{company_address}}',
|
||
'<INN>': '{{company_inn}}',
|
||
'<OGRN>': '{{company_ogrn}}',
|
||
'<KPP>': '{{company_kpp}}',
|
||
'<ChiefFio>': '{{chief_fio_initials}}',
|
||
'<ChiefFioShort>': '{{chief_fio_short}}',
|
||
'<ChiefPosition>': '{{chief_position}}',
|
||
'<RspFio>': '{{responsible_fio_initials}}',
|
||
'<RspFioShort>': '{{responsible_fio_short}}',
|
||
'<RspPosition>': '{{responsible_position}}',
|
||
'<CommissionList>': '{{commission_list}}',
|
||
'<DocumentNumber>': '{{document_number}}',
|
||
'<DocumentDate>': '{{document_date}}',
|
||
'<Date>': '{{date}}',
|
||
# Краткое наименование по падежам (старые шаблоны)
|
||
'<ShortCompanyName1>': '{{company_short_name}}',
|
||
'<ShortCompanyName2>': '{{company_short_name_genitive}}',
|
||
'<ShortCompanyName3>': '{{company_short_name_dative}}',
|
||
'<ShortCompanyName4>': '{{company_short_name_accs}}',
|
||
'<ShortCompanyName5>': '{{company_short_name_ablt}}',
|
||
'<ShortCompanyName6>': '{{company_short_name_loct}}',
|
||
'<IspdnName>': '{{is_name_1}}',
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# 2. ОБРАБОТКА ЦИКЛОВ НА УРОВНЕ ZIP/XML
|
||
# ============================================================
|
||
|
||
W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||
|
||
|
||
def _normalize_loop_markers(doc_xml: str) -> str:
|
||
"""Word разбивает маркеры <!-- loop:... --> и переменные {{item.x}}
|
||
на несколько run'ов (например '<!-- ' в одном <w:t>, 'loop:info' в другом,
|
||
'rmation -->' в третьем). Склеиваем текст каждого параграфа в один run,
|
||
чтобы маркеры и переменные циклов снова стали едиными."""
|
||
try:
|
||
root = etree.fromstring(doc_xml.encode('utf-8'))
|
||
except Exception:
|
||
return doc_xml
|
||
|
||
changed = False
|
||
for p in root.iter(f'{{{W_NS}}}p'):
|
||
# Собираем весь текст параграфа (по всем run'ам)
|
||
texts = []
|
||
for t in p.iter(f'{{{W_NS}}}t'):
|
||
texts.append(t.text or '')
|
||
full = ''.join(texts)
|
||
|
||
# Интересуют параграфы с маркерами циклов ИЛИ любыми переменными {{...}}
|
||
if '{{' not in full and 'loop:' not in full and 'loop_end' not in full:
|
||
continue
|
||
|
||
# Был ли в параграфе разрыв страницы (Word хранит его отдельным run)
|
||
has_page_break = any(
|
||
br.get(f'{{{W_NS}}}type') == 'page'
|
||
for br in p.iter(f'{{{W_NS}}}br')
|
||
)
|
||
|
||
# Сохраняем форматирование первого run
|
||
first_r = p.find(f'{{{W_NS}}}r')
|
||
rpr = None
|
||
if first_r is not None:
|
||
rpr_el = first_r.find(f'{{{W_NS}}}rPr')
|
||
if rpr_el is not None:
|
||
rpr = etree.fromstring(etree.tostring(rpr_el))
|
||
|
||
# Оставляем только pPr, удаляем все run'ы
|
||
for child in list(p):
|
||
if child.tag != f'{{{W_NS}}}pPr':
|
||
p.remove(child)
|
||
|
||
# Разрыв страницы — отдельным run ПЕРЕД текстом (как было в оригинале)
|
||
if has_page_break:
|
||
br_r = etree.SubElement(p, f'{{{W_NS}}}r')
|
||
if rpr is not None:
|
||
br_r.append(etree.fromstring(etree.tostring(rpr)))
|
||
br_el = etree.SubElement(br_r, f'{{{W_NS}}}br')
|
||
br_el.set(f'{{{W_NS}}}type', 'page')
|
||
|
||
# Создаём один run с полным текстом параграфа
|
||
new_r = etree.SubElement(p, f'{{{W_NS}}}r')
|
||
if rpr is not None:
|
||
new_r.append(rpr)
|
||
new_t = etree.SubElement(new_r, f'{{{W_NS}}}t')
|
||
new_t.text = full
|
||
new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||
changed = True
|
||
|
||
if not changed:
|
||
return doc_xml
|
||
|
||
return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8')
|
||
|
||
|
||
def _strip_edge_empty_paras(fragment: str) -> str:
|
||
"""Удаляет пустые параграфы (без текста) с начала и конца XML-фрагмента.
|
||
Word оставляет их вокруг маркеров <!-- loop:... -->, из-за чего
|
||
при размножении цикла между строками появляются пустые строки."""
|
||
para_re = re.compile(r'<w:p\b[^>]*?(?:/>|>.*?</w:p>)', re.S)
|
||
|
||
def _is_empty(p: str) -> bool:
|
||
# Не пустой, если есть разрыв страницы, рисунок, таблица и т.п.
|
||
if '<w:br' in p or '<w:drawing' in p or '<w:tbl' in p or '<w:object' in p:
|
||
return False
|
||
texts = re.findall(r'<w:t[^>]*>(.*?)</w:t>', p, re.S)
|
||
if not texts:
|
||
return True
|
||
return all(t.strip() == '' for t in texts)
|
||
|
||
# С начала
|
||
while True:
|
||
m = para_re.match(fragment)
|
||
if not m:
|
||
break
|
||
if _is_empty(m.group(0)):
|
||
fragment = fragment[m.end():]
|
||
else:
|
||
break
|
||
|
||
# С конца
|
||
while True:
|
||
m = para_re.search(fragment)
|
||
if not m:
|
||
break
|
||
if m.end() != len(fragment):
|
||
break
|
||
if _is_empty(m.group(0)):
|
||
fragment = fragment[:m.start()]
|
||
else:
|
||
break
|
||
|
||
return fragment
|
||
|
||
|
||
def _top_level_ranges(fragment: str):
|
||
"""Возвращает список (start, end) ПОЛНЫХ элементов верхнего уровня
|
||
(w:p, w:tbl) в XML-фрагменте. Таблицы считаются одним элементом.
|
||
Обрывки (незакрытые <w:p в конце фрагмента) не включаются —
|
||
они относятся к параграфу маркера конца (suffix)."""
|
||
ranges = []
|
||
i = 0
|
||
n = len(fragment)
|
||
while i < n:
|
||
if fragment.startswith('<w:tbl>', i) or fragment.startswith('<w:tbl ', i):
|
||
j = i
|
||
d = 0
|
||
while j < n:
|
||
if fragment.startswith('<w:tbl>', j) or fragment.startswith('<w:tbl ', j):
|
||
d += 1
|
||
j += 5
|
||
elif fragment.startswith('</w:tbl>', j):
|
||
d -= 1
|
||
j += 8
|
||
if d == 0:
|
||
break
|
||
else:
|
||
j += 1
|
||
ranges.append((i, j))
|
||
i = j
|
||
elif fragment.startswith('<w:p>', i) or fragment.startswith('<w:p ', i):
|
||
j = fragment.find('</w:p>', i)
|
||
if j == -1:
|
||
break # обрывок — не полный параграф, дальше suffix
|
||
j += 6
|
||
ranges.append((i, j))
|
||
i = j
|
||
else:
|
||
i += 1
|
||
return ranges
|
||
|
||
|
||
def _split_template_edges(template: str):
|
||
"""Разделяет шаблон цикла на три части:
|
||
prefix — хвост параграфа маркера начала (после -->),
|
||
core — полные элементы верхнего уровня (параграфы/таблицы) между маркерами,
|
||
suffix — начало параграфа маркера конца (до <!--)."""
|
||
ranges = _top_level_ranges(template)
|
||
if not ranges:
|
||
return '', template, ''
|
||
prefix = template[:ranges[0][0]]
|
||
suffix = template[ranges[-1][1]:]
|
||
core = template[ranges[0][0]:ranges[-1][1]]
|
||
return prefix, core, suffix
|
||
|
||
|
||
def _cleanup_marker_paras(doc_xml: str) -> str:
|
||
"""Удаляет параграфы-остатки маркеров циклов: <w:p>...<w:r><w:t></w:t></w:r></w:p>
|
||
(пустой run — бывший маркер <!-- loop:... -->). Намеренные пустые строки
|
||
(<w:p/> без run'ов) не трогаем."""
|
||
try:
|
||
root = etree.fromstring(doc_xml.encode('utf-8'))
|
||
except Exception:
|
||
return doc_xml
|
||
removed = False
|
||
for p in list(root.iter(f'{{{W_NS}}}p')):
|
||
# Не трогаем параграфы внутри таблиц (пустые ячейки валидны)
|
||
parent = p.getparent()
|
||
if parent is None:
|
||
continue
|
||
if parent.tag == f'{{{W_NS}}}tc':
|
||
continue
|
||
# Не трогаем параграфы с разрывом страницы/строки, рисунками и т.п.
|
||
if (p.find(f'{{{W_NS}}}r/{{{W_NS}}}br') is not None
|
||
or p.find(f'{{{W_NS}}}r/{{{W_NS}}}drawing') is not None
|
||
or p.find(f'{{{W_NS}}}r/{{{W_NS}}}object') is not None
|
||
or p.find(f'{{{W_NS}}}r/{{{W_NS}}}pict') is not None):
|
||
continue
|
||
# Есть непустой текст — не трогаем
|
||
texts = [t.text or '' for t in p.iter(f'{{{W_NS}}}t')]
|
||
if any(t.strip() for t in texts):
|
||
continue
|
||
# Есть run'ы (признак бывшего маркера), но текста нет — удаляем
|
||
runs = p.findall(f'{{{W_NS}}}r')
|
||
if not runs:
|
||
continue
|
||
parent.remove(p)
|
||
removed = True
|
||
if not removed:
|
||
return doc_xml
|
||
return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8')
|
||
|
||
|
||
def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
|
||
"""Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML.
|
||
Работает до загрузки python-docx — гарантирует корректную вложенность.
|
||
|
||
Args:
|
||
docx_path: путь к шаблону .docx
|
||
loops: словарь {имя_цикла: [{field: value}, ...]}
|
||
|
||
Returns:
|
||
путь к обработанному .docx (временный файл)
|
||
"""
|
||
if not loops:
|
||
return docx_path
|
||
|
||
# Временный файл
|
||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.docx')
|
||
tmp.close()
|
||
|
||
wrote = False # была ли хоть одна запись в tmp
|
||
max_passes = 30
|
||
for _ in range(max_passes):
|
||
# Читаем ВСЕ файлы из исходного ZIP
|
||
all_files = {}
|
||
try:
|
||
with zipfile.ZipFile(docx_path if _ == 0 else tmp.name, 'r') as z:
|
||
for name in z.namelist():
|
||
all_files[name] = z.read(name)
|
||
doc_xml = all_files.get('word/document.xml', b'').decode('utf-8')
|
||
except Exception:
|
||
break
|
||
|
||
if not doc_xml:
|
||
break
|
||
|
||
# Word мог разбить маркеры циклов на несколько run'ов — склеиваем
|
||
doc_xml = _normalize_loop_markers(doc_xml)
|
||
|
||
# Находим ВСЕ маркеры циклов
|
||
start_markers = list(LOOP_START.finditer(doc_xml))
|
||
end_markers = list(LOOP_END.finditer(doc_xml))
|
||
|
||
if not start_markers:
|
||
break # нет больше циклов
|
||
|
||
# Внешний цикл = первый start (самый ранний в документе)
|
||
outer = start_markers[0]
|
||
outer_key = outer.group(1)
|
||
|
||
# Соответствующий end = первый end после outer (или последний, если вложенные)
|
||
# Считаем глубину: каждый start +1, каждый end -1
|
||
depth = 1
|
||
outer_end = None
|
||
all_markers = sorted(
|
||
[('start', m) for m in start_markers] +
|
||
[('end', m) for m in end_markers],
|
||
key=lambda x: x[1].start()
|
||
)
|
||
found_start = False
|
||
for kind, m in all_markers:
|
||
if m.start() <= outer.start():
|
||
continue
|
||
if kind == 'start':
|
||
depth += 1
|
||
elif kind == 'end':
|
||
depth -= 1
|
||
if depth == 0:
|
||
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():]
|
||
all_files['word/document.xml'] = doc_xml.encode('utf-8')
|
||
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
|
||
for name, data in all_files.items():
|
||
z.writestr(name, data)
|
||
wrote = True
|
||
continue
|
||
|
||
# Извлекаем шаблон (всё между start и end маркерами)
|
||
template = doc_xml[outer.end():outer_end.start()]
|
||
|
||
# Word оставляет обрывки параграфов вокруг маркеров и пустые параграфы.
|
||
# Отделяем обрывки (prefix/suffix) от тела цикла и чистим пустые
|
||
# параграфы в теле — иначе между итерациями появляются пустые строки.
|
||
prefix, core, suffix = _split_template_edges(template)
|
||
core = _strip_edge_empty_paras(core)
|
||
|
||
# НЕ удаляем loop_end из шаблона — они принадлежат вложенным циклам!
|
||
template_clean = core
|
||
|
||
# Разрыв страницы рядом с маркером конца цикла (Word хранит его
|
||
# отдельным run в параграфе с <!-- loop_end -->). Если он есть —
|
||
# каждый акт/блок цикла должен начинаться с новой страницы.
|
||
probe = doc_xml[max(0, outer_end.start() - 800):outer_end.end()]
|
||
has_page_break = '<w:br w:type="page"/>' in probe
|
||
# Если разрыв живёт в «хвосте» (параграф loop_end) — выносим его
|
||
# между итерациями, а из хвоста убираем (иначе акты слипаются).
|
||
br_in_suffix = has_page_break and '<w:br w:type="page"/>' in suffix
|
||
if br_in_suffix:
|
||
suffix = re.sub(r'<w:br\b[^>]*?w:type="page"[^>]*?/>', '', suffix)
|
||
|
||
# Размножаем шаблон для каждого элемента данных
|
||
expanded_parts = []
|
||
for idx, item in enumerate(items, 1):
|
||
clone = template_clean
|
||
for field, value in item.items():
|
||
val_str = str(value) if value else ('—' if value is None else '')
|
||
# С префиксом item.
|
||
clone = clone.replace('{{item.%s}}' % field, val_str)
|
||
# Без префикса (алиасы {{name}}, {{pd_list}}, {{document}} и т.д.)
|
||
clone = clone.replace('{{%s}}' % field, val_str)
|
||
# Альтернативный синтаксис {{item_поле}} (без точки)
|
||
clone = clone.replace('{{item_%s}}' % field, val_str)
|
||
clone = clone.replace('{{item.number}}', str(idx))
|
||
clone = clone.replace('{{number}}', str(idx))
|
||
expanded_parts.append(clone)
|
||
if br_in_suffix:
|
||
expanded_parts.append('<w:p><w:r><w:br w:type="page"/></w:r></w:p>')
|
||
|
||
# Собираем новый XML: обрывок параграфа маркера начала (prefix) один раз,
|
||
# затем размноженное тело цикла, затем обрывок параграфа маркера конца (suffix)
|
||
new_xml = (doc_xml[:outer.start()] + prefix
|
||
+ ''.join(expanded_parts) + suffix
|
||
+ doc_xml[outer_end.end():])
|
||
|
||
# Сохраняем ВСЕ файлы ZIP, обновляя только document.xml
|
||
all_files['word/document.xml'] = new_xml.encode('utf-8')
|
||
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
|
||
for name, data in all_files.items():
|
||
z.writestr(name, data)
|
||
wrote = True
|
||
|
||
if not wrote:
|
||
# Циклов в шаблоне не было — возвращаем исходный файл,
|
||
# пустой временный удаляем (иначе python-docx упадёт с Package not found)
|
||
try:
|
||
os.unlink(tmp.name)
|
||
except Exception:
|
||
pass
|
||
return docx_path
|
||
|
||
# Финальная зачистка: пустые параграфы-остатки маркеров (<!-- loop:... -->)
|
||
try:
|
||
with zipfile.ZipFile(tmp.name, 'r') as z:
|
||
all_files = {name: z.read(name) for name in z.namelist()}
|
||
doc_xml = all_files.get('word/document.xml', b'').decode('utf-8')
|
||
cleaned = _cleanup_marker_paras(doc_xml)
|
||
if cleaned != doc_xml:
|
||
all_files['word/document.xml'] = cleaned.encode('utf-8')
|
||
with zipfile.ZipFile(tmp.name, 'w', zipfile.ZIP_DEFLATED) as z:
|
||
for name, data in all_files.items():
|
||
z.writestr(name, data)
|
||
except Exception:
|
||
pass
|
||
|
||
return tmp.name
|
||
|
||
|
||
# ============================================================
|
||
# 3. ЗАМЕНА ПЕРЕМЕННЫХ В ЭЛЕМЕНТАХ DOCX
|
||
# ============================================================
|
||
|
||
def _apply_highlight_color(run_el, color: str):
|
||
"""Добавляет маркер highlight указанного цвета к run."""
|
||
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||
rpr = run_el.find(f'{{{ns}}}rPr')
|
||
if rpr is None:
|
||
rpr = OxmlElement('w:rPr')
|
||
run_el.insert(0, rpr)
|
||
hl = OxmlElement('w:highlight')
|
||
hl.set(f'{{{ns}}}val', color)
|
||
rpr.append(hl)
|
||
|
||
|
||
def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool:
|
||
"""Заменяет {{var}} в параграфе. Возвращает True, если были замены.
|
||
|
||
Значения «—» (незаполненная информация) подсвечиваются жёлтым маркером.
|
||
"""
|
||
full = para.text
|
||
matches = list(VAR_PATTERN.finditer(full))
|
||
if not matches:
|
||
return False
|
||
|
||
# Собираем параграф заново: обычный текст + значения переменных
|
||
first = para.runs[0] if para.runs else None
|
||
para.clear()
|
||
pos = 0
|
||
changed = False
|
||
for m in matches:
|
||
var = m.group(0)
|
||
val = replacements.get(var)
|
||
if val is None:
|
||
continue
|
||
# текст до переменной
|
||
if m.start() > pos:
|
||
run = para.add_run(full[pos:m.start()])
|
||
if first is not None:
|
||
_copy_run_style(first, run)
|
||
# значение переменной
|
||
val_str = str(val)
|
||
run = para.add_run(val_str)
|
||
if first is not None:
|
||
_copy_run_style(first, run)
|
||
if val_str == '—' or val_str == '':
|
||
_apply_highlight_color(run._element, 'yellow')
|
||
changed = True
|
||
pos = m.end()
|
||
# хвост после последней переменной
|
||
if pos < len(full):
|
||
run = para.add_run(full[pos:])
|
||
if first is not None:
|
||
_copy_run_style(first, run)
|
||
return changed
|
||
|
||
|
||
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 _replace_in_paragraphs(paragraphs, replacements):
|
||
for para in paragraphs:
|
||
_replace_text_in_para(para, replacements)
|
||
|
||
|
||
def _replace_in_tables(tables, replacements):
|
||
for table in tables:
|
||
for row in table.rows:
|
||
for cell in row.cells:
|
||
_replace_in_paragraphs(cell.paragraphs, replacements)
|
||
|
||
|
||
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 _strip_prooferr(doc):
|
||
"""Удаляет proofErr из docx (красные волнистые линии)."""
|
||
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)
|
||
|
||
|
||
def _highlight_unmatched(doc):
|
||
"""Выделяет незаменённые переменные красным маркером (только саму переменную)."""
|
||
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||
|
||
def _apply_highlight(run_el):
|
||
rpr = run_el.find(f'{{{ns}}}rPr')
|
||
if rpr is None:
|
||
rpr = OxmlElement('w:rPr')
|
||
run_el.insert(0, rpr)
|
||
hl = OxmlElement('w:highlight')
|
||
hl.set(f'{{{ns}}}val', 'red')
|
||
rpr.append(hl)
|
||
|
||
def _process_para(para):
|
||
full = para.text
|
||
matches = [m for m in UNMATCHED_PATTERN.finditer(full)
|
||
if m.group(0) not in IGNORED_OLD_VARS]
|
||
if not matches:
|
||
return
|
||
|
||
# Пересобираем параграф: обычный текст и переменные отдельными run'ами,
|
||
# переменные — с красным маркером. Сохраняем стиль первого run.
|
||
first = para.runs[0] if para.runs else None
|
||
para.clear()
|
||
pos = 0
|
||
for m in matches:
|
||
if m.start() > pos:
|
||
run = para.add_run(full[pos:m.start()])
|
||
if first is not None:
|
||
_copy_run_style(first, run)
|
||
run = para.add_run(m.group(0))
|
||
if first is not None:
|
||
_copy_run_style(first, run)
|
||
_apply_highlight(run._element)
|
||
pos = m.end()
|
||
if pos < len(full):
|
||
run = para.add_run(full[pos:])
|
||
if first is not None:
|
||
_copy_run_style(first, run)
|
||
|
||
for para in doc.paragraphs:
|
||
_process_para(para)
|
||
|
||
for table in doc.tables:
|
||
for row in table.rows:
|
||
for cell in row.cells:
|
||
for para in cell.paragraphs:
|
||
_process_para(para)
|
||
|
||
for section in doc.sections:
|
||
for hf in [section.header, section.footer,
|
||
section.first_page_header, section.first_page_footer]:
|
||
if hf is not None:
|
||
for para in hf.paragraphs:
|
||
_process_para(para)
|
||
|
||
|
||
def _log_unmatched(doc, replacements=None, log_func=None):
|
||
"""Логирует незаменённые переменные (без клавиш-исключений)."""
|
||
if not log_func:
|
||
return
|
||
unmatched = []
|
||
seen = set()
|
||
for para in doc.paragraphs:
|
||
for m in UNMATCHED_PATTERN.finditer(para.text):
|
||
var = m.group(0)
|
||
if var in IGNORED_OLD_VARS:
|
||
continue
|
||
if var not in seen:
|
||
seen.add(var)
|
||
unmatched.append(var)
|
||
if unmatched:
|
||
log_func(f"⚠️ Незаменённые переменные ({len(unmatched)}): "
|
||
f"{', '.join(unmatched[:20])}"
|
||
f"{' ...' if len(unmatched) > 20 else ''}")
|
||
else:
|
||
log_func("✅ Все переменные успешно заменены")
|
||
|
||
|
||
# ============================================================
|
||
# 4. МИГРАЦИЯ СТАРЫХ ПЕРЕМЕННЫХ
|
||
# ============================================================
|
||
|
||
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)
|
||
|
||
|
||
# ============================================================
|
||
# 5. ОСНОВНАЯ ФУНКЦИЯ ГЕНЕРАЦИИ
|
||
# ============================================================
|
||
|
||
def process_template(
|
||
template_path: str,
|
||
replacements: Dict[str, str],
|
||
output_path: Optional[str] = None,
|
||
log_func: Optional[Callable] = None,
|
||
) -> str:
|
||
"""Обрабатывает шаблон DOCX: подстановка переменных, циклы, сохранение.
|
||
|
||
Args:
|
||
template_path: путь к файлу шаблона .docx
|
||
replacements: словарь {переменная: значение}
|
||
output_path: путь для сохранения результата
|
||
log_func: функция логирования (str → None)
|
||
|
||
Returns:
|
||
путь к сгенерированному файлу
|
||
"""
|
||
if not DOCX_AVAILABLE:
|
||
raise ImportError("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}"
|
||
|
||
# ШАГ 1: Обрабатываем циклы на уровне ZIP/XML (до загрузки python-docx)
|
||
loops = replacements.pop('_loops', {})
|
||
working_path = template_path
|
||
if loops:
|
||
working_path = expand_loops_in_zip(template_path, loops)
|
||
replacements['_loops'] = loops # возвращаем на всякий случай
|
||
|
||
# ШАГ 2: Загружаем через python-docx
|
||
doc = Document(working_path)
|
||
|
||
# ШАГ 3: Миграция старых переменных <...> → {{...}}
|
||
_migrate_old_vars(doc)
|
||
|
||
# ШАГ 4: Убираем proofErr
|
||
_strip_prooferr(doc)
|
||
|
||
# ШАГ 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)
|
||
|
||
# ШАГ 7: Подсветка незаполненных
|
||
_highlight_unmatched(doc)
|
||
|
||
# ШАГ 8: Сохранение
|
||
doc.save(output_path)
|
||
|
||
# ШАГ 9: Если использовали временный файл — удаляем
|
||
if working_path != template_path:
|
||
try:
|
||
os.unlink(working_path)
|
||
except Exception:
|
||
pass
|
||
|
||
if log_func:
|
||
log_func(f"✅ {os.path.basename(output_path)}")
|
||
return output_path
|