- api_settings: убраны реальные API-ключи из кода (env/файл) — 1.1 - verify: промт ИИ для проверки реквизитов больше не ссылается на 187-ФЗ как на закон о ПДн («юрист по ПДн (187-ФЗ)» → специалист по реквизитам) - ui: дата ПП РФ №127 исправлена 08.02.2019 → 08.02.2018 (3 места) - help_docs: группа B переименована «187-ФЗ (персональные данные)» → «187-ФЗ (критическая информационная инфраструктура)» - generator: убран дубль хвоста параграфа в _highlight_unmatched - importer: «магия дат» (числа 10001-79999 → дата Excel) теперь только для полей-дат (as_date), финансовые поля не ломаются - ui (_collect_is): убраны дубли пар маппинга attacker_level/attacker_types - ui (импорт): убрано наследие 152-ФЗ ispdnPosition/ispdnFio из маппинга - test_generator: переписан под модель 187-ФЗ (category/sphere/object_type вместо pd_count/threat_type/defence_level) - criteria_justifications: п.9 для здравоохранения согласован с кодом (убран «критерий неприменим» — противоречие с «применим ко всем») - criteria_applicability: Транспорт (добавлены п.1, п.2; убран п.10), Банковская сфера (добавлены п.8, п.9) — требуется экспертная сверка - ui: Бусредн вынесен в константу DEFAULT_BUDGET_AVG (данные Минфина, обновлять ежегодно); автозаполнение п.9 унифицировано с авторасчётом (ΔБ = ΔНП+ΔНДПИ+ΔДИВ+ΔВСО+ΔНС) - ui: п.8 — предупреждение при неполных данных (менее 5 лет доходов) - models: поля ndpi/div/vso/ns для п.9; комментарий income_loss уточнён - .gitignore: api_settings.json/.env не попадают в git
860 lines
36 KiB
Python
860 lines
36 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'ам),
|
||
# табы <w:tab/> превращаем в символ \t, чтобы не потерять
|
||
texts = []
|
||
for child in p.iter():
|
||
tag = child.tag.split('}')[-1]
|
||
if tag == 't':
|
||
texts.append(child.text or '')
|
||
elif tag == 'tab':
|
||
texts.append('\t')
|
||
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 с полным текстом параграфа, восстанавливая табы <w:tab/>
|
||
new_r = etree.SubElement(p, f'{{{W_NS}}}r')
|
||
if rpr is not None:
|
||
new_r.append(rpr)
|
||
# Разбиваем по \t: обычный текст → <w:t>, таб → <w:tab/>
|
||
parts = full.split('\t')
|
||
for i, part in enumerate(parts):
|
||
if part:
|
||
new_t = etree.SubElement(new_r, f'{{{W_NS}}}t')
|
||
new_t.text = part
|
||
new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||
if i < len(parts) - 1:
|
||
tab_el = etree.SubElement(new_r, f'{{{W_NS}}}tab')
|
||
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 _subst_var_xml(xml: str, var: str, val: str) -> str:
|
||
"""Замена {{var}} в XML-фрагменте.
|
||
|
||
Пустые значения («—»/«») подсвечиваются жёлтым маркером: значение
|
||
выносится в отдельный run с <w:highlight w:val="yellow"/>, чтобы
|
||
подсветка не затиралась при подстановке на уровне XML (циклы).
|
||
"""
|
||
token = '{{%s}}' % var
|
||
if val not in ('—', ''):
|
||
return xml.replace(token, val)
|
||
hl_run = ('<w:rPr><w:highlight w:val="yellow"/></w:rPr>'
|
||
'<w:t xml:space="preserve">%s</w:t>' % val)
|
||
return xml.replace(token,
|
||
'</w:t></w:r><w:r>' + hl_run
|
||
+ '</w:r><w:r><w:t xml:space="preserve">')
|
||
|
||
|
||
def _expand_nested_loops(template: str, item: Dict) -> str:
|
||
"""Разворачивает вложенные циклы вида <!-- loop:users_loop --> ... <!-- loop_end -->
|
||
данными из item (список словарей). Используется для циклов внутри цикла ИС,
|
||
например пользователи ИС. Переменные внутри: {{user_fio}}, {{user_position}}."""
|
||
result = template
|
||
for _ in range(20):
|
||
m_start = LOOP_START.search(result)
|
||
if not m_start:
|
||
break
|
||
key = m_start.group(1)
|
||
data = item.get(key)
|
||
if not isinstance(data, list):
|
||
# Нет данных для вложенного цикла — удаляем блок целиком
|
||
depth = 1
|
||
pos = m_start.end()
|
||
m_end = None
|
||
for mm in LOOP_END.finditer(result, pos):
|
||
depth -= 1
|
||
if depth == 0:
|
||
m_end = mm
|
||
break
|
||
if m_end:
|
||
result = result[:m_start.start()] + result[m_end.end():]
|
||
continue
|
||
# Ищем соответствующий loop_end с учётом вложенности
|
||
depth = 1
|
||
pos = m_start.end()
|
||
m_end = None
|
||
markers = sorted(
|
||
[('s', x) for x in LOOP_START.finditer(result, pos)] +
|
||
[('e', x) for x in LOOP_END.finditer(result, pos)],
|
||
key=lambda t: t[1].start()
|
||
)
|
||
for kind, mm in markers:
|
||
if kind == 's':
|
||
depth += 1
|
||
else:
|
||
depth -= 1
|
||
if depth == 0:
|
||
m_end = mm
|
||
break
|
||
if m_end is None:
|
||
break
|
||
inner = result[m_start.end():m_end.start()]
|
||
parts = []
|
||
for sub in data:
|
||
clone = inner
|
||
for f, v in sub.items():
|
||
val = str(v) if v else ''
|
||
clone = _subst_var_xml(clone, 'user_%s' % f, val)
|
||
clone = _subst_var_xml(clone, 'item.%s' % f, val)
|
||
parts.append(clone)
|
||
result = result[:m_start.start()] + ''.join(parts) + result[m_end.end():]
|
||
return result
|
||
|
||
|
||
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
|
||
# Вложенные циклы (users_loop и т.п.) — разворачиваем ДО подстановки
|
||
# обычных полей, чтобы {{user_fio}} внутри них не затёрлись алиасами
|
||
clone = _expand_nested_loops(clone, item)
|
||
for field, value in item.items():
|
||
if isinstance(value, list):
|
||
continue # списки — данные вложенных циклов, не подставляем
|
||
val_str = str(value) if value else ('—' if value is None else '')
|
||
# С префиксом item.
|
||
clone = _subst_var_xml(clone, 'item.%s' % field, val_str)
|
||
# Без префикса (алиасы {{name}}, {{pd_list}}, {{document}} и т.д.)
|
||
clone = _subst_var_xml(clone, '%s' % field, val_str)
|
||
# Альтернативный синтаксис {{item_поле}} (без точки)
|
||
clone = _subst_var_xml(clone, 'item_%s' % field, val_str)
|
||
clone = _subst_var_xml(clone, 'item.number', str(idx))
|
||
clone = _subst_var_xml(clone, '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 _add_run_with_tabs(para, text, style_source=None):
|
||
"""Добавляет run с текстом, сохраняя табуляцию как <w:tab/>.
|
||
|
||
python-docx add_run('\t') вставляет символ таба в w:t, который Word
|
||
может «съесть». Поэтому разбиваем текст по '\t' и вставляем
|
||
настоящие элементы табуляции.
|
||
"""
|
||
parts = str(text).split('\t')
|
||
for i, part in enumerate(parts):
|
||
if part:
|
||
run = para.add_run(part)
|
||
if style_source is not None:
|
||
_copy_run_style(style_source, run)
|
||
if i < len(parts) - 1:
|
||
run = para.add_run()
|
||
if style_source is not None:
|
||
_copy_run_style(style_source, run)
|
||
run.add_tab()
|
||
|
||
|
||
def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool:
|
||
"""Заменяет {{var}} в параграфе. Возвращает True, если были замены.
|
||
|
||
Значения «—» (незаполненная информация) подсвечиваются жёлтым маркером.
|
||
Табуляция в параграфе сохраняется (<w:tab/>).
|
||
"""
|
||
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 m.start() > pos:
|
||
_add_run_with_tabs(para, full[pos:m.start()], first)
|
||
if val is None:
|
||
# Переменная не заменена — оставляем как есть,
|
||
# красным её подсветит _highlight_unmatched на шаге 7
|
||
run = para.add_run(var)
|
||
if first is not None:
|
||
_copy_run_style(first, run)
|
||
pos = m.end()
|
||
continue
|
||
# значение переменной
|
||
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):
|
||
_add_run_with_tabs(para, full[pos:], first)
|
||
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:
|
||
_add_run_with_tabs(para, full[pos:m.start()], first)
|
||
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):
|
||
_add_run_with_tabs(para, full[pos:], first)
|
||
|
||
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
|