132 lines
5.6 KiB
Python
132 lines
5.6 KiB
Python
"""
|
||
Проход 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()
|