Compare commits
4 Commits
740fa2f5f9
...
64b4f667fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b4f667fb | ||
|
|
9e9d33eb84 | ||
|
|
519d1f2459 | ||
|
|
8390d01f85 |
@@ -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."
|
||||
)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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};
|
||||
|
||||
442
crates/agcanvas/src/export.rs
Normal file
442
crates/agcanvas/src/export.rs
Normal 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("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => 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();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod clipboard;
|
||||
mod command_palette;
|
||||
mod drawing;
|
||||
mod element_tree;
|
||||
mod export;
|
||||
mod history;
|
||||
mod mermaid;
|
||||
mod persistence;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
303
crates/agcanvas/src/svg/converter.rs
Normal file
303
crates/agcanvas/src/svg/converter.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user