From ace8b61ae3ae9ff60468365eee122fdfe7919ac7 Mon Sep 17 00:00:00 2001 From: erjemin Date: Thu, 26 Feb 2026 14:29:54 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=B2=D0=BE=D0=B9=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20&?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etpgrf/config.py | 1 + etpgrf/typograph.py | 27 ++++++++++++++++++--------- tests/test_typograph.py | 7 ++++++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/etpgrf/config.py b/etpgrf/config.py index 202425a..e8e6549 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -73,6 +73,7 @@ CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда испо CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить # в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом. CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов. +CHAR_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в & при замене на мнемонику. CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character). diff --git a/etpgrf/typograph.py b/etpgrf/typograph.py index 49a5ec0..68cb312 100644 --- a/etpgrf/typograph.py +++ b/etpgrf/typograph.py @@ -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: Защита & --- + # Заменяем & на временный плейсхолдер, чтобы он не был декодирован в & + # и не был повторно закодирован в &amp; + text = text.replace('&', CHAR_AMP_PLACEHOLDER) + # Если включена обработка HTML и BeautifulSoup доступен if self.process_html: # --- ЭТАП 1: Анализ структуры --- @@ -264,10 +270,10 @@ class Typographer: # Проще всего рекурсивно вызвать process с выключенным process_html, # но чтобы не менять состояние объекта, просто выполним логику "else" блока здесь. # Или, еще проще: присвоим text = result и пойдем в блок else? Нет, мы уже внутри if. - + # Решение: Выполняем логику обработки простого текста прямо здесь return self._process_plain_text(text) - + # Если результат - soup, продолжаем работу с ним soup = result @@ -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) может вернуть экранированные символы (например, < вместо <), @@ -311,18 +318,18 @@ class Typographer: # Но если строка пустая, split вернет [''], и мы возьмем []. # Если строка 'a\uFFFF', split -> ['a', '']. Берем ['a']. parts = processed_super_string.split(CHAR_NODE_SEPARATOR) - + # Проверка на целостность: количество частей должно совпадать с количеством узлов. # split всегда возвращает хотя бы один элемент. Если super_string пустая, parts=['']. # Если super_string не пустая, parts будет иметь длину N+1 (где N - число разделителей). # Нам нужны первые N частей. - + if len(parts) > len(text_nodes): parts = parts[:len(text_nodes)] - + # Если вдруг частей меньше (кто-то удалил разделитель), это проблема. # Но \uFFFF - Non-character, его сложно удалить случайно. - + for i, node in enumerate(text_nodes): if i < len(parts): new_text_part = parts[i] @@ -364,9 +371,11 @@ class Typographer: # BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали # в _process_text_node. Возвращаем их обратно. - return processed_html.replace('&', '&') + processed_text = processed_html.replace('&', '&') else: - return self._process_plain_text(text) + # Для простого текста тоже нужна защита & + processed_text = self._process_plain_text(text) + return processed_text.replace(CHAR_AMP_PLACEHOLDER, '&') def _process_plain_text(self, text: str) -> str: """ diff --git a/tests/test_typograph.py b/tests/test_typograph.py index 9e99e9a..65b3be5 100644 --- a/tests/test_typograph.py +++ b/tests/test_typograph.py @@ -177,10 +177,15 @@ HTML_STRUCTURE_TEST_CASES = [ ('
Заголовок
', '
Заголовок
'), - # 6/ Исправленный тест на защищенные теги с немаскированными HTML внутри + # 6. Исправленный тест на защищенные теги с немаскированными HTML внутри # (все незакрытые теги будут закрыты через BS, а тег удалены) ('
Заголовок
', '
Заголовок
'), + + # 7. Тест на маскированные мнемоники и де-экранирование & + ('

Текст с < и > и & внутри.

', '

Текст с < и > и & внутри.

'), + ('

Текст с &lt; и &gt; и &amp; внутри.

', '

Текст с &lt; и &gt; и &amp; внутри.

'), + ('

Мнемоника &nbsp; превратится в неразрывный пробел

', '

Мнемоника &nbsp; превратится в неразрывный пробел

'), ] @pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)