plotly_static/
lib.rs

1//! # Plotly Static Image Export
2//!
3//! A Rust library for exporting Plotly plots to static images using headless
4//! browsers via WebDriver.
5//!
6//! This library provides a interface for converting Plotly plots provided as
7//! JSON values into various static image formats (PNG, JPEG, WEBP, SVG,
8//! PDF) using WebDriver and headless browsers.
9//!
10//! ## Features
11//!
12//! - **Async/Sync API Support**: Support for both async and sync contexts
13//! - **Multiple Formats**: Support for PNG, JPEG, WEBP, SVG, and PDF export
14//! - **Headless Rendering**: Uses headless browsers for rendering
15//! - **WebDriver Support**: Supports both Chrome (chromedriver) and Firefox
16//!   (geckodriver)
17//! - **Configurable**: Customizable dimensions, scale, and browser capabilities
18//! - **Offline Mode**: Can work with offline bundled JavaScript libraries
19//! - **Automatic Management**: Handles WebDriver process lifecycle and cleanup
20//! - **Parallelism**: Designed for use in parallel environments (tests, etc.)
21//! - **Logging Support**: Integrated logging with `env_logger` support
22//!
23//! ## Quick Start
24//!
25//! ```no_run
26//! // This example requires a WebDriver-compatible browser (Chrome/Firefox).
27//! // It cannot be run as a doc test.
28//! use plotly_static::{StaticExporterBuilder, ImageFormat};
29//! use serde_json::json;
30//! use std::path::Path;
31//!
32//! // Create a simple plot
33//! let plot = json!({
34//!     "data": [{
35//!         "type": "scatter",
36//!         "x": [1, 2, 3, 4],
37//!         "y": [10, 11, 12, 13]
38//!     }],
39//!     "layout": {
40//!         "title": "Simple Scatter Plot"
41//!     }
42//! });
43//!
44//! // Build and use StaticExporter
45//! let mut exporter = StaticExporterBuilder::default()
46//!     .build()
47//!     .expect("Failed to build StaticExporter");
48//!
49//! // Export to PNG
50//! exporter.write_fig(
51//!     Path::new("my_plot"),
52//!     &plot,
53//!     ImageFormat::PNG,
54//!     800,
55//!     600,
56//!     1.0
57//! ).expect("Failed to export plot");
58//! ```
59//!
60//! ## Features and Dependencies
61//!
62//! ### Required Features
63//!
64//! You must enable one of the following features:
65//!
66//! - `chromedriver`: Use Chrome/Chromium for rendering
67//! - `geckodriver`: Use Firefox for rendering
68//!
69//! ### Optional Features
70//!
71//! - `webdriver_download`: Automatically download WebDriver binaries at build
72//!   time
73//!
74//! ### Example Cargo.toml
75//!
76//! ```toml
77//! [dependencies]
78//! plotly_static = { version = "0.1", features = ["chromedriver", "webdriver_download"] }
79//! ```
80//!
81//! ## Async Support
82//!
83//! The library supports async operations. To use the async API you need to call
84//! `build_async` instead of `build` on the `StaticExporterBuilder` . This will
85//! return an `AsyncStaticExporter` instance where the `write_fig` and
86//! `write_to_string` methods are async.
87//!
88//! ```no_run
89//! use plotly_static::StaticExporterBuilder;
90//!
91//! let exporter = StaticExporterBuilder::default()
92//!     .build_async()
93//!     .expect("Failed to build AsyncStaticExporter");
94//! ```
95//!
96//! Never use the `sync` API in async contexts. The `sync` API wraps the `async`
97//! API and uses a `tokio::runtime::Runtime` instance internally.  Using the
98//! `sync` API in an async context will cause runtime errors such as e.g.,
99//! "Cannot drop a runtime in a context where blocking is not allowed. This
100//! happens when a runtime is dropped from within an asynchronous context." or
101//! similar ones.
102//!
103//! ## Advanced Usage
104//!
105//! ### Custom Configuration
106//!
107//! ```no_run
108//! // This example requires a running WebDriver (chromedriver/geckodriver) and a browser.
109//! // It cannot be run as a doc test.
110//! use plotly_static::StaticExporterBuilder;
111//!
112//! let exporter = StaticExporterBuilder::default()
113//!     .webdriver_port(4444)
114//!     .webdriver_url("http://localhost")
115//!     .spawn_webdriver(true)
116//!     .offline_mode(true)
117//!     .webdriver_browser_caps(vec![
118//!         "--headless".to_string(),
119//!         "--no-sandbox".to_string(),
120//!         "--disable-gpu".to_string(),
121//!     ])
122//!     .build()
123//!     .expect("Failed to build StaticExporter");
124//! ```
125//!
126//! ### Browser Binary Configuration
127//!
128//! You can specify custom browser binaries using environment variables:
129//!
130//! ```bash
131//! # For Chrome/Chromium
132//! export BROWSER_PATH="/path/to/chrome"
133//!
134//! # For Firefox
135//! export BROWSER_PATH="/path/to/firefox"
136//! ```
137//!
138//! The library will automatically use these binaries when creating WebDriver
139//! sessions.
140//!
141//! ### String Export
142//!
143//! ```no_run
144//! // This example requires a running WebDriver (chromedriver/geckodriver) and a browser.
145//! // It cannot be run as a doc test.
146//! use plotly_static::{StaticExporterBuilder, ImageFormat};
147//! use serde_json::json;
148//!
149//! let plot = json!({
150//!     "data": [{"type": "scatter", "x": [1,2,3], "y": [4,5,6]}],
151//!     "layout": {}
152//! });
153//!
154//! let mut exporter = StaticExporterBuilder::default()
155//!     .build()
156//!     .expect("Failed to build StaticExporter");
157//!
158//! let svg_data = exporter.write_to_string(
159//!     &plot,
160//!     ImageFormat::SVG,
161//!     800,
162//!     600,
163//!     1.0
164//! ).expect("Failed to export plot");
165//!
166//! // svg_data contains SVG markup that can be embedded in HTML
167//! ```
168//!
169//! ### Logging Support
170//!
171//! The library supports logging via the `log` crate. Enable it with
172//! `env_logger`:
173//!
174//! ```no_run
175//! use plotly_static::StaticExporterBuilder;
176//!
177//! // Initialize logging (typically done once at the start of your application)
178//! env_logger::init();
179//!
180//! // Set log level via environment variable
181//! // RUST_LOG=debug cargo run
182//!
183//! let mut exporter = StaticExporterBuilder::default()
184//!     .build()
185//!     .expect("Failed to build StaticExporter");
186//! ```
187//!
188//! ### Parallel Usage
189//!
190//! The library is designed to work safely in parallel environments:
191//!
192//! ```no_run
193//! use plotly_static::{StaticExporterBuilder, ImageFormat};
194//! use std::sync::atomic::{AtomicU32, Ordering};
195//!
196//! // Generate unique ports for parallel usage
197//! static PORT_COUNTER: AtomicU32 = AtomicU32::new(4444);
198//!
199//! fn get_unique_port() -> u32 {
200//!     PORT_COUNTER.fetch_add(1, Ordering::SeqCst)
201//! }
202//!
203//! // Each thread/process should use a unique port
204//! let mut exporter = StaticExporterBuilder::default()
205//!     .webdriver_port(get_unique_port())
206//!     .build()
207//!     .expect("Failed to build StaticExporter");
208//! ```
209//!
210//! ## WebDriver Management
211//!
212//! The library automatically manages WebDriver processes:
213//!
214//! - **Automatic Detection**: Detects if WebDriver is already running on the
215//!   specified port
216//! - **Process Spawning**: Automatically spawns WebDriver if not already
217//!   running
218//! - **Connection Reuse**: Reuses existing WebDriver sessions when possible
219//! - **External Sessions**: Can connect to externally managed WebDriver
220//!   sessions
221//!
222//! Due to the underlying WebDriver implementation, the library cannot
223//! automatically close WebDriver processes when `StaticExporter` is dropped.
224//! You must call `close` manually to ensure proper cleanup.
225//!
226//! ### WebDriver Configuration
227//!
228//! Set the `WEBDRIVER_PATH` environment variable to specify a custom WebDriver
229//! binary location (should point to the full executable path):
230//!
231//! ```bash
232//! export WEBDRIVER_PATH=/path/to/chromedriver
233//! cargo run
234//! ```
235//!
236//! Or use the `webdriver_download` feature for automatic download at build
237//! time.
238//!
239//! ## Error Handling
240//!
241//! The library uses `anyhow::Result` for error handling. Common errors include:
242//!
243//! - WebDriver not available or not running
244//! - Invalid Plotly JSON format
245//! - File system errors
246//! - Browser rendering errors
247//!
248//! ## Browser Support
249//!
250//! - **Chrome/Chromium**: Full support via chromedriver
251//! - **Firefox**: Full support via geckodriver
252//! - **Safari**: Not currently supported
253//! - **Edge**: Not currently supported
254//!
255//! ## Performance Considerations
256//!
257//! - **Reuse Exporters**: Reuse `StaticExporter` instances for multiple exports
258//! - **Parallel Usage**: Use unique ports for parallel operations
259//! - **WebDriver Reuse**: The library automatically reuses WebDriver sessions
260//!   when possible
261//!
262//! ## Comparison with Kaleido
263//!
264//! - **No custom Chromium/Chrome external dependency**: Uses standard WebDriver
265//!   instead of proprietary Kaleido
266//! - **Better Browser Support**: Works with any WebDriver-compatible browser:
267//!   Chrome/Chromium,Firefox,Brave
268//! - **Extensible**: Easy to control browser capabilities and customize the
269//!   StaticExporter instance
270//!
271//! ## Limitations
272//!
273//! - Requires a WebDriver-compatible browser
274//! - PDF export uses browser JavaScript `html2pdf` (not native Plotly PDF)
275//! - EPS is no longer supported and will be removed
276//! - Slightly slower than Kaleido
277//!
278//! ## License
279//!
280//! MIT License - see LICENSE file for details.
281
282// TODO: remove this once version 0.14.0 is out
283#![allow(deprecated)]
284use std::fs::File;
285use std::io::prelude::*;
286use std::path::{Path, PathBuf};
287use std::vec;
288#[cfg(any(test, feature = "debug"))]
289use std::{println as error, println as warn, println as debug};
290
291use anyhow::{anyhow, Context, Result};
292use base64::{engine::general_purpose, Engine as _};
293use fantoccini::{wd::Capabilities, Client, ClientBuilder};
294#[cfg(not(any(test, feature = "debug")))]
295use log::{debug, error, warn};
296use serde::Serialize;
297use serde_json::map::Map as JsonMap;
298use urlencoding::encode;
299use webdriver::WebDriver;
300
301use crate::template::{image_export_js_script, pdf_export_js_script};
302
303mod template;
304mod webdriver;
305
306/// Supported image formats for static image export.
307///
308/// This enum defines all the image formats that can be exported from Plotly
309/// plots. Note that PDF export is implemented using browser JavaScript
310/// functionality from `html2pdf` library, not the native Plotly PDF export.
311///
312/// # Supported Formats
313///
314/// - **PNG**: Portable Network Graphics format (recommended for web use)
315/// - **JPEG**: Joint Photographic Experts Group format (good for photos)
316/// - **WEBP**: Google's modern image format (excellent compression)
317/// - **SVG**: Scalable Vector Graphics format (vector-based, scalable)
318/// - **PDF**: Portable Document Format (implemented via browser JS)
319///
320/// # Deprecated Formats
321///
322/// - **EPS**: Encapsulated PostScript format (deprecated since 0.13.0, will be
323///   removed in 0.14.0)
324///   - Use SVG or PDF instead for vector graphics
325///   - EPS is not supported in the open source version and in versions prior to
326///     0.13.0 has been generating empty images.
327///
328/// # Examples
329///
330/// ```rust
331/// use plotly_static::ImageFormat;
332///
333/// let format = ImageFormat::PNG;
334/// assert_eq!(format.to_string(), "png");
335/// ```
336#[derive(Debug, Clone, Serialize)]
337#[allow(deprecated)]
338pub enum ImageFormat {
339    /// Portable Network Graphics format
340    PNG,
341    /// Joint Photographic Experts Group format
342    JPEG,
343    /// WebP format (Google's image format)
344    WEBP,
345    /// Scalable Vector Graphics format
346    SVG,
347    /// Portable Document Format (implemented via browser JS)
348    PDF,
349    /// Encapsulated PostScript format (deprecated)
350    ///
351    /// This format is deprecated since version 0.13.0 and will be removed in
352    /// version 0.14.0. Use SVG or PDF instead for vector graphics. EPS is
353    /// not supported in the open source Plotly ecosystem version.
354    #[deprecated(
355        since = "0.13.0",
356        note = "Use SVG or PDF instead. EPS variant will be removed in version 0.14.0"
357    )]
358    EPS,
359}
360
361impl std::fmt::Display for ImageFormat {
362    /// Formats the ImageFormat as a string.
363    ///
364    /// # Examples
365    ///
366    /// ```rust
367    /// use plotly_static::ImageFormat;
368    /// assert_eq!(ImageFormat::SVG.to_string(), "svg");
369    /// assert_eq!(ImageFormat::PDF.to_string(), "pdf");
370    /// ```
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        write!(
373            f,
374            "{}",
375            match self {
376                Self::PNG => "png",
377                Self::JPEG => "jpeg",
378                Self::WEBP => "webp",
379                Self::SVG => "svg",
380                Self::PDF => "pdf",
381                #[allow(deprecated)]
382                Self::EPS => "eps",
383            }
384        )
385    }
386}
387
388/// TODO: ideally data would be a Plot object which is later serialized to JSON
389/// but with the current workspace set up, that would be a cyclic dependency.
390#[derive(Serialize)]
391struct PlotData<'a> {
392    format: ImageFormat,
393    width: usize,
394    height: usize,
395    scale: f64,
396    data: &'a serde_json::Value,
397}
398
399/// Builder for configuring and creating a `StaticExporter` instance.
400///
401/// This builder provides an interface for configuring WebDriver settings,
402/// browser capabilities, and other options before creating a `StaticExporter`
403/// instance. The builder automatically handles WebDriver process management,
404/// including detection of existing sessions and automatic spawning when needed.
405///
406/// # Examples
407///
408/// ```no_run
409/// // This example requires a running WebDriver (chromedriver/geckodriver) and a browser.
410/// // It cannot be run as a doc test.
411/// use plotly_static::StaticExporterBuilder;
412///
413/// let exporter = StaticExporterBuilder::default()
414///     .webdriver_port(4444)
415///     .spawn_webdriver(true)
416///     .offline_mode(false)
417///     .pdf_export_timeout(500)
418///     .build()
419///     .expect("Failed to build StaticExporter");
420/// ```
421///
422/// # Default Configuration
423///
424/// - WebDriver port: 4444
425/// - WebDriver URL: "http://localhost"
426/// - Spawn webdriver: true (automatically manages WebDriver lifecycle)
427/// - Offline mode: false
428/// - PDF export timeout: 250ms
429/// - Browser capabilities: Default Chrome/Firefox headless options
430/// - Automatic WebDriver detection and connection reuse
431pub struct StaticExporterBuilder {
432    /// WebDriver server port (default: 4444)
433    webdriver_port: u32,
434    /// WebDriver server base URL (default: "http://localhost")
435    webdriver_url: String,
436    /// Auto-spawn WebDriver if not running (default: true)
437    spawn_webdriver: bool,
438    /// Use bundled JS libraries instead of CDN (default: false)
439    offline_mode: bool,
440    /// PDF export timeout in milliseconds (default: 150)
441    pdf_export_timeout: u32,
442    /// Browser command-line flags (e.g., "--headless", "--no-sandbox")
443    webdriver_browser_caps: Vec<String>,
444}
445
446impl Default for StaticExporterBuilder {
447    /// Creates a new `StaticExporterBuilder` with default configuration.
448    ///
449    /// The default configuration includes:
450    /// - WebDriver port: 4444
451    /// - WebDriver URL: "http://localhost"
452    /// - Spawn webdriver: true
453    /// - Offline mode: false
454    /// - PDF export timeout: 250ms
455    /// - Default browser capabilities for headless operation
456    fn default() -> Self {
457        Self {
458            webdriver_port: webdriver::WEBDRIVER_PORT,
459            webdriver_url: webdriver::WEBDRIVER_URL.to_string(),
460            spawn_webdriver: true,
461            offline_mode: false,
462            pdf_export_timeout: 150,
463            webdriver_browser_caps: {
464                #[cfg(feature = "chromedriver")]
465                {
466                    crate::webdriver::chrome_default_caps()
467                        .into_iter()
468                        .map(|s| s.to_string())
469                        .collect()
470                }
471                #[cfg(feature = "geckodriver")]
472                {
473                    crate::webdriver::firefox_default_caps()
474                        .into_iter()
475                        .map(|s| s.to_string())
476                        .collect()
477                }
478                #[cfg(not(any(feature = "chromedriver", feature = "geckodriver")))]
479                {
480                    Vec::new()
481                }
482            },
483        }
484    }
485}
486
487impl StaticExporterBuilder {
488    /// Sets the WebDriver port number.
489    ///
490    /// # Examples
491    ///
492    /// ```rust
493    /// use plotly_static::StaticExporterBuilder;
494    ///
495    /// let builder = StaticExporterBuilder::default()
496    ///     .webdriver_port(4444);
497    /// ```
498    pub fn webdriver_port(mut self, port: u32) -> Self {
499        self.webdriver_port = port;
500        self
501    }
502
503    /// Sets the WebDriver URL.
504    ///
505    /// # Examples
506    ///
507    /// ```rust
508    /// use plotly_static::StaticExporterBuilder;
509    ///
510    /// let builder = StaticExporterBuilder::default()
511    ///     .webdriver_url("http://localhost");
512    /// ```
513    pub fn webdriver_url(mut self, url: &str) -> Self {
514        self.webdriver_url = url.to_string();
515        self
516    }
517
518    /// Controls whether to automatically spawn a WebDriver process.
519    ///
520    /// If `true`, automatically spawns a WebDriver process. If `false`,
521    /// expects an existing WebDriver server to be running.
522    ///
523    /// # Examples
524    ///
525    /// ```rust
526    /// use plotly_static::StaticExporterBuilder;
527    ///
528    /// // Auto-spawn WebDriver
529    /// let builder = StaticExporterBuilder::default()
530    ///     .spawn_webdriver(true);
531    ///
532    /// // Use existing WebDriver server
533    /// let builder = StaticExporterBuilder::default()
534    ///     .spawn_webdriver(false);
535    /// ```
536    pub fn spawn_webdriver(mut self, yes: bool) -> Self {
537        self.spawn_webdriver = yes;
538        self
539    }
540
541    /// Controls whether to use offline mode with bundled JavaScript libraries.
542    ///
543    /// If `true`, uses bundled JavaScript libraries instead of CDN. If `false`,
544    /// downloads libraries from CDN.
545    ///
546    /// # Examples
547    ///
548    /// ```rust
549    /// use plotly_static::StaticExporterBuilder;
550    ///
551    /// // Use bundled libraries (no internet required)
552    /// let builder = StaticExporterBuilder::default()
553    ///     .offline_mode(true);
554    ///
555    /// // Use CDN libraries
556    /// let builder = StaticExporterBuilder::default()
557    ///     .offline_mode(false);
558    /// ```
559    pub fn offline_mode(mut self, yes: bool) -> Self {
560        self.offline_mode = yes;
561        self
562    }
563
564    /// Sets the PDF export timeout in milliseconds.
565    ///
566    /// This timeout controls how long to wait for the SVG image to load before
567    /// proceeding with PDF generation. A longer timeout may be needed for
568    /// complex plots or slower systems.
569    ///
570    /// # Examples
571    ///
572    /// ```rust
573    /// use plotly_static::StaticExporterBuilder;
574    ///
575    /// // Set a longer timeout for complex plots
576    /// let builder = StaticExporterBuilder::default()
577    ///     .pdf_export_timeout(500);
578    ///
579    /// // Use default timeout (150ms)
580    /// let builder = StaticExporterBuilder::default()
581    ///     .pdf_export_timeout(150);
582    /// ```
583    pub fn pdf_export_timeout(mut self, timeout_ms: u32) -> Self {
584        self.pdf_export_timeout = timeout_ms;
585        self
586    }
587
588    /// Sets custom browser capabilities for the WebDriver.
589    ///
590    /// # Examples
591    ///
592    /// ```rust
593    /// use plotly_static::StaticExporterBuilder;
594    ///
595    /// let custom_caps = vec![
596    ///     "--headless".to_string(),
597    ///     "--no-sandbox".to_string(),
598    ///     "--disable-gpu".to_string(),
599    /// ];
600    ///
601    /// let builder = StaticExporterBuilder::default()
602    ///     .webdriver_browser_caps(custom_caps);
603    /// ```
604    pub fn webdriver_browser_caps(mut self, caps: Vec<String>) -> Self {
605        self.webdriver_browser_caps = caps;
606        self
607    }
608
609    /// Builds a synchronous `StaticExporter` instance with the current
610    /// configuration.
611    ///
612    /// The synchronous API is blocking and should not be used in async
613    /// contexts. Use `build_async` instead and the associated
614    /// `AsyncStaticExporter` instance.
615    ///
616    /// - If `spawn_webdriver` is enabled, it first tries to connect to an
617    ///   existing WebDriver session on the specified port, and only spawns a
618    ///   new process if none is found
619    /// - If `spawn_webdriver` is disabled, it creates a connection to an
620    ///   existing WebDriver without spawning
621    ///
622    /// Returns a `Result<StaticExporter>` where:
623    /// - `Ok(exporter)` - Successfully created the StaticExporter instance
624    /// - `Err(e)` - Failed to create the instance (e.g., WebDriver not
625    ///   available, port conflicts, etc.)
626    ///
627    /// # Examples
628    ///
629    /// ```rust,no_run
630    /// use plotly_static::StaticExporterBuilder;
631    ///
632    /// let exporter = StaticExporterBuilder::default()
633    ///     .webdriver_port(4444)
634    ///     .build()
635    ///     .expect("Failed to build StaticExporter");
636    /// ```
637    pub fn build(&self) -> Result<StaticExporter> {
638        let runtime = std::sync::Arc::new(
639            tokio::runtime::Builder::new_multi_thread()
640                .enable_all()
641                .build()
642                .expect("Failed to create Tokio runtime"),
643        );
644
645        let inner = Self::build_async(self)?;
646
647        Ok(StaticExporter { runtime, inner })
648    }
649
650    /// Create a new WebDriver instance based on the spawn_webdriver flag
651    fn create_webdriver(&self) -> Result<WebDriver> {
652        let port = self.webdriver_port;
653        let in_async = tokio::runtime::Handle::try_current().is_ok();
654
655        let run_create_fn = |spawn: bool| -> Result<WebDriver> {
656            let work = move || {
657                if spawn {
658                    WebDriver::connect_or_spawn(port)
659                } else {
660                    WebDriver::new(port)
661                }
662            };
663            if in_async {
664                std::thread::spawn(work)
665                    .join()
666                    .map_err(|_| anyhow!("failed to join webdriver thread"))?
667            } else {
668                work()
669            }
670        };
671
672        run_create_fn(self.spawn_webdriver)
673    }
674
675    /// Build an async exporter for use within async contexts.
676    ///
677    /// This method creates an `AsyncStaticExporter` instance with the current
678    /// configuration. The async API is non-blocking and can be used in async
679    /// contexts.
680    ///
681    /// # Examples
682    ///
683    /// ```rust,no_run
684    /// use plotly_static::StaticExporterBuilder;
685    ///
686    /// let exporter = StaticExporterBuilder::default()
687    ///     .build_async()
688    ///     .expect("Failed to build AsyncStaticExporter");
689    /// ```
690    pub fn build_async(&self) -> Result<AsyncStaticExporter> {
691        let wd = self.create_webdriver()?;
692        Ok(AsyncStaticExporter {
693            webdriver_port: self.webdriver_port,
694            webdriver_url: self.webdriver_url.clone(),
695            webdriver: wd,
696            offline_mode: self.offline_mode,
697            pdf_export_timeout: self.pdf_export_timeout,
698            webdriver_browser_caps: self.webdriver_browser_caps.clone(),
699            webdriver_client: None,
700        })
701    }
702}
703
704/// Synchronous exporter for exporting Plotly plots to static images.
705///
706/// This object provides methods to convert Plotly JSON plots into various
707/// static image formats using a headless browser via WebDriver.
708/// The synchronous API is blocking and should not be used in async contexts.
709/// Use `build_async` instead and the associated `AsyncStaticExporter` object.
710///
711/// Always call `close` when you are done with the exporter to ensure proper
712/// cleanup of the WebDriver process.
713///
714/// # Examples
715///
716/// ```no_run
717/// // This example requires a running WebDriver (chromedriver/geckodriver) and a browser.
718/// // It cannot be run as a doc test.
719/// use plotly_static::{StaticExporterBuilder, ImageFormat};
720/// use serde_json::json;
721/// use std::path::Path;
722///
723/// // Create a simple plot
724/// let plot = json!({
725///     "data": [{
726///         "type": "scatter",
727///         "x": [1, 2, 3],
728///         "y": [4, 5, 6]
729///     }],
730///     "layout": {}
731/// });
732///
733/// // Build StaticExporter instance
734/// let mut exporter = StaticExporterBuilder::default()
735///     .build()
736///     .expect("Failed to build StaticExporter");
737///
738/// // Export to PNG
739/// exporter.write_fig(
740///     Path::new("output"),
741///     &plot,
742///     ImageFormat::PNG,
743///     800,
744///     600,
745///     1.0
746/// ).expect("Failed to export plot");
747///
748/// // Close the exporter
749/// exporter.close();
750/// ```
751///
752/// # Features
753///
754/// - Supports multiple image formats (PNG, JPEG, WEBP, SVG, PDF)
755/// - Uses headless browser for rendering
756/// - Configurable dimensions and scale
757/// - Offline mode support
758/// - Automatic WebDriver management
759pub struct StaticExporter {
760    /// Tokio runtime for async operations
761    runtime: std::sync::Arc<tokio::runtime::Runtime>,
762
763    /// Async inner exporter
764    inner: AsyncStaticExporter,
765}
766
767impl StaticExporter {
768    /// Exports a Plotly plot to a static image file.
769    ///
770    /// This method renders the provided Plotly JSON plot using a headless
771    /// browser and saves the result as an image file in the specified
772    /// format.
773    ///
774    /// Returns `Ok()` on success, or an error if the export fails.
775    ///
776    /// The file extension is automatically added based on the format
777    ///
778    /// # Examples
779    ///
780    /// ```no_run
781    /// 
782    /// // This example requires a running WebDriver (chromedriver/geckodriver) and a browser.
783    /// // It cannot be run as a doc test.
784    ///
785    /// use plotly_static::{StaticExporterBuilder, ImageFormat};
786    /// use serde_json::json;
787    /// use std::path::Path;
788    ///
789    /// let plot = json!({
790    ///     "data": [{"type": "scatter", "x": [1,2,3], "y": [4,5,6]}],
791    ///     "layout": {}
792    /// });
793    ///
794    /// let mut exporter = StaticExporterBuilder::default().build().unwrap();
795    ///
796    /// // Creates "my_plot.png" with 1200x800 pixels at 2x scale
797    /// exporter.write_fig(
798    ///     Path::new("my_plot"),
799    ///     &plot,
800    ///     ImageFormat::PNG,
801    ///     1200,
802    ///     800,
803    ///     2.0
804    /// ).expect("Failed to export plot");
805    ///
806    /// // Close the exporter
807    /// exporter.close();
808    /// ```
809    pub fn write_fig(
810        &mut self,
811        dst: &Path,
812        plot: &serde_json::Value,
813        format: ImageFormat,
814        width: usize,
815        height: usize,
816        scale: f64,
817    ) -> Result<(), Box<dyn std::error::Error>> {
818        if tokio::runtime::Handle::try_current().is_ok() {
819            return Err(anyhow!(
820                "StaticExporter sync methods cannot be used inside an async context. \
821             Use StaticExporterBuilder::build_async() and the associated AsyncStaticExporter::write_fig(...)."
822            )
823            .into());
824        }
825        let rt = self.runtime.clone();
826        rt.block_on(
827            self.inner
828                .write_fig(dst, plot, format, width, height, scale),
829        )
830    }
831
832    /// Exports a Plotly plot to a string representation.
833    ///
834    /// Renders the provided Plotly JSON plot and returns the result as a
835    /// string. or an error if the export fails.
836    ///
837    /// The format of the string depends on the image format. For
838    /// ImageFormat::SVG the function will generate plain SVG text, for
839    /// other formats it will return base64-encoded data.
840    ///
841    ///
842    /// # Examples
843    ///
844    /// ```no_run
845    /// 
846    /// // This example requires a running WebDriver (chromedriver/geckodriver) and a browser.
847    /// // It cannot be run as a doc test.
848    /// use plotly_static::{StaticExporterBuilder, ImageFormat};
849    /// use serde_json::json;
850    ///
851    /// let plot = json!({
852    ///     "data": [{"type": "scatter", "x": [1,2,3], "y": [4,5,6]}],
853    ///     "layout": {}
854    /// });
855    ///
856    /// let mut exporter = StaticExporterBuilder::default().build().unwrap();
857    ///
858    /// let svg_data = exporter.write_to_string(
859    ///     &plot,
860    ///     ImageFormat::SVG,
861    ///     800,
862    ///     600,
863    ///     1.0
864    /// ).expect("Failed to export plot");
865    ///
866    /// // Close the exporter
867    /// exporter.close();
868    ///
869    /// // svg_data contains the SVG markup as a string
870    /// assert!(svg_data.starts_with("<svg"));
871    /// ```
872    pub fn write_to_string(
873        &mut self,
874        plot: &serde_json::Value,
875        format: ImageFormat,
876        width: usize,
877        height: usize,
878        scale: f64,
879    ) -> Result<String, Box<dyn std::error::Error>> {
880        if tokio::runtime::Handle::try_current().is_ok() {
881            return Err(anyhow!(
882                "StaticExporter sync methods cannot be used inside an async context. \
883             Use StaticExporterBuilder::build_async() and the associated AsyncStaticExporter::write_to_string(...)."
884            )
885            .into());
886        }
887        let rt = self.runtime.clone();
888        rt.block_on(
889            self.inner
890                .write_to_string(plot, format, width, height, scale),
891        )
892    }
893
894    /// Get diagnostic information about the underlying WebDriver process.
895    ///
896    /// This method provides detailed information about the WebDriver process
897    /// for debugging purposes, including process status, port information,
898    /// and connection details.
899    pub fn get_webdriver_diagnostics(&self) -> String {
900        self.inner.get_webdriver_diagnostics()
901    }
902
903    /// Explicitly close the WebDriver session and stop the driver.
904    ///
905    /// Always call close to ensure proper cleanup.
906    pub fn close(&mut self) {
907        let runtime = self.runtime.clone();
908        runtime.block_on(self.inner.close());
909    }
910}
911
912/// Async StaticExporter for async contexts. Keeps the same API as the sync
913/// StaticExporter for compatibility.
914pub struct AsyncStaticExporter {
915    /// WebDriver server port (default: 4444)
916    webdriver_port: u32,
917
918    /// WebDriver server base URL (default: "http://localhost")
919    webdriver_url: String,
920
921    /// WebDriver process manager for spawning and cleanup
922    webdriver: WebDriver,
923
924    /// Use bundled JS libraries instead of CDN
925    offline_mode: bool,
926
927    /// PDF export timeout in milliseconds
928    pdf_export_timeout: u32,
929
930    /// Browser command-line flags (e.g., "--headless", "--no-sandbox")
931    webdriver_browser_caps: Vec<String>,
932
933    /// Cached WebDriver client for session reuse
934    webdriver_client: Option<Client>,
935}
936
937impl AsyncStaticExporter {
938    /// Exports a Plotly plot to a static image file
939    ///
940    /// Same as [`StaticExporter::write_fig`] but async.
941    pub async fn write_fig(
942        &mut self,
943        dst: &Path,
944        plot: &serde_json::Value,
945        format: ImageFormat,
946        width: usize,
947        height: usize,
948        scale: f64,
949    ) -> Result<(), Box<dyn std::error::Error>> {
950        let mut dst = PathBuf::from(dst);
951        dst.set_extension(format.to_string());
952
953        let plot_data = PlotData {
954            format: format.clone(),
955            width,
956            height,
957            scale,
958            data: plot,
959        };
960
961        let image_data = self.static_export(&plot_data).await?;
962        let data = match format {
963            ImageFormat::SVG => image_data.as_bytes().to_vec(),
964            _ => general_purpose::STANDARD.decode(image_data)?,
965        };
966        let mut file = File::create(dst.as_path())?;
967        file.write_all(&data)?;
968        file.flush()?;
969
970        Ok(())
971    }
972
973    /// Exports a Plotly plot to a string representation.
974    ///
975    /// Same as [`StaticExporter::write_to_string`] but async.
976    pub async fn write_to_string(
977        &mut self,
978        plot: &serde_json::Value,
979        format: ImageFormat,
980        width: usize,
981        height: usize,
982        scale: f64,
983    ) -> Result<String, Box<dyn std::error::Error>> {
984        let plot_data = PlotData {
985            format,
986            width,
987            height,
988            scale,
989            data: plot,
990        };
991        let image_data = self.static_export(&plot_data).await?;
992        Ok(image_data)
993    }
994
995    /// Close the WebDriver session and stop the driver if it was spawned.
996    ///
997    /// Always call close to ensure proper cleanup.
998    pub async fn close(&mut self) {
999        if let Some(client) = self.webdriver_client.take() {
1000            if let Err(e) = client.close().await {
1001                error!("Failed to close WebDriver client: {e}");
1002            }
1003        }
1004        if let Err(e) = self.webdriver.stop() {
1005            error!("Failed to stop WebDriver: {e}");
1006        }
1007    }
1008
1009    /// Get diagnostic information about the underlying WebDriver process.
1010    pub fn get_webdriver_diagnostics(&self) -> String {
1011        self.webdriver.get_diagnostics()
1012    }
1013
1014    /// Export the Plotly plot image to a string representation calling the
1015    /// Plotly.toImage function.
1016    async fn static_export(&mut self, plot: &PlotData<'_>) -> Result<String> {
1017        let html_content = template::get_html_body(self.offline_mode);
1018        self.extract(&html_content, plot)
1019            .await
1020            .with_context(|| "Failed to extract static image from browser session")
1021    }
1022
1023    /// Extract a static image from a browser session.
1024    async fn extract(&mut self, html_content: &str, plot: &PlotData<'_>) -> Result<String> {
1025        let caps = self.build_webdriver_caps()?;
1026        debug!(
1027            "Use WebDriver and headless browser to export static plot (offline_mode={}, port={})",
1028            self.offline_mode, self.webdriver_port
1029        );
1030        let webdriver_url = format!("{}:{}", self.webdriver_url, self.webdriver_port);
1031        debug!("Connecting to WebDriver at {webdriver_url}");
1032
1033        // Reuse existing client or create new one
1034        let client = if let Some(ref client) = self.webdriver_client {
1035            debug!("Reusing existing WebDriver session");
1036            client.clone()
1037        } else {
1038            debug!("Creating new WebDriver session");
1039            let new_client = ClientBuilder::native()
1040                .capabilities(caps)
1041                .connect(&webdriver_url)
1042                .await
1043                .with_context(|| "WebDriver session error")?;
1044            self.webdriver_client = Some(new_client.clone());
1045            new_client
1046        };
1047
1048        // For offline mode, write HTML to file to avoid data URI size limits since JS
1049        // libraries are embedded in the file
1050        let url = if self.offline_mode {
1051            let temp_file = template::to_file(html_content)
1052                .with_context(|| "Failed to create temporary HTML file")?;
1053            format!("file://{}", temp_file.to_string_lossy())
1054        } else {
1055            // For online mode, use data URI (smaller size since JS is loaded from CDN)
1056            format!("data:text/html,{}", encode(html_content))
1057        };
1058
1059        // Open the HTML
1060        client.goto(&url).await?;
1061
1062        #[cfg(target_os = "windows")]
1063        Self::wait_for_document_ready(&client, std::time::Duration::from_secs(10)).await?;
1064
1065        // Wait for Plotly container element
1066        #[cfg(target_os = "windows")]
1067        Self::wait_for_plotly_container(&client, std::time::Duration::from_secs(10)).await?;
1068
1069        // In online mode, ensure Plotly is loaded
1070        if !self.offline_mode {
1071            #[cfg(target_os = "windows")]
1072            Self::wait_for_plotly_loaded(&client, std::time::Duration::from_secs(15)).await?;
1073        }
1074
1075        let (js_script, args) = match plot.format {
1076            ImageFormat::PDF => {
1077                // Always use SVG for PDF export
1078                let args = vec![
1079                    plot.data.clone(),
1080                    ImageFormat::SVG.to_string().into(),
1081                    plot.width.into(),
1082                    plot.height.into(),
1083                    plot.scale.into(),
1084                ];
1085
1086                (pdf_export_js_script(self.pdf_export_timeout), args)
1087            }
1088            _ => {
1089                let args = vec![
1090                    plot.data.clone(),
1091                    plot.format.to_string().into(),
1092                    plot.width.into(),
1093                    plot.height.into(),
1094                    plot.scale.into(),
1095                ];
1096
1097                (image_export_js_script(), args)
1098            }
1099        };
1100
1101        let data = client.execute_async(&js_script, args).await?;
1102
1103        let result = data.as_str().ok_or(anyhow!(
1104            "Failed to execute Plotly.toImage in browser session"
1105        ))?;
1106
1107        if let Some(err) = result.strip_prefix("ERROR:") {
1108            return Err(anyhow!("JavaScript error during export: {err}"));
1109        }
1110
1111        match plot.format {
1112            ImageFormat::SVG => common::extract_plain(result, &plot.format),
1113            ImageFormat::PNG | ImageFormat::JPEG | ImageFormat::WEBP | ImageFormat::PDF => {
1114                common::extract_encoded(result, &plot.format)
1115            }
1116            #[allow(deprecated)]
1117            ImageFormat::EPS => {
1118                error!("EPS format is deprecated. Use SVG or PDF instead.");
1119                common::extract_encoded(result, &plot.format)
1120            }
1121        }
1122    }
1123
1124    fn build_webdriver_caps(&self) -> Result<Capabilities> {
1125        // Define browser capabilities (copied to avoid reordering existing code)
1126        let mut caps = JsonMap::new();
1127        let mut browser_opts = JsonMap::new();
1128        let browser_args = self.webdriver_browser_caps.clone();
1129
1130        browser_opts.insert("args".to_string(), serde_json::json!(browser_args));
1131
1132        // Add Chrome binary capability if BROWSER_PATH is set
1133        #[cfg(feature = "chromedriver")]
1134        if let Ok(chrome_path) = std::env::var("BROWSER_PATH") {
1135            browser_opts.insert("binary".to_string(), serde_json::json!(chrome_path));
1136            debug!("Added Chrome binary capability: {chrome_path}");
1137        }
1138        // Add Firefox binary capability if BROWSER_PATH is set
1139        #[cfg(feature = "geckodriver")]
1140        if let Ok(firefox_path) = std::env::var("BROWSER_PATH") {
1141            browser_opts.insert("binary".to_string(), serde_json::json!(firefox_path));
1142            debug!("Added Firefox binary capability: {firefox_path}");
1143        }
1144
1145        // Add Firefox-specific preferences for CI environments
1146        #[cfg(feature = "geckodriver")]
1147        {
1148            let prefs = common::get_firefox_ci_preferences();
1149            browser_opts.insert("prefs".to_string(), serde_json::json!(prefs));
1150            debug!("Added Firefox preferences for CI compatibility");
1151        }
1152
1153        caps.insert(
1154            "browserName".to_string(),
1155            serde_json::json!(get_browser_name()),
1156        );
1157        caps.insert(
1158            get_options_key().to_string(),
1159            serde_json::json!(browser_opts),
1160        );
1161
1162        debug!("WebDriver capabilities: {caps:?}");
1163
1164        Ok(caps)
1165    }
1166
1167    #[cfg(target_os = "windows")]
1168    async fn wait_for_document_ready(client: &Client, timeout: std::time::Duration) -> Result<()> {
1169        let start = std::time::Instant::now();
1170        loop {
1171            let state = client
1172                .execute("return document.readyState;", vec![])
1173                .await
1174                .unwrap_or(serde_json::Value::Null);
1175            if state.as_str().map(|s| s == "complete").unwrap_or(false) {
1176                return Ok(());
1177            }
1178            if start.elapsed() > timeout {
1179                return Err(anyhow!(
1180                    "Timeout waiting for document.readyState === 'complete'"
1181                ));
1182            }
1183            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1184        }
1185    }
1186
1187    #[cfg(target_os = "windows")]
1188    async fn wait_for_plotly_container(
1189        client: &Client,
1190        timeout: std::time::Duration,
1191    ) -> Result<()> {
1192        let start = std::time::Instant::now();
1193        loop {
1194            let has_el = client
1195                .execute(
1196                    "return !!document.getElementById('plotly-html-element');",
1197                    vec![],
1198                )
1199                .await
1200                .unwrap_or(serde_json::Value::Bool(false));
1201            if has_el.as_bool().unwrap_or(false) {
1202                return Ok(());
1203            }
1204        }
1205        if start.elapsed() > timeout {
1206            return Err(anyhow!(
1207                "Timeout waiting for #plotly-html-element to appear in DOM"
1208            ));
1209        }
1210        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1211    }
1212
1213    #[cfg(target_os = "windows")]
1214    async fn wait_for_plotly_loaded(client: &Client, timeout: std::time::Duration) -> Result<()> {
1215        let start = std::time::Instant::now();
1216        loop {
1217            let has_plotly = client
1218                .execute("return !!window.Plotly;", vec![])
1219                .await
1220                .unwrap_or(serde_json::Value::Bool(false));
1221            if has_plotly.as_bool().unwrap_or(false) {
1222                return Ok(());
1223            }
1224            if start.elapsed() > timeout {
1225                return Err(anyhow!("Timeout waiting for Plotly library to load"));
1226            }
1227            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1228        }
1229    }
1230}
1231
1232mod common {
1233    use super::*;
1234
1235    pub(crate) fn extract_plain(payload: &str, format: &ImageFormat) -> Result<String> {
1236        match payload.split_once(",") {
1237            Some((type_info, data)) => {
1238                extract_type_info(type_info, format);
1239                let decoded = urlencoding::decode(data)?;
1240                Ok(decoded.to_string())
1241            }
1242            None => Err(anyhow!("'src' attribute has invalid {format} data")),
1243        }
1244    }
1245
1246    pub(crate) fn extract_encoded(payload: &str, format: &ImageFormat) -> Result<String> {
1247        match payload.split_once(";") {
1248            Some((type_info, encoded_data)) => {
1249                extract_type_info(type_info, format);
1250                extract_encoded_data(encoded_data)
1251                    .ok_or(anyhow!("No valid image data found in 'src' attribute"))
1252            }
1253            None => Err(anyhow!("'src' attribute has invalid base64 data")),
1254        }
1255    }
1256
1257    pub(crate) fn extract_type_info(type_info: &str, format: &ImageFormat) {
1258        let val = type_info.split_once("/").map(|d| d.1.to_string());
1259        match val {
1260            Some(ext) => {
1261                if !ext.contains(&format.to_string()) {
1262                    error!("Requested ImageFormat '{format}', got '{ext}'");
1263                }
1264            }
1265            None => warn!("Failed to extract static Image Format from 'src' attribute"),
1266        }
1267    }
1268
1269    pub(crate) fn extract_encoded_data(data: &str) -> Option<String> {
1270        data.split_once(",").map(|d| d.1.to_string())
1271    }
1272
1273    /// Get Firefox preferences optimized for CI environments.
1274    ///
1275    /// These preferences force software rendering and enable WebGL in headless
1276    /// mode to work around graphics/WebGL issues in CI environments.
1277    #[cfg(feature = "geckodriver")]
1278    pub(crate) fn get_firefox_ci_preferences() -> serde_json::Map<String, serde_json::Value> {
1279        let mut prefs = serde_json::Map::new();
1280
1281        // Force software rendering and enable WebGL in headless mode
1282        prefs.insert(
1283            "layers.acceleration.disabled".to_string(),
1284            serde_json::json!(true),
1285        );
1286        prefs.insert("gfx.webrender.all".to_string(), serde_json::json!(false));
1287        prefs.insert(
1288            "gfx.webrender.software".to_string(),
1289            serde_json::json!(true),
1290        );
1291        prefs.insert("webgl.disabled".to_string(), serde_json::json!(false));
1292        prefs.insert("webgl.force-enabled".to_string(), serde_json::json!(true));
1293        prefs.insert("webgl.enable-webgl2".to_string(), serde_json::json!(true));
1294
1295        // Force software WebGL implementation
1296        prefs.insert(
1297            "webgl.software-rendering".to_string(),
1298            serde_json::json!(true),
1299        );
1300        prefs.insert(
1301            "webgl.software-rendering.force".to_string(),
1302            serde_json::json!(true),
1303        );
1304
1305        // Disable hardware acceleration completely
1306        prefs.insert(
1307            "gfx.canvas.azure.accelerated".to_string(),
1308            serde_json::json!(false),
1309        );
1310        prefs.insert(
1311            "gfx.canvas.azure.accelerated-layers".to_string(),
1312            serde_json::json!(false),
1313        );
1314        prefs.insert(
1315            "gfx.content.azure.backends".to_string(),
1316            serde_json::json!("cairo"),
1317        );
1318
1319        // Force software rendering for all graphics
1320        prefs.insert("gfx.2d.force-enabled".to_string(), serde_json::json!(true));
1321        prefs.insert("gfx.2d.force-software".to_string(), serde_json::json!(true));
1322
1323        prefs
1324    }
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use std::path::PathBuf;
1330    use std::sync::atomic::{AtomicU32, Ordering};
1331
1332    use super::*;
1333
1334    fn init() {
1335        let _ = env_logger::try_init();
1336    }
1337
1338    // Helper to generate unique ports for parallel tests
1339    #[cfg(not(feature = "debug"))]
1340    fn get_unique_port() -> u32 {
1341        static PORT_COUNTER: AtomicU32 = AtomicU32::new(4844);
1342        PORT_COUNTER.fetch_add(1, Ordering::SeqCst)
1343    }
1344
1345    // In CI which may run on slow machines, we run a different strategy to generate
1346    // the unique port.
1347    #[cfg(feature = "debug")]
1348    fn get_unique_port() -> u32 {
1349        static PORT_COUNTER: AtomicU32 = AtomicU32::new(4844);
1350
1351        // Sometimes the webdriver process is not stopped immediately
1352        // and we get port conflicts. We try to give some time for other
1353        // webdriver processes to stop so that we don't get port conflicts.
1354        loop {
1355            let p = PORT_COUNTER.fetch_add(1, Ordering::SeqCst);
1356            if !webdriver::WebDriver::is_webdriver_running(p) {
1357                return p;
1358            }
1359        }
1360    }
1361
1362    fn create_test_plot() -> serde_json::Value {
1363        serde_json::to_value(serde_json::json!(
1364            {
1365            "data": [
1366              {
1367                "name": "Surface",
1368                "type": "surface",
1369                "x": [
1370                  1.0,
1371                  2.0,
1372                  3.0
1373                ],
1374                "y": [
1375                  4.0,
1376                  5.0,
1377                  6.0
1378                ],
1379                "z": [
1380                  [
1381                    1.0,
1382                    2.0,
1383                    3.0
1384                  ],
1385                  [
1386                    4.0,
1387                    5.0,
1388                    6.0
1389                  ],
1390                  [
1391                    7.0,
1392                    8.0,
1393                    9.0
1394                  ]
1395                ]
1396              }
1397            ],
1398            "layout": {
1399                "autosize": false,
1400                "width": 1200,
1401                "height": 900,
1402                "scene": {
1403                    "domain": {
1404                        "x": [0.15, 0.95],
1405                        "y": [0.15, 0.95]
1406                    },
1407                    "aspectmode": "data",
1408                    "aspectratio": {
1409                        "x": 1,
1410                        "y": 1,
1411                        "z": 1
1412                    },
1413                    "camera": {
1414                        "eye": {"x": 1.5, "y": 1.5, "z": 1.5}
1415                    }
1416                },
1417                "config": {
1418                    "responsive": false
1419                },
1420            },
1421        }))
1422        .unwrap()
1423    }
1424
1425    #[test]
1426    fn save_png() {
1427        init();
1428        let test_plot = create_test_plot();
1429
1430        let mut exporter = StaticExporterBuilder::default()
1431            .spawn_webdriver(true)
1432            .webdriver_port(get_unique_port())
1433            .build()
1434            .unwrap();
1435        let dst = PathBuf::from("static_example.png");
1436        exporter
1437            .write_fig(dst.as_path(), &test_plot, ImageFormat::PNG, 1200, 900, 4.5)
1438            .unwrap();
1439        assert!(dst.exists());
1440        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1441        let file_size = metadata.len();
1442        assert!(file_size > 0,);
1443        #[cfg(not(feature = "debug"))]
1444        assert!(std::fs::remove_file(dst.as_path()).is_ok());
1445
1446        exporter.close();
1447    }
1448
1449    #[test]
1450    fn save_jpeg() {
1451        init();
1452        let test_plot = create_test_plot();
1453        let mut exporter = StaticExporterBuilder::default()
1454            .spawn_webdriver(true)
1455            .webdriver_port(get_unique_port())
1456            .build()
1457            .unwrap();
1458        let dst = PathBuf::from("static_example.jpeg");
1459        exporter
1460            .write_fig(dst.as_path(), &test_plot, ImageFormat::JPEG, 1200, 900, 4.5)
1461            .unwrap();
1462        assert!(dst.exists());
1463        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1464        let file_size = metadata.len();
1465        assert!(file_size > 0,);
1466        #[cfg(not(feature = "debug"))]
1467        assert!(std::fs::remove_file(dst.as_path()).is_ok());
1468
1469        exporter.close();
1470    }
1471
1472    #[test]
1473    fn save_svg() {
1474        init();
1475        let test_plot = create_test_plot();
1476        let mut exporter = StaticExporterBuilder::default()
1477            .spawn_webdriver(true)
1478            .webdriver_port(get_unique_port())
1479            .build()
1480            .unwrap();
1481        let dst = PathBuf::from("static_example.svg");
1482        exporter
1483            .write_fig(dst.as_path(), &test_plot, ImageFormat::SVG, 1200, 900, 4.5)
1484            .unwrap();
1485        assert!(dst.exists());
1486        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1487        let file_size = metadata.len();
1488        assert!(file_size > 0,);
1489        #[cfg(not(feature = "debug"))]
1490        assert!(std::fs::remove_file(dst.as_path()).is_ok());
1491
1492        exporter.close();
1493    }
1494
1495    #[test]
1496    fn save_webp() {
1497        init();
1498        let test_plot = create_test_plot();
1499        let mut exporter = StaticExporterBuilder::default()
1500            .spawn_webdriver(true)
1501            .webdriver_port(get_unique_port())
1502            .build()
1503            .unwrap();
1504        let dst = PathBuf::from("static_example.webp");
1505        exporter
1506            .write_fig(dst.as_path(), &test_plot, ImageFormat::WEBP, 1200, 900, 4.5)
1507            .unwrap();
1508        assert!(dst.exists());
1509        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1510        let file_size = metadata.len();
1511        assert!(file_size > 0,);
1512        #[cfg(not(feature = "debug"))]
1513        assert!(std::fs::remove_file(dst.as_path()).is_ok());
1514
1515        exporter.close();
1516    }
1517
1518    #[tokio::test]
1519    async fn save_png_async() {
1520        init();
1521        let test_plot = create_test_plot();
1522
1523        let mut exporter = StaticExporterBuilder::default()
1524            .spawn_webdriver(true)
1525            .webdriver_port(5444)
1526            .build_async()
1527            .unwrap();
1528
1529        let dst = PathBuf::from("static_example_async.png");
1530        exporter
1531            .write_fig(dst.as_path(), &test_plot, ImageFormat::PNG, 1200, 900, 4.5)
1532            .await
1533            .unwrap();
1534
1535        assert!(dst.exists());
1536        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1537        let file_size = metadata.len();
1538        assert!(file_size > 0,);
1539        #[cfg(not(feature = "debug"))]
1540        assert!(std::fs::remove_file(dst.as_path()).is_ok());
1541
1542        exporter.close().await;
1543    }
1544
1545    #[test]
1546    fn save_pdf() {
1547        init();
1548        let test_plot = create_test_plot();
1549        #[cfg(feature = "debug")]
1550        let mut exporter = StaticExporterBuilder::default()
1551            .spawn_webdriver(true)
1552            .webdriver_port(get_unique_port())
1553            .pdf_export_timeout(750)
1554            .build()
1555            .unwrap();
1556
1557        #[cfg(not(feature = "debug"))]
1558        let mut exporter = StaticExporterBuilder::default()
1559            .spawn_webdriver(true)
1560            .webdriver_port(get_unique_port())
1561            .build()
1562            .unwrap();
1563
1564        let dst = PathBuf::from("static_example.pdf");
1565        exporter
1566            .write_fig(dst.as_path(), &test_plot, ImageFormat::PDF, 1200, 900, 4.5)
1567            .unwrap();
1568        assert!(dst.exists());
1569        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1570        let file_size = metadata.len();
1571        assert!(file_size > 600000,);
1572        #[cfg(not(feature = "debug"))]
1573        assert!(std::fs::remove_file(dst.as_path()).is_ok());
1574
1575        exporter.close();
1576    }
1577
1578    #[test]
1579    fn save_jpeg_sequentially() {
1580        init();
1581        let test_plot = create_test_plot();
1582        let mut exporter = StaticExporterBuilder::default()
1583            .spawn_webdriver(true)
1584            .webdriver_port(get_unique_port())
1585            .build()
1586            .unwrap();
1587
1588        let dst = PathBuf::from("static_example.jpeg");
1589        exporter
1590            .write_fig(dst.as_path(), &test_plot, ImageFormat::JPEG, 1200, 900, 4.5)
1591            .unwrap();
1592        assert!(dst.exists());
1593        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1594        let file_size = metadata.len();
1595        assert!(file_size > 0,);
1596        #[cfg(not(feature = "debug"))]
1597        assert!(std::fs::remove_file(dst.as_path()).is_ok());
1598
1599        let dst = PathBuf::from("example2.jpeg");
1600        exporter
1601            .write_fig(dst.as_path(), &test_plot, ImageFormat::JPEG, 1200, 900, 4.5)
1602            .unwrap();
1603        assert!(dst.exists());
1604        let metadata = std::fs::metadata(&dst).expect("Could not retrieve file metadata");
1605        let file_size = metadata.len();
1606        assert!(file_size > 0,);
1607        #[cfg(not(feature = "debug"))]
1608        assert!(std::fs::remove_file(dst.as_path()).is_ok());
1609
1610        exporter.close();
1611    }
1612
1613    #[test]
1614    #[cfg(feature = "chromedriver")]
1615    // Skip this test for geckodriver as it doesn't support multiple concurrent
1616    // sessions on the same process as gracefully as chromedriver
1617    fn test_webdriver_process_reuse() {
1618        init();
1619        let test_plot = create_test_plot();
1620
1621        // Use a unique port to test actual WebDriver process reuse
1622        let test_port = get_unique_port();
1623
1624        // Create first exporter - this should spawn a new WebDriver
1625        let mut exporter1 = StaticExporterBuilder::default()
1626            .spawn_webdriver(true)
1627            .webdriver_port(test_port)
1628            .build()
1629            .unwrap();
1630
1631        // Export first image
1632        let dst1 = PathBuf::from("process_reuse_1.png");
1633        exporter1
1634            .write_fig(dst1.as_path(), &test_plot, ImageFormat::PNG, 800, 600, 1.0)
1635            .unwrap();
1636        assert!(dst1.exists());
1637        #[cfg(not(feature = "debug"))]
1638        assert!(std::fs::remove_file(dst1.as_path()).is_ok());
1639        exporter1.close();
1640
1641        // Create second exporter on the same port - this should connect to existing
1642        // WebDriver process (but create a new session)
1643        let mut exporter2 = StaticExporterBuilder::default()
1644            .spawn_webdriver(true)
1645            .webdriver_port(test_port)
1646            .build()
1647            .unwrap();
1648
1649        // Export second image using a new session on the same WebDriver process
1650        let dst2 = PathBuf::from("process_reuse_2.png");
1651        exporter2
1652            .write_fig(dst2.as_path(), &test_plot, ImageFormat::PNG, 800, 600, 1.0)
1653            .unwrap();
1654        assert!(dst2.exists());
1655        #[cfg(not(feature = "debug"))]
1656        assert!(std::fs::remove_file(dst2.as_path()).is_ok());
1657        exporter2.close();
1658
1659        // Create third exporter on the same port - should also connect to existing
1660        // WebDriver process
1661        let mut exporter3 = StaticExporterBuilder::default()
1662            .spawn_webdriver(true)
1663            .webdriver_port(test_port)
1664            .build()
1665            .unwrap();
1666
1667        // Export third image using another new session on the same WebDriver process
1668        let dst3 = PathBuf::from("process_reuse_3.png");
1669        exporter3
1670            .write_fig(dst3.as_path(), &test_plot, ImageFormat::PNG, 800, 600, 1.0)
1671            .unwrap();
1672        assert!(dst3.exists());
1673        #[cfg(not(feature = "debug"))]
1674        assert!(std::fs::remove_file(dst3.as_path()).is_ok());
1675        exporter3.close();
1676    }
1677}
1678
1679#[cfg(feature = "chromedriver")]
1680mod chrome {
1681    /// Returns the browser name for Chrome WebDriver.
1682    ///
1683    /// This function returns "chrome" as the browser identifier for Chrome
1684    /// WebDriver. It's used internally to configure WebDriver capabilities.
1685    pub fn get_browser_name() -> &'static str {
1686        "chrome"
1687    }
1688
1689    /// Returns the Chrome-specific options key for WebDriver capabilities.
1690    ///
1691    /// This function returns "goog:chromeOptions" which is the standard key
1692    /// for Chrome-specific WebDriver options.
1693    pub fn get_options_key() -> &'static str {
1694        "goog:chromeOptions"
1695    }
1696}
1697
1698#[cfg(feature = "geckodriver")]
1699mod firefox {
1700    /// Returns the browser name for Firefox WebDriver.
1701    ///
1702    /// This function returns "firefox" as the browser identifier for Firefox
1703    /// WebDriver. It's used internally to configure WebDriver capabilities.
1704    pub fn get_browser_name() -> &'static str {
1705        "firefox"
1706    }
1707
1708    /// Returns the Firefox-specific options key for WebDriver capabilities.
1709    ///
1710    /// This function returns "moz:firefoxOptions" which is the standard key
1711    /// for Firefox-specific WebDriver options.
1712    pub fn get_options_key() -> &'static str {
1713        "moz:firefoxOptions"
1714    }
1715}
1716
1717#[cfg(feature = "chromedriver")]
1718use chrome::{get_browser_name, get_options_key};
1719#[cfg(feature = "geckodriver")]
1720use firefox::{get_browser_name, get_options_key};