From 746a1e036420525e4c2838f8109b20eee7fc84cb Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Mon, 12 Oct 2026 11:03:13 -0400 Subject: [PATCH] File: Export as PDF in the File menu, and the palette asks for a path (#407) --- apps/gridcraft/src/native_menu.rs | 10 +++++++ crates/engine/src/cmd/print.rs | 17 ++++++++++++ crates/ui-egui/src/lib.rs | 28 +++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 3 ++ crates/ui-egui/tests/file_menu.rs | 46 +++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index b127543..fca3cdd 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -24,6 +24,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Close", "file.close", Some("CMD+W")), ("Save", "file.save", Some("CMD+S")), ("Save As…", "file.saveAs", Some("CMD+SHIFT+S")), + ("Export as PDF…", "file.exportPdf", None), ("Export as CSV…", "dialog:saveCopy:csv", None), ("Save as Web Page…", "dialog:saveCopy:html", None), ("-", "", None), @@ -315,6 +316,15 @@ impl NativeMenu { mod tests { use super::*; + #[test] + fn file_menu_has_export_as_pdf_dispatching_the_command() { + let tree = tree(); + let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu"); + let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu"); + assert_eq!(item.1, "file.exportPdf"); + assert!(gridcraft_engine::find_command("file.exportPdf").is_some()); + } + #[test] fn theme_choices_use_the_shared_preference_command() { let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default()); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 986351e..2aeffbc 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -2046,6 +2046,23 @@ mod tests { assert!(s.execute("file.exportPdf", json!({})).is_ok()); } + #[test] + fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() { + let mut s = s(); + for params in [json!({}), json!({"path": ""}), json!({"path": null})] { + let r = s.execute("file.exportPdf", params).unwrap(); + assert!(r.get("path").is_none(), "no file is written without a path"); + let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + } + assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog"); + // Headless callers (CLI, MCP) get the same bytes, not an error. + assert!(s.execute_headless("file.exportPdf", json!({})).is_ok()); + // With no workbook open it is a clear error, not a panic. + let mut none = Session::new(); + assert!(none.execute("file.exportPdf", json!({})).is_err()); + } + #[test] fn bands_unit() { let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec29139..d80c32a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,11 +408,39 @@ impl SheetApp { if id == "file.print" { return self.print_now(params); } + // Menu-style Export as PDF with no file named: ask where to save, like Save As does. + // Programmatic callers use `run` and keep getting the PDF bytes back. + if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { + return self.export_pdf_with_picker(params); + } if let Err(e) = self.run(id, params) { self.alert(e); } } + /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other + /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. + fn export_pdf_with_picker(&mut self, mut params: Json) { + if !self.commit_edit(0, 0, false, false) { + return; + } + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); + let Some(pick) = &self.services.pick_save else { return }; + let Some(path) = pick(&format!("{stem}.pdf")) else { return }; + // The picker may suggest a workbook extension; the export writes a PDF. + let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); + match &mut params { + Json::Object(map) => { + map.insert("path".into(), Json::String(path)); + } + other => *other = json!({"path": path}), + } + if let Err(e) = self.run("file.exportPdf", params) { + self.alert(e); + } + } + /// Shows a command error: a toast for an unavailable command, else a message box. fn alert(&mut self, e: String) { if e.contains("is not available right now") { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291ac..c4c3a2a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -131,6 +131,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); egui::Popup::menu(&share).show(|ui| { + if ui.button("Export as PDF…").clicked() { + app.run_or_alert("file.exportPdf", json!({})); + } for (label, fmt) in [("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")] { diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index c6ef2b2..691eef3 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -171,3 +171,49 @@ fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() { assert!(d.path.is_none() && d.is_dirty(), "a PDF is a copy, not the workbook's file"); let _ = std::fs::remove_dir_all(&dir); } + +fn pdf_picker_harness(dir: &std::path::Path) -> (Arc>>, egui_kittest::Harness<'static, SheetApp>) { + let asked = Arc::new(Mutex::new(Vec::::new())); + let (capture, target) = (asked.clone(), dir.to_path_buf()); + let h = harness(Services { + pick_save: Some(Box::new(move |suggested| { + capture.lock().unwrap().push(suggested.to_string()); + Some(target.join("Report.xlsx").to_string_lossy().into_owned()) + })), + ..Default::default() + }); + (asked, h) +} + +#[test] +fn the_palette_export_as_pdf_asks_where_to_save() { + let dir = std::env::temp_dir().join(format!("gridcraft-export-pdf-palette-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (asked, mut h) = pdf_picker_harness(&dir); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + // The palette and menus go through run_or_alert. + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({})); + assert!(asked.lock().unwrap().first().is_some_and(|s| s.ends_with(".pdf")), "the picker is offered a .pdf name"); + assert!(std::fs::read(dir.join("Report.pdf")).unwrap().starts_with(b"%PDF-"), "the PDF is written, whatever extension the picker returned"); + assert!(h.state().message.is_none()); + let d = h.state().session.active().unwrap(); + assert!(d.path.is_none(), "a PDF is a copy, not the workbook's file"); + // Other params survive the round trip through the picker. + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"range": "nope"})); + assert!(h.state().message.is_some(), "a bad range is reported, not swallowed"); + // A path in the params skips the picker. + let before = asked.lock().unwrap().len(); + let named = dir.join("Named.pdf"); + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"path": named.to_string_lossy()})); + assert_eq!(asked.lock().unwrap().len(), before); + assert!(named.exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_cancelled_export_pdf_picker_writes_nothing() { + let mut h = harness(Services { pick_save: Some(Box::new(|_| None)), ..Default::default() }); + h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({})); + assert!(h.state().message.is_none()); + assert!(h.state_mut().session.take_ui_requests().is_empty()); +}