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
@@ -139,4 +139,18 @@ function convert(value, from, to) {
139
139
  return (value * f) / t;
140
140
  }
141
141
 
142
- export { baseOf, convert };
142
+ /**
143
+ * Return a base type only for units present in the static conversion table.
144
+ * Units with context-dependent values (em, rem, vw, etc.) return null.
145
+ * @param {string} unit
146
+ * @return {BaseType | null}
147
+ */
148
+ function staticBaseOf(unit) {
149
+ const key = unit.toLowerCase();
150
+ if (TO_CANONICAL[key] === undefined) {
151
+ return null;
152
+ }
153
+ return UNIT_TO_BASE[key] ?? null;
154
+ }
155
+
156
+ export { baseOf, staticBaseOf, convert };
@@ -0,0 +1,364 @@
1
+ import { simplifyMinMax } from './simplify/min-max.js';
2
+ import { simplifyClamp } from './simplify/clamp.js';
3
+ import { simplifyAbs } from './simplify/abs.js';
4
+ import { simplifySign } from './simplify/sign.js';
5
+ import { simplifyModRem } from './simplify/mod-rem.js';
6
+ import { ROUND_STRATEGIES, simplifyRound } from './simplify/round.js';
7
+ import { simplifyTrig } from './simplify/trig.js';
8
+ import { simplifyInverseTrig } from './simplify/inverse-trig.js';
9
+ import { simplifyAtan2 } from './simplify/atan2.js';
10
+ import { simplifyPow } from './simplify/pow.js';
11
+ import { simplifySqrt } from './simplify/sqrt.js';
12
+ import { simplifyExp } from './simplify/exp.js';
13
+ import { simplifyLog } from './simplify/log.js';
14
+ import { simplifyHypot } from './simplify/hypot.js';
15
+
16
+ /** @typedef {import('./node.js').Node} Node */
17
+ /** @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown'} | {kind: 'failure'}} CalculationType */
18
+ /** @typedef {(name: string, args: Node[]) => Node} MathSimplifier */
19
+ /** @typedef {(args: CalculationType[], nodes: Node[]) => CalculationType} TypeAnalyzer */
20
+ /** @typedef {{analyze: TypeAnalyzer, simplify?: MathSimplifier, isKeyword?: (node: Node, index: number) => boolean, calculation?: boolean}} MathFunction */
21
+
22
+ /** @type {CalculationType} */ const numberType = { kind: 'number' };
23
+ /** @type {CalculationType} */ const unknownType = { kind: 'unknown' };
24
+ /** @type {CalculationType} */ const failureType = { kind: 'failure' };
25
+
26
+ /** @param {CalculationType} type @return {boolean} */
27
+ function isFailure(type) {
28
+ return type.kind === 'failure';
29
+ }
30
+
31
+ /** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */
32
+ function addTypes(a, b) {
33
+ if (isFailure(a) || isFailure(b)) return failureType;
34
+ if (a.kind === 'unknown' || b.kind === 'unknown') return unknownType;
35
+ if (a.kind === 'number' && b.kind === 'number') return numberType;
36
+ if (a.kind === 'dimension' && b.kind === 'dimension') {
37
+ if (a.base === null || b.base === null) return unknownType;
38
+ return a.base === b.base ? a : failureType;
39
+ }
40
+ return failureType;
41
+ }
42
+
43
+ /**
44
+ * Check an all-number function without assuming anything about an unresolved
45
+ * operand. A concrete dimension can never become a number, so it is still a
46
+ * definite error when another argument is opaque.
47
+ * @param {CalculationType[]} args
48
+ * @param {number} min
49
+ * @param {number} max
50
+ * @return {CalculationType}
51
+ */
52
+ function numberArguments(args, min, max) {
53
+ if (args.length < min || args.length > max) return failureType;
54
+ let hasUnknown = false;
55
+ for (const arg of args) {
56
+ if (arg.kind === 'dimension') return failureType;
57
+ hasUnknown = hasUnknown || arg.kind === 'unknown';
58
+ }
59
+ return hasUnknown ? unknownType : numberType;
60
+ }
61
+
62
+ /**
63
+ * Check values that must share a calculation type. Unknown operands remain
64
+ * unknown: they might resolve to the concrete type required by their peers.
65
+ * @param {CalculationType[]} args
66
+ * @param {number} min
67
+ * @param {number} max
68
+ * @return {CalculationType}
69
+ */
70
+ function matchingArguments(args, min, max) {
71
+ if (args.length < min || args.length > max) return failureType;
72
+ let result = args[0];
73
+ for (let index = 1; index < args.length; index++) {
74
+ result = addTypes(result, args[index]);
75
+ if (isFailure(result)) return failureType;
76
+ }
77
+ return result;
78
+ }
79
+
80
+ /** @param {CalculationType[]} args @return {CalculationType} */
81
+ function analyzeTrig(args) {
82
+ return args.length === 1 &&
83
+ (args[0].kind === 'unknown' ||
84
+ args[0].kind === 'number' ||
85
+ (args[0].kind === 'dimension' && args[0].base === 'angle'))
86
+ ? numberType
87
+ : failureType;
88
+ }
89
+
90
+ /** @param {CalculationType[]} args @return {CalculationType} */
91
+ function analyzeInverseTrig(args) {
92
+ if (args.length !== 1 || args[0].kind === 'dimension') {
93
+ return failureType;
94
+ }
95
+ return args[0].kind === 'unknown'
96
+ ? unknownType
97
+ : { kind: 'dimension', base: 'angle' };
98
+ }
99
+
100
+ /** @param {CalculationType[]} args @return {CalculationType} */
101
+ function analyzeIdentity(args) {
102
+ return args.length === 1 ? args[0] : failureType;
103
+ }
104
+
105
+ /** @param {CalculationType[]} args @return {CalculationType} */
106
+ function analyzeSign(args) {
107
+ return args.length === 1 ? numberType : failureType;
108
+ }
109
+
110
+ /** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */
111
+ function analyzeRound(args, nodes) {
112
+ const strategy = nodes[0];
113
+ const hasStrategy =
114
+ strategy?.type === 'Ident' &&
115
+ ROUND_STRATEGIES.has(strategy.name.toLowerCase());
116
+ const start = hasStrategy ? 1 : 0;
117
+ const valueCount = args.length - start;
118
+ if (valueCount === 1) {
119
+ const value = args[start];
120
+ if (value.kind === 'dimension') return failureType;
121
+ if (value.kind === 'unknown') return unknownType;
122
+ return numberType;
123
+ }
124
+ if (valueCount !== 2) return failureType;
125
+ const type = addTypes(args[start], args[start + 1]);
126
+ return isFailure(type) ? failureType : type;
127
+ }
128
+
129
+ /** @param {CalculationType[]} args @return {CalculationType} */
130
+ function analyzeAtan2(args) {
131
+ const type = matchingArguments(args, 2, 2);
132
+ return isFailure(type) || type.kind === 'unknown'
133
+ ? type
134
+ : { kind: 'dimension', base: 'angle' };
135
+ }
136
+
137
+ /** @param {CalculationType[]} args @return {CalculationType} */
138
+ function analyzeCalc(args) {
139
+ return args.length === 1 ? args[0] : failureType;
140
+ }
141
+
142
+ /** @param {Node} node @return {boolean} */
143
+ function isRoundStrategy(node) {
144
+ return node.type === 'Ident' && ROUND_STRATEGIES.has(node.name.toLowerCase());
145
+ }
146
+
147
+ /** @param {Node} node @param {number} index @return {boolean} */
148
+ function isClampKeyword(node, index) {
149
+ return (
150
+ (index === 0 || index === 2) &&
151
+ node.type === 'Ident' &&
152
+ node.name.toLowerCase() === 'none'
153
+ );
154
+ }
155
+
156
+ /** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */
157
+ function analyzeClamp(args, nodes) {
158
+ if (args.length !== 3) return failureType;
159
+ let valueCount = 0;
160
+ /** @type {CalculationType} */ let result = failureType;
161
+ for (let index = 0; index < args.length; index++) {
162
+ if (isClampKeyword(nodes[index], index)) continue;
163
+ result = valueCount === 0 ? args[index] : addTypes(result, args[index]);
164
+ valueCount++;
165
+ }
166
+ if (valueCount < 1 || isFailure(result)) return failureType;
167
+ return result;
168
+ }
169
+
170
+ const mathFunctions = new Map(
171
+ /** @type {[string, MathFunction][]} */ ([
172
+ ['calc', { analyze: analyzeCalc, calculation: true }],
173
+ ['-webkit-calc', { analyze: analyzeCalc, calculation: true }],
174
+ ['-moz-calc', { analyze: analyzeCalc, calculation: true }],
175
+ [
176
+ 'min',
177
+ {
178
+ analyze: (args) => matchingArguments(args, 1, Infinity),
179
+ simplify: simplifyMinMax,
180
+ },
181
+ ],
182
+ [
183
+ 'max',
184
+ {
185
+ analyze: (args) => matchingArguments(args, 1, Infinity),
186
+ simplify: simplifyMinMax,
187
+ },
188
+ ],
189
+ [
190
+ 'clamp',
191
+ {
192
+ analyze: analyzeClamp,
193
+ simplify: (_name, args) => simplifyClamp(args),
194
+ isKeyword: isClampKeyword,
195
+ },
196
+ ],
197
+ [
198
+ 'abs',
199
+ {
200
+ analyze: analyzeIdentity,
201
+ simplify: (_name, args) => simplifyAbs(args),
202
+ },
203
+ ],
204
+ [
205
+ 'sign',
206
+ {
207
+ analyze: analyzeSign,
208
+ simplify: (_name, args) => simplifySign(args),
209
+ },
210
+ ],
211
+ [
212
+ 'mod',
213
+ {
214
+ analyze: (args) => matchingArguments(args, 2, 2),
215
+ simplify: (_name, args) => simplifyModRem('mod', args),
216
+ },
217
+ ],
218
+ [
219
+ 'rem',
220
+ {
221
+ analyze: (args) => matchingArguments(args, 2, 2),
222
+ simplify: (_name, args) => simplifyModRem('rem', args),
223
+ },
224
+ ],
225
+ [
226
+ 'round',
227
+ {
228
+ analyze: analyzeRound,
229
+ simplify: (_name, args) => simplifyRound(args),
230
+ isKeyword: (node, index) => index === 0 && isRoundStrategy(node),
231
+ },
232
+ ],
233
+ [
234
+ 'sin',
235
+ {
236
+ analyze: analyzeTrig,
237
+ simplify: (_name, args) => simplifyTrig('sin', args),
238
+ },
239
+ ],
240
+ [
241
+ 'cos',
242
+ {
243
+ analyze: analyzeTrig,
244
+ simplify: (_name, args) => simplifyTrig('cos', args),
245
+ },
246
+ ],
247
+ [
248
+ 'tan',
249
+ {
250
+ analyze: analyzeTrig,
251
+ simplify: (_name, args) => simplifyTrig('tan', args),
252
+ },
253
+ ],
254
+ [
255
+ 'asin',
256
+ {
257
+ analyze: analyzeInverseTrig,
258
+ simplify: (_name, args) => simplifyInverseTrig('asin', args),
259
+ },
260
+ ],
261
+ [
262
+ 'acos',
263
+ {
264
+ analyze: analyzeInverseTrig,
265
+ simplify: (_name, args) => simplifyInverseTrig('acos', args),
266
+ },
267
+ ],
268
+ [
269
+ 'atan',
270
+ {
271
+ analyze: analyzeInverseTrig,
272
+ simplify: (_name, args) => simplifyInverseTrig('atan', args),
273
+ },
274
+ ],
275
+ [
276
+ 'atan2',
277
+ {
278
+ analyze: analyzeAtan2,
279
+ simplify: (_name, args) => simplifyAtan2(args),
280
+ },
281
+ ],
282
+ [
283
+ 'pow',
284
+ {
285
+ analyze: (args) => numberArguments(args, 2, 2),
286
+ simplify: (_name, args) => simplifyPow(args),
287
+ },
288
+ ],
289
+ [
290
+ 'sqrt',
291
+ {
292
+ analyze: (args) => numberArguments(args, 1, 1),
293
+ simplify: (_name, args) => simplifySqrt(args),
294
+ },
295
+ ],
296
+ [
297
+ 'hypot',
298
+ {
299
+ analyze: (args) => matchingArguments(args, 1, Infinity),
300
+ simplify: (_name, args) => simplifyHypot(args),
301
+ },
302
+ ],
303
+ [
304
+ 'log',
305
+ {
306
+ analyze: (args) => numberArguments(args, 1, 2),
307
+ simplify: (_name, args) => simplifyLog(args),
308
+ },
309
+ ],
310
+ [
311
+ 'exp',
312
+ {
313
+ analyze: (args) => numberArguments(args, 1, 1),
314
+ simplify: (_name, args) => simplifyExp(args),
315
+ },
316
+ ],
317
+ ])
318
+ );
319
+
320
+ /**
321
+ * @param {string} name
322
+ * @return {{normalizedName: string, definition: MathFunction} | undefined}
323
+ */
324
+ function lookupMathFunction(name) {
325
+ const normalizedName = name.toLowerCase();
326
+ const definition = mathFunctions.get(normalizedName);
327
+ return definition === undefined ? undefined : { normalizedName, definition };
328
+ }
329
+
330
+ const mathFunctionNames = [...mathFunctions.keys()];
331
+ const QUICK_MATH_TEST = new RegExp(
332
+ `(?:${mathFunctionNames.join('|')})\\(`,
333
+ 'i'
334
+ );
335
+
336
+ /** @param {string} name @return {boolean} */
337
+ function isCalculationFunction(name) {
338
+ return mathFunctions.get(name.toLowerCase())?.calculation === true;
339
+ }
340
+
341
+ /** @param {string} name @return {boolean} */
342
+ function isSupportedMathFunction(name) {
343
+ const normalizedName = name.toLowerCase();
344
+ const definition = mathFunctions.get(normalizedName);
345
+ return definition !== undefined && definition.calculation !== true;
346
+ }
347
+
348
+ /** @param {string} value @return {boolean} */
349
+ function hasPotentialMathFunction(value) {
350
+ return (
351
+ value.includes('(') && (QUICK_MATH_TEST.test(value) || value.includes('\\'))
352
+ );
353
+ }
354
+
355
+ export {
356
+ addTypes,
357
+ mathFunctions,
358
+ lookupMathFunction,
359
+ QUICK_MATH_TEST,
360
+ isFailure,
361
+ isCalculationFunction,
362
+ isSupportedMathFunction,
363
+ hasPotentialMathFunction,
364
+ };
@@ -0,0 +1,49 @@
1
+ /** @typedef {import('./node.js').Node} Node */
2
+
3
+ const MAX_CALCULATION_DEPTH = 1024;
4
+
5
+ /** @param {number} depth @return {void} */
6
+ function assertDepth(depth) {
7
+ if (depth > MAX_CALCULATION_DEPTH) {
8
+ throw new Error(
9
+ `Calculation nesting exceeds the limit of ${MAX_CALCULATION_DEPTH}`
10
+ );
11
+ }
12
+ }
13
+
14
+ /** @param {unknown} part @param {number} depth @return {void} */
15
+ function checkOpaquePartDepth(part, depth) {
16
+ assertDepth(depth);
17
+ if (typeof part === 'string') return;
18
+ if (Array.isArray(part)) {
19
+ for (const child of part) checkOpaquePartDepth(child, depth + 1);
20
+ return;
21
+ }
22
+ checkCalculationDepth(/** @type {Node} */ (part), depth + 1);
23
+ }
24
+
25
+ /** @param {Node} node @param {number} [depth] @return {void} */
26
+ function checkCalculationDepth(node, depth = 0) {
27
+ assertDepth(depth);
28
+ switch (node.type) {
29
+ case 'Sum':
30
+ for (const term of node.terms) {
31
+ checkCalculationDepth(term.node, depth + 1);
32
+ }
33
+ return;
34
+ case 'Product':
35
+ for (const factor of node.factors) {
36
+ checkCalculationDepth(factor.node, depth + 1);
37
+ }
38
+ return;
39
+ case 'Call':
40
+ for (const child of node.args) checkCalculationDepth(child, depth + 1);
41
+ return;
42
+ case 'OpaqueCall':
43
+ for (const part of node.components) {
44
+ checkOpaquePartDepth(part, depth + 1);
45
+ }
46
+ }
47
+ }
48
+
49
+ export { MAX_CALCULATION_DEPTH, assertDepth, checkCalculationDepth };
package/src/lib/node.js CHANGED
@@ -11,19 +11,25 @@
11
11
  // - No Product directly contains another Product (flattened).
12
12
  // - A Sum/Product with one positive element collapses to that element.
13
13
  // - A Sum/Product with no elements collapses to Num(0) / Num(1).
14
- // - Zero-valued Nums are dropped from sums (they contribute nothing).
14
+ // - Positive zero-valued Nums are dropped from all-number sums. They are
15
+ // retained in mixed or unresolved sums because they constrain the other
16
+ // terms to <number>. Negative zero is retained until calculation
17
+ // evaluation has finished, because it is an IEEE-754 value with observable
18
+ // math-function behavior.
15
19
  // Zero-valued Dims are kept — the unit carries type info.
16
20
 
17
21
  /**
18
22
  * @typedef {{type: 'Num', value: number}} Num
19
- * @typedef {{type: 'Dim', value: number, unit: string}} Dim
20
- * @typedef {{type: 'Ident', name: string}} Ident
21
- * @typedef {{type: 'Call', name: string, args: Node[]}} Call
23
+ * @typedef {{type: 'Dim', value: number, unit: string, rawUnit?: string}} Dim
24
+ * @typedef {{type: 'Ident', name: string, rawName?: string}} Ident
25
+ * @typedef {{type: 'Call', name: string, args: Node[], rawName?: string}} Call
26
+ * @typedef {string | Node | OpaqueComponent[]} OpaqueComponent
27
+ * @typedef {{type: 'OpaqueCall', name: string, components: OpaqueComponent[], rawName?: string}} OpaqueCall
22
28
  * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim.
23
29
  * @typedef {{type: 'Sum', terms: SumTerm[], grouped?: boolean}} Sum
24
30
  * @typedef {{exponent: 1 | -1, node: Node}} ProductFactor exponent +1 = numerator, -1 = denominator.
25
31
  * @typedef {{type: 'Product', factors: ProductFactor[]}} Product
26
- * @typedef {Num | Dim | Ident | Call | Sum | Product} Node
32
+ * @typedef {Num | Dim | Ident | Call | OpaqueCall | Sum | Product} Node
27
33
  */
28
34
 
29
35
  /**
@@ -37,27 +43,48 @@ function num(value) {
37
43
  /**
38
44
  * @param {number} value
39
45
  * @param {string} unit
46
+ * @param {string} [rawUnit]
40
47
  * @return {Dim}
41
48
  */
42
- function dim(value, unit) {
43
- return { type: 'Dim', value, unit };
49
+ function dim(value, unit, rawUnit) {
50
+ return rawUnit === undefined
51
+ ? { type: 'Dim', value, unit }
52
+ : { type: 'Dim', value, unit, rawUnit };
44
53
  }
45
54
 
46
55
  /**
47
56
  * @param {string} name
57
+ * @param {string} [rawName]
48
58
  * @return {Ident}
49
59
  */
50
- function ident(name) {
51
- return { type: 'Ident', name };
60
+ function ident(name, rawName) {
61
+ return rawName === undefined
62
+ ? { type: 'Ident', name }
63
+ : { type: 'Ident', name, rawName };
52
64
  }
53
65
 
54
66
  /**
55
67
  * @param {string} name
56
68
  * @param {Node[]} args
69
+ * @param {string} [rawName]
57
70
  * @return {Call}
58
71
  */
59
- function call(name, args) {
60
- return { type: 'Call', name, args };
72
+ function call(name, args, rawName) {
73
+ return rawName === undefined
74
+ ? { type: 'Call', name, args }
75
+ : { type: 'Call', name, args, rawName };
76
+ }
77
+
78
+ /**
79
+ * @param {string} name
80
+ * @param {OpaqueComponent[]} components
81
+ * @param {string} [rawName]
82
+ * @return {OpaqueCall}
83
+ */
84
+ function opaqueCall(name, components, rawName) {
85
+ return rawName === undefined
86
+ ? { type: 'OpaqueCall', name, components }
87
+ : { type: 'OpaqueCall', name, components, rawName };
61
88
  }
62
89
 
63
90
  /**
@@ -67,13 +94,32 @@ function call(name, args) {
67
94
  function mkSum(rawTerms) {
68
95
  /** @type {SumTerm[]} */
69
96
  const flat = [];
97
+ let hasNegativeZero = false;
70
98
  for (const t of rawTerms) {
71
- pushSumTerm(flat, t);
99
+ if (pushSumTerm(flat, t)) hasNegativeZero = true;
72
100
  }
73
- if (flat.length === 0) {
101
+ const zeroIsTypeAnchor = flat.some((term) => term.node.type !== 'Num');
102
+ // `+0 + -0` evaluates to +0. Keep positive zero terms when the sum also
103
+ // contains -0 so simplification can perform that IEEE-754 operation before
104
+ // the canonical zero-elision below.
105
+ let length = 0;
106
+ for (let i = 0; i < flat.length; i++) {
107
+ const term = flat[i];
108
+ if (
109
+ !hasNegativeZero &&
110
+ !zeroIsTypeAnchor &&
111
+ term.node.type === 'Num' &&
112
+ term.node.value === 0
113
+ ) {
114
+ continue;
115
+ }
116
+ flat[length++] = term;
117
+ }
118
+ flat.length = length;
119
+ if (length === 0) {
74
120
  return num(0);
75
121
  }
76
- if (flat.length === 1 && flat[0].sign === 1) {
122
+ if (length === 1 && flat[0].sign === 1) {
77
123
  return flat[0].node;
78
124
  }
79
125
  return { type: 'Sum', terms: flat };
@@ -82,39 +128,35 @@ function mkSum(rawTerms) {
82
128
  /**
83
129
  * @param {SumTerm[]} out
84
130
  * @param {SumTerm} term
85
- * @return {void}
131
+ * @return {boolean} Whether the appended terms contain negative zero.
86
132
  */
87
133
  function pushSumTerm(out, term) {
88
134
  let { sign, node } = term;
89
135
 
90
136
  if (node.type === 'Sum' && !node.grouped) {
137
+ let hasNegativeZero = false;
91
138
  for (const inner of node.terms) {
92
- pushSumTerm(out, {
93
- sign: /** @type {1 | -1} */ (sign * inner.sign),
94
- node: inner.node,
95
- });
139
+ if (
140
+ pushSumTerm(out, {
141
+ sign: /** @type {1 | -1} */ (sign * inner.sign),
142
+ node: inner.node,
143
+ })
144
+ ) {
145
+ hasNegativeZero = true;
146
+ }
96
147
  }
97
- return;
148
+ return hasNegativeZero;
98
149
  }
99
150
 
100
151
  // sign=-1 around a Num/Dim leaf collapses into the value's sign — the
101
152
  // canonical-form rule downstream code relies on.
102
- if (sign === -1) {
103
- if (node.type === 'Num') {
104
- node = num(-node.value);
105
- sign = 1;
106
- } else if (node.type === 'Dim') {
107
- node = dim(-node.value, node.unit);
108
- sign = 1;
109
- }
110
- }
111
-
112
- // Drop zero-valued Nums. Dims with value 0 stay — the unit carries type.
113
- if (node.type === 'Num' && node.value === 0) {
114
- return;
153
+ if (sign === -1 && (node.type === 'Num' || node.type === 'Dim')) {
154
+ node = negate(node);
155
+ sign = 1;
115
156
  }
116
157
 
117
158
  out.push({ sign, node });
159
+ return node.type === 'Num' && Object.is(node.value, -0);
118
160
  }
119
161
 
120
162
  /**
@@ -169,22 +211,26 @@ function negate(node) {
169
211
  return num(-node.value);
170
212
  }
171
213
  if (node.type === 'Dim') {
172
- return dim(-node.value, node.unit);
214
+ return dim(-node.value, node.unit, node.rawUnit);
173
215
  }
174
216
  if (node.type === 'Sum') {
217
+ // A grouped sum may contain opaque terms whose meaning depends on the
218
+ // surrounding context. Keep the group intact so `-(a + b)` cannot turn
219
+ // into `-a - b` while it is still unresolved.
220
+ if (node.grouped) {
221
+ return mkSum([{ sign: -1, node }]);
222
+ }
175
223
  const result = mkSum(
176
224
  node.terms.map((t) => ({
177
225
  sign: /** @type {1 | -1} */ (-t.sign),
178
226
  node: t.node,
179
227
  }))
180
228
  );
181
- return node.grouped && result.type === 'Sum'
182
- ? { ...result, grouped: true }
183
- : result;
229
+ return result;
184
230
  }
185
- // Opaque (Ident, Call, Product): wrap as a single negative-sign term —
231
+ // Opaque (Ident, Call, OpaqueCall, Product): wrap as a single negative-sign term —
186
232
  // the only case where sign=-1 remains on a SumTerm.
187
233
  return mkSum([{ sign: -1, node }]);
188
234
  }
189
235
 
190
- export { num, dim, ident, call, mkSum, mkProduct, negate };
236
+ export { num, dim, ident, call, opaqueCall, mkSum, mkProduct, negate };
@@ -0,0 +1,20 @@
1
+ /** @typedef {import('./node.js').Node} Node */
2
+ /** @typedef {import('./node.js').OpaqueComponent} OpaqueComponent */
3
+
4
+ /** @param {OpaqueComponent[]} components @param {(node: Node) => Node} simplify @return {OpaqueComponent[]} */
5
+ function simplifyComponents(components, simplify) {
6
+ return components.map((part) => {
7
+ if (typeof part === 'string') return part;
8
+ if (Array.isArray(part)) return simplifyComponents(part, simplify);
9
+ return simplify(part);
10
+ });
11
+ }
12
+ /** @param {OpaqueComponent[]} components @param {string[]} buffer @param {(node: Node, buffer: string[]) => void} serialize @return {void} */
13
+ function serializeComponents(components, buffer, serialize) {
14
+ for (const part of components) {
15
+ if (typeof part === 'string') buffer.push(part);
16
+ else if (Array.isArray(part)) serializeComponents(part, buffer, serialize);
17
+ else serialize(part, buffer);
18
+ }
19
+ }
20
+ export { simplifyComponents, serializeComponents };