crates/nornir-docgen/src/svg.rs
//! Static SVG graph renderer powered by the typst engine.
//!
//! Why typst? We already ship typst-as-lib + typst-pdf for PDF docs;
//! typst-svg is the sibling crate that emits SVG from the same
//! `PagedDocument`. Reusing it means one rendering engine for every
//! visual artifact (depgraph, callgraph, lineage) and zero JavaScript
//! anywhere in the consumption path — markdown viewers, terminal
//! previews, PDF exports all see the same static picture.
//!
//! The layout is a simple layered DAG (BFS levels → columns) computed
//! in Rust; typst is only used as the geometry renderer. We deliberately
//! do *not* depend on CeTZ or other typst packages yet — keeps the
//! toolchain minimal. CeTZ can come later if we need fancier layouts.
//!
//! Cargo feature: `export` (nornir maps its `docs-export` onto it).
use std::collections::HashMap;
use anyhow::{Context, Result, anyhow};
use typst_as_lib::TypstEngine;
use typst_as_lib::typst_kit_options::TypstKitFontOptions;
use nornir_introspect::{Edge, EdgeKind, Graph};
/// Render `g` to a standalone SVG string using the typst engine.
///
/// Layout: BFS-layered (sources on the left, sinks on the right).
/// Cycles are broken in a stable, deterministic order so the output is
/// idempotent for a given input.
pub fn render_graph_svg(g: &Graph) -> Result<String> {
// ⚠ SK296: `g.nodes` is NOT the drawable set — `depgraph::extract` runs
// `cargo metadata --no-deps`, so an external dependency is an edge endpoint and
// never a node, and indexing edges through `g.nodes` alone silently DROPPED
// every dependency arrow. `Graph::drawable_nodes()` is the one place that
// decides what gets a box, shared with the inline `Graph::to_svg` renderer, and
// `introspect::svg_layers` is the one layout — this file used to carry a
// byte-identical private copy of it (LAW #5).
let nodes = g.drawable_nodes();
let layers = nornir_introspect::svg_layers(&nodes, &g.edges);
let typst_src = emit_typst(&nodes, &g.edges, &layers);
let engine = TypstEngine::builder()
.main_file(typst_src)
.search_fonts_with(
TypstKitFontOptions::default()
.include_system_fonts(false)
.include_embedded_fonts(true),
)
.build();
let result = engine.compile();
let doc = result
.output
.map_err(|err| anyhow!("typst compile failed for graph svg: {:?}", err))?;
let svg = typst_svg::svg_merged(&doc, &typst_svg::SvgOptions::default(), typst::layout::Abs::pt(8.0));
Ok(svg)
}
fn emit_typst(nodes: &[String], edges: &[Edge], layers: &[Vec<usize>]) -> String {
let col_w_pt: f64 = 160.0;
let row_h_pt: f64 = 36.0;
let box_w_pt: f64 = 130.0;
let box_h_pt: f64 = 22.0;
let margin_pt: f64 = 12.0;
let cols = layers.len().max(1);
let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
let page_w = margin_pt * 2.0 + col_w_pt * cols as f64;
let page_h = margin_pt * 2.0 + row_h_pt * rows as f64 + box_h_pt;
let mut pos: HashMap<usize, (f64, f64)> = HashMap::new();
for (ci, layer) in layers.iter().enumerate() {
let n = layer.len() as f64;
let total_h = n * row_h_pt;
let start_y = margin_pt + (page_h - margin_pt * 2.0 - total_h) / 2.0;
for (ri, &node_i) in layer.iter().enumerate() {
let x = margin_pt + ci as f64 * col_w_pt + 8.0;
let y = start_y + ri as f64 * row_h_pt;
pos.insert(node_i, (x, y));
}
}
let name_to_idx: HashMap<&str, usize> = nodes
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
let mut s = String::new();
s.push_str(&format!(
"#set page(width: {page_w:.1}pt, height: {page_h:.1}pt, margin: 0pt)\n"
));
s.push_str("#set text(font: (\"DejaVu Sans\", \"Liberation Sans\"), size: 9pt)\n\n");
for e in edges {
let (Some(&fi), Some(&ti)) = (
name_to_idx.get(e.from.as_str()),
name_to_idx.get(e.to.as_str()),
) else {
continue;
};
let Some(&(fx, fy)) = pos.get(&fi) else {
continue;
};
let Some(&(tx, ty)) = pos.get(&ti) else {
continue;
};
let x1 = fx + box_w_pt;
let y1 = fy + box_h_pt / 2.0;
let x2 = tx;
let y2 = ty + box_h_pt / 2.0;
let style = match e.kind {
EdgeKind::DependsOn => {
"(paint: rgb(120, 120, 130), thickness: 0.6pt, dash: \"dashed\")"
}
EdgeKind::Calls => "(paint: rgb(40, 80, 160), thickness: 0.7pt)",
EdgeKind::Reexports => "(paint: rgb(40, 130, 60), thickness: 0.8pt)",
EdgeKind::Implements => "(paint: rgb(160, 80, 40), thickness: 0.7pt)",
};
s.push_str(&format!(
"#place(top + left, dx: 0pt, dy: 0pt, \
line(start: ({x1:.1}pt, {y1:.1}pt), end: ({x2:.1}pt, {y2:.1}pt), \
stroke: {style}))\n"
));
}
for (i, name) in nodes.iter().enumerate() {
let Some(&(x, y)) = pos.get(&i) else { continue };
let label = escape_typst(name);
s.push_str(&format!(
"#place(top + left, dx: {x:.1}pt, dy: {y:.1}pt, \
box(width: {box_w_pt}pt, height: {box_h_pt}pt, \
stroke: 0.6pt + rgb(60,60,80), \
fill: rgb(245,247,252), \
inset: 4pt, \
radius: 3pt)[#align(center + horizon)[{label}]])\n"
));
}
s
}
fn escape_typst(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('#', "\\#")
.replace('$', "\\$")
.replace('@', "\\@")
.replace('*', "\\*")
.replace('_', "\\_")
}
/// Convenience: render `g` and write it to `out_path`, returning the
/// repo-relative path (or absolute path) that callers can embed in
/// markdown as ``.
pub fn render_to_file(g: &Graph, out_path: &std::path::Path) -> Result<()> {
let svg = render_graph_svg(g)?;
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
std::fs::write(out_path, svg).with_context(|| format!("write {}", out_path.display()))?;
Ok(())
}
// ---------------------------------------------------------------------------
// Bar chart — a horizontal comparison chart for the `bench_chart` doc section.
// Same engine (typst → SVG), same no-package discipline as the graph renderer:
// bars are plain rectangles, labels plain text. One metric, one bar per
// workload/system, the winner highlighted.
// ---------------------------------------------------------------------------
/// One bar: a workload/system, its value for the charted metric, and whether
/// it's the winner (best by the metric's direction).
pub struct Bar {
pub label: String,
pub value: f64,
pub best: bool,
}
/// A horizontal bar chart. `unit` is a short suffix shown after each value
/// (e.g. "ops/sec", "µs"); `log` scales bar widths logarithmically, which keeps
/// every bar visible when values span orders of magnitude (the common case for
/// embedded-vs-REST throughput).
pub struct BarChart {
pub title: String,
pub unit: String,
pub bars: Vec<Bar>,
pub log: bool,
}
/// Compact value label for a bar, in the chart series' uniform [`column_format`]
/// so the bar labels round exactly like the table cells (2.11930M next to 0.00393M).
fn fmt_bar_value(v: f64, unit: &str, scale: Option<(f64, &'static str, usize)>) -> String {
let num = crate::sections::fmt_metric_scaled(v, scale);
if unit.is_empty() {
num
} else {
format!("{num} {unit}")
}
}
/// Render `chart` to a standalone SVG string using the typst engine.
pub fn render_bars_svg(chart: &BarChart) -> Result<String> {
let typst_src = emit_bars_typst(chart);
let engine = TypstEngine::builder()
.main_file(typst_src)
.search_fonts_with(
TypstKitFontOptions::default()
.include_system_fonts(false)
.include_embedded_fonts(true),
)
.build();
let doc = engine
.compile()
.output
.map_err(|err| anyhow!("typst compile failed for bar chart: {:?}", err))?;
Ok(typst_svg::svg_merged(&doc, &typst_svg::SvgOptions::default(), typst::layout::Abs::pt(8.0)))
}
fn emit_bars_typst(chart: &BarChart) -> String {
let label_w_pt: f64 = 240.0;
let bar_max_pt: f64 = 360.0;
let value_w_pt: f64 = 130.0;
let row_h_pt: f64 = 28.0;
let bar_h_pt: f64 = 16.0;
let margin_pt: f64 = 14.0;
let title_h_pt: f64 = if chart.title.is_empty() { 0.0 } else { 30.0 };
let n = chart.bars.len().max(1);
let page_w = margin_pt * 2.0 + label_w_pt + bar_max_pt + value_w_pt;
let page_h = margin_pt * 2.0 + title_h_pt + row_h_pt * n as f64;
// Width scale. Guard degenerate inputs (all-equal, zero, negatives).
let max_v = chart.bars.iter().map(|b| b.value).fold(f64::MIN, f64::max);
// One uniform magnitude for every bar label, matching the table column.
let value_scale = crate::sections::column_format(
&chart.bars.iter().map(|b| b.value).collect::<Vec<_>>(),
);
let min_pos = chart
.bars
.iter()
.map(|b| b.value)
.filter(|v| *v > 0.0)
.fold(f64::MAX, f64::min);
let width_of = |v: f64| -> f64 {
if !(v > 0.0) || !(max_v > 0.0) {
return 0.0;
}
if chart.log && min_pos.is_finite() && max_v > min_pos {
let lo = min_pos.log10();
let hi = max_v.log10();
let frac = (v.log10() - lo) / (hi - lo);
// Floor at 12% so the smallest positive bar stays visible.
bar_max_pt * (0.12 + 0.88 * frac.clamp(0.0, 1.0))
} else {
bar_max_pt * (v / max_v).clamp(0.0, 1.0)
}
};
let mut s = String::new();
s.push_str(&format!(
"#set page(width: {page_w:.1}pt, height: {page_h:.1}pt, margin: 0pt)\n"
));
s.push_str("#set text(font: (\"DejaVu Sans\", \"Liberation Sans\"), size: 9pt)\n\n");
if !chart.title.is_empty() {
s.push_str(&format!(
"#place(top + left, dx: {margin_pt:.1}pt, dy: {margin_pt:.1}pt, \
text(size: 11pt, weight: \"bold\")[{}])\n",
escape_typst(&chart.title)
));
let unit_note = if chart.unit.is_empty() {
String::new()
} else {
format!(" ({})", chart.unit)
};
let scale_note = if chart.log { ", log scale" } else { "" };
if !unit_note.is_empty() || !scale_note.is_empty() {
s.push_str(&format!(
"#place(top + right, dx: -{margin_pt:.1}pt, dy: {:.1}pt, \
text(size: 8pt, fill: rgb(120,120,130))[{}{}])\n",
margin_pt + 2.0,
escape_typst(unit_note.trim_start()),
scale_note,
));
}
}
let bar_x = margin_pt + label_w_pt;
for (i, b) in chart.bars.iter().enumerate() {
let row_y = margin_pt + title_h_pt + i as f64 * row_h_pt;
let bar_y = row_y + (row_h_pt - bar_h_pt) / 2.0;
// Label (left, right-aligned against the bar).
s.push_str(&format!(
"#place(top + left, dx: {margin_pt:.1}pt, dy: {:.1}pt, \
box(width: {:.1}pt, height: {row_h_pt:.1}pt, inset: (right: 8pt))[#align(right + horizon)[{}]])\n",
row_y,
label_w_pt,
escape_typst(&b.label),
));
// Track + bar.
let w = width_of(b.value);
s.push_str(&format!(
"#place(top + left, dx: {bar_x:.1}pt, dy: {bar_y:.1}pt, \
rect(width: {bar_max_pt:.1}pt, height: {bar_h_pt:.1}pt, fill: rgb(228,239,247), radius: 2pt))\n"
));
// Cold/icy palette: winner = vivid glacier blue, rivals = cool steel.
let fill = if b.best {
"rgb(45,156,210)"
} else {
"rgb(150,177,201)"
};
if w > 0.5 {
s.push_str(&format!(
"#place(top + left, dx: {bar_x:.1}pt, dy: {bar_y:.1}pt, \
rect(width: {w:.1}pt, height: {bar_h_pt:.1}pt, fill: {fill}, radius: 2pt))\n"
));
}
// Value (right of the bar).
let weight = if b.best { "bold" } else { "regular" };
let vfill = if b.best {
"rgb(21,109,158)"
} else {
"rgb(90,104,118)"
};
s.push_str(&format!(
"#place(top + left, dx: {:.1}pt, dy: {:.1}pt, \
text(size: 8pt, weight: \"{weight}\", fill: {vfill})[{}])\n",
bar_x + w + 6.0,
row_y,
escape_typst(&fmt_bar_value(b.value, &chart.unit, value_scale)),
));
}
s
}
/// Render `chart` to `out_path` (creating parent dirs).
pub fn render_bars_to_file(chart: &BarChart, out_path: &std::path::Path) -> Result<()> {
let svg = render_bars_svg(chart)?;
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
std::fs::write(out_path, svg).with_context(|| format!("write {}", out_path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn g_simple() -> Graph {
Graph {
nodes: vec!["a".into(), "b".into(), "c".into()],
edges: vec![
Edge {
from: "a".into(),
to: "b".into(),
kind: EdgeKind::DependsOn,
},
Edge {
from: "b".into(),
to: "c".into(),
kind: EdgeKind::DependsOn,
},
],
}
}
#[test]
fn layered_topological() {
let g = g_simple();
let layers = nornir_introspect::svg_layers(&g.nodes, &g.edges);
assert_eq!(layers.len(), 3);
assert_eq!(layers[0], vec![0]);
assert_eq!(layers[1], vec![1]);
assert_eq!(layers[2], vec![2]);
}
#[test]
fn renders_non_empty_svg() {
let g = g_simple();
let svg = render_graph_svg(&g).expect("render");
assert!(
svg.starts_with("<svg"),
"expected svg root, got: {}",
&svg[..40.min(svg.len())]
);
assert!(svg.contains("</svg>"));
assert!(svg.len() > 200);
}
#[test]
fn cyclic_graph_does_not_hang() {
let g = Graph {
nodes: vec!["a".into(), "b".into()],
edges: vec![
Edge {
from: "a".into(),
to: "b".into(),
kind: EdgeKind::Calls,
},
Edge {
from: "b".into(),
to: "a".into(),
kind: EdgeKind::Calls,
},
],
};
let _ = render_graph_svg(&g).expect("cycle should not hang");
}
}