postcss-calc 11.1.1 → 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 (47) hide show
  1. package/README.md +40 -18
  2. package/package.json +16 -11
  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/convertUnits.js +15 -1
  9. package/src/lib/functions.js +364 -0
  10. package/src/lib/limits.js +49 -0
  11. package/src/lib/node.js +85 -39
  12. package/src/lib/opaque.js +20 -0
  13. package/src/lib/parser.js +493 -257
  14. package/src/lib/print.js +75 -0
  15. package/src/lib/regex.js +4 -0
  16. package/src/lib/scan.js +61 -0
  17. package/src/lib/serialize.js +574 -207
  18. package/src/lib/simplify/abs.js +1 -1
  19. package/src/lib/simplify/bucket.js +18 -23
  20. package/src/lib/simplify/call.js +6 -92
  21. package/src/lib/simplify/product.js +7 -4
  22. package/src/lib/simplify/round.js +4 -4
  23. package/src/lib/simplify/sum.js +22 -10
  24. package/src/lib/simplify.js +20 -5
  25. package/src/reduce.js +46 -118
  26. package/types/index.d.ts +4 -0
  27. package/types/lib/analyze.d.ts +18 -0
  28. package/types/lib/block-index.d.ts +35 -0
  29. package/types/lib/calculation-type.d.ts +18 -0
  30. package/types/lib/compile.d.ts +37 -0
  31. package/types/lib/convertUnits.d.ts +8 -1
  32. package/types/lib/functions.d.ts +40 -0
  33. package/types/lib/limits.d.ts +8 -0
  34. package/types/lib/node.d.ts +31 -9
  35. package/types/lib/opaque.d.ts +9 -0
  36. package/types/lib/parser.d.ts +47 -49
  37. package/types/lib/print.d.ts +16 -0
  38. package/types/lib/regex.d.ts +2 -0
  39. package/types/lib/scan.d.ts +35 -0
  40. package/types/lib/serialize.d.ts +19 -2
  41. package/types/lib/simplify/bucket.d.ts +2 -2
  42. package/types/lib/simplify/call.d.ts +4 -18
  43. package/types/lib/simplify/round.d.ts +3 -1
  44. package/types/lib/simplify.d.ts +5 -2
  45. package/types/reduce.d.ts +47 -6
  46. package/src/lib/tokenizer.js +0 -139
  47. package/types/lib/tokenizer.d.ts +0 -32
@@ -1,8 +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';
5
+ import { serializeComponents } from './opaque.js';
6
+ import { checkCalculationDepth } from './limits.js';
7
+ import { isCalculationFunction } from './functions.js';
6
8
 
7
9
  /**
8
10
  * @typedef {import('./node.js').Node} Node
@@ -12,260 +14,377 @@ import { num, dim } from './node.js';
12
14
  * @typedef {object} SerializeOptions
13
15
  * @property {number | false} [precision] Decimal places for numbers. `false` disables rounding. Default 5.
14
16
  * @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`.
15
- * @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.
16
19
  */
17
20
 
18
- // 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;
19
29
  const NOISE_FLOOR = 1e-12;
20
30
 
21
31
  /**
22
- * Rounding to `prec` decimal places turns `calc(1/1000000)` into `0`, and a
23
- * `0` in CSS is often a switch, not a small number (`flex-grow: 0` never
24
- * grows). So when a value is too small for `prec`, keep its significant digits
25
- * 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.
26
39
  *
27
40
  * @param {number} v
28
41
  * @param {number | false} prec
29
42
  * @return {number}
30
43
  */
31
44
  function round(v, prec) {
32
- if (prec === false) {
33
- 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
+ }
34
85
  }
35
- const m = Math.pow(10, prec);
36
- const rounded = Math.round(v * m) / m;
37
- if (rounded === 0 && Math.abs(v) > NOISE_FLOOR) {
38
- // toPrecision needs at least one significant digit; `prec` may be 0.
39
- 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)));
40
91
  }
41
92
  return rounded;
42
93
  }
43
94
 
44
95
  // §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords.
45
- /**
46
- * @param {number} v
47
- * @return {boolean}
48
- */
96
+ /** @param {number} v @return {boolean} */
49
97
  function isDegenerate(v) {
50
98
  return !Number.isFinite(v) || Number.isNaN(v);
51
99
  }
52
100
 
53
- /**
54
- * @param {number} v
55
- * @return {string}
56
- */
101
+ /** @param {number} v @return {string} */
57
102
  function degenerateKeyword(v) {
58
- if (Number.isNaN(v)) {
59
- return 'NaN';
60
- }
103
+ if (Number.isNaN(v)) return 'NaN';
61
104
  return v > 0 ? 'infinity' : '-infinity';
62
105
  }
63
106
 
64
- /**
65
- * Serialize a finite CSS number. CSS numbers may omit the zero before a
66
- * fractional value between -1 and 1 (`.5`, `-.5`). Scientific notation is
67
- * left untouched because it already has no leading zero to remove.
68
- *
69
- * @param {number} v
70
- * @return {string}
71
- */
107
+ /** @param {number} v @return {string} */
72
108
  function serializeNumber(v) {
109
+ if (Object.is(v, -0)) return '0';
73
110
  const text = String(v);
74
- if (text.startsWith('0.')) {
75
- return text.slice(1);
76
- }
77
- if (text.startsWith('-0.')) {
78
- return `-${text.slice(2)}`;
79
- }
111
+ if (text.startsWith('0.')) return text.slice(1);
112
+ if (text.startsWith('-0.')) return `-${text.slice(2)}`;
80
113
  return text;
81
114
  }
82
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
+
83
123
  /**
84
- * Round and serialize a finite scalar once so callers can use the same value
85
- * to decide its syntactic context and render its text.
86
- *
87
124
  * @param {import('./node.js').Num | import('./node.js').Dim} node
88
- * @param {number | false} prec
89
- * @return {{value: number, text: string}}
125
+ * @param {number | false} precision
126
+ * @param {number} [value]
127
+ * @return {number}
90
128
  */
91
- function serializeScalar(node, prec) {
92
- const value = round(node.value, prec);
93
- const text = `${serializeNumber(value)}${node.type === 'Dim' ? node.unit : ''}`;
94
- return { value, text };
129
+ function roundedScalarValue(node, precision, value) {
130
+ return round(value ?? node.value, precision);
95
131
  }
96
132
 
97
133
  /**
98
- * @param {Node} node
99
- * @param {SerializeOptions} [opts]
100
- * @return {string}
134
+ * @param {import('./node.js').Num | import('./node.js').Dim} node
135
+ * @param {string[]} buffer
136
+ * @param {number} value
137
+ * @return {void}
101
138
  */
102
- function serialize(node, opts = {}) {
103
- const prec = opts.precision ?? 5;
104
- const calcName = opts.calcName ?? 'calc';
105
-
106
- // §10.13: top-level Infinity/NaN wrap in calc(); dim degenerates carry
107
- // the unit as `<keyword> * 1<unit>` so the result keeps its type.
108
- if (node.type === 'Num' && isDegenerate(node.value)) {
109
- return `${calcName}(${degenerateKeyword(node.value)})`;
110
- }
111
- if (node.type === 'Dim' && isDegenerate(node.value)) {
112
- return `${calcName}(${degenerateKeyword(node.value)} * 1${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);
113
143
  }
144
+ }
114
145
 
115
- if (node.type === 'Num' || node.type === 'Dim') {
116
- const scalar = serializeScalar(node, prec);
117
-
118
- // A finite negative scalar must stay inside calc() so CSS parses it as a
119
- // calculation result (and can apply range clamping) rather than as an
120
- // invalid bare value. Base this on the serialized value so tiny negative
121
- // floating-point noise that rounds to zero does not get wrapped.
122
- if (scalar.value < 0) {
123
- return opts.unwrapSingleNegativeNumber
124
- ? scalar.text
125
- : `${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));
126
178
  }
179
+ } else emitFiniteScalar(node, session, value);
180
+ }
127
181
 
128
- return scalar.text;
129
- }
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
+ }
130
191
 
131
- // A grouped sum with a leading negative term is the canonical result of
132
- // negating a parenthesized expression. Re-invert its terms for the body so
133
- // the grouping survives as `-(...)` instead of becoming `-a - b`.
134
- if (
135
- node.type === 'Sum' &&
136
- node.grouped &&
137
- node.terms.length > 1 &&
138
- displaySign(node.terms[0]).sign === -1
139
- ) {
140
- const invertedTerms = node.terms.map((t) => ({
141
- sign: /** @type {1 | -1} */ (-t.sign),
142
- node: t.node,
143
- }));
144
- return `${calcName}(-(${serializeSumTerms(invertedTerms, prec)}))`;
145
- }
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
+ }
146
196
 
147
- if (node.type === 'Ident' || node.type === 'Call') {
148
- return serializeExpr(node, prec);
149
- }
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
+ }
150
201
 
151
- // Single-term Sum is the canonical form for `-var(--x)` / `-(a*b)` —
152
- // sign=-1 around an opaque node. Signed leaves live in Num/Dim directly.
153
- if (node.type === 'Sum' && node.terms.length === 1) {
154
- return `${calcName}(${serializeLeadingNeg(node.terms[0].node, prec)})`;
155
- }
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
+ }
156
208
 
157
- 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
+ );
158
220
  }
159
221
 
160
- // --- 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
+ }
161
246
 
162
247
  /**
163
248
  * @param {Node} node
164
- * @param {number | false} prec
165
- * @return {string}
249
+ * @param {ReturnType<typeof makeContext>} session
250
+ * @param {number} [scalarValueOverride]
251
+ * @return {void}
166
252
  */
167
- function serializeExpr(node, prec) {
253
+ function emitNodeBody(node, session, scalarValueOverride) {
254
+ const buffer = session.buffer;
168
255
  switch (node.type) {
169
256
  case 'Num':
170
- if (isDegenerate(node.value)) {
171
- return degenerateKeyword(node.value);
172
- }
173
- return serializeScalar(node, prec).text;
174
257
  case 'Dim':
175
- if (isDegenerate(node.value)) {
176
- // Nested degenerate Dim wraps in calc() so the `<kw> * 1<unit>` form
177
- // parses back as one Dim factor. The bare form round-trips wrong
178
- // inside a Product — `0 * Dim(Infinity, px)` would re-fold as NaN.
179
- return `calc(${degenerateKeyword(node.value)} * 1${node.unit})`;
180
- }
181
- return serializeScalar(node, prec).text;
258
+ emitScalar(node, session, scalarValueOverride);
259
+ return;
182
260
  case 'Ident':
183
- return node.name;
184
- case 'Call': {
185
- const args = node.args.map((a) => serializeExpr(a, prec)).join(', ');
186
- return `${node.name}(${args})`;
187
- }
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;
188
269
  case 'Sum':
189
- return serializeSum(node, prec);
270
+ emitSum(node, session);
271
+ return;
190
272
  case 'Product':
191
- return serializeProduct(node, prec);
273
+ emitProduct(node, session);
274
+ return;
192
275
  }
193
276
  }
194
277
 
195
278
  /**
196
- * Combine the term's sign with a negative Num/Dim value's sign so
197
- * `{sign:+1, Num(-5)}` renders as `-5`, not `+ -5`. Skip degenerate
198
- * (Infinity/NaN) values — the `degenerateKeyword` path emits `-infinity`
199
- * inline, and a leading minus on `calc(infinity*1<unit>)` would now
200
- * tokenize as a `-calc` function.
201
- * @param {{sign: 1 | -1, node: Node}} term
202
- * @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}
203
283
  */
204
- function displaySign(term) {
205
- const { sign, node } = term;
206
- if (node.type === 'Num' && Number.isFinite(node.value) && node.value < 0) {
207
- return {
208
- sign: /** @type {1 | -1} */ (-sign),
209
- magnitude: num(-node.value),
210
- };
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);
211
290
  }
212
- if (node.type === 'Dim' && Number.isFinite(node.value) && node.value < 0) {
213
- return {
214
- sign: /** @type {1 | -1} */ (-sign),
215
- magnitude: dim(-node.value, node.unit),
216
- };
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);
217
338
  }
218
- return { sign, magnitude: node };
219
339
  }
220
340
 
221
341
  /**
222
342
  * @param {import('./node.js').SumTerm[]} terms
223
- * @param {number | false} prec
224
- * @return {string}
343
+ * @param {ReturnType<typeof makeContext>} session
344
+ * @param {1 | -1} [multiplier]
345
+ * @return {void}
225
346
  */
226
- function serializeSumTerms(terms, prec) {
227
- let out = '';
347
+ function emitSumTerms(terms, session, multiplier = 1) {
348
+ const buffer = session.buffer;
228
349
  for (let i = 0; i < terms.length; i++) {
229
- 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;
230
358
  if (i === 0) {
231
- if (magnitude.type === 'Sum' && magnitude.grouped) {
232
- const body = `(${serializeExpr(magnitude, prec)})`;
233
- out = sign === 1 ? body : `-${body}`;
234
- continue;
359
+ if (scalar) {
360
+ if (sign === -1) buffer.push('-');
361
+ emitScalar(termNode, session, scalarValueOverride);
362
+ } else {
363
+ emitSumTerm(term, session, sign, scalarValueOverride);
235
364
  }
236
- out =
237
- sign === 1
238
- ? serializeExpr(magnitude, prec)
239
- : serializeLeadingNeg(magnitude, prec);
365
+ continue;
366
+ }
367
+ buffer.push(sign === 1 ? ' + ' : ' - ');
368
+ if (scalar) {
369
+ emitScalar(termNode, session, scalarValueOverride);
240
370
  } else {
241
- // `-` binds looser than `*`/`/` so the right side never needs parens.
242
- let body = serializeExpr(magnitude, prec);
243
- if (magnitude.type === 'Sum' && magnitude.grouped) {
244
- body = `(${body})`;
245
- }
246
- out += sign === 1 ? ` + ${body}` : ` - ${body}`;
371
+ emitNode(termNode, session, SUM_PRECEDENCE, true);
247
372
  }
248
373
  }
249
- return out;
250
374
  }
251
375
 
252
- /**
253
- * @param {Sum} sum
254
- * @param {number | false} prec
255
- * @return {string}
256
- */
257
- function serializeSum(sum, prec) {
258
- 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);
259
379
  }
260
380
 
261
381
  /**
262
- * Fold a leading negation into a finite leading Num if there is one
263
- * (`-(0.5 * x)` → `-0.5 * x`); else use `-(…)` for Sum/Product or `-x`.
264
382
  * @param {Node} node
265
- * @param {number | false} prec
266
- * @return {string}
383
+ * @param {ReturnType<typeof makeContext>} session
384
+ * @param {number} [scalarValueOverride]
385
+ * @return {void}
267
386
  */
268
- function serializeLeadingNeg(node, prec) {
387
+ function emitLeadingNeg(node, session, scalarValueOverride) {
269
388
  if (
270
389
  node.type === 'Product' &&
271
390
  node.factors.length > 0 &&
@@ -275,54 +394,302 @@ function serializeLeadingNeg(node, prec) {
275
394
  node.factors[0].node.value !== 0
276
395
  ) {
277
396
  const head = node.factors[0].node;
278
- const negatedValue = -head.value;
279
- const rest = node.factors.slice(1);
280
- // A coefficient of 1 is a no-op factor, matching mkProduct.
281
- /** @type {ProductFactor[]} */
282
- const negatedFactors =
283
- negatedValue === 1
284
- ? rest
285
- : [{ exponent: 1, node: num(negatedValue) }, ...rest];
286
- return serializeFactors(negatedFactors, prec);
397
+ emitProductFactors(node.factors, session, 1, -head.value, head);
398
+ return;
287
399
  }
288
- const body = serializeExpr(node, prec);
289
- return node.type === 'Sum' || node.type === 'Product'
290
- ? `-(${body})`
291
- : `-${body}`;
400
+ session.buffer.push('-');
401
+ emitNode(
402
+ node,
403
+ session,
404
+ UNARY_PRECEDENCE,
405
+ false,
406
+ isScalar(node) ? scalarValueOverride : undefined
407
+ );
292
408
  }
293
409
 
294
410
  /**
295
411
  * @param {ProductFactor[]} factors
296
- * @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
297
535
  * @return {string}
298
536
  */
299
- function serializeFactors(factors, prec) {
300
- let out = '';
301
- for (let i = 0; i < factors.length; i++) {
302
- const f = factors[i];
303
- let body = serializeExpr(f.node, prec);
304
- // A Sum factor needs parens: `a * (b + c)`. Flat canonical form means
305
- // this is the only place parens are required.
306
- if (f.node.type === 'Sum') {
307
- 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
+ };
308
614
  }
309
- if (i === 0) {
310
- // Leading denominator: implicit 1 so we emit `1 / 2px`, not `/ 2px`.
311
- 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);
312
636
  } else {
313
- out += f.exponent === 1 ? ` * ${body}` : ` / ${body}`;
637
+ emitOpaqueCall(
638
+ /** @type {import('./node.js').OpaqueCall} */ (renderSpec.node),
639
+ session,
640
+ renderSpec.callNameOverride
641
+ );
314
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);
315
649
  }
316
- return out;
650
+ return session.buffer.join('');
317
651
  }
318
652
 
319
653
  /**
320
- * @param {Product} product
321
- * @param {number | false} prec
654
+ * @param {Node} node
655
+ * @param {SerializeOptions} [opts]
322
656
  * @return {string}
323
657
  */
324
- function serializeProduct(product, prec) {
325
- 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));
326
693
  }
327
694
 
328
- export { serialize };
695
+ export { serialize, serializeResult };