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
//! Low-level SVG element builders.
//!
//! Everything here writes into a `String` buffer. The renderer composes
//! these and returns the finished document at the end. No data-space or
//! scale concept lives in this module — only pixel coordinates.

use std::fmt::Write;

pub fn num_into(buf: &mut String, v: f64) {
    if v.is_finite() {
        let rounded = (v * 1000.0).round() / 1000.0;
        if rounded == rounded.trunc() {
            let _ = write!(buf, "{}", rounded as i64);
        } else {
            let s = format!("{:.3}", rounded);
            let trimmed = s.trim_end_matches('0').trim_end_matches('.');
            buf.push_str(trimmed);
        }
    } else {
        buf.push('0');
    }
}

pub fn num(v: f64) -> String {
    let mut s = String::new();
    num_into(&mut s, v);
    s
}

pub fn escape_into(buf: &mut String, s: &str) {
    for c in s.chars() {
        match c {
            '&' => buf.push_str("&"),
            '<' => buf.push_str("&lt;"),
            '>' => buf.push_str("&gt;"),
            '"' => buf.push_str("&quot;"),
            '\'' => buf.push_str("&apos;"),
            other => buf.push(other),
        }
    }
}

pub fn escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    escape_into(&mut out, s);
    out
}

pub fn document_open(buf: &mut String, width: f64, height: f64) {
    buf.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
    buf.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ");
    num_into(buf, width);
    buf.push(' ');
    num_into(buf, height);
    buf.push_str("\" width=\"");
    num_into(buf, width);
    buf.push_str("\" height=\"");
    num_into(buf, height);
    buf.push_str("\" shape-rendering=\"geometricPrecision\" font-family=\"Times, 'Liberation Serif', serif\">");
}

pub fn document_close(buf: &mut String) {
    buf.push_str("</svg>");
}

/// Append `<rect x=.. y=.. width=.. height=.. {extra}/>`. `extra` is raw
/// attribute text already including a leading space, e.g. ` fill="black"`.
pub fn rect(buf: &mut String, x: f64, y: f64, w: f64, h: f64, extra: &str) {
    buf.push_str("<rect x=\"");
    num_into(buf, x);
    buf.push_str("\" y=\"");
    num_into(buf, y);
    buf.push_str("\" width=\"");
    num_into(buf, w);
    buf.push_str("\" height=\"");
    num_into(buf, h);
    buf.push('"');
    buf.push_str(extra);
    buf.push_str("/>");
}

pub fn line(buf: &mut String, x1: f64, y1: f64, x2: f64, y2: f64, extra: &str) {
    buf.push_str("<line x1=\"");
    num_into(buf, x1);
    buf.push_str("\" y1=\"");
    num_into(buf, y1);
    buf.push_str("\" x2=\"");
    num_into(buf, x2);
    buf.push_str("\" y2=\"");
    num_into(buf, y2);
    buf.push('"');
    buf.push_str(extra);
    buf.push_str("/>");
}

pub fn circle(buf: &mut String, cx: f64, cy: f64, r: f64, extra: &str) {
    buf.push_str("<circle cx=\"");
    num_into(buf, cx);
    buf.push_str("\" cy=\"");
    num_into(buf, cy);
    buf.push_str("\" r=\"");
    num_into(buf, r);
    buf.push('"');
    buf.push_str(extra);
    buf.push_str("/>");
}

pub fn polyline(buf: &mut String, points: &[(f64, f64)], extra: &str) {
    buf.push_str("<polyline points=\"");
    write_points(buf, points);
    buf.push_str("\" fill=\"none\"");
    buf.push_str(extra);
    buf.push_str("/>");
}

pub fn polygon(buf: &mut String, points: &[(f64, f64)], extra: &str) {
    buf.push_str("<polygon points=\"");
    write_points(buf, points);
    buf.push('"');
    buf.push_str(extra);
    buf.push_str("/>");
}

fn write_points(buf: &mut String, points: &[(f64, f64)]) {
    for (i, (x, y)) in points.iter().enumerate() {
        if i > 0 {
            buf.push(' ');
        }
        num_into(buf, *x);
        buf.push(',');
        num_into(buf, *y);
    }
}

/// Text element. `content` is written literally — pre-escape it if it
/// contains user input.
pub fn text(buf: &mut String, x: f64, y: f64, content: &str, extra: &str) {
    buf.push_str("<text x=\"");
    num_into(buf, x);
    buf.push_str("\" y=\"");
    num_into(buf, y);
    buf.push('"');
    buf.push_str(extra);
    buf.push('>');
    buf.push_str(content);
    buf.push_str("</text>");
}

pub fn group_open(buf: &mut String, extra: &str) {
    buf.push_str("<g");
    buf.push_str(extra);
    buf.push('>');
}

pub fn group_close(buf: &mut String) {
    buf.push_str("</g>");
}

/// Build an attribute fragment of the form ` k1="v1" k2="v2"`. Pairs with
/// `None` values are skipped. Useful for the assorted style overrides
/// the renderer assembles per element.
pub struct Attrs {
    pub buf: String,
}

impl Attrs {
    pub fn new() -> Self {
        Self { buf: String::new() }
    }

    pub fn str(mut self, k: &str, v: &str) -> Self {
        self.buf.push(' ');
        self.buf.push_str(k);
        self.buf.push_str("=\"");
        escape_into(&mut self.buf, v);
        self.buf.push('"');
        self
    }

    pub fn num(mut self, k: &str, v: f64) -> Self {
        self.buf.push(' ');
        self.buf.push_str(k);
        self.buf.push_str("=\"");
        num_into(&mut self.buf, v);
        self.buf.push('"');
        self
    }

    pub fn opt_str(self, k: &str, v: Option<&str>) -> Self {
        match v {
            Some(s) => self.str(k, s),
            None => self,
        }
    }

    pub fn into_string(self) -> String {
        self.buf
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn integer_floats_render_without_decimals() {
        assert_eq!(num(3.0), "3");
        assert_eq!(num(0.0), "0");
        assert_eq!(num(-7.0), "-7");
    }

    #[test]
    fn fractional_values_trim_trailing_zeros() {
        assert_eq!(num(1.5), "1.5");
        assert_eq!(num(1.250), "1.25");
        assert_eq!(num(1.234), "1.234");
    }

    #[test]
    fn escape_handles_all_xml_specials() {
        assert_eq!(escape("a&b<c>d\"e'f"), "a&amp;b&lt;c&gt;d&quot;e&apos;f");
    }
}