bland 0.2.0

Pure-Rust library for paper-ready, monochrome, hatch-patterned technical plots in the visual tradition of 1960s-80s engineering reports.
Documentation
//! Multi-panel composition.
//!
//! Compose a list of fully-built [`Figure`]s into one SVG. Each panel
//! renders independently — own ticks, labels, ornaments — and is placed
//! at the appropriate cell of a grid layout.

use crate::figure::Figure;
use crate::svg;

/// Grid layout configuration for [`multi_panel`].
#[derive(Debug, Clone)]
pub struct PanelGridOpts {
    pub columns: Option<usize>,
    pub rows: Option<usize>,
    pub cell_width: Option<f64>,
    pub cell_height: Option<f64>,
    pub gap: f64,
    pub padding: f64,
    pub title: Option<String>,
    pub background: &'static str,
}

impl Default for PanelGridOpts {
    fn default() -> Self {
        Self {
            columns: None,
            rows: None,
            cell_width: None,
            cell_height: None,
            gap: 16.0,
            padding: 20.0,
            title: None,
            background: "white",
        }
    }
}

impl PanelGridOpts {
    pub fn columns(mut self, n: usize) -> Self {
        self.columns = Some(n);
        self
    }
    pub fn rows(mut self, n: usize) -> Self {
        self.rows = Some(n);
        self
    }
    pub fn cell_size(mut self, w: f64, h: f64) -> Self {
        self.cell_width = Some(w);
        self.cell_height = Some(h);
        self
    }
    pub fn gap(mut self, g: f64) -> Self {
        self.gap = g;
        self
    }
    pub fn padding(mut self, p: f64) -> Self {
        self.padding = p;
        self
    }
    pub fn title(mut self, t: impl Into<String>) -> Self {
        self.title = Some(t.into());
        self
    }
}

/// Compose `figures` into a single SVG. Each panel is rendered at its
/// native viewBox and placed into a nested `<svg>`.
pub fn multi_panel(figures: &[Figure], opts: PanelGridOpts) -> String {
    if figures.is_empty() {
        return String::new();
    }
    let first = &figures[0];
    let cell_w = opts.cell_width.unwrap_or(first.width);
    let cell_h = opts.cell_height.unwrap_or(first.height);

    let n = figures.len();
    let cols = opts.columns.unwrap_or_else(|| match opts.rows {
        Some(r) if r > 0 => (n + r - 1) / r,
        _ => n,
    });
    let cols = cols.max(1);
    let rows = opts.rows.unwrap_or_else(|| (n + cols - 1) / cols);

    let title_h = if opts.title.is_some() { 36.0 } else { 0.0 };
    let total_w = opts.padding * 2.0 + cols as f64 * cell_w + (cols as f64 - 1.0) * opts.gap;
    let total_h =
        opts.padding * 2.0 + title_h + rows as f64 * cell_h + (rows as f64 - 1.0) * opts.gap;

    let mut buf = String::with_capacity(8 * 1024);
    svg::document_open(&mut buf, total_w, total_h);
    let bg = format!(" fill=\"{}\"", opts.background);
    svg::rect(&mut buf, 0.0, 0.0, total_w, total_h, &bg);

    if let Some(title) = &opts.title {
        let attrs = svg::Attrs::new()
            .num("font-size", 16.0)
            .str("font-family", "Times, 'Liberation Serif', serif")
            .str("text-anchor", "middle")
            .str("letter-spacing", "0.05em")
            .str("fill", "black")
            .into_string();
        svg::text(
            &mut buf,
            total_w / 2.0,
            opts.padding + 22.0,
            &svg::escape(title),
            &attrs,
        );
    }

    for (i, fig) in figures.iter().enumerate() {
        let row = i / cols;
        let col = i % cols;
        let x = opts.padding + col as f64 * (cell_w + opts.gap);
        let y = opts.padding + title_h + row as f64 * (cell_h + opts.gap);

        let inner = strip_outer_svg(&fig.to_svg());
        buf.push_str("<svg x=\"");
        svg::num_into(&mut buf, x);
        buf.push_str("\" y=\"");
        svg::num_into(&mut buf, y);
        buf.push_str("\" width=\"");
        svg::num_into(&mut buf, cell_w);
        buf.push_str("\" height=\"");
        svg::num_into(&mut buf, cell_h);
        buf.push_str("\" viewBox=\"0 0 ");
        svg::num_into(&mut buf, fig.width);
        buf.push(' ');
        svg::num_into(&mut buf, fig.height);
        buf.push_str("\" preserveAspectRatio=\"xMidYMid meet\">");
        buf.push_str(&inner);
        buf.push_str("</svg>");
    }

    svg::document_close(&mut buf);
    buf
}

fn strip_outer_svg(svg: &str) -> String {
    let mut s = svg;
    if let Some(idx) = s.find("?>") {
        s = &s[idx + 2..];
    }
    s = s.trim_start();
    if let Some(start) = s.find("<svg") {
        if let Some(end) = s[start..].find('>') {
            s = &s[start + end + 1..];
        }
    }
    let s = s.trim_end();
    let s = s.strip_suffix("</svg>").unwrap_or(s);
    s.to_string()
}