Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 29 additions & 16 deletions lib/runPart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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'));

Expand Down
136 changes: 136 additions & 0 deletions lib/toDecimal.ts
Original file line number Diff line number Diff line change
@@ -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
];
}
2 changes: 1 addition & 1 deletion test/bigint.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
45 changes: 45 additions & 0 deletions test/numfmt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});