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>,
|
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."
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user