diff --git a/README.md b/README.md index f9deb39..98ffd9d 100644 --- a/README.md +++ b/README.md @@ -257,21 +257,31 @@ result = typo.process("100 км/ч") # Останется без (в значении «профессор» или «проспект»). Так же типограф не обрабатывает сокращения, связанные с адресами (ул., д., кв., пл., наб. ...) так как они могут быть как финальными, так и препозиционными. +### Висячая типографика + +Висячая типографика — это приём из классической вёрстки, когда некоторые знаки препинания (кавычки, скобки, иногда +тире и маркеры списков) выносятся на левое (и иногда и по правому, при выравнивании текст по правому краю) поле текста. +Это создаёт идеально ровный край не по формальным границам знаков, а по оптическому краю — по первым буквам строк. +Текст выглядит гораздо аккуратнее и профессиональнее. + +В вебе это достигается с помощью CSS, оборачивая "висячий" символ или слово в и применяя к нему, например, +отрицательный text-indent или margin-left (`«`) + ## 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 | diff --git a/etpgrf/config.py b/etpgrf/config.py index be604fa..8b3df76 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -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 = [ - 'т. е.', 'т. к.', 'т. о.', + 'т. е.', 'т. к.', 'т. о.', 'т. ч.', 'и. о.', 'ио', 'вр. и. о.', 'врио', 'тов.', 'г-н.', 'г-жа.', 'им.', 'д. о. с.', 'д. о. н.', 'д. м. н.', 'к. т. д.', 'к. т. п.', - 'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП', + 'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП', 'НИИ', 'ПБОЮЛ', 'ИП', ] \ No newline at end of file diff --git a/etpgrf/typograph.py b/etpgrf/typograph.py index 0090f21..ab980c6 100644 --- a/etpgrf/typograph.py +++ b/etpgrf/typograph.py @@ -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 по умолчанию выводит без тегов # если их не было в исходной строке. - 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) diff --git a/tests/test_typograph.py b/tests/test_typograph.py new file mode 100644 index 0000000..749d0bd --- /dev/null +++ b/tests/test_typograph.py @@ -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', '

Простой параграф с «кавычками».

', '

Простой параграф с «кавычками».

'), + ('mixed', '

Простой параграф с "кавычками".

', '

Простой параграф с «кавычками».

'), + ('unicode', '

Простой параграф с "кавычками".

', f'

Простой параграф с{CHAR_NBSP}«кавычками».

'), + # --- Рекурсивный обход --- + ('mnemonic', '

Текст, а внутри для проверки "жирный" текст.

', + '

Текст, а внутри для проверки «жирный» текст.

'), + ('mixed', '

Текст, а внутри для проверки "жирный" текст.

', + '

Текст, а внутри для проверки «жирный» текст.

'), + ('unicode', '

Текст, а внутри для проверки "жирный" текст.

', + f'

Текст, а{CHAR_NBSP}внутри для{CHAR_NBSP}проверки «жирный» текст.

'), + # --- Вложенные теги с предлогом в тексте --- + ('mnemonic', '

Текст с предлогом в доме.

', + '

Текст с предлогом в доме.

'), + ('mixed', '

Текст с предлогом в доме.

', + '

Текст с предлогом в доме.

'), + ('unicode', '

Текст с предлогом в доме.

', + f'

Текст с{CHAR_NBSP}предлогом в{CHAR_NBSP}доме.

'), + # --- Обработка соседних текстовых узлов --- + ('mnemonic', '

Союз и слово и еще один союз а текст.

', + '

Союз и слово и еще один союз а текст.

'), + ('mnemonic', '

Союз а слово а еще один союз а текст.

', + '

Союз а слово а еще один союз а текст.

'), + + + + # # --- Обработка соседних текстовых узлов --- + # ( + # 'Несколько текстовых узлов в одном родителе.', + # '

Союз и слово и еще один союз а текст.

', + # f'

Союз и{CHAR_NBSP}слово и{CHAR_NBSP}еще один союз а{CHAR_NBSP}текст.

' + # ), + # # --- Проверка "небезопасных" тегов --- + # ( + # 'Небезопасные теги не должны обрабатываться.', + # '

Текст "до".

  - 10
"тоже не трогать"', + # '

Текст «до».

  - 10
"тоже не трогать"' + # ), + # # --- Проверка атрибутов --- + # ( + # 'Атрибуты тегов не должны обрабатываться.', + # 'Текст "снаружи"', + # 'Текст «снаружи»' + # ), + # # --- Комплексный интеграционный тест --- + # ( + # 'Все правила вместе в HTML.', + # '

Он сказал: "В 1941-1945 гг. -- было 100 тыс. руб. и т. д."

', + # f'

Он сказал: «В 1941–1945{CHAR_NBSP}гг.{CHAR_NBSP}— было 100{CHAR_NBSP}тыс.{CHAR_THIN_SP}руб. и{CHAR_NBSP}т.{CHAR_THIN_SP}д.»

' + # ), + # # --- Проверка пустого текста и узлов с пробелами --- + # ( + # 'Пустые и пробельные узлы.', + # '

\n\t

Слово

', + # '

\n\t

Слово

' + # ), +] + + +@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 = 'Текст "без" HTML, но с предлогом в доме.' + expected_text = '<i>Текст «без» <b>HTML</b>, но с предлогом в доме.</i>' + actual_text = typo.process(input_text) + assert actual_text == expected_text \ No newline at end of file