add: SymbolsProcessor - обработка тире и псевдографики

This commit is contained in:
2025-08-22 15:45:38 +03:00
parent 39ef02884e
commit 9a37467bfc
4 changed files with 181 additions and 8 deletions

50
etpgrf/symbols.py Normal file
View File

@@ -0,0 +1,50 @@
# etpgrf/symbols.py
# Модуль для преобразования псевдографики в правильные типографские символы.
import regex
import logging
from .config import CHAR_NDASH, STR_TO_SYMBOL_REPLACEMENTS
logger = logging.getLogger(__name__)
class SymbolsProcessor:
"""
Преобразует ASCII-последовательности (псевдографику) в семантически
верные Unicode-символы. Работает на раннем этапе, до расстановки пробелов.
"""
def __init__(self):
# Для сложных замен, требующих анализа контекста (например, диапазоны),
# по-прежнему используем регулярные выражения.
# Паттерн для диапазонов: цифра-дефис-цифра -> цифра–цифра (среднее тире).
# Обрабатываем арабские и римские цифры.
self._range_pattern = regex.compile(pattern=r'(\d)-(\d)|([IVXLCDM]+)-([IVXLCDM]+)', flags=regex.IGNORECASE)
logger.debug("SymbolsProcessor `__init__`")
def _replace_range(self, match: regex.Match) -> str:
# Паттерн имеет две группы: (\d)-(\d) ИЛИ ([IVX...])-([IVX...])
if match.group(1) is not None: # Арабские цифры
return f'{match.group(1)}{CHAR_NDASH}{match.group(2)}'
if match.group(3) is not None: # Римские цифры
return f'{match.group(3)}{CHAR_NDASH}{match.group(4)}'
return match.group(0) # На всякий случай
def process(self, text: str) -> str:
# Шаг 1: Выполняем простые замены из списка `STR_TO_SYMBOL_REPLACEMENTS` (см. config.py).
# Этот шаг должен идти первым, чтобы пользователь мог, например,
# использовать '---' в диапазоне '1---5', если ему это нужно.
# В таком случае '---' заменится на '—', и правило для диапазонов
# с дефисом уже не сработает.
processed_text = text
for old, new in STR_TO_SYMBOL_REPLACEMENTS:
processed_text = processed_text.replace(old, new)
# Шаг 2: Обрабатываем диапазоны с помощью регулярного выражения.
# Эта замена более специфична и требует контекста (цифры вокруг дефиса).
processed_text = self._range_pattern.sub(self._replace_range, processed_text)
return processed_text

View File

@@ -8,6 +8,8 @@ from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs
from etpgrf.hyphenation import Hyphenator from etpgrf.hyphenation import Hyphenator
from etpgrf.unbreakables import Unbreakables from etpgrf.unbreakables import Unbreakables
from etpgrf.quotes import QuotesProcessor 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.codec import decode_to_unicode, encode_from_unicode
@@ -24,6 +26,8 @@ class Typographer:
hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов
unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов
quotes: QuotesProcessor | bool | None = True, # Правила для обработки кавычек quotes: QuotesProcessor | bool | None = True, # Правила для обработки кавычек
layout: LayoutProcessor | bool | None = True, # Правила для тире и спецсимволов
symbols: SymbolsProcessor | bool | None = True, # Правила для псевдографики
# ... другие модули правил ... # ... другие модули правил ...
): ):
@@ -38,7 +42,14 @@ class Typographer:
"HTML не будет обработан. Установите ее: `pip install beautifulsoup4`") "HTML не будет обработан. Установите ее: `pip install beautifulsoup4`")
self.process_html = False self.process_html = False
# D. --- Инициализация правила переноса --- # D. --- Конфигурация правил для псевдографики ---
self.symbols: SymbolsProcessor | None = None
if symbols is True or symbols is None:
self.symbols = SymbolsProcessor()
elif isinstance(symbols, SymbolsProcessor):
self.symbols = symbols
# E. --- Инициализация правила переноса ---
# Предпосылка: если вызвали типограф, значит, мы хотим обрабатывать текст и переносы тоже нужно расставлять. # Предпосылка: если вызвали типограф, значит, мы хотим обрабатывать текст и переносы тоже нужно расставлять.
# А для специальных случаев, когда переносы не нужны, пусть не ленятся и делают `hyphenation=False`. # А для специальных случаев, когда переносы не нужны, пусть не ленятся и делают `hyphenation=False`.
self.hyphenation: Hyphenator | None = None self.hyphenation: Hyphenator | None = None
@@ -49,7 +60,7 @@ class Typographer:
# C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode) # C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode)
self.hyphenation = hyphenation self.hyphenation = hyphenation
# E. --- Конфигурация правил неразрывных слов --- # F. --- Конфигурация правил неразрывных слов ---
self.unbreakables: Unbreakables | None = None self.unbreakables: Unbreakables | None = None
if unbreakables is True or unbreakables is None: if unbreakables is True or unbreakables is None:
# D1. Создаем новый объект Unbreakables с заданными языками и режимом, а все остальное по умолчанию # D1. Создаем новый объект Unbreakables с заданными языками и режимом, а все остальное по умолчанию
@@ -58,20 +69,29 @@ class Typographer:
# D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode) # D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode)
self.unbreakables = unbreakables self.unbreakables = unbreakables
# F. --- Конфигурация правил обработки кавычек --- # G. --- Конфигурация правил обработки кавычек ---
self.quotes: QuotesProcessor | None = None self.quotes: QuotesProcessor | None = None
if quotes is True or quotes is None: if quotes is True or quotes is None:
self.quotes = QuotesProcessor(langs=self.langs) self.quotes = QuotesProcessor(langs=self.langs)
elif isinstance(quotes, QuotesProcessor): elif isinstance(quotes, QuotesProcessor):
self.quotes = quotes self.quotes = quotes
# G. --- Конфигурация других правил--- # H. --- Конфигурация правил для тире и спецсимволов ---
self.layout: LayoutProcessor | None = None
if layout is True or layout is None:
self.layout = LayoutProcessor(langs=self.langs)
elif isinstance(layout, LayoutProcessor):
self.layout = layout
# I. --- Конфигурация других правил---
# Z. --- Логирование инициализации --- # Z. --- Логирование инициализации ---
logger.debug(f"Typographer `__init__`: langs: {self.langs}, mode: {self.mode}, " logger.debug(f"Typographer `__init__`: langs: {self.langs}, mode: {self.mode}, "
f"hyphenation: {self.hyphenation is not None}, " f"hyphenation: {self.hyphenation is not None}, "
f"unbreakables: {self.unbreakables is not None}, " f"unbreakables: {self.unbreakables is not None}, "
f"quotes: {self.quotes is not None}, " f"quotes: {self.quotes is not None}, "
f"layout: {self.layout is not None}, "
f"symbols: {self.symbols is not None}, "
f"process_html: {self.process_html}") f"process_html: {self.process_html}")
@@ -85,8 +105,12 @@ class Typographer:
# processed_text = 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: if self.quotes is not None:
processed_text = self.quotes.process(processed_text) processed_text = self.quotes.process(processed_text)
if self.layout is not None:
processed_text = self.layout.process(processed_text)
if self.unbreakables is not None: if self.unbreakables is not None:
processed_text = self.unbreakables.process(processed_text) processed_text = self.unbreakables.process(processed_text)
if self.hyphenation is not None: if self.hyphenation is not None:
@@ -135,4 +159,3 @@ class Typographer:
processed = self._process_text_node(text) processed = self._process_text_node(text)
# Возвращаем # Возвращаем
return encode_from_unicode(processed, self.mode) return encode_from_unicode(processed, self.mode)

View File

@@ -3,7 +3,28 @@
import pytest import pytest
from etpgrf.typograph import Typographer from etpgrf.typograph import Typographer
from etpgrf.config import SHY_CHAR, NBSP_CHAR from etpgrf.config import CHAR_SHY, CHAR_NBSP, CHAR_COPY, CHAR_MDASH, CHAR_ARROW_L
def test_typographer_disables_symbols_processor():
"""
Проверяет, что при symbols=False модуль обработки символов отключается.
"""
# Arrange
input_string = "Текст --- с символами (c) и стрелками A --> B."
typo = Typographer(langs='ru-en', symbols=False)
# Act
output_string = typo.process(input_string)
# Assert
# 1. Проверяем внутреннее состояние: модуль действительно отключен
assert typo.symbols is None
# 2. Проверяем результат: символы НЕ появились в тексте.
# Это главная и самая надежная проверка.
assert CHAR_MDASH not in output_string # длинное тире
assert CHAR_COPY not in output_string # символ копирайта
assert CHAR_ARROW_L not in output_string # стрелка
def test_typographer_disables_quotes_processor(): def test_typographer_disables_quotes_processor():
@@ -42,7 +63,7 @@ def test_typographer_disables_hyphenation():
# 1. Проверяем внутреннее состояние # 1. Проверяем внутреннее состояние
assert typo.hyphenation is None assert typo.hyphenation is None
# 2. Проверяем результат: в тексте не появилось символов мягкого переноса # 2. Проверяем результат: в тексте не появилось символов мягкого переноса
assert SHY_CHAR not in output_string assert CHAR_SHY not in output_string
def test_typographer_disables_unbreakables(): def test_typographer_disables_unbreakables():
@@ -60,4 +81,4 @@ def test_typographer_disables_unbreakables():
# 1. Проверяем внутреннее состояние # 1. Проверяем внутреннее состояние
assert typo.unbreakables is None assert typo.unbreakables is None
# 2. Проверяем результат: в тексте не появилось неразрывных пробелов # 2. Проверяем результат: в тексте не появилось неразрывных пробелов
assert NBSP_CHAR not in output_string assert CHAR_NBSP not in output_string

79
tests/test_symbols.py Normal file
View File

@@ -0,0 +1,79 @@
# tests/test_symbols.py
# Тестирует модуль SymbolsProcessor. Проверяет обработку псевдографики в тексте (тире, стрелки, спецсимволы).
import pytest
from etpgrf.symbols import SymbolsProcessor
from etpgrf.config import (
CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP, CHAR_COPY, CHAR_REG, CHAR_COPYP,
CHAR_TRADE, CHAR_AP, CHAR_ARROW_L, CHAR_ARROW_R, CHAR_ARROW_LR,
CHAR_ARROW_L_DOUBLE, CHAR_ARROW_R_DOUBLE, CHAR_ARROW_LR_DOUBLE,
CHAR_ARROW_L_LONG_DOUBLE, CHAR_ARROW_R_LONG_DOUBLE, CHAR_ARROW_LR_LONG_DOUBLE,
)
SYMBOLS_TEST_CASES = [
# 1. --- Простые замены из STR_TO_SYMBOL_REPLACEMENTS ---
# Тире и многоточие
("Текст --- текст", f"Текст {CHAR_MDASH} текст"),
("Текст---текст", f"Текст{CHAR_MDASH}текст"),
("Текст -- текст", f"Текст {CHAR_NDASH} текст"),
("Текст--текст", f"Текст{CHAR_NDASH}текст"),
("Текст...", f"Текст{CHAR_HELLIP}"),
# Спецсимволы
("(c) 2025 Компания правообладатель", f"{CHAR_COPY} 2025 Компания правообладатель"),
("(C) 2025 Компания правообладатель", f"{CHAR_COPY} 2025 Компания правообладатель"),
("Товар(r)", f"Товар{CHAR_REG}"),
("Товар(R)", f"Товар{CHAR_REG}"),
("(p) 2025 Звукозапись", f"{CHAR_COPYP} 2025 Звукозапись"),
("(P) 2025 Звукозапись", f"{CHAR_COPYP} 2025 Звукозапись"),
("Продукт(tm)", f"Продукт{CHAR_TRADE}"),
("Продукт(TM)", f"Продукт{CHAR_TRADE}"),
# Стрелки
("A <--> B", f"A {CHAR_ARROW_LR} B"),
("A <-- B", f"A {CHAR_ARROW_L} B"),
("A --> B", f"A {CHAR_ARROW_R} B"),
("A <==> B", f"A {CHAR_ARROW_LR_DOUBLE} B"),
("A <== B", f"A {CHAR_ARROW_L_DOUBLE} B"),
("A ==> B", f"A {CHAR_ARROW_R_DOUBLE} B"),
("A <===> B", f"A {CHAR_ARROW_LR_LONG_DOUBLE} B"),
("A <=== B", f"A {CHAR_ARROW_L_LONG_DOUBLE} B"),
("A ===> B", f"A {CHAR_ARROW_R_LONG_DOUBLE} B"),
# Математические
("a ~= b", f"a {CHAR_AP} b"),
# 2. --- Диапазоны чисел (обработка дефиса после простых замен) ---
("1941-1945 гг.", f"1941{CHAR_NDASH}1945 гг."),
("страницы 10-12", f"страницы 10{CHAR_NDASH}12"),
("I-V век", f"I{CHAR_NDASH}V век"),
("ix-vi до н.э.", f"ix{CHAR_NDASH}vi до н.э."),
# 3. --- Комбинированные и пограничные случаи ---
# Сначала сработает простая замена '---' -> '—', потом диапазон '1-5' -> '15'
("1-5 --- это диапазон (c)", f"1{CHAR_NDASH}5 {CHAR_MDASH} это диапазон {CHAR_COPY}"),
# Простая замена '--' -> '' не должна мешать диапазону '1-5'
("1-5 -- это диапазон", f"1{CHAR_NDASH}5 {CHAR_NDASH} это диапазон"),
("-10 -- -5 -- это диапазон", f"-10 {CHAR_NDASH} -5 это диапазон"),
# Проверка порядка: '---' должно замениться до '--'
("A---B--C", f"A{CHAR_MDASH}B{CHAR_NDASH}C"),
# Проверка, что замена не жадная и заменяет все вхождения
("далее...", f"далее{CHAR_HELLIP}"),
("...и...и...", f"{CHAR_HELLIP}и{CHAR_HELLIP}и{CHAR_HELLIP}"),
("A-->B-->C", f"A{CHAR_ARROW_R}B{CHAR_ARROW_R}C"),
("A<--B<--C", f"A{CHAR_ARROW_L}B{CHAR_ARROW_L}C"),
("A<-->B<-->C", f"A{CHAR_ARROW_LR}B{CHAR_ARROW_LR}C"),
("A<==>B<==>C", f"A{CHAR_ARROW_LR_DOUBLE}B{CHAR_ARROW_LR_DOUBLE}C"),
("A<===>B<===>C", f"A{CHAR_ARROW_LR_LONG_DOUBLE}B{CHAR_ARROW_LR_LONG_DOUBLE}C"),
# Очень длинные, комбинированные стрелки
("A <----> B", f"A {CHAR_ARROW_L}{CHAR_ARROW_R} B"),
("A <======> B", f"A {CHAR_ARROW_L_LONG_DOUBLE}{CHAR_ARROW_R_LONG_DOUBLE} B"),
]
@pytest.mark.parametrize("input_string, expected_output", SYMBOLS_TEST_CASES)
def test_symbols_processor(input_string, expected_output):
processor = SymbolsProcessor()
actual_output = processor.process(input_string)
assert actual_output == expected_output