diff --git a/etpgrf/__init__.py b/etpgrf/__init__.py index 65dbc35..1208e02 100644 --- a/etpgrf/__init__.py +++ b/etpgrf/__init__.py @@ -13,4 +13,5 @@ __version__ = "0.1.0" import etpgrf.defaults from etpgrf.typograph import Typographer from etpgrf.hyphenation import Hyphenator +from etpgrf.unbreakables import Unbreakables import etpgrf.logger diff --git a/etpgrf/config.py b/etpgrf/config.py index c6cc1be..01d21f8 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -29,7 +29,11 @@ SHY_ENTITIES = { # Пробелы и неразрывные пробелы SPACE_ENTITIES = { 'NBSP': ('\u00A0', ' '), # Неразрывный пробел - 'ZWSP': ('\u200B', '​'), # Пробел нулевой ширины (если нужен) + 'THINSP': ('\u2009', ' '), # Тонкий пробел + 'ENSP': ('\u2002', ' '), # Полуширокий пробел + 'EMSP': ('\u2003', ' '), # Широкий пробел + 'ZWNJ': ('\u200C', '‌'), # Разрывный пробел нулевой ширины (без пробела) + 'ZWJ': ('\u200D', '‍'), # Неразрывный пробел нулевой ширины } # Тире и дефисы @@ -41,12 +45,19 @@ DASH_ENTITIES = { # Кавычки QUOTE_ENTITIES = { - 'LAQUO': ('\u00AB', '«'), # « - 'RAQUO': ('\u00BB', '»'), # » - 'LDQUO': ('\u201C', '“'), # “ (левая двойная) - 'RDQUO': ('\u201D', '”'), # ” (правая двойная) - 'LSQUO': ('\u2018', '‘'), # ‘ (левая одинарная) - 'RSQUO': ('\u2019', '’'), # ’ (правая одинарная) + 'QUOT': ('\u0022', '"'), # Двойная кавычка (универсальная) -- " + 'APOS': ('\u0027', '''), # Апостроф (одинарная кавычка) -- ' + 'LAQUO': ('\u00AB', '«'), # Открывающая (левая) кавычка «ёлочка» -- « + 'RAQUO': ('\u00BB', '»'), # Закрывающая (правая) кавычка «ёлочка» -- » + 'LDQUO': ('\u201C', '“'), # Oткрывающая (левая) двойная кавычка -- “ + 'RDQUO': ('\u201D', '”'), # Закрывающая (правая) двойная кавычка -- ” + 'BDQUO': ('\u2039', '„'), # Нижняя двойная кавычка -- „ + 'LSQUO': ('\u2018', '‘'), # Открывающая (левая) одинарная кавычка -- ‘ + 'RSQUO': ('\u2019', '’'), # Закрывающая (правая) одинарная кавычка -- ’ + 'SBQUO': ('\u201A', '‚'), # Нижняя одинарная кавычка -- ‚ + 'LSAQUO': ('\u2039', '‹'), # Открывающая французская угловая кавычка -- › + 'RSAQUO': ('\u203A', '›'), # Закрывающая французская угловая кавычка -- ‹ + } # Другие символы (пример для расширения) diff --git a/etpgrf/typograph.py b/etpgrf/typograph.py index f5b38d8..0c01e19 100644 --- a/etpgrf/typograph.py +++ b/etpgrf/typograph.py @@ -1,5 +1,6 @@ from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs from etpgrf.hyphenation import Hyphenator +from etpgrf.unbreakables import Unbreakables import logging # --- Настройки логирования --- @@ -12,7 +13,7 @@ class Typographer: langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, mode: str | None = None, hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов - # glue_prepositions_rule: GluePrepositionsRule | None = None, # Для других правил + unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов # ... другие модули правил ... ): @@ -25,24 +26,47 @@ class Typographer: # А для специальных случаев, когда переносы не нужны, пусть не ленятся и делают `hyphenation=False`. self.hyphenation: Hyphenator | None = None if hyphenation is True or hyphenation is None: - # 1. Создаем новый объект Hyphenator с заданными языками и режимом, а все остальное по умолчанию + # C1. Создаем новый объект Hyphenator с заданными языками и режимом, а все остальное по умолчанию self.hyphenation = Hyphenator(langs=self.langs, mode=self.mode) elif isinstance(hyphenation, Hyphenator): - # 2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode) + # C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode) self.hyphenation = hyphenation elif hyphenation is False: - # 3. Если hyphenation - False, то правило переноса выключено. + # C3. Если hyphenation - False, то правило переноса выключено. self.hyphenation = None else: - # 4. Если hyphenation что-то неведомое, то игнорируем его и правило переноса выключено + # D4. Если hyphenation что-то неведомое, то игнорируем его и правило переноса выключено self.hyphenation = None - # D. --- Конфигурация других правил--- - logger.debug(f"Typographer `__init__`: langs: {self.langs}, mode: {self.mode}, hyphenation: {self.hyphenation}") + # D. --- Конфигурация правил неразрывных слов --- + self.unbreakables: Unbreakables | None = None + if unbreakables is True or unbreakables is None: + # D1. Создаем новый объект Unbreakables с заданными языками и режимом, а все остальное по умолчанию + self.unbreakables = Unbreakables(langs=self.langs, mode=self.mode) + elif isinstance(unbreakables, Unbreakables): + # D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode) + self.unbreakables = unbreakables + elif unbreakables is False: + # D3. Если unbreakables - False, то правило неразрывных слов выключено. + self.unbreakables = None + else: + # D4. Если unbreakables что-то неведомое, то игнорируем его и правило неразрывных слов выключено + self.unbreakables = None + # E. --- Конфигурация других правил--- + + # 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}") + # Конвейер для обработки текста def process(self, text: str) -> str: processed_text = text + # Применяем правила в определенном порядке. + # Неразрывные конструкции лучше применять до переносов. + if self.unbreakables is not None: + processed_text = self.unbreakables.process(processed_text) if self.hyphenation is not None: # Обработчик переносов (Hyphenator) активен. Обрабатываем текст... processed_text = self.hyphenation.hyp_in_text(processed_text) diff --git a/etpgrf/unbreakables.py b/etpgrf/unbreakables.py new file mode 100644 index 0000000..586a55e --- /dev/null +++ b/etpgrf/unbreakables.py @@ -0,0 +1,128 @@ +# etpgrf/unbreakables.py +# Модуль для предотвращения "висячих" предлогов, союзов и других коротких слов в начале строки. +# Он "приклеивает" такие слова к последующему слову с помощью неразрывного пробела. +# Кстати в русском тексте союзы составляют 7,61% + + +import regex +import logging +from etpgrf.config import LANG_RU, LANG_RU_OLD, LANG_EN, SPACE_ENTITIES, MODE_UNICODE +from etpgrf.defaults import etpgrf_settings + +# --- Наборы коротких слов для разных языков --- +# Используем frozenset для скорости и неизменяемости. +# Слова в нижнем регистре для удобства сравнения. + +_RU_UNBREAKABLE_WORDS = frozenset([ + # Предлоги (только короткие... длинные, типа `ввиду`, `ввиду` и т.п., могут быть "висячими") + 'в', 'без', 'до', 'из', 'к', 'на', 'по', 'о', 'от', 'перед', 'при', 'через', 'с', 'у', 'за', 'над', + 'об', 'под', 'про', 'для', 'ко', 'со', 'без', 'то', 'во', 'из-за', 'из-под', 'как' + # Союзы (без сложных, тип 'как будто', 'как если бы', `за то` и т.п.) + 'и', 'а', 'но', 'да', 'как', + # Частицы + 'не', 'ни', + # Местоимения + 'я', 'ты', 'он', 'мы', 'вы', 'им', 'их', 'ей', 'ею', + # Устаревшие или специфичные + 'сей', 'сия', 'сие', +]) + +# Постпозитивные частицы, которые приклеиваются к ПРЕДЫДУЩЕМУ слову +_RU_POSTPOSITIVE_PARTICLES = frozenset([ + 'ли', 'ль', 'же', 'ж', 'бы', 'б' +]) + +# Для дореформенной орфографии можно добавить специфичные слова, если нужно +_RU_OLD_UNBREAKABLE_WORDS = _RU_UNBREAKABLE_WORDS | frozenset([ + 'і', 'безъ', 'черезъ', 'въ', 'изъ', 'къ', 'отъ', 'съ', 'надъ', 'подъ', 'объ', 'какъ', + 'сiя', 'сiе', 'сiй', 'онъ', 'тъ', +]) + +# Постпозитивные частицы, которые приклеиваются к ПРЕДЫДУЩЕМУ слову +_RU_OLD_POSTPOSITIVE_PARTICLES = frozenset([ + 'жъ', 'бъ' +]) + +_EN_UNBREAKABLE_WORDS = frozenset([ + # 1-2 letter words + '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', + 'not', 'but', 'which', +]) + +# --- Настройки логирования --- +logger = logging.getLogger(__name__) + + +# --- Класс Unbreakables (обработка неразрывных конструкций) --- +class Unbreakables: + """ + Правила обработки коротких слов (предлогов, союзов, частиц и местоимений) для предотвращения их отрыва + от последующих слов. + """ + + def __init__(self, + langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, + mode: str = None): + from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs + self.langs = parse_and_validate_langs(langs) + self.mode = parse_and_validate_mode(mode) + + # Определяем символ неразрывного пробела в зависимости от режима + self._nbsp_char = SPACE_ENTITIES['NBSP'][0] if self.mode == MODE_UNICODE else SPACE_ENTITIES['NBSP'][1] + + # --- 1. Собираем наборы слов для обработки --- + pre_words = set() + post_words = set() + # Собираем слова которые должны быть приклеены + if LANG_RU in self.langs: + pre_words.update(_RU_UNBREAKABLE_WORDS) + post_words.update(_RU_POSTPOSITIVE_PARTICLES) + if LANG_RU_OLD in self.langs: + pre_words.update(_RU_OLD_UNBREAKABLE_WORDS) + post_words.update(_RU_OLD_POSTPOSITIVE_PARTICLES) + if LANG_EN in self.langs: + pre_words.update(_EN_UNBREAKABLE_WORDS) + + # Собираем единый набор слов с пост-позиционными словами (не отрываются от предыдущих слов) + # Убедимся, что пост-позиционные слова не обрабатываются дважды + pre_words -= post_words + + # --- 2. Компиляция паттернов с оптимизацией --- + self._pre_pattern = None + if pre_words: + # Оптимизация: сортируем слова по длине от большего к меньшему + sorted_words = sorted(list(pre_words), key=len, reverse=True) + # Паттерн для слов, ПОСЛЕ которых нужен nbsp. regex.escape для безопасности. + self._pre_pattern = regex.compile(r"(?i)\b(" + "|".join(map(regex.escape, sorted_words)) + r")\b\s+") + + self._post_pattern = None + if post_words: + # Оптимизация: сортируем слова по длине от большего к меньшему + sorted_particles = sorted(list(post_words), key=len, reverse=True) + # Паттерн для слов, ПЕРЕД которыми нужен nbsp. + self._post_pattern = regex.compile(r"(?i)(\s)\b(" + "|".join(map(regex.escape, sorted_particles)) + r")\b") + + logger.debug(f"Unbreakables `__init__`. Langs: {self.langs}, Mode: {self.mode}, " + f"Pre-words: {len(pre_words)}, Post-words: {len(post_words)}") + + + def process(self, text: str) -> str: + """ + Заменяет обычные пробелы вокруг коротких слов на неразрывные. + """ + if not text: + return text + processed_text = text + + # 1. Обработка слов, ПОСЛЕ которых нужен неразрывный пробел ("в дом" -> "в дом") + if self._pre_pattern: + processed_text = self._pre_pattern.sub(r"\g<1>" + self._nbsp_char, processed_text) + + # 2. Обработка частиц, ПЕРЕД которыми нужен неразрывный пробел ("сказал бы" -> "сказал бы") + if self._post_pattern: + # \g<1> - это пробел, \g<2> - это частица + processed_text = self._post_pattern.sub(self._nbsp_char + r"\g<2>", processed_text) + + return processed_text diff --git a/main.py b/main.py index bf6624e..c809bc5 100644 --- a/main.py +++ b/main.py @@ -43,6 +43,7 @@ if __name__ == '__main__': # Меняем настройки по умолчанию для переносов etpgrf.defaults.etpgrf_settings.LANGS = "ru" etpgrf.defaults.etpgrf_settings.hyphenation.MAX_UNHYPHENATED_LEN = 8 + etpgrf.defaults.etpgrf_settings.unbreakables = True txt = ("В самом сердце Санкт-Петербурга — там, где старинные фасады спорят с неоном вывесок — мелькнуло" " пятно алого. Это было пальто от КейтБлаш, сшитое на заказ для перформанс-художницы Серафимы-Лукреции" " Д’Анжу-Палладиновой.\n" @@ -79,10 +80,7 @@ if __name__ == '__main__': " clear that this was no ordinary coat.\n" "\n" "Later, over coffee, Anna joked, “I told the tailor, ‘Make it so I never want to take it off.’ " - "Looks like they succeeded!\n" - "\n" - "Mark nodded, “Well, with KATEBLASH, it’s not just about fashion - it’s about craftsmanship, comfort," - " and a little bit of magic.”") + "Looks like they succeeded!") result = typo_en.process(text=txt) print(result, "\n\n")