30 Commits

Author SHA1 Message Date
9695fe80aa mod: Отключена опция 'both' для висячей пунктуации 2026-03-04 16:00:51 +03:00
4b62564c6b mod: Новое описание для "висячей пунктуации" 2026-03-04 15:18:03 +03:00
e8ef4c1bca mod: v0.1.5 + 2026-02-27 01:06:09 +03:00
bb1fcc71e0 mod: v0.1.5 2026-02-27 01:04:13 +03:00
d917634787 fix: ошибка, когда тире приходится на границу еденицы измерения. 2026-02-26 19:10:55 +03:00
67c17ff536 fix: ошибка, когда тире приходится на границу тега. 2026-02-26 18:27:54 +03:00
6b098f161d mod: более быстрая схема (проход в один конвейер вместо двух, избавились от сложной и хрупкой логики с картой длин благодаря применению плейсхолдер границы тегов) 2026-02-26 17:46:51 +03:00
ca3f93a485 fix: URL для mirror2 2026-02-26 14:30:24 +03:00
ace8b61ae3 fix: исправлено удаление двойного экранирования & 2026-02-26 14:29:54 +03:00
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
28b74f0d7e mod: 0.1.2 description 2025-12-27 21:39:04 +03:00
f35a48a0ae version to 0.1.2 2025-12-27 21:30:36 +03:00
7a7f9dc4cc Fix: Add missing 'regex' dependency, bump version to 0.1.1 2025-12-27 21:27:49 +03:00
8f01b1961e add: Add library architecture specs (for LLM) 2025-12-25 13:04:50 +03:00
a77cd3fa46 mov: examples 2025-12-24 15:27:40 +03:00
9d8b5ec55e mod: minor 2025-12-24 15:23:15 +03:00
75a78118ba mod: ver 0.1.1 2025-12-23 23:24:38 +03:00
6b07fd472b mod: minor 2025-12-23 22:32:12 +03:00
00efdde999 mod: add repo & mirror 2025-12-23 22:28:44 +03:00
15 changed files with 544 additions and 218 deletions

49
CHANGELOG.md Normal file
View File

@@ -0,0 +1,49 @@
# Changelog
Все заметные изменения в этом проекте будут задокументированы в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.5] - 2024-02-18
### Исправлено
- Исправлена ошибка, из-за которой `&amp;` в исходном тексте некорректно преобразовывался в `&`. Теперь `&amp;` и его варианты (`&amp;amp;`, `&amp;lt;`) сохраняются в итоговом HTML.
- Исправлена (частично) расстановка неразрывных пробелов `&nbsp;` на границах закрывающих тегов (например, `<b>Текст</b> -- слово` теперь корректно обрабатывается, в `Текст</b>&nbsp;&endash; слово`).
## [0.1.4] - 2024-02-13
### Изменено
- **Архитектурное улучшение:** Полностью переработан механизм обработки 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
View File

@@ -0,0 +1,106 @@
# Библиотека etpgrf: Контекст и Спецификация
Этот документ описывает архитектуру и API библиотеки `etpgrf` (Electro-Typographer), предназначенной для типографирования текста в вебе. Используйте этот контекст при разработке зависимых проектов (например, веб-сайтов).
## 1. Назначение
Библиотека выполняет комплексную обработку текста для улучшения его читаемости и внешнего вида в браузере:
* Замена кавычек («ёлочки», „лапки“).
* Расстановка неразрывных пробелов (предлоги, союзы, инициалы, единицы измерения).
* Замена дефисов на тире (—, ).
* Преобразование псевдографики (`...` -> `…`, `(c)` -> `©`).
* Расстановка мягких переносов (`&shy;`).
* **Висячая пунктуация** (оборачивание символов в `<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`).
* Вставляет `&shy;` (мягкий перенос).
* Настройки: `MAX_UNHYPHENATED_LEN` (не переносить длинные слова), `MIN_TAIL_LEN` (минимальный остаток).
### 4.4. Компоновка (`layout.py`)
* Тире: ` - ` -> `&nbsp;— ` (рус), `—` (анг).
* Инициалы: `А. С. Пушкин` -> `А.&nbsp;С.&thinsp;Пушкин`.
* Единицы измерения: `10 км` -> `10&nbsp;км`.
* Сокращения: `и т.д.` -> `и&nbsp;т.&thinsp;д.`.
## 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)
```

144
README.md
View File

@@ -1,15 +1,27 @@
# etpgrf — типограф для Web # etpgrf — единый типограф для веба / effortless typography for web
[![PyPI version](https://badge.fury.io/py/etpgrf.svg)](https://badge.fury.io/py/etpgrf) [![PyPI version](https://badge.fury.io/py/etpgrf.svg)](https://badge.fury.io/py/etpgrf)
[![Python Version](https://img.shields.io/pypi/pyversions/etpgrf.svg)](https://pypi.org/project/etpgrf/) [![Python Version](https://img.shields.io/pypi/pyversions/etpgrf.svg)](https://pypi.org/project/etpgrf/)
[![License](https://img.shields.io/pypi/l/etpgrf.svg)](https://pypi.org/project/etpgrf/) [![License](https://img.shields.io/pypi/l/etpgrf.svg)](https://pypi.org/project/etpgrf/)
# Типограф для Web # Типограф для веба
Экранная типографика для веба — способствует повышению читабельности текста в интернете, Экранная типографика для веба — способствует повышению читабельности текста в интернете,
приближая его к печатной типографике. приближая его к печатной типографике.
## Репозитории / Repositories
Исходный код доступен на нескольких площадках:
* [Gitea](https://git.cube2.ru/erjemin/2025-etpgrf) (Основной self-hosted)
* [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало)
* [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse)
## Демострация / Demo
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
## Установка ## Установка
```bash ```bash
@@ -92,7 +104,7 @@ result = typo_mixed_mode.process(text="Этот текст будет обраб
двумя символами `\u228a\ufe00` и превратится в `&subne;\ufe00`. Символ `\ufe00` — это невидимый символ, cелектор двумя символами `\u228a\ufe00` и превратится в `&subne;\ufe00`. Символ `\ufe00` — это невидимый символ, cелектор
варианта начертания (Variant Selector), который изменяет начертание предыдущего символа и для него нет варианта начертания (Variant Selector), который изменяет начертание предыдущего символа и для него нет
html-мнемоники. К счастью, в стандарте таких мнемоник (превращающихся в два символа) исчезающе мало и они крайне html-мнемоники. К счастью, в стандарте таких мнемоник (превращающихся в два символа) исчезающе мало и они крайне
редко применяются в тексте, поэтому это не должно вызывать проблем. редко применляются в тексте, поэтому это не должно вызывать проблем.
### Переносы слов ### Переносы слов
@@ -282,9 +294,9 @@ result = typo.process("100 км/ч") # Останется без
### Висячая типографика ### Висячая типографика
Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда
тире и маркеры списков) выносятся на левое (и иногда и по правому, при выравнивании текст по правому краю) поле текста. tире и маркеры списков) выносятся на левое (и иногда и по правому) поле текста. Это создаёт идеально ровный край не по
Это создаёт идеально ровный край не по формальным границам знаков, а по оптическому краю — по первым буквам строк. формальным границам знаков, а по оптическому краю — по первым буквам строк. Текст выглядит гораздо аккуратнее и
Текст выглядит гораздо аккуратнее и профессиональнее. профессиональнее.
Интернет публикации (да и бумажные издания) практически игнорируют висячую типографику. Но иногда это отличный Интернет публикации (да и бумажные издания) практически игнорируют висячую типографику. Но иногда это отличный
инструмент для акцентной типографики: крупные заголовки, цитаты, выносы, подписи к иллюстрациям, оформленные с помощью инструмент для акцентной типографики: крупные заголовки, цитаты, выносы, подписи к иллюстрациям, оформленные с помощью
@@ -294,9 +306,10 @@ Safari), поэтому на него полагаться нельзя. Поэ
через оборачивание висячих символов в специальные HTML-теги с CSS-классами. через оборачивание висячих символов в специальные HTML-теги с CSS-классами.
Оборачивая "висячий" символ или слово в `<span>` и применяя к нему, например, отрицательный `text-indent` или Оборачивая "висячий" символ или слово в `<span>` и применяя к нему, например, отрицательный `text-indent` или
`margin-left` (`<span style="margin-left:-0.44em;">&laquo;</span>`). Важный нюанс: степень "вывешивания" у символов `margin-left` (`<span style="margin-left:-0.44em">&laquo;</span>`), мы можем сместить сам символ, но нужно ещё и
разная. И не только потому, что кавычка, скобка или точка имеют разную ширину, но еще и потому, что кавычка, например, сохранить расстояние до соседнего слова. Поэтому типограф оборачивает не только сам висячий символ, но и ближайшее слово
может "висеть" на 100% своей ширины, а точка или запятая — только на 50-70%, чтобы не отрываться от слова совсем. (до пробела или границы узла), а также, при необходимости, окружающий пробел. Сама визуальная компенсация оформляется через
отрицательные `margin`/`padding` в CSS-классах — никаких `position:absolute`, чтобы не нарушать поток текста.
По умолчанию эта функция висячей типографики **отключена**. Чтобы её включить, нужно задать параметр По умолчанию эта функция висячей типографики **отключена**. Чтобы её включить, нужно задать параметр
`hanging_punctuation` при конфигурировании типографа (по умолчанию `hanging_punctuation=None`): `hanging_punctuation` при конфигурировании типографа (по умолчанию `hanging_punctuation=None`):
@@ -306,60 +319,91 @@ typo = etpgrf.Typographer(hanging_punctuation='left')
``` ```
Параметр `hanging_punctuation` может принимать следующие значения: Параметр `hanging_punctuation` может принимать следующие значения:
* `None`или `False` — функция отключена (по умолчанию); * `None` или `False` — функция отключена (по умолчанию);
* `'left'` — включены только левые висячие символы (выравнивание по левому краю); * `'left'` или `True` — включены только левые висячие символы (выравнивание по левому краю);
* `'right'` — включены только правые висячие символы (выравнивание по правому краю); * `'right'` — включены только правые висячие символы (выравнивание по правому краю).
* `'both'` или `True` — включены и левые, и правые висячие символы.
Так же через `hanging_punctuation` можно задать список тегов, внутри которых висячая типографика будет применяться Значения `'both'` **недоступно**, потому что совмещение левой и правой выверки одновременно приводит к конфликтам
(всегда в режиме `'both'`). Это нерекомендованный способ (так как предполагает знание об исходном тексте, и не сработает с размещением пробелов и делает невозможным контролировать визуальное выравнивание (см. блок про `text-justify`).
для сточных тегов, а поведение "блочных" и "строчных" может быть переназначено через CSS). Тем не менее управление
висячей пунктуацией на уровне тегов может быть полезно в некоторых, редких случаях: Также через `hanging_punctuation` можно задать список тегов, внутри которых висячая типографика будет применяться
```python (всегда в режиме `'both'`). Это нерекомендованный способ, потому что он предполагает знание структуры HTML и неизбежно
typo = etpgrf.Typographer(hanging_punctuation=['blockquote', 'h2', 'h3']) выпадает из общей логики вложенности и пробельных узлов.
### Как работает оборачивание
Процессор висячей типографики запускается после всех текстовых преобразований и работает с деревом BeautifulSoup. Он ищет
последовательности «пробел + висячий символ» для левого выравнивания и «слово + висячий символ + пробел» для правого,
чтобы обернуть нужные фрагменты в пары `<span>` и не допустить «сиротства» символов. Порядок действий можно описать так:
* Для `hanging_punctuation='left'`:
* если символ стоит в начале текстового узла (без пробелов слева), оборачивается только сам символ и следующее
слово (`<span class="etp-laquo">«АукЫон»</span>`);
* если перед символом внутри узла есть пробел, то пробел оборачивается в `<span class="etp-sp-laquo"> </span>`, а
символ вместе со словом — в `<span class="etp-laquo">...</span>`;
* если пробел оказалось в соседнем узле, то он тоже оборачивается в `etp-sp-*`, чтобы не нарушить последовательность;
* если компенсирующий пробел является "непереносимым пробелом" (или любым другим: шпацией, em-пробелом и т.п.), то тогда, для правильного выравнивания, оборачивается он, например: `<span class="etp-sp-laquo">&nbsp;</span><span class="etp-laquo">«АукЫон»</span>`.
* Для `hanging_punctuation='right'`:
* слово с висячим символом оборачивается в соответствующий класс (`.etp-raquo`, `.etp-rpar` и т.д.);
* пробел сразу после символа получает класс `etp-sp-raquo`, `etp-sp-rpar` и т.д., чтобы сохранить переносную ширину и
аккуратно компенсировать смещение;
Пример вывода для `'left'`:
```html
Завтра концерт группы<span class="etp-sp-laquo"> </span><span class="etp-laquo">«АукЫон»</span>
``` ```
Рекомендуемый CSS для этих классов выглядит так (возможно, вам придется подкорректировать значения `em` в зависимости ### CSS для висячих символов
от используемого шрифта, его толщины и начертания):
Предлагаемый, начиная с etpgrf v0.1.6, CSS теперь работает только с `margin` и `padding`, без `position:absolute`. Пробелы получают собственные
классы, поэтому их компенсация контролируется отдельно, а не встроена в сам висячий символ. Убедитесь, что эти стили
подключены к странице и не конфликтуют с `text-justify`, который вытягивает пробелы по всей строке и разрушает аккуратное
выравнивание.
```css ```css
/* --- ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ (выравнивание по левому краю) --- */ /* --- ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */
.etp-laquo { margin-left: -0.44em; } /* « */ .etp-laquo { margin-left: -0.44em; }
.etp-ldquo, .etp-bdquo { margin-left: -0.4em; } /* “ „ */ .etp-ldquo, .etp-bdquo { margin-left: -0.4em; }
.etp-lsquo { margin-left: -0.22em; } /* */ .etp-lsquo { margin-left: -0.22em; }
.etp-lpar, .etp-lsqb, .etp-lcub { margin-left: -0.25em; } /* ( [ { */ .etp-lpar, .etp-lsqb, .etp-lcub { margin-left: -0.25em; }
/* компенсирующие пробелы для левых висячих символов */
.etp-sp-laquo { padding-right: 0.44em; }
.etp-sp-ldquo, .etp-sp-bdquo { padding-right: 0.4em; }
.etp-sp-lsquo { padding-right: 0.22em; }
.etp-sp-lpar, .etp-sp-lsqb, .etp-sp-lcub { padding-right: 0.25em; }
/* --- ПРАВЫЕ ВИСЯЧИЕ СИМВОЛЫ (выравнивание по правому краю) --- */ /* --- ПРАВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */
/* Общая механика: "вырываем" символ из потока для идеального выравнивания текста */ .etp-raquo { padding-right: 0.44em; margin-left: -0.44em; }
[class^="etp-r"], [class*=" etp-r"] { position: absolute; } .etp-rdquo { padding-right: 0.4em; margin-left: -0.4em; }
/* Точечная настройка смещения для каждого символа */ .etp-rsquo { padding-right: 0.22em; margin-left: -0.22em; }
.etp-raquo { right: -0.44em; } /* » */ .etp-rpar, .etp-rsqb, .etp-rcub { padding-right: 0.25em; margin-left: -0.25em; }
.etp-rdquo { right: -0.4em; } /* ” */ /* компенсирующие пробелы для правых висячих символов */
.etp-rsquo { right: -0.22em; } /* */ .etp-sp-raquo { margin-left: -0.44em; }
.etp-rpar, .etp-rsqb, .etp-rcub { right: -0.25em; } /* ) ] } */ .etp-sp-rdquo { margin-left: -0.4em; }
.etp-r-dot, .etp-r-comma, .etp-r-colon { right: -0.15em; } /* . , : */ .etp-sp-rsquo { margin-left: -0.22em; }
.etp-sp-rpar, .etp-sp-rsqb, .etp-sp-rcub { margin-left: -0.25em; }
``` ```
*Комментарии:* Двухстороннее выравнивание текстового блока с помощью стиля `text-justify` в принципе плохо совместим концепцией типографики — он растягивает или сжимает пробелы по всей строке (а это пробелы между словами) и уже этими, переменными, пробелами, делает текст трудночитаемым. Если же вы используете `text-justify` для выравнивания текста по ширине, то, чтобы сохранить оптимальную читаемость текста, включать висячую типографику не рекомендуется.
## Известные особенности и ограничения
При обработке сложного HTML-кода типограф стремится сохранить структуру документа, но некоторые пограничные случаи могут обрабатываться не так, как ожидается. В частности:
* **Обработка на стыке тегов:** Правила, требующие анализа контекста (например, расстановка неразрывных пробелов у тире или единиц измерения), могут работать некорректно, если анализируемые части текста разделены тегами . Например, конструкция `$<b>100</b>` не будет обработана (между $ и 100 не будет вставлен неразрывный пробел), так как типограф не видит их как соседние элементы.
* **"Ремонт" HTML:** Библиотека использует `BeautifulSoup` для парсинга, который может "чинить" невалидный HTML (например, закрывать незакрытые теги). Это может привести к неожиданным изменениям в структуре, если исходный код был некорректен. Так же может меняться порядок атрибутов тега.
Мы знаем об этих особенностях и работаем над улучшением алгоритмов для более точной обработки сложных случаев.
## 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
**Разработка:** **Разработка:**
Проект разработан Sergei Erjemin при активном участии Gemini Assistant (LLM) в роли pair-programmer. Проект разработан Sergei Erjemin при активном участии различных LLM в роли pair-programmer.

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,11 +8,14 @@ etpgrf - библиотека для экранной типографики т
- Висячая пунктуация - Висячая пунктуация
- Очистка и обработка HTML - Очистка и обработка HTML
""" """
__version__ = "0.1.0" __version__ = "0.1.5"
__author__ = "Sergei Erjemin"
__email__ = "erjemin@gmail.com"
__license__ = "MIT"
__copyright__ = "(с) 2025-2026, Sergei Erjemin"
import etpgrf.defaults import etpgrf.defaults
import etpgrf.logger import etpgrf.logger
from etpgrf.hyphenation import Hyphenator from etpgrf.hyphenation import Hyphenator
from etpgrf.layout import LayoutProcessor from etpgrf.layout import LayoutProcessor
from etpgrf.quotes import QuotesProcessor from etpgrf.quotes import QuotesProcessor

View File

@@ -72,6 +72,9 @@ 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_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в &amp; при замене на мнемонику.
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ === # === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
@@ -687,6 +690,13 @@ PROTECTED_HTML_TAGS = ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math']
# === КОНСТАНТЫ ДЛЯ ВИСЯЧЕЙ ТИПОГРАФИКИ === # === КОНСТАНТЫ ДЛЯ ВИСЯЧЕЙ ТИПОГРАФИКИ ===
HANGING_PUNCTUATION_MODE_LEFT = 'left'
HANGING_PUNCTUATION_MODE_RIGHT = 'right'
HANGING_PUNCTUATION_MODES = frozenset([
HANGING_PUNCTUATION_MODE_LEFT,
HANGING_PUNCTUATION_MODE_RIGHT,
])
# 1. Набор символов, которые могут "висеть" слева # 1. Набор символов, которые могут "висеть" слева
HANGING_PUNCTUATION_LEFT_CHARS = frozenset([ HANGING_PUNCTUATION_LEFT_CHARS = frozenset([
CHAR_RU_QUOT1_OPEN, # « CHAR_RU_QUOT1_OPEN, # «
@@ -719,4 +729,5 @@ HANGING_PUNCTUATION_CLASSES = {
'.': 'etp-r-dot', '.': 'etp-r-dot',
',': 'etp-r-comma', ',': 'etp-r-comma',
':': 'etp-r-colon', ':': 'etp-r-colon',
} }

View File

@@ -6,7 +6,9 @@ from bs4 import BeautifulSoup, NavigableString, Tag
from .config import ( from .config import (
HANGING_PUNCTUATION_LEFT_CHARS, HANGING_PUNCTUATION_LEFT_CHARS,
HANGING_PUNCTUATION_RIGHT_CHARS, HANGING_PUNCTUATION_RIGHT_CHARS,
HANGING_PUNCTUATION_CLASSES HANGING_PUNCTUATION_CLASSES,
HANGING_PUNCTUATION_MODE_LEFT,
HANGING_PUNCTUATION_MODE_RIGHT,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,30 +23,27 @@ class HangingPunctuationProcessor:
""" """
:param mode: Режим работы: :param mode: Режим работы:
- None / False: отключено. - None / False: отключено.
- 'left': только левая пунктуация. - 'left': левая висячая пунктуация.
- 'right': только правая пунктуация. - 'right': правая висячая пунктуация.
- 'both' / True: и левая, и правая.
- list[str]: список тегов (например, ['p', 'blockquote']), - list[str]: список тегов (например, ['p', 'blockquote']),
внутри которых применять 'both'. внутри которых применять висячую пунктуацию в обе стороны.
- True эквивалентно 'left'.
""" """
self.mode = mode self.mode = mode
self.target_tags = None self.target_tags = None
self.active_chars = set() self.active_chars = set()
# Определяем, какие символы будем обрабатывать
if isinstance(mode, list): if isinstance(mode, list):
self.target_tags = set(t.lower() for t in mode) self.target_tags = set(t.lower() for t in mode)
# Если передан список тегов, включаем полный режим ('both') внутри них
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
elif mode == 'left': else:
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) normalized_mode = HANGING_PUNCTUATION_MODE_LEFT if mode is True else mode
elif mode == 'right': if normalized_mode == HANGING_PUNCTUATION_MODE_LEFT:
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS) self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
elif mode == 'both' or mode is True: elif normalized_mode == HANGING_PUNCTUATION_MODE_RIGHT:
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
# Предварительно фильтруем карту классов, оставляя только активные символы # Предварительно фильтруем карту классов, оставляя только активные символы
self.char_to_class = { self.char_to_class = {
char: cls char: cls

View File

@@ -5,7 +5,7 @@ import regex
import logging import logging
from etpgrf.config import (LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP, from etpgrf.config import (LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP,
CHAR_UNIT_SEPARATOR, DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, UNIT_MATH_OPERATORS, CHAR_UNIT_SEPARATOR, DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, UNIT_MATH_OPERATORS,
ABBR_COMMON_FINAL, ABBR_COMMON_PREPOSITION) ABBR_COMMON_FINAL, ABBR_COMMON_PREPOSITION, CHAR_NODE_SEPARATOR)
from etpgrf.comutil import parse_and_validate_langs from etpgrf.comutil import parse_and_validate_langs
@@ -35,14 +35,17 @@ class LayoutProcessor:
self.main_lang = self.langs[0] if self.langs else LANG_RU self.main_lang = self.langs[0] if self.langs else LANG_RU
self.process_initials_and_acronyms = process_initials_and_acronyms self.process_initials_and_acronyms = process_initials_and_acronyms
self.process_units = process_units self.process_units = process_units
# Экранируем разделитель для использования в regex
sep = regex.escape(CHAR_NODE_SEPARATOR)
# 1. Паттерн для длинного (—) или среднего () тире, окруженного пробелами. # 1. Паттерн для длинного (—) или среднего () тире, окруженного пробелами.
# (?<=[\p{L}\p{Po}\p{Pf}"\']) - просмотр назад на букву, пунктуацию или кавычку. # (?<=[\p{L}\p{Po}\p{Pf}"\']|sep) - просмотр назад на букву, пунктуацию, кавычку ИЛИ разделитель узлов.
self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\'])\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)') self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\']|{sep})\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
# 2. Паттерн для многоточия, за которым следует пробел и слово. # 2. Паттерн для многоточия, за которым следует пробел и слово.
# Ставит неразрывный пробел после многоточия, чтобы не отрывать его от следующего слова. # Ставит неразрывный пробел после многоточия, чтобы не отрывать его от следующего слова.
# (?=[\p{L}\p{N}]) - просмотр вперед на букву или цифру. # (?=[\p{L}\p{N}]|sep) - просмотр вперед на букву или цифру ИЛИ разделитель узлов.
self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}])') self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}]|{sep})')
# 3. Паттерн для отрицательных чисел. # 3. Паттерн для отрицательных чисел.
# Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел # Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел
@@ -95,7 +98,7 @@ class LayoutProcessor:
units_pattern_part_clean = '|'.join(map(regex.escape, [u.replace('.', '') for u in sorted_units])) units_pattern_part_clean = '|'.join(map(regex.escape, [u.replace('.', '') for u in sorted_units]))
# Простые единицы: число + единица # Простые единицы: число + единица
self._post_units_pattern = regex.compile(rf'({self._NUMBER_PATTERN})\s+({units_pattern_part_full})(?!\w)') self._post_units_pattern = regex.compile(rf'({self._NUMBER_PATTERN}|{sep})\s+({units_pattern_part_full})(?!\w)')
# Составные единицы: ищет пару "единица." + "единица" # Составные единицы: ищет пару "единица." + "единица"
self._complex_unit_pattern = regex.compile(r'\b(' + units_pattern_part_clean + r')\.(\s*)(' self._complex_unit_pattern = regex.compile(r'\b(' + units_pattern_part_clean + r')\.(\s*)('
+ units_pattern_part_clean + r')(?!\w)') + units_pattern_part_clean + r')(?!\w)')

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, CHAR_AMP_PLACEHOLDER
# --- Настройки логирования --- # --- Настройки логирования ---
@@ -115,45 +116,60 @@ class Typographer:
f"process_html: {self.process_html}") f"process_html: {self.process_html}")
def _process_text_node(self, text: str) -> str: def _hide_protected_tags(self, soup) -> list:
""" """
Внутренний конвейер, который работает с чистым текстом. Находит все защищенные теги, заменяет их на плейсхолдеры и возвращает список сохраненных тегов.
""" """
# Шаг 1: Декодируем весь входящий текст в канонический Unicode protected_tags = []
# (здесь можно использовать html.unescape, но наш кодек тоже подойдет) if not PROTECTED_HTML_TAGS:
processed_text = decode_to_unicode(text) return protected_tags
# processed_text = text # ВРЕМЕННО: используем текст как есть
# Шаг 2: Применяем правила к чистому Unicode-тексту (только правила на уровне ноды) selector = ", ".join(PROTECTED_HTML_TAGS)
if self.symbols is not None: tags_to_replace = soup.select(selector)
processed_text = self.symbols.process(processed_text)
if self.layout is not None: for tag in tags_to_replace:
processed_text = self.layout.process(processed_text) protected_tags.append(tag)
if self.hyphenation is not None: tag.replace_with(NavigableString(CHAR_PLACEHOLDER))
processed_text = self.hyphenation.hyp_in_text(processed_text)
# ... вызовы других активных модулей правил ... return protected_tags
# Финальный шаг: кодируем результат в соответствии с выбранным режимом def _restore_protected_tags(self, soup, protected_tags: list):
return encode_from_unicode(processed_text, self.mode)
def _walk_tree(self, node):
""" """
Рекурсивно обходит DOM-дерево, находя и обрабатывая все текстовые узлы. Восстанавливает защищенные теги на места плейсхолдеров.
""" """
# Список "детей" узла, который мы будем изменять. if not protected_tags:
# Копируем в список, так как будем изменять его во время итерации. return
for child in list(node.children):
if isinstance(child, NavigableString):
# Если это текстовый узел, обрабатываем его
# Пропускаем пустые или состоящие из пробелов узлы
if not child.string.strip():
continue
processed_node_text = self._process_text_node(child.string) text_nodes_with_placeholder = [
child.replace_with((processed_node_text)) node for node in soup.descendants
elif child.name not in PROTECTED_HTML_TAGS: if isinstance(node, NavigableString) and CHAR_PLACEHOLDER in node
# Если это "обычный" html-тег, рекурсивно заходим в него ]
self._walk_tree(child)
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
def process(self, text: str) -> str: def process(self, text: str) -> str:
""" """
@@ -162,97 +178,78 @@ class Typographer:
""" """
if not text: if not text:
return "" return ""
# Если включена обработка HTML и BeautifulSoup доступен
text = text.replace('&amp;', CHAR_AMP_PLACEHOLDER)
if self.process_html: if self.process_html:
# --- ЭТАП 1: Токенизация и "умная склейка" --- is_full_document = bool(regex.search(r'^\s*<(?:!DOCTYPE|html|body)', text, regex.IGNORECASE))
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, то результат - это строка (чистый текст)
if isinstance(result, str): if isinstance(result, str):
# Переключаемся на обработку обычного текста return self._process_plain_text(result).replace(CHAR_AMP_PLACEHOLDER, '&amp;')
text = result
# ВАЖНО: Мы выходим из ветки process_html и идем в ветку else,
# но так как мы внутри if, нам нужно явно вызвать логику для текста.
# Проще всего рекурсивно вызвать process с выключенным process_html,
# но чтобы не менять состояние объекта, просто выполним логику "else" блока здесь.
# Или, еще проще: присвоим text = result и пойдем в блок else? Нет, мы уже внутри if.
# Решение: Выполняем логику обработки простого текста прямо здесь
return self._process_plain_text(text)
# Если результат - soup, продолжаем работу с ним
soup = result soup = result
# 1.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать. protected_tags = self._hide_protected_tags(soup)
# 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.parent.name not in PROTECTED_HTML_TAGS]
# 1.2. Создаем "супер-строку" и "карту длин"
super_string = "" super_string = ""
lengths_map = []
for node in text_nodes: for node in text_nodes:
super_string += str(node) node_text = node.string or ""
lengths_map.append(len(str(node))) super_string += node_text + CHAR_NODE_SEPARATOR
# --- ЭТАП 2: Контекстная обработка (ПОКА ЧТО ПРОПУСКАЕМ) --- processed_super_string = self._process_plain_text(super_string)
processed_super_string = super_string
# Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html).
# Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится.
if self.quotes:
processed_super_string = self.quotes.process(processed_super_string)
if self.unbreakables:
processed_super_string = self.unbreakables.process(processed_super_string)
# --- ЭТАП 3: "Восстановление" --- parts = processed_super_string.split(CHAR_NODE_SEPARATOR)
current_pos = 0
if len(parts) > len(text_nodes):
parts = parts[:len(text_nodes)]
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) # Заменяем содержимое узла на месте node.replace_with(new_text_part)
current_pos += length
# --- ЭТАП 4: Локальная обработка (второй проход) --- self._restore_protected_tags(soup, protected_tags)
# Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход,
# который применит все остальные правила к каждому текстовому узлу.
self._walk_tree(soup)
# --- ЭТАП 4.5: Висячая пунктуация ---
# Применяем после всех текстовых преобразований, но перед финальной сборкой
if self.hanging: if self.hanging:
self.hanging.process(soup) self.hanging.process(soup)
# --- ЭТАП 5: Финальная сборка --- if is_full_document:
processed_html = str(soup) processed_html = str(soup)
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &amp;), которые мы сгенерировали else:
# в _process_text_node. Возвращаем их обратно. if soup.body:
return processed_html.replace('&amp;', '&') processed_html = soup.body.decode_contents()
else:
processed_html = str(soup)
processed_html = processed_html.replace('&amp;', '&')
return processed_html.replace(CHAR_AMP_PLACEHOLDER, '&amp;')
else: else:
return self._process_plain_text(text) processed_text = self._process_plain_text(text)
return processed_text.replace(CHAR_AMP_PLACEHOLDER, '&amp;')
def _process_plain_text(self, text: str) -> str: def _process_plain_text(self, text: str) -> str:
""" """
Логика обработки обычного текста (вынесена из process для переиспользования). Логика обработки обычного текста (вынесена из process для переиспользования).
""" """
# Шаг 0: Нормализация
processed_text = decode_to_unicode(text) processed_text = decode_to_unicode(text)
# Шаг 1: Применяем все правила последовательно
if self.symbols:
processed_text = self.symbols.process(processed_text)
if self.quotes: if self.quotes:
processed_text = self.quotes.process(processed_text) processed_text = self.quotes.process(processed_text)
if self.unbreakables: if self.unbreakables:
processed_text = self.unbreakables.process(processed_text) processed_text = self.unbreakables.process(processed_text)
if self.symbols:
processed_text = self.symbols.process(processed_text)
if self.layout: if self.layout:
processed_text = self.layout.process(processed_text) processed_text = self.layout.process(processed_text)
if self.hyphenation: if self.hyphenation:
processed_text = self.hyphenation.hyp_in_text(processed_text) processed_text = self.hyphenation.hyp_in_text(processed_text)
# Шаг 2: Финальное кодирование
return encode_from_unicode(processed_text, self.mode) return encode_from_unicode(processed_text, self.mode)

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "etpgrf" name = "etpgrf"
version = "0.1.0" version = "0.1.5"
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"
@@ -27,11 +27,14 @@ classifiers = [
dependencies = [ dependencies = [
"beautifulsoup4>=4.10.0", "beautifulsoup4>=4.10.0",
"lxml>=4.9.0", # Рекомендуемый парсер "lxml>=4.9.0", # Рекомендуемый парсер
"regex>=2022.1.18", # Критически важная зависимость для Unicode
] ]
[project.urls] [project.urls]
"Homepage" = "https://github.com/erjemin/etpgrf" "Homepage" = "https://github.com/erjemin/etpgrf"
"Bug Tracker" = "https://github.com/erjemin/etpgrf/issues" "Bug Tracker" = "https://github.com/erjemin/etpgrf/issues"
"Mirror1 (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf"
"Mirror2 (Gitea Selfhosted)" = "https://git.cube2.ru/erjemin/2025-etpgrf"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] # Искать пакеты в корне (найдет папку etpgrf) where = ["."] # Искать пакеты в корне (найдет папку etpgrf)

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

@@ -19,60 +19,34 @@ HANGING_TEST_CASES = [
# --- Режим 'left' (только левая пунктуация) --- # --- Режим 'left' (только левая пунктуация) ---
('left', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>', ('left', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата{CHAR_RU_QUOT1_CLOSE}</p>'), f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата{CHAR_RU_QUOT1_CLOSE}</p>'),
('left', f'<p>(Скобки)</p>', ('left', '<p>(Скобки)</p>', '<p><span class="etp-lpar">(</span>Скобки)</p>'),
f'<p><span class="etp-lpar">(</span>Скобки)</p>'), ('left', '<p>Текст.</p>', '<p>Текст.</p>'),
# Правая пунктуация игнорируется
('left', f'<p>Текст.</p>', f'<p>Текст.</p>'),
# --- Режим 'right' (только правая пунктуация) --- # --- Режим 'right' (только правая пунктуация) ---
('right', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>', ('right', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p>{CHAR_RU_QUOT1_OPEN}Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'), f'<p>{CHAR_RU_QUOT1_OPEN}Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
('right', f'<p>Текст.</p>', ('right', '<p>Текст.</p>', '<p>Текст<span class="etp-r-dot">.</span></p>'),
f'<p>Текст<span class="etp-r-dot">.</span></p>'), ('right', '<p>(Скобки)</p>', '<p>(Скобки<span class="etp-rpar">)</span></p>'),
# Левая пунктуация игнорируется ('right', '<p>3.14</p>', '<p>3.14</p>'),
('right', f'<p>(Скобки)</p>', f'<p>(Скобки<span class="etp-rpar">)</span></p>'), ('right', '<p>End.</p>', '<p>End<span class="etp-r-dot">.</span></p>'),
# --- Режим 'both' (и левая, и правая) ---
('both', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
('both', f'<p>Текст.</p>',
f'<p>Текст<span class="etp-r-dot">.</span></p>'),
# Последовательность символов (точка + кавычка)
('both', f'<p>Текст.{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p>Текст<span class="etp-r-dot">.</span><span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
# Вложенные теги
('both', f'<p><b>{CHAR_RU_QUOT1_OPEN}Жирный{CHAR_RU_QUOT1_CLOSE}</b></p>',
f'<p><b><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Жирный<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></b></p>'),
# Смешанный контент
('both', f'<p>{CHAR_RU_QUOT1_OPEN}Начало <i>курсив</i> конец.{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Начало <i>курсив</i> конец<span class="etp-r-dot">.</span><span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
# --- Режим None / False (отключено) --- # --- Режим None / False (отключено) ---
(None, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>', (None, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'), f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'),
(False, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>', (False, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>',
f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'), f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'),
]
# --- Отсутствие висячих символов --- # --- Режим list[str] (список тегов с обеими сторонами) ---
('both', '<p>Простой текст без спецсимволов!</p>', '<p>Простой текст без спецсимволов!</p>'), HANGING_LIST_MODE_CASES = [
(['p'], f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
# --- Проверка контекста (пробелы) --- f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
# 1. Левая кавычка внутри слова (не должна висеть) (['p'], f'<p>Текст.{CHAR_RU_QUOT1_CLOSE}</p>',
('both', f'<p>func{CHAR_RU_QUOT1_OPEN}arg{CHAR_RU_QUOT1_CLOSE}</p>', f'<p>Текст<span class="etp-r-dot">.</span><span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
f'<p>func{CHAR_RU_QUOT1_OPEN}arg<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'), # Правая висит, т.к. конец узла (['p'], f'<p>func {CHAR_RU_QUOT1_OPEN}arg</p>',
# 2. Правая кавычка внутри слова (не должна висеть) f'<p>func <span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>arg</p>'),
('both', f'<p>1{CHAR_RU_QUOT1_CLOSE}2</p>', (['p'], f'<p>arg{CHAR_RU_QUOT1_CLOSE} next</p>',
f'<p>1{CHAR_RU_QUOT1_CLOSE}2</p>'), f'<p>arg<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span> next</p>'),
# 3. Левая кавычка после пробела (должна висеть)
('both', f'<p>func {CHAR_RU_QUOT1_OPEN}arg</p>',
f'<p>func <span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>arg</p>'),
# 4. Правая кавычка перед пробелом (должна висеть)
('both', f'<p>arg{CHAR_RU_QUOT1_CLOSE} next</p>',
f'<p>arg<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span> next</p>'),
# 5. Точка внутри числа (не должна висеть)
('both', '<p>3.14</p>', '<p>3.14</p>'),
# 6. Точка в конце предложения (должна висеть)
('both', '<p>End.</p>', '<p>End<span class="etp-r-dot">.</span></p>'),
] ]
@@ -112,3 +86,13 @@ def test_hanging_punctuation_target_tags():
processor.process(soup) processor.process(soup)
assert str(soup) == expected_html assert str(soup) == expected_html
@pytest.mark.parametrize("mode, input_html, expected_html", HANGING_LIST_MODE_CASES)
def test_hanging_punctuation_processor_list_mode(mode, input_html, expected_html):
"""Проверяет, что list-режим работает и для левой, и для правой стороны внутри указанного тега."""
processor = HangingPunctuationProcessor(mode=mode)
soup = make_soup(input_html)
processor.process(soup)
assert str(soup) == expected_html

View File

@@ -103,6 +103,12 @@ TYPOGRAPHER_HTML_TEST_CASES = [
f'<p>Текст с{CHAR_NBSP}картинкой <img alt="image" src="image.jpg"/> и{CHAR_NBSP}текстом.</p>'), f'<p>Текст с{CHAR_NBSP}картинкой <img alt="image" src="image.jpg"/> и{CHAR_NBSP}текстом.</p>'),
('unicode', '<p>Текст с <code>&lt;br&gt;</code><br>А это новая строка.</p>', ('unicode', '<p>Текст с <code>&lt;br&gt;</code><br>А это новая строка.</p>',
f'<p>Текст с{CHAR_NBSP}<code>&lt;br&gt;</code><br/>А{CHAR_NBSP}это новая строка.</p>'), f'<p>Текст с{CHAR_NBSP}<code>&lt;br&gt;</code><br/>А{CHAR_NBSP}это новая строка.</p>'),
# --- Тесты на стыке тегов ---
('mixed', '<p>Текст <span>с тире</span> --- после закрытого тега.</p>',
'<p>Текст <span>с&nbsp;тире</span>&nbsp;— после закрытого тега.</p>'),
('mixed', '<p>Целых <b>100</b> т веса.</p>',
'<p>Целых <b>100</b>&nbsp;т веса.</p>'),
] ]
@@ -149,3 +155,70 @@ 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>'),
# 7. Тест на маскированные мнемоники и де-экранирование &amp;
('<p>Текст с &lt; и &gt; и &amp; внутри.</p>', '<p>Текст с&nbsp;&lt; и&nbsp;&gt; и&nbsp;&amp; внутри.</p>'),
('<p>Текст с &amp;lt; и &amp;gt; и &amp;amp; внутри.</p>', '<p>Текст с&nbsp;&amp;lt; и&nbsp;&amp;gt; и&nbsp;&amp;amp; внутри.</p>'),
('<p>Мнемоника <code>&amp;nbsp;</code> превратится в неразрывный пробел</p>', '<p>Мнемоника <code>&amp;nbsp;</code> превратится в&nbsp;неразрывный пробел</p>'),
]
@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