From 22ddf6ef118a68d0bc81d6114b31930ab2f85734 Mon Sep 17 00:00:00 2001 From: erjemin Date: Tue, 23 Dec 2025 17:10:56 +0300 Subject: [PATCH] =?UTF-8?q?add:=20=D0=92=D0=B8=D1=81=D1=8F=D1=87=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=83=D0=BD=D0=BA=D1=82=D1=83=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20(?= =?UTF-8?q?=D0=BA=D1=80=D0=BE=D0=BC=D0=B5=20=D1=80=D0=B5=D0=B4=D0=BA=D0=B8?= =?UTF-8?q?=D1=85=20=D1=81=D0=BB=D1=83=D1=87=D0=B0=D0=B5=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B3=D0=B4=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB?= =?UTF-8?q?,=20=D0=B8=D0=BB=D0=B8=20=D0=B5=D0=B3=D0=BE=20=D0=BE=D1=82?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=B2=D0=B8=D0=B5,=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=20=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=D0=B4=D1=83=D1=8E=D1=89=D0=B8=D0=B9=20bs-=D1=83=D0=B7?= =?UTF-8?q?=D0=B5=D0=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etpgrf/hanging.py | 166 ++++++++++++++++++++++++++++++++++++++++++ tests/test_hanging.py | 114 +++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 etpgrf/hanging.py create mode 100644 tests/test_hanging.py diff --git a/etpgrf/hanging.py b/etpgrf/hanging.py new file mode 100644 index 0000000..681b6c9 --- /dev/null +++ b/etpgrf/hanging.py @@ -0,0 +1,166 @@ +# etpgrf/hanging.py +# Модуль для расстановки висячей пунктуации. + +import logging +from bs4 import BeautifulSoup, NavigableString, Tag +from .config import ( + HANGING_PUNCTUATION_LEFT_CHARS, + HANGING_PUNCTUATION_RIGHT_CHARS, + HANGING_PUNCTUATION_CLASSES +) + +logger = logging.getLogger(__name__) + + +class HangingPunctuationProcessor: + """ + Оборачивает символы висячей пунктуации в специальные теги с классами. + """ + + def __init__(self, mode: str | bool | list[str] | None = None): + """ + :param mode: Режим работы: + - None / False: отключено. + - 'left': только левая пунктуация. + - 'right': только правая пунктуация. + - 'both' / True: и левая, и правая. + - list[str]: список тегов (например, ['p', 'blockquote']), + внутри которых применять 'both'. + """ + self.mode = mode + self.target_tags = None + self.active_chars = set() + + # Определяем, какие символы будем обрабатывать + if isinstance(mode, list): + self.target_tags = set(t.lower() for t in mode) + # Если передан список тегов, включаем полный режим ('both') внутри них + self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) + self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) + elif mode == 'left': + self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) + elif mode == 'right': + self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) + elif mode == 'both' or mode is True: + self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) + self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) + + # Предварительно фильтруем карту классов, оставляя только активные символы + self.char_to_class = { + char: cls + for char, cls in HANGING_PUNCTUATION_CLASSES.items() + if char in self.active_chars + } + + logger.debug(f"HangingPunctuationProcessor initialized. Mode: {mode}, Active chars count: {len(self.active_chars)}") + + def process(self, soup: BeautifulSoup) -> BeautifulSoup: + """ + Проходит по дереву soup и оборачивает висячие символы в span. + """ + if not self.active_chars: + return soup + + # Если задан список целевых тегов, обрабатываем только их содержимое + if self.target_tags: + # Находим все теги из списка + # Используем select для поиска (например: "p, blockquote, h1") + selector = ", ".join(self.target_tags) + roots = soup.select(selector) + else: + # Иначе обрабатываем весь документ (начиная с корня) + roots = [soup] + + for root in roots: + self._process_node_recursive(root, soup) + + return soup + + def _process_node_recursive(self, node, soup): + """ + Рекурсивно обходит узлы. Если находит NavigableString с нужными символами, + разбивает его и вставляет span'ы. + """ + # Работаем с копией списка детей, так как будем менять структуру дерева на лету + # (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): + # Не заходим внутрь тегов, которые мы сами же и создали (или аналогичных), + # чтобы избежать рекурсивного ада, хотя классы у нас специфичные. + self._process_node_recursive(child, soup) + + def _process_text_node(self, text_node: NavigableString, soup: BeautifulSoup): + """ + Анализирует текстовый узел. Если в нем есть символы для висячей пунктуации, + заменяет узел на фрагмент (список узлов), где эти символы обернуты в span. + """ + text = str(text_node) + + # Быстрая проверка: если в тексте вообще нет ни одного нашего символа, выходим + if not any(char in text for char in self.active_chars): + return + + # Если символы есть, нам нужно "разобрать" строку. + new_nodes = [] + current_text_buffer = "" + text_len = len(text) + + for i, char in enumerate(text): + if char in self.char_to_class: + should_hang = False + + # Проверяем контекст (пробелы или другие висячие символы вокруг) + if char in HANGING_PUNCTUATION_LEFT_CHARS: + # Левая пунктуация: + # 1. Начало узла + # 2. Перед ней пробел + # 3. Перед ней другой левый висячий символ (например, "((text") + if (i == 0 or + text[i-1].isspace() or + text[i-1] in HANGING_PUNCTUATION_LEFT_CHARS): + should_hang = True + elif char in HANGING_PUNCTUATION_RIGHT_CHARS: + # Правая пунктуация: + # 1. Конец узла + # 2. После нее пробел + # 3. После нее другой правый висячий символ (например, "text.»") + if (i == text_len - 1 or + text[i+1].isspace() or + text[i+1] in HANGING_PUNCTUATION_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 diff --git a/tests/test_hanging.py b/tests/test_hanging.py new file mode 100644 index 0000000..88614b0 --- /dev/null +++ b/tests/test_hanging.py @@ -0,0 +1,114 @@ +# tests/test_hanging.py +# Тесты для модуля висячей пунктуации (HangingPunctuationProcessor). + +import pytest +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 +) + +# Вспомогательная функция для создания soup +def make_soup(html_str): + return BeautifulSoup(html_str, 'html.parser') + +# Набор тестовых случаев в формате: +# (режим, входной_html, ожидаемый_html) +HANGING_TEST_CASES = [ + # --- Режим 'left' (только левая пунктуация) --- + ('left', f'

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

', + f'

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

'), + ('left', f'

(Скобки)

', + f'

(Скобки)

'), + # Правая пунктуация игнорируется + ('left', f'

Текст.

', f'

Текст.

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

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

', + f'

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

'), + ('right', f'

Текст.

', + f'

Текст.

'), + # Левая пунктуация игнорируется + ('right', f'

(Скобки)

', f'

(Скобки)

'), + + # --- Режим 'both' (и левая, и правая) --- + ('both', f'

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

', + f'

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

'), + ('both', f'

Текст.

', + f'

Текст.

'), + # Последовательность символов (точка + кавычка) + ('both', f'

Текст.{CHAR_RU_QUOT1_CLOSE}

', + f'

Текст.{CHAR_RU_QUOT1_CLOSE}

'), + # Вложенные теги + ('both', f'

{CHAR_RU_QUOT1_OPEN}Жирный{CHAR_RU_QUOT1_CLOSE}

', + f'

{CHAR_RU_QUOT1_OPEN}Жирный{CHAR_RU_QUOT1_CLOSE}

'), + # Смешанный контент + ('both', f'

{CHAR_RU_QUOT1_OPEN}Начало курсив конец.{CHAR_RU_QUOT1_CLOSE}

', + f'

{CHAR_RU_QUOT1_OPEN}Начало курсив конец.{CHAR_RU_QUOT1_CLOSE}

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

{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

', + f'

{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

'), + (False, f'

{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

', + f'

{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}

'), + + # --- Отсутствие висячих символов --- + ('both', '

Простой текст без спецсимволов!

', '

Простой текст без спецсимволов!

'), + + # --- Проверка контекста (пробелы) --- + # 1. Левая кавычка внутри слова (не должна висеть) + ('both', f'

func{CHAR_RU_QUOT1_OPEN}arg{CHAR_RU_QUOT1_CLOSE}

', + f'

func{CHAR_RU_QUOT1_OPEN}arg{CHAR_RU_QUOT1_CLOSE}

'), # Правая висит, т.к. конец узла + # 2. Правая кавычка внутри слова (не должна висеть) + ('both', f'

1{CHAR_RU_QUOT1_CLOSE}2

', + f'

1{CHAR_RU_QUOT1_CLOSE}2

'), + # 3. Левая кавычка после пробела (должна висеть) + ('both', f'

func {CHAR_RU_QUOT1_OPEN}arg

', + f'

func {CHAR_RU_QUOT1_OPEN}arg

'), + # 4. Правая кавычка перед пробелом (должна висеть) + ('both', f'

arg{CHAR_RU_QUOT1_CLOSE} next

', + f'

arg{CHAR_RU_QUOT1_CLOSE} next

'), + # 5. Точка внутри числа (не должна висеть) + ('both', '

3.14

', '

3.14

'), + # 6. Точка в конце предложения (должна висеть) + ('both', '

End.

', '

End.

'), +] + + +@pytest.mark.parametrize("mode, input_html, expected_html", HANGING_TEST_CASES) +def test_hanging_punctuation_processor(mode, input_html, expected_html): + """ + Проверяет работу HangingPunctuationProcessor в различных режимах. + """ + # Arrange + processor = HangingPunctuationProcessor(mode=mode) + soup = make_soup(input_html) + + # Act + processor.process(soup) + actual_html = str(soup) + + # Assert + assert actual_html == expected_html + + +def test_hanging_punctuation_target_tags(): + """ + Отдельный тест для проверки работы со списком целевых тегов. + """ + mode = ['blockquote', 'h1'] + input_html = (f'
{CHAR_RU_QUOT1_OPEN}Игнор{CHAR_RU_QUOT1_CLOSE}
' + f'
{CHAR_RU_QUOT1_OPEN}Обработка{CHAR_RU_QUOT1_CLOSE}
' + f'

{CHAR_RU_QUOT1_OPEN}Заголовок{CHAR_RU_QUOT1_CLOSE}

') + + expected_html = (f'
{CHAR_RU_QUOT1_OPEN}Игнор{CHAR_RU_QUOT1_CLOSE}
' + f'
{CHAR_RU_QUOT1_OPEN}Обработка{CHAR_RU_QUOT1_CLOSE}
' + f'

{CHAR_RU_QUOT1_OPEN}Заголовок{CHAR_RU_QUOT1_CLOSE}

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