Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit 6a0686d

Browse files
fix(formatNumber): cope with large and small number corner cases
By manually parsing and rounding we can deal with the more tricky numbers Closes #13394 Closes #8674 Closes #12709 Closes #8705 Closes #12707 Closes #10246 Closes #10252
1 parent 08c9a5e commit 6a0686d

File tree

2 files changed

+218
-77
lines changed

2 files changed

+218
-77
lines changed

src/ng/filter/filters.js

+173-70
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
'use strict';
22

3+
var MAX_DIGITS = 22;
4+
var DECIMAL_SEP = '.';
5+
var ZERO_CHAR = '0';
6+
37
/**
48
* @ngdoc filter
59
* @name currency
@@ -124,8 +128,6 @@ function currencyFilter($locale) {
124128
</file>
125129
</example>
126130
*/
127-
128-
129131
numberFilter.$inject = ['$locale'];
130132
function numberFilter($locale) {
131133
var formats = $locale.NUMBER_FORMATS;
@@ -139,93 +141,194 @@ function numberFilter($locale) {
139141
};
140142
}
141143

142-
var DECIMAL_SEP = '.';
143-
function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) {
144-
if (isObject(number)) return '';
144+
/**
145+
* Parse a number (as a string) into three components that can be used
146+
* for formatting the number.
147+
*
148+
* (Significant bits of this parse algorithm came from https://github.com/MikeMcl/big.js/)
149+
*
150+
* @param {string} numStr The number to parse
151+
* @return {object} An object describing this number, containing the following keys:
152+
* - d : an array of digits containing leading zeros as necessary
153+
* - i : the number of the digits in `d` that are to the left of the decimal point
154+
* - e : the exponent for numbers that would need more than `MAX_DIGITS` digits in `d`
155+
*
156+
*/
157+
function parse(numStr) {
158+
var exponent = 0, digits, numberOfIntegerDigits;
159+
var i, j, zeros;
160+
161+
// Decimal point?
162+
if ((numberOfIntegerDigits = numStr.indexOf(DECIMAL_SEP)) > -1) {
163+
numStr = numStr.replace(DECIMAL_SEP, '');
164+
}
145165

146-
var isNegative = number < 0;
147-
number = Math.abs(number);
166+
// Exponential form?
167+
if ((i = numStr.search(/e/i)) > 0) {
168+
// Work out the exponent.
169+
if (numberOfIntegerDigits < 0) numberOfIntegerDigits = i;
170+
numberOfIntegerDigits += +numStr.slice(i + 1);
171+
numStr = numStr.substring(0, i);
172+
} else if (numberOfIntegerDigits < 0) {
173+
// There was no decimal point or exponent so it is an integer.
174+
numberOfIntegerDigits = numStr.length;
175+
}
148176

149-
var isInfinity = number === Infinity;
150-
if (!isInfinity && !isFinite(number)) return '';
177+
// Count the number of leading zeros.
178+
for (i = 0; numStr.charAt(i) == ZERO_CHAR; i++);
151179

152-
var numStr = number + '',
153-
formatedText = '',
154-
hasExponent = false,
155-
parts = [];
180+
if (i == (zeros = numStr.length)) {
181+
// The digits are all zero.
182+
digits = [0];
183+
numberOfIntegerDigits = 1;
184+
} else {
185+
// Count the number of trailing zeros
186+
zeros--;
187+
while (numStr.charAt(zeros) == ZERO_CHAR) zeros--;
188+
189+
// Trailing zeros are insignificant so ignore them
190+
numberOfIntegerDigits -= i;
191+
digits = [];
192+
// Convert string to array of digits without leading/trailing zeros.
193+
for (j = 0; i <= zeros; i++, j++) {
194+
digits[j] = +numStr.charAt(i);
195+
}
196+
}
156197

157-
if (isInfinity) formatedText = '\u221e';
198+
// If the number overflows the maximum allowed digits then use an exponent.
199+
if (numberOfIntegerDigits > MAX_DIGITS) {
200+
digits = digits.splice(0, MAX_DIGITS - 1);
201+
exponent = numberOfIntegerDigits - 1;
202+
numberOfIntegerDigits = 1;
203+
}
204+
205+
return { d: digits, e: exponent, i: numberOfIntegerDigits };
206+
}
158207

159-
if (!isInfinity && numStr.indexOf('e') !== -1) {
160-
var match = numStr.match(/([\d\.]+)e(-?)(\d+)/);
161-
if (match && match[2] == '-' && match[3] > fractionSize + 1) {
162-
number = 0;
208+
/**
209+
* Round the parsed number to the specified number of decimal places
210+
* This function changed the parsedNumber in-place
211+
*/
212+
function roundNumber(parsedNumber, fractionSize, minFrac, maxFrac) {
213+
var digits = parsedNumber.d;
214+
var fractionLen = digits.length - parsedNumber.i;
215+
216+
// determine fractionSize if it is not specified; `+fractionSize` converts it to a number
217+
fractionSize = (isUndefined(fractionSize)) ? Math.min(Math.max(minFrac, fractionLen), maxFrac) : +fractionSize;
218+
219+
// The index of the digit to where rounding is to occur
220+
var roundAt = fractionSize + parsedNumber.i;
221+
var digit = digits[roundAt];
222+
223+
if (roundAt > 0) {
224+
digits.splice(roundAt);
163225
} else {
164-
formatedText = numStr;
165-
hasExponent = true;
226+
// We rounded to zero so reset the parsedNumber
227+
parsedNumber.i = 1;
228+
digits.length = roundAt = fractionSize + 1;
229+
for (var i=0; i < roundAt; i++) digits[i] = 0;
166230
}
167-
}
168231

169-
if (!isInfinity && !hasExponent) {
170-
var fractionLen = (numStr.split(DECIMAL_SEP)[1] || '').length;
232+
if (digit >= 5) digits[roundAt - 1]++;
171233

172-
// determine fractionSize if it is not specified
173-
if (isUndefined(fractionSize)) {
174-
fractionSize = Math.min(Math.max(pattern.minFrac, fractionLen), pattern.maxFrac);
234+
// Pad out with zeros to get the required fraction length
235+
for (; fractionLen < fractionSize; fractionLen++) digits.push(0);
236+
237+
238+
// Do any carrying, e.g. a digit was rounded up to 10
239+
var carry = digits.reduceRight(function(carry, d, i, digits) {
240+
d = d + carry;
241+
digits[i] = d % 10;
242+
return Math.floor(d / 10);
243+
}, 0);
244+
if (carry) {
245+
digits.unshift(carry);
246+
parsedNumber.i++;
175247
}
248+
}
176249

177-
// safely round numbers in JS without hitting imprecisions of floating-point arithmetics
178-
// inspired by:
179-
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
180-
number = +(Math.round(+(number.toString() + 'e' + fractionSize)).toString() + 'e' + -fractionSize);
181-
182-
var fraction = ('' + number).split(DECIMAL_SEP);
183-
var whole = fraction[0];
184-
fraction = fraction[1] || '';
185-
186-
var i, pos = 0,
187-
lgroup = pattern.lgSize,
188-
group = pattern.gSize;
189-
190-
if (whole.length >= (lgroup + group)) {
191-
pos = whole.length - lgroup;
192-
for (i = 0; i < pos; i++) {
193-
if ((pos - i) % group === 0 && i !== 0) {
194-
formatedText += groupSep;
195-
}
196-
formatedText += whole.charAt(i);
197-
}
250+
/**
251+
* Format a number into a string
252+
* @param {number} number The number to format
253+
* @param {{
254+
* minFrac, // the minimum number of digits required in the fraction part of the number
255+
* maxFrac, // the maximum number of digits required in the fraction part of the number
256+
* gSize, // number of digits in each group of separated digits
257+
* lgSize, // number of digits in the last group of digits before the decimal separator
258+
* negPre, // the string to go in front of a negative number (e.g. `-` or `(`))
259+
* posPre, // the string to go in front of a positive number
260+
* negSuf, // the string to go after a negative number (e.g. `)`)
261+
* posSuf // the string to go after a positive number
262+
* }} pattern
263+
* @param {string} groupSep The string to separate groups of number (e.g. `,`)
264+
* @param {string} decimalSep The string to act as the decimal separator (e.g. `.`)
265+
* @param {[type]} fractionSize The size of the fractional part of the number
266+
* @return {string} The number formatted as a string
267+
*/
268+
function formatNumber(number, pattern, groupSep, decimalSep, fractionSize) {
269+
270+
if (!(isString(number) || isNumber(number)) || isNaN(number)) return '';
271+
272+
var isInfinity = !isFinite(number);
273+
var isZero = false;
274+
var numStr = Math.abs(number) + '',
275+
formattedText = '',
276+
parsedNumber;
277+
278+
if (isInfinity) {
279+
formattedText = '\u221e';
280+
} else {
281+
parsedNumber = parse(numStr);
282+
283+
roundNumber(parsedNumber, fractionSize, pattern.minFrac, pattern.maxFrac);
284+
285+
var digits = parsedNumber.d;
286+
var integerLen = parsedNumber.i;
287+
var exponent = parsedNumber.e;
288+
var decimals = [];
289+
isZero = digits.reduce(function(isZero, d) { return isZero && !d; }, true);
290+
291+
// pad zeros for small numbers
292+
while (integerLen < 0) {
293+
digits.unshift(0);
294+
integerLen++;
198295
}
199296

200-
for (i = pos; i < whole.length; i++) {
201-
if ((whole.length - i) % lgroup === 0 && i !== 0) {
202-
formatedText += groupSep;
203-
}
204-
formatedText += whole.charAt(i);
297+
// extract decimals digits
298+
if (integerLen > 0) {
299+
decimals = digits.splice(integerLen);
300+
} else {
301+
decimals = digits;
302+
digits = [0];
303+
}
304+
305+
// format the integer digits with grouping separators
306+
var groups = [];
307+
if (digits.length > pattern.lgSize) {
308+
groups.unshift(digits.splice(-pattern.lgSize).join(''));
205309
}
310+
while (digits.length > pattern.gSize) {
311+
groups.unshift(digits.splice(-pattern.gSize).join(''));
312+
}
313+
if (digits.length) {
314+
groups.unshift(digits.join(''));
315+
}
316+
formattedText = groups.join(groupSep);
206317

207-
// format fraction part.
208-
while (fraction.length < fractionSize) {
209-
fraction += '0';
318+
// append the decimal digits
319+
if (decimals.length) {
320+
formattedText += decimalSep + decimals.join('');
210321
}
211322

212-
if (fractionSize && fractionSize !== "0") formatedText += decimalSep + fraction.substr(0, fractionSize);
213-
} else {
214-
if (fractionSize > 0 && number < 1) {
215-
formatedText = number.toFixed(fractionSize);
216-
number = parseFloat(formatedText);
217-
formatedText = formatedText.replace(DECIMAL_SEP, decimalSep);
323+
if (exponent) {
324+
formattedText += 'e+' + exponent;
218325
}
219326
}
220-
221-
if (number === 0) {
222-
isNegative = false;
327+
if (number < 0 && !isZero) {
328+
return pattern.negPre + formattedText + pattern.negSuf;
329+
} else {
330+
return pattern.posPre + formattedText + pattern.posSuf;
223331
}
224-
225-
parts.push(isNegative ? pattern.negPre : pattern.posPre,
226-
formatedText,
227-
isNegative ? pattern.negSuf : pattern.posSuf);
228-
return parts.join('');
229332
}
230333

231334
function padNumber(num, digits, trim) {
@@ -235,7 +338,7 @@ function padNumber(num, digits, trim) {
235338
num = -num;
236339
}
237340
num = '' + num;
238-
while (num.length < digits) num = '0' + num;
341+
while (num.length < digits) num = ZERO_CHAR + num;
239342
if (trim) {
240343
num = num.substr(num.length - digits);
241344
}

test/ng/filter/filtersSpec.js

+45-7
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,39 @@ describe('filters', function() {
9292
expect(formatNumber(-0.0001, pattern, ',', '.', 3)).toBe('0.000');
9393
expect(formatNumber(-0.0000001, pattern, ',', '.', 6)).toBe('0.000000');
9494
});
95+
96+
it('should work with numbers that are close to the limit for exponent notation', function() {
97+
// previously, numbers that n * (10 ^ fractionSize) > localLimitMax
98+
// were ending up with a second exponent in them, then coercing to
99+
// NaN when formatNumber rounded them with the safe rounding
100+
// function.
101+
102+
var localLimitMax = 999999999999999900000,
103+
localLimitMin = 10000000000000000000,
104+
exampleNumber = 444444444400000000000;
105+
106+
expect(formatNumber(localLimitMax, pattern, ',', '.', 2))
107+
.toBe('999,999,999,999,999,900,000.00');
108+
expect(formatNumber(localLimitMin, pattern, ',', '.', 2))
109+
.toBe('10,000,000,000,000,000,000.00');
110+
expect(formatNumber(exampleNumber, pattern, ',', '.', 2))
111+
.toBe('444,444,444,400,000,000,000.00');
112+
113+
});
114+
115+
it('should format large number',function() {
116+
var num;
117+
num = formatNumber(12345868059685210000, pattern, ',', '.', 2);
118+
expect(num).toBe('12,345,868,059,685,210,000.00');
119+
num = formatNumber(79832749837498327498274983793234322432, pattern, ',', '.', 2);
120+
expect(num).toBe('7.98e+37');
121+
num = formatNumber(8798327498374983274928, pattern, ',', '.', 2);
122+
expect(num).toBe('8,798,327,498,374,983,000,000.00');
123+
num = formatNumber(879832749374983274928, pattern, ',', '.', 2);
124+
expect(num).toBe('879,832,749,374,983,200,000.00');
125+
num = formatNumber(879832749374983274928, pattern, ',', '.', 32);
126+
expect(num).toBe('879,832,749,374,983,200,000.00000000000000000000000000000000');
127+
});
95128
});
96129

97130
describe('currency', function() {
@@ -186,13 +219,10 @@ describe('filters', function() {
186219
});
187220

188221
it('should filter exponentially large numbers', function() {
189-
expect(number(1e50)).toEqual('1e+50');
190-
expect(number(-2e100)).toEqual('-2e+100');
191-
});
192-
193-
it('should ignore fraction sizes for large numbers', function() {
194-
expect(number(1e50, 2)).toEqual('1e+50');
195-
expect(number(-2e100, 5)).toEqual('-2e+100');
222+
expect(number(1.23e50)).toEqual('1.23e+50');
223+
expect(number(-2.3456e100)).toEqual('-2.346e+100');
224+
expect(number(1e50, 2)).toEqual('1.00e+50');
225+
expect(number(-2e100, 5)).toEqual('-2.00000e+100');
196226
});
197227

198228
it('should filter exponentially small numbers', function() {
@@ -206,6 +236,14 @@ describe('filters', function() {
206236
expect(number(-1e-7, 6)).toEqual('0.000000');
207237
expect(number(-1e-8, 9)).toEqual('-0.000000010');
208238
});
239+
240+
it('should filter exponentially small numbers when no fraction specified', function() {
241+
expect(number(1e-10)).toEqual('0.000');
242+
expect(number(0.0000000001)).toEqual('0.000');
243+
244+
expect(number(-1e-10)).toEqual('0.000');
245+
expect(number(-0.0000000001)).toEqual('0.000');
246+
});
209247
});
210248

211249
describe('json', function() {

0 commit comments

Comments
 (0)