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:
commit
1ea549bfe4
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ページに印刷"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" => "한 페이지에 시트 맞추기",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" => "Вписать лист на одну страницу",
|
||||
|
|
|
|||
|
|
@ -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" => "将工作表调整为一页",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue