From 30368cedfd377d60e7e0e05dfbebbe0e0902f4ed Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 11 May 2025 02:08:06 +0300 Subject: [PATCH] =?UTF-8?q?add:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=8B=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Class-=D1=8B=20?= =?UTF-8?q?(=D0=B4=D1=80=D0=B0=D1=84=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- etpgrf/hyphenation.py | 261 ++++++++++++++++++++++++++++++------------ main.py | 15 ++- 3 files changed, 201 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index c0ea9c3..27bc2c3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ | in progress // в процессе разработки | |--------------------------------------| -| -0 | +| --1 | # Типограф для Web diff --git a/etpgrf/hyphenation.py b/etpgrf/hyphenation.py index 89e30e9..a77a6eb 100755 --- a/etpgrf/hyphenation.py +++ b/etpgrf/hyphenation.py @@ -1,89 +1,204 @@ import regex +UTF = frozenset(['utf-8', 'utf-16', 'utf-32']) +MNEMO_CODE = frozenset(['mnemo', '&']) -def hyphenation_in_word(s: str, max_chunk: int = 14, sep: str = "-") -> str: - """ Расстановка переносов в русском слове с учетом максимальной длины непереносимой группы. - Переносы ставятся половинным делением слова, рекурсивно. +LANGS = frozenset(['ru', 'en']) - :param s: Слово, в котором надо расставить переносы - :param max_chunk: Максимальная длина непереносимой группы символов (по умолчанию 14) - :param sep: Символ переноса (по умолчанию "-") - :return: Слово с расставленными переносами +_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_CONSONANTS_UPPER = frozenset(['B', 'C', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Y', 'Z']) + + +class HyphenationRule: + """Правила расстановки переносов для разных языков. """ + def __init__(self, + langs: frozenset[str], # Языки, которые обрабатываем в переносе слов + max_len_hyphenation_not_required: int = 14, # Максимальная длина непереносимой группы + min_chars_per_part: int = 3): # Минимальная длина после переноса (хвост, который разрешено переносить) + self.langs = langs + self.max_len_hyphenation__not_required = max_len_hyphenation_not_required + self.min_chars_per_part = min_chars_per_part + + # Внутренние языковые ресурсы, если нужны специфично для переносов + self._vowels: frozenset = frozenset() + self._consonants: frozenset = frozenset() + self._j_sound_upper: frozenset = frozenset() + self._signs_upper: frozenset = frozenset() + self._load_language_resources_for_hyphenation() # Загружает наборы символов на основе self.langs + + self._split_memo: dict[str, str] = {} # Кеш для этого экземпляра + + def _load_language_resources_for_hyphenation(self): + # Определяем наборы гласных, согласных и т.д. в зависимости языков. + if "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._vowels |= _EN_VOWELS_UPPER + self._consonants |= _EN_CONSONANTS_UPPER + # ... и для других языков, если они поддерживаются переносами + + # --- Сюда переносятся все методы, связанные с переносами --- + # (адаптированные версии _is_vow, _is_cons, _is_j_sound, _is_sign, + # _hyphenate_one_word, _recursive_split_word, _find_hyphen_point_in_sub_word, _is_valid_split_point) + # Они будут использовать self._vowels, self.hyphen_char и т.д. # Проверка гласных букв - def is_vow(let: str) -> bool: - return let.upper() in ['А', 'О', 'И', 'Е', 'Ё', 'Э', 'Ы', 'У', 'Ю', 'Я'] + def _is_vow(self, char: str) -> bool: + return char.upper() in self._vowels + # Проверка согласных букв - def is_cons(let: str) -> bool: - return let.upper() in ['Б', 'В', 'Г', 'Д', 'Ж', 'З', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', 'Ц', - 'Ч', 'Ш', 'Щ'] + def _is_cons(self, char: str) -> bool: + return char.upper() in self._consonants - # Поиск допустимой позиции для переноса около заданного индекса - def find_hyphen_point(word: str, start_idx: int) -> int: - vow_indices = [i for i in range(len(word)) if is_vow(word[i])] - if not vow_indices: - # Если в слове нет гласных, то перенос невозможен - return -1 - # Ищем ближайшую гласную до или после start_idx - for i in vow_indices: - if i >= start_idx - 2 and i + 2 < len(word): # Проверяем, что после гласной есть минимум 2 символа - ind = i + 1 - if (is_cons(word[ind]) or word[ind] in 'йЙ') and not is_vow(word[ind + 1]): - # Й -- полугласная. Перенос после неё только в случае, если дальше идет согласная - # (например, "бой-кий"), но запретить, если идет гласная (например, "ма-йка" не пройдет). - ind += 1 - if ind <= 3 or ind >= len(word) - 3: - # Не отделяем 3 символ с начала или конца (это некрасиво) - continue - if word[ind] in 'ьЬЪъ' or word[-1] in 'ьЬЪъ': - # Пропускаем мягкий/твердый знак. Согласно правилам русской типографики (например, ГОСТ 7.62-2008 - # или рекомендации по набору текста), перенос не должен разрывать слово так, чтобы мягкий или - # твердый знак оказывался в начале или конце строки - continue - return ind - return -1 # Не нашли подходящую позицию + # Проверка полугласной буквы "й" + def _is_j_sound(self, char: str) -> bool: + return char.upper() in self._j_sound_upper - # Рекурсивное деление слова - def split_word(word: str) -> str: - if len(word) <= max_chunk: # Если длина укладывается в лимит, перенос не нужен + + # Проверка мягкого/твердого знака + def _is_sign(self, char: str) -> bool: + return char.upper() in self._signs_upper + + + def hyphenation_in_word(self, word: str) -> str: + """ Расстановка переносов в русском слове с учетом максимальной длины непереносимой группы. + Переносы ставятся половинным делением слова, рекурсивно. + + :param word: Слово, в котором надо расставить переносы + :return: Слово с расставленными переносами + """ + + # Поиск допустимой позиции для переноса около заданного индекса + def find_hyphen_point(word_2find_point: str, start_idx: int) -> int: + vow_indices = [i for i, char_w in enumerate(word_2find_point) 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_2find_point): + # Проверяем, что после гласной есть минимум символов "хвоста" + ind = i + 1 + if (self._is_cons(word_2find_point[ind]) or self._is_j_sound(word_2find_point[ind])) and not self._is_vow(word_2find_point[ind + 1]): + # Й -- полугласная. Перенос после неё только в случае, если дальше идет согласная + # (например, "бой-кий"), но запретить, если идет гласная (например, "ма-йка" не переносится). + ind += 1 + if ind <= self.min_chars_per_part or ind >= len(word_2find_point) - self.min_chars_per_part: + # Не отделяем 3 символ с начала или конца (это некрасиво) + continue + if self._is_sign(word_2find_point[ind]) or self._is_sign(word_2find_point[-1]): + # Пропускаем мягкий/твердый знак. Согласно правилам русской типографики (например, ГОСТ 7.62-2008 + # или рекомендации по набору текста), перенос не должен разрывать слово так, чтобы мягкий или + # твердый знак оказывался в начале или конце строки + continue + return ind + return -1 # Не нашли подходящую позицию + + # Рекурсивное деление слова + def split_word(word_to_hyphenation: str) -> str: + if len(word_to_hyphenation) <= self.max_len_hyphenation__not_required: # Если длина укладывается в лимит, перенос не нужен + return word_to_hyphenation + + hyphen_idx = find_hyphen_point(word_to_hyphenation, len(word_to_hyphenation) // 2) # Ищем точку переноса около середины + + if hyphen_idx == -1: # Если не нашли точку переноса + return word_to_hyphenation + + left_part = word_to_hyphenation[:hyphen_idx] + right_part = word_to_hyphenation[hyphen_idx:] + + # Рекурсивно делим левую и правую части + return split_word(left_part) + "-­" + split_word(right_part) + + # Основная логика + if len(word) <= self.max_len_hyphenation__not_required or not any(self._is_vow(c) for c in word): + # Короткое слово или без гласных "делению не подлежит", выходим из рекурсии return word - - mid = len(word) // 2 # Середина слова - hyphen_idx = find_hyphen_point(word, mid) # Ищем точку переноса около середины - - if hyphen_idx == -1: # Если не нашли точку переноса - return word - - left_part = word[:hyphen_idx] - right_part = word[hyphen_idx:] - - # Рекурсивно делим левую и правую части - return split_word(left_part) + sep + split_word(right_part) - - # Основная логика - if len(s) <= max_chunk or not any(is_vow(c) for c in s): - # Короткое слово или без гласных - return s - - return split_word(s) + return split_word(word) # Рекурсивно делим слово на части с переносами -def hyphenation_in_text(text: str, min_len_word_hyphenation: int = 14, sep: str = "") -> str: - """ Расстановка переносов в тексте + def apply(self, text: str) -> str: + """ Расстановка переносов в тексте + + :param text: Строка, которую надо обработать (главный аргумент). + :return: str: + """ + rus_worlds = regex.findall(r'\b[а-яА-Я]+\b', text) # ищем все русскоязычные слова в тексте + for word in rus_worlds: + if len(word) > self.max_len_hyphenation__not_required: + hyphenated_word = self.hyphenation_in_word(word) + print(f'{word} -> {hyphenated_word}') + text = text.replace(word, hyphenated_word) + return text + + +# --- Основной класс Typographer --- +class Typographer: + def __init__(self, + langs: str | list[str] | tuple[str, ...] | frozenset[str] = 'ru', + code_out: str = 'mnemo', + hyphenation_rule: HyphenationRule | None = None, # Перенос слов и параметры расстановки переносов + # glue_prepositions_rule: GluePrepositionsRule | None = None, # Для других правил + # ... другие модули правил ... + ): + + # --- Обработка и валидация параметра langs --- + # Параметр langs может быть строкой, списком или кортежем ("ru+en", ["ru", "en"] или ("ru", "en")) + # Для удобств в классе буду использовать только frozenset, чтобы не дублировать коды языков + self.langs = set() + if isinstance(langs, str): + # Разделяем строку по любым небуквенным символам, приводим к нижнему регистру + # и фильтруем пустые строки, которые могут появиться, если разделители идут подряд + lang_codes = [lang.lower() for lang in regex.split(r'[^a-zA-Z]+', langs) if lang] + elif isinstance(langs, (list, tuple)): + lang_codes = [str(lang).lower() for lang in langs] # Приводим к строке и нижнему регистру + else: + raise TypeError(f"etpgrf: 'langs' parameter must be a string, list, or tuple. Got {type(langs)}") + if not lang_codes: + raise ValueError("etpgrf: 'langs' parameter cannot be empty or result in an empty list of languages after parsing.") + for code in lang_codes: + if code not in LANGS: # LANGS = frozenset(['ru', 'en']) + raise ValueError(f"etpgrf: langs code '{code}' is not supported. Supported languages: {list(LANGS)}") + self.langs.add(code) + self.langs: frozenset[str] = frozenset(self.langs) + + # --- Обработка и валидация параметра code_out --- + if code_out not in MNEMO_CODE | UTF: + raise ValueError(f"etpgrf: code_out '{code_out}' is not supported. Supported codes: {MNEMO_CODE | UTF}") + + # Сохраняем переданные модули правил + self.hyphenation_rule = hyphenation_rule + + # TODO: вынести все соответствия UTF ⇄ MNEMO_CODE в отдельный класс + # self.hyphen_char = "­" if code_out in UTF else "­" # Мягкий перенос по умолчанию + + # Конвейер для обработки текста + def process(self, text: str) -> str: + processed_text = text + if self.hyphenation_rule: + # Передаем активные языки и символ переноса, если модуль HyphenationRule + # не получает их в своем __init__ напрямую от пользователя, + # а конструируется с настройками по умолчанию, а потом конфигурируется. + # В нашем примере HyphenationRule уже получает их в __init__. + processed_text = self.hyphenation_rule.apply(processed_text) + + # if self.glue_prepositions_rule: + # processed_text = self.glue_prepositions_rule.apply(processed_text, non_breaking_space_char=self._get_nbsp()) + + # ... вызовы других активных модулей правил ... + return processed_text + + # def _get_nbsp(self): # Пример получения неразрывного пробела + # return "\u00A0" if self.code_out in UTF else " " - :param text: Строка, которую надо обработать (главный аргумент). - :param min_len_word_hyphenation: Минимальная длина слова для расстановки переносов. - :param sep: Символ переноса. - :return: str: - """ - rus_worlds = regex.findall(r'\b[а-яА-Я]+\b', text) # ищем все русскоязычные слова в тексте - rus_worlds = list(set(rus_worlds)) # убираем повторяющиеся слова - for word in rus_worlds: - if len(word) > min_len_word_hyphenation: - hyphenated_word = hyphenation_in_word(word, max_chunk=6, sep=sep) - print(f'{word} -> {hyphenated_word}') - text = text.replace(word, hyphenated_word) - return text diff --git a/main.py b/main.py index 4e0869e..1535c35 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,17 @@ import etpgrf +from etpgrf.hyphenation import HyphenationRule, Typographer if __name__ == '__main__': - text_in = 'Привет, World! Это тестовый текст для проверки расстановки переносов в словах. Миллион 1000000' - result = etpgrf.hyphenation.hyphenation_in_text(text_in, min_len_word_hyphenation=8, sep='-') - print(result) + # --- Пример использования --- + print("\n--- Пример использования класса---\n") + # Определяем пользовательские правила переносов + hyphen_settings = HyphenationRule(langs=frozenset(['ru']), max_len_hyphenation_not_required=8) + # Определяем пользовательские правила типографа + typo = Typographer(langs='ru', code_out='utf-8', hyphenation_rule=hyphen_settings) + + result = typo.process(text="Какой-то длинный текст для проверки переносов. Перпердикюляция!") + print(result, "\n\n") + result = typo.process(text="Привет, World! Это тестовый текст для проверки расстановки переносов в словах. Миллион 100-метровошеих жирножирафов.") + print(result, "\n\n")