From a68d0a458bc1c82cbdd6cda9d814c08a39c4efee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:48:40 +0200 Subject: [PATCH] Export to a browser download on the web instead of failing on a path (#1655) (#1682) --- crates/engine/src/cmd/exchange.rs | 35 +++++++++++++++++++++++++++++-- crates/engine/src/cmd/layout.rs | 5 ++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index 2829c0558..da842c6f0 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -85,11 +85,25 @@ fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> { } #[cfg(target_arch = "wasm32")] { - let _ = (path, bytes); - Err(bad(cmd, "paths are not available on the web; omit `path` to get the file as base64 `data`")) + deliver_file(cmd, path, bytes, super::file::deliver()) } } +/// No file system on the web: hand the bytes to the host's delivery hook (a browser download) +/// named after `path`, like SAVE and TABLEEXPORT do. EXPORT and PLOT (PUBLISH, EXPORTPDF) use it. +#[cfg(any(test, target_arch = "wasm32"))] +pub(super) fn deliver_file(cmd: &str, path: &str, bytes: &[u8], hook: Option) -> Result<()> { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + let mime = match extension(&name).as_str() { + "svg" => "image/svg+xml", + "png" => "image/png", + "pdf" => "application/pdf", + _ => "application/octet-stream", + }; + let deliver = hook.ok_or_else(|| bad(cmd, "files can't be written in this build; omit `path` to get the file as base64 `data`"))?; + deliver(&name, mime, bytes).map_err(|e| bad(cmd, format!("{name}: {e}"))) +} + // ---------- EXPORT ---------- fn run_export(s: &mut Session, p: &Value) -> Result { @@ -545,6 +559,23 @@ mod tests { use super::super::file::{IoHooks, set_io}; use super::*; + static DELIVERED: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + + fn record(name: &str, mime: &str, bytes: &[u8]) -> std::result::Result<(), String> { + DELIVERED.lock().map_err(|e| e.to_string())?.push((name.into(), mime.into(), bytes.len())); + Ok(()) + } + + #[test] + fn web_export_delivers_a_download_instead_of_writing_a_path() { + deliver_file("export", "Drawing1.svg", b"", Some(record)).unwrap(); + deliver_file("export", "dir/Plan.pdf", b"%PDF", Some(record)).unwrap(); + let got = DELIVERED.lock().unwrap().clone(); + assert_eq!(got, [("Drawing1.svg".to_string(), "image/svg+xml".to_string(), 6), ("Plan.pdf".to_string(), "application/pdf".to_string(), 4)]); + let e = deliver_file("export", "a.svg", b"x", None).unwrap_err().to_string(); + assert!(e.contains("base64"), "{e}"); + } + fn fake_plot(_: &Drawing, space: &Space, opts: &Value) -> std::result::Result, String> { // Same output as the layout tests' stand-in: whichever test installs first wins. Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes()) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index b009f687a..f013ab038 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -832,7 +832,10 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages })) } #[cfg(target_arch = "wasm32")] - Err(bad(cmd, "paths are not available on the web; omit `path` to get the PDF as base64 `data`")) + { + super::exchange::deliver_file(cmd, _path, &bytes, super::file::deliver())?; + Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages })) + } } None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages })), }