From ad48f1e3e17303440178d167f0d251ac6cf944ff Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 21:11:03 +0200 Subject: [PATCH 1/2] File: desktop Print opens the system PDF viewer, Save As offers PDF/CSV/HTML (#370, #343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #209, printing from the UI hands the print PDF to Services::print, which only the web build set: on the desktop, Ctrl+P, More › Print, the macOS menu and the Backstage Print button produced the PDF and dropped it. The desktop app now writes it to the temporary folder and opens it in the system PDF viewer, whose Print reaches the printer dialog. Save As has a Save as type chooser (xlsx, csv, html, pdf); file.save and file.saveAs with a .pdf path write a PDF of the sheet (they wrote XLSX bytes into the .pdf and made it the workbook's file). The desktop save picker honours the suggested type instead of always proposing .xlsx. The macOS Properties item opens File › Info instead of running file.properties with no fields. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + apps/gridcraft/src/main.rs | 75 ++++++++++++++++++++++++------- apps/gridcraft/src/native_menu.rs | 3 +- crates/engine/src/cmd/file.rs | 9 +++- crates/engine/src/cmd/print.rs | 20 ++++++++- crates/ui-egui/src/backstage.rs | 47 ++++++++++++++----- crates/ui-egui/src/i18n.rs | 12 ++++- crates/ui-egui/src/i18n/cs.rs | 11 ++++- crates/ui-egui/src/i18n/de.rs | 11 ++++- crates/ui-egui/src/i18n/es.rs | 11 ++++- crates/ui-egui/src/i18n/ko.rs | 11 ++++- crates/ui-egui/src/i18n/pt.rs | 11 ++++- crates/ui-egui/src/i18n/ru.rs | 11 ++++- crates/ui-egui/src/i18n/zh.rs | 9 +++- crates/ui-egui/tests/file_menu.rs | 25 +++++++++++ 15 files changed, 219 insertions(+), 48 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 03830fb..a824032 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Desktop printing opens the print PDF in the system viewer; Save As offers CSV, web page and PDF; macOS File › Properties opens File › Info (#370, #343) | | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index b7e15cf..3445cfd 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -166,29 +166,54 @@ fn services() -> Services { .and_then(|p| p.to_str().map(str::to_string)) })), pick_save: Some(Box::new(|suggested: &str| { - let stem = std::path::Path::new(suggested).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); - rfd::FileDialog::new() - .set_file_name(format!("{stem}.xlsx")) - .add_filter("Excel Workbook (.xlsx)", &["xlsx"]) - .add_filter("CSV UTF-8 (.csv)", &["csv"]) - .add_filter("Web Page (.html)", &["html"]) - .save_file() - .and_then(|p| p.to_str().map(str::to_string)) + let path = std::path::Path::new(suggested); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); + // The suggested name's type (an export asks for .pdf, .csv or .html) comes first. + const TYPES: [(&str, &str); 4] = + [("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")]; + let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e)); + let ext = ext.unwrap_or_else(|| "xlsx".to_string()); + let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}")); + for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) { + dialog = dialog.add_filter(*label, &[*x]); + } + dialog.save_file().and_then(|p| p.to_str().map(str::to_string)) })), - open_url: Some(Box::new(|url: &str| { - let cmd = if cfg!(target_os = "macos") { - "open" - } else if cfg!(windows) { - "explorer" - } else { - "xdg-open" - }; - let _ = std::process::Command::new(cmd).arg(url).spawn(); + open_url: Some(Box::new(|url: &str| open_with_system(url))), + // File › Print, Ctrl+P: the print PDF opens in the system's PDF viewer, whose Print + // command reaches the printer dialog (GridCraft has no printer driver of its own). + print: Some(Box::new(|bytes: &[u8]| match write_print_pdf(&std::env::temp_dir(), bytes) { + Ok(path) => open_with_system(&path.to_string_lossy()), + Err(e) => log::warn!("print: {e}"), })), ..Default::default() } } +/// Opens a URL or file with the system's default application. +fn open_with_system(target: &str) { + let cmd = if cfg!(target_os = "macos") { + "open" + } else if cfg!(windows) { + "explorer" + } else { + "xdg-open" + }; + if let Err(e) = std::process::Command::new(cmd).arg(target).spawn() { + log::warn!("{cmd} {target}: {e}"); + } +} + +/// Writes a print PDF into `dir` under a fresh name (an earlier print may still be open in the +/// viewer) and returns its path. +fn write_print_pdf(dir: &std::path::Path, bytes: &[u8]) -> std::io::Result { + static PRINTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1); + let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id())); + std::fs::write(&path, bytes)?; + Ok(path) +} + fn icon() -> Option { #[cfg(target_os = "macos")] let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); @@ -351,3 +376,19 @@ fn main() -> eframe::Result<()> { } result } + +#[cfg(test)] +mod print_tests { + #[test] + fn print_pdfs_get_fresh_names_in_the_given_folder() { + let dir = std::env::temp_dir().join(format!("gridcraft-print-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = super::write_print_pdf(&dir, b"%PDF-1.4 a").unwrap(); + let b = super::write_print_pdf(&dir, b"%PDF-1.4 b").unwrap(); + assert_ne!(a, b); + assert!(a.starts_with(&dir) && a.extension().is_some_and(|e| e == "pdf")); + assert_eq!(std::fs::read(&b).unwrap(), b"%PDF-1.4 b"); + assert!(super::write_print_pdf(&dir.join("missing"), b"x").is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 621b27a..afa3268 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -30,7 +30,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Page Setup…", "dialog:pageSetup", None), ("Print…", "file.print", Some("CMD+P")), ("-", "", None), - ("Properties…", "file.properties", None), + ("Properties…", "dialog:backstage:info", None), ], ), ( @@ -289,6 +289,7 @@ impl NativeMenu { ("note", _) => json!({"threaded": false}), ("comment", _) => json!({"threaded": true}), ("about", tab) => json!({"tab": tab}), + ("backstage", page) => json!({"page": page}), _ => json!({}), }; let name = if name == "note" { "comment" } else { name }; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a938e98..d7765d2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -9,8 +9,8 @@ pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook), cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open), - cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save), - cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as), + cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save), + cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", has_doc, close), cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")), @@ -94,6 +94,11 @@ fn save(s: &mut Session, p: &Json) -> Result { return ok(); } }; + // A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the + // document keeps its file and its unsaved changes. + if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { + return super::print::export_pdf(s, &json!({"path": path})); + } let d = s.doc_mut()?; let bytes = crate::io::save_bytes(&d.wb, &path)?; crate::io::write_file(&path, &bytes)?; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index aead379..3d49b3d 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1494,7 +1494,7 @@ fn build_pdf(s: &Session, p: &Json, cmd: &str) -> Result<(Vec, usize)> { Ok(render_pdf(&d.wb, &lays, &d.display_title(), &path)) } -fn export_pdf(s: &mut Session, p: &Json) -> Result { +pub(crate) fn export_pdf(s: &mut Session, p: &Json) -> Result { let (bytes, pages) = build_pdf(s, p, "file.exportPdf")?; match str_param(p, "path").filter(|x| !x.is_empty()) { Some(path) => { @@ -1881,6 +1881,24 @@ mod tests { assert!(r["base64"].as_str().unwrap().len() > 100); } + #[test] + fn save_as_pdf_writes_a_pdf_and_keeps_the_workbook_file() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap(); + let dir = std::env::temp_dir().join(format!("gridcraft-save-pdf-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Out.PDF"); + let r = s.execute("file.saveAs", json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(r["pages"], 1); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert!(contains(&bytes, "(hello) Tj")); + // The PDF is a print, not the workbook's file: Save still asks where to put the workbook. + let d = s.doc().unwrap(); + assert!(d.path.is_none() && d.is_dirty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn rich_sheet_renders() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 32000f7..ad457b5 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -107,11 +107,18 @@ pub struct Backstage { preview_page: usize, /// Print: the preview for (document, revision, whole workbook, fit to page). preview: Option<((u64, u64, bool, bool), Json)>, + /// Save As: the chosen file type (an extension from [`SAVE_TYPES`]). + pub save_format: &'static str, } +/// Save As file types: (extension, label). The first is the workbook itself; the others are +/// exports that leave the workbook's own file and unsaved state alone. +pub const SAVE_TYPES: [(&str, &str); 4] = + [("xlsx", "Excel Workbook (*.xlsx)"), ("csv", "CSV UTF-8 (Comma delimited) (*.csv)"), ("html", "Web Page (*.html)"), ("pdf", "PDF (*.pdf)")]; + impl Backstage { pub fn new(page: Page) -> Backstage { - Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None } + Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None, save_format: "xlsx" } } } @@ -184,7 +191,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { Page::New => new_page(app, ui, &mut out), Page::Open => open_page(app, ui, &mut out), Page::Info => info(app, ui, &mut bs), - Page::SaveAs => save_as(app, ui, &mut out), + Page::SaveAs => save_as(app, ui, &mut bs, &mut out), Page::Print => print(app, ui, &mut bs, &mut out), Page::Export => export(app, ui, &mut out), Page::Options => options(app, ui), @@ -493,22 +500,40 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { }); } -fn save_as(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { +fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { let lang = app.ui.language; dim(ui, lang.tr("Save the workbook under another name or in another folder.")); ui.add_space(14.0); - // Desktop: the system Save dialog. Web: the workbook downloads as .xlsx. - let label = if app.services.pick_save.is_some() || app.services.download.is_none() { "Browse…" } else { "Download (.xlsx)" }; - if square_button(ui, Icon::OpenFolder, lang.tr(label)).clicked() && app.commit_edit(0, 0, false, false) { - let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); - app.open_dialog("saveAs", json!({})); - let after = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); - if after != before || app.services.pick_save.is_none() { + let fmt = bs.save_format; + let current = SAVE_TYPES.iter().find(|(ext, _)| *ext == fmt).map_or("Excel Workbook (*.xlsx)", |(_, label)| *label); + ui.horizontal(|ui| { + ui.label(lang.tr("Save as type")); + egui::ComboBox::from_id_salt("backstage_save_type").width(280.0).selected_text(lang.tr(current)).show_ui(ui, |ui| { + for (ext, label) in SAVE_TYPES { + if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() { + bs.save_format = ext; + } + } + }); + }); + ui.add_space(14.0); + // Desktop: the system Save dialog. Web: the file downloads. + let web = app.services.pick_save.is_none() && app.services.download.is_some(); + let label = if web { format!("{} (.{fmt})", lang.tr("Download")) } else { lang.tr("Browse…").to_string() }; + if square_button(ui, Icon::OpenFolder, &label).clicked() && app.commit_edit(0, 0, false, false) { + if fmt == "xlsx" { + let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + app.open_dialog("saveAs", json!({})); + let after = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + if after != before || app.services.pick_save.is_none() { + out.close = true; + } + } else if export_as(app, fmt) { out.close = true; } } ui.add_space(18.0); - dim(ui, lang.tr("PDF, CSV and web pages are on the Export page.")); + dim(ui, lang.tr("A CSV file, web page or PDF is a copy: the workbook keeps its own file.")); } fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9b2831c..bb37a5b 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -522,9 +522,17 @@ const JAPANESE: &[(&str, &str)] = &[ ("Keywords", "キーワード"), ("Description", "説明"), ("No workbook is open.", "ブックが開かれていません。"), + ("Save as type", "ファイルの種類"), + ("Excel Workbook (*.xlsx)", "Excel ブック (*.xlsx)"), + ("CSV UTF-8 (Comma delimited) (*.csv)", "CSV UTF-8 (コンマ区切り) (*.csv)"), + ("Web Page (*.html)", "Web ページ (*.html)"), + ("PDF (*.pdf)", "PDF ファイル (*.pdf)"), + ("Download", "ダウンロード"), + ( + "A CSV file, web page or PDF is a copy: the workbook keeps its own file.", + "CSV ファイル、Web ページ、PDF はコピーです。ブックは元のファイルのままです。", + ), ("Save the workbook under another name or in another folder.", "ブックを別の名前または別のフォルダーに保存します。"), - ("Download (.xlsx)", "ダウンロード (.xlsx)"), - ("PDF, CSV and web pages are on the Export page.", "PDF、CSV、Web ページは [エクスポート] ページにあります。"), ("Print Active Sheets", "作業中のシートを印刷"), ("Print Entire Workbook", "ブック全体を印刷"), ("Fit Sheet on One Page", "シートを 1 ページに印刷"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 65e9def..3129829 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -271,9 +271,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Keywords" => "Klíčová slova", "Description" => "Popis", "No workbook is open." => "Není otevřený žádný sešit.", + "Save as type" => "Typ souboru", + "Excel Workbook (*.xlsx)" => "Sešit Excelu (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (oddělený čárkami) (*.csv)", + "Web Page (*.html)" => "Webová stránka (*.html)", + "PDF (*.pdf)" => "Soubor PDF (*.pdf)", + "Download" => "Stáhnout", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "Soubor CSV, webová stránka nebo PDF je kopie: sešit si ponechá svůj vlastní soubor." + } "Save the workbook under another name or in another folder." => "Uloží sešit pod jiným názvem nebo do jiné složky.", - "Download (.xlsx)" => "Stáhnout (.xlsx)", - "PDF, CSV and web pages are on the Export page." => "PDF, CSV a webové stránky najdete na stránce Exportovat.", "Print Active Sheets" => "Vytisknout aktivní listy", "Print Entire Workbook" => "Vytisknout celý sešit", "Fit Sheet on One Page" => "Přizpůsobit list na jednu stránku", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 5cb9881..40de211 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -269,11 +269,18 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Keywords" => "Stichwörter", "Description" => "Beschreibung", "No workbook is open." => "Es ist keine Arbeitsmappe geöffnet.", + "Save as type" => "Dateityp", + "Excel Workbook (*.xlsx)" => "Excel-Arbeitsmappe (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (durch Trennzeichen getrennt) (*.csv)", + "Web Page (*.html)" => "Webseite (*.html)", + "PDF (*.pdf)" => "PDF-Datei (*.pdf)", + "Download" => "Herunterladen", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "Eine CSV-Datei, Webseite oder PDF ist eine Kopie: Die Arbeitsmappe behält ihre eigene Datei." + } "Save the workbook under another name or in another folder." => { "Speichert die Arbeitsmappe unter einem anderen Namen oder in einem anderen Ordner." } - "Download (.xlsx)" => "Herunterladen (.xlsx)", - "PDF, CSV and web pages are on the Export page." => "PDF, CSV und Webseiten finden Sie auf der Seite „Exportieren“.", "Print Active Sheets" => "Aktive Blätter drucken", "Print Entire Workbook" => "Gesamte Arbeitsmappe drucken", "Fit Sheet on One Page" => "Blatt auf einer Seite darstellen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index b30a1d1..b64f0fa 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -270,9 +270,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Keywords" => "Palabras clave", "Description" => "Descripción", "No workbook is open." => "No hay ningún libro abierto.", + "Save as type" => "Tipo", + "Excel Workbook (*.xlsx)" => "Libro de Excel (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (delimitado por comas) (*.csv)", + "Web Page (*.html)" => "Página web (*.html)", + "PDF (*.pdf)" => "Archivo PDF (*.pdf)", + "Download" => "Descargar", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "Un archivo CSV, una página web o un PDF son una copia: el libro conserva su propio archivo." + } "Save the workbook under another name or in another folder." => "Guarda el libro con otro nombre o en otra carpeta.", - "Download (.xlsx)" => "Descargar (.xlsx)", - "PDF, CSV and web pages are on the Export page." => "PDF, CSV y páginas web están en la página Exportar.", "Print Active Sheets" => "Imprimir hojas activas", "Print Entire Workbook" => "Imprimir todo el libro", "Fit Sheet on One Page" => "Ajustar hoja en una página", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index fb33cbd..dad3a7e 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -357,9 +357,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Keywords" => "키워드", "Description" => "설명", "No workbook is open." => "열려 있는 통합 문서가 없습니다.", + "Save as type" => "파일 형식", + "Excel Workbook (*.xlsx)" => "Excel 통합 문서 (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8(쉼표로 분리) (*.csv)", + "Web Page (*.html)" => "웹 페이지 (*.html)", + "PDF (*.pdf)" => "PDF 파일 (*.pdf)", + "Download" => "다운로드", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "CSV 파일, 웹 페이지 또는 PDF는 사본이며 통합 문서는 원래 파일을 유지합니다." + } "Save the workbook under another name or in another folder." => "통합 문서를 다른 이름 또는 다른 폴더에 저장합니다.", - "Download (.xlsx)" => "다운로드(.xlsx)", - "PDF, CSV and web pages are on the Export page." => "PDF, CSV 및 웹 페이지는 내보내기 페이지에 있습니다.", "Print Active Sheets" => "활성 시트 인쇄", "Print Entire Workbook" => "전체 통합 문서 인쇄", "Fit Sheet on One Page" => "한 페이지에 시트 맞추기", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 35f1336..8407b28 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -358,9 +358,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Keywords" => "Palavras-chave", "Description" => "Descrição", "No workbook is open." => "Nenhuma pasta de trabalho está aberta.", + "Save as type" => "Tipo", + "Excel Workbook (*.xlsx)" => "Pasta de Trabalho do Excel (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (Delimitado por vírgulas) (*.csv)", + "Web Page (*.html)" => "Página da Web (*.html)", + "PDF (*.pdf)" => "Arquivo PDF (*.pdf)", + "Download" => "Baixar", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "Um arquivo CSV, página da Web ou PDF é uma cópia: a pasta de trabalho mantém o próprio arquivo." + } "Save the workbook under another name or in another folder." => "Salve a pasta de trabalho com outro nome ou em outra pasta.", - "Download (.xlsx)" => "Baixar (.xlsx)", - "PDF, CSV and web pages are on the Export page." => "PDF, CSV e páginas da Web estão na página Exportar.", "Print Active Sheets" => "Imprimir planilhas ativas", "Print Entire Workbook" => "Imprimir toda a pasta de trabalho", "Fit Sheet on One Page" => "Ajustar planilha em uma página", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e50e5ff..69710a4 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -357,9 +357,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Keywords" => "Ключевые слова", "Description" => "Описание", "No workbook is open." => "Нет открытых книг.", + "Save as type" => "Тип файла", + "Excel Workbook (*.xlsx)" => "Книга Excel (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8 (разделитель — запятая) (*.csv)", + "Web Page (*.html)" => "Веб-страница (*.html)", + "PDF (*.pdf)" => "Файл PDF (*.pdf)", + "Download" => "Скачать", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => { + "CSV-файл, веб-страница или PDF — это копия: книга остаётся в своём файле." + } "Save the workbook under another name or in another folder." => "Сохранить книгу под другим именем или в другой папке.", - "Download (.xlsx)" => "Скачать (.xlsx)", - "PDF, CSV and web pages are on the Export page." => "PDF, CSV и веб-страницы — на странице «Экспорт».", "Print Active Sheets" => "Напечатать активные листы", "Print Entire Workbook" => "Напечатать всю книгу", "Fit Sheet on One Page" => "Вписать лист на одну страницу", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index db71a16..098635b 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -351,9 +351,14 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Keywords" => "关键词", "Description" => "说明", "No workbook is open." => "没有打开的工作簿。", + "Save as type" => "保存类型", + "Excel Workbook (*.xlsx)" => "Excel 工作簿 (*.xlsx)", + "CSV UTF-8 (Comma delimited) (*.csv)" => "CSV UTF-8(逗号分隔) (*.csv)", + "Web Page (*.html)" => "网页 (*.html)", + "PDF (*.pdf)" => "PDF 文件 (*.pdf)", + "Download" => "下载", + "A CSV file, web page or PDF is a copy: the workbook keeps its own file." => "CSV 文件、网页或 PDF 只是副本:工作簿仍保留其原文件。", "Save the workbook under another name or in another folder." => "以其他名称或在其他文件夹中保存工作簿。", - "Download (.xlsx)" => "下载 (.xlsx)", - "PDF, CSV and web pages are on the Export page." => "PDF、CSV 和网页位于“导出”页。", "Print Active Sheets" => "打印活动工作表", "Print Entire Workbook" => "打印整个工作簿", "Fit Sheet on One Page" => "将工作表调整为一页", diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index d47bf5a..c6ef2b2 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -146,3 +146,28 @@ fn export_as_pdf_is_delivered_as_a_download() { assert!(got[0].0.ends_with(".pdf")); assert!(got[0].1.starts_with(b"%PDF-")); } + +#[test] +fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() { + let dir = std::env::temp_dir().join(format!("gridcraft-save-as-type-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let asked = Arc::new(Mutex::new(Vec::::new())); + let (capture, target) = (asked.clone(), dir.clone()); + let mut h = harness(Services { + pick_save: Some(Box::new(move |suggested| { + capture.lock().unwrap().push(suggested.to_string()); + Some(target.join("Report").to_string_lossy().into_owned()) + })), + ..Default::default() + }); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + choose(&mut h, "Save As"); + h.state_mut().backstage.as_mut().unwrap().save_format = "pdf"; + h.run_steps(2); + click(&mut h, "Browse…"); + 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-")); + let d = h.state().session.active().unwrap(); + 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); +} From 5c26c963f821627977d51c0e62a59a9ddb24d0b2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 16:01:30 -0400 Subject: [PATCH 2/2] Print PDF in the shared temp folder: create it exclusively (never follow or overwrite an existing file or symlink) and owner-only on Unix (review fix-up for #394) Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/main.rs | 40 +++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 3445cfd..25624cf 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -205,13 +205,29 @@ fn open_with_system(target: &str) { } /// Writes a print PDF into `dir` under a fresh name (an earlier print may still be open in the -/// viewer) and returns its path. +/// viewer) and returns its path. The temp folder is shared, so the file is created exclusively +/// (an existing file or a planted symlink is never followed or overwritten) and, on Unix, readable +/// only by the user. fn write_print_pdf(dir: &std::path::Path, bytes: &[u8]) -> std::io::Result { + use std::io::Write; static PRINTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1); - let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id())); - std::fs::write(&path, bytes)?; - Ok(path) + for _ in 0..64 { + let n = PRINTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1); + let path = dir.join(format!("GridCraft print {}-{n}.pdf", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + match options.open(&path) { + Ok(mut file) => { + file.write_all(bytes)?; + return Ok(path); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free print file name")) } fn icon() -> Option { @@ -389,6 +405,20 @@ mod print_tests { assert!(a.starts_with(&dir) && a.extension().is_some_and(|e| e == "pdf")); assert_eq!(std::fs::read(&b).unwrap(), b"%PDF-1.4 b"); assert!(super::write_print_pdf(&dir.join("missing"), b"x").is_err()); + // A file (or symlink) already at the next name is skipped, never overwritten. + let pid = std::process::id(); + let taken: Vec<_> = (1..=200).map(|n| dir.join(format!("GridCraft print {pid}-{n}.pdf"))).filter(|p| !p.exists()).take(3).collect(); + for p in &taken { + std::fs::write(p, b"keep").unwrap(); + } + let c = super::write_print_pdf(&dir, b"%PDF-1.4 c").unwrap(); + assert!(taken.iter().all(|p| std::fs::read(p).unwrap() == b"keep")); + assert_eq!(std::fs::read(&c).unwrap(), b"%PDF-1.4 c"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&c).unwrap().permissions().mode() & 0o077, 0, "only the user can read it"); + } let _ = std::fs::remove_dir_all(&dir); } }