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