lib/css/src/value/color.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2
3 /// One CSS Color 4 named colour and its packed value.
4 pub const Named = struct {
5 name: []const u8,
6 rgba: u32,
7 };
8
9 /// Packs eight bit channels into the `a` word of a colour value.
10 pub fn pack(red_channel: u8, green_channel: u8, blue_channel: u8, alpha_channel: u8) u32 {
11 return (@as(u32, red_channel) << 24) | (@as(u32, green_channel) << 16) |
12 (@as(u32, blue_channel) << 8) | @as(u32, alpha_channel);
13 }
14
15 /// The red channel of a packed colour.
16 pub fn red(rgba: u32) u8 {
17 return @truncate(rgba >> 24);
18 }
19
20 /// The green channel of a packed colour.
21 pub fn green(rgba: u32) u8 {
22 return @truncate(rgba >> 16);
23 }
24
25 /// The blue channel of a packed colour.
26 pub fn blue(rgba: u32) u8 {
27 return @truncate(rgba >> 8);
28 }
29
30 /// The alpha channel of a packed colour.
31 pub fn alpha(rgba: u32) u8 {
32 return @truncate(rgba);
33 }
34
35 /// Fully transparent black, which is what `transparent` computes to.
36 pub const transparent: u32 = 0;
37
38 /// The CSS Color 4 named colours, sorted so that lookup is a binary search.
39 pub const names = [_]Named{
40 .{ .name = "aliceblue", .rgba = 0xF0F8FFFF },
41 .{ .name = "antiquewhite", .rgba = 0xFAEBD7FF },
42 .{ .name = "aqua", .rgba = 0x00FFFFFF },
43 .{ .name = "aquamarine", .rgba = 0x7FFFD4FF },
44 .{ .name = "azure", .rgba = 0xF0FFFFFF },
45 .{ .name = "beige", .rgba = 0xF5F5DCFF },
46 .{ .name = "bisque", .rgba = 0xFFE4C4FF },
47 .{ .name = "black", .rgba = 0x000000FF },
48 .{ .name = "blanchedalmond", .rgba = 0xFFEBCDFF },
49 .{ .name = "blue", .rgba = 0x0000FFFF },
50 .{ .name = "blueviolet", .rgba = 0x8A2BE2FF },
51 .{ .name = "brown", .rgba = 0xA52A2AFF },
52 .{ .name = "burlywood", .rgba = 0xDEB887FF },
53 .{ .name = "cadetblue", .rgba = 0x5F9EA0FF },
54 .{ .name = "chartreuse", .rgba = 0x7FFF00FF },
55 .{ .name = "chocolate", .rgba = 0xD2691EFF },
56 .{ .name = "coral", .rgba = 0xFF7F50FF },
57 .{ .name = "cornflowerblue", .rgba = 0x6495EDFF },
58 .{ .name = "cornsilk", .rgba = 0xFFF8DCFF },
59 .{ .name = "crimson", .rgba = 0xDC143CFF },
60 .{ .name = "cyan", .rgba = 0x00FFFFFF },
61 .{ .name = "darkblue", .rgba = 0x00008BFF },
62 .{ .name = "darkcyan", .rgba = 0x008B8BFF },
63 .{ .name = "darkgoldenrod", .rgba = 0xB8860BFF },
64 .{ .name = "darkgray", .rgba = 0xA9A9A9FF },
65 .{ .name = "darkgreen", .rgba = 0x006400FF },
66 .{ .name = "darkgrey", .rgba = 0xA9A9A9FF },
67 .{ .name = "darkkhaki", .rgba = 0xBDB76BFF },
68 .{ .name = "darkmagenta", .rgba = 0x8B008BFF },
69 .{ .name = "darkolivegreen", .rgba = 0x556B2FFF },
70 .{ .name = "darkorange", .rgba = 0xFF8C00FF },
71 .{ .name = "darkorchid", .rgba = 0x9932CCFF },
72 .{ .name = "darkred", .rgba = 0x8B0000FF },
73 .{ .name = "darksalmon", .rgba = 0xE9967AFF },
74 .{ .name = "darkseagreen", .rgba = 0x8FBC8FFF },
75 .{ .name = "darkslateblue", .rgba = 0x483D8BFF },
76 .{ .name = "darkslategray", .rgba = 0x2F4F4FFF },
77 .{ .name = "darkslategrey", .rgba = 0x2F4F4FFF },
78 .{ .name = "darkturquoise", .rgba = 0x00CED1FF },
79 .{ .name = "darkviolet", .rgba = 0x9400D3FF },
80 .{ .name = "deeppink", .rgba = 0xFF1493FF },
81 .{ .name = "deepskyblue", .rgba = 0x00BFFFFF },
82 .{ .name = "dimgray", .rgba = 0x696969FF },
83 .{ .name = "dimgrey", .rgba = 0x696969FF },
84 .{ .name = "dodgerblue", .rgba = 0x1E90FFFF },
85 .{ .name = "firebrick", .rgba = 0xB22222FF },
86 .{ .name = "floralwhite", .rgba = 0xFFFAF0FF },
87 .{ .name = "forestgreen", .rgba = 0x228B22FF },
88 .{ .name = "fuchsia", .rgba = 0xFF00FFFF },
89 .{ .name = "gainsboro", .rgba = 0xDCDCDCFF },
90 .{ .name = "ghostwhite", .rgba = 0xF8F8FFFF },
91 .{ .name = "gold", .rgba = 0xFFD700FF },
92 .{ .name = "goldenrod", .rgba = 0xDAA520FF },
93 .{ .name = "gray", .rgba = 0x808080FF },
94 .{ .name = "green", .rgba = 0x008000FF },
95 .{ .name = "greenyellow", .rgba = 0xADFF2FFF },
96 .{ .name = "grey", .rgba = 0x808080FF },
97 .{ .name = "honeydew", .rgba = 0xF0FFF0FF },
98 .{ .name = "hotpink", .rgba = 0xFF69B4FF },
99 .{ .name = "indianred", .rgba = 0xCD5C5CFF },
100 .{ .name = "indigo", .rgba = 0x4B0082FF },
101 .{ .name = "ivory", .rgba = 0xFFFFF0FF },
102 .{ .name = "khaki", .rgba = 0xF0E68CFF },
103 .{ .name = "lavender", .rgba = 0xE6E6FAFF },
104 .{ .name = "lavenderblush", .rgba = 0xFFF0F5FF },
105 .{ .name = "lawngreen", .rgba = 0x7CFC00FF },
106 .{ .name = "lemonchiffon", .rgba = 0xFFFACDFF },
107 .{ .name = "lightblue", .rgba = 0xADD8E6FF },
108 .{ .name = "lightcoral", .rgba = 0xF08080FF },
109 .{ .name = "lightcyan", .rgba = 0xE0FFFFFF },
110 .{ .name = "lightgoldenrodyellow", .rgba = 0xFAFAD2FF },
111 .{ .name = "lightgray", .rgba = 0xD3D3D3FF },
112 .{ .name = "lightgreen", .rgba = 0x90EE90FF },
113 .{ .name = "lightgrey", .rgba = 0xD3D3D3FF },
114 .{ .name = "lightpink", .rgba = 0xFFB6C1FF },
115 .{ .name = "lightsalmon", .rgba = 0xFFA07AFF },
116 .{ .name = "lightseagreen", .rgba = 0x20B2AAFF },
117 .{ .name = "lightskyblue", .rgba = 0x87CEFAFF },
118 .{ .name = "lightslategray", .rgba = 0x778899FF },
119 .{ .name = "lightslategrey", .rgba = 0x778899FF },
120 .{ .name = "lightsteelblue", .rgba = 0xB0C4DEFF },
121 .{ .name = "lightyellow", .rgba = 0xFFFFE0FF },
122 .{ .name = "lime", .rgba = 0x00FF00FF },
123 .{ .name = "limegreen", .rgba = 0x32CD32FF },
124 .{ .name = "linen", .rgba = 0xFAF0E6FF },
125 .{ .name = "magenta", .rgba = 0xFF00FFFF },
126 .{ .name = "maroon", .rgba = 0x800000FF },
127 .{ .name = "mediumaquamarine", .rgba = 0x66CDAAFF },
128 .{ .name = "mediumblue", .rgba = 0x0000CDFF },
129 .{ .name = "mediumorchid", .rgba = 0xBA55D3FF },
130 .{ .name = "mediumpurple", .rgba = 0x9370DBFF },
131 .{ .name = "mediumseagreen", .rgba = 0x3CB371FF },
132 .{ .name = "mediumslateblue", .rgba = 0x7B68EEFF },
133 .{ .name = "mediumspringgreen", .rgba = 0x00FA9AFF },
134 .{ .name = "mediumturquoise", .rgba = 0x48D1CCFF },
135 .{ .name = "mediumvioletred", .rgba = 0xC71585FF },
136 .{ .name = "midnightblue", .rgba = 0x191970FF },
137 .{ .name = "mintcream", .rgba = 0xF5FFFAFF },
138 .{ .name = "mistyrose", .rgba = 0xFFE4E1FF },
139 .{ .name = "moccasin", .rgba = 0xFFE4B5FF },
140 .{ .name = "navajowhite", .rgba = 0xFFDEADFF },
141 .{ .name = "navy", .rgba = 0x000080FF },
142 .{ .name = "oldlace", .rgba = 0xFDF5E6FF },
143 .{ .name = "olive", .rgba = 0x808000FF },
144 .{ .name = "olivedrab", .rgba = 0x6B8E23FF },
145 .{ .name = "orange", .rgba = 0xFFA500FF },
146 .{ .name = "orangered", .rgba = 0xFF4500FF },
147 .{ .name = "orchid", .rgba = 0xDA70D6FF },
148 .{ .name = "palegoldenrod", .rgba = 0xEEE8AAFF },
149 .{ .name = "palegreen", .rgba = 0x98FB98FF },
150 .{ .name = "paleturquoise", .rgba = 0xAFEEEEFF },
151 .{ .name = "palevioletred", .rgba = 0xDB7093FF },
152 .{ .name = "papayawhip", .rgba = 0xFFEFD5FF },
153 .{ .name = "peachpuff", .rgba = 0xFFDAB9FF },
154 .{ .name = "peru", .rgba = 0xCD853FFF },
155 .{ .name = "pink", .rgba = 0xFFC0CBFF },
156 .{ .name = "plum", .rgba = 0xDDA0DDFF },
157 .{ .name = "powderblue", .rgba = 0xB0E0E6FF },
158 .{ .name = "purple", .rgba = 0x800080FF },
159 .{ .name = "rebeccapurple", .rgba = 0x663399FF },
160 .{ .name = "red", .rgba = 0xFF0000FF },
161 .{ .name = "rosybrown", .rgba = 0xBC8F8FFF },
162 .{ .name = "royalblue", .rgba = 0x4169E1FF },
163 .{ .name = "saddlebrown", .rgba = 0x8B4513FF },
164 .{ .name = "salmon", .rgba = 0xFA8072FF },
165 .{ .name = "sandybrown", .rgba = 0xF4A460FF },
166 .{ .name = "seagreen", .rgba = 0x2E8B57FF },
167 .{ .name = "seashell", .rgba = 0xFFF5EEFF },
168 .{ .name = "sienna", .rgba = 0xA0522DFF },
169 .{ .name = "silver", .rgba = 0xC0C0C0FF },
170 .{ .name = "skyblue", .rgba = 0x87CEEBFF },
171 .{ .name = "slateblue", .rgba = 0x6A5ACDFF },
172 .{ .name = "slategray", .rgba = 0x708090FF },
173 .{ .name = "slategrey", .rgba = 0x708090FF },
174 .{ .name = "snow", .rgba = 0xFFFAFAFF },
175 .{ .name = "springgreen", .rgba = 0x00FF7FFF },
176 .{ .name = "steelblue", .rgba = 0x4682B4FF },
177 .{ .name = "tan", .rgba = 0xD2B48CFF },
178 .{ .name = "teal", .rgba = 0x008080FF },
179 .{ .name = "thistle", .rgba = 0xD8BFD8FF },
180 .{ .name = "tomato", .rgba = 0xFF6347FF },
181 .{ .name = "turquoise", .rgba = 0x40E0D0FF },
182 .{ .name = "violet", .rgba = 0xEE82EEFF },
183 .{ .name = "wheat", .rgba = 0xF5DEB3FF },
184 .{ .name = "white", .rgba = 0xFFFFFFFF },
185 .{ .name = "whitesmoke", .rgba = 0xF5F5F5FF },
186 .{ .name = "yellow", .rgba = 0xFFFF00FF },
187 .{ .name = "yellowgreen", .rgba = 0x9ACD32FF },
188 };
189
190 comptime {
191 @setEvalBranchQuota(16_000);
192 for (names[1..], 1..) |entry, index| {
193 if (!(std.mem.order(u8, names[index - 1].name, entry.name) == .lt)) {
194 @compileError("named colours must stay sorted for binary search");
195 }
196 }
197 }
198
199 /// Resolves a named colour, ASCII case insensitively.
200 pub fn named(name: []const u8) ?u32 {
201 if (name.len == 0 or name.len > 24) return null;
202 var buffer: [24]u8 = undefined;
203 for (name, 0..) |byte, index| buffer[index] = std.ascii.toLower(byte);
204 const folded = buffer[0..name.len];
205 var low: usize = 0;
206 var high: usize = names.len;
207 while (low < high) {
208 const middle = low + (high - low) / 2;
209 switch (std.mem.order(u8, names[middle].name, folded)) {
210 .lt => low = middle + 1,
211 .gt => high = middle,
212 .eq => return names[middle].rgba,
213 }
214 }
215 return null;
216 }
217
218 /// Parses a `#rgb`, `#rgba`, `#rrggbb`, or `#rrggbbaa` body without its hash.
219 pub fn hex(digits: []const u8) ?u32 {
220 for (digits) |byte| {
221 if (!std.ascii.isHex(byte)) return null;
222 }
223 return switch (digits.len) {
224 3 => pack(expand(digits[0]), expand(digits[1]), expand(digits[2]), 255),
225 4 => pack(expand(digits[0]), expand(digits[1]), expand(digits[2]), expand(digits[3])),
226 6 => pack(pair(digits[0..2]), pair(digits[2..4]), pair(digits[4..6]), 255),
227 8 => pack(pair(digits[0..2]), pair(digits[2..4]), pair(digits[4..6]), pair(digits[6..8])),
228 else => null,
229 };
230 }
231
232 /// Parses the argument list of `rgb()`, `rgba()`, `hsl()`, or `hsla()`.
233 pub fn function(name: []const u8, arguments: []const u8) ?u32 {
234 var parts: [4][]const u8 = undefined;
235 const count = split(arguments, &parts);
236 if (count < 3) return null;
237 if (std.ascii.eqlIgnoreCase(name, "rgb") or std.ascii.eqlIgnoreCase(name, "rgba")) {
238 return pack(
239 channel(parts[0]) orelse return null,
240 channel(parts[1]) orelse return null,
241 channel(parts[2]) orelse return null,
242 if (count == 4) opacity(parts[3]) orelse return null else 255,
243 );
244 }
245 if (std.ascii.eqlIgnoreCase(name, "hsl") or std.ascii.eqlIgnoreCase(name, "hsla")) {
246 const hue = scalar(parts[0]) orelse return null;
247 const saturation = ratio(parts[1]) orelse return null;
248 const lightness = ratio(parts[2]) orelse return null;
249 const opaque_alpha = if (count == 4) opacity(parts[3]) orelse return null else 255;
250 return fromHsl(hue, saturation, lightness, opaque_alpha);
251 }
252 return null;
253 }
254
255 fn expand(digit: u8) u8 {
256 const value = digitValue(digit);
257 return value * 17;
258 }
259
260 fn pair(digits: []const u8) u8 {
261 return digitValue(digits[0]) * 16 + digitValue(digits[1]);
262 }
263
264 fn digitValue(digit: u8) u8 {
265 if (digit <= '9') return digit - '0';
266 return (digit | 0x20) - 'a' + 10;
267 }
268
269 fn split(arguments: []const u8, parts: *[4][]const u8) usize {
270 var count: usize = 0;
271 var iterator = std.mem.tokenizeAny(u8, arguments, ", \t\r\n/");
272 while (iterator.next()) |part| {
273 if (count == parts.len) return count + 1;
274 parts[count] = part;
275 count += 1;
276 }
277 return count;
278 }
279
280 fn scalar(text: []const u8) ?f32 {
281 var body = text;
282 if (std.mem.endsWith(u8, body, "deg")) body = body[0 .. body.len - 3];
283 return std.fmt.parseFloat(f32, body) catch null;
284 }
285
286 fn channel(text: []const u8) ?u8 {
287 if (std.mem.endsWith(u8, text, "%")) {
288 const fraction = std.fmt.parseFloat(f32, text[0 .. text.len - 1]) catch return null;
289 return clamp(fraction * 255.0 / 100.0);
290 }
291 const value = std.fmt.parseFloat(f32, text) catch return null;
292 return clamp(value);
293 }
294
295 fn opacity(text: []const u8) ?u8 {
296 if (std.mem.endsWith(u8, text, "%")) {
297 const fraction = std.fmt.parseFloat(f32, text[0 .. text.len - 1]) catch return null;
298 return clamp(fraction * 255.0 / 100.0);
299 }
300 const value = std.fmt.parseFloat(f32, text) catch return null;
301 return clamp(value * 255.0);
302 }
303
304 fn ratio(text: []const u8) ?f32 {
305 if (!std.mem.endsWith(u8, text, "%")) return std.fmt.parseFloat(f32, text) catch null;
306 const value = std.fmt.parseFloat(f32, text[0 .. text.len - 1]) catch return null;
307 return value / 100.0;
308 }
309
310 fn clamp(value: f32) u8 {
311 if (!(value > 0)) return 0;
312 if (value >= 255) return 255;
313 return @intFromFloat(@round(value));
314 }
315
316 fn fromHsl(hue: f32, saturation: f32, lightness: f32, opaque_alpha: u8) u32 {
317 const s = std.math.clamp(saturation, 0, 1);
318 const l = std.math.clamp(lightness, 0, 1);
319 const chroma = (1.0 - @abs(2.0 * l - 1.0)) * s;
320 const normalized = @mod(hue, 360.0) / 60.0;
321 const second = chroma * (1.0 - @abs(@mod(normalized, 2.0) - 1.0));
322 const match = l - chroma / 2.0;
323 const sector: u32 = @intFromFloat(@floor(normalized));
324 const triple: [3]f32 = switch (sector) {
325 0 => .{ chroma, second, 0 },
326 1 => .{ second, chroma, 0 },
327 2 => .{ 0, chroma, second },
328 3 => .{ 0, second, chroma },
329 4 => .{ second, 0, chroma },
330 else => .{ chroma, 0, second },
331 };
332 return pack(
333 clamp((triple[0] + match) * 255.0),
334 clamp((triple[1] + match) * 255.0),
335 clamp((triple[2] + match) * 255.0),
336 opaque_alpha,
337 );
338 }
339
340 test "a named colour resolves case insensitively and an unknown name fails" {
341 try std.testing.expectEqual(pack(255, 0, 0, 255), named("red").?);
342 try std.testing.expectEqual(pack(255, 0, 0, 255), named("RED").?);
343 try std.testing.expectEqual(pack(0x66, 0x33, 0x99, 255), named("rebeccapurple").?);
344 try std.testing.expect(named("notacolour") == null);
345 try std.testing.expect(named("") == null);
346 }
347
348 test "hex bodies expand from three, four, six, and eight digits" {
349 try std.testing.expectEqual(pack(0xAA, 0xBB, 0xCC, 255), hex("abc").?);
350 try std.testing.expectEqual(pack(0xAA, 0xBB, 0xCC, 0xDD), hex("abcd").?);
351 try std.testing.expectEqual(pack(0x12, 0x34, 0x56, 255), hex("123456").?);
352 try std.testing.expectEqual(pack(0x12, 0x34, 0x56, 0x78), hex("12345678").?);
353 try std.testing.expect(hex("12345") == null);
354 try std.testing.expect(hex("zzz") == null);
355 }
356
357 test "rgb and rgba take numbers, percentages, and an alpha" {
358 try std.testing.expectEqual(pack(1, 2, 3, 255), function("rgb", "1, 2, 3").?);
359 try std.testing.expectEqual(pack(255, 0, 0, 128), function("rgba", "255 0 0 / 0.502").?);
360 try std.testing.expectEqual(pack(255, 128, 0, 255), function("rgb", "100%, 50%, 0%").?);
361 try std.testing.expect(function("rgb", "1, 2") == null);
362 try std.testing.expect(function("lab", "1, 2, 3") == null);
363 }
364
365 test "hsl converts hue, saturation, and lightness into channels" {
366 try std.testing.expectEqual(pack(255, 0, 0, 255), function("hsl", "0, 100%, 50%").?);
367 try std.testing.expectEqual(pack(0, 255, 0, 255), function("hsl", "120deg 100% 50%").?);
368 try std.testing.expectEqual(pack(128, 128, 128, 255), function("hsl", "0, 0%, 50.2%").?);
369 }
370
371 test "channel packing and reading are inverse" {
372 const packed_value = pack(18, 52, 86, 120);
373 try std.testing.expectEqual(@as(u8, 18), red(packed_value));
374 try std.testing.expectEqual(@as(u8, 52), green(packed_value));
375 try std.testing.expectEqual(@as(u8, 86), blue(packed_value));
376 try std.testing.expectEqual(@as(u8, 120), alpha(packed_value));
377 try std.testing.expectEqual(@as(u32, 0), transparent);
378 }