postcss-calc 11.1.2 → 11.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,11 @@
1
1
  // Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree
2
2
  // Outer calc() is added when the top-level result contains an arithmetic
3
- // operator, or when a finite scalar is negative.
3
+ // operator, or when a finite scalar needs context-sensitive CSS semantics.
4
4
 
5
- import { num, dim } from './node.js';
6
- import { getComponents, serializeComponents } from './opaque.js';
5
+ import { serializeComponents } from './opaque.js';
6
+ import { checkCalculationDepth } from './limits.js';
7
+ import { isCalculationFunction } from './functions.js';
8
+ import { num } from './node.js';
7
9
 
8
10
  /**
9
11
  * @typedef {import('./node.js').Node} Node
@@ -13,324 +15,725 @@ import { getComponents, serializeComponents } from './opaque.js';
13
15
  * @typedef {object} SerializeOptions
14
16
  * @property {number | false} [precision] Decimal places for numbers. `false` disables rounding. Default 5.
15
17
  * @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`.
16
- * @property {boolean} [unwrapSingleNegativeNumber] Serialize finite negative scalars without a wrapper. Internal selector-only mode.
18
+ * @property {boolean} [unwrapSingleNegativeNumber] Deprecated alias for `unwrapSingleValue`.
19
+ * @property {boolean} [unwrapSingleValue] Serialize fully resolved finite scalar results without calculation syntax.
17
20
  */
18
21
 
19
- // Below this is float noise, not a value: `0.1 + 0.2 - 0.3` is 5.5e-17.
22
+ // The AST is canonical: sums and products are flat, so these precedence
23
+ // levels cover every binary expression
24
+ const SUM_PRECEDENCE = 1;
25
+ const PRODUCT_PRECEDENCE = 2;
26
+ const ATOMIC_PRECEDENCE = 3;
27
+ // Negation (-1 * ...) binds more tightly than a sum but has the same atomic boundary
28
+ // for deciding whether the operand needs parentheses.
29
+ const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE;
20
30
  const NOISE_FLOOR = 1e-12;
21
31
 
22
32
  /**
23
- * Rounding to `prec` decimal places turns `calc(1/1000000)` into `0`, and a
24
- * `0` in CSS is often a switch, not a small number (`flex-grow: 0` never
25
- * grows). So when a value is too small for `prec`, keep its significant digits
26
- * instead: `1/1000000` -> `0.000001`, `1/3000000` -> `3.3333e-7`.
33
+ * Divide a decimal digit string by 10^k, rounding half away from zero, and
34
+ * return the resulting integer digit string. `digits` has no leading zeros.
35
+ * @param {string} digits
36
+ * @param {number} k
37
+ * @return {string}
38
+ */
39
+ function divideByPowerOfTen(digits, k) {
40
+ // 0x30/0x35/0x39 are the char codes of '0'/'5'/'9'.
41
+ if (digits.length <= k) {
42
+ return digits.length === k && digits.charCodeAt(0) >= 0x35 ? '1' : '0';
43
+ }
44
+ const cut = digits.length - k;
45
+ if (digits.charCodeAt(cut) < 0x35) return digits.slice(0, cut);
46
+ // Round up and propagate the carry through trailing nines.
47
+ let index = cut - 1;
48
+ while (index >= 0 && digits.charCodeAt(index) === 0x39) index--;
49
+ if (index < 0) return `1${'0'.repeat(cut)}`;
50
+ return `${digits.slice(0, index)}${String.fromCharCode(
51
+ digits.charCodeAt(index) + 1
52
+ )}${'0'.repeat(cut - index - 1)}`;
53
+ }
54
+
55
+ /**
56
+ * Round the shortest decimal representation of a non-negative double to `p`
57
+ * fractional digits, half away from zero.
58
+ *
59
+ * `Number(text + 'e' + p)` reads the exact intended decimal (so `1.005` at
60
+ * precision 2 becomes `1.01`), but it is only exact while the shifted value
61
+ * fits in `Number.MAX_SAFE_INTEGER`; beyond that the intermediate double
62
+ * rounds and can move the rounding boundary (e.g. `312834450754803.44` at
63
+ * precision 1 or 6 drifted to `312834450754803.5`). Round the decimal digits
64
+ * directly instead.
27
65
  *
66
+ * @param {number} abs
67
+ * @param {number} p
68
+ * @return {number}
69
+ */
70
+ function roundDecimal(abs, p) {
71
+ const text = String(abs);
72
+ const eIdx = text.indexOf('e');
73
+ const mantissa = eIdx === -1 ? text : text.slice(0, eIdx);
74
+ let exponent = eIdx === -1 ? 0 : Number(text.slice(eIdx + 1));
75
+ const dot = mantissa.indexOf('.');
76
+ let digits = mantissa;
77
+ if (dot !== -1) {
78
+ digits = mantissa.slice(0, dot) + mantissa.slice(dot + 1);
79
+ exponent -= mantissa.length - dot - 1;
80
+ }
81
+
82
+ // value = digits * 10^exponent, so the shortest decimal has -exponent
83
+ // fractional digits when it is smaller than 1.
84
+ if (exponent >= -p) return abs;
85
+
86
+ let start = 0;
87
+ while (start < digits.length - 1 && digits.charCodeAt(start) === 0x30)
88
+ start++;
89
+ const rounded = divideByPowerOfTen(digits.slice(start), -(exponent + p));
90
+ return Number(`${rounded}e-${p}`);
91
+ }
92
+
93
+ /**
28
94
  * @param {number} v
29
95
  * @param {number | false} prec
30
96
  * @return {number}
31
97
  */
32
98
  function round(v, prec) {
33
- if (prec === false) {
34
- return v;
35
- }
36
- const m = Math.pow(10, prec);
37
- const rounded = Math.round(v * m) / m;
38
- if (rounded === 0 && Math.abs(v) > NOISE_FLOOR) {
39
- // toPrecision needs at least one significant digit; `prec` may be 0.
40
- return Number(v.toPrecision(Math.max(prec, 1)));
99
+ if (prec === false || !Number.isFinite(v)) return v;
100
+ if (Object.is(v, -0) || v === 0) return v;
101
+ const abs = Math.abs(v);
102
+ // Numbers >= MAX_SAFE_INTEGER (2^53 - 1) cannot represent fractional values, and
103
+ // integers already have 0 fractional places. Bypassing them avoids float drift.
104
+ if (abs >= Number.MAX_SAFE_INTEGER || Number.isInteger(v)) return v;
105
+
106
+ // Clamp precision to [0, 100] integer to prevent NaN from fractional precisions
107
+ // or exponent overflows into Infinity/NaN (e.g. exponent + prec > 308).
108
+ const p = Math.min(100, Math.max(0, Math.trunc(prec)));
109
+ const sign = v < 0 ? -1 : 1;
110
+ // Fast path: rounding to integer with "round half away from zero".
111
+ const rounded =
112
+ p === 0 ? sign * Math.round(abs) : sign * roundDecimal(abs, p);
113
+
114
+ // Preserve non-zero values smaller than precision (e.g. 1/1000000) from collapsing
115
+ // to zero, while still snapping true floating-point dust (< 1e-12) to zero.
116
+ if (rounded === 0 && abs > NOISE_FLOOR) {
117
+ return Number(v.toPrecision(Math.max(p, 1)));
41
118
  }
42
119
  return rounded;
43
120
  }
44
121
 
45
122
  // §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords.
46
- /**
47
- * @param {number} v
48
- * @return {boolean}
49
- */
123
+ /** @param {number} v @return {boolean} */
50
124
  function isDegenerate(v) {
51
125
  return !Number.isFinite(v) || Number.isNaN(v);
52
126
  }
53
127
 
54
- /**
55
- * @param {number} v
56
- * @return {string}
57
- */
128
+ /** @param {number} v @return {string} */
58
129
  function degenerateKeyword(v) {
59
- if (Number.isNaN(v)) {
60
- return 'NaN';
61
- }
130
+ if (Number.isNaN(v)) return 'NaN';
62
131
  return v > 0 ? 'infinity' : '-infinity';
63
132
  }
64
133
 
65
- /**
66
- * Serialize a finite CSS number. CSS numbers may omit the zero before a
67
- * fractional value between -1 and 1 (`.5`, `-.5`). Scientific notation is
68
- * left untouched because it already has no leading zero to remove.
69
- *
70
- * @param {number} v
71
- * @return {string}
72
- */
134
+ /** @param {number} v @return {string} */
73
135
  function serializeNumber(v) {
136
+ if (Object.is(v, -0)) return '0';
74
137
  const text = String(v);
75
- if (text.startsWith('0.')) {
76
- return text.slice(1);
77
- }
78
- if (text.startsWith('-0.')) {
79
- return `-${text.slice(2)}`;
80
- }
138
+ if (text.startsWith('0.')) return text.slice(1);
139
+ if (text.startsWith('-0.')) return `-${text.slice(2)}`;
81
140
  return text;
82
141
  }
83
142
 
143
+ /** @param {SerializeOptions} opts @return {'standard' | 'unwrap-all'} */
144
+ function normalizeScalarPolicy(opts) {
145
+ return opts.unwrapSingleValue || opts.unwrapSingleNegativeNumber
146
+ ? 'unwrap-all'
147
+ : 'standard';
148
+ }
149
+
84
150
  /**
85
- * Round and serialize a finite scalar once so callers can use the same value
86
- * to decide its syntactic context and render its text.
87
- *
88
151
  * @param {import('./node.js').Num | import('./node.js').Dim} node
89
- * @param {number | false} prec
90
- * @return {{value: number, text: string}}
152
+ * @param {number | false} precision
153
+ * @param {number} [value]
154
+ * @return {number}
91
155
  */
92
- function serializeScalar(node, prec) {
93
- const value = round(node.value, prec);
94
- const text = `${serializeNumber(value)}${node.type === 'Dim' ? (node.rawUnit ?? node.unit) : ''}`;
95
- return { value, text };
156
+ function roundedScalarValue(node, precision, value) {
157
+ return round(value ?? node.value, precision);
96
158
  }
97
159
 
98
160
  /**
99
- * @param {Node} node
100
- * @param {SerializeOptions} [opts]
101
- * @return {string}
161
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
162
+ * @param {string[]} buffer
163
+ * @param {number} value
164
+ * @return {void}
102
165
  */
103
- function serialize(node, opts = {}) {
104
- const prec = opts.precision ?? 5;
105
- const calcName = opts.calcName ?? 'calc';
106
-
107
- // §10.13: top-level Infinity/NaN wrap in calc(); dim degenerates carry
108
- // the unit as `<keyword> * 1<unit>` so the result keeps its type.
109
- if (node.type === 'Num' && isDegenerate(node.value)) {
110
- return `${calcName}(${degenerateKeyword(node.value)})`;
111
- }
112
- if (node.type === 'Dim' && isDegenerate(node.value)) {
113
- return `${calcName}(${degenerateKeyword(node.value)} * 1${node.rawUnit ?? node.unit})`;
166
+ function emitRoundedScalar(node, buffer, value) {
167
+ buffer.push(serializeNumber(value));
168
+ if (node.type === 'Dim') {
169
+ buffer.push(node.rawUnit ?? node.unit);
114
170
  }
171
+ }
172
+
173
+ /**
174
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
175
+ * @param {ReturnType<typeof makeContext>} session
176
+ * @param {number} [value]
177
+ * @return {number}
178
+ */
179
+ function emitFiniteScalar(node, session, value) {
180
+ const rounded = roundedScalarValue(node, session.precision, value);
181
+ emitRoundedScalar(node, session.buffer, rounded);
182
+ return rounded;
183
+ }
115
184
 
116
- if (node.type === 'Num' || node.type === 'Dim') {
117
- const scalar = serializeScalar(node, prec);
118
-
119
- // A finite negative scalar must stay inside calc() so CSS parses it as a
120
- // calculation result (and can apply range clamping) rather than as an
121
- // invalid bare value. Base this on the serialized value so tiny negative
122
- // floating-point noise that rounds to zero does not get wrapped.
123
- if (scalar.value < 0) {
124
- return opts.unwrapSingleNegativeNumber
125
- ? scalar.text
126
- : `${calcName}(${scalar.text})`;
185
+ /**
186
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
187
+ * @param {ReturnType<typeof makeContext>} session
188
+ * @param {number} [value]
189
+ * @return {void}
190
+ */
191
+ function emitScalar(node, session, value) {
192
+ const buffer = session.buffer;
193
+ const effective = value ?? node.value;
194
+ if (Object.is(effective, -0)) emitSignedZero(buffer, node);
195
+ else if (isDegenerate(effective)) {
196
+ if (node.type === 'Dim') {
197
+ buffer.push(
198
+ 'calc(',
199
+ degenerateKeyword(effective),
200
+ ' * 1',
201
+ node.rawUnit ?? node.unit,
202
+ ')'
203
+ );
204
+ } else {
205
+ buffer.push(degenerateKeyword(effective));
127
206
  }
207
+ } else emitFiniteScalar(node, session, effective);
208
+ }
128
209
 
129
- return scalar.text;
130
- }
210
+ /**
211
+ * @param {string[]} buffer
212
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
213
+ * @return {void}
214
+ */
215
+ function emitSignedZero(buffer, node) {
216
+ const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : '';
217
+ buffer.push('calc(-1 * 0', unit, ')');
218
+ }
131
219
 
132
- // A grouped sum with a leading negative term is the canonical result of
133
- // negating a parenthesized expression. Re-invert its terms for the body so
134
- // the grouping survives as `-(...)` instead of becoming `-a - b`.
135
- if (
136
- node.type === 'Sum' &&
137
- node.grouped &&
138
- node.terms.length > 1 &&
139
- displaySign(node.terms[0]).sign === -1
140
- ) {
141
- const invertedTerms = node.terms.map((t) => ({
142
- sign: /** @type {1 | -1} */ (-t.sign),
143
- node: t.node,
144
- }));
145
- return `${calcName}(-(${serializeSumTerms(invertedTerms, prec)}))`;
146
- }
220
+ /** @param {Node} node @return {node is import('./node.js').Num | import('./node.js').Dim} */
221
+ function isScalar(node) {
222
+ return node.type === 'Num' || node.type === 'Dim';
223
+ }
147
224
 
148
- if (node.type === 'Ident' || node.type === 'Call') {
149
- return serializeExpr(node, prec);
150
- }
225
+ /** @param {Node} node @return {node is import('./node.js').Num | import('./node.js').Dim} */
226
+ function isSignedZero(node) {
227
+ return isScalar(node) ? Object.is(node.value, -0) : false;
228
+ }
151
229
 
152
- // Single-term Sum is the canonical form for `-var(--x)` / `-(a*b)` —
153
- // sign=-1 around an opaque node. Signed leaves live in Num/Dim directly.
154
- if (node.type === 'Sum' && node.terms.length === 1) {
155
- return `${calcName}(${serializeLeadingNeg(node.terms[0].node, prec)})`;
156
- }
230
+ /** @param {Node} node @return {number} */
231
+ function precedence(node) {
232
+ if (node.type === 'Sum') return SUM_PRECEDENCE;
233
+ if (node.type === 'Product') return PRODUCT_PRECEDENCE;
234
+ return ATOMIC_PRECEDENCE;
235
+ }
157
236
 
158
- return `${calcName}(${serializeExpr(node, prec)})`;
237
+ /**
238
+ * @param {Node} node
239
+ * @param {number} parentPrecedence
240
+ * @param {boolean} groupedRequired
241
+ * @return {boolean}
242
+ */
243
+ function needsParentheses(node, parentPrecedence, groupedRequired) {
244
+ return (
245
+ precedence(node) < parentPrecedence ||
246
+ (node.type === 'Sum' && node.grouped === true && groupedRequired === true)
247
+ );
159
248
  }
160
249
 
161
- // --- Inside calc() expression --------------------------------------------
250
+ /**
251
+ * @param {Node} node
252
+ * @param {ReturnType<typeof makeContext>} session
253
+ * @param {number} [parentPrecedence]
254
+ * @param {boolean} [groupedRequired]
255
+ * @return {void}
256
+ */
257
+ function emitNode(
258
+ node,
259
+ session,
260
+ parentPrecedence = 0,
261
+ groupedRequired = false
262
+ ) {
263
+ const parenthesized = needsParentheses(
264
+ node,
265
+ parentPrecedence,
266
+ groupedRequired
267
+ );
268
+ if (parenthesized) session.buffer.push('(');
269
+ emitNodeBody(node, session);
270
+ if (parenthesized) session.buffer.push(')');
271
+ }
162
272
 
163
273
  /**
164
274
  * @param {Node} node
165
- * @param {number | false} prec
166
- * @return {string}
275
+ * @param {ReturnType<typeof makeContext>} session
276
+ * @return {void}
167
277
  */
168
- function serializeExpr(node, prec) {
278
+ function emitNodeBody(node, session) {
279
+ const buffer = session.buffer;
169
280
  switch (node.type) {
170
281
  case 'Num':
171
- if (isDegenerate(node.value)) {
172
- return degenerateKeyword(node.value);
173
- }
174
- return serializeScalar(node, prec).text;
175
282
  case 'Dim':
176
- if (isDegenerate(node.value)) {
177
- // Nested degenerate Dim wraps in calc() so the `<kw> * 1<unit>` form
178
- // parses back as one Dim factor. The bare form round-trips wrong
179
- // inside a Product — `0 * Dim(Infinity, px)` would re-fold as NaN.
180
- return `calc(${degenerateKeyword(node.value)} * 1${node.rawUnit ?? node.unit})`;
181
- }
182
- return serializeScalar(node, prec).text;
283
+ emitScalar(node, session);
284
+ return;
183
285
  case 'Ident':
184
- return node.rawName ?? node.name;
185
- case 'Call': {
186
- const components = getComponents(node);
187
- if (components) {
188
- const args = node.args
189
- .map((arg) => serializeExpr(arg, prec))
190
- .join(', ');
191
- return `${node.rawName ?? node.name}(${args}${serializeComponents(components, (child) => serialize(child, { precision: prec }))})`;
192
- }
193
- const args = node.args.map((a) => serializeExpr(a, prec)).join(', ');
194
- return `${node.rawName ?? node.name}(${args})`;
195
- }
286
+ buffer.push(node.rawName ?? node.name);
287
+ return;
288
+ case 'Call':
289
+ emitCall(node, session);
290
+ return;
291
+ case 'OpaqueCall':
292
+ emitOpaqueCall(node, session);
293
+ return;
196
294
  case 'Sum':
197
- return serializeSum(node, prec);
295
+ emitSum(node, session);
296
+ return;
198
297
  case 'Product':
199
- return serializeProduct(node, prec);
298
+ emitProduct(node, session);
299
+ return;
200
300
  }
201
301
  }
202
302
 
203
303
  /**
204
- * Combine the term's sign with a negative Num/Dim value's sign so
205
- * `{sign:+1, Num(-5)}` renders as `-5`, not `+ -5`. Skip degenerate
206
- * (Infinity/NaN) values — the `degenerateKeyword` path emits `-infinity`
207
- * inline, and a leading minus on `calc(infinity*1<unit>)` would now
208
- * tokenize as a `-calc` function.
209
- * @param {{sign: 1 | -1, node: Node}} term
210
- * @return {{sign: 1 | -1, magnitude: Node}}
304
+ * @param {import('./node.js').Call} node
305
+ * @param {ReturnType<typeof makeContext>} session
306
+ * @param {string} [callNameOverride]
307
+ * @return {void}
211
308
  */
212
- function displaySign(term) {
213
- const { sign, node } = term;
214
- if (node.type === 'Num' && Number.isFinite(node.value) && node.value < 0) {
215
- return {
216
- sign: /** @type {1 | -1} */ (-sign),
217
- magnitude: num(-node.value),
218
- };
309
+ function emitCall(node, session, callNameOverride) {
310
+ const buffer = session.buffer;
311
+ buffer.push(callNameOverride ?? node.rawName ?? node.name, '(');
312
+ for (let i = 0; i < node.args.length; i++) {
313
+ if (i > 0) buffer.push(', ');
314
+ emitNode(node.args[i], session);
219
315
  }
220
- if (node.type === 'Dim' && Number.isFinite(node.value) && node.value < 0) {
221
- return {
222
- sign: /** @type {1 | -1} */ (-sign),
223
- magnitude: dim(-node.value, node.unit, node.rawUnit),
224
- };
316
+ buffer.push(')');
317
+ }
318
+
319
+ /**
320
+ * @param {import('./node.js').OpaqueCall} node
321
+ * @param {ReturnType<typeof makeContext>} session
322
+ * @param {string} [callNameOverride]
323
+ * @return {void}
324
+ */
325
+ function emitOpaqueCall(node, session, callNameOverride) {
326
+ const buffer = session.buffer;
327
+ buffer.push(callNameOverride ?? node.rawName ?? node.name, '(');
328
+ serializeComponents(node.components, buffer, (child, childBuffer) => {
329
+ emitNestedMathResult(child, session, childBuffer);
330
+ });
331
+ buffer.push(')');
332
+ }
333
+
334
+ /**
335
+ * Whether a scalar node is strictly negative after precision rounding
336
+ * (excluding signed zero and sub-precision values that round to zero).
337
+ * @param {Node} node
338
+ * @param {number | false} precision
339
+ * @return {node is import('./node.js').Num | import('./node.js').Dim}
340
+ */
341
+ function isEffectivelyNegative(node, precision) {
342
+ return (
343
+ isScalar(node) &&
344
+ !Object.is(node.value, -0) &&
345
+ Number.isFinite(node.value) &&
346
+ round(node.value, precision) < 0
347
+ );
348
+ }
349
+
350
+ /**
351
+ * @param {import('./node.js').SumTerm} term
352
+ * @param {1 | -1} multiplier
353
+ * @param {number | false} precision
354
+ * @return {1 | -1}
355
+ */
356
+ function termSign(term, multiplier, precision) {
357
+ let sign = /** @type {1 | -1} */ (term.sign * multiplier);
358
+ if (isEffectivelyNegative(term.node, precision)) {
359
+ sign = /** @type {1 | -1} */ (-sign);
360
+ }
361
+ return sign;
362
+ }
363
+
364
+ /**
365
+ * @param {import('./node.js').SumTerm} term
366
+ * @param {ReturnType<typeof makeContext>} session
367
+ * @param {1 | -1} sign
368
+ * @return {void}
369
+ */
370
+ function emitSumTerm(term, session, sign) {
371
+ if (sign === 1) {
372
+ emitNode(term.node, session, SUM_PRECEDENCE, true);
373
+ } else {
374
+ emitLeadingNeg(term.node, session);
225
375
  }
226
- return { sign, magnitude: node };
227
376
  }
228
377
 
229
378
  /**
230
379
  * @param {import('./node.js').SumTerm[]} terms
231
- * @param {number | false} prec
232
- * @return {string}
380
+ * @param {ReturnType<typeof makeContext>} session
381
+ * @param {1 | -1} [multiplier]
382
+ * @return {void}
233
383
  */
234
- function serializeSumTerms(terms, prec) {
235
- let out = '';
384
+ function emitSumTerms(terms, session, multiplier = 1) {
385
+ const buffer = session.buffer;
236
386
  for (let i = 0; i < terms.length; i++) {
237
- const { sign, magnitude } = displaySign(terms[i]);
238
- if (i === 0) {
239
- if (magnitude.type === 'Sum' && magnitude.grouped) {
240
- const body = `(${serializeExpr(magnitude, prec)})`;
241
- out = sign === 1 ? body : `-${body}`;
242
- continue;
387
+ const term = terms[i];
388
+ const termNode = term.node;
389
+ if (isScalar(termNode)) {
390
+ const effectiveVal = term.sign * multiplier * termNode.value;
391
+ if (Object.is(effectiveVal, -0)) {
392
+ if (i > 0) buffer.push(' + ');
393
+ emitSignedZero(buffer, termNode);
394
+ } else if (isDegenerate(effectiveVal)) {
395
+ const sign = /** @type {1 | -1} */ (term.sign * multiplier);
396
+ if (i === 0) {
397
+ if (sign === -1) buffer.push('-');
398
+ emitScalar(termNode, session);
399
+ } else {
400
+ buffer.push(sign === 1 ? ' + ' : ' - ');
401
+ emitScalar(termNode, session);
402
+ }
403
+ } else {
404
+ const rounded = round(effectiveVal, session.precision);
405
+ if (rounded < 0) {
406
+ if (i === 0) buffer.push('-');
407
+ else buffer.push(' - ');
408
+ emitRoundedScalar(termNode, buffer, -rounded);
409
+ } else {
410
+ if (i > 0) buffer.push(' + ');
411
+ emitRoundedScalar(termNode, buffer, rounded);
412
+ }
243
413
  }
244
- out =
245
- sign === 1
246
- ? serializeExpr(magnitude, prec)
247
- : serializeLeadingNeg(magnitude, prec);
414
+ continue;
415
+ }
416
+ const sign = /** @type {1 | -1} */ (term.sign * multiplier);
417
+ if (i === 0) {
418
+ emitSumTerm(term, session, sign);
248
419
  } else {
249
- // `-` binds looser than `*`/`/` so the right side never needs parens.
250
- let body = serializeExpr(magnitude, prec);
251
- if (magnitude.type === 'Sum' && magnitude.grouped) {
252
- body = `(${body})`;
253
- }
254
- out += sign === 1 ? ` + ${body}` : ` - ${body}`;
420
+ buffer.push(sign === 1 ? ' + ' : ' - ');
421
+ emitNode(termNode, session, SUM_PRECEDENCE, true);
255
422
  }
256
423
  }
257
- return out;
424
+ }
425
+
426
+ /** @param {Sum} sum @param {ReturnType<typeof makeContext>} session @return {void} */
427
+ function emitSum(sum, session) {
428
+ emitSumTerms(sum.terms, session);
258
429
  }
259
430
 
260
431
  /**
261
- * @param {Sum} sum
262
- * @param {number | false} prec
263
- * @return {string}
432
+ * @param {Node} node
433
+ * @param {ReturnType<typeof makeContext>} session
434
+ * @return {void}
435
+ */
436
+ function emitLeadingNeg(node, session) {
437
+ if (node.type === 'Product') {
438
+ if (
439
+ node.factors.length > 0 &&
440
+ node.factors[0].exponent === 1 &&
441
+ node.factors[0].node.type === 'Num'
442
+ ) {
443
+ const head = node.factors[0].node;
444
+ emitProductFactors(node.factors, session, 1, -head.value, head);
445
+ return;
446
+ }
447
+ emitProductFactors(node.factors, session, 0, -1, num(-1));
448
+ return;
449
+ }
450
+ session.buffer.push('-1 * ');
451
+ emitNode(node, session, UNARY_PRECEDENCE, false);
452
+ }
453
+
454
+ /**
455
+ * @param {ProductFactor[]} factors
456
+ * @param {ReturnType<typeof makeContext>} session
457
+ * @param {number} [start]
458
+ * @param {number} [coefficientValue]
459
+ * @param {import('./node.js').Num} [coefficientNode]
460
+ * @return {void}
264
461
  */
265
- function serializeSum(sum, prec) {
266
- return serializeSumTerms(sum.terms, prec);
462
+ function emitProductFactors(
463
+ factors,
464
+ session,
465
+ start = 0,
466
+ coefficientValue,
467
+ coefficientNode
468
+ ) {
469
+ const buffer = session.buffer;
470
+ let first = true;
471
+ if (coefficientValue !== undefined && coefficientValue !== 1) {
472
+ emitScalar(
473
+ /** @type {import('./node.js').Num} */ (coefficientNode),
474
+ session,
475
+ coefficientValue
476
+ );
477
+ first = false;
478
+ }
479
+ for (let i = start; i < factors.length; i++) {
480
+ const factor = factors[i];
481
+ const factorNode = factor.node;
482
+ if (first) {
483
+ if (factor.exponent === -1) buffer.push('1 / ');
484
+ if (isScalar(factorNode)) emitScalar(factorNode, session);
485
+ else emitNode(factorNode, session, PRODUCT_PRECEDENCE);
486
+ first = false;
487
+ } else {
488
+ buffer.push(factor.exponent === 1 ? ' * ' : ' / ');
489
+ if (isScalar(factorNode)) emitScalar(factorNode, session);
490
+ else emitNode(factorNode, session, PRODUCT_PRECEDENCE);
491
+ }
492
+ }
493
+ }
494
+
495
+ /** @param {Product} product @param {ReturnType<typeof makeContext>} session @return {void} */
496
+ function emitProduct(product, session) {
497
+ emitProductFactors(product.factors, session);
498
+ }
499
+
500
+ /** @param {Node} node @param {ReturnType<typeof makeContext>} session @return {void} */
501
+ function emitRootExpr(node, session) {
502
+ if (
503
+ node.type === 'Sum' &&
504
+ node.grouped &&
505
+ node.terms.length > 1 &&
506
+ termSign(node.terms[0], 1, session.precision) === -1
507
+ ) {
508
+ session.buffer.push('-1 * (');
509
+ emitSumTerms(node.terms, session, -1);
510
+ session.buffer.push(')');
511
+ return;
512
+ }
513
+ if (node.type === 'Sum' && node.terms.length === 1) {
514
+ emitLeadingNeg(node.terms[0].node, session);
515
+ return;
516
+ }
517
+ emitNode(node, session);
267
518
  }
268
519
 
269
520
  /**
270
- * Fold a leading negation into a finite leading Num if there is one
271
- * (`-(0.5 * x)` → `-0.5 * x`); else use `-(…)` for Sum/Product or `-x`.
272
521
  * @param {Node} node
273
- * @param {number | false} prec
274
- * @return {string}
522
+ * @param {ReturnType<typeof makeContext>} session
523
+ * @param {string} wrapper
524
+ * @return {void}
275
525
  */
276
- function serializeLeadingNeg(node, prec) {
526
+ function emitMathResult(node, session, wrapper) {
527
+ if (isScalar(node)) {
528
+ const scalarValue = roundedScalarValue(node, session.precision);
529
+ const buffer = session.buffer;
530
+ if (isDegenerate(scalarValue)) {
531
+ buffer.push(wrapper, '(', degenerateKeyword(scalarValue));
532
+ if (node.type === 'Dim') buffer.push(' * 1', node.rawUnit ?? node.unit);
533
+ buffer.push(')');
534
+ } else if (session.scalarPolicy === 'standard') {
535
+ buffer.push(wrapper, '(');
536
+ emitRoundedScalar(node, buffer, scalarValue);
537
+ buffer.push(')');
538
+ } else {
539
+ emitRoundedScalar(node, buffer, scalarValue);
540
+ }
541
+ return;
542
+ }
277
543
  if (
278
- node.type === 'Product' &&
279
- node.factors.length > 0 &&
280
- node.factors[0].exponent === 1 &&
281
- node.factors[0].node.type === 'Num' &&
282
- Number.isFinite(node.factors[0].node.value) &&
283
- node.factors[0].node.value !== 0
544
+ node.type === 'Sum' &&
545
+ node.grouped &&
546
+ node.terms.length > 1 &&
547
+ termSign(node.terms[0], 1, session.precision) === -1
284
548
  ) {
285
- const head = node.factors[0].node;
286
- const negatedValue = -head.value;
287
- const rest = node.factors.slice(1);
288
- // A coefficient of 1 is a no-op factor, matching mkProduct.
289
- /** @type {ProductFactor[]} */
290
- const negatedFactors =
291
- negatedValue === 1
292
- ? rest
293
- : [{ exponent: 1, node: num(negatedValue) }, ...rest];
294
- return serializeFactors(negatedFactors, prec);
549
+ session.buffer.push(wrapper, '(-1 * (');
550
+ emitSumTerms(node.terms, session, -1);
551
+ session.buffer.push('))');
552
+ return;
295
553
  }
296
- const body = serializeExpr(node, prec);
297
- return node.type === 'Sum' || node.type === 'Product'
298
- ? `-(${body})`
299
- : `-${body}`;
554
+ if (
555
+ node.type === 'Ident' ||
556
+ node.type === 'Call' ||
557
+ node.type === 'OpaqueCall'
558
+ ) {
559
+ emitNode(node, session);
560
+ return;
561
+ }
562
+ if (node.type === 'Sum' && node.terms.length === 1) {
563
+ session.buffer.push(wrapper, '(');
564
+ emitLeadingNeg(node.terms[0].node, session);
565
+ session.buffer.push(')');
566
+ return;
567
+ }
568
+ session.buffer.push(wrapper, '(');
569
+ emitNode(node, session);
570
+ session.buffer.push(')');
300
571
  }
301
572
 
302
573
  /**
303
- * @param {ProductFactor[]} factors
304
- * @param {number | false} prec
574
+ * Serialize a scalar result without allocating a render context or buffer.
575
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
576
+ * @param {number | false} precision
577
+ * @param {'standard' | 'unwrap-all'} scalarPolicy
578
+ * @param {string} wrapper
305
579
  * @return {string}
306
580
  */
307
- function serializeFactors(factors, prec) {
308
- let out = '';
309
- for (let i = 0; i < factors.length; i++) {
310
- const f = factors[i];
311
- let body = serializeExpr(f.node, prec);
312
- // A Sum factor needs parens: `a * (b + c)`. Flat canonical form means
313
- // this is the only place parens are required.
314
- if (f.node.type === 'Sum') {
315
- body = `(${body})`;
581
+ function serializeScalarResult(node, precision, scalarPolicy, wrapper) {
582
+ const value = roundedScalarValue(node, precision);
583
+ const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : '';
584
+ if (isDegenerate(value)) {
585
+ return `${wrapper}(${degenerateKeyword(value)}${unit ? ` * 1${unit}` : ''})`;
586
+ }
587
+ const scalar = serializeNumber(value) + unit;
588
+ return scalarPolicy === 'standard' ? `${wrapper}(${scalar})` : scalar;
589
+ }
590
+
591
+ /**
592
+ * @param {Node} node
593
+ * @param {ReturnType<typeof makeContext>} session
594
+ * @param {string[]} [buffer]
595
+ * @return {void}
596
+ */
597
+ function emitNestedMathResult(node, session, buffer = session.buffer) {
598
+ if (isSignedZero(node)) {
599
+ emitSignedZero(buffer, node);
600
+ return;
601
+ }
602
+ emitMathResult(node, session, 'calc');
603
+ }
604
+
605
+ /**
606
+ * @param {SerializeOptions} opts
607
+ * @return {{buffer: string[], precision: number | false, scalarPolicy: 'standard' | 'unwrap-all'}}
608
+ */
609
+ function makeContext(opts) {
610
+ return {
611
+ buffer: [],
612
+ precision: opts.precision ?? 5,
613
+ scalarPolicy: normalizeScalarPolicy(opts),
614
+ };
615
+ }
616
+
617
+ /**
618
+ * @param {Node} node
619
+ * @param {SerializeOptions} opts
620
+ * @return {{kind: 'math', node: Node, session: ReturnType<typeof makeContext>, wrapper: string}}
621
+ */
622
+ function planSerialize(node, opts) {
623
+ return {
624
+ kind: 'math',
625
+ node,
626
+ session: makeContext(opts),
627
+ wrapper: opts.calcName ?? 'calc',
628
+ };
629
+ }
630
+
631
+ /**
632
+ * @param {{tree: Node, status: 'resolved' | 'unresolved', rootName: string, rootSpelling: string, calculation?: boolean, original?: string}} result
633
+ * @param {SerializeOptions} opts
634
+ * @return {{kind: 'original', text: string} | {kind: 'root-call', node: Node, session: ReturnType<typeof makeContext>, callNameOverride: string} | {kind: 'wrapped-expr', node: Node, session: ReturnType<typeof makeContext>, wrapper: string} | {kind: 'math', node: Node, session: ReturnType<typeof makeContext>, wrapper: string}}
635
+ */
636
+ function planSerializeResult(result, opts) {
637
+ const isCalc = result.calculation ?? isCalculationFunction(result.rootName);
638
+ const normalizedRootName =
639
+ result.calculation === undefined
640
+ ? result.rootName.toLowerCase()
641
+ : result.rootName;
642
+ const wrapper = isCalc
643
+ ? result.rootSpelling || opts.calcName || 'calc'
644
+ : 'calc';
645
+ const session = makeContext(opts);
646
+
647
+ if (!isCalc && result.status === 'unresolved') {
648
+ if (
649
+ (result.tree.type === 'Call' || result.tree.type === 'OpaqueCall') &&
650
+ result.tree.name.toLowerCase() === normalizedRootName
651
+ ) {
652
+ return {
653
+ kind: 'root-call',
654
+ node: result.tree,
655
+ session,
656
+ callNameOverride: result.rootSpelling,
657
+ };
316
658
  }
317
- if (i === 0) {
318
- // Leading denominator: implicit 1 so we emit `1 / 2px`, not `/ 2px`.
319
- out = f.exponent === 1 ? body : `1 / ${body}`;
659
+ return { kind: 'original', text: result.original ?? '' };
660
+ }
661
+
662
+ if (session.scalarPolicy === 'standard') {
663
+ if (isScalar(result.tree))
664
+ return { kind: 'math', node: result.tree, session, wrapper };
665
+ return { kind: 'wrapped-expr', node: result.tree, session, wrapper };
666
+ }
667
+ return { kind: 'math', node: result.tree, session, wrapper };
668
+ }
669
+
670
+ /**
671
+ * @param {ReturnType<typeof planSerialize> | ReturnType<typeof planSerializeResult>} renderSpec
672
+ * @return {string}
673
+ * */
674
+ function emitOutput(renderSpec) {
675
+ if (renderSpec.kind === 'original') return renderSpec.text;
676
+ const { session } = renderSpec;
677
+ if (renderSpec.kind === 'root-call') {
678
+ if (renderSpec.node.type === 'Call') {
679
+ emitCall(renderSpec.node, session, renderSpec.callNameOverride);
320
680
  } else {
321
- out += f.exponent === 1 ? ` * ${body}` : ` / ${body}`;
681
+ emitOpaqueCall(
682
+ /** @type {import('./node.js').OpaqueCall} */ (renderSpec.node),
683
+ session,
684
+ renderSpec.callNameOverride
685
+ );
322
686
  }
687
+ } else if (renderSpec.kind === 'wrapped-expr') {
688
+ session.buffer.push(renderSpec.wrapper, '(');
689
+ emitRootExpr(renderSpec.node, session);
690
+ session.buffer.push(')');
691
+ } else {
692
+ emitMathResult(renderSpec.node, session, renderSpec.wrapper);
323
693
  }
324
- return out;
694
+ return session.buffer.join('');
325
695
  }
326
696
 
327
697
  /**
328
- * @param {Product} product
329
- * @param {number | false} prec
698
+ * @param {Node} node
699
+ * @param {SerializeOptions} [opts]
700
+ * @return {string}
701
+ */
702
+ function serialize(node, opts = {}) {
703
+ if (isScalar(node)) {
704
+ return serializeScalarResult(
705
+ node,
706
+ opts.precision ?? 5,
707
+ normalizeScalarPolicy(opts),
708
+ opts.calcName ?? 'calc'
709
+ );
710
+ }
711
+ checkCalculationDepth(node);
712
+ return emitOutput(planSerialize(node, opts));
713
+ }
714
+
715
+ /**
716
+ * @param {{tree: Node, status: 'resolved' | 'unresolved', rootName: string, rootSpelling: string, calculation?: boolean, original?: string}} result
717
+ * @param {SerializeOptions} [opts]
330
718
  * @return {string}
331
719
  */
332
- function serializeProduct(product, prec) {
333
- return serializeFactors(product.factors, prec);
720
+ function serializeResult(result, opts = {}) {
721
+ const node = result.tree;
722
+ if (isScalar(node)) {
723
+ const isCalc = result.calculation ?? isCalculationFunction(result.rootName);
724
+ if (!isCalc && result.status === 'unresolved') return result.original ?? '';
725
+ const wrapper = isCalc
726
+ ? result.rootSpelling || opts.calcName || 'calc'
727
+ : 'calc';
728
+ return serializeScalarResult(
729
+ node,
730
+ opts.precision ?? 5,
731
+ normalizeScalarPolicy(opts),
732
+ wrapper
733
+ );
734
+ }
735
+ checkCalculationDepth(result.tree);
736
+ return emitOutput(planSerializeResult(result, opts));
334
737
  }
335
738
 
336
- export { serialize };
739
+ export { serialize, serializeResult };