7 Commits
v0.1.4 ... main

8 changed files with 60 additions and 167 deletions

View File

@@ -5,7 +5,12 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html). и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.4] - 2025-02-03 ## [0.1.5] - 2024-02-18
### Исправлено
- Исправлена ошибка, из-за которой `&` в исходном тексте некорректно преобразовывался в `&`. Теперь `&` и его варианты (`&`, `<`) сохраняются в итоговом HTML.
- Исправлена (частично) расстановка неразрывных пробелов `&nbsp;` на границах закрывающих тегов (например, `<b>Текст</b> -- слово` теперь корректно обрабатывается, в `Текст</b>&nbsp;&endash; слово`).
## [0.1.4] - 2024-02-13
### Изменено ### Изменено
- **Архитектурное улучшение:** Полностью переработан механизм обработки HTML. - **Архитектурное улучшение:** Полностью переработан механизм обработки HTML.
- Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов). - Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).

View File

@@ -5,7 +5,7 @@
[![License](https://img.shields.io/pypi/l/etpgrf.svg)](https://pypi.org/project/etpgrf/) [![License](https://img.shields.io/pypi/l/etpgrf.svg)](https://pypi.org/project/etpgrf/)
# Типограф для веба # Типограф для веба
Экранная типографика для веба — способствует повышению читабельности текста в интернете, Экранная типографика для веба — способствует повышению читабельности текста в интернете,
приближая его к печатной типографике. приближая его к печатной типографике.
@@ -14,12 +14,12 @@
Исходный код доступен на нескольких площадках: Исходный код доступен на нескольких площадках:
* [Gitea](https://git.cube2.ru/erjemin/2025-etpgrf) (Основной self-hosted) * [Gitea](https://git.cube2.ru/erjemin/2025-etpgrf) (Основной self-hosted)
* [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало & homepage/issues) * [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало)
* [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse) * [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse)
## Демострация / Demo ## Демострация / Demo
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
вашего текста, сайтов, статей и постов к публикации в интернете за один клик. вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
## Установка ## Установка
@@ -352,6 +352,14 @@ typo = etpgrf.Typographer(hanging_punctuation=['blockquote', 'h2', 'h3'])
.etp-r-dot, .etp-r-comma, .etp-r-colon { right: -0.15em; } /* . , : */ .etp-r-dot, .etp-r-comma, .etp-r-colon { right: -0.15em; } /* . , : */
``` ```
## Известные особенности и ограничения
При обработке сложного HTML-кода типограф стремится сохранить структуру документа, но некоторые пограничные случаи могут обрабатываться не так, как ожидается. В частности:
* **Обработка на стыке тегов:** Правила, требующие анализа контекста (например, расстановка неразрывных пробелов у тире или единиц измерения), могут работать некорректно, если анализируемые части текста разделены тегами . Например, конструкция `$<b>100</b>` не будет обработана (между $ и 100 не будет вставлен неразрывный пробел), так как типограф не видит их как соседние элементы.
* **"Ремонт" HTML:** Библиотека использует `BeautifulSoup` для парсинга, который может "чинить" невалидный HTML (например, закрывать незакрытые теги). Это может привести к неожиданным изменениям в структуре, если исходный код был некорректен. Так же может меняться порядок атрибутов тега.
Мы знаем об этих особенностях и работаем над улучшением алгоритмов для более точной обработки сложных случаев.
## P.S. ## P.S.

View File

@@ -8,15 +8,14 @@ etpgrf - библиотека для экранной типографики т
- Висячая пунктуация - Висячая пунктуация
- Очистка и обработка HTML - Очистка и обработка HTML
""" """
__version__ = "0.1.4" __version__ = "0.1.5"
__author__ = "Sergei Erjemin" __author__ = "Sergei Erjemin"
__email__ = "erjemin@gmail.com" __email__ = "erjemin@gmail.com"
__license__ = "MIT" __license__ = "MIT"
__copyright__ = "Copyright 2025 Sergei Erjemin" __copyright__ = "(с) 2025-2026, Sergei Erjemin"
import etpgrf.defaults import etpgrf.defaults
import etpgrf.logger import etpgrf.logger
from etpgrf.hyphenation import Hyphenator from etpgrf.hyphenation import Hyphenator
from etpgrf.layout import LayoutProcessor from etpgrf.layout import LayoutProcessor
from etpgrf.quotes import QuotesProcessor from etpgrf.quotes import QuotesProcessor

View File

@@ -73,6 +73,7 @@ CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда испо
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом. # в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов. CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
CHAR_AMP_PLACEHOLDER = '\uFFFD' # Маркер-плейсхолдер для амперсанда (&), чтобы избежать его двойного кодирования в &amp; при замене на мнемонику.
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character). CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).

View File

@@ -5,7 +5,7 @@ import regex
import logging import logging
from etpgrf.config import (LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP, from etpgrf.config import (LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP,
CHAR_UNIT_SEPARATOR, DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, UNIT_MATH_OPERATORS, CHAR_UNIT_SEPARATOR, DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, UNIT_MATH_OPERATORS,
ABBR_COMMON_FINAL, ABBR_COMMON_PREPOSITION) ABBR_COMMON_FINAL, ABBR_COMMON_PREPOSITION, CHAR_NODE_SEPARATOR)
from etpgrf.comutil import parse_and_validate_langs from etpgrf.comutil import parse_and_validate_langs
@@ -35,14 +35,17 @@ class LayoutProcessor:
self.main_lang = self.langs[0] if self.langs else LANG_RU self.main_lang = self.langs[0] if self.langs else LANG_RU
self.process_initials_and_acronyms = process_initials_and_acronyms self.process_initials_and_acronyms = process_initials_and_acronyms
self.process_units = process_units self.process_units = process_units
# Экранируем разделитель для использования в regex
sep = regex.escape(CHAR_NODE_SEPARATOR)
# 1. Паттерн для длинного (—) или среднего () тире, окруженного пробелами. # 1. Паттерн для длинного (—) или среднего () тире, окруженного пробелами.
# (?<=[\p{L}\p{Po}\p{Pf}"\']) - просмотр назад на букву, пунктуацию или кавычку. # (?<=[\p{L}\p{Po}\p{Pf}"\']|sep) - просмотр назад на букву, пунктуацию, кавычку ИЛИ разделитель узлов.
self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\'])\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)') self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\']|{sep})\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
# 2. Паттерн для многоточия, за которым следует пробел и слово. # 2. Паттерн для многоточия, за которым следует пробел и слово.
# Ставит неразрывный пробел после многоточия, чтобы не отрывать его от следующего слова. # Ставит неразрывный пробел после многоточия, чтобы не отрывать его от следующего слова.
# (?=[\p{L}\p{N}]) - просмотр вперед на букву или цифру. # (?=[\p{L}\p{N}]|sep) - просмотр вперед на букву или цифру ИЛИ разделитель узлов.
self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}])') self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}]|{sep})')
# 3. Паттерн для отрицательных чисел. # 3. Паттерн для отрицательных чисел.
# Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел # Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел
@@ -95,7 +98,7 @@ class LayoutProcessor:
units_pattern_part_clean = '|'.join(map(regex.escape, [u.replace('.', '') for u in sorted_units])) units_pattern_part_clean = '|'.join(map(regex.escape, [u.replace('.', '') for u in sorted_units]))
# Простые единицы: число + единица # Простые единицы: число + единица
self._post_units_pattern = regex.compile(rf'({self._NUMBER_PATTERN})\s+({units_pattern_part_full})(?!\w)') self._post_units_pattern = regex.compile(rf'({self._NUMBER_PATTERN}|{sep})\s+({units_pattern_part_full})(?!\w)')
# Составные единицы: ищет пару "единица." + "единица" # Составные единицы: ищет пару "единица." + "единица"
self._complex_unit_pattern = regex.compile(r'\b(' + units_pattern_part_clean + r')\.(\s*)(' self._complex_unit_pattern = regex.compile(r'\b(' + units_pattern_part_clean + r')\.(\s*)('
+ units_pattern_part_clean + r')(?!\w)') + units_pattern_part_clean + r')(?!\w)')

View File

@@ -17,7 +17,7 @@ from etpgrf.symbols import SymbolsProcessor
from etpgrf.sanitizer import SanitizerProcessor from etpgrf.sanitizer import SanitizerProcessor
from etpgrf.hanging import HangingPunctuationProcessor from etpgrf.hanging import HangingPunctuationProcessor
from etpgrf.codec import decode_to_unicode, encode_from_unicode from etpgrf.codec import decode_to_unicode, encode_from_unicode
from etpgrf.config import PROTECTED_HTML_TAGS, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR from etpgrf.config import PROTECTED_HTML_TAGS, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR, CHAR_AMP_PLACEHOLDER
# --- Настройки логирования --- # --- Настройки логирования ---
@@ -116,46 +116,6 @@ class Typographer:
f"process_html: {self.process_html}") f"process_html: {self.process_html}")
def _process_text_node(self, text: str) -> str:
"""
Внутренний конвейер, который работает с чистым текстом.
"""
# Шаг 1: Декодируем весь входящий текст в канонический Unicode
# (здесь можно использовать html.unescape, но наш кодек тоже подойдет)
processed_text = decode_to_unicode(text)
# processed_text = text # ВРЕМЕННО: используем текст как есть
# Шаг 2: Применяем правила к чистому Unicode-тексту (только правила на уровне ноды)
if self.symbols is not None:
processed_text = self.symbols.process(processed_text)
if self.layout is not None:
processed_text = self.layout.process(processed_text)
if self.hyphenation is not None:
processed_text = self.hyphenation.hyp_in_text(processed_text)
# ... вызовы других активных модулей правил ...
# Финальный шаг: кодируем результат в соответствии с выбранным режимом
return encode_from_unicode(processed_text, self.mode)
def _walk_tree(self, node):
"""
Рекурсивно обходит DOM-дерево, находя и обрабатывая все текстовые узлы.
"""
# Список "детей" узла, который мы будем изменять.
# Копируем в список, так как будем изменять его во время итерации.
for child in list(node.children):
if isinstance(child, NavigableString):
# Если это текстовый узел, обрабатываем его
# Пропускаем пустые или состоящие из пробелов узлы
if not child.string.strip():
continue
processed_node_text = self._process_text_node(child.string)
child.replace_with((processed_node_text))
elif child.name not in PROTECTED_HTML_TAGS:
# Если это "обычный" html-тег, рекурсивно заходим в него
self._walk_tree(child)
def _hide_protected_tags(self, soup) -> list: def _hide_protected_tags(self, soup) -> list:
""" """
Находит все защищенные теги, заменяет их на плейсхолдеры и возвращает список сохраненных тегов. Находит все защищенные теги, заменяет их на плейсхолдеры и возвращает список сохраненных тегов.
@@ -164,18 +124,11 @@ class Typographer:
if not PROTECTED_HTML_TAGS: if not PROTECTED_HTML_TAGS:
return protected_tags return protected_tags
# Формируем селектор для поиска
selector = ", ".join(PROTECTED_HTML_TAGS) selector = ", ".join(PROTECTED_HTML_TAGS)
# Находим все теги. Важно: find_all возвращает их в порядке появления в документе.
# Но если мы будем заменять их на лету, структура может измениться.
# Поэтому лучше собрать список, а потом заменить.
tags_to_replace = soup.select(selector) tags_to_replace = soup.select(selector)
for tag in tags_to_replace: for tag in tags_to_replace:
# Сохраняем тег (он будет удален из дерева при replace_with, но объект останется в памяти)
protected_tags.append(tag) protected_tags.append(tag)
# Заменяем на текстовый узел с плейсхолдером
tag.replace_with(NavigableString(CHAR_PLACEHOLDER)) tag.replace_with(NavigableString(CHAR_PLACEHOLDER))
return protected_tags return protected_tags
@@ -187,8 +140,6 @@ class Typographer:
if not protected_tags: if not protected_tags:
return return
# Ищем все текстовые узлы, содержащие плейсхолдер
# Используем список, так как будем менять дерево
text_nodes_with_placeholder = [ text_nodes_with_placeholder = [
node for node in soup.descendants node for node in soup.descendants
if isinstance(node, NavigableString) and CHAR_PLACEHOLDER in node if isinstance(node, NavigableString) and CHAR_PLACEHOLDER in node
@@ -197,19 +148,14 @@ class Typographer:
tag_index = 0 tag_index = 0
for node in text_nodes_with_placeholder: for node in text_nodes_with_placeholder:
text = str(node) text = str(node)
# Если в узле есть плейсхолдеры, нам нужно его разбить
if CHAR_PLACEHOLDER in text: if CHAR_PLACEHOLDER in text:
parts = text.split(CHAR_PLACEHOLDER) parts = text.split(CHAR_PLACEHOLDER)
# Создаем список новых узлов для замены
new_nodes = [] new_nodes = []
for i, part in enumerate(parts): for i, part in enumerate(parts):
# Добавляем текст (если он не пустой)
if part: if part:
new_nodes.append(NavigableString(part)) new_nodes.append(NavigableString(part))
# Если это не последняя часть, значит, здесь был плейсхолдер.
# Вставляем тег.
if i < len(parts) - 1: if i < len(parts) - 1:
if tag_index < len(protected_tags): if tag_index < len(protected_tags):
new_nodes.append(protected_tags[tag_index]) new_nodes.append(protected_tags[tag_index])
@@ -217,7 +163,6 @@ class Typographer:
else: else:
logger.warning("Mismatch in protected tags count during restoration.") logger.warning("Mismatch in protected tags count during restoration.")
# Заменяем исходный узел на новые
if new_nodes: if new_nodes:
first_node = new_nodes[0] first_node = new_nodes[0]
node.replace_with(first_node) node.replace_with(first_node)
@@ -225,12 +170,6 @@ class Typographer:
for next_node in new_nodes[1:]: for next_node in new_nodes[1:]:
current_pos.insert_after(next_node) current_pos.insert_after(next_node)
current_pos = next_node current_pos = next_node
else:
# Если узел состоял только из плейсхолдера и мы его заменили на тег,
# то new_nodes может быть пустым (если тег был один и текст пустой).
# Но split('') дает [''], так что parts не пустой.
# Логика выше должна работать.
pass
def process(self, text: str) -> str: def process(self, text: str) -> str:
""" """
@@ -239,15 +178,12 @@ class Typographer:
""" """
if not text: if not text:
return "" return ""
# Если включена обработка HTML и BeautifulSoup доступен
text = text.replace('&amp;', CHAR_AMP_PLACEHOLDER)
if self.process_html: if self.process_html:
# --- ЭТАП 1: Анализ структуры ---
# Проверяем, есть ли в начале текста теги <html> или <body>.
# Если есть - значит, это полноценный документ, и мы должны вернуть его целиком.
# Если нет - значит, это фрагмент, и мы должны вернуть только содержимое body.
is_full_document = bool(regex.search(r'^\s*<(?:!DOCTYPE|html|body)', text, regex.IGNORECASE)) is_full_document = bool(regex.search(r'^\s*<(?:!DOCTYPE|html|body)', text, regex.IGNORECASE))
# --- ЭТАП 2: Парсинг и Санитизация ---
try: try:
soup = BeautifulSoup(text, 'lxml') soup = BeautifulSoup(text, 'lxml')
except Exception: except Exception:
@@ -255,135 +191,65 @@ class Typographer:
if self.sanitizer: if self.sanitizer:
result = self.sanitizer.process(soup) result = self.sanitizer.process(soup)
# Если режим SANITIZE_ALL_HTML, то результат - это строка (чистый текст)
if isinstance(result, str): if isinstance(result, str):
# Переключаемся на обработку обычного текста return self._process_plain_text(result).replace(CHAR_AMP_PLACEHOLDER, '&amp;')
text = result
# ВАЖНО: Мы выходим из ветки process_html и идем в ветку else,
# но так как мы внутри if, нам нужно явно вызвать логику для текста.
# Проще всего рекурсивно вызвать process с выключенным process_html,
# но чтобы не менять состояние объекта, просто выполним логику "else" блока здесь.
# Или, еще проще: присвоим text = result и пойдем в блок else? Нет, мы уже внутри if.
# Решение: Выполняем логику обработки простого текста прямо здесь
return self._process_plain_text(text)
# Если результат - soup, продолжаем работу с ним
soup = result soup = result
# --- ЭТАП 2.5: Скрытие защищенных тегов ---
# Заменяем <code>, <script> и т.д. на плейсхолдеры, чтобы они не мешали обработке
# и не ломали карту длин.
protected_tags = self._hide_protected_tags(soup) protected_tags = self._hide_protected_tags(soup)
# --- ЭТАП 3: Подготовка (токен-стрим) --- text_nodes = [node for node in soup.descendants if isinstance(node, NavigableString)]
# 3.1. Создаем "токен-стрим" из текстовых узлов.
# Теперь здесь только обычный текст и плейсхолдеры.
text_nodes = [node for node in soup.descendants
if isinstance(node, NavigableString)
# and node.strip()
and node.parent.name not in PROTECTED_HTML_TAGS] # PROTECTED уже скрыты, но на всякий случай
# 3.2. Создаем "супер-строку" с маркерами границ
super_string = ""
# lengths_map больше не нужен, так как мы используем разделители
super_string = ""
for node in text_nodes: for node in text_nodes:
# ВАЖНО: Используем node.string (Unicode), а не str(node) (HTML-encoded).
# str(node) может вернуть экранированные символы (например, &lt; вместо <),
# что увеличит длину строки и приведет к рассинхронизации карты длин (смещению текста).
node_text = node.string or "" node_text = node.string or ""
# Добавляем текст и разделитель
super_string += node_text + CHAR_NODE_SEPARATOR super_string += node_text + CHAR_NODE_SEPARATOR
# --- ЭТАП 4: Контекстная обработка --- processed_super_string = self._process_plain_text(super_string)
processed_super_string = super_string
# Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html).
# Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится.
if self.quotes:
processed_super_string = self.quotes.process(processed_super_string)
if self.unbreakables:
processed_super_string = self.unbreakables.process(processed_super_string)
# --- ЭТАП 5: Восстановление структуры ---
# Разбиваем строку по разделителям.
# split вернет список, где последний элемент будет пустым (из-за разделителя в конце).
# Поэтому берем все элементы, кроме последнего.
# Но если строка пустая, split вернет [''], и мы возьмем [].
# Если строка 'a\uFFFF', split -> ['a', '']. Берем ['a'].
parts = processed_super_string.split(CHAR_NODE_SEPARATOR) parts = processed_super_string.split(CHAR_NODE_SEPARATOR)
# Проверка на целостность: количество частей должно совпадать с количеством узлов.
# split всегда возвращает хотя бы один элемент. Если super_string пустая, parts=[''].
# Если super_string не пустая, parts будет иметь длину N+1 (где N - число разделителей).
# Нам нужны первые N частей.
if len(parts) > len(text_nodes): if len(parts) > len(text_nodes):
parts = parts[:len(text_nodes)] parts = parts[:len(text_nodes)]
# Если вдруг частей меньше (кто-то удалил разделитель), это проблема.
# Но \uFFFF - Non-character, его сложно удалить случайно.
for i, node in enumerate(text_nodes): for i, node in enumerate(text_nodes):
if i < len(parts): if i < len(parts):
new_text_part = parts[i] new_text_part = parts[i]
# Заменяем содержимое узла.
# Важно: если new_text_part содержит CHAR_PLACEHOLDER, он останется как есть
# и будет обработан на этапе 5.5.
node.replace_with(new_text_part) node.replace_with(new_text_part)
# --- ЭТАП 5.5: Восстановление защищенных тегов ---
self._restore_protected_tags(soup, protected_tags) self._restore_protected_tags(soup, protected_tags)
# --- ЭТАП 6: Локальная обработка (второй проход) ---
# Теперь, когда структура восстановлена (включая защищенные теги),
# запускаем рекурсивный обход.
# Важно: _walk_tree пропускает PROTECTED_HTML_TAGS, так что содержимое
# восстановленных тегов не будет обработано повторно.
self._walk_tree(soup)
# --- ЭТАП 7: Висячая пунктуация ---
# Применяем после всех текстовых преобразований, но перед финальной сборкой
if self.hanging: if self.hanging:
self.hanging.process(soup) self.hanging.process(soup)
# --- ЭТАП 8: Финальная сборка ---
if is_full_document: if is_full_document:
# Если на входе был полноценный документ, возвращаем все дерево
processed_html = str(soup) processed_html = str(soup)
else: else:
# Если на входе был фрагмент, возвращаем только содержимое body.
# decode_contents() возвращает строку с содержимым тега (без самого тега).
# Если body нет (что странно для BS), возвращаем str(soup).
if soup.body: if soup.body:
processed_html = soup.body.decode_contents() processed_html = soup.body.decode_contents()
else: else:
processed_html = str(soup) processed_html = str(soup)
# Удаляем плейсхолдеры и разделители, если они вдруг просочились processed_html = processed_html.replace('&amp;', '&')
processed_html = processed_html.replace(CHAR_PLACEHOLDER, '').replace(CHAR_NODE_SEPARATOR, '') return processed_html.replace(CHAR_AMP_PLACEHOLDER, '&amp;')
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &amp;), которые мы сгенерировали
# в _process_text_node. Возвращаем их обратно.
return processed_html.replace('&amp;', '&')
else: else:
return self._process_plain_text(text) processed_text = self._process_plain_text(text)
return processed_text.replace(CHAR_AMP_PLACEHOLDER, '&amp;')
def _process_plain_text(self, text: str) -> str: def _process_plain_text(self, text: str) -> str:
""" """
Логика обработки обычного текста (вынесена из process для переиспользования). Логика обработки обычного текста (вынесена из process для переиспользования).
""" """
# Шаг 0: Нормализация
processed_text = decode_to_unicode(text) processed_text = decode_to_unicode(text)
# Шаг 1: Применяем все правила последовательно
if self.symbols:
processed_text = self.symbols.process(processed_text)
if self.quotes: if self.quotes:
processed_text = self.quotes.process(processed_text) processed_text = self.quotes.process(processed_text)
if self.unbreakables: if self.unbreakables:
processed_text = self.unbreakables.process(processed_text) processed_text = self.unbreakables.process(processed_text)
if self.symbols:
processed_text = self.symbols.process(processed_text)
if self.layout: if self.layout:
processed_text = self.layout.process(processed_text) processed_text = self.layout.process(processed_text)
if self.hyphenation: if self.hyphenation:
processed_text = self.hyphenation.hyp_in_text(processed_text) processed_text = self.hyphenation.hyp_in_text(processed_text)
# Шаг 2: Финальное кодирование
return encode_from_unicode(processed_text, self.mode) return encode_from_unicode(processed_text, self.mode)

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "etpgrf" name = "etpgrf"
version = "0.1.4" version = "0.1.5"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ." description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -33,8 +33,8 @@ dependencies = [
[project.urls] [project.urls]
"Homepage" = "https://github.com/erjemin/etpgrf" "Homepage" = "https://github.com/erjemin/etpgrf"
"Bug Tracker" = "https://github.com/erjemin/etpgrf/issues" "Bug Tracker" = "https://github.com/erjemin/etpgrf/issues"
"Mirror (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf" "Mirror1 (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf"
"Selfhosted (Gitea)" = "https://gitverse.ru/erjemin/etpgrf" "Nirror2 (Gitea Selfhosted)" = "https://git.cube2.ru/erjemin/2025-etpgrf"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] # Искать пакеты в корне (найдет папку etpgrf) where = ["."] # Искать пакеты в корне (найдет папку etpgrf)

View File

@@ -103,6 +103,12 @@ TYPOGRAPHER_HTML_TEST_CASES = [
f'<p>Текст с{CHAR_NBSP}картинкой <img alt="image" src="image.jpg"/> и{CHAR_NBSP}текстом.</p>'), f'<p>Текст с{CHAR_NBSP}картинкой <img alt="image" src="image.jpg"/> и{CHAR_NBSP}текстом.</p>'),
('unicode', '<p>Текст с <code>&lt;br&gt;</code><br>А это новая строка.</p>', ('unicode', '<p>Текст с <code>&lt;br&gt;</code><br>А это новая строка.</p>',
f'<p>Текст с{CHAR_NBSP}<code>&lt;br&gt;</code><br/>А{CHAR_NBSP}это новая строка.</p>'), f'<p>Текст с{CHAR_NBSP}<code>&lt;br&gt;</code><br/>А{CHAR_NBSP}это новая строка.</p>'),
# --- Тесты на стыке тегов ---
('mixed', '<p>Текст <span>с тире</span> --- после закрытого тега.</p>',
'<p>Текст <span>с&nbsp;тире</span>&nbsp;— после закрытого тега.</p>'),
('mixed', '<p>Целых <b>100</b> т веса.</p>',
'<p>Целых <b>100</b>&nbsp;т веса.</p>'),
] ]
@@ -181,6 +187,11 @@ HTML_STRUCTURE_TEST_CASES = [
# (все незакрытые теги будут закрыты через BS, а тег <html> удалены) # (все незакрытые теги будут закрыты через BS, а тег <html> удалены)
('<ul><li>Исправлена проблема\n с появлением лишних тегов <code><html>++</html></code> и&nbsp;<code><body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>', ('<ul><li>Исправлена проблема\n с появлением лишних тегов <code><html>++</html></code> и&nbsp;<code><body&></code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>',
'<ul><li>Исправлена проблема\n с&nbsp;появлением лишних тегов <code>++</code> и&nbsp;<code><body&></body&></code> при&nbsp;обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>'), '<ul><li>Исправлена проблема\n с&nbsp;появлением лишних тегов <code>++</code> и&nbsp;<code><body&></body&></code> при&nbsp;обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>'),
# 7. Тест на маскированные мнемоники и де-экранирование &amp;
('<p>Текст с &lt; и &gt; и &amp; внутри.</p>', '<p>Текст с&nbsp;&lt; и&nbsp;&gt; и&nbsp;&amp; внутри.</p>'),
('<p>Текст с &amp;lt; и &amp;gt; и &amp;amp; внутри.</p>', '<p>Текст с&nbsp;&amp;lt; и&nbsp;&amp;gt; и&nbsp;&amp;amp; внутри.</p>'),
('<p>Мнемоника <code>&amp;nbsp;</code> превратится в неразрывный пробел</p>', '<p>Мнемоника <code>&amp;nbsp;</code> превратится в&nbsp;неразрывный пробел</p>'),
] ]
@pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES) @pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)