diff --git a/crates/agcanvas/Cargo.toml b/crates/agcanvas/Cargo.toml index 222a92e..1d4f8da 100644 --- a/crates/agcanvas/Cargo.toml +++ b/crates/agcanvas/Cargo.toml @@ -33,7 +33,7 @@ tokio-tungstenite = "0.24" futures-util = "0.3" # Mermaid diagram rendering -mermaid-rs-renderer = { version = "0.1.2", default-features = false } +mermaid-rs-renderer = { git = "https://github.com/1jehuang/mermaid-rs-renderer", tag = "v0.2.0", default-features = false } # Logging tracing = "0.1" diff --git a/crates/agcanvas/src/mermaid.rs b/crates/agcanvas/src/mermaid.rs index dd476f1..d2975e0 100644 --- a/crates/agcanvas/src/mermaid.rs +++ b/crates/agcanvas/src/mermaid.rs @@ -1,9 +1,67 @@ +use std::panic; + use anyhow::Result; +use mermaid_rs_renderer::RenderOptions; pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result { - let svg = mermaid_rs_renderer::render(mermaid_source) - .map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?; - Ok(svg) + let source = mermaid_source.to_string(); + + let mut options = RenderOptions::modern(); + options.theme.font_family = + "Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif".to_string(); + + let result = panic::catch_unwind(|| mermaid_rs_renderer::render_with_options(&source, options)); + match result { + Ok(Ok(svg)) => Ok(sanitize_svg_font_families(&svg)), + Ok(Err(e)) => Err(anyhow::anyhow!("Mermaid render failed: {}", e)), + Err(_) => Err(anyhow::anyhow!( + "Mermaid renderer panicked (unsupported syntax)" + )), + } +} + +/// Strips nested double quotes inside `font-family` XML attributes. +/// +/// mermaid-rs-renderer v0.2.0 emits `font-family="..., "Segoe UI", ..."` +/// which is invalid XML — usvg rejects it. This rewrites those attributes +/// so the inner quotes are removed. +fn sanitize_svg_font_families(svg: &str) -> String { + let mut result = String::with_capacity(svg.len()); + let mut chars = svg.chars().peekable(); + + while let Some(ch) = chars.next() { + result.push(ch); + + if ch == 'f' && result.ends_with("font-family=\"") { + let mut value = String::new(); + loop { + match chars.next() { + Some('"') => { + if let Some(&next) = chars.peek() { + if next == ' ' || next == '/' || next == '>' || next == '\n' { + result.push_str(&value); + result.push('"'); + break; + } else { + continue; + } + } else { + result.push_str(&value); + result.push('"'); + break; + } + } + Some(c) => value.push(c), + None => { + result.push_str(&value); + break; + } + } + } + } + } + + result } #[cfg(test)] @@ -21,4 +79,23 @@ mod tests { let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap(); assert!(svg.contains("|Yes| B[OK]\n A -->|No| C[Cancel]", + ) + .unwrap(); + assert!(svg.contains("B").unwrap(); + let mut options = usvg::Options::default(); + options.fontdb_mut().load_system_fonts(); + usvg::Tree::from_str(&svg, &options).expect("sanitized SVG should parse with usvg"); + } }