Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2112669f | |||
| d94815d7ee | |||
| cb31c5a3b7 | |||
| 97777a7d0a | |||
| 28b74f0d7e | |||
| f35a48a0ae | |||
| 7a7f9dc4cc | |||
| 8f01b1961e | |||
| a77cd3fa46 | |||
| 9d8b5ec55e | |||
| 75a78118ba | |||
| 6b07fd472b | |||
| 00efdde999 |
35
CHANGELOG.md
Normal file
35
CHANGELOG.md
Normal 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
106
LIBRARY_SPECS.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Библиотека etpgrf: Контекст и Спецификация
|
||||
|
||||
Этот документ описывает архитектуру и API библиотеки `etpgrf` (Electro-Typographer), предназначенной для типографирования текста в вебе. Используйте этот контекст при разработке зависимых проектов (например, веб-сайтов).
|
||||
|
||||
## 1. Назначение
|
||||
Библиотека выполняет комплексную обработку текста для улучшения его читаемости и внешнего вида в браузере:
|
||||
* Замена кавычек («ёлочки», „лапки“).
|
||||
* Расстановка неразрывных пробелов (предлоги, союзы, инициалы, единицы измерения).
|
||||
* Замена дефисов на тире (—, –).
|
||||
* Преобразование псевдографики (`...` -> `…`, `(c)` -> `©`).
|
||||
* Расстановка мягких переносов (`­`).
|
||||
* **Висячая пунктуация** (оборачивание символов в `<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`).
|
||||
* Вставляет `­` (мягкий перенос).
|
||||
* Настройки: `MAX_UNHYPHENATED_LEN` (не переносить длинные слова), `MIN_TAIL_LEN` (минимальный остаток).
|
||||
|
||||
### 4.4. Компоновка (`layout.py`)
|
||||
* Тире: ` - ` -> ` — ` (рус), `—` (анг).
|
||||
* Инициалы: `А. С. Пушкин` -> `А. С. Пушкин`.
|
||||
* Единицы измерения: `10 км` -> `10 км`.
|
||||
* Сокращения: `и т.д.` -> `и т. д.`.
|
||||
|
||||
## 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)
|
||||
```
|
||||
11
README.md
11
README.md
@@ -1,15 +1,22 @@
|
||||
# etpgrf — типограф для Web
|
||||
# etpgrf — единый типограф для веба / effortless typography for web
|
||||
|
||||
[](https://badge.fury.io/py/etpgrf)
|
||||
[](https://pypi.org/project/etpgrf/)
|
||||
[](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
24
Rules
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали
|
||||
# в _process_text_node. Возвращаем их обратно.
|
||||
return processed_html.replace('&', '&')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
('Текст с <b>тегом</b> внутри', 'Текст с <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, чтобы проверить, что добавляются, но теги не ломаются
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user