fix: исправлено удаление двойного экранирования &

This commit is contained in:
2026-02-26 14:29:54 +03:00
parent c54ae63030
commit ace8b61ae3
3 changed files with 25 additions and 10 deletions

View File

@@ -73,6 +73,7 @@ CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда испо
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
CHAR_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в & при замене на мнемонику.
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).

View File

@@ -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, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR
from etpgrf.config import PROTECTED_HTML_TAGS, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR, CHAR_AMP_PLACEHOLDER
# --- Настройки логирования ---
@@ -239,6 +239,12 @@ class Typographer:
"""
if not text:
return ""
# --- ЭТАП 0: Защита & ---
# Заменяем & на временный плейсхолдер, чтобы он не был декодирован в &
# и не был повторно закодирован в &
text = text.replace('&', CHAR_AMP_PLACEHOLDER)
# Если включена обработка HTML и BeautifulSoup доступен
if self.process_html:
# --- ЭТАП 1: Анализ структуры ---
@@ -287,6 +293,7 @@ class Typographer:
super_string = ""
# lengths_map больше не нужен, так как мы используем разделители
super_string = ""
for node in text_nodes:
# ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded).
# str(node) может вернуть экранированные символы (например, &lt; вместо <),
@@ -364,9 +371,11 @@ class Typographer:
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &amp;), которые мы сгенерировали
# в _process_text_node. Возвращаем их обратно.
return processed_html.replace('&amp;', '&')
processed_text = processed_html.replace('&amp;', '&')
else:
return self._process_plain_text(text)
# Для простого текста тоже нужна защита &amp;
processed_text = self._process_plain_text(text)
return processed_text.replace(CHAR_AMP_PLACEHOLDER, '&amp;')
def _process_plain_text(self, text: str) -> str:
"""

View File

@@ -177,10 +177,15 @@ HTML_STRUCTURE_TEST_CASES = [
('<ul><li>Исправлена проблема с&nbsp;появлением лишних тегов <code>&lt;html&gt;</code> и&nbsp;<code>&lt;body&gt;</code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>',
'<ul><li>Исправлена проблема с&nbsp;появлением лишних тегов <code>&lt;html&gt;</code> и&nbsp;<code>&lt;body&gt;</code> при&nbsp;обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>'),
# 6/ Исправленный тест на защищенные теги с немаскированными HTML внутри
# 6. Исправленный тест на защищенные теги с немаскированными HTML внутри
# (все незакрытые теги будут закрыты через BS, а тег <html> удалены)
('<ul><li>Исправлена проблема\n с появлением лишних тегов <code><html>++</html></code> и&nbsp;<code><body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>',
'<ul><li>Исправлена проблема\n с&nbsp;появлением лишних тегов <code>++</code> и&nbsp;<code><body&></body&></code> при&nbsp;обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>'),
# 7. Тест на маскированные мнемоники и де-экранирование &amp;
('<p>Текст с &lt; и &gt; и &amp; внутри.</p>', '<p>Текст с&nbsp;&lt; и&nbsp;&gt; и&nbsp;&amp; внутри.</p>'),
('<p>Текст с &amp;lt; и &amp;gt; и &amp;amp; внутри.</p>', '<p>Текст с&nbsp;&amp;lt; и&nbsp;&amp;gt; и&nbsp;&amp;amp; внутри.</p>'),
('<p>Мнемоника <code>&amp;nbsp;</code> превратится в неразрывный пробел</p>', '<p>Мнемоника <code>&amp;nbsp;</code> превратится в&nbsp;неразрывный пробел</p>'),
]
@pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)