Files
2025-etpgrf/etpgrf/hanging.py

383 lines
18 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_CHARS,
HANGING_PUNCTUATION_LEFT_CHARS,
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__)
class HangingPunctuationProcessor:
"""
Оборачивает символы висячей пунктуации в специальные теги <span> с классами.
"""
def __init__(self, mode: str | bool | list[str] | None = None):
"""
:param mode: Режим работы:
- None / False: отключено.
- 'left': левая висячая пунктуация.
- 'right': правая висячая пунктуация.
- list[str]: список тегов (например, ['p', 'blockquote']),
внутри которых применять висячую пунктуацию в обе стороны.
- True эквивалентно 'left'.
"""
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)
directions = (HANGING_PUNCTUATION_MODE_LEFT, HANGING_PUNCTUATION_MODE_RIGHT)
else:
normalized_mode = HANGING_PUNCTUATION_MODE_LEFT if mode is True else mode
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
for char, cls in HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT.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):
"""Рекурсивно обходит дерево 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 — текст заменяется на набор узлов/тегов.
"""
if self.direction == HANGING_PUNCTUATION_MODE_LEFT:
self._process_text_node_left_mode(text_node, soup)
return
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 self.left_chars:
if (i == 0 or
text[i-1].isspace() or
text[i-1] in self.left_chars):
should_hang = True
elif char in self.right_chars:
if (i == text_len - 1 or
text[i+1].isspace() or
text[i+1] in self.right_chars):
should_hang = True
if should_hang:
if current_text_buffer:
new_nodes.append(NavigableString(current_text_buffer))
current_text_buffer = ""
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