add: LayoutProcessor - обработка едениц измерения (draft)
This commit is contained in:
@@ -92,6 +92,29 @@ STR_TO_SYMBOL_REPLACEMENTS = [
|
||||
('~=', CHAR_AP), # Приблизительно равно (≈)
|
||||
]
|
||||
|
||||
# === КОНСТАНТЫ ДЛЯ ЕДИНИЦ ИЗМЕРЕНИЯ ===
|
||||
# Пост-позиционные (10 км)
|
||||
DEFAULT_POST_UNITS = [
|
||||
# Русские
|
||||
'гг', 'г.', 'кг', 'мг', 'ц', 'т',
|
||||
'кв.м', 'куб.м', 'мм', 'см', 'м', 'км', 'л', 'мл', 'сот', 'га',
|
||||
'сек', 'с.', 'мин', 'ч',
|
||||
'руб', 'коп',
|
||||
'тыс', 'млн', 'млрд',
|
||||
'пп', 'стр', 'рис', 'табл', 'гл', 'п', 'шт',
|
||||
# Английские
|
||||
'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed',
|
||||
]
|
||||
# Пред-позиционные (№ 5, $ 10)
|
||||
DEFAULT_PRE_UNITS = ['№', '$', '€', '£', '₽', '#']
|
||||
|
||||
# === КОНСТАНТЫ ДЛЯ СЛОЖНЫХ (СОСТАВНЫХ) ЕДИНИЦ ИЗМЕРЕНИЯ ===
|
||||
# Эти единицы будут автоматически "склеены" неразрывными пробелами внутри LayoutProcessor
|
||||
DEFAULT_COMPLEX_UNITS = [
|
||||
'до н. э.',
|
||||
'н. э.',
|
||||
]
|
||||
|
||||
# === КОНСТАНТЫ ДЛЯ КОДИРОВАНИЯ HTML-МНЕМНОИКОВ ===
|
||||
# --- ЧЕРНЫЙ СПИСОК: Символы, которые НИКОГДА не нужно кодировать в мнемоники ---
|
||||
NEVER_ENCODE_CHARS = (frozenset(['!', '#', '%', '(', ')', '*', ',', '.', '/', ':', ';', '=', '?', '@',
|
||||
|
@@ -3,7 +3,8 @@
|
||||
|
||||
import regex
|
||||
import logging
|
||||
from etpgrf.config import LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP
|
||||
from etpgrf.config import (LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP,
|
||||
DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, DEFAULT_COMPLEX_UNITS)
|
||||
from etpgrf.comutil import parse_and_validate_langs
|
||||
|
||||
# --
|
||||
@@ -23,14 +24,16 @@ class LayoutProcessor:
|
||||
|
||||
def __init__(self,
|
||||
langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None,
|
||||
process_initials_and_acronyms: bool = True):
|
||||
process_initials_and_acronyms: bool = True,
|
||||
process_units: bool | str | list[str] = True,
|
||||
process_complex_units: bool | list[str] = True):
|
||||
|
||||
self.langs = parse_and_validate_langs(langs)
|
||||
self.main_lang = self.langs[0] if self.langs else LANG_RU
|
||||
self.process_initials_and_acronyms = process_initials_and_acronyms
|
||||
|
||||
self.process_units = process_units
|
||||
self.process_complex_units = process_complex_units
|
||||
# 1. Паттерн для длинного (—) или среднего (–) тире, окруженного пробелами.
|
||||
# (?<=\S) и (?=\S) гарантируют, что тире находится между словами, а не в начале/конце строки.
|
||||
# self._dash_pattern = regex.compile(rf'(?<=\S)\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
|
||||
# (?<=[\p{L}\p{Po}\p{Pf}"\']) - просмотр назад на букву, пунктуацию или кавычку.
|
||||
self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\'])\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
|
||||
|
||||
@@ -59,11 +62,59 @@ class LayoutProcessor:
|
||||
self._initial_to_initial_ns_pattern = regex.compile(r'(\p{Lu}\.)(?=\p{Lu}\.)')
|
||||
self._initial_to_surname_ns_pattern = regex.compile(r'(\p{Lu}\.)(?=\p{Lu}\p{L}{1,})')
|
||||
|
||||
# 5. Паттерны для единиц измерения.
|
||||
self._post_units_pattern = None
|
||||
self._pre_units_pattern = None
|
||||
if self.process_units:
|
||||
post_units = list(DEFAULT_POST_UNITS)
|
||||
pre_units = list(DEFAULT_PRE_UNITS)
|
||||
# Проверяем и добавляем пользовательские единицы измерения
|
||||
custom_units = []
|
||||
|
||||
# Обработка составных единиц: "склеиваем" их тонкой шпацией и добавляем в общий список
|
||||
if self.process_complex_units:
|
||||
complex_units_to_process = list(DEFAULT_COMPLEX_UNITS)
|
||||
if isinstance(self.process_complex_units, (list, tuple, set)):
|
||||
complex_units_to_process.extend(self.process_complex_units)
|
||||
|
||||
# "Склеиваем" пробелы внутри составных единиц и добавляем в общий список
|
||||
post_units.extend([unit.replace(' ', CHAR_THIN_SP) for unit in complex_units_to_process])
|
||||
|
||||
if isinstance(self.process_units, str):
|
||||
# Если кастомные единицы заданы строкой, разбиваем по пробелам
|
||||
custom_units = self.process_units.split()
|
||||
elif isinstance(self.process_units, (list, tuple, set)):
|
||||
# Если кастомные единицы заданы списком/кортежем/множеством, просто конвертируем в список
|
||||
custom_units = list(self.process_units)
|
||||
|
||||
if custom_units:
|
||||
post_units.extend(custom_units)
|
||||
|
||||
if post_units:
|
||||
# [\d.,]+ - число, возможно, с точкой или запятой
|
||||
# Используем негативный просмотр вперед (?!), чтобы убедиться, что за единицей
|
||||
# не следует другая буква. Это надежнее, чем \b, особенно для единиц,
|
||||
# оканчивающихся на точку (например, "г.").
|
||||
post_pattern_str = r'(\d[\d.,]*)\s+(' + '|'.join(regex.escape(u) for u in post_units) + r')(?![\p{L}\p{N}])'
|
||||
self._post_units_pattern = regex.compile(post_pattern_str)
|
||||
|
||||
if pre_units:
|
||||
# Используем негативный просмотр назад (?<!), чтобы убедиться, что перед единицей
|
||||
# нет буквы. \b здесь не работает для символов типа "№" или "$".
|
||||
pre_pattern_str = r'(?<![\p{L}\p{N}])(' + '|'.join(regex.escape(u) for u in pre_units) + r')\s+(\d[\d.,]*)'
|
||||
self._pre_units_pattern = regex.compile(pre_pattern_str)
|
||||
|
||||
# 6. Паттерн для связи единиц-умножителей (тыс., млн.) со следующей единицей.
|
||||
# Ищет умножитель, за которым может быть точка, а затем пробел.
|
||||
multiplier_units = ['тыс', 'млн', 'млрд']
|
||||
self._unit_multiplier_pattern = regex.compile(r'((' + '|'.join(multiplier_units) + r')\.?)\s+')
|
||||
|
||||
logger.debug(f"LayoutProcessor `__init__`. "
|
||||
f"Langs: {self.langs}, "
|
||||
f"Main lang: {self.main_lang}, "
|
||||
f"Process initials and acronyms: {self.process_initials_and_acronyms}")
|
||||
|
||||
f"Process initials and acronyms: {self.process_initials_and_acronyms}, "
|
||||
f"Process units: {bool(self.process_units)}, "
|
||||
f"Process complex units: {bool(self.process_complex_units)}")
|
||||
|
||||
def _replace_dash_spacing(self, match: regex.Match) -> str:
|
||||
"""Callback-функция для расстановки пробелов вокруг тире с учетом языка."""
|
||||
@@ -100,4 +151,16 @@ class LayoutProcessor:
|
||||
processed_text = self._initial_to_surname_ws_pattern.sub(f'\\1{CHAR_NBSP}', processed_text)
|
||||
processed_text = self._surname_to_initial_ws_pattern.sub(f'\\1{CHAR_NBSP}', processed_text)
|
||||
|
||||
# 5. Обработка единиц измерения (если включено).
|
||||
if self.process_units and self._unit_multiplier_pattern:
|
||||
processed_text = self._unit_multiplier_pattern.sub(r'\1' + CHAR_NBSP, processed_text)
|
||||
|
||||
# 6. Обработка единиц измерения (простых и составных).
|
||||
if self.process_units:
|
||||
if self._post_units_pattern:
|
||||
processed_text = self._post_units_pattern.sub(f'\\1{CHAR_NBSP}\\2', processed_text)
|
||||
if self._pre_units_pattern:
|
||||
processed_text = self._pre_units_pattern.sub(f'\\1{CHAR_NBSP}\\2', processed_text)
|
||||
|
||||
|
||||
return processed_text
|
||||
|
@@ -69,6 +69,7 @@ LAYOUT_TEST_CASES = [
|
||||
('ru', "10 - 5 = 5", "10 - 5 = 5"), # Бинарный минус не трогаем
|
||||
('ru', "слово-10", "слово-10"), # Дефис, а не минус
|
||||
('ru', "1-2-3-4-5, я иду тебя искать", "1-2-3-4-5, я иду тебя искать"), # Дефис, а не минус
|
||||
('ru', "в диапазоне (-10, 10)", f"в диапазоне (-10, 10)"), # Без пробела после скобки
|
||||
|
||||
# --- Инициалы (должны обрабатываться по умолчанию) ---
|
||||
# Разные комбинации пробелов
|
||||
@@ -87,7 +88,6 @@ LAYOUT_TEST_CASES = [
|
||||
# Акронимы (бонус)
|
||||
('ru', "Сделано в С.Ш.А.", f"Сделано в С.{CHAR_THIN_SP}Ш.{CHAR_THIN_SP}А."),
|
||||
('ru', "Сделано в С. Ш. А.", f"Сделано в С.{CHAR_NBSP}Ш.{CHAR_NBSP}А."),
|
||||
('en', "На замке стояло клеймо «Made in U.S.A.»", f"На замке стояло клеймо «Made in U.{CHAR_THIN_SP}S.{CHAR_THIN_SP}A.»"),
|
||||
('en', "На замке стояло клеймо «Made in U. S. A.»", f"На замке стояло клеймо «Made in U.{CHAR_NBSP}S.{CHAR_NBSP}A.»"),
|
||||
# Никаких изменений, если пробелы другого типа
|
||||
('ru', "А.\u200DС.\u200AПушкин", "А.\u200DС.\u200AПушкин"),
|
||||
@@ -95,21 +95,69 @@ LAYOUT_TEST_CASES = [
|
||||
('en', "J.\u200DR.\u200DR.\u200ATolkien", "J.\u200DR.\u200DR.\u200ATolkien"),
|
||||
('en', "Tolkien J.\u200AR.\u200AR.", f"Tolkien{CHAR_NBSP}J.\u200AR.\u200AR."),
|
||||
|
||||
# --- Инициалы (проверка отключения опции) ---
|
||||
# ('ru', "А. С. Пушкин", "А. С. Пушкин", False),
|
||||
# ('ru', "Пушкин А.С.", "Пушкин А.С.", False),
|
||||
# --- Единицы измерения (по умолчанию) ---
|
||||
('ru', "Радиус Солнца — около 696.340 км", f"Радиус Солнца{CHAR_NBSP}— около 696.340{CHAR_NBSP}км"),
|
||||
('ru', "5 кг.", f"5{CHAR_NBSP}кг."),
|
||||
('ru', "Доработки проекта стоили 100 тыс. руб.", f"Доработки проекта стоили 100{CHAR_NBSP}тыс.{CHAR_NBSP}руб."),
|
||||
('ru', "№ 5", f"№{CHAR_NBSP}5"),
|
||||
('ru', "Договор № 504/2025А", f"Договор №{CHAR_NBSP}504/2025А"),
|
||||
('ru+en', "Доплата за багаж $ 45.50", f"Доплата за багаж ${CHAR_NBSP}45.50"),
|
||||
('ru+en', "Инвестиции составили $2.5 млн.", f"Инвестиции составили $2.5{CHAR_NBSP}млн."),
|
||||
# Сложные единицы (склеиваются тонкой шпацией, привязываются к числу неразрывным пробелом)
|
||||
('ru', "Дом 120 кв.м. / Участок 6 сот.", f"Дом 120{CHAR_NBSP}кв.м. / Участок 6{CHAR_NBSP}сот."),
|
||||
('ru', "Гробик кладут в ямку 2 кв. м.", f"Гробик кладут в ямку 2 кв. м."),
|
||||
('ru', "500 до н. э.", f"500 до н. э."),
|
||||
('ru+en', "Хаммурапи (1792 - 1750 до н. э.)", f"Хаммурапи (1792 - 1750 до н. э.)"),
|
||||
|
||||
# --- Комбинированные случаи ---
|
||||
('ru', f"Да — это так{CHAR_HELLIP} а может и нет. Счёт -10.",
|
||||
f"Да{CHAR_NBSP}— это так{CHAR_HELLIP}{CHAR_NBSP}а может и нет. Счёт{CHAR_NBSP}-10."),
|
||||
f"Да{CHAR_NBSP}— это так{CHAR_HELLIP}{CHAR_NBSP}а может и нет. Счёт{CHAR_NBSP}-10."),
|
||||
('ru', f"По мнению А.С.Пушкина — это...", f"По мнению А.{CHAR_THIN_SP}С.{CHAR_THIN_SP}Пушкина{CHAR_NBSP}— это..."),
|
||||
('ru', f"К моменту смерти (1837 г.) А.С. Пушкин был должен 135.833 руб.: 92.500 руб. частным лицам и 43.333 руб."
|
||||
f" царской казне.",
|
||||
f"К моменту смерти (1837{CHAR_NBSP}г.) А.{CHAR_THIN_SP}С.{CHAR_NBSP}Пушкин был должен"
|
||||
f" 135.833{CHAR_NBSP}руб.: 92.500{CHAR_NBSP}руб. частным лицам и 43.333{CHAR_NBSP}руб."
|
||||
f" царской казне."),
|
||||
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang, input_string, expected_output", LAYOUT_TEST_CASES)
|
||||
def test_layout_processor(lang, input_string, expected_output):
|
||||
@pytest.mark.parametrize("lang, input_string, expected_output", LAYOUT_TEST_CASES
|
||||
)
|
||||
def test_layout_processor_default(lang, input_string, expected_output):
|
||||
"""Проверяет работу LayoutProcessor в изоляции."""
|
||||
processor = LayoutProcessor(langs=lang)
|
||||
actual_output = processor.process(input_string)
|
||||
assert actual_output == expected_output
|
||||
|
||||
|
||||
# Тесты для проверки работы с кастомными опциями
|
||||
LAYOUT_OPTIONS_TEST_CASES = [
|
||||
# --- Отключение правил ---
|
||||
# Отключение обработки единиц измерения
|
||||
('ru', "10 км", "10 км", {'process_units': False}),
|
||||
('ru', "№ 5", "№ 5", {'process_units': False}),
|
||||
('ru', "100 тыс. руб.", "100 тыс. руб.", {'process_units': False}),
|
||||
# Отключение обработки инициалов
|
||||
('ru', "А. С. Пушкин", "А. С. Пушкин", {'process_initials_and_acronyms': False}),
|
||||
('ru', "Пушкин А.С.", "Пушкин А.С.", {'process_initials_and_acronyms': False}),
|
||||
|
||||
# --- Кастомные единицы измерения ---
|
||||
# Кастомные единицы (список)
|
||||
('ru', "100 тонн", f"100{CHAR_NBSP}тонн", {'process_units': ['тонн']}),
|
||||
# Кастомные единицы (строка)
|
||||
('ru', "50 штук", f"50{CHAR_NBSP}штук", {'process_units': 'штук кг'}),
|
||||
# Стандартные единицы должны продолжать работать вместе с кастомными
|
||||
('ru', "100 тонн и 10 км", f"100{CHAR_NBSP}тонн и 10{CHAR_NBSP}км", {'process_units': ['тонн']}),
|
||||
# Кастомные единицы не должны мешать другим правилам
|
||||
('ru', "А.С. Пушкин получил 10 бочек селёдки",
|
||||
f"А.{CHAR_THIN_SP}С.{CHAR_NBSP}Пушкин получил 10{CHAR_NBSP}бочек селёдки", {'process_units': ['бочек']}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang, input_string, expected_output, options", LAYOUT_OPTIONS_TEST_CASES)
|
||||
def test_layout_processor_with_options(lang, input_string, expected_output, options):
|
||||
"""Проверяет работу LayoutProcessor с кастомными настройками."""
|
||||
processor = LayoutProcessor(langs=lang, **options)
|
||||
actual_output = processor.process(input_string)
|
||||
assert actual_output == expected_output
|
Reference in New Issue
Block a user