plotly/
plot.rs

1use std::{fs::File, io::Write, path::Path};
2
3use dyn_clone::DynClone;
4use erased_serde::Serialize as ErasedSerialize;
5use rand::{
6    distributions::{Alphanumeric, DistString},
7    thread_rng,
8};
9use rinja::Template;
10use serde::Serialize;
11
12use crate::{Configuration, Layout};
13
14#[derive(Template)]
15#[template(path = "plot.html", escape = "none")]
16struct PlotTemplate<'a> {
17    plot: &'a Plot,
18    js_scripts: String,
19}
20
21#[derive(Template)]
22#[template(path = "static_plot.html", escape = "none")]
23#[cfg(not(target_family = "wasm"))]
24struct StaticPlotTemplate<'a> {
25    plot: &'a Plot,
26    format: ImageFormat,
27    js_scripts: String,
28    width: usize,
29    height: usize,
30}
31
32#[derive(Template)]
33#[template(path = "inline_plot.html", escape = "none")]
34struct InlinePlotTemplate<'a> {
35    plot: &'a Plot,
36    plot_div_id: &'a str,
37}
38
39#[derive(Template)]
40#[template(path = "jupyter_notebook_plot.html", escape = "none")]
41struct JupyterNotebookPlotTemplate<'a> {
42    plot: &'a Plot,
43    plot_div_id: &'a str,
44}
45
46#[cfg(not(target_family = "wasm"))]
47const DEFAULT_HTML_APP_NOT_FOUND: &str = r#"Could not find default application for HTML files.
48Consider using the `to_html` method obtain a string representation instead. If using the `kaleido` feature the
49`write_image` method can be used to produce a static image in one of the following formats:
50- ImageFormat::PNG
51- ImageFormat::JPEG
52- ImageFormat::WEBP
53- ImageFormat::SVG
54- ImageFormat::PDF
55- ImageFormat::EPS
56
57Used as follows:
58let plot = Plot::new();
59...
60let width = 1024;
61let height = 680;
62let scale = 1.0;
63plot.write_image("filename", ImageFormat::PNG, width, height, scale);
64
65See https://plotly.github.io/plotly.rs/content/getting_started.html for further details.
66"#;
67
68/// Image format for static image export.
69#[derive(Debug)]
70pub enum ImageFormat {
71    PNG,
72    JPEG,
73    WEBP,
74    SVG,
75    PDF,
76    EPS,
77}
78
79impl std::fmt::Display for ImageFormat {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        write!(
82            f,
83            "{}",
84            match self {
85                Self::PNG => "png",
86                Self::JPEG => "jpeg",
87                Self::WEBP => "webp",
88                Self::SVG => "svg",
89                Self::PDF => "pdf",
90                Self::EPS => "eps",
91            }
92        )
93    }
94}
95
96/// A struct that implements `Trace` can be serialized to json format that is
97/// understood by Plotly.js.
98pub trait Trace: DynClone + ErasedSerialize {
99    fn to_json(&self) -> String;
100}
101
102dyn_clone::clone_trait_object!(Trace);
103erased_serde::serialize_trait_object!(Trace);
104
105#[derive(Default, Serialize, Clone)]
106#[serde(transparent)]
107pub struct Traces {
108    traces: Vec<Box<dyn Trace>>,
109}
110
111impl Traces {
112    pub fn new() -> Self {
113        Self {
114            traces: Vec::with_capacity(1),
115        }
116    }
117
118    pub fn push(&mut self, trace: Box<dyn Trace>) {
119        self.traces.push(trace)
120    }
121
122    pub fn len(&self) -> usize {
123        self.traces.len()
124    }
125
126    pub fn is_empty(&self) -> bool {
127        self.traces.is_empty()
128    }
129
130    pub fn iter(&self) -> std::slice::Iter<'_, Box<dyn Trace>> {
131        self.traces.iter()
132    }
133
134    pub fn to_json(&self) -> String {
135        serde_json::to_string(self).unwrap()
136    }
137}
138
139/// Plot is a container for structs that implement the `Trace` trait. Optionally
140/// a `Layout` can also be specified. Its function is to serialize `Trace`s and
141/// the `Layout` in html format and display and/or persist the resulting plot.
142///
143/// # Examples
144///
145/// ```rust
146/// use plotly::common::Mode;
147/// use plotly::{Layout, Plot, Scatter};
148///
149/// fn line_and_scatter_plot() {
150///     let trace1 = Scatter::new(vec![1, 2, 3, 4], vec![10, 15, 13, 17])
151///         .name("trace1")
152///         .mode(Mode::Markers);
153///     let trace2 = Scatter::new(vec![2, 3, 4, 5], vec![16, 5, 11, 9])
154///         .name("trace2")
155///         .mode(Mode::Lines);
156///     let trace3 = Scatter::new(vec![1, 2, 3, 4], vec![12, 9, 15, 12])
157///         .name("trace3");
158///
159///     let mut plot = Plot::new();
160///     plot.add_trace(trace1);
161///     plot.add_trace(trace2);
162///     plot.add_trace(trace3);
163///
164///     let layout = Layout::new().title("<b>Line and Scatter Plot</b>");
165///     plot.set_layout(layout);
166///
167///     # if false {  // We don't actually want to try and display the plot in a browser when running a doctest.
168///     plot.show();
169///     # }
170/// }
171///
172/// fn main() -> std::io::Result<()> {
173///     line_and_scatter_plot();
174///     Ok(())
175/// }
176/// ```
177#[derive(Default, Serialize, Clone)]
178pub struct Plot {
179    #[serde(rename = "data")]
180    traces: Traces,
181    layout: Layout,
182    #[serde(rename = "config")]
183    configuration: Configuration,
184    #[serde(skip)]
185    js_scripts: String,
186}
187
188impl Plot {
189    /// Create a new `Plot`.
190    pub fn new() -> Plot {
191        Plot {
192            traces: Traces::new(),
193            js_scripts: Self::js_scripts(),
194            ..Default::default()
195        }
196    }
197
198    /// Switch to CDN for `plotly.js` and `MathJax` components in the standalone
199    /// HTML plots rather than using the default local copies of the
200    /// Javascript libraries. Method is only available when the feature
201    /// `plotly_embed_js` is enabled since without this feature the default
202    /// versions used are always the CDN versions.
203    #[cfg(feature = "plotly_embed_js")]
204    pub fn use_cdn_js(&mut self) {
205        self.js_scripts = Self::online_cdn_js();
206    }
207
208    /// Add a `Trace` to the `Plot`.
209    pub fn add_trace(&mut self, trace: Box<dyn Trace>) {
210        self.traces.push(trace);
211    }
212
213    /// Add multiple `Trace`s to the `Plot`.
214    pub fn add_traces(&mut self, traces: Vec<Box<dyn Trace>>) {
215        for trace in traces {
216            self.add_trace(trace);
217        }
218    }
219
220    /// Set the `Layout` to be used by `Plot`.
221    pub fn set_layout(&mut self, layout: Layout) {
222        self.layout = layout;
223    }
224
225    /// Set the `Configuration` to be used by `Plot`.
226    pub fn set_configuration(&mut self, configuration: Configuration) {
227        self.configuration = configuration;
228    }
229
230    /// Get the contained data elements.
231    pub fn data(&self) -> &Traces {
232        &self.traces
233    }
234
235    /// Get the layout specification of the plot.
236    pub fn layout(&self) -> &Layout {
237        &self.layout
238    }
239
240    /// Get the configuration specification of the plot.
241    pub fn configuration(&self) -> &Configuration {
242        &self.configuration
243    }
244
245    /// Display the fully rendered HTML `Plot` in the default system browser.
246    ///
247    /// The HTML file is saved in a temp file, from which it is read and
248    /// displayed by the browser.
249    #[cfg(not(target_family = "wasm"))]
250    pub fn show(&self) {
251        use std::env;
252
253        let rendered = self.render();
254
255        // Set up the temp file with a unique filename.
256        let mut temp = env::temp_dir();
257        let mut plot_name = Alphanumeric.sample_string(&mut thread_rng(), 22);
258        plot_name.push_str(".html");
259        plot_name = format!("plotly_{}", plot_name);
260        temp.push(plot_name);
261
262        // Save the rendered plot to the temp file.
263        let temp_path = temp.to_str().unwrap();
264
265        {
266            let mut file = File::create(temp_path).unwrap();
267            file.write_all(rendered.as_bytes())
268                .expect("failed to write html output");
269            file.flush().unwrap();
270        }
271
272        // Hand off the job of opening the browser to an OS-specific implementation.
273        Plot::show_with_default_app(temp_path);
274    }
275
276    /// Display the fully rendered HTML `Plot` in the default system browser.
277    ///
278    /// The HTML file is generated and saved in the provided filename as long as
279    /// the path already exists, after the file is saved, it is read and
280    /// displayed by the browser.
281    #[cfg(not(target_family = "wasm"))]
282    pub fn show_html<P: AsRef<Path> + std::clone::Clone>(&self, filename: P) {
283        let path = filename.as_ref().to_str().unwrap();
284        self.write_html(filename.clone());
285        // Hand off the job of opening the browser to an OS-specific implementation.
286        Plot::show_with_default_app(path);
287    }
288
289    /// Display the fully rendered `Plot` as a static image of the given format
290    /// in the default system browser.
291    #[cfg(not(target_family = "wasm"))]
292    pub fn show_image(&self, format: ImageFormat, width: usize, height: usize) {
293        use std::env;
294
295        let rendered = self.render_static(format, width, height);
296
297        // Set up the temp file with a unique filename.
298        let mut temp = env::temp_dir();
299        let mut plot_name = Alphanumeric.sample_string(&mut thread_rng(), 22);
300        plot_name.push_str(".html");
301        plot_name = format!("plotly_{}", plot_name);
302        temp.push(plot_name);
303
304        // Save the rendered plot to the temp file.
305        let temp_path = temp.to_str().unwrap();
306
307        {
308            let mut file = File::create(temp_path).unwrap();
309            file.write_all(rendered.as_bytes())
310                .expect("failed to write html output");
311            file.flush().unwrap();
312        }
313
314        // Hand off the job of opening the browser to an OS-specific implementation.
315        Plot::show_with_default_app(temp_path);
316    }
317
318    /// Save the rendered `Plot` to a file at the given location.
319    ///
320    /// This method will render the plot to a full, standalone HTML document,
321    /// before saving it to the given location.
322    pub fn write_html<P: AsRef<Path>>(&self, filename: P) {
323        let rendered = self.to_html();
324
325        let mut file =
326            File::create(filename).expect("Provided filepath does not exist or is not accessible");
327        file.write_all(rendered.as_bytes())
328            .expect("failed to write html output");
329        file.flush().unwrap();
330    }
331
332    /// Convert a `Plot` to an HTML string representation.
333    ///
334    /// This method will generate a full, standalone HTML document. To generate
335    /// a minimal HTML string which can be embedded within an existing HTML
336    /// page, use `Plot::to_inline_html()`.
337    pub fn to_html(&self) -> String {
338        self.render()
339    }
340
341    /// Renders the contents of the `Plot` and returns it as a String suitable
342    /// for embedding within web pages or Jupyter notebooks.
343    ///
344    /// A `div` is generated with the supplied id followed by the `script` block
345    /// that generates the plot. The assumption is that `plotly.js` is
346    /// available within the HTML page that this element is embedded. If
347    /// that assumption is violated then the plot will not be displayed.
348    ///
349    /// If `plot_div_id` is `None` the plot div id will be randomly generated,
350    /// otherwise the user-supplied `plot_div_id` is used.
351    ///
352    /// To generate a full, standalone HTML string or file, use
353    /// `Plot::to_html()` and `Plot::write_html()`, respectively.
354    pub fn to_inline_html(&self, plot_div_id: Option<&str>) -> String {
355        let plot_div_id = match plot_div_id {
356            Some(id) => id.to_string(),
357            None => Alphanumeric.sample_string(&mut thread_rng(), 20),
358        };
359        self.render_inline(&plot_div_id)
360    }
361
362    fn to_jupyter_notebook_html(&self) -> String {
363        let plot_div_id = Alphanumeric.sample_string(&mut thread_rng(), 20);
364
365        let tmpl = JupyterNotebookPlotTemplate {
366            plot: self,
367            plot_div_id: &plot_div_id,
368        };
369        tmpl.render().unwrap()
370    }
371
372    /// Display plot in Jupyter Notebook.
373    pub fn notebook_display(&self) {
374        let plot_data = self.to_jupyter_notebook_html();
375        println!(
376            "EVCXR_BEGIN_CONTENT text/html\n{}\nEVCXR_END_CONTENT",
377            plot_data
378        );
379    }
380
381    /// Display plot in Jupyter Lab.
382    pub fn lab_display(&self) {
383        let plot_data = self.to_json();
384        println!(
385            "EVCXR_BEGIN_CONTENT application/vnd.plotly.v1+json\n{}\nEVCXR_END_CONTENT",
386            plot_data
387        );
388    }
389
390    /// Displays the plot in Jupyter Lab; if running a Jupyter Notebook then use
391    /// the `notebook_display()` method instead.
392    pub fn evcxr_display(&self) {
393        self.lab_display();
394    }
395
396    /// Convert the `Plot` to a static image of the given image format and save
397    /// at the given location.
398    #[cfg(feature = "kaleido")]
399    pub fn write_image<P: AsRef<Path>>(
400        &self,
401        filename: P,
402        format: ImageFormat,
403        width: usize,
404        height: usize,
405        scale: f64,
406    ) {
407        let kaleido = plotly_kaleido::Kaleido::new();
408        kaleido
409            .save(
410                filename.as_ref(),
411                &serde_json::to_value(self).unwrap(),
412                &format.to_string(),
413                width,
414                height,
415                scale,
416            )
417            .unwrap_or_else(|_| panic!("failed to export plot to {:?}", filename.as_ref()));
418    }
419
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
466    fn render(&self) -> String {
467        let tmpl = PlotTemplate {
468            plot: self,
469            js_scripts: self.js_scripts.clone(),
470        };
471        tmpl.render().unwrap()
472    }
473
474    #[cfg(not(target_family = "wasm"))]
475    fn render_static(&self, format: ImageFormat, width: usize, height: usize) -> String {
476        let tmpl = StaticPlotTemplate {
477            plot: self,
478            format,
479            js_scripts: self.js_scripts.clone(),
480            width,
481            height,
482        };
483        tmpl.render().unwrap()
484    }
485
486    fn render_inline(&self, plot_div_id: &str) -> String {
487        let tmpl = InlinePlotTemplate {
488            plot: self,
489            plot_div_id,
490        };
491        tmpl.render().unwrap()
492    }
493
494    fn js_scripts() -> String {
495        if cfg!(feature = "plotly_embed_js") {
496            Self::offline_js_sources()
497        } else {
498            Self::online_cdn_js()
499        }
500    }
501
502    fn offline_js_sources() -> String {
503        let local_plotly_js = include_str!("../templates/plotly.min.js");
504        let local_tex_mml_js = include_str!("../templates/tex-mml-chtml-3.2.0.js");
505        let local_tex_svg_js = include_str!("../templates/tex-svg-3.2.2.js");
506        format!(
507            "<script type=\"text/javascript\">{}</script>\n
508            <script type=\"text/javascript\">
509            /**
510             * tex-mml-chtml JS script
511             **/
512            {}
513            </script>\n
514            <script type=\"text/javascript\">
515            /**
516             * tex-svg JS script
517             **/
518            {}
519            </script>\n",
520            local_plotly_js, local_tex_mml_js, local_tex_svg_js
521        )
522        .to_string()
523    }
524
525    fn online_cdn_js() -> String {
526        r##"<script src="https://cdn.plot.ly/plotly-2.12.1.min.js"></script>
527        <script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js"></script>
528        <script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.0/es5/tex-mml-chtml.js"></script>
529        "##
530        .to_string()
531    }
532
533    pub fn to_json(&self) -> String {
534        serde_json::to_string(self).unwrap()
535    }
536
537    #[cfg(feature = "wasm")]
538    /// Convert a `Plot` to a native Javasript `js_sys::Object`.
539    pub fn to_js_object(&self) -> js_sys::Object {
540        use wasm_bindgen::JsCast;
541        // The only reason this could fail is if to_json() produces structurally
542        // incorrect JSON. That would be a bug, and would require fixing in the
543        // to_json()/serialization methods, rather than here
544        js_sys::JSON::parse(&self.to_json())
545            .expect("Invalid JSON")
546            .dyn_into::<js_sys::Object>()
547            .expect("Invalid JSON structure - expected a top-level Object")
548    }
549
550    #[cfg(all(unix, not(target_os = "android"), not(target_os = "macos")))]
551    fn show_with_default_app(temp_path: &str) {
552        use std::process::Command;
553        Command::new("xdg-open")
554            .args([temp_path])
555            .output()
556            .expect(DEFAULT_HTML_APP_NOT_FOUND);
557    }
558
559    #[cfg(target_os = "macos")]
560    fn show_with_default_app(temp_path: &str) {
561        use std::process::Command;
562        Command::new("open")
563            .args([temp_path])
564            .output()
565            .expect(DEFAULT_HTML_APP_NOT_FOUND);
566    }
567
568    #[cfg(target_os = "windows")]
569    fn show_with_default_app(temp_path: &str) {
570        use std::process::Command;
571        Command::new("cmd")
572            .args(&["/C", "start", &format!(r#"{}"#, temp_path)])
573            .spawn()
574            .expect(DEFAULT_HTML_APP_NOT_FOUND);
575    }
576}
577
578impl PartialEq for Plot {
579    fn eq(&self, other: &Self) -> bool {
580        self.to_json() == other.to_json()
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use std::path::PathBuf;
587
588    #[cfg(feature = "kaleido")]
589    use base64::{engine::general_purpose, Engine as _};
590    use serde_json::{json, to_value};
591
592    use super::*;
593    use crate::Scatter;
594
595    fn create_test_plot() -> Plot {
596        let trace1 = Scatter::new(vec![0, 1, 2], vec![6, 10, 2]).name("trace1");
597        let mut plot = Plot::new();
598        plot.add_trace(trace1);
599        plot
600    }
601
602    #[test]
603    fn inline_plot() {
604        let plot = create_test_plot();
605        let inline_plot_data = plot.to_inline_html(Some("replace_this_with_the_div_id"));
606        assert!(inline_plot_data.contains("replace_this_with_the_div_id"));
607        plot.to_inline_html(None);
608    }
609
610    #[test]
611    fn jupyter_notebook_plot() {
612        let plot = create_test_plot();
613        plot.to_jupyter_notebook_html();
614    }
615
616    #[test]
617    fn notebook_display() {
618        let plot = create_test_plot();
619        plot.notebook_display();
620    }
621
622    #[test]
623    fn lab_display() {
624        let plot = create_test_plot();
625        plot.lab_display();
626    }
627
628    #[test]
629    fn plot_serialize_simple() {
630        let plot = create_test_plot();
631        let expected = json!({
632            "data": [
633                {
634                    "type": "scatter",
635                    "name": "trace1",
636                    "x": [0, 1, 2],
637                    "y": [6, 10, 2]
638                }
639            ],
640            "layout": {},
641            "config": {},
642        });
643
644        assert_eq!(to_value(plot).unwrap(), expected);
645    }
646
647    #[test]
648    fn plot_serialize_with_layout() {
649        let mut plot = create_test_plot();
650        let layout = Layout::new().title("Title");
651        plot.set_layout(layout);
652
653        let expected = json!({
654            "data": [
655                {
656                    "type": "scatter",
657                    "name": "trace1",
658                    "x": [0, 1, 2],
659                    "y": [6, 10, 2]
660                }
661            ],
662            "layout": {
663                "title": {
664                    "text": "Title"
665                }
666            },
667            "config": {},
668        });
669
670        assert_eq!(to_value(plot).unwrap(), expected);
671    }
672
673    #[test]
674    fn data_to_json() {
675        let plot = create_test_plot();
676        let expected = json!([
677            {
678                "type": "scatter",
679                "name": "trace1",
680                "x": [0, 1, 2],
681                "y": [6, 10, 2]
682            }
683        ]);
684
685        assert_eq!(to_value(plot.data()).unwrap(), expected);
686    }
687
688    #[test]
689    fn empty_layout_to_json() {
690        let plot = create_test_plot();
691        let expected = json!({});
692
693        assert_eq!(to_value(plot.layout()).unwrap(), expected);
694    }
695
696    #[test]
697    fn layout_to_json() {
698        let mut plot = create_test_plot();
699        let layout = Layout::new().title("TestTitle");
700        plot.set_layout(layout);
701
702        let expected = json!({
703            "title": {"text": "TestTitle"}
704        });
705
706        assert_eq!(to_value(plot.layout()).unwrap(), expected);
707    }
708
709    #[test]
710    fn plot_eq() {
711        let plot1 = create_test_plot();
712        let plot2 = create_test_plot();
713
714        assert!(plot1 == plot2);
715    }
716
717    #[test]
718    fn plot_neq() {
719        let plot1 = create_test_plot();
720        let trace2 = Scatter::new(vec![10, 1, 2], vec![6, 10, 2]).name("trace2");
721        let mut plot2 = Plot::new();
722        plot2.add_trace(trace2);
723
724        assert!(plot1 != plot2);
725    }
726
727    #[test]
728    fn plot_clone() {
729        let plot1 = create_test_plot();
730        let plot2 = plot1.clone();
731
732        assert!(plot1 == plot2);
733    }
734
735    #[test]
736    #[ignore] // Don't really want it to try and open a browser window every time we run a test.
737    #[cfg(not(feature = "wasm"))]
738    fn show_image() {
739        let plot = create_test_plot();
740        plot.show_image(ImageFormat::PNG, 1024, 680);
741    }
742
743    #[test]
744    fn save_html() {
745        let plot = create_test_plot();
746        let dst = PathBuf::from("example.html");
747        plot.write_html(&dst);
748        assert!(dst.exists());
749        assert!(std::fs::remove_file(&dst).is_ok());
750        assert!(!dst.exists());
751    }
752
753    #[cfg(not(target_os = "macos"))]
754    #[test]
755    #[cfg(feature = "kaleido")]
756    fn save_to_png() {
757        let plot = create_test_plot();
758        let dst = PathBuf::from("example.png");
759        plot.write_image(&dst, ImageFormat::PNG, 1024, 680, 1.0);
760        assert!(dst.exists());
761        assert!(std::fs::remove_file(&dst).is_ok());
762        assert!(!dst.exists());
763    }
764
765    #[cfg(not(target_os = "macos"))]
766    #[test]
767    #[cfg(feature = "kaleido")]
768    fn save_to_jpeg() {
769        let plot = create_test_plot();
770        let dst = PathBuf::from("example.jpeg");
771        plot.write_image(&dst, ImageFormat::JPEG, 1024, 680, 1.0);
772        assert!(dst.exists());
773        assert!(std::fs::remove_file(&dst).is_ok());
774        assert!(!dst.exists());
775    }
776
777    #[cfg(not(target_os = "macos"))]
778    #[test]
779    #[cfg(feature = "kaleido")]
780    fn save_to_svg() {
781        let plot = create_test_plot();
782        let dst = PathBuf::from("example.svg");
783        plot.write_image(&dst, ImageFormat::SVG, 1024, 680, 1.0);
784        assert!(dst.exists());
785        assert!(std::fs::remove_file(&dst).is_ok());
786        assert!(!dst.exists());
787    }
788
789    #[test]
790    #[ignore] // This seems to fail unpredictably on MacOs.
791    #[cfg(feature = "kaleido")]
792    fn save_to_eps() {
793        let plot = create_test_plot();
794        let dst = PathBuf::from("example.eps");
795        plot.write_image(&dst, ImageFormat::EPS, 1024, 680, 1.0);
796        assert!(dst.exists());
797        assert!(std::fs::remove_file(&dst).is_ok());
798        assert!(!dst.exists());
799    }
800
801    #[cfg(not(target_os = "macos"))]
802    #[test]
803    #[cfg(feature = "kaleido")]
804    fn save_to_pdf() {
805        let plot = create_test_plot();
806        let dst = PathBuf::from("example.pdf");
807        plot.write_image(&dst, ImageFormat::PDF, 1024, 680, 1.0);
808        assert!(dst.exists());
809        assert!(std::fs::remove_file(&dst).is_ok());
810        assert!(!dst.exists());
811    }
812
813    #[cfg(not(target_os = "macos"))]
814    #[test]
815    #[cfg(feature = "kaleido")]
816    fn save_to_webp() {
817        let plot = create_test_plot();
818        let dst = PathBuf::from("example.webp");
819        plot.write_image(&dst, ImageFormat::WEBP, 1024, 680, 1.0);
820        assert!(dst.exists());
821        assert!(std::fs::remove_file(&dst).is_ok());
822        assert!(!dst.exists());
823    }
824
825    #[test]
826    #[cfg(not(target_os = "macos"))]
827    #[cfg(feature = "kaleido")]
828    fn image_to_base64() {
829        let plot = create_test_plot();
830
831        let image_base64 = plot.to_base64(ImageFormat::PNG, 200, 150, 1.0);
832
833        assert!(!image_base64.is_empty());
834
835        let result_decoded = general_purpose::STANDARD.decode(image_base64).unwrap();
836        let expected = "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";
837        let expected_decoded = general_purpose::STANDARD.decode(expected).unwrap();
838
839        // Comparing the result seems to end up being a flaky test.
840        // Limit the comparison to the first characters;
841        // As image contents seem to be slightly inconsistent across platforms
842        assert_eq!(expected_decoded[..2], result_decoded[..2]);
843    }
844
845    #[test]
846    #[cfg(feature = "kaleido")]
847    fn image_to_base64_invalid_format() {
848        let plot = create_test_plot();
849        let image_base64 = plot.to_base64(ImageFormat::EPS, 200, 150, 1.0);
850        assert!(image_base64.is_empty());
851    }
852
853    #[test]
854    #[cfg(not(target_os = "macos"))]
855    #[cfg(feature = "kaleido")]
856    fn image_to_svg_string() {
857        let plot = create_test_plot();
858        let image_svg = plot.to_svg(200, 150, 1.0);
859
860        assert!(!image_svg.is_empty());
861
862        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>";
863        // Limit the test to the first LEN characters as generated SVGs
864        // seem to contain uniquely generated IDs
865        const LEN: usize = 10;
866        assert_eq!(expected[..LEN], image_svg[..LEN]);
867    }
868}