postcss-calc 11.2.0 → 11.2.2

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 (40) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +44 -16
  3. package/package.json +13 -13
  4. package/src/index.js +2 -1
  5. package/src/lib/analyze.js +81 -43
  6. package/src/lib/compile.js +1 -1
  7. package/src/lib/functions.js +40 -10
  8. package/src/lib/node.js +62 -5
  9. package/src/lib/parser/opaque.js +151 -0
  10. package/src/lib/parser/tokens.js +242 -0
  11. package/src/lib/parser.js +27 -382
  12. package/src/lib/serialize/expression.js +376 -0
  13. package/src/lib/serialize/precision.js +244 -0
  14. package/src/lib/serialize.js +24 -517
  15. package/src/lib/simplify/bucket.js +22 -5
  16. package/src/lib/simplify/cancel.js +35 -11
  17. package/src/lib/simplify/clamp.js +2 -2
  18. package/src/lib/simplify/exact.js +18 -0
  19. package/src/lib/simplify/fold.js +15 -1
  20. package/src/lib/simplify/min-max.js +2 -2
  21. package/src/lib/simplify/product.js +263 -48
  22. package/src/lib/simplify/sum.js +6 -2
  23. package/src/lib/simplify.js +22 -5
  24. package/src/reduce.js +2 -1
  25. package/types/lib/analyze.d.ts +27 -0
  26. package/types/lib/functions.d.ts +19 -1
  27. package/types/lib/node.d.ts +17 -2
  28. package/types/lib/parser/opaque.d.ts +12 -0
  29. package/types/lib/parser/tokens.d.ts +83 -0
  30. package/types/lib/parser.d.ts +4 -44
  31. package/types/lib/serialize/expression.d.ts +95 -0
  32. package/types/lib/serialize/precision.d.ts +101 -0
  33. package/types/lib/simplify/bucket.d.ts +4 -1
  34. package/types/lib/simplify/cancel.d.ts +11 -7
  35. package/types/lib/simplify/exact.d.ts +9 -0
  36. package/types/lib/simplify/fold.d.ts +14 -1
  37. package/types/lib/simplify/product.d.ts +3 -7
  38. package/types/lib/simplify/sum.d.ts +2 -1
  39. package/types/lib/simplify.d.ts +3 -8
  40. package/src/lib/calculation-type.js +0 -25
@@ -2,9 +2,22 @@
2
2
  // Outer calc() is added when the top-level result contains an arithmetic
3
3
  // operator, or when a finite scalar needs context-sensitive CSS semantics.
4
4
 
5
- import { serializeComponents } from './opaque.js';
6
5
  import { checkCalculationDepth } from './limits.js';
7
6
  import { isCalculationFunction } from './functions.js';
7
+ import {
8
+ isScalar,
9
+ serializeNumber,
10
+ roundedScalarValue,
11
+ isDegenerate,
12
+ degenerateKeyword,
13
+ } from './serialize/precision.js';
14
+ import {
15
+ emitCall,
16
+ emitOpaqueCall,
17
+ emitMathResult,
18
+ emitNode,
19
+ emitLeadingNeg,
20
+ } from './serialize/expression.js';
8
21
 
9
22
  /**
10
23
  * @typedef {import('./node.js').Node} Node
@@ -18,101 +31,6 @@ import { isCalculationFunction } from './functions.js';
18
31
  * @property {boolean} [unwrapSingleValue] Serialize fully resolved finite scalar results without calculation syntax.
19
32
  */
20
33
 
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;
29
- const NOISE_FLOOR = 1e-12;
30
-
31
- /**
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.
39
- *
40
- * @param {number} v
41
- * @param {number | false} prec
42
- * @return {number}
43
- */
44
- function round(v, prec) {
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
- }
85
- }
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)));
91
- }
92
- return rounded;
93
- }
94
-
95
- // §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords.
96
- /** @param {number} v @return {boolean} */
97
- function isDegenerate(v) {
98
- return !Number.isFinite(v) || Number.isNaN(v);
99
- }
100
-
101
- /** @param {number} v @return {string} */
102
- function degenerateKeyword(v) {
103
- if (Number.isNaN(v)) return 'NaN';
104
- return v > 0 ? 'infinity' : '-infinity';
105
- }
106
-
107
- /** @param {number} v @return {string} */
108
- function serializeNumber(v) {
109
- if (Object.is(v, -0)) return '0';
110
- const text = String(v);
111
- if (text.startsWith('0.')) return text.slice(1);
112
- if (text.startsWith('-0.')) return `-${text.slice(2)}`;
113
- return text;
114
- }
115
-
116
34
  /** @param {SerializeOptions} opts @return {'standard' | 'unwrap-all'} */
117
35
  function normalizeScalarPolicy(opts) {
118
36
  return opts.unwrapSingleValue || opts.unwrapSingleNegativeNumber
@@ -120,412 +38,6 @@ function normalizeScalarPolicy(opts) {
120
38
  : 'standard';
121
39
  }
122
40
 
123
- /**
124
- * @param {import('./node.js').Num | import('./node.js').Dim} node
125
- * @param {number | false} precision
126
- * @param {number} [value]
127
- * @return {number}
128
- */
129
- function roundedScalarValue(node, precision, value) {
130
- return round(value ?? node.value, precision);
131
- }
132
-
133
- /**
134
- * @param {import('./node.js').Num | import('./node.js').Dim} node
135
- * @param {string[]} buffer
136
- * @param {number} value
137
- * @return {void}
138
- */
139
- function emitRoundedScalar(node, buffer, value) {
140
- buffer.push(serializeNumber(value));
141
- if (node.type === 'Dim') {
142
- buffer.push(node.rawUnit ?? node.unit);
143
- }
144
- }
145
-
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));
178
- }
179
- } else emitFiniteScalar(node, session, value);
180
- }
181
-
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
- }
191
-
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
- }
196
-
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
- }
201
-
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
- }
208
-
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
- );
220
- }
221
-
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
- }
246
-
247
- /**
248
- * @param {Node} node
249
- * @param {ReturnType<typeof makeContext>} session
250
- * @param {number} [scalarValueOverride]
251
- * @return {void}
252
- */
253
- function emitNodeBody(node, session, scalarValueOverride) {
254
- const buffer = session.buffer;
255
- switch (node.type) {
256
- case 'Num':
257
- case 'Dim':
258
- emitScalar(node, session, scalarValueOverride);
259
- return;
260
- case 'Ident':
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;
269
- case 'Sum':
270
- emitSum(node, session);
271
- return;
272
- case 'Product':
273
- emitProduct(node, session);
274
- return;
275
- }
276
- }
277
-
278
- /**
279
- * @param {import('./node.js').Call} node
280
- * @param {ReturnType<typeof makeContext>} session
281
- * @param {string} [callNameOverride]
282
- * @return {void}
283
- */
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);
290
- }
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);
338
- }
339
- }
340
-
341
- /**
342
- * @param {import('./node.js').SumTerm[]} terms
343
- * @param {ReturnType<typeof makeContext>} session
344
- * @param {1 | -1} [multiplier]
345
- * @return {void}
346
- */
347
- function emitSumTerms(terms, session, multiplier = 1) {
348
- const buffer = session.buffer;
349
- for (let i = 0; i < terms.length; 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;
358
- if (i === 0) {
359
- if (scalar) {
360
- if (sign === -1) buffer.push('-');
361
- emitScalar(termNode, session, scalarValueOverride);
362
- } else {
363
- emitSumTerm(term, session, sign, scalarValueOverride);
364
- }
365
- continue;
366
- }
367
- buffer.push(sign === 1 ? ' + ' : ' - ');
368
- if (scalar) {
369
- emitScalar(termNode, session, scalarValueOverride);
370
- } else {
371
- emitNode(termNode, session, SUM_PRECEDENCE, true);
372
- }
373
- }
374
- }
375
-
376
- /** @param {Sum} sum @param {ReturnType<typeof makeContext>} session @return {void} */
377
- function emitSum(sum, session) {
378
- emitSumTerms(sum.terms, session);
379
- }
380
-
381
- /**
382
- * @param {Node} node
383
- * @param {ReturnType<typeof makeContext>} session
384
- * @param {number} [scalarValueOverride]
385
- * @return {void}
386
- */
387
- function emitLeadingNeg(node, session, scalarValueOverride) {
388
- if (
389
- node.type === 'Product' &&
390
- node.factors.length > 0 &&
391
- node.factors[0].exponent === 1 &&
392
- node.factors[0].node.type === 'Num' &&
393
- Number.isFinite(node.factors[0].node.value) &&
394
- node.factors[0].node.value !== 0
395
- ) {
396
- const head = node.factors[0].node;
397
- emitProductFactors(node.factors, session, 1, -head.value, head);
398
- return;
399
- }
400
- session.buffer.push('-');
401
- emitNode(
402
- node,
403
- session,
404
- UNARY_PRECEDENCE,
405
- false,
406
- isScalar(node) ? scalarValueOverride : undefined
407
- );
408
- }
409
-
410
- /**
411
- * @param {ProductFactor[]} factors
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
41
  /**
530
42
  * Serialize a scalar result without allocating a render context or buffer.
531
43
  * @param {import('./node.js').Num | import('./node.js').Dim} node
@@ -544,20 +56,6 @@ function serializeScalarResult(node, precision, scalarPolicy, wrapper) {
544
56
  return scalarPolicy === 'standard' ? `${wrapper}(${scalar})` : scalar;
545
57
  }
546
58
 
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
59
  /**
562
60
  * @param {SerializeOptions} opts
563
61
  * @return {{buffer: string[], precision: number | false, scalarPolicy: 'standard' | 'unwrap-all'}}
@@ -623,10 +121,19 @@ function planSerializeResult(result, opts) {
623
121
  return { kind: 'math', node: result.tree, session, wrapper };
624
122
  }
625
123
 
124
+ /** @param {Node} node @param {ReturnType<typeof makeContext>} session @return {void} */
125
+ function emitRootExpr(node, session) {
126
+ if (node.type === 'Sum' && node.terms.length === 1) {
127
+ emitLeadingNeg(node.terms[0].node, session);
128
+ return;
129
+ }
130
+ emitNode(node, session);
131
+ }
132
+
626
133
  /**
627
134
  * @param {ReturnType<typeof planSerialize> | ReturnType<typeof planSerializeResult>} renderSpec
628
135
  * @return {string}
629
- * */
136
+ */
630
137
  function emitOutput(renderSpec) {
631
138
  if (renderSpec.kind === 'original') return renderSpec.text;
632
139
  const { session } = renderSpec;
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { convert } from '../convertUnits.js';
8
+ import { isExact } from './exact.js';
8
9
 
9
10
  /**
10
11
  * @typedef {object} UnitBucket
@@ -18,9 +19,12 @@ import { convert } from '../convertUnits.js';
18
19
  /** Mutates `buckets` in place — totals of survivor buckets accumulate the
19
20
  * converted values of merged neighbors. Caller must not reuse the input.
20
21
  * @param {UnitBucket[]} buckets
22
+ * @param {number | false} [precision] A conversion that is not exact at this
23
+ * precision is not merged. When only the reverse direction is exact, the
24
+ * survivor switches to the other bucket's unit.
21
25
  * @return {UnitBucket[]}
22
26
  */
23
- function mergeConvertibleBuckets(buckets) {
27
+ function mergeConvertibleBuckets(buckets, precision = false) {
24
28
  /** @type {Map<import('../convertUnits.js').BaseType, UnitBucket>} */ const representative =
25
29
  new Map();
26
30
  /** @type {UnitBucket[]} */ const out = [];
@@ -36,12 +40,25 @@ function mergeConvertibleBuckets(buckets) {
36
40
  continue;
37
41
  }
38
42
  const converted = convert(b.total, b.unit, first.unit);
39
- if (converted === null) {
40
- out.push(b);
43
+ if (converted !== null && isExact(converted, precision)) {
44
+ first.total += converted;
45
+ first.scale = Math.max(first.scale, Math.abs(converted));
46
+ continue;
47
+ }
48
+ const reversed = convert(first.total, first.unit, b.unit);
49
+ const reversedScale = convert(first.scale, first.unit, b.unit);
50
+ if (
51
+ reversed !== null &&
52
+ reversedScale !== null &&
53
+ isExact(reversed, precision)
54
+ ) {
55
+ first.unit = b.unit;
56
+ first.rawUnit = b.rawUnit;
57
+ first.total = reversed + b.total;
58
+ first.scale = Math.max(reversedScale, Math.abs(b.total));
41
59
  continue;
42
60
  }
43
- first.total += converted;
44
- first.scale = Math.max(first.scale, Math.abs(converted));
61
+ out.push(b);
45
62
  }
46
63
  return out;
47
64
  }
@@ -1,17 +1,21 @@
1
1
  import { baseOf, convert } from '../convertUnits.js';
2
+ import { isExact } from './exact.js';
2
3
 
3
4
  /**
4
5
  * If `dims` contain exactly one numerator / one denominator pair with the
5
- * same base type and convertible units, return the numeric factor produced
6
- * by cancelling them and the list of remaining (uncancelled) dims.
7
- * Otherwise return null. Used by `simplifyProduct` for typed division
8
- * (§10.2). More complex cancellation (e.g. `px^2 / px`) is left
6
+ * same base type and convertible units, return the two sides expressed in a
7
+ * common unit, so the caller can fold them into its own numerator and
8
+ * denominator. Otherwise return null. Used by `simplifyProduct` for typed
9
+ * division (§10.2). More complex cancellation (e.g. `px^2 / px`) is left
9
10
  * unreduced — consumers rarely rely on it and the spec doesn't require it.
10
11
  * @template {{ exponent: 1 | -1, value: number, unit: string }} D
11
12
  * @param {D[]} dims
12
- * @return {{ factor: number, remaining: D[] } | null}
13
+ * @param {number | false} [precision] The denominator is converted into the
14
+ * numerator's unit when that is exact at this precision (`1px / 1in` →
15
+ * `1 / 96`); otherwise the numerator is converted (`1in / 1px` → `96 / 1`).
16
+ * @return {{ numerator: number, denominator: number, remaining: D[] } | null}
13
17
  */
14
- function tryCancelPair(dims) {
18
+ function tryCancelPair(dims, precision = false) {
15
19
  if (dims.length !== 2) {
16
20
  return null;
17
21
  }
@@ -26,12 +30,32 @@ function tryCancelPair(dims) {
26
30
  if (!numBase || numBase !== denBase) {
27
31
  return null;
28
32
  }
29
- const converted = convert(numerator.value, numerator.unit, denominator.unit);
30
- if (converted === null) {
31
- return null;
32
- }
33
+ const converted = convert(
34
+ denominator.value,
35
+ denominator.unit,
36
+ numerator.unit
37
+ );
33
38
  // denominator.value === 0 yields ±Infinity / NaN naturally (§10.9.1).
34
- return { factor: converted / denominator.value, remaining: [] };
39
+ if (converted !== null && isExact(converted, precision)) {
40
+ return {
41
+ numerator: numerator.value,
42
+ denominator: converted,
43
+ remaining: [],
44
+ };
45
+ }
46
+ const numConverted = convert(
47
+ numerator.value,
48
+ numerator.unit,
49
+ denominator.unit
50
+ );
51
+ if (numConverted !== null && isExact(numConverted, precision)) {
52
+ return {
53
+ numerator: numConverted,
54
+ denominator: denominator.value,
55
+ remaining: [],
56
+ };
57
+ }
58
+ return null;
35
59
  }
36
60
 
37
61
  export { tryCancelPair };