mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 10:45:45 +00:00
Opening a .psd or .psb used to give one merged image. document.open now reads the layer records and builds the document from them. The PSD import options page (https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) says the option that converts layers to objects keeps as much of the layer structure as it can, merges the layers of a clipping mask into one layer and discards hidden layers that require merging, and that Import Hidden Layers brings hidden layers in. document.open does the same, with hiddenLayers: true for the hidden layers; layers: false keeps the merged image. VectorCraft's own rules: - Each layer becomes a layer holding its pixels as an image, with its name, visibility, opacity and blend mode. A group becomes a layer of sublayers; a Pass Through group is not isolated. - Clipped layers show where the layer below has pixels. That layer's fill opacity fades only its own pixels. Layers clipped to an adjustment layer, or to a fill that stores no pixels and isn't drawn, show over the whole canvas. - A layer mask becomes the layer's opacity mask, its default color and invert flag applied; a disabled mask is kept, switched off. - Fill opacity multiplies the layer's opacity. - Layers and groups in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, whether or not they hold transparent pixels; Color Dodge, Color Burn and Difference keep their mode. A merged Dissolve layer's pixels are opaque or transparent, in a pattern of each layer's own. - A solid color fill layer that stores no pixels and has no vector mask comes in as its color (RGB, CMYK, Grayscale, Lab or HSB). - A layer name stored only as a Pascal string, without its Unicode name, is read as Mac Roman. - Adjustment layers, fill layers that aren't drawn, layer effects that are switched on, knockouts and vector masks are not applied; the warnings count them. - A merge whose canvases need more memory than allowed leaves the layer alone, in the Normal mode or unclipped, with a warning. This changes what opening a layered PSD does. Batch Trace Folder passes layers: false, so a PSD is still traced as its merged image. A file whose layers can't be read, or whose layers and merges need more memory than allowed, opens as the merged image with a warning. Lengths, counts, nesting, decoded pixels and the canvases merges make are bounded. Zipped 32-bit data with prediction is now read, in layers and in the merged image. import_fuzz gains a case that mutates the PSD and PSB samples and opens them with their layers. The PSD reader's new error messages have rows in every catalog. Docs: docs/mcp.md, docs/target-app-parity.md, docs/file-format-parity.md, README.
148 lines
6.5 KiB
Rust
148 lines
6.5 KiB
Rust
//! Batch Trace Folder (`addon.document.batchTraceFolder`, #4921): 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;
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use super::super::super::fileio;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use super::super::super::str_param;
|
|
use super::super::super::{Result, Session, bad};
|
|
|
|
/// The command id.
|
|
pub(super) const ID: &str = "addon.document.batchTraceFolder";
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
pub(super) fn batch_trace_folder(_: &mut Session, _: &Value) -> Result<Value> {
|
|
Err(bad(ID, "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).
|
|
pub(super) 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(ID, format!("folders deeper than {MAX_DEPTH} levels")));
|
|
}
|
|
let max = MAX_FILES.saturating_mul(2);
|
|
let (entries, truncated) = fileio::list_dir(folder, max)?;
|
|
// The listing is cut before it is sorted, so a cut one could skip any image: refuse it.
|
|
if truncated {
|
|
return Err(bad(ID, format!("{folder}: more than {max} files and folders")));
|
|
}
|
|
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(&fileio::extension(&path).as_str()) => out.push(path),
|
|
Some("folder") if deep => subdirs.push(path),
|
|
_ => {}
|
|
}
|
|
if out.len() > MAX_FILES {
|
|
return Err(bad(ID, 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_trace_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let folder = str_param(p, "folder").map(fileio::absolute_path).ok_or_else(|| bad(ID, "missing `folder`"))?;
|
|
let dest = str_param(p, "destinationFolder").map(fileio::absolute_path).ok_or_else(|| bad(ID, "missing `destinationFolder`"))?;
|
|
let format = match p.get("format") {
|
|
None => "vectorcraft",
|
|
Some(Value::String(value)) => value.as_str(),
|
|
Some(_) => return Err(bad(ID, "`format` must be a string")),
|
|
};
|
|
let preset = match p.get("preset") {
|
|
None => "Default",
|
|
Some(Value::String(value)) => value.as_str(),
|
|
Some(_) => return Err(bad(ID, "`preset` must be a string")),
|
|
};
|
|
let expand = match p.get("expand") {
|
|
None => false,
|
|
Some(Value::Bool(value)) => *value,
|
|
Some(_) => return Err(bad(ID, "`expand` must be true or false")),
|
|
};
|
|
let include_subdirs = match p.get("includeSubdirs") {
|
|
None => false,
|
|
Some(Value::Bool(value)) => *value,
|
|
Some(_) => return Err(bad(ID, "`includeSubdirs` must be true or false")),
|
|
};
|
|
if !FORMATS.contains(&format) {
|
|
return Err(bad(ID, format!("`format` must be one of {}", FORMATS.join(", "))));
|
|
}
|
|
let preset = preset.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(ID, format!("no tracing preset `{preset}` (see imageTrace.presets)")));
|
|
}
|
|
let trace = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
|
|
fileio::may_read(&folder)?;
|
|
fileio::may_write(&dest)?;
|
|
let mut files = vec![];
|
|
images(&folder, include_subdirs, 0, &mut files)?;
|
|
files.sort();
|
|
if files.is_empty() {
|
|
return Err(bad(ID, "no images in the folder"));
|
|
}
|
|
std::fs::create_dir_all(&dest).map_err(|e| bad(ID, format!("{dest}: {e}")))?;
|
|
let ext = fileio::save_format(Some(format), None).map_err(|e| bad(ID, 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> {
|
|
// A PSD or PSB is traced as its merged image, not as the first of its layers.
|
|
s.execute("document.open", &json!({ "path": path, "layers": false }))?;
|
|
s.execute("select.all", &json!({}))?;
|
|
s.execute(trace, &json!({ "preset": preset }))?;
|
|
let stem = fileio::file_stem(path);
|
|
// Names this batch already took count as taken, as do files on disk.
|
|
let free = super::free_name(&dest, &stem, ext, |o| taken.iter().any(|t| t == o) || std::path::Path::new(o).exists())
|
|
.ok_or_else(|| bad(ID, "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()
|
|
{
|
|
// A just-opened image has no work to lose; if closing fails, one extra tab stays open.
|
|
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 }))
|
|
}
|