runtime/shared/intlNumUtils.js (370 lines of code) (raw):

/** * (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. * * This file is shared between www and fbsource and www is the source of truth. * When you make change to this file on www, please make sure you test it on * fbsource and send a diff to update the files too so that the 2 versions are * kept in sync. * * Run the following command to sync the change from www to fbsource. * js1 upgrade www-shared -p fbt --local ~/www * * @flow strict-local * @typechecks * @format * @emails oncall+i18n_fbt_js */ // flowlint ambiguous-object-type:error import type { NumberingSystemData, StandardDecimalPatternInfo, } from 'NumberFormatConfig'; const FbtHooks = require('FbtHooks'); const NumberFormatConsts = require('NumberFormatConsts'); const escapeRegex = require('escapeRegex'); const DEFAULT_GROUPING_SIZE = 3; const CURRENCIES_WITH_DOTS = [ '\u0433\u0440\u043d.', '\u0434\u0435\u043d.', '\u043b\u0432.', '\u043c\u0430\u043d.', '\u0564\u0580.', '\u062c.\u0645.', '\u062f.\u0625.', '\u062f.\u0627.', '\u062f.\u0628.', '\u062f.\u062a.', '\u062f.\u062c.', '\u062f.\u0639.', '\u062f.\u0643.', '\u062f.\u0644.', '\u062f.\u0645.', '\u0631.\u0633.', '\u0631.\u0639.', '\u0631.\u0642.', '\u0631.\u064a.', '\u0644.\u0633.', '\u0644.\u0644.', '\u0783.', 'B/.', 'Bs.', 'Fr.', 'kr.', 'L.', 'p.', 'S/.', ]; const _regexCache: {[string]: RegExp} = {}; function _buildRegex(pattern: string): RegExp { if (!_regexCache[pattern]) { _regexCache[pattern] = new RegExp(pattern, 'i'); } return _regexCache[pattern]; } const matchCurrenciesWithDots = _buildRegex( CURRENCIES_WITH_DOTS.reduce((regex, representation, index) => { return regex + (index ? '|' : '') + '(' + escapeRegex(representation) + ')'; }, ''), ); /** * Format a number for string output. * * Calling this function directly is discouraged, unless you know * exactly what you're doing. Consider using `formatNumber` or * `formatNumberWithThousandDelimiters` below. */ function formatNumberRaw( value: number | string, decimals?: ?number, thousandDelimiter: string = '', decimalDelimiter: string = '.', minDigitsForThousandDelimiter: number = 0, standardPatternInfo: StandardDecimalPatternInfo = { primaryGroupSize: DEFAULT_GROUPING_SIZE, secondaryGroupSize: DEFAULT_GROUPING_SIZE, }, numberingSystemData?: ?NumberingSystemData, ): string { const primaryGroupingSize = standardPatternInfo.primaryGroupSize || DEFAULT_GROUPING_SIZE; const secondaryGroupingSize = standardPatternInfo.secondaryGroupSize || primaryGroupingSize; const digits = numberingSystemData && numberingSystemData.digits; let v; if (decimals == null) { v = value.toString(); } else if (typeof value === 'string') { v = truncateLongNumber(value, decimals); } else { v = _roundNumber(value, decimals); } const valueParts = v.split('.'); let wholeNumber = valueParts[0]; let decimal = valueParts[1]; if ( Math.abs(parseInt(wholeNumber, 10)).toString().length >= minDigitsForThousandDelimiter ) { let replaced = ''; const replaceWith = '$1' + thousandDelimiter + '$2$3'; const primaryPattern = '(\\d)(\\d{' + (primaryGroupingSize - 0) + '})($|\\D)'; replaced = wholeNumber.replace(_buildRegex(primaryPattern), replaceWith); if (replaced != wholeNumber) { wholeNumber = replaced; const secondaryPatternString = '(\\d)(\\d{' + (secondaryGroupingSize - 0) + '})(' + escapeRegex(thousandDelimiter) + ')'; const secondaryPattern = _buildRegex(secondaryPatternString); while ( (replaced = wholeNumber.replace(secondaryPattern, replaceWith)) != wholeNumber ) { wholeNumber = replaced; } } } if (digits != null) { wholeNumber = _replaceWithNativeDigits(wholeNumber, digits); decimal = decimal && _replaceWithNativeDigits(decimal, digits); } let result = wholeNumber; if (decimal) { result += decimalDelimiter + decimal; } return result; } function _replaceWithNativeDigits(number: string, digits: string): string { let result = ''; for (let ii = 0; ii < number.length; ++ii) { const d = digits[number.charCodeAt(ii) - 48]; /* 48 === '0' */ result += d !== undefined ? d : number[ii]; } return result; } /** * Format a number for string output. * * This will format a given number according to the user's locale. * Thousand delimiters will NOT be added, use * `formatNumberWithThousandDelimiters` if you want them to be added. * * You may optionally specify the number of decimal places that should * be displayed. For instance, pass `0` to round to the nearest * integer, `2` to round to nearest cent when displaying currency, etc. */ function formatNumber(value: number, decimals?: ?number): string { const NumberFormatConfig = NumberFormatConsts.get( FbtHooks.getViewerContext().locale, ); return formatNumberRaw( value, decimals, '', NumberFormatConfig.decimalSeparator, NumberFormatConfig.minDigitsForThousandsSeparator, NumberFormatConfig.standardDecimalPatternInfo, NumberFormatConfig.numberingSystemData, ); } /** * Format a number for string output. * * This will format a given number according to the user's locale. * Thousand delimiters will be added. Use `formatNumber` if you don't * want them to be added. * * You may optionally specify the number of decimal places that should * be displayed. For instance, pass `0` to round to the nearest * integer, `2` to round to nearest cent when displaying currency, etc. */ function formatNumberWithThousandDelimiters( value: number | string, decimals?: ?number, ): string { const NumberFormatConfig = NumberFormatConsts.get( FbtHooks.getViewerContext().locale, ); return formatNumberRaw( value, decimals, NumberFormatConfig.numberDelimiter, NumberFormatConfig.decimalSeparator, NumberFormatConfig.minDigitsForThousandsSeparator, NumberFormatConfig.standardDecimalPatternInfo, NumberFormatConfig.numberingSystemData, ); } /** * Calculate how many powers of 10 there are in a given number * I.e. 1.23 has 0, 100 and 999 have 2, and 1000 has 3. * Used in the inflation and rounding calculations below. */ function _getNumberOfPowersOfTen(value: number): number { return value && Math.floor(Math.log10(Math.abs(value))); } /** * Format a number for string output. * * This will format a given number according to the specified significant * figures. * * Also, specify the number of decimal places that should * be displayed. For instance, pass `0` to round to the nearest * integer, `2` to round to nearest cent when displaying currency, etc. * * Example: * > formatNumberWithLimitedSigFig(123456789, 0, 2) * "120,000,000" * > formatNumberWithLimitedSigFig(1.23456789, 2, 2) * "1.20" */ function formatNumberWithLimitedSigFig( value: number, decimals: ?number, numSigFigs: number, ): string { // First make the number sufficiently integer-like. const power = _getNumberOfPowersOfTen(value); let inflatedValue = value; if (power < numSigFigs) { inflatedValue = value * Math.pow(10, -power + numSigFigs); } // Now that we have a large enough integer, round to cut off some digits. const roundTo = Math.pow( 10, _getNumberOfPowersOfTen(inflatedValue) - numSigFigs + 1, ); let truncatedValue = Math.round(inflatedValue / roundTo) * roundTo; // Bring it back to whatever the number's magnitude was before. if (power < numSigFigs) { truncatedValue /= Math.pow(10, -power + numSigFigs); // Determine number of decimals based on sig figs if (decimals == null) { return formatNumberWithThousandDelimiters( truncatedValue, numSigFigs - power - 1, ); } } // Decimals return formatNumberWithThousandDelimiters(truncatedValue, decimals); } function _roundNumber(valueParam: number, decimalsParam?: number): string { const decimals = decimalsParam == null ? 0 : decimalsParam; const pow = Math.pow(10, decimals); let value = valueParam; value = Math.round(value * pow) / pow; value += ''; if (!decimals) { return value; } // if value is small and // was converted to scientific notation, don't append anything // as we are already done if (value.indexOf('e-') !== -1) { return value; } const pos = value.indexOf('.'); let zeros = 0; if (pos == -1) { value += '.'; zeros = decimals; } else { zeros = decimals - (value.length - pos - 1); } for (let i = 0, l = zeros; i < l; i++) { value += '0'; } return value; } const addZeros = (x, count) => { let result = x; for (let i = 0; i < count; i++) { result += '0'; } return result; }; function truncateLongNumber(number: string, decimals?: number): string { const pos = number.indexOf('.'); const dividend = pos === -1 ? number : number.slice(0, pos); const remainder = pos === -1 ? '' : number.slice(pos + 1); return decimals != null ? dividend + '.' + addZeros(remainder.slice(0, decimals), decimals - remainder.length) : dividend; } /** * Parse a number. * * If the number is preceded or followed by a currency symbol or other * letters, they will be ignored. * * A decimal delimiter should be passed to respect the user's locale. * * Calling this function directly is discouraged, unless you know * exactly what you're doing. Consider using `parseNumber` below. */ function parseNumberRaw( text: string, decimalDelimiter: string, numberDelimiter: string = '', ): ?number { // Replace numerals based on current locale data const digitsMap = _getNativeDigitsMap(); let _text = text; if (digitsMap) { _text = text .split('') .map((/*string*/ character) => digitsMap[character] || character) .join('') .trim(); } _text = _text.replace(/^[^\d]*\-/, '\u0002'); // preserve negative sign _text = _text.replace(matchCurrenciesWithDots, ''); // remove some currencies const decimalExp = escapeRegex(decimalDelimiter); const numberExp = escapeRegex(numberDelimiter); const isThereADecimalSeparatorInBetween = _buildRegex( '^[^\\d]*\\d.*' + decimalExp + '.*\\d[^\\d]*$', ); if (!isThereADecimalSeparatorInBetween.test(_text)) { const isValidWithDecimalBeforeHand = _buildRegex( '(^[^\\d]*)' + decimalExp + '(\\d*[^\\d]*$)', ); if (isValidWithDecimalBeforeHand.test(_text)) { _text = _text.replace(isValidWithDecimalBeforeHand, '$1\u0001$2'); return _parseCodifiedNumber(_text); } const isValidWithoutDecimal = _buildRegex( '^[^\\d]*[\\d ' + escapeRegex(numberExp) + ']*[^\\d]*$', ); if (!isValidWithoutDecimal.test(_text)) { _text = ''; } return _parseCodifiedNumber(_text); } const isValid = _buildRegex( '(^[^\\d]*[\\d ' + numberExp + ']*)' + decimalExp + '(\\d*[^\\d]*$)', ); _text = isValid.test(_text) ? _text.replace(isValid, '$1\u0001$2') : ''; return _parseCodifiedNumber(_text); } /** * A codified number has \u0001 in the place of a decimal separator and a * \u0002 in the place of a negative sign. */ function _parseCodifiedNumber(text: string): ?number { const _text = text .replace(/[^0-9\u0001\u0002]/g, '') // remove everything but numbers, // decimal separator and negative sign .replace('\u0001', '.') // restore decimal separator .replace('\u0002', '-'); // restore negative sign const value = Number(_text); return _text === '' || isNaN(value) ? null : value; } function _getNativeDigitsMap(): ?{[string]: string, ...} { const NumberFormatConfig = NumberFormatConsts.get( FbtHooks.getViewerContext().locale, ); const nativeDigitMap: {[string]: string} = {}; const digits = NumberFormatConfig.numberingSystemData && NumberFormatConfig.numberingSystemData.digits; if (digits == null) { return null; } for (let i = 0; i < digits.length; i++) { nativeDigitMap[digits.charAt(i)] = i.toString(); } return nativeDigitMap; } function parseNumber(text: string): ?number { const NumberFormatConfig = NumberFormatConsts.get( FbtHooks.getViewerContext().locale, ); return parseNumberRaw( text, NumberFormatConfig.decimalSeparator || '.', NumberFormatConfig.numberDelimiter, ); } const intlNumUtils = { formatNumber, formatNumberRaw, formatNumberWithThousandDelimiters, formatNumberWithLimitedSigFig, parseNumber, parseNumberRaw, truncateLongNumber, /** * Converts a float into a prettified string. e.g. 1000.5 => "1,000.5" * * @deprecated Use `intlNumber.formatNumberWithThousandDelimiters(num)` * instead. It automatically handles decimal and thousand delimiters and * gets edge cases for Norwegian and Spanish right. * */ getFloatString( num: string | number, thousandDelimiter: string, decimalDelimiter: string, ): string { const str = String(num); const pieces = str.split('.'); const intPart = intlNumUtils.getIntegerString(pieces[0], thousandDelimiter); if (pieces.length === 1) { return intPart; } return intPart + decimalDelimiter + pieces[1]; }, /** * Converts an integer into a prettified string. e.g. 1000 => "1,000" * * @deprecated Use `intlNumber.formatNumberWithThousandDelimiters(num, 0)` * instead. It automatically handles decimal thousand delimiters and gets * edge cases for Norwegian and Spanish right. * */ getIntegerString(num: string | number, thousandDelimiter: string): string { let delim = thousandDelimiter; if (delim === '') { if (__DEV__) { throw new Error('thousandDelimiter cannot be empty string!'); } delim = ','; } let str = String(num); const regex = /(\d+)(\d{3})/; while (regex.test(str)) { str = str.replace(regex, '$1' + delim + '$2'); } return str; }, }; module.exports = intlNumUtils;