mod: Конвейер типографа с рекурсивным обходом DOM
This commit is contained in:
22
README.md
22
README.md
@@ -257,21 +257,31 @@ result = typo.process("100 км/ч") # Останется без
|
||||
(в значении «профессор» или «проспект»). Так же типограф не обрабатывает сокращения, связанные с адресами (ул., д.,
|
||||
кв., пл., наб. ...) так как они могут быть как финальными, так и препозиционными.
|
||||
|
||||
### Висячая типографика
|
||||
|
||||
Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда
|
||||
тире и маркеры списков) выносятся на левое (и иногда и по правому, при выравнивании текст по правому краю) поле текста.
|
||||
Это создаёт идеально ровный край не по формальным границам знаков, а по оптическому краю — по первым буквам строк.
|
||||
Текст выглядит гораздо аккуратнее и профессиональнее.
|
||||
|
||||
В вебе это достигается с помощью CSS, оборачивая "висячий" символ или слово в <span> и применяя к нему, например,
|
||||
отрицательный text-indent или margin-left (`<span style="margin-left:-0.44em;">«</span>`)
|
||||
|
||||
|
||||
## P.S.
|
||||
|
||||
Если вам нравится этот проект и хотите поддержать его, можете отправить любую сумму на мой Т-банк
|
||||
Если вам нравится этот, можете поддержать отправив любую сумму на мой Т-банк
|
||||
[по ссылке](https://tbank.ru/cf/27hMw1BTFMs) или QR-коду.
|
||||
|
||||

|
||||
|
||||
Средства пойдут на улучшение моего настроения путем покупки виниловых пластинок. В списке желаний:
|
||||
|
||||
| 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 |
|
||||
|
@@ -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 = [
|
||||
'т. е.', 'т. к.', 'т. о.',
|
||||
'т. е.', 'т. к.', 'т. о.', 'т. ч.',
|
||||
'и. о.', 'ио', 'вр. и. о.', 'врио',
|
||||
'тов.', 'г-н.', 'г-жа.', 'им.',
|
||||
'д. о. с.', 'д. о. н.', 'д. м. н.', 'к. т. д.', 'к. т. п.',
|
||||
'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП',
|
||||
'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП', 'НИИ', 'ПБОЮЛ', 'ИП',
|
||||
]
|
@@ -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 по умолчанию экранирует амперсанды (& -> &).
|
||||
# Но наш кодек encode_from_unicode() тоже это делает. Так что мы получаем двойное экранирование.
|
||||
# Чтобы избежать этого, мы просто заменяем & обратно на &.
|
||||
return processed_html.replace('&', '&')
|
||||
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
90
tests/test_typograph.py
Normal 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'Простой текст с «кавычками».'),
|
||||
('mixed', 'Простой текст с "кавычками".', f'Простой текст с «кавычками».'),
|
||||
('unicode', 'Простой текст с "кавычками".', f'Простой текст с{CHAR_NBSP}«кавычками».'),
|
||||
# --- Базовая обработка с HTML ---
|
||||
('mnemonic', '<p>Простой параграф с «кавычками».</p>', '<p>Простой параграф с «кавычками».</p>'),
|
||||
('mixed', '<p>Простой параграф с "кавычками".</p>', '<p>Простой параграф с «кавычками».</p>'),
|
||||
('unicode', '<p>Простой параграф с "кавычками".</p>', f'<p>Простой параграф с{CHAR_NBSP}«кавычками».</p>'),
|
||||
# --- Рекурсивный обход ---
|
||||
('mnemonic', '<div><p>Текст, а внутри <b>для проверки "жирный"</b> текст.</p></div>',
|
||||
'<div><p>Текст, а внутри <b>для проверки «жирный»</b> текст.</p></div>'),
|
||||
('mixed', '<div><p>Текст, а внутри <b>для проверки "жирный"</b> текст.</p></div>',
|
||||
'<div><p>Текст, а внутри <b>для проверки «жирный»</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>Текст с предлогом <b>в <i>доме</i></b>.</p></div>'),
|
||||
('mixed', '<div><p>Текст с предлогом <b>в <i>доме</i></b>.</p></div>',
|
||||
'<div><p>Текст с предлогом <b>в <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>Союз и <b>слово</b> и еще один союз а <span>текст</span>.</p>'),
|
||||
('mnemonic', '<p>Союз а <b>слово</b> а еще один союз а <span>текст</span>.</p>',
|
||||
'<p>Союз а <b>слово</b> а еще один союз а <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>Он сказал: «В 1941–1945{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 = '<i>Текст «без» <b>HTML</b>, но с предлогом в доме.</i>'
|
||||
actual_text = typo.process(input_text)
|
||||
assert actual_text == expected_text
|
Reference in New Issue
Block a user