Исходный код DokoGen (генератор документов 152-ФЗ)
This commit is contained in:
@@ -0,0 +1,862 @@
|
||||
# -*- 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)
|
||||
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
|
||||
Reference in New Issue
Block a user