blob: 7b58a35064efcd2b04cb96ca3ec4407acfde91b7 [file] [edit]
// 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 };