diff --git a/plotly/Cargo.toml b/plotly/Cargo.toml index d2408b3b..41867f34 100644 --- a/plotly/Cargo.toml +++ b/plotly/Cargo.toml @@ -50,3 +50,4 @@ itertools-num = "0.1.3" ndarray = "0.16.0" plotly_kaleido = { version = "0.10.0", path = "../plotly_kaleido" } rand_distr = "0.4" +base64 = "0.22" diff --git a/plotly/src/plot.rs b/plotly/src/plot.rs index f1d1b300..1bbaacb1 100644 --- a/plotly/src/plot.rs +++ b/plotly/src/plot.rs @@ -417,6 +417,52 @@ impl Plot { .unwrap_or_else(|_| panic!("failed to export plot to {:?}", filename.as_ref())); } + /// Convert the `Plot` to a static image and return the image as a `base64` + /// String Supported formats are [ImageFormat::JPEG], [ImageFormat::PNG] + /// and [ImageFormat::WEBP] + #[cfg(feature = "kaleido")] + pub fn to_base64( + &self, + format: ImageFormat, + width: usize, + height: usize, + scale: f64, + ) -> String { + match format { + ImageFormat::JPEG | ImageFormat::PNG | ImageFormat::WEBP => { + let kaleido = plotly_kaleido::Kaleido::new(); + kaleido + .image_to_string( + &serde_json::to_value(self).unwrap(), + &format.to_string(), + width, + height, + scale, + ) + .unwrap_or_else(|_| panic!("Kaleido failed to generate image")) + } + _ => { + eprintln!("Cannot generate base64 string for ImageFormat:{format}. Allowed formats are JPEG, PNG, WEBP"); + String::default() + } + } + } + + /// Convert the `Plot` to SVG and return it as a String. + #[cfg(feature = "kaleido")] + pub fn to_svg(&self, width: usize, height: usize, scale: f64) -> String { + let kaleido = plotly_kaleido::Kaleido::new(); + kaleido + .image_to_string( + &serde_json::to_value(self).unwrap(), + "svg", + width, + height, + scale, + ) + .unwrap_or_else(|_| panic!("Kaleido failed to generate image")) + } + fn render(&self) -> String { let tmpl = PlotTemplate { plot: self, @@ -539,6 +585,7 @@ impl PartialEq for Plot { mod tests { use std::path::PathBuf; + use base64::{engine::general_purpose, Engine as _}; use serde_json::{json, to_value}; use super::*; @@ -773,4 +820,47 @@ mod tests { assert!(std::fs::remove_file(&dst).is_ok()); assert!(!dst.exists()); } + + #[cfg(target_os = "linux")] + #[test] + #[cfg(feature = "kaleido")] + fn test_image_to_base64() { + let plot = create_test_plot(); + + let image_base64 = plot.to_base64(ImageFormat::PNG, 200, 150, 1.0); + + assert!(!image_base64.is_empty()); + + let result_decoded = general_purpose::STANDARD.decode(image_base64).unwrap(); + let expected = "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"; + let expected_decoded = general_purpose::STANDARD.decode(expected).unwrap(); + + // Comparing the result seems to end up being a flaky test. + // Limit the comparison to the first characters; + // As image contents seem to be slightly inconsistent across platforms + assert_eq!(expected_decoded[..2], result_decoded[..2]); + } + + #[test] + #[cfg(feature = "kaleido")] + fn test_image_to_base64_invalid_format() { + let plot = create_test_plot(); + let image_base64 = plot.to_base64(ImageFormat::EPS, 200, 150, 1.0); + assert!(image_base64.is_empty()); + } + + #[cfg(target_os = "linux")] + #[test] + #[cfg(feature = "kaleido")] + fn test_image_to_svg_string() { + let plot = create_test_plot(); + let image_svg = plot.to_svg(200, 150, 1.0); + + assert!(!image_svg.is_empty()); + + let expected = "012246810"; + // Limit the test to the first LEN characters + const LEN: usize = 100; + assert_eq!(expected[..LEN], image_svg[..LEN]); + } } diff --git a/plotly_kaleido/src/lib.rs b/plotly_kaleido/src/lib.rs index f6641c25..6b2fee7b 100644 --- a/plotly_kaleido/src/lib.rs +++ b/plotly_kaleido/src/lib.rs @@ -122,6 +122,7 @@ impl Kaleido { Ok(p) } + /// Generate a static image from a Plotly graph and save it to a file pub fn save( &self, dst: &Path, @@ -134,6 +135,44 @@ impl Kaleido { let mut dst = PathBuf::from(dst); dst.set_extension(format); + let image_data = self.convert(plotly_data, format, width, height, scale)?; + let data: Vec = match format { + "svg" | "eps" => image_data.as_bytes().to_vec(), + _ => general_purpose::STANDARD.decode(image_data).unwrap(), + }; + let mut file = File::create(dst.as_path())?; + file.write_all(&data)?; + file.flush()?; + + Ok(()) + } + + /// Generate a static image from a Plotly graph and return it as a String + /// The output may be base64 encoded or a plain text depending on the image + /// format provided as argument. SVG and EPS are returned in plain text + /// while JPEG, PNG, WEBP will be returned as a base64 encoded string. + pub fn image_to_string( + &self, + plotly_data: &Value, + format: &str, + width: usize, + height: usize, + scale: f64, + ) -> Result> { + let image_data = self.convert(plotly_data, format, width, height, scale)?; + Ok(image_data) + } + + /// Convert the Plotly graph to a static image using Kaleido and return the + /// result as a String + pub fn convert( + &self, + plotly_data: &Value, + format: &str, + width: usize, + height: usize, + scale: f64, + ) -> Result> { let p = self.cmd_path.as_path(); let p = p.to_str().unwrap(); let p = String::from(p); @@ -168,17 +207,16 @@ impl Kaleido { for line in output_lines.map_while(Result::ok) { let res = KaleidoResult::from(line.as_str()); if let Some(image_data) = res.result { - let data: Vec = match format { - "svg" | "eps" => image_data.as_bytes().to_vec(), - _ => general_purpose::STANDARD.decode(image_data).unwrap(), - }; - let mut file = File::create(dst.as_path())?; - file.write_all(&data)?; - file.flush()?; + // TODO: this should be refactored + // The assumption is that KaleidoResult contains a single image. + // We should end the loop on the first valid one. + // If that is not the case, prior implementation would have returned the last + // valid image + return Ok(image_data); } } - Ok(()) + Ok(String::default()) } }