9 Commits

10 changed files with 179 additions and 260 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 даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).

113
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`):
@@ -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">&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

@@ -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' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в &amp; при замене на мнемонику.
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, # «
@@ -722,3 +730,4 @@ HANGING_PUNCTUATION_CLASSES = {
',': 'etp-r-comma', ',': 'etp-r-comma',
':': 'etp-r-colon', ':': 'etp-r-colon',
} }

View File

@@ -6,7 +6,9 @@ from bs4 import BeautifulSoup, NavigableString, Tag
from .config import ( from .config import (
HANGING_PUNCTUATION_LEFT_CHARS, HANGING_PUNCTUATION_LEFT_CHARS,
HANGING_PUNCTUATION_RIGHT_CHARS, HANGING_PUNCTUATION_RIGHT_CHARS,
HANGING_PUNCTUATION_CLASSES HANGING_PUNCTUATION_CLASSES,
HANGING_PUNCTUATION_MODE_LEFT,
HANGING_PUNCTUATION_MODE_RIGHT,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,28 +23,25 @@ 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)
# Предварительно фильтруем карту классов, оставляя только активные символы # Предварительно фильтруем карту классов, оставляя только активные символы

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

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

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