@khgtrn/lib 1.0.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 (37) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +113 -0
  3. package/dist/cjs/base-enum.d.ts +106 -0
  4. package/dist/cjs/base-enum.js +137 -0
  5. package/dist/cjs/func.d.ts +227 -0
  6. package/dist/cjs/func.js +574 -0
  7. package/dist/cjs/index.d.ts +4 -0
  8. package/dist/cjs/index.js +20 -0
  9. package/dist/cjs/number-to-words/helpers.d.ts +17 -0
  10. package/dist/cjs/number-to-words/helpers.js +72 -0
  11. package/dist/cjs/number-to-words/index.d.ts +30 -0
  12. package/dist/cjs/number-to-words/index.js +50 -0
  13. package/dist/cjs/number-to-words/locales.d.ts +7 -0
  14. package/dist/cjs/number-to-words/locales.js +106 -0
  15. package/dist/cjs/number-to-words/types.d.ts +35 -0
  16. package/dist/cjs/number-to-words/types.js +2 -0
  17. package/dist/cjs/package.json +4 -0
  18. package/dist/cjs/round.d.ts +13 -0
  19. package/dist/cjs/round.js +19 -0
  20. package/dist/esm/base-enum.d.ts +106 -0
  21. package/dist/esm/base-enum.js +133 -0
  22. package/dist/esm/func.d.ts +227 -0
  23. package/dist/esm/func.js +547 -0
  24. package/dist/esm/index.d.ts +4 -0
  25. package/dist/esm/index.js +4 -0
  26. package/dist/esm/number-to-words/helpers.d.ts +17 -0
  27. package/dist/esm/number-to-words/helpers.js +67 -0
  28. package/dist/esm/number-to-words/index.d.ts +30 -0
  29. package/dist/esm/number-to-words/index.js +47 -0
  30. package/dist/esm/number-to-words/locales.d.ts +7 -0
  31. package/dist/esm/number-to-words/locales.js +103 -0
  32. package/dist/esm/number-to-words/types.d.ts +35 -0
  33. package/dist/esm/number-to-words/types.js +1 -0
  34. package/dist/esm/package.json +4 -0
  35. package/dist/esm/round.d.ts +13 -0
  36. package/dist/esm/round.js +16 -0
  37. package/package.json +50 -0
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Checks whether a value is considered "empty".
3
+ *
4
+ * - string: empty after trimming.
5
+ * - number: equal to `0`.
6
+ * - boolean: `false`.
7
+ * - `null`/`undefined`: always empty.
8
+ * - array: length `0`.
9
+ * - other objects: no own enumerable keys.
10
+ * - anything else (function, symbol, ...): never empty.
11
+ *
12
+ * @param value - Value to check.
13
+ * @returns `true` if the value is considered empty.
14
+ */
15
+ export function isEmpty(value) {
16
+ if (typeof value === "string") {
17
+ return value.trim() === "";
18
+ }
19
+ else if (typeof value === "number") {
20
+ return value === 0;
21
+ }
22
+ else if (typeof value === "boolean") {
23
+ return !value;
24
+ }
25
+ else if (value === undefined || value === null) {
26
+ return true;
27
+ }
28
+ else if (Array.isArray(value)) {
29
+ return value.length === 0;
30
+ }
31
+ else if (typeof value === "object") {
32
+ return Object.keys(value).length === 0;
33
+ }
34
+ return false;
35
+ }
36
+ /**
37
+ * Checks whether a value is a valid number (not `NaN`, not `Infinity`).
38
+ * @param value - Value to check.
39
+ * @returns Type-guard: `true` if `value` is a finite number.
40
+ */
41
+ export function isNumber(value) {
42
+ return typeof value === "number" && Number.isFinite(value);
43
+ }
44
+ /**
45
+ * Converts Vietnamese diacritics to their plain ASCII equivalents
46
+ * (e.g. `"Điều chỉnh"` -> `"Dieu chinh"`).
47
+ *
48
+ * @param s - Input string.
49
+ * @returns The string with Vietnamese diacritics removed.
50
+ */
51
+ export function vi2en(s) {
52
+ return (s
53
+ // Decompose accented characters into base character + combining mark
54
+ .normalize("NFD")
55
+ // Strip all combining diacritical marks
56
+ .replace(/[\u0300-\u036f]/g, "")
57
+ // đ/Đ have no combining-mark decomposition, handle them separately
58
+ .replace(/đ/g, "d")
59
+ .replace(/Đ/g, "D"));
60
+ }
61
+ /**
62
+ * Normalizes Windows-style line endings (`\r\n`) to Unix-style (`\n`).
63
+ * @param value - Input string.
64
+ * @returns The string with all `\r\n` replaced by `\n`.
65
+ */
66
+ export function crlf2lf(value) {
67
+ return value.replace(/\r\n/g, "\n");
68
+ }
69
+ /**
70
+ * Removes every newline from a string, after normalizing line endings and
71
+ * trimming surrounding whitespace.
72
+ * @param value - Input string.
73
+ * @returns The string with all newlines removed.
74
+ */
75
+ export function removeNewline(value) {
76
+ return crlf2lf(value).trim().replace(/\n/g, "");
77
+ }
78
+ /**
79
+ * Shuffles an array in place using the Fisher-Yates algorithm.
80
+ * @param array - Array to shuffle (mutated directly).
81
+ * @returns The same array reference, shuffled.
82
+ */
83
+ export function shuffleArray(array) {
84
+ for (let i = array.length - 1; i > 0; i--) {
85
+ const j = Math.floor(Math.random() * (i + 1));
86
+ // Swap elements i and j
87
+ [array[i], array[j]] = [array[j], array[i]];
88
+ }
89
+ return array;
90
+ }
91
+ /**
92
+ * Collects an object's own enumerable values into an array.
93
+ * @param obj - Source object. `null`/`undefined` yields an empty array.
94
+ * @returns Array of the object's values.
95
+ */
96
+ export function objectValueToArray(obj) {
97
+ var arr = [];
98
+ for (var i in obj)
99
+ arr.push(obj[i]);
100
+ return arr;
101
+ }
102
+ /**
103
+ * Groups list items by the key returned from `fn`, similar to Lodash's
104
+ * `groupBy`. Keys are compared by their JSON representation, so they can be
105
+ * primitives or plain objects.
106
+ *
107
+ * @param list - Array-like list of items to group. `null`/`undefined` yields an empty array.
108
+ * @param fn - Maps an item to the value used to group it.
109
+ * @returns Array of groups, each an array of items sharing the same key.
110
+ */
111
+ export function groupBy(list, fn) {
112
+ if (!list)
113
+ return [];
114
+ var groups = {};
115
+ for (var i = 0; i < list.length; i++) {
116
+ var group = JSON.stringify(fn(list[i]));
117
+ if (group in groups) {
118
+ groups[group].push(list[i]);
119
+ }
120
+ else {
121
+ groups[group] = [list[i]];
122
+ }
123
+ }
124
+ return objectValueToArray(groups);
125
+ }
126
+ /**
127
+ * Remove keys from object or array
128
+ * @param objectOrArray Object or array
129
+ * @param keys Keys to remove
130
+ * @returns Object or array with keys removed
131
+ */
132
+ export function removeByKey(objectOrArray, keys) {
133
+ if (Array.isArray(objectOrArray)) {
134
+ return objectOrArray.map((item) => typeof item === "object" && item !== null ? removeByKey(item, keys) : item);
135
+ }
136
+ if (typeof objectOrArray === "object" && objectOrArray !== null) {
137
+ const entries = Object.entries(objectOrArray)
138
+ .filter(([key]) => !keys.includes(key))
139
+ .map(([key, value]) => {
140
+ if (Array.isArray(value) || (typeof value === "object" && value !== null)) {
141
+ return [key, removeByKey(value, keys)];
142
+ }
143
+ return [key, value];
144
+ });
145
+ return Object.fromEntries(entries);
146
+ }
147
+ // Primitives are returned as-is
148
+ return objectOrArray;
149
+ }
150
+ /**
151
+ * Remove empty values from object or array
152
+ * @param objectOrArray Object or array
153
+ * @param options Options:
154
+ * - removeNull: Remove null values. Default: true
155
+ * - removeUndefined: Remove undefined values. Default: true
156
+ * - removeEmptyString: Remove empty string values. Default: true
157
+ * @returns Object or array with empty values removed
158
+ */
159
+ export function removeEmptyValue(objectOrArray, options = {}) {
160
+ const { removeNull = true, removeUndefined = true, removeEmptyString = true } = options;
161
+ const shouldRemove = (value) => (removeNull && value === null) ||
162
+ (removeUndefined && value === undefined) ||
163
+ (removeEmptyString && value === "");
164
+ if (Array.isArray(objectOrArray)) {
165
+ const result = objectOrArray
166
+ .map((item) => {
167
+ if (Array.isArray(item) || (typeof item === "object" && item !== null)) {
168
+ return removeEmptyValue(item, {
169
+ removeNull,
170
+ removeUndefined,
171
+ removeEmptyString,
172
+ });
173
+ }
174
+ return item;
175
+ })
176
+ .filter((item) => !shouldRemove(item));
177
+ return result;
178
+ }
179
+ if (typeof objectOrArray === "object" && objectOrArray !== null) {
180
+ const entries = Object.entries(objectOrArray)
181
+ .map(([key, value]) => {
182
+ if (Array.isArray(value) || (typeof value === "object" && value !== null)) {
183
+ return [
184
+ key,
185
+ removeEmptyValue(value, {
186
+ removeNull,
187
+ removeUndefined,
188
+ removeEmptyString,
189
+ }),
190
+ ];
191
+ }
192
+ return [key, value];
193
+ })
194
+ .filter(([, value]) => !shouldRemove(value));
195
+ return Object.fromEntries(entries);
196
+ }
197
+ return objectOrArray;
198
+ }
199
+ /**
200
+ * Generates a random string of the given length, optionally guaranteeing at
201
+ * least one character from each requested category ("upper", "lower",
202
+ * "number", "specific").
203
+ *
204
+ * @param length - Desired length of the resulting string.
205
+ * @param opt - Character categories that must each appear at least once.
206
+ * `"specific"` only counts as a required category when `specificChars` is
207
+ * non-empty; otherwise it's silently ignored (as if not requested at all).
208
+ * @param specificChars - Custom character set used for the `"specific"` category.
209
+ * @returns A random string of exactly `length` characters. If `opt` is empty
210
+ * (or every requested category ends up unusable), falls back to alphanumeric
211
+ * characters rather than returning an empty string.
212
+ * @throws {Error} If `length` is smaller than the number of required categories,
213
+ * since the "at least one of each" guarantee couldn't fit otherwise.
214
+ */
215
+ export function randomString(length, opt = [], specificChars = "") {
216
+ const upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
217
+ const lowerChars = "abcdefghijklmnopqrstuvwxyz";
218
+ const numbers = "0123456789";
219
+ const requiredCategories = new Set(opt);
220
+ // Without specificChars, "specific" can't be honored, so treat it as unset
221
+ if (!specificChars) {
222
+ requiredCategories.delete("specific");
223
+ }
224
+ if (length < requiredCategories.size) {
225
+ throw new Error(`length (${length}) is too small to include one of each required category: ${[...requiredCategories].join(", ")}`);
226
+ }
227
+ let result = [];
228
+ let allChars = "";
229
+ const randomOneChar = (chars) => chars.charAt(Math.floor(Math.random() * chars.length));
230
+ if (requiredCategories.has("upper")) {
231
+ allChars += upperChars;
232
+ // Guarantee at least one uppercase letter
233
+ result.push(randomOneChar(upperChars));
234
+ }
235
+ if (requiredCategories.has("lower")) {
236
+ allChars += lowerChars;
237
+ // Guarantee at least one lowercase letter
238
+ result.push(randomOneChar(lowerChars));
239
+ }
240
+ if (requiredCategories.has("number")) {
241
+ allChars += numbers;
242
+ // Guarantee at least one digit
243
+ result.push(randomOneChar(numbers));
244
+ }
245
+ if (requiredCategories.has("specific")) {
246
+ allChars += specificChars;
247
+ // Guarantee at least one character from the custom set
248
+ result.push(randomOneChar(specificChars));
249
+ }
250
+ // If no category applied (e.g. opt is empty, or only "specific" was
251
+ // requested without specificChars), fall back to alphanumeric so the
252
+ // result isn't silently empty.
253
+ if (!allChars) {
254
+ allChars = upperChars + lowerChars + numbers;
255
+ }
256
+ // Fill up the remaining characters to reach the requested length
257
+ for (let i = result.length; i < length; i++) {
258
+ result.push(randomOneChar(allChars));
259
+ }
260
+ // Shuffle so the guaranteed characters aren't always at the front
261
+ return shuffleArray(result).join("");
262
+ }
263
+ /**
264
+ * Formats a byte value (0-255) as a zero-padded, 2-digit lowercase hex string.
265
+ * Intended for internal use with well-formed byte values (e.g. from a
266
+ * `Uint8Array`); values outside 0-255 are not validated.
267
+ *
268
+ * @param b - Byte value, expected in the 0-255 range.
269
+ * @returns 2-character hex string, e.g. `"0f"`.
270
+ */
271
+ export function byte2hex(b) {
272
+ return ("0" + b.toString(16)).slice(-2);
273
+ }
274
+ /**
275
+ * Generates the raw 16 bytes of a UUIDv7 (timestamp + random, RFC 4122
276
+ * variant). Relies on the Web Crypto `crypto.getRandomValues` API, available
277
+ * in browsers and modern Node.js.
278
+ *
279
+ * @returns 16-byte `Uint8Array` encoding a UUIDv7.
280
+ */
281
+ export function uuid7bin() {
282
+ var bytes = new Uint8Array(16);
283
+ var now = Date.now();
284
+ // 48-bit timestamp (ms). Division (rather than a left shift) is used so
285
+ // values above the 32-bit range bitwise operators support are still split
286
+ // correctly; `&` truncates the fractional part left over from the division.
287
+ bytes[0] = (now / 0x10000000000) & 0xff;
288
+ bytes[1] = (now / 0x100000000) & 0xff;
289
+ bytes[2] = (now / 0x1000000) & 0xff;
290
+ bytes[3] = (now / 0x10000) & 0xff;
291
+ bytes[4] = (now / 0x100) & 0xff;
292
+ bytes[5] = now & 0xff;
293
+ // random 10 bytes
294
+ var rnd = new Uint8Array(10);
295
+ crypto.getRandomValues(rnd);
296
+ for (var i = 0; i < 10; i++) {
297
+ bytes[i + 6] = rnd[i];
298
+ }
299
+ // version 7
300
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
301
+ // variant RFC4122
302
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
303
+ return bytes;
304
+ }
305
+ /**
306
+ * Generates a UUIDv7 string (e.g. `"01890a5d-ac96-774b-bcce-b302099a8057"`).
307
+ * @returns UUIDv7 in the standard 8-4-4-4-12 hex string format.
308
+ */
309
+ export function uuid7() {
310
+ var bin = uuid7bin();
311
+ var hex = [];
312
+ for (var i = 0; i < bin.length; i++) {
313
+ hex.push(byte2hex(bin[i]));
314
+ }
315
+ return [
316
+ hex.slice(0, 4).join(""),
317
+ hex.slice(4, 6).join(""),
318
+ hex.slice(6, 8).join(""),
319
+ hex.slice(8, 10).join(""),
320
+ hex.slice(10).join(""),
321
+ ].join("-");
322
+ }
323
+ /**
324
+ * Encodes a string to base64 (UTF-8 bytes).
325
+ * @param str - String to encode.
326
+ * @returns Base64-encoded string.
327
+ */
328
+ export function base64encode(str) {
329
+ const bytes = new TextEncoder().encode(str);
330
+ // Spreading the whole byte array into String.fromCharCode blows the call
331
+ // stack for large inputs, so build the binary string in bounded chunks.
332
+ const chunkSize = 0x8000;
333
+ let binary = "";
334
+ for (let i = 0; i < bytes.length; i += chunkSize) {
335
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
336
+ }
337
+ return btoa(binary);
338
+ }
339
+ /**
340
+ * Decodes a base64 string back to its original (UTF-8) string.
341
+ * @param base64 - Base64-encoded string.
342
+ * @returns Decoded string.
343
+ */
344
+ export function base64decode(base64) {
345
+ return new TextDecoder().decode(Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)));
346
+ }
347
+ /**
348
+ * Reads a nested property from an object using a dot-separated path.
349
+ * Example: `getObjectValue(user, 'abc.def')` returns `user.abc.def`.
350
+ *
351
+ * Equivalent to optional chaining (`object?.abc?.def`), for use on
352
+ * TypeScript versions below 3.7 (e.g. Angular versions below 9). On newer
353
+ * TypeScript versions, prefer optional chaining directly instead.
354
+ *
355
+ * @param object - Source object.
356
+ * @param path - Dot-separated property path.
357
+ * @returns The resolved value, or `null`/`undefined` if any segment is missing.
358
+ * @global
359
+ */
360
+ export function getObjectValue(object, path) {
361
+ if (!object)
362
+ return null;
363
+ return path
364
+ .split(".")
365
+ .reduce((acc, part) => acc && acc[part], object);
366
+ }
367
+ /**
368
+ * Short alias for {@link getObjectValue}.
369
+ * @param object - Source object.
370
+ * @param path - Dot-separated property path.
371
+ * @returns The resolved value, or `null`/`undefined` if any segment is missing.
372
+ */
373
+ export function ov(object, path) {
374
+ return getObjectValue(object, path);
375
+ }
376
+ /**
377
+ * Converts a value to an integer, similar to `parseInt`/`Number` but with an
378
+ * explicit fallback for empty values (see {@link isEmpty}).
379
+ *
380
+ * @param value - Value to convert.
381
+ * @param defaultValue - Value returned when `value` is empty. Defaults to `0`.
382
+ * @returns The parsed integer, `defaultValue` if `value` is empty, or `NaN`
383
+ * if `value` is a non-numeric, non-empty string.
384
+ */
385
+ export function toInt(value, defaultValue = 0) {
386
+ if (isEmpty(value))
387
+ return defaultValue;
388
+ return typeof value === "string" ? Number.parseInt(value.trim()) : Number(value);
389
+ }
390
+ /**
391
+ * Converts a positive integer to a Roman numeral. Only supports the standard
392
+ * range (1-3999); larger numbers produce a non-standard repeating "M" prefix.
393
+ *
394
+ * @param num - Number to convert.
395
+ * @returns Roman numeral string, or `""` if `num` is zero or negative.
396
+ */
397
+ export function numberToRoman(num) {
398
+ if (num <= 0)
399
+ return "";
400
+ const romanMap = [
401
+ { value: 1000, numeral: "M" },
402
+ { value: 900, numeral: "CM" },
403
+ { value: 500, numeral: "D" },
404
+ { value: 400, numeral: "CD" },
405
+ { value: 100, numeral: "C" },
406
+ { value: 90, numeral: "XC" },
407
+ { value: 50, numeral: "L" },
408
+ { value: 40, numeral: "XL" },
409
+ { value: 10, numeral: "X" },
410
+ { value: 9, numeral: "IX" },
411
+ { value: 5, numeral: "V" },
412
+ { value: 4, numeral: "IV" },
413
+ { value: 1, numeral: "I" },
414
+ ];
415
+ let result = "";
416
+ for (const item of romanMap) {
417
+ while (num >= item.value) {
418
+ result += item.numeral;
419
+ num -= item.value;
420
+ }
421
+ }
422
+ return result;
423
+ }
424
+ /**
425
+ * Converts a Roman numeral string to a number. Does not validate that the
426
+ * input is a well-formed Roman numeral (use {@link isRomanNumber} first if
427
+ * that matters) — unrecognized characters make the result `NaN`.
428
+ *
429
+ * @param roman - Roman numeral string (case-sensitive, uppercase letters).
430
+ * @returns The numeric value, or `NaN` if `roman` contains unrecognized characters.
431
+ */
432
+ export function romanToNumber(roman) {
433
+ const map = {
434
+ I: 1,
435
+ V: 5,
436
+ X: 10,
437
+ L: 50,
438
+ C: 100,
439
+ D: 500,
440
+ M: 1000,
441
+ };
442
+ let result = 0;
443
+ for (let i = 0; i < roman.length; i++) {
444
+ const current = map[roman.charAt(i)];
445
+ const next = map[roman.charAt(i + 1)];
446
+ if (next && current < next) {
447
+ result -= current;
448
+ }
449
+ else {
450
+ result += current;
451
+ }
452
+ }
453
+ return result;
454
+ }
455
+ /**
456
+ * Checks whether a string is a well-formed Roman numeral (1-3999), matching
457
+ * the standard subtractive notation. Case-insensitive.
458
+ *
459
+ * @param value - String to validate.
460
+ * @returns `true` if `value` is a valid Roman numeral.
461
+ */
462
+ export function isRomanNumber(value) {
463
+ if (!value)
464
+ return false;
465
+ const romanRegex = /^(M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3}))$/;
466
+ return romanRegex.test(value.toUpperCase());
467
+ }
468
+ /**
469
+ * Converts a base64 string to a `Blob`. Browser-only (relies on `atob` and
470
+ * `Blob`).
471
+ *
472
+ * @param base64 - Base64-encoded content.
473
+ * @param mimeType - MIME type to assign to the resulting `Blob`.
474
+ * @param sliceSize - Chunk size (in decoded characters) used while building
475
+ * the byte arrays, to avoid excessive memory allocation for large inputs.
476
+ * @returns A `Blob` containing the decoded bytes.
477
+ */
478
+ export function base64ToBlob(base64, mimeType, sliceSize = 512) {
479
+ const byteCharacters = atob(base64);
480
+ const bytes = [];
481
+ for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
482
+ const slice = byteCharacters.slice(offset, offset + sliceSize);
483
+ const byteNumbers = new Array(slice.length);
484
+ for (let i = 0; i < slice.length; i++) {
485
+ byteNumbers[i] = slice.charCodeAt(i);
486
+ }
487
+ const byte = new Uint8Array(byteNumbers);
488
+ bytes.push(byte);
489
+ }
490
+ return new Blob(bytes, { type: mimeType });
491
+ }
492
+ /**
493
+ * Triggers a browser download (or opens in a tab) for base64-encoded file
494
+ * content. Browser-only (relies on `document`, `URL.createObjectURL`).
495
+ *
496
+ * @param fileName - Suggested file name for the download.
497
+ * @param mimeType - MIME type/subtype (a bare subtype like `"pdf"` is
498
+ * expanded to `"application/pdf"`).
499
+ * @param base64Content - Base64-encoded file content.
500
+ * @param action - `"download"` saves the file, `"open"` opens it in the same
501
+ * tab, `"open_blank"` opens it in a new tab. Defaults to `"download"`.
502
+ */
503
+ export function downloadFile(fileName, mimeType, base64Content, action = "download") {
504
+ if (!mimeType.startsWith("application/")) {
505
+ mimeType = `application/${mimeType}`;
506
+ }
507
+ const blob = base64ToBlob(base64Content, mimeType);
508
+ const blobUrl = URL.createObjectURL(blob);
509
+ let link = document.createElement("a");
510
+ document.body.appendChild(link);
511
+ link.download = fileName;
512
+ if (action === "open_blank") {
513
+ link.target = "_blank";
514
+ }
515
+ else if (action === "open") {
516
+ link.target = "_self";
517
+ }
518
+ link.href = blobUrl;
519
+ link.click();
520
+ document.body.removeChild(link);
521
+ }
522
+ /**
523
+ * Recursively clones plain objects, arrays and `Date` instances.
524
+ *
525
+ * Note: `Date` has no own enumerable properties, so a plain for-in copy
526
+ * would silently turn every `Date` into an empty `{}` — it's special-cased
527
+ * here. Other special object types (`Map`, `Set`, `RegExp`, ...) are not
528
+ * handled and will also be cloned as plain `{}`.
529
+ *
530
+ * @param obj - Value to clone.
531
+ * @returns A deep copy of `obj` (primitives are returned as-is).
532
+ */
533
+ export function deepClone(obj) {
534
+ if (obj === null || typeof obj !== "object") {
535
+ return obj;
536
+ }
537
+ if (obj instanceof Date) {
538
+ return new Date(obj.getTime());
539
+ }
540
+ let clonedObj = Array.isArray(obj) ? [] : {};
541
+ for (let key in obj) {
542
+ if (obj.hasOwnProperty(key)) {
543
+ clonedObj[key] = deepClone(obj[key]);
544
+ }
545
+ }
546
+ return clonedObj;
547
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./base-enum";
2
+ export * from "./func";
3
+ export * from "./number-to-words";
4
+ export * from "./round";
@@ -0,0 +1,4 @@
1
+ export * from "./base-enum";
2
+ export * from "./func";
3
+ export * from "./number-to-words";
4
+ export * from "./round";
@@ -0,0 +1,17 @@
1
+ import { NumberToWordsDefinition } from "./types";
2
+ /** Spells out a non-negative integer using the given locale's rules. */
3
+ export declare function convertIntegerPart(value: number, definition: NumberToWordsDefinition, num: number | string, locale: string): string;
4
+ /** Spells out a fractional part one digit at a time, e.g. "05" -> "không năm". */
5
+ export declare function convertFractionalPart(fractionalDigits: string, definition: NumberToWordsDefinition): string;
6
+ /**
7
+ * Validates `num` and normalizes it to a plain (non-exponential, unsigned)
8
+ * numeric string with a `-` prefix kept only to signal the sign, e.g.
9
+ * `123` -> `"123"`, `"-1.50"` -> `"-1.50"`.
10
+ *
11
+ * A `number` is only accepted when it's an integer — a non-integer `number`
12
+ * has already gone through IEEE-754 float conversion by the time this
13
+ * function sees it (trailing zeros lost, rounding artifacts like
14
+ * `0.1 + 0.2`), so decimals must be passed as a string instead, which is
15
+ * read back digit-for-digit exactly as written.
16
+ */
17
+ export declare function normalizeNumericInput(num: number | string): string;
@@ -0,0 +1,67 @@
1
+ /** Splits a non-negative integer into groups of 3 digits, most significant first. */
2
+ function splitIntoGroups(num) {
3
+ if (num === 0)
4
+ return [0];
5
+ const groups = [];
6
+ let n = num;
7
+ while (n > 0) {
8
+ groups.unshift(n % 1000);
9
+ n = Math.floor(n / 1000);
10
+ }
11
+ return groups;
12
+ }
13
+ /** Spells out a non-negative integer using the given locale's rules. */
14
+ export function convertIntegerPart(value, definition, num, locale) {
15
+ if (value === 0)
16
+ return definition.zero;
17
+ const groups = splitIntoGroups(value);
18
+ const maxScaleIndex = groups.length - 1;
19
+ if (maxScaleIndex >= definition.scaleWords.length) {
20
+ throw new Error(`numberToWords: ${num} exceeds the supported range for locale "${locale}"`);
21
+ }
22
+ const segments = [];
23
+ let isLeadingGroup = true;
24
+ groups.forEach((group, i) => {
25
+ if (group === 0)
26
+ return;
27
+ const scaleIndex = maxScaleIndex - i;
28
+ const scaleWord = definition.scaleWords[scaleIndex];
29
+ const groupWords = definition.convertGroup(group, isLeadingGroup);
30
+ segments.push(scaleWord ? `${groupWords} ${scaleWord}` : groupWords);
31
+ isLeadingGroup = false;
32
+ });
33
+ return segments.join(" ");
34
+ }
35
+ /** Spells out a fractional part one digit at a time, e.g. "05" -> "không năm". */
36
+ export function convertFractionalPart(fractionalDigits, definition) {
37
+ return [...fractionalDigits].map((d) => definition.digits[Number(d)]).join(" ");
38
+ }
39
+ const numericStringPattern = /^-?\d+(\.\d+)?$/;
40
+ /**
41
+ * Validates `num` and normalizes it to a plain (non-exponential, unsigned)
42
+ * numeric string with a `-` prefix kept only to signal the sign, e.g.
43
+ * `123` -> `"123"`, `"-1.50"` -> `"-1.50"`.
44
+ *
45
+ * A `number` is only accepted when it's an integer — a non-integer `number`
46
+ * has already gone through IEEE-754 float conversion by the time this
47
+ * function sees it (trailing zeros lost, rounding artifacts like
48
+ * `0.1 + 0.2`), so decimals must be passed as a string instead, which is
49
+ * read back digit-for-digit exactly as written.
50
+ */
51
+ export function normalizeNumericInput(num) {
52
+ if (typeof num === "number") {
53
+ if (!Number.isFinite(num)) {
54
+ throw new Error(`numberToWords: ${num} is not a finite number`);
55
+ }
56
+ if (!Number.isInteger(num)) {
57
+ throw new Error(`numberToWords: ${num} is a non-integer number, which may have lost precision as a float ` +
58
+ `(trailing zeros, rounding) — pass it as a string instead, e.g. "${num}"`);
59
+ }
60
+ return num.toString();
61
+ }
62
+ const trimmed = num.trim();
63
+ if (!numericStringPattern.test(trimmed)) {
64
+ throw new Error(`numberToWords: "${num}" is not a valid numeric string`);
65
+ }
66
+ return trimmed;
67
+ }
@@ -0,0 +1,30 @@
1
+ import { NumberToWordsLocale } from "./types";
2
+ export { NumberToWordsLocale };
3
+ /**
4
+ * Spells out a number as words, following Vietnamese or English reading
5
+ * conventions (e.g. `1005` -> `"một nghìn không trăm linh năm"` in Vietnamese,
6
+ * `"one thousand five"` in English).
7
+ *
8
+ * Decimals are supported by passing `num` as a numeric **string** (e.g.
9
+ * `"1.05"`) rather than a `number` — a JS `number` can only exactly
10
+ * represent an integer here, so a non-integer `number` is rejected rather
11
+ * than silently read out with floating-point rounding artifacts (e.g.
12
+ * `0.1 + 0.2`). The fractional part is read one digit at a time after the
13
+ * locale's decimal separator word (e.g. `"1.05"` -> `"một phẩy không năm"`,
14
+ * `"one point zero five"`), keeping `1.05` distinct from `1.5` since a
15
+ * leading fractional zero changes the value. Trailing fractional zeros
16
+ * don't (`1.50 === 1.5`), so they're dropped — `"1.50"` reads the same as
17
+ * `"1.5"`.
18
+ *
19
+ * @param num - Number to convert, as an integer `number` or a numeric string
20
+ * (optionally with a decimal point, e.g. `"1.05"`). The integer part must be
21
+ * finite and within the supported range (currently up to just under 10^18,
22
+ * comfortably covering `Number.MAX_SAFE_INTEGER`).
23
+ * @param locale - Target language. Defaults to `"vi"`. To support another
24
+ * language, add an entry to the `definitions` registry in `locales.ts`.
25
+ * @returns The number spelled out in words.
26
+ * @throws {Error} If `num` is a non-integer `number`, an invalid numeric
27
+ * string, `locale` isn't registered, or the integer part exceeds the
28
+ * locale's supported range.
29
+ */
30
+ export declare function numberToWords(num: number | string, locale?: NumberToWordsLocale): string;