diff --git a/etpgrf_site/etpgrf/__init__.py b/etpgrf_site/etpgrf/__init__.py new file mode 100644 index 0000000..fee94ed --- /dev/null +++ b/etpgrf_site/etpgrf/__init__.py @@ -0,0 +1,22 @@ +""" +etpgrf - библиотека для экранной типографики текста с поддержкой HTML. + +Основные возможности: +- Автоматическая расстановка переносов +- Неразрывные пробелы для союзов и предлогов +- Корректные кавычки в зависимости от языка +- Висячая пунктуация +- Очистка и обработка HTML +""" +__version__ = "0.1.0" + +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 new file mode 100644 index 0000000..db040a6 --- /dev/null +++ b/etpgrf_site/etpgrf/codec.py @@ -0,0 +1,58 @@ +# 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 new file mode 100644 index 0000000..50ef2d2 --- /dev/null +++ b/etpgrf_site/etpgrf/comutil.py @@ -0,0 +1,140 @@ +# 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 new file mode 100644 index 0000000..11bbdc7 --- /dev/null +++ b/etpgrf_site/etpgrf/config.py @@ -0,0 +1,722 @@ +# 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 new file mode 100644 index 0000000..e6036e0 --- /dev/null +++ b/etpgrf_site/etpgrf/defaults.py @@ -0,0 +1,32 @@ +# 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 new file mode 100644 index 0000000..681b6c9 --- /dev/null +++ b/etpgrf_site/etpgrf/hanging.py @@ -0,0 +1,166 @@ +# 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 new file mode 100755 index 0000000..f9d3c26 --- /dev/null +++ b/etpgrf_site/etpgrf/hyphenation.py @@ -0,0 +1,358 @@ +# 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 new file mode 100644 index 0000000..4d37d09 --- /dev/null +++ b/etpgrf_site/etpgrf/layout.py @@ -0,0 +1,211 @@ +# 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 new file mode 100644 index 0000000..1f6bb42 --- /dev/null +++ b/etpgrf_site/etpgrf/logger.py @@ -0,0 +1,124 @@ +# 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 new file mode 100644 index 0000000..1ff3b93 --- /dev/null +++ b/etpgrf_site/etpgrf/quotes.py @@ -0,0 +1,75 @@ +# 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 new file mode 100644 index 0000000..56cf45e --- /dev/null +++ b/etpgrf_site/etpgrf/sanitizer.py @@ -0,0 +1,76 @@ +# 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 new file mode 100644 index 0000000..b2816a4 --- /dev/null +++ b/etpgrf_site/etpgrf/symbols.py @@ -0,0 +1,50 @@ +# 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 new file mode 100644 index 0000000..5126eff --- /dev/null +++ b/etpgrf_site/etpgrf/typograph.py @@ -0,0 +1,258 @@ +# etpgrf/typograph.py +# Основной класс Typographer, который объединяет все модули правил и предоставляет единый интерфейс. +# Поддерживает обработку текста внутри HTML-тегов с помощью BeautifulSoup. +import logging +import html +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: Токенизация и "умная склейка" --- + try: + soup = BeautifulSoup(text, 'lxml') + except Exception: + soup = BeautifulSoup(text, 'html.parser') + + # --- ЭТАП 0: Санитизация (Очистка) --- + 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 + + # 1.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] + # 1.2. Создаем "супер-строку" и "карту длин" + super_string = "" + lengths_map = [] + for node in text_nodes: + super_string += str(node) + lengths_map.append(len(str(node))) + + # --- ЭТАП 2: Контекстная обработка (ПОКА ЧТО ПРОПУСКАЕМ) --- + 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) + + # --- ЭТАП 3: "Восстановление" --- + 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 + + # --- ЭТАП 4: Локальная обработка (второй проход) --- + # Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход, + # который применит все остальные правила к каждому текстовому узлу. + self._walk_tree(soup) + + # --- ЭТАП 4.5: Висячая пунктуация --- + # Применяем после всех текстовых преобразований, но перед финальной сборкой + if self.hanging: + self.hanging.process(soup) + + # --- ЭТАП 5: Финальная сборка --- + 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 new file mode 100644 index 0000000..b12b83b --- /dev/null +++ b/etpgrf_site/etpgrf/unbreakables.py @@ -0,0 +1,124 @@ +# 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