Files
drhider/drhider/replacer.py
T
2026-07-13 17:18:00 +04:00

132 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Проход 2: замена сущностей в документах.
Два метода:
1. apply_replacements — замена в Markdown/plain-тексте (основной)
2. replace_in_docx — замена в DOCX с сохранением форматирования
(для опционального DOCX-выхода, будет добавлен позже)
"""
import io
import re
from typing import Dict, List
def _md_tolerant_pattern(original: str) -> str:
r"""Паттерн, допускающий ** между частями строки (Markdown bold).
DOCX → Markdown: жирность добавляет ** вокруг слов.
Если ** попадает внутрь сущности (например, **ООО **"**НУБЕС**"),
точное совпадение не срабатывает.
Этот паттерн вставляет (?:\*{1,2})? между каждым словом/символом,
позволяя найти сущность даже с ** внутри.
Args:
original: Исходная строка сущности (например, 'ООО "НУБЕС"')
Returns:
Regex-паттерн, находящий ту же строку с ** между частями
"""
MD = r'(?:\*{1,2})?'
parts = re.findall(r'[А-ЯЁа-яёA-Za-z0-9]+|[^А-ЯЁа-яёA-Za-z0-9]', original)
if len(parts) <= 1:
return None # Одно слово — ** не может разорвать изнутри
return MD + MD.join(re.escape(p) for p in parts) + MD
def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str]) -> str:
"""Применить все замены из словаря mapping к строке.
Ключи применяются в порядке убывания длины (sorted_keys).
Это гарантирует, что более длинные совпадения заменяются раньше
коротких (например, «ИНН 123456789012» до «ИНН 1234567890»).
Для сущностей, начинающихся и заканчивающихся на букву/цифру,
используются границы слова (\\b), чтобы избежать частичных замен.
Args:
text: Исходный текст
mapping: Словарь {оригинал: замена}
sorted_keys: Ключи mapping, отсортированные по длине (убывание)
Returns:
Текст с заменами
"""
result = text
for original in sorted_keys:
replacement = mapping[original]
# Если сущность обрамлена буквами/цифрами — используем границы слова
if original and original[0].isalnum() and original[-1].isalnum():
pattern = r'(?<!\w)' + re.escape(original) + r'(?!\w)'
else:
pattern = re.escape(original)
new_result = re.sub(pattern, replacement, result)
# Фоллбэк: Markdown-жирность могла разорвать строку
md_pattern = _md_tolerant_pattern(original)
if md_pattern is not None:
new_result = re.sub(md_pattern, replacement, new_result)
result = new_result
return result
def replace_in_docx(doc, mapping: Dict[str, str], sorted_keys: List[str]) -> bytes:
"""Заменить сущности в DOCX-документе с сохранением форматирования.
Алгоритм для каждого параграфа:
1. Склеиваем текст всех runs в одну строку
2. Применяем замены
3. Пишем результат в первый run, очищаем остальные
Это нужно потому что python-docx разбивает текст на runs
(например, mid-docx форматирование), и сущность может быть
разорвана между несколькими runs.
Args:
doc: Объект python-docx Document
mapping: Словарь замен
sorted_keys: Ключи mapping по убыванию длины
Returns:
Бинарное содержимое изменённого DOCX-файла
"""
# ── Обработка параграфов ──
for para in doc.paragraphs:
if not para.runs:
continue
# Склеиваем все runs в одну строку
full_text = "".join(run.text for run in para.runs)
replaced = apply_replacements(full_text, mapping, sorted_keys)
# Если были замены — пишем в первый run, очищаем остальные
if replaced != full_text:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
# ── Обработка таблиц ──
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
if not para.runs:
continue
full_text = "".join(run.text for run in para.runs)
replaced = apply_replacements(full_text, mapping, sorted_keys)
if replaced != full_text:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
# Сохраняем в буфер
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()