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/),
и этот проект придерживается [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.
- Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).

View File

@@ -14,7 +14,7 @@
Исходный код доступен на нескольких площадках:
* [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)
## Демострация / Demo
@@ -352,6 +352,14 @@ typo = etpgrf.Typographer(hanging_punctuation=['blockquote', 'h2', 'h3'])
.etp-r-dot, .etp-r-comma, .etp-r-colon { right: -0.15em; } /* . , : */
```
## Известные особенности и ограничения
При обработке сложного HTML-кода типограф стремится сохранить структуру документа, но некоторые пограничные случаи могут обрабатываться не так, как ожидается. В частности:
* **Обработка на стыке тегов:** Правила, требующие анализа контекста (например, расстановка неразрывных пробелов у тире или единиц измерения), могут работать некорректно, если анализируемые части текста разделены тегами . Например, конструкция `$<b>100</b>` не будет обработана (между $ и 100 не будет вставлен неразрывный пробел), так как типограф не видит их как соседние элементы.
* **"Ремонт" HTML:** Библиотека использует `BeautifulSoup` для парсинга, который может "чинить" невалидный HTML (например, закрывать незакрытые теги). Это может привести к неожиданным изменениям в структуре, если исходный код был некорректен. Так же может меняться порядок атрибутов тега.
Мы знаем об этих особенностях и работаем над улучшением алгоритмов для более точной обработки сложных случаев.
## P.S.

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import regex
import logging
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,
ABBR_COMMON_FINAL, ABBR_COMMON_PREPOSITION)
ABBR_COMMON_FINAL, ABBR_COMMON_PREPOSITION, CHAR_NODE_SEPARATOR)
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.process_initials_and_acronyms = process_initials_and_acronyms
self.process_units = process_units
# Экранируем разделитель для использования в regex
sep = regex.escape(CHAR_NODE_SEPARATOR)
# 1. Паттерн для длинного (—) или среднего () тире, окруженного пробелами.
# (?<=[\p{L}\p{Po}\p{Pf}"\']) - просмотр назад на букву, пунктуацию или кавычку.
self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\'])\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
# (?<=[\p{L}\p{Po}\p{Pf}"\']|sep) - просмотр назад на букву, пунктуацию, кавычку ИЛИ разделитель узлов.
self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\']|{sep})\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
# 2. Паттерн для многоточия, за которым следует пробел и слово.
# Ставит неразрывный пробел после многоточия, чтобы не отрывать его от следующего слова.
# (?=[\p{L}\p{N}]) - просмотр вперед на букву или цифру.
self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}])')
# (?=[\p{L}\p{N}]|sep) - просмотр вперед на букву или цифру ИЛИ разделитель узлов.
self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}]|{sep})')
# 3. Паттерн для отрицательных чисел.
# Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел
@@ -95,7 +98,7 @@ class LayoutProcessor:
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*)('
+ units_pattern_part_clean + r')(?!\w)')

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
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 ."
readme = "README.md"
requires-python = ">=3.10"
@@ -33,8 +33,8 @@ dependencies = [
[project.urls]
"Homepage" = "https://github.com/erjemin/etpgrf"
"Bug Tracker" = "https://github.com/erjemin/etpgrf/issues"
"Mirror (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf"
"Selfhosted (Gitea)" = "https://gitverse.ru/erjemin/etpgrf"
"Mirror1 (GitVerse)" = "https://gitverse.ru/erjemin/etpgrf"
"Nirror2 (Gitea Selfhosted)" = "https://git.cube2.ru/erjemin/2025-etpgrf"
[tool.setuptools.packages.find]
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>'),
('unicode', '<p>Текст с <code>&lt;br&gt;</code><br>А это новая строка.</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> удалены)
('<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>'),
# 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)