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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: words2num2
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -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.2"
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.2"
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.23.5"
115
+ version = "0.29.2"
140
116
  source = "registry+https://github.com/rust-lang/crates.io-index"
141
- checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872"
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.23.5"
131
+ version = "0.29.2"
159
132
  source = "registry+https://github.com/rust-lang/crates.io-index"
160
- checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb"
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.23.5"
140
+ version = "0.29.2"
169
141
  source = "registry+https://github.com/rust-lang/crates.io-index"
170
- checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d"
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.23.5"
150
+ version = "0.29.2"
179
151
  source = "registry+https://github.com/rust-lang/crates.io-index"
180
- checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da"
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.23.5"
162
+ version = "0.29.2"
191
163
  source = "registry+https://github.com/rust-lang/crates.io-index"
192
- checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028"
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.12.16"
194
+ version = "0.13.5"
230
195
  source = "registry+https://github.com/rust-lang/crates.io-index"
231
- checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
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.2"
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.2"
240
+ version = "0.1.3"
282
241
  dependencies = [
283
242
  "bigdecimal",
284
243
  "num-bigint",
@@ -3,7 +3,7 @@ members = ["words2num2-core", "words2num2-py"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.2"
6
+ version = "0.1.3"
7
7
  edition = "2021"
8
8
  license = "LGPL-2.1-only"
9
9
  repository = "https://github.com/gladiaio/words2num2"
@@ -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 += &current * 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 addition, which is why "nine eleven" is
975
- /// 20 rather than 911.
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")` → `"118"`, **not** `"1999"`.
20
- //! The sentence walker calls `to_cardinal` (19 + 99), never `to_year`.
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: "two seven five" -> 14.
2457
- assert_eq!(s("Two seven five", "en", "cardinal", false).unwrap(), "14");
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.23", features = ["extension-module", "abi3-py38", "num-bigint"] }
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<PyObject> {
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<PyObject> {
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<PyObject> {
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<PyObject> {
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<PyObject> {
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<PyObject> {
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<PyObject> {
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<PyObject> {
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<PyObject> {
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<PyObject> {
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<PyObject> {
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.0"
5
- __version_tuple__ = (0, 3, 0)
4
+ __version__ = "0.3.3"
5
+ __version_tuple__ = (0, 3, 3)
File without changes
File without changes