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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ postcss().use(calc({ precision: 10 }));
#### `precision` (default: `5`)

Allows you to define the precision for decimal numbers. Set it to `false` to
disable rounding.
disable rounding and preserve full IEEE-754 floating-point precision (emitting
the shortest round-tripping decimal representation).

```js
var out = postcss()
Expand Down
16 changes: 6 additions & 10 deletions scripts/benchmark-serialization.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// through both the worktree serializer and the serializer from HEAD.
import { execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
Expand Down Expand Up @@ -144,16 +144,12 @@ function benchmarkPair(worktreeSerializer, headSerializer, node, materialize) {
/** @return {Promise<typeof import('../src/lib/serialize.js')>} */
async function loadHeadSerializer() {
const root = mkdtempSync(join(tmpdir(), 'postcss-calc-serialize-'));
const lib = join(root, 'lib');
mkdirSync(lib);
for (const file of ['serialize.js', 'node.js', 'opaque.js', 'limits.js']) {
const source = execFileSync('git', ['show', `HEAD:src/lib/${file}`], {
encoding: 'utf8',
});
writeFileSync(join(lib, file), source);
}
try {
return await import(pathToFileURL(join(lib, 'serialize.js')).href);
const archive = execFileSync('git', ['archive', 'HEAD', '--', 'src/lib']);
execFileSync('tar', ['-x', '-f', '-', '-C', root], { input: archive });
return await import(
pathToFileURL(join(root, 'src', 'lib', 'serialize.js')).href
);
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down
50 changes: 37 additions & 13 deletions src/lib/analyze.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import { assertDepth } from './limits.js';
/** @type {CalculationType} */ const unknownType = { kind: 'unknown' };
/** @type {CalculationType} */ const failureType = { kind: 'failure' };

/** @param {Node} node @return {Analysis} */
/**
* Analyze the original complete tree and return its root summary. Analysis
* validates and classifies the tree; it is not a rewrite plan.
* @param {Node} node
* @return {Analysis}
*/
function analyze(node) {
const result = analyzeType(node);
return {
Expand Down Expand Up @@ -49,18 +54,32 @@ function analyzeType(node, depth = 0) {

/** @param {Extract<Node, {type: 'Sum'}>} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */
function analyzeSum(node, depth) {
let type = numberType;
let type = null;
let hasUnknown = false;
let valid = true;
let hasTerm = false;
let hasUnresolved = false;
for (const term of node.terms) {
const child = analyzeType(term.node, depth + 1);
type = hasTerm ? addTypes(type, child.type) : child.type;
hasTerm = true;
valid = valid && child.valid;
hasUnresolved = hasUnresolved || child.unresolved;
if (isFailure(child.type)) {
type = failureType;
} else if (child.type.kind === 'unknown') {
hasUnknown = true;
} else if (type === null) {
type = child.type;
} else if (!isFailure(type)) {
type = addTypes(type, child.type);
}
}
return finish(type, valid, hasUnresolved);
if (type !== null && isFailure(type)) {
return finish(failureType, false, hasUnresolved);
}
return finish(
type ?? (hasUnknown ? unknownType : numberType),
valid,
hasUnresolved
);
}

/** @param {Extract<Node, {type: 'Product'}>} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */
Expand Down Expand Up @@ -119,14 +138,19 @@ function analyzeProduct(node, depth) {
/** @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();
const childResults = node.args.map((arg) => analyzeType(arg, depth + 1));
const childTypes = childResults.map((child) => child.type);
const valid = childResults.every((child) => child.valid);
const definition = mathFunctions.get(name);
const unresolvedArgs = childResults.some(
(child, index) =>
!definition?.isKeyword?.(node.args[index], index) && child.unresolved
);
/** @type {CalculationType[]} */
const childTypes = [];
let valid = true;
let unresolvedArgs = false;
for (let index = 0; index < node.args.length; index++) {
const child = analyzeType(node.args[index], depth + 1);
childTypes.push(child.type);
valid = valid && child.valid;
if (!definition?.isKeyword?.(node.args[index], index) && child.unresolved) {
unresolvedArgs = true;
}
}
if (!definition) return finish(unknownType, valid, true);
const type = definition.analyze(childTypes, node.args);
const unresolvedType = type.kind === 'unknown';
Expand Down
4 changes: 3 additions & 1 deletion src/lib/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { analyze } from './analyze.js';
/** @typedef {{options: ResolvedReduceCalcOptions, value: string, tokens: CSSToken[], index: BlockIndex}} CompileContext */

/**
* Parse, analyze, and simplify one candidate.
* Parse, analyze, and simplify one candidate. Analysis is the validity/status
* gate over the original tree; simplification then runs independently as a
* composable AST transformation that may synthesize nodes.
*
* @param {Candidate} candidate
* @param {CompileContext} ctx
Expand Down
46 changes: 34 additions & 12 deletions src/lib/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ function addTypes(a, b) {
*/
function numberArguments(args, min, max) {
if (args.length < min || args.length > max) return failureType;
if (args.some((arg) => arg.kind === 'dimension')) return failureType;
return args.some((arg) => arg.kind === 'unknown') ? unknownType : numberType;
let hasUnknown = false;
for (const arg of args) {
if (arg.kind === 'dimension') return failureType;
hasUnknown = hasUnknown || arg.kind === 'unknown';
}
return hasUnknown ? unknownType : numberType;
}

/**
Expand All @@ -66,8 +70,8 @@ function numberArguments(args, min, max) {
function matchingArguments(args, min, max) {
if (args.length < min || args.length > max) return failureType;
let result = args[0];
for (const arg of args.slice(1)) {
result = addTypes(result, arg);
for (let index = 1; index < args.length; index++) {
result = addTypes(result, args[index]);
if (isFailure(result)) return failureType;
}
return result;
Expand Down Expand Up @@ -98,15 +102,28 @@ function analyzeIdentity(args) {
return args.length === 1 ? args[0] : failureType;
}

/** @param {CalculationType[]} args @return {CalculationType} */
function analyzeSign(args) {
return args.length === 1 ? numberType : failureType;
}

/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */
function analyzeRound(args, nodes) {
const strategy = nodes[0];
const hasStrategy =
strategy?.type === 'Ident' &&
ROUND_STRATEGIES.has(strategy.name.toLowerCase());
const values = args.slice(hasStrategy ? 1 : 0);
if (values.length === 1) return numberArguments(values, 1, 1);
return matchingArguments(values, 2, 2);
const start = hasStrategy ? 1 : 0;
const valueCount = args.length - start;
if (valueCount === 1) {
const value = args[start];
if (value.kind === 'dimension') return failureType;
if (value.kind === 'unknown') return unknownType;
return numberType;
}
if (valueCount !== 2) return failureType;
const type = addTypes(args[start], args[start + 1]);
return isFailure(type) ? failureType : type;
}

/** @param {CalculationType[]} args @return {CalculationType} */
Expand Down Expand Up @@ -139,10 +156,15 @@ function isClampKeyword(node, index) {
/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */
function analyzeClamp(args, nodes) {
if (args.length !== 3) return failureType;
const values = args.filter(
(_, index) => !isClampKeyword(nodes[index], index)
);
return matchingArguments(values, 1, 3);
let valueCount = 0;
/** @type {CalculationType} */ let result = failureType;
for (let index = 0; index < args.length; index++) {
if (isClampKeyword(nodes[index], index)) continue;
result = valueCount === 0 ? args[index] : addTypes(result, args[index]);
valueCount++;
}
if (valueCount < 1 || isFailure(result)) return failureType;
return result;
}

const mathFunctions = new Map(
Expand Down Expand Up @@ -182,7 +204,7 @@ const mathFunctions = new Map(
[
'sign',
{
analyze: analyzeIdentity,
analyze: analyzeSign,
simplify: (_name, args) => simplifySign(args),
},
],
Expand Down
65 changes: 59 additions & 6 deletions src/lib/serialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,66 @@ const ATOMIC_PRECEDENCE = 3;
const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE;
const NOISE_FLOOR = 1e-12;

/** @param {number} v @param {number | false} prec @return {number} */
/**
* Decimal rounding with "round half away from zero" (e.g. 1.005 at precision 2 -> 1.01).
*
* Binary floating-point (IEEE-754) cannot represent many decimal fractions exactly
* (e.g. 1.005 is binary 1.004999999999999893...), causing arithmetic formulas like
* `Math.round(v * 100) / 100` to round down to 1.00. Exponential notation string shifting
* (`1.005e2` -> `100.5`) lets the ECMAScript string-to-number parser read the exact
* intended decimal value before rounding.
*
* @param {number} v
* @param {number | false} prec
* @return {number}
*/
function round(v, prec) {
if (prec === false) return v;
const m = Math.pow(10, prec);
const rounded = Math.round(v * m) / m;
if (rounded === 0 && Math.abs(v) > NOISE_FLOOR) {
return Number(v.toPrecision(Math.max(prec, 1)));
if (prec === false || !Number.isFinite(v)) return v;
if (Object.is(v, -0) || v === 0) return v;
const abs = Math.abs(v);
// Numbers >= MAX_SAFE_INTEGER (2^53 - 1) cannot represent fractional values, and
// integers already have 0 fractional places. Bypassing them avoids float drift.
if (abs >= Number.MAX_SAFE_INTEGER || Number.isInteger(v)) return v;

// Clamp precision to [0, 100] integer to prevent NaN from fractional precisions
// or exponent overflows into Infinity/NaN (e.g. exponent + prec > 308).
const p = Math.min(100, Math.max(0, Math.trunc(prec)));
const sign = v < 0 ? -1 : 1;
let rounded;

if (p === 0) {
// Fast path: rounding to integer with "round half away from zero".
rounded = sign * Math.round(abs);
} else {
// Avoid .split('e') allocations: for numbers between 1e-6 and MAX_SAFE_INTEGER,
// String(abs) never contains exponential notation ('e').
const absStr = String(abs);
const eIdx = absStr.indexOf('e');
let shifted;
if (eIdx === -1) {
shifted = Math.round(Number(absStr + 'e' + p));
} else {
const mantissa = absStr.slice(0, eIdx);
const exponent = Number(absStr.slice(eIdx + 1));
shifted = Math.round(Number(mantissa + 'e' + (exponent + p)));
}

// shifted is an integer. It only contains exponential notation ('e') if >= 1e21.
if (shifted >= 1e21) {
const shiftedStr = String(shifted);
const seIdx = shiftedStr.indexOf('e');
const sMantissa = shiftedStr.slice(0, seIdx);
const sExponent = Number(shiftedStr.slice(seIdx + 1));
rounded = sign * Number(sMantissa + 'e' + (sExponent - p));
} else {
rounded = sign * Number(shifted + 'e-' + p);
}
}

// Preserve non-zero values smaller than precision (e.g. 1/1000000) from collapsing
// to zero, while still snapping true floating-point dust (< 1e-12) to zero.
if (rounded === 0 && abs > NOISE_FLOOR) {
return Number(v.toPrecision(Math.max(p, 1)));
}
return rounded;
}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/simplify.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { assertDepth } from './limits.js';
*/

/**
* Simplify is an independent, composable AST transformation. It may
* synthesize canonical nodes while preserving the Node -> Node contract.
* @param {Node} node
* @param {number} [depth]
* @return {Node}
Expand Down
6 changes: 3 additions & 3 deletions src/lib/simplify/round.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ function simplifyRound(args) {
// to applyRound, where floor*b===ceil*b===±∞ collapses back to A
// (§10.3.1 "result is the same infinity").
if (Number.isNaN(b)) {
return num(Number.NaN);
return foldResult(fold, Number.NaN);
}
if (!Number.isFinite(b)) {
if (!Number.isFinite(a)) {
return num(Number.NaN);
return foldResult(fold, Number.NaN);
}
let result;
if (strategy === 'up' && a > 0) {
Expand All @@ -63,7 +63,7 @@ function simplifyRound(args) {

const result = applyRound(strategy, a, b);
if (Number.isNaN(result)) {
return num(Number.NaN);
return foldResult(fold, Number.NaN);
}
return foldResult(fold, result);
}
Expand Down
27 changes: 27 additions & 0 deletions test/integration/math-operations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,4 +298,31 @@ describe('Precision', () => {
'precision for nested calc',
testValue('calc(calc(100% / 3) * 3)', 'calc(100%)')
);

test(
'accurate midpoint rounding for dimensions',
testValue('calc(1.005px)', 'calc(1.01px)', { precision: 2 })
);

test(
'accurate midpoint rounding for negative dimensions',
testValue('calc(-1.005px)', 'calc(-1.01px)', { precision: 2 })
);

test(
'precision false retains lossless float for division',
testValue('calc(1/3)', 'calc(.3333333333333333)', { precision: false })
);

test(
'canonical reciprocal coefficient with opaque term at default precision',
testValue('calc(var(--x) / 3)', 'calc(.33333 * var(--x))')
);

test(
'canonical reciprocal coefficient with opaque term at precision false',
testValue('calc(var(--x) / 3)', 'calc(.3333333333333333 * var(--x))', {
precision: false,
})
);
});
Loading