add: неразрывные пробелы для предлогов, союзов, частиц и т.п. (с пре-позицией и пост-позицией)
This commit is contained in:
@@ -13,4 +13,5 @@ __version__ = "0.1.0"
|
|||||||
import etpgrf.defaults
|
import etpgrf.defaults
|
||||||
from etpgrf.typograph import Typographer
|
from etpgrf.typograph import Typographer
|
||||||
from etpgrf.hyphenation import Hyphenator
|
from etpgrf.hyphenation import Hyphenator
|
||||||
|
from etpgrf.unbreakables import Unbreakables
|
||||||
import etpgrf.logger
|
import etpgrf.logger
|
||||||
|
@@ -29,7 +29,11 @@ SHY_ENTITIES = {
|
|||||||
# Пробелы и неразрывные пробелы
|
# Пробелы и неразрывные пробелы
|
||||||
SPACE_ENTITIES = {
|
SPACE_ENTITIES = {
|
||||||
'NBSP': ('\u00A0', ' '), # Неразрывный пробел
|
'NBSP': ('\u00A0', ' '), # Неразрывный пробел
|
||||||
'ZWSP': ('\u200B', '​'), # Пробел нулевой ширины (если нужен)
|
'THINSP': ('\u2009', ' '), # Тонкий пробел
|
||||||
|
'ENSP': ('\u2002', ' '), # Полуширокий пробел
|
||||||
|
'EMSP': ('\u2003', ' '), # Широкий пробел
|
||||||
|
'ZWNJ': ('\u200C', '‌'), # Разрывный пробел нулевой ширины (без пробела)
|
||||||
|
'ZWJ': ('\u200D', '‍'), # Неразрывный пробел нулевой ширины
|
||||||
}
|
}
|
||||||
|
|
||||||
# Тире и дефисы
|
# Тире и дефисы
|
||||||
@@ -41,12 +45,19 @@ DASH_ENTITIES = {
|
|||||||
|
|
||||||
# Кавычки
|
# Кавычки
|
||||||
QUOTE_ENTITIES = {
|
QUOTE_ENTITIES = {
|
||||||
'LAQUO': ('\u00AB', '«'), # «
|
'QUOT': ('\u0022', '"'), # Двойная кавычка (универсальная) -- "
|
||||||
'RAQUO': ('\u00BB', '»'), # »
|
'APOS': ('\u0027', '''), # Апостроф (одинарная кавычка) -- '
|
||||||
'LDQUO': ('\u201C', '“'), # “ (левая двойная)
|
'LAQUO': ('\u00AB', '«'), # Открывающая (левая) кавычка «ёлочка» -- «
|
||||||
'RDQUO': ('\u201D', '”'), # ” (правая двойная)
|
'RAQUO': ('\u00BB', '»'), # Закрывающая (правая) кавычка «ёлочка» -- »
|
||||||
'LSQUO': ('\u2018', '‘'), # ‘ (левая одинарная)
|
'LDQUO': ('\u201C', '“'), # Oткрывающая (левая) двойная кавычка -- “
|
||||||
'RSQUO': ('\u2019', '’'), # ’ (правая одинарная)
|
'RDQUO': ('\u201D', '”'), # Закрывающая (правая) двойная кавычка -- ”
|
||||||
|
'BDQUO': ('\u2039', '„'), # Нижняя двойная кавычка -- „
|
||||||
|
'LSQUO': ('\u2018', '‘'), # Открывающая (левая) одинарная кавычка -- ‘
|
||||||
|
'RSQUO': ('\u2019', '’'), # Закрывающая (правая) одинарная кавычка -- ’
|
||||||
|
'SBQUO': ('\u201A', '‚'), # Нижняя одинарная кавычка -- ‚
|
||||||
|
'LSAQUO': ('\u2039', '‹'), # Открывающая французская угловая кавычка -- ›
|
||||||
|
'RSAQUO': ('\u203A', '›'), # Закрывающая французская угловая кавычка -- ‹
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Другие символы (пример для расширения)
|
# Другие символы (пример для расширения)
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# --- Настройки логирования ---
|
# --- Настройки логирования ---
|
||||||
@@ -12,7 +13,7 @@ class Typographer:
|
|||||||
langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None,
|
langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None,
|
||||||
mode: str | None = None,
|
mode: str | None = None,
|
||||||
hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов
|
hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов
|
||||||
# glue_prepositions_rule: GluePrepositionsRule | None = None, # Для других правил
|
unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов
|
||||||
# ... другие модули правил ...
|
# ... другие модули правил ...
|
||||||
):
|
):
|
||||||
|
|
||||||
@@ -25,24 +26,47 @@ class Typographer:
|
|||||||
# А для специальных случаев, когда переносы не нужны, пусть не ленятся и делают `hyphenation=False`.
|
# А для специальных случаев, когда переносы не нужны, пусть не ленятся и делают `hyphenation=False`.
|
||||||
self.hyphenation: Hyphenator | None = None
|
self.hyphenation: Hyphenator | None = None
|
||||||
if hyphenation is True or hyphenation is None:
|
if hyphenation is True or hyphenation is None:
|
||||||
# 1. Создаем новый объект Hyphenator с заданными языками и режимом, а все остальное по умолчанию
|
# C1. Создаем новый объект Hyphenator с заданными языками и режимом, а все остальное по умолчанию
|
||||||
self.hyphenation = Hyphenator(langs=self.langs, mode=self.mode)
|
self.hyphenation = Hyphenator(langs=self.langs, mode=self.mode)
|
||||||
elif isinstance(hyphenation, Hyphenator):
|
elif isinstance(hyphenation, Hyphenator):
|
||||||
# 2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode)
|
# C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode)
|
||||||
self.hyphenation = hyphenation
|
self.hyphenation = hyphenation
|
||||||
elif hyphenation is False:
|
elif hyphenation is False:
|
||||||
# 3. Если hyphenation - False, то правило переноса выключено.
|
# C3. Если hyphenation - False, то правило переноса выключено.
|
||||||
self.hyphenation = None
|
self.hyphenation = None
|
||||||
else:
|
else:
|
||||||
# 4. Если hyphenation что-то неведомое, то игнорируем его и правило переноса выключено
|
# D4. Если hyphenation что-то неведомое, то игнорируем его и правило переноса выключено
|
||||||
self.hyphenation = None
|
self.hyphenation = None
|
||||||
# D. --- Конфигурация других правил---
|
# D. --- Конфигурация правил неразрывных слов ---
|
||||||
logger.debug(f"Typographer `__init__`: langs: {self.langs}, mode: {self.mode}, hyphenation: {self.hyphenation}")
|
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:
|
def process(self, text: str) -> str:
|
||||||
processed_text = text
|
processed_text = text
|
||||||
|
# Применяем правила в определенном порядке.
|
||||||
|
# Неразрывные конструкции лучше применять до переносов.
|
||||||
|
if self.unbreakables is not None:
|
||||||
|
processed_text = self.unbreakables.process(processed_text)
|
||||||
if self.hyphenation is not None:
|
if self.hyphenation is not None:
|
||||||
# Обработчик переносов (Hyphenator) активен. Обрабатываем текст...
|
# Обработчик переносов (Hyphenator) активен. Обрабатываем текст...
|
||||||
processed_text = self.hyphenation.hyp_in_text(processed_text)
|
processed_text = self.hyphenation.hyp_in_text(processed_text)
|
||||||
|
128
etpgrf/unbreakables.py
Normal file
128
etpgrf/unbreakables.py
Normal file
@@ -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
|
6
main.py
6
main.py
@@ -43,6 +43,7 @@ if __name__ == '__main__':
|
|||||||
# Меняем настройки по умолчанию для переносов
|
# Меняем настройки по умолчанию для переносов
|
||||||
etpgrf.defaults.etpgrf_settings.LANGS = "ru"
|
etpgrf.defaults.etpgrf_settings.LANGS = "ru"
|
||||||
etpgrf.defaults.etpgrf_settings.hyphenation.MAX_UNHYPHENATED_LEN = 8
|
etpgrf.defaults.etpgrf_settings.hyphenation.MAX_UNHYPHENATED_LEN = 8
|
||||||
|
etpgrf.defaults.etpgrf_settings.unbreakables = True
|
||||||
txt = ("В самом сердце Санкт-Петербурга — там, где старинные фасады спорят с неоном вывесок — мелькнуло"
|
txt = ("В самом сердце Санкт-Петербурга — там, где старинные фасады спорят с неоном вывесок — мелькнуло"
|
||||||
" пятно алого. Это было пальто от КейтБлаш, сшитое на заказ для перформанс-художницы Серафимы-Лукреции"
|
" пятно алого. Это было пальто от КейтБлаш, сшитое на заказ для перформанс-художницы Серафимы-Лукреции"
|
||||||
" Д’Анжу-Палладиновой.\n"
|
" Д’Анжу-Палладиновой.\n"
|
||||||
@@ -79,10 +80,7 @@ if __name__ == '__main__':
|
|||||||
" clear that this was no ordinary coat.\n"
|
" clear that this was no ordinary coat.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Later, over coffee, Anna joked, “I told the tailor, ‘Make it so I never want to take it off.’ "
|
"Later, over coffee, Anna joked, “I told the tailor, ‘Make it so I never want to take it off.’ "
|
||||||
"Looks like they succeeded!\n"
|
"Looks like they succeeded!")
|
||||||
"\n"
|
|
||||||
"Mark nodded, “Well, with KATEBLASH, it’s not just about fashion - it’s about craftsmanship, comfort,"
|
|
||||||
" and a little bit of magic.”")
|
|
||||||
result = typo_en.process(text=txt)
|
result = typo_en.process(text=txt)
|
||||||
print(result, "\n\n")
|
print(result, "\n\n")
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user