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
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -4,8 +4,9 @@
4
4
  [![Support Chat][git-img]][git-url]
5
5
 
6
6
  [PostCSS Calc] lets you reduce `calc()` references whenever it's possible.
7
- When multiple units are mixed together in the same expression, the `calc()`
8
- statement is left as is, to fallback to the [W3C calc() implementation].
7
+ When an expression mixes units that cannot be combined exactly (such as `px`
8
+ and `em`), or contains values only known at runtime (such as `var()`), the
9
+ unresolved part is left for the browser's [W3C calc() implementation].
9
10
 
10
11
  ## Installation
11
12
 
@@ -62,7 +63,7 @@ leaving all other text untouched.
62
63
  import reduceCalc from 'postcss-calc/reduce';
63
64
 
64
65
  reduceCalc('calc(1in + 10px)');
65
- // => 'calc(1.10417in)'
66
+ // => 'calc(106px)'
66
67
 
67
68
  reduceCalc('min(50px, calc(2 * 40px))');
68
69
  // => 'calc(50px)'
@@ -123,6 +124,28 @@ Allows you to define the precision for decimal numbers. Set it to `false` to
123
124
  disable rounding and preserve full IEEE-754 floating-point precision (emitting
124
125
  the shortest round-tripping decimal representation).
125
126
 
127
+ Values below 1 keep `precision` significant digits (`.0123456px` becomes
128
+ `.012346px`), and larger values keep `precision` decimals. Divisions and unit
129
+ conversions are folded only when the result is exact at the precision;
130
+ otherwise they stay symbolic and everything around them is still simplified:
131
+
132
+ ```css
133
+ .a {
134
+ width: calc(100% / 4);
135
+ } /* calc(25%) */
136
+ .b {
137
+ width: calc(100% / 3);
138
+ } /* calc(100% / 3), not 33.33333% */
139
+ .c {
140
+ width: calc(1cm + 1px);
141
+ } /* calc(1cm + 1px) */
142
+ .d {
143
+ width: calc(1px + 1pt);
144
+ } /* calc(1.75pt) */
145
+ ```
146
+
147
+ With `precision: false` nothing is rounded, so every division is folded.
148
+
126
149
  ```js
127
150
  var out = postcss()
128
151
  .use(calc({ precision: 10 }))
@@ -235,15 +258,10 @@ canonical-form decisions:
235
258
  requires the zero term because it carries the length-percentage type.
236
259
  - **Constant folding.** `calc(43 + pi)` now folds to `46.14159` (§10.7.1).
237
260
  Previously `pi` / `e` stayed symbolic.
238
- - **Reciprocal conversion.** `calc(var(--x) / 2)` becomes
239
- `calc(var(--x) * 0.5)`. The two are mathematically equivalent;
240
- previously the division shape was kept.
241
261
  - **Distributive multiplication.** `calc(0.5 * (100vw - 10px))` becomes
242
262
  `calc(50vw - 5px)`.
243
263
  - **Unit case normalization.** `2PX` becomes `2px` (CSS units are case-
244
264
  insensitive; lowercase is conventional).
245
- - **Calc unwrap (§10.6).** `calc(var(--foo))` becomes `var(--foo)` — a
246
- `calc()` containing a single value is replaced by that value.
247
265
  - **Spec-style spaced operators.** `2px*var(--x)` is serialized as
248
266
  `2px * var(--x)`. The tokenizer is unaffected; only output spacing
249
267
  differs.
@@ -265,13 +283,15 @@ To replace the value of CSS custom properties at build time, try [PostCSS Custom
265
283
  ## Contributing
266
284
 
267
285
  Work on a branch, install dev-dependencies, respect coding style & run tests
268
- before submitting a bug fix or a feature.
286
+ before submitting a bug fix or a feature. See [CONTRIBUTING.md](CONTRIBUTING.md)
287
+ for the full guidelines. The project uses [pnpm](https://pnpm.io/).
269
288
 
270
289
  ```bash
271
290
  git clone git@github.com:postcss/postcss-calc.git
272
291
  git checkout -b patch-1
273
- npm install
274
- npm test
292
+ pnpm install
293
+ pnpm test
294
+ pnpm lint
275
295
  ```
276
296
 
277
297
  The normal test run uses a deterministic structural sample of the harvested
@@ -282,11 +302,19 @@ when changing parsing/simplification behavior:
282
302
  pnpm test:corpus:full
283
303
  ```
284
304
 
285
- Profile parser chains with `pnpm benchmark:arithmetic-chains` or
286
- `pnpm benchmark:nested-fallbacks`; both use 20 fresh paired blocks by default
287
- and write ignored schema-v2 reports. Compare a saved report with
288
- `node scripts/compare-parser-benchmarks.js <report>`. Run the correctness-aware
289
- corpus benchmark with `pnpm benchmark:corpus`.
305
+ Performance changes to the parser, analyzer, simplifier, or serializer should be
306
+ checked with the benchmarks. `pnpm benchmark:arithmetic-chains` and
307
+ `pnpm benchmark:nested-fallbacks` compare the working tree against `HEAD` using
308
+ 20 fresh-process paired blocks by default and write git-ignored schema-v2
309
+ reports under `reports/benchmarks/`. Re-check a saved report with
310
+ `pnpm benchmark:reanalyze <report>`. `pnpm benchmark:corpus` compares the whole
311
+ pipeline with `@csstools/css-calc` on real-world expressions and is
312
+ report-only.
313
+
314
+ These benchmarks time only the parser (or, for the corpus, the whole reducer)
315
+ on one machine, and a `pass` means "no regression detected at the declared
316
+ margin", not "no change". Read [BENCHMARKS.md](BENCHMARKS.md) before
317
+ interpreting results.
290
318
 
291
319
  The PostCSS benchmark awaits `postcss().process(...)`, and that await already
292
320
  triggers result stringification. It therefore does not add a redundant
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-calc",
3
- "version": "11.2.0",
3
+ "version": "11.2.2",
4
4
  "type": "module",
5
5
  "description": "PostCSS plugin to reduce calc()",
6
6
  "keywords": [
@@ -38,20 +38,20 @@
38
38
  "devEngines": {
39
39
  "packageManager": {
40
40
  "name": "pnpm",
41
- "version": "12.4.2"
41
+ "version": "12.8.1"
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",
48
- "oxfmt": "^0.68.0",
49
- "oxlint": "^1.83.0",
45
+ "@csstools/css-calc": "^3.4.1",
46
+ "@types/node": "^26.6.3",
47
+ "fast-check": "^4.10.2",
48
+ "oxfmt": "^0.71.0",
49
+ "oxlint": "^1.86.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.2"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "postcss": "^8.5.28"
@@ -59,13 +59,13 @@
59
59
  "scripts": {
60
60
  "lint": "oxlint . && tsc && oxfmt --check",
61
61
  "fmt": "oxfmt",
62
- "benchmark:arithmetic-chains": "node scripts/benchmark-arithmetic-chains.js",
63
- "benchmark:nested-fallbacks": "node scripts/benchmark-nested-fallbacks.js",
64
- "benchmark:corpus": "node scripts/benchmark.js",
65
- "benchmark:serialization": "node scripts/benchmark-serialization.js",
62
+ "benchmark:arithmetic-chains": "node scripts/benchmark/benchmark-parser.js arithmetic-chains",
63
+ "benchmark:nested-fallbacks": "node scripts/benchmark/benchmark-parser.js nested-fallbacks",
64
+ "benchmark:corpus": "node scripts/benchmark/benchmark-corpus.js",
65
+ "benchmark:serialization": "node scripts/benchmark/benchmark-serialization.js",
66
66
  "test:benchmark": "node --test 'test/unit/benchmark-*.test.js' test/unit/compare-parser-benchmarks.test.js test/unit/corpus-benchmark.test.js",
67
67
  "test:benchmark:simulation": "node test/benchmark/statistical-simulation.js",
68
- "benchmark:reanalyze": "node scripts/compare-parser-benchmarks.js",
68
+ "benchmark:reanalyze": "node scripts/benchmark/compare-parser-benchmarks.js",
69
69
  "test": "node --test --test-reporter=dot 'test/**/*.test.js' 'test/**/*.test.cjs'",
70
70
  "test:mutation:corpus": "node test/mutation/corpus-selection.js",
71
71
  "test:corpus:full": "POSTCSS_CALC_FULL_CORPUS=1 node --test test/conformance/corpus.test.js"
package/src/index.js CHANGED
@@ -65,12 +65,13 @@ function applyTransform(
65
65
  function pluginCreator(opts) {
66
66
  /** @type {ResolvedOptions} */
67
67
  const options = {
68
- precision: 5,
69
68
  warnWhenCannotResolve: false,
70
69
  mediaQueries: false,
71
70
  selectors: false,
72
71
  unwrapSingleValue: false,
73
72
  ...opts,
73
+ // An explicit `precision: undefined` keeps the default.
74
+ precision: opts?.precision ?? 5,
74
75
  };
75
76
 
76
77
  return {
@@ -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,8 @@ 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.
39
53
  return node.unit === '%'
40
- ? finish(unknownType, true, true)
54
+ ? finish(percentageType, true, true)
41
55
  : resolved({ kind: 'dimension', base: baseOf(node.unit) });
42
56
  case 'Ident':
43
57
  return markUnresolved(unknownType);
@@ -56,6 +70,7 @@ function analyzeType(node, depth = 0) {
56
70
  function analyzeSum(node, depth) {
57
71
  let type = null;
58
72
  let hasUnknown = false;
73
+ let hasPercentage = false;
59
74
  let valid = true;
60
75
  let hasUnresolved = false;
61
76
  for (const term of node.terms) {
@@ -65,7 +80,8 @@ function analyzeSum(node, depth) {
65
80
  if (isFailure(child.type)) {
66
81
  type = failureType;
67
82
  } else if (child.type.kind === 'unknown') {
68
- hasUnknown = true;
83
+ if (isPercentage(child.type)) hasPercentage = true;
84
+ else hasUnknown = true;
69
85
  } else if (type === null) {
70
86
  type = child.type;
71
87
  } else if (!isFailure(type)) {
@@ -75,11 +91,10 @@ function analyzeSum(node, depth) {
75
91
  if (type !== null && isFailure(type)) {
76
92
  return finish(failureType, false, hasUnresolved);
77
93
  }
78
- return finish(
79
- type ?? (hasUnknown ? unknownType : numberType),
80
- valid,
81
- hasUnresolved
82
- );
94
+ let fallback = numberType;
95
+ if (hasUnknown) fallback = unknownType;
96
+ else if (hasPercentage) fallback = percentageType;
97
+ return finish(type ?? fallback, valid, hasUnresolved);
83
98
  }
84
99
 
85
100
  /** @param {Extract<Node, {type: 'Product'}>} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */
@@ -88,8 +103,13 @@ function analyzeProduct(node, depth) {
88
103
  let denominator = null;
89
104
  let valid = true;
90
105
  let structurallyValid = true;
91
- let hasUnknownNumerator = false;
92
- let hasUnknownDenominator = false;
106
+ // Opaque factors (unknowns and pure percentages) are counted per side so
107
+ // the pass below can decide whether any unknown remains after cancelling
108
+ // `% / %` pairs.
109
+ let opaqueNumerator = 0;
110
+ let opaqueDenominator = 0;
111
+ let percentageNumerator = 0;
112
+ let percentageDenominator = 0;
93
113
  let hasUnresolved = false;
94
114
  for (const factor of node.factors) {
95
115
  const child = analyzeType(factor.node, depth + 1);
@@ -99,8 +119,13 @@ function analyzeProduct(node, depth) {
99
119
  continue;
100
120
  }
101
121
  if (child.type.kind === 'unknown') {
102
- if (factor.exponent === 1) hasUnknownNumerator = true;
103
- else hasUnknownDenominator = true;
122
+ if (factor.exponent === 1) {
123
+ opaqueNumerator++;
124
+ if (isPercentage(child.type)) percentageNumerator++;
125
+ } else {
126
+ opaqueDenominator++;
127
+ if (isPercentage(child.type)) percentageDenominator++;
128
+ }
104
129
  continue;
105
130
  }
106
131
  if (child.type.kind !== 'dimension') continue;
@@ -112,10 +137,36 @@ function analyzeProduct(node, depth) {
112
137
  else denominator = child.type;
113
138
  }
114
139
  }
140
+ // A percentage divided by a percentage is always a plain number: both
141
+ // operands resolve in the same context, so their contextual type cancels.
142
+ // Consume one such pair before judging the remaining unknowns so a
143
+ // surrounding sum does not mistake `% / %` for a length-compatible term.
144
+ const cancelled = Math.min(percentageNumerator, percentageDenominator);
145
+ const hasOpaqueNumerator = opaqueNumerator - cancelled > 0;
146
+ const hasOpaqueDenominator = opaqueDenominator - cancelled > 0;
147
+ return finishProduct({
148
+ valid,
149
+ structurallyValid,
150
+ hasUnresolved,
151
+ numerator,
152
+ denominator,
153
+ hasOpaqueNumerator,
154
+ hasOpaqueDenominator,
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Classify a product once its factors are analyzed. Opaque factors can supply
160
+ * missing type information, but they cannot make an already-invalid
161
+ * combination of known dimensions valid.
162
+ * @param {ProductFactors} factors
163
+ * @return {{type: CalculationType, valid: boolean, unresolved: boolean}}
164
+ */
165
+ function finishProduct(factors) {
166
+ const { valid, structurallyValid, hasUnresolved } = factors;
115
167
  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
168
  if (!structurallyValid) return finish(failureType, false, hasUnresolved);
169
+ const { numerator, denominator } = factors;
119
170
  if (
120
171
  numerator !== null &&
121
172
  denominator !== null &&
@@ -127,18 +178,13 @@ function analyzeProduct(node, depth) {
127
178
  // only leave that dimension in place (when it resolves to a number) or make
128
179
  // the product invalid. It can never make the product a bare number. Keep
129
180
  // 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
- );
181
+ const constrained = constrainedNumerator(factors);
136
182
  if (constrained !== null) {
137
183
  return finish(constrained, true, hasUnresolved);
138
184
  }
139
185
  // Other opaque factors may supply type information that changes how the
140
186
  // known dimensions combine once the known factors are structurally valid.
141
- if (hasUnknownNumerator || hasUnknownDenominator) {
187
+ if (factors.hasOpaqueNumerator || factors.hasOpaqueDenominator) {
142
188
  return finish(unknownType, true, hasUnresolved);
143
189
  }
144
190
  if (numerator !== null && denominator !== null) {
@@ -153,23 +199,15 @@ function analyzeProduct(node, depth) {
153
199
  }
154
200
 
155
201
  /**
156
- * @param {boolean} hasUnknownNumerator
157
- * @param {boolean} hasUnknownDenominator
158
- * @param {CalculationType | null} numerator
159
- * @param {CalculationType | null} denominator
160
- * @return {CalculationType | null}
202
+ * @param {ProductFactors} factors
203
+ * @return {DimensionType | null}
161
204
  */
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
205
+ function constrainedNumerator(factors) {
206
+ return factors.hasOpaqueNumerator &&
207
+ !factors.hasOpaqueDenominator &&
208
+ factors.numerator !== null &&
209
+ factors.denominator === null
210
+ ? factors.numerator
173
211
  : null;
174
212
  }
175
213
 
@@ -34,7 +34,7 @@ function compileCandidate(candidate, ctx) {
34
34
  if (!analysis.valid) {
35
35
  throw new Error('Invalid CSS calculation type');
36
36
  }
37
- const tree = simplify(parsed);
37
+ const tree = simplify(parsed, ctx.options.precision);
38
38
  const original =
39
39
  analysis.unresolved && !candidate.calculation
40
40
  ? ctx.value.slice(candidate.start, candidate.end)
@@ -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
@@ -8,7 +8,11 @@
8
8
  // - No ungrouped Sum directly contains another Sum (flattened on
9
9
  // construction). A grouped Sum is retained so a later negative sign
10
10
  // cannot be distributed across opaque terms.
11
- // - No Product directly contains another Product (flattened).
11
+ // - No Product directly contains another ungrouped Product (flattened). A
12
+ // grouped Product is retained: it is a parenthesized group around a
13
+ // substitution function (`var()`, `env()`, ...), whose tokens must not
14
+ // merge with the surrounding factors. A grouped Product may hold a single
15
+ // factor.
12
16
  // - A Sum/Product with one positive element collapses to that element.
13
17
  // - A Sum/Product with no elements collapses to Num(0) / Num(1).
14
18
  // - Positive zero-valued Nums are dropped from all-number sums. They are
@@ -28,7 +32,7 @@
28
32
  * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim.
29
33
  * @typedef {{type: 'Sum', terms: SumTerm[], grouped?: boolean}} Sum
30
34
  * @typedef {{exponent: 1 | -1, node: Node}} ProductFactor exponent +1 = numerator, -1 = denominator.
31
- * @typedef {{type: 'Product', factors: ProductFactor[]}} Product
35
+ * @typedef {{type: 'Product', factors: ProductFactor[], grouped?: boolean}} Product
32
36
  * @typedef {Num | Dim | Ident | Call | OpaqueCall | Sum | Product} Node
33
37
  */
34
38
 
@@ -185,7 +189,7 @@ function mkProduct(rawFactors) {
185
189
  */
186
190
  function pushProductFactor(out, f) {
187
191
  const n = f.node;
188
- if (n.type === 'Product') {
192
+ if (n.type === 'Product' && !n.grouped) {
189
193
  for (const inner of n.factors) {
190
194
  out.push({
191
195
  exponent: /** @type {1 | -1} */ (f.exponent * inner.exponent),
@@ -201,6 +205,48 @@ function pushProductFactor(out, f) {
201
205
  out.push(f);
202
206
  }
203
207
 
208
+ /**
209
+ * Mark a node as a source parenthesized group. A Sum or Product keeps its
210
+ * structure; any other node becomes a single-factor grouped Product.
211
+ * @param {Node} node
212
+ * @return {Node}
213
+ */
214
+ function mkGroup(node) {
215
+ if (node.type === 'Sum' || node.type === 'Product') {
216
+ return { ...node, grouped: true };
217
+ }
218
+ return {
219
+ type: 'Product',
220
+ factors: [{ exponent: 1, node }],
221
+ grouped: true,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Whether a node is a substitution function call (`var()`, `env()`, ...), or
227
+ * an ungrouped Product that directly contains one. Nested groups are not
228
+ * inspected: they already bound their own tokens.
229
+ * @param {Node} node
230
+ * @return {boolean}
231
+ */
232
+ function isSubstitutionNode(node) {
233
+ if (node.type === 'OpaqueCall') return true;
234
+ if (node.type === 'Product' && !node.grouped) {
235
+ return node.factors.some((factor) => isSubstitutionNode(factor.node));
236
+ }
237
+ return false;
238
+ }
239
+
240
+ /**
241
+ * Group a parenthesized expression when its tokens must stay bound: a sum, or
242
+ * a product around a substitution function.
243
+ * @param {Node} node
244
+ * @return {Node}
245
+ */
246
+ function groupSubstitution(node) {
247
+ return node.type === 'Sum' || isSubstitutionNode(node) ? mkGroup(node) : node;
248
+ }
249
+
204
250
  /**
205
251
  * Negate any node, preserving canonical form.
206
252
  * @param {Node} node
@@ -215,7 +261,7 @@ function negate(node) {
215
261
  }
216
262
  if (node.type === 'Sum') {
217
263
  // A grouped sum may contain opaque terms whose meaning depends on the
218
- // surrounding context. Keep the group intact so `-(a + b)` cannot turn
264
+ // surrounding context. Keep the group intact so `-1 * (a + b)` cannot turn
219
265
  // into `-a - b` while it is still unresolved.
220
266
  if (node.grouped) {
221
267
  return mkSum([{ sign: -1, node }]);
@@ -233,4 +279,15 @@ function negate(node) {
233
279
  return mkSum([{ sign: -1, node }]);
234
280
  }
235
281
 
236
- export { num, dim, ident, call, opaqueCall, mkSum, mkProduct, negate };
282
+ export {
283
+ num,
284
+ dim,
285
+ ident,
286
+ call,
287
+ opaqueCall,
288
+ mkSum,
289
+ mkProduct,
290
+ mkGroup,
291
+ groupSubstitution,
292
+ negate,
293
+ };