diff --git a/ansi/color.go b/ansi/color.go index 77f8a08d..709b0ad7 100644 --- a/ansi/color.go +++ b/ansi/color.go @@ -194,3 +194,173 @@ func toRGBA(r, g, b uint32) (uint32, uint32, uint32, uint32) { b |= b << 8 return r, g, b, 0xffff } + +// DecodeColor decodes a color from a slice of parameters. It returns the +// number of parameters read and the color. This function is used to read SGR +// color parameters following the ITU T.416 standard. +// +// It supports reading the following color types: +// - 0: implementation defined +// - 1: transparent +// - 2: RGB direct color +// - 3: CMY direct color +// - 4: CMYK direct color +// - 5: indexed color +// - 6: RGBA direct color (WezTerm extension) +// +// The parameters can be separated by semicolons (;) or colons (:). Mixing +// separators is not allowed. +// +// The specs supports defining a color space id, a color tolerance value, and a +// tolerance color space id. However, these values have no effect on the +// returned color and will be ignored. +// +// This implementation includes a few modifications to the specs: +// 1. Support for legacy color values separated by semicolons (;) with respect to RGB, and indexed colors +// 2. Support ignoring and omitting the color space id (second parameter) with respect to RGB colors +// 3. Support ignoring and omitting the 6th parameter with respect to RGB and CMY colors +// 4. Support reading RGBA colors +func DecodeColor(params []Parameter) (n int, co Color) { + if len(params) < 2 { // Need at least SGR type and color type + return 0, nil + } + + // First parameter indicates one of 38, 48, or 58 (foreground, background, or underline) + s := params[0] + p := params[1] + colorType := p.Param(0) + n = 2 + + paramsfn := func() (p1, p2, p3, p4 int) { + // Where should we start reading the color? + switch { + case s.HasMore() && p.HasMore() && len(params) > 8 && params[2].HasMore() && params[3].HasMore() && params[4].HasMore() && params[5].HasMore() && params[6].HasMore() && params[7].HasMore(): + // We have color space id, a 6th parameter, a tolerance value, and a tolerance color space + n += 7 + return params[3].Param(0), params[4].Param(0), params[5].Param(0), params[6].Param(0) + case s.HasMore() && p.HasMore() && len(params) > 7 && params[2].HasMore() && params[3].HasMore() && params[4].HasMore() && params[5].HasMore() && params[6].HasMore(): + // We have color space id, a 6th parameter, and a tolerance value + n += 6 + return params[3].Param(0), params[4].Param(0), params[5].Param(0), params[6].Param(0) + case s.HasMore() && p.HasMore() && len(params) > 6 && params[2].HasMore() && params[3].HasMore() && params[4].HasMore() && params[5].HasMore(): + // We have color space id and a 6th parameter + // 48 : 4 : : 1 : 2 : 3 :4 + n += 5 + return params[3].Param(0), params[4].Param(0), params[5].Param(0), params[6].Param(0) + case s.HasMore() && p.HasMore() && len(params) > 5 && params[2].HasMore() && params[3].HasMore() && params[4].HasMore() && !params[5].HasMore(): + // We have color space + // 48 : 3 : : 1 : 2 : 3 + n += 4 + return params[3].Param(0), params[4].Param(0), params[5].Param(0), -1 + case s.HasMore() && p.HasMore() && p.Param(0) == 2 && params[2].HasMore() && params[3].HasMore() && !params[4].HasMore(): + // We have color values separated by colons (:) + // 48 : 2 : 1 : 2 : 3 + fallthrough + case !s.HasMore() && !p.HasMore() && p.Param(0) == 2 && !params[2].HasMore() && !params[3].HasMore() && !params[4].HasMore(): + // Support legacy color values separated by semicolons (;) + // 48 ; 2 ; 1 ; 2 ; 3 + n += 3 + return params[2].Param(0), params[3].Param(0), params[4].Param(0), -1 + } + // Ambiguous SGR color + return -1, -1, -1, -1 + } + + switch colorType { + case 0: // implementation defined + return 2, nil + case 1: // transparent + return 2, color.Transparent + case 2: // RGB direct color + if len(params) < 5 { + return 0, nil + } + + r, g, b, _ := paramsfn() + if r == -1 || g == -1 || b == -1 { + return 0, nil + } + + co = color.RGBA{ + R: uint8(r), //nolint:gosec + G: uint8(g), //nolint:gosec + B: uint8(b), //nolint:gosec + A: 0xff, + } + return + + case 3: // CMY direct color + if len(params) < 5 { + return 0, nil + } + + c, m, y, _ := paramsfn() + if c == -1 || m == -1 || y == -1 { + return 0, nil + } + + co = color.CMYK{ + C: uint8(c), //nolint:gosec + M: uint8(m), //nolint:gosec + Y: uint8(y), //nolint:gosec + K: 0, + } + return + + case 4: // CMYK direct color + if len(params) < 6 { + return 0, nil + } + + c, m, y, k := paramsfn() + if c == -1 || m == -1 || y == -1 || k == -1 { + return 0, nil + } + + co = color.CMYK{ + C: uint8(c), //nolint:gosec + M: uint8(m), //nolint:gosec + Y: uint8(y), //nolint:gosec + K: uint8(k), //nolint:gosec + } + return + + case 5: // indexed color + if len(params) < 3 { + return 0, nil + } + switch { + case s.HasMore() && p.HasMore() && !params[2].HasMore(): + // Colon separated indexed color + // 38 : 5 : 234 + case !s.HasMore() && !p.HasMore() && !params[2].HasMore(): + // Legacy semicolon indexed color + // 38 ; 5 ; 234 + default: + return 0, nil + } + co = ExtendedColor(params[2].Param(0)) //nolint:gosec + return 3, co + + case 6: // RGBA direct color + if len(params) < 6 { + return 0, nil + } + + r, g, b, a := paramsfn() + if r == -1 || g == -1 || b == -1 || a == -1 { + return 0, nil + } + + co = color.RGBA{ + R: uint8(r), //nolint:gosec + G: uint8(g), //nolint:gosec + B: uint8(b), //nolint:gosec + A: uint8(a), //nolint:gosec + } + return + + default: + return 0, nil + } +} diff --git a/ansi/color_test.go b/ansi/color_test.go index 1ec42436..5fa128b3 100644 --- a/ansi/color_test.go +++ b/ansi/color_test.go @@ -3,6 +3,8 @@ package ansi import ( "image/color" "testing" + + "github.com/charmbracelet/x/ansi/parser" ) func TestRGBAToHex(t *testing.T) { @@ -82,3 +84,371 @@ func TestHexToRGB(t *testing.T) { } } } + +func TestDecodeColor(t *testing.T) { + tests := []struct { + name string + params []Parameter + wantN int + wantColor Color + wantNil bool + }{ + { + name: "invalid - too few parameters", + params: []Parameter{38}, + wantN: 0, + wantNil: true, + }, + { + name: "implementation defined", + params: []Parameter{38, 0}, + wantN: 2, + wantNil: true, + }, + { + name: "transparent", + params: []Parameter{38, 1}, + wantN: 2, + wantColor: color.Transparent, + }, + { + name: "RGB semicolon separated", + params: []Parameter{38, 2, 100, 150, 200}, + wantN: 5, + wantColor: color.RGBA{R: 100, G: 150, B: 200, A: 255}, + }, + { + name: "RGB colon separated", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 2 | parser.HasMoreFlag, + 100 | parser.HasMoreFlag, + 150 | parser.HasMoreFlag, + 200, + }, + wantN: 5, + wantColor: color.RGBA{R: 100, G: 150, B: 200, A: 255}, + }, + { + name: "RGB with color space", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 2 | parser.HasMoreFlag, + 1 | parser.HasMoreFlag, // color space id + 100 | parser.HasMoreFlag, + 150 | parser.HasMoreFlag, + 200, + }, + wantN: 6, + wantColor: color.RGBA{R: 100, G: 150, B: 200, A: 255}, + }, + // { + // name: "CMY semicolon separated", + // params: []Parameter{38, 3, 100, 150, 200}, + // wantN: 5, + // wantColor: color.CMYK{C: 100, M: 150, Y: 200, K: 0}, + // }, + { + name: "CMY with color space", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 3 | parser.HasMoreFlag, + 2 | parser.HasMoreFlag, // color space id + 100 | parser.HasMoreFlag, + 150 | parser.HasMoreFlag, + 200, + }, + wantN: 6, + wantColor: color.CMYK{C: 100, M: 150, Y: 200, K: 0}, + }, + // { + // name: "CMY colon separated", + // params: []Parameter{ + // 38 | parser.HasMoreFlag, + // 3 | parser.HasMoreFlag, + // 100 | parser.HasMoreFlag, + // 150 | parser.HasMoreFlag, + // 200, + // }, + // wantN: 5, + // wantColor: color.CMYK{C: 100, M: 150, Y: 200, K: 0}, + // }, + // { + // name: "CMYK semicolon separated", + // params: []Parameter{38, 4, 100, 150, 200, 50}, + // wantN: 6, + // wantColor: color.CMYK{C: 100, M: 150, Y: 200, K: 50}, + // }, + { + name: "CMYK with color space", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 4 | parser.HasMoreFlag, + 1 | parser.HasMoreFlag, // color space id + 100 | parser.HasMoreFlag, + 150 | parser.HasMoreFlag, + 200 | parser.HasMoreFlag, + 50, + }, + wantN: 7, + wantColor: color.CMYK{C: 100, M: 150, Y: 200, K: 50}, + }, + // { + // name: "CMYK colon separated", + // params: []Parameter{ + // 38 | parser.HasMoreFlag, + // 4 | parser.HasMoreFlag, + // 100 | parser.HasMoreFlag, + // 150 | parser.HasMoreFlag, + // 200 | parser.HasMoreFlag, + // 50, + // }, + // wantN: 6, + // wantColor: color.CMYK{C: 100, M: 150, Y: 200, K: 50}, + // }, + { + name: "indexed color semicolon", + params: []Parameter{38, 5, 123}, + wantN: 3, + wantColor: ExtendedColor(123), + }, + { + name: "indexed color colon", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 5 | parser.HasMoreFlag, + 123, + }, + wantN: 3, + wantColor: ExtendedColor(123), + }, + { + name: "invalid color type", + params: []Parameter{38, 99}, + wantN: 0, + wantNil: true, + }, + { + name: "RGB with tolerance and color space", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 2 | parser.HasMoreFlag, + 1 | parser.HasMoreFlag, // color space id + 100 | parser.HasMoreFlag, + 150 | parser.HasMoreFlag, + 200 | parser.HasMoreFlag, + 0 | parser.HasMoreFlag, // tolerance value + 1, // tolerance color space + }, + wantN: 8, + wantColor: color.RGBA{R: 100, G: 150, B: 200, A: 255}, + }, + // Invalid cases + { + name: "empty params", + params: []Parameter{}, + wantN: 0, + wantNil: true, + }, + { + name: "single param", + params: []Parameter{38}, + wantN: 0, + wantNil: true, + }, + { + name: "nil params", + params: nil, + wantN: 0, + wantNil: true, + }, + // Mixed separator cases (should fail) + { + name: "RGB mixed separators", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 2, // semicolon + 100 | parser.HasMoreFlag, // colon + 150, // semicolon + 200, + }, + wantN: 0, + wantNil: true, + }, + { + name: "CMYK mixed separators", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 4, // semicolon + 100 | parser.HasMoreFlag, // colon + 150, // semicolon + 200 | parser.HasMoreFlag, // colon + 50, + }, + wantN: 0, + wantNil: true, + }, + // Edge cases + { + name: "RGB with max values", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 2 | parser.HasMoreFlag, + 255 | parser.HasMoreFlag, + 255 | parser.HasMoreFlag, + 255, + }, + wantN: 5, + wantColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + }, + { + name: "RGB with negative values", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 2 | parser.HasMoreFlag, + -1 | parser.HasMoreFlag, + -1 | parser.HasMoreFlag, + -1, + }, + wantN: 0, + wantNil: true, + }, + { + name: "indexed color with out of range index", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 5 | parser.HasMoreFlag, + 256, // out of range + }, + wantN: 3, + wantColor: ExtendedColor(0), + }, + { + name: "indexed color with negative index", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 5 | parser.HasMoreFlag, + -1, + }, + wantN: 0, + wantNil: true, + }, + { + name: "RGB truncated params", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 2 | parser.HasMoreFlag, + 100 | parser.HasMoreFlag, + 150, + }, + wantN: 0, + wantNil: true, + }, + { + name: "CMYK truncated params", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 4 | parser.HasMoreFlag, + 100 | parser.HasMoreFlag, + 150 | parser.HasMoreFlag, + 200, + }, + wantN: 0, + wantNil: true, + }, + // RGBA (type 6) test cases + // { + // name: "RGBA semicolon separated", + // params: []Parameter{38, 6, 100, 150, 200, 128}, + // wantN: 6, + // wantColor: color.RGBA{R: 100, G: 150, B: 200, A: 128}, + // }, + // { + // name: "RGBA colon separated", + // params: []Parameter{ + // 38 | parser.HasMoreFlag, + // 6 | parser.HasMoreFlag, + // 100 | parser.HasMoreFlag, + // 150 | parser.HasMoreFlag, + // 200 | parser.HasMoreFlag, + // 128, + // }, + // wantN: 6, + // wantColor: color.RGBA{R: 100, G: 150, B: 200, A: 128}, + // }, + { + name: "RGBA with color space", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 6 | parser.HasMoreFlag, + 1 | parser.HasMoreFlag, // color space id + 100 | parser.HasMoreFlag, + 150 | parser.HasMoreFlag, + 200 | parser.HasMoreFlag, + 128, + }, + wantN: 7, + wantColor: color.RGBA{R: 100, G: 150, B: 200, A: 128}, + }, + { + name: "RGBA with tolerance and color space", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 6 | parser.HasMoreFlag, + 1 | parser.HasMoreFlag, // color space id + 100 | parser.HasMoreFlag, + 150 | parser.HasMoreFlag, + 200 | parser.HasMoreFlag, + 128 | parser.HasMoreFlag, + 0 | parser.HasMoreFlag, // tolerance value + 1, // tolerance color space + }, + wantN: 9, + wantColor: color.RGBA{R: 100, G: 150, B: 200, A: 128}, + }, + { + name: "RGBA with max values", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 6 | parser.HasMoreFlag, + 0 | parser.HasMoreFlag, // color space id + 255 | parser.HasMoreFlag, + 255 | parser.HasMoreFlag, + 255 | parser.HasMoreFlag, + 255, + }, + wantN: 7, + wantColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + }, + { + name: "RGBA truncated params", + params: []Parameter{ + 38 | parser.HasMoreFlag, + 6 | parser.HasMoreFlag, + 100 | parser.HasMoreFlag, + 150 | parser.HasMoreFlag, + 200, + }, + wantN: 0, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotN, gotColor := DecodeColor(tt.params) + if gotN != tt.wantN { + t.Errorf("ReadColor() gotN = %v, want %v", gotN, tt.wantN) + } + if tt.wantNil { + if gotColor != nil { + t.Errorf("ReadColor() gotColor = %v, want nil", gotColor) + } + return + } + if gotColor != tt.wantColor { + t.Errorf("ReadColor() gotColor = %v, want %v", gotColor, tt.wantColor) + } + }) + } +}