use std::panic::{catch_unwind, AssertUnwindSafe};
use sha2::{Digest, Sha256};
use crate::image::{rasterize_svg, LoadedImage, SvgError, SvgScaleMode, SvgSizing};
pub fn warm_fontdb() {
crate::image::svg::warm_fontdb();
let _expected = crate::terminal::ExpectedPanic::new();
let _ = catch_unwind(|| {
let _ = mermaid_rs_renderer::render("flowchart TD\nA-->B\n");
});
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum DiagramSource {
Mermaid(String),
}
#[derive(Debug, thiserror::Error)]
pub enum DiagramError {
#[error("mermaid render failed: {0}")]
RenderFailed(String),
#[error("svg parse failed: {0}")]
SvgParse(String),
#[error("raster failed: {0}")]
Raster(String),
#[error("png decode failed: {0}")]
Decode(String),
}
impl From<SvgError> for DiagramError {
fn from(err: SvgError) -> Self {
match err {
SvgError::Parse(m) => DiagramError::SvgParse(m),
SvgError::Raster(m) => DiagramError::Raster(m),
SvgError::Decode(m) => DiagramError::Decode(m),
}
}
}
const SYNTHETIC_URL_PREFIX: &str = "diagram-mermaid-";
pub fn synthetic_url(source: &DiagramSource) -> String {
match source {
DiagramSource::Mermaid(src) => {
let digest = Sha256::digest(src.as_bytes());
format!("{SYNTHETIC_URL_PREFIX}{digest:x}")
}
}
}
pub fn is_diagram_url(url: &str) -> bool {
url.starts_with(SYNTHETIC_URL_PREFIX)
}
const MAX_MERMAID_SOURCE_BYTES: usize = 64 * 1024;
pub fn render_mermaid_svg(source: &str) -> Result<String, DiagramError> {
if source.len() > MAX_MERMAID_SOURCE_BYTES {
return Err(DiagramError::RenderFailed(format!(
"mermaid source too large: {} bytes (max {MAX_MERMAID_SOURCE_BYTES})",
source.len()
)));
}
let outcome = {
let _expected = crate::terminal::ExpectedPanic::new();
catch_unwind(AssertUnwindSafe(|| mermaid_rs_renderer::render(source)))
}
.map_err(|payload| DiagramError::RenderFailed(format!("panic: {}", panic_message(&payload))))?;
outcome.map_err(|e| DiagramError::RenderFailed(format!("{e:#}")))
}
pub fn resolve_mermaid(
url: String,
source: &str,
max_cells: Option<(u16, u16)>,
font_size: Option<(u16, u16)>,
) -> Result<LoadedImage, DiagramError> {
let svg = render_mermaid_svg(source)?;
let image = rasterize_svg(
&svg,
SvgSizing {
envelope: max_cells,
font_size,
mode: SvgScaleMode::Fill,
},
Some([255, 255, 255, 255]),
)
.map_err(DiagramError::from)?;
Ok(LoadedImage {
url,
image,
scratch: None,
})
}
fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else {
"unknown payload".to_string()
}
}
#[cfg(test)]
mod tests {
use image::DynamicImage;
use super::*;
#[test]
fn resolve_mermaid_result_is_send() {
fn assert_send<T: Send>() {}
assert_send::<Result<LoadedImage, DiagramError>>();
}
#[test]
fn synthetic_url_is_stable_for_same_source() {
let a = synthetic_url(&DiagramSource::Mermaid("flowchart TD\nA-->B".into()));
let b = synthetic_url(&DiagramSource::Mermaid("flowchart TD\nA-->B".into()));
assert_eq!(a, b);
assert!(a.starts_with("diagram-mermaid-"));
assert_eq!(a.len(), "diagram-mermaid-".len() + 64);
}
#[test]
fn oversized_mermaid_source_is_rejected_before_render() {
let huge = format!("flowchart TD\n{}", "A-->B\n".repeat(20_000));
assert!(huge.len() > 64 * 1024);
let err = render_mermaid_svg(&huge).unwrap_err();
assert!(matches!(err, DiagramError::RenderFailed(_)));
}
#[test]
fn synthetic_url_differs_for_different_sources() {
let a = synthetic_url(&DiagramSource::Mermaid("flowchart TD\nA-->B".into()));
let b = synthetic_url(&DiagramSource::Mermaid("flowchart TD\nA-->C".into()));
assert_ne!(a, b);
}
#[test]
#[ignore = "requires system fonts; upstream has known panics"]
fn mermaid_live_renders_trivial_flowchart() {
let loaded = resolve_mermaid(
"test".into(),
"flowchart TD\nA-->B\n",
Some((80, 24)),
Some((8, 16)),
)
.expect("trivial flowchart should render");
assert!(loaded.image.width() > 0);
assert!(loaded.image.height() > 0);
}
#[test]
#[ignore = "requires system fonts; counterfactual benchmark only"]
fn mermaid_live_throughput_unshared_fontdb() {
fn rasterize_unshared(svg: &str) -> Result<DynamicImage, DiagramError> {
let mut opt = usvg::Options::default();
opt.fontdb_mut().load_system_fonts();
let tree = usvg::Tree::from_str(svg, &opt)
.map_err(|e| DiagramError::SvgParse(format!("{e}")))?;
let size = tree.size();
let w = (size.width().ceil() as u32).max(1);
let h = (size.height().ceil() as u32).max(1);
let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h)
.ok_or_else(|| DiagramError::Raster("pixmap".into()))?;
pixmap.fill(resvg::tiny_skia::Color::WHITE);
resvg::render(
&tree,
resvg::tiny_skia::Transform::default(),
&mut pixmap.as_mut(),
);
let bytes = pixmap
.encode_png()
.map_err(|e| DiagramError::Raster(format!("{e}")))?;
image::load_from_memory(&bytes).map_err(|e| DiagramError::Decode(format!("{e}")))
}
let diagrams = [
"flowchart TD\nA-->B-->C\nC-->D\nD-->A",
"sequenceDiagram\nA->>B: hi\nB-->>A: ok",
"pie\n\"A\": 50\n\"B\": 30\n\"C\": 20",
"stateDiagram-v2\n[*] --> Idle\nIdle --> Run : go\nRun --> [*]",
"classDiagram\nAnimal <|-- Dog\nAnimal <|-- Cat\nclass Animal",
];
let iterations = 4usize;
let start = std::time::Instant::now();
for _ in 0..iterations {
for src in &diagrams {
if let Ok(svg) = mermaid_rs_renderer::render(src) {
let _ = rasterize_unshared(&svg);
}
}
}
let total = start.elapsed();
let count = iterations * diagrams.len();
eprintln!(
"mermaid_live_throughput_unshared_fontdb: {count} renders in {:?} ({} µs/render)",
total,
total.as_micros() / count as u128,
);
}
#[test]
#[ignore = "requires system fonts; exercises live mermaid-rs-renderer"]
fn mermaid_live_throughput() {
warm_fontdb();
let diagrams = [
"flowchart TD\nA-->B-->C\nC-->D\nD-->A",
"sequenceDiagram\nA->>B: hi\nB-->>A: ok",
"pie\n\"A\": 50\n\"B\": 30\n\"C\": 20",
"stateDiagram-v2\n[*] --> Idle\nIdle --> Run : go\nRun --> [*]",
"classDiagram\nAnimal <|-- Dog\nAnimal <|-- Cat\nclass Animal",
];
let iterations = 4usize;
let start = std::time::Instant::now();
for _ in 0..iterations {
for src in &diagrams {
let _ = resolve_mermaid("bench".into(), src, Some((80, 24)), Some((8, 16)));
}
}
let total = start.elapsed();
let count = iterations * diagrams.len();
eprintln!(
"mermaid_live_throughput: {count} renders in {:?} ({} µs/render)",
total,
total.as_micros() / count as u128,
);
}
#[test]
#[ignore = "exercises upstream; may panic on some inputs until fixed"]
fn garbage_input_returns_err_not_panic() {
for input in [
"",
"\u{0000}\u{FFFF}",
"not a diagram at all, just prose",
"flowchart TD\n~~~~~~~~~~~",
] {
let result = resolve_mermaid("test".into(), input, None, None);
let _ = result;
}
}
}