UNPKG

decimal.js-i18n

Version:
594 lines (513 loc) 17.7 kB
/// <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