//! 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; Export All Open Documents writes a //! copy of every open document to one folder as Save a Copy does (#4920); Batch Trace Folder traces //! each image of a folder and saves the results to another ([`batch_trace`], #4921). Its submenu //! (Object › Plug-ins › Document Tools) is the UI's. mod batch_trace; #[cfg(test)] mod tests; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeId, NodeKind, SymbolRegistration}; use super::super::brushsym::{instance_in_place, normalize, place_xf, store_symbol}; use super::super::fileio::{self, SaveMode}; use super::super::{CommandSpec, always, bad, checked_ids_param, clock_date, cmd, date_param, has_doc, str_param, switch_or}; use crate::{Result, Session}; /// The most numbers (`-2`, `-3`…) tried after a timestamped name that is taken. const MAX_NUMBER: u32 = 999; pub fn specs() -> Vec { vec![ cmd!( "addon.document.saveTimestampedCopy", "Save a Timestamped Copy", ["Object", "Plug-ins", "Document Tools"], None, "{folder? (default: the document's own folder; needed for a document never saved), format?: vectorcraft|pdf|svg|svgz|ai|eps (default: the document's own, else vectorcraft), time?: Unix seconds (default: now)} write a copy of the document named _YYYY-MM-DD_HH-MM. (the date and time in UTC; -2, -3… added when that name is taken) as File › Save a Copy writes it: the document keeps its path, title and modified state, and no undo step is made. Off where there is no clock (the web) → {path, format, bytes, warnings}", 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 (.; -2, -3… added when that name is taken, so no file is replaced), one self-contained file each with all of its art (images embedded or included, every artboard; no remembered save options apply): 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.batchTraceFolder", "Batch Trace Folder", ["Object", "Plug-ins", "Document Tools"], 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; a folder with more than 1,000 entries is refused. Not on the web (no file system) → {traced: [{path, output}], failed: [{path, error}], format} (#4921)", always, batch_trace::batch_trace_folder ), cmd!( "addon.document.symbolsFromLayers", "New Symbols from Layers", ["Object", "Plug-ins", "Document Tools"], None, "{layers?: [layer id…] (top-level layers; default: those highlighted in the Layers panel when two or more are, else every one), replace?: bool (false: the art stays; true: each layer's art is replaced by an instance of its symbol in the same place)} make one symbol of each 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 with no art are skipped, and with replace so are locked layers; with replace the instances are selected → {symbols: [name…], ids: [instance id…] (with replace), skipped: [{layer, name, reason: \"empty\"|\"locked\"}…]}", has_doc, symbols_from_layers ), ] } /// A document is open and the time can be told. fn has_clock(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; vectorcraft_doc::metadata::output_time().map(|_| ()).ok_or_else(|| "there is no clock here".into()) } /// `.`, then `-2.`… in `folder`: the first that `taken` doesn't hold. fn free_name(folder: &str, base: &str, ext: &str, taken: impl Fn(&str) -> bool) -> Option { (1..=MAX_NUMBER) .map(|i| { let name = if i == 1 { format!("{base}.{ext}") } else { format!("{base}-{i}.{ext}") }; std::path::Path::new(folder).join(name).to_string_lossy().into_owned() }) .find(|path| !taken(path)) } fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.document.saveTimestampedCopy"; let st = s.doc()?; let format = match str_param(p, "format") { Some(f) => fileio::save_format(Some(f), None), // A document opened from a format Save doesn't write (a picture, a drawing) copies as native. None => fileio::save_format(Some(st.format), None).or_else(|_| fileio::save_format(None, None)), } .map_err(|e| bad(C, e))?; let folder = match str_param(p, "folder").filter(|f| !f.trim().is_empty()) { Some(f) => f.to_string(), None => st .path .as_deref() .and_then(|path| std::path::Path::new(path).parent()) .map(|f| f.to_string_lossy().into_owned()) .filter(|f| !f.is_empty()) .ok_or_else(|| bad(C, "the document has never been saved: give `folder`"))?, }; let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&st.doc.title)); let given = date_param(p, "time", C)?; let t = clock_date(s, "time", given).ok_or_else(|| bad(C, "there is no clock here: give `time` (Unix seconds)"))?; let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); let base = format!("{stem}_{y:04}-{mo:02}-{d:02}_{h:02}-{mi:02}"); // Only whether something is there: the write itself goes through the automation roots. let path = free_name(&folder, &base, format.extensions[0], |p| std::path::Path::new(p).exists()) .ok_or_else(|| bad(C, "every numbered name for the copy is taken"))?; fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id })) } /// The save options of every copy, given in full so that no document's remembered options (linked /// SVG images, a PDF page range, separate artboard files) apply: one self-contained file per /// document with all of its art. Each format reads only the options it has, and `range` is left /// out of SVG, where it would pick artboards over `useArtboards`. fn copy_options(format: &str) -> Value { match format { "svg" | "svgz" => json!({ "images": "embed", "useArtboards": false }), "pdf" => json!({ "range": "all" }), "eps" => json!({ "includeLinkedFiles": true, "useArtboards": false }), "ai" | "ait" => json!({ "range": "all", "includeLinked": true, "separateArtboards": false }), _ => json!({ "includeLinked": true, "separateArtboards": false }), } } fn export_all_open(s: &mut Session, p: &Value) -> Result { 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::::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; }; let options = copy_options(format.id); match fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id, "options": options })) { Ok(r) => { written.push(path.clone()); // Any other file the save wrote is taken for the copies still to come. for key in ["files", "linked"] { for f in r.get(key).and_then(Value::as_array).into_iter().flatten() { if let Some(f) = f.as_str().or_else(|| f.get("path").and_then(Value::as_str)) { written.push(f.to_string()); } } } 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 { let mut n = n.clone(); if let NodeKind::Layer { children, clip, .. } = &n.kind { n.kind = NodeKind::Group { children: children.clone(), clip: *clip }; } if let Some(children) = n.children_mut() { for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) { *c = Arc::new(without_layers(c)); } } n } fn symbols_from_layers(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.document.symbolsFromLayers"; let replace = switch_or(p, "replace", false, C)?; let st = s.doc()?; let d = &st.doc; let layers: Vec = match p.get("layers").filter(|v| !v.is_null()) { // The layers highlighted in the Layers panel when two or more are (one is mostly just the // current layer), else every one. None => { let rows = st.highlighted_rows(); let picked: Vec = d.layers.iter().map(|l| l.id).filter(|id| rows.contains(id)).collect(); if picked.len() > 1 { picked } else { d.layers.iter().map(|l| l.id).collect() } } Some(_) => { let ids = checked_ids_param(s, p, "layers", C)?; if let Some(NodeId(id)) = ids.iter().find(|id| !d.layers.iter().any(|l| l.id == **id)) { return Err(bad(C, format!("{id} is no top-level layer"))); } d.layers.iter().map(|l| l.id).filter(|id| ids.contains(id)).collect() } }; // Each layer with art: its id and name, the art's roots, and the art ready for a symbol. let (mut made, mut skipped) = (vec![], vec![]); for layer in layers.iter().filter_map(|id| d.layers.iter().find(|l| l.id == *id)) { let name = layer.display_name(); let roots: Vec = layer.children().map(|c| c.iter().map(|n| n.id).collect()).unwrap_or_default(); let art = match layer.children().map(Vec::as_slice).unwrap_or_default() { [] => None, [one] => Some(without_layers(one)), many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))), }; let reason = if replace && layer.locked { Some("locked") } else { None }; let ready = art.and_then(|mut art| { crate::cmd::threads::keep_shown_text(d, &mut art); normalize(art).ok() }); match (reason, ready) { (None, Some(ready)) => made.push((name, roots, ready)), (reason, _) => skipped.push(json!({ "layer": layer.id.0, "name": name, "reason": reason.unwrap_or("empty") })), } } if made.is_empty() { return Ok(json!({ "symbols": [], "ids": [], "skipped": skipped })); } let (names, ids) = s.edit("New Symbols from Layers", |d, sel| { let (mut names, mut ids) = (vec![], vec![]); for (name, roots, (art, size, center)) in &made { let name = d.unique_symbol_name(name); store_symbol(d, art, &name, *size); if replace { ids.push(instance_in_place(d, roots, &name, place_xf(*center, *size, SymbolRegistration::CENTER))?); } names.push(name); } if replace { sel.set(ids.iter().copied()); } Ok((names, ids)) })?; Ok(json!({ "symbols": names, "ids": ids.iter().map(|i| i.0).collect::>(), "skipped": skipped })) }