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}