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