Skip to content

Commit

Permalink
fix(formatNumber): cope with large and small number corner cases
Browse files Browse the repository at this point in the history
By manually parsing and rounding we can deal with the more tricky numbers

Closes angular#13394
Closes angular#8674
Closes angular#12709
Closes angular#8705
Closes angular#12707
Closes angular#10246
Closes angular#10252
  • Loading branch information
petebacondarwin committed Dec 1, 2015
1 parent 08c9a5e commit 6a0686d
Show file tree
Hide file tree
Showing 2 changed files with 218 additions and 77 deletions.
243 changes: 173 additions & 70 deletions src/ng/filter/filters.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
'use strict';

var MAX_DIGITS = 22;
var DECIMAL_SEP = '.';
var ZERO_CHAR = '0';

/**
* @ngdoc filter
* @name currency
Expand Down Expand Up @@ -124,8 +128,6 @@ function currencyFilter($locale) {
</file>
</example>
*/


numberFilter.$inject = ['$locale'];
function numberFilter($locale) {
var formats = $locale.NUMBER_FORMATS;
Expand All @@ -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) {
Expand All @@ -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);
}
Expand Down
52 changes: 45 additions & 7 deletions test/ng/filter/filtersSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand All @@ -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() {
Expand Down

0 comments on commit 6a0686d

Please sign in to comment.