// noinspection SpellCheckingInspection import { Group, InvalidTokenError } from '../common'; import { numberToExponential } from '../exponent'; const DECIMAL_POINT = '.' as const; const GROUPING_SYMBOL = ',' as const; const NEGATIVE = 'negative' as const; const NEGATIVE_SYMBOL = '-' as const; const POSITIVE_SYMBOL = '+' as const; const SHORT_MILLIA_DELIMITER = '^' as const; const EXPONENT_DELIMITER = 'e' as const; const EMPTY_GROUP_DIGITS = '000' as const; const EMPTY_PLACE: Group = [EMPTY_GROUP_DIGITS, 0]; /** * Ones number names. */ const ONES = [ 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', ] as const; type OnesName = typeof ONES[number]; /** * Ten plus ones number names. */ const TEN_PLUS_ONES = [ 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', ] as const; type TenPlusOnesName = typeof TEN_PLUS_ONES[number]; /** * Tens number names. */ const TENS = [ 'zero', TEN_PLUS_ONES[0], 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety', ] as const; type TensName = typeof TENS[number]; /** * Hundreds name. */ const HUNDRED = 'hundred' as const; /** * Thousands name. */ const THOUSAND = 'thousand' as const; // const ILLION_ORDINAL_SUFFIX = 'illionth' as const; // const THOUSAND_ORDINAL = 'thousandth' as const; /** * Special millions name. */ const MILLIONS_SPECIAL_PREFIXES = [ '', 'm', 'b', 'tr', 'quadr', 'quint', 'sext', 'sept', 'oct', 'non', ] as const; type MillionsSpecialPrefix = Exclude<typeof MILLIONS_SPECIAL_PREFIXES[number], ''>; /** * Millions name. */ const MILLIONS_PREFIXES = [ '', 'un', 'duo', 'tre', 'quattuor', 'quin', 'sex', 'septen', 'octo', 'novem', ] as const; type MillionsPrefix = Exclude<typeof MILLIONS_PREFIXES[number], ''>; /** * Decillions name. */ const DECILLIONS_PREFIXES = [ '', 'dec', 'vigin', 'trigin', 'quadragin', 'quinquagin', 'sexagin', 'septuagin', 'octogin', 'nonagin', ] as const; type DecillionsPrefix = Exclude<typeof DECILLIONS_PREFIXES[number], ''>; /** * Centillions name. */ const CENTILLIONS_PREFIXES = [ '', 'cen', 'duocen', 'trecen', 'quadringen', 'quingen', 'sescen', 'septingen', 'octingen', 'nongen', ] as const; type CentillionsPrefix = Exclude<typeof CENTILLIONS_PREFIXES[number], ''>; /** * Prefix for millia- number names. */ const MILLIA_PREFIX = 'millia' as const; /** * Suffix for -illion number names. */ const ILLION_SUFFIX = 'illion' as const; /** * Builds a name for numbers in tens and ones. * @param tens - Tens digit. * @param ones - Ones digit. * @returns The name for the number. */ const makeTensName = (tens: number, ones: number) => { if (tens === 0) { return ONES[ones]; } if (tens === 1) { return TEN_PLUS_ONES[ones] as unknown as TenPlusOnesName; } if (ones === 0) { return TENS[tens]; } return `${TENS[tens] as Exclude<TensName, 'zero' | 'ten'>} ${ONES[ones] as Exclude<OnesName, 'zero'>}` as const; }; /** * Builds a name for numbers in hundreds, tens, and ones. * @param hundreds - Hundreds digit. * @param tens - Tens digit. * @param ones - Ones digit. * @returns The name for the number. */ const makeHundredsName = (hundreds: number, tens: number, ones: number) => { if (hundreds === 0) { return makeTensName(tens, ones); } if (tens === 0 && ones === 0) { return `${ONES[hundreds]} ${HUNDRED}` as const; } return `${ONES[hundreds]} ${HUNDRED} ${makeTensName(tens, ones)}` as const; }; /** * Builds a name for numbers in the millions. * @param millions - Millions digit. * @param milliaCount - Number of millia- groups. * @returns The millions prefix. */ const makeMillionsPrefix = (millions: number, milliaCount: number) => { if (milliaCount > 0) { return MILLIONS_PREFIXES[millions] as MillionsPrefix; } return MILLIONS_SPECIAL_PREFIXES[millions] as MillionsSpecialPrefix; }; /** * Builds a name for numbers in the decillions. * @param decillions - Decillions digit. * @param millions - Millions digit. * @param milliaCount - Number of millia- groups. * @returns The decillions prefix. */ const makeDecillionsPrefix = (decillions: number, millions: number, milliaCount: number) => { if (decillions === 0) { return makeMillionsPrefix(millions, milliaCount); } const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix; const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix; return `${onesPrefix}${tensName}` as const; }; /** * Builds a name for numbers in the centillions. * @param centillions - Centillions digit. * @param decillions - Decillions digit. * @param millions - Millions digit. * @param milliaCount - Number of millia- groups. * @returns The centillions prefix. */ const makeCentillionsPrefix = ( centillions: number, decillions: number, millions: number, milliaCount: number, ) => { if (centillions === 0) { return makeDecillionsPrefix(decillions, millions, milliaCount); } const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix; const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix; const hundredsName = CENTILLIONS_PREFIXES[centillions] as CentillionsPrefix; return `${hundredsName}${onesPrefix}${decillions > 0 ? tensName : ''}` as const; }; const getGroupName = (place: number, shortenMillia: boolean) => { if (place === 0) { return '' as const; } if (place === 1) { return THOUSAND; } const bigGroupPlace = place - 1; const groupGroups = bigGroupPlace .toString() .split('') .reduceRight<Group[]>( (acc, c, i, cc) => { const firstGroup = acc.at(0); const currentPlace = Math.floor((cc.length - i - 1) / 3); if (typeof firstGroup === 'undefined') { return [[c, currentPlace]]; } if (firstGroup[0].length > 2) { return [[c, currentPlace], ...acc]; } return [ [c + firstGroup[0], currentPlace], ...acc.slice(1), ]; }, [], ) .map(([groupDigits, groupPlace]) => [groupDigits.padStart(3, '0'), groupPlace] as const) .filter(([groupDigits]) => groupDigits !== EMPTY_GROUP_DIGITS) .map(([groupDigits, groupPlace]) => { const [hundreds, tens, ones] = groupDigits.split('').map(Number); if (groupPlace < 1) { return makeCentillionsPrefix(hundreds, tens, ones, groupPlace); } const milliaSuffix = ( shortenMillia && groupPlace > 1 ? `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${groupPlace}` : MILLIA_PREFIX.repeat(groupPlace) ); if (groupDigits === '001') { return milliaSuffix; } return makeCentillionsPrefix(hundreds, tens, ones, groupPlace) + milliaSuffix; }) .join(''); if (groupGroups.endsWith(DECILLIONS_PREFIXES[1])) { return `${groupGroups}${ILLION_SUFFIX}` as const; } if (bigGroupPlace > 10) { return `${groupGroups}t${ILLION_SUFFIX}` as const; } return `${groupGroups}${ILLION_SUFFIX}` as const; }; export const makeGroup = ( group: string, place: number, options?: Record<string, unknown>, ): string => { const makeHundredsArgs = group .padStart(3, '0') .split('') .map((s) => Number(s)) as [number, number, number]; const groupDigitsName = makeHundredsName(...makeHundredsArgs); const groupName = getGroupName(place, options?.shortenMillia as boolean ?? false); if (groupName.length > 0) { return `${groupDigitsName} ${groupName}` as const; } return groupDigitsName; }; /** * Group a number string into groups of three digits, starting from the decimal point. * @param value - The number string to group. */ export const group = (value: string): Group[] => { const [significand, exponentString] = numberToExponential( value, { decimalPoint: DECIMAL_POINT, groupingSymbol: GROUPING_SYMBOL, exponentDelimiter: EXPONENT_DELIMITER, }, ) .split(EXPONENT_DELIMITER); const exponent = Number(exponentString); const significantDigits = significand.replace(DECIMAL_POINT, ''); return significantDigits.split('').reduce<Group[]>( (acc, c, i) => { const currentPlace = Math.floor((exponent - i) / 3); const lastGroup = acc.at(-1) ?? [EMPTY_GROUP_DIGITS, currentPlace]; const currentPlaceInGroup = 2 - ((exponent - i) % 3); if (lastGroup[1] === currentPlace) { const lastGroupDigits = lastGroup[0].split(''); lastGroupDigits[currentPlaceInGroup] = c; return [...acc.slice(0, -1) ?? [], [ lastGroupDigits.join(''), currentPlace, ]]; } return [...acc, [c.padEnd(3, '0'), currentPlace]]; }, [], ); }; /** * Formats the final tokenized string. * @param tokens - The tokens to finalize. */ export const finalize = (tokens: string[]) => ( tokens .map((t) => t.trim()) .join(' ') .trim() ); /** * Makes a negative string. * @param s - The string to make negative. */ export const makeNegative = (s: string) => ( `${NEGATIVE} ${s}` ); export const tokenize = (stringValue: string) => ( stringValue.split(' ').filter((maybeToken) => maybeToken.length > 0) ); const FINAL_TOKEN = '' as const; interface DoParseState { groupNameCurrent: string; millias: number[]; milliaIndex: number; done: boolean; } const doParseGroupName = (result: DoParseState): DoParseState => { if (result.groupNameCurrent.length < 1) { return { ...result, done: true, }; } if (result.groupNameCurrent === 't') { // If the current group name is "t", then we're done. // We use the -t- affix to attach the group prefix to the -illion suffix, except for decillion. return { ...result, done: true, }; } const centillions = CENTILLIONS_PREFIXES.findIndex((p) => ( p.length > 0 && result.groupNameCurrent.startsWith(p) )); if (centillions > -1) { return { milliaIndex: 0, millias: result.millias.map((m, i) => ( i === 0 ? m + (centillions * 100) : m )), groupNameCurrent: result.groupNameCurrent.slice( CENTILLIONS_PREFIXES[centillions].length, ), done: false, }; } const decillions = DECILLIONS_PREFIXES.findIndex((p) => ( p.length > 0 && result.groupNameCurrent.startsWith(p) )); if (decillions > -1) { return { milliaIndex: 0, millias: result.millias.map((m, i) => ( i === 0 ? m + (decillions * 10) : m )), groupNameCurrent: result.groupNameCurrent.slice( DECILLIONS_PREFIXES[decillions].length, ), done: false, }; } const millions = MILLIONS_PREFIXES.findIndex((p) => ( p.length > 0 && result.groupNameCurrent.startsWith(p) )); if (millions > -1) { return { milliaIndex: 0, millias: result.millias.map((m, i) => ( i === 0 ? m + millions : m )), groupNameCurrent: result.groupNameCurrent.slice( MILLIONS_PREFIXES[millions].length, ), done: false, }; } if (result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`)) { // short millia const matchedMilliaArray = result.groupNameCurrent.match(/^\d+/); if (!matchedMilliaArray) { throw new InvalidTokenError(result.groupNameCurrent); } const matchedMillia = matchedMilliaArray[0]; const newMillia = Number(matchedMillia); const oldMillia = result.milliaIndex; const newMillias = [...result.millias]; newMillias[newMillia] = newMillias[oldMillia] || 1; newMillias[oldMillia] = 0; return { milliaIndex: newMillia, millias: newMillias, groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length), done: false, }; } if (result.groupNameCurrent.startsWith(MILLIA_PREFIX)) { const newMillia = result.milliaIndex + 1; const oldMillia = result.milliaIndex; const newMillias = [...result.millias]; newMillias[newMillia] = newMillias[oldMillia] || 1; newMillias[oldMillia] = 0; return { milliaIndex: newMillia, millias: newMillias, groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length), done: false, }; } throw new InvalidTokenError(result.groupNameCurrent); }; const getGroupPlaceFromGroupName = (groupName: string) => { if (groupName === THOUSAND) { return 1; } const groupNameBase = groupName.replace(ILLION_SUFFIX, ''); const specialMillions = MILLIONS_SPECIAL_PREFIXES.findIndex((p) => groupNameBase === p); if (specialMillions > -1) { return specialMillions + 1; } let result: DoParseState = { groupNameCurrent: groupNameBase, millias: [0], milliaIndex: 0, done: false, }; do { result = doParseGroupName(result); } while (!result.done); const bigGroupPlace = Number( result.millias .map((s) => s.toString().padStart(3, '0')) .reverse() .join(''), ); return bigGroupPlace + 1; }; enum ParseGroupsMode { INITIAL = 'unknown', ONES_MODE = 'ones', TENS_MODE = 'tens', TEN_PLUS_ONES_MODE = 'tenPlusOnes', HUNDRED_MODE = 'hundred', THOUSAND_MODE = 'thousand', DONE = 'done', } interface ParserState { lastToken?: string; groups: Group[]; mode: ParseGroupsMode; } export const parseGroups = (tokens: string[]) => { const { groups } = [...tokens, FINAL_TOKEN].reduce<ParserState>( (acc, token) => { const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE]; if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { if (acc.mode === ParseGroupsMode.ONES_MODE) { const ones = ONES.findIndex((o) => o === acc.lastToken); if (ones > -1) { lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`; } } lastGroup[1] = getGroupPlaceFromGroupName(token); return { ...acc, groups: [...acc.groups.slice(0, -1), lastGroup], lastToken: token, mode: ParseGroupsMode.THOUSAND_MODE, }; } if (token === HUNDRED) { if (acc.mode === ParseGroupsMode.ONES_MODE) { const hundreds = ONES.findIndex((o) => o === acc.lastToken); lastGroup[0] = `${hundreds}${lastGroup[0].slice(1)}`; return { ...acc, groups: [...acc.groups.slice(0, -1), lastGroup], mode: ParseGroupsMode.HUNDRED_MODE, }; } } if (token === FINAL_TOKEN) { if (acc.mode === ParseGroupsMode.ONES_MODE) { const ones = ONES.findIndex((o) => o === acc.lastToken); lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`; lastGroup[1] = 0; return { ...acc, groups: [...acc.groups.slice(0, -1), lastGroup], mode: ParseGroupsMode.DONE, }; } } if (ONES.includes(token as OnesName)) { if (acc.mode === ParseGroupsMode.THOUSAND_MODE) { return { ...acc, lastToken: token, mode: ParseGroupsMode.ONES_MODE, groups: [...acc.groups, [...EMPTY_PLACE]], }; } return { ...acc, lastToken: token, mode: ParseGroupsMode.ONES_MODE, }; } const tenPlusOnes = TEN_PLUS_ONES.findIndex((t) => t === token); if (tenPlusOnes > -1) { if (acc.mode === ParseGroupsMode.THOUSAND_MODE) { return { ...acc, lastToken: token, mode: ParseGroupsMode.ONES_MODE, groups: [...acc.groups, [`01${tenPlusOnes}`, lastGroup[1] - 1]], }; } lastGroup[0] = `${lastGroup[0].slice(0, 1)}1${tenPlusOnes}`; return { ...acc, lastToken: token, mode: ParseGroupsMode.TEN_PLUS_ONES_MODE, groups: [...acc.groups.slice(0, -1), lastGroup], }; } const tens = TENS.findIndex((t) => t === token); if (tens > -1) { lastGroup[0] = `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[0].slice(2)}`; return { ...acc, lastToken: token, mode: ParseGroupsMode.TENS_MODE, groups: [...acc.groups.slice(0, -1), lastGroup], }; } return { ...acc, lastToken: token, }; }, { lastToken: undefined, groups: [], mode: ParseGroupsMode.INITIAL, }, ); return groups; }; export const combineGroups = (groups: Group[]) => { const groupsSorted = groups.sort((a, b) => b[1] - a[1]); // sort by place const firstGroup = groupsSorted[0]; const firstGroupPlace = firstGroup[1]; const digits = groupsSorted.reduce( (previousDigits, thisGroup) => { const [groupDigits] = thisGroup; return `${previousDigits}${groupDigits}`; }, '', ).replace(/^0+/, '') || '0'; const firstGroupDigits = firstGroup[0]; const firstGroupDigitsWithoutZeroes = firstGroupDigits.replace(/^0+/, ''); const exponentExtra = firstGroupDigits.length - firstGroupDigitsWithoutZeroes.length; const exponentValue = BigInt((firstGroupPlace * 3) + (2 - exponentExtra)); const isExponentNegative = exponentValue < 0; const exponentValueAbs = isExponentNegative ? -exponentValue : exponentValue; const exponentSign = isExponentNegative ? NEGATIVE_SYMBOL : POSITIVE_SYMBOL; const exponent = `${exponentSign}${exponentValueAbs}`; const significandInteger = digits.slice(0, 1); const significandFraction = digits.slice(1).replace(/0+$/, ''); if (significandFraction.length > 0) { return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; } return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`; };