mod: Конвейер типографа с рекурсивным обходом DOM

This commit is contained in:
2025-10-05 14:12:09 +03:00
parent b814504d1e
commit 5adad34fa2
4 changed files with 145 additions and 34 deletions

View File

@@ -257,21 +257,31 @@ result = typo.process("100 км/ч") # Останется без
(в значении «профессор» или «проспект»). Так же типограф не обрабатывает сокращения, связанные с адресами (ул., д.,
кв., пл., наб. ...) так как они могут быть как финальными, так и препозиционными.
### Висячая типографика
Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда
тире и маркеры списков) выносятся на левое (и иногда и по правому, при выравнивании текст по правому краю) поле текста.
Это создаёт идеально ровный край не по формальным границам знаков, а по оптическому краю — по первым буквам строк.
Текст выглядит гораздо аккуратнее и профессиональнее.
В вебе это достигается с помощью CSS, оборачивая "висячий" символ или слово в <span> и применяя к нему, например,
отрицательный text-indent или margin-left (`<span style="margin-left:-0.44em;">&laquo;</span>`)
## P.S.
Если вам нравится этот проект и хотите поддержать его, можете отправить любую сумму на мой Т-банк
Если вам нравится этот, можете поддержать отправив любую сумму на мой Т-банк
[по ссылке](https://tbank.ru/cf/27hMw1BTFMs) или QR-коду.
![Сбор средств](qr-code.png)
Средства пойдут на улучшение моего настроения путем покупки виниловых пластинок. В списке желаний:
| Bar-Code | Artist | Album | Format | | | Label | Цена | |
|---------------|--------------------------|-----------------------------------------|--------|-------------------------|------------|---------------|---------|---|
| 0711297924305 | SUZANNE VEGA | Flying With Angels | LP | Grey Smoke | 02.05.2025 | Cooking Vinyl | ₽ 4099 | + |
| 0602475914716 | CRANBERRIES | No Need To Argue | 2LP | 30th Ann, Deluxe | 15.08.2025 | Island | ₽ 5499 | + |
| ATL 40 022 | IRON BUTTERFLY | In-A-Gadda-Da-Vida | LP | NM/NM, Germany (винтаж) | 1973 | Atlantic | ₽ 2499 | + |
| Bar-Code | Artist | Album | Format | | | Label | Цена | |
|---------------|--------------------------|-----------------------------------------|--------|-------------------------|------------|---------------|---------|--------|
| 0711297924305 | SUZANNE VEGA | Flying With Angels | LP | Grey Smoke | 02.05.2025 | Cooking Vinyl | ₽ 4099 | куплен |
| 0602475914716 | CRANBERRIES | No Need To Argue | 2LP | 30th Ann, Deluxe | 15.08.2025 | Island | ₽ 5499 | куплен |
| ATL 40 022 | IRON BUTTERFLY | In-A-Gadda-Da-Vida | LP | NM/NM, Germany (винтаж) | 1973 | Atlantic | ₽ 2499 | куплен |
| 5400863145637 | EELS | So Good | LP | coloured | 15.12.2023 | | ₽ 4360 |
| 5400863157845 | EELS | Time! | LP | coloured | 07.06.2024 | | ₽ 4940 |
| 8719262034853 | NICK CAVE & WARREN ELLIS | Mars (Original Sound Track) | LP | coloured | 12.07.2024 | | ₽ 3440 |

View File

@@ -131,12 +131,15 @@ SAFE_MODE_CHARS_TO_MNEMONIC = frozenset([
# 3. СПИСОК ДЛЯ ЧИСЛОВОГО КОДИРОВАНИЯ: Символы без стандартного имени.
ALWAYS_ENCODE_TO_NUMERIC_CHARS = frozenset([
'\u058F', # Знак армянского драма (֏)
'\u20BD', # Знак русского рубля (₽)
'\u20B4', # Знак украинской гривны (₴)
'\u20B8', # Знак казахстанского тенге (₸)
'\u20B9', # Знак индийской рупии (₹)
'\u20BA', # Знак турецкой лиры (₺)
'\u20BB', # Знак итальянской лиры (₻)
'\u20BC', # Знак азербайджанского маната
'\u20BD', # Знак русского рубля (₽)
'\u20BE', # Знак грузинский лари (₾)
'\u20BF', # Знак биткоина (₿)
])
# 4. СЛОВАРЬ ПРИОРИТЕТОВ: Кастомные и/или предпочитаемые мнемоники.
@@ -649,7 +652,8 @@ DEFAULT_POST_UNITS = [
'in', 'ft', 'yd', 'mi', 'oz', 'lb', 'st', 'pt', 'qt', 'gal', 'mph', 'rpm', 'hp', 'psi', 'cal',
]
# Пред-позиционные (№ 5, $ 10)
DEFAULT_PRE_UNITS = ['', '$', '', '£', '', '#', '§']
DEFAULT_PRE_UNITS = ['', '$', '', '£', '', '#', '§', '¤', '', '', '', '', '', '', '', '', '', '',
'ГОСТ', 'ТУ', 'ИСО', 'DIN', 'ASTM', 'EN', 'IEC', 'IEEE'] # технические стандарты перед числом работают как единицы измерения
# Операторы, которые могут стоять между единицами измерения (км/ч)
# Сложение и вычитание здесь намеренно отсутствуют.
@@ -665,9 +669,9 @@ ABBR_COMMON_FINAL = [
]
ABBR_COMMON_PREPOSITION = [
'т. е.', 'т. к.', 'т. о.',
'т. е.', 'т. к.', 'т. о.', 'т. ч.',
'и. о.', 'ио', 'вр. и. о.', 'врио',
'тов.', 'г-н.', 'г-жа.', 'им.',
'д. о. с.', 'д. о. н.', 'д. м. н.', 'к. т. д.', 'к. т. п.',
'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП',
'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП', 'НИИ', 'ПБОЮЛ', 'ИП',
]

View File

@@ -1,3 +1,6 @@
# etpgrf/typograph.py
# Основной класс Typographer, который объединяет все модули правил и предоставляет единый интерфейс.
# Поддерживает обработку текста внутри HTML-тегов с помощью BeautifulSoup.
import logging
import html
try:
@@ -117,12 +120,28 @@ class Typographer:
processed_text = self.hyphenation.hyp_in_text(processed_text)
# ... вызовы других активных модулей правил ...
return 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 ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math']:
# Если это "безопасный" тег, рекурсивно заходим в него
self._walk_tree(child)
# Конвейер для обработки текста
def process(self, text: str) -> str:
"""
Обрабатывает текст, применяя все активные правила типографики.
@@ -134,28 +153,16 @@ class Typographer:
if self.process_html:
# Мы передаем 'html.parser', он быстрый и встроенный.
soup = BeautifulSoup(markup=text, features='html.parser')
text_nodes = soup.find_all(string=True)
for node in text_nodes:
# Пропускаем пустые или состоящие из пробелов узлы и узлы внутри тегов, где не нужно обрабатывать текст
if not node.string.strip() or node.parent.name in ['style', 'script', 'pre', 'code']:
continue
# К каждому текстовому узлу применяем "внутренний" процессор
processed_node_text: str = self._process_text_node(node.string)
# Отладочная печать, чтобы видеть, что происходит
if node.string != processed_node_text:
logger.info(f"Processing node: '{node.string}' -> '{processed_node_text}'")
# Заменяем узел в дереве на обработанный текст.
# BeautifulSoup сама позаботится об экранировании, если нужно.
# Важно: мы не можем просто заменить строку, нужно создать новый объект NavigableString,
# чтобы BeautifulSoup правильно обработал символы вроде '<' и '>'.
# Однако, replace_with достаточно умен, чтобы справиться с этим.
node.replace_with(processed_node_text)
# Запускаем рекурсивный обход дерева, начиная с корневого элемента
self._walk_tree(soup)
# Получаем измененный HTML. BeautifulSoup по умолчанию выводит без тегов <html><body>
# если их не было в исходной строке.
processed = str(soup)
processed_html = str(soup)
# Финальный шаг: BeautifulSoup по умолчанию экранирует амперсанды (& -> &amp;).
# Но наш кодек encode_from_unicode() тоже это делает. Так что мы получаем двойное экранирование.
# Чтобы избежать этого, мы просто заменяем &amp; обратно на &.
return processed_html.replace('&amp;', '&')
else:
# Если HTML-режим выключен
processed = self._process_text_node(text)
# Возвращаем
return encode_from_unicode(processed, self.mode)
return self._process_text_node(text)

90
tests/test_typograph.py Normal file
View File

@@ -0,0 +1,90 @@
# tests/test_typograph.py
# Тестирует основной класс Typographer и его конвейер обработки.
import pytest
from etpgrf import Typographer
from etpgrf.config import CHAR_NBSP, CHAR_THIN_SP
TYPOGRAPHER_HTML_TEST_CASES = [
# --- Базовая обработка без HTML ---
('mnemonic', 'Простой текст с "кавычками".', f'Простой текст с&nbsp;&laquo;кавычками&raquo;.'),
('mixed', 'Простой текст с "кавычками".', f'Простой текст с&nbsp;«кавычками».'),
('unicode', 'Простой текст с "кавычками".', f'Простой текст с{CHAR_NBSP}«кавычками».'),
# --- Базовая обработка с HTML ---
('mnemonic', '<p>Простой параграф с «кавычками».</p>', '<p>Простой параграф с&nbsp;&laquo;кавычками&raquo;.</p>'),
('mixed', '<p>Простой параграф с "кавычками".</p>', '<p>Простой параграф с&nbsp;«кавычками».</p>'),
('unicode', '<p>Простой параграф с "кавычками".</p>', f'<p>Простой параграф с{CHAR_NBSP}«кавычками».</p>'),
# --- Рекурсивный обход ---
('mnemonic', '<div><p>Текст, а внутри <b>для проверки "жирный"</b> текст.</p></div>',
'<div><p>Текст, а&nbsp;внутри <b>для&nbsp;проверки &laquo;жирный&raquo;</b> текст.</p></div>'),
('mixed', '<div><p>Текст, а внутри <b>для проверки "жирный"</b> текст.</p></div>',
'<div><p>Текст, а&nbsp;внутри <b>для&nbsp;проверки «жирный»</b> текст.</p></div>'),
('unicode', '<div><p>Текст, а внутри <b>для проверки "жирный"</b> текст.</p></div>',
f'<div><p>Текст, а{CHAR_NBSP}внутри <b>для{CHAR_NBSP}проверки «жирный»</b> текст.</p></div>'),
# --- Вложенные теги с предлогом в тексте ---
('mnemonic', '<div><p>Текст с предлогом <b>в <i>доме</i></b>.</p></div>',
'<div><p>Текст с&nbsp;предлогом <b>в&nbsp;<i>доме</i></b>.</p></div>'),
('mixed', '<div><p>Текст с предлогом <b>в <i>доме</i></b>.</p></div>',
'<div><p>Текст с&nbsp;предлогом <b>в&nbsp;<i>доме</i></b>.</p></div>'),
('unicode', '<div><p>Текст с предлогом <b>в <i>доме</i></b>.</p></div>',
f'<div><p>Текст с{CHAR_NBSP}предлогом <b>в{CHAR_NBSP}<i>доме</i></b>.</p></div>'),
# --- Обработка соседних текстовых узлов ---
('mnemonic', '<p>Союз и <b>слово</b> и еще один союз а <span>текст</span>.</p>',
'<p>Союз и&nbsp;<b>слово</b> и&nbsp;еще один союз а&nbsp;<span>текст</span>.</p>'),
('mnemonic', '<p>Союз а <b>слово</b> а еще один союз а <span>текст</span>.</p>',
'<p>Союз а&nbsp;<b>слово</b> а&nbsp;еще один союз а&nbsp;<span>текст</span>.</p>'),
# # --- Обработка соседних текстовых узлов ---
# (
# 'Несколько текстовых узлов в одном родителе.',
# '<p>Союз и <b>слово</b> и еще один союз а <span>текст</span>.</p>',
# f'<p>Союз и{CHAR_NBSP}<b>слово</b> и{CHAR_NBSP}еще один союз а{CHAR_NBSP}<span>текст</span>.</p>'
# ),
# # --- Проверка "небезопасных" тегов ---
# (
# 'Небезопасные теги не должны обрабатываться.',
# '<p>Текст "до".</p><script>var text = "не трогать";</script><pre> - 10</pre><code>"тоже не трогать"</code>',
# '<p>Текст «до».</p><script>var text = "не трогать";</script><pre> - 10</pre><code>"тоже не трогать"</code>'
# ),
# # --- Проверка атрибутов ---
# (
# 'Атрибуты тегов не должны обрабатываться.',
# '<a href="/a-b" title="Текст в кавычках \'внутри\' атрибута">Текст "снаружи"</a>',
# '<a href="/a-b" title="Текст в кавычках \'внутри\' атрибута">Текст «снаружи»</a>'
# ),
# # --- Комплексный интеграционный тест ---
# (
# 'Все правила вместе в HTML.',
# '<p>Он сказал: "В 1941-1945 гг. -- было 100 тыс. руб. и т. д."</p>',
# f'<p>Он сказал: «В 19411945{CHAR_NBSP}гг.{CHAR_NBSP}— было 100{CHAR_NBSP}тыс.{CHAR_THIN_SP}руб. и{CHAR_NBSP}т.{CHAR_THIN_SP}д.»</p>'
# ),
# # --- Проверка пустого текста и узлов с пробелами ---
# (
# 'Пустые и пробельные узлы.',
# '<p> </p><div>\n\t</div><p>Слово</p>',
# '<p> </p><div>\n\t</div><p>Слово</p>'
# ),
]
@pytest.mark.parametrize("mode, input_html, expected_html", TYPOGRAPHER_HTML_TEST_CASES)
def test_typographer_html_processing(mode, input_html, expected_html):
"""
Проверяет полный конвейер Typographer при обработке HTML.
"""
typo = Typographer(langs='ru', process_html=True, mode=mode)
actual_html = typo.process(input_html)
assert actual_html == expected_html
def test_typographer_plain_text_processing():
"""
Проверяет, что в режиме process_html=False типограф маскирует HTML-теги и обрабатывает весь текст.
"""
typo = Typographer(langs='ru', process_html=False)
input_text = '<i>Текст "без" <b>HTML</b>, но с предлогом в доме.</i>'
expected_text = '&lt;i&gt;Текст «без» &lt;b&gt;HTML&lt;/b&gt;, но&nbsp;с&nbsp;предлогом в&nbsp;доме.&lt;/i&gt;'
actual_text = typo.process(input_text)
assert actual_text == expected_text