From 519d1f24591af477509dceca4885c33578c65e0b Mon Sep 17 00:00:00 2001 From: David Ibia Date: Tue, 10 Feb 2026 17:05:36 +0100 Subject: [PATCH] feat: add canvas export to PNG via File menu, Cmd+Shift+E, and MCP tool --- crates/agcanvas-mcp/src/tools.rs | 35 ++ crates/agcanvas/src/command_palette.rs | 7 + crates/agcanvas/src/export.rs | 442 +++++++++++++++++++++++++ crates/agcanvas/src/main.rs | 1 + 4 files changed, 485 insertions(+) create mode 100644 crates/agcanvas/src/export.rs diff --git a/crates/agcanvas-mcp/src/tools.rs b/crates/agcanvas-mcp/src/tools.rs index 6874847..db493f8 100644 --- a/crates/agcanvas-mcp/src/tools.rs +++ b/crates/agcanvas-mcp/src/tools.rs @@ -203,6 +203,18 @@ pub struct RenderMermaidParam { pub height: Option, } +#[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, + #[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, + #[schemars(description = "Background color as hex e.g. '#1a1a2e' (default dark canvas color)")] + pub background: Option, +} + #[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, + ) -> Result { + 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." )] diff --git a/crates/agcanvas/src/command_palette.rs b/crates/agcanvas/src/command_palette.rs index 3633e0e..f0cc37b 100644 --- a/crates/agcanvas/src/command_palette.rs +++ b/crates/agcanvas/src/command_palette.rs @@ -10,6 +10,7 @@ pub enum CommandId { ClearCanvas, PasteSvg, PasteMermaid, + ExportPng, ToolSelect, ToolPan, ToolRectangle, @@ -68,6 +69,12 @@ pub fn all_commands() -> Vec { 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( diff --git a/crates/agcanvas/src/export.rs b/crates/agcanvas/src/export.rs new file mode 100644 index 0000000..5b6e7a1 --- /dev/null +++ b/crates/agcanvas/src/export.rs @@ -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, + pub drawing_elements: Vec, + 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 { + let mut bounds: Option = 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 { + 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 { + 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 { + 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#"{text}"#, + 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 { + 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(); + } +} diff --git a/crates/agcanvas/src/main.rs b/crates/agcanvas/src/main.rs index 1780bfa..5f27dac 100644 --- a/crates/agcanvas/src/main.rs +++ b/crates/agcanvas/src/main.rs @@ -5,6 +5,7 @@ mod clipboard; mod command_palette; mod drawing; mod element_tree; +mod export; mod history; mod mermaid; mod persistence;