1use std::sync::atomic::{AtomicU64, Ordering};
2use std::time::{SystemTime, UNIX_EPOCH};
3use std::{fs::File, io::Write, path::Path};
4
5use askama::Template;
6use dyn_clone::DynClone;
7use erased_serde::Serialize as ErasedSerialize;
8#[cfg(feature = "kaleido")]
9use plotly_kaleido::ImageFormat;
10#[cfg(feature = "plotly_static")]
11use plotly_static::ImageFormat;
12use rand::{
13 distr::{Alphanumeric, SampleString},
14 rngs::SmallRng,
15 SeedableRng,
16};
17use serde::Serialize;
18
19use crate::{layout::Frame, Configuration, Layout};
20
21static SEED_COUNTER: AtomicU64 = AtomicU64::new(0);
22
23#[derive(Template)]
24#[template(path = "plot.html", escape = "none")]
25struct PlotTemplate<'a> {
26 plot: &'a Plot,
27 js_scripts: &'a str,
28}
29
30#[cfg(any(feature = "kaleido", feature = "plotly_static"))]
31#[derive(Template)]
32#[template(path = "static_plot.html", escape = "none")]
33#[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
34struct StaticPlotTemplate<'a> {
35 plot: &'a Plot,
36 format: ImageFormat,
37 js_scripts: &'a str,
38 width: usize,
39 height: usize,
40}
41
42#[derive(Template)]
43#[template(path = "inline_plot.html", escape = "none")]
44struct InlinePlotTemplate<'a> {
45 plot: &'a Plot,
46 plot_div_id: &'a str,
47}
48
49#[derive(Template)]
50#[template(path = "jupyter_notebook_plot.html", escape = "none")]
51struct JupyterNotebookPlotTemplate<'a> {
52 plot: &'a Plot,
53 plot_div_id: &'a str,
54}
55
56#[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
57const DEFAULT_HTML_APP_NOT_FOUND: &str = r#"Could not find default application for HTML files.
58Consider using the `to_html` method obtain a string representation instead. If using the `kaleido` or `plotly_static` feature the
59`write_image` method can be used to produce a static image in one of the following formats:
60- ImageFormat::PNG
61- ImageFormat::JPEG
62- ImageFormat::WEBP
63- ImageFormat::SVG
64- ImageFormat::PDF
65- ImageFormat::EPS // will be removed in version 0.14.0
66
67Used as follows:
68let plot = Plot::new();
69...
70let width = 1024;
71let height = 680;
72let scale = 1.0;
73plot.write_image("filename", ImageFormat::PNG, width, height, scale);
74
75See https://plotly.github.io/plotly.rs/content/getting_started.html for further details.
76"#;
77
78pub trait Trace: DynClone + ErasedSerialize {
81 fn to_json(&self) -> String;
82}
83
84dyn_clone::clone_trait_object!(Trace);
85erased_serde::serialize_trait_object!(Trace);
86
87#[derive(Default, Serialize, Clone)]
88#[serde(transparent)]
89pub struct Traces {
90 traces: Vec<Box<dyn Trace>>,
91}
92
93impl Traces {
94 pub fn new() -> Self {
95 Self {
96 traces: Vec::with_capacity(1),
97 }
98 }
99
100 pub fn push(&mut self, trace: Box<dyn Trace>) {
101 self.traces.push(trace)
102 }
103
104 pub fn len(&self) -> usize {
105 self.traces.len()
106 }
107
108 pub fn is_empty(&self) -> bool {
109 self.traces.is_empty()
110 }
111
112 pub fn iter(&self) -> std::slice::Iter<'_, Box<dyn Trace>> {
113 self.traces.iter()
114 }
115
116 pub fn to_json(&self) -> String {
117 serde_json::to_string(self).unwrap()
118 }
119}
120
121#[derive(Default, Serialize, Clone)]
160pub struct Plot {
161 #[serde(rename = "data")]
162 traces: Traces,
163 layout: Layout,
164 #[serde(rename = "config")]
165 configuration: Configuration,
166 frames: Option<Vec<Frame>>,
168 #[serde(skip)]
169 js_scripts: String,
170}
171
172impl Plot {
173 pub fn new() -> Plot {
175 Plot {
176 traces: Traces::new(),
177 js_scripts: Self::js_scripts(),
178 ..Default::default()
179 }
180 }
181
182 #[cfg(feature = "plotly_embed_js")]
188 pub fn use_cdn_js(&mut self) {
189 self.js_scripts = Self::online_cdn_js();
190 }
191
192 pub fn add_trace(&mut self, trace: Box<dyn Trace>) {
194 self.traces.push(trace);
195 }
196
197 pub fn add_traces(&mut self, traces: Vec<Box<dyn Trace>>) {
199 for trace in traces {
200 self.add_trace(trace);
201 }
202 }
203
204 pub fn set_layout(&mut self, layout: Layout) {
206 self.layout = layout;
207 }
208
209 pub fn set_configuration(&mut self, configuration: Configuration) {
211 self.configuration = configuration;
212 }
213
214 pub fn data(&self) -> &Traces {
216 &self.traces
217 }
218
219 pub fn layout(&self) -> &Layout {
221 &self.layout
222 }
223
224 pub fn configuration(&self) -> &Configuration {
226 &self.configuration
227 }
228
229 pub fn add_frame(&mut self, frame: Frame) -> &mut Self {
231 if self.frames.is_none() {
232 self.frames = Some(Vec::new());
233 }
234 self.frames.as_mut().unwrap().push(frame);
235 self
236 }
237
238 pub fn add_frames(&mut self, frames: &[Frame]) -> &mut Self {
240 if self.frames.is_none() {
241 self.frames = Some(frames.to_vec());
242 }
243 self.frames.as_mut().unwrap().extend(frames.iter().cloned());
244 self
245 }
246
247 pub fn clear_frames(&mut self) -> &mut Self {
248 self.frames = None;
249 self
250 }
251
252 pub fn frame_count(&self) -> usize {
253 self.frames.as_ref().map(|f| f.len()).unwrap_or(0)
254 }
255
256 pub fn frames_mut(&mut self) -> Option<&mut Vec<Frame>> {
258 self.frames.as_mut()
259 }
260
261 pub fn frames(&self) -> Option<&[Frame]> {
263 self.frames.as_deref()
264 }
265
266 #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
271 pub fn show(&self) {
272 use std::env;
273 let rendered = self.render();
274
275 let mut temp = env::temp_dir();
277 let mut plot_name =
278 Alphanumeric.sample_string(&mut SmallRng::seed_from_u64(Self::generate_seed()), 22);
279 plot_name.push_str(".html");
280 plot_name = format!("plotly_{plot_name}");
281 temp.push(plot_name);
282
283 let temp_path = temp.to_str().unwrap();
285
286 {
287 let mut file = File::create(temp_path).unwrap();
288 file.write_all(rendered.as_bytes())
289 .expect("failed to write html output");
290 file.flush().unwrap();
291 }
292
293 Plot::show_with_default_app(temp_path);
295 }
296
297 #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
303 pub fn show_html<P: AsRef<Path> + std::clone::Clone>(&self, filename: P) {
304 let path = filename.as_ref().to_str().unwrap();
305 self.write_html(filename.clone());
306 Plot::show_with_default_app(path);
308 }
309
310 #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
313 #[cfg(any(feature = "kaleido", feature = "plotly_static"))]
314 pub fn show_image(&self, format: ImageFormat, width: usize, height: usize) {
315 use std::env;
316
317 let rendered = self.render_static(&format, width, height);
318
319 let mut temp = env::temp_dir();
321 let mut plot_name =
322 Alphanumeric.sample_string(&mut SmallRng::seed_from_u64(Self::generate_seed()), 22);
323 plot_name.push_str(".html");
324 plot_name = format!("plotly_{plot_name}");
325 temp.push(plot_name);
326
327 let temp_path = temp.to_str().unwrap();
329
330 {
331 let mut file = File::create(temp_path).unwrap();
332 file.write_all(rendered.as_bytes())
333 .expect("failed to write html output");
334 file.flush().unwrap();
335 }
336
337 Plot::show_with_default_app(temp_path);
339 }
340
341 pub fn write_html<P: AsRef<Path>>(&self, filename: P) {
346 let rendered = self.to_html();
347
348 let mut file =
349 File::create(filename).expect("Provided filepath does not exist or is not accessible");
350 file.write_all(rendered.as_bytes())
351 .expect("failed to write html output");
352 file.flush().unwrap();
353 }
354
355 pub fn to_html(&self) -> String {
361 self.render()
362 }
363
364 pub fn to_inline_html(&self, plot_div_id: Option<&str>) -> String {
378 let plot_div_id = match plot_div_id {
379 Some(id) => id.to_string(),
380 None => {
381 Alphanumeric.sample_string(&mut SmallRng::seed_from_u64(Self::generate_seed()), 20)
382 }
383 };
384 self.render_inline(&plot_div_id)
385 }
386
387 fn to_jupyter_notebook_html(&self) -> String {
388 let plot_div_id =
389 Alphanumeric.sample_string(&mut SmallRng::seed_from_u64(Self::generate_seed()), 20);
390
391 let tmpl = JupyterNotebookPlotTemplate {
392 plot: self,
393 plot_div_id: &plot_div_id,
394 };
395 tmpl.render().unwrap()
396 }
397
398 pub fn notebook_display(&self) {
400 let plot_data = self.to_jupyter_notebook_html();
401 println!("EVCXR_BEGIN_CONTENT text/html\n{plot_data}\nEVCXR_END_CONTENT");
402 }
403
404 pub fn lab_display(&self) {
406 let plot_data = self.to_json();
407 println!(
408 "EVCXR_BEGIN_CONTENT application/vnd.plotly.v1+json\n{plot_data}\nEVCXR_END_CONTENT"
409 );
410 }
411
412 pub fn evcxr_display(&self) {
415 self.lab_display();
416 }
417
418 #[deprecated(
425 since = "0.13.0",
426 note = "kaleido-based implementation is deprecated. Use plotly_static feature instead. The kaleido implementation will be removed in version 0.14.0"
427 )]
428 #[cfg(feature = "kaleido")]
429 pub fn write_image<P: AsRef<Path>>(
430 &self,
431 filename: P,
432 format: ImageFormat,
433 width: usize,
434 height: usize,
435 scale: f64,
436 ) {
437 let kaleido = plotly_kaleido::Kaleido::new();
438 kaleido
439 .save(
440 filename.as_ref(),
441 &serde_json::to_value(self).unwrap(),
442 format,
443 width,
444 height,
445 scale,
446 )
447 .unwrap_or_else(|_| panic!("failed to export plot to {:?}", filename.as_ref()));
448 }
449
450 #[deprecated(
458 since = "0.13.0",
459 note = "kaleido-based implementation is deprecated. Use plotly_static feature instead. The kaleido implementation will be removed in version 0.14.0"
460 )]
461 #[cfg(feature = "kaleido")]
462 pub fn to_base64(
463 &self,
464 format: ImageFormat,
465 width: usize,
466 height: usize,
467 scale: f64,
468 ) -> String {
469 match format {
470 ImageFormat::JPEG | ImageFormat::PNG | ImageFormat::WEBP => {
471 let kaleido = plotly_kaleido::Kaleido::new();
472 kaleido
473 .image_to_string(
474 &serde_json::to_value(self).unwrap(),
475 format,
476 width,
477 height,
478 scale,
479 )
480 .unwrap_or_else(|_| panic!("Kaleido failed to generate image"))
481 }
482 _ => {
483 eprintln!("Cannot generate base64 string for ImageFormat:{format}. Allowed formats are JPEG, PNG, WEBP");
484 String::default()
485 }
486 }
487 }
488
489 #[deprecated(
495 since = "0.13.0",
496 note = "kaleido-based implementation is deprecated. Use plotly_static feature instead. The kaleido implementation will be removed in version 0.14.0"
497 )]
498 #[cfg(feature = "kaleido")]
499 pub fn to_svg(&self, width: usize, height: usize, scale: f64) -> String {
500 let kaleido = plotly_kaleido::Kaleido::new();
501 kaleido
502 .image_to_string(
503 &serde_json::to_value(self).unwrap(),
504 ImageFormat::SVG,
505 width,
506 height,
507 scale,
508 )
509 .unwrap_or_else(|_| panic!("Kaleido failed to generate image"))
510 }
511
512 #[cfg(feature = "plotly_static")]
524 pub fn write_image<P: AsRef<Path>>(
525 &self,
526 filename: P,
527 format: ImageFormat,
528 width: usize,
529 height: usize,
530 scale: f64,
531 ) -> Result<(), Box<dyn std::error::Error>> {
532 let mut exporter = plotly_static::StaticExporterBuilder::default()
533 .build()
534 .map_err(|e| format!("Failed to create StaticExporter: {e}"))?;
535 self.write_image_with_exporter(&mut exporter, filename, format, width, height, scale)
536 }
537
538 #[cfg(feature = "plotly_static")]
553 pub fn to_base64(
554 &self,
555 format: ImageFormat,
556 width: usize,
557 height: usize,
558 scale: f64,
559 ) -> Result<String, Box<dyn std::error::Error>> {
560 let mut exporter = plotly_static::StaticExporterBuilder::default()
561 .build()
562 .map_err(|e| format!("Failed to create StaticExporter: {e}"))?;
563 self.to_base64_with_exporter(&mut exporter, format, width, height, scale)
564 }
565
566 #[cfg(feature = "plotly_static")]
577 pub fn to_svg(
578 &self,
579 width: usize,
580 height: usize,
581 scale: f64,
582 ) -> Result<String, Box<dyn std::error::Error>> {
583 let mut exporter = plotly_static::StaticExporterBuilder::default()
584 .build()
585 .map_err(|e| format!("Failed to create StaticExporter: {e}"))?;
586 self.to_svg_with_exporter(&mut exporter, width, height, scale)
587 }
588
589 #[cfg(feature = "plotly_static")]
626 pub fn write_image_with_exporter<P: AsRef<Path>>(
627 &self,
628 exporter: &mut plotly_static::StaticExporter,
629 filename: P,
630 format: ImageFormat,
631 width: usize,
632 height: usize,
633 scale: f64,
634 ) -> Result<(), Box<dyn std::error::Error>> {
635 exporter.write_fig(
636 filename.as_ref(),
637 &serde_json::to_value(self)?,
638 format,
639 width,
640 height,
641 scale,
642 )
643 }
644
645 #[cfg(feature = "plotly_static")]
681 pub fn to_base64_with_exporter(
682 &self,
683 exporter: &mut plotly_static::StaticExporter,
684 format: ImageFormat,
685 width: usize,
686 height: usize,
687 scale: f64,
688 ) -> Result<String, Box<dyn std::error::Error>> {
689 match format {
690 ImageFormat::JPEG | ImageFormat::PNG | ImageFormat::WEBP => {
691 exporter.write_to_string(
692 &serde_json::to_value(self)?,
693 format,
694 width,
695 height,
696 scale,
697 )
698 }
699 _ => {
700 Err(format!("Cannot generate base64 string for ImageFormat:{format}. Allowed formats are JPEG, PNG, WEBP").into())
701 }
702 }
703 }
704
705 #[cfg(feature = "plotly_static")]
739 pub fn to_svg_with_exporter(
740 &self,
741 exporter: &mut plotly_static::StaticExporter,
742 width: usize,
743 height: usize,
744 scale: f64,
745 ) -> Result<String, Box<dyn std::error::Error>> {
746 exporter.write_to_string(
747 &serde_json::to_value(self)?,
748 ImageFormat::SVG,
749 width,
750 height,
751 scale,
752 )
753 }
754
755 fn render(&self) -> String {
756 let tmpl = PlotTemplate {
757 plot: self,
758 js_scripts: &self.js_scripts,
759 };
760 tmpl.render().unwrap()
761 }
762
763 #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
764 #[cfg(any(feature = "kaleido", feature = "plotly_static"))]
765 pub fn render_static(&self, format: &ImageFormat, width: usize, height: usize) -> String {
766 let tmpl = StaticPlotTemplate {
767 plot: self,
768 format: format.clone(),
769 js_scripts: &self.js_scripts,
770 width,
771 height,
772 };
773 tmpl.render().unwrap()
774 }
775
776 fn render_inline(&self, plot_div_id: &str) -> String {
777 let tmpl = InlinePlotTemplate {
778 plot: self,
779 plot_div_id,
780 };
781 tmpl.render().unwrap()
782 }
783
784 fn js_scripts() -> String {
785 if cfg!(feature = "plotly_embed_js") {
786 Self::offline_js_sources()
787 } else {
788 Self::online_cdn_js()
789 }
790 }
791
792 pub fn offline_js_sources() -> String {
804 let local_tex_svg_js = include_str!("../resource/tex-svg-3.2.2.js");
807 let local_plotly_js = include_str!("../resource/plotly.min.js");
808
809 format!(
810 "<script type=\"text/javascript\">{local_plotly_js}</script>\n
811 <script type=\"text/javascript\">{local_tex_svg_js}</script>\n",
812 )
813 .to_string()
814 }
815
816 pub fn online_cdn_js() -> String {
829 r##"<script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js"></script>
832 <script src="https://cdn.plot.ly/plotly-3.0.1.min.js"></script>
833 "##
834 .to_string()
835 }
836
837 pub fn to_json(&self) -> String {
838 serde_json::to_string(self).unwrap()
839 }
840
841 #[cfg(target_family = "wasm")]
842 pub fn to_js_object(&self) -> wasm_bindgen_futures::js_sys::Object {
844 use wasm_bindgen_futures::js_sys;
845 use wasm_bindgen_futures::wasm_bindgen::JsCast;
846 js_sys::JSON::parse(&self.to_json())
850 .expect("Invalid JSON")
851 .dyn_into::<js_sys::Object>()
852 .expect("Invalid JSON structure - expected a top-level Object")
853 }
854
855 #[cfg(all(unix, not(target_os = "android"), not(target_os = "macos")))]
856 fn show_with_default_app(temp_path: &str) {
857 use std::process::Command;
858 Command::new("xdg-open")
859 .args([temp_path])
860 .output()
861 .expect(DEFAULT_HTML_APP_NOT_FOUND);
862 }
863
864 #[cfg(target_os = "macos")]
865 fn show_with_default_app(temp_path: &str) {
866 use std::process::Command;
867 Command::new("open")
868 .args([temp_path])
869 .output()
870 .expect(DEFAULT_HTML_APP_NOT_FOUND);
871 }
872
873 #[cfg(target_os = "windows")]
874 fn show_with_default_app(temp_path: &str) {
875 use std::process::Command;
876 Command::new("explorer")
877 .arg(temp_path)
878 .spawn()
879 .expect(DEFAULT_HTML_APP_NOT_FOUND);
880 }
881
882 pub(crate) fn generate_seed() -> u64 {
885 let time = SystemTime::now()
886 .duration_since(UNIX_EPOCH)
887 .unwrap_or_default()
888 .as_nanos() as u64;
889 let counter = SEED_COUNTER.fetch_add(1, Ordering::Relaxed);
890 time ^ counter
891 }
892}
893
894impl PartialEq for Plot {
895 fn eq(&self, other: &Self) -> bool {
896 self.to_json() == other.to_json()
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use std::path::PathBuf;
903 use std::sync::atomic::{AtomicU32, Ordering};
904
905 #[cfg(feature = "kaleido")]
906 use plotly_kaleido::ImageFormat;
907 #[cfg(feature = "plotly_static")]
908 use plotly_static::ImageFormat;
909 use serde_json::{json, to_value};
910 #[cfg(any(feature = "kaleido", feature = "plotly_static"))]
911 use {base64::engine::general_purpose, base64::Engine};
912
913 use super::*;
914 use crate::Scatter;
915
916 fn create_test_plot() -> Plot {
917 let trace1 = Scatter::new(vec![0, 1, 2], vec![6, 10, 2]).name("trace1");
918 let mut plot = Plot::new();
919 plot.add_trace(trace1);
920 plot
921 }
922
923 #[test]
924 fn inline_plot() {
925 let plot = create_test_plot();
926 let inline_plot_data = plot.to_inline_html(Some("replace_this_with_the_div_id"));
927 assert!(inline_plot_data.contains("replace_this_with_the_div_id"));
928 plot.to_inline_html(None);
929 }
930
931 #[test]
932 fn jupyter_notebook_plot() {
933 let plot = create_test_plot();
934 plot.to_jupyter_notebook_html();
935 }
936
937 #[test]
938 fn notebook_display() {
939 let plot = create_test_plot();
940 plot.notebook_display();
941 }
942
943 #[test]
944 fn lab_display() {
945 let plot = create_test_plot();
946 plot.lab_display();
947 }
948
949 #[test]
950 fn plot_serialize_simple() {
951 let plot = create_test_plot();
952 let expected = json!({
953 "data": [
954 {
955 "type": "scatter",
956 "name": "trace1",
957 "x": [0, 1, 2],
958 "y": [6, 10, 2]
959 }
960 ],
961 "layout": {},
962 "config": {},
963 "frames": null,
964 });
965
966 assert_eq!(to_value(plot).unwrap(), expected);
967 }
968
969 #[test]
970 fn plot_serialize_with_layout() {
971 let mut plot = create_test_plot();
972 let layout = Layout::new().title("Title");
973 plot.set_layout(layout);
974
975 let expected = json!({
976 "data": [
977 {
978 "type": "scatter",
979 "name": "trace1",
980 "x": [0, 1, 2],
981 "y": [6, 10, 2]
982 }
983 ],
984 "layout": {
985 "title": {
986 "text": "Title"
987 }
988 },
989 "config": {},
990 "frames": null,
991 });
992
993 assert_eq!(to_value(plot).unwrap(), expected);
994 }
995
996 #[test]
997 fn data_to_json() {
998 let plot = create_test_plot();
999 let expected = json!([
1000 {
1001 "type": "scatter",
1002 "name": "trace1",
1003 "x": [0, 1, 2],
1004 "y": [6, 10, 2]
1005 }
1006 ]);
1007
1008 assert_eq!(to_value(plot.data()).unwrap(), expected);
1009 }
1010
1011 #[test]
1012 fn empty_layout_to_json() {
1013 let plot = create_test_plot();
1014 let expected = json!({});
1015
1016 assert_eq!(to_value(plot.layout()).unwrap(), expected);
1017 }
1018
1019 #[test]
1020 fn layout_to_json() {
1021 let mut plot = create_test_plot();
1022 let layout = Layout::new().title("TestTitle");
1023 plot.set_layout(layout);
1024
1025 let expected = json!({
1026 "title": {"text": "TestTitle"}
1027 });
1028
1029 assert_eq!(to_value(plot.layout()).unwrap(), expected);
1030 }
1031
1032 #[test]
1033 fn plot_eq() {
1034 let plot1 = create_test_plot();
1035 let plot2 = create_test_plot();
1036
1037 assert!(plot1 == plot2);
1038 }
1039
1040 #[test]
1041 fn plot_neq() {
1042 let plot1 = create_test_plot();
1043 let trace2 = Scatter::new(vec![10, 1, 2], vec![6, 10, 2]).name("trace2");
1044 let mut plot2 = Plot::new();
1045 plot2.add_trace(trace2);
1046
1047 assert!(plot1 != plot2);
1048 }
1049
1050 #[test]
1051 fn plot_clone() {
1052 let plot1 = create_test_plot();
1053 let plot2 = plot1.clone();
1054
1055 assert!(plot1 == plot2);
1056 }
1057
1058 #[test]
1059 fn save_html() {
1060 let plot = create_test_plot();
1061 let dst = PathBuf::from("plotly_example.html");
1062 plot.write_html(&dst);
1063 assert!(dst.exists());
1064 #[cfg(not(feature = "debug"))]
1065 assert!(std::fs::remove_file(&dst).is_ok());
1066 }
1067
1068 #[cfg(feature = "plotly_static")]
1069 static PORT_COUNTER: AtomicU32 = AtomicU32::new(4444);
1071
1072 #[cfg(feature = "plotly_static")]
1073 fn get_unique_port() -> u32 {
1074 PORT_COUNTER.fetch_add(1, Ordering::SeqCst)
1075 }
1076
1077 #[test]
1078 #[cfg(feature = "plotly_static")]
1079 fn save_to_png() {
1080 let plot = create_test_plot();
1081 let dst = PathBuf::from("plotly_example.png");
1082 let mut exporter = plotly_static::StaticExporterBuilder::default()
1083 .webdriver_port(get_unique_port())
1084 .build()
1085 .unwrap();
1086 plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::PNG, 1024, 680, 1.0)
1087 .unwrap();
1088 assert!(dst.exists());
1089 let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1090 let file_size = metadata.len();
1091 assert!(file_size > 0,);
1092 #[cfg(not(feature = "debug"))]
1093 assert!(std::fs::remove_file(&dst).is_ok());
1094 }
1095
1096 #[test]
1097 #[cfg(feature = "plotly_static")]
1098 fn save_to_jpeg() {
1099 let plot = create_test_plot();
1100 let dst = PathBuf::from("plotly_example.jpeg");
1101 let mut exporter = plotly_static::StaticExporterBuilder::default()
1102 .webdriver_port(get_unique_port())
1103 .build()
1104 .unwrap();
1105 plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::JPEG, 1024, 680, 1.0)
1106 .unwrap();
1107 assert!(dst.exists());
1108 let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1109 let file_size = metadata.len();
1110 assert!(file_size > 0,);
1111 #[cfg(not(feature = "debug"))]
1112 assert!(std::fs::remove_file(&dst).is_ok());
1113 }
1114
1115 #[test]
1116 #[cfg(feature = "plotly_static")]
1117 fn save_to_svg() {
1118 let plot = create_test_plot();
1119 let dst = PathBuf::from("plotly_example.svg");
1120 let mut exporter = plotly_static::StaticExporterBuilder::default()
1121 .webdriver_port(get_unique_port())
1122 .build()
1123 .unwrap();
1124 plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::SVG, 1024, 680, 1.0)
1125 .unwrap();
1126 assert!(dst.exists());
1127 let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1128 let file_size = metadata.len();
1129 assert!(file_size > 0,);
1130 #[cfg(not(feature = "debug"))]
1131 assert!(std::fs::remove_file(&dst).is_ok());
1132 }
1133
1134 #[test]
1135 #[cfg(feature = "plotly_static")]
1136 fn save_to_pdf() {
1137 let plot = create_test_plot();
1138 let dst = PathBuf::from("plotly_example.pdf");
1139 #[cfg(feature = "debug")]
1140 let mut exporter = plotly_static::StaticExporterBuilder::default()
1141 .spawn_webdriver(true)
1142 .webdriver_port(get_unique_port())
1143 .pdf_export_timeout(750)
1144 .build()
1145 .unwrap();
1146 #[cfg(not(feature = "debug"))]
1147 let mut exporter = plotly_static::StaticExporterBuilder::default()
1148 .webdriver_port(get_unique_port())
1149 .build()
1150 .unwrap();
1151 plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::PDF, 1024, 680, 1.0)
1152 .unwrap();
1153 assert!(dst.exists());
1154 let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1155 let file_size = metadata.len();
1156 assert!(file_size > 0,);
1157 #[cfg(not(feature = "debug"))]
1158 assert!(std::fs::remove_file(&dst).is_ok());
1159 }
1160
1161 #[test]
1162 #[cfg(feature = "plotly_static")]
1163 fn save_to_webp() {
1164 let plot = create_test_plot();
1165 let dst = PathBuf::from("plotly_example.webp");
1166 let mut exporter = plotly_static::StaticExporterBuilder::default()
1167 .webdriver_port(get_unique_port())
1168 .build()
1169 .unwrap();
1170 plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::WEBP, 1024, 680, 1.0)
1171 .unwrap();
1172 assert!(dst.exists());
1173 let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1174 let file_size = metadata.len();
1175 assert!(file_size > 0,);
1176 #[cfg(not(feature = "debug"))]
1177 assert!(std::fs::remove_file(&dst).is_ok());
1178 }
1179
1180 #[test]
1181 #[cfg(feature = "plotly_static")]
1182 fn image_to_base64() {
1183 let plot = create_test_plot();
1184 let mut exporter = plotly_static::StaticExporterBuilder::default()
1185 .webdriver_port(get_unique_port())
1186 .build()
1187 .unwrap();
1188
1189 let image_base64 = plot
1190 .to_base64_with_exporter(&mut exporter, ImageFormat::PNG, 200, 150, 1.0)
1191 .unwrap();
1192
1193 assert!(!image_base64.is_empty());
1194
1195 let result_decoded = general_purpose::STANDARD.decode(image_base64).unwrap();
1196 let expected = "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";
1197 let expected_decoded = general_purpose::STANDARD.decode(expected).unwrap();
1198
1199 assert_eq!(expected_decoded[..2], result_decoded[..2]);
1203 }
1204
1205 #[test]
1206 #[cfg(feature = "plotly_static")]
1207 fn image_to_svg_string() {
1208 let plot = create_test_plot();
1209 let mut exporter = plotly_static::StaticExporterBuilder::default()
1210 .webdriver_port(get_unique_port())
1211 .build()
1212 .unwrap();
1213 let image_svg = plot
1214 .to_svg_with_exporter(&mut exporter, 200, 150, 1.0)
1215 .unwrap();
1216
1217 assert!(!image_svg.is_empty());
1218
1219 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>";
1220 const LEN: usize = 10;
1223 assert_eq!(expected[..LEN], image_svg[..LEN]);
1224 }
1225
1226 #[test]
1227 #[cfg(feature = "plotly_static")]
1228 fn save_surface_to_png() {
1229 use crate::Surface;
1230 let mut plot = Plot::new();
1231 let z_matrix = vec![
1232 vec![1.0, 2.0, 3.0],
1233 vec![4.0, 5.0, 6.0],
1234 vec![7.0, 8.0, 9.0],
1235 ];
1236 let x_unique = vec![1.0, 2.0, 3.0];
1237 let y_unique = vec![4.0, 5.0, 6.0];
1238 let surface = Surface::new(z_matrix)
1239 .x(x_unique)
1240 .y(y_unique)
1241 .name("Surface");
1242
1243 plot.add_trace(surface);
1244 let dst = PathBuf::from("plotly_example_surface.png");
1245 let mut exporter = plotly_static::StaticExporterBuilder::default()
1246 .webdriver_port(get_unique_port())
1247 .build()
1248 .unwrap();
1249
1250 assert!(!plot
1251 .to_base64_with_exporter(&mut exporter, ImageFormat::PNG, 1024, 680, 1.0)
1252 .unwrap()
1253 .is_empty());
1254
1255 plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::PNG, 800, 600, 1.0)
1256 .unwrap();
1257 assert!(dst.exists());
1258
1259 let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1260 let file_size = metadata.len();
1261 assert!(file_size > 0,);
1262 #[cfg(not(feature = "debug"))]
1263 assert!(std::fs::remove_file(&dst).is_ok());
1264 }
1265}