mod: Use node separators and placeholders for robust HTML processing
1. Защита тегов: Защищенные теги (<code>, <script> и т.д.) теперь физически заменяются на плейсхолдеры (\uFFFC) в DOM-дереве перед обработкой. Это предотвращает "протекание" контекста (например, склеивание слов через код) и защищает содержимое тегов от изменений. 2. Маркеры границ: При сборке "супер-строки" (для контекстной обработки) между всеми текстовыми узлами вставляются специальные разделители (\uFFFF). Это позволяет корректно восстанавливать текст по узлам, даже если длина текста изменилась (например, Unbreakables удалил лишние пробелы). Раньше мы полагались на карту длин (lengths_map), что приводило к смещению текста при любых изменениях длины.
This commit is contained in:
@@ -72,7 +72,8 @@ 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) для защищенных тегов.
|
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
|
||||||
|
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
|
||||||
|
|
||||||
|
|
||||||
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
|
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
|
||||||
@@ -254,7 +255,7 @@ CUSTOM_ENCODE_MAP = {
|
|||||||
'\u201d': '”', # ” / ” / ” / ”
|
'\u201d': '”', # ” / ” / ” / ”
|
||||||
'\u2019': '’', # ’ / ’ / ’ / ’
|
'\u2019': '’', # ’ / ’ / ’ / ’
|
||||||
'\u2237': '∷', # ∷ / ∷ / ∷
|
'\u2237': '∷', # ∷ / ∷ / ∷
|
||||||
'\u2201': '∁', # / ∁ / ∁
|
'\u2201': '∁', # ∁ / ∁ / ∁
|
||||||
'\u2218': '∘', # ∘ / ∘ / ∘
|
'\u2218': '∘', # ∘ / ∘ / ∘
|
||||||
'\u2102': 'ℂ', # ℂ / ℂ / ℂ
|
'\u2102': 'ℂ', # ℂ / ℂ / ℂ
|
||||||
'\u222f': '∯', # ∯ / ∯ / ∯
|
'\u222f': '∯', # ∯ / ∯ / ∯
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import regex
|
|||||||
import logging
|
import logging
|
||||||
from .config import (LANG_RU, LANG_EN, CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE, CHAR_EN_QUOT1_OPEN,
|
from .config import (LANG_RU, LANG_EN, CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE, CHAR_EN_QUOT1_OPEN,
|
||||||
CHAR_EN_QUOT1_CLOSE, CHAR_RU_QUOT2_OPEN, CHAR_RU_QUOT2_CLOSE, CHAR_EN_QUOT2_OPEN,
|
CHAR_EN_QUOT1_CLOSE, CHAR_RU_QUOT2_OPEN, CHAR_RU_QUOT2_CLOSE, CHAR_EN_QUOT2_OPEN,
|
||||||
CHAR_EN_QUOT2_CLOSE)
|
CHAR_EN_QUOT2_CLOSE, CHAR_NODE_SEPARATOR)
|
||||||
from .comutil import parse_and_validate_langs
|
from .comutil import parse_and_validate_langs
|
||||||
|
|
||||||
# --- Настройки логирования ---
|
# --- Настройки логирования ---
|
||||||
@@ -40,18 +40,21 @@ class QuotesProcessor:
|
|||||||
f"QuotesProcessor: выбран стиль кавычек для языка '{lang}': '{self.open_quote}...{self.close_quote}'")
|
f"QuotesProcessor: выбран стиль кавычек для языка '{lang}': '{self.open_quote}...{self.close_quote}'")
|
||||||
break # Используем стиль первого найденного языка
|
break # Используем стиль первого найденного языка
|
||||||
|
|
||||||
|
# Экранируем разделитель для использования в regex
|
||||||
|
sep = regex.escape(CHAR_NODE_SEPARATOR)
|
||||||
|
|
||||||
# Паттерн для открывающей кавычки: " перед буквой/цифрой,
|
# Паттерн для открывающей кавычки: " перед буквой/цифрой,
|
||||||
# которой предшествует пробел, начало строки или открывающая скобка.
|
# которой предшествует пробел, начало строки, открывающая скобка ИЛИ разделитель узлов.
|
||||||
# (?<=^|\s|[\(\[„\"‘\']) - "просмотр назад" на начало строки... ищет пробел \s или знак из набора ([„"‘'
|
# (?<=^|\s|[\(\[„\"‘\']|sep) - "просмотр назад" на начало строки... ищет пробел \s или знак из набора ([„"‘' или разделитель
|
||||||
# (?=\p{L}) - "просмотр вперед" на букву \p{L} (но не цифру).
|
# (?=\p{L}|sep) - "просмотр вперед" на букву \p{L} (но не цифру) ИЛИ разделитель узлов.
|
||||||
self._opening_quote_pattern = regex.compile(r'(?<=^|\s|[\(\[„\"‘\'])\"(?=\p{L})')
|
self._opening_quote_pattern = regex.compile(rf'(?<=^|\s|[\(\[„\"‘\']|{sep})\"(?=\p{{L}}|{sep})')
|
||||||
# self._opening_quote_pattern = regex.compile(r'(?<=^|\s|\p{Pi}|["\'\(\)])\"(?=\p{L})')
|
# self._opening_quote_pattern = regex.compile(r'(?<=^|\s|\p{Pi}|["\'\(\)])\"(?=\p{L})')
|
||||||
|
|
||||||
# Паттерн для закрывающей кавычки: " после буквы/цифры,
|
# Паттерн для закрывающей кавычки: " после буквы/цифры,
|
||||||
# за которой следует пробел, пунктуация или конец строки.
|
# за которой следует пробел, пунктуация, конец строки ИЛИ разделитель узлов.
|
||||||
# (?<=\p{L}|[?!…\.]) - "просмотр назад" на букву или ?!… и точку.
|
# (?<=\p{L}|[?!…\.]|sep) - "просмотр назад" на букву или ?!… и точку ИЛИ разделитель узлов.
|
||||||
# (?=\s|[.,;:!?\)\"»”’]|\Z) - "просмотр вперед" на пробел, пунктуацию или конец строки (\Z).
|
# (?=\s|[.,;:!?\)\"»”’]|\Z|sep) - "просмотр вперед" на пробел, пунктуацию, конец строки (\Z) или разделитель.
|
||||||
self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…\.])\"(?=\s|[\.,;:!?\)\]»”’\"\']|\Z)')
|
self._closing_quote_pattern = regex.compile(rf'(?<=\p{{L}}|[?!…\.]|{sep})\"(?=\s|[\.,;:!?\)\]»”’\"\']|\Z|{sep})')
|
||||||
# self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|\p{N})\"(?=\s|[\.,;:!?\)\"»”’]|\Z)')
|
# self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|\p{N})\"(?=\s|[\.,;:!?\)\"»”’]|\Z)')
|
||||||
# self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…])\"(?=\s|[\p{Po}\p{Pf}"\']|\Z)')
|
# self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…])\"(?=\s|[\p{Po}\p{Pf}"\']|\Z)')
|
||||||
|
|
||||||
|
|||||||
@@ -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, CHAR_PLACEHOLDER
|
from etpgrf.config import PROTECTED_HTML_TAGS, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR
|
||||||
|
|
||||||
|
|
||||||
# --- Настройки логирования ---
|
# --- Настройки логирования ---
|
||||||
@@ -277,22 +277,23 @@ class Typographer:
|
|||||||
protected_tags = self._hide_protected_tags(soup)
|
protected_tags = self._hide_protected_tags(soup)
|
||||||
|
|
||||||
# --- ЭТАП 3: Подготовка (токен-стрим) ---
|
# --- ЭТАП 3: Подготовка (токен-стрим) ---
|
||||||
# 3.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать.
|
# 3.1. Создаем "токен-стрим" из текстовых узлов.
|
||||||
# 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] # PROTECTED уже скрыты, но на всякий случай
|
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:
|
||||||
# ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded).
|
# ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded).
|
||||||
# str(node) может вернуть экранированные символы (например, < вместо <),
|
# str(node) может вернуть экранированные символы (например, < вместо <),
|
||||||
# что увеличит длину строки и приведет к рассинхронизации карты длин (смещению текста).
|
# что увеличит длину строки и приведет к рассинхронизации карты длин (смещению текста).
|
||||||
node_text = node.string or ""
|
node_text = node.string or ""
|
||||||
super_string += node_text
|
# Добавляем текст и разделитель
|
||||||
lengths_map.append(len(node_text))
|
super_string += node_text + CHAR_NODE_SEPARATOR
|
||||||
|
|
||||||
# --- ЭТАП 4: Контекстная обработка ---
|
# --- ЭТАП 4: Контекстная обработка ---
|
||||||
processed_super_string = super_string
|
processed_super_string = super_string
|
||||||
@@ -304,19 +305,40 @@ class Typographer:
|
|||||||
processed_super_string = self.unbreakables.process(processed_super_string)
|
processed_super_string = self.unbreakables.process(processed_super_string)
|
||||||
|
|
||||||
# --- ЭТАП 5: Восстановление структуры ---
|
# --- ЭТАП 5: Восстановление структуры ---
|
||||||
current_pos = 0
|
# Разбиваем строку по разделителям.
|
||||||
|
# split вернет список, где последний элемент будет пустым (из-за разделителя в конце).
|
||||||
|
# Поэтому берем все элементы, кроме последнего.
|
||||||
|
# Но если строка пустая, 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):
|
for i, node in enumerate(text_nodes):
|
||||||
length = lengths_map[i]
|
if i < len(parts):
|
||||||
new_text_part = processed_super_string[current_pos : current_pos + length]
|
new_text_part = parts[i]
|
||||||
node.replace_with(new_text_part) # Заменяем содержимое узла на месте
|
# Заменяем содержимое узла.
|
||||||
current_pos += length
|
# Важно: если new_text_part содержит CHAR_PLACEHOLDER, он останется как есть
|
||||||
|
# и будет обработан на этапе 5.5.
|
||||||
|
node.replace_with(new_text_part)
|
||||||
|
|
||||||
# --- ЭТАП 5.5: Восстановление защищенных тегов ---
|
# --- ЭТАП 5.5: Восстановление защищенных тегов ---
|
||||||
self._restore_protected_tags(soup, protected_tags)
|
self._restore_protected_tags(soup, protected_tags)
|
||||||
|
|
||||||
# --- ЭТАП 6: Локальная обработка (второй проход) ---
|
# --- ЭТАП 6: Локальная обработка (второй проход) ---
|
||||||
# Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход,
|
# Теперь, когда структура восстановлена (включая защищенные теги),
|
||||||
# который применит все остальные правила к каждому текстовому узлу.
|
# запускаем рекурсивный обход.
|
||||||
|
# Важно: _walk_tree пропускает PROTECTED_HTML_TAGS, так что содержимое
|
||||||
|
# восстановленных тегов не будет обработано повторно.
|
||||||
self._walk_tree(soup)
|
self._walk_tree(soup)
|
||||||
|
|
||||||
# --- ЭТАП 7: Висячая пунктуация ---
|
# --- ЭТАП 7: Висячая пунктуация ---
|
||||||
@@ -337,8 +359,8 @@ class Typographer:
|
|||||||
else:
|
else:
|
||||||
processed_html = str(soup)
|
processed_html = str(soup)
|
||||||
|
|
||||||
# Удаляем плейсхолдеры, если они вдруг просочились (хотя не должны)
|
# Удаляем плейсхолдеры и разделители, если они вдруг просочились
|
||||||
processed_html = processed_html.replace(CHAR_PLACEHOLDER, '')
|
processed_html = processed_html.replace(CHAR_PLACEHOLDER, '').replace(CHAR_NODE_SEPARATOR, '')
|
||||||
|
|
||||||
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали
|
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали
|
||||||
# в _process_text_node. Возвращаем их обратно.
|
# в _process_text_node. Возвращаем их обратно.
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ HTML_STRUCTURE_TEST_CASES = [
|
|||||||
('<p>Текст</p>', '<p>Текст</p>'),
|
('<p>Текст</p>', '<p>Текст</p>'),
|
||||||
|
|
||||||
# 2. Голый текст -> должен остаться голым текстом (без <p>, <html>, <body>)
|
# 2. Голый текст -> должен остаться голым текстом (без <p>, <html>, <body>)
|
||||||
('Текст без\n тегов', 'Текст без тегов'), # Исправлено: ожидаем nbsp
|
('Текст без тегов', 'Текст без тегов'), # Исправлено: ожидаем nbsp
|
||||||
('Текст с <b>тегом</b> внутри', 'Текст с <b>тегом</b> внутри'),
|
('Текст с <b>тегом</b> внутри', 'Текст с <b>тегом</b> внутри'),
|
||||||
|
|
||||||
# 3. Полноценный html-документ -> должен сохранить структуру
|
# 3. Полноценный html-документ -> должен сохранить структуру
|
||||||
@@ -174,11 +174,13 @@ HTML_STRUCTURE_TEST_CASES = [
|
|||||||
('Текст <b>жирный <i>курсив', 'Текст <b>жирный <i>курсив</i></b>'),
|
('Текст <b>жирный <i>курсив', 'Текст <b>жирный <i>курсив</i></b>'),
|
||||||
|
|
||||||
# 5. Тест на защищенные теги с "битым" HTML внутри (BS их закроет)
|
# 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></code> и <code><body></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>'),
|
||||||
|
|
||||||
# ('<ul><li>Исправлена проблема с появлением лишних тегов <code><html></code>и <code><body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>',
|
# 6/ Исправленный тест на защищенные теги с немаскированными HTML внутри
|
||||||
# '<ul><li>Исправлена проблема с появлением лишних тегов <code><html></html></code> и <code><body&></body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>'),
|
# (все незакрытые теги будут закрыты через BS, а тег <html> удалены)
|
||||||
|
('<ul><li>Исправлена проблема\n с появлением лишних тегов <code><html>++</html></code> и <code><body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>',
|
||||||
|
'<ul><li>Исправлена проблема\n с появлением лишних тегов <code>++</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