diff --git a/.gitignore b/.gitignore index 398516f2..a2f7b2eb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ testdata *.png + +# Allow graphics used for bench test +!ansi/fixtures/graphics/*.png \ No newline at end of file diff --git a/ansi/fixtures/graphics/JigokudaniMonkeyPark.png b/ansi/fixtures/graphics/JigokudaniMonkeyPark.png new file mode 100644 index 00000000..14db9c97 Binary files /dev/null and b/ansi/fixtures/graphics/JigokudaniMonkeyPark.png differ diff --git a/ansi/go.mod b/ansi/go.mod index d5d4b6e6..96c4af5e 100644 --- a/ansi/go.mod +++ b/ansi/go.mod @@ -3,7 +3,12 @@ module github.com/charmbracelet/x/ansi go 1.18 require ( + github.com/bits-and-blooms/bitset v1.20.0 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.16 + github.com/mattn/go-sixel v0.0.5 //go:build benchthis github.com/rivo/uniseg v0.4.7 + github.com/soniakeys/quant v1.0.0 // indirect; go:build benchthis ) + +require github.com/aymanbagabas/quant v0.0.0-20250220224823-9dea6ec382b5 diff --git a/ansi/go.sum b/ansi/go.sum index 4dfe154f..95977e4e 100644 --- a/ansi/go.sum +++ b/ansi/go.sum @@ -1,7 +1,15 @@ +github.com/aymanbagabas/quant v0.0.0-20250220224823-9dea6ec382b5 h1:BQwkJD8W8jgJpvJgA3klNG5W4pyrU4wJ7LynWik+uYc= +github.com/aymanbagabas/quant v0.0.0-20250220224823-9dea6ec382b5/go.mod h1:LZcbdMgqlAs77pcs8WZnQxNflVHNtzWWDDUJoBFC24A= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8= +github.com/mattn/go-sixel v0.0.5/go.mod h1:h2Sss+DiUEHy0pUqcIB6PFXo5Cy8sTQEFr3a9/5ZLNw= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= +github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= diff --git a/ansi/graphics.go b/ansi/graphics.go index af923099..b5b1dc82 100644 --- a/ansi/graphics.go +++ b/ansi/graphics.go @@ -8,11 +8,49 @@ import ( "image" "io" "os" + "strconv" "strings" "github.com/charmbracelet/x/ansi/kitty" ) +// SixelGraphics returns a sequence that encodes the given sixel image payload to +// a DCS sixel sequence. +// +// DCS p1; p2; p3; q [sixel payload] ST +// +// p1 = pixel aspect ratio, deprecated and replaced by pixel metrics in the payload +// +// p2 = This is supposed to be 0 for transparency, but terminals don't seem to +// to use it properly. Value 0 leaves an unsightly black bar on all terminals +// I've tried and looks correct with value 1. +// +// p3 = Horizontal grid size parameter. Everyone ignores this and uses a fixed grid +// size, as far as I can tell. +// +// See https://shuford.invisible-island.net/all_about_sixels.txt +func SixelGraphics(p1, p2, p3 int, payload []byte) string { + var buf bytes.Buffer + + buf.WriteString("\x1bP") + if p1 >= 0 { + buf.WriteString(strconv.Itoa(p1)) + } + buf.WriteByte(';') + if p2 >= 0 { + buf.WriteString(strconv.Itoa(p2)) + } + if p3 > 0 { + buf.WriteByte(';') + buf.WriteString(strconv.Itoa(p3)) + } + buf.WriteByte('q') + buf.Write(payload) + buf.WriteString("\x1b\\") + + return buf.String() +} + // KittyGraphics returns a sequence that encodes the given image in the Kitty // graphics protocol. // diff --git a/ansi/sixel/color.go b/ansi/sixel/color.go new file mode 100644 index 00000000..8e3c03db --- /dev/null +++ b/ansi/sixel/color.go @@ -0,0 +1,135 @@ +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 int) (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) +} + +// ConvertChannel converts a color channel from color.Color 0xffff to 0-100 +// Sixel RGB format. +func ConvertChannel(c uint32) uint32 { + // We add 328 because that is about 0.5 in the sixel 0-100 color range, we're trying to + // round to the nearest value + return (c + 328) * 100 / 0xffff +} + +// FromColor returns a Sixel color from a color.Color. It converts the color +// channels to the 0-100 range. +func FromColor(c color.Color) Color { + if c == nil { + return Color{} + } + r, g, b, _ := c.RGBA() + return Color{ + Pu: 2, // Always use RGB format "2" + Px: int(ConvertChannel(r)), + Py: int(ConvertChannel(g)), + Pz: int(ConvertChannel(b)), + } +} + +// 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 + int(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 + int(data[n]-'0') + } else { + break + } + } + + return +} + +// Color represents a Sixel color. +type Color struct { + // Pc is the color number (0-255). + Pc int + // Pu is an optional color system + // - 0: default color map + // - 1: HLS + // - 2: RGB + Pu int + // 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 int +} + +// 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 colorPalette[c.Pc].RGBA() + } +} + +// #define PALVAL(n,a,m) (((n) * (a) + ((m) / 2)) / (m)) +func palval(n, a, m int) int { + return (n*a + m/2) / m +} + +func sixelRGB(r, g, b int) color.Color { + return color.NRGBA{uint8(palval(r, 0xff, 100)), uint8(palval(g, 0xff, 100)), uint8(palval(b, 0xff, 100)), 0xFF} //nolint:gosec +} + +func sixelHLS(h, l, s int) 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..5844fdc8 --- /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 int + pu int + px int + py int + pz int + 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 int + g int + b int + 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 int + l int + s int + 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 new file mode 100644 index 00000000..503d354b --- /dev/null +++ b/ansi/sixel/decoder.go @@ -0,0 +1,533 @@ +package sixel + +import ( + "bufio" + "errors" + "fmt" + "image" + "image/color" + "io" +) + +// 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 { + // Palette is the color palette used to decode the Sixel image. If the + // palette is nil, the default palette is used from [DefaultPalette]. + Palette color.Palette +} + +// 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 +// image data can only be identified when ST, ESC, or BEL has been read from a reader. +// In order to avoid reading bytes from a reader one at a time to avoid missing +// 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(r io.Reader) (image.Image, error) { + rd := bufio.NewReader(r) + peeked, err := rd.Peek(1) + if err != nil { + return nil, err + } + + var bounds image.Rectangle + 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, 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 + // 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) + } + + img := image.NewRGBA(bounds) + palette := d.Palette + if palette == nil { + palette = colorPalette[:] + } + var currentX, currentBandY, currentPaletteIndex int + + // data buffer used to decode Sixel commands + data := make([]byte, 0, 6) // arbitrary number of bytes to read + // i := 0 // keeps track of the data buffer index + for { + b, err := rd.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 + } + + data = append(data, b) + } + + // Palette operation + c, n := DecodeColor(data) + if n == 0 { + return img, ErrInvalidColor + } + + currentPaletteIndex = c.Pc + if c.Pu > 0 { + // Non-zero Pu means we have a color definition to set. + palette[currentPaletteIndex] = c + } + + 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 + } + + data = append(data, b) + } + + // RLE operation + r, n := DecodeRepeat(data) + if n == 0 { + return img, ErrInvalidRepeat + } + + 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) + currentX++ + } + } + } +} + +// 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 + yOffset := 0 + for maskedSixel != 0 { + if maskedSixel&1 != 0 { + img.Set(x, bandY*6+yOffset, color) + } + + yOffset++ + maskedSixel >>= 1 + } +} + +// scanSize is only used for legacy sixel images that do not define pixel metrics +// near the header (technically permitted). In this case, we need to quickly scan +// the image to figure out what the height and width are. Different terminals +// treat unfilled pixels around the border of the image diffently, but in our case +// we will treat all pixels, even empty ones, as part of the image. However, +// we will allow the image to end with an LF code without increasing the size +// of the image. +// +// In the interest of speed, this method doesn't really parse the image in any +// meaningful way: pixel codes (? to ~), and the RLE, CR, and LF indicators +// (!, $, -) cannot appear within a sixel image except as themselves, so we +// just ignore everything else. The only thing we actually take the time to parse +// is the number after the RLE indicator to know how much width to add to the current +// line. +func (d *Decoder) scanSize(data []byte) (int, int) { + var maxWidth, bandCount int + + // 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 + // current width. (char - '?') will get a 6-bit number and the highest bit is + // the lowest y value, which we should use to increment maxBandPixels. + // + // a ! is a RLE indicator, and we should add the numeral to the current width + var currentWidth int + newBand := true + 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 + case b == CarriageReturn: + // CR + currentWidth = 0 + case b == RepeatIntroducer || (b <= '~' && b >= '?'): + count := 1 + if b == RepeatIntroducer { + // Get the run length for the RLE operation + r, n := DecodeRepeat(data[i:]) + if n == 0 { + return maxWidth, bandCount * 6 + } + + // 1 is added in the loop + i += n - 1 + count = r.Count + } + + currentWidth += count + if newBand { + newBand = false + bandCount++ + } + + maxWidth = max(maxWidth, currentWidth) + } + } + + return maxWidth, bandCount * 6 +} + +// readError will take any error returned from a read method (ReadByte, +// FScanF, etc.) and either wrap or ignore the error. An encountered EOF +// indicates that it's time to return the completed image so we just +// return it. +func (d *Decoder) readError(err error) error { + if errors.Is(err, io.EOF) { + return nil + } + + return fmt.Errorf("failed to read sixel data: %w", err) +} + +// The default palette for Sixel images. This is a combination of the Sixel +// default colors and the xterm colors. +var colorPalette = [256]color.Color{ + // Sixel-specific default colors + 0: color.RGBA{0, 0, 0, 255}, + 1: color.RGBA{51, 51, 204, 255}, + 2: color.RGBA{204, 36, 36, 255}, + 3: color.RGBA{51, 204, 51, 255}, + 4: color.RGBA{204, 51, 204, 255}, + 5: color.RGBA{51, 204, 204, 255}, + 6: color.RGBA{204, 204, 51, 255}, + 7: color.RGBA{120, 120, 120, 255}, + 8: color.RGBA{69, 69, 69, 255}, + 9: color.RGBA{87, 87, 153, 255}, + 10: color.RGBA{153, 69, 69, 255}, + 11: color.RGBA{87, 153, 87, 255}, + 12: color.RGBA{153, 87, 153, 255}, + 13: color.RGBA{87, 153, 153, 255}, + 14: color.RGBA{153, 153, 87, 255}, + 15: color.RGBA{204, 204, 204, 255}, + + // xterm colors + 16: color.RGBA{0, 0, 0, 255}, // Black1 + 17: color.RGBA{0, 0, 95, 255}, // DarkBlue2 + 18: color.RGBA{0, 0, 135, 255}, // DarkBlue1 + 19: color.RGBA{0, 0, 175, 255}, // DarkBlue + 20: color.RGBA{0, 0, 215, 255}, // Blue3 + 21: color.RGBA{0, 0, 255, 255}, // Blue2 + 22: color.RGBA{0, 95, 0, 255}, // DarkGreen4 + 23: color.RGBA{0, 95, 95, 255}, // DarkGreenBlue5 + 24: color.RGBA{0, 95, 135, 255}, // DarkGreenBlue4 + 25: color.RGBA{0, 95, 175, 255}, // DarkGreenBlue3 + 26: color.RGBA{0, 95, 215, 255}, // GreenBlue8 + 27: color.RGBA{0, 95, 255, 255}, // GreenBlue7 + 28: color.RGBA{0, 135, 0, 255}, // DarkGreen3 + 29: color.RGBA{0, 135, 95, 255}, // DarkGreen2 + 30: color.RGBA{0, 135, 0, 255}, // DarkGreenBlue2 + 31: color.RGBA{0, 135, 175, 255}, // DarkGreenBlue1 + 32: color.RGBA{0, 125, 215, 255}, // GreenBlue6 + 33: color.RGBA{0, 135, 255, 255}, // GreenBlue5 + 34: color.RGBA{0, 175, 0, 255}, // DarkGreen1 + 35: color.RGBA{0, 175, 95, 255}, // DarkGreen + 36: color.RGBA{0, 175, 135, 255}, // DarkBlueGreen + 37: color.RGBA{0, 175, 175, 255}, // DarkGreenBlue + 38: color.RGBA{0, 175, 215, 255}, // GreenBlue4 + 39: color.RGBA{0, 175, 255, 255}, // GreenBlue3 + 40: color.RGBA{0, 215, 0, 255}, // Green7 + 41: color.RGBA{0, 215, 95, 255}, // Green6 + 42: color.RGBA{0, 215, 135, 255}, // Green5 + 43: color.RGBA{0, 215, 175, 255}, // BlueGreen1 + 44: color.RGBA{0, 215, 215, 255}, // GreenBlue2 + 45: color.RGBA{0, 215, 255, 255}, // GreenBlue1 + 46: color.RGBA{0, 255, 0, 255}, // Green4 + 47: color.RGBA{0, 255, 95, 255}, // Green3 + 48: color.RGBA{0, 255, 135, 255}, // Green2 + 49: color.RGBA{0, 255, 175, 255}, // Green1 + 50: color.RGBA{0, 255, 215, 255}, // BlueGreen + 51: color.RGBA{0, 255, 255, 255}, // GreenBlue + 52: color.RGBA{95, 0, 0, 255}, // DarkRed2 + 53: color.RGBA{95, 0, 95, 255}, // DarkPurple4 + 54: color.RGBA{95, 0, 135, 255}, // DarkBluePurple2 + 55: color.RGBA{95, 0, 175, 255}, // DarkBluePurple1 + 56: color.RGBA{95, 0, 215, 255}, // PurpleBlue + 57: color.RGBA{95, 0, 255, 255}, // Blue1 + 58: color.RGBA{95, 95, 0, 255}, // DarkYellow4 + 59: color.RGBA{95, 95, 95, 255}, // Gray3 + 60: color.RGBA{95, 95, 135, 255}, // PlueBlue8 + 61: color.RGBA{95, 95, 175, 255}, // PaleBlue7 + 62: color.RGBA{95, 95, 215, 255}, // PaleBlue6 + 63: color.RGBA{95, 95, 255, 255}, // PaleBlue5 + 64: color.RGBA{95, 135, 0, 255}, // DarkYellow3 + 65: color.RGBA{95, 135, 95, 255}, // PaleGreen12 + 66: color.RGBA{95, 135, 135, 255}, // PaleGreen11 + 67: color.RGBA{95, 135, 175, 255}, // PaleGreenBlue10 + 68: color.RGBA{95, 135, 215, 255}, // PaleGreenBlue9 + 69: color.RGBA{95, 135, 255, 255}, // PaleBlue4 + 70: color.RGBA{95, 175, 0, 255}, // DarkGreenYellow + 71: color.RGBA{95, 175, 95, 255}, // PaleGreen11 + 72: color.RGBA{95, 175, 135, 255}, // PaleGreen10 + 73: color.RGBA{95, 175, 175, 255}, // PaleGreenBlue8 + 74: color.RGBA{95, 175, 215, 255}, // PaleGreenBlue7 + 75: color.RGBA{95, 175, 255, 255}, // PaleGreenBlue6 + 76: color.RGBA{95, 215, 0, 255}, // YellowGreen1 + 77: color.RGBA{95, 215, 95, 255}, // PaleGreen9 + 78: color.RGBA{95, 215, 135, 255}, // PaleGreen8 + 79: color.RGBA{95, 215, 175, 255}, // PaleGreen7 + 80: color.RGBA{95, 215, 215, 255}, // PaleGreenBlue5 + 81: color.RGBA{95, 215, 255, 255}, // PaleGreenBlue4 + 82: color.RGBA{95, 255, 0, 255}, // YellowGreen + 83: color.RGBA{95, 255, 95, 255}, // PaleGreen6 + 84: color.RGBA{95, 255, 135, 255}, // PaleGreen5 + 85: color.RGBA{95, 255, 175, 255}, // PaleGreen4 + 86: color.RGBA{95, 255, 215, 255}, // PaleGreen3 + 87: color.RGBA{95, 255, 255, 255}, // PaleGreenBlue3 + 88: color.RGBA{135, 0, 0, 255}, // DarkRed1 + 89: color.RGBA{135, 0, 95, 255}, // DarkPurple3 + 90: color.RGBA{135, 0, 135, 255}, // DarkPurple2 + 91: color.RGBA{135, 0, 175, 255}, // DarkBluePurple + 92: color.RGBA{135, 0, 215, 255}, // BluePurple4 + 93: color.RGBA{135, 0, 255, 255}, // BluePurple3 + 94: color.RGBA{135, 95, 0, 255}, // DarkOrange1 + 95: color.RGBA{135, 95, 95, 255}, // PaleRed5 + 96: color.RGBA{135, 95, 135, 255}, // PalePurple7 + 97: color.RGBA{135, 95, 175, 255}, // PalePurpleBlue + 98: color.RGBA{135, 95, 215, 255}, // PaleBlue3 + 99: color.RGBA{135, 95, 255, 255}, // PaleBlue2 + 100: color.RGBA{135, 135, 0, 255}, // DarkYellow2 + 101: color.RGBA{135, 135, 95, 255}, // PaleYellow7 + 102: color.RGBA{135, 135, 135, 255}, // Gray2 + 103: color.RGBA{135, 135, 175, 255}, // PaleBlue1 + 104: color.RGBA{135, 135, 215, 255}, // PaleBlue + 105: color.RGBA{135, 135, 255, 255}, // LightPaleBlue4 + 106: color.RGBA{135, 175, 0, 255}, // DarkYellow1 + 107: color.RGBA{135, 175, 95, 255}, // PaleYellowGreen3 + 108: color.RGBA{135, 175, 135, 255}, // PaleGreen2 + 109: color.RGBA{135, 175, 175, 255}, // PaleGreenBlue2 + 110: color.RGBA{135, 175, 215, 255}, // PaleGreenBlue1 + 111: color.RGBA{135, 175, 255, 255}, // LightPaleGreenBlue6 + 112: color.RGBA{135, 215, 0, 255}, // Yellow6 + 113: color.RGBA{135, 215, 95, 255}, // PaleYellowGreen2 + 114: color.RGBA{135, 215, 135, 255}, // PaleGreen1 + 115: color.RGBA{135, 215, 175, 255}, // PaleGreen + 116: color.RGBA{135, 215, 215, 255}, // PaleGreenBlue + 117: color.RGBA{135, 215, 255, 255}, // LightPaleGreenBlue5 + 118: color.RGBA{135, 255, 0, 255}, // GreenYellow + 119: color.RGBA{135, 255, 95, 255}, // PaleYellowGreen1 + 120: color.RGBA{135, 255, 135, 255}, // LightPaleGreen6 + 121: color.RGBA{135, 255, 175, 255}, // LightPaleGreen5 + 122: color.RGBA{135, 255, 215, 255}, // LightPaleGreen4 + 123: color.RGBA{135, 255, 255, 255}, // LightPaleGreenBlue4 + 124: color.RGBA{175, 0, 0, 255}, // DarkRed + 125: color.RGBA{175, 0, 95, 255}, // DarkRedPurple + 126: color.RGBA{175, 0, 135, 255}, // DarkPurple1 + 127: color.RGBA{175, 0, 175, 255}, // DarkPurple + 128: color.RGBA{175, 0, 215, 255}, // BluePurple2 + 129: color.RGBA{175, 0, 255, 255}, // BluePurple1 + 130: color.RGBA{175, 95, 0, 255}, // DarkOrange + 131: color.RGBA{175, 95, 95, 255}, // PaleRed4 + 132: color.RGBA{175, 95, 135, 255}, // PalePurpleRed3 + 133: color.RGBA{175, 95, 175, 255}, // PalePurple6 + 134: color.RGBA{175, 95, 215, 255}, // PaleBluePurple3 + 135: color.RGBA{175, 95, 255, 255}, // PaleBluePurple2 + 136: color.RGBA{175, 135, 0, 255}, // DarkYellowOrange + 137: color.RGBA{175, 135, 95, 255}, // PaleRedOrange3 + 138: color.RGBA{175, 135, 135, 255}, // PaleRed3 + 139: color.RGBA{175, 135, 175, 255}, // PalePurple5 + 140: color.RGBA{175, 135, 215, 255}, // PaleBluePurple1 + 141: color.RGBA{175, 135, 255, 255}, // LightPaleBlue3 + 142: color.RGBA{175, 175, 0, 255}, // DarkYellow + 143: color.RGBA{175, 175, 95, 255}, // PaleYellow6 + 144: color.RGBA{175, 175, 135, 255}, // PaleYellow5 + 145: color.RGBA{175, 175, 175, 255}, // Gray1 + 146: color.RGBA{175, 175, 215, 255}, // LightPaleBlue2 + 147: color.RGBA{175, 175, 255, 255}, // LightPaleBlue1 + 148: color.RGBA{175, 215, 0, 255}, // Yellow5 + 149: color.RGBA{175, 215, 95, 255}, // PaleYellow4 + 150: color.RGBA{175, 215, 135, 255}, // PaleGreenYellow + 151: color.RGBA{175, 215, 175, 255}, // LightPaleGreen3 + 152: color.RGBA{175, 215, 215, 255}, // LightPaleGreenBlue3 + 153: color.RGBA{175, 215, 255, 255}, // LightPaleGreenBlue2 + 154: color.RGBA{175, 255, 0, 255}, // Yellow4 + 155: color.RGBA{175, 255, 95, 255}, // PaleYellowGreen + 156: color.RGBA{175, 255, 135, 255}, // LightPaleYellowGreen1 + 157: color.RGBA{175, 255, 215, 255}, // LightPaleGreen2 + 158: color.RGBA{175, 255, 215, 255}, // LightPaleGreen1 + 159: color.RGBA{175, 255, 255, 255}, // LightPaleGreenBlue1 + 160: color.RGBA{215, 0, 0, 255}, // Red2 + 161: color.RGBA{215, 0, 95, 255}, // PurpleRed1 + 162: color.RGBA{215, 0, 135, 255}, // Purple6 + 163: color.RGBA{215, 0, 175, 255}, // Purple5 + 164: color.RGBA{215, 0, 215, 255}, // Purple4 + 165: color.RGBA{215, 0, 255, 255}, // BluePurple + 166: color.RGBA{215, 95, 0, 255}, // RedOrange1 + 167: color.RGBA{215, 95, 95, 255}, // PaleRed2 + 168: color.RGBA{215, 95, 135, 255}, // PalePurpleRed2 + 169: color.RGBA{215, 95, 175, 255}, // PalePurple4 + 170: color.RGBA{215, 95, 215, 255}, // PalePurple3 + 171: color.RGBA{215, 95, 255, 255}, // PaleBluePurple + 172: color.RGBA{215, 135, 0, 255}, // Orange2 + 173: color.RGBA{215, 135, 95, 255}, // PaleRedOrange2 + 174: color.RGBA{215, 135, 135, 255}, // PaleRed1 + 175: color.RGBA{215, 135, 175, 255}, // PaleRedPurple + 176: color.RGBA{215, 135, 215, 255}, // PalePurple2 + 177: color.RGBA{215, 135, 255, 255}, // LightPaleBluePurple + 178: color.RGBA{215, 175, 0, 255}, // OrangeYellow1 + 179: color.RGBA{215, 175, 95, 255}, // PaleOrange1 + 180: color.RGBA{215, 175, 135, 255}, // PaleRedOrange1 + 181: color.RGBA{215, 175, 175, 255}, // LightPaleRed3 + 182: color.RGBA{215, 175, 215, 255}, // LightPalePurple4 + 183: color.RGBA{215, 175, 255, 255}, // LightPalePurpleBlue + 184: color.RGBA{215, 215, 0, 255}, // Yellow3 + 185: color.RGBA{215, 215, 95, 255}, // PaleYellow3 + 186: color.RGBA{215, 215, 135, 255}, // PaleYellow2 + 187: color.RGBA{215, 215, 175, 255}, // LightPaleYellow4 + 188: color.RGBA{215, 215, 215, 255}, // LightGray + 189: color.RGBA{215, 215, 255, 255}, // LightPaleBlue + 190: color.RGBA{215, 255, 0, 255}, // Yellow2 + 191: color.RGBA{215, 255, 95, 255}, // PaleYellow1 + 192: color.RGBA{215, 255, 135, 255}, // LightPaleYellow3 + 193: color.RGBA{215, 255, 175, 255}, // LightPaleYellowGreen + 194: color.RGBA{215, 255, 215, 255}, // LightPaleGreen + 195: color.RGBA{215, 255, 255, 255}, // LightPaleGreenBlue + 196: color.RGBA{255, 0, 0, 255}, // Red1 + 197: color.RGBA{255, 0, 95, 255}, // PurpleRed + 198: color.RGBA{255, 0, 135, 255}, // RedPurple + 199: color.RGBA{255, 0, 175, 255}, // Purple3 + 200: color.RGBA{255, 0, 215, 255}, // Purple2 + 201: color.RGBA{255, 0, 255, 255}, // Purple1 + 202: color.RGBA{255, 95, 0, 255}, // RedOrange + 203: color.RGBA{255, 95, 95, 255}, // PaleRed + 204: color.RGBA{255, 95, 135, 255}, // PalePurpleRed1 + 205: color.RGBA{255, 95, 175, 255}, // PalePurpleRed + 206: color.RGBA{255, 95, 215, 255}, // PalePurple1 + 207: color.RGBA{255, 95, 255, 255}, // PalePurple + 208: color.RGBA{255, 135, 0, 255}, // Orange1 + 209: color.RGBA{255, 135, 95, 255}, // PaleOrangeRed + 210: color.RGBA{255, 135, 135, 255}, // LightPaleRed2 + 211: color.RGBA{255, 135, 175, 255}, // LightPalePurpleRed1 + 212: color.RGBA{255, 135, 215, 255}, // LightPalePurple3 + 213: color.RGBA{255, 135, 255, 255}, // LightPalePurple2 + 214: color.RGBA{255, 175, 0, 255}, // Orange + 215: color.RGBA{255, 175, 95, 255}, // PaleRedOrange + 216: color.RGBA{255, 175, 135, 255}, // LightPaleRedOrange1 + 217: color.RGBA{255, 175, 175, 255}, // LightPaleRed1 + 218: color.RGBA{255, 175, 215, 255}, // LightPalePurpleRed + 219: color.RGBA{255, 175, 255, 255}, // LightPalePurple1 + 220: color.RGBA{255, 215, 0, 255}, // OrangeYellow + 221: color.RGBA{255, 215, 95, 255}, // PaleOrange + 222: color.RGBA{255, 215, 135, 255}, // LightPaleOrange + 223: color.RGBA{255, 215, 175, 255}, // LightPaleRedOrange + 224: color.RGBA{255, 215, 215, 255}, // LightPaleRed + 225: color.RGBA{255, 215, 255, 255}, // LightPalePurple + 226: color.RGBA{255, 255, 0, 255}, // Yellow1 + 227: color.RGBA{255, 255, 95, 255}, // PaleYellow + 228: color.RGBA{255, 255, 135, 255}, // LightPaleYellow2 + 229: color.RGBA{255, 255, 175, 255}, // LightPaleYellow1 + 230: color.RGBA{255, 255, 215, 255}, // LightPaleYellow + 231: color.RGBA{255, 255, 255, 255}, // White1 + 232: color.RGBA{8, 8, 8, 255}, // Gray4 + 233: color.RGBA{18, 18, 18, 255}, // Gray8 + 234: color.RGBA{28, 28, 28, 255}, // Gray11 + 235: color.RGBA{38, 38, 38, 255}, // Gray15 + 236: color.RGBA{48, 48, 48, 255}, // Gray19 + 237: color.RGBA{58, 58, 58, 255}, // Gray23 + 238: color.RGBA{68, 68, 68, 255}, // Gray27 + 239: color.RGBA{78, 78, 78, 255}, // Gray31 + 240: color.RGBA{88, 88, 88, 255}, // Gray35 + 241: color.RGBA{98, 98, 98, 255}, // Gray39 + 242: color.RGBA{108, 108, 108, 255}, // Gray43 + 243: color.RGBA{118, 118, 118, 255}, // Gray47 + 244: color.RGBA{128, 128, 128, 255}, // Gray51 + 245: color.RGBA{138, 138, 138, 255}, // Gray55 + 246: color.RGBA{148, 148, 148, 255}, // Gray59 + 247: color.RGBA{158, 158, 158, 255}, // Gray62 + 248: color.RGBA{168, 168, 168, 255}, // Gray66 + 249: color.RGBA{178, 178, 178, 255}, // Gray70 + 250: color.RGBA{188, 188, 188, 255}, // Gray74 + 251: color.RGBA{198, 198, 198, 255}, // Gray78 + 252: color.RGBA{208, 208, 208, 255}, // Gray82 + 253: color.RGBA{218, 218, 218, 255}, // Gray86 + 254: color.RGBA{228, 228, 228, 255}, // Gray90 + 255: color.RGBA{238, 238, 238, 255}, // Gray94 +} + +// DefaultPalette is the default palette used when decoding a Sixel image. +// It contains the 256 colors defined by the xterm 256-color palette. +func DefaultPalette() color.Palette { + // Undefined colors in sixel images use a set of default colors: 0-15 + // are sixel-specific, 16-255 are the same as the xterm 256-color values + palette := make([]color.Color, len(colorPalette)) + palette = append(palette, colorPalette[:]...) + return palette[:] +} diff --git a/ansi/sixel/encoder.go b/ansi/sixel/encoder.go new file mode 100644 index 00000000..8cb06ca2 --- /dev/null +++ b/ansi/sixel/encoder.go @@ -0,0 +1,279 @@ +package sixel + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/draw" + "io" + "strconv" + + "github.com/aymanbagabas/quant/median" + "github.com/bits-and-blooms/bitset" +) + +// Sixels are a protocol for writing images to the terminal by writing a large blob of ANSI-escaped data. +// They function by encoding columns of 6 pixels into a single character (in much the same way base64 +// encodes data 6 bits at a time). Sixel images are paletted, with a palette established at the beginning +// of the image blob and pixels identifying palette entires by index while writing the pixel data. +// +// Sixels are written one 6-pixel-tall band at a time, one color at a time. For each band, a single +// color's pixels are written, then a carriage return is written to bring the "cursor" back to the +// 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 = '$' + RepeatIntroducer byte = '!' + ColorIntroducer byte = '#' + RasterAttribute byte = '"' + + // MaxColors is the maximum number of colors that can be used in a Sixel + // image. + MaxColors = 256 +) + +// Encoder is a Sixel encoder. It encodes an image to Sixel data format. +type Encoder struct { + // NumColors is the number of colors to use in the palette. It ranges from + // 1 to 256. Zero or less means to use the default value of 256. + NumColors int + + // Quantizer is the color quantizer to use. The default is median cut. + Quantizer draw.Quantizer + + // NoTransparency is a flag that indicates whether to not add a transparent + // color to the palette. + NoTransparency bool + + // TransparentColor is the color to use for the transparent color in the + // palette. If nil, [color.Transparent] will be used. + // This field is ignored if [Encoder.AddTransparent] is false. + TransparentColor color.Color + + // Ditherer is the ditherer to use. A nil value means no dithering. + // A dither example is [draw.FloydSteinberg]. + Ditherer draw.Drawer +} + +// Encode will accept an Image and write sixel data to a Writer. The sixel data +// will be everything after the 'q' that ends the DCS parameters and before the ST +// that ends the sequence. That means it includes the pixel metrics and color +// palette. +func (e *Encoder) Encode(w io.Writer, img image.Image) error { + if img == nil { + return nil + } + + nc := e.NumColors + if nc <= 0 || nc > MaxColors { + nc = MaxColors + } + + var paletted *image.Paletted + if p, ok := img.(*image.Paletted); ok && len(p.Palette) < nc { + paletted = p + } else { + // make adaptive palette using median cut alogrithm + q := e.Quantizer + if q == nil { + q = median.Quantizer(nc) + } + + // Make sure we have a transparent color in the palette. + palette := color.Palette(make([]color.Color, 0, nc)) // preallocate one space for transparency + if !e.NoTransparency { + c := e.TransparentColor + if c == nil { + c = color.Transparent + } + palette = append(palette, c) + } + palette = q.Quantize(palette, img) // quantizer.Quantize will only use the remaining space (255 colors) + paletted = image.NewPaletted(img.Bounds(), palette) + + if e.Ditherer != nil { + e.Ditherer.Draw(paletted, img.Bounds(), img, image.Point{}) + } else { + draw.Draw(paletted, img.Bounds(), img, image.Point{}, draw.Over) + } + } + + imageBounds := img.Bounds() + imageWidth := imageBounds.Dx() + imageHeight := imageBounds.Dy() + + // Set the default raster 1:1 aspect ratio if it's not set + if _, err := WriteRaster(w, 1, 1, imageWidth, imageHeight); err != nil { + return fmt.Errorf("error encoding raster: %w", err) + } + + // Write palette colors + for i, c := range paletted.Palette { + c := FromColor(c) + // Always use RGB format "2" + if _, err := WriteColor(w, i, c.Pu, c.Px, c.Py, c.Pz); err != nil { + return fmt.Errorf("error encoding color: %w", err) + } + } + + var pixelBands bitset.BitSet + bandHeight := bandHeight(img) + + // Write pixel data to bitset. + for y := 0; y < imageHeight; y++ { + for x := 0; x < imageWidth; x++ { + index := paletted.ColorIndexAt(x, y) + setColor(&pixelBands, x, y, imageWidth, bandHeight, int(index)) + } + } + + return newEncoder(w, &pixelBands).writePixelData(img, paletted.Palette) +} + +// setColor will write a single pixel to the bitset data to be used by +// [encoder.writePixelData]. +func setColor(bands *bitset.BitSet, x int, y int, imageWidth int, bandHeight int, paletteIndex int) { + bandY := y / 6 + bit := bandHeight*imageWidth*6*paletteIndex + bandY*imageWidth*6 + (x * 6) + (y % 6) + bands.Set(uint(bit)) //nolint:gosec +} + +func bandHeight(img image.Image) int { + imageHeight := img.Bounds().Dy() + bandHeight := imageHeight / 6 + if imageHeight%6 != 0 { + bandHeight++ + } + return bandHeight +} + +// encoder is the internal encoder used to write sixel pixel data to a writer. +type encoder struct { + w io.Writer + + bands *bitset.BitSet + + repeatCount int + repeatChar byte +} + +func newEncoder(w io.Writer, bands *bitset.BitSet) *encoder { + return &encoder{ + w: w, + bands: bands, + } +} + +// writePixelData will write the image pixel data to the writer. +func (e *encoder) writePixelData(img image.Image, palette color.Palette) error { + imageWidth := img.Bounds().Dx() + bandHeight := bandHeight(img) + for bandY := 0; bandY < bandHeight; bandY++ { + if bandY > 0 { + e.writeControlRune(LineBreak) + } + + hasWrittenAColor := false + + for paletteIndex := 0; paletteIndex < len(palette); paletteIndex++ { + c := palette[paletteIndex] + _, _, _, a := c.RGBA() + if a == 0 { + // Don't draw anything for purely transparent pixels + continue + } + + firstColorBit := uint(bandHeight*imageWidth*6*paletteIndex + bandY*imageWidth*6) //nolint:gosec + nextColorBit := firstColorBit + uint(imageWidth*6) //nolint:gosec + + firstSetBitInBand, anySet := e.bands.NextSet(firstColorBit) + if !anySet || firstSetBitInBand >= nextColorBit { + // Color not appearing in this row + continue + } + + if hasWrittenAColor { + e.writeControlRune(CarriageReturn) + } + hasWrittenAColor = true + + e.writeControlRune(ColorIntroducer) + io.WriteString(e.w, strconv.Itoa(paletteIndex)) //nolint:errcheck + + for x := 0; x < imageWidth; x += 4 { + bit := firstColorBit + uint(x*6) //nolint:gosec + word := e.bands.GetWord64AtBit(bit) + + pixel1 := byte((word & 63) + '?') + pixel2 := byte(((word >> 6) & 63) + '?') + pixel3 := byte(((word >> 12) & 63) + '?') + pixel4 := byte(((word >> 18) & 63) + '?') + + e.writeImageRune(pixel1) + + if x+1 >= imageWidth { + continue + } + e.writeImageRune(pixel2) + + if x+2 >= imageWidth { + continue + } + e.writeImageRune(pixel3) + + if x+3 >= imageWidth { + continue + } + e.writeImageRune(pixel4) + } + } + } + + e.writeControlRune('-') + return nil +} + +// writeImageRune will write a single line of six pixels to pixel data. The data +// doesn't get written to the imageData, it gets buffered for the purposes of RLE +func (e *encoder) writeImageRune(r byte) { //nolint:revive + if r == e.repeatChar { + e.repeatCount++ + return + } + + e.flushRepeats() + e.repeatChar = r + e.repeatCount = 1 +} + +// writeControlRune will write a special rune such as a new line or carriage return +// rune. It will call flushRepeats first, if necessary. +func (e *encoder) writeControlRune(r byte) { + if e.repeatCount > 0 { + e.flushRepeats() + e.repeatCount = 0 + e.repeatChar = 0 + } + + e.w.Write([]byte{r}) //nolint:errcheck +} + +// flushRepeats is used to actually write the current repeatByte to the imageData when +// it is about to change. This buffering is used to manage RLE in the sixelBuilder +func (e *encoder) flushRepeats() { + if e.repeatCount == 0 { + return + } + + // Only write using the RLE form if it's actually providing space savings + if e.repeatCount > 3 { + WriteRepeat(e.w, e.repeatCount, e.repeatChar) //nolint:errcheck + return + } + + e.w.Write(bytes.Repeat([]byte{e.repeatChar}, e.repeatCount)) //nolint:errcheck +} 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 new file mode 100644 index 00000000..a59176f4 --- /dev/null +++ b/ansi/sixel/sixel_bench_test.go @@ -0,0 +1,69 @@ +package sixel + +import ( + "bytes" + "fmt" + "image" + "image/png" + "io" + "os" + "testing" + + "github.com/charmbracelet/x/ansi" + gosixel "github.com/mattn/go-sixel" +) + +func BenchmarkEncodingGoSixel(b *testing.B) { + for i := 0; i < b.N; i++ { + raw, err := loadImage("./../fixtures/graphics/JigokudaniMonkeyPark.png") + if err != nil { + 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) + } +} + +func writeSixelGraphics(w io.Writer, m image.Image) error { + e := &Encoder{} + + 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 +} + +func BenchmarkEncodingXSixel(b *testing.B) { + for i := 0; i < b.N; i++ { + raw, err := loadImage("./../fixtures/graphics/JigokudaniMonkeyPark.png") + if err != nil { + 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) + } +} + +func loadImage(path string) (image.Image, error) { + 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 new file mode 100644 index 00000000..466be09b --- /dev/null +++ b/ansi/sixel/sixel_test.go @@ -0,0 +1,207 @@ +package sixel + +import ( + "bytes" + "image" + "image/color" + "testing" +) + +func TestScanSize(t *testing.T) { + testCases := map[string]struct { + data string + expectedWidth int + expectedHeight int + }{ + "two lines": { + "~~~~~~-~~~~~~-", 6, 12, + }, + "two lines no newline at end": { + "~~~~~~-~~~~~~", 6, 12, + }, + "no pixels": { + "", 0, 0, + }, + "smaller carriage returns": { + "~$~~$~~~$~~~~$~~~~~$~~~~~~", 6, 6, + }, + "transparent": { + "??????", 6, 6, + }, + "RLE": { + "??!20?", 22, 6, + }, + "Colors": { + "#0;2;0;0;0~~~~~$#1;2;100;100;100;~~~~~~-#0~~~~~~-#1~~~~~~", 6, 18, + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + decoder := &Decoder{} + width, height := decoder.scanSize([]byte(testCase.data)) + if width != testCase.expectedWidth { + t.Errorf("expected width of %d, but received width of %d", testCase.expectedWidth, width) + return + } + + if height != testCase.expectedHeight { + t.Errorf("expected height of %d, but received height of %d", testCase.expectedHeight, height) + return + } + }) + } +} + +func TestFullImage(t *testing.T) { + testCases := map[string]struct { + imageWidth int + imageHeight int + bandCount int + // When filling the image, we'll use a map of indices to colors and change colors every + // time the current index is in the map- this will prevent dozens of lines with the same color + // in a row and make this slightly more legible + colors map[int]color.RGBA + }{ + "3x12 single color filled": { + 3, 12, 2, + map[int]color.RGBA{ + 0: {255, 0, 0, 255}, + }, + }, + "3x12 two color filled": { + 3, 12, 2, + map[int]color.RGBA{ + // 3-pixel high alternating bands + 0: {0, 0, 255, 255}, + 9: {0, 255, 0, 255}, + 18: {0, 0, 255, 255}, + 27: {0, 255, 0, 255}, + }, + }, + "3x12 8 color with right gutter": { + 3, 12, 2, + map[int]color.RGBA{ + 0: {255, 0, 0, 255}, + 2: {0, 255, 0, 255}, + 3: {255, 0, 0, 255}, + 5: {0, 255, 0, 255}, + 6: {255, 0, 0, 255}, + 8: {0, 255, 0, 255}, + 9: {0, 0, 255, 255}, + 11: {128, 128, 0, 255}, + 12: {0, 0, 255, 255}, + 14: {128, 128, 0, 255}, + 15: {0, 0, 255, 255}, + 17: {128, 128, 0, 255}, + 18: {0, 128, 128, 255}, + 20: {128, 0, 128, 255}, + 21: {0, 128, 128, 255}, + 23: {128, 0, 128, 255}, + 24: {0, 128, 128, 255}, + 26: {128, 0, 128, 255}, + 27: {64, 0, 0, 255}, + 29: {0, 64, 0, 255}, + 30: {64, 0, 0, 255}, + 32: {0, 64, 0, 255}, + 33: {64, 0, 0, 255}, + 35: {0, 64, 0, 255}, + }, + }, + "3x12 single color with transparent band in the middle": { + 3, 12, 2, + map[int]color.RGBA{ + 0: {255, 0, 0, 255}, + 15: {0, 0, 0, 0}, + 21: {255, 0, 0, 255}, + }, + }, + "3x5 single color": { + 3, 5, 1, + map[int]color.RGBA{ + 0: {255, 0, 0, 255}, + }, + }, + "12x4 single color use RLE": { + 12, 4, 1, + map[int]color.RGBA{ + 0: {255, 0, 0, 255}, + }, + }, + "12x1 two color use RLE": { + 12, 1, 1, + map[int]color.RGBA{ + 0: {255, 0, 0, 255}, + 6: {0, 255, 0, 255}, + }, + }, + "12x12 single color use RLE": { + 12, 12, 2, + map[int]color.RGBA{ + 0: {255, 0, 0, 255}, + }, + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, testCase.imageWidth, testCase.imageHeight)) + + currentColor := color.RGBA{0, 0, 0, 0} + for y := 0; y < testCase.imageHeight; y++ { + for x := 0; x < testCase.imageWidth; x++ { + index := y*testCase.imageWidth + x + newColor, changingColor := testCase.colors[index] + if changingColor { + currentColor = newColor + } + + img.Set(x, y, currentColor) + } + } + + buffer := bytes.NewBuffer(nil) + encoder := Encoder{} + decoder := Decoder{} + + err := encoder.Encode(buffer, img) + if err != nil { + t.Errorf("Unexpected error: %+v", err) + return + } + + compareImg, err := decoder.Decode(buffer) + if err != nil { + t.Errorf("Unexpected error: %+v", err) + return + } + + expectedWidth := img.Bounds().Dx() + expectedHeight := img.Bounds().Dy() + actualWidth := compareImg.Bounds().Dx() + actualHeight := compareImg.Bounds().Dy() + + if actualHeight != expectedHeight { + t.Errorf("SixelImage had a height of %d, but a height of %d was expected", actualHeight, expectedHeight) + return + } + if actualWidth != expectedWidth { + t.Errorf("SixelImage had a width of %d, but a width of %d was expected", actualWidth, expectedWidth) + return + } + + for y := 0; y < expectedHeight; y++ { + for x := 0; x < expectedWidth; x++ { + r, g, b, a := compareImg.At(x, y).RGBA() + expectedR, expectedG, expectedB, expectedA := img.At(x, y).RGBA() + + if r != expectedR || g != expectedG || b != expectedB || a != expectedA { + t.Errorf("SixelImage had color (%d,%d,%d,%d) at coordinates (%d,%d), but color (%d,%d,%d,%d) was expected", + r, g, b, a, x, y, expectedR, expectedG, expectedB, expectedA) + 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 +} diff --git a/examples/img2term/main.go b/examples/img2term/main.go new file mode 100644 index 00000000..1210e97a --- /dev/null +++ b/examples/img2term/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "bytes" + "flag" + "image" + "io" + "log" + "os" + + _ "image/jpeg" + _ "image/png" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/sixel" +) + +func main() { + flag.Parse() + args := flag.Args() + if len(args) == 0 { + flag.Usage() + os.Exit(1) + } + + f, err := os.Open(args[0]) + if err != nil { + log.Fatal(err) + } + + defer f.Close() //nolint:errcheck + img, _, err := image.Decode(f) + if err != nil { + log.Fatal(err) + } + + if _, err := writeSixel(os.Stdout, img); err != nil { + log.Fatal(err) + } +} + +func writeSixel(w io.Writer, img image.Image) (int, error) { + var buf bytes.Buffer + var e sixel.Encoder + if err := e.Encode(&buf, img); err != nil { + return 0, err + } + + return io.WriteString(w, ansi.SixelGraphics(0, 1, 0, buf.Bytes())) +}