UNPKG

postcss-calc

Version:
270 lines (251 loc) 8.38 kB
// Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree // Outer calc() is added only when the top-level result contains an // arithmetic operator. A Sum inside a Product is the only place parens // are ever required on valid canonical input. /** * @typedef {import('./node.js').Node} Node * @typedef {import('./node.js').Sum} Sum * @typedef {import('./node.js').Product} Product * @typedef {import('./node.js').ProductFactor} ProductFactor * @typedef {object} SerializeOptions * @property {number | false} [precision] Decimal places for numbers. `false` disables rounding. Default 5. * @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} */ 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) { // 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. /** * @param {number} v * @return {boolean} */ function isDegenerate(v) { return !Number.isFinite(v) || Number.isNaN(v); } /** * @param {number} v * @return {string} */ function degenerateKeyword(v) { if (Number.isNaN(v)) { return 'NaN'; } return v > 0 ? 'infinity' : '-infinity'; } /** * @param {Node} node * @param {SerializeOptions} [opts] * @return {string} */ function serialize(node, opts = {}) { const prec = opts.precision ?? 5; const calcName = opts.calcName ?? 'calc'; // §10.13: top-level Infinity/NaN wrap in calc(); dim degenerates carry // the unit as `<keyword> * 1<unit>` so the result keeps its type. if (node.type === 'Num' && isDegenerate(node.value)) { return `${calcName}(${degenerateKeyword(node.value)})`; } if (node.type === 'Dim' && isDegenerate(node.value)) { return `${calcName}(${degenerateKeyword(node.value)} * 1${node.unit})`; } // A grouped sum with a leading negative term is the canonical result of // negating a parenthesized expression. Re-invert its terms for the body so // the grouping survives as `-(...)` instead of becoming `-a - b`. if ( node.type === 'Sum' && node.grouped && node.terms.length > 1 && displaySign(node.terms[0]).sign === -1 ) { const body = /** @type {Sum} */ ({ type: 'Sum', terms: node.terms.map((t) => ({ sign: /** @type {1 | -1} */ (-t.sign), node: t.node, })), }); return `${calcName}(-(${serializeExpr(body, prec)}))`; } if ( node.type === 'Num' || node.type === 'Dim' || node.type === 'Ident' || node.type === 'Call' ) { return serializeExpr(node, prec); } // Single-term Sum is the canonical form for `-var(--x)` / `-(a*b)` — // sign=-1 around an opaque node. Signed leaves live in Num/Dim directly. if (node.type === 'Sum' && node.terms.length === 1) { return `${calcName}(${serializeLeadingNeg(node.terms[0].node, prec)})`; } return `${calcName}(${serializeExpr(node, prec)})`; } // --- Inside calc() expression -------------------------------------------- /** * @param {Node} node * @param {number | false} prec * @return {string} */ function serializeExpr(node, prec) { switch (node.type) { case 'Num': if (isDegenerate(node.value)) { return degenerateKeyword(node.value); } return String(round(node.value, prec)); case 'Dim': if (isDegenerate(node.value)) { // Nested degenerate Dim wraps in calc() so the `<kw> * 1<unit>` form // parses back as one Dim factor. The bare form round-trips wrong // inside a Product — `0 * Dim(Infinity, px)` would re-fold as NaN. return `calc(${degenerateKeyword(node.value)} * 1${node.unit})`; } return `${round(node.value, prec)}${node.unit}`; case 'Ident': return node.name; case 'Call': { const args = node.args.map((a) => serializeExpr(a, prec)).join(', '); return `${node.name}(${args})`; } case 'Sum': return serializeSum(node, prec); case 'Product': return serializeProduct(node, prec); } } /** * Combine the term's sign with a negative Num/Dim value's sign so * `{sign:+1, Num(-5)}` renders as `-5`, not `+ -5`. Skip degenerate * (Infinity/NaN) values — the `degenerateKeyword` path emits `-infinity` * inline, and a leading minus on `calc(infinity*1<unit>)` would now * tokenize as a `-calc` function. * @param {{sign: 1 | -1, node: Node}} term * @return {{sign: 1 | -1, magnitude: Node}} */ function displaySign(term) { const { sign, node } = term; if (node.type === 'Num' && Number.isFinite(node.value) && node.value < 0) { return { sign: /** @type {1 | -1} */ (-sign), magnitude: { type: 'Num', value: -node.value }, }; } if (node.type === 'Dim' && Number.isFinite(node.value) && node.value < 0) { return { sign: /** @type {1 | -1} */ (-sign), magnitude: { type: 'Dim', value: -node.value, unit: node.unit }, }; } return { sign, magnitude: node }; } /** * @param {Sum} sum * @param {number | false} prec * @return {string} */ function serializeSum(sum, prec) { let out = ''; for (const [i, t] of sum.terms.entries()) { const { sign, magnitude } = displaySign(t); if (i === 0) { if (magnitude.type === 'Sum' && magnitude.grouped) { const body = `(${serializeExpr(magnitude, prec)})`; out = sign === 1 ? body : `-${body}`; continue; } out = sign === 1 ? serializeExpr(magnitude, prec) : serializeLeadingNeg(magnitude, prec); } else { // `-` binds looser than `*`/`/` so the right side never needs parens. let body = serializeExpr(magnitude, prec); if (magnitude.type === 'Sum' && magnitude.grouped) { body = `(${body})`; } out += sign === 1 ? ` + ${body}` : ` - ${body}`; } } return out; } /** * Fold a leading negation into a finite leading Num if there is one * (`-(0.5 * x)` → `-0.5 * x`); else use `-(…)` for Sum/Product or `-x`. * @param {Node} node * @param {number | false} prec * @return {string} */ function serializeLeadingNeg(node, prec) { if ( node.type === 'Product' && node.factors.length > 0 && node.factors[0].exponent === 1 && node.factors[0].node.type === 'Num' && Number.isFinite(node.factors[0].node.value) && node.factors[0].node.value !== 0 ) { const head = node.factors[0].node; const negatedValue = -head.value; const rest = node.factors.slice(1); // A coefficient of 1 is a no-op factor, matching mkProduct. /** @type {ProductFactor[]} */ const negatedFactors = negatedValue === 1 ? rest : [ { exponent: 1, node: { type: 'Num', value: negatedValue } }, ...rest, ]; return serializeProduct({ type: 'Product', factors: negatedFactors }, prec); } const body = serializeExpr(node, prec); return node.type === 'Sum' || node.type === 'Product' ? `-(${body})` : `-${body}`; } /** * @param {Product} product * @param {number | false} prec * @return {string} */ function serializeProduct(product, prec) { let out = ''; for (const [i, f] of product.factors.entries()) { let body = serializeExpr(f.node, prec); // A Sum factor needs parens: `a * (b + c)`. Flat canonical form means // this is the only place parens are required. if (f.node.type === 'Sum') { body = `(${body})`; } if (i === 0) { // Leading denominator: implicit 1 so we emit `1 / 2px`, not `/ 2px`. out = f.exponent === 1 ? body : `1 / ${body}`; } else { out += f.exponent === 1 ? ` * ${body}` : ` / ${body}`; } } return out; } export { serialize };