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