feat: клавиши (Ctrl/Alt/Del) в исключения + подсветка маркером только переменной

- IGNORED_OLD_VARS: <Ctrl>, <Alt>, <Del> и др. клавиши больше не
  считаются незаменёнными переменными (7.6 Инструкция Пользователя)
- Подсветка: вместо красного цвета текста — красный маркер
  (w:highlight) на самой переменной, а не на всём абзаце
- OLD_VAR_MAP: ShortCompanyName1-6 (склонения краткого названия),
  IspdnName → {{is_name_1}}
This commit is contained in:
2026-08-03 10:53:59 +04:00
parent f5e9d3523d
commit 577bed22d6
+62 -19
View File
@@ -30,6 +30,17 @@ VAR_PATTERN = re.compile(r'\{\{[^}]+\}\}')
OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>') OLD_VAR_PATTERN = re.compile(r'<[A-Za-z0-9_]+>')
UNMATCHED_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'(?:&lt;!--|<!--)\s*loop:(\w+)\s*(?:--&gt;|-->)') LOOP_START = re.compile(r'(?:&lt;!--|<!--)\s*loop:(\w+)\s*(?:--&gt;|-->)')
LOOP_END = re.compile(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)') LOOP_END = re.compile(r'(?:&lt;!--|<!--)\s*loop_end\s*(?:--&gt;|-->)')
@@ -61,6 +72,14 @@ OLD_VAR_MAP = {
'<DocumentNumber>': '{{document_number}}', '<DocumentNumber>': '{{document_number}}',
'<DocumentDate>': '{{document_date}}', '<DocumentDate>': '{{document_date}}',
'<Date>': '{{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}}',
} }
@@ -342,42 +361,64 @@ def _strip_prooferr(doc):
def _highlight_unmatched(doc): def _highlight_unmatched(doc):
"""Подсвечивает незаменённые переменные красным.""" """Выделяет незаменённые переменные красным маркером (только саму переменную)."""
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
def _highlight_run(run): def _apply_highlight(run_el):
rpr = run._element.find(f'{{{ns}}}rPr') rpr = run_el.find(f'{{{ns}}}rPr')
if rpr is None: if rpr is None:
rpr = OxmlElement('w:rPr') rpr = OxmlElement('w:rPr')
run._element.insert(0, rpr) run_el.insert(0, rpr)
color = OxmlElement('w:color') hl = OxmlElement('w:highlight')
color.set(f'{{{ns}}}val', 'FF0000') hl.set(f'{{{ns}}}val', 'red')
rpr.append(color) 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: for para in doc.paragraphs:
for run in para.runs: _process_para(para)
if UNMATCHED_PATTERN.search(run.text or ''):
_highlight_run(run)
for table in doc.tables: for table in doc.tables:
for row in table.rows: for row in table.rows:
for cell in row.cells: for cell in row.cells:
for para in cell.paragraphs: for para in cell.paragraphs:
for run in para.runs: _process_para(para)
if UNMATCHED_PATTERN.search(run.text or ''):
_highlight_run(run)
for section in doc.sections: for section in doc.sections:
for hf in [section.header, section.footer]: for hf in [section.header, section.footer,
if hf: section.first_page_header, section.first_page_footer]:
if hf is not None:
for para in hf.paragraphs: for para in hf.paragraphs:
for run in para.runs: _process_para(para)
if UNMATCHED_PATTERN.search(run.text or ''):
_highlight_run(run)
def _log_unmatched(doc, replacements=None, log_func=None): def _log_unmatched(doc, replacements=None, log_func=None):
"""Логирует незаменённые переменные.""" """Логирует незаменённые переменные (без клавиш-исключений)."""
if not log_func: if not log_func:
return return
unmatched = [] unmatched = []
@@ -385,6 +426,8 @@ def _log_unmatched(doc, replacements=None, log_func=None):
for para in doc.paragraphs: for para in doc.paragraphs:
for m in UNMATCHED_PATTERN.finditer(para.text): for m in UNMATCHED_PATTERN.finditer(para.text):
var = m.group(0) var = m.group(0)
if var in IGNORED_OLD_VARS:
continue
if var not in seen: if var not in seen:
seen.add(var) seen.add(var)
unmatched.append(var) unmatched.append(var)