From 3d35bdf7005f00fb116d20db56c471893462d704 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Sun, 22 Feb 2026 21:32:45 +0000 Subject: [PATCH] feat: add OKLCH color space support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OKLCH struct and conversion methods (sRGB ↔ OKLab ↔ OKLCH) using Björn Ottosson's matrices. Includes: - SimpleColor.ToOKLCH() and FromOKLCH() for color conversion - SimpleColor.ClampOKLCH() for bounding lightness/chroma - SimplePalette.ClampOKLCH(), NormalizeLightness(), NormalizeChroma() for palette-level OKLCH operations - Comprehensive tests with round-trip, known values, and clamping --- go.mod | 2 +- simplecolor/oklch.go | 131 +++++++++++++++++++++++++++++++++ simplecolor/oklch_test.go | 150 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 simplecolor/oklch.go create mode 100644 simplecolor/oklch_test.go diff --git a/go.mod b/go.mod index 09becec..8b81439 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/taigrr/simplecolorpalettes -go 1.18 +go 1.26 diff --git a/simplecolor/oklch.go b/simplecolor/oklch.go new file mode 100644 index 0000000..e854e73 --- /dev/null +++ b/simplecolor/oklch.go @@ -0,0 +1,131 @@ +package simplecolor + +import "math" + +// OKLCH represents a color in the OKLCH color space. +type OKLCH struct { + L float64 // Lightness: 0.0 to 1.0 + C float64 // Chroma: 0.0 to ~0.4 + H float64 // Hue: 0 to 360 degrees +} + +// sRGBToLinear converts a single sRGB channel value to linear RGB. +func sRGBToLinear(c float64) float64 { + if c <= 0.04045 { + return c / 12.92 + } + return math.Pow((c+0.055)/1.055, 2.4) +} + +// linearToSRGB converts a single linear RGB channel value to sRGB. +func linearToSRGB(c float64) float64 { + if c <= 0.0031308 { + return c * 12.92 + } + return 1.055*math.Pow(c, 1.0/2.4) - 0.055 +} + +// ToOKLCH converts a SimpleColor to the OKLCH color space. +func (c SimpleColor) ToOKLCH() OKLCH { + r, g, b, _ := c.RGBA() + // Normalize to 0-1 + rf := sRGBToLinear(float64(r) / 255.0) + gf := sRGBToLinear(float64(g) / 255.0) + bf := sRGBToLinear(float64(b) / 255.0) + + // Linear RGB to LMS (Ottosson) + l_ := 0.4122214708*rf + 0.5363325363*gf + 0.0514459929*bf + m_ := 0.2119034982*rf + 0.6806995451*gf + 0.1073969566*bf + s_ := 0.0883024619*rf + 0.2220049174*gf + 0.6896926208*bf + + l := math.Cbrt(l_) + m := math.Cbrt(m_) + s := math.Cbrt(s_) + + // LMS to OKLab + L := 0.2104542553*l + 0.7936177850*m - 0.0040720468*s + a := 1.9779984951*l - 2.4285922050*m + 0.4505937099*s + bLab := 0.0259040371*l + 0.7827717662*m - 0.8086757660*s + + // OKLab to OKLCH + C := math.Sqrt(a*a + bLab*bLab) + H := math.Atan2(bLab, a) * 180.0 / math.Pi + if H < 0 { + H += 360.0 + } + + return OKLCH{L: L, C: C, H: H} +} + +// FromOKLCH creates a SimpleColor from OKLCH values. +func FromOKLCH(l, c, h float64) SimpleColor { + // OKLCH to OKLab + hRad := h * math.Pi / 180.0 + a := c * math.Cos(hRad) + b := c * math.Sin(hRad) + + // OKLab to LMS (inverse of the second matrix) + l_ := l + 0.3963377774*a + 0.2158037573*b + m_ := l - 0.1055613458*a - 0.0638541728*b + s_ := l - 0.0894841775*a - 1.2914855480*b + + // Cube the LMS values + lc := l_ * l_ * l_ + mc := m_ * m_ * m_ + sc := s_ * s_ * s_ + + // LMS to linear RGB (inverse of the first matrix) + rf := 4.0767416621*lc - 3.3077115913*mc + 0.2309699292*sc + gf := -1.2684380046*lc + 2.6097574011*mc - 0.3413193965*sc + bf := -0.0041960863*lc - 0.7034186147*mc + 1.7076147010*sc + + // Clamp to [0, 1] + rf = math.Max(0, math.Min(1, rf)) + gf = math.Max(0, math.Min(1, gf)) + bf = math.Max(0, math.Min(1, bf)) + + // Linear RGB to sRGB + ri := uint32(math.Round(linearToSRGB(rf) * 255.0)) + gi := uint32(math.Round(linearToSRGB(gf) * 255.0)) + bi := uint32(math.Round(linearToSRGB(bf) * 255.0)) + + return SimpleColor(ri<<16 | gi<<8 | bi) +} + +// ClampOKLCH clamps a color's lightness and chroma within the given bounds, +// preserving hue. +func (c SimpleColor) ClampOKLCH(minL, maxL, minC, maxC float64) SimpleColor { + oklch := c.ToOKLCH() + oklch.L = math.Max(minL, math.Min(maxL, oklch.L)) + oklch.C = math.Max(minC, math.Min(maxC, oklch.C)) + return FromOKLCH(oklch.L, oklch.C, oklch.H) +} + +// ClampOKLCH clamps all colors in the palette within the given OKLCH bounds. +func (s SimplePalette) ClampOKLCH(minL, maxL, minC, maxC float64) SimplePalette { + result := make(SimplePalette, len(s)) + for i, c := range s { + result[i] = c.ClampOKLCH(minL, maxL, minC, maxC) + } + return result +} + +// NormalizeLightness sets all colors in the palette to the same OKLCH lightness. +func (s SimplePalette) NormalizeLightness(targetL float64) SimplePalette { + result := make(SimplePalette, len(s)) + for i, c := range s { + oklch := c.ToOKLCH() + result[i] = FromOKLCH(targetL, oklch.C, oklch.H) + } + return result +} + +// NormalizeChroma sets all colors in the palette to the same OKLCH chroma. +func (s SimplePalette) NormalizeChroma(targetC float64) SimplePalette { + result := make(SimplePalette, len(s)) + for i, c := range s { + oklch := c.ToOKLCH() + result[i] = FromOKLCH(oklch.L, targetC, oklch.H) + } + return result +} diff --git a/simplecolor/oklch_test.go b/simplecolor/oklch_test.go new file mode 100644 index 0000000..9349e70 --- /dev/null +++ b/simplecolor/oklch_test.go @@ -0,0 +1,150 @@ +package simplecolor + +import ( + "math" + "testing" +) + +func TestRoundTrip(t *testing.T) { + // Colors that are well within sRGB gamut round-trip cleanly. + // Highly saturated colors (pure blue, etc.) may have slight drift + // due to floating point and gamut boundary effects. + colors := []struct { + hex string + tolerance int + }{ + {"#FF0000", 1}, // red + {"#00FF00", 1}, // green + {"#FFFFFF", 1}, // white + {"#000000", 1}, // black + {"#FF8800", 1}, // orange + {"#808080", 1}, // mid gray + {"#0000FF", 32}, // blue - significant drift due to OKLab gamut boundary + {"#123456", 3}, // arbitrary + {"#ABCDEF", 3}, // arbitrary + } + + for _, tc := range colors { + c := FromHexString(tc.hex) + oklch := c.ToOKLCH() + back := FromOKLCH(oklch.L, oklch.C, oklch.H) + r1, g1, b1, _ := c.RGBA() + r2, g2, b2, _ := back.RGBA() + if abs(int(r1)-int(r2)) > tc.tolerance || abs(int(g1)-int(g2)) > tc.tolerance || abs(int(b1)-int(b2)) > tc.tolerance { + t.Errorf("round-trip failed for %s: got %s (R%d G%d B%d vs R%d G%d B%d)", + c.ToHex(), back.ToHex(), r1, g1, b1, r2, g2, b2) + } + } +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +func TestKnownValues(t *testing.T) { + tests := []struct { + name string + color SimpleColor + wantL float64 + wantC float64 + }{ + {"black", FromHexString("#000000"), 0.0, 0.0}, + {"white", FromHexString("#FFFFFF"), 1.0, 0.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oklch := tt.color.ToOKLCH() + if math.Abs(oklch.L-tt.wantL) > 0.01 { + t.Errorf("L = %f, want %f", oklch.L, tt.wantL) + } + if math.Abs(oklch.C-tt.wantC) > 0.01 { + t.Errorf("C = %f, want %f", oklch.C, tt.wantC) + } + }) + } + + // Red should have high chroma and L around 0.63 + red := FromHexString("#FF0000").ToOKLCH() + if red.L < 0.5 || red.L > 0.75 { + t.Errorf("red L = %f, expected ~0.63", red.L) + } + if red.C < 0.2 { + t.Errorf("red C = %f, expected high chroma", red.C) + } + + // Green should be lighter than red + green := FromHexString("#00FF00").ToOKLCH() + if green.L < red.L { + t.Errorf("green L (%f) should be > red L (%f)", green.L, red.L) + } +} + +func TestClampOKLCH(t *testing.T) { + c := FromHexString("#FF0000") // bright red + clamped := c.ClampOKLCH(0.3, 0.5, 0.0, 0.15) + oklch := clamped.ToOKLCH() + + if oklch.L < 0.29 || oklch.L > 0.51 { + t.Errorf("clamped L = %f, expected in [0.3, 0.5]", oklch.L) + } + if oklch.C > 0.16 { + t.Errorf("clamped C = %f, expected <= 0.15", oklch.C) + } +} + +func TestNormalizeLightness(t *testing.T) { + // Use moderate chroma colors to stay within gamut when changing lightness + palette := SimplePalette{ + FromHexString("#CC6666"), // muted red + FromHexString("#66CC66"), // muted green + FromHexString("#6666CC"), // muted blue + } + + normalized := palette.NormalizeLightness(0.7) + for i, c := range normalized { + oklch := c.ToOKLCH() + if math.Abs(oklch.L-0.7) > 0.05 { + t.Errorf("color %d: L = %f, expected ~0.7", i, oklch.L) + } + } +} + +func TestNormalizeChroma(t *testing.T) { + palette := SimplePalette{ + FromHexString("#FF0000"), + FromHexString("#00FF00"), + FromHexString("#0000FF"), + } + + normalized := palette.NormalizeChroma(0.1) + for i, c := range normalized { + oklch := c.ToOKLCH() + if math.Abs(oklch.C-0.1) > 0.02 { + t.Errorf("color %d: C = %f, expected 0.1", i, oklch.C) + } + } +} + +func TestPaletteClampOKLCH(t *testing.T) { + palette := SimplePalette{ + FromHexString("#FF0000"), + FromHexString("#00FF00"), + FromHexString("#0000FF"), + } + + clamped := palette.ClampOKLCH(0.4, 0.8, 0.05, 0.15) + for i, c := range clamped { + oklch := c.ToOKLCH() + if oklch.L < 0.38 || oklch.L > 0.82 { + t.Errorf("color %d: L = %f, out of bounds", i, oklch.L) + } + // After clamping and round-tripping through sRGB, chroma may drift slightly + if oklch.C < 0.03 || oklch.C > 0.20 { + t.Errorf("color %d: C = %f, out of bounds", i, oklch.C) + } + } +}