graphics.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. package ansi
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "errors"
  6. "fmt"
  7. "image"
  8. "io"
  9. "os"
  10. "strings"
  11. "github.com/charmbracelet/x/ansi/kitty"
  12. )
  13. // KittyGraphics returns a sequence that encodes the given image in the Kitty
  14. // graphics protocol.
  15. //
  16. // APC G [comma separated options] ; [base64 encoded payload] ST
  17. //
  18. // See https://sw.kovidgoyal.net/kitty/graphics-protocol/
  19. func KittyGraphics(payload []byte, opts ...string) string {
  20. var buf bytes.Buffer
  21. buf.WriteString("\x1b_G")
  22. buf.WriteString(strings.Join(opts, ","))
  23. if len(payload) > 0 {
  24. buf.WriteString(";")
  25. buf.Write(payload)
  26. }
  27. buf.WriteString("\x1b\\")
  28. return buf.String()
  29. }
  30. var (
  31. // KittyGraphicsTempDir is the directory where temporary files are stored.
  32. // This is used in [WriteKittyGraphics] along with [os.CreateTemp].
  33. KittyGraphicsTempDir = ""
  34. // KittyGraphicsTempPattern is the pattern used to create temporary files.
  35. // This is used in [WriteKittyGraphics] along with [os.CreateTemp].
  36. // The Kitty Graphics protocol requires the file path to contain the
  37. // substring "tty-graphics-protocol".
  38. KittyGraphicsTempPattern = "tty-graphics-protocol-*"
  39. )
  40. // WriteKittyGraphics writes an image using the Kitty Graphics protocol with
  41. // the given options to w. It chunks the written data if o.Chunk is true.
  42. //
  43. // You can omit m and use nil when rendering an image from a file. In this
  44. // case, you must provide a file path in o.File and use o.Transmission =
  45. // [kitty.File]. You can also use o.Transmission = [kitty.TempFile] to write
  46. // the image to a temporary file. In that case, the file path is ignored, and
  47. // the image is written to a temporary file that is automatically deleted by
  48. // the terminal.
  49. //
  50. // See https://sw.kovidgoyal.net/kitty/graphics-protocol/
  51. func WriteKittyGraphics(w io.Writer, m image.Image, o *kitty.Options) error {
  52. if o == nil {
  53. o = &kitty.Options{}
  54. }
  55. if o.Transmission == 0 && len(o.File) != 0 {
  56. o.Transmission = kitty.File
  57. }
  58. var data bytes.Buffer // the data to be encoded into base64
  59. e := &kitty.Encoder{
  60. Compress: o.Compression == kitty.Zlib,
  61. Format: o.Format,
  62. }
  63. switch o.Transmission {
  64. case kitty.Direct:
  65. if err := e.Encode(&data, m); err != nil {
  66. return fmt.Errorf("failed to encode direct image: %w", err)
  67. }
  68. case kitty.SharedMemory:
  69. // TODO: Implement shared memory
  70. return fmt.Errorf("shared memory transmission is not yet implemented")
  71. case kitty.File:
  72. if len(o.File) == 0 {
  73. return kitty.ErrMissingFile
  74. }
  75. f, err := os.Open(o.File)
  76. if err != nil {
  77. return fmt.Errorf("failed to open file: %w", err)
  78. }
  79. defer f.Close() //nolint:errcheck
  80. stat, err := f.Stat()
  81. if err != nil {
  82. return fmt.Errorf("failed to get file info: %w", err)
  83. }
  84. mode := stat.Mode()
  85. if !mode.IsRegular() {
  86. return fmt.Errorf("file is not a regular file")
  87. }
  88. // Write the file path to the buffer
  89. if _, err := data.WriteString(f.Name()); err != nil {
  90. return fmt.Errorf("failed to write file path to buffer: %w", err)
  91. }
  92. case kitty.TempFile:
  93. f, err := os.CreateTemp(KittyGraphicsTempDir, KittyGraphicsTempPattern)
  94. if err != nil {
  95. return fmt.Errorf("failed to create file: %w", err)
  96. }
  97. defer f.Close() //nolint:errcheck
  98. if err := e.Encode(f, m); err != nil {
  99. return fmt.Errorf("failed to encode image to file: %w", err)
  100. }
  101. // Write the file path to the buffer
  102. if _, err := data.WriteString(f.Name()); err != nil {
  103. return fmt.Errorf("failed to write file path to buffer: %w", err)
  104. }
  105. }
  106. // Encode image to base64
  107. var payload bytes.Buffer // the base64 encoded image to be written to w
  108. b64 := base64.NewEncoder(base64.StdEncoding, &payload)
  109. if _, err := data.WriteTo(b64); err != nil {
  110. return fmt.Errorf("failed to write base64 encoded image to payload: %w", err)
  111. }
  112. if err := b64.Close(); err != nil {
  113. return err
  114. }
  115. // If not chunking, write all at once
  116. if !o.Chunk {
  117. _, err := io.WriteString(w, KittyGraphics(payload.Bytes(), o.Options()...))
  118. return err
  119. }
  120. // Write in chunks
  121. var (
  122. err error
  123. n int
  124. )
  125. chunk := make([]byte, kitty.MaxChunkSize)
  126. isFirstChunk := true
  127. for {
  128. // Stop if we read less than the chunk size [kitty.MaxChunkSize].
  129. n, err = io.ReadFull(&payload, chunk)
  130. if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
  131. break
  132. }
  133. if err != nil {
  134. return fmt.Errorf("failed to read chunk: %w", err)
  135. }
  136. opts := buildChunkOptions(o, isFirstChunk, false)
  137. if _, err := io.WriteString(w, KittyGraphics(chunk[:n], opts...)); err != nil {
  138. return err
  139. }
  140. isFirstChunk = false
  141. }
  142. // Write the last chunk
  143. opts := buildChunkOptions(o, isFirstChunk, true)
  144. _, err = io.WriteString(w, KittyGraphics(chunk[:n], opts...))
  145. return err
  146. }
  147. // buildChunkOptions creates the options slice for a chunk
  148. func buildChunkOptions(o *kitty.Options, isFirstChunk, isLastChunk bool) []string {
  149. var opts []string
  150. if isFirstChunk {
  151. opts = o.Options()
  152. } else {
  153. // These options are allowed in subsequent chunks
  154. if o.Quite > 0 {
  155. opts = append(opts, fmt.Sprintf("q=%d", o.Quite))
  156. }
  157. if o.Action == kitty.Frame {
  158. opts = append(opts, "a=f")
  159. }
  160. }
  161. if !isFirstChunk || !isLastChunk {
  162. // We don't need to encode the (m=) option when we only have one chunk.
  163. if isLastChunk {
  164. opts = append(opts, "m=0")
  165. } else {
  166. opts = append(opts, "m=1")
  167. }
  168. }
  169. return opts
  170. }