diff --git a/crates/agcanvas/src/agent/protocol.rs b/crates/agcanvas/src/agent/protocol.rs index b731e36..e7c0163 100644 --- a/crates/agcanvas/src/agent/protocol.rs +++ b/crates/agcanvas/src/agent/protocol.rs @@ -224,6 +224,15 @@ pub enum AgentRequest { #[serde(default)] height: Option, }, + ExportCanvas { + #[serde(default)] + session_id: Option, + path: String, + #[serde(default)] + scale: Option, + #[serde(default)] + background: Option, + }, Batch { requests: Vec, }, @@ -292,6 +301,14 @@ pub enum AgentResponse { session_id: String, overlay_id: String, svg_source: String, + #[serde(default)] + element_ids: Vec, + }, + CanvasExported { + session_id: String, + path: String, + width: u32, + height: u32, }, BatchResult { results: Vec, @@ -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, - }, } // --------------------------------------------------------------------------- diff --git a/crates/agcanvas/src/agent/server.rs b/crates/agcanvas/src/agent/server.rs index 7fc771c..7a973bd 100644 --- a/crates/agcanvas/src/agent/server.rs +++ b/crates/agcanvas/src/agent/server.rs @@ -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>, @@ -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) ); - if command_tx - .send(DrawingCommand::RenderMermaid { - session_id: sid.clone(), - overlay_id: overlay_id.clone(), - mermaid_source, - svg_source: svg_source.clone(), - position, - size, - }) - .is_err() + let mut element_ids = Vec::with_capacity(elements.len()); { - return AgentResponse::Error { - message: "Failed to enqueue Mermaid render command".to_string(), - }; + 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::Create { + session_id: sid.clone(), + element: element.clone(), + }) + .is_err() + { + return AgentResponse::Error { + 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(), diff --git a/crates/agcanvas/src/app.rs b/crates/agcanvas/src/app.rs index 1fed0a5..b72cf56 100644 --- a/crates/agcanvas/src/app.rs +++ b/crates/agcanvas/src/app.rs @@ -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, + Option, +); + pub struct AgCanvasApp { sessions: Vec, 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 = 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)> = self + let session_snapshots: Vec = 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 = 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 = 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( diff --git a/crates/agcanvas/src/drawing/mod.rs b/crates/agcanvas/src/drawing/mod.rs index 89fa352..db2ac9c 100644 --- a/crates/agcanvas/src/drawing/mod.rs +++ b/crates/agcanvas/src/drawing/mod.rs @@ -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}; diff --git a/crates/agcanvas/src/session.rs b/crates/agcanvas/src/session.rs index 39d6825..55578d3 100644 --- a/crates/agcanvas/src/session.rs +++ b/crates/agcanvas/src/session.rs @@ -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, - 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, - pub mermaid_overlays: Vec, pub selected_element_ids: Vec, 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, + pub svg_source: Option, pub drawing_elements: Vec, } +pub type ExportSessionData = (String, Option, Vec); + #[derive(Default)] pub struct SessionStore { sessions: HashMap, @@ -265,13 +252,19 @@ impl SessionStore { Self::default() } - pub fn add_session(&mut self, info: SessionInfo, tree: Option) { + pub fn add_session( + &mut self, + info: SessionInfo, + tree: Option, + svg_source: Option, + ) { 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) { + pub fn update_tree( + &mut self, + session_id: &str, + tree: Option, + svg_source: Option, + ) { 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) { + 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 { + 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); diff --git a/crates/agcanvas/src/svg/converter.rs b/crates/agcanvas/src/svg/converter.rs new file mode 100644 index 0000000..4cc90fb --- /dev/null +++ b/crates/agcanvas/src/svg/converter.rs @@ -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> { + 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) { + 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 { + 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 { + 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::>() + .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##"Hello"##; + 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##""##; + 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"); + } + } +} diff --git a/crates/agcanvas/src/svg/mod.rs b/crates/agcanvas/src/svg/mod.rs index f73ea5b..bf31881 100644 --- a/crates/agcanvas/src/svg/mod.rs +++ b/crates/agcanvas/src/svg/mod.rs @@ -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;