From ed43f812eb73cc0682f881c0a68c68ce6cceb0e4 Mon Sep 17 00:00:00 2001 From: Stephen Baynham Date: Thu, 30 Jan 2025 08:26:30 -0800 Subject: [PATCH 1/7] feat: Add sixels to ansi package --- ansi/go.mod | 1 + ansi/go.sum | 2 + ansi/graphics.go | 45 ++++ ansi/sixel/decoder.go | 506 +++++++++++++++++++++++++++++++++++++ ansi/sixel/encoder.go | 262 +++++++++++++++++++ ansi/sixel/palette.go | 352 ++++++++++++++++++++++++++ ansi/sixel/palette_test.go | 124 +++++++++ ansi/sixel/sixel_test.go | 161 ++++++++++++ 8 files changed, 1453 insertions(+) create mode 100644 ansi/sixel/decoder.go create mode 100644 ansi/sixel/encoder.go create mode 100644 ansi/sixel/palette.go create mode 100644 ansi/sixel/palette_test.go create mode 100644 ansi/sixel/sixel_test.go diff --git a/ansi/go.mod b/ansi/go.mod index d5d4b6e6..bdf89d0e 100644 --- a/ansi/go.mod +++ b/ansi/go.mod @@ -3,6 +3,7 @@ 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/rivo/uniseg v0.4.7 diff --git a/ansi/go.sum b/ansi/go.sum index 4dfe154f..20dc59e7 100644 --- a/ansi/go.sum +++ b/ansi/go.sum @@ -1,3 +1,5 @@ +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= diff --git a/ansi/graphics.go b/ansi/graphics.go index 604fef47..a2504cbc 100644 --- a/ansi/graphics.go +++ b/ansi/graphics.go @@ -11,8 +11,53 @@ import ( "strings" "github.com/charmbracelet/x/ansi/kitty" + "github.com/charmbracelet/x/ansi/sixel" ) +// 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(payload []byte) string { + var buf bytes.Buffer + + buf.WriteString("\x1bP0;1;0;q") + buf.Write(payload) + buf.WriteString("\x1b\\") + + return buf.String() +} + +// WriteSixelGraphics encodes an image as sixels into the provided io.Writer. +// Options is provided in expectation of future options (such as dithering), but +// none are yet implemented. o can be nil to use the default options. +func WriteSixelGraphics(w io.Writer, m image.Image, o *sixel.Options) error { + if o == nil { + o = &sixel.Options{} + } + + e := &sixel.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, SixelGraphics(data.Bytes())) + return err +} + // KittyGraphics returns a sequence that encodes the given image in the Kitty // graphics protocol. // diff --git a/ansi/sixel/decoder.go b/ansi/sixel/decoder.go new file mode 100644 index 00000000..68a66c09 --- /dev/null +++ b/ansi/sixel/decoder.go @@ -0,0 +1,506 @@ +package sixel + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/color" + "io" +) + +// buildDefaultDecodePalette will build a map that we'll use as the palette during +// the decoding process- it's pre-loaded with the default colors for sixels, in case +// we are decoding a legacy sixel image that doesn't define its own colors (technically +// permitted). +func buildDefaultDecodePalette() map[int]color.Color { + + // 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 + return map[int]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 + } +} + +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 +// 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(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() + if err != nil { + return nil, d.readError(err) + } + + var bounds image.Rectangle + if b == '"' { + var fixedWidth, fixedHeight int + // We have pixel dimensions + _, err := fmt.Fscanf(buffer, "1;1;%d;%d", &fixedWidth, &fixedHeight) + if err != nil { + return nil, d.readError(err) + } + + bounds = image.Rect(0, 0, fixedWidth, fixedHeight) + } else { + // We're parsing the image with no pixel metrics so unread the byte for the + // main read loop + _ = buffer.UnreadByte() + + width, height := d.scanSize(data) + bounds = image.Rect(0, 0, width, height) + } + + img := image.NewRGBA(bounds) + palette := buildDefaultDecodePalette() + var currentX, currentBandY, currentPaletteIndex int + + for { + b, err := buffer.ReadByte() + if err != nil { + return img, d.readError(err) + } + + // Palette operation + if b == sixelUseColor { + _, 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) + } + + if b != ';' { + // If we're not defining a color, move on + _ = buffer.UnreadByte() + continue + } + + 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) + } + + if red > 100 || green > 100 || blue > 100 { + return img, fmt.Errorf("invalid palette color: %d,%d,%d", red, green, blue) + } + + palette[currentPaletteIndex] = color.RGBA64{ + R: uint16(imageConvertChannel(red)), + G: uint16(imageConvertChannel(green)), + B: uint16(imageConvertChannel(blue)), + A: 65525, + } + + continue + } + + // LF + if b == sixelLineBreak { + currentBandY++ + currentX = 0 + continue + } + + // CR + if b == sixelCarriageReturn { + currentX = 0 + continue + } + + // RLE operation + count := 1 + if b == sixelRepeat { + _, err = fmt.Fscan(buffer, &count) + if err != nil { + return img, d.readError(err) + } + + // The next byte SHOULD be a pixel + b, err = buffer.ReadByte() + if err != nil { + return img, d.readError(err) + } + } + + if 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 + 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 + // 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 + var newBand bool + for { + b, err := buffer.ReadByte() + if err != nil { + return maxWidth, bandCount * 6 + } + + if b == '-' { + // 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 == '$' { + // CR + currentWidth = 0 + } else if b == '!' || (b >= '?' && b <= '~') { + // Either an RLE operation or a single pixel + + var count int + if b == '!' { + // Get the run length for the RLE operation + _, err := fmt.Fscan(buffer, &count) + if err != nil { + 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 + } + + currentWidth += count + + if newBand { + newBand = false + bandCount++ + } + if currentWidth > maxWidth { + maxWidth = currentWidth + } + } + } +} + +// 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) +} diff --git a/ansi/sixel/encoder.go b/ansi/sixel/encoder.go new file mode 100644 index 00000000..ef60eea5 --- /dev/null +++ b/ansi/sixel/encoder.go @@ -0,0 +1,262 @@ +package sixel + +import ( + "image" + "image/color" + "io" + "strconv" + "strings" + + "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. + +const ( + sixelLineBreak byte = '-' + sixelCarriageReturn byte = '$' + sixelRepeat byte = '!' + sixelUseColor byte = '#' +) + +type Options struct { +} + +type Encoder struct { +} + +// 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 + } + + imageBounds := img.Bounds() + + io.WriteString(w, "\"1;1;") //nolint:errcheck + io.WriteString(w, strconv.Itoa(imageBounds.Dx())) //nolint:errcheck + w.Write([]byte{';'}) //nolint:errcheck + io.WriteString(w, strconv.Itoa(imageBounds.Dy())) //nolint:errcheck + + palette := newSixelPalette(img, sixelMaxColors) + + for paletteIndex, color := range palette.PaletteColors { + e.encodePaletteColor(w, paletteIndex, color) + } + + scratch := newSixelBuilder(imageBounds.Dx(), imageBounds.Dy(), palette) + + for y := 0; y < imageBounds.Dy(); y++ { + for x := 0; x < imageBounds.Dx(); x++ { + scratch.SetColor(x, y, img.At(x, y)) + } + } + + pixels := scratch.GeneratePixels() + io.WriteString(w, pixels) //nolint:errcheck + + return nil +} + +func (e *Encoder) encodePaletteColor(w io.Writer, paletteIndex int, c sixelColor) { + // Initializing palette entries + // #;;;; + // a = palette index + // b = color type, 2 is RGB + // c = R + // d = G + // e = B + + w.Write([]byte{sixelUseColor}) //nolint:errcheck + io.WriteString(w, strconv.Itoa(paletteIndex)) //nolint:errcheck + io.WriteString(w, ";2;") + io.WriteString(w, strconv.Itoa(int(c.Red))) //nolint:errcheck + w.Write([]byte{';'}) //nolint:errcheck + io.WriteString(w, strconv.Itoa(int(c.Green))) //nolint:errcheck + w.Write([]byte{';'}) + io.WriteString(w, strconv.Itoa(int(c.Blue))) //nolint:errcheck +} + +// sixelBuilder is a temporary structure used to create a SixelImage. It handles +// breaking pixels out into bits, and then encoding them into a sixel data string. RLE +// handling is included. +// +// Making use of a sixelBuilder is done in two phases. First, SetColor is used to write all +// pixels to the internal BitSet data. Then, GeneratePixels is called to retrieve a string +// representing the pixel data encoded in the sixel format. +type sixelBuilder struct { + SixelPalette sixelPalette + + imageHeight int + imageWidth int + + pixelBands bitset.BitSet + + imageData strings.Builder + repeatRune rune + repeatCount int +} + +// newSixelBuilder creates a sixelBuilder and prepares it for writing +func newSixelBuilder(width, height int, palette sixelPalette) sixelBuilder { + scratch := sixelBuilder{ + imageWidth: width, + imageHeight: height, + SixelPalette: palette, + } + + return scratch +} + +// BandHeight returns the number of six-pixel bands this image consists of +func (s *sixelBuilder) BandHeight() int { + bandHeight := s.imageHeight / 6 + if s.imageHeight%6 != 0 { + bandHeight++ + } + + return bandHeight +} + +// SetColor will write a single pixel to sixelBuilder's internal bitset data to be used by +// GeneratePixels +func (s *sixelBuilder) SetColor(x int, y int, color color.Color) { + bandY := y / 6 + paletteIndex := s.SixelPalette.ColorIndex(sixelConvertColor(color)) + + bit := s.BandHeight()*s.imageWidth*6*paletteIndex + bandY*s.imageWidth*6 + (x * 6) + (y % 6) + s.pixelBands.Set(uint(bit)) +} + +// GeneratePixels is used to write the pixel data to the internal imageData string builder. +// All pixels in the image must be written to the sixelBuilder using SetColor before this method is +// called. This method returns a string that represents the pixel data. Sixel strings consist of five parts: +// ISC
ST +// The header contains some arbitrary options indicating how the sixel image is to be drawn. +// The palette maps palette indices to RGB colors +// The pixels indicates which pixels are to be drawn with which palette colors. +// +// GeneratePixels only produces the part of the string. The rest is written by +// Style.RenderSixelImage. +func (s *sixelBuilder) GeneratePixels() string { + s.imageData = strings.Builder{} + bandHeight := s.BandHeight() + + for bandY := 0; bandY < bandHeight; bandY++ { + if bandY > 0 { + s.writeControlRune(sixelLineBreak) + } + + hasWrittenAColor := false + + for paletteIndex := 0; paletteIndex < len(s.SixelPalette.PaletteColors); paletteIndex++ { + if s.SixelPalette.PaletteColors[paletteIndex].Alpha < 1 { + // Don't draw anything for purely transparent pixels + continue + } + + firstColorBit := uint(s.BandHeight()*s.imageWidth*6*paletteIndex + bandY*s.imageWidth*6) + nextColorBit := firstColorBit + uint(s.imageWidth*6) + + firstSetBitInBand, anySet := s.pixelBands.NextSet(firstColorBit) + if !anySet || firstSetBitInBand >= nextColorBit { + // Color not appearing in this row + continue + } + + if hasWrittenAColor { + s.writeControlRune(sixelCarriageReturn) + } + hasWrittenAColor = true + + s.writeControlRune(sixelUseColor) + s.imageData.WriteString(strconv.Itoa(paletteIndex)) + for x := 0; x < s.imageWidth; x += 4 { + bit := firstColorBit + uint(x*6) + word := s.pixelBands.GetWord64AtBit(bit) + + pixel1 := rune((word & 63) + '?') + pixel2 := rune(((word >> 6) & 63) + '?') + pixel3 := rune(((word >> 12) & 63) + '?') + pixel4 := rune(((word >> 18) & 63) + '?') + + s.writeImageRune(pixel1) + + if x+1 >= s.imageWidth { + continue + } + s.writeImageRune(pixel2) + + if x+2 >= s.imageWidth { + continue + } + s.writeImageRune(pixel3) + + if x+3 >= s.imageWidth { + continue + } + s.writeImageRune(pixel4) + } + } + } + + s.writeControlRune('-') + return s.imageData.String() +} + +// 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 (s *sixelBuilder) writeImageRune(r rune) { + if r == s.repeatRune { + s.repeatCount++ + return + } + + s.flushRepeats() + s.repeatRune = r + s.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 (s *sixelBuilder) writeControlRune(r byte) { + if s.repeatCount > 0 { + s.flushRepeats() + s.repeatCount = 0 + s.repeatRune = 0 + } + + s.imageData.WriteByte(r) +} + +// flushRepeats is used to actually write the current repeatRune to the imageData when +// it is about to change. This buffering is used to manage RLE in the sixelBuilder +func (s *sixelBuilder) flushRepeats() { + if s.repeatCount == 0 { + return + } + + // Only write using the RLE form if it's actually providing space savings + if s.repeatCount > 3 { + countStr := strconv.Itoa(s.repeatCount) + s.imageData.WriteByte(sixelRepeat) + s.imageData.WriteString(countStr) + s.imageData.WriteRune(s.repeatRune) + return + } + + for i := 0; i < s.repeatCount; i++ { + s.imageData.WriteRune(s.repeatRune) + } +} diff --git a/ansi/sixel/palette.go b/ansi/sixel/palette.go new file mode 100644 index 00000000..c7908fcb --- /dev/null +++ b/ansi/sixel/palette.go @@ -0,0 +1,352 @@ +package sixel + +import ( + "cmp" + "container/heap" + "image" + "image/color" + "math" + "slices" +) + +// sixelPalette is a palette of up to 256 colors that lists the colors that will be used by +// a SixelImage. Most images, especially jpegs, have more than 256 colors, so creating a sixelPalette +// requires the use of color quantization. For this we use the Median Cut algorithm. +// +// Median cut requires all pixels in an image to be positioned in a 4D color cube, with one axis per channel. +// The cube is sliced in half along its longest axis such that half the pixels in the cube end up in one of +// the sub-cubes and half end up in the other. We continue slicing the cube with the longest axis in half +// along that axis until there are 256 sub-cubes. Then, the average of all pixels in each subcube is used +// as that cube's color. +// +// Colors are converted to palette colors based on which they are closest to (it's not +// always their cube's color). +// +// This implementation has a few minor (but seemingly very common) differences from the Official algorithm: +// - When determining the longest axis, the number of pixels in the cube are multiplied against axis length +// This improves color selection by quite a bit in cases where an image has a lot of space taken up by different +// shades of the same color. +// - If a single color sits on a cut line, all pixels of that color are assigned to one of the subcubes +// rather than try to split them up between the subcubes. This allows us to use a slice of unique colors +// and a map of pixel counts rather than try to represent each pixel individually. +type sixelPalette struct { + // Map used to convert colors from the image to palette colors + colorConvert map[sixelColor]sixelColor + // Lookup to get palette index from image color + paletteIndexes map[sixelColor]int + PaletteColors []sixelColor +} + +// quantizationChannel is an enum type which indicates an axis in the color cube. Used to indicate which +// axis in a cube is the longest +type quantizationChannel int + +const ( + sixelMaxColors int = 256 + quantizationRed quantizationChannel = iota + quantizationGreen + quantizationBlue + quantizationAlpha +) + +// quantizationCube represents a single cube in the median cut algorithm. +type quantizationCube struct { + // startIndex is the index in the uniqueColors slice where this cube starts + startIndex int + // length is the number of elements in the uniqueColors slice this cube occupies + length int + // sliceChannel is the axis that will be cut if this cube is cut in half + sliceChannel quantizationChannel + // score is a heuristic value: higher means this cube is more likely to be cut + score uint64 + // pixelCount is how many pixels are contained in this cube + pixelCount uint64 +} + +// cubePriorityQueue is a heap used to sort quantizationCube objects in order to select the correct +// one to cut next. Pop will remove the queue with the highest score +type cubePriorityQueue []any + +func (p *cubePriorityQueue) Push(x any) { + *p = append(*p, x) +} + +func (p *cubePriorityQueue) Pop() any { + popped := (*p)[len(*p)-1] + *p = (*p)[:len(*p)-1] + return popped +} + +func (p *cubePriorityQueue) Len() int { + return len(*p) +} + +func (p *cubePriorityQueue) Less(i, j int) bool { + left := (*p)[i].(quantizationCube) + right := (*p)[j].(quantizationCube) + + // We want the largest channel variance + return left.score > right.score +} + +func (p *cubePriorityQueue) Swap(i, j int) { + (*p)[i], (*p)[j] = (*p)[j], (*p)[i] +} + +// createCube is used to initialize a new quantizationCube containing a region of the uniqueColors slice +func (p *sixelPalette) createCube(uniqueColors []sixelColor, pixelCounts map[sixelColor]uint64, startIndex, bucketLength int) quantizationCube { + minRed, minGreen, minBlue, minAlpha := uint32(0xffff), uint32(0xffff), uint32(0xffff), uint32(0xffff) + maxRed, maxGreen, maxBlue, maxAlpha := uint32(0), uint32(0), uint32(0), uint32(0) + totalWeight := uint64(0) + + // Figure out which channel has the greatest variance + for i := startIndex; i < startIndex+bucketLength; i++ { + r, g, b, a := uniqueColors[i].Red, uniqueColors[i].Green, uniqueColors[i].Blue, uniqueColors[i].Alpha + totalWeight += pixelCounts[uniqueColors[i]] + + if r < minRed { + minRed = r + } + if r > maxRed { + maxRed = r + } + if g < minGreen { + minGreen = g + } + if g > maxGreen { + maxGreen = g + } + if b < minBlue { + minBlue = b + } + if b > maxBlue { + maxBlue = b + } + if a < minAlpha { + minAlpha = a + } + if a > maxAlpha { + maxAlpha = a + } + } + + dRed := maxRed - minRed + dGreen := maxGreen - minGreen + dBlue := maxBlue - minBlue + dAlpha := maxAlpha - minAlpha + + cube := quantizationCube{ + startIndex: startIndex, + length: bucketLength, + pixelCount: totalWeight, + } + + if dRed >= dGreen && dRed >= dBlue && dRed >= dAlpha { + cube.sliceChannel = quantizationRed + cube.score = uint64(dRed) + } else if dGreen >= dBlue && dGreen >= dAlpha { + cube.sliceChannel = quantizationGreen + cube.score = uint64(dGreen) + } else if dBlue >= dAlpha { + cube.sliceChannel = quantizationBlue + cube.score = uint64(dBlue) + } else { + cube.sliceChannel = quantizationAlpha + cube.score = uint64(dAlpha) + } + + // Boost the score of cubes with more pixels in them + cube.score *= totalWeight + + return cube +} + +// quantize is a method that will initialize the palette's colors and lookups, provided a set +// of unique colors and a map containing pixel counts for those colors +func (p *sixelPalette) quantize(uniqueColors []sixelColor, pixelCounts map[sixelColor]uint64, maxColors int) { + p.colorConvert = make(map[sixelColor]sixelColor) + p.paletteIndexes = make(map[sixelColor]int) + + // We don't need to quantize if we don't even have more than the maximum colors, and in fact, this code will explode + // if we have fewer than maximum colors + if len(uniqueColors) <= maxColors { + p.PaletteColors = uniqueColors + return + } + + cubeHeap := make(cubePriorityQueue, 0, maxColors) + + // Start with a cube that contains all colors + heap.Init(&cubeHeap) + heap.Push(&cubeHeap, p.createCube(uniqueColors, pixelCounts, 0, len(uniqueColors))) + + // Slice the best cube into two cubes until we have max colors, then we have our palette + for cubeHeap.Len() < maxColors { + cubeToSplit := heap.Pop(&cubeHeap).(quantizationCube) + + // Sort the colors in the bucket's range along the cube's longest color axis + slices.SortFunc(uniqueColors[cubeToSplit.startIndex:cubeToSplit.startIndex+cubeToSplit.length], + func(left sixelColor, right sixelColor) int { + switch cubeToSplit.sliceChannel { + case quantizationRed: + return cmp.Compare(left.Red, right.Red) + case quantizationGreen: + return cmp.Compare(left.Green, right.Green) + case quantizationBlue: + return cmp.Compare(left.Blue, right.Blue) + default: + return cmp.Compare(left.Alpha, right.Alpha) + } + }) + + // We need to split up the colors in this cube so that the pixels are evenly split between the two, + // or at least as close as we can reasonably get. What we do is count up the pixels as we go through + // and place the cut point where around half of the pixels are on the left side + countSoFar := pixelCounts[uniqueColors[cubeToSplit.startIndex]] + targetCount := cubeToSplit.pixelCount / 2 + leftLength := 1 + + for i := cubeToSplit.startIndex + 1; i < cubeToSplit.startIndex+cubeToSplit.length; i++ { + c := uniqueColors[i] + weight := pixelCounts[c] + if countSoFar+weight > targetCount { + break + } + leftLength++ + countSoFar += weight + } + + rightLength := cubeToSplit.length - leftLength + rightIndex := cubeToSplit.startIndex + leftLength + heap.Push(&cubeHeap, p.createCube(uniqueColors, pixelCounts, cubeToSplit.startIndex, leftLength)) + heap.Push(&cubeHeap, p.createCube(uniqueColors, pixelCounts, rightIndex, rightLength)) + } + + // Once we've got max cubes in the heap, pull them all out and load them into the palette + for cubeHeap.Len() > 0 { + bucketToLoad := heap.Pop(&cubeHeap).(quantizationCube) + p.loadColor(uniqueColors, pixelCounts, bucketToLoad.startIndex, bucketToLoad.length) + } +} + +// ColorIndex accepts a raw image color (NOT a palette color) and provides the palette index of that color +func (p *sixelPalette) ColorIndex(c sixelColor) int { + return p.paletteIndexes[c] +} + +// loadColor accepts a range of colors representing a single median cut cube. It calculates the +// average color in the cube and adds it to the palette. +func (p *sixelPalette) loadColor(uniqueColors []sixelColor, pixelCounts map[sixelColor]uint64, startIndex, cubeLen int) { + totalRed, totalGreen, totalBlue, totalAlpha := uint64(0), uint64(0), uint64(0), uint64(0) + totalCount := uint64(0) + for i := startIndex; i < startIndex+cubeLen; i++ { + count := pixelCounts[uniqueColors[i]] + totalRed += uint64(uniqueColors[i].Red) * count + totalGreen += uint64(uniqueColors[i].Green) * count + totalBlue += uint64(uniqueColors[i].Blue) * count + totalAlpha += uint64(uniqueColors[i].Alpha) * count + totalCount += count + } + + averageColor := sixelColor{ + Red: uint32(totalRed / totalCount), + Green: uint32(totalGreen / totalCount), + Blue: uint32(totalBlue / totalCount), + Alpha: uint32(totalAlpha / totalCount), + } + + p.PaletteColors = append(p.PaletteColors, averageColor) +} + +// sixelColor is a flat struct that contains a single color: all channels are 0-100 +// instead of anything sensible +type sixelColor struct { + Red uint32 + Green uint32 + Blue uint32 + Alpha uint32 +} + +// sixelConvertColor accepts an ordinary Go color and converts it to a sixelColor, which +// has channels ranging from 0-100 +func sixelConvertColor(c color.Color) sixelColor { + r, g, b, a := c.RGBA() + return sixelColor{ + Red: sixelConvertChannel(r), + Green: sixelConvertChannel(g), + Blue: sixelConvertChannel(b), + Alpha: sixelConvertChannel(a), + } +} + +// sixelConvertChannel converts a single color channel from go's standard 0-0xffff range to +// sixel's 0-100 range +func sixelConvertChannel(channel 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 (channel + 328) * 100 / 0xffff +} + +// imageConvertChannel converts a single color channel from sixel's 0-100 range to +// go's standard 0-0xffff range +func imageConvertChannel(channel uint32) uint32 { + return (channel*0xffff + 50) / 100 +} + +// newSixelPalette accepts an image and produces an N-color quantized color palette using the median cut +// algorithm. The produced sixelPalette can convert colors from the image to the quantized palette +// in O(1) time. +func newSixelPalette(image image.Image, maxColors int) sixelPalette { + pixelCounts := make(map[sixelColor]uint64) + + height := image.Bounds().Dy() + width := image.Bounds().Dx() + + // Record pixel counts for every color while also getting a set of all unique colors in the image + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + c := sixelConvertColor(image.At(x, y)) + count, _ := pixelCounts[c] + count++ + + pixelCounts[c] = count + } + } + + p := sixelPalette{} + uniqueColors := make([]sixelColor, 0, len(pixelCounts)) + for c := range pixelCounts { + uniqueColors = append(uniqueColors, c) + } + + // Build up p.PaletteColors using the median cut algorithm + p.quantize(uniqueColors, pixelCounts, maxColors) + + // The average color for a cube a color occupies is not always the closest palette color. As a result, + // we need to use this very upsetting double loop to find the lookup palette color for each + // unique color in the image. + for _, c := range uniqueColors { + var bestColor sixelColor + var bestColorIndex int + bestScore := uint32(math.MaxUint32) + + for paletteIndex, paletteColor := range p.PaletteColors { + redDiff := c.Red - paletteColor.Red + greenDiff := c.Green - paletteColor.Green + blueDiff := c.Blue - paletteColor.Blue + alphaDiff := c.Alpha - paletteColor.Alpha + + score := (redDiff * redDiff) + (greenDiff * greenDiff) + (blueDiff * blueDiff) + (alphaDiff * alphaDiff) + if score < bestScore { + bestColor = paletteColor + bestColorIndex = paletteIndex + bestScore = score + } + } + + p.paletteIndexes[c] = bestColorIndex + p.colorConvert[c] = bestColor + } + + return p +} diff --git a/ansi/sixel/palette_test.go b/ansi/sixel/palette_test.go new file mode 100644 index 00000000..e3b2caab --- /dev/null +++ b/ansi/sixel/palette_test.go @@ -0,0 +1,124 @@ +package sixel + +import ( + "image" + "image/color" + "testing" +) + +type testCase struct { + maxColors int + expectedPalette []sixelColor +} + +func TestPaletteCreationRedGreen(t *testing.T) { + redGreen := image.NewRGBA(image.Rect(0, 0, 2, 2)) + redGreen.Set(0, 0, color.RGBA{255, 0, 0, 255}) + redGreen.Set(0, 1, color.RGBA{128, 0, 0, 255}) + redGreen.Set(1, 0, color.RGBA{0, 255, 0, 255}) + redGreen.Set(1, 1, color.RGBA{0, 128, 0, 255}) + + testCases := map[string]testCase{ + "way too many colors": { + maxColors: 16, + expectedPalette: []sixelColor{ + {100, 0, 0, 100}, + {50, 0, 0, 100}, + {0, 100, 0, 100}, + {0, 50, 0, 100}, + }, + }, + "just the right number of colors": { + maxColors: 4, + expectedPalette: []sixelColor{ + {100, 0, 0, 100}, + {50, 0, 0, 100}, + {0, 100, 0, 100}, + {0, 50, 0, 100}, + }, + }, + "color reduction": { + maxColors: 2, + expectedPalette: []sixelColor{ + {75, 0, 0, 100}, + {0, 75, 0, 100}, + }, + }, + } + + runTests(t, redGreen, testCases) +} + +func TestPaletteWithSemiTransparency(t *testing.T) { + blueAlpha := image.NewRGBA(image.Rect(0, 0, 2, 2)) + blueAlpha.Set(0, 0, color.RGBA{0, 0, 255, 255}) + blueAlpha.Set(0, 1, color.RGBA{0, 0, 128, 255}) + blueAlpha.Set(1, 0, color.RGBA{0, 0, 255, 128}) + blueAlpha.Set(1, 1, color.RGBA{0, 0, 255, 0}) + + testCases := map[string]testCase{ + "just the right number of colors": { + maxColors: 4, + expectedPalette: []sixelColor{ + {0, 0, 100, 100}, + {0, 0, 50, 100}, + {0, 0, 100, 50}, + {0, 0, 100, 0}, + }, + }, + "color reduction": { + maxColors: 2, + expectedPalette: []sixelColor{ + {0, 0, 75, 100}, + {0, 0, 100, 25}, + }, + }, + } + runTests(t, blueAlpha, testCases) +} + +func runTests(t *testing.T, img image.Image, testCases map[string]testCase) { + for testName, test := range testCases { + t.Run(testName, func(t *testing.T) { + palette := newSixelPalette(img, test.maxColors) + if len(palette.PaletteColors) != len(test.expectedPalette) { + t.Errorf("Expected colors %+v in palette, but got %+v", test.expectedPalette, palette.PaletteColors) + return + } + + for _, c := range test.expectedPalette { + var foundColor bool + for _, paletteColor := range palette.PaletteColors { + if paletteColor == c { + foundColor = true + break + } + } + + if !foundColor { + t.Errorf("Expected colors %+v in palette, but got %+v", test.expectedPalette, palette.PaletteColors) + return + } + } + + for lookupRawColor, lookupPaletteColor := range palette.colorConvert { + paletteIndex, inReverseLookup := palette.paletteIndexes[lookupRawColor] + if !inReverseLookup { + t.Errorf("Color %+v maps to color %+v in the colorConvert map, but %+v is does not have a corresponding palette index.", lookupRawColor, lookupPaletteColor, lookupPaletteColor) + return + } + + if paletteIndex >= len(palette.PaletteColors) { + t.Errorf("Image color %+v maps to palette index %d, but there are only %d palette colors.", lookupRawColor, paletteIndex, len(palette.PaletteColors)) + return + } + + colorFromPalette := palette.PaletteColors[paletteIndex] + if colorFromPalette != lookupPaletteColor { + t.Errorf("Image color %+v maps to palette color %+v and palette index %d, but palette index %d is actually palette color %+v", lookupRawColor, lookupPaletteColor, paletteIndex, paletteIndex, colorFromPalette) + return + } + } + }) + } +} diff --git a/ansi/sixel/sixel_test.go b/ansi/sixel/sixel_test.go new file mode 100644 index 00000000..3cb0744a --- /dev/null +++ b/ansi/sixel/sixel_test.go @@ -0,0 +1,161 @@ +package sixel + +import ( + "bytes" + "image" + "image/color" + "testing" +) + +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.Bytes()) + 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 + } + } + } + }) + } +} From d44fac5e5467e686fb157df3c2f3ee4a323bbae6 Mon Sep 17 00:00:00 2001 From: Stephen Baynham Date: Sat, 1 Feb 2025 10:06:27 -0800 Subject: [PATCH 2/7] scanSize tests --- ansi/sixel/decoder.go | 2 +- ansi/sixel/sixel_test.go | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/ansi/sixel/decoder.go b/ansi/sixel/decoder.go index 68a66c09..bf5f0f5f 100644 --- a/ansi/sixel/decoder.go +++ b/ansi/sixel/decoder.go @@ -447,7 +447,7 @@ 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 - var newBand bool + newBand := true for { b, err := buffer.ReadByte() if err != nil { diff --git a/ansi/sixel/sixel_test.go b/ansi/sixel/sixel_test.go index 3cb0744a..d18400c5 100644 --- a/ansi/sixel/sixel_test.go +++ b/ansi/sixel/sixel_test.go @@ -7,6 +7,52 @@ import ( "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 From 16b724809db6a91a63d8e032340cee3cc64ec2f4 Mon Sep 17 00:00:00 2001 From: Stephen Baynham Date: Sat, 1 Feb 2025 15:46:17 -0800 Subject: [PATCH 3/7] Update ansi/sixel/encoder.go Co-authored-by: Ayman Bagabas --- ansi/sixel/encoder.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ansi/sixel/encoder.go b/ansi/sixel/encoder.go index ef60eea5..4eaf4358 100644 --- a/ansi/sixel/encoder.go +++ b/ansi/sixel/encoder.go @@ -21,10 +21,12 @@ import ( // band has been drawn, at which time a line break is written to begin the next band. const ( - sixelLineBreak byte = '-' - sixelCarriageReturn byte = '$' - sixelRepeat byte = '!' - sixelUseColor byte = '#' + LineBreak = '-' + CarriageReturn = '$' + RepeatIntroducer = '!' + ColorIntroducer = '#' + RasterAttribute = '"' + ) type Options struct { From f672bee68ee291179d166a7f1e8afbeb6cbba6d6 Mon Sep 17 00:00:00 2001 From: Stephen Baynham Date: Sat, 1 Feb 2025 15:46:24 -0800 Subject: [PATCH 4/7] Update ansi/graphics.go Co-authored-by: Ayman Bagabas --- ansi/graphics.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansi/graphics.go b/ansi/graphics.go index a2504cbc..0243870b 100644 --- a/ansi/graphics.go +++ b/ansi/graphics.go @@ -29,7 +29,7 @@ import ( // size, as far as I can tell. // // See https://shuford.invisible-island.net/all_about_sixels.txt -func SixelGraphics(payload []byte) string { +func SixelGraphics(p1, p2, p3 int, payload []byte) string { var buf bytes.Buffer buf.WriteString("\x1bP0;1;0;q") From 9734d1f1224840ab724960e9d317b80f4d1e2290 Mon Sep 17 00:00:00 2001 From: Stephen Baynham Date: Sat, 1 Feb 2025 15:55:54 -0800 Subject: [PATCH 5/7] pr comments --- ansi/graphics.go | 11 +++++++++-- ansi/sixel/decoder.go | 10 +++++----- ansi/sixel/encoder.go | 24 ++++++++++++------------ 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/ansi/graphics.go b/ansi/graphics.go index 0243870b..d76657c6 100644 --- a/ansi/graphics.go +++ b/ansi/graphics.go @@ -8,6 +8,7 @@ import ( "image" "io" "os" + "strconv" "strings" "github.com/charmbracelet/x/ansi/kitty" @@ -32,7 +33,13 @@ import ( func SixelGraphics(p1, p2, p3 int, payload []byte) string { var buf bytes.Buffer - buf.WriteString("\x1bP0;1;0;q") + buf.WriteString("\x1bP") + buf.WriteString(strconv.Itoa(p1)) + buf.WriteByte(';') + buf.WriteString(strconv.Itoa(p2)) + buf.WriteByte(';') + buf.WriteString(strconv.Itoa(p3)) + buf.WriteString(";q") buf.Write(payload) buf.WriteString("\x1b\\") @@ -54,7 +61,7 @@ func WriteSixelGraphics(w io.Writer, m image.Image, o *sixel.Options) error { return fmt.Errorf("failed to encode sixel image: %w", err) } - _, err := io.WriteString(w, SixelGraphics(data.Bytes())) + _, err := io.WriteString(w, SixelGraphics(0, 1, 0, data.Bytes())) return err } diff --git a/ansi/sixel/decoder.go b/ansi/sixel/decoder.go index bf5f0f5f..b75a6388 100644 --- a/ansi/sixel/decoder.go +++ b/ansi/sixel/decoder.go @@ -302,7 +302,7 @@ func (d *Decoder) Decode(data []byte) (image.Image, error) { } var bounds image.Rectangle - if b == '"' { + if b == RasterAttribute { var fixedWidth, fixedHeight int // We have pixel dimensions _, err := fmt.Fscanf(buffer, "1;1;%d;%d", &fixedWidth, &fixedHeight) @@ -331,7 +331,7 @@ func (d *Decoder) Decode(data []byte) (image.Image, error) { } // Palette operation - if b == sixelUseColor { + if b == ColorIntroducer { _, err = fmt.Fscan(buffer, ¤tPaletteIndex) if err != nil { return img, d.readError(err) @@ -370,21 +370,21 @@ func (d *Decoder) Decode(data []byte) (image.Image, error) { } // LF - if b == sixelLineBreak { + if b == LineBreak { currentBandY++ currentX = 0 continue } // CR - if b == sixelCarriageReturn { + if b == CarriageReturn { currentX = 0 continue } // RLE operation count := 1 - if b == sixelRepeat { + if b == RepeatIntroducer { _, err = fmt.Fscan(buffer, &count) if err != nil { return img, d.readError(err) diff --git a/ansi/sixel/encoder.go b/ansi/sixel/encoder.go index 4eaf4358..fed900ef 100644 --- a/ansi/sixel/encoder.go +++ b/ansi/sixel/encoder.go @@ -21,12 +21,11 @@ import ( // band has been drawn, at which time a line break is written to begin the next band. const ( - LineBreak = '-' - CarriageReturn = '$' - RepeatIntroducer = '!' - ColorIntroducer = '#' - RasterAttribute = '"' - + LineBreak = '-' + CarriageReturn = '$' + RepeatIntroducer = '!' + ColorIntroducer = '#' + RasterAttribute = '"' ) type Options struct { @@ -46,7 +45,8 @@ func (e *Encoder) Encode(w io.Writer, img image.Image) error { imageBounds := img.Bounds() - io.WriteString(w, "\"1;1;") //nolint:errcheck + w.Write([]byte{RasterAttribute}) //nolint:errcheck + io.WriteString(w, "1;1;") //nolint:errcheck io.WriteString(w, strconv.Itoa(imageBounds.Dx())) //nolint:errcheck w.Write([]byte{';'}) //nolint:errcheck io.WriteString(w, strconv.Itoa(imageBounds.Dy())) //nolint:errcheck @@ -80,7 +80,7 @@ func (e *Encoder) encodePaletteColor(w io.Writer, paletteIndex int, c sixelColor // d = G // e = B - w.Write([]byte{sixelUseColor}) //nolint:errcheck + w.Write([]byte{ColorIntroducer}) //nolint:errcheck io.WriteString(w, strconv.Itoa(paletteIndex)) //nolint:errcheck io.WriteString(w, ";2;") io.WriteString(w, strconv.Itoa(int(c.Red))) //nolint:errcheck @@ -157,7 +157,7 @@ func (s *sixelBuilder) GeneratePixels() string { for bandY := 0; bandY < bandHeight; bandY++ { if bandY > 0 { - s.writeControlRune(sixelLineBreak) + s.writeControlRune(LineBreak) } hasWrittenAColor := false @@ -178,11 +178,11 @@ func (s *sixelBuilder) GeneratePixels() string { } if hasWrittenAColor { - s.writeControlRune(sixelCarriageReturn) + s.writeControlRune(CarriageReturn) } hasWrittenAColor = true - s.writeControlRune(sixelUseColor) + s.writeControlRune(ColorIntroducer) s.imageData.WriteString(strconv.Itoa(paletteIndex)) for x := 0; x < s.imageWidth; x += 4 { bit := firstColorBit + uint(x*6) @@ -252,7 +252,7 @@ func (s *sixelBuilder) flushRepeats() { // Only write using the RLE form if it's actually providing space savings if s.repeatCount > 3 { countStr := strconv.Itoa(s.repeatCount) - s.imageData.WriteByte(sixelRepeat) + s.imageData.WriteByte(RepeatIntroducer) s.imageData.WriteString(countStr) s.imageData.WriteRune(s.repeatRune) return From 41a45583ce71d095e25b9508770d50c55e446efd Mon Sep 17 00:00:00 2001 From: Stephen Baynham Date: Sat, 1 Feb 2025 16:04:36 -0800 Subject: [PATCH 6/7] raster/parseraster --- ansi/sixel/decoder.go | 7 ++++++- ansi/sixel/encoder.go | 21 +++++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/ansi/sixel/decoder.go b/ansi/sixel/decoder.go index b75a6388..e780648a 100644 --- a/ansi/sixel/decoder.go +++ b/ansi/sixel/decoder.go @@ -283,6 +283,11 @@ 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 +} + // 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. @@ -305,7 +310,7 @@ func (d *Decoder) Decode(data []byte) (image.Image, error) { if b == RasterAttribute { var fixedWidth, fixedHeight int // We have pixel dimensions - _, err := fmt.Fscanf(buffer, "1;1;%d;%d", &fixedWidth, &fixedHeight) + _, _, fixedWidth, fixedHeight, err := ParseRaster(buffer) if err != nil { return nil, d.readError(err) } diff --git a/ansi/sixel/encoder.go b/ansi/sixel/encoder.go index fed900ef..a706d40f 100644 --- a/ansi/sixel/encoder.go +++ b/ansi/sixel/encoder.go @@ -1,6 +1,7 @@ package sixel import ( + "fmt" "image" "image/color" "io" @@ -21,11 +22,11 @@ import ( // band has been drawn, at which time a line break is written to begin the next band. const ( - LineBreak = '-' - CarriageReturn = '$' - RepeatIntroducer = '!' - ColorIntroducer = '#' - RasterAttribute = '"' + LineBreak byte = '-' + CarriageReturn byte = '$' + RepeatIntroducer byte = '!' + ColorIntroducer byte = '#' + RasterAttribute byte = '"' ) type Options struct { @@ -34,6 +35,10 @@ type Options struct { 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) +} + // 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 @@ -45,11 +50,7 @@ func (e *Encoder) Encode(w io.Writer, img image.Image) error { imageBounds := img.Bounds() - w.Write([]byte{RasterAttribute}) //nolint:errcheck - io.WriteString(w, "1;1;") //nolint:errcheck - io.WriteString(w, strconv.Itoa(imageBounds.Dx())) //nolint:errcheck - w.Write([]byte{';'}) //nolint:errcheck - io.WriteString(w, strconv.Itoa(imageBounds.Dy())) //nolint:errcheck + io.WriteString(w, Raster(1, 1, imageBounds.Dx(), imageBounds.Dy())) //nolint:errcheck palette := newSixelPalette(img, sixelMaxColors) From 8705df7cf4a32da68d8167ee18925f3c26277d73 Mon Sep 17 00:00:00 2001 From: Stephen Baynham Date: Sat, 1 Feb 2025 16:14:09 -0800 Subject: [PATCH 7/7] repeat/parserepeat --- ansi/sixel/decoder.go | 13 ++++++------- ansi/sixel/encoder.go | 13 +++++++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/ansi/sixel/decoder.go b/ansi/sixel/decoder.go index e780648a..185115f5 100644 --- a/ansi/sixel/decoder.go +++ b/ansi/sixel/decoder.go @@ -288,6 +288,11 @@ func ParseRaster(data io.Reader) (pan, pad, ph, pv int, err error) { return } +func ParseRepeat(data io.Reader) (count int, r byte, err error) { + _, err = fmt.Fscanf(data, "%d%b", &count, &r) + return +} + // 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. @@ -390,13 +395,7 @@ func (d *Decoder) Decode(data []byte) (image.Image, error) { // RLE operation count := 1 if b == RepeatIntroducer { - _, err = fmt.Fscan(buffer, &count) - if err != nil { - return img, d.readError(err) - } - - // The next byte SHOULD be a pixel - b, err = buffer.ReadByte() + count, b, err = ParseRepeat(buffer) if err != nil { return img, d.readError(err) } diff --git a/ansi/sixel/encoder.go b/ansi/sixel/encoder.go index a706d40f..eb77e689 100644 --- a/ansi/sixel/encoder.go +++ b/ansi/sixel/encoder.go @@ -39,6 +39,14 @@ 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, repeatRune rune) string { + var sb strings.Builder + sb.WriteByte(RepeatIntroducer) + sb.WriteString(strconv.Itoa(count)) + sb.WriteRune(repeatRune) + return sb.String() +} + // 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 @@ -252,10 +260,7 @@ func (s *sixelBuilder) flushRepeats() { // Only write using the RLE form if it's actually providing space savings if s.repeatCount > 3 { - countStr := strconv.Itoa(s.repeatCount) - s.imageData.WriteByte(RepeatIntroducer) - s.imageData.WriteString(countStr) - s.imageData.WriteRune(s.repeatRune) + s.imageData.WriteString(Repeat(s.repeatCount, s.repeatRune)) return }