Merge pull request #157 from storytold/fix/issue-37

Fix #37: a name typed without an extension gets the chosen format's on Export too
This commit is contained in:
BFlat 2026-10-06 00:18:54 +07:00 committed by GitHub
commit cccdf96cc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 92 additions and 6 deletions

View File

@ -134,12 +134,25 @@ 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.
/// 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> {
if is_web(app) {
return Ok(pick.name.clone());
}
app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into())
let picked = app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or("cancelled")?;
Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true))
}
/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format
/// `keeps` takes, else with `.ext` added.
fn with_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String {
let has = fileio::extension(path);
if ext.is_empty() || has.eq_ignore_ascii_case(ext) || fileio::format_for_name(path).is_some_and(keeps) {
path.to_string()
} else {
format!("{path}.{ext}")
}
}
/// The active document's file name with extension `ext`, and its folder.
@ -172,10 +185,7 @@ pub fn remember_view(app: &mut VectorcraftApp) {
/// A path typed in a save panel: kept when its extension names a save format (the panel's file
/// type), else `f`'s extension is added.
fn with_save_extension(path: &str, f: &Format) -> String {
match fileio::format_for_name(path) {
Some(g) if SAVE_FORMATS.contains(&g.id) => path.to_string(),
_ => format!("{path}.{}", f.extensions[0]),
}
with_extension(path, f.extensions[0], |g| SAVE_FORMATS.contains(&g.id))
}
/// Write every file of an export (one per artboard, linked images) for the destination `path`.

View File

@ -74,6 +74,8 @@ mod tests_recovery;
#[cfg(test)]
mod tests_save;
#[cfg(test)]
mod tests_saveext;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_svg;

View File

@ -0,0 +1,74 @@
//! A name typed in a save panel without an extension gets the chosen format's: Save, Save As,
//! Save a Copy, Export, Export Selection and other writes through a save panel.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use crate::{FilePick, Services, VectorcraftApp, io};
type Written = Rc<RefCell<Vec<String>>>;
/// An app with a document whose save panel answers `typed` and whose writer records the paths.
fn saving_app(typed: &'static str) -> (VectorcraftApp, Written) {
let written = Written::default();
let w = written.clone();
let services = Services {
write: Some(Box::new(move |p: &str, _: &[u8]| {
w.borrow_mut().push(p.to_string());
Ok(())
})),
pick_save: Some(Box::new(move |_: &FilePick| Some(typed.to_string()))),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
(app, written)
}
fn last(w: &Written) -> String {
w.borrow().last().cloned().unwrap_or_default()
}
#[test]
fn saves_add_the_format_extension_to_a_bare_name() {
let (mut app, w) = saving_app("/tmp/Untitled-1");
app.run("file.save", json!({})).unwrap();
assert_eq!(last(&w), "/tmp/Untitled-1.vectorcraft");
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/Untitled-1.vectorcraft"));
let (mut app, w) = saving_app("/tmp/copy");
app.run("file.saveCopy", json!({"format": "svg", "svg": {}})).unwrap();
assert_eq!(last(&w), "/tmp/copy.svg");
let (mut app, w) = saving_app("/tmp/as");
app.run("file.saveAs", json!({"format": "pdf", "options": {}})).unwrap();
assert_eq!(last(&w), "/tmp/as.pdf");
}
#[test]
fn exports_add_the_format_extension_to_a_bare_name() {
let (mut app, w) = saving_app("/tmp/art");
io::export(&mut app, Some("png"), None, &json!({})).unwrap();
assert_eq!(last(&w), "/tmp/art.png");
app.run("select.all", json!({})).unwrap();
app.run("document.exportSelection", json!({})).unwrap();
assert_eq!(last(&w), "/tmp/art.png", "Export Selection: its default format");
io::save_command_output(&mut app, "document.export", "txt", json!({"format": "txt"})).unwrap();
assert_eq!(last(&w), "/tmp/art.txt");
}
#[test]
fn typed_extensions_are_kept() {
let (mut app, w) = saving_app("/tmp/art.svg");
io::export(&mut app, Some("svg"), None, &json!({})).unwrap();
assert_eq!(last(&w), "/tmp/art.svg");
let (mut app, w) = saving_app("/tmp/My.Poster.SVG");
io::export(&mut app, Some("svg"), None, &json!({})).unwrap();
assert_eq!(last(&w), "/tmp/My.Poster.SVG");
// A dot in the name isn't an extension.
let (mut app, w) = saving_app("/tmp/v1.2");
io::export(&mut app, Some("png"), None, &json!({})).unwrap();
assert_eq!(last(&w), "/tmp/v1.2.png");
}