|
1
|
|
|
/*! |
|
2
|
|
|
* Copyright (c) 2022 Pedro José Batista, licensed under the MIT License. |
|
3
|
|
|
* See the LICENSE.md file in the project root for more information. |
|
4
|
|
|
*/ |
|
5
|
1 |
|
import Decimal from "decimal.js"; |
|
6
|
|
|
import type BaseFormatOptions from "./baseOptions"; |
|
7
|
|
|
import type CompactDisplay from "./compactDisplay"; |
|
8
|
1 |
|
import { BIGINT_MODIFIERS, ECMA_LIMIT, PLAIN_MODIFIERS, SUPPORTED_LOCALES } from "./constants"; |
|
9
|
|
|
import type Currency from "./currency"; |
|
10
|
|
|
import type CurrencyDisplay from "./currencyDisplay"; |
|
11
|
|
|
import type CurrencySign from "./currencySign"; |
|
12
|
|
|
import type Locale from "./locale"; |
|
13
|
|
|
import type LocaleMatcher from "./localeMatcher"; |
|
14
|
|
|
import type Notation from "./notation"; |
|
15
|
|
|
import type NumberingSystem from "./numberingSystem"; |
|
16
|
|
|
import type FormatOptions from "./options"; |
|
17
|
1 |
|
import { extend, resolve, toEcma, validate } from "./options"; |
|
18
|
|
|
import type FormatPart from "./part"; |
|
19
|
1 |
|
import { exponents, fractions, integerGroups, integers, PartValue } from "./part"; |
|
20
|
|
|
import type FormatPartTypes from "./partTypes"; |
|
21
|
|
|
import type ResolvedFormatOptions from "./resolvedOptions"; |
|
22
|
|
|
import type SignDisplay from "./signDisplay"; |
|
23
|
|
|
import type Style from "./style"; |
|
24
|
|
|
import type TrailingZeroDisplay from "./trailingZeroDisplay"; |
|
25
|
|
|
import type Unit from "./unit"; |
|
26
|
|
|
import type UnitDisplay from "./unitDisplay"; |
|
27
|
|
|
import type UseGrouping from "./useGrouping"; |
|
28
|
|
|
|
|
29
|
1 |
|
const concatenate = <T extends PartValue>(filter: T[] | ((p: T) => boolean), parts: T[] = []) => { |
|
30
|
21693 |
|
if (typeof filter === "function") { |
|
31
|
16636 |
|
parts = parts.filter(filter); |
|
32
|
|
|
} else { |
|
33
|
5057 |
|
parts = filter; |
|
34
|
|
|
} |
|
35
|
|
|
|
|
36
|
30111 |
|
return parts.map(p => p.value).join(""); |
|
37
|
|
|
}; |
|
38
|
|
|
|
|
39
|
40 |
|
const pow10 = (exponent: Decimal.Value) => Decimal.pow(10, exponent); |
|
40
|
|
|
|
|
41
|
|
|
/** |
|
42
|
|
|
* The `Decimal.Format` object enables language-sensitive decimal number formatting. It is entirely based on |
|
43
|
|
|
* `Intl.NumberFormat`, with the options of the latter being 100% compatible with it. |
|
44
|
|
|
* |
|
45
|
|
|
* This class, however, extend the numeric digits constraints of `Intl.NumberFormat` from 21 to 1000000000 in |
|
46
|
|
|
* order to fully take advantage of the arbitrary-precision of `decimal.js`. |
|
47
|
|
|
* |
|
48
|
|
|
* @template N Numeric notation of formatting. |
|
49
|
|
|
* @template S Numeric style of formatting. |
|
50
|
|
|
*/ |
|
51
|
1 |
|
export class DecimalFormat<N extends Notation, S extends Style> { |
|
52
|
1 |
|
static readonly [Symbol.toPrimitive] = DecimalFormat; |
|
53
|
254 |
|
readonly [Symbol.toStringTag] = "Decimal.Format"; |
|
54
|
|
|
|
|
55
|
|
|
/** |
|
56
|
|
|
* Formats a number according to the locale and formatting options of this {@link DecimalFormat} object. |
|
57
|
|
|
* |
|
58
|
|
|
* @param value A valid [Decimal.Value](https://mikemcl.github.io/decimal.js/#decimal) to format. |
|
59
|
|
|
* @returns Formatted localized string. |
|
60
|
|
|
*/ |
|
61
|
|
|
readonly format: (value: Decimal.Value) => string; |
|
62
|
|
|
|
|
63
|
|
|
/** |
|
64
|
|
|
* Allows locale-aware formatting of strings produced by `Decimal.Format` formatters. |
|
65
|
|
|
* |
|
66
|
|
|
* @param value A valid [Decimal.Value](https://mikemcl.github.io/decimal.js/#decimal) to format. |
|
67
|
|
|
* @returns An array of objects containing the formatted number in parts. |
|
68
|
|
|
*/ |
|
69
|
|
|
readonly formatToParts: (value: Decimal.Value) => FormatPart[]; |
|
70
|
|
|
|
|
71
|
|
|
/** |
|
72
|
|
|
* Returns a new object with properties reflecting the locale and number formatting options computed during |
|
73
|
|
|
* initialization of this {@link Decimal.Format} object. |
|
74
|
|
|
* |
|
75
|
|
|
* @returns A new object with properties reflecting the locale and number formatting options computed |
|
76
|
|
|
* during the initialization of this object. |
|
77
|
|
|
*/ |
|
78
|
|
|
readonly resolvedOptions: () => ResolvedFormatOptions<N, S>; |
|
79
|
|
|
|
|
80
|
|
|
/** |
|
81
|
|
|
* Creates a new instance of the `Decimal.Format` object. |
|
82
|
|
|
* |
|
83
|
|
|
* @param locales A string with a [BCP 47](https://www.rfc-editor.org/info/bcp47) language tag, or an array |
|
84
|
|
|
* of such strings. |
|
85
|
|
|
* |
|
86
|
|
|
* For the general form and interpretation of this parameter, see the [Intl page on |
|
87
|
|
|
* MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl). |
|
88
|
|
|
* @param options Object used to configure the behavior of the string localization. |
|
89
|
|
|
* @throws `RangeError` when an invalid option is given. |
|
90
|
|
|
*/ |
|
91
|
|
|
constructor(locales?: Locale | Locale[], options?: FormatOptions<N, S>) { |
|
92
|
254 |
|
options ??= {}; |
|
93
|
|
|
|
|
94
|
|
|
// 1. Check if options do not extrapolate the limits of decimal.js |
|
95
|
254 |
|
const valid = validate(options); |
|
96
|
|
|
|
|
97
|
254 |
|
if (valid !== true) { |
|
98
|
|
|
// -> it will either be exactly true or contain an array with all faulty properties: |
|
99
|
5 |
|
throw new RangeError(`${valid.join()} value${valid.length === 1 ? " is" : "s are"} out of range."`); |
|
100
|
|
|
} |
|
101
|
|
|
|
|
102
|
|
|
// 2. Create a baseline native formatter native |
|
103
|
249 |
|
const ecmaOptions = toEcma(options); |
|
104
|
249 |
|
const ecmaFormat = new Intl.NumberFormat(locales, ecmaOptions); |
|
105
|
|
|
|
|
106
|
|
|
// 3. Resolve this object's options, using the native resolution as a baseline |
|
107
|
249 |
|
const resolved = resolve(options, ecmaFormat.resolvedOptions()); |
|
108
|
249 |
|
const { minimumIntegerDigits: minID, notation, rounding, style } = resolved; |
|
109
|
|
|
|
|
110
|
|
|
// 4. Create two auxiliary formatters: |
|
111
|
|
|
// One for the integer part, which can have up to a billion minimum digits... |
|
112
|
249 |
|
const bigintOptions = extend(ecmaOptions, BIGINT_MODIFIERS); |
|
113
|
249 |
|
const bigintFormat = new Intl.NumberFormat(locales, bigintOptions); |
|
114
|
|
|
|
|
115
|
|
|
// ...and another for a plain, localized reference, used for decimals and constants |
|
116
|
249 |
|
const plainOptions = extend(bigintOptions, PLAIN_MODIFIERS); |
|
117
|
249 |
|
const plainFormat = new Intl.NumberFormat(locales, plainOptions); |
|
118
|
|
|
|
|
119
|
|
|
// 5. Localized numeric constants |
|
120
|
249 |
|
const numbers = Array(10) |
|
121
|
|
|
.fill(null) |
|
122
|
2490 |
|
.map((_, index) => plainFormat.format(index)); |
|
123
|
249 |
|
const numberMatch = new RegExp("[" + numbers.join("") + "]", "g"); |
|
124
|
249 |
|
const minusSign = /−/gu; |
|
125
|
|
|
|
|
126
|
|
|
// 5.1. Localized zero and one used in substitutions |
|
127
|
249 |
|
const [zero, one] = numbers; |
|
128
|
|
|
|
|
129
|
|
|
// 5.2. Helper functions |
|
130
|
249 |
|
const indexOfValue = (value: string) => numbers.indexOf(value).toString(); |
|
131
|
249 |
|
const convert = (text: string) => text.replaceAll(numberMatch, indexOfValue).replaceAll(minusSign, "-"); |
|
132
|
249 |
|
const zeroFill = (size: number) => Array(size).fill(zero).join(""); |
|
133
|
249 |
|
const zeroTrim = (text: string, mode: "both" | "left" | "right" = "left", max: number | boolean = false) => { |
|
134
|
4159 |
|
let result = text; |
|
135
|
4159 |
|
let count = 0; |
|
136
|
|
|
|
|
137
|
4159 |
|
if (mode === "both" || mode === "left") |
|
138
|
4159 |
|
while (result[0] === zero && result.length > 1 && (max === false || count < max)) { |
|
139
|
335 |
|
result = result.slice(1); |
|
140
|
335 |
|
count++; |
|
141
|
|
|
} |
|
142
|
|
|
|
|
143
|
4159 |
|
if (mode === "both" || mode === "right") |
|
144
|
4 |
|
while (result[result.length - 1] === zero && result.length > 1 && (max === false || count < max)) { |
|
145
|
|
|
result = result.slice(0, -1); |
|
146
|
|
|
count++; |
|
147
|
|
|
} |
|
148
|
|
|
|
|
149
|
4159 |
|
return result; |
|
150
|
|
|
}; |
|
151
|
|
|
|
|
152
|
|
|
// #region Step 6. Main format method - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
153
|
249 |
|
const _formatToParts = (value: Decimal.Value) => { |
|
154
|
5079 |
|
value = new Decimal(value); |
|
155
|
5079 |
|
const sign = Decimal.sign(value); |
|
156
|
|
|
|
|
157
|
|
|
// 6.1. Create a baseline part array |
|
158
|
5079 |
|
const ecmaParts = ecmaFormat.formatToParts(value.toNumber()); |
|
159
|
|
|
|
|
160
|
|
|
// -> if the value is non-numeric or an infinity, the baseline is good enough |
|
161
|
5079 |
|
if ((value.isFinite && !value.isFinite()) || (value.isNaN && value.isNaN())) { |
|
162
|
920 |
|
return ecmaParts; |
|
163
|
|
|
} |
|
164
|
|
|
|
|
165
|
|
|
// 6.2. Splitting the parts for easier assembly |
|
166
|
4159 |
|
const ecmaExponentValue = concatenate(exponents, ecmaParts) || "0"; |
|
167
|
4159 |
|
const ecmaIntegerParts = ecmaParts.filter(integerGroups); |
|
168
|
4159 |
|
const ecmaIntegerTrimmed = zeroTrim(concatenate(integers, ecmaIntegerParts)); |
|
169
|
4159 |
|
const ecmaIntegerDigits = concatenate(integers, ecmaIntegerParts).length; |
|
170
|
4159 |
|
const ecmaIntegerTrimmedDigits = ecmaIntegerTrimmed.length; |
|
171
|
4159 |
|
const ecmaFractionValue = concatenate(fractions, ecmaParts); |
|
172
|
4159 |
|
const ecmaFractionDigits = ecmaFractionValue.length; |
|
173
|
|
|
|
|
174
|
|
|
// 6.3. Shifting exponents according to notation/style |
|
175
|
|
|
|
|
176
|
|
|
// 6.3.1. Compact notation: calculate the shift in integer digits, and therefore exponent |
|
177
|
4159 |
|
if (notation === "compact" && !value.eq(0)) { |
|
178
|
|
|
const baseInteger = value.abs().trunc().toFixed(); |
|
179
|
|
|
const baseIntegerDigits = baseInteger.length; |
|
180
|
|
|
const correctionDigits = baseIntegerDigits - ecmaIntegerTrimmedDigits; |
|
181
|
|
|
|
|
182
|
2 |
|
if (correctionDigits > 0) { |
|
183
|
|
|
value = value.mul(pow10(-correctionDigits)); |
|
184
|
|
|
} |
|
185
|
|
|
} |
|
186
|
|
|
|
|
187
|
|
|
// 6.3.2. Engr./Scientific notations: evaluate the exponent from the text |
|
188
|
4159 |
|
if ((notation === "engineering" || notation === "scientific") && ecmaExponentValue !== zero) { |
|
189
|
4 |
|
const exponential = new Decimal(convert(ecmaExponentValue)); |
|
190
|
4 |
|
value = value.mul(pow10(exponential.mul(-1))).abs().mul(sign); // prettier-ignore |
|
191
|
|
|
} |
|
192
|
|
|
|
|
193
|
|
|
// 6.3.3. Percent style: shift the value accordingly (non numeric parts will remain the same) |
|
194
|
4159 |
|
if (style === "percent") value = value.mul(100); |
|
195
|
|
|
|
|
196
|
|
|
// 6.4. Parsing the information about the numeric parts |
|
197
|
4159 |
|
const integer = value.abs().trunc().mul(sign); |
|
198
|
4159 |
|
const fraction = value.sub(integer).abs(); |
|
199
|
4159 |
|
const integerDigits = !value.eq(0) && integer.eq(0) ? 0 : value.abs().trunc().toFixed().length; |
|
200
|
4159 |
|
const fractionDigits = value.dp(); |
|
201
|
4159 |
|
const maxSD = resolved.maximumSignificantDigits ?? integerDigits + fractionDigits; |
|
202
|
4159 |
|
const maxFD = resolved.maximumFractionDigits ?? fractionDigits; |
|
203
|
4159 |
|
const minSD = resolved.minimumSignificantDigits ?? resolved.minimumFractionDigits! + minID; |
|
204
|
4159 |
|
const minFD = resolved.minimumFractionDigits ?? minSD - minID; |
|
205
|
|
|
|
|
206
|
|
|
// 6.5. Check for the possibility of the native formatter to have accomplished the desired output |
|
207
|
4159 |
|
const integerCheck = !ecmaIntegerParts.length || (minID <= ECMA_LIMIT && ecmaIntegerDigits >= minID); |
|
208
|
|
|
const fractionCheck = |
|
209
|
4159 |
|
ecmaFractionDigits >= fractionDigits && minFD < ECMA_LIMIT && ecmaFractionDigits >= minFD; |
|
210
|
|
|
|
|
211
|
|
|
// -> if the native formatter is good enough for our decimal value, leave it as-is |
|
212
|
4159 |
|
if (integerCheck && fractionCheck) { |
|
213
|
3666 |
|
return ecmaParts as FormatPart[]; |
|
214
|
|
|
} |
|
215
|
|
|
|
|
216
|
|
|
// 6.6. Create the integer value |
|
217
|
493 |
|
const integerParts = (() => { |
|
218
|
493 |
|
if (integerCheck) return ecmaIntegerParts; |
|
219
|
|
|
|
|
220
|
|
|
// Expanding the integer part |
|
221
|
19 |
|
const targetDigits = Math.max(integerDigits, minID); |
|
222
|
|
|
|
|
223
|
|
|
// Creates a base 10 power of the target digits |
|
224
|
19 |
|
const bigint = BigInt(pow10(targetDigits - 1).toFixed()); |
|
225
|
|
|
|
|
226
|
|
|
// Format using the bigint formatter and cut it before joining with the ECMA parts |
|
227
|
19 |
|
const bigintIntegerParts = bigintFormat.formatToParts(bigint).filter(integerGroups); |
|
228
|
|
|
|
|
229
|
|
|
// We need to replace the first 'one' (from the base 10 power) with a 'zero' |
|
230
|
19 |
|
bigintIntegerParts[0].value = bigintIntegerParts[0].value.replace(new RegExp(one), zero); |
|
231
|
|
|
|
|
232
|
|
|
// Merge the first part with the bigint part |
|
233
|
19 |
|
ecmaIntegerParts[0].value = |
|
234
|
|
|
bigintIntegerParts[ecmaIntegerParts.length - 1].value.slice(0, -ecmaIntegerParts[0].value.length) + |
|
235
|
|
|
ecmaIntegerParts[0].value; |
|
236
|
|
|
|
|
237
|
19 |
|
return [...bigintIntegerParts.slice(0, -ecmaIntegerParts.length), ...ecmaIntegerParts]; |
|
238
|
|
|
})(); |
|
239
|
|
|
|
|
240
|
|
|
// 6.7. Create the fraction value |
|
241
|
493 |
|
const fractionValue = (() => { |
|
242
|
493 |
|
if (fractionCheck) return ecmaFractionValue; |
|
243
|
|
|
|
|
244
|
|
|
// Simpler formatting if there is actually no fraction |
|
245
|
492 |
|
if (fraction.eq(0)) { |
|
246
|
17 |
|
return plainFormat.format(BigInt(pow10(minFD).toFixed())).slice(1); |
|
247
|
|
|
} |
|
248
|
|
|
|
|
249
|
|
|
// There are more digits in the number than in the formatting |
|
250
|
475 |
|
const value = fraction |
|
251
|
|
|
.toFixed() |
|
252
|
|
|
.slice(2) |
|
253
|
|
|
.split("") |
|
254
|
3163 |
|
.map(v => numbers[Number(v)]) |
|
255
|
|
|
.join(""); |
|
256
|
|
|
|
|
257
|
475 |
|
if (value.length > maxFD) { |
|
258
|
|
|
return fraction |
|
259
|
|
|
.toDP(maxFD, rounding) |
|
260
|
|
|
.mul(pow10(maxFD)) |
|
261
|
|
|
.toFixed() |
|
262
|
|
|
.split("") |
|
263
|
|
|
.map(v => numbers[Number(v)]) |
|
264
|
|
|
.join(""); |
|
265
|
|
|
} |
|
266
|
|
|
|
|
267
|
475 |
|
if (value.length < minFD) { |
|
268
|
15 |
|
return value + zeroFill(minFD - value.length); |
|
269
|
|
|
} |
|
270
|
|
|
|
|
271
|
460 |
|
return value; |
|
272
|
|
|
})(); |
|
273
|
|
|
|
|
274
|
|
|
// 6.8. Parsing the numeric fragments in a unified part array |
|
275
|
493 |
|
const result: FormatPart[] = []; |
|
276
|
493 |
|
let integerDone = false; |
|
277
|
493 |
|
let fractionDone = false; |
|
278
|
|
|
|
|
279
|
493 |
|
while (ecmaParts.length) { |
|
280
|
3915 |
|
const { type, value } = ecmaParts.shift()!; |
|
281
|
|
|
|
|
282
|
3915 |
|
if (type === "integer" || type === "group") { |
|
283
|
2631 |
|
if (!integerDone) { |
|
284
|
493 |
|
integerDone = true; |
|
285
|
493 |
|
result.push(...integerParts); |
|
286
|
|
|
} |
|
287
|
2631 |
|
continue; |
|
288
|
|
|
} |
|
289
|
|
|
|
|
290
|
1284 |
|
if (type === "fraction") { |
|
291
|
492 |
|
if (!fractionDone) { |
|
292
|
492 |
|
fractionDone = true; |
|
293
|
492 |
|
result.push({ type, value: fractionValue }); |
|
294
|
|
|
} |
|
295
|
492 |
|
continue; |
|
296
|
|
|
} |
|
297
|
|
|
|
|
298
|
792 |
|
result.push({ type, value }); |
|
299
|
|
|
} |
|
300
|
493 |
|
return result; |
|
301
|
|
|
}; |
|
302
|
|
|
//#endregion |
|
303
|
|
|
|
|
304
|
5057 |
|
this.format = value => concatenate(_formatToParts(value)); |
|
305
|
249 |
|
this.formatToParts = value => _formatToParts(value); |
|
306
|
249 |
|
this.resolvedOptions = () => ({ ...resolved }); |
|
307
|
|
|
} |
|
308
|
|
|
|
|
309
|
|
|
/** |
|
310
|
|
|
* Returns an array containing the default locales available to the environment, based on a default |
|
311
|
|
|
* dictionary of locales and regions. |
|
312
|
|
|
* |
|
313
|
|
|
* This method is non-standard method that is not available on `Intl` formatters. |
|
314
|
|
|
* |
|
315
|
|
|
* @returns Array of strings with the available locales. |
|
316
|
|
|
*/ |
|
317
|
|
|
static supportedLocales() { |
|
318
|
2 |
|
return SUPPORTED_LOCALES; |
|
319
|
|
|
} |
|
320
|
|
|
|
|
321
|
|
|
/** |
|
322
|
|
|
* Returns an array containing those of the provided locales that are supported without having to fall back |
|
323
|
|
|
* to the runtime's default locale. |
|
324
|
|
|
* |
|
325
|
|
|
* @template TNotation Numeric notation of formatting. |
|
326
|
|
|
* @template TStyle Numeric style of formatting. |
|
327
|
|
|
* @param locales A string with a BCP 47 language tag, or an array of such strings. For the general form |
|
328
|
|
|
* and interpretation of the locales argument, see the [Intl page on |
|
329
|
|
|
* MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl). |
|
330
|
|
|
* @param options Object used to configure the behavior of the string localization. |
|
331
|
|
|
* @returns Array of strings with the available locales. |
|
332
|
|
|
*/ |
|
333
|
|
|
static supportedLocalesOf<TNotation extends Notation = "standard", TStyle extends Style = "decimal">( |
|
334
|
|
|
locales: string | string[], |
|
335
|
|
|
options?: FormatOptions<TNotation, TStyle>, |
|
336
|
|
|
) { |
|
337
|
2 |
|
return Intl.NumberFormat.supportedLocalesOf(locales, options ? toEcma(options) : undefined) as Locale[]; |
|
338
|
|
|
} |
|
339
|
|
|
} |
|
340
|
|
|
|
|
341
|
|
|
// eslint-disable-next-line @typescript-eslint/no-namespace |
|
342
|
|
|
export declare namespace DecimalFormat { |
|
343
|
|
|
export type { |
|
344
|
|
|
BaseFormatOptions, |
|
345
|
|
|
CompactDisplay, |
|
346
|
|
|
Currency, |
|
347
|
|
|
CurrencyDisplay, |
|
348
|
|
|
CurrencySign, |
|
349
|
|
|
Locale, |
|
350
|
|
|
LocaleMatcher, |
|
351
|
|
|
Notation, |
|
352
|
|
|
NumberingSystem, |
|
353
|
|
|
FormatOptions, |
|
354
|
|
|
FormatPart, |
|
355
|
|
|
FormatPartTypes, |
|
356
|
|
|
ResolvedFormatOptions, |
|
357
|
|
|
SignDisplay, |
|
358
|
|
|
Style, |
|
359
|
|
|
TrailingZeroDisplay, |
|
360
|
|
|
Unit, |
|
361
|
|
|
UnitDisplay, |
|
362
|
|
|
UseGrouping, |
|
363
|
|
|
}; |
|
364
|
|
|
} |
|
365
|
|
|
|
|
366
|
|
|
export default DecimalFormat; |
|
367
|
|
|
|