add: LayoutProcessor - обработка неразрывных пробелов вокруг тире и инициалов
This commit is contained in:
94
etpgrf/layout.py
Normal file
94
etpgrf/layout.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# etpgrf/layout.py
|
||||||
|
# Модуль для обработки тире, специальных символов и правил их компоновки.
|
||||||
|
|
||||||
|
import regex
|
||||||
|
import logging
|
||||||
|
from etpgrf.config import LANG_RU, LANG_EN, CHAR_NBSP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP
|
||||||
|
from etpgrf.comutil import parse_and_validate_langs
|
||||||
|
|
||||||
|
# --
|
||||||
|
|
||||||
|
# --- Настройки логирования ---
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutProcessor:
|
||||||
|
"""
|
||||||
|
Обрабатывает тире, псевдографику (например, … -> © и тому подобные) и применяет
|
||||||
|
правила расстановки пробелов в зависимости от языка (для тире язык важен, так как.
|
||||||
|
Правила типографики различаются для русского и английского языков).
|
||||||
|
Предполагается, что на вход уже поступает текст с правильными типографскими
|
||||||
|
символами тире (— и –).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None,
|
||||||
|
process_initials: bool = True):
|
||||||
|
self.langs = parse_and_validate_langs(langs)
|
||||||
|
self.main_lang = self.langs[0] if self.langs else LANG_RU
|
||||||
|
self.process_initials = process_initials
|
||||||
|
|
||||||
|
# 1. Паттерн для длинного (—) или среднего (–) тире, окруженного пробелами.
|
||||||
|
# (?<=\S) и (?=\S) гарантируют, что тире находится между словами, а не в начале/конце строки.
|
||||||
|
# self._dash_pattern = regex.compile(rf'(?<=\S)\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
|
||||||
|
# (?<=[\p{L}\p{Po}\p{Pf}"\']) - просмотр назад на букву, пунктуацию или кавычку.
|
||||||
|
self._dash_pattern = regex.compile(rf'(?<=[\p{{L}}\p{{Po}}\p{{Pf}}"\'])\s+([{CHAR_MDASH}{CHAR_NDASH}])\s+(?=\S)')
|
||||||
|
|
||||||
|
# 2. Паттерн для многоточия, за которым следует пробел и слово.
|
||||||
|
# Ставит неразрывный пробел после многоточия, чтобы не отрывать его от следующего слова.
|
||||||
|
# (?=[\p{L}\p{N}]) - просмотр вперед на букву или цифру.
|
||||||
|
self._ellipsis_pattern = regex.compile(rf'({CHAR_HELLIP})\s+(?=[\p{{L}}\p{{N}}])')
|
||||||
|
|
||||||
|
# 3. Паттерн для отрицательных чисел.
|
||||||
|
# Ставит неразрывный пробел перед знаком минус, если за минусом идет цифра (неразрывный пробел
|
||||||
|
# заменяет обычный). Это предотвращает "отлипание" знака от числа при переносе строки.
|
||||||
|
# (?<!\d) - негативный просмотр назад, чтобы правило не срабатывало для бинарного минуса
|
||||||
|
# в выражениях типа "10 - 5".
|
||||||
|
self._negative_number_pattern = regex.compile(r'(?<!\d)\s+-(\d+)')
|
||||||
|
|
||||||
|
# 4. Паттерны для обработки инициалов.
|
||||||
|
# \p{Lu} - любая заглавная буква в Unicode.
|
||||||
|
# Этот паттерн находит пробел между фамилией и следующим за ней инициалом.
|
||||||
|
self._surname_initial_pattern = regex.compile(r'(\p{Lu}\p{L}{1,})\s+(?=\p{Lu}\.)')
|
||||||
|
# Этот паттерн находит пробел между инициалом и следующим за ним инициалом или фамилией.
|
||||||
|
# (?=\p{Lu}[\p{L}.]) - просмотр вперед на заглавную букву, за которой идет или буква (фамилия), или точка (инициал).
|
||||||
|
self._initial_pattern = regex.compile(r'(\p{Lu}\.)\s+(?=\p{Lu}[\p{L}.])')
|
||||||
|
|
||||||
|
logger.debug(f"LayoutProcessor `__init__`. "
|
||||||
|
f"Langs: {self.langs}, "
|
||||||
|
f"Main lang: {self.main_lang}, "
|
||||||
|
f"Process initials: {self.process_initials}")
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_dash_spacing(self, match: regex.Match) -> str:
|
||||||
|
"""Callback-функция для расстановки пробелов вокруг тире с учетом языка."""
|
||||||
|
dash = match.group(1) # Получаем сам символ тире (— или –)
|
||||||
|
if self.main_lang == LANG_EN:
|
||||||
|
# Для английского языка — слитно, без пробелов.
|
||||||
|
return dash
|
||||||
|
# По умолчанию (и для русского) — отбивка пробелами.
|
||||||
|
return f'{CHAR_NBSP}{dash} '
|
||||||
|
|
||||||
|
|
||||||
|
def process(self, text: str) -> str:
|
||||||
|
"""Применяет правила компоновки к тексту."""
|
||||||
|
# Порядок применения правил важен.
|
||||||
|
processed_text = text
|
||||||
|
|
||||||
|
# 1. Обработка пробелов вокруг тире.
|
||||||
|
processed_text = self._dash_pattern.sub(self._replace_dash_spacing, processed_text)
|
||||||
|
|
||||||
|
# 2. Обработка пробела после многоточия.
|
||||||
|
processed_text = self._ellipsis_pattern.sub(f'\\1{CHAR_NBSP}', processed_text)
|
||||||
|
|
||||||
|
# 3. Обработка пробела перед отрицательными числами/минусом.
|
||||||
|
processed_text = self._negative_number_pattern.sub(f'{CHAR_NBSP}-\\1', processed_text)
|
||||||
|
|
||||||
|
# 4. Обработка инициалов (если включено).
|
||||||
|
if self.process_initials:
|
||||||
|
# Сначала связываем фамилию с первым инициалом (Пушкин А. -> Пушкин{NBSP}А.)
|
||||||
|
processed_text = self._surname_initial_pattern.sub(f'\\1{CHAR_NBSP}', processed_text)
|
||||||
|
# Затем связываем инициалы между собой и с фамилией (А. С. Пушкин -> А.{NBSP}С.{NBSP}Пушкин)
|
||||||
|
processed_text = self._initial_pattern.sub(f'\\1{CHAR_NBSP}', processed_text)
|
||||||
|
|
||||||
|
return processed_text
|
@@ -26,6 +26,24 @@ def test_typographer_disables_symbols_processor():
|
|||||||
assert CHAR_COPY not in output_string # символ копирайта
|
assert CHAR_COPY not in output_string # символ копирайта
|
||||||
assert CHAR_ARROW_L not in output_string # стрелка
|
assert CHAR_ARROW_L not in output_string # стрелка
|
||||||
|
|
||||||
|
def test_typographer_disable_layout_processor():
|
||||||
|
"""
|
||||||
|
Проверяет, что при layout=False модуль обработки компоновки отключается.
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
input_string = "Текст — с тире, которое не должно измениться."
|
||||||
|
typo = Typographer(langs='ru', layout=False)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
output_string = typo.process(input_string)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
# 1. Проверяем внутреннее состояние: модуль действительно отключен
|
||||||
|
assert typo.layout is None
|
||||||
|
# 2. Проверяем результат: пробелы вокруг тире НЕ появились в тексте.
|
||||||
|
# Это главная и самая надежная проверка.
|
||||||
|
assert CHAR_NBSP in output_string
|
||||||
|
|
||||||
|
|
||||||
def test_typographer_disables_quotes_processor():
|
def test_typographer_disables_quotes_processor():
|
||||||
"""
|
"""
|
||||||
|
102
tests/test_layout.py
Normal file
102
tests/test_layout.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# tests/test _layout.py
|
||||||
|
# Тестирует модуль LayoutProcessor. Проверяет обработку тире и специальных символов в тексте.
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from etpgrf.layout import LayoutProcessor
|
||||||
|
from etpgrf.config import CHAR_NBSP, CHAR_HELLIP
|
||||||
|
|
||||||
|
LAYOUT_TEST_CASES = [
|
||||||
|
# --- Длинное тире (—) для русского языка ---
|
||||||
|
('ru', 'Слово — слово', f'Слово{CHAR_NBSP}— слово'),
|
||||||
|
('ru', 'В начале — слово', f'В начале{CHAR_NBSP}— слово'),
|
||||||
|
('ru', 'Слово — в конце.', f'Слово{CHAR_NBSP}— в конце.'),
|
||||||
|
('ru-en', 'Слово — слово', f'Слово{CHAR_NBSP}— слово'), # Приоритет у 'ru'
|
||||||
|
|
||||||
|
# --- Длинное тире (—) для английского языка ---
|
||||||
|
('en', 'Word — word', 'Word—word'),
|
||||||
|
('en', 'Start — word', 'Start—word'),
|
||||||
|
('en', 'Word — end.', 'Word—end.'),
|
||||||
|
('en-ru', 'Word — word', 'Word—word'), # Приоритет у 'en'
|
||||||
|
|
||||||
|
# --- Среднее тире (–) также должно обрабатываться ---
|
||||||
|
('ru', 'Слово – слово', f'Слово{CHAR_NBSP}– слово'),
|
||||||
|
('en', 'Word – word', 'Word–word'),
|
||||||
|
|
||||||
|
# --- Случаи тире рядом с пунктуацией и кавычками ---
|
||||||
|
('ru', 'Да, — сказал он', f'Да,{CHAR_NBSP}— сказал он'),
|
||||||
|
('en', 'Yes, — he said', 'Yes,—he said'),
|
||||||
|
('ru', '«Слово», — сказал он', f'«Слово»,{CHAR_NBSP}— сказал он'),
|
||||||
|
('en', '“Word,” — he said', '“Word,”—he said'),
|
||||||
|
('ru', 'Слово! — воскликнул он.', f'Слово!{CHAR_NBSP}— воскликнул он.'),
|
||||||
|
('en', 'Word! — he exclaimed.', 'Word!—he exclaimed.'),
|
||||||
|
# Тире после закрывающей кавычки
|
||||||
|
('ru', '«Слово» — это важно.', f'«Слово»{CHAR_NBSP}— это важно.'),
|
||||||
|
('en', '“Word” — is important.', '“Word”—is important.'),
|
||||||
|
|
||||||
|
# --- Случаи, которые не должны меняться ---
|
||||||
|
('ru', 'слово—слово', 'слово—слово'), # Уже слитно, не трогаем
|
||||||
|
('en', 'word—word', 'word—word'), # Уже слитно, не трогаем
|
||||||
|
('ru', "что-нибудь такое", "что-нибудь такое"), # Дефис, а не минус
|
||||||
|
('ru', "что-нибудь такое", "что-нибудь такое"), # Минус
|
||||||
|
('ru', "что-\nнибудь такое", "что-\nнибудь такое"), # Минус
|
||||||
|
('ru', ' — слово', ' — слово'), # Пробел в начале строки, не трогаем
|
||||||
|
('en', ' — word', ' — word'), # Пробел в начале строки, не трогаем
|
||||||
|
('ru', 'слово — ', 'слово — '), # Пробел в конце строки, не трогаем
|
||||||
|
('en', 'word — ', 'word — '), # Пробел в конце строки, не трогаем
|
||||||
|
('ru', '1941–1945', '1941–1945'), # Диапазон без пробелов (через короткое тире) не трогаем
|
||||||
|
('ru', '1941—1945', '1941—1945'), # Диапазон (через длинное тире) не трогаем
|
||||||
|
('ru', '1941-1945', '1941-1945'), # Диапазон (через дефис/минус) не трогаем (будет преобразован в SymbolsProcessor)
|
||||||
|
('ru', '1 — 2', '1 — 2'), # Диапазон с пробелами (цифры!) не трогаем
|
||||||
|
('ru', '1 - 2', '1 - 2'), # Математика (минус) не трогаем
|
||||||
|
|
||||||
|
# --- Многоточие ---
|
||||||
|
('ru', f"Что это{CHAR_HELLIP} \n \t не знаю.", f"Что это{CHAR_HELLIP}{CHAR_NBSP}не знаю."),
|
||||||
|
('ru', f"Что это{CHAR_HELLIP} не знаю.", f"Что это{CHAR_HELLIP}{CHAR_NBSP}не знаю."),
|
||||||
|
('ru', f"Что это{CHAR_HELLIP} 123.", f"Что это{CHAR_HELLIP}{CHAR_NBSP}123."),
|
||||||
|
(f'ru', f"Что это{CHAR_HELLIP}", f"Что это{CHAR_HELLIP}"), # Не меняется в конце
|
||||||
|
(f'ru', f"Что это{CHAR_HELLIP} ", f"Что это{CHAR_HELLIP} "), # Не меняется в конце
|
||||||
|
(f'ru', f"1{CHAR_HELLIP}2{CHAR_HELLIP}3{CHAR_HELLIP}4{CHAR_HELLIP}5, я иду тебя искать!",
|
||||||
|
f"1{CHAR_HELLIP}2{CHAR_HELLIP}3{CHAR_HELLIP}4{CHAR_HELLIP}5, я иду тебя искать!"),
|
||||||
|
(f'ru', f"1{CHAR_HELLIP}2{CHAR_HELLIP}3{CHAR_HELLIP}4{CHAR_HELLIP}5{CHAR_HELLIP} я иду тебя искать!",
|
||||||
|
f"1{CHAR_HELLIP}2{CHAR_HELLIP}3{CHAR_HELLIP}4{CHAR_HELLIP}5{CHAR_HELLIP}{CHAR_NBSP}я иду тебя искать!"),
|
||||||
|
|
||||||
|
# --- Отрицательные числа ---
|
||||||
|
('ru', "температура -10 градусов", f"температура{CHAR_NBSP}-10 градусов"),
|
||||||
|
('ru', "от -5 до +5", f"от{CHAR_NBSP}-5 до +5"),
|
||||||
|
('ru', "в диапазоне ( -10, 10)", f"в диапазоне ({CHAR_NBSP}-10, 10)"), # Пробел после скобки
|
||||||
|
|
||||||
|
# --- Случаи, которые не должны меняться (отрицательные числа) ---
|
||||||
|
('ru', "10 - 5 = 5", "10 - 5 = 5"), # Бинарный минус не трогаем
|
||||||
|
('ru', "слово-10", "слово-10"), # Дефис, а не минус
|
||||||
|
('ru', "1-2-3-4-5, я иду тебя искать", "1-2-3-4-5, я иду тебя искать"), # Дефис, а не минус
|
||||||
|
|
||||||
|
# --- Инициалы (должны обрабатываться по умолчанию) ---
|
||||||
|
('ru', "А. С. Пушкин", f"А.{CHAR_NBSP}С.{CHAR_NBSP}Пушкин"),
|
||||||
|
('ru', "А.С. Пушкин", f"А.С.{CHAR_NBSP}Пушкин"),
|
||||||
|
('ru', "Пушкин А. С.", f"Пушкин{CHAR_NBSP}А.{CHAR_NBSP}С."),
|
||||||
|
('ru', "Пушкин А.С.", f"Пушкин{CHAR_NBSP}А.С."),
|
||||||
|
('en', "J. R. R. Tolkien", f"J.{CHAR_NBSP}R.{CHAR_NBSP}R.{CHAR_NBSP}Tolkien"),
|
||||||
|
('en', "J.R.R. Tolkien", f"J.R.R.{CHAR_NBSP}Tolkien"),
|
||||||
|
('en', "Tolkien J. R. R.", f"Tolkien{CHAR_NBSP}J.{CHAR_NBSP}R.{CHAR_NBSP}R."),
|
||||||
|
('en', "Tolkien J.R.R.", f"Tolkien{CHAR_NBSP}J.R.R."),
|
||||||
|
('ru', "Это был В. Высоцкий.", f"Это был В.{CHAR_NBSP}Высоцкий."),
|
||||||
|
('ru', "Высоцкий В. С. был гением.", f"Высоцкий{CHAR_NBSP}В.{CHAR_NBSP}С. был гением."),
|
||||||
|
|
||||||
|
# --- Инициалы (проверка отключения опции) ---
|
||||||
|
# ('ru', "А. С. Пушкин", "А. С. Пушкин", False),
|
||||||
|
# ('ru', "Пушкин А. С.", "Пушкин А. С.", False),
|
||||||
|
|
||||||
|
# --- Комбинированные случаи ---
|
||||||
|
('ru', f"Да — это так{CHAR_HELLIP} а может и нет. Счёт -10.",
|
||||||
|
f"Да{CHAR_NBSP}— это так{CHAR_HELLIP}{CHAR_NBSP}а может и нет. Счёт{CHAR_NBSP}-10."),
|
||||||
|
('ru', f"По мнению А. С. Пушкина — это...", f"По мнению А.{CHAR_NBSP}С.{CHAR_NBSP}Пушкина{CHAR_NBSP}— это..."),
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("lang, input_string, expected_output", LAYOUT_TEST_CASES)
|
||||||
|
def test_layout_processor(lang, input_string, expected_output):
|
||||||
|
"""Проверяет работу LayoutProcessor в изоляции."""
|
||||||
|
processor = LayoutProcessor(langs=lang)
|
||||||
|
actual_output = processor.process(input_string)
|
||||||
|
assert actual_output == expected_output
|
Reference in New Issue
Block a user