numeric-quantity
Version:
Number parser with support for mixed numbers, vulgar fractions, and Roman numerals
458 lines (457 loc) • 14.3 kB
JavaScript
//#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