2025-etpgrf/etpgrf/hyphenation.py

325 lines
24 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# etpgrf/hyphenation.py
# Представленные здесь алгоритмы реализуют упрощенные правила. Но эти правила лучше, чем их полное отсутствие.
# Тем более что пользователь может отключить переносы из типографа.
# Для русского языка правила реализованы лучше. Для английского дают "разумные" переносы во многих случаях, но из-за
# большого числа беззвучных согласных и их сочетаний, могут давать не совсем корректный результат.
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, is_inside_unbreakable_segment
_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'])
_EN_SUFFIXES_WITHOUT_HYPHENATION_UPPER = frozenset([
"ATION", "ITION", "UTION", "OSITY", # 5-символьные, типа: creation, position, solution, generosity
"ABLE", "IBLE", "MENT", "NESS", # 4-символьные, типа: readable, visible, development, kindness
"LESS", "SHIP", "HOOD", "TIVE", # fearless, friendship, childhood, active (спорно)
"SION", "TION", # decision, action (часто покрываются C-C или V-C-V)
# "ING", "ED", "ER", "EST", "LY" # совсем короткие, но распространенные, не рассматриваем.
])
_EN_UNBREAKABLE_X_GRAPHS_UPPER = frozenset(["SH", "CH", "TH", "PH", "WH", "CK", "NG", "AW", # диграфы с согласными
"TCH", "DGE", "IGH", # триграфы
"EIGH", "OUGH"]) # квадрографы
# --- Настройки логирования ---
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
if self.min_chars_per_part < 2:
# Минимальная длина хвоста должна быть >= 2, иначе вылезаем за индекс в английских словах
raise ValueError(f"etpgrf: минимальная длина хвоста (min_tail_len) должна быть >= 2,"
f" а не {self.min_chars_per_part}")
if self.max_unhyphenated_len <= self.min_chars_per_part:
# Максимальная длина непереносимой группы должна быть больше минимальной длины хвоста
raise ValueError(f"etpgrf: максимальная длина непереносимой группы (max_unhyphenated_len) "
f"должна быть больше минимальной длины хвоста (min_tail_len), "
f"а не {self.max_unhyphenated_len} >= {self.min_chars_per_part}")
# Внутренние языковые ресурсы, если нужны специфично для переносов
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
# 1. Не отделяем "хвостов" с начала или конца (это некрасиво)
if ind <= self.min_chars_per_part or ind >= len(word_segment) - self.min_chars_per_part:
continue
# 2. Пропускаем мягкий/твердый знак, если перенос начинается или заканчивается
# на них (правило из ГОСТ 7.62-2008)
if self._is_sign(word_segment[ind]) or (ind > 0 and self._is_sign(word_segment[ind-1])):
continue
# 3. Провека на `Й` (полугласная). Перенос после неё только в случае, если дальше идет
# согласная (например, "бой-кий"), но запретить, если идет гласная (например,
# "ма-йка" не переносится).
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
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")
# --- Начало логики для английского языка (заглушка) ---
# ПРИМЕЧАНИЕ: правила переноса в английском языке основаны на слогах, и их точное определение без словаря
# слогов или сложного алгоритма (вроде Knuth-Liang) — непростая задача. Здесь реализована упрощенная
# логика и поиск потенциальных точек переноса основан на простых правилах: между согласными, или между
# гласной и согласной. Метод половинного деления и рекурсии (поиск переносов о середины слова).
# Функция для поиска допустимой позиции для переноса около заданного индекса
# Ищет точку переноса, соблюдая min_chars_per_part и простые правила
def find_hyphen_point_en(word_segment: str, start_idx: int) -> int:
word_len = len(word_segment)
min_part = self.min_chars_per_part
# Определяем диапазон допустимых индексов для переноса
# Индекс 'i' - это точка разреза. word_segment[:i] и word_segment[i:] должны быть не короче min_part.
# i >= min_part
# word_len - i >= min_part => i <= word_len - min_part
valid_split_indices = [i for i in range(min_part, word_len - min_part + 1)]
if not valid_split_indices:
# Нет ни одного места, где можно поставить перенос, соблюдая min_part
logger.debug(f"No valid split indices for '{word_segment}' within min_part={min_part}")
return -1
# Сортируем допустимые индексы по удаленности от start_idx (середины)
# Это реализует поиск "около центра"
valid_split_indices.sort(key=lambda i: abs(i - start_idx))
# Проверяем каждый потенциальный индекс переноса по упрощенным правилам
for i in valid_split_indices:
# Упрощенные правила английского переноса (основаны на частых паттернах, не на слогах):
# 1. Запрет переноса между гласными
if self._is_vow(word_segment[i - 1]) and self._is_vow(word_segment[i]):
logger.debug(
f"Skipping V-V split point at index {i} in '{word_segment}' ({word_segment[i - 1]}{word_segment[i]})")
continue # Переходим к следующему кандидату i
# 2. Запрет переноса ВНУТРИ неразрывных диграфов/триграфов и т.д.
if is_inside_unbreakable_segment(word_segment=word_segment,
split_index=i,
unbreakable_set=_EN_UNBREAKABLE_X_GRAPHS_UPPER):
logger.debug(f"Skipping unbreakable segment at index {i} in '{word_segment}'")
continue
# 3. Перенос между двумя согласными (C-C), например, 'but-ter', 'subjec-tive'
# Точка переноса - индекс i. Проверяем символы word[i-1] и word[i].
if self._is_cons(word_segment[i - 1]) and self._is_cons(word_segment[i]):
logger.debug(f"Found C-C split point at index {i} in '{word_segment}'")
return i
# 4. Перенос перед одиночной согласной между двумя гласными (V-C-V), например, 'ho-tel', 'ba-by'
# Точка переноса - индекс i (перед согласной). Проверяем word[i-1], word[i], word[i+1].
# Требуется как минимум 3 символа для этого паттерна.
if i < word_len - 1 and \
self._is_vow(word_segment[i - 1]) and self._is_cons(word_segment[i]) and self._is_vow(
word_segment[i + 1]):
logger.debug(f"Found V-C-V (split before C) split point at index {i} in '{word_segment}'")
return i
# 5. Перенос после одиночной согласной между двумя гласными (V-C-V), например, 'riv-er', 'fin-ish'
# Точка переноса - индекс i (после согласной). Проверяем word[i-2], word[i-1], word[i].
# Требуется как минимум 3 символа для этого паттерна.
if i < word_len and \
self._is_vow(word_segment[i - 2]) and self._is_cons(word_segment[i - 1]) and \
self._is_vow(word_segment[i]):
logger.debug(f"Found V-C-V (split after C) split point at index {i} in '{word_segment}'")
return i
# 6. Правила для распространенных суффиксов (перенос ПЕРЕД суффиксом)
if word_segment[i:].upper() in _EN_SUFFIXES_WITHOUT_HYPHENATION_UPPER:
logger.debug(f"Found suffix '-{word_segment[i:]}' split point at index {i} in '{word_segment}'")
return i
# Если ни одна подходящая точка переноса не найдена в допустимом диапазоне
logger.debug(f"No suitable hyphen point found for '{word_segment}' near center.")
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, len(word_to_split) // 2)
# Если подходящая точка переноса не найдена, возвращаем часть слова как есть
if hyphen_idx == -1:
return word_to_split
# Рекурсивно обрабатываем обе части и объединяем их символом переноса
return (split_word_en(word_to_split[:hyphen_idx]) +
self._split_code + split_word_en(word_to_split[hyphen_idx:]))
# --- Конец логики для английского языка ---
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