russian-tts-normalization 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- russian.py +1198 -0
- russian_tts_normalization-1.0.0.dist-info/METADATA +120 -0
- russian_tts_normalization-1.0.0.dist-info/RECORD +6 -0
- russian_tts_normalization-1.0.0.dist-info/WHEEL +5 -0
- russian_tts_normalization-1.0.0.dist-info/licenses/LICENSE +27 -0
- russian_tts_normalization-1.0.0.dist-info/top_level.txt +1 -0
russian.py
ADDED
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
# ---- Embedded vocabularies (kept in-file so the module is a single file) -------
|
|
4
|
+
|
|
5
|
+
_ABBREVIATIONS_TSV = """\
|
|
6
|
+
# Russian textual abbreviations -> spoken form.
|
|
7
|
+
# Source: NVIDIA NeMo-text-processing, ru/data/whitelist.tsv (Apache-2.0)
|
|
8
|
+
# https://github.com/NVIDIA/NeMo-text-processing
|
|
9
|
+
# Format: <abbreviation><TAB><expansion> (lines starting with # are ignored).
|
|
10
|
+
# Matching is case-insensitive and tolerant of spaces after the dots.
|
|
11
|
+
#
|
|
12
|
+
# Only entries with a single, context-independent expansion are kept. NeMo's
|
|
13
|
+
# whitelist resolves the rest by context (a WFST), which a deterministic rule
|
|
14
|
+
# engine cannot do, so these are intentionally omitted:
|
|
15
|
+
# г. (год/году/года/город), кв. (квартира/квартал), ЖК (5 senses),
|
|
16
|
+
# комн./эт. (adjective agreement), экз. (count agreement).
|
|
17
|
+
гг. годы
|
|
18
|
+
р-н район
|
|
19
|
+
до н. э. до нашей эры
|
|
20
|
+
н. э. нашей эры
|
|
21
|
+
см. также смотри также
|
|
22
|
+
с.ш. северной широты
|
|
23
|
+
ю.ш. южной широты
|
|
24
|
+
в.д. восточной долготы
|
|
25
|
+
з.д. западной долготы
|
|
26
|
+
и т. д. и так далее
|
|
27
|
+
и т. п. и тому подобное
|
|
28
|
+
б/у бывший в употреблении
|
|
29
|
+
и др. и другие
|
|
30
|
+
и пр. и прочие
|
|
31
|
+
т.е. то есть
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_MEASUREMENTS_TSV = """\
|
|
35
|
+
# Units of measurement -> spoken form, with count agreement.
|
|
36
|
+
# Abbreviation inventory informed by NVIDIA NeMo-text-processing
|
|
37
|
+
# (ru/data/measurements.tsv, Apache-2.0). Forms here are standard Russian
|
|
38
|
+
# declensions written/checked by hand, because NeMo's own forms contain
|
|
39
|
+
# spelling errors (e.g. it lists "кг" as "килограм"/"килограмов", one м).
|
|
40
|
+
#
|
|
41
|
+
# Format: <abbr><TAB><one><TAB><few (2-4)><TAB><many (5+, 0, 11-14)><TAB><gender m|f>
|
|
42
|
+
# one -> "1 X" (один/одна X) few -> "2-4 X" (два/две X)
|
|
43
|
+
# many -> "5+ X" (пять X) gender selects один/одна, два/две for the numeral
|
|
44
|
+
# Matching is case-sensitive (км != КМ, Вт != вт) and requires a number before the unit.
|
|
45
|
+
# Ambiguous one-letter abbreviations (г=грамм/год, т=тонна/том, с=секунда/предлог) are omitted.
|
|
46
|
+
#
|
|
47
|
+
# length
|
|
48
|
+
км километр километра километров m
|
|
49
|
+
м метр метра метров m
|
|
50
|
+
см сантиметр сантиметра сантиметров m
|
|
51
|
+
мм миллиметр миллиметра миллиметров m
|
|
52
|
+
дм дециметр дециметра дециметров m
|
|
53
|
+
# mass
|
|
54
|
+
кг килограмм килограмма килограммов m
|
|
55
|
+
мг миллиграмм миллиграмма миллиграммов m
|
|
56
|
+
# volume
|
|
57
|
+
л литр литра литров m
|
|
58
|
+
мл миллилитр миллилитра миллилитров m
|
|
59
|
+
# time
|
|
60
|
+
ч час часа часов m
|
|
61
|
+
мин минута минуты минут f
|
|
62
|
+
сек секунда секунды секунд f
|
|
63
|
+
# area / volume
|
|
64
|
+
га гектар гектара гектаров m
|
|
65
|
+
м² квадратный метр квадратных метра квадратных метров m
|
|
66
|
+
м2 квадратный метр квадратных метра квадратных метров m
|
|
67
|
+
км² квадратный километр квадратных километра квадратных километров m
|
|
68
|
+
км2 квадратный километр квадратных километра квадратных километров m
|
|
69
|
+
см² квадратный сантиметр квадратных сантиметра квадратных сантиметров m
|
|
70
|
+
м³ кубический метр кубических метра кубических метров m
|
|
71
|
+
м3 кубический метр кубических метра кубических метров m
|
|
72
|
+
# speed
|
|
73
|
+
км/ч километр в час километра в час километров в час m
|
|
74
|
+
м/с метр в секунду метра в секунду метров в секунду m
|
|
75
|
+
# frequency
|
|
76
|
+
Гц герц герца герц m
|
|
77
|
+
кГц килогерц килогерца килогерц m
|
|
78
|
+
МГц мегагерц мегагерца мегагерц m
|
|
79
|
+
ГГц гигагерц гигагерца гигагерц m
|
|
80
|
+
# power / electricity
|
|
81
|
+
Вт ватт ватта ватт m
|
|
82
|
+
кВт киловатт киловатта киловатт m
|
|
83
|
+
МВт мегаватт мегаватта мегаватт m
|
|
84
|
+
В вольт вольта вольт m
|
|
85
|
+
кВ киловольт киловольта киловольт m
|
|
86
|
+
А ампер ампера ампер m
|
|
87
|
+
мА миллиампер миллиампера миллиампер m
|
|
88
|
+
Ом ом ома ом m
|
|
89
|
+
# data
|
|
90
|
+
бит бит бита бит m
|
|
91
|
+
байт байт байта байт m
|
|
92
|
+
КБ килобайт килобайта килобайт m
|
|
93
|
+
МБ мегабайт мегабайта мегабайт m
|
|
94
|
+
ГБ гигабайт гигабайта гигабайт m
|
|
95
|
+
ТБ терабайт терабайта терабайт m
|
|
96
|
+
# temperature (degrees; °C / °F handled separately)
|
|
97
|
+
° градус градуса градусов m
|
|
98
|
+
# dotted unit abbreviations (Kaggle gold convention: 82 т. -> тонны, 351 с. -> секунды;
|
|
99
|
+
# the dot disambiguates these from the bare letters omitted above)
|
|
100
|
+
т. тонна тонны тонн f
|
|
101
|
+
с. секунда секунды секунд f
|
|
102
|
+
мин. минута минуты минут f
|
|
103
|
+
ч. час часа часов m
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
_ENGLISH_TSV = """\
|
|
107
|
+
# Frequent English words / brand names -> conventional Russian rendering.
|
|
108
|
+
# Lookup is case-insensitive on whole Latin words; anything not listed falls
|
|
109
|
+
# through to letter-name spelling (acronyms) or transliteration.
|
|
110
|
+
# Format: <word><TAB><cyrillic>
|
|
111
|
+
the зе
|
|
112
|
+
one уан
|
|
113
|
+
two ту
|
|
114
|
+
google гугл
|
|
115
|
+
python питон
|
|
116
|
+
ios айос
|
|
117
|
+
iphone айфон
|
|
118
|
+
ipad айпад
|
|
119
|
+
windows виндоус
|
|
120
|
+
microsoft майкрософт
|
|
121
|
+
apple эппл
|
|
122
|
+
facebook фейсбук
|
|
123
|
+
youtube ютуб
|
|
124
|
+
twitter твиттер
|
|
125
|
+
instagram инстаграм
|
|
126
|
+
telegram телеграм
|
|
127
|
+
whatsapp вотсап
|
|
128
|
+
skype скайп
|
|
129
|
+
android андроид
|
|
130
|
+
samsung самсунг
|
|
131
|
+
intel интел
|
|
132
|
+
linux линукс
|
|
133
|
+
internet интернет
|
|
134
|
+
online онлайн
|
|
135
|
+
email имейл
|
|
136
|
+
news ньюс
|
|
137
|
+
ok окей
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
_YO_TSV = """\
|
|
141
|
+
# ё restoration: words whose е-spelling unambiguously stands for ё.
|
|
142
|
+
# Only unambiguous forms are listed (no звезды/все/вышел-type homographs).
|
|
143
|
+
# Format: <е-spelling><TAB><ё-spelling>; matching is word-bounded,
|
|
144
|
+
# the capitalization of the first letter is preserved.
|
|
145
|
+
еще ещё
|
|
146
|
+
ее её
|
|
147
|
+
нее неё
|
|
148
|
+
елка ёлка
|
|
149
|
+
елки ёлки
|
|
150
|
+
елку ёлку
|
|
151
|
+
елке ёлке
|
|
152
|
+
елкой ёлкой
|
|
153
|
+
зеленый зелёный
|
|
154
|
+
зеленая зелёная
|
|
155
|
+
зеленое зелёное
|
|
156
|
+
зеленые зелёные
|
|
157
|
+
желтый жёлтый
|
|
158
|
+
желтая жёлтая
|
|
159
|
+
желтое жёлтое
|
|
160
|
+
желтые жёлтые
|
|
161
|
+
черный чёрный
|
|
162
|
+
черная чёрная
|
|
163
|
+
черное чёрное
|
|
164
|
+
черные чёрные
|
|
165
|
+
легкий лёгкий
|
|
166
|
+
легкая лёгкая
|
|
167
|
+
легкое лёгкое
|
|
168
|
+
легкие лёгкие
|
|
169
|
+
тяжелый тяжёлый
|
|
170
|
+
тяжелая тяжёлая
|
|
171
|
+
тяжелое тяжёлое
|
|
172
|
+
тяжелые тяжёлые
|
|
173
|
+
теплый тёплый
|
|
174
|
+
теплая тёплая
|
|
175
|
+
теплое тёплое
|
|
176
|
+
теплые тёплые
|
|
177
|
+
твердый твёрдый
|
|
178
|
+
серьезный серьёзный
|
|
179
|
+
серьезно серьёзно
|
|
180
|
+
определенно определённо
|
|
181
|
+
лед лёд
|
|
182
|
+
идет идёт
|
|
183
|
+
придет придёт
|
|
184
|
+
пойдет пойдёт
|
|
185
|
+
найдет найдёт
|
|
186
|
+
поймет поймёт
|
|
187
|
+
начнет начнёт
|
|
188
|
+
дает даёт
|
|
189
|
+
остается остаётся
|
|
190
|
+
шел шёл
|
|
191
|
+
пошел пошёл
|
|
192
|
+
пришел пришёл
|
|
193
|
+
ушел ушёл
|
|
194
|
+
нашел нашёл
|
|
195
|
+
подошел подошёл
|
|
196
|
+
произошел произошёл
|
|
197
|
+
вперед вперёд
|
|
198
|
+
полет полёт
|
|
199
|
+
самолет самолёт
|
|
200
|
+
самолета самолёта
|
|
201
|
+
отчет отчёт
|
|
202
|
+
счет счёт
|
|
203
|
+
учет учёт
|
|
204
|
+
расчет расчёт
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def _read_lines(name):
|
|
208
|
+
"""Yield non-empty, non-comment, stripped lines from an embedded vocabulary."""
|
|
209
|
+
for line in globals()[f'_{name.upper()}_TSV'].splitlines():
|
|
210
|
+
line = line.strip()
|
|
211
|
+
if line and not line.startswith('#'):
|
|
212
|
+
yield line
|
|
213
|
+
|
|
214
|
+
# Updated mapping dictionary with common digraphs
|
|
215
|
+
cyrrilization_mapping_extended = {
|
|
216
|
+
'a': 'а', 'b': 'б', 'c': 'к', 'd': 'д', 'e': 'е',
|
|
217
|
+
'f': 'ф', 'g': 'г', 'h': 'х', 'i': 'и', 'j': 'й',
|
|
218
|
+
'k': 'к', 'l': 'л', 'm': 'м', 'n': 'н', 'o': 'о',
|
|
219
|
+
'p': 'п', 'q': 'к', 'r': 'р', 's': 'с', 't': 'т',
|
|
220
|
+
'u': 'у', 'v': 'в', 'w': 'в', 'x': 'кс', 'y': 'ы',
|
|
221
|
+
'z': 'з',
|
|
222
|
+
# Common digraphs
|
|
223
|
+
'sh': 'ш', 'ch': 'ч', 'th': 'з', 'ph': 'ф', 'oo': 'у', 'ee': 'и', 'kh': 'х',
|
|
224
|
+
# common trigraphs
|
|
225
|
+
'sch': 'ск'
|
|
226
|
+
# Capital letters are also converted to lowercase in the cyrrilization
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Russian letter to its phonetic pronunciation mapping
|
|
231
|
+
pronunciation_map = {
|
|
232
|
+
'А': 'а', 'Б': 'бэ', 'В': 'вэ', 'Г': 'гэ', 'Д': 'дэ',
|
|
233
|
+
'Е': 'е', 'Ё': 'ё', 'Ж': 'жэ', 'З': 'зэ', 'И': 'и',
|
|
234
|
+
'Й': 'ий', 'К': 'ка', 'Л': 'эл', 'М': 'эм', 'Н': 'эн',
|
|
235
|
+
'О': 'о', 'П': 'пэ', 'Р': 'эр', 'С': 'эс', 'Т': 'тэ',
|
|
236
|
+
'У': 'у', 'Ф': 'эф', 'Х': 'ха', 'Ц': 'цэ', 'Ч': 'чэ',
|
|
237
|
+
'Ш': 'ша', 'Щ': 'ща', 'Ъ': 'твёрдый знак', 'Ы': 'ы', 'Ь': 'мягкий знак',
|
|
238
|
+
'Э': 'э', 'Ю': 'ю', 'Я': 'я'
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# ---- Textual abbreviations ----------------------------------------------------
|
|
242
|
+
def _load_abbreviations():
|
|
243
|
+
"""Return (compiled_regex, {canonical_key: expansion})."""
|
|
244
|
+
mapping = {}
|
|
245
|
+
for line in _read_lines('abbreviations'):
|
|
246
|
+
if '\t' not in line:
|
|
247
|
+
continue
|
|
248
|
+
key, value = line.split('\t', 1)
|
|
249
|
+
mapping[re.sub(r'\s+', '', key).lower()] = value.strip()
|
|
250
|
+
if not mapping:
|
|
251
|
+
return None, {}
|
|
252
|
+
# Build one alternation, longest key first; dots may be followed by spaces.
|
|
253
|
+
def to_pattern(key):
|
|
254
|
+
out = ''
|
|
255
|
+
for ch in key:
|
|
256
|
+
out += r'\.\s*' if ch == '.' else (r'\s*' if ch == ' ' else re.escape(ch))
|
|
257
|
+
return out
|
|
258
|
+
keys = sorted({line.split('\t', 1)[0] for line in _read_lines('abbreviations') if '\t' in line},
|
|
259
|
+
key=len, reverse=True)
|
|
260
|
+
pattern = r'(?<![А-Яа-яёЁ])(?:' + '|'.join(to_pattern(k) for k in keys) + r')(?![А-Яа-яёЁ])'
|
|
261
|
+
return re.compile(pattern, re.IGNORECASE), mapping
|
|
262
|
+
|
|
263
|
+
_abbr_re, _abbr_map = _load_abbreviations()
|
|
264
|
+
|
|
265
|
+
def normalize_abbreviations(text):
|
|
266
|
+
"""Expand common textual abbreviations (т.д. -> так далее, ул. -> улица)."""
|
|
267
|
+
if not _abbr_re:
|
|
268
|
+
return text
|
|
269
|
+
def repl(m):
|
|
270
|
+
canon = re.sub(r'\s+', '', m.group(0)).lower()
|
|
271
|
+
return _abbr_map.get(canon, m.group(0))
|
|
272
|
+
return _abbr_re.sub(repl, text)
|
|
273
|
+
|
|
274
|
+
# ---- Acronyms ----------------------------------------------------------------
|
|
275
|
+
# Whether an all-caps acronym is read as a word (НАТО) or spelled out (СССР) is a
|
|
276
|
+
# pronunciation-lexicon question, not a flat list. We use a vowel heuristic, which
|
|
277
|
+
# needs no unverified data: a vowel-less run is spelled letter by letter, anything
|
|
278
|
+
# pronounceable (incl. emphasised words like ВАЖНО) is read as a word. The known
|
|
279
|
+
# exceptions (e.g. США, read letter by letter despite vowels) would need a vetted
|
|
280
|
+
# lexicon, which is intentionally not bundled.
|
|
281
|
+
_RU_VOWELS = set('АЕЁИОУЫЭЮЯ')
|
|
282
|
+
|
|
283
|
+
def _spell_letters(token):
|
|
284
|
+
return ' '.join(pronunciation_map[c] for c in token if c in pronunciation_map)
|
|
285
|
+
|
|
286
|
+
def expand_abbreviations(text):
|
|
287
|
+
"""Read all-caps Cyrillic acronyms: vowel-less runs (СССР) are spelled out,
|
|
288
|
+
pronounceable ones (НАТО) and emphasised words (ВАЖНО) are kept as-is
|
|
289
|
+
(case carries no spoken information, and the gold set preserves it)."""
|
|
290
|
+
def repl(m):
|
|
291
|
+
token = m.group(0)
|
|
292
|
+
if not (set(token.upper()) & _RU_VOWELS):
|
|
293
|
+
return _spell_letters(token.upper())
|
|
294
|
+
return token
|
|
295
|
+
return re.sub(r'\b[А-ЯЁ]{2,}\b', repl, text)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cyrrilize(text):
|
|
299
|
+
"""Transliterate only Latin letters to approximate Cyrillic, leaving Cyrillic
|
|
300
|
+
text (and its original case) and all other characters untouched."""
|
|
301
|
+
cyrrilized_text = ""
|
|
302
|
+
i = 0
|
|
303
|
+
while i < len(text):
|
|
304
|
+
ch = text[i]
|
|
305
|
+
if ch.isascii() and ch.isalpha():
|
|
306
|
+
digraph = text[i:i+2].lower()
|
|
307
|
+
if (i + 1 < len(text) and text[i+1].isascii() and text[i+1].isalpha()
|
|
308
|
+
and digraph in cyrrilization_mapping_extended):
|
|
309
|
+
cyrrilized_text += cyrrilization_mapping_extended[digraph]
|
|
310
|
+
i += 2
|
|
311
|
+
else:
|
|
312
|
+
cyrrilized_text += cyrrilization_mapping_extended.get(ch.lower(), ch)
|
|
313
|
+
i += 1
|
|
314
|
+
else:
|
|
315
|
+
cyrrilized_text += ch
|
|
316
|
+
i += 1
|
|
317
|
+
return cyrrilized_text
|
|
318
|
+
|
|
319
|
+
def number_to_words(n):
|
|
320
|
+
"""
|
|
321
|
+
Convert a number into its word components in Russian
|
|
322
|
+
"""
|
|
323
|
+
if n == 0:
|
|
324
|
+
return 'ноль'
|
|
325
|
+
|
|
326
|
+
units = ['','один','два','три','четыре','пять','шесть','семь','восемь','девять']
|
|
327
|
+
teens = ['десять','одиннадцать','двенадцать','тринадцать','четырнадцать','пятнадцать','шестнадцать','семнадцать','восемнадцать','девятнадцать']
|
|
328
|
+
tens = ['','десять','двадцать','тридцать','сорок','пятьдесят','шестьдесят','семьдесят','восемьдесят','девяносто']
|
|
329
|
+
hundreds = ['','сто','двести','триста','четыреста','пятьсот','шестьсот','семьсот','восемьсот','девятьсот']
|
|
330
|
+
|
|
331
|
+
# (scale value, plural forms, feminine?) from largest to smallest.
|
|
332
|
+
scales = [
|
|
333
|
+
(10**15, ['квадриллион', 'квадриллиона', 'квадриллионов'], False),
|
|
334
|
+
(10**12, ['триллион', 'триллиона', 'триллионов'], False),
|
|
335
|
+
(10**9, ['миллиард', 'миллиарда', 'миллиардов'], False),
|
|
336
|
+
(10**6, ['миллион', 'миллиона', 'миллионов'], False),
|
|
337
|
+
(10**3, ['тысяча', 'тысячи', 'тысяч'], True),
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
words = []
|
|
341
|
+
|
|
342
|
+
# Helper function to resolve the correct form of thousands, millions, and billions
|
|
343
|
+
def russian_plural(number, units):
|
|
344
|
+
if number % 10 == 1 and number % 100 != 11:
|
|
345
|
+
return units[0]
|
|
346
|
+
elif 2 <= number % 10 <= 4 and (number % 100 < 10 or number % 100 >= 20):
|
|
347
|
+
return units[1]
|
|
348
|
+
else:
|
|
349
|
+
return units[2]
|
|
350
|
+
|
|
351
|
+
# Helper function to handle numbers below 1000
|
|
352
|
+
def under_thousand(number):
|
|
353
|
+
if number == 0:
|
|
354
|
+
return []
|
|
355
|
+
elif number < 10:
|
|
356
|
+
return [units[number]]
|
|
357
|
+
elif number < 20:
|
|
358
|
+
return [teens[number - 10]]
|
|
359
|
+
elif number < 100:
|
|
360
|
+
return [tens[number // 10], units[number % 10]]
|
|
361
|
+
else:
|
|
362
|
+
return [hundreds[number // 100]] + under_thousand(number % 100)
|
|
363
|
+
|
|
364
|
+
# Handle very large numbers (>= 10^18) digit by digit rather than failing.
|
|
365
|
+
if n >= 10**18:
|
|
366
|
+
return number_to_words_digit_by_digit(n)
|
|
367
|
+
|
|
368
|
+
for value, forms, feminine in scales:
|
|
369
|
+
count = (n // value) % 1000
|
|
370
|
+
if not count:
|
|
371
|
+
continue
|
|
372
|
+
chunk = under_thousand(count)
|
|
373
|
+
if feminine:
|
|
374
|
+
if chunk[-1] == 'один':
|
|
375
|
+
chunk[-1] = 'одна'
|
|
376
|
+
elif chunk[-1] == 'два':
|
|
377
|
+
chunk[-1] = 'две'
|
|
378
|
+
if count == 1:
|
|
379
|
+
chunk = chunk[:-1] # solitary thousand: "тысяча", not "одна тысяча"
|
|
380
|
+
words += chunk + [russian_plural(count, forms)]
|
|
381
|
+
words += under_thousand(n % 1000)
|
|
382
|
+
|
|
383
|
+
return ' '.join(word for word in words if word)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def detect_numbers(text):
|
|
387
|
+
# Regular expression pattern for matching standalone numbers
|
|
388
|
+
number_pattern = re.compile(r'\b\d+\b')
|
|
389
|
+
# Find all matches and return them along with their start and end indices
|
|
390
|
+
matches = list(number_pattern.finditer(text))
|
|
391
|
+
number_matches = [{'number': match.group(), 'start': match.start(), 'end': match.end()} for match in matches]
|
|
392
|
+
|
|
393
|
+
return number_matches
|
|
394
|
+
|
|
395
|
+
def number_to_words_digit_by_digit(n):
|
|
396
|
+
"""
|
|
397
|
+
Convert a number into its word components in Russian, digit by digit.
|
|
398
|
+
"""
|
|
399
|
+
units = ['ноль', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
|
|
400
|
+
return ' '.join(units[int(digit)] for digit in str(n))
|
|
401
|
+
|
|
402
|
+
# Update the normalize_text_with_numbers to handle large numbers by reading them digit by digit
|
|
403
|
+
def normalize_text_with_numbers(text):
|
|
404
|
+
# Detect all standalone numbers in the text
|
|
405
|
+
detected_numbers = detect_numbers(text)
|
|
406
|
+
# Sort detected numbers by their starting index in descending order
|
|
407
|
+
detected_numbers.sort(key=lambda x: x['start'], reverse=True)
|
|
408
|
+
|
|
409
|
+
# Replace each number with its normalized form
|
|
410
|
+
digit_words = ['ноль', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
|
|
411
|
+
for num in detected_numbers:
|
|
412
|
+
digits = num['number']
|
|
413
|
+
number_value = int(digits)
|
|
414
|
+
# A leading zero (e.g. "06", "007") signals a digit string, not a quantity: read it out digit by digit.
|
|
415
|
+
if len(digits) > 1 and digits[0] == '0':
|
|
416
|
+
normalized_number = ' '.join(digit_words[int(d)] for d in digits)
|
|
417
|
+
else:
|
|
418
|
+
normalized_number = number_to_words(number_value) # self-handles >= 10^18
|
|
419
|
+
# Replace the original number in the text with its normalized form
|
|
420
|
+
text = text[:num['start']] + normalized_number + text[num['end']:]
|
|
421
|
+
|
|
422
|
+
return text
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def normalize_phone_number(phone_number):
|
|
426
|
+
# Strip the phone number of all non-numeric characters
|
|
427
|
+
digits = re.sub(r'\D', '', phone_number)
|
|
428
|
+
|
|
429
|
+
# Define the segments for the Russian phone number
|
|
430
|
+
segments = {
|
|
431
|
+
'country_code': digits[:1], # +7 or 8
|
|
432
|
+
'area_code': digits[1:4], # 495
|
|
433
|
+
'block_1': digits[4:7], # 123
|
|
434
|
+
'block_2': digits[7:9], # 45
|
|
435
|
+
'block_3': digits[9:11], # 67
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
# Normalizing the country code
|
|
439
|
+
if segments['country_code'] == '8':
|
|
440
|
+
segments['country_code'] = 'восемь'
|
|
441
|
+
elif segments['country_code'] == '7':
|
|
442
|
+
segments['country_code'] = 'плюс семь'
|
|
443
|
+
|
|
444
|
+
# Normalize each segment using the number_to_words function
|
|
445
|
+
normalized_segments = {
|
|
446
|
+
key: number_to_words(int(value)) if key != 'country_code' else value
|
|
447
|
+
for key, value in segments.items()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# Combine the segments into the final spoken form
|
|
451
|
+
spoken_form = ' '.join(normalized_segments.values())
|
|
452
|
+
|
|
453
|
+
return spoken_form
|
|
454
|
+
|
|
455
|
+
# Correcting the phone number normalization function to handle various formats correctly
|
|
456
|
+
|
|
457
|
+
def normalize_text_with_phone_numbers(text):
|
|
458
|
+
# Detect all phone numbers in the text
|
|
459
|
+
phone_pattern = re.compile(
|
|
460
|
+
r"(?:\+7|8)\s*\(?\d{3}\)?\s*\d{3}[-\s]?\d{2}[-\s]?\d{2}|8\d{10}"
|
|
461
|
+
)
|
|
462
|
+
# We use finditer here instead of findall to get the match objects, which will include the start and end indices.
|
|
463
|
+
matches = list(phone_pattern.finditer(text))
|
|
464
|
+
detected_phone_numbers = [{'phone': match.group().strip(), 'start': match.start(), 'end': match.end()} for match in matches]
|
|
465
|
+
|
|
466
|
+
# Sort detected phone numbers by their starting index in descending order
|
|
467
|
+
# This ensures that when we replace them, we don't mess up the indices of the remaining phone numbers
|
|
468
|
+
detected_phone_numbers.sort(key=lambda x: x['start'], reverse=True)
|
|
469
|
+
|
|
470
|
+
# Replace each phone number with its normalized form
|
|
471
|
+
for pn in detected_phone_numbers:
|
|
472
|
+
normalized_phone = normalize_phone_number(pn['phone'])
|
|
473
|
+
# Replace the original phone number in the text with its normalized form
|
|
474
|
+
text = text[:pn['start']] + normalized_phone + text[pn['end']:]
|
|
475
|
+
|
|
476
|
+
return text
|
|
477
|
+
|
|
478
|
+
# Full function that detects and converts currency in a text to its full Russian word representation
|
|
479
|
+
def currency_normalization(text):
|
|
480
|
+
"""
|
|
481
|
+
Detects currency amounts in the text and converts them to their word representations in Russian.
|
|
482
|
+
"""
|
|
483
|
+
# Helper function to resolve the correct form of the currency units
|
|
484
|
+
def russian_plural(number, units):
|
|
485
|
+
if number % 10 == 1 and number % 100 != 11:
|
|
486
|
+
return units[0]
|
|
487
|
+
elif 2 <= number % 10 <= 4 and (number % 100 < 10 or number % 100 >= 20):
|
|
488
|
+
return units[1]
|
|
489
|
+
else:
|
|
490
|
+
return units[2]
|
|
491
|
+
|
|
492
|
+
# Function to convert a currency amount into its word components in Russian
|
|
493
|
+
def currency_to_words(amount, currency='rub'):
|
|
494
|
+
# Define the currency units and subunits
|
|
495
|
+
currencies = {
|
|
496
|
+
'rub': (['рубль', 'рубля', 'рублей'], ['копейка', 'копейки', 'копеек']),
|
|
497
|
+
'usd': (['доллар', 'доллара', 'долларов'], ['цент', 'цента', 'центов']),
|
|
498
|
+
'eur': (['евро', 'евро', 'евро'], ['евроцент', 'евроцента', 'евроцентов']), # Euro has invariable form
|
|
499
|
+
'gbp': (['фунт', 'фунта', 'фунтов'], ['пенс', 'пенса', 'пенсов']),
|
|
500
|
+
'uah': (['гривна', 'гривны', 'гривен'], ['копейка', 'копейки', 'копеек']),
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
# Get the correct currency units
|
|
504
|
+
main_units, sub_units = currencies.get(currency, currencies['rub'])
|
|
505
|
+
|
|
506
|
+
# Separate the amount into main and subunits
|
|
507
|
+
main_amount = int(amount)
|
|
508
|
+
sub_amount = int(round((amount - main_amount) * 100))
|
|
509
|
+
|
|
510
|
+
# Convert numbers to words
|
|
511
|
+
main_words = number_to_words(main_amount) + ' ' + russian_plural(main_amount, main_units)
|
|
512
|
+
sub_words = ''
|
|
513
|
+
|
|
514
|
+
# Add subunits if present
|
|
515
|
+
if sub_amount > 0:
|
|
516
|
+
sub_words = number_to_words(sub_amount) + ' ' + russian_plural(sub_amount, sub_units)
|
|
517
|
+
|
|
518
|
+
# Combine main and subunit words
|
|
519
|
+
full_currency_words = main_words.strip()
|
|
520
|
+
if sub_words:
|
|
521
|
+
full_currency_words += ' ' + sub_words.strip()
|
|
522
|
+
|
|
523
|
+
return full_currency_words
|
|
524
|
+
|
|
525
|
+
# Define currency patterns for detection
|
|
526
|
+
# (?![а-яё]) keeps word forms from matching inside longer words
|
|
527
|
+
# (e.g. "рубля" inside "рублями", which the instrumental rule handles).
|
|
528
|
+
currency_patterns = {
|
|
529
|
+
'rub': [r'(\d+(?:\.\d\d)?)\s*(руб(л(ей|я|ь))?(?![а-яё])|₽)', r'(\d+(?:\.\d\d)?)\s*RUB'],
|
|
530
|
+
'usd': [r'(\d+(?:\.\d\d)?)\s*(доллар(ов|а|ы)?(?![а-яё])|\$)', r'(\d+(?:\.\d\d)?)\s*USD', r'\$(\d+(?:\.\d\d)?)'],
|
|
531
|
+
'eur': [r'(\d+(?:\.\d\d)?)\s*(евро(?![а-яё])|€)', r'(\d+(?:\.\d\d)?)\s*EUR', r'(\d+)\s*€'],
|
|
532
|
+
'gbp': [r'(\d+(?:\.\d\d)?)\s*(фунт(ов|а|ы)?(?![а-яё])|£)', r'(\d+(?:\.\d\d)?)\s*GBP', r'£(\d+)'],
|
|
533
|
+
'uah': [r'(\d+(?:\.\d\d)?)\s*(грив(ен|ны|на)(?![а-яё])|₴)', r'(\d+(?:\.\d\d)?)\s*UAH', r'(\d+)\s*₴'],
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
# Detect and convert currencies in the text
|
|
537
|
+
def detect_currency(text):
|
|
538
|
+
# Check each currency pattern to find matches
|
|
539
|
+
for currency_code, patterns in currency_patterns.items():
|
|
540
|
+
for pattern in patterns:
|
|
541
|
+
matches = re.finditer(pattern, text)
|
|
542
|
+
for match in matches:
|
|
543
|
+
# Extract the amount and convert it to words
|
|
544
|
+
amount = float(match.group(1))
|
|
545
|
+
currency_words = currency_to_words(amount, currency_code)
|
|
546
|
+
# Replace the original amount with its word representation in the text
|
|
547
|
+
text = re.sub(pattern, currency_words, text, count=1)
|
|
548
|
+
|
|
549
|
+
return text
|
|
550
|
+
|
|
551
|
+
# Run the detection and conversion on the input text
|
|
552
|
+
return detect_currency(text)
|
|
553
|
+
|
|
554
|
+
# Maps the last word of a cardinal number to its ordinal stem (nominative masculine).
|
|
555
|
+
_cardinal_to_ordinal_stem = {
|
|
556
|
+
'один': 'первый', 'два': 'второй', 'две': 'второй', 'три': 'третий',
|
|
557
|
+
'четыре': 'четвёртый', 'пять': 'пятый', 'шесть': 'шестой', 'семь': 'седьмой',
|
|
558
|
+
'восемь': 'восьмой', 'девять': 'девятый', 'десять': 'десятый',
|
|
559
|
+
'одиннадцать': 'одиннадцатый', 'двенадцать': 'двенадцатый', 'тринадцать': 'тринадцатый',
|
|
560
|
+
'четырнадцать': 'четырнадцатый', 'пятнадцать': 'пятнадцатый', 'шестнадцать': 'шестнадцатый',
|
|
561
|
+
'семнадцать': 'семнадцатый', 'восемнадцать': 'восемнадцатый', 'девятнадцать': 'девятнадцатый',
|
|
562
|
+
'двадцать': 'двадцатый', 'тридцать': 'тридцатый', 'сорок': 'сороковой',
|
|
563
|
+
'пятьдесят': 'пятидесятый', 'шестьдесят': 'шестидесятый', 'семьдесят': 'семидесятый',
|
|
564
|
+
'восемьдесят': 'восьмидесятый', 'девяносто': 'девяностый',
|
|
565
|
+
'сто': 'сотый', 'двести': 'двухсотый', 'триста': 'трёхсотый', 'четыреста': 'четырёхсотый',
|
|
566
|
+
'пятьсот': 'пятисотый', 'шестьсот': 'шестисотый', 'семьсот': 'семисотый',
|
|
567
|
+
'восемьсот': 'восьмисотый', 'девятьсот': 'девятисотый',
|
|
568
|
+
'тысяча': 'тысячный', 'тысячи': 'тысячный', 'тысяч': 'тысячный',
|
|
569
|
+
}
|
|
570
|
+
# Genitive prefix for a count word standing before "тысячный" (e.g. две -> двух тысячный).
|
|
571
|
+
_count_genitive_prefix = {
|
|
572
|
+
'две': 'двух', 'два': 'двух', 'три': 'трёх', 'четыре': 'четырёх', 'пять': 'пяти',
|
|
573
|
+
'шесть': 'шести', 'семь': 'семи', 'восемь': 'восьми', 'девять': 'девяти',
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
# Endings for an ordinal, keyed by grammatical form, for hard (-ый/-ой) and soft (-ий) stems.
|
|
577
|
+
_ordinal_endings = {
|
|
578
|
+
'nom_n': ('ое', 'ье'), 'nom_f': ('ая', 'ья'), 'nom_pl': ('ые', 'ьи'),
|
|
579
|
+
'gen': ('ого', 'ьего'), 'dat': ('ому', 'ьему'), 'prep': ('ом', 'ьем'),
|
|
580
|
+
'pl': ('ых', 'ьих'), 'acc_f': ('ую', 'ью'),
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
def _inflect_ordinal(stem, form):
|
|
584
|
+
"""Inflect a nominative-masculine ordinal stem into the requested form
|
|
585
|
+
(nom_m keeps the stem; other forms drop the -ый/-ой/-ий ending)."""
|
|
586
|
+
if form == 'nom_m':
|
|
587
|
+
return stem
|
|
588
|
+
soft = stem.endswith('ий') # третий
|
|
589
|
+
return stem[:-2] + _ordinal_endings[form][1 if soft else 0]
|
|
590
|
+
|
|
591
|
+
def number_to_ordinal_words(n, form='nom_m'):
|
|
592
|
+
"""Convert an integer to its ordinal words in Russian. Only the final
|
|
593
|
+
component is ordinalized; preceding components stay cardinal."""
|
|
594
|
+
words = number_to_words(n).split()
|
|
595
|
+
last = words[-1]
|
|
596
|
+
if last in ('тысяча', 'тысячи', 'тысяч') and len(words) > 1 and words[-2] in _count_genitive_prefix:
|
|
597
|
+
# round thousands: "две тысячи" -> "двух тысячный" (2000 -> двухтысячный read split)
|
|
598
|
+
words[-2] = _count_genitive_prefix[words[-2]]
|
|
599
|
+
words[-1] = _inflect_ordinal(_cardinal_to_ordinal_stem.get(last, last), form)
|
|
600
|
+
return ' '.join(words)
|
|
601
|
+
|
|
602
|
+
_MONTHS_GEN = ('января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля',
|
|
603
|
+
'августа', 'сентября', 'октября', 'ноября', 'декабря')
|
|
604
|
+
_MONTH_BY_NUM = {f'{i:02d}': m for i, m in enumerate(_MONTHS_GEN, start=1)}
|
|
605
|
+
_GOD_FORM = {'год': 'nom_m', 'года': 'gen', 'году': 'prep', 'годе': 'prep'}
|
|
606
|
+
|
|
607
|
+
_re_date_numeric = re.compile(r'\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b')
|
|
608
|
+
_re_date_spelled = re.compile(
|
|
609
|
+
r'\b(\d{1,2})\s+(' + '|'.join(_MONTHS_GEN) + r')\s+(\d{3,4})(?:\s+года\b|\s*г\.(?![а-яё]))?')
|
|
610
|
+
_re_date_daymonth = re.compile(r'\b(\d{1,2})\s+(' + '|'.join(_MONTHS_GEN) + r')\b')
|
|
611
|
+
_re_year_god = re.compile(r'\b(\d{1,4})\s+(год|года|году|годе)\b')
|
|
612
|
+
# "2008 г." -> ordinal year + год (nominative default; "г." = город never follows digits)
|
|
613
|
+
_re_year_g = re.compile(r'\b(\d{3,4})\s*г\.(?![а-яё])')
|
|
614
|
+
|
|
615
|
+
def normalize_dates(text):
|
|
616
|
+
"""Normalize the rule-tractable date shapes: DD.MM.YYYY, "D month YYYY",
|
|
617
|
+
"D month", and "<year> год/года/году"."""
|
|
618
|
+
def numeric(m):
|
|
619
|
+
day, month, year = m.group(1), m.group(2), m.group(3)
|
|
620
|
+
mn = _MONTH_BY_NUM.get(f'{int(month):02d}')
|
|
621
|
+
if not mn:
|
|
622
|
+
return m.group(0)
|
|
623
|
+
return f"{number_to_ordinal_words(int(day), 'nom_n')} {mn} {number_to_ordinal_words(int(year), 'gen')} года"
|
|
624
|
+
|
|
625
|
+
def spelled(m):
|
|
626
|
+
day, month, year = int(m.group(1)), m.group(2), int(m.group(3))
|
|
627
|
+
return f"{number_to_ordinal_words(day, 'gen')} {month} {number_to_ordinal_words(year, 'gen')} года"
|
|
628
|
+
|
|
629
|
+
def daymonth(m):
|
|
630
|
+
return f"{number_to_ordinal_words(int(m.group(1)), 'gen')} {m.group(2)}"
|
|
631
|
+
|
|
632
|
+
def year_god(m):
|
|
633
|
+
return f"{number_to_ordinal_words(int(m.group(1)), _GOD_FORM[m.group(2)])} {m.group(2)}"
|
|
634
|
+
|
|
635
|
+
def year_g(m):
|
|
636
|
+
y = int(m.group(1))
|
|
637
|
+
if not 900 <= y <= 2199:
|
|
638
|
+
return m.group(0)
|
|
639
|
+
return f"{number_to_ordinal_words(y, 'nom_m')} год"
|
|
640
|
+
|
|
641
|
+
text = _re_date_numeric.sub(numeric, text)
|
|
642
|
+
text = _re_date_spelled.sub(spelled, text)
|
|
643
|
+
text = _re_date_daymonth.sub(daymonth, text)
|
|
644
|
+
text = _re_year_god.sub(year_god, text)
|
|
645
|
+
text = _re_year_g.sub(year_g, text)
|
|
646
|
+
return text
|
|
647
|
+
|
|
648
|
+
# Standalone symbols and non-Russian letters spoken by name.
|
|
649
|
+
# Multi-character keys come first so they are replaced before their substrings.
|
|
650
|
+
_symbol_map = {
|
|
651
|
+
'°C': 'градусов цельсия', '°С': 'градусов цельсия', '°F': 'градусов фаренгейта',
|
|
652
|
+
'±': 'плюс минус', '≈': 'приблизительно равно', '≠': 'не равно',
|
|
653
|
+
'≤': 'меньше или равно', '≥': 'больше или равно', '×': 'умножить на',
|
|
654
|
+
'÷': 'разделить на', '=': 'равно', '<': 'меньше', '>': 'больше',
|
|
655
|
+
'‰': 'промилле', '§': 'параграф', '₿': 'биткоин', '•': ' ', '·': ' ',
|
|
656
|
+
'~': 'тильда',
|
|
657
|
+
'&': 'и', '#': 'решетка', '_': 'нижнее подчеркивание',
|
|
658
|
+
'²': 'в квадрате', '³': 'в кубе', '№': 'номер',
|
|
659
|
+
# Cyrillic letters outside the Russian alphabet
|
|
660
|
+
'ї': 'и', 'і': 'и', 'ў': 'у', 'є': 'е', 'ґ': 'г',
|
|
661
|
+
# Greek alphabet (lower and upper case spoken with the same name)
|
|
662
|
+
'α': 'альфа', 'β': 'бета', 'γ': 'гамма', 'δ': 'дельта', 'ε': 'эпсилон',
|
|
663
|
+
'ζ': 'дзета', 'η': 'эта', 'θ': 'тета', 'ι': 'йота', 'κ': 'каппа',
|
|
664
|
+
'λ': 'лямбда', 'μ': 'мю', 'ν': 'ню', 'ξ': 'кси', 'ο': 'омикрон',
|
|
665
|
+
'π': 'пи', 'ρ': 'ро', 'σ': 'сигма', 'ς': 'сигма', 'τ': 'тау',
|
|
666
|
+
'υ': 'ипсилон', 'φ': 'фи', 'χ': 'хи', 'ψ': 'пси', 'ω': 'омега',
|
|
667
|
+
}
|
|
668
|
+
_symbol_map.update({k.upper(): v for k, v in list(_symbol_map.items()) if k.upper() != k})
|
|
669
|
+
|
|
670
|
+
def normalize_symbols(text):
|
|
671
|
+
"""Replace standalone symbols / foreign letters with their spoken names."""
|
|
672
|
+
for sym, word in _symbol_map.items():
|
|
673
|
+
if sym in text:
|
|
674
|
+
text = text.replace(sym, ' ' + word + ' ')
|
|
675
|
+
return re.sub(r' {2,}', ' ', text).strip() if text else text
|
|
676
|
+
|
|
677
|
+
# Place value (genitive plural) for the fractional part of a decimal, by digit count.
|
|
678
|
+
_decimal_places = {1: 'десятых', 2: 'сотых', 3: 'тысячных', 4: 'десятитысячных',
|
|
679
|
+
5: 'стотысячных', 6: 'миллионных'}
|
|
680
|
+
|
|
681
|
+
def _feminine_last(words):
|
|
682
|
+
"""Russian fractions count in the feminine: один->одна, два->две (last word only)."""
|
|
683
|
+
if words and words[-1] == 'один':
|
|
684
|
+
words[-1] = 'одна'
|
|
685
|
+
elif words and words[-1] == 'два':
|
|
686
|
+
words[-1] = 'две'
|
|
687
|
+
return words
|
|
688
|
+
|
|
689
|
+
def _decimal_to_words(int_part, frac_part):
|
|
690
|
+
"""'7', '54' -> 'семь целых и пятьдесят четыре сотых' (None if unsupported length)."""
|
|
691
|
+
place = _decimal_places.get(len(frac_part))
|
|
692
|
+
if place is None:
|
|
693
|
+
return None
|
|
694
|
+
int_words = _feminine_last(number_to_words(int(int_part)).split())
|
|
695
|
+
whole = 'целая' if (int(int_part) % 10 == 1 and int(int_part) % 100 != 11) else 'целых'
|
|
696
|
+
frac_words = _feminine_last(number_to_words(int(frac_part)).split())
|
|
697
|
+
return f"{' '.join(int_words)} {whole} и {' '.join(frac_words)} {place}"
|
|
698
|
+
|
|
699
|
+
def normalize_decimals(text):
|
|
700
|
+
"""Read decimal-comma numbers: 1,2 -> 'одна целая и две десятых'."""
|
|
701
|
+
def repl(m):
|
|
702
|
+
return _decimal_to_words(m.group(1), m.group(2)) or m.group(0)
|
|
703
|
+
return re.sub(r'\b(\d+),(\d+)\b', repl, text)
|
|
704
|
+
|
|
705
|
+
# Russian ordinal suffix (after a hyphen) -> grammatical form, e.g. "1-й" / "190-го" / "1950-х".
|
|
706
|
+
_ordinal_suffix_form = {
|
|
707
|
+
'й': 'nom_m', 'го': 'gen', 'му': 'dat', 'м': 'prep',
|
|
708
|
+
'я': 'nom_f', 'ю': 'acc_f', 'е': 'nom_pl', 'х': 'pl',
|
|
709
|
+
}
|
|
710
|
+
_re_ordinal_suffix = re.compile(r'(\d+)[-–—](' + '|'.join(_ordinal_suffix_form) + r')\b')
|
|
711
|
+
|
|
712
|
+
# Roman numerals (>=2 chars) in Russian text are almost always ordinals; their case
|
|
713
|
+
# is context-dependent, so we read them in the nominative (the natural default for
|
|
714
|
+
# "XIX век", "Людовик XIV", "том III").
|
|
715
|
+
_roman_values = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000}
|
|
716
|
+
_re_roman = re.compile(r'\b[MDCLXVI]{2,}\b')
|
|
717
|
+
_re_roman_valid = re.compile(r'^M{0,4}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3})$')
|
|
718
|
+
|
|
719
|
+
def _roman_to_int(s):
|
|
720
|
+
total = prev = 0
|
|
721
|
+
for ch in reversed(s):
|
|
722
|
+
v = _roman_values[ch]
|
|
723
|
+
total += -v if v < prev else v
|
|
724
|
+
prev = v
|
|
725
|
+
return total
|
|
726
|
+
|
|
727
|
+
# Latin abbreviations that are also valid Roman numerals — do NOT read as ordinals.
|
|
728
|
+
_roman_stoplist = {'CD', 'DVD', 'MD', 'DC', 'MC', 'MI', 'MM', 'DI', 'DIV', 'MIX', 'CIV', 'LCD'}
|
|
729
|
+
|
|
730
|
+
def normalize_ordinals(text):
|
|
731
|
+
"""Expand ordinals written with a grammatical suffix (1-й, 190-го) and
|
|
732
|
+
Roman numerals (XIX -> 'девятнадцатого')."""
|
|
733
|
+
text = _re_ordinal_suffix.sub(
|
|
734
|
+
lambda m: number_to_ordinal_words(int(m.group(1)), _ordinal_suffix_form[m.group(2)]), text)
|
|
735
|
+
def roman(m):
|
|
736
|
+
tok = m.group(0)
|
|
737
|
+
if tok in _roman_stoplist or not _re_roman_valid.match(tok):
|
|
738
|
+
return tok
|
|
739
|
+
return number_to_ordinal_words(_roman_to_int(tok), 'nom_m')
|
|
740
|
+
return _re_roman.sub(roman, text)
|
|
741
|
+
|
|
742
|
+
def _plural(n, forms):
|
|
743
|
+
"""Pick the Russian plural form: (one, few, many)."""
|
|
744
|
+
if n % 10 == 1 and n % 100 != 11:
|
|
745
|
+
return forms[0]
|
|
746
|
+
if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14:
|
|
747
|
+
return forms[1]
|
|
748
|
+
return forms[2]
|
|
749
|
+
|
|
750
|
+
# Clock times: HH:MM, HH:MM:SS and English 2PM / 8pm forms.
|
|
751
|
+
_re_time = re.compile(r'(?<![\d:])(\d{1,2}):([0-5]\d)(?![\d:])')
|
|
752
|
+
_re_time_hms = re.compile(r'(?<![\d:])(\d{1,2}):([0-5]\d):([0-5]\d)(?![\d:])')
|
|
753
|
+
_re_time_ampm = re.compile(r'\b(\d{1,2})\s*([APap])\.?\s*[Mm]\.?(?![A-Za-zа-яё])')
|
|
754
|
+
|
|
755
|
+
def _hours_words(h):
|
|
756
|
+
return f"{number_to_words(h)} {_plural(h, ('час', 'часа', 'часов'))}"
|
|
757
|
+
|
|
758
|
+
def _minutes_words(mn, forms=('минута', 'минуты', 'минут')):
|
|
759
|
+
return f"{' '.join(_feminine_last(number_to_words(mn).split()))} {_plural(mn, forms)}"
|
|
760
|
+
|
|
761
|
+
def normalize_time(text):
|
|
762
|
+
"""Read clock times: 06:06 -> 'шесть часов шесть минут', 07:00 -> 'семь часов',
|
|
763
|
+
02:25:00 -> '... ноль секунд', 2PM -> 'два часа дня'."""
|
|
764
|
+
def hms(m):
|
|
765
|
+
h, mn, s = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
766
|
+
return (f"{_hours_words(h)} {_minutes_words(mn)} "
|
|
767
|
+
f"{_minutes_words(s, ('секунда', 'секунды', 'секунд'))}")
|
|
768
|
+
|
|
769
|
+
def ampm(m):
|
|
770
|
+
h = int(m.group(1))
|
|
771
|
+
if not 1 <= h <= 12:
|
|
772
|
+
return m.group(0)
|
|
773
|
+
if m.group(2).lower() == 'p':
|
|
774
|
+
part = 'дня' if h < 6 or h == 12 else 'вечера'
|
|
775
|
+
else:
|
|
776
|
+
part = 'ночи' if h < 5 or h == 12 else 'утра'
|
|
777
|
+
return f"{_hours_words(h)} {part}"
|
|
778
|
+
|
|
779
|
+
def repl(m):
|
|
780
|
+
h, mn = int(m.group(1)), int(m.group(2))
|
|
781
|
+
out = _hours_words(h)
|
|
782
|
+
if mn:
|
|
783
|
+
out += ' ' + _minutes_words(mn)
|
|
784
|
+
return out
|
|
785
|
+
|
|
786
|
+
text = _re_time_hms.sub(hms, text)
|
|
787
|
+
text = _re_time_ampm.sub(ampm, text)
|
|
788
|
+
return _re_time.sub(repl, text)
|
|
789
|
+
|
|
790
|
+
# Simple fractions a/b -> numerator (feminine) + denominator as a genitive-plural ordinal.
|
|
791
|
+
_re_fraction = re.compile(r'\b(\d+)/(\d+)\b')
|
|
792
|
+
|
|
793
|
+
def normalize_fractions(text):
|
|
794
|
+
"""Read 'a/b' as 'two thirds': 2/3 -> 'две третьих', 653/26 -> '... двадцать шестых'."""
|
|
795
|
+
def repl(m):
|
|
796
|
+
num, den = int(m.group(1)), int(m.group(2))
|
|
797
|
+
if den >= 10**12:
|
|
798
|
+
return m.group(0)
|
|
799
|
+
numer = ' '.join(_feminine_last(number_to_words(num).split()))
|
|
800
|
+
return f"{numer} {number_to_ordinal_words(den, 'pl')}"
|
|
801
|
+
return _re_fraction.sub(repl, text)
|
|
802
|
+
|
|
803
|
+
# ---- Modern / web text cleanup -----------------------------------------------
|
|
804
|
+
def normalize_typography(text):
|
|
805
|
+
"""Normalise Unicode spaces and strip markdown emphasis markers. Quotes and
|
|
806
|
+
other punctuation are left intact (a TTS engine ignores them, and removing
|
|
807
|
+
them would only diverge from the reference data)."""
|
|
808
|
+
text = re.sub(r"[\u00a0\u2009\u202f\u2060]", " ", text) # NBSP family -> space
|
|
809
|
+
text = re.sub(r"\*\*|__|`", "", text) # markdown bold / code
|
|
810
|
+
return text
|
|
811
|
+
|
|
812
|
+
# Email / URL: spell symbols out and let cyrrilize transliterate the latin parts.
|
|
813
|
+
_re_email = re.compile(r'\b[\w.+-]+@[\w-]+\.[A-Za-zА-Яа-я]{2,}\b')
|
|
814
|
+
_re_url = re.compile(r'\b(?:https?://|www\.)\S+|\b[\w-]+\.(?:com|ru|org|net|info|io|edu|gov|рф)\b', re.I)
|
|
815
|
+
_web_symbols = {'@': ' собака ', '.': ' точка ', '/': ' слэш ', ':': ' двоеточие ',
|
|
816
|
+
'-': ' дефис ', '_': ' подчёркивание '}
|
|
817
|
+
|
|
818
|
+
def normalize_web(text):
|
|
819
|
+
"""Spell out e-mail addresses and URLs (example.com -> 'ексампле точка ком')."""
|
|
820
|
+
def spell(m):
|
|
821
|
+
s = m.group(0).rstrip('.,!?')
|
|
822
|
+
for sym, word in _web_symbols.items():
|
|
823
|
+
s = s.replace(sym, word)
|
|
824
|
+
return re.sub(r' {2,}', ' ', s).strip()
|
|
825
|
+
text = _re_email.sub(spell, text)
|
|
826
|
+
text = _re_url.sub(spell, text)
|
|
827
|
+
return re.sub(r'#([A-Za-zА-Яа-яёЁ0-9_]+)', r'хештег \1', text)
|
|
828
|
+
|
|
829
|
+
# ---- Number pre-processing ----------------------------------------------------
|
|
830
|
+
def normalize_number_groups(text):
|
|
831
|
+
"""Join space-separated digit groups into one number: '1 234 567' -> '1234567'."""
|
|
832
|
+
return re.sub(r'\b\d{1,3}(?: \d{3})+\b', lambda m: m.group(0).replace(' ', ''), text)
|
|
833
|
+
|
|
834
|
+
def normalize_negatives(text):
|
|
835
|
+
"""Read a leading minus before a number: '-5' -> 'минус 5'."""
|
|
836
|
+
return re.sub(r'(?:(?<=^)|(?<=[\s(\[]))[-−](\d)', r'минус \1', text)
|
|
837
|
+
|
|
838
|
+
# Quantity multipliers with grammatical agreement (handled here, not in the flat
|
|
839
|
+
# abbreviation list, so the number agrees: 1 млн -> один миллион, 5 млн -> пять миллионов).
|
|
840
|
+
_multipliers = {
|
|
841
|
+
'тыс': (['тысяча', 'тысячи', 'тысяч'], True),
|
|
842
|
+
'млн': (['миллион', 'миллиона', 'миллионов'], False),
|
|
843
|
+
'млрд': (['миллиард', 'миллиарда', 'миллиардов'], False),
|
|
844
|
+
'трлн': (['триллион', 'триллиона', 'триллионов'], False),
|
|
845
|
+
}
|
|
846
|
+
_re_multiplier = re.compile(r'\b(\d+(?:,\d+)?)\s*(тыс|млн|млрд|трлн)\.?(?![а-яё])', re.I)
|
|
847
|
+
|
|
848
|
+
def normalize_multipliers(text):
|
|
849
|
+
def repl(m):
|
|
850
|
+
forms, feminine = _multipliers[m.group(2).lower()]
|
|
851
|
+
if ',' in m.group(1):
|
|
852
|
+
# decimal count takes the genitive singular: 2,7 млрд -> ... миллиарда
|
|
853
|
+
ip, fp = m.group(1).split(',', 1)
|
|
854
|
+
words = _decimal_to_words(ip, fp)
|
|
855
|
+
if words is None:
|
|
856
|
+
return m.group(0)
|
|
857
|
+
return words + ' ' + forms[1]
|
|
858
|
+
n = int(m.group(1))
|
|
859
|
+
words = number_to_words(n).split()
|
|
860
|
+
if feminine:
|
|
861
|
+
_feminine_last(words)
|
|
862
|
+
return ' '.join(words) + ' ' + _plural(n, forms)
|
|
863
|
+
return _re_multiplier.sub(repl, text)
|
|
864
|
+
|
|
865
|
+
def normalize_percent(text):
|
|
866
|
+
"""Read percentages: 50% -> 'пятьдесят процентов', 3,5% -> '... процента'."""
|
|
867
|
+
forms = ('процент', 'процента', 'процентов')
|
|
868
|
+
def repl(m):
|
|
869
|
+
num = m.group(1)
|
|
870
|
+
if ',' in num or '.' in num:
|
|
871
|
+
ip, fp = re.split(r'[.,]', num, 1)
|
|
872
|
+
words = _decimal_to_words(ip, fp)
|
|
873
|
+
return (words + ' процента') if words else m.group(0)
|
|
874
|
+
n = int(num)
|
|
875
|
+
return f"{number_to_words(n)} {_plural(n, forms)}"
|
|
876
|
+
return re.sub(r'(\d+(?:[.,]\d+)?)\s*%', repl, text)
|
|
877
|
+
|
|
878
|
+
# ---- Units of measurement -----------------------------------------------------
|
|
879
|
+
def _load_measurements():
|
|
880
|
+
units = {}
|
|
881
|
+
for line in _read_lines('measurements'):
|
|
882
|
+
parts = line.split('\t')
|
|
883
|
+
if len(parts) == 5:
|
|
884
|
+
ab, one, few, many, gender = parts
|
|
885
|
+
units[ab] = (one, few, many, gender)
|
|
886
|
+
return units
|
|
887
|
+
|
|
888
|
+
_measurements = _load_measurements()
|
|
889
|
+
_UNIT_ALT = '|'.join(re.escape(u) for u in sorted(_measurements, key=len, reverse=True))
|
|
890
|
+
# Case-sensitive, longest unit first, number required before the unit, and no
|
|
891
|
+
# letter immediately after (so "м" does not fire inside "метр", "°" not in "°C").
|
|
892
|
+
_re_measure = re.compile(
|
|
893
|
+
r'(?<![\d.,])(\d+(?:,\d+)?)\s*(' + _UNIT_ALT +
|
|
894
|
+
r')(?![A-Za-zА-Яа-яёЁ])') if _measurements else None
|
|
895
|
+
|
|
896
|
+
def normalize_measurements(text):
|
|
897
|
+
"""Read a number followed by a unit, agreeing in count: 5 кг -> 'пять
|
|
898
|
+
килограммов', 2 кг -> 'два килограмма', 1,5 км -> '... километра'."""
|
|
899
|
+
if not _re_measure:
|
|
900
|
+
return text
|
|
901
|
+
def repl(m):
|
|
902
|
+
one, few, many, gender = _measurements[m.group(2)]
|
|
903
|
+
if ',' in m.group(1):
|
|
904
|
+
# decimal count takes the genitive singular: 1,5 км -> ... километра
|
|
905
|
+
ip, fp = m.group(1).split(',', 1)
|
|
906
|
+
words = _decimal_to_words(ip, fp)
|
|
907
|
+
if words is None:
|
|
908
|
+
return m.group(0)
|
|
909
|
+
return words + ' ' + few
|
|
910
|
+
n = int(m.group(1))
|
|
911
|
+
words = number_to_words(n).split()
|
|
912
|
+
if gender == 'f':
|
|
913
|
+
_feminine_last(words)
|
|
914
|
+
return ' '.join(words) + ' ' + _plural(n, (one, few, many))
|
|
915
|
+
return _re_measure.sub(repl, text)
|
|
916
|
+
|
|
917
|
+
# ---- Cardinal declension (closed-class morphology, table-driven) --------------
|
|
918
|
+
_CASES = ('gen', 'dat', 'instr', 'prep')
|
|
919
|
+
_CASE_FORMS = {
|
|
920
|
+
'ноль': ('нуля', 'нулю', 'нулём', 'нуле'),
|
|
921
|
+
'один': ('одного', 'одному', 'одним', 'одном'),
|
|
922
|
+
'одна': ('одной', 'одной', 'одной', 'одной'),
|
|
923
|
+
'два': ('двух', 'двум', 'двумя', 'двух'),
|
|
924
|
+
'две': ('двух', 'двум', 'двумя', 'двух'),
|
|
925
|
+
'три': ('трёх', 'трём', 'тремя', 'трёх'),
|
|
926
|
+
'четыре': ('четырёх', 'четырём', 'четырьмя', 'четырёх'),
|
|
927
|
+
'восемь': ('восьми', 'восьми', 'восьмью', 'восьми'),
|
|
928
|
+
'сорок': ('сорока',) * 4,
|
|
929
|
+
'девяносто': ('девяноста',) * 4,
|
|
930
|
+
'сто': ('ста',) * 4,
|
|
931
|
+
'пятьдесят': ('пятидесяти', 'пятидесяти', 'пятьюдесятью', 'пятидесяти'),
|
|
932
|
+
'шестьдесят': ('шестидесяти', 'шестидесяти', 'шестьюдесятью', 'шестидесяти'),
|
|
933
|
+
'семьдесят': ('семидесяти', 'семидесяти', 'семьюдесятью', 'семидесяти'),
|
|
934
|
+
'восемьдесят': ('восьмидесяти', 'восьмидесяти', 'восьмьюдесятью', 'восьмидесяти'),
|
|
935
|
+
'двести': ('двухсот', 'двумстам', 'двумястами', 'двухстах'),
|
|
936
|
+
'триста': ('трёхсот', 'трёмстам', 'тремястами', 'трёхстах'),
|
|
937
|
+
'четыреста': ('четырёхсот', 'четырёмстам', 'четырьмястами', 'четырёхстах'),
|
|
938
|
+
'пятьсот': ('пятисот', 'пятистам', 'пятьюстами', 'пятистах'),
|
|
939
|
+
'шестьсот': ('шестисот', 'шестистам', 'шестьюстами', 'шестистах'),
|
|
940
|
+
'семьсот': ('семисот', 'семистам', 'семьюстами', 'семистах'),
|
|
941
|
+
'восемьсот': ('восьмисот', 'восьмистам', 'восьмьюстами', 'восьмистах'),
|
|
942
|
+
'девятьсот': ('девятисот', 'девятистам', 'девятьюстами', 'девятистах'),
|
|
943
|
+
'тысяча': ('тысячи', 'тысяче', 'тысячей', 'тысяче'),
|
|
944
|
+
'тысячи': ('тысяч', 'тысячам', 'тысячами', 'тысячах'),
|
|
945
|
+
'тысяч': ('тысяч', 'тысячам', 'тысячами', 'тысячах'),
|
|
946
|
+
}
|
|
947
|
+
# Soft-sign numerals decline regularly: пять -> пяти / пятью (восемь is above).
|
|
948
|
+
for _w in ('пять', 'шесть', 'семь', 'девять', 'десять', 'одиннадцать', 'двенадцать',
|
|
949
|
+
'тринадцать', 'четырнадцать', 'пятнадцать', 'шестнадцать', 'семнадцать',
|
|
950
|
+
'восемнадцать', 'девятнадцать', 'двадцать', 'тридцать'):
|
|
951
|
+
_CASE_FORMS[_w] = (_w[:-1] + 'и', _w[:-1] + 'и', _w[:-1] + 'ью', _w[:-1] + 'и')
|
|
952
|
+
for _s in ('миллион', 'миллиард', 'триллион', 'квадриллион'):
|
|
953
|
+
_CASE_FORMS[_s] = (_s + 'а', _s + 'у', _s + 'ом', _s + 'е')
|
|
954
|
+
_CASE_FORMS[_s + 'а'] = _CASE_FORMS[_s + 'ов'] = (_s + 'ов', _s + 'ам', _s + 'ами', _s + 'ах')
|
|
955
|
+
|
|
956
|
+
def number_to_words_case(n, case):
|
|
957
|
+
"""Cardinal in an oblique case: 500/'gen' -> 'пятисот'."""
|
|
958
|
+
idx = _CASES.index(case)
|
|
959
|
+
return ' '.join(_CASE_FORMS.get(w, (w,) * 4)[idx] for w in number_to_words(n).split())
|
|
960
|
+
|
|
961
|
+
# ---- Context-governed case (preposition / oblique noun ending) ----------------
|
|
962
|
+
_prep_case = {
|
|
963
|
+
'около': 'gen', 'более': 'gen', 'менее': 'gen', 'свыше': 'gen', 'от': 'gen',
|
|
964
|
+
'до': 'gen', 'из': 'gen', 'без': 'gen', 'после': 'gen',
|
|
965
|
+
'к': 'dat', 'о': 'prep', 'об': 'prep',
|
|
966
|
+
}
|
|
967
|
+
# Longest alternative first; the trailing guard refuses decimals, times, ranges
|
|
968
|
+
# and percentages, which keep their own rules.
|
|
969
|
+
_re_prep_num = re.compile(
|
|
970
|
+
r'(?<![А-Яа-яёЁ-])(около|после|более|менее|свыше|без|от|до|из|об|к|о)'
|
|
971
|
+
r'\s+(\d+)(?:\s*(' + _UNIT_ALT + r'))?(?![\d.,:%–—-])(?![A-Za-zА-Яа-яёЁ])', re.I)
|
|
972
|
+
# "с 500 рублями": instrumental signalled by the noun ending.
|
|
973
|
+
_re_instr_num = re.compile(r'(?<![А-Яа-яёЁ])([Сс]о?)\s+(\d+)\s+([а-яё]{3,}(?:ами|ями))\b')
|
|
974
|
+
# A number directly before an obliquely-inflected noun agrees with it.
|
|
975
|
+
_re_num_oblique_noun = re.compile(r'\b(\d+)\s+([а-яё]{3,}(?:ами|ями|ах|ях))\b')
|
|
976
|
+
_oblique_suffix_case = {'ами': 'instr', 'ями': 'instr', 'ах': 'prep', 'ях': 'prep'}
|
|
977
|
+
|
|
978
|
+
def normalize_case_context(text):
|
|
979
|
+
"""Inflect a number to the case its context dictates: 'около 500 км' ->
|
|
980
|
+
'около пятисот километров', 'к 5' -> 'к пяти', 'с 500 рублями' -> 'с пятьюстами рублями'."""
|
|
981
|
+
def prep(m):
|
|
982
|
+
case = _prep_case[m.group(1).lower()]
|
|
983
|
+
out = m.group(1) + ' ' + number_to_words_case(int(m.group(2)), case)
|
|
984
|
+
if m.group(3):
|
|
985
|
+
if case != 'gen':
|
|
986
|
+
return m.group(0) # unit in dat/prep needs noun declension; leave it
|
|
987
|
+
out += ' ' + _measurements[m.group(3)][2] # genitive plural == many form
|
|
988
|
+
return out
|
|
989
|
+
|
|
990
|
+
def instr(m):
|
|
991
|
+
return f"{m.group(1)} {number_to_words_case(int(m.group(2)), 'instr')} {m.group(3)}"
|
|
992
|
+
|
|
993
|
+
def oblique(m):
|
|
994
|
+
suffix = next(s for s in ('ами', 'ями', 'ах', 'ях') if m.group(2).endswith(s))
|
|
995
|
+
return f"{number_to_words_case(int(m.group(1)), _oblique_suffix_case[suffix])} {m.group(2)}"
|
|
996
|
+
|
|
997
|
+
text = _re_instr_num.sub(instr, text)
|
|
998
|
+
text = _re_prep_num.sub(prep, text)
|
|
999
|
+
return _re_num_oblique_noun.sub(oblique, text)
|
|
1000
|
+
|
|
1001
|
+
# ---- Bare-number ordinals from a trigger noun ----------------------------------
|
|
1002
|
+
# A singular trigger noun right after a number signals an ordinal reading
|
|
1003
|
+
# ("2 место" is второе место; the cardinal would demand "места"/"мест").
|
|
1004
|
+
_ordinal_trigger = {
|
|
1005
|
+
'место': 'nom_n', 'этаж': 'nom_m', 'класс': 'nom_m', 'век': 'nom_m',
|
|
1006
|
+
'том': 'nom_m', 'курс': 'nom_m', 'раунд': 'nom_m', 'сезон': 'nom_m',
|
|
1007
|
+
'этап': 'nom_m', 'тур': 'nom_m', 'разряд': 'nom_m', 'подъезд': 'nom_m',
|
|
1008
|
+
}
|
|
1009
|
+
_re_ordinal_trigger = re.compile(
|
|
1010
|
+
r'\b(\d{1,4})\s+(' + '|'.join(_ordinal_trigger) + r')\b(?![а-яё])')
|
|
1011
|
+
|
|
1012
|
+
def normalize_ordinal_triggers(text):
|
|
1013
|
+
def repl(m):
|
|
1014
|
+
return f"{number_to_ordinal_words(int(m.group(1)), _ordinal_trigger[m.group(2)])} {m.group(2)}"
|
|
1015
|
+
return _re_ordinal_trigger.sub(repl, text)
|
|
1016
|
+
|
|
1017
|
+
# ---- Compound number adjectives: 5-летний -> пятилетний ------------------------
|
|
1018
|
+
# The numeral joins the adjective as a genitive prefix (sole exceptions below).
|
|
1019
|
+
_compound_prefix_override = {'один': 'одно', 'одна': 'одно', 'девяносто': 'девяносто',
|
|
1020
|
+
'сто': 'сто', 'тысяча': 'тысяче'}
|
|
1021
|
+
_re_compound = re.compile(
|
|
1022
|
+
r'\b(\d+)-([а-яё]{2,}(?:ий|ый|ой|ая|яя|ое|ее|ые|ие|ого|его|ому|ему|ым|им|ом|ем|ую|юю|ых|их|ыми|ими))\b')
|
|
1023
|
+
|
|
1024
|
+
def normalize_compounds(text):
|
|
1025
|
+
def repl(m):
|
|
1026
|
+
words = number_to_words(int(m.group(1))).split()
|
|
1027
|
+
prefix = ''.join(_compound_prefix_override.get(w, _CASE_FORMS.get(w, (w,))[0])
|
|
1028
|
+
for w in words)
|
|
1029
|
+
return prefix + m.group(2)
|
|
1030
|
+
return _re_compound.sub(repl, text)
|
|
1031
|
+
|
|
1032
|
+
# ---- Numeric ranges ------------------------------------------------------------
|
|
1033
|
+
_re_year_range = re.compile(r'\b(\d{3,4})\s*[-–—]\s*(\d{3,4})\s*(?:гг\.?|годы)(?![а-яё])')
|
|
1034
|
+
_re_century_range = re.compile(r'\b([MDCLXVI]{1,6})\s*[-–—]\s*([MDCLXVI]{1,6})\s*вв\.?(?![а-яё])')
|
|
1035
|
+
_re_num_range = re.compile(r'(?<=\d)\s*[–—]\s*(?=\d)') # en/em dash only: hyphen is phones/ISBN
|
|
1036
|
+
|
|
1037
|
+
def normalize_ranges(text):
|
|
1038
|
+
"""Read ranges: '1941—1945 гг.' -> '... первый ... пятый годы', '5–10' -> '5 10'."""
|
|
1039
|
+
def years(m):
|
|
1040
|
+
return (f"{number_to_ordinal_words(int(m.group(1)), 'nom_m')} "
|
|
1041
|
+
f"{number_to_ordinal_words(int(m.group(2)), 'nom_m')} годы")
|
|
1042
|
+
|
|
1043
|
+
def centuries(m):
|
|
1044
|
+
if not (_re_roman_valid.match(m.group(1)) and _re_roman_valid.match(m.group(2))):
|
|
1045
|
+
return m.group(0)
|
|
1046
|
+
return (f"{number_to_ordinal_words(_roman_to_int(m.group(1)), 'nom_m')} "
|
|
1047
|
+
f"{number_to_ordinal_words(_roman_to_int(m.group(2)), 'nom_m')} века")
|
|
1048
|
+
|
|
1049
|
+
text = _re_year_range.sub(years, text)
|
|
1050
|
+
text = _re_century_range.sub(centuries, text)
|
|
1051
|
+
return _re_num_range.sub(' ', text)
|
|
1052
|
+
|
|
1053
|
+
# ---- Scores (after clock times have been consumed): 3:1 -> 'три один' -----------
|
|
1054
|
+
_re_score = re.compile(r'\b(\d{1,2}):(\d{1,2})\b')
|
|
1055
|
+
|
|
1056
|
+
def normalize_scores(text):
|
|
1057
|
+
return _re_score.sub(
|
|
1058
|
+
lambda m: f"{number_to_words(int(m.group(1)))} {number_to_words(int(m.group(2)))}", text)
|
|
1059
|
+
|
|
1060
|
+
# ---- Arithmetic: '+' between numbers (=, ×, ÷ live in the symbol map) ----------
|
|
1061
|
+
def normalize_math(text):
|
|
1062
|
+
return re.sub(r'(?<=\d)\s*\+\s*(?=\d)', ' плюс ', text)
|
|
1063
|
+
|
|
1064
|
+
# ---- Structural references before a number: ст. 158 -> статья 158 ---------------
|
|
1065
|
+
_section_abbr = {'ст': 'статья', 'пп': 'подпункт', 'п': 'пункт',
|
|
1066
|
+
'рис': 'рисунок', 'табл': 'таблица', 'гл': 'глава'}
|
|
1067
|
+
_re_section = re.compile(r'(?<![А-Яа-яёЁ])(ст|пп|табл|рис|гл|п)\.\s*(?=\d)', re.I)
|
|
1068
|
+
|
|
1069
|
+
def normalize_sections(text):
|
|
1070
|
+
return _re_section.sub(lambda m: _section_abbr[m.group(1).lower()] + ' ', text)
|
|
1071
|
+
|
|
1072
|
+
# ---- Versions / IP addresses: dot-separated numbers read with 'точка' ----------
|
|
1073
|
+
_re_ip = re.compile(r'\b\d+(?:\.\d+){2,}\b')
|
|
1074
|
+
# A single dot reads as a version only after a Latin product name (Python 3.11);
|
|
1075
|
+
# bare N.N stays untouched (could be an English-style decimal).
|
|
1076
|
+
_re_version = re.compile(r'\b([A-Za-z][\w-]*\s+)(\d+(?:\.\d+)+)\b')
|
|
1077
|
+
|
|
1078
|
+
def _read_dotted(num):
|
|
1079
|
+
digit_words = ['ноль', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять']
|
|
1080
|
+
def part(p):
|
|
1081
|
+
if len(p) > 1 and p[0] == '0':
|
|
1082
|
+
return ' '.join(digit_words[int(d)] for d in p)
|
|
1083
|
+
return number_to_words(int(p))
|
|
1084
|
+
return ' точка '.join(part(p) for p in num.split('.'))
|
|
1085
|
+
|
|
1086
|
+
def normalize_versions(text):
|
|
1087
|
+
text = _re_version.sub(lambda m: m.group(1) + _read_dotted(m.group(2)), text)
|
|
1088
|
+
return _re_ip.sub(lambda m: _read_dotted(m.group(0)), text)
|
|
1089
|
+
|
|
1090
|
+
# ---- English words and Latin acronyms ------------------------------------------
|
|
1091
|
+
def _load_english():
|
|
1092
|
+
mapping = {}
|
|
1093
|
+
for line in _read_lines('english'):
|
|
1094
|
+
if '\t' in line:
|
|
1095
|
+
key, value = line.split('\t', 1)
|
|
1096
|
+
mapping[key.strip().lower()] = value.strip()
|
|
1097
|
+
return mapping
|
|
1098
|
+
|
|
1099
|
+
_english_words = _load_english()
|
|
1100
|
+
_re_latin_word = re.compile(r"\b[A-Za-z][A-Za-z'’-]*\b")
|
|
1101
|
+
|
|
1102
|
+
# English letter names for spelled-out Latin acronyms (GPS -> джи пи эс).
|
|
1103
|
+
_latin_letter_names = {
|
|
1104
|
+
'a': 'эй', 'b': 'би', 'c': 'си', 'd': 'ди', 'e': 'и', 'f': 'эф', 'g': 'джи',
|
|
1105
|
+
'h': 'эйч', 'i': 'ай', 'j': 'джей', 'k': 'кей', 'l': 'эл', 'm': 'эм', 'n': 'эн',
|
|
1106
|
+
'o': 'оу', 'p': 'пи', 'q': 'кью', 'r': 'ар', 's': 'эс', 't': 'ти', 'u': 'ю',
|
|
1107
|
+
'v': 'ви', 'w': 'дабл ю', 'x': 'экс', 'y': 'уай', 'z': 'зед',
|
|
1108
|
+
}
|
|
1109
|
+
_re_latin_acronym = re.compile(r'\b[A-Z]{2,6}\b')
|
|
1110
|
+
|
|
1111
|
+
def normalize_english(text):
|
|
1112
|
+
"""Dictionary words get their conventional rendering (Google -> гугл); all-caps
|
|
1113
|
+
acronyms that are not pronounceable as a word (a vowel-less stretch of two or
|
|
1114
|
+
more consonants) are spelled with English letter names (GPS -> джи пи эс).
|
|
1115
|
+
Everything else falls through to transliteration."""
|
|
1116
|
+
def word(m):
|
|
1117
|
+
return _english_words.get(m.group(0).lower(), m.group(0))
|
|
1118
|
+
|
|
1119
|
+
def acronym(m):
|
|
1120
|
+
low = m.group(0).lower()
|
|
1121
|
+
if low in _english_words:
|
|
1122
|
+
return m.group(0) # dictionary handled / will handle it
|
|
1123
|
+
if not set(low) & set('aeiou') or re.search(r'[^aeiou]{2}', low):
|
|
1124
|
+
return ' '.join(_latin_letter_names[c] for c in low)
|
|
1125
|
+
return m.group(0)
|
|
1126
|
+
|
|
1127
|
+
text = _re_latin_word.sub(word, text)
|
|
1128
|
+
return _re_latin_acronym.sub(acronym, text)
|
|
1129
|
+
|
|
1130
|
+
# ---- ё restoration (unambiguous е-spellings only) -------------------------------
|
|
1131
|
+
def _load_yo():
|
|
1132
|
+
mapping = {}
|
|
1133
|
+
for line in _read_lines('yo'):
|
|
1134
|
+
if '\t' in line:
|
|
1135
|
+
key, value = line.split('\t', 1)
|
|
1136
|
+
mapping[key.strip().lower()] = value.strip()
|
|
1137
|
+
return mapping
|
|
1138
|
+
|
|
1139
|
+
_yo_map = _load_yo()
|
|
1140
|
+
_re_yo = re.compile(r'\b(' + '|'.join(sorted(_yo_map, key=len, reverse=True)) + r')\b',
|
|
1141
|
+
re.I) if _yo_map else None
|
|
1142
|
+
|
|
1143
|
+
def restore_yo(text):
|
|
1144
|
+
"""Restore ё in words where the е-spelling is unambiguous: еще -> ещё."""
|
|
1145
|
+
if not _re_yo:
|
|
1146
|
+
return text
|
|
1147
|
+
def repl(m):
|
|
1148
|
+
rep = _yo_map.get(m.group(0).lower())
|
|
1149
|
+
if rep is None:
|
|
1150
|
+
return m.group(0)
|
|
1151
|
+
if m.group(0)[0].isupper():
|
|
1152
|
+
rep = rep[0].upper() + rep[1:]
|
|
1153
|
+
return rep
|
|
1154
|
+
return _re_yo.sub(repl, text)
|
|
1155
|
+
|
|
1156
|
+
def normalize_russian(text):
|
|
1157
|
+
text = normalize_typography(text)
|
|
1158
|
+
text = normalize_web(text)
|
|
1159
|
+
text = normalize_abbreviations(text)
|
|
1160
|
+
text = normalize_sections(text)
|
|
1161
|
+
text = normalize_number_groups(text)
|
|
1162
|
+
text = normalize_ranges(text)
|
|
1163
|
+
text = normalize_dates(text)
|
|
1164
|
+
text = normalize_case_context(text)
|
|
1165
|
+
text = normalize_ordinal_triggers(text)
|
|
1166
|
+
text = normalize_compounds(text)
|
|
1167
|
+
text = normalize_ordinals(text)
|
|
1168
|
+
text = normalize_time(text)
|
|
1169
|
+
text = normalize_scores(text) # leftover N:M after clock times
|
|
1170
|
+
text = normalize_fractions(text)
|
|
1171
|
+
text = normalize_percent(text)
|
|
1172
|
+
text = normalize_multipliers(text)
|
|
1173
|
+
text = normalize_measurements(text) # before acronym speller (ГБ/МБ are units, not letters)
|
|
1174
|
+
text = expand_abbreviations(text)
|
|
1175
|
+
text = normalize_symbols(text)
|
|
1176
|
+
text = normalize_math(text)
|
|
1177
|
+
text = normalize_decimals(text)
|
|
1178
|
+
text = currency_normalization(text)
|
|
1179
|
+
text = normalize_text_with_phone_numbers(text)
|
|
1180
|
+
text = normalize_versions(text) # after dates and currency claim their dots
|
|
1181
|
+
text = normalize_negatives(text)
|
|
1182
|
+
text = normalize_text_with_numbers(text)
|
|
1183
|
+
text = normalize_english(text)
|
|
1184
|
+
text = cyrrilize(text)
|
|
1185
|
+
text = restore_yo(text)
|
|
1186
|
+
text = re.sub(r' {2,}', ' ', text).strip()
|
|
1187
|
+
# ё is kept intentionally (it carries pronunciation for TTS); the reference
|
|
1188
|
+
# data drops it, so evaluation should compare ё/е-insensitively.
|
|
1189
|
+
return text
|
|
1190
|
+
|
|
1191
|
+
def _cli():
|
|
1192
|
+
"""Read text from stdin, write normalized text to stdout."""
|
|
1193
|
+
import sys
|
|
1194
|
+
data = sys.stdin.read()
|
|
1195
|
+
sys.stdout.write(normalize_russian(data) + ('\n' if data.endswith('\n') else ''))
|
|
1196
|
+
|
|
1197
|
+
if __name__ == '__main__':
|
|
1198
|
+
_cli()
|