From 465dd9e9e691e24468dc0daf956f51d292ec5319 Mon Sep 17 00:00:00 2001 From: erjemin Date: Thu, 19 Mar 2026 11:03:30 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B0=D0=BB=D0=B3=D0=BE=D1=80=D0=B8=D1=82=D0=BC=20?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D1=8F=D1=87=D0=B5=D0=B9=20=D0=BF=D1=83=D0=BD?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B2=D0=B5=D1=88=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F.=20?= =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D0=BB=D0=B5=D0=B2=D0=BE=D0=BC=20=D0=B2=D1=8B=D0=B2=D0=B5?= =?UTF-8?q?=D1=88=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 + README.md | 60 ++++++-- etpgrf/config.py | 14 +- etpgrf/hanging.py | 320 ++++++++++++++++++++++++++++++++++++++---- tests/test_hanging.py | 58 +++++++- 5 files changed, 399 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c58a740..df66e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.6] - 2024-03-19 +### Изменено +- Новый алгоритм "висячей пунктуации" (HangingPunctuationProcessor). Добавлены компенсирующие пробелы для висячих символов, чтобы избежать наложения на соседние слова. Теперь "висячие символы" (кавычки, тире) оборачиваются в `` вместе с ближайшим словом и пробелом, что обеспечивает корректное визуальное выравнивание внутри сторки без наложения. Режим `both` для одновременного вывешивания в обе стороны отключен из-за потенциальных конфликтов компенсирующих пробелов и проблем с выравниванием при использовании CSS text-justify. + + ## [0.1.5] - 2024-02-18 ### Исправлено - Исправлена ошибка, из-за которой `&` в исходном тексте некорректно преобразовывался в `&`. Теперь `&` и его варианты (`&amp;`, `&lt;`) сохраняются в итоговом HTML. diff --git a/README.md b/README.md index 9db2027..0be5210 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,9 @@ Safari), поэтому на него полагаться нельзя. Поэ сохранить расстояние до соседнего слова. Поэтому типограф оборачивает не только сам висячий символ, но и ближайшее слово (до пробела или границы узла), а также при необходимости, окружающий пробел. Сама визуальная компенсация оформляется через отрицательные `margin`/`padding` в CSS-классах — никаких `position:absolute`, чтобы не нарушать поток текста. +Учтите, что набор символов, попадающих в `HANGING_PUNCTUATION_SPACE_CHARS`, помимо обычного пробела включает табуляции, переводы +строки и множество тонких/математических пробелов. Именно поэтому компенсирующие обёртки иногда захватывают +символы на границе узлов или переносов и сохраняют корректный визуальный зазор. По умолчанию эта функция висячей типографики **отключена**. Чтобы её включить, нужно задать параметр `hanging_punctuation` при конфигурировании типографа (по умолчанию `hanging_punctuation=None`): @@ -332,33 +335,56 @@ typo = etpgrf.Typographer(hanging_punctuation='left') ### Как работает оборачивание Процессор висячей типографики запускается после всех текстовых преобразований и работает с деревом BeautifulSoup. Он ищет -последовательности «пробел + висячий символ» для левого выравнивания и «слово + висячий символ + пробел» для правого, +последовательности «пробел + висячий символ» для левого выравнивания и «висячий символ + пробел» для правого, чтобы обернуть нужные фрагменты в пары `` и не допустить «сиротства» символов. Порядок действий можно описать так: * Для `hanging_punctuation='left'`: * если символ стоит в начале текстового узла (без пробелов слева), оборачивается только сам символ и следующее слово (`«АукЫон»`); - * если перед символом внутри узла есть пробел, то пробел оборачивается в ` `, а - символ вместе со словом — в `...`; - * если пробел оказался в соседнем узле, то он тоже оборачивается в `etp-sp-*`, чтобы не нарушить последовательность; - * если компенсирующий пробел является "непереносимым пробелом" (или любым другим: шпацией, em-пробелом и т.п.), то тогда, для правильного выравнивания, оборачивается он, например: ` «АукЫон»`. + * если перед "висячим" символом внутри узла есть пробел, то пробел и слово слева от него оборачивается + в `слово ` (компенсирующий пробел), а сам "висячий" символ вместе со словом справа — + в `...`; + * если компенсирующий пробел оказался в соседнем узле (слева), то он тоже оборачивается в `etp-sp-*`, чтобы + не нарушить последовательность; + * если слева от "висячего" символа пробел является "неразрывным пробелом" (` `, нулевой неразрывный пробел, + узкий неразрывный пробел или любой "не пробельный" символ) — это означает, что "висячий" символ не может + "вывешиваться" в начале строки и оборачивания в `` не проиходит. * Для `hanging_punctuation='right'`: - * слово с висячим символом оборачивается в соответствующий класс (`.etp-raquo`, `.etp-rpar` и т.д.); - * пробел сразу после символа получает класс `etp-sp-raquo`, `etp-sp-rpar` и т.д., чтобы сохранить переносную ширину и - аккуратно компенсировать смещение; - + * слово с "висячим символом" и слово слева оборачивается в соответствующий класс (`.etp-raquo`, `.etp-rpar` и т.д.); + * пробел сразу после символа (справа) получает класс `etp-sp-raquo`, `etp-sp-rpar` и т.д., чтобы сохранить + переносную ширину и аккуратно компенсировать смещение; + * если компенсирующий пробел оказался в соседнем узле (справа), то он тоже оборачивается в `etp-sp-*`, чтобы + не нарушить последовательность; + * если справа от "висячего" символа пробел является "неразрывным пробелом" (` `, нулевой неразрывный пробел, + узкий неразрывный пробел или любой "не пробельный" символ) — это означает, что "висячий" символ не может + "вывешиваться" в конце строки и оборачивания в `` не проиходит. Пример вывода для `'left'`: ```html -Завтра концерт группы «АукЫон» +«Все обобщения опасны, включая это» (Дюма) + +Завтра концерт группы«Дайте танк» + +Если перед «висячим символом» стоит неразрывный пробел, он не может оказаться вначале строки. +``` + +Пример вывода для `'right'`: + +```html +Right “long quote” + +Отсутствие смещения «висячей пунктуации» внутри строки обеспечивает компенсирующий пробел справа от неё. + +Символ правой «висячей пунктуации» не может оказаться в конце строки, если за ним стоит неразрывный пробел. ``` ### CSS для висячих символов -Предлагаемый, начиная с etpgrf v0.1.6, CSS теперь работает только с `margin` и `padding`, без `position:absolute`. Пробелы получают собственные -классы, поэтому их компенсация контролируется отдельно, а не встроена в сам висячий символ. Убедитесь, что эти стили -подключены к странице и не конфликтуют с `text-justify`, который вытягивает пробелы по всей строке и разрушает аккуратное -выравнивание. +Предлагаемый, начиная с etpgrf v0.1.6, CSS теперь работает только с `margin` и `padding`, без `position:absolute`. +Компенсирующие пробелы получают собственные классы, поэтому их компенсация контролируется отдельно, а не встроена +в сам висячий символ. Убедитесь, что эти стили подключены к странице и не конфликтуют с `text-justify`, который +увеличивает пробелы между словами по всей строке, делают текст менее удобным для чтения и не пригодны +для выравнивания. ```css /* --- ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */ @@ -375,11 +401,17 @@ typo = etpgrf.Typographer(hanging_punctuation='left') /* --- ПРАВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */ .etp-raquo { padding-right: 0.44em; margin-left: -0.44em; } .etp-rdquo { padding-right: 0.4em; margin-left: -0.4em; } +.etp-r-comma { padding-right: 0.28em; margin-left: -0.28em; } +.etp-r-colon { padding-right: 0.32em; margin-left: -0.32em; } +.etp-r-dot { padding-right: 0.12em; margin-left: -0.12em; } .etp-rsquo { padding-right: 0.22em; margin-left: -0.22em; } .etp-rpar, .etp-rsqb, .etp-rcub { padding-right: 0.25em; margin-left: -0.25em; } /* компенсирующие пробелы для правых висячих символов */ .etp-sp-raquo { margin-left: -0.44em; } .etp-sp-rdquo { margin-left: -0.4em; } +.etp-sp-r-comma { margin-left: -0.28em; } +.etp-sp-r-colon { margin-left: -0.32em; } +.etp-sp-r-dot { margin-left: -0.12em; } .etp-sp-rsquo { margin-left: -0.22em; } .etp-sp-rpar, .etp-sp-rsqb, .etp-sp-rcub { margin-left: -0.25em; } ``` diff --git a/etpgrf/config.py b/etpgrf/config.py index db9c49d..b1d3e9c 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -55,6 +55,7 @@ CHAR_EN_QUOT1_OPEN = '“' CHAR_EN_QUOT1_CLOSE = '”' CHAR_EN_QUOT2_OPEN = '‘' CHAR_EN_QUOT2_CLOSE = '’' +CHAR_TIMES = '×' CHAR_COPY = '\u00a9' # Символ авторского права / © / © CHAR_REG = '\u00ae' # Зарегистрированная торговая марка / ® / ® CHAR_COPYP = '\u2117' # Знак звуковой записи / ℗ / ©p; @@ -210,9 +211,9 @@ CUSTOM_ENCODE_MAP = { # '\u007b': '{', # { / { / { # '\u007d': '}', # } / } / } # '\u007c': '|', # | / | / | / | - CHAR_NBSP: ' ', # /   /   - CHAR_REG: '®', # ® / ® / ® / ® - CHAR_COPY: '©', # © / © / © + CHAR_NBSP: ' ', # /   /   + CHAR_REG: '®', # ® / ® / ® / ® + CHAR_COPY: '©', # © / © / © '\u0022': '"', # " / " / " '\u0026': '&', # & / & / & '\u003e': '>', # > / > / > @@ -693,6 +694,7 @@ DEFAULT_POST_UNITS = [ 'рад', 'К', '°C', '°F', '%', 'мкм', 'нм', 'А°', 'эВ', 'Дж', 'кДж', 'МДж', 'пкФ', 'нФ', 'мкФ', 'мФ', 'Ф', 'Гн', 'мГн', 'мкГн', 'Тл', 'Гс', 'эрг', 'бод', 'бит', 'байт', 'Кб', 'Мб', 'Гб', 'Тб', 'Пб', 'Эб', 'кал', 'ккал', # Английские + 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'byte', 'uF', 'pF', 'W', 'A', 'V', 'ohm', # --- Издательское дело --- 'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed', 'rev', 'dpi', # --- Имперские и американские единицы --- @@ -700,11 +702,11 @@ DEFAULT_POST_UNITS = [ ] # Пред-позиционные (№ 5, $ 10) DEFAULT_PRE_UNITS = ['№', '$', '€', '£', '₽', '#', '§', '¤', '₴', '₿', '₺', '₦', '₩', '₪', '₫', '₲', '₡', '₵', - 'ГОСТ', 'ТУ', 'ИСО', 'DIN', 'ASTM', 'EN', 'IEC', 'IEEE'] # технические стандарты перед числом работают как единицы измерения + 'ГОСТ', 'ТУ', 'ИСО', 'ISO', 'DIN', 'ASTM', 'EN', 'IEC', 'IEEE'] # технические стандарты перед числом работают как единицы измерения # Операторы, которые могут стоять между единицами измерения (км/ч) # Сложение и вычитание здесь намеренно отсутствуют. -UNIT_MATH_OPERATORS = ['/', '*', '×', CHAR_MIDDOT, '÷'] +UNIT_MATH_OPERATORS = ['/', '*', CHAR_TIMES, CHAR_MIDDOT, '÷'] # === КОНСТАНТЫ ДЛЯ ФИНАЛЬНЫХ СОКРАЩЕНИЙ === # Эти сокращения (обычно в конце фразы) будут "склеены" тонкой шпацией, а перед ними будет поставлен неразрывный пробел. @@ -734,7 +736,7 @@ HANGING_PUNCTUATION_MODES = frozenset([ HANGING_PUNCTUATION_MODE_RIGHT, ]) -# Пробелы ( символы-ищейки) которые могут использоваться как разделители "компенсационных сдвигов" для висячей пунктуации. +# Пробелы (символы-ищейки) которые могут использоваться как разделители "компенсационных сдвигов" для висячей пунктуации. # Их соседство с висячими символами позволяет "компенсировать" их смещение относительно прилегающего символа. HANGING_PUNCTUATION_SPACE_CHARS = frozenset([ ' ', # обычный пробел diff --git a/etpgrf/hanging.py b/etpgrf/hanging.py index c345ee1..93eaab2 100644 --- a/etpgrf/hanging.py +++ b/etpgrf/hanging.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) class HangingPunctuationProcessor: """ - Оборачивает символы висячей пунктуации в специальные теги с классами. + Оборачивает символы висячей пунктуации в теги с классами. """ def __init__(self, mode: str | bool | list[str] | None = None): @@ -87,14 +87,17 @@ class HangingPunctuationProcessor: def _process_node_recursive(self, node, soup): """Рекурсивно обходит дерево HTML-узлов. - :param node: текущий узел, внутри которого ищем висячие символы. - :param soup: корневой объект soup, нужный для создания новых тегов. + :param node: Текущий узел, внутри которого ищем висячие символы. + :param soup: Корневой объект soup, нужный для создания новых тегов. :return: None — модификации происходят "на месте". """ # Работаем с копией списка детей, так как будем менять структуру дерева на лету # (replace_with меняет дерево) if hasattr(node, 'children'): for child in list(node.children): + if getattr(child, 'parent', None) is None: + # Узел уже был заменён/удалён при обработке соседей + continue if isinstance(child, NavigableString): # Обрабатываем текстовые узлы отдельно согласно выбранному режиму self._process_text_node(child, soup) @@ -106,18 +109,26 @@ class HangingPunctuationProcessor: # Не заходим внутрь тегов, которые мы сами же и создали (или аналогичных), # чтобы избежать рекурсивного ада, хотя классы у нас специфичные. self._process_node_recursive(child, soup) + if self.direction == HANGING_PUNCTUATION_MODE_RIGHT: + last_right = self._find_last_right_char_in_node(child) + if last_right: + self._wrap_parent_space_after_child(child, last_right, soup) def _process_text_node(self, text_node: NavigableString, soup: BeautifulSoup): """Обрабатывает текстовый узел в зависимости от направления висячей пунктуации. - :param text_node: текстовый узел BeautifulSoup, содержащий собранный текст. - :param soup: объект парсера для создания новых тегов. + :param text_node: Текстовый узел BeautifulSoup, содержащий собранный текст. + :param soup: Объект парсера для создания новых тегов. :return: None — текст заменяется на набор узлов/тегов. """ if self.direction == HANGING_PUNCTUATION_MODE_LEFT: self._process_text_node_left_mode(text_node, soup) return + if self.direction == HANGING_PUNCTUATION_MODE_RIGHT: + self._process_text_node_right_mode(text_node, soup) + return + text = str(text_node) # Быстрая проверка: если в тексте вообще нет ни одного нашего символа, выходим @@ -177,8 +188,8 @@ class HangingPunctuationProcessor: Пробегает по тексту, захватывая слово и оборачивая его вместе с левой кавычкой, добавляя компенсационные пробелы, когда они есть. - :param text_node: текстовый узел, где устанавливаются span-ы. - :param soup: парсер для создания span-обёрток. + :param text_node: Текстовый узел, где устанавливаются span-ы. + :param soup: Парсер для создания span-обёрток. :return: None — изменяется дерево DOM. """ text = str(text_node) @@ -199,6 +210,12 @@ class HangingPunctuationProcessor: i += 1 continue + prev_char = self._peek_previous_char_before_text_node(text_node, i) + if prev_char and self._is_word_char(prev_char): + # Если перед левым символом идёт буква/цифра/подчёркивание — это не висячий символ внутри слова + i += 1 + continue + if i > 0 and text[i-1] in cancellation_chars: # Если перед символом стоит запрещённый неразрывной пробел — пропускаем i += 1 @@ -249,12 +266,12 @@ class HangingPunctuationProcessor: boundary_chars: frozenset[str], cancellation_chars: frozenset[str]) -> tuple[int, int] | None: """Находит диапазон (слово + пробел), который нужно обернуть перед левым символом. - :param text: строка, из которой извлекаются границы. - :param left_idx: индекс левой кавычки. - :param last_idx: последний обработанный индекс — не повторяем участки. - :param boundary_chars: набор символов, разрешённых для разрывов. - :param cancellation_chars: символы, отменяющие висячие привязки. - :return: кортеж (start, end) или None, если приращение не нужно. + :param text: Строка, из которой извлекаются границы. + :param left_idx: Индекс левой кавычки. + :param last_idx: Последний обработанный индекс — не повторяем участки. + :param boundary_chars: Набор символов, разрешённых для разрывов. + :param cancellation_chars: Символы, отменяющие висячие привязки. + :return: Кортеж (start, end) или None, если приращение не нужно. """ if left_idx == 0: return None @@ -277,8 +294,8 @@ class HangingPunctuationProcessor: def _find_first_left_char_in_node(self, node: Tag) -> str | None: """Ищет первый символ левого режима во вложенном поддереве. - :param node: тег, внутри которого итерируем по потомкам. - :return: символ из `left_chars` или None. + :param node: Тег, внутри которого итерируем по потомкам. + :return: Символ из `left_chars` или None. """ for descendant in node.descendants: if isinstance(descendant, NavigableString): @@ -293,9 +310,9 @@ class HangingPunctuationProcessor: def _wrap_parent_space_before_child(self, child: Tag, first_left_char: str, soup: BeautifulSoup) -> bool: """Оборачивает слово + пробел перед дочерним узлом у родителя. - :param child: дочерний тег, который начал с висячего символа. - :param first_left_char: символ, наложивший необходимость обёртки. - :param soup: объект парсера для span-ов. + :param child: Дочерний тег, который начал с висячего символа. + :param first_left_char: Символ, наложивший необходимость обёртки. + :param soup: Объект парсера для span-ов. :return: True, если обёртка добавлена; False — иначе. """ prev_text_node = self._find_previous_navigable_text(child) @@ -328,8 +345,8 @@ class HangingPunctuationProcessor: def _extract_trailing_compensation_fragment(self, text: str) -> tuple[int, int] | None: """Извлекает диапазон слова с последним пробелом перед дочерним узлом. - :param text: текущий текст узла, последний символ которого — потенциальный пробел. - :return: кортеж (start, end) или None, если для компенсации нет подходящего фрагмента. + :param text: Текущий текст узла, последний символ которого — потенциальный пробел. + :return: Кортеж (start, end) или None, если для компенсации нет подходящего фрагмента. """ if not text: return None @@ -351,11 +368,7 @@ class HangingPunctuationProcessor: return (word_start, end) if word_start < end else None def _find_previous_navigable_text(self, child: Tag) -> NavigableString | None: - """Возвращает предыдущий текстовый узел, если он содержит непустой текст. - - :param child: узел, от которого ищем сиблинга. - :return: NavigableString или None. - """ + """Возвращает предыдущий текстовый узел, если он содержит непустой текст.""" prev = child.previous_sibling while prev: if isinstance(prev, NavigableString) and prev.strip(): @@ -364,12 +377,7 @@ class HangingPunctuationProcessor: return None def _replace_text_node(self, old_node: NavigableString, new_nodes: list[NavigableString | Tag]) -> None: - """Заменяет текстовый узел на последовательность новых узлов. - - :param old_node: Исходный NavigableString, подлежащий замене. - :param new_nodes: Список узлов, которые должны появиться на его месте. - :return: None. - """ + """Заменяет текстовый узел на последовательность новых узлов.""" if not new_nodes: old_node.extract() return @@ -380,3 +388,253 @@ class HangingPunctuationProcessor: current.insert_after(node) current = node + def _find_last_right_char_in_node(self, node: Tag) -> str | None: + """Ищет последний символ правого режима во вложенном поддереве.""" + last_char = None + for descendant in node.descendants: + if isinstance(descendant, NavigableString): + for char in str(descendant): + if char in self.right_chars: + last_char = char + return last_char + + def _wrap_parent_space_after_child(self, child: Tag, last_right_char: str, soup: BeautifulSoup) -> bool: + """Оборачивает пробел после дочернего узла у родителя.""" + next_text_node = self._find_next_navigable_text(child) + if not next_text_node: + return False + + fragment = self._extract_leading_compensation_fragment(str(next_text_node), 0, HANGING_PUNCTUATION_SPACE_CHARS) + if not fragment: + return False + + text_start, text_end = fragment + substring = str(next_text_node)[text_start:text_end] + tail = str(next_text_node)[text_end:] + css_class = self.space_classes.get(last_right_char) + if not css_class or not substring: + return False + + span = soup.new_tag('span') + span['class'] = css_class + span.string = substring + + new_nodes: list[NavigableString | Tag] = [span] + if tail: + new_nodes.append(NavigableString(tail)) + + self._replace_text_node(next_text_node, new_nodes) + # Если после обёртки остался текстовый хвост, догоним его обработкой правого режима. + if self.direction == HANGING_PUNCTUATION_MODE_RIGHT and new_nodes and isinstance(new_nodes[-1], NavigableString): + self._process_text_node(new_nodes[-1], soup) + return True + + def _extract_leading_compensation_fragment(self, text: str, start_idx: int, + boundary_chars: frozenset[str]) -> tuple[int, int] | None: + """Извлекает диапазон пробелов, начинающийся с заданного индекса.""" + if start_idx >= len(text) or text[start_idx] not in boundary_chars: + return None + + end = start_idx + while end < len(text) and text[end] in boundary_chars: + end += 1 + + return (start_idx, end) if end > start_idx else None + + def _find_next_navigable_text(self, child: Tag) -> NavigableString | None: + """Возвращает следующий текстовый узел, если он содержит непустой текст.""" + next = child.next_sibling + while next: + if isinstance(next, NavigableString) and next.strip(): + return next + next = next.next_sibling + return None + + + def _process_text_node_right_mode(self, text_node: NavigableString, soup: BeautifulSoup): + """Аналогичный левому алгоритм, но в правом направлении. + + :param text_node: Текстовый узел, содержащий возможную правую пунктуацию. + :param soup: Парсер для создания тегов. + :return: None. + """ + text = str(text_node) + if not any(char in self.right_chars for char in text): + return + + nodes: list[NavigableString | Tag] = [] + text_len = len(text) + boundary_chars = HANGING_PUNCTUATION_SPACE_CHARS + cancellation_chars = HANGING_CANCELLATION_SP + last_index = 0 + i = 0 + right_spans: list[tuple[Tag, str]] = [] + + while i < text_len: + char = text[i] + if char not in self.right_chars: + # Пропускаем нецелевые символы + i += 1 + continue + + if i > 0 and text[i-1] in cancellation_chars: + # Не трогаем коль символ окружён запретными символами + i += 1 + continue + + prev_char = self._peek_previous_char_before_text_node(text_node, i) + if prev_char in cancellation_chars: + # Учёт запретных символов даже если они находятся в соседнем узле слева от текущей позиции. + i += 1 + continue + + next_char = self._peek_next_char_after_text_node(text_node, i) + if next_char in cancellation_chars: + # Пропускаем висячий символ, если сразу после него идёт запретный символ в любом соседнем узле + i += 1 + continue + + if self._is_word_char(next_char): + # Не оборачиваем символы внутри слов/идентификаторов и цифр + i += 1 + continue + + # Находим диапазон слова, которое должно соединиться с текущей правой пунктуацией. + comp_bounds = self._locate_right_compensation_bounds(text, i, last_index, boundary_chars) + if not comp_bounds: + # Нет слова в текущем узле (граница узла или символ стоит первым) — оборачиваем сам символ + if i > last_index: + nodes.append(NavigableString(text[last_index:i])) + + solo_span = soup.new_tag('span') + solo_span['class'] = self.char_to_class.get(char, '') + solo_span.string = char + nodes.append(solo_span) + right_spans.append((solo_span, char)) + last_index = i + 1 + + boundary_fragment = self._extract_leading_compensation_fragment(text, last_index, boundary_chars) + if boundary_fragment: + space_start, space_end = boundary_fragment + space_span = soup.new_tag('span') + space_span['class'] = self.space_classes.get(char, '') + space_span.string = text[space_start:space_end] + nodes.append(space_span) + last_index = space_end + i = space_end + else: + i = last_index + continue + + span_start, span_mid, right_idx = comp_bounds + if span_start > last_index and text[span_start - 1] in self.left_chars: + # Захватываем прилегающую слева левую пунктуацию вместе со словом + span_start -= 1 + + if span_start > last_index: + # Вставляем текст между предыдущим фрагментом и текущим словом, чтобы ничего не потерять. + nodes.append(NavigableString(text[last_index:span_start])) + + right_span = soup.new_tag('span') + right_span['class'] = self.char_to_class.get(char, '') + # Объединяем слово перед правым символом и сам символ без промежуточных пробелов + right_span.string = text[span_start:span_mid] + text[right_idx:right_idx + 1] + nodes.append(right_span) + right_spans.append((right_span, char)) + last_index = right_idx + 1 + + # После символа оборачиваем пробелы-компенсаторы в отдельные span'ы + boundary_fragment = self._extract_leading_compensation_fragment(text, last_index, boundary_chars) + if boundary_fragment: + space_start, space_end = boundary_fragment + space_span = soup.new_tag('span') + space_span['class'] = self.space_classes.get(char, '') + space_span.string = text[space_start:space_end] + nodes.append(space_span) + last_index = space_end + i = space_end + else: + # Продолжаем сканирование сразу после обработанного правого символа. + i = last_index + + if last_index < text_len: + nodes.append(NavigableString(text[last_index:])) + + if nodes: + first = nodes[0] + text_node.replace_with(first) + current = first + for part in nodes[1:]: + current.insert_after(part) + current = part + for span_node, span_char in right_spans: + self._wrap_parent_space_after_child(span_node, span_char, soup) + + def _peek_next_char_after_text_node(self, text_node: NavigableString, idx: int) -> str | None: + """Ищет следующий символ текста, переходя через границы узлов.""" + node = text_node + offset = idx + 1 + while node: + if isinstance(node, NavigableString): + text = str(node) + if offset < len(text): + return text[offset] + offset = 0 + node = node.next_element + return None + + def _peek_previous_char_before_text_node(self, text_node: NavigableString, idx: int) -> str | None: + """Ищет предыдущий символ текста перед текущим индексом, переходя между узлами.""" + if idx > 0: + return str(text_node)[idx - 1] + + node = text_node.previous_element + while node: + if isinstance(node, NavigableString): + text = str(node) + if text: + return text[-1] + node = node.previous_element + return None + + def _is_word_char(self, char: str | None) -> bool: + """Возвращает True, если браузер не может разорвать строку именно по этому символу.""" + return bool(char) and char not in HANGING_PUNCTUATION_SPACE_CHARS + + def _locate_right_compensation_bounds(self, text: str, right_idx: int, last_idx: int, + boundary_chars: frozenset[str]) -> tuple[int, int, int] | None: + """Находит пределы слова перед правым символом и сам символ без учета пробелов. + + :param text: Строка, из которой извлекаются границы. + :param right_idx: Индекс правого символа (например, '»'). + :param last_idx: Последний обработанный индекс — чтобы не перекрывать уже взятые фрагменты. + :param boundary_chars: Набор символов, разрешённых для разделения слов. + :return: Кортеж (start_of_word, boundary_start, right_idx) или None, если нет подходящего слова. + """ + if right_idx <= last_idx: + return None + + prev_idx = right_idx - 1 + # Отбрасываем разделители между словом и символом, чтобы добраться до последнего слова. + while prev_idx >= last_idx and text[prev_idx] in boundary_chars: + prev_idx -= 1 + + if prev_idx < last_idx: + return None + + word_end = prev_idx + 1 + word_start = word_end + # Поднимаемся к началу слова, чтобы взять все символы перед правым знаком. + stopper = boundary_chars | (self.right_chars - {text[right_idx]}) + while word_start > last_idx and text[word_start - 1] not in stopper: + word_start -= 1 + + # Разрешаем захватить ведущий левый символ, если он стоит вплотную к слову. + while word_start > last_idx and text[word_start - 1] in self.left_chars: + word_start -= 1 + + if word_start == word_end: + # Если перед символом нет слова, ничего оборачивать не нужно. + return None + + return (word_start, word_end, right_idx) diff --git a/tests/test_hanging.py b/tests/test_hanging.py index 7f2e184..3e42b64 100644 --- a/tests/test_hanging.py +++ b/tests/test_hanging.py @@ -12,7 +12,7 @@ from etpgrf.config import ( CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP, CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP, CHAR_NULL_SP, CHAR_THIN_NBSP, CHAR_NBSP, - CHAR_ZWNJ + CHAR_ZWNJ, CHAR_PUNT_SP ) # Вспомогательная функция для создания soup @@ -37,6 +37,8 @@ HANGING_TEST_CASES = [ f'

А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста

'), ('left', f'

А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста

', f'

А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста

'), + ('left', f'

А вот{CHAR_NBSP}это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста

', + f'

А вот{CHAR_NBSP}это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста

'), # Английские кавычки "лапки" ('left', f'

This is some {CHAR_EN_QUOT1_OPEN}wisdom quote{CHAR_EN_QUOT1_CLOSE} for the test.

', f'

This is some {CHAR_EN_QUOT1_OPEN}wisdom quote{CHAR_EN_QUOT1_CLOSE} for the test.

'), @@ -53,6 +55,8 @@ HANGING_TEST_CASES = [ '

Висячая пунктуация оборачивает (Скобки)

'), ('left', '

Висячая пунктуация оборачивает (Круглые скобки) вот так.

', '

Висячая пунктуация оборачивает (Круглые скобки) вот так.

'), + ('left', '

Перечисли, (элемент списка)

', + '

Перечисли, (элемент списка)

'), # Примеры "квадратная скобка" ('left', '

[Скобки]

', '

[Скобки]

'), ('left', '

Висячая пунктуация оборачивает [Скобки]

', @@ -63,18 +67,58 @@ HANGING_TEST_CASES = [ ('left', '

{Скобки}

', '

{Скобки}

'), ('left', '

Висячая пунктуация оборачивает {Скобки}

', '

Висячая пунктуация оборачивает {Скобки}

'), - ('left', '

Висячая пунктуация оборачивает {Квадратные скобки} вот так.

', - '

Висячая пунктуация оборачивает {Квадратные скобки} вот так.

'), + ('left', '

Висячая пунктуация оборачивает {Фигурные скобки} вот так.

', + '

Висячая пунктуация оборачивает {Фигурные скобки} вот так.

'), # Обычный текст, в котором нет символов для висячей пунктуации, не должен изменяться ('left', '

Текст.

', '

Текст.

'), + ('left', '

Пароль `89(fg#_Wfgq0[89`

', '

Пароль `89(fg#_Wfgq0[89`

'), + # Проверка на альтернативный разрывной символ (не пробел) + ('left', '

Висячая пунктуация оборачивает\t{Скобки}

', + '

Висячая пунктуация оборачивает\t{Скобки}

'), + ('left', f'

Висячая пунктуация оборачивает{CHAR_SHY}{{Скобки}}

', + f'

Висячая пунктуация оборачивает{CHAR_SHY}{{Скобки}}

'), + ('left', f'

Висячая пунктуация оборачивает{CHAR_PUNT_SP}{{Скобки}}

', + f'

Висячая пунктуация оборачивает{CHAR_PUNT_SP}{{Скобки}}

'), + ('left', f'

Висячая пунктуация оборачивает\n{{Скобки}}

', + f'

Висячая пунктуация оборачивает\n{{Скобки}}

'), + ('left', f'

Висячая пунктуация обора{CHAR_SHY}чивает {{Скобки}}

', + f'

Висячая пунктуация обора{CHAR_SHY}чивает {{Скобки}}

'), # --- Режим 'right' (только правая пунктуация) --- - #('right', f'

{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}

', - # f'

{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}

'), + ('right', f'

{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}

', + f'

{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}

'), + ('right', f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}

', + f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}

'), + ('right', f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста

', + f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста

'), + ('right', f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста.

', + f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста.

'), + ('right', f'

Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.

', + f'

Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.

'), + ## С границами узлов + ('right', f'

Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.

', + f'

Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.

'), + ('right', f'

Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.

', + f'

Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.

'), + # Неразрывные пробелы и символы, отменяющие перенос, не должны оборачиваться, но должны сохраняться в тексте + ('right', f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2

', + f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2

'), + ('right', f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_ZWNJ}внутри текста 2

', + f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_ZWNJ}внутри текста 2

'), + ('right', f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_THIN_NBSP}внутри текста 2

', + f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_THIN_NBSP}внутри текста 2

'), + ('right', f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2

', + f'

Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2

'), #('right', '

Текст.

', '

Текст.

'), #('right', '

(Скобки)

', '

(Скобки)

'), - #('right', '

3.14

', '

3.14

'), - #('right', '

End.

', '

End.

'), + ('right', '

End.

', '

End.

'), + # Внутри цифр и текста висячие символы не должны оборачиваться, так как они не являются висячими в этом контексте + ('right', '

3.14

', '

3.14

'), + ('right', '3.14', '3.14'), + ('right', '

3,14

', '

3,14

'), + ('right', '

Переменная self.right_chars

', '

Переменная self.right_chars

'), + ('right', 'Переменная `self.right_chars`', 'Переменная `self.right_chars`'), + # --- Режим None / False (отключено) --- (None, f'

{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

',