diff --git a/etpgrf/config.py b/etpgrf/config.py index 609f39b..202425a 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -72,7 +72,8 @@ CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двуна CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / · 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': '”', # ” / ” / ” / ” '\u2019': '’', # ’ / ’ / ’ / ’ '\u2237': '∷', # ∷ / ∷ / ∷ - '\u2201': '∁', # / ∁ / ∁ + '\u2201': '∁', # ∁ / ∁ / ∁ '\u2218': '∘', # ∘ / ∘ / ∘ '\u2102': 'ℂ', # ℂ / ℂ / ℂ '\u222f': '∯', # ∯ / ∯ / ∯ diff --git a/etpgrf/quotes.py b/etpgrf/quotes.py index 1ff3b93..59f517e 100644 --- a/etpgrf/quotes.py +++ b/etpgrf/quotes.py @@ -5,7 +5,7 @@ import regex import logging 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_QUOT2_CLOSE) + CHAR_EN_QUOT2_CLOSE, CHAR_NODE_SEPARATOR) from .comutil import parse_and_validate_langs # --- Настройки логирования --- @@ -40,18 +40,21 @@ class QuotesProcessor: f"QuotesProcessor: выбран стиль кавычек для языка '{lang}': '{self.open_quote}...{self.close_quote}'") break # Используем стиль первого найденного языка + # Экранируем разделитель для использования в regex + sep = regex.escape(CHAR_NODE_SEPARATOR) + # Паттерн для открывающей кавычки: " перед буквой/цифрой, - # которой предшествует пробел, начало строки или открывающая скобка. - # (?<=^|\s|[\(\[„\"‘\']) - "просмотр назад" на начало строки... ищет пробел \s или знак из набора ([„"‘' - # (?=\p{L}) - "просмотр вперед" на букву \p{L} (но не цифру). - self._opening_quote_pattern = regex.compile(r'(?<=^|\s|[\(\[„\"‘\'])\"(?=\p{L})') + # которой предшествует пробел, начало строки, открывающая скобка ИЛИ разделитель узлов. + # (?<=^|\s|[\(\[„\"‘\']|sep) - "просмотр назад" на начало строки... ищет пробел \s или знак из набора ([„"‘' или разделитель + # (?=\p{L}|sep) - "просмотр вперед" на букву \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})') # Паттерн для закрывающей кавычки: " после буквы/цифры, - # за которой следует пробел, пунктуация или конец строки. - # (?<=\p{L}|[?!…\.]) - "просмотр назад" на букву или ?!… и точку. - # (?=\s|[.,;:!?\)\"»”’]|\Z) - "просмотр вперед" на пробел, пунктуацию или конец строки (\Z). - self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…\.])\"(?=\s|[\.,;:!?\)\]»”’\"\']|\Z)') + # за которой следует пробел, пунктуация, конец строки ИЛИ разделитель узлов. + # (?<=\p{L}|[?!…\.]|sep) - "просмотр назад" на букву или ?!… и точку ИЛИ разделитель узлов. + # (?=\s|[.,;:!?\)\"»”’]|\Z|sep) - "просмотр вперед" на пробел, пунктуацию, конец строки (\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}|[?!…])\"(?=\s|[\p{Po}\p{Pf}"\']|\Z)') @@ -72,4 +75,4 @@ class QuotesProcessor: # 2. Заменяем закрывающие кавычки processed_text = self._closing_quote_pattern.sub(self.close_quote, processed_text) - return processed_text \ No newline at end of file + return processed_text diff --git a/etpgrf/typograph.py b/etpgrf/typograph.py index e92e78a..49a5ec0 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, 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) # --- ЭТАП 3: Подготовка (токен-стрим) --- - # 3.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать. - # soup.descendants возвращает все дочерние узлы (теги и текст) в порядке их следования. + # 3.1. Создаем "токен-стрим" из текстовых узлов. + # Теперь здесь только обычный текст и плейсхолдеры. text_nodes = [node for node in soup.descendants if isinstance(node, NavigableString) # and node.strip() and node.parent.name not in PROTECTED_HTML_TAGS] # PROTECTED уже скрыты, но на всякий случай - # 3.2. Создаем "супер-строку" и "карту длин" + # 3.2. Создаем "супер-строку" с маркерами границ super_string = "" - lengths_map = [] + # lengths_map больше не нужен, так как мы используем разделители + for node in text_nodes: # ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded). # str(node) может вернуть экранированные символы (например, < вместо <), # что увеличит длину строки и приведет к рассинхронизации карты длин (смещению текста). node_text = node.string or "" - super_string += node_text - lengths_map.append(len(node_text)) + # Добавляем текст и разделитель + super_string += node_text + CHAR_NODE_SEPARATOR # --- ЭТАП 4: Контекстная обработка --- processed_super_string = super_string @@ -304,19 +305,40 @@ class Typographer: processed_super_string = self.unbreakables.process(processed_super_string) # --- ЭТАП 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): - length = lengths_map[i] - new_text_part = processed_super_string[current_pos : current_pos + length] - node.replace_with(new_text_part) # Заменяем содержимое узла на месте - current_pos += length + if i < len(parts): + new_text_part = parts[i] + # Заменяем содержимое узла. + # Важно: если new_text_part содержит CHAR_PLACEHOLDER, он останется как есть + # и будет обработан на этапе 5.5. + node.replace_with(new_text_part) # --- ЭТАП 5.5: Восстановление защищенных тегов --- self._restore_protected_tags(soup, protected_tags) # --- ЭТАП 6: Локальная обработка (второй проход) --- - # Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход, - # который применит все остальные правила к каждому текстовому узлу. + # Теперь, когда структура восстановлена (включая защищенные теги), + # запускаем рекурсивный обход. + # Важно: _walk_tree пропускает PROTECTED_HTML_TAGS, так что содержимое + # восстановленных тегов не будет обработано повторно. self._walk_tree(soup) # --- ЭТАП 7: Висячая пунктуация --- @@ -337,8 +359,8 @@ class Typographer: else: processed_html = str(soup) - # Удаляем плейсхолдеры, если они вдруг просочились (хотя не должны) - processed_html = processed_html.replace(CHAR_PLACEHOLDER, '') + # Удаляем плейсхолдеры и разделители, если они вдруг просочились + processed_html = processed_html.replace(CHAR_PLACEHOLDER, '').replace(CHAR_NODE_SEPARATOR, '') # BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали # в _process_text_node. Возвращаем их обратно. diff --git a/tests/test_typograph.py b/tests/test_typograph.py index 782b01c..9e99e9a 100644 --- a/tests/test_typograph.py +++ b/tests/test_typograph.py @@ -159,7 +159,7 @@ HTML_STRUCTURE_TEST_CASES = [ ('

Текст

', '

Текст

'), # 2. Голый текст -> должен остаться голым текстом (без

, , ) - ('Текст без\n тегов', 'Текст без тегов'), # Исправлено: ожидаем nbsp + ('Текст без тегов', 'Текст без тегов'), # Исправлено: ожидаем nbsp ('Текст с тегом внутри', 'Текст с тегом внутри'), # 3. Полноценный html-документ -> должен сохранить структуру @@ -174,11 +174,13 @@ HTML_STRUCTURE_TEST_CASES = [ ('Текст жирный курсив', 'Текст жирный курсив'), # 5. Тест на защищенные теги с "битым" HTML внутри (BS их закроет) - ('

Заголовок
', + ('
Заголовок
', '
Заголовок
'), - - # ('
Заголовок
', - # '
Заголовок
'), + + # 6/ Исправленный тест на защищенные теги с немаскированными HTML внутри + # (все незакрытые теги будут закрыты через BS, а тег удалены) + ('
Заголовок
', + '
Заголовок
'), ] @pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)