From 3a3c694aa9bce5fde158c96b1b2e1c0e101d9c3e Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 15 Oct 2026 13:41:37 +0000 Subject: [PATCH 1/5] Round and write fixed-point numbers as Excel does A fixed-point pattern rounded the number with Math.round(number * 10 ** places), and wrote its digits with String(), so: - a number of 1e21 or more came out garbled: `#,##0` of 1e300 gave `1e+,300` - so did a small number with many decimals: `0.0000000000` of 1e-7 gave `0.0000000000` - rounding followed the double's binary value, not Excel: `0.00` of 1.005 gave `1.00` (Excel: `1.01`), and `0` of 10^15+5 gave `1000000000000005` (Excel: `1000000000000000`) Excel rounds in two steps: first the double's exact value to 15 significant digits, a tie going toward zero; then that decimal to the pattern's last digit, a tie going away from zero. toDecimal does the first step: toPrecision(15) rounds the exact value, and a tie, which it rounds the wrong way, is detected exactly with float arithmetic. roundDecimal does the second step on the digit string, and the integer and fraction digits come out of it as strings, so no number is formatted by String() any longer. The exponent pattern's check for a significand rounding up to 10 now uses the same rounding, so that it agrees with the digits written. Number.MAX_SAFE_INTEGER under `0` now gives 15 significant digits, as `0.0` already did. --- lib/runPart.ts | 27 +++++---- lib/toDecimal.ts | 130 ++++++++++++++++++++++++++++++++++++++++++++ test/bigint.spec.ts | 2 +- test/numfmt.spec.ts | 29 ++++++++++ 4 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 lib/toDecimal.ts diff --git a/lib/runPart.ts b/lib/runPart.ts index 70c4820..60ef3d9 100644 --- a/lib/runPart.ts +++ b/lib/runPart.ts @@ -48,6 +48,7 @@ import { TOKEN_CURRENCY } from './constants.ts'; import { pad } from './pad.ts'; +import { roundDecimal, toDecimal } from './toDecimal.ts'; import { getExponent, getSignificand } from './numberProps.ts'; import type { Partition } from './types.ts'; import type { FormatOptions } from './options.ts'; @@ -115,7 +116,7 @@ export function runPart (value: number | string | bigint, part: Partition, opts: exp++; } v = getSignificand(v, exp); - if (part.int_max === 1 && round(v, part.frac_max) === 10) { + if (part.int_max === 1 && roundDecimal(toDecimal(v), part.frac_max)[0] === '10') { v = 1; exp++; } @@ -123,20 +124,26 @@ export function runPart (value: number | string | bigint, part: Partition, opts: mantissa += Math.abs(exp); } - // integer to text - if (part.integer && typeof value === 'number') { - const i = Math.abs(round(value, part.fractions ? 1 : part.frac_max)); - integer += (i < 1) ? '' : Math.floor(i); + // integer and fraction to text + if (part.fractions) { + if (part.integer && typeof value === 'number') { + const i = Math.abs(round(value, 1)); + integer += (i < 1) ? '' : Math.floor(i); + } + } + else if ((part.integer || part.dec_fractions) && typeof value === 'number') { + const digits = roundDecimal(toDecimal(value), part.frac_max); + if (part.integer) { + integer = digits[0]; + } + if (part.dec_fractions) { + fraction = digits[1]; + } } // integer grouping const group_pri = opts.grouping[0] ?? 3; const group_sec = opts.grouping[1] ?? group_pri; - // fraction to text - if (part.dec_fractions && typeof value === 'number') { - fraction = String(round(value, part.frac_max)).split('.')[1] || ''; - } - // using vulgar fractions const fixed_slash = !part.error && (part.num_p.includes('0') || part.den_p.includes('0')); diff --git a/lib/toDecimal.ts b/lib/toDecimal.ts new file mode 100644 index 0000000..f967799 --- /dev/null +++ b/lib/toDecimal.ts @@ -0,0 +1,130 @@ +/** + * A non-negative decimal number: significant `digits` without leading or + * trailing zeros, and the position of the decimal point relative to the first + * of them. 1234.5 is `{ digits: '12345', point: 4 }`, 0.012 is + * `{ digits: '12', point: -1 }` and zero is `{ digits: '', point: 0 }`. + */ +export type Decimal = { digits: string, point: number }; + +const CHAR_0 = 48; +const CHAR_5 = 53; +const CHAR_9 = 57; + +// read a non-negative number written by String() or toPrecision() +function parseDecimal (str: string): Decimal { + let exp = 0; + const e = str.indexOf('e'); + if (e >= 0) { + exp = +str.slice(e + 1); + str = str.slice(0, e); + } + const dot = str.indexOf('.'); + const digits = dot >= 0 ? str.slice(0, dot) + str.slice(dot + 1) : str; + let start = 0; + while (digits.charCodeAt(start) === CHAR_0) { + start++; + } + let end = digits.length; + while (end > start && digits.charCodeAt(end - 1) === CHAR_0) { + end--; + } + if (start === end) { + return { digits: '', point: 0 }; + } + return { + digits: digits.slice(start, end), + point: (dot >= 0 ? dot : digits.length) + exp - start + }; +} + +// Is the (non-negative) number exactly halfway between two decimals of 15 +// significant digits, when it has `point` integer digits? +function isTie (num: number, point: number): boolean { + if (point >= 15) { + // A tie is an odd multiple of 5 * 10 ** (point - 16), so a multiple of + // 5 ** (point - 15). Beyond 5 ** 22 that won't fit in a double's mantissa. + if (point > 37) { + return false; + } + const unit = 10 ** (point - 15); + return num % unit === unit / 2; + } + // Below that, a tie has a fraction. A fraction of k binary digits is + // exactly k decimal digits, ending in 5, so a tie has 16 - point of them. + return ( + Number.isInteger(num * 2 ** (16 - point)) && + !Number.isInteger(num * 2 ** (15 - point)) + ); +} + +/** + * Return the magnitude of a finite number as Excel sees it: its exact value + * rounded to 15 significant digits, a tie going toward zero. + * + * @param num The number to convert. + * @returns The decimal. + */ +export function toDecimal (num: number): Decimal { + const v = Math.abs(num); + // toPrecision rounds the exact value, but rounds a tie away from zero + const dec = parseDecimal(v.toPrecision(15)); + if ( + isTie(v, dec.point) || + // rounding up to a power of 10 adds an integer digit + (dec.digits === '1' && isTie(v, dec.point - 1)) + ) { + // a tie has 16 digits, and its shortest representation is exact + const exact = parseDecimal(String(v)); + return parseDecimal(exact.digits.slice(0, 15) + 'e' + (exact.point - 15)); + } + return dec; +} + +/** + * Round a decimal to a number of decimal places, a tie going away from zero, + * and return its integer and fraction digits. The integer digits are empty + * when the rounded number is below 1, and the fraction digits have no trailing + * zeros. + * + * @param dec The decimal to round. + * @param places The number of decimals to round to. + * @returns The integer and fraction digits. + */ +export function roundDecimal (dec: Decimal, places: number): [ string, string ] { + let digits = dec.digits; + let point = dec.point; + const keep = point + places; + if (keep < digits.length) { + if (keep < 0) { + return [ '', '' ]; + } + const roundUp = digits.charCodeAt(keep) >= CHAR_5; + let end = keep; + if (roundUp) { + // drop trailing nines, they carry into the digit before them + while (end > 0 && digits.charCodeAt(end - 1) === CHAR_9) { + end--; + } + if (end) { + digits = digits.slice(0, end - 1) + (+digits[end - 1] + 1); + } + else { + digits = '1'; + point++; + } + } + else { + while (end > 0 && digits.charCodeAt(end - 1) === CHAR_0) { + end--; + } + digits = digits.slice(0, end); + } + } + if (!digits) { + return [ '', '' ]; + } + return [ + point > 0 ? digits.slice(0, point).padEnd(point, '0') : '', + point >= 0 ? digits.slice(point) : '0'.repeat(-point) + digits + ]; +} diff --git a/test/bigint.spec.ts b/test/bigint.spec.ts index 2f2eb96..f799e74 100644 --- a/test/bigint.spec.ts +++ b/test/bigint.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from 'vitest'; import { format } from '../lib/index.ts'; test('bigint', () => { - expect(format('0', Number.MAX_SAFE_INTEGER)).toBe(String(Number.MAX_SAFE_INTEGER)); + expect(format('0', Number.MAX_SAFE_INTEGER)).toBe('9007199254740990'); expect(format('0', 10n)).toBe('10'); expect(format('General', 10n)).toBe('10'); diff --git a/test/numfmt.spec.ts b/test/numfmt.spec.ts index 1f49d01..971bd57 100644 --- a/test/numfmt.spec.ts +++ b/test/numfmt.spec.ts @@ -332,3 +332,32 @@ test('General rounds 10 and 11 digit numbers with a fraction', () => { expect(numfmt('General', -4403928373.5)).toBe('-4403928374'); expect(numfmt('General', -99999999999.6)).toBe('-1E+11'); }); + +test('Fixed-point numbers are written out in full, never in exponent notation', () => { + expect(numfmt('#,##0', 2 ** 70)).toBe('1,180,591,620,717,410,000,000'); + expect(numfmt('0', 1e21)).toBe('1000000000000000000000'); + expect(numfmt('#,##0', -1e21)).toBe('-1,000,000,000,000,000,000,000'); + expect(numfmt('0.00', 1.23456789012345e22)).toBe('12345678901234500000000.00'); + expect(numfmt('#,##0', 1e300)).toBe('1' + ',000'.repeat(100)); + expect(numfmt('0.0000000000', 1e-7)).toBe('0.0000001000'); + expect(numfmt('0.00000000000000000000', 1.5e-19)).toBe('0.00000000000000000015'); +}); + +test('Fixed-point numbers round as in Excel', () => { + // the exact value is first rounded to 15 significant digits, a tie going toward zero + expect(numfmt('0', 10 ** 15 + 5)).toBe('1000000000000000'); + expect(numfmt('0', -(10 ** 15 + 5))).toBe('-1000000000000000'); + expect(numfmt('0', 10 ** 15 + 15)).toBe('1000000000000010'); + expect(numfmt('0', 246913578024691 / 2)).toBe('123456789012345'); + expect(numfmt('0', 2.5 - 2 ** -51)).toBe('3'); + expect(numfmt('#,##0', 2 ** 60 + 1)).toBe('1,152,921,504,606,850,000'); + // then that decimal is rounded to the pattern's last digit, a tie going away from zero + expect(numfmt('0.00', 1.005)).toBe('1.01'); + expect(numfmt('0.00', 1.015)).toBe('1.02'); + expect(numfmt('0.00', 0.285)).toBe('0.29'); + expect(numfmt('0.0', -0.15)).toBe('-0.2'); + expect(numfmt('0', 2.5)).toBe('3'); + expect(numfmt('0', -2.5)).toBe('-3'); + expect(numfmt('0.00', 0.125)).toBe('0.13'); + expect(numfmt('0', 12345678901234.5)).toBe('12345678901235'); +}); From 59aace58481e5b792b57ef19c98b716cd0e0a872 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 15 Oct 2026 13:43:53 +0000 Subject: [PATCH 2/5] Round exponent notation's significand as Excel does An exponent pattern computed its significand as number * 10 ** -exp in floating point and then rounded that, so its digits could differ from the number's own: `0.00000000000000E+00` of 3 * 2 ** 51 + 1 gave `6.75539944105575E+15` (Excel: `6.75539944105574E+15`), and `0.00E+00` of 1.005 gave `1.00E+00`. The significand now takes the number's 15-digit decimal with its point shifted, and the exponent comes from that decimal's point, so both of Excel's rounding steps apply as they do for fixed point. Subnormal numbers, which Excel doesn't have, keep their shortest representation (`0.000E+000` of 2.33e-321 stays `2.330E-321`) rather than show the digits of their exact value. --- lib/runPart.ts | 24 ++++++++++------- lib/toDecimal.ts | 64 +++++++++++++++++++++++++-------------------- test/numfmt.spec.ts | 6 +++++ 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/lib/runPart.ts b/lib/runPart.ts index 60ef3d9..2ae18b4 100644 --- a/lib/runPart.ts +++ b/lib/runPart.ts @@ -48,8 +48,8 @@ import { TOKEN_CURRENCY } from './constants.ts'; import { pad } from './pad.ts'; -import { roundDecimal, toDecimal } from './toDecimal.ts'; -import { getExponent, getSignificand } from './numberProps.ts'; +import { roundDecimal, toDecimal, type Decimal } from './toDecimal.ts'; +import { getSignificand } from './numberProps.ts'; import type { Partition } from './types.ts'; import type { FormatOptions } from './options.ts'; @@ -106,20 +106,26 @@ export function runPart (value: number | string | bigint, part: Partition, opts: } // calc exponent + let dec: Decimal | undefined; if (part.exponential && typeof value === 'number') { - let v = Math.abs(value); - if (v) { - exp = getExponent(v, part.int_max); + // the significand has the number's own digits, so round those + dec = toDecimal(value); + if (dec.digits) { + exp = dec.point - 1; + if (part.int_max > 1) { + exp = Math.floor(exp / part.int_max) * part.int_max; + } } if (value && !part.integer) { // when there isn't an integer part, the exp gets shifted by 1 exp++; } - v = getSignificand(v, exp); - if (part.int_max === 1 && roundDecimal(toDecimal(v), part.frac_max)[0] === '10') { - v = 1; + dec.point -= exp; + if (part.int_max === 1 && roundDecimal(dec, part.frac_max)[0] === '10') { + dec.point--; exp++; } + const v = getSignificand(Math.abs(value), exp); value = (value < 0) ? -v : v; mantissa += Math.abs(exp); } @@ -132,7 +138,7 @@ export function runPart (value: number | string | bigint, part: Partition, opts: } } else if ((part.integer || part.dec_fractions) && typeof value === 'number') { - const digits = roundDecimal(toDecimal(value), part.frac_max); + const digits = roundDecimal(dec ?? toDecimal(value), part.frac_max); if (part.integer) { integer = digits[0]; } diff --git a/lib/toDecimal.ts b/lib/toDecimal.ts index f967799..cb1fa4e 100644 --- a/lib/toDecimal.ts +++ b/lib/toDecimal.ts @@ -9,6 +9,7 @@ export type Decimal = { digits: string, point: number }; const CHAR_0 = 48; const CHAR_5 = 53; const CHAR_9 = 57; +const MIN_NORMAL = 2 ** -1022; // read a non-negative number written by String() or toPrecision() function parseDecimal (str: string): Decimal { @@ -66,6 +67,10 @@ function isTie (num: number, point: number): boolean { */ export function toDecimal (num: number): Decimal { const v = Math.abs(num); + if (v < MIN_NORMAL) { + // Excel has no subnormal numbers, use their shortest representation + return roundDigits(parseDecimal(String(v)), 15); + } // toPrecision rounds the exact value, but rounds a tie away from zero const dec = parseDecimal(v.toPrecision(15)); if ( @@ -80,6 +85,35 @@ export function toDecimal (num: number): Decimal { return dec; } +// round a decimal to its first `keep` digits, a tie going away from zero +function roundDigits (dec: Decimal, keep: number): Decimal { + const digits = dec.digits; + if (keep >= digits.length) { + return dec; + } + if (keep < 0) { + return { digits: '', point: 0 }; + } + let end = keep; + if (digits.charCodeAt(keep) >= CHAR_5) { + // drop trailing nines, they carry into the digit before them + while (end > 0 && digits.charCodeAt(end - 1) === CHAR_9) { + end--; + } + if (!end) { + return { digits: '1', point: dec.point + 1 }; + } + return { + digits: digits.slice(0, end - 1) + (+digits[end - 1] + 1), + point: dec.point + }; + } + while (end > 0 && digits.charCodeAt(end - 1) === CHAR_0) { + end--; + } + return end ? { digits: digits.slice(0, end), point: dec.point } : { digits: '', point: 0 }; +} + /** * Round a decimal to a number of decimal places, a tie going away from zero, * and return its integer and fraction digits. The integer digits are empty @@ -91,35 +125,7 @@ export function toDecimal (num: number): Decimal { * @returns The integer and fraction digits. */ export function roundDecimal (dec: Decimal, places: number): [ string, string ] { - let digits = dec.digits; - let point = dec.point; - const keep = point + places; - if (keep < digits.length) { - if (keep < 0) { - return [ '', '' ]; - } - const roundUp = digits.charCodeAt(keep) >= CHAR_5; - let end = keep; - if (roundUp) { - // drop trailing nines, they carry into the digit before them - while (end > 0 && digits.charCodeAt(end - 1) === CHAR_9) { - end--; - } - if (end) { - digits = digits.slice(0, end - 1) + (+digits[end - 1] + 1); - } - else { - digits = '1'; - point++; - } - } - else { - while (end > 0 && digits.charCodeAt(end - 1) === CHAR_0) { - end--; - } - digits = digits.slice(0, end); - } - } + const { digits, point } = roundDigits(dec, dec.point + places); if (!digits) { return [ '', '' ]; } diff --git a/test/numfmt.spec.ts b/test/numfmt.spec.ts index 971bd57..2b0925c 100644 --- a/test/numfmt.spec.ts +++ b/test/numfmt.spec.ts @@ -361,3 +361,9 @@ test('Fixed-point numbers round as in Excel', () => { expect(numfmt('0.00', 0.125)).toBe('0.13'); expect(numfmt('0', 12345678901234.5)).toBe('12345678901235'); }); + +test('Exponent notation rounds as in Excel', () => { + expect(numfmt('0.00000000000000E+00', 3 * 2 ** 51 + 1)).toBe('6.75539944105574E+15'); + expect(numfmt('0.00E+00', 1.005)).toBe('1.01E+00'); + expect(numfmt('0.00E+00', 9.995)).toBe('1.00E+01'); +}); From 4ba44dafc390f7df61642f3bff54bd6b1959ba7d Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 15 Oct 2026 14:41:40 +0000 Subject: [PATCH 3/5] Test a tie that rounds up to a power of ten, and subnormal numbers The tie branch of toDecimal has a second test for a value whose 15-digit rounding carries into a new integer digit; no test reached it. Exponent patterns of a subnormal number had the exponent off by one (Math.log10 of 1e-320 floors to -321), which the branch fixes without a test. --- test/numfmt.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/numfmt.spec.ts b/test/numfmt.spec.ts index 2b0925c..592cdc7 100644 --- a/test/numfmt.spec.ts +++ b/test/numfmt.spec.ts @@ -351,6 +351,9 @@ test('Fixed-point numbers round as in Excel', () => { expect(numfmt('0', 246913578024691 / 2)).toBe('123456789012345'); expect(numfmt('0', 2.5 - 2 ** -51)).toBe('3'); expect(numfmt('#,##0', 2 ** 60 + 1)).toBe('1,152,921,504,606,850,000'); + // a tie whose upper neighbour is a power of ten + expect(numfmt('0', 999999999999999.5)).toBe('999999999999999'); + expect(numfmt('0.0', 99999999999999.25)).toBe('99999999999999.2'); // then that decimal is rounded to the pattern's last digit, a tie going away from zero expect(numfmt('0.00', 1.005)).toBe('1.01'); expect(numfmt('0.00', 1.015)).toBe('1.02'); @@ -367,3 +370,9 @@ test('Exponent notation rounds as in Excel', () => { expect(numfmt('0.00E+00', 1.005)).toBe('1.01E+00'); expect(numfmt('0.00E+00', 9.995)).toBe('1.00E+01'); }); + +test('Subnormal numbers are rounded from their shortest representation', () => { + expect(numfmt('0.00E+00', 1e-320)).toBe('1.00E-320'); + expect(numfmt('0E+00', 5e-324)).toBe('5E-324'); + expect(numfmt('0.00000000000000E+00', 2.225073858507201e-308)).toBe('2.22507385850720E-308'); +}); From b6ed6cd18dcfa767cb4d9095eeeb15bd0696d2e3 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 15 Oct 2026 14:41:49 +0000 Subject: [PATCH 4/5] Say that a subnormal's shortest representation is rounded The shortest representation of a subnormal can have 16 digits, so the 15-digit rounding applies to it; the comment said it is used as is. --- lib/toDecimal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/toDecimal.ts b/lib/toDecimal.ts index cb1fa4e..4723b89 100644 --- a/lib/toDecimal.ts +++ b/lib/toDecimal.ts @@ -68,7 +68,7 @@ function isTie (num: number, point: number): boolean { export function toDecimal (num: number): Decimal { const v = Math.abs(num); if (v < MIN_NORMAL) { - // Excel has no subnormal numbers, use their shortest representation + // Excel has no subnormal numbers, so round their shortest representation return roundDigits(parseDecimal(String(v)), 15); } // toPrecision rounds the exact value, but rounds a tie away from zero From c7cd7ead744769d010e7aaebc23ebe64efed3d99 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Thu, 15 Oct 2026 16:04:30 +0000 Subject: [PATCH 5/5] Test an exponent pattern rounding a tie in the third digit up Excel's TEXT(501.5,"0.00E+00") is 5.02E+02; master gave 5.01E+02. --- test/numfmt.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/numfmt.spec.ts b/test/numfmt.spec.ts index 592cdc7..843e475 100644 --- a/test/numfmt.spec.ts +++ b/test/numfmt.spec.ts @@ -369,6 +369,7 @@ test('Exponent notation rounds as in Excel', () => { expect(numfmt('0.00000000000000E+00', 3 * 2 ** 51 + 1)).toBe('6.75539944105574E+15'); expect(numfmt('0.00E+00', 1.005)).toBe('1.01E+00'); expect(numfmt('0.00E+00', 9.995)).toBe('1.00E+01'); + expect(numfmt('0.00E+00', 501.5)).toBe('5.02E+02'); }); test('Subnormal numbers are rounded from their shortest representation', () => {