From 0e103c0f8f1799e112e47102c49ffd595c0cdced Mon Sep 17 00:00:00 2001 From: erjemin Date: Thu, 15 Jan 2026 13:39:48 +0300 Subject: [PATCH] =?UTF-8?q?del:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B5=D0=BD=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?etpgrf=20(=D0=B1=D1=83=D0=B4=D0=B5=D1=82=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=200.1.3=20?= =?UTF-8?q?=D0=B8=D0=B7=20PyPi)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etpgrf_site/etpgrf/__init__.py | 26 -- etpgrf_site/etpgrf/codec.py | 58 --- etpgrf_site/etpgrf/comutil.py | 140 ------ etpgrf_site/etpgrf/config.py | 722 ----------------------------- etpgrf_site/etpgrf/defaults.py | 32 -- etpgrf_site/etpgrf/hanging.py | 166 ------- etpgrf_site/etpgrf/hyphenation.py | 358 -------------- etpgrf_site/etpgrf/layout.py | 211 --------- etpgrf_site/etpgrf/logger.py | 124 ----- etpgrf_site/etpgrf/quotes.py | 75 --- etpgrf_site/etpgrf/sanitizer.py | 76 --- etpgrf_site/etpgrf/symbols.py | 50 -- etpgrf_site/etpgrf/typograph.py | 276 ----------- etpgrf_site/etpgrf/unbreakables.py | 124 ----- 14 files changed, 2438 deletions(-) delete mode 100644 etpgrf_site/etpgrf/__init__.py delete mode 100644 etpgrf_site/etpgrf/codec.py delete mode 100644 etpgrf_site/etpgrf/comutil.py delete mode 100644 etpgrf_site/etpgrf/config.py delete mode 100644 etpgrf_site/etpgrf/defaults.py delete mode 100644 etpgrf_site/etpgrf/hanging.py delete mode 100755 etpgrf_site/etpgrf/hyphenation.py delete mode 100644 etpgrf_site/etpgrf/layout.py delete mode 100644 etpgrf_site/etpgrf/logger.py delete mode 100644 etpgrf_site/etpgrf/quotes.py delete mode 100644 etpgrf_site/etpgrf/sanitizer.py delete mode 100644 etpgrf_site/etpgrf/symbols.py delete mode 100644 etpgrf_site/etpgrf/typograph.py delete mode 100644 etpgrf_site/etpgrf/unbreakables.py diff --git a/etpgrf_site/etpgrf/__init__.py b/etpgrf_site/etpgrf/__init__.py deleted file mode 100644 index 2224b3f..0000000 --- a/etpgrf_site/etpgrf/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -etpgrf - библиотека для экранной типографики текста с поддержкой HTML. - -Основные возможности: -- Автоматическая расстановка переносов -- Неразрывные пробелы для союзов и предлогов -- Корректные кавычки в зависимости от языка -- Висячая пунктуация -- Очистка и обработка HTML -""" -__version__ = "0.1.3" -__author__ = "Sergei Erjemin" -__email__ = "erjemin@gmail.com" -__license__ = "MIT" -__copyright__ = "Copyright 2025 Sergei Erjemin" - -import etpgrf.defaults -import etpgrf.logger - -from etpgrf.hyphenation import Hyphenator -from etpgrf.layout import LayoutProcessor -from etpgrf.quotes import QuotesProcessor -from etpgrf.sanitizer import SanitizerProcessor -from etpgrf.symbols import SymbolsProcessor -from etpgrf.typograph import Typographer -from etpgrf.unbreakables import Unbreakables diff --git a/etpgrf_site/etpgrf/codec.py b/etpgrf_site/etpgrf/codec.py deleted file mode 100644 index db040a6..0000000 --- a/etpgrf_site/etpgrf/codec.py +++ /dev/null @@ -1,58 +0,0 @@ -# etpgrf/codec.py -# Модуль для преобразования текста между Unicode и HTML-мнемониками. - -import regex -import html -from . import config -# from etpgrf.config import (ALL_ENTITIES, ALWAYS_MNEMONIC_IN_SAFE_MODE, MODE_MNEMONIC, MODE_MIXED) - -# --- Создаем словарь для кодирования Unicode -> Mnemonic --- -# Получаем готовую карту для кодирования один раз при импорте -_ENCODE_MAP = config.get_encode_map() -# Создаем таблицу для быстрой замены через str.translate -_TRANSLATE_TABLE = str.maketrans(_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: - # Если текст пустой, просто возвращаем его - return text - if mode == config.MODE_UNICODE: - # В режиме 'unicode' ничего не делаем - return text - - if mode == config.MODE_MNEMONIC: - # В режиме 'mnemonic' заменяем все известные символы, используя - # заранее скомпилированную таблицу для максимальной производительности. - return text.translate(_TRANSLATE_TABLE) - if mode == config.MODE_MIXED: - # Создаем временную карту только для "безопасных" символов - safe_map = { - char: _ENCODE_MAP[char] - for char in config.SAFE_MODE_CHARS_TO_MNEMONIC - if char in _ENCODE_MAP - } - if not safe_map: - return text - return text.translate(str.maketrans(safe_map)) - - # Возвращаем исходный текст, если режим не распознан - return text diff --git a/etpgrf_site/etpgrf/comutil.py b/etpgrf_site/etpgrf/comutil.py deleted file mode 100644 index 50ef2d2..0000000 --- a/etpgrf_site/etpgrf/comutil.py +++ /dev/null @@ -1,140 +0,0 @@ -# etpgrf/comutil.py -# Общие функции для типографа etpgrf -from etpgrf.config import MODE_UNICODE, MODE_MNEMONIC, MODE_MIXED, SUPPORTED_LANGS, DEFAULT_LANGS -from etpgrf.defaults import etpgrf_settings -import os -import regex -import logging - -# --- Настройки логирования --- -logger = logging.getLogger(__name__) - - -def parse_and_validate_mode( - mode_input: str | None = None, -) -> str: - """ - Обрабатывает и валидирует входной параметр mode. - Если mode_input не предоставлен (None), используется режим по умолчанию. - - :param mode_input: Режим обработки текста. Может быть 'unicode', 'mnemonic' или 'mixed'. - :return: Валидированный режим в нижнем регистре. - :raises TypeError: Если mode_input имеет неожиданный тип. - :raises ValueError: Если mode_input пуст после обработки или содержит неподдерживаемый режим. - """ - if mode_input is None: - # Если mode_input не предоставлен явно, используем режим по умолчанию - _mode_input = etpgrf_settings.MODE - else: - _mode_input = str(mode_input).lower() - - if _mode_input not in {MODE_UNICODE, MODE_MNEMONIC, MODE_MIXED}: - raise ValueError( - f"etpgrf: режим '{_mode_input}' не поддерживается. Поддерживаемые режимы: {MODE_UNICODE}, {MODE_MNEMONIC}, {MODE_MIXED}" - ) - - return _mode_input - - -def parse_and_validate_langs( - langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, -) -> list[str]: - """ - Обрабатывает и валидирует входной параметр языков. - Если langs_input не предоставлен (None), используются языки по умолчанию - (сначала из переменной окружения ETPGRF_DEFAULT_LANGS, затем внутренний дефолт). - - :param langs: Язык(и) для обработки. Может быть строкой (например, "ru+en"), списком, кортежем или frozenset. - :return: Frozenset валидированных кодов языков в нижнем регистре. - :raises TypeError: Если langs_input имеет неожиданный тип. - :raises ValueError: Если langs_input пуст после обработки или содержит неподдерживаемые коды. - """ - _langs = langs - - if _langs is None: - # Если langs не предоставлен явно, будем выкручиваться и искать в разных местах - # 1. Попытка получить языки из переменной окружения системы - env_default_langs = os.environ.get('ETPGRF_DEFAULT_LANGS') - if env_default_langs: - # Нашли язык для библиотеки в переменных окружения - _langs = env_default_langs - else: - # Если в переменной окружения нет, используем то, что есть в конфиге `etpgrf/config.py` - _langs = DEFAULT_LANGS - - if isinstance(_langs, str): - # Разделяем строку по любым небуквенным символам, приводим к нижнему регистру - # и фильтруем пустые строки - parsed_lang_codes_list = [lang.lower() for lang in regex.split(r'[^a-zA-Z]+', _langs) if lang] - elif isinstance(_langs, (list, tuple, frozenset)): # frozenset тоже итерируемый - # Приводим к строке, нижнему регистру и проверяем, что строка не пустая - parsed_lang_codes_list = [str(lang).lower() for lang in _langs if str(lang).strip()] - else: - raise TypeError( - f"etpgrf: параметр 'langs' должен быть строкой, списком, кортежем или frozenset. Получен: {type(_langs)}" - ) - - if not parsed_lang_codes_list: - raise ValueError( - "etpgrf: параметр 'langs' не может быть пустым или приводить к пустому списку языков после обработки." - ) - - # Валидируем языки, сохраняя порядок и удаляя дубликаты - validated_langs = [] - seen_langs = set() - for code in parsed_lang_codes_list: - if code not in SUPPORTED_LANGS: - raise ValueError( - f"etpgrf: код языка '{code}' не поддерживается. Поддерживаемые языки: {list(SUPPORTED_LANGS)}" - ) - if code not in seen_langs: - validated_langs.append(code) - seen_langs.add(code) - - if not validated_langs: - raise ValueError("etpgrf: не предоставлено ни одного валидного кода языка.") - - return validated_langs - - -def is_inside_unbreakable_segment( - word_segment: str, - split_index: int, - unbreakable_set: frozenset[str] | list[str] | set[str], -) -> bool: - """ - Проверяет, находится ли позиция разбиения внутри неразрывного сегмента. - - :param word_segment: -- Сегмент слова, в котором мы ищем позицию разбиения. - :param split_index: -- Индекс (позиция внутри сегмента), по которому мы хотим проверить разбиение. - :param unbreakable_set: -- Набор неразрывных сегментов (например: диграфы, триграфы, акронимы...). - :return: - """ - segment_len = len(word_segment) - # Проверяем, что позиция разбиения не выходит за границы сегмента - if not (0 < split_index < segment_len): - return False - # Пер образуем все в верхний регистр, чтобы сравнения строк работали - word_segment_upper = word_segment.upper() - # unbreakable_set_upper = (unit.upper() for unit in unbreakable_set) # <-- С помощью генератора - - # Отсортируем unbreakable_set по длине лексем (чем короче, тем больше шансов на "ранний выход") - # и заодно превратим в list - sorted_units = sorted(unbreakable_set, key=len) - # sorted_units = sorted(unbreakable_set_upper, key=len) - for unbreakable in sorted_units: - unit_len = len(unbreakable) - if unit_len < 2: - continue - # Спорно, что преобразование в верхний регистр эффективнее делать тут, но благодаря возможному - # "раннему выходу" это может быть быстрее с помощью генератора (см. выше комментарии) - unbreakable_upper = unbreakable.upper() - for offset in range(1, unit_len): - position_start_in_segment = split_index - offset - position_end_in_segment = position_start_in_segment + unit_len - # Убедимся, что предполагаемое положение 'unit' не выходит за границы word_segment - if position_start_in_segment >= 0 and position_end_in_segment <= segment_len and \ - word_segment_upper[position_start_in_segment:position_end_in_segment] == unbreakable_upper: - # Нашли 'unbreakable', и split_index находится внутри него. - return True - return False diff --git a/etpgrf_site/etpgrf/config.py b/etpgrf_site/etpgrf/config.py deleted file mode 100644 index 11bbdc7..0000000 --- a/etpgrf_site/etpgrf/config.py +++ /dev/null @@ -1,722 +0,0 @@ -# etpgrf/conf.py -# Настройки по умолчанию и "источник правды" для типографа etpgrf -from html import entities - -# === КОНФИГУРАЦИИ === -# Режимы "отдачи" результатов обработки -MODE_UNICODE = "unicode" -MODE_MNEMONIC = "mnemonic" -MODE_MIXED = "mixed" - -# Языки, поддерживаемые библиотекой -LANG_RU = 'ru' # Русский -LANG_RU_OLD = 'ruold' # Русская дореволюционная орфография -LANG_EN = 'en' # Английский -SUPPORTED_LANGS = frozenset([LANG_RU, LANG_RU_OLD, LANG_EN]) -DEFAULT_LANGS = (LANG_RU, LANG_EN) # Языки по умолчанию - -# Виды санитизации (очистки) входного текста -SANITIZE_ALL_HTML = "html" # Полная очистка от HTML-тегов -SANITIZE_ETPGRF = "etp" # Очистка от "span-оберток" символов висячей пунктуации (если она была расставлена - # при предыдущих проходах типографа) -SANITIZE_NONE = None # Без очистки (режим по умолчанию). False тоже можно использовать. - -# === ИСТОЧНИК ПРАВДЫ === -# --- Базовые алфавиты: Эти константы используются как для правил переноса, так и для правил кодирования --- - -# Русский алфавит -RU_VOWELS_UPPER = frozenset(['А', 'О', 'И', 'Е', 'Ё', 'Э', 'Ы', 'У', 'Ю', 'Я']) -RU_CONSONANTS_UPPER = frozenset(['Б', 'В', 'Г', 'Д', 'Ж', 'З', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', 'Ц', 'Ч', 'Ш', 'Щ']) -RU_J_SOUND_UPPER = frozenset(['Й']) -RU_SIGNS_UPPER = frozenset(['Ь', 'Ъ']) -RU_ALPHABET_UPPER = RU_VOWELS_UPPER | RU_CONSONANTS_UPPER | RU_J_SOUND_UPPER | RU_SIGNS_UPPER -RU_ALPHABET_LOWER = frozenset([char.lower() for char in RU_ALPHABET_UPPER]) -RU_ALPHABET_FULL = RU_ALPHABET_UPPER | RU_ALPHABET_LOWER - -# Английский алфавит -EN_VOWELS_UPPER = frozenset(['A', 'E', 'I', 'O', 'U', 'Æ', 'Œ']) -EN_CONSONANTS_UPPER = frozenset(['B', 'C', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Y', 'Z']) -EN_ALPHABET_UPPER = EN_VOWELS_UPPER | EN_CONSONANTS_UPPER -EN_ALPHABET_LOWER = frozenset([char.lower() for char in EN_ALPHABET_UPPER]) -EN_ALPHABET_FULL = EN_ALPHABET_UPPER | EN_ALPHABET_LOWER - -# --- Специальные символы --- -CHAR_NBSP = '\u00a0' # Неразрывный пробел ( ) -CHAR_SHY = '\u00ad' # Мягкий перенос (­) -CHAR_THIN_SP = '\u2009' # Тонкий пробел (шпация,  ) -CHAR_NDASH = '\u2013' # Cреднее тире (– / –) -CHAR_MDASH = '\u2014' # Длинное тире (— / —) -CHAR_HELLIP = '\u2026' # Многоточие (… / …) -CHAR_RU_QUOT1_OPEN = '«' # Русские кавычки открывающие (« / «) -CHAR_RU_QUOT1_CLOSE = '»' -CHAR_RU_QUOT2_OPEN = '„' -CHAR_RU_QUOT2_CLOSE = '“' -CHAR_EN_QUOT1_OPEN = '“' -CHAR_EN_QUOT1_CLOSE = '”' -CHAR_EN_QUOT2_OPEN = '‘' -CHAR_EN_QUOT2_CLOSE = '’' -CHAR_COPY = '\u00a9' # Символ авторского права / © / © -CHAR_REG = '\u00ae' # Зарегистрированная торговая марка / ® / ® -CHAR_COPYP = '\u2117' # Знак звуковой записи / ℗ / ©p; -CHAR_TRADE = '\u2122' # Знак торговой марки / ™ / ™ -CHAR_ARROW_LR_DOUBLE = '\u21d4' # Двойная двунаправленная стрелка / ⇔ / ⇔ -CHAR_ARROW_L_DOUBLE = '\u21d0' # Двойная стрелка влево / ⇐ / ⇐ -CHAR_ARROW_R_DOUBLE = '\u21d2' # Двойная стрелка вправо / ⇒ / ⇒ -CHAR_AP = '\u2248' # Приблизительно равно / ≈ / ≈ -CHAR_ARROW_L = '\u27f5' # Стрелка влево / ← / ← -CHAR_ARROW_R = '\u27f6' # Стрелка вправо / → / → -CHAR_ARROW_LR = '\u27f7' # Длинная двунаправленная стрелка ↔ / ↔ -CHAR_ARROW_L_LONG_DOUBLE = '\u27f8' # Длинная двойная стрелка влево -CHAR_ARROW_R_LONG_DOUBLE = '\u27f9' # Длинная двойная стрелка вправо -CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двунаправленная стрелка -CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / · -CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить - # в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом. - - -# === КОНСТАНТЫ ПСЕВДОГРАФИКИ === -# Для простых замен "строка -> символ" используем список кортежей. -# Порядок важен: более длинные последовательности должны идти раньше более коротких, которые -# могут быть их частью (например, '<---' до '---', а та, в свою очередь, до '--'). -STR_TO_SYMBOL_REPLACEMENTS = [ - # 5-символьные последовательности - ('<===>', CHAR_ARROW_LR_LONG_DOUBLE), # Длинная двойная двунаправленная стрелка - # 4-символьные последовательности - ('<===', CHAR_ARROW_L_LONG_DOUBLE), # Длинная двойная стрелка влево - ('===>', CHAR_ARROW_R_LONG_DOUBLE), # Длинная двойная стрелка вправо - ('<==>', CHAR_ARROW_LR_DOUBLE), # Двойная двунаправленная стрелка - ('(tm)', CHAR_TRADE), ('(TM)', CHAR_TRADE), # Знак торговой марки (нижний и верхний регистр) - ('<-->', CHAR_ARROW_LR), # Длинная двунаправленная стрелка - # 3-символьные последовательности - ('<--', CHAR_ARROW_L), # Стрелка влево - ('-->', CHAR_ARROW_R), # Стрелка вправо - ('==>', CHAR_ARROW_R_DOUBLE), # Двойная стрелка вправо - ('<==', CHAR_ARROW_L_DOUBLE), # Двойная стрелка влево - ('---', CHAR_MDASH), # Длинное тире - ('...', CHAR_HELLIP), # Многоточие - ('(c)', CHAR_COPY), ('(C)', CHAR_COPY), # Знак авторского права (нижний и верхний регистр) - ('(r)', CHAR_REG), ('(R)', CHAR_REG), # Знак зарегистрированной торговой марки (нижний и верхний регистр) - ('(p)', CHAR_COPYP), ('(P)', CHAR_COPYP), # Знак права на звукозапись (нижний и верхний регистр) - # 2-символьные последовательности - ('--', CHAR_NDASH), # Среднее тире (дефисные соединения и диапазоны) - ('~=', CHAR_AP), # Приблизительно равно (≈) -] - - -# === КОНСТАНТЫ ДЛЯ КОДИРОВАНИЯ HTML-МНЕМНОИКОВ === -# --- ЧЕРНЫЙ СПИСОК: Символы, которые НИКОГДА не нужно кодировать в мнемоники --- -NEVER_ENCODE_CHARS = (frozenset(['!', '#', '%', '(', ')', '*', ',', '.', '/', ':', ';', '=', '?', '@', - '[', '\\', ']', '^', '_', '`', '{', '|', '}', '~', '\n', '\t', '\r']) - | RU_ALPHABET_FULL | EN_ALPHABET_FULL) - -# 2. БЕЛЫЙ СПИСОК (ДЛЯ БЕЗОПАСНОСТИ): -# Символы, которые ВСЕГДА должны превращаться в мнемоники в "безопасных" режимах вывода. Сюда добавлены символы, -# которые не видны, на глаз и не отличимы друг от друга в обычном тексте, или очень специфичные -SAFE_MODE_CHARS_TO_MNEMONIC = frozenset([ - '<', '>', '&', '"', '\'', - CHAR_SHY, # Мягкий перенос (Soft Hyphen) -- ­ - CHAR_NBSP, # Неразрывный пробел (Non-Breaking Space) --   - '\u2002', # Полужирный пробел (En Space) --   - '\u2003', # Широкий пробел (Em Space) --   - '\u2007', # Цифровой пробел --   - '\u2008', # Пунктуационный пробел --   - CHAR_THIN_SP, # Межсимвольный пробел, тонкий пробел, шпация --  ' - '\u200A', # Толщина волоса (Hair Space) --   - '\u200B', # Негативный пробел (Negative Space) -- ​ - '\u200C', # Нулевая ширина (без объединения) (Zero Width Non-Joiner) -- ‍ - '\u200D', # Нулевая ширина (с объединением) (Zero Width Joiner) -- ‌ - '\u200E', # Изменить направление текста на слева-направо (Left-to-Right Mark /LRE) -- ‎ - '\u200F', # Изменить направление текста направо-налево (Right-to-Left Mark /RLM) -- ‏ - '\u2010', # Дефис (Hyphen) -- ‐ - '\u205F', # Средний пробел (Medium Mathematical Space) --   - '\u2060', # ⁠ - '\u2062', # ⁢ -- для семантической разметки математических выражений - '\u2063', # ⁣ -- для семантической разметки математических выражений - ]) - -# 3. СПИСОК ДЛЯ ЧИСЛОВОГО КОДИРОВАНИЯ: Символы без стандартного имени. -ALWAYS_ENCODE_TO_NUMERIC_CHARS = frozenset([ - '\u058F', # Знак армянского драма (֏) - '\u20B4', # Знак украинской гривны (₴) - '\u20B8', # Знак казахстанского тенге (₸) - '\u20B9', # Знак индийской рупии (₹) - '\u20BA', # Знак турецкой лиры (₺) - '\u20BB', # Знак итальянской лиры (₻) - '\u20BC', # Знак азербайджанского маната - '\u20BD', # Знак русского рубля (₽) - '\u20BE', # Знак грузинский лари (₾) - '\u20BF', # Знак биткоина (₿) -]) - -# 4. СЛОВАРЬ ПРИОРИТЕТОВ: Кастомные и/или предпочитаемые мнемоники. -# Некоторые utf-символы имеют несколько мнемоник, а значит для таких символов преобразование -# в из utf во html-мнемоники может иметь несколько вариантов. Словарь приоритетов задает предпочтительное -# преобразование. Эти правила применяются в последнюю очередь и имеют наивысший приоритет, -# гарантируя предсказуемый результат для символов с несколькими именами. -# -# Также можно использовать для создания исключений из "черного списка" NEVER_ENCODE_CHARS. -CUSTOM_ENCODE_MAP = { - # '\u2010': '‐', # Для \u2010 всегда предпочитаем ‐, а не ‐ - # # Исключения для букв, которые есть в алфавитах, но должны кодироваться (для обеспечения консистентности): - # # 'Æ': 'Æ', - # # 'Œ': 'Œ', - # # 'æ': 'æ', - # # 'œ': 'œ', - # '\u002a': '*', # * / * / * - # '\u005b': '[', # [ / [ / [ - # '\u005d': ']', # ] / ] / ] - # '\u005f': '_', # _ / _ / _ - # '\u007b': '{', # { / { / { - # '\u007d': '}', # } / } / } - # '\u007c': '|', # | / | / | / | - CHAR_NBSP: ' ', # /   /   - CHAR_REG: '®', # ® / ® / ® / ® - CHAR_COPY: '©', # © / © / © - '\u0022': '"', # " / " / " - '\u0026': '&', # & / & / & - '\u003e': '>', # > / > / > - '\u003c': '<', # < / < / < - CHAR_MIDDOT: '·', # · / · / · / · - '\u0060': '`', # ` / ` / ` - '\u00a8': '¨', # ¨ / ¨ / ¨ / ¨ / ¨ - '\u00b1': '±', # ± / ± / ± - '\u00bd': '½', # ½ / ½ / ½ - '\u00af': '¯', # ¯ / ¯ / ¯ - '\u201a': '‚', # ‚ / ‚ / ‚ - '\u223e': '∾', # ∾ / ∾ / ∾ - '\u2207': '∇', # ∇ / ∇ / ∇ - '\u2061': '⁡', # / ⁡ / ⁡ - '\u2221': '∡', # ∡ / ∡ / ∡ - CHAR_AP: '≈', # ≈ / ≈ / ≈ / ≈ / ≈ / ≈ - '\u224a': '≊', # ≊ / ≊ / ≊ - '\u2254': '≔', # ≔ / ≔ / ≔ / ≔ - '\u224d': '≍', # ≍ / ≍ / ≍ - '\u2233': '∳', # ∳ / ∳ / ∳ - '\u224c': '≌', # ≌ / ≌ / ≌ - '\u03f6': '϶', # ϶ / ϶ / ϶ - '\u2035': '‵', # ‵ / ‵ / ‵ - '\u223d': '∽', # ∽ / ∽ / ∽ - '\u22cd': '⋍', # ⋍ / ⋍ / ⋍ - '\u2216': '∖', # ∖ / ∖ / ∖ / ∖ / ∖ / ∖ - '\u2306': '⌆', # ⌆ / ⌆ / ⌆ - '\u2305': '⌅', # ⌅ / ⌅ / ⌅ - '\u23b5': '⎵', # ⎵ / ⎵ / ⎵ - '\u2235': '∵', # ∵ / ∵ / ∵ / ∵ - '\u212c': 'ℬ', # ℬ / ℬ / ℬ / ℬ - '\u2264': '≤', # ≤ / ≤ / ≤ - '\u226c': '≬', # ≬ / ≬ / ≬ - '\u22c2': '⋂', # ⋂ / ⋂ / ⋂ / ⋂ - '\u25ef': '◯', # ◯ / ◯ / ◯ - '\u22c3': '⋃', # ⋃ / ⋃ / ⋃ / ⋃ - '\u2a00': '⨀', # ⨀ / ⨀ / ⨀ - '\u2a01': '⨁', # ⨁ / ⨁ / ⨁ - '\u2a02': '⨂', # ⨂ / ⨂ / ⨂ - '\u2a06': '⨆', # ⨆ / ⨆ / ⨆ - '\u2605': '★', # ★ / ★ / ★ - '\u25bd': '▽', # ▽ / ▽ / ▽ - '\u25b3': '△', # △ / △ / △ - '\u2a04': '⨄', # ⨄ / ⨄ / ⨄ - '\u22c1': '⋁', # ⋁ / ⋁ / ⋁ / ⋁ - '\u22c0': '⋀', # ⋀ / ⋀ / ⋀ / $bigwedge; - '\u2227': '∧', # ∧ / ∧ / ∧ - '\u290d': '⤍', # ⤍ / ⤍ / ⤍ - '\u29eb': '⧫', # ⧫ / ⧫ / ⧫ - '\u25ca': '◊', # ◊ / ◊ / &lozenge - '\u25aa': '▪', # ▪ / ▪ / ▪ / ▪ / ▪ - '\u25b4': '▴', # ▴ / ▴ / ▴ - '\u25be': '▾', # ▾ / ▾ / ▾ - '\u25c2': '◂', # ◂ / ◂ / ◂ - '\u25b8': '▸', # ▸ / ▸ / ▸ - '\u22a5': '⊥', # ⊥ / ⊥ / ⊥ / ⊥ / ⊥ - '\u2500': '─', # ─ / ─ / ─ - '\u229f': '⊟', # ⊟ / ⊟ / ⊟ - '\u229e': '⊞', # ⊞ / ⊞ / ⊞ - '\u22a0': '⊠', # ⊠ / ⊠ / ⊠ - '\u02d8': '˘', # ˘ / ˘ / ˘ - '\u224e': '≎', # ≎ / ≎ / ≎ / ≎ - '\u224f': '≏', # ≏ / ≏ / ≏ / ≏ - '\u2145': 'ⅅ', # ⅅ / ⅅ / ⅅ - '\u02c7': 'ˇ', # ˇ / ˇ / ˇ - '\u212d': 'ℭ', # ℭ / ℭ / ℭ - '\u2713': '✓', # ✓ / ✓ / ✓ - '\u2257': '≗', # ≗ / ≗ / ≗ - '\u21ba': '↺', # ↺ / ↺ / ↺ - '\u21bb': '↻', # ↻ / ↻ / ↻ - '\u229b': '⊛', # ⊛ / ⊛ / ⊛ - '\u229a': '⊚', # ⊚ / ⊚ / ⊚ - '\u229d': '⊝', # ⊝ / ⊝ / ⊝ - '\u2299': '⊙', # ⊙ / ⊙ / ⊙ - '\u2200': '∀', # ∀ / ∀ / ∀ - '\u24c8': 'Ⓢ', # Ⓢ / Ⓢ / Ⓢ - '\u2296': '⊖', # ⊖ / ⊖ / ⊖ - '\u2232': '∲', # ∲ / ∲ / ∲ - '\u201d': '”', # ” / ” / ” / ” - '\u2019': '’', # ’ / ’ / ’ / ’ - '\u2237': '∷', # ∷ / ∷ / ∷ - '\u2201': '∁', # ∁ / ∁ / ∁ - '\u2218': '∘', # ∘ / ∘ / ∘ - '\u2102': 'ℂ', # ℂ / ℂ / ℂ - '\u222f': '∯', # ∯ / ∯ / ∯ - '\u222e': '∮', # ∮ / ∮ / ∮ / ∮ - '\u2210': '∐', # ∐ / ∐ / ∐ - '\u22de': '⋞', # ⋞ / ⋞ / ⋞ - '\u22df': '⋟', # ⋟ / ⋟ / ⋟ - '\u21b6': '↶', # ↶ / ↶ / ↶ - '\u21b7': '↷', # ↷ / ↷ / ↷ - '\u22ce': '⋎', # ⋎ / ⋎ / ⋎ - '\u22cf': '⋏', # ⋏ / ⋏ / ⋏ - '\u2010': '‐', # ‐ / ‐ / ‐ - '\u2ae4': '⫤', # ⫤ / ⫤ / ⫤ - '\u22a3': '⊣', # ⊣ / ⊣ / ⊣ - '\u290f': '⤏', # ⤏ / ⤏ / ⤏ - '\u02dd': '˝', # ˝ / ˝ / ˝ - '\u2146': 'ⅆ', # ⅆ / ⅆ / ⅆ - '\u21ca': '⇊', # ⇊ / ⇊ / ⇊ - '\u2a77': '⩷', # ⩷ / ⩷ / ⩷ - '\u21c3': '⇃', # ⇃ / ⇃ / ⇃ / ⇃ - '\u21c2': '⇂', # ⇂ / ⇂ / ⇂ / ⇂ - '\u02d9': '˙', # ˙ / ˙ / ˙ - '\u222b': '∫', # ∫ / ∫ / ∫ - '\u22c4': '⋄', # ⋄ / ⋄ / ⋄ / ⋄ - '\u03b5': 'ε', # ε / ε / ε - '\u03dd': 'ϝ', # ϝ / ϝ / ϝ - '\u22c7': '⋇', # ⋇ / ⋇ / ⋇ - '\u231e': '⌞', # ⌞ / ⌞ / ⌞ - '\u2250': '≐', # ≐ / ≐ / ≐ / ≐ - '\u2251': '≑', # ≑ / ≑ / ≑ - '\u2238': '∸', # ∸ / ∸ / ∸ - '\u2214': '∔', # ∔ / ∔ / ∔ - '\u22a1': '⊡', # ⊡ / ⊡ / ⊡ - '\u21d3': '⇓', # ⇓ / ⇓ / ⇓ / ⇓ - CHAR_ARROW_R_DOUBLE: '⇒', # ⇒ / ⇒ / ⇒ / ⇒ / ⇒ - CHAR_ARROW_L_DOUBLE: '⇐', # ⇐ / ⇐ / ⇐ / ⇐ - CHAR_ARROW_LR_DOUBLE: '⇔', # ⇔ / ⇔ / ⇔ / ⇔ / ⇔ - CHAR_ARROW_L_LONG_DOUBLE: '⟸', # ⟸ / ⟸ / ⟸ / ⟸ - CHAR_ARROW_R_LONG_DOUBLE: '⟹', # ⟹ / ⟹ / ⟹ / ⟹ - CHAR_ARROW_LR_LONG_DOUBLE: '⟺', # ⟺ / ⟺ / ⟺ / ⟺ - '\u22a8': '⊨', # ⊨ / ⊨ / ⊨ - '\u21d1': '⇑', # ⇑ / ⇑ / ⇑ / ⇑ - '\u2202': '∂', # ∂ / ∂ / ∂ - '\u21d5': '⇕', # ⇕ / ⇕ / ⇕ / ⇕ - '\u2225': '∥', # ∥ / ∥ / ∥ / ∥ / ∥ / ∥ - '\u2193': '↓', # ↓ / ↓ / ↓ / ↓ / ↓ - '\u21f5': '⇵', # ⇵ / ⇵ / ⇵ - '\u21bd': '↽', # ↽ / ↽ /↽ / ↽ - '\u21c1': '⇁', # ⇁ / ⇁ / ⇁ / ⇁ - '\u22a4': '⊤', # ⊤ / ⊤ / ⊤ - '\u21a7': '↧', # ↧ / ↧ / ↧ - '\u2910': '⤐', # ⤐ / ⤐ / ⤐ - '\u231f': '⌟', # ⌟ / ⌟ / ⌟ - '\u25bf': '▿', # ▿ / ▿ / ▿ - '\u296f': '⥯', # ⥯ / ⥯ / ⥯ - '\u2256': '≖', # ≖ / ≖ / ≖ - '\u2255': '≕', # ≕ / ≕ / ≕ - '\u2147': 'ⅇ', # ⅇ / ⅇ / ⅇ / ⅇ - '\u2252': '≒', # ≒ / ≒ / ≒ - '\u2a96': '⪖', # ⪖ / ⪖ / ⪖ - '\u2208': '∈', # ∈ / ∈ / ∈ / ∈ / ∈ - '\u2a95': '⪕', # ⪕ / ⪕ / ⪕ - '\u2205': '∅', # ∅ / ∅ / ∅ / ∅ / ∅ - '\u03f5': 'ϵ', # ϵ / ϵ / ϵ / ϵ - '\u2242': '≂', # ≂ / ≂ / ≂ / ≂ - '\u225f': '≟', # ≟ / ≟ / ≟ - '\u21cc': '⇌', # ⇌ / ⇌ / ⇌ / ⇌ - '\u2253': '≓', # ≓ / ≓ / ≓ - '\u2130': 'ℰ', # ℰ / ℰ / ℰ - '\u22d4': '⋔', # ⋔ / ⋔ / ⋔ - '\u2131': 'ℱ', # ℱ / ℱ / ℱ - '\u2322': '⌢', # ⌢ / ⌢ / ⌢ - '\u2a86': '⪆', # ⪆ / ⪆ / ⪆ - '\u2267': '≧', # ≧ / ≧ / ≧ / ≧ - '\u2a8c': '⪌', # ⪌ / ⪌ / ⪌ - '\u22db': '⋛', # ⋛ / ⋛ / ⋛ / ⋛ - '\u2265': '≥', # ≥ / ≥ / ≥ / ≥ - '\u2a7e': '⩾', # ⩾ / ⩾ / ⩾ / ⩾ - '\u22d9': '⋙', # ⋙ / ⋙ / ⋙ - '\u226b': '≫', # ≫ / &gg ;/ ≫ / ≫ - '\u2277': '≷', # ≷ / ≷ / ≷ / ≷ - '\u2a8a': '⪊', # ⪊ / ⪊ / ⪊ - '\u2269': '≩', # ≩ / ≩ / ≩ - '\u2260': '≠', # ≠ / ≠ / ≠ - '\u2a88': '⪈', # ⪈ / ⪈ / ⪈ - '\u2273': '≳', # ≳ / ≳ / ≳ / ≳ - '\u22d7': '⋗', # ⋗ / ⋗ / ⋗ - '\u200a': ' ', # /   /   - '\u210b': 'ℋ', # ℋ / ℋ / ℋ / ℋ - '\u21ad': '↭', # ↭ / ↭ / ↭ - '\u210f': 'ℏ', # ℏ / ℏ / ℏ / ℏ / ℏ - '\u210c': 'ℌ', # ℌ / ℌ / ℌ - '\u2925': '⤥', # ⤥ / ⤥ / ⤥ - '\u2926': '⤦', # ⤦ / ⤦ / ⤦ - '\u21a9': '↩', # ↩ / ↩ / ↩ - '\u21aa': '↪', # ↪ / ↪ / ↪ - '\u210d': 'ℍ', # ℍ / ℍ / ℍ - '\u2063': '⁣', # ⁣ / ⁣ / ⁣ - '\u2111': 'ℑ', # ℑ / ℑ / ℑ / ℑ / ℑ - '\u2148': 'ⅈ', # ⅈ / ⅈ / ⅈ - '\u2a0c': '⨌', # ⨌ / ⨌ / ⨌ - '\u222d': '∭', # ∭ / ∭ / ∭ - '\u2110': 'ℐ', # ℐ / ℐ / ℐ - '\u0131': 'ı', # ı / ı / ı - '\u22ba': '⊺', # ⊺ / ⊺ / ⊺ - '\u2124': 'ℤ', # ℤ / ℤ / ℤ - '\u2a3c': '⨼', # ⨼ / ⨼ / ⨼ - '\u2062': '⁢', # ⁢ / ⁢ / ⁢ - '\u03f0': 'ϰ', # ϰ / ϰ / ϰ - '\u21da': '⇚', # ⇚ / ⇚ / ⇚ - '\u2112': 'ℒ', # ℒ / ℒ / ℒ / ℒ - '\u27e8': '⟨', # ⟨ / ⟨ / ⟨ / ⟨ - '\u2a85': '⪅', # ⪅ / ⪅ / ⪅ - '\u219e': '↞', # ↞ / ↞ / ↞ - '\u21e4': '⇤', # ⇤ / ⇤ / ⇤ - '\u21ab': '↫', # ↫ / ↫ / ↫ - '\u21a2': '↢', # ↢ / ↢ / ↢ - '\u2266': '≦', # ≦ / ≦ / ≦ / ≦ - '\u2190': '←', # ← / ← / ← / ← / ← / ← - '\u21c6': '⇆', # ⇆ / ⇆ / ⇆ / ⇆ - '\u27e6': '⟦', # ⟦ / ⟦ / ⟦ - '\u21bc': '↼', # ↼ / ↼ / ↼ / ↼ - '\u21c7': '⇇', # ⇇ / ⇇ / ⇇ - '\u2194': '↔', # ↔ / ↔ / ↔ / ↔ - '\u21cb': '⇋', # ⇋ / ⇋ / ⇋ / ⇋ - '\u21a4': '↤', # ↤ / ↤ / ↤ - '\u22cb': '⋋', # ⋋ / ⋋ / ⋋ - '\u22b2': '⊲', # ⊲ / ⊲ / ⊲ / ⊲ - '\u22b4': '⊴', # ⊴ / ⊴ / ⊴ / ⊴ - '\u21bf': '↿', # ↿ / ↿ / ↿ / ↿ - '\u2308': '⌈', # ⌈ / ⌈ / ⌈ - '\u230a': '⌊', # ⌊ / ⌊ / ⌊ - '\u2a8b': '⪋', # ⪋ / ⪋ / ⪋ - '\u22da': '⋚', # ⋚ / ⋚ / ⋚ / ⋚ - '\u2a7d': '⩽', # ⩽ / ⩽ / ⩽ / ⩽ - '\u22d6': '⋖', # ⋖ / ⋖ / ⋖ - '\u2276': '≶', # ≶ / ≶ / ≶ / ≶ - '\u2272': '≲', # ≲ / ≲ / ≲ / ≲ - '\u226a': '≪', # ≪ / ≪ / ≪ / ≪ - '\u23b0': '⎰', # ⎰ / ⎰ / ⎰ - '\u2a89': '⪉', # ⪉ / ⪉ / ⪉ - '\u2268': '≨', # ≨ / ≨ / ≨ - '\u2a87': '⪇', # ⪇ / ⪇ / ⪇ - CHAR_ARROW_L: '⟵', # ⟵ / ⟵ / ⟵ / ⟵ - CHAR_ARROW_R: '⟶', # ⟶ / ⟶ / ⟶ / ⟶ - CHAR_ARROW_LR: '⟷', # ⟷ / ⟷ / ⟷ / ⟷ - '\u27fc': '⟼', # ⟼ / ⟼ / ⟼ - '\u21ac': '↬', # ↬ / ↬ / ↬ - '\u201e': '„', # „ / „ / „ - '\u2199': '↙', # ↙ / ↙ / ↙ / ↙ - '\u2198': '↘', # ↘ / ↘ / ↘ / ↘ - '\u21b0': '↰', # ↰ / ↰ / ↰ - '\u25c3': '◃', # ◃ / ◃ / ◃ - '\u2720': '✠', # ✠ / ✠ / ✠ - '\u21a6': '↦', # ↦ / ↦ / ↦ / ↦ - '\u21a5': '↥', # ↥ / ↥ / ↥ - '\u2133': 'ℳ', # ℳ / ℳ / ℳ / ℳ - '\u2223': '∣', # ∣ / ∣ / ∣ / ∣ / ∣ - '\u2213': '∓', # ∓ / ∓ / ∓ / ∓ - CHAR_HELLIP: '…', # … / … / … - '\u22b8': '⊸', # ⊸ / ⊸ / ⊸ - '\u2249': '≉', # ≉ / ≉ / ≉ / ≉ - '\u266e': '♮', # ♮ / ♮ / ♮ - '\u2115': 'ℕ', # ℕ / ℕ / ℕ - '\u2247': '≇', # ≇ / ≇ / ≇ - '\u2197': '↗', # ↗ / ↗ / ↗ / ↗ - '\u200b': '​', # / ​ / ​ / ​ - # ​ / ​ - '\u2262': '≢', # ≢ / ≢ / ≢ - '\u2928': '⤨', # ⤨ / ⤨ / ⤨ - '\u2203': '∃', # ∃ / ∃ / ∃ - '\u2204': '∄', # ∄ / ∄ / ∄ / ∄ - '\u2271': '≱', # ≱ / ≱ / ≱ / ≱ - '\u2275': '≵', # ≵ / ≵ / ≵ - '\u226f': '≯', # ≯ / ≯ / ≯ / ≯ - '\u21ce': '⇎', # ⇎ / ⇎ / ⇎ - '\u21ae': '↮', # ↮ / ↮ / ↮ - '\u220b': '∋', # ∋ / ∋ / ∋ / ∋ / ∋ - '\u21cd': '⇍', # ⇍ / ⇍ / ⇍ - '\u219a': '↚', # ↚ / ↚ / ↚ - '\u2270': '≰', # ≰ / ≰ / ≰ / ≰ - '\u226e': '≮', # ≮ / ≮ / ≮ / ≮ - '\u2274': '≴', # ≴ / ≴ / ≴ - '\u22ea': '⋪', # ⋪ / ⋪ / ⋪ / ⋪ - '\u22ec': '⋬', # ⋬ / ⋬ / ⋬ / ⋬ - '\u2224': '∤', # ∤ / ∤ / ∤ / ∤ / ∤ - '\u2226': '∦', # ∦ / ∦ / ∦ / ∦ / ∦ / ∦ - '\u2209': '∉', # ∉ / ∉ / ∉ / ∉ - '\u2279': '≹', # ≹ / ≹ / ≹ - '\u2278': '≸', # ≸ / ≸ / ≸ - '\u220c': '∌', # ∌ / ∌ / ∌ / ∌ - '\u2280': '⊀', # ⊀ / ⊀ / ⊀ / ⊀ - '\u22e0': '⋠', # ⋠ / ⋠ / ⋠ - '\u22eb': '⋫', # ⋫ / ⋫ / ⋫ / ⋫ - '\u22ed': '⋭', # ⋭ / ⋭ / ⋭ / ⋭ - '\u22e2': '⋢', # ⋢ / ⋢ / ⋢ - '\u22e3': '⋣', # ⋣ / ⋣ / ⋣ - '\u2288': '⊈', # ⊈ / ⊈ / ⊈ / ⊈ - '\u2281': '⊁', # ⊁ / ⊁ / ⊁ / ⊁ - '\u22e1': '⋡', # ⋡ / ⋡ / ⋡ - '\u2289': '⊉', # ⊉ / ⊉ / ⊉ / ⊉ - '\u2241': '≁', # ≁ / ≁ / ≁ - '\u2244': '≄', # ≄ / ≄ / ≄ / ≄ - '\u21cf': '⇏', # ⇏ / ⇏ / ⇏ - '\u219b': '↛', # ↛ / ↛ / ↛ - '\u2196': '↖', # ↖ / ↖ / ↖ / ↖ - '\u2134': 'ℴ', # ℴ / ℴ / ℴ / ℴ - '\u203e': '‾', # ̄ / ‾ / ‾ - '\u23b4': '⎴', # ⎴ / ⎴ / ⎴ - '\u03d6': 'ϖ', # ϖ / ϖ / ϖ - '\u03d5': 'ϕ', # ϕ / ϕ / ϕ / ϕ - '\u2665': '♥', # ♥ / ♥ / ♥ / - '\u2119': 'ℙ', # ℙ / ℙ / ℙ - '\u227a': '≺', # ≺ / ≺ / ≺ / ≺ - '\u2ab7': '⪷', # ⪷ / ⪷ / ⪷ - '\u227c': '≼', # ≼ / ≼ / ≼ / ≼ - '\u2aaf': '⪯', # ⪯ / ⪯ / ⪯ / ⪯ - '\u227e': '≾', # ≾ / ≾ / ≾ / ≾ - '\u2ab9': '⪹', # ⪹ / ⪹ / ⪹ - '\u2ab5': '⪵', # ⪵ / ⪵ / ⪵ - '\u22e8': '⋨', # ⋨ / ⋨ / ⋨ - '\u220f': '∏', # ∏ / ∏ / ∏ - '\u221d': '∝', # ∝ / ∝ / ∝ / ∝ / ∝ / ∝ - '\u211a': 'ℚ', # ℚ / ℚ / ℚ - '\u21db': '⇛', # ⇛ / ⇛ / ⇛ - '\u27e9': '⟩', # ⟩ / ⟩ / ⟩ / ⟩ - '\u21a0': '↠', # ↠ / ↠ / ↠ - '\u21e5': '⇥', # ⇥ / ⇥ / ⇥ - '\u21a3': '↣', # ↣ / ↣ / ↣ - '\u2309': '⌉', # ⌉ / ⌉ / ⌉ - '\u219d': '↝', # ↝ / ↝ / ↝ - '\u03a9': 'Ω', # Ω / Ω / Ω - '\u211c': 'ℜ', # ℜ / ℜ / ℜ / ℜ / ℜ - '\u211b': 'ℛ', # ℛ / ℛ / ℛ - '\u211d': 'ℝ', # ℝ / ℝ / ℝ - '\u21c0': '⇀', # ⇀ / ⇀ / ⇀ / ⇀ - '\u03f1': 'ϱ', # ϱ / ϱ / ϱ - '\u2192': '→', # → / → / → / → / → / → - '\u21c4': '⇄', # ⇄ / ⇄ / ⇄ / ⇄ - '\u27e7': '⟧', # ⟧ / ⟧ / ⟧ - '\u230b': '⌋', # ⌋ / ⌋ / ⌋ - '\u21c9': '⇉', # ⇉ / ⇉ / ⇉ - '\u22a2': '⊢', # ⊢ / ⊢ / ⊢ - '\u22cc': '⋌', # ⋌ / ⋌ / ⋌ - '\u22b3': '⊳', # ⊳ / ⊳ / ⊳ / ⊳ - '\u22b5': '⊵', # ⊵ / ⊵ / ⊵ / ⊵ - '\u21be': '↾', # ↾ / ↾ / ↾ / ↾ - '\u23b1': '⎱', # ⎱ / ⎱ / ⎱ - '\u201c': '“', # “ / “ / “ - '\u2018': '‘', # ‘ / ‘ / ‘ - '\u21b1': '↱', # ↱ / ↱ / ↱ - '\u25b9': '▹', # ▹ / ▹ / ▹ - '\u227b': '≻', # ≻ / ≻ / ≻ / ≻ - '\u2ab8': '⪸', # ⪸ / ⪸ / ⪸ - '\u227d': '≽', # ≽ / ≽ / ≽ / ≽ - '\u2ab0': '⪰', # ⪰ / ⪰ / ⪰ / ⪰ - '\u2aba': '⪺', # ⪺ / ⪺ / ⪺ - '\u2ab6': '⪶', # ⪶ / ⪶ / ⪶ - '\u22e9': '⋩', # ⋩ / ⋩ / ⋩ - '\u227f': '≿', # ≿ / ≿ / ≿ / ≿ - '\u2929': '⤩', # ⤩ / ⤩ / ⤩ - '\u03c2': 'ς', # ς / ς / ς / ς - '\u2243': '≃', # ≃ / ≃ / ≃ / ≃ - '\u2323': '⌣', # ⌣ / ⌣ / ⌣ - '\u2660': '♠', # ♠ / ♠ / ♠ / - '\u2293': '⊓', # ⊓ / ⊓ / ⊓ - '\u2294': '⊔', # ⊔ / ⊔ / ⊔ - '\u221a': '√', # √ / √ / √ - '\u228f': '⊏', # ⊏ / ⊏ / ⊏ / ⊏ - '\u2291': '⊑', # ⊑ / ⊑ / ⊑ / ⊑ - '\u2290': '⊐', # ⊐ / ⊐ / ⊐ / ⊐ - '\u2292': '⊒', # ⊒ / ⊒ / ⊒ / ⊒ - '\u25a1': '□', # □ / □ / □ / □ - '\u22c6': '⋆', # ⋆ / ⋆ / ⋆ - '\u22d0': '⋐', # ⋐ / ⋐ / ⋐ - '\u2282': '⊂', # ⊂ / ⊂ / ⊂ - '\u2ac5': '⫅', # ⫅ / ⫅ / ⫅ - '\u2acb': '⫋', # ⫋ / ⫋ / ⫋ - '\u228a': '⊊', # ⊊ / ⊊ / ⊊ - '\u2286': '⊆', # ⊆ / ⊆ / ⊆ / ⊆ - '\u2211': '∑', # ∑ / ∑ / ∑ - '\u22d1': '⋑', # ⋑ / ⋑ / ⋑ - '\u2ac6': '⫆', # ⫆ / ⫆ / ⫆ - '\u2283': '⊃', # ⊃ / ⊃ / ⊃ / ⊃ - '\u2287': '⊇', # ⊇ / ⊇ / ⊇ / ⊇ - '\u2acc': '⫌', # ⫌ / ⫌ / ⫌ - '\u228b': '⊋', # ⊋ / ⊋ / ⊋ - '\u223c': '∼', # ∼ / ∼ / ∼ / ∼ / ∼ - '\u2245': '≅', # ≅ / ≅ / ≅ - '\u20db': '⃛', # ⃛ / ⃛ / ⃛ - '\u2234': '∴', # ∴ / ∴ / ∴ / ∴ - '\u03d1': 'ϑ', # ϑ / ϑ / ϑ / ϑ - CHAR_TRADE: '™', # ™ / ™ / ™ - '\u25b5': '▵', # ▵ / ▵ / ▵ - '\u225c': '≜', # ≜ / ≜ / ≜ - '\u21c5': '⇅', # ⇅ / ⇅ / ⇅ - '\u296e': '⥮', # ⥮ / ⥮ / ⥮ - '\u231c': '⌜', # ⌜ / ⌜ / ⌜ - '\u03d2': 'ϒ', # ϒ / ϒ / ϒ - '\u03c5': 'υ', # υ / υ / υ - '\u228e': '⊎', # ⊎ / ⊎ / ⊎ - '\u2195': '↕', # ↕ / ↕ / ↕ / ↕ - '\u2191': '↑', # ↑ / ↑ / ↑ / ↑ / ↑ - '\u21c8': '⇈', # ⇈ / ⇈ / ⇈ - '\u231d': '⌝', # ⌝ / ⌝ / ⌝ - '\u2016': '‖', # ‖ / ‖ / ‖ - '\u2228': '∨', # ∨ / ∨ / ∨ - CHAR_THIN_SP: ' ', # /   /   - '\u2240': '≀', # ≀ / ≀ / ≀ / ≀ - '\u2128': 'ℨ', # ℨ / ℨ / ℨ - '\u2118': '℘', # ℘ / ℘ / ℘ -} - -# === Динамическая генерация карт преобразования === - -def _build_translation_maps() -> dict[str, str]: - """ - Создает карту для кодирования на лету, используя все доступные источники - из html.entities и строгий порядок приоритетов для обеспечения - предсказуемого и детерминированного результата. - """ - # ШАГ 1: Создаем ЕДИНУЮ и ПОЛНУЮ карту {каноническое_имя: числовой_код}. - # Это решает проблему разных форматов и дубликатов с точкой с запятой. - unified_name2codepoint = {} - - # Сначала обрабатываем большой исторический словарь. - for name, codepoint in entities.name2codepoint.items(): - # Нормализуем имя СРАЗУ, убирая опциональную точку с запятой (в html.entities предусмотрено, что иногда - # символ `;` не ставится всякими неаккуратными верстальщиками и парсерами). - canonical_name = name.rstrip(';') - unified_name2codepoint[canonical_name] = codepoint - # Затем обновляем его современным стандартом html5. - # Это гарантирует, что если мнемоника есть в обоих, будет использована версия из html5. - for name, char in entities.html5.items(): - # НОВОЕ: Проверяем, что значение является ОДИНОЧНЫМ символом. - # Наш кодек, основанный на str.translate, не может обрабатывать - # мнемоники, которые соответствуют строкам из нескольких символов - # (например, символ + вариативный селектор). Мы их игнорируем. - if len(char) != 1: - continue - # Нормализуем имя СРАЗУ. - canonical_name = name.rstrip(';') - unified_name2codepoint[canonical_name] = ord(char) - - # Теперь у нас есть полный и консистентный словарь unified_name2codepoint. - # На его основе строим нашу карту для кодирования. - encode_map = {} - - # ШАГ 2: Высший приоритет. Загружаем наши кастомные правила. - encode_map.update(CUSTOM_ENCODE_MAP) - - # ШАГ 3: Следующий приоритет. Добавляем числовое кодирование. - for char in ALWAYS_ENCODE_TO_NUMERIC_CHARS: - if char not in encode_map: - encode_map[char] = f'&#{ord(char)};' - - # ШАГ 4: Низший приоритет. Заполняем все остальное из нашей - # объединенной и нормализованной карты unified_name2codepoint. - for name, codepoint in unified_name2codepoint.items(): - char = chr(codepoint) - if char not in encode_map and char not in NEVER_ENCODE_CHARS: - # Теперь 'name' - это уже каноническое имя без ';', - # поэтому дополнительная нормализация не нужна. Код стал проще! - encode_map[char] = f'&{name};' - - return encode_map - - -# Создаем карту один раз при импорте модуля. -ENCODE_MAP = _build_translation_maps() - -# --- Публичный API модуля --- -def get_encode_map(): - """Возвращает готовую карту для кодирования.""" - return ENCODE_MAP - - -# === КОНСТАНТЫ ДЛЯ ЕДИНИЦ ИЗМЕРЕНИЯ === -# ТОЛЬКО АТОМАРНЫЕ единицы измерения: 'г', 'м', 'с', 'км', 'кв', 'куб', 'ч' и так далее. -# Никаких сложных и составных, типа: 'кв.м.', 'км/ч' или "до н.э." ... -# Пост-позиционные (можно ставить точку после, но не обязательно) (км, г., с.) -DEFAULT_POST_UNITS = [ - # Русские - # --- Время и эпохи --- - 'гг', 'г.', 'в.', 'вв', 'н', 'э', 'сек', 'с.', 'мин', 'ч', - # --- Масса и объём --- - 'кг', 'мг', 'ц', 'т', 'л', 'мл', - # --- Размеры --- - 'кв', 'куб', 'мм', 'см', 'м', 'км', 'сот', 'га', 'м²', 'м³', - # --- Финансы и количество --- - 'руб', 'коп', 'тыс', 'млн', 'млрд', 'трлн', 'трлрд', 'шт', 'об', 'ящ', 'уп', 'кор', 'пар', 'комп', - # --- Издательское дело --- - 'пп', 'стр', 'рис', 'гр', 'табл', 'гл', 'п', 'пт', 'гл', 'том', 'т.', 'кн', 'илл', 'ред', 'изд', 'пер', - # --- Физические и технические --- - 'дБ', 'Вт', 'кВт', 'МВт', 'ГВт', 'А', 'В', 'Ом', 'Па', 'кПа', 'МПа', 'Бар', 'кБар', 'Гц', 'кГц', 'МГц', 'ГГц', - 'рад', 'К', '°C', '°F', '%', 'мкм', 'нм', 'А°', 'эВ', 'Дж', 'кДж', 'МДж', 'пкФ', 'нФ', 'мкФ', 'мФ', 'Ф', - 'Гн', 'мГн', 'мкГн', 'Тл', 'Гс', 'эрг', 'бод', 'бит', 'байт', 'Кб', 'Мб', 'Гб', 'Тб', 'Пб', 'Эб', 'кал', 'ккал', - # Английские - # --- Издательское дело --- - 'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed', 'rev', 'dpi', - # --- Имперские и американские единицы --- - 'in', 'ft', 'yd', 'mi', 'oz', 'lb', 'st', 'pt', 'qt', 'gal', 'mph', 'rpm', 'hp', 'psi', 'cal', -] -# Пред-позиционные (№ 5, $ 10) -DEFAULT_PRE_UNITS = ['№', '$', '€', '£', '₽', '#', '§', '¤', '₴', '₿', '₺', '₦', '₩', '₪', '₫', '₲', '₡', '₵', - 'ГОСТ', 'ТУ', 'ИСО', 'DIN', 'ASTM', 'EN', 'IEC', 'IEEE'] # технические стандарты перед числом работают как единицы измерения - -# Операторы, которые могут стоять между единицами измерения (км/ч) -# Сложение и вычитание здесь намеренно отсутствуют. -UNIT_MATH_OPERATORS = ['/', '*', '×', CHAR_MIDDOT, '÷'] - -# === КОНСТАНТЫ ДЛЯ ФИНАЛЬНЫХ СОКРАЩЕНИЙ === -# Эти сокращения (обычно в конце фразы) будут "склеены" тонкой шпацией, а перед ними будет поставлен неразрывный пробел. -# Важно, чтобы многосложные сокращения (типа "и т. д.") были в списке с разделителем пробелом (иначе мы не сможем их найти). -ABBR_COMMON_FINAL = [ - # 'т. д.', 'т. п.', 'др.', 'пр.', - # УБРАНЫ из-за неоднозначности: др. -- "другой", "доктор", "драм" / пр. -- "прочие", "профессор", "проект", "проезд" ... - 'т. д.', 'т. п.', -] - -ABBR_COMMON_PREPOSITION = [ - 'т. е.', 'т. к.', 'т. о.', 'т. ч.', - 'и. о.', 'ио', 'вр. и. о.', 'врио', - 'тов.', 'г-н.', 'г-жа.', 'им.', - 'д. о. с.', 'д. о. н.', 'д. м. н.', 'к. т. д.', 'к. т. п.', - 'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП', 'НИИ', 'ПБОЮЛ', 'ИП', -] - -# === КОНСТАНТЫ ДЛЯ HTML-ТЕГОВ, ВНУТРИ КОТОРЫХ НЕ НАДО ТИПОГРАФИРОВАТЬ === -PROTECTED_HTML_TAGS = ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math'] - -# === КОНСТАНТЫ ДЛЯ ВИСЯЧЕЙ ТИПОГРАФИКИ === - -# 1. Набор символов, которые могут "висеть" слева -HANGING_PUNCTUATION_LEFT_CHARS = frozenset([ - CHAR_RU_QUOT1_OPEN, # « - CHAR_EN_QUOT1_OPEN, # “ - '(', '[', '{', -]) - -# 2. Набор символов, которые могут "висеть" справа -HANGING_PUNCTUATION_RIGHT_CHARS = frozenset([ - CHAR_RU_QUOT1_CLOSE, # » - CHAR_EN_QUOT1_CLOSE, # ” - ')', ']', '}', - '.', ',', ':', -]) - -# 3. Словарь, сопоставляющий символ с его CSS-классом -HANGING_PUNCTUATION_CLASSES = { - # Левая пунктуация: все классы начинаются с 'etp-l' - CHAR_RU_QUOT1_OPEN: 'etp-laquo', - CHAR_EN_QUOT1_OPEN: 'etp-ldquo', - '(': 'etp-lpar', - '[': 'etp-lsqb', - '{': 'etp-lcub', - # Правая пунктуация: все классы начинаются с 'etp-r' - CHAR_RU_QUOT1_CLOSE: 'etp-raquo', - CHAR_EN_QUOT1_CLOSE: 'etp-rdquo', - ')': 'etp-rpar', - ']': 'etp-rsqb', - '}': 'etp-rcub', - '.': 'etp-r-dot', - ',': 'etp-r-comma', - ':': 'etp-r-colon', -} \ No newline at end of file diff --git a/etpgrf_site/etpgrf/defaults.py b/etpgrf_site/etpgrf/defaults.py deleted file mode 100644 index e6036e0..0000000 --- a/etpgrf_site/etpgrf/defaults.py +++ /dev/null @@ -1,32 +0,0 @@ -# etpgrf/defaults.py -- Настройки по умолчанию для типографа etpgrf -import logging -from etpgrf.config import LANG_RU, MODE_MIXED - -class LoggingDefaults: - LEVEL = logging.NOTSET - FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(module)s.%(funcName)s:%(lineno)d - %(message)s' - # Можно добавить ещё настройки, если понадобятся: - # FILE_PATH: str | None = None # Путь к файлу лога, если None - не пишем в файл - - -class HyphenationDefaults: - """ - Настройки по умолчанию для Hyphenator etpgrf. - """ - MAX_UNHYPHENATED_LEN: int = 12 - MIN_TAIL_LEN: int = 5 # Это значение должно быть >= 2 (чтоб не "вылетать" за индекс в английских словах) - - -class EtpgrfDefaultSettings: - """ - Общие настройки по умолчанию для всех модулей типографа etpgrf. - """ - 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() - -etpgrf_settings = EtpgrfDefaultSettings() \ No newline at end of file diff --git a/etpgrf_site/etpgrf/hanging.py b/etpgrf_site/etpgrf/hanging.py deleted file mode 100644 index 681b6c9..0000000 --- a/etpgrf_site/etpgrf/hanging.py +++ /dev/null @@ -1,166 +0,0 @@ -# etpgrf/hanging.py -# Модуль для расстановки висячей пунктуации. - -import logging -from bs4 import BeautifulSoup, NavigableString, Tag -from .config import ( - HANGING_PUNCTUATION_LEFT_CHARS, - HANGING_PUNCTUATION_RIGHT_CHARS, - HANGING_PUNCTUATION_CLASSES -) - -logger = logging.getLogger(__name__) - - -class HangingPunctuationProcessor: - """ - Оборачивает символы висячей пунктуации в специальные теги с классами. - """ - - def __init__(self, mode: str | bool | list[str] | None = None): - """ - :param mode: Режим работы: - - None / False: отключено. - - 'left': только левая пунктуация. - - 'right': только правая пунктуация. - - 'both' / True: и левая, и правая. - - list[str]: список тегов (например, ['p', 'blockquote']), - внутри которых применять 'both'. - """ - self.mode = mode - self.target_tags = None - self.active_chars = set() - - # Определяем, какие символы будем обрабатывать - if isinstance(mode, list): - self.target_tags = set(t.lower() for t in mode) - # Если передан список тегов, включаем полный режим ('both') внутри них - self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) - self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) - elif mode == 'left': - self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) - elif mode == 'right': - self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) - elif mode == 'both' or mode is True: - self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) - self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) - - # Предварительно фильтруем карту классов, оставляя только активные символы - self.char_to_class = { - char: cls - for char, cls in HANGING_PUNCTUATION_CLASSES.items() - if char in self.active_chars - } - - logger.debug(f"HangingPunctuationProcessor initialized. Mode: {mode}, Active chars count: {len(self.active_chars)}") - - def process(self, soup: BeautifulSoup) -> BeautifulSoup: - """ - Проходит по дереву soup и оборачивает висячие символы в span. - """ - if not self.active_chars: - return soup - - # Если задан список целевых тегов, обрабатываем только их содержимое - if self.target_tags: - # Находим все теги из списка - # Используем select для поиска (например: "p, blockquote, h1") - selector = ", ".join(self.target_tags) - roots = soup.select(selector) - else: - # Иначе обрабатываем весь документ (начиная с корня) - roots = [soup] - - for root in roots: - self._process_node_recursive(root, soup) - - return soup - - def _process_node_recursive(self, node, soup): - """ - Рекурсивно обходит узлы. Если находит NavigableString с нужными символами, - разбивает его и вставляет span'ы. - """ - # Работаем с копией списка детей, так как будем менять структуру дерева на лету - # (replace_with меняет дерево) - if hasattr(node, 'children'): - for child in list(node.children): - if isinstance(child, NavigableString): - self._process_text_node(child, soup) - elif isinstance(child, Tag): - # Не заходим внутрь тегов, которые мы сами же и создали (или аналогичных), - # чтобы избежать рекурсивного ада, хотя классы у нас специфичные. - self._process_node_recursive(child, soup) - - def _process_text_node(self, text_node: NavigableString, soup: BeautifulSoup): - """ - Анализирует текстовый узел. Если в нем есть символы для висячей пунктуации, - заменяет узел на фрагмент (список узлов), где эти символы обернуты в span. - """ - text = str(text_node) - - # Быстрая проверка: если в тексте вообще нет ни одного нашего символа, выходим - if not any(char in text for char in self.active_chars): - return - - # Если символы есть, нам нужно "разобрать" строку. - new_nodes = [] - current_text_buffer = "" - text_len = len(text) - - for i, char in enumerate(text): - if char in self.char_to_class: - should_hang = False - - # Проверяем контекст (пробелы или другие висячие символы вокруг) - if char in HANGING_PUNCTUATION_LEFT_CHARS: - # Левая пунктуация: - # 1. Начало узла - # 2. Перед ней пробел - # 3. Перед ней другой левый висячий символ (например, "((text") - if (i == 0 or - text[i-1].isspace() or - text[i-1] in HANGING_PUNCTUATION_LEFT_CHARS): - should_hang = True - elif char in HANGING_PUNCTUATION_RIGHT_CHARS: - # Правая пунктуация: - # 1. Конец узла - # 2. После нее пробел - # 3. После нее другой правый висячий символ (например, "text.»") - if (i == text_len - 1 or - text[i+1].isspace() or - text[i+1] in HANGING_PUNCTUATION_RIGHT_CHARS): - should_hang = True - - if should_hang: - # 1. Сбрасываем накопленный буфер текста (если есть) - if current_text_buffer: - new_nodes.append(NavigableString(current_text_buffer)) - current_text_buffer = "" - - # 2. Создаем span для висячего символа - span = soup.new_tag("span") - span['class'] = self.char_to_class[char] - span.string = char - new_nodes.append(span) - else: - # Если контекст не подходит, оставляем символ как обычный текст - current_text_buffer += char - else: - # Просто накапливаем символ - current_text_buffer += char - - # Добавляем остаток буфера - if current_text_buffer: - new_nodes.append(NavigableString(current_text_buffer)) - - # Заменяем исходный текстовый узел на набор новых узлов. - if new_nodes: - first_node = new_nodes[0] - text_node.replace_with(first_node) - - # Остальные вставляем последовательно после первого - current_pos = first_node - for next_node in new_nodes[1:]: - current_pos.insert_after(next_node) - current_pos = next_node diff --git a/etpgrf_site/etpgrf/hyphenation.py b/etpgrf_site/etpgrf/hyphenation.py deleted file mode 100755 index f9d3c26..0000000 --- a/etpgrf_site/etpgrf/hyphenation.py +++ /dev/null @@ -1,358 +0,0 @@ -# etpgrf/hyphenation.py -# Представленные здесь алгоритмы реализуют упрощенные правила. Но эти правила лучше, чем их полное отсутствие. -# Тем более что пользователь может отключить переносы из типографа. -# Для русского языка правила реализованы лучше. Для английского дают "разумные" переносы во многих случаях, но из-за -# большого числа беззвучных согласных и их сочетаний, могут давать не совсем корректный результат. - -import regex -import logging -import html -from etpgrf.config import ( - CHAR_SHY, LANG_RU, LANG_RU_OLD, LANG_EN, - RU_VOWELS_UPPER, RU_CONSONANTS_UPPER, RU_J_SOUND_UPPER, RU_SIGNS_UPPER, # RU_ALPHABET_UPPER, - EN_VOWELS_UPPER, EN_CONSONANTS_UPPER # , EN_ALPHABET_UPPER -) -from etpgrf.defaults import etpgrf_settings -from etpgrf.comutil import parse_and_validate_langs, is_inside_unbreakable_segment - - -_RU_OLD_VOWELS_UPPER = frozenset(['І', # И-десятеричное (гласная) - 'Ѣ', # Ять (гласная) - 'Ѵ']) # Ижица (может быть и гласной, и согласной - сложный случай!) -_RU_OLD_CONSONANTS_UPPER = frozenset(['Ѳ',],) # Фита (согласная) - -_EN_SUFFIXES_WITHOUT_HYPHENATION_UPPER = frozenset([ - "ATION", "ITION", "UTION", "OSITY", # 5-символьные, типа: creation, position, solution, generosity - "ABLE", "IBLE", "MENT", "NESS", # 4-символьные, типа: readable, visible, development, kindness - "LESS", "SHIP", "HOOD", "TIVE", # fearless, friendship, childhood, active (спорно) - "SION", "TION", # decision, action (часто покрываются C-C или V-C-V) - # "ING", "ED", "ER", "EST", "LY" # совсем короткие, но распространенные, не рассматриваем. -]) -_EN_UNBREAKABLE_X_GRAPHS_UPPER = frozenset(["SH", "CH", "TH", "PH", "WH", "CK", "NG", "AW", # диграфы с согласными - "TCH", "DGE", "IGH", # триграфы - "EIGH", "OUGH"]) # квадрографы - - -# --- Настройки логирования --- -logger = logging.getLogger(__name__) - - -# --- Класс Hyphenator (расстановка переносов) --- -class Hyphenator: - """Правила расстановки переносов для разных языков. - """ - def __init__(self, - langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, - max_unhyphenated_len: int | None = None, # Максимальная длина непереносимой группы - min_tail_len: int | None = None): # Минимальная длина после переноса (хвост, который разрешено переносить) - self.langs: frozenset[str] = parse_and_validate_langs(langs) - 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: - # Минимальная длина хвоста должна быть >= 2, иначе вылезаем за индекс в английских словах - raise ValueError(f"etpgrf: минимальная длина хвоста (min_tail_len) должна быть >= 2," - f" а не {self.min_chars_per_part}") - if self.max_unhyphenated_len <= self.min_chars_per_part: - # Максимальная длина непереносимой группы должна быть больше минимальной длины хвоста - raise ValueError(f"etpgrf: максимальная длина непереносимой группы (max_unhyphenated_len) " - f"должна быть больше минимальной длины хвоста (min_tail_len), " - f"а не {self.max_unhyphenated_len} >= {self.min_chars_per_part}") - - # Внутренние языковые ресурсы, если нужны специфично для переносов - self._vowels: frozenset = frozenset() - self._consonants: frozenset = frozenset() - self._j_sound_upper: frozenset = frozenset() - self._signs_upper: frozenset = frozenset() - self._ru_alphabet_upper: frozenset = frozenset() - self._en_alphabet_upper: frozenset = frozenset() - # Загружает наборы символов на основе self.langs - self._load_language_resources_for_hyphenation() - - # ... - 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}") - - def _load_language_resources_for_hyphenation(self): - # Определяем наборы гласных, согласных и т.д. в зависимости языков. - if LANG_RU in self.langs: - self._vowels |= RU_VOWELS_UPPER - self._consonants |= RU_CONSONANTS_UPPER - self._j_sound_upper |= RU_J_SOUND_UPPER - self._signs_upper |= RU_SIGNS_UPPER - self._ru_alphabet_upper |= self._vowels | self._consonants | self._j_sound_upper | self._signs_upper - if LANG_RU_OLD in self.langs: - self._vowels |= RU_VOWELS_UPPER | _RU_OLD_VOWELS_UPPER - self._consonants |= RU_CONSONANTS_UPPER | _RU_OLD_CONSONANTS_UPPER - self._j_sound_upper |= RU_J_SOUND_UPPER - self._signs_upper |= RU_SIGNS_UPPER - self._ru_alphabet_upper |= self._vowels | self._consonants | self._j_sound_upper | self._signs_upper - if LANG_EN in self.langs: - self._vowels |= EN_VOWELS_UPPER - self._consonants |= EN_CONSONANTS_UPPER - self._en_alphabet_upper |= EN_VOWELS_UPPER | EN_CONSONANTS_UPPER - # ... и для других языков, если они поддерживаются переносами - - - # Проверка гласных букв - def _is_vow(self, char: str) -> bool: - return char.upper() in self._vowels - - - # Проверка согласных букв - def _is_cons(self, char: str) -> bool: - return char.upper() in self._consonants - - - # Проверка полугласной буквы "й" - def _is_j_sound(self, char: str) -> bool: - return char.upper() in self._j_sound_upper - - - # Проверка мягкого/твердого знака - def _is_sign(self, char: str) -> bool: - return char.upper() in self._signs_upper - - - def hyp_in_word(self, word: str) -> str: - """ Расстановка переносов в русском слове с учетом максимальной длины непереносимой группы. - Переносы ставятся половинным делением слова, рекурсивно. - - :param word: Слово, в котором надо расставить переносы - :return: Слово с расставленными переносами - """ - # 1. ОБЩИЕ ПРОВЕРКИ - # TODO: возможно, для скорости, надо сделать проверку на пробелы и другие разделители, которых не должно быть - if not word: - # Явная проверка на пустую строку - return "" - 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} // 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 (правила одинаковые, но разные наборы букв) - if (LANG_RU in self.langs or LANG_RU_OLD in self.langs) and frozenset(word.upper()) <= self._ru_alphabet_upper: - # Пользователь подключил русскую логику, и слово содержит только русские буквы - logger.debug(f"`{word}` -- use `{LANG_RU}` or `{LANG_RU_OLD}` rules") - - # Поиск допустимой позиции для переноса около заданного индекса - def find_hyphen_point_ru(word_segment: str, start_idx: int) -> int: - word_len = len(word_segment) - min_part = self.min_chars_per_part - - # --- Вложенная функция для оценки качества точки переноса --- - def get_split_score(i: int) -> int: - """ - Вычисляет "оценку" для точки переноса `i`. Чем выше оценка, тем качественнее перенос. - -1 означает, что перенос в этой точке запрещен. - """ - # --- Сначала идут ЗАПРЕТЫ (жесткие "нельзя") --- - # Если правило нарушено, сразу дисквалифицируем точку. - if self._is_sign(word_segment[i]) or self._is_j_sound(word_segment[i]): - return -1 # ЗАПРЕТ 1: Новая строка не может начинаться с Ь, Ъ или Й. - if self._is_j_sound(word_segment[i - 1]) and self._is_vow(word_segment[i]): - return -1 # ЗАПРЕТ 2: Нельзя отрывать Й от следующей за ней гласной. - # --- Теперь идут РАЗРЕШЕНИЯ с разными приоритетами --- - # РАЗРЕШЕНИЕ 1: Перенос между сдвоенными согласными. - if self._is_cons(word_segment[i - 1]) and word_segment[i - 1] == word_segment[i]: - return 10 - # РАЗРЕШЕНИЕ 2: Перенос после "слога" с Ь/Ъ, если дальше идет СОГЛАСНАЯ. - # Пример: "строитель-ство", но НЕ "компь-ютер". - # По-хорошему нужно проверять, что перед Ь/Ъ нет йотированной гласной - # (и переработать ЗАПРЕТ 2), но это еще больше усложнит логику. - if self._is_sign(word_segment[i - 1]) and self._is_cons(word_segment[i]): - return 9 - # РАЗРЕШЕНИЕ 3: Перенос после "слога" если предыдущий Й (очень качественный перенос). - if self._is_j_sound(word_segment[i - 1]): - return 7 - # РАЗРЕШЕНИЕ 4: Перенос между тремя согласными (C-CС), чуть лучше, чем после гласной. - if self._is_cons(word_segment[i]) and self._is_cons(word_segment[i-1]) and self._is_cons(word_segment[i+1]): - return 6 - # # РАЗРЕШЕНИЕ 5 (?): Перенос между согласной и согласной (C-C). - # if self._is_cons(word_segment[i - 1]) and self._is_cons(word_segment[i]): - # return 5 - # РАЗРЕШЕНИЕ 6 (Основное правило): Перенос после гласной. - if self._is_vow(word_segment[i - 1]): - return 5 - # Если ни одно правило не подошло, точка не подходит для переноса. - return 0 - - # 1. Собираем всех кандидатов и их оценки - candidates = [] - possible_indices = range(min_part, word_len - min_part + 1) - for i in possible_indices: - score = get_split_score(i) - if score > 0: - # Добавляем только подходящих кандидатов - distance_from_center = abs(i - start_idx) - candidates.append({'score': score, 'distance': distance_from_center, 'index': i}) - - # 2. Если подходящих кандидатов нет, сдаемся - if not candidates: - return -1 - - # 3. Сортируем кандидатов: сначала по убыванию ОЦЕНКИ, потом по возрастанию УДАЛЕННОСТИ от центра. - # Это гарантирует, что перенос "н-н" (score=10) будет выбран раньше, чем "е-н" (score=5), - # даже если "е-н" чуть ближе к центру. - best_candidate = sorted(candidates, key=lambda c: (-c['score'], c['distance']))[0] - - return best_candidate['index'] # Не нашли подходящую позицию - - # Рекурсивное деление слова - def split_word_ru(word_to_split: str) -> str: - # Если длина укладывается в лимит, перенос не нужен - if len(word_to_split) <= self.max_unhyphenated_len: - return word_to_split - # Ищем точку переноса около середины - hyphen_idx = find_hyphen_point_ru(word_to_split, len(word_to_split) // 2) - # Если не нашли точку переноса - if hyphen_idx == -1: - return word_to_split - # Разделяем слово на две части (до и после точки переноса) - left_part = word_to_split[:hyphen_idx] - right_part = word_to_split[hyphen_idx:] - # Рекурсивно делим левую и правую части и соединяем их через символ переноса - return split_word_ru(left_part) + CHAR_SHY + split_word_ru(right_part) - - # Основная логика - return split_word_ru(word) # Рекурсивно делим слово на части с переносами - - # 2.2. Проверяем EN - elif LANG_EN in self.langs and frozenset(word.upper()) <= self._en_alphabet_upper: - # Пользователь подключил английскую логику, и слово содержит только английские буквы - logger.debug(f"`{word}` -- use `{LANG_EN}` rules") - # --- Начало логики для английского языка (заглушка) --- - # ПРИМЕЧАНИЕ: правила переноса в английском языке основаны на слогах, и их точное определение без словаря - # слогов или сложного алгоритма (вроде Knuth-Liang) — непростая задача. Здесь реализована упрощенная - # логика и поиск потенциальных точек переноса основан на простых правилах: между согласными, или между - # гласной и согласной. Метод половинного деления и рекурсии (поиск переносов о середины слова). - - # Функция для поиска допустимой позиции для переноса около заданного индекса - # Ищет точку переноса, соблюдая min_chars_per_part и простые правила - def find_hyphen_point_en(word_segment: str, start_idx: int) -> int: - word_len = len(word_segment) - min_part = self.min_chars_per_part - - # Определяем диапазон допустимых индексов для переноса - # Индекс 'i' - это точка разреза. word_segment[:i] и word_segment[i:] должны быть не короче min_part. - # i >= min_part - # word_len - i >= min_part => i <= word_len - min_part - valid_split_indices = [i for i in range(min_part, word_len - min_part + 1)] - - if not valid_split_indices: - # Нет ни одного места, где можно поставить перенос, соблюдая min_part - logger.debug(f"No valid split indices for '{word_segment}' within min_part={min_part}") - return -1 - - # Сортируем допустимые индексы по удаленности от start_idx (середины) - # Это реализует поиск "около центра" - valid_split_indices.sort(key=lambda i: abs(i - start_idx)) - - # Проверяем каждый потенциальный индекс переноса по упрощенным правилам - for i in valid_split_indices: - # Упрощенные правила английского переноса (основаны на частых паттернах, не на слогах): - # 1. Запрет переноса между гласными - if self._is_vow(word_segment[i - 1]) and self._is_vow(word_segment[i]): - logger.debug( - f"Skipping V-V split point at index {i} in '{word_segment}' ({word_segment[i - 1]}{word_segment[i]})") - continue # Переходим к следующему кандидату i - - # 2. Запрет переноса ВНУТРИ неразрывных диграфов/триграфов и т.д. - if is_inside_unbreakable_segment(word_segment=word_segment, - split_index=i, - unbreakable_set=_EN_UNBREAKABLE_X_GRAPHS_UPPER): - logger.debug(f"Skipping unbreakable segment at index {i} in '{word_segment}'") - continue - - # 3. Перенос между двумя согласными (C-C), например, 'but-ter', 'subjec-tive' - # Точка переноса - индекс i. Проверяем символы word[i-1] и word[i]. - if self._is_cons(word_segment[i - 1]) and self._is_cons(word_segment[i]): - logger.debug(f"Found C-C split point at index {i} in '{word_segment}'") - return i - - # 4. Перенос перед одиночной согласной между двумя гласными (V-C-V), например, 'ho-tel', 'ba-by' - # Точка переноса - индекс i (перед согласной). Проверяем word[i-1], word[i], word[i+1]. - # Требуется как минимум 3 символа для этого паттерна. - if i < word_len - 1 and \ - self._is_vow(word_segment[i - 1]) and self._is_cons(word_segment[i]) and self._is_vow( - word_segment[i + 1]): - logger.debug(f"Found V-C-V (split before C) split point at index {i} in '{word_segment}'") - return i - - # 5. Перенос после одиночной согласной между двумя гласными (V-C-V), например, 'riv-er', 'fin-ish' - # Точка переноса - индекс i (после согласной). Проверяем word[i-2], word[i-1], word[i]. - # Требуется как минимум 3 символа для этого паттерна. - if i < word_len and \ - self._is_vow(word_segment[i - 2]) and self._is_cons(word_segment[i - 1]) and \ - self._is_vow(word_segment[i]): - logger.debug(f"Found V-C-V (split after C) split point at index {i} in '{word_segment}'") - return i - - # 6. Правила для распространенных суффиксов (перенос ПЕРЕД суффиксом). Проверяем, что word_segment - # заканчивается на суффикс, и точка переноса (i) находится как раз перед ним - if word_segment[i:].upper() in _EN_SUFFIXES_WITHOUT_HYPHENATION_UPPER: - # Мы нашли потенциальный суффикс. - logger.debug(f"Found suffix '-{word_segment[i:]}' split point at index {i} in '{word_segment}'") - return i - - # Если ни одна подходящая точка переноса не найдена в допустимом диапазоне - logger.debug(f"No suitable hyphen point found for '{word_segment}' near center.") - return -1 - - # Рекурсивная функция для деления слова на части с переносами - def split_word_en(word_to_split: str) -> str: - # Базовый случай рекурсии: если часть слова достаточно короткая, не делим ее дальше - if len(word_to_split) <= self.max_unhyphenated_len: - return word_to_split - - # Ищем точку переноса около середины текущей части слова - hyphen_idx = find_hyphen_point_en(word_to_split, len(word_to_split) // 2) - - # Если подходящая точка переноса не найдена, возвращаем часть слова как есть - if hyphen_idx == -1: - return word_to_split - - # Рекурсивно обрабатываем обе части и объединяем их символом переноса - return (split_word_en(word_to_split[:hyphen_idx]) + - CHAR_SHY + split_word_en(word_to_split[hyphen_idx:])) - - # --- Конец логики для английского языка --- - return split_word_en(word) - else: - # кстати "слова" в которых есть пробелы или другие разделители, тоже попадают сюда - logger.debug(f"`{word}` -- use `UNDEFINE` rules") - return word - - - def hyp_in_text(self, text: str) -> str: - """ Расстановка переносов в тексте - - :param text: Строка, которую надо обработать (главный аргумент). - :return: str: Строка с расставленными переносами. - """ - - # 1. Определяем функцию, которая будет вызываться для каждого найденного слова - def replace_word_with_hyphenated(match_obj): - # Модуль regex автоматически передает сюда match_obj для каждого совпадения. - # Чтобы получить `слово` из 'совпадения' делаем .group() или .group(0). - word_to_process = match_obj.group(0) - # И оправляем это слово на расстановку переносов (внутри hyp_in_word уже есть все проверки). - hyphenated_word = self.hyp_in_word(word_to_process) - - # ============= Для отладки (слова в которых появились переносы) ================== - if word_to_process != hyphenated_word: - logger.debug(f"hyp_in_text: '{word_to_process}' -> '{hyphenated_word}'") - - return hyphenated_word - - # 2. regex.sub() -- поиск с заменой. Ищем по паттерну `r'\b\p{L}+\b'` (`\b` - граница слова; - # `\p{L}` - любая буква Unicode; `+` - одно или более вхождений). - # Второй аргумент - это наша функция replace_word_with_hyphenated. - # regex.sub вызовет ее для каждого найденного слова, передав match_obj. - processed_text = regex.sub(pattern=r'\b\p{L}+\b', repl=replace_word_with_hyphenated, string=text) - - return processed_text - - diff --git a/etpgrf_site/etpgrf/layout.py b/etpgrf_site/etpgrf/layout.py deleted file mode 100644 index 4d37d09..0000000 --- a/etpgrf_site/etpgrf/layout.py +++ /dev/null @@ -1,211 +0,0 @@ -# etpgrf/layout.py -# Модуль для обработки тире, специальных символов и правил их компоновки. - -import regex -import logging -from etpgrf.config import (LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP, - CHAR_UNIT_SEPARATOR, DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, UNIT_MATH_OPERATORS, - ABBR_COMMON_FINAL, ABBR_COMMON_PREPOSITION) - -from etpgrf.comutil import parse_and_validate_langs - - - -# -- - -# --- Настройки логирования --- -logger = logging.getLogger(__name__) - - -class LayoutProcessor: - """ - Обрабатывает тире, псевдографику (например, … -> © и тому подобные) и применяет - правила расстановки пробелов в зависимости от языка (для тире язык важен, так как. - Правила типографики различаются для русского и английского языков). - Предполагается, что на вход уже поступает текст с правильными типографскими - символами тире (— и –). - """ - - def __init__(self, - langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, - process_initials_and_acronyms: bool = True, - process_units: bool | str | list[str] = True): - - self.langs = parse_and_validate_langs(langs) - self.main_lang = self.langs[0] if self.langs else LANG_RU - self.process_initials_and_acronyms = process_initials_and_acronyms - self.process_units = process_units - # 1. Паттерн для длинного (—) или среднего (–) тире, окруженного пробелами. - # (?<=[\p{L}\p{Po}\p{Pf}"\']) - просмотр назад на букву, пунктуацию или кавычку. - self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\'])\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)') - - # 2. Паттерн для многоточия, за которым следует пробел и слово. - # Ставит неразрывный пробел после многоточия, чтобы не отрывать его от следующего слова. - # (?=[\p{L}\p{N}]) - просмотр вперед на букву или цифру. - self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}])') - - # 3. Паттерн для отрицательных чисел. - # Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел - # заменяет обычный). Это предотвращает "отлипание" знака от числа при переносе строки. - # (? str: - """Callback-функция для расстановки пробелов вокруг тире с учетом языка.""" - dash = match.group(1) # Получаем сам символ тире (— или –) - if self.main_lang == LANG_EN: - # Для английского языка — слитно, без пробелов. - return dash - # По умолчанию (и для русского) — отбивка пробелами. - return f'{CHAR_NBSP}{dash} ' - - def _process_abbreviations(self, text: str, abbreviations: list[str], mode: str) -> str: - """ - Универсальный обработчик для разных типов сокращений. - - :param text: Входной текст. - :param abbreviations: Список сокращений для обработки. - :param mode: 'final' (NBSP ставится перед) или 'prepositional' (NBSP ставится после). - :return: Обработанный текст. - """ - processed_text = text - - # Шаг 1: "Склеиваем" многосоставные сокращения временным разделителем CHAR_UNIT_SEPARATOR - for abbr in sorted(abbreviations, key=len, reverse=True): - if ' ' in abbr: - pattern = regex.escape(abbr).replace(r'\ ', r'\s*') - replacement = abbr.replace(' ', CHAR_UNIT_SEPARATOR) - processed_text = regex.sub(pattern, replacement, processed_text, flags=regex.IGNORECASE) - - # Шаг 2: Ставим неразрывный пробел. - glued_abbrs = [a.replace(' ', CHAR_UNIT_SEPARATOR) for a in abbreviations] - all_abbrs_pattern = '|'.join(map(regex.escape, sorted(glued_abbrs, key=len, reverse=True))) - - if mode == 'final': - # Ставим nbsp перед сокращением, если перед ним есть пробел - nbsp_pattern = regex.compile(r'(\s)(' + all_abbrs_pattern + r')(?=[.,!?]|\s|$)', flags=regex.IGNORECASE) - processed_text = nbsp_pattern.sub(fr'{CHAR_NBSP}\2', processed_text) - elif mode == 'prepositional': - # Ставим nbsp после сокращения, если после него есть пробел - nbsp_pattern = regex.compile(r'(' + all_abbrs_pattern + r')(\s)', flags=regex.IGNORECASE) - processed_text = nbsp_pattern.sub(fr'\1{CHAR_NBSP}', processed_text) - - # Шаг 3: Заменяем временный разделитель на правильную тонкую шпацию - return processed_text.replace(CHAR_UNIT_SEPARATOR, CHAR_THIN_SP) - - def process(self, text: str) -> str: - """Применяет правила компоновки к тексту.""" - # Порядок применения правил важен. - processed_text = text - - # 1. Обработка пробелов вокруг тире. - processed_text = self._dash_pattern.sub(self._replace_dash_spacing, processed_text) - - # 2. Обработка пробела после многоточия. - processed_text = self._ellipsis_pattern.sub(f'\\1{CHAR_NBSP}', processed_text) - - # 3. Обработка пробела перед отрицательными числами/минусом. - processed_text = self._negative_number_pattern.sub(f'{CHAR_NBSP}-\\1', processed_text) - - # 4. Обработка сокращений. - processed_text = self._process_abbreviations(processed_text, ABBR_COMMON_FINAL, 'final') - processed_text = self._process_abbreviations(processed_text, ABBR_COMMON_PREPOSITION, 'prepositional') - - # 5. Обработка инициалов и акронимов (если включено). - if self.process_initials_and_acronyms: - # Сначала вставляем тонкие пробелы там, где пробелов не было. - processed_text = self._initial_to_initial_ns_pattern.sub(f'\\1{CHAR_THIN_SP}', processed_text) - processed_text = self._initial_to_surname_ns_pattern.sub(f'\\1{CHAR_THIN_SP}', processed_text) - - # Затем заменяем существующие пробелы на неразрывные. - processed_text = self._initial_to_initial_ws_pattern.sub(f'\\1{CHAR_NBSP}', processed_text) - processed_text = self._initial_to_surname_ws_pattern.sub(f'\\1{CHAR_NBSP}', processed_text) - processed_text = self._surname_to_initial_ws_pattern.sub(f'\\1{CHAR_NBSP}', processed_text) - - # 6. Обработка единиц измерения (если включено). - if self.process_units: - if self._complex_unit_pattern: - # Шаг 1: "Склеиваем" все составные единицы с помощью временного разделителя. - # Цикл безопасен, так как мы заменяем пробелы на непробельный символ, и паттерн не найдет себя снова. - while self._complex_unit_pattern.search(processed_text): - processed_text = self._complex_unit_pattern.sub( - fr'\1.{CHAR_UNIT_SEPARATOR}\3', processed_text, count=1) - - if self._math_unit_pattern: - # processed_text = self._math_unit_pattern.sub(r'\1/\2', processed_text) - processed_text = self._math_unit_pattern.sub(r'\1\2\3', processed_text) - # И только потом привязываем простые единицы к числам - if self._post_units_pattern: - processed_text = self._post_units_pattern.sub(f'\\1{CHAR_NBSP}\\2', processed_text) - if self._pre_units_pattern: - processed_text = self._pre_units_pattern.sub(f'\\1{CHAR_NBSP}\\2', processed_text) - - # Шаг 2: Заменяем все временные разделители на правильную тонкую шпацию. - processed_text = processed_text.replace(CHAR_UNIT_SEPARATOR, CHAR_THIN_SP) - - return processed_text diff --git a/etpgrf_site/etpgrf/logger.py b/etpgrf_site/etpgrf/logger.py deleted file mode 100644 index 1f6bb42..0000000 --- a/etpgrf_site/etpgrf/logger.py +++ /dev/null @@ -1,124 +0,0 @@ -# etpgrf/logging_settings.py -import logging -from etpgrf.defaults import etpgrf_settings # Импортируем наш объект настроек по умолчанию - -# --- Корневой логгер для всей библиотеки etpgrf --- -# Имя логгера "etpgrf" позволит пользователям настраивать -# логирование для всех частей библиотеки. -# Например, logging.getLogger("etpgrf").setLevel(logging.DEBUG) -# или logging.getLogger("etpgrf.hyphenation").setLevel(logging.INFO) -_etpgrf_init_logger = logging.getLogger("etpgrf") - - -# --- Настройка корневого логгера --- -def setup_library_logging(): - """ - Настраивает корневой логгер для библиотеки etpgrf. - Эту функцию следует вызывать один раз (например, при импорте - основного модуля библиотеки или при первом обращении к логгеру). - """ - # Проверяем инициализацию хандлеров логера, чтобы случайно не добавлять хендлеры многократно - if not _etpgrf_init_logger.hasHandlers(): - log_level_to_set = logging.WARNING # Значение по умолчанию - # самый мощный формат, который мы можем использовать - log_format_to_set = '%(asctime)s - %(name)s - %(levelname)s - %(module)s.%(funcName)s:%(lineno)d - %(message)s' - # обычно достаточно: - # log_format_to_set = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' # Формат по умолчанию - - fin_message: str | None = None - if hasattr(etpgrf_settings, 'logging_settings'): - if hasattr(etpgrf_settings.logging_settings, 'LEVEL'): - log_level_to_set = etpgrf_settings.logging_settings.LEVEL - if hasattr(etpgrf_settings.logging_settings, 'FORMAT') and etpgrf_settings.logging_settings.FORMAT: - log_format_to_set = etpgrf_settings.logging_settings.FORMAT - else: - # Этого не должно происходить, если defaults.py настроен правильно - fin_message= "ПРЕДУПРЕЖДЕНИЕ: etpgrf_settings.logging_settings не найдены при начальной настройке логгера." - - _etpgrf_init_logger.setLevel(log_level_to_set) # Устанавливаем уровень логирования - console_handler = logging.StreamHandler() # Создаем хендлер вывода в консоль - console_handler.setLevel(log_level_to_set) # Уровень для хендлера тоже - formatter = logging.Formatter(log_format_to_set) # Создаем форматтер для вывода - console_handler.setFormatter(formatter) # Устанавливаем форматтер для хендлера - _etpgrf_init_logger.addHandler(console_handler) # Добавляем хендлер в логгер - if fin_message is not None: - # Если есть сообщение об отсутствии настроек в `etpgrf_settings`, выводим его - _etpgrf_init_logger.warning(fin_message) - _etpgrf_init_logger.debug(f"Корневой логгер `etpgrf` инициализирован." - f" Уровень: {logging.getLevelName(_etpgrf_init_logger.getEffectiveLevel())}") - - -# --- Динамическое изменение уровня логирования --- -def update_etpgrf_log_level_from_settings(): - """ - Обновляет уровень логирования для корневого логгера `etpgrf` и его - обработчиков, читая значение из `etpgrf_settings.logging_settings.LEVEL`. - """ - # Проверяем, что настройки логирования и уровень существуют в `defaults.etpgrf_settings` - if not hasattr(etpgrf_settings, 'logging_settings') or \ - not hasattr(etpgrf_settings.logging_settings, 'LEVEL'): - _etpgrf_init_logger.warning("Невозможно обновить уровень логгера: `etpgrf_settings.logging_settings.LEVEL`" - " не найден.") - return - - new_level = etpgrf_settings.logging_settings.LEVEL - _etpgrf_init_logger.setLevel(new_level) - for handler in _etpgrf_init_logger.handlers: - handler.setLevel(new_level) # Устанавливаем уровень для каждого хендлера - - _etpgrf_init_logger.info(f"Уровень логирования `etpgrf` динамически обновлен на:" - f" {logging.getLevelName(_etpgrf_init_logger.getEffectiveLevel())}") - - -# --- Динамическое изменение формата логирования --- -def update_etpgrf_log_format_from_settings(): - """ - Обновляет формат логирования для обработчиков корневого логгера etpgrf, - читая значение из etpgrf_settings.logging_settings.FORMAT. - """ - if not hasattr(etpgrf_settings, 'logging_settings') or \ - not hasattr(etpgrf_settings.logging_settings, 'FORMAT') or \ - not etpgrf_settings.logging_settings.FORMAT: - _etpgrf_init_logger.warning("Невозможно обновить формат логгера: `etpgrf_settings.logging_settings.FORMAT`" - " не найден или пуст.") - return - - new_format_string = etpgrf_settings.logging_settings.FORMAT - new_formatter = logging.Formatter(new_format_string) - - for handler in _etpgrf_init_logger.handlers: - handler.setFormatter(new_formatter) # Применяем новый форматтер к каждому хендлеру - - _etpgrf_init_logger.info(f"Формат логирования для `etpgrf` динамически обновлен на: `{new_format_string}`") - - -# --- Инициализация логгера при первом импорте --- -setup_library_logging() - - -# --- Предоставление логгеров для модулей --- -def get_logger(name: str) -> logging.Logger: - """ - Возвращает логгер для указанного имени. - Обычно используется как logging.getLogger(__name__) в модулях. - Имя будет дочерним по отношению к "etpgrf", например, "etpgrf.hyphenation". - """ - # Убедимся, что имя логгера начинается с "etpgrf." для правильной иерархии, - # если только это не сам корневой логгер. - if not name.startswith("etpgrf") and name != "etpgrf": - # Это может быть __name__ из модуля верхнего уровня, использующего библиотеку. В этом случае мы не хотим - # делать его дочерним от "etpgrf" насильно. Просто вернем логгер с именем... - # Либо можно настроить, что все логгеры, получаемые через эту функцию, должны быть частью иерархии "etpgrf"... - # Для простоты оставим так: - pass # logging_settings = logging.getLogger(name) - # Более правильный подход для модулей ВНУТРИ библиотеки etpgrf: они должны вызывать `logging.getLogger(__name__)` - # напрямую. Тогда эта функция `get_logger()` может быть и не нужна, если модули ничего не делают кроме: - # import logging - # logging_settings = logging.getLogger(__name__) - # - # Однако, если нужно централизованно получать логгеры, можно сделать, чтобы `get_logger()` всегда возвращал - # дочерний логгер: - # if not name.startswith("etpgrf."): - # name = f"etpgrf.{name}" - return logging.getLogger(name) - diff --git a/etpgrf_site/etpgrf/quotes.py b/etpgrf_site/etpgrf/quotes.py deleted file mode 100644 index 1ff3b93..0000000 --- a/etpgrf_site/etpgrf/quotes.py +++ /dev/null @@ -1,75 +0,0 @@ -# etpgrf/quotes.py -# Модуль для расстановки кавычек в тексте - -import regex -import logging -from .config import (LANG_RU, LANG_EN, CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE, CHAR_EN_QUOT1_OPEN, - CHAR_EN_QUOT1_CLOSE, CHAR_RU_QUOT2_OPEN, CHAR_RU_QUOT2_CLOSE, CHAR_EN_QUOT2_OPEN, - CHAR_EN_QUOT2_CLOSE) -from .comutil import parse_and_validate_langs - -# --- Настройки логирования --- -logger = logging.getLogger(__name__) - -# Определяем стили кавычек для разных языков -# Формат: (('открывающая_ур1', 'закрывающая_ур1'), ('открывающая_ур2', 'закрывающая_ур2')) -_QUOTE_STYLES = { - LANG_RU: ((CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE), (CHAR_RU_QUOT2_OPEN, CHAR_RU_QUOT2_CLOSE)), - LANG_EN: ((CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE), (CHAR_EN_QUOT2_OPEN, CHAR_EN_QUOT2_CLOSE)), -} - - -class QuotesProcessor: - """ - Обрабатывает прямые кавычки ("), превращая их в типографские - в зависимости от языка и контекста. - """ - - def __init__(self, langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None): - self.langs = parse_and_validate_langs(langs) - - # Выбираем стиль кавычек на основе первого поддерживаемого языка - self.open_quote = '"' - self.close_quote = '"' - - for lang in self.langs: - if lang in _QUOTE_STYLES: - self.open_quote = _QUOTE_STYLES[lang][0][0] - self.close_quote = _QUOTE_STYLES[lang][0][1] - logger.debug( - f"QuotesProcessor: выбран стиль кавычек для языка '{lang}': '{self.open_quote}...{self.close_quote}'") - break # Используем стиль первого найденного языка - - # Паттерн для открывающей кавычки: " перед буквой/цифрой, - # которой предшествует пробел, начало строки или открывающая скобка. - # (?<=^|\s|[\(\[„\"‘\']) - "просмотр назад" на начало строки... ищет пробел \s или знак из набора ([„"‘' - # (?=\p{L}) - "просмотр вперед" на букву \p{L} (но не цифру). - self._opening_quote_pattern = regex.compile(r'(?<=^|\s|[\(\[„\"‘\'])\"(?=\p{L})') - # self._opening_quote_pattern = regex.compile(r'(?<=^|\s|\p{Pi}|["\'\(\)])\"(?=\p{L})') - - # Паттерн для закрывающей кавычки: " после буквы/цифры, - # за которой следует пробел, пунктуация или конец строки. - # (?<=\p{L}|[?!…\.]) - "просмотр назад" на букву или ?!… и точку. - # (?=\s|[.,;:!?\)\"»”’]|\Z) - "просмотр вперед" на пробел, пунктуацию или конец строки (\Z). - self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…\.])\"(?=\s|[\.,;:!?\)\]»”’\"\']|\Z)') - # self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|\p{N})\"(?=\s|[\.,;:!?\)\"»”’]|\Z)') - # self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…])\"(?=\s|[\p{Po}\p{Pf}"\']|\Z)') - - def process(self, text: str) -> str: - """ - Применяет правила замены кавычек к тексту. - """ - if '"' not in text: - # Быстрый выход, если в тексте нет прямых кавычек - return text - - processed_text = text - - # 1. Заменяем открывающие кавычки - # Заменяем только найденную кавычку, так как просмотр вперед не захватывает символы. - processed_text = self._opening_quote_pattern.sub(self.open_quote, processed_text) - - # 2. Заменяем закрывающие кавычки - processed_text = self._closing_quote_pattern.sub(self.close_quote, processed_text) - - return processed_text \ No newline at end of file diff --git a/etpgrf_site/etpgrf/sanitizer.py b/etpgrf_site/etpgrf/sanitizer.py deleted file mode 100644 index 56cf45e..0000000 --- a/etpgrf_site/etpgrf/sanitizer.py +++ /dev/null @@ -1,76 +0,0 @@ -# etpgrf/sanitizer.py -# Модуль для очистки и нормализации HTML-кода перед типографикой. - -import logging -from bs4 import BeautifulSoup -from .config import (SANITIZE_ALL_HTML, SANITIZE_ETPGRF, SANITIZE_NONE, - HANGING_PUNCTUATION_CLASSES, PROTECTED_HTML_TAGS) - -logger = logging.getLogger(__name__) - - -class SanitizerProcessor: - """ - Выполняет очистку HTML-кода в соответствии с заданным режимом. - """ - - def __init__(self, mode: str | bool | None = SANITIZE_NONE): - """ - :param mode: Режим очистки: - - 'etp' (SANITIZE_ETPGRF): удаляет только разметку висячей пунктуации. - - 'html' (SANITIZE_ALL_HTML): удаляет все HTML-теги. - - None или False: ничего не делает. - """ - if mode is False: - mode = SANITIZE_NONE - self.mode = mode - - # Оптимизация: заранее готовим CSS-селектор для поиска висячей пунктуации - if self.mode == SANITIZE_ETPGRF: - # Собираем уникальные классы - unique_classes = sorted(list(frozenset(HANGING_PUNCTUATION_CLASSES.values()))) - # Формируем селектор вида: span.class1, span.class2, ... - # Это позволяет использовать нативный парсер (lxml) для поиска, что намного быстрее python-лямбд. - self._etp_selector = ", ".join(f"span.{cls}" for cls in unique_classes) - else: - self._etp_selector = None - - logger.debug(f"SanitizerProcessor `__init__`. Mode: {self.mode}") - - def process(self, soup: BeautifulSoup) -> BeautifulSoup | str: - """ - Применяет правила очистки к `soup`-объекту. - - :param soup: Объект BeautifulSoup для обработки. - :return: Обработанный объект BeautifulSoup или строка (в режиме 'html'). - """ - if self.mode == SANITIZE_ETPGRF: - if not self._etp_selector: - return soup - - # Используем CSS-селектор для быстрого поиска всех нужных элементов - spans_to_clean = soup.select(self._etp_selector) - - # "Агрессивная" очистка: просто "разворачиваем" все найденные теги, - # заменяя их своим содержимым. - for span in spans_to_clean: - span.unwrap() - - return soup - - elif self.mode == SANITIZE_ALL_HTML: - # Оптимизированный подход: - # 1. Удаляем защищенные теги (script, style и т.д.) вместе с содержимым. - # Используем select для поиска, так как это обычно быстрее. - if PROTECTED_HTML_TAGS: - # Формируем селектор: script, style, pre, ... - protected_selector = ", ".join(PROTECTED_HTML_TAGS) - for tag in soup.select(protected_selector): - tag.decompose() # Полное удаление тега из дерева - - # 2. Извлекаем чистый текст из оставшегося дерева. - # get_text() работает на уровне C (в lxml) и намного быстрее ручного обхода. - return soup.get_text() - - # Если режим не задан, ничего не делаем - return soup diff --git a/etpgrf_site/etpgrf/symbols.py b/etpgrf_site/etpgrf/symbols.py deleted file mode 100644 index b2816a4..0000000 --- a/etpgrf_site/etpgrf/symbols.py +++ /dev/null @@ -1,50 +0,0 @@ -# etpgrf/symbols.py -# Модуль для преобразования псевдографики в правильные типографские символы. - -import regex -import logging -from .config import CHAR_NDASH, STR_TO_SYMBOL_REPLACEMENTS - -logger = logging.getLogger(__name__) - - -class SymbolsProcessor: - """ - Преобразует ASCII-последовательности (псевдографику) в семантически - верные Unicode-символы. Работает на раннем этапе, до расстановки пробелов. - """ - - def __init__(self): - # Для сложных замен, требующих анализа контекста (например, диапазоны), - # по-прежнему используем регулярные выражения. - # Паттерн для диапазонов: цифра-дефис-цифра -> цифра–цифра (среднее тире). - # Обрабатываем арабские и римские цифры. - self._range_pattern = regex.compile(pattern=r'(\d)-(\d)|([IVXLCDM]+)-([IVXLCDM]+)', flags=regex.IGNORECASE) - - logger.debug("SymbolsProcessor `__init__`") - - def _replace_range(self, match: regex.Match) -> str: - # Паттерн имеет две группы: (\d)-(\d) ИЛИ ([IVX...])-([IVX...]) - if match.group(1) is not None: # Арабские цифры - return f'{match.group(1)}{CHAR_NDASH}{match.group(2)}' - if match.group(3) is not None: # Римские цифры - return f'{match.group(3)}{CHAR_NDASH}{match.group(4)}' - return match.group(0) # На всякий случай - - - def process(self, text: str) -> str: - # Шаг 1: Выполняем простые замены из списка `STR_TO_SYMBOL_REPLACEMENTS` (см. config.py). - # Этот шаг должен идти первым, чтобы пользователь мог, например, - # использовать '---' в диапазоне '1---5', если ему это нужно. - # В таком случае '---' заменится на '—', и правило для диапазонов - # с дефисом уже не сработает. - processed_text = text - for old, new in STR_TO_SYMBOL_REPLACEMENTS: - processed_text = processed_text.replace(old, new) - - # Шаг 2: Обрабатываем диапазоны с помощью регулярного выражения. - # Эта замена более специфична и требует контекста (цифры вокруг дефиса). - processed_text = self._range_pattern.sub(self._replace_range, processed_text) - - return processed_text - diff --git a/etpgrf_site/etpgrf/typograph.py b/etpgrf_site/etpgrf/typograph.py deleted file mode 100644 index 1c5dd69..0000000 --- a/etpgrf_site/etpgrf/typograph.py +++ /dev/null @@ -1,276 +0,0 @@ -# etpgrf/typograph.py -# Основной класс Typographer, который объединяет все модули правил и предоставляет единый интерфейс. -# Поддерживает обработку текста внутри HTML-тегов с помощью BeautifulSoup. -import logging -import html -import regex # Для проверки наличия корневых тегов -try: - from bs4 import BeautifulSoup, NavigableString -except ImportError: - BeautifulSoup = None -from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs -from etpgrf.hyphenation import Hyphenator -from etpgrf.unbreakables import Unbreakables -from etpgrf.quotes import QuotesProcessor -from etpgrf.layout import LayoutProcessor -from etpgrf.symbols import SymbolsProcessor -from etpgrf.sanitizer import SanitizerProcessor -from etpgrf.hanging import HangingPunctuationProcessor -from etpgrf.codec import decode_to_unicode, encode_from_unicode -from etpgrf.config import PROTECTED_HTML_TAGS, SANITIZE_ALL_HTML - - -# --- Настройки логирования --- -logger = logging.getLogger(__name__) - - -# --- Основной класс Typographer --- -class Typographer: - def __init__(self, - langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, - mode: str | None = None, - process_html: bool = False, # Флаг обработки HTML-тегов - hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов - unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов - quotes: QuotesProcessor | bool | None = True, # Правила для обработки кавычек - layout: LayoutProcessor | bool | None = True, # Правила для тире и спецсимволов - symbols: SymbolsProcessor | bool | None = True, # Правила для псевдографики - sanitizer: SanitizerProcessor | str | bool | None = None, # Правила очистки - hanging_punctuation: str | bool | list[str] | None = None, # Висячая пунктуация - # ... другие модули правил ... - ): - - # A. --- Обработка и валидация параметра langs --- - self.langs: frozenset[str] = parse_and_validate_langs(langs) - # B. --- Обработка и валидация параметра mode --- - self.mode: str = parse_and_validate_mode(mode) - # C. --- Настройка режима обработки HTML --- - self.process_html = process_html - if self.process_html and BeautifulSoup is None: - logger.warning("Параметр 'process_html=True', но библиотека BeautifulSoup не установлена. " - "HTML не будет обработан. Установите ее: `pip install beautifulsoup4`") - self.process_html = False - - # D. --- Конфигурация правил для псевдографики --- - self.symbols: SymbolsProcessor | None = None - if symbols is True or symbols is None: - self.symbols = SymbolsProcessor() - elif isinstance(symbols, SymbolsProcessor): - self.symbols = symbols - - # E. --- Инициализация правила переноса --- - # Предпосылка: если вызвали типограф, значит, мы хотим обрабатывать текст и переносы тоже нужно расставлять. - # А для специальных случаев, когда переносы не нужны, пусть не ленятся и делают `hyphenation=False`. - self.hyphenation: Hyphenator | None = None - if hyphenation is True or hyphenation is None: - # C1. Создаем новый объект Hyphenator с заданными языками и режимом, а все остальное по умолчанию - self.hyphenation = Hyphenator(langs=self.langs) - elif isinstance(hyphenation, Hyphenator): - # C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode) - self.hyphenation = hyphenation - - # F. --- Конфигурация правил неразрывных слов --- - self.unbreakables: Unbreakables | None = None - if unbreakables is True or unbreakables is None: - # D1. Создаем новый объект Unbreakables с заданными языками и режимом, а все остальное по умолчанию - self.unbreakables = Unbreakables(langs=self.langs) - elif isinstance(unbreakables, Unbreakables): - # D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode) - self.unbreakables = unbreakables - - # G. --- Конфигурация правил обработки кавычек --- - self.quotes: QuotesProcessor | None = None - if quotes is True or quotes is None: - self.quotes = QuotesProcessor(langs=self.langs) - elif isinstance(quotes, QuotesProcessor): - self.quotes = quotes - - # H. --- Конфигурация правил для тире и спецсимволов --- - self.layout: LayoutProcessor | None = None - if layout is True or layout is None: - self.layout = LayoutProcessor(langs=self.langs) - elif isinstance(layout, LayoutProcessor): - self.layout = layout - - # I. --- Конфигурация санитайзера --- - self.sanitizer: SanitizerProcessor | None = None - if isinstance(sanitizer, SanitizerProcessor): - self.sanitizer = sanitizer - elif sanitizer: # Если передана строка режима или True - self.sanitizer = SanitizerProcessor(mode=sanitizer) - - # J. --- Конфигурация висячей пунктуации --- - self.hanging: HangingPunctuationProcessor | None = None - if hanging_punctuation: - self.hanging = HangingPunctuationProcessor(mode=hanging_punctuation) - - # 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}, " - f"quotes: {self.quotes is not None}, " - f"layout: {self.layout is not None}, " - f"symbols: {self.symbols is not None}, " - f"sanitizer: {self.sanitizer is not None}, " - f"hanging: {self.hanging is not None}, " - f"process_html: {self.process_html}") - - - def _process_text_node(self, text: str) -> str: - """ - Внутренний конвейер, который работает с чистым текстом. - """ - # Шаг 1: Декодируем весь входящий текст в канонический Unicode - # (здесь можно использовать html.unescape, но наш кодек тоже подойдет) - processed_text = decode_to_unicode(text) - # processed_text = text # ВРЕМЕННО: используем текст как есть - - # Шаг 2: Применяем правила к чистому Unicode-тексту (только правила на уровне ноды) - if self.symbols is not None: - processed_text = self.symbols.process(processed_text) - if self.layout is not None: - processed_text = self.layout.process(processed_text) - if self.hyphenation is not None: - processed_text = self.hyphenation.hyp_in_text(processed_text) - # ... вызовы других активных модулей правил ... - - # Финальный шаг: кодируем результат в соответствии с выбранным режимом - return encode_from_unicode(processed_text, self.mode) - - def _walk_tree(self, node): - """ - Рекурсивно обходит DOM-дерево, находя и обрабатывая все текстовые узлы. - """ - # Список "детей" узла, который мы будем изменять. - # Копируем в список, так как будем изменять его во время итерации. - for child in list(node.children): - if isinstance(child, NavigableString): - # Если это текстовый узел, обрабатываем его - # Пропускаем пустые или состоящие из пробелов узлы - if not child.string.strip(): - continue - - processed_node_text = self._process_text_node(child.string) - child.replace_with((processed_node_text)) - elif child.name not in PROTECTED_HTML_TAGS: - # Если это "обычный" html-тег, рекурсивно заходим в него - self._walk_tree(child) - - def process(self, text: str) -> str: - """ - Обрабатывает текст, применяя все активные правила типографики. - Поддерживает обработку текста внутри HTML-тегов. - """ - if not text: - return "" - # Если включена обработка HTML и BeautifulSoup доступен - if self.process_html: - # --- ЭТАП 1: Анализ структуры --- - # Проверяем, есть ли в начале текста теги или . - # Если есть - значит, это полноценный документ, и мы должны вернуть его целиком. - # Если нет - значит, это фрагмент, и мы должны вернуть только содержимое body. - is_full_document = bool(regex.search(r'^\s*<(?:!DOCTYPE|html|body)', text, regex.IGNORECASE)) - - # --- ЭТАП 2: Парсинг и Санитизация --- - try: - soup = BeautifulSoup(text, 'lxml') - except Exception: - soup = BeautifulSoup(text, 'html.parser') - - if self.sanitizer: - result = self.sanitizer.process(soup) - # Если режим SANITIZE_ALL_HTML, то результат - это строка (чистый текст) - if isinstance(result, str): - # Переключаемся на обработку обычного текста - text = result - # ВАЖНО: Мы выходим из ветки process_html и идем в ветку else, - # но так как мы внутри if, нам нужно явно вызвать логику для текста. - # Проще всего рекурсивно вызвать process с выключенным process_html, - # но чтобы не менять состояние объекта, просто выполним логику "else" блока здесь. - # Или, еще проще: присвоим text = result и пойдем в блок else? Нет, мы уже внутри if. - - # Решение: Выполняем логику обработки простого текста прямо здесь - return self._process_plain_text(text) - - # Если результат - soup, продолжаем работу с ним - soup = result - - # --- ЭТАП 3: Подготовка (токен-стрим) --- - # 3.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать. - # soup.descendants возвращает все дочерние узлы (теги и текст) в порядке их следования. - text_nodes = [node for node in soup.descendants - if isinstance(node, NavigableString) - # and node.strip() - and node.parent.name not in PROTECTED_HTML_TAGS] - # 3.2. Создаем "супер-строку" и "карту длин" - super_string = "" - lengths_map = [] - for node in text_nodes: - super_string += str(node) - lengths_map.append(len(str(node))) - - # --- ЭТАП 4: Контекстная обработка --- - processed_super_string = super_string - # Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html). - # Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится. - if self.quotes: - processed_super_string = self.quotes.process(processed_super_string) - if self.unbreakables: - processed_super_string = self.unbreakables.process(processed_super_string) - - # --- ЭТАП 5: Восстановление структуры --- - current_pos = 0 - for i, node in enumerate(text_nodes): - length = lengths_map[i] - new_text_part = processed_super_string[current_pos : current_pos + length] - node.replace_with(new_text_part) # Заменяем содержимое узла на месте - current_pos += length - - # --- ЭТАП 6: Локальная обработка (второй проход) --- - # Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход, - # который применит все остальные правила к каждому текстовому узлу. - self._walk_tree(soup) - - # --- ЭТАП 7: Висячая пунктуация --- - # Применяем после всех текстовых преобразований, но перед финальной сборкой - if self.hanging: - self.hanging.process(soup) - - # --- ЭТАП 8: Финальная сборка --- - if is_full_document: - # Если на входе был полноценный документ, возвращаем все дерево - processed_html = str(soup) - else: - # Если на входе был фрагмент, возвращаем только содержимое body. - # decode_contents() возвращает строку с содержимым тега (без самого тега). - # Если body нет (что странно для BS), возвращаем str(soup). - if soup.body: - processed_html = soup.body.decode_contents() - else: - processed_html = str(soup) - - # BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали - # в _process_text_node. Возвращаем их обратно. - return processed_html.replace('&', '&') - else: - return self._process_plain_text(text) - - def _process_plain_text(self, text: str) -> str: - """ - Логика обработки обычного текста (вынесена из process для переиспользования). - """ - # Шаг 0: Нормализация - processed_text = decode_to_unicode(text) - # Шаг 1: Применяем все правила последовательно - if self.quotes: - processed_text = self.quotes.process(processed_text) - if self.unbreakables: - processed_text = self.unbreakables.process(processed_text) - if self.symbols: - processed_text = self.symbols.process(processed_text) - if self.layout: - processed_text = self.layout.process(processed_text) - if self.hyphenation: - processed_text = self.hyphenation.hyp_in_text(processed_text) - # Шаг 2: Финальное кодирование - return encode_from_unicode(processed_text, self.mode) diff --git a/etpgrf_site/etpgrf/unbreakables.py b/etpgrf_site/etpgrf/unbreakables.py deleted file mode 100644 index b12b83b..0000000 --- a/etpgrf_site/etpgrf/unbreakables.py +++ /dev/null @@ -1,124 +0,0 @@ -# etpgrf/unbreakables.py -# Модуль для предотвращения "висячих" предлогов, союзов и других коротких слов в начале строки. -# Он "приклеивает" такие слова к последующему слову с помощью неразрывного пробела. -# Кстати в русском тексте союзы составляют 7,61% - - -import regex -import logging -import html -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.config import CHAR_NBSP -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 (I - as pronoun) - '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', 'the' -]) - -# --- Настройки логирования --- -logger = logging.getLogger(__name__) - - -# --- Класс Unbreakables (обработка неразрывных конструкций) --- -class Unbreakables: - """ - Правила обработки коротких слов (предлогов, союзов, частиц и местоимений) для предотвращения их отрыва - от последующих слов. - """ - - def __init__(self, langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None): - self.langs = parse_and_validate_langs(langs) - - # --- 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}, " - 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>" + CHAR_NBSP, processed_text) - - # 2. Обработка частиц, ПЕРЕД которыми нужен неразрывный пробел ("сказал бы" -> "сказал бы") - if self._post_pattern: - # \g<1> - это пробел, \g<2> - это частица - processed_text = self._post_pattern.sub(CHAR_NBSP + r"\g<2>", processed_text) - - return processed_text