diff --git a/etpgrf/codec.py b/etpgrf/codec.py new file mode 100644 index 0000000..b6b12b9 --- /dev/null +++ b/etpgrf/codec.py @@ -0,0 +1,54 @@ +# etpgrf/codec.py +# Модуль для преобразования текста между Unicode и HTML-мнемониками. + +import regex +import html +from etpgrf.config import (ALL_ENTITIES, ALWAYS_MNEMONIC_IN_SAFE_MODE, MODE_MNEMONIC, MODE_MIXED) + +# --- Создаем словарь для кодирования Unicode -> Mnemonic --- +# {'\u00A0': ' ', '\u2014': '—', ...} +_ENCODE_MAP = {} + + +for name, (uni_char, mnemonic) in ALL_ENTITIES.items(): + _ENCODE_MAP[uni_char] = mnemonic + +# --- Основные функции кодека --- + +def decode_to_unicode(text: str) -> str: + """ + Преобразует все известные HTML-мнемоники в их Unicode-эквиваленты, + используя стандартную библиотеку html. + """ + if not text or '&' not in text: + return text + return html.unescape(text) + + +def encode_from_unicode(text: str, mode: str) -> str: + """ + Преобразует Unicode-символы в HTML-мнемоники в соответствии с режимом. + """ + if not text or mode not in [MODE_MNEMONIC, MODE_MIXED]: + # В режиме 'unicode' или неизвестном режиме ничего не делаем + return text + + # 1. Определяем, какие символы нужно заменить + if mode == MODE_MNEMONIC: + # В режиме 'mnemonic' заменяем все известные нам символы + chars_to_replace = set(_ENCODE_MAP.keys()) + else: # mode == MODE_MIXED + # В смешанном режиме заменяем только "безопасные" символы + # (те, что могут вызывать проблемы с отображением или переносами) + safe_chars = {ALL_ENTITIES[name][0] for name in ALWAYS_MNEMONIC_IN_SAFE_MODE} + chars_to_replace = set(_ENCODE_MAP.keys()) & safe_chars + + if not chars_to_replace: + return text + + # 2. Создаем паттерн для поиска только нужных символов + # regex.escape важен, если в наборе будут спецсимволы, например, '-' + pattern = regex.compile(f"[{regex.escape(''.join(chars_to_replace))}]") + + # 3. Заменяем найденные символы, используя нашу карту + return pattern.sub(lambda m: _ENCODE_MAP[m.group(0)], text) diff --git a/etpgrf/config.py b/etpgrf/config.py index 3f15098..ad69a0c 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -19,21 +19,28 @@ SUPPORTED_LANGS = frozenset([LANG_RU, LANG_RU_OLD, LANG_EN]) # DEFAULT_HYP_MAX_LEN = 10 # Максимальная длина слова без переносов # DEFAULT_HYP_MIN_LEN = 3 # Минимальный "хвост" слова для переноса -# ----------------- соответствия `unicode` и `mnemonic` для типографа +# === Соответствия `unicode` и `mnemonic` для типографа # Переносы +KEY_SHY = 'SHY' SHY_ENTITIES = { - 'SHY': ('\u00AD', '­'), # Мягкий перенос + KEY_SHY: ('\u00AD', '­'), # Мягкий перенос } # Пробелы и неразрывные пробелы +KEY_NBSP = 'NBSP' +KEY_THINSP = 'THINSP' +KEY_ENSP = 'ENSP' +KEY_EMSP = 'EMSP' +KEY_ZWNJ = 'ZWNJ' +KEY_ZWJ = 'ZWJ' SPACE_ENTITIES = { - 'NBSP': ('\u00A0', ' '), # Неразрывный пробел - 'THINSP': ('\u2009', ' '), # Тонкий пробел - 'ENSP': ('\u2002', ' '), # Полу-широкий пробел - 'EMSP': ('\u2003', ' '), # Широкий пробел - 'ZWNJ': ('\u200C', '‌'), # Разрывный пробел нулевой ширины (без пробела) - 'ZWJ': ('\u200D', '‍'), # Неразрывный пробел нулевой ширины + KEY_NBSP: ('\u00A0', ' '), # Неразрывный пробел + KEY_THINSP: ('\u2009', ' '), # Тонкий пробел + KEY_ENSP: ('\u2002', ' '), # Полу-широкий пробел + KEY_EMSP: ('\u2003', ' '), # Широкий пробел + KEY_ZWNJ: ('\u200C', '‌'), # Разрывный пробел нулевой ширины (без пробела) + KEY_ZWJ: ('\u200D', '‍'), # Неразрывный пробел нулевой ширины } # Тире и дефисы @@ -60,6 +67,7 @@ QUOTE_ENTITIES = { 'RSAQUO': ('\u203A', '›'), # Закрывающая французская угловая кавычка -- ‹ } +# Символы валют CURRENCY_ENTITIES = { 'DOLLAR': ('\u0024', '$'), # Доллар 'CENT': ('\u00A2', '¢'), # Цент @@ -70,14 +78,46 @@ CURRENCY_ENTITIES = { 'RUBLE': ('\u20BD', '₽'), # Российский рубль (₽) } +# Математические символы +KEY_LT = 'LT' +KEY_GT = 'GT' +MATH_ENTITIES = { + KEY_LT: ('\u00B7', '<'), # Меньше (<) + KEY_GT: ('\u00B7', '>'), # Больше (>) + 'PLUS': ('\u002B', '+'), # Плюс (+) + 'MINUS': ('\u2212', '−'), # Минус (−) + 'MULTIPLY': ('\u00D7', '×'), # Умножение (×) + 'DIVIDE': ('\u00F7', '÷'), # Деление (÷) + 'EQUALS': ('\u003D', '='), # Равно (=) + 'NOT_EQUAL': ('\u2260', '≠'), # Не равно (≠) + 'PLUSMN': ('\u00B1', '±'), # Плюс-минус (±) + 'LESS_EQUAL': ('\u2264', '≤'), # Меньше или равно (≤) + 'GREATER_EQUAL': ('\u2265', '≥'), # Больше или равно (≥) + 'APPROX_EQUAL': ('\u2245', '≅'), # Приблизительно равно (≅) + 'APPROX_EQ': ('\u2245', '≊'), # Приблизительно равно (≅) + 'APPROX': ('\u2248', '≈'), # Приблизительно равно (≈) +} + # Другие символы (пример для расширения) +KEY_AMP = 'AMP' SYMBOL_ENTITIES = { + KEY_AMP: ('\u0026', '&smp;'), #Амперсанд (&) 'HELLIP': ('\u2026', '…'), # Многоточие 'COPY': ('\u00A9', '©'), # Копирайт # ... стрелочки, математические символы и т.д. по мере необходимости } -# Сущности, которые ВСЕГДА должны выводиться как мнемоники в режиме MODE_MIXED -# Указываются их ИМЕНА (ключи из словарей выше) -ALWAYS_MNEMONIC_IN_SAFE_MODE = frozenset(['SHY', 'NBSP', 'ZWSP']) +# --- Сборка и валидация --- + +# 1. Создаем единый словарь всех сущностей для удобного доступа +ALL_ENTITIES = { + **SHY_ENTITIES, **SPACE_ENTITIES, **DASH_ENTITIES, **MATH_ENTITIES, + **QUOTE_ENTITIES, **CURRENCY_ENTITIES, **SYMBOL_ENTITIES +} + +# Сущности, которые ВСЕГДА должны выводиться как мнемоники в режиме MODE_MIXED +# Указываются их ИМЕНА (ключи из словарей выше). +# NOTE: Повторное использование магических строк 'SHY', 'NBSP' и т.д. не создает новый объект в памяти. Умный Python +# когда видит одинаковую строку в коде применяет интернирование строк (string interning). +ALWAYS_MNEMONIC_IN_SAFE_MODE = frozenset([KEY_AMP, KEY_LT, KEY_GT, KEY_SHY, KEY_NBSP, KEY_ZWNJ, KEY_ZWJ]) diff --git a/etpgrf/defaults.py b/etpgrf/defaults.py index 951c5c8..e871eff 100644 --- a/etpgrf/defaults.py +++ b/etpgrf/defaults.py @@ -24,6 +24,7 @@ class EtpgrfDefaultSettings: def __init__(self): self.LANGS: list[str] | str = LANG_RU self.MODE: str = MODE_MIXED + # self.PROCESS_HTML: bool = False # Флаг обработки HTML-тегов self.logging_settings = LoggingDefaults() self.hyphenation = HyphenationDefaults() # self.quotes = EtpgrfQuoteDefaults() diff --git a/etpgrf/hyphenation.py b/etpgrf/hyphenation.py index f9c65de..b44244f 100755 --- a/etpgrf/hyphenation.py +++ b/etpgrf/hyphenation.py @@ -6,9 +6,9 @@ import regex import logging -from etpgrf.config import LANG_RU, LANG_RU_OLD, LANG_EN, SHY_ENTITIES, MODE_UNICODE +from etpgrf.config import LANG_RU, LANG_RU_OLD, LANG_EN, KEY_SHY, ALL_ENTITIES from etpgrf.defaults import etpgrf_settings -from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs, is_inside_unbreakable_segment +from etpgrf.comutil import parse_and_validate_langs, is_inside_unbreakable_segment _RU_VOWELS_UPPER = frozenset(['А', 'О', 'И', 'Е', 'Ё', 'Э', 'Ы', 'У', 'Ю', 'Я']) _RU_CONSONANTS_UPPER = frozenset(['Б', 'В', 'Г', 'Д', 'Ж', 'З', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', @@ -46,11 +46,9 @@ class Hyphenator: """ def __init__(self, langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, - mode: str = None, # Режим обработки текста max_unhyphenated_len: int | None = None, # Максимальная длина непереносимой группы min_tail_len: int | None = None): # Минимальная длина после переноса (хвост, который разрешено переносить) self.langs: frozenset[str] = parse_and_validate_langs(langs) - self.mode: str = parse_and_validate_mode(mode) self.max_unhyphenated_len = etpgrf_settings.hyphenation.MAX_UNHYPHENATED_LEN if max_unhyphenated_len is None else max_unhyphenated_len self.min_chars_per_part = etpgrf_settings.hyphenation.MIN_TAIL_LEN if min_tail_len is None else min_tail_len if self.min_chars_per_part < 2: @@ -72,10 +70,10 @@ class Hyphenator: self._en_alphabet_upper: frozenset = frozenset() # Загружает наборы символов на основе self.langs self._load_language_resources_for_hyphenation() - # Определяем символ переноса в зависимости от режима - self._split_code: str = SHY_ENTITIES['SHY'][0] if self.mode == MODE_UNICODE else SHY_ENTITIES['SHY'][1] + # Так как внутри типографа кодировка html, то символ переноса независим от режима + self._split_code: str = ALL_ENTITIES[KEY_SHY][0] # ... - logger.debug(f"Hyphenator `__init__`. Langs: {self.langs}, Mode: {self.mode}," + logger.debug(f"Hyphenator `__init__`. Langs: {self.langs}," f" Max unhyphenated_len: {self.max_unhyphenated_len}," f" Min chars_per_part: {self.min_chars_per_part}") @@ -135,7 +133,7 @@ class Hyphenator: if len(word) <= self.max_unhyphenated_len or not any(self._is_vow(c) for c in word): # Если слово короткое или не содержит гласных, перенос не нужен return word - logger.debug(f"Hyphenator: word: `{word}` // langs: {self.langs} // mode: {self.mode} // max_unhyphenated_len: {self.max_unhyphenated_len} // min_tail_len: {self.min_chars_per_part}") + logger.debug(f"Hyphenator: word: `{word}` // langs: {self.langs} // max_unhyphenated_len: {self.max_unhyphenated_len} // min_tail_len: {self.min_chars_per_part}") # 2. ОБНАРУЖЕНИЕ ЯЗЫКА И ПОДКЛЮЧЕНИЕ ЯЗЫКОВОЙ ЛОГИКИ # Поиск вхождения букв строки (слова) через `frozenset` -- O(1). Это быстрее регулярного выражения -- O(n) # 2.1. Проверяем RU и RU_OLD (правила одинаковые, но разные наборы букв) diff --git a/etpgrf/typograph.py b/etpgrf/typograph.py index b95bbd4..244c381 100644 --- a/etpgrf/typograph.py +++ b/etpgrf/typograph.py @@ -1,4 +1,5 @@ import logging +import html try: from bs4 import BeautifulSoup, NavigableString except ImportError: @@ -6,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.codec import decode_to_unicode, encode_from_unicode # --- Настройки логирования --- @@ -40,7 +42,7 @@ class Typographer: self.hyphenation: Hyphenator | None = None if hyphenation is True or hyphenation is None: # C1. Создаем новый объект Hyphenator с заданными языками и режимом, а все остальное по умолчанию - self.hyphenation = Hyphenator(langs=self.langs, mode=self.mode) + self.hyphenation = Hyphenator(langs=self.langs) elif isinstance(hyphenation, Hyphenator): # C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode) self.hyphenation = hyphenation @@ -49,7 +51,7 @@ class Typographer: self.unbreakables: Unbreakables | None = None if unbreakables is True or unbreakables is None: # D1. Создаем новый объект Unbreakables с заданными языками и режимом, а все остальное по умолчанию - self.unbreakables = Unbreakables(langs=self.langs, mode=self.mode) + self.unbreakables = Unbreakables(langs=self.langs) elif isinstance(unbreakables, Unbreakables): # D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode) self.unbreakables = unbreakables @@ -69,8 +71,8 @@ class Typographer: """ # Шаг 1: Декодируем весь входящий текст в канонический Unicode # (здесь можно использовать html.unescape, но наш кодек тоже подойдет) - # processed_text = decode_to_unicode(text) - processed_text = text # ВРЕМЕННО: используем текст как есть + processed_text = decode_to_unicode(text) + # processed_text = text # ВРЕМЕННО: используем текст как есть # Шаг 2: Применяем правила к чистому Unicode-тексту if self.unbreakables is not None: @@ -79,10 +81,7 @@ class Typographer: processed_text = self.hyphenation.hyp_in_text(processed_text) # ... вызовы других активных модулей правил ... - # Шаг 3: Кодируем результат в запрошенный формат (mnemonic или mixed) - # final_text = encode_from_unicode(processed_text, self.mode) - final_text = processed_text # ВРЕМЕННО: используем текст как есть - return final_text + return processed_text @@ -105,24 +104,23 @@ class Typographer: if not node.string.strip() or node.parent.name in ['style', 'script', 'pre', 'code']: continue # К каждому текстовому узлу применяем "внутренний" процессор - processed_node_text = self._process_text_node(node.string) + processed_node_text: str = self._process_text_node(node.string) # Отладочная печать, чтобы видеть, что происходит if node.string != processed_node_text: logger.info(f"Processing node: '{node.string}' -> '{processed_node_text}'") # Заменяем узел в дереве на обработанный текст. # BeautifulSoup сама позаботится об экранировании, если нужно. # Важно: мы не можем просто заменить строку, нужно создать новый объект NavigableString, - # чтобы BeautifulSoup правильно обработал символы вроде '<' и '>'. - # Однако, replace_with достаточно умен, чтобы справиться с этим. + # чтобы BeautifulSoup правильно обработал символы вроде '<' и '>'. + # Однако, replace_with достаточно умен, чтобы справиться с этим. node.replace_with(processed_node_text) - # Возвращаем измененный HTML. BeautifulSoup по умолчанию выводит без тегов + # Получаем измененный HTML. BeautifulSoup по умолчанию выводит без тегов # если их не было в исходной строке. - return str(soup) + processed = str(soup) else: - # Если HTML-режим выключен, работаем как раньше - return self._process_text_node(text) - - # def _get_nbsp(self): # Пример получения неразрывного пробела - # return "\u00A0" if self.mode in UTF else " " + # Если HTML-режим выключен + processed = self._process_text_node(text) + # Возвращаем + return encode_from_unicode(processed, self.mode) diff --git a/etpgrf/unbreakables.py b/etpgrf/unbreakables.py index 586a55e..e55da5d 100644 --- a/etpgrf/unbreakables.py +++ b/etpgrf/unbreakables.py @@ -6,7 +6,8 @@ import regex import logging -from etpgrf.config import LANG_RU, LANG_RU_OLD, LANG_EN, SPACE_ENTITIES, MODE_UNICODE +from etpgrf.config import LANG_RU, LANG_RU_OLD, LANG_EN, KEY_NBSP, ALL_ENTITIES +from etpgrf.comutil import parse_and_validate_langs from etpgrf.defaults import etpgrf_settings # --- Наборы коротких слов для разных языков --- @@ -17,7 +18,7 @@ _RU_UNBREAKABLE_WORDS = frozenset([ # Предлоги (только короткие... длинные, типа `ввиду`, `ввиду` и т.п., могут быть "висячими") 'в', 'без', 'до', 'из', 'к', 'на', 'по', 'о', 'от', 'перед', 'при', 'через', 'с', 'у', 'за', 'над', 'об', 'под', 'про', 'для', 'ко', 'со', 'без', 'то', 'во', 'из-за', 'из-под', 'как' - # Союзы (без сложных, тип 'как будто', 'как если бы', `за то` и т.п.) + # Союзы (без сложных, тип `как будто`, `как если бы`, `за то` и т.п.) 'и', 'а', 'но', 'да', 'как', # Частицы 'не', 'ни', @@ -62,15 +63,11 @@ 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 + def __init__(self, langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None): 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] + # Так как внутри типографа кодировка html, то символ неразрывного пробела независим от режима + self._nbsp_char = ALL_ENTITIES[KEY_NBSP][0] # --- 1. Собираем наборы слов для обработки --- pre_words = set() @@ -104,7 +101,7 @@ class Unbreakables: # Паттерн для слов, ПЕРЕД которыми нужен 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}, " + logger.debug(f"Unbreakables `__init__`. Langs: {self.langs}, " f"Pre-words: {len(pre_words)}, Post-words: {len(post_words)}")