@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.
- package/LICENSE +7 -0
- package/README.md +113 -0
- package/dist/cjs/base-enum.d.ts +106 -0
- package/dist/cjs/base-enum.js +137 -0
- package/dist/cjs/func.d.ts +227 -0
- package/dist/cjs/func.js +574 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.js +20 -0
- package/dist/cjs/number-to-words/helpers.d.ts +17 -0
- package/dist/cjs/number-to-words/helpers.js +72 -0
- package/dist/cjs/number-to-words/index.d.ts +30 -0
- package/dist/cjs/number-to-words/index.js +50 -0
- package/dist/cjs/number-to-words/locales.d.ts +7 -0
- package/dist/cjs/number-to-words/locales.js +106 -0
- package/dist/cjs/number-to-words/types.d.ts +35 -0
- package/dist/cjs/number-to-words/types.js +2 -0
- package/dist/cjs/package.json +4 -0
- package/dist/cjs/round.d.ts +13 -0
- package/dist/cjs/round.js +19 -0
- package/dist/esm/base-enum.d.ts +106 -0
- package/dist/esm/base-enum.js +133 -0
- package/dist/esm/func.d.ts +227 -0
- package/dist/esm/func.js +547 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/number-to-words/helpers.d.ts +17 -0
- package/dist/esm/number-to-words/helpers.js +67 -0
- package/dist/esm/number-to-words/index.d.ts +30 -0
- package/dist/esm/number-to-words/index.js +47 -0
- package/dist/esm/number-to-words/locales.d.ts +7 -0
- package/dist/esm/number-to-words/locales.js +103 -0
- package/dist/esm/number-to-words/types.d.ts +35 -0
- package/dist/esm/number-to-words/types.js +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/round.d.ts +13 -0
- package/dist/esm/round.js +16 -0
- package/package.json +50 -0
package/dist/esm/func.js
ADDED
|
@@ -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,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;
|