@ww-samuel/to-words
Version:
Converts numbers (including decimal points) into words & currency.
292 lines (259 loc) • 8.96 kB
text/typescript
import { ConstructorOf, ConverterOptions, LocaleInterface, NumberWordMap, ToWordsOptions } from './types';
import enAe from './locales/en-AE';
import enBd from './locales/en-BD';
import enGh from './locales/en-GH';
import enIn from './locales/en-IN';
import enMm from './locales/en-MM';
import enMu from './locales/en-MU';
import enNg from './locales/en-NG';
import enNp from './locales/en-NP';
import enUs from './locales/en-US';
import enGb from './locales/en-GB';
import enPh from './locales/en-PH';
import faIr from './locales/fa-IR';
import frFr from './locales/fr-FR';
import guIn from './locales/gu-IN';
import hiIn from './locales/hi-IN';
import mrIn from './locales/mr-IN';
import ptBR from './locales/pt-BR';
import trTr from './locales/tr-TR';
import nlSr from './locales/nl-SR';
import eeEE from './locales/ee-EE';
export const DefaultConverterOptions: ConverterOptions = {
currency: false,
ignoreDecimal: false,
ignoreZeroCurrency: false,
doNotAddOnly: false,
};
export const DefaultToWordsOptions: ToWordsOptions = {
localeCode: 'en-IN',
converterOptions: DefaultConverterOptions,
};
export class ToWords {
private options: ToWordsOptions = {};
private locale: InstanceType<ConstructorOf<LocaleInterface>> | undefined = undefined;
constructor(options: ToWordsOptions = {}) {
this.options = Object.assign({}, DefaultToWordsOptions, options);
}
public getLocaleClass(): ConstructorOf<LocaleInterface> {
/* eslint-disable @typescript-eslint/no-var-requires */
switch (this.options.localeCode) {
case 'ee-EE':
return eeEE;
case 'en-AE':
return enAe;
case 'en-BD':
return enBd;
case 'en-GH':
return enGh;
case 'en-IN':
return enIn;
case 'en-MM':
return enMm;
case 'en-MU':
return enMu;
case 'en-NG':
return enNg;
case 'en-NP':
return enNp;
case 'en-US':
return enUs;
case 'en-GB':
return enGb;
case 'en-PH':
return enPh;
case 'fa-IR':
return faIr;
case 'fr-FR':
return frFr;
case 'gu-IN':
return guIn;
case 'hi-IN':
return hiIn;
case 'mr-IN':
return mrIn;
case 'pt-BR':
return ptBR;
case 'tr-TR':
return trTr;
case 'nl-SR':
return nlSr;
}
/* eslint-enable @typescript-eslint/no-var-requires */
throw new Error(`Unknown Locale "${this.options.localeCode}"`);
}
public getLocale(): InstanceType<ConstructorOf<LocaleInterface>> {
if (this.locale === undefined) {
const LocaleClass = this.getLocaleClass();
this.locale = new LocaleClass();
}
return this.locale;
}
public convert(number: number, options: ConverterOptions = {}): string {
options = Object.assign({}, this.options.converterOptions, options);
if (!this.isValidNumber(number)) {
throw new Error(`Invalid Number "${number}"`);
}
if (options.ignoreDecimal) {
number = Number.parseInt(number.toString());
}
let words: string[] = [];
if (options.currency) {
words = this.convertCurrency(number, options);
} else {
words = this.convertNumber(number);
}
return words.join(' ');
}
protected convertNumber(number: number): string[] {
const locale = this.getLocale();
const isNegativeNumber = number < 0;
if (isNegativeNumber) {
number = Math.abs(number);
}
const split = number.toString().split('.');
const ignoreZero = this.isNumberZero(number) && locale.config.ignoreZeroInDecimals;
let words = this.convertInternal(Number(split[0]));
const isFloat = this.isFloat(number);
if (isFloat && ignoreZero) {
words = [];
}
const wordsWithDecimal = [];
if (isFloat) {
if (!ignoreZero) {
wordsWithDecimal.push(locale.config.texts.point);
}
if (split[1].startsWith('0') && !locale.config?.decimalLengthWordMapping) {
const zeroWords = [];
for (const num of split[1]) {
zeroWords.push(...this.convertInternal(Number(num)));
}
wordsWithDecimal.push(...zeroWords);
} else {
wordsWithDecimal.push(...this.convertInternal(Number(split[1])));
const decimalLengthWord = locale.config?.decimalLengthWordMapping?.[split[1].length];
if (decimalLengthWord) {
wordsWithDecimal.push(decimalLengthWord);
}
}
}
const isEmpty = words.length <= 0;
if (!isEmpty && isNegativeNumber) {
words.unshift(locale.config.texts.minus);
}
words.push(...wordsWithDecimal);
return words;
}
protected convertCurrency(number: number, options: ConverterOptions = {}): string[] {
const locale = this.getLocale();
const currencyOptions = options.currencyOptions ?? locale.config.currency;
const isNegativeNumber = number < 0;
if (isNegativeNumber) {
number = Math.abs(number);
}
number = this.toFixed(number);
// Extra check for isFloat to overcome 1.999 rounding off to 2
const split = number.toString().split('.');
let words = [...this.convertInternal(Number(split[0]))];
if (currencyOptions.plural) {
words.push(currencyOptions.plural);
}
const ignoreZero =
this.isNumberZero(number) &&
(options.ignoreZeroCurrency || (locale.config?.ignoreZeroInDecimals && number !== 0));
if (ignoreZero) {
words = [];
}
const wordsWithDecimal = [];
const isFloat = this.isFloat(number);
if (isFloat) {
if (!ignoreZero) {
wordsWithDecimal.push(locale.config.texts.and);
}
wordsWithDecimal.push(
...this.convertInternal(
Number(split[1]) * (!locale.config.decimalLengthWordMapping ? Math.pow(10, 2 - split[1].length) : 1),
),
);
const decimalLengthWord = locale.config?.decimalLengthWordMapping?.[split[1].length];
if (decimalLengthWord?.length) {
wordsWithDecimal.push(decimalLengthWord);
}
wordsWithDecimal.push(currencyOptions.fractionalUnit.plural);
} else if (locale.config.decimalLengthWordMapping && words.length) {
wordsWithDecimal.push(currencyOptions.fractionalUnit.plural);
}
const isEmpty = words.length <= 0 && wordsWithDecimal.length <= 0;
if (!isEmpty && isNegativeNumber) {
words.unshift(locale.config.texts.minus);
}
if (!isEmpty && locale.config.texts.only && !options.doNotAddOnly && !locale.config.onlyInFront) {
wordsWithDecimal.push(locale.config.texts.only);
}
if (wordsWithDecimal.length) {
words.push(...wordsWithDecimal);
}
if (!isEmpty && !options.doNotAddOnly && locale.config.onlyInFront) {
words.splice(0, 0, locale.config.texts.only);
}
return words;
}
protected convertInternal(number: number): string[] {
const locale = this.getLocale();
if (locale.config.exactWordsMapping) {
const exactMatch = locale.config?.exactWordsMapping?.find((elem) => {
return number === elem.number;
});
if (exactMatch) {
return [exactMatch.value];
}
}
const match = locale.config.numberWordsMapping.find((elem) => {
return number >= elem.number;
}) as NumberWordMap;
const words: string[] = [];
if (number <= 100 || (number < 1000 && locale.config.namedLessThan1000)) {
words.push(match.value);
number -= match.number;
if (number > 0) {
if (locale.config?.splitWord?.length) {
words.push(locale.config.splitWord);
}
words.push(...this.convertInternal(number));
}
return words;
}
const quotient = Math.floor(number / match.number);
const remainder = number % match.number;
let matchValue = match.value;
if (quotient > 1 && locale.config?.pluralWords?.find((word) => word === match.value) && locale.config?.pluralMark) {
matchValue += locale.config.pluralMark;
}
if (quotient === 1 && locale.config?.ignoreOneForWords?.includes(matchValue)) {
words.push(matchValue);
} else {
words.push(...this.convertInternal(quotient), matchValue);
}
if (remainder > 0) {
if (locale.config?.splitWord?.length) {
if (!locale.config?.noSplitWordAfter?.find((word) => word === match.value)) {
words.push(locale.config.splitWord);
}
}
words.push(...this.convertInternal(remainder));
}
return words;
}
public toFixed(number: number, precision = 2): number {
return Number(Number(number).toFixed(precision));
}
public isFloat(number: number | string): boolean {
return Number(number) === number && number % 1 !== 0;
}
public isValidNumber(number: number | string): boolean {
return !isNaN(parseFloat(number as string)) && isFinite(number as number);
}
public isNumberZero(number: number): boolean {
return number >= 0 && number < 1;
}
}