Skip to content

Commit

Permalink
feat: Add sixels to ansi package
Browse files Browse the repository at this point in the history
  • Loading branch information
CannibalVox committed Jan 30, 2025
1 parent 8255858 commit ed43f81
Show file tree
Hide file tree
Showing 8 changed files with 1,453 additions and 0 deletions.
1 change: 1 addition & 0 deletions ansi/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ansi/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
45 changes: 45 additions & 0 deletions ansi/graphics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
506 changes: 506 additions & 0 deletions ansi/sixel/decoder.go

Large diffs are not rendered by default.

262 changes: 262 additions & 0 deletions ansi/sixel/encoder.go
Original file line number Diff line number Diff line change
@@ -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>;<b>;<c>;<d>;<e>
// 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 <header> <palette> <pixels> 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 <pixels> 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)
}
}
Loading

0 comments on commit ed43f81

Please sign in to comment.