11 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
12 changed files with 380 additions and 298 deletions

View File

@@ -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.
- Исправлена (частично) расстановка неразрывных пробелов `&nbsp;` на границах закрывающих тегов (например, `<b>Текст</b> -- слово` теперь корректно обрабатывается, в `Текст</b>&nbsp;&endash; слово`).
## [0.1.4] - 2024-02-13
### Изменено ### Изменено
- **Архитектурное улучшение:** Полностью переработан механизм обработки HTML. - **Архитектурное улучшение:** Полностью переработан механизм обработки HTML.
- Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов). - Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).

109
README.md
View File

@@ -14,7 +14,7 @@
Исходный код доступен на нескольких площадках: Исходный код доступен на нескольких площадках:
* [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
@@ -104,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-мнемоники. К счастью, в стандарте таких мнемоник (превращающихся в два символа) исчезающе мало и они крайне
редко применяются в тексте, поэтому это не должно вызывать проблем. редко применляются в тексте, поэтому это не должно вызывать проблем.
### Переносы слов ### Переносы слов
@@ -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;">&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`):
@@ -319,39 +320,81 @@ typo = etpgrf.Typographer(hanging_punctuation='left')
Параметр `hanging_punctuation` может принимать следующие значения: Параметр `hanging_punctuation` может принимать следующие значения:
* `None` или `False` — функция отключена (по умолчанию); * `None` или `False` — функция отключена (по умолчанию);
* `'left'` — включены только левые висячие символы (выравнивание по левому краю); * `'left'` или `True` — включены только левые висячие символы (выравнивание по левому краю);
* `'right'` — включены только правые висячие символы (выравнивание по правому краю); * `'right'` — включены только правые висячие символы (выравнивание по правому краю).
* `'both'` или `True` — включены и левые, и правые висячие символы.
Значения `'both'` **недоступно**, потому что совмещение левой и правой выверки одновременно приводит к конфликтам
с размещением пробелов и делает невозможным контролировать визуальное выравнивание (см. блок про `text-justify`).
Также через `hanging_punctuation` можно задать список тегов, внутри которых висячая типографика будет применяться Также через `hanging_punctuation` можно задать список тегов, внутри которых висячая типографика будет применяться
(всегда в режиме `'both'`). Это нерекомендованный способ (так как предполагает знание об исходном тексте, и не сработает (всегда в режиме `'both'`). Это нерекомендованный способ, потому что он предполагает знание структуры HTML и неизбежно
для сточных тегов, а поведение "блочных" и "строчных" может быть переназначено через CSS). Тем не менее управление выпадает из общей логики вложенности и пробельных узлов.
висячей пунктуацией на уровне тегов может быть полезно в некоторых, редких случаях:
```python ### Как работает оборачивание
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.
@@ -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.

View File

@@ -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

View File

@@ -70,11 +70,43 @@ 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_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
CHAR_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в &amp; при замене на мнемонику.
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character). CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
# === ПЛЕЙСХОЛДЕРЫ (ДЛЯ САНИТАЙЗИНГА НА ХОДЕ) ===
CHARS_SYMBOLS_TO_BAN = frozenset([
CHAR_UNIT_SEPARATOR, CHAR_PLACEHOLDER, CHAR_AMP_PLACEHOLDER, CHAR_NODE_SEPARATOR
])
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ === # === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
# Для простых замен "строка -> символ" используем список кортежей. # Для простых замен "строка -> символ" используем список кортежей.
@@ -107,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. БЕЛЫЙ СПИСОК (ДЛЯ БЕЗОПАСНОСТИ):
@@ -118,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. СПИСОК ДЛЯ ЧИСЛОВОГО КОДИРОВАНИЯ: Символы без стандартного имени.
@@ -148,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. СЛОВАРЬ ПРИОРИТЕТОВ: Кастомные и/или предпочитаемые мнемоники.
@@ -604,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: Следующий приоритет. Добавляем числовое кодирование.
@@ -689,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_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)
elif mode == 'both' or mode is True:
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS) self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
elif normalized_mode == HANGING_PUNCTUATION_MODE_RIGHT:
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
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

@@ -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

@@ -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('&amp;', 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, '&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
# --- ЭТАП 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) может вернуть экранированные символы (например, &lt; вместо <),
# что увеличит длину строки и приведет к рассинхронизации карты длин (смещению текста).
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('&amp;', '&')
processed_html = processed_html.replace(CHAR_PLACEHOLDER, '').replace(CHAR_NODE_SEPARATOR, '') return processed_html.replace(CHAR_AMP_PLACEHOLDER, '&amp;')
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &amp;), которые мы сгенерировали
# в _process_text_node. Возвращаем их обратно.
return processed_html.replace('&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.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)

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. Правая кавычка внутри слова (не должна висеть)
('both', f'<p>1{CHAR_RU_QUOT1_CLOSE}2</p>',
f'<p>1{CHAR_RU_QUOT1_CLOSE}2</p>'),
# 3. Левая кавычка после пробела (должна висеть)
('both', f'<p>func {CHAR_RU_QUOT1_OPEN}arg</p>',
f'<p>func <span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>arg</p>'), f'<p>func <span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>arg</p>'),
# 4. Правая кавычка перед пробелом (должна висеть) (['p'], f'<p>arg{CHAR_RU_QUOT1_CLOSE} next</p>',
('both', f'<p>arg{CHAR_RU_QUOT1_CLOSE} next</p>',
f'<p>arg<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span> 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>'),
] ]
@@ -181,6 +187,11 @@ HTML_STRUCTURE_TEST_CASES = [
# (все незакрытые теги будут закрыты через BS, а тег <html> удалены) # (все незакрытые теги будут закрыты через BS, а тег <html> удалены)
('<ul><li>Исправлена проблема\n с появлением лишних тегов <code><html>++</html></code> и&nbsp;<code><body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>', ('<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>'), '<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) @pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)