From c3e65700b19e08309652345b180028bcb475530d Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 21 Sep 2025 20:23:04 +0300 Subject: [PATCH] =?UTF-8?q?add:=20LayoutProcessor=20-=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=B5=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20(draft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etpgrf/config.py | 23 +++++++++++++ etpgrf/layout.py | 77 ++++++++++++++++++++++++++++++++++++++++---- tests/test_layout.py | 62 +++++++++++++++++++++++++++++++---- 3 files changed, 148 insertions(+), 14 deletions(-) diff --git a/etpgrf/config.py b/etpgrf/config.py index 7baecdc..4628331 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -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(['!', '#', '%', '(', ')', '*', ',', '.', '/', ':', ';', '=', '?', '@', diff --git a/etpgrf/layout.py b/etpgrf/layout.py index 71daa6a..480e598 100644 --- a/etpgrf/layout.py +++ b/etpgrf/layout.py @@ -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: + # Используем негативный просмотр назад (? 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 diff --git a/tests/test_layout.py b/tests/test_layout.py index 2b59e84..b2f096a 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -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 \ No newline at end of file