23 Commits

Author SHA1 Message Date
913f28f2f3 add: санитайзинг плейсхолдеров 2026-03-05 03:31:30 +03:00
125c9560b4 mod: добавлены "компенсирующие" пробелы + немного оптимизации в конфигах. 2026-03-05 03:17:48 +03:00
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
16 changed files with 622 additions and 275 deletions

View File

@@ -5,18 +5,34 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html). и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.2] - 2025-05-02 ## [0.1.5] - 2024-02-18
### Исправлено ### Исправлено
- **Критическое исправление:** Добавлена отсутствующая зависимость `regex` в `pyproject.toml`. Без неё библиотека - Исправлена ошибка, из-за которой `&amp;` в исходном тексте некорректно преобразовывался в `&`. Теперь `&amp;` и его варианты (`&amp;amp;`, `&amp;lt;`) сохраняются в итоговом HTML.
падала при импорте. - Исправлена (частично) расстановка неразрывных пробелов `&nbsp;` на границах закрывающих тегов (например, `<b>Текст</b> -- слово` теперь корректно обрабатывается, в `Текст</b>&nbsp;&endash; слово`).
- Файл `LIBRARY_SPECS.md` для LLM.
## [0.1.1] - 2025-05-02 ## [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`. - Ссылки на зеркала репозитория (GitVerse, Gitea) в `pyproject.toml` и `README.md`.
- Раздел Credits в документации. - Раздел Credits в документации.
## [0.1.0] - 2025-05-01 ## [0.1.0] - 2025-12-23
### Добавлено ### Добавлено
- Первый публичный релиз библиотеки `etpgrf`. - Первый публичный релиз библиотеки `etpgrf`.
- Основные модули: - Основные модули:
@@ -24,8 +40,9 @@
- `Hyphenator`: расстановка мягких переносов (алгоритм Ляна-Кнута). - `Hyphenator`: расстановка мягких переносов (алгоритм Ляна-Кнута).
- `QuotesProcessor`: замена кавычек («ёлочки», „лапки“). - `QuotesProcessor`: замена кавычек («ёлочки», „лапки“).
- `Unbreakables`: неразрывные пробелы для предлогов, союзов и частиц. - `Unbreakables`: неразрывные пробелы для предлогов, союзов и частиц.
- `LayoutProcessor`: типографика тире, инициалов, акронимов, единиц измерения. - `LayoutProcessor`: типографика тире, инициалов, акронимов, единиц измерения, устойчивых сокращений (постпозиционных
- `SymbolsProcessor`: псевдографика. и препозиционных).
- `SymbolsProcessor`: псевдографика (тире, стрелочки, копирайт и т.п.)
- `HangingPunctuationProcessor`: висячая пунктуация. - `HangingPunctuationProcessor`: висячая пунктуация.
- `SanitizerProcessor`: очистка HTML перед обработкой. - `SanitizerProcessor`: очистка HTML перед обработкой.
- Поддержка русского, русского дореформенного и английского языков. - Поддержка русского, русского дореформенного и английского языков.

View File

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

135
README.md
View File

@@ -14,9 +14,14 @@
Исходный код доступен на нескольких площадках: Исходный код доступен на нескольких площадках:
* [Gitea](https://git.cube2.ru/erjemin/2025-etpgrf) (Основной self-hosted) * [Gitea](https://git.cube2.ru/erjemin/2025-etpgrf) (Основной self-hosted)
* [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало & homepage/issues) * [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало)
* [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse) * [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse)
## Демострация / Demo
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
## Установка ## Установка
```bash ```bash
@@ -99,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-мнемоники. К счастью, в стандарте таких мнемоник (превращающихся в два символа) исчезающе мало и они крайне
редко применяются в тексте, поэтому это не должно вызывать проблем. редко применляются в тексте, поэтому это не должно вызывать проблем.
### Переносы слов ### Переносы слов
@@ -289,9 +294,9 @@ result = typo.process("100 км/ч") # Останется без
### Висячая типографика ### Висячая типографика
Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда
тире и маркеры списков) выносятся на левое (и иногда и по правому, при выравнивании текст по правому краю) поле текста. tире и маркеры списков) выносятся на левое (и иногда и по правому) поле текста. Это создаёт идеально ровный край не по
Это создаёт идеально ровный край не по формальным границам знаков, а по оптическому краю — по первым буквам строк. формальным границам знаков, а по оптическому краю — по первым буквам строк. Текст выглядит гораздо аккуратнее и
Текст выглядит гораздо аккуратнее и профессиональнее. профессиональнее.
Интернет публикации (да и бумажные издания) практически игнорируют висячую типографику. Но иногда это отличный Интернет публикации (да и бумажные издания) практически игнорируют висячую типографику. Но иногда это отличный
инструмент для акцентной типографики: крупные заголовки, цитаты, выносы, подписи к иллюстрациям, оформленные с помощью инструмент для акцентной типографики: крупные заголовки, цитаты, выносы, подписи к иллюстрациям, оформленные с помощью
@@ -301,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`):
@@ -313,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

@@ -69,9 +69,43 @@ CHAR_ARROW_LR = '\u27f7' # Длинная двунаправленная ст
CHAR_ARROW_L_LONG_DOUBLE = '\u27f8' # Длинная двойная стрелка влево CHAR_ARROW_L_LONG_DOUBLE = '\u27f8' # Длинная двойная стрелка влево
CHAR_ARROW_R_LONG_DOUBLE = '\u27f9' # Длинная двойная стрелка вправо CHAR_ARROW_R_LONG_DOUBLE = '\u27f9' # Длинная двойная стрелка вправо
CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двунаправленная стрелка CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двунаправленная стрелка
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / &middot; CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / &middot;
# ПРОБЕЛЫ
CHAR_EN_SP = '\u2002' # Полужирный пробел (En Space) -- &ensp;
CHAR_EM_SP = '\u2003' # Широкий пробел (Em Space) -- &emsp;
CHAR_NUM_SP = '\u2007' # Цифровой пробел -- &numsp;
CHAR_PUNT_SP = '\u2008' # Пунктуационный пробел -- &puncsp;
CHAR_HAIR_SP = '\u200A' # Толщина волоса (Hair Space) -- &hairsp;
CHAR_MED_SP = '\u205F' # Средний пробел (Medium Mathematical Space) -- &MediumSpace;
CHAR_NULL_SP = '\u200B' # Нулевой пробел (Zero Width Space)... в стандартах мнемоник путаница, и ее мнемоника &NegativeThinSpace;
CHAR_THIN_NBSP = '\u202F' # Тонкий неразрывной пробел (Narrow No-Break Space) который, к сожалению, не имеет html-мнемоники
CHAR_ZWNJ = '\u200D' # Нулевая ширина (с объединением) (Zero Width Joiner) -- &zwnj;
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) -- &emsp13;
CHAR_FOUR_PER_EM_SP = '\u2005' # Средний пробел, ширина 1/4 от круглой шпации (Four-Per-Em Space) -- &emsp14;
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_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом. # в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
CHAR_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в &amp; при замене на мнемонику.
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-МНЕМНОИКОВ === # === КОНСТАНТЫ ДЛЯ КОДИРОВАНИЯ HTML-МНЕМНОИКОВ ===
# --- ЧЕРНЫЙ СПИСОК: Символы, которые НИКОГДА не нужно кодировать в мнемоники --- # --- ЧЕРНЫЙ СПИСОК: Символы, которые НИКОГДА не нужно кодировать в мнемоники ---
NEVER_ENCODE_CHARS = (frozenset(['!', '#', '%', '(', ')', '*', ',', '.', '/', ':', ';', '=', '?', '@', NEVER_ENCODE_CHARS = (frozenset(['!', '#', '%', CHAR_LPAR, CHAR_RPAR, '*', CHAR_COMMA, CHAR_DOT, '/', CHAR_COLON, ';', '=', '?', '@',
'[', '\\', ']', '^', '_', '`', '{', '|', '}', '~', '\n', '\t', '\r']) CHAR_LSQB, '\\', CHAR_RSQB, '^', '_', '`', CHAR_LCUB, '|', CHAR_RCUB, '~', '\n', '\t', '\r'])
| RU_ALPHABET_FULL | EN_ALPHABET_FULL) | RU_ALPHABET_FULL | EN_ALPHABET_FULL)
# 2. БЕЛЫЙ СПИСОК (ДЛЯ БЕЗОПАСНОСТИ): # 2. БЕЛЫЙ СПИСОК (ДЛЯ БЕЗОПАСНОСТИ):
@@ -116,22 +150,25 @@ SAFE_MODE_CHARS_TO_MNEMONIC = frozenset([
'<', '>', '&', '"', '\'', '<', '>', '&', '"', '\'',
CHAR_SHY, # Мягкий перенос (Soft Hyphen) -- &shy; CHAR_SHY, # Мягкий перенос (Soft Hyphen) -- &shy;
CHAR_NBSP, # Неразрывный пробел (Non-Breaking Space) -- &nbsp; CHAR_NBSP, # Неразрывный пробел (Non-Breaking Space) -- &nbsp;
'\u2002', # Полужирный пробел (En Space) -- &ensp; CHAR_EN_SP, # Полужирный пробел (En Space) -- &ensp;
'\u2003', # Широкий пробел (Em Space) -- &emsp; CHAR_EM_SP, # Широкий пробел (Em Space) -- &emsp;
'\u2007', # Цифровой пробел -- &numsp; CHAR_NUM_SP, # Цифровой пробел -- &numsp;
'\u2008', # Пунктуационный пробел -- &puncsp; CHAR_PUNT_SP, # Пунктуационный пробел -- &puncsp;
CHAR_THIN_SP, # Межсимвольный пробел, тонкий пробел, шпация -- &thinsp;' CHAR_THIN_SP, # Межсимвольный пробел, тонкий пробел, шпация -- &thinsp;'
'\u200A', # Толщина волоса (Hair Space) -- &hairsp; CHAR_HAIR_SP, # Толщина волоса (Hair Space) -- &hairsp;
'\u200B', # Негативный пробел (Negative Space) -- &NegativeThinSpace; CHAR_NULL_SP, # Нулевой пробел (Zero Width Space)... в стандартах мнемоник путаница, и ее мнемоника &NegativeThinSpace;
'\u200C', # Нулевая ширина (без объединения) (Zero Width Non-Joiner) -- &zwj; '\u200C', # Нулевая ширина (без объединения) (Zero Width Non-Joiner) -- &zwj;
'\u200D', # Нулевая ширина (с объединением) (Zero Width Joiner) -- &zwnj; CHAR_ZWNJ, # Нулевая ширина (с объединением) (Zero Width Joiner) -- &zwnj;
CHAR_THREE_PER_EM_SP, # «Толстый» пробел, ширина 1/3 от круглой шпации (Three-Per-Em Space) -- &emsp13;
CHAR_FOUR_PER_EM_SP, # Средний пробел, ширина 1/4 от круглой шпации (Four-Per-Em Space) -- &emsp14;
'\u200E', # Изменить направление текста на слева-направо (Left-to-Right Mark /LRE) -- &lrm; '\u200E', # Изменить направление текста на слева-направо (Left-to-Right Mark /LRE) -- &lrm;
'\u200F', # Изменить направление текста направо-налево (Right-to-Left Mark /RLM) -- &rlm; '\u200F', # Изменить направление текста направо-налево (Right-to-Left Mark /RLM) -- &rlm;
'\u2010', # Дефис (Hyphen) -- &dash; '\u2010', # Дефис (Hyphen) -- &dash;
'\u205F', # Средний пробел (Medium Mathematical Space) -- &MediumSpace; CHAR_MED_SP, # Средний пробел (Medium Mathematical Space) -- &MediumSpace;
'\u2060', # &NoBreak; '\u2060', # &NoBreak;
'\u2062', # &InvisibleTimes; -- для семантической разметки математических выражений '\u2062', # &InvisibleTimes; -- для семантической разметки математических выражений
'\u2063', # &InvisibleComma; -- для семантической разметки математических выражений '\u2063', # &InvisibleComma; -- для семантической разметки математических выражений
]) ])
# 3. СПИСОК ДЛЯ ЧИСЛОВОГО КОДИРОВАНИЯ: Символы без стандартного имени. # 3. СПИСОК ДЛЯ ЧИСЛОВОГО КОДИРОВАНИЯ: Символы без стандартного имени.
@@ -146,6 +183,10 @@ ALWAYS_ENCODE_TO_NUMERIC_CHARS = frozenset([
'\u20BD', # Знак русского рубля (₽) '\u20BD', # Знак русского рубля (₽)
'\u20BE', # Знак грузинский лари (₾) '\u20BE', # Знак грузинский лари (₾)
'\u20BF', # Знак биткоина (₿) '\u20BF', # Знак биткоина (₿)
CHAR_THIN_NBSP, # Тонкий неразрывный пробел (Narrow No-Break Space) -- как &thinsp; но с поведением &nbsp; (к сожалению, не имеет 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. СЛОВАРЬ ПРИОРИТЕТОВ: Кастомные и/или предпочитаемые мнемоники. # 4. СЛОВАРЬ ПРИОРИТЕТОВ: Кастомные и/или предпочитаемые мнемоники.
@@ -602,7 +643,7 @@ def _build_translation_maps() -> dict[str, str]:
# На его основе строим нашу карту для кодирования. # На его основе строим нашу карту для кодирования.
encode_map = {} encode_map = {}
# ШАГ 2: Высший приоритет. Загружаем наши кастомные правила. # ШАГ 2: Высший приоритет. Загружаем кастомные правила.
encode_map.update(CUSTOM_ENCODE_MAP) encode_map.update(CUSTOM_ENCODE_MAP)
# ШАГ 3: Следующий приоритет. Добавляем числовое кодирование. # ШАГ 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, # неразрывный пробел (&nbsp;)
CHAR_SHY, # мягкий перенос (&shy;)
CHAR_THIN_SP, # тонкий пробел (&thinsp;)
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)... в мнемонике &NegativeThinSpace;
'\t', # табуляция
'\n', # перевод строки
'\r', # возврат каретки
'\u000b', # вертикальная табуляция
'\f' # перевод страницы
])
# 1. Набор символов, которые могут "висеть" слева # 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([ HANGING_PUNCTUATION_LEFT_CHARS = frozenset([
CHAR_RU_QUOT1_OPEN, # « CHAR_RU_QUOT1_OPEN, # «
CHAR_EN_QUOT1_OPEN, # “ CHAR_EN_QUOT1_OPEN, # “
'(', '[', '{', CHAR_LPAR, # (
CHAR_LSQB, # [
CHAR_LCUB, # {
]) ])
# 2. Набор символов, которые могут "висеть" справа # 2. Набор символов, которые могут "висеть" справа
HANGING_PUNCTUATION_RIGHT_CHARS = frozenset([ HANGING_PUNCTUATION_RIGHT_CHARS = frozenset([
CHAR_RU_QUOT1_CLOSE, # » CHAR_RU_QUOT1_CLOSE, # »
CHAR_EN_QUOT1_CLOSE, # ” CHAR_EN_QUOT1_CLOSE, # ”
')', ']', '}', CHAR_RPAR, # )
'.', ',', ':', CHAR_RSQB, # ]
CHAR_RCUB, # }
CHAR_DOT, # .
CHAR_COMMA, # ,
CHAR_COLON, # :
]) ])
# 3. Словарь, сопоставляющий символ с его CSS-классом # 3. Словарь, сопоставляющий символ с его CSS-классом
HANGING_PUNCTUATION_CLASSES = { HANGING_PUNCTUATION_SYMBOLS_CLASSES = {
# Левая пунктуация: все классы начинаются с 'etp-l' # Левая пунктуация: все классы начинаются с 'etp-l'
CHAR_RU_QUOT1_OPEN: 'etp-laquo', CHAR_RU_QUOT1_OPEN: 'etp-laquo', # ` «` -- левая открывающая кавычка-ёлочка
CHAR_EN_QUOT1_OPEN: 'etp-ldquo', CHAR_EN_QUOT1_OPEN: 'etp-ldquo', # ` “` -- левая открывающая кавычка-лапка
'(': 'etp-lpar', CHAR_LPAR: 'etp-lpar', # ` (` -- левая открывающая скобка
'[': 'etp-lsqb', CHAR_LSQB: 'etp-lsqb', # ` [` -- левая открывающая квадратная скобка
'{': 'etp-lcub', CHAR_LCUB: 'etp-lcub', # ` {` -- левая открывающая фигурная скобка
# Правая пунктуация: все классы начинаются с 'etp-r' # Правая пунктуация: все классы начинаются с 'etp-r'
CHAR_RU_QUOT1_CLOSE: 'etp-raquo', CHAR_RU_QUOT1_CLOSE: 'etp-raquo', # `» ` -- правая закрывающая кавычка-ёлочка
CHAR_EN_QUOT1_CLOSE: 'etp-rdquo', CHAR_EN_QUOT1_CLOSE: 'etp-rdquo', # `” ` -- правая закрывающая кавычка-лапка
')': 'etp-rpar', CHAR_RPAR: 'etp-rpar', # `) ` -- правая закрывающая скобка
']': 'etp-rsqb', CHAR_RSQB: 'etp-rsqb', # `] ` -- правая закрывающая квадратная скобка
'}': 'etp-rcub', CHAR_RCUB: 'etp-rcub', # `} ` -- правая закрывающая фигурная скобка
'.': 'etp-r-dot', CHAR_DOT: 'etp-r-dot', # `. ` -- точка (обычно в конце предложения и висит справа)
',': 'etp-r-comma', CHAR_COMMA: 'etp-r-comma', # `, ` -- запятая (обычно висит справа)
':': 'etp-r-colon', 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, # неразрывный пробел (&nbsp;)
CHAR_ZWNJ, # нулевой неразрывный пробел (zero width non-joiner, &zwnj;)
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,
} }

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_SYMBOLS_CLASSES,
HANGING_PUNCTUATION_MODE_LEFT,
HANGING_PUNCTUATION_MODE_RIGHT,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,34 +23,31 @@ 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_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:
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)
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 = { self.char_to_class = {
char: cls 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 if char in self.active_chars
} }

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)')

View File

@@ -4,7 +4,10 @@
import logging import logging
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from .config import (SANITIZE_ALL_HTML, SANITIZE_ETPGRF, SANITIZE_NONE, 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__) logger = logging.getLogger(__name__)
@@ -27,8 +30,10 @@ class SanitizerProcessor:
# Оптимизация: заранее готовим CSS-селектор для поиска висячей пунктуации # Оптимизация: заранее готовим CSS-селектор для поиска висячей пунктуации
if self.mode == SANITIZE_ETPGRF: 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, ... # Формируем селектор вида: span.class1, span.class2, ...
# Это позволяет использовать нативный парсер (lxml) для поиска, что намного быстрее python-лямбд. # Это позволяет использовать нативный парсер (lxml) для поиска, что намного быстрее python-лямбд.
self._etp_selector = ", ".join(f"span.{cls}" for cls in unique_classes) self._etp_selector = ", ".join(f"span.{cls}" for cls in unique_classes)
@@ -46,6 +51,7 @@ class SanitizerProcessor:
""" """
if self.mode == SANITIZE_ETPGRF: if self.mode == SANITIZE_ETPGRF:
if not self._etp_selector: if not self._etp_selector:
self._strip_banned_chars_from_soup(soup)
return soup return soup
# Используем CSS-селектор для быстрого поиска всех нужных элементов # Используем CSS-селектор для быстрого поиска всех нужных элементов
@@ -56,6 +62,7 @@ class SanitizerProcessor:
for span in spans_to_clean: for span in spans_to_clean:
span.unwrap() span.unwrap()
self._strip_banned_chars_from_soup(soup)
return soup return soup
elif self.mode == SANITIZE_ALL_HTML: elif self.mode == SANITIZE_ALL_HTML:
@@ -70,7 +77,31 @@ class SanitizerProcessor:
# 2. Извлекаем чистый текст из оставшегося дерева. # 2. Извлекаем чистый текст из оставшегося дерева.
# get_text() работает на уровне C (в lxml) и намного быстрее ручного обхода. # get_text() работает на уровне C (в lxml) и намного быстрее ручного обхода.
return soup.get_text() text = soup.get_text()
return self._strip_banned_chars_from_string(text)
# Если режим не задан, ничего не делаем # Если режим не задан, ничего не делаем
return soup 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

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:
processed_text = self.layout.process(processed_text)
if self.hyphenation is not None:
processed_text = self.hyphenation.hyp_in_text(processed_text)
# ... вызовы других активных модулей правил ...
# Финальный шаг: кодируем результат в соответствии с выбранным режимом for tag in tags_to_replace:
return encode_from_unicode(processed_text, self.mode) 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-дерево, находя и обрабатывая все текстовые узлы. Восстанавливает защищенные теги на места плейсхолдеров.
""" """
# Список "детей" узла, который мы будем изменять. 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). parts = processed_super_string.split(CHAR_NODE_SEPARATOR)
# Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится.
if self.quotes: if len(parts) > len(text_nodes):
processed_super_string = self.quotes.process(processed_super_string) parts = parts[:len(text_nodes)]
if self.unbreakables:
processed_super_string = self.unbreakables.process(processed_super_string)
# --- ЭТАП 3: "Восстановление" ---
current_pos = 0
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.2" 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"
@@ -33,8 +33,8 @@ dependencies = [
[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"
"Mirror (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf" "Mirror1 (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf"
"Selfhosted (Gitea)" = "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

@@ -4,7 +4,7 @@
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from etpgrf.sanitizer import SanitizerProcessor 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(): def test_sanitizer_mode_none():
@@ -67,7 +67,8 @@ ETPGRF_SANITIZE_TEST_CASES = [
), ),
( (
"complex_case", "Сложный случай с несколькими разными span'ами", "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>' '<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) result_soup = processor.process(soup)
assert str(result_soup) == expected_html 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

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