diff --git a/Cargo.toml b/Cargo.toml index afcf544..cccbe33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,21 @@ [package] name = "procyon" version = "0.1.0" -authors = ["François Mockers "] +authors = ["François Mockers ", "Thomas Aubry "] edition = "2018" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] vega_lite_3 = {version = "0.2", optional = true} vega_lite_4 = {git = "https://github.com/procyon-rs/vega_lite_4.rs.git", optional = true} showata = "0.2" +tokio = {version="0.2.11", features = ["full"]} +futures = {version="0.3.4", optional=true} +fantoccini = {version="0.12.0"} +base64 = {version="0.11.0"} +serde_json = "1.0" +retry = "1.0.0" +futures-retry = "0.5.0" +data-url = "0.1.0" [features] default = ["vega_lite_4"] diff --git a/examples/save_png.rs b/examples/save_png.rs new file mode 100644 index 0000000..089e354 --- /dev/null +++ b/examples/save_png.rs @@ -0,0 +1,15 @@ +use procyon::*; +use std::path::Path; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let plot_save_path = Path::new("examples/output/image.png"); + let chart = Procyon::chart( + "https://raw.githubusercontent.com/procyon-rs/vega_lite_4.rs/master/examples/res/data/clustered_data.csv" + ) + .mark_point() + .encode_axis("x", "y").encode_color("cluster") + .save(plot_save_path).await?; + eprintln!("{:?}", chart); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 68e91f6..73c1b2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,10 @@ //! High level helpers to create beautiful graphs based on Vega-Lite #![deny( - warnings, missing_debug_implementations, trivial_casts, trivial_numeric_casts, + warnings, unsafe_code, unstable_features, unused_import_braces, @@ -14,6 +14,79 @@ pub use showata::Showable; +use base64; +use data_url::DataUrl; +use fantoccini::{Client, Locator}; +use retry::{delay::Exponential, retry_with_index, OperationResult}; +//use futures_retry::{RetryPolicy, StreamRetryExt}; +use std::fs::File; +use std::io::Write; +use std::path::Path; +use std::process::Command; + +/// Spawn in background a webdriver, currently support is limited to +/// geckodriver. Please see [geckodriver doc](https://github.com/mozilla/geckodriver) and install it. +/// TODO: allow [chromium webdriver](https://chromedriver.chromium.org/) and update documentation +pub fn spawn_webdriver( + webdriver_name: &str, + port: Option, +) -> Result<(std::process::Child, u64), Box> { + let mut try_port = match port { + Some(n) => n, + None => 4444, + }; + let webdriver_process = retry_with_index(Exponential::from_millis(100), |current_try| { + if current_try > 3 { + return OperationResult::Err("did not succeed within 3 tries"); + } + try_port += current_try; + let try_command = Command::new(webdriver_name) + .args(&["--port", &try_port.to_string()]) + .spawn(); + match try_command { + Ok(cmd) => OperationResult::Ok(cmd), + Err(_) => OperationResult::Retry("Trying with another port"), + } + }) + .unwrap(); + Ok((webdriver_process, try_port)) +} + +/// Create a headless browser instance. +/// Code from : https://github.com/jonhoo/fantoccini/blob/master/tests/common.rs +/// The chrome case will be commented for now and will be tested later.str +/// It also need the port from the webdriver. +pub async fn create_headless_client( + client_type: &str, + port: u64, +) -> Result { + // let mut client = retry_with_index(Exponential::from_millis(100), + // |current_try| { if current_try > 5 { + // return OperationResult::Err("did not succeed within 3 tries"); + // } + // let mut try_client = match client_type { + // "firefox" => { + // let mut caps = serde_json::map::Map::new(); + // let opts = serde_json::json!({ "args": ["--headless"] }); + // caps.insert("moz:firefoxOptions".to_string(), opts.clone()); + // Client::with_capabilities(&format!("http://localhost:{}", port.to_string()), caps) + // .await? + // } + // browser => unimplemented!("unsupported browser backend {}", + // browser), }; + // match try_client { + // Ok(try_client) => OperationResult::Ok(try_client), + // Err(e) => OperationResult::Retry("Trying to establish connection + // between client and webdriver"), } + // }).unwrap(); + // Ok(client) + unimplemented!( + "Currently trying to find out how retry_futures works {} {}", + client_type, + port + ) +} + #[cfg(feature = "vega_lite_4")] mod vega_lite_4_bindings; @@ -116,6 +189,47 @@ impl Procyon { new } + /// Current implem use the saved html by showata and imitate user clik to + /// save the image Another approach is to updated the embeded js like in + /// altair: https://github.com/altair-viz/altair_saver/blob/master/altair_saver/savers/_selenium.py + pub async fn save(&self, image_path: &Path) -> Result<(), Box> { + let (mut webdriver_process, webdriver_port) = + spawn_webdriver("geckodriver", Some(4444)).unwrap(); + let mut client = create_headless_client("firefox", webdriver_port).await?; + client + .goto(&format!( + "file:///private{}", + self.build().to_html_file()?.to_str().unwrap() + )) + .await?; + + let mut summary_button = client + .wait_for_find(Locator::Css("summary")) + .await? + .click() + .await?; + let mut hidden_link = summary_button + .find(Locator::LinkText("Save as PNG")) + .await? + .click() + .await?; + + let link = hidden_link + .find(Locator::LinkText("Save as PNG")) + .await? + .attr("href") + .await? + .unwrap(); + + let image_data_url = DataUrl::process(&link).unwrap(); + let (body, _) = image_data_url.decode_to_vec().unwrap(); + let bytes: Vec = base64::decode(&body).unwrap(); + let mut image_file = File::create(image_path).unwrap(); + image_file.write(&bytes).unwrap(); + hidden_link.close().await?; + webdriver_process.kill()?; + Ok(()) + } /// Build the graph #[cfg(feature = "vega_lite_4")] pub fn build(&self) -> vega_lite_4::Vegalite {