From a68a7ba4921a938db942cfcd97ab7ac6d564da7c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 20 Feb 2025 14:02:29 -0500 Subject: [PATCH] feat(ansi): sixel: implement types and use io.Reader --- ansi/sixel/color.go | 129 +++++++++++++++ ansi/sixel/color_test.go | 286 +++++++++++++++++++++++++++++++++ ansi/sixel/decoder.go | 232 +++++++++++++------------- ansi/sixel/encoder.go | 29 ++-- ansi/sixel/raster.go | 72 +++++++++ ansi/sixel/raster_test.go | 200 +++++++++++++++++++++++ ansi/sixel/repeat.go | 59 +++++++ ansi/sixel/repeat_test.go | 148 +++++++++++++++++ ansi/sixel/sixel_bench_test.go | 92 +++++------ ansi/sixel/sixel_test.go | 2 +- ansi/sixel/util.go | 8 + 11 files changed, 1082 insertions(+), 175 deletions(-) create mode 100644 ansi/sixel/color.go create mode 100644 ansi/sixel/color_test.go create mode 100644 ansi/sixel/raster.go create mode 100644 ansi/sixel/raster_test.go create mode 100644 ansi/sixel/repeat.go create mode 100644 ansi/sixel/repeat_test.go create mode 100644 ansi/sixel/util.go diff --git a/ansi/sixel/color.go b/ansi/sixel/color.go new file mode 100644 index 00000000..761d6c30 --- /dev/null +++ b/ansi/sixel/color.go @@ -0,0 +1,129 @@ +package sixel + +import ( + "fmt" + "image/color" + "io" + + "github.com/lucasb-eyer/go-colorful" +) + +// ErrInvalidColor is returned when a Sixel color is invalid. +var ErrInvalidColor = fmt.Errorf("invalid color") + +// WriteColor writes a Sixel color to a writer. If pu is 0, the rest of the +// parameters are ignored. +func WriteColor(w io.Writer, pc, pu, px, py, pz uint) (int, error) { + if pu <= 0 || pu > 2 { + return fmt.Fprintf(w, "#%d", pc) + } + + return fmt.Fprintf(w, "#%d;%d;%d;%d;%d", pc, pu, px, py, pz) +} + +// DecodeColor decodes a Sixel color from a byte slice. It returns the Color and +// the number of bytes read. +func DecodeColor(data []byte) (c Color, n int) { + if len(data) == 0 || data[0] != ColorIntroducer { + return + } + + if len(data) < 2 { // The minimum length is 2: the introducer and a digit. + return + } + + // Parse the color number and optional color system. + pc := &c.Pc + for n = 1; n < len(data); n++ { + if data[n] == ';' { + if pc == &c.Pc { + pc = &c.Pu + } else { + n++ + break + } + } else if data[n] >= '0' && data[n] <= '9' { + *pc = (*pc)*10 + data[n] - '0' + } else { + break + } + } + + // Parse the color components. + ptr := &c.Px + for ; n < len(data); n++ { + if data[n] == ';' { + if ptr == &c.Px { + ptr = &c.Py + } else if ptr == &c.Py { + ptr = &c.Pz + } else { + n++ + break + } + } else if data[n] >= '0' && data[n] <= '9' { + *ptr = (*ptr)*10 + uint(data[n]-'0') + } else { + break + } + } + + return +} + +// Color represents a Sixel color. +type Color struct { + // Pc is the color number (0-255). + Pc uint8 + // Pu is an optional color system (1: HLS, 2: RGB, 0: default color map). + Pu uint8 + // Color components range from 0-100 for RGB values. For HLS format, the Px + // (Hue) component ranges from 0-360 degrees while L (Lightness) and S + // (Saturation) are 0-100. + Px, Py, Pz uint +} + +// RGBA implements the color.Color interface. +func (c Color) RGBA() (r, g, b, a uint32) { + switch c.Pu { + case 1: + return sixelHLS(c.Px, c.Py, c.Pz).RGBA() + case 2: + return sixelRGB(c.Px, c.Py, c.Pz).RGBA() + default: + return colors[c.Pc].RGBA() + } +} + +var colors = map[uint8]color.Color{ + // 16 predefined color registers of VT340 + 0: sixelRGB(0, 0, 0), + 1: sixelRGB(20, 20, 80), + 2: sixelRGB(80, 13, 13), + 3: sixelRGB(20, 80, 20), + 4: sixelRGB(80, 20, 80), + 5: sixelRGB(20, 80, 80), + 6: sixelRGB(80, 80, 20), + 7: sixelRGB(53, 53, 53), + 8: sixelRGB(26, 26, 26), + 9: sixelRGB(33, 33, 60), + 10: sixelRGB(60, 26, 26), + 11: sixelRGB(33, 60, 33), + 12: sixelRGB(60, 33, 60), + 13: sixelRGB(33, 60, 60), + 14: sixelRGB(60, 60, 33), + 15: sixelRGB(80, 80, 80), +} + +// #define PALVAL(n,a,m) (((n) * (a) + ((m) / 2)) / (m)) +func palval(n, a, m uint) uint { + return (n*a + m/2) / m +} + +func sixelRGB(r, g, b uint) color.Color { + return color.RGBA{uint8(palval(r, 0xff, 100)), uint8(palval(g, 0xff, 100)), uint8(palval(b, 0xff, 100)), 0xFF} //nolint:gosec +} + +func sixelHLS(h, l, s uint) color.Color { + return colorful.Hsl(float64(h), float64(s)/100.0, float64(l)/100.0).Clamped() +} diff --git a/ansi/sixel/color_test.go b/ansi/sixel/color_test.go new file mode 100644 index 00000000..282c3796 --- /dev/null +++ b/ansi/sixel/color_test.go @@ -0,0 +1,286 @@ +package sixel + +import ( + "bytes" + "image/color" + "testing" +) + +func TestWriteColor(t *testing.T) { + tests := []struct { + name string + pc uint + pu uint + px uint + py uint + pz uint + expected string + }{ + { + name: "simple color number", + pc: 1, + pu: 0, + expected: "#1", + }, + { + name: "RGB color", + pc: 1, + pu: 2, + px: 50, + py: 60, + pz: 70, + expected: "#1;2;50;60;70", + }, + { + name: "HLS color", + pc: 2, + pu: 1, + px: 180, + py: 50, + pz: 100, + expected: "#2;1;180;50;100", + }, + { + name: "invalid pu > 2", + pc: 1, + pu: 3, + expected: "#1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + n, err := WriteColor(buf, tt.pc, tt.pu, tt.px, tt.py, tt.pz) + if err != nil { + t.Errorf("WriteColor() unexpected error = %v", err) + return + } + if got := buf.String(); got != tt.expected { + t.Errorf("WriteColor() = %v, want %v", got, tt.expected) + } + if n != len(tt.expected) { + t.Errorf("WriteColor() returned length = %v, want %v", n, len(tt.expected)) + } + }) + } +} + +func TestDecodeColor(t *testing.T) { + tests := []struct { + name string + input []byte + wantC Color + wantN int + }{ + { + name: "simple color number", + input: []byte("#1"), + wantC: Color{Pc: 1}, + wantN: 2, + }, + { + name: "RGB color", + input: []byte("#1;2;50;60;70"), + wantC: Color{Pc: 1, Pu: 2, Px: 50, Py: 60, Pz: 70}, + wantN: 13, + }, + { + name: "HLS color", + input: []byte("#2;1;180;50;100"), + wantC: Color{Pc: 2, Pu: 1, Px: 180, Py: 50, Pz: 100}, + wantN: 15, + }, + { + name: "empty input", + input: []byte{}, + wantC: Color{}, + wantN: 0, + }, + { + name: "invalid introducer", + input: []byte("X1"), + wantC: Color{}, + wantN: 0, + }, + { + name: "incomplete sequence", + input: []byte("#"), + wantC: Color{}, + wantN: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotC, gotN := DecodeColor(tt.input) + if gotC != tt.wantC { + t.Errorf("DecodeColor() gotColor = %v, want %v", gotC, tt.wantC) + } + if gotN != tt.wantN { + t.Errorf("DecodeColor() gotN = %v, want %v", gotN, tt.wantN) + } + }) + } +} + +func TestColor_RGBA(t *testing.T) { + tests := []struct { + name string + color Color + wantR uint32 + wantG uint32 + wantB uint32 + wantA uint32 + }{ + { + name: "default color map 0 (black)", + color: Color{Pc: 0}, + wantR: 0x0000, + wantG: 0x0000, + wantB: 0x0000, + wantA: 0xFFFF, + }, + { + name: "RGB mode (50%, 60%, 70%)", + color: Color{Pc: 1, Pu: 2, Px: 50, Py: 60, Pz: 70}, + wantR: 0x8080, + wantG: 0x9999, + wantB: 0xB3B3, + wantA: 0xFFFF, + }, + { + name: "HLS mode (180°, 50%, 100%)", + color: Color{Pc: 1, Pu: 1, Px: 180, Py: 50, Pz: 100}, + wantR: 0x0000, + wantG: 0xFFFF, + wantB: 0xFFFF, + wantA: 0xFFFF, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotR, gotG, gotB, gotA := tt.color.RGBA() + if gotR != tt.wantR { + t.Errorf("Color.RGBA() gotR = %v, want %v", gotR, tt.wantR) + } + if gotG != tt.wantG { + t.Errorf("Color.RGBA() gotG = %v, want %v", gotG, tt.wantG) + } + if gotB != tt.wantB { + t.Errorf("Color.RGBA() gotB = %v, want %v", gotB, tt.wantB) + } + if gotA != tt.wantA { + t.Errorf("Color.RGBA() gotA = %v, want %v", gotA, tt.wantA) + } + }) + } +} + +func TestSixelRGB(t *testing.T) { + tests := []struct { + name string + r uint + g uint + b uint + want color.Color + }{ + { + name: "black", + r: 0, + g: 0, + b: 0, + want: color.NRGBA{R: 0, G: 0, B: 0, A: 255}, + }, + { + name: "white", + r: 100, + g: 100, + b: 100, + want: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, + }, + { + name: "red", + r: 100, + g: 0, + b: 0, + want: color.NRGBA{R: 255, G: 0, B: 0, A: 255}, + }, + { + name: "half intensity", + r: 50, + g: 50, + b: 50, + want: color.NRGBA{R: 128, G: 128, B: 128, A: 255}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sixelRGB(tt.r, tt.g, tt.b) + gotR, gotG, gotB, gotA := got.RGBA() + wantR, wantG, wantB, wantA := tt.want.RGBA() + if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA { + t.Errorf("sixelRGB() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSixelHLS(t *testing.T) { + tests := []struct { + name string + h uint + l uint + s uint + want color.Color + }{ + { + name: "black", + h: 0, + l: 0, + s: 0, + want: color.NRGBA{R: 0, G: 0, B: 0, A: 255}, + }, + { + name: "white", + h: 0, + l: 100, + s: 0, + want: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, + }, + { + name: "pure red", + h: 0, + l: 50, + s: 100, + want: color.NRGBA{R: 255, G: 0, B: 0, A: 255}, + }, + { + name: "pure green", + h: 120, + l: 50, + s: 100, + want: color.NRGBA{R: 0, G: 255, B: 0, A: 255}, + }, + { + name: "pure blue", + h: 240, + l: 50, + s: 100, + want: color.NRGBA{R: 0, G: 0, B: 255, A: 255}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sixelHLS(tt.h, tt.l, tt.s) + gotR, gotG, gotB, gotA := got.RGBA() + wantR, wantG, wantB, wantA := tt.want.RGBA() + if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA { + t.Errorf("sixelHLS() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ansi/sixel/decoder.go b/ansi/sixel/decoder.go index 72c21671..9b505ee8 100644 --- a/ansi/sixel/decoder.go +++ b/ansi/sixel/decoder.go @@ -1,7 +1,7 @@ package sixel import ( - "bytes" + "bufio" "errors" "fmt" "image" @@ -279,18 +279,9 @@ func buildDefaultDecodePalette() map[int]color.Color { } } -type Decoder struct { -} - -func ParseRaster(data io.Reader) (pan, pad, ph, pv int, err error) { - _, err = fmt.Fscanf(data, "%d;%d;%d;%d", &pan, &pad, &ph, &pv) - return -} - -func ParseRepeat(data io.Reader) (count int, r byte, err error) { - _, err = fmt.Fscanf(data, "%d%b", &count, &r) - return -} +// Decoder is a Sixel image decoder. It reads Sixel image data from an +// io.Reader and decodes it into an image.Image. +type Decoder struct{} // Decode will parse sixel image data into an image or return an error. Because // the sixel image format does not have a predictable size, the end of the sixel @@ -299,31 +290,55 @@ func ParseRepeat(data io.Reader) (count int, r byte, err error) { // the end, this method simply accepts a byte slice instead of a reader. Callers // should read the entire escape sequence and pass the Ps..Ps portion of the sequence // to this method. -func (d *Decoder) Decode(data []byte) (image.Image, error) { - if len(data) == 0 { - return image.NewRGBA(image.Rect(0, 0, 0, 0)), nil - } - - buffer := bytes.NewBuffer(data) - b, err := buffer.ReadByte() +func (d *Decoder) Decode(r io.Reader) (image.Image, error) { + rd := bufio.NewReader(r) + peeked, err := rd.Peek(1) if err != nil { - return nil, d.readError(err) + return nil, err } var bounds image.Rectangle - if b == RasterAttribute { - var fixedWidth, fixedHeight int - // We have pixel dimensions - _, _, fixedWidth, fixedHeight, err := ParseRaster(buffer) - if err != nil { - return nil, d.readError(err) + var raster Raster + if peeked[0] == RasterAttribute { + var read int + n := 16 + for { + peeked, err = rd.Peek(n) // random number, just need to read a few bytes + if err != nil { + return nil, err + } + + raster, read = DecodeRaster(peeked) + if read == 0 { + return nil, ErrInvalidRaster + } + if read >= n { + // We need to read more bytes to get the full raster + n *= 2 + continue + } + + rd.Discard(read) //nolint:errcheck + break } - bounds = image.Rect(0, 0, fixedWidth, fixedHeight) - } else { + bounds = image.Rect(0, 0, raster.Ph, raster.Pv) + } + + if bounds.Max.X == 0 || bounds.Max.Y == 0 { // We're parsing the image with no pixel metrics so unread the byte for the // main read loop - _ = buffer.UnreadByte() + // Peek the whole buffer to get the size of the image before we start + // decoding it. + var data []byte + toPeak := 64 // arbitrary number of bytes to peak + for { + data, err = rd.Peek(toPeak) + if err != nil || len(data) < toPeak { + break + } + toPeak *= 2 + } width, height := d.scanSize(data) bounds = image.Rect(0, 0, width, height) @@ -333,74 +348,80 @@ func (d *Decoder) Decode(data []byte) (image.Image, error) { palette := buildDefaultDecodePalette() var currentX, currentBandY, currentPaletteIndex int + // data buffer used to decode Sixel commands + data := make([]byte, 0, 16) // arbitrary number of bytes to read for { - b, err := buffer.ReadByte() + b, err := rd.ReadByte() if err != nil { return img, d.readError(err) } - // Palette operation - if b == ColorIntroducer { - _, err = fmt.Fscan(buffer, ¤tPaletteIndex) - if err != nil { - return img, d.readError(err) - } - - b, err = buffer.ReadByte() - if err != nil { - return img, d.readError(err) - } + count := 1 // default count for Sixel commands + switch { + case b == LineBreak: // LF + currentBandY++ + currentX = 0 + case b == CarriageReturn: // CR + currentX = 0 + case b == ColorIntroducer: // # + data = data[0:] + data = append(data, b) + for { + b, err = rd.ReadByte() + if err != nil { + return img, d.readError(err) + } + // Read bytes until we hit a non-color byte i.e. non-numeric + // and non-; + if (b < '0' || b > '9') && b != ';' { + rd.UnreadByte() //nolint:errcheck + break + } - if b != ';' { - // If we're not defining a color, move on - _ = buffer.UnreadByte() - continue + data = append(data, b) } - var red, green, blue uint32 - // We only know how to read RGB colors, which is preceded by a 2 - _, err = fmt.Fscanf(buffer, "2;%d;%d;%d", &red, &green, &blue) - if err != nil { - return img, d.readError(err) + // Palette operation + c, n := DecodeColor(data) + if n == 0 { + return img, ErrInvalidColor } - if red > 100 || green > 100 || blue > 100 { - return img, fmt.Errorf("invalid palette color: %d,%d,%d", red, green, blue) - } + currentPaletteIndex = int(c.Pc) + palette[currentPaletteIndex] = c + // palette[currentPaletteIndex] = color.RGBA64{ + // R: uint16(imageConvertChannel(uint32(c.Px))), + // G: uint16(imageConvertChannel(uint32(c.Py))), + // B: uint16(imageConvertChannel(uint32(c.Pz))), + // A: 65525, + // } + case b == RepeatIntroducer: // ! + data = data[0:] + data = append(data, b) + for { + b, err = rd.ReadByte() + if err != nil { + return img, d.readError(err) + } + // Read bytes until we hit a non-numeric and non-repeat byte. + if (b < '0' || b > '9') && (b < '?' || b > '~') { + rd.UnreadByte() //nolint:errcheck + break + } - palette[currentPaletteIndex] = color.RGBA64{ - R: uint16(imageConvertChannel(red)), - G: uint16(imageConvertChannel(green)), - B: uint16(imageConvertChannel(blue)), - A: 65525, + data = append(data, b) } - continue - } - - // LF - if b == LineBreak { - currentBandY++ - currentX = 0 - continue - } - - // CR - if b == CarriageReturn { - currentX = 0 - continue - } - - // RLE operation - count := 1 - if b == RepeatIntroducer { - count, b, err = ParseRepeat(buffer) - if err != nil { - return img, d.readError(err) + // RLE operation + r, n := DecodeRepeat(data) + if n == 0 { + return img, ErrInvalidRepeat } - } - if b >= '?' && b <= '~' { + count = r.Count + b = r.Char + fallthrough + case b >= '?' && b <= '~': color := palette[currentPaletteIndex] for i := 0; i < count; i++ { d.writePixel(currentX, currentBandY, b, color, img) @@ -410,7 +431,7 @@ func (d *Decoder) Decode(data []byte) (image.Image, error) { } } -// WritePixel will accept a sixel byte (from ? to ~) that defines 6 vertical pixels +// writePixel will accept a sixel byte (from ? to ~) that defines 6 vertical pixels // and write any filled pixels to the image func (d *Decoder) writePixel(x int, bandY int, sixel byte, color color.Color, img *image.RGBA) { maskedSixel := (sixel - '?') & 63 @@ -441,7 +462,6 @@ func (d *Decoder) writePixel(x int, bandY int, sixel byte, color color.Color, im // line. func (d *Decoder) scanSize(data []byte) (int, int) { var maxWidth, bandCount int - buffer := bytes.NewBuffer(data) // Pixel values are ? to ~. Each one of these encountered increases the max width. // a - is a LF and increases the max band count by one. a $ is a CR and resets @@ -451,49 +471,43 @@ func (d *Decoder) scanSize(data []byte) (int, int) { // a ! is a RLE indicator, and we should add the numeral to the current width var currentWidth int newBand := true - for { - b, err := buffer.ReadByte() - if err != nil { - return maxWidth, bandCount * 6 - } - - if b == '-' { + for i := 0; i < len(data); i++ { + b := data[i] + switch { + case b == LineBreak: // LF currentWidth = 0 // The image may end with an LF, so we shouldn't increment the band // count until we encounter a pixel newBand = true - } else if b == '$' { + case b == CarriageReturn: // CR currentWidth = 0 - } else if b == '!' || (b >= '?' && b <= '~') { - // Either an RLE operation or a single pixel - - var count int - if b == '!' { + case b == RepeatIntroducer || (b <= '~' && b >= '?'): + count := 1 + if b == RepeatIntroducer { // Get the run length for the RLE operation - _, err := fmt.Fscan(buffer, &count) - if err != nil { + r, n := DecodeRepeat(data[i:]) + if n == 0 { return maxWidth, bandCount * 6 } - // Decrement the RLE because the pixel code will follow the - // RLE and that will count as pixel - count-- - } else { - count = 1 + + // 1 is added in the loop + i += n - 1 + count = r.Count } currentWidth += count - if newBand { newBand = false bandCount++ } - if currentWidth > maxWidth { - maxWidth = currentWidth - } + + maxWidth = max(maxWidth, currentWidth) } } + + return maxWidth, bandCount * 6 } // readError will take any error returned from a read method (ReadByte, diff --git a/ansi/sixel/encoder.go b/ansi/sixel/encoder.go index d739a992..1e6fdc4f 100644 --- a/ansi/sixel/encoder.go +++ b/ansi/sixel/encoder.go @@ -21,6 +21,7 @@ import ( // beginning of a band where a new color is selected and pixels written. This continues until the entire // band has been drawn, at which time a line break is written to begin the next band. +// Sixel control functions. const ( LineBreak byte = '-' CarriageReturn byte = '$' @@ -29,22 +30,9 @@ const ( RasterAttribute byte = '"' ) -type Options struct { -} - +// Encoder is a Sixel encoder. It encodes an image to Sixel data format. type Encoder struct { -} - -func Raster(pan, pad, ph, pv int) string { - return fmt.Sprintf("%s%d;%d;%d;%d", string(RasterAttribute), pan, pad, ph, pv) -} - -func Repeat(count int, repeatByte byte) string { - var sb strings.Builder - sb.WriteByte(RepeatIntroducer) - sb.WriteString(strconv.Itoa(count)) - sb.WriteByte(repeatByte) - return sb.String() + // TODO: Support aspect ratio } // Encode will accept an Image and write sixel data to a Writer. The sixel data @@ -58,7 +46,10 @@ func (e *Encoder) Encode(w io.Writer, img image.Image) error { imageBounds := img.Bounds() - io.WriteString(w, Raster(1, 1, imageBounds.Dx(), imageBounds.Dy())) //nolint:errcheck + // Set the default raster 1:1 aspect ratio if it's not set + if _, err := WriteRaster(w, 1, 1, imageBounds.Dx(), imageBounds.Dy()); err != nil { + return fmt.Errorf("error encoding raster: %w", err) + } palette := newSixelPalette(img, sixelMaxColors) @@ -191,8 +182,8 @@ func (s *sixelBuilder) GeneratePixels() string { } hasWrittenAColor = true - s.writeControlRune(ColorIntroducer) - s.imageData.WriteString(strconv.Itoa(paletteIndex)) + // s.writeControlRune(ColorIntroducer) + // s.imageData.WriteString(strconv.Itoa(paletteIndex)) for x := 0; x < s.imageWidth; x += 4 { bit := firstColorBit + uint(x*6) word := s.pixelBands.GetWord64AtBit(bit) @@ -260,7 +251,7 @@ func (s *sixelBuilder) flushRepeats() { // Only write using the RLE form if it's actually providing space savings if s.repeatCount > 3 { - s.imageData.WriteString(Repeat(s.repeatCount, s.repeatByte)) + WriteRepeat(&s.imageData, s.repeatCount, s.repeatByte) //nolint:errcheck return } diff --git a/ansi/sixel/raster.go b/ansi/sixel/raster.go new file mode 100644 index 00000000..22ca9ed5 --- /dev/null +++ b/ansi/sixel/raster.go @@ -0,0 +1,72 @@ +package sixel + +import ( + "fmt" + "io" + "strings" +) + +// ErrInvalidRaster is returned when Raster Attributes are invalid. +var ErrInvalidRaster = fmt.Errorf("invalid raster attributes") + +// WriteRaster writes Raster attributes to a writer. If ph and pv are 0, they +// are omitted. +func WriteRaster(w io.Writer, pan, pad, ph, pv int) (n int, err error) { + if pad == 0 { + return WriteRaster(w, 1, 1, ph, pv) + } + + if ph <= 0 && pv <= 0 { + return fmt.Fprintf(w, "%c%d;%d", RasterAttribute, pan, pad) + } + + return fmt.Fprintf(w, "%c%d;%d;%d;%d", RasterAttribute, pan, pad, ph, pv) +} + +// Raster represents Sixel raster attributes. +type Raster struct { + Pan, Pad, Ph, Pv int +} + +// WriteTo writes Raster attributes to a writer. +func (r Raster) WriteTo(w io.Writer) (int64, error) { + n, err := WriteRaster(w, r.Pan, r.Pad, r.Ph, r.Pv) + return int64(n), err +} + +// String returns the Raster as a string. +func (r Raster) String() string { + var b strings.Builder + r.WriteTo(&b) //nolint:errcheck + return b.String() +} + +// DecodeRaster decodes a Raster from a byte slice. It returns the Raster and +// the number of bytes read. +func DecodeRaster(data []byte) (r Raster, n int) { + if len(data) == 0 || data[0] != RasterAttribute { + return + } + + ptr := &r.Pan + for n = 1; n < len(data); n++ { + if data[n] == ';' { + if ptr == &r.Pan { + ptr = &r.Pad + } else if ptr == &r.Pad { + ptr = &r.Ph + } else if ptr == &r.Ph { + ptr = &r.Pv + } else { + n++ + break + } + } else if data[n] >= '0' && data[n] <= '9' { + *ptr = (*ptr)*10 + int(data[n]-'0') + } else { + break + } + } + + return +} diff --git a/ansi/sixel/raster_test.go b/ansi/sixel/raster_test.go new file mode 100644 index 00000000..d8d12e52 --- /dev/null +++ b/ansi/sixel/raster_test.go @@ -0,0 +1,200 @@ +package sixel + +import ( + "bytes" + "testing" +) + +func TestWriteRaster(t *testing.T) { + tests := []struct { + name string + pan int + pad int + ph int + pv int + want string + wantErr bool + }{ + { + name: "basic case", + pan: 1, + pad: 2, + want: "\"1;2", + }, + { + name: "with ph and pv", + pan: 2, + pad: 3, + ph: 4, + pv: 5, + want: "\"2;3;4;5", + }, + { + name: "zero pad converts to 1,1", + pan: 2, + pad: 0, + want: "\"1;1", + }, + { + name: "with ph only", + pan: 1, + pad: 2, + ph: 3, + pv: 0, + want: "\"1;2;3;0", + }, + { + name: "with pv only", + pan: 1, + pad: 2, + ph: 0, + pv: 3, + want: "\"1;2;0;3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + n, err := WriteRaster(&buf, tt.pan, tt.pad, tt.ph, tt.pv) + if (err != nil) != tt.wantErr { + t.Errorf("WriteRaster() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got := buf.String(); got != tt.want { + t.Errorf("WriteRaster() = %q, want %q", got, tt.want) + } + if n != len(tt.want) { + t.Errorf("WriteRaster() returned length %d, want %d", n, len(tt.want)) + } + }) + } +} + +func TestRaster_WriteTo(t *testing.T) { + tests := []struct { + name string + raster Raster + want string + wantErr bool + }{ + { + name: "basic case", + raster: Raster{Pan: 1, Pad: 2}, + want: "\"1;2", + }, + { + name: "full attributes", + raster: Raster{Pan: 2, Pad: 3, Ph: 4, Pv: 5}, + want: "\"2;3;4;5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + n, err := tt.raster.WriteTo(&buf) + if (err != nil) != tt.wantErr { + t.Errorf("Raster.WriteTo() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got := buf.String(); got != tt.want { + t.Errorf("Raster.WriteTo() = %q, want %q", got, tt.want) + } + if n != int64(len(tt.want)) { + t.Errorf("Raster.WriteTo() returned length %d, want %d", n, len(tt.want)) + } + }) + } +} + +func TestDecodeRaster(t *testing.T) { + tests := []struct { + name string + input string + want Raster + wantRead int + }{ + { + name: "basic case", + input: "\"1;2", + want: Raster{Pan: 1, Pad: 2}, + wantRead: 4, + }, + { + name: "full attributes", + input: "\"2;3;4;5", + want: Raster{Pan: 2, Pad: 3, Ph: 4, Pv: 5}, + wantRead: 8, + }, + { + name: "empty input", + input: "", + want: Raster{}, + wantRead: 0, + }, + { + name: "invalid start character", + input: "x1;2", + want: Raster{}, + wantRead: 0, + }, + { + name: "too short", + input: "\"1", + want: Raster{Pan: 1}, + wantRead: 2, + }, + { + name: "invalid character", + input: "\"1;a", + want: Raster{Pan: 1}, + wantRead: 3, + }, + { + name: "partial attributes", + input: "\"1;2;3", + want: Raster{Pan: 1, Pad: 2, Ph: 3}, + wantRead: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, n := DecodeRaster([]byte(tt.input)) + if got != tt.want { + t.Errorf("DecodeRaster() = %+v, want %+v", got, tt.want) + } + if n != tt.wantRead { + t.Errorf("DecodeRaster() read = %d, want %d", n, tt.wantRead) + } + }) + } +} + +func TestRaster_String(t *testing.T) { + tests := []struct { + name string + raster Raster + want string + }{ + { + name: "basic case", + raster: Raster{Pan: 1, Pad: 2}, + want: "\"1;2", + }, + { + name: "full attributes", + raster: Raster{Pan: 2, Pad: 3, Ph: 4, Pv: 5}, + want: "\"2;3;4;5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.raster.String(); got != tt.want { + t.Errorf("Raster.String() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/ansi/sixel/repeat.go b/ansi/sixel/repeat.go new file mode 100644 index 00000000..d19ca70e --- /dev/null +++ b/ansi/sixel/repeat.go @@ -0,0 +1,59 @@ +package sixel + +import ( + "fmt" + "io" + "strings" +) + +// ErrInvalidRepeat is returned when a Repeat is invalid +var ErrInvalidRepeat = fmt.Errorf("invalid repeat") + +// WriteRepeat writes a Repeat to a writer. A repeat character is in the range +// of '?' (0x3F) to '~' (0x7E). +func WriteRepeat(w io.Writer, count int, char byte) (int, error) { + return fmt.Fprintf(w, "%c%d%c", RepeatIntroducer, count, char) +} + +// Repeat represents a Sixel repeat introducer. +type Repeat struct { + Count int + Char byte +} + +// WriteTo writes a Repeat to a writer. +func (r Repeat) WriteTo(w io.Writer) (int64, error) { + n, err := WriteRepeat(w, r.Count, r.Char) + return int64(n), err +} + +// String returns the Repeat as a string. +func (r Repeat) String() string { + var b strings.Builder + r.WriteTo(&b) //nolint:errcheck + return b.String() +} + +// DecodeRepeat decodes a Repeat from a byte slice. It returns the Repeat and +// the number of bytes read. +func DecodeRepeat(data []byte) (r Repeat, n int) { + if len(data) == 0 || data[0] != RepeatIntroducer { + return + } + + if len(data) < 3 { // The minimum length is 3: the introducer, a digit, and a character. + return + } + + for n = 1; n < len(data); n++ { + if data[n] >= '0' && data[n] <= '9' { + r.Count = r.Count*10 + int(data[n]-'0') + } else { + r.Char = data[n] + n++ // Include the character in the count. + break + } + } + + return +} diff --git a/ansi/sixel/repeat_test.go b/ansi/sixel/repeat_test.go new file mode 100644 index 00000000..4c5d9da3 --- /dev/null +++ b/ansi/sixel/repeat_test.go @@ -0,0 +1,148 @@ +package sixel + +import ( + "bytes" + "testing" +) + +func TestWriteRepeat(t *testing.T) { + tests := []struct { + name string + count int + char byte + expected string + wantErr bool + }{ + { + name: "basic repeat", + count: 3, + char: 'A', + expected: "!3A", + }, + { + name: "single digit", + count: 5, + char: '#', + expected: "!5#", + }, + { + name: "multiple digits", + count: 123, + char: 'x', + expected: "!123x", + }, + { + name: "zero count", + count: 0, + char: 'B', + expected: "!0B", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + n, err := WriteRepeat(buf, tt.count, tt.char) + if (err != nil) != tt.wantErr { + t.Errorf("WriteRepeat() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got := buf.String(); got != tt.expected { + t.Errorf("WriteRepeat() = %v, want %v", got, tt.expected) + } + if n != len(tt.expected) { + t.Errorf("WriteRepeat() returned length = %v, want %v", n, len(tt.expected)) + } + }) + } +} + +func TestDecodeRepeat(t *testing.T) { + tests := []struct { + name string + input []byte + wantRepeat Repeat + wantN int + description string + }{ + { + name: "basic repeat", + input: []byte("!3A"), + wantRepeat: Repeat{Count: 3, Char: 'A'}, + wantN: 3, + description: "simple single digit repeat", + }, + { + name: "multiple digits", + input: []byte("!123x"), + wantRepeat: Repeat{Count: 123, Char: 'x'}, + wantN: 5, + description: "repeat with multiple digits", + }, + { + name: "empty input", + input: []byte{}, + wantRepeat: Repeat{}, + wantN: 0, + description: "empty input should return zero values", + }, + { + name: "invalid introducer", + input: []byte("X3A"), + wantRepeat: Repeat{}, + wantN: 0, + description: "input without proper introducer", + }, + { + name: "incomplete sequence", + input: []byte("!3"), + wantRepeat: Repeat{}, + wantN: 0, + description: "incomplete sequence without character", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRepeat, gotN := DecodeRepeat(tt.input) + if gotRepeat != tt.wantRepeat { + t.Errorf("DecodeRepeat() gotRepeat = %v, want %v", gotRepeat, tt.wantRepeat) + } + if gotN != tt.wantN { + t.Errorf("DecodeRepeat() gotN = %v, want %v", gotN, tt.wantN) + } + }) + } +} + +func TestRepeat_String(t *testing.T) { + tests := []struct { + name string + repeat Repeat + expected string + }{ + { + name: "basic repeat", + repeat: Repeat{Count: 3, Char: 'A'}, + expected: "!3A", + }, + { + name: "multiple digits", + repeat: Repeat{Count: 123, Char: 'x'}, + expected: "!123x", + }, + { + name: "zero count", + repeat: Repeat{Count: 0, Char: 'B'}, + expected: "!0B", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.repeat.String(); got != tt.expected { + t.Errorf("Repeat.String() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/ansi/sixel/sixel_bench_test.go b/ansi/sixel/sixel_bench_test.go index 7bf2f5b7..a59176f4 100644 --- a/ansi/sixel/sixel_bench_test.go +++ b/ansi/sixel/sixel_bench_test.go @@ -1,69 +1,69 @@ package sixel import ( - "io" - "os" - "bytes" - "fmt" - "testing" - "image" - "image/png" + "bytes" + "fmt" + "image" + "image/png" + "io" + "os" + "testing" - "github.com/charmbracelet/x/ansi" - gosixel "github.com/mattn/go-sixel" + "github.com/charmbracelet/x/ansi" + gosixel "github.com/mattn/go-sixel" ) func BenchmarkEncodingGoSixel(b *testing.B) { - for b.Loop() { - raw, err := loadImage("./../fixtures/graphics/JigokudaniMonkeyPark.png") - if err != nil { - os.Exit(1) - } + for i := 0; i < b.N; i++ { + raw, err := loadImage("./../fixtures/graphics/JigokudaniMonkeyPark.png") + if err != nil { + os.Exit(1) + } - var b = bytes.NewBuffer(nil) - enc := gosixel.NewEncoder(b) - if err := enc.Encode(raw); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + b := bytes.NewBuffer(nil) + enc := gosixel.NewEncoder(b) + if err := enc.Encode(raw); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } - // fmt.Println(b) - } + // fmt.Println(b) + } } func writeSixelGraphics(w io.Writer, m image.Image) error { - e := &Encoder{} + e := &Encoder{} - data := bytes.NewBuffer(nil) - if err := e.Encode(data, m); err != nil { - return fmt.Errorf("failed to encode sixel image: %w", err) - } + data := bytes.NewBuffer(nil) + if err := e.Encode(data, m); err != nil { + return fmt.Errorf("failed to encode sixel image: %w", err) + } - _, err := io.WriteString(w, ansi.SixelGraphics(0, 1, 0, data.Bytes())) - return err + _, err := io.WriteString(w, ansi.SixelGraphics(0, 1, 0, data.Bytes())) + return err } func BenchmarkEncodingXSixel(b *testing.B) { - for b.Loop() { - raw, err := loadImage("./../fixtures/graphics/JigokudaniMonkeyPark.png") - if err != nil { - os.Exit(1) - } + for i := 0; i < b.N; i++ { + raw, err := loadImage("./../fixtures/graphics/JigokudaniMonkeyPark.png") + if err != nil { + os.Exit(1) + } - var b = bytes.NewBuffer(nil) - if err := writeSixelGraphics(b, raw); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + b := bytes.NewBuffer(nil) + if err := writeSixelGraphics(b, raw); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } - // fmt.Println(b) - } + // fmt.Println(b) + } } func loadImage(path string) (image.Image, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - return png.Decode(f) + f, err := os.Open(path) + if err != nil { + return nil, err + } + return png.Decode(f) } diff --git a/ansi/sixel/sixel_test.go b/ansi/sixel/sixel_test.go index d18400c5..5aff710f 100644 --- a/ansi/sixel/sixel_test.go +++ b/ansi/sixel/sixel_test.go @@ -170,7 +170,7 @@ func TestFullImage(t *testing.T) { return } - compareImg, err := decoder.Decode(buffer.Bytes()) + compareImg, err := decoder.Decode(buffer) if err != nil { t.Errorf("Unexpected error: %+v", err) return diff --git a/ansi/sixel/util.go b/ansi/sixel/util.go new file mode 100644 index 00000000..afbddf4d --- /dev/null +++ b/ansi/sixel/util.go @@ -0,0 +1,8 @@ +package sixel + +func max(a, b int) int { + if a > b { + return a + } + return b +}