diff --git a/Color_test.go b/Color_test.go index 8f259a8..87ad195 100644 --- a/Color_test.go +++ b/Color_test.go @@ -1,7 +1,6 @@ package color_test import ( - "fmt" "io" "testing" @@ -9,64 +8,71 @@ import ( "git.akyoto.dev/go/color" ) -func TestNoColors(t *testing.T) { +func TestFprint(t *testing.T) { + color.Terminal = true + + color.RGB(1, 0, 0).Fprint(io.Discard, "red") + color.RGB(0, 1, 0).Fprint(io.Discard, "green") + color.RGB(0, 0, 1).Fprint(io.Discard, "blue") + color.Terminal = false - testRGBColors(t) - testLCHColors(t) + + color.RGB(1, 0, 0).Fprint(io.Discard, "red") + color.RGB(0, 1, 0).Fprint(io.Discard, "green") + color.RGB(0, 0, 1).Fprint(io.Discard, "blue") } -func TestColors(t *testing.T) { - color.Terminal = true - testRGBColors(t) - testLCHColors(t) -} - -func TestRainbow(t *testing.T) { +func TestPrint(t *testing.T) { color.Terminal = true - for chroma := range 5 { - for lightness := range 21 { - for hue := range 80 { - c := color.LCH(color.Value(lightness)*0.05, color.Value(chroma)*0.05, color.Value(hue)*4.5) - c.Print("█") - } + color.RGB(1, 0, 0).Print("red\n") + color.RGB(0, 1, 0).Print("green\n") + color.RGB(0, 0, 1).Print("blue\n") - fmt.Println() - } + color.Terminal = false - fmt.Println() - } + color.RGB(1, 0, 0).Print("red\n") + color.RGB(0, 1, 0).Print("green\n") + color.RGB(0, 0, 1).Print("blue\n") } -func testRGBColors(t *testing.T) { +func TestPrintln(t *testing.T) { + color.Terminal = true + + color.RGB(1, 0, 0).Println("red") + color.RGB(0, 1, 0).Println("green") + color.RGB(0, 0, 1).Println("blue") + + color.Terminal = false + + color.RGB(1, 0, 0).Println("red") + color.RGB(0, 1, 0).Println("green") + color.RGB(0, 0, 1).Println("blue") +} + +func TestRGB(t *testing.T) { + color.Terminal = true + rgbColors := map[string]color.Color{ - "RGB.black": color.RGB(0.0, 0.0, 0.0), - "RGB.white": color.RGB(1.0, 1.0, 1.0), - "RGB.red": color.RGB(1.0, 0.0, 0.0), - "RGB.green": color.RGB(0.0, 1.0, 0.0), - "RGB.blue": color.RGB(0.0, 0.0, 1.0), + "black": color.RGB(0, 0, 0), + "white": color.RGB(1, 1, 1), + "gray": color.RGB(0.5, 0.5, 0.5), + "red": color.RGB(1, 0, 0), + "green": color.RGB(0, 1, 0), + "blue": color.RGB(0, 0, 1), + "cyan": color.RGB(0, 1, 1), + "yellow": color.RGB(1, 1, 0), + "orange": color.RGB(1, 0.5, 0), + "magenta": color.RGB(1, 0, 1), } for name, c := range rgbColors { - testColor(t, c, name) + testColorRange(t, c) + c.Println("█ " + name) } } -func testLCHColors(t *testing.T) { - lchColors := map[string]color.Color{ - "LCH.black": color.LCH(0.0, 0.0, 0.0), - "LCH.white": color.LCH(1.0, 0.0, 0.0), - "LCH.red": color.LCH(0.6, 0.5, 20.0), - "LCH.green": color.LCH(0.6, 0.5, 161.0), - "LCH.blue": color.LCH(0.6, 0.5, 240.0), - } - - for name, c := range lchColors { - testColor(t, c, name) - } -} - -func testColor(t *testing.T, c color.Color, name string) { +func testColorRange(t *testing.T, c color.Color) { assert.True(t, c.R >= 0.0) assert.True(t, c.G >= 0.0) assert.True(t, c.B >= 0.0) @@ -74,8 +80,4 @@ func testColor(t *testing.T, c color.Color, name string) { assert.True(t, c.R <= 1.0) assert.True(t, c.G <= 1.0) assert.True(t, c.B <= 1.0) - - c.Fprint(io.Discard, name) - c.Print(name) - c.Println(name) } diff --git a/LCH.go b/LCH.go index f8565ef..fb2b662 100644 --- a/LCH.go +++ b/LCH.go @@ -4,24 +4,51 @@ import ( "math" ) -// LCH represents a color using lightness, chroma and hue (oklch). +// LCH represents a color using lightness, chroma and hue. func LCH(lightness Value, chroma Value, hue Value) Color { - hue *= math.Pi / 180.0 - a := chroma * math.Cos(hue) - b := chroma * math.Sin(hue) + lightness = min(max(lightness, 0), 1) + hue *= math.Pi / 180 + a := math.Cos(hue) + b := math.Sin(hue) - r, g, b := labToRGB(lightness, a, b) + if !inSRGB(lightness, a, b, chroma) { + chroma = findChromaInSRGB(chroma, lightness, a, b, 0.01) + } - r = gammaCorrection(r) - g = gammaCorrection(g) - b = gammaCorrection(b) + a *= chroma + b *= chroma + + r, g, b := oklabToLinearRGB(lightness, a, b) + + r = sRGB(r) + g = sRGB(g) + b = sRGB(b) return Color{r, g, b} } -// LAB to linear sRGB +// findChromaInSRGB tries to find the closest chroma that can be represented in sRGB color space. +func findChromaInSRGB(chroma Value, lightness Value, a Value, b Value, precision Value) Value { + high := chroma + low := 0.0 + chroma *= 0.5 + + for high-low > precision { + if inSRGB(lightness, a, b, chroma) { + low = chroma + } else { + high = chroma + } + + chroma = (high + low) * 0.5 + } + + return chroma +} + +// OKLAB to linear RGB. // Source: https://bottosson.github.io/posts/oklab/ -func labToRGB(lightness Value, a Value, b Value) (Value, Value, Value) { +func oklabToLinearRGB(lightness Value, a Value, b Value) (Value, Value, Value) { l := lightness + 0.3963377773761749*a + 0.21580375730991364*b m := lightness - 0.10556134581565857*a - 0.0638541728258133*b s := lightness - 0.08948418498039246*a - 1.2914855480194092*b @@ -36,21 +63,3 @@ func labToRGB(lightness Value, a Value, b Value) (Value, Value, Value) { return red, green, blue } - -// Gamma correction -// Source: IEC 61966-2-2 -func gammaCorrection(x Value) Value { - if x < 0 { - return 0 - } - - if x <= 0.0031308 { - return 12.92 * x - } - - if x < 1.0 { - return 1.055*math.Pow(x, 1/2.4) - 0.055 - } - - return 1.0 -} diff --git a/LCH_test.go b/LCH_test.go new file mode 100644 index 0000000..6dc33a2 --- /dev/null +++ b/LCH_test.go @@ -0,0 +1,49 @@ +package color_test + +import ( + "fmt" + "testing" + + "git.akyoto.dev/go/color" +) + +func TestLCH(t *testing.T) { + color.Terminal = true + + lchColors := map[string]color.Color{ + "black": color.LCH(0.0, 0.0, 0), + "white": color.LCH(1.0, 0.0, 0), + "gray": color.LCH(0.5, 0.0, 0), + "pink": color.LCH(0.75, 1.0, 0), + "red": color.LCH(0.75, 1.0, 40), + "orange": color.LCH(0.75, 1.0, 60), + "yellow": color.LCH(0.9, 1.0, 100), + "green": color.LCH(0.75, 1.0, 150), + "blue": color.LCH(0.75, 1.0, 260), + "cyan": color.LCH(0.75, 1.0, 210), + "magenta": color.LCH(0.75, 1.0, 320), + } + + for name, c := range lchColors { + testColorRange(t, c) + c.Println("█ " + name) + } +} + +func TestLCHSpectrum(t *testing.T) { + color.Terminal = true + + for chroma := range 4 { + for lightness := range 21 { + for hue := range 80 { + c := color.LCH(color.Value(lightness)*0.05, color.Value(chroma)*0.05, color.Value(hue)*4.4) + testColorRange(t, c) + c.Print("█") + } + + fmt.Println() + } + + fmt.Println() + } +} diff --git a/README.md b/README.md index f817888..5be6d96 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Adds color to your terminal output. ## Features - RGB color space -- OKLCH color space +- LCH color space (oklch) - Truecolor terminal output ## Installation @@ -19,14 +19,20 @@ go get git.akyoto.dev/go/color ```go red := color.RGB(1.0, 0.0, 0.0) red.Println("red text") + +orange := color.LCH(0.7, 1.0, 65) +orange.Println("orange text") ``` ## Tests ``` -PASS: TestNoColors -PASS: TestColors -PASS: TestRainbow +PASS: TestFprint +PASS: TestPrint +PASS: TestPrintln +PASS: TestRGB +PASS: TestLCH +PASS: TestLCHSpectrum coverage: 100.0% of statements ``` @@ -34,7 +40,7 @@ coverage: 100.0% of statements ``` BenchmarkRGB-12 1000000000 0.3132 ns/op 0 B/op 0 allocs/op -BenchmarkLCH-12 11214220 107.1 ns/op 0 B/op 0 allocs/op +BenchmarkLCH-12 4802006 249.8 ns/op 0 B/op 0 allocs/op BenchmarkFprintColorized-12 6356535 188.4 ns/op 0 B/op 0 allocs/op BenchmarkFprintRaw-12 27374659 43.76 ns/op 0 B/op 0 allocs/op ``` diff --git a/sRGB.go b/sRGB.go new file mode 100644 index 0000000..1d522c8 --- /dev/null +++ b/sRGB.go @@ -0,0 +1,27 @@ +package color + +import "math" + +// inSRGB indicates whether the given color can be mapped to the sRGB color space. +func inSRGB(l Value, a Value, b Value, chroma Value) bool { + r, g, b := oklabToLinearRGB(l, a*chroma, b*chroma) + return r >= 0 && g >= 0 && b >= 0 && r <= 1 && g <= 1 && b <= 1 +} + +// sRGB performs gamma correction to convert linear RGB to sRGB. +// Source: IEC 61966-2-2 +func sRGB(x Value) Value { + if x < 0 { + return 0 + } + + if x < 0.0031308 { + return 12.92 * x + } + + if x < 1.0 { + return 1.055*math.Pow(x, 1/2.4) - 0.055 + } + + return 1.0 +}