diff --git a/lib/runPart.ts b/lib/runPart.ts index 70c4820..2ae18b4 100644 --- a/lib/runPart.ts +++ b/lib/runPart.ts @@ -48,7 +48,8 @@ import { TOKEN_CURRENCY } from './constants.ts'; import { pad } from './pad.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'; @@ -105,38 +106,50 @@ 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 && round(v, part.frac_max) === 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); } - // 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(dec ?? 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..4723b89 --- /dev/null +++ b/lib/toDecimal.ts @@ -0,0 +1,136 @@ +/** + * 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; +const MIN_NORMAL = 2 ** -1022; + +// 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); + if (v < MIN_NORMAL) { + // 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 + 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 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 + * 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 ] { + const { digits, point } = roundDigits(dec, dec.point + places); + 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..843e475 100644 --- a/test/numfmt.spec.ts +++ b/test/numfmt.spec.ts @@ -332,3 +332,48 @@ 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('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'); +});