mod: новая схема висячей левой пунктуации (с компенсирующими пробелами)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user