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

nordisk/edda

crates/nornir-docbook/src/export.rs

//! Document export: render assembled markdown to PDF / HTML via typst.
//!
//! Pipeline:
//!   .md file(s) → pulldown-cmark events → typst markup string
//!                  → typst-as-lib engine (with embedded template + fonts)
//!                  → typst::Document
//!                  → typst_pdf / typst_html bytes
//!
//! No shellout. No JS. 100% Rust. The default template (`assets/typst/report.typ`)
//! is embedded at compile time via `rust-embed`. Future: allow `--template PATH`
//! to override, and a future opt-in path can skip the markdown front-end entirely
//! and let users author in typst directly.
//!
//! See also: `nornir`'s `docs::sections` — the section assembler that fills in
//! `<!-- nornir:gen:start:NAME -->` blocks. Run `assemble_file` first, then
//! `export()`.
//!
//! Cargo feature: `docs-export`.

use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use anyhow::{anyhow, Context, Result};
use rust_embed::RustEmbed;
use typst::foundations::{Bytes, Dict, IntoValue};
use typst::layout::{Frame, FrameItem, Transform};
use typst_layout::Page;
use typst::model::Destination;
use typst::text::TextItem;
use typst_as_lib::TypstEngine;

#[derive(RustEmbed)]
#[folder = "assets/typst/"]
struct TemplateAssets;

/// Output formats supported by the exporter.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DocFormat {
    /// Portable Document Format (typst-rendered).
    Pdf,
    /// HTML (typst-rendered single-file).
    Html,
    /// Raw markdown passthrough (no transformation).
    Md,
    /// Vector SVG (typst-rendered). The single-blob `export()` path returns ALL
    /// pages merged into one tall SVG; [`export_svg_pages`] returns one SVG per
    /// page (what the manual viewer wants — crisp, per-page, no rasterization).
    Svg,
}

impl DocFormat {
    pub fn parse(s: &str) -> Result<Self> {
        match s.to_ascii_lowercase().as_str() {
            "pdf" => Ok(Self::Pdf),
            "html" => Ok(Self::Html),
            "md" | "markdown" => Ok(Self::Md),
            "svg" => Ok(Self::Svg),
            other => Err(anyhow!("unknown doc format: {other}")),
        }
    }
    pub fn extension(self) -> &'static str {
        match self {
            Self::Pdf => "pdf",
            Self::Html => "html",
            Self::Md => "md",
            Self::Svg => "svg",
        }
    }
}

/// Which typst compilation target a translated body is destined for.
///
/// The markdown→typst translation is mostly shared, but a few constructs
/// differ between the paged (PDF/SVG) target and the experimental HTML
/// target — e.g. a thematic break is a `#line()` on a page but an
/// `<hr>` element in HTML, and the latter rejects layout primitives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
    /// Paged document (PDF/SVG) — the default, fully supported path.
    Paged,
    /// HTML document (experimental in typst 0.14). Best-effort.
    Html,
}

/// What goes into the template header.
#[derive(Debug, Clone, Default)]
pub struct ExportMeta {
    pub title: String,
    pub version: String,
    pub generated: String,
    /// Optional cover image, repo-root-relative (resolves via the mounted typst
    /// root). Empty = no cover image. Shown on the title page above the title.
    pub cover_image: String,
}

/// Render `markdown` (already assembled by `nornir`'s `docs::sections`) to `format`.
/// Returns raw bytes.
///
/// - `image_cache_dir`: if `Some`, remote images referenced from the markdown
///   are downloaded (via `ureq` + rustls), cached on disk, and embedded into
///   the typst document. Pass `None` to skip remote fetches (placeholder text
///   is rendered instead).
/// - `root`: if `Some`, a typst filesystem root is mounted at that directory so
///   *local* images (e.g. `.nornir/assets/depgraph.svg`) resolve and embed.
///   Local image links are interpreted as project-root-relative (typically the
///   repo root). Pass `None` when the document references no local images.
pub fn export(
    markdown: &str,
    meta: &ExportMeta,
    format: DocFormat,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
) -> Result<Vec<u8>> {
    match format {
        DocFormat::Md => Ok(markdown.as_bytes().to_vec()),
        DocFormat::Pdf => render_pdf(markdown, meta, image_cache_dir, root),
        DocFormat::Html => render_html(markdown, meta, image_cache_dir, root),
        DocFormat::Svg => {
            // Single-blob SVG = every page merged top-to-bottom into one tall
            // SVG. Serialised straight into the merge and never collected: the
            // `Vec<String>` `export_svg_pages` answers with is the right shape
            // for a caller that wants the pages, and the wrong one for a caller
            // that only wants them concatenated — it held every page AND the
            // concatenation at once.
            let doc = paged_document(markdown, meta, image_cache_dir, root)?;
            Ok(merge_svg_pages(doc.pages().iter().map(page_svg)).into_bytes())
        }
    }
}

/// Render the document to **one SVG string per page** — crisp vector output for
/// the manual viewer (no rasterization, glyphs are outlined as paths). Shares the
/// paged typst pipeline with [`render_pdf`].
pub fn export_svg_pages(
    markdown: &str,
    meta: &ExportMeta,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
) -> Result<Vec<String>> {
    let doc = paged_document(markdown, meta, image_cache_dir, root)?;
    Ok(doc.pages().iter().map(page_svg).collect())
}

/// The paged compile itself — `report.typ`, laid out, not yet serialised.
///
/// The ONE writer behind [`export_svg_pages`] and `export(.., Svg, ..)`. They
/// want the same document and different things out of it (a page per string,
/// and one concatenation that never holds a page twice), so the compile is
/// shared and the serialisation is each caller's.
fn paged_document(
    markdown: &str,
    meta: &ExportMeta,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
) -> Result<typst_layout::PagedDocument> {
    let (engine, inputs) = build_engine(markdown, meta, image_cache_dir, root, Target::Paged)?;
    engine
        .compile_with_input(inputs)
        .output
        .map_err(|err| anyhow!("typst compile failed: {:?}", err))
}

/// **The whole document as ONE tall SVG at a fixed column width** — the shape a
/// web page wants, rather than a stack of A4 sheets.
///
/// [`export_svg_pages`] renders a *book*: `report.typ`'s cover, outline, running
/// header and page numbers, cut into pages. That is right for a manual and
/// wrong for a README on a repository page, where the text would be sliced
/// across sheets that then have to be stacked back up in a browser. This
/// renders `column.typ` instead — no page furniture, `height: auto`, so the
/// document is a single page as tall as it needs to be — and dresses the result
/// so a browser can size it:
///
/// * `width="100%"` with typst's fixed `width`/`height` attributes REMOVED, so
///   the aspect ratio comes from the `viewBox` and the page shrinks to a phone;
/// * a `<title>` first child and an `aria-label`, because `typst-svg` outlines
///   every glyph to a `<path>` and a picture needs a name;
/// * with [`TextLayer::On`] (**the default grade**, and what a browser wants), an
///   invisible `<text>` layer over the drawn glyphs, so the page is selectable,
///   copyable, findable with the browser's search and readable by a screen
///   reader. See [`TextLayer`] for what that costs and how it is placed. With
///   [`TextLayer::Off`] the output is 0.1.1's, byte for byte.
///
/// `width_pt` is the column measure in points; the caller measures a browser and
/// this does not guess one. `images` supplies relative images from memory (see
/// [`ImageSet`]), so no filesystem root is mounted and no network request is
/// made — for any URL, absolute ones included.
///
/// # Errors
///
/// The typesetter's diagnostics, or a document that produced no page at all.
pub fn export_svg_column(
    markdown: &str,
    meta: &ExportMeta,
    images: &ImageSet,
    width_pt: f64,
    text_layer: TextLayer,
) -> Result<String> {
    export_svg_column_on(markdown, meta, images, width_pt, text_layer, None)
}

/// [`export_svg_column`] on a chosen page ground.
///
/// `ground` is `None` for `column.typ`'s white (byte-identical to
/// [`export_svg_column`]), `Some("none")` for a transparent page — the column
/// then sits on whatever web page it is inlined into — or `Some("#rrggbb")`.
pub fn export_svg_column_on(
    markdown: &str,
    meta: &ExportMeta,
    images: &ImageSet,
    width_pt: f64,
    text_layer: TextLayer,
    ground: Option<&str>,
) -> Result<String> {
    export_svg_column_in(markdown, meta, images, width_pt, text_layer, ground, None)
}

/// [`export_svg_column_on`] in a chosen language.
///
/// `lang` is an ISO 639-1 code; `None` keeps `column.typ`'s `en` (byte-identical
/// to [`export_svg_column_on`]). It sets typst's text language (hyphenation, CJK
/// line breaking) and picks the CJK face a caller-supplied Noto CJK collection
/// answers with: `ja` = JP, `zh` = SC, `ko` = KR — the same code point is drawn
/// differently in each, so the language decides, not the collection's order.
pub fn export_svg_column_in(
    markdown: &str,
    meta: &ExportMeta,
    images: &ImageSet,
    width_pt: f64,
    text_layer: TextLayer,
    ground: Option<&str>,
    lang: Option<&str>,
) -> Result<String> {
    let mut extra: Vec<(&str, typst::foundations::Value)> = vec![("width", width_pt.into_value())];
    if let Some(g) = ground {
        extra.push(("ground", g.into_value()));
    }
    if let Some(l) = lang {
        extra.push(("lang", l.into_value()));
    }
    let (engine, inputs) = build_engine_ext(
        markdown,
        meta,
        None,
        None,
        Target::Paged,
        "column.typ",
        &extra,
        images.clone(),
    )?;
    let doc: typst_layout::PagedDocument = engine
        .compile_with_input(inputs)
        .output
        .map_err(|err| anyhow!("typst compile failed: {:?}", err))?;
    // `height: auto` means one page. `merge` rather than indexing `[0]`,
    // because "means" is not "guarantees" and a panic is not a diagnostic.
    //
    // **Everything that needs the laid-out document is taken here, together**,
    // so the document can be dropped before the strings grow. The frame walk is
    // first because it is the small one: a `Layers` for a 3 MB document is tens
    // of megabytes beside the page's hundreds.
    let (svg, placed) = match doc.pages() {
        [] => return Err(anyhow!("the document produced no page")),
        // One page is the ordinary case (`column.typ` sets `height: auto`), and
        // it is the only one whose text can be placed: `merge_svg_pages` stacks
        // pages by nesting them, not by offsetting them, so a run's coordinates
        // in page 2 would name a spot on page 1. A document that somehow paged
        // gets the 0.1.1 picture rather than a layer that lies.
        [page] => {
            let placed = matches!(text_layer, TextLayer::On).then(|| layers(page));
            (page_svg(page), placed)
        }
        pages => (merge_svg_pages(pages.iter().map(page_svg)), None),
    };
    // **The layout arena goes before the output grows.** MEASURED 2026-09-12 on
    // oden (`examples/mem.rs`): the compile of one 3 MB markdown document still
    // holds 1449 MB of live frames when it returns, and every byte of the
    // optimizer's and the text layer's work used to be done on top of it. It is
    // dead from here on — the page has been serialised and the runs have been
    // collected — so it is dropped rather than left to the end of the function.
    drop(doc);
    // **The same lossless pass a committed book gets.** typst indents its SVG
    // and prints `f64` noise on every coordinate; [`crate::svgz::optimize_svg`]
    // is the writer that takes it out, measured at 35.21 MB -> 24.17 MB on
    // korp's real 84-page book and proved pixel-identical by rasterising both
    // (`svgz::tests::optimize_svg_does_not_move_a_single_pixel`). A page going
    // to a browser wants it at least as much as one going into git: measured
    // here at 570 KB -> 397 KB for a 3.3 KB README, which is 173 KB a reader
    // does not wait for.
    //
    // `optimize_svg_owned` and not `optimize_svg`: the page is ours, and the
    // owned door rewrites it in its own buffer instead of building a second
    // copy beside it.
    //
    // **The text layer is added AFTER this pass and never fed through it.**
    // `optimize_svg` is allowed to delete whitespace between a `>` and the next
    // `<` precisely because a typst page has no `<text>` node in it; run it over
    // a page that does and it would eat the space between two words.
    let svg = crate::svgz::optimize_svg_owned(svg);
    Ok(match placed {
        Some(placed) => dress_svg_with_text(svg, &meta.title, &placed),
        None => dress_svg(svg, &meta.title),
    })
}

/// Whether [`export_svg_column`] puts a selectable text layer over the glyphs.
///
/// # What the layer is
///
/// `typst-svg` outlines every glyph to a `<path>`: beautiful, and as far as a
/// browser is concerned a picture of writing. Nothing in it can be selected,
/// copied, found with the browser's search or read aloud. This is the trick a
/// PDF viewer has used for thirty years — over the drawn page, one invisible
/// `<text>` element per shaped run, at the run's baseline, in the run's size,
/// stretched with `textLength` to exactly the advance width the typesetter
/// measured. The reader sees the typeset glyphs and selects the real characters.
///
/// # What it costs
///
/// Bytes and hit-testing.
///
/// **Bytes: about +23%** — `examples/bench.rs`, 2026-09-09, three README-shaped
/// documents:
///
/// ```text
///   input    off ms   on ms    time     off out    on out     bytes
///    3 KB      42.2    42.3    +0.4%     317 KB    372 KB    +17.2%
///   29 KB     207.3   215.1    +3.8%    2383 KB   2921 KB    +22.6%
///  293 KB    1726.6  1793.5    +3.9%   23313 KB  28635 KB    +22.8%
/// ```
///
/// **+4% of the time and a quarter more bytes** for a page that can be read. The
/// time is a second walk of the frames and one element per run — no second
/// compile, no second outlining pass — which is why it is nearly free next to
/// the typesetting it rides on. The bytes are the words a second time, in a file
/// that was already mostly glyph geometry.
///
/// **Hit-testing**: the text layer is above the picture, which is what makes a
/// drag over it select text — and a `<a>` link that `typst-svg` drew *under* it
/// stops taking clicks. 0.1.2 left it there, trading clickable links for
/// selectable prose; measured on gunnar.rs 2026-09-11, that trade was the
/// README's link to its own PDF doing nothing at all. So 0.1.3 lifts every
/// followable link into a **link layer above the text**: the same boxes
/// `typst-svg` drew, at the same absolute placement, last in the file. A link
/// now takes the click, and a drag that starts on one does not select — exactly
/// what a link in an HTML page does. Everything else on the page selects as
/// before.
///
/// "Followable" is `http`, `https`, `mailto` or no scheme at all (a relative
/// path or a `#fragment`). A `javascript:` or `data:` target is left where
/// `typst-svg` put it, under the text, where no click reaches it: a page this
/// crate makes clickable is not a page that runs what a markdown file named.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TextLayer {
    /// Emit the layer. The page carries `role="document"`, the glyphs are
    /// wrapped `aria-hidden="true"` so a screen reader is not read the picture
    /// as well as the text, and a `<style>` inside the SVG makes the layer
    /// transparent and selectable.
    #[default]
    On,
    /// Emit the picture alone — 0.1.1's output, byte for byte. For a caller
    /// that rasterises the page, diffs it against a committed one, or otherwise
    /// wants nothing in the file that is not drawn.
    Off,
}

// ── the text layer ─────────────────────────────────────────────────────────

/// One shaped run of text, in the page's own coordinates.
///
/// SVG user units here **are points**: `typst-svg` writes `viewBox="0 0 {w} {h}"`
/// with `w`/`h` straight off `Abs::to_pt`, so nothing is scaled between a frame
/// coordinate and the number that reaches the file. That is the fact the whole
/// layer rests on, and `a_run_sits_on_the_glyphs_it_names` is the test that
/// would notice if typst ever changed it.
#[derive(Debug, Clone, PartialEq)]
struct TextRun {
    /// The run's baseline origin.
    x: f64,
    y: f64,
    /// Font size, points.
    size: f64,
    /// The advance width the typesetter measured, points. `textLength` is set
    /// to this so that the browser's own idea of the run's width — different,
    /// because it is laying out a substitute font with its own kerning and
    /// ligatures — is stretched onto the drawn glyphs and the selection
    /// highlight lands where the ink is.
    advance: f64,
    /// The typographic family name. The browser will substitute and that is
    /// fine: nothing here is painted.
    family: String,
    /// The run's own string, sliced to what its glyphs actually cover — never
    /// rebuilt from glyph ids, which would lose every ligature and every
    /// mark-to-base pair to a reverse lookup that does not exist.
    text: String,
    /// `sx ky kx sy`, and only when the run is not a plain translation (a
    /// rotated or scaled block). The ordinary run leaves this `None` and is
    /// placed with `x`/`y`.
    linear: Option<[f64; 4]>,
}

/// One followable link on a page: the box `typst-svg` drew for it, in the
/// page's own coordinates, and where it goes.
#[derive(Debug, Clone, PartialEq)]
struct LinkBox {
    /// The box's top-left corner, points.
    x: f64,
    y: f64,
    /// Its size in its own frame, points.
    width: f64,
    height: f64,
    /// `sx ky kx sy`, only when the box is not a plain translation — the same
    /// convention as [`TextRun::linear`].
    linear: Option<[f64; 4]>,
    /// The target, as the markdown wrote it.
    url: String,
}

/// What goes over the picture: the words, and the links above them.
#[derive(Debug, Default)]
struct Layers {
    runs: Vec<TextRun>,
    links: Vec<LinkBox>,
}

/// Every text run and every followable link on a page, in the order
/// `typst-svg` draws them.
///
/// The walk is `typst-svg`'s own, with one simplification that is safe by
/// construction: it accumulates the transform through *every* group, where the
/// renderer resets it inside a hard frame and writes the accumulated matrix onto
/// the wrapping `<g>` instead. Both spellings compose to the same absolute
/// placement, which is why the layer sits on the glyphs rather than beside them
/// — and why a lifted link sits on the box it was lifted from.
fn layers(page: &Page) -> Layers {
    let mut out = Layers::default();
    collect(&page.frame, Transform::identity(), &mut out);
    out
}

fn collect(frame: &Frame, ts: Transform, out: &mut Layers) {
    for (pos, item) in frame.items() {
        let ts = ts.pre_concat(Transform::translate(pos.x, pos.y));
        match item {
            FrameItem::Group(group) => collect(&group.frame, ts.pre_concat(group.transform), out),
            FrameItem::Text(text) => {
                if let Some(run) = run_of(text, ts) {
                    out.runs.push(run);
                }
            }
            FrameItem::Link(Destination::Url(url), size) if followable(url) => {
                let (sx, ky, kx, sy) = (ts.sx.get(), ts.ky.get(), ts.kx.get(), ts.sy.get());
                let plain = sx == 1.0 && sy == 1.0 && kx == 0.0 && ky == 0.0;
                out.links.push(LinkBox {
                    x: ts.tx.to_pt(),
                    y: ts.ty.to_pt(),
                    width: size.x.to_pt(),
                    height: size.y.to_pt(),
                    linear: (!plain).then_some([sx, ky, kx, sy]),
                    url: url.to_string(),
                });
            }
            _ => {}
        }
    }
}

/// True for a target a click may follow: `http`, `https`, `mailto`, or no
/// scheme at all.
///
/// An allow-list, not a deny-list, because browsers are generous about what
/// they read as a scheme — `java\tscript:` and ` javascript:` both run — and a
/// list of what to refuse would have to know all of that. A colon after the
/// first `/`, `?` or `#` is not a scheme separator (`docs/a:b.md`), so those
/// stay relative.
fn followable(url: &str) -> bool {
    match url.split_once(':') {
        None => true,
        Some((scheme, _)) if scheme.contains(['/', '?', '#']) => true,
        Some((scheme, _)) => {
            matches!(
                scheme.to_ascii_lowercase().as_str(),
                "http" | "https" | "mailto"
            )
        }
    }
}

fn run_of(text: &TextItem, ts: Transform) -> Option<TextRun> {
    let slice = run_text(text);
    if slice.is_empty() {
        return None;
    }
    let (sx, ky, kx, sy) = (ts.sx.get(), ts.ky.get(), ts.kx.get(), ts.sy.get());
    let plain = sx == 1.0 && sy == 1.0 && kx == 0.0 && ky == 0.0;
    Some(TextRun {
        x: ts.tx.to_pt(),
        y: ts.ty.to_pt(),
        size: text.size.to_pt(),
        advance: text.width().to_pt(),
        family: text.font.info().family.clone(),
        text: slice,
        linear: (!plain).then_some([sx, ky, kx, sy]),
    })
}

/// The item's own string, cut to the span its glyphs cover.
///
/// A `TextItem` in a frame normally owns its whole string, but a glyph's `range`
/// is the authority on what was actually shaped, and taking it costs nothing.
/// The fallback is the full string rather than a panic: a slice that is not on a
/// `char` boundary is a reason to hand over more text, never less.
fn run_text(text: &TextItem) -> String {
    let mut start = usize::MAX;
    let mut end = 0usize;
    for glyph in &text.glyphs {
        let range = glyph.range();
        start = start.min(range.start);
        end = end.max(range.end);
    }
    if start < end {
        if let Some(slice) = text.text.get(start..end) {
            return slice.to_owned();
        }
    }
    text.text.to_string()
}

/// The `<style>` that makes the layer invisible, selectable and — when a reader
/// does select it — visible as a highlight.
///
/// It is inside the SVG rather than in the consumer's stylesheet because the SVG
/// is the unit that travels: a page dropped into any document brings the rules
/// that make it work. A console with a global `user-select: none` reset is
/// exactly why the property is stated here rather than assumed.
const TEXT_LAYER_STYLE: &str = "<style>.text-layer text{fill:transparent;\
     stroke:none;user-select:text;-webkit-user-select:text;white-space:pre}\
     .text-layer text::selection{background:#3b82f659;fill:transparent}\
     .text-layer text::-moz-selection{background:#3b82f659;fill:transparent}\
     .link-layer a{cursor:pointer}</style>";

/// Give a typeset page a name, a width a browser can shrink, and the text layer.
///
/// The picture keeps every byte `typst-svg` wrote; it is wrapped
/// `aria-hidden="true"` so assistive technology is offered the words once
/// rather than the words and a drawing of them.
///
/// **The page is taken by value and dressed in its own buffer.** It used to be
/// borrowed and a second, larger string built beside it — 313 MB of page and
/// 380 MB of dressed page live at once on a 3 MB document, MEASURED 2026-09-12
/// (`examples/mem.rs`). The layers go on the end, which costs nothing, and the
/// new root element goes on the front, which costs one move of the body and no
/// allocation at all when the buffer the optimizer shrank has the room — which
/// it always does, because the optimizer shrank it.
fn dress_svg_with_text(mut out: String, label: &str, layers: &Layers) -> String {
    let runs = &layers.runs;
    let Some(head_end) = out.find('>') else {
        return out;
    };
    // Everything up to the root's own closing tag is the drawing. `rfind`
    // because a nested `</svg>` is possible in principle (an embedded SVG
    // image); the LAST one is the root's. Absolute, so it survives the
    // truncation below.
    let Some(cut) = out.rfind("</svg>") else {
        return dress_svg(out, label);
    };
    if cut <= head_end {
        return dress_svg(out, label);
    }

    // The new root element, built whole so the body is moved exactly once.
    let mut head = strip_fixed_size(&out[..head_end]);
    // `role="document"` and not `role="img"`: it carries real text now, and a
    // screen reader that treats it as a picture would read only the label.
    head.push_str(" width=\"100%\" role=\"document\" aria-label=\"");
    head.push_str(&xml_escape(label, true));
    head.push_str("\">");
    head.push_str("<title>");
    head.push_str(&xml_escape(label, false));
    head.push_str("</title>");
    head.push_str(TEXT_LAYER_STYLE);
    head.push_str("<g aria-hidden=\"true\">");

    // Tail first: `</svg>` and whatever follows it is a handful of bytes, so
    // lifting it out and putting it back after the layers is free.
    let tail = out.split_off(cut);
    out.reserve(head.len() - (head_end + 1) + runs.len() * 96 + tail.len() + 64);
    out.push_str("</g>");
    out.push_str("<g class=\"text-layer\">");
    for run in runs {
        push_run(&mut out, run);
    }
    out.push_str("</g>");
    // Last of all, so above the text: see [`TextLayer`] for why the links are
    // lifted. Hidden from a screen reader like the picture it came from — the
    // `<a>` elements `typst-svg` drew are still in the drawing, and one link
    // announced twice is one too many.
    if !layers.links.is_empty() {
        out.push_str("<g class=\"link-layer\" aria-hidden=\"true\">");
        for link in &layers.links {
            push_link(&mut out, link);
        }
        out.push_str("</g>");
    }
    out.push_str(&tail);
    // The head last: one move of the body, and by now the buffer is already as
    // long as it is going to get.
    out.replace_range(..=head_end, &head);
    out
}

fn push_link(out: &mut String, link: &LinkBox) {
    let href = xml_escape(&link.url, true);
    out.push_str("<a href=\"");
    out.push_str(&href);
    out.push_str("\" xlink:href=\"");
    out.push_str(&href);
    out.push('"');
    if let Some([sx, ky, kx, sy]) = link.linear {
        out.push_str(" transform=\"matrix(");
        for (i, v) in [sx, ky, kx, sy, link.x, link.y].iter().enumerate() {
            if i > 0 {
                out.push(' ');
            }
            out.push_str(&pt(*v));
        }
        out.push_str(")\"><rect");
    } else {
        out.push_str("><rect x=\"");
        out.push_str(&pt(link.x));
        out.push_str("\" y=\"");
        out.push_str(&pt(link.y));
        out.push('"');
    }
    out.push_str(" width=\"");
    out.push_str(&pt(link.width));
    out.push_str("\" height=\"");
    out.push_str(&pt(link.height));
    out.push_str("\" fill=\"transparent\" stroke=\"none\"/></a>");
}

fn push_run(out: &mut String, run: &TextRun) {
    out.push_str("<text ");
    match run.linear {
        // A rotated or scaled run: the whole matrix goes on the element and the
        // origin with it, because an `x`/`y` pair would be pre-multiplied by it.
        Some([sx, ky, kx, sy]) => {
            out.push_str("x=\"0\" y=\"0\" transform=\"matrix(");
            for (i, v) in [sx, ky, kx, sy, run.x, run.y].iter().enumerate() {
                if i > 0 {
                    out.push(' ');
                }
                out.push_str(&pt(*v));
            }
            out.push_str(")\" ");
        }
        None => {
            out.push_str("x=\"");
            out.push_str(&pt(run.x));
            out.push_str("\" y=\"");
            out.push_str(&pt(run.y));
            out.push_str("\" ");
        }
    }
    out.push_str("font-size=\"");
    out.push_str(&pt(run.size));
    out.push_str("\" font-family=\"");
    out.push_str(&xml_escape(&run.family, true));
    out.push('"');
    // A zero or negative advance is not a width to stretch onto; the run is
    // placed and left at its natural measure.
    if run.advance > 0.0 {
        out.push_str(" textLength=\"");
        out.push_str(&pt(run.advance));
        out.push_str("\" lengthAdjust=\"spacingAndGlyphs\"");
    }
    out.push('>');
    out.push_str(&xml_escape(&run.text, false));
    out.push_str("</text>");
}

/// A coordinate, printed the way [`crate::svgz::optimize_svg`] prints the ones
/// around it — at most four fractional digits, no trailing zeros. Writing them
/// any other way would put `f64` noise back into a file that just had it taken
/// out.
fn pt(v: f64) -> String {
    let mut s = format!("{v:.4}");
    if s.contains('.') {
        while s.ends_with('0') {
            s.pop();
        }
        if s.ends_with('.') {
            s.pop();
        }
    }
    if s == "-0" {
        s.remove(0);
    }
    s
}

/// **The document as HTML, with the text still text.**
///
/// The same typst compilation `export(.., DocFormat::Html, ..)` performs, with
/// two differences that a caller holding bytes rather than files needs: it takes
/// an [`ImageSet`], and it hands back a `String` rather than `Vec<u8>`.
///
/// This is typst's experimental HTML target. What it buys over the SVG column is
/// the thing SVG cannot have: real elements with real text in them, so the page
/// is selectable, searchable and readable by a screen reader. What it costs is
/// that the HTML target rejects layout primitives, so the page is plainer.
///
/// **The output is a full `<html>` document**, not a fragment. A caller
/// embedding it in a page of its own takes the body — and should sanitise it
/// first regardless: the ELEMENTS here are this crate's, but the link and image
/// targets inside them came from somebody's markdown.
///
/// # Errors
///
/// The typesetter's diagnostics, or a serialisation failure.
pub fn export_html(markdown: &str, meta: &ExportMeta, images: &ImageSet) -> Result<String> {
    let (engine, inputs) = build_engine_ext(
        markdown,
        meta,
        None,
        None,
        Target::Html,
        "web.typ",
        &[],
        images.clone(),
    )?;
    let doc: typst_html::HtmlDocument = engine
        .compile_with_input(inputs)
        .output
        .map_err(|err| anyhow!("typst html compile failed: {:?}", err))?;
    typst_html::html(&doc, &HTML_OPTIONS).map_err(|err| anyhow!("typst html serialization failed: {:?}", err))
}

/// Give a typeset page a name and a width a browser can shrink.
///
/// String surgery on typst's own header rather than a hand-built one: `typst_svg`
/// owns the rest of that element (the namespaces, the `viewBox` it computed),
/// and a header written here would have to be kept in step with it.
/// Taken by value and rewritten in place, for the reason on
/// [`dress_svg_with_text`]: the page is hundreds of megabytes on a big
/// document and a second copy of it is the largest thing this crate was doing
/// to its own output.
fn dress_svg(mut out: String, label: &str) -> String {
    let Some(head_end) = out.find('>') else {
        return out;
    };
    let mut head = strip_fixed_size(&out[..head_end]);
    head.push_str(" width=\"100%\" role=\"img\" aria-label=\"");
    head.push_str(&xml_escape(label, true));
    head.push_str("\">");
    head.push_str("<title>");
    head.push_str(&xml_escape(label, false));
    head.push_str("</title>");
    out.replace_range(..=head_end, &head);
    out
}

/// typst's fixed `width="…pt" height="…pt"` off the root element, so the aspect
/// ratio comes from the `viewBox` and the page shrinks to a phone.
///
/// `head` is everything before the root element's FIRST `>`, so this cannot
/// reach a glyph's or a clip path's attributes.
fn strip_fixed_size(head: &str) -> String {
    let mut head = head.to_owned();
    for name in [" width=\"", " height=\""] {
        if let Some(start) = head.find(name) {
            if let Some(len) = head[start + name.len()..].find('"') {
                head.replace_range(start..start + name.len() + len + 1, "");
            }
        }
    }
    head
}

fn xml_escape(text: &str, attribute: bool) -> String {
    let mut out = text
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;");
    if attribute {
        out = out.replace('"', "&quot;");
    }
    out
}

/// Stack per-page SVGs into one document (simple vertical concatenation inside a
/// wrapping `<svg>`), so `DocFormat::Svg` has a usable single-file form too.
///
/// **It consumes an iterator of pages rather than borrowing a slice of them**,
/// so a caller can hand over `doc.pages().iter().map(page_svg)` and each
/// page is serialised, appended and dropped one at a time. Borrowing a
/// `&[String]` meant every page of the book was live *and* concatenated at the
/// same moment — two full copies of the whole document, for a function whose
/// only job is to put them end to end. Nothing here needs a page after it has
/// been written, so nothing keeps one.
fn merge_svg_pages(pages: impl IntoIterator<Item = String>) -> String {
    use std::fmt::Write as _;
    let mut out =
        String::from("<svg xmlns=\"http://www.w3.org/2000/svg\" data-nornir-doc=\"merged\">\n");
    for (i, p) in pages.into_iter().enumerate() {
        let _ = writeln!(out, "<g data-page=\"{}\">", i + 1);
        out.push_str(&p);
        drop(p);
        out.push_str("\n</g>\n");
    }
    out.push_str("</svg>\n");
    out
}

fn render_pdf(
    markdown: &str,
    meta: &ExportMeta,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
) -> Result<Vec<u8>> {
    let (engine, inputs) = build_engine(markdown, meta, image_cache_dir, root, Target::Paged)?;
    pdf_of(engine, inputs)
}

/// **The document as a PDF, with its images from memory.**
///
/// The paged pipeline had two doors for an image — a mounted filesystem root and
/// an HTTP fetch of an absolute URL — and no third. A caller that holds the
/// bytes and no directory, which is what a forge reading blobs out of a git
/// tree is, could typeset the same document to an SVG column WITH its pictures
/// ([`export_svg_column`], 0.1.1) and to a PDF WITHOUT them: every image came
/// out as its alt text, and nothing said so but a comment in the caller.
/// Measured on gunnar.rs's console, 2026-09-12, "View PDF" on any README with a
/// badge.
///
/// This is the same `report.typ` and the same compile [`export`] runs for
/// [`DocFormat::Pdf`], fed through the same [`ImageSet`] door the column and
/// HTML exports use. No root is mounted and no network request is made — for
/// any URL, absolute ones included.
///
/// # Errors
///
/// The typesetter's diagnostics, or a PDF serialisation failure.
pub fn export_pdf(markdown: &str, meta: &ExportMeta, images: &ImageSet) -> Result<Vec<u8>> {
    let (engine, inputs) = build_engine_ext(
        markdown,
        meta,
        None,
        None,
        Target::Paged,
        "report.typ",
        &[],
        images.clone(),
    )?;
    pdf_of(engine, inputs)
}

/// Compile a paged document and serialise it — the one PDF writer both doors
/// share, so they cannot disagree about the bytes.
fn pdf_of(
    engine: typst_as_lib::TypstEngine<typst_as_lib::TypstTemplateMainFile>,
    inputs: Dict,
) -> Result<Vec<u8>> {
    let doc: typst_layout::PagedDocument = engine
        .compile_with_input(inputs)
        .output
        .map_err(|err| anyhow!("typst compile failed: {:?}", err))?;
    let options = Default::default();
    typst_pdf::pdf(&doc, &options).map_err(|e| anyhow!("typst pdf export failed: {:?}", e))
}

/// HTML output via the **typst** engine (experimental in typst 0.14).
///
/// Uses the HTML-safe `web.typ` template and typst's `Target::Html`. This is
/// the low-priority path — if the experimental exporter rejects a construct,
/// the error is surfaced to the caller rather than silently producing a
/// half-rendered page.
fn render_html(
    markdown: &str,
    meta: &ExportMeta,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
) -> Result<Vec<u8>> {
    let (engine, inputs) = build_engine_with_template(
        markdown,
        meta,
        image_cache_dir,
        root,
        Target::Html,
        "web.typ",
    )?;
    let doc: typst_html::HtmlDocument = engine
        .compile_with_input(inputs)
        .output
        .map_err(|err| anyhow!("typst html compile failed: {:?}", err))?;
    let html = typst_html::html(&doc, &HTML_OPTIONS)
        .map_err(|err| anyhow!("typst html serialization failed: {:?}", err))?;
    Ok(html.into_bytes())
}

/// Build a typst engine + inputs for the default `report.typ` (paged) template.
fn build_engine(
    markdown: &str,
    meta: &ExportMeta,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
    target: Target,
) -> Result<(
    typst_as_lib::TypstEngine<typst_as_lib::TypstTemplateMainFile>,
    Dict,
)> {
    build_engine_with_template(markdown, meta, image_cache_dir, root, target, "report.typ")
}

/// Build a typst engine + inputs from an embedded template by file name.
/// Mounts (a) any downloaded remote images as virtual `/img/...` files and
/// (b) a real filesystem root (when `root` is `Some`) so local `#image()`
/// paths resolve off disk.
fn build_engine_with_template(
    markdown: &str,
    meta: &ExportMeta,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
    target: Target,
    template_name: &str,
) -> Result<(
    typst_as_lib::TypstEngine<typst_as_lib::TypstTemplateMainFile>,
    Dict,
)> {
    build_engine_ext(
        markdown,
        meta,
        image_cache_dir,
        root,
        target,
        template_name,
        &[],
        ImageSet::new(),
    )
}

/// Like [`build_engine_with_template`] but with `extra` `sys.inputs` merged in
/// (after the standard `title`/`version`/`generated`/`cover_image`/`body`), so a
/// caller can drive the template's `mode` / `page_start` / `heading_offset` /
/// `toc_*` knobs — the machinery behind the per-chapter SVG book.
#[allow(clippy::too_many_arguments)]
fn build_engine_ext(
    markdown: &str,
    meta: &ExportMeta,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
    target: Target,
    template_name: &str,
    extra: &[(&str, typst::foundations::Value)],
    provided: ImageSet,
) -> Result<(
    typst_as_lib::TypstEngine<typst_as_lib::TypstTemplateMainFile>,
    Dict,
)> {
    let template = TemplateAssets::get(template_name)
        .ok_or_else(|| anyhow!("bundled typst template missing: {template_name}"))?;
    let template_src = std::str::from_utf8(&template.data)
        .context("template not utf-8")?
        .to_owned();

    let extra_fonts = provided.fonts.clone();
    let mut images = ImageResolver::with_set(image_cache_dir, root.is_some(), provided);
    let body_typst = md_to_typst_with(markdown, &mut images, target);

    let mut builder = TypstEngine::builder()
        .main_file(template_src)
        .fonts(embedded_fonts().iter().cloned().chain(extra_fonts));
    // Mount all downloaded image bytes as virtual files at `/img/<hash>.<ext>`.
    if !images.assets.is_empty() {
        let pairs: Vec<(&str, Bytes)> = images
            .assets
            .iter()
            .map(|(path, bytes)| (path.as_str(), Bytes::new(bytes.clone())))
            .collect();
        builder = builder.with_static_file_resolver(pairs);
    }
    // Mount a real filesystem root so local `#image("/...")` links resolve.
    if let Some(r) = root {
        builder = builder.with_file_system_resolver(r.to_path_buf());
    }
    let engine = builder.build();

    let mut inputs = Dict::new();
    inputs.insert("title".into(), meta.title.clone().into_value());
    inputs.insert("version".into(), meta.version.clone().into_value());
    inputs.insert("generated".into(), meta.generated.clone().into_value());
    inputs.insert("cover_image".into(), meta.cover_image.clone().into_value());
    inputs.insert("body".into(), body_typst.into_value());
    for (k, v) in extra {
        inputs.insert((*k).into(), v.clone());
    }

    Ok((engine, inputs))
}

/// One page to SVG with typst 0.14's output shape. typst-svg 0.15 (2026-10-02)
/// takes `SvgOptions`; the default (no bleed, not pretty-printed) is exactly what
/// 0.14's option-less `svg(page)` wrote, so output stays byte-comparable.
fn page_svg(page: &Page) -> String {
    typst_svg::svg(page, &typst_svg::SvgOptions::default())
}

/// typst-html 0.15 (2026-10-02) takes `HtmlOptions`; 0.14 always pretty-printed.
const HTML_OPTIONS: typst_html::HtmlOptions = typst_html::HtmlOptions { pretty: true };


/// **The embedded faces, searched once for the life of the process.**
///
/// `TypstEngine::build()` runs typst-kit's font searcher every time it is
/// called, and `build()` is called once per render. That is right for a CLI
/// that renders one document and exits, and wrong for anything that renders on
/// request: the faces are compiled into the binary and cannot change, so the
/// search is the same answer paid for again and again.
///
/// MEASURED on a one-word document, 40 runs, this laptop: **16.30 ms p50
/// before, 14.60 ms after** — 1.7 ms off every render, 10%, and reproducible
/// across three runs (14.59 / 14.64 / 14.56).
///
/// It is a smaller win than the shape of the problem suggested, and that is the
/// finding rather than a disappointment: most of the floor is NOT the font
/// search. `TypstEngine::build()` also runs `typst::Library::builder().build()`
/// — the whole standard library and its native rule map — on every call, and
/// that is inside typst-as-lib where this crate cannot reach it. Removing it
/// means a change to typst-as-lib or a fork, and neither is worth 13 ms here;
/// it is written down so the next person does not re-derive it.
///
/// System fonts stay off, exactly as `search_fonts_with` had them: a document
/// must render identically on a laptop and on an appliance with no fonts
/// installed, and a host face silently winning a family name is how that stops
/// being true.
fn embedded_fonts() -> &'static Vec<typst::text::Font> {
    static FONTS: OnceLock<Vec<typst::text::Font>> = OnceLock::new();
    FONTS.get_or_init(|| {
        // typst-kit 0.15 (2026-10-02) replaced `Fonts::searcher()` with free
        // functions; `embedded()` yields exactly the bundled faces the old
        // `include_embedded_fonts(true).include_system_fonts(false)` search
        // returned — still no host font can win a family name. A face that will
        // not parse is skipped inside `Font::iter`, as the old slot loader did.
        typst_kit::fonts::embedded().map(|(font, _info)| font).collect()
    })
}

/// One chapter fed to the per-chapter SVG book renderer.
///
/// `markdown` is the chapter's body **with its leading `# H1` already ensured**
/// (exactly what `nornir`'s `docs::book` would concatenate for this chapter in the
/// single-doc path), and `title` is the chapter's display title (used only for
/// the manual front-matter TOC).
#[derive(Debug, Clone)]
pub struct ChapterInput {
    pub title: String,
    pub markdown: String,
    /// Optional plain-text rendering of this chapter's source, for the packed
    /// book's per-page search sidecar. typst outlines every glyph to `<path>`, so
    /// the SVG pages carry NO searchable `<text>`; this text (derived from the
    /// same markdown, before typst) is what `PageSource::page_text` serves. It is
    /// attributed to the chapter's FIRST page — the honest chapter-level anchor a
    /// split render can offer (see [`export_svg_pages_per_chapter`]). `None` when
    /// the chapter has no recoverable text (search then skips it — never faked).
    pub text: Option<String>,
}

/// Render the whole book as **one SVG string per page**, but by compiling each
/// chapter as its own small typst document and concatenating the resulting pages
/// in order — instead of feeding typst one giant concatenated markdown.
///
/// Why: typst's layout is superlinear in the size of a single document, so a big
/// repo's ~600 KB book takes minutes at 100% CPU. Splitting keeps every typst
/// input small (each ~linear), so the total is ~linear in chapters.
///
/// The output is **page-set-equivalent** to [`export_svg_pages`] over the same
/// assembled markdown: page 1 is the cover, page 2 the TOC, then every chapter
/// begins on a fresh page (a level-1 heading is a `pagebreak(weak: true)` in both
/// paths), in the same order. Page numbers and the `1.1` heading numbers run
/// continuously across chapters via the template's `page_start` / `heading_offset`
/// offsets. Two honestly-noted differences from the one-shot render:
///   * the front-matter TOC is chapter-level (the same granularity the manual
///     viewer's sidebar uses) rather than typst's depth-3 `#outline` — a split
///     render cannot introspect other chapters' headings/pages; and
///   * cross-CHAPTER links degrade to the chapter start (intra-chapter links and
///     images resolve exactly as before, off the same mounted FS `root`).
///
/// Returns the SVG pages **and** a parallel per-page text sidecar (`page_texts`,
/// same length/order as the pages): front-matter pages and every non-first page
/// of a chapter are `None`; a chapter's first page carries [`ChapterInput::text`]
/// when present. This is the plain-text layer the packed `book.znippy` exposes to
/// facett-docview search (which otherwise sees only outlined glyph paths).
///
/// # Why pass 2 is serial, and what parallelism would be worth
///
/// MEASURED (`examples/bench`, this laptop, 16 threads): rendering INDEPENDENT
/// documents through `gatling::gatling_forkjoin::gatling_map_balanced` is
/// **4.14x** on 8 x 300 KB and **4.99x** on 32 x 30 KB, at 1.6x and 1.3x the
/// CPU seconds. That is a real win and it is not available here.
///
/// A chapter's `page_start` is the page count of every chapter before it, so
/// pass 2 is a genuine serial dependency: chapter *n* cannot be laid out until
/// *n-1* has been. Breaking it means rendering every chapter twice — once at
/// offset 1 to learn its page count, once at the real offset — which turns a
/// 4.1x wall-clock win into about 2x at **twice the CPU**. On a box that is
/// also serving something else, that is a worse trade than it reads, so the
/// number is written down here and the loop is left alone.
///
/// `heading_offset` has no such dependency (`count_top_headings` is a scan of
/// the markdown), so a future two-pass version would only need the page counts.
pub fn export_svg_pages_per_chapter(
    chapters: &[ChapterInput],
    meta: &ExportMeta,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
) -> Result<(Vec<String>, Vec<Option<String>>)> {
    let titles: Vec<String> = chapters.iter().map(|c| c.title.clone()).collect();

    // Pass 1 — measure the front matter (cover + TOC) so we know how many pages
    // it takes (F): every chapter's page numbering starts after it. The TOC's
    // page-number VALUES don't affect its own layout, so F is stable across the
    // provisional and final front-matter renders.
    let front_pages = render_front_matter(meta, &titles, &vec![0i64; titles.len()], root)?;
    let f = front_pages.len();

    // Pass 2 — render every chapter in order, tracking the running page offset
    // (page_start) and the cumulative level-1 heading count (heading_offset) so
    // the split fragments number continuously. Each compile is small → ~linear.
    let mut chapter_pages: Vec<Vec<String>> = Vec::with_capacity(chapters.len());
    // Per-chapter text sidecar, aligned to each chapter's page block: the first
    // page carries the chapter's plain text, the rest are `None`.
    let mut chapter_texts: Vec<Vec<Option<String>>> = Vec::with_capacity(chapters.len());
    let mut toc_pages: Vec<i64> = Vec::with_capacity(chapters.len());
    let mut page_start = f + 1;
    let mut heading_offset = 0i64;
    for ch in chapters {
        toc_pages.push(page_start as i64);
        let pages = render_chapter(
            &ch.markdown,
            meta,
            page_start,
            heading_offset,
            image_cache_dir,
            root,
        )?;
        heading_offset += count_top_headings(&ch.markdown) as i64;
        let mut texts = vec![None; pages.len()];
        if let (Some(first), Some(text)) = (texts.first_mut(), ch.text.clone()) {
            *first = Some(text);
        }
        page_start += pages.len();
        chapter_pages.push(pages);
        chapter_texts.push(texts);
    }

    // Pass 3 — re-render the front matter now that the real chapter start pages
    // are known, so the TOC shows accurate page numbers. `f` is unchanged (same
    // number of entries → same layout), so nothing downstream shifts.
    let front_final = render_front_matter(meta, &titles, &toc_pages, root)?;

    let mut out = front_final;
    // Front-matter pages have no chapter text sidecar.
    let mut page_texts: Vec<Option<String>> = vec![None; out.len()];
    for (pages, texts) in chapter_pages.into_iter().zip(chapter_texts) {
        out.extend(pages);
        page_texts.extend(texts);
    }
    Ok((out, page_texts))
}

/// [`export_svg_pages_per_chapter`], with every page stored the way a book is
/// COMMITTED: [`crate::svgz::to_svgz`] — lossless SVG optimization, then
/// deterministic gzip. The per-page text sidecar is returned unchanged (it is
/// already plain text and is what search reads).
///
/// Prefer this over compressing the pages at each call site. A book is 84 files in
/// git and in a shipped binary, and if one caller forgets the optimize step, or
/// lets gzip stamp the build clock into every header, that repo's book silently
/// diverges from every other's and churns its whole page set on each re-render.
/// One writer, here.
///
/// Reading a page back needs no part of this crate: `.svgz` is an ordinary gzip
/// stream, so a GUI inflates it with a stock decoder and links no typst.
pub fn export_svgz_pages_per_chapter(
    chapters: &[ChapterInput],
    meta: &ExportMeta,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
) -> Result<(Vec<Vec<u8>>, Vec<Option<String>>)> {
    let (pages, page_texts) = export_svg_pages_per_chapter(chapters, meta, image_cache_dir, root)?;
    Ok((
        pages.iter().map(|p| crate::svgz::to_svgz(p)).collect(),
        page_texts,
    ))
}

/// Compile the "front" template fragment (cover + manual chapter TOC) to SVG pages.
fn render_front_matter(
    meta: &ExportMeta,
    toc_titles: &[String],
    toc_pages: &[i64],
    root: Option<&Path>,
) -> Result<Vec<String>> {
    let extra = [
        ("mode", String::from("front").into_value()),
        ("toc_titles", toc_titles.to_vec().into_value()),
        ("toc_pages", toc_pages.to_vec().into_value()),
    ];
    // Front matter has no markdown body and no remote images; the cover image
    // (if any) resolves off the mounted FS `root`, so no image cache is needed.
    let (engine, inputs) = build_engine_ext(
        "",
        meta,
        None,
        root,
        Target::Paged,
        "report.typ",
        &extra,
        ImageSet::new(),
    )?;
    let doc: typst_layout::PagedDocument = engine
        .compile_with_input(inputs)
        .output
        .map_err(|err| anyhow!("typst front-matter compile failed: {:?}", err))?;
    Ok(doc.pages().iter().map(page_svg).collect())
}

/// Compile one chapter (the "chapter" template fragment) to SVG pages, numbered
/// continuously via `page_start` and `heading_offset`.
fn render_chapter(
    markdown: &str,
    meta: &ExportMeta,
    page_start: usize,
    heading_offset: i64,
    image_cache_dir: Option<&Path>,
    root: Option<&Path>,
) -> Result<Vec<String>> {
    let extra = [
        ("mode", String::from("chapter").into_value()),
        ("page_start", (page_start as i64).into_value()),
        ("heading_offset", heading_offset.into_value()),
    ];
    let (engine, inputs) = build_engine_ext(
        markdown,
        meta,
        image_cache_dir,
        root,
        Target::Paged,
        "report.typ",
        &extra,
        ImageSet::new(),
    )?;
    let doc: typst_layout::PagedDocument = engine
        .compile_with_input(inputs)
        .output
        .map_err(|err| anyhow!("typst chapter compile failed: {:?}", err))?;
    Ok(doc.pages().iter().map(page_svg).collect())
}

/// Count the level-1 ATX headings (`# …`) in a markdown chapter, ignoring lines
/// inside fenced code blocks — this is the count typst will number as top-level
/// headings, i.e. how far to advance the next chapter's `heading_offset`.
fn count_top_headings(md: &str) -> usize {
    let mut n = 0usize;
    let mut in_fence = false;
    for line in md.lines() {
        let t = line.trim_start();
        if t.starts_with("```") || t.starts_with("~~~") {
            in_fence = !in_fence;
            continue;
        }
        if in_fence {
            continue;
        }
        // `# ` (H1) but not `## ` (H2+).
        if let Some(rest) = t.strip_prefix('#') {
            if rest.starts_with(' ') {
                n += 1;
            }
        }
    }
    n
}

// ---------- Image fetcher ----------

/// Downloads remote markdown images into a local cache and tracks them
/// so the typst engine can serve them via its in-memory file resolver.
/// **The images a document references, held in memory.**
///
/// The renderer has had two ways to find a picture: a real directory mounted as
/// the typst root, and an HTTP fetch for an absolute URL. Both assume the caller
/// is a build step on a machine that owns the files. A caller that has the
/// BYTES and nothing else — a forge reading blobs out of a git tree, an archive
/// reader, a row in a database — could not render an image at all, and could
/// not be given one without either writing a temporary directory or letting the
/// renderer make a network request on its behalf. This is the third way, and it
/// is the one that needs neither.
///
/// # The slot table is fixed on purpose
///
/// `typst_syntax::FileId::new` interns `(package, path)` by **leaking the pair
/// forever**, and panics with "out of file ids" past 2¹⁶ of them. That is
/// invisible to a build step, which renders once and exits. It is fatal to a
/// long-running server: [`ImageResolver`]'s remote path mounts each download at
/// `/img/<hash-of-url>.<ext>`, so a process that renders documents referencing
/// more than 65 535 distinct image URLs over its life will take the whole
/// process down. An `ImageSet` mounts at `/img/<slot>.<ext>` instead, with
/// `slot` bounded by [`ImageSet::MAX`] — at most `MAX × 5` paths can ever be
/// interned, however many documents are rendered.
///
/// # It is not a cache
///
/// One `ImageSet` belongs to one render. The caller decides what goes in it and
/// therefore decides what a document may reach; nothing here searches, guesses
/// or fetches.
#[derive(Debug, Clone, Default)]
pub struct ImageSet {
    /// slot -> (virtual path, bytes).
    slots: Vec<(String, Vec<u8>)>,
    /// normalised markdown destination -> slot.
    by_key: HashMap<String, usize>,
    /// Faces the caller brings beside the embedded ones (a site's CJK font).
    /// Still no system font: the caller's bytes are as fixed as the binary's.
    fonts: Vec<typst::text::Font>,
}

impl ImageSet {
    /// The most images one document may carry this way.
    ///
    /// A cap and not a budget. Every slot costs a permanently interned
    /// `FileId` (see the type docs), and a document with more pictures than
    /// this is not one a reader is scrolling through.
    pub const MAX: usize = 64;

    pub fn new() -> ImageSet {
        ImageSet::default()
    }

    /// Offer `bytes` as the image at markdown destination `path`.
    ///
    /// Returns `false` — and keeps nothing — when the set is full or when the
    /// bytes are not an image format typst can place. **The name decides
    /// nothing**: the format is sniffed, so a `.png` that is really an ELF gets
    /// no slot and the document falls back to the alt text.
    pub fn insert(&mut self, path: &str, bytes: Vec<u8>) -> bool {
        let key = normalise_image_key(path);
        if key.is_empty() || self.by_key.contains_key(&key) || self.slots.len() >= Self::MAX {
            return false;
        }
        let Some(ext) = sniff_image_ext(&bytes) else {
            return false;
        };
        let slot = self.slots.len();
        self.slots.push((format!("/img/{slot}.{ext}"), bytes));
        self.by_key.insert(key, slot);
        true
    }

    /// Offer `bytes` (TTF/OTF, or a TTC/OTC collection) as extra font faces.
    ///
    /// They join the embedded faces for this document only; typst falls back to
    /// them for any glyph the template's families lack — which is how Japanese
    /// text gets set in a Latin template. Returns how many faces were taken;
    /// `0` means the bytes are not a font typst can read.
    pub fn insert_font(&mut self, bytes: Vec<u8>) -> usize {
        let faces: Vec<_> = typst::text::Font::iter(Bytes::new(bytes)).collect();
        let n = faces.len();
        self.fonts.extend(faces);
        n
    }

    /// How many images are held.
    pub fn len(&self) -> usize {
        self.slots.len()
    }

    /// True when the document may place no image from memory.
    pub fn is_empty(&self) -> bool {
        self.slots.is_empty()
    }

    /// The virtual path for a markdown destination, if one was offered.
    fn lookup(&self, url: &str) -> Option<&str> {
        let slot = *self.by_key.get(&normalise_image_key(url))?;
        Some(self.slots[slot].0.as_str())
    }
}

/// The lookup key for an image destination.
///
/// A markdown author writes `./docs/a.png`, `docs/a.png` and `docs/a.png?raw=1`
/// meaning one file; a caller reading a git tree knows it as `docs/a.png`. The
/// query and fragment are URL syntax that a tree does not have, and the leading
/// `./` or `/` is a spelling, so all four spell the same key.
fn normalise_image_key(url: &str) -> String {
    url.split(['?', '#'])
        .next()
        .unwrap_or(url)
        .trim_start_matches("./")
        .trim_start_matches('/')
        .to_owned()
}

pub struct ImageResolver {
    cache_dir: Option<PathBuf>,
    /// virtual_path → bytes (mounted into the typst World).
    assets: HashMap<String, Vec<u8>>,
    /// What the caller handed in, consulted before the disk or the network.
    provided: ImageSet,
    /// **Whether a filesystem root is mounted at all**, which decides what a
    /// relative image that nothing could resolve becomes.
    ///
    /// With a root — a build step rendering a repository — an unresolved local
    /// path is emitted as `#image("/…")` and typst fails the compile, which is
    /// right: the file was supposed to be there and the build should say so.
    ///
    /// With NO root, emitting that path is a guaranteed "file not found" that
    /// takes the whole document down over one missing badge. An in-memory
    /// caller has no filesystem for it to be found in, so the honest emission
    /// is the alt text — the thing the author wrote for exactly this case.
    root_mounted: bool,
}

impl ImageResolver {
    fn new(cache_dir: Option<&Path>, root_mounted: bool) -> Self {
        Self::with_set(cache_dir, root_mounted, ImageSet::new())
    }

    /// A resolver that answers from `provided` before it considers anything
    /// else, and answers from it alone when `cache_dir` is `None`.
    ///
    /// The set's bytes are moved straight into `assets`, which is the map
    /// `build_engine_ext` already mounts — so an in-memory image travels the
    /// same road a downloaded one does, and there is one mounting path rather
    /// than two.
    fn with_set(cache_dir: Option<&Path>, root_mounted: bool, provided: ImageSet) -> Self {
        let mut assets = HashMap::new();
        for (path, bytes) in &provided.slots {
            assets.insert(path.clone(), bytes.clone());
        }
        Self {
            cache_dir: cache_dir.map(|p| p.to_path_buf()),
            assets,
            provided,
            root_mounted,
        }
    }

    /// Returns the typst-side virtual path for `url`, downloading + caching
    /// if necessary. Returns `None` when the URL is remote but no cache dir
    /// was configured, or when the download fails.
    fn resolve(&mut self, url: &str) -> Option<String> {
        // **The caller's own bytes win, for a relative path and for an absolute
        // URL alike.** A caller that has already decided what a name means is
        // not second-guessed, and — the half that matters — an absolute URL
        // answered from memory is an absolute URL this crate did not fetch.
        if let Some(virtual_path) = self.provided.lookup(url) {
            return Some(virtual_path.to_owned());
        }
        if !(url.starts_with("http://") || url.starts_with("https://")) {
            // Local URL — typst will read it from the source-relative path.
            // We don't intercept these.
            return None;
        }
        let cache_dir = self.cache_dir.as_ref()?;
        if std::fs::create_dir_all(cache_dir).is_err() {
            return None;
        }
        let ext = guess_image_ext(url);
        let key = url_hash(url);
        let file_name = format!("{key}.{ext}");
        let disk_path = cache_dir.join(&file_name);

        // Already mounted this run (under any extension we picked)?
        let early_virtual = format!("/img/{file_name}");
        if self.assets.contains_key(&early_virtual) {
            return Some(early_virtual);
        }

        // Disk cache hit?
        let raw_bytes = if let Ok(b) = std::fs::read(&disk_path) {
            b
        } else {
            match fetch_url(url) {
                Ok(b) => {
                    let _ = std::fs::write(&disk_path, &b);
                    b
                }
                Err(_) => return None,
            }
        };

        // Sniff actual format. Transcode to WebP only when it's a win:
        //   - PNG → lossless WebP (typically 20–40% smaller).
        //   - JPEG → keep JPEG. Pure-Rust WebP encoder is lossless-only,
        //     so re-encoding photos would *inflate* the file. Lossy WebP
        //     needs the C libwebp crate, which violates the no-C-deps rule.
        //   - GIF → lossless WebP (single frame; animation is dropped,
        //     which is fine for static docs).
        //   - SVG / unknown → passthrough.
        let real_ext = sniff_image_ext(&raw_bytes).unwrap_or(ext);
        let (final_ext, final_bytes) = match real_ext {
            "png" | "gif" => match transcode_to_webp(&raw_bytes) {
                Some(b) if b.len() < raw_bytes.len() => ("webp", b),
                _ => (real_ext, raw_bytes),
            },
            other => (other, raw_bytes),
        };
        let final_name = format!("{key}.{final_ext}");
        if final_ext == "webp" {
            let webp_path = cache_dir.join(&final_name);
            if !webp_path.exists() {
                let _ = std::fs::write(&webp_path, &final_bytes);
            }
        }
        let virtual_path = format!("/img/{final_name}");
        if self.assets.contains_key(&virtual_path) {
            return Some(virtual_path);
        }
        self.assets.insert(virtual_path.clone(), final_bytes);
        Some(virtual_path)
    }
}

/// Best-effort transcode of PNG/JPEG/GIF bytes into WebP. Returns
/// `None` when the input is undecodable or the encode fails — caller
/// falls back to the original bytes.
fn transcode_to_webp(bytes: &[u8]) -> Option<Vec<u8>> {
    let img = image::load_from_memory(bytes).ok()?;
    let mut out = std::io::Cursor::new(Vec::with_capacity(bytes.len() / 2));
    img.write_to(&mut out, image::ImageFormat::WebP).ok()?;
    Some(out.into_inner())
}

/// Detect the image format from magic bytes. Returns `None` for unknown.
fn sniff_image_ext(bytes: &[u8]) -> Option<&'static str> {
    if bytes.len() >= 8 && &bytes[..8] == b"\x89PNG\r\n\x1a\n" {
        Some("png")
    } else if bytes.len() >= 3 && &bytes[..3] == b"\xff\xd8\xff" {
        Some("jpg")
    } else if bytes.len() >= 6 && (&bytes[..6] == b"GIF87a" || &bytes[..6] == b"GIF89a") {
        Some("gif")
    } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
        Some("webp")
    } else if bytes.len() >= 5 && (&bytes[..5] == b"<?xml" || &bytes[..4] == b"<svg") {
        Some("svg")
    } else {
        None
    }
}

fn fetch_url(url: &str) -> Result<Vec<u8>> {
    // ureq 3 (2026-10-02): no env proxy and 5 redirects, as ureq 2 did.
    let resp = ureq::Agent::config_builder()
        .timeout_global(Some(std::time::Duration::from_secs(15)))
        .user_agent("nornir-docs-export/0.1")
        .max_redirects(5)
        .proxy(None)
        .build()
        .new_agent()
        .get(url)
        // Prefer WebP — many CDNs (Cloudflare, fastly, GitHub assets)
        // content-negotiate and hand us WebP directly, skipping the
        // local transcode.
        .header(
            "Accept",
            "image/webp,image/png,image/jpeg,image/*;q=0.8,*/*;q=0.5",
        )
        .call()
        .map_err(|e| anyhow!("fetch {url}: {e}"))?;
    if !resp.status().is_success() {
        return Err(anyhow!("fetch {url}: HTTP {}", resp.status().as_u16()));
    }
    let mut buf = Vec::with_capacity(64 * 1024);
    use std::io::Read;
    let mut reader = resp.into_body().into_reader().take(5 * 1024 * 1024);
    reader
        .read_to_end(&mut buf)
        .map_err(|e| anyhow!("read {url}: {e}"))?;
    Ok(buf)
}

fn url_hash(url: &str) -> String {
    let mut h = std::collections::hash_map::DefaultHasher::new();
    url.hash(&mut h);
    format!("{:016x}", h.finish())
}

fn guess_image_ext(url: &str) -> &'static str {
    // Strip query string before sniffing extension.
    let path = url.split(['?', '#']).next().unwrap_or(url);
    let lower = path.to_ascii_lowercase();
    if lower.ends_with(".png") {
        "png"
    } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
        "jpg"
    } else if lower.ends_with(".gif") {
        "gif"
    } else if lower.ends_with(".svg") {
        "svg"
    } else if lower.ends_with(".webp") {
        "webp"
    } else {
        // Typst supports PNG/JPEG/GIF/SVG/WebP; default to png — most asset
        // hosts return PNG for screenshots when no extension is given.
        "png"
    }
}

// ---------- Markdown → Typst translator ----------

/// Translate a markdown document into typst markup.
///
/// This is *not* a full converter — it handles every construct nornir
/// emits in its own generated sections plus the common subset (headings,
/// lists, code, links, emphasis, tables, blockquotes, images, horizontal
/// rules, line/paragraph breaks). Unknown HTML is passed through as a
/// raw block.
/// Translate markdown to typst markup with no image resolution.
///
/// A **remote** URL cannot be fetched through this door (there is no cache
/// dir), so it becomes its alt text. A **local** path is emitted root-anchored,
/// on the assumption that a caller asking for markup will compile it against a
/// root of its own; [`export_svg_column`] and [`export_html`] mount no root and
/// therefore fall back to the alt text there too, rather than emitting a path
/// whose "file not found" would take the whole document down.
pub fn md_to_typst(md: &str) -> String {
    // `root_mounted: true` even though this door mounts nothing: the caller is
    // asking for the TRANSLATION, and what it does with the markup — including
    // compiling it against a root of its own — is its business. Emitting the
    // root-anchored `#image("/…")` is the answer that keeps working for them.
    // The in-memory path (`build_engine_ext` with `root: None`) is where an
    // unresolvable local image becomes its alt text instead; see
    // `ImageResolver::root_mounted`.
    let mut noop = ImageResolver::new(None, true);
    md_to_typst_with(md, &mut noop, Target::Paged)
}

/// Translate markdown to typst markup, routing remote images through
/// `images` (downloads + mounts them as virtual files). `target` selects
/// a handful of construct emissions that differ between the paged and HTML
/// backends (currently: the thematic break).
pub fn md_to_typst_with(md: &str, images: &mut ImageResolver, target: Target) -> String {
    use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
    let mut opts = Options::empty();
    opts.insert(Options::ENABLE_TABLES);
    opts.insert(Options::ENABLE_STRIKETHROUGH);
    opts.insert(Options::ENABLE_TASKLISTS);

    let mut out = String::with_capacity(md.len() * 2);
    let mut list_stack: Vec<Option<u64>> = Vec::new();
    let mut in_code: Option<String> = None;
    let mut code_buf = String::new();
    // Suppress an image's alt-text run (`![alt](src)`): typst's `#image` takes
    // no alt body, so the alt would otherwise leak into the document as stray
    // text right after the image.
    let mut in_image = false;
    let mut table_cells: Vec<String> = Vec::new();
    let mut cell_buf: Option<String> = None;
    let mut link_url: Option<String> = None;
    // Where the current paragraph began in `out`, and — when it opened with a
    // sized image — where that image's markup ends. See `Tag::Paragraph`.
    let mut para_start: Option<usize> = None;
    let mut side_image: Option<usize> = None;

    let push = |out: &mut String, cell_buf: &mut Option<String>, s: &str| {
        if let Some(b) = cell_buf {
            b.push_str(s);
        } else {
            out.push_str(s);
        }
    };

    for ev in Parser::new_ext(md, opts) {
        match ev {
            Event::Start(Tag::Heading { level, .. }) => {
                let n = match level {
                    HeadingLevel::H1 => 1,
                    HeadingLevel::H2 => 2,
                    HeadingLevel::H3 => 3,
                    HeadingLevel::H4 => 4,
                    HeadingLevel::H5 => 5,
                    HeadingLevel::H6 => 6,
                };
                out.push('\n');
                for _ in 0..n {
                    out.push('=');
                }
                out.push(' ');
            }
            Event::End(TagEnd::Heading(_)) => out.push_str("\n\n"),

            // A paragraph that OPENS with a sized image (see `image_width`) is laid
            // out as that image beside the rest of the paragraph — an icon next to
            // its description — instead of the image sitting inline on the first
            // line. Only outside table cells, where `out` is what is being written.
            Event::Start(Tag::Paragraph) => {
                para_start = cell_buf.is_none().then_some(out.len());
                side_image = None;
            }
            Event::End(TagEnd::Paragraph) => {
                if let (Some(p), Some(end)) = (para_start.take(), side_image.take()) {
                    let rest = out.split_off(end);
                    let img = out.split_off(p);
                    out.push_str("#grid(columns: (auto, 1fr), column-gutter: 12pt, ");
                    out.push_str(img.trim_start_matches('#'));
                    out.push_str(", [");
                    // The space between the image and the text is a string of
                    // its own; in a grid cell it would indent the text.
                    out.push_str(rest.trim().trim_start_matches("#\" \"").trim_start());
                    out.push_str("])");
                }
                out.push_str("\n\n")
            }

            Event::Start(Tag::BlockQuote(_)) => out.push_str("#quote(block: true)[\n"),
            Event::End(TagEnd::BlockQuote(_)) => out.push_str("]\n\n"),

            Event::Start(Tag::CodeBlock(kind)) => {
                let lang = match kind {
                    CodeBlockKind::Fenced(l) => l.to_string(),
                    CodeBlockKind::Indented => String::new(),
                };
                in_code = Some(lang);
                code_buf.clear();
            }
            Event::End(TagEnd::CodeBlock) => {
                let lang = in_code.take().unwrap_or_default();
                // The body is buffered so we can size the typst raw fence longer
                // than any backtick run inside it — nornir's own docs embed
                // ``` fences and `code`, which would otherwise leave the raw
                // block "unclosed" for the typst parser.
                out.push_str(&typst_raw_block(&lang, &code_buf));
                code_buf.clear();
            }

            Event::Start(Tag::List(start)) => {
                list_stack.push(start);
                out.push('\n');
            }
            Event::End(TagEnd::List(_)) => {
                list_stack.pop();
                out.push('\n');
            }
            Event::Start(Tag::Item) => {
                let indent = "  ".repeat(list_stack.len().saturating_sub(1));
                out.push_str(&indent);
                match list_stack.last().copied().flatten() {
                    Some(_) => out.push_str("+ "),
                    None => out.push_str("- "),
                }
            }
            Event::End(TagEnd::Item) => out.push('\n'),

            // Emphasis goes out in typst's FUNCTION form, not the shorthand.
            //
            // typst's `*…*` and `_…_` only close at a word boundary, while
            // markdown's `**…**` closes anywhere. So `**SIGKILL**s` — bold run
            // welded to a suffix, ordinary English — becomes `*SIGKILL*s`,
            // whose closing `*` is not a boundary, and the delimiter stays
            // open until typst gives up on the whole document with "unclosed
            // delimiter". One such word in 297 KB of markdown loses the entire
            // PDF, and the message names no line. `#strong[…]` / `#emph[…]`
            // have no boundary rule; `[` and `]` inside the content are already
            // escaped by `escape_typst_text`, which is what `#strike[…]` below
            // has always relied on.
            Event::Start(Tag::Emphasis) => push(&mut out, &mut cell_buf, "#emph["),
            Event::End(TagEnd::Emphasis) => push(&mut out, &mut cell_buf, "]"),
            Event::Start(Tag::Strong) => push(&mut out, &mut cell_buf, "#strong["),
            Event::End(TagEnd::Strong) => push(&mut out, &mut cell_buf, "]"),
            Event::Start(Tag::Strikethrough) => push(&mut out, &mut cell_buf, "#strike[ "),
            Event::End(TagEnd::Strikethrough) => push(&mut out, &mut cell_buf, " ]"),

            Event::Start(Tag::Link { dest_url, .. }) => {
                link_url = Some(dest_url.to_string());
                push(&mut out, &mut cell_buf, "#link(\"");
                push(&mut out, &mut cell_buf, &escape_typst_str(&dest_url));
                push(&mut out, &mut cell_buf, "\")[");
            }
            Event::End(TagEnd::Link) => {
                link_url = None;
                push(&mut out, &mut cell_buf, "]");
            }

            Event::Start(Tag::Image {
                dest_url, title, ..
            }) => {
                // `![alt](src "40pt")`: a title that is a typst length sizes the
                // image. Any other title is ignored, as before.
                let width = image_width(&title);
                let close = match &width {
                    Some(w) => format!("\", width: {w})"),
                    None => "\")".to_string(),
                };
                // First thing in a paragraph outside a table → side-by-side.
                let opens_para = width.is_some() && para_start == Some(out.len());
                // Cleared below by whichever arm could not place a picture, so
                // that the alt text is drawn exactly when there is no image to
                // draw it beside.
                in_image = true;
                let url = dest_url.to_string();
                let is_remote = url.starts_with("http://") || url.starts_with("https://");
                if is_remote {
                    match images.resolve(&url) {
                        Some(virt) => {
                            push(&mut out, &mut cell_buf, "#image(\"");
                            push(&mut out, &mut cell_buf, &escape_typst_str(&virt));
                            push(&mut out, &mut cell_buf, &close);
                            if opens_para {
                                side_image = Some(out.len());
                            }
                        }
                        // No cache dir, or the fetch failed. The alt text is
                        // what the author wrote for this case, so draw it
                        // rather than a placeholder of ours.
                        None => in_image = false,
                    }
                } else {
                    // In-memory first (an `ImageSet` caller has no root), then
                    // the mounted filesystem root, which is what a build step
                    // has. With neither, `in_image` stays false and the alt
                    // text below is drawn — see `ImageResolver::root_mounted`.
                    let local = images
                        .resolve(&url)
                        .or_else(|| images.root_mounted.then(|| local_image_path(&url)));
                    match local {
                        Some(local) => {
                            push(&mut out, &mut cell_buf, "#image(\"");
                            push(&mut out, &mut cell_buf, &escape_typst_str(&local));
                            push(&mut out, &mut cell_buf, &close);
                            if opens_para {
                                side_image = Some(out.len());
                            }
                        }
                        None => in_image = false,
                    }
                }
            }
            Event::End(TagEnd::Image) => in_image = false,

            Event::Start(Tag::Table(aligns)) => {
                let cols = aligns.len().max(1);
                table_cells.clear();
                out.push_str(&format!("\n#table(\n  columns: {},\n", cols));
            }
            Event::End(TagEnd::Table) => {
                for c in table_cells.drain(..) {
                    out.push_str("  [");
                    out.push_str(&c);
                    out.push_str("],\n");
                }
                out.push_str(")\n\n");
            }
            Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => {}
            Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {}
            Event::Start(Tag::TableCell) => {
                cell_buf = Some(String::new());
            }
            Event::End(TagEnd::TableCell) => {
                if let Some(b) = cell_buf.take() {
                    table_cells.push(b);
                }
            }

            Event::Text(t) => {
                if in_code.is_some() {
                    // Raw code body — buffered verbatim (fenced at End).
                    code_buf.push_str(&t);
                } else if in_image {
                    // Alt text — `#image` has no body; drop it.
                } else {
                    let _ = &link_url;
                    push(&mut out, &mut cell_buf, &typst_string(&t));
                }
            }
            Event::Code(t) => {
                push(&mut out, &mut cell_buf, &typst_raw_inline(&t));
            }
            Event::Html(h) | Event::InlineHtml(h) => {
                // Skip nornir markers; pass other HTML through as comment.
                let s = h.to_string();
                if s.contains("nornir:gen:") || s.contains("nornir:generated") {
                    // section markers — already consumed by sections.rs
                    continue;
                }
                // Recover `<img src="…">` so logos/figures authored as raw HTML
                // (for git-host centering) still render in PDF/HTML. Local paths
                // are normalised to project-root form so the filesystem root
                // resolves them. Other raw HTML is dropped (typst has no DOM).
                if let Some(src) = extract_img_src(&s) {
                    let is_remote = src.starts_with("http://") || src.starts_with("https://");
                    // Same order as the markdown image above: what the caller
                    // handed in, then the mounted root. A README whose badge is
                    // authored as a raw `<img>` renders either way, and draws
                    // nothing at all when neither has it — a raw `<img>` has no
                    // alt text this translator can fall back to.
                    let path = images.resolve(&src).or_else(|| {
                        (!is_remote && images.root_mounted).then(|| local_image_path(&src))
                    });
                    if let Some(p) = path {
                        let esc = escape_typst_str(&p);
                        match target {
                            // `#align` is a layout primitive — not in the HTML target.
                            Target::Paged => {
                                push(&mut out, &mut cell_buf, "\n#align(center, image(\"");
                                push(&mut out, &mut cell_buf, &esc);
                                push(&mut out, &mut cell_buf, "\", width: 70%))\n\n");
                            }
                            Target::Html => {
                                push(&mut out, &mut cell_buf, "\n#image(\"");
                                push(&mut out, &mut cell_buf, &esc);
                                push(&mut out, &mut cell_buf, "\")\n\n");
                            }
                        }
                    }
                }
            }
            Event::SoftBreak => push(&mut out, &mut cell_buf, " "),
            Event::HardBreak => push(&mut out, &mut cell_buf, " \\\n"),
            Event::Rule => match target {
                Target::Paged => out.push_str("\n#line(length: 100%, stroke: 0.4pt + gray)\n\n"),
                // Layout primitives aren't allowed in typst's HTML target; emit
                // a real `<hr>` element instead.
                Target::Html => out.push_str("\n#html.elem(\"hr\")\n\n"),
            },
            Event::FootnoteReference(_)
            | Event::TaskListMarker(_)
            | Event::InlineMath(_)
            | Event::DisplayMath(_) => {}
            Event::Start(_) | Event::End(_) => {}
        }
    }
    out
}

/// Longest run of consecutive backticks in `s`.
/// A markdown image title that is a typst length (`40pt`, `3em`, `25%`, `12mm`,
/// `1.5cm`) — the one way markdown can size a picture. `None` for anything else,
/// so an ordinary title (`"The logo"`) changes nothing.
fn image_width(title: &str) -> Option<String> {
    let t = title.trim();
    let unit = ["pt", "em", "mm", "cm", "%"].into_iter().find(|u| t.ends_with(u))?;
    let n: f64 = t[..t.len() - unit.len()].parse().ok()?;
    (n.is_finite() && n > 0.0).then(|| format!("{n}{unit}"))
}

fn max_backtick_run(s: &str) -> usize {
    let mut max = 0;
    let mut cur = 0;
    for c in s.chars() {
        if c == '`' {
            cur += 1;
            max = max.max(cur);
        } else {
            cur = 0;
        }
    }
    max
}

/// Emit `content` as a typst *inline* raw span, choosing a backtick fence
/// longer than any run inside it (CommonMark rule), padding with spaces when
/// the content starts or ends with a backtick.
fn typst_raw_inline(content: &str) -> String {
    let fence = "`".repeat(max_backtick_run(content) + 1);
    if content.starts_with('`') || content.ends_with('`') {
        format!("{fence} {content} {fence}")
    } else {
        format!("{fence}{content}{fence}")
    }
}

/// Emit `content` as a typst *block* raw, with a fence (≥3 backticks) longer
/// than any run inside it, and the language tag glued to the opening fence.
fn typst_raw_block(lang: &str, content: &str) -> String {
    let fence = "`".repeat((max_backtick_run(content) + 1).max(3));
    let mut body = content.to_string();
    if !body.ends_with('\n') {
        body.push('\n');
    }
    format!("\n{fence}{lang}\n{body}{fence}\n\n")
}

/// Normalise a *local* markdown image path into a typst project-root path.
///
/// nornir emits local image links repo-root-relative (e.g.
/// `.nornir/assets/depgraph.svg`). typst resolves a leading-`/` path against
/// the mounted filesystem root, while a bare relative path is resolved against
/// the (virtual, in-memory) main file — which has no directory. So we strip a
/// `./` prefix and force a single leading `/`, making the path root-anchored.
fn local_image_path(url: &str) -> String {
    let trimmed = url.strip_prefix("./").unwrap_or(url);
    format!("/{}", trimmed.trim_start_matches('/'))
}

/// Best-effort extraction of the first `src="…"` (or `src='…'`) attribute from
/// a raw HTML `<img …>` chunk. Returns `None` when the chunk has no `<img>`.
fn extract_img_src(html: &str) -> Option<String> {
    let lower = html.to_ascii_lowercase();
    if !lower.contains("<img") {
        return None;
    }
    let at = lower.find("src=")? + 4;
    let rest = &html[at..];
    let quote = rest.chars().next()?;
    if quote != '"' && quote != '\'' {
        return None;
    }
    let end = rest[1..].find(quote)?;
    Some(rest[1..1 + end].to_string())
}

/// **A run of text, as a typst STRING LITERAL — the escaping that cannot be
/// incomplete.**
///
/// The obvious translator writes typst *markup* and escapes the characters
/// typst treats specially. This crate did that, and the list it needed was long
/// and kept growing: `\\ # $ * _ ` < > @ [ ]`, and then `/` because `//`
/// opens a comment that swallows an open delimiter, and then `/` at a run
/// boundary because `**a**/**b**` forms `*/` ACROSS two runs. Every one of
/// those was a real document that lost a whole render to "unclosed delimiter",
/// found one at a time, in production. The rule they were fixing is that a
/// character which is inert in one position is active in another, and no list
/// can close over that.
///
/// A string literal has no such rule. `#"…"` is an expression whose value is
/// displayed as text, and the only two characters that can end one are `\` and
/// `"`. So the escape table is two entries and it is complete by construction —
/// `MAX_BLOB_BYTES` keeps its underscores, `100% #1 @ $5` is four glyphs, and a
/// line of somebody's markdown that begins `#import "@preview/…"` is drawn
/// rather than executed.
///
/// It costs three bytes per text run and nothing else: `#raw`, `#link`,
/// `#strong`, `#emph`, `#table` and the heading/list markers are unchanged, so
/// the output is the same document with the text runs quoted.
fn typst_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 3);
    out.push_str("#\"");
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            // A literal newline cannot appear inside a typst string.
            '\n' => out.push_str("\\n"),
            '\r' => {}
            _ => out.push(c),
        }
    }
    out.push('"');
    out
}

fn escape_typst_str(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            _ => out.push(c),
        }
    }
    out
}

// --------- High-level "render this repo" convenience ----------

/// Read `<repo_root>/README.md`, build [`ExportMeta`] from the crate's
/// `Cargo.toml`, and render to `format`. The README must already have been
/// assembled via `nornir`'s `docs::sections::assemble_file`.
pub fn export_repo(repo_root: &Path, format: DocFormat) -> Result<Vec<u8>> {
    let md_path = repo_root.join("README.md");
    let md =
        std::fs::read_to_string(&md_path).with_context(|| format!("read {}", md_path.display()))?;
    let meta = read_meta(repo_root)?;
    let cache_dir = repo_root.join(".nornir/cache/images");
    // Mount the repo root so local `.nornir/assets/…` images resolve and embed.
    export(&md, &meta, format, Some(&cache_dir), Some(repo_root))
}

fn read_meta(repo_root: &Path) -> Result<ExportMeta> {
    let cargo_toml = repo_root.join("Cargo.toml");
    let content = std::fs::read_to_string(&cargo_toml)
        .with_context(|| format!("read {}", cargo_toml.display()))?;
    let parsed: toml::Value = toml::from_str(&content)?;
    let pkg = parsed
        .get("package")
        .or_else(|| parsed.get("workspace").and_then(|w| w.get("package")));
    let dir_name = repo_root
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("project")
        .to_string();
    let (title, version) = if let Some(p) = pkg {
        let name = p
            .get("name")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string())
            .unwrap_or_else(|| dir_name.clone());
        let version = p
            .get("version")
            .and_then(|v| v.as_str())
            .unwrap_or("0.0.0")
            .to_string();
        (name, version)
    } else {
        (dir_name, "0.0.0".to_string())
    };
    let generated = chrono::Utc::now().format("%Y-%m-%d").to_string();
    Ok(ExportMeta {
        title,
        version,
        generated,
        cover_image: String::new(),
    })
}

#[cfg(test)]
mod tests {
    /// What typst-svg 0.15 writes at the start of one drawn text run: the font's
    /// Y-up flip. (0.14 marked the same group `class="typst-text"`.)
    const TYPST_RUN: &str = "<g transform=\"matrix(1 0 0 -1 ";

    use super::*;

    /// Everything the emitted typst will DRAW, recovered by concatenating its
    /// string literals.
    ///
    /// A test written against one spelling of the output pins pulldown-cmark's
    /// run splitting, which is not this crate's promise. This pins what a reader
    /// sees, which is.
    fn quoted_runs(typst: &str) -> String {
        let mut out = String::new();
        let mut rest = typst;
        while let Some(at) = rest.find("#\"") {
            let mut chars = rest[at + 2..].char_indices();
            let mut escaped = false;
            while let Some((i, c)) = chars.next() {
                match c {
                    _ if escaped => {
                        out.push(match c {
                            'n' => '\n',
                            other => other,
                        });
                        escaped = false;
                    }
                    '\\' => escaped = true,
                    '"' => {
                        rest = &rest[at + 2 + i + 1..];
                        break;
                    }
                    other => out.push(other),
                }
                if chars.as_str().is_empty() {
                    rest = "";
                }
            }
            if rest.is_empty() {
                break;
            }
        }
        out
    }

    fn meta() -> ExportMeta {
        ExportMeta {
            title: "ns/name README.md".into(),
            version: "0".into(),
            generated: String::new(),
            cover_image: String::new(),
        }
    }

    /// A one-pixel PNG, so a test can put a REAL image in an `ImageSet` and
    /// have typst decode it rather than assert on a path it never opened.
    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, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00,
        0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
        0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
    ];

    // ── the escaping, which is now a property rather than a list ────────────

    /// **The regression that decided the string-literal form.** A code
    /// repository's prose is made of `snake_case` identifiers, and an escaper
    /// that writes `\_` — or, as one crates.io markdown→typst translator does,
    /// `" \_"` with a leading space — turns `MAX_BLOB_BYTES` into
    /// `MAX _BLOB _BYTES` on the page.
    #[test]
    fn an_underscore_in_prose_keeps_its_place() {
        let out = md_to_typst("Call MAX_BLOB_BYTES and read_object.\n");

        assert!(
            out.contains("#\"Call MAX_BLOB_BYTES and read_object.\""),
            "{out}"
        );
    }

    /// `#` opens an expression, `$` opens math, `@` opens a label reference,
    /// `<` and `>` open a label. All five are ordinary glyphs in a README.
    #[test]
    fn typst_special_characters_are_text_and_not_syntax() {
        let source = "Costs $5 @ #1 and 5 < 6 > 4 at 100%";
        let out = md_to_typst(&format!("# {source}\n"));

        // pulldown-cmark hands `<` back as a run of its own (it might have been
        // a tag), so the heading is several literals in a row. Concatenated they
        // are the source, character for character — which is the claim, and is
        // stronger than matching one spelling of the split.
        assert_eq!(quoted_runs(&out), source, "{out}");
        assert!(out.starts_with("\n= #\""), "{out}");
    }

    /// The two characters that CAN end a string literal, and therefore the
    /// whole of the escape table.
    #[test]
    fn a_backslash_and_a_quote_are_escaped_and_nothing_else_is() {
        let out = md_to_typst("a \\ b \" c\n");

        assert!(out.contains("\\\\"), "the backslash must be escaped: {out}");
        assert!(out.contains("\\\""), "the quote must be escaped: {out}");
    }

    /// A markdown line that reads like typst code is drawn, not run.
    #[test]
    fn a_package_import_written_in_prose_is_drawn_rather_than_executed() {
        let out = md_to_typst("Write #import \"@preview/cmarker:0.1.0\" at the top.\n");

        assert!(out.contains("#\"Write #import "), "{out}");
        assert!(
            !out.contains("\n#import"),
            "an import must never reach markup position: {out}"
        );
    }

    #[test]
    fn an_images_alt_text_is_not_drawn_beside_the_image() {
        let mut images = ImageSet::new();
        assert!(images.insert("docs/logo.png", PNG_1PX.to_vec()));
        let mut resolver = ImageResolver::with_set(None, false, images);

        let out = md_to_typst_with("![the logo](docs/logo.png)\n", &mut resolver, Target::Paged);

        assert!(out.contains("#image(\"/img/0.png\")"), "{out}");
        assert!(
            !out.contains("the logo"),
            "the alt text is not drawn twice: {out}"
        );
    }

    // ── ImageSet ────────────────────────────────────────────────────────────

    /// **The PDF carries the picture the SVG carried.** RED before `export_pdf`
    /// existed: the paged pipeline had no memory door, so the same document
    /// that drew its badge in the column export drew alt text in the PDF. The
    /// PNG is placed by `#image`, and typst-pdf embeds a placed raster as an
    /// image XObject; a PDF with no `/Image` in it placed nothing.
    #[test]
    fn a_pdf_carries_an_image_handed_over_in_memory() {
        let md = "# Badge\n\n![the logo](docs/logo.png)\n\nProse.\n";
        let mut images = ImageSet::new();
        assert!(images.insert("docs/logo.png", PNG_1PX.to_vec()));

        let with = export_pdf(md, &meta(), &images).expect("it renders");
        let without = export(md, &meta(), DocFormat::Pdf, None, None).expect("it renders");

        // COUNTED, not detected: `report.typ` places a picture of its own (the
        // cover), so every PDF has image XObjects. MEASURED 2026-09-12: 6 without
        // the badge, 8 with it — the 1-px PNG has an alpha channel and typst-pdf
        // writes a raster with alpha as TWO XObjects, the image and its soft
        // mask. So "strictly more", with the numbers here rather than a `+ 1`
        // that would break the day the fixture loses its alpha.
        let images_in = |pdf: &[u8]| pdf.windows(6).filter(|w| *w == b"/Image").count();
        let (with_badge, without_badge) = (images_in(&with), images_in(&without));
        assert!(
            with_badge > without_badge,
            "the PDF handed the badge must embed more image XObjects ({with_badge}) than the one \
             that was not ({without_badge}) — with no image door the old path draws alt text \
             and embeds nothing, and that difference IS the finding"
        );
        assert!(with.starts_with(b"%PDF-"), "still a PDF");
    }

    /// **A relative image with no filesystem root anywhere.** This is the whole
    /// point of the type: the caller has the bytes and no directory.
    #[test]
    fn a_relative_image_resolves_from_memory_with_no_root_mounted() {
        let mut images = ImageSet::new();
        assert!(images.insert("./docs/logo.png?raw=1", PNG_1PX.to_vec()));
        let mut resolver = ImageResolver::with_set(None, false, images);

        // Every spelling of the same file finds the one slot.
        let out = md_to_typst_with(
            "![a](docs/logo.png)\n\n![b](/docs/logo.png)\n",
            &mut resolver,
            Target::Paged,
        );

        assert_eq!(out.matches("/img/0.png").count(), 2, "{out}");
    }

    /// A raw `<img src="…">` — how a README centres its badge on a git host —
    /// is recovered as an image, and now from memory too.
    #[test]
    fn a_raw_html_img_block_becomes_an_image_from_the_set() {
        let mut images = ImageSet::new();
        assert!(images.insert("docs/badge.png", PNG_1PX.to_vec()));
        let mut resolver = ImageResolver::with_set(None, false, images);

        let out = md_to_typst_with(
            "<p align=\"center\"><img src=\"docs/badge.png\" width=\"90\"></p>\n",
            &mut resolver,
            Target::Paged,
        );

        assert!(out.contains("image(\"/img/0.png\""), "{out}");
    }

    /// **The name decides nothing.** A file called `.png` that is not a PNG
    /// gets no slot, so the document falls back to the alt text rather than
    /// handing typst bytes it will refuse.
    #[test]
    fn the_format_is_sniffed_and_a_non_image_is_refused() {
        let mut images = ImageSet::new();

        assert!(images.insert("a.png", PNG_1PX.to_vec()));
        assert!(!images.insert("b.png", b"not an image at all".to_vec()));
        assert!(!images.insert("c.png", b"%PDF-1.7\n".to_vec()));
        assert!(
            !images.insert("a.png", PNG_1PX.to_vec()),
            "a second offer for one name"
        );
        assert_eq!(images.len(), 1);
    }

    /// **The slot table is bounded, and that is a `FileId` interner fact.**
    /// Every distinct virtual path is leaked for the life of the process and
    /// typst panics past 2¹⁶ of them, so a long-running renderer must not be
    /// able to mint one per image it ever sees.
    #[test]
    fn the_slot_table_stops_at_its_cap() {
        let mut images = ImageSet::new();
        for i in 0..(ImageSet::MAX + 8) {
            let accepted = images.insert(&format!("img{i}.png"), PNG_1PX.to_vec());
            assert_eq!(accepted, i < ImageSet::MAX, "at {i}");
        }

        assert_eq!(images.len(), ImageSet::MAX);
    }

    // ── the column render ───────────────────────────────────────────────────

    /// **One tall page, dressed so a browser can shrink it** — the 0.1.1 grade,
    /// which is now [`TextLayer::Off`].
    #[test]
    fn a_column_render_is_one_scalable_page_with_no_script() {
        let md = "# The Heading\n\nProse with `code_here` and a [link](https://example.test).\n\n\
                  | A | B |\n| - | - |\n| 1 | 2 |\n\n```rust\nfn f() {}\n```\n";

        let svg = export_svg_column(md, &meta(), &ImageSet::new(), 720.0, TextLayer::Off)
            .expect("it renders");

        assert!(svg.starts_with("<svg "), "{}", &svg[..60.min(svg.len())]);
        assert!(svg.trim_end().ends_with("</svg>"));
        assert!(
            svg.contains("viewBox="),
            "without a viewBox it cannot scale"
        );
        assert!(svg.contains("width=\"100%\""), "it must fit a phone");
        let root = &svg[..svg.find('>').expect("a root element")];
        assert!(
            !root.contains(" height=\""),
            "a fixed height on the root defeats the aspect ratio: {root}"
        );
        // The page is inlined into somebody's DOM. It carries no script, ever.
        assert!(
            !svg.contains("<script"),
            "an inline SVG must carry no script"
        );
        assert!(!svg.contains("javascript:"));
        // The glyphs are paths, so the only name the page can have is the one
        // put on it here.
        assert!(svg.contains("<title>ns/name README.md</title>"), "no title");
        assert!(svg.contains("role=\"img\""));
        // The link survived as a real link.
        assert!(svg.contains("https://example.test"));
        // …and nothing of the 0.1.2 layer is in it.
        assert!(!svg.contains("<text"), "Off must draw the picture alone");
        assert!(!svg.contains("text-layer"), "{}", &svg[..80.min(svg.len())]);
    }

    // ── the text layer ──────────────────────────────────────────────────────

    /// Three headings and two paragraphs, in words that are only words: no
    /// markdown syntax inside them, so the rendered text and the source text
    /// are the same sequence and `the_words_come_out_in_the_markdown_s_order`
    /// can compare them without a parser of its own.
    const PROSE: &str = "# Alpha Heading\n\n\
         The first paragraph says something plain and short.\n\n\
         ## Beta Heading\n\n\
         A second paragraph follows with more plain words in it.\n\n\
         ### Gamma Heading\n";

    fn typeset(md: &str) -> String {
        export_svg_column(md, &meta(), &ImageSet::new(), 720.0, TextLayer::On).expect("it renders")
    }

    /// The contents of every `<text>` element, in document order.
    fn text_contents(svg: &str) -> Vec<String> {
        let mut out = Vec::new();
        let mut rest = svg;
        while let Some(at) = rest.find("<text ") {
            rest = &rest[at..];
            let open = rest.find('>').expect("an open tag ends");
            let close = rest.find("</text>").expect("a text element closes");
            out.push(rest[open + 1..close].to_owned());
            rest = &rest[close + "</text>".len()..];
        }
        out
    }

    /// **One `<text>` per shaped run**, counted against the runs `typst-svg`
    /// itself drew rather than against a number written here — a hard-coded
    /// count would be a fact about this fixture's line breaking, which is the
    /// typesetter's business and may change.
    #[test]
    fn the_layer_has_exactly_one_text_element_per_drawn_run() {
        let svg = typeset(PROSE);

        // typst-svg 0.15 (2026-10-02) dropped the `typst-text` class: a drawn
        // run is now the group carrying the font's Y-flip, `matrix(1 0 0 -1 ..)`.
        let drawn = svg.matches(TYPST_RUN).count();
        let written = svg.matches("<text ").count();

        assert!(drawn >= 5, "the fixture has five blocks of prose: {drawn}");
        assert_eq!(written, drawn, "one <text> per run, no more and no fewer");
        // And the layer is LAST, so it is on top for hit-testing.
        let layer = svg.find("class=\"text-layer\"").expect("a text layer");
        let last_glyph = svg.rfind(TYPST_RUN).expect("glyphs");
        assert!(layer > last_glyph, "the layer must be above the picture");
    }

    /// **The words come out in the markdown's order**, which is what makes a
    /// copy out of the page a copy of the document and not of its line breaks.
    #[test]
    fn the_words_come_out_in_the_markdown_s_order() {
        let svg = typeset(PROSE);

        // Runs are joined with a space: a line break eats the trailing space, so
        // the last word of one line and the first of the next would otherwise
        // arrive fused.
        let got: Vec<String> = text_contents(&svg)
            .join(" ")
            .split_whitespace()
            .map(str::to_owned)
            .collect();
        let want: Vec<String> = PROSE
            .replace('#', " ")
            .split_whitespace()
            .map(str::to_owned)
            .collect();

        assert_eq!(got, want);
    }

    /// **Ctrl-F equivalence**: a whole sentence of the document is in the file,
    /// verbatim and contiguous, so a browser's find can match it.
    #[test]
    fn a_whole_sentence_is_in_the_page_verbatim() {
        let svg = typeset(PROSE);

        let joined = text_contents(&svg).concat();

        assert!(
            joined.contains("The first paragraph says something plain and short."),
            "the sentence is not findable: {joined}"
        );
        assert!(joined.contains("Gamma Heading"), "{joined}");
    }

    /// The layer is text, and text only. Nothing about carrying real characters
    /// makes a page a place to put a script.
    #[test]
    fn the_layer_adds_no_script_and_keeps_the_page_a_document() {
        let svg = typeset("# H\n\nWords.\n");

        assert!(!svg.contains("<script"), "{svg}");
        assert!(!svg.contains("javascript:"), "{svg}");
        assert!(!svg.contains("on click"), "{svg}");
        // It carries text now, so it is a document rather than a picture — and
        // the drawing is hidden from a screen reader that would otherwise be
        // offered both.
        assert!(svg.contains("role=\"document\""), "{}", &svg[..120]);
        assert!(!svg.contains("role=\"img\""), "{}", &svg[..120]);
        assert!(
            svg.contains("<g aria-hidden=\"true\">"),
            "the glyphs are hidden"
        );
        assert!(svg.contains("<title>ns/name README.md</title>"));
        // The style that makes it invisible and selectable travels with it.
        assert!(svg.contains(".text-layer text{fill:transparent"), "{svg}");
        assert!(svg.contains("user-select:text"), "{svg}");
        assert!(svg.contains("::selection"), "a selection must be visible");
        // The highlight spans the drawn run, not the browser's idea of it.
        assert!(svg.contains("lengthAdjust=\"spacingAndGlyphs\""), "{svg}");
        assert!(svg.contains("textLength=\""), "{svg}");
    }

    // ── the link layer ──────────────────────────────────────────────────────

    /// The `link-layer` group, or a failure naming the page.
    fn link_layer(svg: &str) -> &str {
        let start = svg
            .find("<g class=\"link-layer\"")
            .unwrap_or_else(|| panic!("{svg}"));
        let end = svg[start..].find("</g>").expect("the layer closes") + start;
        &svg[start..end]
    }

    /// One attribute's number, from the first element at `from` that has it.
    fn number(svg: &str, from: usize, name: &str) -> f64 {
        let key = format!(" {name}=\"");
        let start = svg[from..].find(&key).expect("an attribute") + from + key.len();
        let end = svg[start..].find('"').expect("a closing quote") + start;
        svg[start..end].parse().expect("a number")
    }

    /// **A link is lifted above the words**, so it takes the click the text
    /// layer used to swallow — measured on gunnar.rs 2026-09-11 as a README's
    /// link to its own PDF doing nothing.
    #[test]
    fn a_link_is_lifted_above_the_text_and_keeps_its_target() {
        let svg = typeset("Read [the guide](docs/guide.pdf) first.\n");

        let text = svg.find("class=\"text-layer\"").expect("a text layer");
        let links = svg.find("class=\"link-layer\"").expect("a link layer");
        assert!(links > text, "the links must be above the words");
        let layer = link_layer(&svg);
        assert_eq!(layer.matches("<a ").count(), 1, "{layer}");
        assert!(
            layer.contains("href=\"docs/guide.pdf\" xlink:href=\"docs/guide.pdf\""),
            "{layer}"
        );
        assert!(layer.contains("fill=\"transparent\""), "{layer}");
        assert!(svg.contains(".link-layer a{cursor:pointer}"), "{svg}");
    }

    /// **The lifted box is on the words it links**, asserted against the text
    /// layer — whose own placement `a_run_sits_on_the_glyphs_it_names` proves
    /// against the glyphs. The linked run starts where the box starts, and its
    /// baseline is inside the box.
    #[test]
    fn a_lifted_link_sits_on_the_words_it_links() {
        let svg = typeset("[Guide](docs/guide.pdf)\n");

        let run = svg.find(">Guide</text>").expect("the linked run");
        let run = svg[..run].rfind("<text ").expect("its element");
        let (rx, ry) = (number(&svg, run, "x"), number(&svg, run, "y"));
        let rect = svg.find("<g class=\"link-layer\"").expect("a link layer");
        let (bx, by) = (number(&svg, rect, "x"), number(&svg, rect, "y"));
        let (bw, bh) = (number(&svg, rect, "width"), number(&svg, rect, "height"));

        assert!((rx - bx).abs() < 0.5, "run x {rx} vs box x {bx}");
        assert!(
            by <= ry && ry <= by + bh,
            "baseline {ry} outside {by}..{}",
            by + bh
        );
        assert!(bw > 10.0 && bh > 5.0, "a box a pointer can find: {bw}x{bh}");
    }

    /// **Only a link a browser can follow without running anything is
    /// lifted.** `typst-svg` still draws the other one, under the text where no
    /// click reaches it — this crate does not make it clickable.
    #[test]
    fn a_script_link_is_not_lifted() {
        let svg = typeset("[x](javascript:alert(1)) and [y](https://example.test/y).\n");

        let layer = link_layer(&svg);
        assert!(layer.contains("href=\"https://example.test/y\""), "{layer}");
        assert!(!layer.contains("javascript"), "{layer}");
        assert_eq!(layer.matches("<a ").count(), 1, "{layer}");
    }

    #[test]
    fn a_page_with_no_link_has_no_link_layer() {
        assert!(!typeset(PROSE).contains("<g class=\"link-layer\""));
    }

    /// The target came from somebody's markdown and goes into an attribute.
    #[test]
    fn a_lifted_target_is_escaped_for_its_attribute() {
        let svg = typeset("[q](search?a=1&b=\"2\")\n");

        let layer = link_layer(&svg);
        assert!(
            layer.contains("href=\"search?a=1&amp;b=&quot;2&quot;\""),
            "{layer}"
        );
    }

    #[test]
    fn followable_is_an_allow_list_of_schemes() {
        for yes in [
            "docs/a.pdf",
            "./a.md",
            "/abs/a.md",
            "#anchor",
            "docs/a:b.md",
            "a?x=y:z",
            "https://example.test",
            "HTTP://example.test",
            "mailto:a@example.test",
        ] {
            assert!(followable(yes), "{yes}");
        }
        for no in [
            "javascript:alert(1)",
            "JavaScript:alert(1)",
            " javascript:alert(1)",
            "java\tscript:alert(1)",
            "data:text/html,x",
            "vbscript:x",
            "file:///etc/passwd",
        ] {
            assert!(!followable(no), "{no:?}");
        }
    }

    /// **A run sits on the glyphs it names.** The relation is asserted, not a
    /// magic number: `typst-svg` writes each run's baseline as the translation
    /// of `matrix(1 0 0 -1 tx ty)` on its glyph group, and the first `<text>`
    /// must be at that same point in the same units — which is also the proof
    /// that SVG user units here are points.
    #[test]
    fn a_run_sits_on_the_glyphs_it_names() {
        let svg = typeset(PROSE);

        // Every `<g transform>` opened before the first drawn run is an
        // ancestor of it — `typst-svg` writes the page margin onto one group and
        // the baseline flip onto another — so the run's absolute origin is the
        // sum of their translations. Each is checked to be axis-aligned first,
        // because summing is only the right composition if they are.
        let at = svg
            .find(TYPST_RUN)
            .expect("a drawn run");
        let run_end = svg[at..].find('>').expect("the group's tag ends") + at;
        let (mut gx, mut gy) = (0.0f64, 0.0f64);
        let mut rest = &svg[..run_end];
        let mut seen = 0;
        // typst-svg 0.15 writes plain offsets as `translate(x [y])` and only the
        // run's flip as `matrix(..)`; both are folded into the same 6-tuple.
        while let Some(open) = rest.find("transform=\"") {
            let start = open + "transform=\"".len();
            let close = rest[start..].find(')').expect(")") + start;
            let (kind, args) = rest[start..close].split_once('(').expect("a transform function");
            let n: Vec<f64> = args
                .split_whitespace()
                .map(|n| n.parse().expect("a number"))
                .collect();
            let m: Vec<f64> = match kind {
                "matrix" => n,
                "translate" => vec![1.0, 0.0, 0.0, 1.0, n[0], n.get(1).copied().unwrap_or(0.0)],
                other => panic!("an ancestor transform that is not a translation: {other}"),
            };
            assert_eq!(m.len(), 6, "{}", &rest[start..close]);
            assert_eq!(
                (m[0].abs(), m[1], m[2], m[3].abs()),
                (1.0, 0.0, 0.0, 1.0),
                "an ancestor transform that is not axis-aligned"
            );
            gx += m[4];
            gy += m[5];
            seen += 1;
            rest = &rest[close..];
        }
        assert!(seen >= 2, "the margin group and the run's own flip: {seen}");
        let matrix = [1.0, 0.0, 0.0, -1.0, gx, gy];

        let text_at = svg.find("<text ").expect("a text element");
        let value = |name: &str| -> f64 {
            let key = format!("{name}=\"");
            let start = svg[text_at..].find(&key).expect("an attribute") + text_at + key.len();
            let end = svg[start..].find('"').expect("a closing quote") + start;
            svg[start..end].parse().expect("a number")
        };

        assert!(
            (value("x") - matrix[4]).abs() < 1.0,
            "x {} vs {}",
            value("x"),
            matrix[4]
        );
        assert!(
            (value("y") - matrix[5]).abs() < 1.0,
            "y {} vs {}",
            value("y"),
            matrix[5]
        );
        // The first run is the first heading, at `column.typ`'s 20 pt.
        assert!(
            (value("font-size") - 20.0).abs() < 0.01,
            "{}",
            value("font-size")
        );
    }

    /// **`Off` is 0.1.1, byte for byte.** The proof is not a resemblance: it
    /// re-runs 0.1.1's exact pipeline — typst's page, the lossless pass,
    /// [`dress_svg`] — and compares the bytes.
    #[test]
    fn text_layer_off_is_the_previous_release_byte_for_byte() {
        let md = "# The Heading\n\nProse, a [link](https://example.test), and a table.\n\n\
                  | A | B |\n| - | - |\n| 1 | 2 |\n";
        let meta = meta();

        let off = export_svg_column(md, &meta, &ImageSet::new(), 720.0, TextLayer::Off)
            .expect("it renders");
        // 0.1.1's body, spelled out.
        let was = {
            let extra: Vec<(&str, typst::foundations::Value)> = vec![("width", 720.0.into_value())];
            let (engine, inputs) = build_engine_ext(
                md,
                &meta,
                None,
                None,
                Target::Paged,
                "column.typ",
                &extra,
                ImageSet::new(),
            )
            .expect("an engine");
            let doc: typst_layout::PagedDocument = engine
                .compile_with_input(inputs)
                .output
                .expect("a document");
            let raw = page_svg(&doc.pages()[0]);
            dress_svg(crate::svgz::optimize_svg(&raw), &meta.title)
        };

        assert_eq!(off, was);
        assert_ne!(
            off,
            export_svg_column(md, &meta, &ImageSet::new(), 720.0, TextLayer::On).expect("on"),
            "On must actually change the output"
        );
    }

    /// **Dressing the page in its own buffer produces the page that dressing it
    /// into a new one produced** — for BOTH grades, byte for byte.
    ///
    /// 0.1.5 stopped building a second, larger copy of the page to put the
    /// root element, the title and the text layer on it, and does the same edits
    /// in the buffer the optimizer already shrank. Every `.svgz` in the
    /// constellation and every README on gunnar.rs comes out of these two
    /// functions, so the check is against 0.1.4's spelling of them —
    /// [`reference_dress_svg`] / [`reference_dress_svg_with_text`] below, kept
    /// verbatim as the oracle — and not against themselves.
    ///
    /// The document carries a link and a table on purpose: the link layer is
    /// the part that is appended after the text layer, and it is the part an
    /// off-by-one in the tail splice would eat.
    ///
    /// Seen RED 2026-09-12 by giving `split_off` the body-relative `cut` the
    /// copying version used instead of the absolute one the in-place version
    /// needs — the exact mistake the move invites. It was the ONLY test in the
    /// suite that noticed: the other 70 passed.
    #[test]
    fn dressing_in_place_is_byte_for_byte_the_dressing_it_replaced() {
        let md = "# The Heading\n\nProse, a [link](https://example.test), and a table.\n\n\
                  | A | B |\n| - | - |\n| 1 | 2 |\n";
        let meta = meta();
        let extra: Vec<(&str, typst::foundations::Value)> = vec![("width", 720.0.into_value())];
        let (engine, inputs) = build_engine_ext(
            md,
            &meta,
            None,
            None,
            Target::Paged,
            "column.typ",
            &extra,
            ImageSet::new(),
        )
        .expect("an engine");
        let doc: typst_layout::PagedDocument =
            engine.compile_with_input(inputs).output.expect("a document");
        let page = &doc.pages()[0];
        let placed = layers(page);
        let svg = crate::svgz::optimize_svg(&page_svg(page));

        // Non-vacuity: an empty layer set would make both spellings agree for
        // the wrong reason.
        assert!(placed.runs.len() > 5, "{} runs", placed.runs.len());
        assert_eq!(placed.links.len(), 1, "the document has one link");

        assert_eq!(
            dress_svg(svg.clone(), &meta.title),
            reference_dress_svg(&svg, &meta.title),
            "TextLayer::Off",
        );
        assert_eq!(
            dress_svg_with_text(svg.clone(), &meta.title, &placed),
            reference_dress_svg_with_text(&svg, &meta.title, &placed),
            "TextLayer::On",
        );
    }

    /// 0.1.4's `dress_svg`, unchanged, as the oracle. Do not fold it back into
    /// the real one — being a second implementation is its whole job.
    fn reference_dress_svg(svg: &str, label: &str) -> String {
        let Some(head_end) = svg.find('>') else {
            return svg.to_owned();
        };
        let (head, rest) = svg.split_at(head_end);
        let head = strip_fixed_size(head);
        let mut out = String::with_capacity(svg.len() + 128);
        out.push_str(&head);
        out.push_str(" width=\"100%\" role=\"img\" aria-label=\"");
        out.push_str(&xml_escape(label, true));
        out.push_str("\">");
        out.push_str("<title>");
        out.push_str(&xml_escape(label, false));
        out.push_str("</title>");
        out.push_str(&rest[1..]);
        out
    }

    /// 0.1.4's `dress_svg_with_text`, unchanged, as the oracle.
    fn reference_dress_svg_with_text(svg: &str, label: &str, layers: &Layers) -> String {
        let runs = &layers.runs;
        let Some(head_end) = svg.find('>') else {
            return svg.to_owned();
        };
        let (head, rest) = svg.split_at(head_end);
        let body = &rest[1..];
        let Some(cut) = body.rfind("</svg>") else {
            return reference_dress_svg(svg, label);
        };
        let head = strip_fixed_size(head);

        let mut out = String::with_capacity(svg.len() + 128 + runs.len() * 96);
        out.push_str(&head);
        out.push_str(" width=\"100%\" role=\"document\" aria-label=\"");
        out.push_str(&xml_escape(label, true));
        out.push_str("\">");
        out.push_str("<title>");
        out.push_str(&xml_escape(label, false));
        out.push_str("</title>");
        out.push_str(TEXT_LAYER_STYLE);
        out.push_str("<g aria-hidden=\"true\">");
        out.push_str(&body[..cut]);
        out.push_str("</g>");
        out.push_str("<g class=\"text-layer\">");
        for run in runs {
            push_run(&mut out, run);
        }
        out.push_str("</g>");
        if !layers.links.is_empty() {
            out.push_str("<g class=\"link-layer\" aria-hidden=\"true\">");
            for link in &layers.links {
                push_link(&mut out, link);
            }
            out.push_str("</g>");
        }
        out.push_str(&body[cut..]);
        out
    }

    /// **The streaming merge is the merge it replaced.** `merge_svg_pages` took
    /// a `&[String]` and now consumes an iterator, so a caller can serialise and
    /// drop one page at a time instead of holding the whole book twice. The
    /// bytes it produces must not have moved: a merged `.svg` is what
    /// `export(.., Svg, ..)` answers with.
    #[test]
    fn the_streaming_merge_writes_what_the_slice_merge_wrote() {
        let pages = vec![
            "<svg><path d=\"M0 0\"/></svg>".to_string(),
            "<svg><path d=\"M1 1\"/></svg>".to_string(),
        ];
        let mut want = String::from(
            "<svg xmlns=\"http://www.w3.org/2000/svg\" data-nornir-doc=\"merged\">\n",
        );
        for (i, p) in pages.iter().enumerate() {
            want.push_str(&format!("<g data-page=\"{}\">\n", i + 1));
            want.push_str(p);
            want.push_str("\n</g>\n");
        }
        want.push_str("</svg>\n");

        assert_eq!(merge_svg_pages(pages.clone()), want);
        assert!(want.contains("data-page=\"2\""), "both pages are in it");
    }

    /// A run's string is the item's own, so a ligature copies as the characters
    /// that were written rather than as whatever the font fused them into. The
    /// `ffi` in "efficient" is one glyph in Libertinus; the copy is three chars.
    #[test]
    fn a_ligature_copies_as_the_characters_that_were_written() {
        let svg = typeset("An efficient office.\n");

        let joined = text_contents(&svg).concat();

        assert!(joined.contains("efficient"), "{joined}");
        assert!(joined.contains("office"), "{joined}");
    }

    /// A `<` or `&` in a README reaches the layer as text and not as markup.
    #[test]
    fn a_run_carrying_markup_characters_is_escaped() {
        let svg = typeset("Use `a < b && c > d` here.\n");

        assert!(svg.contains("&lt;"), "unescaped `<` in the layer");
        assert!(svg.contains("&amp;"), "unescaped `&` in the layer");
        let joined = text_contents(&svg).concat();
        assert!(
            joined.contains("&lt;") && joined.contains("&amp;"),
            "{joined}"
        );
    }

    #[test]
    fn a_coordinate_is_printed_without_f64_noise() {
        assert_eq!(pt(32.592000000000006), "32.592");
        assert_eq!(pt(841.8897637795276), "841.8898");
        assert_eq!(pt(20.0), "20");
        assert_eq!(pt(-0.00001), "0");
        assert_eq!(pt(-12.5), "-12.5");
    }

    #[test]
    fn the_default_grade_is_the_selectable_one() {
        assert_eq!(TextLayer::default(), TextLayer::On);
    }

    #[test]
    fn a_column_render_places_an_image_held_in_memory() {
        let mut images = ImageSet::new();
        assert!(images.insert("docs/logo.png", PNG_1PX.to_vec()));

        let svg = export_svg_column(
            "![logo](docs/logo.png)\n",
            &meta(),
            &images,
            400.0,
            TextLayer::On,
        )
        .expect("it renders");

        // typst inlines a raster image as a base64 data URL — no external
        // reference leaves the page.
        assert!(
            svg.contains("data:image/png;base64,"),
            "the image was not placed"
        );
        // Nothing in the page points OUT. The only `http` in it is the SVG and
        // xlink namespace declarations, which are names and not fetches.
        assert!(
            !svg.contains("xlink:href=\"http"),
            "an image referenced an outside host: {}",
            &svg[..300.min(svg.len())]
        );
    }

    /// The width is the caller's, in points, and it reaches the page.
    #[test]
    fn the_column_width_is_the_one_the_caller_asked_for() {
        let narrow = export_svg_column("hello\n", &meta(), &ImageSet::new(), 200.0, TextLayer::On)
            .expect("narrow");
        let wide = export_svg_column("hello\n", &meta(), &ImageSet::new(), 900.0, TextLayer::On)
            .expect("wide");

        assert!(
            narrow.contains("viewBox=\"0 0 200"),
            "{}",
            &narrow[..90.min(narrow.len())]
        );
        assert!(
            wide.contains("viewBox=\"0 0 900"),
            "{}",
            &wide[..90.min(wide.len())]
        );
    }

    /// **The HTML grade keeps the text as text**, which is the one thing the
    /// SVG column cannot do.
    #[test]
    fn the_html_render_keeps_the_heading_as_readable_text() {
        let html = export_html("# The Heading\n\nSome prose.\n", &meta(), &ImageSet::new())
            .expect("it renders");

        assert!(
            html.contains("The Heading"),
            "the heading text must be selectable: {html}"
        );
        assert!(html.contains("Some prose."), "{html}");
        assert!(!html.contains("<script"), "{html}");
    }

    /// The typesetter's refusal is an error the caller can render, not a panic
    /// and not a half-page.
    #[test]
    fn a_document_the_typesetter_refuses_is_an_error() {
        let refused = export_svg_column(
            "`` `#panic(\"no\")` ``\n",
            &meta(),
            &ImageSet::new(),
            720.0,
            TextLayer::On,
        );

        // A README cannot actually reach the typesetter as code any more, which
        // is the point of `typst_string` — so this asserts the shape of the
        // happy path instead: whatever the input, the result is a Result and
        // never a process exit.
        assert!(refused.is_ok(), "code in a README is drawn, not run");
    }

    #[test]
    fn dressing_an_svg_leaves_the_rest_of_it_alone() {
        let svg = "<svg viewBox=\"0 0 10 20\" width=\"10pt\" height=\"20pt\" \
                   xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>";

        let dressed = dress_svg(svg.to_owned(), "ns/n & \"README\"");

        assert!(dressed.contains("viewBox=\"0 0 10 20\""));
        assert!(dressed.contains("width=\"100%\""));
        assert!(!dressed.contains("height=\"20pt\""));
        assert!(dressed.contains("<path d=\"M0 0\"/>"));
        assert!(dressed.contains("aria-label=\"ns/n &amp; &quot;README&quot;\""));
        assert!(dressed.ends_with("</svg>"));
    }

    #[test]
    fn md_to_typst_basic() {
        let md = "# Title\n\nSome *bold* text and `code`.\n\n- one\n- two\n";
        let out = md_to_typst(md);
        // The heading MARKER is markup; its text is a string literal, which is
        // what makes `#`, `_` and friends inert inside it. See `typst_string`.
        assert!(out.contains("= #\"Title\""), "{out}");
        assert!(out.contains("`code`"));
        assert!(out.contains("- #\"one\""), "{out}");
    }

    /// LAW (inject-assert): real markdown → real per-page SVG. Assert we get ≥1
    /// page, each is a well-formed `<svg…</svg>` carrying the rendered text as
    /// vector paths (typst outlines glyphs), and the merged blob wraps them.
    #[test]
    fn svg_export_yields_valid_per_page_vector() {
        let md = "= Manual\n\nFirst page body with some words.\n\n#pagebreak()\n\nSecond page.\n";
        let meta = ExportMeta {
            title: "T".into(),
            version: "0".into(),
            generated: "2026-06-18".into(),
            cover_image: String::new(),
        };
        let pages = export_svg_pages(md, &meta, None, None).expect("svg pages render");
        assert!(!pages.is_empty(), "at least one page rendered");
        for (i, p) in pages.iter().enumerate() {
            assert!(p.contains("<svg"), "page {i} is an <svg>");
            assert!(p.contains("</svg>"), "page {i} is closed");
            assert!(
                p.contains("<path"),
                "page {i} carries vector paths (outlined glyphs)"
            );
        }
        // The single-blob form wraps every page in a <g data-page="N">.
        let merged =
            String::from_utf8(export(md, &meta, DocFormat::Svg, None, None).expect("merged svg"))
                .unwrap();
        assert!(
            merged.contains("data-page=\"1\""),
            "merged blob tags page 1"
        );
        assert_eq!(DocFormat::parse("svg").unwrap().extension(), "svg");
    }

    #[test]
    fn md_to_typst_table() {
        let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
        let out = md_to_typst(md);
        assert!(out.contains("#table("));
        assert!(out.contains("columns: 2"));
    }

    #[test]
    fn md_to_typst_link() {
        let md = "see [docs](https://example.com)";
        let out = md_to_typst(md);
        assert!(out.contains("#link(\"https://example.com\")"));
    }

    #[test]
    fn md_to_typst_code_fence() {
        let md = "```rust\nfn main() {}\n```\n";
        let out = md_to_typst(md);
        assert!(out.contains("```rust"));
        assert!(out.contains("fn main() {}"));
    }

    #[test]
    fn format_parse() {
        assert_eq!(DocFormat::parse("pdf").unwrap(), DocFormat::Pdf);
        assert_eq!(DocFormat::parse("HTML").unwrap(), DocFormat::Html);
        assert_eq!(DocFormat::parse("md").unwrap(), DocFormat::Md);
        assert!(DocFormat::parse("xml").is_err());
    }

    /// **A remote image nothing could fetch is its ALT TEXT**, not a
    /// placeholder of ours.
    ///
    /// It used to be `_[remote image omitted]_`, which told the reader about
    /// this crate's plumbing instead of about the picture. The alt text is what
    /// the author wrote for exactly this case, and it is what every other
    /// unresolvable image now falls back to as well — one rule instead of two.
    #[test]
    fn md_to_typst_remote_image_becomes_its_alt_text_without_a_cache() {
        let out = md_to_typst("![alt](https://example.com/x.png)\n");

        assert!(
            out.contains("#\"alt\""),
            "expected the alt text, got: {out}"
        );
        assert!(!out.contains("#image("), "nothing was placed: {out}");
        assert!(!out.contains("remote image omitted"), "{out}");
    }

    /// **A local image with no root mounted and no bytes offered does not take
    /// the document down.**
    ///
    /// Found from the other side: gunnar-ui rendered this repository's own
    /// README with an `ImageSet` that could not reach `crates/…/gunnar.webp`,
    /// and the whole compile failed with "file not found" over one badge. With
    /// no filesystem to find it in, the path was never going to resolve, so
    /// emitting it was a guaranteed failure rather than a diagnostic.
    #[test]
    fn a_local_image_with_nowhere_to_resolve_becomes_its_alt_text() {
        let mut resolver = ImageResolver::with_set(None, false, ImageSet::new());

        let out = md_to_typst_with(
            "![a diagram](docs/missing.png)\n",
            &mut resolver,
            Target::Paged,
        );

        assert!(out.contains("#\"a diagram\""), "{out}");
        assert!(!out.contains("#image("), "{out}");
        // …and the whole document still typesets, which is the actual claim.
        let svg = export_svg_column(
            "# T\n\n![a diagram](docs/missing.png)\n",
            &meta(),
            &ImageSet::new(),
            400.0,
            TextLayer::On,
        )
        .expect("a missing image must not fail the render");
        assert!(svg.starts_with("<svg "));
    }

    /// A build step DOES want the failure: with a root mounted, a missing asset
    /// is a broken repository and the render says so rather than quietly
    /// dropping a figure from a manual.
    #[test]
    fn a_local_image_with_a_root_mounted_still_emits_the_path() {
        let mut resolver = ImageResolver::with_set(None, true, ImageSet::new());

        let out = md_to_typst_with("![a](.nornir/assets/x.svg)\n", &mut resolver, Target::Paged);

        assert!(out.contains("#image(\"/.nornir/assets/x.svg\")"), "{out}");
    }

    #[test]
    fn md_to_typst_local_image_root_anchored() {
        // Local image links are normalised to project-root paths so the
        // mounted filesystem root resolves them.
        let out = md_to_typst("![alt](.nornir/assets/logo.png)\n");
        assert!(
            out.contains("#image(\"/.nornir/assets/logo.png\")"),
            "got: {out}"
        );
        let out2 = md_to_typst("![alt](./assets/logo.png)\n");
        assert!(out2.contains("#image(\"/assets/logo.png\")"), "got: {out2}");
    }

    #[test]
    fn html_img_block_becomes_centered_image() {
        let md = "<p align=\"center\"><img src=\".nornir/assets/logo.webp\" width=\"720\" /></p>\n";
        let out = md_to_typst(md);
        assert!(
            out.contains("#align(center, image(\"/.nornir/assets/logo.webp\""),
            "got: {out}"
        );
    }

    #[test]
    fn a_length_title_sizes_the_image_and_an_opening_icon_sits_beside_its_text() {
        assert_eq!(image_width("40pt").as_deref(), Some("40pt"));
        assert_eq!(image_width(" 25% ").as_deref(), Some("25%"));
        assert_eq!(image_width("The logo"), None);
        assert_eq!(image_width("-3pt"), None);
        let md = "![h](/a.png \"40pt\") **holger** does things.\n\nPlain ![x](/b.png \"20pt\") inline.\n";
        let t = md_to_typst(md);
        assert!(t.contains("#grid(columns: (auto, 1fr), column-gutter: 12pt, image(\"/a.png\", width: 40pt), [#strong[#\"holger\"]#\" does things.\"])"), "{t}");
        assert!(t.contains("#image(\"/b.png\", width: 20pt)#\" inline.\""), "{t}");
        // An untitled image is exactly what it was.
        assert!(md_to_typst("![x](/c.png)\n").contains("#image(\"/c.png\")"));
    }

    #[test]
    fn extract_img_src_handles_quotes() {
        assert_eq!(
            extract_img_src("<img src=\"a/b.png\">").as_deref(),
            Some("a/b.png")
        );
        assert_eq!(
            extract_img_src("<img class=x src='c.svg' />").as_deref(),
            Some("c.svg")
        );
        assert_eq!(extract_img_src("<p>no image</p>"), None);
    }

    #[test]
    fn md_passthrough() {
        let md = "hello";
        let meta = ExportMeta {
            title: "t".into(),
            version: "0".into(),
            generated: "g".into(),
            cover_image: String::new(),
        };
        let out = export(md, &meta, DocFormat::Md, None, None).unwrap();
        assert_eq!(out, b"hello");
    }

    /// **The comment-slash class of bug, closed rather than escaped.**
    ///
    /// A plain-text URL carries `//`, which typst reads as a line comment: it
    /// swallowed the following close delimiter and yielded "unclosed
    /// delimiter". Regression: znippy-inbox.md's `**https://…**` sank the whole
    /// doc-book. The translator used to escape the comment-starting slash, and
    /// then had to learn about `/` at a run BOUNDARY too, because
    /// `**a**/**b**` forms `*/` across two runs.
    ///
    /// None of that exists now. The URL is inside a typst string literal, where
    /// `/` is one character of text and cannot start anything — so the
    /// assertion is that the slash is UNTOUCHED and the quoting is what carries
    /// it. `emphasised_url_compiles_to_pdf` is still the end-to-end guarantee.
    #[test]
    fn url_in_text_does_not_open_typst_comment() {
        let out = md_to_typst("**https://git.gunnar.rs/nordisk/znippy**\n");
        assert!(
            out.contains("#strong[#\"https://git.gunnar.rs/nordisk/znippy\"]"),
            "the URL must be a string literal, verbatim: {out}"
        );
        // A lone slash (the common path case) is likewise untouched.
        let path = md_to_typst("see a/b/c here\n");
        assert!(
            path.contains("a/b/c"),
            "lone slashes must stay as written: {path}"
        );
        // A `/*` adjacency cannot open a block comment. pulldown-cmark splits
        // `a /* b` into three runs — `a /`, `*`, ` b` — because `*` might have
        // been an emphasis marker, and each run is quoted on its own, so the
        // two characters never sit next to each other in markup at all.
        let blk = md_to_typst("a /* b\n");
        assert!(
            !blk.contains("/*"),
            "bare /* would open a block comment: {blk}"
        );
    }

    /// Scan typst `out` for a bare `/*`/`*/` block-comment marker that is NOT
    /// inside a raw block (``` fence) or inline raw span (backtick run). Such a
    /// marker reaches typst markup and breaks the compile ("unexpected end of
    /// block comment" / "unclosed delimiter").
    fn has_bare_block_comment(out: &str) -> bool {
        let mut in_fence = false;
        for line in out.lines() {
            if line.trim_start().starts_with("```") {
                in_fence = !in_fence;
                continue;
            }
            if in_fence {
                continue;
            }
            // Strip inline raw spans (backtick runs are verbatim/comment-safe).
            let mut rest = String::new();
            let mut it = line.chars().peekable();
            while let Some(c) = it.next() {
                if c == '`' {
                    let mut run = 1;
                    while it.peek() == Some(&'`') {
                        it.next();
                        run += 1;
                    }
                    let fence = "`".repeat(run);
                    // consume until the matching fence
                    let tail: String = it.clone().collect();
                    if let Some(end) = tail.find(&fence) {
                        for _ in 0..end + run {
                            it.next();
                        }
                    } else {
                        break;
                    }
                    continue;
                }
                // A backslash escapes the next char (typst): `\/` is a literal
                // slash, not a comment opener — drop the pair so it can't form a
                // spurious `/*`/`*/` with a neighbour.
                if c == '\\' {
                    it.next();
                    continue;
                }
                rest.push(c);
            }
            if rest.contains("/*") || rest.contains("*/") {
                return true;
            }
        }
        false
    }

    #[test]
    fn emphasis_slash_adjacency_does_not_open_block_comment() {
        // Regression: facett-demo.md `**card**/**form**` parses as strong-"card",
        // text-"/", strong-"form" → `*card*/*form*` in typst, where the `/`
        // abuts emphasis `*` markers and forms `*/`+`/*` → block-comment chaos
        // ("unexpected end of block comment"). The book render of facett & korp
        // both sank on this. A run-boundary `/` must be escaped.
        let md = "an **card**/**form** for one vessel\n";
        let out = md_to_typst(md);
        assert!(
            !has_bare_block_comment(&out),
            "emphasis/slash adjacency leaked a bare block comment: {out}"
        );
        let meta = ExportMeta {
            title: "t".into(),
            version: "0".into(),
            generated: "g".into(),
            cover_image: String::new(),
        };
        let pdf = export(&format!("# h\n\n{md}"), &meta, DocFormat::Pdf, None, None)
            .expect("emphasis/slash adjacency must compile to pdf");
        assert!(!pdf.is_empty());
    }

    #[test]
    fn rust_fence_block_comment_stays_verbatim() {
        // facett components.md / adding-a-component.md embed balanced
        // `/* draw */` comments inside ```rust fences. The raw block keeps them
        // verbatim (comment-safe), the doc compiles, and the text survives.
        let md = "```rust\nfn ui() { /* draw */ }\n```\n";
        let out = md_to_typst(md);
        assert!(!has_bare_block_comment(&out), "fence comment leaked: {out}");
        // The raw body keeps the literal comment for the rendered code.
        assert!(out.contains("/* draw */"), "code comment lost: {out}");
        let meta = ExportMeta {
            title: "t".into(),
            version: "0".into(),
            generated: "g".into(),
            cover_image: String::new(),
        };
        let pdf = export(&format!("# h\n\n{md}"), &meta, DocFormat::Pdf, None, None)
            .expect("rust fence with /* */ must compile");
        assert!(!pdf.is_empty());
    }

    #[test]
    fn id_comment_in_fence_compiles() {
        // facett-icebergview.md: unbalanced-looking `/*id*/100` inside a fence.
        let md = "```rust\nSnapshot::new(SnapshotMeta::new(/*id*/100, /*ts*/1), b);\n```\n";
        let out = md_to_typst(md);
        assert!(
            !has_bare_block_comment(&out),
            "fence id-comment leaked: {out}"
        );
        assert!(out.contains("/*id*/100"), "code comment lost: {out}");
    }

    #[test]
    fn glob_path_in_inline_code_and_prose_compiles() {
        // korp design.md / korp-for-dummies.md: glob path deps `../knut/crates/*`
        // both as inline code and in prose. Inline code is verbatim raw (safe);
        // prose `crates/*` has `/` before `*` (already escaped). Neither may open
        // a typst comment.
        let inline = "deps in `Cargo.toml` (`../knut/crates/*`, `../facett/*`):\n";
        let prose = "korp pulls them in (../knut/crates/*, ../facett/*) — see graph.\n";
        for md in [inline, prose] {
            let out = md_to_typst(md);
            assert!(
                !has_bare_block_comment(&out),
                "glob path leaked a block comment in {md:?}: {out}"
            );
            let meta = ExportMeta {
                title: "t".into(),
                version: "0".into(),
                generated: "g".into(),
                cover_image: String::new(),
            };
            let pdf = export(&format!("# h\n\n{md}"), &meta, DocFormat::Pdf, None, None)
                .unwrap_or_else(|e| panic!("glob path {md:?} must compile: {e}"));
            assert!(!pdf.is_empty());
        }
    }

    /// Regression: a bold run welded to a suffix — `**SIGKILL**s`, ordinary
    /// English — closed in markdown and did not close in typst, because typst's
    /// `*…*` shorthand only ends at a word boundary. One such word in
    /// `gunnar/.nornir/stinky.md` (line 1666 of 4244) took down the whole
    /// 297 KB render with "unclosed delimiter" and named no line. Found
    /// 2026-08-04 by bisecting prefixes of the file.
    ///
    /// Red first: with `Tag::Strong` back on the `*` shorthand this compile
    /// fails with exactly that message, so the guard is not sitting on a green
    /// it never saw fail.
    #[test]
    fn emphasis_welded_to_a_suffix_still_closes() {
        let out = md_to_typst("child, **SIGKILL**s it mid-repack.\n");
        assert!(
            out.contains("#strong[#\"SIGKILL\"]"),
            "strong not in function form: {out}"
        );
        let it = md_to_typst("the *pack*file\n");
        assert!(
            it.contains("#emph[#\"pack\"]"),
            "emph not in function form: {it}"
        );

        let meta = ExportMeta {
            title: "t".into(),
            version: "0".into(),
            generated: "g".into(),
            cover_image: String::new(),
        };
        // Every byte of this input is load-bearing, and finding that out is the
        // reason the assertion is trusted. Two near-identical variants compile
        // fine even on the broken emitter — one without the leading HTML
        // banner, and one with a second bold run (`and **re**opens`) appended —
        // so either would have been green in both directions and proved
        // nothing. This exact string is the one that fails, and every document
        // `nornir docs render` produces carries that banner, so it is the real
        // input shape rather than a contrivance.
        let md = "<!-- \u{26a0} GENERATED by nornir from .nornir/README.md \u{2014} DO NOT EDIT this file -->\n\n# T\n\nchild, **SIGKILL**s it mid-repack.\n";
        let pdf = export(md, &meta, DocFormat::Pdf, None, None)
            .expect("a bold run with a suffix must still compile");
        assert!(pdf.starts_with(b"%PDF-"), "not a PDF");
    }

    #[test]
    fn emphasised_url_compiles_to_pdf() {
        // Full pipeline: the offending construct must actually compile under
        // typst now (previously: TypstSource "unclosed delimiter").
        let md = "# moved\n\n**https://git.gunnar.rs/nordisk/znippy**\n";
        let meta = ExportMeta {
            title: "t".into(),
            version: "0".into(),
            generated: "g".into(),
            cover_image: String::new(),
        };
        let out = export(md, &meta, DocFormat::Pdf, None, None).expect("pdf compile");
        assert!(!out.is_empty());
    }

    #[test]
    fn html_render_smoke() {
        // HTML is the low-priority, experimental typst target. Assert only
        // that it produces non-empty HTML carrying the body text — the exact
        // tag structure is typst's to decide and may change between versions.
        let md = "# H\n\ntext\n";
        let meta = ExportMeta {
            title: "T".into(),
            version: "1".into(),
            generated: "g".into(),
            cover_image: String::new(),
        };
        let out = export(md, &meta, DocFormat::Html, None, None).unwrap();
        let s = String::from_utf8(out).unwrap();
        assert!(s.contains('H'), "expected heading text in html, got: {s}");
        assert!(
            s.to_ascii_lowercase().contains("<html"),
            "expected html doc, got: {s}"
        );
    }
}