plotly/
plot.rs

1use std::{fs::File, io::Write, path::Path};
2
3use askama::Template;
4use dyn_clone::DynClone;
5use erased_serde::Serialize as ErasedSerialize;
6#[cfg(feature = "kaleido")]
7use plotly_kaleido::ImageFormat;
8#[cfg(feature = "plotly_static")]
9use plotly_static::ImageFormat;
10use rand::{
11    distr::{Alphanumeric, SampleString},
12    rng,
13};
14use serde::Serialize;
15
16use crate::{Configuration, Layout};
17
18#[derive(Template)]
19#[template(path = "plot.html", escape = "none")]
20struct PlotTemplate<'a> {
21    plot: &'a Plot,
22    js_scripts: &'a str,
23}
24
25#[cfg(any(feature = "kaleido", feature = "plotly_static"))]
26#[derive(Template)]
27#[template(path = "static_plot.html", escape = "none")]
28#[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
29struct StaticPlotTemplate<'a> {
30    plot: &'a Plot,
31    format: ImageFormat,
32    js_scripts: &'a str,
33    width: usize,
34    height: usize,
35}
36
37#[derive(Template)]
38#[template(path = "inline_plot.html", escape = "none")]
39struct InlinePlotTemplate<'a> {
40    plot: &'a Plot,
41    plot_div_id: &'a str,
42}
43
44#[derive(Template)]
45#[template(path = "jupyter_notebook_plot.html", escape = "none")]
46struct JupyterNotebookPlotTemplate<'a> {
47    plot: &'a Plot,
48    plot_div_id: &'a str,
49}
50
51#[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
52const DEFAULT_HTML_APP_NOT_FOUND: &str = r#"Could not find default application for HTML files.
53Consider using the `to_html` method obtain a string representation instead. If using the `kaleido` or `plotly_static` feature the
54`write_image` method can be used to produce a static image in one of the following formats:
55- ImageFormat::PNG
56- ImageFormat::JPEG
57- ImageFormat::WEBP
58- ImageFormat::SVG
59- ImageFormat::PDF
60- ImageFormat::EPS // will be removed in version 0.14.0
61
62Used as follows:
63let plot = Plot::new();
64...
65let width = 1024;
66let height = 680;
67let scale = 1.0;
68plot.write_image("filename", ImageFormat::PNG, width, height, scale);
69
70See https://plotly.github.io/plotly.rs/content/getting_started.html for further details.
71"#;
72
73/// A struct that implements `Trace` can be serialized to json format that is
74/// understood by Plotly.js.
75pub trait Trace: DynClone + ErasedSerialize {
76    fn to_json(&self) -> String;
77}
78
79dyn_clone::clone_trait_object!(Trace);
80erased_serde::serialize_trait_object!(Trace);
81
82#[derive(Default, Serialize, Clone)]
83#[serde(transparent)]
84pub struct Traces {
85    traces: Vec<Box<dyn Trace>>,
86}
87
88impl Traces {
89    pub fn new() -> Self {
90        Self {
91            traces: Vec::with_capacity(1),
92        }
93    }
94
95    pub fn push(&mut self, trace: Box<dyn Trace>) {
96        self.traces.push(trace)
97    }
98
99    pub fn len(&self) -> usize {
100        self.traces.len()
101    }
102
103    pub fn is_empty(&self) -> bool {
104        self.traces.is_empty()
105    }
106
107    pub fn iter(&self) -> std::slice::Iter<'_, Box<dyn Trace>> {
108        self.traces.iter()
109    }
110
111    pub fn to_json(&self) -> String {
112        serde_json::to_string(self).unwrap()
113    }
114}
115
116/// Plot is a container for structs that implement the `Trace` trait. Optionally
117/// a `Layout` can also be specified. Its function is to serialize `Trace`s and
118/// the `Layout` in html format and display and/or persist the resulting plot.
119///
120/// # Examples
121///
122/// ```rust
123/// use plotly::common::Mode;
124/// use plotly::{Layout, Plot, Scatter};
125///
126/// fn line_and_scatter_plot() {
127///     let trace1 = Scatter::new(vec![1, 2, 3, 4], vec![10, 15, 13, 17])
128///         .name("trace1")
129///         .mode(Mode::Markers);
130///     let trace2 = Scatter::new(vec![2, 3, 4, 5], vec![16, 5, 11, 9])
131///         .name("trace2")
132///         .mode(Mode::Lines);
133///     let trace3 = Scatter::new(vec![1, 2, 3, 4], vec![12, 9, 15, 12])
134///         .name("trace3");
135///
136///     let mut plot = Plot::new();
137///     plot.add_trace(trace1);
138///     plot.add_trace(trace2);
139///     plot.add_trace(trace3);
140///
141///     let layout = Layout::new().title("<b>Line and Scatter Plot</b>");
142///     plot.set_layout(layout);
143///
144///     # if false {  // We don't actually want to try and display the plot in a browser when running a doctest.
145///     plot.show();
146///     # }
147/// }
148///
149/// fn main() -> std::io::Result<()> {
150///     line_and_scatter_plot();
151///     Ok(())
152/// }
153/// ```
154#[derive(Default, Serialize, Clone)]
155pub struct Plot {
156    #[serde(rename = "data")]
157    traces: Traces,
158    layout: Layout,
159    #[serde(rename = "config")]
160    configuration: Configuration,
161    #[serde(skip)]
162    js_scripts: String,
163}
164
165impl Plot {
166    /// Create a new `Plot`.
167    pub fn new() -> Plot {
168        Plot {
169            traces: Traces::new(),
170            js_scripts: Self::js_scripts(),
171            ..Default::default()
172        }
173    }
174
175    /// Switch to CDN for `plotly.js` and `MathJax` components in the standalone
176    /// HTML plots rather than using the default local copies of the
177    /// Javascript libraries. Method is only available when the feature
178    /// `plotly_embed_js` is enabled since without this feature the default
179    /// versions used are always the CDN versions.
180    #[cfg(feature = "plotly_embed_js")]
181    pub fn use_cdn_js(&mut self) {
182        self.js_scripts = Self::online_cdn_js();
183    }
184
185    /// Add a `Trace` to the `Plot`.
186    pub fn add_trace(&mut self, trace: Box<dyn Trace>) {
187        self.traces.push(trace);
188    }
189
190    /// Add multiple `Trace`s to the `Plot`.
191    pub fn add_traces(&mut self, traces: Vec<Box<dyn Trace>>) {
192        for trace in traces {
193            self.add_trace(trace);
194        }
195    }
196
197    /// Set the `Layout` to be used by `Plot`.
198    pub fn set_layout(&mut self, layout: Layout) {
199        self.layout = layout;
200    }
201
202    /// Set the `Configuration` to be used by `Plot`.
203    pub fn set_configuration(&mut self, configuration: Configuration) {
204        self.configuration = configuration;
205    }
206
207    /// Get the contained data elements.
208    pub fn data(&self) -> &Traces {
209        &self.traces
210    }
211
212    /// Get the layout specification of the plot.
213    pub fn layout(&self) -> &Layout {
214        &self.layout
215    }
216
217    /// Get the configuration specification of the plot.
218    pub fn configuration(&self) -> &Configuration {
219        &self.configuration
220    }
221
222    /// Display the fully rendered HTML `Plot` in the default system browser.
223    ///
224    /// The HTML file is saved in a temp file, from which it is read and
225    /// displayed by the browser.
226    #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
227    pub fn show(&self) {
228        use std::env;
229
230        let rendered = self.render();
231
232        // Set up the temp file with a unique filename.
233        let mut temp = env::temp_dir();
234        let mut plot_name = Alphanumeric.sample_string(&mut rng(), 22);
235        plot_name.push_str(".html");
236        plot_name = format!("plotly_{plot_name}");
237        temp.push(plot_name);
238
239        // Save the rendered plot to the temp file.
240        let temp_path = temp.to_str().unwrap();
241
242        {
243            let mut file = File::create(temp_path).unwrap();
244            file.write_all(rendered.as_bytes())
245                .expect("failed to write html output");
246            file.flush().unwrap();
247        }
248
249        // Hand off the job of opening the browser to an OS-specific implementation.
250        Plot::show_with_default_app(temp_path);
251    }
252
253    /// Display the fully rendered HTML `Plot` in the default system browser.
254    ///
255    /// The HTML file is generated and saved in the provided filename as long as
256    /// the path already exists, after the file is saved, it is read and
257    /// displayed by the browser.
258    #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
259    pub fn show_html<P: AsRef<Path> + std::clone::Clone>(&self, filename: P) {
260        let path = filename.as_ref().to_str().unwrap();
261        self.write_html(filename.clone());
262        // Hand off the job of opening the browser to an OS-specific implementation.
263        Plot::show_with_default_app(path);
264    }
265
266    /// Display the fully rendered `Plot` as a static image of the given format
267    /// in the default system browser.
268    #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
269    #[cfg(any(feature = "kaleido", feature = "plotly_static"))]
270    pub fn show_image(&self, format: ImageFormat, width: usize, height: usize) {
271        use std::env;
272
273        let rendered = self.render_static(&format, width, height);
274
275        // Set up the temp file with a unique filename.
276        let mut temp = env::temp_dir();
277        let mut plot_name = Alphanumeric.sample_string(&mut rng(), 22);
278        plot_name.push_str(".html");
279        plot_name = format!("plotly_{plot_name}");
280        temp.push(plot_name);
281
282        // Save the rendered plot to the temp file.
283        let temp_path = temp.to_str().unwrap();
284
285        {
286            let mut file = File::create(temp_path).unwrap();
287            file.write_all(rendered.as_bytes())
288                .expect("failed to write html output");
289            file.flush().unwrap();
290        }
291
292        // Hand off the job of opening the browser to an OS-specific implementation.
293        Plot::show_with_default_app(temp_path);
294    }
295
296    /// Save the rendered `Plot` to a file at the given location.
297    ///
298    /// This method will render the plot to a full, standalone HTML document,
299    /// before saving it to the given location.
300    pub fn write_html<P: AsRef<Path>>(&self, filename: P) {
301        let rendered = self.to_html();
302
303        let mut file =
304            File::create(filename).expect("Provided filepath does not exist or is not accessible");
305        file.write_all(rendered.as_bytes())
306            .expect("failed to write html output");
307        file.flush().unwrap();
308    }
309
310    /// Convert a `Plot` to an HTML string representation.
311    ///
312    /// This method will generate a full, standalone HTML document. To generate
313    /// a minimal HTML string which can be embedded within an existing HTML
314    /// page, use `Plot::to_inline_html()`.
315    pub fn to_html(&self) -> String {
316        self.render()
317    }
318
319    /// Renders the contents of the `Plot` and returns it as a String suitable
320    /// for embedding within web pages or Jupyter notebooks.
321    ///
322    /// A `div` is generated with the supplied id followed by the `script` block
323    /// that generates the plot. The assumption is that `plotly.js` is
324    /// available within the HTML page that this element is embedded. If
325    /// that assumption is violated then the plot will not be displayed.
326    ///
327    /// If `plot_div_id` is `None` the plot div id will be randomly generated,
328    /// otherwise the user-supplied `plot_div_id` is used.
329    ///
330    /// To generate a full, standalone HTML string or file, use
331    /// `Plot::to_html()` and `Plot::write_html()`, respectively.
332    pub fn to_inline_html(&self, plot_div_id: Option<&str>) -> String {
333        let plot_div_id = match plot_div_id {
334            Some(id) => id.to_string(),
335            None => Alphanumeric.sample_string(&mut rng(), 20),
336        };
337        self.render_inline(&plot_div_id)
338    }
339
340    fn to_jupyter_notebook_html(&self) -> String {
341        let plot_div_id = Alphanumeric.sample_string(&mut rng(), 20);
342
343        let tmpl = JupyterNotebookPlotTemplate {
344            plot: self,
345            plot_div_id: &plot_div_id,
346        };
347        tmpl.render().unwrap()
348    }
349
350    /// Display plot in Jupyter Notebook.
351    pub fn notebook_display(&self) {
352        let plot_data = self.to_jupyter_notebook_html();
353        println!("EVCXR_BEGIN_CONTENT text/html\n{plot_data}\nEVCXR_END_CONTENT");
354    }
355
356    /// Display plot in Jupyter Lab.
357    pub fn lab_display(&self) {
358        let plot_data = self.to_json();
359        println!(
360            "EVCXR_BEGIN_CONTENT application/vnd.plotly.v1+json\n{plot_data}\nEVCXR_END_CONTENT"
361        );
362    }
363
364    /// Displays the plot in Jupyter Lab; if running a Jupyter Notebook then use
365    /// the `notebook_display()` method instead.
366    pub fn evcxr_display(&self) {
367        self.lab_display();
368    }
369
370    /// Convert the `Plot` to a static image of the given image format and save
371    /// at the given location using kaleido.
372    ///
373    /// This function is deprecated since version 0.13.0. The kaleido-based
374    /// implementation will be removed in version 0.14.0. Use
375    /// `plotly_static` feature instead for static image export functionality.
376    #[deprecated(
377        since = "0.13.0",
378        note = "kaleido-based implementation is deprecated. Use plotly_static feature instead. The kaleido implementation will be removed in version 0.14.0"
379    )]
380    #[cfg(feature = "kaleido")]
381    pub fn write_image<P: AsRef<Path>>(
382        &self,
383        filename: P,
384        format: ImageFormat,
385        width: usize,
386        height: usize,
387        scale: f64,
388    ) {
389        let kaleido = plotly_kaleido::Kaleido::new();
390        kaleido
391            .save(
392                filename.as_ref(),
393                &serde_json::to_value(self).unwrap(),
394                format,
395                width,
396                height,
397                scale,
398            )
399            .unwrap_or_else(|_| panic!("failed to export plot to {:?}", filename.as_ref()));
400    }
401
402    /// Convert the `Plot` to a static image and return the image as a `base64`
403    /// String using kaleido. Supported formats are [ImageFormat::JPEG],
404    /// [ImageFormat::PNG] and [ImageFormat::WEBP]
405    ///
406    /// This function is deprecated since version 0.13.0. The kaleido-based
407    /// implementation will be removed in version 0.14.0. Use
408    /// `plotly_static` feature instead for static image export functionality.
409    #[deprecated(
410        since = "0.13.0",
411        note = "kaleido-based implementation is deprecated. Use plotly_static feature instead. The kaleido implementation will be removed in version 0.14.0"
412    )]
413    #[cfg(feature = "kaleido")]
414    pub fn to_base64(
415        &self,
416        format: ImageFormat,
417        width: usize,
418        height: usize,
419        scale: f64,
420    ) -> String {
421        match format {
422            ImageFormat::JPEG | ImageFormat::PNG | ImageFormat::WEBP => {
423                let kaleido = plotly_kaleido::Kaleido::new();
424                kaleido
425                    .image_to_string(
426                        &serde_json::to_value(self).unwrap(),
427                        format,
428                        width,
429                        height,
430                        scale,
431                    )
432                    .unwrap_or_else(|_| panic!("Kaleido failed to generate image"))
433            }
434            _ => {
435                eprintln!("Cannot generate base64 string for ImageFormat:{format}. Allowed formats are JPEG, PNG, WEBP");
436                String::default()
437            }
438        }
439    }
440
441    /// Convert the `Plot` to SVG and return it as a String using kaleido.
442    ///
443    /// This function is deprecated since version 0.13.0. The kaleido-based
444    /// implementation will be removed in version 0.14.0. Use
445    /// `plotly_static` feature instead for static image export functionality.
446    #[deprecated(
447        since = "0.13.0",
448        note = "kaleido-based implementation is deprecated. Use plotly_static feature instead. The kaleido implementation will be removed in version 0.14.0"
449    )]
450    #[cfg(feature = "kaleido")]
451    pub fn to_svg(&self, width: usize, height: usize, scale: f64) -> String {
452        let kaleido = plotly_kaleido::Kaleido::new();
453        kaleido
454            .image_to_string(
455                &serde_json::to_value(self).unwrap(),
456                ImageFormat::SVG,
457                width,
458                height,
459                scale,
460            )
461            .unwrap_or_else(|_| panic!("Kaleido failed to generate image"))
462    }
463
464    /// Convert the `Plot` to a static image of the given image format and save
465    /// at the given location.
466    ///
467    /// This method requires the usage of the `plotly_static` crate using one of
468    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
469    ///
470    /// **Note:** This method creates a new `StaticExporter` (and thus a new
471    /// WebDriver instance) for each call, which is not performant for
472    /// repeated operations. For better performance and resource management,
473    /// consider using `write_image_with_exporter` to reuse a single
474    /// `StaticExporter` instance across multiple operations.
475    #[cfg(feature = "plotly_static")]
476    pub fn write_image<P: AsRef<Path>>(
477        &self,
478        filename: P,
479        format: ImageFormat,
480        width: usize,
481        height: usize,
482        scale: f64,
483    ) -> Result<(), Box<dyn std::error::Error>> {
484        let mut exporter = plotly_static::StaticExporterBuilder::default()
485            .build()
486            .map_err(|e| format!("Failed to create StaticExporter: {e}"))?;
487        self.write_image_with_exporter(&mut exporter, filename, format, width, height, scale)
488    }
489
490    /// Convert the `Plot` to a static image and return the image as a `base64`
491    /// String. Supported formats are [ImageFormat::JPEG],
492    /// [ImageFormat::PNG] and [ImageFormat::WEBP].
493    ///
494    /// 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.
495    ///
496    /// For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
497    ///
498    ///
499    /// **Note:** This method creates a new `StaticExporter` (and thus a new
500    /// WebDriver instance) for each call, which is not performant for
501    /// repeated operations. For better performance and resource management,
502    /// consider using `to_base64_with_exporter` to reuse a single
503    /// `StaticExporter` instance across multiple operations.
504    #[cfg(feature = "plotly_static")]
505    pub fn to_base64(
506        &self,
507        format: ImageFormat,
508        width: usize,
509        height: usize,
510        scale: f64,
511    ) -> Result<String, Box<dyn std::error::Error>> {
512        let mut exporter = plotly_static::StaticExporterBuilder::default()
513            .build()
514            .map_err(|e| format!("Failed to create StaticExporter: {e}"))?;
515        self.to_base64_with_exporter(&mut exporter, format, width, height, scale)
516    }
517
518    /// Convert the `Plot` to SVG and return it as a String using plotly_static.
519    ///
520    /// This method requires the usage of the `plotly_static` crate using one of
521    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
522    ///
523    /// **Note:** This method creates a new `StaticExporter` (and thus a new
524    /// WebDriver instance) for each call, which is not performant for
525    /// repeated operations. For better performance and resource management,
526    /// consider using `to_svg_with_exporter` to reuse a single
527    /// `StaticExporter` instance across multiple operations.
528    #[cfg(feature = "plotly_static")]
529    pub fn to_svg(
530        &self,
531        width: usize,
532        height: usize,
533        scale: f64,
534    ) -> Result<String, Box<dyn std::error::Error>> {
535        let mut exporter = plotly_static::StaticExporterBuilder::default()
536            .build()
537            .map_err(|e| format!("Failed to create StaticExporter: {e}"))?;
538        self.to_svg_with_exporter(&mut exporter, width, height, scale)
539    }
540
541    /// Convert the `Plot` to a static image of the given image format and save
542    /// at the given location using a provided StaticExporter.
543    ///
544    /// This method allows you to reuse a StaticExporter instance across
545    /// multiple plots, which is more efficient than creating a new one for
546    /// each operation.
547    ///
548    /// This method requires the usage of the `plotly_static` crate using one of
549    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
550    ///
551    /// # Arguments
552    ///
553    /// * `exporter` - A mutable reference to a StaticExporter instance
554    /// * `filename` - The destination path for the output file
555    /// * `format` - The desired output image format
556    /// * `width` - The width of the output image in pixels
557    /// * `height` - The height of the output image in pixels
558    /// * `scale` - The scale factor for the image (1.0 = normal size)
559    ///
560    /// # Examples
561    ///
562    /// ```no_run
563    /// use plotly::{Plot, Scatter};
564    /// use plotly_static::{StaticExporterBuilder, ImageFormat};
565    ///
566    /// let mut plot = Plot::new();
567    /// plot.add_trace(Scatter::new(vec![1, 2, 3], vec![4, 5, 6]));
568    ///
569    /// let mut exporter = StaticExporterBuilder::default()
570    ///     .build()
571    ///     .expect("Failed to create StaticExporter");
572    ///
573    /// // Export multiple plots using the same exporter
574    /// plot.write_image_with_exporter(&mut exporter, "plot1", ImageFormat::PNG, 800, 600, 1.0)
575    ///     .expect("Failed to export plot");
576    /// ```
577    #[cfg(feature = "plotly_static")]
578    pub fn write_image_with_exporter<P: AsRef<Path>>(
579        &self,
580        exporter: &mut plotly_static::StaticExporter,
581        filename: P,
582        format: ImageFormat,
583        width: usize,
584        height: usize,
585        scale: f64,
586    ) -> Result<(), Box<dyn std::error::Error>> {
587        exporter.write_fig(
588            filename.as_ref(),
589            &serde_json::to_value(self)?,
590            format,
591            width,
592            height,
593            scale,
594        )
595    }
596
597    /// Convert the `Plot` to a static image and return the image as a `base64`
598    /// String using a provided StaticExporter. Supported formats are
599    /// [ImageFormat::JPEG], [ImageFormat::PNG] and [ImageFormat::WEBP].
600    ///
601    /// This method allows you to reuse a StaticExporter instance across
602    /// multiple plots, which is more efficient than creating a new one for
603    /// each operation.
604    ///
605    /// This method requires the usage of the `plotly_static` crate using one of
606    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
607    ///
608    /// # Arguments
609    ///
610    /// * `exporter` - A mutable reference to a StaticExporter instance
611    /// * `format` - The desired output image format
612    /// * `width` - The width of the output image in pixels
613    /// * `height` - The height of the output image in pixels
614    /// * `scale` - The scale factor for the image (1.0 = normal size)
615    ///
616    /// # Examples
617    ///
618    /// ```no_run
619    /// use plotly::{Plot, Scatter};
620    /// use plotly_static::{StaticExporterBuilder, ImageFormat};
621    ///
622    /// let mut plot = Plot::new();
623    /// plot.add_trace(Scatter::new(vec![1, 2, 3], vec![4, 5, 6]));
624    ///
625    /// let mut exporter = StaticExporterBuilder::default()
626    ///     .build()
627    ///     .expect("Failed to create StaticExporter");
628    ///
629    /// let base64_data = plot.to_base64_with_exporter(&mut exporter, ImageFormat::PNG, 800, 600, 1.0)
630    ///     .expect("Failed to export plot");
631    /// ```
632    #[cfg(feature = "plotly_static")]
633    pub fn to_base64_with_exporter(
634        &self,
635        exporter: &mut plotly_static::StaticExporter,
636        format: ImageFormat,
637        width: usize,
638        height: usize,
639        scale: f64,
640    ) -> Result<String, Box<dyn std::error::Error>> {
641        match format {
642            ImageFormat::JPEG | ImageFormat::PNG | ImageFormat::WEBP => {
643                exporter.write_to_string(
644                    &serde_json::to_value(self)?,
645                    format,
646                    width,
647                    height,
648                    scale,
649                )
650            }
651            _ => {
652                Err(format!("Cannot generate base64 string for ImageFormat:{format}. Allowed formats are JPEG, PNG, WEBP").into())
653            }
654        }
655    }
656
657    /// Convert the `Plot` to SVG and return it as a String using a provided
658    /// StaticExporter.
659    ///
660    /// This method allows you to reuse a StaticExporter instance across
661    /// multiple plots, which is more efficient than creating a new one for
662    /// each operation.
663    ///
664    /// This method requires the usage of the `plotly_static` crate using one of
665    /// the available feature flags. For advanced usage (parallelism, exporter reuse, custom config), see the [plotly_static documentation](https://docs.rs/plotly_static/).
666    ///
667    /// # Arguments
668    ///
669    /// * `exporter` - A mutable reference to a StaticExporter instance
670    /// * `width` - The width of the output image in pixels
671    /// * `height` - The height of the output image in pixels
672    /// * `scale` - The scale factor for the image (1.0 = normal size)
673    ///
674    /// # Examples
675    ///
676    /// ```no_run
677    /// use plotly::{Plot, Scatter};
678    /// use plotly_static::StaticExporterBuilder;
679    ///
680    /// let mut plot = Plot::new();
681    /// plot.add_trace(Scatter::new(vec![1, 2, 3], vec![4, 5, 6]));
682    ///
683    /// let mut exporter = StaticExporterBuilder::default()
684    ///     .build()
685    ///     .expect("Failed to create StaticExporter");
686    ///
687    /// let svg_data = plot.to_svg_with_exporter(&mut exporter, 800, 600, 1.0)
688    ///     .expect("Failed to export plot");
689    /// ```
690    #[cfg(feature = "plotly_static")]
691    pub fn to_svg_with_exporter(
692        &self,
693        exporter: &mut plotly_static::StaticExporter,
694        width: usize,
695        height: usize,
696        scale: f64,
697    ) -> Result<String, Box<dyn std::error::Error>> {
698        exporter.write_to_string(
699            &serde_json::to_value(self)?,
700            ImageFormat::SVG,
701            width,
702            height,
703            scale,
704        )
705    }
706
707    fn render(&self) -> String {
708        let tmpl = PlotTemplate {
709            plot: self,
710            js_scripts: &self.js_scripts,
711        };
712        tmpl.render().unwrap()
713    }
714
715    #[cfg(all(not(target_family = "wasm"), not(target_os = "android")))]
716    #[cfg(any(feature = "kaleido", feature = "plotly_static"))]
717    pub fn render_static(&self, format: &ImageFormat, width: usize, height: usize) -> String {
718        let tmpl = StaticPlotTemplate {
719            plot: self,
720            format: format.clone(),
721            js_scripts: &self.js_scripts,
722            width,
723            height,
724        };
725        tmpl.render().unwrap()
726    }
727
728    fn render_inline(&self, plot_div_id: &str) -> String {
729        let tmpl = InlinePlotTemplate {
730            plot: self,
731            plot_div_id,
732        };
733        tmpl.render().unwrap()
734    }
735
736    fn js_scripts() -> String {
737        if cfg!(feature = "plotly_embed_js") {
738            Self::offline_js_sources()
739        } else {
740            Self::online_cdn_js()
741        }
742    }
743
744    /// Returns HTML script tags containing embedded JavaScript sources for
745    /// offline use.
746    ///
747    /// This function embeds the Plotly.js library and MathJax (tex-svg)
748    /// JavaScript directly into the HTML output, allowing plots to work
749    /// without an internet connection. The embedded sources include:
750    /// - Plotly.js library for interactive plotting
751    /// - MathJax tex-svg for rendering mathematical expressions
752    ///
753    /// This is used when the `plotly_embed_js` feature is enabled, providing
754    /// self-contained HTML files that don't require external CDN resources.
755    pub fn offline_js_sources() -> String {
756        // Note that since 'tex-mml-chtml' conflicts with 'tex-svg' when generating
757        // Latex Titles we no longer include it.
758        let local_tex_svg_js = include_str!("../resource/tex-svg-3.2.2.js");
759        let local_plotly_js = include_str!("../resource/plotly.min.js");
760
761        format!(
762            "<script type=\"text/javascript\">{local_plotly_js}</script>\n
763            <script type=\"text/javascript\">{local_tex_svg_js}</script>\n",
764        )
765        .to_string()
766    }
767
768    /// Returns HTML script tags that reference external CDN resources for
769    /// online use.
770    ///
771    /// This function provides HTML script tags that load JavaScript libraries
772    /// from external CDN sources, requiring an internet connection to
773    /// function. The referenced sources include:
774    /// - Plotly.js library from CDN (version 3.0.1)
775    /// - MathJax tex-svg from jsDelivr CDN (version 3.2.2)
776    ///
777    /// This is the default behavior when the `plotly_embed_js` feature is
778    /// disabled, providing smaller HTML files that rely on external
779    /// resources.
780    pub fn online_cdn_js() -> String {
781        // Note that since 'tex-mml-chtml' conflicts with 'tex-svg' when generating
782        // Latex Titles we no longer include it.
783        r##"<script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js"></script>
784        <script src="https://cdn.plot.ly/plotly-3.0.1.min.js"></script>
785        "##
786        .to_string()
787    }
788
789    pub fn to_json(&self) -> String {
790        serde_json::to_string(self).unwrap()
791    }
792
793    #[cfg(target_family = "wasm")]
794    /// Convert a `Plot` to a native JavaScript `js_sys::Object`.
795    pub fn to_js_object(&self) -> wasm_bindgen_futures::js_sys::Object {
796        use wasm_bindgen_futures::js_sys;
797        use wasm_bindgen_futures::wasm_bindgen::JsCast;
798        // The only reason this could fail is if to_json() produces structurally
799        // incorrect JSON. That would be a bug, and would require fixing in the
800        // to_json()/serialization methods, rather than here
801        js_sys::JSON::parse(&self.to_json())
802            .expect("Invalid JSON")
803            .dyn_into::<js_sys::Object>()
804            .expect("Invalid JSON structure - expected a top-level Object")
805    }
806
807    #[cfg(all(unix, not(target_os = "android"), not(target_os = "macos")))]
808    fn show_with_default_app(temp_path: &str) {
809        use std::process::Command;
810        Command::new("xdg-open")
811            .args([temp_path])
812            .output()
813            .expect(DEFAULT_HTML_APP_NOT_FOUND);
814    }
815
816    #[cfg(target_os = "macos")]
817    fn show_with_default_app(temp_path: &str) {
818        use std::process::Command;
819        Command::new("open")
820            .args([temp_path])
821            .output()
822            .expect(DEFAULT_HTML_APP_NOT_FOUND);
823    }
824
825    #[cfg(target_os = "windows")]
826    fn show_with_default_app(temp_path: &str) {
827        use std::process::Command;
828        Command::new("cmd")
829            .args(&["/C", "start", &format!(r#"{}"#, temp_path)])
830            .spawn()
831            .expect(DEFAULT_HTML_APP_NOT_FOUND);
832    }
833}
834
835impl PartialEq for Plot {
836    fn eq(&self, other: &Self) -> bool {
837        self.to_json() == other.to_json()
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    use std::path::PathBuf;
844    use std::sync::atomic::{AtomicU32, Ordering};
845
846    #[cfg(feature = "kaleido")]
847    use plotly_kaleido::ImageFormat;
848    #[cfg(feature = "plotly_static")]
849    use plotly_static::ImageFormat;
850    use serde_json::{json, to_value};
851    #[cfg(any(feature = "kaleido", feature = "plotly_static"))]
852    use {base64::engine::general_purpose, base64::Engine};
853
854    use super::*;
855    use crate::Scatter;
856
857    fn create_test_plot() -> Plot {
858        let trace1 = Scatter::new(vec![0, 1, 2], vec![6, 10, 2]).name("trace1");
859        let mut plot = Plot::new();
860        plot.add_trace(trace1);
861        plot
862    }
863
864    #[test]
865    fn inline_plot() {
866        let plot = create_test_plot();
867        let inline_plot_data = plot.to_inline_html(Some("replace_this_with_the_div_id"));
868        assert!(inline_plot_data.contains("replace_this_with_the_div_id"));
869        plot.to_inline_html(None);
870    }
871
872    #[test]
873    fn jupyter_notebook_plot() {
874        let plot = create_test_plot();
875        plot.to_jupyter_notebook_html();
876    }
877
878    #[test]
879    fn notebook_display() {
880        let plot = create_test_plot();
881        plot.notebook_display();
882    }
883
884    #[test]
885    fn lab_display() {
886        let plot = create_test_plot();
887        plot.lab_display();
888    }
889
890    #[test]
891    fn plot_serialize_simple() {
892        let plot = create_test_plot();
893        let expected = json!({
894            "data": [
895                {
896                    "type": "scatter",
897                    "name": "trace1",
898                    "x": [0, 1, 2],
899                    "y": [6, 10, 2]
900                }
901            ],
902            "layout": {},
903            "config": {},
904        });
905
906        assert_eq!(to_value(plot).unwrap(), expected);
907    }
908
909    #[test]
910    fn plot_serialize_with_layout() {
911        let mut plot = create_test_plot();
912        let layout = Layout::new().title("Title");
913        plot.set_layout(layout);
914
915        let expected = json!({
916            "data": [
917                {
918                    "type": "scatter",
919                    "name": "trace1",
920                    "x": [0, 1, 2],
921                    "y": [6, 10, 2]
922                }
923            ],
924            "layout": {
925                "title": {
926                    "text": "Title"
927                }
928            },
929            "config": {},
930        });
931
932        assert_eq!(to_value(plot).unwrap(), expected);
933    }
934
935    #[test]
936    fn data_to_json() {
937        let plot = create_test_plot();
938        let expected = json!([
939            {
940                "type": "scatter",
941                "name": "trace1",
942                "x": [0, 1, 2],
943                "y": [6, 10, 2]
944            }
945        ]);
946
947        assert_eq!(to_value(plot.data()).unwrap(), expected);
948    }
949
950    #[test]
951    fn empty_layout_to_json() {
952        let plot = create_test_plot();
953        let expected = json!({});
954
955        assert_eq!(to_value(plot.layout()).unwrap(), expected);
956    }
957
958    #[test]
959    fn layout_to_json() {
960        let mut plot = create_test_plot();
961        let layout = Layout::new().title("TestTitle");
962        plot.set_layout(layout);
963
964        let expected = json!({
965            "title": {"text": "TestTitle"}
966        });
967
968        assert_eq!(to_value(plot.layout()).unwrap(), expected);
969    }
970
971    #[test]
972    fn plot_eq() {
973        let plot1 = create_test_plot();
974        let plot2 = create_test_plot();
975
976        assert!(plot1 == plot2);
977    }
978
979    #[test]
980    fn plot_neq() {
981        let plot1 = create_test_plot();
982        let trace2 = Scatter::new(vec![10, 1, 2], vec![6, 10, 2]).name("trace2");
983        let mut plot2 = Plot::new();
984        plot2.add_trace(trace2);
985
986        assert!(plot1 != plot2);
987    }
988
989    #[test]
990    fn plot_clone() {
991        let plot1 = create_test_plot();
992        let plot2 = plot1.clone();
993
994        assert!(plot1 == plot2);
995    }
996
997    #[test]
998    fn save_html() {
999        let plot = create_test_plot();
1000        let dst = PathBuf::from("plotly_example.html");
1001        plot.write_html(&dst);
1002        assert!(dst.exists());
1003        #[cfg(not(feature = "debug"))]
1004        assert!(std::fs::remove_file(&dst).is_ok());
1005    }
1006
1007    #[cfg(feature = "plotly_static")]
1008    // Helper to generate unique ports for parallel tests
1009    static PORT_COUNTER: AtomicU32 = AtomicU32::new(4444);
1010
1011    #[cfg(feature = "plotly_static")]
1012    fn get_unique_port() -> u32 {
1013        PORT_COUNTER.fetch_add(1, Ordering::SeqCst)
1014    }
1015
1016    #[test]
1017    #[cfg(feature = "plotly_static")]
1018    fn save_to_png() {
1019        let plot = create_test_plot();
1020        let dst = PathBuf::from("plotly_example.png");
1021        let mut exporter = plotly_static::StaticExporterBuilder::default()
1022            .webdriver_port(get_unique_port())
1023            .build()
1024            .unwrap();
1025        plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::PNG, 1024, 680, 1.0)
1026            .unwrap();
1027        assert!(dst.exists());
1028        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1029        let file_size = metadata.len();
1030        assert!(file_size > 0,);
1031        #[cfg(not(feature = "debug"))]
1032        assert!(std::fs::remove_file(&dst).is_ok());
1033    }
1034
1035    #[test]
1036    #[cfg(feature = "plotly_static")]
1037    fn save_to_jpeg() {
1038        let plot = create_test_plot();
1039        let dst = PathBuf::from("plotly_example.jpeg");
1040        let mut exporter = plotly_static::StaticExporterBuilder::default()
1041            .webdriver_port(get_unique_port())
1042            .build()
1043            .unwrap();
1044        plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::JPEG, 1024, 680, 1.0)
1045            .unwrap();
1046        assert!(dst.exists());
1047        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1048        let file_size = metadata.len();
1049        assert!(file_size > 0,);
1050        #[cfg(not(feature = "debug"))]
1051        assert!(std::fs::remove_file(&dst).is_ok());
1052    }
1053
1054    #[test]
1055    #[cfg(feature = "plotly_static")]
1056    fn save_to_svg() {
1057        let plot = create_test_plot();
1058        let dst = PathBuf::from("plotly_example.svg");
1059        let mut exporter = plotly_static::StaticExporterBuilder::default()
1060            .webdriver_port(get_unique_port())
1061            .build()
1062            .unwrap();
1063        plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::SVG, 1024, 680, 1.0)
1064            .unwrap();
1065        assert!(dst.exists());
1066        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1067        let file_size = metadata.len();
1068        assert!(file_size > 0,);
1069        #[cfg(not(feature = "debug"))]
1070        assert!(std::fs::remove_file(&dst).is_ok());
1071    }
1072
1073    #[test]
1074    #[cfg(feature = "plotly_static")]
1075    fn save_to_pdf() {
1076        let plot = create_test_plot();
1077        let dst = PathBuf::from("plotly_example.pdf");
1078        #[cfg(feature = "debug")]
1079        let mut exporter = plotly_static::StaticExporterBuilder::default()
1080            .spawn_webdriver(true)
1081            .webdriver_port(get_unique_port())
1082            .pdf_export_timeout(750)
1083            .build()
1084            .unwrap();
1085        #[cfg(not(feature = "debug"))]
1086        let mut exporter = plotly_static::StaticExporterBuilder::default()
1087            .webdriver_port(get_unique_port())
1088            .build()
1089            .unwrap();
1090        plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::PDF, 1024, 680, 1.0)
1091            .unwrap();
1092        assert!(dst.exists());
1093        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1094        let file_size = metadata.len();
1095        assert!(file_size > 0,);
1096        #[cfg(not(feature = "debug"))]
1097        assert!(std::fs::remove_file(&dst).is_ok());
1098    }
1099
1100    #[test]
1101    #[cfg(feature = "plotly_static")]
1102    fn save_to_webp() {
1103        let plot = create_test_plot();
1104        let dst = PathBuf::from("plotly_example.webp");
1105        let mut exporter = plotly_static::StaticExporterBuilder::default()
1106            .webdriver_port(get_unique_port())
1107            .build()
1108            .unwrap();
1109        plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::WEBP, 1024, 680, 1.0)
1110            .unwrap();
1111        assert!(dst.exists());
1112        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1113        let file_size = metadata.len();
1114        assert!(file_size > 0,);
1115        #[cfg(not(feature = "debug"))]
1116        assert!(std::fs::remove_file(&dst).is_ok());
1117    }
1118
1119    #[test]
1120    #[cfg(feature = "plotly_static")]
1121    fn image_to_base64() {
1122        let plot = create_test_plot();
1123        let mut exporter = plotly_static::StaticExporterBuilder::default()
1124            .webdriver_port(get_unique_port())
1125            .build()
1126            .unwrap();
1127
1128        let image_base64 = plot
1129            .to_base64_with_exporter(&mut exporter, ImageFormat::PNG, 200, 150, 1.0)
1130            .unwrap();
1131
1132        assert!(!image_base64.is_empty());
1133
1134        let result_decoded = general_purpose::STANDARD.decode(image_base64).unwrap();
1135        let expected = "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";
1136        let expected_decoded = general_purpose::STANDARD.decode(expected).unwrap();
1137
1138        // Comparing the result seems to end up being a flaky test.
1139        // Limit the comparison to the first characters;
1140        // As image contents seem to be slightly inconsistent across platforms
1141        assert_eq!(expected_decoded[..2], result_decoded[..2]);
1142    }
1143
1144    #[test]
1145    #[cfg(feature = "plotly_static")]
1146    fn image_to_svg_string() {
1147        let plot = create_test_plot();
1148        let mut exporter = plotly_static::StaticExporterBuilder::default()
1149            .webdriver_port(get_unique_port())
1150            .build()
1151            .unwrap();
1152        let image_svg = plot
1153            .to_svg_with_exporter(&mut exporter, 200, 150, 1.0)
1154            .unwrap();
1155
1156        assert!(!image_svg.is_empty());
1157
1158        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>";
1159        // Limit the test to the first LEN characters as generated SVGs
1160        // seem to contain uniquely generated IDs
1161        const LEN: usize = 10;
1162        assert_eq!(expected[..LEN], image_svg[..LEN]);
1163    }
1164
1165    #[test]
1166    #[cfg(feature = "plotly_static")]
1167    fn save_surface_to_png() {
1168        use crate::Surface;
1169        let mut plot = Plot::new();
1170        let z_matrix = vec![
1171            vec![1.0, 2.0, 3.0],
1172            vec![4.0, 5.0, 6.0],
1173            vec![7.0, 8.0, 9.0],
1174        ];
1175        let x_unique = vec![1.0, 2.0, 3.0];
1176        let y_unique = vec![4.0, 5.0, 6.0];
1177        let surface = Surface::new(z_matrix)
1178            .x(x_unique)
1179            .y(y_unique)
1180            .name("Surface");
1181
1182        plot.add_trace(surface);
1183        let dst = PathBuf::from("plotly_example_surface.png");
1184        let mut exporter = plotly_static::StaticExporterBuilder::default()
1185            .webdriver_port(get_unique_port())
1186            .build()
1187            .unwrap();
1188
1189        assert!(!plot
1190            .to_base64_with_exporter(&mut exporter, ImageFormat::PNG, 1024, 680, 1.0)
1191            .unwrap()
1192            .is_empty());
1193
1194        plot.write_image_with_exporter(&mut exporter, &dst, ImageFormat::PNG, 800, 600, 1.0)
1195            .unwrap();
1196        assert!(dst.exists());
1197
1198        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1199        let file_size = metadata.len();
1200        assert!(file_size > 0,);
1201        #[cfg(not(feature = "debug"))]
1202        assert!(std::fs::remove_file(&dst).is_ok());
1203    }
1204}