Merge pull request #4200 from lightpanda-io/color-parsing

css: add color parsing
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-10-27 07:01:48 +08:00
commit 13908ea453
3 files changed
+911 -9

No files matched your search

+846 -2
View File
@@ -19,6 +19,8 @@
const std = @import("std");
const Io = std.Io;
const Tokenizer = @import("css/Tokenizer.zig");
fn isHexColor(value: []const u8) bool {
if (value.len == 0) {
return false;
@@ -199,13 +201,13 @@ pub const RGBA = packed struct(u32) {
pub const yellowgreen: RGBA = .init(154, 205, 50, 1);
};
pub fn init(r: u8, g: u8, b: u8, a: f32) RGBA {
fn init(r: u8, g: u8, b: u8, a: f32) RGBA {
const clamped = std.math.clamp(a, 0, 1);
return .{ .r = r, .g = g, .b = b, .a = @trunc(clamped * 255) };
}
/// Finds a color by its name.
pub fn find(name: []const u8) ?RGBA {
fn find(name: []const u8) ?RGBA {
const match = std.meta.stringToEnum(std.meta.DeclEnum(Named), name) orelse return null;
return switch (match) {
@@ -296,3 +298,845 @@ pub const RGBA = packed struct(u32) {
return @as(f32, @floatFromInt(self.a)) / 255;
}
};
// CSS <color> values: parsing and the CSSOM serializations of the sRGB forms
// (named, hex, rgb(), hsl(), hwb()).
// https://drafts.csswg.org/css-color-4/#serializing-sRGB-values
/// What `parse` made of a CSS `<color>` value.
const Parsed = union(enum) {
invalid,
// possibly valid, but we don't support it (e.g. lab(), color-mix(), var())
// written as-is.
unsupported,
value: Value,
};
const Value = union(enum) {
/// A named color or `transparent`.
named: struct { name: []const u8, rgb: Rgb },
/// `currentcolor`, a system color or a CSS-wide keyword, lowercased.
keyword: []const u8,
rgb: Rgb,
/// An rgb()/hsl()/hwb() with a `none` component.
missing: Missing,
pub fn writeSpecified(self: Value, writer: *Io.Writer) Io.Writer.Error!void {
switch (self) {
.named => |n| try writer.writeAll(n.name),
.keyword => |k| try writer.writeAll(k),
.rgb => |rgb| try rgb.writeLegacy(writer),
.missing => |m| switch (m.function) {
// Legacy serialization, with none as 0.
.rgb => try (Rgb{
.r = m.channels[0] orelse 0,
.g = m.channels[1] orelse 0,
.b = m.channels[2] orelse 0,
.alpha = m.alpha orelse 0,
}).writeLegacy(writer),
.hsl, .hwb => try m.writeModern(writer, ""),
},
}
}
pub fn writeComputed(self: Value, writer: *Io.Writer) Io.Writer.Error!void {
switch (self) {
.named => |n| try n.rgb.writeLegacy(writer),
.keyword => |k| try writer.writeAll(k),
.rgb => |rgb| try rgb.writeLegacy(writer),
.missing => |m| switch (m.function) {
// Missing components survive computation, which legacy
// rgb() can't express.
.rgb => {
try writer.writeAll("color(srgb");
for (m.channels) |channel| {
try writer.writeByte(' ');
try writeComponent(writer, if (channel) |c| c / 255 else null, 8, "");
}
try m.writeAlpha(writer);
try writer.writeByte(')');
},
.hsl, .hwb => try m.writeModern(writer, "%"),
},
}
}
};
/// Channels 0-255 and alpha 0-1, both clamped.
const Rgb = struct {
r: f64,
g: f64,
b: f64,
alpha: f64 = 1,
fn writeLegacy(self: Rgb, writer: *Io.Writer) Io.Writer.Error!void {
const r = channelByte(self.r);
const g = channelByte(self.g);
const b = channelByte(self.b);
if (self.alpha == 1) {
return writer.print("rgb({d}, {d}, {d})", .{ r, g, b });
}
try writer.print("rgba({d}, {d}, {d}, ", .{ r, g, b });
try writeNumber(writer, self.alpha, 6);
try writer.writeByte(')');
}
fn fromRGBA(rgba: RGBA) Rgb {
return .{
.r = @floatFromInt(rgba.r),
.g = @floatFromInt(rgba.g),
.b = @floatFromInt(rgba.b),
.alpha = byteAlpha(rgba.a),
};
}
};
const Missing = struct {
function: enum { rgb, hsl, hwb },
/// rgb: 0-255. hsl/hwb: hue in degrees, then two 0-100 values.
channels: [3]?f64,
alpha: ?f64,
fn writeModern(self: Missing, writer: *Io.Writer, percent: []const u8) Io.Writer.Error!void {
try writer.writeAll(@tagName(self.function));
try writer.writeByte('(');
try writeComponent(writer, self.channels[0], 6, "");
try writer.writeByte(' ');
try writeComponent(writer, self.channels[1], 6, percent);
try writer.writeByte(' ');
try writeComponent(writer, self.channels[2], 6, percent);
try self.writeAlpha(writer);
try writer.writeByte(')');
}
fn writeAlpha(self: Missing, writer: *Io.Writer) Io.Writer.Error!void {
if (self.alpha == 1) return;
try writer.writeAll(" / ");
try writeComponent(writer, self.alpha, 6, "");
}
};
fn writeComponent(writer: *Io.Writer, value: ?f64, decimals: usize, unit: []const u8) Io.Writer.Error!void {
const v = value orelse return writer.writeAll("none");
try writeNumber(writer, v, decimals);
try writer.writeAll(unit);
}
fn writeNumber(writer: *Io.Writer, value: f64, decimals: usize) Io.Writer.Error!void {
var buf: [400]u8 = undefined;
var str: []const u8 = std.fmt.bufPrint(&buf, "{d:.[1]}", .{ value, decimals }) catch unreachable;
if (std.mem.indexOfScalar(u8, str, '.') != null) {
str = std.mem.trimEnd(u8, std.mem.trimEnd(u8, str, "0"), ".");
}
try writer.writeAll(if (std.mem.eql(u8, str, "-0")) "0" else str);
}
fn channelByte(value: f64) u8 {
// The epsilon keeps a computed exact half (hwb(320 30% 40%)'s blue is
// 127.5) from rounding down after float error.
return @intFromFloat(@floor(std.math.clamp(value, 0, 255) + 0.5 + 1e-6));
}
// An 8-bit alpha serializes as the shortest decimal that maps back to the
// same byte: #ffffff80 is rgba(255, 255, 255, 0.5).
fn byteAlpha(a: u8) f64 {
const byte: f64 = @floatFromInt(a);
const two = @round(byte / 255 * 100) / 100;
if (@round(two * 255) == byte) {
return two;
}
return @round(byte / 255 * 1000) / 1000;
}
pub fn parse(input: []const u8) Parsed {
if (usesSubstitution(input)) {
return .unsupported;
}
var stream: Stream = .{ .tokenizer = .{ .input = input } };
const token = stream.next() orelse return .invalid;
const parsed: Parsed = switch (token) {
.ident => |raw| parseKeyword(raw),
.id_hash, .unrestricted_hash => |hex| parseHex(hex),
.function => |raw| parseFunction(raw, &stream),
else => .invalid,
};
if (parsed != .value) {
return parsed;
}
if (stream.next() != null) {
return .invalid;
}
return parsed;
}
const Stream = struct {
tokenizer: Tokenizer,
peeked: ?Tokenizer.Token = null,
fn next(self: *Stream) ?Tokenizer.Token {
if (self.peeked) |token| {
self.peeked = null;
return token;
}
while (self.tokenizer.next()) |token| {
switch (token) {
.white_space, .comment => {},
else => return token,
}
}
return null;
}
fn peek(self: *Stream) ?Tokenizer.Token {
if (self.peeked == null) {
self.peeked = self.next();
}
return self.peeked;
}
};
// var() and friends substitute at computed-value time; the value can't be
// checked before then.
fn usesSubstitution(input: []const u8) bool {
var tokenizer: Tokenizer = .{ .input = input };
while (tokenizer.next()) |token| {
const raw = switch (token) {
.function => |raw| raw,
else => continue,
};
var buf: [8]u8 = undefined;
const name = lowerName(raw, &buf) orelse continue;
for ([_][]const u8{ "var", "env", "attr", "if", "inherit" }) |substitution| {
if (std.mem.eql(u8, name, substitution)) {
return true;
}
}
}
return false;
}
/// The ASCII-lowercased ident with escapes decoded, or null when it doesn't
/// fit `buf` or decodes to a non-ASCII code point (no keyword we match has
/// one).
fn lowerName(raw: []const u8, buf: []u8) ?[]const u8 {
var len: usize = 0;
var i: usize = 0;
while (i < raw.len) {
var c = raw[i];
i += 1;
if (c == '\\' and i < raw.len) {
if (std.ascii.isHex(raw[i])) {
var code: u32 = 0;
var digits: usize = 0;
while (i < raw.len and digits < 6 and std.ascii.isHex(raw[i])) : (digits += 1) {
code = code * 16 + (std.fmt.charToDigit(raw[i], 16) catch unreachable);
i += 1;
}
if (i < raw.len and std.ascii.isWhitespace(raw[i])) {
i += 1;
}
if (code == 0 or code > 127) {
return null;
}
c = @intCast(code);
} else {
c = raw[i];
i += 1;
}
}
if (len == buf.len) {
return null;
}
buf[len] = std.ascii.toLower(c);
len += 1;
}
return buf[0..len];
}
const keywords = [_][]const u8{
"currentcolor",
// CSS-wide keywords
"inherit",
"initial",
"unset",
"revert",
"revert-layer",
// System colors
"accentcolor",
"accentcolortext",
"activetext",
"buttonborder",
"buttonface",
"buttontext",
"canvas",
"canvastext",
"field",
"fieldtext",
"graytext",
"highlight",
"highlighttext",
"linktext",
"mark",
"marktext",
"selecteditem",
"selecteditemtext",
"visitedtext",
// Deprecated system colors
"activeborder",
"activecaption",
"appworkspace",
"background",
"buttonhighlight",
"buttonshadow",
"captiontext",
"inactiveborder",
"inactivecaption",
"inactivecaptiontext",
"infobackground",
"infotext",
"menu",
"menutext",
"scrollbar",
"threeddarkshadow",
"threedface",
"threedhighlight",
"threedlightshadow",
"threedshadow",
"window",
"windowframe",
"windowtext",
};
fn parseKeyword(raw: []const u8) Parsed {
// Vendor keywords (-webkit-link, ...)
if (std.mem.startsWith(u8, raw, "-")) return .unsupported;
var buf: [32]u8 = undefined;
const name = lowerName(raw, &buf) orelse return .invalid;
if (std.meta.stringToEnum(std.meta.DeclEnum(RGBA.Named), name)) |match| {
const rgba = switch (match) {
inline else => |comptime_enum| @field(RGBA.Named, @tagName(comptime_enum)),
};
return .{ .value = .{ .named = .{ .name = @tagName(match), .rgb = .fromRGBA(rgba) } } };
}
for (keywords) |keyword| {
if (std.mem.eql(u8, name, keyword)) {
return .{ .value = .{ .keyword = keyword } };
}
}
return .invalid;
}
fn parseHex(hex: []const u8) Parsed {
var buf: [9]u8 = undefined;
if (hex.len >= buf.len) {
return .invalid;
}
buf[0] = '#';
@memcpy(buf[1..][0..hex.len], hex);
const rgba = RGBA.parse(buf[0 .. hex.len + 1]) catch return .invalid;
return .{ .value = .{ .rgb = .fromRGBA(rgba) } };
}
// Color functions we don't parse but mustn't reject.
const unsupported_functions = [_][]const u8{
"lab", "lch", "oklab", "oklch", "color", "color-mix",
"light-dark", "alpha", "device-cmyk", "color-layers", "contrast-color",
};
fn parseFunction(raw: []const u8, stream: *Stream) Parsed {
if (std.mem.startsWith(u8, raw, "-")) {
return .unsupported;
}
var buf: [16]u8 = undefined;
const name = lowerName(raw, &buf) orelse return .invalid;
if (std.mem.eql(u8, name, "rgb") or std.mem.eql(u8, name, "rgba")) {
return parseRgb(stream) catch |err| fromError(err);
}
if (std.mem.eql(u8, name, "hsl") or std.mem.eql(u8, name, "hsla")) {
return parseHslLike(stream, .hsl) catch |err| fromError(err);
}
if (std.mem.eql(u8, name, "hwb")) {
return parseHslLike(stream, .hwb) catch |err| fromError(err);
}
for (unsupported_functions) |unsupported| {
if (std.mem.eql(u8, name, unsupported)) {
return .unsupported;
}
}
return .invalid;
}
const ParseError = error{ Invalid, Unsupported };
fn fromError(err: ParseError) Parsed {
return switch (err) {
error.Invalid => .invalid,
error.Unsupported => .unsupported,
};
}
/// A function argument. Percentages are in percent (50% is 50), angles in
/// degrees.
const Component = union(enum) {
number: f64,
percent: f64,
angle: f64,
none,
};
const Arg = union(enum) {
comma,
slash,
value: Component,
};
/// The arguments of a color function: legacy (comma-separated, no `none`)
/// or modern (space-separated, `/ alpha`).
const Args = struct {
legacy: bool,
channels: [3]Component,
alpha: ?Component,
};
fn readArgs(stream: *Stream) ParseError!Args {
var buf: [8]Arg = undefined;
var len: usize = 0;
while (stream.next()) |token| {
const arg: Arg = switch (token) {
.close_parenthesis => break,
.comma => .comma,
.delim => |d| if (d == '/') .slash else return error.Invalid,
.ident => |raw| blk: {
var name_buf: [8]u8 = undefined;
const name = lowerName(raw, &name_buf) orelse return error.Invalid;
if (std.mem.eql(u8, name, "none")) {
break :blk .{ .value = .none };
}
if (std.mem.eql(u8, name, "from")) {
// Relative color syntax
return error.Unsupported;
}
return error.Invalid;
},
.function => |raw| .{ .value = try calcFunction(raw, stream) },
else => .{ .value = try numeric(token) },
};
if (len == buf.len) return error.Invalid;
buf[len] = arg;
len += 1;
}
// An unclosed function at the end of the input closes there.
const args = buf[0..len];
var comma_count: usize = 0;
for (args) |arg| {
if (arg == .comma) {
comma_count += 1;
}
}
if (comma_count > 0) {
if (len != 5 and len != 7) {
return error.Invalid;
}
var values: [4]Component = undefined;
for (0..len / 2 + 1) |i| {
values[i] = switch (args[i * 2]) {
.value => |v| if (v == .none) return error.Invalid else v,
else => return error.Invalid,
};
if (i * 2 + 1 < len and args[i * 2 + 1] != .comma) {
return error.Invalid;
}
}
return .{
.legacy = true,
.channels = values[0..3].*,
.alpha = if (len == 7) values[3] else null,
};
}
if (len != 3 and len != 5) {
return error.Invalid;
}
var values: [4]Component = undefined;
for (args, 0..) |arg, i| {
if (i == 3) {
if (arg != .slash) {
return error.Invalid;
}
continue;
}
values[if (i == 4) 3 else i] = switch (arg) {
.value => |v| v,
else => return error.Invalid,
};
}
return .{
.legacy = false,
.channels = values[0..3].*,
.alpha = if (len == 5) values[3] else null,
};
}
fn numeric(token: Tokenizer.Token) ParseError!Component {
switch (token) {
.number => |n| return .{ .number = widen(n.value) },
.percentage => |p| return .{
.percent = if (p.int_value) |int| @floatFromInt(int) else widen(p.unit_value) * 100,
},
.dimension => |d| {
const value = widen(d.value);
var buf: [8]u8 = undefined;
const unit = lowerName(d.unit, &buf) orelse return error.Invalid;
if (std.mem.eql(u8, unit, "deg")) {
return .{ .angle = value };
}
if (std.mem.eql(u8, unit, "grad")) {
return .{ .angle = value * 0.9 };
}
if (std.mem.eql(u8, unit, "rad")) {
return .{ .angle = std.math.radiansToDegrees(value) };
}
if (std.mem.eql(u8, unit, "turn")) {
return .{ .angle = value * 360 };
}
return error.Invalid;
},
else => return error.Invalid,
}
}
// The tokenizer's f32, as the shortest decimal that reads back the same: 0.8
// stays 0.8, not 0.800000011920929.
fn widen(value: f32) f64 {
var buf: [64]u8 = undefined;
const str = std.fmt.bufPrint(&buf, "{d}", .{value}) catch return value;
return std.fmt.parseFloat(f64, str) catch value;
}
// A calc() whose operands are all numbers, percentages or angles. Anything
// needing layout (1em, sign(), min()) is left unevaluated.
fn calcFunction(raw: []const u8, stream: *Stream) ParseError!Component {
var buf: [8]u8 = undefined;
const name = lowerName(raw, &buf) orelse return error.Unsupported;
if (std.mem.eql(u8, name, "calc") == false) {
return error.Unsupported;
}
var result = try calcBlock(stream);
// NaN is censored to zero at the top level of a calculation.
switch (result) {
.number, .percent, .angle => |*v| if (std.math.isNan(v.*)) {
v.* = 0;
},
.none => unreachable,
}
return result;
}
// A sum, closed by `)` (or the end of the input).
fn calcBlock(stream: *Stream) ParseError!Component {
const result = try calcSum(stream);
if (stream.next()) |token| {
if (token != .close_parenthesis) {
return error.Invalid;
}
}
return result;
}
fn calcSum(stream: *Stream) ParseError!Component {
var acc = try calcProduct(stream);
while (stream.peek()) |token| {
const op = switch (token) {
.delim => |d| if (d == '+' or d == '-') d else return acc,
else => return acc,
};
_ = stream.next();
const rhs = try calcProduct(stream);
if (std.meta.activeTag(acc) != std.meta.activeTag(rhs)) {
return error.Invalid;
}
const value = if (op == '+') magnitude(acc) + magnitude(rhs) else magnitude(acc) - magnitude(rhs);
acc = withMagnitude(acc, value);
}
return acc;
}
fn calcProduct(stream: *Stream) ParseError!Component {
var acc = try calcValue(stream);
while (stream.peek()) |token| {
const op = switch (token) {
.delim => |d| if (d == '*' or d == '/') d else return acc,
else => return acc,
};
_ = stream.next();
const rhs = try calcValue(stream);
if (op == '*') {
const value = magnitude(acc) * magnitude(rhs);
if (acc == .number) {
acc = withMagnitude(rhs, value);
} else if (rhs == .number) {
acc = withMagnitude(acc, value);
} else {
return error.Invalid;
}
} else {
if (rhs != .number) return error.Invalid;
acc = withMagnitude(acc, magnitude(acc) / magnitude(rhs));
}
}
return acc;
}
fn calcValue(stream: *Stream) ParseError!Component {
const token = stream.next() orelse return error.Invalid;
switch (token) {
.parenthesis_block => return calcBlock(stream),
.function => |raw| {
var buf: [8]u8 = undefined;
const name = lowerName(raw, &buf) orelse return error.Unsupported;
if (std.mem.eql(u8, name, "calc") == false) {
return error.Unsupported;
}
return calcBlock(stream);
},
.ident => |raw| {
var buf: [16]u8 = undefined;
const name = lowerName(raw, &buf) orelse return error.Invalid;
const constants = [_]struct { []const u8, f64 }{
.{ "e", std.math.e },
.{ "pi", std.math.pi },
.{ "infinity", std.math.inf(f64) },
.{ "-infinity", -std.math.inf(f64) },
.{ "nan", std.math.nan(f64) },
};
for (constants) |constant| {
if (std.mem.eql(u8, name, constant[0])) {
return .{ .number = constant[1] };
}
}
return error.Invalid;
},
else => return numeric(token),
}
}
fn magnitude(component: Component) f64 {
return switch (component) {
.number, .percent, .angle => |v| v,
.none => unreachable,
};
}
fn withMagnitude(component: Component, value: f64) Component {
return switch (component) {
.number => .{ .number = value },
.percent => .{ .percent = value },
.angle => .{ .angle = value },
.none => unreachable,
};
}
fn parseRgb(stream: *Stream) ParseError!Parsed {
const args = try readArgs(stream);
if (args.legacy) {
// Legacy channels are all numbers or all percentages.
const tag = std.meta.activeTag(args.channels[0]);
for (args.channels) |channel| {
if (std.meta.activeTag(channel) != tag) {
return error.Invalid;
}
}
}
var channels: [3]?f64 = undefined;
var missing = false;
for (args.channels, &channels) |component, *channel| {
channel.* = switch (component) {
.number => |n| std.math.clamp(n, 0, 255),
.percent => |p| std.math.clamp(p / 100 * 255, 0, 255),
.none => null,
.angle => return error.Invalid,
};
if (channel.* == null) missing = true;
}
const alpha = try parseAlpha(args.alpha);
if (missing or alpha == null) {
return .{ .value = .{ .missing = .{ .function = .rgb, .channels = channels, .alpha = alpha } } };
}
return .{ .value = .{ .rgb = .{ .r = channels[0].?, .g = channels[1].?, .b = channels[2].?, .alpha = alpha.? } } };
}
fn parseAlpha(component: ?Component) ParseError!?f64 {
return switch (component orelse return 1) {
.number => |n| std.math.clamp(n, 0, 1),
.percent => |p| std.math.clamp(p / 100, 0, 1),
.none => null,
.angle => error.Invalid,
};
}
fn parseHslLike(stream: *Stream, function: @FieldType(Missing, "function")) ParseError!Parsed {
const args = try readArgs(stream);
if (args.legacy and function == .hwb) {
return error.Invalid;
}
var channels: [3]?f64 = undefined;
channels[0] = switch (args.channels[0]) {
// Hue normalization sends infinities to 0 too.
.number, .angle => |h| if (std.math.isFinite(h)) h else 0,
.none => null,
.percent => return error.Invalid,
};
for (args.channels[1..], channels[1..]) |component, *channel| {
channel.* = switch (component) {
.percent => |p| p,
// Legacy hsl() only takes percentages.
.number => |n| if (args.legacy) return error.Invalid else n,
.none => null,
.angle => return error.Invalid,
};
}
// Negative saturation clamps to 0 at parse time.
if (function == .hsl) {
if (channels[1]) |*s| s.* = @max(s.*, 0);
}
const alpha = try parseAlpha(args.alpha);
const hue = channels[0] orelse return missingValue(function, channels, alpha);
const second = channels[1] orelse return missingValue(function, channels, alpha);
const third = channels[2] orelse return missingValue(function, channels, alpha);
const a = alpha orelse return missingValue(function, channels, alpha);
const rgb = switch (function) {
.hsl => hslToRgb(hue, std.math.clamp(second / 100, 0, 1), std.math.clamp(third / 100, 0, 1)),
.hwb => hwbToRgb(hue, @max(second / 100, 0), @max(third / 100, 0)),
.rgb => unreachable,
};
return .{ .value = .{ .rgb = .{
.r = std.math.clamp(rgb[0] * 255, 0, 255),
.g = std.math.clamp(rgb[1] * 255, 0, 255),
.b = std.math.clamp(rgb[2] * 255, 0, 255),
.alpha = a,
} } };
}
fn missingValue(function: @FieldType(Missing, "function"), channels: [3]?f64, alpha: ?f64) Parsed {
return .{ .value = .{ .missing = .{ .function = function, .channels = channels, .alpha = alpha } } };
}
// https://drafts.csswg.org/css-color-4/#hsl-to-rgb
fn hslToRgb(hue: f64, saturation: f64, lightness: f64) [3]f64 {
var h = @mod(hue, 360);
if (h < 0) h += 360;
const a = saturation * @min(lightness, 1 - lightness);
var rgb: [3]f64 = undefined;
for ([_]f64{ 0, 8, 4 }, &rgb) |n, *channel| {
const k = @mod(n + h / 30, 12);
channel.* = lightness - a * @max(-1, @min(@min(k - 3, 9 - k), 1));
}
return rgb;
}
// https://drafts.csswg.org/css-color-4/#hwb-to-rgb
fn hwbToRgb(hue: f64, white: f64, black: f64) [3]f64 {
if (white + black >= 1) {
const gray = white / (white + black);
return .{ gray, gray, gray };
}
var rgb = hslToRgb(hue, 1, 0.5);
for (&rgb) |*channel| {
channel.* = channel.* * (1 - white - black) + white;
}
return rgb;
}
fn expectSerialized(input: []const u8, specified: ?[]const u8, computed: []const u8) !void {
const parsed = parse(input);
if (parsed != .value) {
std.debug.print("{s}: {t}\n", .{ input, parsed });
return error.TestUnexpectedResult;
}
var buf: [128]u8 = undefined;
var writer: Io.Writer = .fixed(&buf);
try parsed.value.writeSpecified(&writer);
try std.testing.expectEqualStrings(specified orelse computed, writer.buffered());
writer = .fixed(&buf);
try parsed.value.writeComputed(&writer);
try std.testing.expectEqualStrings(computed, writer.buffered());
}
test "color: parse and serialize" {
try expectSerialized("red", "red", "rgb(255, 0, 0)");
try expectSerialized(" TransParent\n", "transparent", "rgba(0, 0, 0, 0)");
try expectSerialized("/**/CyAn", "cyan", "rgb(0, 255, 255)");
try expectSerialized("currentColor", "currentcolor", "currentcolor");
try expectSerialized("ButtonFace", "buttonface", "buttonface");
try expectSerialized("#234", null, "rgb(34, 51, 68)");
try expectSerialized("#FEDCBA", null, "rgb(254, 220, 186)");
try expectSerialized("#ffffff80", null, "rgba(255, 255, 255, 0.5)");
try expectSerialized("rgb(-51, 306, 0)", null, "rgb(0, 255, 0)");
try expectSerialized("rgb(42%, 3%, 50%)", null, "rgb(107, 8, 128)");
try expectSerialized("rgb(/* R */0, /* G */51, /* B */255)", null, "rgb(0, 51, 255)");
try expectSerialized("r\\67 b(00, 51, 102)", null, "rgb(0, 51, 102)");
try expectSerialized("rgba(0, 51, 255, 0.42)", null, "rgba(0, 51, 255, 0.42)");
try expectSerialized("rgba(0, 51, 255, 37)", null, "rgb(0, 51, 255)");
try expectSerialized("rgb(250% 51 40%)", null, "rgb(255, 51, 102)");
try expectSerialized("rgb(0 0 0 / 50%)", null, "rgba(0, 0, 0, 0.5)");
try expectSerialized("rgb(calc(infinity), 0, 0)", null, "rgb(255, 0, 0)");
try expectSerialized("rgba(0, 0, 0, calc(0 / 0))", null, "rgba(0, 0, 0, 0)");
try expectSerialized("rgb(calc(10 + 20 * 2) 0 0)", null, "rgb(50, 0, 0)");
try expectSerialized("rgb(128 none none)", "rgb(128, 0, 0)", "color(srgb 0.50196078 none none)");
try expectSerialized("rgb(20% none none / none)", "rgba(51, 0, 0, 0)", "color(srgb 0.2 none none / none)");
try expectSerialized("hsl(120 30% 50%)", null, "rgb(89, 166, 89)");
try expectSerialized("hsla(-300, 100%, 37.5%, 0.2)", null, "rgba(191, 191, 0, 0.2)");
try expectSerialized("hsl(30deg, 100%, 100%)", null, "rgb(255, 255, 255)");
try expectSerialized("hsl(0.5turn 100% 50%)", null, "rgb(0, 255, 255)");
try expectSerialized("hsl(0 -50% 40%)", null, "rgb(102, 102, 102)");
try expectSerialized("hsl(calc(infinity) 100% 50%)", null, "rgb(255, 0, 0)");
try expectSerialized("hsl(120 80% none)", "hsl(120 80 none)", "hsl(120 80% none)");
try expectSerialized("hsl(120 100% 50% / none)", "hsl(120 100 50 / none)", "hsl(120 100% 50% / none)");
try expectSerialized("hwb(320deg 30% 40%)", null, "rgb(153, 77, 128)");
try expectSerialized("hwb(0 70% 60%)", null, "rgb(137, 137, 137)");
try expectSerialized("hwb(none none none / none)", null, "hwb(none none none / none)");
}
test "color: invalid and unsupported" {
const invalid = [_][]const u8{
"", " /* hey */\n", "4", "top",
"current-Color", "blac\u{212A}", "red blue", "#12",
"#ffg", "#123456789", "rgb", "rgb()",
"rgb(1)", "rgb(1,2,3,4,5)", "rgb(10%, 20, 30%)", "rgb(none, none, none)",
"rgb(0, 0 0)", "rgb(0, 0, 0,)", "rgb(0, 0,, 0)", "rgb(0, 0, 0deg)",
"rgb(0, 0, light)", "rgb(257, 0, 5 / 0)", "hsl(10, 50%, 0)", "hsl(50%, 50%, 0%)",
"hsl(0, 50, 30%)", "hwba(120 30% 50%)", "hwb(90, 50%, 50%)", "hsl(calc(0.56turn * -0.43turn), 47%, 4884.6%)",
"rgba(-2, 300, 400%, -0.5)", "foo(1)",
};
for (invalid) |input| {
std.testing.expectEqual(Parsed.invalid, parse(input)) catch |err| {
std.debug.print("expected invalid: {s}\n", .{input});
return err;
};
}
const unsupported = [_][]const u8{
"var(--x)", "rgb(var(--x), 0, 0)", "red var(--x)",
"lab(50% 20 -30)", "color-mix(in srgb, red, blue)", "light-dark(black, white)",
"rgb(from red r g b)", "rgb(calc(50% + (sign(1em - 10px) * 10%)), 0%, 0%)", "-webkit-link",
};
for (unsupported) |input| {
std.testing.expectEqual(Parsed.unsupported, parse(input)) catch |err| {
std.debug.print("expected unsupported: {s}\n", .{input});
return err;
};
}
}
+1 -1
View File
@@ -391,7 +391,7 @@
const impDiv = document.createElement('div');
impDiv.setAttribute('style', 'color: red !important; color: blue');
document.body.appendChild(impDiv);
testing.expectEqual('red', window.getComputedStyle(impDiv).getPropertyValue('color'));
testing.expectEqual('rgb(255, 0, 0)', window.getComputedStyle(impDiv).getPropertyValue('color'));
testing.expectEqual('red', impDiv.style.getPropertyValue('color'));
}
</script>
+64 -6
View File
@@ -19,10 +19,11 @@
const std = @import("std");
const lp = @import("lightpanda");
const CssParser = @import("../../css/Parser.zig");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const color = @import("../../color.zig");
const CssParser = @import("../../css/Parser.zig");
const Element = @import("../Element.zig");
const DOMMatrixReadOnly = @import("../DOMMatrixReadOnly.zig");
@@ -78,14 +79,34 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const
const wrapped = String.wrap(normalized);
const value = self.propertyValue(normalized, wrapped, frame);
// The resolved transform is always a matrix: animation libraries
// (popmotion) regex it as matrix()/matrix3d() and crash on anything else.
if (self._is_computed and wrapped.eql(comptime .wrap("transform"))) {
if (self._is_computed == false) {
return value;
}
if (wrapped.eql(comptime .wrap("transform"))) {
// The resolved transform is always a matrix: animation libraries
// (popmotion) regex it as matrix()/matrix3d() and crash on anything
// else.
return resolvedTransform(value, frame);
}
if (isColorProperty(normalized)) {
return computedColor(value, frame);
}
return value;
}
fn computedColor(value: []const u8, frame: *Frame) []const u8 {
const parsed = switch (color.parse(value)) {
.value => |v| v,
.invalid, .unsupported => return value,
};
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
parsed.writeComputed(&buf.writer) catch return value;
return buf.written();
}
fn propertyValue(self: *const CSSStyleDeclaration, normalized: []const u8, wrapped: String, frame: *Frame) []const u8 {
// Computed styles must reflect stylesheet rules, not just the element's
@@ -355,7 +376,21 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
}
// Normalize the value for canonical serialization
const normalized_value = try normalizePropertyValue(frame.local_arena, normalized, value);
const normalized_value = blk: {
if (isColorProperty(normalized)) {
switch (color.parse(value)) {
// An invalid value leaves the declaration as it was.
.invalid => return false,
.value => |parsed| {
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try parsed.writeSpecified(&buf.writer);
break :blk buf.written();
},
.unsupported => {},
}
}
break :blk try normalizePropertyValue(frame.local_arena, normalized, value);
};
// Find existing property
if (self.findProperty(.wrap(normalized))) |existing| {
@@ -548,6 +583,29 @@ fn normalizePropertyName(allocator: Allocator, name: []const u8) []const u8 {
return std.ascii.allocLowerString(allocator, name) catch name;
}
fn isColorProperty(name: []const u8) bool {
const color_properties: std.StaticStringMap(void) = .initComptime(.{
.{ "background-color", {} },
.{ "border-block-end-color", {} },
.{ "border-block-start-color", {} },
.{ "border-bottom-color", {} },
.{ "border-inline-end-color", {} },
.{ "border-inline-start-color", {} },
.{ "border-left-color", {} },
.{ "border-right-color", {} },
.{ "border-top-color", {} },
.{ "color", {} },
.{ "column-rule-color", {} },
.{ "flood-color", {} },
.{ "lighting-color", {} },
.{ "outline-color", {} },
.{ "stop-color", {} },
.{ "text-decoration-color", {} },
.{ "text-emphasis-color", {} },
});
return color_properties.has(name);
}
fn isCustomProperty(name: []const u8) bool {
return std.mem.startsWith(u8, name, "--");
}