add: обработка кавычек

This commit is contained in:
2025-08-17 01:12:09 +03:00
parent 73fa57e47e
commit b4248db063
5 changed files with 251 additions and 6 deletions

View File

@@ -87,7 +87,8 @@ result = typo_mixed_mode.process(text="Этот текст будет обраб
Настройки по умолчанию для переноса слов (в `etpgrf.defaults`): Настройки по умолчанию для переноса слов (в `etpgrf.defaults`):
* Длина слова которое не подлежит переносам (`MAX_UNHYPHENATED_LEN`) — 12 символов. * Длина слова которое не подлежит переносам (`MAX_UNHYPHENATED_LEN`) — 12 символов.
* Длина части слова, которое недопустимо переносить или оставлять на строке ("хвост", "сироты") (`MIN_TAIL_LEN`) — 7 символ * Длина части слова, которое недопустимо переносить или оставлять на строке ("хвост", "сироты")
(`MIN_TAIL_LEN`) — 5 символов
Управление этими параметрами осуществляется через переопределение. Например: Управление этими параметрами осуществляется через переопределение. Например:
```python ```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='Этот "текст" будет обработан без кавычек.')
```

74
etpgrf/quotes.py Normal file
View File

@@ -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

View File

@@ -7,6 +7,7 @@ except ImportError:
from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs
from etpgrf.hyphenation import Hyphenator from etpgrf.hyphenation import Hyphenator
from etpgrf.unbreakables import Unbreakables from etpgrf.unbreakables import Unbreakables
from etpgrf.quotes import QuotesProcessor
from etpgrf.codec import decode_to_unicode, encode_from_unicode from etpgrf.codec import decode_to_unicode, encode_from_unicode
@@ -22,6 +23,7 @@ class Typographer:
process_html: bool = False, # Флаг обработки HTML-тегов process_html: bool = False, # Флаг обработки HTML-тегов
hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов
unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов
quotes: QuotesProcessor | bool | None = True, # Правила для обработки кавычек
# ... другие модули правил ... # ... другие модули правил ...
): ):
@@ -56,12 +58,20 @@ class Typographer:
# D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode) # D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode)
self.unbreakables = unbreakables 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. --- Логирование инициализации --- # Z. --- Логирование инициализации ---
logger.debug(f"Typographer `__init__`: langs: {self.langs}, mode: {self.mode}, " logger.debug(f"Typographer `__init__`: langs: {self.langs}, mode: {self.mode}, "
f"hyphenation: {self.hyphenation is not None}, " 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}") f"process_html: {self.process_html}")
@@ -75,6 +85,8 @@ class Typographer:
# processed_text = text # ВРЕМЕННО: используем текст как есть # processed_text = text # ВРЕМЕННО: используем текст как есть
# Шаг 2: Применяем правила к чистому Unicode-тексту # Шаг 2: Применяем правила к чистому Unicode-тексту
if self.quotes is not None:
processed_text = self.quotes.process(processed_text)
if self.unbreakables is not None: if self.unbreakables is not None:
processed_text = self.unbreakables.process(processed_text) processed_text = self.unbreakables.process(processed_text)
if self.hyphenation is not None: if self.hyphenation is not None:

63
tests/test__typograph.py Normal file
View File

@@ -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

74
tests/test_quotes.py Normal file
View File

@@ -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