Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose image data as String with to_b64 and to_svg using Kaleido #251

Merged
merged 10 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plotly/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
87 changes: 66 additions & 21 deletions plotly/src/plot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,49 +417,50 @@ impl Plot {
.unwrap_or_else(|_| panic!("failed to export plot to {:?}", filename.as_ref()));
}

// similar to write_image, but returns b64 string
/// 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")]
andrei-ng marked this conversation as resolved.
Show resolved Hide resolved
pub fn to_b64(
pub fn to_base64(
&self,
format: ImageFormat,
width: usize,
height: usize,
scale: f64,
) -> Result<String, String> {
) -> String {
match format {
ImageFormat::JPEG => {}
ImageFormat::PNG => {}
ImageFormat::WEBP => {}
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"))
}
_ => {
return Err("Format can only be JPEG, PNG, WEBP are allowed".into());
eprintln!("Cannot generate base64 string for ImageFormat:{format}. Allowed formats are JPEG, PNG, WEBP");
String::default()
}
}
let kaleido = plotly_kaleido::Kaleido::new();
let output = kaleido
.get_image_data(
&serde_json::to_value(self).unwrap(),
&format.to_string(),
width,
height,
scale,
)
.unwrap_or_else(|_| panic!("failed to generate b64"));
Ok(output)
}

// similar to write_image, but returns svg contents
/// 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
.get_image_data(
.image_to_string(
&serde_json::to_value(self).unwrap(),
"svg",
width,
height,
scale,
)
.unwrap_or_else(|_| panic!("failed to generate b64"))
.unwrap_or_else(|_| panic!("Kaleido failed to generate image"))
}

fn render(&self) -> String {
Expand Down Expand Up @@ -584,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::*;
Expand Down Expand Up @@ -818,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 = "<svg class=\"main-svg\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"200\" height=\"150\" style=\"\" viewBox=\"0 0 200 150\"><rect x=\"0\" y=\"0\" width=\"200\" height=\"150\" style=\"fill: rgb(255, 255, 255); fill-opacity: 1;\"/><defs id=\"defs-2dc70a\"><g class=\"clips\"><clipPath id=\"clip2dc70axyplot\" class=\"plotclip\"><rect width=\"40\" height=\"2\"/></clipPath><clipPath class=\"axesclip\" id=\"clip2dc70ax\"><rect x=\"80\" y=\"0\" width=\"40\" height=\"150\"/></clipPath><clipPath class=\"axesclip\" id=\"clip2dc70ay\"><rect x=\"0\" y=\"82\" width=\"200\" height=\"2\"/></clipPath><clipPath class=\"axesclip\" id=\"clip2dc70axy\"><rect x=\"80\" y=\"82\" width=\"40\" height=\"2\"/></clipPath></g><g class=\"gradients\"/></defs><g class=\"bglayer\"/><g class=\"layer-below\"><g class=\"imagelayer\"/><g class=\"shapelayer\"/></g><g class=\"cartesianlayer\"><g class=\"subplot xy\"><g class=\"layer-subplot\"><g class=\"shapelayer\"/><g class=\"imagelayer\"/></g><g class=\"gridlayer\"><g class=\"x\"><path class=\"xgrid crisp\" transform=\"translate(100,0)\" d=\"M0,82v2\" style=\"stroke: rgb(238, 238, 238); stroke-opacity: 1; stroke-width: 1px;\"/><path class=\"xgrid crisp\" transform=\"translate(114.25,0)\" d=\"M0,82v2\" style=\"stroke: rgb(238, 238, 238); stroke-opacity: 1; stroke-width: 1px;\"/></g><g class=\"y\"/></g><g class=\"zerolinelayer\"><path class=\"xzl zl crisp\" transform=\"translate(85.75,0)\" d=\"M0,82v2\" style=\"stroke: rgb(68, 68, 68); stroke-opacity: 1; stroke-width: 1px;\"/></g><path class=\"xlines-below\"/><path class=\"ylines-below\"/><g class=\"overlines-below\"/><g class=\"xaxislayer-below\"/><g class=\"yaxislayer-below\"/><g class=\"overaxes-below\"/><g class=\"plot\" transform=\"translate(80,82)\" clip-path=\"url('#clip2dc70axyplot')\"><g class=\"scatterlayer mlayer\"><g class=\"trace scatter trace86f735\" style=\"stroke-miterlimit: 2; opacity: 1;\"><g class=\"fills\"/><g class=\"errorbars\"/><g class=\"lines\"><path class=\"js-line\" d=\"M5.75,1L20,0L34.25,2\" style=\"vector-effect: non-scaling-stroke; fill: none; stroke: rgb(31, 119, 180); stroke-opacity: 1; stroke-width: 2px; opacity: 1;\"/></g><g class=\"points\"><path class=\"point\" transform=\"translate(5.75,1)\" d=\"M3,0A3,3 0 1,1 0,-3A3,3 0 0,1 3,0Z\" style=\"opacity: 1; stroke-width: 0px; fill: rgb(31, 119, 180); fill-opacity: 1;\"/><path class=\"point\" transform=\"translate(20,0)\" d=\"M3,0A3,3 0 1,1 0,-3A3,3 0 0,1 3,0Z\" style=\"opacity: 1; stroke-width: 0px; fill: rgb(31, 119, 180); fill-opacity: 1;\"/><path class=\"point\" transform=\"translate(34.25,2)\" d=\"M3,0A3,3 0 1,1 0,-3A3,3 0 0,1 3,0Z\" style=\"opacity: 1; stroke-width: 0px; fill: rgb(31, 119, 180); fill-opacity: 1;\"/></g><g class=\"text\"/></g></g></g><g class=\"overplot\"/><path class=\"xlines-above crisp\" d=\"M0,0\" style=\"fill: none;\"/><path class=\"ylines-above crisp\" d=\"M0,0\" style=\"fill: none;\"/><g class=\"overlines-above\"/><g class=\"xaxislayer-above\"><g class=\"xtick\"><text text-anchor=\"middle\" x=\"0\" y=\"97\" transform=\"translate(85.75,0)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">0</text></g><g class=\"xtick\"><text text-anchor=\"middle\" x=\"0\" y=\"97\" transform=\"translate(100,0)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">1</text></g><g class=\"xtick\"><text text-anchor=\"middle\" x=\"0\" y=\"97\" transform=\"translate(114.25,0)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">2</text></g></g><g class=\"yaxislayer-above\"><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,84)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">2</text></g><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,83.5)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">4</text></g><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,83)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">6</text></g><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,82.5)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">8</text></g><g class=\"ytick\"><text text-anchor=\"end\" x=\"79\" y=\"4.199999999999999\" transform=\"translate(0,82)\" style=\"font-family: 'Open Sans', verdana, arial, sans-serif; font-size: 12px; fill: rgb(68, 68, 68); fill-opacity: 1; white-space: pre;\">10</text></g></g><g class=\"overaxes-above\"/></g></g><g class=\"polarlayer\"/><g class=\"ternarylayer\"/><g class=\"geolayer\"/><g class=\"funnelarealayer\"/><g class=\"pielayer\"/><g class=\"treemaplayer\"/><g class=\"sunburstlayer\"/><g class=\"glimages\"/><defs id=\"topdefs-2dc70a\"><g class=\"clips\"/></defs><g class=\"layer-above\"><g class=\"imagelayer\"/><g class=\"shapelayer\"/></g><g class=\"infolayer\"><g class=\"g-gtitle\"/><g class=\"g-xtitle\"/><g class=\"g-ytitle\"/></g></svg>";
// Limit the test to the first LEN characters
const LEN: usize = 100;
assert_eq!(expected[..LEN], image_svg[..LEN]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first 100 characters are mostly (or just) SVG metadata that I suspect will make the test less robust.
Is the reason for this to avoid expensive computation or is it maybe to just test that in fact an SVG was produced?
If the latter, then I totally understand. :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @emilbratt. Magical number indeed 😅. Well, when I added the test , I found out that the SVG ends up containing some element ids that are new with each invocation. Didn't have the time to go down the rabbit hole to find out how those get generated. So I ended up limiting the test to the beginning of the string that does not contain any such id. I agree with you, that probably it would be wiser to limit the assert to the first 10ish characters or so. Will probably change it to that before releasing the new version.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andrei-ng Sounds like a good choice to me, hehe. Thank you for taking your time with this.
I was so happy when I read it got merged and am looking forward to the next release. :)

}
}
84 changes: 35 additions & 49 deletions plotly_kaleido/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -134,55 +135,37 @@ impl Kaleido {
let mut dst = PathBuf::from(dst);
dst.set_extension(format);

let p = self.cmd_path.as_path();
let p = p.to_str().unwrap();
let p = String::from(p);

let mut process = Command::new(p.as_str())
.current_dir(self.cmd_path.parent().unwrap())
.args([
"plotly",
"--disable-gpu",
"--allow-file-access-from-files",
"--disable-breakpad",
"--disable-dev-shm-usage",
"--disable-software-rasterizer",
"--single-process",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn Kaleido binary");

{
let plot_data = PlotData::new(plotly_data, format, width, height, scale).to_json();
let mut process_stdin = process.stdin.take().unwrap();
process_stdin
.write_all(plot_data.as_bytes())
.expect("couldn't write to Kaleido stdin");
process_stdin.flush()?;
}

let output_lines = BufReader::new(process.stdout.take().unwrap()).lines();
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<u8> = 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()?;
}
}
let image_data = self.convert(plotly_data, format, width, height, scale)?;
let data: Vec<u8> = 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(())
}

// similar to save, but returns b64 string
pub fn get_image_data(
/// 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<String, Box<dyn std::error::Error>> {
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,
Expand Down Expand Up @@ -221,16 +204,19 @@ impl Kaleido {
}

let output_lines = BufReader::new(process.stdout.take().unwrap()).lines();

let mut b64_str: String = "".into();
for line in output_lines.map_while(Result::ok) {
let res = KaleidoResult::from(line.as_str());
if let Some(image_data) = res.result {
b64_str = image_data
// 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(b64_str)
Ok(String::default())
}
}

Expand Down
Loading