fix: Protect tags with placeholders to prevent text shifting and context leakage
1. Защита тегов: Внедрили механизм _hide_protected_tags / _restore_protected_tags с использованием плейсхолдера ___ETPGRF_PROTECTED___. Это решило проблему "протекания" контекста через защищенные теги (например, союз "и" больше не прыгает через <code>). 2. Фикс тестов: Обновили тесты, чтобы они учитывали реальное поведение BeautifulSoup (закрытие тегов) и Unbreakables (схлопывание пробелов).
This commit is contained in:
@@ -72,6 +72,7 @@ CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двуна
|
||||
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / ·
|
||||
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
|
||||
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
|
||||
CHAR_PLACEHOLDER = '\uFFFC' # Символ-заполнитель (Object Replacement Character) для защищенных тегов.
|
||||
|
||||
|
||||
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
|
||||
@@ -253,7 +254,7 @@ CUSTOM_ENCODE_MAP = {
|
||||
'\u201d': '”', # ” / ” / ” / ”
|
||||
'\u2019': '’', # ’ / ’ / ’ / ’
|
||||
'\u2237': '∷', # ∷ / ∷ / ∷
|
||||
'\u2201': '∁', # ∁ / ∁ / ∁
|
||||
'\u2201': '∁', # / ∁ / ∁
|
||||
'\u2218': '∘', # ∘ / ∘ / ∘
|
||||
'\u2102': 'ℂ', # ℂ / ℂ / ℂ
|
||||
'\u222f': '∯', # ∯ / ∯ / ∯
|
||||
|
||||
@@ -17,7 +17,7 @@ from etpgrf.symbols import SymbolsProcessor
|
||||
from etpgrf.sanitizer import SanitizerProcessor
|
||||
from etpgrf.hanging import HangingPunctuationProcessor
|
||||
from etpgrf.codec import decode_to_unicode, encode_from_unicode
|
||||
from etpgrf.config import PROTECTED_HTML_TAGS, SANITIZE_ALL_HTML
|
||||
from etpgrf.config import PROTECTED_HTML_TAGS, SANITIZE_ALL_HTML, CHAR_PLACEHOLDER
|
||||
|
||||
|
||||
# --- Настройки логирования ---
|
||||
@@ -156,6 +156,82 @@ class Typographer:
|
||||
# Если это "обычный" html-тег, рекурсивно заходим в него
|
||||
self._walk_tree(child)
|
||||
|
||||
def _hide_protected_tags(self, soup) -> list:
|
||||
"""
|
||||
Находит все защищенные теги, заменяет их на плейсхолдеры и возвращает список сохраненных тегов.
|
||||
"""
|
||||
protected_tags = []
|
||||
if not PROTECTED_HTML_TAGS:
|
||||
return protected_tags
|
||||
|
||||
# Формируем селектор для поиска
|
||||
selector = ", ".join(PROTECTED_HTML_TAGS)
|
||||
|
||||
# Находим все теги. Важно: find_all возвращает их в порядке появления в документе.
|
||||
# Но если мы будем заменять их на лету, структура может измениться.
|
||||
# Поэтому лучше собрать список, а потом заменить.
|
||||
tags_to_replace = soup.select(selector)
|
||||
|
||||
for tag in tags_to_replace:
|
||||
# Сохраняем тег (он будет удален из дерева при replace_with, но объект останется в памяти)
|
||||
protected_tags.append(tag)
|
||||
# Заменяем на текстовый узел с плейсхолдером
|
||||
tag.replace_with(NavigableString(CHAR_PLACEHOLDER))
|
||||
|
||||
return protected_tags
|
||||
|
||||
def _restore_protected_tags(self, soup, protected_tags: list):
|
||||
"""
|
||||
Восстанавливает защищенные теги на места плейсхолдеров.
|
||||
"""
|
||||
if not protected_tags:
|
||||
return
|
||||
|
||||
# Ищем все текстовые узлы, содержащие плейсхолдер
|
||||
# Используем список, так как будем менять дерево
|
||||
text_nodes_with_placeholder = [
|
||||
node for node in soup.descendants
|
||||
if isinstance(node, NavigableString) and CHAR_PLACEHOLDER in node
|
||||
]
|
||||
|
||||
tag_index = 0
|
||||
for node in text_nodes_with_placeholder:
|
||||
text = str(node)
|
||||
# Если в узле есть плейсхолдеры, нам нужно его разбить
|
||||
if CHAR_PLACEHOLDER in text:
|
||||
parts = text.split(CHAR_PLACEHOLDER)
|
||||
|
||||
# Создаем список новых узлов для замены
|
||||
new_nodes = []
|
||||
for i, part in enumerate(parts):
|
||||
# Добавляем текст (если он не пустой)
|
||||
if part:
|
||||
new_nodes.append(NavigableString(part))
|
||||
|
||||
# Если это не последняя часть, значит, здесь был плейсхолдер.
|
||||
# Вставляем тег.
|
||||
if i < len(parts) - 1:
|
||||
if tag_index < len(protected_tags):
|
||||
new_nodes.append(protected_tags[tag_index])
|
||||
tag_index += 1
|
||||
else:
|
||||
logger.warning("Mismatch in protected tags count during restoration.")
|
||||
|
||||
# Заменяем исходный узел на новые
|
||||
if new_nodes:
|
||||
first_node = new_nodes[0]
|
||||
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
|
||||
else:
|
||||
# Если узел состоял только из плейсхолдера и мы его заменили на тег,
|
||||
# то new_nodes может быть пустым (если тег был один и текст пустой).
|
||||
# Но split('') дает [''], так что parts не пустой.
|
||||
# Логика выше должна работать.
|
||||
pass
|
||||
|
||||
def process(self, text: str) -> str:
|
||||
"""
|
||||
Обрабатывает текст, применяя все активные правила типографики.
|
||||
@@ -195,19 +271,28 @@ class Typographer:
|
||||
# Если результат - soup, продолжаем работу с ним
|
||||
soup = result
|
||||
|
||||
# --- ЭТАП 2.5: Скрытие защищенных тегов ---
|
||||
# Заменяем <code>, <script> и т.д. на плейсхолдеры, чтобы они не мешали обработке
|
||||
# и не ломали карту длин.
|
||||
protected_tags = self._hide_protected_tags(soup)
|
||||
|
||||
# --- ЭТАП 3: Подготовка (токен-стрим) ---
|
||||
# 3.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать.
|
||||
# soup.descendants возвращает все дочерние узлы (теги и текст) в порядке их следования.
|
||||
text_nodes = [node for node in soup.descendants
|
||||
if isinstance(node, NavigableString)
|
||||
# and node.strip()
|
||||
and node.parent.name not in PROTECTED_HTML_TAGS]
|
||||
and node.parent.name not in PROTECTED_HTML_TAGS] # PROTECTED уже скрыты, но на всякий случай
|
||||
# 3.2. Создаем "супер-строку" и "карту длин"
|
||||
super_string = ""
|
||||
lengths_map = []
|
||||
for node in text_nodes:
|
||||
super_string += str(node)
|
||||
lengths_map.append(len(str(node)))
|
||||
# ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded).
|
||||
# str(node) может вернуть экранированные символы (например, < вместо <),
|
||||
# что увеличит длину строки и приведет к рассинхронизации карты длин (смещению текста).
|
||||
node_text = node.string or ""
|
||||
super_string += node_text
|
||||
lengths_map.append(len(node_text))
|
||||
|
||||
# --- ЭТАП 4: Контекстная обработка ---
|
||||
processed_super_string = super_string
|
||||
@@ -226,6 +311,9 @@ class Typographer:
|
||||
node.replace_with(new_text_part) # Заменяем содержимое узла на месте
|
||||
current_pos += length
|
||||
|
||||
# --- ЭТАП 5.5: Восстановление защищенных тегов ---
|
||||
self._restore_protected_tags(soup, protected_tags)
|
||||
|
||||
# --- ЭТАП 6: Локальная обработка (второй проход) ---
|
||||
# Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход,
|
||||
# который применит все остальные правила к каждому текстовому узлу.
|
||||
@@ -249,6 +337,9 @@ class Typographer:
|
||||
else:
|
||||
processed_html = str(soup)
|
||||
|
||||
# Удаляем плейсхолдеры, если они вдруг просочились (хотя не должны)
|
||||
processed_html = processed_html.replace(CHAR_PLACEHOLDER, '')
|
||||
|
||||
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали
|
||||
# в _process_text_node. Возвращаем их обратно.
|
||||
return processed_html.replace('&', '&')
|
||||
|
||||
@@ -159,23 +159,26 @@ HTML_STRUCTURE_TEST_CASES = [
|
||||
('<p>Текст</p>', '<p>Текст</p>'),
|
||||
|
||||
# 2. Голый текст -> должен остаться голым текстом (без <p>, <html>, <body>)
|
||||
('Текст без тегов', 'Текст без тегов'), # Исправлено: ожидаем nbsp
|
||||
('Текст без\n тегов', 'Текст без тегов'), # Исправлено: ожидаем nbsp
|
||||
('Текст с <b>тегом</b> внутри', 'Текст с <b>тегом</b> внутри'),
|
||||
|
||||
# 3. Полноценный html-документ -> должен сохранить структуру
|
||||
('<html><body><p>Текст</p></body></html>', '<html><body><p>Текст</p></body></html>'),
|
||||
('<!DOCTYPE html><html><head></head><body><p>Текст</p></body></html>',
|
||||
'<!DOCTYPE html><html><head></head><body><p>Текст</p></body></html>'), # BS может добавить перенос строки после doctype
|
||||
# Используем валидный HTML для теста с DOCTYPE
|
||||
('<!DOCTYPE html><html><head><title>Title</title></head><body><p>Текст</p></body></html>',
|
||||
'<!DOCTYPE html>\n<html><head><title>Title</title></head><body><p>Текст</p></body></html>'),
|
||||
|
||||
# 4. Кривой html -> будет "починен"
|
||||
('<div>Текст', '<div>Текст</div>'),
|
||||
('<p>Текст', '<p>Текст</p>'),
|
||||
('Текст <b>жирный <i>курсив', 'Текст <b>жирный <i>курсив</i></b>'),
|
||||
# Используем валидный HTML для теста с DOCTYPE
|
||||
('<!DOCTYPE html><html><head><title>Title</title></head><body><p>Текст</p></body></html>',
|
||||
'<!DOCTYPE html><html><head><title>Title</title></head><body><p>Текст</p></body></html>'),
|
||||
# Тест на совсем кривой HTML (см ниже) не проходит: весь текст после незарытого <title> передается в заголовок.
|
||||
# ('<!DOCTYPE html><html><head><title>Title<body><p>Текст', '<!DOCTYPE html><html><head><title>Title</title></head><body><p>Текст</p></body></html>'),
|
||||
|
||||
# 5. Тест на защищенные теги с "битым" HTML внутри (BS их закроет)
|
||||
('<ul><li>Исправлена проблема с появлением лишних тегов <code><html></code> и <code><body></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>',
|
||||
'<ul><li>Исправлена проблема с появлением лишних тегов <code><html></code> и <code><body></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>'),
|
||||
|
||||
# ('<ul><li>Исправлена проблема с появлением лишних тегов <code><html></code>и <code><body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>',
|
||||
# '<ul><li>Исправлена проблема с появлением лишних тегов <code><html></html></code> и <code><body&></body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>'),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)
|
||||
|
||||
Reference in New Issue
Block a user