Merge origin/main into fix/gradient-stop-drag-off

Resolve the parity-doc conflict: keep main's status line and entries, re-add this PR's entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ljkxandClaude Opus 5.5 committed 2026-11-03 20:40:19 +01:00
commit bc715316ff
149 files changed
+11296 -851

No files matched your search

+16
View File
@@ -190,6 +190,22 @@ pub(crate) struct BatchArgs {
/// originals; --in-place allows it
#[arg(long)]
pub(crate) in_place: bool,
/// Include images in subfolders; every result goes straight into --out, so a later file of
/// the same name replaces an earlier one
#[arg(long)]
pub(crate) recursive: bool,
/// Name each result with Batch tokens such as {name}, {serial}, {date}, and {ext}
#[arg(long, value_name = "TEMPLATE")]
pub(crate) naming: Option<String>,
/// First serial number used by --naming
#[arg(long, value_name = "1-4294967295", value_parser = clap::value_parser!(u32).range(1..))]
pub(crate) start_serial: Option<u32>,
/// Stop after the first failed input
#[arg(long)]
pub(crate) stop_on_error: bool,
/// Write failed inputs and reasons to this text file
#[arg(long, value_name = "FILE")]
pub(crate) error_log: Option<PathBuf>,
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
#[arg(long)]
pub(crate) approve_high_memory: bool,
+73 -17
View File
@@ -58,25 +58,67 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
if !a.in_place && same_dir(in_dir, out_dir) {
return Err(CliError::OutIsIn(out_dir.to_path_buf()));
}
std::fs::create_dir_all(out_dir)?;
let mut inputs: Vec<PathBuf> = std::fs::read_dir(in_dir)
.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?
.flatten()
.map(|e| e.path())
.filter(|p| is_input(p))
.collect();
inputs.sort();
let inputs: Vec<photocraft_engine::file_cmds::BatchInput> = if a.recursive {
photocraft_engine::file_cmds::list_images_tree(&in_dir.to_string_lossy(), &out_dir.to_string_lossy(), "photocraft-cli batch")?
.into_iter()
.filter(|input| is_input(Path::new(&input.path)))
.collect()
} else {
let mut images = Vec::new();
let entries = std::fs::read_dir(in_dir).map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?;
for entry in entries {
let path = entry.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?.path();
if is_input(&path) {
images.push(photocraft_engine::file_cmds::BatchInput { path: path.to_string_lossy().into_owned(), rel_dir: String::new() });
}
}
// The tree listing already holds the engine's order (each folder's files, then its
// subfolders), which `{serial}` numbers; only the flat listing needs sorting.
images.sort_by(|a, b| a.path.cmp(&b.path));
images
};
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.export.format.as_deref().map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let named = photocraft_engine::file_cmds::plan_batch_names(
&inputs,
&out_dir.to_string_lossy(),
format.unwrap_or("same"),
&json!({"naming": a.naming.as_deref().unwrap_or(""), "startingSerial": a.start_serial.unwrap_or(1)}),
)
.map_err(|error| CliError::Naming(error.to_string()))?;
let targets: Vec<PathBuf> = match named {
Some(paths) => paths.into_iter().map(PathBuf::from).collect(),
None => inputs
.iter()
.map(|input| {
let path = Path::new(&input.path);
let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.path), str::to_owned);
let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
out_dir.join(format!("{stem}.{ext}"))
})
.collect(),
};
let plan: Vec<_> = inputs.iter().zip(&targets).map(|(input, target)| (input.path.clone(), target.to_string_lossy().into_owned())).collect();
photocraft_engine::file_cmds::validate_flat_outputs(&plan).map_err(|error| CliError::Naming(error.to_string()))?;
if let Some(log) = &a.error_log {
let same = |path: &Path| photocraft_engine::file_cmds::same_destination(log, path);
if inputs.iter().any(|input| same(Path::new(&input.path))) || targets.iter().any(|target| same(target)) {
return Err(CliError::BatchOption("--error-log must not replace an input or batch output".into()));
}
}
std::fs::create_dir_all(out_dir)?;
let mut failures = Vec::new();
for (input, target) in inputs.iter().zip(targets) {
let input = Path::new(&input.path);
// The selected format encodes the bytes, whatever extension `--naming` gives the file.
// Without --format, a file PhotoCraft only opens (.kra, raw, …) is saved as PSD, the
// same format `--naming`'s {ext} plans.
let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.to_string_lossy()), str::to_owned);
let r = (|| -> Result<Vec<String>> {
written.check(&target_text).map_err(CliError::OutputClaimed)?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
@@ -101,17 +143,31 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
Err(e) => {
failed += 1;
let _ = writeln!(err, "FAIL {}: {e}", input.display());
failures.push((input.to_string_lossy().into_owned(), e.to_string()));
if a.stop_on_error {
break;
}
}
}
}
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
let skipped = inputs.len().saturating_sub(ok + failed);
if skipped > 0 {
let _ = writeln!(err, "--stop-on-error: {skipped} remaining input(s) skipped");
let _ = writeln!(out, "{ok} succeeded, {failed} failed, {skipped} skipped");
} else {
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
}
// After the summary, so a log that can't be written doesn't hide the counts.
if let Some(path) = &a.error_log {
let bytes = photocraft_engine::file_cmds::encode_batch_error_log(failures.iter().map(|(file, error)| (file.as_str(), error.as_str())));
photocraft_format::atomic_write(path, &bytes)?;
}
if failed > 0 { Err(CliError::FilesFailed(failed)) } else { Ok(()) }
}
+3 -2
View File
@@ -32,9 +32,10 @@ pub(crate) enum CliError {
OutIsInput(PathBuf),
#[error("--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", .0.display())]
OutIsIn(PathBuf),
/// Another input already wrote this output ([`photocraft_engine::file_cmds::OutputClaims::check`]).
#[error("--naming: {0}")]
Naming(String),
#[error("{0}")]
OutputClaimed(String),
BatchOption(String),
#[error("{0} file(s) failed")]
FilesFailed(usize),
}
+171 -12
View File
@@ -434,9 +434,25 @@ fn batch_applies_actions_to_directory() {
write_png(&inp.join("two.png"), 9, 9, 2);
std::fs::write(inp.join("notes.txt"), "ignored").unwrap();
let actions = d.join("actions.json");
let log = d.join("errors.txt");
std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])).stdout;
let out = ok(bin()
.args(["batch", "--actions"])
.arg(&actions)
.arg("--in")
.arg(&inp)
.arg("--out")
.arg(&outp)
.args(["--format", "pcraft", "--error-log"])
.arg(&log))
.stdout;
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
let clean_log = std::fs::read(&log).unwrap();
assert!(clean_log.starts_with(&[0xff, 0xfe]));
let (pairs, remainder) = clean_log[2..].as_chunks::<2>();
assert!(remainder.is_empty());
let body = String::from_utf16(&pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect::<Vec<_>>()).unwrap();
assert_eq!(body, "Batch Start\r\n\r\nBatch End\r\n");
for n in ["one", "two"] {
let info = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
@@ -450,6 +466,127 @@ fn batch_applies_actions_to_directory() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn batch_recursive_naming_stop_and_error_log() {
let root = tmp("batch-recursive-options");
let input = root.join("in");
std::fs::create_dir_all(input.join("sub")).unwrap();
for file in ["a.png", "sub/a.png", "sub/c.png"] {
write_png(&input.join(file), 8, 6, 3);
}
std::fs::write(input.join("sub/b.png"), b"not a png").unwrap();
let actions = root.join("actions.json");
std::fs::write(&actions, r#"[["image.imageRotation.90cw", {}]]"#).unwrap();
let output = root.join("out");
let log = root.join("errors.txt");
let run = |out: &Path, extra: &[&str]| {
bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(out).args(extra).output().unwrap()
};
let result = run(&output, &["--recursive", "--naming", "{name}-{serial:2}.{ext}", "--start-serial", "5", "--error-log", &log.to_string_lossy()]);
let stdout = String::from_utf8_lossy(&result.stdout);
assert!(!result.status.success(), "{stdout}");
assert!(stdout.contains("3 succeeded, 1 failed"), "{stdout}");
for file in ["a-05.png", "a-06.png", "c-08.png"] {
assert!(output.join(file).is_file(), "{file}: {stdout}");
}
let bytes = std::fs::read(log).unwrap();
assert!(bytes.starts_with(&[0xff, 0xfe]));
let (pairs, remainder) = bytes[2..].as_chunks::<2>();
assert!(remainder.is_empty());
let errors = String::from_utf16(&pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect::<Vec<_>>()).unwrap();
assert!(errors.contains("Batch Start\r\n") && errors.contains("b.png") && errors.ends_with("Batch End\r\n"), "{errors}");
let stopped = root.join("stopped");
let result = run(&stopped, &["--recursive", "--stop-on-error"]);
let stdout = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(!result.status.success(), "{stdout}");
assert!(stdout.contains("2 succeeded, 1 failed, 1 skipped"), "{stdout}");
assert!(stderr.contains("--stop-on-error"), "{stderr}");
assert!(!stopped.join("c.png").exists());
let collision = root.join("collision");
let alias = collision.join("../collision/a.png");
let result = run(&collision, &["--recursive", "--error-log", &alias.to_string_lossy()]);
assert!(!result.status.success());
assert!(String::from_utf8_lossy(&result.stderr).contains("--error-log"));
assert!(!collision.join("a.png").exists());
}
/// `{serial}` numbers a recursive batch in the engine's order (a folder's files before its
/// subfolders), `--format` encodes the bytes whatever extension `--naming` writes, and an
/// `--error-log` that can't be written still leaves the summary printed.
#[test]
fn batch_naming_keeps_the_tree_order_and_the_selected_encoder() {
let root = tmp("batch-naming-order");
let input = root.join("in");
std::fs::create_dir_all(input.join("sub")).unwrap();
write_png(&input.join("z.png"), 8, 6, 3);
write_png(&input.join("sub/a.png"), 5, 4, 3);
let actions = root.join("actions.json");
std::fs::write(&actions, "[]").unwrap();
let run = |out: &Path, extra: &[&str]| {
bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(out).args(extra).output().unwrap()
};
let png_width = |path: &Path| {
let bytes = std::fs::read(path).unwrap();
u32::from_be_bytes(bytes[16..20].try_into().unwrap())
};
let ordered = root.join("ordered");
let result = run(&ordered, &["--recursive", "--naming", "{serial:2}.{ext}"]);
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
assert_eq!(png_width(&ordered.join("01.png")), 8, "the root file comes first");
assert_eq!(png_width(&ordered.join("02.png")), 5);
let jpeg = root.join("jpeg");
let result = run(&jpeg, &["--format", "jpg", "--naming", "{name}_{serial}"]);
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
assert!(std::fs::read(jpeg.join("z_001")).unwrap().starts_with(&[0xff, 0xd8]), "JPEG bytes without an extension");
let png = root.join("png");
let result = run(&png, &["--format", "png", "--naming", "{name}.jpg"]);
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
assert!(std::fs::read(png.join("z.jpg")).unwrap().starts_with(b"\x89PNG"), "PNG bytes under a .jpg name");
let log = root.join("log-is-a-folder");
std::fs::create_dir_all(&log).unwrap();
let result = run(&root.join("logged"), &["--error-log", &log.to_string_lossy()]);
let stdout = String::from_utf8_lossy(&result.stdout);
assert!(!result.status.success(), "{stdout}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}");
std::fs::remove_dir_all(root).unwrap();
}
/// Without --format, a file PhotoCraft only opens is saved as PSD, with or without --naming:
/// the encoder gets the format the planned name uses, not the input's import-only extension.
#[test]
fn batch_saves_import_only_inputs_as_psd() {
let root = tmp("batch-import-only");
let input = root.join("in");
std::fs::create_dir_all(&input).unwrap();
std::fs::write(
input.join("logo.xpm"),
"/* XPM */
static char *logo[] = {
\"2 2 1 1\",
\"a c #FF0000\",
\"aa\",
\"aa\"
};
",
)
.unwrap();
let actions = root.join("actions.json");
std::fs::write(&actions, "[]").unwrap();
for (name, extra) in [("plain", &[][..]), ("named", &["--naming", "{name}.{ext}"][..])] {
let out = root.join(name);
let result = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out).args(extra).output().unwrap();
assert!(result.status.success(), "{name}: {}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr));
assert!(std::fs::read(out.join("logo.psd")).unwrap().starts_with(b"8BPS"), "{name}: PSD bytes");
assert!(!out.join("logo.xpm").exists(), "{name}");
}
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn batch_skips_recorded_view_and_save_steps() {
let d = tmp("batch-shell-steps");
@@ -804,6 +941,33 @@ fn batch_refuses_to_write_over_its_inputs() {
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
}
#[test]
fn batch_naming_rejects_unread_input_alias_through_a_missing_directory() {
let d = tmp("batch-missing-directory-alias");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
write_png(&input.join("b.png"), 8, 4, 7);
let original = std::fs::read(input.join("b.png")).unwrap();
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let output = bin()
.arg("batch")
.arg("--actions")
.arg(&actions)
.arg("--in")
.arg(&input)
.arg("--out")
.arg(input.join("missing/.."))
.args(["--in-place", "--naming", "b.{ext}"])
.output()
.unwrap();
assert!(!output.status.success(), "{}", String::from_utf8_lossy(&output.stdout));
assert!(String::from_utf8_lossy(&output.stderr).contains("replace an input"));
assert_eq!(std::fs::read(input.join("b.png")).unwrap(), original);
assert!(!input.join("missing").exists());
}
/// #7437: `convert` to its own input file, however the output is spelt, is refused before
/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492).
#[test]
@@ -944,9 +1108,9 @@ fn mcp_bridge_takes_a_port_or_an_address() {
let _ = child.wait();
}
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
/// Photoshop Batch processes every input in order, replacing earlier flat results with the same name.
#[test]
fn batch_fails_inputs_that_share_an_output_name() {
fn batch_later_input_replaces_a_same_named_output() {
let d = tmp("batch-collide");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
@@ -961,15 +1125,10 @@ fn batch_fails_inputs_that_share_an_output_name() {
let out_dir = d.join("out");
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
assert!(
!o.status.success(),
"a lost result must fail the run
{out}
{err}"
);
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
assert_eq!(err.matches("not written").count(), 2, "{err}");
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
assert!(o.status.success(), "{out}{err}");
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
assert!(out_dir.join("a.png").is_file());
assert!(std::fs::read_dir(&out_dir).unwrap().count() <= 2);
// Keeping each file's format, every input gets its own output.
let out_dir = d.join("same");
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir)).stdout;
+1 -1
View File
@@ -685,7 +685,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
});
let automation_save = automation.clone().map(|workspace| {
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
save_through(doc, path, settings, ctl, |p, encoded, _| workspace.write(p, &encoded.bytes().map_err(|e| e.to_string())?).map_err(|e| e.to_string()))
save_through(doc, path, settings, ctl, |p, encoded, _| workspace.write_stream(p, &mut |w| encoded.write_to(w)).map_err(|e| e.to_string()))
}) as photocraft_ui_egui::SaveFileFn
});
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
+26 -5
View File
@@ -227,18 +227,39 @@ async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Res
if v.get("id").and_then(Value::as_u64) != Some(id) {
continue; // stale reply from an earlier, timed-out request
}
return Ok(if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_owned()))
});
return Ok(reply_result(&v));
}
}
/// An app reply as the caller's result. A failed reply can still carry a `result`: a cancelled
/// Batch lists the outputs it already wrote, so the error keeps it rather than dropping it.
fn reply_result(v: &Value) -> Result<Value, AutomationError> {
if v.get("ok").and_then(Value::as_bool) == Some(true) {
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
}
let error = v.get("error").and_then(Value::as_str).unwrap_or("unknown error");
Err(AutomationError::App(match v.get("result") {
Some(result) if !result.is_null() => format!("{error}; partial result: {result}"),
_ => error.to_owned(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_failed_reply_keeps_its_partial_result() {
assert_eq!(reply_result(&json!({"ok": true, "result": {"files": ["a.png"]}})).unwrap(), json!({"files": ["a.png"]}));
let Err(AutomationError::App(message)) = reply_result(&json!({"ok": false, "error": "cancelled", "result": {"files": ["a.png"], "cancelled": true}}))
else {
panic!("a failed reply is an app error")
};
assert!(message.starts_with("cancelled; partial result: ") && message.contains("a.png"), "{message}");
let Err(AutomationError::App(message)) = reply_result(&json!({"ok": false, "error": "cancelled"})) else { panic!("a failed reply is an app error") };
assert_eq!(message, "cancelled");
}
#[test]
fn the_call_timeout_stays_below_the_app_reply_deadline() {
// At or above the app's 60s deadline, callers would see their own cutoff first.
+5 -3
View File
@@ -369,9 +369,11 @@ pub struct UiSetParams {
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name", "mode",
/// "color", "smartObject" or "artboard", which lists the artboards that hold the selected
/// layers), kinds (a list of layer kinds), name (matches part of the layer name), mode (a blend
/// mode name), color (a layer colour label, or "none") and smart (a list of "linked", "missing"
/// and "embedded") for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
/// over this channel). updatesOpen (bool, the Help › Check for Updates window).
/// histogramPanel (an object patching view ("compact", "expanded" or "allChannels"), channel
+29 -3
View File
@@ -87,6 +87,13 @@ impl AuthorizedWorkspace {
/// resolution and file creation relative to the held directory handle, so
/// a non-existent final target is supported without ambient path access.
pub fn write(&self, path: &str, bytes: &[u8]) -> Result<(), AutomationError> {
self.write_stream(path, &mut |w| w.write_all(bytes))
}
/// [`Self::write`] with the file's bytes produced by `write` straight into the temporary
/// file (it may seek back to patch lengths), so a large PSD/PSB is never held in memory
/// whole (#5333). The same refusals, the same atomic replace.
pub fn write_stream(&self, path: &str, write: &mut photocraft_format::atomic::StreamWriter<'_>) -> Result<(), AutomationError> {
let relative = request_path(path, self.write.as_ref())?;
let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?;
let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
@@ -110,9 +117,13 @@ impl AuthorizedWorkspace {
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
let written = root.dir.open_with(&tmp, &options).and_then(|file| {
let opened = root.dir.open_with(&tmp, &options);
// Removes the temporary file on every way out but a rename, a panicking writer included
// (unwinding closes the handle inside the closure first), and only one this call made.
let mut cleanup = TempRemoval { dir: &root.dir, path: &tmp, armed: opened.is_ok() };
let written = opened.and_then(|file| {
let mut file = file.into_std();
file.write_all(bytes)?;
write(&mut file)?;
#[cfg(unix)]
{
use std::os::fd::AsFd;
@@ -130,9 +141,9 @@ impl AuthorizedWorkspace {
});
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
if let Err(e) = renamed {
let _ = root.dir.remove_file(&tmp);
return Err(file_error("write", path, e));
}
cleanup.armed = false;
// Flush the directory entry (Unix); best effort, the new bytes are already in place.
#[cfg(unix)]
{
@@ -145,6 +156,21 @@ impl AuthorizedWorkspace {
}
}
/// Removes a temporary file on drop unless `armed` was cleared after its rename.
struct TempRemoval<'a> {
dir: &'a Dir,
path: &'a Path,
armed: bool,
}
impl Drop for TempRemoval<'_> {
fn drop(&mut self) {
if self.armed {
let _ = self.dir.remove_file(self.path);
}
}
}
/// Whether `name`'s extension is one of the formats PhotoCraft saves or exports.
fn written_kind(name: &str) -> bool {
let Some((_, ext)) = name.rsplit_once('.') else { return false };
+46
View File
@@ -122,6 +122,52 @@ fn write_replaces_atomically_and_leaves_no_temp_file() {
assert!(temp_files(&inside).is_empty());
}
/// #5333: a streamed write (a PSD/PSB serialized as it goes, lengths patched by seeking back)
/// replaces the file the same way; a writer that fails leaves the original and no temp file.
#[test]
fn write_stream_seeks_back_and_keeps_the_original_on_failure() {
use std::io::SeekFrom;
let (inside, _, workspace) = roots("stream");
workspace.write("big.psb", b"original").unwrap();
workspace
.write_stream("big.psb", &mut |w| {
w.write_all(b"....body")?;
w.seek(SeekFrom::Start(0))?;
w.write_all(b"head")?;
w.seek(SeekFrom::End(0))?;
Ok(())
})
.unwrap();
assert_eq!(std::fs::read(inside.join("big.psb")).unwrap(), b"headbody");
let failed = workspace.write_stream("big.psb", &mut |w| {
w.write_all(b"partial")?;
Err(std::io::Error::other("encoder failed"))
});
assert!(failed.is_err());
assert_eq!(std::fs::read(inside.join("big.psb")).unwrap(), b"headbody");
assert!(temp_files(&inside).is_empty());
// The same refusals as a plain write.
std::fs::write(inside.join("notes.txt"), b"keep").unwrap();
assert!(workspace.write_stream("notes.txt", &mut |w| w.write_all(b"x")).is_err());
assert_eq!(std::fs::read(inside.join("notes.txt")).unwrap(), b"keep");
}
/// A writer that panics leaves the original and no temporary file behind.
#[test]
fn write_stream_cleans_up_after_a_panicking_writer() {
let (inside, _, workspace) = roots("stream-panic");
workspace.write("big.psb", b"original").unwrap();
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = workspace.write_stream("big.psb", &mut |w| {
w.write_all(b"partial")?;
panic!("encoder bug")
});
}));
assert!(unwound.is_err());
assert_eq!(std::fs::read(inside.join("big.psb")).unwrap(), b"original");
assert!(temp_files(&inside).is_empty());
}
/// A directory at the target is refused before anything is opened, even under an image name.
#[test]
fn write_refuses_a_directory_target_before_opening_it() {
+63 -16
View File
@@ -25,56 +25,69 @@ pub fn preview_page(doc: &Document, source: Rect, factor: u32, interrupt: Interr
if rect.width() > 1024 || rect.height() > 1024 {
return Err("viewport page is too large".into());
}
fn shrink(s: &Surface, r: Rect, k: i32, i: Interrupt<'_>) -> Result<Surface, String> {
// Cell `c` samples source pixel `c·k + k/2`, clamped to the last document pixel: a partial
// cell at the document's edge samples inside it, not the layer's default past it. (Document
// bounds, not this page's, so neighbouring pages agree.)
let bounds = doc.bounds();
let last = (bounds.x1 - 1, bounds.y1 - 1);
fn shrink(s: &Surface, r: Rect, k: i32, last: (i32, i32), i: Interrupt<'_>) -> Result<Surface, String> {
let mut out = Surface::with_default(s.format(), &s.default_pixel());
let bpp = s.format().bytes_per_pixel();
let x0 = r.x0.saturating_mul(k);
for y in r.y0..r.y1 {
i.check().map_err(|e| e.to_string())?;
let sy = y.saturating_mul(k).saturating_add(k / 2);
let row = s.to_interleaved(Rect::new(r.x0.saturating_mul(k), sy, r.x1.saturating_mul(k), sy.saturating_add(1)));
let sy = y.saturating_mul(k).saturating_add(k / 2).min(last.1);
let row = s.to_interleaved(Rect::new(x0, sy, r.x1.saturating_mul(k).min(last.0 + 1), sy.saturating_add(1)));
let mut reduced = Vec::with_capacity(r.width() as usize * bpp);
for x in 0..r.width() as usize {
let offset = (x * k as usize + k as usize / 2) * bpp;
for x in r.x0..r.x1 {
let sx = x.saturating_mul(k).saturating_add(k / 2).min(last.0);
let offset = (sx - x0) as usize * bpp;
reduced.extend_from_slice(row.get(offset..offset + bpp).ok_or("invalid viewport sample")?);
}
out.write_interleaved(Rect::new(r.x0, y, r.x1, y + 1), &reduced);
}
Ok(out)
}
fn layer(l: &mut photocraft_doc::Layer, r: Rect, k: i32, i: Interrupt<'_>, depth: usize) -> Result<(), String> {
fn layer(l: &mut photocraft_doc::Layer, r: Rect, k: i32, i: Interrupt<'_>, depth: usize, last: (i32, i32)) -> Result<(), String> {
if depth > 256 {
return Err("viewport layer nesting limit exceeded".into());
}
if let Some(m) = &mut l.mask {
m.surface = shrink(&m.surface, r, k, i)?;
m.surface = shrink(&m.surface, r, k, last, i)?;
}
if let Some(c) = &mut l.fill_cache {
c.surface = shrink(&c.surface, r, k, i)?;
c.surface = shrink(&c.surface, r, k, last, i)?;
}
use photocraft_doc::LayerContent;
match &mut l.content {
LayerContent::Raster(s) => *s = shrink(s, r, k, i)?,
LayerContent::Raster(s) => *s = shrink(s, r, k, last, i)?,
LayerContent::Group(g) => {
if let Some(a) = &mut g.artboard {
a.rect = Rect::new(a.rect.x0.div_euclid(k), a.rect.y0.div_euclid(k), a.rect.x1.div_euclid(k), a.rect.y1.div_euclid(k));
// A cell is on the board when its sample `c·k + k/2` is: each edge maps to the
// first cell sampling at or after it.
let edge = |e: i32| {
let (s, k) = (i64::from(e) - i64::from(k / 2), i64::from(k));
(s.div_euclid(k) + i64::from(s.rem_euclid(k) != 0)) as i32
};
a.rect = Rect::new(edge(a.rect.x0), edge(a.rect.y0), edge(a.rect.x1), edge(a.rect.y1));
}
for c in &mut g.children {
layer(c, r, k, i, depth + 1)?;
layer(c, r, k, i, depth + 1, last)?;
}
}
LayerContent::Text(t) => {
if let Some(s) = &mut t.cache {
*s = shrink(s, r, k, i)?;
*s = shrink(s, r, k, last, i)?;
}
}
LayerContent::Shape(t) => {
if let Some(s) = &mut t.cache {
*s = shrink(s, r, k, i)?;
*s = shrink(s, r, k, last, i)?;
}
}
LayerContent::Smart(t) => {
if let Some(s) = &mut t.cache {
*s = shrink(s, r, k, i)?;
*s = shrink(s, r, k, last, i)?;
}
}
_ => {}
@@ -84,10 +97,10 @@ pub fn preview_page(doc: &Document, source: Rect, factor: u32, interrupt: Interr
let mut p = doc.clone();
p.size = photocraft_doc::Size::new(doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor));
for l in &mut p.layers {
layer(l, rect, k, interrupt, 0)?;
layer(l, rect, k, interrupt, 0, last)?;
}
for c in &mut p.channels {
c.surface = shrink(&c.surface, rect, k, interrupt)?;
c.surface = shrink(&c.surface, rect, k, last, interrupt)?;
}
interrupt.check().map_err(|e| e.to_string())?;
Ok(render(&p, rect))
@@ -200,6 +213,40 @@ mod tests {
}
}
}
/// A partial cell at the document's right and bottom edges samples the last document pixel,
/// not the layer's (transparent) default past it (#5332 review).
#[test]
fn preview_edge_cells_sample_inside_the_document() {
let mut d = Document::new("edge", Size::new(10, 10), ColorMode::Rgb, SampleType::U8);
let mut s = photocraft_raster::Surface::new(d.pixel_format());
s.fill_rect(d.bounds(), &[1.0, 0.0, 0.0, 1.0]);
d.layers.push(photocraft_doc::Layer::new("red", photocraft_doc::LayerContent::Raster(s)));
for source in [d.bounds(), Rect::new(4, 4, 10, 10)] {
let p = preview_page(&d, source, 4, Interrupt::NONE).unwrap();
assert_eq!(p.rect, Rect::new(source.x0 / 4, source.y0 / 4, 3, 3));
for (i, px) in p.px.iter().enumerate() {
assert!((px[0] - 1.0).abs() < 1e-6 && (px[3] - 1.0).abs() < 1e-6, "{source:?} cell {i}: {px:?}");
}
}
}
/// An artboard covers the preview cells whose sample (`c·k + k/2`) lies on the board, so a
/// narrow board, or one whose edges fall mid-cell, keeps exactly those cells (#5332 review).
#[test]
fn preview_artboard_covers_the_cells_it_samples() {
let k = 4;
// (board x0..x1, cells expected on it); cell c samples x = 4c + 2.
for (x0, x1, on) in [(2, 3, 0..1), (0, 3, 0..1), (3, 16, 1..4), (1, 7, 0..2), (4, 14, 1..3), (-6, 6, 0..1), (-6, 7, 0..2)] {
let mut d = Document::new("boards", Size::new(16, 4), ColorMode::Rgb, SampleType::U8);
let board = photocraft_doc::Artboard::new(Rect::new(x0, 0, x1, 4));
let group = photocraft_doc::Group { children: vec![], expanded: true, artboard: Some(board), frame_stroke: None };
d.layers.push(photocraft_doc::Layer::new("board", photocraft_doc::LayerContent::Group(group)));
let p = preview_page(&d, d.bounds(), k, Interrupt::NONE).unwrap();
assert_eq!(p.rect, Rect::new(0, 0, 4, 1));
for (c, px) in p.px.iter().enumerate() {
assert_eq!(px[3] > 0.5, on.contains(&c), "board {x0}..{x1}, cell {c}: {px:?}");
}
}
}
/// A white document with a noisy, partly transparent layer and a multiplied solid over one-colour
/// tiles, so the reduced path takes both its one-colour and per-pixel branches.
fn busy(depth: SampleType) -> Document {
+148
View File
@@ -0,0 +1,148 @@
//! Document-independent recording (#219).
//!
//! The journal is what Actions record, so an entry must mean the same thing on another document.
//! The UI names the layer it clicked by id (`"layer": 7`); ids are per document, so a step kept
//! that way replays only on the document it came from. Before an entry is journaled:
//!
//! - a `layer` that was the active (and only selected) layer when the command ran is dropped:
//! the step acts on the active layer, as Photoshop's "current layer" does;
//! - any other `layer` id becomes the layer's name when the name is unique in the document, and
//! `layer.select` always does (selecting is choosing a layer by name, as Photoshop records
//! it); an ambiguous name keeps the id, which still replays on the recording document;
//! - the Type tool's keystrokes fold into the `type.create` that made the layer. On an existing
//! layer, each replacement stays separate so replay keeps style runs between edited spans.
//!
//! Ids are still accepted everywhere; only what the journal keeps changes.
use std::sync::Arc;
use photocraft_doc::{DocId, Document, LayerContent, LayerId};
use serde_json::Value;
use crate::Session;
/// The active document as it was before a command ran, captured by the dispatcher.
#[derive(Clone, Default)]
pub(crate) struct Before {
doc: Option<Arc<Document>>,
active_layer: Option<LayerId>,
/// Every selected layer: a `layer` that is one of several stays explicit, because without it
/// a layer command acts on the whole selection ([`crate::layer_multi_cmds::targets`]).
selected: Vec<LayerId>,
}
impl Before {
pub(crate) fn capture(s: &Session) -> Self {
match s.active() {
Some(st) => Self { doc: Some(st.doc.clone()), active_layer: st.active_layer, selected: st.selected_layers() },
None => Self::default(),
}
}
}
/// Commands whose explicit `layer` names source content rather than a default target.
pub(crate) fn needs_source_layer(id: &str) -> bool {
matches!(id, "layer.new.frame" | "select.loadSelection" | "type.createSelection" | "edit.checkSpelling")
}
/// `params` with its `layer` id made portable (see the module doc).
pub(crate) fn portable(s: &Session, id: &str, mut params: Value, before: &Before) -> Value {
if id == "document.activate"
&& let (Some(from), Some(to)) = (before.doc.as_ref().map(|doc| doc.id), s.active().map(|state| state.doc.id))
&& let Some(offset) = s.document_offset_between(from, to)
{
return serde_json::json!({"offset": offset});
}
let Some(doc) = before.doc.as_deref() else { return params };
if id == "type.create" && s.actions.recording.is_some() && params.get("box").is_none() && doc.size.width > 0 && doc.size.height > 0 {
let x = params.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let y = params.get("y").and_then(Value::as_f64).unwrap_or(0.0);
let x_percent = x / f64::from(doc.size.width) * 100.0;
let y_percent = y / f64::from(doc.size.height) * 100.0;
if x_percent.is_finite()
&& y_percent.is_finite()
&& let Some(object) = params.as_object_mut()
{
object.remove("x");
object.remove("y");
object.insert("xPercent".into(), serde_json::json!(x_percent));
object.insert("yPercent".into(), serde_json::json!(y_percent));
}
}
let Some(target) = params.get("layer").and_then(Value::as_u64).map(LayerId) else { return params };
let Some(obj) = params.as_object_mut() else { return params };
let only_selected = before.selected.len() <= 1;
if id != "layer.select" && !needs_source_layer(id) && before.active_layer == Some(target) && only_selected {
obj.remove("layer");
return params;
}
if let Some(layer) = doc.layer(target)
&& !(id == "select.loadSelection" && (layer.name.is_empty() || layer.name == "merged"))
&& doc.walk().iter().filter(|(_, _, l)| l.name == layer.name).count() == 1
{
obj.insert("layer".into(), Value::String(layer.name.clone()));
}
params
}
/// The text layer a journaled `type.create` made or `type.edit` changed, so the keystrokes that
/// follow fold into that entry only when they type into the same layer (the Type tool selects
/// a clicked layer without a command, so the journal alone cannot tell).
pub(crate) fn typed_layer(s: &Session, id: &str, params: &Value) -> Option<(DocId, LayerId)> {
let doc_id = s.active()?.doc.id;
match id {
"type.create" => s.active()?.active_layer.map(|layer| (doc_id, layer)),
"type.edit" => crate::commands::layer_param(s, params).ok().map(|layer| (doc_id, layer)),
_ => None,
}
}
/// What the journal keeps of a Type-tool keystroke.
pub(crate) enum Keystroke {
/// Not a keystroke: the entry is journaled as it is.
None,
/// Folded into the entry before it.
Folded,
}
/// Fold a Type-tool keystroke into its creation step only. A whole-text step on an existing
/// layer can collapse distinct style runs between separate edited spans on replay (#4514).
/// `previous` is the layer the last entry typed into.
pub(crate) fn fold_keystroke(s: &mut Session, id: &str, params: &Value, previous: Option<(DocId, LayerId)>) -> Keystroke {
if id != "type.edit" {
return Keystroke::None;
}
let Some(obj) = params.as_object() else { return Keystroke::None };
if obj.get("coalesce").and_then(Value::as_str).is_none() {
return Keystroke::None;
}
if !obj.contains_key("replace") || !obj.keys().all(|k| matches!(k.as_str(), "layer" | "replace" | "coalesce")) {
return Keystroke::None;
}
let Ok(target) = crate::commands::layer_param(s, params) else { return Keystroke::None };
// The text as it is now, after this keystroke: the step sets it whole.
let Some(text) = s.active().and_then(|st| st.doc.layer(target)).and_then(|l| match &l.content {
LayerContent::Text(t) => Some(t.text.clone()),
_ => None,
}) else {
return Keystroke::None;
};
if previous != s.active().map(|st| (st.doc.id, target)) {
return Keystroke::None;
}
// A new recording starts at a journal boundary. Its first edit cannot change an entry
// before that boundary, or actions.stop would miss the edit altogether.
if s.actions.recording.is_some_and(|(start, _)| s.journal.len() <= start) {
return Keystroke::None;
}
let Some((last, lp)) = s.journal.last_mut() else { return Keystroke::None };
if last != "type.create" || obj.contains_key("layer") {
return Keystroke::None;
}
let Some(lo) = lp.as_object_mut() else { return Keystroke::None };
lo.insert("text".into(), Value::String(text));
Keystroke::Folded
}
#[cfg(test)]
mod tests;
+380
View File
@@ -0,0 +1,380 @@
use photocraft_doc::LayerId;
use serde_json::{Value, json};
use crate::jobs::Started;
use crate::{EngineError, Session};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
s
}
fn new_layer(s: &mut Session, name: &str) -> u64 {
s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap()
}
fn last(s: &Session) -> &(String, Value) {
s.journal.last().unwrap()
}
fn background(s: &Session) -> u64 {
s.active().unwrap().doc.layers[0].id.0
}
#[test]
fn the_active_layer_is_implicit() {
let mut s = session();
let shade = new_layer(&mut s, "Shade");
s.execute("layer.setProps", json!({"layer": shade, "opacity": 0.5})).unwrap();
assert_eq!(last(&s), &("layer.setProps".into(), json!({"opacity": 0.5})), "the active layer's id is dropped");
assert_eq!(s.active().unwrap().doc.layer(LayerId(shade)).unwrap().opacity, 0.5, "the command still ran on it");
}
#[test]
fn another_layer_is_named() {
let mut s = session();
let bg = background(&s);
new_layer(&mut s, "Shade");
s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap();
assert_eq!(last(&s), &("layer.setProps".into(), json!({"layer": "Background", "visible": false})));
}
#[test]
fn selecting_a_layer_is_always_by_name() {
let mut s = session();
let shade = new_layer(&mut s, "Shade");
s.execute("layer.select", json!({"layer": shade})).unwrap();
assert_eq!(last(&s), &("layer.select".into(), json!({"layer": "Shade"})), "even though it was the active layer");
s.execute("layer.select", json!({"layer": shade, "mode": "toggle"})).unwrap();
assert_eq!(last(&s).1["mode"], "toggle", "the other params are kept");
}
#[test]
fn an_ambiguous_name_keeps_the_id() {
let mut s = session();
let a = new_layer(&mut s, "Copy");
let b = new_layer(&mut s, "Copy");
s.execute("layer.setProps", json!({"layer": a, "opacity": 0.5})).unwrap();
assert_eq!(last(&s), &("layer.setProps".into(), json!({"layer": a, "opacity": 0.5})), "two layers are called Copy");
s.execute("layer.setProps", json!({"layer": b, "opacity": 0.5})).unwrap();
assert_eq!(last(&s).1, json!({"opacity": 0.5}), "the active one is still implicit");
}
#[test]
fn one_of_several_selected_layers_stays_named() {
let mut s = session();
let bg = background(&s);
let shade = new_layer(&mut s, "Shade");
s.execute("layer.select", json!({"layer": bg, "mode": "add"})).unwrap();
assert_eq!(s.active().unwrap().selected_layers().len(), 2);
let active = s.active().unwrap().active_layer.unwrap().0;
let name = if active == shade { "Shade" } else { "Background" };
s.execute("layer.setProps", json!({"layer": active, "opacity": 0.5})).unwrap();
assert_eq!(last(&s).1, json!({"layer": name, "opacity": 0.5}), "without the layer, a layer command acts on the whole selection");
}
#[test]
fn a_name_or_no_document_is_left_alone() {
let mut s = session();
new_layer(&mut s, "Shade");
s.execute("layer.setProps", json!({"layer": "Background", "visible": false})).unwrap();
assert_eq!(last(&s).1, json!({"layer": "Background", "visible": false}));
let e = s.execute("layer.setProps", json!({"layer": 999, "visible": false})).unwrap_err();
assert!(matches!(e, EngineError::UnknownLayer { .. }), "{e}");
}
#[test]
fn a_background_job_journals_the_same_way() {
let mut s = session();
let shade = new_layer(&mut s, "Shade");
let id = match s.start("filter.blur.gaussianBlur", json!({"layer": shade, "radius": 2})).unwrap() {
Started::Job(id) => id,
Started::Done(v) => panic!("ran inline: {v}"),
};
s.wait_job(id).unwrap();
assert_eq!(last(&s), &("filter.blur.gaussianBlur".into(), json!({"radius": 2})));
}
#[test]
fn frame_action_keeps_the_layer_it_wraps() {
let mut s = session();
let picture = new_layer(&mut s, "Picture");
s.execute("actions.record", json!({"name": "Frame picture"})).unwrap();
s.execute("layer.new.frame", json!({"x": 1, "y": 1, "width": 20, "height": 20, "layer": picture})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let steps = s.execute("actions.get", json!({"action": "Frame picture"})).unwrap()["steps"].clone();
assert_eq!(steps[0][1]["layer"], "Picture", "the frame must wrap the recorded source layer");
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
new_layer(&mut s, "Spare");
let target = new_layer(&mut s, "Picture");
assert_ne!(target, picture, "the replay target has a different document-local id");
let played = s.execute("actions.play", json!({"action": "Frame picture"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert!(
s.active().unwrap().doc.walk().iter().any(
|(_, _, layer)| matches!(&layer.content, photocraft_doc::LayerContent::Group(group) if group.children.iter().any(|child| child.id.0 == target))
),
"the replayed frame wraps Picture"
);
}
#[test]
fn selection_actions_keep_the_active_layer_as_their_source() {
let mut s = session();
let raster = new_layer(&mut s, "Pixels");
s.execute("actions.record", json!({"name": "Load pixels"})).unwrap();
s.execute("select.loadSelection", json!({"channel": "transparency", "layer": raster})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let steps = s.execute("actions.get", json!({"action": "Load pixels"})).unwrap()["steps"].clone();
assert_eq!(steps[0][1]["layer"], json!({"name": "Pixels"}), "missing layer loads merged pixels instead");
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
new_layer(&mut s, "Pixels");
let played = s.execute("actions.play", json!({"action": "Load pixels"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(
s.active().unwrap().doc.selection.as_ref().map(|mask| mask.content_bounds()).unwrap_or(photocraft_geom::Rect::EMPTY),
photocraft_geom::Rect::EMPTY,
"the empty source layer must not load the opaque merged document"
);
let type_id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap()["layer"].as_u64().unwrap();
let type_name = s.active().unwrap().doc.layer(LayerId(type_id)).unwrap().name.clone();
s.execute("actions.record", json!({"name": "Mask text"})).unwrap();
s.execute("type.createSelection", json!({"layer": type_id, "removeLayer": true})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let steps = s.execute("actions.get", json!({"action": "Mask text"})).unwrap()["steps"].clone();
assert_eq!(steps[0][1]["layer"], type_name, "without the source, replay cannot remove that type layer");
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
new_layer(&mut s, "Spare");
let replay_type = s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap()["layer"].as_u64().unwrap();
assert_ne!(replay_type, type_id);
let played = s.execute("actions.play", json!({"action": "Mask text"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert!(s.active().unwrap().doc.selection.as_ref().is_some_and(|mask| !mask.content_bounds().is_empty()), "replay selects the glyphs");
assert!(s.active().unwrap().doc.layer(LayerId(replay_type)).is_none(), "replay removed the named source type layer");
}
#[test]
fn named_nonactive_smart_layer_passes_admission_and_converts_to_layers() {
let mut s = session();
s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap();
let smart = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap();
let name = s.active().unwrap().doc.layer(LayerId(smart)).unwrap().name.clone();
let top = new_layer(&mut s, "Top");
let restored = s.execute("layer.smartObjects.convertToLayers", json!({"layer": name})).unwrap()["layer"].as_u64().unwrap();
assert!(s.active().unwrap().doc.layer(LayerId(smart)).is_none());
assert!(s.active().unwrap().doc.layer(LayerId(top)).is_some());
assert!(s.active().unwrap().doc.layer(LayerId(restored)).is_some(), "the restored layer is present");
let error = s.execute("layer.smartObjects.convertToLayers", json!({"layer": "Removed smart"})).unwrap_err();
assert!(error.to_string().contains("Removed smart"), "missing named layer should be identified: {error}");
}
#[test]
fn named_filter_mask_target_uses_the_recorded_smart_layer() {
let mut s = session();
s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap();
let smart = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap();
let name = s.active().unwrap().doc.layer(LayerId(smart)).unwrap().name.clone();
new_layer(&mut s, "Top");
assert_eq!(crate::channel_cmds::filter_mask_layer(&s, "edit.fill", &json!({"target": "filterMask", "layer": name})), Some(LayerId(smart)));
}
#[test]
fn named_nonactive_layer_is_the_one_converted_to_smart() {
let mut s = session();
let target = s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap()["layer"].as_u64().unwrap();
let name = s.active().unwrap().doc.layer(LayerId(target)).unwrap().name.clone();
new_layer(&mut s, "Top");
s.execute("actions.record", json!({"name": "Convert source"})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": target})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let steps = s.execute("actions.get", json!({"action": "Convert source"})).unwrap()["steps"].clone();
assert_eq!(steps[0][1]["layer"], name, "the journal names the explicit source");
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
let replay_target = s.execute("type.create", json!({"x": 2, "y": 20, "text": "I", "size": 12})).unwrap()["layer"].as_u64().unwrap();
let top = new_layer(&mut s, "Top");
let played = s.execute("actions.play", json!({"action": "Convert source"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert!(s.active().unwrap().doc.layer(LayerId(replay_target)).is_none(), "replay removes the named source");
assert!(s.active().unwrap().doc.layer(LayerId(top)).is_some(), "replay keeps the selected top layer");
assert!(
s.active().unwrap().doc.walk().iter().any(|(_, _, layer)| matches!(layer.content, photocraft_doc::LayerContent::Smart(_))),
"replay creates a smart object"
);
}
#[test]
fn merged_named_layer_keeps_its_id_as_a_selection_source() {
let mut s = session();
let source = new_layer(&mut s, "merged");
new_layer(&mut s, "Top");
s.execute("select.loadSelection", json!({"channel": "transparency", "layer": source})).unwrap();
assert_eq!(last(&s).1["layer"], source, "the reserved name must not mean the merged document");
}
#[test]
fn keystrokes_fold_into_the_type_layer_they_made() {
let mut s = session();
let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "", "size": 12})).unwrap()["layer"].as_u64().unwrap();
let n = s.journal.len();
for (i, ch) in "Hey".chars().enumerate() {
s.execute("type.edit", json!({"layer": id, "replace": {"start": i, "end": i, "text": ch.to_string()}, "coalesce": "t1"})).unwrap();
}
// Backspace, then more.
s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 3, "text": ""}, "coalesce": "t1"})).unwrap();
s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 2, "text": "llo"}, "coalesce": "t1"})).unwrap();
assert_eq!(s.journal.len(), n, "every keystroke folded into Make Type Layer");
let (cmd, p) = last(&s);
assert_eq!(cmd, "type.create");
assert_eq!(p["text"], "Hello");
assert!(p.get("replace").is_none() && p.get("coalesce").is_none(), "{p}");
}
#[test]
fn keystrokes_in_an_existing_layer_keep_separate_replacements() {
let mut s = session();
let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "Hi", "size": 12})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"opacity": 0.9})).unwrap();
let n = s.journal.len();
s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 2, "text": "!"}, "coalesce": "t2"})).unwrap();
s.execute("type.edit", json!({"layer": id, "replace": {"start": 0, "end": 1, "text": "h"}, "coalesce": "t2"})).unwrap();
assert_eq!(s.journal.len(), n + 2);
assert_eq!(s.journal[n].1, json!({"replace": {"start": 2, "end": 2, "text": "!"}, "coalesce": "t2"}));
assert_eq!(last(&s).1, json!({"replace": {"start": 0, "end": 1, "text": "h"}, "coalesce": "t2"}));
// A new editing session starts a new step.
s.execute("type.edit", json!({"layer": id, "replace": {"start": 3, "end": 3, "text": "?"}, "coalesce": "t3"})).unwrap();
assert_eq!(s.journal.len(), n + 3);
assert_eq!(last(&s).1, json!({"replace": {"start": 3, "end": 3, "text": "?"}, "coalesce": "t3"}));
}
#[test]
fn action_replay_preserves_styles_between_separate_typing_edits() {
let mut s = session();
let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "a M z", "size": 12})).unwrap()["layer"].as_u64().unwrap();
s.execute("type.edit", json!({"layer": id, "runs": [{"start": 2, "end": 3, "size": 24}]})).unwrap();
let replay = s.active().unwrap().doc.as_ref().clone();
s.execute("actions.record", json!({"name": "Two typed edits"})).unwrap();
s.execute("type.edit", json!({"layer": id, "replace": {"start": 0, "end": 1, "text": "A"}, "coalesce": "typing"})).unwrap();
s.execute("type.edit", json!({"layer": id, "replace": {"start": 4, "end": 5, "text": "Z"}, "coalesce": "typing"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let original = s.active().unwrap().doc.layer(LayerId(id)).unwrap();
let photocraft_doc::LayerContent::Text(original) = &original.content else { panic!("type layer") };
let original = original.clone();
assert_eq!(original.text, "A M Z");
s.add_document(replay, None);
let played = s.execute("actions.play", json!({"action": "Two typed edits"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let copy = s.active().unwrap().doc.layer(LayerId(id)).unwrap();
let photocraft_doc::LayerContent::Text(copy) = &copy.content else { panic!("type layer") };
assert_eq!(copy.text, original.text);
assert_eq!(copy.char_runs(), original.char_runs(), "the unchanged middle run keeps its style");
}
#[test]
fn recording_does_not_fold_into_a_step_from_before_recording_started() {
let mut s = session();
let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "Hi", "size": 12})).unwrap()["layer"].as_u64().unwrap();
s.execute("actions.record", json!({"name": "Type more"})).unwrap();
s.execute("type.edit", json!({"layer": id, "replace": {"start": 2, "end": 2, "text": "!"}, "coalesce": "t2"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let steps = s.execute("actions.get", json!({"action": "Type more"})).unwrap()["steps"].clone();
assert_eq!(steps, json!([["type.edit", {"replace": {"start": 2, "end": 2, "text": "!"}, "coalesce": "t2"}]]));
}
#[test]
fn keystrokes_into_another_layer_do_not_fold() {
let mut s = session();
let first = s.execute("type.create", json!({"x": 2, "y": 10, "text": "One", "size": 12})).unwrap()["layer"].as_u64().unwrap();
let second = s.execute("type.create", json!({"x": 2, "y": 20, "text": "", "size": 12})).unwrap()["layer"].as_u64().unwrap();
// The Type tool selects a clicked layer without a command.
s.select_layer(LayerId(first)).unwrap();
let n = s.journal.len();
s.execute("type.edit", json!({"layer": first, "replace": {"start": 3, "end": 3, "text": "!"}, "coalesce": "t4"})).unwrap();
assert_eq!(s.journal.len(), n + 1, "typing into the first layer is its own step");
assert_eq!(last(&s).1, json!({"replace": {"start": 3, "end": 3, "text": "!"}, "coalesce": "t4"}));
assert_eq!(s.journal[n - 1].1["text"], "", "the second Make Type Layer keeps its own text");
// Programmatic edits (no session key) and other edits stand on their own.
s.execute("type.edit", json!({"layer": second, "replace": {"start": 0, "end": 0, "text": "x"}})).unwrap();
assert!(last(&s).1["layer"].is_string() && last(&s).1.get("replace").is_some(), "{}", last(&s).1);
}
#[test]
fn keystrokes_in_another_document_do_not_fold_into_the_previous_document() {
let mut s = session();
let first = s.execute("type.create", json!({"x": 2, "y": 20, "text": "A", "size": 12})).unwrap()["layer"].as_u64().unwrap();
let mut copy = s.active().unwrap().doc.as_ref().clone();
if let Some(layer) = copy.layer_mut(LayerId(first))
&& let photocraft_doc::LayerContent::Text(text) = &mut layer.content
{
text.text = "B".into();
}
s.add_document(copy, None);
let second = first;
let before = s.journal.len();
s.execute("type.edit", json!({"layer": second, "replace": {"start": 1, "end": 1, "text": "!"}, "coalesce": "typing"})).unwrap();
assert_eq!(s.journal.len(), before + 1, "typing in a different document needs a new journal step");
assert_eq!(s.journal[before - 1].1["text"], "A");
assert_eq!(last(&s).1["replace"], json!({"start": 1, "end": 1, "text": "!"}));
}
#[test]
fn a_recording_replays_on_a_document_with_other_layer_ids() {
let mut s = session();
let bg = background(&s);
s.execute("actions.record", json!({"name": "Shade"})).unwrap();
let shade = new_layer(&mut s, "Shade");
s.execute("layer.setProps", json!({"layer": shade, "opacity": 0.5})).unwrap();
s.execute("layer.select", json!({"layer": bg})).unwrap();
s.execute("edit.fill", json!({"layer": bg, "color": "#ff0000"})).unwrap();
let id = s.execute("type.create", json!({"x": 2, "y": 20, "text": "", "size": 12})).unwrap()["layer"].as_u64().unwrap();
for (i, ch) in "Hi".chars().enumerate() {
s.execute("type.edit", json!({"layer": id, "replace": {"start": i, "end": i, "text": ch.to_string()}, "coalesce": "t5"})).unwrap();
}
s.execute("actions.stop", json!({})).unwrap();
let steps = s.execute("actions.get", json!({"action": "Shade"})).unwrap()["steps"].clone();
let ids: Vec<&str> = steps.as_array().unwrap().iter().map(|st| st[0].as_str().unwrap()).collect();
assert_eq!(ids, ["layer.new.layer", "layer.setProps", "layer.select", "edit.fill", "type.create"], "{steps}");
assert!(steps.as_array().unwrap().iter().all(|st| st[1].get("layer").is_none_or(Value::is_string)), "no layer ids: {steps}");
// A second document whose layers have other ids and a layer stacked over the background.
s.execute("file.new", json!({"width": 40, "height": 40, "background": "black"})).unwrap();
new_layer(&mut s, "Top");
assert_ne!(background(&s), bg);
let played = s.execute("actions.play", json!({"action": "Shade"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let d = &s.active().unwrap().doc;
let shade = d.walk().into_iter().find(|(_, _, l)| l.name == "Shade").map(|(_, _, l)| l).unwrap();
assert_eq!(shade.opacity, 0.5, "the new layer, not the one of the first document, got the opacity");
let text = d.walk().into_iter().find_map(|(_, _, l)| match &l.content {
photocraft_doc::LayerContent::Text(t) => Some(t.text.clone()),
_ => None,
});
assert_eq!(text.as_deref(), Some("Hi"), "the typed text replayed as one step");
assert!(s.documents()[0].doc.layer(LayerId(bg)).is_some());
}
#[test]
fn recorded_edit_targets_a_named_nonactive_type_layer_on_another_document() {
let mut s = session();
let first = s.execute("type.create", json!({"x": 2, "y": 20, "text": "Old", "size": 12})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": first, "name": "Caption"})).unwrap();
new_layer(&mut s, "Top");
s.execute("actions.record", json!({"name": "Edit caption"})).unwrap();
s.execute("type.edit", json!({"layer": first, "text": "New"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let steps = s.execute("actions.get", json!({"action": "Edit caption"})).unwrap()["steps"].clone();
assert_eq!(steps, json!([["type.edit", {"layer": "Caption", "text": "New"}]]));
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
let second = s.execute("type.create", json!({"x": 2, "y": 20, "text": "Other", "size": 12})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": second, "name": "Caption"})).unwrap();
new_layer(&mut s, "Top");
assert_ne!(first, second);
let played = s.execute("actions.play", json!({"action": "Edit caption"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let layer = s.active().unwrap().doc.layer(LayerId(second)).unwrap();
let photocraft_doc::LayerContent::Text(text) = &layer.content else { panic!("expected type layer") };
assert_eq!(text.text, "New");
}
+1 -1
View File
@@ -1098,7 +1098,7 @@ fn build() -> Vec<CommandSpec> {
};
if s.set_active(idx) { Ok(json!({"document": idx})) } else { Err(EngineError::Other(format!("no document at index {idx}"))) }
},
journal: false,
journal: true,
},
CommandSpec {
id: "document.move",
+80 -3
View File
@@ -142,6 +142,23 @@ pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value {
/// entry (Last Filter), a smart filter and the step's dialog never see it.
pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result<Value> {
let mut p = p.clone();
if id == "type.create" && (p.get("xPercent").is_some() || p.get("yPercent").is_some()) {
if p.get("box").is_some() || p.get("x").is_some() || p.get("y").is_some() {
return Err(bad(id, "recorded type position cannot mix percentages with pixel coordinates or a box"));
}
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
for (percent_key, pixel_key, dimension) in [("xPercent", "x", size.width), ("yPercent", "y", size.height)] {
let percent = p.get(percent_key).and_then(Value::as_f64).ok_or_else(|| bad(id, format!("invalid `{percent_key}`")))?;
let pixel = percent / 100.0 * f64::from(dimension);
if !pixel.is_finite() || pixel.abs() > f64::from(f32::MAX) {
return Err(bad(id, format!("`{percent_key}` is outside the canvas coordinate range")));
}
if let Some(object) = p.as_object_mut() {
object.remove(percent_key);
object.insert(pixel_key.into(), json!(pixel));
}
}
}
if id.starts_with("filter.gallery.")
&& let Some(object) = p.as_object_mut()
{
@@ -397,10 +414,19 @@ fn record(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"action": name, "index": idx}))
}
/// Whether a journal entry is recorded as an action step: a replayable command, except Check
/// Spelling's corrections and lookups.
fn recorded(id: &str, p: &Value) -> bool {
replayable(id) && (id != "edit.checkSpelling" || crate::type_spell_cmds::records(p))
}
/// Commands recorded with their step's dialog on: Check Spelling.
const RECORDED_WITH_DIALOG: &[&str] = &["edit.checkSpelling"];
/// Replayable journal entries not yet committed to this action while recording.
pub fn pending_steps(s: &Session, action: usize) -> impl Iterator<Item = &(String, Value)> {
let from = s.actions.recording.filter(|(_, i)| *i == action).map_or(s.journal.len(), |(from, _)| from);
s.journal.iter().skip(from).filter(|(id, _)| replayable(id))
s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p))
}
pub(crate) fn flush_recording(s: &mut Session) -> Result<()> {
@@ -408,15 +434,66 @@ pub(crate) fn flush_recording(s: &mut Session) -> Result<()> {
if idx >= s.actions.list.len() {
return Err(bad("actions.stop", "the action being recorded no longer exists"));
}
let steps: Vec<_> = pending_steps(s, idx).cloned().collect();
let steps = fold_recorded_nudges(pending_steps(s, idx).cloned());
s.actions.recording = Some((s.journal.len(), idx));
if !steps.is_empty() {
s.actions.list[idx].steps.extend(steps);
let action = &mut s.actions.list[idx];
let first = action.steps.len();
action.dialogs.extend(steps.iter().enumerate().filter(|(_, (id, _))| RECORDED_WITH_DIALOG.contains(&id.as_str())).map(|(i, _)| first + i));
action.steps.extend(steps);
s.actions.touch();
}
Ok(())
}
/// Consecutive Move-tool keystrokes in one UI nudge run are one relative action step.
/// The UI's `coalesce` run id distinguishes a held key from a later, separate gesture.
fn fold_recorded_nudges(steps: impl IntoIterator<Item = (String, Value)>) -> Vec<(String, Value)> {
let mut out: Vec<(String, Value)> = Vec::new();
let mut last_run: Option<String> = None;
for (id, mut params) in steps {
let run = (id == "layer.translate")
.then(|| params.as_object())
.flatten()
.filter(|p| p.keys().all(|key| matches!(key.as_str(), "dx" | "dy" | "coalesce")))
.and_then(|p| p.get("coalesce").and_then(Value::as_str).filter(|run| run.starts_with("nudge:")))
.map(str::to_string);
if let Some(run) = &run {
// `layer.translate` moves each step by whole pixels, so the fold adds rounded steps.
let dx = params.get("dx").and_then(Value::as_f64).map(f64::round);
let dy = params.get("dy").and_then(Value::as_f64).map(f64::round);
if let (Some(dx), Some(dy)) = (dx, dy)
&& dx.is_finite()
&& dy.is_finite()
{
if last_run.as_ref() == Some(run)
&& let Some((last_id, previous)) = out.last_mut()
&& last_id == &id
{
let sum_x = previous.get("dx").and_then(Value::as_f64).unwrap_or(0.0) + dx;
let sum_y = previous.get("dy").and_then(Value::as_f64).unwrap_or(0.0) + dy;
if sum_x.is_finite() && sum_y.is_finite() {
previous["dx"] = json!(sum_x);
previous["dy"] = json!(sum_y);
continue;
}
}
if let Some(object) = params.as_object_mut() {
object.remove("coalesce");
object.insert("dx".into(), json!(dx));
object.insert("dy".into(), json!(dy));
}
last_run = Some(run.clone());
out.push((id, params));
continue;
}
}
last_run = None;
out.push((id, params));
}
out
}
fn stop(s: &mut Session, _p: &Value) -> Result<Value> {
not_playing(s, "actions.stop")?;
let Some((_, idx)) = s.actions.recording else {
@@ -381,6 +381,78 @@ fn record_explicit_transform_keeps_one_replayable_step() {
assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4]);
}
#[test]
fn recording_folds_one_run_of_move_tool_nudges() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Target"})).unwrap();
s.execute("actions.record", json!({"name": "Nudge"})).unwrap();
for (dx, dy, run) in [(1, 0, "nudge:1"), (1, 0, "nudge:1"), (1, 10, "nudge:1"), (0, 1, "nudge:2")] {
s.execute("layer.translate", json!({"dx": dx, "dy": dy, "coalesce": run})).unwrap();
}
s.execute("actions.stop", json!({})).unwrap();
let steps = &s.actions.list[0].steps;
assert_eq!(steps, &[("layer.translate".into(), json!({"dx": 3.0, "dy": 10.0})), ("layer.translate".into(), json!({"dx": 0.0, "dy": 1.0}))]);
}
/// Each nudge moves by whole pixels, so two 0.6 px steps fold into the 2 px they moved, not 1.2.
#[test]
fn folded_nudges_add_whole_pixel_steps() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Target"})).unwrap();
s.execute("actions.record", json!({"name": "Nudge"})).unwrap();
for _ in 0..2 {
s.execute("layer.translate", json!({"dx": 0.6, "dy": 0.0, "coalesce": "nudge:1"})).unwrap();
}
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps, [("layer.translate".into(), json!({"dx": 2.0, "dy": 0.0}))]);
}
#[test]
fn recording_document_activation_uses_opening_order_offset() {
let mut s = Session::new();
for name in ["First", "Second"] {
s.execute("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap();
}
s.execute("actions.record", json!({"name": "Switch"})).unwrap();
s.execute("document.activate", json!({"document": 0})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps, [("document.activate".into(), json!({"offset": -1}))]);
}
#[test]
fn recorded_point_text_position_scales_with_the_playback_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100, "background": "transparent"})).unwrap();
s.execute("actions.record", json!({"name": "Caption"})).unwrap();
s.execute("type.create", json!({"x": 20, "y": 30, "text": "Hi", "size": 12})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps[0].1["xPercent"], json!(20.0));
assert_eq!(s.actions.list[0].steps[0].1["yPercent"], json!(30.0));
s.execute("file.new", json!({"width": 200, "height": 300, "background": "transparent"})).unwrap();
let played = s.execute("actions.play", json!({"action": "Caption"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let info = s.execute("type.info", json!({})).unwrap();
assert_eq!(info["transform"][4], json!(40.0));
assert_eq!(info["transform"][5], json!(90.0));
}
#[test]
fn recorded_point_text_rejects_invalid_relative_positions() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
for params in [
json!({"xPercent": 20}),
json!({"xPercent": "20", "yPercent": 30}),
json!({"xPercent": 20, "yPercent": 30, "x": 20}),
json!({"xPercent": 20, "yPercent": 30, "box": [0, 0, 10, 10]}),
json!({"xPercent": 1e100, "yPercent": 30}),
] {
assert!(playback_params(&s, "type.create", &params).is_err(), "{params}");
}
}
#[test]
fn recorded_single_layer_transform_replays_after_layer_ids_shift() {
let mut s = Session::new();
+7 -1
View File
@@ -18,6 +18,8 @@ use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
mod replace_color_range;
/// Built-in Color Lookup looks `(id, label)`.
pub use photocraft_cms::lutfile::BUILTIN as LOOKS;
@@ -401,7 +403,11 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights {
}
}
/// Recorded Lab bounds use native profile matching; ordinary RGB samples keep their existing path.
fn replace_color(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(result) = replace_color_range::apply(s, p)? {
return Ok(result);
}
const CMD: &str = "image.adjustments.replaceColor";
let c = crate::commands::color_param(CMD, p, "color", s.tools.foreground)?;
let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0);
@@ -562,7 +568,7 @@ pub fn specs() -> Vec<CommandSpec> {
"image.adjustments.replaceColor",
"Replace Color…",
["Image", "Adjustments"],
r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?} (color: "#rrggbb", default the foreground colour; Localized Color Clusters: "points" where the colour was picked, "range" % of the longer side)"##,
r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?,"labMinimum":[L,a,b]?,"labMaximum":[L,a,b]?} (Lab bounds: a finite ordered D50 box, mutually exclusive with color/points/localized/range; composite pixel-layer target only. Otherwise color defaults to the foreground; localized points use range % of the longer side.)"##,
has_pixels,
replace_color
),
@@ -0,0 +1,245 @@
//! Recorded Replace Color samples are a Lab box, not a single RGB colour.
use photocraft_algo::{selection, tone};
use photocraft_cms::{Builtin, TransformOptions};
use photocraft_doc::{PixelFormat, SampleType};
use photocraft_geom::{Rect, TileCoord};
use photocraft_raster::Surface;
use serde_json::Value;
use super::{bad, num};
use crate::{EngineError, Result, Session};
const CMD: &str = "image.adjustments.replaceColor";
/// Unstored pixels can still be selected when the mask has a nonzero default.
fn edit_bounds(content: Rect, selected: Option<&Surface>) -> Rect {
match selected {
Some(mask) if mask.default_pixel().first() == Some(&0.0) => content.intersect(&mask.content_bounds()),
_ => content,
}
}
/// Validate original precision before checking order and converting to native f32.
fn bound(p: &Value, key: &str) -> Result<[f64; 3]> {
let values = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 3).ok_or_else(|| bad(CMD, format!("`{key}` must be three finite Lab numbers")))?;
let mut result = [0.0; 3];
for (out, value) in result.iter_mut().zip(values) {
let n = value.as_f64().filter(|n| n.is_finite() && (*n as f32).is_finite()).ok_or_else(|| bad(CMD, format!("invalid `{key}` Lab number")))?;
*out = n;
}
Ok(result)
}
/// Apply the same Lab match, tone shift and selection mix to stored and implicit pixels.
fn replace_sample(format: &PixelFormat, pixel: &mut [f32], sample: &[f32; 4], range: &selection::LabRange, fuzz: f32, shift: [f32; 3], selected: f32) -> bool {
if sample[3].is_nan() || sample[3] <= 0.0 || selected <= 0.0 {
return false;
}
let value = [sample[0] * 100.0, sample[1] * 255.0 - 128.0, sample[2] * 255.0 - 128.0];
let coverage = if format.sample == SampleType::U8 {
selection::lab_range_coverage_8bit(selection::quantize_lab8(value), range, fuzz)
} else {
selection::lab_range_coverage(value, range, fuzz)
};
if coverage <= 0.0 {
return false;
}
let original = photocraft_raster::to_rgba(format, pixel);
let mut edited = [original];
tone::replace_color_masked(&mut edited, &[coverage], shift[0], shift[1], shift[2]);
// Unchanged native samples must not take an incidental RGB roundtrip.
if edited[0] == original {
return false;
}
let mut mixed = std::array::from_fn(|c| original[c] * (1.0 - selected) + edited[0][c] * selected);
mixed[3] = original[3];
photocraft_raster::from_rgba_into(format, mixed, pixel);
true
}
/// A zero-default selection or an unchanged source default needs only stored tiles.
/// Otherwise retain the full edit rectangle, subject to the existing adjustment storage budget.
fn work_tiles(surface: &Surface, selected: Option<&Surface>, bounds: Rect, default_changes: bool) -> Result<Option<Vec<TileCoord>>> {
let sparse = selected.filter(|mask| mask.default_pixel().first() == Some(&0.0)).or_else(|| (!default_changes).then_some(surface));
if let Some(sparse) = sparse {
let coords = || sparse.tiles().map(|(tile, _)| *tile).filter(|tile| !tile.rect().intersect(&bounds).is_empty());
let mut tiles = crate::allocation::capacity(coords().count(), "Replace Color tile coordinates")?;
tiles.extend(coords());
return Ok(Some(tiles));
}
let stored = surface.tiles().filter(|(tile, _)| !tile.rect().intersect(&bounds).is_empty()).count();
crate::pixels::check_adjustment_tile_budget(bounds, stored, &surface.format()).map_err(EngineError::Other)?;
Ok(None)
}
/// Apply explicit recorded bounds; `None` leaves the legacy RGB/sample path alone.
/// Matching converts original surface channels through their document profile,
/// before the existing RGBA tone shift can clip or change their colour space.
pub(super) fn apply(s: &mut Session, p: &Value) -> Result<Option<Value>> {
if p.get("labMinimum").is_none() && p.get("labMaximum").is_none() {
return Ok(None);
}
if ["color", "colors", "points", "subtractPoints", "order", "localized", "range", "channel", "targetChannel"].iter().any(|key| p.get(*key).is_some()) {
return Err(bad(CMD, "Lab bounds cannot be combined with RGB samples, localized settings or channel targets"));
}
if p.get("target").is_some_and(|t| t.as_str() != Some("pixels")) || crate::channel_cmds::edits_channel(s) || crate::color_channels::target(s, p)?.is_some()
{
return Err(bad(CMD, "Lab bounds require the composite pixel-layer target"));
}
let (lo, hi) = (bound(p, "labMinimum")?, bound(p, "labMaximum")?);
if lo.iter().zip(hi).any(|(lo, hi)| *lo > hi) {
return Err(bad(CMD, "Lab minimum must not exceed maximum"));
}
let mut range = selection::LabRange { lo: lo.map(|v| v as f32), hi: hi.map(|v| v as f32) };
// Validate supplied shifts before the edit rather than silently defaulting.
for key in ["fuzziness", "hue", "saturation", "lightness"] {
if p.get(key).is_some_and(|v| !v.as_f64().is_some_and(f64::is_finite)) {
return Err(bad(CMD, format!("`{key}` must be a finite number")));
}
}
let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0);
let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0));
let id = crate::commands::layer_param(s, p)?;
let st = s.active().ok_or(EngineError::NoDocument)?;
super::super::enabled::check_pixels_unlocked(&st.doc, id)?;
let surface = st.doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| bad(CMD, "Lab bounds require a pixel layer"))?;
let format = surface.format();
if format.mode != st.doc.mode {
return Err(bad(CMD, "pixel layout does not match the document profile"));
}
let transform = photocraft_cms::cached(&crate::color_cmds::document_profile(&st.doc), Builtin::LabD50.profile(), TransformOptions::default())
.map_err(|e| bad(CMD, format!("Lab colour transform: {e}")))?;
if transform.inputs() != format.mode.color_channels() || transform.outputs() != 3 || format.channels() != transform.inputs() + usize::from(format.alpha) {
return Err(bad(CMD, "unsupported Lab colour-transform layout"));
}
let grid = format.sample == SampleType::U8;
if grid {
range.lo = selection::quantize_lab8(range.lo);
range.hi = selection::quantize_lab8(range.hi);
}
if h == 0.0 && sat == 0.0 && l == 0.0 {
// Validation still runs, but a zero shift must not re-separate native inks.
return Ok(Some(Value::Null));
}
s.edit("Replace Color", |doc, _| {
let canvas = doc.bounds();
let selected = doc.selection.clone();
let surface = crate::commands::paint_surface(doc, id, &Value::Null)?;
let transparent_default = format.alpha && surface.default_pixel().last().is_some_and(|a| a.is_nan() || *a <= 0.0);
let content = surface.content_bounds();
// A nontransparent default is visible across the canvas even without stored tiles.
let extent = if transparent_default { content } else { content.union(&canvas) };
let bounds = edit_bounds(extent, selected.as_ref());
let mut default = surface.default_pixel();
let mut default_lab = [[0.0, 0.0, 0.0, 1.0]];
transform.convert_f32(&default, format.channels(), default_lab.as_flattened_mut(), 4, true);
let default_selected = selected.as_ref().map_or(1.0, |mask| mask.default_pixel()[0]);
let default_changes = replace_sample(&format, &mut default, &default_lab[0], &range, fuzz, [h, sat, l], default_selected);
let tiles = work_tiles(surface, selected.as_ref(), bounds, default_changes)?;
// Matching and tone changes are pointwise, so pixel work stays tile-sized.
let mut process_tile = |tile: TileCoord| -> Result<()> {
let rect = tile.rect().intersect(&bounds);
if rect.is_empty() {
return Ok(());
}
let mut raw = crate::allocation::read_region(surface, rect, "Replace Color")?;
let mut lab = crate::allocation::filled(raw.len() / format.channels(), [0.0, 0.0, 0.0, 1.0], "Replace Color")?;
transform.convert_f32(&raw, format.channels(), lab.as_flattened_mut(), 4, true);
let width = rect.width() as usize;
let mut selection_row = crate::allocation::filled(width, 1.0, "Replace Color")?;
let mut changed = false;
for (y, (row, lab_row)) in raw.chunks_exact_mut(width * format.channels()).zip(lab.chunks_exact(width)).enumerate() {
if let Some(selected) = selected.as_ref() {
selected.sample_row_strided(rect.y0.saturating_add(y as i32), rect.x0, 1, 0, &mut selection_row);
}
for ((pixel, sample), selected) in row.chunks_exact_mut(format.channels()).zip(lab_row).zip(&selection_row) {
changed |= replace_sample(&format, pixel, sample, &range, fuzz, [h, sat, l], *selected);
}
}
if changed {
surface.try_write_region(rect, &raw).map_err(|e| crate::allocation::error("Replace Color", &e.to_string()))?;
}
Ok(())
};
if let Some(tiles) = tiles {
for tile in tiles {
process_tile(tile)?;
}
} else {
for tile in bounds.tiles() {
process_tile(tile)?;
}
}
Ok(Value::Null)
})
.map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::PixelFormat;
/// Work buffers follow explicit selected pixels without cropping implicitly selected coverage.
#[test]
fn selected_work_bounds_preserve_implicit_selection_and_clip_explicit_pixels() {
let content = Rect::new(-100, -100, 100, 100);
let mut mask = Surface::new(PixelFormat::GRAY8);
assert_eq!(edit_bounds(content, Some(&mask)), Rect::EMPTY);
mask.fill_rect(Rect::new(98, 96, 110, 105), &[0.5]);
assert_eq!(edit_bounds(content, Some(&mask)), Rect::new(98, 96, 100, 100));
assert_eq!(edit_bounds(content, None), content);
for coverage in [0.5, 1.0] {
let mut mask = Surface::with_default(PixelFormat::GRAY8, &[coverage]);
mask.fill_rect(Rect::new(2, 2, 3, 3), &[0.0]);
assert_eq!(edit_bounds(content, Some(&mask)), content, "stored holes do not bound selected pixels");
}
}
/// The work list follows stored source or selection tiles rather than their distant bounds.
#[test]
fn sparse_work_tiles_follow_pixels_that_can_change() {
let near = Rect::new(0, 0, 1, 1);
let far = Rect::new(100_000, 100_000, 100_001, 100_001);
let bounds = near.union(&far);
for alpha in [false, true] {
let format = PixelFormat { alpha, ..PixelFormat::RGBA8 };
let mut surface = Surface::with_default(format, &[1.0; 4][..format.channels()]);
surface.fill_rect(far, &[0.0; 4][..format.channels()]);
assert_eq!(work_tiles(&surface, None, bounds, false).unwrap(), Some(vec![TileCoord::containing(far.x0, far.y0)]));
let mut mask = Surface::new(PixelFormat::GRAY8);
mask.fill_rect(near, &[1.0]);
mask.fill_rect(far, &[1.0]);
assert_eq!(
work_tiles(&surface, Some(&mask), bounds, true).unwrap(),
Some(vec![TileCoord::containing(0, 0), TileCoord::containing(far.x0, far.y0)])
);
assert!(work_tiles(&surface, None, near, true).unwrap().is_none(), "a small default-only area remains editable");
}
}
/// A matching opaque default cannot allocate gap tiles beyond the existing adjustment budget.
#[test]
fn opaque_gap_budget_refuses_before_pixel_allocation_or_edit() {
let mut s = Session::new();
s.execute("file.new", serde_json::json!({"width":4,"height":2,"mode":"lab","depth":32,"background":"transparent"})).unwrap();
s.edit("Distant opaque exception", |doc, active| {
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
*surface = Surface::with_default(surface.format(), &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
surface.fill_rect(Rect::new(100_000, 100_000, 100_001, 100_001), &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
crate::allocation::fail_next_for_test();
let result = s.execute(CMD, serde_json::json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100}));
// Drain the probe even on failure so another test cannot inherit it. A skipped probe
// proves that the resource check ran before any fallible tile-buffer allocation.
let probe_unused = crate::allocation::capacity::<u8>(0, "allocation probe").is_err();
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("too large an area")), "{result:?}");
assert!(probe_unused);
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
+22 -12
View File
@@ -301,22 +301,38 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
/// Convert a plain group at its content bounds; recorded guide IDs are ignored session metadata.
fn artboard_from_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "layer.new.artboardFromGroup";
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = p.get("layer").and_then(Value::as_u64).map(LayerId).or(d.active_layer).ok_or(EngineError::Other("no active layer".into()))?;
let id = crate::commands::layer_param(s, p)?;
let preset = match p.get("preset") {
None => String::new(),
Some(value) => value.as_str().ok_or_else(|| bad(cmd, "preset must be text"))?.to_string(),
};
let apply_background = match p.get("changeBackground") {
None | Some(Value::Bool(true)) => true,
Some(value) => value.as_i64().and_then(|n| i32::try_from(n).ok()).ok_or_else(|| bad(cmd, "changeBackground must be an integer"))? == 1,
};
if let Some(guides) = p.get("guideIDs")
&& !guides.as_array().is_some_and(|ids| ids.iter().all(|id| id.as_i64().is_some_and(|n| i32::try_from(n).is_ok())))
{
return Err(bad(cmd, "guideIDs must be a list of integers"));
}
let canvas = d.doc.bounds();
let background = background_param(p, "layer.new.artboardFromGroup")?.unwrap_or_default();
let background = if apply_background { background_param(p, cmd)?.unwrap_or_default() } else { ArtboardBackground::White };
let rect = s.edit("Artboard from Group", |doc, active| {
if !doc.layers.iter().any(|t| t.id == id && t.is_group() && t.artboard().is_none()) {
return Err(EngineError::Other("artboards are made from a top-level layer group".into()));
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let rect = crate::layer_multi_cmds::layer_bounds(l).unwrap_or(canvas);
set_artboard(l, Artboard { rect, background, preset: String::new() });
set_artboard(l, Artboard { rect, background, preset });
fit_canvas(doc);
*active = Some(id);
Ok(rect)
})?;
crate::layer_multi_cmds::set_selection(s, vec![id], Some(id), Some(id))?;
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
@@ -377,10 +393,7 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "layer.artboard.set";
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = match p.get("layer").and_then(Value::as_u64) {
Some(v) => LayerId(v),
None => active_artboard(s).ok_or_else(|| bad(cmd, "no artboard selected"))?,
};
let id = crate::commands::explicit_layer(s, p)?.or_else(|| active_artboard(s)).ok_or_else(|| bad(cmd, "no artboard selected"))?;
let old = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?;
let preset = p.get("preset").and_then(Value::as_str).map(str::to_string);
let mut base = xywh(old.rect);
@@ -441,10 +454,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "layer.artboard.addBeside";
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = match p.get("layer").and_then(Value::as_u64) {
Some(v) => LayerId(v),
None => active_artboard(s).ok_or_else(|| bad(cmd, "no artboard selected"))?,
};
let id = crate::commands::explicit_layer(s, p)?.or_else(|| active_artboard(s)).ok_or_else(|| bad(cmd, "no artboard selected"))?;
let src = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?;
let side = p.get("side").and_then(Value::as_str).unwrap_or("right");
let duplicate = p.get("duplicate").and_then(Value::as_bool).unwrap_or(false);
@@ -703,7 +713,7 @@ pub fn specs() -> Vec<CommandSpec> {
"layer.new.artboardFromGroup",
"Artboard from Group…",
&["Layer", "New"],
r##"{"layer":id? (a top-level group; default active),"background":…?} → {layer, rect}"##,
r##"{"layer":id? (a top-level group; default active),"background":…?,"color":[r,g,b]|"#rrggbb"?,"preset":str? (tag only; does not resize),"guideIDs":[int]? (ignored recording metadata),"changeBackground":int? (1 or absent applies background/color; other integers give white; legacy true = 1)} → {layer, rect}"##,
has_plain_group,
artboard_from_group
),
+2
View File
@@ -33,7 +33,9 @@ mod events;
mod fields;
mod files;
mod steps_artboard;
mod steps_channels;
mod steps_direct_adjustments;
mod steps_distort;
mod steps_doc;
mod steps_gallery;
+21 -10
View File
@@ -359,7 +359,13 @@ fn gradient_map_from(d: &Descriptor) -> Option<Value> {
Some(out)
}
/// Write the legacy gradient form without absorbing settings owned by a direct recorded event.
fn gradient_map_to(p: &Value) -> Option<Descriptor> {
// A recorded GrMp has its own strict converter. Never fall back to the older
// event if its origin/settings were changed into an unrepresentable form.
if p.get(super::steps_direct_adjustments::GRADIENT_ORIGIN).is_some() {
return None;
}
let stops: Option<Vec<D>> = p
.get("stops")?
.as_array()?
@@ -375,16 +381,7 @@ fn gradient_map_to(p: &Value) -> Option<Descriptor> {
))
})
.collect();
let transparency = D::List(vec![
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))),
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))),
]);
let grad = desc("Grdn")
.with("Nm ", text("Custom"))
.with("GrdF", enm("GrdF", "CstS"))
.with("Intr", D::Double(4096.0))
.with("Clrs", D::List(stops?))
.with("Trns", transparency);
let grad = gradient_descriptor(stops?, "Custom");
let mut d = desc("GdMp").with("Grad", D::Descriptor(grad));
if let Some(r) = jbool(p, "reverse") {
d = d.with("Rvrs", D::Boolean(r));
@@ -395,6 +392,20 @@ fn gradient_map_to(p: &Value) -> Option<Descriptor> {
Some(d)
}
/// The classic, opaque gradient shared by direct and adjustment-layer mappings.
pub(super) fn gradient_descriptor(stops: Vec<D>, name: &str) -> Descriptor {
let transparency = D::List(vec![
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))),
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))),
]);
desc("Grdn")
.with("Nm ", text(name))
.with("GrdF", enm("GrdF", "CstS"))
.with("Intr", D::Double(4096.0))
.with("Clrs", D::List(stops))
.with("Trns", transparency)
}
/// An adjustment: its Photoshop class (usually also the direct event's code; Black & White and
/// Photo Filter run by name), the command suffix (`image.adjustments.<suffix>`, `layer.newAdjustmentLayer.<suffix>`), and
/// the converters between the adjustment descriptor and the command params.
+236 -3
View File
@@ -1,9 +1,12 @@
const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly"), ("LghO", "lightenOnly"), ("Anst", "anisotropic")];
use super::adjustments::{adjustment_by_class, adjustment_steps, levels_from, levels_to, shadows_highlights_from, shadows_highlights_to};
use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target, ref_class};
use super::descriptors::{
class, code, desc, get_bool, get_obj, get_ref, jbool, layer_ref, layer_target, path_text, path_value, ref_class, target_ref, trimmed,
};
use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step};
use super::steps_artboard::{artboard_edit_from, artboard_edit_to, artboard_from, artboard_to};
use super::steps_channels::{match_color_from, match_color_to, merge_spot_from, merge_spot_to, split_from, split_to, trap_from, trap_to};
use super::steps_direct_adjustments::{gradient_from, gradient_to, replace_from, replace_to};
use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, wave_from, wave_to};
use super::steps_doc::{
align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to,
@@ -23,7 +26,7 @@ use super::steps_layer::{
use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to};
use super::steps_other::{DE_INTERLACE, HSB_HSL, bare_from, bare_to, custom_from, custom_to, enums_from, enums_to};
use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM};
use super::{AtnEvent, D, Descriptor, STOP_MESSAGE, Value, json};
use super::{AtnEvent, D, Descriptor, Id, Map, ReferenceItem, STOP_MESSAGE, Value, json};
/// An event: code (empty when Photoshop only has a string name), string name, the panel
/// label, the command ids it exports (the first is the one simple events import to), and the
@@ -187,6 +190,162 @@ fn clipping_to(_: &str, p: &Value) -> Option<Descriptor> {
Some(desc("null").with("null", layer_ref(p)?))
}
/// Check Spelling's `null`: the `spellCheck` property of all text layers.
fn spell_check_ref() -> D {
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") },
ReferenceItem::Enumerated { class: class("TxLr"), type_id: code("Ordn"), value: code("Al") },
])
}
/// Edit › Check Spelling (`spellCheck`): `null` is [`spell_check_ref`] and `Usng` holds only
/// `checkAll`. Any other form stays a kept Photoshop step.
fn spell_check_from(d: &Descriptor) -> Mapped {
let same_ref = |r: &[ReferenceItem]| match r {
[ReferenceItem::Property { class: p, key }, ReferenceItem::Enumerated { class: t, type_id, value }] => {
trimmed(&p.class_id) == "Prpr" && key.is("spellCheck") && trimmed(&t.class_id) == "TxLr" && trimmed(type_id) == "Ordn" && trimmed(value) == "Al"
}
_ => false,
};
// Two keys, read by name: `null` and `Usng` in either order, each once.
let using = get_obj(d, "Usng").filter(|u| u.class_id.is("spellCheck") && u.items.len() == 1);
match (d.items.len(), get_ref(d, "null"), using.and_then(|u| get_bool(u, "checkAll"))) {
(2, Some(r), Some(all)) if same_ref(r) => step("edit.checkSpelling", json!({"allLayers": all})),
_ => Mapped::Unsupported { harmless: false },
}
}
/// A Check Spelling step that holds nothing but `allLayers` (Check All Layers, on by default).
fn spell_check_to(_: &str, p: &Value) -> Option<Descriptor> {
let params = p.as_object()?;
if params.keys().any(|k| k != "allLayers") {
return None;
}
let all = match params.get("allLayers") {
None => true,
Some(v) => v.as_bool()?,
};
Some(desc("null").with("null", spell_check_ref()).with("Usng", D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(all)))))
}
// Adapter-only transport bit: the reference permits either no target key or the
// current-layer target. Save must retain that distinction; native behavior is identical.
const NO_TARGET: &str = "_atnNoTarget";
/// Accept only bare or current-layer targets, retaining their key-presence distinction for save.
fn target_params(d: &Descriptor) -> Option<Map<String, Value>> {
let mut p = Map::new();
if d.items.is_empty() {
p.insert(NO_TARGET.into(), Value::Bool(true));
} else if d.items != desc("null").with("null", target_ref("Lyr ")).items {
return None;
}
Some(p)
}
/// Restore the bare or explicit current-layer descriptor selected by the private transport bit.
fn target_descriptor(p: &Value) -> Option<Descriptor> {
match p.get(NO_TARGET) {
None => Some(desc("null").with("null", target_ref("Lyr "))),
Some(Value::Bool(true)) => Some(desc("null")),
Some(_) => None,
}
}
// Native-parameter allowlist adapted from #10628; refuse unrepresented options on export.
/// Refuse native options that the selected Photoshop form cannot represent.
fn params(p: &Value, allowed: &[&str]) -> bool {
p.as_object().is_some_and(|p| p.keys().all(|key| allowed.contains(&key.as_str())))
}
/// Import a target-only event, refusing other targets and extra descriptor settings.
fn target_only_from(d: &Descriptor, id: &str) -> Mapped {
target_params(d).map_or(Mapped::Unsupported { harmless: false }, |p| step(id, Value::Object(p)))
}
/// Export a current-target native step only when no unrepresented options are present.
fn target_only_to(_: &str, p: &Value) -> Option<Descriptor> {
if !params(p, &[NO_TARGET]) {
return None;
}
target_descriptor(p)
}
const SHAPE_BLUR: &[F] = &[F("Rds ", "radius", Kind::Px)];
/// Map the radius-only Shape Blur form; keep custom assets or extra keys unsupported.
fn shape_blur_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
let Some(p) = fields_from(d, SHAPE_BLUR) else { return unsupported };
let p = Value::Object(p);
if shape_blur_to("filter.blur.shapeBlur", &p).is_some_and(|expected| d.items == expected.items) { step("filter.blur.shapeBlur", p) } else { unsupported }
}
/// Write an optional finite 5–1000 pixel radius, refusing explicit shapes and other options.
fn shape_blur_to(_: &str, p: &Value) -> Option<Descriptor> {
if !params(p, &["radius"]) {
return None;
}
if let Some(radius) = p.get("radius") {
let radius = radius.as_f64()?;
if !radius.is_finite() || !(5.0..=1000.0).contains(&radius) {
return None;
}
}
fields_to(p, SHAPE_BLUR, desc("null"))
}
// Recorded Link and Unlink need explicit state: replaying the UI toggle can
// perform the opposite operation. Target matching follows #10628's adapter.
/// Import Link or Unlink as an explicit operation rather than the state-dependent UI toggle.
fn link_from(d: &Descriptor, linked: bool) -> Mapped {
let Some(mut p) = target_params(d) else { return Mapped::Unsupported { harmless: false } };
p.insert("linked".into(), Value::Bool(linked));
step("layer.linkLayers", Value::Object(p))
}
/// Export only the matching explicit link state and supported current-layer target form.
fn link_to(p: &Value, linked: bool) -> Option<Descriptor> {
if !params(p, &["linked", NO_TARGET]) || p.get("linked") != Some(&Value::Bool(linked)) {
return None;
}
target_descriptor(p)
}
/// Map canonical Smart Object file descriptors, refusing unrepresented paths, targets or keys.
fn smart_path_from(d: &Descriptor, id: &str) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
let key = if id == "layer.smartObjects.convertToLinked" { "Usng" } else { "null" };
let Some(path) = d.get(key).and_then(path_text) else { return unsupported };
let p = json!({"path": path});
let Some(mut expected) = smart_path_to(id, &p) else { return unsupported };
if let Some(hint) = d.get("LyrI") {
// The reference explicitly omits recording-session IDs on save (#10603,
// docs/atn.md:200). They must never become PhotoCraft's playback target.
if !matches!(id, "layer.smartObjects.relinkToFile" | "layer.smartObjects.replaceContents") || !matches!(hint, D::Integer(n) if *n > 0) {
return unsupported;
}
expected = expected.with("LyrI", hint.clone());
}
// Normalize the top-level header as Open/Place do, but refuse unknown keys,
// targets and path encodings rather than partly replaying them.
if d.items == expected.items { step(id, p) } else { unsupported }
}
/// Export path-only native parameters using the existing Open/Place path encoding.
fn smart_path_to(id: &str, p: &Value) -> Option<Descriptor> {
let p = p.as_object()?;
let path = p.get("path")?.as_str()?;
if p.len() != 1 || path.is_empty() || path.contains('\0') {
return None;
}
Some(if id == "layer.smartObjects.convertToLinked" {
desc("null").with("null", target_ref("Lyr ")).with("Usng", path_value(path))
} else {
desc("null").with("null", path_value(path))
})
}
/// A clipping mask event: `clipping_from` / `clipping_to` with one command id.
macro_rules! clipping_event {
($code:literal, $name:literal, $label:literal, $id:literal) => {
@@ -201,6 +360,12 @@ macro_rules! selection_event {
};
}
macro_rules! target_event {
($name:literal, $label:literal, $id:literal) => {
Event { code: "", name: $name, label: $label, ids: &[$id], from: |d| target_only_from(d, $id), to: target_only_to }
};
}
/// Every Photoshop event PhotoCraft maps. The order matters only for the panel label.
pub(super) const EVENTS: &[Event] = &[
selection_event!("AddT", "addTo", "Add to Selection", &["select.rect"], |d| super::steps_selection::selection_from(d, "add"), |_, p| {
@@ -353,6 +518,7 @@ pub(super) const EVENTS: &[Event] = &[
ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]),
ev!("", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]),
ev!("", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]),
Event { code: "", name: "shapeBlur", label: "Shape Blur", ids: &["filter.blur.shapeBlur"], from: shape_blur_from, to: shape_blur_to },
ev!(
"SmrB",
"smartBlur",
@@ -518,7 +684,9 @@ pub(super) const EVENTS: &[Event] = &[
adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"),
adjustment_event!("", "BanW", "blackAndWhite", "Black & White", "image.adjustments.blackWhite"),
adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"),
Event { code: "GrMp", name: "", label: "Gradient Map", ids: &["image.adjustments.gradientMap"], from: gradient_from, to: gradient_to },
adjustment_event!("", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"),
Event { code: "RplC", name: "replaceColor", label: "Replace Color", ids: &["image.adjustments.replaceColor"], from: replace_from, to: replace_to },
adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"),
Event { code: "", name: "matchColor", label: "Match Color", ids: &["image.adjustments.matchColor"], from: match_color_from, to: match_color_to },
ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]),
@@ -595,11 +763,39 @@ pub(super) const EVENTS: &[Event] = &[
},
Event { code: "Dplc", name: "duplicate", label: "Duplicate", ids: &["layer.duplicate", "image.duplicate"], from: duplicate_from, to: duplicate_to },
// Layers.
target_event!("reverse", "Reverse", "layer.arrange.reverse"),
target_event!("selectLinkedLayers", "Select Linked Layers", "layer.selectLinkedLayers"),
target_event!("disableLayerStyle", "Clear Layer Style", "layer.layerStyle.clear"),
target_event!("rasterizeAll", "Rasterize All Layers", "layer.rasterize.allLayers"),
target_event!("addBlankVideoLayer", "New Blank Video Layer", "layer.videoLayers.newBlankVideoLayer"),
target_event!("isolateLayers", "Isolate Layers", "select.isolateLayers"),
target_event!("textLoadDefaultTypeStyles", "Load Default Type Styles", "type.loadDefaultTypeStyles"),
target_event!("textSaveDefaultTypeStyles", "Save Default Type Styles", "type.saveDefaultTypeStyles"),
target_event!("recordMeasurements", "Record Measurements", "image.analysis.recordMeasurements"),
target_event!("CropPhotosAuto0001", "Crop and Straighten Photos", "file.automate.cropAndStraightenPhotos"),
Event {
code: "",
name: "artboardFromLayerGroupEvent",
label: "Artboard from Group",
ids: &["layer.new.artboardFromGroup"],
from: artboard_from,
to: artboard_to,
},
Event { code: "", name: "editArtboardEvent", label: "Edit Artboard", ids: &["layer.artboard.set"], from: artboard_edit_from, to: artboard_edit_to },
Event {
code: "",
name: "unlinkSelectedLayers",
label: "Unlink Layers",
ids: &["layer.linkLayers"],
from: |d| link_from(d, false),
to: |_, p| link_to(p, false),
},
Event {
code: "Mk ",
name: "make",
label: "Make",
ids: &[
"type.convertToShape",
"layer.new.layer",
"layer.new.group",
"layer.groupLayers",
@@ -714,6 +910,42 @@ pub(super) const EVENTS: &[Event] = &[
ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]),
ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]),
ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]),
target_event!("placedLayerMakeCopy", "New Smart Object via Copy", "layer.smartObjects.newSmartObjectViaCopy"),
target_event!("placedLayerConvertToLayers", "Convert to Layers", "layer.smartObjects.convertToLayers"),
target_event!("placedLayerConvertToEmbedded", "Convert to Embedded", "layer.smartObjects.convertToEmbedded"),
target_event!("placedLayerUpdateAllModified", "Update All Modified Content", "layer.smartObjects.updateAllModifiedContent"),
Event {
code: "",
name: "placedLayerExportContents",
label: "Export Contents",
ids: &["layer.smartObjects.exportContents"],
from: |d| smart_path_from(d, "layer.smartObjects.exportContents"),
to: smart_path_to,
},
Event {
code: "",
name: "placedLayerConvertToLinked",
label: "Convert to Linked",
ids: &["layer.smartObjects.convertToLinked"],
from: |d| smart_path_from(d, "layer.smartObjects.convertToLinked"),
to: smart_path_to,
},
Event {
code: "",
name: "placedLayerRelinkToFile",
label: "Relink to File",
ids: &["layer.smartObjects.relinkToFile"],
from: |d| smart_path_from(d, "layer.smartObjects.relinkToFile"),
to: smart_path_to,
},
Event {
code: "",
name: "placedLayerReplaceContents",
label: "Replace Contents",
ids: &["layer.smartObjects.replaceContents"],
from: |d| smart_path_from(d, "layer.smartObjects.replaceContents"),
to: smart_path_to,
},
Event { code: "Hd ", name: "hide", label: "Hide", ids: &["layer.hideLayers"], from: |d| visibility_from(d, "layer.hideLayers"), to: visibility_to },
Event {
code: "Shw ",
@@ -841,6 +1073,7 @@ pub(super) const EVENTS: &[Event] = &[
},
Event { code: "Rset", name: "reset", label: "Reset", ids: &["tools.defaultColors"], from: reset_from, to: reset_to },
ev!("Exch", "exchange", "Exchange", "tools.swapColors", &[]),
Event { code: "", name: "spellCheck", label: "Check Spelling", ids: &["edit.checkSpelling"], from: spell_check_from, to: spell_check_to },
Event {
code: "",
name: "assignProfile",
@@ -0,0 +1,160 @@
//! Measured Artboard conversion and background-only Properties edits.
use super::descriptors::{desc, get_num, get_obj, get_text, target_ref, text};
use super::fields::{Mapped, none, step};
use super::{D, Descriptor, Value, json};
const FROM_GROUP: &str = "layer.new.artboardFromGroup";
const EDIT: &str = "layer.artboard.set";
/// Keep recorded colors and ignored guide IDs, including an omitted background type.
fn settings_from(board: &Descriptor, preset_key: &str) -> Option<Value> {
let color = get_obj(board, "Clr ")?;
let preset = get_text(board, "artboardPresetName")?;
let background = match board.get("artboardBackgroundType") {
None | Some(D::Integer(1)) => "white",
Some(D::Integer(2)) => "black",
Some(D::Integer(3)) => "transparent",
Some(D::Integer(4)) => "custom",
_ => return None,
};
let Some(D::List(guides)) = board.get("guideIDs") else { return None };
let guides: Vec<i32> = guides.iter().map(|v| if let D::Integer(n) = v { Some(*n) } else { None }).collect::<Option<_>>()?;
let recorded: Vec<f64> = ["Rd ", "Grn ", "Bl "].iter().map(|key| get_num(color, key)).collect::<Option<_>>()?;
let rgb: Vec<f64> = recorded.iter().map(|n| n / 255.0).collect();
let mut p = json!({"background": background, "color": rgb, "guideIDs": guides});
if recorded.iter().zip(&rgb).any(|(recorded, native)| *recorded != native * 255.0) {
p["recordedColor"] = json!(recorded);
}
p[preset_key] = preset.into();
if board.get("artboardBackgroundType").is_none() {
p["backgroundTypeOmitted"] = true.into();
}
Some(p)
}
/// Rebuild only settings whose native meaning still agrees with the saved wire form.
fn settings_to(p: &Value, preset_key: &str) -> Option<Descriptor> {
let preset = p.get(preset_key)?.as_str()?;
let background = match p.get("background")?.as_str()? {
"white" => 1,
"black" => 2,
"transparent" => 3,
"custom" => 4,
_ => return None,
};
let guides = p.get("guideIDs")?.as_array()?.iter().map(|v| Some(D::Integer(i32::try_from(v.as_i64()?).ok()?))).collect::<Option<Vec<_>>>()?;
let rgb = p.get("color")?.as_array()?;
if rgb.len() != 3 {
return None;
}
let rgb: Vec<f64> = rgb.iter().map(Value::as_f64).collect::<Option<_>>()?;
if !rgb.iter().all(|n| n.is_finite() && (0.0..=1.0).contains(n)) {
return None;
}
let recorded = match p.get("recordedColor") {
None => rgb.iter().map(|n| n * 255.0).collect(),
Some(value) => {
let values = value.as_array()?.iter().map(Value::as_f64).collect::<Option<Vec<_>>>()?;
if values.len() != 3 || values.iter().zip(&rgb).any(|(wire, native)| !(0.0..=255.0).contains(wire) || wire / 255.0 != *native) {
return None;
}
values
}
};
let color = ["Rd ", "Grn ", "Bl "].into_iter().zip(recorded).fold(desc("RGBC"), |d, (key, n)| d.with(key, D::Double(n)));
let board = desc("artboard").with("guideIDs", D::List(guides)).with("artboardPresetName", text(preset)).with("Clr ", D::Descriptor(color));
match p.get("backgroundTypeOmitted") {
None => Some(board.with("artboardBackgroundType", D::Integer(background))),
Some(Value::Bool(true)) if background == 1 => Some(board),
_ => None,
}
}
/// Import target-only conversion or complete recorded settings, preserving flag absence and values.
pub(super) fn artboard_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
if d.items == desc("null").with("null", target_ref("Lyr ")).items {
return none(FROM_GROUP);
}
let Some(mut p) = get_obj(d, "artboard").and_then(|b| settings_from(b, "preset")) else { return unsupported };
match d.get("changeBackground") {
None => {}
Some(D::Integer(n)) => p["changeBackground"] = (*n).into(),
_ => return unsupported,
}
if artboard_to(FROM_GROUP, &p).is_some_and(|expected| expected.items == d.items) { step(FROM_GROUP, p) } else { unsupported }
}
/// Fill behavior-neutral metadata for native settings; unknown options must not disappear.
pub(super) fn artboard_to(_: &str, p: &Value) -> Option<Descriptor> {
let fields = p.as_object()?;
let d = desc("null").with("null", target_ref("Lyr "));
if fields.is_empty() {
return Some(d);
}
if fields.keys().any(|k| !["background", "color", "recordedColor", "preset", "guideIDs", "changeBackground", "backgroundTypeOmitted"].contains(&k.as_str()))
{
return None;
}
let mut settings = p.clone();
let fields = settings.as_object_mut()?;
if !fields.contains_key("color") && fields.get("background").and_then(Value::as_str) == Some("custom") {
fields.insert("background".into(), json!("white"));
}
let background = if fields.contains_key("color") { "custom" } else { "white" };
// Native custom-without-color means white. Other types ignore this last-used color.
for (key, value) in [("background", json!(background)), ("color", json!([1.0, 1.0, 1.0])), ("preset", json!("")), ("guideIDs", json!([]))] {
fields.entry(key).or_insert(value);
}
let d = d.with("artboard", D::Descriptor(settings_to(&settings, "preset")?));
match p.get("changeBackground") {
None => Some(d),
// Older native action files used true for the recorded integer 1.
Some(Value::Bool(true)) => Some(d.with("changeBackground", D::Integer(1))),
Some(value) => Some(d.with("changeBackground", D::Integer(i32::try_from(value.as_i64()?).ok()?))),
}
}
/// Background-only edits retain recorded geometry as metadata, never as native resize parameters.
pub(super) fn artboard_edit_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
let Some(board) = get_obj(d, "artboard") else { return unsupported };
let Some(rect) = get_obj(board, "artboardRect") else { return unsupported };
let Some(mut p) = settings_from(board, "recordedPreset") else { return unsupported };
let Some(edges) = ["Top ", "Left", "Btom", "Rght"].iter().map(|key| get_num(rect, key)).collect::<Option<Vec<_>>>() else { return unsupported };
p["recordedRect"] = json!(edges);
p["changeSizes"] = 8.into();
p["changeBackground"] = 1.into();
if artboard_edit_to(EDIT, &p).is_some_and(|expected| expected.items == d.items) { step(EDIT, p) } else { unsupported }
}
/// Export only the measured background-edit flags and exact recorded rectangle, without quantization.
pub(super) fn artboard_edit_to(_: &str, p: &Value) -> Option<Descriptor> {
let fields = p.as_object()?;
if fields.keys().any(|k| {
!["background", "color", "recordedColor", "recordedPreset", "guideIDs", "recordedRect", "changeSizes", "changeBackground", "backgroundTypeOmitted"]
.contains(&k.as_str())
}) || p.get("changeSizes")?.as_i64()? != 8
|| p.get("changeBackground")?.as_i64()? != 1
{
return None;
}
let edges = p.get("recordedRect")?.as_array()?;
if edges.len() != 4 {
return None;
}
let rect = ["Top ", "Left", "Btom", "Rght"]
.into_iter()
.zip(edges)
.try_fold(desc("classFloatRect"), |d, (key, value)| Some(d.with(key, D::Double(value.as_f64().filter(|v| v.is_finite())?))))?;
let mut board = desc("artboard").with("artboardRect", D::Descriptor(rect));
board.items.extend(settings_to(p, "recordedPreset")?.items);
Some(
desc("null")
.with("null", target_ref("Lyr "))
.with("artboard", D::Descriptor(board))
.with("changeSizes", D::Integer(8))
.with("changeBackground", D::Integer(1)),
)
}
@@ -0,0 +1,187 @@
//! Direct GrMp and RplC forms measured in #10603 (Group E).
use super::adjustments::gradient_descriptor;
use super::descriptors::{desc, enm, get_list, get_num, get_obj};
use super::events::pixel_target;
use super::fields::{Mapped, step};
use super::{D, Descriptor, Value, json};
/// Typed source values needed to retain the direct event and fractional RGB on save.
/// Execution uses ordinary numeric RGB stops, never a stored descriptor.
pub(super) const GRADIENT_ORIGIN: &str = "__atnGrMp";
/// Refuse extra native settings instead of exporting an incomplete edit.
fn only_params(p: &Value, allowed: &[&str]) -> bool {
p.as_object().is_some_and(|p| p.keys().all(|k| allowed.contains(&k.as_str())))
}
/// Read exactly three channels; callers enforce their colour-space limits.
fn triple(p: &Value) -> Option<[f64; 3]> {
let a = p.as_array().filter(|a| a.len() == 3)?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
}
/// Import only a descriptor the native stop model and the strict writer retain.
/// Comparing the reconstructed typed descriptor rejects dropped settings, unknown
/// keys, duplicates, unsupported units/classes and lossy wire-type conversions.
pub(super) fn gradient_from(d: &Descriptor) -> Mapped {
let mapped = || {
let g = get_obj(d, "Grad")?;
let D::Text(name) = g.get("Nm ")? else { return None };
let mut stops = Vec::new();
let mut colors = Vec::new();
for s in get_list(g, "Clrs").filter(|s| (2..=64).contains(&s.len()))? {
let D::Descriptor(s) = s else { return None };
let c = get_obj(s, "Clr ")?;
let rgb = [get_num(c, "Rd ")?, get_num(c, "Grn ")?, get_num(c, "Bl ")?];
colors.push(rgb);
stops.push(json!([get_num(s, "Lctn")? / 4096.0, rgb.map(|v| v / 255.0)]));
}
let mut p = json!({"stops": stops, GRADIENT_ORIGIN: {"name": name.to_string_lossy(), "colors": colors}});
if d.get("gradientsInterpolationMethod").is_none() {
// The measured default is classic; retain omission as well as meaning.
p[GRADIENT_ORIGIN]["omitInterpolation"] = json!(true);
}
for (key, param) in [("Rvrs", "reverse"), ("Dthr", "dither")] {
if let Some(v) = d.get(key) {
let D::Boolean(v) = v else { return None };
p[param] = json!(v);
}
}
(gradient_to("", &p)?.eq(d)).then_some(p)
};
mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("image.adjustments.gradientMap", p))
}
/// Write the measured classic form. The exact source RGB values must still agree
/// with the executed stops, so changing a stop never revives stale source data.
pub(super) fn gradient_to(_: &str, p: &Value) -> Option<Descriptor> {
if !pixel_target(p) || !only_params(p, &["stops", "reverse", "dither", "target", GRADIENT_ORIGIN]) {
return None;
}
let origin = p.get(GRADIENT_ORIGIN)?;
if !only_params(origin, &["name", "colors", "omitInterpolation"]) {
return None;
}
let name = origin.get("name")?.as_str()?;
let colors = origin.get("colors")?.as_array()?;
let stops = p.get("stops")?.as_array().filter(|s| (2..=64).contains(&s.len()) && s.len() == colors.len())?;
let mut encoded = Vec::with_capacity(stops.len());
let mut previous = -1.0;
for (stop, color) in stops.iter().zip(colors) {
let a = stop.as_array().filter(|a| a.len() == 2)?;
let position = a.first()?.as_f64()?;
if !position.is_finite() || !(0.0..=1.0).contains(&position) || position < previous || (position * 4096.0).fract() != 0.0 {
return None;
}
previous = position;
let rgb = triple(color)?;
if !rgb.iter().all(|v| v.is_finite() && (0.0..=255.0).contains(v)) || triple(a.get(1)?)? != rgb.map(|v| v / 255.0) {
return None;
}
let rgb = desc("RGBC").with("Rd ", D::Double(rgb[0])).with("Grn ", D::Double(rgb[1])).with("Bl ", D::Double(rgb[2]));
encoded.push(D::Descriptor(
desc("Clrt")
.with("Clr ", D::Descriptor(rgb))
.with("Type", enm("Clry", "UsrS"))
.with("Lctn", D::Integer((position * 4096.0) as i32))
.with("Mdpn", D::Integer(50)),
));
}
let mut d = desc("null");
let omit = match origin.get("omitInterpolation") {
None => false,
Some(v) => v.as_bool()?,
};
if !omit {
d = d.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Gcls"));
}
d = d.with("Grad", D::Descriptor(gradient_descriptor(encoded, name)));
for (param, key) in [("reverse", "Rvrs"), ("dither", "Dthr")] {
if let Some(v) = p.get(param) {
d = d.with(key, D::Boolean(v.as_bool()?));
}
}
Some(d)
}
/// Retain source precision while requiring coordinates to stay finite in native f32.
fn lab_bound(p: &Value) -> Option<[f64; 3]> {
let values = triple(p)?;
values.iter().all(|v| v.is_finite() && (*v as f32).is_finite()).then_some(values)
}
/// Reject extra, duplicate or reordered keys that the strict writer cannot retain.
fn ordered(d: &Descriptor, keys: &[&str]) -> bool {
d.items.len() == keys.len() && d.items.iter().zip(keys).all(|((key, _), expected)| key.is(expected))
}
/// Read the measured Lab encoding directly, without RGB clipping or type coercion.
fn lab_from(v: &D) -> Option<[f64; 3]> {
let D::Descriptor(d) = v else { return None };
if !d.class_id.is("LbCl") || !d.name.0.is_empty() || !ordered(d, &["Lmnc", "A ", "B "]) {
return None;
}
let values = d
.items
.iter()
.map(|(_, v)| match v {
D::Double(n) => Some(*n),
_ => None,
})
.collect::<Option<Vec<_>>>()?;
lab_bound(&json!(values))
}
/// RplC records the sampled D50 box directly. RGB conversion would lose both the
/// box width and out-of-gamut coordinates, so pass its bounds to the native command.
pub(super) fn replace_from(d: &Descriptor) -> Mapped {
let mapped = || {
if !d.class_id.is("null") || !d.name.0.is_empty() || !ordered(d, &["Fzns", "Mnm ", "Mxm ", "H ", "Strt", "Lght", "colorModel"]) {
return None;
}
let (lo, hi) = (lab_from(d.get("Mnm ")?)?, lab_from(d.get("Mxm ")?)?);
if lo.iter().zip(hi).any(|(lo, hi)| *lo > hi) || !matches!(d.get("colorModel"), Some(D::Integer(0))) {
return None;
}
let mut p = json!({"labMinimum": lo, "labMaximum": hi});
for (key, param, lo, hi) in
[("Fzns", "fuzziness", 0, 200), ("H ", "hue", -180, 180), ("Strt", "saturation", -100, 100), ("Lght", "lightness", -100, 100)]
{
let D::Integer(n) = d.get(key)? else { return None };
p[param] = json!((*n).clamp(lo, hi));
}
Some(p)
};
mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("image.adjustments.replaceColor", p))
}
/// Emit all measured keys in order. Fractional native shifts have no integer ATN
/// representation and are refused; an ordinary RGB-sample call is not a Lab box.
pub(super) fn replace_to(_: &str, p: &Value) -> Option<Descriptor> {
if !pixel_target(p) || !only_params(p, &["labMinimum", "labMaximum", "fuzziness", "hue", "saturation", "lightness", "target"]) {
return None;
}
let (lo, hi) = (lab_bound(p.get("labMinimum")?)?, lab_bound(p.get("labMaximum")?)?);
if lo.iter().zip(hi).any(|(lo, hi)| *lo > hi) {
return None;
}
let lab = |v: [f64; 3]| D::Descriptor(desc("LbCl").with("Lmnc", D::Double(v[0])).with("A ", D::Double(v[1])).with("B ", D::Double(v[2])));
let field = |key, default: f64, lo, hi| {
let n = match p.get(key) {
None => default,
Some(v) => v.as_f64()?,
};
let n = n.is_finite().then_some(n)?.clamp(lo, hi);
(n.fract() == 0.0).then_some(D::Integer(n as i32))
};
Some(
desc("null")
.with("Fzns", field("fuzziness", 40.0, 0.0, 200.0)?)
.with("Mnm ", lab(lo))
.with("Mxm ", lab(hi))
.with("H ", field("hue", 0.0, -180.0, 180.0)?)
.with("Strt", field("saturation", 0.0, -100.0, 100.0)?)
.with("Lght", field("lightness", 0.0, -100.0, 100.0)?)
.with("colorModel", D::Integer(0)),
)
}
+33 -8
View File
@@ -44,9 +44,12 @@ fn path_extension(path: &str) -> Option<String> {
}
/// `save`: a plain Save, or a Save As (`As` format, `In` destination, `Cpy` As a Copy).
/// Only a destination that is a file of the recorded format imports: a folder (#5430), a missing
/// or unknown format or a mismatched extension stays a kept Photoshop step, so playback stops
/// before writing rather than saving over the played file (#7102).
/// A destination that is a file of the recorded format imports with its path. "Save in
/// directory" (`DIDr`, recorded when the suggested name was kept) imports as a folder step, so
/// playback saves each document under its own name in that folder (#5305). Anything else (a
/// missing or unknown format, a mismatched extension, a copy into a folder) stays a kept
/// Photoshop step, so playback stops before writing rather than saving over the played file
/// (#7102).
pub(super) fn save_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
if d.get("As ").is_none() && d.get("In ").is_none() {
@@ -56,11 +59,16 @@ pub(super) fn save_from(d: &Descriptor) -> Mapped {
// with a format that isn't a descriptor) the step is kept.
let Some(format) = get_obj(d, "As ") else { return unsupported };
let Some(path) = d.get("In ").and_then(path_text) else { return unsupported };
let class = trimmed(&format.class_id);
if get_bool(d, "DIDr") == Some(true) {
return unsupported;
// `In` is the folder alone. Save a Copy has no folder step: it stays kept.
let Some(ext) = SAVE_FORMATS.iter().find(|(c, _)| *c == class).and_then(|(_, exts)| exts.first()) else { return unsupported };
if get_bool(d, "Cpy ") == Some(true) {
return unsupported;
}
return step("file.saveAs", json!({"folder": path, "extension": ext}));
}
let Some(ext) = path_extension(&path) else { return unsupported };
let class = trimmed(&format.class_id);
let Some((_, exts)) = SAVE_FORMATS.iter().find(|(c, _)| *c == class) else { return unsupported };
if !exts.contains(&ext.as_str()) {
return unsupported;
@@ -72,7 +80,15 @@ pub(super) fn save_to(id: &str, p: &Value) -> Option<Descriptor> {
if id == "file.save" {
return Some(desc("null"));
}
let path = jstr(p, "path")?;
let Some(path) = jstr(p, "path").filter(|v| !v.is_empty()) else {
// A Save As recorded with the document's own name: "save in directory" (#5305).
let (folder, ext) = (jstr(p, "folder")?, jstr(p, "extension")?.to_ascii_lowercase());
if id != "file.saveAs" {
return None;
}
let (class, _) = SAVE_FORMATS.iter().find(|(_, exts)| exts.contains(&ext.as_str()))?;
return Some(desc("null").with("As ", D::Descriptor(desc(class))).with("In ", path_value(folder)).with("DIDr", D::Boolean(true)));
};
let ext = path_extension(path)?;
let (class, _) = SAVE_FORMATS.iter().find(|(_, exts)| exts.contains(&ext.as_str()))?;
let d = desc("null").with("As ", D::Descriptor(desc(class))).with("In ", path_value(path));
@@ -144,7 +160,8 @@ pub(super) fn export_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
let Some(u) = get_obj(d, "Usng") else { return unsupported };
let Some(path) = d.get("In ").and_then(path_text) else { return unsupported };
let mut p = json!({"path": path});
// "Save in directory": `In` is only the folder, and each document saves under its own name.
let mut p = if get_bool(u, "DIDr") == Some(true) { json!({"folder": path}) } else { json!({"path": path}) };
if let Some(f) = get_enum(u, "Fmt ") {
match lookup(WEB_FORMATS, &f) {
Some(f) => p["format"] = json!(f),
@@ -171,8 +188,16 @@ pub(super) fn export_from(d: &Descriptor) -> Mapped {
}
pub(super) fn export_to(_: &str, p: &Value) -> Option<Descriptor> {
let path = jstr(p, "path")?;
// A step recorded with the document's own name keeps only its folder (#5305).
let (path, in_folder) = match (jstr(p, "path").filter(|v| !v.is_empty()), jstr(p, "folder")) {
(Some(path), _) => (path, false),
(None, Some(folder)) => (folder, true),
(None, None) => return None,
};
let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")).with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, jstr(p, "format").unwrap_or("png24"))?));
if in_folder {
u = u.with("DIDr", D::Boolean(true));
}
for (key, param, default) in [
("Intr", "interlaced", false),
("Trns", "transparency", true),
+14 -2
View File
@@ -172,8 +172,16 @@ fn new_document_from_descriptor(d: &Descriptor) -> Option<Mapped> {
/// New Snapshot's From: the full document, the layers merged, or the current layer.
const SNAPSHOT_FROM: &[(&str, &str)] = &[("FllD", "fullDocument"), ("MrgL", "mergedLayers"), ("CrnL", "currentLayer")];
/// Import Make events only when their geometry and options can be represented faithfully.
fn type_shape_descriptor() -> Descriptor {
desc("null").with("null", class_ref("contentLayer")).with("Usng", target_ref("textLayer"))
}
/// Import Make events only when their geometry and options can be represented faithfully,
/// including the exact target-text-to-content-layer references.
pub(super) fn make_from(d: &Descriptor) -> Mapped {
if d.items == type_shape_descriptor().items {
return none("type.convertToShape");
}
if let Some(mapped) = duplicate_layer_from_descriptor(d) {
return mapped;
}
@@ -300,8 +308,12 @@ fn recorded_percent(opacity: f64) -> f64 {
if (level - level.round()).abs() < 1e-6 && (whole * 255.0 / 100.0).round() == level.round() { whole } else { percent }
}
/// Write a creation step, refusing native options the target descriptor cannot carry.
/// Write a creation step, refusing native options the target descriptor cannot carry; Type to
/// Shape exports only without native options.
pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
if id == "type.convertToShape" {
return p.as_object().filter(|p| p.is_empty()).map(|_| type_shape_descriptor());
}
let with_name = |cls: &str| {
let mut u = desc(cls);
if let Some(n) = jstr(p, "name") {
+164 -2
View File
@@ -482,6 +482,7 @@ fn settings_at_the_value_the_event_plays_still_export() {
/// Every exported command has a canonical import, including recorded full-stack navigation.
#[test]
/// Each mapped command retains its parameters through its corresponding ATN descriptor.
fn every_mapped_command_exports_and_imports_back() {
let cases: Vec<(&str, Value)> = vec![
("filter.render.clouds", json!({})),
@@ -616,8 +617,31 @@ fn every_mapped_command_exports_and_imports_back() {
("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})),
("image.duplicate", json!({"mergedOnly": true})),
("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})),
("type.convertToShape", json!({})),
("filter.blur.shapeBlur", json!({})),
("filter.blur.shapeBlur", json!({"radius": 10})),
("layer.new.group", json!({"name": "G"})),
("layer.groupLayers", json!({})),
("layer.linkLayers", json!({"linked": false})),
("layer.linkLayers", json!({"linked": false, "_atnNoTarget": true})),
("layer.arrange.reverse", json!({})),
("layer.arrange.reverse", json!({"_atnNoTarget": true})),
("layer.selectLinkedLayers", json!({})),
("layer.layerStyle.clear", json!({})),
("layer.rasterize.allLayers", json!({})),
("layer.videoLayers.newBlankVideoLayer", json!({})),
("select.isolateLayers", json!({})),
("type.loadDefaultTypeStyles", json!({})),
("type.saveDefaultTypeStyles", json!({})),
("image.analysis.recordMeasurements", json!({})),
("file.automate.cropAndStraightenPhotos", json!({})),
("file.automate.cropAndStraightenPhotos", json!({"_atnNoTarget": true})),
("layer.new.artboardFromGroup", json!({})),
("layer.new.artboardFromGroup", json!({"background":"white","color":[1,1,1],"preset":"","guideIDs":[],"changeBackground":1})),
(
"layer.artboard.set",
json!({"background":"custom","color":[0.25,0.5,0.75],"recordedPreset":"","guideIDs":[],"recordedRect":[2,2,6,6],"changeSizes":8,"changeBackground":1}),
),
("layer.new.layerViaCopy", json!({})),
("layer.newFillLayer.solidColor", json!({"color": "#336699"})),
("layer.newAdjustmentLayer.hueSaturation", json!({})),
@@ -665,6 +689,8 @@ fn every_mapped_command_exports_and_imports_back() {
("edit.pasteSpecial.pasteInPlace", json!({})),
("edit.paste", json!({})),
("edit.purge.histories", json!({})),
("edit.checkSpelling", json!({"allLayers": true})),
("edit.checkSpelling", json!({"allLayers": false})),
("tools.defaultColors", json!({})),
(
"edit.convertToProfile",
@@ -689,15 +715,29 @@ fn every_mapped_command_exports_and_imports_back() {
"file.export.saveForWebLegacy",
json!({"path": "/tmp/out/banner.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}),
),
(
"file.export.saveForWebLegacy",
json!({"folder": "/tmp/out", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}),
),
(
"file.export.saveForWebLegacy",
json!({"path": "C:\\shots\\a.jpg", "format": "jpeg", "interlaced": false, "transparency": false, "embedIcc": true, "progressive": true, "optimized": true, "quality": 80, "matte": "none"}),
),
("image.adjustments.gradientMap", json!({"stops": [[0, "#000000"], [0.5, "#ff8000"], [1, "#ffffff"]], "reverse": true, "dither": false})),
("image.adjustments.gradientMap", json!({"stops": [[0,[0.5,0,0]],[1,[0,1,0]]], "__atnGrMp":{"name":"Recorded","colors":[[127.5,0,0],[0,255,0]]}})),
("image.adjustments.replaceColor", json!({"labMinimum":[40,30,40],"labMaximum":[60,35,45],"fuzziness":40,"hue":30,"saturation":0,"lightness":0})),
("layer.newAdjustmentLayer.gradientMap", json!({"stops": [[0, "#102030"], [1, "#f0e0d0"]], "reverse": false, "dither": true})),
("layer.smartObjects.convertToSmartObject", json!({})),
("layer.smartObjects.editContents", json!({})),
("layer.smartObjects.updateModifiedContent", json!({})),
("layer.smartObjects.newSmartObjectViaCopy", json!({})),
("layer.smartObjects.convertToLayers", json!({})),
("layer.smartObjects.convertToEmbedded", json!({})),
("layer.smartObjects.updateAllModifiedContent", json!({})),
("layer.smartObjects.exportContents", json!({"path": "/tmp/contents.png"})),
("layer.smartObjects.convertToLinked", json!({"path": "/tmp/linked.png"})),
("layer.smartObjects.relinkToFile", json!({"path": "/tmp/relinked.png"})),
("layer.smartObjects.replaceContents", json!({"path": "/tmp/replacement.png"})),
("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})),
("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true})),
("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})),
@@ -851,6 +891,13 @@ fn every_mapped_command_exports_and_imports_back() {
Mapped::Unsupported { .. } => panic!("{id} does not import back"),
}
}
// Partial native backgrounds need no recorded preset, guide IDs or last-used color.
for (background, imported_background) in [("white", "white"), ("black", "black"), ("transparent", "transparent"), ("custom", "white")] {
let item = export_item("layer.new.artboardFromGroup", &json!({"background":background}), "S").unwrap();
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("Artboard did not import") };
assert_eq!(steps, [("layer.new.artboardFromGroup".into(), json!({"background":imported_background,"color":[1.0,1.0,1.0],"preset":"","guideIDs":[]}))]);
assert_eq!(export_item(&steps[0].0, &steps[0].1, "S").unwrap().descriptor, item.descriptor);
}
// Every command the table claims to export has a case above.
let covered: std::collections::BTreeSet<&str> = cases.iter().map(|(id, _)| *id).collect();
let mut missing: Vec<&str> = EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| !covered.contains(id)).collect();
@@ -3629,7 +3676,9 @@ fn save_as_that_cannot_be_mapped_stops_playback_instead_of_saving_over_the_file(
// action was played on.
let file = one_action(vec![
item("save", "Save", Some(save_as("PNGF", "/out/folder"))),
item("save", "Save", Some(save_as("PNGF", "/out/v1.png").with("DIDr", D::Boolean(true)))),
// Save a Copy into a folder under the document's own name has no PhotoCraft step.
item("save", "Save", Some(save_as("PNGF", "/out").with("DIDr", D::Boolean(true)).with("Cpy ", D::Boolean(true)))),
item("save", "Save", Some(save_as("Not a format", "/out").with("DIDr", D::Boolean(true)))),
item("save", "Save", Some(save_as("PNGF", "/out/result.webp"))),
item("save", "Save", Some(save_as("GIFf", "/out/result.gif"))),
item("save", "Save", Some(desc("null").with("As ", D::Descriptor(desc("PNGF"))))),
@@ -3641,7 +3690,7 @@ fn save_as_that_cannot_be_mapped_stops_playback_instead_of_saving_over_the_file(
]);
let imp = import(&atn::write(&file).unwrap()).unwrap();
let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, [PHOTOSHOP_STEP; 8]);
assert_eq!(ids, [PHOTOSHOP_STEP; 9]);
assert!(imp.actions[0].disabled.is_empty(), "kept saves stay on, so playback stops on them");
// The kept steps are written back byte for byte.
let a = &imp.actions[0];
@@ -3653,6 +3702,39 @@ fn save_as_that_cannot_be_mapped_stops_playback_instead_of_saving_over_the_file(
}
}
#[test]
fn save_as_into_a_folder_imports_as_a_folder_step_and_saves_back() {
// #5305: "save in directory" (`DIDr`), recorded when Photoshop's suggested name is kept:
// `In` is the folder, and each played document saves under its own name there.
let file = one_action(vec![
item("save", "Save", Some(save_as("PNGF", "/out/web").with("DIDr", D::Boolean(true)))),
item("save", "Save", Some(save_as("JPEG", "C:\\shots").with("DIDr", D::Boolean(true)))),
]);
let imp = import(&atn::write(&file).unwrap()).unwrap();
let steps = vec![
("file.saveAs".to_string(), json!({"folder": "/out/web", "extension": "png"})),
("file.saveAs".to_string(), json!({"folder": "C:\\shots", "extension": "jpg"})),
];
assert_eq!(imp.actions[0].steps, steps);
assert!(imp.unsupported.is_empty());
// Saved back as the same "save in directory" steps.
let (bytes, skipped) = export("Saves", &[&imp.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
let back = atn::parse(&bytes).unwrap();
for (out, orig) in back.actions[0].items.iter().zip(&file.actions[0].items) {
let (out, orig) = (out.descriptor.as_ref().unwrap(), orig.descriptor.as_ref().unwrap());
for key in ["As ", "In ", "DIDr"] {
assert_eq!(out.get(key), orig.get(key), "{key}");
}
}
assert_eq!(import(&bytes).unwrap().actions[0].steps, steps);
// A folder step without its extension, or as a copy, has no Photoshop event.
let odd = Action::new("B", vec![("file.saveAs".to_string(), json!({"folder": "/out"}))]);
assert!(export("Saves", &[&odd], &[], false).is_err());
let copy = Action::new("C", vec![("file.saveACopy".to_string(), json!({"folder": "/out", "extension": "png"}))]);
assert!(export("Saves", &[&copy], &[], false).is_err());
}
#[test]
fn saves_round_trip_through_photoshop_events() {
let steps = vec![
@@ -5816,6 +5898,86 @@ fn load_rejects_bad_encodings() {
assert!(s.actions.list.is_empty(), "nothing is loaded when the encoding is bad");
}
/// Edit › Check Spelling in its recorded form: `spellCheck`, `null` = the `spellCheck` property of
/// all text layers, `Usng` = `checkAll`, with the step's dialog on.
fn spell_check(all: bool) -> Descriptor {
desc("null")
.with(
"null",
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") },
ReferenceItem::Enumerated { class: class("TxLr"), type_id: code("Ordn"), value: code("Al") },
]),
)
.with("Usng", D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(all))))
}
#[test]
fn check_spelling_loads_and_saves_as_the_spell_check_event() {
for all in [true, false] {
let mut step = item("spellCheck", "Check Spelling", Some(spell_check(all)));
step.with_dialog = true;
let file = AtnFile {
name: UnicodeString::new_nul("S"),
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![step], ..Default::default() }],
..Default::default()
};
let imported = import(&atn::write(&file).unwrap()).unwrap();
assert!(imported.unsupported.is_empty(), "{:?}", imported.unsupported);
let action = &imported.actions[0];
assert_eq!(action.steps, [("edit.checkSpelling".to_string(), json!({"allLayers": all}))]);
assert_eq!(action.dialogs.iter().copied().collect::<Vec<_>>(), [0]);
let (bytes, skipped) = export("S", &[action], &[], false).unwrap();
assert!(skipped.is_empty());
let saved = atn::parse(&bytes).unwrap();
let saved = &saved.actions[0].items[0];
assert_eq!(saved.event.id(), "spellCheck");
assert!(saved.with_dialog);
assert_eq!(saved.descriptor.as_ref().unwrap().to_bytes(), spell_check(all).to_bytes(), "checkAll {all}");
}
}
#[test]
fn other_check_spelling_forms_stay_kept_steps_and_corrections_are_not_saved() {
let event = event_for(&AtnEvent::name("spellCheck")).unwrap();
let mut extra = spell_check(true);
extra.items.push((Id::new("Fwd "), D::Boolean(true)));
let mut other_target = spell_check(true);
other_target.items[0].1 = D::Reference(vec![ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") }]);
let mut other_class = spell_check(true);
other_class.items[1].1 = D::Descriptor(desc("findReplace").with("checkAll", D::Boolean(true)));
let mut more_using = spell_check(true);
more_using.items[1].1 = D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(true)).with("Fwd ", D::Boolean(true)));
let mut twice = spell_check(true);
twice.items[1] = twice.items[0].clone();
for d in [extra, other_target, other_class, more_using, twice, desc("null")] {
assert!(matches!((event.from)(&d), Mapped::Unsupported { harmless: false }), "{d:?}");
}
for params in [
json!({"action": "change", "start": 0, "end": 3, "replace": "The"}),
json!({"action": "changeAll", "word": "teh", "replace": "the"}),
json!({"allLayers": true, "layer": "Title"}),
json!({"allLayers": "yes"}),
] {
assert!(export_item("edit.checkSpelling", &params, "S").is_none(), "{params}");
}
}
/// Check Spelling's `null` and `Usng` are read by name: a step that holds them in the other
/// order loads as the same step.
#[test]
fn check_spelling_reads_its_keys_in_either_order() {
let event = event_for(&AtnEvent::name("spellCheck")).unwrap();
for all in [true, false] {
let mut reversed = spell_check(all);
reversed.items.reverse();
match (event.from)(&reversed) {
Mapped::Steps(steps) => assert_eq!(steps, [("edit.checkSpelling".to_string(), json!({"allLayers": all}))]),
Mapped::Unsupported { .. } => panic!("Usng before null was kept: {reversed:?}"),
}
}
}
/// A setting recorded twice (`Wdth`, `Fade`, `Scl `, a gallery key) would play one value and
/// save one, dropping the other: such a step stays a Photoshop step (CodeRabbit on #10776).
#[test]
@@ -269,7 +269,7 @@ pub(crate) fn filter_mask_layer(s: &Session, id: &str, params: &Value) -> Option
if target_of(params) != Target::FilterMask || !follows_filter_mask(id) {
return None;
}
params.get("layer").and_then(Value::as_u64).map(LayerId).or_else(|| s.active()?.active_layer)
crate::commands::layer_param(s, params).ok()
}
/// The colour channel pixel commands are limited to, if a single colour channel is targeted.
+1 -1
View File
@@ -345,7 +345,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Content-Aware Fill…",
["Edit"],
None,
r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id?=active (the layer filled and sampled),"seed":u64=1}"##,
r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id|name?=active (the layer filled and sampled),"seed":u64=1}"##,
can_caf,
content_aware_fill
),
@@ -383,8 +383,8 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &
use photocraft_algo::content_aware::fill_owned;
// The workspace names the layer it opened on: after an Apply to a new layer that one is
// active, but the fill still samples the original.
let id = match p.get("layer").and_then(Value::as_u64) {
Some(l) => pixel_layer_id(s, LayerId(l)),
let id = match crate::commands::explicit_layer(s, p)? {
Some(layer) => pixel_layer_id(s, layer),
None => pixel_layer(s),
}
.map_err(EngineError::Other)?;
@@ -226,6 +226,16 @@ fn blob_session(depth: u32) -> Session {
s
}
#[test]
fn content_aware_fill_uses_a_named_nonactive_source_layer() {
let mut s = blob_session(8);
let id = s.active().unwrap().active_layer.unwrap();
let name = s.active().unwrap().doc.layer(id).unwrap().name.clone();
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
let result = s.execute("edit.contentAwareFill", json!({"layer": name, "colorAdaptation": "none"})).unwrap();
assert_eq!(result["layer"], id.0, "fill must use the recorded source rather than the active layer");
}
#[test]
fn content_aware_fill_huge_area_neither_panics_nor_wraps() {
let mut s = blob_session(8);
+8 -2
View File
@@ -877,9 +877,15 @@ pub fn specs() -> Vec<CommandSpec> {
if all == d.doc.bounds() {
return Ok(json!({"changed": false}));
}
// One History step named as in Photoshop, not the crop's "Crop".
// One History step named as in Photoshop, not the crop's "Crop". The Background stays a
// Background, unlike a Crop tool crop with Delete Cropped Pixels off (#10867).
let p = json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false});
crate::analysis_cmds::compound(s, "Reveal All", |s| s.execute("image.crop", p))?;
// The crop runs without `Session::execute`, so it passes the session's gate here, as a
// command run on Reveal All's behalf would.
if let Some(gate) = s.authorize {
gate("image.crop", &p)?;
}
crate::analysis_cmds::compound(s, "Reveal All", |s| crate::image_cmds::crop_keeping_background(s, &p))?;
Ok(json!({"changed": true}))
}),
spec!(
+12 -7
View File
@@ -28,11 +28,16 @@ mod paths;
mod processing;
mod save;
pub use batch_run::OutputClaims;
pub(crate) use batch_run::{BatchInput, batch_inputs, batch_keeps_step, batch_save_point, process_files};
pub use batch_naming::plan as plan_batch_names;
#[cfg(test)]
pub(crate) use batch_run::{MAX_SUBFOLDER_DEPTH, list_images_tree};
use batch_run::{batch, same_format};
pub(crate) use batch_run::MAX_SUBFOLDER_DEPTH;
use batch_run::batch;
pub use batch_run::same_format as batch_same_format;
use batch_run::same_format;
#[cfg(not(target_arch = "wasm32"))]
pub use batch_run::validate_flat_outputs;
pub use batch_run::{BatchInput, OutputClaims, encode_batch_error_log, list_images_tree};
pub(crate) use batch_run::{batch_inputs, batch_keeps_step, batch_save_point, process_files};
pub use export::{LayerExportReport, SkippedExportLayer, bake_cube, export_layers_report_with, export_layers_with};
use export::{color_lookup_tables, layers_to_files, push_unique};
use info::file_info;
@@ -46,8 +51,8 @@ use paths::is_file;
#[cfg(test)]
use paths::openable_name;
pub use paths::{
extension, holds, holds_at, is_template, layered_extension, list_images, losses_at, portable_device_name, same_file, saves_in_place, template_name,
untitled_name,
extension, holds, holds_at, is_template, layered_extension, list_images, losses_at, portable_device_name, same_destination, same_file, saves_in_place,
template_name, untitled_name,
};
pub(crate) use paths::{file_name, forward_slashes, is_windows_device_name, join, read_file, sanitize, stem, write_file};
use processing::{conditional_mode_change, fit_image, flatten_all_effects, flatten_all_masks, image_processor, load_files_into_stack};
@@ -332,7 +337,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
&["File", "Automate"],
None,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; Save/Save As marks the result),"source":"folder|open"="folder" (open: run on live open documents),"input":folder|[paths] (folder source),"includeSubfolders":bool=false (up to 32 levels and 100000 files, outputs are flat under output),"output":folder (optional for open source),"format":"same|png|jpg|psd|tiff|…"="same","naming":string? (tokens: {name}/{NAME}/{Name}, {serial}/{serial:1..4}, {letter}/{LETTER}, {date} and date formats, {ext}/{EXT}; the naming template determines the whole filename),"startingSerial":1..4294967295=1,"stopOnError":bool=false,"errorLog":path? (blank disables it),"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]?} → {files, errors, stopped?, skipped?, errorLog?, documents?} (folder batches report per-file job progress and support cancellation)"##,
native,
batch
),
+132 -31
View File
@@ -9,9 +9,67 @@ fn bad(message: impl Into<String>) -> EngineError {
enum Part<'a> {
Literal(&'a str),
Name,
Serial,
Date,
Name(NameCase),
Serial(usize),
Letter(bool),
Date(DateFormat),
Ext(bool),
}
#[derive(Clone, Copy)]
enum NameCase {
AsIs,
Upper,
Title,
}
#[derive(Clone, Copy)]
enum DateFormat {
Iso,
Mmddyy,
Mmdd,
Yyyymmdd,
Yymmdd,
Yyddmm,
Ddmmyy,
}
#[derive(Clone, Copy)]
struct Date {
year: u32,
month: u32,
day: u32,
}
impl Date {
fn from_iso(value: &str) -> Option<Self> {
Some(Self { year: value.get(..4)?.parse().ok()?, month: value.get(5..7)?.parse().ok()?, day: value.get(8..10)?.parse().ok()? })
}
fn format(self, kind: DateFormat) -> String {
let (year, month, day, short) = (self.year, self.month, self.day, self.year % 100);
match kind {
DateFormat::Iso => format!("{year:04}-{month:02}-{day:02}"),
DateFormat::Mmddyy => format!("{month:02}{day:02}{short:02}"),
DateFormat::Mmdd => format!("{month:02}{day:02}"),
DateFormat::Yyyymmdd => format!("{year:04}{month:02}{day:02}"),
DateFormat::Yymmdd => format!("{short:02}{month:02}{day:02}"),
DateFormat::Yyddmm => format!("{short:02}{day:02}{month:02}"),
DateFormat::Ddmmyy => format!("{day:02}{month:02}{short:02}"),
}
}
}
fn letters(mut serial: u32, upper: bool) -> String {
let mut bytes = Vec::new();
while serial > 0 {
serial -= 1;
let letter = b'a' + (serial % 26) as u8;
bytes.push(if upper { letter.to_ascii_uppercase() } else { letter });
serial /= 26;
}
bytes.reverse();
String::from_utf8(bytes).unwrap_or_default()
}
fn parse(template: &str) -> Result<Vec<Part<'_>>> {
@@ -25,10 +83,25 @@ fn parse(template: &str) -> Result<Vec<Part<'_>>> {
}
let end = rest.find('}').ok_or_else(|| bad("naming: unclosed token"))?;
parts.push(match &rest[1..end] {
"name" => Part::Name,
"serial" => Part::Serial,
"date" => Part::Date,
token => return Err(bad(format!("naming: unknown token {{{token}}}; use {{name}}, {{serial}} or {{date}}"))),
"name" => Part::Name(NameCase::AsIs),
"NAME" => Part::Name(NameCase::Upper),
"Name" => Part::Name(NameCase::Title),
"serial" => Part::Serial(3),
"letter" => Part::Letter(false),
"LETTER" => Part::Letter(true),
"date" => Part::Date(DateFormat::Iso),
"date:mmddyy" => Part::Date(DateFormat::Mmddyy),
"date:mmdd" => Part::Date(DateFormat::Mmdd),
"date:yyyymmdd" => Part::Date(DateFormat::Yyyymmdd),
"date:yymmdd" => Part::Date(DateFormat::Yymmdd),
"date:yyddmm" => Part::Date(DateFormat::Yyddmm),
"date:ddmmyy" => Part::Date(DateFormat::Ddmmyy),
"ext" => Part::Ext(false),
"EXT" => Part::Ext(true),
token => match token.strip_prefix("serial:").and_then(|value| value.parse::<usize>().ok()) {
Some(width) if (1..=4).contains(&width) => Part::Serial(width),
_ => return Err(bad(format!("naming: unknown token {{{token}}}"))),
},
});
rest = &rest[end + 1..];
}
@@ -36,14 +109,35 @@ fn parse(template: &str) -> Result<Vec<Part<'_>>> {
Ok(parts)
}
fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String {
fn expand(parts: &[Part<'_>], name: &str, ext: &str, serial: u32, date: Date) -> String {
let mut out = String::new();
for part in parts {
for (index, part) in parts.iter().enumerate() {
match part {
Part::Literal(s) => out.push_str(s),
Part::Name => out.push_str(name),
Part::Serial => out.push_str(&format!("{serial:03}")),
Part::Date => out.push_str(date),
Part::Name(NameCase::AsIs) => out.push_str(name),
Part::Name(NameCase::Upper) => out.push_str(&name.to_uppercase()),
Part::Name(NameCase::Title) => {
let mut chars = name.chars();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(&chars.as_str().to_lowercase());
}
}
Part::Serial(width) => out.push_str(&format!("{serial:0width$}")),
Part::Letter(upper) => out.push_str(&letters(serial, *upper)),
Part::Date(kind) => out.push_str(&date.format(*kind)),
Part::Ext(upper) => {
// The reference app adds the separator only for the final extension component.
// A literal dot in the template already supplies that separator.
if !parts[index + 1..].iter().any(|part| !matches!(part, Part::Literal(""))) && !out.ends_with('.') {
out.push('.');
}
if *upper {
out.push_str(&ext.to_uppercase());
} else {
out.push_str(ext);
}
}
}
}
out
@@ -51,7 +145,7 @@ fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String {
/// An absent or empty template preserves the old filename path exactly. An explicit template
/// generates one component per input; the export format supplies the extension separately.
pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result<Option<Vec<String>>> {
pub fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result<Option<Vec<String>>> {
let start = match params.get("startingSerial") {
None => 1,
Some(n) => {
@@ -70,30 +164,23 @@ pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &V
return Err(bad("naming must be at most 255 bytes"));
}
let parts = parse(template)?;
let has_serial = parts.iter().any(|p| matches!(p, Part::Serial));
let has_serial = parts.iter().any(|p| matches!(p, Part::Serial(_) | Part::Letter(_)));
let timestamp = crate::analysis_cmds::now_iso();
let date = timestamp.get(..10).unwrap_or("");
if date.is_empty() && parts.iter().any(|p| matches!(p, Part::Date)) {
return Err(bad("naming: the current UTC date is unavailable"));
}
let date = Date::from_iso(&timestamp).ok_or_else(|| bad("naming: the current UTC date is unavailable"))?;
let mut outputs = Vec::with_capacity(inputs.len());
for (index, BatchInput { path, rel_dir }) in inputs.iter().enumerate() {
for (index, BatchInput { path, .. }) in inputs.iter().enumerate() {
let serial = if has_serial {
u32::try_from(index).ok().and_then(|index| start.checked_add(index)).ok_or_else(|| bad("startingSerial plus the input count exceeds 4294967295"))?
} else {
start
};
let base = expand(&parts, &stem(path), serial, date);
if base.is_empty() || base.ends_with(['.', ' ']) {
return Err(bad("naming must produce a nonempty filename stem without a trailing dot or space"));
}
let ext = if format == "same" { super::same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
let name = format!("{base}.{ext}");
if name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) {
return Err(bad("naming must produce a filename of at most 255 bytes without path separators or reserved characters"));
let name = expand(&parts, &stem(path), &ext, serial, date);
if name.is_empty() || name.ends_with(['.', ' ']) || name.len() > 255 || name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c)) {
return Err(bad("naming must produce a nonempty filename of at most 255 bytes without trailing spaces, path separators or reserved characters"));
}
// From subfolders ("Include All Subfolders"), each result keeps its subfolder.
outputs.push(join(&join(output, rel_dir), &name));
// Photoshop Batch writes all subfolder results into the destination itself.
outputs.push(join(output, &name));
}
// Renaming within the source folder could replace a later input before it is read. Resolve
// existing paths so aliases such as `source/./001.png` cannot bypass the check. The default
@@ -102,7 +189,9 @@ pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &V
{
let sources: std::collections::HashSet<_> = inputs.iter().filter_map(|i| std::fs::canonicalize(&i.path).ok()).collect();
for output in &outputs {
if std::fs::canonicalize(output).ok().is_some_and(|p| sources.contains(&p)) {
let identity = super::paths::destination_identity(std::path::Path::new(output))
.ok_or_else(|| bad(format!("naming output path cannot be resolved safely: {output}")))?;
if sources.contains(&identity) {
return Err(bad(format!("naming would replace an input file: {output}; choose a different destination or template")));
}
}
@@ -117,7 +206,19 @@ mod tests {
#[test]
fn substituted_names_are_literal_and_serials_do_not_wrap_or_truncate() {
let parts = parse("{date}_{name}_{serial}").unwrap();
assert_eq!(expand(&parts, "photo{serial}", 7, "2026-10-13"), "2026-10-13_photo{serial}_007");
assert_eq!(expand(&parts, "photo", 1000, "2026-10-13"), "2026-10-13_photo_1000");
let date = Date { year: 2026, month: 10, day: 13 };
assert_eq!(expand(&parts, "photo{serial}", "png", 7, date), "2026-10-13_photo{serial}_007");
assert_eq!(expand(&parts, "photo", "png", 1000, date), "2026-10-13_photo_1000");
let parts = parse("{NAME}_{Name}_{serial:1}_{letter}{LETTER}_{date:mmddyy}.{EXT}").unwrap();
assert_eq!(expand(&parts, "my Photo", "png", 27, date), "MY PHOTO_My photo_27_aaAA_101326.PNG");
}
#[test]
fn extension_component_only_adds_a_dot_at_the_end() {
let date = Date { year: 2026, month: 11, day: 1 };
for (template, expected) in [("{name}{ext}", "a.png"), ("{name}{ext}{serial}", "apng001"), ("{name}{serial}", "a001"), ("{name}{ext}{ext}", "apng.png")]
{
assert_eq!(expand(&parse(template).unwrap(), "a", "png", 1, date), expected, "{template}");
}
}
}
+483 -78
View File
@@ -1,7 +1,7 @@
use super::open::import_at_raw_ppi;
use super::paths::{extension, file_name, join, list_images, read_file, stem};
use super::paths::{extension, file_name, join, list_images, read_file, stem, write_file};
use super::processing::parse_steps;
use super::save::{SaveOpts, save_doc};
use super::save::{SaveOpts, encode, save_doc, save_doc_as};
use super::{Arc, Document, EngineError, Result, Session, Value, batch_naming, json, str_param};
/// Inputs of a batch command: `"input"` is a folder or an array of files.
@@ -16,16 +16,18 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result<Vec<String>> {
/// One input of a batch run: the file, and the folder it sits in relative to the input folder
/// (`""` at the top level, `"a/b"` two levels down), which its result keeps under `output`.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct BatchInput {
pub struct BatchInput {
pub path: String,
pub rel_dir: String,
}
/// Subfolders below a batch input folder are walked at most this deep ("Include All Subfolders").
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const MAX_SUBFOLDER_DEPTH: usize = 32;
/// A batch over subfolders lists at most this many files; a larger tree is an error, not a run
/// that silently stops partway.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000;
/// [`batch_inputs`] for the commands whose Photoshop dialog has "Include All Subfolders" (Batch,
@@ -47,9 +49,9 @@ pub(crate) fn batch_inputs_tree(p: &Value, cmd: &str, output: &str) -> Result<Ve
/// The images under `dir` and its subfolders: each folder's files sorted by name, then its
/// subfolders in name order (depth first). Bounded by [`MAX_SUBFOLDER_DEPTH`] and
/// [`MAX_SUBFOLDER_FILES`]; a folder reached twice (a symlink loop) is walked once, and `skip`
/// (the output folder) is left out.
/// (the output folder) is left out. An unreadable reached folder is an error.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<BatchInput>> {
pub fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<BatchInput>> {
use std::path::{Path, PathBuf};
let canon = |p: &Path| std::fs::canonicalize(p).ok();
let root = Path::new(dir);
@@ -62,12 +64,8 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<B
let mut stack: Vec<(PathBuf, String, usize)> = vec![(root.to_path_buf(), String::new(), 0)];
while let Some((folder, rel, depth)) = stack.pop() {
let shown = folder.to_string_lossy().into_owned();
// The top folder must be readable; a subfolder that isn't is skipped.
let files = match list_images(&shown) {
Ok(f) => f,
Err(e) if depth == 0 => return Err(e),
Err(_) => continue,
};
// Fail before writing any output if a reached input folder cannot be read.
let files = list_images(&shown)?;
if out.len().saturating_add(files.len()) > MAX_SUBFOLDER_FILES {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} images with its subfolders") });
}
@@ -75,17 +73,20 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<B
if depth >= MAX_SUBFOLDER_DEPTH {
continue;
}
let Ok(rd) = std::fs::read_dir(&folder) else { continue };
let mut subs: Vec<(String, PathBuf)> = rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.filter_map(|p| Some((p.file_name()?.to_string_lossy().into_owned(), p)))
.collect();
let rd = std::fs::read_dir(&folder).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let mut subs: Vec<(String, PathBuf)> = Vec::new();
for entry in rd {
let path = entry.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?.path();
if path.is_dir()
&& let Some(name) = path.file_name()
{
subs.push((name.to_string_lossy().into_owned(), path.clone()));
}
}
subs.sort();
// Pushed in reverse so the stack pops them in name order.
for (name, sub) in subs.into_iter().rev() {
let Some(c) = canon(&sub) else { continue };
let c = canon(&sub).ok_or_else(|| EngineError::Other(format!("{}: cannot resolve the subfolder", sub.display())))?;
if skip.as_ref() == Some(&c) || !seen.insert(c) {
continue;
}
@@ -97,7 +98,7 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<B
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result<Vec<BatchInput>> {
pub fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result<Vec<BatchInput>> {
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
}
@@ -142,7 +143,9 @@ pub(crate) fn process_tree(
/// codec formats without a writer in this build. Those are saved as PSD, the format Photoshop's
/// Save As offers for a raw. An input whose file name has no extension (a dot in a folder name
/// doesn't count) is saved as PNG.
pub(super) fn same_format(path: &str) -> String {
/// The format Batch's "Save in the same format" writes for `path`: its own, or PSD for a file
/// PhotoCraft only opens.
pub fn same_format(path: &str) -> String {
const IMPORT_ONLY: &[&str] =
&["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm", "xcf", "xcfgz", "gz", "psdt"];
let ext = extension(path).unwrap_or_else(|| "png".into());
@@ -188,18 +191,7 @@ fn process_outputs<'a>(
let mut errors = Vec::new();
let mut written = OutputClaims::default();
for (path, out) in outputs {
let r = (|| -> Result<()> {
written.check(&out).map_err(EngineError::Other)?;
let bytes = read_file(path)?;
let mut scratch = Session::new();
let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?;
scratch.add_document(doc, Some(path.to_string()));
if !f(&mut scratch, &out)? {
let d = scratch.active().ok_or(EngineError::NoDocument)?;
save_doc(&d.doc, &out, save)?;
}
Ok(())
})();
let r = process_one(raw_ppi, path, &out, save, ProcessOptions { format: None, written: Some(&written), ctx: None }, f);
match r {
Ok(()) => {
written.record(&out, path);
@@ -211,6 +203,248 @@ fn process_outputs<'a>(
json!({"files": files, "errors": errors})
}
struct ProcessOptions<'a> {
format: Option<&'a str>,
written: Option<&'a OutputClaims>,
ctx: Option<&'a crate::jobs::JobCtx>,
}
fn process_one(raw_ppi: f64, path: &str, out: &str, save: SaveOpts, options: ProcessOptions<'_>, f: &dyn Fn(&mut Session, &str) -> Result<bool>) -> Result<()> {
if let Some(written) = options.written {
written.check(out).map_err(EngineError::Other)?;
}
let bytes = read_file(path)?;
let mut scratch = Session::new();
let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?;
scratch.add_document(doc, Some(path.to_string()));
if let Some(ctx) = options.ctx {
ctx.publish_batch_preview(path, scratch.active().map(|state| state.doc.clone()));
}
let saved_by_action = f(&mut scratch, out)?;
if saved_by_action {
// The action's saved snapshot is already written: a cancel or Esc arriving now stops the
// inputs after this one, not this result.
return Ok(());
}
if let Some(ctx) = options.ctx {
ctx.check()?;
if ctx.batch_interrupt_requested() {
return Err(EngineError::Cancelled);
}
}
let d = scratch.active().ok_or(EngineError::NoDocument)?;
if let Some(format) = options.format {
let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
if let Some(ctx) = options.ctx {
save_batch_doc(&d.doc, out, &ext, save, ctx)?;
} else {
save_doc_as(&d.doc, out, &ext, save)?;
}
} else {
save_doc(&d.doc, out, save)?;
}
Ok(())
}
/// Encode before the Batch write checkpoint. Esc accepted during an action or encode therefore
/// leaves the current destination untouched; the worker owns the write once the checkpoint passes.
fn save_batch_doc(doc: &Document, out: &str, format: &str, save: SaveOpts, ctx: &crate::jobs::JobCtx) -> Result<()> {
let (bytes, _) = encode(doc, format, save)?;
ctx.batch_before_write()?;
write_file(out, &bytes)?;
Ok(())
}
#[derive(Clone, Debug)]
pub(super) struct BatchPolicy {
pub(super) stop_on_error: bool,
pub(super) error_log: Option<String>,
}
impl BatchPolicy {
fn parse(p: &Value, cmd: &str) -> Result<Self> {
let stop_on_error = match p.get("stopOnError") {
None | Some(Value::Null) => false,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"stopOnError\" must be true or false".into() }),
};
let error_log = match p.get("errorLog") {
None | Some(Value::Null) => None,
Some(Value::String(path)) if path.trim().is_empty() => None,
Some(Value::String(path)) => Some(path.clone()),
Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"errorLog\" must be a file path string".into() }),
};
Ok(Self { stop_on_error, error_log })
}
}
/// Photoshop-compatible Batch log encoding, shared by the desktop command and CLI.
pub fn encode_batch_error_log<'a>(errors: impl IntoIterator<Item = (&'a str, &'a str)>) -> Vec<u8> {
let mut body = String::from("Batch Start\r\n\r\n");
for (input, reason) in errors {
body.push_str(&format!("File: \"{input}\"\r\nError: {reason}\r\n\r\n"));
}
body.push_str("Batch End\r\n");
let mut bytes = vec![0xff, 0xfe];
for word in body.encode_utf16() {
bytes.extend_from_slice(&word.to_le_bytes());
}
bytes
}
fn write_error_log(path: &str, errors: &[Value]) -> Result<()> {
let bytes = encode_batch_error_log(errors.iter().map(|error| {
(error.get("file").or_else(|| error.get("document")).and_then(Value::as_str).unwrap_or("?"), error.get("error").and_then(Value::as_str).unwrap_or("?"))
}));
super::paths::write_file(path, &bytes).map_err(|error| EngineError::Other(format!("error log: {error}")))
}
fn error_log_failure(path: Option<&str>, errors: &[Value]) -> Option<String> {
path.and_then(|path| write_error_log(path, errors).err().map(|error| error.to_string()))
}
fn batch_outcome(files: Vec<String>, errors: Vec<Value>, stopped: Option<(String, Vec<String>)>, error_log: Option<&str>) -> Value {
let mut result = json!({"files": files, "errors": errors});
if let Some((at, skipped)) = stopped {
result["stopped"] = json!(at);
result["skipped"] = json!(skipped);
}
if let Some(path) = error_log {
result["errorLog"] = json!(path);
}
result
}
fn validate_error_log(policy: &BatchPolicy, inputs: impl IntoIterator<Item = String>, outputs: impl IntoIterator<Item = String>) -> Result<()> {
let Some(log) = &policy.error_log else { return Ok(()) };
let conflicts = |path: String| super::paths::same_destination(log, &path);
if inputs.into_iter().chain(outputs).any(conflicts) {
return Err(EngineError::BadParams { cmd: "file.automate.batch".into(), msg: "\"errorLog\" must not replace an input or batch output".into() });
}
Ok(())
}
/// A flat Batch result must not replace another input before it is opened. Compare resolved
/// identities once for the whole plan, so aliases and large folder batches stay safe and linear.
#[cfg(not(target_arch = "wasm32"))]
pub fn validate_flat_outputs(outputs: &[(String, String)]) -> Result<()> {
let sources: std::collections::HashMap<_, _> =
outputs.iter().enumerate().filter_map(|(index, (input, _))| std::fs::canonicalize(input).ok().map(|identity| (identity, index))).collect();
for (index, (_, output)) in outputs.iter().enumerate() {
let identity = super::paths::destination_identity(std::path::Path::new(output))
.ok_or_else(|| EngineError::BadParams { cmd: "file.automate.batch".into(), msg: format!("output path cannot be resolved safely: {output}") })?;
if sources.get(&identity).is_some_and(|&source_index| source_index != index) {
return Err(EngineError::BadParams {
cmd: "file.automate.batch".into(),
msg: format!("output would replace another input file: {output}; choose a different destination or naming template"),
});
}
}
Ok(())
}
pub(super) fn process_batch_outputs(
raw_ppi: f64,
outputs: Vec<(String, String)>,
format: &str,
save: SaveOpts,
policy: &BatchPolicy,
ctx: &crate::jobs::JobCtx,
f: &dyn Fn(&mut Session, &str) -> Result<bool>,
) -> Result<Value> {
let total = outputs.len();
let mut files = Vec::new();
let mut errors = Vec::new();
let mut stopped = None;
let mut done = 0;
let mut interrupted = Vec::new();
for (index, (path, out)) in outputs.iter().enumerate() {
if ctx.cancelled() {
break;
}
if ctx.batch_take_interrupt() && !ctx.batch_wait_for_decision() {
ctx.publish_batch_preview(path, None);
ctx.cancel();
break;
}
ctx.progress(index as f32 / total.max(1) as f32, &format!("{} of {total}: {}", index + 1, file_name(path)));
let result = process_one(raw_ppi, path, out, save, ProcessOptions { format: Some(format), written: None, ctx: Some(ctx) }, f);
// An input that failed on its own (not because it was stopped) stays a failure with its
// reason, even when a cancel or Esc lands while it runs.
let failure =
result.as_ref().err().filter(|error| !matches!(error, EngineError::Cancelled)).map(|error| json!({"file": path, "error": error.to_string()}));
if ctx.cancelled() {
ctx.publish_batch_preview(path, None);
// A cancel that lands while the result is written still leaves that file written.
if result.is_ok() {
files.push(out.clone());
done = index + 1;
} else if let Some(failure) = failure {
errors.push(failure);
done = index + 1;
} else {
done = index;
}
break;
}
if ctx.batch_take_interrupt() {
let continue_next = ctx.batch_wait_for_decision();
ctx.publish_batch_preview(path, None);
match failure {
Some(failure) => {
errors.push(failure);
done = index + 1;
}
None if continue_next => {
interrupted.push(path.clone());
done = index + 1;
}
None => {}
}
if continue_next {
continue;
}
ctx.cancel();
break;
}
ctx.batch_after_write();
ctx.publish_batch_preview(path, None);
done = index + 1;
match result {
Ok(()) => {
files.push(out.clone());
}
Err(error) => {
errors.push(json!({"file": path, "error": error.to_string()}));
if policy.stop_on_error {
stopped = Some(path.clone());
break;
}
}
}
}
// No checkpoint follows the last input: a later Esc has nothing to interrupt.
ctx.batch_close_interrupts();
let log_failure = error_log_failure(policy.error_log.as_deref(), &errors);
let cancelled = ctx.cancelled();
if !cancelled {
ctx.progress(1.0, "");
}
let mut skipped = interrupted;
skipped.extend(outputs.iter().skip(done).map(|(path, _)| path.clone()));
let mut result = batch_outcome(files, errors, stopped.map(|at| (at, skipped.clone())), policy.error_log.as_deref().filter(|_| log_failure.is_none()));
if !skipped.is_empty() {
result["skipped"] = json!(skipped);
}
if cancelled {
result["cancelled"] = json!(true);
}
if let Some(error) = log_failure {
result["errorLogError"] = json!(error);
}
Ok(result)
}
/// The output paths a batch run has written, so a later input whose output name matches an
/// earlier one's (`a.png` and `a.jpg` saved as JPEG, or the same name in two input folders) is
/// reported instead of silently replacing that result (#420, #422). Names are compared ignoring
@@ -254,8 +488,22 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
if let Some((id, _)) = steps.iter().find(|(id, _)| !batch_save_point(id) && crate::commands::find(id).is_none()) {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") });
}
let output = str_param(p, "output", cmd)?.to_string();
let inputs = batch_inputs_tree(p, cmd, &output)?;
let policy = BatchPolicy::parse(p, cmd)?;
let source = match p.get("source") {
None | Some(Value::Null) => "folder",
Some(Value::String(source)) if matches!(source.as_str(), "folder" | "open") => source.as_str(),
_ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"source\" must be \"folder\" or \"open\"".into() }),
};
let output = if source == "folder" {
str_param(p, "output", cmd)?.to_string()
} else {
match p.get("output") {
None | Some(Value::Null) => String::new(),
Some(Value::String(path)) => path.clone(),
Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"output\" must be a folder path".into() }),
}
};
let inputs = if source == "folder" { batch_inputs_tree(p, cmd, &output)? } else { Vec::new() };
let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string();
// Steps that call another action play it from the caller's actions; the playback stack comes
// along so an action that batches itself is caught as a recursive call.
@@ -275,9 +523,10 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
// decoded and saved even with no steps) to the playback tree it is in and
// hands the count on, so a batch of batches can't multiply past the limit. A top-level batch
// is not limited by it.
let input_count = if source == "open" { s.documents().len() } else { inputs.len() };
let mut playback_steps = 0;
if s.actions.playing > 0 {
playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(inputs.len() as u64));
playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(input_count as u64));
if playback_steps > crate::actions_cmds::MAX_PLAYBACK_STEPS {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: "this batch would exceed the playback step limit of the action that runs it".into() });
}
@@ -290,60 +539,216 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
playback_steps,
..Default::default()
};
if source == "open" {
let save = SaveOpts::from_params(s, p, cmd)?;
if p.get("calledActions").is_some_and(|value| !value.is_null()) {
let previous = std::mem::replace(&mut s.actions.list, actions.list.clone());
let result = batch_open_documents(s, &steps, p, &output, &format, &policy, save);
s.actions.list = previous;
return result;
}
return batch_open_documents(s, &steps, p, &output, &format, &policy, save);
}
let names = batch_naming::plan(&inputs, &output, &format, p)?;
let save = SaveOpts::from_params(s, p, cmd)?;
let authorize = s.authorize;
let raw_ppi = s.prefs().raw_defaults.resolution;
let outputs: Vec<(String, String)> = match names {
Some(names) => inputs.iter().map(|input| input.path.clone()).zip(names).collect(),
None => inputs
.iter()
.map(|input| {
let ext = if format == "same" { same_format(&input.path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
let out = join(&output, &format!("{}.{ext}", stem(&input.path)));
(input.path.clone(), out)
})
.collect(),
};
#[cfg(not(target_arch = "wasm32"))]
validate_flat_outputs(&outputs)?;
validate_error_log(&policy, outputs.iter().map(|(input, _)| input.clone()), outputs.iter().map(|(_, output)| output.clone()))?;
// Each input runs in its own scratch session, so what the played steps spend is carried from
// one input to the next (failed or not) and back to this session, not restarted per input.
let carried = std::cell::Cell::new(playback_steps);
let carried = &carried;
let run = move |scratch: &mut Session, out: &str| {
scratch.authorize = authorize;
scratch.actions = actions.clone();
scratch.actions.playback_steps = carried.get();
let result = (|| {
// The document as the last recorded save found it, written once the action has run so a
// failing step still writes nothing.
let was_playing = s.actions.playing > 0;
crate::jobs::run(
s,
"Batch",
false,
move |ctx| {
let carried = std::cell::Cell::new(playback_steps);
let run = |scratch: &mut Session, out: &str| {
scratch.authorize = authorize;
scratch.actions = actions.clone();
scratch.actions.playback_steps = carried.get();
let result = (|| {
// A recorded save marks a result. If the action later closes or switches
// documents, write the saved snapshot instead of the new active document.
let source = scratch.active().and_then(|state| state.path.as_deref()).unwrap_or("").to_string();
let encode_format = if format == "same" { same_format(&source) } else { format.trim_start_matches('.').to_ascii_lowercase() };
let mut saved: Option<Arc<Document>> = None;
for (id, params) in &steps {
ctx.check()?;
if ctx.batch_interrupt_requested() {
return Err(EngineError::Cancelled);
}
if batch_save_point(id) {
let state = scratch.active_mut().ok_or(EngineError::NoDocument)?;
saved = Some(state.doc.clone());
// The destination save is performed by Batch; subsequent recorded
// Close steps see Save / Save As as saved, but Save a Copy as changed.
if id != "file.saveACopy" {
state.saved_revision = state.revision;
}
continue;
}
let result = scratch.execute(id, params.clone())?;
ctx.publish_batch_preview(&source, scratch.active().map(|state| state.doc.clone()));
if let Some(error) = crate::actions_cmds::nested_failure(id, &result) {
return Err(EngineError::Other(error));
}
}
match saved {
Some(doc) if scratch.active().is_none_or(|active| active.doc.id != doc.id) => {
ctx.check()?;
save_batch_doc(&doc, out, &encode_format, save, ctx)?;
Ok(true)
}
_ => Ok(false),
}
})();
carried.set(scratch.actions.playback_steps);
result
};
let result = process_batch_outputs(raw_ppi, outputs, &format, save, &policy, ctx, &run);
Ok((result, carried.get()))
},
move |s, (result, carried)| {
if was_playing {
s.actions.playback_steps = carried;
}
result
},
)
}
/// Source: Opened Files runs on the live documents, so edits remain in their histories.
/// Documents are identified before playback; a step that closes or reorders tabs cannot make
/// the next input silently become a different document.
fn batch_open_documents(
s: &mut Session,
steps: &[(String, Value)],
p: &Value,
output: &str,
format: &str,
policy: &BatchPolicy,
save: SaveOpts,
) -> Result<Value> {
let originals: Vec<(u64, String, String)> = s
.documents()
.iter()
.map(|state| {
let path = state.path.clone().unwrap_or_else(|| format!("{}.psd", state.doc.name));
(state.doc.id.0, state.doc.name.clone(), path)
})
.collect();
if originals.is_empty() {
return Err(EngineError::NoDocument);
}
let previous = s.active().map(|state| state.doc.id.0);
let virtual_inputs: Vec<BatchInput> = originals.iter().map(|(_, _, path)| BatchInput { path: path.clone(), rel_dir: String::new() }).collect();
let planned = if output.is_empty() {
None
} else {
let named = batch_naming::plan(&virtual_inputs, output, format, p)?;
Some(match named {
Some(paths) => paths,
None => virtual_inputs
.iter()
.map(|input| {
let ext = if format == "same" { same_format(&input.path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
join(output, &format!("{}.{ext}", stem(&input.path)))
})
.collect(),
})
};
// As for a folder: no result may replace another open document's file.
#[cfg(not(target_arch = "wasm32"))]
if let Some(paths) = &planned {
let plan: Vec<(String, String)> = originals.iter().map(|(_, _, path)| path.clone()).zip(paths.iter().cloned()).collect();
validate_flat_outputs(&plan)?;
}
validate_error_log(policy, originals.iter().map(|(_, _, path)| path.clone()), planned.iter().flat_map(|paths| paths.iter().cloned()))?;
let mut files = Vec::new();
let mut errors = Vec::new();
let mut stopped = None;
let mut done = 0;
for (index, (id, name, path)) in originals.iter().enumerate() {
// The document a recorded Save / Save As marked as saved, with its earlier saved
// revision: when Batch then writes nothing, it reads as changed again.
let mut marked: Option<(u64, u64)> = None;
let result = (|| -> Result<()> {
let position = s.documents().iter().position(|state| state.doc.id.0 == *id).ok_or(EngineError::NoDocument)?;
s.set_active(position);
let mut saved: Option<Arc<Document>> = None;
for (id, params) in &steps {
if batch_save_point(id) {
let st = scratch.active_mut().ok_or(EngineError::NoDocument)?;
saved = Some(st.doc.clone());
// Batch performs the action's Save / Save As (to its destination), so the
// document is saved there as far as the steps after it see: a Close recorded
// without a save policy (`"save":"ask"`) then closes it. A Save a Copy leaves
// the document as changed as it was.
if id != "file.saveACopy" {
st.saved_revision = st.revision;
for (command, params) in steps {
if batch_save_point(command) {
let state = s.active_mut().ok_or(EngineError::NoDocument)?;
saved = Some(state.doc.clone());
// With no destination nothing is written, so the document stays changed.
if command != "file.saveACopy" && planned.is_some() {
if marked.is_none() {
marked = Some((state.doc.id.0, state.saved_revision));
}
state.saved_revision = state.revision;
}
continue;
}
let r = scratch.execute(id, params.clone())?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(error) = crate::actions_cmds::nested_failure(id, &r) {
let response = s.execute(command, params.clone())?;
if let Some(error) = crate::actions_cmds::nested_failure(command, &response) {
return Err(EngineError::Other(error));
}
}
// While the saved document is still open the steps after the save reach the result too
// (#3058), so it is saved after the action as usual. Closed after its save, or with another
// document left active, it is written as the save found it.
match saved {
Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => {
save_doc(&doc, out, save)?;
Ok(true)
}
_ => Ok(false),
}
let Some(paths) = &planned else { return Ok(()) };
let out = paths.get(index).ok_or_else(|| EngineError::Other("batch output plan is incomplete".into()))?;
let doc = match saved {
Some(saved) if s.active().is_some_and(|state| state.doc.id == saved.id) => s.active().ok_or(EngineError::NoDocument)?.doc.clone(),
Some(saved) => saved,
None => s.documents().iter().find(|state| state.doc.id.0 == *id).ok_or(EngineError::NoDocument)?.doc.clone(),
};
let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
save_doc_as(&doc, out, &ext, save)?;
files.push(out.clone());
Ok(())
})();
carried.set(scratch.actions.playback_steps);
result
};
let r = match names {
Some(names) => process_outputs(s, inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run),
None => process_tree(s, &inputs, &output, &format, save, "", &run),
};
if s.actions.playing > 0 {
s.actions.playback_steps = carried.get();
done = index + 1;
if result.is_err()
&& let Some((doc, revision)) = marked
&& let Some(position) = s.documents().iter().position(|state| state.doc.id.0 == doc)
{
s.set_active(position);
if let Some(state) = s.active_mut() {
state.saved_revision = revision;
}
}
if let Err(error) = result {
errors.push(json!({"document": name, "error": error.to_string()}));
if policy.stop_on_error {
stopped = Some(name.clone());
break;
}
}
}
Ok(r)
if let Some(id) = previous
&& let Some(position) = s.documents().iter().position(|state| state.doc.id.0 == id)
{
s.set_active(position);
}
let log_failure = error_log_failure(policy.error_log.as_deref(), &errors);
let skipped = originals.iter().skip(done).map(|(_, name, _)| name.clone()).collect();
let mut result = batch_outcome(files, errors, stopped.map(|at| (at, skipped)), policy.error_log.as_deref().filter(|_| log_failure.is_none()));
if let Some(error) = log_failure {
result["errorLogError"] = json!(error);
}
result["documents"] = json!(originals.len());
Ok(result)
}
+102 -6
View File
@@ -40,12 +40,19 @@ pub(crate) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
pub fn list_images(dir: &str) -> Result<Vec<String>> {
let rd = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?;
let mut out: Vec<String> = rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && openable_name(&p.to_string_lossy()))
.map(|p| p.to_string_lossy().into_owned())
.collect();
let mut out = Vec::new();
for entry in rd {
let path = entry.map_err(|e| EngineError::Other(format!("{dir}: {e}")))?.path();
if !openable_name(&path.to_string_lossy()) {
continue;
}
match std::fs::metadata(&path) {
Ok(metadata) if metadata.is_file() => out.push(path.to_string_lossy().into_owned()),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} // broken link or entry removed during traversal
Err(error) => return Err(EngineError::Other(format!("{}: {error}", path.display()))),
}
}
out.sort();
Ok(out)
}
@@ -181,6 +188,95 @@ pub fn same_file(a: impl AsRef<std::path::Path>, b: impl AsRef<std::path::Path>)
false
}
/// Resolve a future output path component by component. Existing symlinks are followed before
/// applying a later `..`, while components not created yet are kept so a new `x/../file` still
/// resolves to the file it would reach after `x` is created.
#[cfg(not(target_arch = "wasm32"))]
pub fn destination_identity(path: &std::path::Path) -> Option<std::path::PathBuf> {
use std::path::{Component, PathBuf};
let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().ok()?.join(path) };
let mut resolved = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
resolved.pop();
}
Component::Normal(_) => {
resolved.push(component.as_os_str());
match std::fs::symlink_metadata(&resolved) {
Ok(_) => resolved = std::fs::canonicalize(&resolved).ok()?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return None,
}
}
Component::Prefix(_) | Component::RootDir => resolved.push(component.as_os_str()),
}
}
Some(resolved)
}
/// Probe the actual destination directory. macOS volumes and Windows directories may use either
/// case behavior; the OS name alone cannot tell whether two future names will alias.
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(super) fn case_sensitive_destination(path: &std::path::Path) -> Option<bool> {
let parent = path.ancestors().skip(1).find(|ancestor| ancestor.is_dir())?;
let probe = tempfile::Builder::new().prefix(".PhotoCraftCaseProbe-").tempfile_in(parent).ok()?;
let name = probe.path().file_name()?.to_str()?;
let alternate = parent.join(name.replacen("PhotoCraft", "photocraft", 1));
match std::fs::metadata(&alternate) {
Ok(_) => Some(!same_file(probe.path(), &alternate)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(true),
Err(_) => None,
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn resolved_paths_match(a: &std::path::Path, b: &std::path::Path, case_sensitive: impl FnOnce() -> Option<bool>) -> bool {
if a == b {
return true;
}
match (a.to_str(), b.to_str()) {
(Some(a_text), Some(b_text)) if a_text.to_lowercase() == b_text.to_lowercase() => !case_sensitive().unwrap_or(false),
_ => false,
}
}
#[cfg(all(test, any(target_os = "macos", target_os = "windows")))]
#[test]
fn identical_resolved_paths_conflict_on_a_case_sensitive_volume() {
let path = std::path::Path::new("/future/result.png");
assert!(resolved_paths_match(path, path, || Some(true)));
assert!(!resolved_paths_match(path, std::path::Path::new("/future/other.png"), || panic!("unrelated names must not probe the volume")));
}
/// Whether two paths would name the same destination even before the file exists. This is used
/// when a batch error log must not be allowed to replace a result that will be written later.
pub fn same_destination(a: impl AsRef<std::path::Path>, b: impl AsRef<std::path::Path>) -> bool {
let (a, b) = (a.as_ref(), b.as_ref());
if same_file(a, b) {
return true;
}
#[cfg(not(target_arch = "wasm32"))]
{
match (destination_identity(a), destination_identity(b)) {
(Some(a), Some(b)) => {
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
resolved_paths_match(&a, &b, || case_sensitive_destination(&a))
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
a == b
}
}
_ => true,
}
}
#[cfg(target_arch = "wasm32")]
false
}
/// A path as command results report it: `/` between components, whatever the platform.
///
/// `join` already builds with `/`, but a directory from the caller or from `std::path::absolute`
+7
View File
@@ -254,6 +254,13 @@ pub(crate) fn save_doc(doc: &Document, path: &str, save: impl Into<SaveOpts>) ->
Ok(warnings)
}
/// Save Batch bytes in the chosen format even when its naming rules omit or replace the suffix.
pub(crate) fn save_doc_as(doc: &Document, path: &str, format: &str, save: impl Into<SaveOpts>) -> Result<Vec<String>> {
let (bytes, warnings) = encode(doc, format, save)?;
write_file(path, &bytes)?;
Ok(warnings)
}
pub(super) fn save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path", "file.saveACopy")?.to_string();
let d = s.active().ok_or(EngineError::NoDocument)?;
+649 -29
View File
@@ -31,6 +31,622 @@ fn composite(s: &Session, x: i32, y: i32) -> [f32; 4] {
b.px[0]
}
fn batch_log(path: &str) -> String {
let bytes = std::fs::read(path).unwrap();
assert!(bytes.starts_with(&[0xff, 0xfe]), "Batch log needs a UTF-16 LE BOM");
let (pairs, remainder) = bytes[2..].as_chunks::<2>();
assert!(remainder.is_empty(), "UTF-16 bytes must have even length");
let words: Vec<u16> = pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect();
String::from_utf16(&words).unwrap()
}
#[test]
fn batch_stop_on_error_reports_skipped_inputs_and_writes_the_error_log() {
let dir = tmp("batch-stop-error-log");
let good = png(&dir, "good.png", 8, 6, "#00ff00");
let missing = join(&dir, "missing.png");
let output = join(&dir, "out");
let log = join(&dir, "errors.txt");
let result = Session::new()
.execute("file.automate.batch", json!({"steps": [], "input": [missing, good], "output": output, "stopOnError": true, "errorLog": log}))
.unwrap();
assert_eq!(result["files"], json!([]), "{result}");
assert_eq!(result["errors"].as_array().map(Vec::len), Some(1), "{result}");
assert_eq!(result["stopped"], json!(missing), "{result}");
assert_eq!(result["skipped"], json!([good]), "{result}");
let text = batch_log(&log);
assert!(text.starts_with("Batch Start\r\n\r\n") && text.contains("missing.png") && text.contains("File: \"") && text.contains("Error: "), "{text}");
assert!(text.ends_with("Batch End\r\n"), "{text}");
assert!(!std::path::Path::new(&join(&output, "good.png")).exists());
}
#[test]
fn batch_writes_a_utf16_error_log_even_when_every_file_succeeds() {
let dir = tmp("batch-clean-log");
let input = png(&dir, "good.png", 8, 6, "#00ff00");
let output = join(&dir, "out");
let log = join(&dir, "errors.txt");
let result = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
assert_eq!(result["errorLog"], json!(log));
assert_eq!(batch_log(&log), "Batch Start\r\n\r\nBatch End\r\n");
}
#[test]
fn batch_cancelled_during_an_action_does_not_save_that_input() {
let dir = tmp("batch-cancel-current");
let input = png(&dir, "source.png", 8, 6, "#ff0000");
let out = join(&dir, "out/source.png");
let ctx = crate::jobs::JobCtx::new();
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
let result = super::batch_run::process_batch_outputs(72.0, vec![(input, out.clone())], "png", SaveOpts::default(), &policy, &ctx, &|_, _| {
ctx.cancel();
Ok(false)
})
.unwrap();
assert_eq!(result["files"], json!([]), "{result}");
assert_eq!(result["cancelled"], json!(true), "{result}");
assert!(!std::path::Path::new(&out).exists());
}
/// An action that saved and then closed its document has its saved snapshot written by the time
/// it returns: a cancel landing then counts that file as written, not skipped.
#[test]
fn batch_cancel_after_a_saved_snapshot_is_written_keeps_that_file() {
let dir = tmp("batch-cancel-after-snapshot");
let first = png(&dir, "first.png", 8, 6, "#ff0000");
let second = png(&dir, "second.png", 8, 6, "#00ff00");
let first_out = join(&dir, "out/first.png");
let ctx = crate::jobs::JobCtx::new();
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
let outputs = vec![(first, first_out.clone()), (second.clone(), join(&dir, "out/second.png"))];
let result = super::batch_run::process_batch_outputs(72.0, outputs, "png", SaveOpts::default(), &policy, &ctx, &|s, out| {
save_doc(&s.active().unwrap().doc, out, None).unwrap();
ctx.cancel();
Ok(true)
})
.unwrap();
assert_eq!(result["files"], json!([first_out]), "{result}");
assert_eq!(result["skipped"], json!([second]), "{result}");
assert_eq!(result["cancelled"], json!(true), "{result}");
}
/// A cancel that lands while an input fails on its own keeps that failure and its reason; only
/// the inputs after it are skipped.
#[test]
fn batch_cancel_during_a_failing_input_keeps_its_error() {
let dir = tmp("batch-cancel-failing");
let first = png(&dir, "first.png", 8, 6, "#ff0000");
let second = png(&dir, "second.png", 8, 6, "#00ff00");
let ctx = crate::jobs::JobCtx::new();
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
let outputs = vec![(first.clone(), join(&dir, "out/first.png")), (second.clone(), join(&dir, "out/second.png"))];
let result = super::batch_run::process_batch_outputs(72.0, outputs, "png", SaveOpts::default(), &policy, &ctx, &|_, _| {
ctx.cancel();
Err(EngineError::Other("boom".into()))
})
.unwrap();
assert_eq!(result["errors"], json!([{"file": first, "error": "boom"}]), "{result}");
assert_eq!(result["skipped"], json!([second]), "{result}");
assert_eq!(result["cancelled"], json!(true), "{result}");
}
/// Esc during an input that fails on its own: Continue records the failure (not an interrupted
/// input) and goes on with the next one.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn batch_interruption_during_a_failing_input_keeps_its_error() {
use std::time::Duration;
let dir = tmp("batch-interrupt-failing");
let first = png(&dir, "first.png", 8, 6, "#ff0000");
let second = png(&dir, "second.png", 8, 6, "#00ff00");
let second_out = join(&dir, "out/second.png");
let ctx = crate::jobs::JobCtx::new();
let worker_ctx = ctx.clone();
let outputs = vec![(first.clone(), join(&dir, "out/first.png")), (second, second_out.clone())];
let worker = std::thread::spawn(move || {
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
super::batch_run::process_batch_outputs(72.0, outputs, "png", SaveOpts::default(), &policy, &worker_ctx, &|_, path| {
if path.ends_with("first.png") {
assert!(worker_ctx.request_batch_interrupt());
return Err(EngineError::Other("boom".into()));
}
Ok(false)
})
});
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
std::thread::yield_now();
}
assert!(ctx.decide_batch_interrupt(true));
let result = worker.join().unwrap().unwrap();
assert_eq!(result["errors"], json!([{"file": first, "error": "boom"}]), "{result}");
assert_eq!(result["files"], json!([second_out]), "{result}");
assert!(result.get("skipped").is_none(), "{result}");
}
/// Once the last input is handled, Esc has nothing to interrupt and is refused, so the desktop
/// never waits for a question that will not come.
#[test]
fn batch_refuses_esc_after_its_last_input() {
let dir = tmp("batch-late-esc");
let input = png(&dir, "source.png", 8, 6, "#ff0000");
let ctx = crate::jobs::JobCtx::new();
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
let result =
super::batch_run::process_batch_outputs(72.0, vec![(input, join(&dir, "out/source.png"))], "png", SaveOpts::default(), &policy, &ctx, &|_, _| {
Ok(false)
})
.unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
assert!(!ctx.request_batch_interrupt());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn batch_interruption_continues_with_the_original_next_output() {
use std::sync::mpsc;
use std::time::Duration;
let dir = tmp("batch-interrupt-continue");
let first = png(&dir, "first.png", 8, 6, "#ff0000");
let second = png(&dir, "second.png", 8, 6, "#00ff00");
let first_out = join(&dir, "out/first_001.png");
let second_out = join(&dir, "out/second_002.png");
let ctx = crate::jobs::JobCtx::new();
let worker_ctx = ctx.clone();
let (opened_tx, opened_rx) = mpsc::channel();
let (resume_tx, resume_rx) = mpsc::channel();
let first_action = std::sync::atomic::AtomicBool::new(true);
let worker = std::thread::spawn(move || {
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
super::batch_run::process_batch_outputs(
72.0,
vec![(first, first_out.clone()), (second, second_out.clone())],
"png",
SaveOpts::default(),
&policy,
&worker_ctx,
&|_, _| {
if first_action.swap(false, std::sync::atomic::Ordering::AcqRel) {
let _ = opened_tx.send(());
let _ = resume_rx.recv_timeout(Duration::from_secs(10));
}
Ok(false)
},
)
.map(|result| (result, first_out, second_out))
});
opened_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let preview = ctx.batch_preview().expect("the real opened input is published before its action");
assert_eq!(preview.path, join(&dir, "first.png"));
assert_eq!((preview.document.size.width, preview.document.size.height), (8, 6));
assert!(ctx.request_batch_interrupt(), "Esc is accepted while the first action runs");
resume_tx.send(()).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
std::thread::yield_now();
}
assert!(ctx.batch_waiting_for_decision(), "the worker stopped before writing");
assert!(!std::path::Path::new(&join(&dir, "out/first_001.png")).exists());
assert!(ctx.decide_batch_interrupt(true));
let (result, first_out, second_out) = worker.join().unwrap().unwrap();
assert_eq!(result["files"], json!([second_out]), "Continue keeps the original serial: {result}");
assert!(!std::path::Path::new(&first_out).exists());
assert!(std::path::Path::new(&second_out).exists());
assert!(ctx.batch_preview().is_none(), "the preview closes after the last input");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn batch_stop_after_interruption_preserves_prior_outputs_and_never_saves_current() {
use std::sync::mpsc;
use std::time::Duration;
let dir = tmp("batch-interrupt-stop");
let first = png(&dir, "first.png", 8, 6, "#ff0000");
let second = png(&dir, "second.png", 8, 6, "#00ff00");
let third = png(&dir, "third.png", 8, 6, "#0000ff");
let out = join(&dir, "out");
let first_out = join(&out, "first.png");
let second_out = join(&out, "second.png");
let third_out = join(&out, "third.png");
let ctx = crate::jobs::JobCtx::new();
let worker_ctx = ctx.clone();
let (opened_tx, opened_rx) = mpsc::channel();
let (resume_tx, resume_rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
super::batch_run::process_batch_outputs(
72.0,
vec![(first, first_out), (second, second_out), (third, third_out)],
"png",
SaveOpts::default(),
&policy,
&worker_ctx,
&|_, path| {
if path.ends_with("second.png") {
let _ = opened_tx.send(());
let _ = resume_rx.recv_timeout(Duration::from_secs(10));
}
Ok(false)
},
)
});
opened_rx.recv_timeout(Duration::from_secs(10)).unwrap();
assert!(ctx.request_batch_interrupt());
resume_tx.send(()).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
std::thread::yield_now();
}
assert!(ctx.batch_waiting_for_decision());
assert!(ctx.decide_batch_interrupt(false));
let result = worker.join().unwrap().unwrap();
assert_eq!(result["files"], json!([join(&out, "first.png")]), "{result}");
assert_eq!(result["cancelled"], true, "{result}");
assert_eq!(result["skipped"], json!([join(&dir, "second.png"), join(&dir, "third.png")]), "{result}");
assert!(std::path::Path::new(&join(&out, "first.png")).exists());
assert!(!std::path::Path::new(&join(&out, "second.png")).exists());
assert!(!std::path::Path::new(&join(&out, "third.png")).exists());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn batch_continue_keeps_errors_before_and_after_the_interruption_in_one_log() {
use std::time::Duration;
let dir = tmp("batch-interrupt-error-log");
let second = png(&dir, "second.png", 8, 6, "#00ff00");
let fourth = png(&dir, "fourth.png", 8, 6, "#0000ff");
let before = join(&dir, "missing-before.png");
let after = join(&dir, "missing-after.png");
let out = join(&dir, "out");
let log = join(&dir, "errors.txt");
let ctx = crate::jobs::JobCtx::new();
let decider_ctx = ctx.clone();
let decider = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !decider_ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
std::thread::yield_now();
}
if !decider_ctx.batch_waiting_for_decision() {
decider_ctx.cancel();
return false;
}
decider_ctx.decide_batch_interrupt(true)
});
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: Some(log.clone()) };
let result = super::batch_run::process_batch_outputs(
72.0,
vec![
(before.clone(), join(&out, "missing-before.png")),
(second.clone(), join(&out, "second.png")),
(after.clone(), join(&out, "missing-after.png")),
(fourth, join(&out, "fourth.png")),
],
"png",
SaveOpts::default(),
&policy,
&ctx,
&|_, path| {
if path.ends_with("second.png") {
assert!(ctx.request_batch_interrupt());
}
Ok(false)
},
)
.unwrap();
assert!(decider.join().unwrap());
assert_eq!(result["errors"].as_array().map(Vec::len), Some(2), "{result}");
assert_eq!(result["skipped"], json!([second]), "{result}");
assert_eq!(result["files"], json!([join(&out, "fourth.png")]), "{result}");
let text = batch_log(&log);
assert!(text.contains("missing-before.png") && text.contains("missing-after.png"), "{text}");
assert!(!text.contains("second.png"), "a skipped input is not an error: {text}");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn batch_continue_between_inputs_still_processes_the_next_input() {
use std::time::Duration;
let dir = tmp("batch-interrupt-between-inputs");
let input = png(&dir, "next.png", 8, 6, "#ff0000");
let output = join(&dir, "out/next.png");
let ctx = crate::jobs::JobCtx::new();
assert!(ctx.request_batch_interrupt());
let decider_ctx = ctx.clone();
let decider = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !decider_ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
std::thread::yield_now();
}
if !decider_ctx.batch_waiting_for_decision() {
decider_ctx.cancel();
return false;
}
decider_ctx.decide_batch_interrupt(true)
});
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
let result =
super::batch_run::process_batch_outputs(72.0, vec![(input, output.clone())], "png", SaveOpts::default(), &policy, &ctx, &|_, _| Ok(false)).unwrap();
assert!(decider.join().unwrap());
assert_eq!(result["files"], json!([output]), "the unopened input is still next: {result}");
assert!(result.get("skipped").is_none(), "{result}");
}
#[test]
fn batch_error_log_cannot_replace_an_input_or_result() {
let dir = tmp("batch-error-log-collision");
let input = png(&dir, "source.png", 8, 6, "#ff0000");
let original = std::fs::read(&input).unwrap();
let output = join(&dir, "out");
for log in [input.clone(), join(&output, "source.png"), join(&output, "./source.png"), join(&output, "../out/source.png")] {
let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err();
assert!(error.to_string().contains("errorLog"), "{error}");
assert_eq!(std::fs::read(&input).unwrap(), original);
assert!(!std::path::Path::new(&join(&output, "source.png")).exists());
}
}
#[test]
fn batch_naming_rejects_an_input_alias_through_a_missing_parent() {
let dir = tmp("batch-missing-parent-alias");
let first = png(&dir, "a.png", 8, 6, "#ff0000");
let second = png(&dir, "b.png", 8, 6, "#00ff00");
let original = std::fs::read(&second).unwrap();
let output = join(&dir, "missing/..");
let error =
Session::new().execute("file.automate.batch", json!({"steps": [], "input": [first, second], "output": output, "naming": "b.{ext}"})).unwrap_err();
assert!(error.to_string().contains("replace an input"), "{error}");
assert_eq!(std::fs::read(&second).unwrap(), original);
assert!(!std::path::Path::new(&join(&dir, "missing")).exists());
}
#[test]
fn flat_batch_cannot_replace_a_later_unread_input() {
let dir = tmp("batch-flat-input-alias");
let input_png = png(&dir, "a.png", 8, 6, "#0000ff");
let mut s = session(8, 6, 8);
save_doc(doc(&s), &join(&dir, "a.jpg"), None).unwrap();
let before = std::fs::read(&input_png).unwrap();
let result = s.execute("file.automate.batch", json!({"steps": [], "input": dir, "output": dir, "format": "png"}));
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("input")), "{result:?}");
assert_eq!(std::fs::read(&input_png).unwrap(), before);
}
#[cfg(unix)]
#[test]
fn batch_error_log_rejects_a_symlink_parent_alias() {
use std::os::unix::fs::symlink;
let dir = tmp("batch-symlink-parent-alias");
let source_dir = join(&dir, "in");
std::fs::create_dir_all(&source_dir).unwrap();
let input = png(&source_dir, "result.png", 8, 6, "#ff0000");
let other = join(&dir, "other");
std::fs::create_dir_all(join(&other, "child")).unwrap();
symlink(join(&other, "child"), join(&dir, "link")).unwrap();
let output = other;
let log = join(&dir, "link/../result.png");
let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err();
assert!(error.to_string().contains("errorLog"), "{error}");
assert!(!std::path::Path::new(&join(&output, "result.png")).exists());
}
#[test]
fn batch_error_log_rejects_an_uncreated_output_alias() {
let dir = tmp("batch-error-log-future-alias");
let input = png(&dir, "source.png", 8, 6, "#ff0000");
let output = join(&dir, "new-output");
std::fs::create_dir_all(join(&output, "alias")).unwrap();
let log = join(&output, "alias/../source.png");
assert!(same_destination(&log, join(&output, "source.png")));
let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err();
assert!(error.to_string().contains("errorLog"), "{error}");
assert!(!std::path::Path::new(&join(&dir, "new-output/source.png")).exists());
}
#[cfg(target_os = "linux")]
#[test]
fn linux_distinct_case_destinations_are_not_the_same_file() {
let dir = tmp("batch-case-sensitive-destinations");
assert!(!same_destination(join(&dir, "Image.png"), join(&dir, "image.png")));
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn unicode_case_variants_follow_destination_volume() {
let dir = tmp("batch-unicode-case-destinations");
let existing = join(&dir, "Ä-probe");
std::fs::write(&existing, b"probe").unwrap();
let volume_aliases_case = same_file(&existing, join(&dir, "ä-probe"));
assert_eq!(same_destination(join(&dir, "Ä.png"), join(&dir, "ä.png")), volume_aliases_case);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn future_folder_case_variants_follow_the_nearest_existing_ancestor() {
let dir = tmp("batch-future-folder-case");
let existing = join(&dir, "Ä-probe");
std::fs::write(&existing, b"probe").unwrap();
let volume_aliases_case = same_file(&existing, join(&dir, "ä-probe"));
let first = join(&dir, "new/nested/Image.png");
let second = join(&dir, "new/nested/image.png");
assert_eq!(super::paths::case_sensitive_destination(std::path::Path::new(&first)), Some(!volume_aliases_case));
assert_eq!(same_destination(first, second), volume_aliases_case);
assert!(!std::path::Path::new(&join(&dir, "new")).exists());
}
#[test]
fn batch_open_source_edits_each_live_document_and_restores_the_active_one() {
let dir = tmp("batch-open-documents");
let mut session = Session::new();
session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap();
session.execute("file.new", json!({"name": "Second", "width": 12, "height": 5})).unwrap();
session.set_active(0);
let result = session
.execute(
"file.automate.batch",
json!({"source": "open", "steps": [["image.imageRotation.90cw", {}]], "output": dir, "format": "png", "naming": "{serial:2}_{NAME}.{EXT}"}),
)
.unwrap();
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
assert_eq!(result["documents"], 2);
assert_eq!(result["files"].as_array().map(Vec::len), Some(2), "{result}");
assert_eq!(session.active_index(), Some(0));
assert_eq!((session.documents()[0].doc.size.width, session.documents()[0].doc.size.height), (6, 8));
assert_eq!((session.documents()[1].doc.size.width, session.documents()[1].doc.size.height), (5, 12));
assert!(std::path::Path::new(&join(&dir, "01_FIRST.PNG")).is_file());
assert!(std::path::Path::new(&join(&dir, "02_SECOND.PNG")).is_file());
}
#[test]
fn batch_open_source_exports_later_edits_if_saved_document_stays_active() {
let dir = tmp("batch-open-save-point");
let mut session = Session::new();
session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap();
session.execute("file.new", json!({"name": "Second", "width": 12, "height": 5})).unwrap();
let result = session
.execute(
"file.automate.batch",
json!({
"source": "open", "steps": [["document.activate", {"document": 1}], ["file.save", {}], ["image.imageRotation.90cw", {}]],
"output": dir, "format": "png", "naming": "{name}.{ext}"
}),
)
.unwrap();
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
let mut opened = Session::new();
opened.execute("file.openAs", json!({"path": join(&dir, "First.png"), "as": "png"})).unwrap();
assert_eq!((doc(&opened).size.width, doc(&opened).size.height), (5, 12), "the still-active saved document includes later edits");
}
#[test]
fn batch_open_source_save_as_then_close_ask_writes_the_result() {
let dir = tmp("batch-open-save-close-ask");
let mut session = Session::new();
session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap();
let result = session
.execute(
"file.automate.batch",
json!({"source": "open", "steps": [["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {"save": "ask"}]], "output": dir, "format": "png"}),
)
.unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
assert_eq!(result["files"], json!([join(&dir, "First.png")]), "{result}");
assert!(session.documents().is_empty());
}
#[test]
fn batch_open_source_refuses_a_result_that_replaces_another_open_file() {
let dir = tmp("batch-open-replaces-input");
let mut session = Session::new();
// Saved as PNG under its default name, `a.jpg`'s result is the open `a.png`'s file.
for (name, format) in [("a.jpg", "jpg"), ("a.png", "png")] {
let path = png(&dir, name, 8, 6, "#ff0000");
session.execute("file.openAs", json!({"path": path, "as": format})).unwrap();
}
let before = std::fs::read(join(&dir, "a.png")).unwrap();
let error = session
.execute("file.automate.batch", json!({"source": "open", "steps": [["image.imageRotation.90cw", {}]], "output": dir, "format": "png"}))
.unwrap_err();
assert!(error.to_string().contains("replace another input"), "{error}");
assert_eq!(std::fs::read(join(&dir, "a.png")).unwrap(), before, "nothing is written before the refusal");
}
#[test]
fn batch_open_source_save_step_leaves_an_unwritten_document_changed() {
let mut session = Session::new();
session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap();
let steps = json!([["image.adjustments.invert", {}], ["file.save", {}]]);
// No destination: nothing is written.
let result = session.execute("file.automate.batch", json!({"source": "open", "steps": steps})).unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
assert!(session.documents()[0].is_dirty(), "a document Batch did not write stays changed");
// A destination that can't be written: the result's name is taken by a folder, so the path
// resolves on every platform and the write itself fails.
let dir = tmp("batch-open-save-fails");
std::fs::create_dir_all(join(&dir, "First.png")).unwrap();
let result = session.execute("file.automate.batch", json!({"source": "open", "steps": steps, "output": dir, "format": "png"})).unwrap();
assert_eq!(result["errors"].as_array().map(Vec::len), Some(1), "{result}");
assert!(session.documents()[0].is_dirty(), "a failed write leaves the document changed");
}
#[cfg(unix)]
#[test]
fn batch_error_log_rejects_a_dangling_link_to_a_planned_output() {
use std::os::unix::fs::symlink;
let dir = tmp("batch-dangling-error-log-alias");
let input = png(&dir, "source.png", 8, 6, "#ff0000");
let output = join(&dir, "out");
let result = join(&output, "source.png");
let log = join(&dir, "errors.txt");
symlink(&result, &log).unwrap();
let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err();
assert!(error.to_string().contains("errorLog"), "{error}");
assert!(!std::path::Path::new(&result).exists(), "the batch must not write before rejecting the alias");
}
#[test]
fn batch_reports_file_failures_even_if_error_log_cannot_be_written() {
let dir = tmp("batch-error-log-write-fails");
let mut session = session(8, 6, 8);
let result = session
.execute(
"file.automate.batch",
json!({
"source": "open", "steps": [["layer.select", {"layer": "does not exist"}]],
"output": dir, "format": "png", "errorLog": dir
}),
)
.unwrap();
assert_eq!(result["files"], json!([]));
assert_eq!(result["errors"].as_array().map(Vec::len), Some(1), "{result}");
assert!(result["errorLogError"].as_str().is_some(), "{result}");
}
#[cfg(unix)]
#[test]
fn recursive_batch_reports_an_unreadable_subfolder() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp("batch-unreadable-subfolder");
let sub = join(&dir, "locked");
std::fs::create_dir_all(&sub).unwrap();
png(&sub, "image.png", 8, 6, "#ff0000");
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o000)).unwrap();
let inaccessible = std::fs::read_dir(&sub).is_err();
let result = list_images_tree(&dir, "", "file.automate.batch");
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap();
if inaccessible {
assert!(result.is_err(), "an inaccessible input folder must not be omitted");
} else {
eprintln!("permission regression skipped: this user can read a mode-000 folder");
assert!(result.is_ok(), "a privileged reader should still process the folder");
}
}
#[cfg(unix)]
#[test]
fn folder_listing_reports_metadata_denial_and_skips_broken_links() {
use std::os::unix::fs::{PermissionsExt, symlink};
let dir = tmp("batch-list-metadata-denial");
let image = png(&dir, "image.png", 8, 6, "#ff0000");
symlink(join(&dir, "gone.png"), join(&dir, "broken.png")).unwrap();
assert_eq!(list_images(&dir).unwrap(), std::slice::from_ref(&image), "broken links are not inputs");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).unwrap();
let denied = std::fs::metadata(&image).is_err();
let result = list_images(&dir);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
if denied {
assert!(result.is_err(), "metadata denial must not turn a folder of images into an empty batch");
} else {
eprintln!("metadata denial regression skipped: this user can search a mode-0400 folder");
assert_eq!(result.unwrap(), [image]);
}
}
#[test]
fn close_all_and_close_others() {
let mut s = session(8, 8, 8);
@@ -86,7 +702,8 @@ fn batch_same_format_saves_camera_raws_as_psd() {
let mut files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
files.sort();
assert_eq!(files.len(), 2, "{r}");
assert!(files[0].contains("/IMG_0001") && files[0].ends_with(".psd") && files[1].contains("/b") && files[1].ends_with(".png"), "{files:?}");
let suffixes = if naming.is_some() { ("_001", "_002") } else { (".psd", ".png") };
assert!(files[0].contains("/IMG_0001") && files[0].ends_with(suffixes.0) && files[1].contains("/b") && files[1].ends_with(suffixes.1), "{files:?}");
let psd = photocraft_io::import("IMG_0001.psd", &std::fs::read(&files[0]).unwrap()).unwrap().document;
assert_eq!((psd.size.width, psd.size.height), (w as u32, h as u32));
}
@@ -1527,16 +2144,16 @@ fn batch_naming_serial_keeps_same_named_inputs_and_their_pixels() {
)
.unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
assert_eq!(result["files"], json!([join(&out, "photo_007.png"), join(&out, "photo_008.png")]));
for (name, expected) in [("photo_007.png", [1.0, 0.0, 0.0, 1.0]), ("photo_008.png", [0.0, 0.0, 1.0, 1.0])] {
let saved = photocraft_io::import(name, &std::fs::read(join(&out, name)).unwrap()).unwrap().document;
assert_eq!(result["files"], json!([join(&out, "photo_007"), join(&out, "photo_008")]));
for (name, expected) in [("photo_007", [1.0, 0.0, 0.0, 1.0]), ("photo_008", [0.0, 0.0, 1.0, 1.0])] {
let saved = photocraft_io::import("photo.png", &std::fs::read(join(&out, name)).unwrap()).unwrap().document;
let pixel = photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0];
assert_eq!(pixel, expected, "{name}");
}
}
/// Two inputs with one output name: the first result is kept and the second is an error, never
/// a silent overwrite listed as written (#422).
/// Batch follows Photoshop's later-file-wins behavior. Image Processor and lens correction
/// keep their existing collision error so their outputs are never silently replaced.
#[test]
fn batch_reports_inputs_that_share_an_output_name() {
let dir = tmp("collide");
@@ -1560,12 +2177,16 @@ fn batch_reports_inputs_that_share_an_output_name() {
let r = s.execute(cmd, p).unwrap();
let files = r["files"].as_array().unwrap();
let errors = r["errors"].as_array().unwrap();
// `a.*` and `B.png`/`b.tif` (names that differ only in case) each collide once.
assert_eq!((files.len(), errors.len()), (2, 2), "{cmd}: {r}");
assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("not written")), "{r}");
let mut written: Vec<_> = files.iter().map(|f| f.as_str().unwrap().to_lowercase()).collect();
written.dedup();
assert_eq!(written.len(), 2, "each listed file is distinct: {r}");
if cmd == "file.automate.batch" {
assert_eq!((files.len(), errors.len()), (4, 0), "{cmd}: {r}");
assert_eq!(files[1], files[2], "the later a.png result replaces a.jpg: {r}");
} else {
assert_eq!((files.len(), errors.len()), (2, 2), "{cmd}: {r}");
assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("not written")), "{r}");
let mut written: Vec<_> = files.iter().map(|f| f.as_str().unwrap().to_lowercase()).collect();
written.dedup();
assert_eq!(written.len(), 2, "each listed file is distinct: {r}");
}
}
// Keeping each file's format, nothing collides.
let out = join(&dir, "same");
@@ -1579,10 +2200,10 @@ fn batch_reports_inputs_that_share_an_output_name() {
std::fs::write(&droplet, json!({"photocraftDroplet": 1, "name": "d", "action": {"steps": []}, "options": {"format": "png"}}).to_string()).unwrap();
let out = join(&dir, "droplet");
let r = s.execute("file.automate.runDroplet", json!({"droplet": droplet, "input": [join(&input, "a.png"), other], "output": out})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 1), "{r}");
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (2, 0), "{r}");
let kept = photocraft_io::import("a.png", &std::fs::read(join(&out, "a.png")).unwrap()).unwrap().document;
let px = photocraft_compose::render(&kept, Rect::new(1, 1, 2, 2)).px[0];
assert!(px[0] > 0.99 && px[1] < 0.01, "the first input's result is kept: {px:?}");
assert!(px[0] > 0.99 && px[1] > 0.99, "the later input's result replaces the first: {px:?}");
}
/// `file.revealInFinder` (the document tab's Reveal, UI-217-6) reveals the clicked document's
@@ -1640,7 +2261,7 @@ fn batch_include_all_subfolders() {
let out = join(&input, "batch");
let r = s.execute("file.automate.batch", json!({"steps": [], "input": input, "output": out, "format": "png"})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 0), "{r}");
// With subfolders: every image, in folder order, results kept apart by subfolder; the output
// With subfolders: every image, in folder order, results saved flat; the output
// folder (inside the input folder, holding last run's a.png) is not an input.
let r = s
.execute(
@@ -1651,12 +2272,12 @@ fn batch_include_all_subfolders() {
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
let o = out.replace('\\', "/");
assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/sub/b.png"), format!("{o}/sub/deeper/a.png"), format!("{o}/z/a.png")]);
let deep = photocraft_io::import("a.png", &std::fs::read(join(&out, "sub/deeper/a.png")).unwrap()).unwrap().document;
let px = photocraft_compose::render(&deep, Rect::new(1, 1, 2, 2)).px[0];
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "blue inverted to yellow: {px:?}");
assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/b.png"), format!("{o}/a.png"), format!("{o}/a.png")]);
let last = photocraft_io::import("a.png", &std::fs::read(join(&out, "a.png")).unwrap()).unwrap().document;
let px = photocraft_compose::render(&last, Rect::new(1, 1, 2, 2)).px[0];
assert!(px[0] < 0.01 && px[1] < 0.01 && px[2] < 0.01, "the last white input is inverted to black: {px:?}");
std::fs::remove_dir_all(&out).unwrap();
// A naming template applies within each kept subfolder (output outside the input folder).
// A naming template applies across all inputs, with serials distinguishing the flat outputs.
let named = join(&dir, "named");
let r = s
.execute("file.automate.batch", json!({"steps": [], "input": input, "includeSubfolders": true, "output": named, "format": "png", "naming": "{serial}"}))
@@ -1664,7 +2285,7 @@ fn batch_include_all_subfolders() {
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
let n = named.replace('\\', "/");
assert_eq!(files, vec![format!("{n}/001.png"), format!("{n}/sub/002.png"), format!("{n}/sub/deeper/003.png"), format!("{n}/z/004.png")]);
assert_eq!(files, vec![format!("{n}/001"), format!("{n}/002"), format!("{n}/003"), format!("{n}/004")]);
// Image Processor shares the option (with its results out of the input folder).
let out = join(&dir, "jpg");
let r = s.execute("file.scripts.imageProcessor", json!({"input": input, "includeSubfolders": true, "output": out})).unwrap();
@@ -1777,13 +2398,13 @@ fn batch_naming_keeps_serial_positions_after_errors_and_captures_the_utc_date()
assert_eq!(files.len(), 1, "{result}");
let name = file_name(files[0].as_str().unwrap());
assert!(
name == format!("{}_010_good.png", &before[..10]) || name == format!("{}_010_good.png", &after[..10]),
name == format!("{}_010_good", &before[..10]) || name == format!("{}_010_good", &after[..10]),
"the missing input still consumes its serial: {name}"
);
}
#[test]
fn batch_naming_still_reports_collisions_instead_of_overwriting() {
fn batch_naming_later_result_replaces_the_earlier_one() {
let dir = tmp("naming-collision");
let red = png(&dir, "red.png", 8, 8, "#ff0000");
let blue = png(&dir, "blue.png", 8, 8, "#0000ff");
@@ -1797,11 +2418,10 @@ fn batch_naming_still_reports_collisions_instead_of_overwriting() {
}),
)
.unwrap();
assert_eq!(result["files"], json!([join(&out, "constant.png")]));
assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}");
assert!(result["errors"][0]["error"].as_str().unwrap().contains("not written"));
let saved = photocraft_io::import("constant.png", &std::fs::read(join(&out, "constant.png")).unwrap()).unwrap().document;
assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [1.0, 0.0, 0.0, 1.0]);
assert_eq!(result["files"], json!([join(&out, "constant"), join(&out, "constant")]));
assert_eq!(result["errors"], json!([]), "{result}");
let saved = photocraft_io::import("constant.png", &std::fs::read(join(&out, "constant")).unwrap()).unwrap().document;
assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() {
@@ -1816,7 +2436,7 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() {
.execute(
"file.automate.batch",
json!({
"input": [first, second], "output": join(&dir, "."), "steps": [], "naming": "{serial}"
"input": [first, second], "output": join(&dir, "."), "steps": [], "naming": "{serial}.{ext}"
}),
)
.unwrap_err();
+13 -2
View File
@@ -18,7 +18,7 @@ use crate::{EngineError, Result, Session};
/// The fill-key command ids (routed to the Channels panel target like `edit.fill`).
pub const IDS: [&str; 5] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve", "edit.fillHistory"];
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##;
const PARAMS: &str = r##"{"layer":id|name?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##;
/// The shape or type layer the colour fill keys recolor instead of filling pixels.
fn target_live_layer(s: &Session, p: &Value) -> Option<LayerId> {
@@ -31,7 +31,7 @@ fn target_live_layer(s: &Session, p: &Value) -> Option<LayerId> {
if st.channel_view.target == crate::channel_cmds::ChannelTarget::LayerMask {
return None;
}
let id = if let Some(v) = p.get("layer") { LayerId(v.as_u64()?) } else { st.active_layer? };
let id = crate::commands::layer_param(s, p).ok()?;
let layer = st.doc.layer(id)?;
if matches!(layer.content, LayerContent::Shape(_) | LayerContent::Text(_)) { Some(id) } else { None }
}
@@ -411,4 +411,15 @@ mod tests {
}
assert_eq!(type_colors(&s), [[1.0, 0.0, 0.0]]);
}
#[test]
fn fill_key_recolors_a_named_nonactive_type_layer() {
let mut s = session();
let id = s.execute("type.create", json!({"x": 1, "y": 5, "text": "Hello", "size": 4, "color": "#000000"})).unwrap()["layer"].as_u64().unwrap();
let name = s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone();
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
s.execute("edit.fillForeground", json!({"layer": name})).unwrap();
let LayerContent::Text(text) = &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content else { panic!("type layer was replaced") };
assert_eq!(text.char_runs()[0].style.color.to_rgb(), [1.0, 0.0, 0.0]);
}
}
+1 -4
View File
@@ -124,10 +124,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
Some(Some("ellipse")) => true,
Some(_) => return Err(bad("`shape` must be \"rectangle\" or \"ellipse\"".into())),
};
let content = match p.get("layer") {
None | Some(Value::Null) => None,
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad(format!("`layer` = {v} is not a layer id")))?)),
};
let content = crate::commands::explicit_layer(s, p)?;
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let gid = s.edit("New Frame", |doc, active| {
let mask =
+19 -5
View File
@@ -2,7 +2,6 @@
//! hides every effect of that layer; the eye on one effect's row shows or hides that effect. The
//! effects keep their settings either way, and each toggle is one history step.
use photocraft_doc::LayerId;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
@@ -32,10 +31,7 @@ fn set_visible(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("`index` must be an effect position (0, 1, …)"))?),
};
let st = s.active().ok_or(EngineError::NoDocument)?;
let id = match p.get("layer") {
None | Some(Value::Null) => st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?,
Some(v) => LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?),
};
let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer).ok_or_else(|| bad("no layer given and no active layer"))?;
let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if l.effects.items.is_empty() {
return Err(bad(format!("layer {} has no effects", id.0)));
@@ -78,6 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
#[cfg(test)]
mod tests {
use super::*;
use photocraft_doc::LayerId;
/// A red layer with a drop shadow and a blue colour overlay, and the background (no effects).
fn session() -> (Session, u64, u64) {
@@ -98,6 +95,23 @@ mod tests {
(fx.enabled, fx.items.iter().map(|e| e.enabled()).collect())
}
#[test]
fn named_nonactive_effects_layer_can_be_replayed() {
let (mut s, a, _) = session();
s.execute("layer.new.layer", json!({"name": "top"})).unwrap();
let replay = s.active().unwrap().doc.as_ref().clone();
s.execute("actions.record", json!({"name": "Hide effects"})).unwrap();
s.execute("layer.setEffectsVisible", json!({"layer": a, "visible": false})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert!(!state(&s, a).0);
let steps = s.execute("actions.get", json!({"action": "Hide effects"})).unwrap()["steps"].clone();
assert_eq!(steps[0][1]["layer"], "a");
s.add_document(replay, None);
let played = s.execute("actions.play", json!({"action": "Hide effects"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert!(!state(&s, a).0, "the recorded name targeted the nonactive layer on replay");
}
fn steps(s: &Session) -> usize {
s.active().unwrap().history.past_len()
}
+37 -4
View File
@@ -614,6 +614,26 @@ const MAX_CROP_SIDE: i32 = 300_000;
/// Image → Crop (to the selection bounds).
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
crop_with(s, p, false)
}
/// Image › Reveal All's crop: as `image.crop` with Delete Cropped Pixels off, but the Background
/// stays a Background and an extension takes the background colour (Reveal All keeps its own
/// behaviour; only the Crop tool's was measured, #10867).
pub(crate) fn crop_keeping_background(s: &mut Session, p: &Value) -> Result<Value> {
crop_with(s, p, true)
}
/// A Delete Cropped Pixels off crop turns the Background into a normal layer ("Layer 0", as Layer ›
/// New › Layer from Background names it), as in Photoshop: it keeps the pixels past the canvas,
/// and an extension is transparent on every layer (#10867).
fn unlock_background_for_crop(doc: &mut Document) {
if let Some(bg) = doc.background_mut() {
crate::extra_cmds::unlock_background(bg);
}
}
fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value> {
let delete = p.get("deleteCroppedPixels").and_then(Value::as_bool).unwrap_or(true);
// The Crop tool's Fill: Content-Aware Fill of what the crop uncovers (#3823).
let content_aware = match p.get("contentAware") {
@@ -621,6 +641,8 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Bool(b)) => *b,
Some(v) => return Err(bad("image.crop", format!("`contentAware` must be true or false, not {v}"))),
};
// Content-Aware Fill with Delete off wasn't measured: it keeps filling the Background (#10867).
let unlock = !delete && !content_aware && !keep_background;
// Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to
// the numbers passed; reject them (see `commands::int_i32`).
let explicit = match (
@@ -652,7 +674,7 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(a) = crop_angle(p)? {
let rect = explicit.ok_or_else(|| bad("image.crop", "an `angle` needs an explicit `x`, `y`, `width` and `height`"))?;
return rotated_crop(s, rect, a, delete, content_aware, &target);
return rotated_crop(s, rect, a, delete, content_aware, unlock, &target);
}
// An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped.
let r = explicit
@@ -665,6 +687,9 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let extension = s.tools.background;
s.edit("Crop", |doc, active| {
let original = doc.size;
if unlock {
unlock_background_for_crop(doc);
}
crop_doc(doc, "image.crop", r, delete, extension)?;
// A straight crop fills all of the area past the old canvas, pixels an earlier Delete Cropped
// Pixels off crop hid there included, as Photoshop does; only a turned frame keeps them (#8060).
@@ -796,8 +821,10 @@ fn crop_angle(p: &Value) -> Result<Option<f64>> {
/// background colour, other layers keep transparency there and every pixel of theirs), which makes
/// the frame upright, then cropped to it (`deleteCroppedPixels` as for an unrotated crop), all in
/// one undo step. With `content_aware`, the corners the rotation uncovers (and any area past the
/// rotated image) are then filled from their surroundings, in the same step (#3823).
fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, crop_target: &CropTarget) -> Result<Value> {
/// rotated image) are then filled from their surroundings, in the same step (#3823). With `unlock`
/// the Background becomes a normal layer before the turn, so it turns like the others, keeping its
/// overhang and leaving the uncovered corners transparent (#10867).
fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, unlock: bool, crop_target: &CropTarget) -> Result<Value> {
const CMD: &str = "image.crop";
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
let (w, h) = (f64::from(r.width()), f64::from(r.height()));
@@ -860,6 +887,12 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
Ok(())
})?;
}
if unlock {
s.edit("Crop", |doc, _| {
unlock_background_for_crop(doc);
Ok(())
})?;
}
crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?;
// Color Sampler points follow the crop's origin shift too (the rotation moved them above).
s.set_sampler_map(crop_target.sampler_map(target));
@@ -1042,7 +1075,7 @@ pub fn specs() -> Vec<CommandSpec> {
"image.crop",
"Crop",
["Image"],
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##,
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##,
has_doc,
crop
),
+11
View File
@@ -486,6 +486,17 @@ fn extending_the_canvas_keeps_the_backgrounds_hidden_pixels() {
})
.unwrap();
s.execute("image.crop", json!({"x": 50, "y": 0, "width": 50, "height": 100, "deleteCroppedPixels": false})).unwrap();
// That crop makes the Background "Layer 0" now (#10867); a Background with pixels past
// the canvas is still in documents saved before, so lock it back into one.
s.edit("relock", |doc, _| {
let l = doc.layers.first_mut().unwrap();
l.name = "Background".into();
l.locks.transparency = true;
l.locks.position = true;
Ok(())
})
.unwrap();
assert!(doc(s).background().is_some());
s.tools.background = [0.0, 0.0, 0.0, 1.0];
};
let mut s = Session::new();
+9 -3
View File
@@ -134,9 +134,15 @@ pub fn specs() -> Vec<CommandSpec> {
s,
"vertically"
)),
spec!("layer.linkLayers", "Link Layers", &["Layer"], None, "{} (toggles: unlinks when the selection is already one link group)", can_link, |s, _| {
link_layers(s)
}),
spec!(
"layer.linkLayers",
"Link Layers",
&["Layer"],
None,
r#"{"linked":bool?} (omitted: toggle; explicit true links, false unlinks)"#,
can_link,
link_layers
),
spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)),
spec!(
"layer.new.groupFromLayers",
@@ -133,6 +133,15 @@ pub(super) fn same_named_targets(doc: &photocraft_doc::Document, name: &str, mov
/// (omitting `layer`) so that the recorded step applies to whatever layer is targeted when
/// replayed or exported to `.atn` (#7248).
pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Option<&[LayerId]>) -> Value {
if id == "select.loadSelection"
&& let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId)
{
return recorded_source_layer(s, p, layer_id);
}
if crate::actions_record::needs_source_layer(id) {
// An explicit layer supplies content, even when it is the sole selection.
return p;
}
if id == "layer.select" {
return recorded_selection(s, p);
}
@@ -159,9 +168,6 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio
}
return p;
}
if id == "select.loadSelection" {
return recorded_source_layer(s, p, layer_id);
}
p
}
@@ -3,13 +3,28 @@ use super::{Document, EngineError, Layer, LayerContent, LayerId, PixelFormat, Re
// ---------- structural commands ----------
pub(super) fn link_layers(s: &mut Session) -> Result<Value> {
/// Set an explicit link state idempotently, or toggle when omitted, with singleton cleanup and Undo.
pub(super) fn link_layers(s: &mut Session, p: &Value) -> Result<Value> {
let requested = match p.get("linked") {
None => None,
Some(Value::Bool(value)) => Some(*value),
Some(_) => return Err(bad("layer.linkLayers", "`linked` must be true or false")),
};
let sel = selected(s);
let linked = s.edit("Link Layers", |doc, _| {
let groups: Vec<Option<u64>> = sel.iter().map(|id| doc.layer(*id).and_then(|l| l.link_group)).collect();
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let groups = sel.iter().map(|id| doc.layer(*id).map(|l| l.link_group).ok_or(EngineError::NoLayer(*id))).collect::<Result<Vec<_>>>()?;
let first = groups.first().copied().flatten();
let same_group = first.is_some() && groups.iter().all(|group| *group == first);
// A recorded Link/Unlink is an operation, not the UI's state-dependent toggle.
// An explicit repeat must neither allocate a group nor add an undo entry.
let linked_group = same_group && doc.walk().iter().filter(|(_, _, layer)| layer.link_group == first).take(2).count() == 2;
if requested == Some(true) && linked_group || requested == Some(false) && groups.iter().all(Option::is_none) {
return Ok(json!({"linked": requested == Some(true)}));
}
let label = if requested == Some(false) { "Unlink Layers" } else { "Link Layers" };
let linked = s.edit(label, |doc, _| {
// Already one link group (or a single linked layer): unlink, like Photoshop's toggle.
let first = groups.first().copied().flatten();
let unlink = first.is_some() && groups.iter().all(|g| *g == first);
let unlink = requested.map_or(same_group, |linked| !linked) || sel.len() < 2;
// A loaded file can store any id up to u64::MAX, so the new id must not be a bare
// max + 1 (#961). Unlinking needs no new id at all.
let next = if unlink { None } else { Some(doc.unused_link_group().ok_or_else(|| bad("layer.linkLayers", "no free link group id is left"))?) };
+21 -13
View File
@@ -4,23 +4,31 @@ use super::{BlendMode, Document, EngineError, Layer, LayerId, LayerMask, Result,
/// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that
/// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays
/// in another document; a name that matches no layer exactly matches regardless of case, so
/// `"background"` finds that name). `{"background":true}` targets the actual Background,
/// independently of its name. `None` when the param is absent.
/// `"background"` finds that name). A recorded `{"name":"Layer"}` uses the same lookup.
/// `{"background":true}` targets the actual Background, independently of its name. `None`
/// when the param is absent.
pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result<Option<LayerId>> {
let named = |name: &str| {
let d = s.active().ok_or(EngineError::NoDocument)?;
let layers = d.doc.walk();
let mut top_down = layers.iter().rev();
top_down
.clone()
.find(|(_, _, l)| l.name == name)
.or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase()))
.map(|(_, _, l)| Some(l.id))
.ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc))))
};
match p.get("layer") {
Some(Value::Number(n)) => {
n.as_u64().map(|id| Some(LayerId(id))).ok_or_else(|| EngineError::Other(format!("layer id {n} is not a non-negative integer")))
}
Some(Value::String(name)) => {
let d = s.active().ok_or(EngineError::NoDocument)?;
let layers = d.doc.walk();
let mut top_down = layers.iter().rev();
top_down
.clone()
.find(|(_, _, l)| l.name == *name)
.or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase()))
.map(|(_, _, l)| Some(l.id))
.ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc))))
Some(Value::String(name)) => named(name),
Some(Value::Object(selector))
if selector.len() == 1
&& let Some(name) = selector.get("name").and_then(Value::as_str) =>
{
named(name)
}
// A group end is no layer: fail rather than fall back to the selection.
Some(v @ Value::Object(fields)) if fields.contains_key("index") || fields.contains_key("ordinal") => {
@@ -31,7 +39,7 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result<Option<LayerId>>
d.doc.background().map(|l| Some(l.id)).ok_or_else(|| EngineError::Other("the document has no Background layer".into()))
}
Some(Value::Null) | None => Ok(None),
Some(other) => Err(EngineError::Other(format!("`layer` must be an id, a name or {{\"background\":true}}, not {other}"))),
Some(other) => Err(EngineError::Other(format!("`layer` must be an id, a name, {{\"name\":string}} or {{\"background\":true}}, not {other}"))),
}
}
+11 -3
View File
@@ -67,8 +67,8 @@ fn edited(s: &Session, base: &Fill, p: &Value) -> Result<(Fill, Option<Pattern>)
/// The target layer and the fill `p` gives it, checked against the active document.
fn target(s: &Session, p: &Value) -> Result<(photocraft_doc::LayerId, Fill, Fill, Option<Pattern>)> {
if !p.is_object() || p.get("layer").is_some_and(|v| v.as_u64().is_none()) {
return Err(bad("params must be an object and layer an unsigned id"));
if !p.is_object() {
return Err(bad("params must be an object"));
}
let st = s.active().ok_or(EngineError::NoDocument)?;
if let Some(document) = p.get("document")
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Pattern Fill…",
menu: &[],
shortcut: None,
params: r##"{"layer":id? (default active; a Pattern Fill layer),"document":id? (reject a stale dialog),"pattern":id|name?,"patternName":name? (then `pattern` is an id; the name is used when no pattern has that id),"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]? (Snap to Origin: [0,0])}; omitted values keep the layer's"##,
params: r##"{"layer":id|name? (default active; a Pattern Fill layer),"document":id? (reject a stale dialog),"pattern":id|name?,"patternName":name? (then `pattern` is an id; the name is used when no pattern has that id),"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]? (Snap to Origin: [0,0])}; omitted values keep the layer's"##,
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
run: set,
journal: true,
@@ -165,6 +165,14 @@ mod tests {
f.clone()
}
#[test]
fn named_nonactive_pattern_fill_can_be_edited() {
let (mut s, id) = session(8);
let name = s.active().unwrap().doc.layer(id).unwrap().name.clone();
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
assert_eq!(s.execute(SET, json!({"layer": name, "scale": 125})).unwrap()["layer"], id.0);
}
#[test]
fn edit_keeps_masks_is_one_step_and_undoes_at_every_depth() {
for depth in [8, 16, 32] {
+13 -6
View File
@@ -874,7 +874,9 @@ fn mask_unselected(sel: &Surface, area: Rect, w: usize, h: usize, rgba: &mut [[f
true
}
/// Validate the selected layer, then open cropped scan photos without editing the source document.
fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
crop_ready(s)?;
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc = st.doc.clone();
let fmt = doc.pixel_format();
@@ -942,13 +944,18 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
// ---------- specs ----------
/// Photoshop greys Crop and Straighten Photos out for Indexed Color documents.
fn not_indexed(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
/// Crop needs a selected, visible, pixel-unlocked layer, including inherited state.
fn crop_ready(s: &Session) -> Result<()> {
let st = s.active().ok_or(EngineError::NoDocument)?;
if st.doc.mode == ColorMode::Indexed {
return Err("not available for Indexed Color documents".into());
return Err(EngineError::Other("not available for Indexed Color documents".into()));
}
Ok(())
let id = st.active_layer.ok_or_else(|| EngineError::Other("no layer selected".into()))?;
let path = st.doc.path_of(id).ok_or(EngineError::NoLayer(id))?;
if !st.doc.shown_at(&path) {
return Err(EngineError::Other("the selected layer is hidden".into()));
}
crate::commands::check_pixels_unlocked(&st.doc, id)
}
fn always(_: &Session) -> std::result::Result<(), String> {
@@ -983,7 +990,7 @@ pub fn specs() -> Vec<CommandSpec> {
menu: &["File", "Automate"],
shortcut: None,
params: "{} → one new document per photo found on the scan (inside the selection when there is one)",
enabled: not_indexed,
enabled: |s| crop_ready(s).map_err(|e| e.to_string()),
run: crop_and_straighten,
journal: true,
},
+34 -2
View File
@@ -26,6 +26,12 @@ fn layers_hit(doc: &Document, x: i32, y: i32, type_bounds: bool) -> Vec<LayerId>
if x.checked_add(1).is_none() || y.checked_add(1).is_none() {
return Vec::new();
}
// Off the canvas nothing is drawn (only artboard documents show layers over the pasteboard,
// #5712), so pixels hanging past the edge and fill layers can't be hit there: a pasteboard
// click with Auto-Select clears the layer selection instead of picking them (#10665).
if !doc.has_artboards() && !doc.bounds().contains(x, y) {
return Vec::new();
}
let rows = doc.walk();
let mut out = Vec::new();
for (path, _, l) in rows.iter().rev() {
@@ -114,13 +120,15 @@ fn pick(s: &mut Session, p: &Value) -> Result<Value> {
/// Layers whose content touches document rect `r`, top to bottom: visible, not fully locked, not
/// the locked Background (as Select › All Layers). A layer on an artboard counts only where the
/// board shows it. With `group`, each counts as its outermost group (deduplicated).
/// board shows it, and without artboards only where the canvas does (#10665). With `group`, each
/// counts as its outermost group (deduplicated).
pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec<LayerId> {
let mut out = Vec::new();
let canvas = doc.bounds();
let r = if doc.has_artboards() { r } else { r.intersect(&canvas) };
if r.is_empty() {
return out;
}
let canvas = doc.bounds();
for (path, _, l) in doc.walk().iter().rev() {
if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_))
|| doc.is_background(l.id)
@@ -447,6 +455,30 @@ mod tests {
assert!(empty.execute("layer.pickAt", json!({"x": 1, "y": 1})).is_err());
}
/// #10665: off the canvas nothing is drawn, so a fill layer or pixels hanging past the edge
/// are neither picked there nor caught by a marquee that touches only the pasteboard.
#[test]
fn nothing_is_hit_off_the_canvas() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 40, "background": "transparent"})).unwrap();
let fill = s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.new.layer", json!({"name": "Off"})).unwrap();
let off = s.active().unwrap().active_layer.unwrap();
s.edit("paint past the edge", |doc, _| {
doc.layer_mut(off).unwrap().surface_mut().unwrap().fill_rect(Rect::new(-20, 0, 10, 40), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
let pick = |s: &mut Session, x: i32, y: i32| s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"].clone();
assert_eq!(pick(&mut s, 5, 5), off.0, "on the canvas");
assert_eq!(pick(&mut s, 20, 5), fill, "the fill layer covers the canvas");
assert_eq!(pick(&mut s, -10, 5), Value::Null, "the pasteboard, over Off's hidden pixels and the fill");
assert_eq!(s.execute("layer.pickAt", json!({"x": -10, "y": 5, "list": true})).unwrap()["layers"], json!([]));
let in_rect = |s: &mut Session, x: f64| s.execute("layer.selectInRect", json!({"x": x, "y": 0, "width": 5, "height": 5})).unwrap()["layers"].clone();
assert_eq!(in_rect(&mut s, -15.0), json!([]), "a marquee on the pasteboard only");
assert_eq!(in_rect(&mut s, -3.0), json!([off.0, fill]), "a marquee reaching onto the canvas");
}
/// A hide-all mask at density 0 hides nothing (the compositor applies density through
/// `LayerMask::value`), so the layer stays pickable; at full density it is clicked through.
#[test]
+2 -2
View File
@@ -197,8 +197,8 @@ pub fn layers_to_smart(doc: &Document, name: &str, layers: Vec<Layer>) -> Result
}
pub(super) fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let ids = match p.get("layer").and_then(Value::as_u64) {
Some(id) => vec![LayerId(id)],
let ids = match crate::commands::explicit_layer(s, p)? {
Some(id) => vec![id],
None => crate::layer_multi_cmds::selected(s),
};
let new_id = s.edit("Convert to Smart Object", |doc, active| {
+13 -3
View File
@@ -45,8 +45,8 @@ pub fn color_param(value: &Value, original: Color, mode: ColorMode) -> Result<Co
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
if !p.is_object() || p.get("layer").is_some_and(|v| v.as_u64().is_none()) {
return Err(bad("params must be an object and layer an unsigned id"));
if !p.is_object() {
return Err(bad("params must be an object"));
}
let st = s.active().ok_or(EngineError::NoDocument)?;
if let Some(document) = p.get("document")
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Solid Color…",
menu: &[],
shortcut: None,
params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
params: r##"{"layer":id|name? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
run: set,
journal: true,
@@ -95,6 +95,16 @@ pub fn specs() -> Vec<CommandSpec> {
mod tests {
use super::*;
#[test]
fn named_nonactive_solid_fill_can_be_edited() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let id = s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap()["layer"].as_u64().unwrap();
let name = s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().name.clone();
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
assert_eq!(s.execute(SET, json!({"layer": name, "color": "#ff0000"})).unwrap()["layer"], id);
}
/// #8568: CMYK 50/50/50/100 entered for a Solid Color fill in a CMYK document stays exactly
/// those inks, on creation and on edit, instead of what its sRGB stand-in converts back to
/// through the document profile. RGB documents ignore the inks.
+1 -4
View File
@@ -24,10 +24,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
}
fn layer_id(s: &Session, p: &Value) -> Result<LayerId> {
match p.get("layer").and_then(Value::as_u64) {
Some(id) => Ok(LayerId(id)),
None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())),
}
crate::commands::layer_param(s, p)
}
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
+177 -5
View File
@@ -1,6 +1,7 @@
//! The Type menu: anti-aliasing, orientation, point ⇄ paragraph conversion, Create Work Path,
//! Convert to Shape, Rasterize, Warp Text, the OpenType toggles, Paste Lorem Ipsum, Update All
//! Text Layers, missing-font replacement and the default type styles.
//! Convert to Shape, Rasterize, Warp Text, the OpenType toggles (with the Numerals submenu),
//! Paste Lorem Ipsum, Update All Text Layers, missing-font replacement and the default type
//! styles.
//!
//! Everything edits the typed [`TextLayer`] model and re-renders through `type_cmds::refresh`,
//! so the PSD `TySh` data (and with it warp, anti-aliasing and the OpenType flags) is regenerated
@@ -502,6 +503,88 @@ fn warp_text(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- OpenType toggles ----------
/// OpenType figure tags Type › OpenType › Numerals clears or sets (`lnum`/`onum`/`pnum`/`tnum`).
pub const NUMERAL_FEATURE_TAGS: [&str; 4] = ["lnum", "onum", "pnum", "tnum"];
/// One item of Type › OpenType › Numerals (Adobe figure-style radio set).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NumeralsStyle {
Default,
Lining,
Oldstyle,
Proportional,
Tabular,
ProportionalLining,
TabularLining,
ProportionalOldstyle,
TabularOldstyle,
}
impl NumeralsStyle {
/// Command-id suffix after `type.openType.numerals.`.
pub const fn id(self) -> &'static str {
match self {
Self::Default => "default",
Self::Lining => "lining",
Self::Oldstyle => "oldstyle",
Self::Proportional => "proportional",
Self::Tabular => "tabular",
Self::ProportionalLining => "proportionalLining",
Self::TabularLining => "tabularLining",
Self::ProportionalOldstyle => "proportionalOldstyle",
Self::TabularOldstyle => "tabularOldstyle",
}
}
/// Menu label for this numerals style.
pub const fn label(self) -> &'static str {
match self {
Self::Default => "Default",
Self::Lining => "Lining",
Self::Oldstyle => "Oldstyle",
Self::Proportional => "Proportional",
Self::Tabular => "Tabular",
Self::ProportionalLining => "Proportional Lining",
Self::TabularLining => "Tabular Lining",
Self::ProportionalOldstyle => "Proportional Oldstyle",
Self::TabularOldstyle => "Tabular Oldstyle",
}
}
/// Parse a `type.openType.numerals.*` suffix.
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|style| style.id() == id)
}
/// Every Numerals menu item, in menu order.
pub const ALL: [Self; 9] = [
Self::Default,
Self::Lining,
Self::Oldstyle,
Self::Proportional,
Self::Tabular,
Self::ProportionalLining,
Self::TabularLining,
Self::ProportionalOldstyle,
Self::TabularOldstyle,
];
/// OpenType tags this style turns on (the other [`NUMERAL_FEATURE_TAGS`] are cleared).
pub const fn tags(self) -> &'static [&'static str] {
match self {
Self::Default => &[],
Self::Lining => &["lnum"],
Self::Oldstyle => &["onum"],
Self::Proportional => &["pnum"],
Self::Tabular => &["tnum"],
Self::ProportionalLining => &["pnum", "lnum"],
Self::TabularLining => &["tnum", "lnum"],
Self::ProportionalOldstyle => &["pnum", "onum"],
Self::TabularOldstyle => &["tnum", "onum"],
}
}
}
/// Is the OpenType toggle `tag` on for a character style? (`liga`/`dlig` have dedicated fields.)
pub fn feature_on(st: &photocraft_doc::text::CharStyle, tag: &str) -> bool {
match tag {
@@ -511,6 +594,36 @@ pub fn feature_on(st: &photocraft_doc::text::CharStyle, tag: &str) -> bool {
}
}
/// The character style at character `ci` of `t`: its run's, or past the end the last run's.
fn style_at(t: &TextLayer, ci: usize) -> Option<photocraft_doc::text::CharStyle> {
let at = byte_index(&t.text, ci);
let mut end = 0usize;
let mut last = None;
for run in t.char_runs() {
end = end.saturating_add(run.len);
if at < end {
return Some(run.style);
}
last = Some(run.style);
}
last
}
/// Is the OpenType toggle `tag` on at character `ci` of `t`? Past the end it reads the last run.
pub fn feature_on_at(t: &TextLayer, ci: usize, tag: &str) -> bool {
style_at(t, ci).is_some_and(|st| feature_on(&st, tag))
}
/// Which Numerals menu item matches `st`'s figure tags? `None` when the mix is not a menu item.
pub fn numerals_style_of(st: &photocraft_doc::text::CharStyle) -> Option<NumeralsStyle> {
NumeralsStyle::ALL.into_iter().find(|style| NUMERAL_FEATURE_TAGS.iter().all(|tag| feature_on(st, tag) == style.tags().contains(tag)))
}
/// Numerals style at character `ci` of `t` (same run pick as [`feature_on_at`]).
pub fn numerals_style_at(t: &TextLayer, ci: usize) -> Option<NumeralsStyle> {
style_at(t, ci).and_then(|st| numerals_style_of(&st))
}
fn set_feature(st: &mut photocraft_doc::text::CharStyle, tag: &str, on: bool) {
match tag {
"liga" => st.ligatures = on,
@@ -524,13 +637,37 @@ fn set_feature(st: &mut photocraft_doc::text::CharStyle, tag: &str, on: bool) {
}
}
fn apply_numerals(st: &mut photocraft_doc::text::CharStyle, style: NumeralsStyle) {
let on = style.tags();
for tag in NUMERAL_FEATURE_TAGS {
set_feature(st, tag, on.contains(&tag));
}
}
fn set_numerals(s: &mut Session, p: &Value, style: NumeralsStyle) -> Result<Value> {
with_text_targets(s, p, "Change OpenType Numerals", |t, (a, b)| {
style_range(t, a.min(b), a.max(b), &|st| apply_numerals(st, style));
Ok(())
})?;
Ok(json!({"numerals": style.id()}))
}
fn toggle_opentype(s: &mut Session, p: &Value, tag: &'static str) -> Result<Value> {
let ids = format_targets(s, p)?;
let st = s.active().ok_or(EngineError::NoDocument)?;
let representative = st.active_layer.filter(|id| ids.contains(id)).or_else(|| ids.first().copied());
let cur = representative
.and_then(|id| st.doc.layer(id))
.is_some_and(|l| matches!(&l.content, LayerContent::Text(t) if t.char_runs().first().is_some_and(|r| feature_on(&r.style, tag))));
// The toggle direction follows the style where the edit starts: the range start on a single
// layer, else the first character.
let start_char = if p.get("layers").is_none() || p.get("layer").is_some() {
p.get("range").and_then(Value::as_array).filter(|r| r.len() == 2).map_or(0, |r| {
let a = r.first().and_then(Value::as_u64).unwrap_or(0);
let b = r.get(1).and_then(Value::as_u64).unwrap_or(a);
usize::try_from(a.min(b)).unwrap_or(usize::MAX)
})
} else {
0
};
let cur = representative.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(&l.content, LayerContent::Text(t) if feature_on_at(t, start_char, tag)));
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur);
with_text_targets(s, p, "Change OpenType Feature", |t, (a, b)| {
style_range(t, a.min(b), a.max(b), &|st| set_feature(st, tag, on));
@@ -770,6 +907,41 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("type.openType.ornaments", "Ornaments", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "ornm")),
spec!("type.openType.ordinals", "Ordinals", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "ordn")),
spec!("type.openType.fractions", "Fractions", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "frac")),
spec!("type.openType.numerals.default", "Default", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
s,
p,
NumeralsStyle::Default
)),
spec!("type.openType.numerals.lining", "Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::Lining)
}),
spec!("type.openType.numerals.oldstyle", "Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::Oldstyle)
}),
spec!("type.openType.numerals.proportional", "Proportional", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
s,
p,
NumeralsStyle::Proportional
)),
spec!("type.openType.numerals.tabular", "Tabular", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::Tabular)
}),
spec!("type.openType.numerals.proportionalLining", "Proportional Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::ProportionalLining)
}),
spec!("type.openType.numerals.tabularLining", "Tabular Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
s,
p,
NumeralsStyle::TabularLining
)),
spec!("type.openType.numerals.proportionalOldstyle", "Proportional Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
set_numerals(s, p, NumeralsStyle::ProportionalOldstyle)
}),
spec!("type.openType.numerals.tabularOldstyle", "Tabular Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
s,
p,
NumeralsStyle::TabularOldstyle
)),
spec!("type.createWorkPath", "Create Work Path", &["Type"], LP, has_text, create_work_path),
spec!("type.convertToShape", "Convert to Shape", &["Type"], LP, has_text, convert_to_shape),
spec!("type.rasterizeTypeLayer", "Rasterize Type Layer", &["Type"], LP, has_text, |s, p| s.execute("type.rasterize", p.clone())),
@@ -243,6 +243,138 @@ fn opentype_toggles_store_features() {
assert!(feature_on(&back.char_runs()[0].style, "swsh") && !back.char_runs()[0].style.ligatures);
}
/// Is `tag` on at character `ci` of an ASCII text layer?
fn on_at(s: &Session, id: LayerId, ci: usize, tag: &str) -> bool {
let mut end = 0;
text(s, id)
.char_runs()
.iter()
.find(|r| {
end += r.len;
ci < end
})
.is_some_and(|r| feature_on(&r.style, tag))
}
#[test]
fn numerals_are_registered_with_their_menu_path() {
for style in NumeralsStyle::ALL {
let spec = crate::commands::find(&format!("type.openType.numerals.{}", style.id())).unwrap();
assert_eq!(spec.label, style.label());
assert_eq!(spec.menu, ["Type", "OpenType", "Numerals"]);
}
assert!(crate::commands::find("type.openType.numerals.unknown").is_none());
}
#[test]
fn numerals_replace_figure_tags_and_keep_other_features() {
let mut s = session(8);
let id = text_layer(&mut s, "012345");
let before = text(&s, id).char_runs()[0].style.clone();
s.execute("type.openType.fractions", json!({"on": true})).unwrap();
s.execute("type.openType.numerals.tabularLining", json!({})).unwrap();
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::TabularLining));
assert!(on_at(&s, id, 0, "tnum") && on_at(&s, id, 0, "lnum") && !on_at(&s, id, 0, "onum") && !on_at(&s, id, 0, "pnum"));
assert!(feature_on(&text(&s, id).char_runs()[0].style, "frac"), "non-figure features stay");
s.execute("type.openType.numerals.proportionalOldstyle", json!({})).unwrap();
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::ProportionalOldstyle));
assert!(on_at(&s, id, 0, "pnum") && on_at(&s, id, 0, "onum") && !on_at(&s, id, 0, "tnum") && !on_at(&s, id, 0, "lnum"));
s.execute("type.openType.numerals.default", json!({})).unwrap();
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Default));
for tag in NUMERAL_FEATURE_TAGS {
assert!(!on_at(&s, id, 0, tag), "{tag}");
}
let st = text(&s, id).char_runs()[0].style.clone();
assert!(st.ligatures, "liga untouched");
assert_eq!((st.size_pt, st.font_family.clone(), st.color), (before.size_pt, before.font_family, before.color));
}
#[test]
fn numerals_range_edits_only_those_characters() {
let mut s = session(8);
let id = text_layer(&mut s, "abcdef");
s.execute("type.openType.numerals.lining", json!({"range": [2, 5]})).unwrap();
let on: Vec<bool> = (0..6).map(|i| on_at(&s, id, i, "lnum")).collect();
assert_eq!(on, [false, false, true, true, true, false]);
assert_eq!(numerals_style_at(text(&s, id), 2), Some(NumeralsStyle::Lining));
assert_eq!(numerals_style_at(text(&s, id), 0), Some(NumeralsStyle::Default));
s.execute("type.openType.numerals.tabular", json!({"range": [2, 5]})).unwrap();
assert!(on_at(&s, id, 3, "tnum") && !on_at(&s, id, 3, "lnum"));
assert!(!on_at(&s, id, 0, "tnum"));
}
#[test]
fn numerals_apply_to_every_listed_layer() {
let mut s = session(8);
let a = text_layer(&mut s, "one");
let b = text_layer(&mut s, "two");
s.execute("type.openType.numerals.oldstyle", json!({"layers": [a.0, b.0]})).unwrap();
assert!(on_at(&s, a, 0, "onum") && on_at(&s, b, 2, "onum"));
s.execute("type.openType.numerals.default", json!({"layers": [a.0, b.0]})).unwrap();
assert!(!on_at(&s, a, 0, "onum") && !on_at(&s, b, 0, "onum"));
}
#[test]
fn numerals_are_one_undo_step_each() {
let mut s = session(8);
let id = text_layer(&mut s, "123");
s.execute("type.openType.numerals.lining", json!({})).unwrap();
s.execute("type.openType.numerals.tabularOldstyle", json!({})).unwrap();
assert!(s.undo());
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Lining));
assert!(s.undo());
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Default));
assert!(s.redo());
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Lining));
}
#[test]
fn numerals_survive_a_pcraft_round_trip() {
let mut s = session(8);
let id = text_layer(&mut s, "Il36");
s.execute("type.openType.numerals.proportionalLining", json!({})).unwrap();
let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap();
let doc = photocraft_format::load_from_bytes(&bytes).unwrap();
let Some(LayerContent::Text(t)) = doc.layer(id).map(|l| &l.content) else { panic!("text layer lost") };
assert_eq!(numerals_style_of(&t.char_runs()[0].style), Some(NumeralsStyle::ProportionalLining));
}
#[test]
fn numerals_fail_gracefully_on_bad_params() {
let mut s = session(8);
let bg = s.active().unwrap().active_layer.unwrap();
let id = text_layer(&mut s, "abc");
let cmd = "type.openType.numerals.tabularLining";
assert!(s.execute(cmd, json!({"layer": 987_654})).is_err(), "unknown layer");
assert!(s.execute(cmd, json!({"layer": bg.0})).is_err(), "not a type layer");
assert!(s.execute(cmd, json!({"layers": ["x"]})).is_err());
s.execute(cmd, json!({"range": [5, 2]})).unwrap();
s.execute(cmd, json!({"range": [50, 90]})).unwrap();
s.execute(cmd, json!({"range": [u64::MAX, 0]})).unwrap();
s.execute(cmd, json!({"range": "all"})).unwrap();
assert!(s.execute(cmd, json!({"layer": id.0, "range": [0, 1]})).is_ok());
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::TabularLining));
}
#[test]
fn numerals_tabular_changes_inter_digit_widths() {
let mut s = session(8);
let r = s.execute("type.create", json!({"x": 20, "y": 70, "text": "1111111111", "font": "Inter", "size": 36, "color": "#000000"})).unwrap();
let id = LayerId(r["layer"].as_u64().unwrap());
let width = |s: &Session| {
let t = text(s, id).clone();
let mut e = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let layout = e.layout(&t, 72.0);
layout.lines.first().map_or(0.0, |line| line.x1 - line.x0)
};
let plain = width(&s);
s.execute("type.openType.numerals.tabular", json!({})).unwrap();
let tab = width(&s);
assert!((tab - plain).abs() > 0.5, "tnum changes Inter's digit advances: {plain} → {tab}");
s.execute("type.openType.numerals.default", json!({})).unwrap();
assert!((width(&s) - plain).abs() < 0.5, "default restores the advances");
}
#[test]
fn lorem_update_fonts_and_default_styles() {
let mut s = session(16);
+14 -6
View File
@@ -8,7 +8,7 @@
use photocraft_algo::selection::{self as sel, SelectionMode};
use photocraft_doc::text::AntiAlias;
use photocraft_doc::{Document, LayerContent, LayerId, TextLayer};
use photocraft_doc::{Document, LayerContent, TextLayer};
use photocraft_geom::Rect;
use serde_json::{Value, json};
@@ -87,10 +87,7 @@ fn create_selection(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => v.as_bool().ok_or_else(|| bad("removeLayer must be true or false"))?,
};
let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let layer = match p.get("layer") {
None | Some(Value::Null) => None,
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("layer must be a layer id"))?)),
};
let layer = crate::commands::explicit_layer(s, p)?;
if remove && layer.is_none() {
return Err(bad("removeLayer needs a layer"));
}
@@ -144,7 +141,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Type Mask",
menu: &[],
shortcut: None,
params: r##"{"layer":id? (a type layer whose glyphs become the selection),"removeLayer":bool=false (delete that layer in the same step: the Type Mask tools' temporary text),"operation":"new|add|subtract|intersect"="new","antialias":"none|sharp|crisp|strong|smooth"?, …without "layer", type.create's keys: "text","x","y" or "box","orientation","font","size","align",…}"##,
params: r##"{"layer":id|name? (a type layer whose glyphs become the selection),"removeLayer":bool=false (delete that layer in the same step: the Type Mask tools' temporary text),"operation":"new|add|subtract|intersect"="new","antialias":"none|sharp|crisp|strong|smooth"?, …without "layer", type.create's keys: "text","x","y" or "box","orientation","font","size","align",…}"##,
enabled: has_doc,
journal: true,
run: create_selection,
@@ -154,6 +151,7 @@ pub fn specs() -> Vec<CommandSpec> {
#[cfg(test)]
mod tests {
use super::*;
use photocraft_doc::LayerId;
fn session() -> Session {
let mut s = Session::new();
@@ -165,6 +163,16 @@ mod tests {
s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds())
}
#[test]
fn a_named_type_layer_can_make_a_selection_for_action_replay() {
let mut s = session();
let id = s.execute("type.create", json!({"text": "I", "x": 40, "y": 120, "size": 48})).unwrap()["layer"].as_u64().unwrap();
let name = s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute(CMD, json!({"layer": name})).unwrap();
assert!(bounds(&s).is_some());
}
#[test]
fn typed_text_selects_its_glyphs_without_a_layer() {
let mut s = session();
+107 -10
View File
@@ -4,7 +4,8 @@
//! (`photocraft_text::spell`, SCOWL size 50) plus the user dictionary in the preferences
//! (`userDictionary`). Headless, it lists misspellings with suggestions and changes, changes all,
//! or adds words to the dictionary; the UI's dialog drives the same actions. Offsets are
//! character indices.
//! character indices. An action records Check Spelling as one `{"allLayers"}` step with its
//! dialog on, and records none of the corrections.
//!
//! `type.insertText` inserts a string (a glyph) into a type layer at a character position or
//! over a range.
@@ -79,14 +80,14 @@ fn accepted(s: &Session, p: &Value) -> HashSet<String> {
set
}
fn layers_param(s: &Session, p: &Value) -> Vec<LayerId> {
let Some(st) = s.active() else { return Vec::new() };
fn layers_param(s: &Session, p: &Value) -> Result<Vec<LayerId>> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let all = type_layers(&st.doc);
if p.get("allLayers").and_then(Value::as_bool) == Some(false) || p.get("layer").is_some() {
let active = layer_param(s, p).ok();
all.into_iter().filter(|id| Some(*id) == active).collect()
let active = layer_param(s, p)?;
Ok(all.into_iter().filter(|id| *id == active).collect())
} else {
all
Ok(all)
}
}
@@ -96,7 +97,7 @@ fn list(s: &Session, p: &Value) -> Result<Value> {
let ok = accepted(s, p);
let max = p.get("suggestions").and_then(Value::as_u64).unwrap_or(5) as usize;
let mut out = Vec::new();
for id in layers_param(s, p) {
for id in layers_param(s, p)? {
let Some(t) = text(&st.doc, id) else { continue };
let found = dict.misspellings(t, &ok);
// Convert every start/end byte offset to a character offset in one pass over the
@@ -117,7 +118,7 @@ fn list(s: &Session, p: &Value) -> Result<Value> {
/// Replaces one occurrence (verified to still be `word` when given).
fn change(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.checkSpelling";
let id = LayerId(p.get("layer").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `layer`"))?);
let id = layer_param(s, p)?;
let start = p.get("start").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `start`"))? as usize;
let end = p.get("end").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `end`"))? as usize;
let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`change` needs `replace`"))?.to_string();
@@ -147,7 +148,7 @@ fn change_all(s: &mut Session, p: &Value) -> Result<Value> {
let word = p.get("word").and_then(Value::as_str).filter(|w| !w.is_empty()).ok_or_else(|| bad(cmd, "`changeAll` needs `word`"))?.to_string();
let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`changeAll` needs `replace`"))?.to_string();
let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let plan: Vec<(LayerId, Vec<(usize, usize)>)> = layers_param(s, p)
let plan: Vec<(LayerId, Vec<(usize, usize)>)> = layers_param(s, p)?
.into_iter()
.filter_map(|id| {
let t = text(&doc, id)?;
@@ -203,6 +204,18 @@ fn remove_word(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "removed": removed, "userDictionary": s.prefs().user_dictionary }))
}
/// Whether an action records this call: only Check Spelling itself (no `action`), which the
/// dialog runs when it closes. Its corrections, dictionary edits and lookups are not recorded.
pub(crate) fn records(p: &Value) -> bool {
p.get("action").is_none_or(Value::is_null)
}
/// The step a recorded Check Spelling becomes: `{"allLayers"}`, `false` when it checked one layer.
pub(crate) fn recorded_step(p: &Value) -> Value {
let one_layer = p.get("allLayers").and_then(Value::as_bool) == Some(false) || p.get("layer").is_some();
json!({ "allLayers": !one_layer })
}
fn check_spelling(s: &mut Session, p: &Value) -> Result<Value> {
match p.get("action").and_then(Value::as_str).unwrap_or("list") {
"list" | "check" => list(s, p),
@@ -270,7 +283,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Check Spelling…",
menu: &["Edit"],
shortcut: None,
params: r#"{"action":"list|change|changeAll|addToDictionary|removeFromDictionary|suggest"="list","allLayers":bool=true,"layer":id? (check one layer),"ignore":[word]? (Ignore All for this check),"suggestions":n=5; change: "layer","start","end" (chars),"word"? (verified),"replace"; changeAll: "word","replace"; addToDictionary/removeFromDictionary/suggest: "word"} → list: {"misspellings":[{"layer","start","end","word","suggestions"}],"count"}"#,
params: r#"{"action":"list|change|changeAll|addToDictionary|removeFromDictionary|suggest"="list","allLayers":bool=true,"layer":id|name? (check one layer),"ignore":[word]? (Ignore All for this check),"suggestions":n=5; change: "layer"? (default: the active layer),"start","end" (chars),"word"? (verified),"replace"; changeAll: "word","replace"; addToDictionary/removeFromDictionary/suggest: "word"} → list: {"misspellings":[{"layer","start","end","word","suggestions"}],"count"} (an action records only a call without "action", as {"allLayers"} with its dialog on)"#,
enabled: has_type,
journal: true,
run: check_spelling,
@@ -314,6 +327,27 @@ mod tests {
text(&s.active().unwrap().doc, LayerId(id)).unwrap().to_string()
}
#[test]
fn missing_named_spelling_layer_is_an_error_for_list_and_change_all() {
let (mut s, _) = session("Teh");
for action in ["list", "changeAll"] {
let result = s.execute("edit.checkSpelling", json!({"action": action, "layer": "Removed type", "word": "Teh", "replace": "The"}));
assert!(result.is_err(), "{action} silently accepted a missing named layer: {result:?}");
}
}
#[test]
fn named_layer_list_and_change_all_resolve_a_nonactive_layer() {
let (mut s, id) = session("Teh");
let name = s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone();
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
let listed = s.execute("edit.checkSpelling", json!({"action": "list", "layer": name})).unwrap();
assert_eq!(listed["count"], 1);
let changed = s.execute("edit.checkSpelling", json!({"action": "changeAll", "layer": name, "word": "Teh", "replace": "The"})).unwrap();
assert_eq!(changed["changed"], 1);
assert_eq!(text_of(&s, id), "The");
}
#[test]
fn all_locked_layer_rejects_insert_text_and_spelling_change_without_history() {
// #4188: Lock All stopped type.edit (#3132) but not these two text edits.
@@ -369,6 +403,69 @@ mod tests {
assert_eq!(r["correct"], false);
}
#[test]
fn change_without_layer_edits_the_active_type_layer() {
let (mut s, id) = session("Teh end");
let r = s.execute("edit.checkSpelling", json!({"action": "change", "start": 0, "end": 3, "word": "Teh", "replace": "The"})).unwrap();
assert_eq!((r["layer"].as_u64(), r["end"].as_u64()), (Some(id), Some(3)));
assert_eq!(text_of(&s, id), "The end");
}
#[test]
fn recording_keeps_check_spelling_with_its_dialog_and_not_its_corrections() {
let (mut s, id) = session("Teh brwon end");
s.execute("actions.record", json!({"name": "Spell"})).unwrap();
s.execute("edit.checkSpelling", json!({"action": "list", "allLayers": true})).unwrap();
s.execute("edit.checkSpelling", json!({"action": "change", "layer": id, "start": 0, "end": 3, "word": "Teh", "replace": "The"})).unwrap();
s.execute("edit.checkSpelling", json!({"action": "changeAll", "word": "brwon", "replace": "brown"})).unwrap();
s.execute("edit.checkSpelling", json!({"action": "suggest", "word": "recieve"})).unwrap();
s.execute("edit.checkSpelling", json!({"action": "addToDictionary", "word": "Photocraft"})).unwrap();
s.execute("edit.checkSpelling", json!({"action": "removeFromDictionary", "word": "Photocraft"})).unwrap();
// The dialog closing.
s.execute("edit.checkSpelling", json!({"allLayers": true, "suggestions": 8})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(text_of(&s, id), "The brown end");
let action = &s.actions.list[0];
assert_eq!(action.steps, vec![("edit.checkSpelling".to_string(), json!({"allLayers": true}))]);
assert_eq!(action.dialogs.iter().copied().collect::<Vec<_>>(), [0]);
}
#[test]
fn recorded_check_of_one_layer_records_all_layers_off() {
let (mut s, id) = session("Teh end");
s.execute("actions.record", json!({"name": "One"})).unwrap();
s.execute("edit.checkSpelling", json!({"allLayers": false})).unwrap();
s.execute("edit.checkSpelling", json!({"layer": id})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let steps: Vec<&Value> = s.actions.list[0].steps.iter().map(|(_, p)| p).collect();
assert_eq!(steps, [&json!({"allLayers": false}), &json!({"allLayers": false})]);
assert_eq!(s.actions.list[0].dialogs.len(), 2);
}
/// `"action": null` runs Check Spelling itself, so it is recorded as one.
#[test]
fn a_null_action_is_recorded_as_check_spelling() {
let (mut s, _) = session("Teh end");
s.execute("actions.record", json!({"name": "Null"})).unwrap();
s.execute("edit.checkSpelling", json!({"action": null})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps, [("edit.checkSpelling".to_string(), json!({"allLayers": true}))]);
}
#[test]
fn played_check_spelling_without_its_dialog_changes_nothing() {
let (mut s, id) = session("Teh end");
s.execute("actions.record", json!({"name": "Spell"})).unwrap();
s.execute("edit.checkSpelling", json!({"allLayers": true})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let history = s.active().unwrap().history.past_len();
let r = s.execute("actions.play", json!({"action": "Spell"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
assert_eq!(r["ran"], 1);
assert_eq!(text_of(&s, id), "Teh end");
assert_eq!(s.active().unwrap().history.past_len(), history);
}
#[test]
fn disabled_without_type() {
let mut s = Session::new();
+11 -8
View File
@@ -42,21 +42,21 @@ fn text_of(doc: &Document, id: LayerId) -> Option<&TextLayer> {
}
/// Type layers a style command acts on: `layer`, `layers`, else the selected type layers.
fn targets(s: &Session, p: &Value) -> Vec<LayerId> {
let Some(st) = s.active() else { return Vec::new() };
let ids: Vec<LayerId> = if let Some(id) = p.get("layer").and_then(Value::as_u64) {
vec![LayerId(id)]
fn targets(s: &Session, p: &Value) -> Result<Vec<LayerId>> {
let Some(st) = s.active() else { return Ok(Vec::new()) };
let ids: Vec<LayerId> = if let Some(id) = crate::commands::explicit_layer(s, p)? {
vec![id]
} else if let Some(a) = p.get("layers").and_then(Value::as_array) {
a.iter().filter_map(Value::as_u64).map(LayerId).collect()
} else {
st.selected_layers()
};
ids.into_iter().filter(|id| text_of(&st.doc, *id).is_some()).collect()
Ok(ids.into_iter().filter(|id| text_of(&st.doc, *id).is_some()).collect())
}
fn has_target(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if targets(s, &Value::Null).is_empty() { Err("select a type layer".into()) } else { Ok(()) }
if targets(s, &Value::Null).is_ok_and(|targets| !targets.is_empty()) { Ok(()) } else { Err("select a type layer".into()) }
}
fn byte_at(text: &str, ci: usize) -> usize {
@@ -317,7 +317,7 @@ type Target = (LayerId, Option<(usize, usize)>);
/// Target list with byte ranges (`range` applies only to a single layer).
fn target_ranges(s: &Session, p: &Value) -> Result<Vec<Target>> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let ids = targets(s, p);
let ids = targets(s, p)?;
if ids.is_empty() {
return Err(EngineError::Other("no type layer to apply the style to".into()));
}
@@ -334,7 +334,7 @@ fn target_ranges(s: &Session, p: &Value) -> Result<Vec<Target>> {
/// run's byte range.
fn sample(s: &Session, p: &Value) -> Option<(CharStyle, ParagraphStyle, LayerId, (usize, usize))> {
let st = s.active()?;
let id = *targets(s, p).first()?;
let id = *targets(s, p).ok()?.first()?;
let t = text_of(&st.doc, id)?;
let (a, _) = range_of(&t.text, p);
let mut at = 0;
@@ -430,6 +430,9 @@ fn new_style(s: &mut Session, p: &Value, paragraph: bool) -> Result<Value> {
let (mut ca, mut pa) = parse_attrs(cmd, p.get("attrs").unwrap_or(&Value::Null))?;
// With a type layer targeted, the style starts from the text's formatting (as Photoshop).
let from_text = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true);
if from_text {
crate::commands::explicit_layer(s, p)?;
}
if from_text && let Some((run, para, _, _)) = sample(s, p) {
if paragraph {
let base_c = st.resolve_char(None, None, FAMILY);
@@ -73,6 +73,15 @@ fn character_style_lifecycle() {
assert_eq!(run_at(&layer(&s, id), 7).style_sheet, Some(cs as u32));
}
#[test]
fn creating_a_style_rejects_an_unmatched_named_source_layer() {
let (mut s, _) = session("Hello");
for id in ["type.characterStyle.new", "type.paragraphStyle.new"] {
let error = s.execute(id, json!({"name": "From missing", "layer": "Missing", "fromSelection": true})).unwrap_err();
assert!(error.to_string().contains("no layer named"), "{error}");
}
}
#[test]
fn new_style_from_selection_and_redefine() {
let (mut s, id) = session("Title");
+1 -4
View File
@@ -362,10 +362,7 @@ const MAX_WARP_SIDE: u32 = 100_000;
fn target(s: &Session, cmd: &str, p: &Value) -> Result<(LayerId, Rect)> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let id = match p.get("layer").and_then(Value::as_u64) {
Some(v) => LayerId(v),
None => st.active_layer.ok_or(EngineError::Other("no active layer".into()))?,
};
let id = crate::commands::layer_param(s, p)?;
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let rect = match p.get("rect").and_then(Value::as_array) {
Some(r) if r.len() == 4 => {
+1 -1
View File
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web (Legacy)…",
&["File", "Export"],
Some("Cmd+Alt+Shift+S"),
r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp|webp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##,
r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp|webp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"folder":folder? (whole image, named after the document; ignored with "path" or "dir"),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##,
has_doc,
save_for_web
),
+16 -2
View File
@@ -158,7 +158,7 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu
fn remember_web_settings(s: &mut Session, cmd: &str, settings: &Value) {
let mut m = settings.clone();
if let Some(o) = m.as_object_mut() {
for k in ["path", "dir", "trustedSliceHtml"] {
for k in ["path", "dir", "folder", "trustedSliceHtml"] {
o.remove(k);
}
}
@@ -203,6 +203,10 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u
.collect();
let single = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty());
let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty());
// `folder` (with no `path` or `dir`): the whole image into that folder under the document's
// own name, so an action recorded with the suggested name saves each document it is played
// on under that document's name, as in Photoshop (#5305).
let folder = p.get("folder").and_then(Value::as_str).filter(|_| single.is_none() && dir.is_none());
let mut last_web = p.clone();
// A trusted-markup decision applies to this export only, never to another document opened
// with the dialog's remembered settings.
@@ -210,12 +214,22 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u
last.remove("trustedSliceHtml");
}
// Estimate only (the dialog's annotations): the whole image, or each chosen slice.
if single.is_none() && dir.is_none() {
if single.is_none() && dir.is_none() && folder.is_none() {
let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?;
return Ok(
json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::<Vec<_>>(), "transparentIndex": o.transparent_index}),
);
}
if let Some(folder) = folder {
let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?;
let name = format!("{}.{}", portable_generated_stem(slices::base_name(&doc)), o.ext);
write(&web_derived_path(folder, &name, native_paths)?, &o.bytes)?;
remember_web_settings(s, cmd, &last_web);
s.file_menu.last_web = Some(last_web);
crate::automate_cmds::fire_event(s, "export");
let path = join(folder, &name);
return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "warnings": warnings}));
}
if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) {
let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?;
write(path, &o.bytes)?;
+22
View File
@@ -1317,3 +1317,25 @@ fn embed_color_profile_tags_an_untagged_document_srgb() {
assert_eq!(profile(&off), None, "{format}");
}
}
/// #5305: `folder` writes the whole image there under the document's own name, so an action
/// recorded with the suggested name saves each document it is played on under that name; it is
/// a destination, never a remembered setting.
#[test]
fn folder_saves_under_the_documents_own_name() {
let dir = tmp("folder_own_name");
let mut s = session(8);
let r = s.execute("file.export.saveForWebLegacy", json!({"folder": dir, "format": "png24"})).unwrap();
let first = format!("{dir}/web.png");
assert_eq!(r["files"], json!([first]));
assert_eq!(decode(&first).dimensions(), (64, 48));
assert!(s.prefs().dialogs.get("file.export.saveForWebLegacy").is_some_and(|m| m.get("folder").is_none()));
s.execute("file.new", json!({"width": 8, "height": 4, "name": "second.psd"})).unwrap();
let r = s.execute("file.export.saveForWebLegacy", json!({"folder": dir, "format": "jpeg"})).unwrap();
assert_eq!(r["files"], json!([format!("{dir}/second.jpg")]));
assert_eq!(decode(&format!("{dir}/second.jpg")).dimensions(), (8, 4));
// A `path` wins over `folder`.
let r = s.execute("file.export.saveForWebLegacy", json!({"path": format!("{dir}/named.png"), "folder": dir, "format": "png24"})).unwrap();
assert_eq!(r["files"], json!([format!("{dir}/named.png")]));
let _ = std::fs::remove_dir_all(&dir);
}
+405 -51
View File
@@ -14,13 +14,18 @@
//! On wasm `start` runs inline (same API, written once).
//! - [`Session::cancel_job`] sets the flag; the algorithms check it per tile, row band or
//! iteration, and the result is discarded. The document is never touched by a worker.
//! - A job with an effect outside the session (a save replacing its file) calls
//! [`JobCtx::commit`] right before it. From then on it can't be cancelled: every cancel
//! path ([`Session::try_cancel_job`], [`Session::cancel_all_jobs`], closing its document,
//! `jobs.cancel`) leaves it running and says so ([`CancelRefused::Committing`]), so nothing
//! reports a job as cancelled after its effect has happened.
//! - A panic in the worker becomes an error; the document is unchanged.
//! - While a job runs on a document, commands that would edit that document are disabled with a
//! message naming the job (queries and document-independent commands still run).
use std::any::Any;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, PoisonError, Weak};
use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
use std::sync::{Arc, Condvar, Mutex, PoisonError, Weak};
use photocraft_doc::{DocId, Document, LayerId};
use photocraft_raster::Interrupt;
@@ -84,13 +89,51 @@ pub struct JobCtx {
#[derive(Default)]
struct Shared {
cancel: AtomicBool,
/// [`CANCEL_REQUESTED`], [`WORK_FINISHED`] and [`COMMITTED`] bits. One atomic orders a cancel
/// against [`JobCtx::commit`] and against the worker finishing, so exactly one of them wins.
state: AtomicU8,
/// `f32` bits of the progress fraction. Non-negative floats order like their bits, so
/// `fetch_max` keeps progress monotonic even with several reporting threads.
progress: AtomicU32,
message: Mutex<String>,
batch: Mutex<BatchInterrupt>,
batch_decision: Condvar,
batch_preview: Mutex<Option<BatchPreview>>,
batch_preview_generation: std::sync::atomic::AtomicU64,
}
/// The current folder Batch input, published as an immutable snapshot for the desktop canvas.
/// It is never added to the live session and cannot receive commands or saves.
#[derive(Clone, Debug)]
pub struct BatchPreview {
pub path: String,
pub document: Arc<Document>,
pub generation: u64,
}
/// The desktop Batch Esc handshake. The worker alone enters `Waiting` at a pre-save
/// checkpoint; only the UI supplies a decision. Programmatic cancellation uses the separate
/// atomic flag above and wakes a waiting worker without asking a question.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum BatchInterrupt {
#[default]
Processing,
Requested,
Waiting,
Continue,
Stop,
Saving,
/// Every input has been handled; Esc is no longer accepted.
Closed,
}
/// Cancelled before it committed.
const CANCEL_REQUESTED: u8 = 1;
/// The worker has sent its result; a Batch no longer accepts a cancel.
const WORK_FINISHED: u8 = 2;
/// Past [`JobCtx::commit`]: the job runs to the end and can no longer be cancelled.
const COMMITTED: u8 = 4;
impl std::fmt::Debug for JobCtx {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JobCtx").field("progress", &self.fraction()).field("cancelled", &self.cancelled()).finish()
@@ -118,12 +161,173 @@ impl JobCtx {
/// Has the job been cancelled? Long loops check this and stop early.
pub fn cancelled(&self) -> bool {
self.shared.cancel.load(Ordering::Relaxed)
self.shared.state.load(Ordering::Acquire) & CANCEL_REQUESTED != 0
}
/// Ask the job to stop (idempotent).
/// Ask the job to stop (idempotent). Does nothing once it has [committed](Self::commit).
pub fn cancel(&self) {
self.shared.cancel.store(true, Ordering::Relaxed);
let _ = self.try_cancel();
}
/// Cancel unless the job has [committed](Self::commit): true if it is cancelled (now or
/// before), false if it is past its point of no return and will run to the end.
pub fn try_cancel(&self) -> bool {
let r = self.shared.state.try_update(Ordering::AcqRel, Ordering::Acquire, |state| (state & COMMITTED == 0).then_some(state | CANCEL_REQUESTED));
if r.is_ok() {
self.wake_batch_worker();
}
r.is_ok()
}
/// The job's point of no return, for work with an effect outside the session (a save calls
/// it right before replacing the file). `Ok`: no cancel can stop the job any more; it must
/// run to the end, and its result is applied even if its document was closed meanwhile.
/// `Err(Cancelled)`: it was cancelled first, and nothing may be done.
pub fn commit(&self) -> Result<()> {
self.check()?;
self.shared
.state
.try_update(Ordering::AcqRel, Ordering::Acquire, |state| (state & CANCEL_REQUESTED == 0).then_some(state | COMMITTED))
.map(|_| ())
.map_err(|_| EngineError::Cancelled)
}
/// Has the job passed [`Self::commit`]?
pub fn committed(&self) -> bool {
self.shared.state.load(Ordering::Acquire) & COMMITTED != 0
}
/// Wake a Batch worker waiting for the Esc decision, so it sees a cancel.
fn wake_batch_worker(&self) {
let _phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
self.shared.batch_decision.notify_all();
}
/// Accept a cancellation only while the worker has not finished its result. One atomic state
/// orders a late cancel against completion, including the interval before the UI polls.
fn cancel_if_running(&self) -> bool {
let accepted = self
.shared
.state
.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
(state & (CANCEL_REQUESTED | WORK_FINISHED | COMMITTED) == 0).then_some(state | CANCEL_REQUESTED)
})
.is_ok();
if accepted {
self.wake_batch_worker();
}
accepted
}
/// Request the reference application's Esc decision for an interactive Batch. A save that
/// passed its final write checkpoint cannot be interrupted; the caller may try the next
/// input. This does not affect the direct `jobs.cancel` automation API.
pub fn request_batch_interrupt(&self) -> bool {
if self.cancelled() || self.shared.state.load(Ordering::Acquire) & WORK_FINISHED != 0 {
return false;
}
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
if *phase != BatchInterrupt::Processing {
return false;
}
*phase = BatchInterrupt::Requested;
true
}
/// The worker has stopped before writing the current input and awaits Continue or Stop.
pub fn batch_waiting_for_decision(&self) -> bool {
*self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) == BatchInterrupt::Waiting
}
/// Answer a waiting interactive Batch question. `continue_next` skips the current input.
pub fn decide_batch_interrupt(&self, continue_next: bool) -> bool {
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
if *phase != BatchInterrupt::Waiting {
return false;
}
*phase = if continue_next { BatchInterrupt::Continue } else { BatchInterrupt::Stop };
self.shared.batch_decision.notify_all();
true
}
/// Whether an action should stop before its next step because Esc was requested.
pub fn batch_interrupt_requested(&self) -> bool {
*self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) == BatchInterrupt::Requested
}
/// Move an accepted Esc request to the question at a worker checkpoint.
pub fn batch_take_interrupt(&self) -> bool {
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
if *phase != BatchInterrupt::Requested {
return *phase == BatchInterrupt::Waiting;
}
*phase = BatchInterrupt::Waiting;
true
}
/// Wait for the UI decision without polling. Closing the session or `jobs.cancel` wakes this
/// worker; the caller then reports its partial result.
pub fn batch_wait_for_decision(&self) -> bool {
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
while *phase == BatchInterrupt::Waiting && !self.cancelled() {
phase = self.shared.batch_decision.wait(phase).unwrap_or_else(PoisonError::into_inner);
}
let continue_next = *phase == BatchInterrupt::Continue && !self.cancelled();
*phase = BatchInterrupt::Processing;
continue_next
}
/// Mark the start of an input's output write. The same lock orders Esc against this
/// checkpoint: a requested interruption never writes that input.
pub(crate) fn batch_before_write(&self) -> Result<()> {
self.check()?;
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
if *phase == BatchInterrupt::Requested {
*phase = BatchInterrupt::Waiting;
return Err(EngineError::Cancelled);
}
if self.cancelled() {
return Err(EngineError::Cancelled);
}
*phase = BatchInterrupt::Saving;
Ok(())
}
/// The worker has passed its last checkpoint. A pending request is dropped (nothing is left to
/// interrupt) and later requests return false.
pub(crate) fn batch_close_interrupts(&self) {
*self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) = BatchInterrupt::Closed;
self.shared.batch_decision.notify_all();
}
/// A completed write allows Esc on the next input.
pub(crate) fn batch_after_write(&self) {
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
if *phase == BatchInterrupt::Saving {
*phase = BatchInterrupt::Processing;
}
}
/// Publish the current input's document after it opens or an action step changes it.
pub fn publish_batch_preview(&self, path: &str, document: Option<Arc<Document>>) {
let mut preview = self.shared.batch_preview.lock().unwrap_or_else(PoisonError::into_inner);
if preview.as_ref().zip(document.as_ref()).is_some_and(|(shown, next)| shown.path == path && Arc::ptr_eq(&shown.document, next))
|| (preview.is_none() && document.is_none())
{
return;
}
let generation = self.shared.batch_preview_generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1);
*preview = document.map(|document| BatchPreview { path: path.to_string(), document, generation });
}
/// A snapshot for rendering, if the current input still has a document open.
pub fn batch_preview(&self) -> Option<BatchPreview> {
self.shared.batch_preview.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
#[cfg(not(target_arch = "wasm32"))]
fn finish_work(&self) {
self.shared.state.fetch_or(WORK_FINISHED, Ordering::Release);
}
/// `Err(Cancelled)` once cancelled, for `?` between stages.
@@ -151,6 +355,53 @@ impl JobCtx {
}
}
/// Why [`Session::try_cancel_job`] left a job running.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CancelRefused {
/// No such job is running.
NotRunning,
/// A print belongs to the system print queue once it has started.
Printing,
/// Past its [commit](JobCtx::commit): it is writing its result (a save replacing its file)
/// and will report its real outcome when it ends.
Committing,
/// A Batch already asked to stop, or past its last input: it ends on its own and reports
/// the outputs it wrote.
Finishing,
}
impl CancelRefused {
/// The scripting name (`jobs.cancel`'s `notCancelled[].reason`).
pub fn as_str(self) -> &'static str {
match self {
Self::NotRunning => "notRunning",
Self::Printing => "printing",
Self::Committing => "committing",
Self::Finishing => "finishing",
}
}
}
/// What a cancel of several jobs did ([`Session::cancel_all_jobs`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CancelReport {
pub cancelled: Vec<JobId>,
/// The jobs left running, and why.
pub refused: Vec<(JobId, CancelRefused)>,
}
impl CancelReport {
/// The `jobs.cancel` reply: `{cancelled: n}`, with `notCancelled: [{job, reason}]` when a
/// job was left running.
pub fn to_json(&self) -> Value {
let mut v = json!({"cancelled": self.cancelled.len()});
if !self.refused.is_empty() {
v["notCancelled"] = self.refused.iter().map(|(id, why)| json!({"job": id.0, "reason": why.as_str()})).collect();
}
v
}
}
/// Final state of a job.
#[derive(Clone, Debug, PartialEq)]
pub enum JobOutcome {
@@ -192,7 +443,7 @@ pub struct JobInfo {
pub progress: f32,
pub message: String,
pub elapsed_ms: f64,
/// The command's result (done) or the error (failed).
/// The command's result (done), including partial outputs from a cancelled batch.
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -231,6 +482,7 @@ struct Running {
/// later must not pick up an edit made before it.
recorded_by: Option<u64>,
pre_selected: Option<Vec<LayerId>>,
before: crate::actions_record::Before,
doc: Option<DocId>,
/// The document as the job saw it: applying fails if it was edited meanwhile.
snapshot: Option<Weak<Document>>,
@@ -453,6 +705,7 @@ pub fn run<T: Send + 'static>(
let _document = crate::doc_path::scope(document.as_deref());
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload)))
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged"))));
wctx.finish_work();
// The receiver is gone when the job was cancelled: nothing to report.
let _ = tx.send(r);
});
@@ -471,6 +724,7 @@ pub fn run<T: Send + 'static>(
journal: true,
recorded_by: None,
pre_selected: None,
before: crate::actions_record::Before::capture(s),
doc,
snapshot,
ctx,
@@ -1070,31 +1324,82 @@ impl Session {
}
}
/// Cancel job `id`: it stops at its next check, its result is discarded and the document
/// stays unchanged. The document is unlocked at once. False if no such job is running.
/// Cancel job `id`: ordinary jobs stop at their next check, discard their result and leave
/// the document unchanged; the document is unlocked at once. A batch finishes the current
/// input and reports outputs already written. False if no such job is running or it
/// can't be cancelled (see [`Self::try_cancel_job`]).
pub fn cancel_job(&mut self, id: JobId) -> bool {
let Some(i) = self.jobs.running.iter().position(|r| r.id == id) else { return false };
self.try_cancel_job(id).is_ok()
}
/// [`Self::cancel_job`], saying why a job was not cancelled.
pub fn try_cancel_job(&mut self, id: JobId) -> std::result::Result<(), CancelRefused> {
let Some(i) = self.jobs.running.iter().position(|r| r.id == id) else { return Err(CancelRefused::NotRunning) };
// Earlier batch outputs are already on disk. Let the worker stop after its current
// input and return the completed and skipped lists.
if self.jobs.running[i].command == "file.automate.batch" {
return if self.jobs.running[i].ctx.cancel_if_running() { Ok(()) } else { Err(CancelRefused::Finishing) };
}
// A print worker can already have handed the job to the OS. Discarding its result
// would falsely report cancellation; removal belongs to the system print queue.
if matches!(self.jobs.running[i].command.as_str(), "file.print" | "file.printOneCopy") {
return false;
return Err(CancelRefused::Printing);
}
// Past its commit (a save replacing its file) the job runs to the end and reports its
// real outcome; calling it cancelled would be false.
if !self.jobs.running[i].ctx.try_cancel() {
return Err(CancelRefused::Committing);
}
let job = self.jobs.running.remove(i);
job.ctx.cancel();
self.end_job(&job, JobOutcome::Cancelled);
#[cfg(not(target_arch = "wasm32"))]
if let Some(handle) = job.handle {
self.jobs.draining.push(Draining { handle, doc: job.doc, release: false });
}
true
Ok(())
}
/// Cancel every running job (e.g. before quitting).
pub fn cancel_all_jobs(&mut self) {
/// Ask an interactive Batch worker to stop its current input at the next safe checkpoint.
/// Headless clients keep using [`Self::cancel_job`] for a direct partial result.
pub fn request_batch_interrupt_job(&self, id: JobId) -> bool {
self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").is_some_and(|job| job.ctx.request_batch_interrupt())
}
/// Whether a Batch worker has acknowledged Esc and is waiting for the desktop decision.
pub fn batch_interrupt_waiting(&self, id: JobId) -> bool {
self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").is_some_and(|job| job.ctx.batch_waiting_for_decision())
}
/// Continue with the next input or stop a Batch paused by the desktop Esc key.
pub fn decide_batch_interrupt_job(&self, id: JobId, continue_next: bool) -> bool {
self.jobs
.running
.iter()
.find(|job| job.id == id && job.command == "file.automate.batch")
.is_some_and(|job| job.ctx.decide_batch_interrupt(continue_next))
}
/// The current read-only folder Batch document shown by the desktop while work runs.
pub fn batch_preview(&self, id: JobId) -> Option<BatchPreview> {
self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").and_then(|job| job.ctx.batch_preview())
}
/// Cancel every running job (e.g. before quitting). Prints and jobs past their commit keep
/// running; the report lists them.
pub fn cancel_all_jobs(&mut self) -> CancelReport {
let ids: Vec<JobId> = self.jobs.running.iter().map(|r| r.id).collect();
self.cancel_each(ids)
}
fn cancel_each(&mut self, ids: Vec<JobId>) -> CancelReport {
let mut report = CancelReport::default();
for id in ids {
self.cancel_job(id);
match self.try_cancel_job(id) {
Ok(()) => report.cancelled.push(id),
Err(why) => report.refused.push((id, why)),
}
}
report
}
/// Is any job running? (Cheap: the UI asks every frame.)
@@ -1152,17 +1457,17 @@ impl Session {
Some(format!("“{}” is still running on this document; wait for it to finish or cancel it (Esc)", job.label))
}
/// Cancel the jobs editing document `doc` (it is closing).
pub(crate) fn cancel_jobs_on(&mut self, doc: DocId) {
/// Cancel the jobs editing document `doc` (it is closing). A job past its commit keeps
/// running and its result is still applied (see [`JobCtx::commit`]).
pub(crate) fn cancel_jobs_on(&mut self, doc: DocId) -> CancelReport {
let ids: Vec<JobId> = self.jobs.running.iter().filter(|r| r.doc == Some(doc)).map(|r| r.id).collect();
for id in ids {
self.cancel_job(id);
}
let report = self.cancel_each(ids);
// Workers cancelled now, or earlier and still unwinding, hold the closing document.
#[cfg(not(target_arch = "wasm32"))]
for d in self.jobs.draining.iter_mut().filter(|d| d.doc == Some(doc)) {
d.release = true;
}
report
}
/// Run the command `id`: inline, or (with `background`) as a job when it is one.
@@ -1242,6 +1547,7 @@ impl Session {
// Commands this one runs on its own behalf journal after this mark (#3067).
let nested_from = self.journal.len();
let pre_selected = if self.actions.recording.is_some() { self.active().map(|st| st.selected_layers()) } else { None };
let mut record_before = crate::actions_record::Before::capture(self);
// A replayable command is recorded as one step, so recording pauses while it runs: an
// action it plays (a script step) must not flush the steps it ran so far into the action
// being recorded, nor move the recording mark past the entries it is about to replace.
@@ -1279,6 +1585,7 @@ impl Session {
job.journal = spec.journal;
job.recorded_by = self.actions.recording.map(|_| self.actions.take);
job.pre_selected = pre_selected.clone();
job.before = std::mem::take(&mut record_before);
let jid = job.id;
self.jobs.running.push(job);
Ok(Started::Job(jid))
@@ -1298,15 +1605,16 @@ impl Session {
st.fx_collapsed.push(layer_id);
}
}
self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref());
self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref(), &record_before);
Ok(Started::Done(v))
}
};
// A closed document's freed buffers went to the scratch pool (`before` kept the active
// one alive until here): hand them back to the OS (#10479). Compared by id, since Split
// and Merge Channels close documents while opening others.
// A closed document's freed buffers went to the scratch pool (`before` and the action
// recording snapshot kept it alive until here). Drop both before handing the pool back
// to the OS (#10479). Compare ids since Split/Merge Channels open replacement documents.
if docs_before.iter().any(|id| !self.docs.iter().any(|st| st.doc.id == *id)) {
drop(before);
drop(record_before);
photocraft_raster::scratch::release();
}
out
@@ -1344,7 +1652,15 @@ impl Session {
/// dropped, so a recorded action replays the command once, not its steps as well (#3067).
/// Edit › Transform › Again (and Again on a Copy) is not replayable and keeps the concrete
/// steps it ran instead.
fn after_command(&mut self, id: &str, params: Value, journal: bool, nested_from: Option<usize>, pre_selected: Option<&[LayerId]>) {
fn after_command(
&mut self,
id: &str,
params: Value,
journal: bool,
nested_from: Option<usize>,
pre_selected: Option<&[LayerId]>,
before: &crate::actions_record::Before,
) {
// A layer-mask view ends when another layer becomes active (#196).
if let Some(st) = self.active_mut() {
crate::mask_view_cmds::fix(st);
@@ -1358,16 +1674,25 @@ impl Session {
if let Some(from) = nested_from.filter(|_| crate::actions_cmds::replayable(id)) {
self.journal.truncate(from);
}
if !crate::brush_cmds::coalesce_journal(self, id, &params) {
let params = match id {
_ if self.actions.recording.is_none() => params,
"layer.duplicate" => crate::artboard_cmds::recorded_duplicate(self, crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected)),
"layerComp.apply" => crate::comps_cmds::recorded_apply(self, params),
_ => crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected),
};
let params = crate::actions_cmds::recorded_params(self, id, params);
self.journal.push((id.to_string(), params));
let typed = crate::actions_record::typed_layer(self, id, &params);
let params = match id {
_ if self.actions.recording.is_none() => params,
"layer.duplicate" => crate::artboard_cmds::recorded_duplicate(self, crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected)),
"layerComp.apply" => crate::comps_cmds::recorded_apply(self, params),
"edit.checkSpelling" if crate::type_spell_cmds::records(&params) => crate::type_spell_cmds::recorded_step(&params),
_ => crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected),
};
let params = crate::actions_cmds::recorded_params(self, id, params);
let params = crate::actions_record::portable(self, id, params, before);
match crate::actions_record::fold_keystroke(self, id, &params, self.typed_layer) {
crate::actions_record::Keystroke::Folded => {}
crate::actions_record::Keystroke::None => {
if !crate::brush_cmds::coalesce_journal(self, id, &params) {
self.journal.push((id.to_string(), params));
}
}
}
self.typed_layer = typed;
}
/// Apply (or report the failure of) a job whose worker has delivered `r`.
@@ -1378,9 +1703,19 @@ impl Session {
let (outcome, error) = match r {
Err(EngineError::Cancelled) => (JobOutcome::Cancelled, None),
Err(e) => (JobOutcome::Failed(e.to_string()), Some(e)),
Ok(_) if job.ctx.cancelled() => (JobOutcome::Cancelled, None),
Ok(_) if job.ctx.cancelled() && job.command != "file.automate.batch" => (JobOutcome::Cancelled, None),
Ok(payload) => match self.apply_job(&job, apply, payload) {
Ok(v) => (JobOutcome::Done(v), None),
Ok(mut v) => {
// A batch may take its final cancellation snapshot before the worker
// finishes. A cancellation accepted during that interval still counts.
if job.command == "file.automate.batch"
&& job.ctx.cancelled()
&& let Some(result) = v.as_object_mut()
{
result.insert("cancelled".into(), Value::Bool(true));
}
(JobOutcome::Done(v), None)
}
Err(EngineError::Cancelled) => (JobOutcome::Cancelled, None),
Err(e) => (JobOutcome::Failed(e.to_string()), Some(e)),
},
@@ -1389,8 +1724,22 @@ impl Session {
}
fn apply_job(&mut self, job: &Running, apply: Option<Apply>, payload: Payload) -> Result<Value> {
let Running { label, doc, snapshot, coalesce, color_restrict, filter_mask_layer, layers, command, params, journal, recorded_by, pre_selected, .. } =
job;
let Running {
label,
doc,
snapshot,
coalesce,
color_restrict,
filter_mask_layer,
layers,
command,
params,
journal,
recorded_by,
pre_selected,
before,
..
} = job;
// A recording started after the job (#7289) keeps what it has so far, and the job's step
// goes into the journal past its start, so that recording doesn't get it.
let foreign = self.actions.recording.is_some() && *recorded_by != Some(self.actions.take);
@@ -1399,6 +1748,10 @@ impl Session {
}
let prev_active = self.active;
let target = match doc {
// A job past its commit has done its work outside the session (a save wrote its
// file); a closed document doesn't undo that, so its result still applies, to no
// document.
Some(d) if job.ctx.committed() && !self.documents().iter().any(|st| st.doc.id == *d) => None,
Some(d) => {
let i =
self.documents().iter().position(|st| st.doc.id == *d).ok_or_else(|| EngineError::Other(format!("`{label}`: the document was closed")))?;
@@ -1447,7 +1800,7 @@ impl Session {
// Restoring the viewed document first would attribute Fade and slice updates
// to that document instead, when the user switched tabs mid-job.
if r.is_ok() {
self.after_command(command, params.clone(), *journal, Some(nested_from), pre_selected.as_deref());
self.after_command(command, params.clone(), *journal, Some(nested_from), pre_selected.as_deref(), before);
if foreign && let Some((start, _)) = self.actions.recording.as_mut() {
*start = self.journal.len();
}
@@ -1481,6 +1834,9 @@ impl Session {
// and discarded results whose document was closed or changed.
self.live_commit = None;
let (state, result, error) = match &outcome {
JobOutcome::Done(v) if job.command == "file.automate.batch" && v.get("cancelled").and_then(Value::as_bool) == Some(true) => {
("cancelled", Some(v.clone()), None)
}
JobOutcome::Done(v) => ("done", Some(v.clone()), None),
JobOutcome::Failed(e) => ("failed", None, Some(e.clone())),
JobOutcome::Cancelled => ("cancelled", None, None),
@@ -1524,19 +1880,17 @@ pub fn specs() -> Vec<crate::commands::CommandSpec> {
params: r##"{"job":u64? (default: every running job)}"##,
enabled: |_| Ok(()),
run: |s, p| match p.get("job") {
None | Some(Value::Null) => {
let n = s.jobs.running.len();
s.cancel_all_jobs();
Ok(json!({"cancelled": n.saturating_sub(s.jobs.running.len())}))
}
None | Some(Value::Null) => Ok(s.cancel_all_jobs().to_json()),
Some(v) => {
let id = v.as_u64().ok_or_else(|| EngineError::BadParams { cmd: "jobs.cancel".into(), msg: "`job` must be a job id".into() })?;
if s.cancel_job(JobId(id)) {
Ok(json!({"cancelled": 1}))
} else if s.job(JobId(id)).is_some() {
Err(EngineError::Other("printing cannot be cancelled here; use the system print queue".into()))
} else {
Err(EngineError::Other(format!("no running job {id}")))
match s.try_cancel_job(JobId(id)) {
Ok(()) => Ok(json!({"cancelled": 1})),
// Not an error: the job is fine and will report how it ended.
Err(why @ (CancelRefused::Committing | CancelRefused::Finishing)) => {
Ok(CancelReport { cancelled: Vec::new(), refused: vec![(JobId(id), why)] }.to_json())
}
Err(CancelRefused::Printing) => Err(EngineError::Other("printing cannot be cancelled here; use the system print queue".into())),
Err(CancelRefused::NotRunning) => Err(EngineError::Other(format!("no running job {id}"))),
}
}
},
+346
View File
@@ -422,6 +422,216 @@ fn jobs_cancel_command_and_bad_params() {
assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 0);
}
/// A fake save of the active document to `target`, gated by the test: it waits for `go`, calls
/// [`JobCtx::commit`] and reports whether it got through, then waits for `finish` and replaces
/// the file. `done` disconnects once the worker has returned.
struct GatedSave {
id: JobId,
go: std::sync::mpsc::Sender<()>,
committed: std::sync::mpsc::Receiver<bool>,
finish: std::sync::mpsc::Sender<()>,
done: std::sync::mpsc::Receiver<()>,
}
impl GatedSave {
fn start(s: &mut Session, target: &std::path::Path) -> Self {
let (go, go_rx) = std::sync::mpsc::channel();
let (committed_tx, committed) = std::sync::mpsc::channel();
let (finish, finish_rx) = std::sync::mpsc::channel::<()>();
let (done_tx, done) = std::sync::mpsc::channel::<()>();
let target = target.to_path_buf();
let id = job(s
.start_job(
"file.save",
json!({"path": target}),
"Saving w.psd",
true,
move |ctx| {
let _done = done_tx;
go_rx.recv().ok();
let r = ctx.commit();
committed_tx.send(r.is_ok()).ok();
r?;
finish_rx.recv().ok();
std::fs::write(&target, b"new").map_err(|e| EngineError::Other(e.to_string()))?;
Ok(target.display().to_string())
},
|_, path: String| Ok(json!({"path": path})),
)
.unwrap());
Self { id, go, committed, finish, done }
}
/// Let the worker reach its commit; true if it committed.
fn commit(&self) -> bool {
self.go.send(()).unwrap();
self.committed.recv_timeout(Duration::from_secs(60)).unwrap()
}
/// Let a committed worker write the file.
fn finish(&self) {
self.finish.send(()).unwrap();
}
/// Block until the worker has returned.
fn join(&self) {
assert!(matches!(self.done.recv_timeout(Duration::from_secs(60)), Err(std::sync::mpsc::RecvTimeoutError::Disconnected)));
}
}
/// A fresh directory for a [`GatedSave`] test (on every platform, unlike the Unix-only
/// fixtures' `tmp_dir`).
fn save_dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("photocraft-engine-gated-save-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A job on the active document that runs until it is cancelled.
fn open_job(s: &mut Session) -> JobId {
job(s
.start_job(
"test.spin",
json!({}),
"Spinning",
true,
|ctx| loop {
ctx.check()?;
std::thread::sleep(Duration::from_millis(1));
},
|_, ()| Ok(json!(null)),
)
.unwrap())
}
fn index_of(s: &Session, doc: photocraft_doc::DocId) -> usize {
s.documents().iter().position(|st| st.doc.id == doc).unwrap()
}
#[test]
fn cancelling_a_save_before_it_commits_stops_it_without_touching_the_target() {
let dir = save_dir("cancel-before-commit");
let target = dir.join("w.psd");
std::fs::write(&target, b"old").unwrap();
let mut s = session(64, 64);
let save = GatedSave::start(&mut s, &target);
assert_eq!(s.execute("jobs.cancel", json!({"job": save.id.0})).unwrap(), json!({"cancelled": 1}));
assert_eq!(wait_event(&mut s, save.id).outcome, JobOutcome::Cancelled);
// The worker learns it lost at its commit point and writes nothing.
assert!(!save.commit(), "commit after a cancel must fail");
save.join();
assert_eq!(std::fs::read(&target).unwrap(), b"old", "target untouched");
assert!(s.job(save.id).is_none());
}
#[test]
fn cancelling_a_committed_save_is_refused_and_it_reports_its_real_outcome() {
let dir = save_dir("cancel-after-commit");
let target = dir.join("w.psd");
std::fs::write(&target, b"old").unwrap();
let mut s = session(64, 64);
let save = GatedSave::start(&mut s, &target);
assert!(save.commit());
// Every single-job entry point says it was not cancelled.
assert!(!s.cancel_job(save.id));
assert_eq!(s.try_cancel_job(save.id), Err(CancelRefused::Committing));
assert_eq!(
s.execute("jobs.cancel", json!({"job": save.id.0})).unwrap(),
json!({"cancelled": 0, "notCancelled": [{"job": save.id.0, "reason": "committing"}]})
);
assert!(s.job(save.id).is_some(), "still running, document still locked");
assert!(s.poll_jobs().is_empty(), "no Cancelled event");
save.finish();
let e = wait_event(&mut s, save.id);
assert_eq!(e.outcome, JobOutcome::Done(json!({"path": target.display().to_string()})));
save.join();
assert_eq!(std::fs::read(&target).unwrap(), b"new");
}
#[test]
fn cancel_all_cancels_open_jobs_and_reports_a_committed_save_it_left_running() {
let dir = save_dir("cancel-all-mixed");
let (committed_target, open_target) = (dir.join("a.psd"), dir.join("c.psd"));
std::fs::write(&committed_target, b"old").unwrap();
std::fs::write(&open_target, b"old").unwrap();
let mut s = session(64, 64);
let committed = GatedSave::start(&mut s, &committed_target);
assert!(committed.commit());
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let spinning = open_job(&mut s);
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let open = GatedSave::start(&mut s, &open_target);
let report = s.cancel_all_jobs();
assert_eq!(report.cancelled, vec![spinning, open.id]);
assert_eq!(report.refused, vec![(committed.id, CancelRefused::Committing)]);
assert_eq!(report.to_json(), json!({"cancelled": 2, "notCancelled": [{"job": committed.id.0, "reason": "committing"}]}));
// `jobs.cancel {}` again: only the committed save is left, and it is still not cancelled.
assert_eq!(s.execute("jobs.cancel", json!({})).unwrap(), json!({"cancelled": 0, "notCancelled": [{"job": committed.id.0, "reason": "committing"}]}));
// One poll reports both cancellations.
let ended: Vec<_> = s.poll_jobs().into_iter().map(|e| (e.id, e.outcome)).collect();
assert_eq!(ended, vec![(spinning, JobOutcome::Cancelled), (open.id, JobOutcome::Cancelled)]);
assert!(!open.commit());
open.join();
assert_eq!(std::fs::read(&open_target).unwrap(), b"old");
committed.finish();
assert!(matches!(wait_event(&mut s, committed.id).outcome, JobOutcome::Done(_)));
committed.join();
assert_eq!(std::fs::read(&committed_target).unwrap(), b"new");
assert!(s.jobs().is_empty());
}
#[test]
fn closing_documents_cancels_open_saves_but_a_committed_save_finishes() {
let dir = save_dir("cancel-on-close-mixed");
let (committed_target, open_target) = (dir.join("a.psd"), dir.join("c.psd"));
std::fs::write(&committed_target, b"old").unwrap();
std::fs::write(&open_target, b"old").unwrap();
let mut s = session(64, 64);
let a = s.active().unwrap().doc.id;
let committed = GatedSave::start(&mut s, &committed_target);
assert!(committed.commit());
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let b = s.active().unwrap().doc.id;
let spinning = open_job(&mut s);
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let c = s.active().unwrap().doc.id;
let open = GatedSave::start(&mut s, &open_target);
assert_eq!(s.cancel_jobs_on(a).refused, vec![(committed.id, CancelRefused::Committing)]);
s.close(index_of(&s, a));
s.close(index_of(&s, c));
assert_eq!(wait_event(&mut s, open.id).outcome, JobOutcome::Cancelled);
assert!(!open.commit());
open.join();
assert_eq!(std::fs::read(&open_target).unwrap(), b"old", "the open save's target is untouched");
// The other document's job is not affected.
assert!(s.job(spinning).is_some());
// The committed save outlives its document and reports what it did, not "document closed".
committed.finish();
assert_eq!(wait_event(&mut s, committed.id).outcome, JobOutcome::Done(json!({"path": committed_target.display().to_string()})));
committed.join();
assert_eq!(std::fs::read(&committed_target).unwrap(), b"new");
assert!(s.cancel_job(spinning));
assert_eq!(s.documents().iter().map(|st| st.doc.id).collect::<Vec<_>>(), vec![b]);
}
#[test]
fn commit_and_cancel_race_with_exactly_one_winner() {
for _ in 0..200 {
let ctx = JobCtx::default();
let other = ctx.clone();
let t = std::thread::spawn(move || other.commit().is_ok());
let cancelled = ctx.try_cancel();
let committed = t.join().unwrap();
assert_ne!(cancelled, committed, "exactly one of commit and cancel wins");
assert_eq!(ctx.cancelled(), cancelled);
assert_eq!(ctx.committed(), committed);
}
}
#[test]
fn non_job_commands_still_finish_inline_with_start() {
let mut s = session(16, 16);
@@ -543,6 +753,142 @@ fn open_runs_as_a_job_and_cancel_adds_nothing() {
assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_)));
}
#[test]
fn cancelled_batch_job_returns_its_partial_result() {
let mut s = Session::new();
let id = job(s
.start_job(
"file.automate.batch",
json!({}),
"Batch",
false,
|ctx| {
while !ctx.cancelled() {
std::thread::sleep(Duration::from_millis(2));
}
Ok(json!({"files": ["done.png"], "skipped": ["later.png"], "cancelled": true}))
},
|_, result| Ok(result),
)
.unwrap());
assert!(s.cancel_job(id));
let event = wait_event(&mut s, id);
let JobOutcome::Done(result) = &event.outcome else { panic!("batch result was discarded: {:?}", event.outcome) };
assert_eq!(result["files"], json!(["done.png"]));
assert_eq!(result["skipped"], json!(["later.png"]));
assert_eq!(result["cancelled"], true);
let info = s.jobs_with_recent().into_iter().find(|info| info.id == id).unwrap();
assert_eq!(info.state, "cancelled");
assert_eq!(info.result.unwrap()["files"], json!(["done.png"]));
assert!(matches!(s.wait_job(id), Err(EngineError::Cancelled)));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn programmatic_cancel_wakes_a_batch_waiting_for_the_desktop_decision() {
let mut s = Session::new();
let id = job(s
.start_job(
"file.automate.batch",
json!({}),
"Batch",
false,
|ctx| {
while !ctx.batch_take_interrupt() {
ctx.check()?;
std::thread::yield_now();
}
let continued = ctx.batch_wait_for_decision();
Ok(json!({"continued": continued, "files": [], "cancelled": ctx.cancelled()}))
},
|_, result| Ok(result),
)
.unwrap());
assert!(s.request_batch_interrupt_job(id));
let deadline = Instant::now() + Duration::from_secs(10);
while !s.batch_interrupt_waiting(id) && Instant::now() < deadline {
std::thread::yield_now();
}
assert!(s.batch_interrupt_waiting(id));
assert!(s.cancel_job(id), "jobs.cancel remains direct and wakes the worker");
let JobOutcome::Done(result) = wait_event(&mut s, id).outcome else { panic!("batch partial result was lost") };
assert_eq!(result["continued"], false);
assert_eq!(result["cancelled"], true);
}
#[test]
fn accepted_batch_cancellation_after_result_snapshot_is_reported() {
let mut s = Session::new();
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (resume_tx, resume_rx) = std::sync::mpsc::channel();
let id = job(s
.start_job(
"file.automate.batch",
json!({}),
"Batch",
false,
move |ctx| {
let cancelled = ctx.cancelled();
ready_tx.send(()).unwrap();
resume_rx.recv().unwrap();
Ok(json!({"files": ["done.png"], "cancelled": cancelled}))
},
|_, result| Ok(result),
)
.unwrap());
ready_rx.recv_timeout(Duration::from_secs(10)).unwrap();
assert!(s.cancel_job(id));
resume_tx.send(()).unwrap();
let event = wait_event(&mut s, id);
let JobOutcome::Done(result) = event.outcome else { panic!("batch result was discarded") };
assert_eq!(result["cancelled"], true);
assert_eq!(result["files"], json!(["done.png"]));
let info = s.jobs_with_recent().into_iter().find(|info| info.id == id).unwrap();
assert_eq!(info.state, "cancelled");
}
#[test]
fn cancel_all_counts_an_accepted_batch_cancellation() {
let mut s = Session::new();
let id = job(s
.start_job(
"file.automate.batch",
json!({}),
"Batch",
false,
|ctx| {
while !ctx.cancelled() {
std::thread::sleep(Duration::from_millis(2));
}
Ok(json!({"files": [], "cancelled": true}))
},
|_, result| Ok(result),
)
.unwrap());
assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 1);
assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 0, "a repeated request is not a new cancellation");
assert_eq!(s.execute("jobs.cancel", json!({"job": id.0})).unwrap()["cancelled"], 0, "a repeated batch request is not a print error");
assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Done(_)));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_finished_batch_worker_rejects_late_cancellation() {
let mut s = Session::new();
let id = job(s
.start_job("file.automate.batch", json!({}), "Batch", false, |_| Ok(json!({"files": ["done.png"], "cancelled": false})), |_, result| Ok(result))
.unwrap());
let started = Instant::now();
while !s.jobs.running.iter().any(|running| running.id == id && running.handle.as_ref().is_some_and(|handle| handle.is_finished())) {
assert!(started.elapsed() < Duration::from_secs(10), "batch worker did not finish");
std::thread::sleep(Duration::from_millis(2));
}
assert!(!s.cancel_job(id), "a result already sent cannot be cancelled");
let JobOutcome::Done(result) = wait_event(&mut s, id).outcome else { panic!("completed batch result was lost") };
assert_eq!(result["files"], json!(["done.png"]));
assert_eq!(result["cancelled"], false);
}
/// A flat scanline EXR opened from a path goes through the band decoder in the open job and
/// arrives as the same 32-bit document the whole-file decoder makes; cancelling adds nothing.
#[cfg(any(unix, windows))]
+6
View File
@@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod actions_record;
pub mod adjust_params;
mod allocation;
pub mod build_info;
@@ -200,6 +201,8 @@ pub struct Session {
pub tools: ToolState,
/// Log of executed commands (for action recording and debugging).
pub journal: Vec<(String, Value)>,
/// Text layer changed by the last journal entry, for folding Type tool keystrokes.
typed_layer: Option<(photocraft_doc::DocId, LayerId)>,
/// Coalescing key of the command being executed.
coalesce_request: Option<String>,
/// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop).
@@ -270,6 +273,9 @@ pub struct Session {
pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>,
/// The stamps being taken on a worker thread ([`Session::start_link_scan`]).
pub link_scan: Option<link_watch::LinkScan>,
/// Each open document's linked smart objects' files that were not there when last looked at
/// ([`Session::missing_links`]).
link_missing: std::collections::HashSet<(u64, String)>,
/// Select Subject's last result, reused while the pixels are unchanged (#2865).
subject_cache: smartselect_cmds::SubjectCache,
/// Object Selection's Object Finder results (#4087), shared by the hover preview and the
+156 -1
View File
@@ -68,6 +68,9 @@ pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let (doc, files) = (st.doc.id.0, resolved_files(st));
let now = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect();
// Drop a look still running on a worker thread. It stamped the files before this one, and
// applying it later would replace this look's result.
s.link_scan = None;
s.apply_link_stamps(doc, now)
}
@@ -93,8 +96,10 @@ impl Session {
}
let Some(st) = self.active() else { return };
let (doc, files) = (st.doc.id.0, resolved_files(st));
// Nothing to look at: no links, or none that is followed.
// Nothing to look at: no links, or none that is followed. None of them is listed as
// missing either, so a link added back isn't shown missing before its first look.
if files.iter().all(|(_, file, _)| file.is_none()) {
self.link_missing.retain(|(d, _)| *d != doc);
return;
}
let slot: LinkScan = Arc::default();
@@ -108,10 +113,41 @@ impl Session {
}
}
/// The active document's linked files that were not there at the last look (the Layers
/// panel's Missing filter). A path that is not followed ([`crate::doc_path`]) is never looked
/// at, so it is not listed.
pub fn missing_links(&self) -> HashSet<String> {
let Some(doc) = self.active().map(|d| d.doc.id.0) else { return HashSet::new() };
self.link_missing.iter().filter(|(d, _)| *d == doc).map(|(_, p)| p.clone()).collect()
}
/// Remember which of document `doc`'s files in `now` were not there, and forget the files of
/// closed documents. The web app has no files to look at, so it lists none.
fn note_missing_links(&mut self, doc: u64, now: &[(String, Vec<u64>, Option<Stamp>)]) {
let open: HashSet<u64> = self.documents().iter().map(|d| d.doc.id.0).collect();
self.link_missing.retain(|(d, _)| *d != doc && open.contains(d));
if cfg!(target_arch = "wasm32") {
return;
}
let document = self.active().and_then(|st| st.path.clone());
for (path, _, stamp) in now {
if stamp.is_none() && crate::doc_path::resolve_in(path, document.as_deref().map(std::path::Path::new)).is_ok() {
self.link_missing.insert((doc, path.clone()));
}
}
}
/// Apply a finished look (`None` while there is none, or it is still running).
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
let (doc, stamps) = self.link_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?;
self.link_scan = None;
// A relink or embed while the look ran: it stamped files the document no longer links in
// that way, so drop it; the next look takes the current links.
if let Some(st) = self.active().filter(|st| st.doc.id.0 == doc)
&& !linked_files(&st.doc).iter().map(|(p, ids)| (p, ids)).eq(stamps.iter().map(|(p, ids, _)| (p, ids)))
{
return Some(Ok(json!({"updated": []})));
}
Some(self.apply_link_stamps(doc, stamps))
}
@@ -122,6 +158,7 @@ impl Session {
if self.active().map(|d| d.doc.id.0) != Some(doc) {
return Ok(json!({"updated": []}));
}
self.note_missing_links(doc, &now);
// A background job holds the document: refreshing now would replace the snapshot it works
// on and its result would be discarded. The stamps stay as they were, so the change is
// seen again by the first look after the job ends.
@@ -282,6 +319,124 @@ mod tests {
assert!(is(centre(&s), BLUE));
}
#[test]
fn a_look_lists_the_linked_files_that_are_not_there() {
let d = dir("missing");
let path = d.join("art.png");
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
let (mut s, _) = placed(&path);
let stored = super::linked_files(&s.active().unwrap().doc)[0].0.clone();
assert!(s.missing_links().is_empty(), "nothing looked at yet");
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
assert!(s.missing_links().is_empty());
std::fs::remove_file(&path).unwrap();
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
assert_eq!(s.missing_links(), [stored.clone()].into());
// Only the document's own files: another one in front lists none.
s.execute("file.new", json!({"width": 10, "height": 10})).unwrap();
assert!(s.missing_links().is_empty());
s.set_active(0);
assert_eq!(s.missing_links(), [stored].into());
std::fs::write(&path, png(10, 10, "#0000ff")).unwrap();
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
assert!(s.missing_links().is_empty(), "the file is back");
}
#[test]
fn a_reopened_document_lists_no_file_missing_before_it_closed() {
let d = dir("reopen");
let (path, saved) = (d.join("art.png"), d.join("doc.pcraft"));
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
let (mut s, _) = placed(&path);
s.execute("file.saveACopy", json!({"path": saved.to_string_lossy()})).unwrap();
let id = s.active().unwrap().doc.id;
std::fs::remove_file(&path).unwrap();
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
assert_eq!(s.missing_links().len(), 1);
s.close(0);
std::fs::write(&path, png(10, 10, "#0000ff")).unwrap();
let bytes = std::fs::read(&saved).unwrap();
crate::file_cmds::open_bytes_as(&mut s, "doc.pcraft", &bytes, None, Some(saved.to_string_lossy().into_owned())).unwrap();
assert_eq!(s.active().unwrap().doc.id, id, "the saved document keeps its id");
assert!(s.missing_links().is_empty(), "nothing looked at since it opened");
}
#[test]
fn a_look_started_before_update_changed_does_not_replace_its_result() {
let d = dir("stale");
let path = d.join("art.png");
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
let (mut s, _) = placed(&path);
s.start_link_scan();
let t0 = std::time::Instant::now();
while s.link_scan.as_ref().is_some_and(|l| l.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_none()) {
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
std::thread::sleep(std::time::Duration::from_millis(5));
}
std::fs::remove_file(&path).unwrap();
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
assert_eq!(s.missing_links().len(), 1);
let _ = s.poll_link_scan();
assert_eq!(s.missing_links().len(), 1, "the earlier look found the file, but it is gone");
}
#[test]
fn a_scan_finished_after_close_cannot_change_a_reopened_document() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("art.png");
let saved = directory.path().join("doc.pcraft");
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
let (mut s, layer) = placed(&path);
s.execute("file.saveACopy", json!({"path": saved.to_string_lossy()})).unwrap();
let id = s.active().unwrap().doc.id;
let stored = super::linked_files(&s.active().unwrap().doc)[0].0.clone();
let scan = std::sync::Arc::new(std::sync::Mutex::new(None));
s.link_scan = Some(scan.clone());
s.close(0);
let bytes = std::fs::read(&saved).unwrap();
crate::file_cmds::open_bytes_as(&mut s, "doc.pcraft", &bytes, None, Some(saved.to_string_lossy().into_owned())).unwrap();
assert_eq!(s.active().unwrap().doc.id, id);
*scan.lock().unwrap() = Some((id.0, vec![(stored, vec![layer], None)]));
assert!(s.poll_link_scan().is_none());
assert!(s.missing_links().is_empty());
}
#[test]
fn a_document_without_links_lists_none_missing() {
let d = dir("unlinked");
let path = d.join("art.png");
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
let (mut s, id) = placed(&path);
std::fs::remove_file(&path).unwrap();
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
assert_eq!(s.missing_links().len(), 1);
s.execute("layer.delete", json!({"layer": id})).unwrap();
s.start_link_scan();
assert!(s.missing_links().is_empty(), "the last linked layer is gone");
}
#[test]
fn a_look_at_links_that_changed_meanwhile_is_dropped() {
let d = dir("relinked");
let path = d.join("art.png");
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
let (mut s, layer) = placed(&path);
let doc = s.active().unwrap().doc.id.0;
let stored = super::linked_files(&s.active().unwrap().doc)[0].0.clone();
let inject = |s: &mut Session, file: &str| {
let scan = std::sync::Arc::new(std::sync::Mutex::new(Some((doc, vec![(file.to_string(), vec![layer], None)]))));
s.link_scan = Some(scan);
};
// Taken before a relink: its file is no longer linked.
inject(&mut s, &d.join("before-relink.png").to_string_lossy());
assert_eq!(s.poll_link_scan().unwrap().unwrap(), json!({"updated": []}));
assert!(s.missing_links().is_empty(), "a file no longer linked is not listed");
// Taken of the current links: applied.
inject(&mut s, &stored);
s.poll_link_scan().unwrap().unwrap();
assert_eq!(s.missing_links(), [stored].into());
}
#[test]
fn a_background_look_updates_changed_files() {
let d = dir("scan");
+5
View File
@@ -83,6 +83,11 @@ fn tile_rects(r: Rect) -> impl Iterator<Item = Rect> {
const TOO_SPARSE: &str = "the adjustment would have to fill too large an area around the pixels the layer holds";
/// Refuse adjustments that would materialize too much previously unstored pixel data.
pub(crate) fn check_adjustment_tile_budget(area: Rect, stored: usize, format: &photocraft_color::PixelFormat) -> Result<(), String> {
if unstored_bytes(area, stored as u64, format) > MAX_UNSTORED_BYTES { Err(TOO_SPARSE.into()) } else { Ok(()) }
}
/// Apply an adjustment destructively to a surface, weighted by an optional selection.
/// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the
/// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale).
+9
View File
@@ -122,6 +122,13 @@ impl Session {
self.docs.iter().position(|st| st.doc.id == *id).ok_or(EngineError::NoDocument)
}
/// Relative opening-order distance between two open documents, for portable action steps.
pub(crate) fn document_offset_between(&self, from: photocraft_doc::DocId, to: photocraft_doc::DocId) -> Option<i64> {
let from = self.open_document_order.iter().position(|&id| id == from)?;
let to = self.open_document_order.iter().position(|&id| id == to)?;
i64::try_from(to).ok()?.checked_sub(i64::try_from(from).ok()?)
}
/// Add a document (from File → New, an import, etc.) and make it active.
/// Preserve its identity unless another open document already owns it.
/// Identity-dependent callers must read the admitted document at the returned index.
@@ -166,6 +173,8 @@ impl Session {
self.cancel_jobs_on(id);
self.subject_cache.forget_doc(id);
self.object_finder.forget_doc(id);
self.link_missing.retain(|(d, _)| *d != id.0);
self.link_scan = None;
}
let d = self.docs.remove(index);
self.open_document_order.retain(|&id| id != d.doc.id);
+30 -10
View File
@@ -99,7 +99,7 @@ impl Session {
if let Some(why) = color_channels::refusal(self, id, &run_params) {
return Err(EngineError::Disabled(id.into(), why));
}
if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
if let Err(why) = self.explicit_layer_enabled(spec, params)?.unwrap_or_else(|| self.precondition(spec, &run_params)) {
return Err(EngineError::Disabled(id.to_string(), why));
}
if let Some(why) = self.job_conflict(id, spec.journal) {
@@ -160,6 +160,12 @@ impl Session {
if spec.id == "layer.smartFilter.deleteFilterMask" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) {
return smart_cmds::smart_layer_active(self);
}
// Recorded Unlink can be repeated on a single, already-unlinked layer.
// Keep the menu toggle's two-layer requirement when no state is supplied.
if spec.id == "layer.linkLayers" && params.get("linked").and_then(Value::as_bool) == Some(false) {
let d = self.active().ok_or_else(|| "no document open".to_string())?;
return if d.selected_layers().is_empty() { Err("no layer selected".into()) } else { Ok(()) };
}
channel_cmds::mask_target_enabled(self, spec.id, params).unwrap_or_else(|| (spec.enabled)(self))
}
@@ -177,17 +183,31 @@ impl Session {
/// than on the active one, which stays active (#466). A script, action or agent can then
/// Replace Contents, Rasterize or Merge Down a layer without selecting it first. `None` for
/// other commands, or without such a layer.
pub(crate) fn explicit_layer_enabled(&mut self, spec: &commands::CommandSpec, params: &Value) -> Option<std::result::Result<(), String>> {
pub(crate) fn explicit_layer_enabled(&mut self, spec: &commands::CommandSpec, params: &Value) -> Result<Option<std::result::Result<(), String>>> {
if !spec.params.contains("\"layer\":id") {
return None;
return Ok(None);
}
let layer = params.get("layer")?;
if !(layer.is_number() || layer.is_object()) {
return None;
// An ATN group-end index is a valid no-op only for Select. Let that command validate
// its other parameters and preserve the current selection.
if spec.id == "layer.select"
&& let Some(selector @ Value::Object(fields)) = params.get("layer")
&& (fields.contains_key("index") || fields.contains_key("ordinal"))
&& commands::recorded_layer(self, selector)?.is_none()
{
return Ok(None);
}
let target = match commands::explicit_layer(self, params) {
Ok(Some(target)) => target,
Ok(None) => return Ok(None),
// Read-only queries keep their own parameter-error contract; recorded commands
// must identify a missing named receiver before checking the active layer.
Err(_) if !spec.journal => return Ok(None),
Err(error) => return Err(error),
};
let Some(d) = self.active_mut() else { return Ok(None) };
if d.doc.layer(target).is_none() {
return Ok(None);
}
let target = commands::explicit_layer(self, params).ok()??;
let d = self.active_mut()?;
d.doc.layer(target)?;
let active = d.active_layer.replace(target);
// The checks see the resolved id: a relative ordinal (`Frwr`, `Bckw`) would otherwise
// resolve again from the swapped-in active layer and land one layer further.
@@ -199,7 +219,7 @@ impl Session {
if let Some(d) = self.active_mut() {
d.active_layer = active;
}
Some(r)
Ok(Some(r))
}
/// Apply an undoable edit to the active document.
@@ -0,0 +1,699 @@
//! Recorded direct Gradient Map and Replace Color forms from #10603 / #10607.
use photocraft_engine::{Session, atn_cmds};
use photocraft_psd::{
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
descriptor::{Descriptor, Id, UnicodeString, Value as D},
};
use serde_json::json;
fn enumeration(kind: &str, value: &str) -> D {
D::Enumerated { type_id: Id::new(kind), value: Id::new(value) }
}
fn color(red: f64, green: f64, blue: f64) -> D {
D::Descriptor(Descriptor::new("RGBC").with("Rd ", D::Double(red)).with("Grn ", D::Double(green)).with("Bl ", D::Double(blue)))
}
fn gradient() -> Descriptor {
let stop = |location, rgb| {
D::Descriptor(
Descriptor::new("Clrt").with("Clr ", rgb).with("Type", enumeration("Clry", "UsrS")).with("Lctn", D::Integer(location)).with("Mdpn", D::Integer(50)),
)
};
let opacity = |location| {
D::Descriptor(
Descriptor::new("TrnS").with("Opct", D::UnitFloat { unit: *b"#Prc", value: 100.0 }).with("Lctn", D::Integer(location)).with("Mdpn", D::Integer(50)),
)
};
Descriptor::new("null").with("gradientsInterpolationMethod", enumeration("gradientInterpolationMethodType", "Gcls")).with(
"Grad",
D::Descriptor(
Descriptor::new("Grdn")
.with("Nm ", D::Text(UnicodeString::new_nul("Recorded gradient")))
.with("GrdF", enumeration("GrdF", "CstS"))
.with("Intr", D::Double(4096.0))
.with("Clrs", D::List(vec![stop(0, color(0.0, 0.0, 255.0)), stop(4096, color(255.0, 0.0, 0.0))]))
.with("Trns", D::List(vec![opacity(0), opacity(4096)])),
),
)
}
fn replace() -> Descriptor {
let lab = || D::Descriptor(Descriptor::new("LbCl").with("Lmnc", D::Double(40.0)).with("A ", D::Double(30.0)).with("B ", D::Double(40.0)));
Descriptor::new("null")
.with("Fzns", D::Integer(40))
.with("Mnm ", lab())
.with("Mxm ", lab())
.with("H ", D::Integer(30))
.with("Strt", D::Integer(0))
.with("Lght", D::Integer(0))
.with("colorModel", D::Integer(0))
}
fn bytes(event: [u8; 4], descriptor: Descriptor) -> Vec<u8> {
atn::write(&AtnFile {
name: UnicodeString::new_nul("Direct adjustments"),
expanded: false,
actions: vec![AtnAction {
name: UnicodeString::new_nul("Recorded"),
items: vec![AtnItem { event: AtnEvent::Code(event), descriptor: Some(descriptor), ..Default::default() }],
..Default::default()
}],
warnings: vec![],
..Default::default()
})
.unwrap()
}
fn load(event: [u8; 4], descriptor: Descriptor, command: &str) -> Session {
let mut s = Session::new();
let bytes = bytes(event, descriptor);
let report = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap();
assert_eq!(report["unsupported"], json!([]), "recorded {} must map to {command}", String::from_utf8_lossy(&event));
assert_eq!(s.actions.list[0].steps[0].0, command);
s
}
#[test]
fn recorded_grmp_imports_as_direct_gradient_map() {
let s = load(*b"GrMp", gradient(), "image.adjustments.gradientMap");
assert_eq!(s.actions.list[0].steps[0].1["stops"], json!([[0, [0, 0, 1]], [1, [1, 0, 0]]]));
}
#[test]
fn recorded_rplc_imports_as_direct_replace_color() {
let s = load(*b"RplC", replace(), "image.adjustments.replaceColor");
assert_eq!(s.actions.list[0].steps[0].1["hue"], json!(30));
}
fn saved(s: &Session) -> AtnItem {
let refs = s.actions.list.iter().collect::<Vec<_>>();
let (bytes, skipped) = atn_cmds::export("Direct adjustments", &refs, &[], false).unwrap();
assert!(skipped.is_empty());
atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0)
}
/// Edit a known nested fixture object without replacing its surrounding descriptor.
fn object<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut Descriptor {
match &mut d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 {
D::Descriptor(d) => d,
_ => panic!("not an object"),
}
}
/// Expose a fixture list so negative cases change only the intended wire setting.
fn list<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut Vec<D> {
match &mut d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 {
D::List(l) => l,
_ => panic!("not a list"),
}
}
/// Locate the first color stop for fractional-color and unsupported-setting controls.
fn first_stop(d: &mut Descriptor) -> &mut Descriptor {
match &mut list(object(d, "Grad"), "Clrs")[0] {
D::Descriptor(d) => d,
_ => panic!("not a stop"),
}
}
/// Saving retains the measured wire forms, including source colors that hex would quantize.
#[test]
fn direct_forms_save_exact_descriptors_including_fractional_rgb() {
let mut fractional = gradient();
object(first_stop(&mut fractional), "Clr ").items[0].1 = D::Double(73.1234567);
let mut defaults = gradient();
object(first_stop(&mut defaults), "Clr ").items[2].1 = D::Double(0.0);
let g = object(&mut defaults, "Grad");
let D::Descriptor(stop) = &mut list(g, "Clrs")[1] else { panic!("stop") };
object(stop, "Clr ").items[1].1 = D::Double(255.0);
object(stop, "Clr ").items[2].1 = D::Double(255.0);
let mut implicit_classic = gradient();
implicit_classic.items.remove(0);
for descriptor in [defaults, gradient(), fractional, implicit_classic] {
let s = load(*b"GrMp", descriptor.clone(), "image.adjustments.gradientMap");
let item = saved(&s);
assert_eq!(item.event, AtnEvent::Code(*b"GrMp"));
assert_eq!(item.descriptor, Some(descriptor));
}
let mut changed = replace();
object(&mut changed, "Mnm ").items[0].1 = D::Double(20.125);
changed.items[0].1 = D::Integer(130);
changed.items[3].1 = D::Integer(-120);
changed.items[4].1 = D::Integer(45);
changed.items[5].1 = D::Integer(-20);
for descriptor in [replace(), changed] {
let s = load(*b"RplC", descriptor.clone(), "image.adjustments.replaceColor");
let item = saved(&s);
assert_eq!(item.event, AtnEvent::Code(*b"RplC"));
assert_eq!(item.descriptor, Some(descriptor));
}
}
fn fixture(mode: &str, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":1,"mode":mode,"depth":depth})).unwrap();
s.edit("original pixels", |doc, active| {
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let format = surface.format();
let data = [[0.0, 0.0, 0.0, 1.0], [0.3, 0.8, 0.2, 1.0], [0.7, 0.2, 0.6, 0.4], [1.0, 1.0, 1.0, 1.0]]
.into_iter()
.flat_map(|p| photocraft_raster::from_rgba(&format, p))
.collect::<Vec<_>>();
surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 4, 1), &data);
Ok(())
})
.unwrap();
s
}
fn raw(s: &Session) -> Vec<f32> {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds())
}
fn play(s: &mut Session) {
let report = s.execute("actions.play", json!({"action":"Recorded","set":"Direct adjustments"})).unwrap();
assert_eq!(report["ran"], 1, "{report}");
assert!(report.get("failed").is_none(), "{report}");
}
/// Execute fractional stops at every depth while preserving unselected samples and Undo.
#[test]
fn fractional_gradient_changes_pixels_and_preserves_selection_and_undo() {
let mut descriptor = gradient();
object(first_stop(&mut descriptor), "Clr ").items[0].1 = D::Double(73.125);
for depth in [8, 16, 32] {
let imported = atn_cmds::import(&bytes(*b"GrMp", descriptor.clone())).unwrap();
assert!(imported.unsupported.is_empty());
let mut direct = fixture("rgb", depth);
direct.execute("select.rect", json!({"x":0,"y":0,"width":3,"height":1})).unwrap();
let original = raw(&direct);
direct.execute("image.adjustments.gradientMap", imported.actions[0].steps[0].1.clone()).unwrap();
let expected = raw(&direct);
assert_ne!(expected, original);
assert_eq!(&expected[12..], &original[12..], "outside the selection");
let mut replay = fixture("rgb", depth);
replay.execute("select.rect", json!({"x":0,"y":0,"width":3,"height":1})).unwrap();
replay.actions.list = imported.actions;
play(&mut replay);
assert_eq!(raw(&replay), expected);
assert!(replay.undo());
assert_eq!(raw(&replay), original);
if depth == 32 {
assert!((expected[0] - 73.125 / 255.0).abs() < 1e-6, "fractional color must not be rounded to hex");
}
}
}
fn set_lab_pixels(s: &mut Session) {
s.edit("known Lab pixels", |doc, active| {
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
surface.write_region(
photocraft_doc::Rect::from_xywh(0, 0, 4, 1),
&[
0.4,
128.0 / 255.0,
128.0 / 255.0,
1.0,
0.5,
128.0 / 255.0,
128.0 / 255.0,
1.0,
0.6,
128.0 / 255.0,
128.0 / 255.0,
1.0,
0.95,
128.0 / 255.0,
128.0 / 255.0,
1.0,
],
);
Ok(())
})
.unwrap();
}
/// Different box widths must select different pixels even when their midpoint is identical.
#[test]
fn lab_boxes_with_the_same_midpoint_have_different_real_effects() {
// Independent mask anchors: at zero fuzziness a neutral Lab50 pixel is
// inside both boxes, Lab40/60 only the wide one, and Lab95 is outside both.
for depth in [8, 16, 32] {
let mut results = Vec::new();
for (minimum, maximum) in [(49, 51), (39, 61)] {
let mut s = fixture("lab", depth);
set_lab_pixels(&mut s);
let before = raw(&s);
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[minimum,0,0],"labMaximum":[maximum,0,0],"fuzziness":0,"lightness":100})).unwrap();
let after = raw(&s);
assert!(after[4] > 0.99, "Lab50 must become white: {after:?}");
assert!((after[12] - before[12]).abs() < 0.005, "Lab95 stays outside");
if minimum == 49 {
assert!((after[0] - before[0]).abs() < 0.005);
} else {
assert!(after[0] > 0.99 && after[8] > 0.99, "wide box includes Lab40/60");
}
results.push(after);
assert!(s.undo());
assert_eq!(raw(&s), before);
}
assert_ne!(results[0], results[1], "collapsing a box to its midpoint is incorrect");
}
}
/// Conflicting or invalid Lab settings leave both pixels and history untouched.
#[test]
fn invalid_lab_inputs_are_rejected_before_any_edit() {
let mut s = fixture("rgb", 32);
for patch in [
json!({"labMaximum":null}),
json!({"labMinimum":[0,0]}),
json!({"labMinimum":[1e100,0,0]}),
json!({"labMinimum":[50.0000001,0,0],"labMaximum":[50,0,0]}),
json!({"color":"#ff0000"}),
json!({"points":[]}),
json!({"localized":false}),
json!({"target":"mask"}),
json!({"hue":"30"}),
json!({"layer":999999}),
] {
let mut p = json!({"labMinimum":[40,0,0],"labMaximum":[60,0,0],"lightness":100});
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
assert!(s.execute("image.adjustments.replaceColor", p.clone()).is_err(), "{p}");
assert_eq!(s.active().unwrap().doc, before, "{p}");
assert_eq!(s.active().unwrap().history.past_len(), history, "{p}");
}
}
fn kept(event: [u8; 4], d: Descriptor) {
let imported = atn_cmds::import(&bytes(event, d.clone())).unwrap();
assert_eq!(imported.unsupported.len(), 1, "{d:?}");
let mut s = Session::new();
s.actions.list = imported.actions;
assert_eq!(saved(&s).descriptor, Some(d));
}
/// Unsupported interpolation, transparency and wire types survive as complete kept steps.
#[test]
fn unsupported_gradient_settings_and_wire_forms_remain_kept() {
let mut midpoint = gradient();
first_stop(&mut midpoint).items[3].1 = D::Integer(30);
let mut transparency = gradient();
let D::Descriptor(opacity) = &mut list(object(&mut transparency, "Grad"), "Trns")[0] else { panic!("opacity") };
opacity.items[0].1 = D::UnitFloat { unit: *b"#Prc", value: 50.0 };
let mut interpolation = gradient();
interpolation.items[0].1 = enumeration("gradientInterpolationMethodType", "Lnr ");
let mut order = gradient();
list(object(&mut order, "Grad"), "Clrs").reverse();
let mut duplicate = gradient();
duplicate.items.push(duplicate.items[0].clone());
let mut unknown = gradient();
unknown.items.push((Id::new("extra"), D::Boolean(true)));
for d in [midpoint, transparency, interpolation, order, duplicate, unknown] {
kept(*b"GrMp", d);
}
let mut f = atn::parse(&bytes(*b"GrMp", gradient())).unwrap();
f.actions[0].items[0].event = AtnEvent::name("GrMp");
assert_eq!(atn_cmds::import(&atn::write(&f).unwrap()).unwrap().unsupported.len(), 1);
}
/// Invalid recorded boxes stay preserved, and edited or unrepresentable native settings cannot save lossily.
#[test]
fn invalid_replace_descriptors_and_lossy_exports_are_refused() {
let mut model = replace();
model.items[6].1 = D::Integer(1);
let mut inverted = replace();
object(&mut inverted, "Mnm ").items[0].1 = D::Double(80.0);
let mut missing = replace();
missing.items.remove(0);
let mut unknown = replace();
unknown.items.push((Id::new("extra"), D::Boolean(true)));
let mut duplicate = replace();
duplicate.items.push(duplicate.items[0].clone());
for d in [model, inverted, missing, unknown, duplicate] {
kept(*b"RplC", d);
}
let mut s = load(*b"GrMp", gradient(), "image.adjustments.gradientMap");
s.actions.list[0].steps[0].1["stops"][0][1][0] = json!(0.2);
assert!(atn_cmds::export("Direct adjustments", &[&s.actions.list[0]], &[], false).is_err(), "stale color metadata must not fall through to GdMp");
for p in [json!({"color":"#ff0000"}), json!({"labMinimum":[40,0,0],"labMaximum":[60,0,0],"hue":0.5})] {
let a = photocraft_engine::actions_cmds::Action::new("Native", vec![("image.adjustments.replaceColor".into(), p)]);
assert!(atn_cmds::export("Direct adjustments", &[&a], &[], false).is_err());
}
}
/// Playback respects bounded and implicitly selected masks at every depth, including an offset selection and Undo.
#[test]
fn recorded_replace_color_clamps_shifts_and_plays_the_lab_box_at_every_depth() {
for (depth, inverse) in [8, 16, 32].into_iter().flat_map(|depth| [(depth, false), (depth, true)]) {
let mut d = replace();
for key in ["Mnm ", "Mxm "] {
let o = object(&mut d, key);
o.items[0].1 = D::Double(if key == "Mnm " { 39.0 } else { 61.0 });
o.items[1].1 = D::Double(0.0);
o.items[2].1 = D::Double(0.0);
}
d.items[0].1 = D::Integer(-10);
d.items[3].1 = D::Integer(0);
d.items[5].1 = D::Integer(250);
let imported = atn_cmds::import(&bytes(*b"RplC", d)).unwrap();
assert!(imported.unsupported.is_empty());
assert_eq!(imported.actions[0].steps[0].1["fuzziness"], 0);
assert_eq!(imported.actions[0].steps[0].1["lightness"], 100);
let mut s = fixture("lab", depth);
set_lab_pixels(&mut s);
s.execute("select.rect", json!({"x":1,"y":0,"width":1,"height":1})).unwrap();
if inverse {
let mut mask = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[1.0]);
mask.fill_rect(photocraft_doc::Rect::new(1, 0, 2, 1), &[0.0]);
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(mask);
}
let before = raw(&s);
s.actions.list = imported.actions;
play(&mut s);
let after = raw(&s);
if inverse {
assert!(after[0] > 0.99 && after[8] > 0.99);
assert_eq!(&after[4..8], &before[4..8], "the selection hole must stay unchanged");
} else {
assert!(after[4] > 0.99);
assert_eq!(&after[..4], &before[..4], "pixels before the selection must stay unchanged");
assert_eq!(&after[8..], &before[8..], "pixels after the selection must stay unchanged");
}
assert!(s.undo());
assert_eq!(raw(&s), before);
let descriptor = saved(&s).descriptor.unwrap();
assert_eq!(descriptor.get("Fzns"), Some(&D::Integer(0)));
assert_eq!(descriptor.get("Lght"), Some(&D::Integer(100)));
}
}
/// Matching uses original Lab/CMYK channels and tagged RGB rather than display-converted samples.
#[test]
fn lab_matching_uses_native_channels_and_the_embedded_rgb_profile() {
use photocraft_cms::{Builtin, Transform, TransformOptions};
for depth in [8, 16, 32] {
for (mode, profile) in [("rgb", Builtin::DisplayP3), ("gray", Builtin::SGray), ("cmyk", Builtin::CoatedCmyk), ("lab", Builtin::LabD50)] {
let mut s = fixture(mode, depth);
s.edit("explicit profile", |doc, _| {
doc.icc_profile = Some(profile.profile().clone().with_encoded_bytes().to_bytes());
Ok(())
})
.unwrap();
let st = s.active().unwrap();
let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
let format = surface.format();
let before = raw(&s);
// Independent direct transform from this explicitly chosen source profile,
// not the command's profile resolver or its new matching helper.
let t = Transform::with_options(profile.profile(), Builtin::LabD50.profile(), TransformOptions::default()).unwrap();
let mut lab = [[0.0, 0.0, 0.0, 1.0]; 4];
t.convert_f32(&before, format.channels(), lab.as_flattened_mut(), 4, true);
let center = [lab[1][0] as f64 * 100.0, lab[1][1] as f64 * 255.0 - 128.0, lab[1][2] as f64 * 255.0 - 128.0];
let lo = center.map(|v| v - 0.2);
let hi = center.map(|v| v + 0.2);
s.execute("image.adjustments.replaceColor", json!({"labMinimum":lo,"labMaximum":hi,"fuzziness":0,"lightness":100})).unwrap();
let after = raw(&s);
let changed = after.chunks_exact(format.channels()).nth(1).unwrap();
let changed = photocraft_raster::to_rgba(&format, changed);
assert!(changed[..3].iter().all(|v| *v > 0.97), "{mode}/{depth}: {changed:?}");
assert_eq!(&after[..format.channels()], &before[..format.channels()], "black is outside {mode}/{depth}");
assert!(s.undo());
assert_eq!(raw(&s), before);
if mode == "rgb" {
let mut srgb = fixture("rgb", depth);
let before_srgb = raw(&srgb);
srgb.execute("image.adjustments.replaceColor", json!({"labMinimum":lo,"labMaximum":hi,"fuzziness":0,"lightness":100})).unwrap();
assert_eq!(raw(&srgb), before_srgb, "the P3 sample must not be mistaken for an sRGB sample");
}
}
}
}
/// Full and selected edits agree across tile edges, including the final partial tiles and Undo.
#[test]
fn lab_replace_crosses_tile_edges_without_changing_unselected_native_pixels() {
let tile = photocraft_geom::TILE_SIZE;
let area = photocraft_doc::Rect::new(0, 0, tile + 4, tile + 3);
let window = photocraft_doc::Rect::new(tile - 1, tile - 1, tile + 2, tile + 2);
let width = area.width() as usize;
for depth in [8, 16, 32] {
for selected in ["none", "window", "outside"] {
let mut s = fixture("lab", depth);
s.edit("Known Lab tiles", |doc, active| {
doc.size = area.size();
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(area, &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
if selected != "none" {
let default = if selected == "outside" { 1.0 } else { 0.0 };
let mut mask = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[default]);
mask.fill_rect(window, &[1.0 - default]);
doc.selection = Some(mask);
}
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let original = raw(&s);
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap();
let after = raw(&s);
for (i, (pixel, original)) in after.as_chunks::<4>().0.iter().zip(original.as_chunks::<4>().0).enumerate() {
let inside = window.contains((i % width) as i32, (i / width) as i32);
let changed = selected == "none" || (selected == "window") == inside;
if changed {
assert!(pixel[0] > 0.99, "{depth}/{selected} pixel {i}: {pixel:?}");
} else {
assert_eq!(pixel, original, "{depth}/{selected} pixel {i}");
}
assert_eq!(pixel[3], original[3]);
}
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
}
}
}
/// Matching stored pixels change without materializing gaps whose default does not change.
#[test]
fn lab_replace_sparse_pixels_preserve_unstored_gaps() {
let tile = photocraft_geom::TILE_SIZE;
let far = photocraft_doc::Rect::new(4 * tile, 4 * tile, 4 * tile + 1, 4 * tile + 1);
let second = far.translate(2 * tile, 0);
let gap = far.translate(tile, 0);
for (alpha, opacity) in [(true, 0.0), (true, 1.0), (false, 1.0)] {
let mut s = fixture("lab", 32);
s.edit("Two distant Lab pixels", |doc, active| {
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let format = photocraft_doc::PixelFormat { alpha, ..surface.format() };
let channels = format.channels();
*surface = photocraft_raster::Surface::with_default(format, &[0.25, 128.0 / 255.0, 128.0 / 255.0, opacity][..channels]);
for rect in [far, second] {
surface.fill_rect(rect, &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0][..channels]);
}
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let layer = s.active().unwrap().active_layer.unwrap();
let original = before.layer(layer).unwrap().surface().unwrap();
assert_eq!(original.tile_count(), 2);
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap();
let surface = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap();
for rect in [far, second] {
assert!(surface.read_region(rect)[0] > 0.99);
}
assert_eq!(surface.read_region(gap), original.read_region(gap));
assert_eq!(surface.default_pixel(), original.default_pixel());
assert_eq!(surface.tile_count(), 2);
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
}
}
/// Opaque defaults remain visible when the surface has no tiles or only a small exception.
#[test]
fn lab_replace_includes_visible_default_pixels_with_and_without_alpha() {
let tile = photocraft_geom::TILE_SIZE;
let far = photocraft_doc::Rect::new(2 * tile + 1, 2 * tile + 1, 2 * tile + 2, 2 * tile + 2);
for depth in [8, 16, 32] {
for alpha in [false, true] {
for exception in [None, Some(photocraft_doc::Rect::new(1, 0, 2, 1)), Some(far)] {
let mut s = fixture("lab", depth);
s.edit("Opaque default Lab", |doc, active| {
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let format = photocraft_doc::PixelFormat { alpha, ..surface.format() };
let channels = format.channels();
*surface = photocraft_raster::Surface::with_default(format, &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0][..channels]);
if let Some(rect) = exception {
surface.fill_rect(rect, &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0][..channels]);
} else {
assert!(surface.content_bounds().is_empty());
}
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let original = raw(&s);
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap();
for (i, (pixel, original)) in raw(&s).chunks_exact(3 + usize::from(alpha)).zip(original.chunks_exact(3 + usize::from(alpha))).enumerate() {
if exception == Some(photocraft_doc::Rect::new(1, 0, 2, 1)) && i == 1 {
assert_eq!(pixel, original);
} else {
assert!(pixel[0] > 0.99, "{depth}/{alpha}/{exception:?} pixel {i}: {pixel:?}");
}
}
if exception == Some(far) {
let layer = s.active().unwrap().active_layer.unwrap();
let surface = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap();
assert!(surface.read_region(far.translate(-tile, 0))[0] > 0.99, "off-canvas defaults inside the edit extent change too");
assert_eq!(surface.read_region(far), before.layer(layer).unwrap().surface().unwrap().read_region(far));
assert_eq!(surface.read_region(far.translate(2, 2)), surface.default_pixel(), "pixels outside the extent stay unchanged");
}
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
}
}
}
}
/// Selection islands and holes retain their coverage over unstored opaque pixels.
#[test]
fn lab_replace_selection_controls_opaque_gap_pixels() {
let tile = photocraft_geom::TILE_SIZE;
let far = photocraft_doc::Rect::new(2 * tile, 2 * tile, 2 * tile + 1, 2 * tile + 1);
let gap = far.translate(-tile, -tile);
for implicit in [false, true] {
let mut s = fixture("lab", 32);
s.edit("Selected opaque gap", |doc, active| {
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
*surface = photocraft_raster::Surface::with_default(surface.format(), &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
surface.fill_rect(far, &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
let coverage = if implicit { 1.0 } else { 0.0 };
let mut mask = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[coverage]);
mask.fill_rect(gap, &[1.0 - coverage]);
doc.selection = Some(mask);
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let layer = s.active().unwrap().active_layer.unwrap();
let original = before.layer(layer).unwrap().surface().unwrap();
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap();
let surface = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap();
let canvas_pixel = photocraft_doc::Rect::new(0, 0, 1, 1);
for (rect, changed) in [(gap, !implicit), (canvas_pixel, implicit)] {
if changed {
assert!(surface.read_region(rect)[0] > 0.99);
} else {
assert_eq!(surface.read_region(rect), original.read_region(rect));
}
}
assert_eq!(surface.read_region(far), original.read_region(far));
assert_eq!(surface.default_pixel(), original.default_pixel());
if !implicit {
assert_eq!(surface.tile_count(), 2, "only the selected gap tile is added");
}
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
}
}
/// Existing RGB replacement and ordinary gradient export retain their native behavior.
#[test]
fn legacy_rgb_replace_color_and_ordinary_gradient_exports_are_unchanged() {
let mut s = fixture("rgb", 32);
let before = raw(&s);
let mut expected = before.as_chunks::<4>().0.to_vec();
photocraft_algo::tone::replace_color(&mut expected, [0.3, 0.8, 0.2], 40.0, 60.0, 20.0, -10.0);
s.execute("image.adjustments.replaceColor", json!({"color":[0.3,0.8,0.2],"fuzziness":40,"hue":60,"saturation":20,"lightness":-10})).unwrap();
assert_eq!(raw(&s), expected.as_flattened());
let action = photocraft_engine::actions_cmds::Action::new(
"Ordinary",
vec![("image.adjustments.gradientMap".into(), json!({"stops":[[0,"#000000"],[1,"#ffffff"]]}))],
);
let (b, _) = atn_cmds::export("Ordinary", &[&action], &[], false).unwrap();
assert_eq!(
atn::parse(&b).unwrap().actions[0].items[0].event,
AtnEvent::name("gradientMapEvent"),
"an ordinary Gradient Map saves as the string event (#10702)"
);
}
/// Replacement preserves alpha and refuses locked or profile-incompatible surfaces before editing.
#[test]
fn lab_replace_preserves_alpha_and_rejects_locked_or_mismatched_surfaces() {
let p = json!({"labMinimum":[0,-128,-128],"labMaximum":[100,127,127],"lightness":100});
let mut s = fixture("rgb", 32);
let before = raw(&s);
s.execute("image.adjustments.replaceColor", p.clone()).unwrap();
for (a, b) in raw(&s).as_chunks::<4>().0.iter().zip(before.as_chunks::<4>().0) {
assert_eq!(a[3], b[3]);
}
assert!(s.undo());
s.edit("pixel lock", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().locks.pixels = true;
Ok(())
})
.unwrap();
let original = s.active().unwrap().doc.clone();
assert!(s.execute("image.adjustments.replaceColor", p.clone()).is_err());
assert_eq!(s.active().unwrap().doc, original);
let mut mismatch = fixture("rgb", 32);
mismatch
.edit("inconsistent synthetic mode", |doc, _| {
doc.mode = photocraft_doc::ColorMode::Cmyk;
Ok(())
})
.unwrap();
let original = mismatch.active().unwrap().doc.clone();
assert!(mismatch.execute("image.adjustments.replaceColor", p).is_err());
assert_eq!(mismatch.active().unwrap().doc, original);
}
/// Fractional selection mixing changes RGB while preserving each F32 alpha bit exactly.
#[test]
fn lab_replace_fractional_selection_preserves_f32_alpha_bits() {
for coverage in [53.0 / 255.0, 127.0 / 255.0] {
let mut s = fixture("rgb", 32);
s.edit("fractional alpha and selection", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_region(photocraft_doc::Rect::new(2, 0, 3, 1), &[0.7, 0.2, 0.6, 0.7]);
doc.selection = Some(photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[coverage]));
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let original = raw(&s);
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[0,-128,-128],"labMaximum":[100,127,127],"lightness":100})).unwrap();
let after = raw(&s);
assert_ne!(&after[8..11], &original[8..11]);
for (after, before) in after.as_chunks::<4>().0.iter().zip(original.as_chunks::<4>().0) {
assert_eq!(after[3].to_bits(), before[3].to_bits(), "coverage {coverage}");
}
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
}
}
/// A zero shift must not alter native ink values through an unnecessary RGB separation.
#[test]
fn an_unchanged_lab_tone_shift_preserves_native_cmyk_samples() {
for depth in [8, 16, 32] {
let mut s = fixture("cmyk", depth);
s.edit("nontrivial native CMYK", |doc, active| {
doc.layer_mut(active.unwrap())
.unwrap()
.surface_mut()
.unwrap()
.write_region(photocraft_doc::Rect::from_xywh(1, 0, 1, 1), &[0.2, 0.3, 0.4, 0.25, 1.0]);
Ok(())
})
.unwrap();
let before = raw(&s);
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[0,-128,-128],"labMaximum":[100,127,127],"hue":0,"saturation":0,"lightness":0}))
.unwrap();
assert_eq!(raw(&s), before, "an unchanged tone operation must not re-separate CMYK, depth {depth}");
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,249 @@
//! Crop with Delete Cropped Pixels off turns the Background into a normal layer, "Layer 0", as
//! Photoshop does: it keeps the pixels past the canvas, and an extension is transparent on every
//! layer. Delete on keeps the Background, and Image › Reveal All is unchanged (#10867).
use photocraft_engine::actions_cmds::Action;
use photocraft_engine::{EngineError, Session, atn_cmds};
use photocraft_geom::Rect;
use photocraft_psd::{atn, descriptor::Value as D};
use serde_json::{Value, json};
const MAGENTA: [f32; 4] = [1.0, 0.0, 1.0, 1.0];
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
/// The issue's test document at a tenth of the size: 80 × 50 px, a white Background and a pixel
/// layer "Quads" covering the canvas in red, with magenta as the background colour.
fn doc() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 80, "height": 50, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Quads"})).unwrap();
s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#ff00ff"})).unwrap();
s.execute("select.all", json!({})).unwrap();
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s
}
fn d(s: &Session) -> &photocraft_doc::Document {
&s.active().unwrap().doc
}
fn size(s: &Session) -> (u32, u32) {
(d(s).size.width, d(s).size.height)
}
fn past(s: &Session) -> usize {
s.active().unwrap().history.past_len()
}
fn bottom(s: &Session) -> &photocraft_doc::Layer {
&d(s).layers[0]
}
fn content(s: &Session, i: usize) -> Rect {
d(s).layers[i].surface().unwrap().content_bounds()
}
/// The bottom layer is no longer a Background: a normal, unlocked layer named "Layer 0".
fn assert_layer_0(s: &Session, what: &str) {
assert!(d(s).background().is_none(), "{what}: no Background left");
let l = bottom(s);
assert_eq!(l.name, "Layer 0", "{what}");
assert!(!l.locks.transparency && !l.locks.position, "{what}: unlocked");
}
fn crop(s: &mut Session, mut p: Value, delete: bool) {
p["deleteCroppedPixels"] = json!(delete);
let before = past(s);
s.execute("image.crop", p).unwrap();
assert_eq!(past(s), before + 1, "one undo step");
}
#[test]
fn shrinking_turns_the_background_into_layer_0_keeping_the_hidden_pixels() {
let mut s = doc();
crop(&mut s, json!({"x": 0, "y": 0, "width": 70, "height": 50}), false);
assert_eq!(size(&s), (70, 50));
assert_layer_0(&s, "shrink");
// Both layers keep their 80 px, hidden past the canvas.
assert_eq!(content(&s, 0), Rect::new(0, 0, 80, 50), "Layer 0");
assert_eq!(content(&s, 1), Rect::new(0, 0, 80, 50), "Quads");
assert_eq!(bottom(&s).surface().unwrap().rgba(75, 25), WHITE);
}
#[test]
fn extending_leaves_the_new_area_transparent_on_every_layer() {
let mut s = doc();
crop(&mut s, json!({"x": 0, "y": 0, "width": 90, "height": 50}), false);
assert_eq!(size(&s), (90, 50));
assert_layer_0(&s, "extend");
for i in 0..2 {
let surf = d(&s).layers[i].surface().unwrap();
assert_eq!(surf.rgba(85, 25)[3], 0.0, "layer {i}: the added strip is transparent");
}
assert_eq!(bottom(&s).surface().unwrap().rgba(40, 25), WHITE);
assert_eq!(d(&s).layers[1].surface().unwrap().rgba(40, 25), RED);
}
#[test]
fn a_turned_crop_turns_layer_0_with_the_others() {
// The issue's 6.3° frame (687 × 429 of 800 × 500), centred.
let mut s = doc();
crop(&mut s, json!({"x": 6, "y": 4, "width": 68, "height": 42, "angle": 6.3}), false);
assert_eq!(size(&s), (68, 42));
assert_layer_0(&s, "turned");
// Every layer turned, keeping its overhang past the canvas.
let canvas = Rect::new(0, 0, 68, 42);
for i in 0..2 {
let c = content(&s, i);
assert!(c.x0 < canvas.x0 && c.y0 < canvas.y0 && c.x1 > canvas.x1 && c.y1 > canvas.y1, "layer {i} keeps its overhang: {c:?}");
}
// The corners the turn uncovered are transparent, not the background colour.
let surf = bottom(&s).surface().unwrap();
let c = content(&s, 0);
assert_eq!(surf.rgba(c.x0, c.y0)[3], 0.0, "an uncovered corner of Layer 0");
assert_eq!(surf.rgba(34, 21), WHITE);
}
#[test]
fn the_action_steps_turned_crop_unlocks_the_background() {
// The action step `Crop` with `Dlt` false, rect (100,100)–(700,400) and angle 10°, scaled down,
// through a binary .atn: its descriptor carries `Dlt` false, the importer decodes it and
// `actions.play` runs the step.
let params = json!({"x": 10, "y": 10, "width": 60, "height": 30, "angle": 10, "deleteCroppedPixels": false});
let action = Action::new("Crop", vec![("image.crop".into(), params)]);
let (bytes, skipped) = atn_cmds::export("Crop", &[&action], &[], false).unwrap();
assert!(skipped.is_empty());
let file = atn::parse(&bytes).unwrap();
let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap();
assert_eq!(descriptor.get("Dlt "), Some(&D::Boolean(false)));
let imported = atn_cmds::import(&bytes).unwrap();
assert!(imported.unsupported.is_empty() && imported.warnings.is_empty());
let step = &imported.actions[0].steps[0];
assert_eq!((step.0.as_str(), &step.1["deleteCroppedPixels"]), ("image.crop", &json!(false)));
let mut s = doc();
s.actions.list = imported.actions;
let before = past(&s);
s.execute("actions.play", json!({"action": "Crop"})).unwrap();
assert_eq!(past(&s), before + 1, "one undo step");
assert_eq!(size(&s), (60, 30));
assert_layer_0(&s, "action step");
}
#[test]
fn content_aware_with_delete_off_keeps_the_background() {
// Not measured in the reference: the Background stays, and the fill goes on it, as before.
let mut s = doc();
let mut p = json!({"x": 0, "y": 0, "width": 90, "height": 50, "contentAware": true});
p["deleteCroppedPixels"] = json!(false);
s.execute("image.crop", p).unwrap();
assert_eq!(size(&s), (90, 50));
assert_eq!(d(&s).background().map(|l| l.name.as_str()), Some("Background"));
}
#[test]
fn a_target_size_crop_unlocks_the_background() {
let mut s = doc();
crop(&mut s, json!({"x": 0, "y": 0, "width": 40, "height": 50, "targetWidth": 20, "targetHeight": 25, "resolution": 150}), false);
assert_eq!(size(&s), (20, 25));
assert_layer_0(&s, "W x H x Resolution");
}
#[test]
fn delete_on_keeps_the_background_and_fills_an_extension() {
for (frame, want) in [
(json!({"x": 0, "y": 0, "width": 70, "height": 50}), (70, 50)),
(json!({"x": 0, "y": 0, "width": 90, "height": 50}), (90, 50)),
(json!({"x": 6, "y": 4, "width": 68, "height": 42, "angle": 6.3}), (68, 42)),
] {
let mut s = doc();
crop(&mut s, frame.clone(), true);
assert_eq!(size(&s), want, "{frame}");
let bg = d(&s).background().unwrap_or_else(|| panic!("{frame}: the Background stays"));
assert_eq!(bg.name, "Background", "{frame}");
}
let mut s = doc();
crop(&mut s, json!({"x": 0, "y": 0, "width": 90, "height": 50}), true);
assert_eq!(bottom(&s).surface().unwrap().rgba(85, 25), MAGENTA, "the extension takes the background colour");
}
#[test]
fn undo_restores_the_background() {
for frame in [json!({"x": 0, "y": 0, "width": 90, "height": 50}), json!({"x": 6, "y": 4, "width": 68, "height": 42, "angle": 6.3})] {
let mut s = doc();
let before = bottom(&s).clone();
crop(&mut s, frame.clone(), false);
assert_layer_0(&s, "cropped");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(size(&s), (80, 50), "{frame}");
let bg = d(&s).background().unwrap_or_else(|| panic!("{frame}: undo restores the Background"));
assert_eq!(bg.id, before.id, "{frame}");
assert_eq!(bg.name, "Background", "{frame}");
assert!(bg.locks.transparency && bg.locks.position, "{frame}");
let (a, b) = (bg.surface().unwrap(), before.surface().unwrap());
let r = Rect::new(0, 0, 80, 50);
assert_eq!(a.read_region(r), b.read_region(r), "{frame}: the pixels");
s.execute("edit.redo", json!({})).unwrap();
assert_layer_0(&s, "redo");
}
}
#[test]
fn reveal_all_keeps_the_background() {
let mut s = revealable();
let before = past(&s);
s.execute("image.revealAll", json!({})).unwrap();
assert_eq!(past(&s), before + 1);
assert_eq!(size(&s), (90, 50));
let bg = d(&s).background().expect("Reveal All keeps the Background");
assert_eq!(bg.name, "Background");
assert_eq!(bg.surface().unwrap().rgba(85, 25), MAGENTA, "the extension takes the background colour, as before");
}
/// `doc()` with Quads reaching 10 px past the right edge, so Reveal All has something to reveal.
fn revealable() -> Session {
let mut s = doc();
s.edit("paint past the canvas", |doc, _| {
let quads = doc.layers.last_mut().unwrap();
quads.surface_mut().unwrap().fill_rect(Rect::new(80, 0, 90, 50), &RED);
Ok(())
})
.unwrap();
s
}
#[test]
fn reveal_alls_crop_passes_the_session_gate() {
// A gate that refuses `image.crop` refuses Reveal All's crop too, before anything changes.
let mut s = revealable();
s.authorize = Some(|id, _| if id == "image.crop" { Err(EngineError::Other("image.crop is not allowed".into())) } else { Ok(()) });
let before = past(&s);
let err = s.execute("image.revealAll", json!({})).unwrap_err();
assert!(err.to_string().contains("image.crop is not allowed"), "{err}");
assert_eq!(size(&s), (80, 50));
assert_eq!(past(&s), before);
assert!(d(&s).background().is_some());
// A gate that allows it sees the crop Reveal All runs.
let mut s = revealable();
// (An `Err`, not an assert: the gate runs inside the command's panic guard.)
s.authorize = Some(|id, p| {
let expected = json!({"x": 0, "y": 0, "width": 90, "height": 50, "deleteCroppedPixels": false});
if id == "image.crop" && *p != expected { Err(EngineError::Other(format!("unexpected crop {p}"))) } else { Ok(()) }
});
s.execute("image.revealAll", json!({})).unwrap();
assert_eq!(size(&s), (90, 50));
}
#[test]
fn a_document_without_a_background_has_nothing_to_convert() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 80, "height": 50, "background": "transparent"})).unwrap();
let names: Vec<String> = d(&s).layers.iter().map(|l| l.name.clone()).collect();
crop(&mut s, json!({"x": 0, "y": 0, "width": 90, "height": 50}), false);
assert_eq!(size(&s), (90, 50));
assert!(d(&s).background().is_none());
assert_eq!(d(&s).layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>(), names, "no layer renamed");
}
+3
View File
@@ -3,6 +3,8 @@
mod affinity;
mod artboard_export_names;
mod atn_crystallize_cell_size;
mod atn_direct_adjustments;
mod atn_newer_events;
mod atn_reference_forms;
mod atn_ripple_amount;
mod atn_selective_color;
@@ -20,6 +22,7 @@ mod convert_keeps_references;
mod copy_shape_placement;
mod corpus_atn;
mod crop_content_aware;
mod crop_delete_off_background;
mod current_affinity_artboards;
mod duplicate_layer_comps;
mod duplicate_slices;
+1
View File
@@ -1010,6 +1010,7 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &
maximize_compatibility: opts.psd_maximize_compatibility,
document_path: opts.document_path.clone(),
read_linked_files: opts.read_linked_files,
stage_on_scratch_from: Some(psd_export::STAGE_DOCUMENTS_FROM),
..Default::default()
};
// The preview is rendered first, while only the document is in memory: its full-size
+25 -12
View File
@@ -15,11 +15,14 @@ use crate::blocks;
use crate::pixels::{deinterleave, encode_be, psd_depth};
use crate::psd_import::REGENERATED;
mod banded;
mod document;
mod estimate;
mod merged;
mod resources;
mod smart;
mod stage;
pub(crate) use stage::STAGE_DOCUMENTS_FROM;
pub(crate) use document::document_to_psd_interruptible;
#[cfg(test)]
@@ -41,10 +44,6 @@ const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
/// Room kept under [`PSD_MAX_FILE_SIZE`] for what the save adds after the conversion (the image
/// preview resource, a few KB).
const PSD_SAVE_HEADROOM: u64 = 1024 * 1024;
/// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and
/// the channel planes made from them (or the band parts and the joined buffer) are alive together,
/// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`].
const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 };
/// Options for [`document_to_psd_with`].
#[derive(Debug, Clone)]
@@ -74,6 +73,10 @@ pub struct PsdExportOptions {
/// off, since a stored path is document data rather than a granted file: such a layer is
/// then written as pixels, with a warning.
pub read_linked_files: bool,
/// Park each encoded channel on the scratch disk, not in memory, when the document's raw
/// pixel bytes (layers and composite) reach this and spilling is on (`None`: never). The
/// save then holds the file model's structure, not a second copy of its pixels (#5333).
pub stage_on_scratch_from: Option<u64>,
}
impl Default for PsdExportOptions {
@@ -86,11 +89,15 @@ impl Default for PsdExportOptions {
maximize_compatibility: true,
document_path: None,
read_linked_files: true,
stage_on_scratch_from: None,
}
}
}
struct Ex {
struct Ex<'c> {
/// The save's interrupt: a layer saved in bands checks it between bands and channels, so a
/// cancelled save stops within a band (#5333).
ctl: Interrupt<'c>,
fmt: PixelFormat,
/// Document resolution (for generated type-layer data).
dpi: f32,
@@ -126,6 +133,8 @@ struct Ex {
patterns: Vec<photocraft_doc::Pattern>,
/// The layer written as Photoshop's Background (see [`psd_background`]).
background: Option<photocraft_doc::LayerId>,
/// Where encoded channels go instead of memory ([`PsdExportOptions::stage_on_scratch_from`]).
stage: Option<std::sync::Arc<stage::Stage>>,
}
/// The layer Photoshop should open as its Background: the bottom top-level layer when it is a
@@ -175,7 +184,7 @@ fn q255(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
impl Ex {
impl Ex<'_> {
fn compression(&self) -> Compression {
if self.fmt.sample == SampleType::F32 { Compression::ZipPrediction } else { Compression::Rle }
}
@@ -186,9 +195,13 @@ impl Ex {
// A ZIP stream for a zero-sized channel makes readers try to decompress an
// invalid image extent (notably psd-tools for 32-bit adjustment layers).
let compression = if w == 0 || h == 0 { Compression::Raw } else { self.compression() };
ChannelData::encode(id, compression, plane, w, h, depth, self.version)
let c = ChannelData::encode(id, compression, plane, w, h, depth, self.version)
.or_else(|_| ChannelData::encode(id, Compression::Raw, plane, w, h, depth, self.version))
.unwrap_or(ChannelData::new(id, Some(Compression::Raw), plane.to_vec()))
.unwrap_or(ChannelData::new(id, Some(Compression::Raw), plane.to_vec()));
match &self.stage {
Some(stage) => stage.channel(c),
None => c,
}
}
fn empty_channels(&self) -> Vec<ChannelData> {
@@ -208,11 +221,11 @@ impl Ex {
return (PsdRect::default(), self.empty_channels(), warnings);
}
// A layer record is one dense rectangle, so a few pixels far apart make a box of mostly
// nothing, gathered and then copied again. Past the cap the layer is saved empty, said so.
// nothing, gathered and then copied again. Past `banded_from` the layer is gathered and
// encoded a band of rows at a time instead (#5333).
let dense = u64::from(r.width()).saturating_mul(u64::from(r.height())).saturating_mul(s.format().bytes_per_pixel() as u64);
if dense > MAX_LAYER_GATHER_BYTES {
warnings.push(format!("layer \"{name}\" spans {}×{} px, too large to write: saved without its pixels", r.width(), r.height()));
return (PsdRect::default(), self.empty_channels(), warnings);
if dense > banded::banded_from() {
return (to_psd_rect(r), self.banded_channels(&s, r), warnings);
}
// The interleaved gather is the sequential pass for full-canvas layers, so it runs
// in row bands on all cores and the band results concatenate in order.
+152
View File
@@ -0,0 +1,152 @@
//! Layers too large to gather at once, saved band by band (#5333).
//!
//! A layer record is one dense rectangle, gathered interleaved and split into channel planes,
//! so a layer's save held about twice its dense size; past [`BANDED_FROM`] that was refused and the
//! layer saved without its pixels. Now such a layer is gathered, split and encoded a band of
//! rows at a time. RLE rows pack independently (the row-count table is kept and written ahead of
//! the packed rows) and raw rows join, so those channels are the bytes the whole-layer encoder
//! writes. ZIP with prediction (32-bit) predicts per row and feeds one zlib stream: the same
//! pixels, but not always the same bytes, as zlib packs input it is given in parts differently.
//! With a stage each band's parts go to the scratch disk as they are made (see [`Parts`]).
//!
//! A banded layer is minutes of work on a large save, so it checks the save's interrupt before
//! each band and each channel's part of it: once cancelled it stops there (and doesn't retry
//! raw), leaving an incomplete model that the caller discards before the save's commit point.
use std::io::Write as _;
use photocraft_psd::compression::{PlaneLayout, encode_planes, predict};
use photocraft_psd::{ChannelData, Compression, PsdError};
use photocraft_raster::Surface;
use super::Ex;
use super::stage::Parts;
use crate::pixels::{deinterleave, psd_depth};
/// Dense bytes of one layer (as gathered) from which it is saved band by band. Below it a layer
/// is gathered whole, which is faster: the peak is about twice this.
pub(super) const BANDED_FROM: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 };
/// Interleaved bytes gathered per band. A band's planes and encoded parts are alive with it, so a
/// banded layer's save holds about three times this.
const BAND_BYTES: usize = 64 << 20;
#[cfg(test)]
thread_local! {
/// `(BANDED_FROM, BAND_BYTES)` for this thread's exports, so tests band small layers.
pub(crate) static BANDS_FOR_TESTS: std::cell::Cell<Option<(u64, usize)>> = const { std::cell::Cell::new(None) };
/// Bands this thread has gathered and encoded.
pub(crate) static BANDS_DONE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub(super) fn banded_from() -> u64 {
#[cfg(test)]
if let Some((from, _)) = BANDS_FOR_TESTS.get() {
return from;
}
BANDED_FROM
}
fn band_bytes() -> usize {
#[cfg(test)]
if let Some((_, bytes)) = BANDS_FOR_TESTS.get() {
return bytes;
}
BAND_BYTES
}
/// One channel's encoding state across bands.
struct Channel {
id: i16,
table: Vec<u8>,
zip: Option<flate2::write::ZlibEncoder<Vec<u8>>>,
parts: Parts,
}
impl Ex<'_> {
/// The channels of `s` over `r` (non-empty), alpha first, encoded band by band.
pub(super) fn banded_channels(&self, s: &Surface, r: photocraft_geom::Rect) -> Vec<ChannelData> {
// RLE fails only on a row too long for its count (not within PSD/PSB limits); then the
// layer is written raw, as a channel encoded whole falls back to.
match self.banded_pass(s, r, self.compression()) {
Ok(channels) => channels,
// Cancelled: the caller sees the interrupt and discards the whole model, so don't
// encode the layer again.
Err(_) if self.ctl.cancelled() => self.empty_channels(),
Err(_) => self.banded_pass(s, r, Compression::Raw).unwrap_or_else(|_| self.empty_channels()),
}
}
fn banded_pass(&self, s: &Surface, r: photocraft_geom::Rect, compression: Compression) -> photocraft_psd::Result<Vec<ChannelData>> {
let (w, h) = (r.width() as usize, r.height() as usize);
let bpp = s.format().bytes_per_pixel();
let band_rows = (band_bytes() / (w * bpp).max(1)).max(1);
let depth = psd_depth(self.fmt.sample);
let count = if self.version.is_psb() { 4 } else { 2 };
let head = if compression == Compression::Rle { h.checked_mul(count).ok_or(PsdError::LimitExceeded("row count overflow"))? } else { 0 };
let mut channels: Vec<Channel> = (0..=self.cc)
.map(|k| Channel {
id: k as i16 - 1,
table: Vec::with_capacity(head),
zip: (compression == Compression::ZipPrediction).then(|| flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default())),
parts: Parts::new(self.stage.clone(), head),
})
.collect();
let mut invert = vec![self.cmyk; self.cc];
invert.push(false);
let ctl = self.ctl;
let check = || if ctl.cancelled() { Err(PsdError::Invalid("cancelled".into())) } else { Ok(()) };
for y in (0..h).step_by(band_rows) {
check()?;
let rows = band_rows.min(h - y);
let top = r.y0 + y as i32;
let pieces = crate::pixels::par_map(crate::pixels::bands(rows), |b| {
s.to_interleaved(photocraft_geom::Rect::new(r.x0, top + b.start as i32, r.x1, top + b.end as i32))
});
let mut bytes = Vec::with_capacity(w * rows * bpp);
for piece in pieces {
bytes.extend_from_slice(&piece);
}
if self.fmt.mode == photocraft_color::ColorMode::Lab && self.fmt.sample == photocraft_color::SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false);
}
let mut planes: Vec<Option<Vec<u8>>> = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert).into_iter().map(Some).collect();
drop(bytes);
let layout = PlaneLayout { planes: 1, width: w, height: rows, depth, version: self.version };
// Alpha (planes[cc]) is channel 0, then the colour channels; each on its own thread.
let work: Vec<(&mut Channel, Vec<u8>)> =
channels.iter_mut().enumerate().map(|(k, c)| (c, planes[if k == 0 { self.cc } else { k - 1 }].take().unwrap_or_default())).collect();
let done = crate::pixels::par_map(work, |(c, mut plane)| -> photocraft_psd::Result<()> {
check()?;
match compression {
Compression::Rle => {
let mut packed = encode_planes(Compression::Rle, &plane, &layout)?;
let rest = packed.split_off(rows * count);
c.table.extend_from_slice(&packed);
c.parts.push(rest);
}
Compression::ZipPrediction => {
predict(&mut plane, &layout)?;
let zip = c.zip.as_mut().ok_or_else(|| PsdError::Invalid("banded ZIP channel without its stream".into()))?;
zip.write_all(&plane).map_err(|e| PsdError::Invalid(format!("zlib: {e}")))?;
let out = std::mem::take(zip.get_mut());
c.parts.push(out);
}
_ => c.parts.push(plane),
}
Ok(())
});
done.into_iter().collect::<photocraft_psd::Result<()>>()?;
#[cfg(test)]
BANDS_DONE.set(BANDS_DONE.get() + 1);
}
channels
.into_iter()
.map(|mut c| {
if let Some(zip) = c.zip.take() {
c.parts.push(zip.finish().map_err(|e| PsdError::Invalid(format!("zlib: {e}")))?);
}
c.parts.finish(c.id, compression, &c.table)
})
.collect()
}
}
+12 -3
View File
@@ -67,6 +67,7 @@ fn export_supported_document(
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
let version = psd_version(doc, opts.force_psb);
let mut ex = Ex {
ctl: *ctl,
fmt,
dpi: doc.resolution_dpi,
document: opts.document_path.clone(),
@@ -88,6 +89,10 @@ fn export_supported_document(
smart: SmartOut::new(doc, depth),
patterns: doc.patterns.clone(),
background: psd_background(doc),
stage: {
let raw = doc.size.area().saturating_mul(fmt.bytes_per_pixel() as u64).saturating_mul(doc.walk().len() as u64 + 1);
super::stage::Stage::for_document(raw, opts.stage_on_scratch_from)
},
};
add_format_warnings(doc, opts, fmt, big, duotone_inks.is_some(), &mut ex.warnings);
index_layer_ids_and_text(doc, &mut ex);
@@ -307,7 +312,7 @@ struct MergedExport<'a> {
fn prepare_merged_planes<'a>(
doc: &'a Document,
ex: &mut Ex,
ex: &mut Ex<'_>,
opts: &PsdExportOptions,
ctl: &Interrupt,
fmt: PixelFormat,
@@ -331,13 +336,17 @@ fn prepare_merged_planes<'a>(
data: planes,
stored: None,
});
let image_data = match &ex.stage {
Some(stage) => stage.image(image_data),
None => image_data,
};
ctl.progress(1.0);
MergedExport { header, image_data, extra, has_alpha, quick_mask_id, real_merged }
}
fn append_extra_channels<'a>(
doc: &'a Document,
ex: &mut Ex,
ex: &mut Ex<'_>,
planes: &mut Vec<u8>,
has_alpha: bool,
sample: SampleType,
@@ -387,7 +396,7 @@ fn merged_image(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt, fmt: &
(planes, has_alpha)
}
fn index_layer_ids_and_text(doc: &Document, ex: &mut Ex) {
fn index_layer_ids_and_text(doc: &Document, ex: &mut Ex<'_>) {
// Assign layer ids up front (stable across round trips): keep unique
// `psd_id`s, number the rest after them; dividers come after all.
{
+223
View File
@@ -0,0 +1,223 @@
//! Encoded channels parked on the scratch disk while a large PSD/PSB save is assembled (#5333).
//!
//! A save encodes the whole file model before it writes a byte (#8115: it can still be
//! cancelled, and the scratch reads behind it are checked, before the file is replaced). In
//! memory that model is a second, compressed copy of every layer. With
//! [`PsdExportOptions::stage_on_scratch_from`](super::PsdExportOptions::stage_on_scratch_from)
//! set and spilling on, each channel is appended to a [`StageFile`] in the scratch directory as
//! soon as it is encoded, and the model keeps only a [`Stored`] range: the writer then copies it
//! through in short reads (`photocraft_psd`'s `put_encoded`). A channel that cannot be staged
//! (the disk is full, say) stays in memory: staging only ever saves memory.
use std::sync::Arc;
use photocraft_psd::source::{ReadAt, Source, Stored};
use photocraft_psd::{ChannelData, ImageData, PsdError};
use photocraft_raster::stage_file::StageFile;
/// Encoded data shorter than this stays in memory: a range costs more than it saves.
const MIN_STAGED_BYTES: usize = 64 << 10;
/// Raw pixel bytes (layers and composite, as sampled) from which a save made by
/// [`crate::export_with`] stages its channels: about a 24 MP RGBA/16 document with five layers.
pub(crate) const STAGE_DOCUMENTS_FROM: u64 = 1 << 30;
#[cfg(test)]
thread_local! {
/// Where this thread's exports stage, instead of the process-wide scratch directory.
pub(crate) static DIR_FOR_TESTS: std::cell::RefCell<Option<std::path::PathBuf>> = const { std::cell::RefCell::new(None) };
}
struct Backing(StageFile);
impl ReadAt for Backing {
fn len(&self) -> u64 {
self.0.len()
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> {
self.0.read_exact_at(buf, offset).map_err(|e| PsdError::Read(format!("staged export data, {} bytes at offset {offset}: {e}", buf.len())))
}
}
/// The stage of one export: a file on the scratch disk shared by every channel.
pub(crate) struct Stage {
file: Arc<Backing>,
source: Source,
}
impl Stage {
/// A stage for a document of `raw_bytes` pixel bytes, when `from` asks for one at that size
/// and a stage file can be made in the scratch directory.
pub(crate) fn for_document(raw_bytes: u64, from: Option<u64>) -> Option<Arc<Self>> {
if raw_bytes < from? {
return None;
}
#[cfg(test)]
if let Some(dir) = DIR_FOR_TESTS.with(|d| d.borrow().clone()) {
return StageFile::create(&dir).ok().map(Self::new);
}
StageFile::in_scratch().map(Self::new)
}
fn new(file: StageFile) -> Arc<Self> {
let file = Arc::new(Backing(file));
let source = Source::from_arc(file.clone());
Arc::new(Self { file, source })
}
/// Moves `data` to the stage; `None` (the data kept by the caller) when it is short or the
/// write fails.
fn park(&self, data: &[u8]) -> Option<Stored> {
if data.len() < MIN_STAGED_BYTES {
return None;
}
let offset = self.file.0.append(data).ok()?;
Some(Stored { source: self.source.clone(), offset, len: data.len() as u64 })
}
pub(crate) fn channel(&self, mut c: ChannelData) -> ChannelData {
if c.stored.is_none()
&& let Some(stored) = self.park(&c.data)
{
c.data = Vec::new();
c.stored = Some(stored);
}
c
}
pub(crate) fn image(&self, mut image: ImageData) -> ImageData {
if image.stored.is_none()
&& let Some(stored) = self.park(&image.data)
{
image.data = Vec::new();
image.stored = Some(stored);
}
image
}
/// Appends one part of a channel written band by band; `None` when the write fails.
fn append(&self, data: &[u8]) -> Option<u64> {
self.file.0.append(data).ok()
}
/// Bytes on the scratch disk.
#[cfg(test)]
pub(crate) fn len(&self) -> u64 {
self.file.0.len()
}
}
/// One channel of a layer encoded band by band (#5333): its head (an RLE row-count table, filled
/// in when the last band is encoded) and the bands' encoded parts in order. With a stage every
/// part goes to the scratch disk as it is made, so a layer of any size is saved holding about one
/// band; without one the parts are joined in memory, as a channel encoded whole would be.
pub(crate) struct Parts {
stage: Option<Arc<Stage>>,
head_len: usize,
/// Without a stage: the head's room and the parts so far.
joined: Vec<u8>,
/// With a stage: the parts, staged or (when a write failed) kept.
parts: Vec<Part>,
len: u64,
}
enum Part {
Kept(Vec<u8>),
Staged { offset: u64, len: u64 },
}
impl Part {
fn len(&self) -> u64 {
match self {
Part::Kept(v) => v.len() as u64,
Part::Staged { len, .. } => *len,
}
}
}
impl Parts {
pub(crate) fn new(stage: Option<Arc<Stage>>, head_len: usize) -> Self {
let joined = if stage.is_none() { vec![0; head_len] } else { Vec::new() };
Parts { stage, head_len, joined, parts: Vec::new(), len: head_len as u64 }
}
/// Adds the next encoded part.
pub(crate) fn push(&mut self, data: Vec<u8>) {
if data.is_empty() {
return;
}
self.len += data.len() as u64;
match &self.stage {
None => self.joined.extend_from_slice(&data),
Some(stage) => self.parts.push(match stage.append(&data) {
Some(offset) => Part::Staged { offset, len: data.len() as u64 },
None => Part::Kept(data),
}),
}
}
/// The channel: `head` (of the length given to [`Parts::new`]) and then the parts.
pub(crate) fn finish(mut self, id: i16, compression: photocraft_psd::Compression, head: &[u8]) -> photocraft_psd::Result<ChannelData> {
if head.len() != self.head_len {
return Err(PsdError::Invalid("banded channel head has the wrong length".into()));
}
let Some(stage) = self.stage else {
self.joined[..head.len()].copy_from_slice(head);
self.joined.shrink_to_fit();
return Ok(ChannelData::new(id, Some(compression), self.joined));
};
let mut parts = Vec::with_capacity(self.parts.len() + 1);
if !head.is_empty() {
parts.push(Part::Kept(head.to_vec()));
}
parts.append(&mut self.parts);
let mut starts = Vec::with_capacity(parts.len());
let mut at = 0;
for p in &parts {
starts.push(at);
at += p.len();
}
let joined = Joined { file: stage.file.clone(), parts, starts, len: self.len };
let stored = Stored { source: Source::from_arc(Arc::new(joined)), offset: 0, len: self.len };
Ok(ChannelData { id, compression: Some(compression), data: Vec::new(), stored: Some(stored) })
}
}
/// A banded channel's bytes, in memory and in its staged parts. A read is split at the parts, so
/// the writer's consecutive pass reads each staged append from its start to its end, and the stage
/// file checks it.
struct Joined {
file: Arc<Backing>,
parts: Vec<Part>,
starts: Vec<u64>,
len: u64,
}
impl ReadAt for Joined {
fn len(&self) -> u64 {
self.len
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> {
if offset.checked_add(buf.len() as u64).is_none_or(|end| end > self.len) {
return Err(PsdError::Read(format!("staged export data, {} bytes at offset {offset} past its {} bytes", buf.len(), self.len)));
}
let mut pos = offset;
let mut buf = buf;
let mut i = self.starts.partition_point(|&s| s <= pos).saturating_sub(1);
while !buf.is_empty() {
let (part, start) = (&self.parts[i], self.starts[i]);
let within = pos - start;
let n = usize::try_from((part.len() - within).min(buf.len() as u64)).unwrap_or(buf.len());
let (now, rest) = std::mem::take(&mut buf).split_at_mut(n);
match part {
Part::Kept(v) => now.copy_from_slice(&v[within as usize..within as usize + n]),
Part::Staged { offset, .. } => self.file.read_at(offset + within, now)?,
}
buf = rest;
pos += n as u64;
i += 1;
}
Ok(())
}
}
+223
View File
@@ -619,3 +619,226 @@ fn a_linked_fifo_is_not_read() {
let _ = std::fs::remove_dir_all(&dir);
assert!(warnings.iter().any(|w| w.contains("smart object written as pixels") && w.contains("not a regular file")), "{warnings:?}");
}
/// #5333: with staging on, every large encoded channel and the merged image are parked on the
/// scratch disk as they are encoded, and the file written from that model is the same, byte for
/// byte, as one assembled in memory: as PSD, as PSB, and as a PSD promoted to PSB on the way out.
#[test]
fn staged_channels_write_the_same_file_as_in_memory_ones() {
let dir = std::env::temp_dir().join(format!("pc-psd-stage-{}", std::process::id()));
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = Some(dir.clone()));
let doc = sized_document(true, SampleType::U8);
let ctl = Interrupt::NONE;
for (force_psb, limit) in [(false, PSD_MAX_FILE_SIZE), (true, PSD_MAX_FILE_SIZE), (false, SIZED_LIMIT)] {
let plain = PsdExportOptions { force_psb, ..Default::default() };
let staged = PsdExportOptions { stage_on_scratch_from: Some(0), ..plain.clone() };
let (want, _) = document_to_psd_sized(&doc, &plain, 0, &ctl, limit);
let (file, _) = document_to_psd_sized(&doc, &staged, 0, &ctl, limit);
let layer = &file.layers()[0];
// The opaque alpha packs to a few KB and stays in memory; the noise colour channels go.
assert!(layer.channels.iter().filter(|c| c.id >= 0).all(|c| c.stored.is_some() && c.data.is_empty()), "every noise channel is staged");
assert!(layer.channels.iter().filter(|c| c.id == -1).all(|c| c.stored.is_none()), "the short alpha stays in memory");
assert!(file.image_data.stored.is_some() && file.image_data.data.is_empty(), "the merged image is staged");
assert_eq!(file.write_as_psb, want.write_as_psb);
let bytes = want.to_bytes().unwrap();
assert!(file.to_bytes().unwrap() == bytes, "psb {force_psb}, limit {limit}: staged bytes differ");
let mut w = std::io::Cursor::new(Vec::new());
file.write_to(&mut w).unwrap();
assert!(w.into_inner() == bytes, "psb {force_psb}, limit {limit}: streamed staged bytes differ");
}
// Under the threshold, or with no stage asked for, nothing is staged.
let (file, _) = document_to_psd_sized(&doc, &PsdExportOptions { stage_on_scratch_from: Some(u64::MAX), ..Default::default() }, 0, &ctl, PSD_MAX_FILE_SIZE);
assert!(file.layers()[0].channels.iter().all(|c| c.stored.is_none()) && file.image_data.stored.is_none());
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
#[cfg(unix)]
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the stage file has no name on disk");
let _ = std::fs::remove_dir(&dir);
}
/// Short channels stay in memory, and a stage that cannot be written keeps the data in memory.
#[test]
fn staging_keeps_short_or_unwritable_data_in_memory() {
let dir = std::env::temp_dir().join(format!("pc-psd-stage-short-{}", std::process::id()));
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = Some(dir.clone()));
let stage = stage::Stage::for_document(1, Some(0)).unwrap();
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
let short = stage.channel(ChannelData::new(0, Some(Compression::Raw), vec![1; 100]));
assert!(short.stored.is_none() && short.data.len() == 100);
let long = stage.channel(ChannelData::new(0, Some(Compression::Raw), vec![2; 1 << 17]));
assert!(long.stored.is_some() && long.data.is_empty());
assert_eq!(long.encoded().unwrap().as_ref(), &vec![2u8; 1 << 17][..]);
assert_eq!(stage.len(), 1 << 17);
assert!(stage::Stage::for_document(10, Some(11)).is_none(), "under the threshold");
assert!(stage::Stage::for_document(10, None).is_none(), "not asked for");
let _ = std::fs::remove_dir(&dir);
}
/// The first layer's channels, decoded.
fn decoded_channels(file: &PsdFile) -> Vec<Vec<u8>> {
let layer = &file.layers()[0];
let (w, h) = (layer.rect.width() as usize, layer.rect.height() as usize);
layer.channels.iter().map(|c| c.decode(w, h, file.header.depth, file.header.version).unwrap()).collect()
}
/// A noisy, translucent layer inset from the canvas: `w`×`h` px in `mode`/`depth`.
fn banding_document(mode: ColorMode, depth: SampleType, w: u32, h: u32) -> Document {
let mut doc = Document::new("banded", photocraft_geom::Size::new(w, h), mode, depth);
let mut layer = Layer::raster("Layer 1", doc.pixel_format());
let rect = photocraft_geom::Rect::new(5, 3, w as i32 - 7, h as i32 - 2);
let surface = layer.surface_mut().unwrap();
let ch = surface.channels();
let mut state = 0x2545_f491_u32;
let n = rect.width() as usize * rect.height() as usize * ch;
let data: Vec<f32> = (0..n)
.map(|i| {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
// Runs as well as noise, so RLE packs both kinds of row.
let v = if (i / (ch * 40)).is_multiple_of(3) { 0.5 } else { (state >> 20) as f32 / 4095.0 };
if i % ch == ch - 1 { v.max(0.25) } else { v }
})
.collect();
surface.write_region(rect, &data);
doc.layers.push(layer);
doc
}
/// Exports `doc` with layers past `banded` bytes saved in bands of `rows` rows.
fn export_banded(doc: &Document, options: &PsdExportOptions, banded: Option<usize>) -> (PsdFile, Vec<String>) {
let row_bytes = doc.size.width as usize * doc.pixel_format().bytes_per_pixel();
banded::BANDS_FOR_TESTS.set(banded.map(|rows| (0, rows * row_bytes + 3)));
let out = document_to_psd_sized(doc, options, 0, &Interrupt::NONE, PSD_MAX_FILE_SIZE);
banded::BANDS_FOR_TESTS.set(None);
out
}
/// #5333: a layer saved band by band (here every layer, in bands of a few rows) writes the bytes
/// of one gathered whole (ZIP with prediction, 32-bit, the same pixels); RGB, CMYK and Lab; PSD, PSB and a PSD
/// promoted to PSB; in memory and staged on the scratch disk (then also streamed).
#[test]
fn banded_layers_write_the_bytes_of_whole_ones() {
let dir = std::env::temp_dir().join(format!("pc-psd-banded-{}", std::process::id()));
let cases = [
(ColorMode::Rgb, SampleType::U8),
(ColorMode::Rgb, SampleType::U16),
(ColorMode::Rgb, SampleType::F32),
(ColorMode::Cmyk, SampleType::U8),
(ColorMode::Lab, SampleType::U16),
];
for (mode, depth) in cases {
let doc = banding_document(mode, depth, 203, 157);
for force_psb in [false, true] {
for staged in [false, true] {
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = staged.then(|| dir.clone()));
let options = PsdExportOptions { force_psb, stage_on_scratch_from: staged.then_some(0), ..Default::default() };
let (want, want_warnings) = export_banded(&doc, &options, None);
for rows in [1, 7, 1000] {
let what = format!("{mode:?} {depth:?}, psb {force_psb}, staged {staged}, {rows} rows a band");
let (file, warnings) = export_banded(&doc, &options, Some(rows));
assert_eq!(warnings, want_warnings, "{what}");
let layer = &file.layers()[0];
assert_eq!(layer.rect, want.layers()[0].rect, "{what}");
if staged {
assert!(layer.channels.iter().all(|c| c.stored.is_some() && c.data.is_empty()), "{what}: banded channels are staged");
}
if depth == SampleType::F32 {
// One zlib stream fed band by band packs differently from one fed whole
// (the encoder sees a different amount of input ahead), so the ZIP
// channels are compared decoded, here and as the streamed file reads back.
let mut w = std::io::Cursor::new(Vec::new());
file.write_to(&mut w).unwrap();
let back = PsdFile::from_bytes(&w.into_inner()).unwrap();
assert_eq!(decoded_channels(&back), decoded_channels(&want), "{what}: banded ZIP channels differ");
assert!(back.image_data.encoded().unwrap() == want.image_data.encoded().unwrap(), "{what}");
continue;
}
let bytes = want.to_bytes().unwrap();
assert!(file.to_bytes().unwrap() == bytes, "{what}: banded bytes differ");
let mut w = std::io::Cursor::new(Vec::new());
file.write_to(&mut w).unwrap();
assert!(w.into_inner() == bytes, "{what}: streamed banded bytes differ");
if !force_psb {
let (mut promoted, mut whole) = (file.clone(), want.clone());
promoted.promote_to_psb().unwrap();
whole.promote_to_psb().unwrap();
let mut w = std::io::Cursor::new(Vec::new());
promoted.write_to(&mut w).unwrap();
assert!(w.into_inner() == whole.to_bytes().unwrap(), "{what}: promoted banded bytes differ");
}
}
}
}
}
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
let _ = std::fs::remove_dir_all(&dir);
}
/// #5333: a layer past the gather size is saved with its pixels (it used to be saved empty, with a
/// warning), and they come back on import.
#[test]
fn banded_layers_round_trip() {
let dir = std::env::temp_dir().join(format!("pc-psd-banded-rt-{}", std::process::id()));
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let doc = banding_document(ColorMode::Rgb, depth, 181, 133);
for staged in [false, true] {
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = staged.then(|| dir.clone()));
let options = PsdExportOptions { stage_on_scratch_from: staged.then_some(0), ..Default::default() };
let (file, warnings) = export_banded(&doc, &options, Some(5));
assert!(warnings.is_empty(), "{warnings:?}");
let mut w = std::io::Cursor::new(Vec::new());
file.write_to(&mut w).unwrap();
let back = PsdFile::from_bytes(&w.into_inner()).unwrap();
let (read, _) = crate::psd_import::psd_to_document(&back);
let full = photocraft_geom::Rect::new(0, 0, doc.size.width as i32, doc.size.height as i32);
let want = doc.layers[0].surface().unwrap().read_region(full);
let got = read.layers[0].surface().unwrap().read_region(full);
assert!(got == want, "{depth:?}, staged {staged}: pixels differ after the round trip");
}
}
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
let _ = std::fs::remove_dir_all(&dir);
}
/// #5333: a save cancelled while a layer is saved in bands stops at the next band (it neither
/// finishes the layer nor encodes it again raw) and reports the cancel, so it never reaches its
/// commit point and the target is never written: RLE and ZIP, PSD and PSB, in memory and staged.
#[test]
fn a_cancelled_save_stops_between_the_bands_of_a_layer() {
let dir = std::env::temp_dir().join(format!("pc-psd-banded-cancel-{}", std::process::id()));
let this = std::thread::current().id();
// Cancelled once two bands are done (they are counted on this thread).
let after_two = || std::thread::current().id() == this && banded::BANDS_DONE.get() >= 2;
let ctl = Interrupt::cancel_only(&after_two);
for depth in [SampleType::U8, SampleType::F32] {
let doc = banding_document(ColorMode::Rgb, depth, 203, 157);
let row_bytes = doc.size.width as usize * doc.pixel_format().bytes_per_pixel();
let bands = |ctl: &Interrupt, run: &dyn Fn(&Interrupt)| {
banded::BANDS_DONE.set(0);
banded::BANDS_FOR_TESTS.set(Some((0, row_bytes + 3)));
run(ctl);
banded::BANDS_FOR_TESTS.set(None);
banded::BANDS_DONE.get()
};
for force_psb in [false, true] {
for staged in [false, true] {
let what = format!("{depth:?}, psb {force_psb}, staged {staged}");
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = staged.then(|| dir.clone()));
let options = PsdExportOptions { force_psb, stage_on_scratch_from: staged.then_some(0), ..Default::default() };
let export = |ctl: &Interrupt| drop(document_to_psd_sized(&doc, &options, 0, ctl, PSD_MAX_FILE_SIZE));
let rows = doc.layers[0].surface().unwrap().content_bounds().height() as usize;
assert_eq!(bands(&Interrupt::NONE, &export), rows, "{what}: one band a row");
assert_eq!(bands(&ctl, &export), 2, "{what}: stops at the band after the cancel");
}
}
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
// The save sees the cancel, so it returns before its commit point and writes nothing.
let name = format!("banded-cancel-{depth:?}.psd");
let save = |ctl: &Interrupt| {
let r = crate::encode_with(&doc, &name, &crate::ExportOptions::default(), ctl);
assert!(matches!(r, Err(crate::IoError::Cancelled)), "{depth:?}: {:?}", r.err());
};
assert_eq!(bands(&ctl, &save), 2, "{depth:?}: the save stops at the band after the cancel");
}
let _ = std::fs::remove_dir_all(&dir);
}
+2
View File
@@ -366,6 +366,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
maximize_compatibility: true,
document_path: opts.document_path.clone(),
read_linked_files: opts.read_linked_files,
// The layered TIFF reads the merged image's bytes directly: they stay in memory.
stage_on_scratch_from: None,
};
// TIFF has no palette, bitmap or duotone layout: the PSD model of an Indexed Color, Bitmap
// or Duotone document is made in its pixel format (RGB or Grayscale), which the PSD exporter
+21 -3
View File
@@ -731,10 +731,28 @@ mod tests {
#[test]
fn uncertain_guess_warns() {
let f = parse(&one_action("Set 1", "Action 1", vec![bare(AtnEvent::name("x"), b"\x80\xff\x80")])).unwrap();
assert_eq!(f.encoding.as_deref(), Some("windows-1252"));
assert_eq!(f.encoding.as_deref(), Some("windows-1251"));
assert_eq!(f.warnings.len(), 1, "{:?}", f.warnings);
assert!(f.warnings[0].contains("windows-1252"), "{:?}", f.warnings);
assert_eq!(labels(&f), ["€ÿ€"]);
assert!(f.warnings[0].contains("windows-1251"), "{:?}", f.warnings);
assert_eq!(labels(&f), ["ЂяЂ"]);
}
#[test]
fn unresolved_codepage_tie_keeps_a_top_candidate_and_the_original_bytes() {
// GBK 保存 ties with other CJK pages. No Unicode name settles it, but windows-1252
// scored lower, so its "±£´æ" must not replace the tied candidates' readable text.
let bytes = one_action("Set 1", "Action 1", vec![bare(AtnEvent::name("x"), b"\xb1\xa3\xb4\xe6")]);
let f = parse(&bytes).unwrap();
assert_eq!(f.encoding.as_deref(), Some("GBK"));
assert_eq!(labels(&f), ["保存"]);
assert_eq!(f.warnings.len(), 1, "{:?}", f.warnings);
assert!(f.warnings[0].contains("read as GBK"), "{:?}", f.warnings);
assert_eq!(write(&f).unwrap(), bytes);
// Callers can still resolve an ambiguous guess explicitly.
let forced = parse_with_encoding(&bytes, Some("windows-1252")).unwrap();
assert_eq!(labels(&forced), ["±£´æ"]);
assert!(forced.warnings.is_empty(), "{:?}", forced.warnings);
assert_eq!(write(&forced).unwrap(), bytes);
}
#[test]
+25 -7
View File
@@ -15,7 +15,8 @@
//! 3. A tie is broken by the script of a hint (text the file stores as UTF-16, so it decodes
//! fine): kana → Shift_JIS; Hangul → EUC-KR; Cyrillic → windows-1251; Han → GBK, then
//! Big5, then Shift_JIS; Latin letters → windows-1252, then Mac Roman.
//! 4. Still tied: windows-1252, flagged [`Guess::uncertain`].
//! 4. Still tied: windows-1252 if it is among them, otherwise the first highest-scoring candidate,
//! flagged [`Guess::uncertain`].
//! 5. Mac Roman must also beat windows-1252 when the Latin-1 symbols that are no letter in
//! Mac Roman (© ° « ± …) count as common text (1252 "Größe »" is Mac Roman "Grˆfle ª").
//! Otherwise the names are windows-1252, with step 3's hint and step 4's warning.
@@ -39,7 +40,7 @@ pub(crate) const KNOWN_PAIRS: usize = 256;
pub(crate) struct Guess {
/// The encoding to decode every legacy name with.
pub(crate) encoding: &'static Encoding,
/// Nothing told the candidates apart; `encoding` is the windows-1252 fallback.
/// Nothing settled the highest-scoring candidates' tie; `encoding` is a fallback among them.
pub(crate) uncertain: bool,
}
@@ -97,7 +98,11 @@ pub(crate) fn guess(legacy: &[&[u8]], hint: &str) -> Guess {
[only] => Guess { encoding: only, uncertain: false },
tied => match hinted(hint).iter().find(|enc| tied.contains(enc)) {
Some(enc) => Guess { encoding: enc, uncertain: false },
None => Guess { encoding: WINDOWS_1252, uncertain: true },
// A fallback must not discard the scores and choose a lower-scoring page.
None => {
let encoding = if tied.contains(&WINDOWS_1252) { WINDOWS_1252 } else { tied.first().copied().unwrap_or(WINDOWS_1252) };
Guess { encoding, uncertain: true }
}
},
};
if picked.encoding != MACINTOSH {
@@ -377,7 +382,7 @@ mod tests {
assert_eq!(g.encoding, GBK);
assert!(!g.uncertain);
assert_eq!(decode(names[0], g.encoding), "保存");
assert_eq!(guess(names, "Set 1"), Guess { encoding: WINDOWS_1252, uncertain: true });
assert_eq!(guess(names, "Set 1"), Guess { encoding: GBK, uncertain: true });
}
#[test]
@@ -499,14 +504,27 @@ mod tests {
fn bytes_no_cjk_page_accepts_still_decode() {
// 0xFF is invalid in every double-byte candidate; windows-1251 (Ђ я Ђ) and Mac Roman
// (Ä ˇ Ä) read all three bytes as letters, windows-1252 only one (€ ÿ €). With nothing to
// break the tie, the fallback still decodes them.
// break the tie, the first highest-scoring candidate still decodes them.
let names: &[&[u8]] = &[b"\x80\xff\x80"];
let g = guess(names, "");
assert_eq!(g, Guess { encoding: WINDOWS_1252, uncertain: true });
assert_eq!(decode(names[0], g.encoding), "€ÿ€");
assert_eq!(g, Guess { encoding: WINDOWS_1251, uncertain: true });
assert_eq!(decode(names[0], g.encoding), "ЂяЂ");
assert_eq!(guess(names, "Набор").encoding, WINDOWS_1251);
}
#[test]
fn uncertain_fallback_stays_among_the_highest_scoring_candidates() {
// Mac Roman "Ändern": Mac Roman and windows-1251 both score every non-ASCII byte
// as a letter. Windows-1252 reads "€ndern", so it must not win their unresolved tie.
let names: &[&[u8]] = &[b"\x80ndern"];
for hint in ["Set 1", "保存"] {
let g = guess(names, hint);
assert!([MACINTOSH, WINDOWS_1251].contains(&g.encoding), "{hint}: {g:?}");
assert!(g.uncertain, "{hint}: {g:?}");
assert_eq!(score(g.encoding, names[0]), (1, 1));
}
}
#[test]
fn empty_or_utf8_input_is_utf8() {
let g = guess(&[], "");
+11 -4
View File
@@ -228,11 +228,18 @@ pub fn decode_planes(compression: Compression, data: &[u8], layout: &PlaneLayout
/// Bytes a PSD RLE stream (two-byte row counts) of `rows` rows grows by when written as PSB
/// (four-byte row counts); an error when `data` is too short to hold the count table.
pub(crate) fn rle_widening(data: &[u8], rows: usize) -> Result<u64> {
let table = rows.checked_mul(2).ok_or(PsdError::LimitExceeded("row count overflow"))?;
if data.len() < table {
return Err(PsdError::UnexpectedEof { offset: data.len(), needed: table - data.len() });
rle_widening_len(data.len() as u64, rows)
}
/// [`rle_widening`] from the stream's length alone, so data still in its source (or staged on
/// the scratch disk) is not read to measure it.
pub(crate) fn rle_widening_len(len: u64, rows: usize) -> Result<u64> {
let table = rows.checked_mul(2).ok_or(PsdError::LimitExceeded("row count overflow"))? as u64;
if len < table {
let offset = usize::try_from(len).unwrap_or(usize::MAX);
return Err(PsdError::UnexpectedEof { offset, needed: usize::try_from(table - len).unwrap_or(usize::MAX) });
}
Ok(table as u64)
Ok(table)
}
/// Encodes planar data (excluding the 2-byte compression field).
+77 -7
View File
@@ -2,7 +2,7 @@
use std::io::{Seek, Write};
use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, rle_widening, validate_planes};
use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, rle_widening_len, validate_planes};
use crate::error::{PsdError, Result};
use crate::header::{ColorMode, Header, Version};
use crate::image_data::ImageData;
@@ -382,10 +382,9 @@ impl PsdFile {
o.put_u16(self.image_data.compression.as_u16());
Ok(())
})?;
let merged = self.image_data.encoded()?;
match self.merged_widened_rows(widen)? {
0 => out.put(&merged),
rows => out.put_widened_rle(&merged, rows),
0 => out.put_encoded(&self.image_data.data, self.image_data.stored.as_ref()),
rows => out.put_widened_encoded(&self.image_data.data, self.image_data.stored.as_ref(), rows),
}
}
@@ -415,13 +414,13 @@ impl PsdFile {
for c in &layer.channels {
match layer.widened_rows(c, true)? {
0 => {}
rows => n = n.saturating_add(rle_widening(&c.encoded()?, rows)?),
rows => n = n.saturating_add(rle_widening_len(c.encoded_len(), rows)?),
}
}
}
match self.merged_widened_rows(true)? {
0 => Ok(n),
rows => Ok(n.saturating_add(rle_widening(&self.image_data.encoded()?, rows)?)),
rows => Ok(n.saturating_add(rle_widening_len(self.image_data.encoded_len(), rows)?)),
}
}
@@ -464,7 +463,7 @@ impl PsdFile {
}
let merged = match self.merged_widened_rows(widen)? {
0 => 0,
rows => rle_widening(&self.image_data.encoded()?, rows)?,
rows => rle_widening_len(self.image_data.encoded_len(), rows)?,
};
Ok(n.saturating_add(2).saturating_add(self.image_data.encoded_len()).saturating_add(merged))
}
@@ -719,6 +718,77 @@ mod write_tests {
}
}
/// Channel data and a merged image still in their source (an imported file's, or what an
/// exporter spilled to scratch) are copied through in short reads, never read whole.
#[test]
fn source_backed_data_is_copied_in_short_reads() {
struct Reads(Vec<u8>, std::sync::Mutex<usize>);
impl crate::source::ReadAt for Reads {
fn len(&self) -> u64 {
self.0.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
let mut most = self.1.lock().unwrap();
*most = (*most).max(buf.len());
self.0.as_slice().read_at(offset, buf)
}
}
let mut file = testgen::small(Version::Psd, Compression::Raw);
file.image_data = ImageData::new(Compression::Raw, vec![5; 300]);
let info = file.layer_info.as_mut().unwrap();
info.layers[0].channels[0] = crate::layer::ChannelData::new(0, Some(Compression::Raw), vec![6; 301]);
let bytes = file.to_bytes().unwrap();
let source = std::sync::Arc::new(Reads(bytes.clone(), std::sync::Mutex::new(0)));
let lazy = PsdFile::from_source(crate::Source::from_arc(source.clone())).unwrap();
*source.1.lock().unwrap() = 0;
let mut w = std::io::Cursor::new(Vec::new());
lazy.write_to(&mut w).unwrap();
assert!(w.into_inner() == bytes);
assert!(*source.1.lock().unwrap() <= 7, "read {} bytes at once", source.1.lock().unwrap());
}
/// #5333: RLE data still in its source widens to PSB in short reads too, the count table and
/// then the packed rows, one consecutive pass, never the whole merged image or channel at once.
#[test]
fn source_backed_rle_widens_in_short_reads() {
struct Reads(Vec<u8>, std::sync::Mutex<(usize, Vec<(u64, usize)>)>);
impl crate::source::ReadAt for Reads {
fn len(&self) -> u64 {
self.0.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
let mut seen = self.1.lock().unwrap();
seen.0 = seen.0.max(buf.len());
seen.1.push((offset, buf.len()));
self.0.as_slice().read_at(offset, buf)
}
}
let file = testgen::small(Version::Psd, Compression::Rle);
assert_eq!(file.image_data.compression, Compression::Rle);
let bytes = file.to_bytes().unwrap();
let mut promoted = file.clone();
promoted.promote_to_psb().unwrap();
let want = promoted.to_bytes().unwrap();
let source = std::sync::Arc::new(Reads(bytes, std::sync::Mutex::new((0, Vec::new()))));
let mut lazy = PsdFile::from_source(crate::Source::from_arc(source.clone())).unwrap();
assert!(lazy.image_data.stored.is_some(), "the merged image stays in the source");
lazy.promote_to_psb().unwrap();
*source.1.lock().unwrap() = (0, Vec::new());
let mut w = std::io::Cursor::new(Vec::new());
lazy.write_to(&mut w).unwrap();
assert!(w.into_inner() == want);
let seen = source.1.lock().unwrap();
assert!(seen.0 <= 7, "read {} bytes at once", seen.0);
let merged = lazy.image_data.stored.as_ref().unwrap();
let reads: Vec<_> = seen.1.iter().filter(|(at, _)| (merged.offset..merged.offset + merged.len).contains(at)).collect();
let mut next = merged.offset;
for &&(at, n) in &reads {
assert_eq!(at, next, "the merged image is read in one consecutive pass");
next += n as u64;
}
assert_eq!(next, merged.offset + merged.len);
}
/// #6607: pixel data goes to the stream as it is, never through a staging copy, so a save
/// holds the model and a short buffer, not a second copy of the file.
#[test]
+50 -6
View File
@@ -213,6 +213,8 @@ impl WriteExt for Vec<u8> {
/// Staged bytes [`Sink`] collects before writing them out in one call.
const STAGE: usize = 64 << 10;
/// Bytes of stored channel data read per piece by [`Sink::put_encoded`].
const COPY_CHUNK: u64 = if cfg!(test) { 7 } else { 8 << 20 };
#[cfg(test)]
pub(crate) const STAGE_FOR_TESTS: usize = STAGE;
@@ -273,16 +275,58 @@ impl<'w, W: Write + Seek + ?Sized> Sink<'w, W> {
let table_len = usize::try_from(crate::compression::rle_widening(data, rows)?).map_err(|_| PsdError::LimitExceeded("row count overflow"))?;
let (table, packed) = data.split_at_checked(table_len).ok_or_else(|| PsdError::invalid("RLE row-count table out of range"))?;
for part in table.chunks(STAGE / 2) {
self.small(|o| {
for count in part.as_chunks::<2>().0 {
o.extend_from_slice(&[0, 0, count[0], count[1]]);
}
Ok(())
})?;
self.widen_counts(part)?;
}
self.put(packed)
}
/// [`Self::put_widened_rle`] for data held in memory (`data`) or, when `stored`, still in its
/// source: the count table and then the packed rows are read in [`COPY_CHUNK`]-sized parts
/// (#5333), so a staged merged image or channel is never read back whole to widen it.
pub(crate) fn put_widened_encoded(&mut self, data: &[u8], stored: Option<&crate::source::Stored>, rows: usize) -> std::io::Result<()> {
let Some(s) = stored else { return self.put_widened_rle(data, rows) };
let table_len = crate::compression::rle_widening_len(s.len, rows)?;
// Whole counts per part; the parts follow each other, so a staged range is read in one pass.
let table_part = (COPY_CHUNK / 2).max(1) * 2;
let mut at = 0u64;
while at < s.len {
let n = if at < table_len { (table_len - at).min(table_part) } else { (s.len - at).min(COPY_CHUNK) };
let part = s.read_part(at, n)?;
if at < table_len {
self.widen_counts(&part)?
} else {
self.put(&part)?
}
at += n;
}
Ok(())
}
/// Two-byte RLE row counts as four-byte ones (`00 00` and the count), staged.
fn widen_counts(&mut self, counts: &[u8]) -> std::io::Result<()> {
self.small(|o| {
for count in counts.as_chunks::<2>().0 {
o.extend_from_slice(&[0, 0, count[0], count[1]]);
}
Ok(())
})
}
/// Appends encoded bytes held in memory (`data`) or, when `stored`, still in their source:
/// those are copied through in [`COPY_CHUNK`] pieces, so a channel or merged image kept on
/// disk (an imported file's, or one an exporter spilled to scratch) is never read whole.
pub(crate) fn put_encoded(&mut self, data: &[u8], stored: Option<&crate::source::Stored>) -> std::io::Result<()> {
let Some(s) = stored else { return self.put(data) };
let mut at = 0u64;
while at < s.len {
let n = (s.len - at).min(COPY_CHUNK);
let part = s.read_part(at, n)?;
self.put(&part)?;
at += n;
}
Ok(())
}
/// `n` zero bytes (padding, at most a few).
pub(crate) fn zeros(&mut self, n: usize) -> std::io::Result<()> {
self.small(|b| {
+5 -6
View File
@@ -3,7 +3,7 @@
use std::borrow::Cow;
use crate::blend::BlendMode;
use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes, rle_widening};
use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes, rle_widening_len};
use crate::error::{PsdError, Result};
use crate::header::{MAX_DIMENSION, Version};
use crate::io::{Reader, Sink, WriteExt, append_with, decode_legacy_name, read_pascal, write_pascal};
@@ -692,7 +692,7 @@ impl LayerRecord {
for c in &self.channels {
out.put_i16(c.id);
let rows = self.widened_rows(c, widen)?;
let grown = if rows == 0 { 0 } else { rle_widening(&c.encoded()?, rows)? };
let grown = if rows == 0 { 0 } else { rle_widening_len(c.encoded_len(), rows)? };
out.put_len(c.stored_len().saturating_add(grown), version.is_psb())?;
}
out.put(b"8BIM");
@@ -783,7 +783,7 @@ impl LayerInfo {
for l in &self.layers {
for c in l.channels.iter().filter(|c| c.compression.is_some()) {
let rows = l.widened_rows(c, widen)?;
let grown = if rows == 0 { 0 } else { rle_widening(&c.encoded()?, rows)? };
let grown = if rows == 0 { 0 } else { rle_widening_len(c.encoded_len(), rows)? };
n = n.saturating_add(2).saturating_add(c.encoded_len()).saturating_add(grown);
}
}
@@ -858,10 +858,9 @@ impl LayerInfo {
o.put_u16(comp.as_u16());
Ok(())
})?;
let data = c.encoded()?;
match l.widened_rows(c, widen)? {
0 => out.put(&data)?,
rows => out.put_widened_rle(&data, rows)?,
0 => out.put_encoded(&c.data, c.stored.as_ref())?,
rows => out.put_widened_encoded(&c.data, c.stored.as_ref(), rows)?,
}
}
}
+1
View File
@@ -49,6 +49,7 @@ impl std::error::Error for AllocationError {}
pub mod memory;
mod scratch_file;
pub mod spill;
pub mod stage_file;
/// One shared tile per pixel value (and tile size), for [`Surface::share_uniform_tiles`].
#[derive(Debug, Default)]
+47 -34
View File
@@ -118,35 +118,9 @@ impl std::fmt::Debug for ScratchFile {
impl ScratchFile {
pub fn create(dir: &Path) -> std::io::Result<Self> {
std::fs::create_dir_all(dir)?;
static COUNTER: AtomicUsize = AtomicUsize::new(0);
// A reused process id must not collide with an abandoned file from an older launch.
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.saturating_add(COUNTER.fetch_add(1, Ordering::Relaxed) as u128);
let path = dir.join(format!("photocraft-{}-{n}.scratch", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.read(true).write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let (main, path, linked) = open_private(dir)?;
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
options.custom_flags(FILE_FLAG_DELETE_ON_CLOSE);
}
let main = options.open(&path)?;
#[cfg(unix)]
reap_abandoned(dir, &main);
// Unix: the open handle keeps the inode; nothing is left behind at any exit. A
// filesystem that refuses the unlink keeps the name until drop or the next reap.
#[cfg(not(windows))]
let linked = std::fs::remove_file(&path).is_err();
let _ = linked;
Ok(Self {
main,
#[cfg(windows)]
@@ -231,6 +205,45 @@ impl ScratchFile {
}
}
/// Creates a file only this process can reach in `dir`, gone once its last handle closes: Unix
/// opens it private (0600) and unlinks it right away, Windows opens it delete-on-close. Files
/// of dead processes are reaped. Returns the file, its name, and whether the name is still
/// there (a filesystem refused the unlink: remove it when done).
pub(crate) fn open_private(dir: &Path) -> std::io::Result<(File, PathBuf, bool)> {
std::fs::create_dir_all(dir)?;
static COUNTER: AtomicUsize = AtomicUsize::new(0);
// A reused process id must not collide with an abandoned file from an older launch.
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.saturating_add(COUNTER.fetch_add(1, Ordering::Relaxed) as u128);
let path = dir.join(format!("photocraft-{}-{n}.scratch", std::process::id()));
let mut options = std::fs::OpenOptions::new();
options.read(true).write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
options.custom_flags(FILE_FLAG_DELETE_ON_CLOSE);
}
let file = options.open(&path)?;
#[cfg(unix)]
reap_abandoned(dir, &file);
// Unix: the open handle keeps the inode; nothing is left behind at any exit. A
// filesystem that refuses the unlink keeps the name until drop or the next reap.
#[cfg(not(windows))]
let linked = std::fs::remove_file(&path).is_err();
#[cfg(windows)]
let linked = false;
Ok((file, path, linked))
}
/// Accept only the names we generate, never other temporary files or directories.
#[cfg(any(unix, test))]
fn scratch_pid(name: &str) -> Option<u32> {
@@ -297,17 +310,17 @@ fn thread_slot() -> usize {
}
#[cfg(unix)]
fn write_all_at(file: &File, data: &[u8], offset: u64) -> std::io::Result<()> {
pub(crate) fn write_all_at(file: &File, data: &[u8], offset: u64) -> std::io::Result<()> {
std::os::unix::fs::FileExt::write_all_at(file, data, offset)
}
#[cfg(unix)]
fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
pub(crate) fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
std::os::unix::fs::FileExt::read_exact_at(file, buf, offset)
}
#[cfg(windows)]
fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> std::io::Result<()> {
pub(crate) fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> std::io::Result<()> {
use std::os::windows::fs::FileExt;
while !data.is_empty() {
let n = file.seek_write(data, offset)?;
@@ -321,7 +334,7 @@ fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> std::io::Resul
}
#[cfg(windows)]
fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> {
pub(crate) fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> {
use std::os::windows::fs::FileExt;
while !buf.is_empty() {
let n = file.seek_read(buf, offset)?;
@@ -335,12 +348,12 @@ fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::R
}
#[cfg(not(any(unix, windows)))]
fn write_all_at(_file: &File, _data: &[u8], _offset: u64) -> std::io::Result<()> {
pub(crate) fn write_all_at(_file: &File, _data: &[u8], _offset: u64) -> std::io::Result<()> {
Err(std::io::ErrorKind::Unsupported.into())
}
#[cfg(not(any(unix, windows)))]
fn read_exact_at(_file: &File, _buf: &mut [u8], _offset: u64) -> std::io::Result<()> {
pub(crate) fn read_exact_at(_file: &File, _buf: &mut [u8], _offset: u64) -> std::io::Result<()> {
Err(std::io::ErrorKind::Unsupported.into())
}
+178
View File
@@ -0,0 +1,178 @@
//! Encoded data an exporter parks on the scratch disk instead of in memory (#5333).
//!
//! A large PSD/PSB save encodes every channel before it writes a byte (so it can still be
//! cancelled, and its scratch reads are checked, before the file is replaced). Kept in memory,
//! those channels are a second copy of the document. A [`StageFile`] holds them on the scratch
//! disk instead: an append-only file made like the tile scratch file (private, unlinked on Unix,
//! delete-on-close on Windows), so nothing is left behind at any exit, and the space returns as
//! soon as the save drops it.
//!
//! Like the tile scratch file, every append carries a CRC-32: a read pass that covers an append
//! from its start to its end (whole, or in consecutive parts, as a save copies it) fails on its
//! last part if the disk handed back different bytes, so the save errors before it replaces
//! anything instead of writing them into the document.
use std::collections::BTreeMap;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};
use crate::scratch_file::{open_private, read_exact_at, write_all_at};
pub struct StageFile {
file: File,
path: PathBuf,
/// The directory entry still exists (the unlink was refused): remove it on drop.
linked: bool,
/// Bytes reserved so far; appends take their offset from it.
end: Mutex<u64>,
/// Each append by its offset, with its checksum and the read pass under way.
extents: Mutex<BTreeMap<u64, Extent>>,
}
struct Extent {
len: u64,
crc32: u32,
/// Where the current read pass continues (`None` once it skipped or jumped: not checked).
next: Option<u64>,
hasher: crc32fast::Hasher,
}
impl std::fmt::Debug for StageFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StageFile").field("path", &self.path).field("len", &self.len()).finish()
}
}
impl StageFile {
/// A new, empty stage file in `dir`.
pub fn create(dir: &Path) -> std::io::Result<Self> {
let (file, path, linked) = open_private(dir)?;
Ok(Self { file, path, linked, end: Mutex::new(0), extents: Mutex::default() })
}
/// A stage file in the configured scratch directory, when spilling is on (`None` otherwise,
/// or when the file cannot be made: the caller then keeps its data in memory).
pub fn in_scratch() -> Option<Self> {
if !crate::spill::enabled() {
return None;
}
Self::create(&crate::spill::stats().dir?).ok()
}
/// Appends `data` and returns its offset. Parallel appends each get their own range.
pub fn append(&self, data: &[u8]) -> std::io::Result<u64> {
let offset = {
let mut end = self.end.lock().unwrap_or_else(PoisonError::into_inner);
let offset = *end;
*end = offset.checked_add(data.len() as u64).ok_or_else(|| std::io::Error::other("stage file too large"))?;
offset
};
write_all_at(&self.file, data, offset)?;
let extent = Extent { len: data.len() as u64, crc32: crc32fast::hash(data), next: Some(offset), hasher: crc32fast::Hasher::new() };
self.extents.lock().unwrap_or_else(PoisonError::into_inner).insert(offset, extent);
Ok(offset)
}
/// Fills `buf` from `offset`, which must lie within what was appended. A read that ends a
/// pass over one append from its start fails with `InvalidData` if the append's bytes changed.
pub fn read_exact_at(&self, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
let Some(end) = offset.checked_add(buf.len() as u64).filter(|&end| end <= self.len()) else {
return Err(std::io::ErrorKind::UnexpectedEof.into());
};
read_exact_at(&self.file, buf, offset)?;
let mut extents = self.extents.lock().unwrap_or_else(PoisonError::into_inner);
let Some((&start, extent)) = extents.range_mut(..=offset).next_back() else { return Ok(()) };
let extent_end = start + extent.len;
if end > extent_end {
return Ok(()); // spans appends (no save reads this way): not checked
}
if offset == start {
extent.hasher = crc32fast::Hasher::new();
extent.next = Some(start);
}
if extent.next != Some(offset) {
extent.next = None;
return Ok(());
}
extent.hasher.update(buf);
extent.next = Some(end);
if end == extent_end {
let crc32 = std::mem::take(&mut extent.hasher).finalize();
extent.next = None;
if crc32 != extent.crc32 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "staged save data changed on the scratch disk (checksum mismatch)"));
}
}
Ok(())
}
/// Bytes appended (reserved) so far.
pub fn len(&self) -> u64 {
*self.end.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl Drop for StageFile {
fn drop(&mut self) {
if self.linked {
let _ = std::fs::remove_file(&self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appends_read_back_and_leave_nothing_behind() {
let dir = std::env::temp_dir().join(format!("pc-stage-{}", std::process::id()));
let stage = StageFile::create(&dir).unwrap();
#[cfg(unix)]
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the stage file is unlinked while open");
let parts: Vec<Vec<u8>> = (0..8u8).map(|i| vec![i; 1000 + usize::from(i)]).collect();
let offsets: Vec<u64> = std::thread::scope(|s| {
let handles: Vec<_> = parts.iter().map(|p| s.spawn(|| stage.append(p).unwrap())).collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
assert_eq!(stage.len(), parts.iter().map(|p| p.len() as u64).sum::<u64>());
for (p, &at) in parts.iter().zip(&offsets) {
let mut buf = vec![0; p.len()];
stage.read_exact_at(&mut buf, at).unwrap();
assert_eq!(&buf, p);
}
let mut past = [0u8; 2];
assert!(stage.read_exact_at(&mut past, stage.len() - 1).is_err(), "reads stop at what was appended");
drop(stage);
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir(&dir);
}
/// Bytes that change on the disk fail the pass that copies them, whole or in parts.
#[test]
fn a_changed_append_fails_its_read_pass() {
let dir = std::env::temp_dir().join(format!("pc-stage-crc-{}", std::process::id()));
let stage = StageFile::create(&dir).unwrap();
let first = stage.append(&[1; 100]).unwrap();
let second = stage.append(&[2; 100]).unwrap();
let pass = |at: u64, part: usize| -> std::io::Result<()> {
let mut buf = vec![0; part];
(0..100 / part as u64).try_for_each(|i| stage.read_exact_at(&mut buf, at + i * part as u64))
};
pass(first, 10).unwrap();
pass(second, 100).unwrap();
write_all_at(&stage.file, &[9], second + 57).unwrap();
pass(first, 25).unwrap();
assert_eq!(pass(second, 10).unwrap_err().kind(), std::io::ErrorKind::InvalidData, "in parts");
assert_eq!(pass(second, 100).unwrap_err().kind(), std::io::ErrorKind::InvalidData, "whole");
let mut part = [0; 10];
stage.read_exact_at(&mut part, second + 50).unwrap(); // a read that starts mid-append is not a pass
drop(stage);
let _ = std::fs::remove_dir(&dir);
}
}
+20
View File
@@ -1732,6 +1732,26 @@ fn psd_round_trips_antialias_opentype_and_warp() {
}
}
/// EngineData only maps Photoshop's `OldStyle` bool to `onum`. Other Numerals tags (`lnum` /
/// `pnum` / `tnum`) stay in `.pcraft` until a Photoshop-authored PSD oracle exists — we do not
/// invent EngineData keys or copy figure-style tables from GPL editors (AGENTS.md clean-room).
#[test]
fn psd_engine_data_keeps_only_oldstyle_among_numeral_tags() {
let style = CharStyle {
font_family: "Inter".into(),
size_pt: 20.0,
features: ["lnum", "onum", "pnum", "tnum"].iter().map(|t| FontFeature { tag: (*t).into(), value: 1 }).collect(),
..Default::default()
};
let t = styled("0123456789", style);
let bytes = crate::psd::build_tysh(&t, 72.0, None);
let back = crate::psd::text_layer_from_tysh(&bytes, 72.0).expect("tysh");
let runs = back.char_runs();
let mut got: Vec<&str> = runs[0].style.features.iter().map(|f| f.tag.as_str()).collect();
got.sort_unstable();
assert_eq!(got, ["onum"]);
}
/// A legacy-signature `TySh` (#1469): zero PointBase, `bounds` (the layout box) and `boundingBox`
/// (the ink box, as Photoshop writes it) both around `anchor` in a remote local space, and a
/// `transform` that cancels the remote origin.
+93 -14
View File
@@ -248,9 +248,10 @@ impl PhotocraftApp {
{
return r;
}
let from = app.session.journal.len();
let r = app.save_to(path.clone(), choices)?;
if plain_save && own.as_deref() == Some(path.as_str()) {
app.journal_as_plain_save(&path);
app.journal_as_plain_save(&path, from);
}
Ok(r)
})
@@ -310,8 +311,8 @@ impl PhotocraftApp {
/// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF,
/// Targa, DDS or JPEG answer), so an action being recorded keeps it (#2032).
fn save_as_step(&mut self, path: String, tiff_layers: Option<bool>, choices: FormatChoices) -> Result<Value, String> {
let r = self.write_save(path.clone(), tiff_layers, choices)?;
let mut step = serde_json::json!({"path": path});
let mut step = self.save_as_record(&path);
let r = self.write_save(path, tiff_layers, choices)?;
if let Some(layers) = tiff_layers {
step["tiffLayers"] = layers.into();
}
@@ -320,11 +321,67 @@ impl PhotocraftApp {
Ok(r)
}
/// The `file.saveAs` step that records a Save As of the active document to `path`, taken
/// before the save renames the document. With the document's own name kept (the name Save
/// As suggests), only the folder and the extension are recorded, so the action saves each
/// document it is played on under that document's name, as in Photoshop (#5305); a name
/// typed in is recorded as it is.
pub(crate) fn save_as_record(&self, path: &str) -> Value {
let file = std::path::Path::new(path);
let kept = self.own_file_stem().is_some_and(|own| file.file_stem().is_some_and(|s| s.to_string_lossy() == own));
match (kept, file.extension()) {
(true, Some(ext)) => {
let folder = file.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default();
serde_json::json!({"folder": folder, "extension": ext.to_string_lossy()})
}
_ => serde_json::json!({"path": path}),
}
}
/// `step` (a save answered in a format prompt, recorded with its `path`) as
/// [`Self::save_as_record`] records it when it is a Save As of the active document.
pub(crate) fn record_answered_save_as(&self, record: &str, step: &mut Value) {
if record != "file.saveAs" {
return;
}
let Some(path) = step.get("path").and_then(Value::as_str).map(str::to_owned) else { return };
if let (Some(s), Value::Object(r)) = (step.as_object_mut(), self.save_as_record(&path)) {
s.remove("path");
s.extend(r);
}
}
/// The name Save As suggests for the active document, without its extension: its file's,
/// or its own for a document never saved ("Untitled" for one with no name).
fn own_file_stem(&self) -> Option<String> {
let st = self.session.active()?;
let source = st.path.as_deref().unwrap_or(&st.doc.name);
let stem = std::path::Path::new(source).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty());
Some(stem.unwrap_or_else(|| "Untitled".into()))
}
/// The file a recorded `file.saveAs` step writes: its `path`, or for a step recorded with
/// the document's own name, that folder and extension with the active document's name.
fn recorded_save_path(&self, params: &Value) -> Result<String, String> {
if let Some(path) = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()) {
return Ok(path.to_string());
}
let (Some(folder), Some(ext)) =
(params.get("folder").and_then(Value::as_str), params.get("extension").and_then(Value::as_str).filter(|e| !e.is_empty()))
else {
return Err("Save As needs a `path`, or a `folder` and an `extension`".into());
};
let stem = self.own_file_stem().ok_or("no document")?;
let name = format!("{stem}.{ext}");
Ok(if folder.is_empty() { name } else { std::path::Path::new(folder).join(name).to_string_lossy().into_owned() })
}
/// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]),
/// keeping its Targa, DDS, JPEG or TIFF answer so playback writes the file the same way. A save
/// parked behind Targa, DDS, JPEG or TIFF Options hasn't been journaled yet: it is recorded as
/// File › Save once answered.
pub(crate) fn journal_as_plain_save(&mut self, path: &str) {
/// File › Save once answered. Only a step journaled at index `from` or later (by this save)
/// is re-labeled.
pub(crate) fn journal_as_plain_save(&mut self, path: &str, from: usize) {
let parked = [
self.targa_options.as_mut().map(|p| (&p.path, &mut p.record)),
self.dds_options.as_mut().map(|p| (&p.path, &mut p.record)),
@@ -337,19 +394,34 @@ impl PhotocraftApp {
}
// Only the path goes: a script's format answer stays, so a replay writes the file it is
// played on the same way. Any other answer (TIFF layers) keeps the Save As.
if let Some(last) = self.session.journal.last_mut()
&& last.0 == "file.saveAs"
&& last.1.get("path").and_then(Value::as_str) == Some(path)
// A Save As that kept the document's own name was recorded as its folder (#5305); the
// document now has that file, so it resolves back to `path`.
let journal = &self.session.journal;
let recorded = journal.last().filter(|last| last.0 == "file.saveAs" && journal.len() > from).and_then(|last| self.recorded_save_path(&last.1).ok());
if recorded.is_some_and(|r| std::path::Path::new(&r) == std::path::Path::new(path))
&& let Some(last) = self.session.journal.last_mut()
&& let Some(step) = last.1.as_object()
&& step.keys().all(|k| {
matches!(
k.as_str(),
"path" | "tgaBits" | "ddsCompression" | "ddsMipmaps" | "quality" | "jpegFormat" | "tiffCompression" | "tiffTransparency" | "tiffPyramid"
"path"
| "folder"
| "extension"
| "tgaBits"
| "ddsCompression"
| "ddsMipmaps"
| "quality"
| "jpegFormat"
| "tiffCompression"
| "tiffTransparency"
| "tiffPyramid"
)
})
{
let mut step = step.clone();
step.remove("path");
for k in ["path", "folder", "extension"] {
step.remove(k);
}
*last = ("file.save".into(), Value::Object(step));
}
}
@@ -382,14 +454,15 @@ impl PhotocraftApp {
// Export As has its own settings, its TGA bits included (#7207).
return crate::export_dialog::replay(self, params);
}
let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?;
let choices = FormatChoices::from_step(params, id)?;
if id == "file.saveACopy" {
let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save a Copy needs a `path`")?;
// A copy: the document keeps its own file and unsaved state, as when recorded (#4373).
let doc = self.session.active().ok_or("no document")?.doc.clone();
return crate::view_cmds::write_copy(self, &doc, path, choices);
}
self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), choices)
let path = self.recorded_save_path(params)?;
self.save_as_step(path, params.get("tiffLayers").and_then(Value::as_bool), choices)
}
/// What a save writes by default under the preferences: File Handling › Image Previews
@@ -604,9 +677,15 @@ impl PhotocraftApp {
/// (#2032, #4373), reports a failure in the status bar, and releases a close prompt waiting on
/// the save.
pub(crate) fn write_answered_save(&mut self, ctx: &egui::Context, doc: DocId, path: String, settings: &ExportSettings, copy: bool, step: (&str, Value)) {
let written = self.with_document(doc, |app| app.write_document(path, settings, copy));
let (record, mut step) = step;
let written = self.with_document(doc, |app| {
// Before the save renames the document: a Save As that keeps its own name records
// only the folder (#5305).
app.record_answered_save_as(record, &mut step);
app.write_document(path, settings, copy)
});
if written.is_ok() {
self.session.journal.push((step.0.into(), step.1));
self.session.journal.push((record.into(), step));
}
match written {
Err(e) => {
@@ -687,6 +687,9 @@ fn a_canvas_over_the_texture_budget_draws_its_viewport_pages_on_the_gpu() {
gpu.set_canvas_budget(1);
g.harness.run_steps(3);
assert_eq!(render_path_of(&g), (Some("canvasTooLarge"), Some("CPU Rendering")));
// Its pages and their work are freed meanwhile (#5332 review).
assert!(gpu.pages(key).0.is_empty(), "pages freed");
assert!(!g.harness.state().streams.contains_key(&doc.id), "stream dropped");
gpu.set_canvas_budget(set - 1);
settle_pages(&mut g);
// Closing the document cancels its work and frees its pages.
@@ -696,6 +699,63 @@ fn a_canvas_over_the_texture_budget_draws_its_viewport_pages_on_the_gpu() {
assert!(gpu.pages(key).0.is_empty());
}
/// #5332 review: the open documents share one texture budget rather than each having all of
/// it. A document alone on screen keeps the whole budget; one in a background tab gives its
/// textures up when the shown one needs the room; and documents shown side by side split it,
/// a document over its share drawing from viewport pages.
#[test]
fn open_documents_share_one_texture_budget_with_the_shown_ones_first() {
let _gpu = photocraft_testkit::gpu_lock();
let Some(mut g) = gpu_app() else { return };
let gpu = g.harness.state().gpu.clone().expect("gpu canvas");
let new = |g: &mut Gpu| {
g.harness.state_mut().run("file.new", json!({"width": 3000, "height": 2000, "background": "#336699"})).expect("new");
g.harness.run_steps(3);
assert!(on_gpu_canvas(g), "{:?}", render_path_of(g));
g.harness.state().session.active().expect("document").doc.clone()
};
let a = new(&mut g);
let (set, _) = gpu.canvas_fit(&a).expect("fit");
// Room for one texture set and a half: a document alone on screen has all of it.
let budget = set + set / 2;
gpu.set_canvas_budget(budget);
assert_eq!(gpu.canvas_fit(&a), Some((set, budget)), "alone, the whole budget");
assert!(gpu.texture_info(a.id.0).is_some());
// A second tab: the shown document's texture set takes the room of the background one's.
let b = new(&mut g);
assert_eq!(gpu.canvas_fit(&b), Some((set, budget)));
assert!(gpu.texture_info(b.id.0).is_some());
assert!(gpu.texture_info(a.id.0).is_none(), "the background document's textures are freed");
// Back to the first tab: it gets its texture set back and the other gives its up.
g.harness.state_mut().session.set_active(0);
g.harness.run_steps(3);
assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g));
assert!(gpu.texture_info(a.id.0).is_some());
assert!(gpu.texture_info(b.id.0).is_none());
// Side by side, they split the budget: neither set fits its half, so both draw from pages.
g.harness.state_mut().ui.view.arrange = "twoUpVertical".into();
g.harness.run_steps(3);
assert_eq!(gpu.canvas_fit(&a), Some((set, budget / 2)));
assert_eq!(gpu.canvas_fit(&b), Some((set, budget - budget / 2)));
settle_pages(&mut g);
assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g));
let streams = &g.harness.state().streams;
assert!(streams.contains_key(&a.id) && streams.contains_key(&b.id), "both on pages");
assert!(gpu.texture_info(a.id.0).is_none() && gpu.texture_info(b.id.0).is_none());
// Switching between them re-splits nothing: no page set is dropped.
let held = gpu.pages(crate::canvas::viewport_pages::stream_key(a.id.0)).0;
g.harness.state_mut().session.set_active(1);
g.harness.run_steps(3);
assert_eq!(gpu.canvas_fit(&a), Some((set, budget / 2)));
assert_eq!(gpu.pages(crate::canvas::viewport_pages::stream_key(a.id.0)).0, held, "no churn on a switch");
// Back to tabs: the shown document has the whole budget, and its texture set, again.
g.harness.state_mut().ui.view.arrange = "tabs".into();
g.harness.run_steps(3);
assert_eq!(gpu.canvas_fit(&b), Some((set, budget)));
assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g));
assert!(gpu.texture_info(b.id.0).is_some());
}
#[test]
fn a_flipped_view_records_why_it_draws_on_the_cpu() {
let _gpu = photocraft_testkit::gpu_lock();
+7 -4
View File
@@ -84,11 +84,14 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e
Ok(computed) if finished == request => publish_filter_preview(app, idx, finished, computed.result, computed.ms),
// Mid-drag, a frame for the value the pointer has just left is what makes the
// canvas follow the drag. Three things have to hold for it: it belongs to this
// preview, it has a frame to show (an empty one would blank a good preview), and
// it must not displace a frame that is already exact for the dialog, which is
// what a slider dragged out and back would otherwise make the canvas do.
// preview (at any proxy factor: it is drawn at its own, and a radius drag can move
// the factor, #10474), it has a frame to show (an empty one would blank a good
// preview), and it must not displace a frame that is already exact for the dialog,
// which is what a slider dragged out and back would otherwise make the canvas do.
Ok(computed)
if finished.same_target(&request) && computed.result.is_some() && !app.filter_preview.as_ref().is_some_and(|p| p.key == request) =>
if finished.same_target_at_any_scale(&request)
&& computed.result.is_some()
&& !app.filter_preview.as_ref().is_some_and(|p| p.key == request) =>
{
publish_filter_preview(app, idx, finished, computed.result, computed.ms);
}
+32 -11
View File
@@ -42,6 +42,7 @@ mod callback;
mod doc_textures;
mod pages;
mod perf;
mod residency;
mod resources;
mod setup;
mod shaders;
@@ -349,24 +350,43 @@ impl GpuCanvas {
r.callback_resources.get::<Resources>()?.compositor_budget
}
/// Set the GPU bytes one document's display texture set may take (see [`canvas_budget`]).
/// Set the GPU bytes the open documents' display textures may take together (see
/// [`canvas_budget`] and [`GpuCanvas::show_documents`]).
pub fn set_canvas_budget(&self, bytes: u64) {
self.canvas_budget.store(bytes, Ordering::Relaxed);
}
/// Whether `doc`'s display textures fit [`canvas_budget`]; when they don't, nothing is
/// created for it and it is drawn on the CPU canvas.
/// Whether `doc`'s display textures fit its share of [`canvas_budget`]; when they don't,
/// nothing is created for it and it is drawn from viewport pages or on the CPU canvas.
pub fn fits_canvas(&self, doc: &photocraft_doc::Document) -> bool {
self.canvas_fit(doc).is_some_and(|(bytes, budget)| bytes <= budget)
}
/// The bytes `doc`'s display textures would take and [`canvas_budget`] (`None` without a GPU
/// canvas): the numbers of a [`CpuReason::CanvasTooLarge`].
pub fn canvas_fit(&self, doc: &photocraft_doc::Document) -> Option<(u64, u64)> {
/// The bytes `doc`'s whole-document texture set would take.
pub fn canvas_bytes(&self, doc: &photocraft_doc::Document) -> u64 {
let size = [doc.size.width, doc.size.height];
let bytes = canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size)));
canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size)))
}
/// The bytes `doc`'s display textures would take and its share of [`canvas_budget`] (`None`
/// without a GPU canvas): the numbers of a [`CpuReason::CanvasTooLarge`]. Alone on screen a
/// document has the whole budget; see [`GpuCanvas::show_documents`].
pub fn canvas_fit(&self, doc: &photocraft_doc::Document) -> Option<(u64, u64)> {
let bytes = self.canvas_bytes(doc);
let r = self.rs.renderer.read();
r.callback_resources.get::<Resources>().map(|res| (bytes, res.canvas_budget.load(Ordering::Relaxed)))
r.callback_resources.get::<Resources>().map(|res| (bytes, res.residency.share(res.canvas_budget.load(Ordering::Relaxed), doc.id.0, bytes)))
}
/// The documents the app's windows show now, with [`GpuCanvas::canvas_bytes`] of each: they
/// split [`canvas_budget`] (see [`residency`]), and the textures of documents not among them
/// are freed first when a shown one needs the room. Cheap when nothing changed.
pub fn show_documents(&self, shown: &[(u64, u64)]) {
if self.rs.renderer.read().callback_resources.get::<Resources>().is_none_or(|res| res.residency.shows(shown)) {
return;
}
if let Some(res) = self.rs.renderer.write().callback_resources.get_mut::<Resources>() {
res.residency.publish(shown);
}
}
/// Act as if the driver couldn't build the compositor's pipelines (the DX12 FXC X3507 case,
@@ -513,7 +533,7 @@ impl GpuCanvas {
let mut renderer = self.rs.renderer.write();
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
if res.docs.get(&doc).is_none_or(|d| d.size != size || d.format != format) {
let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else {
let Some(tex) = DocTextures::new_for(device, res, doc, size, self.tile, format) else {
res.docs.remove(&doc);
return;
};
@@ -646,7 +666,7 @@ impl GpuCanvas {
}
}
if fresh {
let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else {
let Some(tex) = DocTextures::new_for(device, res, key, size, self.tile, format) else {
res.docs.remove(&key);
res.compositor = Some(comp);
return Err(photocraft_gpu::Unsupported(OVER_CANVAS_BUDGET.into()));
@@ -861,7 +881,7 @@ impl GpuCanvas {
let mut renderer = self.rs.renderer.write();
let res = renderer.callback_resources.get_mut::<Resources>()?;
if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) {
let Some(tex) = DocTextures::new(&self.rs.device, res, size, self.tile, format) else {
let Some(tex) = DocTextures::new_for(&self.rs.device, res, key, size, self.tile, format) else {
res.docs.remove(&key);
return None;
};
@@ -886,6 +906,7 @@ impl GpuCanvas {
// LUTs and identity-transform signatures can outlive document textures. Prune both,
// or a preserved-ID reopen can skip rebuilding a missing LUT.
res.luts.retain(|k, _| live.contains(&k.0));
res.residency.retain(|d| live.contains(&d));
res.display_lut_signatures.retain(|k, _| live.contains(&k.0));
}
@@ -44,6 +44,24 @@ pub(super) fn tile_uniform([x, y, w, h]: [u32; 4], core: Option<[i32; 4]>) -> [f
[x as f32, y as f32, w as f32, h as f32, cx0, cy0, cx1, cy1]
}
/// Run `f` with its validation and out-of-memory errors caught, rather than reaching the
/// device's uncaptured-error handler (which takes the GPU canvas down for the session): what `f`
/// returned, and the first error. Native wgpu reports these synchronously; where they arrive
/// later (WebGPU) none is seen.
pub(super) fn catch_gpu_errors<T>(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option<wgpu::Error>) {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let out = f();
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
let mut error = None;
for pop in [memory.pop(), validation.pop()] {
if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) {
error.get_or_insert(e);
}
}
(out, error)
}
impl DocTextures {
/// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit
/// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB
@@ -54,17 +72,7 @@ impl DocTextures {
if bytes > res.canvas_budget.load(Ordering::Relaxed) {
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let set = Self::create(device, res, size, tile, format);
// Native wgpu reports these synchronously; where they arrive later (WebGPU) none is seen.
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
let mut error = None;
for pop in [memory.pop(), validation.pop()] {
if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) {
error.get_or_insert(e);
}
}
let (set, error) = catch_gpu_errors(device, || Self::create(device, res, size, tile, format));
if let Some(e) = error {
log::warn!("{} MB of canvas textures failed ({e}); larger canvases are drawn on the CPU", bytes >> 20);
res.canvas_budget.fetch_min(bytes.saturating_sub(1), Ordering::Relaxed);
@@ -73,6 +81,16 @@ impl DocTextures {
Some(set)
}
/// [`DocTextures::new`] for key `key`, first freeing the textures of documents no window
/// shows when the open documents' together wouldn't fit the canvas budget.
pub(super) fn new_for(device: &wgpu::Device, res: &mut Resources, key: u64, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option<Self> {
let bytes = canvas_texture_bytes(size, tile, texel_bytes(format));
if bytes <= res.canvas_budget.load(Ordering::Relaxed) {
res.make_room(key, bytes);
}
Self::new(device, res, size, tile, format)
}
fn create(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Self {
let mut tiles = Vec::new();
for ty in (0..size[1]).step_by(tile as usize) {
@@ -66,6 +66,37 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() {
}
// On the same device (see the top of the file).
canvas_textures_the_gpu_refuses_leave_it_working(&gpu);
refused_viewport_page_work_is_caught(&gpu);
}
/// #5332 review: viewport page uploads run their texture, buffer and bind group creation and
/// their write inside the same error scopes as a texture set, so an error the GPU raises there
/// (out of memory, in use) is returned instead of disabling the GPU for the session. A texture
/// wider than the device allows is refused the same way, synchronously, on any adapter.
fn refused_viewport_page_work_is_caught(gpu: &GpuCanvas) {
let device = &gpu.rs.device;
let max = device.limits().max_texture_dimension_2d;
let (texture, error) = super::doc_textures::catch_gpu_errors(device, || {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("too_wide_page"),
size: wgpu::Extent3d { width: max + 1, height: 1, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
});
drop(texture);
assert!(error.is_some(), "the refusal is caught");
assert!(gpu.health.is_ok(), "{:?}", gpu.fault());
// A page the GPU accepts is stored as before.
let buf = Buffer::filled(Rect::from_xywh(0, 0, 64, 64), [0.5, 0.5, 0.5, 1.0]);
let core = Rect::new(1, 1, 63, 63);
assert!(gpu.upload_page(PageUpload { key: 9, page: 1, size: [64, 64], depth: SampleType::U8, factor: 1, core, buf: &buf }));
assert_eq!(gpu.pages(9).0, vec![1]);
assert!(gpu.health.is_ok(), "{:?}", gpu.fault());
}
/// #4017: a 3 GP canvas's 16 GB of textures failed with Out of Memory, and the uncaptured error
+44 -28
View File
@@ -2,7 +2,7 @@
//! `canvas::viewport_pages`): independent single-level textures at their own reduction, drawn
//! by the same tile pipeline. They live under their own key, never next to a texture set.
use super::doc_textures::{init_buffer, tile_uniform};
use super::doc_textures::{catch_gpu_errors, init_buffer, tile_uniform};
use super::resources::{DocTextures, Resources, Tile, f32_bytes};
use super::texels::{texel_bytes, texels};
use super::{GpuCanvas, Ordering, wgpu};
@@ -108,34 +108,49 @@ impl GpuCanvas {
if held.saturating_add(bytes) > budget {
return false;
}
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("pc_viewport_page"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
// Room among the other documents: those no window shows give theirs up first.
res.make_room(key, held + bytes);
let rect = [x, y, dw, dh];
let uniform =
init_buffer(device, "pc_viewport_page", &f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))), wgpu::BufferUsages::UNIFORM);
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("pc_viewport_page"),
layout: &res.tile_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() },
],
// A page the GPU refuses (out of memory) fails alone: it falls back to the CPU canvas
// instead of the uncaptured error disabling the GPU canvas for the session.
let ((texture, view, uniform, bind_group), error) = catch_gpu_errors(device, || {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("pc_viewport_page"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let uniform = init_buffer(
device,
"pc_viewport_page",
&f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))),
wgpu::BufferUsages::UNIFORM,
);
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("pc_viewport_page"),
layout: &res.tile_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() },
],
});
queue.write_texture(
texture.as_image_copy(),
&texels(format, &buf.px),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
(texture, view, uniform, bind_group)
});
queue.write_texture(
texture.as_image_copy(),
&texels(format, &buf.px),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
if let Some(e) = error {
log::warn!("a {w}×{h} viewport page couldn't be stored on the GPU: {e}");
return false;
}
let Some(d) = res.docs.get_mut(&key) else { return false };
if let Some(i) = replaced {
d.tiles.remove(i);
@@ -176,7 +191,8 @@ impl GpuCanvas {
(d.tiles.iter().filter_map(|t| t.page.map(|(id, _)| id)).collect(), d.page_bytes())
}
/// The per-document display texture budget ([`super::canvas_budget`]) pages are held to.
/// The display texture budget all documents share ([`super::canvas_budget`]); a document's
/// pages are held to its share ([`GpuCanvas::canvas_fit`]).
pub fn canvas_budget(&self) -> u64 {
self.canvas_budget.load(Ordering::Relaxed)
}
@@ -0,0 +1,209 @@
//! One display-texture budget ([`super::canvas_budget`]) for every open document, rather than
//! the whole budget for each (N documents could hold N times it, #5332 review).
//!
//! The app publishes the documents its windows show and what each one's whole-document texture
//! set needs ([`super::GpuCanvas::show_documents`]). Those split the budget max-min fairly: a
//! document that fits its share keeps its texture set, a larger one is drawn from viewport pages
//! within it, and a document alone on screen still has the whole budget. The split changes only
//! when the shown documents (or their sizes) do, never with focus, so switching between windows
//! moves nothing. Documents no window shows have no share: they keep their textures while there
//! is room (switching back to a tab is free) and are the first freed, least recently shown
//! first, when a shown document needs the room.
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use super::resources::{DocTextures, Resources};
use super::texels::texel_bytes;
/// Keys tagged above this bit are previews (filter preview, adjustment proxies, gradients,
/// thumbnails): never evicted for another document.
const TAG_BITS: u64 = !((1u64 << 58) - 1);
/// The tag of a document's viewport pages (`viewport_pages::stream_key`).
const PAGES_BIT: u64 = 1u64 << 57;
impl DocTextures {
/// GPU bytes of every texture (all mip levels) in this set or page set.
pub(super) fn bytes(&self) -> u64 {
let bpp = texel_bytes(self.format);
let level = |w: u32, h: u32, l: u32| u64::from((w >> l).max(1)) * u64::from((h >> l).max(1)) * bpp;
self.tiles.iter().map(|t| (0..t.texture.mip_level_count()).map(|l| level(t.texture.width(), t.texture.height(), l)).sum::<u64>()).sum()
}
}
impl Resources {
/// Free the textures of documents no window shows ([`Residency::victims`]) so that `key`
/// can hold `bytes` within the canvas budget.
pub(super) fn make_room(&mut self, key: u64, bytes: u64) {
let budget = self.canvas_budget.load(Ordering::Relaxed);
let held: Vec<(u64, u64)> = self.docs.iter().map(|(k, d)| (*k, d.bytes())).collect();
for k in self.residency.victims(&held, key, bytes, budget) {
log::debug!("freeing the display textures of background key {k:#x} for {key:#x}");
self.docs.remove(&k);
}
}
}
/// Document `doc`'s allocation when documents needing `needs` bytes split `budget` max-min
/// fairly (`doc` indexes `needs`): those needing less than an equal share take what they need,
/// the rest split what is left; the leftover, if any, is `doc`'s too.
pub(super) fn fair_share(budget: u64, needs: &[u64], doc: usize) -> u64 {
let mut order: Vec<usize> = (0..needs.len()).collect();
order.sort_by_key(|&i| needs[i]);
let mut left = budget;
let mut others = 0u64;
for (n, &i) in order.iter().enumerate() {
let take = needs[i].min(left / (order.len() - n) as u64);
left -= take;
if i != doc {
others += take;
}
}
budget - others
}
/// The documents on screen and when each was last shown.
#[derive(Default)]
pub(super) struct Residency {
/// Shown documents: (document id, bytes of its whole texture set).
shown: Vec<(u64, u64)>,
/// When each document was last among the shown (a counter of changes).
seen: HashMap<u64, u64>,
tick: u64,
}
impl Residency {
/// Record the shown documents. False when nothing changed.
pub(super) fn publish(&mut self, shown: &[(u64, u64)]) -> bool {
if self.shown == shown {
return false;
}
self.tick += 1;
for (id, _) in shown {
self.seen.insert(*id, self.tick);
}
self.shown = shown.to_vec();
true
}
/// The share of `budget` document `id` (its texture set `need` bytes) may hold: the whole
/// budget when nothing else is shown.
pub(super) fn share(&self, budget: u64, id: u64, need: u64) -> u64 {
let mut needs: Vec<u64> = self.shown.iter().filter(|(d, _)| *d != id).map(|(_, n)| *n).collect();
needs.push(need);
fair_share(budget, &needs, needs.len() - 1)
}
/// Which held keys (`held`: key and bytes) to free, in order, so that `key` can hold
/// `bytes` within `budget`: the texture sets and pages of documents no window shows, least
/// recently shown first. Never a shown document's, nor `key`'s own document's, nor a preview
/// or a key of a document never shown (an adjustment proxy). It may not free enough: what
/// a shown document takes is bounded by its share instead.
pub(super) fn victims(&self, held: &[(u64, u64)], key: u64, bytes: u64, budget: u64) -> Vec<u64> {
let owner = |k: u64| (k & TAG_BITS == 0).then_some(k & !PAGES_BIT);
let mut total = held.iter().filter(|(k, _)| *k != key).map(|(_, b)| *b).fold(bytes, u64::saturating_add);
if total <= budget {
return Vec::new();
}
let mine = owner(key);
let mut cold: Vec<(u64, u64, u64)> = held
.iter()
.filter_map(|&(k, b)| {
let d = owner(k)?;
let at = *self.seen.get(&d)?;
(Some(d) != mine && !self.shown.iter().any(|(s, _)| *s == d)).then_some((at, k, b))
})
.collect();
cold.sort_unstable();
let mut out = Vec::new();
for (_, k, b) in cold {
if total <= budget {
break;
}
total -= b;
out.push(k);
}
out
}
/// Whether `shown` is what was published last.
pub(super) fn shows(&self, shown: &[(u64, u64)]) -> bool {
self.shown == shown
}
/// Forget closed documents.
pub(super) fn retain(&mut self, live: impl Fn(u64) -> bool) {
self.seen.retain(|d, _| live(*d));
self.shown.retain(|(d, _)| live(*d));
}
}
#[cfg(test)]
mod tests {
use super::*;
const GB: u64 = 1 << 30;
#[test]
fn one_document_has_the_whole_budget() {
let r = Residency::default();
assert_eq!(r.share(4 * GB, 1, 9 * GB), 4 * GB);
let mut r = Residency::default();
r.publish(&[(1, 9 * GB)]);
assert_eq!(r.share(4 * GB, 1, 9 * GB), 4 * GB, "shown alone");
assert_eq!(r.share(4 * GB, 1, GB), 4 * GB);
}
#[test]
fn shown_documents_split_the_budget_max_min_fairly() {
let mut r = Residency::default();
// A small document takes what it needs; the large one the rest.
r.publish(&[(1, GB), (2, 9 * GB)]);
assert_eq!(r.share(4 * GB, 1, GB), GB);
assert_eq!(r.share(4 * GB, 2, 9 * GB), 3 * GB);
// What the others leave is the asking document's too.
r.publish(&[(1, GB), (2, 2 * GB)]);
assert_eq!(r.share(4 * GB, 2, 2 * GB), 3 * GB, "the leftover is its own too");
// Two large ones split it evenly, whichever has focus: no churn switching windows.
r.publish(&[(1, 3 * GB), (2, 3 * GB)]);
assert_eq!(r.share(4 * GB, 1, 3 * GB), 2 * GB);
assert_eq!(r.share(4 * GB, 2, 3 * GB), 2 * GB);
// Three, one of them small.
r.publish(&[(1, 3 * GB), (2, 3 * GB), (3, GB / 2)]);
assert_eq!(r.share(4 * GB, 1, 3 * GB), 7 * GB / 4);
assert_eq!(r.share(4 * GB, 3, GB / 2), GB / 2, "needs only half a GB");
// Everything fits: each share covers its need, together within the budget.
r.publish(&[(1, GB), (2, GB)]);
assert!(r.share(4 * GB, 1, GB) >= GB && r.share(4 * GB, 2, GB) >= GB);
// A document no window shows (a refresh of a background tab) is priced as one more.
assert_eq!(r.share(4 * GB, 7, 4 * GB), 2 * GB);
}
#[test]
fn republishing_the_same_documents_changes_nothing() {
let mut r = Residency::default();
assert!(r.publish(&[(1, GB)]));
assert!(!r.publish(&[(1, GB)]));
assert!(r.publish(&[(1, 2 * GB)]), "the document was resized");
}
#[test]
fn background_documents_are_freed_least_recently_shown_first() {
let mut r = Residency::default();
// Tabs 1, 2, 3 shown in turn, then 4.
for d in 1..=4 {
r.publish(&[(d, GB)]);
}
let pages = |d: u64| d ^ PAGES_BIT;
let held = [(1, GB), (pages(2), GB / 2), (2, GB / 2), (3, GB), (1 << 61 | 3, GB), (99, GB)];
// Room enough: nothing goes.
assert!(r.victims(&held, 4, GB, 16 * GB).is_empty());
// 4 needs 2 GB within 6 GB (5 held): tab 1 goes first, then tab 2's pages and set.
assert_eq!(r.victims(&held, 4, 2 * GB, 6 * GB), vec![1]);
assert_eq!(r.victims(&held, 4, 3 * GB, 6 * GB), vec![1, 2, pages(2)]);
// Never a shown document's, a preview's or an unknown key's, even when that isn't enough.
assert_eq!(r.victims(&held, 4, 10 * GB, 6 * GB), vec![1, 2, pages(2), 3]);
// Nor the document's own: its pages make room by evicting the other tabs.
assert_eq!(r.victims(&held, pages(3), GB, 4 * GB), vec![1, 2, pages(2)]);
}
}
@@ -36,8 +36,10 @@ pub(super) struct Resources {
pub(super) layer_fit: Option<LayerFit>,
pub(super) compositor_cache_budget: Option<u64>,
pub(super) compositor_focus: Option<photocraft_geom::Rect>,
/// GPU bytes one document's display texture set may take (see [`canvas_budget`]).
/// GPU bytes the documents' display textures may take together (see [`canvas_budget`]).
pub(super) canvas_budget: std::sync::Arc<std::sync::atomic::AtomicU64>,
/// How that budget is split between the open documents (see [`super::residency`]).
pub(super) residency: super::residency::Residency,
pub(super) encode_bgl: wgpu::BindGroupLayout,
encode_pipeline: wgpu::RenderPipeline,
pub(super) lut_bgl: wgpu::BindGroupLayout,
@@ -369,6 +371,7 @@ impl Resources {
compositor_cache_budget: None,
compositor_focus: None,
canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))),
residency: Default::default(),
encode_bgl,
encode_pipeline,
}
+38 -20
View File
@@ -71,9 +71,13 @@ impl Span {
}
}
/// Clamp one axis of the view centre with Overscroll off, as Photoshop 25.4 does: an image larger
/// than the view can't leave its edges, and one smaller than the view stays inside it, where
/// zooming put it (zooming out around the pointer doesn't re-centre it; Fit on Screen does).
/// Clamp one axis of the view centre with Overscroll off: an image larger than the view can't
/// leave its edges, and one smaller than the view is centred in it, so zooming out around the
/// pointer keeps it in the middle (#10488, #4799).
///
/// #1718 measured Photoshop 25.4 leaving a smaller image where zooming put it. Two reports of
/// Photoshop itself say it stays centred with Overscroll off, which is what turning Overscroll off
/// is asked for, so PhotoCraft centres it; Overscroll on keeps the free view.
pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 {
if !(zoom.is_finite() && zoom > 0.0 && len.is_finite() && len > 0.0 && size.is_finite() && size > 0.0) {
return center;
@@ -82,9 +86,11 @@ pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 {
return len / 2.0;
}
let half = size / zoom / 2.0;
// Larger: the view stays within the image. Smaller: the image stays within the view.
let (a, b) = (half, len - half);
center.clamp(a.min(b), a.max(b))
// Smaller than the view (or exactly its size): centred. Larger: the view stays within it.
if 2.0 * half >= len {
return len / 2.0;
}
center.clamp(half, len - half)
}
/// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points on a
@@ -112,7 +118,7 @@ pub fn clamp_view_with_aspect(view: &mut View, size: egui::Vec2, ppp: f32, aspec
if view.center[0].is_finite() { view.center[0] } else { w as f32 / 2.0 },
if view.center[1].is_finite() { view.center[1] } else { h as f32 / 2.0 },
];
let xf = crate::canvas::ViewXform { rect: egui::Rect::from_min_size(Pos2::ZERO, size), zoom, center, flip: false, rotation: view.rotation, aspect };
let xf = crate::canvas::ViewXform { rect: Rect::from_min_size(Pos2::ZERO, size), zoom, center, flip: false, rotation: view.rotation, aspect };
let image = xf.image_bounds(view.doc_size);
if !image.is_finite() {
return false;
@@ -309,12 +315,13 @@ mod tests {
assert_eq!(clamp_axis(-400.0, 1000.0, 500.0, 1.0), 250.0);
assert_eq!(clamp_axis(4000.0, 1000.0, 500.0, 1.0), 750.0);
assert_eq!(clamp_axis(600.0, 1000.0, 500.0, 1.0), 600.0);
// Smaller than the view (2500 doc px of view for 1000 of image): it stays where it is
// while it is inside the view, and is pushed back in at the edges.
assert_eq!(clamp_axis(-90.0, 1000.0, 500.0, 0.2), -90.0);
assert_eq!(clamp_axis(-400.0, 1000.0, 500.0, 0.2), -250.0, "the image's right edge on the view's");
assert_eq!(clamp_axis(1400.0, 1000.0, 500.0, 0.2), 1250.0, "its left edge on the view's");
// Smaller than the view (2500 doc px of view for 1000 of image): centred, wherever zooming
// around the pointer left it (#10488).
for center in [-90.0, -400.0, 300.0, 1400.0] {
assert_eq!(clamp_axis(center, 1000.0, 500.0, 0.2), 500.0, "{center}");
}
assert_eq!(clamp_axis(500.0, 1000.0, 1000.0, 1.0), 500.0, "exactly the view's size: centred");
assert_eq!(clamp_axis(10.0, 1000.0, 1000.0, 1.0), 500.0);
let mut v = View {
zoom: 1.0,
center: [-500.0, 20.0],
@@ -332,6 +339,20 @@ mod tests {
assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0), "stable: no repaint loop");
}
#[test]
fn overscroll_off_centres_a_fitting_rotated_image() {
// 600 px at 45 degrees is about 849 points across, so it fits a 900-point view both ways.
// Started off centre, the clamp centres its screen-space bounds in the view (#10488).
let mut v = View { zoom: 1.0, center: [100.0, 450.0], doc_size: [600, 600], rotation: 45.0, ..Default::default() };
let size = vec2(900.0, 900.0);
assert!(clamp_view_with_aspect(&mut v, size, 1.0, 1.0));
let rect = Rect::from_min_size(Pos2::ZERO, size);
let xf = crate::canvas::ViewXform { rect, zoom: 1.0, center: v.center, flip: false, rotation: v.rotation, aspect: 1.0 };
let c = xf.image_bounds(v.doc_size).center();
assert!((c - xf.rect.center()).length() < 1e-2, "centred: {c:?}");
assert!(!clamp_view_with_aspect(&mut v, size, 1.0, 1.0), "stable: no repaint loop");
}
#[test]
fn hostile_numbers_are_harmless() {
for z in [0.0, -1.0, f32::NAN, f32::INFINITY] {
@@ -516,16 +537,13 @@ mod tests {
} else {
assert!((x - w / 2.0).abs() < 1e-3, "clamped to the left edge: {x}");
}
// Zoomed out below fit (Photoshop): the image is not re-centred, it stays where it
// is while it fits in the view, and is pushed back in at the view's edge.
// Zoomed out below fit: the image is centred in the view on both axes, wherever
// the zoom left it (#10488).
set_view(&mut h, 0.1, [-50.0, 9000.0]);
if !overscroll {
let r = h.state().last_canvas_rect;
let (hw, hh) = (r.width() / 0.1 / 2.0, r.height() / 0.1 / 2.0);
let c = view(&h).center;
assert_eq!(c[0], -50.0, "inside the view: left alone");
assert!((c[1] - hh).abs() < 1e-2, "pushed back in: the image's top on the view's: {c:?}");
assert!(c[0] - hw <= 0.0 && c[0] + hw >= 2000.0 && c[1] - hh <= 0.0 && c[1] + hh >= 1500.0, "{c:?}");
assert_eq!(view(&h).center, [1000.0, 750.0], "centred");
} else {
assert_eq!(view(&h).center, [-50.0, 9000.0], "overscroll on: the view is free");
}
}
}
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more