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