Files
2025-etpgrf/etpgrf/hanging.py

167 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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