From d45ebe465bfc074350c49337956e7aa6088e8ab4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 09:48:16 -0800 Subject: [PATCH 1/3] .ai save: the editing copy names the active artboard The editing copy of a .ai or .ait file that VectorCraft saves or exports now has `%_ /Int (CropAreaActive) ,` after the ArtboardArray in its recorded part, and marks that artboard as the selected one. The app records its view's active artboard before each command (DocState::active_artboard; an index past the last artboard is the last, as in the Artboards panel); without a view, the artboard the document opened on (Document::opened_artboard) is written, else the first. A .ai file read from its editing copy opens on that artboard. Before, the copy named no active artboard and always marked the first as selected. Docs: docs/mcp.md, docs/file-format-parity.md, the editingData option text. --- crates/doc/src/lib.rs | 4 +- crates/engine/src/cmd/fileio/export.rs | 6 +- crates/engine/src/cmd/fileio/native.rs | 2 +- crates/engine/src/cmd/fileio/save.rs | 16 +++++- .../engine/src/cmd/fileio/tests_aiimport.rs | 55 +++++++++++++++++++ crates/engine/src/doc_state.rs | 6 ++ crates/eps/src/ai_editing.rs | 11 ++-- crates/eps/src/ai_editing/tests.rs | 29 +++++++++- crates/ui-egui/src/app/commands.rs | 3 + crates/ui-egui/src/io.rs | 45 ++++++++++++++- crates/ui-egui/src/io/save.rs | 15 ++++- docs/file-format-parity.md | 4 +- docs/mcp.md | 5 ++ 13 files changed, 185 insertions(+), 16 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 988df4c51..6d069872d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -326,8 +326,8 @@ pub struct Document { #[serde(skip)] pub retired_artboard_id: RetiredArtboardId, /// The id of the artboard an imported file names as its active one (a `.ai` file's - /// `CropAreaActive`): the document opens with that artboard active. `None`: the first. Never - /// saved. + /// `CropAreaActive`): the document opens with that artboard active. A `.ai` save or export + /// names it as the file's active artboard. `None`: the first. Native files do not keep it. #[serde(skip)] pub opened_artboard: Option, /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 852f2c735..77adaeac9 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -190,7 +190,11 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C return Ok((Cow::Owned(doc), display_doc_title)); } let st = s.doc()?; - let doc = export_source(st, p)?; + let mut doc = export_source(st, p)?; + // A `.ai` export names the view's active artboard, as a `.ai` save does. + if let Some(id) = super::save::moved_active_artboard(st, &doc, f) { + doc.to_mut().opened_artboard = Some(id); + } let display_doc_title = super::has_own_title(&doc); Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title)) } diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 4d293fe69..770c11644 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "editingData", ty: "boolean", default: "true", - description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", + description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", }, FormatOption { name: "aiVersion", diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index b594c51b8..55fcbaa08 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -447,22 +447,34 @@ fn is_svg(f: &Format) -> bool { } /// The document as written: native files carry the view to reopen at and the Layers panel's open -/// rows and transparency grid visibility. +/// rows and transparency grid visibility; a `.ai` file names the view's active artboard +/// ([`DocState::active_artboard`]) as its active one. fn doc_to_save(st: &DocState, f: &Format) -> Arc { if !is_native(f) { return st.doc.clone(); } let open = st.layers_open.saved(&st.doc); - if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid { + let active = moved_active_artboard(st, &st.doc, f); + if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid && active.is_none() { return st.doc.clone(); } let mut d = (*st.doc).clone(); + if active.is_some() { + d.opened_artboard = active; + } d.last_view = st.view.clone(); d.transparency_grid = st.transparency_grid; d.layers_open = open; Arc::new(d) } +/// The artboard a `.ai` or `.ait` file written from `st` (`doc`, the document it writes) names as +/// its active one, when that is not already the one `doc` opened on: the view's active artboard +/// ([`DocState::active_artboard`]), if `doc` has it. +pub(super) fn moved_active_artboard(st: &DocState, doc: &Document, f: &Format) -> Option { + st.active_artboard.filter(|id| matches!(f.id, "ai" | "ait") && doc.artboards.iter().any(|a| a.id == *id) && doc.opened_artboard != Some(*id)) +} + /// What a format loses against a native file (reported whenever a save writes it). fn fidelity_warning(f: &Format) -> Option { (!is_native(f)).then(|| { diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 0aa1733d8..e9e24e5a1 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -148,6 +148,61 @@ fn an_ai_file_opens_with_its_active_artboard() { assert_eq!(d.opened_artboard, Some(d.artboards[1].id), "{r}"); } +/// A `.ai` save names the view's active artboard in its editing copy, else the one the document +/// opened on, else the first; the file read from that copy opens on it. +#[test] +fn an_ai_save_names_its_active_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let ids: Vec = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect(); + let opts = LoadOptions { native: false, ..Default::default() }; + let active = |s: &mut Session, format: &str| { + let cmd = if format == "ait" { "file.saveAsTemplate" } else { "file.saveCopy" }; + let saved = s.execute(cmd, &json!({"format": format})).unwrap(); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + let l = load::load_with(&format!("a.{format}"), &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + assert_eq!(l.doc.artboards.len(), 3); + l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0) + }; + assert_eq!(active(&mut s, "ai"), 0); + // The view's. + s.doc_mut().unwrap().active_artboard = Some(ids[2]); + assert_eq!((active(&mut s, "ai"), active(&mut s, "ait")), (2, 2)); + // The one the document opened on, when the view names none or an artboard it doesn't have. + Arc::make_mut(&mut s.doc_mut().unwrap().doc).opened_artboard = Some(ids[1]); + for view in [None, Some(99)] { + s.doc_mut().unwrap().active_artboard = view; + assert_eq!(active(&mut s, "ai"), 1, "{view:?}"); + } + // The view's first artboard over the one the document opened on. + s.doc_mut().unwrap().active_artboard = Some(ids[0]); + assert_eq!(active(&mut s, "ai"), 0); + assert_eq!(s.doc().unwrap().doc.opened_artboard, Some(ids[1]), "the document is unchanged"); +} + +/// A `.ai` export (`document.serialize`, `document.export`, with or without `selectedOnly`) names +/// the view's active artboard in its editing copy, as a save does. +#[test] +fn an_ai_export_names_its_active_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let ids: Vec = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect(); + s.doc_mut().unwrap().active_artboard = Some(ids[2]); + let opts = LoadOptions { native: false, ..Default::default() }; + for (cmd, selected) in [("document.serialize", false), ("document.serialize", true), ("document.export", false)] { + let out = s.execute(cmd, &json!({"format": "ai", "selectedOnly": selected})).unwrap(); + let bytes = vectorcraft_format::base64_decode(out["dataBase64"].as_str().unwrap()).unwrap(); + let l = load::load_with("a.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{cmd}: {:?}", l.warnings); + let active = l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)); + assert_eq!(active, Some(2), "{cmd} {selected}"); + } + assert_eq!(s.doc().unwrap().doc.opened_artboard, None, "the document is unchanged"); +} + #[test] fn an_ai_unread_group_keeps_its_layers_and_live_neighbor() { let live = format!("0 0 1 0 k\n{}f", ai::rect(10.0, 10.0, 14.0, 14.0)); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 3d1c4be89..5c3a9130d 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -76,6 +76,11 @@ pub struct DocState { /// The view saved into native files (`Document::last_view`); the UI keeps it current before a /// save and restores it when the document opens. pub view: Option, + /// The id of the view's active artboard, which the UI keeps current before each command it + /// runs: a `.ai` save or export names that artboard as the file's active one. `None`, or an + /// artboard the document no longer has: the artboard the document opened on + /// ([`vectorcraft_doc::Document::opened_artboard`]), else the first. + pub active_artboard: Option, /// Restored by Data Recovery: the title says "[Recovered]" and Save asks where to save it /// (suggesting `path`, the file it was copied from) instead of overwriting that file. pub recovered: bool, @@ -266,6 +271,7 @@ impl DocState { save_artboards: vec![], converted: false, view, + active_artboard: None, recovered: false, recovery: None, print_tiling: false, diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 26293842c..b6ea72eea 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -431,10 +431,12 @@ fn ruler_units(u: Unit) -> u8 { /// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`), its top-left corner on /// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio /// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the -/// first) and its name; then the canvas's size. Every record holds all of these entries. The -/// artboards and the canvas size are written in the `/Recorded` part of the `/Document` -/// dictionary, followed by an empty `/NotRecorded` part. +/// active one) and its name; then the index of the active artboard (`CropAreaActive`: the one +/// [`Document::opened_artboard`] names, else the first) and the canvas's size. Every record holds +/// all of these entries. The artboards, the active artboard and the canvas size are written in the +/// `/Recorded` part of the `/Document` dictionary, followed by an empty `/NotRecorded` part. fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { + let active = doc.opened_artboard.and_then(|id| doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0); s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); for (i, ab) in doc.artboards.iter().enumerate() { let r = to_art.transform_rect_bbox(ab.rect); @@ -449,10 +451,11 @@ fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) ps::push_num(s, if par.is_finite() && par > 0.0 { par } else { 1.0 }); // Display marks: 1 the center mark, 2 cross hairs, 4 video safe areas. let mark = u8::from(ab.show_center_mark) | u8::from(ab.show_cross_hairs) << 1 | u8::from(ab.show_video_safe_areas) << 2; - let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0)); + let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == active)); let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes())); } s.push_str("%_; (ArtboardArray) ,\n"); + let _ = writeln!(s, "%_{active} /Int (CropAreaActive) ,"); let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); } diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 529c54a5f..c3262ac98 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -367,9 +367,9 @@ fn add_rect(doc: &mut Document, r: Rect) { doc.insert(Some(layer), usize::MAX, rect).unwrap(); } -/// The end of the document data: the artboards and the canvas size in the `/Recorded` part of the -/// `/Document` dictionary, then an empty `/NotRecorded` part. -const RECORDED: &str = "%_; (ArtboardArray) ,\n%_16383 /Int (AIDocumentCanvasSize) ,\n%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"; +/// The end of the document data: the artboards, the active artboard (the first) and the canvas +/// size in the `/Recorded` part of the `/Document` dictionary, then an empty `/NotRecorded` part. +const RECORDED: &str = "%_; (ArtboardArray) ,\n%_0 /Int (CropAreaActive) ,\n%_16383 /Int (AIDocumentCanvasSize) ,\n%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"; /// One letter-size artboard: the art 36 points in from its top-left corner, the artboard's record /// and the boxes, with the artboards in the `/Recorded` part of the `/Document` dictionary. @@ -984,6 +984,29 @@ fn artboard_display_marks_are_written() { assert!(ab.show_center_mark && !ab.show_cross_hairs && ab.show_video_safe_areas); } +/// The active artboard, the one `opened_artboard` names (else the first), is the selected one and +/// its index follows the artboards in the recorded part; the editing data reads back with that +/// artboard active. +#[test] +fn the_active_artboard_follows_the_artboards() { + let mut doc = Document::new(200.0, 100.0); + for (id, x0) in [(2, 250.0), (3, 500.0)] { + doc.artboards.push(Artboard { id, rect: Rect::new(x0, 0.0, x0 + 200.0, 100.0), ..doc.artboards[0].clone() }); + } + let first = doc.artboards[0].id; + // None, the first, the third, and an artboard the document doesn't have (the first). + for (opened, index) in [(None, 0), (Some(first), 0), (Some(3), 2), (Some(9), 0)] { + doc.opened_artboard = opened; + let p = program(&doc); + let entry = format!("%_; (ArtboardArray) ,\n%_{index} /Int (CropAreaActive) ,\n%_16383 /Int (AIDocumentCanvasSize) ,\n%_; /Recorded ,\n"); + assert!(p.contains(&entry), "{opened:?}: {p}"); + assert_eq!(p.matches("%_1 /Bool (IsArtboardSelected) ,\n").count(), 1, "{opened:?}"); + let back = read_back(&doc); + let active = back.opened_artboard.and_then(|id| back.artboards.iter().position(|a| a.id == id)).unwrap_or(0); + assert_eq!((back.artboards.len(), active), (3, index), "{opened:?}"); + } +} + /// A guide is its path followed by `(n) *` (closed) or `(N) *` (open), with no `(N) *` before /// it, and reads back as the same guide. #[test] diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 0652e6d9a..a21411587 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -173,6 +173,9 @@ impl VectorcraftApp { return r; } } + // A `.ai` file a command writes (a save, an export, a batch's save) names the view's + // active artboard. + io::remember_active_artboard(self); // Native files carry the view they reopen at. if fileio::SaveMode::of(id).is_some() { io::remember_view(self); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 4818a0c15..d394d8e6c 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -28,7 +28,7 @@ pub use open::{ }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; -pub use save::{ask_format_options, ask_pdf_options, remember_view, save, save_pdf}; +pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf}; pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; #[cfg(test)] pub(crate) use save::{report_write_error, saved_status}; @@ -164,6 +164,49 @@ mod tests { assert!(!app.session.doc().unwrap().is_dirty()); } + /// A `.ai` file saved from the app names the view's active artboard as its active one, in + /// the editing copy other apps read. + #[test] + fn a_saved_ai_file_names_the_views_active_artboard() { + let (mut app, written) = app(); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.sync_views(); + let opts = fileio::LoadOptions { native: false, ..Default::default() }; + for artboard in [2, 0] { + if let Some(v) = app.view_mut() { + v.artboard = artboard; + } + save(&mut app, SaveMode::Copy, &json!({"path": "Art.ai"}), false).unwrap(); + let bytes = written.borrow_mut().pop().map(|(_, b)| b).unwrap(); + let l = fileio::load_with("Art.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + let active = l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0); + assert_eq!(active, artboard); + } + } + + /// A `.ai` file a command exports names the view's active artboard as its active one: an + /// index past the last artboard names the last, as the Artboards panel shows it. + #[test] + fn an_exported_ai_file_names_the_views_active_artboard() { + let (mut app, _) = app(); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.sync_views(); + let opts = fileio::LoadOptions { native: false, ..Default::default() }; + for (artboard, active) in [(1, 1), (99, 2)] { + if let Some(v) = app.view_mut() { + v.artboard = artboard; + } + let out = app.run("document.serialize", json!({"format": "ai"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(out["dataBase64"].as_str().unwrap()).unwrap(); + let l = fileio::load_with("Art.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + assert_eq!(l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)), Some(active), "{artboard}"); + } + } + fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec { let v = app.session.execute(cmd, &p).unwrap(); vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 3392e7e43..3d81aff54 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -171,14 +171,27 @@ pub(super) fn target_path_keeping( pick_path(app, &FilePick { name, folder, filters }, keeps) } -/// Keep the active document's saved view current before a save (native files reopen at it). +/// Keep the active document's saved view current before a save (native files reopen at it), and +/// its active artboard ([`remember_active_artboard`]). pub fn remember_view(app: &mut VectorcraftApp) { + remember_active_artboard(app); let Some(v) = app.view().copied().filter(|v| v.fitted) else { return }; if let Some(st) = app.session.active_mut() { st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation }); } } +/// Keep the active document's active artboard current (a `.ai` file saved or exported names it +/// as its active one): the view's, an index past the last artboard being the last one as in the +/// Artboards panel; `None` without a view. +pub fn remember_active_artboard(app: &mut VectorcraftApp) { + let index = app.view().map(|v| v.artboard); + if let Some(st) = app.session.active_mut() { + let last = st.doc.artboards.len().saturating_sub(1); + st.active_artboard = index.and_then(|i| st.doc.artboards.get(i.min(last))).map(|a| a.id); + } +} + /// A path typed in a save panel: kept when its extension names one of the panel's file types (a /// save format, or a template format for Save as Template, `mode`). An extension that names /// another format (`card.png`, or `card.ait` in Save As) is replaced with `f`'s, and `f`'s is diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 971dfa016..22c13bdbb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -62,7 +62,9 @@ private editing data. VectorCraft: Editing Data for Other Apps) every page's `PieceInfo` also carries an editing copy of the art in `AIPrivateData` streams, in the form VectorCraft's `.ai` reader reads: layers and sublayers, groups, paths with their fills and strokes (a compound path as one path of all its subpaths), process, spot and global colors, swatches and color groups, guides, - hidden and locked objects, object names, every artboard with its name, the transparency of objects and layers + hidden and locked objects, object names, every artboard with its name, the active artboard (`CropAreaActive`: the + view's active artboard when the app saves or exports the file, else the one the document opened on, else the first; + the reader opens the file on it), the transparency of objects and layers (opacity, blending mode, isolation, knockout and knockout shape), objects' opacity masks, and clipping groups and clipping layers clipped by a path or compound path, linear and radial gradients on fills and strokes (with their stops' opacity, midpoints and spot colors, elliptical radials and focal points) and gradient swatches, and diff --git a/docs/mcp.md b/docs/mcp.md index ee359ccdf..0b418bbc7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4684,6 +4684,11 @@ or in `options` (`file.formatOptions {format}` lists them with their values): - `pdfCompatible`: native files (default false) also carry a PDF of every artboard (`pdf` in the file); `.ai` files (default true) with false write blank pages around the native document (smaller; other apps show empty pages). - `.ai` only: `compress` (default true) is the PDF option `compression.compressText`. +- The editing copy of a `.ai` or `.ait` file (`editingData`) names its active artboard (`CropAreaActive`): the active + artboard of the app's view when the app saves or exports the file, otherwise the artboard the document opened on (the + active artboard of the `.ai` file it was opened from), else the first. A `.ai` file read from its editing copy + opens on that artboard. VectorCraft opens a `.ai` file it saved from the native document the file carries, on the + first artboard. File → Save As asks for these in the save options dialog (VectorCraft Options) after the save panel, and Save a Copy does too for a `.ai` file; Save, Save a Copy to a native file and Save as Template reuse the options as last saved (or Use Compression). From 5216632e6b02ad34fe2c0ea15e467cef91f2a050 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 13:37:09 -0800 Subject: [PATCH 2/3] .ai save: exports to a path and timestamped copies name the active artboard A .ai file that File > Export As, its dialog or a control request writes to a path now names the view's active artboard, as document.export and document.serialize do. The UI export records the view's artboard and takes its document from fileio::export_doc, which applies DocState::active_artboard to the export's copy; document.export and document.serialize use the same function. The app now records the view's active artboard before every command, including the commands the UI handles itself, so a .ai copy from Save a Timestamped Copy or Export All Open Documents names it too. Before, these files named the artboard the document opened on, or the first. The open document is unchanged. --- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/export.rs | 17 +++++++++---- crates/ui-egui/src/addons/document/tests.rs | 27 ++++++++++++++++++++- crates/ui-egui/src/app/commands.rs | 6 ++--- crates/ui-egui/src/io.rs | 27 +++++++++++++++++++++ crates/ui-egui/src/io/export.rs | 8 +++--- 6 files changed, 74 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 7c828221b..0c888d13c 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -57,9 +57,9 @@ use serde_json::{Value, json}; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; -pub use export::export_source; pub(crate) use export::with_artboard_fills; pub(crate) use export::{export_crop, isolated}; +pub use export::{export_doc, export_source}; pub(crate) use load::check_not_lossy_overwrite; use load::err; pub(crate) use load::import_svg; diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 77adaeac9..13564614b 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -72,6 +72,17 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) } +/// The document an export of `st` in format `f` writes ([`export_source`]): a `.ai` or `.ait` +/// file names the view's active artboard ([`DocState::active_artboard`]) as its active one, as a +/// save does. The open document is unchanged. +pub fn export_doc<'a>(st: &'a DocState, f: &Format, p: &Value) -> Result> { + let mut doc = export_source(st, p)?; + if let Some(id) = super::save::moved_active_artboard(st, &doc, f) { + doc.to_mut().opened_artboard = Some(id); + } + Ok(doc) +} + /// The document an export of `st` writes: the whole document, or with `selectedOnly` just the /// selected objects, each in its layers and groups (a clipping group keeps its clipping path). pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result> { @@ -190,11 +201,7 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C return Ok((Cow::Owned(doc), display_doc_title)); } let st = s.doc()?; - let mut doc = export_source(st, p)?; - // A `.ai` export names the view's active artboard, as a `.ai` save does. - if let Some(id) = super::save::moved_active_artboard(st, &doc, f) { - doc.to_mut().opened_artboard = Some(id); - } + let doc = export_doc(st, f, p)?; let display_doc_title = super::has_own_title(&doc); Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title)) } diff --git a/crates/ui-egui/src/addons/document/tests.rs b/crates/ui-egui/src/addons/document/tests.rs index 69cb75091..7ef678308 100644 --- a/crates/ui-egui/src/addons/document/tests.rs +++ b/crates/ui-egui/src/addons/document/tests.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use serde_json::json; use vectorcraft_engine::Session; -use super::SAVE_COPY; +use super::{EXPORT_ALL, SAVE_COPY}; use crate::{VectorcraftApp, menus}; /// A new, empty folder of its own for test `name`. @@ -46,6 +46,31 @@ fn save_a_timestamped_copy_writes_beside_the_document() { let _ = std::fs::remove_dir_all(&dir); } +/// A `.ai` timestamped copy, and a `.ai` copy Export All Open Documents writes, name the view's +/// active artboard as their active one. +#[test] +fn ai_copies_name_the_views_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + app.sync_views(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let opts = vectorcraft_engine::cmd::fileio::LoadOptions { native: false, ..Default::default() }; + for (i, (cmd, artboard)) in [(SAVE_COPY, 2), (EXPORT_ALL, 1)].into_iter().enumerate() { + let dir = folder(&format!("active-artboard-{i}")); + if let Some(v) = app.view_mut() { + v.artboard = artboard; + } + app.run(cmd, json!({"folder": dir, "format": "ai"})).unwrap(); + let names = files(&dir); + assert_eq!(names.len(), 1, "{cmd}: {names:?}"); + let bytes = std::fs::read(dir.join(&names[0])).unwrap(); + let l = vectorcraft_engine::cmd::fileio::load_with("a.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{cmd}: {:?}", l.warnings); + assert_eq!(l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)), Some(artboard), "{cmd}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[test] fn an_unsaved_document_asks_for_a_folder() { let dir = folder("picked"); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index a21411587..d8ef1bff8 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -93,6 +93,9 @@ impl VectorcraftApp { if cfg!(debug_assertions) { menus::check_params(&self.session, id, ¶ms)?; } + // A `.ai` file a command writes (a save, an export, a timestamped copy, a batch's save) names + // the view's active artboard. + io::remember_active_artboard(self); let artboards = self.artboard_tool_state(); if let Some(r) = menus::run_ui_command(self, id, ¶ms) { self.follow_artboard_tool(artboards); @@ -173,9 +176,6 @@ impl VectorcraftApp { return r; } } - // A `.ai` file a command writes (a save, an export, a batch's save) names the view's - // active artboard. - io::remember_active_artboard(self); // Native files carry the view they reopen at. if fileio::SaveMode::of(id).is_some() { io::remember_view(self); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index d394d8e6c..d1e34e38b 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -207,6 +207,33 @@ mod tests { } } + /// A `.ai` file exported to a path (File › Export As, its dialog, a control request) names the + /// view's active artboard as its active one, and the open document is unchanged. + #[test] + fn an_ai_file_exported_to_a_path_names_the_views_active_artboard() { + let (mut app, written) = app(); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + app.sync_views(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let opts = fileio::LoadOptions { native: false, ..Default::default() }; + let active = |app: &VectorcraftApp| { + let bytes = written.borrow_mut().pop().map(|(_, b)| b).unwrap(); + let l = fileio::load_with("Art.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + assert_eq!(app.session.doc().unwrap().doc.opened_artboard, None, "the document is unchanged"); + l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)) + }; + for artboard in [2, 1] { + if let Some(v) = app.view_mut() { + v.artboard = artboard; + } + app.run("file.exportAs", json!({"format": "ai", "path": "Art.ai"})).unwrap(); + assert_eq!(active(&app), Some(artboard), "File › Export As"); + export(&mut app, Some("ai"), Some("Art.ai".into()), &json!({})).unwrap(); + assert_eq!(active(&app), Some(artboard), "the Export As dialog"); + } + } + fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec { let v = app.session.execute(cmd, &p).unwrap(); vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index f32323151..1b438adf9 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -1,6 +1,6 @@ use super::save::{ - ask_before_losing, export_path, is_web, report_saved, report_write_error, saved_status, target_path, target_path_keeping, view_file, - write_encoded, write_named, write_reporting, + ask_before_losing, export_path, is_web, remember_active_artboard, report_saved, report_write_error, saved_status, target_path, + target_path_keeping, view_file, write_encoded, write_named, write_reporting, }; use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress}; @@ -33,8 +33,10 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option st.doc.clone(), std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), }; From 5ce8ce486e9d4d3ac9fa207aefab1321fb8b1d0f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 14:09:16 -0800 Subject: [PATCH 3/3] .ai save: Export All names each document's own active artboard Export All Open Documents read the active artboard recorded for each document, which was kept current only for the active one. A document whose artboard changed in its view while another tab was active wrote that stale artboard as the file's active one. Before each command the app now records every open document's active artboard from that document's own view. --- crates/ui-egui/src/addons/document/tests.rs | 33 +++++++++++++++++++++ crates/ui-egui/src/io/save.rs | 19 +++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/addons/document/tests.rs b/crates/ui-egui/src/addons/document/tests.rs index 7ef678308..6e637c718 100644 --- a/crates/ui-egui/src/addons/document/tests.rs +++ b/crates/ui-egui/src/addons/document/tests.rs @@ -71,6 +71,39 @@ fn ai_copies_name_the_views_active_artboard() { } } +/// Export All Open Documents as `.ai` names each document's own active artboard, also for a +/// document whose artboard changed in its view after the last command and that is not active. +#[test] +fn export_all_names_each_documents_active_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + for name in ["first", "second"] { + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 3, "name": name})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + } + app.sync_views(); + // Choosing a tab and an artboard runs no command. + assert!(app.session.set_active(0)); + if let Some(v) = app.view_mut() { + v.artboard = 2; + } + assert!(app.session.set_active(1)); + if let Some(v) = app.view_mut() { + v.artboard = 1; + } + let dir = folder("export-all-active-artboards"); + let r = app.run(EXPORT_ALL, json!({"folder": dir, "format": "ai"})).unwrap(); + let opts = vectorcraft_engine::cmd::fileio::LoadOptions { native: false, ..Default::default() }; + let written = r["files"].as_array().unwrap(); + assert_eq!(written.len(), 2, "{r}"); + for (f, artboard) in written.iter().zip([2, 1]) { + let bytes = std::fs::read(f["path"].as_str().unwrap()).unwrap(); + let l = vectorcraft_engine::cmd::fileio::load_with("a.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + assert_eq!(l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)), Some(artboard), "{}", f["title"]); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn an_unsaved_document_asks_for_a_folder() { let dir = folder("picked"); diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 3d81aff54..98e52a2c3 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -172,7 +172,7 @@ pub(super) fn target_path_keeping( } /// Keep the active document's saved view current before a save (native files reopen at it), and -/// its active artboard ([`remember_active_artboard`]). +/// each document's active artboard ([`remember_active_artboard`]). pub fn remember_view(app: &mut VectorcraftApp) { remember_active_artboard(app); let Some(v) = app.view().copied().filter(|v| v.fitted) else { return }; @@ -181,14 +181,17 @@ pub fn remember_view(app: &mut VectorcraftApp) { } } -/// Keep the active document's active artboard current (a `.ai` file saved or exported names it -/// as its active one): the view's, an index past the last artboard being the last one as in the -/// Artboards panel; `None` without a view. +/// Keep every open document's active artboard current (a `.ai` file saved or exported names it as +/// its active one, also in Export All Open Documents): its own view's, an index past the last +/// artboard being the last one as in the Artboards panel; `None` without a view. pub fn remember_active_artboard(app: &mut VectorcraftApp) { - let index = app.view().map(|v| v.artboard); - if let Some(st) = app.session.active_mut() { - let last = st.doc.artboards.len().saturating_sub(1); - st.active_artboard = index.and_then(|i| st.doc.artboards.get(i.min(last))).map(|a| a.id); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + for uid in uids { + let index = app.view_uids.iter().position(|u| *u == uid).and_then(|k| app.views.get(k)).map(|v| v.artboard); + if let Some(st) = app.session.document_mut(uid) { + let last = st.doc.artboards.len().saturating_sub(1); + st.active_artboard = index.and_then(|i| st.doc.artboards.get(i.min(last))).map(|a| a.id); + } } }