-
Notifications
You must be signed in to change notification settings - Fork 21
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
Sixel Graphics Implementation #352
Open
CannibalVox
wants to merge
7
commits into
charmbracelet:main
Choose a base branch
from
CannibalVox:mine/sixel
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
ed43f81
feat: Add sixels to ansi package
CannibalVox d44fac5
scanSize tests
CannibalVox 16b7248
Update ansi/sixel/encoder.go
CannibalVox f672bee
Update ansi/graphics.go
CannibalVox 9734d1f
pr comments
CannibalVox 41a4558
raster/parseraster
CannibalVox 8705df7
repeat/parserepeat
CannibalVox File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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(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 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 { | ||
s.imageData.WriteString(Repeat(s.repeatCount, s.repeatRune)) | ||
return | ||
} | ||
|
||
for i := 0; i < s.repeatCount; i++ { | ||
s.imageData.WriteRune(s.repeatRune) | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there anyway we can get rid of this dependency?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only way I see it working is writing our own bitset implementation- is that something you want to pursue?