words2num2 0.3.2__tar.gz → 0.3.3__tar.gz
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.
- {words2num2-0.3.2 → words2num2-0.3.3}/PKG-INFO +1 -1
- {words2num2-0.3.2 → words2num2-0.3.3}/pyproject.toml +1 -1
- {words2num2-0.3.2 → words2num2-0.3.3}/rust/Cargo.lock +16 -57
- {words2num2-0.3.2 → words2num2-0.3.3}/rust/Cargo.toml +1 -1
- {words2num2-0.3.2 → words2num2-0.3.3}/rust/words2num2-core/src/lib.rs +53 -0
- {words2num2-0.3.2 → words2num2-0.3.3}/rust/words2num2-core/src/w2n_lang_en.rs +53 -2
- {words2num2-0.3.2 → words2num2-0.3.3}/rust/words2num2-core/src/w2n_sentence.rs +198 -4
- {words2num2-0.3.2 → words2num2-0.3.3}/rust/words2num2-py/Cargo.toml +1 -1
- {words2num2-0.3.2 → words2num2-0.3.3}/rust/words2num2-py/src/lib.rs +11 -11
- {words2num2-0.3.2 → words2num2-0.3.3}/words2num2/_version.py +2 -2
- {words2num2-0.3.2 → words2num2-0.3.3}/COPYING +0 -0
- {words2num2-0.3.2 → words2num2-0.3.3}/README.rst +0 -0
- {words2num2-0.3.2 → words2num2-0.3.3}/rust/words2num2-core/Cargo.toml +0 -0
- {words2num2-0.3.2 → words2num2-0.3.3}/rust/words2num2-core/src/w2n_formats.rs +0 -0
- {words2num2-0.3.2 → words2num2-0.3.3}/words2num2/__init__.py +0 -0
- {words2num2-0.3.2 → words2num2-0.3.3}/words2num2/__main__.py +0 -0
- {words2num2-0.3.2 → words2num2-0.3.3}/words2num2/base.py +0 -0
- {words2num2-0.3.2 → words2num2-0.3.3}/words2num2/converters/__init__.py +0 -0
- {words2num2-0.3.2 → words2num2-0.3.3}/words2num2/converters/auto.py +0 -0
|
@@ -6,7 +6,7 @@ build-backend = "maturin"
|
|
|
6
6
|
name = "words2num2"
|
|
7
7
|
# Static base version. The release CI overrides it from the pushed git tag
|
|
8
8
|
# (see .github/workflows/release.yml); bump here for local source builds.
|
|
9
|
-
version = "0.3.
|
|
9
|
+
version = "0.3.3"
|
|
10
10
|
license = "LGPL-2.1-only"
|
|
11
11
|
license-files = ["COPYING"]
|
|
12
12
|
description = "Rust-powered inverse of num2words2: parse spoken-form numbers back to numeric values across 100+ languages, optimized for ASR/LLM/speech."
|
|
@@ -21,27 +21,12 @@ dependencies = [
|
|
|
21
21
|
"num-traits",
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
-
[[package]]
|
|
25
|
-
name = "cfg-if"
|
|
26
|
-
version = "1.0.4"
|
|
27
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
28
|
-
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
29
|
-
|
|
30
24
|
[[package]]
|
|
31
25
|
name = "heck"
|
|
32
26
|
version = "0.5.0"
|
|
33
27
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
34
28
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
35
29
|
|
|
36
|
-
[[package]]
|
|
37
|
-
name = "indoc"
|
|
38
|
-
version = "2.0.7"
|
|
39
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
40
|
-
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
|
41
|
-
dependencies = [
|
|
42
|
-
"rustversion",
|
|
43
|
-
]
|
|
44
|
-
|
|
45
30
|
[[package]]
|
|
46
31
|
name = "libc"
|
|
47
32
|
version = "0.2.186"
|
|
@@ -54,15 +39,6 @@ version = "0.2.16"
|
|
|
54
39
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
55
40
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
|
56
41
|
|
|
57
|
-
[[package]]
|
|
58
|
-
name = "memoffset"
|
|
59
|
-
version = "0.9.1"
|
|
60
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
61
|
-
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
|
62
|
-
dependencies = [
|
|
63
|
-
"autocfg",
|
|
64
|
-
]
|
|
65
|
-
|
|
66
42
|
[[package]]
|
|
67
43
|
name = "num-bigint"
|
|
68
44
|
version = "0.4.8"
|
|
@@ -105,7 +81,7 @@ dependencies = [
|
|
|
105
81
|
|
|
106
82
|
[[package]]
|
|
107
83
|
name = "num2words2-core"
|
|
108
|
-
version = "0.1.
|
|
84
|
+
version = "0.1.3"
|
|
109
85
|
dependencies = [
|
|
110
86
|
"bigdecimal",
|
|
111
87
|
"num-bigint",
|
|
@@ -136,38 +112,34 @@ dependencies = [
|
|
|
136
112
|
|
|
137
113
|
[[package]]
|
|
138
114
|
name = "pyo3"
|
|
139
|
-
version = "0.
|
|
115
|
+
version = "0.29.2"
|
|
140
116
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
141
|
-
checksum = "
|
|
117
|
+
checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
|
|
142
118
|
dependencies = [
|
|
143
|
-
"cfg-if",
|
|
144
|
-
"indoc",
|
|
145
119
|
"libc",
|
|
146
|
-
"memoffset",
|
|
147
120
|
"num-bigint",
|
|
121
|
+
"num-traits",
|
|
148
122
|
"once_cell",
|
|
149
123
|
"portable-atomic",
|
|
150
124
|
"pyo3-build-config",
|
|
151
125
|
"pyo3-ffi",
|
|
152
126
|
"pyo3-macros",
|
|
153
|
-
"unindent",
|
|
154
127
|
]
|
|
155
128
|
|
|
156
129
|
[[package]]
|
|
157
130
|
name = "pyo3-build-config"
|
|
158
|
-
version = "0.
|
|
131
|
+
version = "0.29.2"
|
|
159
132
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
160
|
-
checksum = "
|
|
133
|
+
checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
|
|
161
134
|
dependencies = [
|
|
162
|
-
"once_cell",
|
|
163
135
|
"target-lexicon",
|
|
164
136
|
]
|
|
165
137
|
|
|
166
138
|
[[package]]
|
|
167
139
|
name = "pyo3-ffi"
|
|
168
|
-
version = "0.
|
|
140
|
+
version = "0.29.2"
|
|
169
141
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
170
|
-
checksum = "
|
|
142
|
+
checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
|
|
171
143
|
dependencies = [
|
|
172
144
|
"libc",
|
|
173
145
|
"pyo3-build-config",
|
|
@@ -175,9 +147,9 @@ dependencies = [
|
|
|
175
147
|
|
|
176
148
|
[[package]]
|
|
177
149
|
name = "pyo3-macros"
|
|
178
|
-
version = "0.
|
|
150
|
+
version = "0.29.2"
|
|
179
151
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
180
|
-
checksum = "
|
|
152
|
+
checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
|
|
181
153
|
dependencies = [
|
|
182
154
|
"proc-macro2",
|
|
183
155
|
"pyo3-macros-backend",
|
|
@@ -187,13 +159,12 @@ dependencies = [
|
|
|
187
159
|
|
|
188
160
|
[[package]]
|
|
189
161
|
name = "pyo3-macros-backend"
|
|
190
|
-
version = "0.
|
|
162
|
+
version = "0.29.2"
|
|
191
163
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
192
|
-
checksum = "
|
|
164
|
+
checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
|
|
193
165
|
dependencies = [
|
|
194
166
|
"heck",
|
|
195
167
|
"proc-macro2",
|
|
196
|
-
"pyo3-build-config",
|
|
197
168
|
"quote",
|
|
198
169
|
"syn",
|
|
199
170
|
]
|
|
@@ -207,12 +178,6 @@ dependencies = [
|
|
|
207
178
|
"proc-macro2",
|
|
208
179
|
]
|
|
209
180
|
|
|
210
|
-
[[package]]
|
|
211
|
-
name = "rustversion"
|
|
212
|
-
version = "1.0.23"
|
|
213
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
214
|
-
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|
215
|
-
|
|
216
181
|
[[package]]
|
|
217
182
|
name = "syn"
|
|
218
183
|
version = "2.0.119"
|
|
@@ -226,9 +191,9 @@ dependencies = [
|
|
|
226
191
|
|
|
227
192
|
[[package]]
|
|
228
193
|
name = "target-lexicon"
|
|
229
|
-
version = "0.
|
|
194
|
+
version = "0.13.5"
|
|
230
195
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
231
|
-
checksum = "
|
|
196
|
+
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|
232
197
|
|
|
233
198
|
[[package]]
|
|
234
199
|
name = "tinyvec"
|
|
@@ -260,15 +225,9 @@ dependencies = [
|
|
|
260
225
|
"tinyvec",
|
|
261
226
|
]
|
|
262
227
|
|
|
263
|
-
[[package]]
|
|
264
|
-
name = "unindent"
|
|
265
|
-
version = "0.2.4"
|
|
266
|
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
267
|
-
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
|
268
|
-
|
|
269
228
|
[[package]]
|
|
270
229
|
name = "words2num2-core"
|
|
271
|
-
version = "0.1.
|
|
230
|
+
version = "0.1.3"
|
|
272
231
|
dependencies = [
|
|
273
232
|
"bigdecimal",
|
|
274
233
|
"num-bigint",
|
|
@@ -278,7 +237,7 @@ dependencies = [
|
|
|
278
237
|
|
|
279
238
|
[[package]]
|
|
280
239
|
name = "words2num2-py"
|
|
281
|
-
version = "0.1.
|
|
240
|
+
version = "0.1.3"
|
|
282
241
|
dependencies = [
|
|
283
242
|
"bigdecimal",
|
|
284
243
|
"num-bigint",
|
|
@@ -422,6 +422,59 @@ fn hundred_word(lang: &str) -> Option<String> {
|
|
|
422
422
|
out
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
+
/// Cache of the derived bare hundred prefix per language (es "ciento").
|
|
426
|
+
fn hundred_prefix_cache() -> &'static RwLock<HashMap<String, Option<String>>> {
|
|
427
|
+
static P: OnceLock<RwLock<HashMap<String, Option<String>>>> = OnceLock::new();
|
|
428
|
+
P.get_or_init(|| RwLock::new(HashMap::new()))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/// The hundred word num2words never renders on its own, only as the head of
|
|
432
|
+
/// a composed hundred: es "ciento" (100 is "cien", 101 is "ciento uno"),
|
|
433
|
+
/// pt "cento" (100 is "cem"). The reverse table has no entry for it, so the
|
|
434
|
+
/// sentence walker never opened a run on it and "ciento cincuenta y cuatro"
|
|
435
|
+
/// came out as "ciento 54". Derived by rendering 101..=109 and keeping the
|
|
436
|
+
/// head token when it is not a table word by itself while the rest is the
|
|
437
|
+
/// unit. `None` where the hundred is a table word already (fr "cent") or is
|
|
438
|
+
/// glued to its unit (de "hunderteins", it "centouno").
|
|
439
|
+
fn hundred_prefix(lang: &str) -> Option<String> {
|
|
440
|
+
if let Some(v) = hundred_prefix_cache().read().unwrap().get(lang) {
|
|
441
|
+
return v.clone();
|
|
442
|
+
}
|
|
443
|
+
let out = (|| {
|
|
444
|
+
let l = num2words2_core::get_lang_by_key(lang)?;
|
|
445
|
+
let conns = connector_words(lang);
|
|
446
|
+
for n in 101i64..=109 {
|
|
447
|
+
let words = l.to_cardinal(&BigInt::from(n)).ok()?;
|
|
448
|
+
let norm = normalize(&words);
|
|
449
|
+
let toks: Vec<&str> = norm.split_whitespace().collect();
|
|
450
|
+
if toks.len() < 2 || lookup_plain(lang, toks[0]).is_some() {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
let rest = trim_connectors(&toks[1..].join(" "), conns);
|
|
454
|
+
if lookup_plain(lang, &rest) == Some(n - 100) {
|
|
455
|
+
return Some(toks[0].to_string());
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
None
|
|
459
|
+
})();
|
|
460
|
+
hundred_prefix_cache()
|
|
461
|
+
.write()
|
|
462
|
+
.unwrap()
|
|
463
|
+
.insert(lang.to_string(), out.clone());
|
|
464
|
+
out
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/// The bare hundred prefix on its own ("ciento") reads as 100. A run head in
|
|
468
|
+
/// the sentence walker must be a number word by itself, and this is the one
|
|
469
|
+
/// hundred word the reverse table cannot vouch for.
|
|
470
|
+
pub fn lookup_hundred_prefix(lang: &str, text: &str) -> Option<i64> {
|
|
471
|
+
if !supported_langs().contains(&lang) {
|
|
472
|
+
return None;
|
|
473
|
+
}
|
|
474
|
+
let prefix = hundred_prefix(lang)?;
|
|
475
|
+
(normalize(text) == prefix).then_some(100)
|
|
476
|
+
}
|
|
477
|
+
|
|
425
478
|
/// Recover a spoken "year" reading that is not num2words' canonical spelling:
|
|
426
479
|
/// `L <hundred> R` (explicit or glued) → `L*100 + R`, or two 2-digit groups
|
|
427
480
|
/// `a b` → `a*100 + b`. All parts are resolved through the reverse table, so no
|
|
@@ -634,6 +634,18 @@ const TENS: [(&str, i64); 8] = [
|
|
|
634
634
|
("ninety", 90),
|
|
635
635
|
];
|
|
636
636
|
|
|
637
|
+
/// Value of a unit or teen word (`_UNITS`: "zero".."nineteen", "oh"), for
|
|
638
|
+
/// the sentence walker's digit-string and spoken-year readings.
|
|
639
|
+
pub(crate) fn unit_value(word: &str) -> Option<i64> {
|
|
640
|
+
UNITS.iter().find(|(w, _)| *w == word).map(|&(_, v)| v)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/// Value of a tens word (`_TENS`: "twenty".."ninety"), for the sentence
|
|
644
|
+
/// walker's spoken-year reading.
|
|
645
|
+
pub(crate) fn tens_value(word: &str) -> Option<i64> {
|
|
646
|
+
TENS.iter().find(|(w, _)| *w == word).map(|&(_, v)| v)
|
|
647
|
+
}
|
|
648
|
+
|
|
637
649
|
/// `_SCALES`, as (word, power-of-ten). "hundred" is 100 = 10^2.
|
|
638
650
|
///
|
|
639
651
|
/// Note "hundred" is a member here *and* is special-cased ahead of the
|
|
@@ -886,19 +898,56 @@ impl W2nLangEn {
|
|
|
886
898
|
let mut total = BigInt::from(0);
|
|
887
899
|
let mut current = BigInt::from(0);
|
|
888
900
|
let mut seen_any = false;
|
|
901
|
+
// What the sub-hundred slot of `current` already holds.
|
|
902
|
+
//
|
|
903
|
+
// English composes a tens word with a *following* unit ("sixty
|
|
904
|
+
// three" = 63), never with a preceding one, and a unit closes the
|
|
905
|
+
// slot. "three sixty" is two numbers read in sequence, not 3 + 60;
|
|
906
|
+
// "twenty twenty" is two twenties, not 40; "four five six seven" is
|
|
907
|
+
// four digits read out, not 22; "twenty nineteen" is a year, not 39.
|
|
908
|
+
// Rejecting a word the slot cannot take is what makes them come out
|
|
909
|
+
// as separate runs — or a digit string, or a year — in a sentence.
|
|
910
|
+
//
|
|
911
|
+
// "hundred" and the scale words close the slot, so "one hundred
|
|
912
|
+
// sixty" (160) and "two thousand sixty" (2060) are unaffected.
|
|
913
|
+
#[derive(PartialEq)]
|
|
914
|
+
enum Slot {
|
|
915
|
+
Empty,
|
|
916
|
+
/// A tens word; a single digit (0-9) may still follow.
|
|
917
|
+
Tens,
|
|
918
|
+
/// A unit, or a tens word plus its unit; nothing may follow.
|
|
919
|
+
Full,
|
|
920
|
+
}
|
|
921
|
+
let mut slot = Slot::Empty;
|
|
922
|
+
let cannot_follow = |tok: &str| {
|
|
923
|
+
W2nError::new(format!(
|
|
924
|
+
"{} cannot follow a smaller number in {}",
|
|
925
|
+
py_repr(tok),
|
|
926
|
+
py_repr(&toks.join(" "))
|
|
927
|
+
))
|
|
928
|
+
};
|
|
889
929
|
for tok in toks {
|
|
890
930
|
if let Some(&v) = self.units.get(*tok) {
|
|
931
|
+
if slot == Slot::Full || (slot == Slot::Tens && v >= 10) {
|
|
932
|
+
return Err(cannot_follow(tok));
|
|
933
|
+
}
|
|
891
934
|
current += v;
|
|
892
935
|
seen_any = true;
|
|
936
|
+
slot = Slot::Full;
|
|
893
937
|
} else if let Some(&v) = self.tens.get(*tok) {
|
|
938
|
+
if slot != Slot::Empty {
|
|
939
|
+
return Err(cannot_follow(tok));
|
|
940
|
+
}
|
|
894
941
|
current += v;
|
|
895
942
|
seen_any = true;
|
|
943
|
+
slot = Slot::Tens;
|
|
896
944
|
} else if *tok == "hundred" {
|
|
897
945
|
if current.sign() == Sign::NoSign {
|
|
898
946
|
current = BigInt::from(1);
|
|
899
947
|
}
|
|
900
948
|
current *= 100;
|
|
901
949
|
seen_any = true;
|
|
950
|
+
slot = Slot::Empty;
|
|
902
951
|
} else if let Some(scale) = self.scales.get(*tok) {
|
|
903
952
|
if current.sign() == Sign::NoSign {
|
|
904
953
|
current = BigInt::from(1);
|
|
@@ -906,6 +955,7 @@ impl W2nLangEn {
|
|
|
906
955
|
total += ¤t * scale;
|
|
907
956
|
current = BigInt::from(0);
|
|
908
957
|
seen_any = true;
|
|
958
|
+
slot = Slot::Empty;
|
|
909
959
|
} else if is_digits(tok) {
|
|
910
960
|
// Allow embedded digit groups, e.g. "two thousand 24".
|
|
911
961
|
match BigInt::from_str(&nd_to_ascii(tok)) {
|
|
@@ -971,8 +1021,9 @@ impl W2nLangEn {
|
|
|
971
1021
|
///
|
|
972
1022
|
/// Tries every split point and takes the first where the high half is a
|
|
973
1023
|
/// 2-digit number and the low half is at most 99. When nothing matches it
|
|
974
|
-
/// falls back to plain cardinal
|
|
975
|
-
///
|
|
1024
|
+
/// falls back to the plain cardinal reading — which, since a unit may
|
|
1025
|
+
/// not follow a unit, now rejects "nine eleven" rather than summing it
|
|
1026
|
+
/// to 20.
|
|
976
1027
|
fn year_value(&self, toks: &[&str]) -> W2nResult<BigInt> {
|
|
977
1028
|
if toks.len() < 2 {
|
|
978
1029
|
return self.cardinal_value(toks);
|
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
//! Verified against the live interpreter. These all look wrong and are all
|
|
17
17
|
//! correct ports:
|
|
18
18
|
//!
|
|
19
|
-
//! * `words2num_sentence("nineteen ninety nine")`
|
|
20
|
-
//!
|
|
19
|
+
//! * `words2num_sentence("nineteen ninety nine")` used to be `"118"` (the
|
|
20
|
+
//! walker calls `to_cardinal`, 19 + 99). Since a tens word may no longer
|
|
21
|
+
//! follow a smaller number, and the walker now reads a spoken year that
|
|
22
|
+
//! outgrows the cardinal ([`en_year_run`]) and a digit string read one
|
|
23
|
+
//! digit at a time ([`en_digit_run`]), it is `"1999"` — and "four five six
|
|
24
|
+
//! seven" is `"4567"` rather than `"22"`.
|
|
21
25
|
//! * `words2num_sentence("minus forty two")` → `"minus 42"`. A run may not
|
|
22
26
|
//! *start* with a connector, and `to_cardinal("minus")` raises, so "minus"
|
|
23
27
|
//! is not a run head. Same for `"a hundred and one dogs"` → `"a 101 dogs"`.
|
|
@@ -717,6 +721,12 @@ fn base_convert(lang: &str, text: &str, ordinal: bool) -> Result<W2nValue, W2nEr
|
|
|
717
721
|
// Composition multi-échelle pour les valeurs hors table (> 10001) :
|
|
718
722
|
// « soixante-neuf mille huit » → 69008. Uniquement en cardinal.
|
|
719
723
|
if !ordinal {
|
|
724
|
+
// The bare hundred prefix num2words never renders alone (es
|
|
725
|
+
// "ciento"): 100, so the walker can open a run on it and grow it
|
|
726
|
+
// into the table hit "ciento cincuenta y cuatro".
|
|
727
|
+
if let Some(v) = crate::lookup_hundred_prefix(lang, text) {
|
|
728
|
+
return Ok(W2nValue::Int(BigInt::from(v)));
|
|
729
|
+
}
|
|
720
730
|
if let Some(v) = crate::parse_scaled(lang, text) {
|
|
721
731
|
return Ok(W2nValue::Int(BigInt::from(v)));
|
|
722
732
|
}
|
|
@@ -955,6 +965,129 @@ fn is_candidate(converter: &Converter, token: &str, includable: &[&str]) -> bool
|
|
|
955
965
|
.any(|sub| converter.is_number_word(sub))
|
|
956
966
|
}
|
|
957
967
|
|
|
968
|
+
/// One word of an English number run: the word itself (lowercased, hyphens
|
|
969
|
+
/// split), the index of the sentence part it came from, and whether it is the
|
|
970
|
+
/// last word of that part — a reading may only stop on a part boundary, never
|
|
971
|
+
/// halfway through "ninety-nine".
|
|
972
|
+
struct EnWord {
|
|
973
|
+
word: String,
|
|
974
|
+
part: usize,
|
|
975
|
+
last: bool,
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/// The consecutive English number words starting at part `i`: units, teens,
|
|
979
|
+
/// tens and "oh"/"o", each part split on hyphens. Stops at the first part
|
|
980
|
+
/// that is not wholly made of such words, and after one that ends in
|
|
981
|
+
/// terminal punctuation — the same run boundaries the cardinal walk uses.
|
|
982
|
+
fn en_number_words(parts: &[String], i: usize) -> Vec<EnWord> {
|
|
983
|
+
use crate::w2n_lang_en::{tens_value, unit_value};
|
|
984
|
+
let mut words = Vec::new();
|
|
985
|
+
let mut j = i;
|
|
986
|
+
while j < parts.len() {
|
|
987
|
+
let tok = &parts[j];
|
|
988
|
+
if py_str_isspace(tok) {
|
|
989
|
+
j += 1;
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
let clean = rstrip_punct(tok).to_lowercase().replace('-', " ");
|
|
993
|
+
let subs = py_split_whitespace(&clean);
|
|
994
|
+
if subs.is_empty()
|
|
995
|
+
|| !subs
|
|
996
|
+
.iter()
|
|
997
|
+
.all(|w| *w == "o" || unit_value(w).is_some() || tens_value(w).is_some())
|
|
998
|
+
{
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
let n = subs.len();
|
|
1002
|
+
for (k, w) in subs.iter().enumerate() {
|
|
1003
|
+
words.push(EnWord {
|
|
1004
|
+
word: w.to_string(),
|
|
1005
|
+
part: j,
|
|
1006
|
+
last: k + 1 == n,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
if ends_with_terminal_punct(tok) {
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
j += 1;
|
|
1013
|
+
}
|
|
1014
|
+
words
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/// A digit string read one digit at a time — an account number, ZIP code or
|
|
1018
|
+
/// phone number: "four five six seven" -> "4567", "zero six one two" ->
|
|
1019
|
+
/// "0612". Two or more consecutive single-digit words (zero, one..nine), no
|
|
1020
|
+
/// tens or scale word among them; "oh"/"o" count as 0 only *inside* such a
|
|
1021
|
+
/// run, never as its first word. A lone digit word ("press one") is left to
|
|
1022
|
+
/// the cardinal grammar, as is "twenty five". The digits are concatenated,
|
|
1023
|
+
/// never summed, and a leading zero is kept — which is why this returns the
|
|
1024
|
+
/// string rather than a value. Also returns the last part index covered.
|
|
1025
|
+
fn en_digit_run(parts: &[String], i: usize) -> Option<(String, usize)> {
|
|
1026
|
+
use crate::w2n_lang_en::unit_value;
|
|
1027
|
+
let mut digits = String::new();
|
|
1028
|
+
let mut end: Option<(usize, usize)> = None;
|
|
1029
|
+
for (k, w) in en_number_words(parts, i).iter().enumerate() {
|
|
1030
|
+
let d = match w.word.as_str() {
|
|
1031
|
+
"oh" | "o" if k > 0 => 0,
|
|
1032
|
+
"oh" | "o" | "nought" | "naught" => break,
|
|
1033
|
+
other => match unit_value(other) {
|
|
1034
|
+
Some(v) if v <= 9 => v,
|
|
1035
|
+
_ => break,
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
digits.push(char::from(b'0' + d as u8));
|
|
1039
|
+
if w.last {
|
|
1040
|
+
end = Some((digits.len(), w.part));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
let (len, part) = end?;
|
|
1044
|
+
if len < 2 {
|
|
1045
|
+
return None;
|
|
1046
|
+
}
|
|
1047
|
+
digits.truncate(len);
|
|
1048
|
+
Some((digits, part))
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/// A spoken year that is not one English cardinal: "nineteen" or "twenty"
|
|
1052
|
+
/// followed by "oh" + digit ("twenty oh five" = 2005), a teen ("twenty
|
|
1053
|
+
/// nineteen" = 2019), or a tens word with an optional unit ("nineteen ninety
|
|
1054
|
+
/// nine" = 1999, "twenty twenty" = 2020). This is the pair reading of
|
|
1055
|
+
/// `to="year"`, applied by the cardinal walk only when it reaches further
|
|
1056
|
+
/// than the cardinal did — so "twenty five" stays 25, and "two thousand
|
|
1057
|
+
/// twenty five" never comes here. Returns the year and the last part index.
|
|
1058
|
+
fn en_year_run(parts: &[String], i: usize) -> Option<(i64, usize)> {
|
|
1059
|
+
use crate::w2n_lang_en::{tens_value, unit_value};
|
|
1060
|
+
let words = en_number_words(parts, i);
|
|
1061
|
+
let word = |k: usize| words.get(k).map(|w| w.word.as_str());
|
|
1062
|
+
let century = match word(0)? {
|
|
1063
|
+
"nineteen" => 1900,
|
|
1064
|
+
"twenty" => 2000,
|
|
1065
|
+
_ => return None,
|
|
1066
|
+
};
|
|
1067
|
+
let digit = |k: usize| unit_value(word(k)?).filter(|v| (1..=9).contains(v));
|
|
1068
|
+
// Candidate readings as (word count, value); the longest one that ends
|
|
1069
|
+
// on a part boundary wins.
|
|
1070
|
+
let mut readings: Vec<(usize, i64)> = Vec::new();
|
|
1071
|
+
let second = word(1)?;
|
|
1072
|
+
if second == "oh" || second == "o" {
|
|
1073
|
+
if let Some(u) = digit(2) {
|
|
1074
|
+
readings.push((3, century + u));
|
|
1075
|
+
}
|
|
1076
|
+
} else if let Some(t) = unit_value(second).filter(|v| (10..=19).contains(v)) {
|
|
1077
|
+
readings.push((2, century + t));
|
|
1078
|
+
} else if let Some(t) = tens_value(second) {
|
|
1079
|
+
readings.push((2, century + t));
|
|
1080
|
+
if let Some(u) = digit(2) {
|
|
1081
|
+
readings.push((3, century + t + u));
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
readings
|
|
1085
|
+
.into_iter()
|
|
1086
|
+
.rev()
|
|
1087
|
+
.find(|&(n, _)| words[n - 1].last)
|
|
1088
|
+
.map(|(n, year)| (year, words[n - 1].part))
|
|
1089
|
+
}
|
|
1090
|
+
|
|
958
1091
|
/// `SentenceConverter.INCLUDABLE` — tokens allowed *inside* a run though they
|
|
959
1092
|
/// are not numbers. Keyed by the **resolved** code, so only exactly `"en"`
|
|
960
1093
|
/// gets them: `words2num_sentence(..., lang="en_IN")` resolves to `"en_IN"`
|
|
@@ -1004,6 +1137,11 @@ pub fn words2num_sentence(
|
|
|
1004
1137
|
includable_for(&resolved)
|
|
1005
1138
|
};
|
|
1006
1139
|
|
|
1140
|
+
// The digit-string and spoken-year readings are English-only and belong
|
|
1141
|
+
// to the cardinal walk: `to="year"` keeps its own pair reading, and any
|
|
1142
|
+
// extra keyword argument fails every conversion (see `has_kwargs`).
|
|
1143
|
+
let en_cardinal = matches!(converter, Converter::En) && to == "cardinal" && !has_kwargs;
|
|
1144
|
+
|
|
1007
1145
|
let parts = tokenize(sentence);
|
|
1008
1146
|
let n = parts.len();
|
|
1009
1147
|
let mut out = String::new();
|
|
@@ -1024,6 +1162,18 @@ pub fn words2num_sentence(
|
|
|
1024
1162
|
continue;
|
|
1025
1163
|
}
|
|
1026
1164
|
|
|
1165
|
+
// A digit string read one digit at a time is concatenated, not
|
|
1166
|
+
// summed: "four five six seven" is 4567, never 22.
|
|
1167
|
+
if en_cardinal {
|
|
1168
|
+
if let Some((digits, end)) = en_digit_run(&parts, i) {
|
|
1169
|
+
let run = parts[i..=end].concat();
|
|
1170
|
+
out.push_str(&digits);
|
|
1171
|
+
out.push_str(trailing_punct(&run));
|
|
1172
|
+
i = end + 1;
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1027
1177
|
// Grow a number run starting at i.
|
|
1028
1178
|
let mut best_value: Option<W2nValue> = None;
|
|
1029
1179
|
let mut best_end = i;
|
|
@@ -1055,6 +1205,18 @@ pub fn words2num_sentence(
|
|
|
1055
1205
|
j += 1;
|
|
1056
1206
|
}
|
|
1057
1207
|
|
|
1208
|
+
// A spoken year outgrows the cardinal reading: "twenty twenty five"
|
|
1209
|
+
// is the cardinal "twenty" and then a dangling "twenty five", but the
|
|
1210
|
+
// year 2025.
|
|
1211
|
+
if en_cardinal {
|
|
1212
|
+
if let Some((year, end)) = en_year_run(&parts, i) {
|
|
1213
|
+
if best_value.is_none() || end > best_end {
|
|
1214
|
+
best_value = Some(W2nValue::Int(BigInt::from(year)));
|
|
1215
|
+
best_end = end;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1058
1220
|
if let Some(v) = best_value {
|
|
1059
1221
|
// Preserve trailing punctuation that was stripped during parse.
|
|
1060
1222
|
let run = parts[i..=best_end].concat();
|
|
@@ -2453,7 +2615,39 @@ mod digits_tests {
|
|
|
2453
2615
|
s("sixty-one hundred Main Street", "en", "digits", false).unwrap(),
|
|
2454
2616
|
"sixty-one hundred Main Street"
|
|
2455
2617
|
);
|
|
2456
|
-
// The old cardinal behaviour that motivated this
|
|
2457
|
-
|
|
2618
|
+
// The old cardinal behaviour that motivated this was "two seven five"
|
|
2619
|
+
// -> 14; the cardinal walk now reads the digit string itself.
|
|
2620
|
+
assert_eq!(s("Two seven five", "en", "cardinal", false).unwrap(), "275");
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
#[test]
|
|
2624
|
+
fn cardinal_walk_reads_years_and_digit_strings() {
|
|
2625
|
+
// Spoken years outgrow the cardinal reading.
|
|
2626
|
+
assert_eq!(s("twenty twenty five", "en", "cardinal", false).unwrap(), "2025");
|
|
2627
|
+
assert_eq!(s("nineteen ninety-nine", "en", "cardinal", false).unwrap(), "1999");
|
|
2628
|
+
assert_eq!(s("twenty oh five", "en", "cardinal", false).unwrap(), "2005");
|
|
2629
|
+
assert_eq!(
|
|
2630
|
+
s("december one twenty twenty five.", "en", "cardinal", false).unwrap(),
|
|
2631
|
+
"december 1 2025."
|
|
2632
|
+
);
|
|
2633
|
+
// Digit strings concatenate and keep a leading zero.
|
|
2634
|
+
assert_eq!(
|
|
2635
|
+
s("account ending in four five six seven", "en", "cardinal", false).unwrap(),
|
|
2636
|
+
"account ending in 4567"
|
|
2637
|
+
);
|
|
2638
|
+
assert_eq!(s("zero six one two", "en", "cardinal", false).unwrap(), "0612");
|
|
2639
|
+
// Valid cardinals and lone digits are untouched.
|
|
2640
|
+
assert_eq!(s("twenty five people", "en", "cardinal", false).unwrap(), "25 people");
|
|
2641
|
+
assert_eq!(s("press one", "en", "cardinal", false).unwrap(), "press 1");
|
|
2642
|
+
assert_eq!(s("five twenty", "en", "cardinal", false).unwrap(), "5 20");
|
|
2643
|
+
assert_eq!(s("two thousand twenty five", "en", "cardinal", false).unwrap(), "2025");
|
|
2644
|
+
// `to="year"` keeps its own pair reading.
|
|
2645
|
+
assert_eq!(s("twenty twenty five", "en", "year", false).unwrap(), "2025");
|
|
2646
|
+
// es: the bare hundred prefix opens the run.
|
|
2647
|
+
assert_eq!(
|
|
2648
|
+
s("ciento cincuenta y cuatro dólares", "es", "cardinal", false).unwrap(),
|
|
2649
|
+
"154 dólares"
|
|
2650
|
+
);
|
|
2651
|
+
assert_eq!(s("ciento uno", "es", "cardinal", false).unwrap(), "101");
|
|
2458
2652
|
}
|
|
2459
2653
|
}
|
|
@@ -11,4 +11,4 @@ crate-type = ["cdylib"]
|
|
|
11
11
|
words2num2-core = { path = "../words2num2-core" }
|
|
12
12
|
num-bigint = "0.4"
|
|
13
13
|
bigdecimal = "0.4"
|
|
14
|
-
pyo3 = { version = "0.
|
|
14
|
+
pyo3 = { version = "0.29", features = ["extension-module", "abi3-py38", "num-bigint"] }
|
|
@@ -50,7 +50,7 @@ fn w2n_error_to_pyerr(py: Python<'_>, e: w2n_sentence::W2nError) -> PyErr {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/// `decimal.Decimal(s)` — rebuilds an exact `Decimal` from its `str()` form.
|
|
53
|
-
fn make_decimal(py: Python<'_>, s: String) -> PyResult<
|
|
53
|
+
fn make_decimal(py: Python<'_>, s: String) -> PyResult<Py<PyAny>> {
|
|
54
54
|
let decimal = py.import("decimal")?.getattr("Decimal")?;
|
|
55
55
|
Ok(decimal.call1((s,))?.unbind())
|
|
56
56
|
}
|
|
@@ -58,7 +58,7 @@ fn make_decimal(py: Python<'_>, s: String) -> PyResult<PyObject> {
|
|
|
58
58
|
/// Convert a `w2n_lang_en::W2nValue` (`int` / `float` / `Decimal`) to Python.
|
|
59
59
|
/// `PyDec`'s `Display` is Python's `str(Decimal)`, so `Decimal(pydec.to_string())`
|
|
60
60
|
/// reproduces the value (including a signed zero) exactly.
|
|
61
|
-
fn en_value_to_py(py: Python<'_>, v: w2n_lang_en::W2nValue) -> PyResult<
|
|
61
|
+
fn en_value_to_py(py: Python<'_>, v: w2n_lang_en::W2nValue) -> PyResult<Py<PyAny>> {
|
|
62
62
|
use w2n_lang_en::W2nValue;
|
|
63
63
|
match v {
|
|
64
64
|
W2nValue::Int(i) => i.into_py_any(py),
|
|
@@ -69,7 +69,7 @@ fn en_value_to_py(py: Python<'_>, v: w2n_lang_en::W2nValue) -> PyResult<PyObject
|
|
|
69
69
|
|
|
70
70
|
/// Convert a `w2n_formats::W2nValue` to Python. `parse_number_string` only ever
|
|
71
71
|
/// yields `int` / `float`; the `Decimal` arm is here for completeness.
|
|
72
|
-
fn formats_value_to_py(py: Python<'_>, v: w2n_formats::W2nValue) -> PyResult<
|
|
72
|
+
fn formats_value_to_py(py: Python<'_>, v: w2n_formats::W2nValue) -> PyResult<Py<PyAny>> {
|
|
73
73
|
use w2n_formats::W2nValue;
|
|
74
74
|
match v {
|
|
75
75
|
W2nValue::Int(i) => i.into_py_any(py),
|
|
@@ -79,7 +79,7 @@ fn formats_value_to_py(py: Python<'_>, v: w2n_formats::W2nValue) -> PyResult<PyO
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/// Convert a `w2n_sentence::W2nValue` (`int` / `float` / `Decimal`) to Python.
|
|
82
|
-
fn sentence_value_to_py(py: Python<'_>, v: &w2n_sentence::W2nValue) -> PyResult<
|
|
82
|
+
fn sentence_value_to_py(py: Python<'_>, v: &w2n_sentence::W2nValue) -> PyResult<Py<PyAny>> {
|
|
83
83
|
use w2n_sentence::W2nValue;
|
|
84
84
|
match v {
|
|
85
85
|
W2nValue::Int(i) => i.clone().into_py_any(py),
|
|
@@ -109,7 +109,7 @@ fn w2nvalue_from_py(obj: &Bound<'_, PyAny>) -> PyResult<w2n_sentence::W2nValue>
|
|
|
109
109
|
/// Build a `words2num2.converters.auto.Quantity` from the core struct. The
|
|
110
110
|
/// dataclass stays defined in Python (it is a public API type users import);
|
|
111
111
|
/// only the parsing logic lives in the core.
|
|
112
|
-
fn quantity_to_py(py: Python<'_>, q: w2n_sentence::Quantity) -> PyResult<
|
|
112
|
+
fn quantity_to_py(py: Python<'_>, q: w2n_sentence::Quantity) -> PyResult<Py<PyAny>> {
|
|
113
113
|
let value = sentence_value_to_py(py, &q.value)?;
|
|
114
114
|
let cls = py
|
|
115
115
|
.import("words2num2.converters.auto")?
|
|
@@ -164,7 +164,7 @@ fn parse_int(s: &str) -> PyResult<i64> {
|
|
|
164
164
|
|
|
165
165
|
/// `_rust.en_to_cardinal(text)`.
|
|
166
166
|
#[pyfunction]
|
|
167
|
-
fn en_to_cardinal(py: Python<'_>, text: &str) -> PyResult<
|
|
167
|
+
fn en_to_cardinal(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
|
|
168
168
|
match words2num2_core::en_to_cardinal(text) {
|
|
169
169
|
Ok(v) => en_value_to_py(py, v),
|
|
170
170
|
Err(e) => Err(words2num_error(py, e.msg)),
|
|
@@ -173,7 +173,7 @@ fn en_to_cardinal(py: Python<'_>, text: &str) -> PyResult<PyObject> {
|
|
|
173
173
|
|
|
174
174
|
/// `_rust.en_to_ordinal(text)`.
|
|
175
175
|
#[pyfunction]
|
|
176
|
-
fn en_to_ordinal(py: Python<'_>, text: &str) -> PyResult<
|
|
176
|
+
fn en_to_ordinal(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
|
|
177
177
|
match words2num2_core::en_to_ordinal(text) {
|
|
178
178
|
Ok(v) => en_value_to_py(py, v),
|
|
179
179
|
Err(e) => Err(words2num_error(py, e.msg)),
|
|
@@ -182,7 +182,7 @@ fn en_to_ordinal(py: Python<'_>, text: &str) -> PyResult<PyObject> {
|
|
|
182
182
|
|
|
183
183
|
/// `_rust.en_to_year(text)`.
|
|
184
184
|
#[pyfunction]
|
|
185
|
-
fn en_to_year(py: Python<'_>, text: &str) -> PyResult<
|
|
185
|
+
fn en_to_year(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
|
|
186
186
|
match words2num2_core::en_to_year(text) {
|
|
187
187
|
Ok(v) => en_value_to_py(py, v),
|
|
188
188
|
Err(e) => Err(words2num_error(py, e.msg)),
|
|
@@ -198,7 +198,7 @@ fn parse_number_string(
|
|
|
198
198
|
thousands_sep: Option<&str>,
|
|
199
199
|
decimal_sep: Option<&str>,
|
|
200
200
|
lang: Option<&str>,
|
|
201
|
-
) -> PyResult<
|
|
201
|
+
) -> PyResult<Py<PyAny>> {
|
|
202
202
|
match w2n_formats::parse_number_string(s, thousands_sep, decimal_sep, lang) {
|
|
203
203
|
Ok(v) => formats_value_to_py(py, v),
|
|
204
204
|
Err(e) => Err(words2num_error(py, e.0)),
|
|
@@ -220,7 +220,7 @@ fn words2num(
|
|
|
220
220
|
lang: &str,
|
|
221
221
|
to: &str,
|
|
222
222
|
kwargs: Option<Bound<'_, PyDict>>,
|
|
223
|
-
) -> PyResult<
|
|
223
|
+
) -> PyResult<Py<PyAny>> {
|
|
224
224
|
if kwargs.as_ref().is_some_and(|d| !d.is_empty()) {
|
|
225
225
|
return Err(PyTypeError::new_err(
|
|
226
226
|
"words2num() got an unexpected keyword argument",
|
|
@@ -267,7 +267,7 @@ fn auto_parse(
|
|
|
267
267
|
prefer: Option<HashMap<String, String>>,
|
|
268
268
|
thousands_sep: Option<&str>,
|
|
269
269
|
decimal_sep: Option<&str>,
|
|
270
|
-
) -> PyResult<
|
|
270
|
+
) -> PyResult<Py<PyAny>> {
|
|
271
271
|
let prefer = prefer.unwrap_or_default();
|
|
272
272
|
match w2n_sentence::auto_parse(text, lang, &prefer, thousands_sep, decimal_sep) {
|
|
273
273
|
Ok(q) => quantity_to_py(py, q),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Static version file. maturin (unlike setuptools_scm) does not regenerate
|
|
2
2
|
# this; the release CI sets pyproject's version from the git tag, and this is
|
|
3
3
|
# bumped alongside it so `words2num2.__version__` matches the wheel.
|
|
4
|
-
__version__ = "0.3.
|
|
5
|
-
__version_tuple__ = (0, 3,
|
|
4
|
+
__version__ = "0.3.3"
|
|
5
|
+
__version_tuple__ = (0, 3, 3)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|