UNPKG

numeric-quantity

Version:

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

458 lines (457 loc) 14.3 kB
//#region src/constants.ts /** * Unicode decimal digit block start code points. * Each block contains 10 contiguous digits (0-9). * This list covers all \p{Nd} (Decimal_Number) blocks through Unicode 17.0. * The drift test in index.test.ts validates completeness against the JS engine. */ const decimalDigitBlockStarts = [ 48, 1632, 1776, 1984, 2406, 2534, 2662, 2790, 2918, 3046, 3174, 3302, 3430, 3558, 3664, 3792, 3872, 4160, 4240, 6112, 6160, 6470, 6608, 6784, 6800, 6992, 7088, 7232, 7248, 42528, 43216, 43264, 43472, 43504, 43600, 44016, 65296, 66720, 68912, 68928, 69734, 69872, 69942, 70096, 70384, 70736, 70864, 71248, 71360, 71376, 71386, 71472, 71904, 72016, 72688, 72784, 73040, 73120, 73184, 73552, 90416, 92768, 92864, 93008, 93552, 118e3, 120782, 120792, 120802, 120812, 120822, 123200, 123632, 124144, 124401, 125264, 130032 ]; /** * Normalizes non-ASCII decimal digits to ASCII digits. * Converts characters from Unicode decimal digit blocks (e.g., Arabic-Indic, * Devanagari, Bengali) to their ASCII equivalents (0-9). * * All current Unicode \p{Nd} blocks are included in decimalDigitBlockStarts. */ const normalizeDigits = (str) => str.replace(/\p{Nd}/gu, (ch) => { const cp = ch.codePointAt(0); if (cp <= 57) return ch; let lo = 0; let hi = decimalDigitBlockStarts.length - 1; while (lo < hi) { const mid = lo + hi + 1 >>> 1; if (decimalDigitBlockStarts[mid] <= cp) lo = mid; else hi = mid - 1; } return String(cp - decimalDigitBlockStarts[lo]); }); /** * Map of Unicode superscript and subscript digit code points to ASCII digits. */ const superSubDigitToAsciiMap = { "⁰": "0", "¹": "1", "²": "2", "³": "3", "⁴": "4", "⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9", "₀": "0", "₁": "1", "₂": "2", "₃": "3", "₄": "4", "₅": "5", "₆": "6", "₇": "7", "₈": "8", "₉": "9" }; /** * Captures Unicode superscript and subscript digits. */ const superSubDigitsRegex = /[⁰¹²³⁴⁵⁶⁷⁸⁹₀₁₂₃₄₅₆₇₈₉]/g; /** * Map of Unicode fraction code points to their ASCII equivalents. */ const vulgarFractionToAsciiMap = { "¼": "1/4", "½": "1/2", "¾": "3/4", "⅐": "1/7", "⅑": "1/9", "⅒": "1/10", "⅓": "1/3", "⅔": "2/3", "⅕": "1/5", "⅖": "2/5", "⅗": "3/5", "⅘": "4/5", "⅙": "1/6", "⅚": "5/6", "⅛": "1/8", "⅜": "3/8", "⅝": "5/8", "⅞": "7/8", "⅟": "1/" }; /** * Captures the individual elements of a numeric string. Commas and underscores are allowed * as separators, as long as they appear between digits and are not consecutive. * * Capture groups: * * | # | Description | Example(s) | * | --- | ------------------------------------------------ | ------------------------------------------------------------------- | * | `0` | entire string | `"2 1/3"` from `"2 1/3"` | * | `1` | sign (`-` or `+`) | `"-"` from `"-2 1/3"` | * | `2` | whole number or numerator | `"2"` from `"2 1/3"`; `"1"` from `"1/3"` | * | `3` | entire fraction, decimal portion, or denominator | `" 1/3"` from `"2 1/3"`; `".33"` from `"2.33"`; `"/3"` from `"1/3"` | * * _Capture group 2 may include comma/underscore separators._ * * @example * * ```ts * numericRegex.exec("1") // [ "1", "1", null, null ] * numericRegex.exec("1.23") // [ "1.23", "1", ".23", null ] * numericRegex.exec("1 2/3") // [ "1 2/3", "1", " 2/3", " 2" ] * numericRegex.exec("2/3") // [ "2/3", "2", "/3", null ] * numericRegex.exec("2 / 3") // [ "2 / 3", "2", "/ 3", null ] * ``` */ const numericRegex = /^(?=[-+]?\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)*)?$/; /** * Same as {@link numericRegex}, but allows (and ignores) trailing invalid characters. * Capture group 7 contains the trailing invalid portion. */ const numericRegexWithTrailingInvalid = /^(?=[-+]?\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)*)?(\s*[^.\d/].*)?/; /** * Captures any Unicode vulgar fractions. */ const vulgarFractionsRegex = /([¼½¾⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞⅟}])/g; /** * Map of Roman numeral sequences to their decimal equivalents. */ const romanNumeralValues = { MMM: 3e3, MM: 2e3, M: 1e3, CM: 900, DCCC: 800, DCC: 700, DC: 600, D: 500, CD: 400, CCC: 300, CC: 200, C: 100, XC: 90, LXXX: 80, LXX: 70, LX: 60, L: 50, XL: 40, XXX: 30, XX: 20, XII: 12, XI: 11, X: 10, IX: 9, VIII: 8, VII: 7, VI: 6, V: 5, IV: 4, III: 3, II: 2, I: 1 }; /** * Map of Unicode Roman numeral code points to their ASCII equivalents. */ const romanNumeralUnicodeToAsciiMap = { Ⅰ: "I", Ⅱ: "II", Ⅲ: "III", Ⅳ: "IV", Ⅴ: "V", Ⅵ: "VI", Ⅶ: "VII", Ⅷ: "VIII", Ⅸ: "IX", Ⅹ: "X", Ⅺ: "XI", Ⅻ: "XII", Ⅼ: "L", Ⅽ: "C", Ⅾ: "D", Ⅿ: "M", ⅰ: "I", ⅱ: "II", ⅲ: "III", ⅳ: "IV", ⅴ: "V", ⅵ: "VI", ⅶ: "VII", ⅷ: "VIII", ⅸ: "IX", ⅹ: "X", ⅺ: "XI", ⅻ: "XII", ⅼ: "L", ⅽ: "C", ⅾ: "D", ⅿ: "M" }; /** * Captures all Unicode Roman numeral code points. */ const romanNumeralUnicodeRegex = /([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿ])/gi; /** * Captures a valid Roman numeral sequence. * * Capture groups: * * | # | Description | Example | * | --- | --------------- | ------------------------ | * | `0` | Entire string | "MCCXIV" from "MCCXIV" | * | `1` | Thousands | "M" from "MCCXIV" | * | `2` | Hundreds | "CC" from "MCCXIV" | * | `3` | Tens | "X" from "MCCXIV" | * | `4` | Ones | "IV" from "MCCXIV" | * * @example * * ```ts * romanNumeralRegex.exec("M") // [ "M", "M", "", "", "" ] * romanNumeralRegex.exec("XII") // [ "XII", "", "", "X", "II" ] * romanNumeralRegex.exec("MCCXIV") // [ "MCCXIV", "M", "CC", "X", "IV" ] * ``` */ const romanNumeralRegex = /^(?=[MDCLXVI])(M{0,3})(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/i; /** * Default options for {@link numericQuantity}. */ const defaultOptions = { round: 3, allowTrailingInvalid: false, romanNumerals: false, bigIntOnOverflow: false, decimalSeparator: ".", allowCurrency: false, percentage: false, verbose: false }; //#endregion //#region src/parseRomanNumerals.ts /** * Converts a string of Roman numerals to a number, like `parseInt` * for Roman numerals. Uses modern, strict rules (only 1 to 3999). * * The string can include ASCII representations of Roman numerals * or Unicode Roman numeral code points (`U+2160` through `U+217F`). */ const parseRomanNumerals = (romanNumerals) => { const normalized = `${romanNumerals}`.replace(romanNumeralUnicodeRegex, (_m, rn) => romanNumeralUnicodeToAsciiMap[rn]).toUpperCase(); const regexResult = romanNumeralRegex.exec(normalized); if (!regexResult) return NaN; const [, thousands, hundreds, tens, ones] = regexResult; return (romanNumeralValues[thousands] ?? 0) + (romanNumeralValues[hundreds] ?? 0) + (romanNumeralValues[tens] ?? 0) + (romanNumeralValues[ones] ?? 0); }; //#endregion //#region src/numericQuantity.ts const spaceThenSlashRegex = /^\s*\//; const currencyPrefixRegex = /^([-+]?)\s*(\p{Sc}+)\s*/u; const currencySuffixRegex = /\s*(\p{Sc}+)$/u; const percentageSuffixRegex = /%$/; function numericQuantity(quantity, options = defaultOptions) { const opts = { ...defaultOptions, ...options }; const originalInput = typeof quantity === "string" ? quantity : `${quantity}`; let currencyPrefix; let currencySuffix; let percentageSuffix; let trailingInvalid; let parsedSign; let parsedWhole; let parsedNumerator; let parsedDenominator; const buildVerboseResult = (value) => { const result = { value, input: originalInput }; if (currencyPrefix) result.currencyPrefix = currencyPrefix; if (currencySuffix) result.currencySuffix = currencySuffix; if (percentageSuffix) result.percentageSuffix = percentageSuffix; if (trailingInvalid) result.trailingInvalid = trailingInvalid; if (parsedSign) result.sign = parsedSign; if (parsedWhole !== void 0) result.whole = parsedWhole; if (parsedNumerator !== void 0) result.numerator = parsedNumerator; if (parsedDenominator !== void 0) result.denominator = parsedDenominator; return result; }; const returnValue = (value) => opts.verbose ? buildVerboseResult(value) : value; if (typeof quantity === "number" || typeof quantity === "bigint") return returnValue(quantity); let finalResult = NaN; let workingString = originalInput; if (opts.allowCurrency) { const prefixMatch = currencyPrefixRegex.exec(workingString); if (prefixMatch && prefixMatch[2]) { currencyPrefix = prefixMatch[2]; workingString = (prefixMatch[1] || "") + workingString.slice(prefixMatch[0].length); } } if (opts.allowCurrency) { const suffixMatch = currencySuffixRegex.exec(workingString); if (suffixMatch) { currencySuffix = suffixMatch[1]; workingString = workingString.slice(0, -suffixMatch[0].length); } } if (opts.percentage && percentageSuffixRegex.test(workingString)) { percentageSuffix = true; workingString = workingString.slice(0, -1); } const quantityAsString = normalizeDigits(workingString.replace(vulgarFractionsRegex, (_m, vf) => ` ${vulgarFractionToAsciiMap[vf]}`).replace(superSubDigitsRegex, (ch) => superSubDigitToAsciiMap[ch]).replace("⁄", "/").trim()); if (quantityAsString.length === 0) return returnValue(NaN); let normalizedString = quantityAsString; if (opts.decimalSeparator === ",") { const commaCount = (quantityAsString.match(/,/g) || []).length; if (commaCount === 1) normalizedString = quantityAsString.replaceAll(".", "_").replace(",", "."); else if (commaCount > 1) { if (!opts.allowTrailingInvalid) return returnValue(NaN); const firstCommaIndex = quantityAsString.indexOf(","); const secondCommaIndex = quantityAsString.indexOf(",", firstCommaIndex + 1); const beforeSecondComma = quantityAsString.substring(0, secondCommaIndex).replaceAll(".", "_").replace(",", "."); const afterSecondComma = quantityAsString.substring(secondCommaIndex + 1); normalizedString = opts.allowTrailingInvalid ? beforeSecondComma + "&" + afterSecondComma : beforeSecondComma; } else normalizedString = quantityAsString.replaceAll(".", "_"); } const regexResult = numericRegexWithTrailingInvalid.exec(normalizedString); if (!regexResult) return returnValue(opts.romanNumerals ? parseRomanNumerals(quantityAsString) : NaN); const rawTrailing = (regexResult[7] || normalizedString.slice(regexResult[0].length)).trim(); if (rawTrailing) { trailingInvalid = rawTrailing; if (!opts.allowTrailingInvalid) return returnValue(NaN); } const [, sign, ng1temp, ng2temp] = regexResult; if (sign === "-" || sign === "+") parsedSign = sign; const numberGroup1 = ng1temp.replaceAll(",", "").replaceAll("_", ""); const numberGroup2 = ng2temp === null || ng2temp === void 0 ? void 0 : ng2temp.replaceAll(",", "").replaceAll("_", ""); if (!numberGroup1 && numberGroup2 && numberGroup2.startsWith(".")) finalResult = 0; else { if (opts.bigIntOnOverflow) { const asBigInt = sign === "-" ? BigInt(`-${numberGroup1}`) : BigInt(numberGroup1); if (asBigInt > BigInt(Number.MAX_SAFE_INTEGER) || asBigInt < BigInt(Number.MIN_SAFE_INTEGER)) return returnValue(asBigInt); } finalResult = parseInt(numberGroup1); } if (!numberGroup2) { finalResult = sign === "-" ? finalResult * -1 : finalResult; if (percentageSuffix && opts.percentage !== "number") finalResult = finalResult / 100; return returnValue(finalResult); } const roundingFactor = opts.round === false ? NaN : parseFloat(`1e${Math.floor(Math.max(0, opts.round))}`); if (numberGroup2.startsWith(".") || numberGroup2.startsWith("e") || numberGroup2.startsWith("E")) { const decimalValue = parseFloat(`${finalResult}${numberGroup2}`); finalResult = isNaN(roundingFactor) ? decimalValue : Math.round(decimalValue * roundingFactor) / roundingFactor; } else if (spaceThenSlashRegex.test(numberGroup2)) { const numerator = parseInt(numberGroup1); const denominator = parseInt(numberGroup2.replace("/", "")); parsedNumerator = numerator; parsedDenominator = denominator; finalResult = isNaN(roundingFactor) ? numerator / denominator : Math.round(numerator * roundingFactor / denominator) / roundingFactor; } else { const [numerator, denominator] = numberGroup2.split("/").map((v) => parseInt(v)); parsedWhole = finalResult; parsedNumerator = numerator; parsedDenominator = denominator; finalResult += isNaN(roundingFactor) ? numerator / denominator : Math.round(numerator * roundingFactor / denominator) / roundingFactor; } finalResult = sign === "-" ? finalResult * -1 : finalResult; if (percentageSuffix && opts.percentage !== "number") finalResult = isNaN(roundingFactor) ? finalResult / 100 : Math.round(finalResult / 100 * roundingFactor) / roundingFactor; return returnValue(finalResult); } //#endregion //#region src/isNumericQuantity.ts /** * Checks if a string represents a valid numeric quantity. * * Returns `true` if the string can be parsed as a number, `false` otherwise. * Accepts the same options as `numericQuantity`. */ const isNumericQuantity = (quantity, options) => { const result = numericQuantity(quantity, { ...options, verbose: false }); return typeof result === "bigint" || !isNaN(result); }; //#endregion export { defaultOptions, isNumericQuantity, normalizeDigits, numericQuantity, numericRegex, numericRegexWithTrailingInvalid, parseRomanNumerals, romanNumeralRegex, romanNumeralUnicodeRegex, romanNumeralUnicodeToAsciiMap, romanNumeralValues, superSubDigitToAsciiMap, superSubDigitsRegex, vulgarFractionToAsciiMap, vulgarFractionsRegex }; //# sourceMappingURL=numeric-quantity.mjs.map