tmp: код etpgrf перенесен внутрь проекта...
This commit is contained in:
22
etpgrf_site/etpgrf/__init__.py
Normal file
22
etpgrf_site/etpgrf/__init__.py
Normal 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
|
||||||
58
etpgrf_site/etpgrf/codec.py
Normal file
58
etpgrf_site/etpgrf/codec.py
Normal 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
|
||||||
140
etpgrf_site/etpgrf/comutil.py
Normal file
140
etpgrf_site/etpgrf/comutil.py
Normal 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
|
||||||
722
etpgrf_site/etpgrf/config.py
Normal file
722
etpgrf_site/etpgrf/config.py
Normal 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' # Неразрывный пробел ( )
|
||||||
|
CHAR_SHY = '\u00ad' # Мягкий перенос (­)
|
||||||
|
CHAR_THIN_SP = '\u2009' # Тонкий пробел (шпация,  )
|
||||||
|
CHAR_NDASH = '\u2013' # Cреднее тире (– / –)
|
||||||
|
CHAR_MDASH = '\u2014' # Длинное тире (— / —)
|
||||||
|
CHAR_HELLIP = '\u2026' # Многоточие (… / …)
|
||||||
|
CHAR_RU_QUOT1_OPEN = '«' # Русские кавычки открывающие (« / «)
|
||||||
|
CHAR_RU_QUOT1_CLOSE = '»'
|
||||||
|
CHAR_RU_QUOT2_OPEN = '„'
|
||||||
|
CHAR_RU_QUOT2_CLOSE = '“'
|
||||||
|
CHAR_EN_QUOT1_OPEN = '“'
|
||||||
|
CHAR_EN_QUOT1_CLOSE = '”'
|
||||||
|
CHAR_EN_QUOT2_OPEN = '‘'
|
||||||
|
CHAR_EN_QUOT2_CLOSE = '’'
|
||||||
|
CHAR_COPY = '\u00a9' # Символ авторского права / © / ©
|
||||||
|
CHAR_REG = '\u00ae' # Зарегистрированная торговая марка / ® / ®
|
||||||
|
CHAR_COPYP = '\u2117' # Знак звуковой записи / ℗ / ©p;
|
||||||
|
CHAR_TRADE = '\u2122' # Знак торговой марки / ™ / ™
|
||||||
|
CHAR_ARROW_LR_DOUBLE = '\u21d4' # Двойная двунаправленная стрелка / ⇔ / ⇔
|
||||||
|
CHAR_ARROW_L_DOUBLE = '\u21d0' # Двойная стрелка влево / ⇐ / ⇐
|
||||||
|
CHAR_ARROW_R_DOUBLE = '\u21d2' # Двойная стрелка вправо / ⇒ / ⇒
|
||||||
|
CHAR_AP = '\u2248' # Приблизительно равно / ≈ / ≈
|
||||||
|
CHAR_ARROW_L = '\u27f5' # Стрелка влево / ← / ←
|
||||||
|
CHAR_ARROW_R = '\u27f6' # Стрелка вправо / → / →
|
||||||
|
CHAR_ARROW_LR = '\u27f7' # Длинная двунаправленная стрелка ↔ / ↔
|
||||||
|
CHAR_ARROW_L_LONG_DOUBLE = '\u27f8' # Длинная двойная стрелка влево
|
||||||
|
CHAR_ARROW_R_LONG_DOUBLE = '\u27f9' # Длинная двойная стрелка вправо
|
||||||
|
CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двунаправленная стрелка
|
||||||
|
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / ·
|
||||||
|
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
|
||||||
|
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
|
||||||
|
|
||||||
|
|
||||||
|
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
|
||||||
|
# Для простых замен "строка -> символ" используем список кортежей.
|
||||||
|
# Порядок важен: более длинные последовательности должны идти раньше более коротких, которые
|
||||||
|
# могут быть их частью (например, '<---' до '---', а та, в свою очередь, до '--').
|
||||||
|
STR_TO_SYMBOL_REPLACEMENTS = [
|
||||||
|
# 5-символьные последовательности
|
||||||
|
('<===>', CHAR_ARROW_LR_LONG_DOUBLE), # Длинная двойная двунаправленная стрелка
|
||||||
|
# 4-символьные последовательности
|
||||||
|
('<===', CHAR_ARROW_L_LONG_DOUBLE), # Длинная двойная стрелка влево
|
||||||
|
('===>', CHAR_ARROW_R_LONG_DOUBLE), # Длинная двойная стрелка вправо
|
||||||
|
('<==>', CHAR_ARROW_LR_DOUBLE), # Двойная двунаправленная стрелка
|
||||||
|
('(tm)', CHAR_TRADE), ('(TM)', CHAR_TRADE), # Знак торговой марки (нижний и верхний регистр)
|
||||||
|
('<-->', CHAR_ARROW_LR), # Длинная двунаправленная стрелка
|
||||||
|
# 3-символьные последовательности
|
||||||
|
('<--', CHAR_ARROW_L), # Стрелка влево
|
||||||
|
('-->', CHAR_ARROW_R), # Стрелка вправо
|
||||||
|
('==>', CHAR_ARROW_R_DOUBLE), # Двойная стрелка вправо
|
||||||
|
('<==', CHAR_ARROW_L_DOUBLE), # Двойная стрелка влево
|
||||||
|
('---', CHAR_MDASH), # Длинное тире
|
||||||
|
('...', CHAR_HELLIP), # Многоточие
|
||||||
|
('(c)', CHAR_COPY), ('(C)', CHAR_COPY), # Знак авторского права (нижний и верхний регистр)
|
||||||
|
('(r)', CHAR_REG), ('(R)', CHAR_REG), # Знак зарегистрированной торговой марки (нижний и верхний регистр)
|
||||||
|
('(p)', CHAR_COPYP), ('(P)', CHAR_COPYP), # Знак права на звукозапись (нижний и верхний регистр)
|
||||||
|
# 2-символьные последовательности
|
||||||
|
('--', CHAR_NDASH), # Среднее тире (дефисные соединения и диапазоны)
|
||||||
|
('~=', CHAR_AP), # Приблизительно равно (≈)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# === КОНСТАНТЫ ДЛЯ КОДИРОВАНИЯ HTML-МНЕМНОИКОВ ===
|
||||||
|
# --- ЧЕРНЫЙ СПИСОК: Символы, которые НИКОГДА не нужно кодировать в мнемоники ---
|
||||||
|
NEVER_ENCODE_CHARS = (frozenset(['!', '#', '%', '(', ')', '*', ',', '.', '/', ':', ';', '=', '?', '@',
|
||||||
|
'[', '\\', ']', '^', '_', '`', '{', '|', '}', '~', '\n', '\t', '\r'])
|
||||||
|
| RU_ALPHABET_FULL | EN_ALPHABET_FULL)
|
||||||
|
|
||||||
|
# 2. БЕЛЫЙ СПИСОК (ДЛЯ БЕЗОПАСНОСТИ):
|
||||||
|
# Символы, которые ВСЕГДА должны превращаться в мнемоники в "безопасных" режимах вывода. Сюда добавлены символы,
|
||||||
|
# которые не видны, на глаз и не отличимы друг от друга в обычном тексте, или очень специфичные
|
||||||
|
SAFE_MODE_CHARS_TO_MNEMONIC = frozenset([
|
||||||
|
'<', '>', '&', '"', '\'',
|
||||||
|
CHAR_SHY, # Мягкий перенос (Soft Hyphen) -- ­
|
||||||
|
CHAR_NBSP, # Неразрывный пробел (Non-Breaking Space) --
|
||||||
|
'\u2002', # Полужирный пробел (En Space) --  
|
||||||
|
'\u2003', # Широкий пробел (Em Space) --  
|
||||||
|
'\u2007', # Цифровой пробел --  
|
||||||
|
'\u2008', # Пунктуационный пробел --  
|
||||||
|
CHAR_THIN_SP, # Межсимвольный пробел, тонкий пробел, шпация --  '
|
||||||
|
'\u200A', # Толщина волоса (Hair Space) --  
|
||||||
|
'\u200B', # Негативный пробел (Negative Space) -- ​
|
||||||
|
'\u200C', # Нулевая ширина (без объединения) (Zero Width Non-Joiner) -- ‍
|
||||||
|
'\u200D', # Нулевая ширина (с объединением) (Zero Width Joiner) -- ‌
|
||||||
|
'\u200E', # Изменить направление текста на слева-направо (Left-to-Right Mark /LRE) -- ‎
|
||||||
|
'\u200F', # Изменить направление текста направо-налево (Right-to-Left Mark /RLM) -- ‏
|
||||||
|
'\u2010', # Дефис (Hyphen) -- ‐
|
||||||
|
'\u205F', # Средний пробел (Medium Mathematical Space) --  
|
||||||
|
'\u2060', # ⁠
|
||||||
|
'\u2062', # ⁢ -- для семантической разметки математических выражений
|
||||||
|
'\u2063', # ⁣ -- для семантической разметки математических выражений
|
||||||
|
])
|
||||||
|
|
||||||
|
# 3. СПИСОК ДЛЯ ЧИСЛОВОГО КОДИРОВАНИЯ: Символы без стандартного имени.
|
||||||
|
ALWAYS_ENCODE_TO_NUMERIC_CHARS = frozenset([
|
||||||
|
'\u058F', # Знак армянского драма (֏)
|
||||||
|
'\u20B4', # Знак украинской гривны (₴)
|
||||||
|
'\u20B8', # Знак казахстанского тенге (₸)
|
||||||
|
'\u20B9', # Знак индийской рупии (₹)
|
||||||
|
'\u20BA', # Знак турецкой лиры (₺)
|
||||||
|
'\u20BB', # Знак итальянской лиры (₻)
|
||||||
|
'\u20BC', # Знак азербайджанского маната
|
||||||
|
'\u20BD', # Знак русского рубля (₽)
|
||||||
|
'\u20BE', # Знак грузинский лари (₾)
|
||||||
|
'\u20BF', # Знак биткоина (₿)
|
||||||
|
])
|
||||||
|
|
||||||
|
# 4. СЛОВАРЬ ПРИОРИТЕТОВ: Кастомные и/или предпочитаемые мнемоники.
|
||||||
|
# Некоторые utf-символы имеют несколько мнемоник, а значит для таких символов преобразование
|
||||||
|
# в из utf во html-мнемоники может иметь несколько вариантов. Словарь приоритетов задает предпочтительное
|
||||||
|
# преобразование. Эти правила применяются в последнюю очередь и имеют наивысший приоритет,
|
||||||
|
# гарантируя предсказуемый результат для символов с несколькими именами.
|
||||||
|
#
|
||||||
|
# Также можно использовать для создания исключений из "черного списка" NEVER_ENCODE_CHARS.
|
||||||
|
CUSTOM_ENCODE_MAP = {
|
||||||
|
# '\u2010': '‐', # Для \u2010 всегда предпочитаем ‐, а не ‐
|
||||||
|
# # Исключения для букв, которые есть в алфавитах, но должны кодироваться (для обеспечения консистентности):
|
||||||
|
# # 'Æ': 'Æ',
|
||||||
|
# # 'Œ': 'Œ',
|
||||||
|
# # 'æ': 'æ',
|
||||||
|
# # 'œ': 'œ',
|
||||||
|
# '\u002a': '*', # * / * / *
|
||||||
|
# '\u005b': '[', # [ / [ / [
|
||||||
|
# '\u005d': ']', # ] / ] / ]
|
||||||
|
# '\u005f': '_', # _ / _ / _
|
||||||
|
# '\u007b': '{', # { / { / {
|
||||||
|
# '\u007d': '}', # } / } / }
|
||||||
|
# '\u007c': '|', # | / | / | / |
|
||||||
|
CHAR_NBSP: ' ', # / /  
|
||||||
|
CHAR_REG: '®', # ® / ® / ® / ®
|
||||||
|
CHAR_COPY: '©', # © / © / ©
|
||||||
|
'\u0022': '"', # " / " / "
|
||||||
|
'\u0026': '&', # & / & / &
|
||||||
|
'\u003e': '>', # > / > / >
|
||||||
|
'\u003c': '<', # < / < / <
|
||||||
|
CHAR_MIDDOT: '·', # · / · / · / ·
|
||||||
|
'\u0060': '`', # ` / ` / `
|
||||||
|
'\u00a8': '¨', # ¨ / ¨ / ¨ / ¨ / ¨
|
||||||
|
'\u00b1': '±', # ± / ± / ±
|
||||||
|
'\u00bd': '½', # ½ / ½ / ½
|
||||||
|
'\u00af': '¯', # ¯ / ¯ / ¯
|
||||||
|
'\u201a': '‚', # ‚ / ‚ / ‚
|
||||||
|
'\u223e': '∾', # ∾ / ∾ / ∾
|
||||||
|
'\u2207': '∇', # ∇ / ∇ / ∇
|
||||||
|
'\u2061': '⁡', # / ⁡ / ⁡
|
||||||
|
'\u2221': '∡', # ∡ / ∡ / ∡
|
||||||
|
CHAR_AP: '≈', # ≈ / ≈ / ≈ / ≈ / ≈ / ≈
|
||||||
|
'\u224a': '≊', # ≊ / ≊ / ≊
|
||||||
|
'\u2254': '≔', # ≔ / ≔ / ≔ / ≔
|
||||||
|
'\u224d': '≍', # ≍ / ≍ / ≍
|
||||||
|
'\u2233': '∳', # ∳ / ∳ / ∳
|
||||||
|
'\u224c': '≌', # ≌ / ≌ / ≌
|
||||||
|
'\u03f6': '϶', # ϶ / ϶ / ϶
|
||||||
|
'\u2035': '‵', # ‵ / ‵ / ‵
|
||||||
|
'\u223d': '∽', # ∽ / ∽ / ∽
|
||||||
|
'\u22cd': '⋍', # ⋍ / ⋍ / ⋍
|
||||||
|
'\u2216': '∖', # ∖ / ∖ / ∖ / ∖ / ∖ / ∖
|
||||||
|
'\u2306': '⌆', # ⌆ / ⌆ / ⌆
|
||||||
|
'\u2305': '⌅', # ⌅ / ⌅ / ⌅
|
||||||
|
'\u23b5': '⎵', # ⎵ / ⎵ / ⎵
|
||||||
|
'\u2235': '∵', # ∵ / ∵ / ∵ / ∵
|
||||||
|
'\u212c': 'ℬ', # ℬ / ℬ / ℬ / ℬ
|
||||||
|
'\u2264': '≤', # ≤ / ≤ / ≤
|
||||||
|
'\u226c': '≬', # ≬ / ≬ / ≬
|
||||||
|
'\u22c2': '⋂', # ⋂ / ⋂ / ⋂ / ⋂
|
||||||
|
'\u25ef': '◯', # ◯ / ◯ / ◯
|
||||||
|
'\u22c3': '⋃', # ⋃ / ⋃ / ⋃ / ⋃
|
||||||
|
'\u2a00': '⨀', # ⨀ / ⨀ / ⨀
|
||||||
|
'\u2a01': '⨁', # ⨁ / ⨁ / ⨁
|
||||||
|
'\u2a02': '⨂', # ⨂ / ⨂ / ⨂
|
||||||
|
'\u2a06': '⨆', # ⨆ / ⨆ / ⨆
|
||||||
|
'\u2605': '★', # ★ / ★ / ★
|
||||||
|
'\u25bd': '▽', # ▽ / ▽ / ▽
|
||||||
|
'\u25b3': '△', # △ / △ / △
|
||||||
|
'\u2a04': '⨄', # ⨄ / ⨄ / ⨄
|
||||||
|
'\u22c1': '⋁', # ⋁ / ⋁ / ⋁ / ⋁
|
||||||
|
'\u22c0': '⋀', # ⋀ / ⋀ / ⋀ / $bigwedge;
|
||||||
|
'\u2227': '∧', # ∧ / ∧ / ∧
|
||||||
|
'\u290d': '⤍', # ⤍ / ⤍ / ⤍
|
||||||
|
'\u29eb': '⧫', # ⧫ / ⧫ / ⧫
|
||||||
|
'\u25ca': '◊', # ◊ / ◊ / &lozenge
|
||||||
|
'\u25aa': '▪', # ▪ / ▪ / ▪ / ▪ / ▪
|
||||||
|
'\u25b4': '▴', # ▴ / ▴ / ▴
|
||||||
|
'\u25be': '▾', # ▾ / ▾ / ▾
|
||||||
|
'\u25c2': '◂', # ◂ / ◂ / ◂
|
||||||
|
'\u25b8': '▸', # ▸ / ▸ / ▸
|
||||||
|
'\u22a5': '⊥', # ⊥ / ⊥ / ⊥ / ⊥ / ⊥
|
||||||
|
'\u2500': '─', # ─ / ─ / ─
|
||||||
|
'\u229f': '⊟', # ⊟ / ⊟ / ⊟
|
||||||
|
'\u229e': '⊞', # ⊞ / ⊞ / ⊞
|
||||||
|
'\u22a0': '⊠', # ⊠ / ⊠ / ⊠
|
||||||
|
'\u02d8': '˘', # ˘ / ˘ / ˘
|
||||||
|
'\u224e': '≎', # ≎ / ≎ / ≎ / ≎
|
||||||
|
'\u224f': '≏', # ≏ / ≏ / ≏ / ≏
|
||||||
|
'\u2145': 'ⅅ', # ⅅ / ⅅ / ⅅ
|
||||||
|
'\u02c7': 'ˇ', # ˇ / ˇ / ˇ
|
||||||
|
'\u212d': 'ℭ', # ℭ / ℭ / ℭ
|
||||||
|
'\u2713': '✓', # ✓ / ✓ / ✓
|
||||||
|
'\u2257': '≗', # ≗ / ≗ / ≗
|
||||||
|
'\u21ba': '↺', # ↺ / ↺ / ↺
|
||||||
|
'\u21bb': '↻', # ↻ / ↻ / ↻
|
||||||
|
'\u229b': '⊛', # ⊛ / ⊛ / ⊛
|
||||||
|
'\u229a': '⊚', # ⊚ / ⊚ / ⊚
|
||||||
|
'\u229d': '⊝', # ⊝ / ⊝ / ⊝
|
||||||
|
'\u2299': '⊙', # ⊙ / ⊙ / ⊙
|
||||||
|
'\u2200': '∀', # ∀ / ∀ / ∀
|
||||||
|
'\u24c8': 'Ⓢ', # Ⓢ / Ⓢ / Ⓢ
|
||||||
|
'\u2296': '⊖', # ⊖ / ⊖ / ⊖
|
||||||
|
'\u2232': '∲', # ∲ / ∲ / ∲
|
||||||
|
'\u201d': '”', # ” / ” / ” / ”
|
||||||
|
'\u2019': '’', # ’ / ’ / ’ / ’
|
||||||
|
'\u2237': '∷', # ∷ / ∷ / ∷
|
||||||
|
'\u2201': '∁', # ∁ / ∁ / ∁
|
||||||
|
'\u2218': '∘', # ∘ / ∘ / ∘
|
||||||
|
'\u2102': 'ℂ', # ℂ / ℂ / ℂ
|
||||||
|
'\u222f': '∯', # ∯ / ∯ / ∯
|
||||||
|
'\u222e': '∮', # ∮ / ∮ / ∮ / ∮
|
||||||
|
'\u2210': '∐', # ∐ / ∐ / ∐
|
||||||
|
'\u22de': '⋞', # ⋞ / ⋞ / ⋞
|
||||||
|
'\u22df': '⋟', # ⋟ / ⋟ / ⋟
|
||||||
|
'\u21b6': '↶', # ↶ / ↶ / ↶
|
||||||
|
'\u21b7': '↷', # ↷ / ↷ / ↷
|
||||||
|
'\u22ce': '⋎', # ⋎ / ⋎ / ⋎
|
||||||
|
'\u22cf': '⋏', # ⋏ / ⋏ / ⋏
|
||||||
|
'\u2010': '‐', # ‐ / ‐ / ‐
|
||||||
|
'\u2ae4': '⫤', # ⫤ / ⫤ / ⫤
|
||||||
|
'\u22a3': '⊣', # ⊣ / ⊣ / ⊣
|
||||||
|
'\u290f': '⤏', # ⤏ / ⤏ / ⤏
|
||||||
|
'\u02dd': '˝', # ˝ / ˝ / ˝
|
||||||
|
'\u2146': 'ⅆ', # ⅆ / ⅆ / ⅆ
|
||||||
|
'\u21ca': '⇊', # ⇊ / ⇊ / ⇊
|
||||||
|
'\u2a77': '⩷', # ⩷ / ⩷ / ⩷
|
||||||
|
'\u21c3': '⇃', # ⇃ / ⇃ / ⇃ / ⇃
|
||||||
|
'\u21c2': '⇂', # ⇂ / ⇂ / ⇂ / ⇂
|
||||||
|
'\u02d9': '˙', # ˙ / ˙ / ˙
|
||||||
|
'\u222b': '∫', # ∫ / ∫ / ∫
|
||||||
|
'\u22c4': '⋄', # ⋄ / ⋄ / ⋄ / ⋄
|
||||||
|
'\u03b5': 'ε', # ε / ε / ε
|
||||||
|
'\u03dd': 'ϝ', # ϝ / ϝ / ϝ
|
||||||
|
'\u22c7': '⋇', # ⋇ / ⋇ / ⋇
|
||||||
|
'\u231e': '⌞', # ⌞ / ⌞ / ⌞
|
||||||
|
'\u2250': '≐', # ≐ / ≐ / ≐ / ≐
|
||||||
|
'\u2251': '≑', # ≑ / ≑ / ≑
|
||||||
|
'\u2238': '∸', # ∸ / ∸ / ∸
|
||||||
|
'\u2214': '∔', # ∔ / ∔ / ∔
|
||||||
|
'\u22a1': '⊡', # ⊡ / ⊡ / ⊡
|
||||||
|
'\u21d3': '⇓', # ⇓ / ⇓ / ⇓ / ⇓
|
||||||
|
CHAR_ARROW_R_DOUBLE: '⇒', # ⇒ / ⇒ / ⇒ / ⇒ / ⇒
|
||||||
|
CHAR_ARROW_L_DOUBLE: '⇐', # ⇐ / ⇐ / ⇐ / ⇐
|
||||||
|
CHAR_ARROW_LR_DOUBLE: '⇔', # ⇔ / ⇔ / ⇔ / ⇔ / ⇔
|
||||||
|
CHAR_ARROW_L_LONG_DOUBLE: '⟸', # ⟸ / ⟸ / ⟸ / ⟸
|
||||||
|
CHAR_ARROW_R_LONG_DOUBLE: '⟹', # ⟹ / ⟹ / ⟹ / ⟹
|
||||||
|
CHAR_ARROW_LR_LONG_DOUBLE: '⟺', # ⟺ / ⟺ / ⟺ / ⟺
|
||||||
|
'\u22a8': '⊨', # ⊨ / ⊨ / ⊨
|
||||||
|
'\u21d1': '⇑', # ⇑ / ⇑ / ⇑ / ⇑
|
||||||
|
'\u2202': '∂', # ∂ / ∂ / ∂
|
||||||
|
'\u21d5': '⇕', # ⇕ / ⇕ / ⇕ / ⇕
|
||||||
|
'\u2225': '∥', # ∥ / ∥ / ∥ / ∥ / ∥ / ∥
|
||||||
|
'\u2193': '↓', # ↓ / ↓ / ↓ / ↓ / ↓
|
||||||
|
'\u21f5': '⇵', # ⇵ / ⇵ / ⇵
|
||||||
|
'\u21bd': '↽', # ↽ / ↽ /↽ / ↽
|
||||||
|
'\u21c1': '⇁', # ⇁ / ⇁ / ⇁ / ⇁
|
||||||
|
'\u22a4': '⊤', # ⊤ / ⊤ / ⊤
|
||||||
|
'\u21a7': '↧', # ↧ / ↧ / ↧
|
||||||
|
'\u2910': '⤐', # ⤐ / ⤐ / ⤐
|
||||||
|
'\u231f': '⌟', # ⌟ / ⌟ / ⌟
|
||||||
|
'\u25bf': '▿', # ▿ / ▿ / ▿
|
||||||
|
'\u296f': '⥯', # ⥯ / ⥯ / ⥯
|
||||||
|
'\u2256': '≖', # ≖ / ≖ / ≖
|
||||||
|
'\u2255': '≕', # ≕ / ≕ / ≕
|
||||||
|
'\u2147': 'ⅇ', # ⅇ / ⅇ / ⅇ / ⅇ
|
||||||
|
'\u2252': '≒', # ≒ / ≒ / ≒
|
||||||
|
'\u2a96': '⪖', # ⪖ / ⪖ / ⪖
|
||||||
|
'\u2208': '∈', # ∈ / ∈ / ∈ / ∈ / ∈
|
||||||
|
'\u2a95': '⪕', # ⪕ / ⪕ / ⪕
|
||||||
|
'\u2205': '∅', # ∅ / ∅ / ∅ / ∅ / ∅
|
||||||
|
'\u03f5': 'ϵ', # ϵ / ϵ / ϵ / ϵ
|
||||||
|
'\u2242': '≂', # ≂ / ≂ / ≂ / ≂
|
||||||
|
'\u225f': '≟', # ≟ / ≟ / ≟
|
||||||
|
'\u21cc': '⇌', # ⇌ / ⇌ / ⇌ / ⇌
|
||||||
|
'\u2253': '≓', # ≓ / ≓ / ≓
|
||||||
|
'\u2130': 'ℰ', # ℰ / ℰ / ℰ
|
||||||
|
'\u22d4': '⋔', # ⋔ / ⋔ / ⋔
|
||||||
|
'\u2131': 'ℱ', # ℱ / ℱ / ℱ
|
||||||
|
'\u2322': '⌢', # ⌢ / ⌢ / ⌢
|
||||||
|
'\u2a86': '⪆', # ⪆ / ⪆ / ⪆
|
||||||
|
'\u2267': '≧', # ≧ / ≧ / ≧ / ≧
|
||||||
|
'\u2a8c': '⪌', # ⪌ / ⪌ / ⪌
|
||||||
|
'\u22db': '⋛', # ⋛ / ⋛ / ⋛ / ⋛
|
||||||
|
'\u2265': '≥', # ≥ / ≥ / ≥ / ≥
|
||||||
|
'\u2a7e': '⩾', # ⩾ / ⩾ / ⩾ / ⩾
|
||||||
|
'\u22d9': '⋙', # ⋙ / ⋙ / ⋙
|
||||||
|
'\u226b': '≫', # ≫ / &gg ;/ ≫ / ≫
|
||||||
|
'\u2277': '≷', # ≷ / ≷ / ≷ / ≷
|
||||||
|
'\u2a8a': '⪊', # ⪊ / ⪊ / ⪊
|
||||||
|
'\u2269': '≩', # ≩ / ≩ / ≩
|
||||||
|
'\u2260': '≠', # ≠ / ≠ / ≠
|
||||||
|
'\u2a88': '⪈', # ⪈ / ⪈ / ⪈
|
||||||
|
'\u2273': '≳', # ≳ / ≳ / ≳ / ≳
|
||||||
|
'\u22d7': '⋗', # ⋗ / ⋗ / ⋗
|
||||||
|
'\u200a': ' ', # /   /  
|
||||||
|
'\u210b': 'ℋ', # ℋ / ℋ / ℋ / ℋ
|
||||||
|
'\u21ad': '↭', # ↭ / ↭ / ↭
|
||||||
|
'\u210f': 'ℏ', # ℏ / ℏ / ℏ / ℏ / ℏ
|
||||||
|
'\u210c': 'ℌ', # ℌ / ℌ / ℌ
|
||||||
|
'\u2925': '⤥', # ⤥ / ⤥ / ⤥
|
||||||
|
'\u2926': '⤦', # ⤦ / ⤦ / ⤦
|
||||||
|
'\u21a9': '↩', # ↩ / ↩ / ↩
|
||||||
|
'\u21aa': '↪', # ↪ / ↪ / ↪
|
||||||
|
'\u210d': 'ℍ', # ℍ / ℍ / ℍ
|
||||||
|
'\u2063': '⁣', # / ⁣ / ⁣
|
||||||
|
'\u2111': 'ℑ', # ℑ / ℑ / ℑ / ℑ / ℑ
|
||||||
|
'\u2148': 'ⅈ', # ⅈ / ⅈ / ⅈ
|
||||||
|
'\u2a0c': '⨌', # ⨌ / ⨌ / ⨌
|
||||||
|
'\u222d': '∭', # ∭ / ∭ / ∭
|
||||||
|
'\u2110': 'ℐ', # ℐ / ℐ / ℐ
|
||||||
|
'\u0131': 'ı', # ı / ı / ı
|
||||||
|
'\u22ba': '⊺', # ⊺ / ⊺ / ⊺
|
||||||
|
'\u2124': 'ℤ', # ℤ / ℤ / ℤ
|
||||||
|
'\u2a3c': '⨼', # ⨼ / ⨼ / ⨼
|
||||||
|
'\u2062': '⁢', # / ⁢ / ⁢
|
||||||
|
'\u03f0': 'ϰ', # ϰ / ϰ / ϰ
|
||||||
|
'\u21da': '⇚', # ⇚ / ⇚ / ⇚
|
||||||
|
'\u2112': 'ℒ', # ℒ / ℒ / ℒ / ℒ
|
||||||
|
'\u27e8': '⟨', # ⟨ / ⟨ / ⟨ / ⟨
|
||||||
|
'\u2a85': '⪅', # ⪅ / ⪅ / ⪅
|
||||||
|
'\u219e': '↞', # ↞ / ↞ / ↞
|
||||||
|
'\u21e4': '⇤', # ⇤ / ⇤ / ⇤
|
||||||
|
'\u21ab': '↫', # ↫ / ↫ / ↫
|
||||||
|
'\u21a2': '↢', # ↢ / ↢ / ↢
|
||||||
|
'\u2266': '≦', # ≦ / ≦ / ≦ / ≦
|
||||||
|
'\u2190': '←', # ← / ← / ← / ← / ← / ←
|
||||||
|
'\u21c6': '⇆', # ⇆ / ⇆ / ⇆ / ⇆
|
||||||
|
'\u27e6': '⟦', # ⟦ / ⟦ / ⟦
|
||||||
|
'\u21bc': '↼', # ↼ / ↼ / ↼ / ↼
|
||||||
|
'\u21c7': '⇇', # ⇇ / ⇇ / ⇇
|
||||||
|
'\u2194': '↔', # ↔ / ↔ / ↔ / ↔
|
||||||
|
'\u21cb': '⇋', # ⇋ / ⇋ / ⇋ / ⇋
|
||||||
|
'\u21a4': '↤', # ↤ / ↤ / ↤
|
||||||
|
'\u22cb': '⋋', # ⋋ / ⋋ / ⋋
|
||||||
|
'\u22b2': '⊲', # ⊲ / ⊲ / ⊲ / ⊲
|
||||||
|
'\u22b4': '⊴', # ⊴ / ⊴ / ⊴ / ⊴
|
||||||
|
'\u21bf': '↿', # ↿ / ↿ / ↿ / ↿
|
||||||
|
'\u2308': '⌈', # ⌈ / ⌈ / ⌈
|
||||||
|
'\u230a': '⌊', # ⌊ / ⌊ / ⌊
|
||||||
|
'\u2a8b': '⪋', # ⪋ / ⪋ / ⪋
|
||||||
|
'\u22da': '⋚', # ⋚ / ⋚ / ⋚ / ⋚
|
||||||
|
'\u2a7d': '⩽', # ⩽ / ⩽ / ⩽ / ⩽
|
||||||
|
'\u22d6': '⋖', # ⋖ / ⋖ / ⋖
|
||||||
|
'\u2276': '≶', # ≶ / ≶ / ≶ / ≶
|
||||||
|
'\u2272': '≲', # ≲ / ≲ / ≲ / ≲
|
||||||
|
'\u226a': '≪', # ≪ / ≪ / ≪ / ≪
|
||||||
|
'\u23b0': '⎰', # ⎰ / ⎰ / ⎰
|
||||||
|
'\u2a89': '⪉', # ⪉ / ⪉ / ⪉
|
||||||
|
'\u2268': '≨', # ≨ / ≨ / ≨
|
||||||
|
'\u2a87': '⪇', # ⪇ / ⪇ / ⪇
|
||||||
|
CHAR_ARROW_L: '⟵', # ⟵ / ⟵ / ⟵ / ⟵
|
||||||
|
CHAR_ARROW_R: '⟶', # ⟶ / ⟶ / ⟶ / ⟶
|
||||||
|
CHAR_ARROW_LR: '⟷', # ⟷ / ⟷ / ⟷ / ⟷
|
||||||
|
'\u27fc': '⟼', # ⟼ / ⟼ / ⟼
|
||||||
|
'\u21ac': '↬', # ↬ / ↬ / ↬
|
||||||
|
'\u201e': '„', # „ / „ / „
|
||||||
|
'\u2199': '↙', # ↙ / ↙ / ↙ / ↙
|
||||||
|
'\u2198': '↘', # ↘ / ↘ / ↘ / ↘
|
||||||
|
'\u21b0': '↰', # ↰ / ↰ / ↰
|
||||||
|
'\u25c3': '◃', # ◃ / ◃ / ◃
|
||||||
|
'\u2720': '✠', # ✠ / ✠ / ✠
|
||||||
|
'\u21a6': '↦', # ↦ / ↦ / ↦ / ↦
|
||||||
|
'\u21a5': '↥', # ↥ / ↥ / ↥
|
||||||
|
'\u2133': 'ℳ', # ℳ / ℳ / ℳ / ℳ
|
||||||
|
'\u2223': '∣', # ∣ / ∣ / ∣ / ∣ / ∣
|
||||||
|
'\u2213': '∓', # ∓ / ∓ / ∓ / ∓
|
||||||
|
CHAR_HELLIP: '…', # … / … / …
|
||||||
|
'\u22b8': '⊸', # ⊸ / ⊸ / ⊸
|
||||||
|
'\u2249': '≉', # ≉ / ≉ / ≉ / ≉
|
||||||
|
'\u266e': '♮', # ♮ / ♮ / ♮
|
||||||
|
'\u2115': 'ℕ', # ℕ / ℕ / ℕ
|
||||||
|
'\u2247': '≇', # ≇ / ≇ / ≇
|
||||||
|
'\u2197': '↗', # ↗ / ↗ / ↗ / ↗
|
||||||
|
'\u200b': '​', # / ​ / ​ / ​
|
||||||
|
# ​ / ​
|
||||||
|
'\u2262': '≢', # ≢ / ≢ / ≢
|
||||||
|
'\u2928': '⤨', # ⤨ / ⤨ / ⤨
|
||||||
|
'\u2203': '∃', # ∃ / ∃ / ∃
|
||||||
|
'\u2204': '∄', # ∄ / ∄ / ∄ / ∄
|
||||||
|
'\u2271': '≱', # ≱ / ≱ / ≱ / ≱
|
||||||
|
'\u2275': '≵', # ≵ / ≵ / ≵
|
||||||
|
'\u226f': '≯', # ≯ / ≯ / ≯ / ≯
|
||||||
|
'\u21ce': '⇎', # ⇎ / ⇎ / ⇎
|
||||||
|
'\u21ae': '↮', # ↮ / ↮ / ↮
|
||||||
|
'\u220b': '∋', # ∋ / ∋ / ∋ / ∋ / ∋
|
||||||
|
'\u21cd': '⇍', # ⇍ / ⇍ / ⇍
|
||||||
|
'\u219a': '↚', # ↚ / ↚ / ↚
|
||||||
|
'\u2270': '≰', # ≰ / ≰ / ≰ / ≰
|
||||||
|
'\u226e': '≮', # ≮ / ≮ / ≮ / ≮
|
||||||
|
'\u2274': '≴', # ≴ / ≴ / ≴
|
||||||
|
'\u22ea': '⋪', # ⋪ / ⋪ / ⋪ / ⋪
|
||||||
|
'\u22ec': '⋬', # ⋬ / ⋬ / ⋬ / ⋬
|
||||||
|
'\u2224': '∤', # ∤ / ∤ / ∤ / ∤ / ∤
|
||||||
|
'\u2226': '∦', # ∦ / ∦ / ∦ / ∦ / ∦ / ∦
|
||||||
|
'\u2209': '∉', # ∉ / ∉ / ∉ / ∉
|
||||||
|
'\u2279': '≹', # ≹ / ≹ / ≹
|
||||||
|
'\u2278': '≸', # ≸ / ≸ / ≸
|
||||||
|
'\u220c': '∌', # ∌ / ∌ / ∌ / ∌
|
||||||
|
'\u2280': '⊀', # ⊀ / ⊀ / ⊀ / ⊀
|
||||||
|
'\u22e0': '⋠', # ⋠ / ⋠ / ⋠
|
||||||
|
'\u22eb': '⋫', # ⋫ / ⋫ / ⋫ / ⋫
|
||||||
|
'\u22ed': '⋭', # ⋭ / ⋭ / ⋭ / ⋭
|
||||||
|
'\u22e2': '⋢', # ⋢ / ⋢ / ⋢
|
||||||
|
'\u22e3': '⋣', # ⋣ / ⋣ / ⋣
|
||||||
|
'\u2288': '⊈', # ⊈ / ⊈ / ⊈ / ⊈
|
||||||
|
'\u2281': '⊁', # ⊁ / ⊁ / ⊁ / ⊁
|
||||||
|
'\u22e1': '⋡', # ⋡ / ⋡ / ⋡
|
||||||
|
'\u2289': '⊉', # ⊉ / ⊉ / ⊉ / ⊉
|
||||||
|
'\u2241': '≁', # ≁ / ≁ / ≁
|
||||||
|
'\u2244': '≄', # ≄ / ≄ / ≄ / ≄
|
||||||
|
'\u21cf': '⇏', # ⇏ / ⇏ / ⇏
|
||||||
|
'\u219b': '↛', # ↛ / ↛ / ↛
|
||||||
|
'\u2196': '↖', # ↖ / ↖ / ↖ / ↖
|
||||||
|
'\u2134': 'ℴ', # ℴ / ℴ / ℴ / ℴ
|
||||||
|
'\u203e': '‾', # ̄ / ‾ / ‾
|
||||||
|
'\u23b4': '⎴', # ⎴ / ⎴ / ⎴
|
||||||
|
'\u03d6': 'ϖ', # ϖ / ϖ / ϖ
|
||||||
|
'\u03d5': 'ϕ', # ϕ / ϕ / ϕ / ϕ
|
||||||
|
'\u2665': '♥', # ♥ / ♥ / ♥ /
|
||||||
|
'\u2119': 'ℙ', # ℙ / ℙ / ℙ
|
||||||
|
'\u227a': '≺', # ≺ / ≺ / ≺ / ≺
|
||||||
|
'\u2ab7': '⪷', # ⪷ / ⪷ / ⪷
|
||||||
|
'\u227c': '≼', # ≼ / ≼ / ≼ / ≼
|
||||||
|
'\u2aaf': '⪯', # ⪯ / ⪯ / ⪯ / ⪯
|
||||||
|
'\u227e': '≾', # ≾ / ≾ / ≾ / ≾
|
||||||
|
'\u2ab9': '⪹', # ⪹ / ⪹ / ⪹
|
||||||
|
'\u2ab5': '⪵', # ⪵ / ⪵ / ⪵
|
||||||
|
'\u22e8': '⋨', # ⋨ / ⋨ / ⋨
|
||||||
|
'\u220f': '∏', # ∏ / ∏ / ∏
|
||||||
|
'\u221d': '∝', # ∝ / ∝ / ∝ / ∝ / ∝ / ∝
|
||||||
|
'\u211a': 'ℚ', # ℚ / ℚ / ℚ
|
||||||
|
'\u21db': '⇛', # ⇛ / ⇛ / ⇛
|
||||||
|
'\u27e9': '⟩', # ⟩ / ⟩ / ⟩ / ⟩
|
||||||
|
'\u21a0': '↠', # ↠ / ↠ / ↠
|
||||||
|
'\u21e5': '⇥', # ⇥ / ⇥ / ⇥
|
||||||
|
'\u21a3': '↣', # ↣ / ↣ / ↣
|
||||||
|
'\u2309': '⌉', # ⌉ / ⌉ / ⌉
|
||||||
|
'\u219d': '↝', # ↝ / ↝ / ↝
|
||||||
|
'\u03a9': 'Ω', # Ω / Ω / Ω
|
||||||
|
'\u211c': 'ℜ', # ℜ / ℜ / ℜ / ℜ / ℜ
|
||||||
|
'\u211b': 'ℛ', # ℛ / ℛ / ℛ
|
||||||
|
'\u211d': 'ℝ', # ℝ / ℝ / ℝ
|
||||||
|
'\u21c0': '⇀', # ⇀ / ⇀ / ⇀ / ⇀
|
||||||
|
'\u03f1': 'ϱ', # ϱ / ϱ / ϱ
|
||||||
|
'\u2192': '→', # → / → / → / → / → / →
|
||||||
|
'\u21c4': '⇄', # ⇄ / ⇄ / ⇄ / ⇄
|
||||||
|
'\u27e7': '⟧', # ⟧ / ⟧ / ⟧
|
||||||
|
'\u230b': '⌋', # ⌋ / ⌋ / ⌋
|
||||||
|
'\u21c9': '⇉', # ⇉ / ⇉ / ⇉
|
||||||
|
'\u22a2': '⊢', # ⊢ / ⊢ / ⊢
|
||||||
|
'\u22cc': '⋌', # ⋌ / ⋌ / ⋌
|
||||||
|
'\u22b3': '⊳', # ⊳ / ⊳ / ⊳ / ⊳
|
||||||
|
'\u22b5': '⊵', # ⊵ / ⊵ / ⊵ / ⊵
|
||||||
|
'\u21be': '↾', # ↾ / ↾ / ↾ / ↾
|
||||||
|
'\u23b1': '⎱', # ⎱ / ⎱ / ⎱
|
||||||
|
'\u201c': '“', # “ / “ / “
|
||||||
|
'\u2018': '‘', # ‘ / ‘ / ‘
|
||||||
|
'\u21b1': '↱', # ↱ / ↱ / ↱
|
||||||
|
'\u25b9': '▹', # ▹ / ▹ / ▹
|
||||||
|
'\u227b': '≻', # ≻ / ≻ / ≻ / ≻
|
||||||
|
'\u2ab8': '⪸', # ⪸ / ⪸ / ⪸
|
||||||
|
'\u227d': '≽', # ≽ / ≽ / ≽ / ≽
|
||||||
|
'\u2ab0': '⪰', # ⪰ / ⪰ / ⪰ / ⪰
|
||||||
|
'\u2aba': '⪺', # ⪺ / ⪺ / ⪺
|
||||||
|
'\u2ab6': '⪶', # ⪶ / ⪶ / ⪶
|
||||||
|
'\u22e9': '⋩', # ⋩ / ⋩ / ⋩
|
||||||
|
'\u227f': '≿', # ≿ / ≿ / ≿ / ≿
|
||||||
|
'\u2929': '⤩', # ⤩ / ⤩ / ⤩
|
||||||
|
'\u03c2': 'ς', # ς / ς / ς / ς
|
||||||
|
'\u2243': '≃', # ≃ / ≃ / ≃ / ≃
|
||||||
|
'\u2323': '⌣', # ⌣ / ⌣ / ⌣
|
||||||
|
'\u2660': '♠', # ♠ / ♠ / ♠ /
|
||||||
|
'\u2293': '⊓', # ⊓ / ⊓ / ⊓
|
||||||
|
'\u2294': '⊔', # ⊔ / ⊔ / ⊔
|
||||||
|
'\u221a': '√', # √ / √ / √
|
||||||
|
'\u228f': '⊏', # ⊏ / ⊏ / ⊏ / ⊏
|
||||||
|
'\u2291': '⊑', # ⊑ / ⊑ / ⊑ / ⊑
|
||||||
|
'\u2290': '⊐', # ⊐ / ⊐ / ⊐ / ⊐
|
||||||
|
'\u2292': '⊒', # ⊒ / ⊒ / ⊒ / ⊒
|
||||||
|
'\u25a1': '□', # □ / □ / □ / □
|
||||||
|
'\u22c6': '⋆', # ⋆ / ⋆ / ⋆
|
||||||
|
'\u22d0': '⋐', # ⋐ / ⋐ / ⋐
|
||||||
|
'\u2282': '⊂', # ⊂ / ⊂ / ⊂
|
||||||
|
'\u2ac5': '⫅', # ⫅ / ⫅ / ⫅
|
||||||
|
'\u2acb': '⫋', # ⫋ / ⫋ / ⫋
|
||||||
|
'\u228a': '⊊', # ⊊ / ⊊ / ⊊
|
||||||
|
'\u2286': '⊆', # ⊆ / ⊆ / ⊆ / ⊆
|
||||||
|
'\u2211': '∑', # ∑ / ∑ / ∑
|
||||||
|
'\u22d1': '⋑', # ⋑ / ⋑ / ⋑
|
||||||
|
'\u2ac6': '⫆', # ⫆ / ⫆ / ⫆
|
||||||
|
'\u2283': '⊃', # ⊃ / ⊃ / ⊃ / ⊃
|
||||||
|
'\u2287': '⊇', # ⊇ / ⊇ / ⊇ / ⊇
|
||||||
|
'\u2acc': '⫌', # ⫌ / ⫌ / ⫌
|
||||||
|
'\u228b': '⊋', # ⊋ / ⊋ / ⊋
|
||||||
|
'\u223c': '∼', # ∼ / ∼ / ∼ / ∼ / ∼
|
||||||
|
'\u2245': '≅', # ≅ / ≅ / ≅
|
||||||
|
'\u20db': '⃛', # ⃛ / ⃛ / ⃛
|
||||||
|
'\u2234': '∴', # ∴ / ∴ / ∴ / ∴
|
||||||
|
'\u03d1': 'ϑ', # ϑ / ϑ / ϑ / ϑ
|
||||||
|
CHAR_TRADE: '™', # ™ / ™ / ™
|
||||||
|
'\u25b5': '▵', # ▵ / ▵ / ▵
|
||||||
|
'\u225c': '≜', # ≜ / ≜ / ≜
|
||||||
|
'\u21c5': '⇅', # ⇅ / ⇅ / ⇅
|
||||||
|
'\u296e': '⥮', # ⥮ / ⥮ / ⥮
|
||||||
|
'\u231c': '⌜', # ⌜ / ⌜ / ⌜
|
||||||
|
'\u03d2': 'ϒ', # ϒ / ϒ / ϒ
|
||||||
|
'\u03c5': 'υ', # υ / υ / υ
|
||||||
|
'\u228e': '⊎', # ⊎ / ⊎ / ⊎
|
||||||
|
'\u2195': '↕', # ↕ / ↕ / ↕ / ↕
|
||||||
|
'\u2191': '↑', # ↑ / ↑ / ↑ / ↑ / ↑
|
||||||
|
'\u21c8': '⇈', # ⇈ / ⇈ / ⇈
|
||||||
|
'\u231d': '⌝', # ⌝ / ⌝ / ⌝
|
||||||
|
'\u2016': '‖', # ‖ / ‖ / ‖
|
||||||
|
'\u2228': '∨', # ∨ / ∨ / ∨
|
||||||
|
CHAR_THIN_SP: ' ', # /   /  
|
||||||
|
'\u2240': '≀', # ≀ / ≀ / ≀ / ≀
|
||||||
|
'\u2128': 'ℨ', # ℨ / ℨ / ℨ
|
||||||
|
'\u2118': '℘', # ℘ / ℘ / ℘
|
||||||
|
}
|
||||||
|
|
||||||
|
# === Динамическая генерация карт преобразования ===
|
||||||
|
|
||||||
|
def _build_translation_maps() -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Создает карту для кодирования на лету, используя все доступные источники
|
||||||
|
из html.entities и строгий порядок приоритетов для обеспечения
|
||||||
|
предсказуемого и детерминированного результата.
|
||||||
|
"""
|
||||||
|
# ШАГ 1: Создаем ЕДИНУЮ и ПОЛНУЮ карту {каноническое_имя: числовой_код}.
|
||||||
|
# Это решает проблему разных форматов и дубликатов с точкой с запятой.
|
||||||
|
unified_name2codepoint = {}
|
||||||
|
|
||||||
|
# Сначала обрабатываем большой исторический словарь.
|
||||||
|
for name, codepoint in entities.name2codepoint.items():
|
||||||
|
# Нормализуем имя СРАЗУ, убирая опциональную точку с запятой (в html.entities предусмотрено, что иногда
|
||||||
|
# символ `;` не ставится всякими неаккуратными верстальщиками и парсерами).
|
||||||
|
canonical_name = name.rstrip(';')
|
||||||
|
unified_name2codepoint[canonical_name] = codepoint
|
||||||
|
# Затем обновляем его современным стандартом html5.
|
||||||
|
# Это гарантирует, что если мнемоника есть в обоих, будет использована версия из html5.
|
||||||
|
for name, char in entities.html5.items():
|
||||||
|
# НОВОЕ: Проверяем, что значение является ОДИНОЧНЫМ символом.
|
||||||
|
# Наш кодек, основанный на str.translate, не может обрабатывать
|
||||||
|
# мнемоники, которые соответствуют строкам из нескольких символов
|
||||||
|
# (например, символ + вариативный селектор). Мы их игнорируем.
|
||||||
|
if len(char) != 1:
|
||||||
|
continue
|
||||||
|
# Нормализуем имя СРАЗУ.
|
||||||
|
canonical_name = name.rstrip(';')
|
||||||
|
unified_name2codepoint[canonical_name] = ord(char)
|
||||||
|
|
||||||
|
# Теперь у нас есть полный и консистентный словарь unified_name2codepoint.
|
||||||
|
# На его основе строим нашу карту для кодирования.
|
||||||
|
encode_map = {}
|
||||||
|
|
||||||
|
# ШАГ 2: Высший приоритет. Загружаем наши кастомные правила.
|
||||||
|
encode_map.update(CUSTOM_ENCODE_MAP)
|
||||||
|
|
||||||
|
# ШАГ 3: Следующий приоритет. Добавляем числовое кодирование.
|
||||||
|
for char in ALWAYS_ENCODE_TO_NUMERIC_CHARS:
|
||||||
|
if char not in encode_map:
|
||||||
|
encode_map[char] = f'&#{ord(char)};'
|
||||||
|
|
||||||
|
# ШАГ 4: Низший приоритет. Заполняем все остальное из нашей
|
||||||
|
# объединенной и нормализованной карты unified_name2codepoint.
|
||||||
|
for name, codepoint in unified_name2codepoint.items():
|
||||||
|
char = chr(codepoint)
|
||||||
|
if char not in encode_map and char not in NEVER_ENCODE_CHARS:
|
||||||
|
# Теперь 'name' - это уже каноническое имя без ';',
|
||||||
|
# поэтому дополнительная нормализация не нужна. Код стал проще!
|
||||||
|
encode_map[char] = f'&{name};'
|
||||||
|
|
||||||
|
return encode_map
|
||||||
|
|
||||||
|
|
||||||
|
# Создаем карту один раз при импорте модуля.
|
||||||
|
ENCODE_MAP = _build_translation_maps()
|
||||||
|
|
||||||
|
# --- Публичный API модуля ---
|
||||||
|
def get_encode_map():
|
||||||
|
"""Возвращает готовую карту для кодирования."""
|
||||||
|
return ENCODE_MAP
|
||||||
|
|
||||||
|
|
||||||
|
# === КОНСТАНТЫ ДЛЯ ЕДИНИЦ ИЗМЕРЕНИЯ ===
|
||||||
|
# ТОЛЬКО АТОМАРНЫЕ единицы измерения: 'г', 'м', 'с', 'км', 'кв', 'куб', 'ч' и так далее.
|
||||||
|
# Никаких сложных и составных, типа: 'кв.м.', 'км/ч' или "до н.э." ...
|
||||||
|
# Пост-позиционные (можно ставить точку после, но не обязательно) (км, г., с.)
|
||||||
|
DEFAULT_POST_UNITS = [
|
||||||
|
# Русские
|
||||||
|
# --- Время и эпохи ---
|
||||||
|
'гг', 'г.', 'в.', 'вв', 'н', 'э', 'сек', 'с.', 'мин', 'ч',
|
||||||
|
# --- Масса и объём ---
|
||||||
|
'кг', 'мг', 'ц', 'т', 'л', 'мл',
|
||||||
|
# --- Размеры ---
|
||||||
|
'кв', 'куб', 'мм', 'см', 'м', 'км', 'сот', 'га', 'м²', 'м³',
|
||||||
|
# --- Финансы и количество ---
|
||||||
|
'руб', 'коп', 'тыс', 'млн', 'млрд', 'трлн', 'трлрд', 'шт', 'об', 'ящ', 'уп', 'кор', 'пар', 'комп',
|
||||||
|
# --- Издательское дело ---
|
||||||
|
'пп', 'стр', 'рис', 'гр', 'табл', 'гл', 'п', 'пт', 'гл', 'том', 'т.', 'кн', 'илл', 'ред', 'изд', 'пер',
|
||||||
|
# --- Физические и технические ---
|
||||||
|
'дБ', 'Вт', 'кВт', 'МВт', 'ГВт', 'А', 'В', 'Ом', 'Па', 'кПа', 'МПа', 'Бар', 'кБар', 'Гц', 'кГц', 'МГц', 'ГГц',
|
||||||
|
'рад', 'К', '°C', '°F', '%', 'мкм', 'нм', 'А°', 'эВ', 'Дж', 'кДж', 'МДж', 'пкФ', 'нФ', 'мкФ', 'мФ', 'Ф',
|
||||||
|
'Гн', 'мГн', 'мкГн', 'Тл', 'Гс', 'эрг', 'бод', 'бит', 'байт', 'Кб', 'Мб', 'Гб', 'Тб', 'Пб', 'Эб', 'кал', 'ккал',
|
||||||
|
# Английские
|
||||||
|
# --- Издательское дело ---
|
||||||
|
'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed', 'rev', 'dpi',
|
||||||
|
# --- Имперские и американские единицы ---
|
||||||
|
'in', 'ft', 'yd', 'mi', 'oz', 'lb', 'st', 'pt', 'qt', 'gal', 'mph', 'rpm', 'hp', 'psi', 'cal',
|
||||||
|
]
|
||||||
|
# Пред-позиционные (№ 5, $ 10)
|
||||||
|
DEFAULT_PRE_UNITS = ['№', '$', '€', '£', '₽', '#', '§', '¤', '₴', '₿', '₺', '₦', '₩', '₪', '₫', '₲', '₡', '₵',
|
||||||
|
'ГОСТ', 'ТУ', 'ИСО', 'DIN', 'ASTM', 'EN', 'IEC', 'IEEE'] # технические стандарты перед числом работают как единицы измерения
|
||||||
|
|
||||||
|
# Операторы, которые могут стоять между единицами измерения (км/ч)
|
||||||
|
# Сложение и вычитание здесь намеренно отсутствуют.
|
||||||
|
UNIT_MATH_OPERATORS = ['/', '*', '×', CHAR_MIDDOT, '÷']
|
||||||
|
|
||||||
|
# === КОНСТАНТЫ ДЛЯ ФИНАЛЬНЫХ СОКРАЩЕНИЙ ===
|
||||||
|
# Эти сокращения (обычно в конце фразы) будут "склеены" тонкой шпацией, а перед ними будет поставлен неразрывный пробел.
|
||||||
|
# Важно, чтобы многосложные сокращения (типа "и т. д.") были в списке с разделителем пробелом (иначе мы не сможем их найти).
|
||||||
|
ABBR_COMMON_FINAL = [
|
||||||
|
# 'т. д.', 'т. п.', 'др.', 'пр.',
|
||||||
|
# УБРАНЫ из-за неоднозначности: др. -- "другой", "доктор", "драм" / пр. -- "прочие", "профессор", "проект", "проезд" ...
|
||||||
|
'т. д.', 'т. п.',
|
||||||
|
]
|
||||||
|
|
||||||
|
ABBR_COMMON_PREPOSITION = [
|
||||||
|
'т. е.', 'т. к.', 'т. о.', 'т. ч.',
|
||||||
|
'и. о.', 'ио', 'вр. и. о.', 'врио',
|
||||||
|
'тов.', 'г-н.', 'г-жа.', 'им.',
|
||||||
|
'д. о. с.', 'д. о. н.', 'д. м. н.', 'к. т. д.', 'к. т. п.',
|
||||||
|
'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП', 'НИИ', 'ПБОЮЛ', 'ИП',
|
||||||
|
]
|
||||||
|
|
||||||
|
# === КОНСТАНТЫ ДЛЯ HTML-ТЕГОВ, ВНУТРИ КОТОРЫХ НЕ НАДО ТИПОГРАФИРОВАТЬ ===
|
||||||
|
PROTECTED_HTML_TAGS = ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math']
|
||||||
|
|
||||||
|
# === КОНСТАНТЫ ДЛЯ ВИСЯЧЕЙ ТИПОГРАФИКИ ===
|
||||||
|
|
||||||
|
# 1. Набор символов, которые могут "висеть" слева
|
||||||
|
HANGING_PUNCTUATION_LEFT_CHARS = frozenset([
|
||||||
|
CHAR_RU_QUOT1_OPEN, # «
|
||||||
|
CHAR_EN_QUOT1_OPEN, # “
|
||||||
|
'(', '[', '{',
|
||||||
|
])
|
||||||
|
|
||||||
|
# 2. Набор символов, которые могут "висеть" справа
|
||||||
|
HANGING_PUNCTUATION_RIGHT_CHARS = frozenset([
|
||||||
|
CHAR_RU_QUOT1_CLOSE, # »
|
||||||
|
CHAR_EN_QUOT1_CLOSE, # ”
|
||||||
|
')', ']', '}',
|
||||||
|
'.', ',', ':',
|
||||||
|
])
|
||||||
|
|
||||||
|
# 3. Словарь, сопоставляющий символ с его CSS-классом
|
||||||
|
HANGING_PUNCTUATION_CLASSES = {
|
||||||
|
# Левая пунктуация: все классы начинаются с 'etp-l'
|
||||||
|
CHAR_RU_QUOT1_OPEN: 'etp-laquo',
|
||||||
|
CHAR_EN_QUOT1_OPEN: 'etp-ldquo',
|
||||||
|
'(': 'etp-lpar',
|
||||||
|
'[': 'etp-lsqb',
|
||||||
|
'{': 'etp-lcub',
|
||||||
|
# Правая пунктуация: все классы начинаются с 'etp-r'
|
||||||
|
CHAR_RU_QUOT1_CLOSE: 'etp-raquo',
|
||||||
|
CHAR_EN_QUOT1_CLOSE: 'etp-rdquo',
|
||||||
|
')': 'etp-rpar',
|
||||||
|
']': 'etp-rsqb',
|
||||||
|
'}': 'etp-rcub',
|
||||||
|
'.': 'etp-r-dot',
|
||||||
|
',': 'etp-r-comma',
|
||||||
|
':': 'etp-r-colon',
|
||||||
|
}
|
||||||
32
etpgrf_site/etpgrf/defaults.py
Normal file
32
etpgrf_site/etpgrf/defaults.py
Normal 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()
|
||||||
166
etpgrf_site/etpgrf/hanging.py
Normal file
166
etpgrf_site/etpgrf/hanging.py
Normal 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
358
etpgrf_site/etpgrf/hyphenation.py
Executable 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
|
||||||
|
|
||||||
|
|
||||||
211
etpgrf_site/etpgrf/layout.py
Normal file
211
etpgrf_site/etpgrf/layout.py
Normal 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
|
||||||
124
etpgrf_site/etpgrf/logger.py
Normal file
124
etpgrf_site/etpgrf/logger.py
Normal 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)
|
||||||
|
|
||||||
75
etpgrf_site/etpgrf/quotes.py
Normal file
75
etpgrf_site/etpgrf/quotes.py
Normal 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
|
||||||
76
etpgrf_site/etpgrf/sanitizer.py
Normal file
76
etpgrf_site/etpgrf/sanitizer.py
Normal 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
|
||||||
50
etpgrf_site/etpgrf/symbols.py
Normal file
50
etpgrf_site/etpgrf/symbols.py
Normal 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
|
||||||
|
|
||||||
258
etpgrf_site/etpgrf/typograph.py
Normal file
258
etpgrf_site/etpgrf/typograph.py
Normal 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 по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали
|
||||||
|
# в _process_text_node. Возвращаем их обратно.
|
||||||
|
return processed_html.replace('&', '&')
|
||||||
|
else:
|
||||||
|
return self._process_plain_text(text)
|
||||||
|
|
||||||
|
def _process_plain_text(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Логика обработки обычного текста (вынесена из process для переиспользования).
|
||||||
|
"""
|
||||||
|
# Шаг 0: Нормализация
|
||||||
|
processed_text = decode_to_unicode(text)
|
||||||
|
# Шаг 1: Применяем все правила последовательно
|
||||||
|
if self.quotes:
|
||||||
|
processed_text = self.quotes.process(processed_text)
|
||||||
|
if self.unbreakables:
|
||||||
|
processed_text = self.unbreakables.process(processed_text)
|
||||||
|
if self.symbols:
|
||||||
|
processed_text = self.symbols.process(processed_text)
|
||||||
|
if self.layout:
|
||||||
|
processed_text = self.layout.process(processed_text)
|
||||||
|
if self.hyphenation:
|
||||||
|
processed_text = self.hyphenation.hyp_in_text(processed_text)
|
||||||
|
# Шаг 2: Финальное кодирование
|
||||||
|
return encode_from_unicode(processed_text, self.mode)
|
||||||
124
etpgrf_site/etpgrf/unbreakables.py
Normal file
124
etpgrf_site/etpgrf/unbreakables.py
Normal 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я', 'сiе', 'сiй', 'онъ', 'тъ',
|
||||||
|
])
|
||||||
|
|
||||||
|
# Постпозитивные частицы, которые приклеиваются к ПРЕДЫДУЩЕМУ слову
|
||||||
|
_RU_OLD_POSTPOSITIVE_PARTICLES = frozenset([
|
||||||
|
'жъ', 'бъ'
|
||||||
|
])
|
||||||
|
|
||||||
|
_EN_UNBREAKABLE_WORDS = frozenset([
|
||||||
|
# 1-2 letter words (I - as pronoun)
|
||||||
|
'a', 'an', 'as', 'at', 'by', 'in', 'is', 'it', 'of', 'on', 'or', 'so', 'to', 'if',
|
||||||
|
# 3-4 letter words
|
||||||
|
'for', 'from', 'into', 'that', 'then', 'they', 'this', 'was', 'were', 'what', 'when', 'with',
|
||||||
|
'not', 'but', 'which', 'the'
|
||||||
|
])
|
||||||
|
|
||||||
|
# --- Настройки логирования ---
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Класс Unbreakables (обработка неразрывных конструкций) ---
|
||||||
|
class Unbreakables:
|
||||||
|
"""
|
||||||
|
Правила обработки коротких слов (предлогов, союзов, частиц и местоимений) для предотвращения их отрыва
|
||||||
|
от последующих слов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None):
|
||||||
|
self.langs = parse_and_validate_langs(langs)
|
||||||
|
|
||||||
|
# --- 1. Собираем наборы слов для обработки ---
|
||||||
|
pre_words = set()
|
||||||
|
post_words = set()
|
||||||
|
# Собираем слова которые должны быть приклеены
|
||||||
|
if LANG_RU in self.langs:
|
||||||
|
pre_words.update(_RU_UNBREAKABLE_WORDS)
|
||||||
|
post_words.update(_RU_POSTPOSITIVE_PARTICLES)
|
||||||
|
if LANG_RU_OLD in self.langs:
|
||||||
|
pre_words.update(_RU_OLD_UNBREAKABLE_WORDS)
|
||||||
|
post_words.update(_RU_OLD_POSTPOSITIVE_PARTICLES)
|
||||||
|
if LANG_EN in self.langs:
|
||||||
|
pre_words.update(_EN_UNBREAKABLE_WORDS)
|
||||||
|
|
||||||
|
# Собираем единый набор слов с пост-позиционными словами (не отрываются от предыдущих слов)
|
||||||
|
# Убедимся, что пост-позиционные слова не обрабатываются дважды
|
||||||
|
pre_words -= post_words
|
||||||
|
|
||||||
|
# --- 2. Компиляция паттернов с оптимизацией ---
|
||||||
|
self._pre_pattern = None
|
||||||
|
if pre_words:
|
||||||
|
# Оптимизация: сортируем слова по длине от большего к меньшему
|
||||||
|
sorted_words = sorted(list(pre_words), key=len, reverse=True)
|
||||||
|
# Паттерн для слов, ПОСЛЕ которых нужен nbsp. regex.escape для безопасности.
|
||||||
|
self._pre_pattern = regex.compile(r"(?i)\b(" + "|".join(map(regex.escape, sorted_words)) + r")\b\s+")
|
||||||
|
|
||||||
|
self._post_pattern = None
|
||||||
|
if post_words:
|
||||||
|
# Оптимизация: сортируем слова по длине от большего к меньшему
|
||||||
|
sorted_particles = sorted(list(post_words), key=len, reverse=True)
|
||||||
|
# Паттерн для слов, ПЕРЕД которыми нужен nbsp.
|
||||||
|
self._post_pattern = regex.compile(r"(?i)(\s)\b(" + "|".join(map(regex.escape, sorted_particles)) + r")\b")
|
||||||
|
|
||||||
|
logger.debug(f"Unbreakables `__init__`. Langs: {self.langs}, "
|
||||||
|
f"Pre-words: {len(pre_words)}, Post-words: {len(post_words)}")
|
||||||
|
|
||||||
|
|
||||||
|
def process(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Заменяет обычные пробелы вокруг коротких слов на неразрывные.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
processed_text = text
|
||||||
|
|
||||||
|
# 1. Обработка слов, ПОСЛЕ которых нужен неразрывный пробел ("в дом" -> "в дом")
|
||||||
|
if self._pre_pattern:
|
||||||
|
processed_text = self._pre_pattern.sub(r"\g<1>" + CHAR_NBSP, processed_text)
|
||||||
|
|
||||||
|
# 2. Обработка частиц, ПЕРЕД которыми нужен неразрывный пробел ("сказал бы" -> "сказал бы")
|
||||||
|
if self._post_pattern:
|
||||||
|
# \g<1> - это пробел, \g<2> - это частица
|
||||||
|
processed_text = self._post_pattern.sub(CHAR_NBSP + r"\g<2>", processed_text)
|
||||||
|
|
||||||
|
return processed_text
|
||||||
Reference in New Issue
Block a user