diff --git a/etpgrf/symbols.py b/etpgrf/symbols.py new file mode 100644 index 0000000..b2816a4 --- /dev/null +++ b/etpgrf/symbols.py @@ -0,0 +1,50 @@ +# etpgrf/symbols.py +# Модуль для преобразования псевдографики в правильные типографские символы. + +import regex +import logging +from .config import CHAR_NDASH, STR_TO_SYMBOL_REPLACEMENTS + +logger = logging.getLogger(__name__) + + +class SymbolsProcessor: + """ + Преобразует ASCII-последовательности (псевдографику) в семантически + верные Unicode-символы. Работает на раннем этапе, до расстановки пробелов. + """ + + def __init__(self): + # Для сложных замен, требующих анализа контекста (например, диапазоны), + # по-прежнему используем регулярные выражения. + # Паттерн для диапазонов: цифра-дефис-цифра -> цифра–цифра (среднее тире). + # Обрабатываем арабские и римские цифры. + self._range_pattern = regex.compile(pattern=r'(\d)-(\d)|([IVXLCDM]+)-([IVXLCDM]+)', flags=regex.IGNORECASE) + + logger.debug("SymbolsProcessor `__init__`") + + def _replace_range(self, match: regex.Match) -> str: + # Паттерн имеет две группы: (\d)-(\d) ИЛИ ([IVX...])-([IVX...]) + if match.group(1) is not None: # Арабские цифры + return f'{match.group(1)}{CHAR_NDASH}{match.group(2)}' + if match.group(3) is not None: # Римские цифры + return f'{match.group(3)}{CHAR_NDASH}{match.group(4)}' + return match.group(0) # На всякий случай + + + def process(self, text: str) -> str: + # Шаг 1: Выполняем простые замены из списка `STR_TO_SYMBOL_REPLACEMENTS` (см. config.py). + # Этот шаг должен идти первым, чтобы пользователь мог, например, + # использовать '---' в диапазоне '1---5', если ему это нужно. + # В таком случае '---' заменится на '—', и правило для диапазонов + # с дефисом уже не сработает. + processed_text = text + for old, new in STR_TO_SYMBOL_REPLACEMENTS: + processed_text = processed_text.replace(old, new) + + # Шаг 2: Обрабатываем диапазоны с помощью регулярного выражения. + # Эта замена более специфична и требует контекста (цифры вокруг дефиса). + processed_text = self._range_pattern.sub(self._replace_range, processed_text) + + return processed_text + diff --git a/etpgrf/typograph.py b/etpgrf/typograph.py index 3523017..0090f21 100644 --- a/etpgrf/typograph.py +++ b/etpgrf/typograph.py @@ -8,6 +8,8 @@ from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs from etpgrf.hyphenation import Hyphenator from etpgrf.unbreakables import Unbreakables from etpgrf.quotes import QuotesProcessor +from etpgrf.layout import LayoutProcessor +from etpgrf.symbols import SymbolsProcessor from etpgrf.codec import decode_to_unicode, encode_from_unicode @@ -24,6 +26,8 @@ class Typographer: hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов quotes: QuotesProcessor | bool | None = True, # Правила для обработки кавычек + layout: LayoutProcessor | bool | None = True, # Правила для тире и спецсимволов + symbols: SymbolsProcessor | bool | None = True, # Правила для псевдографики # ... другие модули правил ... ): @@ -38,7 +42,14 @@ class Typographer: "HTML не будет обработан. Установите ее: `pip install beautifulsoup4`") self.process_html = False - # D. --- Инициализация правила переноса --- + # D. --- Конфигурация правил для псевдографики --- + self.symbols: SymbolsProcessor | None = None + if symbols is True or symbols is None: + self.symbols = SymbolsProcessor() + elif isinstance(symbols, SymbolsProcessor): + self.symbols = symbols + + # E. --- Инициализация правила переноса --- # Предпосылка: если вызвали типограф, значит, мы хотим обрабатывать текст и переносы тоже нужно расставлять. # А для специальных случаев, когда переносы не нужны, пусть не ленятся и делают `hyphenation=False`. self.hyphenation: Hyphenator | None = None @@ -49,7 +60,7 @@ class Typographer: # C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode) self.hyphenation = hyphenation - # E. --- Конфигурация правил неразрывных слов --- + # F. --- Конфигурация правил неразрывных слов --- self.unbreakables: Unbreakables | None = None if unbreakables is True or unbreakables is None: # D1. Создаем новый объект Unbreakables с заданными языками и режимом, а все остальное по умолчанию @@ -58,20 +69,29 @@ class Typographer: # D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode) self.unbreakables = unbreakables - # F. --- Конфигурация правил обработки кавычек --- + # G. --- Конфигурация правил обработки кавычек --- self.quotes: QuotesProcessor | None = None if quotes is True or quotes is None: self.quotes = QuotesProcessor(langs=self.langs) elif isinstance(quotes, QuotesProcessor): self.quotes = quotes - # G. --- Конфигурация других правил--- + # H. --- Конфигурация правил для тире и спецсимволов --- + self.layout: LayoutProcessor | None = None + if layout is True or layout is None: + self.layout = LayoutProcessor(langs=self.langs) + elif isinstance(layout, LayoutProcessor): + self.layout = layout + + # I. --- Конфигурация других правил--- # Z. --- Логирование инициализации --- logger.debug(f"Typographer `__init__`: langs: {self.langs}, mode: {self.mode}, " f"hyphenation: {self.hyphenation is not None}, " f"unbreakables: {self.unbreakables is not None}, " f"quotes: {self.quotes is not None}, " + f"layout: {self.layout is not None}, " + f"symbols: {self.symbols is not None}, " f"process_html: {self.process_html}") @@ -85,8 +105,12 @@ class Typographer: # processed_text = text # ВРЕМЕННО: используем текст как есть # Шаг 2: Применяем правила к чистому Unicode-тексту + if self.symbols is not None: + processed_text = self.symbols.process(processed_text) if self.quotes is not None: processed_text = self.quotes.process(processed_text) + if self.layout is not None: + processed_text = self.layout.process(processed_text) if self.unbreakables is not None: processed_text = self.unbreakables.process(processed_text) if self.hyphenation is not None: @@ -135,4 +159,3 @@ class Typographer: processed = self._process_text_node(text) # Возвращаем return encode_from_unicode(processed, self.mode) - diff --git a/tests/test__typograph.py b/tests/test__typograph.py index 157652e..54a5f54 100644 --- a/tests/test__typograph.py +++ b/tests/test__typograph.py @@ -3,7 +3,28 @@ import pytest from etpgrf.typograph import Typographer -from etpgrf.config import SHY_CHAR, NBSP_CHAR +from etpgrf.config import CHAR_SHY, CHAR_NBSP, CHAR_COPY, CHAR_MDASH, CHAR_ARROW_L + + +def test_typographer_disables_symbols_processor(): + """ + Проверяет, что при symbols=False модуль обработки символов отключается. + """ + # Arrange + input_string = "Текст --- с символами (c) и стрелками A --> B." + typo = Typographer(langs='ru-en', symbols=False) + + # Act + output_string = typo.process(input_string) + + # Assert + # 1. Проверяем внутреннее состояние: модуль действительно отключен + assert typo.symbols is None + # 2. Проверяем результат: символы НЕ появились в тексте. + # Это главная и самая надежная проверка. + assert CHAR_MDASH not in output_string # длинное тире + assert CHAR_COPY not in output_string # символ копирайта + assert CHAR_ARROW_L not in output_string # стрелка def test_typographer_disables_quotes_processor(): @@ -42,7 +63,7 @@ def test_typographer_disables_hyphenation(): # 1. Проверяем внутреннее состояние assert typo.hyphenation is None # 2. Проверяем результат: в тексте не появилось символов мягкого переноса - assert SHY_CHAR not in output_string + assert CHAR_SHY not in output_string def test_typographer_disables_unbreakables(): @@ -60,4 +81,4 @@ def test_typographer_disables_unbreakables(): # 1. Проверяем внутреннее состояние assert typo.unbreakables is None # 2. Проверяем результат: в тексте не появилось неразрывных пробелов - assert NBSP_CHAR not in output_string \ No newline at end of file + assert CHAR_NBSP not in output_string \ No newline at end of file diff --git a/tests/test_symbols.py b/tests/test_symbols.py new file mode 100644 index 0000000..b025ed3 --- /dev/null +++ b/tests/test_symbols.py @@ -0,0 +1,79 @@ +# tests/test_symbols.py +# Тестирует модуль SymbolsProcessor. Проверяет обработку псевдографики в тексте (тире, стрелки, спецсимволы). + +import pytest +from etpgrf.symbols import SymbolsProcessor +from etpgrf.config import ( + CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP, CHAR_COPY, CHAR_REG, CHAR_COPYP, + CHAR_TRADE, CHAR_AP, CHAR_ARROW_L, CHAR_ARROW_R, CHAR_ARROW_LR, + CHAR_ARROW_L_DOUBLE, CHAR_ARROW_R_DOUBLE, CHAR_ARROW_LR_DOUBLE, + CHAR_ARROW_L_LONG_DOUBLE, CHAR_ARROW_R_LONG_DOUBLE, CHAR_ARROW_LR_LONG_DOUBLE, +) + +SYMBOLS_TEST_CASES = [ + # 1. --- Простые замены из STR_TO_SYMBOL_REPLACEMENTS --- + # Тире и многоточие + ("Текст --- текст", f"Текст {CHAR_MDASH} текст"), + ("Текст---текст", f"Текст{CHAR_MDASH}текст"), + ("Текст -- текст", f"Текст {CHAR_NDASH} текст"), + ("Текст--текст", f"Текст{CHAR_NDASH}текст"), + ("Текст...", f"Текст{CHAR_HELLIP}"), + + # Спецсимволы + ("(c) 2025 Компания правообладатель", f"{CHAR_COPY} 2025 Компания правообладатель"), + ("(C) 2025 Компания правообладатель", f"{CHAR_COPY} 2025 Компания правообладатель"), + ("Товар(r)", f"Товар{CHAR_REG}"), + ("Товар(R)", f"Товар{CHAR_REG}"), + ("(p) 2025 Звукозапись", f"{CHAR_COPYP} 2025 Звукозапись"), + ("(P) 2025 Звукозапись", f"{CHAR_COPYP} 2025 Звукозапись"), + ("Продукт(tm)", f"Продукт{CHAR_TRADE}"), + ("Продукт(TM)", f"Продукт{CHAR_TRADE}"), + + + # Стрелки + ("A <--> B", f"A {CHAR_ARROW_LR} B"), + ("A <-- B", f"A {CHAR_ARROW_L} B"), + ("A --> B", f"A {CHAR_ARROW_R} B"), + ("A <==> B", f"A {CHAR_ARROW_LR_DOUBLE} B"), + ("A <== B", f"A {CHAR_ARROW_L_DOUBLE} B"), + ("A ==> B", f"A {CHAR_ARROW_R_DOUBLE} B"), + ("A <===> B", f"A {CHAR_ARROW_LR_LONG_DOUBLE} B"), + ("A <=== B", f"A {CHAR_ARROW_L_LONG_DOUBLE} B"), + ("A ===> B", f"A {CHAR_ARROW_R_LONG_DOUBLE} B"), + + # Математические + ("a ~= b", f"a {CHAR_AP} b"), + + # 2. --- Диапазоны чисел (обработка дефиса после простых замен) --- + ("1941-1945 гг.", f"1941{CHAR_NDASH}1945 гг."), + ("страницы 10-12", f"страницы 10{CHAR_NDASH}12"), + ("I-V век", f"I{CHAR_NDASH}V век"), + ("ix-vi до н.э.", f"ix{CHAR_NDASH}vi до н.э."), + + # 3. --- Комбинированные и пограничные случаи --- + # Сначала сработает простая замена '---' -> '—', потом диапазон '1-5' -> '1–5' + ("1-5 --- это диапазон (c)", f"1{CHAR_NDASH}5 {CHAR_MDASH} это диапазон {CHAR_COPY}"), + # Простая замена '--' -> '–' не должна мешать диапазону '1-5' + ("1-5 -- это диапазон", f"1{CHAR_NDASH}5 {CHAR_NDASH} это диапазон"), + ("-10 -- -5 -- это диапазон", f"-10 {CHAR_NDASH} -5 – это диапазон"), + # Проверка порядка: '---' должно замениться до '--' + ("A---B--C", f"A{CHAR_MDASH}B{CHAR_NDASH}C"), + # Проверка, что замена не жадная и заменяет все вхождения + ("далее...", f"далее{CHAR_HELLIP}"), + ("...и...и...", f"{CHAR_HELLIP}и{CHAR_HELLIP}и{CHAR_HELLIP}"), + ("A-->B-->C", f"A{CHAR_ARROW_R}B{CHAR_ARROW_R}C"), + ("A<--B<--C", f"A{CHAR_ARROW_L}B{CHAR_ARROW_L}C"), + ("A<-->B<-->C", f"A{CHAR_ARROW_LR}B{CHAR_ARROW_LR}C"), + ("A<==>B<==>C", f"A{CHAR_ARROW_LR_DOUBLE}B{CHAR_ARROW_LR_DOUBLE}C"), + ("A<===>B<===>C", f"A{CHAR_ARROW_LR_LONG_DOUBLE}B{CHAR_ARROW_LR_LONG_DOUBLE}C"), + # Очень длинные, комбинированные стрелки + ("A <----> B", f"A {CHAR_ARROW_L}{CHAR_ARROW_R} B"), + ("A <======> B", f"A {CHAR_ARROW_L_LONG_DOUBLE}{CHAR_ARROW_R_LONG_DOUBLE} B"), +] + + +@pytest.mark.parametrize("input_string, expected_output", SYMBOLS_TEST_CASES) +def test_symbols_processor(input_string, expected_output): + processor = SymbolsProcessor() + actual_output = processor.process(input_string) + assert actual_output == expected_output \ No newline at end of file