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));
+ }
+ }
+}