add: Висячая пунктуация работает (кроме редких случаев когда пробел, или его отуствие, попадает на следующий bs-узел)

This commit is contained in:
2025-12-23 17:10:56 +03:00
parent d4aa437558
commit 22ddf6ef11
2 changed files with 280 additions and 0 deletions

166
etpgrf/hanging.py Normal file
View 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