@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,823 @@
1
+ /**
2
+ * Any CSS color -> `[r, g, b, a]` floats 0..1 in sRGB (svd invariant 4).
3
+ *
4
+ * Two paths, deliberately:
5
+ *
6
+ * - Everything with a closed-form definition — hex, named, `rgb()`, `hsl()`,
7
+ * `hwb()`, `lab()`, `lch()`, `oklab()`, `oklch()`, `color()` — is parsed and
8
+ * converted right here, in pure JS. That is what keeps this file importable
9
+ * from Node with no DOM, which the contract requires so the harness can fuzz it.
10
+ * - Anything else a canvas understands but this file does not — `color-mix()`,
11
+ * relative color syntax, a `calc()` inside a component — goes to a 1x1 canvas:
12
+ * set it as `fillStyle`, paint, read the pixel. Cheap and authoritative by
13
+ * construction. With no DOM there is nothing to ask, so those return `null`
14
+ * rather than a guess.
15
+ *
16
+ * What the canvas does NOT cover is anything that needs an element to resolve
17
+ * against, because a canvas has none: measured in Chrome, `fillStyle` rejects
18
+ * `light-dark()` and every system color (`AccentColor`, `ButtonFace`). Those return
19
+ * `null`. In practice they do not arrive — `getComputedStyle` has already resolved
20
+ * them to `rgb()` by the time a collector reads a property — and a caller holding
21
+ * an authored string has the element that this module, by contract, does not.
22
+ *
23
+ * When a DOM IS present the modern color functions take BOTH paths, and the split
24
+ * is deliberate. The maths decides whether the color falls outside sRGB, which a
25
+ * pixel read cannot report. If it is INSIDE, the canvas supplies the final channel
26
+ * values, because what the engine paints is what a capture has to match — and the
27
+ * two agree to within 1/255 anyway, measured against Chrome. If it is OUTSIDE, the
28
+ * maths wins, because an sRGB canvas resolves such a color by clipping each channel
29
+ * and that is exactly the answer this module may never give.
30
+ *
31
+ * Gamut mapping is chroma reduction in OKLCh (CSS Color 4 §13), never per-channel
32
+ * clipping. The difference is not academic: clipping turns `oklch(0.7 0.4 30)`
33
+ * from a coral into pure red, and `color(display-p3 0 1 0)` into pure green,
34
+ * discarding the hue and lightness the author chose. Chroma reduction keeps both
35
+ * and gives up only saturation, which is the one dimension sRGB genuinely lacks.
36
+ *
37
+ * Alpha never comes from a pixel — canvas storage is premultiplied, so reading it
38
+ * back costs precision. It is parsed exactly and the string handed to the canvas is
39
+ * built without it.
40
+ *
41
+ * Every approximation lands in `warnings` on the returned object. Nothing here
42
+ * degrades silently.
43
+ */
44
+
45
+ // ——— cache ———
46
+ //
47
+ // A capture asks for the same handful of strings thousands of times (one per
48
+ // element per color-valued property), and the modern paths cost a matrix chain
49
+ // plus a GPU readback. Keyed on the raw string, so `red` and `RED` are two
50
+ // entries and both are right.
51
+
52
+ const CACHE = new Map()
53
+ const CACHE_MAX = 4096
54
+
55
+ /**
56
+ * The 148 CSS named colors, packed. Built into a Map on first named lookup — a
57
+ * document made entirely of hex and `rgb()` never pays for it. `grey` spellings
58
+ * are separate entries because CSS defines them as separate names, not aliases
59
+ * resolved at parse time.
60
+ */
61
+ const NAMED_PACKED =
62
+ 'aliceblue f0f8ff,antiquewhite faebd7,aqua 00ffff,aquamarine 7fffd4,azure f0ffff,' +
63
+ 'beige f5f5dc,bisque ffe4c4,black 000000,blanchedalmond ffebcd,blue 0000ff,' +
64
+ 'blueviolet 8a2be2,brown a52a2a,burlywood deb887,cadetblue 5f9ea0,chartreuse 7fff00,' +
65
+ 'chocolate d2691e,coral ff7f50,cornflowerblue 6495ed,cornsilk fff8dc,crimson dc143c,' +
66
+ 'cyan 00ffff,darkblue 00008b,darkcyan 008b8b,darkgoldenrod b8860b,darkgray a9a9a9,' +
67
+ 'darkgreen 006400,darkgrey a9a9a9,darkkhaki bdb76b,darkmagenta 8b008b,' +
68
+ 'darkolivegreen 556b2f,darkorange ff8c00,darkorchid 9932cc,darkred 8b0000,' +
69
+ 'darksalmon e9967a,darkseagreen 8fbc8f,darkslateblue 483d8b,darkslategray 2f4f4f,' +
70
+ 'darkslategrey 2f4f4f,darkturquoise 00ced1,darkviolet 9400d3,deeppink ff1493,' +
71
+ 'deepskyblue 00bfff,dimgray 696969,dimgrey 696969,dodgerblue 1e90ff,firebrick b22222,' +
72
+ 'floralwhite fffaf0,forestgreen 228b22,fuchsia ff00ff,gainsboro dcdcdc,ghostwhite f8f8ff,' +
73
+ 'gold ffd700,goldenrod daa520,gray 808080,green 008000,greenyellow adff2f,grey 808080,' +
74
+ 'honeydew f0fff0,hotpink ff69b4,indianred cd5c5c,indigo 4b0082,ivory fffff0,khaki f0e68c,' +
75
+ 'lavender e6e6fa,lavenderblush fff0f5,lawngreen 7cfc00,lemonchiffon fffacd,' +
76
+ 'lightblue add8e6,lightcoral f08080,lightcyan e0ffff,lightgoldenrodyellow fafad2,' +
77
+ 'lightgray d3d3d3,lightgreen 90ee90,lightgrey d3d3d3,lightpink ffb6c1,lightsalmon ffa07a,' +
78
+ 'lightseagreen 20b2aa,lightskyblue 87cefa,lightslategray 778899,lightslategrey 778899,' +
79
+ 'lightsteelblue b0c4de,lightyellow ffffe0,lime 00ff00,limegreen 32cd32,linen faf0e6,' +
80
+ 'magenta ff00ff,maroon 800000,mediumaquamarine 66cdaa,mediumblue 0000cd,' +
81
+ 'mediumorchid ba55d3,mediumpurple 9370db,mediumseagreen 3cb371,mediumslateblue 7b68ee,' +
82
+ 'mediumspringgreen 00fa9a,mediumturquoise 48d1cc,mediumvioletred c71585,' +
83
+ 'midnightblue 191970,mintcream f5fffa,mistyrose ffe4e1,moccasin ffe4b5,' +
84
+ 'navajowhite ffdead,navy 000080,oldlace fdf5e6,olive 808000,olivedrab 6b8e23,' +
85
+ 'orange ffa500,orangered ff4500,orchid da70d6,palegoldenrod eee8aa,palegreen 98fb98,' +
86
+ 'paleturquoise afeeee,palevioletred db7093,papayawhip ffefd5,peachpuff ffdab9,' +
87
+ 'peru cd853f,pink ffc0cb,plum dda0dd,powderblue b0e0e6,purple 800080,' +
88
+ 'rebeccapurple 663399,red ff0000,rosybrown bc8f8f,royalblue 4169e1,saddlebrown 8b4513,' +
89
+ 'salmon fa8072,sandybrown f4a460,seagreen 2e8b57,seashell fff5ee,sienna a0522d,' +
90
+ 'silver c0c0c0,skyblue 87ceeb,slateblue 6a5acd,slategray 708090,slategrey 708090,' +
91
+ 'snow fffafa,springgreen 00ff7f,steelblue 4682b4,tan d2b48c,teal 008080,thistle d8bfd8,' +
92
+ 'tomato ff6347,turquoise 40e0d0,violet ee82ee,wheat f5deb3,white ffffff,' +
93
+ 'whitesmoke f5f5f5,yellow ffff00,yellowgreen 9acd32'
94
+
95
+ let NAMED = null
96
+
97
+ function namedColor(name) {
98
+ if (!NAMED) {
99
+ NAMED = new Map()
100
+ for (const entry of NAMED_PACKED.split(',')) {
101
+ const sp = entry.indexOf(' ')
102
+ NAMED.set(entry.slice(0, sp), parseInt(entry.slice(sp + 1), 16))
103
+ }
104
+ }
105
+ return NAMED.get(name)
106
+ }
107
+
108
+ // ——— colorimetry ———
109
+ //
110
+ // Matrices are CSS Color 4's own, to the precision it publishes them, so a value
111
+ // that round-trips through here matches what a browser computes to the last bit
112
+ // an 8-bit channel can hold. Flat row-major; `mul` is the only consumer.
113
+
114
+ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
115
+
116
+ function mul(m, v) {
117
+ return [
118
+ m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
119
+ m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
120
+ m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
121
+ ]
122
+ }
123
+
124
+ /**
125
+ * sRGB transfer functions, sign-preserving. An out-of-gamut color reaches these
126
+ * with negative channels, and `Math.pow(-0.3, 1/2.4)` is NaN — mirroring through
127
+ * zero instead keeps the value readable so the gamut mapper can act on it.
128
+ */
129
+ function encodeSrgb(c) {
130
+ const a = Math.abs(c)
131
+ return a <= 0.0031308 ? c * 12.92 : Math.sign(c) * (1.055 * Math.pow(a, 1 / 2.4) - 0.055)
132
+ }
133
+
134
+ function decodeSrgb(c) {
135
+ const a = Math.abs(c)
136
+ return a <= 0.04045 ? c / 12.92 : Math.sign(c) * Math.pow((a + 0.055) / 1.055, 2.4)
137
+ }
138
+
139
+ const XYZ_TO_LINEAR_SRGB = [
140
+ 3.2409699419045226, -1.537383177570094, -0.4986107602930034,
141
+ -0.9692436362808796, 1.8759675015077202, 0.04155505740717559,
142
+ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786,
143
+ ]
144
+
145
+ /** Bradford-adapted, per CSS Color 4 — `lab()` and ProPhoto are D50, sRGB is D65. */
146
+ const D50_TO_D65 = [
147
+ 0.9554734527042182, -0.023098536874261423, 0.0632593086610217,
148
+ -0.028369706963208136, 1.0099954580058226, 0.021041398966943008,
149
+ 0.012314001688319899, -0.020507696433477912, 1.3303659366080753,
150
+ ]
151
+
152
+ const D50 = [0.3457 / 0.3585, 1, (1 - 0.3457 - 0.3585) / 0.3585]
153
+
154
+ const KAPPA = 24389 / 27
155
+ const EPSILON = 216 / 24389
156
+
157
+ /** CIE Lab (D50) -> linear sRGB. */
158
+ function labToLinearSrgb(L, a, b) {
159
+ const fy = (L + 16) / 116
160
+ const fx = a / 500 + fy
161
+ const fz = fy - b / 200
162
+ const fx3 = fx * fx * fx
163
+ const fz3 = fz * fz * fz
164
+ const xyz = [
165
+ (fx3 > EPSILON ? fx3 : (116 * fx - 16) / KAPPA) * D50[0],
166
+ (L > KAPPA * EPSILON ? Math.pow(fy, 3) : L / KAPPA) * D50[1],
167
+ (fz3 > EPSILON ? fz3 : (116 * fz - 16) / KAPPA) * D50[2],
168
+ ]
169
+ return mul(XYZ_TO_LINEAR_SRGB, mul(D50_TO_D65, xyz))
170
+ }
171
+
172
+ const OKLAB_TO_LMS = [
173
+ 1, 0.3963377773761749, 0.2158037573099136,
174
+ 1, -0.1055613458156586, -0.0638541728258133,
175
+ 1, -0.0894841775298119, -1.2914855480194092,
176
+ ]
177
+
178
+ const LMS_TO_LINEAR_SRGB = [
179
+ 4.0767416621, -3.3077115913, 0.2309699292,
180
+ -1.2684380046, 2.6097574011, -0.3413193965,
181
+ -0.0041960863, -0.7034186147, 1.7076147010,
182
+ ]
183
+
184
+ const LINEAR_SRGB_TO_LMS = [
185
+ 0.4122214708, 0.5363325363, 0.0514459929,
186
+ 0.2119034982, 0.6806995451, 0.1073969566,
187
+ 0.0883024619, 0.2817188376, 0.6299787005,
188
+ ]
189
+
190
+ const LMS_TO_OKLAB = [
191
+ 0.2104542553, 0.7936177850, -0.0040720468,
192
+ 1.9779984951, -2.4285922050, 0.4505937099,
193
+ 0.0259040371, 0.7827717662, -0.8086757660,
194
+ ]
195
+
196
+ function oklabToLinearSrgb(L, a, b) {
197
+ const lms = mul(OKLAB_TO_LMS, [L, a, b])
198
+ return mul(LMS_TO_LINEAR_SRGB, [lms[0] ** 3, lms[1] ** 3, lms[2] ** 3])
199
+ }
200
+
201
+ function linearSrgbToOklab(lin) {
202
+ const lms = mul(LINEAR_SRGB_TO_LMS, lin)
203
+ return mul(LMS_TO_OKLAB, [Math.cbrt(lms[0]), Math.cbrt(lms[1]), Math.cbrt(lms[2])])
204
+ }
205
+
206
+ /** Polar -> rectangular, shared by `lch()` and `oklch()`. */
207
+ function polarToRect(C, hDeg) {
208
+ const h = (hDeg * Math.PI) / 180
209
+ return [C * Math.cos(h), C * Math.sin(h)]
210
+ }
211
+
212
+ // ——— gamut mapping (CSS Color 4 §13) ———
213
+
214
+ /**
215
+ * Float noise from a matrix chain lands a few ulps outside 0..1 for colors that
216
+ * are exactly on the gamut boundary — `color(display-p3 0 1 0)`'s green stays put
217
+ * but `lab()` white does not. Below this the value is snapped, not mapped, and no
218
+ * warning is raised: nothing was approximated, the arithmetic just breathed.
219
+ */
220
+ const GAMUT_TOL = 1e-5
221
+
222
+ /** Just-noticeable difference and search precision, both fixed by the spec. */
223
+ const JND = 0.02
224
+ const MAP_EPS = 0.0001
225
+
226
+ function inGamut(rgb) {
227
+ return rgb[0] >= -GAMUT_TOL && rgb[0] <= 1 + GAMUT_TOL &&
228
+ rgb[1] >= -GAMUT_TOL && rgb[1] <= 1 + GAMUT_TOL &&
229
+ rgb[2] >= -GAMUT_TOL && rgb[2] <= 1 + GAMUT_TOL
230
+ }
231
+
232
+ const clipRgb = (rgb) => [clamp(rgb[0], 0, 1), clamp(rgb[1], 0, 1), clamp(rgb[2], 0, 1)]
233
+
234
+ /** Perceptual distance, plain Euclidean in OKLab — that is what OKLab is for. */
235
+ function deltaEOK(a, b) {
236
+ const dl = a[0] - b[0]
237
+ const da = a[1] - b[1]
238
+ const db = a[2] - b[2]
239
+ return Math.sqrt(dl * dl + da * da + db * db)
240
+ }
241
+
242
+ /** OKLCh -> gamma-encoded sRGB, unclamped. */
243
+ function oklchToSrgb(L, C, H) {
244
+ const [a, b] = polarToRect(C, H)
245
+ const lin = oklabToLinearSrgb(L, a, b)
246
+ return [encodeSrgb(lin[0]), encodeSrgb(lin[1]), encodeSrgb(lin[2])]
247
+ }
248
+
249
+ /**
250
+ * `css-gamut-map` verbatim: binary-search the chroma downward, keeping lightness
251
+ * and hue, until clipping what remains costs less than one JND. The clip inside
252
+ * the loop is not a fallback to per-channel clipping — it is the spec's own
253
+ * "close enough to stop" test, applied to a color already pulled to the boundary.
254
+ *
255
+ * @returns {{rgb: number[], deltaE: number}} gamma-encoded sRGB, in gamut.
256
+ */
257
+ function gamutMapOklch(L, C, H) {
258
+ if (L >= 1) return { rgb: [1, 1, 1], deltaE: 0 }
259
+ if (L <= 0) return { rgb: [0, 0, 0], deltaE: 0 }
260
+
261
+ /** Distance from a candidate chroma's clipped rendering to the ideal color at that chroma. */
262
+ const cost = (chroma, rgb) => {
263
+ const [ca, cb] = polarToRect(chroma, H)
264
+ return deltaEOK(linearSrgbToOklab(clipRgb(rgb).map(decodeSrgb)), [L, ca, cb])
265
+ }
266
+
267
+ let current = oklchToSrgb(L, C, H)
268
+ let delta = cost(C, current)
269
+ if (delta < JND) return { rgb: clipRgb(current), deltaE: delta }
270
+
271
+ let min = 0
272
+ let max = C
273
+ let minInGamut = true
274
+ while (max - min > MAP_EPS) {
275
+ const chroma = (min + max) / 2
276
+ current = oklchToSrgb(L, chroma, H)
277
+ if (minInGamut && inGamut(current)) {
278
+ min = chroma
279
+ continue
280
+ }
281
+ delta = cost(chroma, current)
282
+ if (delta < JND) {
283
+ if (JND - delta < MAP_EPS) return { rgb: clipRgb(current), deltaE: delta }
284
+ minInGamut = false
285
+ min = chroma
286
+ } else {
287
+ max = chroma
288
+ }
289
+ }
290
+ current = oklchToSrgb(L, min, H)
291
+ return { rgb: clipRgb(current), deltaE: cost(min, current) }
292
+ }
293
+
294
+ // ——— canvas ———
295
+ //
296
+ // Created on first use and never at module scope: the contract requires this file
297
+ // to import cleanly into Node, where none of these globals exist.
298
+
299
+ let CTX
300
+ let CTX_TRIED = false
301
+
302
+ function context() {
303
+ if (CTX_TRIED) return CTX
304
+ CTX_TRIED = true
305
+ try {
306
+ let canvas = null
307
+ if (typeof OffscreenCanvas === 'function') {
308
+ canvas = new OffscreenCanvas(1, 1)
309
+ } else if (typeof document !== 'undefined' && document.createElement) {
310
+ canvas = document.createElement('canvas')
311
+ canvas.width = 1
312
+ canvas.height = 1
313
+ }
314
+ if (canvas) CTX = canvas.getContext('2d', { willReadFrequently: true }) || undefined
315
+ } catch {
316
+ CTX = undefined
317
+ }
318
+ return CTX
319
+ }
320
+
321
+ /**
322
+ * An invalid `fillStyle` assignment is a no-op, so the property keeps whatever it
323
+ * held. Probing from two different values is what tells "the engine rejected it"
324
+ * from "the engine accepted it and it happens to equal the previous value".
325
+ */
326
+ function accepted(ctx, css) {
327
+ ctx.fillStyle = '#000000'
328
+ ctx.fillStyle = css
329
+ const first = ctx.fillStyle
330
+ ctx.fillStyle = '#ffffff'
331
+ ctx.fillStyle = css
332
+ return ctx.fillStyle === first
333
+ }
334
+
335
+ /** `copy` rather than the default `source-over`, so the previous pixel cannot blend in. */
336
+ function pixel(ctx, css) {
337
+ ctx.globalCompositeOperation = 'copy'
338
+ ctx.fillStyle = css
339
+ ctx.fillRect(0, 0, 1, 1)
340
+ return ctx.getImageData(0, 0, 1, 1).data
341
+ }
342
+
343
+ /** Opaque color string -> `[r, g, b]` 0..1, or `null` with no DOM / on rejection. */
344
+ function canvasRgb(css) {
345
+ const ctx = context()
346
+ if (!ctx) return null
347
+ try {
348
+ if (!accepted(ctx, css)) return null
349
+ const d = pixel(ctx, css)
350
+ return [d[0] / 255, d[1] / 255, d[2] / 255]
351
+ } catch {
352
+ return null
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Last resort for syntax this file does not model. Alpha comes back through the
358
+ * canvas's premultiplied store, so a translucent result is approximate in all four
359
+ * channels — hence the warning.
360
+ */
361
+ function canvasColor(css, warnings) {
362
+ const ctx = context()
363
+ if (!ctx) return null
364
+ let d
365
+ try {
366
+ if (!accepted(ctx, css)) return null
367
+ d = pixel(ctx, css)
368
+ } catch {
369
+ return null
370
+ }
371
+ const a = d[3] / 255
372
+ if (a > 0 && a < 1) {
373
+ warnings.push(`"${css}" was resolved by painting a canvas pixel; its channels are un-premultiplied from 8-bit storage and are approximate.`)
374
+ }
375
+ return { rgba: [d[0] / 255, d[1] / 255, d[2] / 255, a], warnings }
376
+ }
377
+
378
+ // ——— syntax ———
379
+
380
+ const NUMBER = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(%?)$/i
381
+ const ANGLE = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(deg|grad|rad|turn)?$/i
382
+
383
+ /**
384
+ * A component value. `pctBase` is what `100%` means in this slot, which differs
385
+ * per function and per channel — 255 in `rgb()`, 125 in `lab()`'s a/b, 0.4 in
386
+ * `oklch()`'s chroma. `none` is 0: for an absolute color the spec's "missing"
387
+ * component behaves as zero, and nothing downstream of svd carries missingness.
388
+ */
389
+ function number(token, pctBase) {
390
+ if (token === 'none') return 0
391
+ const m = NUMBER.exec(token)
392
+ if (!m) return NaN
393
+ const v = parseFloat(m[1])
394
+ if (!Number.isFinite(v)) return NaN
395
+ return m[2] ? (v / 100) * pctBase : v
396
+ }
397
+
398
+ function angle(token) {
399
+ if (token === 'none') return 0
400
+ const m = ANGLE.exec(token)
401
+ if (!m) return NaN
402
+ const v = parseFloat(m[1])
403
+ if (!Number.isFinite(v)) return NaN
404
+ switch (m[2] || 'deg') {
405
+ case 'grad': return v * 0.9
406
+ case 'rad': return (v * 180) / Math.PI
407
+ case 'turn': return v * 360
408
+ default: return v
409
+ }
410
+ }
411
+
412
+ function alphaOf(token) {
413
+ if (token === null) return 1
414
+ const v = number(token, 1)
415
+ return Number.isNaN(v) ? NaN : clamp(v, 0, 1)
416
+ }
417
+
418
+ /**
419
+ * Split a function's argument list into components plus alpha, covering both
420
+ * grammars at once: legacy `rgb(1, 2, 3, .5)` and modern `rgb(1 2 3 / .5)`.
421
+ * Nested parens survive whole, so a `calc()` in a slot arrives as one unparseable
422
+ * token and falls through to the canvas rather than being sliced into nonsense.
423
+ *
424
+ * @returns {{comps: string[], alpha: string|null}|null}
425
+ */
426
+ function splitComponents(raw) {
427
+ const tokens = []
428
+ let depth = 0
429
+ let cur = ''
430
+ let sawComma = false
431
+ for (let i = 0; i < raw.length; i++) {
432
+ const ch = raw[i]
433
+ if (ch === '(') depth++
434
+ else if (ch === ')') { if (depth > 0) depth-- }
435
+ if (depth === 0 && (ch === ',' || ch === '/' || ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f')) {
436
+ if (cur) { tokens.push(cur); cur = '' }
437
+ if (ch === ',') { tokens.push(','); sawComma = true } else if (ch === '/') tokens.push('/')
438
+ continue
439
+ }
440
+ cur += ch
441
+ }
442
+ if (cur) tokens.push(cur)
443
+
444
+ const comps = []
445
+ let alpha = null
446
+ let slash = false
447
+ for (const t of tokens) {
448
+ if (t === ',') continue
449
+ if (t === '/') {
450
+ if (slash) return null
451
+ slash = true
452
+ continue
453
+ }
454
+ if (slash) {
455
+ if (alpha !== null) return null
456
+ alpha = t
457
+ continue
458
+ }
459
+ comps.push(t)
460
+ }
461
+ if (slash && alpha === null) return null
462
+ // `rgba(r, g, b, a)`: in the comma grammar the alpha is just the fourth value.
463
+ if (!slash && sawComma && comps.length === 4) alpha = comps.pop()
464
+ return { comps, alpha }
465
+ }
466
+
467
+ // ——— per-function conversion ———
468
+
469
+ function hslToRgb(h, s, l) {
470
+ const hue = ((h % 360) + 360) % 360
471
+ const a = s * Math.min(l, 1 - l)
472
+ const f = (n) => {
473
+ const k = (n + hue / 30) % 12
474
+ return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
475
+ }
476
+ return [f(0), f(8), f(4)]
477
+ }
478
+
479
+ function hwbToRgb(h, w, b) {
480
+ if (w + b >= 1) {
481
+ const g = w / (w + b)
482
+ return [g, g, g]
483
+ }
484
+ return hslToRgb(h, 1, 0.5).map((v) => v * (1 - w - b) + w)
485
+ }
486
+
487
+ /**
488
+ * `color()` predefined spaces. `to` is the space's linear-light -> XYZ matrix, or
489
+ * `null` for the two that are already linear sRGB and would only pick up float
490
+ * noise from a round trip through XYZ.
491
+ */
492
+ const COLOR_SPACES = {
493
+ srgb: { to: null, decode: decodeSrgb, d50: false },
494
+ 'srgb-linear': { to: null, decode: (c) => c, d50: false },
495
+ 'display-p3': {
496
+ to: [
497
+ 0.4865709486482162, 0.26566769316909306, 0.1982172852343625,
498
+ 0.2289745640697488, 0.6917385218365064, 0.079286914093745,
499
+ 0, 0.04511338185890264, 1.043944368900976,
500
+ ],
501
+ decode: decodeSrgb,
502
+ d50: false,
503
+ },
504
+ 'a98-rgb': {
505
+ to: [
506
+ 0.5766690429101305, 0.1855582379065463, 0.1882286462349947,
507
+ 0.29734497525053605, 0.6273635662554661, 0.07529145849399788,
508
+ 0.02703136138641234, 0.07068885253582723, 0.9913375368376388,
509
+ ],
510
+ // A98 is a pure power curve, exponent 563/256.
511
+ decode: (c) => Math.sign(c) * Math.pow(Math.abs(c), 563 / 256),
512
+ d50: false,
513
+ },
514
+ 'prophoto-rgb': {
515
+ to: [
516
+ 0.7977604896723027, 0.13518583717574031, 0.0313493495815248,
517
+ 0.2880711282292934, 0.7118432178101014, 0.00008565396060525902,
518
+ 0, 0, 0.8251046025104601,
519
+ ],
520
+ decode: (c) => (Math.abs(c) <= 16 / 512 ? c / 16 : Math.sign(c) * Math.pow(Math.abs(c), 1.8)),
521
+ d50: true,
522
+ },
523
+ rec2020: {
524
+ to: [
525
+ 0.6369580483012914, 0.14461690358620832, 0.1688809751641721,
526
+ 0.2627002120112671, 0.6779980715188708, 0.05930171646986196,
527
+ 0, 0.028072693049087428, 1.060985057710791,
528
+ ],
529
+ decode: (c) => {
530
+ const a = 1.09929682680944
531
+ const b = 0.018053968510807
532
+ const abs = Math.abs(c)
533
+ return abs < b * 4.5 ? c / 4.5 : Math.sign(c) * Math.pow((abs + a - 1) / a, 1 / 0.45)
534
+ },
535
+ d50: false,
536
+ },
537
+ }
538
+
539
+ const XYZ_SPACES = new Set(['xyz', 'xyz-d65', 'xyz-d50'])
540
+
541
+ /**
542
+ * Finish a wide-gamut color: gamut-map if needed, then let the engine overrule the
543
+ * channel values. Both halves matter — the maths knows the color left sRGB, the
544
+ * engine knows what it will actually paint, and disagreeing with the engine on a
545
+ * capture is worse than a few ulps of drift.
546
+ *
547
+ * @param {number[]} lin linear-light sRGB, possibly out of gamut
548
+ * @param {string} canonical the same color as an alpha-free CSS string
549
+ * @param {number} alpha
550
+ * @param {string} src the caller's original string, for the warning text
551
+ */
552
+ function wideGamut(lin, canonical, alpha, src) {
553
+ const warnings = []
554
+ const rgb = [encodeSrgb(lin[0]), encodeSrgb(lin[1]), encodeSrgb(lin[2])]
555
+
556
+ if (inGamut(rgb)) {
557
+ // Inside the gamut the two agree to within 1/255 — measured against Chrome
558
+ // across `lab()`, `lch()`, `oklab()`, `oklch()` and all eight `color()`
559
+ // spaces — so deferring costs nothing and buys exactness against the render.
560
+ const painted = canvasRgb(canonical)
561
+ return { rgba: [...(painted || clipRgb(rgb)), alpha], warnings }
562
+ }
563
+
564
+ const [L, a, b] = linearSrgbToOklab(lin)
565
+ const mapped = gamutMapOklch(L, Math.hypot(a, b), (Math.atan2(b, a) * 180) / Math.PI)
566
+ warnings.push(`"${src}" is outside the sRGB gamut; mapped by chroma reduction in OKLCh (ΔEOK ${mapped.deltaE.toFixed(4)}).`)
567
+ // The canvas is deliberately NOT consulted on this branch. Its backing store is
568
+ // sRGB and it resolves an out-of-gamut colour by clipping each channel, which is
569
+ // the one answer this module may never return: measured, Chrome turns
570
+ // `oklch(0.7 0.4 30)` into pure red and `color(display-p3 0 1 0)` into pure
571
+ // green, hue and lightness gone. Chroma reduction keeps both, so here the maths
572
+ // outranks the engine.
573
+ return { rgba: [...mapped.rgb, alpha], warnings }
574
+ }
575
+
576
+ /** Trim trailing zeros so the string handed to the canvas stays short and exact. */
577
+ const fmt = (n) => (Number.isInteger(n) ? String(n) : String(Number(n.toFixed(6))))
578
+
579
+ function parseFunction(fn, raw, src) {
580
+ const split = splitComponents(raw)
581
+ if (!split) return null
582
+ const { comps, alpha: alphaToken } = split
583
+ const alpha = alphaOf(alphaToken)
584
+ if (Number.isNaN(alpha)) return null
585
+
586
+ switch (fn) {
587
+ case 'rgb':
588
+ case 'rgba': {
589
+ if (comps.length !== 3) return null
590
+ const c = []
591
+ for (const t of comps) {
592
+ const v = number(t, 255)
593
+ if (Number.isNaN(v)) return null
594
+ c.push(clamp(v, 0, 255) / 255)
595
+ }
596
+ return { rgba: [c[0], c[1], c[2], alpha], warnings: [] }
597
+ }
598
+
599
+ case 'hsl':
600
+ case 'hsla': {
601
+ if (comps.length !== 3) return null
602
+ const h = angle(comps[0])
603
+ // Bare numbers are legal in the modern grammar and mean percent, so the
604
+ // base is 100 either way and `50` and `50%` land in the same place.
605
+ const s = number(comps[1], 100)
606
+ const l = number(comps[2], 100)
607
+ if (Number.isNaN(h + s + l)) return null
608
+ const rgb = hslToRgb(h, clamp(s, 0, 100) / 100, clamp(l, 0, 100) / 100)
609
+ return { rgba: [rgb[0], rgb[1], rgb[2], alpha], warnings: [] }
610
+ }
611
+
612
+ case 'hwb': {
613
+ if (comps.length !== 3) return null
614
+ const h = angle(comps[0])
615
+ const w = number(comps[1], 100)
616
+ const b = number(comps[2], 100)
617
+ if (Number.isNaN(h + w + b)) return null
618
+ const rgb = hwbToRgb(h, clamp(w, 0, 100) / 100, clamp(b, 0, 100) / 100)
619
+ return { rgba: [rgb[0], rgb[1], rgb[2], alpha], warnings: [] }
620
+ }
621
+
622
+ case 'lab': {
623
+ if (comps.length !== 3) return null
624
+ const L = number(comps[0], 100)
625
+ const a = number(comps[1], 125)
626
+ const b = number(comps[2], 125)
627
+ if (Number.isNaN(L + a + b)) return null
628
+ const l = Math.max(0, L)
629
+ return wideGamut(labToLinearSrgb(l, a, b), `lab(${fmt(l)} ${fmt(a)} ${fmt(b)})`, alpha, src)
630
+ }
631
+
632
+ case 'lch': {
633
+ if (comps.length !== 3) return null
634
+ const L = number(comps[0], 100)
635
+ const C = number(comps[1], 150)
636
+ const H = angle(comps[2])
637
+ if (Number.isNaN(L + C + H)) return null
638
+ const l = Math.max(0, L)
639
+ const c = Math.max(0, C)
640
+ const [a, b] = polarToRect(c, H)
641
+ return wideGamut(labToLinearSrgb(l, a, b), `lch(${fmt(l)} ${fmt(c)} ${fmt(H)})`, alpha, src)
642
+ }
643
+
644
+ case 'oklab': {
645
+ if (comps.length !== 3) return null
646
+ const L = number(comps[0], 1)
647
+ const a = number(comps[1], 0.4)
648
+ const b = number(comps[2], 0.4)
649
+ if (Number.isNaN(L + a + b)) return null
650
+ const l = Math.max(0, L)
651
+ return wideGamut(oklabToLinearSrgb(l, a, b), `oklab(${fmt(l)} ${fmt(a)} ${fmt(b)})`, alpha, src)
652
+ }
653
+
654
+ case 'oklch': {
655
+ if (comps.length !== 3) return null
656
+ const L = number(comps[0], 1)
657
+ const C = number(comps[1], 0.4)
658
+ const H = angle(comps[2])
659
+ if (Number.isNaN(L + C + H)) return null
660
+ const l = Math.max(0, L)
661
+ const c = Math.max(0, C)
662
+ const [a, b] = polarToRect(c, H)
663
+ return wideGamut(oklabToLinearSrgb(l, a, b), `oklch(${fmt(l)} ${fmt(c)} ${fmt(H)})`, alpha, src)
664
+ }
665
+
666
+ case 'color': {
667
+ if (comps.length !== 4) return null
668
+ const space = comps[0]
669
+ const v = []
670
+ for (let i = 1; i < 4; i++) {
671
+ const n = number(comps[i], 1)
672
+ if (Number.isNaN(n)) return null
673
+ v.push(n)
674
+ }
675
+ const canonical = `color(${space} ${fmt(v[0])} ${fmt(v[1])} ${fmt(v[2])})`
676
+
677
+ if (XYZ_SPACES.has(space)) {
678
+ const xyz = space === 'xyz-d50' ? mul(D50_TO_D65, v) : v
679
+ return wideGamut(mul(XYZ_TO_LINEAR_SRGB, xyz), canonical, alpha, src)
680
+ }
681
+ const def = COLOR_SPACES[space]
682
+ // A custom `@color-profile` space, or one newer than this table. The engine
683
+ // may still know it, so hand the whole thing over rather than guessing.
684
+ if (!def) return null
685
+
686
+ const linear = v.map(def.decode)
687
+ if (!def.to) return wideGamut(linear, canonical, alpha, src)
688
+ let xyz = mul(def.to, linear)
689
+ if (def.d50) xyz = mul(D50_TO_D65, xyz)
690
+ return wideGamut(mul(XYZ_TO_LINEAR_SRGB, xyz), canonical, alpha, src)
691
+ }
692
+
693
+ default:
694
+ return null
695
+ }
696
+ }
697
+
698
+ /** Expand one hex digit to a byte: `f` -> `0xff`, the way `#fff` means `#ffffff`. */
699
+ const expand = (c) => {
700
+ const v = parseInt(c, 16)
701
+ return v * 16 + v
702
+ }
703
+
704
+ function parseHex(s) {
705
+ const h = s.slice(1)
706
+ if (!/^[0-9a-f]+$/i.test(h)) return null
707
+ let r, g, b
708
+ let a = 255
709
+ if (h.length === 3 || h.length === 4) {
710
+ r = expand(h[0]); g = expand(h[1]); b = expand(h[2])
711
+ if (h.length === 4) a = expand(h[3])
712
+ } else if (h.length === 6 || h.length === 8) {
713
+ r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16)
714
+ if (h.length === 8) a = parseInt(h.slice(6, 8), 16)
715
+ } else {
716
+ return null
717
+ }
718
+ return { rgba: [r / 255, g / 255, b / 255, a / 255], warnings: [] }
719
+ }
720
+
721
+ function resolve(css) {
722
+ const s = css.trim()
723
+ if (!s) return null
724
+ const lower = s.toLowerCase()
725
+
726
+ if (lower === 'transparent') return { rgba: [0, 0, 0, 0], warnings: [] }
727
+ // Per the contract the caller resolves `currentColor` before getting here; a
728
+ // computed style never carries it. Returning null rather than guessing black is
729
+ // what makes a caller that forgot fail loudly.
730
+ if (lower === 'currentcolor') return null
731
+
732
+ if (s[0] === '#') return parseHex(s)
733
+
734
+ const open = s.indexOf('(')
735
+ if (open > 0 && s.endsWith(')')) {
736
+ // Lowercased for the function name, the `none` keyword and the `color()` space
737
+ // ident. Numbers and units are unaffected, and a custom dashed-ident space is
738
+ // not modelled here anyway.
739
+ const out = parseFunction(lower.slice(0, open).trim(), lower.slice(open + 1, -1), s)
740
+ if (out) return out
741
+ return canvasColor(s, [])
742
+ }
743
+
744
+ const packed = namedColor(lower)
745
+ if (packed !== undefined) {
746
+ return { rgba: [(packed >> 16) / 255, ((packed >> 8) & 255) / 255, (packed & 255) / 255, 1], warnings: [] }
747
+ }
748
+
749
+ // A bare keyword this table does not hold — a system color, or something newer
750
+ // than CSS Color 4's list. Worth one canvas probe for the latter, though a
751
+ // system color will not survive it: it needs an element to resolve against and a
752
+ // canvas has none, so this returns null. See the header.
753
+ return canvasColor(s, [])
754
+ }
755
+
756
+ /**
757
+ * Parse any CSS color into sRGB.
758
+ *
759
+ * @param {string} css
760
+ * @returns {{rgba: number[], srcCss: string, warnings: string[]}|null}
761
+ * `rgba` is `[r, g, b, a]`, floats 0..1, sRGB. `srcCss` is the caller's string
762
+ * verbatim, so an emitter can round-trip the authored value instead of
763
+ * re-serialising it. `warnings` lists every approximation made — gamut mapping,
764
+ * canvas resolution — and is empty for the exact paths, which is most of them.
765
+ * `null` when the string is not a color, or is one only the engine can resolve
766
+ * (`color-mix()`, relative syntax) and there is no DOM to ask. `currentColor` is
767
+ * `null` by design: only the caller can resolve it.
768
+ *
769
+ * The returned object and both arrays are freshly allocated, so a caller may
770
+ * keep or mutate `rgba` without corrupting the shared cache behind it.
771
+ */
772
+ export function parseColor(css) {
773
+ if (typeof css !== 'string') return null
774
+ let hit = CACHE.get(css)
775
+ if (hit === undefined) {
776
+ hit = resolve(css)
777
+ // Flat reset rather than an LRU: the working set of a capture is a few hundred
778
+ // strings, so this only ever fires on pathological input, where paying to
779
+ // rebuild beats paying to track recency on every hit.
780
+ if (CACHE.size >= CACHE_MAX) CACHE.clear()
781
+ CACHE.set(css, hit)
782
+ }
783
+ if (!hit) return null
784
+ return { rgba: hit.rgba.slice(), srcCss: css, warnings: hit.warnings.slice() }
785
+ }
786
+
787
+ const hex2 = (v) => clamp(Math.round(v * 255), 0, 255).toString(16).padStart(2, '0')
788
+
789
+ /**
790
+ * `[r, g, b, a]` -> a CSS string for SVG output.
791
+ *
792
+ * Opaque colors become `#rrggbb`, everything else `rgba(r, g, b, a)`: SVG 1.1
793
+ * consumers (Figma's importer among them) read both, but only `#rrggbb` survives
794
+ * `fill=` in every one of them. Alpha keeps four decimals — enough that 8-bit
795
+ * source alpha round-trips exactly, short enough not to bloat the markup.
796
+ *
797
+ * @param {number[]} rgba floats 0..1; a missing alpha is treated as opaque
798
+ * @returns {string}
799
+ */
800
+ export function toCssString(rgba) {
801
+ if (!rgba || typeof rgba.length !== 'number' || rgba.length < 3) {
802
+ throw new TypeError('toCssString: expected [r, g, b, a]')
803
+ }
804
+ const r = Number(rgba[0]) || 0
805
+ const g = Number(rgba[1]) || 0
806
+ const b = Number(rgba[2]) || 0
807
+ const a = rgba.length > 3 && Number.isFinite(Number(rgba[3])) ? clamp(Number(rgba[3]), 0, 1) : 1
808
+ if (a >= 1) return `#${hex2(r)}${hex2(g)}${hex2(b)}`
809
+ const alpha = Number(a.toFixed(4))
810
+ return `rgba(${clamp(Math.round(r * 255), 0, 255)}, ${clamp(Math.round(g * 255), 0, 255)}, ${clamp(Math.round(b * 255), 0, 255)}, ${alpha})`
811
+ }
812
+
813
+ /**
814
+ * Fully transparent — nothing to paint, so the caller can drop the fill entirely.
815
+ *
816
+ * @param {number[]} rgba
817
+ * @returns {boolean}
818
+ * `true` for a missing or non-finite alpha too: a color nobody can make sense of
819
+ * is not one to paint, and the alternative is emitting a NaN into the document.
820
+ */
821
+ export function isTransparent(rgba) {
822
+ return !rgba || !(Number(rgba[3]) > 0)
823
+ }