add: SymbolsProcessor - обработка тире и псевдографики
This commit is contained in:
50
etpgrf/symbols.py
Normal file
50
etpgrf/symbols.py
Normal 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
|
||||||
|
|
@@ -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)
|
||||||
|
|
||||||
|
@@ -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
79
tests/test_symbols.py
Normal 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' -> '1–5'
|
||||||
|
("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
|
Reference in New Issue
Block a user