Compare commits
14 Commits
9695fe80aa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8788797e7b | |||
| 2e5a6812dd | |||
| edeca18bd0 | |||
| 216053067c | |||
| 51bb0f3473 | |||
| d092cbdb5c | |||
| 465dd9e9e6 | |||
| 88b228050e | |||
| 14ac3682ee | |||
| ce3d1c146a | |||
| 321c2efc26 | |||
| c7d8b18c68 | |||
| 913f28f2f3 | |||
| 125c9560b4 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -478,3 +478,6 @@ fabric.properties
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
# Synology Drive client
|
||||
.synology-drive
|
||||
.DS_Store
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -5,12 +5,19 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.5] - 2024-02-18
|
||||
## [0.1.6] - 2026-03-19
|
||||
### Изменено
|
||||
- Новый алгоритм «висячей пунктуации» (HangingPunctuationProcessor). Добавлены компенсирующие пробелы для висячих символов, чтобы избежать наложения на соседние слова. Теперь «висячие символы» (кавычки, скобки и т. п.) оборачиваются в `<span>` вместе с ближайшим словом и пробелом, что обеспечивает отсутствие визуальных смещений внутри строки. Режим `both` (для одновременного вывешивания в обе стороны) отключен из-за потенциальных конфликтов компенсирующих пробелов и проблем с выравниванием при использовании CSS `text-justify`.</span>
|
||||
### Добавлено
|
||||
- Автоматическая замена символов `x`, `X`, `х`, `Х`, стоящих между числами, на знак умножения `×`, чтобы выражения вида `100x100` или `100 х 100` корректно обрабатывались и выглядели типографски правильными (`100×100` или `100 × 100`).
|
||||
|
||||
|
||||
## [0.1.5] - 2026-02-18
|
||||
### Исправлено
|
||||
- Исправлена ошибка, из-за которой `&` в исходном тексте некорректно преобразовывался в `&`. Теперь `&` и его варианты (`&amp;`, `&lt;`) сохраняются в итоговом HTML.
|
||||
- Исправлена (частично) расстановка неразрывных пробелов ` ` на границах закрывающих тегов (например, `<b>Текст</b> -- слово` теперь корректно обрабатывается, в `Текст</b> &endash; слово`).
|
||||
|
||||
## [0.1.4] - 2024-02-13
|
||||
## [0.1.4] - 2026-02-13
|
||||
### Изменено
|
||||
- **Архитектурное улучшение:** Полностью переработан механизм обработки HTML.
|
||||
- Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).
|
||||
|
||||
132
README.md
132
README.md
@@ -17,7 +17,7 @@
|
||||
* [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало)
|
||||
* [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse)
|
||||
|
||||
## Демострация / Demo
|
||||
## Демонстрация / Demo
|
||||
|
||||
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
|
||||
вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
|
||||
@@ -34,7 +34,7 @@ import etpgrf
|
||||
# Создаем типограф с настройками по умолчанию
|
||||
typo = etpgrf.Typographer(langs='ru')
|
||||
# Обрабатываем текст
|
||||
result = typo.process(text="\"Пример текста для типографирования!\" - сказал он.")
|
||||
result = typo.process(text="\"Пример текста для типографа!\" - сказал он.")
|
||||
print(result)
|
||||
```
|
||||
|
||||
@@ -45,9 +45,9 @@ print(result)
|
||||
некоторых специфических символов (например, кавычек, тире, стрелочек, математических символов) используют
|
||||
html-мнемоники (например, `—` для длинного тире, `«` для открывающей кавычки-ёлочки и т.д.).
|
||||
|
||||
tpgrf имеет три режима работы с кодировками:
|
||||
- Режим `unicode` — весь вывод осуществляется в кодировке UTF-8. ВЕСЬ! Включая невидимые символы, типа неразрывных и нулевых
|
||||
пробелов, мягких переносов и т.д. Это не всегда удобно зато типографированый текст (строки) будет максимально
|
||||
Библиотека etpgrf имеет три режима работы с кодировками:
|
||||
- Режим `unicode` — весь вывод осуществляется в кодировке UTF-8. ВЕСЬ! Включая невидимые символы, типа неразрывных
|
||||
и нулевых пробелов, мягких переносов и т.д. Это не всегда удобно зато типографированый текст (строки) будет максимально
|
||||
компактен и занимать меньше места в памяти. В этом режиме в html-мнемоники преобразуются только опасные символы:
|
||||
* `<` — знак меньше `<`;
|
||||
* `>` — знак больше `>`;
|
||||
@@ -95,16 +95,15 @@ result = typo_mixed_mode.process(text="Этот текст будет обраб
|
||||
короткое из них (для компактности), а значит:
|
||||
* если в исходном тексте были html-мнемоники, то они будут заменены на более короткие;
|
||||
* если html-мнемоники использовались как элементы семантической разметки (например, для математических выражений),
|
||||
то после замены на более короткие html-мнемоники, текст может потерять такую семантику. Например _F = A ⋂ B_:
|
||||
`F = A ⋂ B` будет преобразовано в `F = A ⋂ B`;
|
||||
то после замены на более короткие html-мнемоники, текст может потерять такую семантику. Например `F = A ⋂ B` (_F = A ⋂ B_) будет преобразовано в `F = A ⋂ B`;
|
||||
3. Мнемоники для русских букв не используются в типографе. Все мнемоники русских букв будут преобразованы в русские
|
||||
буквы и останутся в тексте в виде русских букв.
|
||||
4. Все исходные html-мнемоники, которые превращаются в два unicode-символа будут превращены обратно в мнемоники каждый
|
||||
как отдельный символ. Например, множество собственное другого подмножества `⊊︀` в unicode отображается
|
||||
двумя символами `\u228a\ufe00` и превратится в `⊊\ufe00`. Символ `\ufe00` — это невидимый символ, cелектор
|
||||
двумя символами `\u228a\ufe00` и превратится в `⊊\ufe00`. Символ `\ufe00` — это невидимый символ, селектор
|
||||
варианта начертания (Variant Selector), который изменяет начертание предыдущего символа и для него нет
|
||||
html-мнемоники. К счастью, в стандарте таких мнемоник (превращающихся в два символа) исчезающе мало и они крайне
|
||||
редко применляются в тексте, поэтому это не должно вызывать проблем.
|
||||
редко применяются в тексте, поэтому это не должно вызывать проблем.
|
||||
|
||||
|
||||
### Переносы слов
|
||||
@@ -171,7 +170,7 @@ result = typo_hyp.process(text="Электрофоретическое иссл
|
||||
слева или справа от кавычки.
|
||||
|
||||
Преобразование рядом с цифрами (например, когда обозначаются дюймы (`17"`) или секунды (`3' 25"`)) не производится. Также
|
||||
не обрабатываются кавычки окруженные пробелами. Все кавычки которые в исходном тексте уже были оформлены в виде
|
||||
не обрабатываются кавычки, окруженные пробелами. Все кавычки, которые в исходном тексте уже были оформлены в виде
|
||||
«ёлочек» или “лапок” — тоже не обрабатываются.
|
||||
|
||||
ВАЖНО1: По правилам орфографии перед закрывающей кавычкой разрешены только определенные знаки препинания:
|
||||
@@ -183,7 +182,7 @@ result = typo_hyp.process(text="Электрофоретическое иссл
|
||||
кавычкой.**
|
||||
|
||||
ВАЖНО2: Если в настройке типографа указано несколько языков (`langs='ru+en'`), то кавычки будут преобразованы по правилам
|
||||
для языка который идет первым в списке. Например, для `langs='ru+en'` кавычки будут преобразованы в «ёлочки»,
|
||||
для языка, который идет первым в списке. Например, для `langs='ru+en'` кавычки будут преобразованы в «ёлочки».
|
||||
|
||||
Если при типорафировании преобразование не требуется, то можно обработку кавычек можно отключить с помощью
|
||||
параметра `quotes=False`:
|
||||
@@ -202,8 +201,8 @@ result = typo_no_quotes.process(text='Этот "текст" будет обра
|
||||
|
||||
#### Тире
|
||||
|
||||
По правилам русской типографики, длинное тире (—) должно отбиваться пробелами от соседних слов. Чтобы тире не "повисло"
|
||||
в начале строки и визуально не смешивалось с диалогами, etpgrf заменяет пробел перед тире на неразрывный ( ).
|
||||
По правилам русской типографики, длинное тире (`—`) должно отбиваться пробелами от соседних слов. Чтобы тире не "повисло"
|
||||
в начале строки и визуально не смешивалось с диалогами, etpgrf заменяет пробел перед тире на неразрывный (` `).
|
||||
|
||||
* `слово — слово` → `слово — слово`
|
||||
|
||||
@@ -227,7 +226,7 @@ result = typo_no_quotes.process(text='Этот "текст" будет обра
|
||||
* Неразрывный пробел (` `) ставится между фамилией и инициалом/инициалами (`А. Пушкин` → `А. Пушкин`).
|
||||
Неважно стоят ли инициалы перед фамилией или после неё. Важно наличие точки и буквы (инициала), написанного
|
||||
с заглавной буквы.
|
||||
* Тонкая шпация ( ) ставится между самими инициалами, если они написаны слитно, для улучшения внешнего вида
|
||||
* Тонкая шпация (` `) ставится между самими инициалами, если они написаны слитно, для улучшения внешнего вида
|
||||
(`Пушкин А. С. ` → `Пушкин А. С.`). Число инициалов не ограничено (`J.R.R. Tolkien` →
|
||||
`J. R. R. Tolkien`), наличие или отсутствие пробелов между инициалами в исходном тексте неважно.
|
||||
* Акронимы, написанные через точку (не слитно, например, **Н.Л.О.**), разделяются так же, как инициалы, через тонкую шпацию
|
||||
@@ -255,6 +254,7 @@ result = typo.process("А. С. Пушкин") # Останется без изм
|
||||
* Если между единицами изменений есть математические символы (например, умножение или деление):
|
||||
`10 км / ч` → `10 км/ч` (неважно есть пробелы вокруг `/` или нет). Распознаются и другие символы:
|
||||
`·`, `*`, `×`, `÷`.
|
||||
* Символы `x`, `X`, `х`, `Х`, стоящие между двумя числами, заменяются на знак умножения `×`, чтобы выражения вида `100x100` или `100 х 100` корректно обрабатывались и выглядели типографски правильными (`100×100` или `100 × 100`).
|
||||
|
||||
Библиотека "знает" множество стандартных единиц для русского и английского языков. Но не все. Вы можете расширить этот
|
||||
список, передав свои кастомные единицы через параметр `process_units`:
|
||||
@@ -262,7 +262,7 @@ result = typo.process("А. С. Пушкин") # Останется без изм
|
||||
```python
|
||||
# Передаем список
|
||||
typo = etpgrf.Typographer(process_units=['бочек', 'вёдер'])
|
||||
# Можно передавать и с помощзью строки через пробелы
|
||||
# Можно передавать и с помощью строки через пробелы
|
||||
typo = etpgrf.Typographer(process_units='бочек вёдер аршин сажен')
|
||||
result = typo.process("Нужно 10 бочек.") # -> "Нужно 10 бочек."
|
||||
```
|
||||
@@ -294,7 +294,7 @@ result = typo.process("100 км/ч") # Останется без
|
||||
### Висячая типографика
|
||||
|
||||
Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда
|
||||
tире и маркеры списков) выносятся на левое (и иногда и по правому) поле текста. Это создаёт идеально ровный край не по
|
||||
тире и маркеры списков) выносятся на левое (и иногда и по правому) поле текста. Это создаёт идеально ровный край не по
|
||||
формальным границам знаков, а по оптическому краю — по первым буквам строк. Текст выглядит гораздо аккуратнее и
|
||||
профессиональнее.
|
||||
|
||||
@@ -308,8 +308,11 @@ Safari), поэтому на него полагаться нельзя. Поэ
|
||||
Оборачивая "висячий" символ или слово в `<span>` и применяя к нему, например, отрицательный `text-indent` или
|
||||
`margin-left` (`<span style="margin-left:-0.44em">«</span>`), мы можем сместить сам символ, но нужно ещё и
|
||||
сохранить расстояние до соседнего слова. Поэтому типограф оборачивает не только сам висячий символ, но и ближайшее слово
|
||||
(до пробела или границы узла), а также, при необходимости, окружающий пробел. Сама визуальная компенсация оформляется через
|
||||
(до пробела или границы узла), а также при необходимости, окружающий пробел. Сама визуальная компенсация оформляется через
|
||||
отрицательные `margin`/`padding` в CSS-классах — никаких `position:absolute`, чтобы не нарушать поток текста.
|
||||
Учтите, что набор символов, попадающих в `HANGING_PUNCTUATION_SPACE_CHARS`, помимо обычного пробела включает табуляции, переводы
|
||||
строки и множество тонких/математических пробелов. Именно поэтому компенсирующие обёртки иногда захватывают
|
||||
символы на границе узлов или переносов и сохраняют корректный визуальный зазор.
|
||||
|
||||
По умолчанию эта функция висячей типографики **отключена**. Чтобы её включить, нужно задать параметр
|
||||
`hanging_punctuation` при конфигурировании типографа (по умолчанию `hanging_punctuation=None`):
|
||||
@@ -327,62 +330,97 @@ typo = etpgrf.Typographer(hanging_punctuation='left')
|
||||
с размещением пробелов и делает невозможным контролировать визуальное выравнивание (см. блок про `text-justify`).
|
||||
|
||||
Также через `hanging_punctuation` можно задать список тегов, внутри которых висячая типографика будет применяться
|
||||
(всегда в режиме `'both'`). Это нерекомендованный способ, потому что он предполагает знание структуры HTML и неизбежно
|
||||
(всегда в режиме `'both'`). Это не рекомендованный способ, потому что он предполагает знание структуры HTML и неизбежно
|
||||
выпадает из общей логики вложенности и пробельных узлов.
|
||||
|
||||
### Как работает оборачивание
|
||||
|
||||
Процессор висячей типографики запускается после всех текстовых преобразований и работает с деревом BeautifulSoup. Он ищет
|
||||
последовательности «пробел + висячий символ» для левого выравнивания и «слово + висячий символ + пробел» для правого,
|
||||
последовательности «пробел + висячий символ» для левого выравнивания и «висячий символ + пробел» для правого,
|
||||
чтобы обернуть нужные фрагменты в пары `<span>` и не допустить «сиротства» символов. Порядок действий можно описать так:
|
||||
* Для `hanging_punctuation='left'`:
|
||||
* если символ стоит в начале текстового узла (без пробелов слева), оборачивается только сам символ и следующее
|
||||
слово (`<span class="etp-laquo">«АукЫон»</span>`);
|
||||
* если перед символом внутри узла есть пробел, то пробел оборачивается в `<span class="etp-sp-laquo"> </span>`, а
|
||||
символ вместе со словом — в `<span class="etp-laquo">...</span>`;
|
||||
* если пробел оказалось в соседнем узле, то он тоже оборачивается в `etp-sp-*`, чтобы не нарушить последовательность;
|
||||
* если компенсирующий пробел является "непереносимым пробелом" (или любым другим: шпацией, em-пробелом и т.п.), то тогда, для правильного выравнивания, оборачивается он, например: `<span class="etp-sp-laquo"> </span><span class="etp-laquo">«АукЫон»</span>`.
|
||||
* если перед "висячим" символом внутри узла есть пробел, то пробел и слово слева от него оборачивается
|
||||
в `<span class="etp-sp-laquo">слово </span>` (компенсирующий пробел), а сам "висячий" символ вместе со словом справа —
|
||||
в `<span class="etp-laquo">...</span>`;
|
||||
* если компенсирующий пробел оказался в соседнем узле (слева), то он тоже оборачивается в `etp-sp-*`, чтобы
|
||||
не нарушить последовательность;
|
||||
* если слева от "висячего" символа пробел является "неразрывным пробелом" (` `, нулевой неразрывный пробел,
|
||||
узкий неразрывный пробел или любой "не пробельный" символ) — это означает, что "висячий" символ не может
|
||||
"вывешиваться" в начале строки и оборачивания в `<span>` не проиходит.
|
||||
* Для `hanging_punctuation='right'`:
|
||||
* слово с висячим символом оборачивается в соответствующий класс (`.etp-raquo`, `.etp-rpar` и т.д.);
|
||||
* пробел сразу после символа получает класс `etp-sp-raquo`, `etp-sp-rpar` и т.д., чтобы сохранить переносную ширину и
|
||||
аккуратно компенсировать смещение;
|
||||
|
||||
* слово с "висячим символом" и слово слева оборачивается в соответствующий класс (`.etp-raquo`, `.etp-rpar` и т.д.);
|
||||
* пробел сразу после символа (справа) получает класс `etp-sp-raquo`, `etp-sp-rpar` и т.д., чтобы сохранить
|
||||
переносную ширину и аккуратно компенсировать смещение;
|
||||
* если компенсирующий пробел оказался в соседнем узле (справа), то он тоже оборачивается в `etp-sp-*`, чтобы
|
||||
не нарушить последовательность;
|
||||
* если справа от "висячего" символа пробел является "неразрывным пробелом" (` `, нулевой неразрывный пробел,
|
||||
узкий неразрывный пробел или любой "не пробельный" символ) — это означает, что "висячий" символ не может
|
||||
"вывешиваться" в конце строки и оборачивания в `<span>` не проиходит.
|
||||
|
||||
Пример вывода для `'left'`:
|
||||
|
||||
```html
|
||||
Завтра концерт группы<span class="etp-sp-laquo"> </span><span class="etp-laquo">«АукЫон»</span>
|
||||
<span class="etp-laquo">«Все</span> обобщения опасны, включая это» (Дюма)
|
||||
|
||||
Завтра концерт <span class="etp-sp-laquo">группы</span><span class="etp-laquo">«Дайте</span> танк»
|
||||
|
||||
Если перед «висячим символом» стоит неразрывный пробел, он не может оказаться вначале строки.
|
||||
```
|
||||
|
||||
Пример вывода для `'right'`:
|
||||
|
||||
```html
|
||||
Right “long <span class="etp-rdquo">quote”</span><span class="etp-sp-rdquo"> </span>with compensation space
|
||||
|
||||
Отсутствие смещения «символа висячей <span class="etp-raquo">пунктуации»</span><span class="etp-sp-raquo"> </span>внутри строки обеспечивает компенсирующий пробел справа <span class="etp-r-dot">от неё.</span>
|
||||
|
||||
Символ правой «висячей пунктуации» не может оказаться в конце строки, если за ним стоит неразрывный пробел.
|
||||
```
|
||||
|
||||
### CSS для висячих символов
|
||||
|
||||
Предлагаемый, начиная с etpgrf v0.1.6, CSS теперь работает только с `margin` и `padding`, без `position:absolute`. Пробелы получают собственные
|
||||
классы, поэтому их компенсация контролируется отдельно, а не встроена в сам висячий символ. Убедитесь, что эти стили
|
||||
подключены к странице и не конфликтуют с `text-justify`, который вытягивает пробелы по всей строке и разрушает аккуратное
|
||||
выравнивание.
|
||||
Предлагаемый, начиная с etpgrf v0.1.6, CSS теперь работает только с `margin` и `padding`, без `position:absolute`.
|
||||
Компенсирующие пробелы получают собственные классы, поэтому их компенсация контролируется отдельно, а не встроена
|
||||
в сам висячий символ. Убедитесь, что эти стили подключены к странице и не конфликтуют с `text-justify`, который
|
||||
увеличивает пробелы между словами по всей строке, делают текст менее удобным для чтения и не пригодны
|
||||
для выравнивания.
|
||||
|
||||
```css
|
||||
/* -----------------------------------------
|
||||
СТИЛИ ДЛЯ ВИСЯЧЕЙ ПУНКТУАЦИИ
|
||||
Значения отступов (padding) для компенсирующих пробелов и полей (margin) для самих символов висячей
|
||||
пунктуации приведены для шрифта Times New Roman и должны быть скорректированы в зависимости
|
||||
от выбранного вами шрифта.
|
||||
------------------------------------------ */
|
||||
/* --- ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */
|
||||
.etp-laquo { margin-left: -0.44em; }
|
||||
.etp-ldquo, .etp-bdquo { margin-left: -0.4em; }
|
||||
.etp-lsquo { margin-left: -0.22em; }
|
||||
.etp-lpar, .etp-lsqb, .etp-lcub { margin-left: -0.25em; }
|
||||
.etp-laquo { margin-left: -0.49em; } /* « */
|
||||
.etp-ldquo, .etp-bdquo { margin-left: -0.4em; } /* “ “ */
|
||||
.etp-lsquo { margin-left: -0.22em; } /* ’ */
|
||||
.etp-lpar, .etp-lsqb, .etp-lcub { margin-left: -0.23em; } /* ( [ { */
|
||||
/* компенсирующие пробелы для левых висячих символов */
|
||||
.etp-sp-laquo { padding-right: 0.44em; }
|
||||
.etp-sp-laquo { padding-right: 0.49em; }
|
||||
.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-sp-lpar, .etp-sp-lsqb, .etp-sp-lcub { padding-right: 0.35em; }
|
||||
|
||||
/* --- ПРАВЫЕ ВИСЯЧИЕ СИМВОЛЫ --- */
|
||||
.etp-raquo { padding-right: 0.44em; margin-left: -0.44em; }
|
||||
.etp-rdquo { padding-right: 0.4em; margin-left: -0.4em; }
|
||||
.etp-rsquo { padding-right: 0.22em; margin-left: -0.22em; }
|
||||
.etp-rpar, .etp-rsqb, .etp-rcub { padding-right: 0.25em; margin-left: -0.25em; }
|
||||
.etp-raquo { margin-right: -0.49em; } /* » */
|
||||
.etp-rdquo { margin-right: -0.4em; } /* ” */
|
||||
.etp-rsquo { margin-right: -0.22em; } /* ’ */
|
||||
.etp-r-comma { margin-right: -0.06ex; } /* , */
|
||||
.etp-r-colon { margin-right: -0.06ex;; } /* : */
|
||||
.etp-r-dot { margin-right: -0.06ex; } /* . */
|
||||
.etp-rpar, .etp-rsqb, .etp-rcub { margin-right: -0.23em; } /* ) ] } */
|
||||
/* компенсирующие пробелы для правых висячих символов */
|
||||
.etp-sp-raquo { margin-left: -0.44em; }
|
||||
.etp-sp-rdquo { margin-left: -0.4em; }
|
||||
.etp-sp-rsquo { margin-left: -0.22em; }
|
||||
.etp-sp-rpar, .etp-sp-rsqb, .etp-sp-rcub { margin-left: -0.25em; }
|
||||
.etp-sp-raquo { padding-left: 0.49em; }
|
||||
.etp-sp-rdquo { padding-left: 0.4em; }
|
||||
.etp-sp-rsquo { padding-left: 0.22em; }
|
||||
.etp-sp-r-comma { padding-left: 0.06ex; }
|
||||
.etp-sp-r-colon { padding-left: 0.06ex; }
|
||||
.etp-sp-r-dot { padding-left: 0.06ex; }
|
||||
.etp-sp-rpar, .etp-sp-rsqb, .etp-sp-rcub { padding-left: 0.23em; }
|
||||
```
|
||||
|
||||
*Комментарии:* Двухстороннее выравнивание текстового блока с помощью стиля `text-justify` в принципе плохо совместим концепцией типографики — он растягивает или сжимает пробелы по всей строке (а это пробелы между словами) и уже этими, переменными, пробелами, делает текст трудночитаемым. Если же вы используете `text-justify` для выравнивания текста по ширине, то, чтобы сохранить оптимальную читаемость текста, включать висячую типографику не рекомендуется.
|
||||
@@ -391,7 +429,7 @@ typo = etpgrf.Typographer(hanging_punctuation='left')
|
||||
|
||||
При обработке сложного HTML-кода типограф стремится сохранить структуру документа, но некоторые пограничные случаи могут обрабатываться не так, как ожидается. В частности:
|
||||
|
||||
* **Обработка на стыке тегов:** Правила, требующие анализа контекста (например, расстановка неразрывных пробелов у тире или единиц измерения), могут работать некорректно, если анализируемые части текста разделены тегами . Например, конструкция `$<b>100</b>` не будет обработана (между $ и 100 не будет вставлен неразрывный пробел), так как типограф не видит их как соседние элементы.
|
||||
* **Обработка на стыке тегов:** Правила, требующие анализа контекста (например, расстановка неразрывных пробелов у тире или единиц измерения), могут работать некорректно, если анализируемые части текста разделены тегами. Например, конструкция `$<b>100</b>` не будет обработана (между $ и 100 не будет вставлен неразрывный пробел), так как типограф не видит их как соседние элементы.
|
||||
* **"Ремонт" HTML:** Библиотека использует `BeautifulSoup` для парсинга, который может "чинить" невалидный HTML (например, закрывать незакрытые теги). Это может привести к неожиданным изменениям в структуре, если исходный код был некорректен. Так же может меняться порядок атрибутов тега.
|
||||
|
||||
Мы знаем об этих особенностях и работаем над улучшением алгоритмов для более точной обработки сложных случаев.
|
||||
|
||||
229
etpgrf/config.py
229
etpgrf/config.py
@@ -55,6 +55,7 @@ CHAR_EN_QUOT1_OPEN = '“'
|
||||
CHAR_EN_QUOT1_CLOSE = '”'
|
||||
CHAR_EN_QUOT2_OPEN = '‘'
|
||||
CHAR_EN_QUOT2_CLOSE = '’'
|
||||
CHAR_TIMES = '×'
|
||||
CHAR_COPY = '\u00a9' # Символ авторского права / © / ©
|
||||
CHAR_REG = '\u00ae' # Зарегистрированная торговая марка / ® / ®
|
||||
CHAR_COPYP = '\u2117' # Знак звуковой записи / ℗ / ©p;
|
||||
@@ -69,13 +70,44 @@ CHAR_ARROW_LR = '\u27f7' # Длинная двунаправленная ст
|
||||
CHAR_ARROW_L_LONG_DOUBLE = '\u27f8' # Длинная двойная стрелка влево
|
||||
CHAR_ARROW_R_LONG_DOUBLE = '\u27f9' # Длинная двойная стрелка вправо
|
||||
CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двунаправленная стрелка
|
||||
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / ·
|
||||
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / ·
|
||||
# ПРОБЕЛЫ
|
||||
CHAR_EN_SP = '\u2002' # Полужирный пробел (En Space) --  
|
||||
CHAR_EM_SP = '\u2003' # Широкий пробел (Em Space) --  
|
||||
CHAR_NUM_SP = '\u2007' # Цифровой пробел --  
|
||||
CHAR_PUNT_SP = '\u2008' # Пунктуационный пробел --  
|
||||
CHAR_HAIR_SP = '\u200A' # Толщина волоса (Hair Space) --  
|
||||
CHAR_MED_SP = '\u205F' # Средний пробел (Medium Mathematical Space) --  
|
||||
CHAR_NULL_SP = '\u200B' # Нулевой пробел (Zero Width Space)... в стандартах мнемоник путаница, и ее мнемоника ​
|
||||
CHAR_THIN_NBSP = '\u202F' # Тонкий неразрывной пробел (Narrow No-Break Space) который, к сожалению, не имеет html-мнемоники
|
||||
CHAR_ZWNJ = '\u200D' # Нулевая ширина (с объединением) (Zero Width Joiner) -- ‌
|
||||
CHAR_EN_QUAD_SP = '\u2000' # Полукруглая шпация, ширина равна 1/2 от кегля (En Quad)
|
||||
CHAR_EM_QUAD_SP = '\u2001' # Круглая шпация, ширина равна 1/1 (полный кегль), квадрат (Em Quad)
|
||||
CHAR_THREE_PER_EM_SP = '\u2004' # «Толстый» пробел, ширина 1/3 от круглой шпации (Three-Per-Em Space) --  
|
||||
CHAR_FOUR_PER_EM_SP = '\u2005' # Средний пробел, ширина 1/4 от круглой шпации (Four-Per-Em Space) --  
|
||||
CHAR_SIX_PER_EM_SP = '\u2006' # «Тонкий» волосяной пробел, ширина 1/6 от круглой шпации (Six-Per-Em Space)
|
||||
# ПРОСТО ЧАСТО ИСПОЛЬЗУЕМЫЕ СИМВОЛЫ, ДЛЯ ЭКОНОМИИ ПАМЯТИ (эфемерно, т.к. Python все равно бы оптимизировал, но для ясности и удобства):
|
||||
CHAR_LPAR = '(' # Левая круглая скобка
|
||||
CHAR_LSQB = '[' # Левая квадратная скобка
|
||||
CHAR_LCUB = '{' # Левая фигурная скобка
|
||||
CHAR_RPAR = ')' # Правая круглая скобка
|
||||
CHAR_RSQB = ']' # Правая квадратная скобка
|
||||
CHAR_RCUB = '}' # Правая фигурная скобка
|
||||
CHAR_DOT = '.' # Точка
|
||||
CHAR_COMMA = ',' # Запятая
|
||||
CHAR_COLON = ':' # Двоеточие
|
||||
# СЛУЖЕБНЫЕ СИМВОЛЫ (НЕ ДОЛЖНЫ ПРИНИМАТЬСЯ ВВОДОМ И НЕ ДОЛЖНЫ ВЫВОДИТЬСЯ, ИСПОЛЬЗУЮТСЯ ТОЛЬКО ВНУТРИ ПРОЦЕССОРОВ ДЛЯ ВРЕМЕННОЙ ЗАМЕНЫ ИЛИ РАЗДЕЛЕНИЯ):
|
||||
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
|
||||
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
|
||||
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
|
||||
CHAR_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в & при замене на мнемонику.
|
||||
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
|
||||
|
||||
# === ПЛЕЙСХОЛДЕРЫ (ДЛЯ САНИТАЙЗИНГА НА ХОДЕ) ===
|
||||
CHARS_SYMBOLS_TO_BAN = frozenset([
|
||||
CHAR_UNIT_SEPARATOR, CHAR_PLACEHOLDER, CHAR_AMP_PLACEHOLDER, CHAR_NODE_SEPARATOR
|
||||
])
|
||||
|
||||
|
||||
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
|
||||
# Для простых замен "строка -> символ" используем список кортежей.
|
||||
@@ -108,8 +140,8 @@ STR_TO_SYMBOL_REPLACEMENTS = [
|
||||
|
||||
# === КОНСТАНТЫ ДЛЯ КОДИРОВАНИЯ HTML-МНЕМНОИКОВ ===
|
||||
# --- ЧЕРНЫЙ СПИСОК: Символы, которые НИКОГДА не нужно кодировать в мнемоники ---
|
||||
NEVER_ENCODE_CHARS = (frozenset(['!', '#', '%', '(', ')', '*', ',', '.', '/', ':', ';', '=', '?', '@',
|
||||
'[', '\\', ']', '^', '_', '`', '{', '|', '}', '~', '\n', '\t', '\r'])
|
||||
NEVER_ENCODE_CHARS = (frozenset(['!', '#', '%', CHAR_LPAR, CHAR_RPAR, '*', CHAR_COMMA, CHAR_DOT, '/', CHAR_COLON, ';', '=', '?', '@',
|
||||
CHAR_LSQB, '\\', CHAR_RSQB, '^', '_', '`', CHAR_LCUB, '|', CHAR_RCUB, '~', '\n', '\t', '\r'])
|
||||
| RU_ALPHABET_FULL | EN_ALPHABET_FULL)
|
||||
|
||||
# 2. БЕЛЫЙ СПИСОК (ДЛЯ БЕЗОПАСНОСТИ):
|
||||
@@ -119,22 +151,25 @@ SAFE_MODE_CHARS_TO_MNEMONIC = frozenset([
|
||||
'<', '>', '&', '"', '\'',
|
||||
CHAR_SHY, # Мягкий перенос (Soft Hyphen) -- ­
|
||||
CHAR_NBSP, # Неразрывный пробел (Non-Breaking Space) --
|
||||
'\u2002', # Полужирный пробел (En Space) --  
|
||||
'\u2003', # Широкий пробел (Em Space) --  
|
||||
'\u2007', # Цифровой пробел --  
|
||||
'\u2008', # Пунктуационный пробел --  
|
||||
CHAR_EN_SP, # Полужирный пробел (En Space) --  
|
||||
CHAR_EM_SP, # Широкий пробел (Em Space) --  
|
||||
CHAR_NUM_SP, # Цифровой пробел --  
|
||||
CHAR_PUNT_SP, # Пунктуационный пробел --  
|
||||
CHAR_THIN_SP, # Межсимвольный пробел, тонкий пробел, шпация --  '
|
||||
'\u200A', # Толщина волоса (Hair Space) --  
|
||||
'\u200B', # Негативный пробел (Negative Space) -- ​
|
||||
CHAR_HAIR_SP, # Толщина волоса (Hair Space) --  
|
||||
CHAR_NULL_SP, # Нулевой пробел (Zero Width Space)... в стандартах мнемоник путаница, и ее мнемоника ​
|
||||
'\u200C', # Нулевая ширина (без объединения) (Zero Width Non-Joiner) -- ‍
|
||||
'\u200D', # Нулевая ширина (с объединением) (Zero Width Joiner) -- ‌
|
||||
CHAR_ZWNJ, # Нулевая ширина (с объединением) (Zero Width Joiner) -- ‌
|
||||
CHAR_THREE_PER_EM_SP, # «Толстый» пробел, ширина 1/3 от круглой шпации (Three-Per-Em Space) --  
|
||||
CHAR_FOUR_PER_EM_SP, # Средний пробел, ширина 1/4 от круглой шпации (Four-Per-Em Space) --  
|
||||
'\u200E', # Изменить направление текста на слева-направо (Left-to-Right Mark /LRE) -- ‎
|
||||
'\u200F', # Изменить направление текста направо-налево (Right-to-Left Mark /RLM) -- ‏
|
||||
'\u2010', # Дефис (Hyphen) -- ‐
|
||||
'\u205F', # Средний пробел (Medium Mathematical Space) --  
|
||||
CHAR_MED_SP, # Средний пробел (Medium Mathematical Space) --  
|
||||
'\u2060', # ⁠
|
||||
'\u2062', # ⁢ -- для семантической разметки математических выражений
|
||||
'\u2063', # ⁣ -- для семантической разметки математических выражений
|
||||
|
||||
])
|
||||
|
||||
# 3. СПИСОК ДЛЯ ЧИСЛОВОГО КОДИРОВАНИЯ: Символы без стандартного имени.
|
||||
@@ -149,6 +184,10 @@ ALWAYS_ENCODE_TO_NUMERIC_CHARS = frozenset([
|
||||
'\u20BD', # Знак русского рубля (₽)
|
||||
'\u20BE', # Знак грузинский лари (₾)
|
||||
'\u20BF', # Знак биткоина (₿)
|
||||
CHAR_THIN_NBSP, # Тонкий неразрывный пробел (Narrow No-Break Space) -- как   но с поведением (к сожалению, не имеет html-мнемоники)
|
||||
CHAR_EN_QUAD_SP, # Полукруглая шпация, ширина равна 1/2 от кегля (En Quad)
|
||||
CHAR_EM_QUAD_SP, # Круглая шпация, ширина равна 1/1 (полный кегль), квадрат (Em Quad)
|
||||
CHAR_SIX_PER_EM_SP, # «Тонкий» волосяной пробел, ширина 1/6 от круглой шпации (Six-Per-Em Space)
|
||||
])
|
||||
|
||||
# 4. СЛОВАРЬ ПРИОРИТЕТОВ: Кастомные и/или предпочитаемые мнемоники.
|
||||
@@ -172,9 +211,9 @@ CUSTOM_ENCODE_MAP = {
|
||||
# '\u007b': '{', # { / { / {
|
||||
# '\u007d': '}', # } / } / }
|
||||
# '\u007c': '|', # | / | / | / |
|
||||
CHAR_NBSP: ' ', # / /  
|
||||
CHAR_REG: '®', # ® / ® / ® / ®
|
||||
CHAR_COPY: '©', # © / © / ©
|
||||
CHAR_NBSP: ' ', # / /  
|
||||
CHAR_REG: '®', # ® / ® / ® / ®
|
||||
CHAR_COPY: '©', # © / © / ©
|
||||
'\u0022': '"', # " / " / "
|
||||
'\u0026': '&', # & / & / &
|
||||
'\u003e': '>', # > / > / >
|
||||
@@ -605,7 +644,7 @@ def _build_translation_maps() -> dict[str, str]:
|
||||
# На его основе строим нашу карту для кодирования.
|
||||
encode_map = {}
|
||||
|
||||
# ШАГ 2: Высший приоритет. Загружаем наши кастомные правила.
|
||||
# ШАГ 2: Высший приоритет. Загружаем кастомные правила.
|
||||
encode_map.update(CUSTOM_ENCODE_MAP)
|
||||
|
||||
# ШАГ 3: Следующий приоритет. Добавляем числовое кодирование.
|
||||
@@ -655,6 +694,7 @@ DEFAULT_POST_UNITS = [
|
||||
'рад', 'К', '°C', '°F', '%', 'мкм', 'нм', 'А°', 'эВ', 'Дж', 'кДж', 'МДж', 'пкФ', 'нФ', 'мкФ', 'мФ', 'Ф',
|
||||
'Гн', 'мГн', 'мкГн', 'Тл', 'Гс', 'эрг', 'бод', 'бит', 'байт', 'Кб', 'Мб', 'Гб', 'Тб', 'Пб', 'Эб', 'кал', 'ккал',
|
||||
# Английские
|
||||
'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'byte', 'uF', 'pF', 'W', 'A', 'V', 'ohm',
|
||||
# --- Издательское дело ---
|
||||
'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed', 'rev', 'dpi',
|
||||
# --- Имперские и американские единицы ---
|
||||
@@ -662,11 +702,11 @@ DEFAULT_POST_UNITS = [
|
||||
]
|
||||
# Пред-позиционные (№ 5, $ 10)
|
||||
DEFAULT_PRE_UNITS = ['№', '$', '€', '£', '₽', '#', '§', '¤', '₴', '₿', '₺', '₦', '₩', '₪', '₫', '₲', '₡', '₵',
|
||||
'ГОСТ', 'ТУ', 'ИСО', 'DIN', 'ASTM', 'EN', 'IEC', 'IEEE'] # технические стандарты перед числом работают как единицы измерения
|
||||
'ГОСТ', 'ТУ', 'ИСО', 'ISO', 'DIN', 'ASTM', 'EN', 'IEC', 'IEEE'] # технические стандарты перед числом работают как единицы измерения
|
||||
|
||||
# Операторы, которые могут стоять между единицами измерения (км/ч)
|
||||
# Сложение и вычитание здесь намеренно отсутствуют.
|
||||
UNIT_MATH_OPERATORS = ['/', '*', '×', CHAR_MIDDOT, '÷']
|
||||
UNIT_MATH_OPERATORS = ['/', '*', CHAR_TIMES, CHAR_MIDDOT, '÷']
|
||||
|
||||
# === КОНСТАНТЫ ДЛЯ ФИНАЛЬНЫХ СОКРАЩЕНИЙ ===
|
||||
# Эти сокращения (обычно в конце фразы) будут "склеены" тонкой шпацией, а перед ними будет поставлен неразрывный пробел.
|
||||
@@ -689,7 +729,6 @@ ABBR_COMMON_PREPOSITION = [
|
||||
PROTECTED_HTML_TAGS = ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math']
|
||||
|
||||
# === КОНСТАНТЫ ДЛЯ ВИСЯЧЕЙ ТИПОГРАФИКИ ===
|
||||
|
||||
HANGING_PUNCTUATION_MODE_LEFT = 'left'
|
||||
HANGING_PUNCTUATION_MODE_RIGHT = 'right'
|
||||
HANGING_PUNCTUATION_MODES = frozenset([
|
||||
@@ -697,37 +736,129 @@ HANGING_PUNCTUATION_MODES = frozenset([
|
||||
HANGING_PUNCTUATION_MODE_RIGHT,
|
||||
])
|
||||
|
||||
# Пробелы (символы-ищейки) которые могут использоваться как разделители "компенсационных сдвигов" для висячей пунктуации.
|
||||
# Их соседство с висячими символами позволяет "компенсировать" их смещение относительно прилегающего символа.
|
||||
HANGING_PUNCTUATION_SPACE_CHARS = frozenset([
|
||||
' ', # обычный пробел
|
||||
# CHAR_NBSP, # неразрывный пробел ( )
|
||||
CHAR_SHY, # мягкий перенос (­)
|
||||
CHAR_THIN_SP, # тонкий пробел ( )
|
||||
CHAR_EN_QUAD_SP, # Полукруглая шпация, ширина равна 1/2 от кегля (En Quad)
|
||||
CHAR_EM_QUAD_SP, # Круглая шпация, ширина равна 1/1 (полный кегль), квадрат (Em Quad)
|
||||
CHAR_EN_SP, # EN-пробел (en space)
|
||||
CHAR_EM_SP, # EM-пробел (em space)
|
||||
CHAR_THREE_PER_EM_SP, # «Толстый» пробел, ширина 1/3 от круглой шпации (Three-Per-Em Space)
|
||||
CHAR_FOUR_PER_EM_SP, # Средний пробел, ширина 1/4 от круглой шпации (Four-Per-Em Space)
|
||||
CHAR_SIX_PER_EM_SP, # «Тонкий» волосяной пробел, ширина 1/6 от круглой шпации (Six-Per-Em Space)
|
||||
CHAR_NUM_SP, # цифровой пробел (figure space)
|
||||
CHAR_PUNT_SP, # пунктуационный пробел (punctuation space)
|
||||
CHAR_HAIR_SP, # волосной пробел (hair space)
|
||||
# CHAR_THIN_NBSP, # Тонкий неразрывной пробел (Narrow No-Break Space) который, к сожалению, не имеет html-мнемоники
|
||||
CHAR_MED_SP, # средний пробел (medium space)
|
||||
CHAR_NULL_SP, # нулевой пробел (zero width space)... в мнемонике ​
|
||||
'\t', # табуляция
|
||||
'\n', # перевод строки
|
||||
'\r', # возврат каретки
|
||||
'\u000b', # вертикальная табуляция
|
||||
'\f' # перевод страницы
|
||||
])
|
||||
|
||||
# 1. Набор символов, которые могут "висеть" слева
|
||||
HANGING_PUNCTUATION_LEFT_CHARS = frozenset([
|
||||
CHAR_RU_QUOT1_OPEN, # «
|
||||
CHAR_EN_QUOT1_OPEN, # “
|
||||
'(', '[', '{',
|
||||
])
|
||||
|
||||
# 2. Набор символов, которые могут "висеть" справа
|
||||
HANGING_PUNCTUATION_RIGHT_CHARS = frozenset([
|
||||
CHAR_RU_QUOT1_CLOSE, # »
|
||||
CHAR_EN_QUOT1_CLOSE, # ”
|
||||
')', ']', '}',
|
||||
'.', ',', ':',
|
||||
])
|
||||
|
||||
# 3. Словарь, сопоставляющий символ с его CSS-классом
|
||||
HANGING_PUNCTUATION_CLASSES = {
|
||||
# Левая пунктуация: все классы начинаются с 'etp-l'
|
||||
CHAR_RU_QUOT1_OPEN: 'etp-laquo',
|
||||
CHAR_EN_QUOT1_OPEN: 'etp-ldquo',
|
||||
'(': 'etp-lpar',
|
||||
'[': 'etp-lsqb',
|
||||
'{': 'etp-lcub',
|
||||
# Правая пунктуация: все классы начинаются с 'etp-r'
|
||||
CHAR_RU_QUOT1_CLOSE: 'etp-raquo',
|
||||
CHAR_EN_QUOT1_CLOSE: 'etp-rdquo',
|
||||
')': 'etp-rpar',
|
||||
']': 'etp-rsqb',
|
||||
'}': 'etp-rcub',
|
||||
'.': 'etp-r-dot',
|
||||
',': 'etp-r-comma',
|
||||
':': 'etp-r-colon',
|
||||
# ВАЖНО: кавычки второго уровня (CHAR_EN_QUOT2_OPEN = '„' и CHAR_RU_QUOT2_OPEN = '„') НЕ ВКЛЮЧЕНЫ,
|
||||
# т.к. CHAR_RU_QUOT2_CLOSE == CHAR_EN_QUOT1_OPEN и невозможно отличить закрывающую кавычку (ru)
|
||||
# от открывающей кавычки (en) и однозначно решить к какую сторону делать вывешивание.
|
||||
HANGING_PUNCTUATION_CHARS = {
|
||||
HANGING_PUNCTUATION_MODE_LEFT: frozenset([
|
||||
CHAR_RU_QUOT1_OPEN, # «
|
||||
CHAR_EN_QUOT1_OPEN, # “
|
||||
CHAR_LPAR, # (
|
||||
CHAR_LSQB, # [
|
||||
CHAR_LCUB, # {
|
||||
]),
|
||||
HANGING_PUNCTUATION_MODE_RIGHT: frozenset([
|
||||
CHAR_RU_QUOT1_CLOSE, # »
|
||||
CHAR_EN_QUOT1_CLOSE, # ”
|
||||
CHAR_RPAR, # )
|
||||
CHAR_RSQB, # ]
|
||||
CHAR_RCUB, # }
|
||||
CHAR_DOT, # .
|
||||
CHAR_COMMA, # ,
|
||||
CHAR_COLON, # :
|
||||
]),
|
||||
}
|
||||
|
||||
# Сохраняем старые имена ради совместимости, пока модуль не переписан полностью
|
||||
HANGING_PUNCTUATION_LEFT_CHARS = HANGING_PUNCTUATION_CHARS[HANGING_PUNCTUATION_MODE_LEFT]
|
||||
HANGING_PUNCTUATION_RIGHT_CHARS = HANGING_PUNCTUATION_CHARS[HANGING_PUNCTUATION_MODE_RIGHT]
|
||||
|
||||
# 3. Словарь, сопоставляющий символ с его CSS-классом
|
||||
HANGING_PUNCTUATION_SYMBOLS_CLASSES = {
|
||||
HANGING_PUNCTUATION_MODE_LEFT: {
|
||||
# Левая пунктуация: все классы начинаются с 'etp-l'
|
||||
CHAR_RU_QUOT1_OPEN: 'etp-laquo', # ` «` -- левая открывающая кавычка-ёлочка
|
||||
CHAR_EN_QUOT1_OPEN: 'etp-ldquo', # ` “` -- левая открывающая кавычка-лапка
|
||||
CHAR_EN_QUOT2_OPEN: 'etp-lsquo', # ` ‘` -- левая открывающая кавычка-апостроф (одинарная)
|
||||
CHAR_LPAR: 'etp-lpar', # ` (` -- левая открывающая скобка
|
||||
CHAR_LSQB: 'etp-lsqb', # ` [` -- левая открывающая квадратная скобка
|
||||
CHAR_LCUB: 'etp-lcub', # ` {` -- левая открывающая фигурная скобка
|
||||
},
|
||||
HANGING_PUNCTUATION_MODE_RIGHT: {
|
||||
# Правая пунктуация: все классы начинаются с 'etp-r'
|
||||
CHAR_RU_QUOT1_CLOSE: 'etp-raquo', # `» ` -- правая закрывающая кавычка-ёлочка
|
||||
CHAR_EN_QUOT1_CLOSE: 'etp-rdquo', # `” ` -- правая закрывающая кавычка-лапка
|
||||
CHAR_EN_QUOT2_CLOSE: 'etp-rsquo', # `’ ` -- правая закрывающая кавычка-апостроф (одинарная)
|
||||
CHAR_RPAR: 'etp-rpar', # `) ` -- правая закрывающая скобка
|
||||
CHAR_RSQB: 'etp-rsqb', # `] ` -- правая закрывающая квадратная скобка
|
||||
CHAR_RCUB: 'etp-rcub', # `} ` -- правая закрывающая фигурная скобка
|
||||
CHAR_DOT: 'etp-r-dot', # `. ` -- точка (обычно в конце предложения и висит справа)
|
||||
CHAR_COMMA: 'etp-r-comma', # `, ` -- запятая (обычно висит справа)
|
||||
CHAR_COLON: 'etp-r-colon', # `: ` -- двоеточие (обычно висит справа)
|
||||
},
|
||||
}
|
||||
HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT = {
|
||||
**HANGING_PUNCTUATION_SYMBOLS_CLASSES[HANGING_PUNCTUATION_MODE_LEFT],
|
||||
**HANGING_PUNCTUATION_SYMBOLS_CLASSES[HANGING_PUNCTUATION_MODE_RIGHT],
|
||||
}
|
||||
|
||||
# 4. Словарь, сопоставляющий классам висячей пунктуации классы для компенсационных пробелов
|
||||
HANGING_PUNCTUATION_SPACE_CLASSES = {
|
||||
HANGING_PUNCTUATION_MODE_LEFT: {
|
||||
# Для левой пунктуации (компенсационный пробел слева от висячей пунктуации)
|
||||
CHAR_RU_QUOT1_OPEN: 'etp-sp-laquo', # ` «` -- для пробела пред открывающей кавычкой-ёлочкой
|
||||
CHAR_EN_QUOT1_OPEN: 'etp-sp-ldquo', # ` “` -- для пробела пред открывающей кавычкой-лапкой
|
||||
CHAR_EN_QUOT2_OPEN: 'etp-sp-lsquo', # ` ‘` -- для пробела пред открывающей кавычкой-апострофом (одинарной)
|
||||
CHAR_LPAR: 'etp-sp-lpar', # ` (` -- для пробела пред левой открывающей скобкой
|
||||
CHAR_LSQB: 'etp-sp-lsqb', # ` [` -- для пробела пред левой открывающей квадратной скобкой
|
||||
CHAR_LCUB: 'etp-sp-lcub', # ` {` -- для пробела пред левой открывающей фигурной скобкой
|
||||
},
|
||||
HANGING_PUNCTUATION_MODE_RIGHT: {
|
||||
# Для правой пунктуации (компенсационный пробел справа от висячей пунктуации)
|
||||
CHAR_RU_QUOT1_CLOSE: 'etp-sp-raquo', # `» ` -- для пробела после закрывающей кавычки-ёлочки
|
||||
CHAR_EN_QUOT1_CLOSE: 'etp-sp-rdquo', # `” ` -- для пробела после закрывающей кавычки-лапки
|
||||
CHAR_EN_QUOT2_CLOSE: 'etp-sp-rsquo', # `’ ` -- для пробела после закрывающей кавычки-апострофом (одинарной)
|
||||
CHAR_RPAR: 'etp-sp-rpar', # `) ` -- для пробела после правой закрывающей скобки
|
||||
CHAR_RSQB: 'etp-sp-rsqb', # `] ` -- для пробела после правой закрывающей квадратной скобки
|
||||
CHAR_RCUB: 'etp-sp-rcub', # `} ` -- для пробела после правой закрывающей фигурной скобки
|
||||
CHAR_DOT: 'etp-sp-r-dot', # `. ` -- для пробела после точки
|
||||
CHAR_COMMA: 'etp-sp-r-comma', # `, ` -- для пробела после запятой
|
||||
CHAR_COLON: 'etp-sp-r-colon', # `: ` -- для пробела после двоеточия
|
||||
},
|
||||
}
|
||||
|
||||
# 5. Набор пробелов (неразрывные) которые ОТМЕНЯЮТ висячую пунктуацию у прилегающего символа. Т.к. это неразрывный
|
||||
# пробел, то символ не может "висеть" в принципе, он "прилеплен" к соседу и не может от него отрываться
|
||||
HANGING_CANCELLATION_SP = frozenset([
|
||||
CHAR_NBSP, # неразрывный пробел ( )
|
||||
CHAR_ZWNJ, # нулевой неразрывный пробел (zero width non-joiner, ‌)
|
||||
CHAR_THIN_NBSP, # узкий неразрывной пробел (narrow no-break space)
|
||||
])
|
||||
|
||||
HANGING_PUNCTUATION_SPACE_CLASSES_FLAT = {
|
||||
**HANGING_PUNCTUATION_SPACE_CLASSES['left'],
|
||||
**HANGING_PUNCTUATION_SPACE_CLASSES['right'],
|
||||
}
|
||||
|
||||
HANGING_PUNCTUATION_CLASSES = {
|
||||
**HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT,
|
||||
**HANGING_PUNCTUATION_SPACE_CLASSES_FLAT,
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
import logging
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
from .config import (
|
||||
HANGING_PUNCTUATION_CHARS,
|
||||
HANGING_PUNCTUATION_LEFT_CHARS,
|
||||
HANGING_PUNCTUATION_RIGHT_CHARS,
|
||||
HANGING_PUNCTUATION_CLASSES,
|
||||
HANGING_PUNCTUATION_MODE_LEFT,
|
||||
HANGING_PUNCTUATION_MODE_RIGHT,
|
||||
HANGING_PUNCTUATION_RIGHT_CHARS,
|
||||
HANGING_PUNCTUATION_SPACE_CHARS,
|
||||
HANGING_PUNCTUATION_SPACE_CLASSES,
|
||||
HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT,
|
||||
HANGING_CANCELLATION_SP,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,7 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class HangingPunctuationProcessor:
|
||||
"""
|
||||
Оборачивает символы висячей пунктуации в специальные теги <span> с классами.
|
||||
Оборачивает символы висячей пунктуации в теги <span> с классами.
|
||||
"""
|
||||
|
||||
def __init__(self, mode: str | bool | list[str] | None = None):
|
||||
@@ -32,22 +36,27 @@ class HangingPunctuationProcessor:
|
||||
self.mode = mode
|
||||
self.target_tags = None
|
||||
self.active_chars = set()
|
||||
self.space_classes = {}
|
||||
self.direction = None
|
||||
self.left_chars = HANGING_PUNCTUATION_LEFT_CHARS
|
||||
self.right_chars = HANGING_PUNCTUATION_RIGHT_CHARS
|
||||
# Сначала определяем, какие режимы и классы активны (левый, правый или оба) и подгружаем символы.
|
||||
|
||||
if isinstance(mode, list):
|
||||
self.target_tags = set(t.lower() for t in mode)
|
||||
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
|
||||
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
|
||||
directions = (HANGING_PUNCTUATION_MODE_LEFT, HANGING_PUNCTUATION_MODE_RIGHT)
|
||||
else:
|
||||
normalized_mode = HANGING_PUNCTUATION_MODE_LEFT if mode is True else mode
|
||||
if normalized_mode == HANGING_PUNCTUATION_MODE_LEFT:
|
||||
self.active_chars.update(HANGING_PUNCTUATION_LEFT_CHARS)
|
||||
elif normalized_mode == HANGING_PUNCTUATION_MODE_RIGHT:
|
||||
self.active_chars.update(HANGING_PUNCTUATION_RIGHT_CHARS)
|
||||
directions = (normalized_mode,) if normalized_mode in (HANGING_PUNCTUATION_MODE_LEFT, HANGING_PUNCTUATION_MODE_RIGHT) else ()
|
||||
self.direction = normalized_mode
|
||||
|
||||
for direction in directions:
|
||||
self.active_chars.update(HANGING_PUNCTUATION_CHARS.get(direction, frozenset()))
|
||||
self.space_classes.update(HANGING_PUNCTUATION_SPACE_CLASSES.get(direction, {}))
|
||||
|
||||
# Предварительно фильтруем карту классов, оставляя только активные символы
|
||||
self.char_to_class = {
|
||||
char: cls
|
||||
for char, cls in HANGING_PUNCTUATION_CLASSES.items()
|
||||
for char, cls in HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT.items()
|
||||
if char in self.active_chars
|
||||
}
|
||||
|
||||
@@ -76,26 +85,50 @@ class HangingPunctuationProcessor:
|
||||
return soup
|
||||
|
||||
def _process_node_recursive(self, node, soup):
|
||||
"""
|
||||
Рекурсивно обходит узлы. Если находит NavigableString с нужными символами,
|
||||
разбивает его и вставляет span'ы.
|
||||
"""Рекурсивно обходит дерево HTML-узлов.
|
||||
|
||||
:param node: Текущий узел, внутри которого ищем висячие символы.
|
||||
:param soup: Корневой объект soup, нужный для создания новых тегов.
|
||||
:return: None — модификации происходят "на месте".
|
||||
"""
|
||||
# Работаем с копией списка детей, так как будем менять структуру дерева на лету
|
||||
# (replace_with меняет дерево)
|
||||
if hasattr(node, 'children'):
|
||||
for child in list(node.children):
|
||||
if getattr(child, 'parent', None) is None:
|
||||
# Узел уже был заменён/удалён при обработке соседей
|
||||
continue
|
||||
if isinstance(child, NavigableString):
|
||||
# Обрабатываем текстовые узлы отдельно согласно выбранному режиму
|
||||
self._process_text_node(child, soup)
|
||||
elif isinstance(child, Tag):
|
||||
if self.direction == HANGING_PUNCTUATION_MODE_LEFT:
|
||||
first_left = self._find_first_left_char_in_node(child)
|
||||
if first_left:
|
||||
self._wrap_parent_space_before_child(child, first_left, soup)
|
||||
# Не заходим внутрь тегов, которые мы сами же и создали (или аналогичных),
|
||||
# чтобы избежать рекурсивного ада, хотя классы у нас специфичные.
|
||||
self._process_node_recursive(child, soup)
|
||||
if self.direction == HANGING_PUNCTUATION_MODE_RIGHT:
|
||||
last_right = self._find_last_right_char_in_node(child)
|
||||
if last_right:
|
||||
self._wrap_parent_space_after_child(child, last_right, soup)
|
||||
|
||||
def _process_text_node(self, text_node: NavigableString, soup: BeautifulSoup):
|
||||
"""Обрабатывает текстовый узел в зависимости от направления висячей пунктуации.
|
||||
|
||||
:param text_node: Текстовый узел BeautifulSoup, содержащий собранный текст.
|
||||
:param soup: Объект парсера для создания новых тегов.
|
||||
:return: None — текст заменяется на набор узлов/тегов.
|
||||
"""
|
||||
Анализирует текстовый узел. Если в нем есть символы для висячей пунктуации,
|
||||
заменяет узел на фрагмент (список узлов), где эти символы обернуты в span.
|
||||
"""
|
||||
if self.direction == HANGING_PUNCTUATION_MODE_LEFT:
|
||||
self._process_text_node_left_mode(text_node, soup)
|
||||
return
|
||||
|
||||
if self.direction == HANGING_PUNCTUATION_MODE_RIGHT:
|
||||
self._process_text_node_right_mode(text_node, soup)
|
||||
return
|
||||
|
||||
text = str(text_node)
|
||||
|
||||
# Быстрая проверка: если в тексте вообще нет ни одного нашего символа, выходим
|
||||
@@ -112,54 +145,496 @@ class HangingPunctuationProcessor:
|
||||
should_hang = False
|
||||
|
||||
# Проверяем контекст (пробелы или другие висячие символы вокруг)
|
||||
if char in HANGING_PUNCTUATION_LEFT_CHARS:
|
||||
# Левая пунктуация:
|
||||
# 1. Начало узла
|
||||
# 2. Перед ней пробел
|
||||
# 3. Перед ней другой левый висячий символ (например, "((text")
|
||||
if char in self.left_chars:
|
||||
if (i == 0 or
|
||||
text[i-1].isspace() or
|
||||
text[i-1] in HANGING_PUNCTUATION_LEFT_CHARS):
|
||||
text[i-1] in self.left_chars):
|
||||
should_hang = True
|
||||
elif char in HANGING_PUNCTUATION_RIGHT_CHARS:
|
||||
# Правая пунктуация:
|
||||
# 1. Конец узла
|
||||
# 2. После нее пробел
|
||||
# 3. После нее другой правый висячий символ (например, "text.»")
|
||||
elif char in self.right_chars:
|
||||
if (i == text_len - 1 or
|
||||
text[i+1].isspace() or
|
||||
text[i+1] in HANGING_PUNCTUATION_RIGHT_CHARS):
|
||||
text[i+1] in self.right_chars):
|
||||
should_hang = True
|
||||
|
||||
if should_hang:
|
||||
# 1. Сбрасываем накопленный буфер текста (если есть)
|
||||
if current_text_buffer:
|
||||
new_nodes.append(NavigableString(current_text_buffer))
|
||||
current_text_buffer = ""
|
||||
|
||||
# 2. Создаем span для висячего символа
|
||||
span = soup.new_tag("span")
|
||||
span['class'] = self.char_to_class[char]
|
||||
span.string = char
|
||||
new_nodes.append(span)
|
||||
else:
|
||||
# Если контекст не подходит, оставляем символ как обычный текст
|
||||
current_text_buffer += char
|
||||
else:
|
||||
# Просто накапливаем символ
|
||||
current_text_buffer += char
|
||||
|
||||
# Добавляем остаток буфера
|
||||
if current_text_buffer:
|
||||
new_nodes.append(NavigableString(current_text_buffer))
|
||||
|
||||
# Заменяем исходный текстовый узел на набор новых узлов.
|
||||
if new_nodes:
|
||||
first_node = new_nodes[0]
|
||||
text_node.replace_with(first_node)
|
||||
|
||||
# Остальные вставляем последовательно после первого
|
||||
current_pos = first_node
|
||||
for next_node in new_nodes[1:]:
|
||||
current_pos.insert_after(next_node)
|
||||
current_pos = next_node
|
||||
|
||||
def _process_text_node_left_mode(self, text_node: NavigableString, soup: BeautifulSoup):
|
||||
"""Реализует алгоритм обёртки для левого режима висячей пунктуации.
|
||||
|
||||
Пробегает по тексту, захватывая слово и оборачивая его вместе с левой кавычкой,
|
||||
добавляя компенсационные пробелы, когда они есть.
|
||||
|
||||
:param text_node: Текстовый узел, где устанавливаются span-ы.
|
||||
:param soup: Парсер для создания span-обёрток.
|
||||
:return: None — изменяется дерево DOM.
|
||||
"""
|
||||
text = str(text_node)
|
||||
if not any(char in self.left_chars for char in text):
|
||||
return
|
||||
|
||||
nodes: list[NavigableString | Tag] = []
|
||||
text_len = len(text)
|
||||
boundary_chars = HANGING_PUNCTUATION_SPACE_CHARS
|
||||
cancellation_chars = HANGING_CANCELLATION_SP
|
||||
last_index = 0
|
||||
i = 0
|
||||
|
||||
while i < text_len:
|
||||
char = text[i]
|
||||
if char not in self.left_chars:
|
||||
# Пропускаем любые символы, которые не участвуют в левом висении
|
||||
i += 1
|
||||
continue
|
||||
|
||||
prev_char = self._peek_previous_char_before_text_node(text_node, i)
|
||||
if prev_char and self._is_word_char(prev_char):
|
||||
# Если перед левым символом идёт буква/цифра/подчёркивание — это не висячий символ внутри слова
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if i > 0 and text[i-1] in cancellation_chars:
|
||||
# Если перед символом стоит запрещённый неразрывной пробел — пропускаем
|
||||
i += 1
|
||||
continue
|
||||
|
||||
comp_bounds = self._locate_left_compensation_bounds(text, i, last_index,
|
||||
boundary_chars, cancellation_chars)
|
||||
if comp_bounds:
|
||||
comp_start, comp_end = comp_bounds
|
||||
if comp_start > last_index:
|
||||
nodes.append(NavigableString(text[last_index:comp_start]))
|
||||
space_span = soup.new_tag('span')
|
||||
space_span['class'] = self.space_classes.get(char, '')
|
||||
space_span.string = text[comp_start:comp_end]
|
||||
nodes.append(space_span)
|
||||
last_index = comp_end
|
||||
elif last_index < i:
|
||||
# Добавляем буфер текста между последней обёрткой и новой левым кавычкой
|
||||
nodes.append(NavigableString(text[last_index:i]))
|
||||
last_index = i
|
||||
|
||||
span_start = last_index
|
||||
span_end = span_start
|
||||
while span_end < text_len and text[span_end] not in boundary_chars:
|
||||
span_end += 1
|
||||
if span_end == span_start:
|
||||
span_end = span_start + 1
|
||||
|
||||
left_span = soup.new_tag('span')
|
||||
left_span['class'] = self.char_to_class.get(char, '')
|
||||
left_span.string = text[span_start:span_end]
|
||||
nodes.append(left_span)
|
||||
last_index = span_end
|
||||
i = span_end
|
||||
|
||||
if last_index < text_len:
|
||||
nodes.append(NavigableString(text[last_index:]))
|
||||
|
||||
if nodes:
|
||||
first = nodes[0]
|
||||
text_node.replace_with(first)
|
||||
current = first
|
||||
for part in nodes[1:]:
|
||||
current.insert_after(part)
|
||||
current = part
|
||||
|
||||
def _locate_left_compensation_bounds(self, text: str, left_idx: int, last_idx: int,
|
||||
boundary_chars: frozenset[str], cancellation_chars: frozenset[str]) -> tuple[int, int] | None:
|
||||
"""Находит диапазон (слово + пробел), который нужно обернуть перед левым символом.
|
||||
|
||||
:param text: Строка, из которой извлекаются границы.
|
||||
:param left_idx: Индекс левой кавычки.
|
||||
:param last_idx: Последний обработанный индекс — не повторяем участки.
|
||||
:param boundary_chars: Набор символов, разрешённых для разрывов.
|
||||
:param cancellation_chars: Символы, отменяющие висячие привязки.
|
||||
:return: Кортеж (start, end) или None, если приращение не нужно.
|
||||
"""
|
||||
if left_idx == 0:
|
||||
return None
|
||||
prev_idx = left_idx - 1
|
||||
prev_char = text[prev_idx]
|
||||
if prev_char not in boundary_chars or prev_char in cancellation_chars:
|
||||
return None
|
||||
|
||||
# Перемещаемся назад по строке, чтобы найти цепочку пробелов перед словом
|
||||
space_end = prev_idx
|
||||
while space_end > last_idx and text[space_end - 1] in boundary_chars:
|
||||
space_end -= 1
|
||||
|
||||
start = space_end
|
||||
while start > last_idx and text[start - 1] not in boundary_chars:
|
||||
start -= 1
|
||||
|
||||
return (start, left_idx) if start < left_idx else None
|
||||
|
||||
def _find_first_left_char_in_node(self, node: Tag) -> str | None:
|
||||
"""Ищет первый символ левого режима во вложенном поддереве.
|
||||
|
||||
:param node: Тег, внутри которого итерируем по потомкам.
|
||||
:return: Символ из `left_chars` или None.
|
||||
"""
|
||||
for descendant in node.descendants:
|
||||
if isinstance(descendant, NavigableString):
|
||||
for char in str(descendant):
|
||||
if char.isspace():
|
||||
continue
|
||||
if char in self.left_chars:
|
||||
return char
|
||||
return None
|
||||
return None
|
||||
|
||||
def _wrap_parent_space_before_child(self, child: Tag, first_left_char: str, soup: BeautifulSoup) -> bool:
|
||||
"""Оборачивает слово + пробел перед дочерним узлом у родителя.
|
||||
|
||||
:param child: Дочерний тег, который начал с висячего символа.
|
||||
:param first_left_char: Символ, наложивший необходимость обёртки.
|
||||
:param soup: Объект парсера для span-ов.
|
||||
:return: True, если обёртка добавлена; False — иначе.
|
||||
"""
|
||||
prev_text_node = self._find_previous_navigable_text(child)
|
||||
if not prev_text_node:
|
||||
return False
|
||||
|
||||
fragment = self._extract_trailing_compensation_fragment(str(prev_text_node))
|
||||
if not fragment:
|
||||
return False
|
||||
|
||||
text_start, text_end = fragment
|
||||
substring = str(prev_text_node)[text_start:text_end]
|
||||
head = str(prev_text_node)[:text_start]
|
||||
css_class = self.space_classes.get(first_left_char)
|
||||
if not css_class or not substring:
|
||||
return False
|
||||
|
||||
span = soup.new_tag('span')
|
||||
span['class'] = css_class
|
||||
span.string = substring
|
||||
|
||||
new_nodes: list[NavigableString | Tag] = []
|
||||
if head:
|
||||
new_nodes.append(NavigableString(head))
|
||||
new_nodes.append(span)
|
||||
|
||||
self._replace_text_node(prev_text_node, new_nodes)
|
||||
return True
|
||||
|
||||
def _extract_trailing_compensation_fragment(self, text: str) -> tuple[int, int] | None:
|
||||
"""Извлекает диапазон слова с последним пробелом перед дочерним узлом.
|
||||
|
||||
:param text: Текущий текст узла, последний символ которого — потенциальный пробел.
|
||||
:return: Кортеж (start, end) или None, если для компенсации нет подходящего фрагмента.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
boundary_chars = HANGING_PUNCTUATION_SPACE_CHARS
|
||||
end = len(text)
|
||||
if text[end - 1] not in boundary_chars:
|
||||
return None
|
||||
|
||||
start = end
|
||||
while start > 0 and text[start - 1] in boundary_chars:
|
||||
# Отступаем до конца последовательности пробельных символов
|
||||
start -= 1
|
||||
|
||||
word_start = start
|
||||
while word_start > 0 and text[word_start - 1] not in boundary_chars:
|
||||
# Продолжаем вверх до начала последнего слова перед пробелами
|
||||
word_start -= 1
|
||||
|
||||
return (word_start, end) if word_start < end else None
|
||||
|
||||
def _find_previous_navigable_text(self, child: Tag) -> NavigableString | None:
|
||||
"""Возвращает предыдущий текстовый узел, если он содержит непустой текст."""
|
||||
prev = child.previous_sibling
|
||||
while prev:
|
||||
if isinstance(prev, NavigableString) and prev.strip():
|
||||
return prev
|
||||
prev = prev.previous_sibling
|
||||
return None
|
||||
|
||||
def _replace_text_node(self, old_node: NavigableString, new_nodes: list[NavigableString | Tag]) -> None:
|
||||
"""Заменяет текстовый узел на последовательность новых узлов."""
|
||||
if not new_nodes:
|
||||
old_node.extract()
|
||||
return
|
||||
first = new_nodes[0]
|
||||
old_node.replace_with(first)
|
||||
current = first
|
||||
for node in new_nodes[1:]:
|
||||
current.insert_after(node)
|
||||
current = node
|
||||
|
||||
def _find_last_right_char_in_node(self, node: Tag) -> str | None:
|
||||
"""Ищет последний символ правого режима во вложенном поддереве."""
|
||||
last_char = None
|
||||
for descendant in node.descendants:
|
||||
if isinstance(descendant, NavigableString):
|
||||
for char in str(descendant):
|
||||
if char in self.right_chars:
|
||||
last_char = char
|
||||
return last_char
|
||||
|
||||
def _wrap_parent_space_after_child(self, child: Tag, last_right_char: str, soup: BeautifulSoup) -> bool:
|
||||
"""Оборачивает пробел после дочернего узла у родителя."""
|
||||
next_text_node = self._find_next_navigable_text(child)
|
||||
if not next_text_node:
|
||||
return False
|
||||
|
||||
fragment = self._extract_leading_compensation_fragment(str(next_text_node), 0, HANGING_PUNCTUATION_SPACE_CHARS)
|
||||
if not fragment:
|
||||
return False
|
||||
|
||||
text_start, text_end = fragment
|
||||
substring = str(next_text_node)[text_start:text_end]
|
||||
tail = str(next_text_node)[text_end:]
|
||||
css_class = self.space_classes.get(last_right_char)
|
||||
if not css_class or not substring:
|
||||
return False
|
||||
|
||||
span = soup.new_tag('span')
|
||||
span['class'] = css_class
|
||||
span.string = substring
|
||||
|
||||
new_nodes: list[NavigableString | Tag] = [span]
|
||||
if tail:
|
||||
new_nodes.append(NavigableString(tail))
|
||||
|
||||
self._replace_text_node(next_text_node, new_nodes)
|
||||
# Если после обёртки остался текстовый хвост, догоним его обработкой правого режима.
|
||||
if self.direction == HANGING_PUNCTUATION_MODE_RIGHT and new_nodes and isinstance(new_nodes[-1], NavigableString):
|
||||
self._process_text_node(new_nodes[-1], soup)
|
||||
return True
|
||||
|
||||
def _extract_leading_compensation_fragment(self, text: str, start_idx: int,
|
||||
boundary_chars: frozenset[str]) -> tuple[int, int] | None:
|
||||
"""Извлекает диапазон пробелов, начинающийся с заданного индекса."""
|
||||
if start_idx >= len(text) or text[start_idx] not in boundary_chars:
|
||||
return None
|
||||
|
||||
end = start_idx
|
||||
while end < len(text) and text[end] in boundary_chars:
|
||||
end += 1
|
||||
|
||||
return (start_idx, end) if end > start_idx else None
|
||||
|
||||
def _find_next_navigable_text(self, child: Tag) -> NavigableString | None:
|
||||
"""Возвращает следующий текстовый узел, если он содержит непустой текст."""
|
||||
next = child.next_sibling
|
||||
while next:
|
||||
if isinstance(next, NavigableString) and next.strip():
|
||||
return next
|
||||
next = next.next_sibling
|
||||
return None
|
||||
|
||||
|
||||
def _process_text_node_right_mode(self, text_node: NavigableString, soup: BeautifulSoup):
|
||||
"""Аналогичный левому алгоритм, но в правом направлении.
|
||||
|
||||
:param text_node: Текстовый узел, содержащий возможную правую пунктуацию.
|
||||
:param soup: Парсер для создания тегов.
|
||||
:return: None.
|
||||
"""
|
||||
text = str(text_node)
|
||||
if not any(char in self.right_chars for char in text):
|
||||
return
|
||||
|
||||
nodes: list[NavigableString | Tag] = []
|
||||
text_len = len(text)
|
||||
boundary_chars = HANGING_PUNCTUATION_SPACE_CHARS
|
||||
cancellation_chars = HANGING_CANCELLATION_SP
|
||||
last_index = 0
|
||||
i = 0
|
||||
right_spans: list[tuple[Tag, str]] = []
|
||||
|
||||
while i < text_len:
|
||||
char = text[i]
|
||||
if char not in self.right_chars:
|
||||
# Пропускаем нецелевые символы
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if i > 0 and text[i-1] in cancellation_chars:
|
||||
# Не трогаем коль символ окружён запретными символами
|
||||
i += 1
|
||||
continue
|
||||
|
||||
prev_char = self._peek_previous_char_before_text_node(text_node, i)
|
||||
if prev_char in cancellation_chars:
|
||||
# Учёт запретных символов даже если они находятся в соседнем узле слева от текущей позиции.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
next_char = self._peek_next_char_after_text_node(text_node, i)
|
||||
if next_char in cancellation_chars:
|
||||
# Пропускаем висячий символ, если сразу после него идёт запретный символ в любом соседнем узле
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if self._is_word_char(next_char):
|
||||
# Не оборачиваем символы внутри слов/идентификаторов и цифр
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Находим диапазон слова, которое должно соединиться с текущей правой пунктуацией.
|
||||
comp_bounds = self._locate_right_compensation_bounds(text, i, last_index, boundary_chars)
|
||||
if not comp_bounds:
|
||||
# Нет слова в текущем узле (граница узла или символ стоит первым) — оборачиваем сам символ
|
||||
if i > last_index:
|
||||
nodes.append(NavigableString(text[last_index:i]))
|
||||
|
||||
solo_span = soup.new_tag('span')
|
||||
solo_span['class'] = self.char_to_class.get(char, '')
|
||||
solo_span.string = char
|
||||
nodes.append(solo_span)
|
||||
right_spans.append((solo_span, char))
|
||||
last_index = i + 1
|
||||
|
||||
boundary_fragment = self._extract_leading_compensation_fragment(text, last_index, boundary_chars)
|
||||
if boundary_fragment:
|
||||
space_start, space_end = boundary_fragment
|
||||
space_span = soup.new_tag('span')
|
||||
space_span['class'] = self.space_classes.get(char, '')
|
||||
space_span.string = text[space_start:space_end]
|
||||
nodes.append(space_span)
|
||||
last_index = space_end
|
||||
i = space_end
|
||||
else:
|
||||
i = last_index
|
||||
continue
|
||||
|
||||
span_start, span_mid, right_idx = comp_bounds
|
||||
if span_start > last_index and text[span_start - 1] in self.left_chars:
|
||||
# Захватываем прилегающую слева левую пунктуацию вместе со словом
|
||||
span_start -= 1
|
||||
|
||||
if span_start > last_index:
|
||||
# Вставляем текст между предыдущим фрагментом и текущим словом, чтобы ничего не потерять.
|
||||
nodes.append(NavigableString(text[last_index:span_start]))
|
||||
|
||||
right_span = soup.new_tag('span')
|
||||
right_span['class'] = self.char_to_class.get(char, '')
|
||||
# Объединяем слово перед правым символом и сам символ без промежуточных пробелов
|
||||
right_span.string = text[span_start:span_mid] + text[right_idx:right_idx + 1]
|
||||
nodes.append(right_span)
|
||||
right_spans.append((right_span, char))
|
||||
last_index = right_idx + 1
|
||||
|
||||
# После символа оборачиваем пробелы-компенсаторы в отдельные span'ы
|
||||
boundary_fragment = self._extract_leading_compensation_fragment(text, last_index, boundary_chars)
|
||||
if boundary_fragment:
|
||||
space_start, space_end = boundary_fragment
|
||||
space_span = soup.new_tag('span')
|
||||
space_span['class'] = self.space_classes.get(char, '')
|
||||
space_span.string = text[space_start:space_end]
|
||||
nodes.append(space_span)
|
||||
last_index = space_end
|
||||
i = space_end
|
||||
else:
|
||||
# Продолжаем сканирование сразу после обработанного правого символа.
|
||||
i = last_index
|
||||
|
||||
if last_index < text_len:
|
||||
nodes.append(NavigableString(text[last_index:]))
|
||||
|
||||
if nodes:
|
||||
first = nodes[0]
|
||||
text_node.replace_with(first)
|
||||
current = first
|
||||
for part in nodes[1:]:
|
||||
current.insert_after(part)
|
||||
current = part
|
||||
for span_node, span_char in right_spans:
|
||||
self._wrap_parent_space_after_child(span_node, span_char, soup)
|
||||
|
||||
def _peek_next_char_after_text_node(self, text_node: NavigableString, idx: int) -> str | None:
|
||||
"""Ищет следующий символ текста, переходя через границы узлов."""
|
||||
node = text_node
|
||||
offset = idx + 1
|
||||
while node:
|
||||
if isinstance(node, NavigableString):
|
||||
text = str(node)
|
||||
if offset < len(text):
|
||||
return text[offset]
|
||||
offset = 0
|
||||
node = node.next_element
|
||||
return None
|
||||
|
||||
def _peek_previous_char_before_text_node(self, text_node: NavigableString, idx: int) -> str | None:
|
||||
"""Ищет предыдущий символ текста перед текущим индексом, переходя между узлами."""
|
||||
if idx > 0:
|
||||
return str(text_node)[idx - 1]
|
||||
|
||||
node = text_node.previous_element
|
||||
while node:
|
||||
if isinstance(node, NavigableString):
|
||||
text = str(node)
|
||||
if text:
|
||||
return text[-1]
|
||||
node = node.previous_element
|
||||
return None
|
||||
|
||||
def _is_word_char(self, char: str | None) -> bool:
|
||||
"""Возвращает True, если браузер не может разорвать строку именно по этому символу."""
|
||||
return bool(char) and char not in HANGING_PUNCTUATION_SPACE_CHARS
|
||||
|
||||
def _locate_right_compensation_bounds(self, text: str, right_idx: int, last_idx: int,
|
||||
boundary_chars: frozenset[str]) -> tuple[int, int, int] | None:
|
||||
"""Находит пределы слова перед правым символом и сам символ без учета пробелов.
|
||||
|
||||
:param text: Строка, из которой извлекаются границы.
|
||||
:param right_idx: Индекс правого символа (например, '»').
|
||||
:param last_idx: Последний обработанный индекс — чтобы не перекрывать уже взятые фрагменты.
|
||||
:param boundary_chars: Набор символов, разрешённых для разделения слов.
|
||||
:return: Кортеж (start_of_word, boundary_start, right_idx) или None, если нет подходящего слова.
|
||||
"""
|
||||
if right_idx <= last_idx:
|
||||
return None
|
||||
|
||||
prev_idx = right_idx - 1
|
||||
# Отбрасываем разделители между словом и символом, чтобы добраться до последнего слова.
|
||||
while prev_idx >= last_idx and text[prev_idx] in boundary_chars:
|
||||
prev_idx -= 1
|
||||
|
||||
if prev_idx < last_idx:
|
||||
return None
|
||||
|
||||
word_end = prev_idx + 1
|
||||
word_start = word_end
|
||||
# Поднимаемся к началу слова, чтобы взять все символы перед правым знаком.
|
||||
stopper = boundary_chars | (self.right_chars - {text[right_idx]})
|
||||
while word_start > last_idx and text[word_start - 1] not in stopper:
|
||||
word_start -= 1
|
||||
|
||||
# Разрешаем захватить ведущий левый символ, если он стоит вплотную к слову.
|
||||
while word_start > last_idx and text[word_start - 1] in self.left_chars:
|
||||
word_start -= 1
|
||||
|
||||
if word_start == word_end:
|
||||
# Если перед символом нет слова, ничего оборачивать не нужно.
|
||||
return None
|
||||
|
||||
return (word_start, word_end, right_idx)
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
import logging
|
||||
from bs4 import BeautifulSoup
|
||||
from .config import (SANITIZE_ALL_HTML, SANITIZE_ETPGRF, SANITIZE_NONE,
|
||||
HANGING_PUNCTUATION_CLASSES, PROTECTED_HTML_TAGS)
|
||||
PROTECTED_HTML_TAGS,
|
||||
HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT,
|
||||
HANGING_PUNCTUATION_SPACE_CLASSES_FLAT,
|
||||
CHARS_SYMBOLS_TO_BAN)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,8 +30,10 @@ class SanitizerProcessor:
|
||||
|
||||
# Оптимизация: заранее готовим CSS-селектор для поиска висячей пунктуации
|
||||
if self.mode == SANITIZE_ETPGRF:
|
||||
# Собираем уникальные классы
|
||||
unique_classes = sorted(list(frozenset(HANGING_PUNCTUATION_CLASSES.values())))
|
||||
# Собираем уникальные классы из отдельных коллекций (чтобы избежать пустого селектора)
|
||||
symbol_classes = set(HANGING_PUNCTUATION_SYMBOLS_CLASSES_FLAT.values())
|
||||
space_classes = set(HANGING_PUNCTUATION_SPACE_CLASSES_FLAT.values())
|
||||
unique_classes = sorted(symbol_classes | space_classes)
|
||||
# Формируем селектор вида: span.class1, span.class2, ...
|
||||
# Это позволяет использовать нативный парсер (lxml) для поиска, что намного быстрее python-лямбд.
|
||||
self._etp_selector = ", ".join(f"span.{cls}" for cls in unique_classes)
|
||||
@@ -46,6 +51,7 @@ class SanitizerProcessor:
|
||||
"""
|
||||
if self.mode == SANITIZE_ETPGRF:
|
||||
if not self._etp_selector:
|
||||
self._strip_banned_chars_from_soup(soup)
|
||||
return soup
|
||||
|
||||
# Используем CSS-селектор для быстрого поиска всех нужных элементов
|
||||
@@ -56,6 +62,7 @@ class SanitizerProcessor:
|
||||
for span in spans_to_clean:
|
||||
span.unwrap()
|
||||
|
||||
self._strip_banned_chars_from_soup(soup)
|
||||
return soup
|
||||
|
||||
elif self.mode == SANITIZE_ALL_HTML:
|
||||
@@ -70,7 +77,31 @@ class SanitizerProcessor:
|
||||
|
||||
# 2. Извлекаем чистый текст из оставшегося дерева.
|
||||
# get_text() работает на уровне C (в lxml) и намного быстрее ручного обхода.
|
||||
return soup.get_text()
|
||||
text = soup.get_text()
|
||||
return self._strip_banned_chars_from_string(text)
|
||||
|
||||
# Если режим не задан, ничего не делаем
|
||||
return soup
|
||||
|
||||
def _strip_banned_chars_from_soup(self, soup: BeautifulSoup) -> None:
|
||||
"""
|
||||
Удаляет запрещенные символы из всего содержимого soup-объекта.
|
||||
|
||||
:param soup: Объект BeautifulSoup для обработки.
|
||||
"""
|
||||
for element in soup.find_all(string=True):
|
||||
if isinstance(element, str):
|
||||
new_string = self._strip_banned_chars_from_string(element)
|
||||
element.replace_with(new_string)
|
||||
|
||||
def _strip_banned_chars_from_string(self, text: str) -> str:
|
||||
"""
|
||||
Удаляет запрещенные символы из строки.
|
||||
|
||||
:param text: Исходная строка.
|
||||
:return: Строка без запрещенных символов.
|
||||
"""
|
||||
# Удаляем все символы, которые есть в CHARS_SYMBOLS_TO_BAN
|
||||
for char in CHARS_SYMBOLS_TO_BAN:
|
||||
text = text.replace(char, "")
|
||||
return text
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import regex
|
||||
import logging
|
||||
from .config import CHAR_NDASH, STR_TO_SYMBOL_REPLACEMENTS
|
||||
from .config import CHAR_NDASH, CHAR_NBSP, CHAR_TIMES, STR_TO_SYMBOL_REPLACEMENTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +20,7 @@ class SymbolsProcessor:
|
||||
# Паттерн для диапазонов: цифра-дефис-цифра -> цифра–цифра (среднее тире).
|
||||
# Обрабатываем арабские и римские цифры.
|
||||
self._range_pattern = regex.compile(pattern=r'(\d)-(\d)|([IVXLCDM]+)-([IVXLCDM]+)', flags=regex.IGNORECASE)
|
||||
self._times_pattern = regex.compile(pattern=r'(?<=\d)(?P<pre>\s*)(?P<letter>[xхXХ])(?P<post>\s*)(?=\d)')
|
||||
|
||||
logger.debug("SymbolsProcessor `__init__`")
|
||||
|
||||
@@ -31,6 +32,14 @@ class SymbolsProcessor:
|
||||
return f'{match.group(3)}{CHAR_NDASH}{match.group(4)}'
|
||||
return match.group(0) # На всякий случай
|
||||
|
||||
def _replace_times(self, match: regex.Match) -> str:
|
||||
# Встраивает CHAR_TIMES между цифрами и защищает его от переноса
|
||||
pre = match.group('pre')
|
||||
post = match.group('post')
|
||||
before = CHAR_NBSP if pre else ''
|
||||
after = CHAR_NBSP if post else ''
|
||||
return f'{before}{CHAR_TIMES}{after}'
|
||||
|
||||
|
||||
def process(self, text: str) -> str:
|
||||
# Шаг 1: Выполняем простые замены из списка `STR_TO_SYMBOL_REPLACEMENTS` (см. config.py).
|
||||
@@ -45,6 +54,7 @@ class SymbolsProcessor:
|
||||
# Шаг 2: Обрабатываем диапазоны с помощью регулярного выражения.
|
||||
# Эта замена более специфична и требует контекста (цифры вокруг дефиса).
|
||||
processed_text = self._range_pattern.sub(self._replace_range, processed_text)
|
||||
processed_text = self._times_pattern.sub(self._replace_times, processed_text)
|
||||
|
||||
return processed_text
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "etpgrf"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6.post1"
|
||||
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,7 +12,7 @@ license = {text = "MIT"} # Или выбери другую лицензию
|
||||
authors = [
|
||||
{name = "Sergei Erjemin", email = "erjemin@gmail.com"},
|
||||
]
|
||||
keywords = ["typography", "html", "non-breaking-spaces", "hanging-punctuation", "hyphenation", "russian", "english"]
|
||||
keywords = ["typography", "html", "html-typography", "non-breaking-spaces", "hanging-punctuation", "hyphenation", "symbols", "russian", "english"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
@@ -31,10 +31,11 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://github.com/erjemin/etpgrf"
|
||||
"Homepage" = "https://typograph.cube2.ru/"
|
||||
"Bug Tracker" = "https://github.com/erjemin/etpgrf/issues"
|
||||
"Mirror1 (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf"
|
||||
"Mirror2 (Gitea Selfhosted)" = "https://git.cube2.ru/erjemin/2025-etpgrf"
|
||||
"Online Demo" = "https://typograph.cube2.ru/"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."] # Искать пакеты в корне (найдет папку etpgrf)
|
||||
|
||||
@@ -6,7 +6,13 @@ from bs4 import BeautifulSoup
|
||||
from etpgrf.hanging import HangingPunctuationProcessor
|
||||
from etpgrf.config import (
|
||||
CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE,
|
||||
CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE
|
||||
CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE,
|
||||
CHAR_LPAR, CHAR_LSQB, CHAR_LCUB,
|
||||
CHAR_RPAR, CHAR_RSQB, CHAR_RCUB,
|
||||
CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP,
|
||||
CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP,
|
||||
CHAR_NULL_SP, CHAR_THIN_NBSP, CHAR_NBSP,
|
||||
CHAR_ZWNJ, CHAR_PUNT_SP
|
||||
)
|
||||
|
||||
# Вспомогательная функция для создания soup
|
||||
@@ -16,19 +22,103 @@ def make_soup(html_str):
|
||||
# Набор тестовых случаев в формате:
|
||||
# (режим, входной_html, ожидаемый_html)
|
||||
HANGING_TEST_CASES = [
|
||||
# --- Режим 'left' (только левая пунктуация) ---
|
||||
# --- Режим 'left' (только левая висячая пунктуация) ---
|
||||
('left', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата{CHAR_RU_QUOT1_CLOSE}</p>'),
|
||||
('left', '<p>(Скобки)</p>', '<p><span class="etp-lpar">(</span>Скобки)</p>'),
|
||||
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</span></p>'),
|
||||
('left', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE} вначале текста</p>',
|
||||
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</span> вначале текста</p>'),
|
||||
('left', f'<p>А вот это {CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
|
||||
f'<p>А вот <span class="etp-sp-laquo">это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</span> внутри текста</p>'),
|
||||
('left', f'<p>А вот это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
|
||||
f'<p>А вот <span class="etp-sp-laquo">это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
|
||||
('left', f'<p>А вот это <b>{CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}</b> внутри текста</p>',
|
||||
f'<p>А вот <span class="etp-sp-laquo">это </span><b><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE}</b> внутри текста</p>'),\
|
||||
('left', f'<p>А вот это <b><i>{CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}</i></b> внутри текста</p>',
|
||||
f'<p>А вот <span class="etp-sp-laquo">это </span><b><i><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE}</i></b> внутри текста</p>'),
|
||||
('left', f'<p>А вот это {CHAR_RU_QUOT1_OPEN}<b>Длинная цитата</b>{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
|
||||
f'<p>А вот <span class="etp-sp-laquo">это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span><b>Длинная цитата</b>{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
|
||||
('left', f'<p>А вот{CHAR_NBSP}это {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
|
||||
f'<p>А <span class="etp-sp-laquo">вот{CHAR_NBSP}это </span><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Длинная</span> цитата{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
|
||||
# Английские кавычки "лапки"
|
||||
('left', f'<p>This is some {CHAR_EN_QUOT1_OPEN}<b>wisdom quote</b>{CHAR_EN_QUOT1_CLOSE} for the test.</p>',
|
||||
f'<p>This is <span class="etp-sp-ldquo">some </span><span class="etp-ldquo">{CHAR_EN_QUOT1_OPEN}</span><b>wisdom quote</b>{CHAR_EN_QUOT1_CLOSE} for the test.</p>'),
|
||||
# Неразрывные пробелы и символы, отменяющие перенос, не должны оборачиваться, но должны сохраняться в тексте
|
||||
('left', f'<p>Неразрывный пробел перед{CHAR_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
|
||||
f'<p>Неразрывный пробел перед{CHAR_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
|
||||
('left', f'<p>Неразрывный пробел перед{CHAR_ZWNJ}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
|
||||
f'<p>Неразрывный пробел перед{CHAR_ZWNJ}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
|
||||
('left', f'<p>Неразрывный пробел перед{CHAR_THIN_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста</p>',
|
||||
f'<p>Неразрывный пробел перед{CHAR_THIN_NBSP}{CHAR_RU_QUOT1_OPEN}Цитатой{CHAR_RU_QUOT1_CLOSE} внутри текста</p>'),
|
||||
# Примеры "круглая скобка"
|
||||
('left', '<p>(Скобки)</p>', '<p><span class="etp-lpar">(Скобки)</span></p>'),
|
||||
('left', '<p>Висячая пунктуация оборачивает (Скобки)</p>',
|
||||
'<p>Висячая пунктуация <span class="etp-sp-lpar">оборачивает </span><span class="etp-lpar">(Скобки)</span></p>'),
|
||||
('left', '<p>Висячая пунктуация оборачивает (Круглые скобки) вот так.</p>',
|
||||
'<p>Висячая пунктуация <span class="etp-sp-lpar">оборачивает </span><span class="etp-lpar">(Круглые</span> скобки) вот так.</p>'),
|
||||
('left', '<p>Перечисли, (элемент списка)</p>',
|
||||
'<p><span class="etp-sp-lpar">Перечисли, </span><span class="etp-lpar">(элемент</span> списка)</p>'),
|
||||
# Примеры "квадратная скобка"
|
||||
('left', '<p>[Скобки]</p>', '<p><span class="etp-lsqb">[Скобки]</span></p>'),
|
||||
('left', '<p>Висячая пунктуация оборачивает [Скобки]</p>',
|
||||
'<p>Висячая пунктуация <span class="etp-sp-lsqb">оборачивает </span><span class="etp-lsqb">[Скобки]</span></p>'),
|
||||
('left', '<p>Висячая пунктуация оборачивает [Квадратные скобки] вот так.</p>',
|
||||
'<p>Висячая пунктуация <span class="etp-sp-lsqb">оборачивает </span><span class="etp-lsqb">[Квадратные</span> скобки] вот так.</p>'),
|
||||
# Примеры "фигурная скобка"
|
||||
('left', '<p>{Скобки}</p>', '<p><span class="etp-lcub">{Скобки}</span></p>'),
|
||||
('left', '<p>Висячая пунктуация оборачивает {Скобки}</p>',
|
||||
'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает </span><span class="etp-lcub">{Скобки}</span></p>'),
|
||||
('left', '<p>Висячая пунктуация оборачивает {Фигурные скобки} вот так.</p>',
|
||||
'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает </span><span class="etp-lcub">{Фигурные</span> скобки} вот так.</p>'),
|
||||
# Обычный текст, в котором нет символов для висячей пунктуации, не должен изменяться
|
||||
('left', '<p>Текст.</p>', '<p>Текст.</p>'),
|
||||
('left', '<p>Пароль `89(fg#_Wfgq0[89`</p>', '<p>Пароль `89(fg#_Wfgq0[89`</p>'),
|
||||
# Проверка на альтернативный разрывной символ (не пробел)
|
||||
('left', '<p>Висячая пунктуация оборачивает\t{Скобки}</p>',
|
||||
'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает\t</span><span class="etp-lcub">{Скобки}</span></p>'),
|
||||
('left', f'<p>Висячая пунктуация оборачивает{CHAR_SHY}{{Скобки}}</p>',
|
||||
f'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает{CHAR_SHY}</span><span class="etp-lcub">{{Скобки}}</span></p>'),
|
||||
('left', f'<p>Висячая пунктуация оборачивает{CHAR_PUNT_SP}{{Скобки}}</p>',
|
||||
f'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает{CHAR_PUNT_SP}</span><span class="etp-lcub">{{Скобки}}</span></p>'),
|
||||
('left', f'<p>Висячая пунктуация оборачивает\n{{Скобки}}</p>',
|
||||
f'<p>Висячая пунктуация <span class="etp-sp-lcub">оборачивает\n</span><span class="etp-lcub">{{Скобки}}</span></p>'),
|
||||
('left', f'<p>Висячая пунктуация обора{CHAR_SHY}чивает {{Скобки}}</p>',
|
||||
f'<p>Висячая пунктуация обора{CHAR_SHY}<span class="etp-sp-lcub">чивает </span><span class="etp-lcub">{{Скобки}}</span></p>'),
|
||||
|
||||
# --- Режим 'right' (только правая пунктуация) ---
|
||||
('right', f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||
f'<p>{CHAR_RU_QUOT1_OPEN}Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
|
||||
('right', '<p>Текст.</p>', '<p>Текст<span class="etp-r-dot">.</span></p>'),
|
||||
('right', '<p>(Скобки)</p>', '<p>(Скобки<span class="etp-rpar">)</span></p>'),
|
||||
f'<p><span class="etp-raquo">{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</span></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>'),
|
||||
('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><span class="etp-sp-raquo"> </span>внутри текста</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><span class="etp-sp-raquo"> </span>внутри <span class="etp-r-dot">текста.</span></p>'),
|
||||
('right', f'<p>Right {CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE} within the text.</p>',
|
||||
f'<p>Right {CHAR_EN_QUOT1_OPEN}long <span class="etp-rdquo">quote{CHAR_EN_QUOT1_CLOSE}</span><span class="etp-sp-rdquo"> </span>within the <span class="etp-r-dot">text.</span></p>'),
|
||||
## С границами узлов
|
||||
('right', f'<p>Right {CHAR_EN_QUOT1_OPEN}<b>long quote</b>{CHAR_EN_QUOT1_CLOSE} within the text.</p>',
|
||||
f'<p>Right {CHAR_EN_QUOT1_OPEN}<b>long quote</b><span class="etp-rdquo">{CHAR_EN_QUOT1_CLOSE}</span><span class="etp-sp-rdquo"> </span>within the <span class="etp-r-dot">text.</span></p>'),
|
||||
('right', f'<p>Right <b>{CHAR_EN_QUOT1_OPEN}long quote{CHAR_EN_QUOT1_CLOSE}</b> within the text.</p>',
|
||||
f'<p>Right <b>{CHAR_EN_QUOT1_OPEN}long <span class="etp-rdquo">quote{CHAR_EN_QUOT1_CLOSE}</span></b><span class="etp-sp-rdquo"> </span>within the <span class="etp-r-dot">text.</span></p>'),
|
||||
# Неразрывные пробелы и символы, отменяющие перенос, не должны оборачиваться, но должны сохраняться в тексте
|
||||
('right', f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2</p>',
|
||||
f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_NBSP}внутри текста 2</p>'),
|
||||
('right', f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_ZWNJ}внутри текста 2</p>',
|
||||
f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_ZWNJ}внутри текста 2</p>'),
|
||||
('right', f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_THIN_NBSP}внутри текста 2</p>',
|
||||
f'<p>Правая {CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}{CHAR_THIN_NBSP}внутри текста 2</p>'),
|
||||
('right', f'<p>Правая <b>{CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}</b>{CHAR_NBSP}внутри текста 2</p>',
|
||||
f'<p>Правая <b>{CHAR_RU_QUOT1_OPEN}Длинная цитата{CHAR_RU_QUOT1_CLOSE}</b>{CHAR_NBSP}внутри текста 2</p>'),
|
||||
#('right', '<p>Текст.</p>', '<p>Текст<span class="etp-r-dot">.</span></p>'),
|
||||
#('right', '<p>(Скобки)</p>', '<p>(Скобки<span class="etp-rpar">)</span></p>'),
|
||||
('right', '<p>End.</p>', '<p><span class="etp-r-dot">End.</span></p>'),
|
||||
# Внутри цифр и текста висячие символы не должны оборачиваться, так как они не являются висячими в этом контексте
|
||||
('right', '<p>3.14</p>', '<p>3.14</p>'),
|
||||
('right', '<p>End.</p>', '<p>End<span class="etp-r-dot">.</span></p>'),
|
||||
('right', '3.14', '3.14'),
|
||||
('right', '<p>3,14</p>', '<p>3,14</p>'),
|
||||
('right', '<p>Переменная <code>self.right_chars</code></p>', '<p>Переменная <code>self.right_chars</code></p>'),
|
||||
('right', 'Переменная `self.right_chars`', 'Переменная `self.right_chars`'),
|
||||
|
||||
|
||||
# --- Режим None / False (отключено) ---
|
||||
(None, f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||
@@ -37,18 +127,6 @@ HANGING_TEST_CASES = [
|
||||
f'<p>{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'),
|
||||
]
|
||||
|
||||
# --- Режим list[str] (список тегов с обеими сторонами) ---
|
||||
HANGING_LIST_MODE_CASES = [
|
||||
(['p'], f'<p>{CHAR_RU_QUOT1_OPEN}Цитата{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||
f'<p><span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>Цитата<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
|
||||
(['p'], f'<p>Текст.{CHAR_RU_QUOT1_CLOSE}</p>',
|
||||
f'<p>Текст<span class="etp-r-dot">.</span><span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span></p>'),
|
||||
(['p'], f'<p>func {CHAR_RU_QUOT1_OPEN}arg</p>',
|
||||
f'<p>func <span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}</span>arg</p>'),
|
||||
(['p'], f'<p>arg{CHAR_RU_QUOT1_CLOSE} next</p>',
|
||||
f'<p>arg<span class="etp-raquo">{CHAR_RU_QUOT1_CLOSE}</span> next</p>'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode, input_html, expected_html", HANGING_TEST_CASES)
|
||||
def test_hanging_punctuation_processor(mode, input_html, expected_html):
|
||||
@@ -88,11 +166,60 @@ def test_hanging_punctuation_target_tags():
|
||||
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)
|
||||
LEFT_FULL_SYMBOLS = [
|
||||
(CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE, 'etp-laquo'),
|
||||
(CHAR_EN_QUOT1_OPEN, CHAR_EN_QUOT1_CLOSE, 'etp-ldquo'),
|
||||
(CHAR_LPAR, CHAR_RPAR, 'etp-lpar'),
|
||||
(CHAR_LSQB, CHAR_RSQB, 'etp-lsqb'),
|
||||
(CHAR_LCUB, CHAR_RCUB, 'etp-lcub'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("open_char, close_char, cls", LEFT_FULL_SYMBOLS)
|
||||
def test_hanging_left_mode_wraps_symbol_pairs(open_char, close_char, cls):
|
||||
"""
|
||||
Убедимся, что разные висячие символы полностью оборачиваются в левом режиме.
|
||||
|
||||
open_char: символ, открывающий висячий знак.
|
||||
close_char: символ, закрывающий висячий знак.
|
||||
cls: CSS класс для обёртки висячих знаков.
|
||||
|
||||
"""
|
||||
input_html = f'<p>{open_char}Текст{close_char}</p>'
|
||||
expected_html = f'<p><span class="{cls}">{open_char}Текст{close_char}</span></p>'
|
||||
processor = HangingPunctuationProcessor(mode='left')
|
||||
soup = make_soup(input_html)
|
||||
|
||||
processor.process(soup)
|
||||
assert str(soup) == expected_html
|
||||
|
||||
|
||||
SPACE_VARIANTS = [
|
||||
' ', CHAR_SHY, CHAR_THIN_SP, CHAR_HAIR_SP,
|
||||
CHAR_MED_SP, CHAR_EN_SP, CHAR_EM_SP, CHAR_NULL_SP,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("space", SPACE_VARIANTS)
|
||||
def test_hanging_left_mode_compensates_different_spaces(space):
|
||||
"""Проверяем компенсацию для разных типов разрывных пробелов."""
|
||||
text = f'<p>Проба{space}{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'
|
||||
expected_html = (f'<p><span class="etp-sp-laquo">Проба{space}</span>'
|
||||
f'<span class="etp-laquo">{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</span></p>')
|
||||
|
||||
processor = HangingPunctuationProcessor(mode='left')
|
||||
soup = make_soup(text)
|
||||
|
||||
processor.process(soup)
|
||||
assert str(soup) == expected_html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separator", [CHAR_NBSP, CHAR_THIN_NBSP, CHAR_ZWNJ])
|
||||
def test_hanging_left_mode_honors_cancellation(separator):
|
||||
"""Символы, отменяющие перенос, остаются без обёрток."""
|
||||
input_html = f'<p>Неразрывный{separator}{CHAR_RU_QUOT1_OPEN}Текст{CHAR_RU_QUOT1_CLOSE}</p>'
|
||||
processor = HangingPunctuationProcessor(mode='left')
|
||||
soup = make_soup(input_html)
|
||||
|
||||
processor.process(soup)
|
||||
assert str(soup) == input_html
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from etpgrf.sanitizer import SanitizerProcessor
|
||||
from etpgrf.config import SANITIZE_NONE, SANITIZE_ETPGRF, SANITIZE_ALL_HTML
|
||||
from etpgrf.config import SANITIZE_NONE, SANITIZE_ETPGRF, SANITIZE_ALL_HTML, CHARS_SYMBOLS_TO_BAN
|
||||
|
||||
|
||||
def test_sanitizer_mode_none():
|
||||
@@ -67,7 +67,8 @@ ETPGRF_SANITIZE_TEST_CASES = [
|
||||
),
|
||||
(
|
||||
"complex_case", "Сложный случай с несколькими разными span'ами",
|
||||
'<h1><span class="etp-laquo">«</span>Title<span class="etp-raquo">»</span></h1>\n<p>And <span class="note">note</span>.</p>',
|
||||
'<h1><span class="etp-laquo">«</span>Title<span class="etp-raquo">»</span></h1>\n'
|
||||
'<p>And <span class="note">note</span>.</p>',
|
||||
'<h1>«Title»</h1>\n<p>And <span class="note">note</span>.</p>'
|
||||
),
|
||||
]
|
||||
@@ -83,3 +84,19 @@ def test_sanitizer_mode_etpgrf(case_id, description, html_input, expected_html):
|
||||
result_soup = processor.process(soup)
|
||||
|
||||
assert str(result_soup) == expected_html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [SANITIZE_ETPGRF, SANITIZE_ALL_HTML])
|
||||
def test_sanitizer_strips_service_placeholders(mode):
|
||||
"""
|
||||
Проверяет, что в обоих режимах удаляются запрещенные символы (плейсхолдеры, используемые внутри типографа).
|
||||
Это важно для защиты от потенциальных XSS-атак или других проблем с безопасностью, связанных с этими символами.
|
||||
"""
|
||||
placeholder = next(iter(CHARS_SYMBOLS_TO_BAN))
|
||||
html_input = f'<p>Start{placeholder}End</p>'
|
||||
soup = BeautifulSoup(html_input, 'html.parser')
|
||||
processor = SanitizerProcessor(mode=mode)
|
||||
result = processor.process(soup)
|
||||
output = str(result) if isinstance(result, BeautifulSoup) else result
|
||||
assert placeholder not in output
|
||||
assert 'StartEnd' in output
|
||||
|
||||
@@ -8,6 +8,7 @@ from etpgrf.config import (
|
||||
CHAR_TRADE, CHAR_AP, CHAR_ARROW_L, CHAR_ARROW_R, CHAR_ARROW_LR,
|
||||
CHAR_ARROW_L_DOUBLE, CHAR_ARROW_R_DOUBLE, CHAR_ARROW_LR_DOUBLE,
|
||||
CHAR_ARROW_L_LONG_DOUBLE, CHAR_ARROW_R_LONG_DOUBLE, CHAR_ARROW_LR_LONG_DOUBLE,
|
||||
CHAR_NBSP,
|
||||
)
|
||||
|
||||
SYMBOLS_TEST_CASES = [
|
||||
@@ -50,7 +51,17 @@ SYMBOLS_TEST_CASES = [
|
||||
("I-V век", f"I{CHAR_NDASH}V век"),
|
||||
("ix-vi до н.э.", f"ix{CHAR_NDASH}vi до н.э."),
|
||||
|
||||
# 3. --- Комбинированные и пограничные случаи ---
|
||||
# 3. --- Проверка замены `x`, `X`, `х` и `Х` на `×` ---
|
||||
("222 x 333 = 73926", f"222{CHAR_NBSP}×{CHAR_NBSP}333 = 73926"),
|
||||
("222 X 333 = 73926", f"222{CHAR_NBSP}×{CHAR_NBSP}333 = 73926"),
|
||||
("222 х 333 = 73926", f"222{CHAR_NBSP}×{CHAR_NBSP}333 = 73926"), # русская х
|
||||
("222 Х 333 = 73926", f"222{CHAR_NBSP}×{CHAR_NBSP}333 = 73926"), # русская Х
|
||||
("Размер 5x10 см", f"Размер 5×10 см"),
|
||||
("Размер 5X10 см", f"Размер 5×10 см"),
|
||||
("Размер 5х10 см", f"Размер 5×10 см"), # русская х
|
||||
("Размер 5Х10 см", f"Размер 5×10 см"), # русская Х
|
||||
|
||||
# 4. --- Комбинированные и пограничные случаи ---
|
||||
# Сначала сработает простая замена '---' -> '—', потом диапазон '1-5' -> '1–5'
|
||||
("1-5 --- это диапазон (c)", f"1{CHAR_NDASH}5 {CHAR_MDASH} это диапазон {CHAR_COPY}"),
|
||||
# Простая замена '--' -> '–' не должна мешать диапазону '1-5'
|
||||
|
||||
@@ -71,9 +71,9 @@ TYPOGRAPHER_HTML_TEST_CASES = [
|
||||
f'<p>Он{CHAR_NBSP}сказал: «В{CHAR_NBSP}1941{CHAR_NDASH}1945{CHAR_NBSP}гг.{CHAR_NBSP}{CHAR_NDASH} было'
|
||||
f' 100{CHAR_NBSP}тыс.{CHAR_THIN_SP}руб. и{CHAR_NBSP}т.{CHAR_THIN_SP}д.»</p>'),
|
||||
# --- Теги внутри кавычек ---
|
||||
('mnemonic', '<p>"<u>Почему</u>", "<u>зачем</u>" и "<u>кому это выгодно</u>" -- вопросы требующие ответа.</p>',
|
||||
('mnemonic', '<p>"<u>Почему</u>", "<u>зачем</u>" и "<u>кому это выгодно</u>" -- вопросы, требующие ответа.</p>',
|
||||
'<p>«<u>Почему</u>», «<u>зачем</u>» и «<u>кому это выгодно</u>'
|
||||
'» – вопросы требующие ответа.</p>'),
|
||||
'» – вопросы, требующие ответа.</p>'),
|
||||
('mixed', '<p>"<u>Почему</u>", "<u>зачем</u>" и "<u>кому это выгодно</u>" -- вопросы требующие ответа.</p>',
|
||||
'<p>«<u>Почему</u>», «<u>зачем</u>» и «<u>кому это выгодно</u>» – вопросы требующие ответа.</p>'),
|
||||
('unicode', '<p>"<u>Почему</u>", "<u>зачем</u>" и "<u>кому это выгодно</u>" -- вопросы требующие ответа.</p>',
|
||||
@@ -86,7 +86,7 @@ TYPOGRAPHER_HTML_TEST_CASES = [
|
||||
|
||||
# --- Самозакрывающиеся теги и теги с атрибутами ---
|
||||
# ВАЖНО: 1. Порядок атрибутов в типографированном тексте может быть произвольным
|
||||
# 2. Любое число пробельных символов внутри "пустых" тегов будут редуцированы до одного пробела или
|
||||
# 2. Любое число пробельных символов внутри "пустых" тегов будет редуцировано до одного пробела или
|
||||
# перевода строки.
|
||||
# 3. Самозакрывающиеся теги будут приведены к единому виду с косой чертой в конце. Типа <br/>
|
||||
# 4. Все это "проделки" связаны с использованием библиотеки BeautifulSoup для парсинга HTML,
|
||||
|
||||
Reference in New Issue
Block a user