Files
2025-etpgrf/etpgrf/hanging.py

641 lines
31 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 getattr(child, 'parent', None) is None:
# Узел уже был заменён/удалён при обработке соседей
continue
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)
if self.direction == HANGING_PUNCTUATION_MODE_RIGHT:
last_right = self._find_last_right_char_in_node(child)
if last_right:
self._wrap_parent_space_after_child(child, last_right, 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
if self.direction == HANGING_PUNCTUATION_MODE_RIGHT:
self._process_text_node_right_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
prev_char = self._peek_previous_char_before_text_node(text_node, i)
if prev_char and self._is_word_char(prev_char):
# Если перед левым символом идёт буква/цифра/подчёркивание — это не висячий символ внутри слова
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:
"""Возвращает предыдущий текстовый узел, если он содержит непустой текст."""
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:
"""Заменяет текстовый узел на последовательность новых узлов."""
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
def _find_last_right_char_in_node(self, node: Tag) -> str | None:
"""Ищет последний символ правого режима во вложенном поддереве."""
last_char = None
for descendant in node.descendants:
if isinstance(descendant, NavigableString):
for char in str(descendant):
if char in self.right_chars:
last_char = char
return last_char
def _wrap_parent_space_after_child(self, child: Tag, last_right_char: str, soup: BeautifulSoup) -> bool:
"""Оборачивает пробел после дочернего узла у родителя."""
next_text_node = self._find_next_navigable_text(child)
if not next_text_node:
return False
fragment = self._extract_leading_compensation_fragment(str(next_text_node), 0, HANGING_PUNCTUATION_SPACE_CHARS)
if not fragment:
return False
text_start, text_end = fragment
substring = str(next_text_node)[text_start:text_end]
tail = str(next_text_node)[text_end:]
css_class = self.space_classes.get(last_right_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] = [span]
if tail:
new_nodes.append(NavigableString(tail))
self._replace_text_node(next_text_node, new_nodes)
# Если после обёртки остался текстовый хвост, догоним его обработкой правого режима.
if self.direction == HANGING_PUNCTUATION_MODE_RIGHT and new_nodes and isinstance(new_nodes[-1], NavigableString):
self._process_text_node(new_nodes[-1], soup)
return True
def _extract_leading_compensation_fragment(self, text: str, start_idx: int,
boundary_chars: frozenset[str]) -> tuple[int, int] | None:
"""Извлекает диапазон пробелов, начинающийся с заданного индекса."""
if start_idx >= len(text) or text[start_idx] not in boundary_chars:
return None
end = start_idx
while end < len(text) and text[end] in boundary_chars:
end += 1
return (start_idx, end) if end > start_idx else None
def _find_next_navigable_text(self, child: Tag) -> NavigableString | None:
"""Возвращает следующий текстовый узел, если он содержит непустой текст."""
next = child.next_sibling
while next:
if isinstance(next, NavigableString) and next.strip():
return next
next = next.next_sibling
return None
def _process_text_node_right_mode(self, text_node: NavigableString, soup: BeautifulSoup):
"""Аналогичный левому алгоритм, но в правом направлении.
:param text_node: Текстовый узел, содержащий возможную правую пунктуацию.
:param soup: Парсер для создания тегов.
:return: None.
"""
text = str(text_node)
if not any(char in self.right_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
right_spans: list[tuple[Tag, str]] = []
while i < text_len:
char = text[i]
if char not in self.right_chars:
# Пропускаем нецелевые символы
i += 1
continue
if i > 0 and text[i-1] in cancellation_chars:
# Не трогаем коль символ окружён запретными символами
i += 1
continue
prev_char = self._peek_previous_char_before_text_node(text_node, i)
if prev_char in cancellation_chars:
# Учёт запретных символов даже если они находятся в соседнем узле слева от текущей позиции.
i += 1
continue
next_char = self._peek_next_char_after_text_node(text_node, i)
if next_char in cancellation_chars:
# Пропускаем висячий символ, если сразу после него идёт запретный символ в любом соседнем узле
i += 1
continue
if self._is_word_char(next_char):
# Не оборачиваем символы внутри слов/идентификаторов и цифр
i += 1
continue
# Находим диапазон слова, которое должно соединиться с текущей правой пунктуацией.
comp_bounds = self._locate_right_compensation_bounds(text, i, last_index, boundary_chars)
if not comp_bounds:
# Нет слова в текущем узле (граница узла или символ стоит первым) — оборачиваем сам символ
if i > last_index:
nodes.append(NavigableString(text[last_index:i]))
solo_span = soup.new_tag('span')
solo_span['class'] = self.char_to_class.get(char, '')
solo_span.string = char
nodes.append(solo_span)
right_spans.append((solo_span, char))
last_index = i + 1
boundary_fragment = self._extract_leading_compensation_fragment(text, last_index, boundary_chars)
if boundary_fragment:
space_start, space_end = boundary_fragment
space_span = soup.new_tag('span')
space_span['class'] = self.space_classes.get(char, '')
space_span.string = text[space_start:space_end]
nodes.append(space_span)
last_index = space_end
i = space_end
else:
i = last_index
continue
span_start, span_mid, right_idx = comp_bounds
if span_start > last_index and text[span_start - 1] in self.left_chars:
# Захватываем прилегающую слева левую пунктуацию вместе со словом
span_start -= 1
if span_start > last_index:
# Вставляем текст между предыдущим фрагментом и текущим словом, чтобы ничего не потерять.
nodes.append(NavigableString(text[last_index:span_start]))
right_span = soup.new_tag('span')
right_span['class'] = self.char_to_class.get(char, '')
# Объединяем слово перед правым символом и сам символ без промежуточных пробелов
right_span.string = text[span_start:span_mid] + text[right_idx:right_idx + 1]
nodes.append(right_span)
right_spans.append((right_span, char))
last_index = right_idx + 1
# После символа оборачиваем пробелы-компенсаторы в отдельные span'ы
boundary_fragment = self._extract_leading_compensation_fragment(text, last_index, boundary_chars)
if boundary_fragment:
space_start, space_end = boundary_fragment
space_span = soup.new_tag('span')
space_span['class'] = self.space_classes.get(char, '')
space_span.string = text[space_start:space_end]
nodes.append(space_span)
last_index = space_end
i = space_end
else:
# Продолжаем сканирование сразу после обработанного правого символа.
i = last_index
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
for span_node, span_char in right_spans:
self._wrap_parent_space_after_child(span_node, span_char, soup)
def _peek_next_char_after_text_node(self, text_node: NavigableString, idx: int) -> str | None:
"""Ищет следующий символ текста, переходя через границы узлов."""
node = text_node
offset = idx + 1
while node:
if isinstance(node, NavigableString):
text = str(node)
if offset < len(text):
return text[offset]
offset = 0
node = node.next_element
return None
def _peek_previous_char_before_text_node(self, text_node: NavigableString, idx: int) -> str | None:
"""Ищет предыдущий символ текста перед текущим индексом, переходя между узлами."""
if idx > 0:
return str(text_node)[idx - 1]
node = text_node.previous_element
while node:
if isinstance(node, NavigableString):
text = str(node)
if text:
return text[-1]
node = node.previous_element
return None
def _is_word_char(self, char: str | None) -> bool:
"""Возвращает True, если браузер не может разорвать строку именно по этому символу."""
return bool(char) and char not in HANGING_PUNCTUATION_SPACE_CHARS
def _locate_right_compensation_bounds(self, text: str, right_idx: int, last_idx: int,
boundary_chars: frozenset[str]) -> tuple[int, int, int] | None:
"""Находит пределы слова перед правым символом и сам символ без учета пробелов.
:param text: Строка, из которой извлекаются границы.
:param right_idx: Индекс правого символа (например, '»').
:param last_idx: Последний обработанный индекс — чтобы не перекрывать уже взятые фрагменты.
:param boundary_chars: Набор символов, разрешённых для разделения слов.
:return: Кортеж (start_of_word, boundary_start, right_idx) или None, если нет подходящего слова.
"""
if right_idx <= last_idx:
return None
prev_idx = right_idx - 1
# Отбрасываем разделители между словом и символом, чтобы добраться до последнего слова.
while prev_idx >= last_idx and text[prev_idx] in boundary_chars:
prev_idx -= 1
if prev_idx < last_idx:
return None
word_end = prev_idx + 1
word_start = word_end
# Поднимаемся к началу слова, чтобы взять все символы перед правым знаком.
stopper = boundary_chars | (self.right_chars - {text[right_idx]})
while word_start > last_idx and text[word_start - 1] not in stopper:
word_start -= 1
# Разрешаем захватить ведущий левый символ, если он стоит вплотную к слову.
while word_start > last_idx and text[word_start - 1] in self.left_chars:
word_start -= 1
if word_start == word_end:
# Если перед символом нет слова, ничего оборачивать не нужно.
return None
return (word_start, word_end, right_idx)