Skip to content

Commit dc995bc

Browse files
committed
fix: fix wrong rounding and update changelog
1 parent 394ee49 commit dc995bc

8 files changed

Lines changed: 134 additions & 43 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,20 @@ All notable changes to this project will be documented in this file. See [commit
66

77
### Bug fixes
88

9-
- fold a division or unit conversion only when the result is exact at the
9+
- transform a division or unit conversion only when the result is exact at the
1010
configured `precision`. `calc(100% / 3)` stays `calc(100% / 3)` instead of
11-
becoming `calc(33.33333%)`, which drifted when repeated (#62), and
12-
`calc(1cm + 1px)` is no longer converted to an approximate `1.02646cm`.
13-
Use `precision: false` to fold every division.
11+
becoming `calc(33.33333%)`, and `calc(1cm + 1px)`
12+
is no longer converted to an approximate `1.02646cm`.
13+
Use `precision: false` to transform every division.
1414
- keep `precision` significant digits for values below 1, so
1515
`calc(1px / 150000)` is no longer rounded to `.00001px`
16-
- `min()`, `max()` and `clamp()` return the chosen argument, keeping its
17-
original unit
18-
- treat `var()`, `env()`, `attr()` and other substitution functions as
19-
positional barriers. They are replaced by raw tokens before the value is
20-
computed, so with `--a: 1px + 2px`, `var(--a) * 2` means `1px + 2px * 2`.
21-
Factors are no longer reordered or cancelled across them
22-
(`var(--a) * 2` is no longer rewritten to `2 * var(--a)`,
23-
`var(--a) / var(--a)` is not cancelled), and parentheses around them are kept
24-
(`1px - (2 * var(--a))`)
16+
- `min()`, `max()` and `clamp()` preserve the original unit
17+
- keep the order of terms and factors around `var()`, `env()`, `attr()` and
18+
other substitution functions. These are replaced by raw tokens before the
19+
value is computed, so with `--a: 1px + 2px`, `var(--a) * 2` means
20+
`1px + 2px * 2`. Factors are no longer reordered or cancelled:
21+
`var(--a) * 2` is not rewritten to `2 * var(--a)`, `var(--a) / var(--a)` is
22+
not cancelled, and parentheses are kept (`1px - (2 * var(--a))`).
2523
Unrecognised functions such as `anchor-size()` are treated the same way, so
2624
`2 * anchor-size(width) * .5` is no longer folded
2725
- keep the parentheses of a nested `calc()` that contains unresolved values, so

‎src/index.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,13 @@ function applyTransform(
6565
function pluginCreator(opts) {
6666
/** @type {ResolvedOptions} */
6767
const options = {
68-
precision: 5,
6968
warnWhenCannotResolve: false,
7069
mediaQueries: false,
7170
selectors: false,
7271
unwrapSingleValue: false,
7372
...opts,
73+
// An explicit `precision: undefined` keeps the default.
74+
precision: opts?.precision ?? 5,
7475
};
7576

7677
return {

‎src/lib/simplify/cancel.js‎

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@ import { isExact } from './exact.js';
33

44
/**
55
* If `dims` contain exactly one numerator / one denominator pair with the
6-
* same base type and convertible units, return the numeric factor produced
7-
* by cancelling them and the list of remaining (uncancelled) dims.
8-
* Otherwise return null. Used by `simplifyProduct` for typed division
9-
* (§10.2). More complex cancellation (e.g. `px^2 / px`) is left
6+
* same base type and convertible units, return the two sides expressed in a
7+
* common unit, so the caller can fold them into its own numerator and
8+
* denominator. Otherwise return null. Used by `simplifyProduct` for typed
9+
* division (§10.2). More complex cancellation (e.g. `px^2 / px`) is left
1010
* unreduced — consumers rarely rely on it and the spec doesn't require it.
1111
* @template {{ exponent: 1 | -1, value: number, unit: string }} D
1212
* @param {D[]} dims
13-
* @param {number | false} [precision] A factor that is not exact at this
14-
* precision is not cancelled, so the quotient stays symbolic.
15-
* @return {{ factor: number, remaining: D[] } | null}
13+
* @param {number | false} [precision] The denominator is converted into the
14+
* numerator's unit when that is exact at this precision (`1px / 1in` →
15+
* `1 / 96`); otherwise the numerator is converted (`1in / 1px` → `96 / 1`).
16+
* @return {{ numerator: number, denominator: number, remaining: D[] } | null}
1617
*/
1718
function tryCancelPair(dims, precision = false) {
1819
if (dims.length !== 2) {
@@ -29,16 +30,32 @@ function tryCancelPair(dims, precision = false) {
2930
if (!numBase || numBase !== denBase) {
3031
return null;
3132
}
32-
const converted = convert(numerator.value, numerator.unit, denominator.unit);
33-
if (converted === null) {
34-
return null;
35-
}
33+
const converted = convert(
34+
denominator.value,
35+
denominator.unit,
36+
numerator.unit
37+
);
3638
// denominator.value === 0 yields ±Infinity / NaN naturally (§10.9.1).
37-
const factor = converted / denominator.value;
38-
if (!isExact(factor, precision)) {
39-
return null;
39+
if (converted !== null && isExact(converted, precision)) {
40+
return {
41+
numerator: numerator.value,
42+
denominator: converted,
43+
remaining: [],
44+
};
45+
}
46+
const numConverted = convert(
47+
numerator.value,
48+
numerator.unit,
49+
denominator.unit
50+
);
51+
if (numConverted !== null && isExact(numConverted, precision)) {
52+
return {
53+
numerator: numConverted,
54+
denominator: denominator.value,
55+
remaining: [],
56+
};
4057
}
41-
return { factor, remaining: [] };
58+
return null;
4259
}
4360

4461
export { tryCancelPair };

‎src/lib/simplify/product.js‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@ function isScalarSum(opaque) {
4949
);
5050
}
5151

52+
/**
53+
* Whether a folded quotient prints exactly. A quotient distributed over a
54+
* scalar Sum is judged term by term, so `(3px + 6em) / 3` still folds.
55+
* @param {number} value
56+
* @param {ProductFactor[]} opaque
57+
* @param {boolean} distributable
58+
* @param {number | false} precision
59+
* @return {boolean}
60+
*/
61+
function foldsExactly(value, opaque, distributable, precision) {
62+
if (!Number.isFinite(value) || isExact(value, precision)) {
63+
return true;
64+
}
65+
if (!distributable) {
66+
return false;
67+
}
68+
const sum = /** @type {import('../node.js').Sum} */ (opaque[0].node);
69+
return sum.terms.every((t) =>
70+
isExact(
71+
value *
72+
/** @type {import('../node.js').Num | import('../node.js').Dim} */ (
73+
t.node
74+
).value,
75+
precision
76+
)
77+
);
78+
}
79+
5280
/**
5381
* Emit an inexact quotient as `numerator * dims * opaque / denominator`, with
5482
* integer parts reduced by their gcd. A lone Dim absorbs the numerator.
@@ -166,8 +194,9 @@ function foldFactors(items, start, end, simplify, precision) {
166194
// unreduced — consumers don't rely on it and the spec doesn't require it.
167195
const cancelled = tryCancelPair(dims, precision);
168196
if (cancelled !== null) {
169-
coeff *= cancelled.factor;
170-
numerator *= cancelled.factor;
197+
coeff *= cancelled.numerator / cancelled.denominator;
198+
numerator *= cancelled.numerator;
199+
denominator *= cancelled.denominator;
171200
}
172201
const divides = denominator !== 1 || cancelled !== null;
173202
const remainingDims = cancelled ? cancelled.remaining : dims;
@@ -178,10 +207,11 @@ function foldFactors(items, start, end, simplify, precision) {
178207
? remainingDims[0]
179208
: null;
180209
const value = loneDim === null ? coeff : chainValue(scalarChain);
210+
const distributable = remainingDims.length === 0 && isScalarSum(opaque);
181211

182212
// An inexact quotient stays symbolic when its numerator and denominator
183213
// print exactly; otherwise it is folded and rounded at serialization.
184-
if (divides && Number.isFinite(value) && !isExact(value, precision)) {
214+
if (divides && !foldsExactly(value, opaque, distributable, precision)) {
185215
const rational = rationalProduct(
186216
numerator,
187217
denominator,
@@ -198,7 +228,7 @@ function foldFactors(items, start, end, simplify, precision) {
198228
// §10.10 distributive multiplication: `0.5 * (100vw - 10px)` → `50vw - 5px`.
199229
// Only distribute when every Sum term is Num/Dim — partial distribution
200230
// over opaque terms matches neither the legacy implementation nor csstools.
201-
if (remainingDims.length === 0 && isScalarSum(opaque)) {
231+
if (distributable) {
202232
const sum = /** @type {import('../node.js').Sum} */ (opaque[0].node);
203233
const distributed = sum.terms.map((t) => ({
204234
sign: t.sign,

‎src/reduce.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,12 @@ function reduceCalc(value, opts) {
6161

6262
/** @type {ResolvedReduceCalcOptions} */
6363
const options = {
64-
precision: 5,
6564
warnWhenCannotResolve: false,
6665
unwrapSingleNegativeNumber: false,
6766
unwrapSingleValue: false,
6867
...opts,
68+
// An explicit `precision: undefined` keeps the default.
69+
precision: opts?.precision ?? 5,
6970
};
7071
/** @type {import('@csstools/css-tokenizer').CSSToken[]} */
7172
let tokens;

‎test/index-options.test.cjs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,10 @@ test(
106106
'calc(var(--xxx, var(--yyy)) / 2)'
107107
)
108108
);
109+
110+
test(
111+
'should keep the default precision when precision is undefined',
112+
testCss('a{width:calc(100% / 3)}', 'a{width:calc(100% / 3)}', {
113+
precision: undefined,
114+
})
115+
);

‎test/integration/exact-division.test.js‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,25 @@ describe('Exact-by-default division', () => {
2222
['calc(1 / 3)', 'calc(1 / 3)'],
2323
['calc(10px / 3 * 3)', 'calc(10px)'],
2424
['calc(10px / -3)', 'calc(-10px / 3)'],
25-
['calc(1px / 1in)', 'calc(1px / 1in)'],
25+
['calc(1px / 1in)', 'calc(1 / 96)'],
2626
['calc(1in / 1px)', 'calc(96)'],
27+
['calc(1pt / 1pc)', 'calc(1 / 12)'],
28+
['calc(1pc / 1pt)', 'calc(12)'],
29+
['calc(2in / 1px)', 'calc(192)'],
30+
['calc(7px / 1in)', 'calc(7 / 96)'],
31+
['calc(1in / 7px)', 'calc(96 / 7)'],
32+
['calc(-1px / 1in)', 'calc(-1 / 96)'],
33+
['calc(1px / -1in)', 'calc(-1 / 96)'],
34+
['calc(1cm / 1px)', 'calc(1cm / 1px)'],
35+
['calc(1deg / 1rad)', 'calc(1deg / 1rad)'],
36+
['calc((100px + 60em) / 3)', 'calc((100px + 60em) / 3)'],
37+
['calc((3px + 5em) / 3)', 'calc((3px + 5em) / 3)'],
38+
['calc((3px + 6em) / -3)', 'calc(-1px - 2em)'],
39+
['calc((3px + var(--a)) / 3)', 'calc((3px + var(--a)) / 3)'],
40+
['calc(1px * 3 / 3px)', 'calc(1)'],
41+
['calc(1px / 3px * 3)', 'calc(1)'],
42+
['calc((3px + 6em) / 3)', 'calc(1px + 2em)'],
43+
['calc((30px + 60%) / 3)', 'calc(10px + 20%)'],
2744
['calc(var(--n) / 4)', 'calc(var(--n) / 4)'],
2845
['calc(2 * var(--n) / 3)', 'calc(2 * var(--n) / 3)'],
2946
// Quotients whose parts would be rounded on output are folded instead.
@@ -54,6 +71,24 @@ describe('Exact-by-default division', () => {
5471
);
5572
});
5673

74+
test('precision undefined keeps the default', () => {
75+
assert.equal(
76+
reduceCalc('calc(100% / 3)', { precision: undefined }),
77+
'calc(100% / 3)'
78+
);
79+
assert.equal(
80+
reduceCalc('calc(1cm + 1px)', { precision: undefined }),
81+
'calc(1cm + 1px)'
82+
);
83+
});
84+
85+
test('precision false folds inexact unit quotients', () => {
86+
assert.equal(
87+
reduceCalc('calc(1cm / 1px)', { precision: false }),
88+
'calc(37.79527559055118)'
89+
);
90+
});
91+
5792
test('a higher precision folds quotients that are exact there', () => {
5893
assert.equal(
5994
reduceCalc('calc(1px / 96)', { precision: 10 }),

‎types/lib/simplify/cancel.d.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
/**
22
* If `dims` contain exactly one numerator / one denominator pair with the
3-
* same base type and convertible units, return the numeric factor produced
4-
* by cancelling them and the list of remaining (uncancelled) dims.
5-
* Otherwise return null. Used by `simplifyProduct` for typed division
6-
* (§10.2). More complex cancellation (e.g. `px^2 / px`) is left
3+
* same base type and convertible units, return the two sides expressed in a
4+
* common unit, so the caller can fold them into its own numerator and
5+
* denominator. Otherwise return null. Used by `simplifyProduct` for typed
6+
* division (§10.2). More complex cancellation (e.g. `px^2 / px`) is left
77
* unreduced — consumers rarely rely on it and the spec doesn't require it.
88
* @template {{ exponent: 1 | -1, value: number, unit: string }} D
99
* @param {D[]} dims
10-
* @param {number | false} [precision] A factor that is not exact at this
11-
* precision is not cancelled, so the quotient stays symbolic.
12-
* @return {{ factor: number, remaining: D[] } | null}
10+
* @param {number | false} [precision] The denominator is converted into the
11+
* numerator's unit when that is exact at this precision (`1px / 1in` →
12+
* `1 / 96`); otherwise the numerator is converted (`1in / 1px` → `96 / 1`).
13+
* @return {{ numerator: number, denominator: number, remaining: D[] } | null}
1314
*/
1415
declare function tryCancelPair<D extends {
1516
exponent: 1 | -1;
1617
value: number;
1718
unit: string;
1819
}>(dims: D[], precision?: number | false): {
19-
factor: number;
20+
numerator: number;
21+
denominator: number;
2022
remaining: D[];
2123
} | null;
2224
export { tryCancelPair };

0 commit comments

Comments
 (0)