pulldown_cmark_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, ErrorKind, 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::new(ErrorKind::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 = if i < number_of_chunks - 1 { 1 } else { 0 };
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        self,
205        mime_data: MimeData,
206        terminal_size: TerminalSize,
207    ) -> Result<KittyImageData, KittyImageError> {
208        use image::ImageFormat;
209
210        let image = if let Some("image/svg+xml") = mime_data.mime_type_essence() {
211            event!(Level::DEBUG, "Rendering mime data to SVG");
212            let png_data = crate::resources::svg::render_svg_to_png(&mime_data.data)?;
213            image::load_from_memory_with_format(&png_data, ImageFormat::Png)?
214        } else {
215            let image_format = mime_data
216                .mime_type_essence()
217                .and_then(image::ImageFormat::from_mime_type);
218            match image_format {
219                // If we already have information about the mime type of the resource data let's
220                // use it, and trust whoever provided it to have gotten it right.
221                Some(format) => image::load_from_memory_with_format(&mime_data.data, format)?,
222                // If we don't know the mime type of the original data have image guess the format.
223                None => image::load_from_memory(&mime_data.data)?,
224            }
225        };
226
227        match downsize_to_columns(&image, terminal_size) {
228            Some(downsized_image) => {
229                event!(
230                    Level::DEBUG,
231                    "Image scaled down to column limit, rendering RGB data"
232                );
233                Ok(self.render_as_rgb_or_rgba(downsized_image))
234            }
235            None if mime_data.mime_type_essence() == Some("image/png") => {
236                event!(
237                    Level::DEBUG,
238                    "PNG image of appropriate size, rendering original image data"
239                );
240                Ok(self.render_as_png(mime_data.data))
241            }
242            None => {
243                event!(Level::DEBUG, "Image not in PNG format, rendering RGB data");
244                Ok(self.render_as_rgb_or_rgba(image))
245            }
246        }
247    }
248
249    /// Render mime data obtained from `url` and wrap it in a `KittyImageData`.
250    ///
251    /// This implementation does not support image processing, and only renders PNG images which
252    /// kitty supports directly.
253    #[cfg(not(feature = "image-processing"))]
254    fn render(
255        self,
256        mime_data: MimeData,
257        _terminal_size: TerminalSize,
258    ) -> Result<KittyImageData, KittyImageError> {
259        match mime_data.mime_type_essence() {
260            Some("image/png") => Ok(self.render_as_png(mime_data.data)),
261            _ => {
262                event!(
263                    Level::DEBUG,
264                    "Only PNG images supported without image-processing feature, but got {:?}",
265                    mime_data.mime_type
266                );
267                Err(std::io::Error::new(
268                    ErrorKind::Unsupported,
269                    format!(
270                        "Image data with mime type {:?} not supported",
271                        mime_data.mime_type
272                    ),
273                )
274                .into())
275            }
276        }
277    }
278
279    /// Wrap the image bytes as PNG format in `KittyImage`.
280    fn render_as_png(self, data: Vec<u8>) -> KittyImageData {
281        KittyImageData::Png(data)
282    }
283
284    /// Render the image as RGB/RGBA format and wrap the image bytes in `KittyImage`.
285    ///
286    /// If the image size exceeds `terminal_size` in either dimension scale the
287    /// image down to `terminal_size` (preserving aspect ratio).
288    #[cfg(feature = "image-processing")]
289    fn render_as_rgb_or_rgba(self, image: image::DynamicImage) -> KittyImageData {
290        use image::{ColorType, GenericImageView};
291
292        let size = PixelSize::from_xy(image.dimensions());
293        match image.color() {
294            ColorType::L8 | ColorType::Rgb8 | ColorType::L16 | ColorType::Rgb16 => {
295                KittyImageData::Rgb(size, image.into_rgb8().into_raw())
296            }
297            // Default to RGBA format: We cannot match all colour types because
298            // ColorType is marked non-exhaustive, but RGBA is a safe default
299            // because we can convert any image to RGBA, at worth with additional
300            // runtime costs.
301            _ => KittyImageData::Rgba(size, image.into_rgba8().into_raw()),
302        }
303    }
304}
305
306/// Kitty's inline image protocol.
307///
308/// Kitty's escape sequence is like: Put the command key/value pairs together like "{}={}(,*)"
309/// and write them along with the image bytes in 4096 bytes chunks to the stdout.
310///
311/// Its documentation gives the following python example:
312///
313/// ```python
314/// import sys
315/// from base64 import standard_b64encode
316///
317/// def serialize_gr_command(cmd, payload=None):
318///   cmd = ','.join('{}={}'.format(k, v) for k, v in cmd.items())
319///   ans = []
320///   w = ans.append
321///   w(b'\033_G'), w(cmd.encode('ascii'))
322///   if payload:
323///     w(b';')
324///     w(payload)
325///   w(b'\033\\')
326///   return b''.join(ans)
327///
328/// def write_chunked(cmd, data):
329///   cmd = {'a': 'T', 'f': 100}
330///   data = standard_b64encode(data)
331///   while data:
332///     chunk, data = data[:4096], data[4096:]
333///     m = 1 if data else 0
334///     cmd['m'] = m
335///     sys.stdout.buffer.write(serialize_gr_command(cmd, chunk))
336///     sys.stdout.flush()
337///     cmd.clear()
338/// ```
339///
340/// See <https://sw.kovidgoyal.net/kitty/graphics-protocol.html#control-data-reference>
341/// for reference.
342impl InlineImageProtocol for KittyGraphicsProtocol {
343    #[instrument(skip(self, writer, resource_handler, terminal_size))]
344    fn write_inline_image(
345        &self,
346        writer: &mut dyn Write,
347        resource_handler: &dyn crate::ResourceUrlHandler,
348        url: &url::Url,
349        terminal_size: crate::TerminalSize,
350    ) -> std::io::Result<()> {
351        let mime_data = resource_handler.read_resource(url)?;
352        event!(
353            Level::DEBUG,
354            "Received data of mime type {:?}",
355            mime_data.mime_type
356        );
357        let image = self.render(mime_data, terminal_size)?;
358        image.write_to(writer)
359    }
360}