diff --git a/etpgrf/config.py b/etpgrf/config.py index 4628331..000775f 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -63,6 +63,10 @@ CHAR_ARROW_LR = '\u27f7' # Длинная двунаправленная ст CHAR_ARROW_L_LONG_DOUBLE = '\u27f8' # Длинная двойная стрелка влево CHAR_ARROW_R_LONG_DOUBLE = '\u27f9' # Длинная двойная стрелка вправо CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двунаправленная стрелка +CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / · +CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить + # в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом. + # === КОНСТАНТЫ ПСЕВДОГРАФИКИ === # Для простых замен "строка -> символ" используем список кортежей. @@ -92,28 +96,6 @@ 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-МНЕМНОИКОВ === # --- ЧЕРНЫЙ СПИСОК: Символы, которые НИКОГДА не нужно кодировать в мнемоники --- @@ -185,7 +167,7 @@ CUSTOM_ENCODE_MAP = { '\u0026': '&', # & / & / & '\u003e': '>', # > / > / > '\u003c': '<', # < / < / < - '\u00b7': '·', # · / · / · / · + CHAR_MIDDOT: '·', # · / · / · / · '\u0060': '`', # ` / ` / ` '\u00a8': '¨', # ¨ / ¨ / ¨ / ¨ / ¨ '\u00b1': '±', # ± / ± / ± @@ -637,4 +619,28 @@ ENCODE_MAP = _build_translation_maps() # --- Публичный API модуля --- def get_encode_map(): """Возвращает готовую карту для кодирования.""" - return ENCODE_MAP \ No newline at end of file + return ENCODE_MAP + + +# === КОНСТАНТЫ ДЛЯ ЕДИНИЦ ИЗМЕРЕНИЯ === +# ТОЛЬКО АТОМАРНЫЕ единицы измерения: 'г', 'м', 'с', 'км', 'кв', 'куб', 'ч' и так далее. +# Никаких сложных и составных, типа: 'кв.м.', 'км/ч' или "до н.э." ... +# Пост-позиционные (10 км). +DEFAULT_POST_UNITS = [ + # Русские + 'гг', 'г.', 'в.', 'вв', 'н', 'э', + 'кг', 'мг', 'ц', 'т', + 'кв', 'куб', 'мм', 'см', 'м', 'км', 'л', 'мл', 'сот', 'га', + 'сек', 'с.', 'мин', 'ч', + 'руб', 'коп', + 'тыс', 'млн', 'млрд', 'трлн', 'трлрд', + 'пп', 'стр', 'рис', 'табл', 'гл', 'п', 'шт', 'об' + # Английские + 'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed', +] +# Пред-позиционные (№ 5, $ 10) +DEFAULT_PRE_UNITS = ['№', '$', '€', '£', '₽', '#'] + +# Операторы, которые могут стоять между единицами измерения (км/ч) +# Сложение и вычитание здесь намеренно отсутствуют. +UNIT_MATH_OPERATORS = ['/', '*', '×', CHAR_MIDDOT, '÷'] \ No newline at end of file diff --git a/etpgrf/layout.py b/etpgrf/layout.py index 480e598..d51d41a 100644 --- a/etpgrf/layout.py +++ b/etpgrf/layout.py @@ -3,8 +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, - DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, DEFAULT_COMPLEX_UNITS) +from etpgrf.config import (LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP, CHAR_UNIT_SEPARATOR, + DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, UNIT_MATH_OPERATORS) from etpgrf.comutil import parse_and_validate_langs # -- @@ -25,14 +25,12 @@ class LayoutProcessor: def __init__(self, langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, process_initials_and_acronyms: bool = True, - process_units: bool | str | list[str] = True, - process_complex_units: bool | list[str] = True): + process_units: bool | str | 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. Паттерн для длинного (—) или среднего (–) тире, окруженного пробелами. # (?<=[\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)') @@ -62,59 +60,49 @@ 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. Паттерны для единиц измерения. + # Паттерн, описывающий "число" - арабское (включая дроби) ИЛИ римское. + # Для римских цифр используется \b, чтобы не спутать 'I' с частью слова. + self._NUMBER_PATTERN = r'(?:\d[\d.,]*|\b[IVXLCDM]+\b)' + + # 5. Паттерны для единиц измерения (простые и составные). self._post_units_pattern = None self._pre_units_pattern = None + self._complex_unit_pattern = None + self._math_unit_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]) - + all_post_units = list(DEFAULT_POST_UNITS) if isinstance(self.process_units, str): - # Если кастомные единицы заданы строкой, разбиваем по пробелам - custom_units = self.process_units.split() + all_post_units.extend(self.process_units.split()) elif isinstance(self.process_units, (list, tuple, set)): - # Если кастомные единицы заданы списком/кортежем/множеством, просто конвертируем в список - custom_units = list(self.process_units) + all_post_units.extend(self.process_units) - if custom_units: - post_units.extend(custom_units) + units_pattern_part = '' - 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 all_post_units: + sorted_units = sorted(all_post_units, key=len, reverse=True) + units_pattern_part = '|'.join(map(regex.escape, sorted_units)) - if pre_units: - # Используем негативный просмотр назад (? str: """Callback-функция для расстановки пробелов вокруг тире с учетом языка.""" @@ -152,15 +140,24 @@ class LayoutProcessor: 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._complex_unit_pattern: + # Шаг 1: "Склеиваем" все составные единицы с помощью временного разделителя. + # Цикл безопасен, так как мы заменяем пробелы на непробельный символ, и паттерн не найдет себя снова. + while self._complex_unit_pattern.search(processed_text): + processed_text = self._complex_unit_pattern.sub( + fr'\1.{CHAR_UNIT_SEPARATOR}\3', processed_text, count=1) + + if self._math_unit_pattern: + # processed_text = self._math_unit_pattern.sub(r'\1/\2', processed_text) + processed_text = self._math_unit_pattern.sub(r'\1\2\3', processed_text) + # И только потом привязываем простые единицы к числам 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) + # Шаг 2: Заменяем все временные разделители на правильную тонкую шпацию. + processed_text = processed_text.replace(CHAR_UNIT_SEPARATOR, CHAR_THIN_SP) return processed_text diff --git a/etpgrf/unbreakables.py b/etpgrf/unbreakables.py index 3ba7be3..2bd8b0e 100644 --- a/etpgrf/unbreakables.py +++ b/etpgrf/unbreakables.py @@ -47,7 +47,7 @@ _RU_OLD_POSTPOSITIVE_PARTICLES = frozenset([ ]) _EN_UNBREAKABLE_WORDS = frozenset([ - # 1-2 letter words + # 1-2 letter words (I - as pronoun) 'a', 'an', 'as', 'at', 'by', 'in', 'is', 'it', 'of', 'on', 'or', 'so', 'to', 'if', # 3-4 letter words 'for', 'from', 'into', 'that', 'then', 'they', 'this', 'was', 'were', 'what', 'when', 'with', diff --git a/tests/test_layout.py b/tests/test_layout.py index b2f096a..74c1d86 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -98,16 +98,34 @@ LAYOUT_TEST_CASES = [ # --- Единицы измерения (по умолчанию) --- ('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', "Доработки проекта стоили 100 тыс. руб.", f"Доработки проекта стоили 100{CHAR_NBSP}тыс.{CHAR_THIN_SP}руб."), ('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+en', "Инвестиции составили $ 2.5 млн.", f"Инвестиции составили ${CHAR_NBSP}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', "Дом 120 кв.м. / Участок 6 сот.", f"Дом 120{CHAR_NBSP}кв.м. / Участок 6{CHAR_NBSP}сот."), + # ('ru', "Гробик кладут в ямку 2 кв. м.", f"Гробик кладут в ямку 2 кв. м."), + ('ru', "IV-X вв.", f"IV-X{CHAR_NBSP}вв."), + ('ru', "IV в. н. э.", f"IV{CHAR_NBSP}в. н.{CHAR_THIN_SP}э."), + ('ru+en', "Хаммурапи (1792 - 1750 до н. э.)", + f"Хаммурапи (1792 - 1750 до н.{CHAR_THIN_SP}э.)"), + + # Составные и математические единицы + ('ru', "Площадь 120 кв. м.", f"Площадь 120{CHAR_NBSP}кв.{CHAR_THIN_SP}м."), + ('ru', "Площадь 130 кв.м.", f"Площадь 130{CHAR_NBSP}кв.{CHAR_THIN_SP}м."), + ('ru', f"Площадь 140 {CHAR_NBSP} кв.{CHAR_NBSP}м.", f"Площадь 140{CHAR_NBSP}кв.{CHAR_THIN_SP}м."), + ('ru', "Площадь 150 тыс. кв. км.", f"Площадь 150{CHAR_NBSP}тыс.{CHAR_THIN_SP}кв.{CHAR_THIN_SP}км."), + ('ru', "Скорость 90 км/ч", f"Скорость 90{CHAR_NBSP}км/ч"), + ('ru', "Скорость 90 км / ч", f"Скорость 90{CHAR_NBSP}км/ч"), + ('ru', "В 500 г. н. э.", f"В 500{CHAR_NBSP}г. н.{CHAR_THIN_SP}э."), + + # Сложные единицы (склеиваются тонкой шпацией, привязываются к числу неразрывным пробелом) + ('ru', "Дом 120 кв.м. / Участок 6 сот.", f"Дом 120{CHAR_NBSP}кв.{CHAR_THIN_SP}м. / Участок 6{CHAR_NBSP}сот."), + # ('ru', "Гробик кладут в ямку 2 кв. м.", f"Гробик кладут в ямку 2 кв. м."), + ('ru', "500 до н. э.", f"500 до н.{CHAR_THIN_SP}э."), + ('ru+en', "Хаммурапи (1792 - 1750 до н. э.)", f"Хаммурапи (1792 - 1750 до н.{CHAR_THIN_SP}э.)"), # --- Комбинированные случаи --- ('ru', f"Да — это так{CHAR_HELLIP} а может и нет. Счёт -10.", diff --git a/tests/test_unbreakables.py b/tests/test_unbreakables.py index 862a80d..d2bd833 100644 --- a/tests/test_unbreakables.py +++ b/tests/test_unbreakables.py @@ -1,6 +1,7 @@ # tests/test_unbreakables.py import pytest from etpgrf import Unbreakables +from etpgrf.config import CHAR_NBSP, CHAR_THIN_SP # Список русских слов, которые должны "приклеиваться" к следующему слову. @@ -26,7 +27,7 @@ def test_russian_prepositions_are_unbreakable(word): # Arrange (подготовка) unbreakables_ru = Unbreakables(langs='ru') input_text = f"Проверка {word} тестирование." - expected_text = f"Проверка {word}\u00A0тестирование." + expected_text = f"Проверка {word}{CHAR_NBSP}тестирование." # Act (действие, которое выполняем) actual_text = unbreakables_ru.process(input_text) # Assert (утверждение, что результат соответствует ожиданиям) @@ -48,7 +49,7 @@ def test_english_prepositions_are_unbreakable(word): # Arrange (подготовка) unbreakables_en = Unbreakables(langs='en') input_text = f"Training {word} test." - expected_text = f"Training {word}\u00A0test." + expected_text = f"Training {word}{CHAR_NBSP}test." # Act (действие, которое выполняем) actual_text = unbreakables_en.process(input_text) # Assert (утверждение, что результат соответствует ожиданиям) @@ -63,7 +64,7 @@ def test_mix_prepositions_are_unbreakable(): # Arrange (подготовка) unbreakables_mix = Unbreakables(langs='ru+en') input_text = f"Для the Guardian он написал блестящую статью." - expected_text = f"Для\u00A0the\u00A0Guardian он\u00A0написал блестящую статью." + expected_text = f"Для{CHAR_NBSP}the{CHAR_NBSP}Guardian он{CHAR_NBSP}написал блестящую статью." # Act (действие, которое выполняем) actual_text = unbreakables_mix.process(input_text) # Assert (утверждение, что результат соответствует ожиданиям) @@ -83,8 +84,22 @@ def test_russian_postpositive_particle(word): # Arrange unbreakables_ru = Unbreakables(langs='ru') input_text = f"Отчего {word} не поспать?" - expected_text = f"Отчего\u00A0{word} не\u00A0поспать?" + expected_text = f"Отчего{CHAR_NBSP}{word} не{CHAR_NBSP}поспать?" # Act actual_text = unbreakables_ru.process(input_text) # Assert assert actual_text == expected_text + + +# Тесты для проверки особых случаев в Unbreakables +UNBREAKABLES_SPECIAL_TEST_CASES = [ + ('ru', "до н.э.", f"до{CHAR_NBSP}н.э."), +] + + +@pytest.mark.parametrize("lang, input_string, expected_output", UNBREAKABLES_SPECIAL_TEST_CASES) +def test_layout_processor_with_options(lang, input_string, expected_output): + """Проверяет работу Unbreakables с особыми случаями. """ + processor = Unbreakables(langs=lang) + actual_output = processor.process(input_string) + assert actual_output == expected_output