diff --git a/lib/dec15.ts b/lib/dec15.ts new file mode 100644 index 0000000..e07ef24 --- /dev/null +++ b/lib/dec15.ts @@ -0,0 +1,258 @@ +// Excel works with 15 significant decimal digits. These helpers get those digits +// from a double using only number arithmetic, which is faster than going through +// strings for nearly all values. + +/** Powers of ten that are exactly representable as doubles. */ +const POW10: number[] = []; +for (let i = 0; i <= 22; i++) { + POW10[i] = 10 ** i; +} + +const CHAR_0 = 48; + +/** Smallest normal double. Values below this are subnormal. */ +const MIN_NORMAL = 2.2250738585072014e-308; + +// Veltkamp splitter for doubles: 2^27 + 1 +const SPLIT = 134217729; + +/** + * Returns 10 to the power of `n`, exact for 0 ≤ n ≤ 22. + * + * @internal + * @param n The exponent. + * @returns 10^n + */ +export function pow10 (n: number): number { + return (n >= 0 && n <= 22) ? POW10[n] : 10 ** n; +} + +// Scale `a` so its value has 15 integer digits when its decimal exponent is `e`, +// and round to an integer with a tie going toward zero. Returns NaN when that +// can't be done exactly with doubles. +function scale15 (a: number, e: number): number { + const n = 14 - e; + if (n >= 0 && n <= 22) { + // Dekker's two-product: a * b === p + err, exactly + const b = POW10[n]; + const p = a * b; + let t = SPLIT * a; + const ah = t - (t - a); + const al = a - ah; + t = SPLIT * b; + const bh = t - (t - b); + const bl = b - bh; + const err = ((ah * bh - p) + ah * bl + al * bh) + al * bl; + const fl = Math.floor(p); + // the exact fraction is (p - fl) + err; an exact tie (d === -err) stays down + const d = (p - fl) - 0.5; + return (d > -err) ? fl + 1 : fl; + } + if (n < 0 && n >= -22) { + const u = POW10[-n]; + // % is exact for doubles; a - r may round, but by far less than u / 2 + const r = a % u; + const q = Math.round((a - r) / u); + return (r > u / 2) ? q + 1 : q; + } + return NaN; +} + +// Read up to 15 significant digits from a number in exponent notation ("1.5e-19"). +// Longer digit strings are rounded, a tie going toward zero. +function fromExponential (s: string): [ number, number ] { + const ePos = s.indexOf('e'); + const e = +s.slice(ePos + 1); + const digits = s.charAt(0) + s.slice(2, ePos); // drop the "." + if (digits.length <= 15) { + return [ +digits * POW10[15 - digits.length], e ]; + } + let N = +digits.slice(0, 15); + const next = digits.charCodeAt(15); + // above a half: 6-9, or 5 followed by any non-zero digit + if (next > 53 || (next === 53 && /[1-9]/.test(digits.slice(16)))) { + N++; + } + return (N >= 1e15) ? [ N / 10, e + 1 ] : [ N, e ]; +} + +/** + * Round a positive finite number to 15 significant digits, as Excel does. + * + * The exact value is rounded, a tie going toward zero. Subnormal numbers start + * from their shortest representation instead. + * + * @internal + * @param a A positive, finite, non-zero number. + * @returns `[ N, e ]` where `N` is an integer with exactly 15 digits and the + * rounded value is `N × 10^(e − 14)`. + */ +export function toDec15 (a: number): [ number, number ] { + if (a < MIN_NORMAL) { + // shortest round-trip representation + return fromExponential(a.toExponential()); + } + let e = Math.floor(Math.log10(a)); + // log10 can be off by one near powers of ten + if (a < pow10(e)) { + e--; + } + else if (a >= pow10(e + 1)) { + e++; + } + let N = scale15(a, e); + // powers of ten outside 1–1e22 are not exact, so check the digit count as well + if (N >= 1e15) { + e++; + N = scale15(a, e); + } + else if (N < 1e14) { + e--; + N = scale15(a, e); + } + if (N !== N) { // NaN: outside the exact ranges + // An exact tie at the 16th digit can't happen here, so the + // correctly rounded (ties up) toExponential gives the same result. + return fromExponential(a.toExponential(14)); + } + // 999999999999999.6 rounds up to the next power of ten + if (N >= 1e15) { + return [ N / 10, e + 1 ]; + } + return [ N, e ]; +} + +/** + * Convert a decimal `q × 10^k` back to a number, the reverse of `toDec15`. + * + * @internal + * @param q An integer below 2^53. + * @param k The decimal exponent. + * @returns The nearest double to `q × 10^k`. + */ +export function fromDec (q: number, k: number): number { + if (k >= 0 && k <= 22) { + return q * POW10[k]; + } + if (k < 0 && k >= -22) { + // q and the power of ten are both exact, so the division is correctly rounded + return q / POW10[-k]; + } + // outside the exact powers of ten, let the parser round correctly + return +(q + 'e' + k); +} + +/** + * Round a 15 digit integer to `keep` significant digits, a tie going away from zero. + * + * @internal + * @param N An integer with 15 digits. + * @param keep The number of leading digits to keep. May be 0 or negative. + * @returns The rounded integer. It equals `10^keep` when rounding carried over. + */ +export function roundDigits (N: number, keep: number): number { + if (keep >= 15) { + return N; + } + const u = pow10(15 - keep); + const q = Math.floor(N / u); + return ((N - q * u) * 2 >= u) ? q + 1 : q; +} + +/** + * Round a non-negative number to a number of decimal places, as Excel does: + * first to 15 significant digits, then to the places, a tie going away from zero. + * + * @internal + * @param a A non-negative, finite number. + * @param places The number of decimals to round to. May be negative. + * @returns `[ q, k ]` where `q` is an integer and the rounded value is `q × 10^k`. + */ +export function roundPlaces (a: number, places: number): [ number, number ] { + if (!a) { + return [ 0, 0 ]; + } + if (places >= 0 && a < 1e15 && Number.isInteger(a)) { + // a whole number with at most 15 digits is unchanged by rounding + return [ a, 0 ]; + } + const [ N, e ] = toDec15(a); + const keep = e + 1 + places; + return (keep >= 15) + ? [ N, e - 14 ] + : [ roundDigits(N, keep), -places ]; +} + +/** + * Split a non-negative number into a significand and an exponent for exponent + * notation, and round the significand to a number of decimal places as Excel + * does. If rounding carries into a new digit (9.99 → 10.0), the exponent moves. + * + * @internal + * @param a A non-negative, finite number. + * @param places The number of decimals in the significand. + * @param intMax The number of integer digits in the pattern. When above 1 the + * exponent is a multiple of it (engineering notation). + * @param hasInt Whether the pattern has integer digits. Without them, the + * significand is below 1. + * @returns `[ q, k, exp ]` where the significand is `q × 10^k` and the value is + * that times `10^exp`. + */ +export function roundSignificand ( + a: number, + places: number, + intMax: number, + hasInt: boolean +): [ number, number, number ] { + if (!a) { + return [ 0, 0, 0 ]; + } + let [ N, e ] = toDec15(a); + for (;;) { + let exp = (intMax > 1) ? Math.floor(e / intMax) * intMax : e; + if (!hasInt) { + exp++; + } + const keep = e - exp + 1 + places; + if (keep >= 15) { + return [ N, e - 14 - exp, exp ]; + } + const q = roundDigits(N, keep); + if (keep < 0 || q < pow10(keep)) { + return [ q, -places, exp ]; + } + // rounding carried into a new digit: redo for the next power of ten + N = 1e14; + e++; + } +} + +/** + * Write a decimal `q × 10^k` as integer and fraction digits. The integer digits + * are empty when the value is below 1, and the fraction digits have no + * trailing zeros. + * + * @internal + * @param q A non-negative integer below 2^53. + * @param k The decimal exponent. + * @returns The integer and fraction digits. + */ +export function decToText (q: number, k: number): [ string, string ] { + if (!q) { + return [ '', '' ]; + } + // q is an integer below 2^53, so String() gives plain digits + const s = String(q); + if (k >= 0) { + return [ k ? s + '0'.repeat(k) : s, '' ]; + } + const p = s.length + k; // digits before the decimal point + let end = s.length; + while (end > 0 && s.charCodeAt(end - 1) === CHAR_0) { + end--; + } + return [ + (p > 0) ? s.slice(0, p) : '', + (end > p) ? (p >= 0 ? '' : '0'.repeat(-p)) + s.slice(Math.max(p, 0), end) : '' + ]; +} diff --git a/lib/round.ts b/lib/round.ts index 8d6a2e3..dabe307 100644 --- a/lib/round.ts +++ b/lib/round.ts @@ -1,7 +1,34 @@ +import { fromDec, roundPlaces } from './dec15.ts'; + +/** + * Round a number with plain binary arithmetic, a tie going away from zero. + * Faster than `round`, but rounds the stored double rather than its 15 digit + * decimal, so 1.005 rounds to 1.00. + * + * Used for vulgar fractions, where this matches Excel better than `round` + * does: Excel shows 36.305 as "36 30/100". + * + * @internal + * @param number The number to round. + * @param [places] The number of decimals to round to. + * @returns A rounded number. + */ +export function roundBinary (number: number, places: number = 0): number { + if (number < 0) { + return -roundBinary(-number, places); + } + if (places) { + const p = 10 ** places || 1; + return Math.round(number * p) / p; + } + return Math.round(number); +} + /** * Return a number rounded to the specified amount of places. This is the * rounding function used internally by the formatter (symmetric arithmetic - * rounding). It rounds the same way Excel does. + * rounding). It rounds the same way Excel does: the value is first rounded + * to 15 significant digits, and then to the requested places. * * @param number The number to round. * @param [places] The number of decimals to round to. @@ -14,9 +41,8 @@ export function round (number: number, places: number = 0): number { if (number < 0) { return -round(-number, places); } - if (places) { - const p = 10 ** (places) || 1; - return round(number * p, 0) / p; + if (!number || !isFinite(number)) { + return number; } - return Math.round(number); + return fromDec(...roundPlaces(number, places)); } diff --git a/lib/runPart.ts b/lib/runPart.ts index 70c4820..aa56d72 100644 --- a/lib/runPart.ts +++ b/lib/runPart.ts @@ -1,4 +1,4 @@ -import { round } from './round.ts'; +import { roundBinary } from './round.ts'; import { clamp } from './clamp.ts'; import { dec2frac } from './dec2frac.ts'; import { general } from './general.ts'; @@ -48,7 +48,7 @@ import { TOKEN_CURRENCY } from './constants.ts'; import { pad } from './pad.ts'; -import { getExponent, getSignificand } from './numberProps.ts'; +import { decToText, pow10, roundPlaces, roundSignificand } from './dec15.ts'; import type { Partition } from './types.ts'; import type { FormatOptions } from './options.ts'; @@ -104,39 +104,42 @@ export function runPart (value: number | string | bigint, part: Partition, opts: value = clamp(value * part.scale); } - // calc exponent - if (part.exponential && typeof value === 'number') { - let v = Math.abs(value); - if (v) { - exp = getExponent(v, 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 && round(v, part.frac_max) === 10) { - v = 1; - exp++; - } - value = (value < 0) ? -v : v; - mantissa += Math.abs(exp); + if (typeof value === 'number' && part.fractions) { + // vulgar fractions round like the stored double, which matches Excel better + if (part.integer) { + const i = Math.abs(roundBinary(value, 1)); + integer += (i < 1) ? '' : Math.floor(i); + } } - - // 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); + else if ( + typeof value === 'number' && isFinite(value) && + (part.exponential || part.integer || part.dec_fractions) + ) { + // Excel rounds the value to 15 significant digits, then to the pattern's + // last digit. The result is the decimal q × 10^k. + let q: number; + let k: number; + if (part.exponential) { + [ q, k, exp ] = roundSignificand(Math.abs(value), part.frac_max, part.int_max, !!part.integer); + mantissa += Math.abs(exp); + value = (value < 0 ? -q : q) * pow10(k); + } + else { + [ q, k ] = roundPlaces(Math.abs(value), part.frac_max); + } + const [ intDigits, fracDigits ] = decToText(q, k); + if (part.integer) { + integer = intDigits; + } + if (part.dec_fractions) { + fraction = fracDigits; + } } + // 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')); @@ -149,7 +152,7 @@ export function runPart (value: number | string | bigint, part: Partition, opts: if (part.denominator && isFinite(part.denominator)) { // predefined denominator denominator += part.denominator; - numerator += round(_dec * part.denominator); + numerator += roundBinary(_dec * part.denominator); if (numerator === '0') { numerator = ''; denominator = ''; 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/dec15.spec.ts b/test/dec15.spec.ts new file mode 100644 index 0000000..973643b --- /dev/null +++ b/test/dec15.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'vitest'; +import { roundDigits, toDec15 } from '../lib/dec15.ts'; + +describe('toDec15', () => { + test('returns 15 digits and the decimal exponent', () => { + expect(toDec15(1)).toEqual([ 100000000000000, 0 ]); + expect(toDec15(1234.5)).toEqual([ 123450000000000, 3 ]); + expect(toDec15(0.001)).toEqual([ 100000000000000, -3 ]); + }); + + test('rounds the exact value, so 1.005 (stored as 1.00499…) becomes 1.005', () => { + expect(toDec15(1.005)).toEqual([ 100500000000000, 0 ]); + expect(toDec15(2.5 - 2 ** -51)).toEqual([ 250000000000000, 0 ]); + }); + + test('an exact tie goes toward zero', () => { + expect(toDec15(10 ** 15 + 5)).toEqual([ 100000000000000, 15 ]); + expect(toDec15(10 ** 15 + 15)).toEqual([ 100000000000001, 15 ]); + expect(toDec15(123456789012345.5)).toEqual([ 123456789012345, 14 ]); + expect(toDec15(99999999999999.25)).toEqual([ 999999999999992, 13 ]); + expect(toDec15(3 * 2 ** 51 + 1)).toEqual([ 675539944105574, 15 ]); + }); + + test('values just below a power of ten', () => { + expect(toDec15(999999999999999.5)).toEqual([ 999999999999999, 14 ]); + expect(toDec15(999999999999999.6)).toEqual([ 100000000000000, 15 ]); + expect(toDec15(0.9999999999999999)).toEqual([ 100000000000000, 0 ]); + }); + + test('values on both sides of the exact scaling ranges', () => { + expect(toDec15(1e-8)).toEqual([ 100000000000000, -8 ]); + expect(toDec15(1.5e-19)).toEqual([ 150000000000000, -19 ]); + expect(toDec15(Number.MAX_SAFE_INTEGER)).toEqual([ 900719925474099, 15 ]); + expect(toDec15(2 ** 70)).toEqual([ 118059162071741, 21 ]); + expect(toDec15(1e37)).toEqual([ 100000000000000, 37 ]); + expect(toDec15(1e300)).toEqual([ 100000000000000, 300 ]); + expect(toDec15(Number.MAX_VALUE)).toEqual([ 179769313486232, 308 ]); + }); + + test('subnormal numbers start from their shortest representation', () => { + expect(toDec15(1e-320)).toEqual([ 100000000000000, -320 ]); + expect(toDec15(5e-324)).toEqual([ 500000000000000, -324 ]); + expect(toDec15(2.225073858507201e-308)).toEqual([ 222507385850720, -308 ]); + }); +}); + +describe('roundDigits', () => { + test('rounds to the leading digits, a tie going away from zero', () => { + expect(roundDigits(123450000000000, 4)).toBe(1235); + expect(roundDigits(123449999999999, 4)).toBe(1234); + expect(roundDigits(100500000000000, 3)).toBe(101); + }); + + test('returns 10^keep when rounding carries', () => { + expect(roundDigits(999500000000000, 3)).toBe(1000); + }); + + test('keeps all 15 digits or rounds to nothing', () => { + expect(roundDigits(123456789012345, 15)).toBe(123456789012345); + expect(roundDigits(500000000000000, 0)).toBe(1); + expect(roundDigits(499999999999999, 0)).toBe(0); + expect(roundDigits(999999999999999, -1)).toBe(0); + }); +}); diff --git a/test/numfmt.spec.ts b/test/numfmt.spec.ts index 1f49d01..eafcc53 100644 --- a/test/numfmt.spec.ts +++ b/test/numfmt.spec.ts @@ -332,3 +332,72 @@ 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'); + // 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'); + 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'); +}); + +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('Engineering notation moves to the next exponent when rounding carries', () => { + expect(numfmt('##0.0E+0', 999.96)).toBe('1.0E+3'); + expect(numfmt('##0.0E+0', 999960)).toBe('1.0E+6'); + expect(numfmt('##0.0E+0', 999999999999999.5)).toBe('1.0E+15'); + expect(numfmt('##0.0E+0', 99.96)).toBe('100.0E+0'); +}); + +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'); +}); + +test('General rounds a tie at the tenth digit as Excel does', () => { + expect(numfmt('General', 4.2394791745)).toBe('4.239479175'); + expect(numfmt('General', 19147467.115)).toBe('19147467.12'); + expect(numfmt('General', 42299445.995)).toBe('42299446'); +}); + +test('Fixed denominator fractions round the numerator as Excel does', () => { + // (36.305 - 36) * 100 is stored as 30.4999999999999716 + expect(numfmt('# ?/100', 36.305)).toBe('36 30/100'); +}); + +// 0.285 * 100 is stored as 28.4999999999999964, but Excel rounds it up. +// Remove .fails once vulgar fractions round as Excel does. +test.fails('Fixed denominator fractions round a near tie up as Excel does', () => { + expect(numfmt('# ?/100', 0.285)).toBe(' 29/100'); +});