641 lines
31 KiB
Python
641 lines
31 KiB
Python
# 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)
|