├── .github └── workflows │ └── test.yml ├── LICENSE ├── README.md ├── cmd └── icat │ ├── .gitignore │ ├── icat.go │ └── icat_test.go ├── dolmen.gif ├── examples_test.go ├── go.mod ├── go.sum ├── payload.go └── print.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | on: [ push, pull_request ] 4 | jobs: 5 | test: 6 | strategy: 7 | matrix: 8 | go-version: 9 | - stable 10 | - oldstable 11 | os: 12 | - ubuntu-latest 13 | # - macos-latest 14 | #arch: 15 | # - amd64 16 | # - ppc64le 17 | # - s390x 18 | # - arm64 19 | runs-on: ${{ matrix.os }} 20 | env: 21 | GO111MODULE: on 22 | steps: 23 | # https://github.com/mvdan/github-actions-golang 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | - name: Install Go 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: ${{ matrix.go-version }} 30 | - name: Fetch dependencies 31 | run: go mod download 32 | - name: Test 33 | run: go test -v -covermode=atomic -coverprofile=coverage.out ./... 34 | - name: Upload coverage to Codecov.io 35 | # https://github.com/codecov/codecov-action 36 | # https://docs.codecov.com/docs/quick-start 37 | uses: codecov/codecov-action@v5 38 | with: 39 | # https://app.codecov.io/gh/dolmen-go/kittyimg/settings 40 | # https://github.com/dolmen-go/kittyimg/settings/secrets/actions 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kittyimg 2 | 3 | `kittyimg` is a Go library that allows to display images in terminal emulators implementing [kitty's *terminal graphics protocol*](https://sw.kovidgoyal.net/kitty/graphics-protocol.html). 4 | 5 | [![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)](https://pkg.go.dev/github.com/dolmen-go/kittyimg) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/dolmen-go/kittyimg)](https://goreportcard.com/report/github.com/dolmen-go/kittyimg) 7 | [![Codecov](https://img.shields.io/codecov/c/github/dolmen-go/kittyimg/main.svg)](https://codecov.io/gh/dolmen-go/kittyimg/branch/main) 8 | 9 | ## ✨ Features 10 | 11 | A [basic API](https://pkg.go.dev/github.com/dolmen-go/kittyimg) (`Fprint`, `Fprintln`, `Transcode`) allows to display an image (loaded with stdlib's [image](https://pkg.go.dev/image) package) at the cursor position. 12 | 13 | ``` 14 | go get github.com/dolmen-go/kittyimg@latest 15 | ``` 16 | 17 | A command-line tool ([`icat`](https://pkg.go.dev/github.com/dolmen-go/kittyimg/cmd/icat)) is provided. 18 | 19 | ``` 20 | go install github.com/dolmen-go/kittyimg/cmd/icat@latest 21 | ``` 22 | 23 | `icat ` works the same as [Kitty's command](https://sw.kovidgoyal.net/kitty/kittens/icat/) `kitten icat --transfer-mode=stream --align=left `. 24 | 25 | ## 🏗️ Status 26 | 27 | Production ready. 28 | 29 | ## 🔄 See also 30 | 31 | The [Go Playground](https://go.dev/play) has [support for displaying images](https://play.golang.org/p/LXmxkAV0z_M) with its own protocol: `IMAGE:` prefix followed by base64 image file data. 32 | 33 | Display tools for images on terminals: 34 | * [tycat](https://git.enlightenment.org/apps/terminology.git/tree/src/bin/tycat.c): 35 | Similar tool to `icat`, but for the Enlightenment Terminology app (which uses a different terminal protocol). 36 | * [timg](https://github.com/hzeller/timg) 37 | * [viu](https://github.com/atanunq/viu) 38 | * [chafa](https://hpjansson.org/chafa/) 39 | 40 | ## 🛡️ License 41 | 42 | Copyright 2021-2025 Olivier Mengué 43 | 44 | Licensed under the Apache License, Version 2.0 (the "License"); 45 | you may not use this file except in compliance with the License. 46 | You may obtain a copy of the License at 47 | 48 | http://www.apache.org/licenses/LICENSE-2.0 49 | 50 | Unless required by applicable law or agreed to in writing, software 51 | distributed under the License is distributed on an "AS IS" BASIS, 52 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 53 | See the License for the specific language governing permissions and 54 | limitations under the License. 55 | -------------------------------------------------------------------------------- /cmd/icat/.gitignore: -------------------------------------------------------------------------------- 1 | /icat 2 | /kitty.png 3 | -------------------------------------------------------------------------------- /cmd/icat/icat.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021-2025 Olivier Mengué. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // icat - Print images in kitty/ghostty terminal emulators 18 | // 19 | // Usage 20 | // 21 | // icat < file.png 22 | // icat file.png [file.png [...]] 23 | // 24 | // Install 25 | // 26 | // go install github.com/dolmen-go/kittyimg/cmd/icat@latest 27 | // 28 | // Description 29 | // 30 | // icat kitty.png 31 | // 32 | // is equivalent to: 33 | // 34 | // kitten icat --transfer-mode=stream --align=left kitty.png 35 | package main 36 | 37 | import ( 38 | "fmt" 39 | "os" 40 | 41 | _ "image/gif" 42 | _ "image/jpeg" 43 | _ "image/png" 44 | 45 | "golang.org/x/term" 46 | 47 | "github.com/dolmen-go/kittyimg" 48 | ) 49 | 50 | func main() { 51 | var status int 52 | if err := _main(os.Stdout, os.Args[1:]); err != nil { 53 | fmt.Fprintln(os.Stderr, err) 54 | status = 1 55 | } 56 | os.Exit(status) 57 | } 58 | 59 | func _main(out *os.File, args []string) error { 60 | if (len(args) == 0 || args[0] == "-") && !term.IsTerminal(int(os.Stdin.Fd())) { 61 | if err := kittyimg.Transcode(out, os.Stdin); err != nil { 62 | return err 63 | } 64 | out.WriteString("\n") 65 | return nil 66 | } 67 | 68 | for _, file := range args { 69 | err := (func(file string) error { 70 | f, err := os.Open(file) 71 | if err != nil { 72 | return err 73 | } 74 | defer f.Close() 75 | 76 | return kittyimg.Transcode(out, f) 77 | })(file) 78 | if err != nil { 79 | return err 80 | } 81 | out.WriteString("\n") 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /cmd/icat/icat_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func runMain(t *testing.T, name string, args ...string) (string, error) { 11 | r, w, err := os.Pipe() 12 | if err != nil { 13 | t.Fatal("pipe:", err) 14 | } 15 | done := make(chan string) 16 | go func() { 17 | defer r.Close() 18 | var buf strings.Builder 19 | _, err = io.Copy(&buf, r) 20 | done <- buf.String() 21 | }() 22 | 23 | var mainErr error 24 | t.Run(name, func(t *testing.T) { 25 | origStdout := os.Stdout 26 | os.Stdout = w 27 | t.Cleanup(func() { 28 | os.Stdout = origStdout 29 | w.Close() 30 | }) 31 | 32 | mainErr = _main(w, args) 33 | }) 34 | 35 | out := <-done 36 | if err != nil { // Report copy error 37 | // t.Logf("%T %T", err, errors.Unwrap(err)) 38 | t.Error("copy error:", err) 39 | } 40 | return out, mainErr 41 | } 42 | 43 | func Test(t *testing.T) { 44 | out, err := runMain(t, "icat dolmen.gif", "../../dolmen.gif") 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | t.Log(out) 49 | t.Logf("%q", out) 50 | if !strings.HasPrefix(out, "\x1b_Gq=1,a=T,f=32,s=420,v=66,t=d,o=z,m=0;eJzsndGt") || 51 | !strings.HasSuffix(out, "9yLYll\x1b\\\n") { 52 | t.Error("unexpected output") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /dolmen.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dolmen-go/kittyimg/6d6dc00afde0b04cb11e26770851ea07ceb1e308/dolmen.gif -------------------------------------------------------------------------------- /examples_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021-2025 Olivier Mengué. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package kittyimg_test 18 | 19 | import ( 20 | "bytes" 21 | "embed" 22 | "image" 23 | "io" 24 | "os" 25 | "strings" 26 | "testing" 27 | 28 | _ "image/gif" 29 | _ "image/png" 30 | 31 | "github.com/dolmen-go/kittyimg" 32 | ) 33 | 34 | // Source: https://go.dev/play/p/XN6x3L23Vok 35 | var favicon = []byte{ 36 | 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 37 | 0, 0, 0, 13, 'I', 'H', 'D', 'R', 38 | 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x0f, 0x04, 0x03, 0x00, 0x00, 0x00, 39 | 0x1f, 0x5d, 0x52, 0x1c, // CRC 40 | 0, 0, 0, 15, 'P', 'L', 'T', 'E', 41 | 0x7a, 0xdf, 0xfd, 0xfd, 0xff, 0xfc, 0x39, 0x4d, 0x52, 0x19, 0x16, 0x15, 0xc3, 0x8d, 0x76, 42 | 0xc7, 0x36, 0x2c, 0xf5, // CRC 43 | 0, 0, 0, 64, 'I', 'D', 'A', 'T', 44 | 0x08, 0xd7, 0x95, 0xc9, 0xd1, 0x0d, 0xc0, 0x20, 0x0c, 0x03, 0xd1, 0x23, 0x5d, 0xa0, 0x49, 0x17, 45 | 0x20, 0x4c, 0xc0, 0x10, 0xec, 0x3f, 0x53, 0x8d, 0xc2, 0x02, 0x9c, 0xfc, 0xf1, 0x24, 0xe3, 0x31, 46 | 0x54, 0x3a, 0xd1, 0x51, 0x96, 0x74, 0x1c, 0xcd, 0x18, 0xed, 0x9b, 0x9a, 0x11, 0x85, 0x24, 0xea, 47 | 0xda, 0xe0, 0x99, 0x14, 0xd6, 0x3a, 0x68, 0x6f, 0x41, 0xdd, 0xe2, 0x07, 0xdb, 0xb5, 0x05, 0xca, 48 | 0xdb, 0xb2, 0x9a, 0xdd, // CRC 49 | 0, 0, 0, 0, 'I', 'E', 'N', 'D', 0xae, 0x42, 0x60, 0x82, 50 | } 51 | 52 | func Example() { 53 | f := bytes.NewReader(favicon) 54 | 55 | img, _, err := image.Decode(f) 56 | if err != nil { 57 | panic(err) 58 | } 59 | 60 | kittyimg.Fprintln(os.Stdout, img) 61 | } 62 | 63 | func ExampleTranscode_png() { 64 | f := bytes.NewReader(favicon) 65 | 66 | kittyimg.Transcode(os.Stdout, f) 67 | os.Stdout.WriteString("\n") 68 | } 69 | 70 | //go:embed dolmen.gif 71 | var files embed.FS 72 | 73 | func ExampleTranscode_gif() { 74 | f, err := files.Open("dolmen.gif") 75 | if err != nil { 76 | panic(err) 77 | } 78 | defer f.Close() 79 | 80 | kittyimg.Transcode(os.Stdout, f) 81 | os.Stdout.WriteString("\n") 82 | } 83 | 84 | func captureExampleOutput(t *testing.T, name string, example func()) string { 85 | r, w, err := os.Pipe() 86 | if err != nil { 87 | t.Fatal("pipe:", err) 88 | } 89 | done := make(chan string) 90 | go func() { 91 | defer r.Close() 92 | var buf strings.Builder 93 | _, err = io.Copy(&buf, r) 94 | done <- buf.String() 95 | }() 96 | t.Run(name, func(t *testing.T) { 97 | origStdout := os.Stdout 98 | os.Stdout = w 99 | t.Cleanup(func() { 100 | os.Stdout = origStdout 101 | w.Close() 102 | }) 103 | example() 104 | }) 105 | out := <-done 106 | if err != nil { // Report copy error 107 | // t.Logf("%T %T", err, errors.Unwrap(err)) 108 | t.Error("copy error:", err) 109 | } 110 | return out 111 | } 112 | 113 | func TestExample(t *testing.T) { 114 | out := captureExampleOutput(t, "Example", Example) 115 | t.Log(out) 116 | if !strings.HasPrefix(out, "\x1b_Gq=1,a=T,f=32,s=16,v=15,t=d,o=z,m=0;eJz6+//Pfxi29A") { 117 | t.Fatalf("unexpected output: %q", out) 118 | } 119 | } 120 | 121 | func TestExampleTranscode_png(t *testing.T) { 122 | out := captureExampleOutput(t, "ExampleTranscode_png", ExampleTranscode_png) 123 | t.Log(out) 124 | // PNG file is directly transmitted 125 | if !strings.HasPrefix(out, "\x1b_Gq=1,a=T,f=100,s=16,v=15,m=0;iVBORw0KGgoA") { 126 | t.Fatalf("unexpected output: %q", out) 127 | } 128 | } 129 | 130 | func TestExampleTranscode_gif(t *testing.T) { 131 | out := captureExampleOutput(t, "ExampleTranscode_gif", ExampleTranscode_gif) 132 | t.Log(out) 133 | if !strings.HasPrefix(out, "\x1b_Gq=1,a=T,f=32,s=420,v=66,t=d,o=z,m=0;eJzsndGt") { 134 | t.Fatalf("unexpected output: %q", out) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dolmen-go/kittyimg 2 | 3 | go 1.21 4 | 5 | require golang.org/x/term v0.29.0 6 | 7 | require golang.org/x/sys v0.30.0 // indirect 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 2 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 3 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 4 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 5 | -------------------------------------------------------------------------------- /payload.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021-2025 Olivier Mengué. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package kittyimg 18 | 19 | import ( 20 | "compress/zlib" 21 | "encoding/base64" 22 | "io" 23 | ) 24 | 25 | const ( 26 | // https://sw.kovidgoyal.net/kitty/graphics-protocol.html#remote-client 27 | chunkEncSize = 4096 28 | chunkRawSize = (chunkEncSize / 4) * 3 29 | ) 30 | 31 | // payloadWriter is an [io.WriteCloser] that encodes the payload binary data in the stream. 32 | // It handles encoding to base64 and 4096 characters chunking. 33 | // https://sw.kovidgoyal.net/kitty/graphics-protocol.html#remote-client 34 | type payloadWriter struct { 35 | bufEnc [chunkEncSize]byte 36 | bufRaw [chunkRawSize]byte 37 | n int 38 | w io.Writer 39 | } 40 | 41 | func (pw *payloadWriter) Reset(w io.Writer) { 42 | pw.w = w 43 | pw.n = 0 44 | } 45 | 46 | func (pw *payloadWriter) encode() error { 47 | // fmt.Fprintln(os.Stderr, len(bufRaw), "=>", (len(bufRaw)+2)/3*4) 48 | 49 | base64.StdEncoding.Encode(pw.bufEnc[:], pw.bufRaw[:pw.n]) 50 | _, err := pw.w.Write(pw.bufEnc[:(pw.n+2)/3*4]) 51 | pw.n = 0 52 | return err 53 | } 54 | 55 | func (pw *payloadWriter) Write(b []byte) (n int, err error) { 56 | for len(b) > 0 { 57 | if pw.n == cap(pw.bufRaw) { 58 | _, err = pw.w.Write([]byte("m=1;")) 59 | if err != nil { 60 | return 61 | } 62 | err = pw.encode() 63 | if err != nil { 64 | return 65 | } 66 | _, err = pw.w.Write([]byte("\033\\\033_G")) 67 | if err != nil { 68 | return 69 | } 70 | } 71 | 72 | l := copy(pw.bufRaw[pw.n:], b) 73 | pw.n += l 74 | n += l 75 | b = b[l:] 76 | } 77 | return 78 | } 79 | 80 | // Close closes the writer, flushing any unwritten data to the underlying [io.Writer], but does not close the underlying [io.Writer]. 81 | func (pw *payloadWriter) Close() (err error) { 82 | if pw.n == 0 { 83 | _, err = pw.w.Write([]byte("m=0;\033\\")) 84 | return 85 | } 86 | _, err = pw.w.Write([]byte("m=0;")) 87 | if err != nil { 88 | return 89 | } 90 | err = pw.encode() 91 | if err != nil { 92 | return 93 | } 94 | _, err = pw.w.Write([]byte("\033\\")) 95 | return 96 | } 97 | 98 | // zlibPayloadWriter is an [io.WriteCloser] that adds a [compress/zlib] layer over [payloadWriter]. 99 | // https://sw.kovidgoyal.net/kitty/graphics-protocol.html#compression 100 | type zlibPayloadWriter struct { 101 | buffer [16384]byte 102 | n int 103 | pw payloadWriter 104 | zw *zlib.Writer 105 | } 106 | 107 | func (zpw *zlibPayloadWriter) Reset(w io.Writer) { 108 | _, _ = w.Write([]byte("o=z,")) 109 | zpw.pw.Reset(w) 110 | zpw.zw = zlib.NewWriter(&zpw.pw) 111 | zpw.n = 0 112 | } 113 | 114 | func (zpw *zlibPayloadWriter) Write(b []byte) (n int, err error) { 115 | for len(b) > 0 { 116 | if zpw.n == cap(zpw.buffer) { 117 | _, err = zpw.zw.Write(zpw.buffer[:]) 118 | if err != nil { 119 | return 120 | } 121 | zpw.n = 0 122 | } 123 | m := copy(zpw.buffer[zpw.n:], b) 124 | zpw.n += m 125 | n += m 126 | b = b[m:] 127 | } 128 | return 129 | } 130 | 131 | // Close closes the Writer, flushing any unwritten data to the underlying [io.Writer], but does not close the underlying [io.Writer]. 132 | func (zp *zlibPayloadWriter) Close() error { 133 | if zp.n > 0 { 134 | if _, err := zp.zw.Write(zp.buffer[:zp.n]); err != nil { 135 | return err 136 | } 137 | } 138 | if err := zp.zw.Close(); err != nil { 139 | return err 140 | } 141 | return zp.pw.Close() 142 | } 143 | -------------------------------------------------------------------------------- /print.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021-2025 Olivier Mengué. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package kittyimg provides utilities to show image in a graphic terminal emulator supporting kitty's "terminal graphics protocol". 18 | // 19 | // See https://sw.kovidgoyal.net/kitty/graphics-protocol.html. 20 | package kittyimg 21 | 22 | import ( 23 | "bytes" 24 | "fmt" 25 | "image" 26 | "io" 27 | ) 28 | 29 | func Fprint(w io.Writer, img image.Image) error { 30 | bounds := img.Bounds() 31 | 32 | // f=32 => RGBA 33 | _, err := fmt.Fprintf(w, "\033_Gq=1,a=T,f=32,s=%d,v=%d,t=d,", bounds.Dx(), bounds.Dy()) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | buf := make([]byte, 0, min(bounds.Dx()*bounds.Dy()*4, 16384)) // Multiple of 4 (RGBA) 39 | 40 | // var p payloadWriter 41 | var p zlibPayloadWriter 42 | p.Reset(w) 43 | 44 | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { 45 | for x := bounds.Min.X; x < bounds.Max.X; x++ { 46 | if len(buf) == cap(buf) { 47 | if _, err = p.Write(buf); err != nil { 48 | return err 49 | } 50 | buf = buf[:0] 51 | } 52 | r, g, b, a := img.At(x, y).RGBA() 53 | // A color's RGBA method returns values in the range [0, 65535]. 54 | // Shifting by 8 reduces this to the range [0, 255]. 55 | buf = append(buf, byte(r>>8), byte(g>>8), byte(b>>8), byte(a>>8)) 56 | } 57 | } 58 | 59 | if _, err = p.Write(buf); err != nil { 60 | return err 61 | } 62 | return p.Close() 63 | } 64 | 65 | func Fprintln(w io.Writer, img image.Image) error { 66 | err := Fprint(w, img) 67 | if err != nil { 68 | return err 69 | } 70 | _, err = w.Write([]byte{'\n'}) 71 | return err 72 | } 73 | 74 | // Transcode transforms the image file into the Kitty protocol representation for display 75 | // on a terminal. 76 | // 77 | // The supported input image formats depend on the formats registered with the [image] 78 | // framework (see [image/png], [image/gif], [image/jpeg]). 79 | func Transcode(w io.Writer, r io.Reader) error { 80 | var buf bytes.Buffer 81 | in := io.TeeReader(r, &buf) 82 | cfg, format, err := image.DecodeConfig(in) 83 | if err != nil { 84 | return readError(r, err) 85 | } 86 | // Restart from byte 0 87 | in = io.MultiReader(&buf, r) 88 | 89 | // For PNG we send the raw file that probably has better compression 90 | // https://sw.kovidgoyal.net/kitty/graphics-protocol/#png-data 91 | if format == "png" { 92 | if _, err = fmt.Fprintf(w, "\033_Gq=1,a=T,f=100,s=%d,v=%d,", cfg.Width, cfg.Height); err != nil { 93 | return err 94 | } 95 | 96 | var pw payloadWriter 97 | pw.Reset(w) 98 | 99 | if _, err = io.Copy(&pw, in); err != nil { 100 | return err 101 | } 102 | return pw.Close() 103 | } 104 | 105 | img, _, err := image.Decode(in) 106 | if err != nil { 107 | return readError(r, err) 108 | } 109 | return Fprint(w, img) 110 | } 111 | 112 | func readError(r io.Reader, err error) error { 113 | if r, ok := r.(interface{ Name() string }); ok { 114 | if name := r.Name(); name != "" { 115 | return fmt.Errorf("%s: %w", r.Name(), err) 116 | } 117 | } 118 | return err 119 | } 120 | --------------------------------------------------------------------------------