mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3608 from dotCooCoo/fix/export-as-uses-the-picked-format-extension
Export: a typed extension of a format the export doesn't write is replaced
This commit is contained in:
@@ -140,7 +140,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into());
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
let path = io::target_path(app, None, f.extensions[0])?;
|
||||
let path = io::export_path(app, None, f)?;
|
||||
let range = if all { "all".to_string() } else { range };
|
||||
let r = options_or_write(app, f, plugin, path, use_artboards, all, range);
|
||||
// The options follow the save panel: the Enter that closed it isn't theirs (as in Save As).
|
||||
|
||||
+40
-11
@@ -337,7 +337,7 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name:
|
||||
None => {
|
||||
let (_, folder) = suggested(app, "");
|
||||
let filters = file_types(&fileio::extension(name));
|
||||
pick_path(app, &FilePick { name: name.to_string(), folder, filters })?
|
||||
pick_path(app, &FilePick { name: name.to_string(), folder, filters }, |_| false)?
|
||||
}
|
||||
};
|
||||
write_reporting(app, &path, bytes)?;
|
||||
@@ -345,11 +345,24 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name:
|
||||
}
|
||||
|
||||
/// Where a file goes when no path was given: the suggested name on the web (a download), else the
|
||||
/// save panel's choice, given the suggested name's extension when it names no file type (a name
|
||||
/// typed without one).
|
||||
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
|
||||
/// save panel's choice with the suggested name's extension, [`with_written_extension`]: a name
|
||||
/// typed with the extension of a format `keeps` takes stays as typed too.
|
||||
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick, keeps: impl Fn(&Format) -> bool) -> Result<String, String> {
|
||||
let ext = fileio::extension(&pick.name);
|
||||
pick_finished(app, pick, |picked, _| with_extension(picked, &ext, |_| true))
|
||||
pick_finished(app, pick, |picked, _| with_written_extension(picked, &ext, &keeps))
|
||||
}
|
||||
|
||||
/// `path` as typed in a save panel for a file with extension `ext`: kept when its extension is
|
||||
/// `ext` in any letter case, or names the format `ext` names (`photo.jpeg` for a `.jpg` file) or
|
||||
/// a format `keeps` takes. An extension that names another format (`card.svg` for a `.png` file)
|
||||
/// is replaced with `ext`, and `.ext` is added to any other path (`v1.2` becomes `v1.2.png`).
|
||||
fn with_written_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String {
|
||||
let written = fileio::format(ext);
|
||||
let keeps = |g: &Format| written.is_some_and(|w| w.id == g.id) || keeps(g);
|
||||
if !ext.is_empty() && !fileio::extension(path).eq_ignore_ascii_case(ext) && fileio::format_for_name(path).is_some_and(|g| !keeps(g)) {
|
||||
return std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned();
|
||||
}
|
||||
with_extension(path, ext, keeps)
|
||||
}
|
||||
|
||||
/// The suggested name made into a path by `finish` on the web (a download), else the save panel's
|
||||
@@ -416,14 +429,26 @@ fn file_types(ext: &str) -> Vec<crate::FileFilter> {
|
||||
}
|
||||
|
||||
/// `path`, else one picked in a save panel (the web: the suggested name) for the active document
|
||||
/// with extension `ext`, filtered to its file type when it has one ([`file_types`]).
|
||||
/// with extension `ext`, filtered to its file type when it has one ([`file_types`]). A name typed
|
||||
/// with another format's extension gets `ext` instead ([`with_written_extension`]).
|
||||
pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Result<String, String> {
|
||||
target_path_keeping(app, path, ext, |_| false)
|
||||
}
|
||||
|
||||
/// [`target_path`] for an export in format `f` (Export As's OK and [`export`]): a name typed with
|
||||
/// `.svgz` for an SVG stays as typed, and [`export`] writes it compressed.
|
||||
pub(crate) fn export_path(app: &mut VectorcraftApp, path: Option<String>, f: &Format) -> Result<String, String> {
|
||||
target_path_keeping(app, path, f.extensions[0], |g| f.id == "svg" && g.id == "svgz")
|
||||
}
|
||||
|
||||
/// [`target_path`], keeping a typed extension of a format `keeps` takes.
|
||||
fn target_path_keeping(app: &mut VectorcraftApp, path: Option<String>, ext: &str, keeps: impl Fn(&Format) -> bool) -> Result<String, String> {
|
||||
if let Some(p) = path {
|
||||
return Ok(p);
|
||||
}
|
||||
let (name, folder) = suggested(app, ext);
|
||||
let filters = file_types(ext);
|
||||
pick_path(app, &FilePick { name, folder, filters })
|
||||
pick_path(app, &FilePick { name, folder, filters }, keeps)
|
||||
}
|
||||
|
||||
/// Keep the active document's saved view current before a save (native files reopen at it).
|
||||
@@ -758,7 +783,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
|
||||
std::borrow::Cow::Owned(d) => std::sync::Arc::new(d),
|
||||
};
|
||||
let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new);
|
||||
let path = target_path(app, path, f.extensions[0])?;
|
||||
let path = export_path(app, path, f)?;
|
||||
let mut again = params.clone();
|
||||
if let Some(o) = again.as_object_mut() {
|
||||
o.insert("format".into(), json!(f.id));
|
||||
@@ -856,14 +881,18 @@ fn take_output(v: &mut Value) -> Result<Vec<u8>, String> {
|
||||
/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to
|
||||
/// that path (else a picked or suggested name) → (path, the command's result without the data).
|
||||
/// The command runs before a path is asked for, so bad params never open a save dialog, except
|
||||
/// for Export Selection without a `format`, which takes it from the picked path.
|
||||
/// for Export Selection without a `format`, which takes it from the picked path. Export Selection
|
||||
/// with a `format` picks a file with that format's extension in place of `ext`.
|
||||
pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> {
|
||||
let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string));
|
||||
let selection = id == "document.exportSelection";
|
||||
let given = params.get("format").and_then(Value::as_str).and_then(fileio::format).filter(|_| selection);
|
||||
let ext = given.map_or(ext, |f| f.extensions[0]);
|
||||
if let Some(o) = params.as_object_mut()
|
||||
&& o.get("format").is_none()
|
||||
&& id == "document.exportSelection"
|
||||
&& selection
|
||||
{
|
||||
let picked = target_path(app, path, ext)?;
|
||||
let picked = target_path_keeping(app, path, ext, |_| true)?;
|
||||
let e = Some(fileio::extension(&picked)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into());
|
||||
o.insert("format".into(), serde_json::json!(e));
|
||||
path = Some(picked);
|
||||
|
||||
@@ -396,3 +396,64 @@ fn typed_extensions_are_kept() {
|
||||
io::export(&mut app, Some("png"), None, &json!({})).unwrap();
|
||||
assert_eq!(last(&w), "/tmp/v1.2.png");
|
||||
}
|
||||
|
||||
/// A name typed in an export's save panel with the extension of another format than the one the
|
||||
/// export writes gets the written format's extension: Export As PNG with `card.svg` typed goes
|
||||
/// on to PNG Options with `card.png`, and each export in the table below does the same.
|
||||
#[test]
|
||||
fn an_export_replaces_a_typed_extension_of_another_format() {
|
||||
let (mut app, _) = saving_app("/tmp/card.svg");
|
||||
app.run("file.exportAs", json!({"format": "png"})).unwrap();
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
let d = app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.str("path")));
|
||||
assert_eq!(d, Some(("pngOptions".to_string(), "/tmp/card.png".to_string())), "Export As");
|
||||
for (typed, expected, what) in [
|
||||
("/tmp/card.svg", "/tmp/card.png", "export"),
|
||||
("/tmp/card.PNG", "/tmp/card.pdf", "Save as PDF"),
|
||||
("/tmp/card.ai", "/tmp/card.pdf", "Save as PDF"),
|
||||
("/tmp/card.svg", "/tmp/card.png", "document.exportForOffice"),
|
||||
("/tmp/sel.png", "/tmp/sel.svg", "document.exportSelection"),
|
||||
("/tmp/sel.svgz", "/tmp/sel.svg", "document.exportSelection"),
|
||||
("/tmp/sel", "/tmp/sel.svg", "document.exportSelection"),
|
||||
("/tmp/art.svg", "/tmp/art.txt", "Text Export Options"),
|
||||
("/tmp/Brand.svg", "/tmp/Brand.vclibrary", "Export Library…"),
|
||||
] {
|
||||
let (mut app, w) = saving_app(typed);
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
match what {
|
||||
"export" => io::export(&mut app, Some("png"), None, &json!({})).map(|_| ()),
|
||||
"Save as PDF" => io::export_pdf(&mut app, json!({})).map(|_| ()),
|
||||
"document.exportForOffice" => app.run(what, json!({"ppi": 150})).map(|_| ()),
|
||||
"document.exportSelection" => app.run(what, json!({"format": "svg"})).map(|_| ()),
|
||||
"Text Export Options" => io::save_command_output(&mut app, "document.export", "txt", json!({"format": "txt"})).map(|_| ()),
|
||||
_ => {
|
||||
let id = app.run("library.create", json!({"name": "Brand"})).unwrap()["id"].as_str().unwrap().to_string();
|
||||
io::save_command_output_named(&mut app, "library.export", "Brand.vclibrary", json!({"library": id})).map(|_| ())
|
||||
}
|
||||
}
|
||||
.unwrap();
|
||||
assert_eq!(last(&w), expected, "{what} with {typed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name typed with an extension of the format the export writes stays as typed, in any letter
|
||||
/// case (`photo.JPEG` for a JPEG), and `art.svgz` for an SVG export is written compressed. Export
|
||||
/// Selection without a `format` writes the format the typed extension names.
|
||||
#[test]
|
||||
fn an_export_keeps_a_typed_extension_of_the_format_it_writes() {
|
||||
let (mut app, w) = saving_app("/tmp/photo.JPEG");
|
||||
io::export(&mut app, Some("jpg"), None, &json!({})).unwrap();
|
||||
assert_eq!(last(&w), "/tmp/photo.JPEG");
|
||||
let (mut app, _) = saving_app("/tmp/art.svgz");
|
||||
app.run("file.exportAs", json!({"format": "svg"})).unwrap();
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("path")).as_deref(), Some("/tmp/art.svgz"), "Export As SVG");
|
||||
let (mut app, dir) = on_disk("svgz", "art.svgz");
|
||||
io::export(&mut app, Some("svg"), None, &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join("art.svgz")).unwrap()[..2], [0x1f, 0x8b], "written compressed");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (mut app, w) = saving_app("/tmp/sel.pdf");
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("document.exportSelection", json!({})).unwrap();
|
||||
assert_eq!(last(&w), "/tmp/sel.pdf", "Export Selection takes its format from the typed name");
|
||||
}
|
||||
|
||||
@@ -817,6 +817,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
|
||||
A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing).
|
||||
A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`).
|
||||
Export As, Save as PDF, Save for Office Documents, Save for Web, Text Export Options, print to file, the presets' Export… and the library, swatch and Document Info saves replace a typed extension of another format than the one they write in the same way: Export As PNG writes `card.svg` as `card.png`, Save as PDF writes `card.png` as `card.pdf`, and `document.exportSelection` with a `format` suggests and adds that format's extension. An extension of the written format stays as typed in any letter case (`photo.JPEG` for JPEG), Export As SVG writes `art.svgz` compressed, and `document.exportSelection` without a `format` writes the format the typed extension names (`tests_saveext.rs`).
|
||||
- macOS: the save sheet of Save As, Save a Copy, Save as Template and the first Save of a new document has a Format menu that lists each format the save can write, with the suggested file's format selected. Choosing a format changes the extension in the name field, and a name typed without an extension gets the chosen format's. A name typed with another listed extension is saved in that format. The menu is set up before the sheet opens; a save panel with one file type is shown by rfd as before (#866, `apps/vectorcraft/src/mac_save_format.rs`).
|
||||
- Linux and the BSDs: the save dialog of Save As, Save a Copy, Save as Template and the first Save of a new document is asked of the desktop portal, starting on the suggested file's type, and the file type picked in it decides the format. A name typed without an extension, or with the suggested file's in any letter case, gets the picked type's extension; a name typed with another save format's extension (for Save as Template, a template format's) is saved in that format, another format's extension is replaced with the picked type's, and any other extension gets the picked type's added. rfd shows the dialog when the portal can't (#2679, `apps/vectorcraft/src/portal_save.rs`).
|
||||
- Save as Template writes a PDF-compatible `.ait` as well as a `.vctemplate`, which stays the default: the save panel and the web build's Save as Template dialog list both, and New from Template opens the `.ait` as an untitled document with its layers and art (`file.saveAsTemplate {format: "ait"}`, or a path ending in `.ait`).
|
||||
|
||||
Reference in New Issue
Block a user