Merge pull request #209 from Dejan-Port/print-only

web: print straight to the system print dialog, not just export
This commit is contained in:
Brandon Thomas
2026-10-11 15:04:11 -04:00
4 changed files with 82 additions and 2 deletions
+1 -1
View File
@@ -26,4 +26,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator"] }
+47
View File
@@ -136,6 +136,11 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
log::error!("download of {name} failed: {e}");
}
})),
print: Some(Box::new(|bytes: &[u8]| {
if let Err(e) = print_pdf(bytes) {
log::error!("print failed: {e}");
}
})),
open_url: Some(Box::new(|url: &str| {
if let Some(w) = web_sys::window() {
let _ = w.open_with_url_and_target(url, "_blank");
@@ -179,6 +184,48 @@ fn copy_html(html: &str, text: &str) -> Result<(), String> {
Ok(())
}
/// Opens `bytes` (a PDF) as an object URL in a hidden iframe and calls the iframe's own
/// `print()` once it has finished loading — the standard way to drive the browser's native print
/// dialog on a PDF without a download or a popup window the browser might block. The blob URL and
/// the iframe are both cleaned up once the print dialog closes, so repeated prints don't leak
/// them.
fn print_pdf(bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type("application/pdf");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?;
iframe.style().set_property("display", "none").map_err(js)?;
document.body().ok_or("no body")?.append_child(&iframe).map_err(js)?;
// `onload` fires once the PDF has actually rendered inside the iframe;
// printing before that would show a blank page. Set the handler BEFORE
// `src` so a fast/cached load can't fire before we're listening.
let iframe_for_load = iframe.clone();
let url_for_cleanup = url.clone();
let on_load = wasm_bindgen::closure::Closure::once_into_js(move || {
let Some(content_window) = iframe_for_load.content_window() else { return };
let _ = content_window.print();
// `afterprint` fires once the print dialog (or print preview) closes, whether the user
// printed or cancelled — the right moment to release the blob URL and drop the iframe.
let iframe_for_cleanup = iframe_for_load.clone();
let on_after_print = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url_for_cleanup).ok();
iframe_for_cleanup.remove();
});
let _ = content_window.add_event_listener_with_callback("afterprint", on_after_print.unchecked_ref());
});
iframe.set_onload(Some(on_load.unchecked_ref()));
iframe.set_src(&url);
Ok(())
}
/// Triggers a browser download of `bytes`.
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
+27
View File
@@ -155,6 +155,9 @@ pub struct Services {
pub open_async: Option<Box<dyn Fn()>>,
/// Files delivered asynchronously (name, bytes), opened on the next frame.
pub inbox: Option<Inbox>,
/// Web: hand PDF bytes to the browser's print flow directly (no download,
/// no intermediate file) — opens the system print dialog on that PDF.
pub print: Option<Box<dyn Fn(&[u8])>>,
}
/// Shared queue of files read asynchronously (browser file picker, drag and drop).
@@ -329,6 +332,10 @@ impl SheetApp {
/// Runs a command and shows its error in a message box (for menu/ribbon clicks).
pub fn run_or_alert(&mut self, id: &str, params: Json) {
// Printing from the UI also hands the PDF to the host's print flow (web).
if id == "file.print" {
return self.print_now(params);
}
if let Err(e) = self.run(id, params) {
self.alert(e);
}
@@ -343,6 +350,26 @@ impl SheetApp {
}
}
/// Runs `file.print`, builds the PDF and (where the host provides the hook — the web
/// build) hands the bytes straight to the browser's print flow; errors show the usual
/// alert. Desktop builds have no `services.print`, so this falls back to `run_or_alert`
/// (which still produces the PDF — just without a UI to act on the result yet).
pub fn print_now(&mut self, params: Json) {
match self.run("file.print", params) {
Ok(r) => {
let Some(b64) = r.get("base64").and_then(Json::as_str) else { return };
let Some(bytes) = gridcraft_engine::io::base64_decode(b64) else {
self.message = Some(("GridCraft".into(), "Couldn't decode the generated PDF.".into()));
return;
};
if let Some(print) = &self.services.print {
print(&bytes);
}
}
Err(e) => self.alert(e),
}
}
/// UI-only commands (dialogs, view toggles that aren't saved in the file).
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
let r = match id {
+7 -1
View File
@@ -98,7 +98,6 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
("New Workbook", "file.new"),
("Open…", "file.open"),
("Save As…", "file.saveAs"),
("Print…", "file.print"),
("Sort A to Z", "data.sortAscending"),
("Calculate Now", "formulas.calculateNow"),
] {
@@ -106,6 +105,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
app.run_or_alert(id, json!({}));
}
}
if ui.button("Print…").clicked() {
app.print_now(json!({}));
}
});
// Centered title.
let title = app.session.active().map(|d| d.display_title()).unwrap_or_default();
@@ -1991,6 +1993,10 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
Key::O => Some(("file.open", json!({}))),
Key::N => Some(("file.new", json!({}))),
Key::W => Some(("file.close", json!({}))),
Key::P => {
app.print_now(json!({}));
None
}
Key::K => {
app.open_dialog("insertLink", json!({}));
None