Skip to main content

pulldown_cmark_mdcat/terminal/capabilities/
sixel.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Sixel image support as thin wrapper on top of icy_sixel.
4
5use std::io::{self, Write};
6
7use crate::resources::image::*;
8use icy_sixel::SixelImage;
9use image::GenericImageView;
10
11use crate::resources::MimeData;
12use crate::terminal::size::TerminalSize;
13
14/// Sixel graphics protocol implementation.
15#[derive(Debug, Copy, Clone)]
16pub struct SixelProtocol;
17
18impl SixelProtocol {
19    fn load_image(mime: MimeData) -> io::Result<image::DynamicImage> {
20        match mime.mime_type_essence() {
21            Some("image/svg+xml") => {
22                let png = crate::resources::svg::render_svg_to_png(&mime.data)?;
23
24                image::load_from_memory_with_format(&png, image::ImageFormat::Png)
25                    .map_err(io::Error::other)
26            }
27
28            Some("image/png") => {
29                image::load_from_memory_with_format(&mime.data, image::ImageFormat::Png)
30                    .map_err(io::Error::other)
31            }
32
33            _ => image::load_from_memory(&mime.data).map_err(io::Error::other),
34        }
35    }
36
37    /// Inject a sixel "set raster attributes" command (`"Pan;Pad;Ph;Pv`) declaring the image's
38    /// true pixel size, right after the DCS introducer and before the colour/band data.
39    ///
40    /// icy_sixel never emits this command itself; it only writes data in bands of 6 rows
41    /// (rounding the row count up), so a terminal has no way to know the exact height short of
42    /// counting bands, and ends up treating the image as up to 5px taller than it is. On a
43    /// terminal whose cell height isn't itself a multiple of 6, that's enough to push the
44    /// post-image cursor row (see DECSET 8452 in [`Self::render_sixel`]) one row further down
45    /// than intended, and that compounds with every consecutive image into a descending
46    /// staircase.
47    fn inject_raster_attributes(sixel: &str, width: u32, height: u32) -> String {
48        let intro_end = sixel.find('q').map_or(0, |i| i + 1);
49        format!(
50            "{}\"1;1;{width};{height}{}",
51            &sixel[..intro_end],
52            &sixel[intro_end..]
53        )
54    }
55
56    fn render_sixel(writer: &mut dyn Write, img: image::DynamicImage) -> io::Result<()> {
57        let (w, h) = img.dimensions();
58        let rgba = img.to_rgba8();
59
60        let sixel = SixelImage::try_from_rgba(rgba.into_raw(), w as usize, h as usize)
61            .map_err(io::Error::other)?
62            .encode()
63            .map_err(io::Error::other)?;
64        let sixel = Self::inject_raster_attributes(&sixel, w, h);
65        // By default a terminal moves the cursor to the left margin of the line below a sixel
66        // image, regardless of the image's height; DECSET 8452 instead leaves the cursor to the
67        // right of the image, on the row it started on. Without this, images meant to flow
68        // inline with surrounding text (e.g. several badges in a row) stack vertically instead,
69        // one below the other, and can scroll the earlier ones out of view. Scope the mode to
70        // just this image so we don't leave the terminal's sixel behaviour altered afterwards.
71        write!(writer, "\x1b[?8452h{sixel}\x1b[?8452l")
72    }
73
74    /// Write raw PNG bytes inline to the terminal.
75    pub(crate) fn write_png_data(&self, writer: &mut dyn Write, png_data: &[u8]) -> io::Result<()> {
76        let img = image::load_from_memory_with_format(png_data, image::ImageFormat::Png)
77            .map_err(io::Error::other)?;
78
79        Self::render_sixel(writer, img)
80    }
81}
82
83impl crate::resources::InlineImageProtocol for SixelProtocol {
84    fn write_inline_image(
85        &self,
86        writer: &mut dyn Write,
87        resource_handler: &dyn crate::resources::ResourceUrlHandler,
88        url: &url::Url,
89        terminal_size: TerminalSize,
90    ) -> io::Result<()> {
91        let mime = resource_handler.read_resource(url)?;
92        let image = SixelProtocol::load_image(mime)?;
93
94        let image = if let Some(downsized) = fit_image_to_terminal(&image, terminal_size) {
95            downsized
96        } else {
97            image
98        };
99
100        SixelProtocol::render_sixel(writer, image)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn raster_attributes_are_inserted_right_after_the_dcs_introducer() {
110        let sixel = "\x1bP9;1;0q#0;2;0;0;0$-";
111        let with_raster = SixelProtocol::inject_raster_attributes(sixel, 108, 20);
112        assert_eq!(with_raster, "\x1bP9;1;0q\"1;1;108;20#0;2;0;0;0$-");
113    }
114
115    #[test]
116    fn raster_attributes_reflect_the_true_dimensions() {
117        let sixel = "\x1bP0;0;0q#0;2;0;0;0$-";
118        let with_raster = SixelProtocol::inject_raster_attributes(sixel, 1, 1);
119        assert!(with_raster.contains("\"1;1;1;1"));
120    }
121}