n2words 1.23.0 → 1.24.0

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.
Files changed (317) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +182 -53
  3. package/dist/languages/ar.js +2 -0
  4. package/dist/languages/ar.js.map +1 -0
  5. package/dist/languages/az.js +2 -0
  6. package/dist/languages/az.js.map +1 -0
  7. package/dist/languages/bn.js +2 -0
  8. package/dist/languages/bn.js.map +1 -0
  9. package/dist/languages/cs.js +2 -0
  10. package/dist/languages/cs.js.map +1 -0
  11. package/dist/languages/da.js +2 -0
  12. package/dist/languages/da.js.map +1 -0
  13. package/dist/languages/de.js +2 -0
  14. package/dist/languages/de.js.map +1 -0
  15. package/dist/languages/el.js +2 -0
  16. package/dist/languages/el.js.map +1 -0
  17. package/dist/languages/en.js +2 -0
  18. package/dist/languages/en.js.map +1 -0
  19. package/dist/languages/es.js +2 -0
  20. package/dist/languages/es.js.map +1 -0
  21. package/dist/languages/fa.js +2 -0
  22. package/dist/languages/fa.js.map +1 -0
  23. package/dist/languages/fil.js +2 -0
  24. package/dist/languages/fil.js.map +1 -0
  25. package/dist/languages/fr-BE.js +2 -0
  26. package/dist/languages/fr-BE.js.map +1 -0
  27. package/dist/languages/fr.js +2 -0
  28. package/dist/languages/fr.js.map +1 -0
  29. package/dist/languages/gu.js +2 -0
  30. package/dist/languages/gu.js.map +1 -0
  31. package/dist/languages/he.js +2 -0
  32. package/dist/languages/he.js.map +1 -0
  33. package/dist/languages/hi.js +2 -0
  34. package/dist/languages/hi.js.map +1 -0
  35. package/dist/languages/hr.js +2 -0
  36. package/dist/languages/hr.js.map +1 -0
  37. package/dist/languages/hu.js +2 -0
  38. package/dist/languages/hu.js.map +1 -0
  39. package/dist/languages/id.js +2 -0
  40. package/dist/languages/id.js.map +1 -0
  41. package/dist/languages/it.js +2 -0
  42. package/dist/languages/it.js.map +1 -0
  43. package/dist/languages/ja.js +2 -0
  44. package/dist/languages/ja.js.map +1 -0
  45. package/dist/languages/kn.js +2 -0
  46. package/dist/languages/kn.js.map +1 -0
  47. package/dist/languages/ko.js +2 -0
  48. package/dist/languages/ko.js.map +1 -0
  49. package/dist/languages/lt.js +2 -0
  50. package/dist/languages/lt.js.map +1 -0
  51. package/dist/languages/lv.js +2 -0
  52. package/dist/languages/lv.js.map +1 -0
  53. package/dist/languages/mr.js +2 -0
  54. package/dist/languages/mr.js.map +1 -0
  55. package/dist/languages/ms.js +2 -0
  56. package/dist/languages/ms.js.map +1 -0
  57. package/dist/languages/nb.js +2 -0
  58. package/dist/languages/nb.js.map +1 -0
  59. package/dist/languages/nl.js +2 -0
  60. package/dist/languages/nl.js.map +1 -0
  61. package/dist/languages/pa-Guru.js +2 -0
  62. package/dist/languages/pa-Guru.js.map +1 -0
  63. package/dist/languages/pl.js +2 -0
  64. package/dist/languages/pl.js.map +1 -0
  65. package/dist/languages/pt.js +2 -0
  66. package/dist/languages/pt.js.map +1 -0
  67. package/dist/languages/ro.js +2 -0
  68. package/dist/languages/ro.js.map +1 -0
  69. package/dist/languages/ru.js +2 -0
  70. package/dist/languages/ru.js.map +1 -0
  71. package/dist/languages/sr-Latn.js +2 -0
  72. package/dist/languages/sr-Latn.js.map +1 -0
  73. package/dist/languages/sv.js +2 -0
  74. package/dist/languages/sv.js.map +1 -0
  75. package/dist/languages/sw.js +2 -0
  76. package/dist/languages/sw.js.map +1 -0
  77. package/dist/languages/ta.js +2 -0
  78. package/dist/languages/ta.js.map +1 -0
  79. package/dist/languages/te.js +2 -0
  80. package/dist/languages/te.js.map +1 -0
  81. package/dist/languages/th.js +2 -0
  82. package/dist/languages/th.js.map +1 -0
  83. package/dist/languages/tr.js +2 -0
  84. package/dist/languages/tr.js.map +1 -0
  85. package/dist/languages/uk.js +2 -0
  86. package/dist/languages/uk.js.map +1 -0
  87. package/dist/languages/ur.js +2 -0
  88. package/dist/languages/ur.js.map +1 -0
  89. package/dist/languages/vi.js +2 -0
  90. package/dist/languages/vi.js.map +1 -0
  91. package/dist/languages/zh-Hans.js +2 -0
  92. package/dist/languages/zh-Hans.js.map +1 -0
  93. package/dist/n2words.js +1 -1
  94. package/dist/n2words.js.map +1 -1
  95. package/lib/classes/abstract-language.js +211 -110
  96. package/lib/classes/greedy-scale-language.js +195 -0
  97. package/lib/classes/slavic-language.js +251 -0
  98. package/lib/classes/south-asian-language.js +161 -0
  99. package/lib/classes/turkic-language.js +63 -0
  100. package/lib/languages/ar.js +243 -0
  101. package/lib/languages/az.js +58 -0
  102. package/lib/languages/bn.js +126 -0
  103. package/lib/languages/cs.js +212 -0
  104. package/lib/languages/da.js +167 -0
  105. package/lib/languages/de.js +135 -0
  106. package/lib/languages/el.js +116 -0
  107. package/lib/languages/en.js +123 -0
  108. package/lib/languages/es.js +153 -0
  109. package/lib/languages/fa.js +127 -0
  110. package/lib/languages/fil.js +162 -0
  111. package/lib/languages/fr-BE.js +61 -0
  112. package/lib/languages/fr.js +145 -0
  113. package/lib/languages/gu.js +156 -0
  114. package/lib/languages/he.js +329 -0
  115. package/lib/languages/hi.js +126 -0
  116. package/lib/languages/hr.js +157 -0
  117. package/lib/languages/hu.js +155 -0
  118. package/lib/languages/id.js +174 -0
  119. package/lib/languages/it.js +148 -0
  120. package/lib/languages/ja.js +190 -0
  121. package/lib/languages/kn.js +71 -0
  122. package/lib/languages/ko.js +83 -0
  123. package/lib/languages/lt.js +171 -0
  124. package/lib/languages/lv.js +153 -0
  125. package/lib/languages/mr.js +156 -0
  126. package/lib/languages/ms.js +146 -0
  127. package/lib/languages/nb.js +120 -0
  128. package/lib/languages/nl.js +206 -0
  129. package/lib/languages/pa-Guru.js +126 -0
  130. package/lib/languages/pl.js +189 -0
  131. package/lib/languages/pt.js +147 -0
  132. package/lib/languages/ro.js +380 -0
  133. package/lib/languages/ru.js +116 -0
  134. package/lib/languages/sr-Latn.js +157 -0
  135. package/lib/languages/sv.js +127 -0
  136. package/lib/languages/sw.js +121 -0
  137. package/lib/languages/ta.js +226 -0
  138. package/lib/languages/te.js +229 -0
  139. package/lib/languages/th.js +123 -0
  140. package/lib/languages/tr.js +83 -0
  141. package/lib/{i18n → languages}/uk.js +50 -23
  142. package/lib/languages/ur.js +126 -0
  143. package/lib/languages/vi.js +193 -0
  144. package/lib/languages/zh-Hans.js +165 -0
  145. package/lib/n2words.js +246 -75
  146. package/package.json +80 -72
  147. package/typings/classes/abstract-language.d.ts +144 -0
  148. package/typings/classes/greedy-scale-language.d.ts +148 -0
  149. package/typings/classes/slavic-language.d.ts +145 -0
  150. package/typings/classes/south-asian-language.d.ts +101 -0
  151. package/typings/classes/turkic-language.d.ts +42 -0
  152. package/typings/languages/ar.d.ts +93 -0
  153. package/typings/languages/az.d.ts +25 -0
  154. package/typings/languages/bn.d.ts +1 -0
  155. package/typings/languages/cs.d.ts +120 -0
  156. package/typings/languages/da.d.ts +53 -0
  157. package/typings/languages/de.d.ts +26 -0
  158. package/typings/languages/el.d.ts +11 -0
  159. package/typings/languages/en.d.ts +30 -0
  160. package/typings/languages/es.d.ts +43 -0
  161. package/typings/languages/fa.d.ts +81 -0
  162. package/typings/languages/fil.d.ts +12 -0
  163. package/typings/languages/fr-BE.d.ts +41 -0
  164. package/typings/languages/fr.d.ts +43 -0
  165. package/typings/languages/gu.d.ts +12 -0
  166. package/typings/languages/he.d.ts +197 -0
  167. package/typings/languages/hi.d.ts +1 -0
  168. package/typings/languages/hr.d.ts +110 -0
  169. package/typings/languages/hu.d.ts +37 -0
  170. package/typings/languages/id.d.ts +69 -0
  171. package/typings/languages/it.d.ts +51 -0
  172. package/typings/languages/ja.d.ts +58 -0
  173. package/typings/languages/kn.d.ts +11 -0
  174. package/typings/languages/ko.d.ts +25 -0
  175. package/typings/languages/lt.d.ts +110 -0
  176. package/typings/languages/lv.d.ts +99 -0
  177. package/typings/languages/mr.d.ts +12 -0
  178. package/typings/languages/ms.d.ts +37 -0
  179. package/typings/languages/nb.d.ts +27 -0
  180. package/typings/languages/nl.d.ts +65 -0
  181. package/typings/languages/pa-Guru.d.ts +1 -0
  182. package/typings/languages/pl.d.ts +116 -0
  183. package/typings/languages/pt.d.ts +39 -0
  184. package/typings/languages/ro.d.ts +229 -0
  185. package/typings/languages/ru.d.ts +108 -0
  186. package/typings/languages/sr-Latn.d.ts +98 -0
  187. package/typings/languages/sv.d.ts +30 -0
  188. package/typings/languages/sw.d.ts +1 -0
  189. package/typings/languages/ta.d.ts +1 -0
  190. package/typings/languages/te.d.ts +1 -0
  191. package/typings/languages/th.d.ts +1 -0
  192. package/typings/languages/tr.d.ts +46 -0
  193. package/typings/languages/uk.d.ts +117 -0
  194. package/typings/languages/ur.d.ts +1 -0
  195. package/typings/languages/vi.d.ts +116 -0
  196. package/typings/languages/zh-Hans.d.ts +57 -0
  197. package/typings/n2words.d.ts +177 -0
  198. package/dist/ar.js +0 -2
  199. package/dist/ar.js.map +0 -1
  200. package/dist/az.js +0 -2
  201. package/dist/az.js.map +0 -1
  202. package/dist/cz.js +0 -2
  203. package/dist/cz.js.map +0 -1
  204. package/dist/de.js +0 -2
  205. package/dist/de.js.map +0 -1
  206. package/dist/dk.js +0 -2
  207. package/dist/dk.js.map +0 -1
  208. package/dist/en.js +0 -2
  209. package/dist/en.js.map +0 -1
  210. package/dist/es.js +0 -2
  211. package/dist/es.js.map +0 -1
  212. package/dist/fa.js +0 -2
  213. package/dist/fa.js.map +0 -1
  214. package/dist/fr-BE.js +0 -2
  215. package/dist/fr-BE.js.map +0 -1
  216. package/dist/fr.js +0 -2
  217. package/dist/fr.js.map +0 -1
  218. package/dist/he.js +0 -2
  219. package/dist/he.js.map +0 -1
  220. package/dist/hr.js +0 -2
  221. package/dist/hr.js.map +0 -1
  222. package/dist/hu.js +0 -2
  223. package/dist/hu.js.map +0 -1
  224. package/dist/id.js +0 -2
  225. package/dist/id.js.map +0 -1
  226. package/dist/it.js +0 -2
  227. package/dist/it.js.map +0 -1
  228. package/dist/ko.js +0 -2
  229. package/dist/ko.js.map +0 -1
  230. package/dist/lt.js +0 -2
  231. package/dist/lt.js.map +0 -1
  232. package/dist/lv.js +0 -2
  233. package/dist/lv.js.map +0 -1
  234. package/dist/n2words.d.ts +0 -2
  235. package/dist/nl.js +0 -2
  236. package/dist/nl.js.map +0 -1
  237. package/dist/no.js +0 -2
  238. package/dist/no.js.map +0 -1
  239. package/dist/pl.js +0 -2
  240. package/dist/pl.js.map +0 -1
  241. package/dist/pt.js +0 -2
  242. package/dist/pt.js.map +0 -1
  243. package/dist/ro.js +0 -2
  244. package/dist/ro.js.map +0 -1
  245. package/dist/ru.js +0 -2
  246. package/dist/ru.js.map +0 -1
  247. package/dist/sr.js +0 -2
  248. package/dist/sr.js.map +0 -1
  249. package/dist/tr.js +0 -2
  250. package/dist/tr.js.map +0 -1
  251. package/dist/uk.js +0 -2
  252. package/dist/uk.js.map +0 -1
  253. package/dist/vi.js +0 -2
  254. package/dist/vi.js.map +0 -1
  255. package/dist/zh.js +0 -2
  256. package/dist/zh.js.map +0 -1
  257. package/lib/classes/abstract-language.d.ts +0 -54
  258. package/lib/classes/base-language.d.ts +0 -58
  259. package/lib/classes/base-language.js +0 -172
  260. package/lib/i18n/ar.d.ts +0 -41
  261. package/lib/i18n/ar.js +0 -209
  262. package/lib/i18n/az.d.ts +0 -15
  263. package/lib/i18n/az.js +0 -66
  264. package/lib/i18n/cz.d.ts +0 -68
  265. package/lib/i18n/cz.js +0 -135
  266. package/lib/i18n/de.d.ts +0 -17
  267. package/lib/i18n/de.js +0 -103
  268. package/lib/i18n/dk.d.ts +0 -14
  269. package/lib/i18n/dk.js +0 -110
  270. package/lib/i18n/en.d.ts +0 -22
  271. package/lib/i18n/en.js +0 -86
  272. package/lib/i18n/es.d.ts +0 -16
  273. package/lib/i18n/es.js +0 -110
  274. package/lib/i18n/fa.d.ts +0 -54
  275. package/lib/i18n/fa.js +0 -106
  276. package/lib/i18n/fr-BE.d.ts +0 -11
  277. package/lib/i18n/fr-BE.js +0 -20
  278. package/lib/i18n/fr.d.ts +0 -15
  279. package/lib/i18n/fr.js +0 -99
  280. package/lib/i18n/he.d.ts +0 -61
  281. package/lib/i18n/he.js +0 -132
  282. package/lib/i18n/hr.d.ts +0 -68
  283. package/lib/i18n/hr.js +0 -129
  284. package/lib/i18n/hu.d.ts +0 -17
  285. package/lib/i18n/hu.js +0 -135
  286. package/lib/i18n/id.d.ts +0 -43
  287. package/lib/i18n/id.js +0 -156
  288. package/lib/i18n/it.d.ts +0 -29
  289. package/lib/i18n/it.js +0 -137
  290. package/lib/i18n/ko.d.ts +0 -15
  291. package/lib/i18n/ko.js +0 -56
  292. package/lib/i18n/lt.d.ts +0 -68
  293. package/lib/i18n/lt.js +0 -138
  294. package/lib/i18n/lv.d.ts +0 -57
  295. package/lib/i18n/lv.js +0 -120
  296. package/lib/i18n/nl.d.ts +0 -20
  297. package/lib/i18n/nl.js +0 -125
  298. package/lib/i18n/no.d.ts +0 -15
  299. package/lib/i18n/no.js +0 -77
  300. package/lib/i18n/pl.d.ts +0 -67
  301. package/lib/i18n/pl.js +0 -126
  302. package/lib/i18n/pt.d.ts +0 -26
  303. package/lib/i18n/pt.js +0 -118
  304. package/lib/i18n/ro.d.ts +0 -109
  305. package/lib/i18n/ro.js +0 -360
  306. package/lib/i18n/ru.d.ts +0 -30
  307. package/lib/i18n/ru.js +0 -198
  308. package/lib/i18n/sr.d.ts +0 -56
  309. package/lib/i18n/sr.js +0 -127
  310. package/lib/i18n/tr.d.ts +0 -15
  311. package/lib/i18n/tr.js +0 -64
  312. package/lib/i18n/uk.d.ts +0 -78
  313. package/lib/i18n/vi.d.ts +0 -70
  314. package/lib/i18n/vi.js +0 -151
  315. package/lib/i18n/zh.d.ts +0 -18
  316. package/lib/i18n/zh.js +0 -78
  317. package/lib/n2words.d.ts +0 -9
@@ -1,160 +1,261 @@
1
1
  /**
2
- * Creates new common language class that processes decimals separately.
3
- * Requires implementing `toCardinal`.
2
+ * Abstract base class for language converters.
3
+ *
4
+ * What this class handles:
5
+ * - Validates and normalizes caller input (`number | string | bigint`), rejecting NaN/invalid strings.
6
+ * - Splits sign, whole, and decimal parts, caching the whole part for languages that need it.
7
+ * - Delegates whole-number wording to `convertWholePart(wholeNumber)` implemented by subclasses.
8
+ * - Converts decimals via `decimalDigitsToWords()`, preserving leading zeros and supporting per-digit mode when `convertDecimalsPerDigit` is true.
9
+ *
10
+ * What subclasses must provide:
11
+ * - `convertWholePart(wholeNumber)` method implementation (required; abstract).
12
+ * - `negativeWord` class property (word preceding negative numbers, e.g., "minus").
13
+ * - `zeroWord` class property (word for digit 0, e.g., "zero").
14
+ * - `decimalSeparatorWord` class property (word between whole and decimal, e.g., "point").
15
+ * - `wordSeparator` class property (word separator in output, typically a space).
16
+ * Optional: override `convertDecimalsPerDigit`, `digits`, or `convertDigitToWord()` for custom behavior.
17
+ *
18
+ * This class stays minimal; language grammar lives in subclasses.
19
+ *
20
+ * @abstract
4
21
  */
5
22
  class AbstractLanguage {
6
- #negativeWord;
7
- #separatorWord;
8
- #zero;
9
- #spaceSeparator;
10
- #wholeNumber;
11
-
12
23
  /**
13
- * @param {object} options Options for class.
14
- * @param {string} [options.negativeWord] Word that precedes a negative number (if any).
15
- * @param {string} options.separatorWord Word that separates cardinal numbers (i.e. "and").
16
- * @param {string} options.zero Word for 0 (i.e. "zero").
17
- * @param {string} [options.spaceSeparator] Character that separates words.
24
+ * Word that precedes negative numbers (e.g., "minus", "negative", "moins").
25
+ * @type {string}
18
26
  */
19
- constructor(options) {
20
- // Merge supplied options with defaults
21
- options = Object.assign({
22
- negativeWord: '',
23
- separatorWord: '',
24
- zero: '',
25
- spaceSeparator: ' '
26
- }, options);
27
-
28
- // Make options available to class
29
- this.#negativeWord = options.negativeWord;
30
- this.#separatorWord = options.separatorWord;
31
- this.#zero = options.zero;
32
- this.#spaceSeparator = options.spaceSeparator;
33
- }
27
+ negativeWord = ''
34
28
 
35
29
  /**
36
- * @returns {string} Word that precedes a negative number (if any).
30
+ * Word that separates whole and decimal parts (e.g., "point", "virgule", "comma").
31
+ * @type {string}
37
32
  */
38
- get negativeWord() {
39
- return this.#negativeWord;
40
- }
33
+ decimalSeparatorWord = ''
41
34
 
42
35
  /**
43
- * @returns {string} Word that separates cardinal numbers (i.e. "and").
36
+ * Word representation for the digit 0 (e.g., "zero", "zéro", "null").
37
+ * @type {string}
44
38
  */
45
- get separatorWord() {
46
- return this.#separatorWord;
47
- }
39
+ zeroWord = ''
48
40
 
49
41
  /**
50
- * @returns {string} Word for 0 (i.e. "zero").
42
+ * Character(s) used to separate words in the output (typically a space).
43
+ * @type {string}
51
44
  */
52
- get zero() {
53
- return this.#zero;
54
- }
45
+ wordSeparator = ' '
55
46
 
56
47
  /**
57
- * @returns {string} Character that separates words.
48
+ * Cached whole number portion from the most recent conversion.
49
+ * Some languages need access to this value during conversion for
50
+ * pluralization rules or special cases (e.g., Czech, Hebrew).
51
+ * @type {bigint}
58
52
  */
59
- get spaceSeparator() {
60
- return this.#spaceSeparator;
61
- }
53
+ cachedWholeNumber = 0n
62
54
 
63
55
  /**
64
- * @returns {number} Input value without decimal.
56
+ * Whether to convert decimal digits individually rather than grouped.
57
+ * - `true`: Each digit converted separately (e.g., "05" → "zero five")
58
+ * - `false`: Leading zeros preserved, remaining grouped (e.g., "14" → "fourteen")
59
+ * Used by languages like Japanese, Thai, Tamil, Telugu.
60
+ * @type {boolean}
65
61
  */
66
- get wholeNumber() {
67
- return this.#wholeNumber;
68
- }
62
+ convertDecimalsPerDigit = false
69
63
 
70
64
  /**
71
- * Convert ONLY decimal portion of number (processing leading zeros) to a string array of cardinal numbers.
72
- * @param {string} decimal Decimal string to convert.
73
- * @returns {string} Value in written format.
65
+ * Optional array of digit words for direct lookup in `convertDigitToWord()`.
66
+ * - Length 10: indices 0–9 map directly to digit words
67
+ * - Length 9: indices 0–8 map to words for digits 1–9
68
+ * - `null`: Falls back to `convertWholePart()` for digit conversion
69
+ * @type {string[]|null}
74
70
  */
75
- decimalToCardinal(decimal) {
76
- const words = [];
77
-
78
- // Split decimal string into an array of characters
79
- const chars = [...decimal];
71
+ digits = null
80
72
 
81
- // Loop through characters adding leading zeros to words array
82
- let index = 0;
83
- while (index < chars.length && chars[index] === '0') {
84
- words.push(this.zero);
85
- index++;
86
- }
73
+ /**
74
+ * Convert a single decimal digit (0-9) to its word representation.
75
+ *
76
+ * Default behavior:
77
+ * - 0 returns the language's `zeroWord`
78
+ * - If a `digits` array is present, use it for direct lookup
79
+ * - Length 10: indices 0–9 map directly
80
+ * - Length 9: indices 1–9 map via `idx - 1`
81
+ * - Otherwise delegate to `convertWholePart(digit)`
82
+ *
83
+ * Subclasses may override this for custom logic.
84
+ *
85
+ * @protected
86
+ * @param {bigint} digit A single digit value (0-9) as BigInt
87
+ * @returns {string} The word representation of the digit
88
+ */
89
+ convertDigitToWord (digit) {
90
+ const idx = Number(digit)
91
+ if (idx === 0) return this.zeroWord
87
92
 
88
- // Prevent further processing if entire string was zeros
89
- if (index === chars.length) {
90
- return words;
93
+ if (Array.isArray(this.digits)) {
94
+ if (this.digits.length === 10) {
95
+ return this.digits[idx] ?? this.zeroWord
96
+ }
97
+ if (this.digits.length === 9) {
98
+ return this.digits[idx - 1] ?? this.zeroWord
99
+ }
91
100
  }
92
101
 
93
- // Convert and add remaining then return words array
94
- return [...words, this.toCardinal(BigInt(decimal))];
102
+ return this.convertWholePart(digit)
95
103
  }
96
104
 
97
105
  /**
98
- * Convert a number to cardinal form.
99
- * @param {number|string|bigint} value Number to be convert.
100
- * @returns {string} Value in written format.
101
- * @throws {Error} Value must be a valid number.
106
+ * Convert the decimal fractional digits into words.
107
+ *
108
+ * Behavior depends on the `convertDecimalsPerDigit` class property:
109
+ *
110
+ * **Per-digit mode** (`convertDecimalsPerDigit: true`):
111
+ * - Each decimal digit is converted individually using `convertDigitToWord()`
112
+ * - Example: "05" -> [zero, 'five'], "14" -> ['one', 'four']
113
+ * - Used by: Japanese, Thai, Tamil, Telugu
114
+ *
115
+ * **Grouped mode** (default, `convertDecimalsPerDigit: false`):
116
+ * - Leading zeros are preserved as individual `zeroWord` entries
117
+ * - Remaining digits are grouped and converted as a number
118
+ * - Example: "05" -> [zero, 'five'], "14" -> ['fourteen']
119
+ * - Used by: Most languages (English, Spanish, French, etc.)
120
+ *
121
+ * @protected
122
+ * @param {string} decimalString The decimal digits as a string (e.g. `'05'` for 3.05).
123
+ * @returns {Array<string>} Array of word tokens representing the fractional part.
124
+ *
125
+ * @example
126
+ * // Per-digit mode
127
+ * decimalDigitsToWords('05'); // -> [this.zeroWord, 'five']
128
+ * decimalDigitsToWords('14'); // -> ['one', 'four']
129
+ *
130
+ * // Grouped mode
131
+ * decimalDigitsToWords('05'); // -> [this.zeroWord, 'five']
132
+ * decimalDigitsToWords('14'); // -> ['fourteen']
102
133
  */
103
- floatToCardinal(value) {
104
- // Validate user input value and convert to string (excluding BigInt)
105
- if (typeof value == 'number') {
106
- if (Number.isNaN(value)) {
107
- throw new TypeError('NaN is not an accepted number.');
108
- }
109
- value = value.toString();
110
- } else if (typeof value == 'string') {
111
- value = value.trim();
112
- if (value.length === 0 || Number.isNaN(Number(value))) {
113
- throw new Error('"' + value + '" is not a valid number.');
134
+ decimalDigitsToWords (decimalString) {
135
+ const words = []
136
+ const len = decimalString.length
137
+
138
+ if (this.convertDecimalsPerDigit) {
139
+ for (let i = 0; i < len; i++) {
140
+ const decimalDigit = BigInt(decimalString[i])
141
+ words.push(this.convertDigitToWord(decimalDigit))
114
142
  }
115
- } else if (typeof value != 'bigint') {
116
- throw new TypeError('Invalid variable type: ' + typeof value);
143
+ return words
144
+ }
145
+
146
+ // Default grouped-decimal behavior with leading zero preservation
147
+ let i = 0
148
+ while (i < len && decimalString[i] === '0') {
149
+ words.push(this.zeroWord)
150
+ i++
117
151
  }
118
152
 
119
- let words = [];
120
- let wholeNumber;
121
- let decimalNumber;
153
+ if (i === len) return words
154
+
155
+ const remainingDigits = decimalString.slice(i)
156
+ words.push(this.convertWholePart(BigInt(remainingDigits)))
157
+
158
+ return words
159
+ }
160
+
161
+ /**
162
+ * Convert a numeric input into its language cardinal representation.
163
+ *
164
+ * This is the public entry point used by consumers. It normalizes the input
165
+ * (accepting `number | string | bigint`), validates it, splits sign and
166
+ * fractional parts, and delegates whole-number conversion to
167
+ * `convertWholePart(BigInt)` and fractional conversion to `decimalDigitsToWords()`.
168
+ *
169
+ * Errors and validation:
170
+ * - Passing `NaN` (as `number`) throws `TypeError`.
171
+ * - Passing a non-numeric `string` throws `Error`.
172
+ * - Passing an unsupported type throws `TypeError`.
173
+ *
174
+ * @public
175
+ * @param {number|string|bigint} value Numeric input to convert. Strings may include a single `.` decimal marker.
176
+ * @returns {string} The localized cardinal string.
177
+ * @throws {TypeError|Error} For invalid input as described above.
178
+ */
179
+ convertToWords (value) {
180
+ // Normalize and validate input
181
+ const inputType = typeof value
122
182
 
123
- // If negative number add negative word
124
- if (value < 0) {
125
- words.push(this.negativeWord);
183
+ if (inputType === 'number') {
184
+ if (Number.isNaN(value)) throw new TypeError('NaN is not an accepted number.')
185
+ value = value.toString()
186
+ } else if (inputType === 'string') {
187
+ value = value.trim()
188
+ if (value.length === 0 || Number.isNaN(Number(value))) { throw new Error('Invalid number format: "' + value + '"') }
189
+ } else if (inputType !== 'bigint') {
190
+ throw new TypeError(
191
+ 'Invalid variable type, expected number|string|bigint, received: ' + inputType
192
+ )
126
193
  }
127
194
 
128
- // Split value decimal (if any) then convert to BigInt
129
- if (typeof value == 'bigint') {
130
- wholeNumber = value;
195
+ const words = []
196
+
197
+ // Detect negativity and strip sign for further processing.
198
+ // Must check before type coercion for BigInt (comparison works but slice does not).
199
+ let isNegative = false
200
+ if (inputType === 'bigint') {
201
+ if (value < 0n) {
202
+ isNegative = true
203
+ value = -value
204
+ }
131
205
  } else {
132
- const splitValue = value.split('.');
133
- wholeNumber = BigInt(splitValue[0]);
134
- decimalNumber = splitValue[1];
206
+ // For strings, check first character before normalization
207
+ if (value[0] === '-') {
208
+ isNegative = true
209
+ value = value.slice(1)
210
+ }
135
211
  }
136
212
 
137
- // Convert whole number to positive (if negative)
138
- if (wholeNumber < 0) {
139
- wholeNumber = -wholeNumber;
213
+ // Extract whole and decimal parts based on type.
214
+ // BigInt has no decimal point; strings may contain a single '.'.
215
+ // Default whole part to '0' if empty (e.g., '.5' -> '0' + '.5')
216
+ let wholeNumber
217
+ let decimalPart
218
+ if (inputType === 'bigint') {
219
+ wholeNumber = value
220
+ } else {
221
+ const decimalPointIndex = value.indexOf('.')
222
+ if (decimalPointIndex === -1) {
223
+ wholeNumber = BigInt(value)
224
+ } else {
225
+ const wholePartString = value.slice(0, decimalPointIndex) || '0'
226
+ wholeNumber = BigInt(wholePartString)
227
+ decimalPart = value.slice(decimalPointIndex + 1)
228
+ }
140
229
  }
141
230
 
142
- // NOTE: Only needed for CZ
143
- this.#wholeNumber = wholeNumber;
231
+ this.cachedWholeNumber = wholeNumber
144
232
 
145
- // Add whole number in written form
146
- words = [...words, this.toCardinal(wholeNumber)];
233
+ if (isNegative) words.push(this.negativeWord)
147
234
 
148
- // Add decimal number in written form (if any)
149
- if (decimalNumber) {
150
- words.push(this.separatorWord);
235
+ words.push(this.convertWholePart(wholeNumber))
151
236
 
152
- words = [...words, ...this.decimalToCardinal(decimalNumber)];
237
+ // Append decimal portion if present (separator + fractional digits)
238
+ if (decimalPart) {
239
+ words.push(this.decimalSeparatorWord)
240
+ words.push(...this.decimalDigitsToWords(decimalPart))
153
241
  }
154
242
 
155
- // Join words with spaces
156
- return words.join(this.spaceSeparator);
243
+ return words.join(this.wordSeparator)
244
+ }
245
+
246
+ /**
247
+ * Convert a BigInt whole number to its cardinal word representation.
248
+ *
249
+ * This is a template method that subclasses MUST implement to provide
250
+ * language-specific number conversion logic.
251
+ *
252
+ * @abstract
253
+ * @param {bigint} wholeNumber The whole number part to convert
254
+ * @returns {string} The cardinal representation in the target language
255
+ */
256
+ convertWholePart (wholeNumber) {
257
+ throw new Error('convertWholePart() must be implemented by subclass')
157
258
  }
158
259
  }
159
260
 
160
- export default AbstractLanguage;
261
+ export default AbstractLanguage
@@ -0,0 +1,195 @@
1
+ import AbstractLanguage from './abstract-language.js'
2
+
3
+ /**
4
+ * @typedef {Object} WordSet
5
+ * @property {string} word - The language word or phrase
6
+ * @property {bigint} value - The numeric value represented by the word
7
+ */
8
+
9
+ /**
10
+ * @typedef {Array.<Array.<(bigint|string)>>} ScaleWordPairs
11
+ * Array of scale word pairs in descending order. Each pair contains:
12
+ * - [0]: BigInt numeric value
13
+ * - [1]: String word representation
14
+ * Must be ordered largest to smallest for the greedy algorithm.
15
+ */
16
+
17
+ /**
18
+ * Greedy scale language converter implementing the "highest-matching scale word" algorithm.
19
+ *
20
+ * Responsibilities:
21
+ * - Decompose a whole-number value into a sequence of scale word-sets.
22
+ * - Provide helpers to merge and post-process matched word-sets.
23
+ * - Inherits decimal handling from AbstractLanguage (supports grouped and per-digit
24
+ * modes via the `convertDecimalsPerDigit` class property).
25
+ *
26
+ * Subclass requirements:
27
+ * - Define `scaleWordPairs` (ordered descending) as `[BigInt, string]` tuples.
28
+ * - Implement `mergeScales(leftWordSet, rightWordSet)` to combine adjacent word-sets
29
+ * per language grammar.
30
+ *
31
+ * Scale words specification:
32
+ * - `scaleWordPairs` is an Array of 2-tuples: `[BigInt, string]` where the first element
33
+ * is the numeric value (BigInt) and the second is the word used for that value.
34
+ * - Scale words MUST be ordered from largest to smallest (descending) for the algorithm
35
+ * to function correctly.
36
+ *
37
+ * @abstract
38
+ * @extends AbstractLanguage
39
+ * @example
40
+ * // Example `scaleWordPairs` for English (descending order):
41
+ * // [[1000000000n, 'billion'], [1000000n, 'million'], [1000n, 'thousand'], [100n, 'hundred'], ..., [1n, 'one']]
42
+ */
43
+
44
+ class GreedyScaleLanguage extends AbstractLanguage {
45
+ /**
46
+ * Array of scale word pairs mapping numeric values to their word representations.
47
+ *
48
+ * Each element is a 2-tuple: `[BigInt, string]` where the first element is the
49
+ * numeric value and the second is the word for that value. The array MUST be
50
+ * ordered from largest to smallest (descending) for the greedy algorithm to work correctly.
51
+ *
52
+ * @type {Array.<Array.<(bigint|string)>>}
53
+ * @example
54
+ * // English scale words (descending order):
55
+ * // [[1000000000n, 'billion'], [1000000n, 'million'], [1000n, 'thousand'], [100n, 'hundred'], ..., [1n, 'one']]
56
+ */
57
+ scaleWordPairs
58
+
59
+ /**
60
+ * Return the word associated with an exact scale word-set numeric value.
61
+ *
62
+ * @param {bigint|number} scaleValue Numeric word-set object key (prefer BigInt for exact matching).
63
+ * @returns {string|undefined} The word for the provided scale value, or `undefined`.
64
+ */
65
+ getScaleWord (scaleValue) {
66
+ const matchingPair = this.scaleWordPairs.find((pair) => pair[0] === scaleValue)
67
+ return matchingPair?.[1]
68
+ }
69
+
70
+ /**
71
+ * Decompose a whole-number into a sequence of scale word-sets.
72
+ *
73
+ * This internal helper returns a nested structure that represents quantities and
74
+ * their matching scale words (e.g. `[{ 'one': 1n }, { 'hundred': 100n }, ...]`).
75
+ * The result is designed to be reduced by `mergeWordSets()` using language-specific `mergeScales()`.
76
+ *
77
+ * For quantities > 1, the multiplier is recursively decomposed. For quantity = 1,
78
+ * the implicit "one" is represented with `{ 'one': 1n }` and typically omitted during mergeScales().
79
+ *
80
+ * @protected
81
+ * @param {bigint} wholeNumber The integer value to decompose (BigInt preferred).
82
+ * @returns {Array<Object|Array>} An array of word-set objects and possibly nested arrays.
83
+ */
84
+ decomposeToScales (wholeNumber) {
85
+ const decomposeWordSets = []
86
+ let remainingValue = wholeNumber
87
+
88
+ do {
89
+ const matchingScaleWordPair = this.scaleWordPairs.find((scaleWordPair) => remainingValue >= scaleWordPair[0])
90
+ if (!matchingScaleWordPair) break
91
+
92
+ const multiplier = remainingValue === 0n ? 1n : remainingValue / matchingScaleWordPair[0]
93
+
94
+ if (multiplier === 1n) {
95
+ decomposeWordSets.push({ [this.getScaleWord(1n)]: 1n })
96
+ } else {
97
+ decomposeWordSets.push(this.decomposeToScales(multiplier))
98
+ }
99
+
100
+ decomposeWordSets.push({ [matchingScaleWordPair[1]]: matchingScaleWordPair[0] })
101
+
102
+ remainingValue = remainingValue === 0n ? 0n : remainingValue % matchingScaleWordPair[0]
103
+ } while (remainingValue > 0n)
104
+
105
+ return decomposeWordSets
106
+ }
107
+
108
+ /**
109
+ * Reduce a nested array of word-sets into a single merged word-set object.
110
+ *
111
+ * This method repeatedly applies the subclass `mergeScales()` operation until a single
112
+ * object remains. It normalizes nested arrays by recursively merging them.
113
+ *
114
+ * @protected
115
+ * @param {Array<Object|Array>} mergeWordSetsList Array of word-set objects and nested arrays.
116
+ * @returns {Object} Merged word-set where the single object key is the language string
117
+ * and its value is the numeric BigInt result for that string.
118
+ */
119
+ mergeWordSets (mergeWordSetsList) {
120
+ while (mergeWordSetsList.length > 1) {
121
+ const [firstWordSet, secondWordSet, ...remainingWordSets] = mergeWordSetsList
122
+
123
+ if (!Array.isArray(firstWordSet) && !Array.isArray(secondWordSet)) {
124
+ const merged = this.mergeScales(firstWordSet, secondWordSet)
125
+ mergeWordSetsList = remainingWordSets.length > 0 ? [merged, remainingWordSets] : [merged]
126
+ continue
127
+ }
128
+
129
+ const normalizedWordSets = mergeWordSetsList.map((wordSetElement) => {
130
+ if (!Array.isArray(wordSetElement)) return wordSetElement
131
+ return wordSetElement.length === 1 ? wordSetElement[0] : this.mergeWordSets(wordSetElement)
132
+ })
133
+
134
+ mergeWordSetsList = normalizedWordSets
135
+ }
136
+
137
+ return mergeWordSetsList[0]
138
+ }
139
+
140
+ /**
141
+ * Combine two adjacent word-sets into a single merged word-set.
142
+ *
143
+ * This is the core language-specific method that must be implemented by subclasses
144
+ * to define how adjacent word-sets are combined according to the language's grammar.
145
+ * For example, English combines "twenty" + "three" → "twenty-three", while
146
+ * French might combine "quatre-vingts" + "dix" → "quatre-vingt-dix".
147
+ *
148
+ * @abstract
149
+ * @protected
150
+ * @param {Object} leftWordSet Left operand as `{ word: bigint }` pair.
151
+ * @param {Object} rightWordSet Right operand as `{ word: bigint }` pair.
152
+ * @returns {Object} Single merged word-set object with combined text and numeric value.
153
+ *
154
+ * @example
155
+ * // English implementation might handle:
156
+ * // mergeScales({ 'twenty': 20n }, { 'three': 3n }) → { 'twenty-three': 23n }
157
+ * // mergeScales({ 'one': 1n }, { 'hundred': 100n }) → { 'one hundred': 100n }
158
+ */
159
+ mergeScales (leftWordSet, rightWordSet) {
160
+ throw new Error('mergeScales() must be implemented by subclass')
161
+ }
162
+
163
+ /**
164
+ * Final string post-processing hook.
165
+ *
166
+ * Subclasses may override to apply language-specific whitespace, punctuation or
167
+ * orthographic corrections.
168
+ *
169
+ * @protected
170
+ * @param {string} output Merged language string produced by `convertWholePart` flow.
171
+ * @returns {string} Final formatted string.
172
+ */
173
+ finalizeWords (output) {
174
+ return output.trimEnd()
175
+ }
176
+
177
+ /**
178
+ * Convert an integer value to its language-specific cardinal words.
179
+ *
180
+ * This method orchestrates decomposition, merging and final formatting. It does
181
+ * not handle decimals or sign; those concerns are implemented in
182
+ * `AbstractLanguage.convertToWords` which calls this helper for the whole-number part.
183
+ *
184
+ * @param {bigint|number} wholeNumber Whole-number value to convert (BigInt is preferred).
185
+ * @returns {string} The cardinal representation for `value` in the language.
186
+ */
187
+ convertWholePart (wholeNumber) {
188
+ const decomposeWordSets = this.decomposeToScales(wholeNumber)
189
+ const mergedWordSet = this.mergeWordSets(decomposeWordSets)
190
+ const resultString = Object.keys(mergedWordSet)[0]
191
+ return this.finalizeWords(resultString)
192
+ }
193
+ }
194
+
195
+ export default GreedyScaleLanguage