tmp: код etpgrf перенесен внутрь проекта...

This commit is contained in:
2026-01-05 22:43:57 +03:00
parent 0a4fcb44be
commit 9740887359
14 changed files with 2416 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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' # Неразрывный пробел (&nbsp;)
CHAR_SHY = '\u00ad' # Мягкий перенос (&shy;)
CHAR_THIN_SP = '\u2009' # Тонкий пробел (шпация, &thinsp;)
CHAR_NDASH = '\u2013' # Cреднее тире ( / &ndash;)
CHAR_MDASH = '\u2014' # Длинное тире (— / &mdash;)
CHAR_HELLIP = '\u2026' # Многоточие (… / &hellip;)
CHAR_RU_QUOT1_OPEN = '«' # Русские кавычки открывающие (« / &laquo;)
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' # Символ авторского права / © / &copy;
CHAR_REG = '\u00ae' # Зарегистрированная торговая марка / ® / &reg;
CHAR_COPYP = '\u2117' # Знак звуковой записи / ℗ / &copyp;
CHAR_TRADE = '\u2122' # Знак торговой марки / ™ / &trade;
CHAR_ARROW_LR_DOUBLE = '\u21d4' # Двойная двунаправленная стрелка / ⇔ / &hArr;
CHAR_ARROW_L_DOUBLE = '\u21d0' # Двойная стрелка влево / ⇐ / &lArr;
CHAR_ARROW_R_DOUBLE = '\u21d2' # Двойная стрелка вправо / ⇒ / &rArr;
CHAR_AP = '\u2248' # Приблизительно равно / ≈ / &ap;
CHAR_ARROW_L = '\u27f5' # Стрелка влево / ← / &larr;
CHAR_ARROW_R = '\u27f6' # Стрелка вправо / → / &rarr;
CHAR_ARROW_LR = '\u27f7' # Длинная двунаправленная стрелка ↔ / &harr;
CHAR_ARROW_L_LONG_DOUBLE = '\u27f8' # Длинная двойная стрелка влево
CHAR_ARROW_R_LONG_DOUBLE = '\u27f9' # Длинная двойная стрелка вправо
CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двунаправленная стрелка
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / &middot;
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) -- &shy;
CHAR_NBSP, # Неразрывный пробел (Non-Breaking Space) -- &nbsp;
'\u2002', # Полужирный пробел (En Space) -- &ensp;
'\u2003', # Широкий пробел (Em Space) -- &emsp;
'\u2007', # Цифровой пробел -- &numsp;
'\u2008', # Пунктуационный пробел -- &puncsp;
CHAR_THIN_SP, # Межсимвольный пробел, тонкий пробел, шпация -- &thinsp;'
'\u200A', # Толщина волоса (Hair Space) -- &hairsp;
'\u200B', # Негативный пробел (Negative Space) -- &NegativeThinSpace;
'\u200C', # Нулевая ширина (без объединения) (Zero Width Non-Joiner) -- &zwj;
'\u200D', # Нулевая ширина (с объединением) (Zero Width Joiner) -- &zwnj;
'\u200E', # Изменить направление текста на слева-направо (Left-to-Right Mark /LRE) -- &lrm;
'\u200F', # Изменить направление текста направо-налево (Right-to-Left Mark /RLM) -- &rlm;
'\u2010', # Дефис (Hyphen) -- &dash;
'\u205F', # Средний пробел (Medium Mathematical Space) -- &MediumSpace;
'\u2060', # &NoBreak;
'\u2062', # &InvisibleTimes; -- для семантической разметки математических выражений
'\u2063', # &InvisibleComma; -- для семантической разметки математических выражений
])
# 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': '&hyphen;', # Для \u2010 всегда предпочитаем &hyphen;, а не &dash;
# # Исключения для букв, которые есть в алфавитах, но должны кодироваться (для обеспечения консистентности):
# # 'Æ': '&AElig;',
# # 'Œ': '&OElig;',
# # 'æ': '&aelig;',
# # 'œ': '&oelig;',
# '\u002a': '&ast;', # * / &ast; / &midast;
# '\u005b': '&lsqb;', # [ / &lsqb; / &lbrack;
# '\u005d': '&rsqb;', # ] / &rsqb; / &rbrack;
# '\u005f': '&lowbar;', # _ / &lowbar; / &UnderBar;
# '\u007b': '&lcub;', # { / &lcub; / &lbrace;
# '\u007d': '&rcub;', # } / &rcub; / &rbrace;
# '\u007c': '&vert;', # | / &vert; / &verbar; / &VerticalLine;
CHAR_NBSP: '&nbsp;', # / &nbsp; / &NonBreakingSpace;
CHAR_REG: '&reg;', # ® / &reg; / &REG; / &circledR;
CHAR_COPY: '&copy;', # © / &copy; / &COPY;
'\u0022': '&quot;', # " / &quot; / &QUOT;
'\u0026': '&amp;', # & / &amp; / &AMP;
'\u003e': '&gt;', # > / &gt; / &GT;
'\u003c': '&lt;', # < / &lt; / &LT;
CHAR_MIDDOT: '&middot;', # · / &middot; / &centerdot; / &CenterDot;
'\u0060': '&grave;', # ` / &grave; / &DiacriticalGrave;
'\u00a8': '&die;', # ¨ / &die; / &Dot; / &uml; / &DoubleDot;
'\u00b1': '&pm;', # ± / &pm; / &PlusMinus;
'\u00bd': '&half;', # ½ / &frac12; / &half;
'\u00af': '&macr;', # ¯ / &macr; / &strns;
'\u201a': '&sbquo;', # / &sbquo; / &lsquor;
'\u223e': '&ac;', # ∾ / &ac; / &mstpos;
'\u2207': '&Del;', # ∇ / &Del; / &nabla;
'\u2061': '&af;', # / &af; / &ApplyFunction;
'\u2221': '&angmsd;', # ∡ / &angmsd; / &measuredangle;
CHAR_AP: '&ap;', # ≈ / &ap; / &thkap; / &approx; / &TildeTilde; / &thickapprox;
'\u224a': '&ape;', # ≊ / &ape; / &approxeq;
'\u2254': '&Assign;', # ≔ / &Assign; / &colone; / &coloneq;
'\u224d': '&CupCap;', # ≍ / &CupCap; / &asympeq;
'\u2233': '&awconint;', # ∳ / &awconint; / &CounterClockwiseContourIntegral;
'\u224c': '&bcong;', # ≌ / &bcong; / &backcong;
'\u03f6': '&bepsi;', # ϶ / &bepsi; / &backepsilon;
'\u2035': '&bprime;', # / &bprime; / &backprime;
'\u223d': '&bsim;', # ∽ / &bsim; / &backsim;
'\u22cd': '&bsime;', # ⋍ / &bsime; / &backsimeq;
'\u2216': '&setmn;', # / &setmn; / &ssetmn; / &setminus; / &Backslash; / &smallsetminus;
'\u2306': '&Barwed;', # ⌆ / &Barwed; / &doublebarwedge;
'\u2305': '&barwed;', # ⌅ / &barwed; / &barwedge;
'\u23b5': '&bbrk;', # ⎵ / &bbrk; / &UnderBracket;
'\u2235': '&becaus;', # ∵ / &becaus; / &because; / &Because;
'\u212c': '&Bscr;', # / &Bscr; / &bernou; / &Bernoullis;
'\u2264': '&le;', # ≤ / &le; / &leq;
'\u226c': '&twixt;', # ≬ / &twixt; / &between;
'\u22c2': '&xcap;', # ⋂ / &xcap; / &bigcap; / &Intersection;
'\u25ef': '&xcirc;', # ◯ / &xcirc; / &bigcirc;
'\u22c3': '&xcup;', # / &xcup; / &Union; / &bigcup;
'\u2a00': '&xodot;', # ⨀ / &xodot; / &bigodot;
'\u2a01': '&xoplus;', # ⨁ / &xoplus; / &bigoplus;
'\u2a02': '&xotime;', # ⨂ / &xotime; / &bigotimes;
'\u2a06': '&xsqcup;', # ⨆ / &xsqcup; / &bigsqcup;
'\u2605': '&starf;', # ★ / &starf; / &bigstar;
'\u25bd': '&xdtri;', # ▽ / &xdtri; / &bigtriangledown;
'\u25b3': '&xutri;', # △ / &xutri; / &bigtriangleup;
'\u2a04': '&xuplus;', # ⨄ / &xuplus; / &biguplus;
'\u22c1': '&Vee;', # / &Vee; / &xvee; / &bigvee;
'\u22c0': '&Wedge;', # ⋀ / &Wedge; / &xwedge; / $bigwedge;
'\u2227': '&and;', # ∧ / &and; / &wedge;
'\u290d': '&rbarr;', # ⤍ / &rbarr; / &bkarow;
'\u29eb': '&lozf;', # ⧫ / &lozf; / &blacklozenge;
'\u25ca': '&loz;', # ◊ / &loz; / &lozenge
'\u25aa': '&squf;', # ▪ / &squf; / &squarf; / &blacksquare; / &FilledVerySmallSquare;
'\u25b4': '&utrif;', # ▴ / &utrif; / &blacktriangle;
'\u25be': '&dtrif;', # ▾ / &dtrif; / &blacktriangledown;
'\u25c2': '&ltrif;', # ◂ / &ltrif; / &blacktriangleleft;
'\u25b8': '&rtrif;', # ▸ / &rtrif; / &blacktriangleright;
'\u22a5': '&bot;', # ⊥ / &bot; / &UpTee; / &bottom; / &perp;
'\u2500': '&boxh;', # ─ / &boxh; / &HorizontalLine;
'\u229f': '&minusb;', # ⊟ / &minusb; / &boxminus;
'\u229e': '&plusb;', # ⊞ / &plusb; / &boxplus;
'\u22a0': '&timesb;', # ⊠ / &timesb; / &boxtimes;
'\u02d8': '&breve;', # ˘ / &breve; / &Breve;
'\u224e': '&bump;', # ≎ / &bump; / &Bumpeq; / &HumpDownHump;
'\u224f': '&bumpe;', # ≏ / &bumpe; / &bumpeq; / &HumpEqual;
'\u2145': '&DD;', # / &DD; / &CapitalDifferentialD;
'\u02c7': '&caron;', # ˇ / &Hacek; / &caron;
'\u212d': '&Cfr;', # / &Cfr; / &Cayleys;
'\u2713': '&check;', # ✓ / &check; / &checkmark;
'\u2257': '&cire;', # ≗ / &cire; / &circeq;
'\u21ba': '&olarr;', # ↺ / &olarr; / &circlearrowleft;
'\u21bb': '&orarr;', # ↻ / &orarr; / &circlearrowright;
'\u229b': '&oast;', # ⊛ / &oast; / &circledast;
'\u229a': '&ocir;', # ⊚ / &ocir; / &circledcirc;
'\u229d': '&odash;', # ⊝ / &odash; / &circleddash;
'\u2299': '&odot;', # ⊙ / &odot; / &CircleDot;
'\u2200': '&forall;', # ∀ / &forall; / &ForAll;
'\u24c8': '&oS;', # Ⓢ / &oS; / &circledS;
'\u2296': '&ominus;', # ⊖ / &ominus; / &CircleMinus;
'\u2232': '&cwconint;', # ∲ / &cwconint; / &ClockwiseContourIntegral;
'\u201d': '&rdquo;', # ” / &rdquo; / &rdquor; / &CloseCurlyDoubleQuote;
'\u2019': '&rsquo;', # / &rsquo; / &rsquor; / &CloseCurlyQuote;
'\u2237': '&Colon;', # ∷ / &Colon; / &Proportion;
'\u2201': '&comp;', # ∁ / &comp; / &complement;
'\u2218': '&compfn;', # ∘ / &compfn; / &SmallCircle;
'\u2102': '&Copf;', # / &Copf; / &complexes;
'\u222f': '&Conint;', # ∯ / &Conint; / &DoubleContourIntegral;
'\u222e': '&oint;', # ∮ / &oint; / &conint; / &ContourIntegral;
'\u2210': '&coprod;', # ∐ / &coprod; / &Coproduct;
'\u22de': '&cuepr;', # ⋞ / &cuepr; / &curlyeqprec;
'\u22df': '&cuesc;', # ⋟ / &cuesc; / &curlyeqsucc;
'\u21b6': '&cularr;', # ↶ / &cularr; / &curvearrowleft;
'\u21b7': '&curarr;', # ↷ / &curarr; / &curvearrowright;
'\u22ce': '&cuvee;', # ⋎ / &cuvee; / &curlyvee;
'\u22cf': '&cuwed;', # ⋏ / &cuwed; / &curlywedge;
'\u2010': '&dash;', # / &dash; / &hyphen;
'\u2ae4': '&Dashv;', # ⫤ / &Dashv; / &DoubleLeftTee;
'\u22a3': '&dashv;', # ⊣ / &dashv; / &LeftTee;
'\u290f': '&rBarr;', # ⤏ / &rBarr; / &dbkarow;
'\u02dd': '&dblac;', # ˝ / &dblac; / &DiacriticalDoubleAcute;
'\u2146': '&dd;', # / &dd; / &DifferentialD;
'\u21ca': '&ddarr;', # ⇊ / &ddarr; / &downdownarrows;
'\u2a77': '&eDDot;', # ⩷ / &eDDot; / &ddotseq;
'\u21c3': '&dharl;', # ⇃ / &dharl; / &LeftDownVector; / &downharpoonleft;
'\u21c2': '&dharr;', # ⇂ / &dharr; / &RightDownVector; / &downharpoonright;
'\u02d9': '&dot;', # ˙ / &dot; / &DiacriticalDot;
'\u222b': '&int;', # ∫ / &int; / &Integral;
'\u22c4': '&diam;', # ⋄ / &diam; / &diamond; / &Diamond;
'\u03b5': '&epsi;', # ε / &epsi; / &epsilon;
'\u03dd': '&gammad;', # ϝ / &gammad; / &digamma;
'\u22c7': '&divonx;', # ⋇ / &divonx; / &divideontimes;
'\u231e': '&dlcorn;', # ⌞ / &dlcorn; / &llcorner;
'\u2250': '&esdot;', # ≐ / &esdot; / &doteq; / &DotEqual;
'\u2251': '&eDot;', # ≑ / &eDot; / &doteqdot;
'\u2238': '&minusd;', # ∸ / &minusd; / &dotminus;
'\u2214': '&plusdo;', # ∔ / &plusdo; / &dotplus;
'\u22a1': '&sdotb;', # ⊡ / &sdotb; / &dotsquare;
'\u21d3': '&dArr;', # ⇓ / &dArr; / &Downarrow; / &DoubleDownArrow;
CHAR_ARROW_R_DOUBLE: '&rArr;', # ⇒ / &rArr; / &Implies; / &Rightarrow; / &DoubleRightArrow;
CHAR_ARROW_L_DOUBLE: '&lArr;', # ⇐ / &lArr; / &Leftarrow; / &DoubleLeftArrow;
CHAR_ARROW_LR_DOUBLE: '&iff;', # ⇔ / &iff; / &hArr; / &Leftrightarrow; / &DoubleLeftRightArrow;
CHAR_ARROW_L_LONG_DOUBLE: '&xlArr;', # ⟸ / &xlArr; / &Longleftarrow; / &DoubleLongLeftArrow;
CHAR_ARROW_R_LONG_DOUBLE: '&xrArr;', # ⟹ / &xrArr; / &Longrightarrow; / &DoubleLongRightArrow;
CHAR_ARROW_LR_LONG_DOUBLE: '&xhArr;', # ⟺ / &xhArr; / &Longleftrightarrow; / &DoubleLongLeftRightArrow;
'\u22a8': '&vDash;', # ⊨ / &vDash; / &DoubleRightTee;
'\u21d1': '&uArr;', # ⇑ / &uArr; / &Uparrow; / &DoubleUpArrow;
'\u2202': '&part;', # ∂ / &part; / &PartialD;
'\u21d5': '&vArr;', # ⇕ / &vArr; / &Updownarrow; / &DoubleUpDownArrow;
'\u2225': '&par;', # ∥ / &par; / &spar; / &parallel; / &shortparallel; / &DoubleVerticalBar;
'\u2193': '&darr;', # ↓ / &darr; / &downarrow; / &DownArrow; / &ShortDownArrow;
'\u21f5': '&duarr;', # ⇵ / &duarr; / &DownArrowUpArrow;
'\u21bd': '&lhard;', # ↽ / &lhard; /&DownLeftVector; / &leftharpoondown;
'\u21c1': '&rhard;', # ⇁ / &rhard; / &DownRightVector; / &rightharpoondown;
'\u22a4': '&top;', # / &top; / &DownTee;
'\u21a7': '&mapstodown;', # ↧ / &mapstodown; / &DownTeeArrow;
'\u2910': '&RBarr;', # ⤐ / &RBarr; / &drbkarow;
'\u231f': '&drcorn;', # ⌟ / &drcorn; / &lrcorner;
'\u25bf': '&dtri;', # ▿ / &dtri; / &triangledown;
'\u296f': '&duhar;', # ⥯ / &duhar; / &ReverseUpEquilibrium;
'\u2256': '&ecir;', # ≖ / &ecir; / &eqcirc;
'\u2255': '&ecolon;', # ≕ / &ecolon; / &eqcolon;
'\u2147': '&ee;', # / &ee; / &exponentiale; / &ExponentialE;
'\u2252': '&efDot;', # ≒ / &efDot; / &fallingdotseq;
'\u2a96': '&egs;', # ⪖ / &egs; / &eqslantgtr;
'\u2208': '&in;', # ∈ / &in; / &isin; / &isinv; / &Element;
'\u2a95': '&els;', # ⪕ / &els; / &eqslantless;
'\u2205': '&empty;', # ∅ / &empty; / &emptyv; / &emptyset; / &varnothing;
'\u03f5': '&epsiv;', # ϵ / &epsiv; / &varepsilon; / &straightepsilon;
'\u2242': '&esim;', # ≂ / &esim; / &eqsim; / &EqualTilde;
'\u225f': '&equest;', # ≟ / &equest; / &questeq;
'\u21cc': '&rlhar;', # ⇌ / &rlhar; / &Equilibrium; / &rightleftharpoons;
'\u2253': '&erDot;', # ≓ / &erDot; / &risingdotseq;
'\u2130': '&Escr;', # / &Escr; / &expectation;
'\u22d4': '&fork;', # ⋔ / &fork; / &pitchfork;
'\u2131': '&Fscr;', # / &Fscr; / &Fouriertrf;
'\u2322': '&frown;', # ⌢ / &frown; / &sfrown;
'\u2a86': '&gap;', # ⪆ / &gap; / &gtrapprox;
'\u2267': '&gE;', # ≧ / &gE; / &geqq; / &GreaterFullEqual;
'\u2a8c': '&gEl;', # ⪌ / &gEl; / &gtreqqless;
'\u22db': '&gel;', # ⋛ / &gel; / &gtreqless; / &GreaterEqualLess;
'\u2265': '&ge;', # ≥ / &ge; / &geq; / &GreaterEqual;
'\u2a7e': '&ges;', # ⩾ / &ges; / &geqslant; / &GreaterSlantEqual;
'\u22d9': '&Gg;', # ⋙ / &Gg; / &ggg;
'\u226b': '&gg;', # ≫ / &gg ;/ &Gt; / &NestedGreaterGreater;
'\u2277': '&gl;', # ≷ / &gl; / &gtrless; / &GreaterLess;
'\u2a8a': '&gnap;', # ⪊ / &gnap; / &gnapprox;
'\u2269': '&gnE;', # ≩ / &gnE; / &gneqq;
'\u2260': '&ne;', # ≠ / &ne; / &NotEqual;
'\u2a88': '&gne;', # ⪈ / &gne; / &gneq;
'\u2273': '&gsim;', # ≳ / &gsim; / &gtrsim; / &GreaterTilde;
'\u22d7': '&gtdot;', # ⋗ / &gtdot; / &gtrdot;
'\u200a': '&hairsp;', # / &hairsp; / &VeryThinSpace;
'\u210b': '&Hscr;', # / &Hscr; / &hamilt; / &HilbertSpace;
'\u21ad': '&harrw;', # ↭ / &harrw; / &leftrightsquigarrow;
'\u210f': '&hbar;', # ℏ / &hbar; / &planck; / &hslash; / &plankv;
'\u210c': '&Hfr;', # / &Hfr; / &Poincareplane;
'\u2925': '&searhk;', # ⤥ / &searhk; / &hksearow;
'\u2926': '&swarhk;', # ⤦ / &swarhk; / &hkswarow;
'\u21a9': '&larrhk;', # ↩ / &larrhk; / &hookleftarrow;
'\u21aa': '&rarrhk;', # ↪ / &rarrhk; / &hookrightarrow;
'\u210d': '&Hopf;', # / &Hopf; / &quaternions;
'\u2063': '&ic;', # / &ic; / &InvisibleComma;
'\u2111': '&Im;', # / &Im; / &Ifr; / &image; / &imagpart;
'\u2148': '&ii;', # / &ii; / &ImaginaryI;
'\u2a0c': '&qint;', # ⨌ / &qint; / &iiiint;
'\u222d': '&tint;', # ∭ / &tint; / &iiint;
'\u2110': '&Iscr;', # / &Iscr; / &imagline;
'\u0131': '&imath;', # ı / &imath; / &inodot;
'\u22ba': '&intcal;', # ⊺ / &intcal; / &intercal;
'\u2124': '&Zopf;', # / &Zopf; / &integers;
'\u2a3c': '&iprod;', # ⨼ / &iprod; / &intprod;
'\u2062': '&it;', # / &it; / &InvisibleTimes;
'\u03f0': '&kappav;', # ϰ / &kappav; / &varkappa;
'\u21da': '&lAarr;', # ⇚ / &lAarr; / &Lleftarrow;
'\u2112': '&Lscr;', # / &Lscr; / &lagran; / &Laplacetrf;
'\u27e8': '&lang;', # ⟨ / &lang; / &langle; / &LeftAngleBracket;
'\u2a85': '&lap;', # ⪅ / &lap; / &lessapprox;
'\u219e': '&Larr;', # ↞ / &Larr; / &twoheadleftarrow;
'\u21e4': '&larrb;', # ⇤ / &larrb; / &LeftArrowBar;
'\u21ab': '&larrlp;', # ↫ / &larrlp; / &looparrowleft;
'\u21a2': '&larrtl;', # ↢ / &larrtl; / &leftarrowtail;
'\u2266': '&lE;', # ≦ / &lE; / &leqq; / &LessFullEqual;
'\u2190': '&larr;', # ← / &larr; / &slarr; / &LeftArrow; / &leftarrow; / &ShortLeftArrow;
'\u21c6': '&lrarr;', # ⇆ / &lrarr; / &leftrightarrows; / &LeftArrowRightArrow;
'\u27e6': '&lobrk;', # ⟦ / &lobrk; / &LeftDoubleBracket;
'\u21bc': '&lharu;', # ↼ / &lharu; / &LeftVector; / &leftharpoonup;
'\u21c7': '&llarr;', # ⇇ / &llarr; / &leftleftarrows;
'\u2194': '&harr;', # ↔ / &harr; / &leftrightarrow; / &LeftRightArrow;
'\u21cb': '&lrhar;', # ⇋ / &lrhar; / &leftrightharpoons; / &ReverseEquilibrium;
'\u21a4': '&mapstoleft;', # ↤ / &mapstoleft; / &LeftTeeArrow;
'\u22cb': '&lthree;', # ⋋ / &lthree; / &leftthreetimes;
'\u22b2': '&vltri;', # ⊲ / &vltri; / &LeftTriangle; / &vartriangleleft;
'\u22b4': '&ltrie;', # ⊴ / &ltrie; / &trianglelefteq; / &LeftTriangleEqual;
'\u21bf': '&uharl;', # ↿ / &uharl; / &LeftUpVector; / &upharpoonleft;
'\u2308': '&lceil;', # ⌈ / &lceil; / &LeftCeiling;
'\u230a': '&lfloor;', # ⌊ / &lfloor; / &LeftFloor;
'\u2a8b': '&lEg;', # ⪋ / &lEg; / &lesseqqgtr;
'\u22da': '&leg;', # ⋚ / &leg; / &lesseqgtr; / &LessEqualGreater;
'\u2a7d': '&les;', # ⩽ / &les; / &leqslant; / &LessSlantEqual;
'\u22d6': '&ltdot;', # ⋖ / &ltdot; / &lessdot;
'\u2276': '&lg;', # ≶ / &lg; / &lessgtr; / &LessGreater;
'\u2272': '&lsim;', # ≲ / &lsim; / &lesssim; / &LessTilde;
'\u226a': '&ll;', # ≪ / &ll; / &Lt; / &NestedLessLess;
'\u23b0': '&lmoust;', # ⎰ / &lmoust; / &lmoustache;
'\u2a89': '&lnap;', # ⪉ / &lnap; / &lnapprox;
'\u2268': '&lnE;', # ≨ / &lnE; / &lneqq;
'\u2a87': '&lne;', # ⪇ / &lne; / &lneq;
CHAR_ARROW_L: '&xlarr;', # ⟵ / &xlarr; / &longleftarrow; / &LongLeftArrow;
CHAR_ARROW_R: '&xrarr;', # ⟶ / &xrarr; / &LongRightArrow; / &longrightarrow;
CHAR_ARROW_LR: '&xharr;', # ⟷ / &xharr; / &longleftrightarrow; / &LongLeftRightArrow;
'\u27fc': '&xmap;', # ⟼ / &xmap; / &longmapsto;
'\u21ac': '&rarrlp;', # ↬ / &rarrlp; / &looparrowright;
'\u201e': '&bdquo;', # „ / &bdquo; / &ldquor;
'\u2199': '&swarr;', # ↙ / &swarr; / &swarrow; / &LowerLeftArrow;
'\u2198': '&searr;', # ↘ / &searr; / &searrow; / &LowerRightArrow;
'\u21b0': '&lsh;', # ↰ / &Lsh; / &lsh;
'\u25c3': '&ltri;', # ◃ / &ltri; / &triangleleft;
'\u2720': '&malt;', # ✠ / &malt; / &maltese;
'\u21a6': '&map;', # ↦ / &map; / &mapsto; / &RightTeeArrow;
'\u21a5': '&mapstoup;', # ↥ / &mapstoup; / &UpTeeArrow;
'\u2133': '&Mscr;', # / &Mscr; / &phmmat; / &Mellintrf;
'\u2223': '&mid;', # / &mid; / &smid; / &shortmid; / &VerticalBar;
'\u2213': '&mp;', # ∓ / &mp; / &mnplus; / &MinusPlus;
CHAR_HELLIP: '&mldr;', # … / &mldr; / &hellip;
'\u22b8': '&mumap;', # ⊸ / &mumap; / &multimap;
'\u2249': '&nap;', # ≉ / &nap; / &napprox; / &NotTildeTilde;
'\u266e': '&natur;', # ♮ / &natur; / &natural;
'\u2115': '&Nopf;', # / &Nopf; / &naturals;
'\u2247': '&ncong;', # ≇ / &ncong; / &NotTildeFullEqual;
'\u2197': '&nearr;', # ↗ / &nearr; / &nearrow; / &UpperRightArrow;
'\u200b': '&ZeroWidthSpace;', # / &ZeroWidthSpace; / &NegativeThinSpace; / &NegativeMediumSpace;
# &NegativeThickSpace; / &NegativeVeryThinSpace;
'\u2262': '&nequiv;', # ≢ / &nequiv; / &NotCongruent;
'\u2928': '&toea;', # ⤨ / &toea; / &nesear;
'\u2203': '&exist;', # ∃ / &exist; / &Exists;
'\u2204': '&nexist;', # ∄ / &nexist; / &nexists; / &NotExists;
'\u2271': '&nge;', # ≱ / &nge; / &ngeq; / &NotGreaterEqual;
'\u2275': '&ngsim;', # ≵ / &ngsim; / &NotGreaterTilde;
'\u226f': '&ngt;', # ≯ / &ngt; / &ngtr; / &NotGreater;
'\u21ce': '&nhArr;', # ⇎ / &nhArr; / &nLeftrightarrow;
'\u21ae': '&nharr;', # ↮ / &nharr; / &nleftrightarrow;
'\u220b': '&ni;', # ∋ / &ni; / &niv; / &SuchThat; / &ReverseElement;
'\u21cd': '&nlArr;', # ⇍ / &nlArr; / &nLeftarrow;
'\u219a': '&nlarr;', # ↚ / &nlarr; / &nleftarrow;
'\u2270': '&nle;', # ≰ / &nle; / &nleq; / &NotLessEqual;
'\u226e': '&nlt;', # ≮ / &nlt; / &nless; / &NotLess;
'\u2274': '&nlsim;', # ≴ / &nlsim; / &NotLessTilde;
'\u22ea': '&nltri;', # ⋪ / &nltri; / &ntriangleleft; / &NotLeftTriangle;
'\u22ec': '&nltrie;', # ⋬ / &nltrie; / &ntrianglelefteq; / &NotLeftTriangleEqual;
'\u2224': '&nmid;', # ∤ / &nmid; / &nsmid; / &nshortmid; / &NotVerticalBar;
'\u2226': '&npar;', # ∦ / &npar; / &nspar; / &nparallel; / &nshortparallel; / &NotDoubleVerticalBar;
'\u2209': '&notin;', # ∉ / &notin; / &notinva; / &NotElement;
'\u2279': '&ntgl;', # ≹ / &ntgl; / &NotGreaterLess;
'\u2278': '&ntlg;', # ≸ / &ntlg; / &NotLessGreater;
'\u220c': '&notni;', # ∌ / &notni; / &notniva; / &NotReverseElement;
'\u2280': '&npr;', # ⊀ / &npr; / &nprec; / &NotPrecedes;
'\u22e0': '&nprcue;', # ⋠ / &nprcue; / &NotPrecedesSlantEqual;
'\u22eb': '&nrtri;', # ⋫ / &nrtri; / &ntriangleright; / &NotRightTriangle;
'\u22ed': '&nrtrie;', # ⋭ / &nrtrie; / &ntrianglerighteq; / &NotRightTriangleEqual;
'\u22e2': '&nsqsube;', # ⋢ / &nsqsube; / &NotSquareSubsetEqual;
'\u22e3': '&nsqsupe;', # ⋣ / &nsqsupe; / &NotSquareSupersetEqual;
'\u2288': '&nsube;', # ⊈ / &nsube; / &nsubseteq; / &NotSubsetEqual;
'\u2281': '&nsc;', # ⊁ / &nsc; / &nsucc; / &NotSucceeds;
'\u22e1': '&nsccue;', # ⋡ / &nsccue; / &NotSucceedsSlantEqual;
'\u2289': '&nsupe;', # ⊉ / &nsupe; / &nsupseteq; / &NotSupersetEqual;
'\u2241': '&nsim;', # ≁ / &nsim; / &NotTilde;
'\u2244': '&nsime;', # ≄ / &nsime; / &nsimeq; / &NotTildeEqual;
'\u21cf': '&nrArr;', # ⇏ / &nrArr; / &nRightarrow;
'\u219b': '&nrarr;', # ↛ / &nrarr; / &nrightarrow;
'\u2196': '&nwarr;', # ↖ / &nwarr; / &nwarrow; / &UpperLeftArrow;
'\u2134': '&oscr;', # / &oscr; / &order; / &orderof;
'\u203e': '&oline;', # ̄ / &oline; / &OverBar;
'\u23b4': '&tbrk;', # ⎴ / &tbrk; / &OverBracket;
'\u03d6': '&piv;', # ϖ / &piv; / &varpi;
'\u03d5': '&phiv;', # ϕ / &phiv; / &varphi; / &straightphi;
'\u2665': '&hearts;', # ♥ / &hearts; / &heartsuit; /
'\u2119': '&Popf;', # / &Popf; / &primes;
'\u227a': '&pr;', # ≺ / &pr; / &prec; / &Precedes;
'\u2ab7': '&prap;', # ⪷ / &prap; / &precapprox;
'\u227c': '&prcue;', # ≼ / &prcue; / &preccurlyeq; / &PrecedesSlantEqual;
'\u2aaf': '&pre;', # ⪯ / &pre; / &preceq; / &PrecedesEqual;
'\u227e': '&prsim;', # ≾ / &prsim; / &precsim; / &PrecedesTilde;
'\u2ab9': '&prnap;', # ⪹ / &prnap; / &precnapprox;
'\u2ab5': '&prnE;', # ⪵ / &prnE; / &precneqq;
'\u22e8': '&prnsim;', # ⋨ / &prnsim; / &precnsim;
'\u220f': '&prod;', # ∏ / &prod; / &Product;
'\u221d': '&prop;', # ∝ / &prop; / &vprop; / &propto; / &varpropto; / &Proportional;
'\u211a': '&Qopf;', # / &Qopf; / &rationals;
'\u21db': '&rAarr;', # ⇛ / &rAarr; / &Rrightarrow;
'\u27e9': '&rang;', # ⟩ / &rang; / &rangle; / &RightAngleBracket;
'\u21a0': '&Rarr;', # ↠ / &Rarr; / &twoheadrightarrow;
'\u21e5': '&rarrb;', # ⇥ / &rarrb; / &RightArrowBar;
'\u21a3': '&rarrtl;', # ↣ / &rarrtl; / &rightarrowtail;
'\u2309': '&rceil;', # ⌉ / &rceil; / &RightCeiling;
'\u219d': '&rarrw;', # ↝ / &rarrw; / &rightsquigarrow;
'\u03a9': '&ohm;', # Ω / &ohm; / &Omega;
'\u211c': '&Re;', # / &real; / &Re; / &Rfr; / &realpart;
'\u211b': '&Rscr;', # / &Rscr; / &realine;
'\u211d': '&Ropf;', # / &Ropf; / &reals;
'\u21c0': '&rharu;', # ⇀ / &rharu; / &RightVector; / &rightharpoonup;
'\u03f1': '&rhov;', # ϱ / &rhov; / &varrho;
'\u2192': '&rarr;', # → / &rarr; / &srarr; / &rightarrow; / &RightArrow; / &ShortRightArrow;
'\u21c4': '&rlarr;', # ⇄ / &rlarr; / &rightleftarrows; / &RightArrowLeftArrow;
'\u27e7': '&robrk;', # ⟧ / &robrk; / &RightDoubleBracket;
'\u230b': '&rfloor;', # ⌋ / &rfloor; / &RightFloor;
'\u21c9': '&rrarr;', # ⇉ / &rrarr; / &rightrightarrows;
'\u22a2': '&vdash;', # ⊢ / &vdash; / &RightTee;
'\u22cc': '&rthree;', # ⋌ / &rthree; / &rightthreetimes;
'\u22b3': '&vrtri;', # ⊳ / &vrtri; / &RightTriangle; / &vartriangleright;
'\u22b5': '&rtrie;', # ⊵ / &rtrie; / &trianglerighteq; / &RightTriangleEqual;
'\u21be': '&uharr;', # ↾ / &uharr; / &RightUpVector; / &upharpoonright;
'\u23b1': '&rmoust;', # ⎱ / &rmoust; / &rmoustache;
'\u201c': '&ldquo;', # “ / &ldquo; / &OpenCurlyDoubleQuote;
'\u2018': '&lsquo;', # / &lsquo; / &OpenCurlyQuote;
'\u21b1': '&rsh;', # ↱ / &rsh; / &Rsh;
'\u25b9': '&rtri;', # ▹ / &rtri; / &triangleright;
'\u227b': '&sc;', # ≻ / &sc; / &succ; / &Succeeds;
'\u2ab8': '&scap;', # ⪸ / &scap; / &succapprox;
'\u227d': '&sccue;', # ≽ / &sccue; / &succcurlyeq; / &SucceedsSlantEqual;
'\u2ab0': '&sce;', # ⪰ / &sce; / &succeq; / &SucceedsEqual;
'\u2aba': '&scnap;', # ⪺ / &scnap; / &succnapprox;
'\u2ab6': '&scnE;', # ⪶ / &scnE; / &succneqq;
'\u22e9': '&scnsim;', # ⋩ / &scnsim; / &succnsim;
'\u227f': '&scsim;', # ≿ / &scsim; / &succsim; / &SucceedsTilde;
'\u2929': '&tosa;', # ⤩ / &tosa; / &seswar;
'\u03c2': '&sigmaf;', # ς / &sigmaf; / &sigmav; / &varsigma;
'\u2243': '&sime;', # ≃ / &sime; / &simeq; / &TildeEqual;
'\u2323': '&smile;', # ⌣ / &smile; / &ssmile;
'\u2660': '&spades;', # ♠ / &spades; / &spadesuit; /
'\u2293': '&sqcap;', # ⊓ / &sqcap; / &SquareIntersection;
'\u2294': '&sqcup;', # ⊔ / &sqcup; / &SquareUnion;
'\u221a': '&Sqrt;', # √ / &Sqrt; / &radic;
'\u228f': '&sqsub;', # ⊏ / &sqsub; / &sqsubset; / &SquareSubset;
'\u2291': '&sqsube;', # ⊑ / &sqsube; / &sqsubseteq; / &SquareSubsetEqual;
'\u2290': '&sqsup;', # ⊐ / &sqsup; / &sqsupset; / &SquareSuperset;
'\u2292': '&sqsupe;', # ⊒ / &sqsupe; / &sqsupseteq; / &SquareSupersetEqual;
'\u25a1': '&squ;', # □ / &squ; / &Square; / &square;
'\u22c6': '&Star;', # ⋆ / &Star; / &sstarf;
'\u22d0': '&Sub;', # ⋐ / &Sub; / &Subset;
'\u2282': '&sub;', # ⊂ / &sub; / &subset;
'\u2ac5': '&subE;', # ⫅ / &subE; / &subseteqq;
'\u2acb': '&subnE;', # ⫋ / &subnE; / &subsetneqq;
'\u228a': '&subne;', # ⊊ / &subne; / &subsetneq;
'\u2286': '&sube;', # ⊆ / &sube; / &subseteq; / &SubsetEqual;
'\u2211': '&sum;', # ∑ / &sum; / &Sum;
'\u22d1': '&Sup;', # ⋑ / &Sup; / &Supset;
'\u2ac6': '&supE;', # ⫆ / &supE; / &supseteqq;
'\u2283': '&sup;', # ⊃ / &sup; / &supset; / &Superset;
'\u2287': '&supe;', # ⊇ / &supe; / &supseteq; / &SupersetEqual;
'\u2acc': '&supnE;', # ⫌ / &supnE; / &supsetneqq;
'\u228b': '&supne;', # ⊋ / &supne; / &supsetneq;
'\u223c': '&sim;', # / &sim; / &Tilde; / &thksim; / &thicksim;
'\u2245': '&cong;', # ≅ / &cong; / &TildeFullEqual;
'\u20db': '&tdot;', # ⃛ / &tdot; / &TripleDot;
'\u2234': '&there4;', # ∴ / &there4; / &Therefore; / &therefore;
'\u03d1': '&thetav;', # ϑ / &thetav; / &vartheta; / &thetasym;
CHAR_TRADE: '&trade;', # ™ / &trade; / &TRADE;
'\u25b5': '&utri;', # ▵ / &utri; / &triangle;
'\u225c': '&trie;', # ≜ / &trie; / &triangleq;
'\u21c5': '&udarr;', # ⇅ / &udarr; / &UpArrowDownArrow;
'\u296e': '&udhar;', # ⥮ / &udhar; / &UpEquilibrium;
'\u231c': '&ulcorn;', # ⌜ / &ulcorn; / &ulcorner;
'\u03d2': '&Upsi;', # ϒ / &Upsi; / &upsih;
'\u03c5': '&upsi;', # υ / &upsi; / &upsilon;
'\u228e': '&uplus;', # ⊎ / &uplus; / &UnionPlus;
'\u2195': '&varr;', # ↕ / &varr; / &updownarrow; / &UpDownArrow;
'\u2191': '&uarr;', # ↑ / &uarr; / &uparrow; / &UpArrow; / &ShortUpArrow;
'\u21c8': '&uuarr;', # ⇈ / &uuarr; / &upuparrows;
'\u231d': '&urcorn;', # ⌝ / &urcorn; / &urcorner;
'\u2016': '&Vert;', # ‖ / &Vert; / &Verbar;
'\u2228': '&or;', # / &or; / &vee;
CHAR_THIN_SP: '&thinsp;', # / &thinsp; / &ThinSpace;
'\u2240': '&wr;', # ≀ / &wr; / &wreath; / &VerticalTilde;
'\u2128': '&Zfr;', # / &Zfr; / &zeetrf;
'\u2118': '&wp;', # ℘ / &wp; / &weierp;
}
# === Динамическая генерация карт преобразования ===
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',
}

View File

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

View File

@@ -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:
"""
Оборачивает символы висячей пунктуации в специальные теги <span> с классами.
"""
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

358
etpgrf_site/etpgrf/hyphenation.py Executable file
View File

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

View File

@@ -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. Паттерн для отрицательных чисел.
# Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел
# заменяет обычный). Это предотвращает "отлипание" знака от числа при переносе строки.
# (?<!\d) - негативный просмотр назад, чтобы правило не срабатывало для бинарного минуса
# в выражениях типа "10 - 5".
self._negative_number_pattern = regex.compile(r'(?<!\d)\s+-(\d+)')
# 4. Паттерны для обработки инициалов и акронимов.
# \p{Lu} - любая заглавная буква в Unicode.
# Правила для случаев, когда пробел УЖЕ ЕСТЬ (заменяем на неразрывный)
# Используем ` +` (пробел) вместо `\s+`, чтобы не заменять уже вставленные тонкие пробелы.
self._initial_to_initial_ws_pattern = regex.compile(r'(\p{Lu}\.) +(?=\p{Lu}\.)')
self._initial_to_surname_ws_pattern = regex.compile(r'(\p{Lu}\.) +(?=\p{Lu}\p{L}{1,})')
self._surname_to_initial_ws_pattern = regex.compile(r'(\p{Lu}\p{L}{2,}) +(?=\p{Lu}\.)')
# Правила для случаев, когда пробела НЕТ (вставляем тонкий пробел)
self._initial_to_initial_ns_pattern = regex.compile(r'(\p{Lu}\.)(?=\p{Lu}\.)')
self._initial_to_surname_ns_pattern = regex.compile(r'(\p{Lu}\.)(?=\p{Lu}\p{L}{1,})')
# Вся логика обработки финальных сокращений перенесена в метод process для надежной итеративной обработки
# 6. Паттерн, описывающий "число" - арабское (включая десятичные дроби через запятую или точку) ИЛИ римское.
# Для римских цифр используется \b, чтобы не спутать 'I' с частью слова.
self._NUMBER_PATTERN = r'(?:\d[\d.,]*|\b[IVXLCDM]+\b)'
# 7. Паттерны для единиц измерения (простые и составные).
self._post_units_pattern = None
self._pre_units_pattern = None
self._complex_unit_pattern = None
self._math_unit_pattern = None
if self.process_units:
all_post_units = list(DEFAULT_POST_UNITS)
# Добавляем кастомные единицы, если они есть
if isinstance(self.process_units, str):
all_post_units.extend(self.process_units.split())
elif isinstance(self.process_units, (list, tuple, set)):
all_post_units.extend(self.process_units)
# Единая проверка безопасности: удаляем все единицы, содержащие временный разделитель.
safe_units = [unit for unit in all_post_units if CHAR_UNIT_SEPARATOR not in unit]
if len(safe_units) != len(all_post_units):
logger.warning(f"One or more units contained the reserved separator ('{CHAR_UNIT_SEPARATOR}') and were ignored.")
# Создаем паттерны только из безопасных единиц
if safe_units:
sorted_units = sorted(safe_units, key=len, reverse=True)
units_pattern_part_full = '|'.join(map(regex.escape, sorted_units))
units_pattern_part_clean = '|'.join(map(regex.escape, [u.replace('.', '') for u in sorted_units]))
# Простые единицы: число + единица
self._post_units_pattern = regex.compile(rf'({self._NUMBER_PATTERN})\s+({units_pattern_part_full})(?!\w)')
# Составные единицы: ищет пару "единица." + "единица"
self._complex_unit_pattern = regex.compile(r'\b(' + units_pattern_part_clean + r')\.(\s*)('
+ units_pattern_part_clean + r')(?!\w)')
# Математические операции между единицами
math_ops_pattern = '|'.join(map(regex.escape, UNIT_MATH_OPERATORS))
self._math_unit_pattern = regex.compile(
r'\b(' + units_pattern_part_clean + r')\s*(' + math_ops_pattern + r')\s*('
+ units_pattern_part_clean + r')(?!\w)')
# Паттерн для пред-позиционных единиц
self._pre_units_pattern = regex.compile(
r'(?<![\p{L}\p{N}])(' + '|'.join(map(regex.escape, DEFAULT_PRE_UNITS)) + rf')\s+({self._NUMBER_PATTERN})')
logger.debug(f"LayoutProcessor `__init__`. "
f"Langs: {self.langs}, "
f"Main lang: {self.main_lang}, "
f"Process initials and acronyms: {self.process_initials_and_acronyms}, "
f"Process units: {bool(self.process_units)}")
def _replace_dash_spacing(self, match: regex.Match) -> 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 по умолчанию экранирует амперсанды (& -> &amp;), которые мы сгенерировали
# в _process_text_node. Возвращаем их обратно.
return processed_html.replace('&amp;', '&')
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)

View File

@@ -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е', 'с', 'онъ', 'тъ',
])
# Постпозитивные частицы, которые приклеиваются к ПРЕДЫДУЩЕМУ слову
_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. Обработка слов, ПОСЛЕ которых нужен неразрывный пробел ("в дом" -> "в&nbsp;дом")
if self._pre_pattern:
processed_text = self._pre_pattern.sub(r"\g<1>" + CHAR_NBSP, processed_text)
# 2. Обработка частиц, ПЕРЕД которыми нужен неразрывный пробел ("сказал бы" -> "сказал&nbsp;бы")
if self._post_pattern:
# \g<1> - это пробел, \g<2> - это частица
processed_text = self._post_pattern.sub(CHAR_NBSP + r"\g<2>", processed_text)
return processed_text