Compare commits
32 Commits
v0.1.0
...
913f28f2f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 913f28f2f3 | |||
| 125c9560b4 | |||
| 9695fe80aa | |||
| 4b62564c6b | |||
| e8ef4c1bca | |||
| bb1fcc71e0 | |||
| d917634787 | |||
| 67c17ff536 | |||
| 6b098f161d | |||
| ca3f93a485 | |||
| ace8b61ae3 | |||
| c54ae63030 | |||
| 00c80b79f1 | |||
| f3a651a54f | |||
| fe6f2a1522 | |||
| 57b8f4f74a | |||
| 6f5551ec29 | |||
| d1b8728002 | |||
| 604d510b24 | |||
| aa2112669f | |||
| d94815d7ee | |||
| cb31c5a3b7 | |||
| 97777a7d0a | |||
| 28b74f0d7e | |||
| f35a48a0ae | |||
| 7a7f9dc4cc | |||
| 8f01b1961e | |||
| a77cd3fa46 | |||
| 9d8b5ec55e | |||
| 75a78118ba | |||
| 6b07fd472b | |||
| 00efdde999 |
49
CHANGELOG.md
Normal file
49
CHANGELOG.md
Normal 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
|
||||
### Исправлено
|
||||
- Исправлена ошибка, из-за которой `&` в исходном тексте некорректно преобразовывался в `&`. Теперь `&` и его варианты (`&`, `<`) сохраняются в итоговом HTML.
|
||||
- Исправлена (частично) расстановка неразрывных пробелов ` ` на границах закрывающих тегов (например, `<b>Текст</b> -- слово` теперь корректно обрабатывается, в `Текст</b> &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
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)
|
||||
```
|
||||
144
README.md
144
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) (Главное зеркало)
|
||||
* [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse)
|
||||
|
||||
## Демострация / Demo
|
||||
|
||||
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
|
||||
вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
@@ -92,7 +104,7 @@ result = typo_mixed_mode.process(text="Этот текст будет обраб
|
||||
двумя символами `\u228a\ufe00` и превратится в `⊊\ufe00`. Символ `\ufe00` — это невидимый символ, cелектор
|
||||
варианта начертания (Variant Selector), который изменяет начертание предыдущего символа и для него нет
|
||||
html-мнемоники. К счастью, в стандарте таких мнемоник (превращающихся в два символа) исчезающе мало и они крайне
|
||||
редко применяются в тексте, поэтому это не должно вызывать проблем.
|
||||
редко применляются в тексте, поэтому это не должно вызывать проблем.
|
||||
|
||||
|
||||
### Переносы слов
|
||||
@@ -282,9 +294,9 @@ result = typo.process("100 км/ч") # Останется без
|
||||
### Висячая типографика
|
||||
|
||||
Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда
|
||||
тире и маркеры списков) выносятся на левое (и иногда и по правому, при выравнивании текст по правому краю) поле текста.
|
||||
Это создаёт идеально ровный край не по формальным границам знаков, а по оптическому краю — по первым буквам строк.
|
||||
Текст выглядит гораздо аккуратнее и профессиональнее.
|
||||
tире и маркеры списков) выносятся на левое (и иногда и по правому) поле текста. Это создаёт идеально ровный край не по
|
||||
формальным границам знаков, а по оптическому краю — по первым буквам строк. Текст выглядит гораздо аккуратнее и
|
||||
профессиональнее.
|
||||
|
||||
Интернет публикации (да и бумажные издания) практически игнорируют висячую типографику. Но иногда это отличный
|
||||
инструмент для акцентной типографики: крупные заголовки, цитаты, выносы, подписи к иллюстрациям, оформленные с помощью
|
||||
@@ -294,9 +306,10 @@ Safari), поэтому на него полагаться нельзя. Поэ
|
||||
через оборачивание висячих символов в специальные HTML-теги с CSS-классами.
|
||||
|
||||
Оборачивая "висячий" символ или слово в `<span>` и применяя к нему, например, отрицательный `text-indent` или
|
||||
`margin-left` (`<span style="margin-left:-0.44em;">«</span>`). Важный нюанс: степень "вывешивания" у символов
|
||||
разная. И не только потому, что кавычка, скобка или точка имеют разную ширину, но еще и потому, что кавычка, например,
|
||||
может "висеть" на 100% своей ширины, а точка или запятая — только на 50-70%, чтобы не отрываться от слова совсем.
|
||||
`margin-left` (`<span style="margin-left:-0.44em">«</span>`), мы можем сместить сам символ, но нужно ещё и
|
||||
сохранить расстояние до соседнего слова. Поэтому типограф оборачивает не только сам висячий символ, но и ближайшее слово
|
||||
(до пробела или границы узла), а также, при необходимости, окружающий пробел. Сама визуальная компенсация оформляется через
|
||||
отрицательные `margin`/`padding` в CSS-классах — никаких `position:absolute`, чтобы не нарушать поток текста.
|
||||
|
||||
По умолчанию эта функция висячей типографики **отключена**. Чтобы её включить, нужно задать параметр
|
||||
`hanging_punctuation` при конфигурировании типографа (по умолчанию `hanging_punctuation=None`):
|
||||
@@ -306,60 +319,91 @@ typo = etpgrf.Typographer(hanging_punctuation='left')
|
||||
```
|
||||
|
||||
Параметр `hanging_punctuation` может принимать следующие значения:
|
||||
* `None`или `False` — функция отключена (по умолчанию);
|
||||
* `'left'` — включены только левые висячие символы (выравнивание по левому краю);
|
||||
* `'right'` — включены только правые висячие символы (выравнивание по правому краю);
|
||||
* `'both'` или `True` — включены и левые, и правые висячие символы.
|
||||
* `None` или `False` — функция отключена (по умолчанию);
|
||||
* `'left'` или `True` — включены только левые висячие символы (выравнивание по левому краю);
|
||||
* `'right'` — включены только правые висячие символы (выравнивание по правому краю).
|
||||
|
||||
Так же через `hanging_punctuation` можно задать список тегов, внутри которых висячая типографика будет применяться
|
||||
(всегда в режиме `'both'`). Это нерекомендованный способ (так как предполагает знание об исходном тексте, и не сработает
|
||||
для сточных тегов, а поведение "блочных" и "строчных" может быть переназначено через CSS). Тем не менее управление
|
||||
висячей пунктуацией на уровне тегов может быть полезно в некоторых, редких случаях:
|
||||
```python
|
||||
typo = etpgrf.Typographer(hanging_punctuation=['blockquote', 'h2', 'h3'])
|
||||
Значения `'both'` **недоступно**, потому что совмещение левой и правой выверки одновременно приводит к конфликтам
|
||||
с размещением пробелов и делает невозможным контролировать визуальное выравнивание (см. блок про `text-justify`).
|
||||
|
||||
Также через `hanging_punctuation` можно задать список тегов, внутри которых висячая типографика будет применяться
|
||||
(всегда в режиме `'both'`). Это нерекомендованный способ, потому что он предполагает знание структуры HTML и неизбежно
|
||||
выпадает из общей логики вложенности и пробельных узлов.
|
||||
|
||||
### Как работает оборачивание
|
||||
|
||||
Процессор висячей типографики запускается после всех текстовых преобразований и работает с деревом 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"> </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
|
||||
/* --- ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ (выравнивание по левому краю) --- */
|
||||
.etp-laquo { margin-left: -0.44em; } /* « */
|
||||
.etp-ldquo, .etp-bdquo { margin-left: -0.4em; } /* “ „ */
|
||||
.etp-lsquo { margin-left: -0.22em; } /* ‘ */
|
||||
.etp-lpar, .etp-lsqb, .etp-lcub { margin-left: -0.25em; } /* ( [ { */
|
||||
/* --- ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */
|
||||
.etp-laquo { margin-left: -0.44em; }
|
||||
.etp-ldquo, .etp-bdquo { margin-left: -0.4em; }
|
||||
.etp-lsquo { margin-left: -0.22em; }
|
||||
.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; }
|
||||
|
||||
/* --- ПРАВЫЕ ВИСЯЧИЕ СИМВОЛЫ (выравнивание по правому краю) --- */
|
||||
/* Общая механика: "вырываем" символ из потока для идеального выравнивания текста */
|
||||
[class^="etp-r"], [class*=" etp-r"] { position: absolute; }
|
||||
/* Точечная настройка смещения для каждого символа */
|
||||
.etp-raquo { right: -0.44em; } /* » */
|
||||
.etp-rdquo { right: -0.4em; } /* ” */
|
||||
.etp-rsquo { right: -0.22em; } /* ’ */
|
||||
.etp-rpar, .etp-rsqb, .etp-rcub { right: -0.25em; } /* ) ] } */
|
||||
.etp-r-dot, .etp-r-comma, .etp-r-colon { right: -0.15em; } /* . , : */
|
||||
/* --- ПРАВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */
|
||||
.etp-raquo { padding-right: 0.44em; margin-left: -0.44em; }
|
||||
.etp-rdquo { padding-right: 0.4em; margin-left: -0.4em; }
|
||||
.etp-rsquo { padding-right: 0.22em; margin-left: -0.22em; }
|
||||
.etp-rpar, .etp-rsqb, .etp-rcub { padding-right: 0.25em; margin-left: -0.25em; }
|
||||
/* компенсирующие пробелы для правых висячих символов */
|
||||
.etp-sp-raquo { margin-left: -0.44em; }
|
||||
.etp-sp-rdquo { margin-left: -0.4em; }
|
||||
.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.
|
||||
|
||||
Если вам нравится этот, можете поддержать отправив любую сумму на мой Т-банк
|
||||
[по ссылке](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
|
||||
|
||||
**Разработка:**
|
||||
Проект разработан Sergei Erjemin при активном участии Gemini Assistant (LLM) в роли pair-programmer.
|
||||
Проект разработан Sergei Erjemin при активном участии различных LLM в роли pair-programmer.
|
||||
|
||||
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,11 +8,14 @@ etpgrf - библиотека для экранной типографики т
|
||||
- Висячая пунктуация
|
||||
- Очистка и обработка 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.logger
|
||||
|
||||
from etpgrf.hyphenation import Hyphenator
|
||||
from etpgrf.layout import LayoutProcessor
|
||||
from etpgrf.quotes import QuotesProcessor
|
||||
|
||||
182
etpgrf/config.py
182
etpgrf/config.py
@@ -70,8 +70,42 @@ CHAR_ARROW_L_LONG_DOUBLE = '\u27f8' # Длинная двойная стрел
|
||||
CHAR_ARROW_R_LONG_DOUBLE = '\u27f9' # Длинная двойная стрелка вправо
|
||||
CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двунаправленная стрелка
|
||||
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / ·
|
||||
# ПРОБЕЛЫ
|
||||
CHAR_EN_SP = '\u2002' # Полужирный пробел (En Space) --  
|
||||
CHAR_EM_SP = '\u2003' # Широкий пробел (Em Space) --  
|
||||
CHAR_NUM_SP = '\u2007' # Цифровой пробел --  
|
||||
CHAR_PUNT_SP = '\u2008' # Пунктуационный пробел --  
|
||||
CHAR_HAIR_SP = '\u200A' # Толщина волоса (Hair Space) --  
|
||||
CHAR_MED_SP = '\u205F' # Средний пробел (Medium Mathematical Space) --  
|
||||
CHAR_NULL_SP = '\u200B' # Нулевой пробел (Zero Width Space)... в стандартах мнемоник путаница, и ее мнемоника ​
|
||||
CHAR_THIN_NBSP = '\u202F' # Тонкий неразрывной пробел (Narrow No-Break Space) который, к сожалению, не имеет html-мнемоники
|
||||
CHAR_ZWNJ = '\u200D' # Нулевая ширина (с объединением) (Zero Width Joiner) -- ‌
|
||||
CHAR_EN_QUAD_SP = '\u2000' # Полукруглая шпация, ширина равна 1/2 от кегля (En Quad)
|
||||
CHAR_EM_QUAD_SP = '\u2001' # Круглая шпация, ширина равна 1/1 (полный кегль), квадрат (Em Quad)
|
||||
CHAR_THREE_PER_EM_SP = '\u2004' # «Толстый» пробел, ширина 1/3 от круглой шпации (Three-Per-Em Space) --  
|
||||
CHAR_FOUR_PER_EM_SP = '\u2005' # Средний пробел, ширина 1/4 от круглой шпации (Four-Per-Em Space) --  
|
||||
CHAR_SIX_PER_EM_SP = '\u2006' # «Тонкий» волосяной пробел, ширина 1/6 от круглой шпации (Six-Per-Em Space)
|
||||
# ПРОСТО ЧАСТО ИСПОЛЬЗУЕМЫЕ СИМВОЛЫ, ДЛЯ ЭКОНОМИИ ПАМЯТИ (эфемерно, т.к. Python все равно бы оптимизировал, но для ясности и удобства):
|
||||
CHAR_LPAR = '(' # Левая круглая скобка
|
||||
CHAR_LSQB = '[' # Левая квадратная скобка
|
||||
CHAR_LCUB = '{' # Левая фигурная скобка
|
||||
CHAR_RPAR = ')' # Правая круглая скобка
|
||||
CHAR_RSQB = ']' # Правая квадратная скобка
|
||||
CHAR_RCUB = '}' # Правая фигурная скобка
|
||||
CHAR_DOT = '.' # Точка
|
||||
CHAR_COMMA = ',' # Запятая
|
||||
CHAR_COLON = ':' # Двоеточие
|
||||
# СЛУЖЕБНЫЕ СИМВОЛЫ (НЕ ДОЛЖНЫ ПРИНИМАТЬСЯ ВВОДОМ И НЕ ДОЛЖНЫ ВЫВОДИТЬСЯ, ИСПОЛЬЗУЮТСЯ ТОЛЬКО ВНУТРИ ПРОЦЕССОРОВ ДЛЯ ВРЕМЕННОЙ ЗАМЕНЫ ИЛИ РАЗДЕЛЕНИЯ):
|
||||
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
|
||||
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
|
||||
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
|
||||
CHAR_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в & при замене на мнемонику.
|
||||
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
|
||||
|
||||
# === ПЛЕЙСХОЛДЕРЫ (ДЛЯ САНИТАЙЗИНГА НА ХОДЕ) ===
|
||||
CHARS_SYMBOLS_TO_BAN = frozenset([
|
||||
CHAR_UNIT_SEPARATOR, CHAR_PLACEHOLDER, CHAR_AMP_PLACEHOLDER, CHAR_NODE_SEPARATOR
|
||||
])
|
||||
|
||||
|
||||
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
|
||||
@@ -105,8 +139,8 @@ STR_TO_SYMBOL_REPLACEMENTS = [
|
||||
|
||||
# === КОНСТАНТЫ ДЛЯ КОДИРОВАНИЯ HTML-МНЕМНОИКОВ ===
|
||||
# --- ЧЕРНЫЙ СПИСОК: Символы, которые НИКОГДА не нужно кодировать в мнемоники ---
|
||||
NEVER_ENCODE_CHARS = (frozenset(['!', '#', '%', '(', ')', '*', ',', '.', '/', ':', ';', '=', '?', '@',
|
||||
'[', '\\', ']', '^', '_', '`', '{', '|', '}', '~', '\n', '\t', '\r'])
|
||||
NEVER_ENCODE_CHARS = (frozenset(['!', '#', '%', CHAR_LPAR, CHAR_RPAR, '*', CHAR_COMMA, CHAR_DOT, '/', CHAR_COLON, ';', '=', '?', '@',
|
||||
CHAR_LSQB, '\\', CHAR_RSQB, '^', '_', '`', CHAR_LCUB, '|', CHAR_RCUB, '~', '\n', '\t', '\r'])
|
||||
| RU_ALPHABET_FULL | EN_ALPHABET_FULL)
|
||||
|
||||
# 2. БЕЛЫЙ СПИСОК (ДЛЯ БЕЗОПАСНОСТИ):
|
||||
@@ -116,22 +150,25 @@ SAFE_MODE_CHARS_TO_MNEMONIC = frozenset([
|
||||
'<', '>', '&', '"', '\'',
|
||||
CHAR_SHY, # Мягкий перенос (Soft Hyphen) -- ­
|
||||
CHAR_NBSP, # Неразрывный пробел (Non-Breaking Space) --
|
||||
'\u2002', # Полужирный пробел (En Space) --  
|
||||
'\u2003', # Широкий пробел (Em Space) --  
|
||||
'\u2007', # Цифровой пробел --  
|
||||
'\u2008', # Пунктуационный пробел --  
|
||||
CHAR_EN_SP, # Полужирный пробел (En Space) --  
|
||||
CHAR_EM_SP, # Широкий пробел (Em Space) --  
|
||||
CHAR_NUM_SP, # Цифровой пробел --  
|
||||
CHAR_PUNT_SP, # Пунктуационный пробел --  
|
||||
CHAR_THIN_SP, # Межсимвольный пробел, тонкий пробел, шпация --  '
|
||||
'\u200A', # Толщина волоса (Hair Space) --  
|
||||
'\u200B', # Негативный пробел (Negative Space) -- ​
|
||||
CHAR_HAIR_SP, # Толщина волоса (Hair Space) --  
|
||||
CHAR_NULL_SP, # Нулевой пробел (Zero Width Space)... в стандартах мнемоник путаница, и ее мнемоника ​
|
||||
'\u200C', # Нулевая ширина (без объединения) (Zero Width Non-Joiner) -- ‍
|
||||
'\u200D', # Нулевая ширина (с объединением) (Zero Width Joiner) -- ‌
|
||||
CHAR_ZWNJ, # Нулевая ширина (с объединением) (Zero Width Joiner) -- ‌
|
||||
CHAR_THREE_PER_EM_SP, # «Толстый» пробел, ширина 1/3 от круглой шпации (Three-Per-Em Space) --  
|
||||
CHAR_FOUR_PER_EM_SP, # Средний пробел, ширина 1/4 от круглой шпации (Four-Per-Em Space) --  
|
||||
'\u200E', # Изменить направление текста на слева-направо (Left-to-Right Mark /LRE) -- ‎
|
||||
'\u200F', # Изменить направление текста направо-налево (Right-to-Left Mark /RLM) -- ‏
|
||||
'\u2010', # Дефис (Hyphen) -- ‐
|
||||
'\u205F', # Средний пробел (Medium Mathematical Space) --  
|
||||
CHAR_MED_SP, # Средний пробел (Medium Mathematical Space) --  
|
||||
'\u2060', # ⁠
|
||||
'\u2062', # ⁢ -- для семантической разметки математических выражений
|
||||
'\u2063', # ⁣ -- для семантической разметки математических выражений
|
||||
|
||||
])
|
||||
|
||||
# 3. СПИСОК ДЛЯ ЧИСЛОВОГО КОДИРОВАНИЯ: Символы без стандартного имени.
|
||||
@@ -146,6 +183,10 @@ ALWAYS_ENCODE_TO_NUMERIC_CHARS = frozenset([
|
||||
'\u20BD', # Знак русского рубля (₽)
|
||||
'\u20BE', # Знак грузинский лари (₾)
|
||||
'\u20BF', # Знак биткоина (₿)
|
||||
CHAR_THIN_NBSP, # Тонкий неразрывный пробел (Narrow No-Break Space) -- как   но с поведением (к сожалению, не имеет html-мнемоники)
|
||||
CHAR_EN_QUAD_SP, # Полукруглая шпация, ширина равна 1/2 от кегля (En Quad)
|
||||
CHAR_EM_QUAD_SP, # Круглая шпация, ширина равна 1/1 (полный кегль), квадрат (Em Quad)
|
||||
CHAR_SIX_PER_EM_SP, # «Тонкий» волосяной пробел, ширина 1/6 от круглой шпации (Six-Per-Em Space)
|
||||
])
|
||||
|
||||
# 4. СЛОВАРЬ ПРИОРИТЕТОВ: Кастомные и/или предпочитаемые мнемоники.
|
||||
@@ -602,7 +643,7 @@ def _build_translation_maps() -> dict[str, str]:
|
||||
# На его основе строим нашу карту для кодирования.
|
||||
encode_map = {}
|
||||
|
||||
# ШАГ 2: Высший приоритет. Загружаем наши кастомные правила.
|
||||
# ШАГ 2: Высший приоритет. Загружаем кастомные правила.
|
||||
encode_map.update(CUSTOM_ENCODE_MAP)
|
||||
|
||||
# ШАГ 3: Следующий приоритет. Добавляем числовое кодирование.
|
||||
@@ -687,36 +728,121 @@ 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,
|
||||
])
|
||||
|
||||
# Пробелы ( символы-ищейки) которые могут использоваться как разделители "компенсационных сдвигов" для висячей пунктуации.
|
||||
# Их соседство с висячими символами позволяет "компенсировать" их смещение относительно прилегающего символа.
|
||||
HANGING_PUNCTUATION_SPACE_CHARS = frozenset([
|
||||
' ', # обычный пробел
|
||||
# CHAR_NBSP, # неразрывный пробел ( )
|
||||
CHAR_SHY, # мягкий перенос (­)
|
||||
CHAR_THIN_SP, # тонкий пробел ( )
|
||||
CHAR_EN_QUAD_SP, # Полукруглая шпация, ширина равна 1/2 от кегля (En Quad)
|
||||
CHAR_EM_QUAD_SP, # Круглая шпация, ширина равна 1/1 (полный кегль), квадрат (Em Quad)
|
||||
CHAR_EN_SP, # EN-пробел (en space)
|
||||
CHAR_EM_SP, # EM-пробел (em space)
|
||||
CHAR_THREE_PER_EM_SP, # «Толстый» пробел, ширина 1/3 от круглой шпации (Three-Per-Em Space)
|
||||
CHAR_FOUR_PER_EM_SP, # Средний пробел, ширина 1/4 от круглой шпации (Four-Per-Em Space)
|
||||
CHAR_SIX_PER_EM_SP, # «Тонкий» волосяной пробел, ширина 1/6 от круглой шпации (Six-Per-Em Space)
|
||||
CHAR_NUM_SP, # цифровой пробел (figure space)
|
||||
CHAR_PUNT_SP, # пунктуационный пробел (punctuation space)
|
||||
CHAR_HAIR_SP, # волосной пробел (hair space)
|
||||
# CHAR_THIN_NBSP, # Тонкий неразрывной пробел (Narrow No-Break Space) который, к сожалению, не имеет html-мнемоники
|
||||
CHAR_MED_SP, # средний пробел (medium space)
|
||||
CHAR_NULL_SP, # нулевой пробел (zero width space)... в мнемонике ​
|
||||
'\t', # табуляция
|
||||
'\n', # перевод строки
|
||||
'\r', # возврат каретки
|
||||
'\u000b', # вертикальная табуляция
|
||||
'\f' # перевод страницы
|
||||
])
|
||||
|
||||
# 1. Набор символов, которые могут "висеть" слева
|
||||
# ВАЖНО: кавычки второго уровня (CHAR_EN_QUOT2_OPEN = '„' и CHAR_RU_QUOT2_OPEN = '„') НЕ ВКЛЮЧЕНЫ,
|
||||
# т.к. CHAR_RU_QUOT2_CLOSE == CHAR_EN_QUOT1_OPEN и невозможно отличить закрывающую кавычку (ru)
|
||||
# от открывающей кавычки (en) и однозначно решить к какую сторону делать вывешивание.
|
||||
# TODO: в будущем можно попробовать определять это по прилегающему пробелу (слева или справа).
|
||||
HANGING_PUNCTUATION_LEFT_CHARS = frozenset([
|
||||
CHAR_RU_QUOT1_OPEN, # «
|
||||
CHAR_EN_QUOT1_OPEN, # “
|
||||
'(', '[', '{',
|
||||
CHAR_LPAR, # (
|
||||
CHAR_LSQB, # [
|
||||
CHAR_LCUB, # {
|
||||
])
|
||||
|
||||
# 2. Набор символов, которые могут "висеть" справа
|
||||
HANGING_PUNCTUATION_RIGHT_CHARS = frozenset([
|
||||
CHAR_RU_QUOT1_CLOSE, # »
|
||||
CHAR_EN_QUOT1_CLOSE, # ”
|
||||
')', ']', '}',
|
||||
'.', ',', ':',
|
||||
CHAR_RPAR, # )
|
||||
CHAR_RSQB, # ]
|
||||
CHAR_RCUB, # }
|
||||
CHAR_DOT, # .
|
||||
CHAR_COMMA, # ,
|
||||
CHAR_COLON, # :
|
||||
])
|
||||
|
||||
# 3. Словарь, сопоставляющий символ с его CSS-классом
|
||||
HANGING_PUNCTUATION_CLASSES = {
|
||||
HANGING_PUNCTUATION_SYMBOLS_CLASSES = {
|
||||
# Левая пунктуация: все классы начинаются с 'etp-l'
|
||||
CHAR_RU_QUOT1_OPEN: 'etp-laquo',
|
||||
CHAR_EN_QUOT1_OPEN: 'etp-ldquo',
|
||||
'(': 'etp-lpar',
|
||||
'[': 'etp-lsqb',
|
||||
'{': 'etp-lcub',
|
||||
CHAR_RU_QUOT1_OPEN: 'etp-laquo', # ` «` -- левая открывающая кавычка-ёлочка
|
||||
CHAR_EN_QUOT1_OPEN: 'etp-ldquo', # ` “` -- левая открывающая кавычка-лапка
|
||||
CHAR_LPAR: 'etp-lpar', # ` (` -- левая открывающая скобка
|
||||
CHAR_LSQB: 'etp-lsqb', # ` [` -- левая открывающая квадратная скобка
|
||||
CHAR_LCUB: 'etp-lcub', # ` {` -- левая открывающая фигурная скобка
|
||||
# Правая пунктуация: все классы начинаются с 'etp-r'
|
||||
CHAR_RU_QUOT1_CLOSE: 'etp-raquo',
|
||||
CHAR_EN_QUOT1_CLOSE: 'etp-rdquo',
|
||||
')': 'etp-rpar',
|
||||
']': 'etp-rsqb',
|
||||
'}': 'etp-rcub',
|
||||
'.': 'etp-r-dot',
|
||||
',': 'etp-r-comma',
|
||||
':': 'etp-r-colon',
|
||||
CHAR_RU_QUOT1_CLOSE: 'etp-raquo', # `» ` -- правая закрывающая кавычка-ёлочка
|
||||
CHAR_EN_QUOT1_CLOSE: 'etp-rdquo', # `” ` -- правая закрывающая кавычка-лапка
|
||||
CHAR_RPAR: 'etp-rpar', # `) ` -- правая закрывающая скобка
|
||||
CHAR_RSQB: 'etp-rsqb', # `] ` -- правая закрывающая квадратная скобка
|
||||
CHAR_RCUB: 'etp-rcub', # `} ` -- правая закрывающая фигурная скобка
|
||||
CHAR_DOT: 'etp-r-dot', # `. ` -- точка (обычно в конце предложения и висит справа)
|
||||
CHAR_COMMA: 'etp-r-comma', # `, ` -- запятая (обычно висит справа)
|
||||
CHAR_COLON: 'etp-r-colon', # `: ` -- двоеточие (обычно висит справа)
|
||||
}
|
||||
|
||||
# 4. Словарь, сопоставляющий классам висячей пунктуации классы для компенсационных пробелов
|
||||
HANGING_PUNCTUATION_SPACE_CLASSES = {
|
||||
'left': {
|
||||
# Для левой пунктуации (компенсационный пробел слева от висячей пунктуации)
|
||||
CHAR_RU_QUOT1_OPEN: 'etp-sp-laquo', # ` «` -- для пробела пред открывающей кавычкой-ёлочкой
|
||||
CHAR_EN_QUOT1_OPEN: 'etp-sp-ldquo', # ` “` -- для пробела пред открывающей кавычкой-лапкой
|
||||
CHAR_LPAR: 'etp-sp-lpar', # ` (` -- для пробела пред левой открывающей скобкой
|
||||
CHAR_LSQB: 'etp-sp-lsqb', # ` [` -- для пробела пред левой открывающей квадратной скобкой
|
||||
CHAR_LCUB: 'etp-sp-lcub', # ` {` -- для пробела пред левой открывающей фигурной скобкой
|
||||
},
|
||||
'right': {
|
||||
# Для правой пунктуации (компенсационный пробел справа от висячей пунктуации)
|
||||
CHAR_RU_QUOT1_CLOSE: 'etp-sp-raquo', # `» ` -- для пробела после закрывающей кавычки-ёлочки
|
||||
CHAR_EN_QUOT1_CLOSE: 'etp-sp-rdquo', # `” ` -- для пробела после закрывающей кавычки-лапки
|
||||
CHAR_RPAR: 'etp-sp-rpar', # `) ` -- для пробела после правой закрывающей скобки
|
||||
CHAR_RSQB: 'etp-sp-rsqb', # `] ` -- для пробела после правой закрывающей квадратной скобки
|
||||
CHAR_RCUB: 'etp-sp-rcub', # `} ` -- для пробела после правой закрывающей фигурной скобки
|
||||
CHAR_DOT: 'etp-sp-r-dot', # `. ` -- для пробела после точки
|
||||
CHAR_COMMA: 'etp-sp-r-comma', # `, ` -- для пробела после запятой
|
||||
CHAR_COLON: 'etp-sp-r-colon', # `: ` -- для пробела после двоеточия
|
||||
},
|
||||
}
|
||||
|
||||
# 5. Набор пробелов (неразрывные) которые ОТМЕНЯЮТ висячую пунктуацию у прилегающего символа. Т.к. это неразрывный
|
||||
# пробел, то символ не может "висеть" в принципе, он "прилеплен" к соседу и не может от него отрываться
|
||||
HANGING_CANCELLATION_SP = frozenset([
|
||||
CHAR_NBSP, # неразрывный пробел ( )
|
||||
CHAR_ZWNJ, # нулевой неразрывный пробел (zero width non-joiner, ‌)
|
||||
CHAR_THIN_NBSP, # узкий неразрывной пробел (narrow no-break space)
|
||||
])
|
||||
|
||||
HANGING_PUNCTUATION_SPACE_CLASSES_FLAT = {
|
||||
**HANGING_PUNCTUATION_SPACE_CLASSES['left'],
|
||||
**HANGING_PUNCTUATION_SPACE_CLASSES['right'],
|
||||
}
|
||||
|
||||
HANGING_PUNCTUATION_CLASSES = {
|
||||
**HANGING_PUNCTUATION_SYMBOLS_CLASSES,
|
||||
**HANGING_PUNCTUATION_SPACE_CLASSES_FLAT,
|
||||
}
|
||||
@@ -6,7 +6,9 @@ from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
from .config import (
|
||||
HANGING_PUNCTUATION_LEFT_CHARS,
|
||||
HANGING_PUNCTUATION_RIGHT_CHARS,
|
||||
HANGING_PUNCTUATION_CLASSES
|
||||
HANGING_PUNCTUATION_SYMBOLS_CLASSES,
|
||||
HANGING_PUNCTUATION_MODE_LEFT,
|
||||
HANGING_PUNCTUATION_MODE_RIGHT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,34 +23,31 @@ class HangingPunctuationProcessor:
|
||||
"""
|
||||
:param mode: Режим работы:
|
||||
- None / False: отключено.
|
||||
- 'left': только левая пунктуация.
|
||||
- 'right': только правая пунктуация.
|
||||
- 'both' / True: и левая, и правая.
|
||||
- 'left': левая висячая пунктуация.
|
||||
- 'right': правая висячая пунктуация.
|
||||
- list[str]: список тегов (например, ['p', 'blockquote']),
|
||||
внутри которых применять 'both'.
|
||||
внутри которых применять висячую пунктуацию в обе стороны.
|
||||
- True эквивалентно 'left'.
|
||||
"""
|
||||
self.mode = mode
|
||||
self.target_tags = None
|
||||
self.active_chars = set()
|
||||
|
||||
# Определяем, какие символы будем обрабатывать
|
||||
if isinstance(mode, list):
|
||||
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_RIGHT_CHARS)
|
||||
elif mode == 'left':
|
||||
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
|
||||
elif mode == 'right':
|
||||
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
|
||||
elif mode == 'both' or mode is True:
|
||||
else:
|
||||
normalized_mode = HANGING_PUNCTUATION_MODE_LEFT if mode is True else mode
|
||||
if normalized_mode == HANGING_PUNCTUATION_MODE_LEFT:
|
||||
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
|
||||
elif normalized_mode == HANGING_PUNCTUATION_MODE_RIGHT:
|
||||
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
|
||||
|
||||
# Предварительно фильтруем карту классов, оставляя только активные символы
|
||||
self.char_to_class = {
|
||||
char: cls
|
||||
for char, cls in HANGING_PUNCTUATION_CLASSES.items()
|
||||
for char, cls in HANGING_PUNCTUATION_SYMBOLS_CLASSES.items()
|
||||
if char in self.active_chars
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import regex
|
||||
import logging
|
||||
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,
|
||||
ABBR_COMMON_FINAL, ABBR_COMMON_PREPOSITION)
|
||||
ABBR_COMMON_FINAL, ABBR_COMMON_PREPOSITION, CHAR_NODE_SEPARATOR)
|
||||
|
||||
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.process_initials_and_acronyms = process_initials_and_acronyms
|
||||
self.process_units = process_units
|
||||
# Экранируем разделитель для использования в regex
|
||||
sep = regex.escape(CHAR_NODE_SEPARATOR)
|
||||
|
||||
# 1. Паттерн для длинного (—) или среднего (–) тире, окруженного пробелами.
|
||||
# (?<=[\p{L}\p{Po}\p{Pf}"\']) - просмотр назад на букву, пунктуацию или кавычку.
|
||||
self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\'])\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
|
||||
# (?<=[\p{L}\p{Po}\p{Pf}"\']|sep) - просмотр назад на букву, пунктуацию, кавычку ИЛИ разделитель узлов.
|
||||
self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\']|{sep})\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
|
||||
|
||||
# 2. Паттерн для многоточия, за которым следует пробел и слово.
|
||||
# Ставит неразрывный пробел после многоточия, чтобы не отрывать его от следующего слова.
|
||||
# (?=[\p{L}\p{N}]) - просмотр вперед на букву или цифру.
|
||||
self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}])')
|
||||
# (?=[\p{L}\p{N}]|sep) - просмотр вперед на букву или цифру ИЛИ разделитель узлов.
|
||||
self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}]|{sep})')
|
||||
|
||||
# 3. Паттерн для отрицательных чисел.
|
||||
# Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел
|
||||
@@ -95,7 +98,7 @@ class LayoutProcessor:
|
||||
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*)('
|
||||
+ units_pattern_part_clean + r')(?!\w)')
|
||||
|
||||
@@ -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)')
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
import logging
|
||||
from bs4 import BeautifulSoup
|
||||
from .config import (SANITIZE_ALL_HTML, SANITIZE_ETPGRF, SANITIZE_NONE,
|
||||
HANGING_PUNCTUATION_CLASSES, PROTECTED_HTML_TAGS)
|
||||
PROTECTED_HTML_TAGS,
|
||||
HANGING_PUNCTUATION_SYMBOLS_CLASSES,
|
||||
HANGING_PUNCTUATION_SPACE_CLASSES_FLAT,
|
||||
CHARS_SYMBOLS_TO_BAN)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,8 +30,10 @@ class SanitizerProcessor:
|
||||
|
||||
# Оптимизация: заранее готовим CSS-селектор для поиска висячей пунктуации
|
||||
if self.mode == SANITIZE_ETPGRF:
|
||||
# Собираем уникальные классы
|
||||
unique_classes = sorted(list(frozenset(HANGING_PUNCTUATION_CLASSES.values())))
|
||||
# Собираем уникальные классы из отдельных коллекций (чтобы избежать пустого селектора)
|
||||
symbol_classes = set(HANGING_PUNCTUATION_SYMBOLS_CLASSES.values())
|
||||
space_classes = set(HANGING_PUNCTUATION_SPACE_CLASSES_FLAT.values())
|
||||
unique_classes = sorted(symbol_classes | space_classes)
|
||||
# Формируем селектор вида: span.class1, span.class2, ...
|
||||
# Это позволяет использовать нативный парсер (lxml) для поиска, что намного быстрее python-лямбд.
|
||||
self._etp_selector = ", ".join(f"span.{cls}" for cls in unique_classes)
|
||||
@@ -46,6 +51,7 @@ class SanitizerProcessor:
|
||||
"""
|
||||
if self.mode == SANITIZE_ETPGRF:
|
||||
if not self._etp_selector:
|
||||
self._strip_banned_chars_from_soup(soup)
|
||||
return soup
|
||||
|
||||
# Используем CSS-селектор для быстрого поиска всех нужных элементов
|
||||
@@ -56,6 +62,7 @@ class SanitizerProcessor:
|
||||
for span in spans_to_clean:
|
||||
span.unwrap()
|
||||
|
||||
self._strip_banned_chars_from_soup(soup)
|
||||
return soup
|
||||
|
||||
elif self.mode == SANITIZE_ALL_HTML:
|
||||
@@ -70,7 +77,31 @@ class SanitizerProcessor:
|
||||
|
||||
# 2. Извлекаем чистый текст из оставшегося дерева.
|
||||
# get_text() работает на уровне C (в lxml) и намного быстрее ручного обхода.
|
||||
return soup.get_text()
|
||||
text = soup.get_text()
|
||||
return self._strip_banned_chars_from_string(text)
|
||||
|
||||
# Если режим не задан, ничего не делаем
|
||||
return soup
|
||||
|
||||
def _strip_banned_chars_from_soup(self, soup: BeautifulSoup) -> None:
|
||||
"""
|
||||
Удаляет запрещенные символы из всего содержимого soup-объекта.
|
||||
|
||||
:param soup: Объект BeautifulSoup для обработки.
|
||||
"""
|
||||
for element in soup.find_all(string=True):
|
||||
if isinstance(element, str):
|
||||
new_string = self._strip_banned_chars_from_string(element)
|
||||
element.replace_with(new_string)
|
||||
|
||||
def _strip_banned_chars_from_string(self, text: str) -> str:
|
||||
"""
|
||||
Удаляет запрещенные символы из строки.
|
||||
|
||||
:param text: Исходная строка.
|
||||
:return: Строка без запрещенных символов.
|
||||
"""
|
||||
# Удаляем все символы, которые есть в CHARS_SYMBOLS_TO_BAN
|
||||
for char in CHARS_SYMBOLS_TO_BAN:
|
||||
text = text.replace(char, "")
|
||||
return 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, CHAR_AMP_PLACEHOLDER
|
||||
|
||||
|
||||
# --- Настройки логирования ---
|
||||
@@ -115,45 +116,60 @@ class Typographer:
|
||||
f"process_html: {self.process_html}")
|
||||
|
||||
|
||||
def _process_text_node(self, text: str) -> str:
|
||||
def _hide_protected_tags(self, soup) -> list:
|
||||
"""
|
||||
Внутренний конвейер, который работает с чистым текстом.
|
||||
Находит все защищенные теги, заменяет их на плейсхолдеры и возвращает список сохраненных тегов.
|
||||
"""
|
||||
# Шаг 1: Декодируем весь входящий текст в канонический Unicode
|
||||
# (здесь можно использовать html.unescape, но наш кодек тоже подойдет)
|
||||
processed_text = decode_to_unicode(text)
|
||||
# processed_text = text # ВРЕМЕННО: используем текст как есть
|
||||
protected_tags = []
|
||||
if not PROTECTED_HTML_TAGS:
|
||||
return protected_tags
|
||||
|
||||
# Шаг 2: Применяем правила к чистому Unicode-тексту (только правила на уровне ноды)
|
||||
if self.symbols is not None:
|
||||
processed_text = self.symbols.process(processed_text)
|
||||
if self.layout is not None:
|
||||
processed_text = self.layout.process(processed_text)
|
||||
if self.hyphenation is not None:
|
||||
processed_text = self.hyphenation.hyp_in_text(processed_text)
|
||||
# ... вызовы других активных модулей правил ...
|
||||
selector = ", ".join(PROTECTED_HTML_TAGS)
|
||||
tags_to_replace = soup.select(selector)
|
||||
|
||||
# Финальный шаг: кодируем результат в соответствии с выбранным режимом
|
||||
return encode_from_unicode(processed_text, self.mode)
|
||||
for tag in tags_to_replace:
|
||||
protected_tags.append(tag)
|
||||
tag.replace_with(NavigableString(CHAR_PLACEHOLDER))
|
||||
|
||||
def _walk_tree(self, node):
|
||||
return protected_tags
|
||||
|
||||
def _restore_protected_tags(self, soup, protected_tags: list):
|
||||
"""
|
||||
Рекурсивно обходит DOM-дерево, находя и обрабатывая все текстовые узлы.
|
||||
Восстанавливает защищенные теги на места плейсхолдеров.
|
||||
"""
|
||||
# Список "детей" узла, который мы будем изменять.
|
||||
# Копируем в список, так как будем изменять его во время итерации.
|
||||
for child in list(node.children):
|
||||
if isinstance(child, NavigableString):
|
||||
# Если это текстовый узел, обрабатываем его
|
||||
# Пропускаем пустые или состоящие из пробелов узлы
|
||||
if not child.string.strip():
|
||||
continue
|
||||
if not protected_tags:
|
||||
return
|
||||
|
||||
processed_node_text = self._process_text_node(child.string)
|
||||
child.replace_with((processed_node_text))
|
||||
elif child.name not in PROTECTED_HTML_TAGS:
|
||||
# Если это "обычный" html-тег, рекурсивно заходим в него
|
||||
self._walk_tree(child)
|
||||
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
|
||||
|
||||
def process(self, text: str) -> str:
|
||||
"""
|
||||
@@ -162,97 +178,78 @@ class Typographer:
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
# Если включена обработка HTML и BeautifulSoup доступен
|
||||
|
||||
text = text.replace('&', CHAR_AMP_PLACEHOLDER)
|
||||
|
||||
if self.process_html:
|
||||
# --- ЭТАП 1: Токенизация и "умная склейка" ---
|
||||
is_full_document = bool(regex.search(r'^\s*<(?:!DOCTYPE|html|body)', text, regex.IGNORECASE))
|
||||
|
||||
try:
|
||||
soup = BeautifulSoup(text, 'lxml')
|
||||
except Exception:
|
||||
soup = BeautifulSoup(text, 'html.parser')
|
||||
|
||||
# --- ЭТАП 0: Санитизация (Очистка) ---
|
||||
if self.sanitizer:
|
||||
result = self.sanitizer.process(soup)
|
||||
# Если режим SANITIZE_ALL_HTML, то результат - это строка (чистый текст)
|
||||
if isinstance(result, str):
|
||||
# Переключаемся на обработку обычного текста
|
||||
text = result
|
||||
# ВАЖНО: Мы выходим из ветки process_html и идем в ветку else,
|
||||
# но так как мы внутри if, нам нужно явно вызвать логику для текста.
|
||||
# Проще всего рекурсивно вызвать process с выключенным process_html,
|
||||
# но чтобы не менять состояние объекта, просто выполним логику "else" блока здесь.
|
||||
# Или, еще проще: присвоим text = result и пойдем в блок else? Нет, мы уже внутри if.
|
||||
|
||||
# Решение: Выполняем логику обработки простого текста прямо здесь
|
||||
return self._process_plain_text(text)
|
||||
|
||||
# Если результат - soup, продолжаем работу с ним
|
||||
return self._process_plain_text(result).replace(CHAR_AMP_PLACEHOLDER, '&')
|
||||
soup = result
|
||||
|
||||
# 1.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать.
|
||||
# soup.descendants возвращает все дочерние узлы (теги и текст) в порядке их следования.
|
||||
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. Создаем "супер-строку" и "карту длин"
|
||||
protected_tags = self._hide_protected_tags(soup)
|
||||
|
||||
text_nodes = [node for node in soup.descendants if isinstance(node, NavigableString)]
|
||||
|
||||
super_string = ""
|
||||
lengths_map = []
|
||||
for node in text_nodes:
|
||||
super_string += str(node)
|
||||
lengths_map.append(len(str(node)))
|
||||
node_text = node.string or ""
|
||||
super_string += node_text + CHAR_NODE_SEPARATOR
|
||||
|
||||
# --- ЭТАП 2: Контекстная обработка (ПОКА ЧТО ПРОПУСКАЕМ) ---
|
||||
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)
|
||||
processed_super_string = self._process_plain_text(super_string)
|
||||
|
||||
parts = processed_super_string.split(CHAR_NODE_SEPARATOR)
|
||||
|
||||
if len(parts) > len(text_nodes):
|
||||
parts = parts[:len(text_nodes)]
|
||||
|
||||
# --- ЭТАП 3: "Восстановление" ---
|
||||
current_pos = 0
|
||||
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]
|
||||
node.replace_with(new_text_part)
|
||||
|
||||
# --- ЭТАП 4: Локальная обработка (второй проход) ---
|
||||
# Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход,
|
||||
# который применит все остальные правила к каждому текстовому узлу.
|
||||
self._walk_tree(soup)
|
||||
self._restore_protected_tags(soup, protected_tags)
|
||||
|
||||
# --- ЭТАП 4.5: Висячая пунктуация ---
|
||||
# Применяем после всех текстовых преобразований, но перед финальной сборкой
|
||||
if self.hanging:
|
||||
self.hanging.process(soup)
|
||||
|
||||
# --- ЭТАП 5: Финальная сборка ---
|
||||
if is_full_document:
|
||||
processed_html = str(soup)
|
||||
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали
|
||||
# в _process_text_node. Возвращаем их обратно.
|
||||
return processed_html.replace('&', '&')
|
||||
else:
|
||||
return self._process_plain_text(text)
|
||||
if soup.body:
|
||||
processed_html = soup.body.decode_contents()
|
||||
else:
|
||||
processed_html = str(soup)
|
||||
|
||||
processed_html = processed_html.replace('&', '&')
|
||||
return processed_html.replace(CHAR_AMP_PLACEHOLDER, '&')
|
||||
else:
|
||||
processed_text = self._process_plain_text(text)
|
||||
return processed_text.replace(CHAR_AMP_PLACEHOLDER, '&')
|
||||
|
||||
def _process_plain_text(self, text: str) -> str:
|
||||
"""
|
||||
Логика обработки обычного текста (вынесена из process для переиспользования).
|
||||
"""
|
||||
# Шаг 0: Нормализация
|
||||
processed_text = decode_to_unicode(text)
|
||||
# Шаг 1: Применяем все правила последовательно
|
||||
|
||||
if self.symbols:
|
||||
processed_text = self.symbols.process(processed_text)
|
||||
if self.quotes:
|
||||
processed_text = self.quotes.process(processed_text)
|
||||
if self.unbreakables:
|
||||
processed_text = self.unbreakables.process(processed_text)
|
||||
if self.symbols:
|
||||
processed_text = self.symbols.process(processed_text)
|
||||
if self.layout:
|
||||
processed_text = self.layout.process(processed_text)
|
||||
if self.hyphenation:
|
||||
processed_text = self.hyphenation.hyp_in_text(processed_text)
|
||||
# Шаг 2: Финальное кодирование
|
||||
|
||||
return encode_from_unicode(processed_text, self.mode)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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 ."
|
||||
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"
|
||||
"Mirror1 (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf"
|
||||
"Mirror2 (Gitea Selfhosted)" = "https://git.cube2.ru/erjemin/2025-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
|
||||
|
||||
@@ -19,60 +19,34 @@ HANGING_TEST_CASES = [
|
||||
# --- Режим 'left' (только левая пунктуация) ---
|
||||
('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>'),
|
||||
('left', f'<p>(Скобки)</p>',
|
||||
f'<p><span class="etp-lpar">(</span>Скобки)</p>'),
|
||||
# Правая пунктуация игнорируется
|
||||
('left', f'<p>Текст.</p>', f'<p>Текст.</p>'),
|
||||
('left', '<p>(Скобки)</p>', '<p><span class="etp-lpar">(</span>Скобки)</p>'),
|
||||
('left', '<p>Текст.</p>', '<p>Текст.</p>'),
|
||||
|
||||
# --- Режим 'right' (только правая пунктуация) ---
|
||||
('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>'),
|
||||
('right', f'<p>Текст.</p>',
|
||||
f'<p>Текст<span class="etp-r-dot">.</span></p>'),
|
||||
# Левая пунктуация игнорируется
|
||||
('right', f'<p>(Скобки)</p>', f'<p>(Скобки<span class="etp-rpar">)</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>'),
|
||||
('right', '<p>Текст.</p>', '<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', '<p>End.</p>', '<p>End<span class="etp-r-dot">.</span></p>'),
|
||||
|
||||
# --- Режим None / False (отключено) ---
|
||||
(None, 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>',
|
||||
f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'),
|
||||
]
|
||||
|
||||
# --- Отсутствие висячих символов ---
|
||||
('both', '<p>Простой текст без спецсимволов!</p>', '<p>Простой текст без спецсимволов!</p>'),
|
||||
|
||||
# --- Проверка контекста (пробелы) ---
|
||||
# 1. Левая кавычка внутри слова (не должна висеть)
|
||||
('both', f'<p>func{CHAR_RU_QUOT1_OPEN}arg{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||
f'<p>func{CHAR_RU_QUOT1_OPEN}arg<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'), # Правая висит, т.к. конец узла
|
||||
# 2. Правая кавычка внутри слова (не должна висеть)
|
||||
('both', f'<p>1{CHAR_RU_QUOT1_CLOSE}2</p>',
|
||||
f'<p>1{CHAR_RU_QUOT1_CLOSE}2</p>'),
|
||||
# 3. Левая кавычка после пробела (должна висеть)
|
||||
('both', f'<p>func {CHAR_RU_QUOT1_OPEN}arg</p>',
|
||||
# --- Режим list[str] (список тегов с обеими сторонами) ---
|
||||
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>'),
|
||||
(['p'], 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>'),
|
||||
(['p'], 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>',
|
||||
(['p'], 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)
|
||||
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from etpgrf.sanitizer import SanitizerProcessor
|
||||
from etpgrf.config import SANITIZE_NONE, SANITIZE_ETPGRF, SANITIZE_ALL_HTML
|
||||
from etpgrf.config import SANITIZE_NONE, SANITIZE_ETPGRF, SANITIZE_ALL_HTML, CHARS_SYMBOLS_TO_BAN
|
||||
|
||||
|
||||
def test_sanitizer_mode_none():
|
||||
@@ -67,7 +67,8 @@ ETPGRF_SANITIZE_TEST_CASES = [
|
||||
),
|
||||
(
|
||||
"complex_case", "Сложный случай с несколькими разными span'ами",
|
||||
'<h1><span class="etp-laquo">«</span>Title<span class="etp-raquo">»</span></h1>\n<p>And <span class="note">note</span>.</p>',
|
||||
'<h1><span class="etp-laquo">«</span>Title<span class="etp-raquo">»</span></h1>\n'
|
||||
'<p>And <span class="note">note</span>.</p>',
|
||||
'<h1>«Title»</h1>\n<p>And <span class="note">note</span>.</p>'
|
||||
),
|
||||
]
|
||||
@@ -83,3 +84,19 @@ def test_sanitizer_mode_etpgrf(case_id, description, html_input, expected_html):
|
||||
result_soup = processor.process(soup)
|
||||
|
||||
assert str(result_soup) == expected_html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [SANITIZE_ETPGRF, SANITIZE_ALL_HTML])
|
||||
def test_sanitizer_strips_service_placeholders(mode):
|
||||
"""
|
||||
Проверяет, что в обоих режимах удаляются запрещенные символы (плейсхолдеры, используемые внутри типографа).
|
||||
Это важно для защиты от потенциальных XSS-атак или других проблем с безопасностью, связанных с этими символами.
|
||||
"""
|
||||
placeholder = next(iter(CHARS_SYMBOLS_TO_BAN))
|
||||
html_input = f'<p>Start{placeholder}End</p>'
|
||||
soup = BeautifulSoup(html_input, 'html.parser')
|
||||
processor = SanitizerProcessor(mode=mode)
|
||||
result = processor.process(soup)
|
||||
output = str(result) if isinstance(result, BeautifulSoup) else result
|
||||
assert placeholder not in output
|
||||
assert 'StartEnd' in output
|
||||
|
||||
@@ -103,6 +103,12 @@ TYPOGRAPHER_HTML_TEST_CASES = [
|
||||
f'<p>Текст с{CHAR_NBSP}картинкой <img alt="image" src="image.jpg"/> и{CHAR_NBSP}текстом.</p>'),
|
||||
('unicode', '<p>Текст с <code><br></code><br>А это новая строка.</p>',
|
||||
f'<p>Текст с{CHAR_NBSP}<code><br></code><br/>А{CHAR_NBSP}это новая строка.</p>'),
|
||||
|
||||
# --- Тесты на стыке тегов ---
|
||||
('mixed', '<p>Текст <span>с тире</span> --- после закрытого тега.</p>',
|
||||
'<p>Текст <span>с тире</span> — после закрытого тега.</p>'),
|
||||
('mixed', '<p>Целых <b>100</b> т веса.</p>',
|
||||
'<p>Целых <b>100</b> т веса.</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')
|
||||
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>'),
|
||||
|
||||
# 7. Тест на маскированные мнемоники и де-экранирование &
|
||||
('<p>Текст с < и > и & внутри.</p>', '<p>Текст с < и > и & внутри.</p>'),
|
||||
('<p>Текст с &lt; и &gt; и &amp; внутри.</p>', '<p>Текст с &lt; и &gt; и &amp; внутри.</p>'),
|
||||
('<p>Мнемоника <code>&nbsp;</code> превратится в неразрывный пробел</p>', '<p>Мнемоника <code>&nbsp;</code> превратится в неразрывный пробел</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, чтобы проверить, что добавляются, но теги не ломаются
|
||||
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