Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "postcss-calc",
"version": "11.1.2",
"version": "11.2.0",
"type": "module",
"description": "PostCSS plugin to reduce calc()",
"keywords": [
Expand Down
48 changes: 43 additions & 5 deletions src/lib/analyze.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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, {type: 'Call'}>} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */
function analyzeCall(node, depth) {
const name = node.name.toLowerCase();
Expand Down
17 changes: 12 additions & 5 deletions src/lib/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number>. 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.

/**
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 8 additions & 5 deletions src/lib/simplify/sum.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number>; 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,
Expand Down
15 changes: 9 additions & 6 deletions test/integration/math-operations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
61 changes: 61 additions & 0 deletions test/unit/analyze.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
42 changes: 42 additions & 0 deletions test/unit/node.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
num,
dim,
ident,
call,
opaqueCall,
mkSum,
mkProduct,
Expand Down Expand Up @@ -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) },
Expand All @@ -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) },
Expand Down
46 changes: 44 additions & 2 deletions test/unit/reduceCalc-core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down