add: codec (внутренний utf-8 и мнемокод для in/out
This commit is contained in:
54
etpgrf/codec.py
Normal file
54
etpgrf/codec.py
Normal 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)
|
@@ -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])
|
||||
|
||||
|
@@ -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()
|
||||
|
@@ -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 (правила одинаковые, но разные наборы букв)
|
||||
|
@@ -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><body>
|
||||
# Получаем измененный HTML. BeautifulSoup по умолчанию выводит без тегов <html><body>
|
||||
# если их не было в исходной строке.
|
||||
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)
|
||||
|
||||
|
@@ -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)}")
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user