@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.
- package/CHANGELOG.md +13 -0
- package/README.md +54 -20
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +17 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- 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
|
+
}
|