UNPKG

english-words-to-numbers

Version:

Convert all words in a string into numbers. Unlike other packages, it can optionally return the entire revised string with Arabic numbers instead of words.

261 lines (221 loc) 13.4 kB
/** * @fileoverview Converts strings like "i need forty-five items" into "i need 45 items". * @author Anton Ivanov <anton@ivanov.hk> */ 'use strict'; const { numToWords } = require('num-to-words'); // There is another package called words-to-numbers, which supports decimals // but fails to convert "twenty-two thousand" properly (a bug in v1.5.1). const WTN = require('word-to-number-node'); const wtn = new WTN(); const DECIMAL_NUMBER_REGEX = /\d+[.]\d+/g; const DECIMAL_NUMBER_WITH_DECIMAL_WORD = /\d+(?:\spoint\s|\sdecimal\s)\d+/gi; // e.g. "123 point 45" /** For phrases like 3.5 million, 9.1 thousand */ const DECIMAL_WITH_MILLION_REGEX = /(?:eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|zero|one|two|three|four|five|six|seven|eight|nine|ten|and|[\s,-])+(?:point|decimal)(?:eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|zero|one|two|three|four|five|six|seven|eight|nine|ten|and|[\s,-])+(?:thousand|million|billion|trillion|quadrillion|quintillion|sextillion|septillion|octillion|nonillion|decillion|undecillion|duodecillion|tredecillion|quattuordecillion|quindecillion|sexdecillion|septendecillion|octodecillion|novemdecillion|vigintillion|unvigintillion|duovigintillion|tresvigintillion|quattuorvigintillion|quinquavigintillion|sesvigintillion|septemvigintillion|octovigintillion|novemvigintillion|trigintillion|untrigintillion|duotrigintillion|googol|trestrigintillion|quattuortrigintillion|quinquatrigintillion|sestrigintillion|septentrigintillion|octotrigintillion|noventrigintillion|quadragintillion|quinquagintillion|sexagintillion|septuagintillion|octogintillion|nonagintillion|centillion|uncentillion|duocentillion|trescentillion|decicentillion|undecicentillion|viginticentillion|unviginticentillion|trigintacentillion|quadragintacentillion|quinquagintacentillion|sexagintacentillion|septuagintacentillion|octogintacentillion|nonagintacentillion|ducentillion|trecentillion|quadringentillion|quingentillion|sescentillion|septingentillion|octingentillion|nongentillion|millinillion)/gi; const NUMBERS_REGEX = /\b\d+/g; // \b because we want to avoid numbers inside words, like email0123@example.com const NUMBER_IN_WORDS_REGEX = /(?:(?:\d+[.]\d+)|(?:\d+|\ba\s|(?:\b(?:hundred|thousand|million|billion|trillion|quadrillion|quintillion|sextillion|septillion|octillion|nonillion|decillion|undecillion|duodecillion|tredecillion|quattuordecillion|quindecillion|sexdecillion|septendecillion|octodecillion|novemdecillion|vigintillion|unvigintillion|duovigintillion|tresvigintillion|quattuorvigintillion|quinquavigintillion|sesvigintillion|septemvigintillion|octovigintillion|novemvigintillion|trigintillion|untrigintillion|duotrigintillion|googol|trestrigintillion|quattuortrigintillion|quinquatrigintillion|sestrigintillion|septentrigintillion|octotrigintillion|noventrigintillion|quadragintillion|quinquagintillion|sexagintillion|septuagintillion|octogintillion|nonagintillion|centillion|uncentillion|duocentillion|trescentillion|decicentillion|undecicentillion|viginticentillion|unviginticentillion|trigintacentillion|quadragintacentillion|quinquagintacentillion|sexagintacentillion|septuagintacentillion|octogintacentillion|nonagintacentillion|ducentillion|trecentillion|quadringentillion|quingentillion|sescentillion|septingentillion|octingentillion|nongentillion|millinillion|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|zero|one|two|three|four|five|six|seven|eight|nine)))|(?:and)|(?:[\s,-]))+/gi; const SINGLE_DIGIT_REGEX = /\d/; /** Matches double-digit numbers + "hundred" */ const TENS_HUNDREDS = /(?:(?:\d+[.]?\d+|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|one|two|three|four|five|six|seven|eight|nine|point|decimal)(?:[\s-])+)+(\bhundred\b)/gi; const TRAILING_OR_LEADING_UNNECESSARY_CHARACTERS = /(^[,])|(^and\b)|(^[-])|([,]$)|(\band$)|(\ba$)|([-]$)/gi; const DEFAULT_EXTRAS = [ { id: 1, from: /(\d)\s*mil\b/gi, to: '$1 million' }, // 123 mil => 123 million { id: 2, from: /(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand)\s*mil\b/gi, to: '$1 million' }, // 123 mil => 123 million { id: 3, from: /(\d)\s*k\b/gi, to: '$1 thousand' }, // 123k => 123 thousand { id: 4, from: /(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand)\s*k\b/gi, to: '$1 thousand' }, // 123 k => 123 thousand { id: 5, from: /(\d)\s*grand\b/gi, to: '$1 thousand' }, // 123 grand => 123 thousand { id: 6, from: /(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand)\s*grand\b/gi, to: '$1 thousand' }, // 123 grand => 123 thousand { id: 7, from: /(\d)\s*bn\b/gi, to: '$1 billion' }, // 123 bn => 123 billion { id: 8, from: /(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand)\s*bn\b/gi, to: '$1 billion' }, // 123 bn => 123 billion ]; /** * Converts strings like "i need forty-five items" into "i need 45 items". * @param {string} text - Any English sentence. * @param {object} [options] - The options object. * @param {boolean} options.enableExtras - Whether to enable additional * replacements which may result in false positives in some cases. * @param {object[]} options.extras - An array of objects containing additional * replacements. Each object must have the 'from' and 'to' properties, e.g. * { from: /\bmil\b/gi, to: 'million' }. Default list is in `DEFAULT_EXTRAS`. * Setting `extras` overrides the `DEFAULT_EXTRAS`, so if you want to expand * it, you need to append to that array. `from` must be a string or pattern, * `to` must be a string. The string can contain references to regex match * groups such as $1. * @returns {string} - The sentence with the number in words changed into * actual Arabic numbers. */ function englishWtn(text, options) { if (!text) { return text; } /** Extra replacements for cases like "2.7k" */ let extras; options = options || {}; if (options.enableExtras) { if (options.extras) { extras = options.extras; } else { extras = DEFAULT_EXTRAS; } } let textOnlyString = _preprocess(text, extras); let arabicNumbersString = textOnlyString; // Special case for decimal + million, e.g. 3.65 million, which is not // supported by the library. let decimalWithThousandsMatches = textOnlyString.match(DECIMAL_WITH_MILLION_REGEX); let stringModified = false; if (decimalWithThousandsMatches) { for (let match of decimalWithThousandsMatches) { // Split "three point five million" into "three point five" and "million" let splitAt = match.lastIndexOf(' '); let [firstPart, secondPart] = [match.substr(0, splitAt), match.substr(splitAt)]; // Clean up for the case if the number start with ", and 17...". Match // has to be cleaned up too, otherwise we will discard the comma, "and" // and similar characters. [match] = _cleanUpNumberMatches([match]); [firstPart, secondPart] = _cleanUpNumberMatches([firstPart, secondPart]); let firstPartAsNumber = englishWtn(firstPart); let secondPartAsNumber = englishWtn(secondPart); if (firstPartAsNumber !== false && secondPartAsNumber !== false) { firstPartAsNumber = parseFloat(firstPartAsNumber); secondPartAsNumber = parseFloat(secondPartAsNumber); let multiplied = Math.round(firstPartAsNumber * secondPartAsNumber); arabicNumbersString = arabicNumbersString.replace(match, multiplied); stringModified = true; } } } // Normal, general case: textOnlyString = _preprocess(arabicNumbersString, extras); arabicNumbersString = textOnlyString; let textOnlyNumbers = _cleanUpNumberMatches(textOnlyString.match(NUMBER_IN_WORDS_REGEX)); if (!textOnlyNumbers || !textOnlyNumbers.length) { // Nothing to do - return early: if (!stringModified) { return text; } } else { // Convert each number from words to Arabic separately: for (let textOnlyNumber of textOnlyNumbers) { let parseWhat = textOnlyNumber.replace(/\ba\b/i, '').trim(); // remove "a" from "a hundred thousand" or trash bits like ", and a" // Normal general case of replacement: let replacement = wtn.parse(parseWhat); // console.log('TRYING TO REPLACE: ', parseWhat, ' => ', replacement); if (replacement !== false) { // false means there was an error in words-to-numbers arabicNumbersString = arabicNumbersString.replace(textOnlyNumber, replacement); } } // If any decimals, convert the word " point " and " decimal " into a dot: let decimalMatches = arabicNumbersString.match(DECIMAL_NUMBER_WITH_DECIMAL_WORD); if (decimalMatches) { for (let decimalMatch of decimalMatches) { let replacement = decimalMatch.replace(/(\spoint\s|\sdecimal\s)/gi, '.'); arabicNumbersString = arabicNumbersString.replace(decimalMatch, replacement); } } } return arabicNumbersString; } /** * Since our regex catches a lot of unnecessary stuff like single spaces and * single words "and", clean up those matches from the array: * @param {string[]} matches */ function _cleanUpNumberMatches(matches) { if (matches) { // Clean up empties: matches = matches.filter(m => { m = m.replace(/and/gi, ''); // technically should be \band\b m = m.replace(/^\s?a\s/i, ''); m = m.replace(/[\s,-]/g, ''); m = m.trim(); return m; }); // Trim leading/trailing trash like ", and" from matches: matches = matches.map(m => { let oldString; let newString = m; // We loop here because there could be multiple things to replace, like // in the case of the string ", and seventeen" - both comma and "and": while (oldString !== newString) { oldString = newString; newString = oldString .trim() .replace(TRAILING_OR_LEADING_UNNECESSARY_CHARACTERS, '') .trim(); } return newString; }); } return matches; } /** * * @param {string} text * @returns {string[]} */ function numbers(text) { if (!text) { return text; } let textOnlyString = _preprocess(text); let extractedNumbers = wtn.parse(textOnlyString) || []; return extractedNumbers; } function _fixCommonVariants(text, extras) { // change fourty into forty: text = text.replace(/\bfourty\b/g, 'forty'); // convert "twelve hundred" into "one thousand two hundred" text = _convertTenHundred(text); // grand, half a million, dozen, if (extras) { for (let extra of extras) { text = text.replace(extra.from, extra.to); } } return text; } function _convertTenHundred(text) { const HUNDRED = 100; let matches = text.match(TENS_HUNDREDS); // "twelve hundred", "25 hundred" if (matches) { for (let match of matches) { let matchWithoutHundred = match.replace(/hundred$/i, ''); let replacement = englishWtn(matchWithoutHundred); let cleanedUpReplacement = replacement.replace(/[^0-9.]/g, ''); let product = Math.round(HUNDRED * parseFloat(cleanedUpReplacement)); text = text.replace(match, product); } } return text; } /** Converts the mixed words and numbers input into uniform words-only input. */ function _preprocess(text, extras) { text = _fixCommonVariants(text, extras); // If the text contains Arabic numbers, preprocess them into words. This // is because we need to support cases like "23 million", and the word-to-number-node // package does not support that. It only supports "twenty-three million" if (text.match(SINGLE_DIGIT_REGEX)) { // First, check for numbers like "123.45" and replace them with "123 point 45", // because the third-party package does not support decimals in input: let decimals = text.match(DECIMAL_NUMBER_REGEX); if (decimals) { for (let decimal of decimals) { let replacement = decimal.replace('.', ' point '); text = text.replace(decimal, replacement); } } // We turned decimals into [number] "point" number, now convert all // numbers into words: let matches = text.match(NUMBERS_REGEX) || []; if (matches) { for (let match of matches) { let matchInWords = numToWords(match); text = text.replace(match, matchInWords); } } } return text; } module.exports = englishWtn; module.exports.numbers = numbers;