@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/src/itn.js ADDED
@@ -0,0 +1,255 @@
1
+ // trnorm/itn.py in JavaScript. Inverse text normalization: spoken Turkish -> written form with digits.
2
+ //
3
+ // For ASR output, which comes out as words:
4
+ //
5
+ // inverse_normalize("saat on dört otuzda yüzde on beşlik indirim")
6
+ // -> "saat 14.30'da %15'lik indirim"
7
+ //
8
+ // A run of number words is split into the longest *canonical* numbers (a word sequence is a number iff
9
+ // number_to_words(value) reproduces it), so "sıfır beş yüz otuz iki yüz yirmi üç kırk beş altmış yedi" becomes
10
+ // "0555 555 55 55". Small numbers (<= 10) standing alone stay as words, as Turkish style guides recommend ("bir gün",
11
+ // "üç kişi"), unless the context asks for digits ("saat üçte", "yüzde beş", "beş lira").
12
+ //
13
+ // Values are BigInts, as Python's ints are unbounded (number_to_words throws where Python raises, past 4300 digits).
14
+ // Tokens come from Python's split() and every word the module looks for is BMP, so UTF-16 slicing of a token's start
15
+ // is Python's code point slicing there.
16
+
17
+ import * as L from './lexicon.js';
18
+ import { tr_lower } from './casing.js';
19
+ import { ONES, TENS, VOWELS, number_to_words, ordinal_to_words } from './numbers.js';
20
+ import { pin_newer, unpin_newer } from './normalizer.js';
21
+ import { B, D, END, U_PAIR_BUG, U_PAIR_RE, first_cp, isupper, re, rstrip, split, strip } from './py.js';
22
+
23
+ const _SCALE_WORDS = ['yüz', 'bin', 'milyon', 'milyar', 'trilyon', 'katrilyon', 'kentilyon'];
24
+ export const NUMBER_WORDS = new Set([...ONES, ...TENS.filter(Boolean), ..._SCALE_WORDS]);
25
+ // "altmış" before "altı". Among words of one length Python's order is the set's (hash order, which varies from run
26
+ // to run); it cannot matter, as two different words of one length are never both prefixes of a token.
27
+ const _NUMBER_WORDS_SORTED = [...NUMBER_WORDS].sort((a, b) => b.length - a.length);
28
+ const _SINGLE_VALUE = new Map();
29
+ ONES.forEach((w, i) => _SINGLE_VALUE.set(w, BigInt(i)));
30
+ TENS.forEach((w, i) => { if (w) _SINGLE_VALUE.set(w, BigInt(i * 10)); });
31
+ _SCALE_WORDS.forEach((w, i) => _SINGLE_VALUE.set(w, i ? 10n ** BigInt(3 * i) : 100n));
32
+
33
+ // all BMP and no negated class: no u flag (see py.js Pattern)
34
+ const _ORDINAL_RE = re.compile('^(?:ı|i|u|ü)?nc(?:ı|i|u|ü)', null, false);
35
+
36
+ // plural? + possessive? + case? + ki? (enough for suffixes that follow numbers)
37
+ const _V4 = '[ıiuü]';
38
+ const _V2 = '[ae]';
39
+ const _SUFFIX_GRAMMAR = re.compile(
40
+ `^(?:l${_V2}r)?`
41
+ + `(?:y?${_V4}|s${_V4}|${_V4}m${_V4}z|${_V4}n${_V4}z|${_V4}m|${_V4}n|l${_V4}k|l${_V4}|ş?${_V2}r)?`
42
+ + `(?:n?[dt]${_V2}n?|n?[dt]${_V2}ki|y?${_V2}|n?${_V2}|y?${_V4}|n?${_V4}|n${_V4}n|${_V4}n|y?l${_V2}|[cç]${_V2})?`
43
+ + END,
44
+ null,
45
+ false,
46
+ );
47
+
48
+ const _MONTHS_LOWER = new Set(L.MONTHS.filter(Boolean).map(tr_lower));
49
+ const _UNIT_WORDS = new Set([
50
+ 'lira', 'liraya', 'lirayı', 'liralık', 'kuruş', 'dolar', 'avro', 'euro', 'sterlin', 'sent',
51
+ 'kilometre', 'metre', 'santimetre', 'milimetre', 'kilogram', 'kilo', 'gram', 'miligram',
52
+ 'litre', 'mililitre', 'derece', 'metrekare', 'metreküp', 'saniye', 'dakika', 'gigabayt',
53
+ 'megabayt', 'kilobayt', 'terabayt', 'volt', 'vat', 'kilovat', 'hertz', 'virgül', 'buçuk',
54
+ ]);
55
+ const _DIGIT_CONTEXT_BEFORE = new Set([
56
+ 'saat', 'yüzde', 'binde', 'numara', 'numarası', 'numaralı', 'no', 'kod', 'kodu', 'kodunuz',
57
+ 'daire', 'sayfa', 'madde', 'plaka', 'hat', 'dahili', 'virgül', 'eksi', 'artı',
58
+ ]);
59
+ const PUNCT = ',.;:!?';
60
+
61
+ // token -> [base number word, suffix, is_ordinal] or null.
62
+ //
63
+ // "beşte" -> ["beş", "te", false]; "dördüncüsü" -> ["dört", "sü", true]
64
+ function _split_number_word(token) {
65
+ for (const w of _NUMBER_WORDS_SORTED) {
66
+ let rest;
67
+ if (token.startsWith(w)) rest = token.slice(w.length);
68
+ else if (w === 'dört' && token.startsWith('dörd') && VOWELS.has(token[4])) rest = token.slice(4);
69
+ else continue;
70
+ if (!rest) return [w, '', false];
71
+ const m = _ORDINAL_RE.match(rest);
72
+ if (m) {
73
+ const tail = rest.slice(m[0].length);
74
+ if (!tail || _SUFFIX_GRAMMAR.match(tail)) return [w, tail, true];
75
+ continue;
76
+ }
77
+ if (L.BARE_NUMBER_SUFFIXES.has(rest) || _SUFFIX_GRAMMAR.match(rest)) return [w, rest, false];
78
+ }
79
+ return null;
80
+ }
81
+
82
+ // Value of a word list if it is a canonical Turkish cardinal, else null.
83
+ function _canonical_value(words) {
84
+ if (!words.length || words.some((w) => !NUMBER_WORDS.has(w))) return null;
85
+ let total = 0n;
86
+ let current = 0n;
87
+ for (const w of words) {
88
+ if (ONES.includes(w)) current += BigInt(ONES.indexOf(w));
89
+ else if (TENS.includes(w)) current += BigInt(TENS.indexOf(w) * 10);
90
+ else if (w === 'yüz') current = (current || 1n) * 100n;
91
+ else {
92
+ total += (current || 1n) * 10n ** BigInt(3 * _SCALE_WORDS.indexOf(w));
93
+ current = 0n;
94
+ }
95
+ }
96
+ const value = total + current;
97
+ return number_to_words(value) === words.join(' ') ? value : null;
98
+ }
99
+
100
+ // f"{v:,}".replace(",", ".") from 10000 on; a canonical value is at most 10**21 - 1, far below the 4300 digits
101
+ function _format_int(v) {
102
+ const s = v.toString();
103
+ return v >= 10000n ? s.replace(/\B(?=(\d{3})+$)/g, '.') : s;
104
+ }
105
+
106
+ // -> [base, suffix, is_ordinal, raw, has trailing punctuation] or null
107
+ function _parse_token(raw) {
108
+ const core = rstrip(raw, PUNCT);
109
+ if (!core) return null;
110
+ const low = tr_lower(core);
111
+ const parsed = _split_number_word(low);
112
+ if (parsed === null) return null;
113
+ // capitalized tokens are usually names ("Kırklareli"); accept only exact number words
114
+ if (isupper(first_cp(core)) && parsed[1] && !L.BARE_NUMBER_SUFFIXES.has(parsed[1])) return null;
115
+ return [parsed[0], parsed[1], parsed[2], raw, core !== raw];
116
+ }
117
+
118
+ // An Error for a text this engine cannot read right (py.js U_PAIR_BUG): not a Python exception.
119
+ function _engine_error() {
120
+ return Object.assign(new Error("this browser's RegExp misreads a pair of characters here (Safari 26.0 to 26.5)"), { name: 'EngineError' });
121
+ }
122
+
123
+ // Convert spoken-form Turkish to written form with digits.
124
+ //
125
+ // Characters Unicode 15.0 (Python's) does not assign are swapped for a placeholder that this engine, like Python,
126
+ // sees as no letter, digit, space or cased character, and swapped back at the end (nothing here drops or reorders
127
+ // a character): a newer engine would take a Unicode 16 digit for one in _post_patterns.
128
+ export function inverse_normalize(text) {
129
+ if (typeof text !== 'string') {
130
+ throw Object.assign(new TypeError(`'${typeof text}' object has no attribute 'split'`), { name: 'AttributeError', python: true });
131
+ }
132
+ // _post_patterns' \b and \d run with the u flag; a buggy engine reads such a pair as one character there
133
+ if (U_PAIR_BUG && U_PAIR_RE.test(text)) throw _engine_error();
134
+ const [pinned, unassigned, lone] = pin_newer(text);
135
+ const out = _inverse(pinned);
136
+ return unassigned.length || lone.length ? unpin_newer(out, unassigned, lone, false) ?? _inverse(text) : out;
137
+ }
138
+
139
+ function _inverse(text) {
140
+ const tokens = split(text);
141
+ const out = [];
142
+ const n = tokens.length;
143
+ let i = 0;
144
+ while (i < n) {
145
+ const run = [];
146
+ let j = i;
147
+ while (j < n) {
148
+ const p = _parse_token(tokens[j]);
149
+ if (p === null) break;
150
+ run.push(p);
151
+ j += 1;
152
+ if (p[1] || p[2] || p[4]) break; // suffix, ordinal or punctuation closes the number
153
+ }
154
+ if (!run.length) {
155
+ out.push(tokens[i]);
156
+ i += 1;
157
+ continue;
158
+ }
159
+ out.push(..._render_run(run, out, j < n ? tokens[j] : ''));
160
+ i = j;
161
+ }
162
+ return _post_patterns(out.join(' '));
163
+ }
164
+
165
+ const _TRAIL_RE = /[,.;:!?]+$/;
166
+ const pad2 = (v) => (v < 10n ? `0${v}` : String(v));
167
+
168
+ function _render_run(run, prev_out, next_token) {
169
+ const bases = run.map((r) => r[0]);
170
+ const groups = [];
171
+ let k = 0;
172
+ while (k < bases.length) {
173
+ let end = k + 1;
174
+ for (let e = bases.length; e > k; e--) {
175
+ if (_canonical_value(bases.slice(k, e)) !== null) {
176
+ end = e;
177
+ break;
178
+ }
179
+ }
180
+ groups.push([k, end]);
181
+ k = end;
182
+ }
183
+ const values = groups.map(([a, b]) => _canonical_value(bases.slice(a, b)));
184
+
185
+ const trail_m = _TRAIL_RE.exec(run[run.length - 1][3]);
186
+ const trail = trail_m ? trail_m[0] : '';
187
+ const [, last_suffix, last_ord] = run[run.length - 1];
188
+ const suf = last_suffix ? `'${last_suffix}` : '';
189
+ const prev_word = prev_out.length ? strip(tr_lower(prev_out[prev_out.length - 1]), PUNCT) : '';
190
+ const next_word = strip(tr_lower(next_token), PUNCT);
191
+
192
+ if (values.every((v) => v !== null)) {
193
+ // identifiers read digit by digit: three or more single digits
194
+ if (groups.length >= 3 && values.every((v, x) => v < 10n && groups[x][1] - groups[x][0] === 1)) {
195
+ return [values.join('') + suf + trail];
196
+ }
197
+ // phone numbers: sıfır + area code + groups
198
+ if (groups.length >= 4 && values[0] === 0n && values[1] >= 100n && values[1] <= 999n) {
199
+ const parts = [`0${values[1]}`, ...values.slice(2).map(pad2)];
200
+ return [parts.join(' ') + suf + trail];
201
+ }
202
+ }
203
+
204
+ // "on bir yirmide" -> "11.20'de" (hour + minutes with a suffix)
205
+ if (groups.length === 2 && values.every((v) => v !== null) && last_suffix && !last_ord) {
206
+ const [h, mi] = values;
207
+ if (h <= 24n && mi >= 10n && mi <= 59n && groups[1][1] - groups[1][0] <= 2) return [`${h}.${pad2(mi)}${suf}${trail}`];
208
+ }
209
+
210
+ const rendered = [];
211
+ groups.forEach(([a, b], idx) => {
212
+ const v = values[idx];
213
+ const last = idx === groups.length - 1;
214
+ const originals = run.slice(a, b).map((r) => r[3]);
215
+ if (v === null) {
216
+ rendered.push(...originals);
217
+ return;
218
+ }
219
+ const ordinal = last_ord && last;
220
+ const single = b - a === 1;
221
+ const small = single && (v <= 10n || bases[a] === 'yüz' || bases[a] === 'bin');
222
+ const digit_ctx = _DIGIT_CONTEXT_BEFORE.has(prev_word)
223
+ || (last && (_UNIT_WORDS.has(next_word) || _MONTHS_LOWER.has(next_word)))
224
+ || groups.length > 1;
225
+ if (small && !digit_ctx) {
226
+ rendered.push(...originals);
227
+ return;
228
+ }
229
+ let s;
230
+ if (ordinal) {
231
+ if (last_suffix) {
232
+ const ord_tail = ordinal_to_words(_SINGLE_VALUE.get(bases[b - 1])).slice(bases[b - 1].length);
233
+ s = `${v}'${ord_tail}${last_suffix}`;
234
+ } else s = `${v}.`;
235
+ } else s = _format_int(v) + (last ? suf : '');
236
+ rendered.push(s + (last ? trail : ''));
237
+ });
238
+ return rendered;
239
+ }
240
+
241
+ // Python's \d and \b are Unicode: a run of digits of any script ("٣ virgül ٤" -> "٣,٤")
242
+ const _POST = [
243
+ [re.compile(`${B}(${D}+) virgül (${D}+)${B}`), (m) => `${m[1]},${m[2]}`],
244
+ [re.compile(`${B}(${D}+) buçuk${B}`), (m) => `${m[1]},5`],
245
+ [re.compile(`${B}yüzde (${D}+(?:,${D}+)?)`), (m) => `%${m[1]}`],
246
+ [re.compile(`${B}binde (${D}+(?:,${D}+)?)`), (m) => `‰${m[1]}`],
247
+ [re.compile(`${B}eksi (${D})`), (m) => `-${m[1]}`],
248
+ // times: "saat 14 30'da" -> "saat 14.30'da"
249
+ [re.compile(`${B}([Ss]aat) ([01]?${D}|2[0-4]) ([0-5]${D})${B}`), (m) => `${m[1]} ${m[2]}.${m[3]}`],
250
+ ];
251
+
252
+ function _post_patterns(text) {
253
+ for (const [rx, repl] of _POST) text = rx.sub(repl, text);
254
+ return text;
255
+ }