@thejaredwilcurt/csslop 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -0
- package/index.js +5 -0
- package/package.json +48 -0
- package/src/context.js +17 -0
- package/src/declarations/config.js +56 -0
- package/src/declarations/process.js +662 -0
- package/src/index.js +90 -0
- package/src/position-try.js +199 -0
- package/src/preprocess.js +27 -0
- package/src/rules/normalize.js +108 -0
- package/src/rules/optimize.js +375 -0
- package/src/rules/stringify.js +556 -0
- package/src/utilities.js +37 -0
- package/src/value/colors.js +780 -0
- package/src/value/gradients.js +121 -0
- package/src/value/math.js +281 -0
- package/src/value/minify.js +716 -0
- package/src/value/named-colors.js +159 -0
- package/src/value/shared.js +146 -0
- package/src/value/transforms.js +222 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Provides color parsing, color space conversion (sRGB, OKLab, OKLCH, HSL, HWB), color-mix evaluation, and hex formatting for CSS value minification.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { namedColors } from './named-colors.js';
|
|
6
|
+
import {
|
|
7
|
+
parseAlphaString,
|
|
8
|
+
roundCompactNumber
|
|
9
|
+
} from './shared.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reverse lookup from "r,g,b" to the shortest CSS named color keyword.
|
|
13
|
+
* Built once at module load from the namedColors map.
|
|
14
|
+
*
|
|
15
|
+
* @type {Map<string, string>}
|
|
16
|
+
*/
|
|
17
|
+
const rgbToShortestName = new Map();
|
|
18
|
+
for (const [name, channels] of Object.entries(namedColors)) {
|
|
19
|
+
const key = channels[0] + ',' + channels[1] + ',' + channels[2];
|
|
20
|
+
const existing = rgbToShortestName.get(key);
|
|
21
|
+
if (!existing || name.length < existing.length) {
|
|
22
|
+
rgbToShortestName.set(key, name);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Converts HSL color values to RGB channel values in the 0–255 range.
|
|
28
|
+
*
|
|
29
|
+
* @param {number} hue The hue angle in degrees.
|
|
30
|
+
* @param {number} saturation The saturation as a fraction from 0 to 1.
|
|
31
|
+
* @param {number} lightness The lightness as a fraction from 0 to 1.
|
|
32
|
+
* @return {Array} An array of [r, g, b] channel values, each 0–255.
|
|
33
|
+
*/
|
|
34
|
+
function hslToRgbChannels (hue, saturation, lightness) {
|
|
35
|
+
const normalizedHue = (((hue % 360) + 360) % 360) / 360;
|
|
36
|
+
const normalizedSaturation = Math.max(0, Math.min(1, saturation));
|
|
37
|
+
const normalizedLightness = Math.max(0, Math.min(1, lightness));
|
|
38
|
+
|
|
39
|
+
if (normalizedSaturation === 0) {
|
|
40
|
+
const channel = Math.round(normalizedLightness * 255);
|
|
41
|
+
return [channel, channel, channel];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hue2rgb = (p, q, t) => {
|
|
45
|
+
if (t < 0) {
|
|
46
|
+
t += 1;
|
|
47
|
+
}
|
|
48
|
+
if (t > 1) {
|
|
49
|
+
t -= 1;
|
|
50
|
+
}
|
|
51
|
+
if (t < 1 / 6) {
|
|
52
|
+
return p + (q - p) * 6 * t;
|
|
53
|
+
}
|
|
54
|
+
if (t < 1 / 2) {
|
|
55
|
+
return q;
|
|
56
|
+
}
|
|
57
|
+
if (t < 2 / 3) {
|
|
58
|
+
return p + (q - p) * (2 / 3 - t) * 6;
|
|
59
|
+
}
|
|
60
|
+
return p;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const q = normalizedLightness < 0.5 ?
|
|
64
|
+
normalizedLightness * (1 + normalizedSaturation) :
|
|
65
|
+
normalizedLightness + normalizedSaturation - normalizedLightness * normalizedSaturation;
|
|
66
|
+
const p = 2 * normalizedLightness - q;
|
|
67
|
+
|
|
68
|
+
return [
|
|
69
|
+
Math.round(hue2rgb(p, q, normalizedHue + 1 / 3) * 255),
|
|
70
|
+
Math.round(hue2rgb(p, q, normalizedHue) * 255),
|
|
71
|
+
Math.round(hue2rgb(p, q, normalizedHue - 1 / 3) * 255)
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Converts RGBA channel values to a hex color string, omitting the alpha suffix when fully opaque.
|
|
77
|
+
*
|
|
78
|
+
* @param {number} r The red channel value, 0–255.
|
|
79
|
+
* @param {number} g The green channel value, 0–255.
|
|
80
|
+
* @param {number} b The blue channel value, 0–255.
|
|
81
|
+
* @param {number} alpha The alpha value from 0 to 1.
|
|
82
|
+
* @return {string} A hex color string like "#rrggbb" or "#rrggbbaa".
|
|
83
|
+
*/
|
|
84
|
+
function rgbaToHex (r, g, b, alpha = 1) {
|
|
85
|
+
const rgbHex = [r, g, b].map((channel) => {
|
|
86
|
+
const value = Math.max(0, Math.min(255, Math.round(channel)));
|
|
87
|
+
return value.toString(16).padStart(2, '0');
|
|
88
|
+
}).join('');
|
|
89
|
+
const normalizedAlpha = Math.max(0, Math.min(1, alpha));
|
|
90
|
+
const alphaByte = Math.round(normalizedAlpha * 255);
|
|
91
|
+
const alphaHex = alphaByte === 255 ? '' : alphaByte.toString(16).padStart(2, '0');
|
|
92
|
+
return '#' + rgbHex + alphaHex;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Finds the shortest CSS representation of an RGBA color by comparing
|
|
97
|
+
* the full hex, shortened hex (collapsed digit pairs), and any matching
|
|
98
|
+
* named color keyword. Prefers hex when representations tie in length.
|
|
99
|
+
*
|
|
100
|
+
* @param {number} r The red channel value, 0–255.
|
|
101
|
+
* @param {number} g The green channel value, 0–255.
|
|
102
|
+
* @param {number} b The blue channel value, 0–255.
|
|
103
|
+
* @param {number} alpha The alpha value from 0 to 1.
|
|
104
|
+
* @return {string} The shortest CSS color string.
|
|
105
|
+
*/
|
|
106
|
+
function shortestColor (r, g, b, alpha = 1) {
|
|
107
|
+
r = Math.max(0, Math.min(255, Math.round(r)));
|
|
108
|
+
g = Math.max(0, Math.min(255, Math.round(g)));
|
|
109
|
+
b = Math.max(0, Math.min(255, Math.round(b)));
|
|
110
|
+
alpha = Math.max(0, Math.min(1, alpha));
|
|
111
|
+
|
|
112
|
+
const fullHex = rgbaToHex(r, g, b, alpha);
|
|
113
|
+
|
|
114
|
+
// Try collapsing identical hex digit pairs: #rrggbbaa → #rgba, #rrggbb → #rgb
|
|
115
|
+
let shortest = fullHex;
|
|
116
|
+
const match8 = fullHex.match(/^#([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3([0-9a-f])\4$/);
|
|
117
|
+
if (match8) {
|
|
118
|
+
shortest = '#' + match8[1] + match8[2] + match8[3] + match8[4];
|
|
119
|
+
} else {
|
|
120
|
+
const match6 = fullHex.match(/^#([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3$/);
|
|
121
|
+
if (match6) {
|
|
122
|
+
shortest = '#' + match6[1] + match6[2] + match6[3];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check named colors (only fully opaque colors have named equivalents)
|
|
127
|
+
const alphaByte = Math.round(alpha * 255);
|
|
128
|
+
if (alphaByte === 255) {
|
|
129
|
+
const key = r + ',' + g + ',' + b;
|
|
130
|
+
const name = rgbToShortestName.get(key);
|
|
131
|
+
if (name && name.length < shortest.length) {
|
|
132
|
+
shortest = name;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return shortest;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Converts HWB (hue, whiteness, blackness) color values to RGB channel values in the 0–255 range.
|
|
141
|
+
*
|
|
142
|
+
* @param {number} hue The hue angle in degrees.
|
|
143
|
+
* @param {number} whiteness The whiteness as a fraction from 0 to 1.
|
|
144
|
+
* @param {number} blackness The blackness as a fraction from 0 to 1.
|
|
145
|
+
* @return {Array} An array of [r, g, b] channel values, each 0–255.
|
|
146
|
+
*/
|
|
147
|
+
function hwbToRgbChannels (hue, whiteness, blackness) {
|
|
148
|
+
let w = Math.max(0, Math.min(1, whiteness));
|
|
149
|
+
let b = Math.max(0, Math.min(1, blackness));
|
|
150
|
+
|
|
151
|
+
if (w + b >= 1) {
|
|
152
|
+
const gray = Math.round((w / (w + b)) * 255);
|
|
153
|
+
return [gray, gray, gray];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const [r, g, bl] = hslToRgbChannels(hue, 1, 0.5);
|
|
157
|
+
const ratio = 1 - w - b;
|
|
158
|
+
return [
|
|
159
|
+
Math.round(r / 255 * ratio * 255 + w * 255),
|
|
160
|
+
Math.round(g / 255 * ratio * 255 + w * 255),
|
|
161
|
+
Math.round(bl / 255 * ratio * 255 + w * 255)
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Converts a gamma-encoded sRGB component to linear light using the sRGB transfer function.
|
|
167
|
+
*
|
|
168
|
+
* @param {number} c The gamma-encoded sRGB component, 0 to 1.
|
|
169
|
+
* @return {number} The linearized component value.
|
|
170
|
+
*/
|
|
171
|
+
function linearize (c) {
|
|
172
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Applies sRGB gamma encoding to a linear-light component, the inverse of linearize.
|
|
177
|
+
*
|
|
178
|
+
* @param {number} c The linear-light component, 0 to 1.
|
|
179
|
+
* @return {number} The gamma-encoded sRGB component value.
|
|
180
|
+
*/
|
|
181
|
+
function delinearize (c) {
|
|
182
|
+
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Converts sRGB color components (0–1) to the OKLab perceptual color space.
|
|
187
|
+
*
|
|
188
|
+
* @param {number} r The red component, 0 to 1.
|
|
189
|
+
* @param {number} g The green component, 0 to 1.
|
|
190
|
+
* @param {number} b The blue component, 0 to 1.
|
|
191
|
+
* @return {object} An object with L, a, b OKLab components.
|
|
192
|
+
*/
|
|
193
|
+
function srgbToOklab (r, g, b) {
|
|
194
|
+
const lr = linearize(r);
|
|
195
|
+
const lg = linearize(g);
|
|
196
|
+
const lb = linearize(b);
|
|
197
|
+
|
|
198
|
+
const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
199
|
+
const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
200
|
+
const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
|
204
|
+
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
|
205
|
+
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Converts OKLab color components to unclamped linear sRGB, used for gamut-boundary checking.
|
|
211
|
+
*
|
|
212
|
+
* @param {number} L The OKLab lightness component.
|
|
213
|
+
* @param {number} a The OKLab green-red axis component.
|
|
214
|
+
* @param {number} b The OKLab blue-yellow axis component.
|
|
215
|
+
* @return {object} An object with r, g, b linear sRGB components (may exceed 0–1).
|
|
216
|
+
*/
|
|
217
|
+
function oklabToLinearSrgb (L, a, b) {
|
|
218
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
219
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
220
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
221
|
+
|
|
222
|
+
const l = l_ * l_ * l_;
|
|
223
|
+
const m = m_ * m_ * m_;
|
|
224
|
+
const s = s_ * s_ * s_;
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
228
|
+
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
229
|
+
b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Converts OKLab color components to clamped, gamma-encoded sRGB (0–1).
|
|
235
|
+
*
|
|
236
|
+
* @param {number} L The OKLab lightness component.
|
|
237
|
+
* @param {number} a The OKLab green-red axis component.
|
|
238
|
+
* @param {number} b The OKLab blue-yellow axis component.
|
|
239
|
+
* @return {object} An object with r, g, b sRGB components, each clamped to 0–1.
|
|
240
|
+
*/
|
|
241
|
+
function oklabToSrgb (L, a, b) {
|
|
242
|
+
const linear = oklabToLinearSrgb(L, a, b);
|
|
243
|
+
return {
|
|
244
|
+
r: Math.max(0, Math.min(1, delinearize(linear.r))),
|
|
245
|
+
g: Math.max(0, Math.min(1, delinearize(linear.g))),
|
|
246
|
+
b: Math.max(0, Math.min(1, delinearize(linear.b)))
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Converts OKLab color components to the OKLCH cylindrical representation.
|
|
252
|
+
*
|
|
253
|
+
* @param {number} L The OKLab lightness component.
|
|
254
|
+
* @param {number} a The OKLab green-red axis component.
|
|
255
|
+
* @param {number} b The OKLab blue-yellow axis component.
|
|
256
|
+
* @return {object} An object with L (lightness), C (chroma), H (hue in degrees).
|
|
257
|
+
*/
|
|
258
|
+
function oklabToOklch (L, a, b) {
|
|
259
|
+
const C = Math.sqrt(a * a + b * b);
|
|
260
|
+
let H = Math.atan2(b, a) * (180 / Math.PI);
|
|
261
|
+
if (H < 0) {
|
|
262
|
+
H += 360;
|
|
263
|
+
}
|
|
264
|
+
return { L, C, H };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Converts OKLCH cylindrical color components back to OKLab rectangular coordinates.
|
|
269
|
+
*
|
|
270
|
+
* @param {number} L The OKLCH lightness component.
|
|
271
|
+
* @param {number} C The chroma value.
|
|
272
|
+
* @param {number} H The hue angle in degrees.
|
|
273
|
+
* @return {object} An object with L, a, b OKLab components.
|
|
274
|
+
*/
|
|
275
|
+
function oklchToOklab (L, C, H) {
|
|
276
|
+
const hRad = H * Math.PI / 180;
|
|
277
|
+
return {
|
|
278
|
+
L,
|
|
279
|
+
a: C * Math.cos(hRad),
|
|
280
|
+
b: C * Math.sin(hRad)
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Interpolates between two hue angles along the shorter arc, per the CSS Color specification.
|
|
286
|
+
*
|
|
287
|
+
* @param {number} h1 The first hue angle in degrees.
|
|
288
|
+
* @param {number} h2 The second hue angle in degrees.
|
|
289
|
+
* @param {number} t The interpolation factor from 0 to 1.
|
|
290
|
+
* @return {number} The interpolated hue angle in degrees, normalized to 0–360.
|
|
291
|
+
*/
|
|
292
|
+
function interpolateHueShorter (h1, h2, t) {
|
|
293
|
+
let diff = h2 - h1;
|
|
294
|
+
if (diff > 180) {
|
|
295
|
+
diff -= 360;
|
|
296
|
+
}
|
|
297
|
+
if (diff < -180) {
|
|
298
|
+
diff += 360;
|
|
299
|
+
}
|
|
300
|
+
let result = h1 + diff * t;
|
|
301
|
+
return ((result % 360) + 360) % 360;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Parse a hex color string to [r, g, b, a].
|
|
306
|
+
*
|
|
307
|
+
* @param {string} hex The hex color string (3, 4, 6, or 8 hex digits, with or without leading #).
|
|
308
|
+
* @return {Array|null} An [r, g, b, a] array (r,g,b: 0–255, a: 0–1), or null for invalid lengths.
|
|
309
|
+
*/
|
|
310
|
+
function parseHex (hex) {
|
|
311
|
+
// Strip leading # from hex string
|
|
312
|
+
hex = hex.replace(/^#/, '');
|
|
313
|
+
if (hex.length === 3) {
|
|
314
|
+
return [parseInt(hex[0] + hex[0], 16), parseInt(hex[1] + hex[1], 16), parseInt(hex[2] + hex[2], 16), 1];
|
|
315
|
+
}
|
|
316
|
+
if (hex.length === 4) {
|
|
317
|
+
return [parseInt(hex[0] + hex[0], 16), parseInt(hex[1] + hex[1], 16), parseInt(hex[2] + hex[2], 16), parseInt(hex[3] + hex[3], 16) / 255];
|
|
318
|
+
}
|
|
319
|
+
if (hex.length === 6) {
|
|
320
|
+
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), 1];
|
|
321
|
+
}
|
|
322
|
+
if (hex.length === 8) {
|
|
323
|
+
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), parseInt(hex.slice(6, 8), 16) / 255];
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Parse a CSS color string to [r, g, b, a] (r,g,b: 0-255, a: 0-1) or null if unresolvable.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} colorString The CSS color string (named, hex, rgb, rgba, hsl, hsla, or hwb).
|
|
332
|
+
* @return {Array|null} An [r, g, b, a] array, or null if the color cannot be parsed.
|
|
333
|
+
*/
|
|
334
|
+
function parseColor (colorString) {
|
|
335
|
+
let normalized = colorString.trim().toLowerCase();
|
|
336
|
+
|
|
337
|
+
// Handle 'none' keyword in channels
|
|
338
|
+
normalized = normalized.replace(/\bnone\b/g, '0');
|
|
339
|
+
|
|
340
|
+
// Named color
|
|
341
|
+
if (namedColors[normalized]) {
|
|
342
|
+
return [...namedColors[normalized], 1];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Hex
|
|
346
|
+
if (normalized.startsWith('#')) {
|
|
347
|
+
return parseHex(normalized);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// rgb() / rgba() space syntax
|
|
351
|
+
let match = normalized.match(/^rgba?\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/);
|
|
352
|
+
if (match) {
|
|
353
|
+
const r = Math.round(parseFloat(match[1]));
|
|
354
|
+
const g = Math.round(parseFloat(match[2]));
|
|
355
|
+
const b = Math.round(parseFloat(match[3]));
|
|
356
|
+
return [r, g, b, Math.max(0, Math.min(1, parseAlphaString(match[4])))];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// rgb() / rgba() comma syntax
|
|
360
|
+
match = normalized.match(/^rgba?\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)(?:\s*,\s*(-?[\d.]+%?))?\s*\)$/);
|
|
361
|
+
if (match) {
|
|
362
|
+
const r = Math.round(parseFloat(match[1]));
|
|
363
|
+
const g = Math.round(parseFloat(match[2]));
|
|
364
|
+
const b = Math.round(parseFloat(match[3]));
|
|
365
|
+
return [r, g, b, Math.max(0, Math.min(1, parseAlphaString(match[4])))];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// hsl() / hsla() space syntax
|
|
369
|
+
match = normalized.match(/^hsla?\(\s*(-?[\d.]+)\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/);
|
|
370
|
+
if (match) {
|
|
371
|
+
const [r, g, b] = hslToRgbChannels(parseFloat(match[1]), parseFloat(match[2]) / 100, parseFloat(match[3]) / 100);
|
|
372
|
+
return [r, g, b, Math.max(0, Math.min(1, parseAlphaString(match[4])))];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// hsl() / hsla() comma syntax
|
|
376
|
+
match = normalized.match(/^hsla?\(\s*(-?[\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*(-?[\d.]+%?))?\s*\)$/);
|
|
377
|
+
if (match) {
|
|
378
|
+
const [r, g, b] = hslToRgbChannels(parseFloat(match[1]), parseFloat(match[2]) / 100, parseFloat(match[3]) / 100);
|
|
379
|
+
return [r, g, b, Math.max(0, Math.min(1, parseAlphaString(match[4])))];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// hwb()
|
|
383
|
+
match = normalized.match(/^hwb\(\s*(-?[\d.]+)\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/);
|
|
384
|
+
if (match) {
|
|
385
|
+
const [r, g, b] = hwbToRgbChannels(parseFloat(match[1]), parseFloat(match[2]) / 100, parseFloat(match[3]) / 100);
|
|
386
|
+
return [r, g, b, Math.max(0, Math.min(1, parseAlphaString(match[4])))];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Convert [r, g, b] (0-255) to OKLab {L, a, b}.
|
|
394
|
+
*
|
|
395
|
+
* @param {number} r The red channel value, 0–255.
|
|
396
|
+
* @param {number} g The green channel value, 0–255.
|
|
397
|
+
* @param {number} b The blue channel value, 0–255.
|
|
398
|
+
* @return {object} An object with L, a, b OKLab components.
|
|
399
|
+
*/
|
|
400
|
+
function rgbToOklab (r, g, b) {
|
|
401
|
+
return srgbToOklab(r / 255, g / 255, b / 255);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Convert [r, g, b] (0-255) to OKLCH {L, C, H}.
|
|
406
|
+
*
|
|
407
|
+
* @param {number} r The red channel value, 0–255.
|
|
408
|
+
* @param {number} g The green channel value, 0–255.
|
|
409
|
+
* @param {number} b The blue channel value, 0–255.
|
|
410
|
+
* @return {object} An object with L (lightness), C (chroma), H (hue in degrees).
|
|
411
|
+
*/
|
|
412
|
+
function rgbToOklch (r, g, b) {
|
|
413
|
+
const lab = rgbToOklab(r, g, b);
|
|
414
|
+
return oklabToOklch(lab.L, lab.a, lab.b);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Convert OKLab {L, a, b} to [r, g, b] (0-255).
|
|
419
|
+
*
|
|
420
|
+
* @param {number} L The OKLab lightness component.
|
|
421
|
+
* @param {number} a The OKLab green-red axis component.
|
|
422
|
+
* @param {number} b The OKLab blue-yellow axis component.
|
|
423
|
+
* @return {Array} An [r, g, b] array with channel values 0–255.
|
|
424
|
+
*/
|
|
425
|
+
function oklabToRgb (L, a, b) {
|
|
426
|
+
const srgb = oklabToSrgb(L, a, b);
|
|
427
|
+
return [Math.round(srgb.r * 255), Math.round(srgb.g * 255), Math.round(srgb.b * 255)];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Format an OKLCH result as a minified string.
|
|
432
|
+
*
|
|
433
|
+
* @param {number} L The lightness component.
|
|
434
|
+
* @param {number} C The chroma component.
|
|
435
|
+
* @param {number} H The hue angle in degrees.
|
|
436
|
+
* @param {number|undefined} alpha The alpha value from 0 to 1, or undefined for fully opaque.
|
|
437
|
+
* @return {string} A minified oklch() function string.
|
|
438
|
+
*/
|
|
439
|
+
function formatOklch (L, C, H, alpha) {
|
|
440
|
+
const fmtL = roundCompactNumber(L, 3);
|
|
441
|
+
const fmtC = roundCompactNumber(C, 3);
|
|
442
|
+
const fmtH = roundCompactNumber(H, 1);
|
|
443
|
+
if (alpha !== undefined && alpha < 1) {
|
|
444
|
+
return 'oklch(' + fmtL + ' ' + fmtC + ' ' + fmtH + '/' + roundCompactNumber(alpha, 3) + ')';
|
|
445
|
+
}
|
|
446
|
+
return 'oklch(' + fmtL + ' ' + fmtC + ' ' + fmtH + ')';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Detect which channel indices have 'none' in a raw color function string.
|
|
451
|
+
*
|
|
452
|
+
* @param {string} rawColorStr The raw CSS color function string (e.g. "rgb(none 0 0)").
|
|
453
|
+
* @return {Array} An array of zero-based channel indices where 'none' was found.
|
|
454
|
+
*/
|
|
455
|
+
function findNoneChannels (rawColorStr) {
|
|
456
|
+
const indices = [];
|
|
457
|
+
// Match rgb/rgba/hsl/hsla/hwb function calls and extract their arguments
|
|
458
|
+
const functionMatch = rawColorStr.match(/\b(?:rgba?|hsla?|hwb)\(([^)]*)\)/i);
|
|
459
|
+
if (functionMatch) {
|
|
460
|
+
// Split arguments on whitespace, commas, or slash separators
|
|
461
|
+
const parts = functionMatch[1].trim().split(/[\s,/]+/).map((part) => {
|
|
462
|
+
return part.trim();
|
|
463
|
+
}).filter((part) => {
|
|
464
|
+
return part.length > 0;
|
|
465
|
+
});
|
|
466
|
+
parts.forEach((part, index) => {
|
|
467
|
+
if (part.toLowerCase() === 'none') {
|
|
468
|
+
indices.push(index);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return indices;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Evaluate a color-mix() expression. Returns a minified CSS color string or null.
|
|
477
|
+
*
|
|
478
|
+
* @param {string} expr The full color-mix() expression string.
|
|
479
|
+
* @return {string|null} A minified CSS color string, or null if the expression cannot be evaluated.
|
|
480
|
+
*/
|
|
481
|
+
function evaluateColorMix (expr) {
|
|
482
|
+
// Parse: color-mix(in <space> [<hue-method>], <color> [<p>%], <color> [<p>%])
|
|
483
|
+
// We need to handle nested parentheses for inner color functions
|
|
484
|
+
const inner = extractBalancedArgs(expr, 'color-mix');
|
|
485
|
+
if (!inner) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Parse the interpolation method
|
|
490
|
+
const inMatch = inner.match(/^in\s+(srgb|oklch|oklab)(?:\s+shorter\s+hue)?\s*,\s*/i);
|
|
491
|
+
if (!inMatch) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const colorSpace = inMatch[1].toLowerCase();
|
|
496
|
+
const rest = inner.slice(inMatch[0].length);
|
|
497
|
+
|
|
498
|
+
// Split the two color arguments (handling nested parens)
|
|
499
|
+
const [arg1, arg2] = splitColorMixArgs(rest);
|
|
500
|
+
if (!arg1 || !arg2) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Parse each argument: "<color> [<percentage>]"
|
|
505
|
+
const parsed1 = parseColorMixArg(arg1.trim());
|
|
506
|
+
const parsed2 = parseColorMixArg(arg2.trim());
|
|
507
|
+
if (!parsed1 || !parsed2) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Check for unresolvable colors (var(), currentcolor, etc.)
|
|
512
|
+
if (!parsed1.color || !parsed2.color) {
|
|
513
|
+
// Can still do normalization but not computation
|
|
514
|
+
return normalizeColorMix(colorSpace, parsed1, parsed2);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Normalize percentages per CSS spec
|
|
518
|
+
let p1 = parsed1.percentage;
|
|
519
|
+
let p2 = parsed2.percentage;
|
|
520
|
+
|
|
521
|
+
if (p1 === null && p2 === null) {
|
|
522
|
+
p1 = 50;
|
|
523
|
+
p2 = 50;
|
|
524
|
+
} else if (p1 === null) {
|
|
525
|
+
p1 = 100 - p2;
|
|
526
|
+
} else if (p2 === null) {
|
|
527
|
+
p2 = 100 - p1;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let alphaMultiplier = 1;
|
|
531
|
+
const pSum = p1 + p2;
|
|
532
|
+
if (pSum === 0) {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (pSum < 100) {
|
|
537
|
+
alphaMultiplier = pSum / 100;
|
|
538
|
+
} else if (pSum > 100) {
|
|
539
|
+
p1 = p1 / pSum * 100;
|
|
540
|
+
p2 = p2 / pSum * 100;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Trivial cases
|
|
544
|
+
if (p1 === 0) {
|
|
545
|
+
return rgbaToHex(parsed2.color[0], parsed2.color[1], parsed2.color[2], parsed2.color[3]);
|
|
546
|
+
}
|
|
547
|
+
if (p2 === 0) {
|
|
548
|
+
return rgbaToHex(parsed1.color[0], parsed1.color[1], parsed1.color[2], parsed1.color[3]);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// CSS spec: 'none' channels are missing — fill from the other color before mixing
|
|
552
|
+
const nones1 = findNoneChannels(parsed1.raw);
|
|
553
|
+
const nones2 = findNoneChannels(parsed2.raw);
|
|
554
|
+
for (const idx of nones1) {
|
|
555
|
+
if (idx < parsed1.color.length) {
|
|
556
|
+
parsed1.color[idx] = parsed2.color[idx];
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
for (const idx of nones2) {
|
|
560
|
+
if (idx < parsed2.color.length) {
|
|
561
|
+
parsed2.color[idx] = parsed1.color[idx];
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const t1 = p1 / (p1 + p2);
|
|
566
|
+
const t2 = p2 / (p1 + p2);
|
|
567
|
+
const [r1, g1, b1, a1] = parsed1.color;
|
|
568
|
+
const [r2, g2, b2, a2] = parsed2.color;
|
|
569
|
+
|
|
570
|
+
if (colorSpace === 'srgb') {
|
|
571
|
+
const r = Math.round(r1 * t1 + r2 * t2);
|
|
572
|
+
const g = Math.round(g1 * t1 + g2 * t2);
|
|
573
|
+
const b = Math.round(b1 * t1 + b2 * t2);
|
|
574
|
+
const a = (a1 * t1 + a2 * t2) * alphaMultiplier;
|
|
575
|
+
return rgbaToHex(r, g, b, a);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (colorSpace === 'oklab') {
|
|
579
|
+
const lab1 = rgbToOklab(r1, g1, b1);
|
|
580
|
+
const lab2 = rgbToOklab(r2, g2, b2);
|
|
581
|
+
const L = lab1.L * t1 + lab2.L * t2;
|
|
582
|
+
const a = lab1.a * t1 + lab2.a * t2;
|
|
583
|
+
const b = lab1.b * t1 + lab2.b * t2;
|
|
584
|
+
const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
|
|
585
|
+
// Check if result fits in sRGB gamut
|
|
586
|
+
const rgb = oklabToRgb(L, a, b);
|
|
587
|
+
if (alpha >= 1) {
|
|
588
|
+
return rgbaToHex(rgb[0], rgb[1], rgb[2], 1);
|
|
589
|
+
}
|
|
590
|
+
return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (colorSpace === 'oklch') {
|
|
594
|
+
const lch1 = rgbToOklch(r1, g1, b1);
|
|
595
|
+
const lch2 = rgbToOklch(r2, g2, b2);
|
|
596
|
+
const L = lch1.L * t1 + lch2.L * t2;
|
|
597
|
+
const C = lch1.C * t1 + lch2.C * t2;
|
|
598
|
+
const H = interpolateHueShorter(lch1.H, lch2.H, t2);
|
|
599
|
+
const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
|
|
600
|
+
return formatOklch(L, C, H, alpha);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Extract the balanced content inside a function call.
|
|
608
|
+
*
|
|
609
|
+
* @param {string} expr The expression string containing the function call.
|
|
610
|
+
* @param {string} funcName The function name to locate (e.g. "color-mix").
|
|
611
|
+
* @return {string|null} The content between the matching parentheses, or null if not found.
|
|
612
|
+
*/
|
|
613
|
+
function extractBalancedArgs (expr, funcName) {
|
|
614
|
+
const prefix = funcName + '(';
|
|
615
|
+
const start = expr.indexOf(prefix);
|
|
616
|
+
if (start === -1) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
let depth = 1;
|
|
620
|
+
let position = start + prefix.length;
|
|
621
|
+
while (position < expr.length && depth > 0) {
|
|
622
|
+
if (expr[position] === '(') {
|
|
623
|
+
depth++;
|
|
624
|
+
} else if (expr[position] === ')') {
|
|
625
|
+
depth--;
|
|
626
|
+
}
|
|
627
|
+
position++;
|
|
628
|
+
}
|
|
629
|
+
return expr.slice(start + prefix.length, position - 1);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Split color-mix arguments at the top-level comma (handling nested parens).
|
|
634
|
+
*
|
|
635
|
+
* @param {string} str The two color arguments string, separated by a comma.
|
|
636
|
+
* @return {Array} A two-element array of [firstArg, secondArg], or [null, null] if no split point.
|
|
637
|
+
*/
|
|
638
|
+
function splitColorMixArgs (str) {
|
|
639
|
+
let depth = 0;
|
|
640
|
+
for (let position = 0; position < str.length; position++) {
|
|
641
|
+
if (str[position] === '(') {
|
|
642
|
+
depth++;
|
|
643
|
+
} else if (str[position] === ')') {
|
|
644
|
+
depth--;
|
|
645
|
+
} else if (str[position] === ',' && depth === 0) {
|
|
646
|
+
return [str.slice(0, position), str.slice(position + 1)];
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return [null, null];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Parse a single color-mix argument: "<color> [<percentage>]" or "<percentage> <color>".
|
|
654
|
+
*
|
|
655
|
+
* @param {string} arg The color-mix argument string to parse.
|
|
656
|
+
* @return {object|null} An object with color (Array or null), percentage (number or null), raw (string), and hasVar (boolean), or null if unparseable.
|
|
657
|
+
*/
|
|
658
|
+
function parseColorMixArg (arg) {
|
|
659
|
+
arg = arg.trim();
|
|
660
|
+
|
|
661
|
+
// Try: percentage at end, e.g. "red 50%" or "rgb(0 0 0)50%"
|
|
662
|
+
let match = arg.match(/^(.+?)\s*(\d+(?:\.\d+)?)%\s*$/);
|
|
663
|
+
if (match) {
|
|
664
|
+
const colorStr = match[1].trim();
|
|
665
|
+
const percentage = parseFloat(match[2]);
|
|
666
|
+
const color = parseColor(colorStr);
|
|
667
|
+
// Check if color contains var() or currentcolor (cannot be evaluated statically)
|
|
668
|
+
return { color, percentage, raw: colorStr, hasVar: /var\(|currentcolor/i.test(colorStr) };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Try: percentage at start, e.g. "50% red"
|
|
672
|
+
match = arg.match(/^(\d+(?:\.\d+)?)%\s+(.+)$/);
|
|
673
|
+
if (match) {
|
|
674
|
+
const colorStr = match[2].trim();
|
|
675
|
+
const percentage = parseFloat(match[1]);
|
|
676
|
+
const color = parseColor(colorStr);
|
|
677
|
+
// Check if color contains var() or currentcolor (cannot be evaluated statically)
|
|
678
|
+
return { color, percentage, raw: colorStr, hasVar: /var\(|currentcolor/i.test(colorStr) };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// No percentage
|
|
682
|
+
const color = parseColor(arg);
|
|
683
|
+
// Check if color contains var() or currentcolor (cannot be evaluated statically)
|
|
684
|
+
return { color, percentage: null, raw: arg, hasVar: /var\(|currentcolor/i.test(arg) };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Normalize a color-mix expression when we can't fully compute it.
|
|
689
|
+
*
|
|
690
|
+
* @param {string} colorSpace The interpolation color space ("srgb", "oklab", or "oklch").
|
|
691
|
+
* @param {object} parsed1 The parsed first color argument with color, percentage, and raw fields.
|
|
692
|
+
* @param {object} parsed2 The parsed second color argument with color, percentage, and raw fields.
|
|
693
|
+
* @return {string} A normalized color-mix() expression with default percentages and color space elided.
|
|
694
|
+
*/
|
|
695
|
+
function normalizeColorMix (colorSpace, parsed1, parsed2) {
|
|
696
|
+
// Normalize percentages: strip explicit 50%/50% (the defaults)
|
|
697
|
+
let p1Str = '';
|
|
698
|
+
let p2Str = '';
|
|
699
|
+
if (parsed1.percentage !== null && parsed1.percentage !== 50) {
|
|
700
|
+
p1Str = ' ' + parsed1.percentage + '%';
|
|
701
|
+
}
|
|
702
|
+
if (parsed2.percentage !== null && parsed2.percentage !== 50) {
|
|
703
|
+
p2Str = ' ' + parsed2.percentage + '%';
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Use the raw color strings (but try to minify known colors)
|
|
707
|
+
let c1 = parsed1.raw;
|
|
708
|
+
let c2 = parsed2.raw;
|
|
709
|
+
if (parsed1.color) {
|
|
710
|
+
c1 = rgbaToHex(parsed1.color[0], parsed1.color[1], parsed1.color[2], parsed1.color[3]);
|
|
711
|
+
}
|
|
712
|
+
if (parsed2.color) {
|
|
713
|
+
c2 = rgbaToHex(parsed2.color[0], parsed2.color[1], parsed2.color[2], parsed2.color[3]);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// oklab is the default interpolation method per CSS Color 5 — elide it
|
|
717
|
+
const spacePrefix = colorSpace === 'oklab' ? '' : 'in ' + colorSpace + ',';
|
|
718
|
+
return 'color-mix(' + spacePrefix + c1 + p1Str + ',' + c2 + p2Str + ')';
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Convert a standalone oklab() value to hex if it fits in sRGB gamut; returns null if out-of-gamut.
|
|
723
|
+
*
|
|
724
|
+
* @param {number} L The OKLab lightness component.
|
|
725
|
+
* @param {number} a The OKLab green-red axis component.
|
|
726
|
+
* @param {number} b The OKLab blue-yellow axis component.
|
|
727
|
+
* @param {number} alpha The alpha value from 0 to 1.
|
|
728
|
+
* @return {string|null} A hex color string, or null if the color is outside the sRGB gamut.
|
|
729
|
+
*/
|
|
730
|
+
function convertOklabToHex (L, a, b, alpha) {
|
|
731
|
+
const linear = oklabToLinearSrgb(L, a, b);
|
|
732
|
+
// Out-of-gamut check on unclamped linear sRGB channels
|
|
733
|
+
if (linear.r < -0.002 || linear.r > 1.002 || linear.g < -0.002 || linear.g > 1.002 || linear.b < -0.002 || linear.b > 1.002) {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
const r = Math.round(delinearize(Math.max(0, Math.min(1, linear.r))) * 255);
|
|
737
|
+
const g = Math.round(delinearize(Math.max(0, Math.min(1, linear.g))) * 255);
|
|
738
|
+
const bl = Math.round(delinearize(Math.max(0, Math.min(1, linear.b))) * 255);
|
|
739
|
+
return rgbaToHex(r, g, bl, alpha !== undefined ? alpha : 1);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Handle color(from ...) relative color syntax for simple identity cases.
|
|
744
|
+
*
|
|
745
|
+
* @param {string} expr The color(from ...) expression string.
|
|
746
|
+
* @return {string|null} A hex color string if the relative color is a simple identity transform, or null otherwise.
|
|
747
|
+
*/
|
|
748
|
+
function evaluateRelativeColor (expr) {
|
|
749
|
+
// Match: color(from <base-color> srgb r g b [/ <alpha>]) identity transform pattern
|
|
750
|
+
const match = expr.match(/^color\(\s*from\s+(.+?)\s+srgb\s+r\s+g\s+b(?:\s*\/\s*([\d.]+%?))?\s*\)$/i);
|
|
751
|
+
if (!match) {
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
const baseColor = parseColor(match[1]);
|
|
755
|
+
if (!baseColor) {
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
const alpha = parseAlphaString(match[2], baseColor[3]);
|
|
759
|
+
return rgbaToHex(baseColor[0], baseColor[1], baseColor[2], alpha);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export {
|
|
763
|
+
hslToRgbChannels,
|
|
764
|
+
rgbaToHex,
|
|
765
|
+
hwbToRgbChannels,
|
|
766
|
+
namedColors,
|
|
767
|
+
parseColor,
|
|
768
|
+
parseHex,
|
|
769
|
+
evaluateColorMix,
|
|
770
|
+
convertOklabToHex,
|
|
771
|
+
evaluateRelativeColor,
|
|
772
|
+
shortestColor,
|
|
773
|
+
srgbToOklab,
|
|
774
|
+
oklabToSrgb,
|
|
775
|
+
oklabToOklch,
|
|
776
|
+
oklchToOklab,
|
|
777
|
+
rgbToOklab,
|
|
778
|
+
rgbToOklch,
|
|
779
|
+
oklabToRgb
|
|
780
|
+
};
|