Skip to content

Commit

Permalink
feat: allow math with multiple units, combine with expr-eval
Browse files Browse the repository at this point in the history
  • Loading branch information
jorenbroekema committed May 13, 2024
1 parent 2d26dff commit 06a147d
Show file tree
Hide file tree
Showing 4 changed files with 58 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .changeset/short-doors-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tokens-studio/sd-transforms': patch
---

Restructure evaluate math util to support expr eval expressions in combination with regular math.
5 changes: 5 additions & 0 deletions .changeset/ten-icons-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tokens-studio/sd-transforms': patch
---

Allow math expressions where multiple components contain units, as long as they are still computable.
64 changes: 40 additions & 24 deletions src/checkAndEvaluateMath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function splitMultiIntoSingleValues(expr: string): string[] {
left === '' && mathChars.includes(right), // tail of expr, right is math char
right === '' && mathChars.includes(left), // head of expr, left is math char
tokens.length <= 1, // expr is valid if it's a simple 1 token expression
Boolean(tok.match(/\)$/) && mathChars.includes(right)), // end of group ), right is math char
checkIfInsideGroup(tok, expr), // exprs that aren't math expressions are okay within ( ) groups
];

Expand Down Expand Up @@ -73,44 +74,59 @@ function splitMultiIntoSingleValues(expr: string): string[] {
}

function parseAndReduce(expr: string): string | boolean | number {
// We check for px unit, then remove it
let result: string | number = expr;

let evaluated;
// Try to evaluate as expr-eval expression
try {
evaluated = parser.evaluate(`${result}`);
if (typeof evaluated === 'number') {
result = evaluated;
}
} catch (ex) {
//
}

// We check for px unit, then remove it, since these are essentially numbers in tokens context
// We remember that we had px in there so we can put it back in the end result
const hasPx = expr.match('px');
let unitlessExpr = expr.replace(/px/g, '');
const noPixExpr = expr.replace(/px/g, '');
const unitRegex = /(\d+\.?\d*)(?<unit>([a-zA-Z]|%)+)/g;

let matchArr;
const foundUnits: Set<string> = new Set();
while ((matchArr = unitRegex.exec(noPixExpr)) !== null) {
foundUnits.add(matchArr.groups.unit);
}
// multiple units (besides px) found, cannot parse the expression
if (foundUnits.size > 1) {
return result;
}
const resultUnit = Array.from(foundUnits)[0] ?? (hasPx ? 'px' : '');

// Remove it here so we can evaluate expressions like 16px + 24px
const calcParsed = parse(unitlessExpr, { allowInlineCommnets: false });
const calcParsed = parse(noPixExpr, { allowInlineCommnets: false });

// No expression to evaluate, just return it (in case of number as string e.g. '10')
if (calcParsed.nodes.length === 1 && calcParsed.nodes[0].type === 'Number') {
return expr;
return `${result}`;
}

// Attempt to reduce the math expression
const reduced = reduceExpression(calcParsed);
let unit;

// E.g. if type is Length, like 4 * 7rem would be 28rem
if (reduced && reduced.type !== 'Number') {
unitlessExpr = `${reduced.value}`.replace(new RegExp(reduced.unit, 'ig'), '');
unit = reduced.unit;
if (reduced) {
result = reduced.value;
}

// Try to evaluate expression (minus unit) with expr-eval
let evaluated;
try {
evaluated = parser.evaluate(unitlessExpr);
if (typeof evaluated !== 'number') {
return expr;
}
} catch (ex) {
return expr;
if (typeof result !== 'number') {
return result;
}

const formatted = Number.parseFloat(evaluated.toFixed(3));
// Put back the px unit if needed and if reduced doesn't come with one
const formattedUnit = unit ?? (hasPx ? 'px' : '');

// This ensures stringification is not done when not needed (e.g. type number or boolean kept intact)
return formattedUnit ? `${formatted}${formattedUnit}` : formatted;
// the outer Number() gets rid of insignificant trailing zeros of decimal numbers
const reducedTo3Fixed = Number(Number.parseFloat(`${result}`).toFixed(3));
result = resultUnit ? `${reducedTo3Fixed}${resultUnit}` : reducedTo3Fixed;
return result;
}

export function checkAndEvaluateMath(
Expand Down
16 changes: 8 additions & 8 deletions test/spec/checkAndEvaluateMath.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,11 @@ describe('check and evaluate math', () => {
// exception for pixels, it strips px, making it 4 * 7em = 28em = 448px, where 4px * 7em would be 4px * 112px = 448px as well
expect(checkAndEvaluateMath('4px * 7em')).to.equal('28em');
});
// TODO: we can make this smarter in the future. If every piece of the expression shares the same unit,
// we can strip the unit, do the calculation, and add back the unit.
// However, there's not really a good way to do calculations with mixed units,
// e.g. 2em * 4rem is not possible

it('can evaluate math expressions where more than one token has a unit, as long as for each piece of the expression the unit is the same', () => {
// can resolve them, because all values share the same unit
// TODO: implement tests below, failing atm
expect(checkAndEvaluateMath('5rem * 4rem / 2rem')).to.equal('10rem'); // current: '5rem * 4rem / 2rem'
expect(checkAndEvaluateMath('10vw + 20vw')).to.equal('10vw'); // current: '10vw + 20vw'
expect(checkAndEvaluateMath('5px * 4px / 2px')).to.equal('10px');
expect(checkAndEvaluateMath('10vw + 20vw')).to.equal('30vw');

// cannot resolve them, because em is dynamic and 20/20px is static value
expect(checkAndEvaluateMath('2em + 20')).to.equal('2em + 20');
Expand Down Expand Up @@ -67,6 +63,10 @@ describe('check and evaluate math', () => {
expect(checkAndEvaluateMath('ceil(roundTo(16/1.2,0)/2)*2')).to.equal(14);
});

it('should support expr eval expressions in combination with regular math', () => {
expect(checkAndEvaluateMath('roundTo(4 / 7, 1) * 24')).to.equal(14.4);
});

it('does not unnecessarily remove wrapped quotes around font-family values', () => {
expect(checkAndEvaluateMath(`800 italic 16px/1 'Arial Black'`)).to.equal(
`800 italic 16px/1 'Arial Black'`,
Expand All @@ -75,7 +75,7 @@ describe('check and evaluate math', () => {

it('does not unnecessarily change the type of the value', () => {
expect(checkAndEvaluateMath(11)).to.equal(11);
// qchanges to number because the expression is a math expression evaluating to a number result
// changes to number because the expression is a math expression evaluating to a number result
expect(checkAndEvaluateMath('11 * 5')).to.equal(55);
// keeps it as string because there is no math expression to evaluate, so just keep it as is
expect(checkAndEvaluateMath('11')).to.equal('11');
Expand Down

0 comments on commit 06a147d

Please sign in to comment.