diff --git a/src/lib/serialize.js b/src/lib/serialize.js index 8e4844f..9b45fc2 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -15,7 +15,15 @@ * @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`. */ +// Below this is float noise, not a value: `0.1 + 0.2 - 0.3` is 5.5e-17. +const NOISE_FLOOR = 1e-12; + /** + * Rounding to `prec` decimal places turns `calc(1/1000000)` into `0`, and a + * `0` in CSS is often a switch, not a small number (`flex-grow: 0` never + * grows). So when a value is too small for `prec`, keep its significant digits + * instead: `1/1000000` -> `0.000001`, `1/3000000` -> `3.3333e-7`. + * * @param {number} v * @param {number | false} prec * @return {number} @@ -25,7 +33,12 @@ function round(v, prec) { return v; } const m = Math.pow(10, prec); - return Math.round(v * m) / m; + const rounded = Math.round(v * m) / m; + if (rounded === 0 && Math.abs(v) > NOISE_FLOOR) { + // toPrecision needs at least one significant digit; `prec` may be 0. + return Number(v.toPrecision(Math.max(prec, 1))); + } + return rounded; } // §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords. diff --git a/src/lib/simplify/bucket.js b/src/lib/simplify/bucket.js index 3e66c99..a70b493 100644 --- a/src/lib/simplify/bucket.js +++ b/src/lib/simplify/bucket.js @@ -11,6 +11,7 @@ const { convert } = require('../convertUnits.js'); * @typedef {object} UnitBucket * @property {string} unit * @property {number} total + * @property {number} scale largest |term| accumulated into `total`, for noise detection * @property {import('../convertUnits.js').BaseType | null} base * @property {number} order */ diff --git a/src/lib/simplify/sum.js b/src/lib/simplify/sum.js index d188d05..6c491c0 100644 --- a/src/lib/simplify/sum.js +++ b/src/lib/simplify/sum.js @@ -12,6 +12,19 @@ const { mergeConvertibleBuckets } = require('./bucket.js'); * @typedef {import('./bucket.js').UnitBucket} UnitBucket */ +// Subtracting near-equal terms leaves float dust: `0.07 * 1e7 - 700000` is +// 1.16e-10, not 0. Snap a total that's tiny next to its terms back to 0. +const NOISE_REL = Number.EPSILON * 8; + +/** + * @param {number} total + * @param {number} scale largest |term| accumulated into `total` + * @return {number} + */ +function denoise(total, scale) { + return Math.abs(total) < scale * NOISE_REL ? 0 : total; +} + /** * @param {Sum} sum * @param {SimplifyFn} simplify @@ -23,6 +36,7 @@ function simplifySum(sum, simplify) { // encountered unit. `100vh - 5rem - 10rem - 100px` → `-15rem` in phase 1, // then vh/rem/px stay separate in phase 2 (none convert to each other). let numTotal = 0; + let numScale = 0; /** @type {Map} */ const byUnit = new Map(); /** @type {SumTerm[]} */ @@ -43,6 +57,7 @@ function simplifySum(sum, simplify) { } if (n.type === 'Num') { numTotal += sign * n.value; + numScale = Math.max(numScale, Math.abs(n.value)); return; } if (n.type === 'Dim') { @@ -50,10 +65,12 @@ function simplifySum(sum, simplify) { const existing = byUnit.get(key); if (existing) { existing.total += sign * n.value; + existing.scale = Math.max(existing.scale, Math.abs(n.value)); } else { byUnit.set(key, { unit: n.unit, total: sign * n.value, + scale: Math.abs(n.value), base: baseOf(n.unit), order: bucketOrder++, }); @@ -71,9 +88,9 @@ function simplifySum(sum, simplify) { // unconditionally is harmless. Zero-valued unit buckets are kept for // type info (WPT calc-serialization-002). /** @type {SumTerm[]} */ - const terms = [{ sign: 1, node: num(numTotal) }]; + const terms = [{ sign: 1, node: num(denoise(numTotal, numScale)) }]; for (const bucket of mergeConvertibleBuckets([...byUnit.values()])) { - terms.push({ sign: 1, node: dim(bucket.total, bucket.unit) }); + terms.push({ sign: 1, node: dim(denoise(bucket.total, bucket.scale), bucket.unit) }); } terms.push(...opaque); diff --git a/test/index.js b/test/index.js index c165ae2..2d3957e 100644 --- a/test/index.js +++ b/test/index.js @@ -382,6 +382,41 @@ test( testValue('calc(5/1000000)', '0.000005', { precision: 6 }) ); +test( + 'should keep a value smaller than the precision instead of rounding it to zero', + testValue('calc(1/1000000)', '0.000001') +); + +test( + 'should keep a dimension smaller than the precision', + testValue('calc(1px/1000000)', '0.000001px') +); + +test( + 'should keep a negative value smaller than the precision', + testValue('calc(-1/1000000)', '-0.000001') +); + +test( + 'should keep the ratio between two values smaller than the precision', + testValue('calc(2/1000000)', '0.000002') +); + +test( + 'should limit a value smaller than the precision to that many significant digits', + testValue('calc(1/3000000)', '3.3333e-7') +); + +test( + 'should still round float noise down to zero', + testValue('calc(0.1px + 0.2px - 0.3px)', '0px') +); + +test( + 'should fold exact cancellation with large operands to zero, not a phantom', + testValue('calc(0.07px * 1e7 - 700000px)', '0px') +); + test( 'should reduce browser-prefixed calc (1)', testValue('-webkit-calc(1px + 1px)', '2px') diff --git a/types/lib/simplify/bucket.d.ts b/types/lib/simplify/bucket.d.ts index 34bdd51..44f404b 100644 --- a/types/lib/simplify/bucket.d.ts +++ b/types/lib/simplify/bucket.d.ts @@ -1,6 +1,10 @@ export type UnitBucket = { unit: string; total: number; + /** + * largest |term| accumulated into `total`, for noise detection + */ + scale: number; base: import("../convertUnits.js").BaseType | null; order: number; }; @@ -8,6 +12,7 @@ export type UnitBucket = { * @typedef {object} UnitBucket * @property {string} unit * @property {number} total + * @property {number} scale largest |term| accumulated into `total`, for noise detection * @property {import('../convertUnits.js').BaseType | null} base * @property {number} order */ diff --git a/types/lib/simplify/sum.d.ts b/types/lib/simplify/sum.d.ts index a74574f..4250d9c 100644 --- a/types/lib/simplify/sum.d.ts +++ b/types/lib/simplify/sum.d.ts @@ -3,13 +3,6 @@ export type Sum = import("../node.js").Sum; export type SumTerm = import("../node.js").SumTerm; export type SimplifyFn = import("../simplify.js").SimplifyFn; export type UnitBucket = import("./bucket.js").UnitBucket; -/** - * @typedef {import('../node.js').Node} Node - * @typedef {import('../node.js').Sum} Sum - * @typedef {import('../node.js').SumTerm} SumTerm - * @typedef {import('../simplify.js').SimplifyFn} SimplifyFn - * @typedef {import('./bucket.js').UnitBucket} UnitBucket - */ /** * @param {Sum} sum * @param {SimplifyFn} simplify