// noinspection SpellCheckingInspection import { Group } from '../common'; import { numberToExponential } from '../exponent'; const DECIMAL_POINT = '.'; const GROUPING_SYMBOL = ','; const NEGATIVE = 'negative'; const ONES = [ 'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', ] as const; type OnesName = typeof ONES[number]; const TEN_PLUS_ONES = [ 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', ] as const; type TenPlusOnesName = typeof TEN_PLUS_ONES[number]; const TENS = [ 'zero', TEN_PLUS_ONES[0], 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety', ] as const; type TensName = typeof TENS[number]; const HUNDRED = 'hundred' as const; const THOUSAND = 'thousand' as const; // const ILLION_ORDINAL_SUFFIX = 'illionth' as const; // const THOUSAND_ORDINAL = 'thousandth' as const; const MILLIONS_SPECIAL_PREFIXES = [ '', 'm', 'b', 'tr', 'quadr', 'quint', 'sext', 'sept', 'oct', 'non', ] as const; type MillionsSpecialPrefix = typeof MILLIONS_SPECIAL_PREFIXES[number]; const MILLIONS_PREFIXES = [ '', 'un', 'duo', 'tre', 'quattuor', 'quin', 'sex', 'septen', 'octo', 'novem', ] as const; type MillionsPrefix = typeof MILLIONS_PREFIXES[number]; const DECILLIONS_PREFIXES = [ '', 'dec', 'vigin', 'trigin', 'quadragin', 'quinquagin', 'sexagin', 'septuagin', 'octogin', 'nonagin', ] as const; type DecillionsPrefix = typeof DECILLIONS_PREFIXES[number]; const CENTILLIONS_PREFIXES = [ '', 'cen', 'duocen', 'trecen', 'quadringen', 'quingen', 'sescen', 'septingen', 'octingen', 'nongen', ] as const; type CentillionsPrefix = typeof CENTILLIONS_PREFIXES[number]; const MILLIA_PREFIX = 'millia' as const; const ILLION_SUFFIX = 'illion' as const; 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} ${ONES[ones] as Exclude}` as const; }; 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; }; const makeMillionsPrefix = (millions: number, milliaCount: number) => { if (milliaCount > 0) { return MILLIONS_PREFIXES[millions] as Exclude; } return MILLIONS_SPECIAL_PREFIXES[millions] as Exclude; }; const makeDecillionsPrefix = (decillions: number, millions: number, milliaCount: number) => { if (decillions === 0) { return makeMillionsPrefix(millions, milliaCount); } const onesPrefix = MILLIONS_PREFIXES[millions] as Exclude; const tensName = DECILLIONS_PREFIXES[decillions] as Exclude; return `${onesPrefix}${tensName}` as const; }; const makeCentillionsPrefix = ( centillions: number, decillions: number, millions: number, milliaCount: number, ) => { if (centillions === 0) { return makeDecillionsPrefix(decillions, millions, milliaCount); } const onesPrefix = MILLIONS_PREFIXES[millions] as Exclude; const tensName = DECILLIONS_PREFIXES[decillions] as Exclude; const hundredsName = CENTILLIONS_PREFIXES[centillions] as Exclude; 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( (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(([group, groupPlace]) => [group.padStart(3, '0'), groupPlace] as const) .filter(([group]) => group !== '000') .map(([group, groupPlace]) => { const [hundreds, tens, ones] = group.split('').map(Number); if (groupPlace < 1) { return makeCentillionsPrefix(hundreds, tens, ones, groupPlace); } const milliaSuffix = ( shortenMillia && groupPlace > 1 ? `${MILLIA_PREFIX}^${groupPlace}` : MILLIA_PREFIX.repeat(groupPlace) ); if (group === '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 => { 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 exponentDelimiter = 'e'; const [significand, exponentString] = numberToExponential( value, { decimalPoint: DECIMAL_POINT, groupingSymbol: GROUPING_SYMBOL, exponentDelimiter, }, ) .split(exponentDelimiter); const exponent = Number(exponentString); const significantDigits = significand.replace(DECIMAL_POINT, ''); return significantDigits.split('').reduce( (acc, c, i) => { const currentPlace = Math.floor((exponent - i) / 3); const lastGroup = acc.at(-1) ?? ['000', 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 = ''; const getGroupFromGroupName = (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 1 + specialMillions; } let groupNameCurrent = groupNameBase; const millias = [0]; let milliaIndex = 0; while (groupNameCurrent.length > 0) { if (groupNameCurrent === 't') { break; } const centillions = CENTILLIONS_PREFIXES.findIndex((p) => ( p.length > 0 && groupNameCurrent.startsWith(p) )); if (centillions > -1) { milliaIndex = 0; millias[milliaIndex] += (centillions * 100); groupNameCurrent = groupNameCurrent.slice(CENTILLIONS_PREFIXES[centillions].length); continue; } const decillions = DECILLIONS_PREFIXES.findIndex((p) => ( p.length > 0 && groupNameCurrent.startsWith(p) )); if (decillions > -1) { milliaIndex = 0; millias[milliaIndex] += decillions * 10; groupNameCurrent = groupNameCurrent.slice(DECILLIONS_PREFIXES[decillions].length); continue; } const millions = MILLIONS_PREFIXES.findIndex((p) => ( p.length > 0 && groupNameCurrent.startsWith(p) )); if (millions > -1) { milliaIndex = 0; millias[milliaIndex] += millions; groupNameCurrent = groupNameCurrent.slice(MILLIONS_PREFIXES[millions].length); continue; } if (groupNameCurrent.startsWith(`${MILLIA_PREFIX}^`)) { // short millia groupNameCurrent = groupNameCurrent.slice(MILLIA_PREFIX.length); const matchedMilliaArray = groupNameCurrent.match(/^\d+/); if (!matchedMilliaArray) { throw new Error(`Invalid groupName: ${groupName}`); } const matchedMillia = matchedMilliaArray[0]; millias[Number(matchedMillia)] = millias[milliaIndex] || 1; millias[milliaIndex] = 0; groupNameCurrent = groupNameCurrent.slice(matchedMillia.length); } if (groupNameCurrent.startsWith(MILLIA_PREFIX)) { millias[milliaIndex + 1] = millias[milliaIndex] || 1; millias[milliaIndex] = 0; milliaIndex += 1; groupNameCurrent = groupNameCurrent.slice(MILLIA_PREFIX.length); continue; } break; } const bigGroupPlace = Number( millias .map((s) => s.toString().padStart(3, '0')) .reverse() .join(''), ); return 1 + bigGroupPlace; }; 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( (acc, token) => { const lastGroup = acc.groups.at(-1) ?? ['000', 0]; if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { if (acc.mode === ParseGroupsMode.ONES_MODE) { const ones = ONES.findIndex((o) => o === acc.lastToken); lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`; } lastGroup[1] = getGroupFromGroupName(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, ['000', 0]], }; } return { ...acc, lastToken: token, mode: ParseGroupsMode.ONES_MODE, }; } const tenPlusOnes = TEN_PLUS_ONES.findIndex((t) => t === token); if (tenPlusOnes > -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 exponent = exponentValue < 0 ? exponentValue.toString() : `+${exponentValue}`; const significandInteger = digits.slice(0, 1); const significandFraction = digits.slice(1); if (significandFraction.length > 0) { return `${significandInteger}${DECIMAL_POINT}${significandFraction}e${exponent}`; } return `${significandInteger}e${exponent}`; };