219 lines
14 KiB
Python
Executable File
219 lines
14 KiB
Python
Executable File
import regex
|
||
import logging
|
||
from etpgrf.config import LANG_RU, LANG_RU_OLD, LANG_EN, SHY_ENTITIES, MODE_UNICODE
|
||
from etpgrf.defaults import etpgrf_settings
|
||
from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs
|
||
|
||
_RU_VOWELS_UPPER = frozenset(['А', 'О', 'И', 'Е', 'Ё', 'Э', 'Ы', 'У', 'Ю', 'Я'])
|
||
_RU_CONSONANTS_UPPER = frozenset(['Б', 'В', 'Г', 'Д', 'Ж', 'З', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', 'Ц', 'Ч', 'Ш', 'Щ'])
|
||
_RU_J_SOUND_UPPER = frozenset(['Й'])
|
||
_RU_SIGNS_UPPER = frozenset(['Ь', 'Ъ'])
|
||
_RU_OLD_VOWELS_UPPER = frozenset(['І', # И-десятеричное (гласная)
|
||
'Ѣ']) # Ять (гласная)
|
||
_RU_OLD_CONSONANTS_UPPER = frozenset(['Ѳ', # Фита (согласная)
|
||
'Ѵ']) # Ижица (может быть и гласной, и согласной - сложный случай!)
|
||
|
||
|
||
_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'])
|
||
|
||
# --- Настройки логирования ---
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# --- Класс Hyphenator (расстановка переносов) ---
|
||
class Hyphenator:
|
||
"""Правила расстановки переносов для разных языков.
|
||
"""
|
||
def __init__(self,
|
||
langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None,
|
||
mode: str = None, # Режим обработки текста
|
||
max_unhyphenated_len: int | None = None, # Максимальная длина непереносимой группы
|
||
min_tail_len: int | None = None): # Минимальная длина после переноса (хвост, который разрешено переносить)
|
||
self.langs: frozenset[str] = parse_and_validate_langs(langs)
|
||
self.mode: str = parse_and_validate_mode(mode)
|
||
self.max_unhyphenated_len = etpgrf_settings.hyphenation.MAX_UNHYPHENATED_LEN if max_unhyphenated_len is None else max_unhyphenated_len
|
||
self.min_chars_per_part = etpgrf_settings.hyphenation.MIN_TAIL_LEN if min_tail_len is None else min_tail_len
|
||
|
||
# Внутренние языковые ресурсы, если нужны специфично для переносов
|
||
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()
|
||
# Определяем символ переноса в зависимости от режима
|
||
self._split_code: str = SHY_ENTITIES['SHY'][0] if self.mode == MODE_UNICODE else SHY_ENTITIES['SHY'][1]
|
||
# ...
|
||
logger.debug(f"Hyphenator `__init__`. Langs: {self.langs}, Mode: {self.mode},"
|
||
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} // mode: {self.mode} // 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:
|
||
vow_indices = [i for i, char_w in enumerate(word_segment) if self._is_vow(char_w)]
|
||
# Если в слове нет гласных, то перенос невозможен
|
||
if not vow_indices:
|
||
return -1
|
||
# Ищем ближайшую гласную до или после start_idx
|
||
for i in vow_indices:
|
||
if i >= start_idx - self.min_chars_per_part and i + self.min_chars_per_part < len(word_segment):
|
||
# Проверяем, что после гласной есть минимум символов "хвоста"
|
||
ind = i + 1
|
||
if (self._is_cons(word_segment[ind]) or self._is_j_sound(word_segment[ind])) and not self._is_vow(word_segment[ind + 1]):
|
||
# Й -- полугласная. Перенос после неё только в случае, если дальше идет согласная
|
||
# (например, "бой-кий"), но запретить, если идет гласная (например, "ма-йка" не переносится).
|
||
ind += 1
|
||
if ind <= self.min_chars_per_part or ind >= len(word_segment) - self.min_chars_per_part:
|
||
# Не отделяем 3 символ с начала или конца (это некрасиво)
|
||
continue
|
||
if self._is_sign(word_segment[ind]) or (ind > 0 and self._is_sign(word_segment[ind-1])):
|
||
# Пропускаем мягкий/твердый знак, если перенос начинается или заканчивается на них (ГОСТ 7.62-2008)
|
||
continue
|
||
return ind
|
||
return -1 # Не нашли подходящую позицию
|
||
|
||
# Рекурсивное деление слова
|
||
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) + self._split_code + 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")
|
||
# --- Начало логики для английского языка (заглушка) ---
|
||
# ПРИМЕЧАНИЕ: Это очень упрощенная заглушка.
|
||
def find_hyphen_point_en(word_segment: str) -> int:
|
||
for i in range(self.min_chars_per_part, len(word_segment) - self.min_chars_per_part):
|
||
if self._is_vow(word_segment[i - 1]) and self._is_cons(word_segment[i]):
|
||
if len(word_segment[:i]) >= self.min_chars_per_part and \
|
||
len(word_segment[i:]) >= self.min_chars_per_part:
|
||
return i
|
||
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)
|
||
if hyphen_idx != -1:
|
||
return word_to_split[:hyphen_idx] + self._split_code + word_to_split[hyphen_idx:]
|
||
return word_to_split
|
||
# --- Конец логики для английского языка ---
|
||
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
|
||
|
||
|