diff --git a/ROADMAP.md b/ROADMAP.md index 58082d0..57aa8ba 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 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) | | 2026-10-11 | Merged cells draw their whole outline: right and bottom edges come from the last column's and last row's cells (#376) | | 2026-10-11 | Shrink to fit draws too-wide text and numbers in a smaller font inside the cell or merged area (#375) | diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index b7e15cf..25624cf 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -166,29 +166,70 @@ 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. 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); + 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 { #[cfg(target_os = "macos")] let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); @@ -351,3 +392,33 @@ 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()); + // 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); + } +} 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 0f18bcf..824b4da 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), @@ -507,22 +514,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); +}