From 4918645496a822a5f5eddffccbab12864c47cf12 Mon Sep 17 00:00:00 2001 From: erjemin Date: Mon, 25 Aug 2025 18:15:10 +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=BD=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=80=D1=8B=D0=B2=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D0=B5=D0=BB=D0=BE=D0=B2=20=D0=B2=D0=BE=D0=BA=D1=80=D1=83?= =?UTF-8?q?=D0=B3=20=D1=82=D0=B8=D1=80=D0=B5=20=D0=B8=20=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B8=D0=B0=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etpgrf/layout.py | 94 ++++++++++++++++++++++++++++++++++++ tests/test__typograph.py | 18 +++++++ tests/test_layout.py | 102 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 etpgrf/layout.py create mode 100644 tests/test_layout.py diff --git a/etpgrf/layout.py b/etpgrf/layout.py new file mode 100644 index 0000000..9962e29 --- /dev/null +++ b/etpgrf/layout.py @@ -0,0 +1,94 @@ +# etpgrf/layout.py +# Модуль для обработки тире, специальных символов и правил их компоновки. + +import regex +import logging +from etpgrf.config import LANG_RU, LANG_EN, CHAR_NBSP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP +from etpgrf.comutil import parse_and_validate_langs + +# -- + +# --- Настройки логирования --- +logger = logging.getLogger(__name__) + + +class LayoutProcessor: + """ + Обрабатывает тире, псевдографику (например, … -> © и тому подобные) и применяет + правила расстановки пробелов в зависимости от языка (для тире язык важен, так как. + Правила типографики различаются для русского и английского языков). + Предполагается, что на вход уже поступает текст с правильными типографскими + символами тире (— и –). + """ + + def __init__(self, + langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, + process_initials: bool = True): + self.langs = parse_and_validate_langs(langs) + self.main_lang = self.langs[0] if self.langs else LANG_RU + self.process_initials = process_initials + + # 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)') + + # 2. Паттерн для многоточия, за которым следует пробел и слово. + # Ставит неразрывный пробел после многоточия, чтобы не отрывать его от следующего слова. + # (?=[\p{L}\p{N}]) - просмотр вперед на букву или цифру. + self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}])') + + # 3. Паттерн для отрицательных чисел. + # Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел + # заменяет обычный). Это предотвращает "отлипание" знака от числа при переносе строки. + # (? str: + """Callback-функция для расстановки пробелов вокруг тире с учетом языка.""" + dash = match.group(1) # Получаем сам символ тире (— или –) + if self.main_lang == LANG_EN: + # Для английского языка — слитно, без пробелов. + return dash + # По умолчанию (и для русского) — отбивка пробелами. + return f'{CHAR_NBSP}{dash} ' + + + def process(self, text: str) -> str: + """Применяет правила компоновки к тексту.""" + # Порядок применения правил важен. + processed_text = text + + # 1. Обработка пробелов вокруг тире. + processed_text = self._dash_pattern.sub(self._replace_dash_spacing, processed_text) + + # 2. Обработка пробела после многоточия. + processed_text = self._ellipsis_pattern.sub(f'\\1{CHAR_NBSP}', processed_text) + + # 3. Обработка пробела перед отрицательными числами/минусом. + processed_text = self._negative_number_pattern.sub(f'{CHAR_NBSP}-\\1', processed_text) + + # 4. Обработка инициалов (если включено). + if self.process_initials: + # Сначала связываем фамилию с первым инициалом (Пушкин А. -> Пушкин{NBSP}А.) + processed_text = self._surname_initial_pattern.sub(f'\\1{CHAR_NBSP}', processed_text) + # Затем связываем инициалы между собой и с фамилией (А. С. Пушкин -> А.{NBSP}С.{NBSP}Пушкин) + processed_text = self._initial_pattern.sub(f'\\1{CHAR_NBSP}', processed_text) + + return processed_text diff --git a/tests/test__typograph.py b/tests/test__typograph.py index 54a5f54..7e31d5c 100644 --- a/tests/test__typograph.py +++ b/tests/test__typograph.py @@ -26,6 +26,24 @@ def test_typographer_disables_symbols_processor(): assert CHAR_COPY not in output_string # символ копирайта assert CHAR_ARROW_L not in output_string # стрелка + def test_typographer_disable_layout_processor(): + """ + Проверяет, что при layout=False модуль обработки компоновки отключается. + """ + # Arrange + input_string = "Текст — с тире, которое не должно измениться." + typo = Typographer(langs='ru', layout=False) + + # Act + output_string = typo.process(input_string) + + # Assert + # 1. Проверяем внутреннее состояние: модуль действительно отключен + assert typo.layout is None + # 2. Проверяем результат: пробелы вокруг тире НЕ появились в тексте. + # Это главная и самая надежная проверка. + assert CHAR_NBSP in output_string + def test_typographer_disables_quotes_processor(): """ diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..d2030a9 --- /dev/null +++ b/tests/test_layout.py @@ -0,0 +1,102 @@ +# tests/test _layout.py +# Тестирует модуль LayoutProcessor. Проверяет обработку тире и специальных символов в тексте. + +import pytest +from etpgrf.layout import LayoutProcessor +from etpgrf.config import CHAR_NBSP, CHAR_HELLIP + +LAYOUT_TEST_CASES = [ + # --- Длинное тире (—) для русского языка --- + ('ru', 'Слово — слово', f'Слово{CHAR_NBSP}— слово'), + ('ru', 'В начале — слово', f'В начале{CHAR_NBSP}— слово'), + ('ru', 'Слово — в конце.', f'Слово{CHAR_NBSP}— в конце.'), + ('ru-en', 'Слово — слово', f'Слово{CHAR_NBSP}— слово'), # Приоритет у 'ru' + + # --- Длинное тире (—) для английского языка --- + ('en', 'Word — word', 'Word—word'), + ('en', 'Start — word', 'Start—word'), + ('en', 'Word — end.', 'Word—end.'), + ('en-ru', 'Word — word', 'Word—word'), # Приоритет у 'en' + + # --- Среднее тире (–) также должно обрабатываться --- + ('ru', 'Слово – слово', f'Слово{CHAR_NBSP}– слово'), + ('en', 'Word – word', 'Word–word'), + +# --- Случаи тире рядом с пунктуацией и кавычками --- + ('ru', 'Да, — сказал он', f'Да,{CHAR_NBSP}— сказал он'), + ('en', 'Yes, — he said', 'Yes,—he said'), + ('ru', '«Слово», — сказал он', f'«Слово»,{CHAR_NBSP}— сказал он'), + ('en', '“Word,” — he said', '“Word,”—he said'), + ('ru', 'Слово! — воскликнул он.', f'Слово!{CHAR_NBSP}— воскликнул он.'), + ('en', 'Word! — he exclaimed.', 'Word!—he exclaimed.'), + # Тире после закрывающей кавычки + ('ru', '«Слово» — это важно.', f'«Слово»{CHAR_NBSP}— это важно.'), + ('en', '“Word” — is important.', '“Word”—is important.'), + + # --- Случаи, которые не должны меняться --- + ('ru', 'слово—слово', 'слово—слово'), # Уже слитно, не трогаем + ('en', 'word—word', 'word—word'), # Уже слитно, не трогаем + ('ru', "что-нибудь такое", "что-нибудь такое"), # Дефис, а не минус + ('ru', "что-нибудь такое", "что-нибудь такое"), # Минус + ('ru', "что-\nнибудь такое", "что-\nнибудь такое"), # Минус + ('ru', ' — слово', ' — слово'), # Пробел в начале строки, не трогаем + ('en', ' — word', ' — word'), # Пробел в начале строки, не трогаем + ('ru', 'слово — ', 'слово — '), # Пробел в конце строки, не трогаем + ('en', 'word — ', 'word — '), # Пробел в конце строки, не трогаем + ('ru', '1941–1945', '1941–1945'), # Диапазон без пробелов (через короткое тире) не трогаем + ('ru', '1941—1945', '1941—1945'), # Диапазон (через длинное тире) не трогаем + ('ru', '1941-1945', '1941-1945'), # Диапазон (через дефис/минус) не трогаем (будет преобразован в SymbolsProcessor) + ('ru', '1 — 2', '1 — 2'), # Диапазон с пробелами (цифры!) не трогаем + ('ru', '1 - 2', '1 - 2'), # Математика (минус) не трогаем + + # --- Многоточие --- + ('ru', f"Что это{CHAR_HELLIP} \n \t не знаю.", f"Что это{CHAR_HELLIP}{CHAR_NBSP}не знаю."), + ('ru', f"Что это{CHAR_HELLIP} не знаю.", f"Что это{CHAR_HELLIP}{CHAR_NBSP}не знаю."), + ('ru', f"Что это{CHAR_HELLIP} 123.", f"Что это{CHAR_HELLIP}{CHAR_NBSP}123."), + (f'ru', f"Что это{CHAR_HELLIP}", f"Что это{CHAR_HELLIP}"), # Не меняется в конце + (f'ru', f"Что это{CHAR_HELLIP} ", f"Что это{CHAR_HELLIP} "), # Не меняется в конце + (f'ru', f"1{CHAR_HELLIP}2{CHAR_HELLIP}3{CHAR_HELLIP}4{CHAR_HELLIP}5, я иду тебя искать!", + f"1{CHAR_HELLIP}2{CHAR_HELLIP}3{CHAR_HELLIP}4{CHAR_HELLIP}5, я иду тебя искать!"), + (f'ru', f"1{CHAR_HELLIP}2{CHAR_HELLIP}3{CHAR_HELLIP}4{CHAR_HELLIP}5{CHAR_HELLIP} я иду тебя искать!", + f"1{CHAR_HELLIP}2{CHAR_HELLIP}3{CHAR_HELLIP}4{CHAR_HELLIP}5{CHAR_HELLIP}{CHAR_NBSP}я иду тебя искать!"), + + # --- Отрицательные числа --- + ('ru', "температура -10 градусов", f"температура{CHAR_NBSP}-10 градусов"), + ('ru', "от -5 до +5", f"от{CHAR_NBSP}-5 до +5"), + ('ru', "в диапазоне ( -10, 10)", f"в диапазоне ({CHAR_NBSP}-10, 10)"), # Пробел после скобки + + # --- Случаи, которые не должны меняться (отрицательные числа) --- + ('ru', "10 - 5 = 5", "10 - 5 = 5"), # Бинарный минус не трогаем + ('ru', "слово-10", "слово-10"), # Дефис, а не минус + ('ru', "1-2-3-4-5, я иду тебя искать", "1-2-3-4-5, я иду тебя искать"), # Дефис, а не минус + + # --- Инициалы (должны обрабатываться по умолчанию) --- + ('ru', "А. С. Пушкин", f"А.{CHAR_NBSP}С.{CHAR_NBSP}Пушкин"), + ('ru', "А.С. Пушкин", f"А.С.{CHAR_NBSP}Пушкин"), + ('ru', "Пушкин А. С.", f"Пушкин{CHAR_NBSP}А.{CHAR_NBSP}С."), + ('ru', "Пушкин А.С.", f"Пушкин{CHAR_NBSP}А.С."), + ('en', "J. R. R. Tolkien", f"J.{CHAR_NBSP}R.{CHAR_NBSP}R.{CHAR_NBSP}Tolkien"), + ('en', "J.R.R. Tolkien", f"J.R.R.{CHAR_NBSP}Tolkien"), + ('en', "Tolkien J. R. R.", f"Tolkien{CHAR_NBSP}J.{CHAR_NBSP}R.{CHAR_NBSP}R."), + ('en', "Tolkien J.R.R.", f"Tolkien{CHAR_NBSP}J.R.R."), + ('ru', "Это был В. Высоцкий.", f"Это был В.{CHAR_NBSP}Высоцкий."), + ('ru', "Высоцкий В. С. был гением.", f"Высоцкий{CHAR_NBSP}В.{CHAR_NBSP}С. был гением."), + + # --- Инициалы (проверка отключения опции) --- + # ('ru', "А. С. Пушкин", "А. С. Пушкин", False), + # ('ru', "Пушкин А. С.", "Пушкин А. С.", False), + + # --- Комбинированные случаи --- + ('ru', f"Да — это так{CHAR_HELLIP} а может и нет. Счёт -10.", + f"Да{CHAR_NBSP}— это так{CHAR_HELLIP}{CHAR_NBSP}а может и нет. Счёт{CHAR_NBSP}-10."), + ('ru', f"По мнению А. С. Пушкина — это...", f"По мнению А.{CHAR_NBSP}С.{CHAR_NBSP}Пушкина{CHAR_NBSP}— это..."), + +] + + +@pytest.mark.parametrize("lang, input_string, expected_output", LAYOUT_TEST_CASES) +def test_layout_processor(lang, input_string, expected_output): + """Проверяет работу LayoutProcessor в изоляции.""" + processor = LayoutProcessor(langs=lang) + actual_output = processor.process(input_string) + assert actual_output == expected_output \ No newline at end of file