Merge pull request #704 from isaac-levine:file-export-pdf-menu

This commit is contained in:
Brandon Thomas 2026-10-12 23:48:55 -04:00
commit 2f38d0162f
5 changed files with 104 additions and 0 deletions

View File

@ -24,6 +24,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("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),
@ -317,6 +318,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());

View File

@ -2054,6 +2054,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);

View File

@ -496,11 +496,39 @@ impl SheetApp {
}
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") {

View File

@ -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")]
{

View File

@ -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<Mutex<Vec<String>>>, egui_kittest::Harness<'static, SheetApp>) {
let asked = Arc::new(Mutex::new(Vec::<String>::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());
}