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")