From 48c90409b8132c098959be6539d57284c5cd6021 Mon Sep 17 00:00:00 2001 From: erjemin Date: Tue, 28 Oct 2025 23:46:38 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D0=A1=D0=B0=D0=BD=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D0=B9=D0=B7=D0=B5=D1=80=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BA=D0=B8=20=D0=BE=D1=82=20HTML=20(=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etpgrf/__init__.py | 11 ++++-- etpgrf/config.py | 44 ++++++++++++++++++++++- etpgrf/sanitizer.py | 62 ++++++++++++++++++++++++++++++++ tests/test_sanitizer.py | 80 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 etpgrf/sanitizer.py create mode 100644 tests/test_sanitizer.py diff --git a/etpgrf/__init__.py b/etpgrf/__init__.py index 67d34e7..fee94ed 100644 --- a/etpgrf/__init__.py +++ b/etpgrf/__init__.py @@ -11,7 +11,12 @@ etpgrf - библиотека для экранной типографики т __version__ = "0.1.0" import etpgrf.defaults -from etpgrf.typograph import Typographer -from etpgrf.hyphenation import Hyphenator -from etpgrf.unbreakables import Unbreakables import etpgrf.logger + +from etpgrf.hyphenation import Hyphenator +from etpgrf.layout import LayoutProcessor +from etpgrf.quotes import QuotesProcessor +from etpgrf.sanitizer import SanitizerProcessor +from etpgrf.symbols import SymbolsProcessor +from etpgrf.typograph import Typographer +from etpgrf.unbreakables import Unbreakables diff --git a/etpgrf/config.py b/etpgrf/config.py index 4307540..11bbdc7 100644 --- a/etpgrf/config.py +++ b/etpgrf/config.py @@ -15,6 +15,12 @@ LANG_EN = 'en' # Английский SUPPORTED_LANGS = frozenset([LANG_RU, LANG_RU_OLD, LANG_EN]) DEFAULT_LANGS = (LANG_RU, LANG_EN) # Языки по умолчанию +# Виды санитизации (очистки) входного текста +SANITIZE_ALL_HTML = "html" # Полная очистка от HTML-тегов +SANITIZE_ETPGRF = "etp" # Очистка от "span-оберток" символов висячей пунктуации (если она была расставлена + # при предыдущих проходах типографа) +SANITIZE_NONE = None # Без очистки (режим по умолчанию). False тоже можно использовать. + # === ИСТОЧНИК ПРАВДЫ === # --- Базовые алфавиты: Эти константы используются как для правил переноса, так и для правил кодирования --- @@ -677,4 +683,40 @@ ABBR_COMMON_PREPOSITION = [ ] # === КОНСТАНТЫ ДЛЯ HTML-ТЕГОВ, ВНУТРИ КОТОРЫХ НЕ НАДО ТИПОГРАФИРОВАТЬ === -PROTECTED_HTML_TAGS = ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math'] \ No newline at end of file +PROTECTED_HTML_TAGS = ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math'] + +# === КОНСТАНТЫ ДЛЯ ВИСЯЧЕЙ ТИПОГРАФИКИ === + +# 1. Набор символов, которые могут "висеть" слева +HANGING_PUNCTUATION_LEFT_CHARS = frozenset([ + CHAR_RU_QUOT1_OPEN, # « + CHAR_EN_QUOT1_OPEN, # “ + '(', '[', '{', +]) + +# 2. Набор символов, которые могут "висеть" справа +HANGING_PUNCTUATION_RIGHT_CHARS = frozenset([ + CHAR_RU_QUOT1_CLOSE, # » + CHAR_EN_QUOT1_CLOSE, # ” + ')', ']', '}', + '.', ',', ':', +]) + +# 3. Словарь, сопоставляющий символ с его CSS-классом +HANGING_PUNCTUATION_CLASSES = { + # Левая пунктуация: все классы начинаются с 'etp-l' + CHAR_RU_QUOT1_OPEN: 'etp-laquo', + CHAR_EN_QUOT1_OPEN: 'etp-ldquo', + '(': 'etp-lpar', + '[': 'etp-lsqb', + '{': 'etp-lcub', + # Правая пунктуация: все классы начинаются с 'etp-r' + CHAR_RU_QUOT1_CLOSE: 'etp-raquo', + CHAR_EN_QUOT1_CLOSE: 'etp-rdquo', + ')': 'etp-rpar', + ']': 'etp-rsqb', + '}': 'etp-rcub', + '.': 'etp-r-dot', + ',': 'etp-r-comma', + ':': 'etp-r-colon', +} \ No newline at end of file diff --git a/etpgrf/sanitizer.py b/etpgrf/sanitizer.py new file mode 100644 index 0000000..1f487cc --- /dev/null +++ b/etpgrf/sanitizer.py @@ -0,0 +1,62 @@ +# etpgrf/sanitizer.py +# Модуль для очистки и нормализации HTML-кода перед типографикой. + +import logging +from bs4 import BeautifulSoup, NavigableString +from .config import (SANITIZE_ALL_HTML, SANITIZE_ETPGRF, SANITIZE_NONE, + HANGING_PUNCTUATION_CLASSES, PROTECTED_HTML_TAGS) + +logger = logging.getLogger(__name__) + + +class SanitizerProcessor: + """ + Выполняет очистку HTML-кода в соответствии с заданным режимом. + """ + + def __init__(self, mode: str | bool | None = SANITIZE_NONE): + """ + :param mode: Режим очистки: + - 'etp' (SANITIZE_ETPGRF): удаляет только разметку висячей пунктуации. + - 'html' (SANITIZE_ALL_HTML): удаляет все HTML-теги. + - None или False: ничего не делает. + """ + if mode is False: + mode = SANITIZE_NONE + self.mode = mode + self._etp_classes_to_clean = frozenset(HANGING_PUNCTUATION_CLASSES.values()) + + logger.debug(f"SanitizerProcessor `__init__`. Mode: {self.mode}") + + def process(self, soup: BeautifulSoup) -> BeautifulSoup | str: + """ + Применяет правила очистки к `soup`-объекту. + + :param soup: Объект BeautifulSoup для обработки. + :return: Обработанный объект BeautifulSoup или строка (в режиме 'html'). + """ + if self.mode == SANITIZE_ETPGRF: + # Находим все span'ы, у которых есть с хотя бы одним из наших классов висячей пунктуации + spans_to_clean = soup.find_all( + name='span', + class_=lambda c: c and any(etp_class in c.split() for etp_class in self._etp_classes_to_clean) + ) + + # "Агрессивная" очистка: просто "разворачиваем" все найденные теги, + # заменяя их своим содержимым. + for span in spans_to_clean: + span.unwrap() + + return soup + + elif self.mode == SANITIZE_ALL_HTML: + # Возвращаем только текст, удаляя все теги + # При этом уважаем защищенные теги, не извлекая текст из них. + text_parts = [ + str(node) for node in soup.descendants + if isinstance(node, NavigableString) and node.parent.name not in PROTECTED_HTML_TAGS + ] + return "".join(text_parts) + + # Если режим не задан, ничего не делаем + return soup \ No newline at end of file diff --git a/tests/test_sanitizer.py b/tests/test_sanitizer.py new file mode 100644 index 0000000..5ec3c0b --- /dev/null +++ b/tests/test_sanitizer.py @@ -0,0 +1,80 @@ +# tests/test_sanitizer.py +# Тестирует модуль SanitizerProcessor. + +import pytest +from bs4 import BeautifulSoup +from etpgrf.sanitizer import SanitizerProcessor +from etpgrf.config import SANITIZE_NONE, SANITIZE_ETPGRF, SANITIZE_ALL_HTML + + +def test_sanitizer_mode_none(): + """ + Проверяет, что в режиме SANITIZE_NONE (по умолчанию) ничего не происходит. + """ + html_input = '

«Hello world.

' + soup = BeautifulSoup(html_input, 'html.parser') + + # Тестируем с mode=None и mode=False + processor_none = SanitizerProcessor(mode=SANITIZE_NONE) + processor_false = SanitizerProcessor(mode=False) + + result_soup_none = processor_none.process(soup) + result_soup_false = processor_false.process(soup) + + assert str(result_soup_none) == html_input + assert str(result_soup_false) == html_input + + +def test_sanitizer_mode_all_html(): + """ + Проверяет, что в режиме SANITIZE_ALL_HTML удаляются все теги. + """ + html_input = '

Hello world! Click me.

' + soup = BeautifulSoup(html_input, 'html.parser') + processor = SanitizerProcessor(mode=SANITIZE_ALL_HTML) + + result_text = processor.process(soup) + + assert result_text == "Hello world! Click me." + + +ETPGRF_SANITIZE_TEST_CASES = [ + # ID, Описание, Входной HTML, Ожидаемый HTML + ( + "simple_unwrap", "Простое разворачивание span'а с одним etp-классом", + '

«Hello

', + '

«Hello

' + ), + ( + "aggressive_unwrap", "Агрессивное разворачивание span'а со смешанными классами", + '

Hello»

', + '

Hello»

' + ), + ( + "keep_user_span", "Не трогаем span'ы с пользовательскими классами", + '

Hello world

', + '

Hello world

' + ), + ( + "keep_other_tags", "Не трогаем другие теги", + '
Bold and italic
', + '
Bold and italic
' + ), + ( + "complex_case", "Сложный случай с несколькими разными span'ами", + '

«Title»

And note.

', + '

«Title»

And note.

' + ), +] + +@pytest.mark.parametrize("case_id, description, html_input, expected_html", ETPGRF_SANITIZE_TEST_CASES) +def test_sanitizer_mode_etpgrf(case_id, description, html_input, expected_html): + """ + Проверяет, что в режиме SANITIZE_ETPGRF удаляется только разметка висячей пунктуации. + """ + soup = BeautifulSoup(html_input, 'html.parser') + processor = SanitizerProcessor(mode=SANITIZE_ETPGRF) + + result_soup = processor.process(soup) + + assert str(result_soup) == expected_html \ No newline at end of file