use std::fs::File;
use std::io::prelude::*;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use base64::{engine::general_purpose, Engine as _};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[allow(dead_code)]
#[derive(Deserialize, Debug)]
struct KaleidoResult {
code: i32,
message: Option<String>,
#[serde(rename = "pdfBgColor")]
pdf_background_color: Option<String>,
format: Option<String>,
result: Option<String>,
width: Option<usize>,
height: Option<usize>,
scale: Option<f64>,
version: Option<String>,
}
impl KaleidoResult {
fn from(result: &str) -> KaleidoResult {
serde_json::from_str(result).unwrap()
}
}
#[derive(Serialize)]
struct PlotData<'a> {
format: String,
width: usize,
height: usize,
scale: f64,
data: &'a Value,
}
impl<'a> PlotData<'a> {
fn new(data: &'a Value, format: &str, width: usize, height: usize, scale: f64) -> PlotData<'a> {
PlotData {
format: format.to_string(),
width,
height,
scale,
data,
}
}
fn to_json(&self) -> String {
serde_json::to_string(self).unwrap()
}
}
#[derive(Default)]
pub struct Kaleido {
cmd_path: PathBuf,
}
impl Kaleido {
const KALEIDO_PATH_ENV: &str = "KALEIDO_PATH";
pub fn new() -> Kaleido {
use std::env;
let path = match env::var(Self::KALEIDO_PATH_ENV) {
Ok(runtime_env) => runtime_env,
Err(runtime_env_err) => match option_env!("KALEIDO_COMPILE_TIME_DLD_PATH") {
Some(compile_time_path) => compile_time_path.to_string(),
None => {
println!("{}: {}", Self::KALEIDO_PATH_ENV, runtime_env_err);
println!("Use `kaleido_download` feature to automatically download, install and use Kaleido when targeting applications that run on the host machine.");
println!("Use `{}` environment variable when targeting applications intended to run on different machines. Manually install Kaleido on the target machine and point {} to the installation location.", Self::KALEIDO_PATH_ENV, Self::KALEIDO_PATH_ENV
);
std::process::exit(1);
}
},
};
let path = match Kaleido::binary_path(&path) {
Ok(kaleido_path) => kaleido_path,
Err(msg) => panic!("Failed tu use Kaleido binary at {} due to {}", path, msg),
};
Kaleido { cmd_path: path }
}
fn binary_path(dld_path: &str) -> Result<PathBuf, &'static str> {
let mut p = PathBuf::from(dld_path);
p = Self::os_binary_path(p);
if !p.exists() {
return Err("could not find kaleido executable in path");
}
Ok(p)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn os_binary_path(path: PathBuf) -> PathBuf {
match path.join("kaleido").canonicalize() {
Ok(v) => v,
Err(e) => {
println!(
"Failed to find Kaleido binary at '{}': {e}",
path.to_string_lossy()
);
panic!("{e}");
}
}
}
#[cfg(target_os = "windows")]
fn os_binary_path(path: PathBuf) -> PathBuf {
path.join("kaleido.cmd")
}
pub fn save(
&self,
dst: &Path,
plotly_data: &Value,
format: &str,
width: usize,
height: usize,
scale: f64,
) -> Result<(), Box<dyn std::error::Error>> {
let mut dst = PathBuf::from(dst);
dst.set_extension(format);
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(())
}
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)
}
pub fn convert(
&self,
plotly_data: &Value,
format: &str,
width: usize,
height: usize,
scale: f64,
) -> Result<String, Box<dyn std::error::Error>> {
let p = self.cmd_path.as_path();
let p = p.to_str().unwrap();
let p = String::from(p);
#[allow(clippy::zombie_processes)]
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",
"--disable-gpu",
"--no-sandbox",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|_| {
panic!(
"{}",
format!(
"failed to spawn Kaleido binary at {}",
self.cmd_path.to_string_lossy()
)
.to_string()
)
});
{
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 {
return Ok(image_data);
}
}
println!("Kaleido failed to generate static image for format: {format}.");
println!("Kaleido stderr output:");
let stderr = process.stderr.take().unwrap();
let stderr_lines = BufReader::new(stderr).lines();
for line in stderr_lines {
let line = line.unwrap();
eprintln!("{}", line);
}
Ok(String::default())
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use serde_json::{json, to_value};
use super::*;
fn create_test_plot() -> Value {
to_value(json!({
"data": [
{
"type": "scatter",
"x": [1, 2, 3, 4],
"y": [10, 15, 13, 17],
"name": "trace1",
"mode": "markers"
},
{
"type": "scatter",
"x": [2, 3, 4, 5],
"y": [16, 5, 11, 9],
"name": "trace2",
"mode": "lines"
},
{
"type": "scatter",
"x": [1, 2, 3, 4],
"y": [12, 9, 15, 12],
"name": "trace3",
}
],
"layout": {}
}))
.unwrap()
}
#[test]
fn can_find_kaleido_executable() {
let _k = Kaleido::new();
}
#[test]
fn plot_data_to_json() {
let test_plot = create_test_plot();
let kaleido_data = PlotData::new(&test_plot, "png", 400, 500, 1.);
let expected = json!({
"data": test_plot,
"format": "png",
"width": 400,
"height": 500,
"scale": 1.0
});
assert_eq!(to_value(kaleido_data).unwrap(), expected);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn save_png() {
let test_plot = create_test_plot();
let k = Kaleido::new();
let dst = PathBuf::from("example.png");
let r = k.save(dst.as_path(), &test_plot, "png", 1200, 900, 4.5);
assert!(r.is_ok());
assert!(std::fs::remove_file(dst.as_path()).is_ok());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn save_jpeg() {
let test_plot = create_test_plot();
let k = Kaleido::new();
let dst = PathBuf::from("example.jpeg");
let r = k.save(dst.as_path(), &test_plot, "jpeg", 1200, 900, 4.5);
assert!(r.is_ok());
assert!(std::fs::remove_file(dst.as_path()).is_ok());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn save_webp() {
let test_plot = create_test_plot();
let k = Kaleido::new();
let dst = PathBuf::from("example.webp");
let r = k.save(dst.as_path(), &test_plot, "webp", 1200, 900, 4.5);
assert!(r.is_ok());
assert!(std::fs::remove_file(dst.as_path()).is_ok());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn save_svg() {
let test_plot = create_test_plot();
let k = Kaleido::new();
let dst = PathBuf::from("example.svg");
let r = k.save(dst.as_path(), &test_plot, "svg", 1200, 900, 4.5);
assert!(r.is_ok());
assert!(std::fs::remove_file(dst.as_path()).is_ok());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn save_pdf() {
let test_plot = create_test_plot();
let k = Kaleido::new();
let dst = PathBuf::from("example.pdf");
let r = k.save(dst.as_path(), &test_plot, "pdf", 1200, 900, 4.5);
assert!(r.is_ok());
assert!(std::fs::remove_file(dst.as_path()).is_ok());
}
#[test]
#[ignore]
fn save_eps() {
let test_plot = create_test_plot();
let k = Kaleido::new();
let dst = PathBuf::from("example.eps");
let r = k.save(dst.as_path(), &test_plot, "eps", 1200, 900, 4.5);
assert!(r.is_ok());
assert!(std::fs::remove_file(dst.as_path()).is_ok());
}
}