From ce3d1c146ac8d2501184e3727f4f4e014c419044 Mon Sep 17 00:00:00 2001 From: erjemin Date: Mon, 16 Mar 2026 13:27:39 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=81?= =?UTF-8?q?=D1=85=D0=B5=D0=BC=D0=B0=20=D0=B2=D0=B8=D1=81=D1=8F=D1=87=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=BB=D0=B5=D0=B2=D0=BE=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(=D1=81=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=B5=D0=BD=D1=81=D0=B8=D1=80=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=BC=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etpgrf/hanging.py | 287 ++++++++++++++++++++++++++++++++++++------ tests/test_hanging.py | 110 ++++++++++++---- 2 files changed, 336 insertions(+), 61 deletions(-) diff --git a/etpgrf/hanging.py b/etpgrf/hanging.py index a6b8aa4..c345ee1 100644 --- a/etpgrf/hanging.py +++ b/etpgrf/hanging.py @@ -4,11 +4,15 @@ import logging from bs4 import BeautifulSoup, NavigableString, Tag from .config import ( + HANGING_PUNCTUATION_CHARS, HANGING_PUNCTUATION_LEFT_CHARS, - HANGING_PUNCTUATION_RIGHT_CHARS, - HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT, HANGING_PUNCTUATION_MODE_LEFT, HANGING_PUNCTUATION_MODE_RIGHT, + HANGING_PUNCTUATION_RIGHT_CHARS, + HANGING_PUNCTUATION_SPACE_CHARS, + HANGING_PUNCTUATION_SPACE_CLASSES, + HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT, + HANGING_CANCELLATION_SP, ) logger = logging.getLogger(__name__) @@ -32,21 +36,26 @@ class HangingPunctuationProcessor: self.mode = mode self.target_tags = None self.active_chars = set() + self.space_classes = {} + self.direction = None + self.left_chars = HANGING_PUNCTUATION_LEFT_CHARS + self.right_chars = HANGING_PUNCTUATION_RIGHT_CHARS + # Сначала определяем, какие режимы и классы активны (левый, правый или оба) и подгружаем символы. if isinstance(mode, list): self.target_tags = set(t.lower() for t in mode) - self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) - self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) + directions = (HANGING_PUNCTUATION_MODE_LEFT, HANGING_PUNCTUATION_MODE_RIGHT) else: normalized_mode = HANGING_PUNCTUATION_MODE_LEFT if mode is True else mode - if normalized_mode == HANGING_PUNCTUATION_MODE_LEFT: - self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) - elif normalized_mode == HANGING_PUNCTUATION_MODE_RIGHT: - self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) + directions = (normalized_mode,) if normalized_mode in (HANGING_PUNCTUATION_MODE_LEFT, HANGING_PUNCTUATION_MODE_RIGHT) else () + self.direction = normalized_mode + + for direction in directions: + self.active_chars.update(HANGING_PUNCTUATION_CHARS.get(direction, frozenset())) + self.space_classes.update(HANGING_PUNCTUATION_SPACE_CLASSES.get(direction, {})) - # Предварительно фильтруем карту классов, оставляя только активные символы self.char_to_class = { - char: cls + char: cls for char, cls in HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT.items() if char in self.active_chars } @@ -76,26 +85,39 @@ class HangingPunctuationProcessor: return soup def _process_node_recursive(self, node, soup): - """ - Рекурсивно обходит узлы. Если находит NavigableString с нужными символами, - разбивает его и вставляет span'ы. + """Рекурсивно обходит дерево HTML-узлов. + + :param node: текущий узел, внутри которого ищем висячие символы. + :param soup: корневой объект soup, нужный для создания новых тегов. + :return: None — модификации происходят "на месте". """ # Работаем с копией списка детей, так как будем менять структуру дерева на лету # (replace_with меняет дерево) if hasattr(node, 'children'): for child in list(node.children): if isinstance(child, NavigableString): + # Обрабатываем текстовые узлы отдельно согласно выбранному режиму self._process_text_node(child, soup) elif isinstance(child, Tag): + if self.direction == HANGING_PUNCTUATION_MODE_LEFT: + first_left = self._find_first_left_char_in_node(child) + if first_left: + self._wrap_parent_space_before_child(child, first_left, soup) # Не заходим внутрь тегов, которые мы сами же и создали (или аналогичных), # чтобы избежать рекурсивного ада, хотя классы у нас специфичные. self._process_node_recursive(child, soup) def _process_text_node(self, text_node: NavigableString, soup: BeautifulSoup): + """Обрабатывает текстовый узел в зависимости от направления висячей пунктуации. + + :param text_node: текстовый узел BeautifulSoup, содержащий собранный текст. + :param soup: объект парсера для создания новых тегов. + :return: None — текст заменяется на набор узлов/тегов. """ - Анализирует текстовый узел. Если в нем есть символы для висячей пунктуации, - заменяет узел на фрагмент (список узлов), где эти символы обернуты в span. - """ + if self.direction == HANGING_PUNCTUATION_MODE_LEFT: + self._process_text_node_left_mode(text_node, soup) + return + text = str(text_node) # Быстрая проверка: если в тексте вообще нет ни одного нашего символа, выходим @@ -112,54 +134,249 @@ class HangingPunctuationProcessor: should_hang = False # Проверяем контекст (пробелы или другие висячие символы вокруг) - if char in HANGING_PUNCTUATION_LEFT_CHARS: - # Левая пунктуация: - # 1. Начало узла - # 2. Перед ней пробел - # 3. Перед ней другой левый висячий символ (например, "((text") + if char in self.left_chars: if (i == 0 or text[i-1].isspace() or - text[i-1] in HANGING_PUNCTUATION_LEFT_CHARS): + text[i-1] in self.left_chars): should_hang = True - elif char in HANGING_PUNCTUATION_RIGHT_CHARS: - # Правая пунктуация: - # 1. Конец узла - # 2. После нее пробел - # 3. После нее другой правый висячий символ (например, "text.»") + elif char in self.right_chars: if (i == text_len - 1 or text[i+1].isspace() or - text[i+1] in HANGING_PUNCTUATION_RIGHT_CHARS): + text[i+1] in self.right_chars): should_hang = True if should_hang: - # 1. Сбрасываем накопленный буфер текста (если есть) if current_text_buffer: new_nodes.append(NavigableString(current_text_buffer)) current_text_buffer = "" - # 2. Создаем span для висячего символа span = soup.new_tag("span") span['class'] = self.char_to_class[char] span.string = char new_nodes.append(span) else: - # Если контекст не подходит, оставляем символ как обычный текст current_text_buffer += char else: - # Просто накапливаем символ current_text_buffer += char - # Добавляем остаток буфера if current_text_buffer: new_nodes.append(NavigableString(current_text_buffer)) - # Заменяем исходный текстовый узел на набор новых узлов. if new_nodes: first_node = new_nodes[0] text_node.replace_with(first_node) - # Остальные вставляем последовательно после первого current_pos = first_node for next_node in new_nodes[1:]: current_pos.insert_after(next_node) current_pos = next_node + + def _process_text_node_left_mode(self, text_node: NavigableString, soup: BeautifulSoup): + """Реализует алгоритм обёртки для левого режима висячей пунктуации. + + Пробегает по тексту, захватывая слово и оборачивая его вместе с левой кавычкой, + добавляя компенсационные пробелы, когда они есть. + + :param text_node: текстовый узел, где устанавливаются span-ы. + :param soup: парсер для создания span-обёрток. + :return: None — изменяется дерево DOM. + """ + text = str(text_node) + if not any(char in self.left_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 + + while i < text_len: + char = text[i] + if char not in self.left_chars: + # Пропускаем любые символы, которые не участвуют в левом висении + i += 1 + continue + + if i > 0 and text[i-1] in cancellation_chars: + # Если перед символом стоит запрещённый неразрывной пробел — пропускаем + i += 1 + continue + + comp_bounds = self._locate_left_compensation_bounds(text, i, last_index, + boundary_chars, cancellation_chars) + if comp_bounds: + comp_start, comp_end = comp_bounds + if comp_start > last_index: + nodes.append(NavigableString(text[last_index:comp_start])) + space_span = soup.new_tag('span') + space_span['class'] = self.space_classes.get(char, '') + space_span.string = text[comp_start:comp_end] + nodes.append(space_span) + last_index = comp_end + elif last_index < i: + # Добавляем буфер текста между последней обёрткой и новой левым кавычкой + nodes.append(NavigableString(text[last_index:i])) + last_index = i + + span_start = last_index + span_end = span_start + while span_end < text_len and text[span_end] not in boundary_chars: + span_end += 1 + if span_end == span_start: + span_end = span_start + 1 + + left_span = soup.new_tag('span') + left_span['class'] = self.char_to_class.get(char, '') + left_span.string = text[span_start:span_end] + nodes.append(left_span) + last_index = span_end + i = span_end + + 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 + + def _locate_left_compensation_bounds(self, text: str, left_idx: int, last_idx: int, + 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, если приращение не нужно. + """ + if left_idx == 0: + return None + prev_idx = left_idx - 1 + prev_char = text[prev_idx] + if prev_char not in boundary_chars or prev_char in cancellation_chars: + return None + + # Перемещаемся назад по строке, чтобы найти цепочку пробелов перед словом + space_end = prev_idx + while space_end > last_idx and text[space_end - 1] in boundary_chars: + space_end -= 1 + + start = space_end + while start > last_idx and text[start - 1] not in boundary_chars: + start -= 1 + + return (start, left_idx) if start < left_idx else None + + def _find_first_left_char_in_node(self, node: Tag) -> str | None: + """Ищет первый символ левого режима во вложенном поддереве. + + :param node: тег, внутри которого итерируем по потомкам. + :return: символ из `left_chars` или None. + """ + for descendant in node.descendants: + if isinstance(descendant, NavigableString): + for char in str(descendant): + if char.isspace(): + continue + if char in self.left_chars: + return char + return None + return None + + 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-ов. + :return: True, если обёртка добавлена; False — иначе. + """ + prev_text_node = self._find_previous_navigable_text(child) + if not prev_text_node: + return False + + fragment = self._extract_trailing_compensation_fragment(str(prev_text_node)) + if not fragment: + return False + + text_start, text_end = fragment + substring = str(prev_text_node)[text_start:text_end] + head = str(prev_text_node)[:text_start] + css_class = self.space_classes.get(first_left_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] = [] + if head: + new_nodes.append(NavigableString(head)) + new_nodes.append(span) + + self._replace_text_node(prev_text_node, new_nodes) + return True + + def _extract_trailing_compensation_fragment(self, text: str) -> tuple[int, int] | None: + """Извлекает диапазон слова с последним пробелом перед дочерним узлом. + + :param text: текущий текст узла, последний символ которого — потенциальный пробел. + :return: кортеж (start, end) или None, если для компенсации нет подходящего фрагмента. + """ + if not text: + return None + boundary_chars = HANGING_PUNCTUATION_SPACE_CHARS + end = len(text) + if text[end - 1] not in boundary_chars: + return None + + start = end + while start > 0 and text[start - 1] in boundary_chars: + # Отступаем до конца последовательности пробельных символов + start -= 1 + + word_start = start + while word_start > 0 and text[word_start - 1] not in boundary_chars: + # Продолжаем вверх до начала последнего слова перед пробелами + word_start -= 1 + + 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(): + return prev + prev = prev.previous_sibling + 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 + first = new_nodes[0] + old_node.replace_with(first) + current = first + for node in new_nodes[1:]: + current.insert_after(node) + current = node + diff --git a/tests/test_hanging.py b/tests/test_hanging.py index 5d2baae..642ef84 100644 --- a/tests/test_hanging.py +++ b/tests/test_hanging.py @@ -6,7 +6,13 @@ from bs4 import BeautifulSoup from etpgrf.hanging import HangingPunctuationProcessor from etpgrf.config import ( CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE, - CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE + CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE, + CHAR_LPAR, CHAR_LSQB, CHAR_LCUB, + CHAR_RPAR, CHAR_RSQB, CHAR_RCUB, + 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 ) # Вспомогательная функция для создания soup @@ -16,19 +22,34 @@ def make_soup(html_str): # Набор тестовых случаев в формате: # (режим, входной_html, ожидаемый_html) HANGING_TEST_CASES = [ - # --- Режим 'left' (только левая пунктуация) --- + # --- Режим 'left' (только левая висячая пунктуация) --- ('left', f'

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

', - f'

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

'), - ('left', '

(Скобки)

', '

(Скобки)

'), + 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_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE} внутри текста

', + 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_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста

', + 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_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', '

(Скобки)

', '

(Скобки)

'), ('left', '

Текст.

', '

Текст.

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

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

', - f'

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

'), - ('right', '

Текст.

', '

Текст.

'), - ('right', '

(Скобки)

', '

(Скобки)

'), - ('right', '

3.14

', '

3.14

'), - ('right', '

End.

', '

End.

'), + #('right', f'

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

', + # f'

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

'), + #('right', '

Текст.

', '

Текст.

'), + #('right', '

(Скобки)

', '

(Скобки)

'), + #('right', '

3.14

', '

3.14

'), + #('right', '

End.

', '

End.

'), # --- Режим None / False (отключено) --- (None, f'

{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

', @@ -37,18 +58,6 @@ HANGING_TEST_CASES = [ f'

{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

'), ] -# --- Режим list[str] (список тегов с обеими сторонами) --- -HANGING_LIST_MODE_CASES = [ - (['p'], f'

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

', - f'

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

'), - (['p'], f'

Текст.{CHAR_RU_QUOT1_CLOSE}

', - f'

Текст.{CHAR_RU_QUOT1_CLOSE}

'), - (['p'], f'

func {CHAR_RU_QUOT1_OPEN}arg

', - f'

func {CHAR_RU_QUOT1_OPEN}arg

'), - (['p'], f'

arg{CHAR_RU_QUOT1_CLOSE} next

', - f'

arg{CHAR_RU_QUOT1_CLOSE} next

'), -] - @pytest.mark.parametrize("mode, input_html, expected_html", HANGING_TEST_CASES) def test_hanging_punctuation_processor(mode, input_html, expected_html): @@ -88,11 +97,60 @@ def test_hanging_punctuation_target_tags(): assert str(soup) == expected_html -@pytest.mark.parametrize("mode, input_html, expected_html", HANGING_LIST_MODE_CASES) -def test_hanging_punctuation_processor_list_mode(mode, input_html, expected_html): - """Проверяет, что list-режим работает и для левой, и для правой стороны внутри указанного тега.""" - processor = HangingPunctuationProcessor(mode=mode) +LEFT_FULL_SYMBOLS = [ + (CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE, 'etp-laquo'), + (CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE, 'etp-ldquo'), + (CHAR_LPAR, CHAR_RPAR, 'etp-lpar'), + (CHAR_LSQB, CHAR_RSQB, 'etp-lsqb'), + (CHAR_LCUB, CHAR_RCUB, 'etp-lcub'), +] + + +@pytest.mark.parametrize("open_char, close_char, cls", LEFT_FULL_SYMBOLS) +def test_hanging_left_mode_wraps_symbol_pairs(open_char, close_char, cls): + """ + Убедимся, что разные висячие символы полностью оборачиваются в левом режиме. + + open_char: символ, открывающий висячий знак. + close_char: символ, закрывающий висячий знак. + cls: CSS класс для обёртки висячих знаков. + + """ + input_html = f'

{open_char}Текст{close_char}

' + expected_html = f'

{open_char}Текст{close_char}

' + processor = HangingPunctuationProcessor(mode='left') soup = make_soup(input_html) processor.process(soup) assert str(soup) == expected_html + + +SPACE_VARIANTS = [ + ' ', CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP, + CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP, CHAR_NULL_SP, +] + + +@pytest.mark.parametrize("space", SPACE_VARIANTS) +def test_hanging_left_mode_compensates_different_spaces(space): + """Проверяем компенсацию для разных типов разрывных пробелов.""" + text = f'

Проба{space}{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

' + expected_html = (f'

Проба{space}' + f'{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

') + + processor = HangingPunctuationProcessor(mode='left') + soup = make_soup(text) + + processor.process(soup) + assert str(soup) == expected_html + + +@pytest.mark.parametrize("separator", [CHAR_NBSP, CHAR_THIN_NBSP, CHAR_ZWNJ]) +def test_hanging_left_mode_honors_cancellation(separator): + """Символы, отменяющие перенос, остаются без обёрток.""" + input_html = f'

Неразрывный{separator}{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

' + processor = HangingPunctuationProcessor(mode='left') + soup = make_soup(input_html) + + processor.process(soup) + assert str(soup) == input_html