Compare commits
9 Commits
v0.1.4
...
9695fe80aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 9695fe80aa | |||
| 4b62564c6b | |||
| e8ef4c1bca | |||
| bb1fcc71e0 | |||
| d917634787 | |||
| 67c17ff536 | |||
| 6b098f161d | |||
| ca3f93a485 | |||
| ace8b61ae3 |
@@ -5,7 +5,12 @@
|
|||||||
Формат основан на [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.4] - 2025-02-03
|
## [0.1.5] - 2024-02-18
|
||||||
|
### Исправлено
|
||||||
|
- Исправлена ошибка, из-за которой `&` в исходном тексте некорректно преобразовывался в `&`. Теперь `&` и его варианты (`&`, `<`) сохраняются в итоговом HTML.
|
||||||
|
- Исправлена (частично) расстановка неразрывных пробелов ` ` на границах закрывающих тегов (например, `<b>Текст</b> -- слово` теперь корректно обрабатывается, в `Текст</b> &endash; слово`).
|
||||||
|
|
||||||
|
## [0.1.4] - 2024-02-13
|
||||||
### Изменено
|
### Изменено
|
||||||
- **Архитектурное улучшение:** Полностью переработан механизм обработки HTML.
|
- **Архитектурное улучшение:** Полностью переработан механизм обработки HTML.
|
||||||
- Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).
|
- Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).
|
||||||
|
|||||||
117
README.md
117
README.md
@@ -5,7 +5,7 @@
|
|||||||
[](https://pypi.org/project/etpgrf/)
|
[](https://pypi.org/project/etpgrf/)
|
||||||
|
|
||||||
|
|
||||||
# Типограф для веба
|
# Типограф для веба
|
||||||
|
|
||||||
Экранная типографика для веба — способствует повышению читабельности текста в интернете,
|
Экранная типографика для веба — способствует повышению читабельности текста в интернете,
|
||||||
приближая его к печатной типографике.
|
приближая его к печатной типографике.
|
||||||
@@ -14,12 +14,12 @@
|
|||||||
|
|
||||||
Исходный код доступен на нескольких площадках:
|
Исходный код доступен на нескольких площадках:
|
||||||
* [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
|
## Демострация / Demo
|
||||||
|
|
||||||
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
|
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
|
||||||
вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
|
вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
@@ -104,7 +104,7 @@ result = typo_mixed_mode.process(text="Этот текст будет обраб
|
|||||||
двумя символами `\u228a\ufe00` и превратится в `⊊\ufe00`. Символ `\ufe00` — это невидимый символ, cелектор
|
двумя символами `\u228a\ufe00` и превратится в `⊊\ufe00`. Символ `\ufe00` — это невидимый символ, cелектор
|
||||||
варианта начертания (Variant Selector), который изменяет начертание предыдущего символа и для него нет
|
варианта начертания (Variant Selector), который изменяет начертание предыдущего символа и для него нет
|
||||||
html-мнемоники. К счастью, в стандарте таких мнемоник (превращающихся в два символа) исчезающе мало и они крайне
|
html-мнемоники. К счастью, в стандарте таких мнемоник (превращающихся в два символа) исчезающе мало и они крайне
|
||||||
редко применяются в тексте, поэтому это не должно вызывать проблем.
|
редко применляются в тексте, поэтому это не должно вызывать проблем.
|
||||||
|
|
||||||
|
|
||||||
### Переносы слов
|
### Переносы слов
|
||||||
@@ -294,9 +294,9 @@ result = typo.process("100 км/ч") # Останется без
|
|||||||
### Висячая типографика
|
### Висячая типографика
|
||||||
|
|
||||||
Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда
|
Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда
|
||||||
тире и маркеры списков) выносятся на левое (и иногда и по правому, при выравнивании текст по правому краю) поле текста.
|
tире и маркеры списков) выносятся на левое (и иногда и по правому) поле текста. Это создаёт идеально ровный край не по
|
||||||
Это создаёт идеально ровный край не по формальным границам знаков, а по оптическому краю — по первым буквам строк.
|
формальным границам знаков, а по оптическому краю — по первым буквам строк. Текст выглядит гораздо аккуратнее и
|
||||||
Текст выглядит гораздо аккуратнее и профессиональнее.
|
профессиональнее.
|
||||||
|
|
||||||
Интернет публикации (да и бумажные издания) практически игнорируют висячую типографику. Но иногда это отличный
|
Интернет публикации (да и бумажные издания) практически игнорируют висячую типографику. Но иногда это отличный
|
||||||
инструмент для акцентной типографики: крупные заголовки, цитаты, выносы, подписи к иллюстрациям, оформленные с помощью
|
инструмент для акцентной типографики: крупные заголовки, цитаты, выносы, подписи к иллюстрациям, оформленные с помощью
|
||||||
@@ -306,9 +306,10 @@ Safari), поэтому на него полагаться нельзя. Поэ
|
|||||||
через оборачивание висячих символов в специальные HTML-теги с CSS-классами.
|
через оборачивание висячих символов в специальные HTML-теги с CSS-классами.
|
||||||
|
|
||||||
Оборачивая "висячий" символ или слово в `<span>` и применяя к нему, например, отрицательный `text-indent` или
|
Оборачивая "висячий" символ или слово в `<span>` и применяя к нему, например, отрицательный `text-indent` или
|
||||||
`margin-left` (`<span style="margin-left:-0.44em;">«</span>`). Важный нюанс: степень "вывешивания" у символов
|
`margin-left` (`<span style="margin-left:-0.44em">«</span>`), мы можем сместить сам символ, но нужно ещё и
|
||||||
разная. И не только потому, что кавычка, скобка или точка имеют разную ширину, но еще и потому, что кавычка, например,
|
сохранить расстояние до соседнего слова. Поэтому типограф оборачивает не только сам висячий символ, но и ближайшее слово
|
||||||
может "висеть" на 100% своей ширины, а точка или запятая — только на 50-70%, чтобы не отрываться от слова совсем.
|
(до пробела или границы узла), а также, при необходимости, окружающий пробел. Сама визуальная компенсация оформляется через
|
||||||
|
отрицательные `margin`/`padding` в CSS-классах — никаких `position:absolute`, чтобы не нарушать поток текста.
|
||||||
|
|
||||||
По умолчанию эта функция висячей типографики **отключена**. Чтобы её включить, нужно задать параметр
|
По умолчанию эта функция висячей типографики **отключена**. Чтобы её включить, нужно задать параметр
|
||||||
`hanging_punctuation` при конфигурировании типографа (по умолчанию `hanging_punctuation=None`):
|
`hanging_punctuation` при конфигурировании типографа (по умолчанию `hanging_punctuation=None`):
|
||||||
@@ -318,40 +319,82 @@ 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"> </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.
|
||||||
|
|
||||||
@@ -363,4 +406,4 @@ typo = etpgrf.Typographer(hanging_punctuation=['blockquote', 'h2', 'h3'])
|
|||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
**Разработка:**
|
**Разработка:**
|
||||||
Проект разработан Sergei Erjemin при активном участии Gemini Assistant (LLM) в роли pair-programmer.
|
Проект разработан Sergei Erjemin при активном участии различных LLM в роли pair-programmer.
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ etpgrf - библиотека для экранной типографики т
|
|||||||
- Висячая пунктуация
|
- Висячая пунктуация
|
||||||
- Очистка и обработка HTML
|
- Очистка и обработка HTML
|
||||||
"""
|
"""
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.1.5"
|
||||||
__author__ = "Sergei Erjemin"
|
__author__ = "Sergei Erjemin"
|
||||||
__email__ = "erjemin@gmail.com"
|
__email__ = "erjemin@gmail.com"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
||||||
__copyright__ = "Copyright 2025 Sergei Erjemin"
|
__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
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда испо
|
|||||||
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
|
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
|
||||||
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
|
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
|
||||||
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
|
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
|
||||||
|
CHAR_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в & при замене на мнемонику.
|
||||||
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
|
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
|
||||||
|
|
||||||
|
|
||||||
@@ -689,6 +690,13 @@ PROTECTED_HTML_TAGS = ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math']
|
|||||||
|
|
||||||
# === КОНСТАНТЫ ДЛЯ ВИСЯЧЕЙ ТИПОГРАФИКИ ===
|
# === КОНСТАНТЫ ДЛЯ ВИСЯЧЕЙ ТИПОГРАФИКИ ===
|
||||||
|
|
||||||
|
HANGING_PUNCTUATION_MODE_LEFT = 'left'
|
||||||
|
HANGING_PUNCTUATION_MODE_RIGHT = 'right'
|
||||||
|
HANGING_PUNCTUATION_MODES = frozenset([
|
||||||
|
HANGING_PUNCTUATION_MODE_LEFT,
|
||||||
|
HANGING_PUNCTUATION_MODE_RIGHT,
|
||||||
|
])
|
||||||
|
|
||||||
# 1. Набор символов, которые могут "висеть" слева
|
# 1. Набор символов, которые могут "висеть" слева
|
||||||
HANGING_PUNCTUATION_LEFT_CHARS = frozenset([
|
HANGING_PUNCTUATION_LEFT_CHARS = frozenset([
|
||||||
CHAR_RU_QUOT1_OPEN, # «
|
CHAR_RU_QUOT1_OPEN, # «
|
||||||
@@ -721,4 +729,5 @@ HANGING_PUNCTUATION_CLASSES = {
|
|||||||
'.': 'etp-r-dot',
|
'.': 'etp-r-dot',
|
||||||
',': 'etp-r-comma',
|
',': 'etp-r-comma',
|
||||||
':': 'etp-r-colon',
|
':': 'etp-r-colon',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from bs4 import BeautifulSoup, NavigableString, Tag
|
|||||||
from .config import (
|
from .config import (
|
||||||
HANGING_PUNCTUATION_LEFT_CHARS,
|
HANGING_PUNCTUATION_LEFT_CHARS,
|
||||||
HANGING_PUNCTUATION_RIGHT_CHARS,
|
HANGING_PUNCTUATION_RIGHT_CHARS,
|
||||||
HANGING_PUNCTUATION_CLASSES
|
HANGING_PUNCTUATION_CLASSES,
|
||||||
|
HANGING_PUNCTUATION_MODE_LEFT,
|
||||||
|
HANGING_PUNCTUATION_MODE_RIGHT,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,30 +23,27 @@ class HangingPunctuationProcessor:
|
|||||||
"""
|
"""
|
||||||
:param mode: Режим работы:
|
:param mode: Режим работы:
|
||||||
- None / False: отключено.
|
- None / False: отключено.
|
||||||
- 'left': только левая пунктуация.
|
- 'left': левая висячая пунктуация.
|
||||||
- 'right': только правая пунктуация.
|
- 'right': правая висячая пунктуация.
|
||||||
- 'both' / True: и левая, и правая.
|
|
||||||
- list[str]: список тегов (например, ['p', 'blockquote']),
|
- list[str]: список тегов (например, ['p', 'blockquote']),
|
||||||
внутри которых применять 'both'.
|
внутри которых применять висячую пунктуацию в обе стороны.
|
||||||
|
- True эквивалентно 'left'.
|
||||||
"""
|
"""
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.target_tags = None
|
self.target_tags = None
|
||||||
self.active_chars = set()
|
self.active_chars = set()
|
||||||
|
|
||||||
# Определяем, какие символы будем обрабатывать
|
|
||||||
if isinstance(mode, list):
|
if isinstance(mode, list):
|
||||||
self.target_tags = set(t.lower() for t in mode)
|
self.target_tags = set(t.lower() for t in mode)
|
||||||
# Если передан список тегов, включаем полный режим ('both') внутри них
|
|
||||||
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
|
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
|
||||||
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
|
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
|
||||||
elif mode == 'left':
|
else:
|
||||||
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
|
normalized_mode = HANGING_PUNCTUATION_MODE_LEFT if mode is True else mode
|
||||||
elif mode == 'right':
|
if normalized_mode == HANGING_PUNCTUATION_MODE_LEFT:
|
||||||
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
|
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
|
||||||
elif mode == 'both' or mode is True:
|
elif normalized_mode == HANGING_PUNCTUATION_MODE_RIGHT:
|
||||||
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
|
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
|
||||||
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
|
|
||||||
|
|
||||||
# Предварительно фильтруем карту классов, оставляя только активные символы
|
# Предварительно фильтруем карту классов, оставляя только активные символы
|
||||||
self.char_to_class = {
|
self.char_to_class = {
|
||||||
char: cls
|
char: cls
|
||||||
|
|||||||
@@ -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)')
|
||||||
|
|||||||
@@ -17,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, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR
|
from etpgrf.config import PROTECTED_HTML_TAGS, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR, CHAR_AMP_PLACEHOLDER
|
||||||
|
|
||||||
|
|
||||||
# --- Настройки логирования ---
|
# --- Настройки логирования ---
|
||||||
@@ -116,46 +116,6 @@ class Typographer:
|
|||||||
f"process_html: {self.process_html}")
|
f"process_html: {self.process_html}")
|
||||||
|
|
||||||
|
|
||||||
def _process_text_node(self, text: str) -> str:
|
|
||||||
"""
|
|
||||||
Внутренний конвейер, который работает с чистым текстом.
|
|
||||||
"""
|
|
||||||
# Шаг 1: Декодируем весь входящий текст в канонический Unicode
|
|
||||||
# (здесь можно использовать html.unescape, но наш кодек тоже подойдет)
|
|
||||||
processed_text = decode_to_unicode(text)
|
|
||||||
# processed_text = text # ВРЕМЕННО: используем текст как есть
|
|
||||||
|
|
||||||
# Шаг 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)
|
|
||||||
# ... вызовы других активных модулей правил ...
|
|
||||||
|
|
||||||
# Финальный шаг: кодируем результат в соответствии с выбранным режимом
|
|
||||||
return encode_from_unicode(processed_text, self.mode)
|
|
||||||
|
|
||||||
def _walk_tree(self, node):
|
|
||||||
"""
|
|
||||||
Рекурсивно обходит DOM-дерево, находя и обрабатывая все текстовые узлы.
|
|
||||||
"""
|
|
||||||
# Список "детей" узла, который мы будем изменять.
|
|
||||||
# Копируем в список, так как будем изменять его во время итерации.
|
|
||||||
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)
|
|
||||||
child.replace_with((processed_node_text))
|
|
||||||
elif child.name not in PROTECTED_HTML_TAGS:
|
|
||||||
# Если это "обычный" html-тег, рекурсивно заходим в него
|
|
||||||
self._walk_tree(child)
|
|
||||||
|
|
||||||
def _hide_protected_tags(self, soup) -> list:
|
def _hide_protected_tags(self, soup) -> list:
|
||||||
"""
|
"""
|
||||||
Находит все защищенные теги, заменяет их на плейсхолдеры и возвращает список сохраненных тегов.
|
Находит все защищенные теги, заменяет их на плейсхолдеры и возвращает список сохраненных тегов.
|
||||||
@@ -164,18 +124,11 @@ class Typographer:
|
|||||||
if not PROTECTED_HTML_TAGS:
|
if not PROTECTED_HTML_TAGS:
|
||||||
return protected_tags
|
return protected_tags
|
||||||
|
|
||||||
# Формируем селектор для поиска
|
|
||||||
selector = ", ".join(PROTECTED_HTML_TAGS)
|
selector = ", ".join(PROTECTED_HTML_TAGS)
|
||||||
|
|
||||||
# Находим все теги. Важно: find_all возвращает их в порядке появления в документе.
|
|
||||||
# Но если мы будем заменять их на лету, структура может измениться.
|
|
||||||
# Поэтому лучше собрать список, а потом заменить.
|
|
||||||
tags_to_replace = soup.select(selector)
|
tags_to_replace = soup.select(selector)
|
||||||
|
|
||||||
for tag in tags_to_replace:
|
for tag in tags_to_replace:
|
||||||
# Сохраняем тег (он будет удален из дерева при replace_with, но объект останется в памяти)
|
|
||||||
protected_tags.append(tag)
|
protected_tags.append(tag)
|
||||||
# Заменяем на текстовый узел с плейсхолдером
|
|
||||||
tag.replace_with(NavigableString(CHAR_PLACEHOLDER))
|
tag.replace_with(NavigableString(CHAR_PLACEHOLDER))
|
||||||
|
|
||||||
return protected_tags
|
return protected_tags
|
||||||
@@ -187,8 +140,6 @@ class Typographer:
|
|||||||
if not protected_tags:
|
if not protected_tags:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Ищем все текстовые узлы, содержащие плейсхолдер
|
|
||||||
# Используем список, так как будем менять дерево
|
|
||||||
text_nodes_with_placeholder = [
|
text_nodes_with_placeholder = [
|
||||||
node for node in soup.descendants
|
node for node in soup.descendants
|
||||||
if isinstance(node, NavigableString) and CHAR_PLACEHOLDER in node
|
if isinstance(node, NavigableString) and CHAR_PLACEHOLDER in node
|
||||||
@@ -197,19 +148,14 @@ class Typographer:
|
|||||||
tag_index = 0
|
tag_index = 0
|
||||||
for node in text_nodes_with_placeholder:
|
for node in text_nodes_with_placeholder:
|
||||||
text = str(node)
|
text = str(node)
|
||||||
# Если в узле есть плейсхолдеры, нам нужно его разбить
|
|
||||||
if CHAR_PLACEHOLDER in text:
|
if CHAR_PLACEHOLDER in text:
|
||||||
parts = text.split(CHAR_PLACEHOLDER)
|
parts = text.split(CHAR_PLACEHOLDER)
|
||||||
|
|
||||||
# Создаем список новых узлов для замены
|
|
||||||
new_nodes = []
|
new_nodes = []
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
# Добавляем текст (если он не пустой)
|
|
||||||
if part:
|
if part:
|
||||||
new_nodes.append(NavigableString(part))
|
new_nodes.append(NavigableString(part))
|
||||||
|
|
||||||
# Если это не последняя часть, значит, здесь был плейсхолдер.
|
|
||||||
# Вставляем тег.
|
|
||||||
if i < len(parts) - 1:
|
if i < len(parts) - 1:
|
||||||
if tag_index < len(protected_tags):
|
if tag_index < len(protected_tags):
|
||||||
new_nodes.append(protected_tags[tag_index])
|
new_nodes.append(protected_tags[tag_index])
|
||||||
@@ -217,7 +163,6 @@ class Typographer:
|
|||||||
else:
|
else:
|
||||||
logger.warning("Mismatch in protected tags count during restoration.")
|
logger.warning("Mismatch in protected tags count during restoration.")
|
||||||
|
|
||||||
# Заменяем исходный узел на новые
|
|
||||||
if new_nodes:
|
if new_nodes:
|
||||||
first_node = new_nodes[0]
|
first_node = new_nodes[0]
|
||||||
node.replace_with(first_node)
|
node.replace_with(first_node)
|
||||||
@@ -225,12 +170,6 @@ class Typographer:
|
|||||||
for next_node in new_nodes[1:]:
|
for next_node in new_nodes[1:]:
|
||||||
current_pos.insert_after(next_node)
|
current_pos.insert_after(next_node)
|
||||||
current_pos = next_node
|
current_pos = next_node
|
||||||
else:
|
|
||||||
# Если узел состоял только из плейсхолдера и мы его заменили на тег,
|
|
||||||
# то new_nodes может быть пустым (если тег был один и текст пустой).
|
|
||||||
# Но split('') дает [''], так что parts не пустой.
|
|
||||||
# Логика выше должна работать.
|
|
||||||
pass
|
|
||||||
|
|
||||||
def process(self, text: str) -> str:
|
def process(self, text: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -239,15 +178,12 @@ class Typographer:
|
|||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
# Если включена обработка HTML и BeautifulSoup доступен
|
|
||||||
|
text = text.replace('&', CHAR_AMP_PLACEHOLDER)
|
||||||
|
|
||||||
if self.process_html:
|
if self.process_html:
|
||||||
# --- ЭТАП 1: Анализ структуры ---
|
|
||||||
# Проверяем, есть ли в начале текста теги <html> или <body>.
|
|
||||||
# Если есть - значит, это полноценный документ, и мы должны вернуть его целиком.
|
|
||||||
# Если нет - значит, это фрагмент, и мы должны вернуть только содержимое body.
|
|
||||||
is_full_document = bool(regex.search(r'^\s*<(?:!DOCTYPE|html|body)', text, regex.IGNORECASE))
|
is_full_document = bool(regex.search(r'^\s*<(?:!DOCTYPE|html|body)', text, regex.IGNORECASE))
|
||||||
|
|
||||||
# --- ЭТАП 2: Парсинг и Санитизация ---
|
|
||||||
try:
|
try:
|
||||||
soup = BeautifulSoup(text, 'lxml')
|
soup = BeautifulSoup(text, 'lxml')
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -255,135 +191,65 @@ class Typographer:
|
|||||||
|
|
||||||
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, '&')
|
||||||
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
|
||||||
|
|
||||||
# --- ЭТАП 2.5: Скрытие защищенных тегов ---
|
|
||||||
# Заменяем <code>, <script> и т.д. на плейсхолдеры, чтобы они не мешали обработке
|
|
||||||
# и не ломали карту длин.
|
|
||||||
protected_tags = self._hide_protected_tags(soup)
|
protected_tags = self._hide_protected_tags(soup)
|
||||||
|
|
||||||
# --- ЭТАП 3: Подготовка (токен-стрим) ---
|
text_nodes = [node for node in soup.descendants if isinstance(node, NavigableString)]
|
||||||
# 3.1. Создаем "токен-стрим" из текстовых узлов.
|
|
||||||
# Теперь здесь только обычный текст и плейсхолдеры.
|
|
||||||
text_nodes = [node for node in soup.descendants
|
|
||||||
if isinstance(node, NavigableString)
|
|
||||||
# and node.strip()
|
|
||||||
and node.parent.name not in PROTECTED_HTML_TAGS] # PROTECTED уже скрыты, но на всякий случай
|
|
||||||
# 3.2. Создаем "супер-строку" с маркерами границ
|
|
||||||
super_string = ""
|
|
||||||
# lengths_map больше не нужен, так как мы используем разделители
|
|
||||||
|
|
||||||
|
super_string = ""
|
||||||
for node in text_nodes:
|
for node in text_nodes:
|
||||||
# ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded).
|
|
||||||
# str(node) может вернуть экранированные символы (например, < вместо <),
|
|
||||||
# что увеличит длину строки и приведет к рассинхронизации карты длин (смещению текста).
|
|
||||||
node_text = node.string or ""
|
node_text = node.string or ""
|
||||||
# Добавляем текст и разделитель
|
|
||||||
super_string += node_text + CHAR_NODE_SEPARATOR
|
super_string += node_text + CHAR_NODE_SEPARATOR
|
||||||
|
|
||||||
# --- ЭТАП 4: Контекстная обработка ---
|
processed_super_string = self._process_plain_text(super_string)
|
||||||
processed_super_string = super_string
|
|
||||||
# Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html).
|
|
||||||
# Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится.
|
|
||||||
if self.quotes:
|
|
||||||
processed_super_string = self.quotes.process(processed_super_string)
|
|
||||||
if self.unbreakables:
|
|
||||||
processed_super_string = self.unbreakables.process(processed_super_string)
|
|
||||||
|
|
||||||
# --- ЭТАП 5: Восстановление структуры ---
|
|
||||||
# Разбиваем строку по разделителям.
|
|
||||||
# split вернет список, где последний элемент будет пустым (из-за разделителя в конце).
|
|
||||||
# Поэтому берем все элементы, кроме последнего.
|
|
||||||
# Но если строка пустая, split вернет [''], и мы возьмем [].
|
|
||||||
# Если строка 'a\uFFFF', split -> ['a', '']. Берем ['a'].
|
|
||||||
parts = processed_super_string.split(CHAR_NODE_SEPARATOR)
|
parts = processed_super_string.split(CHAR_NODE_SEPARATOR)
|
||||||
|
|
||||||
# Проверка на целостность: количество частей должно совпадать с количеством узлов.
|
|
||||||
# split всегда возвращает хотя бы один элемент. Если super_string пустая, parts=[''].
|
|
||||||
# Если super_string не пустая, parts будет иметь длину N+1 (где N - число разделителей).
|
|
||||||
# Нам нужны первые N частей.
|
|
||||||
|
|
||||||
if len(parts) > len(text_nodes):
|
if len(parts) > len(text_nodes):
|
||||||
parts = parts[:len(text_nodes)]
|
parts = parts[:len(text_nodes)]
|
||||||
|
|
||||||
# Если вдруг частей меньше (кто-то удалил разделитель), это проблема.
|
|
||||||
# Но \uFFFF - Non-character, его сложно удалить случайно.
|
|
||||||
|
|
||||||
for i, node in enumerate(text_nodes):
|
for i, node in enumerate(text_nodes):
|
||||||
if i < len(parts):
|
if i < len(parts):
|
||||||
new_text_part = parts[i]
|
new_text_part = parts[i]
|
||||||
# Заменяем содержимое узла.
|
|
||||||
# Важно: если new_text_part содержит CHAR_PLACEHOLDER, он останется как есть
|
|
||||||
# и будет обработан на этапе 5.5.
|
|
||||||
node.replace_with(new_text_part)
|
node.replace_with(new_text_part)
|
||||||
|
|
||||||
# --- ЭТАП 5.5: Восстановление защищенных тегов ---
|
|
||||||
self._restore_protected_tags(soup, protected_tags)
|
self._restore_protected_tags(soup, protected_tags)
|
||||||
|
|
||||||
# --- ЭТАП 6: Локальная обработка (второй проход) ---
|
|
||||||
# Теперь, когда структура восстановлена (включая защищенные теги),
|
|
||||||
# запускаем рекурсивный обход.
|
|
||||||
# Важно: _walk_tree пропускает PROTECTED_HTML_TAGS, так что содержимое
|
|
||||||
# восстановленных тегов не будет обработано повторно.
|
|
||||||
self._walk_tree(soup)
|
|
||||||
|
|
||||||
# --- ЭТАП 7: Висячая пунктуация ---
|
|
||||||
# Применяем после всех текстовых преобразований, но перед финальной сборкой
|
|
||||||
if self.hanging:
|
if self.hanging:
|
||||||
self.hanging.process(soup)
|
self.hanging.process(soup)
|
||||||
|
|
||||||
# --- ЭТАП 8: Финальная сборка ---
|
|
||||||
if is_full_document:
|
if is_full_document:
|
||||||
# Если на входе был полноценный документ, возвращаем все дерево
|
|
||||||
processed_html = str(soup)
|
processed_html = str(soup)
|
||||||
else:
|
else:
|
||||||
# Если на входе был фрагмент, возвращаем только содержимое body.
|
|
||||||
# decode_contents() возвращает строку с содержимым тега (без самого тега).
|
|
||||||
# Если body нет (что странно для BS), возвращаем str(soup).
|
|
||||||
if soup.body:
|
if soup.body:
|
||||||
processed_html = soup.body.decode_contents()
|
processed_html = soup.body.decode_contents()
|
||||||
else:
|
else:
|
||||||
processed_html = str(soup)
|
processed_html = str(soup)
|
||||||
|
|
||||||
# Удаляем плейсхолдеры и разделители, если они вдруг просочились
|
processed_html = processed_html.replace('&', '&')
|
||||||
processed_html = processed_html.replace(CHAR_PLACEHOLDER, '').replace(CHAR_NODE_SEPARATOR, '')
|
return processed_html.replace(CHAR_AMP_PLACEHOLDER, '&')
|
||||||
|
|
||||||
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали
|
|
||||||
# в _process_text_node. Возвращаем их обратно.
|
|
||||||
return processed_html.replace('&', '&')
|
|
||||||
else:
|
else:
|
||||||
return self._process_plain_text(text)
|
processed_text = self._process_plain_text(text)
|
||||||
|
return processed_text.replace(CHAR_AMP_PLACEHOLDER, '&')
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "etpgrf"
|
name = "etpgrf"
|
||||||
version = "0.1.4"
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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><br></code><br>А это новая строка.</p>',
|
('unicode', '<p>Текст с <code><br></code><br>А это новая строка.</p>',
|
||||||
f'<p>Текст с{CHAR_NBSP}<code><br></code><br/>А{CHAR_NBSP}это новая строка.</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>'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -181,6 +187,11 @@ HTML_STRUCTURE_TEST_CASES = [
|
|||||||
# (все незакрытые теги будут закрыты через BS, а тег <html> удалены)
|
# (все незакрытые теги будут закрыты через BS, а тег <html> удалены)
|
||||||
('<ul><li>Исправлена проблема\n с появлением лишних тегов <code><html>++</html></code> и <code><body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>',
|
('<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>'),
|
'<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)
|
@pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)
|
||||||
|
|||||||
Reference in New Issue
Block a user