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
//!  / <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('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
#[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\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\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\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());
}
}