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}