UNPKG

pretty-bytes

Version:

Convert bytes to a human readable string: 1337 → 1.34 kB

288 lines (233 loc) • 9.83 kB
const BYTE_UNITS = [ 'B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', ]; const BIBYTE_UNITS = [ 'B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB', ]; const BIT_UNITS = [ 'b', 'kbit', 'Mbit', 'Gbit', 'Tbit', 'Pbit', 'Ebit', 'Zbit', 'Ybit', ]; const BIBIT_UNITS = [ 'b', 'kibit', 'Mibit', 'Gibit', 'Tibit', 'Pibit', 'Eibit', 'Zibit', 'Yibit', ]; // Indexed by `bits` and then `binary`, so the four unit systems are picked in one step. const UNIT_SETS = [BYTE_UNITS, BIBYTE_UNITS, BIT_UNITS, BIBIT_UNITS]; /* `Number#toString` switches to exponential notation outside the range [1e-6, 1e21) and `Intl.NumberFormat` drops every fraction digit past the 100 it accepts. Byte counts read as plain decimal numbers, so both losses are undone here. Expects a finite, non-negative number. */ const toDecimalString = number => { if (number >= 1e-6 && number < 1e21) { return String(number); } const [mantissa, exponentString] = number.toExponential().split('e'); const digits = mantissa.replace('.', ''); const pointPosition = Number(exponentString) + 1; const isPositive = pointPosition > 0; const padded = isPositive ? digits.padEnd(pointPosition, '0') : '0'.repeat(1 - pointPosition) + digits; const integerPart = padded.slice(0, isPositive ? pointPosition : 1); const fractionPart = padded.slice(isPositive ? pointPosition : 1).replace(/0+$/, ''); return fractionPart ? `${integerPart}.${fractionPart}` : integerPart; }; /* Formats the given number using `Number#toLocaleString`. - If locale is a string or array, the value is expected to be a BCP 47 language tag or list of language tags (for example: `de`). - If locale is true, the system default locale is used for translation. - If locale is false or unspecified, formatting options are applied without localization; without options, the number is written out as plain digits. */ const toLocaleString = (number, locale, options) => { if (typeof locale === 'string' || Array.isArray(locale) || locale === true) { const locales = locale === true ? undefined : locale; if (options !== undefined) { return number.toLocaleString(locales, options); } // `Intl.NumberFormat` limits itself to 3 *fraction* digits while the default here is 3 *significant* digits, so the limit comes from the value. const text = toDecimalString(number); const pointIndex = text.indexOf('.'); const maximumFractionDigits = pointIndex === -1 ? 0 : text.length - pointIndex - 1; // `Intl.NumberFormat` accepts at most 100 fraction digits. Rounding to 3 significant digits only needs more than that for absurdly small values, which are written out as plain digits instead. if (maximumFractionDigits > 100) { return text; } return number.toLocaleString(locales, {maximumFractionDigits}); } if (options === undefined) { return toDecimalString(number); } return number.toLocaleString('en-US', {...options, useGrouping: false}); }; const log10 = numberOrBigInt => { if (typeof numberOrBigInt === 'number') { return Math.log10(numberOrBigInt); } const string = numberOrBigInt.toString(10); // A `number` only holds about 15 significant digits, so the remaining digits cannot move the magnitude. The result can still land exactly on a unit boundary, which the caller corrects for. return string.length + Math.log10(`0.${string.slice(0, 15)}`); }; const log = numberOrBigInt => { if (typeof numberOrBigInt === 'number') { return Math.log(numberOrBigInt); } return log10(numberOrBigInt) * Math.log(10); }; /* A `bigint` that a `number` can hold exactly is divided the same way, so the output never depends on which type it was given as. `floor(a / b) + (a % b) / b` is a different double than `a / b`, which flips the rounding at ties like 1805 bytes. */ const divide = (numberOrBigInt, base, exponent) => { const divisor = base ** exponent; if (typeof numberOrBigInt === 'number' || numberOrBigInt <= Number.MAX_SAFE_INTEGER) { return Number(numberOrBigInt) / divisor; } // `divisor` is not exact for every unit, as `1000 ** 8` is not `1e24`, so the exact divisor is rebuilt from the base and the exponent. const bigintDivisor = BigInt(base) ** BigInt(exponent); const integerPart = numberOrBigInt / bigintDivisor; const remainder = numberOrBigInt % bigintDivisor; return Number(integerPart) + (Number(remainder) / divisor); }; /* The plain decimal digits of a scaled value that no `number` can hold. `Intl.NumberFormat` is not involved, so the output carries no grouping or localized separator, the same as the fallback for a value with more fraction digits than it accepts. */ const toUnrepresentableString = (number, base, exponent, localeOptions) => { const scale = BigInt(base) ** BigInt(exponent); const whole = number / scale; // The 3 significant digit default never bites here: it only ever rounds to at least as many digits as the value has integers, and this one has far more than 3. if (localeOptions === undefined) { return whole.toString(); } // The fraction digit options take over from that default, so the integer part stays exact and the fraction is truncated. const minimumFractionDigits = localeOptions.minimumFractionDigits ?? 0; const maximumFractionDigits = Math.max(localeOptions.maximumFractionDigits ?? 3, minimumFractionDigits); const truncated = ((number % scale) * (10n ** BigInt(maximumFractionDigits)) / scale).toString().padStart(maximumFractionDigits, '0'); const fraction = truncated.replace(/0+$/, '').padEnd(minimumFractionDigits, '0'); return fraction ? `${whole}.${fraction}` : whole.toString(); }; /* The sign to print in front of the number. A difference of exactly zero gets a space instead of a plus sign, for better alignment. `0 === 0n` is `false`, so both are compared. */ const signOf = (number, signed) => { if (number < 0) { return '-'; } if (!signed) { return ''; } return number === 0 || number === 0n ? ' ' : '+'; }; const assertFixedWidth = fixedWidth => { if (fixedWidth !== undefined && (typeof fixedWidth !== 'number' || !Number.isSafeInteger(fixedWidth) || fixedWidth < 0)) { throw new TypeError(`Expected fixedWidth to be a non-negative integer, got ${typeof fixedWidth}: ${fixedWidth}`); } }; /* `Intl.NumberFormat` silently truncates non-integers, so they are rejected here instead of quietly changing the output. */ const assertFractionDigits = (name, value) => { if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 100)) { throw new TypeError(`Expected ${name} to be an integer between 0 and 100, got ${typeof value}: ${value}`); } }; const buildLocaleOptions = options => { const {minimumFractionDigits, maximumFractionDigits} = options; if (minimumFractionDigits === undefined && maximumFractionDigits === undefined) { return undefined; } assertFractionDigits('minimumFractionDigits', minimumFractionDigits); assertFractionDigits('maximumFractionDigits', maximumFractionDigits); // Checked here rather than left to `Intl.NumberFormat`, which is skipped for a value past the number range. if (minimumFractionDigits > maximumFractionDigits) { throw new RangeError(`Expected minimumFractionDigits (${minimumFractionDigits}) to not be greater than maximumFractionDigits (${maximumFractionDigits})`); } return { ...(minimumFractionDigits !== undefined && {minimumFractionDigits}), ...(maximumFractionDigits !== undefined && {maximumFractionDigits}), roundingMode: 'trunc', }; }; export default function prettyBytes(number, options) { if (typeof number !== 'bigint' && !Number.isFinite(number)) { throw new TypeError(`Expected a finite number, got ${typeof number}: ${number}`); } options = { bits: false, binary: false, space: true, nonBreakingSpace: false, ...options, }; assertFixedWidth(options.fixedWidth); const localeOptions = buildLocaleOptions(options); // Indexed by `bits` then `binary`, so the four combinations line up in one table. const UNITS = UNIT_SETS[(options.bits ? 2 : 0) + (options.binary ? 1 : 0)]; const separator = options.space ? (options.nonBreakingSpace ? '\u00A0' : ' ') : ''; // Normalize `-0` to `0` so it never formats as `-0`. if (Object.is(number, -0)) { number = 0; } const prefix = signOf(number, options.signed); if (number < 0) { number = -number; } const base = options.binary ? 1024 : 1000; let exponent = 0; if (number >= 1) { exponent = Math.min(Math.floor(options.binary ? log(number) / Math.log(base) : log10(number) / 3), UNITS.length - 1); } // A `number` always scales down to something a `number` can hold, and a `bigint` stops being able to once the quotient passes `Number.MAX_VALUE`, where `divide` overflows to `Infinity` and the exact quotient is formatted instead. const scaled = divide(number, base, exponent); let numberString; if (Number.isFinite(scaled)) { number = scaled; // A value that sits just below a unit boundary, like `999999999999999` bytes, has a `log10` that rounds up to the boundary itself, so the exponent has to be corrected back. if (number < 1 && exponent > 0) { number *= base; exponent -= 1; } if (!localeOptions) { const minimumPrecision = Math.max(3, Math.floor(number).toString().length); number = Number(number.toPrecision(minimumPrecision)); if (number >= base && exponent < UNITS.length - 1) { number /= base; exponent += 1; } } numberString = toLocaleString(number, options.locale, localeOptions); } else { // The scaled value is past what a `number` can hold, so the exact quotient is written out instead. numberString = toUnrepresentableString(number, base, exponent, localeOptions); } const result = prefix + numberString + separator + UNITS[exponent]; return result.padStart(options.fixedWidth ?? 0); }