Gets the name of a number, even if it's stupidly big. Supersedes TheoryOfNekomata/number-name.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

725 line
17 KiB

  1. // noinspection SpellCheckingInspection
  2. import { Group, InvalidTokenError } from '../common';
  3. import { numberToExponential } from '../exponent';
  4. const DECIMAL_POINT = '.' as const;
  5. const GROUPING_SYMBOL = ',' as const;
  6. const NEGATIVE = 'negative' as const;
  7. const NEGATIVE_SYMBOL = '-' as const;
  8. const POSITIVE_SYMBOL = '+' as const;
  9. const SHORT_MILLIA_DELIMITER = '^' as const;
  10. const EXPONENT_DELIMITER = 'e' as const;
  11. const EMPTY_GROUP_DIGITS = '000' as const;
  12. const EMPTY_PLACE: Group = [EMPTY_GROUP_DIGITS, 0];
  13. /**
  14. * Ones number names.
  15. */
  16. const ONES = [
  17. 'zero',
  18. 'one',
  19. 'two',
  20. 'three',
  21. 'four',
  22. 'five',
  23. 'six',
  24. 'seven',
  25. 'eight',
  26. 'nine',
  27. ] as const;
  28. type OnesName = typeof ONES[number];
  29. /**
  30. * Ten plus ones number names.
  31. */
  32. const TEN_PLUS_ONES = [
  33. 'ten',
  34. 'eleven',
  35. 'twelve',
  36. 'thirteen',
  37. 'fourteen',
  38. 'fifteen',
  39. 'sixteen',
  40. 'seventeen',
  41. 'eighteen',
  42. 'nineteen',
  43. ] as const;
  44. type TenPlusOnesName = typeof TEN_PLUS_ONES[number];
  45. /**
  46. * Tens number names.
  47. */
  48. const TENS = [
  49. 'zero',
  50. TEN_PLUS_ONES[0],
  51. 'twenty',
  52. 'thirty',
  53. 'forty',
  54. 'fifty',
  55. 'sixty',
  56. 'seventy',
  57. 'eighty',
  58. 'ninety',
  59. ] as const;
  60. type TensName = typeof TENS[number];
  61. /**
  62. * Hundreds name.
  63. */
  64. const HUNDRED = 'hundred' as const;
  65. /**
  66. * Thousands name.
  67. */
  68. const THOUSAND = 'thousand' as const;
  69. // const ILLION_ORDINAL_SUFFIX = 'illionth' as const;
  70. // const THOUSAND_ORDINAL = 'thousandth' as const;
  71. /**
  72. * Special millions name.
  73. */
  74. const MILLIONS_SPECIAL_PREFIXES = [
  75. '',
  76. 'm',
  77. 'b',
  78. 'tr',
  79. 'quadr',
  80. 'quint',
  81. 'sext',
  82. 'sept',
  83. 'oct',
  84. 'non',
  85. ] as const;
  86. type MillionsSpecialPrefix = Exclude<typeof MILLIONS_SPECIAL_PREFIXES[number], ''>;
  87. /**
  88. * Millions name.
  89. */
  90. const MILLIONS_PREFIXES = [
  91. '',
  92. 'un',
  93. 'duo',
  94. 'tre',
  95. 'quattuor',
  96. 'quin',
  97. 'sex',
  98. 'septen',
  99. 'octo',
  100. 'novem',
  101. ] as const;
  102. type MillionsPrefix = Exclude<typeof MILLIONS_PREFIXES[number], ''>;
  103. /**
  104. * Decillions name.
  105. */
  106. const DECILLIONS_PREFIXES = [
  107. '',
  108. 'dec',
  109. 'vigin',
  110. 'trigin',
  111. 'quadragin',
  112. 'quinquagin',
  113. 'sexagin',
  114. 'septuagin',
  115. 'octogin',
  116. 'nonagin',
  117. ] as const;
  118. type DecillionsPrefix = Exclude<typeof DECILLIONS_PREFIXES[number], ''>;
  119. /**
  120. * Centillions name.
  121. */
  122. const CENTILLIONS_PREFIXES = [
  123. '',
  124. 'cen',
  125. 'duocen',
  126. 'trecen',
  127. 'quadringen',
  128. 'quingen',
  129. 'sescen',
  130. 'septingen',
  131. 'octingen',
  132. 'nongen',
  133. ] as const;
  134. type CentillionsPrefix = Exclude<typeof CENTILLIONS_PREFIXES[number], ''>;
  135. /**
  136. * Prefix for millia- number names.
  137. */
  138. const MILLIA_PREFIX = 'millia' as const;
  139. /**
  140. * Suffix for -illion number names.
  141. */
  142. const ILLION_SUFFIX = 'illion' as const;
  143. /**
  144. * Builds a name for numbers in tens and ones.
  145. * @param tens - Tens digit.
  146. * @param ones - Ones digit.
  147. * @returns string The name for the number.
  148. */
  149. const makeTensName = (tens: number, ones: number) => {
  150. if (tens === 0) {
  151. return ONES[ones];
  152. }
  153. if (tens === 1) {
  154. return TEN_PLUS_ONES[ones] as unknown as TenPlusOnesName;
  155. }
  156. if (ones === 0) {
  157. return TENS[tens];
  158. }
  159. return `${TENS[tens] as Exclude<TensName, 'zero' | 'ten'>} ${ONES[ones] as Exclude<OnesName, 'zero'>}` as const;
  160. };
  161. /**
  162. * Builds a name for numbers in hundreds, tens, and ones.
  163. * @param hundreds - Hundreds digit.
  164. * @param tens - Tens digit.
  165. * @param ones - Ones digit.
  166. * @returns string The name for the number.
  167. */
  168. const makeHundredsName = (hundreds: number, tens: number, ones: number) => {
  169. if (hundreds === 0) {
  170. return makeTensName(tens, ones);
  171. }
  172. if (tens === 0 && ones === 0) {
  173. return `${ONES[hundreds]} ${HUNDRED}` as const;
  174. }
  175. return `${ONES[hundreds]} ${HUNDRED} ${makeTensName(tens, ones)}` as const;
  176. };
  177. /**
  178. * Builds a name for numbers in the millions.
  179. * @param millions - Millions digit.
  180. * @param milliaCount - Number of millia- groups.
  181. * @returns string The millions prefix.
  182. */
  183. const makeMillionsPrefix = (millions: number, milliaCount: number) => {
  184. if (milliaCount > 0) {
  185. return MILLIONS_PREFIXES[millions] as MillionsPrefix;
  186. }
  187. return MILLIONS_SPECIAL_PREFIXES[millions] as MillionsSpecialPrefix;
  188. };
  189. /**
  190. * Builds a name for numbers in the decillions.
  191. * @param decillions - Decillions digit.
  192. * @param millions - Millions digit.
  193. * @param milliaCount - Number of millia- groups.
  194. * @returns string The decillions prefix.
  195. */
  196. const makeDecillionsPrefix = (decillions: number, millions: number, milliaCount: number) => {
  197. if (decillions === 0) {
  198. return makeMillionsPrefix(millions, milliaCount);
  199. }
  200. const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix;
  201. const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix;
  202. return `${onesPrefix}${tensName}` as const;
  203. };
  204. /**
  205. * Builds a name for numbers in the centillions.
  206. * @param centillions - Centillions digit.
  207. * @param decillions - Decillions digit.
  208. * @param millions - Millions digit.
  209. * @param milliaCount - Number of millia- groups.
  210. * @returns string The centillions prefix.
  211. */
  212. const makeCentillionsPrefix = (
  213. centillions: number,
  214. decillions: number,
  215. millions: number,
  216. milliaCount: number,
  217. ) => {
  218. if (centillions === 0) {
  219. return makeDecillionsPrefix(decillions, millions, milliaCount);
  220. }
  221. const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix;
  222. const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix;
  223. const hundredsName = CENTILLIONS_PREFIXES[centillions] as CentillionsPrefix;
  224. return `${hundredsName}${onesPrefix}${decillions > 0 ? tensName : ''}` as const;
  225. };
  226. const getGroupName = (place: number, shortenMillia: boolean) => {
  227. if (place === 0) {
  228. return '' as const;
  229. }
  230. if (place === 1) {
  231. return THOUSAND;
  232. }
  233. const bigGroupPlace = place - 1;
  234. const groupGroups = bigGroupPlace
  235. .toString()
  236. .split('')
  237. .reduceRight<Group[]>(
  238. (acc, c, i, cc) => {
  239. const firstGroup = acc.at(0);
  240. const currentPlace = Math.floor((cc.length - i - 1) / 3);
  241. if (typeof firstGroup === 'undefined') {
  242. return [[c, currentPlace]];
  243. }
  244. if (firstGroup[0].length > 2) {
  245. return [[c, currentPlace], ...acc];
  246. }
  247. return [
  248. [c + firstGroup[0], currentPlace],
  249. ...acc.slice(1),
  250. ];
  251. },
  252. [],
  253. )
  254. .map(([groupDigits, groupPlace]) => [groupDigits.padStart(3, '0'), groupPlace] as const)
  255. .filter(([groupDigits]) => groupDigits !== EMPTY_GROUP_DIGITS)
  256. .map(([groupDigits, groupPlace]) => {
  257. const [hundreds, tens, ones] = groupDigits.split('').map(Number);
  258. if (groupPlace < 1) {
  259. return makeCentillionsPrefix(hundreds, tens, ones, groupPlace);
  260. }
  261. const milliaSuffix = (
  262. shortenMillia && groupPlace > 1
  263. ? `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${groupPlace}`
  264. : MILLIA_PREFIX.repeat(groupPlace)
  265. );
  266. if (groupDigits === '001') {
  267. return milliaSuffix;
  268. }
  269. return makeCentillionsPrefix(hundreds, tens, ones, groupPlace) + milliaSuffix;
  270. })
  271. .join('');
  272. if (groupGroups.endsWith(DECILLIONS_PREFIXES[1])) {
  273. return `${groupGroups}${ILLION_SUFFIX}` as const;
  274. }
  275. if (bigGroupPlace > 10) {
  276. return `${groupGroups}t${ILLION_SUFFIX}` as const;
  277. }
  278. return `${groupGroups}${ILLION_SUFFIX}` as const;
  279. };
  280. export const makeGroups = (groups: Group[], options?: Record<string, unknown>): string[] => {
  281. const filteredGroups = groups.filter(([digits, place]) => (
  282. place === 0 || digits !== EMPTY_GROUP_DIGITS
  283. ));
  284. return filteredGroups.map(
  285. ([group, place]) => {
  286. const makeHundredsArgs = group
  287. .padStart(3, '0')
  288. .split('')
  289. .map((s) => Number(s)) as [number, number, number];
  290. const groupDigitsName = makeHundredsName(...makeHundredsArgs);
  291. const groupName = getGroupName(place, options?.shortenMillia as boolean ?? false);
  292. if (groupName.length > 0) {
  293. return `${groupDigitsName} ${groupName}`;
  294. }
  295. return groupDigitsName;
  296. },
  297. );
  298. };
  299. /**
  300. * Group a number string into groups of three digits, starting from the decimal point.
  301. * @param value - The number string to group.
  302. */
  303. export const group = (value: string): Group[] => {
  304. const [significand, exponentString] = numberToExponential(
  305. value,
  306. {
  307. decimalPoint: DECIMAL_POINT,
  308. groupingSymbol: GROUPING_SYMBOL,
  309. exponentDelimiter: EXPONENT_DELIMITER,
  310. },
  311. )
  312. .split(EXPONENT_DELIMITER);
  313. const exponent = Number(exponentString);
  314. const significantDigits = significand.replace(DECIMAL_POINT, '');
  315. return significantDigits.split('').reduce<Group[]>(
  316. (acc, c, i) => {
  317. const currentPlace = Math.floor((exponent - i) / 3);
  318. const lastGroup = acc.at(-1) ?? [EMPTY_GROUP_DIGITS, currentPlace];
  319. const currentPlaceInGroup = 2 - ((exponent - i) % 3);
  320. if (lastGroup[1] === currentPlace) {
  321. const lastGroupDigits = lastGroup[0].split('');
  322. lastGroupDigits[currentPlaceInGroup] = c;
  323. return [...acc.slice(0, -1) ?? [], [
  324. lastGroupDigits.join(''),
  325. currentPlace,
  326. ]];
  327. }
  328. return [...acc, [c.padEnd(3, '0'), currentPlace]];
  329. },
  330. [],
  331. );
  332. };
  333. /**
  334. * Formats the final tokenized string.
  335. * @param tokens - The tokens to finalize.
  336. */
  337. export const finalize = (tokens: string[]) => (
  338. tokens
  339. .map((t) => t.trim())
  340. .join(' ')
  341. .trim()
  342. );
  343. /**
  344. * Makes a negative string.
  345. * @param s - The string to make negative.
  346. */
  347. export const makeNegative = (s: string) => (
  348. `${NEGATIVE} ${s}`
  349. );
  350. export const tokenize = (stringValue: string) => (
  351. stringValue.split(' ').filter((maybeToken) => maybeToken.length > 0)
  352. );
  353. const FINAL_TOKEN = '' as const;
  354. interface DoParseState {
  355. groupNameCurrent: string;
  356. millias: number[];
  357. milliaIndex: number;
  358. done: boolean;
  359. }
  360. const doParseGroupName = (result: DoParseState): DoParseState => {
  361. if (result.groupNameCurrent.length < 1) {
  362. return {
  363. ...result,
  364. done: true,
  365. };
  366. }
  367. if (result.groupNameCurrent === 't') {
  368. // If the current group name is "t", then we're done.
  369. // We use the -t- affix to attach the group prefix to the -illion suffix, except for decillion.
  370. return {
  371. ...result,
  372. done: true,
  373. };
  374. }
  375. const centillions = CENTILLIONS_PREFIXES.findIndex((p) => (
  376. p.length > 0 && result.groupNameCurrent.startsWith(p)
  377. ));
  378. if (centillions > -1) {
  379. return {
  380. milliaIndex: 0,
  381. millias: result.millias.map((m, i) => (
  382. i === 0
  383. ? m + (centillions * 100)
  384. : m
  385. )),
  386. groupNameCurrent: result.groupNameCurrent.slice(
  387. CENTILLIONS_PREFIXES[centillions].length,
  388. ),
  389. done: false,
  390. };
  391. }
  392. const decillions = DECILLIONS_PREFIXES.findIndex((p) => (
  393. p.length > 0 && result.groupNameCurrent.startsWith(p)
  394. ));
  395. if (decillions > -1) {
  396. return {
  397. milliaIndex: 0,
  398. millias: result.millias.map((m, i) => (
  399. i === 0
  400. ? m + (decillions * 10)
  401. : m
  402. )),
  403. groupNameCurrent: result.groupNameCurrent.slice(
  404. DECILLIONS_PREFIXES[decillions].length,
  405. ),
  406. done: false,
  407. };
  408. }
  409. const millions = MILLIONS_PREFIXES.findIndex((p) => (
  410. p.length > 0 && result.groupNameCurrent.startsWith(p)
  411. ));
  412. if (millions > -1) {
  413. return {
  414. milliaIndex: 0,
  415. millias: result.millias.map((m, i) => (
  416. i === 0
  417. ? m + millions
  418. : m
  419. )),
  420. groupNameCurrent: result.groupNameCurrent.slice(
  421. MILLIONS_PREFIXES[millions].length,
  422. ),
  423. done: false,
  424. };
  425. }
  426. if (result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`)) {
  427. // short millia
  428. const matchedMilliaArray = result.groupNameCurrent.match(/^\d+/);
  429. if (!matchedMilliaArray) {
  430. throw new InvalidTokenError(result.groupNameCurrent);
  431. }
  432. const matchedMillia = matchedMilliaArray[0];
  433. const newMillia = Number(matchedMillia);
  434. const oldMillia = result.milliaIndex;
  435. const newMillias = [...result.millias];
  436. newMillias[newMillia] = newMillias[oldMillia] || 1;
  437. newMillias[oldMillia] = 0;
  438. return {
  439. milliaIndex: newMillia,
  440. millias: newMillias,
  441. groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length),
  442. done: false,
  443. };
  444. }
  445. if (result.groupNameCurrent.startsWith(MILLIA_PREFIX)) {
  446. const newMillia = result.milliaIndex + 1;
  447. const oldMillia = result.milliaIndex;
  448. const newMillias = [...result.millias];
  449. newMillias[newMillia] = newMillias[oldMillia] || 1;
  450. newMillias[oldMillia] = 0;
  451. return {
  452. milliaIndex: newMillia,
  453. millias: newMillias,
  454. groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length),
  455. done: false,
  456. };
  457. }
  458. throw new InvalidTokenError(result.groupNameCurrent);
  459. };
  460. const getGroupPlaceFromGroupName = (groupName: string) => {
  461. if (groupName === THOUSAND) {
  462. return 1;
  463. }
  464. const groupNameBase = groupName.replace(ILLION_SUFFIX, '');
  465. const specialMillions = MILLIONS_SPECIAL_PREFIXES.findIndex((p) => groupNameBase === p);
  466. if (specialMillions > -1) {
  467. return specialMillions + 1;
  468. }
  469. let result: DoParseState = {
  470. groupNameCurrent: groupNameBase,
  471. millias: [0],
  472. milliaIndex: 0,
  473. done: false,
  474. };
  475. do {
  476. result = doParseGroupName(result);
  477. } while (!result.done);
  478. const bigGroupPlace = Number(
  479. result.millias
  480. .map((s) => s.toString().padStart(3, '0'))
  481. .reverse()
  482. .join(''),
  483. );
  484. return bigGroupPlace + 1;
  485. };
  486. enum ParseGroupsMode {
  487. INITIAL = 'unknown',
  488. ONES_MODE = 'ones',
  489. TENS_MODE = 'tens',
  490. TEN_PLUS_ONES_MODE = 'tenPlusOnes',
  491. HUNDRED_MODE = 'hundred',
  492. THOUSAND_MODE = 'thousand',
  493. DONE = 'done',
  494. }
  495. interface ParserState {
  496. lastToken?: string;
  497. groups: Group[];
  498. mode: ParseGroupsMode;
  499. }
  500. export const parseGroups = (tokens: string[]) => {
  501. const { groups } = [...tokens, FINAL_TOKEN].reduce<ParserState>(
  502. (acc, token) => {
  503. const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE];
  504. if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) {
  505. if (acc.mode === ParseGroupsMode.ONES_MODE) {
  506. const ones = ONES.findIndex((o) => o === acc.lastToken);
  507. if (ones > -1) {
  508. lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`;
  509. }
  510. }
  511. lastGroup[1] = getGroupPlaceFromGroupName(token);
  512. return {
  513. ...acc,
  514. groups: [...acc.groups.slice(0, -1), lastGroup],
  515. lastToken: token,
  516. mode: ParseGroupsMode.THOUSAND_MODE,
  517. };
  518. }
  519. if (token === HUNDRED) {
  520. if (acc.mode === ParseGroupsMode.ONES_MODE) {
  521. const hundreds = ONES.findIndex((o) => o === acc.lastToken);
  522. lastGroup[0] = `${hundreds}${lastGroup[0].slice(1)}`;
  523. return {
  524. ...acc,
  525. groups: [...acc.groups.slice(0, -1), lastGroup],
  526. mode: ParseGroupsMode.HUNDRED_MODE,
  527. };
  528. }
  529. }
  530. if (token === FINAL_TOKEN) {
  531. if (acc.mode === ParseGroupsMode.ONES_MODE) {
  532. const ones = ONES.findIndex((o) => o === acc.lastToken);
  533. lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`;
  534. lastGroup[1] = 0;
  535. return {
  536. ...acc,
  537. groups: [...acc.groups.slice(0, -1), lastGroup],
  538. mode: ParseGroupsMode.DONE,
  539. };
  540. }
  541. }
  542. if (ONES.includes(token as OnesName)) {
  543. if (acc.mode === ParseGroupsMode.THOUSAND_MODE) {
  544. return {
  545. ...acc,
  546. lastToken: token,
  547. mode: ParseGroupsMode.ONES_MODE,
  548. groups: [...acc.groups, [...EMPTY_PLACE]],
  549. };
  550. }
  551. return {
  552. ...acc,
  553. lastToken: token,
  554. mode: ParseGroupsMode.ONES_MODE,
  555. };
  556. }
  557. const tenPlusOnes = TEN_PLUS_ONES.findIndex((t) => t === token);
  558. if (tenPlusOnes > -1) {
  559. if (acc.mode === ParseGroupsMode.THOUSAND_MODE) {
  560. return {
  561. ...acc,
  562. lastToken: token,
  563. mode: ParseGroupsMode.ONES_MODE,
  564. groups: [...acc.groups, [`01${tenPlusOnes}`, lastGroup[1] - 1]],
  565. };
  566. }
  567. lastGroup[0] = `${lastGroup[0].slice(0, 1)}1${tenPlusOnes}`;
  568. return {
  569. ...acc,
  570. lastToken: token,
  571. mode: ParseGroupsMode.TEN_PLUS_ONES_MODE,
  572. groups: [...acc.groups.slice(0, -1), lastGroup],
  573. };
  574. }
  575. const tens = TENS.findIndex((t) => t === token);
  576. if (tens > -1) {
  577. lastGroup[0] = `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[0].slice(2)}`;
  578. return {
  579. ...acc,
  580. lastToken: token,
  581. mode: ParseGroupsMode.TENS_MODE,
  582. groups: [...acc.groups.slice(0, -1), lastGroup],
  583. };
  584. }
  585. return {
  586. ...acc,
  587. lastToken: token,
  588. };
  589. },
  590. {
  591. lastToken: undefined,
  592. groups: [],
  593. mode: ParseGroupsMode.INITIAL,
  594. },
  595. );
  596. return groups;
  597. };
  598. export const combineGroups = (groups: Group[]) => {
  599. const places = groups.map((g) => g[1]);
  600. const maxPlace = Math.max(...places);
  601. const minPlace = Math.min(...places);
  602. const firstGroup = groups.find((g) => g[1] === maxPlace) ?? [...EMPTY_PLACE];
  603. const firstGroupPlace = firstGroup[1];
  604. const groupsSorted = [];
  605. for (let i = maxPlace; i >= minPlace; i -= 1) {
  606. const thisGroup = groups.find((g) => g[1] === i) ?? [...EMPTY_PLACE];
  607. groupsSorted.push(thisGroup);
  608. }
  609. const digits = groupsSorted.reduce(
  610. (previousDigits, thisGroup) => {
  611. const [groupDigits] = thisGroup;
  612. return `${previousDigits}${groupDigits}`;
  613. },
  614. '',
  615. ).replace(/^0+/, '') || '0';
  616. const firstGroupDigits = firstGroup[0];
  617. const firstGroupDigitsWithoutZeroes = firstGroupDigits.replace(/^0+/, '');
  618. const exponentExtra = firstGroupDigits.length - firstGroupDigitsWithoutZeroes.length;
  619. const exponentValue = BigInt((firstGroupPlace * 3) + (2 - exponentExtra));
  620. const isExponentNegative = exponentValue < 0;
  621. const exponentValueAbs = isExponentNegative ? -exponentValue : exponentValue;
  622. const exponentSign = isExponentNegative ? NEGATIVE_SYMBOL : POSITIVE_SYMBOL;
  623. const exponent = `${exponentSign}${exponentValueAbs}`;
  624. const significandInteger = digits.slice(0, 1);
  625. const significandFraction = digits.slice(1).replace(/0+$/, '');
  626. if (significandFraction.length > 0) {
  627. return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`;
  628. }
  629. return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`;
  630. };