| // Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree | |
| // Outer calc() is added when the top-level result contains an arithmetic | |
| // operator, or when a finite scalar is negative. | |
| import { num, dim } from './node.js'; | |
| /** | |
| * @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'`. | |
| * @property {boolean} [unwrapSingleNegativeNumber] Serialize finite negative scalars without a wrapper. Internal selector-only mode. | |
| */ | |
| // 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'; | |
| } | |
| /** | |
| * Serialize a finite CSS number. CSS numbers may omit the zero before a | |
| * fractional value between -1 and 1 (`.5`, `-.5`). Scientific notation is | |
| * left untouched because it already has no leading zero to remove. | |
| * | |
| * @param {number} v | |
| * @return {string} | |
| */ | |
| function serializeNumber(v) { | |
| const text = String(v); | |
| if (text.startsWith('0.')) { | |
| return text.slice(1); | |
| } | |
| if (text.startsWith('-0.')) { | |
| return `-${text.slice(2)}`; | |
| } | |
| return text; | |
| } | |
| /** | |
| * Round and serialize a finite scalar once so callers can use the same value | |
| * to decide its syntactic context and render its text. | |
| * | |
| * @param {import('./node.js').Num | import('./node.js').Dim} node | |
| * @param {number | false} prec | |
| * @return {{value: number, text: string}} | |
| */ | |
| function serializeScalar(node, prec) { | |
| const value = round(node.value, prec); | |
| const text = `${serializeNumber(value)}${node.type === 'Dim' ? node.unit : ''}`; | |
| return { value, text }; | |
| } | |
| /** | |
| * @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})`; | |
| } | |
| if (node.type === 'Num' || node.type === 'Dim') { | |
| const scalar = serializeScalar(node, prec); | |
| // A finite negative scalar must stay inside calc() so CSS parses it as a | |
| // calculation result (and can apply range clamping) rather than as an | |
| // invalid bare value. Base this on the serialized value so tiny negative | |
| // floating-point noise that rounds to zero does not get wrapped. | |
| if (scalar.value < 0) { | |
| return opts.unwrapSingleNegativeNumber | |
| ? scalar.text | |
| : `${calcName}(${scalar.text})`; | |
| } | |
| return scalar.text; | |
| } | |
| // 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 invertedTerms = node.terms.map((t) => ({ | |
| sign: /** @type {1 | -1} */ (-t.sign), | |
| node: t.node, | |
| })); | |
| return `${calcName}(-(${serializeSumTerms(invertedTerms, prec)}))`; | |
| } | |
| if (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 serializeScalar(node, prec).text; | |
| 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 serializeScalar(node, prec).text; | |
| 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: num(-node.value), | |
| }; | |
| } | |
| if (node.type === 'Dim' && Number.isFinite(node.value) && node.value < 0) { | |
| return { | |
| sign: /** @type {1 | -1} */ (-sign), | |
| magnitude: dim(-node.value, node.unit), | |
| }; | |
| } | |
| return { sign, magnitude: node }; | |
| } | |
| /** | |
| * @param {import('./node.js').SumTerm[]} terms | |
| * @param {number | false} prec | |
| * @return {string} | |
| */ | |
| function serializeSumTerms(terms, prec) { | |
| let out = ''; | |
| for (let i = 0; i < terms.length; i++) { | |
| const { sign, magnitude } = displaySign(terms[i]); | |
| 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; | |
| } | |
| /** | |
| * @param {Sum} sum | |
| * @param {number | false} prec | |
| * @return {string} | |
| */ | |
| function serializeSum(sum, prec) { | |
| return serializeSumTerms(sum.terms, prec); | |
| } | |
| /** | |
| * 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: num(negatedValue) }, ...rest]; | |
| return serializeFactors(negatedFactors, prec); | |
| } | |
| const body = serializeExpr(node, prec); | |
| return node.type === 'Sum' || node.type === 'Product' | |
| ? `-(${body})` | |
| : `-${body}`; | |
| } | |
| /** | |
| * @param {ProductFactor[]} factors | |
| * @param {number | false} prec | |
| * @return {string} | |
| */ | |
| function serializeFactors(factors, prec) { | |
| let out = ''; | |
| for (let i = 0; i < factors.length; i++) { | |
| const f = factors[i]; | |
| 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; | |
| } | |
| /** | |
| * @param {Product} product | |
| * @param {number | false} prec | |
| * @return {string} | |
| */ | |
| function serializeProduct(product, prec) { | |
| return serializeFactors(product.factors, prec); | |
| } | |
| export { serialize }; |