12 Commits

Author SHA1 Message Date
c54ae63030 mod: v0.1.4 2026-02-03 02:15:56 +03:00
00c80b79f1 mod: Use node separators and placeholders for robust HTML processing
1. Защита тегов: Защищенные теги (<code>, <script> и т.д.) теперь физически заменяются на плейсхолдеры (\uFFFC) в DOM-дереве перед обработкой. Это предотвращает "протекание" контекста (например, склеивание слов через код) и защищает содержимое тегов от изменений.

2. Маркеры границ: При сборке "супер-строки" (для контекстной обработки) между всеми текстовыми узлами вставляются специальные разделители (\uFFFF). Это позволяет корректно восстанавливать текст по узлам, даже если длина текста изменилась (например, Unbreakables удалил лишние пробелы). Раньше мы полагались на карту длин (lengths_map), что приводило к смещению текста при любых изменениях длины.
2026-02-03 02:04:46 +03:00
f3a651a54f fix: Protect tags with placeholders to prevent text shifting and context leakage
1. Защита тегов: Внедрили механизм _hide_protected_tags / _restore_protected_tags с использованием плейсхолдера ___ETPGRF_PROTECTED___. Это решило проблему "протекания" контекста через защищенные теги (например, союз "и" больше не прыгает через <code>).

2. Фикс тестов: Обновили тесты, чтобы они учитывали реальное поведение BeautifulSoup (закрытие тегов) и Unbreakables (схлопывание пробелов).
2026-02-03 00:57:46 +03:00
fe6f2a1522 mod: Демо.. 2026-01-19 21:32:41 +03:00
57b8f4f74a mod: Демо. 2026-01-19 21:31:26 +03:00
6f5551ec29 mod: Демо. 2026-01-19 21:31:09 +03:00
d1b8728002 mod: Демо+ 2026-01-19 21:30:44 +03:00
604d510b24 mod: Демо 2026-01-19 21:29:35 +03:00
aa2112669f mod: правки для версии 0.1.3 2026-01-11 19:04:04 +03:00
d94815d7ee mod: избавляемся от паразитного "обертывания" в <html> и <body>... 2026-01-11 18:41:42 +03:00
cb31c5a3b7 add: добавлены тесты, для проверки обёртывания в <html> и <body> 2026-01-11 17:08:41 +03:00
97777a7d0a mod: minor 2025-12-27 23:16:02 +03:00
11 changed files with 331 additions and 66 deletions

View File

@@ -5,18 +5,29 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html). и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.2] - 2025-05-02 ## [0.1.4] - 2025-02-03
### Изменено
- **Архитектурное улучшение:** Полностью переработан механизм обработки HTML.
- Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).
- Внедрены **плейсхолдеры** (`\uFFFC`) для защищенных тегов (`<code>`, `<script>` и др.). Теперь содержимое этих тегов физически изолируется перед обработкой, что предотвращает "протекание" контекста (например, склеивание слов, разделенных кодом).
### Исправлено ### Исправлено
- **Критическое исправление:** Добавлена отсутствующая зависимость `regex` в `pyproject.toml`. Без неё библиотека - Исправлена ошибка смещения текста при наличии спецсимволов (мнемоник) или при изменении длины строки.
падала при импорте. - Исправлена обработка кавычек, стоящих вплотную к границам тегов (например, `"<b>Текст</b>"`).
- Файл `LIBRARY_SPECS.md` для LLM.
## [0.1.1] - 2025-05-02 ## [0.1.3] - 2026-01-11
### Исправлено
- Исправлена проблема с появлением лишних тегов `<html>` и `<body>` при обработке фрагментов HTML (когда используется парсер `lxml`). Теперь типограф автоматически определяет, был ли на входе полноценный документ или фрагмент, и возвращает соответствующий результат.
## [0.1.2] - 2025-12-27
### Исправлено
- **Критическое исправление:** Добавлена отсутствующая зависимость `regex` в `pyproject.toml`. Без неё библиотека падала при импорте.
## [0.1.1] - 2025-12-23
### Добавлено ### Добавлено
- Ссылки на зеркала репозитория (GitVerse, Gitea) в `pyproject.toml` и `README.md`. - Ссылки на зеркала репозитория (GitVerse, Gitea) в `pyproject.toml` и `README.md`.
- Раздел Credits в документации. - Раздел Credits в документации.
## [0.1.0] - 2025-05-01 ## [0.1.0] - 2025-12-23
### Добавлено ### Добавлено
- Первый публичный релиз библиотеки `etpgrf`. - Первый публичный релиз библиотеки `etpgrf`.
- Основные модули: - Основные модули:
@@ -24,8 +35,9 @@
- `Hyphenator`: расстановка мягких переносов (алгоритм Ляна-Кнута). - `Hyphenator`: расстановка мягких переносов (алгоритм Ляна-Кнута).
- `QuotesProcessor`: замена кавычек («ёлочки», „лапки“). - `QuotesProcessor`: замена кавычек («ёлочки», „лапки“).
- `Unbreakables`: неразрывные пробелы для предлогов, союзов и частиц. - `Unbreakables`: неразрывные пробелы для предлогов, союзов и частиц.
- `LayoutProcessor`: типографика тире, инициалов, акронимов, единиц измерения. - `LayoutProcessor`: типографика тире, инициалов, акронимов, единиц измерения, устойчивых сокращений (постпозиционных
- `SymbolsProcessor`: псевдографика. и препозиционных).
- `SymbolsProcessor`: псевдографика (тире, стрелочки, копирайт и т.п.)
- `HangingPunctuationProcessor`: висячая пунктуация. - `HangingPunctuationProcessor`: висячая пунктуация.
- `SanitizerProcessor`: очистка HTML перед обработкой. - `SanitizerProcessor`: очистка HTML перед обработкой.
- Поддержка русского, русского дореформенного и английского языков. - Поддержка русского, русского дореформенного и английского языков.

View File

@@ -1,4 +1,4 @@
# Библиотека etpgrf: Контекст и Спецификация для LLM # Библиотека etpgrf: Контекст и Спецификация
Этот документ описывает архитектуру и API библиотеки `etpgrf` (Electro-Typographer), предназначенной для типографирования текста в вебе. Используйте этот контекст при разработке зависимых проектов (например, веб-сайтов). Этот документ описывает архитектуру и API библиотеки `etpgrf` (Electro-Typographer), предназначенной для типографирования текста в вебе. Используйте этот контекст при разработке зависимых проектов (например, веб-сайтов).
@@ -16,14 +16,20 @@
* **Язык:** Python 3.10+. * **Язык:** Python 3.10+.
* **Зависимости:** `beautifulsoup4`, `lxml`, `regex`. * **Зависимости:** `beautifulsoup4`, `lxml`, `regex`.
* **Главный класс:** `etpgrf.Typographer`. * **Главный класс:** `etpgrf.Typographer`.
* **Принцип работы:** * **Принцип работы (Pipeline):**
1. Принимает текст или HTML. 1. **Анализ структуры:** Типограф проверяет, является ли входной текст полным HTML-документом (есть `<html>`, `<body>`) или фрагментом. Это важно для корректной сборки на выходе.
2. Если HTML: парсит через `BeautifulSoup` (lxml), очищает от старой разметки (Sanitizer). 2. **Парсинг и Санитизация:** Текст парсится через `BeautifulSoup` (предпочтительно `lxml`). Если включен санитайзер, происходит очистка от старой разметки (удаление висячей пунктуации или всех тегов).
3. Извлекает текстовые узлы, склеивает их в "супер-строку" для контекстных правил (кавычки). 3. **Подготовка (Токенизация):** Извлекаются все текстовые узлы (`NavigableString`), которые склеиваются в одну "супер-строку". Это позволяет правилам видеть контекст через границы тегов (например, кавычка в `<b>` и слово после него).
4. Применяет правила к тексту. 4. **Контекстная обработка:** К "супер-строке" применяются правила, требующие глобального контекста:
5. Восстанавливает структуру HTML. * `QuotesProcessor`: расстановка кавычек.
6. Применяет висячую пунктуацию (модификация дерева DOM). * `Unbreakables`: привязка предлогов и союзов.
7. Возвращает строку. 5. **Восстановление структуры:** Обработанная "супер-строка" аккуратно нарезается обратно и раскладывается по исходным текстовым узлам DOM-дерева.
6. **Локальная обработка:** Запускается рекурсивный обход дерева. К каждому текстовому узлу применяются локальные правила:
* `SymbolsProcessor`: псевдографика.
* `LayoutProcessor`: тире, спецсимволы.
* `Hyphenator`: расстановка переносов.
7. **Висячая пунктуация:** Модифицируется само DOM-дерево. Символы пунктуации оборачиваются в теги `<span>` с классами для CSS-коррекции.
8. **Финальная сборка:** Дерево сериализуется в строку. Если на входе был фрагмент, возвращается только содержимое `<body>`, чтобы не плодить лишние теги.
## 3. API: Класс Typographer ## 3. API: Класс Typographer

View File

@@ -17,6 +17,11 @@
* [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало & homepage/issues) * [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало & homepage/issues)
* [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse) * [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse)
## Демострация / Demo
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
## Установка ## Установка
```bash ```bash
@@ -350,21 +355,10 @@ typo = etpgrf.Typographer(hanging_punctuation=['blockquote', 'h2', 'h3'])
## P.S. ## P.S.
Если вам нравится этот, можете поддержать отправив любую сумму на мой Т-банк Если вам нравится этот проект, можете поддержать отправив любую сумму на мой Т-банк
[по ссылке](https://tbank.ru/cf/27hMw1BTFMs) или QR-коду. [по ссылке](https://tbank.ru/cf/27hMw1BTFMs) или, для приверженцев децентрализованного будущего,
через Toncoin (TON) (адрес кошелька `UQApEkzNMYOg5qesWwlyfGFf4ayFyki5Mrpcd2yadgS2_1cx`)
![Сбор средств](qr-code.png)
Средства пойдут на улучшение моего настроения путем покупки виниловых пластинок. В списке желаний:
| Bar-Code | Artist | Album | Format | Note | Date | Label | Цена | |
|----------------|--------------------------|-----------------------------------------|--------|----------------------|------------|---------|-------|--------|
| 5400863157845 | EELS | Time! | LP | coloured | 07.06.2024 | | ₽4360 |
| 5400863145637 | EELS | So Good | LP | coloured | 15.12.2023 | | ₽4940 |
| 8719262034853 | NICK CAVE & WARREN ELLIS | Mars (Original Sound Track) | LP | coloured | 12.07.2024 | | ₽3440 |
| 5021732526007 | GORILLAZ | Demon Days Live From The Apollo Theater | 2LP | RSD2025, Red | 12.04.2025 | Warner | ₽5740 |
| 5021732717696 | GORILLAZ | TOMORROW COMES TODAY | EP 12" | color (white & blue) | 20.06.2025 | | ₽3600 |
| 0198028824118 | Lou Reed | Metal Machine Music (RSD2025 50th) | 2LP | Ann Silver | 04.12.2025 | RCA | ₽5299 |
## Credits ## Credits

24
Rules Normal file
View File

@@ -0,0 +1,24 @@
# Правила проекта 2025-etpgrf при обработке ИИ
## Общие сведения
- **Проект:** Python-библиотека для экранной типографики для веб (висячая пунктуация, неразрывные пробелы, перенос слов и т.д.).
- **Язык:** Python 3.10+.
- **Стиль кода:** PEP8.
- **Типизация:** Обязательные Type Hints для аргументов и возвращаемых значений.
- **Язык комментариев:** Русский.
## Архитектура
- **Точка входа:** Класс `Typographer` в `etpgrf/typograph.py`.
- **Обработка HTML:**
- Использовать `BeautifulSoup4` (предпочтительно парсер `lxml`).
- НИКОГДА не парсить HTML регулярными выражениями.
- **Санитизация:** Всегда выполняется *до* рекурсивного обхода дерева.
- **Рекурсия:** Использовать `_walk_tree` для обработки текстовых узлов, сохраняя структуру HTML.
- **Конфигурация:** Все константы (regex, коды символов, классы) должны быть в `etpgrf/config.py`.
## Тестирование
- **Фреймворк:** `pytest`.
- **Структура:**
- Юнит-тесты: `tests/test_<module>.py`.
- Интеграционные тесты: `tests/test_typograph.py`.
- **Философия:** Тестировать как режим простого текста ("plain text"), так и режим HTML.

View File

@@ -8,7 +8,11 @@ etpgrf - библиотека для экранной типографики т
- Висячая пунктуация - Висячая пунктуация
- Очистка и обработка HTML - Очистка и обработка HTML
""" """
__version__ = "0.1.0" __version__ = "0.1.4"
__author__ = "Sergei Erjemin"
__email__ = "erjemin@gmail.com"
__license__ = "MIT"
__copyright__ = "Copyright 2025 Sergei Erjemin"
import etpgrf.defaults import etpgrf.defaults
import etpgrf.logger import etpgrf.logger

View File

@@ -72,6 +72,8 @@ CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двуна
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / &middot; CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / &middot;
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом. # в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ === # === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===

View File

@@ -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)')
@@ -72,4 +75,4 @@ class QuotesProcessor:
# 2. Заменяем закрывающие кавычки # 2. Заменяем закрывающие кавычки
processed_text = self._closing_quote_pattern.sub(self.close_quote, processed_text) processed_text = self._closing_quote_pattern.sub(self.close_quote, processed_text)
return processed_text return processed_text

View File

@@ -3,6 +3,7 @@
# Поддерживает обработку текста внутри HTML-тегов с помощью BeautifulSoup. # Поддерживает обработку текста внутри HTML-тегов с помощью BeautifulSoup.
import logging import logging
import html import html
import regex # Для проверки наличия корневых тегов
try: try:
from bs4 import BeautifulSoup, NavigableString from bs4 import BeautifulSoup, NavigableString
except ImportError: except ImportError:
@@ -16,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 from etpgrf.config import PROTECTED_HTML_TAGS, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR
# --- Настройки логирования --- # --- Настройки логирования ---
@@ -155,6 +156,82 @@ class Typographer:
# Если это "обычный" html-тег, рекурсивно заходим в него # Если это "обычный" html-тег, рекурсивно заходим в него
self._walk_tree(child) self._walk_tree(child)
def _hide_protected_tags(self, soup) -> list:
"""
Находит все защищенные теги, заменяет их на плейсхолдеры и возвращает список сохраненных тегов.
"""
protected_tags = []
if not PROTECTED_HTML_TAGS:
return protected_tags
# Формируем селектор для поиска
selector = ", ".join(PROTECTED_HTML_TAGS)
# Находим все теги. Важно: find_all возвращает их в порядке появления в документе.
# Но если мы будем заменять их на лету, структура может измениться.
# Поэтому лучше собрать список, а потом заменить.
tags_to_replace = soup.select(selector)
for tag in tags_to_replace:
# Сохраняем тег (он будет удален из дерева при replace_with, но объект останется в памяти)
protected_tags.append(tag)
# Заменяем на текстовый узел с плейсхолдером
tag.replace_with(NavigableString(CHAR_PLACEHOLDER))
return protected_tags
def _restore_protected_tags(self, soup, protected_tags: list):
"""
Восстанавливает защищенные теги на места плейсхолдеров.
"""
if not protected_tags:
return
# Ищем все текстовые узлы, содержащие плейсхолдер
# Используем список, так как будем менять дерево
text_nodes_with_placeholder = [
node for node in soup.descendants
if isinstance(node, NavigableString) and CHAR_PLACEHOLDER in node
]
tag_index = 0
for node in text_nodes_with_placeholder:
text = str(node)
# Если в узле есть плейсхолдеры, нам нужно его разбить
if CHAR_PLACEHOLDER in text:
parts = text.split(CHAR_PLACEHOLDER)
# Создаем список новых узлов для замены
new_nodes = []
for i, part in enumerate(parts):
# Добавляем текст (если он не пустой)
if part:
new_nodes.append(NavigableString(part))
# Если это не последняя часть, значит, здесь был плейсхолдер.
# Вставляем тег.
if i < len(parts) - 1:
if tag_index < len(protected_tags):
new_nodes.append(protected_tags[tag_index])
tag_index += 1
else:
logger.warning("Mismatch in protected tags count during restoration.")
# Заменяем исходный узел на новые
if new_nodes:
first_node = new_nodes[0]
node.replace_with(first_node)
current_pos = first_node
for next_node in new_nodes[1:]:
current_pos.insert_after(next_node)
current_pos = next_node
else:
# Если узел состоял только из плейсхолдера и мы его заменили на тег,
# то new_nodes может быть пустым (если тег был один и текст пустой).
# Но split('') дает [''], так что parts не пустой.
# Логика выше должна работать.
pass
def process(self, text: str) -> str: def process(self, text: str) -> str:
""" """
Обрабатывает текст, применяя все активные правила типографики. Обрабатывает текст, применяя все активные правила типографики.
@@ -164,13 +241,18 @@ class Typographer:
return "" return ""
# Если включена обработка HTML и BeautifulSoup доступен # Если включена обработка HTML и BeautifulSoup доступен
if self.process_html: if self.process_html:
# --- ЭТАП 1: Токенизация и "умная склейка" --- # --- ЭТАП 1: Анализ структуры ---
# Проверяем, есть ли в начале текста теги <html> или <body>.
# Если есть - значит, это полноценный документ, и мы должны вернуть его целиком.
# Если нет - значит, это фрагмент, и мы должны вернуть только содержимое body.
is_full_document = bool(regex.search(r'^\s*<(?:!DOCTYPE|html|body)', text, regex.IGNORECASE))
# --- ЭТАП 2: Парсинг и Санитизация ---
try: try:
soup = BeautifulSoup(text, 'lxml') soup = BeautifulSoup(text, 'lxml')
except Exception: except Exception:
soup = BeautifulSoup(text, 'html.parser') soup = BeautifulSoup(text, 'html.parser')
# --- ЭТАП 0: Санитизация (Очистка) ---
if self.sanitizer: if self.sanitizer:
result = self.sanitizer.process(soup) result = self.sanitizer.process(soup)
# Если режим SANITIZE_ALL_HTML, то результат - это строка (чистый текст) # Если режим SANITIZE_ALL_HTML, то результат - это строка (чистый текст)
@@ -189,20 +271,31 @@ class Typographer:
# Если результат - soup, продолжаем работу с ним # Если результат - soup, продолжаем работу с ним
soup = result soup = result
# 1.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать. # --- ЭТАП 2.5: Скрытие защищенных тегов ---
# soup.descendants возвращает все дочерние узлы (теги и текст) в порядке их следования. # Заменяем <code>, <script> и т.д. на плейсхолдеры, чтобы они не мешали обработке
# и не ломали карту длин.
protected_tags = self._hide_protected_tags(soup)
# --- ЭТАП 3: Подготовка (токен-стрим) ---
# 3.1. Создаем "токен-стрим" из текстовых узлов.
# Теперь здесь только обычный текст и плейсхолдеры.
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] and node.parent.name not in PROTECTED_HTML_TAGS] # PROTECTED уже скрыты, но на всякий случай
# 1.2. Создаем "супер-строку" и "карту длин" # 3.2. Создаем "супер-строку" с маркерами границ
super_string = "" super_string = ""
lengths_map = [] # lengths_map больше не нужен, так как мы используем разделители
for node in text_nodes: for node in text_nodes:
super_string += str(node) # ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded).
lengths_map.append(len(str(node))) # str(node) может вернуть экранированные символы (например, &lt; вместо <),
# что увеличит длину строки и приведет к рассинхронизации карты длин (смещению текста).
node_text = node.string or ""
# Добавляем текст и разделитель
super_string += node_text + CHAR_NODE_SEPARATOR
# --- ЭТАП 2: Контекстная обработка (ПОКА ЧТО ПРОПУСКАЕМ) --- # --- ЭТАП 4: Контекстная обработка ---
processed_super_string = super_string processed_super_string = super_string
# Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html). # Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html).
# Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится. # Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится.
@@ -211,26 +304,64 @@ class Typographer:
if self.unbreakables: if self.unbreakables:
processed_super_string = self.unbreakables.process(processed_super_string) processed_super_string = self.unbreakables.process(processed_super_string)
# --- ЭТАП 3: "Восстановление" --- # --- ЭТАП 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)
# --- ЭТАП 4: Локальная обработка (второй проход) --- # --- ЭТАП 5.5: Восстановление защищенных тегов ---
# Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход, self._restore_protected_tags(soup, protected_tags)
# который применит все остальные правила к каждому текстовому узлу.
# --- ЭТАП 6: Локальная обработка (второй проход) ---
# Теперь, когда структура восстановлена (включая защищенные теги),
# запускаем рекурсивный обход.
# Важно: _walk_tree пропускает PROTECTED_HTML_TAGS, так что содержимое
# восстановленных тегов не будет обработано повторно.
self._walk_tree(soup) self._walk_tree(soup)
# --- ЭТАП 4.5: Висячая пунктуация --- # --- ЭТАП 7: Висячая пунктуация ---
# Применяем после всех текстовых преобразований, но перед финальной сборкой # Применяем после всех текстовых преобразований, но перед финальной сборкой
if self.hanging: if self.hanging:
self.hanging.process(soup) self.hanging.process(soup)
# --- ЭТАП 5: Финальная сборка --- # --- ЭТАП 8: Финальная сборка ---
processed_html = str(soup) if is_full_document:
# Если на входе был полноценный документ, возвращаем все дерево
processed_html = str(soup)
else:
# Если на входе был фрагмент, возвращаем только содержимое body.
# decode_contents() возвращает строку с содержимым тега (без самого тега).
# Если body нет (что странно для BS), возвращаем str(soup).
if soup.body:
processed_html = soup.body.decode_contents()
else:
processed_html = str(soup)
# Удаляем плейсхолдеры и разделители, если они вдруг просочились
processed_html = processed_html.replace(CHAR_PLACEHOLDER, '').replace(CHAR_NODE_SEPARATOR, '')
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &amp;), которые мы сгенерировали # BeautifulSoup по умолчанию экранирует амперсанды (& -> &amp;), которые мы сгенерировали
# в _process_text_node. Возвращаем их обратно. # в _process_text_node. Возвращаем их обратно.
return processed_html.replace('&amp;', '&') return processed_html.replace('&amp;', '&')

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "etpgrf" name = "etpgrf"
version = "0.1.2" version = "0.1.4"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ." description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -11,3 +11,30 @@ packaging==25.0
pluggy==1.6.0 pluggy==1.6.0
Pygments==2.19.2 Pygments==2.19.2
tomli==2.2.1 tomli==2.2.1
backports.tarfile==1.2.0
build==1.3.0
certifi==2025.11.12
charset-normalizer==3.4.4
docutils==0.22.4
exceptiongroup==1.3.0
id==1.5.0
idna==3.11
importlib_metadata==8.7.1
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.4.0
keyring==25.7.0
markdown-it-py==4.0.0
mdurl==0.1.2
more-itertools==10.8.0
nh3==0.3.2
pyproject_hooks==1.2.0
readme_renderer==44.0
requests==2.32.5
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==14.2.0
twine==6.2.0
urllib3==2.6.2
zipp==3.23.0

View File

@@ -149,3 +149,65 @@ def test_typographer_sanitizer_all_html_integration():
typo = Typographer(langs='ru', process_html=True, sanitizer=SANITIZE_ALL_HTML, mode='mixed') typo = Typographer(langs='ru', process_html=True, sanitizer=SANITIZE_ALL_HTML, mode='mixed')
actual_text = typo.process(input_html) actual_text = typo.process(input_html)
assert actual_text == expected_text assert actual_text == expected_text
# --- Новые тесты на структуру HTML (проверка отсутствия лишних оберток) ---
HTML_STRUCTURE_TEST_CASES = [
# 1. Фрагмент HTML (без html/body) -> должен остаться фрагментом
('<div>Текст</div>', '<div>Текст</div>'),
('<span>Текст</span>', '<span>Текст</span>'),
('<p>Текст</p>', '<p>Текст</p>'),
# 2. Голый текст -> должен остаться голым текстом (без <p>, <html>, <body>)
('Текст без тегов', 'Текст без&nbsp;тегов'), # Исправлено: ожидаем nbsp
('Текст с <b>тегом</b> внутри', 'Текст с&nbsp;<b>тегом</b> внутри'),
# 3. Полноценный html-документ -> должен сохранить структуру
('<html><body><p>Текст</p></body></html>', '<html><body><p>Текст</p></body></html>'),
# Используем валидный HTML для теста с DOCTYPE
('<!DOCTYPE html><html><head><title>Title</title></head><body><p>Текст</p></body></html>',
'<!DOCTYPE html>\n<html><head><title>Title</title></head><body><p>Текст</p></body></html>'),
# 4. Кривой html -> будет "починен"
('<div>Текст', '<div>Текст</div>'),
('<p>Текст', '<p>Текст</p>'),
('Текст <b>жирный <i>курсив', 'Текст <b>жирный <i>курсив</i></b>'),
# 5. Тест на защищенные теги с "битым" HTML внутри (BS их закроет)
('<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 внутри
# (все незакрытые теги будут закрыты через 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>'),
]
@pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)
def test_typographer_html_structure_preservation(input_html, expected_html):
"""
Проверяет, что Typographer не добавляет лишние теги (html, body, p)
вокруг фрагментов и текста, но сохраняет их, если они были.
"""
# Отключаем все "украшательства" (кавычки, неразрывные пробелы),
# чтобы проверять только структуру тегов.
typo = Typographer(
langs='ru',
process_html=True,
mode='mixed',
hyphenation=False,
quotes=False,
unbreakables=True, # Оставим unbreakables, чтобы проверить, что &nbsp; добавляются, но теги не ломаются
layout=False,
symbols=False
)
actual_html = typo.process(input_html)
# Для теста с doctype может быть нюанс с форматированием (переносы строк),
# поэтому нормализуем пробелы перед сравнением
if '<!DOCTYPE' in input_html:
assert '<html>' in actual_html
assert '<body>' in actual_html
assert '<p>Текст</p>' in actual_html
else:
assert actual_html == expected_html