postcss-calc 11.2.0 → 11.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-calc",
3
- "version": "11.2.0",
3
+ "version": "11.2.1",
4
4
  "type": "module",
5
5
  "description": "PostCSS plugin to reduce calc()",
6
6
  "keywords": [
@@ -42,16 +42,16 @@
42
42
  }
43
43
  },
44
44
  "devDependencies": {
45
- "@csstools/css-calc": "^3.3.0",
46
- "@types/node": "^26.5.1",
47
- "fast-check": "^4.10.0",
45
+ "@csstools/css-calc": "^3.4.0",
46
+ "@types/node": "^26.6.1",
47
+ "fast-check": "^4.10.1",
48
48
  "oxfmt": "^0.68.0",
49
49
  "oxlint": "^1.83.0",
50
50
  "postcss": "^8.5.28",
51
51
  "typescript": "~7.0.2"
52
52
  },
53
53
  "dependencies": {
54
- "@csstools/css-tokenizer": "^4.0.0"
54
+ "@csstools/css-tokenizer": "^4.0.1"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "postcss": "^8.5.28"
@@ -1,16 +1,32 @@
1
1
  import { baseOf } from './convertUnits.js';
2
- import { addTypes, isFailure, mathFunctions } from './functions.js';
2
+ import {
3
+ addTypes,
4
+ failureType,
5
+ isFailure,
6
+ isPercentage,
7
+ mathFunctions,
8
+ numberType,
9
+ percentageType,
10
+ unknownType,
11
+ } from './functions.js';
3
12
  import { assertDepth } from './limits.js';
4
13
 
5
14
  /** @typedef {import('./node.js').Node} Node */
6
15
  /** @typedef {import('./functions.js').CalculationType} CalculationType */
16
+ /** @typedef {Extract<CalculationType, {kind: 'dimension'}>} DimensionType */
7
17
 
8
18
  /** @typedef {'number' | 'unknown' | {dimension: string | null}} AnalysisType */
9
19
  /** @typedef {{type: AnalysisType, valid: boolean, unresolved: boolean}} Analysis */
10
-
11
- /** @type {CalculationType} */ const numberType = { kind: 'number' };
12
- /** @type {CalculationType} */ const unknownType = { kind: 'unknown' };
13
- /** @type {CalculationType} */ const failureType = { kind: 'failure' };
20
+ /**
21
+ * @typedef {Object} ProductFactors
22
+ * @property {boolean} valid
23
+ * @property {boolean} structurallyValid
24
+ * @property {boolean} hasUnresolved
25
+ * @property {DimensionType | null} numerator
26
+ * @property {DimensionType | null} denominator
27
+ * @property {boolean} hasOpaqueNumerator
28
+ * @property {boolean} hasOpaqueDenominator
29
+ */
14
30
 
15
31
  /**
16
32
  * Analyze the original complete tree and return its root summary. Analysis
@@ -34,10 +50,11 @@ function analyzeType(node, depth = 0) {
34
50
  case 'Num':
35
51
  return resolved(numberType);
36
52
  case 'Dim':
37
- // Percentages are contextual. Unknown units are opaque, while known
38
- // families can still reject px + seconds.
53
+ // Percentages are contextual; their percent-ness is tracked so a
54
+ // `% / %` product cancels to a number. Unknown units are opaque, while
55
+ // known families can still reject px + seconds.
39
56
  return node.unit === '%'
40
- ? finish(unknownType, true, true)
57
+ ? finish(percentageType, true, true)
41
58
  : resolved({ kind: 'dimension', base: baseOf(node.unit) });
42
59
  case 'Ident':
43
60
  return markUnresolved(unknownType);
@@ -56,6 +73,7 @@ function analyzeType(node, depth = 0) {
56
73
  function analyzeSum(node, depth) {
57
74
  let type = null;
58
75
  let hasUnknown = false;
76
+ let hasPercentage = false;
59
77
  let valid = true;
60
78
  let hasUnresolved = false;
61
79
  for (const term of node.terms) {
@@ -65,7 +83,11 @@ function analyzeSum(node, depth) {
65
83
  if (isFailure(child.type)) {
66
84
  type = failureType;
67
85
  } else if (child.type.kind === 'unknown') {
68
- hasUnknown = true;
86
+ // A pure percentage sum stays percentage-typed so a surrounding
87
+ // product can cancel `% / %`; any other opaque term must widen the
88
+ // sum back to unknown.
89
+ if (isPercentage(child.type)) hasPercentage = true;
90
+ else hasUnknown = true;
69
91
  } else if (type === null) {
70
92
  type = child.type;
71
93
  } else if (!isFailure(type)) {
@@ -75,11 +97,10 @@ function analyzeSum(node, depth) {
75
97
  if (type !== null && isFailure(type)) {
76
98
  return finish(failureType, false, hasUnresolved);
77
99
  }
78
- return finish(
79
- type ?? (hasUnknown ? unknownType : numberType),
80
- valid,
81
- hasUnresolved
82
- );
100
+ let fallback = numberType;
101
+ if (hasUnknown) fallback = unknownType;
102
+ else if (hasPercentage) fallback = percentageType;
103
+ return finish(type ?? fallback, valid, hasUnresolved);
83
104
  }
84
105
 
85
106
  /** @param {Extract<Node, {type: 'Product'}>} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */
@@ -88,8 +109,13 @@ function analyzeProduct(node, depth) {
88
109
  let denominator = null;
89
110
  let valid = true;
90
111
  let structurallyValid = true;
91
- let hasUnknownNumerator = false;
92
- let hasUnknownDenominator = false;
112
+ // Opaque factors (unknowns and pure percentages) are counted per side so
113
+ // the pass below can decide whether any unknown remains after cancelling
114
+ // `% / %` pairs.
115
+ let opaqueNumerator = 0;
116
+ let opaqueDenominator = 0;
117
+ let percentageNumerator = 0;
118
+ let percentageDenominator = 0;
93
119
  let hasUnresolved = false;
94
120
  for (const factor of node.factors) {
95
121
  const child = analyzeType(factor.node, depth + 1);
@@ -99,8 +125,13 @@ function analyzeProduct(node, depth) {
99
125
  continue;
100
126
  }
101
127
  if (child.type.kind === 'unknown') {
102
- if (factor.exponent === 1) hasUnknownNumerator = true;
103
- else hasUnknownDenominator = true;
128
+ if (factor.exponent === 1) {
129
+ opaqueNumerator++;
130
+ if (isPercentage(child.type)) percentageNumerator++;
131
+ } else {
132
+ opaqueDenominator++;
133
+ if (isPercentage(child.type)) percentageDenominator++;
134
+ }
104
135
  continue;
105
136
  }
106
137
  if (child.type.kind !== 'dimension') continue;
@@ -112,10 +143,36 @@ function analyzeProduct(node, depth) {
112
143
  else denominator = child.type;
113
144
  }
114
145
  }
146
+ // A percentage divided by a percentage is always a plain number: both
147
+ // operands resolve in the same context, so their contextual type cancels.
148
+ // Consume one such pair before judging the remaining unknowns so a
149
+ // surrounding sum does not mistake `% / %` for a length-compatible term.
150
+ const cancelled = Math.min(percentageNumerator, percentageDenominator);
151
+ const hasOpaqueNumerator = opaqueNumerator - cancelled > 0;
152
+ const hasOpaqueDenominator = opaqueDenominator - cancelled > 0;
153
+ return finishProduct({
154
+ valid,
155
+ structurallyValid,
156
+ hasUnresolved,
157
+ numerator,
158
+ denominator,
159
+ hasOpaqueNumerator,
160
+ hasOpaqueDenominator,
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Classify a product once its factors are analyzed. Opaque factors can supply
166
+ * missing type information, but they cannot make an already-invalid
167
+ * combination of known dimensions valid.
168
+ * @param {ProductFactors} factors
169
+ * @return {{type: CalculationType, valid: boolean, unresolved: boolean}}
170
+ */
171
+ function finishProduct(factors) {
172
+ const { valid, structurallyValid, hasUnresolved } = factors;
115
173
  if (!valid) return finish(failureType, false, hasUnresolved);
116
- // Opaque factors can supply missing type information, but they cannot make
117
- // an already-invalid combination of known dimensions valid.
118
174
  if (!structurallyValid) return finish(failureType, false, hasUnresolved);
175
+ const { numerator, denominator } = factors;
119
176
  if (
120
177
  numerator !== null &&
121
178
  denominator !== null &&
@@ -127,18 +184,13 @@ function analyzeProduct(node, depth) {
127
184
  // only leave that dimension in place (when it resolves to a number) or make
128
185
  // the product invalid. It can never make the product a bare number. Keep
129
186
  // that known constraint so a surrounding sum can reject `1px * 1% + 1`.
130
- const constrained = constrainedNumerator(
131
- hasUnknownNumerator,
132
- hasUnknownDenominator,
133
- numerator,
134
- denominator
135
- );
187
+ const constrained = constrainedNumerator(factors);
136
188
  if (constrained !== null) {
137
189
  return finish(constrained, true, hasUnresolved);
138
190
  }
139
191
  // Other opaque factors may supply type information that changes how the
140
192
  // known dimensions combine once the known factors are structurally valid.
141
- if (hasUnknownNumerator || hasUnknownDenominator) {
193
+ if (factors.hasOpaqueNumerator || factors.hasOpaqueDenominator) {
142
194
  return finish(unknownType, true, hasUnresolved);
143
195
  }
144
196
  if (numerator !== null && denominator !== null) {
@@ -153,23 +205,15 @@ function analyzeProduct(node, depth) {
153
205
  }
154
206
 
155
207
  /**
156
- * @param {boolean} hasUnknownNumerator
157
- * @param {boolean} hasUnknownDenominator
158
- * @param {CalculationType | null} numerator
159
- * @param {CalculationType | null} denominator
160
- * @return {CalculationType | null}
208
+ * @param {ProductFactors} factors
209
+ * @return {DimensionType | null}
161
210
  */
162
- function constrainedNumerator(
163
- hasUnknownNumerator,
164
- hasUnknownDenominator,
165
- numerator,
166
- denominator
167
- ) {
168
- return hasUnknownNumerator &&
169
- !hasUnknownDenominator &&
170
- numerator !== null &&
171
- denominator === null
172
- ? numerator
211
+ function constrainedNumerator(factors) {
212
+ return factors.hasOpaqueNumerator &&
213
+ !factors.hasOpaqueDenominator &&
214
+ factors.numerator !== null &&
215
+ factors.denominator === null
216
+ ? factors.numerator
173
217
  : null;
174
218
  }
175
219
 
@@ -14,13 +14,23 @@ import { simplifyLog } from './simplify/log.js';
14
14
  import { simplifyHypot } from './simplify/hypot.js';
15
15
 
16
16
  /** @typedef {import('./node.js').Node} Node */
17
- /** @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown'} | {kind: 'failure'}} CalculationType */
17
+ /**
18
+ * `percent` marks a value that resolves in the same percentage context as its
19
+ * peers (a pure percentage). It is only produced at leaves, by abs(), and by
20
+ * homogeneous sums and calls — never by a product, where an unpaired
21
+ * percentage could no longer cancel against anything.
22
+ * @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown', percent?: true} | {kind: 'failure'}} CalculationType
23
+ */
18
24
  /** @typedef {(name: string, args: Node[]) => Node} MathSimplifier */
19
25
  /** @typedef {(args: CalculationType[], nodes: Node[]) => CalculationType} TypeAnalyzer */
20
26
  /** @typedef {{analyze: TypeAnalyzer, simplify?: MathSimplifier, isKeyword?: (node: Node, index: number) => boolean, calculation?: boolean}} MathFunction */
21
27
 
22
28
  /** @type {CalculationType} */ const numberType = { kind: 'number' };
23
29
  /** @type {CalculationType} */ const unknownType = { kind: 'unknown' };
30
+ /** @type {CalculationType} */ const percentageType = {
31
+ kind: 'unknown',
32
+ percent: true,
33
+ };
24
34
  /** @type {CalculationType} */ const failureType = { kind: 'failure' };
25
35
 
26
36
  /** @param {CalculationType} type @return {boolean} */
@@ -28,10 +38,23 @@ function isFailure(type) {
28
38
  return type.kind === 'failure';
29
39
  }
30
40
 
41
+ /** @param {CalculationType} type @return {boolean} */
42
+ function isPercentage(type) {
43
+ return type.kind === 'unknown' && type.percent === true;
44
+ }
45
+
31
46
  /** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */
32
47
  function addTypes(a, b) {
33
48
  if (isFailure(a) || isFailure(b)) return failureType;
34
- if (a.kind === 'unknown' || b.kind === 'unknown') return unknownType;
49
+ if (a.kind === 'unknown' || b.kind === 'unknown') {
50
+ // A pure percentage keeps its contextual type so a surrounding product
51
+ // can cancel `% / %`; mixing it with any other opaque operand loses the
52
+ // guarantee that it resolves in the same context as its peers. A known
53
+ // number + percentage sum is likewise kept unknown and valid: the spec
54
+ // resolves the percentage against its surrounding context, which this
55
+ // coarse type model does not track.
56
+ return isPercentage(a) && isPercentage(b) ? percentageType : unknownType;
57
+ }
35
58
  if (a.kind === 'number' && b.kind === 'number') return numberType;
36
59
  if (a.kind === 'dimension' && b.kind === 'dimension') {
37
60
  if (a.base === null || b.base === null) return unknownType;
@@ -129,9 +152,11 @@ function analyzeRound(args, nodes) {
129
152
  /** @param {CalculationType[]} args @return {CalculationType} */
130
153
  function analyzeAtan2(args) {
131
154
  const type = matchingArguments(args, 2, 2);
132
- return isFailure(type) || type.kind === 'unknown'
133
- ? type
134
- : { kind: 'dimension', base: 'angle' };
155
+ if (isFailure(type)) return type;
156
+ // The type table gives atan2() «["angle" → 1]»; an unresolved result is
157
+ // plain unknown, never the percentage of its arguments.
158
+ if (type.kind === 'unknown') return unknownType;
159
+ return { kind: 'dimension', base: 'angle' };
135
160
  }
136
161
 
137
162
  /** @param {CalculationType[]} args @return {CalculationType} */
@@ -354,11 +379,16 @@ function hasPotentialMathFunction(value) {
354
379
 
355
380
  export {
356
381
  addTypes,
357
- mathFunctions,
358
- lookupMathFunction,
359
- QUICK_MATH_TEST,
360
- isFailure,
382
+ failureType,
383
+ hasPotentialMathFunction,
361
384
  isCalculationFunction,
385
+ isFailure,
386
+ isPercentage,
362
387
  isSupportedMathFunction,
363
- hasPotentialMathFunction,
388
+ lookupMathFunction,
389
+ mathFunctions,
390
+ numberType,
391
+ percentageType,
392
+ QUICK_MATH_TEST,
393
+ unknownType,
364
394
  };
package/src/lib/node.js CHANGED
@@ -215,7 +215,7 @@ function negate(node) {
215
215
  }
216
216
  if (node.type === 'Sum') {
217
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
218
+ // surrounding context. Keep the group intact so `-1 * (a + b)` cannot turn
219
219
  // into `-a - b` while it is still unresolved.
220
220
  if (node.grouped) {
221
221
  return mkSum([{ sign: -1, node }]);
package/src/lib/parser.js CHANGED
@@ -1,16 +1,7 @@
1
1
  // Pratt parser over native @csstools/css-tokenizer tokens.
2
2
  import { TokenType as CssType } from '@csstools/css-tokenizer';
3
3
  import { baseOf } from './convertUnits.js';
4
- import {
5
- call,
6
- dim,
7
- ident,
8
- mkProduct,
9
- mkSum,
10
- negate,
11
- num,
12
- opaqueCall,
13
- } from './node.js';
4
+ import { call, dim, ident, mkProduct, mkSum, num, opaqueCall } from './node.js';
14
5
  import { isCalculationFunction, isSupportedMathFunction } from './functions.js';
15
6
  import { assertDepth } from './limits.js';
16
7
  import { CSS_NUMBER_PREFIX } from './regex.js';
@@ -293,11 +284,10 @@ function parsePrefix(input, cursor, token, depth) {
293
284
  ? { ...expression, grouped: true }
294
285
  : expression;
295
286
  }
296
- case '-':
297
- return negate(parseExpr(input, cursor, 7, depth + 1));
298
- case '+':
299
- return parseExpr(input, cursor, 7, depth + 1);
300
287
  }
288
+ // No unary `+`/`-` production exists in the <calc-value> grammar; a
289
+ // sign is only valid inside a number/dimension token or as a binary
290
+ // operator. `-(...)` therefore fails to parse and is preserved.
301
291
  }
302
292
  throw new Error(`Unexpected token "${token.raw}" at position ${token.pos}`);
303
293
  }
@@ -5,6 +5,7 @@
5
5
  import { serializeComponents } from './opaque.js';
6
6
  import { checkCalculationDepth } from './limits.js';
7
7
  import { isCalculationFunction } from './functions.js';
8
+ import { num } from './node.js';
8
9
 
9
10
  /**
10
11
  * @typedef {import('./node.js').Node} Node
@@ -23,20 +24,73 @@ import { isCalculationFunction } from './functions.js';
23
24
  const SUM_PRECEDENCE = 1;
24
25
  const PRODUCT_PRECEDENCE = 2;
25
26
  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.
27
+ // Negation (-1 * ...) binds more tightly than a sum but has the same atomic boundary
28
+ // for deciding whether the operand needs parentheses.
28
29
  const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE;
29
30
  const NOISE_FLOOR = 1e-12;
30
31
 
31
32
  /**
32
- * Decimal rounding with "round half away from zero" (e.g. 1.005 at precision 2 -> 1.01).
33
+ * Divide a decimal digit string by 10^k, rounding half away from zero, and
34
+ * return the resulting integer digit string. `digits` has no leading zeros.
35
+ * @param {string} digits
36
+ * @param {number} k
37
+ * @return {string}
38
+ */
39
+ function divideByPowerOfTen(digits, k) {
40
+ // 0x30/0x35/0x39 are the char codes of '0'/'5'/'9'.
41
+ if (digits.length <= k) {
42
+ return digits.length === k && digits.charCodeAt(0) >= 0x35 ? '1' : '0';
43
+ }
44
+ const cut = digits.length - k;
45
+ if (digits.charCodeAt(cut) < 0x35) return digits.slice(0, cut);
46
+ // Round up and propagate the carry through trailing nines.
47
+ let index = cut - 1;
48
+ while (index >= 0 && digits.charCodeAt(index) === 0x39) index--;
49
+ if (index < 0) return `1${'0'.repeat(cut)}`;
50
+ return `${digits.slice(0, index)}${String.fromCharCode(
51
+ digits.charCodeAt(index) + 1
52
+ )}${'0'.repeat(cut - index - 1)}`;
53
+ }
54
+
55
+ /**
56
+ * Round the shortest decimal representation of a non-negative double to `p`
57
+ * fractional digits, half away from zero.
33
58
  *
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.
59
+ * `Number(text + 'e' + p)` reads the exact intended decimal (so `1.005` at
60
+ * precision 2 becomes `1.01`), but it is only exact while the shifted value
61
+ * fits in `Number.MAX_SAFE_INTEGER`; beyond that the intermediate double
62
+ * rounds and can move the rounding boundary (e.g. `312834450754803.44` at
63
+ * precision 1 or 6 drifted to `312834450754803.5`). Round the decimal digits
64
+ * directly instead.
39
65
  *
66
+ * @param {number} abs
67
+ * @param {number} p
68
+ * @return {number}
69
+ */
70
+ function roundDecimal(abs, p) {
71
+ const text = String(abs);
72
+ const eIdx = text.indexOf('e');
73
+ const mantissa = eIdx === -1 ? text : text.slice(0, eIdx);
74
+ let exponent = eIdx === -1 ? 0 : Number(text.slice(eIdx + 1));
75
+ const dot = mantissa.indexOf('.');
76
+ let digits = mantissa;
77
+ if (dot !== -1) {
78
+ digits = mantissa.slice(0, dot) + mantissa.slice(dot + 1);
79
+ exponent -= mantissa.length - dot - 1;
80
+ }
81
+
82
+ // value = digits * 10^exponent, so the shortest decimal has -exponent
83
+ // fractional digits when it is smaller than 1.
84
+ if (exponent >= -p) return abs;
85
+
86
+ let start = 0;
87
+ while (start < digits.length - 1 && digits.charCodeAt(start) === 0x30)
88
+ start++;
89
+ const rounded = divideByPowerOfTen(digits.slice(start), -(exponent + p));
90
+ return Number(`${rounded}e-${p}`);
91
+ }
92
+
93
+ /**
40
94
  * @param {number} v
41
95
  * @param {number | false} prec
42
96
  * @return {number}
@@ -53,36 +107,9 @@ function round(v, prec) {
53
107
  // or exponent overflows into Infinity/NaN (e.g. exponent + prec > 308).
54
108
  const p = Math.min(100, Math.max(0, Math.trunc(prec)));
55
109
  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
- }
110
+ // Fast path: rounding to integer with "round half away from zero".
111
+ const rounded =
112
+ p === 0 ? sign * Math.round(abs) : sign * roundDecimal(abs, p);
86
113
 
87
114
  // Preserve non-zero values smaller than precision (e.g. 1/1000000) from collapsing
88
115
  // to zero, while still snapping true floating-point dust (< 1e-12) to zero.
@@ -163,20 +190,21 @@ function emitFiniteScalar(node, session, value) {
163
190
  */
164
191
  function emitScalar(node, session, value) {
165
192
  const buffer = session.buffer;
166
- if (Object.is(node.value, -0)) emitSignedZero(buffer, node);
167
- else if (isDegenerate(node.value)) {
193
+ const effective = value ?? node.value;
194
+ if (Object.is(effective, -0)) emitSignedZero(buffer, node);
195
+ else if (isDegenerate(effective)) {
168
196
  if (node.type === 'Dim') {
169
197
  buffer.push(
170
198
  'calc(',
171
- degenerateKeyword(node.value),
199
+ degenerateKeyword(effective),
172
200
  ' * 1',
173
201
  node.rawUnit ?? node.unit,
174
202
  ')'
175
203
  );
176
204
  } else {
177
- buffer.push(degenerateKeyword(node.value));
205
+ buffer.push(degenerateKeyword(effective));
178
206
  }
179
- } else emitFiniteScalar(node, session, value);
207
+ } else emitFiniteScalar(node, session, effective);
180
208
  }
181
209
 
182
210
  /**
@@ -224,15 +252,13 @@ function needsParentheses(node, parentPrecedence, groupedRequired) {
224
252
  * @param {ReturnType<typeof makeContext>} session
225
253
  * @param {number} [parentPrecedence]
226
254
  * @param {boolean} [groupedRequired]
227
- * @param {number} [scalarValueOverride]
228
255
  * @return {void}
229
256
  */
230
257
  function emitNode(
231
258
  node,
232
259
  session,
233
260
  parentPrecedence = 0,
234
- groupedRequired = false,
235
- scalarValueOverride
261
+ groupedRequired = false
236
262
  ) {
237
263
  const parenthesized = needsParentheses(
238
264
  node,
@@ -240,22 +266,21 @@ function emitNode(
240
266
  groupedRequired
241
267
  );
242
268
  if (parenthesized) session.buffer.push('(');
243
- emitNodeBody(node, session, scalarValueOverride);
269
+ emitNodeBody(node, session);
244
270
  if (parenthesized) session.buffer.push(')');
245
271
  }
246
272
 
247
273
  /**
248
274
  * @param {Node} node
249
275
  * @param {ReturnType<typeof makeContext>} session
250
- * @param {number} [scalarValueOverride]
251
276
  * @return {void}
252
277
  */
253
- function emitNodeBody(node, session, scalarValueOverride) {
278
+ function emitNodeBody(node, session) {
254
279
  const buffer = session.buffer;
255
280
  switch (node.type) {
256
281
  case 'Num':
257
282
  case 'Dim':
258
- emitScalar(node, session, scalarValueOverride);
283
+ emitScalar(node, session);
259
284
  return;
260
285
  case 'Ident':
261
286
  buffer.push(node.rawName ?? node.name);
@@ -306,18 +331,31 @@ function emitOpaqueCall(node, session, callNameOverride) {
306
331
  buffer.push(')');
307
332
  }
308
333
 
334
+ /**
335
+ * Whether a scalar node is strictly negative after precision rounding
336
+ * (excluding signed zero and sub-precision values that round to zero).
337
+ * @param {Node} node
338
+ * @param {number | false} precision
339
+ * @return {node is import('./node.js').Num | import('./node.js').Dim}
340
+ */
341
+ function isEffectivelyNegative(node, precision) {
342
+ return (
343
+ isScalar(node) &&
344
+ !Object.is(node.value, -0) &&
345
+ Number.isFinite(node.value) &&
346
+ round(node.value, precision) < 0
347
+ );
348
+ }
349
+
309
350
  /**
310
351
  * @param {import('./node.js').SumTerm} term
311
352
  * @param {1 | -1} multiplier
353
+ * @param {number | false} precision
312
354
  * @return {1 | -1}
313
- * */
314
- function termSign(term, multiplier) {
355
+ */
356
+ function termSign(term, multiplier, precision) {
315
357
  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
- ) {
358
+ if (isEffectivelyNegative(term.node, precision)) {
321
359
  sign = /** @type {1 | -1} */ (-sign);
322
360
  }
323
361
  return sign;
@@ -327,14 +365,13 @@ function termSign(term, multiplier) {
327
365
  * @param {import('./node.js').SumTerm} term
328
366
  * @param {ReturnType<typeof makeContext>} session
329
367
  * @param {1 | -1} sign
330
- * @param {number | undefined} scalarValueOverride
331
368
  * @return {void}
332
369
  */
333
- function emitSumTerm(term, session, sign, scalarValueOverride) {
370
+ function emitSumTerm(term, session, sign) {
334
371
  if (sign === 1) {
335
- emitNode(term.node, session, SUM_PRECEDENCE, true, scalarValueOverride);
372
+ emitNode(term.node, session, SUM_PRECEDENCE, true);
336
373
  } else {
337
- emitLeadingNeg(term.node, session, scalarValueOverride);
374
+ emitLeadingNeg(term.node, session);
338
375
  }
339
376
  }
340
377
 
@@ -349,25 +386,38 @@ function emitSumTerms(terms, session, multiplier = 1) {
349
386
  for (let i = 0; i < terms.length; i++) {
350
387
  const term = terms[i];
351
388
  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);
389
+ if (isScalar(termNode)) {
390
+ const effectiveVal = term.sign * multiplier * termNode.value;
391
+ if (Object.is(effectiveVal, -0)) {
392
+ if (i > 0) buffer.push(' + ');
393
+ emitSignedZero(buffer, termNode);
394
+ } else if (isDegenerate(effectiveVal)) {
395
+ const sign = /** @type {1 | -1} */ (term.sign * multiplier);
396
+ if (i === 0) {
397
+ if (sign === -1) buffer.push('-');
398
+ emitScalar(termNode, session);
399
+ } else {
400
+ buffer.push(sign === 1 ? ' + ' : ' - ');
401
+ emitScalar(termNode, session);
402
+ }
362
403
  } else {
363
- emitSumTerm(term, session, sign, scalarValueOverride);
404
+ const rounded = round(effectiveVal, session.precision);
405
+ if (rounded < 0) {
406
+ if (i === 0) buffer.push('-');
407
+ else buffer.push(' - ');
408
+ emitRoundedScalar(termNode, buffer, -rounded);
409
+ } else {
410
+ if (i > 0) buffer.push(' + ');
411
+ emitRoundedScalar(termNode, buffer, rounded);
412
+ }
364
413
  }
365
414
  continue;
366
415
  }
367
- buffer.push(sign === 1 ? ' + ' : ' - ');
368
- if (scalar) {
369
- emitScalar(termNode, session, scalarValueOverride);
416
+ const sign = /** @type {1 | -1} */ (term.sign * multiplier);
417
+ if (i === 0) {
418
+ emitSumTerm(term, session, sign);
370
419
  } else {
420
+ buffer.push(sign === 1 ? ' + ' : ' - ');
371
421
  emitNode(termNode, session, SUM_PRECEDENCE, true);
372
422
  }
373
423
  }
@@ -381,30 +431,24 @@ function emitSum(sum, session) {
381
431
  /**
382
432
  * @param {Node} node
383
433
  * @param {ReturnType<typeof makeContext>} session
384
- * @param {number} [scalarValueOverride]
385
434
  * @return {void}
386
435
  */
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);
436
+ function emitLeadingNeg(node, session) {
437
+ if (node.type === 'Product') {
438
+ if (
439
+ node.factors.length > 0 &&
440
+ node.factors[0].exponent === 1 &&
441
+ node.factors[0].node.type === 'Num'
442
+ ) {
443
+ const head = node.factors[0].node;
444
+ emitProductFactors(node.factors, session, 1, -head.value, head);
445
+ return;
446
+ }
447
+ emitProductFactors(node.factors, session, 0, -1, num(-1));
398
448
  return;
399
449
  }
400
- session.buffer.push('-');
401
- emitNode(
402
- node,
403
- session,
404
- UNARY_PRECEDENCE,
405
- false,
406
- isScalar(node) ? scalarValueOverride : undefined
407
- );
450
+ session.buffer.push('-1 * ');
451
+ emitNode(node, session, UNARY_PRECEDENCE, false);
408
452
  }
409
453
 
410
454
  /**
@@ -459,9 +503,9 @@ function emitRootExpr(node, session) {
459
503
  node.type === 'Sum' &&
460
504
  node.grouped &&
461
505
  node.terms.length > 1 &&
462
- termSign(node.terms[0], 1) === -1
506
+ termSign(node.terms[0], 1, session.precision) === -1
463
507
  ) {
464
- session.buffer.push('-(');
508
+ session.buffer.push('-1 * (');
465
509
  emitSumTerms(node.terms, session, -1);
466
510
  session.buffer.push(')');
467
511
  return;
@@ -500,9 +544,9 @@ function emitMathResult(node, session, wrapper) {
500
544
  node.type === 'Sum' &&
501
545
  node.grouped &&
502
546
  node.terms.length > 1 &&
503
- termSign(node.terms[0], 1) === -1
547
+ termSign(node.terms[0], 1, session.precision) === -1
504
548
  ) {
505
- session.buffer.push(wrapper, '(-(');
549
+ session.buffer.push(wrapper, '(-1 * (');
506
550
  emitSumTerms(node.terms, session, -1);
507
551
  session.buffer.push('))');
508
552
  return;
@@ -1,5 +1,8 @@
1
1
  export type Node = import('./node.js').Node;
2
2
  export type CalculationType = import('./functions.js').CalculationType;
3
+ export type DimensionType = Extract<CalculationType, {
4
+ kind: 'dimension';
5
+ }>;
3
6
  export type AnalysisType = 'number' | 'unknown' | {
4
7
  dimension: string | null;
5
8
  };
@@ -8,6 +11,30 @@ export type Analysis = {
8
11
  valid: boolean;
9
12
  unresolved: boolean;
10
13
  };
14
+ export type ProductFactors = {
15
+ valid: boolean;
16
+ structurallyValid: boolean;
17
+ hasUnresolved: boolean;
18
+ numerator: DimensionType | null;
19
+ denominator: DimensionType | null;
20
+ hasOpaqueNumerator: boolean;
21
+ hasOpaqueDenominator: boolean;
22
+ };
23
+ /** @typedef {import('./node.js').Node} Node */
24
+ /** @typedef {import('./functions.js').CalculationType} CalculationType */
25
+ /** @typedef {Extract<CalculationType, {kind: 'dimension'}>} DimensionType */
26
+ /** @typedef {'number' | 'unknown' | {dimension: string | null}} AnalysisType */
27
+ /** @typedef {{type: AnalysisType, valid: boolean, unresolved: boolean}} Analysis */
28
+ /**
29
+ * @typedef {Object} ProductFactors
30
+ * @property {boolean} valid
31
+ * @property {boolean} structurallyValid
32
+ * @property {boolean} hasUnresolved
33
+ * @property {DimensionType | null} numerator
34
+ * @property {DimensionType | null} denominator
35
+ * @property {boolean} hasOpaqueNumerator
36
+ * @property {boolean} hasOpaqueDenominator
37
+ */
11
38
  /**
12
39
  * Analyze the original complete tree and return its root summary. Analysis
13
40
  * validates and classifies the tree; it is not a rewrite plan.
@@ -6,6 +6,7 @@ export type CalculationType = {
6
6
  base: string | null;
7
7
  } | {
8
8
  kind: 'unknown';
9
+ percent?: true;
9
10
  } | {
10
11
  kind: 'failure';
11
12
  };
@@ -17,8 +18,25 @@ export type MathFunction = {
17
18
  isKeyword?: (node: Node, index: number) => boolean;
18
19
  calculation?: boolean;
19
20
  };
21
+ /** @typedef {import('./node.js').Node} Node */
22
+ /**
23
+ * `percent` marks a value that resolves in the same percentage context as its
24
+ * peers (a pure percentage). It is only produced at leaves, by abs(), and by
25
+ * homogeneous sums and calls — never by a product, where an unpaired
26
+ * percentage could no longer cancel against anything.
27
+ * @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown', percent?: true} | {kind: 'failure'}} CalculationType
28
+ */
29
+ /** @typedef {(name: string, args: Node[]) => Node} MathSimplifier */
30
+ /** @typedef {(args: CalculationType[], nodes: Node[]) => CalculationType} TypeAnalyzer */
31
+ /** @typedef {{analyze: TypeAnalyzer, simplify?: MathSimplifier, isKeyword?: (node: Node, index: number) => boolean, calculation?: boolean}} MathFunction */
32
+ /** @type {CalculationType} */ declare const numberType: CalculationType;
33
+ /** @type {CalculationType} */ declare const unknownType: CalculationType;
34
+ /** @type {CalculationType} */ declare const percentageType: CalculationType;
35
+ /** @type {CalculationType} */ declare const failureType: CalculationType;
20
36
  /** @param {CalculationType} type @return {boolean} */
21
37
  declare function isFailure(type: CalculationType): boolean;
38
+ /** @param {CalculationType} type @return {boolean} */
39
+ declare function isPercentage(type: CalculationType): boolean;
22
40
  /** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */
23
41
  declare function addTypes(a: CalculationType, b: CalculationType): CalculationType;
24
42
  declare const mathFunctions: Map<string, MathFunction>;
@@ -37,4 +55,4 @@ declare function isCalculationFunction(name: string): boolean;
37
55
  declare function isSupportedMathFunction(name: string): boolean;
38
56
  /** @param {string} value @return {boolean} */
39
57
  declare function hasPotentialMathFunction(value: string): boolean;
40
- export { addTypes, mathFunctions, lookupMathFunction, QUICK_MATH_TEST, isFailure, isCalculationFunction, isSupportedMathFunction, hasPotentialMathFunction, };
58
+ export { addTypes, failureType, hasPotentialMathFunction, isCalculationFunction, isFailure, isPercentage, isSupportedMathFunction, lookupMathFunction, mathFunctions, numberType, percentageType, QUICK_MATH_TEST, unknownType, };
@@ -1,25 +0,0 @@
1
- // Compatibility facade for the former calculation-type module. New code uses
2
- // analyze() for the complete result and limits.js for depth policy.
3
-
4
- import { analyze } from './analyze.js';
5
- import { MAX_CALCULATION_DEPTH, checkCalculationDepth } from './limits.js';
6
-
7
- /** @typedef {import('./node.js').Node} Node */
8
-
9
- /** @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown'} | {kind: 'failure'}} CalculationType */
10
-
11
- /** @param {Node} node @return {CalculationType} */
12
- function checkCalculationType(node) {
13
- const result = analyze(node);
14
- if (!result.valid) return { kind: 'failure' };
15
- if (result.type === 'number') return { kind: 'number' };
16
- if (result.type === 'unknown') return { kind: 'unknown' };
17
- return { kind: 'dimension', base: result.type.dimension };
18
- }
19
-
20
- export {
21
- MAX_CALCULATION_DEPTH,
22
- checkCalculationDepth,
23
- checkCalculationType,
24
- analyze,
25
- };