add: Висячая пунктуация работает (кроме редких случаев когда пробел, или его отуствие, попадает на следующий bs-узел)
This commit is contained in:
166
etpgrf/hanging.py
Normal file
166
etpgrf/hanging.py
Normal file
@@ -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:
|
||||||
|
"""
|
||||||
|
Оборачивает символы висячей пунктуации в специальные теги <span> с классами.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
114
tests/test_hanging.py
Normal file
114
tests/test_hanging.py
Normal file
@@ -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'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||||
|
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата{CHAR_RU_QUOT1_CLOSE}</p>'),
|
||||||
|
('left', f'<p>(Скобки)</p>',
|
||||||
|
f'<p><span class="etp-lpar">(</span>Скобки)</p>'),
|
||||||
|
# Правая пунктуация игнорируется
|
||||||
|
('left', f'<p>Текст.</p>', f'<p>Текст.</p>'),
|
||||||
|
|
||||||
|
# --- Режим 'right' (только правая пунктуация) ---
|
||||||
|
('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>Текст.</p>',
|
||||||
|
f'<p>Текст<span class="etp-r-dot">.</span></p>'),
|
||||||
|
# Левая пунктуация игнорируется
|
||||||
|
('right', f'<p>(Скобки)</p>', f'<p>(Скобки<span class="etp-rpar">)</span></p>'),
|
||||||
|
|
||||||
|
# --- Режим 'both' (и левая, и правая) ---
|
||||||
|
('both', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||||
|
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
|
||||||
|
('both', f'<p>Текст.</p>',
|
||||||
|
f'<p>Текст<span class="etp-r-dot">.</span></p>'),
|
||||||
|
# Последовательность символов (точка + кавычка)
|
||||||
|
('both', f'<p>Текст.{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||||
|
f'<p>Текст<span class="etp-r-dot">.</span><span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
|
||||||
|
# Вложенные теги
|
||||||
|
('both', f'<p><b>{CHAR_RU_QUOT1_OPEN}Жирный{CHAR_RU_QUOT1_CLOSE}</b></p>',
|
||||||
|
f'<p><b><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Жирный<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></b></p>'),
|
||||||
|
# Смешанный контент
|
||||||
|
('both', f'<p>{CHAR_RU_QUOT1_OPEN}Начало <i>курсив</i> конец.{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||||
|
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Начало <i>курсив</i> конец<span class="etp-r-dot">.</span><span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
|
||||||
|
|
||||||
|
# --- Режим None / False (отключено) ---
|
||||||
|
(None, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||||
|
f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'),
|
||||||
|
(False, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||||
|
f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'),
|
||||||
|
|
||||||
|
# --- Отсутствие висячих символов ---
|
||||||
|
('both', '<p>Простой текст без спецсимволов!</p>', '<p>Простой текст без спецсимволов!</p>'),
|
||||||
|
|
||||||
|
# --- Проверка контекста (пробелы) ---
|
||||||
|
# 1. Левая кавычка внутри слова (не должна висеть)
|
||||||
|
('both', f'<p>func{CHAR_RU_QUOT1_OPEN}arg{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||||
|
f'<p>func{CHAR_RU_QUOT1_OPEN}arg<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'), # Правая висит, т.к. конец узла
|
||||||
|
# 2. Правая кавычка внутри слова (не должна висеть)
|
||||||
|
('both', f'<p>1{CHAR_RU_QUOT1_CLOSE}2</p>',
|
||||||
|
f'<p>1{CHAR_RU_QUOT1_CLOSE}2</p>'),
|
||||||
|
# 3. Левая кавычка после пробела (должна висеть)
|
||||||
|
('both', f'<p>func {CHAR_RU_QUOT1_OPEN}arg</p>',
|
||||||
|
f'<p>func <span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>arg</p>'),
|
||||||
|
# 4. Правая кавычка перед пробелом (должна висеть)
|
||||||
|
('both', f'<p>arg{CHAR_RU_QUOT1_CLOSE} next</p>',
|
||||||
|
f'<p>arg<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span> next</p>'),
|
||||||
|
# 5. Точка внутри числа (не должна висеть)
|
||||||
|
('both', '<p>3.14</p>', '<p>3.14</p>'),
|
||||||
|
# 6. Точка в конце предложения (должна висеть)
|
||||||
|
('both', '<p>End.</p>', '<p>End<span class="etp-r-dot">.</span></p>'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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'<div>{CHAR_RU_QUOT1_OPEN}Игнор{CHAR_RU_QUOT1_CLOSE}</div>'
|
||||||
|
f'<blockquote>{CHAR_RU_QUOT1_OPEN}Обработка{CHAR_RU_QUOT1_CLOSE}</blockquote>'
|
||||||
|
f'<h1>{CHAR_RU_QUOT1_OPEN}Заголовок{CHAR_RU_QUOT1_CLOSE}</h1>')
|
||||||
|
|
||||||
|
expected_html = (f'<div>{CHAR_RU_QUOT1_OPEN}Игнор{CHAR_RU_QUOT1_CLOSE}</div>'
|
||||||
|
f'<blockquote><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Обработка<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></blockquote>'
|
||||||
|
f'<h1><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Заголовок<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></h1>')
|
||||||
|
|
||||||
|
processor = HangingPunctuationProcessor(mode=mode)
|
||||||
|
soup = make_soup(input_html)
|
||||||
|
|
||||||
|
processor.process(soup)
|
||||||
|
|
||||||
|
assert str(soup) == expected_html
|
||||||
Reference in New Issue
Block a user