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
JavaScript
/**
* @fileoverview Converts strings like "i need forty-five items" into "i need 45 items".
* @author Anton Ivanov <anton@ivanov.hk>
*/
;
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;