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

nordisk/edda

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 `![alt](path)`.
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");
    }
}