├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── iterm_wez.go ├── kitty.go ├── kittyChunkWri.go ├── rasterm_test.go ├── screenshot.png ├── sixel.go ├── term_misc.go ├── test_images ├── 11.gif ├── 1580624931717m.jpg ├── 19.png ├── 28.png ├── 69224903_2412485828820667_3857837082370113536_n.jpg ├── Image15.gif └── Ys1pc88_title.png └── todo.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.prof 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jason Stewart 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rasterm 2 | 3 | Encodes images to iTerm / Kitty / SIXEL (terminal) inline graphics protocols. 4 | 5 | [![GoDoc](https://godoc.org/github.com/BourgeoisBear/rasterm?status.png)](http://godoc.org/github.com/BourgeoisBear/rasterm) 6 | 7 | ![rasterm sample output](screenshot.png) 8 | 9 | ## Supported Image Encodings 10 | 11 | - **Kitty** 12 | - **iTerm2 / WezTerm** 13 | - **Sixel** 14 | 15 | ## TODO 16 | 17 | - mintty: 18 | - detection for iTerm format: https://github.com/mintty/mintty/issues/881 19 | - iTerm2: 20 | - support: name, width, height, preserveAspectRatio options 21 | - perhaps query tmux directly: TMUX=/tmp/tmux-1000/default,3218,4 22 | - improve terminal identification 23 | 19:VT340 24 | ESC[>0c = 19;344:0c 25 | https://invisible-island.net/xterm/ctlseqs/ctlseqs-contents.html 26 | 27 | ## TESTING 28 | 29 | - test sixel with 30 | - https://domterm.org/ 31 | - https://www.macterm.net/ 32 | - test wez/iterm img with 33 | - iterm2 34 | - https://www.macterm.net/ 35 | 36 | ## Notes 37 | 38 | ### terminal features matrix 39 | 40 | | terminal | sixel | iTerm2 format | kitty format | 41 | | :--- | :--: | :--: | :--: | 42 | | ghostty | | | Y | 43 | | iterm2 | Y | Y | | 44 | | kitty | | | Y | 45 | | rio | Y | Y | | 46 | | mintty | Y | Y | | 47 | | mlterm | Y | Y | | 48 | | putty | | | | 49 | | rlogin | Y | Y | | 50 | | wezterm | Y | Y | | 51 | | xterm | Y | | | 52 | 53 | ### known responses 54 | 55 | #### CSI 0 c 56 | 57 | | terminal | response | 58 | | :---- | :---- | 59 | | apple terminal | `\x1b[?1;2c` | 60 | | ghostty | `\x1b[?62;22c` | 61 | | guake | `\x1b[?65;1;9c` | 62 | | iterm2 | `\x1b[?62;4c` | 63 | | kitty | `\x1b[?62;c` | 64 | | rio | `\x1b[?62;4;6;22c` | 65 | | mintty | `\x1b[?64;1;2;4;6;9;15;21;22;28;29c` | 66 | | mlterm | `\x1b[?63;1;2;3;4;7;29c` | 67 | | putty | `\x1b[?6c` | 68 | | rlogin | `\x1b[?65;1;2;3;4;6;7;8;9;15;18;21;22;29;39;42;44c` | 69 | | st | `\x1b[?6c` | 70 | | terminology | `\x1b[?64;1;9;15;18;21;22c` | 71 | | vimterm | `\x1b[?1;2c` | 72 | | wez | `\x1b[?65;4;6;18;22c` | 73 | | xfce | `\x1b[?65;1;9c` | 74 | | xterm | `\x1b[?63;1;2;4;6;9;15;22c` | 75 | 76 | #### CSI > 0 c 77 | 78 | | terminal | response | 79 | | :---- | :---- | 80 | | apple terminal | `\x1b[>1;95;0c` | 81 | | ghostty | `\x1b[>1;10;0c` | 82 | | guake | `\x1b[>65;5402;1c` | 83 | | iterm2 | `\x1b[>0;95;0c` | 84 | | rio | `\x1b[>0;95;0c` | 85 | | kitty | `\x1b[>1;4000;19c` | 86 | | mintty | `\x1b[>77;30104;0c` | 87 | | mlterm | `\x1b[>24;279;0c` | 88 | | putty | `\x1b[>0;136;0c` | 89 | | rlogin | `\x1b[>65;331;0c` | 90 | | st | NO RESPONSE | 91 | | vimterm | `\x1b[>0;100;0c` | 92 | | wez | `\x1b[>0;0;0c` | 93 | | xfce | `\x1b[>65;5402;1c` | 94 | | xterm | `\x1b[>19;344;0c` | 95 | 96 | #### identifications 97 | 98 | | terminal | values | 99 | | :---- | :---- | 100 | | apple terminal | `TERM_PROGRAM="Apple_Terminal" ` | 101 | | apple terminal | `__CFBundleIdentifier="com.apple.Terminal"` | 102 | | ghostty | `TERM="xterm-ghostty" ` | 103 | | rio | `TERM="rio" ` | 104 | | guake | ` ` | 105 | | iterm2 | `LC_TERMINAL="iTerm2" ` | 106 | | kitty | `TERM="xterm-kitty" ` | 107 | | mintty | `TERM="mintty" ` | 108 | | mlterm | ` ` | 109 | | putty | ` ` | 110 | | rlogin | ` ` | 111 | | st | ` ` | 112 | | terminology | `TERM_PROGRAM=terminology` | 113 | | vimterm | `VIM_TERMINAL is set ` | 114 | | wez | `TERM_PROGRAM="wezterm" ` | 115 | | xfce | ` ` | 116 | | xterm | ` ` | 117 | 118 | ### opinions 119 | 120 | - Sixel is a primitive and wasteful format. Most sixel terminals also support the iTerm2 format--fewer bytes, full color instead of paletted, and no pixel re-processing required. Much better! 121 | 122 | ### go stuff 123 | 124 | ```sh 125 | go tool pprof -http=:8080 ./name.prof 126 | godoc -http=:8099 -goroot="$HOME/go" 127 | go test -v 128 | go mod tidy 129 | https://blog.golang.org/pprof 130 | ``` 131 | 132 | ### more reading 133 | 134 | - kitty inline images: https://sw.kovidgoyal.net/kitty/graphics-protocol.html 135 | - iterm2 inline images: https://iterm2.com/documentation-images.html 136 | - xterm ctl seqs: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html 137 | - sixel ctl seqs: https://vt100.net/docs/vt3xx-gp/chapter14.html 138 | - libsixel: https://saitoha.github.io/libsixel/ 139 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BourgeoisBear/rasterm 2 | 3 | go 1.16 4 | 5 | require golang.org/x/term v0.18.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 2 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 3 | golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= 4 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 5 | -------------------------------------------------------------------------------- /iterm_wez.go: -------------------------------------------------------------------------------- 1 | package rasterm 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "image" 8 | "image/jpeg" 9 | "image/png" 10 | "io" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | const ( 16 | ITERM_IMG_HDR = "\x1b]1337;File=" 17 | ITERM_IMG_FTR = "\a" 18 | ) 19 | 20 | type ItermImgOpts struct { 21 | // Filename. Defaults to "Unnamed file". 22 | Name string 23 | 24 | // Width to render. See notes below. 25 | Width string 26 | 27 | // Height to render. See notes below. 28 | Height string 29 | 30 | // The width and height are given as a number followed by a unit, or the word "auto". 31 | // 32 | // - N: N character cells. 33 | // - Npx: N pixels. 34 | // - N%: N percent of the session's width or height. 35 | // - auto: The image's inherent size will be used to determine an appropriate dimension. 36 | 37 | // File size in bytes. Optional; this is only used by the progress indicator. 38 | Size int64 39 | 40 | // If set, the file will be displayed inline. Otherwise, it will be downloaded 41 | // with no visual representation in the terminal session. 42 | DisplayInline bool 43 | 44 | // If set, the image's inherent aspect ratio will not be respected. 45 | IgnoreAspectRatio bool 46 | } 47 | 48 | func (o ItermImgOpts) ToHeader() string { 49 | 50 | var opts []string 51 | 52 | if o.Name != "" { 53 | opts = append(opts, "name="+base64.StdEncoding.EncodeToString([]byte(o.Name))) 54 | } 55 | 56 | if o.Width != "" { 57 | opts = append(opts, "width="+o.Width) 58 | } 59 | 60 | if o.Height != "" { 61 | opts = append(opts, "height="+o.Height) 62 | } 63 | 64 | if o.Size > 0 { 65 | opts = append(opts, "size="+strconv.FormatInt(o.Size, 10)) 66 | } 67 | 68 | // default: inline=0 69 | if o.DisplayInline { 70 | opts = append(opts, "inline=1") 71 | } 72 | 73 | // default: preserveAspectRatio=1 74 | if o.IgnoreAspectRatio { 75 | opts = append(opts, "preserveAspectRatio=0") 76 | } 77 | 78 | return ITERM_IMG_HDR + strings.Join(opts, ";") + ":" 79 | } 80 | 81 | // NOTE: uses $TERM_PROGRAM, which isn't passed through tmux or ssh 82 | // checks if iterm inline image protocol is supported 83 | func IsItermCapable() bool { 84 | 85 | V := GetEnvIdentifiers() 86 | 87 | if V["TERM"] == "mintty" { 88 | return true 89 | } 90 | 91 | if V["LC_TERMINAL"] == "iterm2" { 92 | return true 93 | } 94 | 95 | if V["TERM_PROGRAM"] == "wezterm" { 96 | return true 97 | } 98 | 99 | if V["TERM_PROGRAM"] == "rio" { 100 | return true 101 | } 102 | 103 | return false 104 | } 105 | 106 | /* 107 | Encode image using the iTerm2/WezTerm terminal image protocol: 108 | 109 | https://iterm2.com/documentation-images.html 110 | */ 111 | func ItermWriteImageWithOptions(out io.Writer, iImg image.Image, opts ItermImgOpts) error { 112 | 113 | pBuf := new(bytes.Buffer) 114 | var E error 115 | 116 | // NOTE: doing this under suspicion that wezterm PNG handling is slow 117 | if _, bOK := iImg.(*image.Paletted); bOK { 118 | 119 | // PNG IF PALETTED 120 | E = png.Encode(pBuf, iImg) 121 | 122 | } else { 123 | 124 | // JPG IF NOT 125 | E = jpeg.Encode(pBuf, iImg, &jpeg.Options{Quality: 93}) 126 | } 127 | 128 | if E != nil { 129 | return E 130 | } 131 | 132 | opts.Size = int64(pBuf.Len()) 133 | return ItermCopyFileInlineWithOptions(out, pBuf, opts) 134 | } 135 | 136 | func ItermCopyFileInlineWithOptions(out io.Writer, in io.Reader, opts ItermImgOpts) (E error) { 137 | 138 | if _, E = fmt.Fprint(out, opts.ToHeader()); E != nil { 139 | return 140 | } 141 | 142 | enc64 := base64.NewEncoder(base64.StdEncoding, out) 143 | if _, E = io.Copy(enc64, in); E != nil { 144 | return 145 | } 146 | 147 | if E = enc64.Close(); E != nil { 148 | return 149 | } 150 | 151 | _, E = out.Write([]byte(ITERM_IMG_FTR)) 152 | return 153 | } 154 | 155 | func ItermWriteImage(out io.Writer, iImg image.Image) error { 156 | return ItermWriteImageWithOptions(out, iImg, ItermImgOpts{DisplayInline: true}) 157 | } 158 | 159 | func ItermCopyFileInline(out io.Writer, in io.Reader, nLen int64) (E error) { 160 | return ItermCopyFileInlineWithOptions(out, in, ItermImgOpts{DisplayInline: true, Size: nLen}) 161 | } 162 | -------------------------------------------------------------------------------- /kitty.go: -------------------------------------------------------------------------------- 1 | package rasterm 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "image" 9 | "image/png" 10 | "io" 11 | "strings" 12 | ) 13 | 14 | // See https://sw.kovidgoyal.net/kitty/graphics-protocol.html for more details. 15 | 16 | const ( 17 | KITTY_IMG_HDR = "\x1b_G" 18 | KITTY_IMG_FTR = "\x1b\\" 19 | ) 20 | 21 | type KittyImgOpts struct { 22 | SrcX uint32 // x= 23 | SrcY uint32 // y= 24 | SrcWidth uint32 // w= 25 | SrcHeight uint32 // h= 26 | CellOffsetX uint32 // X= (pixel x-offset inside terminal cell) 27 | CellOffsetY uint32 // Y= (pixel y-offset inside terminal cell) 28 | DstCols uint32 // c= (display width in terminal columns) 29 | DstRows uint32 // r= (display height in terminal rows) 30 | ZIndex int32 // z= 31 | ImageId uint32 // i= 32 | ImageNo uint32 // I= 33 | PlacementId uint32 // p= 34 | } 35 | 36 | func (o KittyImgOpts) ToHeader(opts ...string) string { 37 | 38 | type fldmap struct { 39 | pv *uint32 40 | code rune 41 | } 42 | sFld := []fldmap{ 43 | fldmap{&o.SrcX, 'x'}, 44 | fldmap{&o.SrcY, 'y'}, 45 | fldmap{&o.SrcWidth, 'w'}, 46 | fldmap{&o.SrcHeight, 'h'}, 47 | fldmap{&o.CellOffsetX, 'X'}, 48 | fldmap{&o.CellOffsetY, 'Y'}, 49 | fldmap{&o.DstCols, 'c'}, 50 | fldmap{&o.DstRows, 'r'}, 51 | fldmap{&o.ImageId, 'i'}, 52 | fldmap{&o.ImageNo, 'I'}, 53 | fldmap{&o.PlacementId, 'p'}, 54 | } 55 | 56 | for _, f := range sFld { 57 | if *f.pv != 0 { 58 | opts = append(opts, fmt.Sprintf("%c=%d", f.code, *f.pv)) 59 | } 60 | } 61 | 62 | if o.ZIndex != 0 { 63 | opts = append(opts, fmt.Sprintf("z=%d", o.ZIndex)) 64 | } 65 | 66 | return KITTY_IMG_HDR + strings.Join(opts, ",") + ";" 67 | } 68 | 69 | // checks if terminal supports kitty image protocols 70 | func IsKittyCapable() bool { 71 | 72 | // TODO: more rigorous check 73 | V := GetEnvIdentifiers() 74 | return (len(V["KITTY_WINDOW_ID"]) > 0) || (V["TERM_PROGRAM"] == "wezterm") || (V["TERM_PROGRAM"] == "ghostty") 75 | } 76 | 77 | // Display local PNG file 78 | // - pngFileName must be directly accesssible from Kitty instance 79 | // - pngFileName must be an absolute path 80 | func KittyWritePNGLocal(out io.Writer, pngFileName string, opts KittyImgOpts) error { 81 | 82 | _, e := fmt.Fprint(out, opts.ToHeader("a=T", "f=100", "t=f")) 83 | if e != nil { 84 | return e 85 | } 86 | 87 | enc64 := base64.NewEncoder(base64.StdEncoding, out) 88 | 89 | _, e = fmt.Fprint(enc64, pngFileName) 90 | if e != nil { 91 | return e 92 | } 93 | 94 | e = enc64.Close() 95 | if e != nil { 96 | return e 97 | } 98 | 99 | _, e = fmt.Fprint(out, KITTY_IMG_FTR) 100 | return e 101 | } 102 | 103 | // Serialize image.Image into Kitty terminal in-band format. 104 | func KittyWriteImage(out io.Writer, iImg image.Image, opts KittyImgOpts) error { 105 | 106 | pBuf := new(bytes.Buffer) 107 | if E := png.Encode(pBuf, iImg); E != nil { 108 | return E 109 | } 110 | 111 | return KittyCopyPNGInline(out, pBuf, opts) 112 | } 113 | 114 | // Serialize PNG image from io.Reader into Kitty terminal in-band format. 115 | func KittyCopyPNGInline(out io.Writer, in io.Reader, opts KittyImgOpts) error { 116 | 117 | _, err := fmt.Fprint(out, opts.ToHeader("a=T", "f=100", "t=d", "m=1"), KITTY_IMG_FTR) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | // PIPELINE: PNG (io.Reader) -> B64 -> CHUNKER -> (io.Writer) 123 | // SEND IN 4K CHUNKS 124 | cw := kittyChunkWri{ 125 | nChunkSize: 4096, 126 | iWri: out, 127 | } 128 | 129 | enc64 := base64.NewEncoder(base64.StdEncoding, &cw) 130 | _, err = io.Copy(enc64, in) 131 | return errors.Join( 132 | err, 133 | enc64.Close(), 134 | cw.Close(), 135 | ) 136 | } 137 | -------------------------------------------------------------------------------- /kittyChunkWri.go: -------------------------------------------------------------------------------- 1 | package rasterm 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | type kittyChunkWri struct { 9 | iWri io.Writer 10 | nChunkSize int 11 | nMod int 12 | } 13 | 14 | // signal last chunk to Kitty on close 15 | func (c *kittyChunkWri) Close() error { 16 | _, err := fmt.Fprint(c.iWri, KITTY_IMG_HDR, "m=0;", KITTY_IMG_FTR) 17 | return err 18 | } 19 | 20 | func (c *kittyChunkWri) Write(buf []byte) (int, error) { 21 | 22 | l := len(buf) 23 | var toWrite, nWritten int 24 | 25 | for l > 0 { 26 | 27 | if (c.nMod + l) >= c.nChunkSize { 28 | toWrite = c.nChunkSize - c.nMod 29 | c.nMod = 0 30 | } else { 31 | toWrite = l 32 | c.nMod += l 33 | } 34 | 35 | // prefix 36 | _, err := fmt.Fprint(c.iWri, KITTY_IMG_HDR, "m=1;") 37 | if err != nil { 38 | return nWritten, err 39 | } 40 | 41 | // data 42 | var n int 43 | n, err = c.iWri.Write(buf[:toWrite]) 44 | nWritten += n 45 | if err != nil { 46 | return nWritten, err 47 | } 48 | 49 | // suffix 50 | _, err = fmt.Fprint(c.iWri, KITTY_IMG_FTR) 51 | if err != nil { 52 | return nWritten, err 53 | } 54 | 55 | buf = buf[toWrite:] 56 | l -= toWrite 57 | } 58 | 59 | return nWritten, nil 60 | } 61 | -------------------------------------------------------------------------------- /rasterm_test.go: -------------------------------------------------------------------------------- 1 | package rasterm 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "image" 7 | _ "image/gif" 8 | _ "image/jpeg" 9 | _ "image/png" 10 | "io" 11 | "os" 12 | "path/filepath" 13 | "testing" 14 | ) 15 | 16 | var testFiles []string 17 | 18 | func init() { 19 | 20 | files, e := os.ReadDir("./test_images") 21 | if e != nil { 22 | panic(e) 23 | } 24 | 25 | for _, fi := range files { 26 | switch fi.Name() { 27 | default: 28 | testFiles = append(testFiles, fi.Name()) 29 | } 30 | } 31 | 32 | os.Stdout.Write([]byte(ESC_ERASE_DISPLAY)) 33 | } 34 | 35 | func getFile(fpath string) (*os.File, int64, error) { 36 | 37 | pF, E := os.Open(fpath) 38 | if E != nil { 39 | return nil, 0, E 40 | } 41 | 42 | fInf, E := pF.Stat() 43 | if E != nil { 44 | pF.Close() 45 | return nil, 0, E 46 | } 47 | 48 | return pF, fInf.Size(), nil 49 | } 50 | 51 | type TestLogger interface { 52 | Log(...interface{}) 53 | Logf(string, ...interface{}) 54 | } 55 | 56 | func testImage(iWri io.Writer, fpath, mode string) error { 57 | 58 | fIn, nImgLen, err := getFile(fpath) 59 | if err != nil { 60 | return err 61 | } 62 | defer fIn.Close() 63 | 64 | fmt.Println(fpath) 65 | 66 | imgCfg, fmtName, err := image.DecodeConfig(fIn) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | _, err = fIn.Seek(0, 0) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | iImg, _, err := image.Decode(fIn) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | _, err = fIn.Seek(0, 0) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | fmt.Printf("[FMT: %s, W: %d, H: %d, LEN: %d, IMG: %T]\n", fmtName, imgCfg.Width, imgCfg.Height, nImgLen, iImg) 87 | 88 | switch mode { 89 | case "iterm": 90 | 91 | // WEZ/ITERM SUPPORT ALL FORMATS, SO NO NEED TO RE-ENCODE TO PNG 92 | err = ItermCopyFileInline(iWri, fIn, nImgLen) 93 | 94 | case "sixel": 95 | 96 | if iPaletted, bOK := iImg.(*image.Paletted); bOK { 97 | 98 | err = SixelWriteImage(iWri, iPaletted) 99 | 100 | } else { 101 | 102 | fmt.Println("[NOT PALETTED, SKIPPING.]") 103 | } 104 | 105 | case "kitty": 106 | 107 | if fmtName == "png" { 108 | 109 | fmt.Println("Kitty PNG Local File") 110 | eF := KittyWritePNGLocal(iWri, fpath, KittyImgOpts{}) 111 | fmt.Println("\nKitty PNG Inline") 112 | eI := KittyCopyPNGInline(iWri, fIn, KittyImgOpts{}) 113 | err = errors.Join(eI, eF) 114 | 115 | } else { 116 | 117 | err = KittyWriteImage(iWri, iImg, KittyImgOpts{}) 118 | } 119 | } 120 | 121 | fmt.Println("") 122 | return err 123 | } 124 | 125 | func testEx(iLog TestLogger, iWri io.Writer, mode string, testFiles []string) error { 126 | 127 | /* 128 | fProf, E := os.Create("./kitty.prof") 129 | if E != nil { 130 | return E 131 | } 132 | defer fProf.Close() 133 | pprof.StartCPUProfile(fProf) 134 | defer pprof.StopCPUProfile() 135 | */ 136 | 137 | baseDir, err := filepath.Abs("./test_images") 138 | if err != nil { 139 | return err 140 | } 141 | 142 | for _, file := range testFiles { 143 | 144 | fpath := baseDir + "/" + file 145 | iLog.Log(fpath) 146 | 147 | err = testImage(iWri, fpath, mode) 148 | if err != nil { 149 | iLog.Log(err) 150 | } 151 | } 152 | 153 | return nil 154 | } 155 | 156 | func TestSixel(pT *testing.T) { 157 | 158 | // NOTE: go test captures stdin/stdout to where they are no longer TTYs. 159 | // This prevents IsSixelCapable() from operating, so always attempting 160 | // sixels from the test, whether the terminal is capable or not. 161 | // https://github.com/golang/go/issues/18153 162 | 163 | // bSix, err := IsSixelCapable() 164 | // if err != nil { 165 | // pT.Fatal(err) 166 | // } 167 | 168 | fmt.Println("SIXEL") 169 | if E := testEx(pT, os.Stdout, "sixel", testFiles); E != nil { 170 | pT.Fatal(E) 171 | } 172 | } 173 | 174 | func TestItermWez(pT *testing.T) { 175 | 176 | if !IsItermCapable() { 177 | pT.SkipNow() 178 | } 179 | 180 | fmt.Println("ITERM/WEZ/MINTTY") 181 | if E := testEx(pT, os.Stdout, "iterm", testFiles); E != nil { 182 | pT.Fatal(E) 183 | } 184 | } 185 | 186 | func TestKitty(pT *testing.T) { 187 | 188 | if !IsKittyCapable() { 189 | pT.SkipNow() 190 | } 191 | 192 | fmt.Println("KITTY") 193 | if E := testEx(pT, os.Stdout, "kitty", testFiles); E != nil { 194 | pT.Fatal(E) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BourgeoisBear/rasterm/4bdb036b3ac9c927e1624cb7742eb04ad3fd07a2/screenshot.png -------------------------------------------------------------------------------- /sixel.go: -------------------------------------------------------------------------------- 1 | package rasterm 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "io" 7 | "strconv" 8 | ) 9 | 10 | // NOTE: valid sixel encodeds are in range 0x3F (?) TO 0x7E (~) 11 | const ( 12 | SIXEL_MIN byte = 0x3f 13 | SIXEL_MAX byte = 0x7e 14 | ) 15 | 16 | func IsSixelCapable() (bool, error) { 17 | 18 | sATT, E := RequestTermAttributes() 19 | if E != nil { 20 | return false, E 21 | } 22 | 23 | for ix := range sATT { 24 | 25 | // IGNORE `4` @ 1ST INDEX -- THAT IS TERMINAL ID RATHER THAN SIXEL SUPPORT 26 | if (ix > 0) && (sATT[ix] == 4) { 27 | return true, nil 28 | } 29 | } 30 | 31 | return false, nil 32 | } 33 | 34 | /* 35 | Encodes a paletted image into DECSIXEL format. 36 | Forked & heavily modified from https://github.com/mattn/go-sixel/ 37 | 38 | Since SIXEL does not support alpha transparency, any alpha > 0 39 | will be treated as fully opaque. 40 | 41 | SIXEL is a paletted format. To keep dependencies to a minimum, this only 42 | supports paletted images. Palette entries beyond index 255 are ignored. 43 | To handle non-paletted images, please pre-dither from the caller. 44 | 45 | For more information on DECSIXEL format: 46 | 47 | https://www.vt100.net/docs/vt3xx-gp/chapter14.html 48 | https://saitoha.github.io/libsixel/ 49 | */ 50 | func SixelWriteImage(out io.Writer, pI *image.Paletted) (E error) { 51 | 52 | width, height := pI.Bounds().Dx(), pI.Bounds().Dy() 53 | if (width <= 0) || (height <= 0) { 54 | return 55 | } 56 | 57 | if len(pI.Palette) == 0 { 58 | return 59 | } 60 | 61 | OSC_OPEN, OSC_CLOSE := "\x1b", "\x1b\\" 62 | 63 | // CAPTURE WRITE ERROR FOR SIMPLIFIED CHECKING 64 | fnWri := func(v []byte) error { 65 | _, E = out.Write(v) 66 | return E 67 | } 68 | 69 | // INTRODUCER = P0;1q 70 | // 0; rely on RASTER ATTRIBUTES to set aspect ratio 71 | // 1; palette[0] as opaque 72 | // RASTER ATTRIBUTES (1:1 aspect ratio) = "1;1;width;height 73 | _, E = fmt.Fprintf(out, "%sP0;1q\"1;1;%d;%d", OSC_OPEN, width, height) 74 | if E != nil { 75 | return 76 | } 77 | 78 | // CONVERT uint32 [0..0xFFFF] COLOR COMPONENT TO WHOLE PERCENTAGE 79 | P := func(v uint32) uint8 { 80 | return uint8(((v + 1) * 100) >> 16) 81 | } 82 | 83 | // SEND PALETTE 84 | for ix_color, v := range pI.Palette { 85 | 86 | // SIXEL ONLY SUPPORTS 256 COLORS 87 | if ix_color > 255 { 88 | break 89 | } 90 | 91 | // R,G,B AS WHOLE PERCENTAGES 92 | r, g, b, a := v.RGBA() 93 | 94 | // OMIT FULLY-TRANSPARENT COLORS FROM GCI PALETTE 95 | if a == 0 { 96 | continue 97 | } 98 | 99 | // DECGCI (#): Graphics Color Introducer 100 | // SEE: https://www.vt100.net/docs/vt3xx-gp/chapter14.html 101 | _, E = fmt.Fprintf(out, "#%d;2;%d;%d;%d", ix_color, P(r), P(g), P(b)) 102 | if E != nil { 103 | return 104 | } 105 | } 106 | 107 | nColors := len(pI.Palette) 108 | color_used := make([]bool, nColors) 109 | color_used_blank := make([]bool, nColors) 110 | buf := make([]byte, width*nColors) 111 | buf_blank := make([]byte, width*nColors) 112 | 113 | // WALK IMAGE HEIGHT IN SIXEL ROWS 114 | sixel_rows := (height + 5) / 6 115 | for ix_srow := 0; ix_srow < sixel_rows; ix_srow++ { 116 | 117 | // GRAPHICS NL (start a new sixel line) 118 | if ix_srow > 0 { 119 | if fnWri([]byte(`-`)) != nil { 120 | return 121 | } 122 | } 123 | 124 | // RESET COLOR USAGE FLAGS & SIXEL LINE BUFFER 125 | copy(color_used, color_used_blank) 126 | copy(buf, buf_blank) 127 | 128 | // BUFFER SIXEL ROW, TRACK USED COLORS 129 | for p := 0; p < 6; p++ { 130 | 131 | y := (ix_srow * 6) + p 132 | 133 | if y >= height { 134 | break 135 | } 136 | 137 | for x := 0; x < width; x++ { 138 | 139 | color_ix := pI.ColorIndexAt(x, y) 140 | 141 | // SKIP FULLY-TRANSPARENT PIXELS 142 | _, _, _, a := pI.Palette[color_ix].RGBA() 143 | if a == 0 { 144 | continue 145 | } 146 | 147 | color_used[color_ix] = true 148 | buf[(width*int(color_ix))+x] |= 1 << uint(p) 149 | } 150 | } 151 | 152 | // RENDER SIXEL ROW FOR EACH PALETTE ENTRY 153 | bFirstColorWritten := false 154 | for n := 0; n < nColors; n++ { 155 | 156 | if !color_used[n] { 157 | continue 158 | } 159 | 160 | // GRAPHICS CR (overwrite last line w/ new color) 161 | if bFirstColorWritten { 162 | if fnWri([]byte(`$`)) != nil { 163 | return 164 | } 165 | } 166 | 167 | // COLOR INTRODUCER (#) 168 | tmpCI := make([]byte, 1, 4) 169 | tmpCI[0] = byte('#') 170 | tmpCI = strconv.AppendInt(tmpCI, int64(n), 10) 171 | if fnWri(tmpCI) != nil { 172 | return 173 | } 174 | 175 | rleCt := 0 176 | cPrev := byte(255) 177 | for x := 0; x < width; x++ { 178 | 179 | // GET BUFFERED SIXEL 180 | cNext := buf[(n*width)+x] 181 | 182 | // RLE ENCODE, WRITE ON VALUE CHANGE 183 | // USE 255 AS SENTINEL FOR INITIAL RUN 184 | if (cPrev != 255) && (cNext != cPrev) { 185 | 186 | if fnWri(encodeGRI(rleCt, cPrev)) != nil { 187 | return 188 | } 189 | rleCt = 0 190 | } 191 | 192 | cPrev = cNext 193 | rleCt++ 194 | } 195 | 196 | // WRITE LAST SIXEL IN LINE 197 | if fnWri(encodeGRI(rleCt, cPrev)) != nil { 198 | return 199 | } 200 | 201 | bFirstColorWritten = true 202 | } 203 | } 204 | 205 | // SIXEL TERMINATOR 206 | fnWri([]byte(OSC_CLOSE)) 207 | return 208 | } 209 | 210 | func encodeGRI(rleCt int, sixl byte) []byte { 211 | 212 | if rleCt <= 0 { 213 | return nil 214 | } 215 | 216 | // MASK WITH VALID SIXEL BITS, APPLY OFFSET 217 | sixl = SIXEL_MIN + (sixl & 0b111111) 218 | tmpGRI := make([]byte, 0, 6) 219 | 220 | if rleCt > 3 { 221 | 222 | // GRAPHICS REPEAT INTRODUCER (!) 223 | tmpGRI = append(tmpGRI, byte('!')) 224 | tmpGRI = strconv.AppendInt(tmpGRI, int64(rleCt), 10) 225 | tmpGRI = append(tmpGRI, sixl) 226 | 227 | } else if rleCt > 0 { 228 | 229 | for ix := 0; ix < rleCt; ix++ { 230 | tmpGRI = append(tmpGRI, sixl) 231 | } 232 | } 233 | 234 | return tmpGRI 235 | } 236 | -------------------------------------------------------------------------------- /term_misc.go: -------------------------------------------------------------------------------- 1 | package rasterm 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "golang.org/x/term" 13 | ) 14 | 15 | const ( 16 | ESC_ERASE_DISPLAY = "\x1b[2J\x1b[0;0H" 17 | ) 18 | 19 | var ( 20 | E_NON_TTY = errors.New("NON TTY") 21 | E_TIMED_OUT = errors.New("TERM RESPONSE TIMED OUT") 22 | ) 23 | 24 | func IsTmuxScreen() bool { 25 | TERM := strings.ToLower(strings.TrimSpace(os.Getenv("TERM"))) 26 | return strings.HasPrefix(TERM, "screen") 27 | } 28 | 29 | /* 30 | Handles request/response terminal control sequences like [0c 31 | 32 | STDIN & STDOUT are parameterized for special cases. 33 | os.Stdin & os.Stdout are usually sufficient. 34 | 35 | `sRq` should be the request control sequence to the terminal. 36 | 37 | NOTE: only captures up to 1KB of response 38 | 39 | NOTE: when println debugging the response, probably want to go-escape 40 | it, like: 41 | 42 | fmt.Printf("%#v\n", sRsp) 43 | 44 | since most responses begin with , which the terminal treats as 45 | another control sequence rather than text to output. 46 | */ 47 | func TermRequestResponse(fileIN, fileOUT *os.File, sRq string) (sRsp []byte, E error) { 48 | 49 | // defer func() { 50 | // if E != nil { 51 | // if _, file, line, ok := runtime.Caller(1); ok { 52 | // E = fmt.Errorf("%s:%d - %s", file, line, E.Error()) 53 | // } 54 | // } 55 | // }() 56 | 57 | fdIN := int(fileIN.Fd()) 58 | 59 | // NOTE: raw mode tip came from https://play.golang.org/p/kcMLTiDRZY 60 | if !term.IsTerminal(fdIN) { 61 | return nil, E_NON_TTY 62 | } 63 | 64 | // STDIN "RAW MODE" TO CAPTURE TERMINAL RESPONSE 65 | // NOTE: without this, response bypasses stdin, 66 | // and is written directly to the console 67 | var oldState *term.State 68 | if oldState, E = term.MakeRaw(fdIN); E != nil { 69 | return 70 | } 71 | defer func() { 72 | // CAPTURE RESTORE ERROR (IF ANY) IF THERE HASN'T ALREADY BEEN AN ERROR 73 | if e2 := term.Restore(fdIN, oldState); E == nil { 74 | E = e2 75 | } 76 | }() 77 | 78 | // SEND REQUEST 79 | if _, E = fileOUT.Write([]byte(sRq)); E != nil { 80 | return 81 | } 82 | 83 | TMP := make([]byte, 1024) 84 | 85 | // WAIT 1/16 SECOND FOR TERM RESPONSE. IF TIMER EXPIRES, 86 | // TRIGGER BYTES TO STDIN SO .Read() CAN FINISH 87 | tmr := time.NewTimer(time.Second >> 4) 88 | cDone := make(chan bool) 89 | WG := sync.WaitGroup{} 90 | WG.Add(1) 91 | go func() { 92 | select { 93 | case <-tmr.C: 94 | // "Report Cursor Position (CPR) [row; column] 95 | // JUST TO GET SOME BYTES TO STDIN 96 | // NOTE: seems to work for everything except mlterm 97 | fileOUT.Write([]byte("\x1b\x1b[" + "6n")) 98 | break 99 | case <-cDone: 100 | break 101 | } 102 | WG.Done() 103 | }() 104 | 105 | // CAPTURE RESPONSE 106 | nBytes, E := fileIN.Read(TMP) 107 | 108 | // ENSURE GOROUTINE TERMINATION 109 | if tmr.Stop() { 110 | cDone <- true 111 | } else { 112 | // fmt.Fprintf(os.Stderr, "%#v\n", string(TMP[1:nBytes])) 113 | E = E_TIMED_OUT 114 | } 115 | WG.Wait() 116 | 117 | if (nBytes > 0) && (E != E_TIMED_OUT) { 118 | return TMP[:nBytes], nil 119 | } 120 | 121 | return nil, E 122 | } 123 | 124 | /* 125 | NOTE: the calling program MUST be connected to an actual terminal for this to work 126 | 127 | Requests terminal attributes per: 128 | https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Functions-using-CSI-_-ordered-by-the-final-character-lparen-s-rparen:CSI-Ps-c.1CA3 129 | 130 | CSI Ps c Send Device Attributes (Primary DA). 131 | Ps = 0 or omitted ⇒ request attributes from terminal. The 132 | response depends on the decTerminalID resource setting. 133 | ⇒ CSI ? 1 ; 2 c ("VT100 with Advanced Video Option") 134 | ⇒ CSI ? 1 ; 0 c ("VT101 with No Options") 135 | ⇒ CSI ? 4 ; 6 c ("VT132 with Advanced Video and Graphics") 136 | ⇒ CSI ? 6 c ("VT102") 137 | ⇒ CSI ? 7 c ("VT131") 138 | ⇒ CSI ? 1 2 ; Ps c ("VT125") 139 | ⇒ CSI ? 6 2 ; Ps c ("VT220") 140 | ⇒ CSI ? 6 3 ; Ps c ("VT320") 141 | ⇒ CSI ? 6 4 ; Ps c ("VT420") 142 | 143 | The VT100-style response parameters do not mean anything by 144 | themselves. VT220 (and higher) parameters do, telling the 145 | host what features the terminal supports: 146 | Ps = 1 ⇒ 132-columns. 147 | Ps = 2 ⇒ Printer. 148 | Ps = 3 ⇒ ReGIS graphics. 149 | Ps = 4 ⇒ Sixel graphics. 150 | Ps = 6 ⇒ Selective erase. 151 | Ps = 8 ⇒ User-defined keys. 152 | Ps = 9 ⇒ National Replacement Character sets. 153 | Ps = 1 5 ⇒ Technical characters. 154 | Ps = 1 6 ⇒ Locator port. 155 | Ps = 1 7 ⇒ Terminal state interrogation. 156 | Ps = 1 8 ⇒ User windows. 157 | Ps = 2 1 ⇒ Horizontal scrolling. 158 | Ps = 2 2 ⇒ ANSI color, e.g., VT525. 159 | Ps = 2 8 ⇒ Rectangular editing. 160 | Ps = 2 9 ⇒ ANSI text locator (i.e., DEC Locator mode). 161 | */ 162 | 163 | func RequestTermAttributes() (sAttrs []int, E error) { 164 | 165 | text, E := TermRequestResponse(os.Stdin, os.Stdout, "\x1b[0c") 166 | if E != nil { 167 | return 168 | } 169 | 170 | // EXTRACT CODES 171 | t2 := rxNumber.FindAll(text, -1) 172 | sAttrs = make([]int, len(t2)) 173 | for ix, sN := range t2 { 174 | iN, _ := strconv.Atoi(string(sN)) 175 | sAttrs[ix] = iN 176 | } 177 | 178 | return 179 | } 180 | 181 | var rxNumber = regexp.MustCompile(`\d+`) 182 | 183 | func lcaseEnv(k string) string { 184 | return strings.ToLower(strings.TrimSpace(os.Getenv(k))) 185 | } 186 | 187 | func GetEnvIdentifiers() map[string]string { 188 | 189 | KEYS := []string{"TERM", "TERM_PROGRAM", "LC_TERMINAL", "VIM_TERMINAL", "KITTY_WINDOW_ID"} 190 | V := make(map[string]string) 191 | for _, K := range KEYS { 192 | V[K] = lcaseEnv(K) 193 | } 194 | 195 | return V 196 | } 197 | -------------------------------------------------------------------------------- /test_images/11.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BourgeoisBear/rasterm/4bdb036b3ac9c927e1624cb7742eb04ad3fd07a2/test_images/11.gif -------------------------------------------------------------------------------- /test_images/1580624931717m.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BourgeoisBear/rasterm/4bdb036b3ac9c927e1624cb7742eb04ad3fd07a2/test_images/1580624931717m.jpg -------------------------------------------------------------------------------- /test_images/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BourgeoisBear/rasterm/4bdb036b3ac9c927e1624cb7742eb04ad3fd07a2/test_images/19.png -------------------------------------------------------------------------------- /test_images/28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BourgeoisBear/rasterm/4bdb036b3ac9c927e1624cb7742eb04ad3fd07a2/test_images/28.png -------------------------------------------------------------------------------- /test_images/69224903_2412485828820667_3857837082370113536_n.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BourgeoisBear/rasterm/4bdb036b3ac9c927e1624cb7742eb04ad3fd07a2/test_images/69224903_2412485828820667_3857837082370113536_n.jpg -------------------------------------------------------------------------------- /test_images/Image15.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BourgeoisBear/rasterm/4bdb036b3ac9c927e1624cb7742eb04ad3fd07a2/test_images/Image15.gif -------------------------------------------------------------------------------- /test_images/Ys1pc88_title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BourgeoisBear/rasterm/4bdb036b3ac9c927e1624cb7742eb04ad3fd07a2/test_images/Ys1pc88_title.png -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | TODO: 2 | - iterm2 width, height, preserveAspectRatio, inline 3 | https://iterm2.com/documentation-images.html 4 | - gif frames 5 | - animation 6 | - clear, d= 7 | a all 8 | i id (i=, and optionally p=) 9 | n newest (optionallay I= and p=) 10 | c cursor position 11 | f animation framse 12 | p specific cell (x=, y=) 13 | q cell at z-index (x, y, z) 14 | r id in range (x <= id <= y) 15 | x placements at column (x) 16 | y placements at row (y) 17 | z placementz at z index (z) 18 | --------------------------------------------------------------------------------