diff --git a/etpgrf/comutil.py b/etpgrf/comutil.py index 0b1baae..6fb0e94 100644 --- a/etpgrf/comutil.py +++ b/etpgrf/comutil.py @@ -4,7 +4,7 @@ import os import regex -def parce_and_validate_mode( +def parse_and_validate_mode( mode_input: str | None = None, ) -> str: """ diff --git a/etpgrf/config.py b/etpgrf/config.py index cd46634..310845e 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -9,9 +9,11 @@ MODE_MIXED = "mixed" DEFAULT_MODE = MODE_MIXED # Языки, поддерживаемые библиотекой -SUPPORTED_LANGS = frozenset(['ru', 'en']) +LANG_RU = 'ru' # Русский +LANG_EN = 'en' # Английский +SUPPORTED_LANGS = frozenset([LANG_RU, LANG_EN]) # Язык(и) по умолчанию, если не указаны пользователем и не заданы через ETPGRF_DEFAULT_LANGS_MODULE -DEFAULT_LANGS = 'ru' +DEFAULT_LANGS = LANG_RU # # ----------------- соответствия `unicode` и `mnemonic` для типографа diff --git a/etpgrf/hyphenation.py b/etpgrf/hyphenation.py index a817dbf..80dc2f0 100755 --- a/etpgrf/hyphenation.py +++ b/etpgrf/hyphenation.py @@ -1,13 +1,13 @@ import regex -from etpgrf.config import DEFAULT_MODE, DEFAULT_LANGS, SHY_ENTITIES, MODE_UNICODE -from etpgrf.comutil import parce_and_validate_mode, parse_and_validate_langs +from etpgrf.config import LANG_RU, LANG_EN, DEFAULT_MODE, DEFAULT_LANGS, SHY_ENTITIES, MODE_UNICODE +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(['Ь', 'Ъ']) -_EN_VOWELS_UPPER = frozenset(['A', 'E', 'I', 'O', 'U']) +_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']) @@ -15,12 +15,12 @@ class Hyphenator: """Правила расстановки переносов для разных языков. """ def __init__(self, - langs: frozenset[str] = None, # Языки, которые обрабатываем в переносе слов + langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None, mode: str = None, # Режим обработки текста max_unhyphenated_len: int = 14, # Максимальная длина непереносимой группы min_chars_per_part: int = 3): # Минимальная длина после переноса (хвост, который разрешено переносить) self.langs: frozenset[str] = parse_and_validate_langs(langs) - self.mode: str = parce_and_validate_mode(mode) + self.mode: str = parse_and_validate_mode(mode) self.max_unhyphenated_len = max_unhyphenated_len self.min_chars_per_part = min_chars_per_part @@ -29,6 +29,8 @@ class Hyphenator: 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() # Определяем символ переноса в зависимости от режима @@ -37,14 +39,16 @@ class Hyphenator: def _load_language_resources_for_hyphenation(self): # Определяем наборы гласных, согласных и т.д. в зависимости языков. - if "ru" in self.langs: + 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 - if "en" in self.langs: + self._ru_alphabet_upper |= _RU_VOWELS_UPPER | _RU_CONSONANTS_UPPER | _RU_SIGNS_UPPER | _RU_J_SOUND_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 # ... и для других языков, если они поддерживаются переносами # --- Сюда переносятся все методы, связанные с переносами --- @@ -79,67 +83,119 @@ class Hyphenator: :param word: Слово, в котором надо расставить переносы :return: Слово с расставленными переносами """ - - # Поиск допустимой позиции для переноса около заданного индекса - def find_hyphen_point(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 self._is_sign(word_segment[-1]): - # Пропускаем мягкий/твердый знак. Согласно правилам русской типографики (например, ГОСТ 7.62-2008 - # или рекомендации по набору текста), перенос не должен разрывать слово так, чтобы мягкий или - # твердый знак оказывался в начале или конце строки - continue - return ind - return -1 # Не нашли подходящую позицию - - # Рекурсивное деление слова - def split_word(word_to_split: str) -> str: - if len(word_to_split) <= self.max_unhyphenated_len: # Если длина укладывается в лимит, перенос не нужен - return word_to_split - - hyphen_idx = find_hyphen_point(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(left_part) + self._split_code + split_word(right_part) - - # Основная логика + # 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 + + # 2. ОБНАРУЖЕНИЕ ЯЗЫКА И ПОДКЛЮЧЕНИЕ ЯЗЫКОВОЙ ЛОГИКИ + # Поиск вхождения букв строки (слова) через `frozenset` -- O(1). Это быстрее регулярного выражения -- O(n) + # 2.1. Проверяем RU + if LANG_RU in self.langs and frozenset(word.upper()) <= self._ru_alphabet_upper: + # Пользователь подключил русскую логику, и слово содержит только русские буквы + # Поиск допустимой позиции для переноса около заданного индекса + 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: + # Пользователь подключил английскую логику, и слово содержит только английские буквы + print(f"#### Applying English rules to: {word}") # Для отладки + # --- Начало логики для английского языка (заглушка) --- + # ПРИМЕЧАНИЕ: Это очень упрощенная заглушка. + 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: + # кстати "слова" в которых есть пробелы или другие разделители, тоже попадают сюда + print("!!!!ФИГНЯ") return word - return split_word(word) # Рекурсивно делим слово на части с переносами def hyp_in_text(self, text: str) -> str: """ Расстановка переносов в тексте :param text: Строка, которую надо обработать (главный аргумент). - :return: str: + :return: str: Строка с расставленными переносами. """ - rus_worlds = regex.findall(r'\b[а-яА-Я]+\b', text) # ищем все русскоязычные слова в тексте - for word in rus_worlds: - if len(word) > self.max_unhyphenated_len: - hyphenated_word = self.hyp_in_word(word) - print(f'{word} -> {hyphenated_word}') - text = text.replace(word, hyphenated_word) - return text + + # 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: + print(f"hyp_in_text: '{word_to_process}' -> '{hyphenated_word}'") + + return hyphenated_word + + # 2. regex.sub() -- поиск с заменой. Ищем по паттерну `r'\b\p{L}+\b'` (`\b` - граница слова; + # `\p{L}` - любая буква Unicode; `+` - одно или более вхождений). + # Второй аргумент - это наша функция replace_word_with_hyphenated. + # regex.sub вызовет ее для каждого найденного слова, передав match_obj. + processed_text = regex.sub(pattern=r'\b\p{L}+\b', repl=replace_word_with_hyphenated, string=text) + + return processed_text + diff --git a/etpgrf/typograph.py b/etpgrf/typograph.py index fa9906e..850ad97 100644 --- a/etpgrf/typograph.py +++ b/etpgrf/typograph.py @@ -1,4 +1,4 @@ -from etpgrf.comutil import parce_and_validate_mode, parse_and_validate_langs +from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs from etpgrf.hyphenation import Hyphenator import copy @@ -17,7 +17,7 @@ class Typographer: self.langs: frozenset[str] = parse_and_validate_langs(langs) # --- Обработка и валидация параметра mode --- - self.mode: str = parce_and_validate_mode(mode) + self.mode: str = parse_and_validate_mode(mode) # Сохраняем переданные модули правил if hyphenation_rule is not None: diff --git a/main.py b/main.py index 159aae2..219be73 100644 --- a/main.py +++ b/main.py @@ -3,20 +3,25 @@ import etpgrf if __name__ == '__main__': # --- Пример использования --- - ETPGRF_DEFAULT_LANGS = "ru" print("\n--- Пример использования класса---\n") # Определяем пользовательские правила переносов - hyphen_settings = etpgrf.Hyphenator(langs=frozenset(['ru']), max_unhyphenated_len=8) + hyphen_settings = etpgrf.Hyphenator(langs='ru', max_unhyphenated_len=8) # Определяем пользовательские правила типографа - typo = etpgrf.Typographer(langs='ru', mode='mnemonic', hyphenation_rule=hyphen_settings) result = hyphen_settings.hyp_in_text("Бармалейщина") print(result, "\n\n") result = hyphen_settings.hyp_in_word("Длинношеевый жираф") print(result, "\n\n") + + hyphen_settings2 = etpgrf.Hyphenator(langs='en', max_unhyphenated_len=8) + result = hyphen_settings2.hyp_in_text("frozenseter") + print(result, "\n\n") + + typo = etpgrf.Typographer(langs='ru', mode='mnemonic', hyphenation_rule=hyphen_settings) result = typo.process(text="Какой-то длинный текст для проверки переносов. Перпердикюляция!") print(result, "\n\n") - result = typo.process(text="Привет, World! Это тестовый текст для проверки расстановки переносов в словах. Миллион 100-метровошеих жирножирафов.") + result = typo.process(text="Привет, frozenseter! Это тестовый текст для проверки расстановки переносов" + " в словах. Миллион 100-метровошеих жирножирафов.") print(result, "\n\n") txt = ("Каждое пальто, которое мы создаём — это не просто одежда. Это"