From b4248db063836bf24b854d1d5bcee1d5a60b1e2d Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 17 Aug 2025 01:12:09 +0300 Subject: [PATCH] =?UTF-8?q?add:=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BA=D0=B0=D0=B2=D1=8B=D1=87=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 +++++++++++++--- etpgrf/quotes.py | 74 ++++++++++++++++++++++++++++++++++++++++ etpgrf/typograph.py | 16 +++++++-- tests/test__typograph.py | 63 ++++++++++++++++++++++++++++++++++ tests/test_quotes.py | 74 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 etpgrf/quotes.py create mode 100644 tests/test__typograph.py create mode 100644 tests/test_quotes.py diff --git a/README.md b/README.md index 01d1bff..e1b7419 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,8 @@ result = typo_mixed_mode.process(text="Этот текст будет обраб Настройки по умолчанию для переноса слов (в `etpgrf.defaults`): * Длина слова которое не подлежит переносам (`MAX_UNHYPHENATED_LEN`) — 12 символов. -* Длина части слова, которое недопустимо переносить или оставлять на строке ("хвост", "сироты") (`MIN_TAIL_LEN`) — 7 символ +* Длина части слова, которое недопустимо переносить или оставлять на строке ("хвост", "сироты") + (`MIN_TAIL_LEN`) — 5 символов Управление этими параметрами осуществляется через переопределение. Например: ```python @@ -114,9 +115,30 @@ result = typo_hyp.process(text="Электрофоретическое иссл ### Кавычки +В текстах кавычки бывают двух видов: «ёлочки» (для русского языка) и “лапки” (для английского языка). В типографе +реализована автоматическая замена кавычек на соответствующие типографские символы в зависимости от языка текста. -Теперь хочу обсудить модуль работы с кавычками. Большинство изсестных мне типографов разибают праные кавчки и превращают их в спец-сиволы. Мне каежтся это неверно, ведь ковычки моут быть не парными и вложенность может быть произвольной. Мне кажется нужен другой подход. Обнаруживать только конструкции `"слово` или `слово"` и преобразовывать только их. Соответсвнно в елочки или лапки в зависимоати от языка. При этом если уже в тескте есть лапки, елочки или другие типы кавычек — ничего не менять (автор текстьа сам знает, какие кавычки лучшк). Кавычки окруженные пробелами или буквать с дрвух сторон превоащать в " ... +Большинство типографов при обработке кавычек находят парные (и определяют вложенность). В etpgrf же реализован +другой подход. Он ищет и обрабатывает кавычки, которые находятся рядом со словами. То есть какие-то буквы следуют +слева или справа от кавычки. -Как считаешь, это рабочий подход?? +Преобразование рядом с цифрами (например, когда обозначаются дюймы (`17"`) или секунды (`3' 25"`)) не производится. Также +не обрабатываются кавычки окруженные пробелами. Все кавычки которые в исходном тексте уже были оформлены в виде +«ёлочек» или “лапок” — тоже не обрабатываются. -1.Надежность (Robustness): Вы правильно заметили, что символ " используется не только для кавычек. Это могут быть дюймы (17"), секунды (3' 25"), обозначения в коде (print("Hello")) и т.д. Ваш метод, который ищет кавычку рядом со словом ("слово или слово"), с гораздо меньшей вероятностью сломает такие конструкции.2.Уважение к авторскому замыслу: Правило "если уже есть ёлочки или лапки — ничего не менять" — это золотой стандарт. Типограф не должен "исправлять" то, что уже было отформатировано автором вручную. Это предотвращает двойное преобразование и сохраняет особые случаи, задуманные автором.3.Простота и производительность: Вместо сложного и медленного парсера, который отслеживает уровни вложенности (и часто ошибается), ваш подход можно реализовать с помощью нескольких быстрых регулярных выражений.4.Безопасность: Идея превращать оставшиеся "одинокие" кавычки в " — это отличный механизм защиты. Он гарантирует, что на выходе не останется "сырых" кавычек, которые могут сломать HTML-разметку. +ВАЖНО1: По правилам орфографии перед закрывающей кавычкой разрешены только определенные знаки препинания: +вопросительный (?), восклицательный (!) знаки и многоточие (…). Такие конструкции используются для цитат. Это учтено +в etpgrf, и кавычки будут обработаны: `Она воскликнула: "Какая красота!"` будет преобразовано в `Она воскликнула: + «Какая красота!»`. В неправильны конструкциях (например, `"Какая красота."`) закрывающая кавычка не будет обработана. + +ВАЖНО2: Если в настройке типографа указано несколько языков (`langs='ru+en'`), то кавычки будут преобразованы по правилам +для языка который идет первым в списке. Например, для `langs='ru+en'` кавычки будут преобразованы в «ёлочки», + +Если при типорафировании преобразование не требуется, то можно обработку кавычек можно отключить с помощью +параметра `quotes=False`: +```python +# Задаем конфигурацию типографа без кавычек +typo_no_quotes = etpgrf.Typographer(langs='ru', quotes=False) +# Обработка текста без кавычек +result = typo_no_quotes.process(text='Этот "текст" будет обработан без кавычек.') +``` diff --git a/etpgrf/quotes.py b/etpgrf/quotes.py new file mode 100644 index 0000000..3d6ad19 --- /dev/null +++ b/etpgrf/quotes.py @@ -0,0 +1,74 @@ +# etpgrf/quotes.py +# Модуль для расстановки кавычек в тексте + +import regex +import logging +from .config import LANG_RU, LANG_EN, RU_QUOT1_OPEN, RU_QUOT1_CLOSE, EN_QUOT1_OPEN, EN_QUOT1_CLOSE, \ + RU_QUOT2_OPEN, RU_QUOT2_CLOSE, EN_QUOT2_OPEN, EN_QUOT2_CLOSE +from .comutil import parse_and_validate_langs + +# --- Настройки логирования --- +logger = logging.getLogger(__name__) + +# Определяем стили кавычек для разных языков +# Формат: (('открывающая_ур1', 'закрывающая_ур1'), ('открывающая_ур2', 'закрывающая_ур2')) +_QUOTE_STYLES = { + LANG_RU: ((RU_QUOT1_OPEN, RU_QUOT1_CLOSE), (RU_QUOT2_OPEN, RU_QUOT2_CLOSE)), + LANG_EN: ((EN_QUOT1_OPEN, EN_QUOT1_CLOSE), (EN_QUOT2_OPEN, EN_QUOT2_CLOSE)), +} + + +class QuotesProcessor: + """ + Обрабатывает прямые кавычки ("), превращая их в типографские + в зависимости от языка и контекста. + """ + + def __init__(self, langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None): + self.langs = parse_and_validate_langs(langs) + + # Выбираем стиль кавычек на основе первого поддерживаемого языка + self.open_quote = '"' + self.close_quote = '"' + + for lang in self.langs: + if lang in _QUOTE_STYLES: + self.open_quote = _QUOTE_STYLES[lang][0][0] + self.close_quote = _QUOTE_STYLES[lang][0][1] + logger.debug( + f"QuotesProcessor: выбран стиль кавычек для языка '{lang}': '{self.open_quote}...{self.close_quote}'") + break # Используем стиль первого найденного языка + + # Паттерн для открывающей кавычки: " перед буквой/цифрой, + # которой предшествует пробел, начало строки или открывающая скобка. + # (?<=^|\s|[\(\[„\"‘\']) - "просмотр назад" на начало строки... ищет пробел \s или знак из набора ([„"‘' + # (?=\p{L}) - "просмотр вперед" на букву \p{L} (но не цифру). + self._opening_quote_pattern = regex.compile(r'(?<=^|\s|[\(\[„\"‘\'])\"(?=\p{L})') + # self._opening_quote_pattern = regex.compile(r'(?<=^|\s|\p{Pi}|["\'\(\)])\"(?=\p{L})') + + # Паттерн для закрывающей кавычки: " после буквы/цифры, + # за которой следует пробел, пунктуация или конец строки. + # (?<=\p{L}|[?!…]) - "просмотр назад" на букву или ?!… + # (?=\s|[.,;:!?\)\"»”’]|\Z) - "просмотр вперед" на пробел, пунктуацию или конец строки (\Z). + self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…])\"(?=\s|[\.,;:!?\)\]»”’\"\']|\Z)') + # self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|\p{N})\"(?=\s|[\.,;:!?\)\"»”’]|\Z)') + # self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…])\"(?=\s|[\p{Po}\p{Pf}"\']|\Z)') + + def process(self, text: str) -> str: + """ + Применяет правила замены кавычек к тексту. + """ + if '"' not in text: + # Быстрый выход, если в тексте нет прямых кавычек + return text + + processed_text = text + + # 1. Заменяем открывающие кавычки + # Заменяем только найденную кавычку, так как просмотр вперед не захватывает символы. + processed_text = self._opening_quote_pattern.sub(self.open_quote, processed_text) + + # 2. Заменяем закрывающие кавычки + processed_text = self._closing_quote_pattern.sub(self.close_quote, processed_text) + + return processed_text \ No newline at end of file diff --git a/etpgrf/typograph.py b/etpgrf/typograph.py index 244c381..3523017 100644 --- a/etpgrf/typograph.py +++ b/etpgrf/typograph.py @@ -7,6 +7,7 @@ except ImportError: 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.codec import decode_to_unicode, encode_from_unicode @@ -22,6 +23,7 @@ class Typographer: process_html: bool = False, # Флаг обработки HTML-тегов hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов + quotes: QuotesProcessor | bool | None = True, # Правила для обработки кавычек # ... другие модули правил ... ): @@ -56,12 +58,20 @@ class Typographer: # D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode) self.unbreakables = unbreakables - # F. --- Конфигурация других правил--- + # F. --- Конфигурация правил обработки кавычек --- + 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. --- Конфигурация других правил--- # 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"unbreakables: {self.unbreakables is not None}, " + f"quotes: {self.quotes is not None}, " f"process_html: {self.process_html}") @@ -75,6 +85,8 @@ class Typographer: # processed_text = text # ВРЕМЕННО: используем текст как есть # Шаг 2: Применяем правила к чистому Unicode-тексту + if self.quotes is not None: + processed_text = self.quotes.process(processed_text) if self.unbreakables is not None: processed_text = self.unbreakables.process(processed_text) if self.hyphenation is not None: diff --git a/tests/test__typograph.py b/tests/test__typograph.py new file mode 100644 index 0000000..157652e --- /dev/null +++ b/tests/test__typograph.py @@ -0,0 +1,63 @@ +# tests/test__typograph.py +# Тесты для модуля Typographer. Проверяют отключение модулей обработки текста. + +import pytest +from etpgrf.typograph import Typographer +from etpgrf.config import SHY_CHAR, NBSP_CHAR + + +def test_typographer_disables_quotes_processor(): + """ + Проверяет, что при quotes=False модуль обработки кавычек отключается. + """ + # Arrange + input_string = 'Текст "в кавычках", который не должен измениться.' + # Создаем два экземпляра: с None и с False для полной проверки + typo_false = Typographer(langs='ru', quotes=False) + + # Act + output_false = typo_false.process(input_string) + + # Assert + # 1. Проверяем внутреннее состояние: модуль действительно отключен + assert typo_false.quotes is None + + # 2. Проверяем результат: типографские кавычки НЕ появились в тексте. + # Это главная и самая надежная проверка. + assert '«' not in output_false and '»' not in output_false + + +def test_typographer_disables_hyphenation(): + """ + Проверяет, что при hyphenation=False модуль переносов отключается. + """ + # Arrange + input_string = "Длинноесловодляпроверкипереносов" + typo = Typographer(langs='ru', hyphenation=False) + + # Act + output_string = typo.process(input_string) + + # Assert + # 1. Проверяем внутреннее состояние + assert typo.hyphenation is None + # 2. Проверяем результат: в тексте не появилось символов мягкого переноса + assert SHY_CHAR not in output_string + + +def test_typographer_disables_unbreakables(): + """ + Проверяет, что при unbreakables=False модуль неразрывных пробелов отключается. + """ + # Arrange + input_string = "Он сказал: в дом вошла она." + typo = Typographer(langs='ru', unbreakables=False) + + # Act + output_string = typo.process(input_string) + + # Assert + # 1. Проверяем внутреннее состояние + assert typo.unbreakables is None + # 2. Проверяем результат: в тексте не появилось неразрывных пробелов + assert NBSP_CHAR not in output_string \ No newline at end of file diff --git a/tests/test_quotes.py b/tests/test_quotes.py new file mode 100644 index 0000000..01f616b --- /dev/null +++ b/tests/test_quotes.py @@ -0,0 +1,74 @@ +# tests/test_quotes.py +# Тесты для модуля QuotesProcessor. Проверяют обработку кавычек в тексте. + +import pytest +from etpgrf.quotes import QuotesProcessor + +# Набор тестовых случаев в формате: +# (язык, входная_строка, ожидаемый_результат) +QUOTES_TEST_CASES = [ + # --- Базовые случаи --- + ('ru', 'Текст \"в кавычках\"', 'Текст «в кавычках»'), + ('en', 'Text \"in quotes\"', 'Text “in quotes”'), + ('ru', '(\"в скобках\")', '(«в скобках»)'), + + # --- Случаи, которые не должны меняться --- + ('ru', 'Текст без кавычек.', 'Текст без кавычек.'), + ('ru', 'Уже есть «ёлочки» и „лапки“', 'Уже есть «ёлочки» и „лапки“'), + ('en', 'Already have “curly” and ‘single’ quotes', 'Already have “curly” and ‘single’ quotes'), + + # --- Сложные случаи и исключения (QuotesProcessor должен их игнорировать) --- + ('ru', 'Размер экрана 17"', 'Размер экрана 17"'), + ('en', 'Screen size 17"', 'Screen size 17"'), + ('ru', 'Код print("Hello, World!")', 'Код print(«Hello, World!»)'), # Скобки помогают определить контекст + ('ru', ' " одинокая кавычка " ', ' " одинокая кавычка " '), # Кавычки окружены пробелами + ('ru', 'слово"вплотную', 'слово"вплотную'), + ('ru', 'вплотную"слово', 'вплотную"слово'), + + # --- Вложенность и несколько пар --- + ('ru', 'Он сказал: "Привет, мир!"', 'Он сказал: «Привет, мир!»'), + ('ru', 'Она ответила: "И тебе "привет"!"', 'Она ответила: «И тебе «привет»!»'), + + # --- Обработка пунктуации --- + # Точка СНАРУЖИ кавычек - правильная пунктуация, корректно обрабатывается + ('ru', 'Текст "в кавычках".', 'Текст «в кавычках».'), + ('en', '"Word".', '“Word”.'), + # Точка ВНУТРИ кавычек - неправильная пунктуация, закрывающая кавычка не обрабатывается (согласно README) + ('ru', 'Текст "в кавычках."', 'Текст «в кавычках."'), + ('en', '"Word."', '“Word."'), + # Знаки ?, !, … ВНУТРИ кавычек - правильная пунктуация, корректно обрабатывается + ('ru', '"Слово?"', '«Слово?»'), + ('en', '"Word?"', '“Word?”'), + ('ru', '"Слово!"', '«Слово!»'), + ('en', '"Word!"', '“Word!”'), + ('ru', '"Слово…"', '«Слово…»'), + ('en', '"Word…"', '“Word…”'), + # Запятая СНАРУЖИ кавычек + ('ru', '"Слово", - сказал он.', '«Слово», - сказал он.'), + # Цифры в кавычках не обрабатываются + ('ru', 'Цифры "123" в кавычках', 'Цифры "123" в кавычках'), + ('en', 'Numbers "123" in quotes', 'Numbers "123" in quotes'), + + # --- Языки и приоритеты --- + (None, 'Текст "в кавычках"', 'Текст «в кавычках»'), # Язык не указан -> используется DEFAULT_LANGS[0] ('ru') + ('ru-en', 'Текст "в кавычках"', 'Текст «в кавычках»'), # 'ru' первый в списке -> используются русские кавычки + ('en-ru', 'Text "in quotes"', 'Text “in quotes”'), # 'en' первый в списке -> используются английские кавычки +] + + +@pytest.mark.parametrize("lang, input_string, expected_output", QUOTES_TEST_CASES) +def test_quotes_processor(lang, input_string, expected_output): + """ + Проверяет работу QuotesProcessor в изоляции. + """ + # Arrange (подготовка) + # Создаем экземпляр процессора только для этого теста + processor = QuotesProcessor(langs=lang) + + # Act (действие) + # Выполняем обработку + actual_output = processor.process(input_string) + + # Assert (проверка) + # Сравниваем результат с ожидаемым + assert actual_output == expected_output \ No newline at end of file