add: codec (внутренний utf-8 и мнемокод для in/out

This commit is contained in:
2025-07-21 14:46:13 +03:00
parent 1c5fe77706
commit 7ff18dc2e8
6 changed files with 135 additions and 47 deletions

54
etpgrf/codec.py Normal file
View File

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

View File

@@ -19,21 +19,28 @@ SUPPORTED_LANGS = frozenset([LANG_RU, LANG_RU_OLD, LANG_EN])
# DEFAULT_HYP_MAX_LEN = 10 # Максимальная длина слова без переносов # DEFAULT_HYP_MAX_LEN = 10 # Максимальная длина слова без переносов
# DEFAULT_HYP_MIN_LEN = 3 # Минимальный "хвост" слова для переноса # DEFAULT_HYP_MIN_LEN = 3 # Минимальный "хвост" слова для переноса
# ----------------- соответствия `unicode` и `mnemonic` для типографа # === Соответствия `unicode` и `mnemonic` для типографа
# Переносы # Переносы
KEY_SHY = 'SHY'
SHY_ENTITIES = { 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 = { SPACE_ENTITIES = {
'NBSP': ('\u00A0', ' '), # Неразрывный пробел KEY_NBSP: ('\u00A0', ' '), # Неразрывный пробел
'THINSP': ('\u2009', ' '), # Тонкий пробел KEY_THINSP: ('\u2009', ' '), # Тонкий пробел
'ENSP': ('\u2002', ' '), # Полу-широкий пробел KEY_ENSP: ('\u2002', ' '), # Полу-широкий пробел
'EMSP': ('\u2003', ' '), # Широкий пробел KEY_EMSP: ('\u2003', ' '), # Широкий пробел
'ZWNJ': ('\u200C', '‌'), # Разрывный пробел нулевой ширины (без пробела) KEY_ZWNJ: ('\u200C', '‌'), # Разрывный пробел нулевой ширины (без пробела)
'ZWJ': ('\u200D', '‍'), # Неразрывный пробел нулевой ширины KEY_ZWJ: ('\u200D', '‍'), # Неразрывный пробел нулевой ширины
} }
# Тире и дефисы # Тире и дефисы
@@ -60,6 +67,7 @@ QUOTE_ENTITIES = {
'RSAQUO': ('\u203A', '›'), # Закрывающая французская угловая кавычка -- 'RSAQUO': ('\u203A', '›'), # Закрывающая французская угловая кавычка --
} }
# Символы валют
CURRENCY_ENTITIES = { CURRENCY_ENTITIES = {
'DOLLAR': ('\u0024', '$'), # Доллар 'DOLLAR': ('\u0024', '$'), # Доллар
'CENT': ('\u00A2', '¢'), # Цент 'CENT': ('\u00A2', '¢'), # Цент
@@ -70,14 +78,46 @@ CURRENCY_ENTITIES = {
'RUBLE': ('\u20BD', '₽'), # Российский рубль (₽) 'RUBLE': ('\u20BD', '₽'), # Российский рубль (₽)
} }
# Математические символы
KEY_LT = 'LT'
KEY_GT = 'GT'
MATH_ENTITIES = {
KEY_LT: ('\u00B7', '&lt;'), # Меньше (<)
KEY_GT: ('\u00B7', '&gt;'), # Больше (>)
'PLUS': ('\u002B', '&plus;'), # Плюс (+)
'MINUS': ('\u2212', '&minus;'), # Минус ()
'MULTIPLY': ('\u00D7', '&times;'), # Умножение (×)
'DIVIDE': ('\u00F7', '&divide;'), # Деление (÷)
'EQUALS': ('\u003D', '&equals;'), # Равно (=)
'NOT_EQUAL': ('\u2260', '&ne;'), # Не равно (≠)
'PLUSMN': ('\u00B1', '&plusmn;'), # Плюс-минус (±)
'LESS_EQUAL': ('\u2264', '&le;'), # Меньше или равно (≤)
'GREATER_EQUAL': ('\u2265', '&ge;'), # Больше или равно (≥)
'APPROX_EQUAL': ('\u2245', '&cong;'), # Приблизительно равно (≅)
'APPROX_EQ': ('\u2245', '&approxeq;'), # Приблизительно равно (≅)
'APPROX': ('\u2248', '&asymp;'), # Приблизительно равно (≈)
}
# Другие символы (пример для расширения) # Другие символы (пример для расширения)
KEY_AMP = 'AMP'
SYMBOL_ENTITIES = { SYMBOL_ENTITIES = {
KEY_AMP: ('\u0026', '&smp;'), #Амперсанд (&)
'HELLIP': ('\u2026', '&hellip;'), # Многоточие 'HELLIP': ('\u2026', '&hellip;'), # Многоточие
'COPY': ('\u00A9', '&copy;'), # Копирайт 'COPY': ('\u00A9', '&copy;'), # Копирайт
# ... стрелочки, математические символы и т.д. по мере необходимости # ... стрелочки, математические символы и т.д. по мере необходимости
} }
# Сущности, которые ВСЕГДА должны выводиться как мнемоники в режиме 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])

View File

@@ -24,6 +24,7 @@ class EtpgrfDefaultSettings:
def __init__(self): def __init__(self):
self.LANGS: list[str] | str = LANG_RU self.LANGS: list[str] | str = LANG_RU
self.MODE: str = MODE_MIXED self.MODE: str = MODE_MIXED
# self.PROCESS_HTML: bool = False # Флаг обработки HTML-тегов
self.logging_settings = LoggingDefaults() self.logging_settings = LoggingDefaults()
self.hyphenation = HyphenationDefaults() self.hyphenation = HyphenationDefaults()
# self.quotes = EtpgrfQuoteDefaults() # self.quotes = EtpgrfQuoteDefaults()

View File

@@ -6,9 +6,9 @@
import regex import regex
import logging 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.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_VOWELS_UPPER = frozenset(['А', 'О', 'И', 'Е', 'Ё', 'Э', 'Ы', 'У', 'Ю', 'Я'])
_RU_CONSONANTS_UPPER = frozenset(['Б', 'В', 'Г', 'Д', 'Ж', 'З', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', _RU_CONSONANTS_UPPER = frozenset(['Б', 'В', 'Г', 'Д', 'Ж', 'З', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х',
@@ -46,11 +46,9 @@ class Hyphenator:
""" """
def __init__(self, def __init__(self,
langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None,
mode: str = None, # Режим обработки текста
max_unhyphenated_len: int | None = None, # Максимальная длина непереносимой группы max_unhyphenated_len: int | None = None, # Максимальная длина непереносимой группы
min_tail_len: int | None = None): # Минимальная длина после переноса (хвост, который разрешено переносить) min_tail_len: int | None = None): # Минимальная длина после переноса (хвост, который разрешено переносить)
self.langs: frozenset[str] = parse_and_validate_langs(langs) 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.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 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: if self.min_chars_per_part < 2:
@@ -72,10 +70,10 @@ class Hyphenator:
self._en_alphabet_upper: frozenset = frozenset() self._en_alphabet_upper: frozenset = frozenset()
# Загружает наборы символов на основе self.langs # Загружает наборы символов на основе self.langs
self._load_language_resources_for_hyphenation() self._load_language_resources_for_hyphenation()
# Определяем символ переноса в зависимости от режима # Так как внутри типографа кодировка html, то символ переноса независим от режима
self._split_code: str = SHY_ENTITIES['SHY'][0] if self.mode == MODE_UNICODE else SHY_ENTITIES['SHY'][1] 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" Max unhyphenated_len: {self.max_unhyphenated_len},"
f" Min chars_per_part: {self.min_chars_per_part}") 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): if len(word) <= self.max_unhyphenated_len or not any(self._is_vow(c) for c in word):
# Если слово короткое или не содержит гласных, перенос не нужен # Если слово короткое или не содержит гласных, перенос не нужен
return 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. ОБНАРУЖЕНИЕ ЯЗЫКА И ПОДКЛЮЧЕНИЕ ЯЗЫКОВОЙ ЛОГИКИ # 2. ОБНАРУЖЕНИЕ ЯЗЫКА И ПОДКЛЮЧЕНИЕ ЯЗЫКОВОЙ ЛОГИКИ
# Поиск вхождения букв строки (слова) через `frozenset` -- O(1). Это быстрее регулярного выражения -- O(n) # Поиск вхождения букв строки (слова) через `frozenset` -- O(1). Это быстрее регулярного выражения -- O(n)
# 2.1. Проверяем RU и RU_OLD (правила одинаковые, но разные наборы букв) # 2.1. Проверяем RU и RU_OLD (правила одинаковые, но разные наборы букв)

View File

@@ -1,4 +1,5 @@
import logging import logging
import html
try: try:
from bs4 import BeautifulSoup, NavigableString from bs4 import BeautifulSoup, NavigableString
except ImportError: except ImportError:
@@ -6,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.codec import decode_to_unicode, encode_from_unicode
# --- Настройки логирования --- # --- Настройки логирования ---
@@ -40,7 +42,7 @@ class Typographer:
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:
# C1. Создаем новый объект Hyphenator с заданными языками и режимом, а все остальное по умолчанию # C1. Создаем новый объект Hyphenator с заданными языками и режимом, а все остальное по умолчанию
self.hyphenation = Hyphenator(langs=self.langs, mode=self.mode) self.hyphenation = Hyphenator(langs=self.langs)
elif isinstance(hyphenation, Hyphenator): elif isinstance(hyphenation, Hyphenator):
# C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode) # C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode)
self.hyphenation = hyphenation self.hyphenation = hyphenation
@@ -49,7 +51,7 @@ class Typographer:
self.unbreakables: Unbreakables | None = None self.unbreakables: Unbreakables | None = None
if unbreakables is True or unbreakables is None: if unbreakables is True or unbreakables is None:
# D1. Создаем новый объект Unbreakables с заданными языками и режимом, а все остальное по умолчанию # D1. Создаем новый объект Unbreakables с заданными языками и режимом, а все остальное по умолчанию
self.unbreakables = Unbreakables(langs=self.langs, mode=self.mode) self.unbreakables = Unbreakables(langs=self.langs)
elif isinstance(unbreakables, Unbreakables): elif isinstance(unbreakables, Unbreakables):
# D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode) # D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode)
self.unbreakables = unbreakables self.unbreakables = unbreakables
@@ -69,8 +71,8 @@ class Typographer:
""" """
# Шаг 1: Декодируем весь входящий текст в канонический Unicode # Шаг 1: Декодируем весь входящий текст в канонический Unicode
# (здесь можно использовать html.unescape, но наш кодек тоже подойдет) # (здесь можно использовать html.unescape, но наш кодек тоже подойдет)
# processed_text = decode_to_unicode(text) processed_text = decode_to_unicode(text)
processed_text = text # ВРЕМЕННО: используем текст как есть # processed_text = text # ВРЕМЕННО: используем текст как есть
# Шаг 2: Применяем правила к чистому Unicode-тексту # Шаг 2: Применяем правила к чистому Unicode-тексту
if self.unbreakables is not None: if self.unbreakables is not None:
@@ -79,10 +81,7 @@ class Typographer:
processed_text = self.hyphenation.hyp_in_text(processed_text) processed_text = self.hyphenation.hyp_in_text(processed_text)
# ... вызовы других активных модулей правил ... # ... вызовы других активных модулей правил ...
# Шаг 3: Кодируем результат в запрошенный формат (mnemonic или mixed) return processed_text
# final_text = encode_from_unicode(processed_text, self.mode)
final_text = processed_text # ВРЕМЕННО: используем текст как есть
return final_text
@@ -105,24 +104,23 @@ class Typographer:
if not node.string.strip() or node.parent.name in ['style', 'script', 'pre', 'code']: if not node.string.strip() or node.parent.name in ['style', 'script', 'pre', 'code']:
continue 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: if node.string != processed_node_text:
logger.info(f"Processing node: '{node.string}' -> '{processed_node_text}'") logger.info(f"Processing node: '{node.string}' -> '{processed_node_text}'")
# Заменяем узел в дереве на обработанный текст. # Заменяем узел в дереве на обработанный текст.
# BeautifulSoup сама позаботится об экранировании, если нужно. # BeautifulSoup сама позаботится об экранировании, если нужно.
# Важно: мы не можем просто заменить строку, нужно создать новый объект NavigableString, # Важно: мы не можем просто заменить строку, нужно создать новый объект NavigableString,
# чтобы BeautifulSoup правильно обработал символы вроде '<' и '>'. # чтобы BeautifulSoup правильно обработал символы вроде '<' и '>'.
# Однако, replace_with достаточно умен, чтобы справиться с этим. # Однако, replace_with достаточно умен, чтобы справиться с этим.
node.replace_with(processed_node_text) node.replace_with(processed_node_text)
# Возвращаем измененный HTML. BeautifulSoup по умолчанию выводит без тегов <html><body> # Получаем измененный HTML. BeautifulSoup по умолчанию выводит без тегов <html><body>
# если их не было в исходной строке. # если их не было в исходной строке.
return str(soup) processed = str(soup)
else: else:
# Если HTML-режим выключен, работаем как раньше # Если HTML-режим выключен
return self._process_text_node(text) processed = self._process_text_node(text)
# Возвращаем
# def _get_nbsp(self): # Пример получения неразрывного пробела return encode_from_unicode(processed, self.mode)
# return "\u00A0" if self.mode in UTF else "&nbsp;"

View File

@@ -6,7 +6,8 @@
import regex import regex
import logging 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 from etpgrf.defaults import etpgrf_settings
# --- Наборы коротких слов для разных языков --- # --- Наборы коротких слов для разных языков ---
@@ -17,7 +18,7 @@ _RU_UNBREAKABLE_WORDS = frozenset([
# Предлоги (только короткие... длинные, типа `ввиду`, `ввиду` и т.п., могут быть "висячими") # Предлоги (только короткие... длинные, типа `ввиду`, `ввиду` и т.п., могут быть "висячими")
'в', 'без', 'до', 'из', 'к', 'на', 'по', 'о', 'от', 'перед', 'при', 'через', 'с', 'у', 'за', 'над', 'в', 'без', 'до', 'из', 'к', 'на', 'по', 'о', 'от', 'перед', 'при', 'через', 'с', 'у', 'за', 'над',
'об', 'под', 'про', 'для', 'ко', 'со', 'без', 'то', 'во', 'из-за', 'из-под', 'как' 'об', 'под', 'про', 'для', 'ко', 'со', 'без', 'то', 'во', 'из-за', 'из-под', 'как'
# Союзы (без сложных, тип 'как будто', 'как если бы', `за то` и т.п.) # Союзы (без сложных, тип `как будто`, `как если бы`, `за то` и т.п.)
'и', 'а', 'но', 'да', 'как', 'и', 'а', 'но', 'да', 'как',
# Частицы # Частицы
'не', 'ни', 'не', 'ни',
@@ -62,15 +63,11 @@ class Unbreakables:
от последующих слов. от последующих слов.
""" """
def __init__(self, def __init__(self, langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None):
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.langs = parse_and_validate_langs(langs)
self.mode = parse_and_validate_mode(mode)
# Определяем символ неразрывного пробела в зависимости от режима # Так как внутри типографа кодировка html, то символ неразрывного пробела независим от режима
self._nbsp_char = SPACE_ENTITIES['NBSP'][0] if self.mode == MODE_UNICODE else SPACE_ENTITIES['NBSP'][1] self._nbsp_char = ALL_ENTITIES[KEY_NBSP][0]
# --- 1. Собираем наборы слов для обработки --- # --- 1. Собираем наборы слов для обработки ---
pre_words = set() pre_words = set()
@@ -104,7 +101,7 @@ class Unbreakables:
# Паттерн для слов, ПЕРЕД которыми нужен nbsp. # Паттерн для слов, ПЕРЕД которыми нужен nbsp.
self._post_pattern = regex.compile(r"(?i)(\s)\b(" + "|".join(map(regex.escape, sorted_particles)) + r")\b") 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)}") f"Pre-words: {len(pre_words)}, Post-words: {len(post_words)}")