From 579903cc6d16f6b3bc4e871ba85d962889db566d Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 12 Oct 2025 20:16:02 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D0=B4=D0=B2=D1=83=D1=85=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=85=D0=BE=D0=B4=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=B2=D0=B5=D0=B9=D0=B5=D1=80=20=D1=82=D0=B8=D0=BF=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=84=D0=B0=20(=D1=82=D0=B5=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B1=D0=BB=D0=B5=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D0=BA=D0=B0=D0=B2=D1=8B?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=BD=D0=B5=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=B8=D0=B7-=D0=B7?= =?UTF-8?q?=D0=B0=20html-=D1=82=D0=B5=D0=B3=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etpgrf/config.py | 5 ++- etpgrf/typograph.py | 83 ++++++++++++++++++++++++++--------- tests/test_layout.py | 10 ++++- tests/test_typograph.py | 95 ++++++++++++++++++++++++++++------------- 4 files changed, 143 insertions(+), 50 deletions(-) diff --git a/etpgrf/config.py b/etpgrf/config.py index 8b3df76..4307540 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -674,4 +674,7 @@ ABBR_COMMON_PREPOSITION = [ 'тов.', 'г-н.', 'г-жа.', 'им.', 'д. о. с.', 'д. о. н.', 'д. м. н.', 'к. т. д.', 'к. т. п.', 'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП', 'НИИ', 'ПБОЮЛ', 'ИП', -] \ No newline at end of file +] + +# === КОНСТАНТЫ ДЛЯ HTML-ТЕГОВ, ВНУТРИ КОТОРЫХ НЕ НАДО ТИПОГРАФИРОВАТЬ === +PROTECTED_HTML_TAGS = ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math'] \ No newline at end of file diff --git a/etpgrf/typograph.py b/etpgrf/typograph.py index 69ec21b..07ab5a9 100644 --- a/etpgrf/typograph.py +++ b/etpgrf/typograph.py @@ -14,6 +14,7 @@ from etpgrf.quotes import QuotesProcessor from etpgrf.layout import LayoutProcessor from etpgrf.symbols import SymbolsProcessor from etpgrf.codec import decode_to_unicode, encode_from_unicode +from etpgrf.config import PROTECTED_HTML_TAGS # --- Настройки логирования --- @@ -107,13 +108,9 @@ class Typographer: processed_text = decode_to_unicode(text) # processed_text = text # ВРЕМЕННО: используем текст как есть - # Шаг 2: Применяем правила к чистому Unicode-тексту + # Шаг 2: Применяем правила к чистому Unicode-тексту (только правила на уровне ноды) if self.symbols is not None: processed_text = self.symbols.process(processed_text) - if self.quotes is not None: - processed_text = self.quotes.process(processed_text) - if self.unbreakables is not None: - processed_text = self.unbreakables.process(processed_text) if self.layout is not None: processed_text = self.layout.process(processed_text) if self.hyphenation is not None: @@ -138,8 +135,8 @@ class Typographer: 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']: - # Если это "безопасный" тег, рекурсивно заходим в него + elif child.name not in PROTECTED_HTML_TAGS: + # Если это "обычный" html-тег, рекурсивно заходим в него self._walk_tree(child) def process(self, text: str) -> str: @@ -151,18 +148,66 @@ class Typographer: return "" # Если включена обработка HTML и BeautifulSoup доступен if self.process_html: - # Мы передаем 'html.parser', он быстрый и встроенный. - soup = BeautifulSoup(markup=text, features='html.parser') - # Запускаем рекурсивный обход дерева, начиная с корневого элемента - self._walk_tree(soup) - # Получаем измененный HTML. BeautifulSoup по умолчанию выводит без тегов - # если их не было в исходной строке. - processed_html = str(soup) + # --- ЭТАП 1: Токенизация и "умная склейка" --- + try: + soup = BeautifulSoup(text, 'lxml') + except Exception: + soup = BeautifulSoup(text, 'html.parser') + # 1.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. Создаем "супер-строку" и "карту длин" + super_string = "" + lengths_map = [] + for node in text_nodes: + super_string += str(node) + lengths_map.append(len(str(node))) - # Финальный шаг: BeautifulSoup по умолчанию экранирует амперсанды (& -> &). - # Но наш кодек encode_from_unicode() тоже это делает. Так что мы получаем двойное экранирование. - # Чтобы избежать этого, мы просто заменяем & обратно на &. + # --- ЭТАП 2: Контекстная обработка (ПОКА ЧТО ПРОПУСКАЕМ) --- + processed_super_string = super_string + # Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html). + # Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится. + if self.quotes: + processed_super_string = self.quotes.process(processed_super_string) + if self.unbreakables: + processed_super_string = self.unbreakables.process(processed_super_string) + + # --- ЭТАП 3: "Восстановление" --- + current_pos = 0 + for i, node in enumerate(text_nodes): + length = lengths_map[i] + new_text_part = processed_super_string[current_pos : current_pos + length] + node.replace_with(new_text_part) # Заменяем содержимое узла на месте + current_pos += length + + # --- ЭТАП 4: Локальная обработка (второй проход) --- + # Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход, + # который применит все остальные правила к каждому текстовому узлу. + self._walk_tree(soup) + + # --- ЭТАП 5: Финальная сборка --- + processed_html = str(soup) + # BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали + # в _process_text_node. Возвращаем их обратно. return processed_html.replace('&', '&') else: - # Если HTML-режим выключен - return self._process_text_node(text) + # Если HTML-режим выключен, используем полный конвейер для простого текста. + # Шаг 0: Нормализация + processed_text = decode_to_unicode(text) + # Шаг 1: Применяем все правила последовательно + if self.quotes: + processed_text = self.quotes.process(processed_text) + if self.unbreakables: + processed_text = self.unbreakables.process(processed_text) + if self.symbols: + processed_text = self.symbols.process(processed_text) + if self.layout: + processed_text = self.layout.process(processed_text) + if self.hyphenation: + processed_text = self.hyphenation.hyp_in_text(processed_text) + # Шаг 2: Финальное кодирование + return encode_from_unicode(processed_text, self.mode) + diff --git a/tests/test_layout.py b/tests/test_layout.py index 25792e8..1848456 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -116,6 +116,8 @@ LAYOUT_TEST_CASES = [ # Составные и математические единицы ('ru', "Площадь 120 кв. м.", f"Площадь 120{CHAR_NBSP}кв.{CHAR_THIN_SP}м."), ('ru', "Площадь 130 кв.м.", f"Площадь 130{CHAR_NBSP}кв.{CHAR_THIN_SP}м."), + ('ru', "Площадь 130 м²", f"Площадь 130{CHAR_NBSP}м²"), + ('ru', "Площадь 130м²", f"Площадь 130м²"), ('ru', f"Площадь 140 {CHAR_NBSP} кв.{CHAR_NBSP}м.", f"Площадь 140{CHAR_NBSP}кв.{CHAR_THIN_SP}м."), ('ru', "Площадь 150 тыс. кв. км.", f"Площадь 150{CHAR_NBSP}тыс.{CHAR_THIN_SP}кв.{CHAR_THIN_SP}км."), ('ru', "Скорость 90 км/ч", f"Скорость 90{CHAR_NBSP}км/ч"), @@ -123,11 +125,17 @@ LAYOUT_TEST_CASES = [ ('ru', "В 500 г. н. э.", f"В 500{CHAR_NBSP}г.{CHAR_THIN_SP}н.{CHAR_THIN_SP}э."), ('ru', "Пластинка 45 мин. об.", f"Пластинка 45{CHAR_NBSP}мин.{CHAR_THIN_SP}об."), ('ru', "Пластинка 45 об. мин.", f"Пластинка 45{CHAR_NBSP}об.{CHAR_THIN_SP}мин."), - ('ru', "За окном 15°C", f"За окном 15°C"), ('ru', "За окном 15 °C", f"За окном 15{CHAR_NBSP}°C"), ('ru', "HiFi 20 Гц - 20 кГц", f"HiFi 20{CHAR_NBSP}Гц - 20{CHAR_NBSP}кГц"), + # Случаи когда единица измерения вплотную к числу и не должны меняться + ('ru', "Площадь 130м²", f"Площадь 130м²"), + ('ru', "За окном 15°C", f"За окном 15°C"), + ('ru', "Скорость 90км/ч", f"Скорость 90км/ч"), + ('ru', "Зачислено на счёт $5тыс.", f"Зачислено на счёт $5тыс."), + ('ru', "Зачислено на счёт $5000", f"Зачислено на счёт $5000"), # Сложные единицы (склеиваются тонкой шпацией, привязываются к числу неразрывным пробелом) + ('ru', "Зачислено на счёт 10 млн.руб.", f"Зачислено на счёт 10{CHAR_NBSP}млн.{CHAR_THIN_SP}руб."), ('ru', "Дом 120 кв.м. / Участок 6 сот.", f"Дом 120{CHAR_NBSP}кв.{CHAR_THIN_SP}м. / Участок 6{CHAR_NBSP}сот."), # ('ru', "Гробик кладут в ямку 2 кв. м.", f"Гробик кладут в ямку 2 кв. м."), ('ru', "500 до н. э.", f"500 до н.{CHAR_THIN_SP}э."), diff --git a/tests/test_typograph.py b/tests/test_typograph.py index d75d596..c0ea4c0 100644 --- a/tests/test_typograph.py +++ b/tests/test_typograph.py @@ -3,7 +3,7 @@ import pytest from etpgrf import Typographer -from etpgrf.config import CHAR_NBSP, CHAR_THIN_SP +from etpgrf.config import CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH TYPOGRAPHER_HTML_TEST_CASES = [ # --- Базовая обработка без HTML --- @@ -32,37 +32,74 @@ TYPOGRAPHER_HTML_TEST_CASES = [ ('mnemonic', '

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

', '

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

'), ('mixed', '

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

', - '

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

'), + '

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

'), ('unicode', '

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

', - f'

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

'), + f'

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

'), + + # --- Проверка тегов ', + '

Текст «до».

'), + ('mixed', '

Текст "до".

', + '

Текст «до».

'), + ('mixed', '

Текст "до".

Ctrl + C', + '

Текст «до».

Ctrl + C'), + ('mixed', '

Текст "до".

Sample "text"', + '

Текст «до».

Sample "text"'), + ('mixed', '

Текст "до".

x=5', + '

Текст «до».

x=5'), + + # --- Проверка тегов с атрибутами --- + ('mixed', 'Текст "снаружи"', + 'Текст «снаружи»'), + ('mixed', 'Текст "снаружи"', + 'Текст «снаружи»'), + ('mixed', 'Текст "снаружи"', + 'Текст «снаружи»'), + ('mnemonic', 'Текст "снаружи"', + 'Текст «снаружи»'), + + # --- Комплексный интеграционный тест --- + ('mnemonic', '

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

', + '

Он сказал: «В 1941–1945 гг. – было 100 тыс. руб.' + ' и т. д.»

'), + ('mixed', '

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

', + '

Он сказал: «В 1941–1945 гг. – было 100 тыс. руб.' + ' и т. д.»

'), + ('unicode', '

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

', + f'

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

'), + # --- Теги внутри кавычек --- + ('mnemonic', '

"Почему", "зачем" и "кому это выгодно" -- вопросы требующие ответа.

', + '

«Почему», «зачем» и «кому это выгодно' + '» – вопросы требующие ответа.

'), + ('mixed', '

"Почему", "зачем" и "кому это выгодно" -- вопросы требующие ответа.

', + '

«Почему», «зачем» и «кому это выгодно» – вопросы требующие ответа.

'), + ('unicode', '

"Почему", "зачем" и "кому это выгодно" -- вопросы требующие ответа.

', + f'

«Почему», «зачем» и{CHAR_NBSP}«кому это выгодно»{CHAR_NBSP}{CHAR_NDASH} вопросы требующие ответа.

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

\n\t

Слово

', '

\n

Слово

'), + ('mixed', '

\n\t

Слово

', '

\n

Слово

'), + ('unicode', '

\n\t

Слово

', '

\n

Слово

'), + + # --- Самозакрывающиеся теги и теги с атрибутами --- + # ВАЖНО: порядок атрибутов в типографированном тексте может быть произвольным + ('mnemonic', '

Текст с картинкой image и текстом.

', + '

Текст с картинкой image и текстом.

'), + ('mnemonic', '

Текст с <br>
А это новая строка.

', + '

Текст с <br>
А это новая строка.

'), + ('mixed', '

Текст с картинкой image и текстом.

', + '

Текст с картинкой image и текстом.

'), + ('mixed', '

Текст с <br>
А это новая строка.

', + '

Текст с <br>
А это новая строка.

'), + ('unicode', '

Текст с картинкой image и текстом.

', + f'

Текст с{CHAR_NBSP}картинкой image и{CHAR_NBSP}текстом.

'), + ('unicode', '

Текст с <br>
А это новая строка.

', + f'

Текст с{CHAR_NBSP}<br>
А{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

Слово

' - # ), ]