Files
ezbookkeeping/src/lib/numeral.ts
T
2025-09-14 01:43:04 +08:00

327 lines
11 KiB
TypeScript

import {
type HiddenAmount,
type NumberFormatOptions,
NumeralSystem,
DecimalSeparator,
DigitGroupingSymbol
} from '@/core/numeral.ts';
import { DEFAULT_DECIMAL_NUMBER_COUNT, MAX_SUPPORTED_DECIMAL_NUMBER_COUNT, DISPLAY_HIDDEN_AMOUNT } from '@/consts/numeral.ts';
import { isDefined, isString, isNumber, replaceAll, removeAll } from './common.ts';
export function sumAmounts(amounts: number[]): number {
let sum = 0;
for (const amount of amounts) {
sum += amount;
}
return sum;
}
export function appendDigitGroupingSymbolAndDecimalSeparator(textualNumber: string, options: NumberFormatOptions): string {
if (!textualNumber) {
return textualNumber;
}
const numeralSystem = options.numeralSystem || NumeralSystem.Default;
const digitGroupingType = options.digitGrouping;
const digitGroupingSymbol = options.digitGroupingSymbol || DigitGroupingSymbol.Default.symbol;
const decimalSeparator = options.decimalSeparator || DecimalSeparator.Default.symbol;
const negative = textualNumber.charAt(0) === '-';
if (negative) {
textualNumber = textualNumber.substring(1);
}
const integerChars: string[] = [];
const decimalChars: string[] = [];
let currentDecimalSeparator = '';
if (textualNumber === DISPLAY_HIDDEN_AMOUNT) {
for (let i = 0; i < textualNumber.length - 2; i++) {
integerChars.push(textualNumber.charAt(i));
}
const decimalStartIndex = Math.max(0, textualNumber.length - 2);
for (let i = decimalStartIndex; i < textualNumber.length; i++) {
decimalChars.push(textualNumber.charAt(i));
}
} else {
for (let i = 0; i < textualNumber.length; i++) {
const ch = textualNumber.charAt(i);
if (!currentDecimalSeparator) {
if (numeralSystem.isDigit(ch)) {
integerChars.push(ch);
} else {
currentDecimalSeparator = ch;
}
} else {
if (numeralSystem.isDigit(ch)) {
decimalChars.push(ch);
} else {
throw new Error('Number \"' + textualNumber + '\" is not a valid textual number');
}
}
}
}
let integer = '';
if (digitGroupingType) {
integer = digitGroupingType.format(integerChars, digitGroupingSymbol);
} else {
integer = integerChars.join('');
}
const decimals = decimalChars.join('');
if (decimals) {
textualNumber = `${integer}${decimalSeparator}${decimals}`;
} else {
textualNumber = integer;
}
if (negative) {
textualNumber = `-${textualNumber}`;
}
return textualNumber;
}
export function parseAmount(str: string, options: NumberFormatOptions): number {
if (!isString(str)) {
return 0;
}
if (!str || str.length < 1) {
return 0;
}
const negative = str.charAt(0) === '-';
if (negative) {
str = str.substring(1);
}
if (!str || str.length < 1) {
return 0;
}
const sign = negative ? -1 : 1;
const numeralSystem = options.numeralSystem || NumeralSystem.Default;
const decimalSeparator = options.decimalSeparator || DecimalSeparator.Default.symbol;
const digitGroupingSymbol = options.digitGroupingSymbol || DigitGroupingSymbol.Default.symbol;
if (str.indexOf(digitGroupingSymbol) >= 0) {
str = removeAll(str, digitGroupingSymbol);
}
let decimalSeparatorPos = str.indexOf(decimalSeparator);
if (decimalSeparatorPos < 0) {
return sign * numeralSystem.parseInt(str) * 100;
} else if (decimalSeparatorPos === 0) {
str = numeralSystem.digitZero + str;
decimalSeparatorPos++;
}
const integer = str.substring(0, decimalSeparatorPos);
const decimals = str.substring(decimalSeparatorPos + 1, str.length);
if (decimals.length < 1) {
return sign * numeralSystem.parseInt(integer) * 100;
} else if (decimals.length === 1) {
return sign * numeralSystem.parseInt(integer) * 100 + sign * numeralSystem.parseInt(decimals) * 10;
} else if (decimals.length === 2) {
return sign * numeralSystem.parseInt(integer) * 100 + sign * numeralSystem.parseInt(decimals);
} else {
return sign * numeralSystem.parseInt(integer) * 100 + sign * numeralSystem.parseInt(decimals.substring(0, 2));
}
}
export function formatAmount(value: number, options: NumberFormatOptions): string {
if (!Number.isSafeInteger(value)) {
throw new Error('Number \"' + value + '\" is not amount number');
}
const numeralSystem = options.numeralSystem || NumeralSystem.Default;
let textualNumber = numeralSystem.formatNumber(value);
if (!textualNumber) {
return textualNumber;
}
const negative = textualNumber.charAt(0) === '-';
if (negative) {
textualNumber = textualNumber.substring(1);
}
const digitGroupingType = options.digitGrouping;
const digitGroupingSymbol = options.digitGroupingSymbol || DigitGroupingSymbol.Default.symbol;
const decimalSeparator = options.decimalSeparator || DecimalSeparator.Default.symbol;
let decimalNumberCount = options.decimalNumberCount;
if (!isNumber(decimalNumberCount) || decimalNumberCount > MAX_SUPPORTED_DECIMAL_NUMBER_COUNT) {
decimalNumberCount = DEFAULT_DECIMAL_NUMBER_COUNT;
}
let integer = numeralSystem.digitZero;
let decimals = numeralSystem.doubleDigitZero;
if (textualNumber.length > 2) {
integer = textualNumber.substring(0, textualNumber.length - 2);
decimals = textualNumber.substring(textualNumber.length - 2);
} else if (textualNumber.length === 2) {
decimals = textualNumber;
} else if (textualNumber.length === 1) {
decimals = numeralSystem.digitZero + textualNumber;
}
if (decimalNumberCount === 0) {
if (decimals === numeralSystem.doubleDigitZero) {
decimals = '';
} else if (decimals.charAt(1) === numeralSystem.digitZero) {
decimals = decimals.charAt(0);
}
} else if (decimalNumberCount === 1) {
if (decimals.charAt(1) === numeralSystem.digitZero) {
decimals = decimals.charAt(0);
}
}
if (options.trimTailZero) {
if (decimals.charAt(0) === numeralSystem.digitZero && decimals.charAt(1) === numeralSystem.digitZero) {
decimals = '';
} else if (decimals.charAt(0) !== numeralSystem.digitZero && decimals.charAt(1) === numeralSystem.digitZero) {
decimals = decimals.charAt(0);
}
}
if (integer && integer.length > 1 && digitGroupingType) {
integer = digitGroupingType.format(integer.split(''), digitGroupingSymbol);
}
if (decimals) {
textualNumber = `${integer}${decimalSeparator}${decimals}`;
} else {
textualNumber = integer;
}
if (negative) {
textualNumber = `-${textualNumber}`;
}
return textualNumber;
}
export function formatHiddenAmount(value: HiddenAmount, options: NumberFormatOptions): string {
return appendDigitGroupingSymbolAndDecimalSeparator(value, options);
}
export function formatNumber(value: number, options: NumberFormatOptions, precision?: number): string {
const numeralSystem = options.numeralSystem || NumeralSystem.Default;
if (isDefined(precision)) {
const ratio = Math.pow(10, precision);
const normalizedValue = Math.trunc(value * ratio);
const textualValue = numeralSystem.formatNumber(normalizedValue / ratio);
return appendDigitGroupingSymbolAndDecimalSeparator(textualValue, options);
} else {
const textualValue = numeralSystem.formatNumber(value);
return appendDigitGroupingSymbolAndDecimalSeparator(textualValue, options);
}
}
export function formatPercent(value: number, precision: number, lowPrecisionValue: string, options: NumberFormatOptions): string {
const numeralSystem = options.numeralSystem || NumeralSystem.Default;
const ratio = Math.pow(10, precision);
const normalizedValue = Math.trunc(value * ratio);
if (value > 0 && normalizedValue < 1 && lowPrecisionValue) {
const systemDecimalSeparator = DecimalSeparator.Dot.symbol;
const decimalSeparator = options.decimalSeparator || DecimalSeparator.Default.symbol;
lowPrecisionValue = numeralSystem.replaceWesternArabicDigitsToLocalizedDigits(lowPrecisionValue);
if (systemDecimalSeparator === decimalSeparator) {
return lowPrecisionValue + '%';
}
return replaceAll(lowPrecisionValue, systemDecimalSeparator, decimalSeparator) + '%';
}
return formatNumber(value, options, precision) + '%';
}
export function getAmountWithDecimalNumberCount(amount: number, decimalNumberCount: number): number {
if (decimalNumberCount === 0) {
return Math.trunc(amount / 100) * 100;
} else if (decimalNumberCount === 1) {
return Math.trunc(amount / 10) * 10;
}
return amount;
}
export function formatExchangeRateAmount(exchangeRateAmount: number, options: NumberFormatOptions): string {
const numeralSystem = options.numeralSystem || NumeralSystem.Default;
const rateStr = numeralSystem.formatNumber(exchangeRateAmount);
const decimalSeparator = DecimalSeparator.Dot.symbol;
if (rateStr.indexOf(decimalSeparator) < 0) {
return appendDigitGroupingSymbolAndDecimalSeparator(rateStr, options);
} else {
let firstNonZeroPos = 0;
for (let i = 0; i < rateStr.length; i++) {
if (rateStr.charAt(i) !== decimalSeparator && rateStr.charAt(i) !== numeralSystem.digitZero) {
firstNonZeroPos = Math.min(i + 4, rateStr.length);
break;
}
}
const trimmedRateStr = rateStr.substring(0, Math.max(6, Math.max(firstNonZeroPos, rateStr.indexOf(decimalSeparator) + 2)));
return appendDigitGroupingSymbolAndDecimalSeparator(trimmedRateStr, options);
}
}
export function getAdaptiveDisplayAmountRate(amount1: number, amount2: number, options: NumberFormatOptions, fromExchangeRate?: { rate: string }, toExchangeRate?: { rate: string }): string | null {
const numeralSystem = options.numeralSystem || NumeralSystem.Default;
if (!amount1 || !amount2 || amount1 === amount2) {
if (!fromExchangeRate || !fromExchangeRate.rate || !toExchangeRate || !toExchangeRate.rate) {
return null;
}
amount1 = parseFloat(fromExchangeRate.rate);
amount2 = parseFloat(toExchangeRate.rate);
}
if (amount1 > amount2) {
const rate = amount1 / amount2;
const displayRateStr = formatExchangeRateAmount(rate, options);
return `${displayRateStr} : ${numeralSystem.getLocalizedDigit(1)}`;
} else {
const rate = amount2 / amount1;
const displayRateStr = formatExchangeRateAmount(rate, options);
return `${numeralSystem.getLocalizedDigit(1)} : ${displayRateStr}`;
}
}
export function getExchangedAmountByRate(amount: number, fromRate: string, toRate: string): number | null {
const exchangeRate = parseFloat(toRate) / parseFloat(fromRate);
if (!isNumber(exchangeRate)) {
return null;
}
return amount * exchangeRate;
}