diff --git a/src/ng/filter/filters.js b/src/ng/filter/filters.js index c37a11f8a488..3844e51b2d91 100644 --- a/src/ng/filter/filters.js +++ b/src/ng/filter/filters.js @@ -1,5 +1,9 @@ 'use strict'; +var MAX_DIGITS = 22; +var DECIMAL_SEP = '.'; +var ZERO_CHAR = '0'; + /** * @ngdoc filter * @name currency @@ -124,8 +128,6 @@ function currencyFilter($locale) { */ - - numberFilter.$inject = ['$locale']; function numberFilter($locale) { var formats = $locale.NUMBER_FORMATS; @@ -139,93 +141,194 @@ function numberFilter($locale) { }; } -var DECIMAL_SEP = '.'; -function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { - if (isObject(number)) return ''; +/** + * Parse a number (as a string) into three components that can be used + * for formatting the number. + * + * (Significant bits of this parse algorithm came from https://github.com/MikeMcl/big.js/) + * + * @param {string} numStr The number to parse + * @return {object} An object describing this number, containing the following keys: + * - d : an array of digits containing leading zeros as necessary + * - i : the number of the digits in `d` that are to the left of the decimal point + * - e : the exponent for numbers that would need more than `MAX_DIGITS` digits in `d` + * + */ +function parse(numStr) { + var exponent = 0, digits, numberOfIntegerDigits; + var i, j, zeros; + + // Decimal point? + if ((numberOfIntegerDigits = numStr.indexOf(DECIMAL_SEP)) > -1) { + numStr = numStr.replace(DECIMAL_SEP, ''); + } - var isNegative = number < 0; - number = Math.abs(number); + // Exponential form? + if ((i = numStr.search(/e/i)) > 0) { + // Work out the exponent. + if (numberOfIntegerDigits < 0) numberOfIntegerDigits = i; + numberOfIntegerDigits += +numStr.slice(i + 1); + numStr = numStr.substring(0, i); + } else if (numberOfIntegerDigits < 0) { + // There was no decimal point or exponent so it is an integer. + numberOfIntegerDigits = numStr.length; + } - var isInfinity = number === Infinity; - if (!isInfinity && !isFinite(number)) return ''; + // Count the number of leading zeros. + for (i = 0; numStr.charAt(i) == ZERO_CHAR; i++); - var numStr = number + '', - formatedText = '', - hasExponent = false, - parts = []; + if (i == (zeros = numStr.length)) { + // The digits are all zero. + digits = [0]; + numberOfIntegerDigits = 1; + } else { + // Count the number of trailing zeros + zeros--; + while (numStr.charAt(zeros) == ZERO_CHAR) zeros--; + + // Trailing zeros are insignificant so ignore them + numberOfIntegerDigits -= i; + digits = []; + // Convert string to array of digits without leading/trailing zeros. + for (j = 0; i <= zeros; i++, j++) { + digits[j] = +numStr.charAt(i); + } + } - if (isInfinity) formatedText = '\u221e'; + // If the number overflows the maximum allowed digits then use an exponent. + if (numberOfIntegerDigits > MAX_DIGITS) { + digits = digits.splice(0, MAX_DIGITS - 1); + exponent = numberOfIntegerDigits - 1; + numberOfIntegerDigits = 1; + } + + return { d: digits, e: exponent, i: numberOfIntegerDigits }; +} - if (!isInfinity && numStr.indexOf('e') !== -1) { - var match = numStr.match(/([\d\.]+)e(-?)(\d+)/); - if (match && match[2] == '-' && match[3] > fractionSize + 1) { - number = 0; +/** + * Round the parsed number to the specified number of decimal places + * This function changed the parsedNumber in-place + */ +function roundNumber(parsedNumber, fractionSize, minFrac, maxFrac) { + var digits = parsedNumber.d; + var fractionLen = digits.length - parsedNumber.i; + + // determine fractionSize if it is not specified; `+fractionSize` converts it to a number + fractionSize = (isUndefined(fractionSize)) ? Math.min(Math.max(minFrac, fractionLen), maxFrac) : +fractionSize; + + // The index of the digit to where rounding is to occur + var roundAt = fractionSize + parsedNumber.i; + var digit = digits[roundAt]; + + if (roundAt > 0) { + digits.splice(roundAt); } else { - formatedText = numStr; - hasExponent = true; + // We rounded to zero so reset the parsedNumber + parsedNumber.i = 1; + digits.length = roundAt = fractionSize + 1; + for (var i=0; i < roundAt; i++) digits[i] = 0; } - } - if (!isInfinity && !hasExponent) { - var fractionLen = (numStr.split(DECIMAL_SEP)[1] || '').length; + if (digit >= 5) digits[roundAt - 1]++; - // determine fractionSize if it is not specified - if (isUndefined(fractionSize)) { - fractionSize = Math.min(Math.max(pattern.minFrac, fractionLen), pattern.maxFrac); + // Pad out with zeros to get the required fraction length + for (; fractionLen < fractionSize; fractionLen++) digits.push(0); + + + // Do any carrying, e.g. a digit was rounded up to 10 + var carry = digits.reduceRight(function(carry, d, i, digits) { + d = d + carry; + digits[i] = d % 10; + return Math.floor(d / 10); + }, 0); + if (carry) { + digits.unshift(carry); + parsedNumber.i++; } +} - // safely round numbers in JS without hitting imprecisions of floating-point arithmetics - // inspired by: - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round - number = +(Math.round(+(number.toString() + 'e' + fractionSize)).toString() + 'e' + -fractionSize); - - var fraction = ('' + number).split(DECIMAL_SEP); - var whole = fraction[0]; - fraction = fraction[1] || ''; - - var i, pos = 0, - lgroup = pattern.lgSize, - group = pattern.gSize; - - if (whole.length >= (lgroup + group)) { - pos = whole.length - lgroup; - for (i = 0; i < pos; i++) { - if ((pos - i) % group === 0 && i !== 0) { - formatedText += groupSep; - } - formatedText += whole.charAt(i); - } +/** + * Format a number into a string + * @param {number} number The number to format + * @param {{ + * minFrac, // the minimum number of digits required in the fraction part of the number + * maxFrac, // the maximum number of digits required in the fraction part of the number + * gSize, // number of digits in each group of separated digits + * lgSize, // number of digits in the last group of digits before the decimal separator + * negPre, // the string to go in front of a negative number (e.g. `-` or `(`)) + * posPre, // the string to go in front of a positive number + * negSuf, // the string to go after a negative number (e.g. `)`) + * posSuf // the string to go after a positive number + * }} pattern + * @param {string} groupSep The string to separate groups of number (e.g. `,`) + * @param {string} decimalSep The string to act as the decimal separator (e.g. `.`) + * @param {[type]} fractionSize The size of the fractional part of the number + * @return {string} The number formatted as a string + */ +function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) { + + if (!(isString(number) || isNumber(number)) || isNaN(number)) return ''; + + var isInfinity = !isFinite(number); + var isZero = false; + var numStr = Math.abs(number) + '', + formattedText = '', + parsedNumber; + + if (isInfinity) { + formattedText = '\u221e'; + } else { + parsedNumber = parse(numStr); + + roundNumber(parsedNumber, fractionSize, pattern.minFrac, pattern.maxFrac); + + var digits = parsedNumber.d; + var integerLen = parsedNumber.i; + var exponent = parsedNumber.e; + var decimals = []; + isZero = digits.reduce(function(isZero, d) { return isZero && !d; }, true); + + // pad zeros for small numbers + while (integerLen < 0) { + digits.unshift(0); + integerLen++; } - for (i = pos; i < whole.length; i++) { - if ((whole.length - i) % lgroup === 0 && i !== 0) { - formatedText += groupSep; - } - formatedText += whole.charAt(i); + // extract decimals digits + if (integerLen > 0) { + decimals = digits.splice(integerLen); + } else { + decimals = digits; + digits = [0]; + } + + // format the integer digits with grouping separators + var groups = []; + if (digits.length > pattern.lgSize) { + groups.unshift(digits.splice(-pattern.lgSize).join('')); } + while (digits.length > pattern.gSize) { + groups.unshift(digits.splice(-pattern.gSize).join('')); + } + if (digits.length) { + groups.unshift(digits.join('')); + } + formattedText = groups.join(groupSep); - // format fraction part. - while (fraction.length < fractionSize) { - fraction += '0'; + // append the decimal digits + if (decimals.length) { + formattedText += decimalSep + decimals.join(''); } - if (fractionSize && fractionSize !== "0") formatedText += decimalSep + fraction.substr(0, fractionSize); - } else { - if (fractionSize > 0 && number < 1) { - formatedText = number.toFixed(fractionSize); - number = parseFloat(formatedText); - formatedText = formatedText.replace(DECIMAL_SEP, decimalSep); + if (exponent) { + formattedText += 'e+' + exponent; } } - - if (number === 0) { - isNegative = false; + if (number < 0 && !isZero) { + return pattern.negPre + formattedText + pattern.negSuf; + } else { + return pattern.posPre + formattedText + pattern.posSuf; } - - parts.push(isNegative ? pattern.negPre : pattern.posPre, - formatedText, - isNegative ? pattern.negSuf : pattern.posSuf); - return parts.join(''); } function padNumber(num, digits, trim) { @@ -235,7 +338,7 @@ function padNumber(num, digits, trim) { num = -num; } num = '' + num; - while (num.length < digits) num = '0' + num; + while (num.length < digits) num = ZERO_CHAR + num; if (trim) { num = num.substr(num.length - digits); } diff --git a/test/ng/filter/filtersSpec.js b/test/ng/filter/filtersSpec.js index 4bb58f45e3a3..979fd2bf02b8 100644 --- a/test/ng/filter/filtersSpec.js +++ b/test/ng/filter/filtersSpec.js @@ -92,6 +92,39 @@ describe('filters', function() { expect(formatNumber(-0.0001, pattern, ',', '.', 3)).toBe('0.000'); expect(formatNumber(-0.0000001, pattern, ',', '.', 6)).toBe('0.000000'); }); + + it('should work with numbers that are close to the limit for exponent notation', function() { + // previously, numbers that n * (10 ^ fractionSize) > localLimitMax + // were ending up with a second exponent in them, then coercing to + // NaN when formatNumber rounded them with the safe rounding + // function. + + var localLimitMax = 999999999999999900000, + localLimitMin = 10000000000000000000, + exampleNumber = 444444444400000000000; + + expect(formatNumber(localLimitMax, pattern, ',', '.', 2)) + .toBe('999,999,999,999,999,900,000.00'); + expect(formatNumber(localLimitMin, pattern, ',', '.', 2)) + .toBe('10,000,000,000,000,000,000.00'); + expect(formatNumber(exampleNumber, pattern, ',', '.', 2)) + .toBe('444,444,444,400,000,000,000.00'); + + }); + + it('should format large number',function() { + var num; + num = formatNumber(12345868059685210000, pattern, ',', '.', 2); + expect(num).toBe('12,345,868,059,685,210,000.00'); + num = formatNumber(79832749837498327498274983793234322432, pattern, ',', '.', 2); + expect(num).toBe('7.98e+37'); + num = formatNumber(8798327498374983274928, pattern, ',', '.', 2); + expect(num).toBe('8,798,327,498,374,983,000,000.00'); + num = formatNumber(879832749374983274928, pattern, ',', '.', 2); + expect(num).toBe('879,832,749,374,983,200,000.00'); + num = formatNumber(879832749374983274928, pattern, ',', '.', 32); + expect(num).toBe('879,832,749,374,983,200,000.00000000000000000000000000000000'); + }); }); describe('currency', function() { @@ -186,13 +219,10 @@ describe('filters', function() { }); it('should filter exponentially large numbers', function() { - expect(number(1e50)).toEqual('1e+50'); - expect(number(-2e100)).toEqual('-2e+100'); - }); - - it('should ignore fraction sizes for large numbers', function() { - expect(number(1e50, 2)).toEqual('1e+50'); - expect(number(-2e100, 5)).toEqual('-2e+100'); + expect(number(1.23e50)).toEqual('1.23e+50'); + expect(number(-2.3456e100)).toEqual('-2.346e+100'); + expect(number(1e50, 2)).toEqual('1.00e+50'); + expect(number(-2e100, 5)).toEqual('-2.00000e+100'); }); it('should filter exponentially small numbers', function() { @@ -206,6 +236,14 @@ describe('filters', function() { expect(number(-1e-7, 6)).toEqual('0.000000'); expect(number(-1e-8, 9)).toEqual('-0.000000010'); }); + + it('should filter exponentially small numbers when no fraction specified', function() { + expect(number(1e-10)).toEqual('0.000'); + expect(number(0.0000000001)).toEqual('0.000'); + + expect(number(-1e-10)).toEqual('0.000'); + expect(number(-0.0000000001)).toEqual('0.000'); + }); }); describe('json', function() {