@@ -640,6 +640,23 @@ impl StaticExporterBuilder {
640640 }
641641}
642642
643+ // Async builder for async-first exporter (added without reordering existing items)
644+ impl StaticExporterBuilder {
645+ /// Build an async exporter for use within async contexts.
646+ pub fn build_async ( & self ) -> Result < AsyncStaticExporter > {
647+ let wd = self . create_webdriver ( ) ?;
648+ Ok ( AsyncStaticExporter {
649+ webdriver_port : self . webdriver_port ,
650+ webdriver_url : self . webdriver_url . clone ( ) ,
651+ webdriver : wd,
652+ offline_mode : self . offline_mode ,
653+ pdf_export_timeout : self . pdf_export_timeout ,
654+ webdriver_browser_caps : self . webdriver_browser_caps . clone ( ) ,
655+ webdriver_client : None ,
656+ } )
657+ }
658+ }
659+
643660/// Main struct for exporting Plotly plots to static images.
644661///
645662/// This struct provides methods to convert Plotly JSON plots into various
@@ -1120,6 +1137,265 @@ impl StaticExporter {
11201137 pub fn get_webdriver_diagnostics ( & self ) -> String {
11211138 self . webdriver . get_diagnostics ( )
11221139 }
1140+
1141+ /// Explicitly close the WebDriver session and stop the driver.
1142+ /// Prefer calling this in long-running applications to ensure deterministic cleanup.
1143+ pub fn close ( & mut self ) -> Result < ( ) > {
1144+ if let Some ( client) = self . webdriver_client . take ( ) {
1145+ let runtime = self . runtime . clone ( ) ;
1146+ runtime. block_on ( async {
1147+ if let Err ( e) = client. close ( ) . await {
1148+ error ! ( "Failed to close WebDriver client: {e}" ) ;
1149+ }
1150+ } ) ;
1151+ }
1152+ if let Err ( e) = self . webdriver . stop ( ) {
1153+ error ! ( "Failed to stop WebDriver: {e}" ) ;
1154+ }
1155+ Ok ( ( ) )
1156+ }
1157+ }
1158+
1159+ /// Async-first exporter for async contexts. Keeps existing sync API intact.
1160+ pub struct AsyncStaticExporter {
1161+ /// WebDriver server port (default: 4444)
1162+ webdriver_port : u32 ,
1163+
1164+ /// WebDriver server base URL (default: "http://localhost")
1165+ webdriver_url : String ,
1166+
1167+ /// WebDriver process manager for spawning and cleanup
1168+ webdriver : WebDriver ,
1169+
1170+ /// Use bundled JS libraries instead of CDN
1171+ offline_mode : bool ,
1172+
1173+ /// PDF export timeout in milliseconds
1174+ pdf_export_timeout : u32 ,
1175+
1176+ /// Browser command-line flags (e.g., "--headless", "--no-sandbox")
1177+ webdriver_browser_caps : Vec < String > ,
1178+
1179+ /// Cached WebDriver client for session reuse
1180+ webdriver_client : Option < Client > ,
1181+ }
1182+
1183+ impl AsyncStaticExporter {
1184+ pub async fn write_fig (
1185+ & mut self ,
1186+ dst : & Path ,
1187+ plot : & serde_json:: Value ,
1188+ format : ImageFormat ,
1189+ width : usize ,
1190+ height : usize ,
1191+ scale : f64 ,
1192+ ) -> Result < ( ) , Box < dyn std:: error:: Error > > {
1193+ let mut dst = PathBuf :: from ( dst) ;
1194+ dst. set_extension ( format. to_string ( ) ) ;
1195+
1196+ let plot_data = PlotData {
1197+ format : format. clone ( ) ,
1198+ width,
1199+ height,
1200+ scale,
1201+ data : plot,
1202+ } ;
1203+
1204+ let image_data = self . export ( plot_data) . await ?;
1205+ let data = match format {
1206+ ImageFormat :: SVG => image_data. as_bytes ( ) . to_vec ( ) ,
1207+ _ => general_purpose:: STANDARD . decode ( image_data) ?,
1208+ } ;
1209+ let mut file = File :: create ( dst. as_path ( ) ) ?;
1210+ file. write_all ( & data) ?;
1211+ file. flush ( ) ?;
1212+
1213+ Ok ( ( ) )
1214+ }
1215+
1216+ pub async fn write_to_string (
1217+ & mut self ,
1218+ plot : & serde_json:: Value ,
1219+ format : ImageFormat ,
1220+ width : usize ,
1221+ height : usize ,
1222+ scale : f64 ,
1223+ ) -> Result < String , Box < dyn std:: error:: Error > > {
1224+ let plot_data = PlotData {
1225+ format,
1226+ width,
1227+ height,
1228+ scale,
1229+ data : plot,
1230+ } ;
1231+ let image_data = self . export ( plot_data) . await ?;
1232+ Ok ( image_data)
1233+ }
1234+
1235+ async fn export ( & mut self , plot : PlotData < ' _ > ) -> Result < String > {
1236+ let data = self . static_export ( & plot) . await ?;
1237+ Ok ( data)
1238+ }
1239+
1240+ async fn static_export ( & mut self , plot : & PlotData < ' _ > ) -> Result < String > {
1241+ let html_content = template:: get_html_body ( self . offline_mode ) ;
1242+ self . extract ( & html_content, plot)
1243+ . await
1244+ . with_context ( || "Failed to extract static image from browser session" )
1245+ }
1246+
1247+ async fn extract ( & mut self , html_content : & str , plot : & PlotData < ' _ > ) -> Result < String > {
1248+ let caps = self . build_webdriver_caps ( ) ?;
1249+ debug ! ( "Use WebDriver and headless browser to export static plot" ) ;
1250+ let webdriver_url = format ! ( "{}:{}" , self . webdriver_url, self . webdriver_port) ;
1251+
1252+ // Reuse existing client or create new one
1253+ let client = if let Some ( ref client) = self . webdriver_client {
1254+ debug ! ( "Reusing existing WebDriver session" ) ;
1255+ client. clone ( )
1256+ } else {
1257+ debug ! ( "Creating new WebDriver session" ) ;
1258+ let new_client = ClientBuilder :: native ( )
1259+ . capabilities ( caps)
1260+ . connect ( & webdriver_url)
1261+ . await
1262+ . with_context ( || "WebDriver session error" ) ?;
1263+ self . webdriver_client = Some ( new_client. clone ( ) ) ;
1264+ new_client
1265+ } ;
1266+
1267+ // For offline mode, write HTML to file to avoid data URI size limits since JS
1268+ // libraries are embedded in the file
1269+ let url = if self . offline_mode {
1270+ let temp_file = template:: to_file ( html_content)
1271+ . with_context ( || "Failed to create temporary HTML file" ) ?;
1272+ format ! ( "file://{}" , temp_file. to_string_lossy( ) )
1273+ } else {
1274+ // For online mode, use data URI (smaller size since JS is loaded from CDN)
1275+ format ! ( "data:text/html,{}" , encode( html_content) )
1276+ } ;
1277+
1278+ // Open the HTML
1279+ client. goto ( & url) . await ?;
1280+
1281+ let ( js_script, args) = match plot. format {
1282+ ImageFormat :: PDF => {
1283+ // Always use SVG for PDF export
1284+ let args = vec ! [
1285+ plot. data. clone( ) ,
1286+ ImageFormat :: SVG . to_string( ) . into( ) ,
1287+ plot. width. into( ) ,
1288+ plot. height. into( ) ,
1289+ plot. scale. into( ) ,
1290+ ] ;
1291+
1292+ ( pdf_export_js_script ( self . pdf_export_timeout ) , args)
1293+ }
1294+ _ => {
1295+ let args = vec ! [
1296+ plot. data. clone( ) ,
1297+ plot. format. to_string( ) . into( ) ,
1298+ plot. width. into( ) ,
1299+ plot. height. into( ) ,
1300+ plot. scale. into( ) ,
1301+ ] ;
1302+
1303+ ( image_export_js_script ( ) , args)
1304+ }
1305+ } ;
1306+
1307+ let data = client. execute_async ( & js_script, args) . await ?;
1308+
1309+ let result = data. as_str ( ) . ok_or ( anyhow ! (
1310+ "Failed to execute Plotly.toImage in browser session"
1311+ ) ) ?;
1312+
1313+ if let Some ( err) = result. strip_prefix ( "ERROR:" ) {
1314+ return Err ( anyhow ! ( "JavaScript error during export: {err}" ) ) ;
1315+ }
1316+
1317+ match plot. format {
1318+ ImageFormat :: SVG => StaticExporter :: extract_plain ( result, & plot. format ) ,
1319+ ImageFormat :: PNG | ImageFormat :: JPEG | ImageFormat :: WEBP | ImageFormat :: PDF => {
1320+ StaticExporter :: extract_encoded ( result, & plot. format )
1321+ }
1322+ #[ allow( deprecated) ]
1323+ ImageFormat :: EPS => {
1324+ error ! ( "EPS format is deprecated. Use SVG or PDF instead." ) ;
1325+ StaticExporter :: extract_encoded ( result, & plot. format )
1326+ }
1327+ }
1328+ }
1329+
1330+ fn build_webdriver_caps ( & self ) -> Result < Capabilities > {
1331+ // Define browser capabilities (copied to avoid reordering existing code)
1332+ let mut caps = JsonMap :: new ( ) ;
1333+ let mut browser_opts = JsonMap :: new ( ) ;
1334+ let browser_args = self . webdriver_browser_caps . clone ( ) ;
1335+
1336+ browser_opts. insert ( "args" . to_string ( ) , serde_json:: json!( browser_args) ) ;
1337+
1338+ // Add Chrome binary capability if BROWSER_PATH is set
1339+ #[ cfg( feature = "chromedriver" ) ]
1340+ if let Ok ( chrome_path) = std:: env:: var ( "BROWSER_PATH" ) {
1341+ browser_opts. insert ( "binary" . to_string ( ) , serde_json:: json!( chrome_path) ) ;
1342+ debug ! ( "Added Chrome binary capability: {chrome_path}" ) ;
1343+ }
1344+ // Add Firefox binary capability if BROWSER_PATH is set
1345+ #[ cfg( feature = "geckodriver" ) ]
1346+ if let Ok ( firefox_path) = std:: env:: var ( "BROWSER_PATH" ) {
1347+ browser_opts. insert ( "binary" . to_string ( ) , serde_json:: json!( firefox_path) ) ;
1348+ debug ! ( "Added Firefox binary capability: {firefox_path}" ) ;
1349+ }
1350+
1351+ // Add Firefox-specific preferences for CI environments
1352+ #[ cfg( feature = "geckodriver" ) ]
1353+ {
1354+ let prefs = StaticExporter :: get_firefox_ci_preferences ( ) ;
1355+ browser_opts. insert ( "prefs" . to_string ( ) , serde_json:: json!( prefs) ) ;
1356+ debug ! ( "Added Firefox preferences for CI compatibility" ) ;
1357+ }
1358+
1359+ caps. insert (
1360+ "browserName" . to_string ( ) ,
1361+ serde_json:: json!( get_browser_name( ) ) ,
1362+ ) ;
1363+ caps. insert (
1364+ get_options_key ( ) . to_string ( ) ,
1365+ serde_json:: json!( browser_opts) ,
1366+ ) ;
1367+
1368+ debug ! ( "WebDriver capabilities: {caps:?}" ) ;
1369+
1370+ Ok ( caps)
1371+ }
1372+
1373+ /// Close the WebDriver session and stop the driver if it was spawned.
1374+ pub async fn close ( & mut self ) -> Result < ( ) > {
1375+ if let Some ( client) = self . webdriver_client . take ( ) {
1376+ if let Err ( e) = client. close ( ) . await {
1377+ error ! ( "Failed to close WebDriver client: {e}" ) ;
1378+ }
1379+ }
1380+ if let Err ( e) = self . webdriver . stop ( ) {
1381+ error ! ( "Failed to stop WebDriver: {e}" ) ;
1382+ }
1383+ Ok ( ( ) )
1384+ }
1385+
1386+ /// Get diagnostic information about the underlying WebDriver process.
1387+ pub fn get_webdriver_diagnostics ( & self ) -> String {
1388+ self . webdriver . get_diagnostics ( )
1389+ }
1390+ }
1391+
1392+ impl Drop for AsyncStaticExporter {
1393+ fn drop ( & mut self ) {
1394+ // Best-effort sync cleanup only; do not block on async.
1395+ if let Err ( e) = self . webdriver . stop ( ) {
1396+ error ! ( "Failed to stop WebDriver: {e}" ) ;
1397+ }
1398+ }
11231399}
11241400
11251401#[ cfg( test) ]
0 commit comments