Compare commits

...

4 Commits

Author SHA1 Message Date
David Ibia
64b4f667fb feat: upgrade mermaid-rs-renderer to v0.2.0 with edge label support
Adds font-family sanitization to fix nested double quotes that break
usvg XML parsing. Edge labels (-->|Yes|) now render correctly as
interactive DrawingElements.
2026-02-10 17:05:55 +01:00
David Ibia
9e9d33eb84 feat: convert Mermaid diagrams to native DrawingElements instead of raster overlays
Mermaid SVG is now parsed into interactive shapes (rectangles, arrows,
text) that can be selected, moved, and resized. Removes the MermaidOverlay
system entirely in favor of first-class drawing elements.
2026-02-10 17:05:45 +01:00
David Ibia
519d1f2459 feat: add canvas export to PNG via File menu, Cmd+Shift+E, and MCP tool 2026-02-10 17:05:36 +01:00
David Ibia
8390d01f85 fix: load system fonts in SVG parser for proper text rendering 2026-02-10 17:05:29 +01:00
14 changed files with 1086 additions and 171 deletions

View File

@@ -203,6 +203,18 @@ pub struct RenderMermaidParam {
pub height: Option<f32>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ExportCanvasParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "File path to save the PNG export to")]
pub path: String,
#[schemars(description = "Scale factor for the export (default 2.0 for high DPI)")]
pub scale: Option<f32>,
#[schemars(description = "Background color as hex e.g. '#1a1a2e' (default dark canvas color)")]
pub background: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BatchParam {
#[schemars(
@@ -565,6 +577,29 @@ impl AgCanvasServer {
self.call_agcanvas(&request).await
}
#[tool(
description = "Export the canvas as a PNG image. Renders all layers (SVG, drawing elements) into a single image file."
)]
async fn export_canvas(
&self,
Parameters(params): Parameters<ExportCanvasParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "ExportCanvas",
"path": params.path,
});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
if let Some(s) = params.scale {
request["scale"] = serde_json::json!(s);
}
if let Some(bg) = params.background {
request["background"] = serde_json::Value::String(bg);
}
self.call_agcanvas(&request).await
}
#[tool(
description = "Send multiple Augmented Canvas operations in one request. Accepts a JSON array of request objects and returns one response per operation in a BatchResult."
)]

View File

@@ -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"

View File

@@ -224,6 +224,15 @@ pub enum AgentRequest {
#[serde(default)]
height: Option<f32>,
},
ExportCanvas {
#[serde(default)]
session_id: Option<String>,
path: String,
#[serde(default)]
scale: Option<f32>,
#[serde(default)]
background: Option<String>,
},
Batch {
requests: Vec<AgentRequest>,
},
@@ -292,6 +301,14 @@ pub enum AgentResponse {
session_id: String,
overlay_id: String,
svg_source: String,
#[serde(default)]
element_ids: Vec<String>,
},
CanvasExported {
session_id: String,
path: String,
width: u32,
height: u32,
},
BatchResult {
results: Vec<AgentResponse>,
@@ -323,14 +340,6 @@ pub enum DrawingCommand {
Clear {
session_id: String,
},
RenderMermaid {
session_id: String,
overlay_id: String,
mermaid_source: String,
svg_source: String,
position: Pos2,
size: Vec2,
},
}
// ---------------------------------------------------------------------------

View File

@@ -3,6 +3,7 @@ use super::protocol::{
DrawingCommand, GuiEvent, SessionCommand,
};
use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle};
use crate::export::ExportData;
use crate::session::{SessionCreator, SessionStore};
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
@@ -11,10 +12,9 @@ use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{broadcast, mpsc, RwLock};
use tokio_tungstenite::tungstenite::Message;
use usvg::Tree;
const EVENT_CHANNEL_CAPACITY: usize = 64;
static OVERLAY_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
static MERMAID_RENDER_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub struct AgentServer {
sessions: Arc<RwLock<SessionStore>>,
@@ -237,7 +237,7 @@ async fn process_single_request(
.unwrap_or(0),
};
store.add_session(info.clone(), None);
store.add_session(info.clone(), None, None);
drop(store);
let _ = session_command_tx.send(SessionCommand::Create {
@@ -690,8 +690,8 @@ async fn process_single_request(
mermaid_source,
x,
y,
width,
height,
width: _,
height: _,
} => {
let sid = {
let store = sessions.read().await;
@@ -714,47 +714,104 @@ async fn process_single_request(
}
};
let options = usvg::Options::default();
let tree = match Tree::from_str(&svg_source, &options) {
Ok(tree) => tree,
let elements = match crate::svg::svg_to_drawing_elements(
&svg_source,
x.unwrap_or(0.0),
y.unwrap_or(0.0),
) {
Ok(elements) => elements,
Err(e) => {
return AgentResponse::Error {
message: format!("Failed to parse Mermaid SVG: {}", e),
message: format!("Failed to convert Mermaid SVG: {}", e),
}
}
};
let natural_size = tree.size();
let overlay_id = format!(
"mermaid_{}",
OVERLAY_ID_COUNTER.fetch_add(1, Ordering::SeqCst)
);
let position = egui::pos2(x.unwrap_or(0.0), y.unwrap_or(0.0));
let size = egui::vec2(
width.unwrap_or(natural_size.width()),
height.unwrap_or(natural_size.height()),
"mermaid_group_{}",
MERMAID_RENDER_COUNTER.fetch_add(1, Ordering::SeqCst)
);
let mut element_ids = Vec::with_capacity(elements.len());
{
let mut store = sessions.write().await;
for element in &elements {
element_ids.push(element.id.clone());
store.add_drawing_element(&sid, element.clone());
if command_tx
.send(DrawingCommand::RenderMermaid {
.send(DrawingCommand::Create {
session_id: sid.clone(),
overlay_id: overlay_id.clone(),
mermaid_source,
svg_source: svg_source.clone(),
position,
size,
element: element.clone(),
})
.is_err()
{
return AgentResponse::Error {
message: "Failed to enqueue Mermaid render command".to_string(),
message: "Failed to send drawing command".to_string(),
};
}
let _ = event_tx.send(GuiEvent::DrawingElementCreated {
session_id: sid.clone(),
element: element.clone(),
});
}
}
AgentResponse::MermaidRendered {
session_id: sid,
overlay_id,
svg_source,
element_ids,
}
}
AgentRequest::ExportCanvas {
session_id,
path,
scale,
background,
} => {
let export_scale = scale.unwrap_or(2.0);
if !(export_scale.is_finite() && export_scale > 0.0) {
return AgentResponse::Error {
message: "scale must be a positive finite value".to_string(),
};
}
let background_color = background
.as_deref()
.and_then(parse_hex_color)
.map(color32_to_skia)
.unwrap_or_else(|| tiny_skia::Color::from_rgba8(30, 30, 30, 255));
let (sid, svg_source, drawing_elements) = {
let store = sessions.read().await;
match store.get_export_data(session_id.as_deref()) {
Some(data) => data,
None => {
return AgentResponse::Error {
message: "No session found".to_string(),
}
}
}
};
let export_data = ExportData {
svg_source,
drawing_elements,
background_color,
};
match crate::export::export_canvas_to_png(&export_data, &path, export_scale) {
Ok((width, height)) => AgentResponse::CanvasExported {
session_id: sid,
path,
width,
height,
},
Err(e) => AgentResponse::Error {
message: format!("Failed to export canvas: {}", e),
},
}
}
AgentRequest::Batch { .. } => AgentResponse::Error {
@@ -763,6 +820,10 @@ async fn process_single_request(
}
}
fn color32_to_skia(c: egui::Color32) -> tiny_skia::Color {
tiny_skia::Color::from_rgba8(c.r(), c.g(), c.b(), c.a())
}
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
let description = crate::element_tree::ElementTree {
root: element.clone(),

View File

@@ -3,14 +3,14 @@ use crate::canvas::{CanvasInteraction, CanvasState};
use crate::clipboard::ClipboardManager;
use crate::command_palette::{CommandId, CommandPalette};
use crate::drawing::{
canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection,
find_handle_at_screen_pos, screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle,
Tool,
draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
};
use crate::export::{self, ExportData};
use crate::history::{ChangeSource, HistoryTree, NodeId};
use crate::mermaid::render_mermaid_to_svg;
use crate::persistence::{self, SavedSession, SavedWorkspace};
use crate::session::{MermaidOverlay, Session, SessionCreator, SessionStore};
use crate::session::{Session, SessionCreator, SessionStore};
use crate::svg::{parse_svg, SvgRenderer};
use egui::{Color32, ColorImage, TextureOptions};
use std::path::Path;
@@ -21,6 +21,12 @@ use tokio::sync::{broadcast, mpsc, RwLock};
const AGENT_PORT: u16 = 9876;
const MIN_SHAPE_SIZE: f32 = 5.0;
type SessionExportSnapshot = (
String,
Vec<DrawingElement>,
Option<String>,
);
pub struct AgCanvasApp {
sessions: Vec<Session>,
active_session_idx: usize,
@@ -112,7 +118,7 @@ impl AgCanvasApp {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mut store = sessions_handle.write().await;
store.add_session(info_clone.clone(), None);
store.add_session(info_clone.clone(), None, None);
store.set_active(&info_clone.id);
});
let _ = event_tx.send(GuiEvent::SessionCreated {
@@ -205,12 +211,13 @@ impl AgCanvasApp {
let sessions_handle = self.sessions_handle.clone();
let event_tx = self.event_tx.clone();
let tree_clone = tree.clone();
let svg_source = svg_data.to_string();
let sid = session_id.clone();
std::thread::spawn(move || {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mut store = sessions_handle.write().await;
store.update_tree(&sid, Some(tree_clone));
store.update_tree(&sid, Some(tree_clone), Some(svg_source));
});
let _ = event_tx.send(GuiEvent::SvgLoaded {
session_id,
@@ -256,7 +263,7 @@ impl AgCanvasApp {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mut store = sessions_handle.write().await;
store.update_tree(&sid, None);
store.update_tree(&sid, None, None);
});
let _ = event_tx.send(GuiEvent::SvgCleared { session_id });
});
@@ -291,44 +298,7 @@ impl AgCanvasApp {
self.status_message = Some((message, std::time::Instant::now()));
}
fn render_mermaid_overlays_to_texture(&mut self, ctx: &egui::Context) {
let ppp = ctx.pixels_per_point();
let scale = self.active_session().canvas_state.zoom.max(1.0) * ppp;
let session = self.active_session_mut();
let session_id = session.id.clone();
for overlay in &mut session.mermaid_overlays {
if overlay.texture.is_some() {
continue;
}
if let Ok(pixmap) = overlay.renderer.render(scale) {
let size = [pixmap.width() as usize, pixmap.height() as usize];
let pixels: Vec<Color32> = pixmap
.pixels()
.iter()
.map(|p| {
Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha())
})
.collect();
let image = ColorImage { size, pixels };
overlay.texture = Some(ctx.load_texture(
format!(
"mermaid-{}-{}-{}-{}",
session_id,
overlay.id,
overlay.mermaid_source.len(),
overlay.svg_source.len()
),
image,
TextureOptions::LINEAR,
));
}
}
}
fn handle_mermaid_render(&mut self, ctx: &egui::Context) {
fn handle_mermaid_render(&mut self, _ctx: &egui::Context) {
let source = self.mermaid_input.trim().to_string();
if source.is_empty() {
self.set_status("Mermaid input is empty".to_string());
@@ -336,11 +306,23 @@ impl AgCanvasApp {
}
match render_mermaid_to_svg(&source) {
Ok(svg_string) => {
Ok(svg_source) => {
self.show_mermaid_dialog = false;
self.mermaid_input.clear();
self.load_svg_data(&svg_string, ctx);
self.set_status("Rendered Mermaid diagram".to_string());
match crate::svg::svg_to_drawing_elements(&svg_source, 0.0, 0.0) {
Ok(elements) => {
let count = elements.len();
self.active_session_mut().drawing_elements.extend(elements);
self.active_session_mut()
.record_edit("Paste Mermaid", crate::history::ChangeSource::Human);
self.sync_drawing_elements_to_store();
self.set_status(format!("Rendered Mermaid diagram ({} elements)", count));
}
Err(e) => {
self.set_status(format!("Failed to convert Mermaid: {}", e));
}
}
}
Err(e) => {
self.set_status(format!("Mermaid error: {}", e));
@@ -402,38 +384,6 @@ impl AgCanvasApp {
.record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None });
}
}
DrawingCommand::RenderMermaid {
session_id,
overlay_id,
mermaid_source,
svg_source,
position,
size,
} => {
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
match parse_svg(&svg_source) {
Ok((_, usvg_tree)) => {
let overlay = MermaidOverlay {
id: overlay_id,
mermaid_source,
svg_source,
renderer: SvgRenderer::new(usvg_tree),
texture: None,
position,
size,
};
session.mermaid_overlays.push(overlay);
session.record_edit(
"Agent: Render Mermaid",
ChangeSource::Agent { name: None },
);
}
Err(e) => {
tracing::warn!("Failed to parse Mermaid overlay SVG: {}", e);
}
}
}
}
}
}
}
@@ -480,23 +430,47 @@ impl AgCanvasApp {
fn sync_drawing_elements_to_store(&self) {
let sessions_handle = self.sessions_handle.clone();
let elements_by_session: Vec<(String, Vec<DrawingElement>)> = self
let session_snapshots: Vec<SessionExportSnapshot> = self
.sessions
.iter()
.map(|s| (s.id.clone(), s.drawing_elements.clone()))
.map(|s| (s.id.clone(), s.drawing_elements.clone(), s.svg_source.clone()))
.collect();
std::thread::spawn(move || {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mut store = sessions_handle.write().await;
for (sid, elements) in elements_by_session {
for (sid, elements, svg_source) in session_snapshots {
store.update_drawing_elements(&sid, elements);
store.update_export_layers(&sid, svg_source);
}
});
});
}
fn export_canvas_png(&mut self) {
let session = self.active_session();
let session_id = session.id.clone();
let export_data = ExportData {
svg_source: session.svg_source.clone(),
drawing_elements: session.drawing_elements.clone(),
background_color: tiny_skia::Color::from_rgba8(30, 30, 30, 255),
};
let mut path = std::env::temp_dir();
path.push(format!("canvas_export_{}.png", session_id));
let path_str = path.to_string_lossy().to_string();
match export::export_canvas_to_png(&export_data, &path_str, 2.0) {
Ok((width, height)) => {
self.set_status(format!("Exported PNG: {} ({}x{})", path_str, width, height));
}
Err(e) => {
self.set_status(format!("Export failed: {}", e));
}
}
}
fn build_saved_workspace(&self) -> SavedWorkspace {
let sessions: Vec<SavedSession> = self
.sessions
@@ -557,6 +531,7 @@ impl AgCanvasApp {
let info = session.info();
let tree_clone = session.element_tree.clone();
let svg_source_clone = saved.svg_source.clone();
self.sessions.push(session);
let sessions_handle = self.sessions_handle.clone();
@@ -565,7 +540,7 @@ impl AgCanvasApp {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mut store = sessions_handle.write().await;
store.add_session(info_clone, tree_clone);
store.add_session(info_clone, tree_clone, svg_source_clone);
});
});
}
@@ -619,6 +594,7 @@ impl AgCanvasApp {
CommandId::ClearCanvas => self.clear_canvas(),
CommandId::PasteSvg => self.handle_paste(ctx),
CommandId::PasteMermaid => self.show_mermaid_dialog = true,
CommandId::ExportPng => self.export_canvas_png(),
CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select,
CommandId::ToolPan => self.active_session_mut().active_tool = Tool::Pan,
CommandId::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle,
@@ -931,6 +907,7 @@ impl eframe::App for AgCanvasApp {
let mut toggle_history = false;
let mut undo = false;
let mut redo = false;
let mut export_png = false;
let mut tool_switch: Option<Tool> = None;
let palette_open = self.command_palette.visible;
@@ -962,6 +939,9 @@ impl eframe::App for AgCanvasApp {
undo = true;
}
}
if i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::E) {
export_png = true;
}
if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) {
delete_selected = true;
}
@@ -1011,6 +991,9 @@ impl eframe::App for AgCanvasApp {
if toggle_history {
self.show_history_panel = !self.show_history_panel;
}
if export_png {
self.export_canvas_png();
}
if delete_selected
&& !self.show_text_input
&& !self.show_mermaid_dialog
@@ -1102,14 +1085,6 @@ impl eframe::App for AgCanvasApp {
{
self.render_svg_to_texture(ctx);
}
if self
.active_session()
.mermaid_overlays
.iter()
.any(|overlay| overlay.texture.is_none())
{
self.render_mermaid_overlays_to_texture(ctx);
}
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
@@ -1133,6 +1108,11 @@ impl eframe::App for AgCanvasApp {
ui.close_menu();
}
ui.separator();
if ui.button("Export as PNG (Cmd+Shift+E)").clicked() {
self.export_canvas_png();
ui.close_menu();
}
ui.separator();
if ui.button("Clear Canvas").clicked() {
self.clear_canvas();
ui.close_menu();
@@ -1577,21 +1557,6 @@ impl eframe::App for AgCanvasApp {
let offset = self.active_session().canvas_state.offset;
let zoom = self.active_session().canvas_state.zoom;
for overlay in &self.active_session().mermaid_overlays {
if let Some(texture) = &overlay.texture {
let screen_pos =
canvas_to_screen(overlay.position, canvas_center, offset, zoom);
let screen_size = overlay.size * zoom;
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
painter.image(
texture.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
}
}
draw_elements(
&painter,
&self.active_session().drawing_elements,
@@ -1621,7 +1586,6 @@ impl eframe::App for AgCanvasApp {
);
if self.active_session().svg_texture.is_none()
&& self.active_session().mermaid_overlays.is_empty()
&& self.active_session().drawing_elements.is_empty()
{
painter.text(

View File

@@ -10,6 +10,7 @@ pub enum CommandId {
ClearCanvas,
PasteSvg,
PasteMermaid,
ExportPng,
ToolSelect,
ToolPan,
ToolRectangle,
@@ -68,6 +69,12 @@ pub fn all_commands() -> Vec<PaletteCommand> {
None,
"Canvas",
),
PaletteCommand::new(
CommandId::ExportPng,
"Export as PNG",
Some("Cmd+Shift+E"),
"Canvas",
),
PaletteCommand::new(CommandId::ToolSelect, "Select Tool", Some("V"), "Tool"),
PaletteCommand::new(CommandId::ToolPan, "Pan Tool", Some("H"), "Tool"),
PaletteCommand::new(

View File

@@ -6,7 +6,7 @@ mod tool;
pub use boolean::BooleanOpType;
pub use element::{DrawingElement, Shape, ShapeStyle};
pub use render::{
canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection,
find_handle_at_screen_pos, screen_to_canvas,
draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
screen_to_canvas,
};
pub use tool::{DragState, Tool};

View File

@@ -0,0 +1,442 @@
use std::path::Path;
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
use anyhow::{anyhow, Result};
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
const EXPORT_PADDING: f32 = 20.0;
/// Data needed to export a canvas snapshot (no egui dependency).
pub struct ExportData {
pub svg_source: Option<String>,
pub drawing_elements: Vec<DrawingElement>,
pub background_color: tiny_skia::Color,
}
#[derive(Debug, Clone, Copy)]
struct Bounds {
min_x: f32,
min_y: f32,
max_x: f32,
max_y: f32,
}
impl Bounds {
fn from_rect(min_x: f32, min_y: f32, max_x: f32, max_y: f32) -> Self {
Self {
min_x,
min_y,
max_x,
max_y,
}
}
fn union(self, other: Self) -> Self {
Self {
min_x: self.min_x.min(other.min_x),
min_y: self.min_y.min(other.min_y),
max_x: self.max_x.max(other.max_x),
max_y: self.max_y.max(other.max_y),
}
}
}
pub fn export_canvas_to_png(data: &ExportData, path: &str, scale: f32) -> Result<(u32, u32)> {
if !(scale.is_finite() && scale > 0.0) {
return Err(anyhow!("Scale must be a positive finite value"));
}
if let Some(parent) = Path::new(path).parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(anyhow!(
"Export directory does not exist: {}",
parent.display()
));
}
}
let mut total_bounds = compute_total_bounds(data)?;
total_bounds.min_x -= EXPORT_PADDING;
total_bounds.min_y -= EXPORT_PADDING;
total_bounds.max_x += EXPORT_PADDING;
total_bounds.max_y += EXPORT_PADDING;
let width_f = (total_bounds.max_x - total_bounds.min_x).max(1.0) * scale;
let height_f = (total_bounds.max_y - total_bounds.min_y).max(1.0) * scale;
let width = width_f.ceil().max(1.0) as u32;
let height = height_f.ceil().max(1.0) as u32;
let mut pixmap =
Pixmap::new(width, height).ok_or_else(|| anyhow!("Failed to create export pixmap"))?;
pixmap.fill(data.background_color);
if let Some(svg_source) = &data.svg_source {
let tree = parse_svg(svg_source)?;
let tx = -total_bounds.min_x * scale;
let ty = -total_bounds.min_y * scale;
let transform = Transform::from_row(scale, 0.0, 0.0, scale, tx, ty);
resvg::render(&tree, transform, &mut pixmap.as_mut());
}
render_drawing_elements(
&mut pixmap,
&data.drawing_elements,
total_bounds.min_x,
total_bounds.min_y,
scale,
)?;
pixmap.save_png(path)?;
Ok((width, height))
}
fn compute_total_bounds(data: &ExportData) -> Result<Bounds> {
let mut bounds: Option<Bounds> = None;
if let Some(svg_source) = &data.svg_source {
let tree = parse_svg(svg_source)?;
let size = tree.size();
bounds = Some(Bounds::from_rect(0.0, 0.0, size.width(), size.height()));
}
if let Some(drawing_bounds) = compute_drawing_bounds(&data.drawing_elements) {
bounds = Some(match bounds {
Some(existing) => existing.union(drawing_bounds),
None => drawing_bounds,
});
}
bounds.ok_or_else(|| anyhow!("Cannot export empty canvas"))
}
fn compute_drawing_bounds(elements: &[DrawingElement]) -> Option<Bounds> {
let mut min_x = f32::INFINITY;
let mut min_y = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut max_y = f32::NEG_INFINITY;
for element in elements {
let rect = element.bounding_rect();
let half_stroke = (element.style.stroke_width * 0.5).max(0.0);
min_x = min_x.min(rect.min.x - half_stroke);
min_y = min_y.min(rect.min.y - half_stroke);
max_x = max_x.max(rect.max.x + half_stroke);
max_y = max_y.max(rect.max.y + half_stroke);
}
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() {
Some(Bounds::from_rect(min_x, min_y, max_x, max_y))
} else {
None
}
}
fn render_drawing_elements(
pixmap: &mut Pixmap,
elements: &[DrawingElement],
min_x: f32,
min_y: f32,
scale: f32,
) -> Result<()> {
for element in elements {
match &element.shape {
Shape::Rectangle { pos, size } => {
if size.x <= 0.0 || size.y <= 0.0 {
continue;
}
let x = (pos.x - min_x) * scale;
let y = (pos.y - min_y) * scale;
let w = size.x * scale;
let h = size.y * scale;
let Some(rect) = tiny_skia::Rect::from_xywh(x, y, w, h) else {
continue;
};
let mut pb = PathBuilder::new();
pb.push_rect(rect);
if let Some(path) = pb.finish() {
fill_and_stroke_path(pixmap, &path, &element.style, scale);
}
}
Shape::Ellipse { center, radii } => {
if radii.x <= 0.0 || radii.y <= 0.0 {
continue;
}
let x = (center.x - radii.x - min_x) * scale;
let y = (center.y - radii.y - min_y) * scale;
let w = radii.x * 2.0 * scale;
let h = radii.y * 2.0 * scale;
let Some(rect) = tiny_skia::Rect::from_xywh(x, y, w, h) else {
continue;
};
let mut pb = PathBuilder::new();
pb.push_oval(rect);
if let Some(path) = pb.finish() {
fill_and_stroke_path(pixmap, &path, &element.style, scale);
}
}
Shape::Line { start, end } => {
if let Some(path) = build_line_path(*start, *end, min_x, min_y, scale) {
stroke_path(pixmap, &path, &element.style, scale);
}
}
Shape::Arrow { start, end } => {
if let Some(path) = build_line_path(*start, *end, min_x, min_y, scale) {
stroke_path(pixmap, &path, &element.style, scale);
}
if let Some(arrowhead) = build_arrowhead_path(*start, *end, min_x, min_y, scale) {
stroke_path(pixmap, &arrowhead, &element.style, scale);
}
}
Shape::Text {
pos,
content,
font_size,
} => {
render_text_element(
pixmap,
content,
(pos.x - min_x) * scale,
(pos.y - min_y) * scale,
*font_size * scale,
element.style.stroke_color,
)?;
}
Shape::Path { polygons } => {
for polygon in polygons {
let mut pb = PathBuilder::new();
add_ring_to_path(&mut pb, &polygon.exterior, min_x, min_y, scale, true);
for hole in &polygon.holes {
add_ring_to_path(&mut pb, hole, min_x, min_y, scale, true);
}
let Some(path) = pb.finish() else {
continue;
};
fill_and_stroke_path_even_odd(pixmap, &path, &element.style, scale);
}
}
}
}
Ok(())
}
fn fill_and_stroke_path(
pixmap: &mut Pixmap,
path: &tiny_skia::Path,
style: &ShapeStyle,
scale: f32,
) {
if let Some(fill) = style.fill {
let mut fill_paint = Paint::default();
fill_paint.set_color(egui_to_skia_color(fill));
pixmap.fill_path(
path,
&fill_paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
stroke_path(pixmap, path, style, scale);
}
fn fill_and_stroke_path_even_odd(
pixmap: &mut Pixmap,
path: &tiny_skia::Path,
style: &ShapeStyle,
scale: f32,
) {
if let Some(fill) = style.fill {
let mut fill_paint = Paint::default();
fill_paint.set_color(egui_to_skia_color(fill));
pixmap.fill_path(
path,
&fill_paint,
FillRule::EvenOdd,
Transform::identity(),
None,
);
}
stroke_path(pixmap, path, style, scale);
}
fn stroke_path(pixmap: &mut Pixmap, path: &tiny_skia::Path, style: &ShapeStyle, scale: f32) {
let stroke_width = style.stroke_width * scale;
if stroke_width <= 0.0 {
return;
}
let mut paint = Paint::default();
paint.set_color(egui_to_skia_color(style.stroke_color));
let stroke = Stroke {
width: stroke_width,
line_cap: LineCap::Round,
line_join: LineJoin::Round,
..Stroke::default()
};
pixmap.stroke_path(path, &paint, &stroke, Transform::identity(), None);
}
fn build_line_path(
start: egui::Pos2,
end: egui::Pos2,
min_x: f32,
min_y: f32,
scale: f32,
) -> Option<tiny_skia::Path> {
let mut pb = PathBuilder::new();
pb.move_to((start.x - min_x) * scale, (start.y - min_y) * scale);
pb.line_to((end.x - min_x) * scale, (end.y - min_y) * scale);
pb.finish()
}
fn build_arrowhead_path(
start: egui::Pos2,
end: egui::Pos2,
min_x: f32,
min_y: f32,
scale: f32,
) -> Option<tiny_skia::Path> {
let dx = end.x - start.x;
let dy = end.y - start.y;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-6 {
return None;
}
let dir_x = dx / len;
let dir_y = dy / len;
let perp_x = -dir_y;
let perp_y = dir_x;
let arrow_size = 12.0;
let left = egui::pos2(
end.x - dir_x * arrow_size + perp_x * (arrow_size * 0.4),
end.y - dir_y * arrow_size + perp_y * (arrow_size * 0.4),
);
let right = egui::pos2(
end.x - dir_x * arrow_size - perp_x * (arrow_size * 0.4),
end.y - dir_y * arrow_size - perp_y * (arrow_size * 0.4),
);
let mut pb = PathBuilder::new();
pb.move_to((left.x - min_x) * scale, (left.y - min_y) * scale);
pb.line_to((end.x - min_x) * scale, (end.y - min_y) * scale);
pb.move_to((right.x - min_x) * scale, (right.y - min_y) * scale);
pb.line_to((end.x - min_x) * scale, (end.y - min_y) * scale);
pb.finish()
}
fn add_ring_to_path(
pb: &mut PathBuilder,
ring: &[egui::Pos2],
min_x: f32,
min_y: f32,
scale: f32,
close: bool,
) {
if ring.is_empty() {
return;
}
pb.move_to((ring[0].x - min_x) * scale, (ring[0].y - min_y) * scale);
for point in ring.iter().skip(1) {
pb.line_to((point.x - min_x) * scale, (point.y - min_y) * scale);
}
if close {
pb.close();
}
}
fn render_text_element(
pixmap: &mut Pixmap,
text: &str,
x: f32,
y: f32,
font_size: f32,
color: egui::Color32,
) -> Result<()> {
if text.is_empty() || font_size <= 0.0 {
return Ok(());
}
let escaped = escape_xml(text);
let approx_width = (font_size * text.chars().count() as f32 * 0.7).max(font_size);
let approx_height = (font_size * 1.6).max(font_size);
let opacity = color.a() as f32 / 255.0;
let svg_text = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"><text x="0" y="{baseline}" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="{size}" fill="rgb({r},{g},{b})" fill-opacity="{opacity}">{text}</text></svg>"#,
w = approx_width,
h = approx_height,
baseline = font_size,
size = font_size,
r = color.r(),
g = color.g(),
b = color.b(),
opacity = opacity,
text = escaped,
);
let tree = parse_svg(&svg_text)?;
let transform = Transform::from_translate(x, y);
resvg::render(&tree, transform, &mut pixmap.as_mut());
Ok(())
}
fn parse_svg(svg_source: &str) -> Result<usvg::Tree> {
let mut options = usvg::Options::default();
options.fontdb_mut().load_system_fonts();
Ok(usvg::Tree::from_str(svg_source, &options)?)
}
fn escape_xml(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(ch),
}
}
escaped
}
fn egui_to_skia_color(c: egui::Color32) -> tiny_skia::Color {
tiny_skia::Color::from_rgba8(c.r(), c.g(), c.b(), c.a())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_simple_rectangle() {
let data = ExportData {
svg_source: None,
drawing_elements: vec![DrawingElement::new(
Shape::Rectangle {
pos: egui::Pos2::new(10.0, 10.0),
size: egui::Vec2::new(100.0, 50.0),
},
ShapeStyle::default(),
)],
background_color: tiny_skia::Color::from_rgba8(26, 26, 46, 255),
};
let path = "/tmp/agcanvas_test_export.png";
let result = export_canvas_to_png(&data, path, 1.0);
assert!(result.is_ok());
assert!(std::path::Path::new(path).exists());
std::fs::remove_file(path).ok();
}
}

View File

@@ -5,6 +5,7 @@ mod clipboard;
mod command_palette;
mod drawing;
mod element_tree;
mod export;
mod history;
mod mermaid;
mod persistence;

View File

@@ -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<String> {
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("<svg"));
}
#[test]
fn renders_edge_labels() {
let svg = render_mermaid_to_svg(
"flowchart LR\n A{Decision} -->|Yes| B[OK]\n A -->|No| C[Cancel]",
)
.unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("Yes"));
assert!(svg.contains("No"));
}
#[test]
fn sanitized_svg_is_valid_xml() {
let svg = render_mermaid_to_svg("flowchart LR\n A-->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");
}
}

View File

@@ -77,16 +77,6 @@ pub struct SessionInfo {
pub created_at: i64,
}
pub struct MermaidOverlay {
pub id: String,
pub mermaid_source: String,
pub svg_source: String,
pub renderer: SvgRenderer,
pub texture: Option<TextureHandle>,
pub position: egui::Pos2,
pub size: egui::Vec2,
}
pub struct Session {
pub id: String,
pub name: String,
@@ -98,7 +88,6 @@ pub struct Session {
pub description_text: String,
pub drawing_elements: Vec<DrawingElement>,
pub mermaid_overlays: Vec<MermaidOverlay>,
pub selected_element_ids: Vec<String>,
pub active_tool: Tool,
pub drag_state: DragState,
@@ -121,7 +110,6 @@ impl Session {
svg_source: None,
description_text: String::new(),
drawing_elements: Vec::new(),
mermaid_overlays: Vec::new(),
selected_element_ids: Vec::new(),
active_tool: Tool::default(),
drag_state: DragState::default(),
@@ -157,7 +145,6 @@ impl Session {
self.svg_source = None;
self.description_text.clear();
self.drawing_elements.clear();
self.mermaid_overlays.clear();
self.selected_element_ids.clear();
self.drag_state = DragState::default();
self.canvas_state.reset();
@@ -206,7 +193,6 @@ impl Session {
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.mermaid_overlays.clear();
self.selected_element_ids.clear();
self.drag_state = DragState::default();
}
@@ -219,7 +205,6 @@ impl Session {
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.mermaid_overlays.clear();
self.selected_element_ids.clear();
self.drag_state = DragState::default();
true
@@ -236,7 +221,6 @@ impl Session {
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.mermaid_overlays.clear();
self.selected_element_ids.clear();
self.drag_state = DragState::default();
true
@@ -250,9 +234,12 @@ impl Session {
pub struct SessionData {
pub info: SessionInfo,
pub tree: Option<ElementTree>,
pub svg_source: Option<String>,
pub drawing_elements: Vec<DrawingElement>,
}
pub type ExportSessionData = (String, Option<String>, Vec<DrawingElement>);
#[derive(Default)]
pub struct SessionStore {
sessions: HashMap<String, SessionData>,
@@ -265,13 +252,19 @@ impl SessionStore {
Self::default()
}
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) {
pub fn add_session(
&mut self,
info: SessionInfo,
tree: Option<ElementTree>,
svg_source: Option<String>,
) {
let id = info.id.clone();
self.sessions.insert(
id.clone(),
SessionData {
info,
tree,
svg_source,
drawing_elements: Vec::new(),
},
);
@@ -293,11 +286,17 @@ impl SessionStore {
}
}
pub fn update_tree(&mut self, session_id: &str, tree: Option<ElementTree>) {
pub fn update_tree(
&mut self,
session_id: &str,
tree: Option<ElementTree>,
svg_source: Option<String>,
) {
if let Some(data) = self.sessions.get_mut(session_id) {
data.info.has_svg = tree.is_some();
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
data.tree = tree;
data.svg_source = svg_source;
}
}
@@ -316,6 +315,12 @@ impl SessionStore {
}
}
pub fn update_export_layers(&mut self, session_id: &str, svg_source: Option<String>) {
if let Some(data) = self.sessions.get_mut(session_id) {
data.svg_source = svg_source;
}
}
pub fn get_drawing_elements(
&self,
session_id: Option<&str>,
@@ -327,6 +332,14 @@ impl SessionStore {
Some((id, &data.drawing_elements))
}
pub fn get_export_data(&self, session_id: Option<&str>) -> Option<ExportSessionData> {
let id = session_id
.map(|s| s.to_string())
.or_else(|| self.active_session_id.clone())?;
let data = self.sessions.get(&id)?;
Some((id, data.svg_source.clone(), data.drawing_elements.clone()))
}
pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool {
if let Some(data) = self.sessions.get_mut(session_id) {
data.drawing_elements.push(element);

View File

@@ -0,0 +1,303 @@
use anyhow::Result;
use egui::{Color32, Pos2, Vec2};
use usvg::tiny_skia_path::PathSegment;
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
/// Convert an SVG string into editable DrawingElements.
/// `offset_x` and `offset_y` shift all elements (for positioning on canvas).
pub fn svg_to_drawing_elements(
svg_source: &str,
offset_x: f32,
offset_y: f32,
) -> Result<Vec<DrawingElement>> {
let mut options = usvg::Options::default();
options.fontdb_mut().load_system_fonts();
let tree = usvg::Tree::from_str(svg_source, &options)?;
let mut elements = Vec::new();
walk_group(tree.root(), offset_x, offset_y, &mut elements);
Ok(elements)
}
fn walk_group(group: &usvg::Group, ox: f32, oy: f32, elements: &mut Vec<DrawingElement>) {
let (gx, gy) = extract_group_translate(group);
let next_ox = ox + gx;
let next_oy = oy + gy;
for node in group.children() {
match node {
usvg::Node::Group(g) => walk_group(g, next_ox, next_oy, elements),
usvg::Node::Path(path) => {
if let Some(element) = convert_path(path, next_ox, next_oy) {
elements.push(element);
}
}
usvg::Node::Text(text) => {
if let Some(element) = convert_text(text, next_ox, next_oy) {
elements.push(element);
}
}
usvg::Node::Image(_) => {}
}
}
}
fn extract_group_translate(group: &usvg::Group) -> (f32, f32) {
let t = group.transform();
(t.tx, t.ty)
}
fn convert_path(path: &usvg::Path, ox: f32, oy: f32) -> Option<DrawingElement> {
let bbox = path.bounding_box();
// Skip degenerate paths (zero area AND zero length — truly empty)
if bbox.width() <= 0.0 && bbox.height() <= 0.0 {
return None;
}
if is_background_rect(path) {
return None;
}
// Skip tiny marker definition fragments
if bbox.width() < 1.0 && bbox.height() < 1.0 {
return None;
}
let segments: Vec<_> = path.data().segments().collect();
if segments.is_empty() {
return None;
}
let style = extract_style(path);
if is_rect_path(&segments) {
return Some(DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(bbox.left() + ox, bbox.top() + oy),
size: Vec2::new(bbox.width(), bbox.height()),
},
style,
));
}
if is_line_path(&segments) {
let (start, end) = extract_line_endpoints(&segments)?;
let shape = if is_stroke_only(path) {
Shape::Arrow {
start: Pos2::new(start.0 + ox, start.1 + oy),
end: Pos2::new(end.0 + ox, end.1 + oy),
}
} else {
Shape::Line {
start: Pos2::new(start.0 + ox, start.1 + oy),
end: Pos2::new(end.0 + ox, end.1 + oy),
}
};
return Some(DrawingElement::new(shape, style));
}
let aspect = bbox.width() / bbox.height().max(0.001);
if (0.9..=1.1).contains(&aspect) && has_curves(&segments) {
return Some(DrawingElement::new(
Shape::Ellipse {
center: Pos2::new(
bbox.left() + bbox.width() * 0.5 + ox,
bbox.top() + bbox.height() * 0.5 + oy,
),
radii: Vec2::new(bbox.width() * 0.5, bbox.height() * 0.5),
},
style,
));
}
None
}
fn convert_text(text: &usvg::Text, ox: f32, oy: f32) -> Option<DrawingElement> {
let content = extract_text_content(text);
if content.trim().is_empty() {
return None;
}
let bbox = text.bounding_box();
let font_size = extract_font_size(text);
let fill_color = extract_text_color(text);
Some(DrawingElement::new(
Shape::Text {
pos: Pos2::new(bbox.left() + ox, bbox.top() + oy),
content,
font_size,
},
ShapeStyle {
fill: None,
stroke_color: fill_color,
stroke_width: 0.0,
},
))
}
fn extract_text_content(text: &usvg::Text) -> String {
text.chunks()
.iter()
.map(|chunk| chunk.text())
.collect::<Vec<_>>()
.join("")
}
fn extract_font_size(text: &usvg::Text) -> f32 {
text.chunks()
.iter()
.flat_map(|chunk| chunk.spans().iter())
.next()
.map(|span| span.font_size().get())
.unwrap_or(16.0)
}
fn extract_text_color(text: &usvg::Text) -> Color32 {
for chunk in text.chunks() {
for span in chunk.spans() {
if let Some(fill) = span.fill() {
if let usvg::Paint::Color(c) = fill.paint() {
return Color32::from_rgb(c.red, c.green, c.blue);
}
}
}
}
Color32::WHITE
}
fn extract_style(path: &usvg::Path) -> ShapeStyle {
let fill = path.fill().and_then(|f| match f.paint() {
usvg::Paint::Color(c) => Some(Color32::from_rgb(c.red, c.green, c.blue)),
_ => None,
});
let (stroke_color, stroke_width) = path
.stroke()
.map(|s| {
let color = match s.paint() {
usvg::Paint::Color(c) => Color32::from_rgb(c.red, c.green, c.blue),
_ => Color32::WHITE,
};
(color, s.width().get())
})
.unwrap_or((Color32::WHITE, 2.0));
ShapeStyle {
fill,
stroke_color,
stroke_width,
}
}
fn is_background_rect(path: &usvg::Path) -> bool {
let Some(fill) = path.fill() else {
return false;
};
let usvg::Paint::Color(c) = fill.paint() else {
return false;
};
if !(c.red > 240 && c.green > 240 && c.blue > 240) {
return false;
}
let bbox = path.bounding_box();
bbox.left().abs() < 1.0 && bbox.top().abs() < 1.0
}
fn is_rect_path(segments: &[PathSegment]) -> bool {
let has_close = segments.iter().any(|s| matches!(s, PathSegment::Close));
if !has_close {
return false;
}
let line_count = segments
.iter()
.filter(|s| matches!(s, PathSegment::LineTo(_)))
.count();
line_count >= 3
}
fn is_line_path(segments: &[PathSegment]) -> bool {
!segments.iter().any(|s| matches!(s, PathSegment::Close))
}
fn extract_line_endpoints(segments: &[PathSegment]) -> Option<((f32, f32), (f32, f32))> {
let mut first: Option<(f32, f32)> = None;
let mut last: Option<(f32, f32)> = None;
for segment in segments {
let point = match segment {
PathSegment::MoveTo(p) => (p.x, p.y),
PathSegment::LineTo(p) => (p.x, p.y),
PathSegment::QuadTo(_, p) => (p.x, p.y),
PathSegment::CubicTo(_, _, p) => (p.x, p.y),
PathSegment::Close => continue,
};
if first.is_none() {
first = Some(point);
}
last = Some(point);
}
match (first, last) {
(Some(a), Some(b)) => Some((a, b)),
_ => None,
}
}
fn has_curves(segments: &[PathSegment]) -> bool {
segments
.iter()
.any(|s| matches!(s, PathSegment::QuadTo(_, _) | PathSegment::CubicTo(_, _, _)))
}
fn is_stroke_only(path: &usvg::Path) -> bool {
path.fill().is_none() && path.stroke().is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_simple_mermaid_svg() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="70" viewBox="0 0 100 70"><rect x="0" y="0" width="100" height="70" fill="#FFFFFF"/><rect x="10" y="10" width="80" height="50" rx="3" ry="3" fill="#F8FAFF" stroke="#C7D2E5" stroke-width="1"/><text x="50" y="40" text-anchor="middle" font-family="sans-serif" font-size="13" fill="#1C2430"><tspan x="50" dy="0">Hello</tspan></text></svg>"##;
let elements = svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse svg");
assert!(
elements.len() >= 2,
"Expected at least 2 elements, got {}",
elements.len()
);
let has_rect = elements
.iter()
.any(|element| matches!(element.shape, Shape::Rectangle { .. }));
assert!(has_rect, "Expected a rectangle element");
let has_text = elements
.iter()
.any(|element| matches!(element.shape, Shape::Text { .. }));
assert!(has_text, "Expected a text element");
}
#[test]
fn applies_offset() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="70"><rect x="0" y="0" width="100" height="70" fill="#FFFFFF"/><rect x="10" y="10" width="80" height="50" fill="#F8FAFF" stroke="#C7D2E5"/></svg>"##;
let elements =
svg_to_drawing_elements(svg, 100.0, 200.0).expect("converter should parse svg");
if let Some(element) = elements.first() {
let rect = element.bounding_rect();
assert!(rect.min.x >= 100.0, "Expected x offset applied");
assert!(rect.min.y >= 200.0, "Expected y offset applied");
}
}
}

View File

@@ -1,5 +1,7 @@
mod converter;
mod parser;
mod renderer;
pub use converter::svg_to_drawing_elements;
pub use parser::parse_svg;
pub use renderer::SvgRenderer;

View File

@@ -11,7 +11,8 @@ fn generate_id() -> String {
}
pub fn parse_svg(svg_data: &str) -> Result<(ElementTree, Tree)> {
let options = usvg::Options::default();
let mut options = usvg::Options::default();
options.fontdb_mut().load_system_fonts();
let tree = Tree::from_str(svg_data, &options)?;
let size = tree.size();