mod: новая схема висячей левой пунктуации (с компенсирующими пробелами)

This commit is contained in:
2026-03-16 13:27:39 +03:00
parent 321c2efc26
commit ce3d1c146a
2 changed files with 336 additions and 61 deletions

View File

@@ -4,11 +4,15 @@
import logging
from bs4 import BeautifulSoup, NavigableString, Tag
from .config import (
HANGING_PUNCTUATION_CHARS,
HANGING_PUNCTUATION_LEFT_CHARS,
HANGING_PUNCTUATION_RIGHT_CHARS,
HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT,
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__)
@@ -32,19 +36,24 @@ class HangingPunctuationProcessor:
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)
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
directions = (HANGING_PUNCTUATION_MODE_LEFT, HANGING_PUNCTUATION_MODE_RIGHT)
else:
normalized_mode = HANGING_PUNCTUATION_MODE_LEFT if mode is True else mode
if normalized_mode == HANGING_PUNCTUATION_MODE_LEFT:
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
elif normalized_mode == HANGING_PUNCTUATION_MODE_RIGHT:
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
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()
@@ -76,26 +85,39 @@ class HangingPunctuationProcessor:
return soup
def _process_node_recursive(self, node, soup):
"""
Рекурсивно обходит узлы. Если находит NavigableString с нужными символами,
разбивает его и вставляет span'ы.
"""Рекурсивно обходит дерево 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 — текст заменяется на набор узлов/тегов.
"""
Анализирует текстовый узел. Если в нем есть символы для висячей пунктуации,
заменяет узел на фрагмент (список узлов), где эти символы обернуты в span.
"""
if self.direction == HANGING_PUNCTUATION_MODE_LEFT:
self._process_text_node_left_mode(text_node, soup)
return
text = str(text_node)
# Быстрая проверка: если в тексте вообще нет ни одного нашего символа, выходим
@@ -112,54 +134,249 @@ class HangingPunctuationProcessor:
should_hang = False
# Проверяем контекст (пробелы или другие висячие символы вокруг)
if char in HANGING_PUNCTUATION_LEFT_CHARS:
# Левая пунктуация:
# 1. Начало узла
# 2. Перед ней пробел
# 3. Перед ней другой левый висячий символ (например, "((text")
if char in self.left_chars:
if (i == 0 or
text[i-1].isspace() or
text[i-1] in HANGING_PUNCTUATION_LEFT_CHARS):
text[i-1] in self.left_chars):
should_hang = True
elif char in HANGING_PUNCTUATION_RIGHT_CHARS:
# Правая пунктуация:
# 1. Конец узла
# 2. После нее пробел
# 3. После нее другой правый висячий символ (например, "text.»")
elif char in self.right_chars:
if (i == text_len - 1 or
text[i+1].isspace() or
text[i+1] in HANGING_PUNCTUATION_RIGHT_CHARS):
text[i+1] in self.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
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

View File

@@ -6,7 +6,13 @@ from bs4 import BeautifulSoup
from etpgrf.hanging import HangingPunctuationProcessor
from etpgrf.config import (
CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE,
CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE
CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE,
CHAR_LPAR, CHAR_LSQB, CHAR_LCUB,
CHAR_RPAR, CHAR_RSQB, CHAR_RCUB,
CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP,
CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP,
CHAR_NULL_SP, CHAR_THIN_NBSP, CHAR_NBSP,
CHAR_ZWNJ
)
# Вспомогательная функция для создания soup
@@ -16,19 +22,34 @@ def make_soup(html_str):
# Набор тестовых случаев в формате:
# (режим, входной_html, ожидаемый_html)
HANGING_TEST_CASES = [
# --- Режим 'left' (только левая пунктуация) ---
# --- Режим 'left' (только левая висячая пунктуация) ---
('left', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата{CHAR_RU_QUOT1_CLOSE}</p>'),
('left', '<p>(Скобки)</p>', '<p><span class="etp-lpar">(</span>Скобки)</p>'),
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</span></p>'),
('left', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE} вначале текста</p>',
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</span> вначале текста</p>'),
('left', f'<p>А вот это {CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
f'<p>А вот <span class="etp-sp-laquo">это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</span> внутри текста</p>'),
('left', f'<p>А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
f'<p>А вот <span class="etp-sp-laquo">это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
('left', f'<p>А вот это <b>{CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}</b> внутри текста</p>',
f'<p>А вот <span class="etp-sp-laquo">это </span><b><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE}</b> внутри текста</p>'),\
('left', f'<p>А вот это <b><i>{CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}</i></b> внутри текста</p>',
f'<p>А вот <span class="etp-sp-laquo">это </span><b><i><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE}</i></b> внутри текста</p>'),
('left', f'<p>А вот это {CHAR_RU_QUOT1_OPEN}<b>Длинная цитата</b>{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
f'<p>А вот <span class="etp-sp-laquo">это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span><b>Длинная цитата</b>{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
('left', f'<p>Неразрывный пробел перед{CHAR_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
f'<p>Неразрывный пробел перед{CHAR_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
('left', '<p>(Скобки)</p>', '<p><span class="etp-lpar">(Скобки)</span></p>'),
('left', '<p>Текст.</p>', '<p>Текст.</p>'),
# --- Режим 'right' (только правая пунктуация) ---
('right', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p>{CHAR_RU_QUOT1_OPEN}Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
('right', '<p>Текст.</p>', '<p>Текст<span class="etp-r-dot">.</span></p>'),
('right', '<p>(Скобки)</p>', '<p>(Скобки<span class="etp-rpar">)</span></p>'),
('right', '<p>3.14</p>', '<p>3.14</p>'),
('right', '<p>End.</p>', '<p>End<span class="etp-r-dot">.</span></p>'),
#('right', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
# f'<p>{CHAR_RU_QUOT1_OPEN}Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
#('right', '<p>Текст.</p>', '<p>Текст<span class="etp-r-dot">.</span></p>'),
#('right', '<p>(Скобки)</p>', '<p>(Скобки<span class="etp-rpar">)</span></p>'),
#('right', '<p>3.14</p>', '<p>3.14</p>'),
#('right', '<p>End.</p>', '<p>End<span class="etp-r-dot">.</span></p>'),
# --- Режим None / False (отключено) ---
(None, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>',
@@ -37,18 +58,6 @@ HANGING_TEST_CASES = [
f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'),
]
# --- Режим list[str] (список тегов с обеими сторонами) ---
HANGING_LIST_MODE_CASES = [
(['p'], f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
(['p'], f'<p>Текст.{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p>Текст<span class="etp-r-dot">.</span><span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
(['p'], f'<p>func {CHAR_RU_QUOT1_OPEN}arg</p>',
f'<p>func <span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>arg</p>'),
(['p'], f'<p>arg{CHAR_RU_QUOT1_CLOSE} next</p>',
f'<p>arg<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span> next</p>'),
]
@pytest.mark.parametrize("mode, input_html, expected_html", HANGING_TEST_CASES)
def test_hanging_punctuation_processor(mode, input_html, expected_html):
@@ -88,11 +97,60 @@ def test_hanging_punctuation_target_tags():
assert str(soup) == expected_html
@pytest.mark.parametrize("mode, input_html, expected_html", HANGING_LIST_MODE_CASES)
def test_hanging_punctuation_processor_list_mode(mode, input_html, expected_html):
"""Проверяет, что list-режим работает и для левой, и для правой стороны внутри указанного тега."""
processor = HangingPunctuationProcessor(mode=mode)
LEFT_FULL_SYMBOLS = [
(CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE, 'etp-laquo'),
(CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE, 'etp-ldquo'),
(CHAR_LPAR, CHAR_RPAR, 'etp-lpar'),
(CHAR_LSQB, CHAR_RSQB, 'etp-lsqb'),
(CHAR_LCUB, CHAR_RCUB, 'etp-lcub'),
]
@pytest.mark.parametrize("open_char, close_char, cls", LEFT_FULL_SYMBOLS)
def test_hanging_left_mode_wraps_symbol_pairs(open_char, close_char, cls):
"""
Убедимся, что разные висячие символы полностью оборачиваются в левом режиме.
open_char: символ, открывающий висячий знак.
close_char: символ, закрывающий висячий знак.
cls: CSS класс для обёртки висячих знаков.
"""
input_html = f'<p>{open_char}Текст{close_char}</p>'
expected_html = f'<p><span class="{cls}">{open_char}Текст{close_char}</span></p>'
processor = HangingPunctuationProcessor(mode='left')
soup = make_soup(input_html)
processor.process(soup)
assert str(soup) == expected_html
SPACE_VARIANTS = [
' ', CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP,
CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP, CHAR_NULL_SP,
]
@pytest.mark.parametrize("space", SPACE_VARIANTS)
def test_hanging_left_mode_compensates_different_spaces(space):
"""Проверяем компенсацию для разных типов разрывных пробелов."""
text = f'<p>Проба{space}{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'
expected_html = (f'<p><span class="etp-sp-laquo">Проба{space}</span>'
f'<span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</span></p>')
processor = HangingPunctuationProcessor(mode='left')
soup = make_soup(text)
processor.process(soup)
assert str(soup) == expected_html
@pytest.mark.parametrize("separator", [CHAR_NBSP, CHAR_THIN_NBSP, CHAR_ZWNJ])
def test_hanging_left_mode_honors_cancellation(separator):
"""Символы, отменяющие перенос, остаются без обёрток."""
input_html = f'<p>Неразрывный{separator}{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'
processor = HangingPunctuationProcessor(mode='left')
soup = make_soup(input_html)
processor.process(soup)
assert str(soup) == input_html