plotly/
plot.rs

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
78/// A struct that implements `Trace` can be serialized to json format that is
79/// understood by Plotly.js.
80pub 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/// Plot is a container for structs that implement the `Trace` trait. Optionally
122/// a `Layout` can also be specified. Its function is to serialize `Trace`s and
123/// the `Layout` in html format and display and/or persist the resulting plot.
124///
125/// # Examples
126///
127/// ```rust
128/// use plotly::common::Mode;
129/// use plotly::{Layout, Plot, Scatter};
130///
131/// fn line_and_scatter_plot() {
132///     let trace1 = Scatter::new(vec![1, 2, 3, 4], vec![10, 15, 13, 17])
133///         .name("trace1")
134///         .mode(Mode::Markers);
135///     let trace2 = Scatter::new(vec![2, 3, 4, 5], vec![16, 5, 11, 9])
136///         .name("trace2")
137///         .mode(Mode::Lines);
138///     let trace3 = Scatter::new(vec![1, 2, 3, 4], vec![12, 9, 15, 12])
139///         .name("trace3");
140///
141///     let mut plot = Plot::new();
142///     plot.add_trace(trace1);
143///     plot.add_trace(trace2);
144///     plot.add_trace(trace3);
145///
146///     let layout = Layout::new().title("<b>Line and Scatter Plot</b>");
147///     plot.set_layout(layout);
148///
149///     # if false {  // We don't actually want to try and display the plot in a browser when running a doctest.
150///     plot.show();
151///     # }
152/// }
153///
154/// fn main() -> std::io::Result<()> {
155///     line_and_scatter_plot();
156///     Ok(())
157/// }
158/// ```
159#[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    /// Animation frames
167    frames: Option<Vec<Frame>>,
168    #[serde(skip)]
169    js_scripts: String,
170}
171
172impl Plot {
173    /// Create a new `Plot`.
174    pub fn new() -> Plot {
175        Plot {
176            traces: Traces::new(),
177            js_scripts: Self::js_scripts(),
178            ..Default::default()
179        }
180    }
181
182    /// Switch to CDN for `plotly.js` and `MathJax` components in the standalone
183    /// HTML plots rather than using the default local copies of the
184    /// Javascript libraries. Method is only available when the feature
185    /// `plotly_embed_js` is enabled since without this feature the default
186    /// versions used are always the CDN versions.
187    #[cfg(feature = "plotly_embed_js")]
188    pub fn use_cdn_js(&mut self) {
189        self.js_scripts = Self::online_cdn_js();
190    }
191
192    /// Add a `Trace` to the `Plot`.
193    pub fn add_trace(&mut self, trace: Box<dyn Trace>) {
194        self.traces.push(trace);
195    }
196
197    /// Add multiple `Trace`s to the `Plot`.
198    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    /// Set the `Layout` to be used by `Plot`.
205    pub fn set_layout(&mut self, layout: Layout) {
206        self.layout = layout;
207    }
208
209    /// Set the `Configuration` to be used by `Plot`.
210    pub fn set_configuration(&mut self, configuration: Configuration) {
211        self.configuration = configuration;
212    }
213
214    /// Get the contained data elements.
215    pub fn data(&self) -> &Traces {
216        &self.traces
217    }
218
219    /// Get the layout specification of the plot.
220    pub fn layout(&self) -> &Layout {
221        &self.layout
222    }
223
224    /// Get the configuration specification of the plot.
225    pub fn configuration(&self) -> &Configuration {
226        &self.configuration
227    }
228
229    /// Add a single frame to the animation sequence.
230    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    /// Add multiple frames to the animation sequence.
239    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    /// Get the animation frames as mutable reference
257    pub fn frames_mut(&mut self) -> Option<&mut Vec<Frame>> {
258        self.frames.as_mut()
259    }
260
261    /// Get the animation frames.
262    pub fn frames(&self) -> Option<&[Frame]> {
263        self.frames.as_deref()
264    }
265
266    /// Display the fully rendered HTML `Plot` in the default system browser.
267    ///
268    /// The HTML file is saved in a temp file, from which it is read and
269    /// displayed by the browser.
270    #[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        // Set up the temp file with a unique filename.
276        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        // Save the rendered plot to the temp file.
284        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        // Hand off the job of opening the browser to an OS-specific implementation.
294        Plot::show_with_default_app(temp_path);
295    }
296
297    /// Display the fully rendered HTML `Plot` in the default system browser.
298    ///
299    /// The HTML file is generated and saved in the provided filename as long as
300    /// the path already exists, after the file is saved, it is read and
301    /// displayed by the browser.
302    #[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        // Hand off the job of opening the browser to an OS-specific implementation.
307        Plot::show_with_default_app(path);
308    }
309
310    /// Display the fully rendered `Plot` as a static image of the given format
311    /// in the default system browser.
312    #[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        // Set up the temp file with a unique filename.
320        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        // Save the rendered plot to the temp file.
328        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        // Hand off the job of opening the browser to an OS-specific implementation.
338        Plot::show_with_default_app(temp_path);
339    }
340
341    /// Save the rendered `Plot` to a file at the given location.
342    ///
343    /// This method will render the plot to a full, standalone HTML document,
344    /// before saving it to the given location.
345    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    /// Convert a `Plot` to an HTML string representation.
356    ///
357    /// This method will generate a full, standalone HTML document. To generate
358    /// a minimal HTML string which can be embedded within an existing HTML
359    /// page, use `Plot::to_inline_html()`.
360    pub fn to_html(&self) -> String {
361        self.render()
362    }
363
364    /// Renders the contents of the `Plot` and returns it as a String suitable
365    /// for embedding within web pages or Jupyter notebooks.
366    ///
367    /// A `div` is generated with the supplied id followed by the `script` block
368    /// that generates the plot. The assumption is that `plotly.js` is
369    /// available within the HTML page that this element is embedded. If
370    /// that assumption is violated then the plot will not be displayed.
371    ///
372    /// If `plot_div_id` is `None` the plot div id will be randomly generated,
373    /// otherwise the user-supplied `plot_div_id` is used.
374    ///
375    /// To generate a full, standalone HTML string or file, use
376    /// `Plot::to_html()` and `Plot::write_html()`, respectively.
377    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    /// Display plot in Jupyter Notebook.
399    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    /// Display plot in Jupyter Lab.
405    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    /// Displays the plot in Jupyter Lab; if running a Jupyter Notebook then use
413    /// the `notebook_display()` method instead.
414    pub fn evcxr_display(&self) {
415        self.lab_display();
416    }
417
418    /// Convert the `Plot` to a static image of the given image format and save
419    /// at the given location using kaleido.
420    ///
421    /// This function is deprecated since version 0.13.0. The kaleido-based
422    /// implementation will be removed in version 0.14.0. Use
423    /// `plotly_static` feature instead for static image export functionality.
424    #[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    /// Convert the `Plot` to a static image and return the image as a `base64`
451    /// String using kaleido. Supported formats are [ImageFormat::JPEG],
452    /// [ImageFormat::PNG] and [ImageFormat::WEBP]
453    ///
454    /// This function is deprecated since version 0.13.0. The kaleido-based
455    /// implementation will be removed in version 0.14.0. Use
456    /// `plotly_static` feature instead for static image export functionality.
457    #[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    /// Convert the `Plot` to SVG and return it as a String using kaleido.
490    ///
491    /// This function is deprecated since version 0.13.0. The kaleido-based
492    /// implementation will be removed in version 0.14.0. Use
493    /// `plotly_static` feature instead for static image export functionality.
494    #[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    /// Convert the `Plot` to a static image of the given image format and save
513    /// at the given location.
514    ///
515    /// This method requires the usage of the `plotly_static` crate using one of
516    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
517    ///
518    /// **Note:** This method creates a new `StaticExporter` (and thus a new
519    /// WebDriver instance) for each call, which is not performant for
520    /// repeated operations. For better performance and resource management,
521    /// consider using `write_image_with_exporter` to reuse a single
522    /// `StaticExporter` instance across multiple operations.
523    #[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    /// Convert the `Plot` to a static image and return the image as a `base64`
539    /// String. Supported formats are [ImageFormat::JPEG],
540    /// [ImageFormat::PNG] and [ImageFormat::WEBP].
541    ///
542    /// This method uses the [plotly_static](https://docs.rs/plotly_static/) crate and requires a WebDriver-compatible browser (Chrome or Firefox) to be available on the system.
543    ///
544    /// For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
545    ///
546    ///
547    /// **Note:** This method creates a new `StaticExporter` (and thus a new
548    /// WebDriver instance) for each call, which is not performant for
549    /// repeated operations. For better performance and resource management,
550    /// consider using `to_base64_with_exporter` to reuse a single
551    /// `StaticExporter` instance across multiple operations.
552    #[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    /// Convert the `Plot` to SVG and return it as a String using plotly_static.
567    ///
568    /// This method requires the usage of the `plotly_static` crate using one of
569    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
570    ///
571    /// **Note:** This method creates a new `StaticExporter` (and thus a new
572    /// WebDriver instance) for each call, which is not performant for
573    /// repeated operations. For better performance and resource management,
574    /// consider using `to_svg_with_exporter` to reuse a single
575    /// `StaticExporter` instance across multiple operations.
576    #[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    /// Convert the `Plot` to a static image of the given image format and save
590    /// at the given location using a provided StaticExporter.
591    ///
592    /// This method allows you to reuse a StaticExporter instance across
593    /// multiple plots, which is more efficient than creating a new one for
594    /// each operation.
595    ///
596    /// This method requires the usage of the `plotly_static` crate using one of
597    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
598    ///
599    /// # Arguments
600    ///
601    /// * `exporter` - A mutable reference to a StaticExporter instance
602    /// * `filename` - The destination path for the output file
603    /// * `format` - The desired output image format
604    /// * `width` - The width of the output image in pixels
605    /// * `height` - The height of the output image in pixels
606    /// * `scale` - The scale factor for the image (1.0 = normal size)
607    ///
608    /// # Examples
609    ///
610    /// ```no_run
611    /// use plotly::{Plot, Scatter};
612    /// use plotly_static::{StaticExporterBuilder, ImageFormat};
613    ///
614    /// let mut plot = Plot::new();
615    /// plot.add_trace(Scatter::new(vec![1, 2, 3], vec![4, 5, 6]));
616    ///
617    /// let mut exporter = StaticExporterBuilder::default()
618    ///     .build()
619    ///     .expect("Failed to create StaticExporter");
620    ///
621    /// // Export multiple plots using the same exporter
622    /// plot.write_image_with_exporter(&mut exporter, "plot1", ImageFormat::PNG, 800, 600, 1.0)
623    ///     .expect("Failed to export plot");
624    /// ```
625    #[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    /// Convert the `Plot` to a static image and return the image as a `base64`
646    /// String using a provided StaticExporter. Supported formats are
647    /// [ImageFormat::JPEG], [ImageFormat::PNG] and [ImageFormat::WEBP].
648    ///
649    /// This method allows you to reuse a StaticExporter instance across
650    /// multiple plots, which is more efficient than creating a new one for
651    /// each operation.
652    ///
653    /// This method requires the usage of the `plotly_static` crate using one of
654    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
655    ///
656    /// # Arguments
657    ///
658    /// * `exporter` - A mutable reference to a StaticExporter instance
659    /// * `format` - The desired output image format
660    /// * `width` - The width of the output image in pixels
661    /// * `height` - The height of the output image in pixels
662    /// * `scale` - The scale factor for the image (1.0 = normal size)
663    ///
664    /// # Examples
665    ///
666    /// ```no_run
667    /// use plotly::{Plot, Scatter};
668    /// use plotly_static::{StaticExporterBuilder, ImageFormat};
669    ///
670    /// let mut plot = Plot::new();
671    /// plot.add_trace(Scatter::new(vec![1, 2, 3], vec![4, 5, 6]));
672    ///
673    /// let mut exporter = StaticExporterBuilder::default()
674    ///     .build()
675    ///     .expect("Failed to create StaticExporter");
676    ///
677    /// let base64_data = plot.to_base64_with_exporter(&mut exporter, ImageFormat::PNG, 800, 600, 1.0)
678    ///     .expect("Failed to export plot");
679    /// ```
680    #[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    /// Convert the `Plot` to SVG and return it as a String using a provided
706    /// StaticExporter.
707    ///
708    /// This method allows you to reuse a StaticExporter instance across
709    /// multiple plots, which is more efficient than creating a new one for
710    /// each operation.
711    ///
712    /// This method requires the usage of the `plotly_static` crate using one of
713    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
714    ///
715    /// # Arguments
716    ///
717    /// * `exporter` - A mutable reference to a StaticExporter instance
718    /// * `width` - The width of the output image in pixels
719    /// * `height` - The height of the output image in pixels
720    /// * `scale` - The scale factor for the image (1.0 = normal size)
721    ///
722    /// # Examples
723    ///
724    /// ```no_run
725    /// use plotly::{Plot, Scatter};
726    /// use plotly_static::StaticExporterBuilder;
727    ///
728    /// let mut plot = Plot::new();
729    /// plot.add_trace(Scatter::new(vec![1, 2, 3], vec![4, 5, 6]));
730    ///
731    /// let mut exporter = StaticExporterBuilder::default()
732    ///     .build()
733    ///     .expect("Failed to create StaticExporter");
734    ///
735    /// let svg_data = plot.to_svg_with_exporter(&mut exporter, 800, 600, 1.0)
736    ///     .expect("Failed to export plot");
737    /// ```
738    #[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    /// Returns HTML script tags containing embedded JavaScript sources for
793    /// offline use.
794    ///
795    /// This function embeds the Plotly.js library and MathJax (tex-svg)
796    /// JavaScript directly into the HTML output, allowing plots to work
797    /// without an internet connection. The embedded sources include:
798    /// - Plotly.js library for interactive plotting
799    /// - MathJax tex-svg for rendering mathematical expressions
800    ///
801    /// This is used when the `plotly_embed_js` feature is enabled, providing
802    /// self-contained HTML files that don't require external CDN resources.
803    pub fn offline_js_sources() -> String {
804        // Note that since 'tex-mml-chtml' conflicts with 'tex-svg' when generating
805        // Latex Titles we no longer include it.
806        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    /// Returns HTML script tags that reference external CDN resources for
817    /// online use.
818    ///
819    /// This function provides HTML script tags that load JavaScript libraries
820    /// from external CDN sources, requiring an internet connection to
821    /// function. The referenced sources include:
822    /// - Plotly.js library from CDN (version 3.0.1)
823    /// - MathJax tex-svg from jsDelivr CDN (version 3.2.2)
824    ///
825    /// This is the default behavior when the `plotly_embed_js` feature is
826    /// disabled, providing smaller HTML files that rely on external
827    /// resources.
828    pub fn online_cdn_js() -> String {
829        // Note that since 'tex-mml-chtml' conflicts with 'tex-svg' when generating
830        // Latex Titles we no longer include it.
831        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    /// Convert a `Plot` to a native JavaScript `js_sys::Object`.
843    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        // The only reason this could fail is if to_json() produces structurally
847        // incorrect JSON. That would be a bug, and would require fixing in the
848        // to_json()/serialization methods, rather than here
849        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    /// Generate unique seeds for SmallRng such that file names and div names
883    /// are unique random for each call
884    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    // Helper to generate unique ports for parallel tests
1070    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 = "iVBORw0KGgoAAAANSUhEUgAAAMgAAACWCAYAAACb3McZAAAH0klEQVR4Xu2bSWhVZxiGv2gC7SKJWrRWxaGoULsW7L7gXlAMKApiN7pxI46ggnNQcDbOoAZUcCG4CCiIQ4MSkWKFLNSCihTR2ESTCNVb/lMTEmvu8OYuTN/nQBHb895zv+f9H+6ZWpHL5XLBBgEIfJZABYKwMiAwMAEEYXVAIA8BBGF5QABBWAMQ0AjwC6JxI2VCAEFMimZMjQCCaNxImRBAEJOiGVMjgCAaN1ImBBDEpGjG1AggiMaNlAkBBDEpmjE1AgiicSNlQgBBTIpmTI0AgmjcSJkQQBCTohlTI4AgGjdSJgQQxKRoxtQIIIjGjZQJAQQxKZoxNQIIonEjZUIAQUyKZkyNAIJo3EiZEEAQk6IZUyOAIBo3UiYEEMSkaMbUCCCIxo2UCQEEMSmaMTUCCKJxI2VCAEFMimZMjQCCaNxImRBAEJOiGVMjgCAaN1ImBBDEpGjG1AggiMaNlAkBBDEpmjE1AgiicSNlQgBBTIpmTI0AgmjcSJkQQBCTohlTI4AgGjdSJgQQxKRoxtQIIIjGjZQJAQQxKZoxNQIIonEjZUIAQUyKZkyNAIJo3EiZEEAQk6IZUyOAIBo3UiYEEMSkaMbUCCCIxo2UCQEEMSmaMTUCCPKR26NHj+LUqVNx69atuHDhQtTW1vYSvX37dhw4cCC6u7tj4sSJsXr16hg5cqRGnNSQIoAgH+vavHlzzJ49O9auXRvnzp3rFeTNmzdRV1cXHz58yP7J5XIxbdq02Lt375Aqmi+rEUCQT7glSfoKcunSpdizZ0+MGDEik+PVq1cxfPjwuHz5clRVVWnUSQ0ZAghSQJA1a9ZEOsVqaGiIHTt2xLNnz6Krqys7HRs/fvyQKZovqhFAkAKCpFOuO3fuxOjRo+Pdu3fR3t6e/ZIcPHgwpk6dqlEnNWQIIEgBQTZu3Bg3b96MioqKmDBhQjx58iQT5OTJk/1+QX599DLqGpr/U3wuF1FRUb71MOv7b6Lmq8qYMa42Hjz/K5p+/7Pfh6f/9tuG2eU7oPknIUgBQbZu3RpXrlyJ7du3Z9ceK1euzAQ5c+ZMjBkzpjc9kCDVaTF/V5PtlxZ3z1bzdVXMGPfvv69vao2WP9r6fZMfx9XEzz98G0/buuJpW2c8eN4eHd1/99tnIPkaf5kVP/U5lvkaH9T4CFJAkBUrVsT9+/dj6dKlkS7YOzo6It3ZOnr0aEyePHlQ8Al/+QQQJCJb9EmAtL18+TJGjRqVnVIdOnQo6uvro7m5Ofv7sGHDslu9aduyZUvMnDnzy2+YbzgoAghSAN/bt29j/vz58f79++zUKv2ZZJo7d+6gwBMeGgQQpEBPTU1NsWvXruw5SNra2tqiuro6Tpw4kf3J9v8mgCBl7Hcwr6Tke9Ul31e8evVqnD59OrsFnW4apGum9DoMW3kIIEh5OGYX7osWLYp012v69OnZon38+HGsX7++qCMM9KpLvnB6aLl8+fLYt29fdsu5sbEx7t69Gzt37izqmOxUmACCFGZU1B7Xrl2LdDqWFnraOjs7Y968eXHx4sWSXkn59FWXfAdP10cvXrzovZv28OHDWLduXSYKW3kIIEh5OGbPRV6/fh3Lli3r/cQkyO7du0t6JaUUQT796ufPn4/W1tZMErbyEECQ8nCM48eP997h6vnIBQsWxIYNG0p6JUUV5N69e9mpVRKy7wPMMo1n+zEIUqbqz549m93h6vsLMmfOnOy1+FJealQEuXHjRhw+fDg2bdoUU6ZMKdNEfEwigCBlWgfXr1/PXoFPF+lpS6dbCxcuzK5BKisriz5KqYKkFyn3798f27Zti7FjxxZ9HHYsjgCCFMep4F7pgnnx4sXZRXq6i3Xs2LHsqXx6d6uUrRRB0jGXLFmSvSc2adKkUg7DvkUSQJAiQRWzW0tLS3ZKle5gpf/rcNWqVUU9TMz3qkvPA8rPHf/Th5g9+xw5cqSo4xYzk/s+COK+Apg/LwEEYYFAIA8BBGF5QABBWAMQ0AjwC6JxI2VCAEFMimZMjQCCaNxImRBAEJOiGVMjgCAaN1ImBBDEpGjG1AggiMaNlAkBBDEpmjE1AgiicSNlQgBBTIpmTI0AgmjcSJkQQBCTohlTI4AgGjdSJgQQxKRoxtQIIIjGjZQJAQQxKZoxNQIIonEjZUIAQUyKZkyNAIJo3EiZEEAQk6IZUyOAIBo3UiYEEMSkaMbUCCCIxo2UCQEEMSmaMTUCCKJxI2VC4B+Ci/5sJeSfvgAAAABJRU5ErkJggg==";
1197        let expected_decoded = general_purpose::STANDARD.decode(expected).unwrap();
1198
1199        // Comparing the result seems to end up being a flaky test.
1200        // Limit the comparison to the first characters;
1201        // As image contents seem to be slightly inconsistent across platforms
1202        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        // Limit the test to the first LEN characters as generated SVGs
1221        // seem to contain uniquely generated IDs
1222        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}