diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 76f408d22..f20aa051e 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -32,6 +32,7 @@ mod export; pub(crate) mod imagemap; mod legacy_ai; mod load; +mod trace_batch; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; mod jp2read; mod metafile; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 5b734c5a2..16d85cbaf 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -112,6 +112,15 @@ pub fn specs() -> Vec { has_doc, batch::batch ), + cmd!( + "imageTrace.batchFolder", + "Batch Trace Folder", + ["Object", "Image Trace"], + None, + "{folder, destinationFolder, preset?: name (imageTrace.presets; default: Default), expand?: false (true: trace and expand, as Make and Expand), format?: vectorcraft (default)|svg|pdf|ai|eps, includeSubdirs?: false} open each image in folder (png, jpg, tif, bmp, gif, webp, psd), select it, trace it with the preset, save it into destinationFolder as . (-2, -3… added when that name is taken, so no file is replaced) and close it again, whether it traced or not; the document that was active stays active. Caps: 500 images, 8 folder levels. Not on the web (no file system) → {traced: [{path, output}], failed: [{path, error}], format} (#4921)", + always, + super::trace_batch::batch_folder + ), cmd!( "action.batch", "Batch…", diff --git a/crates/engine/src/cmd/fileio/trace_batch.rs b/crates/engine/src/cmd/fileio/trace_batch.rs new file mode 100644 index 000000000..354af7c02 --- /dev/null +++ b/crates/engine/src/cmd/fileio/trace_batch.rs @@ -0,0 +1,180 @@ +//! `imageTrace.batchFolder` (#4921): Image Trace › Batch Trace Folder. Each image in a folder is +//! opened, traced with a preset (and expanded when asked), saved into a destination folder under +//! its own name, and closed again, whether it traced or not. + +use serde_json::Value; +#[cfg(not(target_arch = "wasm32"))] +use serde_json::json; + +use super::super::{Result, Session, bad}; +#[cfg(not(target_arch = "wasm32"))] +use super::super::{bool_or, str_param}; + +#[cfg(target_arch = "wasm32")] +pub(super) fn batch_folder(_: &mut Session, _: &Value) -> Result { + Err(bad("imageTrace.batchFolder", "no file system here")) +} + +#[cfg(not(target_arch = "wasm32"))] +/// The image files a batch traces, by extension. +const IMAGE_EXTS: [&str; 10] = ["png", "jpg", "jpeg", "tif", "tiff", "bmp", "gif", "webp", "psd", "psb"]; +#[cfg(not(target_arch = "wasm32"))] +/// Caps on a batch (folders and MCP payloads are untrusted). +const MAX_FILES: usize = 500; +#[cfg(not(target_arch = "wasm32"))] +const MAX_DEPTH: usize = 8; +#[cfg(not(target_arch = "wasm32"))] +const FORMATS: [&str; 5] = ["vectorcraft", "svg", "pdf", "ai", "eps"]; + +#[cfg(not(target_arch = "wasm32"))] +/// The image files in `folder` (and its subfolders with `deep`), sorted. +fn images(folder: &str, deep: bool, depth: usize, out: &mut Vec) -> Result<()> { + if depth > MAX_DEPTH { + return Err(bad("imageTrace.batchFolder", format!("folders deeper than {MAX_DEPTH} levels"))); + } + let (entries, _) = super::list_dir(folder, MAX_FILES.saturating_mul(2))?; + let mut subdirs = vec![]; + for e in entries { + let name = e.get("name").and_then(Value::as_str).unwrap_or(""); + if name.is_empty() || name.starts_with('.') { + continue; + } + let path = std::path::Path::new(folder).join(name).to_string_lossy().into_owned(); + match e.get("kind").and_then(Value::as_str) { + Some("file") if IMAGE_EXTS.contains(&super::extension(&path).as_str()) => out.push(path), + Some("folder") if deep => subdirs.push(path), + _ => {} + } + if out.len() > MAX_FILES { + return Err(bad("imageTrace.batchFolder", format!("more than {MAX_FILES} images"))); + } + } + for d in subdirs { + images(&d, deep, depth + 1, out)?; + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn batch_folder(s: &mut Session, p: &Value) -> Result { + const C: &str = "imageTrace.batchFolder"; + let folder = str_param(p, "folder").map(super::absolute_path).ok_or_else(|| bad(C, "missing `folder`"))?; + let dest = str_param(p, "destinationFolder").map(super::absolute_path).ok_or_else(|| bad(C, "missing `destinationFolder`"))?; + let format = str_param(p, "format").unwrap_or("vectorcraft"); + if !FORMATS.contains(&format) { + return Err(bad(C, format!("`format` must be one of {}", FORMATS.join(", ")))); + } + let preset = str_param(p, "preset").unwrap_or("Default").to_string(); + let presets = s.execute("imageTrace.presets", &json!({}))?; + if !presets["presets"].as_array().into_iter().flatten().any(|q| q["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(&preset))) { + return Err(bad(C, format!("no tracing preset `{preset}` (see imageTrace.presets)"))); + } + let trace = if bool_or(p, "expand", false) { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; + super::may_read(&folder)?; + super::may_write(&dest)?; + let mut files = vec![]; + images(&folder, bool_or(p, "includeSubdirs", false), 0, &mut files)?; + files.sort(); + if files.is_empty() { + return Err(bad(C, "no images in the folder")); + } + std::fs::create_dir_all(&dest).map_err(|e| bad(C, format!("{dest}: {e}")))?; + let ext = super::save_format(Some(format), None).map_err(|e| bad(C, e))?.extensions.first().copied().unwrap_or(format); + let active = s.active_index(); + let (mut traced, mut failed, mut taken) = (vec![], vec![], Vec::::new()); + for path in &files { + let opened_before = s.documents().len(); + let one = |s: &mut Session| -> Result { + s.execute("document.open", &json!({ "path": path }))?; + s.execute("select.all", &json!({}))?; + s.execute(trace, &json!({ "preset": preset }))?; + let stem = super::file_stem(path); + let free = (1..=999u32) + .map(|i| if i == 1 { format!("{stem}.{ext}") } else { format!("{stem}-{i}.{ext}") }) + .map(|n| std::path::Path::new(&dest).join(n).to_string_lossy().into_owned()) + .find(|o| !taken.contains(o) && !std::path::Path::new(o).exists()) + .ok_or_else(|| bad(C, "every numbered name for the result is taken"))?; + s.execute("document.save", &json!({ "path": free, "format": format }))?; + Ok(free) + }; + let result = one(s); + // The image's document closes whatever happened, so a failure leaves no tab behind. + if s.documents().len() > opened_before + && let Some(i) = s.active_index() + { + let _ = s.execute("file.close", &json!({ "index": i })); + } + match result { + Ok(out) => { + taken.push(out.clone()); + traced.push(json!({ "path": path, "output": out })); + } + Err(e) => failed.push(json!({ "path": path, "error": e.to_string() })), + } + } + if let Some(i) = active.filter(|i| *i < s.documents().len()) { + s.set_active(i); + } + Ok(json!({ "traced": traced, "failed": failed, "format": format })) +} + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use serde_json::json; + + use crate::Session; + + fn png(path: &std::path::Path, dark: bool) { + let mut img = image::RgbaImage::from_pixel(24, 24, image::Rgba([255, 255, 255, 255])); + for y in 6..18 { + for x in 6..18 { + img.put_pixel(x, y, if dark { image::Rgba([0, 0, 0, 255]) } else { image::Rgba([200, 30, 30, 255]) }); + } + } + img.save(path).unwrap(); + } + + /// #4921: each image of the folder is traced and saved under its own name; a file that isn't + /// an image is left alone, one that can't be read is reported, and every document the batch + /// opened is closed again, the active one staying active. + #[test] + fn batch_trace_folder_traces_and_saves_each_image() { + let dir = std::env::temp_dir().join(format!("vc-batch-trace-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (src, out) = (dir.join("in"), dir.join("out")); + std::fs::create_dir_all(&src).unwrap(); + png(&src.join("logo.png"), true); + png(&src.join("mark.png"), false); + std::fs::write(src.join("notes.txt"), "not an image").unwrap(); + std::fs::write(src.join("broken.png"), b"not a png").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let docs = s.documents().len(); + let r = s.execute("imageTrace.batchFolder", &json!({"folder": src, "destinationFolder": out, "format": "svg", "expand": true})).unwrap(); + assert_eq!(r["traced"].as_array().unwrap().len(), 2, "{r}"); + assert_eq!(r["failed"].as_array().unwrap().len(), 1, "{r}"); + assert!(r["failed"][0]["path"].as_str().unwrap().ends_with("broken.png")); + let mut names: Vec = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["logo.svg", "mark.svg"]); + let svg = std::fs::read_to_string(out.join("logo.svg")).unwrap(); + assert!(svg.contains(">(), ["Pack", "Pack 2"]); } } + +/// Object › Image Trace › Batch Trace Folder (#4921) with params `p`: the source folder and then +/// the destination folder are asked for when not given; the status bar says how many images were +/// traced and how many weren't. +pub(crate) fn batch_trace(app: &mut VectorcraftApp, p: &Value) -> Result { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + for key in ["folder", "destinationFolder"] { + if p.get(key).is_none() { + p[key] = json!(crate::picks::folder(app).ok_or("cancelled")?); + } + } + let r = app.session.execute("imageTrace.batchFolder", &p).map_err(|e| e.to_string())?; + let traced = r["traced"].as_array().map_or(0, Vec::len) as u64; + let failed = r["failed"].as_array().map_or(0, Vec::len) as u64; + let mut status = crate::i18n::msg_n(traced, "Traced {n} image", "Traced {n} images", &[]); + if failed > 0 { + status = format!("{status}; {}", crate::i18n::msg_n(failed, "{n} image couldn't be traced", "{n} images couldn't be traced", &[])); + } + app.status(status); + Ok(r) +} + +#[cfg(test)] +mod batch_trace_tests { + use serde_json::json; + + use crate::VectorcraftApp; + + /// #4921: Batch Trace Folder from the menu asks for the source folder, then the destination, + /// traces each image there and reports the count; cancelling either does nothing. + #[test] + fn batch_trace_folder_asks_for_both_folders() { + let dir = std::env::temp_dir().join(format!("vc-ui-batch-trace-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (src, out) = (dir.join("in"), dir.join("out")); + std::fs::create_dir_all(&src).unwrap(); + image::RgbaImage::from_pixel(16, 16, image::Rgba([0, 0, 0, 255])).save(src.join("a.png")).unwrap(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let mut picks = vec![out.to_string_lossy().into_owned(), src.to_string_lossy().into_owned()]; + app.services.pick_folder = Some(Box::new(move || picks.pop())); + crate::menus::invoke(&mut app, "imageTrace.batchFolder", json!({})); + assert!(out.join("a.vectorcraft").exists(), "{}", app.ui.status); + assert!(app.ui.status.starts_with("Traced 1 image"), "{}", app.ui.status); + assert!(app.session.documents().is_empty(), "the image's document is closed"); + app.services.pick_folder = Some(Box::new(|| None)); + assert_eq!(app.run("imageTrace.batchFolder", json!({})).unwrap_err(), "cancelled"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index c20e3b650..8bf9b147d 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -565,6 +565,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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), + "imageTrace.batchFolder" => crate::io::batch_trace(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), diff --git a/docs/mcp.md b/docs/mcp.md index 6843b8cf6..75721fb75 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1880,6 +1880,13 @@ already walks Image Trace. Short agent entry points: Its journal entry records the saved preset's settings as `savedPreset`, so a replay traces with them after the preset is changed, renamed or deleted. In the app, `ui.imageTracePresetDialog {params?, rename?}` opens the Image Trace panel's Save as New Preset… or Rename… dialog. + `imageTrace.batchFolder {folder, destinationFolder, preset?, expand?, format?, includeSubdirs?}` (Object › Image + Trace › Batch Trace Folder) opens each image in `folder` (PNG, JPEG, TIFF, BMP, GIF, WebP, PSD), traces it with + `preset` (Default by default; with `expand`, as Make and Expand), saves it into `destinationFolder` as + `.` in `format` (`vectorcraft` by default; `svg`, `pdf`, `ai`, `eps`), with `-2`, `-3`… added when the + name is taken so no file is replaced, and closes it again whether it traced or not; the active document stays + active. Up to 500 images and 8 folder levels; not on the web. The app asks for both folders (#4921). + → `{traced: [{path, output}], failed: [{path, error}], format}` - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, `livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 25a6f25e0..730eeed04 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1571,6 +1571,10 @@ Symbols area (#4938), from the public symbol documentation ([create and place sy 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). +## Batch Trace Folder (2026-11-01) + +A VectorCraft addition (it replaces a script some users ran), so no area score moves. Object › Image Trace › Batch Trace Folder (`imageTrace.batchFolder`) asks for a folder of images and a destination folder, traces each image with a preset (optionally expanded) and saves it there under its own name as `.vectorcraft`, `.svg`, `.pdf`, `.ai` or `.eps`, without replacing files, closing each image's document again and reporting the images it couldn't trace (#4921). Tests: `cmd::fileio::trace_batch::tests::batch_trace_folder_traces_and_saves_each_image` (engine), `io::batch_trace_tests::batch_trace_folder_asks_for_both_folders` (ui-egui). + ## Color Effects add-on: Find Nearest Swatch (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Color Effects › Find Nearest Swatch… lists each colour of the selection (fills and strokes, as ticked; solid colours and gradient stops, inside groups and type) with the three swatches of the chosen library closest to it by CIEDE2000 difference, with that difference (`addon.color.nearestSwatch`, #4923). The library is the document's swatches, a built-in library, or a library or colour book the user saved or loaded (no colour book data ships with VectorCraft). Apply gives each colour its nearest swatch as one undo step (`addon.color.applyNearestSwatch`): global and spot swatches link, and a library's swatches the colours take are added to the document first, so a spot colour stays spot. Remaining: typing a colour to look up in the dialog (the command takes `color`), and applying a match other than the nearest. Tests: `cmd::addons::color::nearest::tests` (engine), `addons::color::nearest::tests::find_nearest_swatch_lists_the_matches_and_applies_the_nearest` (ui-egui). @@ -1602,6 +1606,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. | | 2026-11-01 | minor | Copy and paste ruler guides (#3226); evidence added; scores unchanged. | | 2026-11-04 | minor | Area type vertical alignment buttons in the Properties panel's Paragraph section and the Control panel, off for columns, rows and threaded frames (#3759); evidence added; scores unchanged. | | 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 934323ff7..e9bbe627a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -420,6 +420,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Object › Image Trace › Batch Trace Folder asks for a source and a destination folder and traces each image into it (#4921); scores and estimates unchanged | | 2026-11-01 | minor | Edit › Copy and Cut take the selected ruler guides, with the objects or alone; pasting puts them on the layer new art goes to, moved as the objects are (alone: where they were), Paste in Place onto the active artboard and Paste on All Artboards onto each (#3226); scores and estimates unchanged | | 2026-11-04 | minor | Area type: Align Top / Center / Bottom / Justify Vertically buttons in the Properties panel (Paragraph section) and the Control panel set its vertical alignment in the frame, as Area Type Options does; turned off for frames with rows or columns and threaded frames (#3759); scores and estimates unchanged | | 2026-11-03 | minor | Enclosing Lasso selects fully enclosed objects with a freehand loop under Object › Plug-ins; scores and estimates unchanged. |