Skip to content

Commit f925085

Browse files
authored
Merge pull request #251 from mchant/main
Expose image data as String with `to_b64` and `to_svg` using Kaleido
2 parents 7aecfa7 + c451332 commit f925085

File tree

3 files changed

+137
-8
lines changed

3 files changed

+137
-8
lines changed

Diff for: plotly/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,4 @@ itertools-num = "0.1.3"
5050
ndarray = "0.16.0"
5151
plotly_kaleido = { version = "0.10.0", path = "../plotly_kaleido" }
5252
rand_distr = "0.4"
53+
base64 = "0.22"

Diff for: plotly/src/plot.rs

+90
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,52 @@ impl Plot {
417417
.unwrap_or_else(|_| panic!("failed to export plot to {:?}", filename.as_ref()));
418418
}
419419

420+
/// Convert the `Plot` to a static image and return the image as a `base64`
421+
/// String Supported formats are [ImageFormat::JPEG], [ImageFormat::PNG]
422+
/// and [ImageFormat::WEBP]
423+
#[cfg(feature = "kaleido")]
424+
pub fn to_base64(
425+
&self,
426+
format: ImageFormat,
427+
width: usize,
428+
height: usize,
429+
scale: f64,
430+
) -> String {
431+
match format {
432+
ImageFormat::JPEG | ImageFormat::PNG | ImageFormat::WEBP => {
433+
let kaleido = plotly_kaleido::Kaleido::new();
434+
kaleido
435+
.image_to_string(
436+
&serde_json::to_value(self).unwrap(),
437+
&format.to_string(),
438+
width,
439+
height,
440+
scale,
441+
)
442+
.unwrap_or_else(|_| panic!("Kaleido failed to generate image"))
443+
}
444+
_ => {
445+
eprintln!("Cannot generate base64 string for ImageFormat:{format}. Allowed formats are JPEG, PNG, WEBP");
446+
String::default()
447+
}
448+
}
449+
}
450+
451+
/// Convert the `Plot` to SVG and return it as a String.
452+
#[cfg(feature = "kaleido")]
453+
pub fn to_svg(&self, width: usize, height: usize, scale: f64) -> String {
454+
let kaleido = plotly_kaleido::Kaleido::new();
455+
kaleido
456+
.image_to_string(
457+
&serde_json::to_value(self).unwrap(),
458+
"svg",
459+
width,
460+
height,
461+
scale,
462+
)
463+
.unwrap_or_else(|_| panic!("Kaleido failed to generate image"))
464+
}
465+
420466
fn render(&self) -> String {
421467
let tmpl = PlotTemplate {
422468
plot: self,
@@ -539,6 +585,7 @@ impl PartialEq for Plot {
539585
mod tests {
540586
use std::path::PathBuf;
541587

588+
use base64::{engine::general_purpose, Engine as _};
542589
use serde_json::{json, to_value};
543590

544591
use super::*;
@@ -773,4 +820,47 @@ mod tests {
773820
assert!(std::fs::remove_file(&dst).is_ok());
774821
assert!(!dst.exists());
775822
}
823+
824+
#[cfg(target_os = "linux")]
825+
#[test]
826+
#[cfg(feature = "kaleido")]
827+
fn test_image_to_base64() {
828+
let plot = create_test_plot();
829+
830+
let image_base64 = plot.to_base64(ImageFormat::PNG, 200, 150, 1.0);
831+
832+
assert!(!image_base64.is_empty());
833+
834+
let result_decoded = general_purpose::STANDARD.decode(image_base64).unwrap();
835+
let expected = "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";
836+
let expected_decoded = general_purpose::STANDARD.decode(expected).unwrap();
837+
838+
// Comparing the result seems to end up being a flaky test.
839+
// Limit the comparison to the first characters;
840+
// As image contents seem to be slightly inconsistent across platforms
841+
assert_eq!(expected_decoded[..2], result_decoded[..2]);
842+
}
843+
844+
#[test]
845+
#[cfg(feature = "kaleido")]
846+
fn test_image_to_base64_invalid_format() {
847+
let plot = create_test_plot();
848+
let image_base64 = plot.to_base64(ImageFormat::EPS, 200, 150, 1.0);
849+
assert!(image_base64.is_empty());
850+
}
851+
852+
#[cfg(target_os = "linux")]
853+
#[test]
854+
#[cfg(feature = "kaleido")]
855+
fn test_image_to_svg_string() {
856+
let plot = create_test_plot();
857+
let image_svg = plot.to_svg(200, 150, 1.0);
858+
859+
assert!(!image_svg.is_empty());
860+
861+
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>";
862+
// Limit the test to the first LEN characters
863+
const LEN: usize = 100;
864+
assert_eq!(expected[..LEN], image_svg[..LEN]);
865+
}
776866
}

Diff for: plotly_kaleido/src/lib.rs

+46-8
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ impl Kaleido {
122122
Ok(p)
123123
}
124124

125+
/// Generate a static image from a Plotly graph and save it to a file
125126
pub fn save(
126127
&self,
127128
dst: &Path,
@@ -134,6 +135,44 @@ impl Kaleido {
134135
let mut dst = PathBuf::from(dst);
135136
dst.set_extension(format);
136137

138+
let image_data = self.convert(plotly_data, format, width, height, scale)?;
139+
let data: Vec<u8> = match format {
140+
"svg" | "eps" => image_data.as_bytes().to_vec(),
141+
_ => general_purpose::STANDARD.decode(image_data).unwrap(),
142+
};
143+
let mut file = File::create(dst.as_path())?;
144+
file.write_all(&data)?;
145+
file.flush()?;
146+
147+
Ok(())
148+
}
149+
150+
/// Generate a static image from a Plotly graph and return it as a String
151+
/// The output may be base64 encoded or a plain text depending on the image
152+
/// format provided as argument. SVG and EPS are returned in plain text
153+
/// while JPEG, PNG, WEBP will be returned as a base64 encoded string.
154+
pub fn image_to_string(
155+
&self,
156+
plotly_data: &Value,
157+
format: &str,
158+
width: usize,
159+
height: usize,
160+
scale: f64,
161+
) -> Result<String, Box<dyn std::error::Error>> {
162+
let image_data = self.convert(plotly_data, format, width, height, scale)?;
163+
Ok(image_data)
164+
}
165+
166+
/// Convert the Plotly graph to a static image using Kaleido and return the
167+
/// result as a String
168+
pub fn convert(
169+
&self,
170+
plotly_data: &Value,
171+
format: &str,
172+
width: usize,
173+
height: usize,
174+
scale: f64,
175+
) -> Result<String, Box<dyn std::error::Error>> {
137176
let p = self.cmd_path.as_path();
138177
let p = p.to_str().unwrap();
139178
let p = String::from(p);
@@ -168,17 +207,16 @@ impl Kaleido {
168207
for line in output_lines.map_while(Result::ok) {
169208
let res = KaleidoResult::from(line.as_str());
170209
if let Some(image_data) = res.result {
171-
let data: Vec<u8> = match format {
172-
"svg" | "eps" => image_data.as_bytes().to_vec(),
173-
_ => general_purpose::STANDARD.decode(image_data).unwrap(),
174-
};
175-
let mut file = File::create(dst.as_path())?;
176-
file.write_all(&data)?;
177-
file.flush()?;
210+
// TODO: this should be refactored
211+
// The assumption is that KaleidoResult contains a single image.
212+
// We should end the loop on the first valid one.
213+
// If that is not the case, prior implementation would have returned the last
214+
// valid image
215+
return Ok(image_data);
178216
}
179217
}
180218

181-
Ok(())
219+
Ok(String::default())
182220
}
183221
}
184222

0 commit comments

Comments
 (0)