Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
/**
* Attempts to evaluate a CSS calc expression
*
* @param {string} expression - The CSS `calc()` expression to evaluate.
* It can include numbers, units (e.g., px, %, rem), and operators (+, -, *, /).
*
* @returns {string} - The evaluated result of the expression as a string, including the unit (if any).
*
* @throws {Error} - Throws an error if the expression is invalid or if multiple units are mixed.
*
* @example
* resolveCssCalc("2 * 5rem"); // returns "10rem"
* resolveCssCalc("8 / 2"); // returns "4"
* resolveCssCalc("2px + 5px"); // returns "7px"
* resolveCssCalc("1% + 5px"); // throws Error: Mixing units is not allowed
*/
export const resolveCssCalc = expression => {
const unitRegex = /[a-zA-Z%]+/g;
const precedence = { "+": 1, "-": 1, "*": 2, "/": 2 };
// Tokenize the expression into numbers, units, and operators
const tokens = expression.match(/\d*\.?\d+[a-zA-Z%]*|[-+*/()]/g);
if (!tokens) {
throw new Error("[resolveCssCalc] Invalid expression");
}
// Collect all unique units found in the expression
const units = new Set();
const parsedTokens = tokens.map(token => {
const unit = token.match(unitRegex)?.[0] || ""; // Extract the unit from the token
if (unit) {
units.add(unit); // Add the unit to the set
}
return parseFloat(token) || token; // Parse the numeric value or keep the operator
});
// Ensure that only one type of unit is used in the expression
// If two units eliminate each other (e.g. 5px - 5px + 1rem) then that also fails, but why should we make such calculations.
if (units.size > 1) {
throw new Error("[resolveCssCalc] Mixing units is not allowed");
}
const resultUnit = units.size ? [...units][0] : "";
const output = [];
const operators = [];
// Function to compute the result of the top two numbers in the output stack
const compute = () => {
const b = output.pop();
const a = output.pop();
switch (operators.pop()) {
case "+":
output.push(a + b);
break;
case "-":
output.push(a - b);
break;
case "*":
output.push(a * b);
break;
case "/":
output.push(a / b);
break;
}
};
// Process each token in the expression
for (let i = 0; i < parsedTokens.length; i++) {
let token = parsedTokens[i];
// Push numbers to the output stack
if (typeof token === "number") {
output.push(token);
}
// Push opening parenthesis to the operators stack
else if (token === "(") {
operators.push(token);
}
// Compute closing parenthesis, until the matching opening parenthesis is found
else if (token === ")") {
while (operators.at(-1) !== "(") {
compute();
}
// Remove the opening parenthesis from the stack
operators.pop();
}
// If the token is an operator (+, -, *, /)
else {
// Handle signed numbers (e.g., "-5" or "+3")
if (
(token === "-" || token === "+") &&
(i === 0 || parsedTokens[i - 1] === "(")
) {
output.push(0); // Treat it as "0 - 5" or "0 + 3"
}
// While the precedence of the current operator is less than or equal to the operator on top of the stack,
// compute the result of the top two numbers in the output stack
while (
operators.length &&
precedence[token] <= precedence[operators.at(-1)]
) {
compute();
}
// Push the current operator to the operators stack
operators.push(token);
}
}
// Compute any remaining operations in the stacks
while (operators.length) {
compute();
}
if (isNaN(output[0])) {
throw new Error(
"[resolveCssCalc] Resolving math expression resulted in NaN"
);
}
return output[0] + resultUnit;
};