Merge pull request #2 from taigrr/cd/oklch-support

feat: add OKLCH color space support
This commit is contained in:
2026-02-22 21:08:54 -05:00
committed by GitHub
3 changed files with 282 additions and 1 deletions

2
go.mod
View File

@@ -1,3 +1,3 @@
module github.com/taigrr/simplecolorpalettes module github.com/taigrr/simplecolorpalettes
go 1.18 go 1.26

131
simplecolor/oklch.go Normal file
View File

@@ -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
}

150
simplecolor/oklch_test.go Normal file
View File

@@ -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)
}
}
}