add: LayoutProcessor - обработка едениц измерения (кажется все)

This commit is contained in:
2025-09-22 01:04:38 +03:00
parent c3e65700b1
commit 67c5bd805a
5 changed files with 118 additions and 82 deletions

View File

@@ -63,6 +63,10 @@ CHAR_ARROW_LR = '\u27f7' # Длинная двунаправленная ст
CHAR_ARROW_L_LONG_DOUBLE = '\u27f8' # Длинная двойная стрелка влево
CHAR_ARROW_R_LONG_DOUBLE = '\u27f9' # Длинная двойная стрелка вправо
CHAR_ARROW_LR_LONG_DOUBLE = '\u27fa' # Длинная двойная двунаправленная стрелка
CHAR_MIDDOT = '\u00b7' # Средняя точка (· иногда используется как знак умножения) / ·
CHAR_UNIT_SEPARATOR = '\u25F0' # Символ временный разделитель для составных единиц (◰), чтобы не уходить
# в "мертвый" цикл при замене на тонкий пробел. Можно взять любой редкий символом.
# === КОНСТАНТЫ ПСЕВДОГРАФИКИ ===
# Для простых замен "строка -> символ" используем список кортежей.
@@ -92,28 +96,6 @@ STR_TO_SYMBOL_REPLACEMENTS = [
('~=', CHAR_AP), # Приблизительно равно (≈)
]
# === КОНСТАНТЫ ДЛЯ ЕДИНИЦ ИЗМЕРЕНИЯ ===
# Пост-позиционные (10 км)
DEFAULT_POST_UNITS = [
# Русские
'гг', 'г.', 'кг', 'мг', 'ц', 'т',
'кв.м', 'куб.м', 'мм', 'см', 'м', 'км', 'л', 'мл', 'сот', 'га',
'сек', 'с.', 'мин', 'ч',
'руб', 'коп',
'тыс', 'млн', 'млрд',
'пп', 'стр', 'рис', 'табл', 'гл', 'п', 'шт',
# Английские
'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed',
]
# Пред-позиционные (№ 5, $ 10)
DEFAULT_PRE_UNITS = ['', '$', '', '£', '', '#']
# === КОНСТАНТЫ ДЛЯ СЛОЖНЫХ (СОСТАВНЫХ) ЕДИНИЦ ИЗМЕРЕНИЯ ===
# Эти единицы будут автоматически "склеены" неразрывными пробелами внутри LayoutProcessor
DEFAULT_COMPLEX_UNITS = [
'до н. э.',
'н. э.',
]
# === КОНСТАНТЫ ДЛЯ КОДИРОВАНИЯ HTML-МНЕМНОИКОВ ===
# --- ЧЕРНЫЙ СПИСОК: Символы, которые НИКОГДА не нужно кодировать в мнемоники ---
@@ -185,7 +167,7 @@ CUSTOM_ENCODE_MAP = {
'\u0026': '&', # & / & / &
'\u003e': '>', # > / > / >
'\u003c': '&lt;', # < / &lt; / &LT;
'\u00b7': '&middot;', # · / &middot; / &centerdot; / &CenterDot;
CHAR_MIDDOT: '&middot;', # · / &middot; / &centerdot; / &CenterDot;
'\u0060': '&grave;', # ` / &grave; / &DiacriticalGrave;
'\u00a8': '&die;', # ¨ / &die; / &Dot; / &uml; / &DoubleDot;
'\u00b1': '&pm;', # ± / &pm; / &PlusMinus;
@@ -637,4 +619,28 @@ ENCODE_MAP = _build_translation_maps()
# --- Публичный API модуля ---
def get_encode_map():
"""Возвращает готовую карту для кодирования."""
return ENCODE_MAP
return ENCODE_MAP
# === КОНСТАНТЫ ДЛЯ ЕДИНИЦ ИЗМЕРЕНИЯ ===
# ТОЛЬКО АТОМАРНЫЕ единицы измерения: 'г', 'м', 'с', 'км', 'кв', 'куб', 'ч' и так далее.
# Никаких сложных и составных, типа: 'кв.м.', 'км/ч' или "до н.э." ...
# Пост-позиционные (10 км).
DEFAULT_POST_UNITS = [
# Русские
'гг', 'г.', 'в.', 'вв', 'н', 'э',
'кг', 'мг', 'ц', 'т',
'кв', 'куб', 'мм', 'см', 'м', 'км', 'л', 'мл', 'сот', 'га',
'сек', 'с.', 'мин', 'ч',
'руб', 'коп',
'тыс', 'млн', 'млрд', 'трлн', 'трлрд',
'пп', 'стр', 'рис', 'табл', 'гл', 'п', 'шт', 'об'
# Английские
'pp', 'p', 'para', 'sect', 'fig', 'vol', 'ed',
]
# Пред-позиционные (№ 5, $ 10)
DEFAULT_PRE_UNITS = ['', '$', '', '£', '', '#']
# Операторы, которые могут стоять между единицами измерения (км/ч)
# Сложение и вычитание здесь намеренно отсутствуют.
UNIT_MATH_OPERATORS = ['/', '*', '×', CHAR_MIDDOT, '÷']

View File

@@ -3,8 +3,8 @@
import regex
import logging
from etpgrf.config import (LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP,
DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, DEFAULT_COMPLEX_UNITS)
from etpgrf.config import (LANG_RU, LANG_EN, CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH, CHAR_HELLIP, CHAR_UNIT_SEPARATOR,
DEFAULT_POST_UNITS, DEFAULT_PRE_UNITS, UNIT_MATH_OPERATORS)
from etpgrf.comutil import parse_and_validate_langs
# --
@@ -25,14 +25,12 @@ class LayoutProcessor:
def __init__(self,
langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None,
process_initials_and_acronyms: bool = True,
process_units: bool | str | list[str] = True,
process_complex_units: bool | list[str] = True):
process_units: bool | str | list[str] = True):
self.langs = parse_and_validate_langs(langs)
self.main_lang = self.langs[0] if self.langs else LANG_RU
self.process_initials_and_acronyms = process_initials_and_acronyms
self.process_units = process_units
self.process_complex_units = process_complex_units
# 1. Паттерн для длинного (—) или среднего () тире, окруженного пробелами.
# (?<=[\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)')
@@ -62,59 +60,49 @@ class LayoutProcessor:
self._initial_to_initial_ns_pattern = regex.compile(r'(\p{Lu}\.)(?=\p{Lu}\.)')
self._initial_to_surname_ns_pattern = regex.compile(r'(\p{Lu}\.)(?=\p{Lu}\p{L}{1,})')
# 5. Паттерны для единиц измерения.
# Паттерн, описывающий "число" - арабское (включая дроби) ИЛИ римское.
# Для римских цифр используется \b, чтобы не спутать 'I' с частью слова.
self._NUMBER_PATTERN = r'(?:\d[\d.,]*|\b[IVXLCDM]+\b)'
# 5. Паттерны для единиц измерения (простые и составные).
self._post_units_pattern = None
self._pre_units_pattern = None
self._complex_unit_pattern = None
self._math_unit_pattern = None
if self.process_units:
post_units = list(DEFAULT_POST_UNITS)
pre_units = list(DEFAULT_PRE_UNITS)
# Проверяем и добавляем пользовательские единицы измерения
custom_units = []
# Обработка составных единиц: "склеиваем" их тонкой шпацией и добавляем в общий список
if self.process_complex_units:
complex_units_to_process = list(DEFAULT_COMPLEX_UNITS)
if isinstance(self.process_complex_units, (list, tuple, set)):
complex_units_to_process.extend(self.process_complex_units)
# "Склеиваем" пробелы внутри составных единиц и добавляем в общий список
post_units.extend([unit.replace(' ', CHAR_THIN_SP) for unit in complex_units_to_process])
all_post_units = list(DEFAULT_POST_UNITS)
if isinstance(self.process_units, str):
# Если кастомные единицы заданы строкой, разбиваем по пробелам
custom_units = self.process_units.split()
all_post_units.extend(self.process_units.split())
elif isinstance(self.process_units, (list, tuple, set)):
# Если кастомные единицы заданы списком/кортежем/множеством, просто конвертируем в список
custom_units = list(self.process_units)
all_post_units.extend(self.process_units)
if custom_units:
post_units.extend(custom_units)
units_pattern_part = ''
if post_units:
# [\d.,]+ - число, возможно, с точкой или запятой
# Используем негативный просмотр вперед (?!), чтобы убедиться, что за единицей
# не следует другая буква. Это надежнее, чем \b, особенно для единиц,
# оканчивающихся на точку (например, "г.").
post_pattern_str = r'(\d[\d.,]*)\s+(' + '|'.join(regex.escape(u) for u in post_units) + r')(?![\p{L}\p{N}])'
self._post_units_pattern = regex.compile(post_pattern_str)
# Общий паттерн для всех остальных единиц
if all_post_units:
sorted_units = sorted(all_post_units, key=len, reverse=True)
units_pattern_part = '|'.join(map(regex.escape, sorted_units))
if pre_units:
# Используем негативный просмотр назад (?<!), чтобы убедиться, что перед единицей
# нет буквы. \b здесь не работает для символов типа "№" или "$".
pre_pattern_str = r'(?<![\p{L}\p{N}])(' + '|'.join(regex.escape(u) for u in pre_units) + r')\s+(\d[\d.,]*)'
self._pre_units_pattern = regex.compile(pre_pattern_str)
if units_pattern_part:
# Простые единицы: число + единица
self._post_units_pattern = regex.compile(rf'({self._NUMBER_PATTERN})\s+({units_pattern_part})(?!\w)')
# Паттерн для составных единиц: ищет пару "единица." + "единица", разделенную пробелами (или без них).
# Обязательное наличие точки `\.` после первой единицы делает цикл обработки безопасным.
self._complex_unit_pattern = regex.compile(r'\b(' + units_pattern_part + r')\.(\s*)(' + units_pattern_part + r')(?!\w)')
# Паттерн для математических операций между единицами
math_ops_pattern = '|'.join(map(regex.escape, UNIT_MATH_OPERATORS))
self._math_unit_pattern = regex.compile(
r'\b(' + units_pattern_part + r')\s*(' + math_ops_pattern + r')\s*(' + units_pattern_part + r')(?!\w)')
# 6. Паттерн для связи единиц-умножителей (тыс., млн.) со следующей единицей.
# Ищет умножитель, за которым может быть точка, а затем пробел.
multiplier_units = ['тыс', 'млн', 'млрд']
self._unit_multiplier_pattern = regex.compile(r'((' + '|'.join(multiplier_units) + r')\.?)\s+')
# Паттерн для пред-позиционных единиц
self._pre_units_pattern = regex.compile(
r'(?<![\p{L}\p{N}])(' + '|'.join(map(regex.escape, DEFAULT_PRE_UNITS)) + rf')\s+({self._NUMBER_PATTERN})')
logger.debug(f"LayoutProcessor `__init__`. "
f"Langs: {self.langs}, "
f"Main lang: {self.main_lang}, "
f"Process initials and acronyms: {self.process_initials_and_acronyms}, "
f"Process units: {bool(self.process_units)}, "
f"Process complex units: {bool(self.process_complex_units)}")
f"Process units: {bool(self.process_units)}")
def _replace_dash_spacing(self, match: regex.Match) -> str:
"""Callback-функция для расстановки пробелов вокруг тире с учетом языка."""
@@ -152,15 +140,24 @@ class LayoutProcessor:
processed_text = self._surname_to_initial_ws_pattern.sub(f'\\1{CHAR_NBSP}', processed_text)
# 5. Обработка единиц измерения (если включено).
if self.process_units and self._unit_multiplier_pattern:
processed_text = self._unit_multiplier_pattern.sub(r'\1' + CHAR_NBSP, processed_text)
# 6. Обработка единиц измерения (простых и составных).
if self.process_units:
if self._complex_unit_pattern:
# Шаг 1: "Склеиваем" все составные единицы с помощью временного разделителя.
# Цикл безопасен, так как мы заменяем пробелы на непробельный символ, и паттерн не найдет себя снова.
while self._complex_unit_pattern.search(processed_text):
processed_text = self._complex_unit_pattern.sub(
fr'\1.{CHAR_UNIT_SEPARATOR}\3', processed_text, count=1)
if self._math_unit_pattern:
# processed_text = self._math_unit_pattern.sub(r'\1/\2', processed_text)
processed_text = self._math_unit_pattern.sub(r'\1\2\3', processed_text)
# И только потом привязываем простые единицы к числам
if self._post_units_pattern:
processed_text = self._post_units_pattern.sub(f'\\1{CHAR_NBSP}\\2', processed_text)
if self._pre_units_pattern:
processed_text = self._pre_units_pattern.sub(f'\\1{CHAR_NBSP}\\2', processed_text)
# Шаг 2: Заменяем все временные разделители на правильную тонкую шпацию.
processed_text = processed_text.replace(CHAR_UNIT_SEPARATOR, CHAR_THIN_SP)
return processed_text

View File

@@ -47,7 +47,7 @@ _RU_OLD_POSTPOSITIVE_PARTICLES = frozenset([
])
_EN_UNBREAKABLE_WORDS = frozenset([
# 1-2 letter words
# 1-2 letter words (I - as pronoun)
'a', 'an', 'as', 'at', 'by', 'in', 'is', 'it', 'of', 'on', 'or', 'so', 'to', 'if',
# 3-4 letter words
'for', 'from', 'into', 'that', 'then', 'they', 'this', 'was', 'were', 'what', 'when', 'with',

View File

@@ -98,16 +98,34 @@ LAYOUT_TEST_CASES = [
# --- Единицы измерения (по умолчанию) ---
('ru', "Радиус Солнца — около 696.340 км", f"Радиус Солнца{CHAR_NBSP}— около 696.340{CHAR_NBSP}км"),
('ru', "5 кг.", f"5{CHAR_NBSP}кг."),
('ru', "Доработки проекта стоили 100 тыс. руб.", f"Доработки проекта стоили 100{CHAR_NBSP}тыс.{CHAR_NBSP}руб."),
('ru', "Доработки проекта стоили 100 тыс. руб.", f"Доработки проекта стоили 100{CHAR_NBSP}тыс.{CHAR_THIN_SP}руб."),
('ru', "№ 5", f"{CHAR_NBSP}5"),
('ru', "Договор № 504/2025А", f"Договор №{CHAR_NBSP}504/2025А"),
('ru+en', "Доплата за багаж $ 45.50", f"Доплата за багаж ${CHAR_NBSP}45.50"),
('ru+en', "Инвестиции составили $2.5 млн.", f"Инвестиции составили $2.5{CHAR_NBSP}млн."),
('ru+en', "Инвестиции составили $ 2.5 млн.", f"Инвестиции составили ${CHAR_NBSP}2.5{CHAR_NBSP}млн."),
# Сложные единицы (склеиваются тонкой шпацией, привязываются к числу неразрывным пробелом)
('ru', "Дом 120 кв.м. / Участок 6 сот.", f"Дом 120{CHAR_NBSP}кв.м. / Участок 6{CHAR_NBSP}сот."),
('ru', "Гробик кладут в ямку 2 кв. м.", f"Гробик кладут в ямку 2 кв. м."),
('ru', "500 до н. э.", f"500 до н. э."),
('ru+en', "Хаммурапи (1792 - 1750 до н. э.)", f"Хаммурапи (1792 - 1750 до н. э.)"),
# ('ru', "Дом 120 кв.м. / Участок 6 сот.", f"Дом 120{CHAR_NBSP}кв.м. / Участок 6{CHAR_NBSP}сот."),
# ('ru', "Гробик кладут в ямку 2 кв. м.", f"Гробик кладут в ямку 2 кв. м."),
('ru', "IV-X вв.", f"IV-X{CHAR_NBSP}вв."),
('ru', "IV в. н. э.", f"IV{CHAR_NBSP}в. н.{CHAR_THIN_SP}э."),
('ru+en', "Хаммурапи (1792 - 1750 до н. э.)",
f"Хаммурапи (1792 - 1750 до н.{CHAR_THIN_SP}э.)"),
# Составные и математические единицы
('ru', "Площадь 120 кв. м.", f"Площадь 120{CHAR_NBSP}кв.{CHAR_THIN_SP}м."),
('ru', "Площадь 130 кв.м.", f"Площадь 130{CHAR_NBSP}кв.{CHAR_THIN_SP}м."),
('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}км/ч"),
('ru', "Скорость 90 км / ч", f"Скорость 90{CHAR_NBSP}км/ч"),
('ru', "В 500 г. н. э.", f"В 500{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}э."),
('ru+en', "Хаммурапи (1792 - 1750 до н. э.)", f"Хаммурапи (1792 - 1750 до н.{CHAR_THIN_SP}э.)"),
# --- Комбинированные случаи ---
('ru', f"Да — это так{CHAR_HELLIP} а может и нет. Счёт -10.",

View File

@@ -1,6 +1,7 @@
# tests/test_unbreakables.py
import pytest
from etpgrf import Unbreakables
from etpgrf.config import CHAR_NBSP, CHAR_THIN_SP
# Список русских слов, которые должны "приклеиваться" к следующему слову.
@@ -26,7 +27,7 @@ def test_russian_prepositions_are_unbreakable(word):
# Arrange (подготовка)
unbreakables_ru = Unbreakables(langs='ru')
input_text = f"Проверка {word} тестирование."
expected_text = f"Проверка {word}\u00A0тестирование."
expected_text = f"Проверка {word}{CHAR_NBSP}тестирование."
# Act (действие, которое выполняем)
actual_text = unbreakables_ru.process(input_text)
# Assert (утверждение, что результат соответствует ожиданиям)
@@ -48,7 +49,7 @@ def test_english_prepositions_are_unbreakable(word):
# Arrange (подготовка)
unbreakables_en = Unbreakables(langs='en')
input_text = f"Training {word} test."
expected_text = f"Training {word}\u00A0test."
expected_text = f"Training {word}{CHAR_NBSP}test."
# Act (действие, которое выполняем)
actual_text = unbreakables_en.process(input_text)
# Assert (утверждение, что результат соответствует ожиданиям)
@@ -63,7 +64,7 @@ def test_mix_prepositions_are_unbreakable():
# Arrange (подготовка)
unbreakables_mix = Unbreakables(langs='ru+en')
input_text = f"Для the Guardian он написал блестящую статью."
expected_text = f"Для\u00A0the\u00A0Guardian он\u00A0написал блестящую статью."
expected_text = f"Для{CHAR_NBSP}the{CHAR_NBSP}Guardian он{CHAR_NBSP}написал блестящую статью."
# Act (действие, которое выполняем)
actual_text = unbreakables_mix.process(input_text)
# Assert (утверждение, что результат соответствует ожиданиям)
@@ -83,8 +84,22 @@ def test_russian_postpositive_particle(word):
# Arrange
unbreakables_ru = Unbreakables(langs='ru')
input_text = f"Отчего {word} не поспать?"
expected_text = f"Отчего\u00A0{word} не\u00A0поспать?"
expected_text = f"Отчего{CHAR_NBSP}{word} не{CHAR_NBSP}поспать?"
# Act
actual_text = unbreakables_ru.process(input_text)
# Assert
assert actual_text == expected_text
# Тесты для проверки особых случаев в Unbreakables
UNBREAKABLES_SPECIAL_TEST_CASES = [
('ru', "до н.э.", f"до{CHAR_NBSP}н.э."),
]
@pytest.mark.parametrize("lang, input_string, expected_output", UNBREAKABLES_SPECIAL_TEST_CASES)
def test_layout_processor_with_options(lang, input_string, expected_output):
"""Проверяет работу Unbreakables с особыми случаями. """
processor = Unbreakables(langs=lang)
actual_output = processor.process(input_string)
assert actual_output == expected_output