From 96fa73e43d5996ac271285bcae3f4ea71d4b3b35 Mon Sep 17 00:00:00 2001 From: erjemin Date: Mon, 19 May 2025 20:42:49 +0300 Subject: [PATCH] =?UTF-8?q?add:=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D0=B4=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=BC=D1=8B/=D1=82=D1=80=D0=B8=D0=B3=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=BC=D1=8B,=D0=BA=D0=B2=D0=B0=D0=B4=D1=80=D0=BE=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=BC=D1=8B=20=D0=B2=20=D0=B0=D0=BD=D0=B3=D0=BB?= =?UTF-8?q?=D0=B8=D0=B9=D1=81=D0=BA=D0=B8=D1=85=20=20=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 ++++++++++++++++ etpgrf/comutil.py | 50 ++++++++++++++++++++++++++++++++++++- etpgrf/hyphenation.py | 47 +++++++++++++++++++++++++++-------- main.py | 58 +++++++++++++++++++++++++++++++------------ 4 files changed, 149 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d2ce4c2..70ab73e 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,25 @@ Экранная типографика для веба -- способствует повышению читабельности текста в интернете, приближая его к печатной типографике. + + + ### Переносы слов + +В основе переносов слов лежит фонетический принцип — деление по слогам. И этот принцип типограф решает. + +Однако регение не идеально, т.к. на эту основу накладывается множество других факторов: + +1. Морфологический принцип -- переносы стараются не разрывать значащие части слова (морфемы: приставки, корни, суффиксы, окончания). +2. Устоявшиеся практики (традиция) -- Для многих слов существуют традиционные, общепринятые варианты переноса, которые могут не всегда строго следовать фонетическим или морфологическим правилам. Эти традиции формировались десятилетиями и закреплены в словарях и справочниках по типографике. +3. Удобочитаемость и эстетика -- иногда правила переноса корректируются для улучшения внешнего вида текста и легкости чтения. Например, стараются не оставлять или переносить слишком короткие части слова (в типографе-etpgrf, это решается с помощью настроек по умолчанию: + * `defaults.etpgrf_settings.hyphenation.MAX_UNHYPHENATED_LEN` -- длинна части слова не подлежащего переносам; + * `defaults.etpgrf_settings.hyphenation.MIN_TAIL_LEN` -- длинна части слова которое недопустимо переносить или оставлять на строке ("хвост", "сироты"). +4. Избегают переносов, создающих неблагозвучные или двусмысленные сочетания букв на стыке строк. +5. А еще, любые правила, особенно в языке, имеют исключения и существуют слова, которые переносятся вопреки общим закономерностям. + +Etpgrf -- работает только на эвристических правилах, основанных на буквенных паттернах. Этого недостаточно, особенно для английского языка. Возможные пути улучшения (TODO): +* Использование словарей переносов (hyphenation dictionaries): Это наиболее точный подход. Существуют готовые словари (часто в формате, используемом TeX), где для каждого слова указаны возможные точки переноса. Интеграция такого словаря — сложная, но самая надежная задача. +* Алгоритмы на основе паттернов (например, алгоритм Франклина Ляна, используемый в TeX): Эти алгоритмы не хранят все слова, а используют набор паттернов и их "весов" для определения оптимальных точек переноса. Они очень эффективны и дают высокое качество, но их реализация или адаптация — нетривиальная задача. +* Гибридный подход: Комбинация эвристических правил для простых случаев и обращение к словарю или более сложному алгоритму для неоднозначных или длинных слов. +* Расширение набора эвристических правил: Можно продолжать добавлять более специфичные правила (например, для диграфов, триграфов, более сложных приставок и суффиксов), но это путь с убывающей отдачей — правил становится много, они могут конфликтовать, а покрытие всех случаев все равно не гарантировано. +* Учет морфологии: Более глубокий анализ морфемной структуры слова. \ No newline at end of file diff --git a/etpgrf/comutil.py b/etpgrf/comutil.py index 507a6b5..3aa41b4 100644 --- a/etpgrf/comutil.py +++ b/etpgrf/comutil.py @@ -3,6 +3,10 @@ from etpgrf.config import MODE_UNICODE, MODE_MNEMONIC, MODE_MIXED, SUPPORTED_LAN from etpgrf.defaults import etpgrf_settings import os import regex +import logging + +# --- Настройки логирования --- +logger = logging.getLogger(__name__) def parse_and_validate_mode( @@ -89,4 +93,48 @@ def parse_and_validate_langs( if not validated_langs_set: raise ValueError("etpgrf: не предоставлено ни одного валидного кода языка.") - return frozenset(validated_langs_set) \ No newline at end of file + return frozenset(validated_langs_set) + + +def is_inside_unbreakable_segment( + word_segment: str, + split_index: int, + unbreakable_set: frozenset[str] | list[str] | set[str], +) -> bool: + """ + Проверяет, находится ли позиция разбиения внутри неразрывного сегмента. + + :param word_segment: -- Сегмент слова, в котором мы ищем позицию разбиения. + :param split_index: -- Индекс (позиция внутри сегмента), по которому мы хотим проверить разбиение. + :param unbreakable_set: -- Набор неразрывных сегментов (например: диграфы, триграфы, акронимы...). + :return: + """ + segment_len = len(word_segment) + # Проверяем, что позиция разбиения не выходит за границы сегмента + if not (0 < split_index < segment_len): + return False + # Пер образуем все в верхний регистр, чтобы сравнения строк работали + word_segment_upper = word_segment.upper() + # unbreakable_set_upper = (unit.upper() for unit in unbreakable_set) # <-- С помощью генератора + + # Отсортируем unbreakable_set по длине лексем (чем короче, тем больше шансов на "ранний выход") + # и заодно превратим в list + sorted_units = sorted(unbreakable_set, key=len) + # sorted_units = sorted(unbreakable_set_upper, key=len) + for unbreakable in sorted_units: + unit_len = len(unbreakable) + if unit_len < 2: + continue + # Спорно, что преобразование в верхний регистр эффективнее делать тут, но благодаря возможному + # "раннему выходу" это может быть быстрее с помощью генератора (см. выше комментарии) + unbreakable_upper = unbreakable.upper() + for offset in range(1, unit_len): + position_start_in_segment = split_index - offset + position_end_in_segment = position_start_in_segment + unit_len + # Убедимся, что предполагаемое положение 'unit' не выходит за границы word_segment + if position_start_in_segment >= 0 and position_end_in_segment <= segment_len and \ + word_segment_upper[position_start_in_segment:position_end_in_segment] == unbreakable_upper: + # Нашли 'unbreakable', и split_index находится внутри него. + return True + return False + diff --git a/etpgrf/hyphenation.py b/etpgrf/hyphenation.py index 33aaed8..f9c65de 100755 --- a/etpgrf/hyphenation.py +++ b/etpgrf/hyphenation.py @@ -1,8 +1,14 @@ +# 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 +from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs, is_inside_unbreakable_segment _RU_VOWELS_UPPER = frozenset(['А', 'О', 'И', 'Е', 'Ё', 'Э', 'Ы', 'У', 'Ю', 'Я']) _RU_CONSONANTS_UPPER = frozenset(['Б', 'В', 'Г', 'Д', 'Ж', 'З', 'К', 'Л', 'М', 'Н', 'П', 'Р', 'С', 'Т', 'Ф', 'Х', @@ -25,6 +31,10 @@ _EN_SUFFIXES_WITHOUT_HYPHENATION_UPPER = frozenset([ "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__) @@ -143,16 +153,18 @@ class Hyphenator: 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 + # 1. Не отделяем "хвостов" с начала или конца (это некрасиво) if ind <= self.min_chars_per_part or ind >= len(word_segment) - self.min_chars_per_part: - # Не отделяем 3 символ с начала или конца (это некрасиво) continue + # 2. Пропускаем мягкий/твердый знак, если перенос начинается или заканчивается + # на них (правило из ГОСТ 7.62-2008) if self._is_sign(word_segment[ind]) or (ind > 0 and self._is_sign(word_segment[ind-1])): - # Пропускаем мягкий/твердый знак, если перенос начинается или заканчивается на них (ГОСТ 7.62-2008) 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 # Не нашли подходящую позицию @@ -209,13 +221,26 @@ class Hyphenator: # Проверяем каждый потенциальный индекс переноса по упрощенным правилам for i in valid_split_indices: # Упрощенные правила английского переноса (основаны на частых паттернах, не на слогах): - # 1. Перенос между двумя согласными (C-C), например, 'but-ter', 'subjec-tive' + # 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 - # 2. Перенос перед одиночной согласной между двумя гласными (V-C-V), например, 'ho-tel', 'ba-by' + # 4. Перенос перед одиночной согласной между двумя гласными (V-C-V), например, 'ho-tel', 'ba-by' # Точка переноса - индекс i (перед согласной). Проверяем word[i-1], word[i], word[i+1]. # Требуется как минимум 3 символа для этого паттерна. if i < word_len - 1 and \ @@ -224,7 +249,7 @@ class Hyphenator: logger.debug(f"Found V-C-V (split before C) split point at index {i} in '{word_segment}'") return i - # 3. Перенос после одиночной согласной между двумя гласными (V-C-V), например, 'riv-er', 'fin-ish' + # 5. Перенос после одиночной согласной между двумя гласными (V-C-V), например, 'riv-er', 'fin-ish' # Точка переноса - индекс i (после согласной). Проверяем word[i-2], word[i-1], word[i]. # Требуется как минимум 3 символа для этого паттерна. if i < word_len and \ @@ -233,7 +258,7 @@ class Hyphenator: logger.debug(f"Found V-C-V (split after C) split point at index {i} in '{word_segment}'") return i - # 4. Правила для распространенных суффиксов (перенос ПЕРЕД суффиксом) + # 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 diff --git a/main.py b/main.py index de318f1..bf6624e 100644 --- a/main.py +++ b/main.py @@ -38,11 +38,7 @@ if __name__ == '__main__': " в словах. Миллион 100-метровошеих жирножирафов.") print(result, "\n\n") - # меняем настройки логирования - etpgrf.defaults.etpgrf_settings.logging_settings.LEVEL = logging.DEBUG - etpgrf.logger.update_etpgrf_log_level_from_settings() # Обновляем уровень логирования из настроек - etpgrf.defaults.etpgrf_settings.logging_settings.FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - etpgrf.logger.update_etpgrf_log_format_from_settings() # Обновляем формат логирования из настроек + # Меняем настройки по умолчанию для переносов etpgrf.defaults.etpgrf_settings.LANGS = "ru" @@ -63,19 +59,49 @@ if __name__ == '__main__': print(result, "\n-----\n\n-----") # Проверяем переносы в смешанном тексте (русский + английский) - typo_en = etpgrf.Typographer(langs='en', mode='mixed', hyphenation=True) etpgrf.defaults.etpgrf_settings.hyphenation.MAX_UNHYPHENATED_LEN = 6 - txt = ("As the sun set—casting long shadows across the meadow—a remarkably sophisticated individual pondered" - " life’s complexities. \"Is it possible,\" they wondered aloud, \"that such an inconsequential event" - " could hold deeper meaning?\n" + typo_en = etpgrf.Typographer(langs='en', mode='mixed', hyphenation=True) + txt = ("It was a chilly autumn afternoon when Anna finally received her custom-made KATEBLASH coat." + " “I can’t believe how perfectly it fits!” she exclaimed, wrapping the soft, woolen fabric tightly" + " around her shoulders.\n" "\n" - "Their notebook (a leather-bound relic from 1923) contained hastily scribbled observations:" - " \"Interdisciplinary collaboration requires mutual understanding—not just technical expertise.\"" - " Nearby, an unfinished cup of coffee sat atop a stack of papers titled \"The Phenomenology of" - " Subjective Experience in Modern Literature.\n" + "The coat - designed with unique check patterns and a detachable hood - was more than just a garment." + " It was a statement of style and comfort, crafted with care and precision. Anna remembered the" + " fitting session vividly: “The tailor said, ‘This coat will keep you style through even the coldest" + " things winter throws at you.’”\n" "\n" - "Suddenly, a deafening noise—like thunder, yet mechanical—echoed in the distance." - " \"What on Earth…?\" they muttered, peering through binoculars. Was it an airplane?" - " A construction vehicle? Or something entirely different?") + "Her friend Mark raised an eyebrow: “Only you would get a coat with such an elaborate" + " design - and those fancy oughtstanding stitches! Sounds like your coat has more personality" + " than some people I know!”\n" + "\n" + "As they walked down the street, Anna noticed how the coat’s tailored cut moved gracefully with her." + " The consideration of every detail - from the choice of fabric to the delicate embroidery - made it" + " clear that this was no ordinary coat.\n" + "\n" + "Later, over coffee, Anna joked, “I told the tailor, ‘Make it so I never want to take it off.’ " + "Looks like they succeeded!\n" + "\n" + "Mark nodded, “Well, with KATEBLASH, it’s not just about fashion - it’s about craftsmanship, comfort," + " and a little bit of magic.”") result = typo_en.process(text=txt) print(result, "\n\n") + + # Спасибо. Для английского текста, для проверки типографа, мне не хватает неразрывных диграфов-квадрографов -- sh, ch, th, ph, wh, ck, ng, aw, tch, dge, igh, eigh, ough и неразрывных суффиксов -- ation, ition, ution, osity, able, ible, ment, ness, less, ship, hood, tive, sion, tion в длинный словах (8 символов и более). и пусть тескт тоже будет про пальто KATEBLASH. Справишься?? + + # меняем настройки логирования + etpgrf.defaults.etpgrf_settings.logging_settings.LEVEL = logging.DEBUG + etpgrf.logger.update_etpgrf_log_level_from_settings() # Обновляем уровень логирования из настроек + # etpgrf.defaults.etpgrf_settings.logging_settings.FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + # etpgrf.logger.update_etpgrf_log_format_from_settings() # Обновляем формат логирования из настроек + # Определяем пользовательские правила переносов + hyphen_settings = etpgrf.Hyphenator(langs='en', max_unhyphenated_len=6) + + # Проверяем переносы в словах + result = hyphen_settings.hyp_in_text("oughtstanding") + print(result, "==\n\n") + result = hyphen_settings.hyp_in_text("blacksmithing") + print(result, "==\n\n") + result = hyphen_settings.hyp_in_text("dccadckpoooughremawgreen") + print(result, "==\n\n") + +