Compare commits
4 Commits
740fa2f5f9
...
64b4f667fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b4f667fb | ||
|
|
9e9d33eb84 | ||
|
|
519d1f2459 | ||
|
|
8390d01f85 |
@@ -203,6 +203,18 @@ pub struct RenderMermaidParam {
|
|||||||
pub height: Option<f32>,
|
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)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
pub struct BatchParam {
|
pub struct BatchParam {
|
||||||
#[schemars(
|
#[schemars(
|
||||||
@@ -565,6 +577,29 @@ impl AgCanvasServer {
|
|||||||
self.call_agcanvas(&request).await
|
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(
|
#[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."
|
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"
|
futures-util = "0.3"
|
||||||
|
|
||||||
# Mermaid diagram rendering
|
# 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
|
# Logging
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -224,6 +224,15 @@ pub enum AgentRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
height: Option<f32>,
|
height: Option<f32>,
|
||||||
},
|
},
|
||||||
|
ExportCanvas {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
scale: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
background: Option<String>,
|
||||||
|
},
|
||||||
Batch {
|
Batch {
|
||||||
requests: Vec<AgentRequest>,
|
requests: Vec<AgentRequest>,
|
||||||
},
|
},
|
||||||
@@ -292,6 +301,14 @@ pub enum AgentResponse {
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
overlay_id: String,
|
overlay_id: String,
|
||||||
svg_source: String,
|
svg_source: String,
|
||||||
|
#[serde(default)]
|
||||||
|
element_ids: Vec<String>,
|
||||||
|
},
|
||||||
|
CanvasExported {
|
||||||
|
session_id: String,
|
||||||
|
path: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
},
|
},
|
||||||
BatchResult {
|
BatchResult {
|
||||||
results: Vec<AgentResponse>,
|
results: Vec<AgentResponse>,
|
||||||
@@ -323,14 +340,6 @@ pub enum DrawingCommand {
|
|||||||
Clear {
|
Clear {
|
||||||
session_id: String,
|
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,
|
DrawingCommand, GuiEvent, SessionCommand,
|
||||||
};
|
};
|
||||||
use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle};
|
use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle};
|
||||||
|
use crate::export::ExportData;
|
||||||
use crate::session::{SessionCreator, SessionStore};
|
use crate::session::{SessionCreator, SessionStore};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
@@ -11,10 +12,9 @@ use std::sync::Arc;
|
|||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use usvg::Tree;
|
|
||||||
|
|
||||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
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 {
|
pub struct AgentServer {
|
||||||
sessions: Arc<RwLock<SessionStore>>,
|
sessions: Arc<RwLock<SessionStore>>,
|
||||||
@@ -237,7 +237,7 @@ async fn process_single_request(
|
|||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
};
|
};
|
||||||
|
|
||||||
store.add_session(info.clone(), None);
|
store.add_session(info.clone(), None, None);
|
||||||
drop(store);
|
drop(store);
|
||||||
|
|
||||||
let _ = session_command_tx.send(SessionCommand::Create {
|
let _ = session_command_tx.send(SessionCommand::Create {
|
||||||
@@ -690,8 +690,8 @@ async fn process_single_request(
|
|||||||
mermaid_source,
|
mermaid_source,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width,
|
width: _,
|
||||||
height,
|
height: _,
|
||||||
} => {
|
} => {
|
||||||
let sid = {
|
let sid = {
|
||||||
let store = sessions.read().await;
|
let store = sessions.read().await;
|
||||||
@@ -714,47 +714,104 @@ async fn process_single_request(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let options = usvg::Options::default();
|
let elements = match crate::svg::svg_to_drawing_elements(
|
||||||
let tree = match Tree::from_str(&svg_source, &options) {
|
&svg_source,
|
||||||
Ok(tree) => tree,
|
x.unwrap_or(0.0),
|
||||||
|
y.unwrap_or(0.0),
|
||||||
|
) {
|
||||||
|
Ok(elements) => elements,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return AgentResponse::Error {
|
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!(
|
let overlay_id = format!(
|
||||||
"mermaid_{}",
|
"mermaid_group_{}",
|
||||||
OVERLAY_ID_COUNTER.fetch_add(1, Ordering::SeqCst)
|
MERMAID_RENDER_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()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if command_tx
|
let mut element_ids = Vec::with_capacity(elements.len());
|
||||||
.send(DrawingCommand::RenderMermaid {
|
|
||||||
session_id: sid.clone(),
|
|
||||||
overlay_id: overlay_id.clone(),
|
|
||||||
mermaid_source,
|
|
||||||
svg_source: svg_source.clone(),
|
|
||||||
position,
|
|
||||||
size,
|
|
||||||
})
|
|
||||||
.is_err()
|
|
||||||
{
|
{
|
||||||
return AgentResponse::Error {
|
let mut store = sessions.write().await;
|
||||||
message: "Failed to enqueue Mermaid render command".to_string(),
|
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 {
|
AgentResponse::MermaidRendered {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
overlay_id,
|
overlay_id,
|
||||||
svg_source,
|
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 {
|
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 {
|
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
|
||||||
let description = crate::element_tree::ElementTree {
|
let description = crate::element_tree::ElementTree {
|
||||||
root: element.clone(),
|
root: element.clone(),
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ use crate::canvas::{CanvasInteraction, CanvasState};
|
|||||||
use crate::clipboard::ClipboardManager;
|
use crate::clipboard::ClipboardManager;
|
||||||
use crate::command_palette::{CommandId, CommandPalette};
|
use crate::command_palette::{CommandId, CommandPalette};
|
||||||
use crate::drawing::{
|
use crate::drawing::{
|
||||||
canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection,
|
draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
|
||||||
find_handle_at_screen_pos, screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle,
|
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
|
||||||
Tool,
|
|
||||||
};
|
};
|
||||||
|
use crate::export::{self, ExportData};
|
||||||
use crate::history::{ChangeSource, HistoryTree, NodeId};
|
use crate::history::{ChangeSource, HistoryTree, NodeId};
|
||||||
use crate::mermaid::render_mermaid_to_svg;
|
use crate::mermaid::render_mermaid_to_svg;
|
||||||
use crate::persistence::{self, SavedSession, SavedWorkspace};
|
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 crate::svg::{parse_svg, SvgRenderer};
|
||||||
use egui::{Color32, ColorImage, TextureOptions};
|
use egui::{Color32, ColorImage, TextureOptions};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -21,6 +21,12 @@ use tokio::sync::{broadcast, mpsc, RwLock};
|
|||||||
const AGENT_PORT: u16 = 9876;
|
const AGENT_PORT: u16 = 9876;
|
||||||
const MIN_SHAPE_SIZE: f32 = 5.0;
|
const MIN_SHAPE_SIZE: f32 = 5.0;
|
||||||
|
|
||||||
|
type SessionExportSnapshot = (
|
||||||
|
String,
|
||||||
|
Vec<DrawingElement>,
|
||||||
|
Option<String>,
|
||||||
|
);
|
||||||
|
|
||||||
pub struct AgCanvasApp {
|
pub struct AgCanvasApp {
|
||||||
sessions: Vec<Session>,
|
sessions: Vec<Session>,
|
||||||
active_session_idx: usize,
|
active_session_idx: usize,
|
||||||
@@ -112,7 +118,7 @@ impl AgCanvasApp {
|
|||||||
let rt = Runtime::new().unwrap();
|
let rt = Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let mut store = sessions_handle.write().await;
|
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);
|
store.set_active(&info_clone.id);
|
||||||
});
|
});
|
||||||
let _ = event_tx.send(GuiEvent::SessionCreated {
|
let _ = event_tx.send(GuiEvent::SessionCreated {
|
||||||
@@ -205,12 +211,13 @@ impl AgCanvasApp {
|
|||||||
let sessions_handle = self.sessions_handle.clone();
|
let sessions_handle = self.sessions_handle.clone();
|
||||||
let event_tx = self.event_tx.clone();
|
let event_tx = self.event_tx.clone();
|
||||||
let tree_clone = tree.clone();
|
let tree_clone = tree.clone();
|
||||||
|
let svg_source = svg_data.to_string();
|
||||||
let sid = session_id.clone();
|
let sid = session_id.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = Runtime::new().unwrap();
|
let rt = Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let mut store = sessions_handle.write().await;
|
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 {
|
let _ = event_tx.send(GuiEvent::SvgLoaded {
|
||||||
session_id,
|
session_id,
|
||||||
@@ -256,7 +263,7 @@ impl AgCanvasApp {
|
|||||||
let rt = Runtime::new().unwrap();
|
let rt = Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let mut store = sessions_handle.write().await;
|
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 });
|
let _ = event_tx.send(GuiEvent::SvgCleared { session_id });
|
||||||
});
|
});
|
||||||
@@ -291,44 +298,7 @@ impl AgCanvasApp {
|
|||||||
self.status_message = Some((message, std::time::Instant::now()));
|
self.status_message = Some((message, std::time::Instant::now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_mermaid_overlays_to_texture(&mut self, ctx: &egui::Context) {
|
fn handle_mermaid_render(&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) {
|
|
||||||
let source = self.mermaid_input.trim().to_string();
|
let source = self.mermaid_input.trim().to_string();
|
||||||
if source.is_empty() {
|
if source.is_empty() {
|
||||||
self.set_status("Mermaid input is empty".to_string());
|
self.set_status("Mermaid input is empty".to_string());
|
||||||
@@ -336,11 +306,23 @@ impl AgCanvasApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match render_mermaid_to_svg(&source) {
|
match render_mermaid_to_svg(&source) {
|
||||||
Ok(svg_string) => {
|
Ok(svg_source) => {
|
||||||
self.show_mermaid_dialog = false;
|
self.show_mermaid_dialog = false;
|
||||||
self.mermaid_input.clear();
|
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) => {
|
Err(e) => {
|
||||||
self.set_status(format!("Mermaid error: {}", e));
|
self.set_status(format!("Mermaid error: {}", e));
|
||||||
@@ -402,38 +384,6 @@ impl AgCanvasApp {
|
|||||||
.record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None });
|
.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) {
|
fn sync_drawing_elements_to_store(&self) {
|
||||||
let sessions_handle = self.sessions_handle.clone();
|
let sessions_handle = self.sessions_handle.clone();
|
||||||
let elements_by_session: Vec<(String, Vec<DrawingElement>)> = self
|
let session_snapshots: Vec<SessionExportSnapshot> = self
|
||||||
.sessions
|
.sessions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| (s.id.clone(), s.drawing_elements.clone()))
|
.map(|s| (s.id.clone(), s.drawing_elements.clone(), s.svg_source.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = Runtime::new().unwrap();
|
let rt = Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let mut store = sessions_handle.write().await;
|
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_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 {
|
fn build_saved_workspace(&self) -> SavedWorkspace {
|
||||||
let sessions: Vec<SavedSession> = self
|
let sessions: Vec<SavedSession> = self
|
||||||
.sessions
|
.sessions
|
||||||
@@ -557,6 +531,7 @@ impl AgCanvasApp {
|
|||||||
|
|
||||||
let info = session.info();
|
let info = session.info();
|
||||||
let tree_clone = session.element_tree.clone();
|
let tree_clone = session.element_tree.clone();
|
||||||
|
let svg_source_clone = saved.svg_source.clone();
|
||||||
self.sessions.push(session);
|
self.sessions.push(session);
|
||||||
|
|
||||||
let sessions_handle = self.sessions_handle.clone();
|
let sessions_handle = self.sessions_handle.clone();
|
||||||
@@ -565,7 +540,7 @@ impl AgCanvasApp {
|
|||||||
let rt = Runtime::new().unwrap();
|
let rt = Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let mut store = sessions_handle.write().await;
|
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::ClearCanvas => self.clear_canvas(),
|
||||||
CommandId::PasteSvg => self.handle_paste(ctx),
|
CommandId::PasteSvg => self.handle_paste(ctx),
|
||||||
CommandId::PasteMermaid => self.show_mermaid_dialog = true,
|
CommandId::PasteMermaid => self.show_mermaid_dialog = true,
|
||||||
|
CommandId::ExportPng => self.export_canvas_png(),
|
||||||
CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select,
|
CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select,
|
||||||
CommandId::ToolPan => self.active_session_mut().active_tool = Tool::Pan,
|
CommandId::ToolPan => self.active_session_mut().active_tool = Tool::Pan,
|
||||||
CommandId::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle,
|
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 toggle_history = false;
|
||||||
let mut undo = false;
|
let mut undo = false;
|
||||||
let mut redo = false;
|
let mut redo = false;
|
||||||
|
let mut export_png = false;
|
||||||
let mut tool_switch: Option<Tool> = None;
|
let mut tool_switch: Option<Tool> = None;
|
||||||
|
|
||||||
let palette_open = self.command_palette.visible;
|
let palette_open = self.command_palette.visible;
|
||||||
@@ -962,6 +939,9 @@ impl eframe::App for AgCanvasApp {
|
|||||||
undo = true;
|
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) {
|
if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) {
|
||||||
delete_selected = true;
|
delete_selected = true;
|
||||||
}
|
}
|
||||||
@@ -1011,6 +991,9 @@ impl eframe::App for AgCanvasApp {
|
|||||||
if toggle_history {
|
if toggle_history {
|
||||||
self.show_history_panel = !self.show_history_panel;
|
self.show_history_panel = !self.show_history_panel;
|
||||||
}
|
}
|
||||||
|
if export_png {
|
||||||
|
self.export_canvas_png();
|
||||||
|
}
|
||||||
if delete_selected
|
if delete_selected
|
||||||
&& !self.show_text_input
|
&& !self.show_text_input
|
||||||
&& !self.show_mermaid_dialog
|
&& !self.show_mermaid_dialog
|
||||||
@@ -1102,14 +1085,6 @@ impl eframe::App for AgCanvasApp {
|
|||||||
{
|
{
|
||||||
self.render_svg_to_texture(ctx);
|
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::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||||
egui::menu::bar(ui, |ui| {
|
egui::menu::bar(ui, |ui| {
|
||||||
@@ -1133,6 +1108,11 @@ impl eframe::App for AgCanvasApp {
|
|||||||
ui.close_menu();
|
ui.close_menu();
|
||||||
}
|
}
|
||||||
ui.separator();
|
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() {
|
if ui.button("Clear Canvas").clicked() {
|
||||||
self.clear_canvas();
|
self.clear_canvas();
|
||||||
ui.close_menu();
|
ui.close_menu();
|
||||||
@@ -1577,21 +1557,6 @@ impl eframe::App for AgCanvasApp {
|
|||||||
let offset = self.active_session().canvas_state.offset;
|
let offset = self.active_session().canvas_state.offset;
|
||||||
let zoom = self.active_session().canvas_state.zoom;
|
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(
|
draw_elements(
|
||||||
&painter,
|
&painter,
|
||||||
&self.active_session().drawing_elements,
|
&self.active_session().drawing_elements,
|
||||||
@@ -1621,7 +1586,6 @@ impl eframe::App for AgCanvasApp {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if self.active_session().svg_texture.is_none()
|
if self.active_session().svg_texture.is_none()
|
||||||
&& self.active_session().mermaid_overlays.is_empty()
|
|
||||||
&& self.active_session().drawing_elements.is_empty()
|
&& self.active_session().drawing_elements.is_empty()
|
||||||
{
|
{
|
||||||
painter.text(
|
painter.text(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub enum CommandId {
|
|||||||
ClearCanvas,
|
ClearCanvas,
|
||||||
PasteSvg,
|
PasteSvg,
|
||||||
PasteMermaid,
|
PasteMermaid,
|
||||||
|
ExportPng,
|
||||||
ToolSelect,
|
ToolSelect,
|
||||||
ToolPan,
|
ToolPan,
|
||||||
ToolRectangle,
|
ToolRectangle,
|
||||||
@@ -68,6 +69,12 @@ pub fn all_commands() -> Vec<PaletteCommand> {
|
|||||||
None,
|
None,
|
||||||
"Canvas",
|
"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::ToolSelect, "Select Tool", Some("V"), "Tool"),
|
||||||
PaletteCommand::new(CommandId::ToolPan, "Pan Tool", Some("H"), "Tool"),
|
PaletteCommand::new(CommandId::ToolPan, "Pan Tool", Some("H"), "Tool"),
|
||||||
PaletteCommand::new(
|
PaletteCommand::new(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ mod tool;
|
|||||||
pub use boolean::BooleanOpType;
|
pub use boolean::BooleanOpType;
|
||||||
pub use element::{DrawingElement, Shape, ShapeStyle};
|
pub use element::{DrawingElement, Shape, ShapeStyle};
|
||||||
pub use render::{
|
pub use render::{
|
||||||
canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection,
|
draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
|
||||||
find_handle_at_screen_pos, screen_to_canvas,
|
screen_to_canvas,
|
||||||
};
|
};
|
||||||
pub use tool::{DragState, Tool};
|
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 command_palette;
|
||||||
mod drawing;
|
mod drawing;
|
||||||
mod element_tree;
|
mod element_tree;
|
||||||
|
mod export;
|
||||||
mod history;
|
mod history;
|
||||||
mod mermaid;
|
mod mermaid;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
|
|||||||
@@ -1,9 +1,67 @@
|
|||||||
|
use std::panic;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use mermaid_rs_renderer::RenderOptions;
|
||||||
|
|
||||||
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
|
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
|
||||||
let svg = mermaid_rs_renderer::render(mermaid_source)
|
let source = mermaid_source.to_string();
|
||||||
.map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?;
|
|
||||||
Ok(svg)
|
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)]
|
#[cfg(test)]
|
||||||
@@ -21,4 +79,23 @@ mod tests {
|
|||||||
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
|
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
|
||||||
assert!(svg.contains("<svg"));
|
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 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 struct Session {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -98,7 +88,6 @@ pub struct Session {
|
|||||||
pub description_text: String,
|
pub description_text: String,
|
||||||
|
|
||||||
pub drawing_elements: Vec<DrawingElement>,
|
pub drawing_elements: Vec<DrawingElement>,
|
||||||
pub mermaid_overlays: Vec<MermaidOverlay>,
|
|
||||||
pub selected_element_ids: Vec<String>,
|
pub selected_element_ids: Vec<String>,
|
||||||
pub active_tool: Tool,
|
pub active_tool: Tool,
|
||||||
pub drag_state: DragState,
|
pub drag_state: DragState,
|
||||||
@@ -121,7 +110,6 @@ impl Session {
|
|||||||
svg_source: None,
|
svg_source: None,
|
||||||
description_text: String::new(),
|
description_text: String::new(),
|
||||||
drawing_elements: Vec::new(),
|
drawing_elements: Vec::new(),
|
||||||
mermaid_overlays: Vec::new(),
|
|
||||||
selected_element_ids: Vec::new(),
|
selected_element_ids: Vec::new(),
|
||||||
active_tool: Tool::default(),
|
active_tool: Tool::default(),
|
||||||
drag_state: DragState::default(),
|
drag_state: DragState::default(),
|
||||||
@@ -157,7 +145,6 @@ impl Session {
|
|||||||
self.svg_source = None;
|
self.svg_source = None;
|
||||||
self.description_text.clear();
|
self.description_text.clear();
|
||||||
self.drawing_elements.clear();
|
self.drawing_elements.clear();
|
||||||
self.mermaid_overlays.clear();
|
|
||||||
self.selected_element_ids.clear();
|
self.selected_element_ids.clear();
|
||||||
self.drag_state = DragState::default();
|
self.drag_state = DragState::default();
|
||||||
self.canvas_state.reset();
|
self.canvas_state.reset();
|
||||||
@@ -206,7 +193,6 @@ impl Session {
|
|||||||
self.svg_renderer = None;
|
self.svg_renderer = None;
|
||||||
self.svg_texture = None;
|
self.svg_texture = None;
|
||||||
self.description_text.clear();
|
self.description_text.clear();
|
||||||
self.mermaid_overlays.clear();
|
|
||||||
self.selected_element_ids.clear();
|
self.selected_element_ids.clear();
|
||||||
self.drag_state = DragState::default();
|
self.drag_state = DragState::default();
|
||||||
}
|
}
|
||||||
@@ -219,7 +205,6 @@ impl Session {
|
|||||||
self.svg_renderer = None;
|
self.svg_renderer = None;
|
||||||
self.svg_texture = None;
|
self.svg_texture = None;
|
||||||
self.description_text.clear();
|
self.description_text.clear();
|
||||||
self.mermaid_overlays.clear();
|
|
||||||
self.selected_element_ids.clear();
|
self.selected_element_ids.clear();
|
||||||
self.drag_state = DragState::default();
|
self.drag_state = DragState::default();
|
||||||
true
|
true
|
||||||
@@ -236,7 +221,6 @@ impl Session {
|
|||||||
self.svg_renderer = None;
|
self.svg_renderer = None;
|
||||||
self.svg_texture = None;
|
self.svg_texture = None;
|
||||||
self.description_text.clear();
|
self.description_text.clear();
|
||||||
self.mermaid_overlays.clear();
|
|
||||||
self.selected_element_ids.clear();
|
self.selected_element_ids.clear();
|
||||||
self.drag_state = DragState::default();
|
self.drag_state = DragState::default();
|
||||||
true
|
true
|
||||||
@@ -250,9 +234,12 @@ impl Session {
|
|||||||
pub struct SessionData {
|
pub struct SessionData {
|
||||||
pub info: SessionInfo,
|
pub info: SessionInfo,
|
||||||
pub tree: Option<ElementTree>,
|
pub tree: Option<ElementTree>,
|
||||||
|
pub svg_source: Option<String>,
|
||||||
pub drawing_elements: Vec<DrawingElement>,
|
pub drawing_elements: Vec<DrawingElement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type ExportSessionData = (String, Option<String>, Vec<DrawingElement>);
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct SessionStore {
|
pub struct SessionStore {
|
||||||
sessions: HashMap<String, SessionData>,
|
sessions: HashMap<String, SessionData>,
|
||||||
@@ -265,13 +252,19 @@ impl SessionStore {
|
|||||||
Self::default()
|
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();
|
let id = info.id.clone();
|
||||||
self.sessions.insert(
|
self.sessions.insert(
|
||||||
id.clone(),
|
id.clone(),
|
||||||
SessionData {
|
SessionData {
|
||||||
info,
|
info,
|
||||||
tree,
|
tree,
|
||||||
|
svg_source,
|
||||||
drawing_elements: Vec::new(),
|
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) {
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
data.info.has_svg = tree.is_some();
|
data.info.has_svg = tree.is_some();
|
||||||
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
|
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
|
||||||
data.tree = tree;
|
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(
|
pub fn get_drawing_elements(
|
||||||
&self,
|
&self,
|
||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
@@ -327,6 +332,14 @@ impl SessionStore {
|
|||||||
Some((id, &data.drawing_elements))
|
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 {
|
pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool {
|
||||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
data.drawing_elements.push(element);
|
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 parser;
|
||||||
mod renderer;
|
mod renderer;
|
||||||
|
|
||||||
|
pub use converter::svg_to_drawing_elements;
|
||||||
pub use parser::parse_svg;
|
pub use parser::parse_svg;
|
||||||
pub use renderer::SvgRenderer;
|
pub use renderer::SvgRenderer;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ fn generate_id() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_svg(svg_data: &str) -> Result<(ElementTree, Tree)> {
|
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 tree = Tree::from_str(svg_data, &options)?;
|
||||||
|
|
||||||
let size = tree.size();
|
let size = tree.size();
|
||||||
|
|||||||
Reference in New Issue
Block a user