src/format/index.ts
type Template = 'number' | 'usd' | 'irt' | 'irr' | 'percent';
type Precision = 'auto' | 'high' | 'medium' | 'low';
type Language = 'en' | 'fa';
type OutputFormat = 'plain' | 'html' | 'markdown';
interface Options {
precision?: Precision;
template?: Template;
language?: Language;
outputFormat?: OutputFormat;
prefixMarker?: string;
postfixMarker?: string;
prefix?: string;
postfix?: string;
}
interface FormattedObject {
value?: string;
prefix: string;
postfix: string;
sign: string;
wholeNumber: string;
}
interface LanguageConfig {
prefixMarker?: string;
postfixMarker?: string;
prefix?: string;
postfix?: string;
}
class NumberFormatter {
private options: Options = {
language: 'en',
template: 'number',
precision: 'high',
outputFormat: 'plain',
prefixMarker: 'i',
postfixMarker: 'i',
prefix: '',
postfix: '',
};
constructor(options: Options = {}) {
this.options = { ...this.options, ...options };
}
setLanguage(lang: Language, config: LanguageConfig = {}): NumberFormatter {
this.options.language = lang;
this.options.prefixMarker =
config.prefixMarker || this.options.prefixMarker;
this.options.postfixMarker =
config.postfixMarker || this.options.postfixMarker;
this.options.prefix = config.prefix || this.options.prefix;
this.options.postfix = config.postfix || this.options.postfix;
return this;
}
setTemplate(template: Template, precision: Precision): NumberFormatter {
this.options.template = template;
this.options.precision = precision;
return this;
}
toJson(input: string | number): FormattedObject {
const formattedObject = this.format(input);
delete formattedObject.value;
return formattedObject;
}
toString(input: string | number): string {
const formattedObject = this.format(input);
return formattedObject.value || '';
}
toPlainString(input: string | number): string {
this.options.outputFormat = 'plain';
const formattedObject = this.format(input);
return formattedObject.value || '';
}
toHtmlString(input: string | number): string {
this.options.outputFormat = 'html';
const formattedObject = this.format(input);
return formattedObject.value || '';
}
toMdString(input: string | number): string {
this.options.outputFormat = 'markdown';
const formattedObject = this.format(input);
return formattedObject.value || '';
}
// Private methods...
private isENotation(input: string): boolean {
return /^[-+]?[0-9]*\.?[0-9]+([eE][-+][0-9]+)$/.test(input);
}
private format(input: string | number): FormattedObject {
let { precision, template } = this.options;
const {
language,
outputFormat,
prefixMarker,
postfixMarker,
prefix,
postfix,
} = this.options;
if (!input) return {} as FormattedObject;
if (!template?.match(/^(number|usd|irt|irr|percent)$/g))
template = 'number';
if (this.isENotation(input.toString())) {
input = this.convertENotationToRegularNumber(Number(input));
}
// Replace each Persian/Arabic numeral in the string with its English counterpart and strip all non-numeric chars
let numberString = input
.toString()
.replace(/[\u0660-\u0669\u06F0-\u06F9]/g, function (match: string) {
return String(match.charCodeAt(0) & 0xf);
})
.replace(/[^\d.-]/g, '');
// Stripping leading zeros and trailing zeros after a decimal point
numberString = numberString
.replace(/^0+(?=\d)/g, '')
.replace(/(?<=\.\d*)0+$|(?<=\.\d)0+\b/g, '');
const number = Math.abs(Number(numberString));
let p, d, r, c;
let f = 0;
// Auto precision selection
if (precision === 'auto') {
if (template.match(/^(usd|irt|irr)$/g)) {
if (number >= 0.0001 && number < 100_000_000_000) {
precision = 'high';
} else {
precision = 'medium';
}
} else if (template === 'number') {
precision = 'medium';
} else if (template === 'percent') {
precision = 'low';
}
}
if (precision === 'medium') {
if (number >= 0 && number < 0.0001) {
p = 33;
d = 4;
r = false;
c = true;
} else if (number >= 0.0001 && number < 0.001) {
p = 7;
d = 4;
r = false;
c = false;
} else if (number >= 0.001 && number < 0.01) {
p = 5;
d = 3;
r = false;
c = false;
} else if (number >= 0.001 && number < 0.1) {
p = 3;
d = 2;
r = false;
c = false;
} else if (number >= 0.1 && number < 1) {
p = 1;
d = 1;
r = false;
c = false;
} else if (number >= 1 && number < 10) {
p = 3;
d = 3;
r = false;
c = false;
} else if (number >= 10 && number < 100) {
p = 2;
d = 2;
r = false;
c = false;
} else if (number >= 100 && number < 1000) {
p = 1;
d = 1;
r = false;
c = false;
} else if (number >= 1000) {
const x = Math.floor(Math.log10(number)) % 3;
p = 2 - x;
d = 2 - x;
r = true;
c = true;
} else {
p = 0;
d = 0;
r = true;
c = true;
}
} else if (precision === 'low') {
if (number >= 0 && number < 0.01) {
p = 2;
d = 0;
r = true;
c = false;
f = 2;
} else if (number >= 0.01 && number < 0.1) {
p = 2;
d = 1;
r = true;
c = false;
} else if (number >= 0.1 && number < 1) {
p = 2;
d = 2;
r = true;
c = false;
} else if (number >= 1 && number < 10) {
p = 2;
d = 2;
r = true;
c = false;
f = 2;
} else if (number >= 10 && number < 100) {
p = 1;
d = 1;
r = true;
c = false;
f = 1;
} else if (number >= 100 && number < 1000) {
p = 0;
d = 0;
r = true;
c = false;
} else if (number >= 1000) {
const x = Math.floor(Math.log10(number)) % 3;
p = 1 - x;
d = 1 - x;
r = true;
c = true;
} else {
p = 0;
d = 0;
r = true;
c = true;
f = 2;
}
} else {
// precision === "high"
if (number >= 0 && number < 1) {
p = 33;
d = 4;
r = false;
c = false;
} else if (number >= 1 && number < 10) {
p = 3;
d = 3;
r = true;
c = false;
} else if (number >= 10 && number < 100) {
p = 2;
d = 2;
r = true;
c = false;
} else if (number >= 100 && number < 1000) {
p = 2;
d = 2;
r = true;
c = false;
} else if (number >= 1000 && number < 10000) {
p = 1;
d = 1;
r = true;
c = false;
} else {
p = 0;
d = 0;
r = true;
c = false;
}
}
return this.reducePrecision(
numberString,
p,
d,
r,
c,
f,
template,
language,
outputFormat,
prefixMarker,
postfixMarker,
prefix,
postfix
);
}
private convertENotationToRegularNumber(eNotation: number): string {
const [coefficientStr, exponentStr] = eNotation.toString().split('e');
const coefficientLength = coefficientStr
.replace('.', '')
.replace('-', '').length;
const exponent = parseFloat(exponentStr);
const precision = Math.max(coefficientLength - exponent, 1);
return eNotation.toFixed(precision);
}
private reducePrecision(
numberString: string,
precision = 30,
nonZeroDigits = 4,
round = false,
compress = false,
fixedDecimalZeros = 0,
template = 'number',
language = 'en',
outputFormat = 'plain',
prefixMarker = 'span',
postfixMarker = 'span',
prefix = '',
postfix = ''
) {
if (!numberString) {
return {} as FormattedObject;
}
numberString = numberString.toString();
const maxPrecision = 30;
const maxIntegerDigits = 21;
const scaleUnits = template.match(/^(number|percent)$/g)
? {
'': '',
K: ' هزار',
M: ' میلیون',
B: ' میلیارد',
T: ' تریلیون',
Qd: ' کادریلیون',
Qt: ' کنتیلیون',
}
: {
'': '',
K: ' هزار ت',
M: ' میلیون ت',
B: ' میلیارد ت',
T: ' همت',
Qd: ' هزار همت',
Qt: ' میلیون همت',
};
let parts = /^(-)?(\d+)\.?([0]*)(\d*)$/g.exec(numberString);
if (!parts) {
return {} as FormattedObject;
}
const sign = parts[1] || '';
let nonFractionalStr = parts[2];
let fractionalZeroStr = parts[3];
let fractionalNonZeroStr = parts[4];
let unitPrefix = '';
let unitPostfix = '';
if (fractionalZeroStr.length >= maxPrecision) {
// Number is smaller than maximum precision
fractionalZeroStr = '0'.padEnd(maxPrecision - 1, '0');
fractionalNonZeroStr = '1';
} else if (fractionalZeroStr.length + nonZeroDigits > precision) {
// decrease non-zero digits
nonZeroDigits = precision - fractionalZeroStr.length;
if (nonZeroDigits < 1) nonZeroDigits = 1;
} else if (nonFractionalStr.length > maxIntegerDigits) {
nonFractionalStr = '0';
fractionalZeroStr = '';
fractionalNonZeroStr = '';
}
// compress large numbers
if (compress && nonFractionalStr.length >= 4) {
const scaleUnitKeys = Object.keys(scaleUnits);
let scaledWholeNumber = nonFractionalStr;
let unitIndex = 0;
while (+scaledWholeNumber > 999 && unitIndex < scaleUnitKeys.length - 1) {
scaledWholeNumber = (+scaledWholeNumber / 1000).toFixed(2);
unitIndex++;
}
unitPostfix = scaleUnitKeys[unitIndex];
parts = /^(-)?(\d+)\.?([0]*)(\d*)$/g.exec(scaledWholeNumber.toString());
if (!parts) {
return {} as FormattedObject;
}
// sign = parts[1] || "";
nonFractionalStr = parts[2];
fractionalZeroStr = parts[3];
fractionalNonZeroStr = parts[4];
}
// Truncate the fractional part or round it
// if (precision > 0 && nonZeroDigits > 0 && fractionalNonZeroStr.length > nonZeroDigits) {
if (fractionalNonZeroStr.length > nonZeroDigits) {
if (!round) {
fractionalNonZeroStr = fractionalNonZeroStr.substring(0, nonZeroDigits);
} else {
if (parseInt(fractionalNonZeroStr[nonZeroDigits]) < 5) {
fractionalNonZeroStr = fractionalNonZeroStr.substring(
0,
nonZeroDigits
);
} else {
fractionalNonZeroStr = (
parseInt(fractionalNonZeroStr.substring(0, nonZeroDigits)) + 1
).toString();
// If overflow occurs (e.g., 999 + 1 = 1000), adjust the substring length
if (fractionalNonZeroStr.length > nonZeroDigits) {
if (fractionalZeroStr.length > 0) {
fractionalZeroStr = fractionalZeroStr.substring(
0,
fractionalZeroStr.length - 1
);
} else {
nonFractionalStr = (Number(nonFractionalStr) + 1).toString();
fractionalNonZeroStr = fractionalNonZeroStr.substring(1);
}
}
}
}
}
// Using dex style
if (compress && fractionalZeroStr !== '' && unitPostfix === '') {
fractionalZeroStr =
'0' +
fractionalZeroStr.length.toString().replace(/\d/g, function (match) {
return [
'₀',
'₁',
'₂',
'₃',
'₄',
'₅',
'₆',
'₇',
'₈',
'₉',
][parseInt(match, 10)];
});
}
let fractionalPartStr = `${fractionalZeroStr}${fractionalNonZeroStr}`;
fractionalPartStr = fractionalPartStr.substring(0, precision);
fractionalPartStr = fractionalPartStr.replace(/^(\d*[1-9])0+$/g, '$1');
// Output Formating, Prefix, Postfix
if (template === 'usd') {
unitPrefix = language === 'en' ? '$' : '';
if (!unitPostfix) unitPostfix = language === 'fa' ? ' دلار' : '';
} else if (template === 'irr') {
if (!unitPostfix) unitPostfix = language === 'fa' ? ' ر' : ' R';
} else if (template === 'irt') {
if (!unitPostfix) unitPostfix = language === 'fa' ? ' ت' : ' T';
} else if (template === 'percent') {
if (language === 'en') {
unitPostfix += '%';
} else {
unitPostfix += !unitPostfix ? '٪' : ' درصد';
}
}
unitPrefix = prefix + unitPrefix;
unitPostfix += postfix;
if (outputFormat === 'html') {
if (unitPrefix)
unitPrefix = `<${prefixMarker}>${unitPrefix}</${prefixMarker}>`;
if (unitPostfix)
unitPostfix = `<${postfixMarker}>${unitPostfix}</${postfixMarker}>`;
} else if (outputFormat === 'markdown') {
if (unitPrefix)
unitPrefix = `${prefixMarker}${unitPrefix}${prefixMarker}`;
if (unitPostfix)
unitPostfix = `${postfixMarker}${unitPostfix}${postfixMarker}`;
}
const thousandSeparatorRegex = /\B(?=(\d{3})+(?!\d))/g;
const fixedDecimalZeroStr = fixedDecimalZeros
? '.'.padEnd(fixedDecimalZeros + 1, '0')
: '';
let out = '';
let wholeNumberStr;
if (precision <= 0 || nonZeroDigits <= 0 || !fractionalNonZeroStr) {
wholeNumberStr = `${nonFractionalStr.replace(
thousandSeparatorRegex,
','
)}${fixedDecimalZeroStr}`;
} else {
wholeNumberStr = `${nonFractionalStr.replace(
thousandSeparatorRegex,
','
)}.${fractionalPartStr}`;
}
out = `${sign}${unitPrefix}${wholeNumberStr}${unitPostfix}`;
const formattedObject: FormattedObject = {
value: out,
prefix: unitPrefix,
postfix: unitPostfix,
sign: sign,
wholeNumber: wholeNumberStr,
};
// Convert output to Persian numerals if language is "fa"
if (language === 'fa') {
formattedObject.value = (formattedObject?.value ?? '')
.replace(/[0-9]/g, c => String.fromCharCode(c.charCodeAt(0) + 1728))
.replace(/,/g, '٬')
.replace(/\./g, '٫')
.replace(/(K|M|B|T|Qt|Qd)/g, function (c: string) {
return String(scaleUnits[c as keyof typeof scaleUnits]);
});
formattedObject.postfix = formattedObject.postfix
.replace(/[0-9]/g, c => String.fromCharCode(c.charCodeAt(0) + 1728))
.replace(/(K|M|B|T|Qt|Qd)/g, function (c: string) {
return String(scaleUnits[c as keyof typeof scaleUnits]);
});
formattedObject.wholeNumber = formattedObject.wholeNumber
.replace(/[0-9]/g, c => String.fromCharCode(c.charCodeAt(0) + 1728))
.replace(/(K|M|B|T|Qt|Qd)/g, function (c: string) {
return String(scaleUnits[c as keyof typeof scaleUnits]);
});
}
return formattedObject;
}
}
export default NumberFormatter;