Skip to content

Commit 33e2a77

Browse files
committed
step one
Signed-off-by: Andrei Gherghescu <8067229+andrei-ng@users.noreply.github.com>
1 parent ef25443 commit 33e2a77

File tree

1 file changed

+276
-0
lines changed

1 file changed

+276
-0
lines changed

plotly_static/src/lib.rs

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)