From 11c61c97b128bd82964209a40410218a43def9ae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 10:44:09 -0700 Subject: [PATCH 1/2] Save: a remembered artboard pick follows the artboards it named A save without options reuses the options the document was last saved with in that format. Their artboard, artboards and range name artboards by number, so once artboards were deleted or moved, the save wrote other artboards or was refused: with three artboards, a PDF saved with range "2" wrote the third artboard after the first was deleted, and one saved with range "1, 3" was refused after the third was deleted. The same held for SVG saves, a legacy .ai, and .ai and native files saved with separateArtboards. file.formatOptions and SVG Options showed the same numbers. The document now keeps the ids of the artboards it had when it was saved, and the remembered pick follows them: an artboard deleted since is left out, one that moved is named by its new number, and the rest of the pick is kept. A range open at its end ("2-") is written out as the artboards it named once artboards are added. When none of the artboards the save used is left, the pick is left out, and the save writes the artboards it writes when none is named. A range of "all" stays as it is. An artboard given to the save is still refused when the document does not have it. A new artboard no longer takes the id of an artboard deleted earlier in the same document (Document::retire_artboard_id), or of one an undo took away: the highest id given out is shared with the undo history, so a new artboard is not taken for an earlier one. file.revert keeps the pick only when the file read back has the artboards the document had at the save, with the same ids in the same order. A PDF saved with a range holds only those pages, and one saved without preserveEditing reads back with new ids in page order, so their picks are left out. --- crates/doc/src/lib.rs | 41 ++++++- crates/engine/src/cmd/fileio/mod.rs | 4 +- crates/engine/src/cmd/fileio/save.rs | 103 +++++++++++++++++- crates/engine/src/cmd/layer.rs | 1 + crates/engine/src/cmd/menucmds.rs | 4 +- crates/engine/src/cmd/panelcmds.rs | 1 + crates/engine/src/lib.rs | 16 +++ crates/engine/src/tests_menucmds.rs | 18 ++++ crates/engine/src/tests_saveoptions.rs | 124 ++++++++++++++++++++++ crates/ui-egui/src/dialogs/svg_options.rs | 14 ++- 10 files changed, 317 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1cb8a4f38..2a07cd55a 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -959,6 +959,12 @@ pub struct Document { /// engine sets it from the selection's ([`Selection::new_art`]). Editing state, never saved. #[serde(skip)] pub new_art: NewArtIds, + /// The highest artboard id given out ([`Document::next_artboard_id`]) or deleted from the + /// document ([`Document::retire_artboard_id`]): a new artboard takes a higher one. Shared by + /// every copy of the document, the undo history's included, so an undo does not lower it. + /// Never saved. + #[serde(skip)] + pub retired_artboard_id: RetiredArtboardId, /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this /// is set, so documents without any pay nothing for them. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -968,6 +974,27 @@ pub struct Document { index: id_index::IdIndex, } +/// [`Document::retired_artboard_id`]: not document content, so two documents are equal whatever it +/// holds. A clone shares the value with the document it was cloned from. +#[derive(Clone, Debug, Default)] +pub struct RetiredArtboardId(Arc); + +impl RetiredArtboardId { + pub fn get(&self) -> u32 { + self.0.load(std::sync::atomic::Ordering::Relaxed) + } + /// Raise the value to `id` when `id` is higher. + pub fn raise(&self, id: u32) { + self.0.fetch_max(id, std::sync::atomic::Ordering::Relaxed); + } +} + +impl PartialEq for RetiredArtboardId { + fn eq(&self, _: &Self) -> bool { + true + } +} + fn ppi72() -> f64 { 72.0 } @@ -1052,6 +1079,7 @@ impl Document { puppet, new_art, dimensions, + retired_artboard_id: _, index: _, } = self; *version == other.version @@ -1225,6 +1253,7 @@ impl Document { puppet: None, new_art: Default::default(), dimensions: false, + retired_artboard_id: Default::default(), index: Default::default(), }; let id = d.alloc_id(); @@ -1940,8 +1969,18 @@ impl Document { self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); self.guides.extend(copies); } + /// An id for a new artboard: one no artboard has, above the ids given out before and the ids of + /// the artboards deleted from the document ([`Document::retired_artboard_id`]). The id counts + /// as given out, so an artboard made after an undo does not take the id of one the undo took + /// away. pub fn next_artboard_id(&self) -> u32 { - free_id(self.artboards.iter().map(|a| a.id)) + let id = free_id(self.artboards.iter().map(|a| a.id).chain([self.retired_artboard_id.get()])); + self.retired_artboard_id.raise(id); + id + } + /// Record that the artboard with `id` was deleted: no later artboard takes its id. + pub fn retire_artboard_id(&self, id: u32) { + self.retired_artboard_id.raise(id); } pub fn pattern(&self, name: &str) -> Option<&PatternDef> { self.patterns.iter().find(|p| p.name == name) diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index bea905946..18314cc2e 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -74,8 +74,8 @@ pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan, - save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, remembered_save_options, save_filters, save_format, + save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; pub use screens::{ PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExport, ScreenExportPreset, ScreenSize, diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 170b55f88..bc0171e19 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -14,7 +14,8 @@ use vectorcraft_doc::Document; use super::super::*; use super::{ - ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, + ARTBOARD_PARAMS, ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, + write_encoded, }; use crate::{DocState, Prefs}; @@ -253,7 +254,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { } } // None given: the ones the document was last saved with in this format. - let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given }; + let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; // Save writes the document's own file; the other modes only where they are told to. let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten()); @@ -275,6 +276,99 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { Ok(SavePlan { mode, path, format, options, name, folder, modified }) } +/// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`) +/// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is +/// left out, and one that moved is named by its new number. When none of the artboards the save +/// used is left, the pick is left out, and a save without options writes the artboards it writes +/// when none is named. `range` `"all"` stays as it is. Artboards given to a save are still checked. +pub fn remembered_save_options(st: &DocState) -> Map { + let mut options = st.save_options.clone(); + let ids: Vec = st.doc.artboards.iter().map(|a| a.id).collect(); + // The encoders refuse a document without artboards themselves. + if ids.is_empty() { + return options; + } + // Artboard `i` of the save, where it is now (by number when the save's artboards are unknown). + let then = &st.save_artboards; + let count = if then.is_empty() { ids.len() } else { then.len() }; + let now = |i: usize| match then.get(i) { + Some(id) => ids.iter().position(|x| x == id), + None => (then.is_empty() && i < ids.len()).then_some(i), + }; + // Each pick that names artboards: `Some` of where they are now (empty: all gone). + let mut moved: Vec<(&str, Option>)> = vec![]; + for k in ARTBOARD_PARAMS { + let named = match (k, options.get(k)) { + ("range", Some(Value::String(r))) if r.trim().is_empty() || r.trim().eq_ignore_ascii_case("all") => continue, + ("range", Some(Value::String(r))) => match vectorcraft_doc::range::parse_range(r, count) { + Ok(v) => v, + Err(_) => { + moved.push((k, Some(vec![]))); + continue; + } + }, + ("artboards", Some(v @ Value::Array(_))) => match serde_json::from_value::>(v.clone()) { + Ok(v) => v, + Err(_) => continue, + }, + ("artboard", Some(v)) => match v.as_u64().and_then(|i| usize::try_from(i).ok()) { + Some(i) => vec![i], + None => continue, + }, + _ => continue, + }; + let there: Vec = named.iter().filter_map(|i| now(*i)).collect(); + // A range that reads differently against the artboards there are now (`"2-"` once one + // was added) is written out as the artboards it named. + let reads = match (k, options.get(k)) { + ("range", Some(Value::String(r))) => vectorcraft_doc::range::parse_range(r, ids.len()).ok(), + _ => Some(named), + }; + moved.push((k, (reads.as_ref() != Some(&there)).then_some(there))); + } + // The pick a save uses (as `ArtboardPick::resolve` reads it: `range`, then `artboards`, then + // `artboard`) is gone: so is the rest. + let used = ["range", "artboards", "artboard"].into_iter().find(|k| match options.get(*k) { + Some(Value::String(r)) => !r.trim().is_empty(), + Some(v) => !v.is_null(), + None => false, + }); + let gone = |k: &str| moved.iter().any(|(m, there)| *m == k && there.as_ref().is_some_and(Vec::is_empty)); + if used.is_some_and(gone) { + for k in ARTBOARD_PARAMS { + options.remove(k); + } + return options; + } + for (k, there) in moved { + let Some(there) = there else { continue }; + let value = match (k, there.first()) { + (_, None) => None, + ("range", Some(_)) => Some(json!(range_text(&there))), + ("artboards", Some(_)) => Some(json!(there)), + (_, Some(i)) => Some(json!(i)), + }; + match value { + Some(v) => _ = options.insert(k.into(), v), + None => _ = options.remove(k), + } + } + options +} + +/// 0-based artboard indices as a 1-based range in the same order, runs joined (`"1-2, 5"`). +fn range_text(picked: &[usize]) -> String { + let mut runs: Vec<(usize, usize)> = vec![]; + for &i in picked { + match runs.last_mut() { + Some((_, end)) if *end + 1 == i => *end = i, + _ => runs.push((i, i)), + } + } + let run = |&(a, b): &(usize, usize)| if a == b { format!("{}", a + 1) } else { format!("{}-{}", a + 1, b + 1) }; + runs.iter().map(run).collect::>().join(", ") +} + /// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General /// settings (`createLayers`…) and presets. fn reads_option(f: &Format, key: &str) -> bool { @@ -664,6 +758,7 @@ impl SaveJob { st.path = Some(path); st.format = self.plan.format.id; st.save_options = self.plan.options; + st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect(); st.converted = false; st.recovered = false; if let Some(at) = self.stamped { @@ -756,14 +851,14 @@ fn format_options(s: &mut Session, p: &Value) -> Result { let f = format(id) .filter(|f| f.write || SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id)) .ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?; - let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options); + let saved = st.filter(|d| d.format == f.id).map(remembered_save_options); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); let mut v = f.to_json(); if let Some(options) = v["options"].as_object_mut() { for (name, o) in options.iter_mut() { let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]); - o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(default).clone(); + o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone(); } } v["saveFormats"] = diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 5c31ca009..c90ac89da 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -866,6 +866,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result { // The last first, so the others keep their places; their guides go with them. for &i in indices.iter().rev() { let id = d.artboards.remove(i).id; + d.retire_artboard_id(id); d.retain_guides(sel, |_, g| g.artboard != Some(id)); } Ok(()) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index fe0cb1c52..c9ff3f455 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -969,10 +969,12 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { if b.width() <= 0.0 || b.height() <= 0.0 { continue; } + // Named one past the highest artboard id the document has, whatever id it is given. + let number = d.artboards.iter().map(|a| a.id).max().map_or(1, |m| m.saturating_add(1)); let aid = d.next_artboard_id(); d.artboards.push(vectorcraft_doc::Artboard { id: aid, - name: format!("Artboard {aid}"), + name: format!("Artboard {number}"), rect: b, locked: false, show_center_mark: false, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b99e6d9b0..d48b76492 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -208,6 +208,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result, + /// The ids of the document's artboards, in order, when `save_options` were stored: the + /// artboards their `artboard`, `artboards` and `range` named by number then + /// ([`cmd::fileio::remembered_save_options`]). + pub save_artboards: Vec, /// Opened from an older native file (former name or format version): the title says /// "[Converted]" and Save asks for a new name instead of overwriting it. pub converted: bool, @@ -569,6 +573,7 @@ impl DocState { transparency_grid, format: "vectorcraft", save_options: Default::default(), + save_artboards: vec![], converted: false, view, recovered: false, @@ -1682,6 +1687,17 @@ impl Session { st.revision = old.revision + 1; st.format = old.format; st.save_options = old.save_options.clone(); + // The remembered artboard pick follows the artboards it named when the file read back has + // the artboards the document had at the save, in the same order and with the same ids (a + // native file keeps them; a PDF's pages get new ids in page order); otherwise it is left + // out. + if old.save_artboards.is_empty() || st.doc.artboards.iter().map(|a| a.id).eq(old.save_artboards.iter().copied()) { + st.save_artboards = old.save_artboards.clone(); + } else { + for k in cmd::fileio::ARTBOARD_PARAMS { + st.save_options.remove(k); + } + } st.converted = old.converted; st.imported_from = old.imported_from.clone(); st.import_losses = old.import_losses.clone(); diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 115665ac6..c0fb6de45 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -977,6 +977,24 @@ fn convert_to_artboards_and_rearrange() { assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0)); } +/// Convert to Artboards names an artboard one past the highest artboard id the document has, also +/// after a delete of the last artboard or an undo of a new one. +#[test] +fn convert_to_artboards_names_by_the_artboards_the_document_has() { + let name_after = |change: &dyn Fn(&mut Session)| { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.new", &json!({})).unwrap(); + change(&mut s); + let a = rect(&mut s, 3000.0, 0.0, 200.0, 100.0); + sel(&mut s, &[a]); + s.execute("artboard.convertToArtboards", &json!({})).unwrap(); + s.doc().unwrap().doc.artboards.last().map(|b| b.name.clone()).unwrap() + }; + assert_eq!(name_after(&|s| _ = s.execute("artboard.delete", &json!({"index": 2})).unwrap()), "Artboard 3"); + assert_eq!(name_after(&|s| _ = s.execute("edit.undo", &json!({})).unwrap()), "Artboard 3"); +} + /// Rearrange All Artboards refuses `columns` that are not a whole number (a fraction, a negative number or a string), /// and the artboards stay where they were; 1.0, as the Rearrange All Artboards dialog sends a count, is one column. #[test] diff --git a/crates/engine/src/tests_saveoptions.rs b/crates/engine/src/tests_saveoptions.rs index f0adc4322..3d2179756 100644 --- a/crates/engine/src/tests_saveoptions.rs +++ b/crates/engine/src/tests_saveoptions.rs @@ -188,6 +188,130 @@ fn ai_without_pdf_content_and_compression() { assert!(loose.len() > packed.len(), "{} vs {}", loose.len(), packed.len()); } +#[test] +fn a_remembered_artboard_the_document_no_longer_has_is_dropped() { + let dir = Folder::new("remembered-artboards"); + for (name, p) in [ + ("legacy.ai", json!({"aiVersion": "7", "range": "3"})), + ("separate.ai", json!({"separateArtboards": true, "range": "3"})), + ("separate.vectorcraft", json!({"separateArtboards": true, "range": "1, 3"})), + ("doc.pdf", json!({"range": "3"})), + ("doc.svg", json!({"artboard": 2})), + ] { + let mut s = three_boards(); + let mut first = p.clone(); + first["path"] = json!(dir.file(name)); + s.execute("file.saveAs", &first).unwrap_or_else(|e| panic!("{name}: {e}")); + s.execute("artboard.delete", &json!({"index": 2})).unwrap(); + let v = s.execute("file.formatOptions", &json!({})).unwrap(); + for k in ["artboard", "range"].iter().filter(|k| p.get(**k).is_some()) { + assert_ne!(v["options"][*k]["value"], p[*k], "{name}: Save no longer uses {k}"); + } + s.execute("document.save", &json!({})).unwrap_or_else(|e| panic!("{name}: {e}")); + // Given to the save, it is still refused. + let e = s.execute("document.save", &p).unwrap_err().to_string(); + assert!(e.contains("from 1 to 2") || e.contains("no artboard 2"), "{name}: {e}"); + } + // A remembered artboard that is still there is kept. + let mut s = three_boards(); + s.execute("file.saveAs", &json!({"path": dir.file("kept.ai"), "aiVersion": "7", "range": "2"})).unwrap(); + s.execute("artboard.delete", &json!({"index": 2})).unwrap(); + let v = s.execute("file.formatOptions", &json!({})).unwrap(); + assert_eq!(v["options"]["range"]["value"], json!("2")); + let r = s.execute("document.save", &json!({})).unwrap(); + assert!(r["path"].as_str().is_some_and(|p| p.ends_with("kept.ai")), "{r}"); +} + +#[test] +fn a_remembered_artboard_pick_follows_the_artboards_it_named() { + let dir = Folder::new("remembered-pick"); + // Save each artboard of A, B and C to a file with `range`, change the artboards, then save + // without options: the range Save now uses and the artboards it writes, by file name. + let save = |range: &str, change: &[(&str, Value)]| -> (Value, Vec) { + let mut s = three_boards(); + let path = dir.file("x.ai"); + s.execute("file.saveAs", &json!({"path": path, "separateArtboards": true, "range": range})).unwrap(); + for (cmd, p) in change { + s.execute(cmd, p).unwrap(); + } + let range = s.execute("file.formatOptions", &json!({})).unwrap()["options"]["range"]["value"].clone(); + let r = s.execute("document.save", &json!({})).unwrap(); + let files = r["files"].as_array().unwrap().iter().skip(1).map(|f| f.as_str().unwrap().rsplit('/').next().unwrap().to_string()); + (range, files.collect()) + }; + let delete = |i: usize| ("artboard.delete", json!({"index": i})); + assert_eq!(save("1, 3", &[delete(2)]), (json!("1"), vec!["x-A.ai".to_string()])); + assert_eq!(save("1, 3", &[delete(0)]), (json!("2"), vec!["x-C.ai".to_string()])); + assert_eq!(save("1-3", &[delete(1)]), (json!("1-2"), vec!["x-A.ai".to_string(), "x-C.ai".to_string()])); + let (range, mut files) = save("1, 3", &[("artboard.reorder", json!({"index": 2, "to": 0}))]); + files.sort(); + assert_eq!((range, files), (json!("2, 1"), vec!["x-A.ai".to_string(), "x-C.ai".to_string()])); + // None of them left: every artboard, as when none is named. + assert_eq!(save("1, 3", &[delete(2), delete(0)]), (Value::Null, vec!["x-B.ai".to_string()])); + assert_eq!(save("all", &[delete(0)]), (json!("all"), vec!["x-B.ai".to_string(), "x-C.ai".to_string()])); + // An artboard made after one was deleted is not taken for it. + let new = ("artboard.new", json!({"x": 600, "y": 0, "width": 100, "height": 100, "name": "D"})); + let all = ["x-A.ai", "x-B.ai", "x-D.ai"].map(String::from).to_vec(); + assert_eq!(save("3", &[delete(2), new.clone()]), (Value::Null, all)); + assert_eq!(save("1, 3", &[delete(2), new.clone()]), (json!("1"), vec!["x-A.ai".to_string()])); + // A range open at its end keeps to the artboards it named. + assert_eq!(save("2-", std::slice::from_ref(&new)), (json!("2-3"), vec!["x-B.ai".to_string(), "x-C.ai".to_string()])); + // Nor is one made after undoing the artboard (and the three rectangles made after it). + let undo = ("edit.undo", json!({})); + let undone = [undo.clone(), undo.clone(), undo.clone(), undo, new]; + assert_eq!(save("1, 3", &undone), (json!("1"), vec!["x-A.ai".to_string()])); + + // The other saves that remember a pick: a PDF's range, an SVG's artboard, a legacy .ai's + // range and a native file's separate files. + for (name, p, gone, after) in [ + ("doc.pdf", json!({"range": "1, 3"}), 0, json!({"range": "2"})), + // `range` wins over `artboard`, which a save then does not use. + ("unused.pdf", json!({"artboard": 0, "range": "3"}), 0, json!({"range": "2"})), + ("doc.svg", json!({"artboard": 2}), 0, json!({"artboard": 1})), + ("legacy.ai", json!({"aiVersion": "7", "range": "1, 3"}), 2, json!({"range": "1"})), + ("doc.vectorcraft", json!({"separateArtboards": true, "range": "1, 3"}), 0, json!({"range": "2"})), + ] { + let mut s = three_boards(); + let mut first = p.clone(); + first["path"] = json!(dir.file(name)); + s.execute("file.saveAs", &first).unwrap(); + s.execute("artboard.delete", &json!({"index": gone})).unwrap(); + let v = s.execute("file.formatOptions", &json!({})).unwrap(); + for (k, value) in after.as_object().unwrap() { + assert_eq!(&v["options"][k]["value"], value, "{name}: {k}"); + } + s.execute("document.save", &json!({})).unwrap_or_else(|e| panic!("{name}: {e}")); + if name.ends_with(".pdf") { + let bytes = std::fs::read(dir.file(name)).unwrap(); + let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(info["pages"], 1, "only the artboard that was 3"); + } + } + + // File > Revert: a PDF holds only the pages it was saved with, so its pick is left out; a + // native file keeps every artboard, and the pick stays. + let mut s = three_boards(); + s.execute("file.saveAs", &json!({"path": dir.file("revert.pdf"), "range": "1, 3"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 30, "height": 30})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 2); + assert_eq!(s.execute("file.formatOptions", &json!({})).unwrap()["options"]["range"]["value"], Value::Null); + // A PDF without the editable document reads back with new ids in page order: after a reorder, + // the pick is left out, not taken for another reorder. + let mut s = three_boards(); + s.execute("artboard.reorder", &json!({"index": 2, "to": 0})).unwrap(); + s.execute("file.saveAs", &json!({"path": dir.file("reordered.pdf"), "range": "1-3", "preserveEditing": false})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 30, "height": 30})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect::>(), [1, 2, 3]); + assert_eq!(s.execute("file.formatOptions", &json!({})).unwrap()["options"]["range"]["value"], Value::Null); + let mut s = three_boards(); + s.execute("file.saveAs", &json!({"path": dir.file("revert.vectorcraft"), "separateArtboards": true, "range": "1, 3"})).unwrap(); + s.execute("artboard.delete", &json!({"index": 0})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(s.execute("file.formatOptions", &json!({})).unwrap()["options"]["range"]["value"], json!("1, 3")); +} + #[test] fn format_options_list_the_save_options() { let mut s = three_boards(); diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 0fd6998b0..611d1f8ff 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -92,7 +92,7 @@ pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) { .session .active() .filter(|st| mode != Mode::Export && matches!(st.format, "svg" | "svgz")) - .map(|st| Value::Object(st.save_options.clone())); + .map(|st| Value::Object(vectorcraft_engine::cmd::fileio::remembered_save_options(st))); for last in [&app.ui.svg_options].into_iter().chain(saved.as_ref()) { if let Some(o) = last.as_object() { fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); @@ -416,6 +416,18 @@ mod tests { assert!(vectorcraft_format::sniff(&written.borrow()[3].1)); } + #[test] + fn save_as_svg_leaves_out_a_remembered_artboard_the_document_no_longer_has() { + let (mut app, written) = app("/tmp/doc.svg"); + app.run("file.saveAs", json!({"path": "/tmp/doc.svg", "svg": {"artboard": 1}})).unwrap(); + assert_eq!(app.session.active().unwrap().save_options["artboard"], 1); + app.run("artboard.delete", json!({"index": 1})).unwrap(); + app.run("file.saveAs", Value::Null).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().fields.get("artboard").is_none()); + super::super::confirm(&mut app).unwrap(); + assert_eq!((written.borrow().len(), written.borrow()[1].0.as_str()), (2, "/tmp/doc.svg")); + } + #[test] fn profile_encoding_and_embedded_fonts_reach_the_file() { let (mut app, written) = app("/tmp/tiny.svg"); From 7f8bc837bdaa2342be9b5906252278d62a258fc7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 17:29:32 +0700 Subject: [PATCH 2/2] Save options test: take the file name after either path separator (Windows) --- crates/engine/src/tests_saveoptions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tests_saveoptions.rs b/crates/engine/src/tests_saveoptions.rs index 3d2179756..9c5bb66a9 100644 --- a/crates/engine/src/tests_saveoptions.rs +++ b/crates/engine/src/tests_saveoptions.rs @@ -236,7 +236,7 @@ fn a_remembered_artboard_pick_follows_the_artboards_it_named() { } let range = s.execute("file.formatOptions", &json!({})).unwrap()["options"]["range"]["value"].clone(); let r = s.execute("document.save", &json!({})).unwrap(); - let files = r["files"].as_array().unwrap().iter().skip(1).map(|f| f.as_str().unwrap().rsplit('/').next().unwrap().to_string()); + let files = r["files"].as_array().unwrap().iter().skip(1).map(|f| f.as_str().unwrap().rsplit(['/', '\\']).next().unwrap().to_string()); (range, files.collect()) }; let delete = |i: usize| ("artboard.delete", json!({"index": i}));