decimal.js-i18n
Version:
Full internationalization support for decimal.js.
594 lines (513 loc) • 17.7 kB
JavaScript
/// <reference path="index.d.ts" />
/*!
* Copyright (c) 2022 Pedro José Batista, licensed under the MIT License.
* See the LICENSE.md file in the project root for more information.
*/
import Decimal from "decimal.js";
export { default as Decimal, default } from "decimal.js";
const BIGINT_MODIFIERS = {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
maximumSignificantDigits: undefined,
minimumSignificantDigits: undefined,
notation: "standard",
style: "decimal",
};
const PLAIN_MODIFIERS = {
minimumIntegerDigits: 1,
signDisplay: "never",
useGrouping: false,
};
const DECIMAL_LIMIT = 1e9;
const ECMA_LIMIT = 21;
const DEFAULT_OPTIONS = {
compactDisplay: "short",
currencyDisplay: "symbol",
currencySign: "standard",
maximumFractionDigits: 499,
minimumFractionDigits: 0,
minimumIntegerDigits: 1,
maximumSignificantDigits: 500,
minimumSignificantDigits: 1,
notation: "standard",
rounding: Decimal.ROUND_HALF_UP,
signDisplay: "auto",
style: "decimal",
unitDisplay: "short",
trailingZeroDisplay: "auto",
};
const LOCALES = [
"af-ZA",
"am-ET",
"ar-AE",
"ar-BH",
"ar-DZ",
"ar-EG",
"ar-IQ",
"ar-JO",
"ar-KW",
"ar-LB",
"ar-LY",
"ar-MA",
"arn-CL",
"ar-OM",
"ar-QA",
"ar-SA",
"ar-SD",
"ar-SY",
"ar-TN",
"ar-YE",
"as-IN",
"az-az",
"az-Cyrl-AZ",
"az-Latn-AZ",
"ba-RU",
"be-BY",
"bg-BG",
"bn-BD",
"bn-IN",
"bo-CN",
"br-FR",
"bs-Cyrl-BA",
"bs-Latn-BA",
"ca-ES",
"co-FR",
"cs-CZ",
"cy-GB",
"da-DK",
"de-AT",
"de-CH",
"de-DE",
"de-LI",
"de-LU",
"dsb-DE",
"dv-MV",
"el-CY",
"el-GR",
"en-029",
"en-AU",
"en-BZ",
"en-CA",
"en-cb",
"en-GB",
"en-IE",
"en-IN",
"en-JM",
"en-MT",
"en-MY",
"en-NZ",
"en-PH",
"en-SG",
"en-TT",
"en-US",
"en-ZA",
"en-ZW",
"es-AR",
"es-BO",
"es-CL",
"es-CO",
"es-CR",
"es-DO",
"es-EC",
"es-ES",
"es-GT",
"es-HN",
"es-MX",
"es-NI",
"es-PA",
"es-PE",
"es-PR",
"es-PY",
"es-SV",
"es-US",
"es-UY",
"es-VE",
"et-EE",
"eu-ES",
"fa-IR",
"fi-FI",
"fil-PH",
"fo-FO",
"fr-BE",
"fr-CA",
"fr-CH",
"fr-FR",
"fr-LU",
"fr-MC",
"fy-NL",
"ga-IE",
"gd-GB",
"gd-ie",
"gl-ES",
"gsw-FR",
"gu-IN",
"ha-Latn-NG",
"he-IL",
"hi-IN",
"hr-BA",
"hr-HR",
"hsb-DE",
"hu-HU",
"hy-AM",
"id-ID",
"ig-NG",
"ii-CN",
"in-ID",
"is-IS",
"it-CH",
"it-IT",
"iu-Cans-CA",
"iu-Latn-CA",
"iw-IL",
"ja-JP",
"ka-GE",
"kk-KZ",
"kl-GL",
"km-KH",
"kn-IN",
"kok-IN",
"ko-KR",
"ky-KG",
"lb-LU",
"lo-LA",
"lt-LT",
"lv-LV",
"mi-NZ",
"mk-MK",
"ml-IN",
"mn-MN",
"mn-Mong-CN",
"moh-CA",
"mr-IN",
"ms-BN",
"ms-MY",
"mt-MT",
"nb-NO",
"ne-NP",
"nl-BE",
"nl-NL",
"nn-NO",
"no-no",
"nso-ZA",
"oc-FR",
"or-IN",
"pa-IN",
"pl-PL",
"prs-AF",
"ps-AF",
"pt-BR",
"pt-PT",
"qut-GT",
"quz-BO",
"quz-EC",
"quz-PE",
"rm-CH",
"ro-mo",
"ro-RO",
"ru-mo",
"ru-RU",
"rw-RW",
"sah-RU",
"sa-IN",
"se-FI",
"se-NO",
"se-SE",
"si-LK",
"sk-SK",
"sl-SI",
"sma-NO",
"sma-SE",
"smj-NO",
"smj-SE",
"smn-FI",
"sms-FI",
"sq-AL",
"sr-BA",
"sr-CS",
"sr-Cyrl-BA",
"sr-Cyrl-CS",
"sr-Cyrl-ME",
"sr-Cyrl-RS",
"sr-Latn-BA",
"sr-Latn-CS",
"sr-Latn-ME",
"sr-Latn-RS",
"sr-ME",
"sr-RS",
"sr-sp",
"sv-FI",
"sv-SE",
"sw-KE",
"syr-SY",
"ta-IN",
"te-IN",
"tg-Cyrl-TJ",
"th-TH",
"tk-TM",
"tlh-QS",
"tn-ZA",
"tr-TR",
"tt-RU",
"tzm-Latn-DZ",
"ug-CN",
"uk-UA",
"ur-PK",
"uz-Cyrl-UZ",
"uz-Latn-UZ",
"uz-uz",
"vi-VN",
"wo-SN",
"xh-ZA",
"yo-NG",
"zh-CN",
"zh-HK",
"zh-MO",
"zh-SG",
"zh-TW",
"zu-ZA",
];
const SUPPORTED_LOCALES = Intl.NumberFormat.supportedLocalesOf(LOCALES);
// -> factor is a simple index shifting value (fractions go from 0 to 1e9-1, other from 1 to 1e9)
const forEachDigitsPropertyGenerator = function* (callback) {
yield callback("maximumFractionDigits", -1);
yield callback("minimumFractionDigits", -1);
yield callback("minimumIntegerDigits", 0);
yield callback("maximumSignificantDigits", 0);
yield callback("minimumSignificantDigits", 0);
}; // Spreads the generated values of `forEachDigitsPropertyGenerator` to an array
const forEachDigitsProperty = callback => [...forEachDigitsPropertyGenerator(callback)];
const extend = (options, ...modifiers) => Object.assign({ ...options }, ...modifiers);
const resolve = (options, ecma) => {
const result = { ...ecma };
forEachDigitsProperty(property => {
if (!(property in result)) {
return;
} // Gets the maximum in between both objects and the defaults
result[property] = Math.max(ecma[property] ?? 0, options[property] ?? DEFAULT_OPTIONS[property]);
}); // Parsed from group 1?
if (typeof result.maximumFractionDigits === "number") {
result.maximumFractionDigits = Math.max(result.minimumFractionDigits, result.maximumFractionDigits);
} // Or from group 2?
else {
result.maximumSignificantDigits = Math.max(result.minimumSignificantDigits, result.maximumSignificantDigits);
} // Our custom defaults:
result.rounding ??= options.rounding ?? DEFAULT_OPTIONS.rounding;
result.trailingZeroDisplay ??= options.trailingZeroDisplay ?? DEFAULT_OPTIONS.trailingZeroDisplay;
return result;
};
const toEcma = options => {
const result = { ...options };
forEachDigitsProperty((property, factor) => {
// Check if it already exists to prevent creating unnecessary properties
if (property in result && Number(result[property]) > ECMA_LIMIT + factor) {
result[property] = ECMA_LIMIT + factor;
}
});
return result;
};
const validate = options => {
const result = forEachDigitsProperty((property, factor) => {
if (property in options && Number(options[property]) > DECIMAL_LIMIT + factor) {
return property;
}
return true;
});
if (result.every(entry => entry === true)) {
return true;
}
return result.filter(entry => entry !== true);
};
// Filters:
const exponents = ({ type: t }) => t === "exponentInteger" || t === "exponentMinusSign";
const fractions = ({ type }) => type === "fraction";
const integerGroups = ({ type }) => type === "integer" || type === "group";
const integers = ({ type }) => type === "integer";
const concatenate = (filter, parts = []) => {
if (typeof filter === "function") {
parts = parts.filter(filter);
} else {
parts = filter;
}
return parts.map(p => p.value).join("");
};
const pow10 = exponent => Decimal.pow(10, exponent);
class DecimalFormat {
static [Symbol.toPrimitive] = DecimalFormat;
[Symbol.toStringTag] = "Decimal.Format";
format;
formatToParts;
resolvedOptions;
constructor(locales, options) {
options ??= {}; // 1. Check if options do not extrapolate the limits of decimal.js
const valid = validate(options);
if (valid !== true) {
// -> it will either be exactly true or contain an array with all faulty properties:
throw new RangeError(`${valid.join()} value${valid.length === 1 ? " is" : "s are"} out of range."`);
} // 2. Create a baseline native formatter native
const ecmaOptions = toEcma(options);
const ecmaFormat = new Intl.NumberFormat(locales, ecmaOptions); // 3. Resolve this object's options, using the native resolution as a baseline
const resolved = resolve(options, ecmaFormat.resolvedOptions());
const { minimumIntegerDigits: minID, notation, rounding, style } = resolved; // 4. Create two auxiliary formatters:
// One for the integer part, which can have up to a billion minimum digits...
const bigintOptions = extend(ecmaOptions, BIGINT_MODIFIERS);
const bigintFormat = new Intl.NumberFormat(locales, bigintOptions); // ...and another for a plain, localized reference, used for decimals and constants
const plainOptions = extend(bigintOptions, PLAIN_MODIFIERS);
const plainFormat = new Intl.NumberFormat(locales, plainOptions); // 5. Localized numeric constants
const numbers = Array(10)
.fill(null)
.map((_, index) => plainFormat.format(index));
const numberMatch = new RegExp("[" + numbers.join("") + "]", "g");
const minusSign = /−/gu; // 5.1. Localized zero and one used in substitutions
const [zero, one] = numbers; // 5.2. Helper functions
const indexOfValue = value => numbers.indexOf(value).toString();
const convert = text => text.replaceAll(numberMatch, indexOfValue).replaceAll(minusSign, "-");
const zeroFill = size => Array(size).fill(zero).join("");
const zeroTrim = (text, mode = "left", max = false) => {
let result = text;
let count = 0;
if (mode === "both" || mode === "left")
while (result[0] === zero && result.length > 1 && (max === false || count < max)) {
result = result.slice(1);
count++;
}
if (mode === "both" || mode === "right")
while (result[result.length - 1] === zero && result.length > 1 && (max === false || count < max)) {
result = result.slice(0, -1);
count++;
}
return result;
}; // #region Step 6. Main format method - - - - - - - - - - - - - - - - - - - - - - - - - - - -
const _formatToParts = value => {
value = new Decimal(value);
const sign = Decimal.sign(value); // 6.1. Create a baseline part array
const ecmaParts = ecmaFormat.formatToParts(value.toNumber()); // -> if the value is non-numeric or an infinity, the baseline is good enough
if ((value.isFinite && !value.isFinite()) || (value.isNaN && value.isNaN())) {
return ecmaParts;
} // 6.2. Splitting the parts for easier assembly
const ecmaExponentValue = concatenate(exponents, ecmaParts) || "0";
const ecmaIntegerParts = ecmaParts.filter(integerGroups);
const ecmaIntegerTrimmed = zeroTrim(concatenate(integers, ecmaIntegerParts));
const ecmaIntegerDigits = concatenate(integers, ecmaIntegerParts).length;
const ecmaIntegerTrimmedDigits = ecmaIntegerTrimmed.length;
const ecmaFractionValue = concatenate(fractions, ecmaParts);
const ecmaFractionDigits = ecmaFractionValue.length; // 6.3. Shifting exponents according to notation/style
// 6.3.1. Compact notation: calculate the shift in integer digits, and therefore exponent
if (notation === "compact" && !value.eq(0)) {
const baseInteger = value.abs().trunc().toFixed();
const baseIntegerDigits = baseInteger.length;
const correctionDigits = baseIntegerDigits - ecmaIntegerTrimmedDigits;
if (correctionDigits > 0) {
value = value.mul(pow10(-correctionDigits));
}
} // 6.3.2. Engr./Scientific notations: evaluate the exponent from the text
if ((notation === "engineering" || notation === "scientific") && ecmaExponentValue !== zero) {
const exponential = new Decimal(convert(ecmaExponentValue));
value = value.mul(pow10(exponential.mul(-1))).abs().mul(sign); // prettier-ignore
} // 6.3.3. Percent style: shift the value accordingly (non numeric parts will remain the same)
if (style === "percent") value = value.mul(100); // 6.4. Parsing the information about the numeric parts
const integer = value.abs().trunc().mul(sign);
const fraction = value.sub(integer).abs();
const integerDigits = !value.eq(0) && integer.eq(0) ? 0 : value.abs().trunc().toFixed().length;
const fractionDigits = value.dp();
resolved.maximumSignificantDigits ?? integerDigits + fractionDigits;
const maxFD = resolved.maximumFractionDigits ?? fractionDigits;
const minSD = resolved.minimumSignificantDigits ?? resolved.minimumFractionDigits + minID;
const minFD = resolved.minimumFractionDigits ?? minSD - minID; // 6.5. Check for the possibility of the native formatter to have accomplished the desired output
const integerCheck = !ecmaIntegerParts.length || (minID <= ECMA_LIMIT && ecmaIntegerDigits >= minID);
const fractionCheck =
ecmaFractionDigits >= fractionDigits && minFD < ECMA_LIMIT && ecmaFractionDigits >= minFD; // -> if the native formatter is good enough for our decimal value, leave it as-is
if (integerCheck && fractionCheck) {
return ecmaParts;
} // 6.6. Create the integer value
const integerParts = (() => {
if (integerCheck) return ecmaIntegerParts; // Expanding the integer part
const targetDigits = Math.max(integerDigits, minID); // Creates a base 10 power of the target digits
const bigint = BigInt(pow10(targetDigits - 1).toFixed()); // Format using the bigint formatter and cut it before joining with the ECMA parts
const bigintIntegerParts = bigintFormat.formatToParts(bigint).filter(integerGroups); // We need to replace the first 'one' (from the base 10 power) with a 'zero'
bigintIntegerParts[0].value = bigintIntegerParts[0].value.replace(new RegExp(one), zero); // Merge the first part with the bigint part
ecmaIntegerParts[0].value =
bigintIntegerParts[ecmaIntegerParts.length - 1].value.slice(0, -ecmaIntegerParts[0].value.length) +
ecmaIntegerParts[0].value;
return [...bigintIntegerParts.slice(0, -ecmaIntegerParts.length), ...ecmaIntegerParts];
})(); // 6.7. Create the fraction value
const fractionValue = (() => {
if (fractionCheck) return ecmaFractionValue; // Simpler formatting if there is actually no fraction
if (fraction.eq(0)) {
return plainFormat.format(BigInt(pow10(minFD).toFixed())).slice(1);
} // There are more digits in the number than in the formatting
const value = fraction
.toFixed()
.slice(2)
.split("")
.map(v => numbers[Number(v)])
.join("");
if (value.length > maxFD) {
return fraction
.toDP(maxFD, rounding)
.mul(pow10(maxFD))
.toFixed()
.split("")
.map(v => numbers[Number(v)])
.join("");
}
if (value.length < minFD) {
return value + zeroFill(minFD - value.length);
}
return value;
})(); // 6.8. Parsing the numeric fragments in a unified part array
const result = [];
let integerDone = false;
let fractionDone = false;
while (ecmaParts.length) {
const { type, value } = ecmaParts.shift();
if (type === "integer" || type === "group") {
if (!integerDone) {
integerDone = true;
result.push(...integerParts);
}
continue;
}
if (type === "fraction") {
if (!fractionDone) {
fractionDone = true;
result.push({
type,
value: fractionValue,
});
}
continue;
}
result.push({
type,
value,
});
}
return result;
}; //#endregion
this.format = value => concatenate(_formatToParts(value));
this.formatToParts = value => _formatToParts(value);
this.resolvedOptions = () => ({ ...resolved });
}
static supportedLocales() {
return SUPPORTED_LOCALES;
}
static supportedLocalesOf(locales, options) {
return Intl.NumberFormat.supportedLocalesOf(locales, options ? toEcma(options) : undefined);
}
}
const main = Decimal => {
// Do not attempt to redefine the module, if already extended
if (typeof Decimal.Format !== "undefined") {
return;
}
Object.defineProperty(Decimal, "Format", {
value: DecimalFormat,
});
Object.defineProperty(Decimal.prototype, "toLocaleString", {
value: function (locales, options) {
return new Decimal.Format(locales, options).format(this);
},
});
};
main(Decimal);
//# sourceMappingURL=index.mjs.map