mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 04:50:18 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user