diff --git a/src/ExCSS.Tests/ModernColorTests.cs b/src/ExCSS.Tests/ModernColorTests.cs new file mode 100644 index 0000000..ceb76c7 --- /dev/null +++ b/src/ExCSS.Tests/ModernColorTests.cs @@ -0,0 +1,324 @@ +using System.Collections.Generic; +using System.Linq; + +namespace ExCSS.Tests +{ + using ExCSS; + using Xunit; + + /// + /// CSS Color 4/5 function forms that the strict Layer A color grammar doesn't compute — oklch(), + /// oklab(), lab(), lch(), and color-mix(), plus the lenient CSS Color 4 space-separated / slash-alpha + /// forms of rgb()/hsl()/hwb() — now resolve to real sRGB colors via + /// . (These are what a Tailwind v4 default + /// palette and its opacity modifiers are authored in.) + /// + public class ModernColorTests : CssConstructionFunctions + { + // Tokenize a bare CSS color value and resolve it to a concrete sRGB Color, mirroring how the + // object-model resolver consumes an already-parsed token stream (nested functions arrive as a + // single FunctionToken). + private static Color Resolve(string value) + { + var color = Tokenize(value).ToResolvedColor(); + Assert.True(color.HasValue, $"expected '{value}' to resolve to a color"); + return color.Value; + } + + private static List Tokenize(string value) + { + var lexer = new Lexer(new TextSource(value)); + var tokens = new List(); + Token token; + do + { + token = lexer.Get(); + if (token.Type != TokenType.EndOfFile && token.Type != TokenType.Whitespace) + { + tokens.Add(token); + } + } while (token.Type != TokenType.EndOfFile); + + return tokens; + } + + // ── hsl()/hwb() as solid colors (previously would stack-overflow at resolve time) ── + + [Theory] + [InlineData("hsl(120, 100%, 50%)")] + [InlineData("hsl(120 100% 50%)")] + public void Hsl_ResolvesToGreen(string value) + { + var c = Resolve(value); + Assert.Equal(0, c.R); + Assert.Equal(255, c.G); + Assert.Equal(0, c.B); + Assert.Equal(255, c.A); + } + + [Fact] + public void Hsla_SlashAndCommaAlpha_AreApplied() + { + Assert.InRange(Resolve("hsla(0, 100%, 50%, 0.5)").A, 127, 128); + Assert.InRange(Resolve("hsl(0 100% 50% / 50%)").A, 127, 128); + } + + [Fact] + public void Hwb_ResolvesToPrimary() + { + var c = Resolve("hwb(240 0% 0%)"); + Assert.Equal(0, c.R); + Assert.Equal(0, c.G); + Assert.Equal(255, c.B); + } + + [Fact] + public void Gray_Resolves() + { + var c = Resolve("gray(128)"); + Assert.Equal(128, c.R); + Assert.Equal(128, c.G); + Assert.Equal(128, c.B); + } + + // ── Space-separated & slash-alpha rgb() (lenient CSS Color 4 forms) ──────── + + [Theory] + [InlineData("rgb(10 20 30)")] + [InlineData("rgb(10, 20, 30)")] + public void Rgb_SpaceAndCommaSeparated_Resolve(string value) + { + var c = Resolve(value); + Assert.Equal(10, c.R); + Assert.Equal(20, c.G); + Assert.Equal(30, c.B); + Assert.Equal(255, c.A); + } + + [Fact] + public void Rgb_SlashAlpha_IsApplied() + { + Assert.InRange(Resolve("rgb(10 20 30 / 0.5)").A, 127, 128); + Assert.InRange(Resolve("rgba(10 20 30 / 50%)").A, 127, 128); + } + + // ── oklch hue-angle units ───────────────────────────────────────────────── + + [Theory] + [InlineData("oklch(0.7 0.15 0.25turn)")] // 0.25turn == 90deg + [InlineData("oklch(0.7 0.15 100grad)")] // 100grad == 90deg + [InlineData("oklch(0.7 0.15 1.5707963rad)")] // ~90deg + public void Oklch_HueAngleUnits_MatchDegrees(string value) + { + var expected = Resolve("oklch(0.7 0.15 90deg)"); + var c = Resolve(value); + Assert.InRange(c.R, expected.R - 1, expected.R + 1); + Assert.InRange(c.G, expected.G - 1, expected.G + 1); + Assert.InRange(c.B, expected.B - 1, expected.B + 1); + } + + [Fact] + public void NoneComponents_ResolveToZero() + { + // `none` resolves to 0: oklab(1 none none) == oklab(1 0 0) == white. + var c = Resolve("oklab(1 none none)"); + Assert.InRange(c.R, 252, 255); + Assert.InRange(c.G, 252, 255); + Assert.InRange(c.B, 252, 255); + } + + [Fact] + public void ColorMix_InPolarSpace_WithHueMethod_Resolves() + { + // A polar interpolation space plus an explicit hue-interpolation method. + var c = Resolve("color-mix(in oklch longer hue, oklch(0.7 0.2 20), oklch(0.7 0.2 200))"); + Assert.False(c.R == 0 && c.G == 0 && c.B == 0, "expected a real mixed color, not black"); + } + + // ── Lightness extremes (exact, space-independent) ───────────────────────── + + [Theory] + [InlineData("oklab(0 0 0)")] + [InlineData("lab(0 0 0)")] + [InlineData("oklch(0 0 0)")] + [InlineData("lch(0 0 0)")] + public void Lightness0_IsBlack(string value) + { + var c = Resolve(value); + Assert.Equal(0, c.R); + Assert.Equal(0, c.G); + Assert.Equal(0, c.B); + Assert.Equal(255, c.A); + } + + [Theory] + [InlineData("oklab(1 0 0)")] + [InlineData("lab(100 0 0)")] + [InlineData("oklch(1 0 0)")] + [InlineData("lch(100 0 0)")] + public void MaxLightness_NoChroma_IsWhite(string value) + { + var c = Resolve(value); + Assert.InRange(c.R, 252, 255); + Assert.InRange(c.G, 252, 255); + Assert.InRange(c.B, 252, 255); + } + + // ── Chroma / hue actually take effect (not a no-op) ─────────────────────── + + [Fact] + public void Oklch_RedHue_IsReddish() + { + // ~sRGB red sits near oklch(0.63 0.26 29deg); assert the channel ordering, not exact bytes. + var c = Resolve("oklch(0.63 0.26 29)"); + Assert.True(c.R > c.G && c.R > c.B, $"expected reddish, got {c.R},{c.G},{c.B}"); + Assert.True(c.R > 200, $"expected a strong red channel, got R={c.R}"); + } + + [Fact] + public void Oklch_PercentAndAngleUnits_AreHonored() + { + // 70% lightness == 0.7; 240deg is a blue-ish hue. + var c = Resolve("oklch(70% 0.15 240deg)"); + Assert.True(c.B > c.R, $"expected blue-dominant, got {c.R},{c.G},{c.B}"); + } + + [Fact] + public void Oklch_SlashAlpha_IsApplied() + { + var c = Resolve("oklch(0 0 0 / 0.5)"); + Assert.InRange(c.A, 127, 128); + } + + [Fact] + public void Lch_ResolvesToNonBlackColor() + { + var c = Resolve("lch(50% 40 30)"); + Assert.False(c.R == 0 && c.G == 0 && c.B == 0, "expected a real color, not black"); + Assert.True(c.R > c.B, $"expected a warm hue, got {c.R},{c.G},{c.B}"); + } + + [Fact] + public void Lab_ResolvesToNonBlackColor() + { + var c = Resolve("lab(50% 40 30)"); + Assert.False(c.R == 0 && c.G == 0 && c.B == 0, "expected a real color, not black"); + } + + // ── color-mix() ─────────────────────────────────────────────────────────── + + [Fact] + public void ColorMix_Srgb_WhiteBlack_IsMidGray() + { + var c = Resolve("color-mix(in srgb, white, black)"); + Assert.InRange(c.R, 127, 128); + Assert.InRange(c.G, 127, 128); + Assert.InRange(c.B, 127, 128); + Assert.Equal(255, c.A); + } + + [Fact] + public void ColorMix_Srgb_RedBlue_IsPurple() + { + var c = Resolve("color-mix(in srgb, red, blue)"); + Assert.InRange(c.R, 127, 128); + Assert.Equal(0, c.G); + Assert.InRange(c.B, 127, 128); + } + + [Fact] + public void ColorMix_Srgb_WeightedPercentages_ShiftTowardHeavierColor() + { + // 25% white + (implicit) 75% black -> 0.25 gray. + var c = Resolve("color-mix(in srgb, white 25%, black)"); + Assert.InRange(c.R, 63, 64); + } + + [Fact] + public void ColorMix_WithTransparent_IsOpacityModifier() + { + // The Tailwind v4 opacity-modifier shape: color at 50%, mixed with transparent -> same + // color at half alpha. + var c = Resolve("color-mix(in oklab, blue 50%, transparent)"); + Assert.True(c.B > 250, $"expected blue preserved, got B={c.B}"); + Assert.InRange(c.R, 0, 4); + Assert.InRange(c.G, 0, 4); + Assert.InRange(c.A, 126, 129); + } + + [Fact] + public void ColorMix_PercentageBeforeColor_IsAccepted() + { + // Per CSS Color 5 §3.1 the percentage may precede the color; `30% white` == `white 30%`. + var before = Resolve("color-mix(in srgb, 30% white, black)"); + var after = Resolve("color-mix(in srgb, white 30%, black)"); + Assert.Equal(after.R, before.R); + Assert.Equal(after.G, before.G); + Assert.Equal(after.B, before.B); + Assert.InRange(before.R, 76, 77); // 0.30 gray + } + + [Fact] + public void ColorMix_PercentagesBelow100_ScaleResultAlpha() + { + // 30% red + 30% blue: components mix 50/50, but the 60% total scales alpha to 0.6. + var c = Resolve("color-mix(in srgb, red 30%, blue 30%)"); + Assert.InRange(c.A, 152, 154); // 0.6 * 255 ≈ 153 + } + + [Fact] + public void ColorMix_WithNestedOklchOperand_Resolves() + { + // The Tailwind v4 opacity-modifier shape with an oklch operand (rather than a plain named + // color): the nested function must resolve through the full resolver. + var c = Resolve("color-mix(in oklab, oklch(0.62 0.2 265) 50%, transparent)"); + Assert.True(c.B > c.R, $"expected blue-dominant, got {c.R},{c.G},{c.B}"); + Assert.InRange(c.A, 126, 129); + } + + // ── Parse level: the value survives the Layer A cascade ─────────────────── + + [Theory] + [InlineData("oklch(0.63 0.26 29)")] + [InlineData("oklab(0.7 0.1 0.1)")] + [InlineData("lab(50% 40 30)")] + [InlineData("lch(50% 40 30)")] + [InlineData("color-mix(in srgb, red, blue)")] + [InlineData("hsl(280 70% 55%)")] + [InlineData("hwb(240 0% 0%)")] + [InlineData("rgb(1 2 3 / 0.5)")] + public void ModernColorFunction_IsAcceptedAsBackgroundColor(string value) + { + var sheet = ParseStyleSheet($"div {{ background-color: {value}; }}"); + var rule = sheet.StyleRules.OfType().Single(); + var declared = rule.Style.GetPropertyValue("background-color"); + Assert.False(string.IsNullOrEmpty(declared)); + } + + [Fact] + public void Oklch_ViaBackgroundShorthand_ExpandsToBackgroundColor() + { + // Regression: the `background` shorthand must accept a CSS Color 4/5 function and carry it to + // the background-color longhand (a strict-only Layer A color grammar would drop it). + var sheet = ParseStyleSheet("div { background: oklch(0.63 0.26 29); }"); + var rule = sheet.StyleRules.OfType().Single(); + var bgColor = rule.Style.GetPropertyValue("background-color"); + Assert.False(string.IsNullOrEmpty(bgColor)); + + // And the carried value resolves to a real (reddish) color, not black. + var resolved = Resolve(bgColor); + Assert.True(resolved.R > resolved.G && resolved.R > resolved.B, + $"expected reddish, got {resolved.R},{resolved.G},{resolved.B}"); + } + + [Fact] + public void HslAndHwb_AsSolidColors_DoNotThrow() + { + // hsl()/hwb() as solid colors must parse and resolve without recursion/overflow. + var green = Resolve("hsl(120 100% 50%)"); + Assert.Equal(255, green.G); + var blue = Resolve("hwb(240 0% 0%)"); + Assert.Equal(255, blue.B); + } + } +} diff --git a/src/ExCSS/Enumerations/FunctionNames.cs b/src/ExCSS/Enumerations/FunctionNames.cs index afd8eb8..98bbcc6 100644 --- a/src/ExCSS/Enumerations/FunctionNames.cs +++ b/src/ExCSS/Enumerations/FunctionNames.cs @@ -55,5 +55,10 @@ public static class FunctionNames public static readonly string Perspective = "perspective"; public static readonly string Gray = "gray"; public static readonly string Hwb = "hwb"; + public static readonly string Lab = "lab"; + public static readonly string Oklab = "oklab"; + public static readonly string Lch = "lch"; + public static readonly string Oklch = "oklch"; + public static readonly string ColorMix = "color-mix"; } } \ No newline at end of file diff --git a/src/ExCSS/Extensions/ColorFunctionExtensions.cs b/src/ExCSS/Extensions/ColorFunctionExtensions.cs new file mode 100644 index 0000000..5ac7f98 --- /dev/null +++ b/src/ExCSS/Extensions/ColorFunctionExtensions.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ExCSS +{ + /// + /// Resolves the CSS color function forms from a tokenized value into a concrete + /// : the legacy/CSS Color 4 rgb()/rgba()/hsl()/hsla()/hwb()/gray() and the + /// CSS Color 4/5 lab()/oklab()/lch()/oklch()/color-mix(). Tokenization is the object model's + /// job, so this consumes the already-parsed stream (nested functions + /// arrive as a single token) rather than re-scanning the source text. + /// + internal static class ColorFunctionExtensions + { + /// + /// Fully resolves a tokenized color value to a concrete - named colors and + /// hex via , plus every color function + /// (rgb/hsl/hwb/gray/lab/oklab/lch/oklch/color-mix). Unlike ToColor (which the object-model + /// cascade uses and which deliberately preserves a function's specified form for serialization), + /// this resolver always computes the sRGB value. + /// + public static Color? ToResolvedColor(this IEnumerable value) + { + var enumerable = value as Token[] ?? value.ToArray(); + + var basic = enumerable.ToColor(); + if (basic.HasValue) return basic; + + var element = enumerable.OnlyOrDefault(); + + // A hex color nested inside a function (e.g. a color-mix() operand) tokenizes as a Hash + // keyword token rather than a ColorToken, so ToColor misses it - resolve it here. + if (element is { Type: TokenType.Hash } hash) return Color.FromHex(hash.Data); + + return element is FunctionToken function ? ParseColorFunction(function) : null; + } + + public static Color? ParseColorFunction(FunctionToken function) + { + var name = function.Data; + var args = function.ArgumentTokens; + + if (name.Equals(FunctionNames.Rgb, StringComparison.OrdinalIgnoreCase) || + name.Equals(FunctionNames.Rgba, StringComparison.OrdinalIgnoreCase)) + return ParseRgb(args); + + if (name.Equals(FunctionNames.Hsl, StringComparison.OrdinalIgnoreCase) || + name.Equals(FunctionNames.Hsla, StringComparison.OrdinalIgnoreCase)) + return ParseHsl(args); + + if (name.Equals(FunctionNames.Hwb, StringComparison.OrdinalIgnoreCase)) + return ParseHwb(args); + + if (name.Equals(FunctionNames.Gray, StringComparison.OrdinalIgnoreCase)) + return ParseGray(args); + + if (name.Equals(FunctionNames.Lab, StringComparison.OrdinalIgnoreCase)) + return ParseRect(args, Color.FromLab, abReference: 125.0, lReference: 100.0); + if (name.Equals(FunctionNames.Oklab, StringComparison.OrdinalIgnoreCase)) + return ParseRect(args, Color.FromOklab, abReference: 0.4, lReference: 1.0); + if (name.Equals(FunctionNames.Lch, StringComparison.OrdinalIgnoreCase)) + return ParsePolar(args, Color.FromLch, chromaReference: 150.0, lReference: 100.0); + if (name.Equals(FunctionNames.Oklch, StringComparison.OrdinalIgnoreCase)) + return ParsePolar(args, Color.FromOklch, chromaReference: 0.4, lReference: 1.0); + + if (name.Equals(FunctionNames.ColorMix, StringComparison.OrdinalIgnoreCase)) + return ParseColorMix(args); + + return null; + } + + // ── rgb()/rgba(): 3 components (int or % of 255) + optional alpha ───────── + private static Color? ParseRgb(IEnumerable args) + { + if (!Extract(args, 3, out var comps, out var alphaTok)) return null; + var r = AsTokens(comps[0]).ToRgbComponent(); + var g = AsTokens(comps[1]).ToRgbComponent(); + var b = AsTokens(comps[2]).ToRgbComponent(); + if (r is null || g is null || b is null) return null; + var a = alphaTok is null ? 1f : AsTokens(alphaTok).ToAlphaValue() ?? 1f; + return new Color(r.Value, g.Value, b.Value, Clamp01Byte(a)); + } + + // ── hsl()/hsla(): hue angle + saturation% + lightness% + optional alpha ── + private static Color? ParseHsl(IEnumerable args) + { + if (!Extract(args, 3, out var comps, out var alphaTok)) return null; + var hue = HueDegrees(comps[0]); + var sat = AsTokens(comps[1]).ToPercent(); + var light = AsTokens(comps[2]).ToPercent(); + if (hue is null || sat is null || light is null) return null; + var a = alphaTok is null ? 1f : AsTokens(alphaTok).ToAlphaValue() ?? 1f; + return Color.FromHsla((float)(hue.Value / 360.0), sat.Value.NormalizedValue, light.Value.NormalizedValue, a); + } + + // ── hwb(): hue angle + whiteness% + blackness% + optional alpha ────────── + private static Color? ParseHwb(IEnumerable args) + { + if (!Extract(args, 3, out var comps, out var alphaTok)) return null; + var hue = HueDegrees(comps[0]); + var white = AsTokens(comps[1]).ToPercent(); + var black = AsTokens(comps[2]).ToPercent(); + if (hue is null || white is null || black is null) return null; + var a = alphaTok is null ? 1f : AsTokens(alphaTok).ToAlphaValue() ?? 1f; + return Color.FromHwba((float)(hue.Value / 360.0), white.Value.NormalizedValue, black.Value.NormalizedValue, a); + } + + // ── gray(): a single lightness component + optional alpha ──────────────── + private static Color? ParseGray(IEnumerable args) + { + if (!Extract(args, 1, out var comps, out var alphaTok)) return null; + var value = AsTokens(comps[0]).ToRgbComponent(); + if (value is null) return null; + var a = alphaTok is null ? 1f : AsTokens(alphaTok).ToAlphaValue() ?? 1f; + return Color.FromGray(value.Value, a); + } + + // ── lab()/oklab(): L + a + b + optional alpha ──────────────────────────── + private static Color? ParseRect(IEnumerable args, Func build, double abReference, double lReference) + { + if (!Extract(args, 3, out var comps, out var alphaTok)) return null; + var l = Component(comps[0], lReference); + var a = Component(comps[1], abReference); + var b = Component(comps[2], abReference); + if (l is null || a is null || b is null) return null; + var alpha = alphaTok is null ? 1f : AsTokens(alphaTok).ToAlphaValue() ?? 1f; + return build(l.Value, a.Value, b.Value, alpha); + } + + // ── lch()/oklch(): L + C + hue angle + optional alpha ──────────────────── + private static Color? ParsePolar(IEnumerable args, Func build, double chromaReference, double lReference) + { + if (!Extract(args, 3, out var comps, out var alphaTok)) return null; + var l = Component(comps[0], lReference); + var c = Component(comps[1], chromaReference); + var h = HueDegrees(comps[2]); + if (l is null || c is null || h is null) return null; + var alpha = alphaTok is null ? 1f : AsTokens(alphaTok).ToAlphaValue() ?? 1f; + return build(l.Value, c.Value, h.Value, alpha); + } + + // ── color-mix(in [], c1 [p1], c2 [p2]) ─────────────── + private static Color? ParseColorMix(IEnumerable args) + { + var segments = SplitByComma(args); + if (segments.Count != 3) return null; + + var config = segments[0].Where(t => t.Type != TokenType.Whitespace) + .Select(t => t.Data.ToLowerInvariant()).ToList(); + if (config.Count < 2 || config[0] != "in") return null; + var space = MapSpace(config[1]); + var hue = MapHue(config, 2); + + if (!ParseMixOperand(segments[1], out var c1, out var p1)) return null; + if (!ParseMixOperand(segments[2], out var c2, out var p2)) return null; + + // CSS Color 5 §3 normalization: a missing percentage is 100% minus the other; percentages + // scale to sum 1, and a pre-normalization sum below 100% scales the result alpha down. + if (p1 is null && p2 is null) { p1 = 0.5; p2 = 0.5; } + else if (p1 is null) p1 = Math.Max(0, 1.0 - p2.Value); + else if (p2 is null) p2 = Math.Max(0, 1.0 - p1.Value); + + var sum = p1.Value + p2.Value; + if (sum <= 0) return null; + var alphaMultiplier = sum < 1.0 ? sum : 1.0; + var t = p2.Value / sum; + + return Color.Mix(c1, c2, t, space, hue, alphaMultiplier); + } + + private static bool ParseMixOperand(List segment, out Color color, out double? percent) + { + color = default; + percent = null; + + var tokens = new List(); + foreach (var token in segment) + { + if (token.Type != TokenType.Whitespace) tokens.Add(token); + } + if (tokens.Count == 0) return false; + + // The optional percentage may appear before or after the color (CSS Color 5 §3.1), e.g. + // both `red 30%` and `30% red` are valid. + if (tokens[tokens.Count - 1].Type == TokenType.Percentage) + { + percent = AsTokens(tokens[tokens.Count - 1]).ToPercent()?.NormalizedValue; + tokens.RemoveAt(tokens.Count - 1); + } + else if (tokens[0].Type == TokenType.Percentage) + { + percent = AsTokens(tokens[0]).ToPercent()?.NormalizedValue; + tokens.RemoveAt(0); + } + + // A color-mix operand may itself be any color form (named/hex/rgb/oklch/...), so resolve it + // through the full resolver rather than the named/hex-only ToColor. + var resolved = tokens.ToResolvedColor(); + if (resolved is null) return false; + color = resolved.Value; + return true; + } + + // ── Token helpers ──────────────────────────────────────────────────────── + + // Partitions a function's argument tokens into `count` component tokens and an optional alpha + // token. Whitespace and commas separate; a "/" delimiter (CSS Color 4) marks the alpha that + // follows. Without a slash, a trailing extra value (legacy rgba/hsla comma alpha) is the alpha. + private static bool Extract(IEnumerable args, int count, out List components, out Token alpha) + { + components = new List(); + alpha = null; + + var values = new List(); + var slashIndex = -1; + foreach (var token in args) + { + switch (token.Type) + { + case TokenType.Whitespace: + case TokenType.Comma: + continue; + case TokenType.Delim when token.Data == "/": + slashIndex = values.Count; + continue; + default: + values.Add(token); + break; + } + } + + if (values.Count < count) return false; + for (var i = 0; i < count; i++) components.Add(values[i]); + + var alphaAt = slashIndex >= 0 ? slashIndex : count; + if (alphaAt < values.Count) alpha = values[alphaAt]; + return true; + } + + private static List> SplitByComma(IEnumerable tokens) + { + var result = new List>(); + var current = new List(); + foreach (var token in tokens) + { + if (token.Type == TokenType.Comma) + { + result.Add(current); + current = new List(); + } + else + { + current.Add(token); + } + } + result.Add(current); + return result; + } + + private static Token[] AsTokens(Token token) => new[] { token }; + + // A number (returned as-is) or a percentage resolved against `reference`; "none" resolves to 0. + private static double? Component(Token token, double reference) + { + if (token.Type == TokenType.Ident && token.Data.Equals("none", StringComparison.OrdinalIgnoreCase)) + return 0.0; + var number = AsTokens(token).ToSingle(); + if (number.HasValue) return number.Value; + var percent = AsTokens(token).ToPercent(); + return percent.HasValue ? percent.Value.NormalizedValue * reference : (double?)null; + } + + private static double? HueDegrees(Token token) + { + if (token.Type == TokenType.Ident && token.Data.Equals("none", StringComparison.OrdinalIgnoreCase)) + return 0.0; + var angle = AsTokens(token).ToAngleNumber(); + if (!angle.HasValue) return null; + switch (angle.Value.Type) + { + case Angle.Unit.Grad: return angle.Value.Value * 0.9; + case Angle.Unit.Turn: return angle.Value.Value * 360.0; + case Angle.Unit.Rad: return angle.Value.Value * 180.0 / Math.PI; + default: return angle.Value.Value; + } + } + + private static byte Clamp01Byte(float alpha) + { + var clamped = alpha < 0f ? 0f : (alpha > 1f ? 1f : alpha); + return (byte)Math.Round(clamped * 255); + } + + private static ColorSpaceMath.Space MapSpace(string name) + { + switch (name) + { + case "srgb-linear": return ColorSpaceMath.Space.SrgbLinear; + case "display-p3": return ColorSpaceMath.Space.DisplayP3; + case "lab": return ColorSpaceMath.Space.Lab; + case "oklab": return ColorSpaceMath.Space.Oklab; + case "xyz": + case "xyz-d65": return ColorSpaceMath.Space.XyzD65; + case "xyz-d50": return ColorSpaceMath.Space.XyzD50; + case "hsl": return ColorSpaceMath.Space.Hsl; + case "hwb": return ColorSpaceMath.Space.Hwb; + case "lch": return ColorSpaceMath.Space.Lch; + case "oklch": return ColorSpaceMath.Space.Oklch; + default: return ColorSpaceMath.Space.Srgb; + } + } + + private static ColorSpaceMath.HueMethod MapHue(List idents, int startIndex) + { + for (var i = startIndex; i + 1 < idents.Count; i++) + { + if (idents[i + 1] != "hue") continue; + switch (idents[i]) + { + case "longer": return ColorSpaceMath.HueMethod.Longer; + case "increasing": return ColorSpaceMath.HueMethod.Increasing; + case "decreasing": return ColorSpaceMath.HueMethod.Decreasing; + default: return ColorSpaceMath.HueMethod.Shorter; + } + } + return ColorSpaceMath.HueMethod.Shorter; + } + } +} diff --git a/src/ExCSS/Model/Converters.cs b/src/ExCSS/Model/Converters.cs index 0cd8c44..be9df33 100644 --- a/src/ExCSS/Model/Converters.cs +++ b/src/ExCSS/Model/Converters.cs @@ -197,6 +197,39 @@ public static readonly IValueConverter return new FunctionValueConverter(FunctionNames.Hwb, WithArgs(hue, percent, percent, alpha)); }); + // CSS Color 4/5 function forms (lab/oklab/lch/oklch/color-mix). Layer A accepts them leniently + // (preserving the specified text for serialization and shorthand expansion); the exact grammar + // check and the sRGB resolution happen via ColorFunctionExtensions, so a + // `background: oklch(...)` shorthand still carries its color through to the background-color + // longhand. Reference `new AnyValueConverter()` directly rather than the shared `Any` field, + // which is initialized later in this file (Construct here runs eagerly, so `Any` would be null). + public static readonly IValueConverter LabColorConverter = + new FunctionValueConverter(FunctionNames.Lab, new AnyValueConverter()); + public static readonly IValueConverter OklabColorConverter = + new FunctionValueConverter(FunctionNames.Oklab, new AnyValueConverter()); + public static readonly IValueConverter LchColorConverter = + new FunctionValueConverter(FunctionNames.Lch, new AnyValueConverter()); + public static readonly IValueConverter OklchColorConverter = + new FunctionValueConverter(FunctionNames.Oklch, new AnyValueConverter()); + public static readonly IValueConverter ColorMixConverter = + new FunctionValueConverter(FunctionNames.ColorMix, new AnyValueConverter()); + + // Lenient fallbacks for the CSS Color 4 space/slash syntax of the legacy functions. The strict + // comma-form converters above run first (so their canonical serialization is preserved); these + // catch the space-separated / slash-alpha forms the strict grammars reject, so e.g. + // `background: hsl(280 70% 55%)` or `rgb(1 2 3 / .5)` still populate the color longhand and get + // resolved via ColorFunctionExtensions. + public static readonly IValueConverter RgbLenientConverter = + new FunctionValueConverter(FunctionNames.Rgb, new AnyValueConverter()); + public static readonly IValueConverter RgbaLenientConverter = + new FunctionValueConverter(FunctionNames.Rgba, new AnyValueConverter()); + public static readonly IValueConverter HslLenientConverter = + new FunctionValueConverter(FunctionNames.Hsl, new AnyValueConverter()); + public static readonly IValueConverter HslaLenientConverter = + new FunctionValueConverter(FunctionNames.Hsla, new AnyValueConverter()); + public static readonly IValueConverter HwbLenientConverter = + new FunctionValueConverter(FunctionNames.Hwb, new AnyValueConverter()); + public static readonly IValueConverter PerspectiveConverter = Construct(() => new FunctionValueConverter(FunctionNames.Perspective, WithArgs(LengthConverter))); @@ -421,7 +454,13 @@ public static readonly IValueConverter public static readonly IValueConverter ColorConverter = PureColorConverter .Or(RgbColorConverter.Or(RgbaColorConverter)) .Or(HslColorConverter.Or(HslaColorConverter)) - .Or(GrayColorConverter.Or(HwbColorConverter)); + .Or(GrayColorConverter.Or(HwbColorConverter)) + .Or(LabColorConverter.Or(OklabColorConverter)) + .Or(LchColorConverter.Or(OklchColorConverter)) + .Or(ColorMixConverter) + .Or(RgbLenientConverter.Or(RgbaLenientConverter)) + .Or(HslLenientConverter.Or(HslaLenientConverter)) + .Or(HwbLenientConverter); public static readonly IValueConverter CurrentColorConverter = ColorConverter.WithCurrentColor(); public static readonly IValueConverter InvertedColorConverter = CurrentColorConverter.Or(Keywords.Invert); diff --git a/src/ExCSS/Values/Color.cs b/src/ExCSS/Values/Color.cs index 4fee1af..b28b173 100644 --- a/src/ExCSS/Values/Color.cs +++ b/src/ExCSS/Values/Color.cs @@ -268,6 +268,72 @@ public static Color FromHwba(float hue, float whiteness, float blackness, float return FromRgba(red, green, blue, alpha); } + // ── CSS Color 4/5 spaces (lab/oklab/lch/oklch) and color-mix() ──────── + // Components are in each space's native units (L: lab/lch 0..100, oklab/oklch 0..1; a/b and C + // in native chroma units; H in degrees). The conversion math is shared with any future gradient + // interpolation via ColorSpaceMath so there is a single implementation of the color science. + + public static Color FromLab(double l, double a, double b, float alpha = 1f) + => FromSpaceComponents(new[] { l, a, b }, ColorSpaceMath.Space.Lab, alpha); + + public static Color FromOklab(double l, double a, double b, float alpha = 1f) + => FromSpaceComponents(new[] { l, a, b }, ColorSpaceMath.Space.Oklab, alpha); + + public static Color FromLch(double l, double c, double h, float alpha = 1f) + => FromSpaceComponents(new[] { l, c, h }, ColorSpaceMath.Space.Lch, alpha); + + public static Color FromOklch(double l, double c, double h, float alpha = 1f) + => FromSpaceComponents(new[] { l, c, h }, ColorSpaceMath.Space.Oklch, alpha); + + private static Color FromSpaceComponents(double[] v, ColorSpaceMath.Space cs, float alpha) + { + var (r, g, b) = ColorSpaceMath.FromSpace(v, cs); + return new Color(ToByte(r), ToByte(g), ToByte(b), Normalize(alpha)); + } + + /// + /// CSS Color 5 color-mix(): + /// premultiplied-alpha interpolation in with weight + /// toward , the result alpha then scaled by + /// (below 1 only when the two percentages summed to under 100%). + /// + internal static Color Mix(Color c1, Color c2, double t, ColorSpaceMath.Space cs, ColorSpaceMath.HueMethod hue, double alphaMultiplier) + { + t = ColorSpaceMath.Clamp01(t); + double a1 = c1.A / 255.0, a2 = c2.A / 255.0; + + var v1 = ColorSpaceMath.ToSpace(c1.R / 255.0, c1.G / 255.0, c1.B / 255.0, cs); + var v2 = ColorSpaceMath.ToSpace(c2.R / 255.0, c2.G / 255.0, c2.B / 255.0, cs); + var hueIndex = ColorSpaceMath.HueIndex(cs); + + for (var i = 0; i < 3; i++) + { + if (i == hueIndex) continue; + v1[i] *= a1; + v2[i] *= a2; + } + + var mixedAlpha = a1 + t * (a2 - a1); + var v = new double[3]; + for (var i = 0; i < 3; i++) + { + if (i == hueIndex) + { + v[i] = ColorSpaceMath.InterpolateHue(v1[i], v2[i], t, hue); + continue; + } + + var mixed = v1[i] + t * (v2[i] - v1[i]); + v[i] = mixedAlpha > 1e-10 ? mixed / mixedAlpha : 0; + } + + var (r, g, b) = ColorSpaceMath.FromSpace(v, cs); + var outAlpha = (byte)Math.Round(ColorSpaceMath.Clamp01(mixedAlpha * alphaMultiplier) * 255); + return new Color(ToByte(r), ToByte(g), ToByte(b), outAlpha); + } + + private static byte ToByte(double v) => (byte)Math.Round(ColorSpaceMath.Clamp01(v) * 255); + public int Value => _hashcode; public byte A => _alpha; public double Alpha => Math.Round(_alpha / 255.0, 2); diff --git a/src/ExCSS/Values/ColorSpaceMath.cs b/src/ExCSS/Values/ColorSpaceMath.cs new file mode 100644 index 0000000..f61189a --- /dev/null +++ b/src/ExCSS/Values/ColorSpaceMath.cs @@ -0,0 +1,408 @@ +using System; + +namespace ExCSS +{ + /// + /// The single home for CSS color-space conversion math (CSS Color 4/5): forward/reverse transforms + /// between gamma-encoded sRGB and every interpolation space, plus polar-hue interpolation. Kept in + /// the object model so both solid-color resolution ('s + /// oklch()/lab()/color-mix() factories) and any future gradient interpolation + /// share one implementation. + /// + internal static class ColorSpaceMath + { + internal enum Space { Srgb, SrgbLinear, DisplayP3, Lab, Oklab, XyzD65, XyzD50, Hsl, Hwb, Lch, Oklch } + + internal enum HueMethod { Shorter, Longer, Increasing, Decreasing } + + public static bool IsPolar(Space cs) => + cs == Space.Hsl || cs == Space.Hwb || cs == Space.Lch || cs == Space.Oklch; + + // The index of the hue coordinate in a space's ToSpace/FromSpace layout, or -1 if rectangular. + // HSL/HWB carry hue first ([h, s/w, l/bk]); LCH/OKLch carry it last ([L, C, H]). + public static int HueIndex(Space cs) + { + switch (cs) + { + case Space.Hsl: + case Space.Hwb: + return 0; + case Space.Lch: + case Space.Oklch: + return 2; + default: + return -1; + } + } + + // Portable helpers: netstandard2.0 / net48 lack Math.Clamp and Math.Cbrt. + internal static double Clamp01(double c) => c < 0 ? 0 : (c > 1 ? 1 : c); + + internal static double Cbrt(double x) => + x < 0 ? -Math.Pow(-x, 1.0 / 3.0) : Math.Pow(x, 1.0 / 3.0); + + public static double NormalizeHue(double h) => ((h % 360) + 360) % 360; + + public static double InterpolateHue(double h1, double h2, double t, HueMethod hue) + { + h1 = NormalizeHue(h1); + h2 = NormalizeHue(h2); + var diff = h2 - h1; + switch (hue) + { + case HueMethod.Longer: + if (diff > 0 && diff < 180) diff -= 360; + else if (diff < 0 && diff > -180) diff += 360; + break; + case HueMethod.Increasing: + if (diff < 0) diff += 360; + break; + case HueMethod.Decreasing: + if (diff > 0) diff -= 360; + break; + default: // Shorter + if (diff > 180) diff -= 360; + else if (diff < -180) diff += 360; + break; + } + return h1 + t * diff; + } + + // ── sRGB gamma ──────────────────────────────────────────────────────── + + private static double Linearize(double c) + { + c = Clamp01(c); + return c <= 0.04045 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4); + } + + private static double Delinearize(double c) + { + c = Clamp01(c); + return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.Pow(c, 1.0 / 2.4) - 0.055; + } + + // ── Linear sRGB ↔ XYZ-D65 (IEC 61966-2-1) ─────────────────────────── + + private static (double X, double Y, double Z) LinSrgbToXyzD65(double r, double g, double b) => + (0.4124564 * r + 0.3575761 * g + 0.1804375 * b, + 0.2126729 * r + 0.7151522 * g + 0.0721750 * b, + 0.0193339 * r + 0.1191920 * g + 0.9503041 * b); + + private static (double R, double G, double B) XyzD65ToLinSrgb(double x, double y, double z) => + ( 3.2404542 * x - 1.5371385 * y - 0.4985314 * z, + -0.9692660 * x + 1.8760108 * y + 0.0415560 * z, + 0.0556434 * x - 0.2040259 * y + 1.0572252 * z); + + // ── XYZ-D65 ↔ XYZ-D50 (Bradford) ──────────────────────────────────── + + private static (double X, double Y, double Z) XyzD65ToXyzD50(double x, double y, double z) => + ( 1.0478112 * x + 0.0228866 * y - 0.0501270 * z, + 0.0295424 * x + 0.9904844 * y - 0.0170491 * z, + -0.0092345 * x + 0.0150436 * y + 0.7521316 * z); + + private static (double X, double Y, double Z) XyzD50ToXyzD65(double x, double y, double z) => + ( 0.9554734 * x - 0.0230393 * y + 0.0631636 * z, + -0.0282895 * x + 1.0099416 * y + 0.0210077 * z, + 0.0122982 * x - 0.0204830 * y + 1.3299098 * z); + + // ── Display-P3 ↔ XYZ-D65 (P3 uses sRGB gamma) ─────────────────────── + + private static (double X, double Y, double Z) LinP3ToXyzD65(double r, double g, double b) => + (0.4865709 * r + 0.2656677 * g + 0.1982173 * b, + 0.2289746 * r + 0.6917386 * g + 0.0792869 * b, + 0.0000000 * r + 0.0451134 * g + 1.0439444 * b); + + private static (double R, double G, double B) XyzD65ToLinP3(double x, double y, double z) => + ( 2.4934969 * x - 0.9313836 * y - 0.4027108 * z, + -0.8294890 * x + 1.7626641 * y + 0.0236247 * z, + 0.0358458 * x - 0.0761724 * y + 0.9568845 * z); + + // ── CIE L*a*b* ──────────────────────────────────────────────────────── + + private const double Xn50 = 0.96422, Yn50 = 1.0, Zn50 = 0.82521; + + private static double LabF(double t) + { + const double d = 6.0 / 29.0; + return t > d * d * d ? Cbrt(t) : t / (3 * d * d) + 4.0 / 29.0; + } + + private static double LabFInv(double t) + { + const double d = 6.0 / 29.0; + return t > d ? t * t * t : 3 * d * d * (t - 4.0 / 29.0); + } + + private static (double L, double a, double b) XyzD50ToLab(double x, double y, double z) + { + double fx = LabF(x / Xn50), fy = LabF(y / Yn50), fz = LabF(z / Zn50); + return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)); + } + + private static (double X, double Y, double Z) LabToXyzD50(double L, double a, double b) + { + var fy = (L + 16) / 116; + return (Xn50 * LabFInv(a / 500 + fy), Yn50 * LabFInv(fy), Zn50 * LabFInv(fy - b / 200)); + } + + // ── OKLab (Björn Ottosson) ──────────────────────────────────────────── + + private static (double L, double a, double b) XyzD65ToOkLab(double x, double y, double z) + { + var l = Cbrt(0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z); + var m = Cbrt(0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z); + var s = Cbrt(0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z); + return (0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s); + } + + private static (double X, double Y, double Z) OkLabToXyzD65(double L, double a, double b) + { + double l = L + 0.3963377774 * a + 0.2158037573 * b; l = l * l * l; + double m = L - 0.1055613458 * a - 0.0638541728 * b; m = m * m * m; + double s = L - 0.0894841775 * a - 1.2914855480 * b; s = s * s * s; + return ( 1.2270138511 * l - 0.5577999807 * m + 0.2812561490 * s, + -0.0405801784 * l + 1.1122568696 * m - 0.0716766787 * s, + -0.0763812845 * l - 0.4214819784 * m + 1.5861632204 * s); + } + + // ── HSL ─────────────────────────────────────────────────────────────── + + private static (double H, double S, double L) SrgbToHsl(double r, double g, double b) + { + double max = Math.Max(r, Math.Max(g, b)); + double min = Math.Min(r, Math.Min(g, b)); + double d = max - min; + double l = (max + min) / 2; + if (d < 1e-10) return (0, 0, l * 100); + double s = d / (1 - Math.Abs(2 * l - 1)); + double h; + if (max == r) h = 60 * (((g - b) / d) % 6); + else if (max == g) h = 60 * ((b - r) / d + 2); + else h = 60 * ((r - g) / d + 4); + if (h < 0) h += 360; + return (h, s * 100, l * 100); + } + + private static (double R, double G, double B) HslToSrgb(double h, double s, double l) + { + s /= 100; l /= 100; + double c = (1 - Math.Abs(2 * l - 1)) * s; + double x = c * (1 - Math.Abs(h / 60 % 2 - 1)); + double m = l - c / 2; + double r = 0, g = 0, b = 0; + switch ((int)(h / 60) % 6) + { + case 0: r = c; g = x; break; + case 1: r = x; g = c; break; + case 2: g = c; b = x; break; + case 3: g = x; b = c; break; + case 4: r = x; b = c; break; + case 5: r = c; b = x; break; + } + return (r + m, g + m, b + m); + } + + // ── HWB ─────────────────────────────────────────────────────────────── + + private static (double H, double W, double Bk) SrgbToHwb(double r, double g, double b) + { + var (h, _, _) = SrgbToHsl(r, g, b); + return (h, + Math.Min(r, Math.Min(g, b)) * 100, + (1 - Math.Max(r, Math.Max(g, b))) * 100); + } + + private static (double R, double G, double B) HwbToSrgb(double h, double w, double bk) + { + w /= 100; bk /= 100; + if (w + bk >= 1) { double grey = w / (w + bk); return (grey, grey, grey); } + var (r, g, b) = HslToSrgb(h, 100, 50); + double f = 1 - w - bk; + return (r * f + w, g * f + w, b * f + w); + } + + // ── LCH / OKLch (rectangular ↔ polar) ──────────────────────────────── + + private static (double L, double C, double H) RectToPolar(double L, double a, double b) + { + double c = Math.Sqrt(a * a + b * b); + double h = Math.Atan2(b, a) * (180.0 / Math.PI); + if (h < 0) h += 360; + return (L, c, h); + } + + private static (double L, double a, double b) PolarToRect(double L, double c, double h) + { + double rad = h * (Math.PI / 180.0); + return (L, c * Math.Cos(rad), c * Math.Sin(rad)); + } + + // ── Forward: gamma-sRGB (0..1) → color-space coordinates ───────────── + + public static double[] ToSpace(double r, double g, double b, Space cs) + { + switch (cs) + { + case Space.SrgbLinear: + return new[] { Linearize(r), Linearize(g), Linearize(b) }; + + case Space.DisplayP3: + { + var (x, y, z) = LinSrgbToXyzD65(Linearize(r), Linearize(g), Linearize(b)); + var (pr, pg, pb) = XyzD65ToLinP3(x, y, z); + return new[] { Delinearize(pr), Delinearize(pg), Delinearize(pb) }; + } + + case Space.Lab: + { + var (x, y, z) = LinSrgbToXyzD65(Linearize(r), Linearize(g), Linearize(b)); + var (x50, y50, z50) = XyzD65ToXyzD50(x, y, z); + var (L, a, bb) = XyzD50ToLab(x50, y50, z50); + return new[] { L, a, bb }; + } + + case Space.Oklab: + { + var (x, y, z) = LinSrgbToXyzD65(Linearize(r), Linearize(g), Linearize(b)); + var (L, a, bb) = XyzD65ToOkLab(x, y, z); + return new[] { L, a, bb }; + } + + case Space.XyzD65: + { + var (x, y, z) = LinSrgbToXyzD65(Linearize(r), Linearize(g), Linearize(b)); + return new[] { x, y, z }; + } + + case Space.XyzD50: + { + var (x, y, z) = LinSrgbToXyzD65(Linearize(r), Linearize(g), Linearize(b)); + var (x50, y50, z50) = XyzD65ToXyzD50(x, y, z); + return new[] { x50, y50, z50 }; + } + + case Space.Hsl: + { + var (h, s, l) = SrgbToHsl(r, g, b); + return new[] { h, s, l }; + } + + case Space.Hwb: + { + var (h, w, bk) = SrgbToHwb(r, g, b); + return new[] { h, w, bk }; + } + + case Space.Lch: + { + var (x, y, z) = LinSrgbToXyzD65(Linearize(r), Linearize(g), Linearize(b)); + var (x50, y50, z50) = XyzD65ToXyzD50(x, y, z); + var (L, a, bb) = XyzD50ToLab(x50, y50, z50); + var (lL, lC, lH) = RectToPolar(L, a, bb); + return new[] { lL, lC, lH }; + } + + case Space.Oklch: + { + var (x, y, z) = LinSrgbToXyzD65(Linearize(r), Linearize(g), Linearize(b)); + var (L, a, bb) = XyzD65ToOkLab(x, y, z); + var (oL, oC, oH) = RectToPolar(L, a, bb); + return new[] { oL, oC, oH }; + } + + default: // Srgb + return new[] { r, g, b }; + } + } + + // ── Reverse: color-space coordinates → gamut-mapped sRGB (0..1) ────── + + public static (double R, double G, double B) FromSpace(double[] v, Space cs) + { + double r, g, b; + + switch (cs) + { + case Space.SrgbLinear: + r = Delinearize(v[0]); g = Delinearize(v[1]); b = Delinearize(v[2]); + break; + + case Space.DisplayP3: + { + var (x, y, z) = LinP3ToXyzD65(Linearize(v[0]), Linearize(v[1]), Linearize(v[2])); + var (rl, gl, bl) = XyzD65ToLinSrgb(x, y, z); + r = Delinearize(rl); g = Delinearize(gl); b = Delinearize(bl); + break; + } + + case Space.Lab: + { + var (x50, y50, z50) = LabToXyzD50(v[0], v[1], v[2]); + var (x, y, z) = XyzD50ToXyzD65(x50, y50, z50); + var (rl, gl, bl) = XyzD65ToLinSrgb(x, y, z); + r = Delinearize(rl); g = Delinearize(gl); b = Delinearize(bl); + break; + } + + case Space.Oklab: + { + var (x, y, z) = OkLabToXyzD65(v[0], v[1], v[2]); + var (rl, gl, bl) = XyzD65ToLinSrgb(x, y, z); + r = Delinearize(rl); g = Delinearize(gl); b = Delinearize(bl); + break; + } + + case Space.XyzD65: + { + var (rl, gl, bl) = XyzD65ToLinSrgb(v[0], v[1], v[2]); + r = Delinearize(rl); g = Delinearize(gl); b = Delinearize(bl); + break; + } + + case Space.XyzD50: + { + var (x, y, z) = XyzD50ToXyzD65(v[0], v[1], v[2]); + var (rl, gl, bl) = XyzD65ToLinSrgb(x, y, z); + r = Delinearize(rl); g = Delinearize(gl); b = Delinearize(bl); + break; + } + + case Space.Hsl: + (r, g, b) = HslToSrgb(v[0], v[1], v[2]); + break; + + case Space.Hwb: + (r, g, b) = HwbToSrgb(v[0], v[1], v[2]); + break; + + case Space.Lch: + { + var (L, a, bb) = PolarToRect(v[0], v[1], v[2]); + var (x50, y50, z50) = LabToXyzD50(L, a, bb); + var (x, y, z) = XyzD50ToXyzD65(x50, y50, z50); + var (rl, gl, bl) = XyzD65ToLinSrgb(x, y, z); + r = Delinearize(rl); g = Delinearize(gl); b = Delinearize(bl); + break; + } + + case Space.Oklch: + { + var (L, a, bb) = PolarToRect(v[0], v[1], v[2]); + var (x, y, z) = OkLabToXyzD65(L, a, bb); + var (rl, gl, bl) = XyzD65ToLinSrgb(x, y, z); + r = Delinearize(rl); g = Delinearize(gl); b = Delinearize(bl); + break; + } + + default: // Srgb + r = v[0]; g = v[1]; b = v[2]; + break; + } + + return (Clamp01(r), Clamp01(g), Clamp01(b)); + } + } +}