diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc4aef..f319774 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,14 +3,17 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. -## Unreleased +## 11.2.0 (2026-09-17) -### Bug Fixes +### Features -- Resolved calculation roots now default to standard serialization inside - `calc()`. Use `unwrapSingleValue: true` to emit fully resolved finite scalar - results as bare values, since unwrapping can discard browser-applied range clamping or integer - rounding. +- postcss-calc now offers two modes. By default, it tries to follow the spec +as closely as possible by not unwrapping calc() value. This allows the browser to transform negative and floating point values, especially in cases where only integers are allowed in the CSS property. +If you want to remove the `calc()` function when it wraps a single value, use `unwrapSingleValue: true`. This allows you to get rid of `calc()` and replace it with a single number wherever that is possible. + +### Bug fixes +- postcss-calc performs rounding in a way that's hopefully closer to the specification +- fix cases where the `sign()` function was not computed correctly ### Migration diff --git a/package.json b/package.json index 234fc30..8207a7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postcss-calc", - "version": "11.1.2", + "version": "11.2.0", "type": "module", "description": "PostCSS plugin to reduce calc()", "keywords": [ diff --git a/src/lib/analyze.js b/src/lib/analyze.js index 4ec7771..7c063a5 100644 --- a/src/lib/analyze.js +++ b/src/lib/analyze.js @@ -88,7 +88,8 @@ function analyzeProduct(node, depth) { let denominator = null; let valid = true; let structurallyValid = true; - let hasUnknown = false; + let hasUnknownNumerator = false; + let hasUnknownDenominator = false; let hasUnresolved = false; for (const factor of node.factors) { const child = analyzeType(factor.node, depth + 1); @@ -98,7 +99,8 @@ function analyzeProduct(node, depth) { continue; } if (child.type.kind === 'unknown') { - hasUnknown = true; + if (factor.exponent === 1) hasUnknownNumerator = true; + else hasUnknownDenominator = true; continue; } if (child.type.kind !== 'dimension') continue; @@ -121,9 +123,24 @@ function analyzeProduct(node, depth) { ) { return finish(failureType, false, hasUnresolved); } - // An opaque factor may supply type information that changes how the known - // dimensions combine once the known factors are structurally valid. - if (hasUnknown) return finish(unknownType, true, hasUnresolved); + // An unresolved numerator multiplied by one known numerator dimension can + // only leave that dimension in place (when it resolves to a number) or make + // the product invalid. It can never make the product a bare number. Keep + // that known constraint so a surrounding sum can reject `1px * 1% + 1`. + const constrained = constrainedNumerator( + hasUnknownNumerator, + hasUnknownDenominator, + numerator, + denominator + ); + if (constrained !== null) { + return finish(constrained, true, hasUnresolved); + } + // Other opaque factors may supply type information that changes how the + // known dimensions combine once the known factors are structurally valid. + if (hasUnknownNumerator || hasUnknownDenominator) { + return finish(unknownType, true, hasUnresolved); + } if (numerator !== null && denominator !== null) { return finish( numerator.base === denominator.base ? numberType : failureType, @@ -135,6 +152,27 @@ function analyzeProduct(node, depth) { return finish(numerator ?? numberType, valid, hasUnresolved); } +/** + * @param {boolean} hasUnknownNumerator + * @param {boolean} hasUnknownDenominator + * @param {CalculationType | null} numerator + * @param {CalculationType | null} denominator + * @return {CalculationType | null} + */ +function constrainedNumerator( + hasUnknownNumerator, + hasUnknownDenominator, + numerator, + denominator +) { + return hasUnknownNumerator && + !hasUnknownDenominator && + numerator !== null && + denominator === null + ? numerator + : null; +} + /** @param {Extract} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ function analyzeCall(node, depth) { const name = node.name.toLowerCase(); diff --git a/src/lib/node.js b/src/lib/node.js index 2078ba1..b4fe5c7 100644 --- a/src/lib/node.js +++ b/src/lib/node.js @@ -11,10 +11,11 @@ // - No Product directly contains another Product (flattened). // - A Sum/Product with one positive element collapses to that element. // - A Sum/Product with no elements collapses to Num(0) / Num(1). -// - Positive zero-valued Nums are dropped from sums (they contribute -// nothing). Negative zero is retained until calculation evaluation has -// finished, because it is an IEEE-754 value with observable math-function -// behavior. +// - Positive zero-valued Nums are dropped from all-number sums. They are +// retained in mixed or unresolved sums because they constrain the other +// terms to . Negative zero is retained until calculation +// evaluation has finished, because it is an IEEE-754 value with observable +// math-function behavior. // Zero-valued Dims are kept — the unit carries type info. /** @@ -97,13 +98,19 @@ function mkSum(rawTerms) { for (const t of rawTerms) { if (pushSumTerm(flat, t)) hasNegativeZero = true; } + const zeroIsTypeAnchor = flat.some((term) => term.node.type !== 'Num'); // `+0 + -0` evaluates to +0. Keep positive zero terms when the sum also // contains -0 so simplification can perform that IEEE-754 operation before // the canonical zero-elision below. let length = 0; for (let i = 0; i < flat.length; i++) { const term = flat[i]; - if (!hasNegativeZero && term.node.type === 'Num' && term.node.value === 0) { + if ( + !hasNegativeZero && + !zeroIsTypeAnchor && + term.node.type === 'Num' && + term.node.value === 0 + ) { continue; } flat[length++] = term; diff --git a/src/lib/simplify/sum.js b/src/lib/simplify/sum.js index 15b35fc..23ac0ff 100644 --- a/src/lib/simplify/sum.js +++ b/src/lib/simplify/sum.js @@ -97,12 +97,15 @@ function simplifySum(sum, simplify) { processTerm(t.sign, simplify(t.node)); } - // mkSum drops positive zero-valued Nums, so pushing the numeric total - // unconditionally is harmless; a negative zero is deliberately retained. - // Zero-valued unit buckets are kept for type info - // (WPT calc-serialization-002). + // Keep a numeric total, including zero, when the source sum contained a + // number. In a mixed or unresolved sum that value constrains the other + // terms to ; mkSum retains it as a type anchor. A negative zero is + // also deliberately retained. Zero-valued unit buckets are kept for type + // info (WPT calc-serialization-002). /** @type {SumTerm[]} */ - const terms = [{ sign: 1, node: num(denoise(numTotal, numScale)) }]; + const terms = hasNum + ? [{ sign: /** @type {1} */ (1), node: num(denoise(numTotal, numScale)) }] + : []; for (const bucket of mergeConvertibleBuckets([...byUnit.values()])) { terms.push({ sign: 1, diff --git a/test/integration/math-operations.test.js b/test/integration/math-operations.test.js index a386e92..4860afb 100644 --- a/test/integration/math-operations.test.js +++ b/test/integration/math-operations.test.js @@ -46,18 +46,21 @@ describe('Complex calculations', () => { describe('Subtraction from zero', () => { test( - 'should reduce substraction from zero', - testValue('calc( 0 - 10px)', 'calc(-10px)') + 'preserves an invalid subtraction of a length from a number', + testValue('calc( 0 - 10px)', 'calc( 0 - 10px)') ); test( - 'should reduce subtracted expression from zero', - testValue('calc( 0 - calc(1px + 1em) )', 'calc(-1px - 1em)') + 'preserves an invalid subtraction of a mixed length expression from a number', + testValue('calc( 0 - calc(1px + 1em) )', 'calc( 0 - calc(1px + 1em) )') ); test( - 'should reduce substracted expression from zero (1)', - testValue('calc( 0 - (100vw - 10px) / 2 )', 'calc(-50vw + 5px)') + 'preserves an invalid subtraction of a divided length from a number', + testValue( + 'calc( 0 - (100vw - 10px) / 2 )', + 'calc( 0 - (100vw - 10px) / 2 )' + ) ); test( diff --git a/test/unit/analyze.test.js b/test/unit/analyze.test.js index a81bf43..85b8501 100644 --- a/test/unit/analyze.test.js +++ b/test/unit/analyze.test.js @@ -111,6 +111,67 @@ test('analyze: resolves sum type when unresolved term is constrained by a dimens }); }); +test('analyze: a percentage product retains its known numerator dimension', () => { + assert.deepEqual(analyzeSource('0% * 0 * 0px'), { + type: { dimension: 'length' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('0% * 0 * 0px + 0'), { + type: 'unknown', + valid: false, + unresolved: true, + }); + assert.deepEqual(analyzeSource('0% / 1px'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('analyze: opaque numerator products retain known dimension constraints', () => { + assert.deepEqual(analyzeSource('var(--x) * 10px'), { + type: { dimension: 'length' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10px + 5'), { + type: 'unknown', + valid: false, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10s + 5'), { + type: 'unknown', + valid: false, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10s'), { + type: { dimension: 'time' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10deg + 1rad'), { + type: { dimension: 'angle' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--x) * 10hz + 1khz'), { + type: { dimension: 'frequency' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('var(--a) * var(--b) * 10px + 5'), { + type: 'unknown', + valid: false, + unresolved: true, + }); + assert.deepEqual(analyzeSource('10px / var(--x)'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + test('analyze: rejects sum when multiple incompatible dimensions surround an unresolved term', () => { assert.deepEqual(analyzeSource('10px + var(--x) + 5s'), { type: 'unknown', diff --git a/test/unit/node.test.js b/test/unit/node.test.js index 827cdc2..490aa9e 100644 --- a/test/unit/node.test.js +++ b/test/unit/node.test.js @@ -7,6 +7,7 @@ import { num, dim, ident, + call, opaqueCall, mkSum, mkProduct, @@ -129,6 +130,15 @@ describe('mkSum:', () => { assert.deepEqual(result, { type: 'Num', value: 5 }); }); + test('mkSum: zero-valued Num is retained as a mixed-sum type anchor', () => { + const result = mkSum([ + { sign: 1, node: num(0) }, + { sign: 1, node: ident('x') }, + ]); + assert.equal(result.type, 'Sum'); + assert.deepEqual(result.terms[0], { sign: 1, node: num(0) }); + }); + test('mkSum: negative zero Num is retained until evaluation finishes', () => { const result = mkSum([ { sign: 1, node: num(-0) }, @@ -147,6 +157,38 @@ describe('mkSum:', () => { assert.equal(result.terms.length, 2); }); + test('mkSum: positive zero is retained beside a concrete dimension', () => { + assert.deepEqual( + mkSum([ + { sign: 1, node: num(0) }, + { sign: 1, node: dim(10, 'px') }, + ]), + { + type: 'Sum', + terms: [ + { sign: 1, node: num(0) }, + { sign: 1, node: dim(10, 'px') }, + ], + } + ); + }); + + test('mkSum: positive zero is retained beside a Call node', () => { + assert.deepEqual( + mkSum([ + { sign: 1, node: num(0) }, + { sign: 1, node: call('sin', [num(0)]) }, + ]), + { + type: 'Sum', + terms: [ + { sign: 1, node: num(0) }, + { sign: 1, node: call('sin', [num(0)]) }, + ], + } + ); + }); + test('mkSum: all-zero Nums collapse entirely to Num(0)', () => { const result = mkSum([ { sign: 1, node: num(0) }, diff --git a/test/unit/reduceCalc-core.test.js b/test/unit/reduceCalc-core.test.js index e6504cd..844c7dc 100644 --- a/test/unit/reduceCalc-core.test.js +++ b/test/unit/reduceCalc-core.test.js @@ -86,17 +86,59 @@ describe('reduceCalc: basic pipeline', () => { ); }); - test('reduceCalc: source signed zero is ordinary zero', () => { + test('reduceCalc: source signed zero is ordinary zero and remains a type anchor', () => { assert.equal( reduceCalc('calc(-0 * var(--x))', { precision: false }), 'calc(0 * var(--x))' ); assert.equal( reduceCalc('calc(-0 + var(--x))', { precision: false }), - 'calc(var(--x))' + 'calc(0 + var(--x))' ); }); + test('reduceCalc: literal and folded zero constrain unresolved sums identically', () => { + assert.equal(reduceCalc('calc(var(--x) + 0)'), 'calc(0 + var(--x))'); + assert.equal(reduceCalc('calc(var(--x) + (0 * 1))'), 'calc(0 + var(--x))'); + }); + + test('reduceCalc: zero anchors do not block reductions of sibling terms', () => { + assert.equal( + reduceCalc('calc(10 + var(--x) + 20 + (0 * 1))'), + 'calc(30 + var(--x))' + ); + }); + + test('reduceCalc: literal and folded non-finite terms simplify consistently', () => { + assert.equal( + reduceCalc('calc(var(--x) + infinity)'), + 'calc(infinity + var(--x))' + ); + assert.equal( + reduceCalc('calc(var(--x) + (1 / 0))'), + 'calc(infinity + var(--x))' + ); + assert.equal(reduceCalc('calc(var(--x) + NaN)'), 'calc(NaN + var(--x))'); + assert.equal( + reduceCalc('calc(var(--x) + (0 / 0))'), + 'calc(NaN + var(--x))' + ); + }); + + test('reduceCalc: grouped zero anchor survives enclosing subtraction', () => { + assert.equal( + reduceCalc('calc(100 - (var(--x) + (0 * 1)))'), + 'calc(100 - (0 + var(--x)))' + ); + }); + + test('reduceCalc: invalid percentage-dimension products are preserved whole', () => { + const single = 'calc(0% * 0 * 0px + (-1 / -infinity * 0))'; + const multi = 'calc(10px + 20px + 0% * 0 * 0px + (-1 / -infinity * 0))'; + assert.equal(reduceCalc(single), single); + assert.equal(reduceCalc(multi), multi); + }); + test('reduceCalc: preserves arithmetic signed zero inside unresolved calculations', () => { assert.equal( reduceCalc('calc(0 / -1 * var(--x))', { precision: false }),