UNPKG

numeric-quantity

Version:

Number parser with support for mixed numbers, vulgar fractions, and Roman numerals

1 lines 16.5 kB
{"version":3,"sources":["../src/constants.ts","../src/parseRomanNumerals.ts","../src/numericQuantity.ts"],"sourcesContent":["import type {\n NumericQuantityOptions,\n RomanNumeralAscii,\n RomanNumeralUnicode,\n VulgarFraction,\n} from './types';\n\n// #region Arabic numerals\n/**\n * Map of Unicode fraction code points to their ASCII equivalents.\n */\nexport const vulgarFractionToAsciiMap: Record<\n VulgarFraction,\n `${number}/${number | ''}`\n> = {\n '¼': '1/4',\n '½': '1/2',\n '¾': '3/4',\n '⅐': '1/7',\n '⅑': '1/9',\n '⅒': '1/10',\n '⅓': '1/3',\n '⅔': '2/3',\n '⅕': '1/5',\n '⅖': '2/5',\n '⅗': '3/5',\n '⅘': '4/5',\n '⅙': '1/6',\n '⅚': '5/6',\n '⅛': '1/8',\n '⅜': '3/8',\n '⅝': '5/8',\n '⅞': '7/8',\n '⅟': '1/',\n} as const;\n\n/**\n * Captures the individual elements of a numeric string.\n *\n * Capture groups:\n *\n * | # | Description | Example(s) |\n * | --- | ------------------------------------------------ | ------------------------------------------------------------------- |\n * | `0` | entire string | `\"2 1/3\"` from `\"2 1/3\"` |\n * | `1` | \"negative\" dash | `\"-\"` from `\"-2 1/3\"` |\n * | `2` | whole number or numerator | `\"2\"` from `\"2 1/3\"`; `\"1\"` from `\"1/3\"` |\n * | `3` | entire fraction, decimal portion, or denominator | `\" 1/3\"` from `\"2 1/3\"`; `\".33\"` from `\"2.33\"`; `\"/3\"` from `\"1/3\"` |\n *\n * _Capture group 2 may include comma/underscore separators._\n *\n * @example\n *\n * ```ts\n * numericRegex.exec(\"1\") // [ \"1\", \"1\", null, null ]\n * numericRegex.exec(\"1.23\") // [ \"1.23\", \"1\", \".23\", null ]\n * numericRegex.exec(\"1 2/3\") // [ \"1 2/3\", \"1\", \" 2/3\", \" 2\" ]\n * numericRegex.exec(\"2/3\") // [ \"2/3\", \"2\", \"/3\", null ]\n * numericRegex.exec(\"2 / 3\") // [ \"2 / 3\", \"2\", \"/ 3\", null ]\n * ```\n */\nexport const numericRegex: RegExp =\n /^(?=-?\\s*\\.\\d|-?\\s*\\d)(-)?\\s*((?:\\d(?:[\\d,_]*\\d)?)*)(([eE][+-]?\\d(?:[\\d,_]*\\d)?)?|\\.\\d(?:[\\d,_]*\\d)?([eE][+-]?\\d(?:[\\d,_]*\\d)?)?|(\\s+\\d(?:[\\d,_]*\\d)?\\s*)?\\s*\\/\\s*\\d(?:[\\d,_]*\\d)?)?$/;\n/**\n * Same as {@link numericRegex}, but allows (and ignores) trailing invalid characters.\n */\nexport const numericRegexWithTrailingInvalid: RegExp = new RegExp(\n numericRegex.source.replace(/\\$$/, '(?:\\\\s*[^\\\\.\\\\d\\\\/].*)?')\n);\n\n/**\n * Captures any Unicode vulgar fractions.\n */\nexport const vulgarFractionsRegex: RegExp = new RegExp(\n `(${Object.keys(vulgarFractionToAsciiMap).join('|')})`\n);\n// #endregion\n\n// #region Roman numerals\ntype RomanNumeralSequenceFragment =\n | `${RomanNumeralAscii}`\n | `${RomanNumeralAscii}${RomanNumeralAscii}`\n | `${RomanNumeralAscii}${RomanNumeralAscii}${RomanNumeralAscii}`\n | `${RomanNumeralAscii}${RomanNumeralAscii}${RomanNumeralAscii}${RomanNumeralAscii}`;\n\n/**\n * Map of Roman numeral sequences to their decimal equivalents.\n */\nexport const romanNumeralValues: {\n [k in RomanNumeralSequenceFragment]?: number;\n} = {\n MMM: 3000,\n MM: 2000,\n M: 1000,\n CM: 900,\n DCCC: 800,\n DCC: 700,\n DC: 600,\n D: 500,\n CD: 400,\n CCC: 300,\n CC: 200,\n C: 100,\n XC: 90,\n LXXX: 80,\n LXX: 70,\n LX: 60,\n L: 50,\n XL: 40,\n XXX: 30,\n XX: 20,\n XII: 12, // only here for tests; not used in practice\n XI: 11, // only here for tests; not used in practice\n X: 10,\n IX: 9,\n VIII: 8,\n VII: 7,\n VI: 6,\n V: 5,\n IV: 4,\n III: 3,\n II: 2,\n I: 1,\n} as const;\n\n/**\n * Map of Unicode Roman numeral code points to their ASCII equivalents.\n */\nexport const romanNumeralUnicodeToAsciiMap: Record<\n RomanNumeralUnicode,\n keyof typeof romanNumeralValues\n> = {\n // Roman Numeral One (U+2160)\n Ⅰ: 'I',\n // Roman Numeral Two (U+2161)\n Ⅱ: 'II',\n // Roman Numeral Three (U+2162)\n Ⅲ: 'III',\n // Roman Numeral Four (U+2163)\n Ⅳ: 'IV',\n // Roman Numeral Five (U+2164)\n Ⅴ: 'V',\n // Roman Numeral Six (U+2165)\n Ⅵ: 'VI',\n // Roman Numeral Seven (U+2166)\n Ⅶ: 'VII',\n // Roman Numeral Eight (U+2167)\n Ⅷ: 'VIII',\n // Roman Numeral Nine (U+2168)\n Ⅸ: 'IX',\n // Roman Numeral Ten (U+2169)\n Ⅹ: 'X',\n // Roman Numeral Eleven (U+216A)\n Ⅺ: 'XI',\n // Roman Numeral Twelve (U+216B)\n Ⅻ: 'XII',\n // Roman Numeral Fifty (U+216C)\n Ⅼ: 'L',\n // Roman Numeral One Hundred (U+216D)\n Ⅽ: 'C',\n // Roman Numeral Five Hundred (U+216E)\n Ⅾ: 'D',\n // Roman Numeral One Thousand (U+216F)\n Ⅿ: 'M',\n // Small Roman Numeral One (U+2170)\n ⅰ: 'I',\n // Small Roman Numeral Two (U+2171)\n ⅱ: 'II',\n // Small Roman Numeral Three (U+2172)\n ⅲ: 'III',\n // Small Roman Numeral Four (U+2173)\n ⅳ: 'IV',\n // Small Roman Numeral Five (U+2174)\n ⅴ: 'V',\n // Small Roman Numeral Six (U+2175)\n ⅵ: 'VI',\n // Small Roman Numeral Seven (U+2176)\n ⅶ: 'VII',\n // Small Roman Numeral Eight (U+2177)\n ⅷ: 'VIII',\n // Small Roman Numeral Nine (U+2178)\n ⅸ: 'IX',\n // Small Roman Numeral Ten (U+2179)\n ⅹ: 'X',\n // Small Roman Numeral Eleven (U+217A)\n ⅺ: 'XI',\n // Small Roman Numeral Twelve (U+217B)\n ⅻ: 'XII',\n // Small Roman Numeral Fifty (U+217C)\n ⅼ: 'L',\n // Small Roman Numeral One Hundred (U+217D)\n ⅽ: 'C',\n // Small Roman Numeral Five Hundred (U+217E)\n ⅾ: 'D',\n // Small Roman Numeral One Thousand (U+217F)\n ⅿ: 'M',\n} as const;\n\n/**\n * Captures all Unicode Roman numeral code points.\n */\nexport const romanNumeralUnicodeRegex: RegExp = new RegExp(\n `(${Object.keys(romanNumeralUnicodeToAsciiMap).join('|')})`,\n 'gi'\n);\n\n/**\n * Captures a valid Roman numeral sequence.\n *\n * Capture groups:\n *\n * | # | Description | Example |\n * | --- | --------------- | ------------------------ |\n * | `0` | Entire string | \"MCCXIV\" from \"MCCXIV\" |\n * | `1` | Thousands | \"M\" from \"MCCXIV\" |\n * | `2` | Hundreds | \"CC\" from \"MCCXIV\" |\n * | `3` | Tens | \"X\" from \"MCCXIV\" |\n * | `4` | Ones | \"IV\" from \"MCCXIV\" |\n *\n * @example\n *\n * ```ts\n * romanNumeralRegex.exec(\"M\") // [ \"M\", \"M\", \"\", \"\", \"\" ]\n * romanNumeralRegex.exec(\"XII\") // [ \"XII\", \"\", \"\", \"X\", \"II\" ]\n * romanNumeralRegex.exec(\"MCCXIV\") // [ \"MCCXIV\", \"M\", \"CC\", \"X\", \"IV\" ]\n * ```\n */\nexport const romanNumeralRegex: RegExp =\n /^(?=[MDCLXVI])(M{0,3})(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/i;\n// #endregion\n\n/**\n * Default options for {@link numericQuantity}.\n */\nexport const defaultOptions: Required<NumericQuantityOptions> = {\n round: 3,\n allowTrailingInvalid: false,\n romanNumerals: false,\n bigIntOnOverflow: false,\n} as const;\n","import {\n romanNumeralRegex,\n romanNumeralUnicodeRegex,\n romanNumeralUnicodeToAsciiMap,\n romanNumeralValues,\n} from './constants';\n\n// Just a shorthand type alias\ntype RNV = keyof typeof romanNumeralValues;\n\n/**\n * Converts a string of Roman numerals to a number, like `parseInt`\n * for Roman numerals. Uses modern, strict rules (only 1 to 3999).\n *\n * The string can include ASCII representations of Roman numerals\n * or Unicode Roman numeral code points (`U+2160` through `U+217F`).\n */\nexport const parseRomanNumerals = (romanNumerals: string): number => {\n const normalized = `${romanNumerals}`\n // Convert Unicode Roman numerals to ASCII\n .replace(\n romanNumeralUnicodeRegex,\n (_m, rn: keyof typeof romanNumeralUnicodeToAsciiMap) =>\n romanNumeralUnicodeToAsciiMap[rn]\n )\n // Normalize to uppercase (more common for Roman numerals)\n .toUpperCase();\n\n const regexResult = romanNumeralRegex.exec(normalized);\n\n if (!regexResult) {\n return NaN;\n }\n\n const [, thousands, hundreds, tens, ones] = regexResult;\n\n return (\n (romanNumeralValues[thousands as RNV] ?? 0) +\n (romanNumeralValues[hundreds as RNV] ?? 0) +\n (romanNumeralValues[tens as RNV] ?? 0) +\n (romanNumeralValues[ones as RNV] ?? 0)\n );\n};\n","import {\n defaultOptions,\n numericRegex,\n numericRegexWithTrailingInvalid,\n vulgarFractionToAsciiMap,\n vulgarFractionsRegex,\n} from './constants';\nimport { parseRomanNumerals } from './parseRomanNumerals';\nimport type { NumericQuantityOptions } from './types';\n\nconst spaceThenSlashRegex = /^\\s*\\//;\n\n/**\n * Converts a string to a number, like an enhanced version of `parseFloat`.\n *\n * The string can include mixed numbers, vulgar fractions, or Roman numerals.\n */\nfunction numericQuantity(quantity: string | number): number;\nfunction numericQuantity(quantity: string | number): number;\nfunction numericQuantity(\n quantity: string | number,\n options: NumericQuantityOptions & { bigIntOnOverflow: true }\n): number | bigint;\nfunction numericQuantity(\n quantity: string | number,\n options?: NumericQuantityOptions\n): number;\nfunction numericQuantity(\n quantity: string | number,\n options: NumericQuantityOptions = defaultOptions\n) {\n if (typeof quantity === 'number' || typeof quantity === 'bigint') {\n return quantity;\n }\n\n let finalResult = NaN;\n\n // Coerce to string in case qty is a number\n const quantityAsString = `${quantity}`\n // Convert vulgar fractions to ASCII, with a leading space\n // to keep the whole number and the fraction separate\n .replace(\n vulgarFractionsRegex,\n (_m, vf: keyof typeof vulgarFractionToAsciiMap) =>\n ` ${vulgarFractionToAsciiMap[vf]}`\n )\n // Convert fraction slash to standard slash\n .replace('⁄', '/')\n .trim();\n\n // Bail out if the string was only white space\n if (quantityAsString.length === 0) {\n return NaN;\n }\n\n const opts: Required<NumericQuantityOptions> = {\n ...defaultOptions,\n ...options,\n };\n\n const regexResult = (\n opts.allowTrailingInvalid ? numericRegexWithTrailingInvalid : numericRegex\n ).exec(quantityAsString);\n\n // If the Arabic numeral regex fails, try Roman numerals\n if (!regexResult) {\n return opts.romanNumerals ? parseRomanNumerals(quantityAsString) : NaN;\n }\n\n const [, dash, ng1temp, ng2temp] = regexResult;\n const numberGroup1 = ng1temp.replace(/[,_]/g, '');\n const numberGroup2 = ng2temp?.replace(/[,_]/g, '');\n\n // Numerify capture group 1\n if (!numberGroup1 && numberGroup2 && numberGroup2.startsWith('.')) {\n finalResult = 0;\n } else {\n if (opts.bigIntOnOverflow) {\n const asBigInt = dash ? BigInt(`-${numberGroup1}`) : BigInt(numberGroup1);\n if (\n asBigInt > BigInt(Number.MAX_SAFE_INTEGER) ||\n asBigInt < BigInt(Number.MIN_SAFE_INTEGER)\n ) {\n return asBigInt;\n }\n }\n\n finalResult = parseInt(numberGroup1);\n }\n\n // If capture group 2 is null, then we're dealing with an integer\n // and there is nothing left to process\n if (!numberGroup2) {\n return dash ? finalResult * -1 : finalResult;\n }\n\n const roundingFactor =\n opts.round === false\n ? NaN\n : parseFloat(`1e${Math.floor(Math.max(0, opts.round))}`);\n\n if (\n numberGroup2.startsWith('.') ||\n numberGroup2.startsWith('e') ||\n numberGroup2.startsWith('E')\n ) {\n // If first char of `numberGroup2` is \".\" or \"e\"/\"E\", it's a decimal\n const decimalValue = parseFloat(`${finalResult}${numberGroup2}`);\n finalResult = isNaN(roundingFactor)\n ? decimalValue\n : Math.round(decimalValue * roundingFactor) / roundingFactor;\n } else if (spaceThenSlashRegex.test(numberGroup2)) {\n // If the first non-space char is \"/\" it's a pure fraction (e.g. \"1/2\")\n const numerator = parseInt(numberGroup1);\n const denominator = parseInt(numberGroup2.replace('/', ''));\n finalResult = isNaN(roundingFactor)\n ? numerator / denominator\n : Math.round((numerator * roundingFactor) / denominator) / roundingFactor;\n } else {\n // Otherwise it's a mixed fraction (e.g. \"1 2/3\")\n const fractionArray = numberGroup2.split('/');\n const [numerator, denominator] = fractionArray.map(v => parseInt(v));\n finalResult += isNaN(roundingFactor)\n ? numerator / denominator\n : Math.round((numerator * roundingFactor) / denominator) / roundingFactor;\n }\n\n return dash ? finalResult * -1 : finalResult;\n}\n\nexport { numericQuantity };\n"],"mappings":"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","names":[]}