# 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: """ Оборачивает символы висячей пунктуации в специальные теги с классами. """ 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