Skip to main content

mdcat/terminal/capabilities/
kitty.rs

1// Copyright 2020 Sebastian Wiesner <sebastian@swsnr.de>
2// Copyright 2019 Fabian Spillner <fabian.spillner@gmail.com>
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//! Kitty terminal extensions.
17use std::fmt::Display;
18use std::io::{Error, Write};
19use std::str;
20
21use base64::engine::general_purpose::STANDARD;
22use base64::Engine;
23use tracing::{event, instrument, Level};
24
25use crate::resources::image::*;
26use crate::resources::MimeData;
27use crate::terminal::size::{PixelSize, TerminalSize};
28
29/// An error which occurred while rendering or writing an image with the Kitty image protocol.
30#[derive(Debug)]
31pub enum KittyImageError {
32    /// A general IO error.
33    IoError(std::io::Error),
34    /// Processing a pixel image, e.g. for format conversion, failed
35    #[cfg(feature = "image-processing")]
36    ImageError(image::ImageError),
37}
38
39impl Display for KittyImageError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            KittyImageError::IoError(error) => write!(f, "Failed to render kitty image: {error}"),
43            #[cfg(feature = "image-processing")]
44            KittyImageError::ImageError(image_error) => {
45                write!(f, "Failed to process pixel image: {image_error}")
46            }
47        }
48    }
49}
50
51impl std::error::Error for KittyImageError {
52    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
53        match self {
54            KittyImageError::IoError(error) => Some(error),
55            #[cfg(feature = "image-processing")]
56            KittyImageError::ImageError(image_error) => Some(image_error),
57        }
58    }
59}
60
61impl From<KittyImageError> for std::io::Error {
62    fn from(value: KittyImageError) -> Self {
63        std::io::Error::other(value)
64    }
65}
66
67impl From<std::io::Error> for KittyImageError {
68    fn from(value: std::io::Error) -> Self {
69        Self::IoError(value)
70    }
71}
72
73#[cfg(feature = "image-processing")]
74impl From<image::ImageError> for KittyImageError {
75    fn from(value: image::ImageError) -> Self {
76        Self::ImageError(value)
77    }
78}
79
80/// Image data for the kitty graphics protocol.
81///
82/// See [Terminal graphics protocol][1] for a complete documentation.
83///
84/// [1]: https://sw.kovidgoyal.net/kitty/graphics-protocol/
85enum KittyImageData {
86    Png(Vec<u8>),
87    #[cfg(feature = "image-processing")]
88    Rgb(PixelSize, Vec<u8>),
89    #[cfg(feature = "image-processing")]
90    Rgba(PixelSize, Vec<u8>),
91}
92
93impl KittyImageData {
94    /// Return the format code for this data for the `f` control data field.
95    ///
96    /// See the [Transferring pixel data][1] for reference.
97    ///
98    /// [1]: https://sw.kovidgoyal.net/kitty/graphics-protocol.html#transferring-pixel-data
99    fn f_format_code(&self) -> &str {
100        match self {
101            KittyImageData::Png(_) => "100",
102            #[cfg(feature = "image-processing")]
103            KittyImageData::Rgb(_, _) => "24",
104            #[cfg(feature = "image-processing")]
105            KittyImageData::Rgba(_, _) => "32",
106        }
107    }
108
109    /// Get the actual data.
110    fn data(&self) -> &[u8] {
111        match self {
112            KittyImageData::Png(ref contents) => contents,
113            #[cfg(feature = "image-processing")]
114            KittyImageData::Rgb(_, ref contents) => contents,
115            #[cfg(feature = "image-processing")]
116            KittyImageData::Rgba(_, ref contents) => contents,
117        }
118    }
119
120    /// Get the size of the image contained in this data.
121    ///
122    /// `Some` if the size is explicitly specified for this data, `None` otherwise, i.e. in PNG
123    /// format).
124    fn size(&self) -> Option<PixelSize> {
125        match self {
126            KittyImageData::Png(_) => None,
127            #[cfg(feature = "image-processing")]
128            KittyImageData::Rgb(size, _) => Some(*size),
129            #[cfg(feature = "image-processing")]
130            KittyImageData::Rgba(size, _) => Some(*size),
131        }
132    }
133
134    /// The width of the image for the `s` control data field.
135    fn s_width(&self) -> u32 {
136        self.size().map_or(0, |s| s.x)
137    }
138
139    /// The height of the image for the `v` control data field.
140    fn v_height(&self) -> u32 {
141        self.size().map_or(0, |s| s.y)
142    }
143}
144
145impl KittyImageData {
146    fn write_to(&self, writer: &mut dyn Write) -> Result<(), Error> {
147        let image_data = STANDARD.encode(self.data());
148        let image_data_chunks = image_data.as_bytes().chunks(4096);
149        let number_of_chunks = image_data_chunks.len();
150
151        for (i, chunk_data) in image_data_chunks.enumerate() {
152            let is_first_chunk = i == 0;
153            // The value for the m field
154            let m = i32::from(i < number_of_chunks - 1);
155            if is_first_chunk {
156                // For the first chunk we must write the header for the image.
157                //
158                // a=T tells kitty that we transfer image data and want to show the image
159                // immediately.
160                //
161                // t=d tells kitty that we transfer image data inline in the escape code.
162                //
163                // I=1 tells kitty that we want to treat every image as unique and not have kitty
164                // reuse images.  At least wezterm requires this; otherwise past images disappear
165                // because wezterm seems to assume that we're reusing some image ID.
166                //
167                // f tells kitty about the data format.
168                //
169                // s and v tell kitty about the size of our image.
170                //
171                // m tells kitty whether to expect more chunks or whether this is the last one.
172                //
173                // q=2 tells kitty never to respond to our image sequence; we're not reading these
174                // responses anyway.
175                //
176                let f = self.f_format_code();
177                let s = self.s_width();
178                let v = self.v_height();
179                write!(writer, "\x1b_Ga=T,t=d,I=1,f={f},s={s},v={v},m={m},q=2;")?;
180            } else {
181                // For follow up chunks we must not repeat the header, but only indicate whether we
182                // expect a response and whether more data is to follow.
183                write!(writer, "\x1b_Gm={m},q=2;")?;
184            }
185            writer.write_all(chunk_data)?;
186            write!(writer, "\x1b\\")?;
187        }
188
189        Ok(())
190    }
191}
192
193/// Provides access to printing images for kitty.
194#[derive(Debug, Copy, Clone)]
195pub struct KittyGraphicsProtocol;
196
197impl KittyGraphicsProtocol {
198    /// Render mime data obtained from `url` and wrap it in a `KittyImage`.
199    ///
200    /// This implementation processes the image to scale it to the given `terminal_size`, and
201    /// supports various pixel image types, as well as SVG.
202    #[cfg(feature = "image-processing")]
203    fn render(
204        mime_data: MimeData,
205        terminal_size: TerminalSize,
206    ) -> Result<KittyImageData, KittyImageError> {
207        let image = crate::resources::image::decode_image(&mime_data)?;
208
209        match downsize_to_columns(&image, terminal_size) {
210            Some(downsized_image) => {
211                event!(
212                    Level::DEBUG,
213                    "Image scaled down to column limit, rendering RGB data"
214                );
215                Ok(Self::render_as_rgb_or_rgba(downsized_image))
216            }
217            None if mime_data.mime_type_essence() == Some("image/png") => {
218                event!(
219                    Level::DEBUG,
220                    "PNG image of appropriate size, rendering original image data"
221                );
222                Ok(Self::render_as_png(mime_data.data))
223            }
224            None => {
225                event!(Level::DEBUG, "Image not in PNG format, rendering RGB data");
226                Ok(Self::render_as_rgb_or_rgba(image))
227            }
228        }
229    }
230
231    /// Render mime data obtained from `url` and wrap it in a `KittyImageData`.
232    ///
233    /// This implementation does not support image processing, and only renders PNG images which
234    /// kitty supports directly.
235    #[cfg(not(feature = "image-processing"))]
236    fn render(
237        mime_data: MimeData,
238        _terminal_size: TerminalSize,
239    ) -> Result<KittyImageData, KittyImageError> {
240        match mime_data.mime_type_essence() {
241            Some("image/png") => Ok(Self::render_as_png(mime_data.data)),
242            _ => {
243                event!(
244                    Level::DEBUG,
245                    "Only PNG images supported without image-processing feature, but got {:?}",
246                    mime_data.mime_type
247                );
248                Err(std::io::Error::new(
249                    std::io::ErrorKind::Unsupported,
250                    format!(
251                        "Image data with mime type {:?} not supported",
252                        mime_data.mime_type
253                    ),
254                )
255                .into())
256            }
257        }
258    }
259
260    /// Wrap the image bytes as PNG format in `KittyImage`.
261    fn render_as_png(data: Vec<u8>) -> KittyImageData {
262        KittyImageData::Png(data)
263    }
264
265    /// Render the image as RGB/RGBA format and wrap the image bytes in `KittyImage`.
266    ///
267    /// If the image size exceeds `terminal_size` in either dimension scale the
268    /// image down to `terminal_size` (preserving aspect ratio).
269    #[cfg(feature = "image-processing")]
270    fn render_as_rgb_or_rgba(image: image::DynamicImage) -> KittyImageData {
271        use image::{ColorType, GenericImageView};
272
273        let size = PixelSize::from_xy(image.dimensions());
274        match image.color() {
275            ColorType::L8 | ColorType::Rgb8 | ColorType::L16 | ColorType::Rgb16 => {
276                KittyImageData::Rgb(size, image.into_rgb8().into_raw())
277            }
278            // Default to RGBA format: We cannot match all colour types because
279            // ColorType is marked non-exhaustive, but RGBA is a safe default
280            // because we can convert any image to RGBA, at worth with additional
281            // runtime costs.
282            _ => KittyImageData::Rgba(size, image.into_rgba8().into_raw()),
283        }
284    }
285}
286
287/// Kitty's inline image protocol.
288///
289/// Kitty's escape sequence is like: Put the command key/value pairs together like "{}={}(,*)"
290/// and write them along with the image bytes in 4096 bytes chunks to the stdout.
291///
292/// Its documentation gives the following python example:
293///
294/// ```python
295/// import sys
296/// from base64 import standard_b64encode
297///
298/// def serialize_gr_command(cmd, payload=None):
299///   cmd = ','.join('{}={}'.format(k, v) for k, v in cmd.items())
300///   ans = []
301///   w = ans.append
302///   w(b'\033_G'), w(cmd.encode('ascii'))
303///   if payload:
304///     w(b';')
305///     w(payload)
306///   w(b'\033\\')
307///   return b''.join(ans)
308///
309/// def write_chunked(cmd, data):
310///   cmd = {'a': 'T', 'f': 100}
311///   data = standard_b64encode(data)
312///   while data:
313///     chunk, data = data[:4096], data[4096:]
314///     m = 1 if data else 0
315///     cmd['m'] = m
316///     sys.stdout.buffer.write(serialize_gr_command(cmd, chunk))
317///     sys.stdout.flush()
318///     cmd.clear()
319/// ```
320///
321/// See <https://sw.kovidgoyal.net/kitty/graphics-protocol.html#control-data-reference>
322/// for reference.
323impl InlineImageProtocol for KittyGraphicsProtocol {
324    #[instrument(skip(self, writer, resource_handler, terminal_size))]
325    fn write_inline_image(
326        &self,
327        writer: &mut dyn Write,
328        resource_handler: &dyn crate::ResourceUrlHandler,
329        url: &url::Url,
330        terminal_size: crate::TerminalSize,
331    ) -> std::io::Result<()> {
332        let mime_data = resource_handler.read_resource(url)?;
333        event!(
334            Level::DEBUG,
335            "Received data of mime type {:?}",
336            mime_data.mime_type
337        );
338        let image = Self::render(mime_data, terminal_size)?;
339        image.write_to(writer)
340    }
341}