diff --git a/Cargo.toml b/Cargo.toml index 1dcfbca8..a8b5c0e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,9 @@ cfg-if = "0.1" parking_lot = "0.10" +nsvg = "0.5.1" +image = "0.23" + [dependencies.futures-util] version = "0.3" default-features = false diff --git a/docs/assets/images/docs/squares.png b/docs/assets/images/docs/squares.png new file mode 100644 index 00000000..e472e6d2 Binary files /dev/null and b/docs/assets/images/docs/squares.png differ diff --git a/src/async_drawing.rs b/src/async_drawing.rs index 42931ea1..f10cabdf 100644 --- a/src/async_drawing.rs +++ b/src/async_drawing.rs @@ -174,6 +174,10 @@ impl AsyncDrawing { self.client.export_svg(path.as_ref().to_path_buf()).await } + pub async fn save_png>(&self, path: P) -> Result<(), ExportError> { + self.client.export_png(path.as_ref().to_path_buf()).await + } + //TODO: If we move to a shared memory architecture, we wouldn't need to make // any request here and thus would not need this method at all. We should // think things through before making this method public. diff --git a/src/drawing.rs b/src/drawing.rs index 014e9eb1..5934ec20 100644 --- a/src/drawing.rs +++ b/src/drawing.rs @@ -627,6 +627,51 @@ impl Drawing { pub fn save_svg>(&self, path: P) -> Result<(), ExportError> { block_on(self.drawing.save_svg(path)) } + + /// Saves the current drawings in SVG format at the location specified by `path`. + /// + /// ```rust,no_run + /// use turtle::{Drawing, Turtle, Color, ExportError}; + /// + /// fn main() -> Result<(), ExportError> { + /// let mut drawing = Drawing::new(); + /// let mut turtle = drawing.add_turtle(); + /// drawing.set_background_color("pink"); + /// + /// for i in 0..36 { + /// let base_color: Color = if i % 2 == 0 { + /// "red".into() + /// } else { + /// "white".into() + /// }; + /// + /// turtle.set_fill_color(base_color.with_alpha(1.0 - i as f64 / 54.0)); + /// turtle.begin_fill(); + /// square(&mut turtle); + /// turtle.end_fill(); + /// turtle.right(10.0); + /// } + /// + /// turtle.hide(); + /// drawing.save_png("squares.png")?; + /// + /// Ok(()) + /// } + /// + /// fn square(turtle: &mut Turtle) { + /// for _ in 0..4 { + /// turtle.forward(200.0); + /// turtle.right(90.0); + /// } + /// } + /// ``` + /// + /// This will produce the following image in the current directory under the name `squares.png`: + /// + /// ![squares](https://raw.githubusercontent.com/sunjay/turtle/master/docs/assets/images/docs/squares.png?sanitize=true) + pub fn save_png>(&self, path: P) -> Result<(), ExportError> { + block_on(self.drawing.save_png(path)) + } } #[cfg(test)] diff --git a/src/ipc_protocol/messages.rs b/src/ipc_protocol/messages.rs index 0fdb1f18..a084ba8f 100644 --- a/src/ipc_protocol/messages.rs +++ b/src/ipc_protocol/messages.rs @@ -194,6 +194,7 @@ pub enum RotationDirection { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ExportFormat { Svg, + Png, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/ipc_protocol/protocol.rs b/src/ipc_protocol/protocol.rs index 6e13beb5..53374483 100644 --- a/src/ipc_protocol/protocol.rs +++ b/src/ipc_protocol/protocol.rs @@ -62,6 +62,16 @@ impl ProtocolClient { } } + pub async fn export_png(&self, path: PathBuf) -> Result<(), ExportError> { + self.client.send(ClientRequest::Export(path, ExportFormat::Png)); + + let response = self.client.recv().await; + match response { + ServerResponse::ExportComplete(res) => res, + _ => unreachable!("bug: expected to receive `ExportComplete` in response to `Export` request"), + } + } + pub async fn poll_event(&self) -> Option { self.client.send(ClientRequest::PollEvent); diff --git a/src/renderer_server/handlers/export_drawings.rs b/src/renderer_server/handlers/export_drawings.rs index a69e1a01..a4fb2009 100644 --- a/src/renderer_server/handlers/export_drawings.rs +++ b/src/renderer_server/handlers/export_drawings.rs @@ -20,6 +20,7 @@ pub(crate) fn export_drawings( use ExportFormat::*; let res = match format { Svg => export::save_svg(display_list, drawing, path), + Png => export::save_png(display_list, drawing, path), }; conn.send(ServerResponse::ExportComplete(res))?; diff --git a/src/renderer_server/renderer/export.rs b/src/renderer_server/renderer/export.rs index 4fca380a..a23bbfbf 100644 --- a/src/renderer_server/renderer/export.rs +++ b/src/renderer_server/renderer/export.rs @@ -4,6 +4,8 @@ use std::path::Path as FilePath; use thiserror::Error; use serde::{Serialize, Deserialize}; use svg::node::element::{Line, Polygon, Rectangle}; +use image; +use nsvg; use crate::Color; @@ -13,10 +15,10 @@ use super::super::{ state::DrawingState, }; -/// Converts a color to its RGBA color string (suitable for SVG) -fn rgba(color: Color) -> String { - let Color {red, green, blue, alpha} = color; - format!("rgba({}, {}, {}, {})", red as u8, green as u8, blue as u8, alpha) +/// Converts a color to its RGB color string (suitable for SVG) +fn rgb(color: Color) -> String { + let Color {red, green, blue, alpha: _} = color; + format!("rgb({}, {}, {})", red as u8, green as u8, blue as u8) } /// Converts a value into a string with the unit "px" @@ -43,11 +45,10 @@ fn pairs(mut items: impl Iterator) -> String { #[error("{0}")] pub struct ExportError(String); -pub fn save_svg( +fn create_svg_document( display_list: &DisplayList, drawing: &DrawingState, - path: &FilePath, -) -> Result<(), ExportError> { +) -> Result { let mut document = svg::Document::new() .set("viewBox", (0, 0, drawing.width, drawing.height)); @@ -55,7 +56,8 @@ pub fn save_svg( let background = Rectangle::new() .set("width", "100%") .set("height", "100%") - .set("fill", rgba(drawing.background)); + .set("fill-opacity", drawing.background.alpha) + .set("fill", rgb(drawing.background)); document = document.add(background); let center = drawing.center; @@ -76,7 +78,8 @@ pub fn save_svg( .set("y2", end.y) .set("stroke-linecap", "round") .set("stroke-linejoin", "round") - .set("stroke", rgba(color)) + .set("stroke", rgb(color)) + .set("stroke-opacity", color.alpha) .set("stroke-width", px(thickness)); document = document.add(line); @@ -93,12 +96,54 @@ pub fn save_svg( let polygon = Polygon::new() .set("points", pairs(points)) .set("fill-rule", "nonzero") - .set("fill", rgba(fill_color)); + .set("fill-opacity", fill_color.alpha) + .set("fill", rgb(fill_color)); document = document.add(polygon); }, } } + Ok(document) +} + + +pub fn save_svg( + display_list: &DisplayList, + drawing: &DrawingState, + path: &FilePath, +) -> Result<(), ExportError> { + let document = create_svg_document(display_list, drawing)?; svg::save(path, &document).map_err(|err| ExportError(err.to_string())) } + +pub fn save_png( + display_list: &DisplayList, + drawing: &DrawingState, + path: &FilePath, +) -> Result<(), ExportError> { + let document = create_svg_document(display_list, drawing)?; + + // Some SVG features are not supported by nsvg: + // - Text elements are ignored, although text can simply be converted to a path and it will work just fine + // - Embedded bitmap images are ignored + // - Scripts are ignored + // - Animations are ignored + // If this becomes an issue we can "usvg" to convert the svg into a svg of just paths. + let svg = nsvg::parse_str(&document.to_string(), nsvg::Units::Pixel, 96.0) + .map_err(|err| ExportError(err.to_string()))?; + + // Rasterize the loaded SVG and return an RgbaImage + let image = svg.rasterize(1.0).map_err(|err| ExportError(err.to_string()))?; + + let (width, height) = image.dimensions(); + + // Write the image to disk as a PNG + image::save_buffer( + path, + &image.into_raw(), + width, + height, + image::ColorType::Rgba8, + ).map_err(|err| ExportError(err.to_string())) +} \ No newline at end of file