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 }