postcss-calc 11.1.2 → 11.2.0

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.
Files changed (41) hide show
  1. package/README.md +40 -18
  2. package/package.json +16 -12
  3. package/src/index.js +8 -5
  4. package/src/lib/analyze.js +228 -0
  5. package/src/lib/block-index.js +118 -0
  6. package/src/lib/calculation-type.js +25 -0
  7. package/src/lib/compile.js +80 -0
  8. package/src/lib/functions.js +364 -0
  9. package/src/lib/limits.js +49 -0
  10. package/src/lib/node.js +63 -21
  11. package/src/lib/opaque.js +12 -32
  12. package/src/lib/parser.js +383 -328
  13. package/src/lib/print.js +75 -0
  14. package/src/lib/regex.js +4 -0
  15. package/src/lib/scan.js +61 -0
  16. package/src/lib/serialize.js +574 -215
  17. package/src/lib/simplify/call.js +5 -98
  18. package/src/lib/simplify/round.js +4 -4
  19. package/src/lib/simplify/sum.js +14 -5
  20. package/src/lib/simplify.js +20 -5
  21. package/src/reduce.js +46 -115
  22. package/types/index.d.ts +4 -0
  23. package/types/lib/analyze.d.ts +18 -0
  24. package/types/lib/block-index.d.ts +35 -0
  25. package/types/lib/calculation-type.d.ts +18 -0
  26. package/types/lib/compile.d.ts +37 -0
  27. package/types/lib/functions.d.ts +40 -0
  28. package/types/lib/limits.d.ts +8 -0
  29. package/types/lib/node.d.ts +19 -3
  30. package/types/lib/opaque.d.ts +8 -14
  31. package/types/lib/parser.d.ts +42 -48
  32. package/types/lib/print.d.ts +16 -0
  33. package/types/lib/regex.d.ts +2 -0
  34. package/types/lib/scan.d.ts +35 -0
  35. package/types/lib/serialize.d.ts +19 -2
  36. package/types/lib/simplify/call.d.ts +4 -18
  37. package/types/lib/simplify/round.d.ts +3 -1
  38. package/types/lib/simplify.d.ts +5 -2
  39. package/types/reduce.d.ts +47 -6
  40. package/src/lib/tokenizer.js +0 -10
  41. package/types/lib/tokenizer.d.ts +0 -3
@@ -1,9 +1,10 @@
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';
7
8
 
8
9
  /**
9
10
  * @typedef {import('./node.js').Node} Node
@@ -13,267 +14,377 @@ import { getComponents, serializeComponents } from './opaque.js';
13
14
  * @typedef {object} SerializeOptions
14
15
  * @property {number | false} [precision] Decimal places for numbers. `false` disables rounding. Default 5.
15
16
  * @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.
17
+ * @property {boolean} [unwrapSingleNegativeNumber] Deprecated alias for `unwrapSingleValue`.
18
+ * @property {boolean} [unwrapSingleValue] Serialize fully resolved finite scalar results without calculation syntax.
17
19
  */
18
20
 
19
- // Below this is float noise, not a value: `0.1 + 0.2 - 0.3` is 5.5e-17.
21
+ // The AST is canonical: sums and products are flat, so these precedence
22
+ // levels cover every binary expression
23
+ const SUM_PRECEDENCE = 1;
24
+ const PRODUCT_PRECEDENCE = 2;
25
+ const ATOMIC_PRECEDENCE = 3;
26
+ // Unary minus binds more tightly than a sum but has the same atomic boundary
27
+ // for deciding whether `-x` needs parentheses.
28
+ const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE;
20
29
  const NOISE_FLOOR = 1e-12;
21
30
 
22
31
  /**
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`.
32
+ * Decimal rounding with "round half away from zero" (e.g. 1.005 at precision 2 -> 1.01).
33
+ *
34
+ * Binary floating-point (IEEE-754) cannot represent many decimal fractions exactly
35
+ * (e.g. 1.005 is binary 1.004999999999999893...), causing arithmetic formulas like
36
+ * `Math.round(v * 100) / 100` to round down to 1.00. Exponential notation string shifting
37
+ * (`1.005e2` -> `100.5`) lets the ECMAScript string-to-number parser read the exact
38
+ * intended decimal value before rounding.
27
39
  *
28
40
  * @param {number} v
29
41
  * @param {number | false} prec
30
42
  * @return {number}
31
43
  */
32
44
  function round(v, prec) {
33
- if (prec === false) {
34
- return v;
45
+ if (prec === false || !Number.isFinite(v)) return v;
46
+ if (Object.is(v, -0) || v === 0) return v;
47
+ const abs = Math.abs(v);
48
+ // Numbers >= MAX_SAFE_INTEGER (2^53 - 1) cannot represent fractional values, and
49
+ // integers already have 0 fractional places. Bypassing them avoids float drift.
50
+ if (abs >= Number.MAX_SAFE_INTEGER || Number.isInteger(v)) return v;
51
+
52
+ // Clamp precision to [0, 100] integer to prevent NaN from fractional precisions
53
+ // or exponent overflows into Infinity/NaN (e.g. exponent + prec > 308).
54
+ const p = Math.min(100, Math.max(0, Math.trunc(prec)));
55
+ const sign = v < 0 ? -1 : 1;
56
+ let rounded;
57
+
58
+ if (p === 0) {
59
+ // Fast path: rounding to integer with "round half away from zero".
60
+ rounded = sign * Math.round(abs);
61
+ } else {
62
+ // Avoid .split('e') allocations: for numbers between 1e-6 and MAX_SAFE_INTEGER,
63
+ // String(abs) never contains exponential notation ('e').
64
+ const absStr = String(abs);
65
+ const eIdx = absStr.indexOf('e');
66
+ let shifted;
67
+ if (eIdx === -1) {
68
+ shifted = Math.round(Number(absStr + 'e' + p));
69
+ } else {
70
+ const mantissa = absStr.slice(0, eIdx);
71
+ const exponent = Number(absStr.slice(eIdx + 1));
72
+ shifted = Math.round(Number(mantissa + 'e' + (exponent + p)));
73
+ }
74
+
75
+ // shifted is an integer. It only contains exponential notation ('e') if >= 1e21.
76
+ if (shifted >= 1e21) {
77
+ const shiftedStr = String(shifted);
78
+ const seIdx = shiftedStr.indexOf('e');
79
+ const sMantissa = shiftedStr.slice(0, seIdx);
80
+ const sExponent = Number(shiftedStr.slice(seIdx + 1));
81
+ rounded = sign * Number(sMantissa + 'e' + (sExponent - p));
82
+ } else {
83
+ rounded = sign * Number(shifted + 'e-' + p);
84
+ }
35
85
  }
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)));
86
+
87
+ // Preserve non-zero values smaller than precision (e.g. 1/1000000) from collapsing
88
+ // to zero, while still snapping true floating-point dust (< 1e-12) to zero.
89
+ if (rounded === 0 && abs > NOISE_FLOOR) {
90
+ return Number(v.toPrecision(Math.max(p, 1)));
41
91
  }
42
92
  return rounded;
43
93
  }
44
94
 
45
95
  // §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords.
46
- /**
47
- * @param {number} v
48
- * @return {boolean}
49
- */
96
+ /** @param {number} v @return {boolean} */
50
97
  function isDegenerate(v) {
51
98
  return !Number.isFinite(v) || Number.isNaN(v);
52
99
  }
53
100
 
54
- /**
55
- * @param {number} v
56
- * @return {string}
57
- */
101
+ /** @param {number} v @return {string} */
58
102
  function degenerateKeyword(v) {
59
- if (Number.isNaN(v)) {
60
- return 'NaN';
61
- }
103
+ if (Number.isNaN(v)) return 'NaN';
62
104
  return v > 0 ? 'infinity' : '-infinity';
63
105
  }
64
106
 
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
- */
107
+ /** @param {number} v @return {string} */
73
108
  function serializeNumber(v) {
109
+ if (Object.is(v, -0)) return '0';
74
110
  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
- }
111
+ if (text.startsWith('0.')) return text.slice(1);
112
+ if (text.startsWith('-0.')) return `-${text.slice(2)}`;
81
113
  return text;
82
114
  }
83
115
 
116
+ /** @param {SerializeOptions} opts @return {'standard' | 'unwrap-all'} */
117
+ function normalizeScalarPolicy(opts) {
118
+ return opts.unwrapSingleValue || opts.unwrapSingleNegativeNumber
119
+ ? 'unwrap-all'
120
+ : 'standard';
121
+ }
122
+
84
123
  /**
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
124
  * @param {import('./node.js').Num | import('./node.js').Dim} node
89
- * @param {number | false} prec
90
- * @return {{value: number, text: string}}
125
+ * @param {number | false} precision
126
+ * @param {number} [value]
127
+ * @return {number}
91
128
  */
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 };
129
+ function roundedScalarValue(node, precision, value) {
130
+ return round(value ?? node.value, precision);
96
131
  }
97
132
 
98
133
  /**
99
- * @param {Node} node
100
- * @param {SerializeOptions} [opts]
101
- * @return {string}
134
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
135
+ * @param {string[]} buffer
136
+ * @param {number} value
137
+ * @return {void}
102
138
  */
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})`;
139
+ function emitRoundedScalar(node, buffer, value) {
140
+ buffer.push(serializeNumber(value));
141
+ if (node.type === 'Dim') {
142
+ buffer.push(node.rawUnit ?? node.unit);
114
143
  }
144
+ }
115
145
 
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})`;
146
+ /**
147
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
148
+ * @param {ReturnType<typeof makeContext>} session
149
+ * @param {number} [value]
150
+ * @return {number}
151
+ */
152
+ function emitFiniteScalar(node, session, value) {
153
+ const rounded = roundedScalarValue(node, session.precision, value);
154
+ emitRoundedScalar(node, session.buffer, rounded);
155
+ return rounded;
156
+ }
157
+
158
+ /**
159
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
160
+ * @param {ReturnType<typeof makeContext>} session
161
+ * @param {number} [value]
162
+ * @return {void}
163
+ */
164
+ function emitScalar(node, session, value) {
165
+ const buffer = session.buffer;
166
+ if (Object.is(node.value, -0)) emitSignedZero(buffer, node);
167
+ else if (isDegenerate(node.value)) {
168
+ if (node.type === 'Dim') {
169
+ buffer.push(
170
+ 'calc(',
171
+ degenerateKeyword(node.value),
172
+ ' * 1',
173
+ node.rawUnit ?? node.unit,
174
+ ')'
175
+ );
176
+ } else {
177
+ buffer.push(degenerateKeyword(node.value));
127
178
  }
179
+ } else emitFiniteScalar(node, session, value);
180
+ }
128
181
 
129
- return scalar.text;
130
- }
182
+ /**
183
+ * @param {string[]} buffer
184
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
185
+ * @return {void}
186
+ */
187
+ function emitSignedZero(buffer, node) {
188
+ const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : '';
189
+ buffer.push('calc(-1 * 0', unit, ')');
190
+ }
131
191
 
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
- }
192
+ /** @param {Node} node @return {node is import('./node.js').Num | import('./node.js').Dim} */
193
+ function isScalar(node) {
194
+ return node.type === 'Num' || node.type === 'Dim';
195
+ }
147
196
 
148
- if (node.type === 'Ident' || node.type === 'Call') {
149
- return serializeExpr(node, prec);
150
- }
197
+ /** @param {Node} node @return {node is import('./node.js').Num | import('./node.js').Dim} */
198
+ function isSignedZero(node) {
199
+ return isScalar(node) ? Object.is(node.value, -0) : false;
200
+ }
151
201
 
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
- }
202
+ /** @param {Node} node @return {number} */
203
+ function precedence(node) {
204
+ if (node.type === 'Sum') return SUM_PRECEDENCE;
205
+ if (node.type === 'Product') return PRODUCT_PRECEDENCE;
206
+ return ATOMIC_PRECEDENCE;
207
+ }
157
208
 
158
- return `${calcName}(${serializeExpr(node, prec)})`;
209
+ /**
210
+ * @param {Node} node
211
+ * @param {number} parentPrecedence
212
+ * @param {boolean} groupedRequired
213
+ * @return {boolean}
214
+ */
215
+ function needsParentheses(node, parentPrecedence, groupedRequired) {
216
+ return (
217
+ precedence(node) < parentPrecedence ||
218
+ (node.type === 'Sum' && node.grouped === true && groupedRequired === true)
219
+ );
159
220
  }
160
221
 
161
- // --- Inside calc() expression --------------------------------------------
222
+ /**
223
+ * @param {Node} node
224
+ * @param {ReturnType<typeof makeContext>} session
225
+ * @param {number} [parentPrecedence]
226
+ * @param {boolean} [groupedRequired]
227
+ * @param {number} [scalarValueOverride]
228
+ * @return {void}
229
+ */
230
+ function emitNode(
231
+ node,
232
+ session,
233
+ parentPrecedence = 0,
234
+ groupedRequired = false,
235
+ scalarValueOverride
236
+ ) {
237
+ const parenthesized = needsParentheses(
238
+ node,
239
+ parentPrecedence,
240
+ groupedRequired
241
+ );
242
+ if (parenthesized) session.buffer.push('(');
243
+ emitNodeBody(node, session, scalarValueOverride);
244
+ if (parenthesized) session.buffer.push(')');
245
+ }
162
246
 
163
247
  /**
164
248
  * @param {Node} node
165
- * @param {number | false} prec
166
- * @return {string}
249
+ * @param {ReturnType<typeof makeContext>} session
250
+ * @param {number} [scalarValueOverride]
251
+ * @return {void}
167
252
  */
168
- function serializeExpr(node, prec) {
253
+ function emitNodeBody(node, session, scalarValueOverride) {
254
+ const buffer = session.buffer;
169
255
  switch (node.type) {
170
256
  case 'Num':
171
- if (isDegenerate(node.value)) {
172
- return degenerateKeyword(node.value);
173
- }
174
- return serializeScalar(node, prec).text;
175
257
  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;
258
+ emitScalar(node, session, scalarValueOverride);
259
+ return;
183
260
  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
- }
261
+ buffer.push(node.rawName ?? node.name);
262
+ return;
263
+ case 'Call':
264
+ emitCall(node, session);
265
+ return;
266
+ case 'OpaqueCall':
267
+ emitOpaqueCall(node, session);
268
+ return;
196
269
  case 'Sum':
197
- return serializeSum(node, prec);
270
+ emitSum(node, session);
271
+ return;
198
272
  case 'Product':
199
- return serializeProduct(node, prec);
273
+ emitProduct(node, session);
274
+ return;
200
275
  }
201
276
  }
202
277
 
203
278
  /**
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}}
279
+ * @param {import('./node.js').Call} node
280
+ * @param {ReturnType<typeof makeContext>} session
281
+ * @param {string} [callNameOverride]
282
+ * @return {void}
211
283
  */
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
- };
284
+ function emitCall(node, session, callNameOverride) {
285
+ const buffer = session.buffer;
286
+ buffer.push(callNameOverride ?? node.rawName ?? node.name, '(');
287
+ for (let i = 0; i < node.args.length; i++) {
288
+ if (i > 0) buffer.push(', ');
289
+ emitNode(node.args[i], session);
219
290
  }
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
- };
291
+ buffer.push(')');
292
+ }
293
+
294
+ /**
295
+ * @param {import('./node.js').OpaqueCall} node
296
+ * @param {ReturnType<typeof makeContext>} session
297
+ * @param {string} [callNameOverride]
298
+ * @return {void}
299
+ */
300
+ function emitOpaqueCall(node, session, callNameOverride) {
301
+ const buffer = session.buffer;
302
+ buffer.push(callNameOverride ?? node.rawName ?? node.name, '(');
303
+ serializeComponents(node.components, buffer, (child, childBuffer) => {
304
+ emitNestedMathResult(child, session, childBuffer);
305
+ });
306
+ buffer.push(')');
307
+ }
308
+
309
+ /**
310
+ * @param {import('./node.js').SumTerm} term
311
+ * @param {1 | -1} multiplier
312
+ * @return {1 | -1}
313
+ * */
314
+ function termSign(term, multiplier) {
315
+ let sign = /** @type {1 | -1} */ (term.sign * multiplier);
316
+ if (
317
+ isScalar(term.node) &&
318
+ Number.isFinite(term.node.value) &&
319
+ term.node.value < 0
320
+ ) {
321
+ sign = /** @type {1 | -1} */ (-sign);
322
+ }
323
+ return sign;
324
+ }
325
+
326
+ /**
327
+ * @param {import('./node.js').SumTerm} term
328
+ * @param {ReturnType<typeof makeContext>} session
329
+ * @param {1 | -1} sign
330
+ * @param {number | undefined} scalarValueOverride
331
+ * @return {void}
332
+ */
333
+ function emitSumTerm(term, session, sign, scalarValueOverride) {
334
+ if (sign === 1) {
335
+ emitNode(term.node, session, SUM_PRECEDENCE, true, scalarValueOverride);
336
+ } else {
337
+ emitLeadingNeg(term.node, session, scalarValueOverride);
225
338
  }
226
- return { sign, magnitude: node };
227
339
  }
228
340
 
229
341
  /**
230
342
  * @param {import('./node.js').SumTerm[]} terms
231
- * @param {number | false} prec
232
- * @return {string}
343
+ * @param {ReturnType<typeof makeContext>} session
344
+ * @param {1 | -1} [multiplier]
345
+ * @return {void}
233
346
  */
234
- function serializeSumTerms(terms, prec) {
235
- let out = '';
347
+ function emitSumTerms(terms, session, multiplier = 1) {
348
+ const buffer = session.buffer;
236
349
  for (let i = 0; i < terms.length; i++) {
237
- const { sign, magnitude } = displaySign(terms[i]);
350
+ const term = terms[i];
351
+ const termNode = term.node;
352
+ const scalar = isScalar(termNode);
353
+ const negativeScalar =
354
+ scalar && Number.isFinite(termNode.value) && termNode.value < 0;
355
+ let sign = /** @type {1 | -1} */ (term.sign * multiplier);
356
+ if (negativeScalar) sign = /** @type {1 | -1} */ (-sign);
357
+ const scalarValueOverride = negativeScalar ? -termNode.value : undefined;
238
358
  if (i === 0) {
239
- if (magnitude.type === 'Sum' && magnitude.grouped) {
240
- const body = `(${serializeExpr(magnitude, prec)})`;
241
- out = sign === 1 ? body : `-${body}`;
242
- continue;
359
+ if (scalar) {
360
+ if (sign === -1) buffer.push('-');
361
+ emitScalar(termNode, session, scalarValueOverride);
362
+ } else {
363
+ emitSumTerm(term, session, sign, scalarValueOverride);
243
364
  }
244
- out =
245
- sign === 1
246
- ? serializeExpr(magnitude, prec)
247
- : serializeLeadingNeg(magnitude, prec);
365
+ continue;
366
+ }
367
+ buffer.push(sign === 1 ? ' + ' : ' - ');
368
+ if (scalar) {
369
+ emitScalar(termNode, session, scalarValueOverride);
248
370
  } 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}`;
371
+ emitNode(termNode, session, SUM_PRECEDENCE, true);
255
372
  }
256
373
  }
257
- return out;
258
374
  }
259
375
 
260
- /**
261
- * @param {Sum} sum
262
- * @param {number | false} prec
263
- * @return {string}
264
- */
265
- function serializeSum(sum, prec) {
266
- return serializeSumTerms(sum.terms, prec);
376
+ /** @param {Sum} sum @param {ReturnType<typeof makeContext>} session @return {void} */
377
+ function emitSum(sum, session) {
378
+ emitSumTerms(sum.terms, session);
267
379
  }
268
380
 
269
381
  /**
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
382
  * @param {Node} node
273
- * @param {number | false} prec
274
- * @return {string}
383
+ * @param {ReturnType<typeof makeContext>} session
384
+ * @param {number} [scalarValueOverride]
385
+ * @return {void}
275
386
  */
276
- function serializeLeadingNeg(node, prec) {
387
+ function emitLeadingNeg(node, session, scalarValueOverride) {
277
388
  if (
278
389
  node.type === 'Product' &&
279
390
  node.factors.length > 0 &&
@@ -283,54 +394,302 @@ function serializeLeadingNeg(node, prec) {
283
394
  node.factors[0].node.value !== 0
284
395
  ) {
285
396
  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);
397
+ emitProductFactors(node.factors, session, 1, -head.value, head);
398
+ return;
295
399
  }
296
- const body = serializeExpr(node, prec);
297
- return node.type === 'Sum' || node.type === 'Product'
298
- ? `-(${body})`
299
- : `-${body}`;
400
+ session.buffer.push('-');
401
+ emitNode(
402
+ node,
403
+ session,
404
+ UNARY_PRECEDENCE,
405
+ false,
406
+ isScalar(node) ? scalarValueOverride : undefined
407
+ );
300
408
  }
301
409
 
302
410
  /**
303
411
  * @param {ProductFactor[]} factors
304
- * @param {number | false} prec
412
+ * @param {ReturnType<typeof makeContext>} session
413
+ * @param {number} [start]
414
+ * @param {number} [coefficientValue]
415
+ * @param {import('./node.js').Num} [coefficientNode]
416
+ * @return {void}
417
+ */
418
+ function emitProductFactors(
419
+ factors,
420
+ session,
421
+ start = 0,
422
+ coefficientValue,
423
+ coefficientNode
424
+ ) {
425
+ const buffer = session.buffer;
426
+ let first = true;
427
+ if (coefficientValue !== undefined && coefficientValue !== 1) {
428
+ emitScalar(
429
+ /** @type {import('./node.js').Num} */ (coefficientNode),
430
+ session,
431
+ coefficientValue
432
+ );
433
+ first = false;
434
+ }
435
+ for (let i = start; i < factors.length; i++) {
436
+ const factor = factors[i];
437
+ const factorNode = factor.node;
438
+ if (first) {
439
+ if (factor.exponent === -1) buffer.push('1 / ');
440
+ if (isScalar(factorNode)) emitScalar(factorNode, session);
441
+ else emitNode(factorNode, session, PRODUCT_PRECEDENCE);
442
+ first = false;
443
+ } else {
444
+ buffer.push(factor.exponent === 1 ? ' * ' : ' / ');
445
+ if (isScalar(factorNode)) emitScalar(factorNode, session);
446
+ else emitNode(factorNode, session, PRODUCT_PRECEDENCE);
447
+ }
448
+ }
449
+ }
450
+
451
+ /** @param {Product} product @param {ReturnType<typeof makeContext>} session @return {void} */
452
+ function emitProduct(product, session) {
453
+ emitProductFactors(product.factors, session);
454
+ }
455
+
456
+ /** @param {Node} node @param {ReturnType<typeof makeContext>} session @return {void} */
457
+ function emitRootExpr(node, session) {
458
+ if (
459
+ node.type === 'Sum' &&
460
+ node.grouped &&
461
+ node.terms.length > 1 &&
462
+ termSign(node.terms[0], 1) === -1
463
+ ) {
464
+ session.buffer.push('-(');
465
+ emitSumTerms(node.terms, session, -1);
466
+ session.buffer.push(')');
467
+ return;
468
+ }
469
+ if (node.type === 'Sum' && node.terms.length === 1) {
470
+ emitLeadingNeg(node.terms[0].node, session);
471
+ return;
472
+ }
473
+ emitNode(node, session);
474
+ }
475
+
476
+ /**
477
+ * @param {Node} node
478
+ * @param {ReturnType<typeof makeContext>} session
479
+ * @param {string} wrapper
480
+ * @return {void}
481
+ */
482
+ function emitMathResult(node, session, wrapper) {
483
+ if (isScalar(node)) {
484
+ const scalarValue = roundedScalarValue(node, session.precision);
485
+ const buffer = session.buffer;
486
+ if (isDegenerate(scalarValue)) {
487
+ buffer.push(wrapper, '(', degenerateKeyword(scalarValue));
488
+ if (node.type === 'Dim') buffer.push(' * 1', node.rawUnit ?? node.unit);
489
+ buffer.push(')');
490
+ } else if (session.scalarPolicy === 'standard') {
491
+ buffer.push(wrapper, '(');
492
+ emitRoundedScalar(node, buffer, scalarValue);
493
+ buffer.push(')');
494
+ } else {
495
+ emitRoundedScalar(node, buffer, scalarValue);
496
+ }
497
+ return;
498
+ }
499
+ if (
500
+ node.type === 'Sum' &&
501
+ node.grouped &&
502
+ node.terms.length > 1 &&
503
+ termSign(node.terms[0], 1) === -1
504
+ ) {
505
+ session.buffer.push(wrapper, '(-(');
506
+ emitSumTerms(node.terms, session, -1);
507
+ session.buffer.push('))');
508
+ return;
509
+ }
510
+ if (
511
+ node.type === 'Ident' ||
512
+ node.type === 'Call' ||
513
+ node.type === 'OpaqueCall'
514
+ ) {
515
+ emitNode(node, session);
516
+ return;
517
+ }
518
+ if (node.type === 'Sum' && node.terms.length === 1) {
519
+ session.buffer.push(wrapper, '(');
520
+ emitLeadingNeg(node.terms[0].node, session);
521
+ session.buffer.push(')');
522
+ return;
523
+ }
524
+ session.buffer.push(wrapper, '(');
525
+ emitNode(node, session);
526
+ session.buffer.push(')');
527
+ }
528
+
529
+ /**
530
+ * Serialize a scalar result without allocating a render context or buffer.
531
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
532
+ * @param {number | false} precision
533
+ * @param {'standard' | 'unwrap-all'} scalarPolicy
534
+ * @param {string} wrapper
305
535
  * @return {string}
306
536
  */
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})`;
537
+ function serializeScalarResult(node, precision, scalarPolicy, wrapper) {
538
+ const value = roundedScalarValue(node, precision);
539
+ const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : '';
540
+ if (isDegenerate(value)) {
541
+ return `${wrapper}(${degenerateKeyword(value)}${unit ? ` * 1${unit}` : ''})`;
542
+ }
543
+ const scalar = serializeNumber(value) + unit;
544
+ return scalarPolicy === 'standard' ? `${wrapper}(${scalar})` : scalar;
545
+ }
546
+
547
+ /**
548
+ * @param {Node} node
549
+ * @param {ReturnType<typeof makeContext>} session
550
+ * @param {string[]} [buffer]
551
+ * @return {void}
552
+ */
553
+ function emitNestedMathResult(node, session, buffer = session.buffer) {
554
+ if (isSignedZero(node)) {
555
+ emitSignedZero(buffer, node);
556
+ return;
557
+ }
558
+ emitMathResult(node, session, 'calc');
559
+ }
560
+
561
+ /**
562
+ * @param {SerializeOptions} opts
563
+ * @return {{buffer: string[], precision: number | false, scalarPolicy: 'standard' | 'unwrap-all'}}
564
+ */
565
+ function makeContext(opts) {
566
+ return {
567
+ buffer: [],
568
+ precision: opts.precision ?? 5,
569
+ scalarPolicy: normalizeScalarPolicy(opts),
570
+ };
571
+ }
572
+
573
+ /**
574
+ * @param {Node} node
575
+ * @param {SerializeOptions} opts
576
+ * @return {{kind: 'math', node: Node, session: ReturnType<typeof makeContext>, wrapper: string}}
577
+ */
578
+ function planSerialize(node, opts) {
579
+ return {
580
+ kind: 'math',
581
+ node,
582
+ session: makeContext(opts),
583
+ wrapper: opts.calcName ?? 'calc',
584
+ };
585
+ }
586
+
587
+ /**
588
+ * @param {{tree: Node, status: 'resolved' | 'unresolved', rootName: string, rootSpelling: string, calculation?: boolean, original?: string}} result
589
+ * @param {SerializeOptions} opts
590
+ * @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}}
591
+ */
592
+ function planSerializeResult(result, opts) {
593
+ const isCalc = result.calculation ?? isCalculationFunction(result.rootName);
594
+ const normalizedRootName =
595
+ result.calculation === undefined
596
+ ? result.rootName.toLowerCase()
597
+ : result.rootName;
598
+ const wrapper = isCalc
599
+ ? result.rootSpelling || opts.calcName || 'calc'
600
+ : 'calc';
601
+ const session = makeContext(opts);
602
+
603
+ if (!isCalc && result.status === 'unresolved') {
604
+ if (
605
+ (result.tree.type === 'Call' || result.tree.type === 'OpaqueCall') &&
606
+ result.tree.name.toLowerCase() === normalizedRootName
607
+ ) {
608
+ return {
609
+ kind: 'root-call',
610
+ node: result.tree,
611
+ session,
612
+ callNameOverride: result.rootSpelling,
613
+ };
316
614
  }
317
- if (i === 0) {
318
- // Leading denominator: implicit 1 so we emit `1 / 2px`, not `/ 2px`.
319
- out = f.exponent === 1 ? body : `1 / ${body}`;
615
+ return { kind: 'original', text: result.original ?? '' };
616
+ }
617
+
618
+ if (session.scalarPolicy === 'standard') {
619
+ if (isScalar(result.tree))
620
+ return { kind: 'math', node: result.tree, session, wrapper };
621
+ return { kind: 'wrapped-expr', node: result.tree, session, wrapper };
622
+ }
623
+ return { kind: 'math', node: result.tree, session, wrapper };
624
+ }
625
+
626
+ /**
627
+ * @param {ReturnType<typeof planSerialize> | ReturnType<typeof planSerializeResult>} renderSpec
628
+ * @return {string}
629
+ * */
630
+ function emitOutput(renderSpec) {
631
+ if (renderSpec.kind === 'original') return renderSpec.text;
632
+ const { session } = renderSpec;
633
+ if (renderSpec.kind === 'root-call') {
634
+ if (renderSpec.node.type === 'Call') {
635
+ emitCall(renderSpec.node, session, renderSpec.callNameOverride);
320
636
  } else {
321
- out += f.exponent === 1 ? ` * ${body}` : ` / ${body}`;
637
+ emitOpaqueCall(
638
+ /** @type {import('./node.js').OpaqueCall} */ (renderSpec.node),
639
+ session,
640
+ renderSpec.callNameOverride
641
+ );
322
642
  }
643
+ } else if (renderSpec.kind === 'wrapped-expr') {
644
+ session.buffer.push(renderSpec.wrapper, '(');
645
+ emitRootExpr(renderSpec.node, session);
646
+ session.buffer.push(')');
647
+ } else {
648
+ emitMathResult(renderSpec.node, session, renderSpec.wrapper);
323
649
  }
324
- return out;
650
+ return session.buffer.join('');
325
651
  }
326
652
 
327
653
  /**
328
- * @param {Product} product
329
- * @param {number | false} prec
654
+ * @param {Node} node
655
+ * @param {SerializeOptions} [opts]
330
656
  * @return {string}
331
657
  */
332
- function serializeProduct(product, prec) {
333
- return serializeFactors(product.factors, prec);
658
+ function serialize(node, opts = {}) {
659
+ if (isScalar(node)) {
660
+ return serializeScalarResult(
661
+ node,
662
+ opts.precision ?? 5,
663
+ normalizeScalarPolicy(opts),
664
+ opts.calcName ?? 'calc'
665
+ );
666
+ }
667
+ checkCalculationDepth(node);
668
+ return emitOutput(planSerialize(node, opts));
669
+ }
670
+
671
+ /**
672
+ * @param {{tree: Node, status: 'resolved' | 'unresolved', rootName: string, rootSpelling: string, calculation?: boolean, original?: string}} result
673
+ * @param {SerializeOptions} [opts]
674
+ * @return {string}
675
+ */
676
+ function serializeResult(result, opts = {}) {
677
+ const node = result.tree;
678
+ if (isScalar(node)) {
679
+ const isCalc = result.calculation ?? isCalculationFunction(result.rootName);
680
+ if (!isCalc && result.status === 'unresolved') return result.original ?? '';
681
+ const wrapper = isCalc
682
+ ? result.rootSpelling || opts.calcName || 'calc'
683
+ : 'calc';
684
+ return serializeScalarResult(
685
+ node,
686
+ opts.precision ?? 5,
687
+ normalizeScalarPolicy(opts),
688
+ wrapper
689
+ );
690
+ }
691
+ checkCalculationDepth(result.tree);
692
+ return emitOutput(planSerializeResult(result, opts));
334
693
  }
335
694
 
336
- export { serialize };
695
+ export { serialize, serializeResult };