cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / chart / sixel_test.go
sixel_test.go 5.0K
1package chart23import (4 "image"5 "image/color"6 "strings"7 "testing"89 "src.rest/cat/012/internal/sheet"10)1112// The encoder's bytes for a small image: a register per color in the13// order they appear, runs of four or more as "!n", trailing empty14// sixels left out, "$" between the colors of a band and "-" between15// bands, a pixel with half coverage composited over the background and16// a transparent one drawn in the background, so text under the image17// never shows through.18func TestSixelGolden(t *testing.T) {19 img := image.NewRGBA(image.Rect(0, 0, 4, 7))20 red := color.RGBA{255, 0, 0, 255}21 for x := range 4 {22 img.SetRGBA(x, 0, red)23 }24 img.SetRGBA(1, 1, color.RGBA{0, 128, 0, 128}) // premultiplied: half green25 img.SetRGBA(0, 2, red)26 img.SetRGBA(1, 2, red)27 img.SetRGBA(2, 2, red)28 img.SetRGBA(0, 6, color.RGBA{0, 0, 255, 255})29 got := Sixel(img, color.RGBA{0, 0, 0, 255}, 256)30 want := "\x1bP0;1;0q\"1;1;4;7" +31 "#0;2;100;0;0#1;2;0;0;0#2;2;0;50;0#3;2;0;0;100" +32 "#0DDD@$#1ywy}$#2?A" + // red: rows 0 and 2 (1+4) three times, then row 0 alone; black around33 "-#1?@@@$#3@" +34 "\x1b\\"35 if got != want {36 t.Errorf("Sixel =\n%q\nwant\n%q", got, want)37 }38 img.SetRGBA(3, 2, red)39 if got := Sixel(img, color.RGBA{0, 0, 0, 255}, 256); !strings.Contains(got, "#0!4D$") {40 t.Errorf("a run of four isn't run-length encoded: %q", got)41 }42}4344// Past the terminal's registers, colors are merged until they fit, and45// every pixel still names a register that exists.46func TestSixelQuantize(t *testing.T) {47 img := image.NewRGBA(image.Rect(0, 0, 40, 30))48 for y := range 30 {49 for x := range 40 {50 img.SetRGBA(x, y, color.RGBA{uint8(x * 6), uint8(y * 8), uint8((x + y) * 3), 255})51 }52 }53 for _, regs := range []int{16, 256} {54 idx, pal := quantize(img, color.RGBA{}, regs)55 if len(pal) > regs {56 t.Errorf("%d registers: %d colors", regs, len(pal))57 }58 for _, i := range idx {59 if i < 0 || int(i) >= len(pal) {60 t.Fatalf("%d registers: pixel names register %d of %d", regs, i, len(pal))61 }62 }63 }64 if s := Sixel(img, color.RGBA{}, 16); strings.Count(s, ";2;") > 16 {65 t.Errorf("more than 16 colors defined")66 }67}6869// A chart's image encodes, and an empty image draws nothing.70func TestSixelChart(t *testing.T) {71 img := Image(sheet.ChartColumn, single, 40, 12, Options{CellW: 8, CellH: 16}, testPalette)72 if img == nil {73 t.Fatal("no image")74 }75 s := Sixel(img, color.RGBA{0, 0, 0, 255}, 256)76 if !strings.HasPrefix(s, "\x1bP0;1;0q\"1;1;") || !strings.HasSuffix(s, "\x1b\\") {77 t.Errorf("not a sixel sequence: %.40q", s)78 }79 if Sixel(image.NewRGBA(image.Rect(0, 0, 0, 0)), color.RGBA{}, 256) != "" {80 t.Error("an empty image drew something")81 }82}8384// A chart's sixel image decodes to the image composited over the85// background, each channel within sixel's percent steps, pixels86// without coverage included.87func TestSixelDecodes(t *testing.T) {88 bg := color.RGBA{250, 250, 245, 255}89 for _, ct := range []sheet.ChartType{sheet.ChartLine, sheet.ChartPie, sheet.ChartArea} {90 img := Image(ct, budget, 40, 12, Options{CellW: 7, CellH: 15}, testPalette)91 got := decodeSixel(t, Sixel(img, bg, 256))92 b := img.Bounds()93 for y := range b.Dy() {94 for x := range b.Dx() {95 want := over(img, x, y, bg)96 c, set := got[image.Pt(x, y)]97 if !set || (diff(c.R, want.R) > 3 || diff(c.G, want.G) > 3 || diff(c.B, want.B) > 3) {98 t.Fatalf("%v at %d,%d: %v %v, want %v", ct, x, y, c, set, want)99 }100 }101 }102 }103}104105func diff(a, b uint8) int { return max(int(a)-int(b), int(b)-int(a)) }106107// decodeSixel reads what Sixel writes: color definitions, "#n"108// selections, sixels and their runs, "$" and "-".109func decodeSixel(t *testing.T, s string) map[image.Point]color.RGBA {110 t.Helper()111 body, ok := strings.CutPrefix(s, "\x1bP0;1;0q")112 body, ok2 := strings.CutSuffix(body, "\x1b\\")113 if !ok || !ok2 {114 t.Fatalf("not a sixel sequence: %.30q", s)115 }116 d := sixelDecoder{body: body, pal: map[int]color.RGBA{}, out: map[image.Point]color.RGBA{}}117 for d.i < len(body) {118 d.step()119 }120 return d.out121}122123type sixelDecoder struct {124 body string125 i int126 x, y, cur int127 pal map[int]color.RGBA128 out map[image.Point]color.RGBA129}130131func (d *sixelDecoder) num() int {132 n := 0133 for ; d.i < len(d.body) && d.body[d.i] >= '0' && d.body[d.i] <= '9'; d.i++ {134 n = n*10 + int(d.body[d.i]-'0')135 }136 return n137}138139func (d *sixelDecoder) step() {140 ch := d.body[d.i]141 d.i++142 switch ch {143 case '"':144 for d.i < len(d.body) && (d.body[d.i] >= '0' && d.body[d.i] <= '9' || d.body[d.i] == ';') {145 d.i++146 }147 case '#':148 d.cur = d.num()149 if d.i < len(d.body) && d.body[d.i] == ';' {150 var c [4]int151 for k := range c {152 d.i++153 c[k] = d.num()154 }155 pc := func(v int) uint8 { return uint8((v*255 + 50) / 100) }156 d.pal[d.cur] = color.RGBA{pc(c[1]), pc(c[2]), pc(c[3]), 255}157 }158 case '$':159 d.x = 0160 case '-':161 d.x, d.y = 0, d.y+6162 default:163 run := 1164 if ch == '!' {165 run = d.num()166 ch = d.body[d.i]167 d.i++168 }169 for range run {170 for dy := range 6 {171 if (ch-'?')&(1<<dy) != 0 {172 d.out[image.Pt(d.x, d.y+dy)] = d.pal[d.cur]173 }174 }175 d.x++176 }177 }178}