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