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('&', "&")
.replace('<', "<")
.replace('>', ">");
if attribute {
out = out.replace('"', """);
}
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 (``): 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, ..
}) => {
// ``: 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("\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\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(
"\n\n\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&b="2"\""),
"{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("<"), "unescaped `<` in the layer");
assert!(svg.contains("&"), "unescaped `&` in the layer");
let joined = text_contents(&svg).concat();
assert!(
joined.contains("<") && joined.contains("&"),
"{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(
"\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 & "README"\""));
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("\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(
"\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\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("\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("\n");
assert!(
out.contains("#image(\"/.nornir/assets/logo.png\")"),
"got: {out}"
);
let out2 = md_to_typst("\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 = " **holger** does things.\n\nPlain  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("\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}"
);
}
}