Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c54ae63030 | |||
| 00c80b79f1 | |||
| f3a651a54f | |||
| fe6f2a1522 | |||
| 57b8f4f74a | |||
| 6f5551ec29 | |||
| d1b8728002 | |||
| 604d510b24 | |||
| aa2112669f | |||
| d94815d7ee | |||
| cb31c5a3b7 | |||
| 97777a7d0a | |||
| 28b74f0d7e | |||
| f35a48a0ae | |||
| 7a7f9dc4cc | |||
| 8f01b1961e | |||
| a77cd3fa46 | |||
| 9d8b5ec55e | |||
| 75a78118ba | |||
| 6b07fd472b | |||
| 00efdde999 |
44
CHANGELOG.md
Normal file
44
CHANGELOG.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
Все заметные изменения в этом проекте будут задокументированы в этом файле.
|
||||
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.4] - 2025-02-03
|
||||
### Изменено
|
||||
- **Архитектурное улучшение:** Полностью переработан механизм обработки HTML.
|
||||
- Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).
|
||||
- Внедрены **плейсхолдеры** (`\uFFFC`) для защищенных тегов (`<code>`, `<script>` и др.). Теперь содержимое этих тегов физически изолируется перед обработкой, что предотвращает "протекание" контекста (например, склеивание слов, разделенных кодом).
|
||||
### Исправлено
|
||||
- Исправлена ошибка смещения текста при наличии спецсимволов (мнемоник) или при изменении длины строки.
|
||||
- Исправлена обработка кавычек, стоящих вплотную к границам тегов (например, `"<b>Текст</b>"`).
|
||||
|
||||
## [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`.
|
||||
- Раздел Credits в документации.
|
||||
|
||||
## [0.1.0] - 2025-12-23
|
||||
### Добавлено
|
||||
- Первый публичный релиз библиотеки `etpgrf`.
|
||||
- Основные модули:
|
||||
- `Typographer`: основной класс-оркестратор.
|
||||
- `Hyphenator`: расстановка мягких переносов (алгоритм Ляна-Кнута).
|
||||
- `QuotesProcessor`: замена кавычек («ёлочки», „лапки“).
|
||||
- `Unbreakables`: неразрывные пробелы для предлогов, союзов и частиц.
|
||||
- `LayoutProcessor`: типографика тире, инициалов, акронимов, единиц измерения, устойчивых сокращений (постпозиционных
|
||||
и препозиционных).
|
||||
- `SymbolsProcessor`: псевдографика (тире, стрелочки, копирайт и т.п.)
|
||||
- `HangingPunctuationProcessor`: висячая пунктуация.
|
||||
- `SanitizerProcessor`: очистка HTML перед обработкой.
|
||||
- Поддержка русского, русского дореформенного и английского языков.
|
||||
- Поддержка обработки HTML (через BeautifulSoup).
|
||||
106
LIBRARY_SPECS.md
Normal file
106
LIBRARY_SPECS.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Библиотека etpgrf: Контекст и Спецификация
|
||||
|
||||
Этот документ описывает архитектуру и API библиотеки `etpgrf` (Electro-Typographer), предназначенной для типографирования текста в вебе. Используйте этот контекст при разработке зависимых проектов (например, веб-сайтов).
|
||||
|
||||
## 1. Назначение
|
||||
Библиотека выполняет комплексную обработку текста для улучшения его читаемости и внешнего вида в браузере:
|
||||
* Замена кавычек («ёлочки», „лапки“).
|
||||
* Расстановка неразрывных пробелов (предлоги, союзы, инициалы, единицы измерения).
|
||||
* Замена дефисов на тире (—, –).
|
||||
* Преобразование псевдографики (`...` -> `…`, `(c)` -> `©`).
|
||||
* Расстановка мягких переносов (`­`).
|
||||
* **Висячая пунктуация** (оборачивание символов в `<span>`).
|
||||
* **Санитизация** (очистка HTML от старой типографики).
|
||||
|
||||
## 2. Архитектура
|
||||
* **Язык:** Python 3.10+.
|
||||
* **Зависимости:** `beautifulsoup4`, `lxml`, `regex`.
|
||||
* **Главный класс:** `etpgrf.Typographer`.
|
||||
* **Принцип работы (Pipeline):**
|
||||
1. **Анализ структуры:** Типограф проверяет, является ли входной текст полным HTML-документом (есть `<html>`, `<body>`) или фрагментом. Это важно для корректной сборки на выходе.
|
||||
2. **Парсинг и Санитизация:** Текст парсится через `BeautifulSoup` (предпочтительно `lxml`). Если включен санитайзер, происходит очистка от старой разметки (удаление висячей пунктуации или всех тегов).
|
||||
3. **Подготовка (Токенизация):** Извлекаются все текстовые узлы (`NavigableString`), которые склеиваются в одну "супер-строку". Это позволяет правилам видеть контекст через границы тегов (например, кавычка в `<b>` и слово после него).
|
||||
4. **Контекстная обработка:** К "супер-строке" применяются правила, требующие глобального контекста:
|
||||
* `QuotesProcessor`: расстановка кавычек.
|
||||
* `Unbreakables`: привязка предлогов и союзов.
|
||||
5. **Восстановление структуры:** Обработанная "супер-строка" аккуратно нарезается обратно и раскладывается по исходным текстовым узлам DOM-дерева.
|
||||
6. **Локальная обработка:** Запускается рекурсивный обход дерева. К каждому текстовому узлу применяются локальные правила:
|
||||
* `SymbolsProcessor`: псевдографика.
|
||||
* `LayoutProcessor`: тире, спецсимволы.
|
||||
* `Hyphenator`: расстановка переносов.
|
||||
7. **Висячая пунктуация:** Модифицируется само DOM-дерево. Символы пунктуации оборачиваются в теги `<span>` с классами для CSS-коррекции.
|
||||
8. **Финальная сборка:** Дерево сериализуется в строку. Если на входе был фрагмент, возвращается только содержимое `<body>`, чтобы не плодить лишние теги.
|
||||
|
||||
## 3. API: Класс Typographer
|
||||
|
||||
### Инициализация
|
||||
```python
|
||||
from etpgrf import Typographer
|
||||
|
||||
typo = Typographer(
|
||||
langs='ru', # Языки: 'ru', 'en', 'ru+en'
|
||||
mode='mixed', # Режим вывода: 'unicode', 'mnemonic', 'mixed'
|
||||
process_html=True, # Обрабатывать HTML-теги (иначе экранирует их)
|
||||
|
||||
# Модули (можно отключить, передав False)
|
||||
hyphenation=True, # Переносы слов
|
||||
quotes=True, # Кавычки
|
||||
unbreakables=True, # Неразрывные пробелы (предлоги, союзы)
|
||||
layout=True, # Тире, спецсимволы, инициалы, единицы измерения
|
||||
symbols=True, # Псевдографика (стрелочки, копирайт)
|
||||
|
||||
# Специфические настройки
|
||||
sanitizer='etp', # Очистка перед обработкой: 'etp' (удалить висячую), 'html' (удалить все теги)
|
||||
hanging_punctuation='both' # Висячая пунктуация: 'left', 'right', 'both', None
|
||||
)
|
||||
```
|
||||
|
||||
### Метод process
|
||||
```python
|
||||
html = '<p>Привет, мир!</p>'
|
||||
result = typo.process(html)
|
||||
# Результат: '<p>Привет, мир!</p>' (с неразрывными пробелами и т.д.)
|
||||
```
|
||||
|
||||
## 4. Особенности модулей
|
||||
|
||||
### 4.1. Висячая пунктуация (`hanging.py`)
|
||||
* Оборачивает символы (`«`, `„`, `(`, `.`, `,` и др.) в теги `<span class="etp-...">`.
|
||||
* **Логика:**
|
||||
* Левые символы (`«`): оборачиваются, если в начале узла ИЛИ перед ними пробел/другой левый символ.
|
||||
* Правые символы (`»`, `.`): оборачиваются, если в конце узла ИЛИ после них пробел/другой правый символ.
|
||||
* **Классы CSS:** `etp-laquo`, `etp-r-dot`, `etp-r-comma` и т.д. (см. `config.py`).
|
||||
* **Важно:** Не добавляет компенсирующие пробелы. Визуализация — задача CSS.
|
||||
|
||||
### 4.2. Санитайзер (`sanitizer.py`)
|
||||
* Запускается **до** основного типографирования.
|
||||
* `mode='etp'`: Удаляет теги `<span>` с классами висячей пунктуации (unwrap), сохраняя текст.
|
||||
* `mode='html'`: Удаляет ВСЕ теги, возвращает чистый текст.
|
||||
|
||||
### 4.3. Переносы (`hyphenation.py`)
|
||||
* Использует алгоритм Ляна-Кнута (портирован из `hyphen`).
|
||||
* Вставляет `­` (мягкий перенос).
|
||||
* Настройки: `MAX_UNHYPHENATED_LEN` (не переносить длинные слова), `MIN_TAIL_LEN` (минимальный остаток).
|
||||
|
||||
### 4.4. Компоновка (`layout.py`)
|
||||
* Тире: ` - ` -> ` — ` (рус), `—` (анг).
|
||||
* Инициалы: `А. С. Пушкин` -> `А. С. Пушкин`.
|
||||
* Единицы измерения: `10 км` -> `10 км`.
|
||||
* Сокращения: `и т.д.` -> `и т. д.`.
|
||||
|
||||
## 5. Конфигурация (`config.py`)
|
||||
Все константы (списки слов, коды символов, CSS-классы) находятся здесь.
|
||||
* `HANGING_PUNCTUATION_CLASSES`: словарь {символ: класс}.
|
||||
* `PROTECTED_HTML_TAGS`: теги, внутри которых типографика не применяется (`script`, `code`, `pre`...).
|
||||
|
||||
## 6. Пример использования (FastAPI/Flask/Django)
|
||||
|
||||
```python
|
||||
from etpgrf import Typographer
|
||||
|
||||
# Создаем экземпляр один раз (он stateless, кроме конфигурации)
|
||||
typo = Typographer(langs='ru', process_html=True, hanging_punctuation='both')
|
||||
|
||||
def process_request(text):
|
||||
return typo.process(text)
|
||||
```
|
||||
33
README.md
33
README.md
@@ -1,15 +1,27 @@
|
||||
# etpgrf — типограф для Web
|
||||
# etpgrf — единый типограф для веба / effortless typography for web
|
||||
|
||||
[](https://badge.fury.io/py/etpgrf)
|
||||
[](https://pypi.org/project/etpgrf/)
|
||||
[](https://pypi.org/project/etpgrf/)
|
||||
|
||||
|
||||
# Типограф для Web
|
||||
# Типограф для веба
|
||||
|
||||
Экранная типографика для веба — способствует повышению читабельности текста в интернете,
|
||||
приближая его к печатной типографике.
|
||||
|
||||
## Репозитории / Repositories
|
||||
|
||||
Исходный код доступен на нескольких площадках:
|
||||
* [Gitea](https://git.cube2.ru/erjemin/2025-etpgrf) (Основной self-hosted)
|
||||
* [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало & homepage/issues)
|
||||
* [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse)
|
||||
|
||||
## Демострация / Demo
|
||||
|
||||
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
|
||||
вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
@@ -343,21 +355,10 @@ typo = etpgrf.Typographer(hanging_punctuation=['blockquote', 'h2', 'h3'])
|
||||
|
||||
## P.S.
|
||||
|
||||
Если вам нравится этот, можете поддержать отправив любую сумму на мой Т-банк
|
||||
[по ссылке](https://tbank.ru/cf/27hMw1BTFMs) или QR-коду.
|
||||
Если вам нравится этот проект, можете поддержать отправив любую сумму на мой Т-банк
|
||||
[по ссылке](https://tbank.ru/cf/27hMw1BTFMs) или, для приверженцев децентрализованного будущего,
|
||||
через Toncoin (TON) (адрес кошелька `UQApEkzNMYOg5qesWwlyfGFf4ayFyki5Mrpcd2yadgS2_1cx`)
|
||||
|
||||

|
||||
|
||||
Средства пойдут на улучшение моего настроения путем покупки виниловых пластинок. В списке желаний:
|
||||
|
||||
| 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
|
||||
|
||||
|
||||
24
Rules
Normal file
24
Rules
Normal 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.
|
||||
@@ -8,7 +8,11 @@ etpgrf - библиотека для экранной типографики т
|
||||
- Висячая пунктуация
|
||||
- Очистка и обработка 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.logger
|
||||
|
||||
@@ -72,6 +72,8 @@ CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двуна
|
||||
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / ·
|
||||
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
|
||||
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
|
||||
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
|
||||
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
|
||||
|
||||
|
||||
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
|
||||
|
||||
@@ -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
|
||||
return processed_text
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Поддерживает обработку текста внутри HTML-тегов с помощью BeautifulSoup.
|
||||
import logging
|
||||
import html
|
||||
import regex # Для проверки наличия корневых тегов
|
||||
try:
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
except ImportError:
|
||||
@@ -16,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
|
||||
from etpgrf.config import PROTECTED_HTML_TAGS, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR
|
||||
|
||||
|
||||
# --- Настройки логирования ---
|
||||
@@ -155,6 +156,82 @@ class Typographer:
|
||||
# Если это "обычный" html-тег, рекурсивно заходим в него
|
||||
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:
|
||||
"""
|
||||
Обрабатывает текст, применяя все активные правила типографики.
|
||||
@@ -164,13 +241,18 @@ class Typographer:
|
||||
return ""
|
||||
# Если включена обработка HTML и BeautifulSoup доступен
|
||||
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:
|
||||
soup = BeautifulSoup(text, 'lxml')
|
||||
except Exception:
|
||||
soup = BeautifulSoup(text, 'html.parser')
|
||||
|
||||
# --- ЭТАП 0: Санитизация (Очистка) ---
|
||||
if self.sanitizer:
|
||||
result = self.sanitizer.process(soup)
|
||||
# Если режим SANITIZE_ALL_HTML, то результат - это строка (чистый текст)
|
||||
@@ -189,20 +271,31 @@ class Typographer:
|
||||
# Если результат - soup, продолжаем работу с ним
|
||||
soup = result
|
||||
|
||||
# 1.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать.
|
||||
# soup.descendants возвращает все дочерние узлы (теги и текст) в порядке их следования.
|
||||
# --- ЭТАП 2.5: Скрытие защищенных тегов ---
|
||||
# Заменяем <code>, <script> и т.д. на плейсхолдеры, чтобы они не мешали обработке
|
||||
# и не ломали карту длин.
|
||||
protected_tags = self._hide_protected_tags(soup)
|
||||
|
||||
# --- ЭТАП 3: Подготовка (токен-стрим) ---
|
||||
# 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]
|
||||
# 1.2. Создаем "супер-строку" и "карту длин"
|
||||
and node.parent.name not in PROTECTED_HTML_TAGS] # PROTECTED уже скрыты, но на всякий случай
|
||||
# 3.2. Создаем "супер-строку" с маркерами границ
|
||||
super_string = ""
|
||||
lengths_map = []
|
||||
# lengths_map больше не нужен, так как мы используем разделители
|
||||
|
||||
for node in text_nodes:
|
||||
super_string += str(node)
|
||||
lengths_map.append(len(str(node)))
|
||||
# ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded).
|
||||
# str(node) может вернуть экранированные символы (например, < вместо <),
|
||||
# что увеличит длину строки и приведет к рассинхронизации карты длин (смещению текста).
|
||||
node_text = node.string or ""
|
||||
# Добавляем текст и разделитель
|
||||
super_string += node_text + CHAR_NODE_SEPARATOR
|
||||
|
||||
# --- ЭТАП 2: Контекстная обработка (ПОКА ЧТО ПРОПУСКАЕМ) ---
|
||||
# --- ЭТАП 4: Контекстная обработка ---
|
||||
processed_super_string = super_string
|
||||
# Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html).
|
||||
# Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится.
|
||||
@@ -211,26 +304,64 @@ class Typographer:
|
||||
if self.unbreakables:
|
||||
processed_super_string = self.unbreakables.process(processed_super_string)
|
||||
|
||||
# --- ЭТАП 3: "Восстановление" ---
|
||||
current_pos = 0
|
||||
# --- ЭТАП 5: Восстановление структуры ---
|
||||
# Разбиваем строку по разделителям.
|
||||
# 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)
|
||||
|
||||
# --- ЭТАП 4: Локальная обработка (второй проход) ---
|
||||
# Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход,
|
||||
# который применит все остальные правила к каждому текстовому узлу.
|
||||
# --- ЭТАП 5.5: Восстановление защищенных тегов ---
|
||||
self._restore_protected_tags(soup, protected_tags)
|
||||
|
||||
# --- ЭТАП 6: Локальная обработка (второй проход) ---
|
||||
# Теперь, когда структура восстановлена (включая защищенные теги),
|
||||
# запускаем рекурсивный обход.
|
||||
# Важно: _walk_tree пропускает PROTECTED_HTML_TAGS, так что содержимое
|
||||
# восстановленных тегов не будет обработано повторно.
|
||||
self._walk_tree(soup)
|
||||
|
||||
# --- ЭТАП 4.5: Висячая пунктуация ---
|
||||
# --- ЭТАП 7: Висячая пунктуация ---
|
||||
# Применяем после всех текстовых преобразований, но перед финальной сборкой
|
||||
if self.hanging:
|
||||
self.hanging.process(soup)
|
||||
|
||||
# --- ЭТАП 5: Финальная сборка ---
|
||||
processed_html = str(soup)
|
||||
# --- ЭТАП 8: Финальная сборка ---
|
||||
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 по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали
|
||||
# в _process_text_node. Возвращаем их обратно.
|
||||
return processed_html.replace('&', '&')
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "etpgrf"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -27,11 +27,14 @@ classifiers = [
|
||||
dependencies = [
|
||||
"beautifulsoup4>=4.10.0",
|
||||
"lxml>=4.9.0", # Рекомендуемый парсер
|
||||
"regex>=2022.1.18", # Критически важная зависимость для Unicode
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://github.com/erjemin/etpgrf"
|
||||
"Bug Tracker" = "https://github.com/erjemin/etpgrf/issues"
|
||||
"Mirror (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf"
|
||||
"Selfhosted (Gitea)" = "https://gitverse.ru/erjemin/etpgrf"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."] # Искать пакеты в корне (найдет папку etpgrf)
|
||||
|
||||
@@ -11,3 +11,30 @@ packaging==25.0
|
||||
pluggy==1.6.0
|
||||
Pygments==2.19.2
|
||||
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
|
||||
|
||||
@@ -149,3 +149,65 @@ def test_typographer_sanitizer_all_html_integration():
|
||||
typo = Typographer(langs='ru', process_html=True, sanitizer=SANITIZE_ALL_HTML, mode='mixed')
|
||||
actual_text = typo.process(input_html)
|
||||
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
|
||||
('Текст с <b>тегом</b> внутри', 'Текст с <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>Исправлена проблема с появлением лишних тегов <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 внутри
|
||||
# (все незакрытые теги будут закрыты через 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)
|
||||
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, чтобы проверить, что добавляются, но теги не ломаются
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user