Fix #4921: Image Trace > Batch Trace Folder

imageTrace.batchFolder opens each image in a folder, traces it with a
preset (optionally expanded), saves it into a destination folder under
its own name (vectorcraft, svg, pdf, ai or eps; taken names numbered so
nothing is replaced) and closes it again whether it traced or not; the
active document stays active. Object > Image Trace > Batch Trace Folder
asks for both folders. Not on the web.
This commit is contained in:
Don Omondi
2026-11-04 20:12:05 +07:00
committed by bflatastic
parent ed634a78f4
commit 7c04fd8973
8 changed files with 253 additions and 0 deletions
+1
View File
@@ -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;
+9
View File
@@ -112,6 +112,15 @@ pub fn specs() -> Vec<CommandSpec> {
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 <name>.<ext> (-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…",
+180
View File
@@ -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<Value> {
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<String>) -> 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<Value> {
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::<String>::new());
for path in &files {
let opened_before = s.documents().len();
let one = |s: &mut Session| -> Result<String> {
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<String> = 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("<path") && !svg.contains("<image"), "traced and expanded, not the image");
assert_eq!(s.documents().len(), docs, "the opened images are closed");
assert_eq!(s.active_index(), Some(0));
// Again: the taken names are numbered, nothing is replaced.
s.execute("imageTrace.batchFolder", &json!({"folder": src, "destinationFolder": out, "format": "svg"})).unwrap();
assert!(out.join("logo-2.svg").exists() && out.join("mark-2.svg").exists());
// Refused: no folders, an unknown preset or format, a folder without images.
for bad in [
json!({"destinationFolder": out}),
json!({"folder": src}),
json!({"folder": src, "destinationFolder": out, "preset": "No Such Preset"}),
json!({"folder": src, "destinationFolder": out, "format": "docx"}),
json!({"folder": out.join("empty"), "destinationFolder": out}),
] {
assert!(s.execute("imageTrace.batchFolder", &bad).is_err(), "{bad}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+49
View File
@@ -288,3 +288,52 @@ mod tests {
assert_eq!(app.session.prefs.screen_export_presets.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["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<Value, String> {
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);
}
}
+1
View File
@@ -565,6 +565,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),
"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),
+7
View File
@@ -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
`<name>.<ext>` 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
+5
View File
@@ -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. |
+1
View File
@@ -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. |