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
//! Native-webview viewer for [`Figure`].
//!
//! Available when the `gui` cargo feature is enabled. Wraps the
//! figure's existing SVG output in a tiny HTML page and shows it in an
//! OS-native webview window — WKWebView on macOS, WebView2 on Windows,
//! WebKitGTK on Linux. Browsers render SVG natively, so all hatch
//! patterns, marker shapes, dashed strokes, and embedded fonts come
//! through pixel-perfect.
//!
//! ## What you get
//!
//! - A resizable window with the figure displayed at native size,
//!   scaled to fit when smaller than the figure.
//! - **Save SVG** button — writes the original SVG bytes via the
//!   browser's download mechanism (same content as
//!   [`Figure::to_svg`](crate::Figure::to_svg)).
//! - **Zoom +/–/Reset** buttons.
//! - Native window close to exit.
//!
//! ## Linux system requirements
//!
//! On Linux you must have WebKitGTK installed. On Debian/Ubuntu:
//!
//! ```bash
//! sudo apt install libwebkit2gtk-4.1-dev
//! ```
//!
//! macOS and Windows require no extra packages — they use the system
//! webview that ships with the OS.
//!
//! ## Example
//!
//! ```no_run
//! use bland::{Figure, PaperSize};
//!
//! let xs: Vec<f64> = (0..=100).map(|i| i as f64 / 10.0).collect();
//! let ys: Vec<f64> = xs.iter().map(|t| t.sin()).collect();
//!
//! Figure::new()
//!     .size(PaperSize::A5Landscape)
//!     .title("sin(t)")
//!     .line(&xs, &ys, |s| s)
//!     .show();  // blocks until the user closes the window
//! ```

use tao::dpi::LogicalSize;
use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoop};
use tao::window::WindowBuilder;
use wry::WebViewBuilder;

use crate::figure::Figure;

/// Configuration for [`show_with_options`].
#[derive(Debug, Clone)]
pub struct ShowOptions {
    /// Window title bar text. Defaults to `"BLAND — <figure title>"`.
    pub window_title: Option<String>,
    /// Default file name suggested when the user clicks "Save SVG".
    pub default_filename: String,
    /// Background color shown around the figure (CSS color string).
    pub chrome_color: String,
    /// Cap the initial window size to this many pixels wide / tall.
    /// Useful when the figure is larger than typical screens.
    pub max_initial_dim: f64,
}

impl Default for ShowOptions {
    fn default() -> Self {
        Self {
            window_title: None,
            default_filename: "figure.svg".to_string(),
            chrome_color: "#f5f5f5".to_string(),
            max_initial_dim: 1280.0,
        }
    }
}

impl ShowOptions {
    pub fn window_title(mut self, t: impl Into<String>) -> Self {
        self.window_title = Some(t.into());
        self
    }
    pub fn default_filename(mut self, t: impl Into<String>) -> Self {
        self.default_filename = t.into();
        self
    }
    pub fn chrome_color(mut self, t: impl Into<String>) -> Self {
        self.chrome_color = t.into();
        self
    }
    pub fn max_initial_dim(mut self, d: f64) -> Self {
        self.max_initial_dim = d;
        self
    }
}

/// Open a blocking window that displays `fig` and returns when the
/// window is closed. Equivalent to matplotlib's `plt.show()`.
pub fn show(fig: &Figure) {
    show_with_options(fig, ShowOptions::default());
}

/// [`show`] with explicit options.
pub fn show_with_options(fig: &Figure, opts: ShowOptions) {
    let svg = fig.to_svg();
    let window_title = opts
        .window_title
        .clone()
        .or_else(|| fig.title.clone().map(|t| format!("BLAND — {t}")))
        .unwrap_or_else(|| "BLAND".to_string());

    // Initial window size: fit the figure but cap so we don't open at
    // 1344×1056 on a small screen.
    let aspect = fig.height / fig.width;
    let mut w = fig.width.min(opts.max_initial_dim);
    let mut h = w * aspect + 48.0; // 48px toolbar
    let max_h = opts.max_initial_dim * 0.95;
    if h > max_h {
        h = max_h;
        w = (h - 48.0) / aspect;
    }

    let event_loop = EventLoop::new();
    let window = WindowBuilder::new()
        .with_title(&window_title)
        .with_inner_size(LogicalSize::new(w, h))
        .build(&event_loop)
        .expect("create tao window");

    let html = build_html(&svg, &opts);

    #[cfg(any(target_os = "windows", target_os = "macos"))]
    let _webview = WebViewBuilder::new()
        .with_html(html)
        .build(&window)
        .expect("create wry webview");

    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
    let _webview = {
        use tao::platform::unix::WindowExtUnix;
        use wry::WebViewBuilderExtUnix;
        let vbox = window.default_vbox().expect("default vbox");
        WebViewBuilder::new()
            .with_html(html)
            .build_gtk(vbox)
            .expect("create wry webview (gtk)")
    };

    event_loop.run(move |event, _, control_flow| {
        *control_flow = ControlFlow::Wait;
        if let Event::WindowEvent {
            event: WindowEvent::CloseRequested,
            ..
        } = event
        {
            *control_flow = ControlFlow::Exit;
        }
    });
}

fn build_html(svg: &str, opts: &ShowOptions) -> String {
    // Strip the XML prolog if present — browsers tolerate it, but it
    // causes lint warnings in DOM tools and can confuse some webkits.
    // The save button re-emits a fresh prolog.
    let svg_body = svg
        .strip_prefix("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
        .unwrap_or(svg);

    // SVG content goes inline into the body so the browser parses it
    // as part of the DOM. We also keep a JS-string copy so the Save
    // button writes the bytes verbatim — surviving any DOM mutation
    // the user might have triggered via zoom transforms.
    let svg_js = encode_js_string(svg);
    let chrome = html_escape(&opts.chrome_color);
    let filename = encode_js_string(&opts.default_filename);

    format!(
        r#"<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>BLAND</title>
  <style>
    html, body {{ margin: 0; padding: 0; height: 100%; }}
    body {{
      background: {chrome};
      display: flex;
      flex-direction: column;
      font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
    }}
    .toolbar {{
      flex: 0 0 auto;
      display: flex;
      gap: 8px;
      padding: 8px 12px;
      background: white;
      border-bottom: 1px solid #d8d8d8;
      align-items: center;
    }}
    .toolbar button {{
      padding: 4px 12px;
      font: inherit;
      font-size: 13px;
      background: #f8f8f8;
      border: 1px solid #c5c5c5;
      border-radius: 3px;
      cursor: pointer;
    }}
    .toolbar button:hover {{ background: #ececec; }}
    .toolbar .sep {{ flex: 1; }}
    .toolbar .meta {{ color: #666; font-size: 12px; }}
    .viewer {{
      flex: 1 1 auto;
      overflow: auto;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 16px;
    }}
    .viewer-inner {{
      transform-origin: center center;
      transition: transform 0.05s ease-out;
    }}
    .viewer-inner svg {{
      display: block;
      max-width: 100%;
      max-height: 100%;
      box-shadow: 0 1px 4px rgba(0,0,0,0.08);
      background: white;
    }}
  </style>
</head>
<body>
  <div class="toolbar">
    <button id="save">Save SVG</button>
    <button id="zoom-in">+</button>
    <button id="zoom-out">−</button>
    <button id="zoom-reset">Reset</button>
    <span class="sep"></span>
    <span class="meta" id="zoom-meta">100%</span>
  </div>
  <div class="viewer">
    <div class="viewer-inner" id="viewer-inner">
      {svg_body}
    </div>
  </div>
  <script>
    const SVG_TEXT = {svg_js};
    const DEFAULT_FILENAME = {filename};

    let zoom = 1.0;
    const inner = document.getElementById('viewer-inner');
    const meta = document.getElementById('zoom-meta');

    function applyZoom() {{
      inner.style.transform = `scale(${{zoom}})`;
      meta.textContent = Math.round(zoom * 100) + '%';
    }}

    document.getElementById('zoom-in').addEventListener('click', () => {{
      zoom *= 1.2;
      applyZoom();
    }});
    document.getElementById('zoom-out').addEventListener('click', () => {{
      zoom /= 1.2;
      applyZoom();
    }});
    document.getElementById('zoom-reset').addEventListener('click', () => {{
      zoom = 1.0;
      applyZoom();
    }});

    // Ctrl/Cmd + scroll = zoom
    document.querySelector('.viewer').addEventListener('wheel', (e) => {{
      if (e.ctrlKey || e.metaKey) {{
        e.preventDefault();
        zoom *= e.deltaY < 0 ? 1.1 : 1 / 1.1;
        applyZoom();
      }}
    }}, {{ passive: false }});

    document.getElementById('save').addEventListener('click', () => {{
      const blob = new Blob([SVG_TEXT], {{ type: 'image/svg+xml' }});
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = DEFAULT_FILENAME;
      document.body.appendChild(a);
      a.click();
      a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
    }});

    // Ctrl/Cmd+S also saves
    document.addEventListener('keydown', (e) => {{
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {{
        e.preventDefault();
        document.getElementById('save').click();
      }}
    }});
  </script>
</body>
</html>"#
    )
}

/// JSON-encode a string into a JavaScript-safe literal (with surrounding quotes).
fn encode_js_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 16);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\x08' => out.push_str("\\b"),
            '\x0c' => out.push_str("\\f"),
            // U+2028 and U+2029 are valid in JSON strings but break
            // JavaScript string literals; escape them.
            '\u{2028}' => out.push_str("\\u2028"),
            '\u{2029}' => out.push_str("\\u2029"),
            c if (c as u32) < 0x20 => {
                use std::fmt::Write;
                let _ = write!(out, "\\u{:04x}", c as u32);
            }
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

fn html_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            c => out.push(c),
        }
    }
    out
}

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

    #[test]
    fn js_string_encodes_specials() {
        assert_eq!(encode_js_string("hi"), "\"hi\"");
        assert_eq!(encode_js_string("a\"b"), "\"a\\\"b\"");
        assert_eq!(encode_js_string("a\nb"), "\"a\\nb\"");
        assert_eq!(encode_js_string("a\\b"), "\"a\\\\b\"");
    }

    #[test]
    fn html_html_escapes_amp_and_quotes() {
        assert_eq!(html_escape("a&b"), "a&amp;b");
        assert_eq!(html_escape("\"x\""), "&quot;x&quot;");
    }

    #[test]
    fn build_html_contains_the_svg() {
        let html = build_html(
            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg/>",
            &ShowOptions::default(),
        );
        assert!(html.contains("<svg/>"));
        // Prolog should be stripped from inline body but present in JS const.
        assert!(html.contains("Save SVG"));
        assert!(html.contains("\\u003c?xml") || html.contains("<?xml"));
    }
}