|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 |
- /**
- * Valid values that can be converted to exponential notation.
- */
- export type ValidValue = string | number | bigint;
-
- interface BaseOptions {
- /**
- * The decimal point character to use. Defaults to ".".
- */
- decimalPoint?: string;
- /**
- * The grouping symbol to use. Defaults to ",".
- */
- groupingSymbol?: string;
- /**
- * The exponent character to use. Defaults to "e".
- */
- exponentDelimiter?: string;
- }
-
- /**
- * Options to use when converting a number to exponential notation.
- */
- export type NumberToExponentialOptions = BaseOptions;
-
- /**
- * Options to use when converting exponential notation to a number string.
- */
- export interface ExponentialToNumberStringOptions extends BaseOptions {
- /**
- * The maximum length to represent the return value else the input will be preserved.
- */
- maxLength?: number;
- }
-
- /**
- * Default decimal point character.
- */
- const DEFAULT_DECIMAL_POINT = '.' as const;
-
- /**
- * Default grouping symbol.
- */
- const DEFAULT_GROUPING_SYMBOL = ',' as const;
-
- /**
- * Default exponent character.
- */
- const DEFAULT_EXPONENT_DELIMITER = 'e' as const;
-
- /**
- * Default positive symbol.
- */
- const DEFAULT_POSITIVE_SYMBOL = '+' as const;
-
- /**
- * Default negative symbol.
- */
- const DEFAULT_NEGATIVE_SYMBOL = '-' as const;
-
- /**
- * Forces a value to have a decimal point.
- * @param value - The value to force a decimal point on.
- * @param decimalPoint - The decimal point character to use.
- * @returns string The value with a decimal point.
- */
- const forceDecimalPoint = (value: string, decimalPoint: string) => (
- value.includes(decimalPoint)
- ? value
- : `${value}${decimalPoint}0`
- );
-
- /**
- * Error thrown when a value is not in exponential notation.
- */
- export class InvalidFormatError extends TypeError {
- constructor(value: string) {
- super(`Value must be in exponential notation. Received: ${value}`);
- }
- }
-
- /**
- * Error thrown when a value is not a string, number, or bigint.
- */
- export class InvalidValueTypeError extends TypeError {
- constructor(value: unknown) {
- super(`Value must be a string, number, or bigint. Received: ${typeof value}`);
- }
- }
-
- /**
- * Forces a number to have a sign.
- * @param value - The value to force a sign on.
- * @returns string The value with a sign.
- */
- const forceNumberSign = (value: bigint) => {
- const isExponentNegative = value < 0;
- const exponentValueAbs = isExponentNegative ? -value : value;
- const exponentSign = isExponentNegative ? DEFAULT_NEGATIVE_SYMBOL : DEFAULT_POSITIVE_SYMBOL;
- return `${exponentSign}${exponentValueAbs}`;
- };
-
- interface ExponentialComponents {
- integer: string;
- fractional: string;
- exponent: string;
- }
-
- /**
- * Extracts the integer, fractional, and exponent components of a string in exponential notation.
- * @param value - The string value to extract components from.
- * @param options - Options to use when extracting components.
- * @returns ExponentialComponents The extracted components.
- */
- export const extractExponentialComponents = (
- value: string,
- options = {} as BaseOptions,
- ): ExponentialComponents => {
- const {
- decimalPoint = DEFAULT_DECIMAL_POINT,
- groupingSymbol = DEFAULT_GROUPING_SYMBOL,
- exponentDelimiter = DEFAULT_EXPONENT_DELIMITER,
- } = options;
- const valueWithoutGroupingSymbols = value.replace(new RegExp(`${groupingSymbol}`, 'g'), '');
- const exponentDelimiterIndex = valueWithoutGroupingSymbols.indexOf(exponentDelimiter);
-
- if (exponentDelimiterIndex < 0) {
- // We force the value to have decimal point so that we can extract the integer and fractional
- // components.
- const stringValueWithDecimal = forceDecimalPoint(valueWithoutGroupingSymbols, decimalPoint);
- const [integerRaw, fractionalRaw] = stringValueWithDecimal.split(decimalPoint);
- const integer = integerRaw.replace(/^0+/, '');
- const fractional = fractionalRaw.replace(/0+$/, '');
- const exponentValue = BigInt(integer.length - 1);
- return {
- integer,
- exponent: forceNumberSign(exponentValue),
- fractional,
- };
- }
-
- if (exponentDelimiterIndex !== valueWithoutGroupingSymbols.lastIndexOf(exponentDelimiter)) {
- throw new InvalidFormatError(value);
- }
-
- const [base, exponentRaw] = valueWithoutGroupingSymbols.split(exponentDelimiter);
- const [integerRaw, fractionalRaw = ''] = base.split(decimalPoint);
- const integerWithoutZeroes = integerRaw.replace(/^0+/, '');
- const integer = integerWithoutZeroes[0] ?? '0';
- const extraIntegerDigits = integerWithoutZeroes.slice(1);
- const fractional = `${extraIntegerDigits}${fractionalRaw.replace(/0+$/, '')}`;
- const exponentValue = BigInt(exponentRaw) + BigInt(extraIntegerDigits.length);
- return {
- integer,
- exponent: forceNumberSign(exponentValue),
- fractional,
- };
- };
-
- /**
- * Converts a numeric value to a string in exponential notation. Supports numbers of all types.
- * @param value - The value to convert.
- * @param options - Options to use when extracting components.
- * @returns string The value in exponential notation.
- */
- export const numberToExponential = (
- value: ValidValue,
- options = {} as NumberToExponentialOptions,
- ): string => {
- const valueRaw = value as unknown;
-
- if (typeof valueRaw === 'bigint' || typeof valueRaw === 'number') {
- return numberToExponential(valueRaw.toString(), options);
- }
-
- if (typeof valueRaw !== 'string') {
- throw new InvalidValueTypeError(valueRaw);
- }
-
- if (valueRaw.startsWith(DEFAULT_NEGATIVE_SYMBOL)) {
- return `${DEFAULT_NEGATIVE_SYMBOL}${numberToExponential(valueRaw.slice(DEFAULT_NEGATIVE_SYMBOL.length), options)}}`;
- }
-
- const {
- decimalPoint = DEFAULT_DECIMAL_POINT,
- groupingSymbol = DEFAULT_GROUPING_SYMBOL,
- exponentDelimiter = DEFAULT_EXPONENT_DELIMITER,
- } = options;
-
- // Remove invalid characters.
- // We also remove the grouping symbols in case they come from human input.
- const stringValue = valueRaw
- .replace(new RegExp(`${groupingSymbol}`, 'g'), '')
- .toLowerCase()
- .replace(/\s/g, '');
-
- const {
- integer,
- fractional,
- exponent,
- } = extractExponentialComponents(stringValue, options);
-
- const significantDigits = `${integer}${fractional}`;
- if (significantDigits.length === 0) {
- // We copy the behavior from `Number.prototype.toExponential` here.
- return `0${exponentDelimiter}${DEFAULT_POSITIVE_SYMBOL}0`;
- }
-
- const significandInteger = significantDigits[0];
- const significandFractional = significantDigits.slice(1).replace(/0+$/, '');
-
- if (significandFractional.length === 0) {
- return `${significandInteger}${exponentDelimiter}${exponent}`;
- }
-
- return `${significandInteger}${decimalPoint}${significandFractional}${exponentDelimiter}${exponent}`;
- };
-
- export const exponentialToNumberString = (
- exp: string,
- options = {} as ExponentialToNumberStringOptions,
- ) => {
- const {
- decimalPoint = DEFAULT_DECIMAL_POINT,
- maxLength = Number.MAX_SAFE_INTEGER,
- } = options;
- const { integer, fractional, exponent } = extractExponentialComponents(exp, options);
- const currentDecimalPointIndex = integer.length;
- const newDecimalPointIndex = currentDecimalPointIndex + Number(exponent);
- if (newDecimalPointIndex > maxLength) {
- // Integer part overflow
- //
- // Important to bail out as early as possible.
- throw new RangeError('Could not represent number in string form.');
- }
- const fractionalDigits = fractional.length > 0 ? fractional : '0';
- const significantDigits = `${integer}${fractionalDigits}`;
- if (significantDigits.length > maxLength) {
- // Digits overflow
- //
- // Should we choose to truncate instead of throw an exception?
- // Quite tricky when only the fractional part overflows.
- throw new RangeError('Could not represent number in string form.');
- }
- let newInteger: string;
- try {
- newInteger = significantDigits.slice(0, newDecimalPointIndex)
- .padEnd(newDecimalPointIndex, '0');
- } catch {
- // Error in padEnd(), shadow it.
- throw new RangeError('Could not represent number in string form.');
- }
- const newFractional = significantDigits.slice(newDecimalPointIndex).replace(/0+$/, '');
- if (newFractional.length === 0) {
- return newInteger;
- }
- if (newInteger.length + decimalPoint.length + newFractional.length > maxLength) {
- // Fractional part overflow
- //
- // Final length check.
- throw new RangeError('Could not represent number in string form.');
- }
- return `${newInteger}${decimalPoint}${newFractional}`;
- };
|