Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/readme/pictures.rs

//! **A document's pictures: placed from memory when small, pointed at the raw
//! route when not.**
//!
//! ```text
//!   ![…](assets/a.png) / <img src="assets/a.png">
//!        │
//!        ├─ ≤ MAX_IMAGE_BYTES, whole ──▶ the bytes go to docbook (a data URL in the page)
//!        │
//!        └─ larger, or a Git LFS object ─▶ a STAND-IN goes to docbook: an empty SVG
//!                                          of the picture's own width and height, so
//!                                          typst lays out the right box; its data URL
//!                                          is then swapped for "/assets/a.png", and
//!                                          `links::rewrite` points that at
//!                                          /api/repos/{ns}/{name}/raw?path=assets/a.png
//! ```
//!
//! # Why this exists — gunnar.rs, nordisk/knut, 2026-10-15
//!
//! knut's README opens with `<img src="assets/generated_knut_iceberg.png">`,
//! and `*.png` is in Git LFS. The tree holds a 131-byte pointer; the image fetch
//! read the pointer, docbook's sniff refused it as "not an image", and a raw
//! `<img>` with no slot is drawn as **nothing at all** — no alt text, no box.
//! Following the pointer was not enough either: the picture is 3.9 MB, and a
//! page that inlines it is a 5 MB `innerHTML`, which is what `MAX_IMAGE_BYTES`
//! exists to refuse. So a large picture is not inlined; the page names it, and
//! the browser fetches it from the raw route with the visitor's own credential
//! — anonymous for a public repository, the session for a member of a private
//! one — exactly as a click on it would.
//!
//! # Why the HTML grade swaps the small ones too
//!
//! `readme::sanitise` (ammonia) keeps `http`, `https` and relative URLs and
//! drops every `data:` one, so a picture typst-html inlined as a data URL came
//! out of the sanitiser as an `<img>` with no `src`. In the HTML grade every
//! raster picture therefore points at the raw route. A small SVG picture
//! cannot be named the same way — the raw route serves a private repository's
//! `.svg` as a download, because it could run script on this origin, and a
//! browser draws nothing from that (a public one's is a picture since BADGE-7,
//! but one rule for both keeps the page the same) — so it stays a
//! `data:image/svg+xml;base64,` URL, held
//! out of the sanitiser's reach by [`hold_svg_data`] and put back by
//! [`restore_svg_data`]. An SVG drawn through `<img>` runs no script, and the
//! console's `img-src 'self' data:` allows it.
//!
//! The swap is made **before** the cache and before the sanitiser, to a
//! revision-free tree path; the revision is added after the cache with every
//! other link, for the reason `links` gives.

use base64::Engine as _;
use nornir_docbook::ImageSet;

use super::Shape;

/// The pictures one render places, and which of them the page should name by
/// URL rather than carry.
#[derive(Debug, Clone, Default)]
pub struct Pictures {
    /// What docbook is handed.
    pub set: ImageSet,
    /// `(data URL docbook will write, tree path)` for the stand-ins: swapped in
    /// both shapes.
    standins: Vec<(String, String)>,
    /// The same for pictures carried whole, swapped in the HTML grade only.
    embedded: Vec<(String, String)>,
    /// `(data URL, tree path, alt)` for a raw `<img>` too large to hand a
    /// browser: a box with the alt text in the typeset grade; in the HTML
    /// grade an `<img>` naming the file, whose refusal the browser draws as
    /// the alt.
    unavailable: Vec<(String, String, String)>,
}

impl Pictures {
    pub fn new() -> Pictures {
        Pictures::default()
    }

    /// How many pictures are placed.
    pub fn len(&self) -> usize {
        self.set.len()
    }

    /// True when nothing is placed.
    pub fn is_empty(&self) -> bool {
        self.set.is_empty()
    }

    /// Place the picture's own bytes under markdown destination `dest`, which
    /// is the blob at `tree_path`. `false` when docbook would not take them.
    pub fn embed(&mut self, dest: &str, tree_path: &str, bytes: Vec<u8>) -> bool {
        let url = raster_format(&bytes).map(|format| data_url(format, &bytes));
        if !self.set.insert(dest, bytes) {
            return false;
        }
        if let Some(url) = url {
            self.embedded.push((url, root_path(tree_path)));
        }
        true
    }

    /// Place a stand-in the size of the picture at `tree_path`, read off its
    /// header, so the page names the file instead of carrying it. `false` when
    /// `head` is not a picture whose size can be read.
    ///
    /// `head` may be the start of the file only: the size is in the header.
    pub fn refer(&mut self, dest: &str, tree_path: &str, head: &[u8]) -> bool {
        let Some((width, height)) = dimensions(head) else {
            return false;
        };
        // The slot number makes every stand-in's bytes — and so its data URL
        // — unique, so two pictures of one size are never swapped for each
        // other.
        let standin = format!(
            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" height=\"{height}\" \
             viewBox=\"0 0 {width} {height}\"><desc>gunnar-ui stand-in {}</desc></svg>",
            self.set.len()
        )
        .into_bytes();
        let url = data_url("svg+xml", &standin);
        if !self.set.insert(dest, standin) {
            return false;
        }
        self.standins.push((url, root_path(tree_path)));
        true
    }

    /// Place a box with `alt` in it for the picture at `tree_path`, which is
    /// too large for the raw route to hand a browser (gunnar has no read that
    /// streams one). Only for a raw `<img>`: docbook draws a markdown image's
    /// alt text by itself, and a raw `<img>` with no slot as nothing at all.
    pub fn unavailable(&mut self, dest: &str, tree_path: &str, alt: &str) -> bool {
        let standin = format!(
            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"600\" height=\"44\" \
             viewBox=\"0 0 600 44\"><desc>gunnar-ui stand-in {}</desc>\
             <rect x=\"0.5\" y=\"0.5\" width=\"599\" height=\"43\" fill=\"#f5f6f8\" \
             stroke=\"#cfd4da\"/><text x=\"14\" y=\"27\" font-family=\"sans-serif\" \
             font-size=\"14\" fill=\"#16191d\">{}</text></svg>",
            self.set.len(),
            escape(alt)
        )
        .into_bytes();
        let url = data_url("svg+xml", &standin);
        if !self.set.insert(dest, standin) {
            return false;
        }
        self.unavailable
            .push((url, root_path(tree_path), alt.to_owned()));
        true
    }

    /// Swap the data URLs this render chose to name rather than carry for
    /// their tree paths. Run on docbook's output before anything else sees it.
    pub fn point(&self, page: String, shape: Shape) -> String {
        let html = match shape {
            Shape::Html => self.embedded.as_slice(),
            Shape::Svg => &[],
        };
        let mut page = page;
        for (url, path) in self.standins.iter().chain(html) {
            if page.contains(url.as_str()) {
                page = page.replace(url.as_str(), &escape(path));
            }
        }
        if shape == Shape::Html {
            // typst-html writes `<img src="…">`; the value becomes the file and
            // the alt follows it, so a browser that is refused the file draws
            // the words.
            for (url, path, alt) in &self.unavailable {
                if page.contains(url.as_str()) {
                    let named = format!("{}\" alt=\"{}", escape(path), escape(alt));
                    page = page.replace(url.as_str(), &named);
                }
            }
        }
        page
    }
}

/// A picture's width and height in pixels, from its header.
pub fn dimensions(head: &[u8]) -> Option<(u32, u32)> {
    // A PNG says its size in its first 24 bytes (IHDR, always the first
    // chunk): read there, so a prefix cut anywhere after it still answers.
    if head.len() >= 24 && head.starts_with(b"\x89PNG\r\n\x1a\n") && &head[12..16] == b"IHDR" {
        let width = u32::from_be_bytes(head[16..20].try_into().ok()?);
        let height = u32::from_be_bytes(head[20..24].try_into().ok()?);
        return (width > 0 && height > 0).then_some((width, height));
    }
    let (width, height) = image::ImageReader::new(std::io::Cursor::new(head))
        .with_guessed_format()
        .ok()?
        .into_dimensions()
        .ok()?;
    (width > 0 && height > 0).then_some((width, height))
}

/// What an SVG picture's `src` starts with when typst carries it.
const SVG_DATA: &str = "src=\"data:image/svg+xml;base64,";
/// The relative stand-in the sanitiser keeps; `{n}` is the slot.
const HELD: &str = "gunnar-ui-held-svg-";

/// Swap every `<img src="data:image/svg+xml;base64,…">` for a relative
/// stand-in the sanitiser keeps. Only plain base64 is held; anything else is
/// left for the sanitiser to drop.
pub fn hold_svg_data(page: &str) -> (String, Vec<String>) {
    let mut out = String::with_capacity(page.len());
    let mut held = Vec::new();
    let mut rest = page;
    while let Some(at) = rest.find(SVG_DATA) {
        let start = at + "src=\"".len();
        let body = &rest[at + SVG_DATA.len()..];
        let Some(end) = body.find('"') else { break };
        let b64 = &body[..end];
        out.push_str(&rest[..start]);
        if b64.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')) {
            out.push_str(&format!("{HELD}{}", held.len()));
            held.push(format!("data:image/svg+xml;base64,{b64}"));
        } else {
            out.push_str(&rest[start..at + SVG_DATA.len() + end]);
        }
        rest = &body[end..];
    }
    out.push_str(rest);
    (out, held)
}

/// Put the URLs [`hold_svg_data`] held back, in `src` attributes only.
pub fn restore_svg_data(page: String, held: &[String]) -> String {
    let mut page = page;
    // Last first, so slot 1 is never read as the start of slot 10.
    for (n, url) in held.iter().enumerate().rev() {
        page = page.replace(&format!("src=\"{HELD}{n}\""), &format!("src=\"{url}\""));
    }
    page
}

/// The `image/…` subtype typst writes for a raster picture, from its bytes —
/// the same names `typst_svg::convert_image_to_base64_url` uses, because the
/// swap only works if this spells the data URL exactly as typst does.
fn raster_format(bytes: &[u8]) -> Option<&'static str> {
    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
        Some("png")
    } else if bytes.starts_with(b"\xff\xd8\xff") {
        Some("jpeg")
    } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
        Some("gif")
    } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
        Some("webp")
    } else {
        None
    }
}

fn data_url(format: &str, bytes: &[u8]) -> String {
    format!(
        "data:image/{format};base64,{}",
        base64::engine::general_purpose::STANDARD.encode(bytes)
    )
}

/// A tree path spelled from the root, which `links::resolve` reads as "from
/// the tree root" whatever directory the document is in.
fn root_path(tree_path: &str) -> String {
    format!("/{}", tree_path.trim_start_matches('/'))
}

/// Escape a value for a double-quoted attribute, in either document.
fn escape(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('"', "&quot;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::readme::{render, Shape};

    /// A real 1×1 PNG.
    pub const PNG_1PX: &[u8] = &[
        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
        0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
        0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8,
        0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00,
        0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
    ];

    /// The first bytes of a `width`×`height` PNG — all a stand-in needs.
    fn png_head(width: u32, height: u32) -> Vec<u8> {
        let mut head = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR".to_vec();
        head.extend_from_slice(&width.to_be_bytes());
        head.extend_from_slice(&height.to_be_bytes());
        head.extend_from_slice(&[8, 6, 0, 0, 0]);
        head
    }

    #[test]
    fn a_picture_s_size_is_read_off_its_header() {
        assert_eq!(dimensions(&png_head(1600, 900)), Some((1600, 900)));
        assert_eq!(dimensions(PNG_1PX), Some((1, 1)));
        assert_eq!(
            dimensions(b"version https://git-lfs.github.com/spec/v1\n"),
            None
        );
    }

    /// ★ **knut's README (gunnar.rs, 2026-10-15)**: a raw `<img>` whose file is
    /// too large to carry is laid out at its own size and drawn from the raw
    /// route — in the typeset grade, as an SVG `<image>`.
    #[test]
    fn a_large_html_img_is_named_on_the_typeset_page_not_carried() {
        let md = "<p align=\"center\"><img src=\"assets/big.png\" alt=\"big\" width=\"560\"></p>\n\n# knut\n";
        let mut pictures = Pictures::new();
        assert!(pictures.refer("assets/big.png", "assets/big.png", &png_head(1600, 900)));

        let svg = render(md, "ns/knut README.md", &pictures, Shape::Svg).expect("it renders");

        assert!(
            svg.contains("<image"),
            "the picture is placed: no <image> in the page"
        );
        assert!(
            svg.contains("href=\"/assets/big.png\""),
            "the picture is named by its tree path, not carried"
        );
        assert!(
            !svg.contains("gunnar-ui stand-in"),
            "no stand-in survives into the page"
        );
    }

    /// The HTML grade: the same picture, as an `<img>` the sanitiser keeps.
    #[test]
    fn a_large_markdown_image_is_named_on_the_plain_page_too() {
        let md = "# knut\n\n![the iceberg](assets/big.png)\n";
        let mut pictures = Pictures::new();
        assert!(pictures.refer("assets/big.png", "assets/big.png", &png_head(1600, 900)));

        let html = render(md, "ns/knut README.md", &pictures, Shape::Html).expect("it renders");

        assert!(html.contains("<img"), "the picture is placed: {html}");
        assert!(html.contains("src=\"/assets/big.png\""), "{html}");
    }

    /// ammonia drops `data:` URLs, so in the HTML grade a small picture
    /// carried whole used to come out as an `<img>` with no `src`. It is named
    /// too, and the typeset grade still carries it.
    #[test]
    fn a_small_raster_is_carried_on_the_typeset_page_and_named_on_the_plain_one() {
        let md = "# t\n\n![dot](docs/dot.png)\n";
        let mut pictures = Pictures::new();
        assert!(pictures.embed("docs/dot.png", "docs/dot.png", PNG_1PX.to_vec()));

        let svg = render(md, "ns/t README.md", &pictures, Shape::Svg).expect("it renders");
        assert!(
            svg.contains("data:image/png;base64,"),
            "the typeset page carries it"
        );

        let html = render(md, "ns/t README.md", &pictures, Shape::Html).expect("it renders");
        assert!(html.contains("src=\"/docs/dot.png\""), "{html}");
    }

    /// **A small SVG is drawn in the HTML grade too (2026-10-18).** The raw
    /// route serves an `.svg` as a download, so naming the file draws
    /// nothing; and ammonia drops the `data:` URL. The picture is carried as
    /// a `data:image/svg+xml` `<img src>` past the sanitiser — an `<img>`
    /// never runs an SVG's script.
    #[test]
    fn a_small_svg_is_drawn_on_the_plain_page() {
        let md = "# t\n\n![logo](docs/logo.svg)\n";
        let logo = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><rect width=\"16\" height=\"16\" fill=\"#0a0\"/></svg>";
        let mut pictures = Pictures::new();
        assert!(pictures.embed("docs/logo.svg", "docs/logo.svg", logo.to_vec()));

        let html = render(md, "ns/t README.md", &pictures, Shape::Html).expect("it renders");
        assert!(html.contains("<img"), "the picture is placed: {html}");
        assert!(
            html.contains("src=\"data:image/svg+xml;base64,"),
            "the small SVG is carried, not dropped: {html}"
        );
    }

    /// ★ **The owner's 5.7 MB LFS picture (2026-10-15)**: past what the raw
    /// route hands a browser. A raw `<img>` with no slot is drawn as nothing,
    /// so it gets a box with its alt in the typeset grade, and an `<img>`
    /// naming the file with the alt in the HTML grade.
    #[test]
    fn a_picture_too_large_to_serve_shows_its_alt_text() {
        let md =
            "<p align=\"center\"><img src=\"assets/huge.png\" alt=\"the iceberg\"></p>\n\n# knut\n";
        let mut pictures = Pictures::new();
        assert!(pictures.unavailable("assets/huge.png", "assets/huge.png", "the iceberg"));

        let svg = render(md, "ns/knut README.md", &pictures, Shape::Svg).expect("it renders");
        let boxed = svg
            .split("data:image/svg+xml;base64,")
            .skip(1)
            .filter_map(|rest| {
                let b64 = &rest[..rest.find('"').unwrap_or(rest.len())];
                base64::engine::general_purpose::STANDARD.decode(b64).ok()
            })
            .any(|bytes| String::from_utf8_lossy(&bytes).contains(">the iceberg</text>"));
        assert!(
            boxed,
            "the typeset page draws the alt text in the picture's place"
        );

        let html = render(md, "ns/knut README.md", &pictures, Shape::Html).expect("it renders");
        assert!(
            html.contains("src=\"/assets/huge.png\" alt=\"the iceberg\""),
            "{html}"
        );
    }

    #[test]
    fn something_that_is_not_a_picture_is_not_placed() {
        let mut pictures = Pictures::new();
        let pointer = b"version https://git-lfs.github.com/spec/v1\noid sha256:00\nsize 3976594\n";
        assert!(!pictures.refer("a.png", "a.png", pointer));
        assert!(!pictures.embed("a.png", "a.png", pointer.to_vec()));
        assert!(pictures.is_empty());
    }
}