Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add sixel to ansi package #380

Merged
merged 43 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ed43f81
feat: Add sixels to ansi package
CannibalVox Jan 30, 2025
d44fac5
scanSize tests
CannibalVox Feb 1, 2025
16b7248
Update ansi/sixel/encoder.go
CannibalVox Feb 1, 2025
f672bee
Update ansi/graphics.go
CannibalVox Feb 1, 2025
9734d1f
pr comments
CannibalVox Feb 1, 2025
41a4558
raster/parseraster
CannibalVox Feb 2, 2025
8705df7
repeat/parserepeat
CannibalVox Feb 2, 2025
01e9042
remove sixel.Options and address pX conditional
raphamorim Feb 19, 2025
59b7744
remove WriteSixelGraphics func
raphamorim Feb 19, 2025
693be27
ignore p3
raphamorim Feb 19, 2025
0f4f102
move back to ;q
raphamorim Feb 19, 2025
2a70985
use byte instead of rune
raphamorim Feb 19, 2025
ce91bcb
remove WriteString
raphamorim Feb 19, 2025
af13a23
remove slices
raphamorim Feb 19, 2025
d68cbad
use Compare and isNan from 1.24
raphamorim Feb 19, 2025
e3b8780
fixup! use Compare and isNan from 1.24
raphamorim Feb 19, 2025
394b145
fixup! fixup! use Compare and isNan from 1.24
raphamorim Feb 19, 2025
39755ba
fixup! fixup! fixup! use Compare and isNan from 1.24
raphamorim Feb 19, 2025
d53c02c
remove code
raphamorim Feb 19, 2025
38f4f5a
chore: merge branch 'main' into feat-sixel-support
aymanbagabas Feb 19, 2025
226b4c2
add bench
raphamorim Feb 20, 2025
3653b43
rename file
raphamorim Feb 20, 2025
f1a551f
add palette_sort
raphamorim Feb 20, 2025
49ef044
remove stableCmpFunc
raphamorim Feb 20, 2025
eb42d4e
remove unnecessary stuff
raphamorim Feb 20, 2025
53d8da5
remove comments
raphamorim Feb 20, 2025
a68a7ba
feat(ansi): sixel: implement types and use io.Reader
aymanbagabas Feb 20, 2025
4cece45
Update decoder and encoder
raphamorim Feb 21, 2025
78fe515
remove unused and expose default color palette
raphamorim Feb 21, 2025
cb8b527
fixup! remove unused and expose default color palette
raphamorim Feb 21, 2025
6748926
fixup! fixup! remove unused and expose default color palette
raphamorim Feb 21, 2025
b1ce404
fixup! fixup! fixup! remove unused and expose default color palette
raphamorim Feb 21, 2025
1274bd6
uncommenting code
raphamorim Feb 21, 2025
86eb1f8
add .gitattributes lfs to png folder
raphamorim Feb 21, 2025
833523d
remove quant
raphamorim Feb 21, 2025
595a567
remove go-sixel
raphamorim Feb 21, 2025
405e40c
comment go-sixel
raphamorim Feb 21, 2025
5ea93ef
Update ansi/sixel/decoder.go
raphamorim Feb 21, 2025
c8efdb7
remove ununsed stuff
raphamorim Feb 21, 2025
243a2e8
add example
raphamorim Feb 21, 2025
e1664ec
Add example img2term
raphamorim Feb 21, 2025
a642787
proper format
raphamorim Feb 21, 2025
17ea3f3
Merge branch 'main' into feat-sixel-support
raphamorim Feb 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
38 changes: 38 additions & 0 deletions ansi/graphics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
510 changes: 510 additions & 0 deletions ansi/sixel/decoder.go

Large diffs are not rendered by default.

270 changes: 270 additions & 0 deletions ansi/sixel/encoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
package sixel

import (
"fmt"
"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 (
LineBreak byte = '-'
CarriageReturn byte = '$'
RepeatIntroducer byte = '!'
ColorIntroducer byte = '#'
RasterAttribute byte = '"'
)

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)
}

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
// palette.
func (e *Encoder) Encode(w io.Writer, img image.Image) error {
if img == nil {
return nil
}

imageBounds := img.Bounds()

io.WriteString(w, Raster(1, 1, imageBounds.Dx(), 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{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
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
repeatByte byte
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(LineBreak)
}

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(CarriageReturn)
}
hasWrittenAColor = true

s.writeControlRune(ColorIntroducer)
s.imageData.WriteString(strconv.Itoa(paletteIndex))
for x := 0; x < s.imageWidth; x += 4 {
bit := firstColorBit + uint(x*6)
word := s.pixelBands.GetWord64AtBit(bit)

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 byte) {
if r == s.repeatByte {
s.repeatCount++
return
}

s.flushRepeats()
s.repeatByte = 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.repeatByte = 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 {
s.imageData.WriteString(Repeat(s.repeatCount, s.repeatRune))
return
}

for i := 0; i < s.repeatCount; i++ {
s.imageData.WriteRune(s.repeatRune)
}
}
Loading
Loading