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_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом. # в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов. CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
CHAR_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в & при замене на мнемонику.
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character). CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).

View File

@@ -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, 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: if not text:
return "" return ""
# --- ЭТАП 0: Защита & ---
# Заменяем & на временный плейсхолдер, чтобы он не был декодирован в &
# и не был повторно закодирован в &
text = text.replace('&', CHAR_AMP_PLACEHOLDER)
# Если включена обработка HTML и BeautifulSoup доступен # Если включена обработка HTML и BeautifulSoup доступен
if self.process_html: if self.process_html:
# --- ЭТАП 1: Анализ структуры --- # --- ЭТАП 1: Анализ структуры ---
@@ -264,10 +270,10 @@ class Typographer:
# Проще всего рекурсивно вызвать process с выключенным process_html, # Проще всего рекурсивно вызвать process с выключенным process_html,
# но чтобы не менять состояние объекта, просто выполним логику "else" блока здесь. # но чтобы не менять состояние объекта, просто выполним логику "else" блока здесь.
# Или, еще проще: присвоим text = result и пойдем в блок else? Нет, мы уже внутри if. # Или, еще проще: присвоим text = result и пойдем в блок else? Нет, мы уже внутри if.
# Решение: Выполняем логику обработки простого текста прямо здесь # Решение: Выполняем логику обработки простого текста прямо здесь
return self._process_plain_text(text) return self._process_plain_text(text)
# Если результат - soup, продолжаем работу с ним # Если результат - soup, продолжаем работу с ним
soup = result soup = result
@@ -287,6 +293,7 @@ class Typographer:
super_string = "" super_string = ""
# lengths_map больше не нужен, так как мы используем разделители # lengths_map больше не нужен, так как мы используем разделители
super_string = ""
for node in text_nodes: for node in text_nodes:
# ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded). # ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded).
# str(node) может вернуть экранированные символы (например, &lt; вместо <), # str(node) может вернуть экранированные символы (например, &lt; вместо <),
@@ -311,18 +318,18 @@ class Typographer:
# Но если строка пустая, split вернет [''], и мы возьмем []. # Но если строка пустая, split вернет [''], и мы возьмем [].
# Если строка 'a\uFFFF', split -> ['a', '']. Берем ['a']. # Если строка 'a\uFFFF', split -> ['a', '']. Берем ['a'].
parts = processed_super_string.split(CHAR_NODE_SEPARATOR) parts = processed_super_string.split(CHAR_NODE_SEPARATOR)
# Проверка на целостность: количество частей должно совпадать с количеством узлов. # Проверка на целостность: количество частей должно совпадать с количеством узлов.
# split всегда возвращает хотя бы один элемент. Если super_string пустая, parts=['']. # split всегда возвращает хотя бы один элемент. Если super_string пустая, parts=[''].
# Если super_string не пустая, parts будет иметь длину N+1 (где N - число разделителей). # Если super_string не пустая, parts будет иметь длину N+1 (где N - число разделителей).
# Нам нужны первые N частей. # Нам нужны первые N частей.
if len(parts) > len(text_nodes): if len(parts) > len(text_nodes):
parts = parts[:len(text_nodes)] parts = parts[:len(text_nodes)]
# Если вдруг частей меньше (кто-то удалил разделитель), это проблема. # Если вдруг частей меньше (кто-то удалил разделитель), это проблема.
# Но \uFFFF - Non-character, его сложно удалить случайно. # Но \uFFFF - Non-character, его сложно удалить случайно.
for i, node in enumerate(text_nodes): for i, node in enumerate(text_nodes):
if i < len(parts): if i < len(parts):
new_text_part = parts[i] new_text_part = parts[i]
@@ -364,9 +371,11 @@ class Typographer:
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &amp;), которые мы сгенерировали # BeautifulSoup по умолчанию экранирует амперсанды (& -> &amp;), которые мы сгенерировали
# в _process_text_node. Возвращаем их обратно. # в _process_text_node. Возвращаем их обратно.
return processed_html.replace('&amp;', '&') processed_text = processed_html.replace('&amp;', '&')
else: 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: 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> при обработке фрагментов 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>'), '<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> удалены) # (все незакрытые теги будут закрыты через BS, а тег <html> удалены)
('<ul><li>Исправлена проблема\n с появлением лишних тегов <code><html>++</html></code> и&nbsp;<code><body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>', ('<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>'), '<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) @pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)