diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index fa9b1a655e..c3583bf83d 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -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, + /// 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, + /// 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, /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget #[arg(long)] pub(crate) approve_high_memory: bool, diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index a9f85fddb1..347f829443 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -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 = 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 = 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 `.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 = 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> { - 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(()) } } diff --git a/apps/photocraft-cli/src/error.rs b/apps/photocraft-cli/src/error.rs index 3e4aa3caef..55e8df48e2 100644 --- a/apps/photocraft-cli/src/error.rs +++ b/apps/photocraft-cli/src/error.rs @@ -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), } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 5c5ffc7870..386eafe62c 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -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::>()).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::>()).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; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d9ddcf64d8..7741a8d8a3 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -685,7 +685,7 @@ pub fn native(automation: Option) -> }); 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; diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs index 5c238a3b51..adb6d8eea6 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -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 { + 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. diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index e5929f2864..5c74340355 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -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 diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index a60cc66a16..3e068258fe 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -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 }; diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index e96ccb369d..5ea4a5b3fe 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -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() { diff --git a/crates/compose/src/viewport.rs b/crates/compose/src/viewport.rs index c7a5cc3c10..40a6accee9 100644 --- a/crates/compose/src/viewport.rs +++ b/crates/compose/src/viewport.rs @@ -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 { + // 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 { 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 { diff --git a/crates/engine/src/actions_record.rs b/crates/engine/src/actions_record.rs new file mode 100644 index 0000000000..4bfb54befa --- /dev/null +++ b/crates/engine/src/actions_record.rs @@ -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>, + active_layer: Option, + /// 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, +} + +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; diff --git a/crates/engine/src/actions_record/tests.rs b/crates/engine/src/actions_record/tests.rs new file mode 100644 index 0000000000..dfffc2c08f --- /dev/null +++ b/crates/engine/src/actions_record/tests.rs @@ -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) = ©.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"); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index e2e9c3bd76..df40333b90 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -1098,7 +1098,7 @@ fn build() -> Vec { }; 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", diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index d0be63bf5e..ddeeb8c13f 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -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 { 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 { 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 { 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) -> Vec<(String, Value)> { + let mut out: Vec<(String, Value)> = Vec::new(); + let mut last_run: Option = 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 { not_playing(s, "actions.stop")?; let Some((_, idx)) = s.actions.recording else { diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 02bc22ae35..8f38b142d5 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -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", ¶ms).is_err(), "{params}"); + } +} + #[test] fn recorded_single_layer_transform_replays_after_layer_ids_shift() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/adjust_cmds.rs b/crates/engine/src/cmd/adjust_cmds.rs index f5301979dc..d974576a53 100644 --- a/crates/engine/src/cmd/adjust_cmds.rs +++ b/crates/engine/src/cmd/adjust_cmds.rs @@ -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 { + 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 { "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 ), diff --git a/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs b/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs new file mode 100644 index 0000000000..574610523c --- /dev/null +++ b/crates/engine/src/cmd/adjust_cmds/replace_color_range.rs @@ -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>> { + 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> { + 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::(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); + } +} diff --git a/crates/engine/src/cmd/artboard_cmds.rs b/crates/engine/src/cmd/artboard_cmds.rs index 2f05ddfae0..f9e31e4cd0 100644 --- a/crates/engine/src/cmd/artboard_cmds.rs +++ b/crates/engine/src/cmd/artboard_cmds.rs @@ -301,22 +301,38 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result { 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 { + 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 { fn set_props(s: &mut Session, p: &Value) -> Result { 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 { fn add_beside(s: &mut Session, p: &Value) -> Result { 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 { "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 ), diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 47f4b688fb..41078987cd 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -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; diff --git a/crates/engine/src/cmd/atn_cmds/adjustments.rs b/crates/engine/src/cmd/atn_cmds/adjustments.rs index ece241f3cf..383b0b6267 100644 --- a/crates/engine/src/cmd/atn_cmds/adjustments.rs +++ b/crates/engine/src/cmd/atn_cmds/adjustments.rs @@ -359,7 +359,13 @@ fn gradient_map_from(d: &Descriptor) -> Option { Some(out) } +/// Write the legacy gradient form without absorbing settings owned by a direct recorded event. fn gradient_map_to(p: &Value) -> Option { + // 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> = p .get("stops")? .as_array()? @@ -375,16 +381,7 @@ fn gradient_map_to(p: &Value) -> Option { )) }) .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 { Some(d) } +/// The classic, opaque gradient shared by direct and adjustment-layer mappings. +pub(super) fn gradient_descriptor(stops: Vec, 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.`, `layer.newAdjustmentLayer.`), and /// the converters between the adjustment descriptor and the command params. diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index a29b4a1957..87d327c2e5 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -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 { 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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", diff --git a/crates/engine/src/cmd/atn_cmds/steps_artboard.rs b/crates/engine/src/cmd/atn_cmds/steps_artboard.rs new file mode 100644 index 0000000000..de7b7be9e3 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_artboard.rs @@ -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 { + 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 = guides.iter().map(|v| if let D::Integer(n) = v { Some(*n) } else { None }).collect::>()?; + let recorded: Vec = ["Rd ", "Grn ", "Bl "].iter().map(|key| get_num(color, key)).collect::>()?; + let rgb: Vec = 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 { + 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::>>()?; + let rgb = p.get("color")?.as_array()?; + if rgb.len() != 3 { + return None; + } + let rgb: Vec = rgb.iter().map(Value::as_f64).collect::>()?; + 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::>>()?; + 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 { + 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::>>() 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 { + 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)), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_direct_adjustments.rs b/crates/engine/src/cmd/atn_cmds/steps_direct_adjustments.rs new file mode 100644 index 0000000000..a3e0bf4c9e --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_direct_adjustments.rs @@ -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 { + 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::>>()?; + 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 { + 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)), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 9b23dd1c10..c0c442f443 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -44,9 +44,12 @@ fn path_extension(path: &str) -> Option { } /// `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 { 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 { - 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), diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 07c71040a5..9ce3bbb8f7 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -172,8 +172,16 @@ fn new_document_from_descriptor(d: &Descriptor) -> Option { /// 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 { + 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") { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f153ee83af..f468f72432 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -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", &[©], &[], 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::>(), [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", ¶ms, "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] diff --git a/crates/engine/src/cmd/channel_cmds/targeting.rs b/crates/engine/src/cmd/channel_cmds/targeting.rs index 899ecb73f1..13926d1d89 100644 --- a/crates/engine/src/cmd/channel_cmds/targeting.rs +++ b/crates/engine/src/cmd/channel_cmds/targeting.rs @@ -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. diff --git a/crates/engine/src/cmd/edit_menu_cmds.rs b/crates/engine/src/cmd/edit_menu_cmds.rs index cef435e46a..b4287e649e 100644 --- a/crates/engine/src/cmd/edit_menu_cmds.rs +++ b/crates/engine/src/cmd/edit_menu_cmds.rs @@ -345,7 +345,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs b/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs index 928f315bdf..c979d8114b 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/content_aware.rs @@ -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)?; diff --git a/crates/engine/src/cmd/edit_menu_cmds/tests.rs b/crates/engine/src/cmd/edit_menu_cmds/tests.rs index 03cd97679f..fb8dcacdcf 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/tests.rs @@ -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); diff --git a/crates/engine/src/cmd/extra_cmds.rs b/crates/engine/src/cmd/extra_cmds.rs index e523b4b5e6..2faefdf9e8 100644 --- a/crates/engine/src/cmd/extra_cmds.rs +++ b/crates/engine/src/cmd/extra_cmds.rs @@ -877,9 +877,15 @@ pub fn specs() -> Vec { 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!( diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 5d78a8f72a..4f555c2a61 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -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 { "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 ), diff --git a/crates/engine/src/cmd/file_cmds/batch_naming.rs b/crates/engine/src/cmd/file_cmds/batch_naming.rs index 8e14bcf428..89b5203409 100644 --- a/crates/engine/src/cmd/file_cmds/batch_naming.rs +++ b/crates/engine/src/cmd/file_cmds/batch_naming.rs @@ -9,9 +9,67 @@ fn bad(message: impl Into) -> 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 { + 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>> { @@ -25,10 +83,25 @@ fn parse(template: &str) -> Result>> { } 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::().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>> { 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>> { +pub fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result>> { 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(×tamp).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}"); + } } } diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs index 5d500f549f..1a981298c0 100644 --- a/crates/engine/src/cmd/file_cmds/batch_run.rs +++ b/crates/engine/src/cmd/file_cmds/batch_run.rs @@ -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> { /// 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 Result> { +pub fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result> { 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![(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= 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 Result> { +pub fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result> { 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) -> 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, +} + +impl BatchPolicy { + fn parse(p: &Value, cmd: &str) -> Result { + 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) -> Vec { + 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 { + path.and_then(|path| write_error_log(path, errors).err().map(|error| error.to_string())) +} + +fn batch_outcome(files: Vec, errors: Vec, stopped: Option<(String, Vec)>, 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, outputs: impl IntoIterator) -> 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, +) -> Result { + 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 { 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 { // 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 { 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> = 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 { + 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 = 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> = 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) } diff --git a/crates/engine/src/cmd/file_cmds/paths.rs b/crates/engine/src/cmd/file_cmds/paths.rs index 8f3abdc6b8..928325e92f 100644 --- a/crates/engine/src/cmd/file_cmds/paths.rs +++ b/crates/engine/src/cmd/file_cmds/paths.rs @@ -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> { let rd = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?; - let mut out: Vec = 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, b: impl AsRef) 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 { + 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 { + 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 { + 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, b: impl AsRef) -> 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` diff --git a/crates/engine/src/cmd/file_cmds/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index 0f784671c8..681a466366 100644 --- a/crates/engine/src/cmd/file_cmds/save.rs +++ b/crates/engine/src/cmd/file_cmds/save.rs @@ -254,6 +254,13 @@ pub(crate) fn save_doc(doc: &Document, path: &str, save: impl Into) -> 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) -> Result> { + 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 { let path = str_param(p, "path", "file.saveACopy")?.to_string(); let d = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index ff66258820..804e6a01ca 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -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 = 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 = 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 = 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 = 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(); diff --git a/crates/engine/src/cmd/fill_key_cmds.rs b/crates/engine/src/cmd/fill_key_cmds.rs index 16585a33d6..b2135984e2 100644 --- a/crates/engine/src/cmd/fill_key_cmds.rs +++ b/crates/engine/src/cmd/fill_key_cmds.rs @@ -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 { @@ -31,7 +31,7 @@ fn target_live_layer(s: &Session, p: &Value) -> Option { 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]); + } } diff --git a/crates/engine/src/cmd/frame_cmds.rs b/crates/engine/src/cmd/frame_cmds.rs index c2cd6283a7..9bfcb257de 100644 --- a/crates/engine/src/cmd/frame_cmds.rs +++ b/crates/engine/src/cmd/frame_cmds.rs @@ -124,10 +124,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result { 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 = diff --git a/crates/engine/src/cmd/fx_visibility_cmds.rs b/crates/engine/src/cmd/fx_visibility_cmds.rs index 7911bcd3cc..dadb8f888b 100644 --- a/crates/engine/src/cmd/fx_visibility_cmds.rs +++ b/crates/engine/src/cmd/fx_visibility_cmds.rs @@ -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 { 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 { #[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() } diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 1553d0564f..cd5af85152 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -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 { + 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 { + 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 { 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 { 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 { } 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 { 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> { /// 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 { +/// 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 { 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 { "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 ), diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index 64c3e5028f..fc4ce0bcf9 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -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(); diff --git a/crates/engine/src/cmd/layer_multi_cmds.rs b/crates/engine/src/cmd/layer_multi_cmds.rs index 79abb250a3..ada040e8b7 100644 --- a/crates/engine/src/cmd/layer_multi_cmds.rs +++ b/crates/engine/src/cmd/layer_multi_cmds.rs @@ -134,9 +134,15 @@ pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index 49f9bdae29..e5e361fb1c 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -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 } diff --git a/crates/engine/src/cmd/layer_multi_cmds/structure.rs b/crates/engine/src/cmd/layer_multi_cmds/structure.rs index 70aa512223..228372e293 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/structure.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/structure.rs @@ -3,13 +3,28 @@ use super::{Document, EngineError, Layer, LayerContent, LayerId, PixelFormat, Re // ---------- structural commands ---------- -pub(super) fn link_layers(s: &mut Session) -> Result { +/// 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 { + 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> = 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::>>()?; + 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"))?) }; diff --git a/crates/engine/src/cmd/layer_params.rs b/crates/engine/src/cmd/layer_params.rs index 3d94bcc888..82726f8369 100644 --- a/crates/engine/src/cmd/layer_params.rs +++ b/crates/engine/src/cmd/layer_params.rs @@ -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> { + 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> 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}"))), } } diff --git a/crates/engine/src/cmd/pattern_fill_cmds.rs b/crates/engine/src/cmd/pattern_fill_cmds.rs index 8372fed530..9c422341fd 100644 --- a/crates/engine/src/cmd/pattern_fill_cmds.rs +++ b/crates/engine/src/cmd/pattern_fill_cmds.rs @@ -67,8 +67,8 @@ fn edited(s: &Session, base: &Fill, p: &Value) -> Result<(Fill, Option) /// 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)> { - 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 { 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] { diff --git a/crates/engine/src/cmd/photo_cmds.rs b/crates/engine/src/cmd/photo_cmds.rs index 2aa2a383b8..9dd7da094b 100644 --- a/crates/engine/src/cmd/photo_cmds.rs +++ b/crates/engine/src/cmd/photo_cmds.rs @@ -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 { + 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 { // ---------- 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 { 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, }, diff --git a/crates/engine/src/cmd/pick_cmds.rs b/crates/engine/src/cmd/pick_cmds.rs index c40e15e9b8..76a9e8b773 100644 --- a/crates/engine/src/cmd/pick_cmds.rs +++ b/crates/engine/src/cmd/pick_cmds.rs @@ -26,6 +26,12 @@ fn layers_hit(doc: &Document, x: i32, y: i32, type_bounds: bool) -> Vec 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 { /// 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 { 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] diff --git a/crates/engine/src/cmd/smart_cmds/placing.rs b/crates/engine/src/cmd/smart_cmds/placing.rs index d2e4420aca..847ad700db 100644 --- a/crates/engine/src/cmd/smart_cmds/placing.rs +++ b/crates/engine/src/cmd/smart_cmds/placing.rs @@ -197,8 +197,8 @@ pub fn layers_to_smart(doc: &Document, name: &str, layers: Vec) -> Result } pub(super) fn convert(s: &mut Session, p: &Value) -> Result { - 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| { diff --git a/crates/engine/src/cmd/solid_fill_cmds.rs b/crates/engine/src/cmd/solid_fill_cmds.rs index 85d941760d..5763868d25 100644 --- a/crates/engine/src/cmd/solid_fill_cmds.rs +++ b/crates/engine/src/cmd/solid_fill_cmds.rs @@ -45,8 +45,8 @@ pub fn color_param(value: &Value, original: Color, mode: ColorMode) -> Result Result { - 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 { 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 { 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. diff --git a/crates/engine/src/cmd/type_cmds.rs b/crates/engine/src/cmd/type_cmds.rs index 58a2145409..6206a17fc9 100644 --- a/crates/engine/src/cmd/type_cmds.rs +++ b/crates/engine/src/cmd/type_cmds.rs @@ -24,10 +24,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { } fn layer_id(s: &Session, p: &Value) -> Result { - 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) -> EngineError { diff --git a/crates/engine/src/cmd/type_extra_cmds.rs b/crates/engine/src/cmd/type_extra_cmds.rs index d8c11a52d6..566912b21c 100644 --- a/crates/engine/src/cmd/type_extra_cmds.rs +++ b/crates/engine/src/cmd/type_extra_cmds.rs @@ -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 { // ---------- 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::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 { + 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::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 { + 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 { + 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 { 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 { 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())), diff --git a/crates/engine/src/cmd/type_extra_cmds/tests.rs b/crates/engine/src/cmd/type_extra_cmds/tests.rs index 8ae504cfa1..4008f82001 100644 --- a/crates/engine/src/cmd/type_extra_cmds/tests.rs +++ b/crates/engine/src/cmd/type_extra_cmds/tests.rs @@ -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 = (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); diff --git a/crates/engine/src/cmd/type_mask_cmds.rs b/crates/engine/src/cmd/type_mask_cmds.rs index 3a4b162a32..f09cc80122 100644 --- a/crates/engine/src/cmd/type_mask_cmds.rs +++ b/crates/engine/src/cmd/type_mask_cmds.rs @@ -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 { 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 { 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 { #[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(); diff --git a/crates/engine/src/cmd/type_spell_cmds.rs b/crates/engine/src/cmd/type_spell_cmds.rs index d42500c722..46e007d290 100644 --- a/crates/engine/src/cmd/type_spell_cmds.rs +++ b/crates/engine/src/cmd/type_spell_cmds.rs @@ -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 { set } -fn layers_param(s: &Session, p: &Value) -> Vec { - let Some(st) = s.active() else { return Vec::new() }; +fn layers_param(s: &Session, p: &Value) -> Result> { + 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 { 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 { /// Replaces one occurrence (verified to still be `word` when given). fn change(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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 { 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 { 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::>(), [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(); diff --git a/crates/engine/src/cmd/type_styles_cmds.rs b/crates/engine/src/cmd/type_styles_cmds.rs index 7fc58a5ec4..9d6e68f091 100644 --- a/crates/engine/src/cmd/type_styles_cmds.rs +++ b/crates/engine/src/cmd/type_styles_cmds.rs @@ -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 { - let Some(st) = s.active() else { return Vec::new() }; - let ids: Vec = if let Some(id) = p.get("layer").and_then(Value::as_u64) { - vec![LayerId(id)] +fn targets(s: &Session, p: &Value) -> Result> { + let Some(st) = s.active() else { return Ok(Vec::new()) }; + let ids: Vec = 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> { 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> { /// 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 { 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); diff --git a/crates/engine/src/cmd/type_styles_cmds/tests.rs b/crates/engine/src/cmd/type_styles_cmds/tests.rs index 6f6f735ff0..aba2f0fb5f 100644 --- a/crates/engine/src/cmd/type_styles_cmds/tests.rs +++ b/crates/engine/src/cmd/type_styles_cmds/tests.rs @@ -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"); diff --git a/crates/engine/src/cmd/warp_cmds.rs b/crates/engine/src/cmd/warp_cmds.rs index c5c04132d5..c2f434fb1e 100644 --- a/crates/engine/src/cmd/warp_cmds.rs +++ b/crates/engine/src/cmd/warp_cmds.rs @@ -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 => { diff --git a/crates/engine/src/cmd/web_cmds.rs b/crates/engine/src/cmd/web_cmds.rs index 15ea4ae143..3bd4f5a6c3 100644 --- a/crates/engine/src/cmd/web_cmds.rs +++ b/crates/engine/src/cmd/web_cmds.rs @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/web_cmds/save_web.rs b/crates/engine/src/cmd/web_cmds/save_web.rs index 46fe609d16..cac6a15e5c 100644 --- a/crates/engine/src/cmd/web_cmds/save_web.rs +++ b/crates/engine/src/cmd/web_cmds/save_web.rs @@ -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::>(), "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)?; diff --git a/crates/engine/src/cmd/web_cmds/tests.rs b/crates/engine/src/cmd/web_cmds/tests.rs index 590ea0d7ad..851171e4ce 100644 --- a/crates/engine/src/cmd/web_cmds/tests.rs +++ b/crates/engine/src/cmd/web_cmds/tests.rs @@ -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); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index ae1b880a64..b6bb17bcd8 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -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, + batch: Mutex, + batch_decision: Condvar, + batch_preview: Mutex>, + 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, + 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>) { + 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 { + 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, + /// 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, #[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, pre_selected: Option>, + before: crate::actions_record::Before, doc: Option, /// The document as the job saw it: applying fails if it was edited meanwhile. snapshot: Option>, @@ -453,6 +705,7 @@ pub fn run( 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( 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 { + 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 = self.jobs.running.iter().map(|r| r.id).collect(); + self.cancel_each(ids) + } + + fn cancel_each(&mut self, ids: Vec) -> 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 = 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, pre_selected: Option<&[LayerId]>) { + fn after_command( + &mut self, + id: &str, + params: Value, + journal: bool, + nested_from: Option, + 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, ¶ms) { - 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, ¶ms); + 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(¶ms) => crate::type_spell_cmds::recorded_step(¶ms), + _ => 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, ¶ms, self.typed_layer) { + crate::actions_record::Keystroke::Folded => {} + crate::actions_record::Keystroke::None => { + if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { + 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, payload: Payload) -> Result { - 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 { 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}"))), } } }, diff --git a/crates/engine/src/jobs/tests.rs b/crates/engine/src/jobs/tests.rs index 38cca3b206..109a008826 100644 --- a/crates/engine/src/jobs/tests.rs +++ b/crates/engine/src/jobs/tests.rs @@ -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, + 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![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))] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 65d3eb04ff..f975ad40a0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, /// 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, + /// 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 diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 453af7806b..893933fd80 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -68,6 +68,9 @@ pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result { 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 { + 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, Option)]) { + let open: HashSet = 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> { 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"); diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 3f2274cef6..a66a67b927 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -83,6 +83,11 @@ fn tile_rects(r: Rect) -> impl Iterator { 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). diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 8b23a7110b..3e6bc4abc7 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -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 { + 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); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index d7916fe548..e0fc23447b 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -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> { + pub(crate) fn explicit_layer_enabled(&mut self, spec: &commands::CommandSpec, params: &Value) -> Result>> { 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. diff --git a/crates/engine/tests/it/atn_direct_adjustments.rs b/crates/engine/tests/it/atn_direct_adjustments.rs new file mode 100644 index 0000000000..8f59433b9e --- /dev/null +++ b/crates/engine/tests/it/atn_direct_adjustments.rs @@ -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 { + 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::>(); + 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 { + 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::>(); + surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 4, 1), &data); + Ok(()) + }) + .unwrap(); + s +} + +fn raw(s: &Session) -> Vec { + 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}"); + } +} diff --git a/crates/engine/tests/it/atn_newer_events.rs b/crates/engine/tests/it/atn_newer_events.rs new file mode 100644 index 0000000000..acecf5ec7e --- /dev/null +++ b/crates/engine/tests/it/atn_newer_events.rs @@ -0,0 +1,1208 @@ +//! #10700: recorded Link and Unlink must not replay as a state-dependent toggle. +use photocraft_doc::{Layer, LayerContent, LayerId, SmartObject, SmartSource}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::json; +use std::sync::Arc; + +fn fixture(groups: &[Option], selected: &[usize]) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 2})).unwrap(); + let st = s.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + doc.layers = groups + .iter() + .enumerate() + .map(|(i, group)| { + let mut layer = Layer::raster(format!("Layer {i}"), doc.pixel_format()); + layer.link_group = *group; + layer + }) + .collect(); + let ids: Vec<_> = selected.iter().map(|i| doc.layers[*i].id).collect(); + s.restore_layer_selection(ids.last().copied(), &ids, ids.first().copied()).unwrap(); + s +} + +fn groups(s: &Session) -> Vec> { + s.active().unwrap().doc.layers.iter().map(|layer| layer.link_group).collect() +} + +fn target() -> D { + target_class("Lyr ") +} + +fn target_class(class_id: &str) -> D { + ordinal_target(class_id, "Trgt") +} + +fn ordinal_target(class_id: &str, ordinal: &str) -> D { + D::Reference(vec![ReferenceItem::Enumerated { + class: Class { name: UnicodeString::new_nul(""), class_id: Id::new(class_id) }, + type_id: Id::new("Ordn"), + value: Id::new(ordinal), + }]) +} + +fn file(event: &str, descriptor: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Links"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![AtnItem { + event: match event.as_bytes() { + [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), + _ => AtnEvent::name(event), + }, + descriptor: Some(descriptor), + ..Default::default() + }], + ..Default::default() + }], + warnings: vec![], + ..Default::default() + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> serde_json::Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn play(s: &mut Session) { + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert!(report.get("failed").is_none(), "{report}"); + assert_eq!(report["ran"], 1, "{report}"); +} + +fn saved_item(s: &mut Session) -> AtnItem { + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + atn::parse(&bytes).unwrap().actions[0].items[0].clone() +} + +#[test] +fn explicit_links_are_idempotent_and_omitted_state_still_toggles() { + let mut s = fixture(&[None, None], &[0, 1]); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + s.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + s.execute("layer.linkLayers", json!({"linked": true})).unwrap(); + let linked = s.active().unwrap().doc.clone(); + assert!(groups(&s)[0].is_some()); + assert_eq!(groups(&s)[0], groups(&s)[1]); + let history = s.active().unwrap().history.past_len(); + s.execute("layer.linkLayers", json!({"linked": true})).unwrap(); + assert_eq!(s.active().unwrap().doc, linked); + assert_eq!(s.active().unwrap().history.past_len(), history); + s.execute("layer.linkLayers", json!({})).unwrap(); + assert_eq!(groups(&s), [None, None]); + s.execute("layer.linkLayers", json!({})).unwrap(); + assert!(groups(&s).iter().all(Option::is_some)); +} + +#[test] +fn unlink_cleans_singletons_preserves_other_groups_and_undoes() { + for initial in [vec![Some(77), Some(77), Some(77), Some(93), Some(93)], vec![Some(77), None, Some(77), Some(93), Some(93)]] { + let mut s = fixture(&initial, &[0, 1]); + let before = s.active().unwrap().doc.clone(); + s.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(groups(&s), [None, None, None, Some(93), Some(93)]); + let history = s.active().unwrap().history.past_len(); + s.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + let mut one = fixture(&[Some(77), Some(77)], &[0]); + one.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(groups(&one), [None, None]); + let history = one.active().unwrap().history.past_len(); + one.execute("layer.linkLayers", json!({"linked": false})).unwrap(); + assert_eq!(one.active().unwrap().history.past_len(), history); + assert!(!one.is_enabled("layer.linkLayers"), "the UI toggle still requires two unlinked layers"); +} + +/// Explicit Link cleans an orphan without touching a real group's unselected member. +#[test] +fn explicit_link_cleans_a_singleton_but_preserves_a_linked_pair() { + for initial in [vec![Some(77)], vec![Some(77), None], vec![Some(77), Some(77)]] { + let mut s = fixture(&initial, &[0]); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + let linked = initial == [Some(77), Some(77)]; + assert_eq!(s.execute("layer.linkLayers", json!({"linked": true})).unwrap()["linked"], linked); + if linked { + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } else { + assert!(groups(&s).iter().all(Option::is_none)); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} + +#[test] +fn invalid_link_state_fails_without_editing() { + let mut s = fixture(&[None, None], &[0, 1]); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + for invalid in [json!(null), json!("false"), json!(0), json!([]), json!({})] { + assert!(s.execute("layer.linkLayers", json!({"linked": invalid})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + for active in [None, Some(LayerId(u64::MAX))] { + let st = s.active_mut().unwrap(); + st.active_layer = active; + st.selected_layers = active.into_iter().collect(); + assert!(s.execute("layer.linkLayers", json!({"linked": false})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + assert!(Session::new().execute("layer.linkLayers", json!({"linked": false})).is_err()); +} + +#[test] +fn atn_link_events_keep_descriptors_and_replay_the_requested_operation() { + // Link Layers keeps the measured toggle (main's Group F mapping); Unlink is explicit. + { + let (event, linked) = ("unlinkSelectedLayers", false); + for initial in [vec![None, None, None, Some(93), Some(93)], vec![Some(77), Some(77), Some(77), Some(93), Some(93)]] { + let descriptor = Descriptor::new("null").with("null", target()); + let mut s = fixture(&initial, &[0, 1]); + assert_eq!(load(&mut s, &file(event, descriptor.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, [("layer.linkLayers".into(), json!({"linked": linked}))]); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].event, AtnEvent::name(event)); + assert_eq!(parsed.actions[0].items[0].descriptor, Some(descriptor)); + let mut replay = fixture(&initial, &[0, 1]); + assert_eq!(load(&mut replay, &bytes)["unsupported"], json!([])); + play(&mut replay); + if linked { + let result = groups(&replay); + assert!(result[0].is_some()); + assert_eq!(result[0], result[1]); + assert_eq!(&result[2..], &[initial[2], Some(93), Some(93)]); + } else { + assert_eq!(groups(&replay), [None, None, None, Some(93), Some(93)]); + } + let before = replay.active().unwrap().doc.clone(); + let history = replay.active().unwrap().history.past_len(); + play(&mut replay); + assert_eq!(replay.active().unwrap().doc, before); + assert_eq!(replay.active().unwrap().history.past_len(), history); + } + } +} + +#[test] +fn unsupported_link_descriptors_are_kept_and_ambiguous_exports_are_refused() { + for event in ["linkSelectedLayers", "unlinkSelectedLayers"] { + for descriptor in [ + Descriptor::new("null").with("null", D::Reference(vec![])), + Descriptor::new("null").with("null", D::Integer(1)), + Descriptor::new("null").with("null", target_class("Dcmn")), + Descriptor::new("null").with("null", target()).with("extra", D::Boolean(true)), + Descriptor::new("null").with("null", target()).with("null", target()), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file(event, descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].event, AtnEvent::name(event)); + assert_eq!(parsed.actions[0].items[0].descriptor, Some(descriptor)); + } + } + // `{}` is the measured Link Layers toggle and exports (main's Group F mapping). + for params in [json!({"linked": null}), json!({"linked": true, "layer": "Layer 0"}), json!({"linked": false, "layers": [1, 2]})] { + let action = Action::new("A", vec![("layer.linkLayers".into(), params)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } +} + +const TARGET_EVENTS: &[(&str, &str)] = &[ + ("reverse", "layer.arrange.reverse"), + ("selectLinkedLayers", "layer.selectLinkedLayers"), + ("disableLayerStyle", "layer.layerStyle.clear"), + ("placedLayerMakeCopy", "layer.smartObjects.newSmartObjectViaCopy"), + ("placedLayerConvertToLayers", "layer.smartObjects.convertToLayers"), + ("placedLayerConvertToEmbedded", "layer.smartObjects.convertToEmbedded"), + ("placedLayerUpdateAllModified", "layer.smartObjects.updateAllModifiedContent"), + ("rasterizeAll", "layer.rasterize.allLayers"), + ("addBlankVideoLayer", "layer.videoLayers.newBlankVideoLayer"), + ("isolateLayers", "select.isolateLayers"), + ("textLoadDefaultTypeStyles", "type.loadDefaultTypeStyles"), + ("textSaveDefaultTypeStyles", "type.saveDefaultTypeStyles"), + ("recordMeasurements", "image.analysis.recordMeasurements"), + ("CropPhotosAuto0001", "file.automate.cropAndStraightenPhotos"), +]; + +fn targeted(event: &str, explicit: bool) -> Descriptor { + let d = Descriptor::new(event); + if explicit { d.with("null", target()) } else { d } +} + +fn load_targeted(s: &mut Session, event: &str, id: &str, explicit: bool) { + assert_eq!(load(s, &file(event, targeted(event, explicit)))["unsupported"], json!([]), "{event}"); + let p = if explicit { json!({}) } else { json!({"_atnNoTarget": true}) }; + assert_eq!(s.actions.list[0].steps, [(id.into(), p)]); +} + +#[test] +fn target_events_keep_empty_and_explicit_target_descriptors_distinct() { + for &(event, id) in TARGET_EVENTS { + for explicit in [false, true] { + let mut s = Session::new(); + load_targeted(&mut s, event, id, explicit); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let item = &atn::parse(&bytes).unwrap().actions[0].items[0]; + assert_eq!(item.event, AtnEvent::name(event)); + assert_eq!(item.descriptor, Some(targeted("null", explicit)), "{event}"); + assert_eq!(load(&mut s, &bytes)["unsupported"], json!([])); + } + } + // Reference lines197–199 permits writing an empty descriptor for an absent one. + let mut f = atn::parse(&file("reverse", Descriptor::new("null"))).unwrap(); + f.actions[0].items[0].descriptor = None; + let mut s = Session::new(); + assert_eq!(load(&mut s, &atn::write(&f).unwrap())["unsupported"], json!([])); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(Descriptor::new("null"))); +} + +#[test] +fn bare_link_events_keep_their_operation_and_empty_keys() { + // Link Layers keeps the measured toggle (main's Group F mapping); Unlink is explicit. + { + let (event, linked) = ("unlinkSelectedLayers", false); + let initial = if linked { None } else { Some(77) }; + let mut s = fixture(&[initial, initial], &[0, 1]); + assert_eq!(load(&mut s, &file(event, Descriptor::new(event)))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].1, json!({"linked": linked, "_atnNoTarget": true})); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(Descriptor::new("null"))); + let before = s.active().unwrap().doc.clone(); + play(&mut s); + assert_ne!(s.active().unwrap().doc, before); + let after = s.active().unwrap().doc.clone(); + assert!(groups(&s).iter().all(|group| group.is_some() == linked)); + assert_eq!(groups(&s)[0], groups(&s)[1]); + play(&mut s); + assert_eq!(s.active().unwrap().doc, after); + } +} + +#[test] +fn target_events_keep_unknown_targets_and_refuse_native_options() { + for &(event, id) in TARGET_EVENTS { + for descriptor in [ + Descriptor::new(event).with("null", D::Integer(1)), + Descriptor::new(event).with("null", target_class("Dcmn")), + targeted(event, true).with("null", target()), + targeted(event, true).with("extra", D::Boolean(true)), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file(event, descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(descriptor)); + } + for p in [json!({"layer": 1}), json!({"on": true}), json!({"_atnNoTarget": false}), json!({"_atnNoTarget": "true"})] { + let action = Action::new("A", vec![(id.into(), p)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err(), "{event}"); + } + } + for linked in [false, true] { + let action = Action::new("A", vec![("layer.linkLayers".into(), json!({"linked": linked, "_atnNoTarget": false}))]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } +} + +fn scan_fixture() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 480, "height": 340, "background": "#fafafa"})).unwrap(); + let st = s.active_mut().unwrap(); + let id = st.active_layer.unwrap(); + let surface = Arc::make_mut(&mut st.doc).layer_mut(id).unwrap().surface_mut().unwrap(); + for (x, y, w, h, color) in [(40, 60, 160, 110, [0.6, 0.1, 0.1, 1.0]), (290, 180, 150, 120, [0.1, 0.2, 0.6, 1.0])] { + surface.fill_rect(photocraft_doc::Rect::new(x, y, x + w, y + h), &color); + } + s.execute("select.rect", json!({"x": 270, "y": 160, "width": 190, "height": 160})).unwrap(); + s +} + +fn assert_cropped_scan(s: &Session) { + assert_eq!(s.documents().len(), 2); + let doc = &s.documents()[1].doc; + assert!((i64::from(doc.size.width) - 150).abs() <= 4); + assert!((i64::from(doc.size.height) - 120).abs() <= 4); + let color = doc.layers[0].surface().unwrap().rgba(doc.size.width as i32 / 2, doc.size.height as i32 / 2); + assert!(color[2] > 0.5 && color[0] < 0.2, "{color:?}"); +} + +#[test] +fn crop_parent_creates_the_selected_photo_without_changing_the_scan() { + for explicit in [false, true] { + let mut s = scan_fixture(); + let source = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + load_targeted(&mut s, "CropPhotosAuto0001", "file.automate.cropAndStraightenPhotos", explicit); + assert!(s.is_enabled("file.automate.cropAndStraightenPhotos")); + play(&mut s); + assert_cropped_scan(&s); + assert_eq!(s.documents()[0].doc, source); + assert_eq!(s.documents()[0].history.past_len(), history); + } +} + +#[test] +fn crop_parent_refuses_ineligible_layers_without_document_or_history_changes() { + for case in ["missing", "stale", "hidden", "hidden-parent", "pixels", "locked-parent", "indexed"] { + let mut s = scan_fixture(); + let st = s.active_mut().unwrap(); + let id = st.active_layer.unwrap(); + let doc = Arc::make_mut(&mut st.doc); + match case { + "missing" => st.active_layer = None, + "stale" => st.active_layer = Some(LayerId::fresh()), + "hidden" => doc.layer_mut(id).unwrap().visible = false, + "pixels" => doc.layer_mut(id).unwrap().locks.pixels = true, + "indexed" => doc.mode = photocraft_doc::ColorMode::Indexed, + _ => { + let mut group = Layer::group("Parent", std::mem::take(&mut doc.layers)); + group.visible = case != "hidden-parent"; + group.locks.all = case == "locked-parent"; + doc.layers.push(group); + } + } + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + assert!(!s.is_enabled("file.automate.cropAndStraightenPhotos"), "{case}"); + load_targeted(&mut s, "CropPhotosAuto0001", "file.automate.cropAndStraightenPhotos", false); + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert_eq!(report["ran"], 0, "{case}: {report}"); + assert_eq!(report["failed"]["id"], "file.automate.cropAndStraightenPhotos", "{case}: {report}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + assert!(!Session::new().is_enabled("file.automate.cropAndStraightenPhotos")); +} + +#[test] +fn crop_parent_without_photos_fails_without_creating_documents() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 80, "background": "white"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + load_targeted(&mut s, "CropPhotosAuto0001", "file.automate.cropAndStraightenPhotos", false); + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert_eq!(report["ran"], 0, "{report}"); + assert!(report["failed"]["error"].as_str().unwrap().contains("no photos found"), "{report}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + +#[test] +fn crop_parent_runs_once_and_the_kept_helper_stops_the_duplicate_crop_tail() { + let mut f = atn::parse(&file("CropPhotosAuto0001", Descriptor::new("cjsp"))).unwrap(); + let helper = Descriptor::new("null").with("Vl ", D::List([0.0, 0.0, 150.0, 0.0, 150.0, 120.0, 0.0, 120.0].into_iter().map(D::Double).collect())); + f.actions[0].items.push(AtnItem { event: AtnEvent::name("CropPhotos0001"), descriptor: Some(helper.clone()), ..Default::default() }); + let tail = Action::new("Tail", vec![("image.duplicate".into(), json!({})), ("image.crop".into(), json!({"x": 1, "y": 1, "width": 10, "height": 10}))]); + let (bytes, skipped) = atn_cmds::export("Tail", &[&tail], &[], false).unwrap(); + assert!(skipped.is_empty()); + f.actions[0].items.extend(atn::parse(&bytes).unwrap().actions[0].items.clone()); + let mut s = scan_fixture(); + let source = s.active().unwrap().doc.clone(); + let report = load(&mut s, &atn::write(&f).unwrap()); + assert_eq!(report["unsupported"].as_array().unwrap().len(), 1); + assert_eq!( + s.actions.list[0].steps.iter().map(|step| step.0.as_str()).collect::>(), + ["file.automate.cropAndStraightenPhotos", "actions.photoshopStep", "image.duplicate", "image.crop"] + ); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[1].descriptor, Some(helper)); + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert_eq!(report["ran"], 1, "{report}"); + assert_eq!(report["failed"]["step"], 1); + assert_eq!(report["failed"]["id"], "actions.photoshopStep"); + assert_cropped_scan(&s); + assert_eq!(s.documents()[0].doc, source); +} + +/// Two sparse marks leave a transparent gap where the converted board's background is observable. +fn artboard_fixture() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 12, "height": 10, "background": "transparent"})).unwrap(); + let st = s.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let mut child = Layer::raster("Pixels", doc.pixel_format()); + let surface = child.surface_mut().unwrap(); + for rect in [photocraft_doc::Rect::new(2, 2, 3, 3), photocraft_doc::Rect::new(5, 5, 6, 6)] { + surface.fill_rect(rect, &[1.0, 0.0, 0.0, 1.0]); + } + let group = Layer::group("Group", vec![child]); + let id = group.id; + doc.layers = vec![group]; + s.restore_layer_selection(Some(id), &[id], Some(id)).unwrap(); + s +} + +/// The reference's ordered settings keys, with fractional RGB that must not be rounded to hex. +fn artboard_descriptor(background: i32, guides: Vec, flag: D) -> Descriptor { + Descriptor::new("null").with("null", target()).with("artboard", D::Descriptor(artboard_settings(Some(background), guides))).with("changeBackground", flag) +} + +/// Background types 1..3 still record the last custom RGB value; absent type means white. +fn artboard_settings(background: Option, guides: Vec) -> Descriptor { + let color = Descriptor::new("RGBC").with("Rd ", D::Double(63.75)).with("Grn ", D::Double(127.5)).with("Bl ", D::Double(191.25)); + let board = Descriptor::new("artboard") + .with("guideIDs", D::List(guides)) + .with("artboardPresetName", D::Text(UnicodeString::new_nul("iPhone 14"))) + .with("Clr ", D::Descriptor(color)); + match background { + Some(n) => board.with("artboardBackgroundType", D::Integer(n)), + None => board, + } +} + +/// Conversion preserves the group's geometry/content while applying every admitted setting and Undo. +#[test] +fn artboard_recorded_flags_and_guides_roundtrip_without_resizing() { + for (background, expected) in [(1, [1.0, 1.0, 1.0, 1.0]), (2, [0.0, 0.0, 0.0, 1.0]), (3, [0.0; 4]), (4, [0.25, 0.5, 0.75, 1.0])] { + for flag in [None, Some(0), Some(1), Some(2), Some(3), Some(-1), Some(7)] { + let mut s = artboard_fixture(); + Arc::make_mut(&mut s.active_mut().unwrap().doc).guides.horizontal.push(3.0); + Arc::make_mut(&mut s.active_mut().unwrap().doc).guides.vertical.push(4.0); + let before = s.active().unwrap().doc.clone(); + let id = s.active().unwrap().active_layer.unwrap(); + let mut d = artboard_descriptor(background, vec![D::Integer(2), D::Integer(99999)], D::Integer(flag.unwrap_or(1))); + if flag.is_none() { + d.items.pop(); + } + assert_eq!(load(&mut s, &file("artboardFromLayerGroupEvent", d.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].0, "layer.new.artboardFromGroup"); + assert_eq!(s.actions.list[0].steps[0].1["color"], json!([0.25, 0.5, 0.75])); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + play(&mut s); + let doc = &s.active().unwrap().doc; + let board = doc.layer(id).unwrap().artboard().unwrap(); + assert_eq!(board.rect, photocraft_doc::Rect::new(2, 2, 6, 6), "preset is a tag, not a request to resize the group"); + assert_eq!(board.preset, "iPhone 14"); + assert_eq!(doc.size, before.size); + assert_eq!(doc.layer(id).unwrap().children(), before.layer(id).unwrap().children()); + assert_eq!(doc.guides, before.guides); + let pixel = photocraft_compose::render(doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0]; + let applied = flag.is_none_or(|n| n == 1); + for (actual, expected) in pixel.into_iter().zip(if applied { expected } else { [1.0; 4] }) { + assert!((actual - expected).abs() < 0.005, "background {background}, flag {flag:?}: {pixel:?}"); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} + +/// The target-only call uses the existing menu behavior without synthesizing settings. +#[test] +fn artboard_target_only_form_keeps_existing_native_group_behavior() { + let mut s = artboard_fixture(); + let before = s.active().unwrap().doc.clone(); + let d = Descriptor::new("null").with("null", target()); + assert_eq!(load(&mut s, &file("artboardFromLayerGroupEvent", d.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, [("layer.new.artboardFromGroup".into(), json!({}))]); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + play(&mut s); + assert!(s.active().unwrap().doc.layers[0].artboard().is_some()); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); +} + +/// A conversion selects its result, so a following Delete cannot remove another selected layer. +#[test] +fn artboard_from_group_replaces_the_previous_layer_selection() { + let mut s = artboard_fixture(); + let group = s.active().unwrap().active_layer.unwrap(); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let other = Layer::raster("Other", doc.pixel_format()); + let id = other.id; + doc.layers.push(other); + s.restore_layer_selection(Some(group), &[id, group], Some(id)).unwrap(); + load(&mut s, &file("artboardFromLayerGroupEvent", Descriptor::new("null").with("null", target()))); + play(&mut s); + assert_eq!(s.active().unwrap().selected_layers(), vec![group]); + s.execute("layer.delete", json!({})).unwrap(); + assert!(s.active().unwrap().doc.layer(id).is_some()); + assert!(s.active().unwrap().doc.layer(group).is_none()); +} + +/// A missing custom color means White; an explicit white color remains Custom through save/load. +#[test] +fn artboard_partial_custom_background_preserves_the_document_variant() { + for params in [json!({"background":"custom"}), json!({"background":"custom","color":[1,1,1]})] { + let mut s = artboard_fixture(); + s.execute("layer.new.artboardFromGroup", params.clone()).unwrap(); + let expected = s.active().unwrap().doc.clone(); + assert!(s.undo()); + let action = Action::new("A", vec![("layer.new.artboardFromGroup".into(), params)]); + let (bytes, skipped) = atn_cmds::export("Links", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(load(&mut s, &bytes)["unsupported"], json!([])); + play(&mut s); + assert_eq!(s.active().unwrap().doc, expected); + } +} + +/// Preserve fractional wire values exactly while playback uses normalized native RGB. +#[test] +fn artboard_fractional_colors_survive_import_save_and_playback() { + let rgb = [7.97, 31.88, 127.6]; + for event in ["artboardFromLayerGroupEvent", "editArtboardEvent"] { + let mut s = artboard_fixture(); + let mut d = if event == "editArtboardEvent" { + s.execute("layer.new.artboardFromGroup", json!({})).unwrap(); + artboard_edit_descriptor(4, 8) + } else { + artboard_descriptor(4, vec![], D::Integer(1)) + }; + let D::Descriptor(board) = &mut d.items.iter_mut().find(|(key, _)| key.is("artboard")).unwrap().1 else { unreachable!() }; + board.items.iter_mut().find(|(key, _)| key.is("Clr ")).unwrap().1 = + D::Descriptor(["Rd ", "Grn ", "Bl "].into_iter().zip(rgb).fold(Descriptor::new("RGBC"), |color, (key, n)| color.with(key, D::Double(n)))); + assert_eq!(load(&mut s, &file(event, d.clone()))["unsupported"], json!([])); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + let (id, mut changed) = s.actions.list[0].steps[0].clone(); + changed["color"][0] = 0.5.into(); + assert!(atn_cmds::export("Links", &[&Action::new("Changed", vec![(id, changed)])], &[], false).is_err()); + let before = s.active().unwrap().doc.clone(); + play(&mut s); + let pixel = photocraft_compose::render(&s.active().unwrap().doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0]; + for (actual, expected) in pixel.into_iter().zip(rgb.map(|n| n as f32 / 255.0)) { + assert!((actual - expected).abs() < 0.005, "{event}: {pixel:?}"); + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +/// Unproved settings remain byte-preserving kept steps and cannot partially edit the document. +#[test] +fn artboard_unmeasured_types_and_extra_settings_stay_whole() { + for d in [ + artboard_descriptor(1, vec![D::Double(7.0)], D::Integer(1)), + artboard_descriptor(1, vec![], D::Boolean(true)), + artboard_descriptor(5, vec![], D::Integer(1)), + artboard_descriptor(1, vec![], D::Integer(1)).with("extra", D::Boolean(true)), + Descriptor::new("null"), + ] { + let mut s = artboard_fixture(); + assert_eq!(load(&mut s, &file("artboardFromLayerGroupEvent", d.clone()))["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + let report = s.execute("actions.play", json!({"action":"A","set":"Links"})).unwrap(); + assert_eq!(report["ran"], 0, "{report}"); + assert_eq!(report["failed"]["id"], "actions.photoshopStep"); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + for p in [json!({"layer":1}), json!({"preset":7}), json!({"guideIDs":[7.5]}), json!({"changeBackground":false})] { + let action = Action::new("A", vec![("layer.new.artboardFromGroup".into(), p)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } +} + +/// Omitted type means white rather than the color-only native call's custom background. +#[test] +fn artboard_omitted_type_defaults_to_white_and_invalid_native_settings_are_atomic() { + let mut s = artboard_fixture(); + let before = s.active().unwrap().doc.clone(); + let d = + Descriptor::new("null").with("null", target()).with("artboard", D::Descriptor(artboard_settings(None, vec![]))).with("changeBackground", D::Integer(1)); + assert_eq!(load(&mut s, &file("artboardFromLayerGroupEvent", d.clone()))["unsupported"], json!([])); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + let mut changed = s.actions.list[0].steps[0].1.clone(); + changed["background"] = "black".into(); + assert!(atn_cmds::export("Links", &[&Action::new("A", vec![("layer.new.artboardFromGroup".into(), changed)])], &[], false).is_err()); + play(&mut s); + assert_eq!(photocraft_compose::render(&s.active().unwrap().doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0], [1.0; 4]); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + let history = s.active().unwrap().history.past_len(); + for p in [ + json!({"guideIDs":[7.5]}), + json!({"guideIDs":false}), + json!({"changeBackground":false}), + json!({"changeBackground":2147483648i64}), + json!({"preset":7}), + ] { + assert!(s.execute("layer.new.artboardFromGroup", p).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} + +/// Recorded geometry deliberately differs from the target: flag 8 changes only its background. +fn artboard_edit_descriptor(background: i32, change_sizes: i32) -> Descriptor { + let rect = Descriptor::new("classFloatRect") + .with("Top ", D::Double(40.5)) + .with("Left", D::Double(60.25)) + .with("Btom", D::Double(340.5)) + .with("Rght", D::Double(260.25)); + let mut board = Descriptor::new("artboard").with("artboardRect", D::Descriptor(rect)); + board.items.extend(artboard_settings(Some(background), vec![D::Integer(99999)]).items); + Descriptor::new("null") + .with("null", target()) + .with("artboard", D::Descriptor(board)) + .with("changeSizes", D::Integer(change_sizes)) + .with("changeBackground", D::Integer(1)) +} + +/// Background edits preserve all wire fields, target geometry, children and guides, with one Undo. +#[test] +fn artboard_background_edit_roundtrips_without_replaying_recorded_geometry() { + for (kind, expected) in [(1, [1.0; 4]), (2, [0.0, 0.0, 0.0, 1.0]), (3, [0.0; 4]), (4, [0.25, 0.5, 0.75, 1.0])] { + let mut s = artboard_fixture(); + s.execute("layer.new.artboardFromGroup", json!({})).unwrap(); + Arc::make_mut(&mut s.active_mut().unwrap().doc).guides.horizontal.push(3.0); + let before = s.active().unwrap().doc.clone(); + let id = s.active().unwrap().active_layer.unwrap(); + let history = s.active().unwrap().history.past_len(); + let d = artboard_edit_descriptor(kind, 8); + assert_eq!(load(&mut s, &file("editArtboardEvent", d.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].0, "layer.artboard.set"); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + play(&mut s); + let doc = &s.active().unwrap().doc; + let board = doc.layer(id).unwrap().artboard().unwrap(); + assert_eq!(board.rect, before.layer(id).unwrap().artboard().unwrap().rect); + assert_eq!(board.preset, before.layer(id).unwrap().artboard().unwrap().preset); + assert_eq!(doc.layer(id).unwrap().children(), before.layer(id).unwrap().children()); + assert_eq!(doc.size, before.size); + assert_eq!(doc.guides, before.guides); + let pixel = photocraft_compose::render(doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0]; + for (actual, expected) in pixel.into_iter().zip(expected) { + assert!((actual - expected).abs() < 0.005, "{kind}: {pixel:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +/// Unmeasured geometry modes remain whole; edited native resize options cannot be silently omitted. +#[test] +fn artboard_edit_refuses_unmeasured_modes_and_lossy_native_exports() { + let mut s = artboard_fixture(); + for d in [artboard_edit_descriptor(1, 4), artboard_edit_descriptor(5, 8), artboard_edit_descriptor(1, 8).with("extra", D::Integer(1))] { + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + assert_eq!(load(&mut s, &file("editArtboardEvent", d.clone()))["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved_item(&mut s).descriptor, Some(d)); + let result = s.execute("actions.play", json!({"set":"Links","action":"A"})).unwrap(); + assert_eq!(result["ran"], 0); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + load(&mut s, &file("editArtboardEvent", artboard_edit_descriptor(4, 8))); + let params = s.actions.list[0].steps[0].1.clone(); + for (key, value) in [("rect", json!([0, 0, 100, 100])), ("preset", json!("iPhone 14")), ("changeSizes", json!(4)), ("changeBackground", json!(0))] { + let mut edited = params.clone(); + edited[key] = value; + assert!(atn_cmds::export("Links", &[&Action::new("A", vec![("layer.artboard.set".into(), edited)])], &[], false).is_err()); + } +} + +#[test] +fn reverse_linked_selection_and_clear_style_events_do_their_edits() { + for explicit in [false, true] { + let mut s = fixture(&[None, None, None], &[0, 1, 2]); + let before = s.active().unwrap().doc.clone(); + let ids: Vec<_> = before.layers.iter().map(|layer| layer.id).rev().collect(); + load_targeted(&mut s, "reverse", "layer.arrange.reverse", explicit); + play(&mut s); + assert_eq!(s.active().unwrap().doc.layers.iter().map(|layer| layer.id).collect::>(), ids); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + + let mut s = fixture(&[Some(77), Some(77), None], &[0]); + let ids: Vec<_> = s.active().unwrap().doc.layers[..2].iter().map(|layer| layer.id).collect(); + load_targeted(&mut s, "selectLinkedLayers", "layer.selectLinkedLayers", explicit); + play(&mut s); + assert_eq!(s.active().unwrap().selected_layers(), ids); + + let mut s = fixture(&[None], &[0]); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + assert_eq!(before.layers[0].effects.items.len(), 1); + load_targeted(&mut s, "disableLayerStyle", "layer.layerStyle.clear", explicit); + play(&mut s); + assert!(s.active().unwrap().doc.layers[0].effects.items.is_empty()); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +fn smart_target_scene(event: &str, bytes: &[u8], replacement: &[u8], path: &str) -> Session { + let mut s = smart_fixture(bytes); + if matches!(event, "placedLayerConvertToEmbedded" | "placedLayerUpdateAllModified") { + s.execute("layer.smartObjects.convertToLinked", json!({"path": path})).unwrap(); + } + if event == "placedLayerUpdateAllModified" { + std::fs::write(path, replacement).unwrap(); + } + s +} + +fn assert_smart_target_edit(event: &str, s: &Session, original_id: photocraft_doc::SmartContentsId, pixels: &[[f32; 4]], bytes: &[u8]) { + match event { + "placedLayerMakeCopy" => { + assert_ne!(active_smart(s).contents_id, original_id); + assert_eq!(s.active().unwrap().doc.walk().iter().filter(|(_, _, layer)| matches!(layer.content, LayerContent::Smart(_))).count(), 2); + } + "placedLayerConvertToLayers" => assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, layer)| !matches!(layer.content, LayerContent::Smart(_)))), + "placedLayerConvertToEmbedded" => assert!(matches!(&active_smart(s).source, SmartSource::Embedded { bytes: actual, .. } if actual.as_slice() == bytes)), + "placedLayerUpdateAllModified" => assert!(photocraft_compose::flatten(&s.active().unwrap().doc).px.iter().all(|px| *px == [1.0, 0.0, 0.0, 1.0])), + _ => unreachable!(), + } + if event != "placedLayerUpdateAllModified" { + assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px, pixels); + } +} + +#[test] +fn smart_target_events_copy_unpack_embed_and_refresh_real_contents() { + let original = png("#0000ff"); + let replacement = png("#ff0000"); + for &(event, id) in &TARGET_EVENTS[3..7] { + for explicit in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("linked.png"); + let mut s = smart_target_scene(event, &original, &replacement, path.to_str().unwrap()); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before).into_px(); + let contents_id = active_smart(&s).contents_id; + load_targeted(&mut s, event, id, explicit); + play(&mut s); + assert_smart_target_edit(event, &s, contents_id, &pixels, &original); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} + +#[test] +fn rasterize_all_event_visits_nested_and_top_level_smart_layers() { + for explicit in [false, true] { + let mut s = smart_fixture(&png("#0000ff")); + let first = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.smartObjects.newSmartObjectViaCopy", json!({})).unwrap(); + let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc); + let nested = doc.remove(first).unwrap(); + doc.layers.push(Layer::group("Nested", vec![nested])); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before).into_px(); + load_targeted(&mut s, "rasterizeAll", "layer.rasterize.allLayers", explicit); + play(&mut s); + assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, layer)| !matches!(layer.content, LayerContent::Smart(_)))); + assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px, pixels); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn video_and_isolate_events_use_current_document_state() { + for explicit in [false, true] { + let mut s = fixture(&[None], &[0]); + let before = s.active().unwrap().doc.clone(); + load_targeted(&mut s, "addBlankVideoLayer", "layer.videoLayers.newBlankVideoLayer", explicit); + play(&mut s); + let st = s.active().unwrap(); + let video = st.doc.layer(st.active_layer.unwrap()).unwrap().video.as_ref().unwrap(); + assert_eq!(video.frames.len(), 1); + assert_eq!(video.fps, 30.0); + assert!(video.frames[0].content_bounds().is_empty()); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + + let mut s = fixture(&[None, None, None], &[0, 2]); + let before = s.active().unwrap().doc.clone(); + let ids = s.active().unwrap().selected_layers(); + let history = s.active().unwrap().history.past_len(); + load_targeted(&mut s, "isolateLayers", "select.isolateLayers", explicit); + play(&mut s); + assert_eq!(s.active().unwrap().isolated_layers, ids); + play(&mut s); + assert!(s.active().unwrap().isolated_layers.is_empty()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} + +fn active_text(s: &Session) -> &photocraft_doc::TextLayer { + let st = s.active().unwrap(); + let LayerContent::Text(text) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("expected Type") }; + text +} + +#[test] +fn type_default_events_save_then_restore_changed_styles() { + for explicit in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s.execute("type.create", json!({"text": "Hi", "size": 14})).unwrap(); + assert!(s.type_defaults.is_none()); + load_targeted(&mut s, "textSaveDefaultTypeStyles", "type.saveDefaultTypeStyles", explicit); + play(&mut s); + assert_eq!(s.type_defaults.as_ref().unwrap().0.size_pt, 14.0); + s.execute("type.setStyle", json!({"size": 28})).unwrap(); + assert_eq!(active_text(&s).char_runs()[0].style.size_pt, 28.0); + let before = s.active().unwrap().doc.clone(); + load_targeted(&mut s, "textLoadDefaultTypeStyles", "type.loadDefaultTypeStyles", explicit); + play(&mut s); + assert_eq!(active_text(&s).char_runs()[0].style.size_pt, 14.0); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn measurement_event_records_actual_selected_area() { + for explicit in [false, true] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap(); + let before = s.active().unwrap().doc.clone(); + load_targeted(&mut s, "recordMeasurements", "image.analysis.recordMeasurements", explicit); + play(&mut s); + assert_eq!(s.analysis.log.len(), 1); + assert_eq!(s.analysis.log[0].values["source"], "Selection"); + assert_eq!(s.analysis.log[0].values["area"].as_f64(), Some(4.0)); + assert_eq!(s.active().unwrap().doc, before); + } +} + +fn class_reference(class_id: &str) -> D { + D::Reference(vec![ReferenceItem::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new(class_id) })]) +} + +fn make_type_descriptor() -> Descriptor { + Descriptor::new("Mk ").with("null", class_reference("contentLayer")).with("Usng", target_class("textLayer")) +} + +#[test] +fn make_type_shape_roundtrips_and_converts_actual_glyphs_with_undo() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 128, "height": 80, "background": "white"})).unwrap(); + let id = LayerId(s.execute("type.create", json!({"text": "oil", "size": 24, "x": 8, "y": 48})).unwrap()["layer"].as_u64().unwrap()); + Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().psd_blocks.push((*b"TySh", Arc::new(vec![1, 2, 3]))); + let before = s.active().unwrap().doc.clone(); + assert_eq!(load(&mut s, &file("Mk ", make_type_descriptor()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, [("type.convertToShape".into(), json!({}))]); + let saved = saved_item(&mut s); + assert_eq!(saved.event, AtnEvent::Code(*b"Mk ")); + let mut canonical = make_type_descriptor(); + canonical.class_id = Id::new("null"); + assert_eq!(saved.descriptor, Some(canonical.clone())); + assert_eq!(load(&mut s, &file("Mk ", canonical))["unsupported"], json!([])); + play(&mut s); + let layer = s.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Shape(shape) = &layer.content else { panic!("Type was not converted to Shape") }; + assert!(!shape.path.subpaths.is_empty()); + assert!(!layer.psd_blocks.iter().any(|(key, _)| key == b"TySh")); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); +} + +#[test] +fn make_type_shape_keeps_wrong_references_and_preserves_fill_mappings() { + for descriptor in [ + Descriptor::new("null").with("null", class_reference("contentLayer")), + Descriptor::new("null").with("null", class_reference("contentLayer")).with("Usng", target_class("Lyr ")), + Descriptor::new("null").with("null", class_reference("contentLayer")).with("Usng", ordinal_target("textLayer", "Frnt")), + make_type_descriptor().with("extra", D::Boolean(true)), + make_type_descriptor().with("Usng", target_class("textLayer")), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file("Mk ", descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved_item(&mut s).descriptor, Some(descriptor)); + } + for (id, p) in [("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})), ("layer.newFillLayer.pattern", json!({"pattern": "Grid"}))] { + let action = Action::new("A", vec![(id.into(), p)]); + let (bytes, skipped) = atn_cmds::export("Links", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let mut s = Session::new(); + assert_eq!(load(&mut s, &bytes)["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].0, id); + } + let action = Action::new("A", vec![("type.convertToShape".into(), json!({"layer": 1}))]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); +} + +fn radius_descriptor(radius: f64) -> Descriptor { + Descriptor::new("shapeBlur").with("Rds ", D::UnitFloat { unit: *b"#Pxl", value: radius }) +} + +fn impulse_fixture() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 9, "height": 9, "background": "transparent"})).unwrap(); + s.edit("impulse fixture", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_doc::Rect::new(4, 4, 5, 5), &[1.0, 1.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn radius_only_shape_blur_roundtrips_and_changes_pixels_including_default() { + for radius in [None, Some(5.0)] { + let mut s = impulse_fixture(); + let d = radius.map_or_else(|| Descriptor::new("shapeBlur"), radius_descriptor); + assert_eq!(load(&mut s, &file("shapeBlur", d.clone()))["unsupported"], json!([])); + let saved = saved_item(&mut s); + assert_eq!(saved.event, AtnEvent::name("shapeBlur")); + let mut canonical = d; + canonical.class_id = Id::new("null"); + assert_eq!(saved.descriptor, Some(canonical.clone())); + assert_eq!(load(&mut s, &file("shapeBlur", canonical))["unsupported"], json!([])); + let before = s.active().unwrap().doc.clone(); + let pixels = photocraft_compose::flatten(&before).into_px(); + play(&mut s); + let blurred = photocraft_compose::flatten(&s.active().unwrap().doc).into_px(); + assert_ne!(blurred, pixels); + let mut direct = impulse_fixture(); + direct.execute("filter.blur.shapeBlur", json!({"radius": radius.unwrap_or(10.0)})).unwrap(); + assert_eq!(blurred, photocraft_compose::flatten(&direct.active().unwrap().doc).px); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn shape_blur_radius_boundaries_keep_their_value_without_running_large_kernels() { + for radius in [5.0, 10.0, 1000.0] { + let mut s = Session::new(); + let mut canonical = radius_descriptor(radius); + assert_eq!(load(&mut s, &file("shapeBlur", canonical.clone()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].1["radius"].as_f64(), Some(radius)); + canonical.class_id = Id::new("null"); + assert_eq!(saved_item(&mut s).descriptor, Some(canonical)); + } +} + +#[test] +fn shape_blur_keeps_custom_assets_and_invalid_radius_forms_whole() { + let custom = Descriptor::new("customShape").with("Nm ", D::Text(UnicodeString::new_nul("Star"))).with("Idnt", D::Text(UnicodeString::new_nul("asset-id"))); + for descriptor in [ + radius_descriptor(4.0), + radius_descriptor(1001.0), + radius_descriptor(f64::NAN), + radius_descriptor(f64::INFINITY), + Descriptor::new("shapeBlur").with("Rds ", D::UnitFloat { unit: *b"#Prc", value: 10.0 }), + radius_descriptor(10.0).with("customShape", D::Descriptor(custom)).with("keyOriginType", D::Integer(9)), + radius_descriptor(10.0).with("extra", D::Boolean(true)), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file("shapeBlur", descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved_item(&mut s).descriptor.unwrap().to_bytes(), descriptor.to_bytes()); + } + for p in [json!({"radius": 0}), json!({"radius": 1001}), json!({"radius": null}), json!({"radius": 10, "shape": "circle"}), json!({"target": "mask"})] { + let action = Action::new("A", vec![("filter.blur.shapeBlur".into(), p)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } +} + +#[test] +fn link_descriptor_headers_normalize_without_changing_meaningful_keys() { + for event in ["linkSelectedLayers", "unlinkSelectedLayers"] { + let mut s = Session::new(); + let descriptor = Descriptor::new(event).with("null", target()); + assert_eq!(load(&mut s, &file(event, descriptor))["unsupported"], json!([])); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].event, AtnEvent::name(event)); + assert_eq!(parsed.actions[0].items[0].descriptor, Some(Descriptor::new("null").with("null", target()))); + } +} + +const SMART_PATHS: &[(&str, &str)] = &[ + ("placedLayerExportContents", "layer.smartObjects.exportContents"), + ("placedLayerConvertToLinked", "layer.smartObjects.convertToLinked"), + ("placedLayerRelinkToFile", "layer.smartObjects.relinkToFile"), + ("placedLayerReplaceContents", "layer.smartObjects.replaceContents"), +]; + +fn path_value(path: &str) -> D { + let units: Vec<_> = path.encode_utf16().chain(std::iter::once(0)).collect(); + let mut bytes = b"txtu".to_vec(); + bytes.extend_from_slice(&u32::try_from(12 + units.len() * 2).unwrap().to_le_bytes()); + bytes.extend_from_slice(&u32::try_from(units.len()).unwrap().to_le_bytes()); + bytes.extend(units.iter().flat_map(|unit| unit.to_le_bytes())); + D::Path(bytes) +} + +fn smart_path_descriptor(event: &str, path: &str) -> Descriptor { + if event == "placedLayerConvertToLinked" { + Descriptor::new("null").with("null", target()).with("Usng", path_value(path)) + } else { + Descriptor::new("null").with("null", path_value(path)) + } +} + +fn png(color: &str) -> Vec { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "background": color})).unwrap(); + photocraft_io::export(&s.active().unwrap().doc, "png", &Default::default()).unwrap().bytes +} + +fn smart_fixture(bytes: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + photocraft_engine::file_cmds::place_bytes(&mut s, "original.png", bytes.to_vec(), None, &json!({})).unwrap(); + s +} + +fn active_smart(s: &Session) -> &SmartObject { + let st = s.active().unwrap(); + let LayerContent::Smart(smart) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("expected a Smart Object") }; + smart +} + +fn assert_smart_file_output(event: &str, s: &Session, path: &str, original: &[u8], replacement: &[u8]) { + match event { + "placedLayerExportContents" => assert_eq!(std::fs::read(path).unwrap().as_slice(), original), + "placedLayerConvertToLinked" => { + assert_eq!(std::fs::read(path).unwrap().as_slice(), original); + assert!(matches!(&active_smart(s).source, SmartSource::Linked { path: actual } if actual == path)); + } + "placedLayerRelinkToFile" => assert!(matches!(&active_smart(s).source, SmartSource::Linked { path: actual } if actual == path)), + "placedLayerReplaceContents" => { + assert!(matches!(&active_smart(s).source, SmartSource::Embedded { bytes, .. } if bytes.as_slice() == replacement)); + } + _ => unreachable!(), + } +} + +#[test] +fn smart_paths_roundtrip_and_play_real_files_on_the_current_target() { + let original = png("#0000ff"); + let replacement = png("#ff0000"); + for &(event, id) in SMART_PATHS { + for header in ["null", event] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("contents Ω.png"); + let path = path.to_str().unwrap(); + let replacing = matches!(event, "placedLayerRelinkToFile" | "placedLayerReplaceContents"); + if replacing { + std::fs::write(path, &replacement).unwrap(); + } + let canonical = smart_path_descriptor(event, path); + let mut recorded = canonical.clone(); + recorded.class_id = Id::new(header); + let mut s = smart_fixture(&original); + if replacing { + let hint = i32::MAX; + assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, layer)| layer.id.0 != hint as u64)); + recorded = recorded.with("LyrI", D::Integer(hint)); + } + assert_eq!(load(&mut s, &file(event, recorded))["unsupported"], json!([]), "{event}"); + assert_eq!(s.actions.list[0].steps, [(id.to_string(), json!({"path": path}))]); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].event, AtnEvent::name(event)); + // Reference #10603, docs/atn.md:197–201: normalize the descriptor class, + // and omit session LyrI. All meaningful event/path keys remain identical. + assert_eq!(parsed.actions[0].items[0].descriptor, Some(canonical)); + assert_eq!(load(&mut s, &bytes)["unsupported"], json!([])); + let before = s.active().unwrap().doc.clone(); + let before_smart = active_smart(&s).clone(); + play(&mut s); + assert_eq!(active_smart(&s).transform, before_smart.transform); + assert_smart_file_output(event, &s, path, &original, &replacement); + if replacing { + assert!(photocraft_compose::flatten(&s.active().unwrap().doc).px.iter().all(|px| *px == [1.0, 0.0, 0.0, 1.0])); + } + if event == "placedLayerExportContents" { + assert_eq!(s.active().unwrap().doc, before); + } else { + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before); + } + } + } +} + +#[test] +fn smart_paths_keep_missing_unusable_and_unrepresented_descriptors_whole() { + for &(event, _) in SMART_PATHS { + let key = if event == "placedLayerConvertToLinked" { "Usng" } else { "null" }; + for descriptor in [ + Descriptor::new("null"), + Descriptor::new("null").with(key, path_value("")), + Descriptor::new("null").with(key, path_value("a\0b")), + Descriptor::new("null").with(key, D::Text(UnicodeString::new("/tmp/file.png"))), + Descriptor::new("null").with(key, D::Path(b"txtu".to_vec())), + Descriptor::new("null").with("Usng", path_value("/tmp/file.png")), + smart_path_descriptor(event, "/tmp/file.png").with("extra", D::Integer(1)), + smart_path_descriptor(event, "/tmp/file.png").with("LyrI", D::Boolean(true)), + smart_path_descriptor(event, "/tmp/file.png").with("LyrI", D::Integer(-1)), + smart_path_descriptor(event, "/tmp/file.png").with("LyrI", D::LargeInteger(i64::from(i32::MAX) + 1)), + ] { + let mut s = Session::new(); + assert_eq!(load(&mut s, &file(event, descriptor.clone()))["unsupported"].as_array().unwrap().len(), 1); + let saved = s.execute("actions.save", json!({"set": "Links"})).unwrap(); + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(descriptor)); + } + } +} + +#[test] +fn smart_path_exports_refuse_unrepresentable_native_params() { + for &(_, id) in SMART_PATHS { + for params in [json!({}), json!({"path": ""}), json!({"path": "a\0b"}), json!({"path": 3}), json!({"path": "/tmp/a.png", "layer": 1})] { + let action = Action::new("A", vec![(id.into(), params)]); + assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + } + } +} + +#[test] +fn unusable_smart_file_at_playback_fails_without_changing_the_document() { + let original = png("#0000ff"); + let dir = tempfile::tempdir().unwrap(); + for &(event, id) in SMART_PATHS { + let mut s = smart_fixture(&original); + assert_eq!(load(&mut s, &file(event, smart_path_descriptor(event, dir.path().to_str().unwrap())))["unsupported"], json!([])); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + let report = s.execute("actions.play", json!({"action": "A", "set": "Links"})).unwrap(); + assert_eq!(report["ran"], 0, "{event}: {report}"); + assert_eq!(report["failed"]["id"], id, "{event}: {report}"); + assert!(report["failed"]["error"].as_str().is_some_and(|error| !error.is_empty()), "{event}: {report}"); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} diff --git a/crates/engine/tests/it/crop_delete_off_background.rs b/crates/engine/tests/it/crop_delete_off_background.rs new file mode 100644 index 0000000000..96350d4a1c --- /dev/null +++ b/crates/engine/tests/it/crop_delete_off_background.rs @@ -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 = 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::>(), names, "no layer renamed"); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 467b04720d..8e8553d509 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -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; diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index d133832ce2..9364287344 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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 diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 6086aaf644..89bf250b4e 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -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, } 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, /// The layer written as Photoshop's Background (see [`psd_background`]). background: Option, + /// Where encoded channels go instead of memory ([`PsdExportOptions::stage_on_scratch_from`]). + stage: Option>, } /// 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 { @@ -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. diff --git a/crates/io/src/psd_export/banded.rs b/crates/io/src/psd_export/banded.rs new file mode 100644 index 0000000000..a2aed97473 --- /dev/null +++ b/crates/io/src/psd_export/banded.rs @@ -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> = const { std::cell::Cell::new(None) }; + /// Bands this thread has gathered and encoded. + pub(crate) static BANDS_DONE: std::cell::Cell = 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, + zip: Option>>, + 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 { + // 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> { + 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 = (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>> = 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)> = + 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::>()?; + #[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() + } +} diff --git a/crates/io/src/psd_export/document.rs b/crates/io/src/psd_export/document.rs index f6c36c2d42..b46a21fc3c 100644 --- a/crates/io/src/psd_export/document.rs +++ b/crates/io/src/psd_export/document.rs @@ -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, 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. { diff --git a/crates/io/src/psd_export/stage.rs b/crates/io/src/psd_export/stage.rs new file mode 100644 index 0000000000..869208e319 --- /dev/null +++ b/crates/io/src/psd_export/stage.rs @@ -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> = 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, + 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) -> Option> { + 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 { + 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 { + 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 { + 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>, + head_len: usize, + /// Without a stage: the head's room and the parts so far. + joined: Vec, + /// With a stage: the parts, staged or (when a write failed) kept. + parts: Vec, + len: u64, +} + +enum Part { + Kept(Vec), + 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>, 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) { + 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 { + 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, + parts: Vec, + starts: Vec, + 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(()) + } +} diff --git a/crates/io/src/psd_export/tests.rs b/crates/io/src/psd_export/tests.rs index c53f2c06f3..fdf0288c9d 100644 --- a/crates/io/src/psd_export/tests.rs +++ b/crates/io/src/psd_export/tests.rs @@ -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> { + 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 = (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) -> (PsdFile, Vec) { + 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); +} diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index fdb31c93b5..619e9e53c9 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -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 diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index 0e5e08d699..384070f286 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -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] diff --git a/crates/psd/src/codepage.rs b/crates/psd/src/codepage.rs index 75e0f7c468..aec13de6bf 100644 --- a/crates/psd/src/codepage.rs +++ b/crates/psd/src/codepage.rs @@ -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(&[], ""); diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index 5a49284c44..73c71bab1d 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -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 { - 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 { + 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). diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 1e2c60f827..910d19563a 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -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, std::sync::Mutex); + 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, 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] diff --git a/crates/psd/src/io.rs b/crates/psd/src/io.rs index eb7185bde2..8537b74649 100644 --- a/crates/psd/src/io.rs +++ b/crates/psd/src/io.rs @@ -213,6 +213,8 @@ impl WriteExt for Vec { /// 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| { diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs index fc1a3ff571..90f6550863 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -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)?, } } } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 994c02c57c..77121d0e7d 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -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)] diff --git a/crates/raster/src/scratch_file.rs b/crates/raster/src/scratch_file.rs index 188c0cbcd1..19a9a1056b 100644 --- a/crates/raster/src/scratch_file.rs +++ b/crates/raster/src/scratch_file.rs @@ -118,35 +118,9 @@ impl std::fmt::Debug for ScratchFile { impl ScratchFile { pub fn create(dir: &Path) -> std::io::Result { - 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 { @@ -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()) } diff --git a/crates/raster/src/stage_file.rs b/crates/raster/src/stage_file.rs new file mode 100644 index 0000000000..aff858c279 --- /dev/null +++ b/crates/raster/src/stage_file.rs @@ -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, + /// Each append by its offset, with its checksum and the read pass under way. + extents: Mutex>, +} + +struct Extent { + len: u64, + crc32: u32, + /// Where the current read pass continues (`None` once it skipped or jumped: not checked). + next: Option, + 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 { + 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 { + 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 { + 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> = (0..8u8).map(|i| vec![i; 1000 + usize::from(i)]).collect(); + let offsets: Vec = 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::()); + 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); + } +} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 3aa0e6d40f..b88ec9ec72 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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. diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 5bfd070fcf..de9c6dc147 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -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, choices: FormatChoices) -> Result { - 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 { + 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 { + 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) => { diff --git a/crates/ui-egui/src/canvas/background_redraw/tests.rs b/crates/ui-egui/src/canvas/background_redraw/tests.rs index a5397c77dd..ce47c0c003 100644 --- a/crates/ui-egui/src/canvas/background_redraw/tests.rs +++ b/crates/ui-egui/src/canvas/background_redraw/tests.rs @@ -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(); diff --git a/crates/ui-egui/src/canvas/filter_preview.rs b/crates/ui-egui/src/canvas/filter_preview.rs index 0144390b46..3a6ee54d97 100644 --- a/crates/ui-egui/src/canvas/filter_preview.rs +++ b/crates/ui-egui/src/canvas/filter_preview.rs @@ -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); } diff --git a/crates/ui-egui/src/canvas/gpu_canvas.rs b/crates/ui-egui/src/canvas/gpu_canvas.rs index 19a3b55846..ef9435f346 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas.rs @@ -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::()?.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::().map(|res| (bytes, res.canvas_budget.load(Ordering::Relaxed))) + r.callback_resources.get::().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::().is_none_or(|res| res.residency.shows(shown)) { + return; + } + if let Some(res) = self.rs.renderer.write().callback_resources.get_mut::() { + 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::() 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::()?; 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)); } diff --git a/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs b/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs index 155bb21c91..e1c2389bfb 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs @@ -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(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option) { + 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 { + 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) { diff --git a/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs b/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs index feb0df375e..8de45748b6 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/mip_tests.rs @@ -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 diff --git a/crates/ui-egui/src/canvas/gpu_canvas/pages.rs b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs index b5f9fe7e7d..bc62505d7e 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/pages.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs @@ -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) } diff --git a/crates/ui-egui/src/canvas/gpu_canvas/residency.rs b/crates/ui-egui/src/canvas/gpu_canvas/residency.rs new file mode 100644 index 0000000000..df681a000b --- /dev/null +++ b/crates/ui-egui/src/canvas/gpu_canvas/residency.rs @@ -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::()).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 = (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, + 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 = 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 { + 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)]); + } +} diff --git a/crates/ui-egui/src/canvas/gpu_canvas/resources.rs b/crates/ui-egui/src/canvas/gpu_canvas/resources.rs index ce8f8b0881..d9990f0920 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/resources.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/resources.rs @@ -36,8 +36,10 @@ pub(super) struct Resources { pub(super) layer_fit: Option, pub(super) compositor_cache_budget: Option, pub(super) compositor_focus: Option, - /// 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, + /// 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, } diff --git a/crates/ui-egui/src/canvas/scrollbars.rs b/crates/ui-egui/src/canvas/scrollbars.rs index 375edc13b6..305e9c86d3 100644 --- a/crates/ui-egui/src/canvas/scrollbars.rs +++ b/crates/ui-egui/src/canvas/scrollbars.rs @@ -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"); } } } diff --git a/crates/ui-egui/src/canvas/snap_ui.rs b/crates/ui-egui/src/canvas/snap_ui.rs index 4e1537d41b..8b3c6525a1 100644 --- a/crates/ui-egui/src/canvas/snap_ui.rs +++ b/crates/ui-egui/src/canvas/snap_ui.rs @@ -381,10 +381,13 @@ fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: let deg = crate::crop_ui::angle(app); let ratio = crate::crop_ui::resize_ratio(app, rect, mods); let q = view.map_or(p, |view| crate::crop_mode::to_press(app, Some(view), p)); - // The frame for the pointer at `q`, as `crop_ui` resizes it. - let frame_at = |q: [f64; 2]| match view { - Some(_) => crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio, alt: mods.alt }).map(|(f, _)| f), - None => Some(crate::crop_ui::resized_turned(rect, deg, hx, hy, [q[0] - start[0], q[1] - start[1]], ratio, mods.alt)), + // The frame for the pointer at `q`, as `crop_ui` resizes it (a ⇧ corner along its diagonal). + let frame_at = |q: [f64; 2]| { + let q = crate::crop_ui::resize_point(app, rect, hx, hy, start, q, mods); + match view { + Some(_) => crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio, alt: mods.alt }).map(|(f, _)| f), + None => Some(crate::crop_ui::resized_turned(rect, deg, hx, hy, [q[0] - start[0], q[1] - start[1]], ratio, mods.alt)), + } }; let frame = frame_at(q)?; let at = |r: [f64; 4]| { @@ -417,17 +420,37 @@ fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: let original = at(rect); let travel = crate::crop_ui::turn([handle[0] - original[0], handle[1] - original[1]], [0.0, 0.0], -deg); let mut local = [(travel[0] + d[0]) / scale, (travel[1] + d[1]) / scale]; - if deg == 0.0 - && hx != 0 + if hx != 0 && hy != 0 && let Some(k) = ratio { - // A corner with a ratio has one degree of freedom. Put the other axis on the same - // ratio, rather than letting the raw pointer's unused axis override the snapped edge. - if lines.iter().any(|line| line.vertical) { - local[1] = f64::from(hy) * ((rect[2] - rect[0] + grow * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / grow; + if deg == 0.0 { + // A corner with a ratio has one degree of freedom. Put the other axis on the same + // ratio, rather than letting the raw pointer's unused axis override the snapped edge. + if lines.iter().any(|line| line.vertical) { + local[1] = f64::from(hy) * ((rect[2] - rect[0] + grow * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / grow; + } else { + local[0] = f64::from(hx) * ((rect[3] - rect[1] + grow * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / grow; + } } else { - local[0] = f64::from(hx) * ((rect[3] - rect[1] + grow * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / grow; + // A turned corner with a ratio slides along its turned diagonal, which crosses the + // guides at an angle: slide it onto the nearest line it reached. Moved sideways onto + // the line instead, the ratio (a ⇧ corner's diagonal) put it back off the line. + let along = [f64::from(hx) * k, f64::from(hy)]; + let v = crate::crop_ui::turn(along, [0.0, 0.0], deg); + // A diagonal nearly parallel to the line meets it far along: snap only to a crossing + // inside the tolerance box `snap_point` read the handle in (|s·v| ≤ tol on each axis). + let reach = tol / v[0].abs().max(v[1].abs()); + let slide = lines + .iter() + .filter_map(|line| { + let (gap, step) = if line.vertical { (line.pos - handle[0], v[0]) } else { (line.pos - handle[1], v[1]) }; + (step.abs() > 1e-9).then(|| gap / step) + }) + .filter(|s| s.abs() <= reach) + .min_by(|a, b| a.abs().total_cmp(&b.abs())); + let Some(s) = slide else { return Some((p, Vec::new())) }; + local = [(travel[0] + s * along[0]) / scale, (travel[1] + s * along[1]) / scale]; } } let d = crate::crop_ui::turn(local, [0.0, 0.0], deg); diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 2ab777b532..8d06d2a499 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -5,6 +5,9 @@ use super::{Color32, PhotocraftApp, Pos2, Rect, Sense, Stroke, TextureOptions, c /// Tabs + canvas for the active document, or the start screen; then drops layers dragged onto /// another document (`layer_transfer`). pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if crate::jobs_ui::batch_preview(app, ui) { + return; + } crate::distort_ui::drop_foreign(app); documents(app, ui); crate::layer_transfer::finish(app, ui.ctx()); @@ -29,10 +32,29 @@ pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option = match arranged_cells(app, app.last_canvas_rect) { + Some(cells) => cells.into_iter().map(|(d, _)| d).collect(), + None => main_canvas_document(app).into_iter().collect(), + }; + shown.extend(app.ui.windows.iter().filter(|w| w.open).map(|w| w.document)); + let mut shown: Vec<(u64, u64)> = shown.into_iter().filter_map(|d| docs.get(d)).map(|st| (st.doc.id.0, gpu.canvas_bytes(&st.doc))).collect(); + shown.sort_unstable(); + shown.dedup(); + gpu.show_documents(&shown); +} + fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.drop_canvas_rect = None; app.tab_strip = None; retain_gpu_documents(app); + publish_shown(app); crate::transform_tool::track_steps(app, ui.ctx()); crate::cas_transform::tick(app, ui.ctx()); crate::symmetry_ui::track(app, ui.ctx()); @@ -66,6 +88,8 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.tab_strip = Some(tabs(app, ui)); ui.spacing_mut().item_spacing.y = spacing; drop_slot_line(app, ui); + // A tab click switches documents before any canvas is drawn this frame. + publish_shown(app); } if let Some(job) = app.jobs.focus.or_else(|| (n == 0).then(|| app.jobs.opens.last().map(|o| o.job)).flatten()) { crate::jobs_ui::open_card(app, ui, job); diff --git a/crates/ui-egui/src/canvas/tabs_tests.rs b/crates/ui-egui/src/canvas/tabs_tests.rs index 677949600b..6285b7e651 100644 --- a/crates/ui-egui/src/canvas/tabs_tests.rs +++ b/crates/ui-egui/src/canvas/tabs_tests.rs @@ -150,6 +150,17 @@ fn ctrl_tab_cycles_through_the_document_tabs() { assert!(!h.state().ui.panels.dock, "plain Tab hides the panels"); } +#[test] +fn ctrl_tab_records_a_relative_document_switch() { + let mut h = harness(2, ThemeKind::Pro); + h.state_mut().run("actions.record", json!({"name": "Switch"})).unwrap(); + h.key_press_modifiers(ctrl(), egui::Key::Tab); + h.run_steps(2); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let steps = &h.state().session.actions.list[0].steps; + assert_eq!(steps, &[("document.activate".into(), json!({"offset": -1}))]); +} + /// One document: Ctrl+Tab stays on it quietly; a focused widget or text field doesn't keep the key. #[test] fn ctrl_tab_with_one_document_or_a_focused_field() { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6bce89cc0d..73ef696976 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -371,6 +371,31 @@ fn drive_until(app: &mut PhotocraftApp, ctx: &egui::Context, what: &str, done: i } } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_held_preview_frame_stays_on_the_canvas_when_the_proxy_factor_changes() { + // #10474: Minimum and Maximum take their proxy factor from the radius, so a radius drag across + // a whole pixel changes `FilterPreviewKey::k`. The frame for the old factor stays on the + // canvas, drawn at its own factor, until the one for the new factor lands. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 2400, "height": 2200})).unwrap(); + app.background_jobs = true; + app.ui.views.get_mut(0).expect("the new document's view").zoom = 0.25; + crate::filter_dialog::open(&mut app, "filter.other.maximum").unwrap(); + app.ui.dialogs[0].fields.insert("radius".into(), json!(6.0)); + let ctx = egui::Context::default(); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.as_ref().is_some_and(|p| p.result.is_some())); + assert_eq!(app.filter_preview.as_ref().map(|p| p.key.k), Some(2), "6 px on a reduced document previews on the proxy"); + + // 1.5 px previews at full resolution: a new request at a new factor, computed on the worker. + app.ui.dialogs[0].fields.insert("radius".into(), json!(1.5)); + let (k, _, size) = ensure_filter_preview(&mut app, 0, &ctx).expect("the held frame stays on the canvas"); + assert_eq!((k, size), (2, [1200, 1100]), "drawn at its own factor"); + drive_until(&mut app, &ctx, "the full-resolution frame", |a| a.filter_preview.as_ref().is_some_and(|p| p.key.k == 1 && p.result.is_some())); + let (k, _, size) = ensure_filter_preview(&mut app, 0, &ctx).expect("the new frame"); + assert_eq!((k, size), (1, [2400, 2200])); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn a_preview_computed_mid_drag_reaches_the_canvas_and_then_converges() { diff --git a/crates/ui-egui/src/canvas/viewport_pages.rs b/crates/ui-egui/src/canvas/viewport_pages.rs index 72483f9c0e..07933ede30 100644 --- a/crates/ui-egui/src/canvas/viewport_pages.rs +++ b/crates/ui-egui/src/canvas/viewport_pages.rs @@ -209,6 +209,10 @@ pub(crate) struct Stream { /// The page ids last given to the GPU canvas, to tell it only of changes. synced: Vec, health: (u64, u64), + /// Each window showing the document (New Window for Document): the clock of its last frame + /// and the pages it wanted, so one window's frame doesn't cancel, drop or evict the pages + /// another window still shows. + views: HashMap)>, pub stats: Stats, } @@ -227,6 +231,7 @@ impl Default for Stream { clock: 0, synced: Vec::new(), health: (0, 0), + views: HashMap::new(), stats: Stats::default(), } } @@ -317,13 +322,28 @@ impl Stream { } } + /// Window `view` is about to show `wanted` (call before [`Self::update`] in its frame). + pub fn view(&mut self, view: u64, wanted: &[PageId]) { + self.views.insert(view, (self.clock + 1, wanted.to_vec())); + } + + /// Whether a window other than the one updating shows `page`: one whose frame was at most + /// [`VIEW_TTL`] updates ago (a closed window's pages are released after that). + fn shown_elsewhere(&self, page: &PageId) -> bool { + self.views.values().any(|(at, pages)| *at != self.clock && *at + VIEW_TTL >= self.clock && pages.contains(page)) + } + /// Take finished work, cancel work for pages not `wanted`, and start work for wanted pages /// that are missing, dirty or still a quick preview (`faithful`: the document has a quick /// preview at reductions), as `spawn(page, epoch, exact, cancel, region)` allows: `region`, the /// part of a held page to render again ([`Page::partial`]), or the whole page. pub fn update(&mut self, wanted: &[PageId], faithful: bool, mut spawn: impl FnMut(PageId, u64, bool, Arc, Option) -> bool) { self.clock += 1; - let want: HashSet = wanted.iter().copied().collect(); + let clock = self.clock; + self.views.retain(|_, (at, _)| *at + VIEW_TTL >= clock); + // What any window shows: work and results for another window's pages are kept. + let want: HashSet = + wanted.iter().copied().chain(self.views.values().filter(|(at, _)| *at != clock).flat_map(|(_, pages)| pages.iter().copied())).collect(); while let Ok(d) = self.rx.try_recv() { let Some(i) = self.jobs.iter().position(|j| j.page == d.page && j.epoch == d.epoch) else { continue }; // Cancelled work ends in an interruption, not a failure: the page is asked for again. @@ -396,10 +416,11 @@ impl Stream { } } - /// Whether a wanted page can't be shown: its render or upload failed and nothing older of it - /// is held, so the view would have a hole (the CPU canvas draws instead). + /// Whether a wanted page can't be shown: its render or upload failed and nothing current of + /// it is held, so the view would have a hole, or keep showing the page from before an edit + /// (the CPU canvas draws instead). pub fn stuck(&self, wanted: &[PageId]) -> bool { - wanted.iter().any(|p| !self.pages.contains_key(p) && self.failed.contains(&(*p, self.epoch(*p)))) + wanted.iter().any(|p| self.pages.get(p).is_none_or(|q| q.dirty) && self.failed.contains(&(*p, self.epoch(*p)))) } /// Whether work is outstanding (the view repaints until it is done). @@ -426,12 +447,18 @@ impl Stream { } /// Make room for `bytes` more under `limit` by evicting the least recently wanted pages not - /// in `wanted`: whether any was evicted, or `None` when the wanted pages leave no room. + /// in `wanted` (another window's last: they are only shown there): whether any was evicted, + /// or `None` when the wanted pages leave no room. pub fn make_room(&mut self, page: PageId, bytes: u64, limit: u64, wanted: &[PageId]) -> Option { let held = |s: &Self| s.pages.iter().filter(|(p, _)| **p != page).map(|(_, q)| q.bytes).sum::(); let mut evicted = false; while held(self) + bytes > limit { - let cold = self.pages.iter().filter(|(p, _)| **p != page && !wanted.contains(p)).min_by_key(|(_, q)| q.touched).map(|(p, _)| *p)?; + let cold = self + .pages + .iter() + .filter(|(p, _)| **p != page && !wanted.contains(p)) + .min_by_key(|(p, q)| (self.shown_elsewhere(p), q.touched)) + .map(|(p, _)| *p)?; self.drop_page(cold); self.stats.evicted += 1; evicted = true; @@ -482,7 +509,8 @@ impl Stream { pub fn prune(&mut self, wanted: &[PageId]) -> Option> { let all_current = wanted.iter().all(|p| self.pages.get(p).is_some_and(|q| !q.dirty)); if all_current && let Some(f) = wanted.first().map(|p| p.factor) { - let drop: Vec = self.pages.iter().filter(|(p, q)| !wanted.contains(p) && (q.dirty || p.factor < f)).map(|(p, _)| *p).collect(); + let drop: Vec = + self.pages.iter().filter(|(p, q)| !wanted.contains(p) && !self.shown_elsewhere(p) && (q.dirty || p.factor < f)).map(|(p, _)| *p).collect(); for p in drop { self.drop_page(p); } @@ -506,6 +534,9 @@ impl Stream { } } +/// How many stream updates a window's pages stay protected after its last frame. +const VIEW_TTL: u64 = 64; + /// Render `page` of `doc` on a worker, if one of the [`MAX_JOBS`] is free. fn spawn( ctx: &egui::Context, @@ -596,6 +627,21 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: } return false; } + let key = stream_key(id.0); + // Half the document's share of the canvas budget: the rest is headroom for what the + // compositor needs. + let limit = gpu.canvas_fit(&doc).map_or(0, |(_, share)| share) / 2; + let page_bytes = gpu.page_texture_bytes(doc.depth, size, PAGE_SIDE + 2, PAGE_SIDE + 2).max(1); + let max_pages = usize::try_from(limit / page_bytes).unwrap_or(usize::MAX); + // No room for a single page: the CPU canvas draws the document, as main does for every + // document over the budget, and the pages and work held for it are freed. + if max_pages == 0 { + if let Some(s) = app.streams.remove(&id) { + drop(s); + gpu.retain_pages(key, &[]); + } + return false; + } let (display, _) = canvas_display(app, &doc, None); let display_key = texture_key(display.as_deref()); let now = Identity { revision, preview: raw_key, display: display_key, size }; @@ -606,11 +652,6 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: .and_then(|s| damage_since(app, idx, (s.revision, s.preview ^ display_key), (revision, raw_key), display_key, last_damage)); app.streams.entry(id).or_default().invalidate(now, damage); } - let key = stream_key(id.0); - // Half the per-document canvas budget: the rest is headroom for what the compositor needs. - let limit = gpu.canvas_budget() / 2; - let page_bytes = gpu.page_texture_bytes(doc.depth, size, PAGE_SIDE + 2, PAGE_SIDE + 2).max(1); - let max_pages = usize::try_from(limit / page_bytes).unwrap_or(usize::MAX); let wanted = pages(size, visible_doc_rect(xf), zoom, max_pages); if gpu.texture_info(id.0).is_some() { // Switching from the texture set: free it, the pages are budgeted against that memory. @@ -618,9 +659,8 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: } let stream = app.streams.entry(id).or_default(); stream.retry_after((photocraft_raster::spill::read_error_generation(), photocraft_raster::memory::configuration_generation())); - // No room for a single page, or a page that failed: the CPU canvas draws the document, as - // main does for every document over the budget. - if max_pages == 0 || stream.stuck(&wanted) { + // A page that failed: the CPU canvas draws the document until it is retried. + if stream.stuck(&wanted) { return false; } let faithful = photocraft_compose::proxy::proxy_faithful(&doc); @@ -637,6 +677,7 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: app.navigator_gpu.remove(&id); crate::render_path::note(app, id.0, crate::render_path::Path::Gpu { full: true }); let stream = app.streams.entry(id).or_default(); + stream.view(ctx.viewport_id().0.value(), &wanted); let tx = stream.tx.clone(); stream.update(&wanted, faithful, |page, epoch, exact, cancel, region| { spawn(ctx, tx.clone(), doc.clone(), display.clone(), (page, epoch, exact, region), cancel) @@ -926,6 +967,71 @@ mod tests { assert!(!s.stuck(&view)); } + /// An edited page whose new render fails isn't left showing its old content (#5332 review). + #[test] + fn a_dirty_page_whose_render_failed_falls_back() { + let view = [page(1, 0, 0)]; + let mut s = holding(&view); + s.invalidate(id(2), None); + assert!(s.pages[&view[0]].dirty && !s.stuck(&view), "the old page shows while it renders"); + let mut log = Vec::new(); + s.update(&view, true, recorder(&mut log)); + s.tx.send(Done { page: view[0], epoch: log[0].1, exact: true, region: None, result: Err("out of memory".into()) }).unwrap(); + s.update(&view, true, |_, _, _, _, _| panic!("no retry while failed")); + assert!(s.stuck(&view), "a stale page is not drawn in place of the failed one"); + s.retry_after((7, 7)); + assert!(!s.stuck(&view)); + } + + /// #5332 review: two windows showing one document (New Window for Document) share its + /// stream. One window's frame doesn't cancel the other's work, drop its results, evict its + /// pages or prune them as finer than its own view; a window gone for a while releases them. + #[test] + fn windows_on_one_document_keep_each_others_pages() { + let (a, b) = ([page(1, 0, 0)], [page(4, 0, 0)]); + let mut s = holding(&[]); + let (mut log_a, mut log_b) = (Vec::new(), Vec::new()); + s.view(1, &a); + s.update(&a, true, recorder(&mut log_a)); + s.view(2, &b); + s.update(&b, false, recorder(&mut log_b)); + assert!(!log_a[0].3.load(Ordering::Relaxed), "window 2's frame keeps window 1's work"); + // Window 1's page finishes during window 2's frame: it is kept for upload. + s.tx.send(done(a[0], log_a[0].1, true, 4)).unwrap(); + s.view(2, &b); + s.update(&b, false, |_, _, _, _, _| true); + assert!(s.ready.iter().any(|d| d.page == a[0]), "window 1's result is kept"); + for d in s.take_uploads(&b, 16) { + s.uploaded(&d, 100, true); + } + s.tx.send(done(b[0], log_b[0].1, true, 4)).unwrap(); + s.view(2, &b); + s.update(&b, false, |_, _, _, _, _| true); + for d in s.take_uploads(&b, 16) { + s.uploaded(&d, 100, true); + } + assert_eq!(s.resident(), 2); + // Window 2's view is current: window 1's finer page isn't pruned, nor evicted first. + s.prune(&b); + assert!(s.pages.contains_key(&a[0]), "not pruned while window 1 shows it"); + s.pages.insert(page(4, 1, 0), Page { dirty: false, exact: true, partial: None, epoch: 0, touched: s.clock, bytes: 100 }); + assert_eq!(s.make_room(page(4, 2, 0), 100, 300, &b), Some(true)); + assert!(s.pages.contains_key(&a[0]) && !s.pages.contains_key(&page(4, 1, 0)), "a page no window shows goes first"); + // Window 1 closed: once its pages are past the grace period, window 2 prunes them. + for _ in 0..=VIEW_TTL { + s.view(2, &b); + s.update(&b, false, |_, _, _, _, _| true); + } + s.prune(&b); + assert!(!s.pages.contains_key(&a[0]), "released after the closed window's grace period"); + // Without a recorded window (one view), nothing changes: other pages are cancelled. + let mut s = holding(&[]); + let mut log = Vec::new(); + s.update(&a, true, recorder(&mut log)); + s.update(&b, true, |_, _, _, _, _| true); + assert!(log[0].3.load(Ordering::Relaxed)); + } + #[test] fn prune_drops_finer_and_dirty_pages_once_the_view_is_current() { let (fine, coarse, view) = (page(1, 0, 0), page(4, 0, 0), page(2, 0, 0)); diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index 7d9c6dd5cd..3c667480f6 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -5,6 +5,7 @@ use super::params::{ input_object, merged_object, modifiers_from, num_field, ok, screen_point, uint_field, whole_object, }; use super::{ControlRequest, DialogKind, Outcome, PhotocraftApp, SHELL_DIALOG, Tool, ToolEvent, Value, json, run_waiting, tool_event, wrap}; +use photocraft_engine::jobs::{CancelReport, JobId}; /// A domain handler: `Some` when it owns the request's method, `None` to let the next one try. type Handler = fn(&mut PhotocraftApp, &egui::Context, &ControlRequest) -> Option; @@ -123,20 +124,29 @@ fn execute_command(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRe fn jobs_cancel(app: &mut PhotocraftApp, p: &Value) -> Outcome { let all = p.get("job").is_none_or(Value::is_null); match p.get("job").and_then(Value::as_u64) { - Some(j) if app.session.job(photocraft_engine::jobs::JobId(j)).is_some() => { - let cancelled = crate::jobs_ui::cancel(app, photocraft_engine::jobs::JobId(j)); - ok(json!({"cancelled": usize::from(cancelled)})) - } + // Same reply as the engine's `jobs.cancel`: `{cancelled: n}`, plus `notCancelled: + // [{job, reason}]` for the jobs left running (a save already writing its file, a print). + Some(j) if app.session.job(JobId(j)).is_some() => ok(cancel_each(app, vec![JobId(j)]).to_json()), Some(j) => err(format!("no running job {j}")), None if all => { let ids: Vec<_> = app.session.jobs().into_iter().map(|j| j.id).collect(); - let cancelled = ids.into_iter().filter(|j| crate::jobs_ui::cancel(app, *j)).count(); - ok(json!({"cancelled": cancelled})) + ok(cancel_each(app, ids).to_json()) } None => err("`job` must be a job id"), } } +fn cancel_each(app: &mut PhotocraftApp, ids: Vec) -> CancelReport { + let mut report = CancelReport::default(); + for id in ids { + match crate::jobs_ui::try_cancel(app, id) { + Ok(()) => report.cancelled.push(id), + Err(why) => report.refused.push((id, why)), + } + } + report +} + // ---- the shell: view state, windows, GPU, focus, screenshots ---- fn shell_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Option { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 9868624183..dc630df23c 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -879,6 +879,46 @@ fn ui_set_gradient_blend_mode_validates_and_updates_options() { assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options"); } +/// The Layers panel's Artboard filter type is drivable; it has no criteria of its own. +#[test] +fn ui_set_drives_the_layers_panel_artboard_filter() { + use crate::layer_filter_ui::FilterBy; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboard"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard); + let bad = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboards"}})); + assert_eq!(bad["ok"], false, "{bad}"); + assert!(bad.to_string().contains("kind, name, mode, color, smartObject or artboard"), "{bad}"); + assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard, "a bad value changed nothing"); +} + +/// The Layers panel's Smart Object filter takes a list of its toggles. +#[test] +fn ui_set_drives_the_layers_panel_smart_object_filter() { + use crate::layer_filter_ui::FilterBy; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "smartObject", "smart": ["missing", "embedded", "missing"]}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!(app.ui.layer_filter_by, FilterBy::SmartObject); + assert_eq!(app.ui.layer_filter_smart, ["missing", "embedded"], "toggles are kept once each"); + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"smart": []}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert!(app.ui.layer_filter_smart.is_empty()); + for bad in [ + json!({"layerFilter": {"smart": ["library"]}}), + json!({"layerFilter": {"smart": "linked"}}), + json!({"layerFilter": {"by": "kind", "smart": [1]}}), + json!({"layerFilter": {"by": "smart"}}), + ] { + let r = call(&mut app, &ctx, "ui.set", bad.clone()); + assert_eq!(r["ok"], false, "{bad} → {r}"); + assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_smart.len()), (FilterBy::SmartObject, 0), "{bad} changed nothing"); + } +} + /// #5048: the Layers panel filter is drivable; a bad field changes nothing. #[test] fn ui_set_drives_the_layers_panel_filter() { @@ -900,6 +940,7 @@ fn ui_set_drives_the_layers_panel_filter() { json!({"layerFilter": {"kinds": "pixel"}}), json!({"layerFilter": {"name": 3}}), json!({"layerFilter": {"mode": "Nonsense"}}), + json!({"layerFilter": {"color": "crimson"}}), json!({"layerFilter": {"by": "kind", "extra": true}}), json!({"layerFilter": {"by": "kind"}, "tileGrid": "yes"}), ] { @@ -907,6 +948,10 @@ fn ui_set_drives_the_layers_panel_filter() { assert_eq!(r["ok"], false, "{bad} → {r}"); assert_eq!(app.ui.layer_filter_by, FilterBy::Mode, "{bad} changed nothing"); } + // #5898: the Color filter through the same path. + let ok = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "color", "color": "red"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!((app.ui.layer_filter_by, app.ui.layer_filter_color), (FilterBy::Color, photocraft_doc::LabelColor::Red)); } #[test] diff --git a/crates/ui-egui/src/dialogs/dds_options_ui.rs b/crates/ui-egui/src/dialogs/dds_options_ui.rs index 6864e098c6..a696e458b6 100644 --- a/crates/ui-egui/src/dialogs/dds_options_ui.rs +++ b/crates/ui-egui/src/dialogs/dds_options_ui.rs @@ -96,7 +96,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let settings = ExportSettings { dds: p.options, ..app.save_settings() }; let mut step = if p.record == "file.save" { serde_json::json!({}) } else { serde_json::json!({"path": p.path}) }; record(p.options, &mut step); - let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy)); + let written = app.with_document(p.doc, |app| { + // A Save As that keeps the document's own name records only its folder (#5305). + app.record_answered_save_as(p.record, &mut step); + app.write_document(p.path, &settings, p.copy) + }); if written.is_ok() { // An action being recorded keeps the Save As or Save a Copy with this answer. app.session.journal.push((p.record.into(), step)); diff --git a/crates/ui-egui/src/dialogs/discard_ui/tests/targa_tests.rs b/crates/ui-egui/src/dialogs/discard_ui/tests/targa_tests.rs index 0a05370ecf..9360bdde69 100644 --- a/crates/ui-egui/src/dialogs/discard_ui/tests/targa_tests.rs +++ b/crates/ui-egui/src/dialogs/discard_ui/tests/targa_tests.rs @@ -182,6 +182,40 @@ fn recorded_targa_save_as_keeps_its_bits_and_replays_without_asking() { ); } +/// #5305: a Targa Save As answered in Targa Options with the document's own name records only the +/// folder and extension (with the bits), so playback saves each document under its own name; a +/// Save a Copy keeps its path. +#[test] +fn recorded_targa_save_as_with_the_documents_name_keeps_only_the_folder() { + let (mut h, writes) = targa_app(false, "copy.tga"); + let ctx = h.ctx.clone(); + let name = h.state().session.active().unwrap().doc.name.clone(); + h.state_mut().run("actions.record", json!({})).unwrap(); + h.state_mut().save_as(Some(format!("out/{name}.tga"))).unwrap(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); + h.run_steps(3); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let steps = &h.state().session.actions.list[0].steps; + assert_eq!( + steps, + &[ + ("file.saveAs".into(), json!({"folder": "out", "extension": "tga", "tgaBits": 24})), + ("file.saveACopy".into(), json!({"path": "copy.tga", "tgaBits": 24})) + ] + ); + writes.borrow_mut().clear(); + h.state_mut().run("file.new", json!({"width": 8, "height": 8, "name": "second"})).unwrap(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let second = std::path::Path::new("out").join("second.tga").to_string_lossy().into_owned(); + assert_eq!(writes.borrow().as_slice(), &[(second, asked(Some(24))), ("copy.tga".to_string(), asked(Some(24)))]); +} + /// Closing an unsaved document whose save goes to a Targa waits behind Targa Options, then closes. #[test] fn closing_after_a_targa_save_waits_for_targa_options() { diff --git a/crates/ui-egui/src/dialogs/disk_change_prompt.rs b/crates/ui-egui/src/dialogs/disk_change_prompt.rs index 34d83075e1..06727c7a8b 100644 --- a/crates/ui-egui/src/dialogs/disk_change_prompt.rs +++ b/crates/ui-egui/src/dialogs/disk_change_prompt.rs @@ -50,8 +50,9 @@ pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result u64 { .unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); if let Value::Object(m) = last { for (k, v) in m { - if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") { + if !matches!(k.as_str(), "path" | "dir" | "folder" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") { f.insert(k, v); } } @@ -562,7 +562,11 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re authorize(&prepared)?; p = prepared; } - if p.get("path").is_some() || p.get("dir").is_some() { + // A null or empty destination is none: the picker asks for one. + if let Some(fields) = p.as_object_mut() { + fields.retain(|key, value| !(matches!(key.as_str(), "path" | "dir" | "folder") && (value.is_null() || value.as_str() == Some("")))); + } + if p.get("path").is_some() || p.get("dir").is_some() || p.get("folder").is_some() { return save_for_web(app, p); } let doc = app.session.active().ok_or("no document")?.doc.clone(); @@ -577,6 +581,14 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re let dir = path.rfind(['/', '\\']).map(|i| path[..i].to_string()).unwrap_or_else(|| ".".into()); p["dir"] = json!(dir); p["html"] = json!(html); + } else if let Some(folder) = kept_name_folder(&path, &base, st.format.ext()) + // A save that renamed the document meanwhile: the engine would name the file after + // the new name, not the one picked. + && app.session.active().is_some_and(|state| photocraft_doc::slices::base_name(&state.doc) == base) + { + // The suggested name kept: only the folder, so a recorded action saves each + // document under its own name, as in Photoshop (#5305). + p["folder"] = json!(folder); } else { p["path"] = json!(path); } @@ -584,6 +596,15 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re }) } +/// The folder of `path` when its file is the one Save for Web suggests and writes from a folder +/// alone: `base` (a name usable on Windows as it is) with the format's own extension `ext`. +fn kept_name_folder(path: &str, base: &str, ext: &str) -> Option { + let file = std::path::Path::new(path); + let portable = photocraft_engine::file_cmds::portable_device_name(base.to_string()) == base; + let kept = portable && file.file_stem().is_some_and(|s| s.to_string_lossy() == base) && file.extension().is_some_and(|e| e.to_string_lossy() == ext); + kept.then(|| file.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default()) +} + /// Runs Save for Web with `p`, which says where to write. fn save_for_web(app: &mut PhotocraftApp, p: Value) -> Result { #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 08ce0f6ab1..f2e97a80e8 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -279,16 +279,17 @@ pub fn preview_factor(command: &str, params: &Value, k: u32) -> u32 { // Minimum and Maximum measure the radius in whole pixels (Squareness rounds it, Roundness // floors it, and 0 is the identity), so at k = 4 a typed 1.5 px became 0.375 = 0 proxy px and // the preview showed the untouched document while OK applied a real effect (#10474). Keep at - // least one proxy pixel of radius, and for Squareness the largest k whose rounded proxy radius, - // scaled back, is within 1 px of the one OK applies (6 px at k = 4 rounded to 2 proxy px, 8 px). + // least one proxy pixel of radius, and the largest k whose whole-pixel proxy radius, scaled + // back, is within 1 px of the one OK applies: Squareness at 6 px and k = 4 rounds 1.5 to 2 proxy + // px (8 px), and Roundness's disc reaches floor(1.5) = 1 proxy px along an axis (4 px). // A large radius still previews on a proxy: at full size Roundness costs O(radius) per pixel. if matches!(command, "filter.other.minimum" | "filter.other.maximum") { let Some(r) = params.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite()) else { return k }; let r = r.clamp(0.2, 500.0); - let square = params.get("preserve").and_then(Value::as_str) != Some("roundness"); + let whole: fn(f64) -> f64 = if params.get("preserve").and_then(Value::as_str) == Some("roundness") { f64::floor } else { f64::round }; let fits = |d: u32| { let d = f64::from(d); - d <= r.floor() && (!square || ((r / d).round() * d - r.round()).abs() <= 1.0) + d <= r.floor() && (whole(r / d) * d - whole(r)).abs() <= 1.0 }; return (1..=k).rev().find(|&d| fits(d)).unwrap_or(1); } diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index c5013adc25..dcaa32ddbd 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -1047,16 +1047,29 @@ fn proxy_previews_scale_only_lengths() { assert_eq!(preview_factor("filter.pixelate.mosaic", &json!({"cellSize": "x"}), 4), 4); let (k, diff) = error("filter.pixelate.mosaic", json!({"cellSize": 10.0})); assert!(k == 2 && diff < 0.01, "Mosaic: {diff}"); + // Roundness at a fractional radius: at 6.5 px, k = 3 reaches floor(2.17) = 2 proxy px along an + // axis (6 px), where k = 4 reached floor(1.63) = 1 (4 px), so the chosen factor previews closer + // to what OK applies than k = 4 did (#10474). + for cmd in ["filter.other.minimum", "filter.other.maximum"] { + let p = json!({"radius": 6.5, "preserve": "roundness"}); + let (k, diff) = error(cmd, p.clone()); + let applied = preview_document(&doc, Some(bg), cmd, &p, 1).unwrap(); + let at_four = preview_document(&doc, Some(bg), cmd, &p, 4).unwrap(); + let four = mean_abs(&every_kth(&applied, 4), &every_kth(&at_four, 1)); + assert!(k == 3 && diff < four, "{cmd}: k = {k} differs by {diff}, k = 4 by {four}"); + } // Minimum and Maximum keep at least one proxy pixel of radius (#10474), and a large radius - // keeps a proxy instead of a full-size preview that costs minutes at radius 500. Squareness - // also keeps its rounded radius within 1 px of OK's: 6 px is 2 proxy px at k = 3 (6 px), not - // at k = 4 (8 px); 50 px is 17 at k = 3 (51 px), not 13 at k = 4 (52 px). + // keeps a proxy instead of a full-size preview that costs minutes at radius 500. Both keep + // their whole-pixel radius within 1 px of OK's. Squareness rounds: 6 px is 2 proxy px at k = 3 + // (6 px), not at k = 4 (8 px); 50 px is 17 at k = 3 (51 px), not 13 at k = 4 (52 px). Roundness's + // disc reaches floor(r) along an axis: 6 px is 2 at k = 3 (6 px), not 1 at k = 4 (4 px); 50 px + // is 25 at k = 2, not 12 at k = 4 or 16 at k = 3 (48 px). let factors = |cmd: &str, preserve: &str| { [0.2, 1.5, 2.5, 3.9, 6.0, 50.0, 500.0, 9999.0].map(|radius| preview_factor(cmd, &json!({"radius": radius, "preserve": preserve}), 4)) }; for cmd in ["filter.other.minimum", "filter.other.maximum"] { assert_eq!(factors(cmd, "squareness"), [1, 1, 2, 3, 3, 3, 4, 4], "{cmd} squareness"); - assert_eq!(factors(cmd, "roundness"), [1, 1, 2, 3, 4, 4, 4, 4], "{cmd} roundness"); + assert_eq!(factors(cmd, "roundness"), [1, 1, 2, 3, 3, 2, 4, 4], "{cmd} roundness"); assert_eq!(preview_factor(cmd, &json!({"radius": 6.0}), 4), 3, "{cmd}: Squareness is the default"); assert_eq!(preview_factor(cmd, &json!({"radius": "x"}), 4), 4, "{cmd}: a junk radius keeps the proxy"); } diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 760a7b6b48..620574f909 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -20,12 +20,12 @@ //! The canvas keeps showing the pre-job state: workers never touch the session's documents. use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; use egui::{Color32, Rect, RichText, Sense, Stroke, vec2}; use photocraft_doc::DocId; -use photocraft_engine::jobs::{JobCtx, JobEvent, JobId, JobInfo, JobOutcome, OPEN_JOB, OpenSource, Started}; +use photocraft_engine::jobs::{CancelRefused, JobCtx, JobEvent, JobId, JobInfo, JobOutcome, OPEN_JOB, OpenSource, Started}; use serde_json::{Value, json}; use crate::theme::Tokens; @@ -73,23 +73,14 @@ pub(crate) struct OpenBatch { /// The job command of a background save. pub const SAVE_JOB: &str = "file.save"; -const ENCODING: u8 = 0; -const WRITING: u8 = 1; -const CANCELLED: u8 = 2; - -/// A save worker's stage, shared between the worker and the UI. +/// A save worker's state, shared between the worker and the UI. Whether it may still be +/// cancelled is its job's ([`JobCtx::commit`]), so every cancel path agrees. #[derive(Debug, Default)] pub struct SaveState { - stage: AtomicU8, finished: AtomicBool, } impl SaveState { - /// Cancel unless writing has started; false once it has. - fn try_cancel(&self) -> bool { - matches!(self.stage.compare_exchange(ENCODING, CANCELLED, Ordering::AcqRel, Ordering::Acquire), Ok(_) | Err(CANCELLED)) - } - /// The worker has returned (or unwound): nothing of this save can still touch the file. pub fn finished(&self) -> bool { self.finished.load(Ordering::Acquire) @@ -110,7 +101,6 @@ impl Drop for WorkerDone { /// be cancelled. pub struct SaveCtl { pub job: JobCtx, - state: Arc, } impl SaveCtl { @@ -120,15 +110,15 @@ impl SaveCtl { /// `Err` once the save was cancelled: stop, write nothing. pub fn check(&self) -> Result<(), String> { - if self.job.cancelled() || self.state.stage.load(Ordering::Acquire) == CANCELLED { Err(SAVE_CANCELLED.into()) } else { Ok(()) } + if self.job.cancelled() { Err(SAVE_CANCELLED.into()) } else { Ok(()) } } /// Call once, right before replacing the file. `Ok`: the save can no longer be cancelled - /// (Cancel and Esc are refused) and must run to the end; `Err`: it was cancelled first and - /// nothing may be written. + /// (Cancel, Esc, `jobs.cancel`, cancelling every job and closing the document all leave it + /// running) and must run to the end; `Err`: it was cancelled first and nothing may be + /// written. pub fn commit(&self) -> Result<(), String> { - self.check()?; - self.state.stage.compare_exchange(ENCODING, WRITING, Ordering::AcqRel, Ordering::Acquire).map(|_| ()).map_err(|_| SAVE_CANCELLED.into()) + self.job.commit().map_err(|_| SAVE_CANCELLED.into()) } } @@ -175,6 +165,8 @@ pub struct JobsUi { pub focus: Option, /// The job the latest [`run`] started (the control channel waits on it). pub(crate) last_started: Option, + /// Cached read-only canvas image of the current folder Batch input. + batch_preview_texture: Option<(JobId, u64, egui::TextureHandle)>, } /// Run command `id`: in the background when background jobs are on and the command is a job @@ -311,8 +303,8 @@ pub fn start_save( &label, true, move |ctx| { - let _done = WorkerDone(Arc::clone(&worker)); - let ctl = SaveCtl { job: ctx.clone(), state: worker }; + let _done = WorkerDone(worker); + let ctl = SaveCtl { job: ctx.clone() }; save(&snapshot, &target, &settings, &ctl) .map_err(|e| if ctl.check().is_err() { photocraft_engine::EngineError::Cancelled } else { photocraft_engine::EngineError::Other(e) }) }, @@ -406,7 +398,15 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { && !crate::zoom_field::editing(ctx) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - cancel(app, job.id); + if job.command == "file.automate.batch" { + if app.session.batch_interrupt_waiting(job.id) { + app.session.decide_batch_interrupt_job(job.id, false); + } else { + app.session.request_batch_interrupt_job(job.id); + } + } else { + cancel(app, job.id); + } } } @@ -447,33 +447,38 @@ pub fn job_in_view(app: &PhotocraftApp) -> Option { /// Cancel job `id` (the status bar's ×, the dialog's Cancel, Esc, closing an opening tab). pub fn cancel(app: &mut PhotocraftApp, id: JobId) -> bool { + try_cancel(app, id).is_ok() +} + +/// [`cancel`], saying why a job was left running. +pub fn try_cancel(app: &mut PhotocraftApp, id: JobId) -> Result<(), CancelRefused> { let label = app.session.job(id).map(|j| j.label); + let r = app.session.try_cancel_job(id); // A save that has started replacing the file runs to the end: cancelling it now would leave // the file written but the document marked unsaved. - if let Some(save) = app.jobs.saves.iter().find(|s| s.job == id) - && !save.state.try_cancel() - { + if r == Err(CancelRefused::Committing) { app.ui.status = crate::i18n::fmt(tl!("{label} is writing the file and can no longer be cancelled"), &[("label", label.as_deref().unwrap_or_default())]); app.ui.status_error = false; - return false; } - let cancelled = app.session.cancel_job(id); - if cancelled { + if r.is_ok() { // The event (and its status text) arrives with the next poll; say so now as well. if let Some(l) = label { app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", &l)]); app.ui.status_error = false; } } - cancelled + r } /// Handles one finished job: optional completion beep, waiting control replies, and the /// command-specific open, save, transform and recovery paths. fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { + let cancelled_batch = + e.command == "file.automate.batch" && matches!(&e.outcome, JobOutcome::Done(result) if result.get("cancelled").and_then(Value::as_bool) == Some(true)); // Preferences ▸ General ▸ Beep When Done (off by default): a finished operation beeps, like // Photoshop's cue. Cancelling is the user's own action and stays silent. if matches!(e.outcome, JobOutcome::Done(_)) + && !cancelled_batch && app.session.prefs().general.beep_when_done && let Some(beep) = app.services.beep { @@ -481,6 +486,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { } // Control requests waiting on this job. let reply = match &e.outcome { + JobOutcome::Done(v) if cancelled_batch => json!({"ok": false, "error": "cancelled", "result": v}), JobOutcome::Done(v) => json!({"ok": true, "result": v}), JobOutcome::Failed(err) => json!({"ok": false, "error": err}), JobOutcome::Cancelled => json!({"ok": false, "error": "cancelled"}), @@ -549,6 +555,10 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { app.sync_views(); crate::file_ui::print_status(app, result); } + JobOutcome::Done(_) if cancelled_batch => { + app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", &e.label)]); + app.ui.status_error = false; + } JobOutcome::Done(_) => { app.sync_views(); app.ui.status = e.label; @@ -744,6 +754,9 @@ pub fn save_progress(app: &PhotocraftApp, doc: DocId) -> Option { /// (the file name on hover). Draws nothing when no job runs. pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(j) = job_in_view(app) else { return }; + if j.command == "file.automate.batch" { + return; + } let saving = app.jobs.saves.iter().any(|s| s.job == j.id); let t = Tokens::get(ui.ctx()); let (bar_w, bar_h) = if t.pro { (120.0, 6.0) } else { (140.0, 6.0) }; @@ -775,17 +788,63 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// Show the folder Batch worker's immutable current document in the canvas area. The worker +/// publishes snapshots rather than adding them to `Session`, so no edit or Save can target one. +pub(crate) fn batch_preview(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { + let current = app + .session + .jobs() + .into_iter() + .filter(|job| job.command == "file.automate.batch") + .find_map(|job| app.session.batch_preview(job.id).map(|preview| (job.id, preview))); + let Some((id, preview)) = current else { + app.jobs.batch_preview_texture = None; + return false; + }; + if !app.jobs.batch_preview_texture.as_ref().is_some_and(|(cached_id, generation, _)| *cached_id == id && *generation == preview.generation) { + let image = photocraft_compose::thumbnail(&preview.document, 1024); + let pixels = egui::ColorImage::from_rgba_unmultiplied([image.width as usize, image.height as usize], &image.pixels); + let texture = ui.ctx().load_texture(format!("batch-preview-{}-{}", id.0, preview.generation), pixels, egui::TextureOptions::LINEAR); + app.jobs.batch_preview_texture = Some((id, preview.generation, texture)); + } + let Some((_, _, texture)) = &app.jobs.batch_preview_texture else { return false }; + let t = Tokens::get(ui.ctx()); + let name = std::path::Path::new(&preview.path).file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_else(|| preview.path.clone()); + ui.horizontal(|ui| { + ui.label(RichText::new(name).color(t.text).font(crate::theme::semibold(12.5))); + ui.label(RichText::new(tl!("Batch preview")).color(t.text_faint).size(11.0)); + }); + let area = ui.available_rect_before_wrap(); + ui.allocate_rect(area, Sense::hover()); + let size = texture.size_vec2(); + if size.x > 0.0 && size.y > 0.0 { + let scale = (area.width() / size.x).min(area.height() / size.y); + if !scale.is_finite() { + return true; + } + let scale = scale.clamp(0.0, 1.0); + let target = Rect::from_center_size(area.center(), size * scale); + ui.painter().rect_filled(target, 0.0, Color32::WHITE); + ui.painter().image(texture.id(), target, Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE); + } + true +} + /// The modal progress window for a job locking the active document (Photoshop shows one for /// long filters): title, status, bar, percentage and elapsed time, Cancel. Appears after /// [`DIALOG_DELAY_MS`]; Esc cancels (see [`tick`]). A save shows it only when it runs in the /// foreground (Save in Background off), and then at once, so nothing can be clicked meanwhile; a /// background save shows its progress in the tab and the status bar instead. pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { + if let Some(job) = app.session.jobs().into_iter().find(|job| job.command == "file.automate.batch" && app.session.batch_interrupt_waiting(job.id)) { + batch_interrupt_dialog(app, ctx, job.id); + return; + } let j = match foreground_save(app) { Some(j) => j, None => { let Some(j) = app.session.active_job() else { return }; - if j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { + if j.command == "file.automate.batch" || j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { return; } j @@ -825,6 +884,31 @@ pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Photoshop's question after Esc interrupts one input. The worker is parked before its save; +/// Continue advances the same run to the next input, and Stop returns its partial result. +fn batch_interrupt_dialog(app: &mut PhotocraftApp, ctx: &egui::Context, id: JobId) { + let t = Tokens::get(ctx); + let mut decision = None; + egui::Modal::new(egui::Id::unique("batch-interrupted")).show(ctx, |ui| { + ui.set_width(390.0); + ui.label(RichText::new(tl!("Batch")).font(crate::theme::semibold(15.0))); + ui.add_space(8.0); + ui.label(RichText::new(tl!("A command was cancelled or stopped during batch processing. Continue with the next file or stop?")).color(t.text_dim)); + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::primary_button(ui, tl!("Continue"), 96.0).clicked() { + decision = Some(true); + } + if crate::widgets::secondary_button(ui, tl!("Stop"), 84.0).clicked() { + decision = Some(false); + } + }); + }); + if let Some(continue_next) = decision { + app.session.decide_batch_interrupt_job(id, continue_next); + } +} + fn elapsed(ms: f64) -> String { let s = (ms / 1000.0).max(0.0); if s < 60.0 { format!("{s:.1} s") } else { format!("{}:{:02}", (s / 60.0).floor() as u64, (s % 60.0).floor() as u64) } diff --git a/crates/ui-egui/src/jobs_ui/tests.rs b/crates/ui-egui/src/jobs_ui/tests.rs index ffb6207fd4..764936b46f 100644 --- a/crates/ui-egui/src/jobs_ui/tests.rs +++ b/crates/ui-egui/src/jobs_ui/tests.rs @@ -604,6 +604,95 @@ fn a_save_that_has_started_writing_cannot_be_cancelled() { assert!(!h.state().session.active().unwrap().is_dirty(), "the written save is recorded"); } +/// A `save_file` service held by `gates` that really replaces the file: it encodes until +/// `encode` opens, commits, then writes `new` once `write` opens. +fn file_save(gates: &Gates) -> crate::SaveFileFn { + let g = gates.clone(); + Arc::new(move |_, path, _, ctl| { + ctl.progress(0.0, "Encoding"); + while !g.encode.load(Ordering::Relaxed) { + ctl.check()?; + std::thread::sleep(Duration::from_millis(1)); + } + ctl.commit()?; + ctl.progress(0.0, "Writing"); + while !g.write.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(1)); + } + std::fs::write(path, b"new").map_err(|e| e.to_string())?; + Ok(Vec::new()) + }) +} + +/// A file holding `old`, in a fresh directory named for the test. +fn old_file(test: &str) -> String { + let dir = std::env::temp_dir().join(format!("photocraft-jobs-ui-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("w.psd"); + std::fs::write(&path, b"old").unwrap(); + path.to_string_lossy().into_owned() +} + +fn control(h: &mut Harness<'static, PhotocraftApp>, method: &str, params: Value) -> Value { + let (req, _rx) = crate::ControlRequest::new(method, params); + let ctx = h.ctx.clone(); + let crate::control::Outcome::Done(v) = crate::control::handle(h.state_mut(), &ctx, &req) else { panic!("{method} replies at once") }; + v +} + +#[test] +fn jobs_cancel_before_a_save_writes_stops_it_and_leaves_the_target_untouched() { + let mut h = app_harness(); + let path = old_file("cancel-before-write"); + let gates = Gates::default(); + h.state_mut().services.save_file = Some(file_save(&gates)); + h.state_mut().save_as(Some(path.clone())).unwrap(); + h.run_steps(2); + let job = h.state().jobs.saves[0].job; + let v = control(&mut h, "jobs.cancel", json!({"job": job.0})); + assert_eq!(v["result"], json!({"cancelled": 1}), "{v}"); + // The worker reaches its commit point only now, and must give up there. + gates.encode.store(true, Ordering::Relaxed); + gates.write.store(true, Ordering::Relaxed); + step_until(&mut h, |a| !a.saving()); + assert_eq!(std::fs::read(&path).unwrap(), b"old", "target untouched"); + let st = h.state().session.active().unwrap(); + assert!(st.is_dirty() && st.path.is_none(), "the document is not marked saved"); + assert_eq!(h.state().ui.status, "Cancelled Saving w.psd"); +} + +#[test] +fn jobs_cancel_during_the_write_reports_the_save_as_not_cancelled_and_it_finishes() { + let mut h = app_harness(); + let path = old_file("cancel-during-write"); + let gates = Gates { encode: Arc::new(AtomicBool::new(true)), ..Gates::default() }; + h.state_mut().services.save_file = Some(file_save(&gates)); + h.state_mut().save_as(Some(path.clone())).unwrap(); + let saved_doc = h.state().session.active().unwrap().doc.id; + until_writing(&mut h); + let save = h.state().jobs.saves[0].job; + // A second document with a job that can still be cancelled. + h.state_mut().session.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + let slow = slow_job(h.state_mut(), &Arc::new(AtomicBool::new(false))); + + let refused = json!([{"job": save.0, "reason": "committing"}]); + let v = control(&mut h, "jobs.cancel", json!({"job": save.0})); + assert_eq!(v["result"], json!({"cancelled": 0, "notCancelled": refused}), "{v}"); + assert_eq!(h.state().ui.status, "Saving w.psd is writing the file and can no longer be cancelled"); + // Cancel everything: the other job stops, the save is reported as left running. + let v = control(&mut h, "jobs.cancel", json!({})); + assert_eq!(v["result"], json!({"cancelled": 1, "notCancelled": refused}), "{v}"); + assert!(h.state().session.job(slow).is_none()); + assert!(h.state().session.job(save).is_some(), "the save still runs and keeps its document locked"); + + gates.write.store(true, Ordering::Relaxed); + step_until(&mut h, |a| !a.saving()); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + let st = h.state().session.documents().iter().find(|st| st.doc.id == saved_doc).unwrap(); + assert!(!st.is_dirty() && st.path.as_deref() == Some(path.as_str()), "the save that happened is recorded"); +} + /// #3389: like Photoshop, a running save reads "Saving 45%" in the status bar and appends /// " - Saving 45%" to its document's tab title, in both tab strips; both go when it ends. #[test] @@ -748,6 +837,104 @@ fn a_finished_job_beeps_when_the_preference_is_on() { assert_eq!(BEEPS.load(Ordering::Relaxed), 1, "one beep for the finished job"); } +#[test] +fn cancelled_batch_with_partial_results_reports_cancellation_without_beeping() { + static BEEPS: AtomicUsize = AtomicUsize::new(0); + fn count_beep() { + BEEPS.fetch_add(1, Ordering::Relaxed); + } + BEEPS.store(0, Ordering::Relaxed); + let mut h = app_harness_with(crate::Services { beep: Some(count_beep), ..Default::default() }); + h.state_mut().run("prefs.set", json!({"path": "general.beepWhenDone", "value": true})).unwrap(); + let Started::Job(id) = h + .state_mut() + .session + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + |ctx| { + while !ctx.cancelled() { + std::thread::sleep(Duration::from_millis(1)); + } + Ok(json!({"files": ["done.png"], "cancelled": true})) + }, + |_, result| Ok(result), + ) + .unwrap() + else { + panic!("batch did not start as a job") + }; + assert!(h.state_mut().session.cancel_job(id)); + let started = Instant::now(); + let event = loop { + if let Some(event) = h.state_mut().session.poll_jobs().into_iter().find(|event| event.id == id) { + break event; + } + assert!(started.elapsed() < Duration::from_secs(10), "batch did not finish"); + std::thread::sleep(Duration::from_millis(1)); + }; + let ctx = h.ctx.clone(); + let (reply_tx, reply_rx) = std::sync::mpsc::channel(); + h.state_mut().jobs.waiters.push((id, reply_tx)); + crate::jobs_ui::on_event(h.state_mut(), &ctx, event); + assert!(h.state().ui.status.contains("Cancelled"), "{}", h.state().ui.status); + assert_eq!(BEEPS.load(Ordering::Relaxed), 0); + let reply = reply_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(reply["ok"], false); + assert_eq!(reply["error"], "cancelled"); + assert_eq!(reply["result"]["files"], json!(["done.png"])); +} + +#[test] +fn interactive_batch_shows_its_read_only_input_and_asks_after_escape() { + let mut h = app_harness(); + let original = h.state().session.active().unwrap().doc.clone(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let Started::Job(id) = h + .state_mut() + .session + .start_job( + "file.automate.batch", + json!({}), + "Batch", + false, + move |ctx| { + let mut scratch = photocraft_engine::Session::new(); + scratch.execute("file.new", json!({"name": "input.png", "width": 16, "height": 12, "background": "#00ff00"}))?; + ctx.publish_batch_preview("/source/input.png", scratch.active().map(|state| state.doc.clone())); + let _ = ready_tx.send(()); + while !ctx.batch_take_interrupt() { + ctx.check()?; + std::thread::sleep(Duration::from_millis(1)); + } + let continued = ctx.batch_wait_for_decision(); + ctx.publish_batch_preview("/source/input.png", None); + Ok(json!({"continued": continued, "files": []})) + }, + |_, result| Ok(result), + ) + .unwrap() + else { + panic!("batch did not start as a job") + }; + ready_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + frames_until(&mut h, 1, |h| h.query_by_label("Batch preview").is_some()); + assert!(h.query_by_label("input.png").is_some(), "the current input appears in the canvas"); + assert!(h.query_by_label("Batch").is_none(), "the Batch progress readout is hidden while its input is visible"); + assert!(Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc), "preview cannot replace the editable document"); + h.key_press(egui::Key::Escape); + frames_until(&mut h, 1, |h| h.query_by_label("Continue").is_some()); + assert!(h.state().session.batch_interrupt_waiting(id)); + h.get_by_label("Continue").click(); + frames_until(&mut h, 1, |h| h.state().session.job(id).is_none()); + let result = h.state().session.jobs_with_recent().into_iter().find(|job| job.id == id).and_then(|job| job.result).unwrap(); + assert_eq!(result["continued"], true); + assert!(h.query_by_label("Batch preview").is_none(), "the input closes after the decision"); + assert!(Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc)); +} + #[test] fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { for success in [true, false] { diff --git a/crates/ui-egui/src/menu_catalog.rs b/crates/ui-egui/src/menu_catalog.rs index 1d98028625..09f732dc7a 100644 --- a/crates/ui-egui/src/menu_catalog.rs +++ b/crates/ui-egui/src/menu_catalog.rs @@ -474,6 +474,15 @@ pub static CATALOG: &[(&[&str], &str, Option<&str>, &str)] = &[ (&["Type", "OpenType"], "Ornaments", None, "type.openType.ornaments"), (&["Type", "OpenType"], "Ordinals", None, "type.openType.ordinals"), (&["Type", "OpenType"], "Fractions", None, "type.openType.fractions"), + (&["Type", "OpenType", "Numerals"], "Default", None, "type.openType.numerals.default"), + (&["Type", "OpenType", "Numerals"], "Lining", None, "type.openType.numerals.lining"), + (&["Type", "OpenType", "Numerals"], "Oldstyle", None, "type.openType.numerals.oldstyle"), + (&["Type", "OpenType", "Numerals"], "Proportional", None, "type.openType.numerals.proportional"), + (&["Type", "OpenType", "Numerals"], "Tabular", None, "type.openType.numerals.tabular"), + (&["Type", "OpenType", "Numerals"], "Proportional Lining", None, "type.openType.numerals.proportionalLining"), + (&["Type", "OpenType", "Numerals"], "Tabular Lining", None, "type.openType.numerals.tabularLining"), + (&["Type", "OpenType", "Numerals"], "Proportional Oldstyle", None, "type.openType.numerals.proportionalOldstyle"), + (&["Type", "OpenType", "Numerals"], "Tabular Oldstyle", None, "type.openType.numerals.tabularOldstyle"), (&["Type"], "---", None, "---"), (&["Type"], "Create Work Path", None, "type.createWorkPath"), (&["Type"], "Convert to Shape", None, "type.convertToShape"), diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b8d4791d80..a089803be8 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -707,7 +707,9 @@ fn invoke_window_command(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str) let count = app.session.documents().len(); let active = app.session.active_index().ok_or("no document open")?; let next = if id == "window.nextDocument" { (active + 1) % count } else { (active + count - 1) % count }; - app.session.set_active(next); + if next != active { + app.run("document.activate", json!({"document": next}))?; + } app.jobs.focus = None; Ok(json!({"document": next})) })()), diff --git a/crates/ui-egui/src/menus/parity.rs b/crates/ui-egui/src/menus/parity.rs index ab5fd5eee7..2aa25e9b40 100644 --- a/crates/ui-egui/src/menus/parity.rs +++ b/crates/ui-egui/src/menus/parity.rs @@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG; use crate::menus::is_live; /// Minimum number of live catalog items. Raise it when parity grows; never lower it. -pub const FLOOR: usize = 631; +pub const FLOOR: usize = 640; /// One top-level menu's coverage. #[derive(Clone, Debug, serde::Serialize)] diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 06f16ef927..45c3d5447f 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -490,8 +490,15 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para let name = action.name.clone(); dialog( app, - json!({"action": name, "steps": steps, "input": dir, "includeSubfolders": false, "__labels": subfolders_label(), "output": format!("{dir}/batch"), "format": "same", "quality": null, "__batchQuality": 8, "naming": "", "startingSerial": 1}), - json!({"format": ["same", "png", "jpg", "psd", "tiff", "webp"]}), + json!({ + "action": name, "steps": steps, "source": "folder", "input": dir, + "includeSubfolders": false, "__labels": subfolders_label(), + "output": format!("{dir}/batch"), "format": "same", "quality": null, + "__batchQuality": 8, "naming": "", "startingSerial": 1, + "stopOnError": false, "errorLog": "", + "__order": ["action", "source", "input", "includeSubfolders", "output", "format", "naming", "startingSerial", "quality", "stopOnError", "errorLog"] + }), + json!({"format": ["same", "png", "jpg", "psd", "tiff", "webp"], "source": ["folder", "open"]}), ) } // Channel workflow dialogs (see channels_panel.rs for the channel references). diff --git a/crates/ui-egui/src/menus/view_cmds/dispatch.rs b/crates/ui-egui/src/menus/view_cmds/dispatch.rs index 80e8d41c88..d103ef5f14 100644 --- a/crates/ui-egui/src/menus/view_cmds/dispatch.rs +++ b/crates/ui-egui/src/menus/view_cmds/dispatch.rs @@ -185,6 +185,13 @@ fn type_checked(app: &PhotocraftApp, id: &str) -> Option { return Some(t.is_some_and(|t| (t.orientation == photocraft_doc::text::Orientation::Vertical) == (k == "vertical"))); } let t = t?; + // The style where the menu reads: the selection start while editing a range (as the Character + // panel shows it), else the layer's first run. + let start = crate::type_tool::target(app).and_then(|(_, range)| range).map_or(0, |r| r[0]); + if let Some(key) = id.strip_prefix("type.openType.numerals.") { + let want = photocraft_engine::type_extra_cmds::NumeralsStyle::from_id(key)?; + return Some(photocraft_engine::type_extra_cmds::numerals_style_at(t, start) == Some(want)); + } let tag = match id.strip_prefix("type.openType.")? { "standardLigatures" => "liga", "contextualAlternates" => "calt", @@ -198,7 +205,7 @@ fn type_checked(app: &PhotocraftApp, id: &str) -> Option { "fractions" => "frac", _ => return None, }; - Some(t.char_runs().first().is_some_and(|r| photocraft_engine::type_extra_cmds::feature_on(&r.style, tag))) + Some(photocraft_engine::type_extra_cmds::feature_on_at(t, start, tag)) } /// Engine commands that get a dialog or file picker here when invoked without parameters. diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index 3ef2ab13fa..aebc91f702 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -255,6 +255,26 @@ fn type_menu_checkmarks_follow_the_layer() { assert_eq!(checked(&app, "type.orientation.horizontal"), Some(true)); } +#[test] +fn numerals_checkmark_follows_the_selection() { + let (mut app, ctx) = app_with(1); + let id = "type.openType.numerals.tabularLining"; + assert!(!crate::menus::is_enabled(&app, id)); + assert_eq!(checked(&app, id), None, "no type layer"); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Type", "size": 24})).unwrap(); + assert!(crate::menus::is_enabled(&app, id)); + assert_eq!(checked(&app, "type.openType.numerals.default"), Some(true)); + assert_eq!(checked(&app, id), Some(false)); + menu(&mut app, &ctx, id, json!({})).unwrap(); + assert_eq!(checked(&app, id), Some(true)); + assert_eq!(checked(&app, "type.openType.numerals.default"), Some(false)); + assert_eq!(checked(&app, "type.openType.numerals.lining"), Some(false)); + menu(&mut app, &ctx, "type.openType.numerals.default", json!({})).unwrap(); + assert_eq!(checked(&app, "type.openType.numerals.default"), Some(true)); + assert_eq!(checked(&app, id), Some(false)); + assert_eq!(checked(&app, "type.openType.numerals.notAStyle"), None); +} + #[test] fn batch_menu_requires_a_recorded_action() { let (mut app, ctx) = app_with(1); @@ -437,12 +457,22 @@ fn batch_dialogs_include_all_subfolders() { let fields = &mut app.ui.dialog_mut(d).unwrap().fields; assert_eq!(fields["includeSubfolders"], json!(false), "{id}"); assert_eq!(fields["__labels"]["includeSubfolders"], json!("Include All Subfolders"), "{id}"); + if id == "file.automate.batch" { + assert_eq!(fields["source"], json!("folder")); + assert_eq!(fields["stopOnError"], json!(false)); + assert_eq!(fields["errorLog"], json!("")); + } fields.insert("input".into(), json!(input)); fields.insert("output".into(), json!(dir.join(out).to_string_lossy())); fields.insert("includeSubfolders".into(), json!(true)); let r = crate::dialogs::confirm(&mut app, d).unwrap(); assert_eq!(r["files"].as_array().map(Vec::len), Some(1), "{id}: {r}"); - assert!(dir.join(out).join("sub").is_dir(), "{id} keeps the subfolder: {r}"); + if id == "file.automate.batch" { + assert!(dir.join(out).join("a.png").is_file(), "{id} writes subfolder inputs flat: {r}"); + assert!(!dir.join(out).join("sub").exists()); + } else { + assert!(dir.join(out).join("sub").is_dir(), "{id} keeps the subfolder: {r}"); + } } let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index d84d6d96e5..c627b52a2e 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -110,7 +110,7 @@ fn play_targets(app: &mut PhotocraftApp, targets: &[usize], before: u64) -> Resu let mut v = app.run("actions.play", json!({"action": i}))?; ran = ran.saturating_add(v.get("ran").and_then(Value::as_u64).unwrap_or(0)); let failed = v.get("failed").is_some_and(Value::is_object); - let paused = v.get("paused").filter(|p| p.is_object()).and_then(|p| p.get("dialog")).and_then(Value::as_u64); + let paused = v.get("paused").filter(|p| p.is_object()).and_then(waiting_dialog).cloned(); if let Some(o) = v.as_object_mut() { o.insert("ran".into(), json!(ran)); } @@ -120,7 +120,7 @@ fn play_targets(app: &mut PhotocraftApp, targets: &[usize], before: u64) -> Resu // (`actions.move`), and one deleted and made again under its name isn't the same action. let list = &app.session.actions.list; let rest: Vec = targets.get(n + 1..).unwrap_or_default().iter().filter_map(|i| list.get(*i).cloned()).collect(); - let token = app.ui.dialog_mut(dialog).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64); + let token = paused_token(app, &dialog); if let Some(entry) = token.and_then(|t| app.paused_playbacks.paused.get_mut(&t)) && name.as_ref() == Some(&entry.action) { @@ -136,6 +136,19 @@ fn play_targets(app: &mut PhotocraftApp, targets: &[usize], before: u64) -> Resu Ok(last) } +/// The dialog a paused playback waits at: a step dialog by id, or Check Spelling. +fn waiting_dialog(paused: &Value) -> Option<&Value> { + paused.get("dialog").filter(|d| d.is_u64() || d.as_str() == Some(crate::type_panels_ui::SPELLING)) +} + +/// The token of the paused playback waiting at `dialog` (see [`waiting_dialog`]). +fn paused_token(app: &mut PhotocraftApp, dialog: &Value) -> Option { + match dialog.as_u64() { + Some(id) => app.ui.dialog_mut(id).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64), + None => app.ui.type_panels.spell.as_ref().and_then(|d| d.resume), + } +} + /// Index of the playback target. Photoshop's Play button uses the first action when /// the panel has actions but none of its rows is selected. fn playback_selection(app: &PhotocraftApp) -> Option { @@ -240,6 +253,30 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) paused = Some(json!({"step": i, "id": id, "dialog": dialog})); break; } + // Check Spelling's dialog opens with Check All Layers as recorded; Done continues the action. + // While the dialog is open (for another action or by hand), this step fails and the open + // one keeps its place and ignored words. + // A correction (`action` set) opens no dialog and runs below. + if action.dialogs.contains(&i) && !app.automation_input && id == "edit.checkSpelling" && p.get("action").is_none() { + if let Some(open) = app.ui.type_panels.spell.as_ref() { + let error = if open.resume.is_some() { "Check Spelling is already open for another action" } else { "Check Spelling is already open" }; + failed = Some(json!({"step": i, "id": id, "error": error})); + break; + } + if let Err(error) = crate::type_panels_ui::open_spelling(app, p.get("allLayers").and_then(Value::as_bool).unwrap_or(true)) { + failed = Some(json!({"step": i, "id": id, "error": error})); + break; + } + let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + if let Some(entry) = app.paused_playbacks.paused.get_mut(&token) { + entry.doc = app.session.active().map(|d| d.doc.id); + } + if let Some(d) = app.ui.type_panels.spell.as_mut() { + d.resume = Some(token); + } + paused = Some(json!({"step": i, "id": id, "dialog": crate::type_panels_ui::SPELLING})); + break; + } let target = app.session.active().map(|d| d.doc.id); let result = if id == "actions.play" { play(app, p) @@ -281,10 +318,11 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) } } actions_cmds::finish_playback(&mut app.session, None, &action, from); - match paused.as_ref().and_then(|p| p.get("dialog")).and_then(Value::as_u64).and_then(|d| app.ui.dialog_mut(d)) { + match paused.as_ref().and_then(waiting_dialog).cloned() { // Paused for a dialog: the call goes on after it, so recording stays suspended. - Some(d) if failed.is_none() => { - let entry = d.fields.get(RESUME).and_then(Value::as_u64).and_then(|t| app.paused_playbacks.paused.get_mut(&t)); + Some(dialog) if failed.is_none() => { + let token = paused_token(app, &dialog); + let entry = token.and_then(|t| app.paused_playbacks.paused.get_mut(&t)); match (owner, entry) { (Some(owner), Some(p)) => p.record = Some(owner), // No continuation to carry the recording: the call ends here. @@ -791,6 +829,9 @@ pub(crate) struct PausedPlayback { /// The steps that Play ran before this pause (earlier actions of the set, and this one's /// steps before the dialog), so the final count covers the whole Play. ran: u64, + /// The document a pause at Check Spelling was playing on. That dialog doesn't block the + /// document tabs, so Done ([`resume_spelling`]) continues only on this document. + doc: Option, } /// The paused playbacks, by the token their dialog carries. The dialog's fields hold only the @@ -804,7 +845,7 @@ pub(crate) struct PausedPlaybacks { impl PausedPlaybacks { fn pause(&mut self, action: String, from: usize, gate: Option) -> u64 { self.last = self.last.wrapping_add(1); - self.paused.insert(self.last, PausedPlayback { action, from, gate, record: None, then: Vec::new(), ran: 0 }); + self.paused.insert(self.last, PausedPlayback { action, from, gate, record: None, then: Vec::new(), ran: 0, doc: None }); self.last } @@ -813,6 +854,23 @@ impl PausedPlaybacks { } } +/// Check Spelling's Done for the action paused at its step: the action continues when the document +/// it was playing on is still the active one, and otherwise ends there with the reason in the +/// status bar. Returns `Ok` when nothing failed. +pub(crate) fn resume_spelling(app: &mut PhotocraftApp, token: u64) -> Result<(), String> { + let fields = serde_json::Map::from_iter([(RESUME.to_string(), json!(token))]); + let Some((from, doc)) = app.paused_playbacks.paused.get(&token).map(|p| (p.from, p.doc)) else { + return resume(app, &fields); + }; + if doc.is_none() || doc == app.session.active().map(|d| d.doc.id) { + return resume(app, &fields); + } + abandoned(app, &fields); + let error = "the active document changed while Check Spelling was open"; + report_play(app, &json!({"failed": {"step": from.saturating_sub(1), "id": "edit.checkSpelling", "error": error}})); + Err(error.into()) +} + /// A step dialog confirmed: continue the action after it (`dialogs::confirm` calls this). /// /// The remaining steps run under the gate the playback paused with, however the dialog was @@ -841,8 +899,8 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) // Paused again at a later dialog of the same action (not of one it calls, which resumes // alone): that dialog carries the rest of the set. if let Ok(v) = &played - && let Some(dialog) = v.get("paused").and_then(|p| p.get("dialog")).and_then(Value::as_u64) - && let Some(token) = app.ui.dialog_mut(dialog).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64) + && let Some(dialog) = v.get("paused").and_then(waiting_dialog) + && let Some(token) = paused_token(app, dialog) && let Some(entry) = app.paused_playbacks.paused.get_mut(&token) && entry.action == paused.action { diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index e35999dedc..cfe2214de2 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -682,15 +682,15 @@ fn recorded_save_never_flattens_layers_over_a_flat_file() { assert!(!b_changed && !a_changed); } -/// Loads a Photoshop set holding Invert + a Save As into `dest` and plays it on a PSD. -/// Returns the played result, whether the PSD changed on disk and the files in `out`. -fn play_imported_save_as(dest: &str) -> (Value, bool, Vec) { +/// Loads a Photoshop set holding Invert + a Save As into `dest` (in `out`; with `in_folder`, +/// Photoshop's "save in directory", `out` itself) and plays it on each PSD in `sources`. +/// Returns the played results, whether any PSD changed on disk and the files in `out`. +fn play_imported_save_as(dest: &str, in_folder: bool, sources: &[&str]) -> (Vec, bool, Vec) { use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; - let dir = std::env::temp_dir().join(format!("photocraft-atn-save-as-{}-{}", dest.len(), std::process::id())); + let dir = std::env::temp_dir().join(format!("photocraft-atn-save-as-{dest}-{in_folder}-{}", std::process::id())); let out = dir.join("out"); std::fs::create_dir_all(&out).unwrap(); - let src = dir.join("src.psd").to_string_lossy().into_owned(); let path = |text: &str| { let units: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); let mut b = b"txtu".to_vec(); @@ -699,10 +699,13 @@ fn play_imported_save_as(dest: &str) -> (Value, bool, Vec) { units.iter().for_each(|u| b.extend_from_slice(&u.to_le_bytes())); D::Path(b) }; - let save = Descriptor::new("null") - .with("As ", D::Descriptor(Descriptor::new("PNGF"))) - .with("In ", path(&out.join(dest).to_string_lossy())) - .with("DocI", D::Integer(53)); + let mut save = Descriptor::new("null").with("As ", D::Descriptor(Descriptor::new("PNGF"))); + save = if in_folder { + save.with("In ", path(&out.to_string_lossy())).with("DIDr", D::Boolean(true)) + } else { + save.with("In ", path(&out.join(dest).to_string_lossy())) + }; + let save = save.with("DocI", D::Integer(53)); let item = |code: &[u8; 4], label: &str, d: Option| AtnItem { event: AtnEvent::Code(*code), dictionary: label.into(), @@ -719,37 +722,99 @@ fn play_imported_save_as(dest: &str) -> (Value, bool, Vec) { ..Default::default() }; let mut app = disk_app(vec![]); - app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); - app.save_as(Some(src.clone())).unwrap(); - let before = std::fs::read(&src).unwrap(); + let sources: Vec = sources.iter().map(|n| dir.join(format!("{n}.psd")).to_string_lossy().into_owned()).collect(); + let mut before = Vec::new(); + for src in &sources { + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.save_as(Some(src.clone())).unwrap(); + before.push(std::fs::read(src).unwrap()); + } app.run("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(&photocraft_psd::atn::write(&set).unwrap())})).unwrap(); - app.open_path(&src).unwrap(); - let played = app.run("actions.play", json!({"action": 0})).unwrap(); + let mut played = Vec::new(); + for src in &sources { + app.open_path(src).unwrap(); + played.push(app.run("actions.play", json!({"action": 0})).unwrap()); + } let mut written: Vec = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); written.sort(); - let r = (played, std::fs::read(&src).unwrap() != before, written); + let changed = sources.iter().zip(&before).any(|(src, before)| std::fs::read(src).unwrap() != *before); let _ = std::fs::remove_dir_all(&dir); - r + (played, changed, written) } #[test] fn imported_save_as_writes_the_recorded_file_not_the_played_one() { // #7102: the Save As imported as File › Save, which wrote over src.psd. - let (played, src_changed, written) = play_imported_save_as("result.png"); - assert!(played.get("failed").is_none(), "{played}"); + let (played, src_changed, written) = play_imported_save_as("result.png", false, &["src"]); + assert!(played[0].get("failed").is_none(), "{}", played[0]); assert!(!src_changed, "the Save As wrote over the file the action was played on"); assert_eq!(written, ["result.png"]); } #[test] -fn imported_save_as_into_a_folder_stops_before_writing() { - // A folder destination isn't mapped yet (#5430): playback stops on it, nothing is saved. - let (played, src_changed, written) = play_imported_save_as("folder"); - assert!(played.get("failed").is_some(), "{played}"); +fn imported_save_as_to_a_file_without_an_extension_stops_before_writing() { + // A destination that isn't a file of the recorded format stays a kept step (#7102): + // playback stops on it, nothing is saved. + let (played, src_changed, written) = play_imported_save_as("folder", false, &["src"]); + assert!(played[0].get("failed").is_some(), "{}", played[0]); assert!(!src_changed); assert!(written.is_empty(), "{written:?}"); } +#[test] +fn imported_save_as_into_a_folder_saves_each_document_under_its_own_name() { + // #5305: Photoshop's "save in directory" Save As, played on two documents, writes one file + // for each, named after it, in the recorded folder, and leaves the played files alone. + let (played, src_changed, written) = play_imported_save_as("", true, &["first", "second"]); + for p in &played { + assert!(p.get("failed").is_none(), "{p}"); + } + assert!(!src_changed); + assert_eq!(written, ["first.png", "second.png"]); +} + +/// #5305: a Save As recorded with the document's own name (the one Save As suggests) keeps +/// only the folder, so playing the action saves each document under its own name instead of +/// over the recorded file; a name typed in is recorded as it is. +#[test] +fn recorded_save_as_with_the_suggested_name_saves_each_document_under_its_own() { + let written = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let log = written.clone(); + let services = crate::Services { + export: Some(Box::new(|doc, path, _| photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))), + write: Some(Box::new(move |path, _| { + log.borrow_mut().push(path.to_string()); + Ok(()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.background_jobs = false; + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 16, "height": 16, "name": "first"})).unwrap(); + app.run("actions.record", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "out/first.psd"})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "out/renamed.psd"})).unwrap(); + app.run("actions.stop", json!({})).unwrap(); + let steps = &app.session.actions.list[0].steps; + assert_eq!(steps, &[("file.saveAs".into(), json!({"folder": "out", "extension": "psd"})), ("file.saveAs".into(), json!({"path": "out/renamed.psd"}))]); + // Played on two other documents: each under its own name in the recorded folder. + app.session.actions.list[0].steps.truncate(1); + written.borrow_mut().clear(); + let mut expected = Vec::new(); + for name in ["second", "third"] { + app.run("file.new", json!({"width": 16, "height": 16, "name": name})).unwrap(); + let played = app.run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + let own = std::path::Path::new("out").join(format!("{name}.psd")).to_string_lossy().into_owned(); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(own.as_str())); + expected.push(own); + } + assert_eq!(*written.borrow(), expected); + // A folder step needs its extension. + assert!(app.replay_save("file.saveAs", &json!({"folder": "out"})).is_err()); +} + #[test] fn set_folders_group_actions_and_collapse() { use egui_kittest::{Harness, kittest::Queryable}; @@ -1505,6 +1570,161 @@ fn dialog_worker_failure_stops_action_before_following_steps() { } } +#[test] +fn a_check_spelling_step_with_its_dialog_opens_it_and_done_continues() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!((v["ran"].as_u64(), v["paused"]["step"].as_u64()), (Some(0), Some(0)), "{v}"); + assert_eq!(v["paused"]["dialog"], crate::type_panels_ui::SPELLING); + let d = app.ui.type_panels.spell.clone().expect("Check Spelling is open"); + assert!(!d.all_layers, "the dialog opens with Check All Layers as recorded"); + assert_eq!(d.items.len(), 1); + assert!(!layer_names(&app).contains(&"after".to_string()), "nothing after the dialog ran yet"); + crate::type_panels_ui::close_spelling(&mut app); + assert!(layer_names(&app).contains(&"after".to_string()), "Done played the rest"); + assert!(app.ui.status.starts_with("Played"), "{}", app.ui.status); + // With its dialog off, the step checks without opening anything and changes nothing. + app.session.actions.list[0].dialogs.clear(); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["ran"], 2, "{v}"); + assert!(app.ui.type_panels.spell.is_none()); + let st = app.session.active().unwrap(); + let texts: Vec = st + .doc + .walk() + .into_iter() + .filter_map(|(_, _, l)| match &l.content { + photocraft_doc::LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["Teh cat"]); +} + +/// Check Spelling opened by hand keeps its place: an action's Check Spelling step fails instead +/// of replacing it. +#[test] +fn a_check_spelling_step_fails_while_the_dialog_is_open_by_hand() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + crate::type_panels_ui::open_spelling(&mut app, true).unwrap(); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["failed"]["error"], "Check Spelling is already open", "{v}"); + let d = app.ui.type_panels.spell.as_ref().expect("the dialog stays open"); + assert!(d.all_layers && d.resume.is_none(), "the hand-opened dialog is kept"); + assert!(!layer_names(&app).contains(&"after".to_string())); +} + +/// A correction step (`action` set) runs as it did before the dialog toggle applied to Check +/// Spelling: its dialog switch opens nothing. +#[test] +fn a_check_spelling_correction_with_its_dialog_on_runs_without_opening_it() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let change = json!({"action": "change", "start": 0, "end": 3, "word": "Teh", "replace": "The"}); + let mut a = Action::new("Fix", vec![("edit.checkSpelling".into(), change), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Fix"})).unwrap(); + assert_eq!(v["ran"], 2, "{v}"); + assert!(app.ui.type_panels.spell.is_none(), "no dialog opened"); + let st = app.session.active().unwrap(); + let texts: Vec = st + .doc + .walk() + .into_iter() + .filter_map(|(_, _, l)| match &l.content { + photocraft_doc::LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["The cat"]); +} + +/// Done after another document became active ends the waiting action there: its remaining steps +/// don't run on the other document. +#[test] +fn check_spelling_done_on_another_document_ends_the_waiting_action() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{v}"); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + crate::type_panels_ui::close_spelling(&mut app); + for st in app.session.documents() { + assert!(!st.doc.walk().iter().any(|(_, _, l)| l.name == "after"), "a step after the dialog ran"); + } + assert!(app.paused_playbacks.paused.is_empty()); + assert!(app.ui.status_error, "{}", app.ui.status); + assert!(app.ui.status.contains("document"), "{}", app.ui.status); +} + +fn deny_spelling(id: &str, p: &Value) -> photocraft_engine::Result<()> { + if id == "edit.checkSpelling" && p.get("action").is_none() { Err(photocraft_engine::EngineError::Other("spelling denied".into())) } else { Ok(()) } +} + +/// Done records the step and continues the waiting action: when recording it fails, the status +/// keeps that error next to the playback's outcome. +#[test] +fn check_spelling_done_keeps_a_recording_error_beside_the_playback_status() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{v}"); + app.run("actions.record", json!({"name": "Rec"})).unwrap(); + app.session.authorize = Some(deny_spelling); + crate::type_panels_ui::close_spelling(&mut app); + app.session.authorize = None; + assert!(layer_names(&app).contains(&"after".to_string()), "Done played the rest"); + assert!(app.ui.status_error, "{}", app.ui.status); + assert!(app.ui.status.contains("spelling denied"), "{}", app.ui.status); + assert!(app.ui.status.contains("Played"), "{}", app.ui.status); +} + +#[test] +fn check_spelling_opened_again_while_an_action_waits_keeps_its_continuation() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + app.run("type.create", json!({"x": 10, "y": 50, "text": "Teh cat"})).unwrap(); + let mut a = Action::new("Spell", vec![("edit.checkSpelling".into(), json!({"allLayers": false})), ("layer.new.layer".into(), json!({"name": "after"}))]); + a.dialogs.insert(0); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{v}"); + let token = app.ui.type_panels.spell.as_ref().and_then(|d| d.resume); + assert!(token.is_some()); + // Edit › Check Spelling… while the action waits. + crate::type_panels_ui::open_spelling(&mut app, true).unwrap(); + assert_eq!(app.ui.type_panels.spell.as_ref().and_then(|d| d.resume), token, "the reopened dialog keeps the token"); + // A second playback that reaches a Check Spelling step with its dialog fails there. + let v = play(&mut app, &json!({"action": "Spell"})).unwrap(); + assert_eq!(v["failed"]["step"], 0, "{v}"); + assert_eq!(app.ui.type_panels.spell.as_ref().and_then(|d| d.resume), token, "the waiting action keeps the dialog"); + assert!(!layer_names(&app).contains(&"after".to_string())); + crate::type_panels_ui::close_spelling(&mut app); + let after = layer_names(&app).iter().filter(|n| *n == "after").count(); + assert_eq!(after, 1, "Done continued the waiting action once"); + assert!(app.paused_playbacks.paused.is_empty()); +} + #[test] fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index fd035f3284..e7916010e8 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -1,10 +1,13 @@ //! The Layers panel's filter row (#5048): a filter-type dropdown, as in Photoshop, then the //! controls for that type. Kind toggles layer kinds, Name matches part of the layer name, Mode -//! picks one blend mode. The criteria live in `UiState` (`layer_filter*`), so the control channel -//! can read and set them. +//! picks one blend mode, Color one layer colour label (#5898), Smart Object toggles linked, missing +//! and embedded smart objects, Artboard lists only the artboards that hold the selected layers. The +//! criteria live in `UiState` (`layer_filter*`), so the control channel can read and set them. + +use std::collections::HashSet; use photocraft_color::BlendMode; -use photocraft_doc::{Layer, LayerContent}; +use photocraft_doc::{Document, LabelColor, Layer, LayerContent, LayerId, SmartSource}; use serde::{Deserialize, Serialize}; use crate::state::UiState; @@ -18,6 +21,9 @@ pub enum FilterBy { Kind, Name, Mode, + Color, + SmartObject, + Artboard, } /// The filter criteria, copied out of `UiState` for one frame of the Layers panel. @@ -28,20 +34,56 @@ pub struct Filter { /// As typed; matched trimmed and ignoring case. pub name: String, pub mode: BlendMode, + pub color: LabelColor, + /// The Smart Object toggles that are on, from [`SMART`]. + pub smart: Vec, + /// The linked files the Smart Object filter's Missing toggle lists + /// ([`photocraft_engine::Session::missing_links`]). + pub missing: HashSet, + /// The rows the Artboard filter lists ([`board_rows`]); empty lists every row. + pub board_rows: HashSet, } impl Filter { pub fn of(ui: &UiState) -> Self { - Self { by: ui.layer_filter_by, kinds: ui.layer_filter.clone(), name: ui.layer_filter_name.clone(), mode: ui.layer_filter_mode } + Self { + by: ui.layer_filter_by, + kinds: ui.layer_filter.clone(), + name: ui.layer_filter_name.clone(), + mode: ui.layer_filter_mode, + color: ui.layer_filter_color, + smart: ui.layer_filter_smart.clone(), + missing: HashSet::new(), + board_rows: HashSet::new(), + } } - /// Whether the panel is filtered. Like Photoshop, Mode always filters (by Normal at first); - /// Kind and Name only once a kind is toggled or some text is typed. + /// Returns [`Filter::of`] for `app`'s settings, with the active document's missing linked + /// files while the panel filters by Smart Object, and `board_rows` set to the rows of the + /// artboards that hold the active document's selected layers while it filters by Artboard. + pub fn of_app(app: &PhotocraftApp) -> Self { + let mut f = Self::of(&app.ui); + if f.by == FilterBy::SmartObject { + f.missing = app.session.missing_links(); + } + if f.by == FilterBy::Artboard + && let Some(d) = app.session.active() + { + f.board_rows = board_rows(&d.doc, &d.selected_layers()); + } + f + } + + /// Whether the panel is filtered. Like Photoshop, Mode and Color always filter (by Normal and + /// by no colour at first); Kind, Name and Smart Object only once a toggle is on or some text is + /// typed; Artboard only while some layers are selected and each of them is in an artboard. pub fn active(&self) -> bool { match self.by { FilterBy::Kind => !self.kinds.is_empty(), FilterBy::Name => !self.name.trim().is_empty(), - FilterBy::Mode => true, + FilterBy::Mode | FilterBy::Color => true, + FilterBy::SmartObject => !self.smart.is_empty(), + FilterBy::Artboard => !self.board_rows.is_empty(), } } @@ -54,25 +96,75 @@ impl Filter { FilterBy::Kind => self.kinds.iter().any(|k| k == kind(l)), FilterBy::Name => l.name.to_lowercase().contains(&self.name.trim().to_lowercase()), FilterBy::Mode => l.blend == self.mode, + FilterBy::Color => l.label == self.color, + FilterBy::SmartObject => { + let LayerContent::Smart(sm) = &l.content else { return false }; + self.smart.iter().any(|k| match (k.as_str(), &sm.source) { + ("linked", SmartSource::Linked { .. }) | ("embedded", SmartSource::Embedded { .. }) => true, + ("missing", SmartSource::Linked { path }) => self.missing.contains(path), + _ => false, + }) + } + FilterBy::Artboard => self.board_rows.contains(&l.id), } } } +/// The Artboard filter's rows for `selection`: the artboards that hold the selected layers and +/// every layer inside them. The set is empty when nothing is selected or a selected layer is in +/// no artboard, and the panel is then not filtered. The panel lists these rows as its tree does: the contents of a closed +/// group stay hidden until the group is opened (`layer_tree_ui::panel_rows` opens every group only +/// for the types whose matches can sit in a closed group). +pub fn board_rows(doc: &Document, selection: &[LayerId]) -> HashSet { + fn add(l: &Layer, rows: &mut HashSet) { + let mut pending = vec![l]; + while let Some(layer) = pending.pop() { + rows.insert(layer.id); + pending.extend(layer.children().unwrap_or_default()); + } + } + let selected: HashSet = selection.iter().copied().collect(); + if selected.is_empty() { + return HashSet::new(); + } + // Artboards are top-level groups ([`Document::artboard_of`]): each one's layers are walked once. + let (mut rows, mut held) = (HashSet::new(), 0); + for board in doc.layers.iter().filter(|l| l.artboard().is_some()) { + let mut inside = HashSet::new(); + add(board, &mut inside); + let n = selected.iter().filter(|id| inside.contains(id)).count(); + if n > 0 { + held += n; + rows.extend(inside); + } + } + if held < selected.len() { + return HashSet::new(); + } + rows +} + /// The kinds the Kind filter toggles. pub const KINDS: [&str; 5] = ["pixel", "adjustment", "type", "shape", "smart"]; -/// `ui.set {layerFilter: {by?, kinds?, name?, mode?}}`: `current` with the given fields replaced, -/// or an error naming the bad field (nothing changes then). `by` is `kind`, `name` or `mode`; -/// `kinds` a list of [`KINDS`]; `mode` a blend mode name such as `Multiply` or `Pass Through`. +/// The Smart Object filter's toggles: smart objects linked to a file, linked to a file that is +/// not there, and embedded in the document. +pub const SMART: [&str; 3] = ["linked", "missing", "embedded"]; + +/// `ui.set {layerFilter: {by?, kinds?, name?, mode?, color?, smart?}}`: `current` with the given +/// fields replaced, or an error naming the bad field (nothing changes then). `by` is `kind`, +/// `name`, `mode`, `color`, `smartObject` or `artboard`; `kinds` a list of [`KINDS`]; `mode` a +/// blend mode name such as `Multiply` or `Pass Through`; `color` a label colour id (`none`, `red`, +/// … `gray`, [`LabelColor::id`]); `smart` a list of [`SMART`]. Artboard has no criteria of its +/// own: the selected layers pick the artboards. pub fn patched(current: &UiState, v: &serde_json::Value) -> Result { let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) }; - if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "mode"].contains(&k.as_str())) { - return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode)")); + if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "mode", "color", "smart"].contains(&k.as_str())) { + return Err(format!("unknown layerFilter field `{k}` (fields: by, kinds, name, mode, color, smart)")); } - let mut f = - Filter { by: current.layer_filter_by, kinds: current.layer_filter.clone(), name: current.layer_filter_name.clone(), mode: current.layer_filter_mode }; + let mut f = Filter::of(current); if let Some(by) = o.get("by") { - f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name or mode".to_string())?; + f.by = serde_json::from_value(by.clone()).map_err(|_| "layerFilter.by must be kind, name, mode, color, smartObject or artboard".to_string())?; } if let Some(kinds) = o.get("kinds") { let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?; @@ -95,6 +187,25 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result = LabelColor::ALL.iter().map(|c| c.id()).collect(); + format!("layerFilter.color: unknown colour `{id}` (colours: {})", ids.join(", ")) + })?; + } + if let Some(smart) = o.get("smart") { + let list = smart.as_array().ok_or("layerFilter.smart must be a list")?; + f.smart = Vec::new(); + for k in list { + let Some(k) = k.as_str().filter(|k| SMART.contains(k)) else { + return Err(format!("layerFilter.smart: unknown toggle {k} (toggles: {})", SMART.join(", "))); + }; + if !f.smart.iter().any(|have| have == k) { + f.smart.push(k.to_string()); + } + } + } Ok(f) } @@ -104,6 +215,8 @@ pub fn apply(ui: &mut UiState, f: Filter) { ui.layer_filter = f.kinds; ui.layer_filter_name = f.name; ui.layer_filter_mode = f.mode; + ui.layer_filter_color = f.color; + ui.layer_filter_smart = f.smart; } /// The kind filter's name for `l`'s content ("" for groups, which no kind matches). @@ -122,7 +235,14 @@ fn kind(l: &Layer) -> &'static str { pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - let by = [(FilterBy::Kind, tl!("Kind")), (FilterBy::Name, tl!("Name")), (FilterBy::Mode, tl!("Mode"))]; + let by = [ + (FilterBy::Kind, tl!("Kind")), + (FilterBy::Name, tl!("Name")), + (FilterBy::Mode, tl!("Mode")), + (FilterBy::Color, tl!("Color")), + (FilterBy::SmartObject, tl!("Smart Object")), + (FilterBy::Artboard, tl!("Artboard")), + ]; widgets::dropdown(ui, "layer-filter-by", tl!("Kind"), &mut app.ui.layer_filter_by, &by, 64.0); ui.add_space(4.0); match app.ui.layer_filter_by { @@ -153,6 +273,31 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let width = ui.available_width().max(96.0); widgets::dropdown(ui, "layer-filter-mode", tl!("Mode"), &mut app.ui.layer_filter_mode, &modes, width); } + FilterBy::Color => { + // Photoshop's list: None, then the label colours in the Layers panel menu's order. + let colors: Vec<(LabelColor, &str)> = + LabelColor::ALL.into_iter().map(|c| (c, if c == LabelColor::None { tl!("None") } else { tl!(c.label()) })).collect(); + let width = ui.available_width().max(96.0); + widgets::dropdown(ui, "layer-filter-color", tl!("Color"), &mut app.ui.layer_filter_color, &colors, width); + } + FilterBy::SmartObject => { + for (kind, icon, tip) in [ + ("linked", "link", tl!("Filter for smart objects linked locally")), + ("missing", "unlink", tl!("Filter for linked smart objects that are missing")), + ("embedded", "file", tl!("Filter for embedded smart objects")), + ] { + let on = app.ui.layer_filter_smart.iter().any(|k| k == kind); + if icons::button(ui, icon, 22.0, on, tip).clicked() { + if on { + app.ui.layer_filter_smart.retain(|k| k != kind); + } else { + app.ui.layer_filter_smart.push(kind.to_string()); + } + } + } + } + // Artboard has no controls: the selected layers pick the artboards ([`board_rows`]). + FilterBy::Artboard => {} } }); } diff --git a/crates/ui-egui/src/panels/layer_filter_ui/tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/tests.rs index 89e1a6a0b9..50eeb863cd 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/tests.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/tests.rs @@ -1,4 +1,4 @@ -//! #5048: the Layers panel's filter-type dropdown (Kind, Name, Mode). +//! #5048: the Layers panel's filter-type dropdown (Kind, Name, Mode, Color, Smart Object, Artboard). use egui::vec2; use egui_kittest::Harness; @@ -10,6 +10,7 @@ use serde_json::json; use super::{Filter, FilterBy}; use crate::PhotocraftApp; use crate::state::UiState; +use crate::theme::ThemeKind; /// Background, "Ink" (Multiply) and "Paper" in a group "Sheets", and "ink notes" (type) on top. fn session() -> photocraft_engine::Session { @@ -91,10 +92,17 @@ fn saved_settings_without_the_new_fields_still_load() { } fn harness() -> Harness<'static, PhotocraftApp> { + harness_in(ThemeKind::default()) +} + +fn harness_in(theme: ThemeKind) -> Harness<'static, PhotocraftApp> { let session = session(); let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - PhotocraftApp::new(session, crate::Services::default()) + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.run("prefs.set", json!({"path": "interface.theme", "value": theme.id()})).unwrap(); + app.ui.theme = theme; + app }); h.run_steps(8); h @@ -136,3 +144,285 @@ fn the_dropdown_switches_the_panel_between_filter_types() { h.run_steps(4); assert_eq!(drawn(&h), ["ink notes"]); } + +/// The filter row was drawn in the Pro themes only, while Filter Options › Show is in every +/// theme's panel menu: in Studio and the others it did nothing. +#[test] +fn every_theme_shows_the_filter_row() { + for theme in ThemeKind::ALL { + let mut h = harness_in(theme); + assert!(h.query_by_value("Kind").is_some(), "{theme:?} draws the filter row"); + h.state_mut().ui.layer_filter = vec!["type".into()]; + h.run_steps(4); + assert_eq!(drawn(&h), ["ink notes"], "{theme:?} filters by kind"); + } +} + +/// #5898: Photoshop's Color filter lists the layers with one colour label; it filters at once, +/// by no colour (None) at first, like Mode. Agents set it with `layerFilter.color`. +#[test] +fn the_color_filter_lists_one_label() { + let mut s = session(); + let ink = layer(&s.active().unwrap().doc, "Ink"); + let sheets = layer(&s.active().unwrap().doc, "Sheets"); + // A group's colour goes to its layers too (Photoshop), so the group first, then Ink inside it. + s.execute("layer.setLabelColor", json!({"layer": sheets, "color": "seafoam"})).unwrap(); + s.execute("layer.setLabelColor", json!({"layer": ink, "color": "red"})).unwrap(); + let doc = &s.active().unwrap().doc; + let by_color = |c: photocraft_doc::LabelColor| Filter::of(&UiState { layer_filter_by: FilterBy::Color, layer_filter_color: c, ..UiState::default() }); + assert!(by_color(photocraft_doc::LabelColor::None).active(), "Color filters at once, as in Photoshop"); + assert_eq!(listed(doc, &by_color(photocraft_doc::LabelColor::Red)), ["Ink"]); + assert_eq!(listed(doc, &by_color(photocraft_doc::LabelColor::Seafoam)), ["Sheets", "Paper"]); + assert_eq!(listed(doc, &by_color(photocraft_doc::LabelColor::None)), ["ink notes", "Background"]); + assert!(listed(doc, &by_color(photocraft_doc::LabelColor::Blue)).is_empty(), "a colour no layer has lists nothing"); + // Over the control channel. + let f = super::patched(&UiState::default(), &json!({"by": "color", "color": "red"})).unwrap(); + assert_eq!((f.by, f.color), (FilterBy::Color, photocraft_doc::LabelColor::Red)); + assert!(super::patched(&UiState::default(), &json!({"color": "crimson"})).unwrap_err().contains("crimson")); + assert!(super::patched(&UiState::default(), &json!({"color": 2})).is_err()); +} + +/// On a Background: the artboard "Back" holding "Grass", the artboard "Front" holding the group +/// "Trees" with "Oak", and "Loose" in no artboard, on top. +fn boards_session() -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + // Every layer is made before the artboards, since a new layer goes into the artboard it is on. + for name in ["Grass", "Oak", "Loose"] { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + } + let grass = layer(&s.active().unwrap().doc, "Grass"); + s.execute("layer.select", json!({"layer": grass})).unwrap(); + s.execute("layer.new.artboardFromLayers", json!({"name": "Back"})).unwrap(); + let oak = layer(&s.active().unwrap().doc, "Oak"); + s.execute("layer.select", json!({"layer": oak})).unwrap(); + s.execute("layer.groupLayers", json!({"name": "Trees"})).unwrap(); + s.execute("layer.new.artboardFromLayers", json!({"name": "Front"})).unwrap(); + let loose = layer(&s.active().unwrap().doc, "Loose"); + s.execute("layer.select", json!({"layer": loose})).unwrap(); + s.execute("layer.arrange.bringToFront", json!({})).unwrap(); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); + s +} + +/// The names the Artboard filter lists with the layers `names` selected, every group open. +fn listed_by_artboard(s: &mut photocraft_engine::Session, names: &[&str]) -> Vec { + s.execute("select.deselectLayers", json!({})).unwrap(); + for (i, name) in names.iter().enumerate() { + let id = layer(&s.active().unwrap().doc, name); + s.execute("layer.select", json!({"layer": id, "mode": if i == 0 { "replace" } else { "add" }})).unwrap(); + } + let d = s.active().unwrap(); + let mut f = filter(FilterBy::Artboard, &[], "", BlendMode::Normal); + f.board_rows = super::board_rows(&d.doc, &d.selected_layers()); + listed(&d.doc, &f) +} + +#[test] +fn the_artboard_filter_lists_the_artboards_that_hold_the_selected_layers() { + let mut s = boards_session(); + let all = ["Loose", "Front", "Trees", "Oak", "Back", "Grass", "Background"]; + assert_eq!(listed_by_artboard(&mut s, &[]), all, "nothing selected: every layer"); + // A layer at any depth in an artboard, or the artboard itself: that artboard only. + assert_eq!(listed_by_artboard(&mut s, &["Oak"]), ["Front", "Trees", "Oak"]); + assert_eq!(listed_by_artboard(&mut s, &["Front"]), ["Front", "Trees", "Oak"]); + assert_eq!(listed_by_artboard(&mut s, &["Grass"]), ["Back", "Grass"]); + // Layers in two artboards: both artboards. + assert_eq!(listed_by_artboard(&mut s, &["Oak", "Grass"]), ["Front", "Trees", "Oak", "Back", "Grass"]); + // A selected layer in no artboard: every layer again. + assert_eq!(listed_by_artboard(&mut s, &["Loose"]), all); + assert_eq!(listed_by_artboard(&mut s, &["Oak", "Loose"]), all); + let round = serde_json::to_value(UiState { layer_filter_by: FilterBy::Artboard, ..UiState::default() }).unwrap(); + assert_eq!(round["layer_filter_by"], "artboard"); +} + +#[test] +fn the_artboard_filter_follows_the_selection_in_the_panel() { + let session = boards_session(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.run_steps(8); + let all = ["Loose", "Front", "Trees", "Oak", "Back", "Grass", "Background"]; + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Artboard").last().expect("Artboard in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Artboard); + assert_eq!(drawn(&h), all, "Loose, in no artboard, is selected: every layer"); + let oak = layer(&h.state().session.active().unwrap().doc, "Oak"); + h.state_mut().session.execute("layer.select", json!({"layer": oak})).unwrap(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Front", "Trees", "Oak"]); + // The artboard's tree as the panel shows it: a closed group keeps its contents hidden. + let trees = layer(&h.state().session.active().unwrap().doc, "Trees"); + h.state_mut().session.execute("layer.setExpanded", json!({"layer": trees, "expanded": false})).unwrap(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Front", "Trees"]); + h.state_mut().session.execute("select.deselectLayers", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Loose", "Front", "Trees", "Back", "Grass", "Background"], "nothing selected: every row"); +} + +#[test] +fn the_artboard_filter_walks_deep_groups_without_recursion() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Artboard, Group, Layer, LayerContent, Size}; + use photocraft_geom::Rect; + use std::collections::HashSet; + + let mut nested = Layer::new("Leaf", LayerContent::Group(Group { children: vec![], expanded: false, artboard: None, frame_stroke: None })); + let selected = nested.id; + let mut expected = HashSet::from([selected]); + for _ in 0..16_384 { + nested = Layer::new("Group", LayerContent::Group(Group { children: vec![nested], expanded: false, artboard: None, frame_stroke: None })); + expected.insert(nested.id); + } + let mut doc = Document::new("Deep groups", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let board = Layer::new( + "Board", + LayerContent::Group(Group { children: vec![nested], expanded: true, artboard: Some(Artboard::new(Rect::new(0, 0, 8, 8))), frame_stroke: None }), + ); + expected.insert(board.id); + doc.layers.push(board); + std::thread::Builder::new() + .stack_size(128 * 1024) + .spawn(move || { + let actual = super::board_rows(&doc, &[selected]); + let mut pending = std::mem::take(&mut doc.layers); + while let Some(mut layer) = pending.pop() { + if let Some(children) = layer.children_mut() { + pending.append(children); + } + } + assert_eq!(actual, expected); + }) + .unwrap() + .join() + .unwrap(); +} + +/// Creates a PNG fixture and returns its temporary directory with the file path. +fn art_file() -> (tempfile::TempDir, std::path::PathBuf) { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("art.png"); + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ff0000"})).unwrap(); + s.execute("file.saveACopy", json!({"path": file.to_string_lossy(), "layers": false})).unwrap(); + (directory, file) +} + +/// "Linked art" (linked to `file`) above "Embedded art" (a copy of `file`), "Plain" and +/// "Background". +fn smart_session(file: &std::path::Path) -> photocraft_engine::Session { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Plain"})).unwrap(); + for (command, name) in [("file.placeEmbedded", "Embedded art"), ("file.placeLinked", "Linked art")] { + s.execute(command, json!({"path": file.to_string_lossy()})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("layer.setProps", json!({"layer": id, "name": name})).unwrap(); + } + s +} + +fn by_smart(smart: &[&str], missing: std::collections::HashSet) -> Filter { + Filter { smart: smart.iter().map(|k| k.to_string()).collect(), missing, ..filter(FilterBy::SmartObject, &[], "", BlendMode::Normal) } +} + +#[test] +fn the_smart_object_filter_lists_linked_missing_and_embedded_smart_objects() { + let (_directory, file) = art_file(); + let mut s = smart_session(&file); + let doc = &s.active().unwrap().doc; + let none = std::collections::HashSet::new; + assert!(!by_smart(&[], none()).active(), "no toggle on: every layer"); + assert_eq!(listed(doc, &by_smart(&[], none())), ["Linked art", "Embedded art", "Plain", "Background"]); + assert_eq!(listed(doc, &by_smart(&["linked"], none())), ["Linked art"]); + assert_eq!(listed(doc, &by_smart(&["embedded"], none())), ["Embedded art"]); + assert_eq!(listed(doc, &by_smart(&["linked", "embedded"], none())), ["Linked art", "Embedded art"]); + assert_eq!(listed(doc, &by_smart(&["missing"], s.missing_links())), Vec::::new(), "the file is there"); + std::fs::remove_file(&file).unwrap(); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(listed(doc, &by_smart(&["missing"], s.missing_links())), ["Linked art"]); + assert_eq!(listed(doc, &by_smart(&["missing", "embedded"], s.missing_links())), ["Linked art", "Embedded art"]); +} + +#[test] +fn saved_settings_without_the_smart_object_toggles_still_load() { + let mut saved = serde_json::to_value(UiState::default()).unwrap(); + assert!(saved.as_object_mut().unwrap().remove("layer_filter_smart").is_some(), "layer_filter_smart is saved"); + let ui: UiState = serde_json::from_value(saved).unwrap(); + assert!(ui.layer_filter_smart.is_empty()); + let round = + serde_json::to_value(UiState { layer_filter_by: FilterBy::SmartObject, layer_filter_smart: vec!["missing".into()], ..UiState::default() }).unwrap(); + let back: UiState = serde_json::from_value(round.clone()).unwrap(); + assert_eq!( + (round["layer_filter_by"].as_str(), back.layer_filter_by, back.layer_filter_smart), + (Some("smartObject"), FilterBy::SmartObject, vec!["missing".to_string()]) + ); +} + +#[test] +fn the_smart_object_toggles_filter_the_panel() { + let (_directory, file) = art_file(); + let session = smart_session(&file); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.run_steps(8); + let all = ["Linked art", "Embedded art", "Plain", "Background"]; + assert_eq!(drawn(&h), all); + h.get_by_value("Kind").click(); + h.run_steps(4); + h.query_all_by_label("Smart Object").last().expect("Smart Object in the open list").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::SmartObject); + assert_eq!(drawn(&h), all, "no toggle on yet: every layer"); + h.get_by_label("Filter for embedded smart objects").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_smart, ["embedded"]); + assert_eq!(drawn(&h), ["Embedded art"]); + h.get_by_label("Filter for embedded smart objects").click(); + h.get_by_label("Filter for smart objects linked locally").click(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Linked art"]); + h.get_by_label("Filter for smart objects linked locally").click(); + h.get_by_label("Filter for linked smart objects that are missing").click(); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_smart, ["missing"]); + assert_eq!(drawn(&h), Vec::::new(), "the file is there"); + std::fs::remove_file(&file).unwrap(); + h.state_mut().session.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + h.run_steps(4); + assert_eq!(drawn(&h), ["Linked art"]); +} + +#[test] +fn the_artboard_filter_lists_the_outer_artboard_for_a_layer_in_a_nested_artboard() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Artboard, Group, Layer, LayerContent, Size}; + use photocraft_geom::Rect; + use std::collections::HashSet; + + fn group(name: &str, children: Vec, artboard: Option) -> Layer { + Layer::new(name, LayerContent::Group(Group { children, expanded: true, artboard, frame_stroke: None })) + } + let leaf = group("Leaf", vec![], None); + let inner = group("Inner", vec![leaf.clone()], Some(Artboard::new(Rect::new(0, 0, 4, 4)))); + let sibling = group("Sibling", vec![], None); + let outer = group("Outer", vec![inner.clone(), sibling.clone()], Some(Artboard::new(Rect::new(0, 0, 8, 8)))); + let other = group("Other", vec![], Some(Artboard::new(Rect::new(8, 0, 8, 8)))); + let mut doc = Document::new("Nested artboards", Size::new(16, 8), ColorMode::Rgb, SampleType::U8); + let (outer_id, inner_id, leaf_id, sibling_id, other_id) = (outer.id, inner.id, leaf.id, sibling.id, other.id); + doc.layers.push(outer); + doc.layers.push(other); + let outer_rows = HashSet::from([outer_id, inner_id, leaf_id, sibling_id]); + assert_eq!(super::board_rows(&doc, &[leaf_id]), outer_rows, "a layer in a nested artboard: the outer artboard's rows"); + assert_eq!(super::board_rows(&doc, &[inner_id]), outer_rows, "the nested artboard itself: the outer artboard's rows"); + let both: HashSet<_> = outer_rows.iter().copied().chain([other_id]).collect(); + assert_eq!(super::board_rows(&doc, &[leaf_id, other_id]), both); +} diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 176ed271e1..ea5d672058 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -123,7 +123,8 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !ui.input(|i| i.pointer.primary_down()) || !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers { app.reorder_preview = None; } - if t.pro && app.ui.layers_panel_options.show_filters { + // Every theme draws the filter row: Filter Options › Show is in every theme's panel menu. + if app.ui.layers_panel_options.show_filters { crate::layer_filter_ui::bar(app, ui); ui.add_space(4.0); } @@ -270,7 +271,7 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.scroll_with_delta(vec2(0.0, delta)); ctx.request_repaint(); } - let filter = crate::layer_filter_ui::Filter::of(&app.ui); + let filter = crate::layer_filter_ui::Filter::of_app(app); let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default(); crate::layer_row_ui::begin(ui.ctx()); // The drawn rows, top to bottom (#1721): the space between them and below the last diff --git a/crates/ui-egui/src/panels/type_panels_ui.rs b/crates/ui-egui/src/panels/type_panels_ui.rs index d95c22addb..0a58ee8542 100644 --- a/crates/ui-egui/src/panels/type_panels_ui.rs +++ b/crates/ui-egui/src/panels/type_panels_ui.rs @@ -48,8 +48,15 @@ pub struct SpellDialog { pub change_to: String, pub all_layers: bool, pub message: String, + /// The token of the action playback that opened the dialog at a Check Spelling step: Done + /// continues it ([`crate::actions::resume`]). + #[serde(skip)] + pub resume: Option, } +/// The `dialog` that Check Spelling's results and a playback paused at its step name. +pub const SPELLING: &str = "checkSpelling"; + const IDS: [&str; 6] = [ "type.panels.characterStyles", "window.panel.characterStyles", @@ -766,13 +773,33 @@ fn refresh_spelling(app: &mut PhotocraftApp) { } } -/// Opens Edit › Check Spelling (returns the misspellings). +/// Opens Edit › Check Spelling (returns the misspellings). Opening it again while an action waits +/// at a Check Spelling step keeps that action's token, so Done still continues it. pub fn open_spelling(app: &mut PhotocraftApp, all_layers: bool) -> Result { app.session.execute("edit.checkSpelling", json!({ "action": "list", "allLayers": all_layers })).map_err(|e| e.to_string())?; - app.ui.type_panels.spell = Some(SpellDialog { all_layers, ..Default::default() }); + let resume = app.ui.type_panels.spell.as_ref().and_then(|d| d.resume); + app.ui.type_panels.spell = Some(SpellDialog { all_layers, resume, ..Default::default() }); refresh_spelling(app); let d = app.ui.type_panels.spell.as_ref().map(|d| d.items.clone()).unwrap_or_default(); - Ok(json!({ "dialog": "checkSpelling", "misspellings": d })) + Ok(json!({ "dialog": SPELLING, "misspellings": d })) +} + +/// Done: an action being recorded gets one Check Spelling step with Check All Layers as the dialog +/// has it, and an action paused at a Check Spelling step continues on the document it was playing +/// on. When recording the step fails, the status keeps that error with the playback's outcome. +pub fn close_spelling(app: &mut PhotocraftApp) { + let Some(d) = app.ui.type_panels.spell.take() else { return }; + let mut recording_error = None; + if app.session.actions.recording.is_some() && run(app, "edit.checkSpelling", json!({ "allLayers": d.all_layers })).is_none() { + recording_error = Some(app.ui.status.clone()); + } + if let Some(token) = d.resume { + let _ = crate::actions::resume_spelling(app, token); + if let Some(error) = recording_error { + app.ui.status = format!("{error} ({})", app.ui.status); + app.ui.status_error = true; + } + } } fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { @@ -875,7 +902,7 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(a) = act else { return }; let word = item.as_ref().and_then(|i| i["word"].as_str()).unwrap_or("").to_string(); match a { - "done" => app.ui.type_panels.spell = None, + "done" => close_spelling(app), "relist" => { if let Some(s) = app.ui.type_panels.spell.as_mut() { s.index = 0; @@ -964,6 +991,25 @@ mod tests { assert!(menu(&mut app, "edit.checkSpelling", &json!({"action": "list"})).is_none()); } + #[test] + fn recording_the_dialog_records_one_step_with_its_dialog_on() { + let mut app = app_with_text("Teh cat"); + app.run("actions.record", json!({"name": "Spell"})).unwrap(); + menu(&mut app, "edit.checkSpelling", &json!({})).unwrap().unwrap(); + let item = app.ui.type_panels.spell.as_ref().unwrap().items[0].clone(); + // Change, as the dialog's Change button runs it, then untick Check All Layers and press Done. + let change = json!({"action": "change", "layer": item["layer"], "start": item["start"], "end": item["end"], "word": "Teh", "replace": "The"}); + run(&mut app, "edit.checkSpelling", change).unwrap(); + refresh_spelling(&mut app); + app.ui.type_panels.spell.as_mut().unwrap().all_layers = false; + close_spelling(&mut app); + app.run("actions.stop", json!({})).unwrap(); + assert!(app.ui.type_panels.spell.is_none()); + let action = &app.session.actions.list[0]; + assert_eq!(action.steps, vec![("edit.checkSpelling".to_string(), json!({"allLayers": false}))]); + assert_eq!(action.dialogs.iter().copied().collect::>(), [0]); + } + /// #5496: Check Spelling's close box is Done, as in Photoshop on Windows: the window closes /// and the text keeps what it had. None on macOS. #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c18a418c4c..43539ecf89 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -349,6 +349,9 @@ pub struct UiState { /// Layers panel kind filter ("pixel", "adjustment", "type", "shape", "smart"); empty = all. #[serde(default)] pub layer_filter: Vec, + /// Layers panel Smart Object filter ("linked", "missing", "embedded"); empty = all. + #[serde(default)] + pub layer_filter_smart: Vec, /// Thumbnail dimensions, content cropping and filter-strip visibility. #[serde(default)] pub layers_panel_options: LayersPanelOptions, @@ -364,6 +367,9 @@ pub struct UiState { /// Layers panel blend-mode filter. #[serde(default = "default_layer_filter_mode")] pub layer_filter_mode: photocraft_color::BlendMode, + /// Layers panel colour filter (#5898): the label colour listed (None: layers without one). + #[serde(default)] + pub layer_filter_color: photocraft_doc::LabelColor, /// Actions panel: which row is selected and which are expanded. The list lives on the session. #[serde(default)] pub actions: crate::actions::ActionsUi, @@ -568,6 +574,7 @@ impl Default for UiState { artboard_tool: Default::default(), shell: Default::default(), layer_filter: Vec::new(), + layer_filter_smart: Vec::new(), layers_panel_options: LayersPanelOptions::default(), histogram_panel: Default::default(), histogram_range: None, @@ -575,6 +582,7 @@ impl Default for UiState { layer_filter_by: Default::default(), layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), + layer_filter_color: Default::default(), pen: None, pen_doc: None, stroke_editor: None, diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 3a05ba2cc7..003debc441 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -8,8 +8,8 @@ //! being drawn; //! - inside the frame, a drag moves it; //! - on an edge or corner, a drag resizes it along the frame's own (possibly turned) axes; ⇧ keeps -//! the frame's aspect ratio, ⌥ resizes about the centre, and an options-bar ratio preset always -//! holds; +//! the frame's aspect ratio (on a corner the pointer is followed along the frame's diagonal), +//! ⌥ resizes about the centre, and an options-bar ratio preset always holds; //! - outside the frame (off its handles) the pointer is the rotate cursor and a drag turns the //! frame about its centre, ⇧ in 15° steps, with the angle beside the pointer (#1792). As in //! Photoshop, a new frame is drawn after the pending one is committed or cancelled; @@ -204,6 +204,30 @@ pub fn resized_turned(r: [f64; 4], deg: f64, hx: i8, hy: i8, d: [f64; 2], ratio: [nc[0] - hw, nc[1] - hh, nc[0] + hw, nc[1] + hh] } +/// Drag `d` on corner (`hx`, `hy`) of frame `r` turned `deg` degrees, projected onto the frame's +/// diagonal through that corner: a ⇧ corner drag with a free ratio slides the corner along it, as +/// in Photoshop (#10871). A corner drag on a ratio otherwise lets the larger relative extent win, +/// so (−100, −50) on 800 × 500 gave 720 × 450 instead of Photoshop's 706 × 441. +fn along_diagonal(r: [f64; 4], deg: f64, hx: i8, hy: i8, d: [f64; 2]) -> [f64; 2] { + let v = turn([f64::from(hx) * (r[2] - r[0]), f64::from(hy) * (r[3] - r[1])], [0.0, 0.0], deg); + let vv = v[0] * v[0] + v[1] * v[1]; + let t = (d[0] * v[0] + d[1] * v[1]) / vv; + if vv > 0.0 && t.is_finite() { [t * v[0], t * v[1]] } else { d } +} + +/// The pointer at `q` of a resize drag on handle (`hx`, `hy`) of frame `rect`, pressed at +/// `start`, as the resize reads it. ⇧ on a corner with no ratio preset and no ⌥ follows the +/// frame's diagonal ([`along_diagonal`]); a preset, ⌥ ⇧ and the edge handles keep the +/// larger-extent rule, and the point is unchanged. The resize, Auto Center Preview and handle +/// snapping all read the pointer through this, so the handle snaps where it is drawn. +pub(crate) fn resize_point(app: &PhotocraftApp, rect: [f64; 4], hx: i8, hy: i8, start: [f64; 2], q: [f64; 2], mods: Modifiers) -> [f64; 2] { + if !(mods.shift && !mods.alt && hx != 0 && hy != 0 && preset_ratio(app).is_none()) { + return q; + } + let d = along_diagonal(rect, angle(app), hx, hy, [q[0] - start[0], q[1] - start[1]]); + [start[0] + d[0], start[1] + d[1]] +} + /// `[x0, y0, x1, y1]` with x0 ≤ x1, y0 ≤ y1. fn normalized(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] @@ -935,7 +959,7 @@ fn follow_view_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> bo } Some(CropDrag::Resize { hx, hy, start, rect, view: Some(view) }) => { // Auto Center Preview keeps the box centred while the handle follows the pointer. - let at = crate::crop_mode::to_press(app, Some(view), p); + let at = resize_point(app, rect, hx, hy, start, crate::crop_mode::to_press(app, Some(view), p), mods); let ratio = resize_ratio(app, rect, mods); let deg = angle(app); crate::crop_mode::live_resize(app, crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q: at, ratio, alt: mods.alt }, view); @@ -950,6 +974,10 @@ fn dragged_frame(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> Optio let ratio = preset_ratio(app); let space = app.crop.space; let deg = angle(app); + let resize_at = match app.crop.drag { + Some(CropDrag::Resize { hx, hy, start, rect, .. }) => resize_point(app, rect, hx, hy, start, p, mods), + _ => p, + }; match app.crop.drag.as_mut()? { CropDrag::Draw { anchor, cur, last, .. } => { if space { @@ -973,7 +1001,7 @@ fn dragged_frame(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> Optio (w > 0.0 && h > 0.0).then(|| w / h) }; let ratio = ratio.or_else(|| if mods.shift { keep() } else { None }); - Some(resized_turned(*rect, deg, *hx, *hy, [p[0] - start[0], p[1] - start[1]], ratio, mods.alt)) + Some(resized_turned(*rect, deg, *hx, *hy, [resize_at[0] - start[0], resize_at[1] - start[1]], ratio, mods.alt)) } CropDrag::Rotate { .. } | CropDrag::TurnImage { .. } | CropDrag::MoveImage { .. } => None, } diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 6fba4e57d4..d70e2f5792 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -387,13 +387,14 @@ fn handles_resize_with_shift_and_alt() { // Bottom-right corner, free. drag(&mut app, &[[120.0, 60.0], [140.0, 70.0]], NONE); assert_eq!(app.ui.crop_rect, Some([20.0, 20.0, 140.0, 70.0])); - // ⇧ keeps 120:50 from the top-left corner: the larger relative extent wins. + // ⇧ keeps 120:50 from the top-left corner: the corner follows the frame's diagonal, so the + // drag (−10, −20) projected onto (−120, −50) grows the frame by 2200 / 16900 (#10871). app.ui.crop_rect = Some([20.0, 20.0, 140.0, 70.0]); drag(&mut app, &[[20.0, 20.0], [10.0, 0.0]], SHIFT); let r = app.ui.crop_rect.unwrap(); assert_eq!((r[2], r[3]), (140.0, 70.0), "opposite corner fixed"); assert!(((r[2] - r[0]) / (r[3] - r[1]) - 120.0 / 50.0).abs() < 1e-9, "{r:?}"); - assert_eq!(r[3] - r[1], 70.0); + assert!((r[3] - r[1] - 50.0 * (1.0 + 2200.0 / 16900.0)).abs() < 1e-9, "{r:?}"); // ⌥ resizes about the centre. app.ui.crop_rect = Some([40.0, 20.0, 80.0, 60.0]); drag(&mut app, &[[80.0, 40.0], [90.0, 40.0]], Modifiers::ALT); @@ -405,6 +406,26 @@ fn handles_resize_with_shift_and_alt() { assert_eq!(app.ui.crop_rect, Some([40.0, 10.0, 100.0, 70.0])); } +/// The top-right and bottom-left corners follow the other diagonal, (120, −50) and (−120, 50): +/// the drag projected onto it grows the frame by 2200 / 16900, as on the top-left corner. Along +/// the top-left / bottom-right diagonal the top-right x1 would move to about 141.4. +#[test] +fn shift_resize_follows_the_diagonal_of_the_top_right_and_bottom_left_corners() { + let mut app = app(SampleType::U8); + app.ui.crop_rect = Some([20.0, 20.0, 140.0, 70.0]); + drag(&mut app, &[[140.0, 20.0], [150.0, 0.0]], SHIFT); + let r = app.ui.crop_rect.unwrap(); + assert_eq!((r[0], r[3]), (20.0, 70.0), "top-right: opposite corner fixed"); + assert!((r[2] - (140.0 + 2640.0 / 169.0)).abs() < 1e-9, "top-right: {r:?}"); + assert!((r[1] - (20.0 - 1100.0 / 169.0)).abs() < 1e-9, "top-right: {r:?}"); + app.ui.crop_rect = Some([20.0, 20.0, 140.0, 70.0]); + drag(&mut app, &[[20.0, 70.0], [10.0, 90.0]], SHIFT); + let r = app.ui.crop_rect.unwrap(); + assert_eq!((r[2], r[1]), (140.0, 20.0), "bottom-left: opposite corner fixed"); + assert!((r[0] - (20.0 - 2640.0 / 169.0)).abs() < 1e-9, "bottom-left: {r:?}"); + assert!((r[3] - (70.0 + 1100.0 / 169.0)).abs() < 1e-9, "bottom-left: {r:?}"); +} + #[test] fn dragging_a_handle_past_the_other_side_flips_and_collapse_reverts() { let mut app = app(SampleType::U8); diff --git a/crates/ui-egui/src/tools/move_box.rs b/crates/ui-egui/src/tools/move_box.rs index f49a5061be..05f12222b4 100644 --- a/crates/ui-egui/src/tools/move_box.rs +++ b/crates/ui-egui/src/tools/move_box.rs @@ -154,6 +154,31 @@ mod tests { assert!(app.layer_box.is_none()); } + /// #10665: a fill layer, or pixels hanging past the canvas edge, aren't drawn on the + /// pasteboard, so a click there still clears the layer selection rather than picking them. + #[test] + fn pasteboard_click_deselects_over_fill_layers_and_off_canvas_pixels() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "transparent"})).unwrap(); + app.run("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let off = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("paint past the edge", |doc, _| { + doc.layer_mut(off).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(-20, 0, 10, 64), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = true; + tool_event(&mut app, ToolEvent::Down { x: -10.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: -10.0, y: 30.0 }, egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert!(st.selected_layers().is_empty() && st.active_layer.is_none(), "selected {:?}", st.selected_layers()); + assert_eq!(st.doc.layer(off).unwrap().surface().unwrap().content_bounds().x0, -20, "nothing moved"); + } + /// #4058: a drag that starts on the locked Background draws the layer marquee. It does not /// open the move-lock dialog, and releasing selects the unlocked layer the box touches. #[test] diff --git a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs index fbe0d88c7c..38baecc450 100644 --- a/crates/ui-egui/src/tools/type_tool/canvas_tests.rs +++ b/crates/ui-egui/src/tools/type_tool/canvas_tests.rs @@ -1943,9 +1943,12 @@ mod multi_layer_formatting { fn controls(app: PhotocraftApp, properties: bool) -> Harness<'static, PhotocraftApp> { // The lazily populated font popup introduces widgets in successive passes. egui 0.37 - // styles each new widget on its following pass; keep a bounded settle assertion while - // allowing those additional passes (the loaded menu requires six under parallel tests). - let mut h = Harness::builder().with_size(vec2(1100.0, 800.0)).with_max_steps(8).build_ui_state( + // styles each new widget on its following pass (`widget_style` requests a repaint for a + // widget it has not seen yet), so `run` settles only after the last batch of the font + // list has arrived. The loaded menu takes six passes alone, but under a loaded parallel + // run the list arrives in more batches and it took ten. Allow 16 passes; a UI that never + // settles still fails, because `run` errors on any pass past the cap. + let mut h = Harness::builder().with_size(vec2(1100.0, 800.0)).with_max_steps(16).build_ui_state( move |ui, app| { if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { if properties { diff --git a/crates/ui-egui/tests/it/batch_export_options.rs b/crates/ui-egui/tests/it/batch_export_options.rs index 980331a91f..1890f6dd9e 100644 --- a/crates/ui-egui/tests/it/batch_export_options.rs +++ b/crates/ui-egui/tests/it/batch_export_options.rs @@ -290,7 +290,7 @@ fn batch_naming_controls_export_the_requested_names_and_reset_on_cancel() { edit_path(&mut h, "Output", &output); h.get_by_role_and_label(egui::accesskit::Role::TextInput, "File Naming").click(); h.run_steps(2); - replace_text(&mut h, "{serial}_{name}"); + replace_text(&mut h, "{serial}_{name}.{ext}"); h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Starting serial").click(); h.run_steps(2); h.event(egui::Event::Text("7".into())); diff --git a/crates/ui-egui/tests/it/crop_beyond_canvas.rs b/crates/ui-egui/tests/it/crop_beyond_canvas.rs index fd23d7f888..d03ed92d0d 100644 --- a/crates/ui-egui/tests/it/crop_beyond_canvas.rs +++ b/crates/ui-egui/tests/it/crop_beyond_canvas.rs @@ -110,7 +110,8 @@ fn editing_a_crop_frame_shows_the_pixels_past_the_canvas() { let app = h.state_mut(); // 300 × 120: red down the left 60 px and blue down the right 60 px of a new layer, then // cropped to (50, 0) 200 × 100 keeping the pixels: 50 px of red past the left edge, 50 of - // blue past the right, and 20 rows of the white Background below. + // blue past the right, and 20 rows of the white Background below (the crop makes it "Layer 0", + // which keeps them, #10867). app.run("file.new", json!({"width": 300, "height": 120, "background": "white"})).expect("new"); app.run("layer.new.layer", json!({})).expect("layer"); for (x, color) in [(0, "#ff0000"), (240, "#0000ff")] { diff --git a/crates/ui-egui/tests/it/crop_shift_corner.rs b/crates/ui-egui/tests/it/crop_shift_corner.rs new file mode 100644 index 0000000000..f7452b94cf --- /dev/null +++ b/crates/ui-egui/tests/it/crop_shift_corner.rs @@ -0,0 +1,242 @@ +//! Crop tool: ⇧-dragging a frame corner keeps its proportions by following the pointer along the +//! frame's diagonal, as Photoshop does (#10871). +//! +//! Measured in Photoshop: 800 × 500 document, frame = whole canvas, free ratio, Auto Center +//! Preview off, ⇧-drag of the bottom-right corner by (−100, −50) px commits 706 × 441 (the drag +//! projected onto (800, 500) shortens the frame by 11.8 %). PhotoCraft let the larger relative +//! extent win and committed 720 × 450. +//! +//! Driven through `ui.pointer`, as an agent or the canvas sends the drag; no GPU needed. The Auto +//! Center Preview test sends screen points through the view instead, as the mouse does, since the +//! view pans with the frame during that drag. + +use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::{ToolEvent, ViewXform, tool_event}; +use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use photocraft_ui_egui::crop_ui; +use serde_json::{Value, json}; + +/// The measured ⇧ drag. +const DRAG: [f64; 2] = [-100.0, -50.0]; + +#[derive(Default)] +struct Setup { + auto_center: bool, + classic: bool, + /// Options-bar ratio preset, `""` for none. + ratio: &'static str, + snap: bool, +} + +/// An 800 × 500 document at 100 % in a canvas it fits, the Crop tool's frame up. +fn crop_app(s: Setup) -> PhotocraftApp { + let doc = Document::with_background("crop", Size::new(800, 500), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut session = photocraft_engine::Session::new(); + session.add_document(doc, None); + let mut app = PhotocraftApp::new(session, Default::default()); + app.sync_views(); + app.ui.extras.snap = s.snap; + app.ui.extras.guides = true; + app.ui.extras.rulers = false; + app.ui.view.show.smart_guides = false; + app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let v = &mut app.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [400.0, 250.0], false, 0.0); + app.ui.tool_options.crop_shield.auto_center_preview = s.auto_center; + app.ui.tool_options.crop_shield.classic_mode = s.classic; + app.ui.tool_options.crop_ratio = s.ratio.into(); + pointer(&mut app, json!({"tool": "Crop", "events": []})); + crop_ui::ensure_frame(&mut app); + app +} + +fn pointer(app: &mut PhotocraftApp, params: Value) { + let ctx = egui::Context::default(); + let (req, _rx) = ControlRequest::new("ui.pointer", params); + let Outcome::Done(v) = handle(app, &ctx, &req) else { panic!("ui.pointer didn't finish") }; + assert_eq!(v["ok"], true, "{v}"); +} + +/// The frame's (turned) bottom-right corner, where the drags start. +fn corner(app: &PhotocraftApp) -> [f64; 2] { + crop_ui::corners(app.ui.crop_rect.expect("a frame"), crop_ui::angle(app))[2] +} + +/// Commits with ↵ and returns the document size. +fn commit(app: &mut PhotocraftApp) -> (u32, u32) { + photocraft_ui_egui::canvas::commit_crop(app); + let size = app.session.active().expect("a document").doc.size; + (size.width, size.height) +} + +/// Drags the bottom-right corner by document vector `d` in steps through `ui.pointer`, holding +/// `mods`, and commits. +fn drag_corner_and_commit(app: &mut PhotocraftApp, d: [f64; 2], mods: Value) -> (u32, u32) { + drag_corner(app, d, mods); + commit(app) +} + +/// [`drag_corner_and_commit`] without the commit: the frame stays pending. +fn drag_corner(app: &mut PhotocraftApp, d: [f64; 2], mods: Value) { + let [x, y] = corner(app); + let mut events = vec![json!({"kind": "down", "x": x, "y": y})]; + events.extend((1..=4).map(|i| { + let t = f64::from(i) / 4.0; + json!({"kind": "move", "x": x + d[0] * t, "y": y + d[1] * t}) + })); + events.push(json!({"kind": "up", "x": x + d[0], "y": y + d[1]})); + let mut params = mods; + params["events"] = Value::Array(events); + pointer(app, params); +} + +fn assert_near(got: (u32, u32), want: (u32, u32), what: &str) { + let off = |a: u32, b: u32| a.abs_diff(b); + assert!(off(got.0, want.0) <= 1 && off(got.1, want.1) <= 1, "{what}: committed {got:?}, want {want:?} ±1 px"); +} + +#[test] +fn shift_corner_drag_follows_the_frames_diagonal() { + let mut app = crop_app(Setup::default()); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "the default frame is the whole canvas"); + let got = drag_corner_and_commit(&mut app, DRAG, json!({"shift": true})); + assert_near(got, (706, 441), "⇧ drag by (−100, −50)"); +} + +#[test] +fn shift_corner_drag_outward_past_the_canvas_follows_the_diagonal() { + let mut app = crop_app(Setup::default()); + // (100, 50) onto (800, 500) grows the frame by 11.8 %: 894.4 × 559.0, past the canvas. + let got = drag_corner_and_commit(&mut app, [100.0, 50.0], json!({"shift": true})); + assert_near(got, (894, 559), "⇧ drag by (100, 50)"); +} + +/// A turned frame: the diagonal turns with it, so the measured drag taken in the frame's own axes +/// gives the measured size, in Classic Mode (the frame turns) and the default mode (the image +/// turns). Projected onto the upright diagonal instead it gives about 714 × 446. +#[test] +fn shift_corner_drag_on_a_turned_frame_follows_its_diagonal() { + for classic in [true, false] { + let mut app = crop_app(Setup { classic, ..Setup::default() }); + app.ui.crop_angle = 30.0; + photocraft_ui_egui::crop_mode::sync_frame(&mut app); + let d = crop_ui::turn(DRAG, [0.0, 0.0], 30.0); + let got = drag_corner_and_commit(&mut app, d, json!({"shift": true})); + assert_near(got, (706, 441), &format!("⇧ drag on a frame turned 30°, Classic Mode {classic}")); + } +} + +/// Auto Center Preview (the default): the box stays centred on screen and shrinks on both sides +/// as the handle follows the pointer, so each extent changes by twice the projected drag: +/// 800 − 2 × 94.4 = 611.2 and 500 − 2 × 59.0 = 382.0. With the larger relative extent winning it +/// was 640 × 400. +#[test] +fn shift_corner_drag_follows_the_diagonal_with_auto_center_preview() { + let mut app = crop_app(Setup { auto_center: true, ..Setup::default() }); + let shift = egui::Modifiers::SHIFT; + let [x, y] = corner(&app); + let from = ViewXform::active(&app).expect("a view").to_screen(x as f32, y as f32); + let to = from + egui::vec2(DRAG[0] as f32, DRAG[1] as f32); + // Each screen point is read through the view as it is then, as the canvas does every frame. + let at = |app: &PhotocraftApp, p: egui::Pos2| ViewXform::active(app).expect("a view").to_doc(p); + let [x, y] = at(&app, from); + tool_event(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, shift); + for i in 1..=4 { + let [x, y] = at(&app, from + (to - from) * (i as f32 / 4.0)); + tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, shift); + } + let [x, y] = at(&app, to); + tool_event(&mut app, ToolEvent::Up { x, y }, shift); + assert_near(commit(&mut app), (611, 382), "⇧ drag with Auto Center Preview"); +} + +/// A ratio preset keeps today's rule (not measured): the larger relative extent wins. On the +/// 500 × 500 frame 1 : 1 fits, (−100, −50) gives max(400, 450) = 450 square; along the diagonal +/// it would be 425 square. +#[test] +fn shift_corner_drag_with_a_ratio_preset_is_unchanged() { + let mut app = crop_app(Setup { ratio: "1:1", ..Setup::default() }); + assert_eq!(app.ui.crop_rect, Some([150.0, 0.0, 650.0, 500.0]), "the preset fits a 500 × 500 frame"); + assert_near(drag_corner_and_commit(&mut app, DRAG, json!({"shift": true})), (450, 450), "⇧ with 1 : 1"); +} + +#[test] +fn the_same_drag_without_shift_or_with_alt_is_unchanged() { + // No ⇧: the corner follows the pointer freely, 700 × 450 as in Photoshop. + let mut app = crop_app(Setup::default()); + assert_near(drag_corner_and_commit(&mut app, DRAG, json!({})), (700, 450), "no ⇧"); + // ⌥ ⇧ (not measured, today's rule): about the centre, 600 × 400 grows to 8 : 5 as 640 × 400. + let mut app = crop_app(Setup::default()); + assert_near(drag_corner_and_commit(&mut app, DRAG, json!({"shift": true, "alt": true})), (640, 400), "⌥ ⇧"); +} + +/// Snapping reads the handle where the ⇧ drag puts it, on the diagonal (x ≈ 705.6 for the +/// measured drag), not where the larger-extent rule would (x = 720). The tolerance is 8 px at +/// 100 %: a guide 5.6 px away catches the handle, one 8.4 px away doesn't. +#[test] +fn a_shift_corner_drag_snaps_where_its_handle_is() { + for (guide, want) in [(700.0, (700, 438)), (714.0, (706, 441))] { + let mut app = crop_app(Setup { snap: true, ..Setup::default() }); + app.run("view.newGuide", json!({"orientation": "vertical", "position": guide})).expect("guide"); + let got = drag_corner_and_commit(&mut app, DRAG, json!({"shift": true})); + assert_near(got, want, &format!("⇧ drag with a guide at x = {guide}")); + } +} + +/// On a frame turned 30° the corner slides along the turned diagonal, direction (0.89, 1.67) for +/// 8 : 5, so a vertical guide is met by sliding along it, not by moving the handle sideways. +/// +/// - ⇧, free ratio: the measured drag leaves the handle at x ≈ 569.2, 4.2 px from a guide at +/// x = 565. Sliding on to it shortens the frame by 12.7 % to 698.1 × 436.3. Moving the handle +/// sideways and projecting that back onto the diagonal stopped it at x ≈ 568.3, off the guide, +/// at 704 × 440. +/// - An 8 : 5 preset (the larger extent wins, 720 × 450) leaves it at x ≈ 577.1, 4.1 px from a +/// guide at x = 573: 712.5 × 445.3. Sideways, the larger extent grew the frame to 723 × 452, +/// away from the guide. +#[test] +fn a_shift_corner_drag_on_a_turned_frame_snaps_along_its_diagonal() { + for (ratio, guide, want) in [("", 565.0, (698, 436)), ("8:5", 573.0, (712, 445))] { + for classic in [true, false] { + let what = format!("ratio {ratio:?}, guide at x = {guide}, Classic Mode {classic}"); + let mut app = crop_app(Setup { classic, ratio, snap: true, ..Setup::default() }); + app.ui.crop_angle = 30.0; + photocraft_ui_egui::crop_mode::sync_frame(&mut app); + app.run("view.newGuide", json!({"orientation": "vertical", "position": guide})).expect("guide"); + drag_corner(&mut app, crop_ui::turn(DRAG, [0.0, 0.0], 30.0), json!({"shift": true})); + let at = corner(&app); + assert!((at[0] - guide).abs() < 1e-3, "{what}: the handle is at {at:?}, not on the guide"); + let r = app.ui.crop_rect.expect("a frame"); + assert!(((r[2] - r[0]) / (r[3] - r[1]) - 1.6).abs() < 1e-9, "{what}: 8 : 5 kept, {r:?}"); + assert_near(commit(&mut app), want, &what); + } + } +} + +/// Turned 31° the other way, the 8 : 5 frame's bottom-right diagonal runs 1° off horizontal, so it +/// crosses a horizontal guide 3 px from the handle about 170 px further along. That crossing is +/// well outside the 8 px snap tolerance: the guide doesn't catch the handle and the frame follows +/// the drag, as with no guide. Sliding to the crossing grew the frame by about 40 %. +#[test] +fn a_turned_shift_corner_ignores_a_guide_its_diagonal_meets_far_away() { + let drag = crop_ui::turn([-50.0, -25.0], [0.0, 0.0], -31.0); + for classic in [true, false] { + let what = format!("Classic Mode {classic}"); + // Off the document's centre lines, which snap too. + let setup = || { + let mut app = crop_app(Setup { classic, snap: true, ..Setup::default() }); + app.ui.crop_rect = Some([100.0, 60.0, 500.0, 310.0]); + app.ui.crop_angle = -31.0; + photocraft_ui_egui::crop_mode::sync_frame(&mut app); + app + }; + let mut free = setup(); + drag_corner(&mut free, drag, json!({"shift": true})); + let (want, at) = (free.ui.crop_rect.expect("a frame"), corner(&free)); + let mut app = setup(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": at[1] + 3.0})).expect("guide"); + drag_corner(&mut app, drag, json!({"shift": true})); + let r = app.ui.crop_rect.expect("a frame"); + assert!(r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6), "{what}: {r:?}, want {want:?} as with no guide"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 22a28c8477..90db2f9773 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -14,6 +14,7 @@ mod color_managed_canvas; mod crop_beyond_canvas; mod crop_default_mode; mod crop_popup_keys; +mod crop_shift_corner; mod custom_print_paper; mod display_shadows; mod drag_preview_canvas; diff --git a/docs/atn.md b/docs/atn.md index 99f883fc98..eec29cf7e0 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -18,7 +18,7 @@ layers and documents, newer and older events and files, and a measured reference map yet, with everything needed to implement them without the reference application. -## Supported events (144) +## Supported events (168) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -84,6 +84,8 @@ the reference application. | (string ID only) | focusMask | Focus Area | `select.focusArea` | | `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | | (string ID only) | gradientMapEvent | Gradient Map | `image.adjustments.gradientMap` | +| `GrMp` | (code only) | Gradient Map (recorded classic form) | `image.adjustments.gradientMap` | +| `RplC` | replaceColor | Replace Color (Lab sample box) | `image.adjustments.replaceColor` | | `Grow` | grow | Grow | `select.grow` | | `Hd ` | hide | Hide | `layer.hideLayers` | | `HghP` | highPass | High Pass | `filter.other.highPass` | @@ -97,7 +99,7 @@ the reference application. | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | -| `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | +| `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` | | (string ID only) | animationFramesFromLayers | Make Frames from Layers | | | (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | @@ -166,6 +168,83 @@ the reference application. | (string ID only) | vibrance | Vibrance | `image.adjustments.vibrance` | | `Wave` | wave | Wave | `filter.distort.wave` | | `ZgZg` | zigZag | ZigZag | `filter.distort.zigZag` | +| (string ID only) | reverse | Reverse | `layer.arrange.reverse` | +| (string ID only) | linkSelectedLayers | Link Layers (toggles, as measured) | `layer.linkLayers` | +| (string ID only) | unlinkSelectedLayers | Unlink Layers | `layer.linkLayers {"linked":false}` | +| (string ID only) | selectLinkedLayers | Select Linked Layers | `layer.selectLinkedLayers` | +| (string ID only) | disableLayerStyle | Clear Layer Style | `layer.layerStyle.clear` | +| (string ID only) | placedLayerMakeCopy | New Smart Object via Copy | `layer.smartObjects.newSmartObjectViaCopy` | +| (string ID only) | placedLayerConvertToLayers | Convert to Layers | `layer.smartObjects.convertToLayers` | +| (string ID only) | placedLayerConvertToEmbedded | Embed Linked | `layer.smartObjects.convertToEmbedded` | +| (string ID only) | placedLayerUpdateAllModified | Update All Modified Content | `layer.smartObjects.updateAllModifiedContent` | +| (string ID only) | rasterizeAll | Rasterize All Layers | `layer.rasterize.allLayers` | +| (string ID only) | addBlankVideoLayer | New Blank Video Layer | `layer.videoLayers.newBlankVideoLayer` | +| (string ID only) | isolateLayers | Isolate Layers | `select.isolateLayers` | +| (string ID only) | textLoadDefaultTypeStyles | Load Default Type Styles | `type.loadDefaultTypeStyles` | +| (string ID only) | textSaveDefaultTypeStyles | Save Default Type Styles | `type.saveDefaultTypeStyles` | +| (string ID only) | recordMeasurements | Record Measurements | `image.analysis.recordMeasurements` | +| (string ID only) | CropPhotosAuto0001 | Crop and Straighten Photos | `file.automate.cropAndStraightenPhotos` | +| (string ID only) | placedLayerExportContents | Export Contents | `layer.smartObjects.exportContents` | +| (string ID only) | placedLayerConvertToLinked | Convert to Linked | `layer.smartObjects.convertToLinked` | +| (string ID only) | placedLayerRelinkToFile | Relink to File | `layer.smartObjects.relinkToFile` | +| (string ID only) | placedLayerReplaceContents | Replace Contents | `layer.smartObjects.replaceContents` | +| (string ID only) | shapeBlur | Shape Blur (radius-only form) | `filter.blur.shapeBlur` | +| (string ID only) | artboardFromLayerGroupEvent | Artboard from Group | `layer.new.artboardFromGroup` | +| (string ID only) | editArtboardEvent | Artboard background color (flags 8/1) | `layer.artboard.set` | + +### Newer event mapping boundaries + +The 23 requested forms in the two **Measured** tables under +[Newer events](#newer-events-string-id-only) are mapped. Artboard from Group +retains the current-layer target, preset tag, fractional RGB, background type +and integer `changeBackground`, including absence. A flag of 1 or absence applies +the background; other integers give white. An omitted type defaults to white. +Recorded guide IDs are preserved on save and ignored on playback, as measured +in [the guide/background reference below](#newer-events-string-id-only). +Existing document guides remain unchanged. The preset does not resize the group. + +The additional `editArtboardEvent` maps background-only changes with `changeSizes` +8 and `changeBackground` 1. Its rectangle and preset remain recording metadata: +they do not move or resize the target artboard. Colors remain in the saved step +even for white, black or transparent backgrounds. Other flags, extra settings, +unknown targets and forms that cannot reconstruct exactly remain whole kept +steps. Native geometry edits without a matching wire form refuse export. + +The sixteen target-only events accept an empty descriptor or the current-layer +`null` reference (`Lyr `/`Ordn.Trgt`). Save retains which form was imported. +Unknown targets or extra keys stay complete kept steps. Unrepresented native +options are refused on export. Playback uses the native command's preconditions +and current state. Isolate Layers toggles the panel view; type defaults remain +session settings; Record Measurements uses the current measurement configuration. + +Link Layers plays as the toggle the reference application performs (played twice on the same +layers it links, then unlinks them; see the measurements below). Unlink is explicit and +idempotent: it keeps an already unlinked selection unlinked and clears singleton link groups. +Undo restores an edit. Explicit native layer lists cannot be exported as these targeted events. + +The four Smart Object file events use Open/Place's `Pth `/`txtu` path encoding. +Export Contents uses `null` for its path; Convert to Linked uses the current-layer +`null` reference and `Usng` for its path; Relink and Replace use `null` for the path. +A positive integer `LyrI` is a recording-session hint, never a PhotoCraft layer +identity, and is omitted on save under the normalization rule below. Undecodable +paths or extra keys keep the entire step. File access happens at playback; a +missing or unusable file reports the native command's error. + +Convert Type to Shape retains Make's `contentLayer` class reference and `Usng` +target `textLayer` reference; it does not export a separate `convertToShape` +event. Other Make mappings, including Solid Color and Pattern fills, are unchanged. +Shape Blur maps only the measured radius form (`Rds ` pixels, 5–1000; omitted +radius uses the native factory value 10). Later dialog forms carrying +`customShape` or `keyOriginType` remain kept. Native explicit shape choices cannot +be saved as radius-only descriptors; no custom asset lookup or new kernel is added. + +Crop and Straighten Photos executes the native scan operation once, creating one +document per photo found inside the selection and leaving the source unchanged. +It requires a selected, visible layer with no pixel or full lock, including +inherited group state, and remains unavailable for Indexed Color documents. +The separate `CropPhotos0001` helper remains a complete kept Photoshop step. +Playback stops at that enabled helper before subsequent duplicate/crop steps can +run. This covers the parent event, not complete recordings with generated helpers. ### Settings retained by mapped events @@ -235,9 +314,84 @@ supported-event count and the measured unmapped reference. |---|---| | animationFramesFromLayers | Make Frames from Layers | +### Supported direct adjustment settings + +#### Gradient Map (direct) + +- Event: `GrMp` (no string ID) +- PhotoCraft command: `image.adjustments.gradientMap` +- Supported: classic (`Gcls`, also when omitted), 2..64 ordered custom RGB stops at midpoint 50, smoothness 4096, and the two opaque endpoint transparency stops. Fractional RGB components retain their precision and the recorded name and event survive save. +- Other interpolation methods, midpoints, transparency, unsorted stops and unsupported descriptor forms remain kept steps. This does not change the separate legacy `GdMp` event mapping. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | gradientsInterpolationMethod | enumerated | | gradientInterpolationMethodType.'Gcls' classic | | +| `Grad` | gradient | object | | obj 'Grdn' gradientClassEvent | | + +Nested values as recorded (defaults): +``` + 'Nm ' name : STRINGTYPE = "Vorder- zu Hintergrundfarbe" + 'GrdF' gradientForm : ENUMERATEDTYPE = 'GrdF' gradientForm.'CstS' customStops + 'Intr' interfaceIconFrameDimmed : DOUBLETYPE = 4096 + 'Clrs' colors : LISTTYPE = list[2] + [0] OBJECTTYPE = obj 'Clrt' colorStop + 'Clr ' color : OBJECTTYPE = obj 'RGBC' RGBColor + 'Rd ' red : DOUBLETYPE = 0 + 'Grn ' green : DOUBLETYPE = 0 + 'Bl ' blue : DOUBLETYPE = 0 + 'Type' type : ENUMERATEDTYPE = 'Clry' colorStopType.'UsrS' userStop + 'Lctn' location : INTEGERTYPE = 0 + 'Mdpn' midpoint : INTEGERTYPE = 50 + [1] OBJECTTYPE = obj 'Clrt' colorStop + 'Clr ' color : OBJECTTYPE = obj 'RGBC' RGBColor + 'Rd ' red : DOUBLETYPE = 255 + 'Grn ' green : DOUBLETYPE = 255 + 'Bl ' blue : DOUBLETYPE = 255 + 'Type' type : ENUMERATEDTYPE = 'Clry' colorStopType.'UsrS' userStop + 'Lctn' location : INTEGERTYPE = 4096 + 'Mdpn' midpoint : INTEGERTYPE = 50 + 'Trns' transparency : LISTTYPE = list[2] + [0] OBJECTTYPE = obj 'TrnS' transferSpec + 'Opct' opacity : UNITDOUBLE = 100 '#Prc' percentUnit + 'Lctn' location : INTEGERTYPE = 0 + 'Mdpn' midpoint : INTEGERTYPE = 50 + [1] OBJECTTYPE = obj 'TrnS' transferSpec + 'Opct' opacity : UNITDOUBLE = 100 '#Prc' percentUnit + 'Lctn' location : INTEGERTYPE = 4096 + 'Mdpn' midpoint : INTEGERTYPE = 50 +``` + +#### Replace Color + +- Event: `RplC` / `replaceColor` +- PhotoCraft command: `image.adjustments.replaceColor` +- Supported: the explicit D50 Lab minimum/maximum box and `colorModel` 0, with the four integer settings clamped to the ranges below. Matching uses original pixel channels through the document profile. Unsupported color models, malformed or missing samples and unknown keys remain kept steps. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `Fzns` | fuzziness | integer | | 40 | 0 .. 200 (out-of-range values are clamped) | +| `Mnm ` | minimum | object | | obj 'LbCl' labColor | | +| `Mxm ` | maximum | object | | obj 'LbCl' labColor | | +| `H ` | hue | integer | | 30 | -180 .. 180 (out-of-range values are clamped) | +| `Strt` | saturation | integer | | 0 | -100 .. 100 (out-of-range values are clamped) | +| `Lght` | lightness | integer | | 0 | -100 .. 100 (out-of-range values are clamped) | +| `` | colorModel | integer | | 0 | | + +Nested values as recorded (example values): +``` + 'Lmnc' luminance : DOUBLETYPE = 40 + 'A ' a : DOUBLETYPE = 30 + 'B ' b : DOUBLETYPE = 40 + 'Lmnc' luminance : DOUBLETYPE = 40 + 'A ' a : DOUBLETYPE = 30 + 'B ' b : DOUBLETYPE = 40 +``` + + ## Known gaps in mapped events -A mapped event can still lose a setting. Every mapped event was checked on 2026-11-01: the 5 296 steps of +A mapped event can still lose a setting. The original 117-event mapping was checked on 2026-11-01: the 5 296 steps of the `.atn` corpus (sets the reference application recorded) and factory descriptors measured in the reference application went through `actions.load` and `actions.save`, and each saved descriptor was compared with the original. Then every step PhotoCraft saves for the cases in @@ -323,13 +477,16 @@ name form today; the index, ID and ordinal forms load as kept steps. ## Newer events (string ID only) -The events the reference application records for the menu items PhotoCraft has that are neither mapped -nor in the reference below: 63 candidates from PhotoCraft's menu catalogue, each run in the reference +The original survey covered menu events that were neither mapped nor in the reference below: +63 candidates from PhotoCraft's menu catalogue, each run in the reference application without a dialog on a document prepared for it (layers, a selection, a smart object, a type layer, a layer style or a group) on 2026-11-01. 27 more menu items record no event of their own (puppet warp and warp splits, combine shapes, scripts such as Load Files into Stack and Statistics, artboard and video export, missing-font commands): they cannot be recorded or play as a script step. +The two Measured tables remain the recording reference. The mappings and remaining +limits added since that survey are listed under [Newer event mapping boundaries](#newer-event-mapping-boundaries). + **Measured: no settings beyond the target.** A step with these events carries at most `null` (the target layer reference, `Ordn.Trgt`); mapping them needs only the event name. @@ -433,6 +590,11 @@ commands record). with more than one layer is refused. - **Load Actions** with a set whose name is already in the panel adds a second set of that name; nothing is replaced. +- PhotoCraft preserves fractional recorded RGB values exactly. Native actions with partial settings export an empty preset and guide list when absent. + An absent native color exports as white: it matches the native custom-color fallback and is visually ignored for white, black and transparent backgrounds. + A custom background without a color uses the White variant (type 1); an explicit custom white color keeps type 4. + This is canonical export metadata, not a claim about the reference application's remembered color. Imported steps retain their recorded color instead. + ## Measured through dialogs and recordings Measured on 2026-11-01 in two ways: by running the event with its dialog and pressing OK (the reference @@ -561,6 +723,20 @@ Measured with a folder `in` holding `a.png`, `b.png`, `notes.txt` and `sub/a.png during batch processing. Continue with the next file or stop?" (Continue / Stop). Stop ends the batch at once; the file being processed is not saved (12 of 60 written in the test). There is no progress bar: the documents open and close in the window as they are processed. + +Batch cancellation conformance (Photoshop 2026 observation above; PhotoCraft's producer is +`file_cmds::batch_run::process_batch_outputs`, and its desktop consumer is `jobs_ui::tick`): + +| Reference step | Acceptance gate | Status | +|---|---|---| +| Plan folder order, names and serials once | Continue retains the original plan and next serial | Implemented and tested; Esc between inputs does not skip the unopened input | +| Show the opened input while its action runs | A read-only document preview appears in the canvas and closes with the input | Implemented and visually checked; deliberate deviation: worker snapshots are not live tabs or Layers panel targets, so they cannot be edited or saved by mistake | +| Interrupt the current command on Esc | The current input reaches a checkpoint before any output write | Implemented and tested before save; once the crash-safe atomic write begins, Esc is refused because publication cannot be retracted | +| Ask Continue or Stop | Continue skips the interrupted input; Stop returns prior outputs and skips the rest | Implemented and tested in engine and UI | +| Keep headless cancellation direct | `jobs.cancel` returns partial results without an interactive question | Implemented and tested, including a worker waiting for a desktop decision | +| No progress bar | The desktop Batch preview is the visible feedback | Implemented, UI tested and visually checked | +| Preserve error policy and log | Continue retains earlier errors and writes one final UTF-16 LE log | Implemented and tested with errors before and after Esc | + - **Return value:** when any file failed, the Batch step itself reports "cancelled" (the dialog returns no descriptor); a script run with Stop For Errors reports success even though it stopped. @@ -669,6 +845,7 @@ and string ID, the PhotoCraft command it maps to (or should map to), and every k order it is recorded, with type, unit, factory default and allowed values. The Supported events table says which of them PhotoCraft maps today; for the others an entry has everything needed to implement them. When an event is implemented, add it to that table in the same change. +Current support and limits of the newer events are described under [Newer event mapping boundaries](#newer-event-mapping-boundaries). ### How these values were measured @@ -1507,7 +1684,7 @@ No parameters: the step carries an empty descriptor. ### Group E: Video, Other and matting filters and the direct Gradient Map / Replace Color adjustments -All commands exist in PhotoCraft; only the `.atn` mapping is missing. Gradient Map and Replace Color here are the direct adjustments (Image > Adjustments), not adjustment layers (those are already mapped). +All commands exist in PhotoCraft. The two direct adjustments, classic Gradient Map and Lab-range Replace Color, are supported above; the remaining events below still need their `.atn` mappings. These direct adjustments are separate from adjustment layers. #### De-Interlace @@ -1550,50 +1727,6 @@ No parameters: the step carries an empty descriptor. No parameters: the step carries an empty descriptor. -#### Gradient Map (direct) - -- Event: `GrMp` (no string ID) -- PhotoCraft command: `image.adjustments.gradientMap` -- The `gradient` object is the same gradient form the adjustment layer already maps; reuse that code. - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `` | gradientsInterpolationMethod | enumerated | | gradientInterpolationMethodType.'Gcls' classic | | -| `Grad` | gradient | object | | obj 'Grdn' gradientClassEvent | | - -Nested values as recorded (defaults): -``` - 'Nm ' name : STRINGTYPE = "Vorder- zu Hintergrundfarbe" - 'GrdF' gradientForm : ENUMERATEDTYPE = 'GrdF' gradientForm.'CstS' customStops - 'Intr' interfaceIconFrameDimmed : DOUBLETYPE = 4096 - 'Clrs' colors : LISTTYPE = list[2] - [0] OBJECTTYPE = obj 'Clrt' colorStop - 'Clr ' color : OBJECTTYPE = obj 'RGBC' RGBColor - 'Rd ' red : DOUBLETYPE = 0 - 'Grn ' green : DOUBLETYPE = 0 - 'Bl ' blue : DOUBLETYPE = 0 - 'Type' type : ENUMERATEDTYPE = 'Clry' colorStopType.'UsrS' userStop - 'Lctn' location : INTEGERTYPE = 0 - 'Mdpn' midpoint : INTEGERTYPE = 50 - [1] OBJECTTYPE = obj 'Clrt' colorStop - 'Clr ' color : OBJECTTYPE = obj 'RGBC' RGBColor - 'Rd ' red : DOUBLETYPE = 255 - 'Grn ' green : DOUBLETYPE = 255 - 'Bl ' blue : DOUBLETYPE = 255 - 'Type' type : ENUMERATEDTYPE = 'Clry' colorStopType.'UsrS' userStop - 'Lctn' location : INTEGERTYPE = 4096 - 'Mdpn' midpoint : INTEGERTYPE = 50 - 'Trns' transparency : LISTTYPE = list[2] - [0] OBJECTTYPE = obj 'TrnS' transferSpec - 'Opct' opacity : UNITDOUBLE = 100 '#Prc' percentUnit - 'Lctn' location : INTEGERTYPE = 0 - 'Mdpn' midpoint : INTEGERTYPE = 50 - [1] OBJECTTYPE = obj 'TrnS' transferSpec - 'Opct' opacity : UNITDOUBLE = 100 '#Prc' percentUnit - 'Lctn' location : INTEGERTYPE = 4096 - 'Mdpn' midpoint : INTEGERTYPE = 50 -``` - #### Average - Event: `Avrg` (no string ID) @@ -1614,32 +1747,6 @@ No parameters: the step carries an empty descriptor. | `Ofst` | offset | integer | | 0 | -9999 .. 9999 (measured in the dialog); applied also when played without the dialog (offset 50 adds 50) | | `Mtrx` | matrix | list | | none: a step always carries all 25 cells | exactly 25 integers (5 x 5, row by row), each -999 .. 999; a 3 x 3 list makes the application fail | -#### Replace Color - -- Event: `RplC` / `replaceColor` -- PhotoCraft command: `image.adjustments.replaceColor` -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `Fzns` | fuzziness | integer | | 40 | 0 .. 200 (out-of-range values are clamped) | -| `Mnm ` | minimum | object | | obj 'LbCl' labColor | | -| `Mxm ` | maximum | object | | obj 'LbCl' labColor | | -| `H ` | hue | integer | | 30 | -180 .. 180 (out-of-range values are clamped) | -| `Strt` | saturation | integer | | 0 | -100 .. 100 (out-of-range values are clamped) | -| `Lght` | lightness | integer | | 0 | -100 .. 100 (out-of-range values are clamped) | -| `` | colorModel | integer | | 0 | | - -Nested values as recorded (example values): -``` - 'Lmnc' luminance : DOUBLETYPE = 40 - 'A ' a : DOUBLETYPE = 30 - 'B ' b : DOUBLETYPE = 40 - 'Lmnc' luminance : DOUBLETYPE = 40 - 'A ' a : DOUBLETYPE = 30 - 'B ' b : DOUBLETYPE = 40 -``` - ### Group F: Selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older merge/mask events Steps recorded in an ordinary editing session. The values below were measured in a document set up for each step (a selection, the clipboard, linked or several selected layers). Align, Copy/Paste Layer Style and Rename are already mapped under their current events and are not repeated here. diff --git a/docs/batch-naming.md b/docs/batch-naming.md index a922803f81..02e4d10f04 100644 --- a/docs/batch-naming.md +++ b/docs/batch-naming.md @@ -1,28 +1,46 @@ # Batch output naming -Select a recorded action, then open **File > Automate > Batch**. Set the input and output -folders. **File Naming** accepts a filename stem template; the selected export format supplies -the extension. Leave it blank to keep the existing source filenames and collision reporting. +Select a recorded action, then open **File > Automate > Batch**. Source can be a folder (with +**Include All Subfolders**) or the open documents. **File Naming** accepts a template. Leave it +blank to keep source stems with the selected output extension. An explicit template spells the +whole filename; without `{ext}`, no extension is added. | Token | Value | | --- | --- | -| `{name}` | Input filename without its last extension | -| `{serial}` | Input position plus **Starting serial** (default 1), padded to at least three digits | +| `{name}`, `{NAME}`, `{Name}` | Input filename without its last extension, unchanged, uppercase, or initial capital | +| `{serial}`, `{serial:1}` … `{serial:4}` | Input position plus **Starting serial** (default 1), padded to at least 3 or the specified number of digits | +| `{letter}`, `{LETTER}` | Serial letters: a … z, aa …; uppercase with `{LETTER}` | | `{date}` | UTC date at the start of the batch, in `YYYY-MM-DD` form | +| `{date:mmddyy}`, `{date:mmdd}`, `{date:yyyymmdd}`, `{date:yymmdd}`, `{date:yyddmm}`, `{date:ddmmyy}` | Other UTC date arrangements | +| `{ext}`, `{EXT}` | Output extension, lowercase or uppercase; a dot is added when this is the final component, unless the template already supplied one | -For example, `{name}_{serial}` with starting serial 7 writes `photo_007.png` and -`photo_008.png` for two PNG inputs named `photo.png`. A serial after 999 becomes 1000; +For example, `{name}_{serial}` with starting serial 7 writes `photo_007` and +`photo_008` for two PNG inputs named `photo.png`; `{name}_{serial}{ext}` writes +`photo_007.png` and `photo_008.png`. The file bytes still use the selected output format. +A serial after 999 becomes 1000; it does not wrap. Folder inputs use the existing filename order, and an explicit input array keeps its order. Failed inputs consume their position, so later filenames do not shift. -Braces in an input filename are literal text, not additional tokens. +Braces in an input filename are literal text, not additional tokens. Batch writes subfolder +inputs flat into the output folder in input order. A later same-name result replaces an earlier +one, as measured in Photoshop 2026. **Stop On Error** leaves later inputs unprocessed; +**Error Log** writes a UTF-16 LE file with a BOM and CRLF line endings even when no file fails. +It starts with `Batch Start`, records a `File` / `Error` pair per failure, and ends with +`Batch End`. The wording is English because PhotoCraft does not have Photoshop's numeric error +codes or German message catalog. Folder batches report progress per file and +can be cancelled after the current file. A cancelled batch returns `cancelled: true`, its +completed `files`, and `skipped` inputs. Background job status is `cancelled` and retains +the partial result; a waiting control request replies with `ok: false`, `error: "cancelled"`, +and that result. If writing the error log fails, the batch still +returns its per-file results with `errorLogError` instead of `errorLog`. Unknown tokens, unmatched braces, invalid parameter types, path separators, reserved filename characters, excessive filename length (255 UTF-8 bytes), and serial overflow are rejected before export writes begin. Starting serial must be an integer from 1 to 4,294,967,295. -A custom template that resolves to an existing input file is also rejected; choose a separate -destination or another template. Ordinary per-file read, action and save failures still appear -in the batch's `errors` result. When two results share a name, including a case-only difference, -the first successful result is kept and the later collision is reported. +A custom template that resolves to an existing input file is also rejected before processing; +the same guard checks default flat output names. This prevents an early result from destroying +a later unread input. Ordinary per-file read, +action and save failures still appear in the batch's `errors` result. Image Processor and lens +correction continue to report output-name collisions. The same options are available on `file.automate.batch` through the engine command interface: @@ -37,5 +55,18 @@ The same options are available on `file.automate.batch` through the engine comma } ``` -The CLI can invoke this command with `run --cmd file.automate.batch --params ...`. -This option belongs to Batch; Image Processor and droplet options retain their existing behavior. +The CLI has matching `batch --recursive`, `--naming`, `--start-serial`, `--stop-on-error`, and +`--error-log` flags. Droplets use Batch's flat output and naming behavior. + +## Reference behavior and conformance + +The [Photoshop 2026 Batch measurements](https://github.com/davidmann4/photocraft/blob/docs/atn-event-reference/docs/atn.md#batch-behaviour) +record flat subfolder output, last-result-wins collisions, and extension components that add a +dot only at the end. `batch_include_all_subfolders`, +`batch_naming_later_result_replaces_the_earlier_one`, and +`extension_component_only_adds_a_dot_at_the_end` check those cases. PhotoCraft deliberately +rejects a plan that would replace another input file before it is read, to prevent data loss. +The desktop Batch follows the measured flow: it shows no progress in the status bar, previews each +input read-only on the canvas, and Esc asks whether to continue or stop. Headless control clients +still read the job's progress and stop it with `jobs.cancel`. The inputs are not live tabs with +Layers panel content, so AUTO-219-5 stays partial. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9227b37f44..b569e68189 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root -- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing. +- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?, color?, smart?}`, any subset. `by` is `kind`, `name`, `mode`, `color`, `smartObject` or `artboard` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`); `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Mode and Color always filter, by Normal and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Fields left out keep their value; a bad one is an error and changes nothing. - `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error. @@ -152,7 +152,7 @@ store expose and persist the same setting; scripts keep using canonical command | `actions.list` | `{}` → `{actions:[{name, steps}], recording}` (the name being recorded, or null) | | `actions.get` | `{"action": name or index}` → `{name, steps:[[id, params], …], disabled:[step, …]}`. `steps` is the shape `file.automate.batch` and droplets take; `disabled` lists the 0-based steps playback skips | | `actions.record` | `{"name":"Red"}` starts a new action (default name `Action N`). `{"action": name or index}` appends to one that exists | -| `actions.stop` | `{}` → `{action, steps}`. Copies replayable journal entries since `actions.record` (queries and action-editing commands omitted) | +| `actions.stop` | `{}` → `{action, steps}`. Copies replayable journal entries since `actions.record` (queries and action-editing commands omitted). For portable playback, an active `layer` id is dropped only when it is the sole selected layer and does not supply source content (`layer.new.frame`, `select.loadSelection`, `type.createSelection`, `edit.checkSpelling`; a Check Spelling step keeps only `allLayers`, as the reference application's step does). Otherwise, including when several layers are selected, it is recorded by its unique name; `layer.select` records `{"name":"Layer","occurrence":0}`. Type tool keystrokes on a new layer fold into its `type.create` step; edits on an existing text layer retain separate replacements so styled text between edited spans survives replay. For commands other than `layer.select`, ambiguous layer names keep the id and require a unique name for portable playback | | `actions.play` | `{"action": name or index, "from": step?}`. `from` and `failed.step` are 0-based. Returns `{action, ran, failed?:{step, id, error}}` and still returns ok when a step fails, so a partial run is reported. Leaves one history step per step that ran. Supports nested action calls up to 16 levels, rejecting cycles. While recording, playing another action appends one named `actions.play` step instead of its expanded commands. On an untrusted session each step is authorized the same way as a top-level command | | `actions.move` | `{"action": name or index, "step": index?, "to": index}` moves a step within its action, or moves the whole action when `step` is omitted. `to` is the final zero-based index. Pending steps can be reordered while recording; invalid moves leave the list unchanged. | | `actions.delete` | `{"action": name or index, "step": index?}`. With a zero-based `step`: `{action, deletedStep, steps}`, also allowed while recording. Without `step`: `{deleted}`, refused while recording | @@ -250,7 +250,7 @@ The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). ### Background jobs (#210) -Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. +Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged; a folder Batch can already have written earlier output files. A cancelled Batch has `state: "cancelled"` with its partial `result` in `jobs.list`; a waiting control request receives `{"ok":false,"error":"cancelled","result":…}`, and an MCP call through the app bridge gets the error `cancelled; partial result: {…}`. A Batch already asked to stop, or past its last input, is listed with `reason: "finishing"`. A save that has started replacing its file can no longer be cancelled: `jobs.cancel` leaves it running and lists it in `notCancelled: [{job, reason: "committing"}]` (next to `cancelled`, the number of jobs it did cancel); the save then ends as done or failed like any other. Closing its document doesn't stop it either. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline. ## Preferences @@ -440,7 +440,7 @@ stdio, and a value that is not a port is a usage error naming the variable. Impl | Method | Params | |---|---| | `engine.execute` | `{command, params?, wait?}`: any engine command (`wait: false` starts a long one as a background job: `{job, pending}`) | -| `jobs.list` / `jobs.cancel` | `{}` / `{job?}`: background jobs; cancel one or all. Every request (and MCP tool call) first applies the jobs that finished, so `doc.save`, `doc.inspect`, `doc.render` and `session.list` include a finished job's result without polling `jobs.list` first | +| `jobs.list` / `jobs.cancel` | `{}` / `{job?}`: background jobs; cancel one or all (`{cancelled: n}`, plus `notCancelled: [{job, reason}]` for jobs left running, e.g. a save already writing its file). Every request (and MCP tool call) first applies the jobs that finished, so `doc.save`, `doc.inspect`, `doc.render` and `session.list` include a finished job's result without polling `jobs.list` first | | `engine.commands` | `{filter?}`: registry with params docs and enablement (`enabled`, `disabledReason`, `needsDocument`); `result` is a bare array of command objects ([Command list replies](#command-list-replies)) | | `session.list` | open documents and the active index | | `doc.open` / `doc.new` | `{path, approveHighMemory? (false)}` / `file.new` params (`approveHighMemory` is the same one-shot PSD/PSB consent as the `file.openAs` command; see [development.md](development.md) for the structured refusal fields) | diff --git a/docs/development.md b/docs/development.md index 25578943fc..121c781bb9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -638,6 +638,22 @@ PhotoCraft equivalent. Events outside the table load as `actions.photoshopStep` steps that keep the descriptor verbatim (so Save Actions writes them back) and fail at playback with the step's Photoshop name. +Recorded direct Gradient Map (`GrMp`) and Replace Color (`RplC`) forms are described in +[the ATN reference](atn.md#supported-direct-adjustment-settings), based on davidmann4's +measurements in #10603. Gradient Map keeps its event, fractional RGB stops and recorded name; +unsupported interpolation, midpoint and transparency settings remain preserved steps. +Replace Color accepts `labMinimum` / `labMaximum` (three D50 Lab numbers each), mutually +exclusive with RGB samples and localized settings. The new option matches native surface samples through +the document profile, retains alpha and untouched native pixels, and uses one undoable edit. +A zero tone shift validates its inputs without changing pixels. The existing RGB option keeps +its previous behavior. Explicit Lab bounds require a composite pixel-layer target. + +Artboard from Group action settings follow [the measured guide/background behavior](atn.md#newer-events-string-id-only): +guide IDs are ignored session metadata, 1 or absent `changeBackground` applies the +background, and other integer flags give white. The group's bounds are retained. +Background-only `editArtboardEvent` uses the existing Artboard edit command; its +recorded rectangle/preset are retained for saving, without resizing the target. + `crates/psd/tests/it/atn_contract.rs` checks the parser's public API against small, independently encoded version-16 fixtures (the version-12 ones are read by `atn_v12::`) in `crates/psd/tests/fixtures/atn/` (provenance and checksums in its README): `cargo test -p photocraft-psd --test it atn_contract::`. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 602597f6e4..48b1929872 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -133,7 +133,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` reads version 12 and 16 sets and writes version 16 (exact round trip for version 16; version 12 legacy bare identifiers, reference classes and object-array values keep their meaning; trailing bytes remain a warning, #10699). `engine/src/cmd/atn_cmds/` maps the reference application's events to commands (144 supported event IDs, the table in [atn.md](atn.md); one recognized-but-disabled event), including Apply Layer Comp by name (#7101), recorded layer indexes/ordinals and indexed moves, opening-order document indexes and comp index/ID/ordinal references (#10701); other steps kept verbatim. Save As maps to `file.saveAs`/`file.saveACopy` for a file destination (#7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Version 7 and MacBinary wrappers remain unsupported; version 12 coverage uses synthetic fixtures. | +| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` reads version 12 and 16 sets and writes version 16 (exact round trip for version 16; version 12 legacy bare identifiers, reference classes and object-array values keep their meaning; trailing bytes remain a warning, #10699). `engine/src/cmd/atn_cmds/` maps the reference application's events to commands (146 supported event IDs, the table in [atn.md](atn.md); one recognized-but-disabled event), including Apply Layer Comp by name (#7101), recorded layer indexes/ordinals and indexed moves, opening-order document indexes and comp index/ID/ordinal references (#10701), recorded classic `GrMp` with fractional RGB stops and `RplC` with explicit Lab sample bounds (#10607); other steps kept verbatim. Save As maps to `file.saveAs`/`file.saveACopy` for a file destination (#7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Version 7 and MacBinary wrappers remain unsupported; version 12 coverage uses synthetic fixtures. | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -151,6 +151,8 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | `.atn`: 23 newer measured forms mapped, including explicit Link/Unlink, Smart Object file operations, Make Type to Shape, radius-only Shape Blur, Artboard from Group and the Crop Photos parent; supported in batch 13; Link Layers keeps the measured toggle. Artboard forms with unmeasured settings and generated Crop helper steps remain kept. | +| 2026-11-01 | minor | Recorded direct Gradient Map and Lab-range Replace Color load, play and retain their measured settings on save; unsupported forms remain preserved (part of #10607). | | 2026-11-02 | minor | TIFF Options on Save As and Save a Copy (#8827): Image Compression (None, LZW, ZIP), Save Image Pyramid (half-size levels marked reduced-resolution, down to 64 px, alpha-weighted; +45 ms on a 30 MP RGB/8 save), Save Transparency (on: transparency kept ahead of alpha and spot channels, `MTrn` in a layered file; off: matted on white) and Discard Layers and Save a Copy. Scripts, `doc.save`, MCP `save` and the CLI (`--tiff-transparency`, `--tiff-pyramid`) take the same answers. JPEG compression, Pixel Order and Byte Order not offered | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 120 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | diff --git a/docs/gaps.md b/docs/gaps.md index 0b7ee59a00..207fbf2888 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: four Render ATN events mapped, 120 supported/97 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (G11: twelve Stylize/Pixelate ATN events mapped, 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -99,8 +98,10 @@ what an agent can't finish alone. and saves back into the file's `Txt2` since 2026-10-21, #7962), the visual RTL caret and PSD storage of the paragraph direction (TYPE-218-2; the paragraph direction buttons landed 2026-10-22), complex-script fallback fonts and golden tests - (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus - (TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), text free transform + (TYPE-218-3), OpenType Stylistic Sets submenu and greying out features the font lacks + (TYPE-218-5; Character panel buttons 2026-10-11, #3752; Numerals submenu 2026-11-03 — + `.pcraft` keeps all figure tags; PSD EngineData only `OldStyle`/`onum` until a Photoshop-authored + oracle lands), text free transform (#2630), area type in PSD (TYPE-218-10). Done since the re-measure: manual and optical kerning with PSD round trip (TYPE-218-6, #206), variable-font axes (TYPE-218-4, #2985), Warp on live type without a rasterize prompt, as in Photoshop (TYPE-218-9, #3004), and the live Warp Text preview that opens @@ -112,6 +113,13 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting +- **Partial 2026-11-02 (#4579):** Batch accepts recursive folders with flat output and + last-result-wins names, as measured in Photoshop 2026. It also accepts live open documents, + extended naming components, stop-on-error, UTF-16 LE logs, and job cancellation. The CLI + exposes the folder options. The desktop now asks Continue/Stop after Esc, preserves the + original output plan and cumulative error log, and shows each folder input as a read-only + canvas preview without a progress bar. AUTO-219-5 remains partial because worker inputs are + not live tabs with Layers panel content. No readiness percentage or estimate changed. - **Done 2026-10-31 (#10604):** all four Render ATN events (Group B) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 120 supported and 97 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. - **2026-10-31 evidence (#10608):** Group F selection, paste, Fade, Link, Distribute, pattern and older mask/merge events load, play and save through existing commands. Unsupported settings remain kept; AUTO-219-2 remains partial. - **Done 2026-10-31 (#10606):** all twelve Stylize/Pixelate ATN events (Group D) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 128 supported and 89 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. @@ -124,10 +132,14 @@ what an agent can't finish alone. in the document being played; missing comps stop playback. Other reference forms stay kept Photoshop steps and are reported as unsupported. AUTO-219-2 remains partial. - **2026-10-13 evidence:** Batch supports [output naming templates](batch-naming.md) using - `{name}`, `{serial}` and a captured UTC `{date}`, with a starting serial number. Colliding - names remain errors; custom output names cannot replace inputs. AUTO-219-5 stays partial. -- **Missing:** document-independent action recording (AUTO-219-1), Batch recursion, open-document inputs, progress/cancel and an error-log file - (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the + `{name}`, `{serial}` and a captured UTC `{date}`, with a starting serial number. Custom output + names cannot replace inputs before they are read. AUTO-219-5 stays partial. +- **Partial 2026-11-02 (#4514):** Action recording drops an implicit active-layer id, names a + unique non-active layer, folds Type keystrokes into whole-text steps and Move-tool nudge runs + into one relative move, records keyboard document switches as opening-order offsets, and + places recorded point text by canvas percentage. Duplicate layer names remain ambiguous and + keep the id; paragraph text boxes still use absolute coordinates. AUTO-219-1 remains partial. +- **Missing:** ExtendScript/UXP compatibility. `.atn` steps outside the mapped events (paths, scripts, Generative Fill, Filter Gallery effects other than Spatter, Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps that fail at playback by name. @@ -428,6 +440,11 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | G11: Batch Esc Continue/Stop, one plan and error log across interruptions, and read-only input previews without a progress bar (#4579); live tab parity remains open, AUTO-219-5 partial; readiness and estimate unchanged | +| 2026-11-02 | minor | Action recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and places point text by canvas percentage; paragraph boxes remain absolute (AUTO-219-1, #4514); parity percentages unchanged | +| 2026-11-01 | minor | Action recording stores portable layer targets and whole-text Type steps where unambiguous (AUTO-219-1, #4514); duplicate names remain ambiguous; parity percentages unchanged | +| 2026-11-02 | minor | G11: measured Batch output is flat and later results replace earlier ones; naming spells the whole filename; UTF-16 LE logs; current progress and cancellation UI tracked as remaining parity work (#4579); AUTO-219-5 partial; readiness and estimate unchanged | +| 2026-11-01 | minor | G11: Batch recursion, open-document source, extended naming, stop/error log and folder-job progress/cancel (#4579); AUTO-219-5 done; readiness and estimate unchanged | | 2026-10-31 | minor | G11: four Render ATN events mapped; current counts 120 supported/97 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | | 2026-10-31 | minor | G11: Group F action steps map to existing commands; 127 supported event IDs; action-file support remains partial; estimates unchanged | | 2026-10-31 | minor | G11: twelve Stylize/Pixelate ATN events mapped; current counts 128 supported/89 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | @@ -528,6 +545,7 @@ what an agent can't finish alone. | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | | 2026-10-11 | minor | G4: Pattern Stamp and Background Eraser draw live, Spot Healing shows a dark overlay while dragging (#3754); only the Mixer Brush is left of TOOL-213-7 | +| 2026-11-03 | minor | G8: Type › OpenType › Numerals submenu landed (TYPE-218-5); `.pcraft` keeps figure tags, PSD only `OldStyle`/`onum` for now; Stylistic Sets and font-feature greying still missing | | 2026-10-11 | minor | G8: Character panel OpenType buttons landed (TYPE-218-5, #3752); Numerals and Stylistic Sets submenus still missing | | 2026-10-11 | minor | G10: one-page flattened PDF write shipped (io::pdf, #3401); FILE-215-10 now partial | | 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5c747e5d52..064be8aba2 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**631 of 631 menu items live (100.0%).** +**640 of 640 menu items live (100.0%).** | Menu | Live | Total | % | |---|---:|---:|---:| @@ -10,7 +10,7 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho | Edit | 75 | 75 | 100% | | Image | 61 | 61 | 100% | | Layer | 163 | 163 | 100% | -| Type | 45 | 45 | 100% | +| Type | 54 | 54 | 100% | | Select | 25 | 25 | 100% | | Filter | 75 | 75 | 100% | | View | 74 | 74 | 100% | diff --git a/docs/scorecard.md b/docs/scorecard.md index 884db18142..a94cca1468 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 36 | 9 | 0 | | | [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 186 (target 0) | | [Type](#type) | 5 | 5 | 0 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 3 | 3 | 640 of 640 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -241,7 +241,7 @@ Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace. | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Paragraph section (Window > Paragraph and Properties) and the options bar have Auto / Left-to-right / Right-to-left buttons setting the paragraph direction through type.setStyle; the options bar's choice is also the direction new type starts with (type_tool.rs direction_items; paragraph_direction_buttons_set_and_show_the_direction, options_bar_direction_sets_selected_layers_and_new_type). Still missing: visual RTL caret (the arrow keys move in logical order), and PSD doesn't store the direction (crates/text psd.rs neither reads nor writes it, so it reopens as Auto) | | TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | -| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks | +| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Type > OpenType > Numerals (Default, Lining, Oldstyle, Proportional, Tabular, and the four height×width combinations) landed 2026-11-03: each sets lnum/onum/pnum/tnum as a radio style on the selection or layer, round-trips .pcraft, and shows its checkmark from the style at the selection start. Still missing: the Stylistic Sets submenu, and greying out features the font lacks | | TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/it/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | | TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | done | [#218](https://github.com/storytold/photocraft/issues/218) | Type > Warp Text opens with the shown layer's warp, or None, never remembered values (filter_dialog.rs:503 warp_defaults; warp_text_dialog_tests.rs:80 warp_text_opens_with_the_type_layers_current_warp, :99 warp_text_opens_at_none_for_a_layer_without_warp, :108 remembered_warp_values_never_override_the_layers_warp). The dialog previews live without editing the document, Cancel leaves no step and OK is one undoable step (:138 warp_text_dialog_previews_the_warp_without_editing_the_document, :160 cancelling_warp_text_leaves_the_document_and_history_untouched, :179 confirming_warp_text_is_one_undoable_step); the reduced-proxy preview matches the full-size result (filter_dialog.rs:2150 warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy) | | TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | done | [#218](https://github.com/storytold/photocraft/issues/218) | Like Photoshop, 2 clicks select the word, 3 the laid-out line (a column in vertical type), 4 the paragraph up to the hard return, 5 all text, and the next quick click starts over (#3493; type_tool.rs next_click_count, click_selection, multi_click). Tested through real canvas clicks in point, paragraph and vertical type, with paste replacing the selection and Cmd+A selecting all (type_tool_tests.rs multi_click_selects_word_line_paragraph_and_all_in_point_text, multi_click_separates_wrapped_lines_from_paragraphs, click_counts_cycle_and_restart, click_selection_without_layout_and_on_hostile_input, multi_click_without_a_session_does_nothing) | @@ -250,16 +250,16 @@ Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace. ## Automation -Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). This counts dispatch, not behaviour. +Menu wiring (`docs/parity-checklist.md`): 640 of 640 menu items live (100.0%). This counts dispatch, not behaviour. | Id | Target | Status | Issue | Note | |---|---|---|---|---| | AUTO-1 | Every menu item dispatches a command id (UI, CLI, control channel and MCP share them) | done | | docs/parity-checklist.md: all menu items live; menus.rs is_live | -| AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids | +| AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | partial | [#219](https://github.com/storytold/photocraft/issues/219) | actions_record.rs/actions_cmds.rs: active layer ids are dropped, uniquely named layers replay by name, Type tool keystrokes and Move-tool nudge runs fold, document activation records an opening-order offset, and point text placement scales with the playback canvas; numeric-only receivers, ambiguous layer names and paragraph box placement remain open, #4514 | | AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | done | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: set folders (collapsible, drag between sets, actions.assignSet), dialog toggle column (playback opens the step's dialog seeded with its values and resumes on OK; actions.dialog), Insert Stop… (actions.insert), step toggles, reorder, delete a step; Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | -| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | +| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. Desktop Esc asks Continue/Stop, retaining one plan and error log; a read-only canvas preview shows each scratch-session input without a progress bar. Inputs are not live tabs with Layers panel content, so full window parity remains open. | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | | AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1234115bc2..ea92f30ec6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** minor (11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -176,14 +176,18 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in PSD and TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking; no per-camera colour for non-DNG cameras); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); compound layer properties export as separate Set events with rename last (#8304, 2026-10-21; independent Photoshop replay pending); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; recording uses implicit active layers, unique layer names, whole-text Type steps, bundled Move-tool nudges, relative keyboard document switches and canvas-relative point text (AUTO-219-1, #4514; duplicate names and paragraph boxes remain open); Actions panel remains partial (AUTO-219-2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); compound layer properties export as separate Set events with rename last (#8304, 2026-10-21; independent Photoshop replay pending); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options without Matte, progressive Scans or size preview (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | | Workspace, panels, preferences | 40% | 150–250 | 11 of 183 prefs do nothing (scorecard, 2026-11-01); no floating/OS-window panel groups; Libraries, Comments, Contextual Task Bar missing | | AI / generative | 10% | 300–600 | See the dimension row | -Batch supports [output naming templates](batch-naming.md); AUTO-219-5 remains partial. +Batch supports [output naming templates, recursive folders, open documents, Esc Continue/Stop, +headless cancellation, stop-on-error and error logs](batch-naming.md); measured output names and +UTF-16 logs match Photoshop 2026. The desktop shows each folder input as a read-only canvas +snapshot while its action runs, with no progress bar. Full live tabs and Layers panel content for +those inputs remain different. AUTO-219-5 is partial (#4579). ## Remaining effort and calibration @@ -585,6 +589,11 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Automation Batch: Esc Continue/Stop preserves one file plan and error log; a read-only input preview replaces the progress bar; full live tabs remain open, AUTO-219-5 partial (#4579); percentages and estimates unchanged | +| 2026-11-02 | minor | AUTO-219-1: action recording folds Move-tool nudges, records relative document switches and scales point text placement to the playback canvas; duplicate names and paragraph box placement remain open; Automation parity estimate unchanged | +| 2026-11-01 | minor | AUTO-219-1: action recording uses implicit or uniquely named layers and folds Type keystrokes into whole-text steps; duplicate names remain ambiguous; Automation parity estimate unchanged | +| 2026-11-02 | minor | Automation Batch: flat subfolder output, last-result-wins naming and UTF-16 LE logs matched to measured Photoshop 2026; progress and cancel prompt remain partial, AUTO-219-5 (#4579); percentages and estimates unchanged | +| 2026-11-01 | minor | Automation Batch: recursive folders, open documents, extended naming, stop/error log and folder-job progress/cancel; AUTO-219-5 done (#4579); percentages and estimates unchanged | | 2026-11-01 | minor | UI / UX and Workspace rows: 11 of 183 preferences do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see. Percentages and estimates unchanged | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7cdd233c95..570abea63f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; Layers panel filter by Color, #5898; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -257,7 +257,7 @@ Paragraph; the rest open as in-app floating windows. | New layers (New Layer/Group, adjustment, fill, type, shape, paste, place…) go in as the top layer of an open active group (or an artboard the new content is on), above a closed one; type, shapes, pastes, placed files and frames go above a group whose lock (all, pixels, transparency or position) would refuse them; a new frame starts closed | done | `layer_place.rs`, `layer_place_tests.rs` | | A new layer or group inside a clipping mask joins it (clipped when the layer above its slot is), so the clipped layers keep their base | done | `layer_place_tests.rs` (#2203) | | Layers panel New Layer button: ⌘-click adds the layer below the active one (never under the Background) | done | `layer_place_tests.rs`, `new_group_button_tests.rs` | -| Layers panel filter-type dropdown (Kind, Name, Effect, Mode, Attribute, Color, Smart Object, Selected, Artboard) | partial | Kind, Name and Mode; the other six missing. `layer_filter_ui.rs`, `ui.set {layerFilter}` (#5048) | +| Layers panel filter-type dropdown (Kind, Name, Effect, Mode, Attribute, Color, Smart Object, Selected, Artboard) | partial | Kind, Name, Mode, Color, Smart Object (linked, missing and embedded; no library links or out-of-date state) and Artboard; the other three missing. `layer_filter_ui.rs`, `ui.set {layerFilter}` (#5048, #5898) | | Adjustment scope: clipping control visible above tall Properties editors in a compact dock | done (2026-10-21) | `panels.rs`, `clipping_indicator_tests.rs`; #5231; stack-wide default retained | | Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes; with a row selected, the Delete button and the ⌫/Delete key remove that filter, not the smart object | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271), `smart_ui::keys` (#4751) | | Layers panel: Clip Thumbnails to Layer Bounds (panel menu, `interface.clipThumbnailsToLayerBounds`) | done (2026-10-12) | `layer_thumb_bounds_tests.rs`; #4281. Thumbnail size and the Panel Options dialog to come | @@ -269,6 +269,7 @@ Paragraph; the rest open as in-app floating windows. |---|---|---| | 2026-11-02 | minor | Gradient stops dragged off the bar are removed, with a live preview and drag-back cancel, in every stop editor: Gradient Map and Layer Style gained it, the Gradient Editor, Properties and shape popover share the same threshold (#10742); row added (done); no percentage changed | | 2026-11-01 | minor | Every preference takes effect: 11 of 183 do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see | +| 2026-10-31 | minor | Layers panel filter-type dropdown: Color filters by the layers' label colour (#5898); Kind, Name, Mode, Color, Smart Object and Artboard, the other three missing; no percentage changed | | 2026-10-29 | minor | Paragraph box overflow: text a box can't hold enlarges the box's lower-right handle and shows a black + in it, as the reference application does (#8311); agents read `typeBoxOverflow` in `ui.inspect`. Not a new command, so no percentage changed | | 2026-10-28 | minor | The text and gradient stop Color Pickers preview the picked colour on the canvas before OK, as Photoshop's do (#5530); no percentage changed | | 2026-10-27 | minor | Shape Fill/Stroke popover as Photoshop's (paint types, Color Picker button, Recently Used Colors, swatch folders, with the Gradient tab) and every colour swatch opens the full Color Picker; rows added (partial, done); Recently Used Colors is a new preference, so 12 of 183 do nothing; no percentage changed; 2026-10-30: the swatches preview live while their Color Picker is open | @@ -338,6 +339,7 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | | 2026-10-21 | minor | Crop rows corrected against the code and the scorecard: saved custom crop presets (TOOL-214-9, #2723) and Delete Cropped Pixels off are done (#6562); no percentage changed | | 2026-10-20 | minor | Free Transform of an artboard child omits ancestor paper from the covering and isolated composites while retaining board clipping. The base canvas and whole-artboard backgrounds are preserved; synthetic regressions in `transform_tool.rs`. No parity percentage changed. | +| 2026-10-24 | minor | Layers panel filter row: Artboard lists only the artboards that hold the selected layers, and the unfiltered tree when nothing is selected or a selected layer is in no artboard; drivable by `ui.set {layerFilter: {by: "artboard"}}`; no percentage changed | | 2026-10-18 | minor | Move tool: dragging selected pixels shrinks the marching ants to the moved pixels as the drag starts, and the dropped selection is theirs, as in Photoshop (#7538) | | 2026-10-18 | minor | Marching ants drawn in device pixels like Photoshop's: equally thick on every edge at 125 %/150 % display scale, thicker on HiDPI, slanted dash ends (#7489) | | 2026-10-16 | minor | View › Extras (⌘H) hides the Move tool's transform controls and their handles stop responding; ⌘H again restores them, and Show Transform Controls keeps its setting (#6699) | @@ -345,6 +347,7 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) | | 2026-10-14 | minor | A colour picked in the Color or Swatches panel or the Color Picker recolours the selected type layers (Type tool) or shape fills (shape, Pen and path selection tools) | | 2026-10-15 | minor | Color panel: Photoshop's panel-menu modes, colour ramp and copy commands; the theme no longer picks the panel's layout (UI-217-17 done, #2569, #6000) | +| 2026-10-24 | minor | Layers panel filter row: Smart Object lists linked, missing or embedded smart objects, drivable by `ui.set {layerFilter: {by: "smartObject", smart}}`; no percentage changed | | 2026-10-23 | minor | ⌥ + an adjustment's shortcut (⌥⌘L Levels, ⌥⌘M Curves) opens its dialog at the settings of its last OK, as in Photoshop (#8900); no percentage changed | | 2026-10-25 | minor | Shared font-family fields support direct name editing and search, with full-name selection on entry, explicit acceptance and cancellation, plus a compact no-match result (#9338); no percentage changed | | 2026-10-25 | minor | RC2 follow-up: selection-aware font family/style display, explicit keyboard application to mixed selections, pending caret fonts for typing/paste/IME, and Ctrl/Cmd+Enter from focused Type fields (`type_font_commit_tests.rs`); no percentage changed | @@ -352,4 +355,5 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-15 | minor | ⇧⌥ blend mode letters work while a Free Transform box is open, without picking a tool | | 2026-10-14 | minor | Mode dropdowns for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; the blend mode keys reach them, and tools without a blend mode (not Dodge, Burn or Sponge) pass the keys to the layers | | 2026-10-16 | minor | Hand tool: ⌘ held zooms in and ⌥ zooms out, as in Photoshop (#6434) | +| 2026-11-03 | minor | Tools › Overscroll off centres an image smaller than the window on that axis, so zooming out around the pointer keeps it in the middle instead of where the zoom left it (#10488, #4799); this overrides #1718's Photoshop 25.4 measurement on the strength of two Photoshop users' reports, and Overscroll on keeps the free view; no percentage changed | | 2026-10-31 | minor | Ctrl + Alt + wheel over the canvas zooms like ⌥ + wheel (#10488): ×1.1 a notch around the pointer with Animated Zoom off, eased with it on, and it follows the ⌥ swap when General › Zoom with Scroll Wheel is on. Windows and Linux report a held Ctrl as ctrl AND command (`wheel_nav::alt_gesture`), macOS ⌘ + ⌥ is left alone, plain ⌘/Ctrl + wheel still scrolls sideways; the combination was a dead gesture, so nothing existing was replaced. The centred-zoom half of the report is still open: `canvas.rs:1737` re-clamps the view centre each frame only when Tools › Overscroll is off (it is on by default), so the drift the reporter saw is more likely the deliberate nearest-image-point anchor (`view_xform.rs:239`, #3720) or Zoom Clicked Point to Center, which needs its own repro; no percentage changed | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 862de726f2..617914afbd 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -14,9 +14,9 @@ note = "docs/parity-checklist.md: all menu items live; menus.rs is_live" [[item]] id = "AUTO-219-1" target = "Record actions in a document-independent form (implicit layer, merged type keystrokes)" -status = "missing" +status = "partial" issue = 219 -note = "actions.rs:47 copies raw journal entries with explicit layer ids" +note = "actions_record.rs/actions_cmds.rs: active layer ids are dropped, uniquely named layers replay by name, Type tool keystrokes and Move-tool nudge runs fold, document activation records an opening-order offset, and point text placement scales with the playback canvas; numeric-only receivers, ambiguous layer names and paragraph box placement remain open, #4514" [[item]] id = "AUTO-219-2" @@ -44,7 +44,7 @@ id = "AUTO-219-5" target = "Batch: subfolders, open documents, naming template, progress/cancel, error log, stop" status = "partial" issue = 219 -note = "file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file" +note = "file_cmds/batch_run.rs and batch_naming.rs: flat subfolder output and last-result-wins names measured in Photoshop 2026; live open-document source; file naming components and serials; UTF-16 LE error log; stop-on-error; CLI and Batch dialog, #4579. Desktop Esc asks Continue/Stop, retaining one plan and error log; a read-only canvas preview shows each scratch-session input without a progress bar. Inputs are not live tabs with Layers panel content, so full window parity remains open." [[item]] id = "AUTO-219-6" diff --git a/scorecard/type.toml b/scorecard/type.toml index c119ac984d..24e5afe709 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -37,7 +37,7 @@ id = "TYPE-218-5" target = "OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus" status = "partial" issue = 218 -note = "Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks" +note = "Type > OpenType toggles (type_extra_cmds.rs). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Type > OpenType > Numerals (Default, Lining, Oldstyle, Proportional, Tabular, and the four height×width combinations) landed 2026-11-03: each sets lnum/onum/pnum/tnum as a radio style on the selection or layer, round-trips .pcraft, and shows its checkmark from the style at the selection start. Still missing: the Stylistic Sets submenu, and greying out features the font lacks" [[item]] id = "TYPE-218-6"