postcss-calc 11.1.2 → 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.
- package/README.md +40 -18
- package/package.json +16 -12
- package/src/index.js +8 -5
- package/src/lib/analyze.js +228 -0
- package/src/lib/block-index.js +118 -0
- package/src/lib/calculation-type.js +25 -0
- package/src/lib/compile.js +80 -0
- package/src/lib/functions.js +364 -0
- package/src/lib/limits.js +49 -0
- package/src/lib/node.js +63 -21
- package/src/lib/opaque.js +12 -32
- package/src/lib/parser.js +383 -328
- package/src/lib/print.js +75 -0
- package/src/lib/regex.js +4 -0
- package/src/lib/scan.js +61 -0
- package/src/lib/serialize.js +574 -215
- package/src/lib/simplify/call.js +5 -98
- package/src/lib/simplify/round.js +4 -4
- package/src/lib/simplify/sum.js +14 -5
- package/src/lib/simplify.js +20 -5
- package/src/reduce.js +46 -115
- package/types/index.d.ts +4 -0
- package/types/lib/analyze.d.ts +18 -0
- package/types/lib/block-index.d.ts +35 -0
- package/types/lib/calculation-type.d.ts +18 -0
- package/types/lib/compile.d.ts +37 -0
- package/types/lib/functions.d.ts +40 -0
- package/types/lib/limits.d.ts +8 -0
- package/types/lib/node.d.ts +19 -3
- package/types/lib/opaque.d.ts +8 -14
- package/types/lib/parser.d.ts +42 -48
- package/types/lib/print.d.ts +16 -0
- package/types/lib/regex.d.ts +2 -0
- package/types/lib/scan.d.ts +35 -0
- package/types/lib/serialize.d.ts +19 -2
- package/types/lib/simplify/call.d.ts +4 -18
- package/types/lib/simplify/round.d.ts +3 -1
- package/types/lib/simplify.d.ts +5 -2
- package/types/reduce.d.ts +47 -6
- package/src/lib/tokenizer.js +0 -10
- package/types/lib/tokenizer.d.ts +0 -3
package/src/lib/serialize.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree
|
|
2
2
|
// Outer calc() is added when the top-level result contains an arithmetic
|
|
3
|
-
// operator, or when a finite scalar
|
|
3
|
+
// operator, or when a finite scalar needs context-sensitive CSS semantics.
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { serializeComponents } from './opaque.js';
|
|
6
|
+
import { checkCalculationDepth } from './limits.js';
|
|
7
|
+
import { isCalculationFunction } from './functions.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @typedef {import('./node.js').Node} Node
|
|
@@ -13,267 +14,377 @@ import { getComponents, serializeComponents } from './opaque.js';
|
|
|
13
14
|
* @typedef {object} SerializeOptions
|
|
14
15
|
* @property {number | false} [precision] Decimal places for numbers. `false` disables rounding. Default 5.
|
|
15
16
|
* @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`.
|
|
16
|
-
* @property {boolean} [unwrapSingleNegativeNumber]
|
|
17
|
+
* @property {boolean} [unwrapSingleNegativeNumber] Deprecated alias for `unwrapSingleValue`.
|
|
18
|
+
* @property {boolean} [unwrapSingleValue] Serialize fully resolved finite scalar results without calculation syntax.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
|
-
//
|
|
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;
|
|
20
29
|
const NOISE_FLOOR = 1e-12;
|
|
21
30
|
|
|
22
31
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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.
|
|
27
39
|
*
|
|
28
40
|
* @param {number} v
|
|
29
41
|
* @param {number | false} prec
|
|
30
42
|
* @return {number}
|
|
31
43
|
*/
|
|
32
44
|
function round(v, prec) {
|
|
33
|
-
if (prec === false)
|
|
34
|
-
|
|
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
|
+
}
|
|
35
85
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return Number(v.toPrecision(Math.max(
|
|
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)));
|
|
41
91
|
}
|
|
42
92
|
return rounded;
|
|
43
93
|
}
|
|
44
94
|
|
|
45
95
|
// §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords.
|
|
46
|
-
/**
|
|
47
|
-
* @param {number} v
|
|
48
|
-
* @return {boolean}
|
|
49
|
-
*/
|
|
96
|
+
/** @param {number} v @return {boolean} */
|
|
50
97
|
function isDegenerate(v) {
|
|
51
98
|
return !Number.isFinite(v) || Number.isNaN(v);
|
|
52
99
|
}
|
|
53
100
|
|
|
54
|
-
/**
|
|
55
|
-
* @param {number} v
|
|
56
|
-
* @return {string}
|
|
57
|
-
*/
|
|
101
|
+
/** @param {number} v @return {string} */
|
|
58
102
|
function degenerateKeyword(v) {
|
|
59
|
-
if (Number.isNaN(v))
|
|
60
|
-
return 'NaN';
|
|
61
|
-
}
|
|
103
|
+
if (Number.isNaN(v)) return 'NaN';
|
|
62
104
|
return v > 0 ? 'infinity' : '-infinity';
|
|
63
105
|
}
|
|
64
106
|
|
|
65
|
-
/**
|
|
66
|
-
* Serialize a finite CSS number. CSS numbers may omit the zero before a
|
|
67
|
-
* fractional value between -1 and 1 (`.5`, `-.5`). Scientific notation is
|
|
68
|
-
* left untouched because it already has no leading zero to remove.
|
|
69
|
-
*
|
|
70
|
-
* @param {number} v
|
|
71
|
-
* @return {string}
|
|
72
|
-
*/
|
|
107
|
+
/** @param {number} v @return {string} */
|
|
73
108
|
function serializeNumber(v) {
|
|
109
|
+
if (Object.is(v, -0)) return '0';
|
|
74
110
|
const text = String(v);
|
|
75
|
-
if (text.startsWith('0.'))
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
if (text.startsWith('-0.')) {
|
|
79
|
-
return `-${text.slice(2)}`;
|
|
80
|
-
}
|
|
111
|
+
if (text.startsWith('0.')) return text.slice(1);
|
|
112
|
+
if (text.startsWith('-0.')) return `-${text.slice(2)}`;
|
|
81
113
|
return text;
|
|
82
114
|
}
|
|
83
115
|
|
|
116
|
+
/** @param {SerializeOptions} opts @return {'standard' | 'unwrap-all'} */
|
|
117
|
+
function normalizeScalarPolicy(opts) {
|
|
118
|
+
return opts.unwrapSingleValue || opts.unwrapSingleNegativeNumber
|
|
119
|
+
? 'unwrap-all'
|
|
120
|
+
: 'standard';
|
|
121
|
+
}
|
|
122
|
+
|
|
84
123
|
/**
|
|
85
|
-
* Round and serialize a finite scalar once so callers can use the same value
|
|
86
|
-
* to decide its syntactic context and render its text.
|
|
87
|
-
*
|
|
88
124
|
* @param {import('./node.js').Num | import('./node.js').Dim} node
|
|
89
|
-
* @param {number | false}
|
|
90
|
-
* @
|
|
125
|
+
* @param {number | false} precision
|
|
126
|
+
* @param {number} [value]
|
|
127
|
+
* @return {number}
|
|
91
128
|
*/
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
const text = `${serializeNumber(value)}${node.type === 'Dim' ? (node.rawUnit ?? node.unit) : ''}`;
|
|
95
|
-
return { value, text };
|
|
129
|
+
function roundedScalarValue(node, precision, value) {
|
|
130
|
+
return round(value ?? node.value, precision);
|
|
96
131
|
}
|
|
97
132
|
|
|
98
133
|
/**
|
|
99
|
-
* @param {
|
|
100
|
-
* @param {
|
|
101
|
-
* @
|
|
134
|
+
* @param {import('./node.js').Num | import('./node.js').Dim} node
|
|
135
|
+
* @param {string[]} buffer
|
|
136
|
+
* @param {number} value
|
|
137
|
+
* @return {void}
|
|
102
138
|
*/
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// §10.13: top-level Infinity/NaN wrap in calc(); dim degenerates carry
|
|
108
|
-
// the unit as `<keyword> * 1<unit>` so the result keeps its type.
|
|
109
|
-
if (node.type === 'Num' && isDegenerate(node.value)) {
|
|
110
|
-
return `${calcName}(${degenerateKeyword(node.value)})`;
|
|
111
|
-
}
|
|
112
|
-
if (node.type === 'Dim' && isDegenerate(node.value)) {
|
|
113
|
-
return `${calcName}(${degenerateKeyword(node.value)} * 1${node.rawUnit ?? node.unit})`;
|
|
139
|
+
function emitRoundedScalar(node, buffer, value) {
|
|
140
|
+
buffer.push(serializeNumber(value));
|
|
141
|
+
if (node.type === 'Dim') {
|
|
142
|
+
buffer.push(node.rawUnit ?? node.unit);
|
|
114
143
|
}
|
|
144
|
+
}
|
|
115
145
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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));
|
|
127
178
|
}
|
|
179
|
+
} else emitFiniteScalar(node, session, value);
|
|
180
|
+
}
|
|
128
181
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|
|
131
191
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
node.type === 'Sum' &&
|
|
137
|
-
node.grouped &&
|
|
138
|
-
node.terms.length > 1 &&
|
|
139
|
-
displaySign(node.terms[0]).sign === -1
|
|
140
|
-
) {
|
|
141
|
-
const invertedTerms = node.terms.map((t) => ({
|
|
142
|
-
sign: /** @type {1 | -1} */ (-t.sign),
|
|
143
|
-
node: t.node,
|
|
144
|
-
}));
|
|
145
|
-
return `${calcName}(-(${serializeSumTerms(invertedTerms, prec)}))`;
|
|
146
|
-
}
|
|
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
|
+
}
|
|
147
196
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|
|
151
201
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (node.type === 'Sum'
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
}
|
|
157
208
|
|
|
158
|
-
|
|
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
|
+
);
|
|
159
220
|
}
|
|
160
221
|
|
|
161
|
-
|
|
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
|
+
}
|
|
162
246
|
|
|
163
247
|
/**
|
|
164
248
|
* @param {Node} node
|
|
165
|
-
* @param {
|
|
166
|
-
* @
|
|
249
|
+
* @param {ReturnType<typeof makeContext>} session
|
|
250
|
+
* @param {number} [scalarValueOverride]
|
|
251
|
+
* @return {void}
|
|
167
252
|
*/
|
|
168
|
-
function
|
|
253
|
+
function emitNodeBody(node, session, scalarValueOverride) {
|
|
254
|
+
const buffer = session.buffer;
|
|
169
255
|
switch (node.type) {
|
|
170
256
|
case 'Num':
|
|
171
|
-
if (isDegenerate(node.value)) {
|
|
172
|
-
return degenerateKeyword(node.value);
|
|
173
|
-
}
|
|
174
|
-
return serializeScalar(node, prec).text;
|
|
175
257
|
case 'Dim':
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// parses back as one Dim factor. The bare form round-trips wrong
|
|
179
|
-
// inside a Product — `0 * Dim(Infinity, px)` would re-fold as NaN.
|
|
180
|
-
return `calc(${degenerateKeyword(node.value)} * 1${node.rawUnit ?? node.unit})`;
|
|
181
|
-
}
|
|
182
|
-
return serializeScalar(node, prec).text;
|
|
258
|
+
emitScalar(node, session, scalarValueOverride);
|
|
259
|
+
return;
|
|
183
260
|
case 'Ident':
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
const args = node.args.map((a) => serializeExpr(a, prec)).join(', ');
|
|
194
|
-
return `${node.rawName ?? node.name}(${args})`;
|
|
195
|
-
}
|
|
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;
|
|
196
269
|
case 'Sum':
|
|
197
|
-
|
|
270
|
+
emitSum(node, session);
|
|
271
|
+
return;
|
|
198
272
|
case 'Product':
|
|
199
|
-
|
|
273
|
+
emitProduct(node, session);
|
|
274
|
+
return;
|
|
200
275
|
}
|
|
201
276
|
}
|
|
202
277
|
|
|
203
278
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* tokenize as a `-calc` function.
|
|
209
|
-
* @param {{sign: 1 | -1, node: Node}} term
|
|
210
|
-
* @return {{sign: 1 | -1, magnitude: Node}}
|
|
279
|
+
* @param {import('./node.js').Call} node
|
|
280
|
+
* @param {ReturnType<typeof makeContext>} session
|
|
281
|
+
* @param {string} [callNameOverride]
|
|
282
|
+
* @return {void}
|
|
211
283
|
*/
|
|
212
|
-
function
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
};
|
|
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);
|
|
219
290
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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);
|
|
225
338
|
}
|
|
226
|
-
return { sign, magnitude: node };
|
|
227
339
|
}
|
|
228
340
|
|
|
229
341
|
/**
|
|
230
342
|
* @param {import('./node.js').SumTerm[]} terms
|
|
231
|
-
* @param {
|
|
232
|
-
* @
|
|
343
|
+
* @param {ReturnType<typeof makeContext>} session
|
|
344
|
+
* @param {1 | -1} [multiplier]
|
|
345
|
+
* @return {void}
|
|
233
346
|
*/
|
|
234
|
-
function
|
|
235
|
-
|
|
347
|
+
function emitSumTerms(terms, session, multiplier = 1) {
|
|
348
|
+
const buffer = session.buffer;
|
|
236
349
|
for (let i = 0; i < terms.length; i++) {
|
|
237
|
-
const
|
|
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;
|
|
238
358
|
if (i === 0) {
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
359
|
+
if (scalar) {
|
|
360
|
+
if (sign === -1) buffer.push('-');
|
|
361
|
+
emitScalar(termNode, session, scalarValueOverride);
|
|
362
|
+
} else {
|
|
363
|
+
emitSumTerm(term, session, sign, scalarValueOverride);
|
|
243
364
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
buffer.push(sign === 1 ? ' + ' : ' - ');
|
|
368
|
+
if (scalar) {
|
|
369
|
+
emitScalar(termNode, session, scalarValueOverride);
|
|
248
370
|
} else {
|
|
249
|
-
|
|
250
|
-
let body = serializeExpr(magnitude, prec);
|
|
251
|
-
if (magnitude.type === 'Sum' && magnitude.grouped) {
|
|
252
|
-
body = `(${body})`;
|
|
253
|
-
}
|
|
254
|
-
out += sign === 1 ? ` + ${body}` : ` - ${body}`;
|
|
371
|
+
emitNode(termNode, session, SUM_PRECEDENCE, true);
|
|
255
372
|
}
|
|
256
373
|
}
|
|
257
|
-
return out;
|
|
258
374
|
}
|
|
259
375
|
|
|
260
|
-
/**
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
* @return {string}
|
|
264
|
-
*/
|
|
265
|
-
function serializeSum(sum, prec) {
|
|
266
|
-
return serializeSumTerms(sum.terms, prec);
|
|
376
|
+
/** @param {Sum} sum @param {ReturnType<typeof makeContext>} session @return {void} */
|
|
377
|
+
function emitSum(sum, session) {
|
|
378
|
+
emitSumTerms(sum.terms, session);
|
|
267
379
|
}
|
|
268
380
|
|
|
269
381
|
/**
|
|
270
|
-
* Fold a leading negation into a finite leading Num if there is one
|
|
271
|
-
* (`-(0.5 * x)` → `-0.5 * x`); else use `-(…)` for Sum/Product or `-x`.
|
|
272
382
|
* @param {Node} node
|
|
273
|
-
* @param {
|
|
274
|
-
* @
|
|
383
|
+
* @param {ReturnType<typeof makeContext>} session
|
|
384
|
+
* @param {number} [scalarValueOverride]
|
|
385
|
+
* @return {void}
|
|
275
386
|
*/
|
|
276
|
-
function
|
|
387
|
+
function emitLeadingNeg(node, session, scalarValueOverride) {
|
|
277
388
|
if (
|
|
278
389
|
node.type === 'Product' &&
|
|
279
390
|
node.factors.length > 0 &&
|
|
@@ -283,54 +394,302 @@ function serializeLeadingNeg(node, prec) {
|
|
|
283
394
|
node.factors[0].node.value !== 0
|
|
284
395
|
) {
|
|
285
396
|
const head = node.factors[0].node;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
// A coefficient of 1 is a no-op factor, matching mkProduct.
|
|
289
|
-
/** @type {ProductFactor[]} */
|
|
290
|
-
const negatedFactors =
|
|
291
|
-
negatedValue === 1
|
|
292
|
-
? rest
|
|
293
|
-
: [{ exponent: 1, node: num(negatedValue) }, ...rest];
|
|
294
|
-
return serializeFactors(negatedFactors, prec);
|
|
397
|
+
emitProductFactors(node.factors, session, 1, -head.value, head);
|
|
398
|
+
return;
|
|
295
399
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
400
|
+
session.buffer.push('-');
|
|
401
|
+
emitNode(
|
|
402
|
+
node,
|
|
403
|
+
session,
|
|
404
|
+
UNARY_PRECEDENCE,
|
|
405
|
+
false,
|
|
406
|
+
isScalar(node) ? scalarValueOverride : undefined
|
|
407
|
+
);
|
|
300
408
|
}
|
|
301
409
|
|
|
302
410
|
/**
|
|
303
411
|
* @param {ProductFactor[]} factors
|
|
304
|
-
* @param {
|
|
412
|
+
* @param {ReturnType<typeof makeContext>} session
|
|
413
|
+
* @param {number} [start]
|
|
414
|
+
* @param {number} [coefficientValue]
|
|
415
|
+
* @param {import('./node.js').Num} [coefficientNode]
|
|
416
|
+
* @return {void}
|
|
417
|
+
*/
|
|
418
|
+
function emitProductFactors(
|
|
419
|
+
factors,
|
|
420
|
+
session,
|
|
421
|
+
start = 0,
|
|
422
|
+
coefficientValue,
|
|
423
|
+
coefficientNode
|
|
424
|
+
) {
|
|
425
|
+
const buffer = session.buffer;
|
|
426
|
+
let first = true;
|
|
427
|
+
if (coefficientValue !== undefined && coefficientValue !== 1) {
|
|
428
|
+
emitScalar(
|
|
429
|
+
/** @type {import('./node.js').Num} */ (coefficientNode),
|
|
430
|
+
session,
|
|
431
|
+
coefficientValue
|
|
432
|
+
);
|
|
433
|
+
first = false;
|
|
434
|
+
}
|
|
435
|
+
for (let i = start; i < factors.length; i++) {
|
|
436
|
+
const factor = factors[i];
|
|
437
|
+
const factorNode = factor.node;
|
|
438
|
+
if (first) {
|
|
439
|
+
if (factor.exponent === -1) buffer.push('1 / ');
|
|
440
|
+
if (isScalar(factorNode)) emitScalar(factorNode, session);
|
|
441
|
+
else emitNode(factorNode, session, PRODUCT_PRECEDENCE);
|
|
442
|
+
first = false;
|
|
443
|
+
} else {
|
|
444
|
+
buffer.push(factor.exponent === 1 ? ' * ' : ' / ');
|
|
445
|
+
if (isScalar(factorNode)) emitScalar(factorNode, session);
|
|
446
|
+
else emitNode(factorNode, session, PRODUCT_PRECEDENCE);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** @param {Product} product @param {ReturnType<typeof makeContext>} session @return {void} */
|
|
452
|
+
function emitProduct(product, session) {
|
|
453
|
+
emitProductFactors(product.factors, session);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** @param {Node} node @param {ReturnType<typeof makeContext>} session @return {void} */
|
|
457
|
+
function emitRootExpr(node, session) {
|
|
458
|
+
if (
|
|
459
|
+
node.type === 'Sum' &&
|
|
460
|
+
node.grouped &&
|
|
461
|
+
node.terms.length > 1 &&
|
|
462
|
+
termSign(node.terms[0], 1) === -1
|
|
463
|
+
) {
|
|
464
|
+
session.buffer.push('-(');
|
|
465
|
+
emitSumTerms(node.terms, session, -1);
|
|
466
|
+
session.buffer.push(')');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (node.type === 'Sum' && node.terms.length === 1) {
|
|
470
|
+
emitLeadingNeg(node.terms[0].node, session);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
emitNode(node, session);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* @param {Node} node
|
|
478
|
+
* @param {ReturnType<typeof makeContext>} session
|
|
479
|
+
* @param {string} wrapper
|
|
480
|
+
* @return {void}
|
|
481
|
+
*/
|
|
482
|
+
function emitMathResult(node, session, wrapper) {
|
|
483
|
+
if (isScalar(node)) {
|
|
484
|
+
const scalarValue = roundedScalarValue(node, session.precision);
|
|
485
|
+
const buffer = session.buffer;
|
|
486
|
+
if (isDegenerate(scalarValue)) {
|
|
487
|
+
buffer.push(wrapper, '(', degenerateKeyword(scalarValue));
|
|
488
|
+
if (node.type === 'Dim') buffer.push(' * 1', node.rawUnit ?? node.unit);
|
|
489
|
+
buffer.push(')');
|
|
490
|
+
} else if (session.scalarPolicy === 'standard') {
|
|
491
|
+
buffer.push(wrapper, '(');
|
|
492
|
+
emitRoundedScalar(node, buffer, scalarValue);
|
|
493
|
+
buffer.push(')');
|
|
494
|
+
} else {
|
|
495
|
+
emitRoundedScalar(node, buffer, scalarValue);
|
|
496
|
+
}
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (
|
|
500
|
+
node.type === 'Sum' &&
|
|
501
|
+
node.grouped &&
|
|
502
|
+
node.terms.length > 1 &&
|
|
503
|
+
termSign(node.terms[0], 1) === -1
|
|
504
|
+
) {
|
|
505
|
+
session.buffer.push(wrapper, '(-(');
|
|
506
|
+
emitSumTerms(node.terms, session, -1);
|
|
507
|
+
session.buffer.push('))');
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (
|
|
511
|
+
node.type === 'Ident' ||
|
|
512
|
+
node.type === 'Call' ||
|
|
513
|
+
node.type === 'OpaqueCall'
|
|
514
|
+
) {
|
|
515
|
+
emitNode(node, session);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (node.type === 'Sum' && node.terms.length === 1) {
|
|
519
|
+
session.buffer.push(wrapper, '(');
|
|
520
|
+
emitLeadingNeg(node.terms[0].node, session);
|
|
521
|
+
session.buffer.push(')');
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
session.buffer.push(wrapper, '(');
|
|
525
|
+
emitNode(node, session);
|
|
526
|
+
session.buffer.push(')');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Serialize a scalar result without allocating a render context or buffer.
|
|
531
|
+
* @param {import('./node.js').Num | import('./node.js').Dim} node
|
|
532
|
+
* @param {number | false} precision
|
|
533
|
+
* @param {'standard' | 'unwrap-all'} scalarPolicy
|
|
534
|
+
* @param {string} wrapper
|
|
305
535
|
* @return {string}
|
|
306
536
|
*/
|
|
307
|
-
function
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
537
|
+
function serializeScalarResult(node, precision, scalarPolicy, wrapper) {
|
|
538
|
+
const value = roundedScalarValue(node, precision);
|
|
539
|
+
const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : '';
|
|
540
|
+
if (isDegenerate(value)) {
|
|
541
|
+
return `${wrapper}(${degenerateKeyword(value)}${unit ? ` * 1${unit}` : ''})`;
|
|
542
|
+
}
|
|
543
|
+
const scalar = serializeNumber(value) + unit;
|
|
544
|
+
return scalarPolicy === 'standard' ? `${wrapper}(${scalar})` : scalar;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* @param {Node} node
|
|
549
|
+
* @param {ReturnType<typeof makeContext>} session
|
|
550
|
+
* @param {string[]} [buffer]
|
|
551
|
+
* @return {void}
|
|
552
|
+
*/
|
|
553
|
+
function emitNestedMathResult(node, session, buffer = session.buffer) {
|
|
554
|
+
if (isSignedZero(node)) {
|
|
555
|
+
emitSignedZero(buffer, node);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
emitMathResult(node, session, 'calc');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* @param {SerializeOptions} opts
|
|
563
|
+
* @return {{buffer: string[], precision: number | false, scalarPolicy: 'standard' | 'unwrap-all'}}
|
|
564
|
+
*/
|
|
565
|
+
function makeContext(opts) {
|
|
566
|
+
return {
|
|
567
|
+
buffer: [],
|
|
568
|
+
precision: opts.precision ?? 5,
|
|
569
|
+
scalarPolicy: normalizeScalarPolicy(opts),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* @param {Node} node
|
|
575
|
+
* @param {SerializeOptions} opts
|
|
576
|
+
* @return {{kind: 'math', node: Node, session: ReturnType<typeof makeContext>, wrapper: string}}
|
|
577
|
+
*/
|
|
578
|
+
function planSerialize(node, opts) {
|
|
579
|
+
return {
|
|
580
|
+
kind: 'math',
|
|
581
|
+
node,
|
|
582
|
+
session: makeContext(opts),
|
|
583
|
+
wrapper: opts.calcName ?? 'calc',
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* @param {{tree: Node, status: 'resolved' | 'unresolved', rootName: string, rootSpelling: string, calculation?: boolean, original?: string}} result
|
|
589
|
+
* @param {SerializeOptions} opts
|
|
590
|
+
* @return {{kind: 'original', text: string} | {kind: 'root-call', node: Node, session: ReturnType<typeof makeContext>, callNameOverride: string} | {kind: 'wrapped-expr', node: Node, session: ReturnType<typeof makeContext>, wrapper: string} | {kind: 'math', node: Node, session: ReturnType<typeof makeContext>, wrapper: string}}
|
|
591
|
+
*/
|
|
592
|
+
function planSerializeResult(result, opts) {
|
|
593
|
+
const isCalc = result.calculation ?? isCalculationFunction(result.rootName);
|
|
594
|
+
const normalizedRootName =
|
|
595
|
+
result.calculation === undefined
|
|
596
|
+
? result.rootName.toLowerCase()
|
|
597
|
+
: result.rootName;
|
|
598
|
+
const wrapper = isCalc
|
|
599
|
+
? result.rootSpelling || opts.calcName || 'calc'
|
|
600
|
+
: 'calc';
|
|
601
|
+
const session = makeContext(opts);
|
|
602
|
+
|
|
603
|
+
if (!isCalc && result.status === 'unresolved') {
|
|
604
|
+
if (
|
|
605
|
+
(result.tree.type === 'Call' || result.tree.type === 'OpaqueCall') &&
|
|
606
|
+
result.tree.name.toLowerCase() === normalizedRootName
|
|
607
|
+
) {
|
|
608
|
+
return {
|
|
609
|
+
kind: 'root-call',
|
|
610
|
+
node: result.tree,
|
|
611
|
+
session,
|
|
612
|
+
callNameOverride: result.rootSpelling,
|
|
613
|
+
};
|
|
316
614
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
615
|
+
return { kind: 'original', text: result.original ?? '' };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (session.scalarPolicy === 'standard') {
|
|
619
|
+
if (isScalar(result.tree))
|
|
620
|
+
return { kind: 'math', node: result.tree, session, wrapper };
|
|
621
|
+
return { kind: 'wrapped-expr', node: result.tree, session, wrapper };
|
|
622
|
+
}
|
|
623
|
+
return { kind: 'math', node: result.tree, session, wrapper };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* @param {ReturnType<typeof planSerialize> | ReturnType<typeof planSerializeResult>} renderSpec
|
|
628
|
+
* @return {string}
|
|
629
|
+
* */
|
|
630
|
+
function emitOutput(renderSpec) {
|
|
631
|
+
if (renderSpec.kind === 'original') return renderSpec.text;
|
|
632
|
+
const { session } = renderSpec;
|
|
633
|
+
if (renderSpec.kind === 'root-call') {
|
|
634
|
+
if (renderSpec.node.type === 'Call') {
|
|
635
|
+
emitCall(renderSpec.node, session, renderSpec.callNameOverride);
|
|
320
636
|
} else {
|
|
321
|
-
|
|
637
|
+
emitOpaqueCall(
|
|
638
|
+
/** @type {import('./node.js').OpaqueCall} */ (renderSpec.node),
|
|
639
|
+
session,
|
|
640
|
+
renderSpec.callNameOverride
|
|
641
|
+
);
|
|
322
642
|
}
|
|
643
|
+
} else if (renderSpec.kind === 'wrapped-expr') {
|
|
644
|
+
session.buffer.push(renderSpec.wrapper, '(');
|
|
645
|
+
emitRootExpr(renderSpec.node, session);
|
|
646
|
+
session.buffer.push(')');
|
|
647
|
+
} else {
|
|
648
|
+
emitMathResult(renderSpec.node, session, renderSpec.wrapper);
|
|
323
649
|
}
|
|
324
|
-
return
|
|
650
|
+
return session.buffer.join('');
|
|
325
651
|
}
|
|
326
652
|
|
|
327
653
|
/**
|
|
328
|
-
* @param {
|
|
329
|
-
* @param {
|
|
654
|
+
* @param {Node} node
|
|
655
|
+
* @param {SerializeOptions} [opts]
|
|
330
656
|
* @return {string}
|
|
331
657
|
*/
|
|
332
|
-
function
|
|
333
|
-
|
|
658
|
+
function serialize(node, opts = {}) {
|
|
659
|
+
if (isScalar(node)) {
|
|
660
|
+
return serializeScalarResult(
|
|
661
|
+
node,
|
|
662
|
+
opts.precision ?? 5,
|
|
663
|
+
normalizeScalarPolicy(opts),
|
|
664
|
+
opts.calcName ?? 'calc'
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
checkCalculationDepth(node);
|
|
668
|
+
return emitOutput(planSerialize(node, opts));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* @param {{tree: Node, status: 'resolved' | 'unresolved', rootName: string, rootSpelling: string, calculation?: boolean, original?: string}} result
|
|
673
|
+
* @param {SerializeOptions} [opts]
|
|
674
|
+
* @return {string}
|
|
675
|
+
*/
|
|
676
|
+
function serializeResult(result, opts = {}) {
|
|
677
|
+
const node = result.tree;
|
|
678
|
+
if (isScalar(node)) {
|
|
679
|
+
const isCalc = result.calculation ?? isCalculationFunction(result.rootName);
|
|
680
|
+
if (!isCalc && result.status === 'unresolved') return result.original ?? '';
|
|
681
|
+
const wrapper = isCalc
|
|
682
|
+
? result.rootSpelling || opts.calcName || 'calc'
|
|
683
|
+
: 'calc';
|
|
684
|
+
return serializeScalarResult(
|
|
685
|
+
node,
|
|
686
|
+
opts.precision ?? 5,
|
|
687
|
+
normalizeScalarPolicy(opts),
|
|
688
|
+
wrapper
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
checkCalculationDepth(result.tree);
|
|
692
|
+
return emitOutput(planSerializeResult(result, opts));
|
|
334
693
|
}
|
|
335
694
|
|
|
336
|
-
export { serialize };
|
|
695
|
+
export { serialize, serializeResult };
|