feat: add canvas export to PNG via File menu, Cmd+Shift+E, and MCP tool

This commit is contained in:
David Ibia
2026-02-10 17:05:36 +01:00
parent 8390d01f85
commit 519d1f2459
4 changed files with 485 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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