mod: улучшен алгоритм висячей пунктуации для правого вывешивания. Изменения в левом вывешивании.

This commit is contained in:
2026-03-19 11:03:30 +03:00
parent 88b228050e
commit 465dd9e9e6
5 changed files with 399 additions and 58 deletions

View File

@@ -5,6 +5,11 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html). и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.6] - 2024-03-19
### Изменено
- Новый алгоритм "висячей пунктуации" (HangingPunctuationProcessor). Добавлены компенсирующие пробелы для висячих символов, чтобы избежать наложения на соседние слова. Теперь "висячие символы" (кавычки, тире) оборачиваются в `<span>` вместе с ближайшим словом и пробелом, что обеспечивает корректное визуальное выравнивание внутри сторки без наложения. Режим `both` для одновременного вывешивания в обе стороны отключен из-за потенциальных конфликтов компенсирующих пробелов и проблем с выравниванием при использовании CSS text-justify.
## [0.1.5] - 2024-02-18 ## [0.1.5] - 2024-02-18
### Исправлено ### Исправлено
- Исправлена ошибка, из-за которой `&amp;` в исходном тексте некорректно преобразовывался в `&`. Теперь `&amp;` и его варианты (`&amp;amp;`, `&amp;lt;`) сохраняются в итоговом HTML. - Исправлена ошибка, из-за которой `&amp;` в исходном тексте некорректно преобразовывался в `&`. Теперь `&amp;` и его варианты (`&amp;amp;`, `&amp;lt;`) сохраняются в итоговом HTML.

View File

@@ -309,6 +309,9 @@ Safari), поэтому на него полагаться нельзя. Поэ
сохранить расстояние до соседнего слова. Поэтому типограф оборачивает не только сам висячий символ, но и ближайшее слово сохранить расстояние до соседнего слова. Поэтому типограф оборачивает не только сам висячий символ, но и ближайшее слово
(до пробела или границы узла), а также при необходимости, окружающий пробел. Сама визуальная компенсация оформляется через (до пробела или границы узла), а также при необходимости, окружающий пробел. Сама визуальная компенсация оформляется через
отрицательные `margin`/`padding` в CSS-классах — никаких `position:absolute`, чтобы не нарушать поток текста. отрицательные `margin`/`padding` в CSS-классах — никаких `position:absolute`, чтобы не нарушать поток текста.
Учтите, что набор символов, попадающих в `HANGING_PUNCTUATION_SPACE_CHARS`, помимо обычного пробела включает табуляции, переводы
строки и множество тонких/математических пробелов. Именно поэтому компенсирующие обёртки иногда захватывают
символы на границе узлов или переносов и сохраняют корректный визуальный зазор.
По умолчанию эта функция висячей типографики **отключена**. Чтобы её включить, нужно задать параметр По умолчанию эта функция висячей типографики **отключена**. Чтобы её включить, нужно задать параметр
`hanging_punctuation` при конфигурировании типографа (по умолчанию `hanging_punctuation=None`): `hanging_punctuation` при конфигурировании типографа (по умолчанию `hanging_punctuation=None`):
@@ -332,33 +335,56 @@ typo = etpgrf.Typographer(hanging_punctuation='left')
### Как работает оборачивание ### Как работает оборачивание
Процессор висячей типографики запускается после всех текстовых преобразований и работает с деревом BeautifulSoup. Он ищет Процессор висячей типографики запускается после всех текстовых преобразований и работает с деревом BeautifulSoup. Он ищет
последовательности «пробел + висячий символ» для левого выравнивания и «слово + висячий символ + пробел» для правого, последовательности «пробел + висячий символ» для левого выравнивания и «висячий символ + пробел» для правого,
чтобы обернуть нужные фрагменты в пары `<span>` и не допустить «сиротства» символов. Порядок действий можно описать так: чтобы обернуть нужные фрагменты в пары `<span>` и не допустить «сиротства» символов. Порядок действий можно описать так:
* Для `hanging_punctuation='left'`: * Для `hanging_punctuation='left'`:
* если символ стоит в начале текстового узла (без пробелов слева), оборачивается только сам символ и следующее * если символ стоит в начале текстового узла (без пробелов слева), оборачивается только сам символ и следующее
слово (`<span class="etp-laquo">«АукЫон»</span>`); слово (`<span class="etp-laquo">«АукЫон»</span>`);
* если перед символом внутри узла есть пробел, то пробел оборачивается в `<span class="etp-sp-laquo"> </span>`, а * если перед "висячим" символом внутри узла есть пробел, то пробел и слово слева от него оборачивается
символ вместе со словом — в `<span class="etp-laquo">...</span>`; в `<span class="etp-sp-laquo">слово </span>` (компенсирующий пробел), а сам "висячий" символ вместе со словом справа —
* если пробел оказался в соседнем узле, то он тоже оборачивается в `etp-sp-*`, чтобы не нарушить последовательность; в `<span class="etp-laquo">...</span>`;
* если компенсирующий пробел является "непереносимым пробелом" (или любым другим: шпацией, em-пробелом и т.п.), то тогда, для правильного выравнивания, оборачивается он, например: `<span class="etp-sp-laquo">&nbsp;</span><span class="etp-laquo">«АукЫон»</span>`. * если компенсирующий пробел оказался в соседнем узле (слева), то он тоже оборачивается в `etp-sp-*`, чтобы
не нарушить последовательность;
* если слева от "висячего" символа пробел является "неразрывным пробелом" (`&nbsp;`, нулевой неразрывный пробел,
узкий неразрывный пробел или любой "не пробельный" символ) — это означает, что "висячий" символ не может
"вывешиваться" в начале строки и оборачивания в `<span>` не проиходит.
* Для `hanging_punctuation='right'`: * Для `hanging_punctuation='right'`:
* слово с висячим символом оборачивается в соответствующий класс (`.etp-raquo`, `.etp-rpar` и т.д.); * слово с "висячим символом" и слово слева оборачивается в соответствующий класс (`.etp-raquo`, `.etp-rpar` и т.д.);
* пробел сразу после символа получает класс `etp-sp-raquo`, `etp-sp-rpar` и т.д., чтобы сохранить переносную ширину и * пробел сразу после символа (справа) получает класс `etp-sp-raquo`, `etp-sp-rpar` и т.д., чтобы сохранить
аккуратно компенсировать смещение; переносную ширину и аккуратно компенсировать смещение;
* если компенсирующий пробел оказался в соседнем узле (справа), то он тоже оборачивается в `etp-sp-*`, чтобы
не нарушить последовательность;
* если справа от "висячего" символа пробел является "неразрывным пробелом" (`&nbsp;`, нулевой неразрывный пробел,
узкий неразрывный пробел или любой "не пробельный" символ) — это означает, что "висячий" символ не может
"вывешиваться" в конце строки и оборачивания в `<span>` не проиходит.
Пример вывода для `'left'`: Пример вывода для `'left'`:
```html ```html
Завтра концерт группы<span class="etp-sp-laquo"> </span><span class="etp-laquo">«АукЫон»</span> <span class="etp-laquo">«Все</span> обобщения опасны, включая это» (Дюма)
Завтра концерт <span class="etp-sp-laquo">группы</span><span class="etp-laquo">«Дайте</span> танк»
Если перед&nbsp;«висячим символом» стоит неразрывный пробел, он&nbsp;не&nbsp;может оказаться вначале строки.
```
Пример вывода для `'right'`:
```html
Right “long <span class="etp-rdquo">quote”</span><span class="etp-sp-rdquo">
Отсутствие смещения «висячей <span class="etp-raquo">пунктуации»</span><span class="etp-sp-raquo"> </span>внутри строки обеспечивает компенсирующий пробел справа <span class="etp-r-dot">от&nbsp;неё.</span>
Символ правой «висячей пунктуации»&nbsp;не может оказаться в конце строки, если за&nbsp;ним стоит неразрывный пробел.
``` ```
### CSS для висячих символов ### CSS для висячих символов
Предлагаемый, начиная с etpgrf v0.1.6, CSS теперь работает только с `margin` и `padding`, без `position:absolute`. Пробелы получают собственные Предлагаемый, начиная с etpgrf v0.1.6, CSS теперь работает только с `margin` и `padding`, без `position:absolute`.
классы, поэтому их компенсация контролируется отдельно, а не встроена в сам висячий символ. Убедитесь, что эти стили Компенсирующие пробелы получают собственные классы, поэтому их компенсация контролируется отдельно, а не встроена
подключены к странице и не конфликтуют с `text-justify`, который вытягивает пробелы по всей строке и разрушает аккуратное в сам висячий символ. Убедитесь, что эти стили подключены к странице и не конфликтуют с `text-justify`, который
выравнивание. увеличивает пробелы между словами по всей строке, делают текст менее удобным для чтения и не пригодны
для выравнивания.
```css ```css
/* --- ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */ /* --- ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */
@@ -375,11 +401,17 @@ typo = etpgrf.Typographer(hanging_punctuation='left')
/* --- ПРАВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */ /* --- ПРАВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */
.etp-raquo { padding-right: 0.44em; margin-left: -0.44em; } .etp-raquo { padding-right: 0.44em; margin-left: -0.44em; }
.etp-rdquo { padding-right: 0.4em; margin-left: -0.4em; } .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-rsquo { padding-right: 0.22em; margin-left: -0.22em; }
.etp-rpar, .etp-rsqb, .etp-rcub { padding-right: 0.25em; margin-left: -0.25em; } .etp-rpar, .etp-rsqb, .etp-rcub { padding-right: 0.25em; margin-left: -0.25em; }
/* компенсирующие пробелы для правых висячих символов */ /* компенсирующие пробелы для правых висячих символов */
.etp-sp-raquo { margin-left: -0.44em; } .etp-sp-raquo { margin-left: -0.44em; }
.etp-sp-rdquo { margin-left: -0.4em; } .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-rsquo { margin-left: -0.22em; }
.etp-sp-rpar, .etp-sp-rsqb, .etp-sp-rcub { margin-left: -0.25em; } .etp-sp-rpar, .etp-sp-rsqb, .etp-sp-rcub { margin-left: -0.25em; }
``` ```

View File

@@ -55,6 +55,7 @@ CHAR_EN_QUOT1_OPEN = '“'
CHAR_EN_QUOT1_CLOSE = '' CHAR_EN_QUOT1_CLOSE = ''
CHAR_EN_QUOT2_OPEN = '' CHAR_EN_QUOT2_OPEN = ''
CHAR_EN_QUOT2_CLOSE = '' CHAR_EN_QUOT2_CLOSE = ''
CHAR_TIMES = '×'
CHAR_COPY = '\u00a9' # Символ авторского права / © / &copy; CHAR_COPY = '\u00a9' # Символ авторского права / © / &copy;
CHAR_REG = '\u00ae' # Зарегистрированная торговая марка / ® / &reg; CHAR_REG = '\u00ae' # Зарегистрированная торговая марка / ® / &reg;
CHAR_COPYP = '\u2117' # Знак звуковой записи / ℗ / &copyp; CHAR_COPYP = '\u2117' # Знак звуковой записи / ℗ / &copyp;
@@ -693,6 +694,7 @@ DEFAULT_POST_UNITS = [
'рад', 'К', '°C', '°F', '%', 'мкм', 'нм', 'А°', 'эВ', 'Дж', 'кДж', 'МДж', 'пкФ', 'нФ', 'мкФ', 'мФ', 'Ф', 'рад', 'К', '°C', '°F', '%', 'мкм', 'нм', 'А°', 'эВ', 'Дж', 'кДж', 'МДж', 'пкФ', 'нФ', 'мкФ', 'мФ', 'Ф',
'Гн', 'мГн', 'мкГн', 'Тл', 'Гс', 'эрг', 'бод', 'бит', 'байт', 'Кб', 'Мб', 'Гб', 'Тб', 'Пб', 'Эб', 'кал', 'ккал', 'Гн', 'мГн', 'мкГн', 'Тл', 'Гс', 'эрг', 'бод', 'бит', 'байт', 'Кб', 'Мб', 'Гб', 'Тб', 'Пб', 'Эб', 'кал', 'ккал',
# Английские # Английские
'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'byte', 'uF', 'pF', 'W', 'A', 'V', 'ohm',
# --- Издательское дело --- # --- Издательское дело ---
'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed', 'rev', 'dpi', 'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed', 'rev', 'dpi',
# --- Имперские и американские единицы --- # --- Имперские и американские единицы ---
@@ -700,11 +702,11 @@ DEFAULT_POST_UNITS = [
] ]
# Пред-позиционные (№ 5, $ 10) # Пред-позиционные (№ 5, $ 10)
DEFAULT_PRE_UNITS = ['', '$', '', '£', '', '#', '§', '¤', '', '', '', '', '', '', '', '', '', '', 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, '÷']
# === КОНСТАНТЫ ДЛЯ ФИНАЛЬНЫХ СОКРАЩЕНИЙ === # === КОНСТАНТЫ ДЛЯ ФИНАЛЬНЫХ СОКРАЩЕНИЙ ===
# Эти сокращения (обычно в конце фразы) будут "склеены" тонкой шпацией, а перед ними будет поставлен неразрывный пробел. # Эти сокращения (обычно в конце фразы) будут "склеены" тонкой шпацией, а перед ними будет поставлен неразрывный пробел.

View File

@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
class HangingPunctuationProcessor: class HangingPunctuationProcessor:
""" """
Оборачивает символы висячей пунктуации в специальные теги <span> с классами. Оборачивает символы висячей пунктуации в теги <span> с классами.
""" """
def __init__(self, mode: str | bool | list[str] | None = None): def __init__(self, mode: str | bool | list[str] | None = None):
@@ -87,14 +87,17 @@ class HangingPunctuationProcessor:
def _process_node_recursive(self, node, soup): def _process_node_recursive(self, node, soup):
"""Рекурсивно обходит дерево HTML-узлов. """Рекурсивно обходит дерево HTML-узлов.
:param node: текущий узел, внутри которого ищем висячие символы. :param node: Текущий узел, внутри которого ищем висячие символы.
:param soup: корневой объект soup, нужный для создания новых тегов. :param soup: Корневой объект soup, нужный для создания новых тегов.
:return: None — модификации происходят "на месте". :return: None — модификации происходят "на месте".
""" """
# Работаем с копией списка детей, так как будем менять структуру дерева на лету # Работаем с копией списка детей, так как будем менять структуру дерева на лету
# (replace_with меняет дерево) # (replace_with меняет дерево)
if hasattr(node, 'children'): if hasattr(node, 'children'):
for child in list(node.children): for child in list(node.children):
if getattr(child, 'parent', None) is None:
# Узел уже был заменён/удалён при обработке соседей
continue
if isinstance(child, NavigableString): if isinstance(child, NavigableString):
# Обрабатываем текстовые узлы отдельно согласно выбранному режиму # Обрабатываем текстовые узлы отдельно согласно выбранному режиму
self._process_text_node(child, soup) self._process_text_node(child, soup)
@@ -106,18 +109,26 @@ class HangingPunctuationProcessor:
# Не заходим внутрь тегов, которые мы сами же и создали (или аналогичных), # Не заходим внутрь тегов, которые мы сами же и создали (или аналогичных),
# чтобы избежать рекурсивного ада, хотя классы у нас специфичные. # чтобы избежать рекурсивного ада, хотя классы у нас специфичные.
self._process_node_recursive(child, soup) 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): def _process_text_node(self, text_node: NavigableString, soup: BeautifulSoup):
"""Обрабатывает текстовый узел в зависимости от направления висячей пунктуации. """Обрабатывает текстовый узел в зависимости от направления висячей пунктуации.
:param text_node: текстовый узел BeautifulSoup, содержащий собранный текст. :param text_node: Текстовый узел BeautifulSoup, содержащий собранный текст.
:param soup: объект парсера для создания новых тегов. :param soup: Объект парсера для создания новых тегов.
:return: None — текст заменяется на набор узлов/тегов. :return: None — текст заменяется на набор узлов/тегов.
""" """
if self.direction == HANGING_PUNCTUATION_MODE_LEFT: if self.direction == HANGING_PUNCTUATION_MODE_LEFT:
self._process_text_node_left_mode(text_node, soup) self._process_text_node_left_mode(text_node, soup)
return return
if self.direction == HANGING_PUNCTUATION_MODE_RIGHT:
self._process_text_node_right_mode(text_node, soup)
return
text = str(text_node) text = str(text_node)
# Быстрая проверка: если в тексте вообще нет ни одного нашего символа, выходим # Быстрая проверка: если в тексте вообще нет ни одного нашего символа, выходим
@@ -177,8 +188,8 @@ class HangingPunctuationProcessor:
Пробегает по тексту, захватывая слово и оборачивая его вместе с левой кавычкой, Пробегает по тексту, захватывая слово и оборачивая его вместе с левой кавычкой,
добавляя компенсационные пробелы, когда они есть. добавляя компенсационные пробелы, когда они есть.
:param text_node: текстовый узел, где устанавливаются span-ы. :param text_node: Текстовый узел, где устанавливаются span-ы.
:param soup: парсер для создания span-обёрток. :param soup: Парсер для создания span-обёрток.
:return: None — изменяется дерево DOM. :return: None — изменяется дерево DOM.
""" """
text = str(text_node) text = str(text_node)
@@ -199,6 +210,12 @@ class HangingPunctuationProcessor:
i += 1 i += 1
continue 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: if i > 0 and text[i-1] in cancellation_chars:
# Если перед символом стоит запрещённый неразрывной пробел — пропускаем # Если перед символом стоит запрещённый неразрывной пробел — пропускаем
i += 1 i += 1
@@ -249,12 +266,12 @@ class HangingPunctuationProcessor:
boundary_chars: frozenset[str], cancellation_chars: frozenset[str]) -> tuple[int, int] | None: boundary_chars: frozenset[str], cancellation_chars: frozenset[str]) -> tuple[int, int] | None:
"""Находит диапазон (слово + пробел), который нужно обернуть перед левым символом. """Находит диапазон (слово + пробел), который нужно обернуть перед левым символом.
:param text: строка, из которой извлекаются границы. :param text: Строка, из которой извлекаются границы.
:param left_idx: индекс левой кавычки. :param left_idx: Индекс левой кавычки.
:param last_idx: последний обработанный индекс — не повторяем участки. :param last_idx: Последний обработанный индекс — не повторяем участки.
:param boundary_chars: набор символов, разрешённых для разрывов. :param boundary_chars: Набор символов, разрешённых для разрывов.
:param cancellation_chars: символы, отменяющие висячие привязки. :param cancellation_chars: Символы, отменяющие висячие привязки.
:return: кортеж (start, end) или None, если приращение не нужно. :return: Кортеж (start, end) или None, если приращение не нужно.
""" """
if left_idx == 0: if left_idx == 0:
return None return None
@@ -277,8 +294,8 @@ class HangingPunctuationProcessor:
def _find_first_left_char_in_node(self, node: Tag) -> str | None: def _find_first_left_char_in_node(self, node: Tag) -> str | None:
"""Ищет первый символ левого режима во вложенном поддереве. """Ищет первый символ левого режима во вложенном поддереве.
:param node: тег, внутри которого итерируем по потомкам. :param node: Тег, внутри которого итерируем по потомкам.
:return: символ из `left_chars` или None. :return: Символ из `left_chars` или None.
""" """
for descendant in node.descendants: for descendant in node.descendants:
if isinstance(descendant, NavigableString): 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: def _wrap_parent_space_before_child(self, child: Tag, first_left_char: str, soup: BeautifulSoup) -> bool:
"""Оборачивает слово + пробел перед дочерним узлом у родителя. """Оборачивает слово + пробел перед дочерним узлом у родителя.
:param child: дочерний тег, который начал с висячего символа. :param child: Дочерний тег, который начал с висячего символа.
:param first_left_char: символ, наложивший необходимость обёртки. :param first_left_char: Символ, наложивший необходимость обёртки.
:param soup: объект парсера для span-ов. :param soup: Объект парсера для span-ов.
:return: True, если обёртка добавлена; False — иначе. :return: True, если обёртка добавлена; False — иначе.
""" """
prev_text_node = self._find_previous_navigable_text(child) 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: def _extract_trailing_compensation_fragment(self, text: str) -> tuple[int, int] | None:
"""Извлекает диапазон слова с последним пробелом перед дочерним узлом. """Извлекает диапазон слова с последним пробелом перед дочерним узлом.
:param text: текущий текст узла, последний символ которого — потенциальный пробел. :param text: Текущий текст узла, последний символ которого — потенциальный пробел.
:return: кортеж (start, end) или None, если для компенсации нет подходящего фрагмента. :return: Кортеж (start, end) или None, если для компенсации нет подходящего фрагмента.
""" """
if not text: if not text:
return None return None
@@ -351,11 +368,7 @@ class HangingPunctuationProcessor:
return (word_start, end) if word_start < end else None return (word_start, end) if word_start < end else None
def _find_previous_navigable_text(self, child: Tag) -> NavigableString | None: def _find_previous_navigable_text(self, child: Tag) -> NavigableString | None:
"""Возвращает предыдущий текстовый узел, если он содержит непустой текст. """Возвращает предыдущий текстовый узел, если он содержит непустой текст."""
:param child: узел, от которого ищем сиблинга.
:return: NavigableString или None.
"""
prev = child.previous_sibling prev = child.previous_sibling
while prev: while prev:
if isinstance(prev, NavigableString) and prev.strip(): if isinstance(prev, NavigableString) and prev.strip():
@@ -364,12 +377,7 @@ class HangingPunctuationProcessor:
return None return None
def _replace_text_node(self, old_node: NavigableString, new_nodes: list[NavigableString | Tag]) -> 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: if not new_nodes:
old_node.extract() old_node.extract()
return return
@@ -380,3 +388,253 @@ class HangingPunctuationProcessor:
current.insert_after(node) current.insert_after(node)
current = 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)

View File

@@ -12,7 +12,7 @@ from etpgrf.config import (
CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP, CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP,
CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP, CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP,
CHAR_NULL_SP, CHAR_THIN_NBSP, CHAR_NBSP, CHAR_NULL_SP, CHAR_THIN_NBSP, CHAR_NBSP,
CHAR_ZWNJ CHAR_ZWNJ, CHAR_PUNT_SP
) )
# Вспомогательная функция для создания soup # Вспомогательная функция для создания soup
@@ -37,6 +37,8 @@ HANGING_TEST_CASES = [
f'<p>А вот <span class="etp-sp-laquo">это </span><b><i><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE}</i></b> внутри текста</p>'), f'<p>А вот <span class="etp-sp-laquo">это </span><b><i><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE}</i></b> внутри текста</p>'),
('left', f'<p>А вот это {CHAR_RU_QUOT1_OPEN}<b>Длинная цитата</b>{CHAR_RU_QUOT1_CLOSE} внутри текста</p>', ('left', f'<p>А вот это {CHAR_RU_QUOT1_OPEN}<b>Длинная цитата</b>{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
f'<p>А вот <span class="etp-sp-laquo">это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span><b>Длинная цитата</b>{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'), f'<p>А вот <span class="etp-sp-laquo">это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span><b>Длинная цитата</b>{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
('left', f'<p>А вот{CHAR_NBSP}это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
f'<p>А <span class="etp-sp-laquo">вот{CHAR_NBSP}это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
# Английские кавычки "лапки" # Английские кавычки "лапки"
('left', f'<p>This is some {CHAR_EN_QUOT1_OPEN}<b>wisdom quote</b>{CHAR_EN_QUOT1_CLOSE} for the test.</p>', ('left', f'<p>This is some {CHAR_EN_QUOT1_OPEN}<b>wisdom quote</b>{CHAR_EN_QUOT1_CLOSE} for the test.</p>',
f'<p>This is <span class="etp-sp-ldquo">some </span><span class="etp-ldquo">{CHAR_EN_QUOT1_OPEN}</span><b>wisdom quote</b>{CHAR_EN_QUOT1_CLOSE} for the test.</p>'), f'<p>This is <span class="etp-sp-ldquo">some </span><span class="etp-ldquo">{CHAR_EN_QUOT1_OPEN}</span><b>wisdom quote</b>{CHAR_EN_QUOT1_CLOSE} for the test.</p>'),
@@ -53,6 +55,8 @@ HANGING_TEST_CASES = [
'<p>Висячая пунктуация <span class="etp-sp-lpar">оборачивает </span><span class="etp-lpar">(Скобки)</span></p>'), '<p>Висячая пунктуация <span class="etp-sp-lpar">оборачивает </span><span class="etp-lpar">(Скобки)</span></p>'),
('left', '<p>Висячая пунктуация оборачивает (Круглые скобки) вот так.</p>', ('left', '<p>Висячая пунктуация оборачивает (Круглые скобки) вот так.</p>',
'<p>Висячая пунктуация <span class="etp-sp-lpar">оборачивает </span><span class="etp-lpar">(Круглые</span> скобки) вот так.</p>'), '<p>Висячая пунктуация <span class="etp-sp-lpar">оборачивает </span><span class="etp-lpar">(Круглые</span> скобки) вот так.</p>'),
('left', '<p>Перечисли, (элемент списка)</p>',
'<p><span class="etp-sp-lpar">Перечисли, </span><span class="etp-lpar">(элемент</span> списка)</p>'),
# Примеры "квадратная скобка" # Примеры "квадратная скобка"
('left', '<p>[Скобки]</p>', '<p><span class="etp-lsqb">[Скобки]</span></p>'), ('left', '<p>[Скобки]</p>', '<p><span class="etp-lsqb">[Скобки]</span></p>'),
('left', '<p>Висячая пунктуация оборачивает [Скобки]</p>', ('left', '<p>Висячая пунктуация оборачивает [Скобки]</p>',
@@ -63,18 +67,58 @@ HANGING_TEST_CASES = [
('left', '<p>{Скобки}</p>', '<p><span class="etp-lcub">{Скобки}</span></p>'), ('left', '<p>{Скобки}</p>', '<p><span class="etp-lcub">{Скобки}</span></p>'),
('left', '<p>Висячая пунктуация оборачивает {Скобки}</p>', ('left', '<p>Висячая пунктуация оборачивает {Скобки}</p>',
'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает </span><span class="etp-lcub">{Скобки}</span></p>'), '<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает </span><span class="etp-lcub">{Скобки}</span></p>'),
('left', '<p>Висячая пунктуация оборачивает {Квадратные скобки} вот так.</p>', ('left', '<p>Висячая пунктуация оборачивает {Фигурные скобки} вот так.</p>',
'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает </span><span class="etp-lcub">{Квадратные</span> скобки} вот так.</p>'), '<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает </span><span class="etp-lcub">{Фигурные</span> скобки} вот так.</p>'),
# Обычный текст, в котором нет символов для висячей пунктуации, не должен изменяться # Обычный текст, в котором нет символов для висячей пунктуации, не должен изменяться
('left', '<p>Текст.</p>', '<p>Текст.</p>'), ('left', '<p>Текст.</p>', '<p>Текст.</p>'),
('left', '<p>Пароль `89(fg#_Wfgq0[89`</p>', '<p>Пароль `89(fg#_Wfgq0[89`</p>'),
# Проверка на альтернативный разрывной символ (не пробел)
('left', '<p>Висячая пунктуация оборачивает\t{Скобки}</p>',
'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает\t</span><span class="etp-lcub">{Скобки}</span></p>'),
('left', f'<p>Висячая пунктуация оборачивает{CHAR_SHY}{{Скобки}}</p>',
f'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает{CHAR_SHY}</span><span class="etp-lcub">{{Скобки}}</span></p>'),
('left', f'<p>Висячая пунктуация оборачивает{CHAR_PUNT_SP}{{Скобки}}</p>',
f'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает{CHAR_PUNT_SP}</span><span class="etp-lcub">{{Скобки}}</span></p>'),
('left', f'<p>Висячая пунктуация оборачивает\n{{Скобки}}</p>',
f'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает\n</span><span class="etp-lcub">{{Скобки}}</span></p>'),
('left', f'<p>Висячая пунктуация обора{CHAR_SHY}чивает {{Скобки}}</p>',
f'<p>Висячая пунктуация обора{CHAR_SHY}<span class="etp-sp-lcub">чивает </span><span class="etp-lcub">{{Скобки}}</span></p>'),
# --- Режим 'right' (только правая пунктуация) --- # --- Режим 'right' (только правая пунктуация) ---
#('right', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>', ('right', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
# f'<p>{CHAR_RU_QUOT1_OPEN}Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'), f'<p><span class="etp-raquo">{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</span></p>'),
('right', f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная <span class="etp-raquo">цитата{CHAR_RU_QUOT1_CLOSE}</span></p>'),
('right', f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная <span class="etp-raquo">цитата{CHAR_RU_QUOT1_CLOSE}</span><span class="etp-sp-raquo"> </span>внутри текста</p>'),
('right', f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста.</p>',
f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная <span class="etp-raquo">цитата{CHAR_RU_QUOT1_CLOSE}</span><span class="etp-sp-raquo"> </span>внутри <span class="etp-r-dot">текста.</span></p>'),
('right', f'<p>Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.</p>',
f'<p>Right {CHAR_EN_QUOT1_OPEN}long <span class="etp-rdquo">quote{CHAR_EN_QUOT1_CLOSE}</span><span class="etp-sp-rdquo"> </span>within the <span class="etp-r-dot">text.</span></p>'),
## С границами узлов
('right', f'<p>Right {CHAR_EN_QUOT1_OPEN}<b>long quote</b>{CHAR_EN_QUOT1_CLOSE} within the text.</p>',
f'<p>Right {CHAR_EN_QUOT1_OPEN}<b>long quote</b><span class="etp-rdquo">{CHAR_EN_QUOT1_CLOSE}</span><span class="etp-sp-rdquo"> </span>within the <span class="etp-r-dot">text.</span></p>'),
('right', f'<p>Right <b>{CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE}</b> within the text.</p>',
f'<p>Right <b>{CHAR_EN_QUOT1_OPEN}long <span class="etp-rdquo">quote{CHAR_EN_QUOT1_CLOSE}</span></b><span class="etp-sp-rdquo"> </span>within the <span class="etp-r-dot">text.</span></p>'),
# Неразрывные пробелы и символы, отменяющие перенос, не должны оборачиваться, но должны сохраняться в тексте
('right', f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2</p>',
f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2</p>'),
('right', f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_ZWNJ}внутри текста 2</p>',
f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_ZWNJ}внутри текста 2</p>'),
('right', f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_THIN_NBSP}внутри текста 2</p>',
f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_THIN_NBSP}внутри текста 2</p>'),
('right', f'<p>Правая <b>{CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}</b>{CHAR_NBSP}внутри текста 2</p>',
f'<p>Правая <b>{CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}</b>{CHAR_NBSP}внутри текста 2</p>'),
#('right', '<p>Текст.</p>', '<p>Текст<span class="etp-r-dot">.</span></p>'), #('right', '<p>Текст.</p>', '<p>Текст<span class="etp-r-dot">.</span></p>'),
#('right', '<p>(Скобки)</p>', '<p>(Скобки<span class="etp-rpar">)</span></p>'), #('right', '<p>(Скобки)</p>', '<p>(Скобки<span class="etp-rpar">)</span></p>'),
#('right', '<p>3.14</p>', '<p>3.14</p>'), ('right', '<p>End.</p>', '<p><span class="etp-r-dot">End.</span></p>'),
#('right', '<p>End.</p>', '<p>End<span class="etp-r-dot">.</span></p>'), # Внутри цифр и текста висячие символы не должны оборачиваться, так как они не являются висячими в этом контексте
('right', '<p>3.14</p>', '<p>3.14</p>'),
('right', '3.14', '3.14'),
('right', '<p>3,14</p>', '<p>3,14</p>'),
('right', '<p>Переменная <code>self.right_chars</code></p>', '<p>Переменная <code>self.right_chars</code></p>'),
('right', 'Переменная `self.right_chars`', 'Переменная `self.right_chars`'),
# --- Режим None / False (отключено) --- # --- Режим None / False (отключено) ---
(None, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>', (None, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>',