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};