Fix #4920: Document Tools exports every open document

addon.document.exportAllOpen {folder, format?} writes a copy of every
open document to one folder as Save a Copy does, each named after its
document with taken names numbered so no file is replaced. Documents
keep their paths and modified state, the active one stays active, and a
document that can't be written is reported while the others are still
written. Object > Plug-ins > Document Tools gets Export All Open
Documents as PDF and as SVG, which ask for the folder.
This commit is contained in:
Don Omondi
2026-11-01 09:24:25 +07:00
committed by bflatastic
parent 18b9c6113e
commit 1e8ac2434c
8 changed files with 151 additions and 2 deletions
+42 -1
View File
@@ -1,6 +1,7 @@
//! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of
//! the document named with the date and time through File › Save a Copy's save; New Symbols from
//! Layers makes a symbol of each layer's art as New Symbol does. Its submenu (Object › Plug-ins ›
//! Layers makes a symbol of each layer's art as New Symbol does; Export All Open Documents writes a
//! copy of every open document to one folder as Save a Copy does (#4920). Its submenu (Object › Plug-ins ›
//! Document Tools) is the UI's.
#[cfg(test)]
@@ -30,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_clock,
save_timestamped_copy
),
cmd!(
"addon.document.exportAllOpen",
"Export All Open Documents",
["Object", "Plug-ins", "Document Tools"],
None,
"{folder, format?: vectorcraft|pdf|svg|svgz|ai|eps (default: pdf)} write a copy of every open document, in tab order, to `folder` as File › Save a Copy writes it, each named after its document (<name>.<ext>; -2, -3… added when that name is taken, so no file is replaced): every document keeps its path, title and modified state, and no undo step is made. A document that can't be written is listed in `failed` and the others are still written; the active document stays active → {files: [{title, path, warnings}], failed: [{title, error}], format}",
has_doc,
export_all_open
),
cmd!(
"addon.document.symbolsFromLayers",
"New Symbols from Layers",
@@ -88,6 +98,37 @@ fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result<Value> {
fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id }))
}
fn export_all_open(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.document.exportAllOpen";
let format = fileio::save_format(Some(str_param(p, "format").unwrap_or("pdf")), None).map_err(|e| bad(C, e))?;
let folder = str_param(p, "folder").map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "give `folder`"))?.to_string();
let ext = format.extensions.first().copied().ok_or_else(|| bad(C, "the format has no file extension"))?;
let (mut files, mut failed, mut written) = (vec![], vec![], Vec::<String>::new());
let active = s.active;
for i in 0..s.docs.len() {
// Each document in turn is the one Save a Copy writes; switching tabs this way leaves every
// document's tool and interaction alone, and the active one comes back below.
s.active = Some(i);
let Ok(st) = s.doc() else { continue };
let title = st.doc.title.clone();
let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&title));
// Names the copies already took count as taken, as do files on disk.
let Some(path) = free_name(&folder, &stem, ext, |p| written.iter().any(|w| w == p) || std::path::Path::new(p).exists()) else {
failed.push(json!({ "title": title, "error": "every numbered name for the copy is taken" }));
continue;
};
match fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id })) {
Ok(r) => {
written.push(path.clone());
files.push(json!({ "title": title, "path": r.get("path").cloned().unwrap_or(json!(path)), "warnings": r.get("warnings").cloned().unwrap_or(json!([])) }));
}
Err(e) => failed.push(json!({ "title": title, "error": e.to_string() })),
}
}
s.active = active;
Ok(json!({ "files": files, "failed": failed, "format": format.id }))
}
/// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds
/// no layers).
fn without_layers(n: &Node) -> Node {
@@ -194,3 +194,26 @@ fn the_layers_highlighted_in_the_layers_panel_are_the_default() {
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
assert_eq!((r["symbols"].clone(), r["skipped"].as_array().unwrap().len()), (json!(["Icons"]), 1));
}
/// #4920: Export All Open Documents writes an SVG of each open document; a folder that can't be
/// written lists every document in `failed`, and either way the active document stays active.
#[test]
fn export_all_open_documents_writes_each_and_reports_failures() {
let dir = std::env::temp_dir().join(format!("vc-export-all-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "one"})).unwrap();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "two"})).unwrap();
s.execute("document.activate", &json!({"index": 0})).unwrap();
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": dir, "format": "svg"})).unwrap();
assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}");
assert!(dir.join("one.svg").exists() && dir.join("two.svg").exists());
assert_eq!(s.active_index(), Some(0));
assert!(s.documents().iter().all(|d| d.path.is_none()), "the documents stay unsaved");
let missing = dir.join("missing").join("deeper");
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": missing, "format": "svg"})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (0, 2), "{r}");
assert_eq!(s.active_index(), Some(0));
let _ = std::fs::remove_dir_all(&dir);
}
+25 -1
View File
@@ -1,7 +1,8 @@
//! Document Tools in the interface: its submenu (Object › Plug-ins › Document Tools), which runs
//! Save a Timestamped Copy, New Symbols from Layers and Convert Layers to Symbols (the same, with
//! each layer's art replaced by an instance). Save a Timestamped Copy of a document never saved
//! asks for the folder to put it in ([`save_copy`]).
//! asks for the folder to put it in ([`save_copy`]). Export All Open Documents as PDF or SVG asks
//! for the folder the copies go in ([`export_all`], #4920).
use serde_json::{Value, json};
@@ -10,6 +11,8 @@ use crate::menus::Item;
/// Save a Timestamped Copy.
pub const SAVE_COPY: &str = "addon.document.saveTimestampedCopy";
/// Export All Open Documents.
pub const EXPORT_ALL: &str = "addon.document.exportAllOpen";
/// New Symbols from Layers (Convert Layers to Symbols: with their art replaced by instances).
const SYMBOLS: &str = "addon.document.symbolsFromLayers";
@@ -17,6 +20,8 @@ const SYMBOLS: &str = "addon.document.symbolsFromLayers";
pub fn menu() -> Vec<Item> {
vec![
Item::Cmd("Save a Timestamped Copy", SAVE_COPY, json!({})),
Item::Cmd("Export All Open Documents as PDF", EXPORT_ALL, json!({ "format": "pdf" })),
Item::Cmd("Export All Open Documents as SVG", EXPORT_ALL, json!({ "format": "svg" })),
Item::Sep,
Item::Cmd("New Symbols from Layers", SYMBOLS, json!({})),
Item::Cmd("Convert Layers to Symbols", SYMBOLS, json!({ "replace": true })),
@@ -35,5 +40,24 @@ pub(crate) fn save_copy(app: &mut VectorcraftApp, p: &Value) -> Result<Value, St
Ok(r)
}
/// Export All Open Documents with params `p`: without a `folder`, the copies go in the folder
/// picked. The status bar says how many were written and names the first that failed.
pub(crate) fn export_all(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
if p.get("folder").is_none() {
p["folder"] = json!(crate::picks::folder(app).ok_or("cancelled")?);
}
let r = app.session.execute(EXPORT_ALL, &p).map_err(|e| e.to_string())?;
let written = r["files"].as_array().map_or(0, Vec::len);
let failed = r["failed"].as_array().cloned().unwrap_or_default();
let mut status = crate::i18n::msg_n(written as u64, "Exported {n} document", "Exported {n} documents", &[]);
if let Some(f) = failed.first() {
let (title, error) = (f["title"].as_str().unwrap_or_default(), f["error"].as_str().unwrap_or_default());
status = format!("{status}; {title} wasn't written: {error}");
}
app.status(status);
Ok(r)
}
#[cfg(test)]
mod tests;
@@ -82,3 +82,44 @@ fn new_symbols_from_layers_and_convert_layers_to_symbols() {
.all(|l| matches!(l.children().unwrap()[..], [ref one] if matches!(one.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })))
);
}
/// #4920: Export All Open Documents as PDF asks for a folder and writes one PDF per open document,
/// named after each, without replacing a file there, and leaves every document as it was.
#[test]
fn export_all_open_documents_writes_each_to_the_folder_picked() {
let dir = folder("export-all");
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
app.run("document.save", json!({"path": dir.join("card.vectorcraft")})).unwrap();
app.run("file.new", json!({"width": 200, "height": 100, "name": "card"})).unwrap();
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let untitled = app.session.doc().unwrap().doc.title.clone();
let out = dir.join("out");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(out.join(format!("{untitled}.pdf")), b"keep").unwrap();
let picked = out.to_string_lossy().into_owned();
app.services.pick_folder = Some(Box::new(move || Some(picked.clone())));
let active = app.session.active_index();
click(&mut app, "Export All Open Documents as PDF");
let mut names = files(&out);
names.sort();
let mut want = vec!["card.pdf".to_string(), "card-2.pdf".into(), format!("{untitled}.pdf"), format!("{untitled}-2.pdf")];
want.sort();
assert_eq!(names, want, "one per document, the taken names numbered");
assert_eq!(std::fs::read(out.join(format!("{untitled}.pdf"))).unwrap(), b"keep", "no file is replaced");
assert!(std::fs::read(out.join("card.pdf")).unwrap().starts_with(b"%PDF"));
assert_eq!(app.session.active_index(), active, "the active document stays active");
assert!(
app.session.documents().iter().all(|d| d.path.as_ref().is_none_or(|p| p.ends_with("card.vectorcraft"))),
"no document takes a copy's path"
);
assert!(app.ui.status.starts_with("Exported 3 documents"), "{}", app.ui.status);
// Cancelled: nothing written.
app.services.pick_folder = Some(Box::new(|| None));
assert_eq!(app.run(super::EXPORT_ALL, json!({})).unwrap_err(), "cancelled");
assert_eq!(files(&out).len(), 4);
// The engine command refuses a missing folder and an unknown format.
assert!(app.session.execute(super::EXPORT_ALL, &json!({})).is_err());
assert!(app.session.execute(super::EXPORT_ALL, &json!({"folder": out, "format": "doc"})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -531,6 +531,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app),
crate::addons::arrange::ZOOM => crate::addons::arrange::zoom(app),
crate::addons::document::SAVE_COPY => crate::addons::document::save_copy(app, p),
crate::addons::document::EXPORT_ALL => crate::addons::document::export_all(app, p),
"tool.setOption" => app.session.set_tool_option_cmd(p),
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
"ui.recolorDialog" => crate::dialogs::recolor::open(app, p),
+12
View File
@@ -6401,6 +6401,18 @@ command is off where there is no clock (the web). → `{path, format, bytes, war
{"name":"run_command","arguments":{"command":"addon.document.saveTimestampedCopy","params":{"folder":"/Users/me/Versions","format":"pdf"}}}
```
`addon.document.exportAllOpen {folder, format?}` writes a copy of every open document, in tab order, to `folder`,
each named after its document (`<name>.<ext>`, with `-2`, `-3`… added when that name is taken there or by another
copy, so no file is replaced). `format` is a save format (`pdf` by default; `svg`, `svgz`, `vectorcraft`, `ai`,
`eps`). Each copy is written as `file.saveCopy` writes it, inside the automation roots in force: every document keeps
its path, title and modified state, no undo step is made, and the active document stays active. A document that
can't be written is listed in `failed` and the others are still written (#4920). The desktop app's Export All Open
Documents as PDF and as SVG ask for the folder. → `{files: [{title, path, warnings}], failed: [{title, error}], format}`
```json
{"name":"run_command","arguments":{"command":"addon.document.exportAllOpen","params":{"folder":"/Users/me/Out","format":"svg"}}}
```
`addon.document.symbolsFromLayers {layers?, replace?}` makes one symbol of each top-level layer's art, as `symbol.new`
makes one (registration in the centre), named after the layer with a number added when a symbol has that name, as
one undo step. `layers` lists layer ids (top-level layers only; anything else is refused); by default it takes the
+5
View File
@@ -1448,10 +1448,15 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov
Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8.
## Document Tools add-on: Export All Open Documents (2026-11-01)
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Document Tools › Export All Open Documents as PDF (or as SVG) asks for a folder and writes a copy of every open document there, each named after its document with `-2`, `-3`… added when a name is taken, so no file is replaced (`addon.document.exportAllOpen {folder, format?}`, #4920). Each copy is written as Save a Copy writes it: the documents keep their paths, titles and modified state, and the active one stays active. A document that can't be written is named in the status bar and the others are still written. Tests: `addons::document::tests::export_all_open_documents_writes_each_to_the_folder_picked` (ui-egui), `cmd::addons::document::tests::export_all_open_documents_writes_each_and_reports_failures` (engine).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Document Tools add-on (a VectorCraft addition): Export All Open Documents as PDF or SVG (#4920); evidence added; scores unchanged. |
| 2026-11-01 | minor | Path Precision Tools add-on (a VectorCraft addition): Expand Dashes into Paths (#4922); evidence added; scores unchanged. |
| 2026-11-01 | minor | Type core: the Glyphs panel highlights the character selected with the Type tool and scrolls it into view (#4908); evidence added; scores unchanged. |
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); evidence added; scores unchanged. |
+2
View File
@@ -337,6 +337,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Randomize Opacity… gives each selected object its own opacity at random between Minimum and Maximum (%), with a Seed that repeats it and Preview, one undo step | done | #4925; `addon.arrange.randomizeOpacity`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored |
| Arrange Tools add-on (VectorCraft addition): Object › Plug-ins › Arrange Tools › Sort Objects… sorts the selected objects by Sort By (width, height, area, X or Y position, opacity, stacking order) in an Order (Small to Large, Large to Small, Random) and gives them Apply To in that order, trading the values or with Distribute Evenly; Scale Strokes & Effects; Preview; one undo step | done | #4917; `addon.arrange.sort`, `crates/engine/src/cmd/addons/arrange.rs`, `crates/ui-egui/src/addons/arrange.rs` and their tests; not a feature of the reference app, so it is not scored |
| Document Tools add-on (VectorCraft addition): Object › Plug-ins › Document Tools › Save a Timestamped Copy writes `<name>_YYYY-MM-DD_HH-MM.<ext>` next to the document (a folder picked for one never saved), which keeps its path, title and modified state; off on the web | done | #4926; `addon.document.saveTimestampedCopy`, `crates/engine/src/cmd/addons/document.rs`, `crates/ui-egui/src/addons/document.rs` and their tests; not a feature of the reference app, so it is not scored |
| Document Tools add-on (VectorCraft addition): Export All Open Documents as PDF / as SVG (#4920) | done | asks for a folder, writes a copy of every open document named after it (taken names numbered, nothing replaced), documents and active tab unchanged; failures named in the status bar |
| Document Tools add-on (VectorCraft addition): Object › Plug-ins › Document Tools › New Symbols from Layers makes a symbol of each layer's art named after the layer (the highlighted layers when two or more are, else all); Convert Layers to Symbols also replaces each layer's art with an instance in place; empty layers skipped, one undo step | done | #4927; `addon.document.symbolsFromLayers`, `crates/engine/src/cmd/addons/document.rs`, `crates/ui-egui/src/addons/document.rs` and their tests; not a feature of the reference app, so it is not scored |
| Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point) | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not a feature of the reference app, so it is not scored |
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its seven tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape, Corner Tool, Slide Anchor Along Path, Shared Edge Merge Tool), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; the Corner Tool cuts editable corners on any path (hover shows the anchor, a click uses its radius and kind, a drag sizes the corner, Shift whole values; a corner's marker selects it and loads its values, drags to resize, to 0 or double-clicked removes it; never past what fits; Alt-marquee cuts every anchor inside) with a Corners section in the Anchor Inspector (radius, kind, selected count, Apply to Selected Anchors); Slide Anchor Along Path drags an anchor along its curve with the segments beside it refitted, Shift snapping where the tangent is a multiple of 45°; the Shared Edge Merge Tool shows, over two paths touching at two coincident anchors, the two stretches and the edge merging them, a click merges them (half way; Shift the upper path, Alt the lower), and near anchors meet or an anchor one path lacks is added to match (Shift+Alt asks for this inside a stretch), with a Shared Edges section in the Anchor Inspector (brush diameter, coincidence 0 to 3 pt, Keep Shared Points Together, which takes coincident anchors along with the add-on's own anchor moves); a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs`, `crates/ui-egui/src/addons/pathtools/` and their tests; not a feature of the reference app, so it is not scored |
@@ -392,6 +393,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Document Tools add-on (a VectorCraft addition): Export All Open Documents as PDF or SVG (#4920); scores and estimates unchanged |
| 2026-11-01 | minor | Path Precision Tools add-on (a VectorCraft addition): Expand Dashes into Paths (#4922); scores and estimates unchanged |
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Randomize Opacity (#4925); scores and estimates unchanged |
| 2026-11-01 | minor | Arrange Tools add-on (a VectorCraft addition): Sort Objects (#4917); scores and estimates unchanged |