8 Commits

Author SHA1 Message Date
c54ae63030 mod: v0.1.4 2026-02-03 02:15:56 +03:00
00c80b79f1 mod: Use node separators and placeholders for robust HTML processing
1. Защита тегов: Защищенные теги (<code>, <script> и т.д.) теперь физически заменяются на плейсхолдеры (\uFFFC) в DOM-дереве перед обработкой. Это предотвращает "протекание" контекста (например, склеивание слов через код) и защищает содержимое тегов от изменений.

2. Маркеры границ: При сборке "супер-строки" (для контекстной обработки) между всеми текстовыми узлами вставляются специальные разделители (\uFFFF). Это позволяет корректно восстанавливать текст по узлам, даже если длина текста изменилась (например, Unbreakables удалил лишние пробелы). Раньше мы полагались на карту длин (lengths_map), что приводило к смещению текста при любых изменениях длины.
2026-02-03 02:04:46 +03:00
f3a651a54f fix: Protect tags with placeholders to prevent text shifting and context leakage
1. Защита тегов: Внедрили механизм _hide_protected_tags / _restore_protected_tags с использованием плейсхолдера ___ETPGRF_PROTECTED___. Это решило проблему "протекания" контекста через защищенные теги (например, союз "и" больше не прыгает через <code>).

2. Фикс тестов: Обновили тесты, чтобы они учитывали реальное поведение BeautifulSoup (закрытие тегов) и Unbreakables (схлопывание пробелов).
2026-02-03 00:57:46 +03:00
fe6f2a1522 mod: Демо.. 2026-01-19 21:32:41 +03:00
57b8f4f74a mod: Демо. 2026-01-19 21:31:26 +03:00
6f5551ec29 mod: Демо. 2026-01-19 21:31:09 +03:00
d1b8728002 mod: Демо+ 2026-01-19 21:30:44 +03:00
604d510b24 mod: Демо 2026-01-19 21:29:35 +03:00
9 changed files with 203 additions and 50 deletions

View File

@@ -5,6 +5,15 @@
Формат основан на [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
### Изменено
- **Архитектурное улучшение:** Полностью переработан механизм обработки HTML.
- Внедрены **маркеры границ узлов** (`\uFFFF`) при сборке текста. Это позволяет корректно восстанавливать структуру HTML даже если длина текста изменилась в процессе обработки (например, при удалении лишних пробелов).
- Внедрены **плейсхолдеры** (`\uFFFC`) для защищенных тегов (`<code>`, `<script>` и др.). Теперь содержимое этих тегов физически изолируется перед обработкой, что предотвращает "протекание" контекста (например, склеивание слов, разделенных кодом).
### Исправлено
- Исправлена ошибка смещения текста при наличии спецсимволов (мнемоник) или при изменении длины строки.
- Исправлена обработка кавычек, стоящих вплотную к границам тегов (например, `"<b>Текст</b>"`).
## [0.1.3] - 2026-01-11
### Исправлено
- Исправлена проблема с появлением лишних тегов `<html>` и `<body>` при обработке фрагментов HTML (когда используется парсер `lxml`). Теперь типограф автоматически определяет, был ли на входе полноценный документ или фрагмент, и возвращает соответствующий результат.

View File

@@ -17,6 +17,11 @@
* [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало & homepage/issues)
* [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse)
## Демострация / Demo
Работа etpgrf-типографа представлена по адресу: [typograph.cube2.ru](https://typograph.cube2.ru/). Подготовьте верстку
вашего текста, сайтов, статей и постов к публикации в интернете за один клик.
## Установка
```bash
@@ -350,21 +355,10 @@ typo = etpgrf.Typographer(hanging_punctuation=['blockquote', 'h2', 'h3'])
## P.S.
Если вам нравится этот, можете поддержать отправив любую сумму на мой Т-банк
[по ссылке](https://tbank.ru/cf/27hMw1BTFMs) или QR-коду.
Если вам нравится этот проект, можете поддержать отправив любую сумму на мой Т-банк
[по ссылке](https://tbank.ru/cf/27hMw1BTFMs) или, для приверженцев децентрализованного будущего,
через Toncoin (TON) (адрес кошелька `UQApEkzNMYOg5qesWwlyfGFf4ayFyki5Mrpcd2yadgS2_1cx`)
![Сбор средств](qr-code.png)
Средства пойдут на улучшение моего настроения путем покупки виниловых пластинок. В списке желаний:
| Bar-Code | Artist | Album | Format | Note | Date | Label | Цена | |
|----------------|--------------------------|-----------------------------------------|--------|----------------------|------------|---------|-------|--------|
| 5400863157845 | EELS | Time! | LP | coloured | 07.06.2024 | | ₽4360 |
| 5400863145637 | EELS | So Good | LP | coloured | 15.12.2023 | | ₽4940 |
| 8719262034853 | NICK CAVE & WARREN ELLIS | Mars (Original Sound Track) | LP | coloured | 12.07.2024 | | ₽3440 |
| 5021732526007 | GORILLAZ | Demon Days Live From The Apollo Theater | 2LP | RSD2025, Red | 12.04.2025 | Warner | ₽5740 |
| 5021732717696 | GORILLAZ | TOMORROW COMES TODAY | EP 12" | color (white & blue) | 20.06.2025 | | ₽3600 |
| 0198028824118 | Lou Reed | Metal Machine Music (RSD2025 50th) | 2LP | Ann Silver | 04.12.2025 | RCA | ₽5299 |
## Credits

View File

@@ -8,7 +8,7 @@ etpgrf - библиотека для экранной типографики т
- Висячая пунктуация
- Очистка и обработка HTML
"""
__version__ = "0.1.3"
__version__ = "0.1.4"
__author__ = "Sergei Erjemin"
__email__ = "erjemin@gmail.com"
__license__ = "MIT"

View File

@@ -72,6 +72,8 @@ CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двуна
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / &middot;
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
CHAR_PLACEHOLDER = '\uFFFC' # Уникальная строка-заполнитель для защищенных тегов.
CHAR_NODE_SEPARATOR = '\uFFFF' # Маркер границы текстовых узлов (Non-character).
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===

View File

@@ -5,7 +5,7 @@ import regex
import logging
from .config import (LANG_RU, LANG_EN, CHAR_RU_QUOT1_OPEN, CHAR_RU_QUOT1_CLOSE, CHAR_EN_QUOT1_OPEN,
CHAR_EN_QUOT1_CLOSE, CHAR_RU_QUOT2_OPEN, CHAR_RU_QUOT2_CLOSE, CHAR_EN_QUOT2_OPEN,
CHAR_EN_QUOT2_CLOSE)
CHAR_EN_QUOT2_CLOSE, CHAR_NODE_SEPARATOR)
from .comutil import parse_and_validate_langs
# --- Настройки логирования ---
@@ -40,18 +40,21 @@ class QuotesProcessor:
f"QuotesProcessor: выбран стиль кавычек для языка '{lang}': '{self.open_quote}...{self.close_quote}'")
break # Используем стиль первого найденного языка
# Экранируем разделитель для использования в regex
sep = regex.escape(CHAR_NODE_SEPARATOR)
# Паттерн для открывающей кавычки: " перед буквой/цифрой,
# которой предшествует пробел, начало строки или открывающая скобка.
# (?<=^|\s|[\(\[„\"\']) - "просмотр назад" на начало строки... ищет пробел \s или знак из набора ([„"'
# (?=\p{L}) - "просмотр вперед" на букву \p{L} (но не цифру).
self._opening_quote_pattern = regex.compile(r'(?<=^|\s|[\(\[„\"\'])\"(?=\p{L})')
# которой предшествует пробел, начало строки, открывающая скобка ИЛИ разделитель узлов.
# (?<=^|\s|[\(\[„\"\']|sep) - "просмотр назад" на начало строки... ищет пробел \s или знак из набора ([„"' или разделитель
# (?=\p{L}|sep) - "просмотр вперед" на букву \p{L} (но не цифру) ИЛИ разделитель узлов.
self._opening_quote_pattern = regex.compile(rf'(?<=^|\s|[\(\[„\"\']|{sep})\"(?=\p{{L}}|{sep})')
# self._opening_quote_pattern = regex.compile(r'(?<=^|\s|\p{Pi}|["\'\(\)])\"(?=\p{L})')
# Паттерн для закрывающей кавычки: " после буквы/цифры,
# за которой следует пробел, пунктуация или конец строки.
# (?<=\p{L}|[?!…\.]) - "просмотр назад" на букву или ?!… и точку.
# (?=\s|[.,;:!?\)\"»”’]|\Z) - "просмотр вперед" на пробел, пунктуацию или конец строки (\Z).
self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…\.])\"(?=\s|[\.,;:!?\)\]»”’\"\']|\Z)')
# за которой следует пробел, пунктуация, конец строки ИЛИ разделитель узлов.
# (?<=\p{L}|[?!…\.]|sep) - "просмотр назад" на букву или ?!… и точку ИЛИ разделитель узлов.
# (?=\s|[.,;:!?\)\"»”’]|\Z|sep) - "просмотр вперед" на пробел, пунктуацию, конец строки (\Z) или разделитель.
self._closing_quote_pattern = regex.compile(rf'(?<=\p{{L}}|[?!…\.]|{sep})\"(?=\s|[\.,;:!?\)\]»”’\"\']|\Z|{sep})')
# self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|\p{N})\"(?=\s|[\.,;:!?\)\"»”’]|\Z)')
# self._closing_quote_pattern = regex.compile(r'(?<=\p{L}|[?!…])\"(?=\s|[\p{Po}\p{Pf}"\']|\Z)')
@@ -72,4 +75,4 @@ class QuotesProcessor:
# 2. Заменяем закрывающие кавычки
processed_text = self._closing_quote_pattern.sub(self.close_quote, processed_text)
return processed_text
return processed_text

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, SANITIZE_ALL_HTML
from etpgrf.config import PROTECTED_HTML_TAGS, CHAR_PLACEHOLDER, CHAR_NODE_SEPARATOR
# --- Настройки логирования ---
@@ -156,6 +156,82 @@ class Typographer:
# Если это "обычный" html-тег, рекурсивно заходим в него
self._walk_tree(child)
def _hide_protected_tags(self, soup) -> list:
"""
Находит все защищенные теги, заменяет их на плейсхолдеры и возвращает список сохраненных тегов.
"""
protected_tags = []
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
def _restore_protected_tags(self, soup, protected_tags: list):
"""
Восстанавливает защищенные теги на места плейсхолдеров.
"""
if not protected_tags:
return
# Ищем все текстовые узлы, содержащие плейсхолдер
# Используем список, так как будем менять дерево
text_nodes_with_placeholder = [
node for node in soup.descendants
if isinstance(node, NavigableString) and CHAR_PLACEHOLDER in node
]
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])
tag_index += 1
else:
logger.warning("Mismatch in protected tags count during restoration.")
# Заменяем исходный узел на новые
if new_nodes:
first_node = new_nodes[0]
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
else:
# Если узел состоял только из плейсхолдера и мы его заменили на тег,
# то new_nodes может быть пустым (если тег был один и текст пустой).
# Но split('') дает [''], так что parts не пустой.
# Логика выше должна работать.
pass
def process(self, text: str) -> str:
"""
Обрабатывает текст, применяя все активные правила типографики.
@@ -195,19 +271,29 @@ class Typographer:
# Если результат - soup, продолжаем работу с ним
soup = result
# --- ЭТАП 2.5: Скрытие защищенных тегов ---
# Заменяем <code>, <script> и т.д. на плейсхолдеры, чтобы они не мешали обработке
# и не ломали карту длин.
protected_tags = self._hide_protected_tags(soup)
# --- ЭТАП 3: Подготовка (токен-стрим) ---
# 3.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать.
# soup.descendants возвращает все дочерние узлы (теги и текст) в порядке их следования.
# 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]
# 3.2. Создаем "супер-строку" и "карту длин"
and node.parent.name not in PROTECTED_HTML_TAGS] # PROTECTED уже скрыты, но на всякий случай
# 3.2. Создаем "супер-строку" с маркерами границ
super_string = ""
lengths_map = []
# lengths_map больше не нужен, так как мы используем разделители
for node in text_nodes:
super_string += str(node)
lengths_map.append(len(str(node)))
# ВАЖНО: Используем 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
@@ -219,16 +305,40 @@ class Typographer:
processed_super_string = self.unbreakables.process(processed_super_string)
# --- ЭТАП 5: Восстановление структуры ---
current_pos = 0
# Разбиваем строку по разделителям.
# 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):
length = lengths_map[i]
new_text_part = processed_super_string[current_pos : current_pos + length]
node.replace_with(new_text_part) # Заменяем содержимое узла на месте
current_pos += length
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: Висячая пунктуация ---
@@ -249,6 +359,9 @@ class Typographer:
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;', '&')

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "etpgrf"
version = "0.1.3"
version = "0.1.4"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
readme = "README.md"
requires-python = ">=3.10"
@@ -26,7 +26,7 @@ classifiers = [
]
dependencies = [
"beautifulsoup4>=4.10.0",
"lxml>=4.9.0", # Рекомендуемый парсер (в принципе со встроенным html.parser тоже будет работать, но медленнее)
"lxml>=4.9.0", # Рекомендуемый парсер
"regex>=2022.1.18", # Критически важная зависимость для Unicode
]

View File

@@ -11,3 +11,30 @@ packaging==25.0
pluggy==1.6.0
Pygments==2.19.2
tomli==2.2.1
backports.tarfile==1.2.0
build==1.3.0
certifi==2025.11.12
charset-normalizer==3.4.4
docutils==0.22.4
exceptiongroup==1.3.0
id==1.5.0
idna==3.11
importlib_metadata==8.7.1
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.4.0
keyring==25.7.0
markdown-it-py==4.0.0
mdurl==0.1.2
more-itertools==10.8.0
nh3==0.3.2
pyproject_hooks==1.2.0
readme_renderer==44.0
requests==2.32.5
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==14.2.0
twine==6.2.0
urllib3==2.6.2
zipp==3.23.0

View File

@@ -164,18 +164,23 @@ HTML_STRUCTURE_TEST_CASES = [
# 3. Полноценный html-документ -> должен сохранить структуру
('<html><body><p>Текст</p></body></html>', '<html><body><p>Текст</p></body></html>'),
('<!DOCTYPE html><html><head></head><body><p>Текст</p></body></html>',
'<!DOCTYPE html><html><head></head><body><p>Текст</p></body></html>'), # BS может добавить перенос строки после doctype
# Используем валидный HTML для теста с DOCTYPE
('<!DOCTYPE html><html><head><title>Title</title></head><body><p>Текст</p></body></html>',
'<!DOCTYPE html>\n<html><head><title>Title</title></head><body><p>Текст</p></body></html>'),
# 4. Кривой html -> будет "починен"
('<div>Текст', '<div>Текст</div>'),
('<p>Текст', '<p>Текст</p>'),
('Текст <b>жирный <i>курсив', 'Текст <b>жирный <i>курсив</i></b>'),
# Используем валидный HTML для теста с DOCTYPE
('<!DOCTYPE html><html><head><title>Title</title></head><body><p>Текст</p></body></html>',
'<!DOCTYPE html><html><head><title>Title</title></head><body><p>Текст</p></body></html>'),
# Тест на совсем кривой HTML (см ниже) не проходит: весь текст после незарытого <title> передается в заголовок.
# ('<!DOCTYPE html><html><head><title>Title<body><p>Текст', '<!DOCTYPE html><html><head><title>Title</title></head><body><p>Текст</p></body></html>'),
# 5. Тест на защищенные теги с "битым" HTML внутри (BS их закроет)
('<ul><li>Исправлена проблема с&nbsp;появлением лишних тегов <code>&lt;html&gt;</code> и&nbsp;<code>&lt;body&gt;</code> при обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>',
'<ul><li>Исправлена проблема с&nbsp;появлением лишних тегов <code>&lt;html&gt;</code> и&nbsp;<code>&lt;body&gt;</code> при&nbsp;обработке фрагментов HTML.</li></ul><h5>Заголовок</h5>'),
# 6/ Исправленный тест на защищенные теги с немаскированными HTML внутри
# (все незакрытые теги будут закрыты через 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>'),
]
@pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)