@zumer/snapdom-plugins 3.0.0-beta.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +54 -20
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +17 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,1126 @@
1
+ /**
2
+ * CSS gradient layers -> SVD `Paint`.
3
+ *
4
+ * The whole point of this module is that **nothing downstream ever sees a
5
+ * percentage or a px**. A gradient is a shape in the unit square [0,1]² plus a
6
+ * 2x3 matrix that maps that square onto the painting box. Everything the CSS
7
+ * geometry knows — the angle, the gradient-line length, the ellipse aspect
8
+ * ratio, the `at <position>` offset — is absorbed into the matrix, so a backend
9
+ * only has to compose one affine transform and read stops whose `t` is already
10
+ * in 0..1. That is SVD invariant 6, and it is the documented cure for Figma's
11
+ * "failed to invert transform" on SVG import: every matrix this module emits is
12
+ * non-singular by construction.
13
+ *
14
+ * Unit-space conventions, per gradient type — these are normative for emitters:
15
+ *
16
+ * linear `t = x`. The gradient runs from `unit.p0` (0,0) to `unit.p1` (1,0).
17
+ * The matrix' second basis vector is the perpendicular of the first
18
+ * and the same length, so the map is a similarity: colours do not
19
+ * shear.
20
+ * radial `t = 2·|p − (0.5,0.5)|`. The ending shape is the circle of radius
21
+ * 0.5 centred at (0.5,0.5); the unit SQUARE maps onto the ellipse's
22
+ * bounding box, which is where the anisotropy lives. A backend that
23
+ * wants an SVG `<radialGradient>` emits r=0.5, cx=cy=0.5 and this
24
+ * matrix as `gradientTransform`, and gets the ellipse for free.
25
+ * angular `t = θ / 360`, θ measured **clockwise from `unit.startAngle`**, and
26
+ * `startAngle` itself is degrees clockwise from straight up — CSS's
27
+ * own convention for `from <angle>`. The matrix is deliberately a
28
+ * uniform scale (no aspect, no rotation): a conic gradient's angles
29
+ * are true screen angles, so squashing the unit square would bend
30
+ * them. `startAngle` is therefore NOT folded into the matrix; an
31
+ * emitter that needs it there composes the rotation itself.
32
+ *
33
+ * Approximations are named in `notes`, never silent. The interesting ones:
34
+ * colour-stop hints (`red, 30%, blue`) are a power curve no target primitive has,
35
+ * so they are resampled to 8–16 stops chosen to keep the CIEDE2000 error under
36
+ * 1.0 — below the just-noticeable difference — and reported as `hint-resampled:N`.
37
+ *
38
+ * Pure: no DOM, no `getComputedStyle`, no `eval`/`new Function`. Node can fuzz it.
39
+ */
40
+ import { parseColor, toCssString } from './color.js'
41
+
42
+ const DEG = 180 / Math.PI
43
+ const RAD = Math.PI / 180
44
+ const EPS = 1e-9
45
+
46
+ /**
47
+ * Repetitions unrolled for `repeating-*` before we give up and pad, and the stop
48
+ * budget that also bounds them: a 0.5px period carrying a resampled hint is 10
49
+ * stops a copy, and 64 of those is a paint no backend wants to be handed.
50
+ */
51
+ const MAX_REPEATS = 64
52
+ const MAX_STOPS = 512
53
+
54
+ /** Stops emitted per hinted segment, endpoints included, and the error they must beat. */
55
+ const HINT_MIN = 8
56
+ const HINT_MAX = 16
57
+ const HINT_DE = 1.0
58
+
59
+ const GRADIENT_HEAD = /^(-webkit-|-moz-|-o-|-ms-)?(repeating-)?(linear|radial|conic)-gradient\s*\(/i
60
+
61
+ const SIZE_KEYWORDS = new Set(['closest-side', 'closest-corner', 'farthest-side', 'farthest-corner'])
62
+ const SHAPE_KEYWORDS = new Set(['circle', 'ellipse'])
63
+ const POS_KEYWORDS = new Set(['left', 'right', 'top', 'bottom', 'center'])
64
+
65
+ const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v
66
+
67
+ // ————————————————————————————————————————————————————————————————
68
+ // Tokenizing
69
+ // ————————————————————————————————————————————————————————————————
70
+
71
+ /**
72
+ * Split on `sep` (a comma) or on whitespace, ignoring separators nested inside
73
+ * parentheses — `rgb(0, 0, 0)` is one token, not three.
74
+ */
75
+ function splitTop (s, sep) {
76
+ const out = []
77
+ let depth = 0
78
+ let start = 0
79
+ for (let i = 0; i < s.length; i++) {
80
+ const c = s[i]
81
+ if (c === '(') depth++
82
+ else if (c === ')') depth--
83
+ else if (depth === 0 && (sep === ',' ? c === ',' : c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f')) {
84
+ out.push(s.slice(start, i))
85
+ start = i + 1
86
+ }
87
+ }
88
+ out.push(s.slice(start))
89
+ const trimmed = []
90
+ for (const t of out) {
91
+ const v = t.trim()
92
+ if (v !== '') trimmed.push(v)
93
+ }
94
+ return trimmed
95
+ }
96
+
97
+ /**
98
+ * Recognise `<prefix?><repeating-?><kind>-gradient( … )` and return its inside.
99
+ * The closing paren is found by balancing rather than by regex: a stop colour
100
+ * may contain arbitrarily nested functions.
101
+ */
102
+ function matchGradient (css) {
103
+ const s = String(css).trim()
104
+ const head = GRADIENT_HEAD.exec(s)
105
+ if (!head) return null
106
+ const open = head[0].lastIndexOf('(') + head.index
107
+ let depth = 0
108
+ let close = -1
109
+ for (let i = open; i < s.length; i++) {
110
+ if (s[i] === '(') depth++
111
+ else if (s[i] === ')' && --depth === 0) { close = i; break }
112
+ }
113
+ if (close === -1 || s.slice(close + 1).trim() !== '') return null
114
+ return {
115
+ prefixed: !!head[1],
116
+ repeating: !!head[2],
117
+ kind: head[3].toLowerCase(),
118
+ args: s.slice(open + 1, close),
119
+ }
120
+ }
121
+
122
+ // ————————————————————————————————————————————————————————————————
123
+ // Dimensions — px / % / deg components, resolved against a reference later.
124
+ // `n` is the unitless part; it only ever legally survives as zero.
125
+ // ————————————————————————————————————————————————————————————————
126
+
127
+ const UNIT_PX = { px: 1, pt: 96 / 72, pc: 16, in: 96, cm: 96 / 2.54, mm: 96 / 25.4, q: 96 / 101.6 }
128
+ const UNIT_DEG = { deg: 1, grad: 0.9, rad: DEG, turn: 360 }
129
+
130
+ const dim = (px = 0, pct = 0, deg = 0, n = 0) => ({ px, pct, deg, n })
131
+
132
+ const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/
133
+
134
+ /** A single `<number><unit>` token. `null` when the unit is not resolvable here (em, vw, ch…). */
135
+ function parseNumeric (tok) {
136
+ const m = NUMBER.exec(tok)
137
+ if (!m || m[0].length === 0) return null
138
+ const n = parseFloat(m[0])
139
+ if (!Number.isFinite(n)) return null
140
+ const unit = tok.slice(m[0].length).toLowerCase()
141
+ if (unit === '') return dim(0, 0, 0, n)
142
+ if (unit === '%') return dim(0, n)
143
+ if (UNIT_PX[unit] !== undefined) return dim(n * UNIT_PX[unit])
144
+ if (UNIT_DEG[unit] !== undefined) return dim(0, 0, n * UNIT_DEG[unit])
145
+ return null
146
+ }
147
+
148
+ const isPureNumber = (d) => d.px === 0 && d.pct === 0 && d.deg === 0
149
+
150
+ /**
151
+ * `calc()` without an evaluator: the expression is tokenized, then walked by
152
+ * recursive descent over dimension vectors. `*` and `/` demand a dimensionless
153
+ * operand, which is exactly the CSS restriction, so a malformed expression
154
+ * returns `null` instead of a plausible-looking number.
155
+ */
156
+ function calcTokens (s) {
157
+ const out = []
158
+ let i = 0
159
+ while (i < s.length) {
160
+ const c = s[i]
161
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f') { i++; continue }
162
+ if (c === '(' || c === ')' || c === '*' || c === '/') { out.push(c); i++; continue }
163
+ if (c === '+' || c === '-') {
164
+ const prev = out[out.length - 1]
165
+ const opens = prev === undefined || prev === '(' || (typeof prev === 'string' && '+-*/'.indexOf(prev) >= 0)
166
+ const next = s[i + 1] || ''
167
+ if (!(opens && (next >= '0' && next <= '9' || next === '.'))) { out.push(c); i++; continue }
168
+ }
169
+ const m = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?[a-z%]*/i.exec(s.slice(i))
170
+ if (!m) return null
171
+ const v = parseNumeric(m[0])
172
+ if (!v) return null
173
+ out.push(v)
174
+ i += m[0].length
175
+ }
176
+ return out
177
+ }
178
+
179
+ function calcParse (tokens) {
180
+ let i = 0
181
+
182
+ const primary = () => {
183
+ const t = tokens[i]
184
+ if (t === '(') {
185
+ i++
186
+ const v = sum()
187
+ if (tokens[i] !== ')') return null
188
+ i++
189
+ return v
190
+ }
191
+ if (t === '-' || t === '+') {
192
+ i++
193
+ const v = primary()
194
+ if (!v) return null
195
+ return t === '-' ? dim(-v.px, -v.pct, -v.deg, -v.n) : v
196
+ }
197
+ if (t && typeof t === 'object') { i++; return t }
198
+ return null
199
+ }
200
+
201
+ const product = () => {
202
+ let left = primary()
203
+ if (!left) return null
204
+ while (tokens[i] === '*' || tokens[i] === '/') {
205
+ const op = tokens[i++]
206
+ const right = primary()
207
+ if (!right) return null
208
+ if (op === '*') {
209
+ const k = isPureNumber(right) ? right.n : isPureNumber(left) ? left.n : null
210
+ if (k === null) return null
211
+ const other = isPureNumber(right) ? left : right
212
+ left = dim(other.px * k, other.pct * k, other.deg * k, other.n * k)
213
+ } else {
214
+ if (!isPureNumber(right) || right.n === 0) return null
215
+ const k = 1 / right.n
216
+ left = dim(left.px * k, left.pct * k, left.deg * k, left.n * k)
217
+ }
218
+ }
219
+ return left
220
+ }
221
+
222
+ const sum = () => {
223
+ let left = product()
224
+ if (!left) return null
225
+ while (tokens[i] === '+' || tokens[i] === '-') {
226
+ const op = tokens[i++]
227
+ const right = product()
228
+ if (!right) return null
229
+ const s = op === '+' ? 1 : -1
230
+ left = dim(left.px + s * right.px, left.pct + s * right.pct, left.deg + s * right.deg, left.n + s * right.n)
231
+ }
232
+ return left
233
+ }
234
+
235
+ const v = sum()
236
+ return v && i === tokens.length ? v : null
237
+ }
238
+
239
+ /** `<length-percentage>` / `<angle>` / `calc(…)` -> dimension vector, or `null`. */
240
+ function parseDim (tok) {
241
+ const s = tok.trim()
242
+ const lower = s.toLowerCase()
243
+ if (lower.startsWith('calc(') && s.endsWith(')')) {
244
+ const tokens = calcTokens(s.slice(5, -1))
245
+ return tokens ? calcParse(tokens) : null
246
+ }
247
+ return parseNumeric(s)
248
+ }
249
+
250
+ /** True for tokens worth handing to `parseDim` — keeps `red` out of the numeric path. */
251
+ function looksNumeric (tok) {
252
+ if (!tok) return false
253
+ const c = tok[0]
254
+ return (c >= '0' && c <= '9') || c === '.' || c === '+' || c === '-' || /^calc\(/i.test(tok)
255
+ }
256
+
257
+ const resolveLength = (d, ref) => (d && d.n === 0 && d.deg === 0) ? d.px + d.pct / 100 * ref : null
258
+ const resolveAngle = (d) => (d && d.n === 0 && d.px === 0) ? d.deg + d.pct / 100 * 360 : null
259
+
260
+ // ————————————————————————————————————————————————————————————————
261
+ // Colour maths
262
+ // ————————————————————————————————————————————————————————————————
263
+
264
+ /**
265
+ * Interpolate in **premultiplied** sRGB, which is what CSS, SVG and Figma all
266
+ * do. Doing it unpremultiplied is where the grey halo around a fade-to-
267
+ * transparent comes from.
268
+ */
269
+ function lerpColor (c0, c1, t) {
270
+ const a0 = c0[3]
271
+ const a1 = c1[3]
272
+ const a = a0 + (a1 - a0) * t
273
+ if (a <= EPS) return [c0[0] + (c1[0] - c0[0]) * t, c0[1] + (c1[1] - c0[1]) * t, c0[2] + (c1[2] - c0[2]) * t, 0]
274
+ const out = [0, 0, 0, a]
275
+ for (let i = 0; i < 3; i++) {
276
+ const p = c0[i] * a0 + (c1[i] * a1 - c0[i] * a0) * t
277
+ out[i] = clamp(p / a, 0, 1)
278
+ }
279
+ return out
280
+ }
281
+
282
+ const srgbToLinear = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
283
+
284
+ /** sRGB 0..1 -> CIE Lab (D65), the space CIEDE2000 is defined in. */
285
+ function srgbToLab (rgb) {
286
+ const r = srgbToLinear(clamp(rgb[0], 0, 1))
287
+ const g = srgbToLinear(clamp(rgb[1], 0, 1))
288
+ const b = srgbToLinear(clamp(rgb[2], 0, 1))
289
+ const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047
290
+ const y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b) / 1.0
291
+ const z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883
292
+ const f = (t) => t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29
293
+ const fx = f(x)
294
+ const fy = f(y)
295
+ const fz = f(z)
296
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
297
+ }
298
+
299
+ /** CIEDE2000. Verbatim from Sharma et al. 2005; kL = kC = kH = 1. */
300
+ function deltaE00 (lab1, lab2) {
301
+ const [L1, a1, b1] = lab1
302
+ const [L2, a2, b2] = lab2
303
+ const C1 = Math.hypot(a1, b1)
304
+ const C2 = Math.hypot(a2, b2)
305
+ const Cb = (C1 + C2) / 2
306
+ const Cb7 = Math.pow(Cb, 7)
307
+ const G = 0.5 * (1 - Math.sqrt(Cb7 / (Cb7 + 6103515625)))
308
+ const a1p = (1 + G) * a1
309
+ const a2p = (1 + G) * a2
310
+ const C1p = Math.hypot(a1p, b1)
311
+ const C2p = Math.hypot(a2p, b2)
312
+ const hue = (b, ap) => (b === 0 && ap === 0) ? 0 : (Math.atan2(b, ap) * DEG + 360) % 360
313
+ const h1p = hue(b1, a1p)
314
+ const h2p = hue(b2, a2p)
315
+ const dLp = L2 - L1
316
+ const dCp = C2p - C1p
317
+ let dhp = 0
318
+ if (C1p * C2p !== 0) {
319
+ dhp = h2p - h1p
320
+ if (dhp > 180) dhp -= 360
321
+ else if (dhp < -180) dhp += 360
322
+ }
323
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(dhp / 2 * RAD)
324
+ const Lbp = (L1 + L2) / 2
325
+ const Cbp = (C1p + C2p) / 2
326
+ let hbp
327
+ if (C1p * C2p === 0) hbp = h1p + h2p
328
+ else if (Math.abs(h1p - h2p) <= 180) hbp = (h1p + h2p) / 2
329
+ else hbp = (h1p + h2p + (h1p + h2p < 360 ? 360 : -360)) / 2
330
+ const T = 1 - 0.17 * Math.cos((hbp - 30) * RAD) + 0.24 * Math.cos(2 * hbp * RAD) +
331
+ 0.32 * Math.cos((3 * hbp + 6) * RAD) - 0.20 * Math.cos((4 * hbp - 63) * RAD)
332
+ const dTheta = 30 * Math.exp(-Math.pow((hbp - 275) / 25, 2))
333
+ const Cbp7 = Math.pow(Cbp, 7)
334
+ const Rc = 2 * Math.sqrt(Cbp7 / (Cbp7 + 6103515625))
335
+ const Sl = 1 + (0.015 * Math.pow(Lbp - 50, 2)) / Math.sqrt(20 + Math.pow(Lbp - 50, 2))
336
+ const Sc = 1 + 0.045 * Cbp
337
+ const Sh = 1 + 0.015 * Cbp * T
338
+ const Rt = -Math.sin(2 * dTheta * RAD) * Rc
339
+ const tL = dLp / Sl
340
+ const tC = dCp / Sc
341
+ const tH = dHp / Sh
342
+ return Math.sqrt(tL * tL + tC * tC + tH * tH + Rt * tC * tH)
343
+ }
344
+
345
+ /**
346
+ * Perceptual distance between two possibly-translucent colours: the worst case
347
+ * of compositing both over black and over white. A plain ΔE on the unmultiplied
348
+ * channels would call two colours identical when only their alpha differs.
349
+ */
350
+ function colorError (c0, c1) {
351
+ const over = (c, bg) => srgbToLab([
352
+ c[0] * c[3] + bg * (1 - c[3]),
353
+ c[1] * c[3] + bg * (1 - c[3]),
354
+ c[2] * c[3] + bg * (1 - c[3]),
355
+ ])
356
+ return Math.max(deltaE00(over(c0, 0), over(c1, 0)), deltaE00(over(c0, 1), over(c1, 1)))
357
+ }
358
+
359
+ const sameColor = (a, b) =>
360
+ Math.abs(a[0] - b[0]) < 1e-4 && Math.abs(a[1] - b[1]) < 1e-4 &&
361
+ Math.abs(a[2] - b[2]) < 1e-4 && Math.abs(a[3] - b[3]) < 1e-4
362
+
363
+ const stop = (t, color, srcCss) => ({ t, color, srcCss: srcCss || toCssString(color) })
364
+
365
+ // ————————————————————————————————————————————————————————————————
366
+ // Position (`at <position>` / legacy leading position)
367
+ // ————————————————————————————————————————————————————————————————
368
+
369
+ const H_KEY = { left: 0, center: 0.5, right: 1 }
370
+ const V_KEY = { top: 0, center: 0.5, bottom: 1 }
371
+
372
+ /**
373
+ * `<position>` in all four of its arities, resolved to px in the painting box.
374
+ * `null` when the tokens are not a position at all — which is how the radial
375
+ * prelude sniffer tells `at 10px 20px` from a colour stop.
376
+ */
377
+ function parsePosition (tokens, w, h) {
378
+ if (tokens.length === 0 || tokens.length > 4) return null
379
+ let x = null
380
+ let y = null
381
+
382
+ if (tokens.length >= 3) {
383
+ // Edge-offset form: `left 10px top 20px`, `right 5% bottom`.
384
+ let i = 0
385
+ while (i < tokens.length) {
386
+ const k = tokens[i++].toLowerCase()
387
+ if (!POS_KEYWORDS.has(k)) return null
388
+ let off = null
389
+ if (i < tokens.length && !POS_KEYWORDS.has(tokens[i].toLowerCase())) {
390
+ if (!looksNumeric(tokens[i])) return null
391
+ off = parseDim(tokens[i++])
392
+ if (!off) return null
393
+ }
394
+ if (k === 'center') {
395
+ if (off) return null
396
+ if (x === null) x = w / 2
397
+ else if (y === null) y = h / 2
398
+ else return null
399
+ continue
400
+ }
401
+ const vertical = k === 'top' || k === 'bottom'
402
+ const ref = vertical ? h : w
403
+ const d = off ? resolveLength(off, ref) : 0
404
+ if (d === null) return null
405
+ const v = (k === 'right' || k === 'bottom') ? ref - d : d
406
+ if (vertical) { if (y !== null) return null; y = v } else { if (x !== null) return null; x = v }
407
+ }
408
+ } else {
409
+ const a = tokens[0]
410
+ const b = tokens[1]
411
+ const ka = a.toLowerCase()
412
+ const kb = b === undefined ? undefined : b.toLowerCase()
413
+ // `top left` is legal, so a leading vertical keyword swaps the pair.
414
+ const swap = tokens.length === 2 &&
415
+ ((ka === 'top' || ka === 'bottom') || (kb === 'left' || kb === 'right'))
416
+ const first = swap ? b : a
417
+ const second = swap ? a : b
418
+ const axis = (tok, keys, ref) => {
419
+ if (tok === undefined) return ref / 2
420
+ const k = tok.toLowerCase()
421
+ if (keys[k] !== undefined) return keys[k] * ref
422
+ if (!looksNumeric(tok)) return null
423
+ return resolveLength(parseDim(tok), ref)
424
+ }
425
+ if (tokens.length === 1 && (ka === 'top' || ka === 'bottom')) {
426
+ x = w / 2
427
+ y = V_KEY[ka] * h
428
+ } else {
429
+ x = axis(first, H_KEY, w)
430
+ y = axis(second, V_KEY, h)
431
+ }
432
+ if (x === null || y === null) return null
433
+ }
434
+
435
+ return { x: x === null ? w / 2 : x, y: y === null ? h / 2 : y }
436
+ }
437
+
438
+ // ————————————————————————————————————————————————————————————————
439
+ // Geometry
440
+ // ————————————————————————————————————————————————————————————————
441
+
442
+ /**
443
+ * The corner formula from CSS Images 3: for `to top right` the gradient line is
444
+ * angled so that the perpendicular through the centre passes through the two
445
+ * NEIGHBOURING corners (top-left and bottom-right) — it does not point at the
446
+ * corner itself. Perpendicular to (w, h) is (h, −w), and CSS measures clockwise
447
+ * from straight up, hence atan2(h, w). On a 200x100 box that is 26.57deg, not
448
+ * the naive 63.43deg of the diagonal.
449
+ */
450
+ function cornerAngle (w, h) {
451
+ return Math.atan2(h, w) * DEG
452
+ }
453
+
454
+ const SIDE_ANGLE = { top: 0, right: 90, bottom: 180, left: 270 }
455
+
456
+ function sideOrCornerAngle (words, w, h) {
457
+ const set = new Set(words.map((s) => s.toLowerCase()))
458
+ if (set.size !== words.length) return null
459
+ const horiz = set.has('left') ? 'left' : set.has('right') ? 'right' : null
460
+ const vert = set.has('top') ? 'top' : set.has('bottom') ? 'bottom' : null
461
+ if (set.size === 1) {
462
+ const only = words[0].toLowerCase()
463
+ return SIDE_ANGLE[only] === undefined ? null : SIDE_ANGLE[only]
464
+ }
465
+ if (set.size !== 2 || !horiz || !vert) return null
466
+ const a = cornerAngle(w, h)
467
+ if (vert === 'top') return horiz === 'right' ? a : 360 - a
468
+ return horiz === 'right' ? 180 - a : 180 + a
469
+ }
470
+
471
+ /**
472
+ * Gradient line for an angle, per spec: it passes through the centre, and its
473
+ * length is `|w·sinθ| + |h·cosθ|` — the projection of the whole box onto it. So
474
+ * t=0 and t=1 land exactly on the corner projections, and the painted domain is
475
+ * exactly [0,1]. That is what lets the stop list be clamped to [0,1] with no loss.
476
+ */
477
+ function linearLine (angle, w, h) {
478
+ const r = angle * RAD
479
+ const sin = Math.sin(r)
480
+ const cos = Math.cos(r)
481
+ const L = Math.abs(w * sin) + Math.abs(h * cos)
482
+ const dx = sin
483
+ const dy = -cos
484
+ const cx = w / 2
485
+ const cy = h / 2
486
+ return {
487
+ L,
488
+ p0: [cx - dx * L / 2, cy - dy * L / 2],
489
+ p1: [cx + dx * L / 2, cy + dy * L / 2],
490
+ }
491
+ }
492
+
493
+ /** Ending-shape size for every keyword, both shapes. Returns px radii. */
494
+ function radialSize (keyword, shape, cx, cy, w, h) {
495
+ const left = Math.abs(cx)
496
+ const right = Math.abs(w - cx)
497
+ const top = Math.abs(cy)
498
+ const bottom = Math.abs(h - cy)
499
+ if (shape === 'circle') {
500
+ let r
501
+ if (keyword === 'closest-side') r = Math.min(left, right, top, bottom)
502
+ else if (keyword === 'farthest-side') r = Math.max(left, right, top, bottom)
503
+ else {
504
+ const dx = keyword === 'closest-corner' ? Math.min(left, right) : Math.max(left, right)
505
+ const dy = keyword === 'closest-corner' ? Math.min(top, bottom) : Math.max(top, bottom)
506
+ r = Math.hypot(dx, dy)
507
+ }
508
+ return { rx: r, ry: r }
509
+ }
510
+ const near = keyword === 'closest-side' || keyword === 'closest-corner'
511
+ const rx = near ? Math.min(left, right) : Math.max(left, right)
512
+ const ry = near ? Math.min(top, bottom) : Math.max(top, bottom)
513
+ // A corner ellipse keeps the side ellipse's aspect ratio and passes through the
514
+ // corner; solving (dx/rx)²+(dy/ry)²=1 under that constraint collapses to √2.
515
+ const k = (keyword === 'closest-corner' || keyword === 'farthest-corner') ? Math.SQRT2 : 1
516
+ return { rx: rx * k, ry: ry * k }
517
+ }
518
+
519
+ /** Largest `t` any pixel of the box reaches, in ending-shape units. */
520
+ function radialCornerT (cx, cy, rx, ry, w, h) {
521
+ let m = 0
522
+ for (const [x, y] of [[0, 0], [w, 0], [0, h], [w, h]]) {
523
+ m = Math.max(m, Math.hypot((x - cx) / rx, (y - cy) / ry))
524
+ }
525
+ return m
526
+ }
527
+
528
+ // ————————————————————————————————————————————————————————————————
529
+ // Stop list
530
+ // ————————————————————————————————————————————————————————————————
531
+
532
+ /**
533
+ * One comma-separated argument -> zero or more entries. A bare position is a
534
+ * colour hint; a colour with two positions is the shorthand for two stops at the
535
+ * same colour, which is how hard-edged stripes are written.
536
+ */
537
+ function parseStopPart (part, notes) {
538
+ const tokens = splitTop(part, ' ')
539
+ if (tokens.length === 0) return []
540
+ if (tokens.length === 1 && looksNumeric(tokens[0])) {
541
+ const d = parseDim(tokens[0])
542
+ if (!d) { notes.push(`unsupported-value:${tokens[0]}`); return [] }
543
+ return [{ hint: true, d, t: null }]
544
+ }
545
+ const parsed = parseColor(tokens[0])
546
+ if (!parsed) {
547
+ notes.push(`unparsed-color:${tokens[0]}`)
548
+ return []
549
+ }
550
+ const positions = []
551
+ for (let i = 1; i < tokens.length && i < 3; i++) {
552
+ const d = parseDim(tokens[i])
553
+ if (!d) { notes.push(`unsupported-value:${tokens[i]}`); continue }
554
+ positions.push(d)
555
+ }
556
+ if (tokens.length > 3) notes.push(`extra-stop-tokens:${tokens.length - 3}`)
557
+ if (positions.length === 0) return [{ color: parsed.rgba, srcCss: parsed.srcCss, d: null, t: null }]
558
+ return positions.map((d) => ({ color: parsed.rgba, srcCss: parsed.srcCss, d, t: null }))
559
+ }
560
+
561
+ /**
562
+ * CSS Images 3 §3.4.3 colour-stop fixup: default the ends, force the sequence
563
+ * non-decreasing, then spread unpositioned runs evenly. Hints participate as
564
+ * fixed anchors — they always carry a position.
565
+ */
566
+ function fixupPositions (entries) {
567
+ const first = entries[0]
568
+ const last = entries[entries.length - 1]
569
+ if (first.t === null) first.t = 0
570
+ if (last.t === null) last.t = 1
571
+
572
+ let high = -Infinity
573
+ for (const e of entries) {
574
+ if (e.t === null) continue
575
+ if (e.t < high) e.t = high
576
+ else high = e.t
577
+ }
578
+
579
+ for (let i = 0; i < entries.length; i++) {
580
+ if (entries[i].t !== null) continue
581
+ let j = i
582
+ while (j < entries.length && entries[j].t === null) j++
583
+ const before = entries[i - 1].t
584
+ const after = j < entries.length ? entries[j].t : 1
585
+ const n = j - i + 1
586
+ for (let k = i; k < j; k++) entries[k].t = before + (after - before) * (k - i + 1) / n
587
+ i = j - 1
588
+ }
589
+ return entries
590
+ }
591
+
592
+ /**
593
+ * Give every fully transparent stop the RGB of its neighbour. Under
594
+ * premultiplied interpolation the RGB behind a zero alpha is unobservable, so
595
+ * this is lossless — but every backend that interpolates unpremultiplied (and
596
+ * most raster paths do, somewhere) otherwise fades through the black of
597
+ * `rgba(0,0,0,0)`, which is the classic grey halo. When the two neighbours
598
+ * disagree the stop is SPLIT into two coincident ones, one carrying each
599
+ * neighbour's RGB: still invisible, and now correct on both sides.
600
+ */
601
+ function fixTransparentStops (entries, notes) {
602
+ const out = []
603
+ let fixed = 0
604
+ for (let i = 0; i < entries.length; i++) {
605
+ const e = entries[i]
606
+ if (e.hint || e.color[3] > EPS) { out.push(e); continue }
607
+ let prev = null
608
+ let next = null
609
+ for (let j = i - 1; j >= 0; j--) if (!entries[j].hint && entries[j].color[3] > EPS) { prev = entries[j].color; break }
610
+ for (let j = i + 1; j < entries.length; j++) if (!entries[j].hint && entries[j].color[3] > EPS) { next = entries[j].color; break }
611
+ if (!prev && !next) { out.push(e); continue }
612
+ fixed++
613
+ const mk = (c) => ({ t: e.t, color: [c[0], c[1], c[2], 0], srcCss: toCssString([c[0], c[1], c[2], 0]) })
614
+ if (prev && next && !sameColor([prev[0], prev[1], prev[2], 0], [next[0], next[1], next[2], 0])) {
615
+ out.push(mk(prev), mk(next))
616
+ } else {
617
+ out.push(mk(prev || next))
618
+ }
619
+ }
620
+ if (fixed) notes.push(`transparent-neighbour-rgb:${fixed}`)
621
+ return out
622
+ }
623
+
624
+ const SEG_SAMPLES = [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875]
625
+
626
+ /**
627
+ * A colour hint places the 50% point of a transition at an arbitrary position,
628
+ * which is a power curve — `mix(c0, c1, u^k)` with `k = log(.5)/log(ratio)`.
629
+ * No target primitive has that, so it is sampled.
630
+ *
631
+ * Nodes are placed by greedy refinement rather than on a fixed grid: split the
632
+ * segment with the worst error, measure again, repeat. A fixed grid — uniform in
633
+ * position, or uniform in colour — is badly wrong at one end or the other
634
+ * whenever the hint is far from the middle, because the curvature of `u^k` is
635
+ * concentrated there. Refinement finds those places on its own, and stops as
636
+ * soon as the whole segment is under a just-noticeable ΔE00.
637
+ */
638
+ function resampleHint (a, hint, b, notes) {
639
+ const span = b.t - a.t
640
+ if (span <= EPS) { notes.push('hint-degenerate'); return [] }
641
+ const ratio = (hint.t - a.t) / span
642
+ if (sameColor(a.color, b.color)) return []
643
+ if (ratio <= EPS) {
644
+ // Midpoint at the very start: the transition is instantaneous there.
645
+ notes.push('hint-degenerate')
646
+ return [stop(a.t, b.color)]
647
+ }
648
+ if (ratio >= 1 - EPS) {
649
+ notes.push('hint-degenerate')
650
+ return [stop(b.t, a.color)]
651
+ }
652
+ if (Math.abs(ratio - 0.5) < 1e-6) return [] // already linear
653
+
654
+ const k = Math.log(0.5) / Math.log(ratio)
655
+ const curve = (u) => lerpColor(a.color, b.color, Math.pow(clamp(u, 0, 1), k))
656
+ const segError = (u0, u1) => {
657
+ const c0 = curve(u0)
658
+ const c1 = curve(u1)
659
+ let e = 0
660
+ for (const f of SEG_SAMPLES) e = Math.max(e, colorError(curve(u0 + (u1 - u0) * f), lerpColor(c0, c1, f)))
661
+ return e
662
+ }
663
+
664
+ const nodes = [0, 1]
665
+ const errs = [segError(0, 1)]
666
+ while (nodes.length < HINT_MAX) {
667
+ let worst = 0
668
+ for (let i = 1; i < errs.length; i++) if (errs[i] > errs[worst]) worst = i
669
+ if (nodes.length >= HINT_MIN && errs[worst] < HINT_DE) break
670
+ const u0 = nodes[worst]
671
+ const u1 = nodes[worst + 1]
672
+ // Split where the curve is halfway in COLOUR, not in position: on a power
673
+ // curve that is where the remaining error actually divides evenly.
674
+ let um = Math.pow((Math.pow(u0, k) + Math.pow(u1, k)) / 2, 1 / k)
675
+ if (!(um > u0 + EPS && um < u1 - EPS)) um = (u0 + u1) / 2
676
+ nodes.splice(worst + 1, 0, um)
677
+ errs.splice(worst, 1, segError(u0, um), segError(um, u1))
678
+ }
679
+
680
+ notes.push(`hint-resampled:${nodes.length}`)
681
+ const err = Math.max(...errs)
682
+ if (err >= HINT_DE) notes.push(`hint-residual-de:${err.toFixed(2)}`)
683
+ // The two endpoints are the neighbouring stops, already in the list.
684
+ const out = []
685
+ for (let i = 1; i < nodes.length - 1; i++) out.push(stop(a.t + nodes[i] * span, curve(nodes[i])))
686
+ return out
687
+ }
688
+
689
+ function applyHints (entries, notes) {
690
+ const out = []
691
+ for (let i = 0; i < entries.length; i++) {
692
+ const e = entries[i]
693
+ if (!e.hint) { out.push(stop(e.t, e.color, e.srcCss)); continue }
694
+ const a = entries[i - 1]
695
+ const b = entries[i + 1]
696
+ if (!a || !b || a.hint || b.hint) { notes.push('hint-orphan'); continue }
697
+ for (const s of resampleHint(a, e, b, notes)) out.push(s)
698
+ }
699
+ return out
700
+ }
701
+
702
+ /** Colour anywhere on the ramp, with the CSS pad behaviour outside it. */
703
+ function colorAt (stops, t) {
704
+ if (t <= stops[0].t) return stops[0].color
705
+ const last = stops[stops.length - 1]
706
+ if (t >= last.t) return last.color
707
+ for (let i = 1; i < stops.length; i++) {
708
+ const a = stops[i - 1]
709
+ const b = stops[i]
710
+ if (t <= b.t) {
711
+ const span = b.t - a.t
712
+ return span <= EPS ? b.color : lerpColor(a.color, b.color, (t - a.t) / span)
713
+ }
714
+ }
715
+ return last.color
716
+ }
717
+
718
+ /**
719
+ * Restrict the ramp to the domain that is actually painted, inserting the
720
+ * interpolated boundary colours. Lossless: outside [lo,hi] nothing is drawn, and
721
+ * inside it the colours are unchanged.
722
+ */
723
+ function clampDomain (stops, lo, hi) {
724
+ const out = []
725
+ if (stops[0].t < lo - EPS) out.push(stop(lo, colorAt(stops, lo)))
726
+ for (const s of stops) {
727
+ if (s.t < lo - EPS || s.t > hi + EPS) continue
728
+ out.push(stop(clamp(s.t, lo, hi), s.color, s.srcCss))
729
+ }
730
+ if (stops[stops.length - 1].t > hi + EPS) out.push(stop(hi, colorAt(stops, hi)))
731
+ return out.length ? out : [stop(lo, colorAt(stops, lo)), stop(hi, colorAt(stops, hi))]
732
+ }
733
+
734
+ /** Premultiplied average of every stop — what a zero-period repeating gradient renders as. */
735
+ function averageColor (stops) {
736
+ let r = 0
737
+ let g = 0
738
+ let b = 0
739
+ let a = 0
740
+ for (const s of stops) {
741
+ r += s.color[0] * s.color[3]
742
+ g += s.color[1] * s.color[3]
743
+ b += s.color[2] * s.color[3]
744
+ a += s.color[3]
745
+ }
746
+ const n = stops.length
747
+ if (a <= EPS) return [0, 0, 0, 0]
748
+ return [clamp(r / a, 0, 1), clamp(g / a, 0, 1), clamp(b / a, 0, 1), a / n]
749
+ }
750
+
751
+ /**
752
+ * Unroll `repeating-*` across the painted domain. The period is the distance
753
+ * between the first and last stop; copies are laid down until [lo,hi] is covered
754
+ * or the budget is spent, after which the last colour pads and the truncation is
755
+ * reported — `emitted/needed` — rather than left to be discovered visually.
756
+ */
757
+ function unrollRepeat (stops, lo, hi, notes) {
758
+ const period = stops[stops.length - 1].t - stops[0].t
759
+ if (!(period > EPS)) {
760
+ notes.push('repeating-zero-period-averaged')
761
+ const avg = averageColor(stops)
762
+ return [stop(lo, avg), stop(hi, avg)]
763
+ }
764
+ const kMin = Math.floor((lo - stops[stops.length - 1].t) / period)
765
+ const kMax = Math.ceil((hi - stops[0].t) / period)
766
+ const needed = kMax - kMin + 1
767
+ const cap = Math.min(MAX_REPEATS, Math.max(1, Math.floor(MAX_STOPS / stops.length)))
768
+ let copies = needed
769
+ if (copies > cap) {
770
+ notes.push(`repeating-truncated:${cap}/${needed}`)
771
+ copies = cap
772
+ }
773
+ const out = []
774
+ for (let i = 0; i < copies; i++) {
775
+ const shift = (kMin + i) * period
776
+ for (const s of stops) out.push(stop(s.t + shift, s.color, s.srcCss))
777
+ }
778
+ return out
779
+ }
780
+
781
+ /**
782
+ * Drop stops that carry no information: exact duplicates, and third-and-beyond
783
+ * ties at one position. Two DIFFERENT colours at one position are kept — that
784
+ * is a hard edge, the whole basis of CSS stripes, and also what `red 60%,
785
+ * blue 20%` collapses to after the monotonic fixup.
786
+ */
787
+ function dedupe (stops) {
788
+ const out = []
789
+ for (const s of stops) {
790
+ const n = out.length
791
+ if (n && Math.abs(out[n - 1].t - s.t) < 1e-7) {
792
+ if (sameColor(out[n - 1].color, s.color)) continue
793
+ if (n > 1 && Math.abs(out[n - 2].t - s.t) < 1e-7) { out[n - 1] = s; continue }
794
+ }
795
+ out.push(s)
796
+ }
797
+ // A lone stop is a constant colour; spanning it is the only form every backend reads.
798
+ if (out.length === 1) return [stop(0, out[0].color, out[0].srcCss), stop(1, out[0].color, out[0].srcCss)]
799
+ return out
800
+ }
801
+
802
+ // ————————————————————————————————————————————————————————————————
803
+ // Preludes
804
+ // ————————————————————————————————————————————————————————————————
805
+
806
+ /** Legacy `-webkit-` gradients name the START side and measure angles CCW from east. */
807
+ function legacyLinearAngle (tokens, w, h) {
808
+ if (tokens.length === 1 && looksNumeric(tokens[0])) {
809
+ const a = resolveAngle(parseDim(tokens[0]))
810
+ return a === null ? null : ((90 - a) % 360 + 360) % 360
811
+ }
812
+ const flip = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
813
+ const flipped = tokens.map((t) => flip[t.toLowerCase()])
814
+ if (flipped.some((t) => t === undefined)) return null
815
+ return sideOrCornerAngle(flipped, w, h)
816
+ }
817
+
818
+ function radialPrelude (tokens, w, h, notes) {
819
+ let shape = null
820
+ let sizeKeyword = null
821
+ const dims = []
822
+ let at = null
823
+
824
+ const atIndex = tokens.findIndex((t) => t.toLowerCase() === 'at')
825
+ const head = atIndex === -1 ? tokens : tokens.slice(0, atIndex)
826
+ if (atIndex !== -1) {
827
+ at = parsePosition(tokens.slice(atIndex + 1), w, h)
828
+ if (!at) { notes.push('unparsed-position'); at = { x: w / 2, y: h / 2 } }
829
+ }
830
+
831
+ for (const tok of head) {
832
+ const k = tok.toLowerCase()
833
+ if (SHAPE_KEYWORDS.has(k)) { shape = k; continue }
834
+ if (SIZE_KEYWORDS.has(k)) { sizeKeyword = k; continue }
835
+ if (looksNumeric(tok)) {
836
+ const d = parseDim(tok)
837
+ if (d) dims.push(d)
838
+ else notes.push(`unsupported-value:${tok}`)
839
+ continue
840
+ }
841
+ notes.push(`unknown-radial-token:${tok}`)
842
+ }
843
+ return { shape, sizeKeyword, dims, at }
844
+ }
845
+
846
+ const HUE_METHODS = new Set(['shorter', 'longer', 'increasing', 'decreasing'])
847
+
848
+ /**
849
+ * Remove `in <colorspace> [<hue> hue]` from a prelude. The gradient is still
850
+ * interpolated in sRGB — matching a non-sRGB ramp would need the whole stop list
851
+ * resampled through that space, which is a different job — but the DIRECTION has
852
+ * to survive: dropping the prelude wholesale would turn `45deg in oklab` into the
853
+ * default `to bottom`, a geometry error on top of a colour one.
854
+ */
855
+ function stripInterpolationMethod (tokens, notes) {
856
+ const at = tokens.findIndex((t) => t.toLowerCase() === 'in')
857
+ if (at === -1) return null
858
+ let end = at + 1
859
+ const space = tokens[end] || ''
860
+ if (space) end++
861
+ if (HUE_METHODS.has((tokens[end] || '').toLowerCase())) {
862
+ end++
863
+ if ((tokens[end] || '').toLowerCase() === 'hue') end++
864
+ }
865
+ notes.push(`interpolation-space-ignored:${space || '?'}`)
866
+ return tokens.slice(0, at).concat(tokens.slice(end))
867
+ }
868
+
869
+ /** A first argument is a prelude only when it cannot be a colour stop. */
870
+ function isRadialPrelude (part) {
871
+ const tokens = splitTop(part, ' ')
872
+ if (tokens.length === 0) return false
873
+ for (const t of tokens) {
874
+ const k = t.toLowerCase()
875
+ if (k === 'at' || SHAPE_KEYWORDS.has(k) || SIZE_KEYWORDS.has(k)) return true
876
+ }
877
+ return tokens.length <= 2 && tokens.every(looksNumeric)
878
+ }
879
+
880
+ // ————————————————————————————————————————————————————————————————
881
+ // Entry point
882
+ // ————————————————————————————————————————————————————————————————
883
+
884
+ /**
885
+ * @typedef {Object} GradientStop
886
+ * @property {number} t 0..1 along the gradient's own parameter
887
+ * @property {number[]} color [r,g,b,a] floats 0..1, sRGB
888
+ * @property {string} srcCss round-trip form
889
+ */
890
+
891
+ /**
892
+ * @typedef {Object} Paint
893
+ * @property {'linear'|'radial'|'angular'} type
894
+ * @property {Object} unit canonical geometry in the unit square (see file header)
895
+ * @property {number[]} transform [a,b,c,d,e,f] mapping unit space -> painting box px
896
+ * @property {GradientStop[]} stops non-decreasing in `t`, all within 0..1
897
+ * @property {string[]} notes every approximation, named
898
+ */
899
+
900
+ /**
901
+ * Parse one `background-image` layer into an SVD gradient paint.
902
+ *
903
+ * @param {string} css one background-image layer, e.g. "linear-gradient(...)"
904
+ * @param {{w:number,h:number}} box the painting box in px
905
+ * @returns {Paint|null} null when the layer is not a gradient
906
+ */
907
+ export function parseGradient (css, box) {
908
+ if (typeof css !== 'string') return null
909
+ const g = matchGradient(css)
910
+ if (!g) return null
911
+
912
+ const notes = []
913
+ if (g.prefixed) notes.push('legacy-prefixed-syntax')
914
+
915
+ let w = box && Number.isFinite(box.w) ? box.w : 0
916
+ let h = box && Number.isFinite(box.h) ? box.h : 0
917
+ if (!(w > EPS) || !(h > EPS)) {
918
+ notes.push('degenerate-box')
919
+ w = Math.max(w, 1)
920
+ h = Math.max(h, 1)
921
+ }
922
+
923
+ const parts = splitTop(g.args, ',')
924
+ if (parts.length === 0) return null
925
+ if (parts.length > 1) {
926
+ const stripped = stripInterpolationMethod(splitTop(parts[0], ' '), notes)
927
+ if (stripped) {
928
+ if (stripped.length === 0) parts.shift()
929
+ else parts[0] = stripped.join(' ')
930
+ }
931
+ }
932
+
933
+ // ——— geometry ———
934
+ let type
935
+ let unit
936
+ let transform
937
+ let resolvePos // dimension -> t
938
+ let domainHi = 1
939
+ let radial = null
940
+ let consumed = 0
941
+
942
+ if (g.kind === 'linear') {
943
+ const tokens = splitTop(parts[0], ' ')
944
+ let angle = null
945
+ if (tokens.length && tokens[0].toLowerCase() === 'to') {
946
+ angle = sideOrCornerAngle(tokens.slice(1), w, h)
947
+ consumed = 1
948
+ } else if (g.prefixed && tokens.length <= 2 &&
949
+ (looksNumeric(tokens[0]) || POS_KEYWORDS.has(tokens[0].toLowerCase()))) {
950
+ angle = legacyLinearAngle(tokens, w, h)
951
+ consumed = 1
952
+ } else if (tokens.length === 1 && looksNumeric(tokens[0])) {
953
+ angle = resolveAngle(parseDim(tokens[0]))
954
+ consumed = 1
955
+ }
956
+ if (consumed && angle === null) {
957
+ notes.push(`unparsed-direction:${parts[0]}`)
958
+ angle = 180
959
+ }
960
+ if (angle === null) angle = 180 // `to bottom`
961
+ angle = ((angle % 360) + 360) % 360
962
+
963
+ const line = linearLine(angle, w, h)
964
+ type = 'linear'
965
+ unit = { p0: [0, 0], p1: [1, 0], angle }
966
+ const ax = line.p1[0] - line.p0[0]
967
+ const ay = line.p1[1] - line.p0[1]
968
+ // Second basis vector = perpendicular of the first, same length: determinant
969
+ // L² > 0, so the matrix always inverts.
970
+ transform = [ax, ay, -ay, ax, line.p0[0], line.p0[1]]
971
+ resolvePos = (d) => {
972
+ const px = resolveLength(d, line.L)
973
+ return px === null ? null : (line.L > EPS ? px / line.L : 0)
974
+ }
975
+ } else if (g.kind === 'radial') {
976
+ let prelude = { shape: null, sizeKeyword: null, dims: [], at: null }
977
+ if (isRadialPrelude(parts[0])) {
978
+ prelude = radialPrelude(splitTop(parts[0], ' '), w, h, notes)
979
+ consumed = 1
980
+ // `-webkit-radial-gradient(<position>, <shape size>, …)` puts the position
981
+ // first, in its own argument, with no `at`.
982
+ if (g.prefixed && !prelude.at && prelude.dims.length && !prelude.shape && !prelude.sizeKeyword) {
983
+ const pos = parsePosition(splitTop(parts[0], ' '), w, h)
984
+ if (pos && parts.length > 2 && isRadialPrelude(parts[1])) {
985
+ const second = radialPrelude(splitTop(parts[1], ' '), w, h, notes)
986
+ prelude = { ...second, at: pos }
987
+ consumed = 2
988
+ }
989
+ }
990
+ }
991
+ const at = prelude.at || { x: w / 2, y: h / 2 }
992
+ let shape = prelude.shape
993
+ if (!shape) shape = prelude.dims.length === 1 ? 'circle' : 'ellipse'
994
+
995
+ let rx
996
+ let ry
997
+ if (prelude.dims.length >= 1) {
998
+ if (prelude.sizeKeyword) notes.push('radial-size-keyword-and-length')
999
+ const dx = resolveLength(prelude.dims[0], w)
1000
+ const dy = prelude.dims.length > 1 ? resolveLength(prelude.dims[1], h) : dx
1001
+ if (dx === null || dy === null) {
1002
+ notes.push('unresolvable-radial-size')
1003
+ const s = radialSize('farthest-corner', shape, at.x, at.y, w, h)
1004
+ rx = s.rx
1005
+ ry = s.ry
1006
+ } else {
1007
+ rx = Math.abs(dx)
1008
+ ry = shape === 'circle' ? Math.abs(dx) : Math.abs(dy)
1009
+ }
1010
+ } else {
1011
+ const s = radialSize(prelude.sizeKeyword || 'farthest-corner', shape, at.x, at.y, w, h)
1012
+ rx = s.rx
1013
+ ry = s.ry
1014
+ }
1015
+
1016
+ if (!(rx > EPS) || !(ry > EPS)) {
1017
+ // A zero-size ending shape paints the last stop's colour everywhere. Doing
1018
+ // that by shrinking the matrix would hand the backend a singular transform,
1019
+ // so the shape is inflated instead and the ramp collapsed further down.
1020
+ notes.push('degenerate-shape-solid')
1021
+ radial = { at, rx: Math.max(w, h), ry: Math.max(w, h), solid: true }
1022
+ } else {
1023
+ radial = { at, rx, ry, solid: false }
1024
+ }
1025
+ type = 'radial'
1026
+ unit = { center: [0.5, 0.5], radius: 0.5, shape }
1027
+ domainHi = radial.solid ? 1 : radialCornerT(at.x, at.y, radial.rx, radial.ry, w, h)
1028
+ resolvePos = (d) => {
1029
+ const px = resolveLength(d, radial.rx)
1030
+ return px === null ? null : (radial.rx > EPS ? px / radial.rx : 0)
1031
+ }
1032
+ transform = null // built after scaling
1033
+ } else {
1034
+ let from = 0
1035
+ let at = null
1036
+ if (parts.length > 1) {
1037
+ const tokens = splitTop(parts[0], ' ')
1038
+ const k0 = tokens[0] ? tokens[0].toLowerCase() : ''
1039
+ if (k0 === 'from' || k0 === 'at') {
1040
+ consumed = 1
1041
+ let i = 0
1042
+ if (tokens[0].toLowerCase() === 'from') {
1043
+ const a = resolveAngle(parseDim(tokens[1] || ''))
1044
+ if (a === null) notes.push(`unparsed-angle:${tokens[1] || ''}`)
1045
+ else from = a
1046
+ i = 2
1047
+ }
1048
+ if (tokens[i] && tokens[i].toLowerCase() === 'at') {
1049
+ at = parsePosition(tokens.slice(i + 1), w, h)
1050
+ if (!at) notes.push('unparsed-position')
1051
+ }
1052
+ }
1053
+ }
1054
+ const c = at || { x: w / 2, y: h / 2 }
1055
+ // Uniform scale only: a conic gradient's angles are true screen angles, so
1056
+ // any anisotropy in the matrix would bend them. The square is sized to cover
1057
+ // the box from wherever the centre sits.
1058
+ const reach = Math.max(
1059
+ Math.hypot(c.x, c.y), Math.hypot(w - c.x, c.y),
1060
+ Math.hypot(c.x, h - c.y), Math.hypot(w - c.x, h - c.y)) || Math.max(w, h)
1061
+ const d = 2 * reach
1062
+ type = 'angular'
1063
+ unit = { center: [0.5, 0.5], startAngle: ((from % 360) + 360) % 360 }
1064
+ transform = [d, 0, 0, d, c.x - d / 2, c.y - d / 2]
1065
+ resolvePos = (dm) => {
1066
+ const a = resolveAngle(dm)
1067
+ return a === null ? null : a / 360
1068
+ }
1069
+ }
1070
+
1071
+ // ——— stops ———
1072
+ const raw = []
1073
+ for (let i = consumed; i < parts.length; i++) {
1074
+ for (const e of parseStopPart(parts[i], notes)) raw.push(e)
1075
+ }
1076
+ for (const e of raw) {
1077
+ if (e.d) {
1078
+ const t = resolvePos(e.d)
1079
+ if (t === null) notes.push('unresolvable-stop-position')
1080
+ else e.t = t
1081
+ }
1082
+ }
1083
+ while (raw.length && raw[0].hint) { raw.shift(); notes.push('hint-orphan') }
1084
+ while (raw.length && raw[raw.length - 1].hint) { raw.pop(); notes.push('hint-orphan') }
1085
+ if (raw.length === 0) {
1086
+ notes.push('no-parsable-stops')
1087
+ raw.push({ color: [0, 0, 0, 0], srcCss: 'rgba(0, 0, 0, 0)', d: null, t: 0 })
1088
+ }
1089
+
1090
+ fixupPositions(raw)
1091
+ const fixed = fixTransparentStops(raw, notes)
1092
+ let stops = applyHints(fixed, notes)
1093
+ if (stops.length === 0) stops = [stop(0, [0, 0, 0, 0])]
1094
+
1095
+ if (g.repeating && stops.length > 1) stops = unrollRepeat(stops, 0, domainHi, notes)
1096
+ stops = clampDomain(stops, 0, domainHi)
1097
+
1098
+ // ——— radial: absorb any overshoot into the shape so every `t` lands in 0..1 ———
1099
+ if (type === 'radial') {
1100
+ if (radial.solid) {
1101
+ const c = stops[stops.length - 1].color
1102
+ stops = [stop(0, c), stop(1, c)]
1103
+ }
1104
+ const maxT = stops[stops.length - 1].t
1105
+ const s = maxT > 1 + EPS ? maxT : 1
1106
+ if (s !== 1) for (const st of stops) st.t /= s
1107
+ const rx = radial.rx * s
1108
+ const ry = radial.ry * s
1109
+ transform = [2 * rx, 0, 0, 2 * ry, radial.at.x - rx, radial.at.y - ry]
1110
+ }
1111
+
1112
+ // Unrolling adds `k * period` to every position and the radial overshoot divides
1113
+ // them all by `s`; both accumulate float error, so two stops emitted in order can
1114
+ // come back 1e-16 apart the WRONG way and fail the schema's monotonicity check.
1115
+ // The order is real, the epsilon is not — so the order is what is kept. This runs
1116
+ // before `dedupe` so stops that become equal here still collapse.
1117
+ let prev = 0
1118
+ for (const st of stops) {
1119
+ st.t = clamp(st.t, 0, 1)
1120
+ if (st.t < prev) st.t = prev
1121
+ prev = st.t
1122
+ }
1123
+ stops = dedupe(stops)
1124
+
1125
+ return { type, unit, transform, stops, notes }
1126
+ }