@ozgurguler/trnorm 0.0.0-stage → 0.1.1
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.
- package/LICENSE +202 -0
- package/README.md +77 -3
- package/index.d.ts +30 -0
- package/package.json +50 -6
- package/src/case_tables.js +89 -0
- package/src/casing.js +34 -0
- package/src/index.js +19 -0
- package/src/itn.js +255 -0
- package/src/lexicon.js +489 -0
- package/src/normalizer.js +1513 -0
- package/src/numbers.js +325 -0
- package/src/py.js +438 -0
- package/src/unassigned.js +100 -0
package/src/numbers.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// trnorm/numbers.py in JavaScript: Turkish number reading and suffix (vowel/consonant) harmony.
|
|
2
|
+
//
|
|
3
|
+
// number_to_words(1923) -> "bin dokuz yüz yirmi üç"
|
|
4
|
+
// ordinal_to_words(4) -> "dördüncü"
|
|
5
|
+
// attach_suffix("dört", "e") -> "dörde"
|
|
6
|
+
// attach_suffix("on beş", "de") -> "on beşte" (re-agrees d/t)
|
|
7
|
+
// attach_suffix("altı", "a") -> "altıya" (inserts buffer y)
|
|
8
|
+
// harmonize_clitic("lira", "mi") -> "mı" (separate question particle / dA)
|
|
9
|
+
//
|
|
10
|
+
// Numbers are BigInts (Python ints are unbounded); number_to_words and ordinal_to_words also take a JS number (see
|
|
11
|
+
// _py_number for which Python value it stands for).
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
digit_value, first_cp, int_str, isdecimal, last_cp, lower, lstrip, plen, py_int, split, type_error,
|
|
15
|
+
} from './py.js';
|
|
16
|
+
|
|
17
|
+
export const ONES = ['sıfır', 'bir', 'iki', 'üç', 'dört', 'beş', 'altı', 'yedi', 'sekiz', 'dokuz'];
|
|
18
|
+
export const TENS = ['', 'on', 'yirmi', 'otuz', 'kırk', 'elli', 'altmış', 'yetmiş', 'seksen', 'doksan'];
|
|
19
|
+
export const SCALES = [
|
|
20
|
+
[10n ** 18n, 'kentilyon'],
|
|
21
|
+
[10n ** 15n, 'katrilyon'],
|
|
22
|
+
[10n ** 12n, 'trilyon'],
|
|
23
|
+
[10n ** 9n, 'milyar'],
|
|
24
|
+
[10n ** 6n, 'milyon'],
|
|
25
|
+
[10n ** 3n, 'bin'],
|
|
26
|
+
];
|
|
27
|
+
export const MAX_NUMBER = 10n ** 21n - 1n;
|
|
28
|
+
|
|
29
|
+
export const VOWELS = new Set('aeıioöuüâîû');
|
|
30
|
+
export const BACK_VOWELS = new Set('aıouâû');
|
|
31
|
+
export const ROUNDED_VOWELS = new Set('oöuüû');
|
|
32
|
+
export const VOICELESS = new Set('fstkçşhp');
|
|
33
|
+
|
|
34
|
+
// Loanword stems whose suffixes take front harmony despite a back last vowel.
|
|
35
|
+
const _FRONT_EXCEPTIONS = new Set(['saat', 'kontrol', 'gol', 'rol', 'alkol', 'petrol', 'protokol', 'hal', 'kalp']);
|
|
36
|
+
|
|
37
|
+
// n: a number below 1000
|
|
38
|
+
function _below_thousand(n) {
|
|
39
|
+
const words = [];
|
|
40
|
+
const hundreds = Math.floor(n / 100);
|
|
41
|
+
const rest = n % 100;
|
|
42
|
+
if (hundreds) {
|
|
43
|
+
if (hundreds > 1) words.push(ONES[hundreds]);
|
|
44
|
+
words.push('yüz');
|
|
45
|
+
}
|
|
46
|
+
const tens = Math.floor(rest / 10);
|
|
47
|
+
const ones = rest % 10;
|
|
48
|
+
if (tens) words.push(TENS[tens]);
|
|
49
|
+
if (ones) words.push(ONES[ones]);
|
|
50
|
+
return words;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The Python value an argument of number_to_words stands for: a BigInt or a safe integer is an int, true and false
|
|
54
|
+
// are 1 and 0 (a bool is an int), any other number a float (2 ** 53 + 1 is already 9007199254740992 in JS, 1e21 a
|
|
55
|
+
// float in both). Anything else raises the TypeError Python's n < 0 raises.
|
|
56
|
+
function _py_number(n) {
|
|
57
|
+
if (typeof n === 'bigint') return n;
|
|
58
|
+
if (typeof n === 'number') return Number.isSafeInteger(n) ? BigInt(n) : n;
|
|
59
|
+
if (typeof n === 'boolean') return n ? 1n : 0n;
|
|
60
|
+
const type = typeof n === 'string' ? 'str' : n == null ? 'NoneType' : Array.isArray(n) ? 'list' : typeof n;
|
|
61
|
+
throw type_error(`'<' not supported between instances of '${type}' and 'int'`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// divmod(x, y) of floats, x >= 0 and y > 0, as CPython computes it (_float_div_mod)
|
|
65
|
+
function _float_divmod(x, y) {
|
|
66
|
+
const mod = x % y; // fmod
|
|
67
|
+
const div = (x - mod) / y;
|
|
68
|
+
let floor = 0;
|
|
69
|
+
if (div) {
|
|
70
|
+
floor = Math.floor(div);
|
|
71
|
+
if (div - floor > 0.5) floor += 1;
|
|
72
|
+
}
|
|
73
|
+
return [floor, mod];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const _float_index = () => type_error('list indices must be integers or slices, not float');
|
|
77
|
+
|
|
78
|
+
function _below_thousand_float(n) {
|
|
79
|
+
const [hundreds, rest] = _float_divmod(n, 100);
|
|
80
|
+
if (hundreds > 1) throw _float_index(); // ONES[hundreds]
|
|
81
|
+
const [tens, ones] = _float_divmod(rest, 10);
|
|
82
|
+
if (tens || ones) throw _float_index(); // TENS[tens], ONES[ones]
|
|
83
|
+
return hundreds ? ['yüz'] : [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// number_to_words of a Python float: the same steps on floats, so a float reads only where no list is indexed with
|
|
87
|
+
// one (1e20 -> "yüz kentilyon"), and from 1e21 on (MAX_NUMBER) as the digits of its repr ("1e+21" -> "bir iki bir")
|
|
88
|
+
function _float_words(x) {
|
|
89
|
+
if (Number.isNaN(x)) throw _float_index(); // every comparison fails, divmod gives nan, TENS[nan]
|
|
90
|
+
if (x < 0) return `eksi ${_float_words(-x)}`;
|
|
91
|
+
if (x === 0) return 'sıfır';
|
|
92
|
+
if (x >= 1e21) return digits_to_words(String(x)); // repr(): the same digits ("1.2345e+22"; inf has none)
|
|
93
|
+
const words = [];
|
|
94
|
+
let n = x;
|
|
95
|
+
for (const [value, name] of SCALES) {
|
|
96
|
+
let count;
|
|
97
|
+
[count, n] = _float_divmod(n, Number(value));
|
|
98
|
+
if (count) {
|
|
99
|
+
if (!(value === 1000n && count === 1)) words.push(..._below_thousand_float(count));
|
|
100
|
+
words.push(name);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (n) words.push(..._below_thousand_float(n));
|
|
104
|
+
return words.join(' ');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Cardinal reading of a non-negative (or negative) integer.
|
|
108
|
+
export function number_to_words(n) {
|
|
109
|
+
n = _py_number(n);
|
|
110
|
+
if (typeof n === 'number') return _float_words(n);
|
|
111
|
+
if (n < 0n) return `eksi ${number_to_words(-n)}`;
|
|
112
|
+
if (n === 0n) return 'sıfır';
|
|
113
|
+
if (n > MAX_NUMBER) return digits_to_words(int_str(n));
|
|
114
|
+
const words = [];
|
|
115
|
+
for (const [value, name] of SCALES) {
|
|
116
|
+
const count = n / value;
|
|
117
|
+
n %= value;
|
|
118
|
+
if (count) {
|
|
119
|
+
if (!(value === 1000n && count === 1n)) words.push(..._below_thousand(Number(count))); // "bin", never "bir bin"
|
|
120
|
+
words.push(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (n) words.push(..._below_thousand(Number(n)));
|
|
124
|
+
return words.join(' ');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Read a digit string one digit at a time: "054" -> "sıfır beş dört".
|
|
128
|
+
export function digits_to_words(digits) {
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const d of digits) if (isdecimal(d)) out.push(ONES[digit_value(d)]);
|
|
131
|
+
return out.join(' ');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _last_vowel(word) {
|
|
135
|
+
const chars = [...word];
|
|
136
|
+
for (let i = chars.length - 1; i >= 0; i--) if (VOWELS.has(chars[i])) return chars[i];
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// [is_back, is_rounded] for the stem's last vowel.
|
|
141
|
+
function _harmony_state(stem) {
|
|
142
|
+
const words = split(stem);
|
|
143
|
+
const last_word = words.length ? words[words.length - 1] : stem;
|
|
144
|
+
if (_FRONT_EXCEPTIONS.has(last_word) || last_word.endsWith('saat')) { // also "kilovatsaat" (kWh)
|
|
145
|
+
const v = _last_vowel(last_word) || 'e';
|
|
146
|
+
return [false, ROUNDED_VOWELS.has(v)];
|
|
147
|
+
}
|
|
148
|
+
const v = _last_vowel(stem);
|
|
149
|
+
if (v === null) return [false, false]; // e.g. bare consonant: default to front unrounded
|
|
150
|
+
return [BACK_VOWELS.has(v), ROUNDED_VOWELS.has(v)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _vowel4(back, rounded) {
|
|
154
|
+
if (back) return rounded ? 'u' : 'ı';
|
|
155
|
+
return rounded ? 'ü' : 'i';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const _vowel2 = (back) => (back ? 'a' : 'e');
|
|
159
|
+
|
|
160
|
+
// Re-agree a suffix with the *spoken* stem.
|
|
161
|
+
//
|
|
162
|
+
// Writers harmonize suffixes with the reading they have in mind ("5'te", "15.00'te"), but often get it wrong
|
|
163
|
+
// ("15.00'de", "SGK'ye"). We rebuild vowels and the d/t, c/ç alternations from the stem. Invariant morphemes (-ki
|
|
164
|
+
// after a locative, -yor, -ken) are kept as written.
|
|
165
|
+
export function harmonize_suffix(stem, suffix) {
|
|
166
|
+
if (!suffix) return suffix;
|
|
167
|
+
let s = [...lower(suffix)]; // code points: s[i] is Python's s[i]
|
|
168
|
+
const stem_l = lower(stem);
|
|
169
|
+
const stem_last = last_cp(stem_l);
|
|
170
|
+
const stem_ends_vowel = VOWELS.has(stem_last);
|
|
171
|
+
|
|
172
|
+
// Buffer consonants: drop after consonant-final stems, add after vowel-final.
|
|
173
|
+
if (!stem_ends_vowel && s.length >= 2 && 'ysnm'.includes(s[0])) {
|
|
174
|
+
if (s[0] === 'y' && VOWELS.has(s[1])) { // "5'ye" -> "beşe", "5'yle" -> "beşle"
|
|
175
|
+
s = s.slice(1);
|
|
176
|
+
} else if (s[0] === 's' && 'ıiuü'.includes(s[1])) { // possessive: "5'si" -> "beşi" (not conditional -sA)
|
|
177
|
+
s = s.slice(1);
|
|
178
|
+
} else if (s[0] === 'n' && 'ıiuü'.includes(s[1]) && s[2] === 'n') { // genitive: "5'nin" -> "beşin"
|
|
179
|
+
s = s.slice(1);
|
|
180
|
+
} else if (
|
|
181
|
+
('mn'.includes(s[0]) && !VOWELS.has(s[1]) && s[0] + s[1] !== 'nc') || /^[mn][ıiuü]z/u.test(s.join(''))
|
|
182
|
+
) {
|
|
183
|
+
// possessive -(I)m, -(I)mIz, -(I)nIz after "kuruş": "TL'mda", "TL'nız" -> "kuruşumda", "kuruşunuz"
|
|
184
|
+
s = ['ı', ...s];
|
|
185
|
+
}
|
|
186
|
+
} else if (!stem_ends_vowel && s.length === 1 && (s[0] === 'm' || s[0] === 'n')) {
|
|
187
|
+
s = ['ı', ...s]; // possessive -m/-n: "25,50 TL'm" -> "... kuruşum"
|
|
188
|
+
} else if (stem_ends_vowel && VOWELS.has(s[0])) {
|
|
189
|
+
// genitive -(n)In takes n ("6'ın" -> "altının"), everything else takes y
|
|
190
|
+
if ('ıiuü'.includes(s[0]) && s[1] === 'n' && !VOWELS.has(s[2] ?? '')) s = ['n', ...s];
|
|
191
|
+
else s = ['y', ...s];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -ki after a locative never harmonizes and sets the harmony of what follows:
|
|
195
|
+
// "2023'teki", "1990'lardakiler", "2026'dakidir" (written -kı is re-spelled -ki)
|
|
196
|
+
const fixed_i = new Set();
|
|
197
|
+
for (let i = 2; i < s.length - 1; i++) {
|
|
198
|
+
if (s[i] === 'k' && 'ıi'.includes(s[i + 1]) && 'dt'.includes(s[i - 2]) && 'ae'.includes(s[i - 1])) {
|
|
199
|
+
fixed_i.add(i + 1);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let [back, rounded] = _harmony_state(stem_l);
|
|
204
|
+
let prev = stem_last;
|
|
205
|
+
const out = [];
|
|
206
|
+
s.forEach((c, i) => {
|
|
207
|
+
let ch = c;
|
|
208
|
+
if (fixed_i.has(i)) {
|
|
209
|
+
ch = 'i';
|
|
210
|
+
back = false;
|
|
211
|
+
rounded = false;
|
|
212
|
+
} else if ('aeâ'.includes(ch)) {
|
|
213
|
+
ch = _vowel2(back);
|
|
214
|
+
back = BACK_VOWELS.has(ch);
|
|
215
|
+
rounded = false;
|
|
216
|
+
} else if ('ıiuü'.includes(ch)) {
|
|
217
|
+
ch = _vowel4(back, rounded);
|
|
218
|
+
rounded = ROUNDED_VOWELS.has(ch);
|
|
219
|
+
} else if ('oö'.includes(ch)) {
|
|
220
|
+
back = ch === 'o';
|
|
221
|
+
rounded = true;
|
|
222
|
+
} else if ('dt'.includes(ch) && i === 0) {
|
|
223
|
+
ch = VOICELESS.has(prev) ? 't' : 'd';
|
|
224
|
+
} else if ('cç'.includes(ch) && i === 0) {
|
|
225
|
+
ch = VOICELESS.has(prev) ? 'ç' : 'c';
|
|
226
|
+
}
|
|
227
|
+
out.push(ch);
|
|
228
|
+
prev = ch;
|
|
229
|
+
});
|
|
230
|
+
return out.join('');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Re-agree a separately written clitic (question mI and its inflections, dA) with the spoken stem.
|
|
234
|
+
//
|
|
235
|
+
// Only vowels change; consonants and buffer y are kept: ("lira", "mi") -> "mı", ("altı", "de") -> "da",
|
|
236
|
+
// ("dokuz", "misiniz") -> "musunuz". Both args lowercase.
|
|
237
|
+
export function harmonize_clitic(stem, clitic) {
|
|
238
|
+
let [back, rounded] = _harmony_state(stem);
|
|
239
|
+
const out = [];
|
|
240
|
+
for (let ch of clitic) {
|
|
241
|
+
if ('ae'.includes(ch)) {
|
|
242
|
+
ch = _vowel2(back);
|
|
243
|
+
back = BACK_VOWELS.has(ch);
|
|
244
|
+
rounded = false;
|
|
245
|
+
} else if ('ıiuü'.includes(ch)) {
|
|
246
|
+
ch = _vowel4(back, rounded);
|
|
247
|
+
rounded = ROUNDED_VOWELS.has(ch);
|
|
248
|
+
}
|
|
249
|
+
out.push(ch);
|
|
250
|
+
}
|
|
251
|
+
return out.join('');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Attach a (re-harmonized) suffix to a spoken stem, handling dört->dörd.
|
|
255
|
+
export function attach_suffix(stem, suffix) {
|
|
256
|
+
if (!suffix) return stem;
|
|
257
|
+
const suf = harmonize_suffix(stem, suffix);
|
|
258
|
+
if (suf && VOWELS.has(first_cp(suf)) && stem.endsWith('dört')) stem = `${stem.slice(0, -4)}dörd`;
|
|
259
|
+
return stem + suf;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Ordinal reading: 1 -> birinci, 4 -> dördüncü, 20 -> yirminci.
|
|
263
|
+
export function ordinal_to_words(n) {
|
|
264
|
+
const cardinal = number_to_words(n);
|
|
265
|
+
let stem = cardinal;
|
|
266
|
+
const [back, rounded] = _harmony_state(stem);
|
|
267
|
+
const v = _vowel4(back, rounded);
|
|
268
|
+
// stem[-1]: the float inf reads as '' (its repr has no digits), and Python's index fails there
|
|
269
|
+
if (!stem) throw Object.assign(new RangeError('string index out of range'), { name: 'IndexError', python: true });
|
|
270
|
+
const suf = VOWELS.has(last_cp(stem)) ? `nc${v}` : `${v}nc${v}`;
|
|
271
|
+
if (VOWELS.has(suf[0]) && stem.endsWith('dört')) stem = `${stem.slice(0, -4)}dörd`;
|
|
272
|
+
return stem + suf;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Read the part after the decimal comma: "5" -> beş, "05" -> sıfır beş, "25" -> yirmi beş.
|
|
276
|
+
export function read_decimal_fraction(digits) {
|
|
277
|
+
if (!digits) return '';
|
|
278
|
+
if (plen(digits) > 3) return digits_to_words(digits);
|
|
279
|
+
const rest = lstrip(digits, '0');
|
|
280
|
+
const parts = Array(digits.length - rest.length).fill('sıfır'); // "0" is one UTF-16 unit
|
|
281
|
+
if (rest) parts.push(number_to_words(py_int(rest)));
|
|
282
|
+
return parts.join(' ');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Read a digit group keeping leading zeros: "05" -> "sıfır beş", "693" -> "altı yüz doksan üç".
|
|
286
|
+
export function read_digit_group(group) {
|
|
287
|
+
const rest = lstrip(group, '0');
|
|
288
|
+
const parts = Array(group.length - rest.length).fill('sıfır');
|
|
289
|
+
if (rest) parts.push(number_to_words(py_int(rest)));
|
|
290
|
+
return parts.join(' ');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const _GROUP_PATTERNS = new Map([
|
|
294
|
+
[5, [3, 2]],
|
|
295
|
+
[6, [3, 3]],
|
|
296
|
+
[7, [3, 2, 2]],
|
|
297
|
+
[8, [3, 3, 2]],
|
|
298
|
+
[9, [3, 3, 3]],
|
|
299
|
+
[10, [3, 3, 2, 2]],
|
|
300
|
+
[11, [3, 3, 3, 2]],
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
// Read an identifier-like digit string in speakable groups (3-3-2-2 style).
|
|
304
|
+
export function read_long_digits(digits) {
|
|
305
|
+
const chars = [...digits];
|
|
306
|
+
const n = chars.length;
|
|
307
|
+
if (n <= 4) return read_digit_group(digits);
|
|
308
|
+
let sizes = _GROUP_PATTERNS.get(n);
|
|
309
|
+
if (sizes === undefined) {
|
|
310
|
+
sizes = Array(Math.floor(n / 3)).fill(3);
|
|
311
|
+
if (n % 3 === 1) {
|
|
312
|
+
sizes[sizes.length - 1] = 2;
|
|
313
|
+
sizes.push(2);
|
|
314
|
+
} else if (n % 3 === 2) {
|
|
315
|
+
sizes.push(2);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const groups = [];
|
|
319
|
+
let i = 0;
|
|
320
|
+
for (const size of sizes) {
|
|
321
|
+
groups.push(chars.slice(i, i + size).join(''));
|
|
322
|
+
i += size;
|
|
323
|
+
}
|
|
324
|
+
return groups.map(read_digit_group).join(', ');
|
|
325
|
+
}
|