13 Commits
v0.1.0 ... main

9 changed files with 269 additions and 15 deletions

35
CHANGELOG.md Normal file
View File

@@ -0,0 +1,35 @@
# Changelog
Все заметные изменения в этом проекте будут задокументированы в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.3] - 2026-01-11
### Исправлено
- Исправлена проблема с появлением лишних тегов `<html>` и `<body>` при обработке фрагментов HTML (когда используется парсер `lxml`). Теперь типограф автоматически определяет, был ли на входе полноценный документ или фрагмент, и возвращает соответствующий результат.
## [0.1.2] - 2025-12-27
### Исправлено
- **Критическое исправление:** Добавлена отсутствующая зависимость `regex` в `pyproject.toml`. Без неё библиотека падала при импорте.
## [0.1.1] - 2025-12-23
### Добавлено
- Ссылки на зеркала репозитория (GitVerse, Gitea) в `pyproject.toml` и `README.md`.
- Раздел Credits в документации.
## [0.1.0] - 2025-12-23
### Добавлено
- Первый публичный релиз библиотеки `etpgrf`.
- Основные модули:
- `Typographer`: основной класс-оркестратор.
- `Hyphenator`: расстановка мягких переносов (алгоритм Ляна-Кнута).
- `QuotesProcessor`: замена кавычек («ёлочки», „лапки“).
- `Unbreakables`: неразрывные пробелы для предлогов, союзов и частиц.
- `LayoutProcessor`: типографика тире, инициалов, акронимов, единиц измерения, устойчивых сокращений (постпозиционных
и препозиционных).
- `SymbolsProcessor`: псевдографика (тире, стрелочки, копирайт и т.п.)
- `HangingPunctuationProcessor`: висячая пунктуация.
- `SanitizerProcessor`: очистка HTML перед обработкой.
- Поддержка русского, русского дореформенного и английского языков.
- Поддержка обработки HTML (через BeautifulSoup).

106
LIBRARY_SPECS.md Normal file
View File

@@ -0,0 +1,106 @@
# Библиотека etpgrf: Контекст и Спецификация
Этот документ описывает архитектуру и API библиотеки `etpgrf` (Electro-Typographer), предназначенной для типографирования текста в вебе. Используйте этот контекст при разработке зависимых проектов (например, веб-сайтов).
## 1. Назначение
Библиотека выполняет комплексную обработку текста для улучшения его читаемости и внешнего вида в браузере:
* Замена кавычек («ёлочки», „лапки“).
* Расстановка неразрывных пробелов (предлоги, союзы, инициалы, единицы измерения).
* Замена дефисов на тире (—, ).
* Преобразование псевдографики (`...` -> `…`, `(c)` -> `©`).
* Расстановка мягких переносов (`&shy;`).
* **Висячая пунктуация** (оборачивание символов в `<span>`).
* **Санитизация** (очистка HTML от старой типографики).
## 2. Архитектура
* **Язык:** Python 3.10+.
* **Зависимости:** `beautifulsoup4`, `lxml`, `regex`.
* **Главный класс:** `etpgrf.Typographer`.
* **Принцип работы (Pipeline):**
1. **Анализ структуры:** Типограф проверяет, является ли входной текст полным HTML-документом (есть `<html>`, `<body>`) или фрагментом. Это важно для корректной сборки на выходе.
2. **Парсинг и Санитизация:** Текст парсится через `BeautifulSoup` (предпочтительно `lxml`). Если включен санитайзер, происходит очистка от старой разметки (удаление висячей пунктуации или всех тегов).
3. **Подготовка (Токенизация):** Извлекаются все текстовые узлы (`NavigableString`), которые склеиваются в одну "супер-строку". Это позволяет правилам видеть контекст через границы тегов (например, кавычка в `<b>` и слово после него).
4. **Контекстная обработка:** К "супер-строке" применяются правила, требующие глобального контекста:
* `QuotesProcessor`: расстановка кавычек.
* `Unbreakables`: привязка предлогов и союзов.
5. **Восстановление структуры:** Обработанная "супер-строка" аккуратно нарезается обратно и раскладывается по исходным текстовым узлам DOM-дерева.
6. **Локальная обработка:** Запускается рекурсивный обход дерева. К каждому текстовому узлу применяются локальные правила:
* `SymbolsProcessor`: псевдографика.
* `LayoutProcessor`: тире, спецсимволы.
* `Hyphenator`: расстановка переносов.
7. **Висячая пунктуация:** Модифицируется само DOM-дерево. Символы пунктуации оборачиваются в теги `<span>` с классами для CSS-коррекции.
8. **Финальная сборка:** Дерево сериализуется в строку. Если на входе был фрагмент, возвращается только содержимое `<body>`, чтобы не плодить лишние теги.
## 3. API: Класс Typographer
### Инициализация
```python
from etpgrf import Typographer
typo = Typographer(
langs='ru', # Языки: 'ru', 'en', 'ru+en'
mode='mixed', # Режим вывода: 'unicode', 'mnemonic', 'mixed'
process_html=True, # Обрабатывать HTML-теги (иначе экранирует их)
# Модули (можно отключить, передав False)
hyphenation=True, # Переносы слов
quotes=True, # Кавычки
unbreakables=True, # Неразрывные пробелы (предлоги, союзы)
layout=True, # Тире, спецсимволы, инициалы, единицы измерения
symbols=True, # Псевдографика (стрелочки, копирайт)
# Специфические настройки
sanitizer='etp', # Очистка перед обработкой: 'etp' (удалить висячую), 'html' (удалить все теги)
hanging_punctuation='both' # Висячая пунктуация: 'left', 'right', 'both', None
)
```
### Метод process
```python
html = '<p>Привет, мир!</p>'
result = typo.process(html)
# Результат: '<p>Привет, мир!</p>' (с неразрывными пробелами и т.д.)
```
## 4. Особенности модулей
### 4.1. Висячая пунктуация (`hanging.py`)
* Оборачивает символы (`«`, `„`, `(`, `.`, `,` и др.) в теги `<span class="etp-...">`.
* **Логика:**
* Левые символы (`«`): оборачиваются, если в начале узла ИЛИ перед ними пробел/другой левый символ.
* Правые символы (`»`, `.`): оборачиваются, если в конце узла ИЛИ после них пробел/другой правый символ.
* **Классы CSS:** `etp-laquo`, `etp-r-dot`, `etp-r-comma` и т.д. (см. `config.py`).
* **Важно:** Не добавляет компенсирующие пробелы. Визуализация — задача CSS.
### 4.2. Санитайзер (`sanitizer.py`)
* Запускается **до** основного типографирования.
* `mode='etp'`: Удаляет теги `<span>` с классами висячей пунктуации (unwrap), сохраняя текст.
* `mode='html'`: Удаляет ВСЕ теги, возвращает чистый текст.
### 4.3. Переносы (`hyphenation.py`)
* Использует алгоритм Ляна-Кнута (портирован из `hyphen`).
* Вставляет `&shy;` (мягкий перенос).
* Настройки: `MAX_UNHYPHENATED_LEN` (не переносить длинные слова), `MIN_TAIL_LEN` (минимальный остаток).
### 4.4. Компоновка (`layout.py`)
* Тире: ` - ` -> `&nbsp;— ` (рус), `—` (анг).
* Инициалы: `А. С. Пушкин` -> `А.&nbsp;С.&thinsp;Пушкин`.
* Единицы измерения: `10 км` -> `10&nbsp;км`.
* Сокращения: `и т.д.` -> `и&nbsp;т.&thinsp;д.`.
## 5. Конфигурация (`config.py`)
Все константы (списки слов, коды символов, CSS-классы) находятся здесь.
* `HANGING_PUNCTUATION_CLASSES`: словарь {символ: класс}.
* `PROTECTED_HTML_TAGS`: теги, внутри которых типографика не применяется (`script`, `code`, `pre`...).
## 6. Пример использования (FastAPI/Flask/Django)
```python
from etpgrf import Typographer
# Создаем экземпляр один раз (он stateless, кроме конфигурации)
typo = Typographer(langs='ru', process_html=True, hanging_punctuation='both')
def process_request(text):
return typo.process(text)
```

View File

@@ -1,15 +1,22 @@
# etpgrf — типограф для Web
# etpgrf — единый типограф для веба / effortless typography for web
[![PyPI version](https://badge.fury.io/py/etpgrf.svg)](https://badge.fury.io/py/etpgrf)
[![Python Version](https://img.shields.io/pypi/pyversions/etpgrf.svg)](https://pypi.org/project/etpgrf/)
[![License](https://img.shields.io/pypi/l/etpgrf.svg)](https://pypi.org/project/etpgrf/)
# Типограф для Web
# Типограф для веба
Экранная типографика для веба — способствует повышению читабельности текста в интернете,
приближая его к печатной типографике.
## Репозитории / Repositories
Исходный код доступен на нескольких площадках:
* [Gitea](https://git.cube2.ru/erjemin/2025-etpgrf) (Основной self-hosted)
* [GitHub](https://github.com/erjemin/etpgrf) (Главное зеркало & homepage/issues)
* [GitVerse](https://gitverse.ru/erjemin/etpgrf) (Зеркало на GitVerse)
## Установка
```bash

24
Rules Normal file
View File

@@ -0,0 +1,24 @@
# Правила проекта 2025-etpgrf при обработке ИИ
## Общие сведения
- **Проект:** Python-библиотека для экранной типографики для веб (висячая пунктуация, неразрывные пробелы, перенос слов и т.д.).
- **Язык:** Python 3.10+.
- **Стиль кода:** PEP8.
- **Типизация:** Обязательные Type Hints для аргументов и возвращаемых значений.
- **Язык комментариев:** Русский.
## Архитектура
- **Точка входа:** Класс `Typographer` в `etpgrf/typograph.py`.
- **Обработка HTML:**
- Использовать `BeautifulSoup4` (предпочтительно парсер `lxml`).
- НИКОГДА не парсить HTML регулярными выражениями.
- **Санитизация:** Всегда выполняется *до* рекурсивного обхода дерева.
- **Рекурсия:** Использовать `_walk_tree` для обработки текстовых узлов, сохраняя структуру HTML.
- **Конфигурация:** Все константы (regex, коды символов, классы) должны быть в `etpgrf/config.py`.
## Тестирование
- **Фреймворк:** `pytest`.
- **Структура:**
- Юнит-тесты: `tests/test_<module>.py`.
- Интеграционные тесты: `tests/test_typograph.py`.
- **Философия:** Тестировать как режим простого текста ("plain text"), так и режим HTML.

View File

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

View File

@@ -3,6 +3,7 @@
# Поддерживает обработку текста внутри HTML-тегов с помощью BeautifulSoup.
import logging
import html
import regex # Для проверки наличия корневых тегов
try:
from bs4 import BeautifulSoup, NavigableString
except ImportError:
@@ -164,13 +165,18 @@ class Typographer:
return ""
# Если включена обработка HTML и BeautifulSoup доступен
if self.process_html:
# --- ЭТАП 1: Токенизация и "умная склейка" ---
# --- ЭТАП 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:
soup = BeautifulSoup(text, 'html.parser')
# --- ЭТАП 0: Санитизация (Очистка) ---
if self.sanitizer:
result = self.sanitizer.process(soup)
# Если режим SANITIZE_ALL_HTML, то результат - это строка (чистый текст)
@@ -189,20 +195,21 @@ class Typographer:
# Если результат - soup, продолжаем работу с ним
soup = result
# 1.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать.
# --- ЭТАП 3: Подготовка (токен-стрим) ---
# 3.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать.
# soup.descendants возвращает все дочерние узлы (теги и текст) в порядке их следования.
text_nodes = [node for node in soup.descendants
if isinstance(node, NavigableString)
# and node.strip()
and node.parent.name not in PROTECTED_HTML_TAGS]
# 1.2. Создаем "супер-строку" и "карту длин"
# 3.2. Создаем "супер-строку" и "карту длин"
super_string = ""
lengths_map = []
for node in text_nodes:
super_string += str(node)
lengths_map.append(len(str(node)))
# --- ЭТАП 2: Контекстная обработка (ПОКА ЧТО ПРОПУСКАЕМ) ---
# --- ЭТАП 4: Контекстная обработка ---
processed_super_string = super_string
# Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html).
# Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится.
@@ -211,7 +218,7 @@ class Typographer:
if self.unbreakables:
processed_super_string = self.unbreakables.process(processed_super_string)
# --- ЭТАП 3: "Восстановление" ---
# --- ЭТАП 5: Восстановление структуры ---
current_pos = 0
for i, node in enumerate(text_nodes):
length = lengths_map[i]
@@ -219,18 +226,29 @@ class Typographer:
node.replace_with(new_text_part) # Заменяем содержимое узла на месте
current_pos += length
# --- ЭТАП 4: Локальная обработка (второй проход) ---
# --- ЭТАП 6: Локальная обработка (второй проход) ---
# Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход,
# который применит все остальные правила к каждому текстовому узлу.
self._walk_tree(soup)
# --- ЭТАП 4.5: Висячая пунктуация ---
# --- ЭТАП 7: Висячая пунктуация ---
# Применяем после всех текстовых преобразований, но перед финальной сборкой
if self.hanging:
self.hanging.process(soup)
# --- ЭТАП 5: Финальная сборка ---
processed_html = str(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)
# 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.0"
version = "0.1.3"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
readme = "README.md"
requires-python = ">=3.10"
@@ -26,12 +26,15 @@ classifiers = [
]
dependencies = [
"beautifulsoup4>=4.10.0",
"lxml>=4.9.0", # Рекомендуемый парсер
"lxml>=4.9.0", # Рекомендуемый парсер (в принципе со встроенным html.parser тоже будет работать, но медленнее)
"regex>=2022.1.18", # Критически важная зависимость для Unicode
]
[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"
[tool.setuptools.packages.find]
where = ["."] # Искать пакеты в корне (найдет папку etpgrf)

View File

@@ -149,3 +149,60 @@ def test_typographer_sanitizer_all_html_integration():
typo = Typographer(langs='ru', process_html=True, sanitizer=SANITIZE_ALL_HTML, mode='mixed')
actual_text = typo.process(input_html)
assert actual_text == expected_text
# --- Новые тесты на структуру HTML (проверка отсутствия лишних оберток) ---
HTML_STRUCTURE_TEST_CASES = [
# 1. Фрагмент HTML (без html/body) -> должен остаться фрагментом
('<div>Текст</div>', '<div>Текст</div>'),
('<span>Текст</span>', '<span>Текст</span>'),
('<p>Текст</p>', '<p>Текст</p>'),
# 2. Голый текст -> должен остаться голым текстом (без <p>, <html>, <body>)
('Текст без тегов', 'Текст без&nbsp;тегов'), # Исправлено: ожидаем nbsp
('Текст с <b>тегом</b> внутри', 'Текст с&nbsp;<b>тегом</b> внутри'),
# 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
# 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>'),
]
@pytest.mark.parametrize("input_html, expected_html", HTML_STRUCTURE_TEST_CASES)
def test_typographer_html_structure_preservation(input_html, expected_html):
"""
Проверяет, что Typographer не добавляет лишние теги (html, body, p)
вокруг фрагментов и текста, но сохраняет их, если они были.
"""
# Отключаем все "украшательства" (кавычки, неразрывные пробелы),
# чтобы проверять только структуру тегов.
typo = Typographer(
langs='ru',
process_html=True,
mode='mixed',
hyphenation=False,
quotes=False,
unbreakables=True, # Оставим unbreakables, чтобы проверить, что &nbsp; добавляются, но теги не ломаются
layout=False,
symbols=False
)
actual_html = typo.process(input_html)
# Для теста с doctype может быть нюанс с форматированием (переносы строк),
# поэтому нормализуем пробелы перед сравнением
if '<!DOCTYPE' in input_html:
assert '<html>' in actual_html
assert '<body>' in actual_html
assert '<p>Текст</p>' in actual_html
else:
assert actual_html == expected_html