Merge pull request #394 from vangel76/fix/print-save-as-pdf

File: desktop Print opens the system PDF viewer, Save As offers PDF/CSV/HTML (#370, #343)
This commit is contained in:
Brandon Thomas 2026-10-11 16:01:33 -04:00
commit 1ea549bfe4
15 changed files with 249 additions and 48 deletions

View File

@ -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) |

View File

@ -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<std::path::PathBuf> {
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<egui::IconData> {
#[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);
}
}

View File

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

View File

@ -9,8 +9,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<Json> {
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)?;

View File

@ -1494,7 +1494,7 @@ fn build_pdf(s: &Session, p: &Json, cmd: &str) -> Result<(Vec<u8>, usize)> {
Ok(render_pdf(&d.wb, &lays, &d.display_title(), &path))
}
fn export_pdf(s: &mut Session, p: &Json) -> Result<Json> {
pub(crate) fn export_pdf(s: &mut Session, p: &Json) -> Result<Json> {
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();

View File

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

View File

@ -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 ページに印刷"),

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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" => "한 페이지에 시트 맞추기",

View File

@ -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",

View File

@ -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" => "Вписать лист на одну страницу",

View File

@ -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" => "将工作表调整为一页",

View File

@ -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::<String>::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);
}