mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
Merge origin/main into fix/gradient-stop-drag-off
Resolve the parity-doc conflict: keep main's status line and entries, re-add this PR's entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bc715316ff
149 files changed
+11296
-851
No files matched your search
@@ -190,6 +190,22 @@ pub(crate) struct BatchArgs {
|
||||
/// originals; --in-place allows it
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
/// Include images in subfolders; every result goes straight into --out, so a later file of
|
||||
/// the same name replaces an earlier one
|
||||
#[arg(long)]
|
||||
pub(crate) recursive: bool,
|
||||
/// Name each result with Batch tokens such as {name}, {serial}, {date}, and {ext}
|
||||
#[arg(long, value_name = "TEMPLATE")]
|
||||
pub(crate) naming: Option<String>,
|
||||
/// First serial number used by --naming
|
||||
#[arg(long, value_name = "1-4294967295", value_parser = clap::value_parser!(u32).range(1..))]
|
||||
pub(crate) start_serial: Option<u32>,
|
||||
/// Stop after the first failed input
|
||||
#[arg(long)]
|
||||
pub(crate) stop_on_error: bool,
|
||||
/// Write failed inputs and reasons to this text file
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub(crate) error_log: Option<PathBuf>,
|
||||
/// Explicitly approve high-memory PSD/PSB imports within the hard memory budget
|
||||
#[arg(long)]
|
||||
pub(crate) approve_high_memory: bool,
|
||||
|
||||
@@ -58,25 +58,67 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
|
||||
if !a.in_place && same_dir(in_dir, out_dir) {
|
||||
return Err(CliError::OutIsIn(out_dir.to_path_buf()));
|
||||
}
|
||||
std::fs::create_dir_all(out_dir)?;
|
||||
let mut inputs: Vec<PathBuf> = std::fs::read_dir(in_dir)
|
||||
.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| is_input(p))
|
||||
.collect();
|
||||
inputs.sort();
|
||||
let inputs: Vec<photocraft_engine::file_cmds::BatchInput> = if a.recursive {
|
||||
photocraft_engine::file_cmds::list_images_tree(&in_dir.to_string_lossy(), &out_dir.to_string_lossy(), "photocraft-cli batch")?
|
||||
.into_iter()
|
||||
.filter(|input| is_input(Path::new(&input.path)))
|
||||
.collect()
|
||||
} else {
|
||||
let mut images = Vec::new();
|
||||
let entries = std::fs::read_dir(in_dir).map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?;
|
||||
for entry in entries {
|
||||
let path = entry.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?.path();
|
||||
if is_input(&path) {
|
||||
images.push(photocraft_engine::file_cmds::BatchInput { path: path.to_string_lossy().into_owned(), rel_dir: String::new() });
|
||||
}
|
||||
}
|
||||
// The tree listing already holds the engine's order (each folder's files, then its
|
||||
// subfolders), which `{serial}` numbers; only the flat listing needs sorting.
|
||||
images.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
images
|
||||
};
|
||||
let (mut ok, mut failed) = (0, 0);
|
||||
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
|
||||
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
|
||||
let format = a.export.format.as_deref().map(|f| f.strip_prefix('.').unwrap_or(f));
|
||||
for input in &inputs {
|
||||
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
|
||||
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let target = out_dir.join(format!("{stem}.{ext}"));
|
||||
let target_text = target.to_string_lossy();
|
||||
let named = photocraft_engine::file_cmds::plan_batch_names(
|
||||
&inputs,
|
||||
&out_dir.to_string_lossy(),
|
||||
format.unwrap_or("same"),
|
||||
&json!({"naming": a.naming.as_deref().unwrap_or(""), "startingSerial": a.start_serial.unwrap_or(1)}),
|
||||
)
|
||||
.map_err(|error| CliError::Naming(error.to_string()))?;
|
||||
let targets: Vec<PathBuf> = match named {
|
||||
Some(paths) => paths.into_iter().map(PathBuf::from).collect(),
|
||||
None => inputs
|
||||
.iter()
|
||||
.map(|input| {
|
||||
let path = Path::new(&input.path);
|
||||
let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.path), str::to_owned);
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
out_dir.join(format!("{stem}.{ext}"))
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let plan: Vec<_> = inputs.iter().zip(&targets).map(|(input, target)| (input.path.clone(), target.to_string_lossy().into_owned())).collect();
|
||||
photocraft_engine::file_cmds::validate_flat_outputs(&plan).map_err(|error| CliError::Naming(error.to_string()))?;
|
||||
if let Some(log) = &a.error_log {
|
||||
let same = |path: &Path| photocraft_engine::file_cmds::same_destination(log, path);
|
||||
if inputs.iter().any(|input| same(Path::new(&input.path))) || targets.iter().any(|target| same(target)) {
|
||||
return Err(CliError::BatchOption("--error-log must not replace an input or batch output".into()));
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(out_dir)?;
|
||||
let mut failures = Vec::new();
|
||||
for (input, target) in inputs.iter().zip(targets) {
|
||||
let input = Path::new(&input.path);
|
||||
// The selected format encodes the bytes, whatever extension `--naming` gives the file.
|
||||
// Without --format, a file PhotoCraft only opens (.kra, raw, …) is saved as PSD, the
|
||||
// same format `--naming`'s {ext} plans.
|
||||
let ext = format.map_or_else(|| photocraft_engine::file_cmds::batch_same_format(&input.to_string_lossy()), str::to_owned);
|
||||
let r = (|| -> Result<Vec<String>> {
|
||||
written.check(&target_text).map_err(CliError::OutputClaimed)?;
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut h = Headless::trusted_local();
|
||||
// Steps that play another action find it in the set; the session's playback stack
|
||||
// stops an action that calls itself.
|
||||
@@ -101,17 +143,31 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
|
||||
match r {
|
||||
Ok(ws) => {
|
||||
ok += 1;
|
||||
written.record(&target_text, &input.to_string_lossy());
|
||||
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
let _ = writeln!(err, "FAIL {}: {e}", input.display());
|
||||
failures.push((input.to_string_lossy().into_owned(), e.to_string()));
|
||||
if a.stop_on_error {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
|
||||
let skipped = inputs.len().saturating_sub(ok + failed);
|
||||
if skipped > 0 {
|
||||
let _ = writeln!(err, "--stop-on-error: {skipped} remaining input(s) skipped");
|
||||
let _ = writeln!(out, "{ok} succeeded, {failed} failed, {skipped} skipped");
|
||||
} else {
|
||||
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
|
||||
}
|
||||
// After the summary, so a log that can't be written doesn't hide the counts.
|
||||
if let Some(path) = &a.error_log {
|
||||
let bytes = photocraft_engine::file_cmds::encode_batch_error_log(failures.iter().map(|(file, error)| (file.as_str(), error.as_str())));
|
||||
photocraft_format::atomic_write(path, &bytes)?;
|
||||
}
|
||||
if failed > 0 { Err(CliError::FilesFailed(failed)) } else { Ok(()) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -434,9 +434,25 @@ fn batch_applies_actions_to_directory() {
|
||||
write_png(&inp.join("two.png"), 9, 9, 2);
|
||||
std::fs::write(inp.join("notes.txt"), "ignored").unwrap();
|
||||
let actions = d.join("actions.json");
|
||||
let log = d.join("errors.txt");
|
||||
std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
|
||||
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])).stdout;
|
||||
let out = ok(bin()
|
||||
.args(["batch", "--actions"])
|
||||
.arg(&actions)
|
||||
.arg("--in")
|
||||
.arg(&inp)
|
||||
.arg("--out")
|
||||
.arg(&outp)
|
||||
.args(["--format", "pcraft", "--error-log"])
|
||||
.arg(&log))
|
||||
.stdout;
|
||||
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
|
||||
let clean_log = std::fs::read(&log).unwrap();
|
||||
assert!(clean_log.starts_with(&[0xff, 0xfe]));
|
||||
let (pairs, remainder) = clean_log[2..].as_chunks::<2>();
|
||||
assert!(remainder.is_empty());
|
||||
let body = String::from_utf16(&pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect::<Vec<_>>()).unwrap();
|
||||
assert_eq!(body, "Batch Start\r\n\r\nBatch End\r\n");
|
||||
for n in ["one", "two"] {
|
||||
let info = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))).stdout;
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
@@ -450,6 +466,127 @@ fn batch_applies_actions_to_directory() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_recursive_naming_stop_and_error_log() {
|
||||
let root = tmp("batch-recursive-options");
|
||||
let input = root.join("in");
|
||||
std::fs::create_dir_all(input.join("sub")).unwrap();
|
||||
for file in ["a.png", "sub/a.png", "sub/c.png"] {
|
||||
write_png(&input.join(file), 8, 6, 3);
|
||||
}
|
||||
std::fs::write(input.join("sub/b.png"), b"not a png").unwrap();
|
||||
let actions = root.join("actions.json");
|
||||
std::fs::write(&actions, r#"[["image.imageRotation.90cw", {}]]"#).unwrap();
|
||||
let output = root.join("out");
|
||||
let log = root.join("errors.txt");
|
||||
let run = |out: &Path, extra: &[&str]| {
|
||||
bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(out).args(extra).output().unwrap()
|
||||
};
|
||||
let result = run(&output, &["--recursive", "--naming", "{name}-{serial:2}.{ext}", "--start-serial", "5", "--error-log", &log.to_string_lossy()]);
|
||||
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||
assert!(!result.status.success(), "{stdout}");
|
||||
assert!(stdout.contains("3 succeeded, 1 failed"), "{stdout}");
|
||||
for file in ["a-05.png", "a-06.png", "c-08.png"] {
|
||||
assert!(output.join(file).is_file(), "{file}: {stdout}");
|
||||
}
|
||||
let bytes = std::fs::read(log).unwrap();
|
||||
assert!(bytes.starts_with(&[0xff, 0xfe]));
|
||||
let (pairs, remainder) = bytes[2..].as_chunks::<2>();
|
||||
assert!(remainder.is_empty());
|
||||
let errors = String::from_utf16(&pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect::<Vec<_>>()).unwrap();
|
||||
assert!(errors.contains("Batch Start\r\n") && errors.contains("b.png") && errors.ends_with("Batch End\r\n"), "{errors}");
|
||||
|
||||
let stopped = root.join("stopped");
|
||||
let result = run(&stopped, &["--recursive", "--stop-on-error"]);
|
||||
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(!result.status.success(), "{stdout}");
|
||||
assert!(stdout.contains("2 succeeded, 1 failed, 1 skipped"), "{stdout}");
|
||||
assert!(stderr.contains("--stop-on-error"), "{stderr}");
|
||||
assert!(!stopped.join("c.png").exists());
|
||||
|
||||
let collision = root.join("collision");
|
||||
let alias = collision.join("../collision/a.png");
|
||||
let result = run(&collision, &["--recursive", "--error-log", &alias.to_string_lossy()]);
|
||||
assert!(!result.status.success());
|
||||
assert!(String::from_utf8_lossy(&result.stderr).contains("--error-log"));
|
||||
assert!(!collision.join("a.png").exists());
|
||||
}
|
||||
|
||||
/// `{serial}` numbers a recursive batch in the engine's order (a folder's files before its
|
||||
/// subfolders), `--format` encodes the bytes whatever extension `--naming` writes, and an
|
||||
/// `--error-log` that can't be written still leaves the summary printed.
|
||||
#[test]
|
||||
fn batch_naming_keeps_the_tree_order_and_the_selected_encoder() {
|
||||
let root = tmp("batch-naming-order");
|
||||
let input = root.join("in");
|
||||
std::fs::create_dir_all(input.join("sub")).unwrap();
|
||||
write_png(&input.join("z.png"), 8, 6, 3);
|
||||
write_png(&input.join("sub/a.png"), 5, 4, 3);
|
||||
let actions = root.join("actions.json");
|
||||
std::fs::write(&actions, "[]").unwrap();
|
||||
let run = |out: &Path, extra: &[&str]| {
|
||||
bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(out).args(extra).output().unwrap()
|
||||
};
|
||||
let png_width = |path: &Path| {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
u32::from_be_bytes(bytes[16..20].try_into().unwrap())
|
||||
};
|
||||
let ordered = root.join("ordered");
|
||||
let result = run(&ordered, &["--recursive", "--naming", "{serial:2}.{ext}"]);
|
||||
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
|
||||
assert_eq!(png_width(&ordered.join("01.png")), 8, "the root file comes first");
|
||||
assert_eq!(png_width(&ordered.join("02.png")), 5);
|
||||
|
||||
let jpeg = root.join("jpeg");
|
||||
let result = run(&jpeg, &["--format", "jpg", "--naming", "{name}_{serial}"]);
|
||||
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
|
||||
assert!(std::fs::read(jpeg.join("z_001")).unwrap().starts_with(&[0xff, 0xd8]), "JPEG bytes without an extension");
|
||||
let png = root.join("png");
|
||||
let result = run(&png, &["--format", "png", "--naming", "{name}.jpg"]);
|
||||
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
|
||||
assert!(std::fs::read(png.join("z.jpg")).unwrap().starts_with(b"\x89PNG"), "PNG bytes under a .jpg name");
|
||||
|
||||
let log = root.join("log-is-a-folder");
|
||||
std::fs::create_dir_all(&log).unwrap();
|
||||
let result = run(&root.join("logged"), &["--error-log", &log.to_string_lossy()]);
|
||||
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||
assert!(!result.status.success(), "{stdout}");
|
||||
assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// Without --format, a file PhotoCraft only opens is saved as PSD, with or without --naming:
|
||||
/// the encoder gets the format the planned name uses, not the input's import-only extension.
|
||||
#[test]
|
||||
fn batch_saves_import_only_inputs_as_psd() {
|
||||
let root = tmp("batch-import-only");
|
||||
let input = root.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
std::fs::write(
|
||||
input.join("logo.xpm"),
|
||||
"/* XPM */
|
||||
static char *logo[] = {
|
||||
\"2 2 1 1\",
|
||||
\"a c #FF0000\",
|
||||
\"aa\",
|
||||
\"aa\"
|
||||
};
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let actions = root.join("actions.json");
|
||||
std::fs::write(&actions, "[]").unwrap();
|
||||
for (name, extra) in [("plain", &[][..]), ("named", &["--naming", "{name}.{ext}"][..])] {
|
||||
let out = root.join(name);
|
||||
let result = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out).args(extra).output().unwrap();
|
||||
assert!(result.status.success(), "{name}: {}{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr));
|
||||
assert!(std::fs::read(out.join("logo.psd")).unwrap().starts_with(b"8BPS"), "{name}: PSD bytes");
|
||||
assert!(!out.join("logo.xpm").exists(), "{name}");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_skips_recorded_view_and_save_steps() {
|
||||
let d = tmp("batch-shell-steps");
|
||||
@@ -804,6 +941,33 @@ fn batch_refuses_to_write_over_its_inputs() {
|
||||
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_naming_rejects_unread_input_alias_through_a_missing_directory() {
|
||||
let d = tmp("batch-missing-directory-alias");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
write_png(&input.join("b.png"), 8, 4, 7);
|
||||
let original = std::fs::read(input.join("b.png")).unwrap();
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let output = bin()
|
||||
.arg("batch")
|
||||
.arg("--actions")
|
||||
.arg(&actions)
|
||||
.arg("--in")
|
||||
.arg(&input)
|
||||
.arg("--out")
|
||||
.arg(input.join("missing/.."))
|
||||
.args(["--in-place", "--naming", "b.{ext}"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success(), "{}", String::from_utf8_lossy(&output.stdout));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("replace an input"));
|
||||
assert_eq!(std::fs::read(input.join("b.png")).unwrap(), original);
|
||||
assert!(!input.join("missing").exists());
|
||||
}
|
||||
|
||||
/// #7437: `convert` to its own input file, however the output is spelt, is refused before
|
||||
/// anything is read or written, unless `--in-place` asks for it (as `batch` does, #492).
|
||||
#[test]
|
||||
@@ -944,9 +1108,9 @@ fn mcp_bridge_takes_a_port_or_an_address() {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
|
||||
/// Photoshop Batch processes every input in order, replacing earlier flat results with the same name.
|
||||
#[test]
|
||||
fn batch_fails_inputs_that_share_an_output_name() {
|
||||
fn batch_later_input_replaces_a_same_named_output() {
|
||||
let d = tmp("batch-collide");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
@@ -961,15 +1125,10 @@ fn batch_fails_inputs_that_share_an_output_name() {
|
||||
let out_dir = d.join("out");
|
||||
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
|
||||
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||||
assert!(
|
||||
!o.status.success(),
|
||||
"a lost result must fail the run
|
||||
{out}
|
||||
{err}"
|
||||
);
|
||||
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
|
||||
assert_eq!(err.matches("not written").count(), 2, "{err}");
|
||||
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
|
||||
assert!(o.status.success(), "{out}{err}");
|
||||
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
|
||||
assert!(out_dir.join("a.png").is_file());
|
||||
assert!(std::fs::read_dir(&out_dir).unwrap().count() <= 2);
|
||||
// Keeping each file's format, every input gets its own output.
|
||||
let out_dir = d.join("same");
|
||||
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir)).stdout;
|
||||
|
||||
@@ -685,7 +685,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
});
|
||||
let automation_save = automation.clone().map(|workspace| {
|
||||
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
|
||||
save_through(doc, path, settings, ctl, |p, encoded, _| workspace.write(p, &encoded.bytes().map_err(|e| e.to_string())?).map_err(|e| e.to_string()))
|
||||
save_through(doc, path, settings, ctl, |p, encoded, _| workspace.write_stream(p, &mut |w| encoded.write_to(w)).map_err(|e| e.to_string()))
|
||||
}) as photocraft_ui_egui::SaveFileFn
|
||||
});
|
||||
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
|
||||
|
||||
@@ -227,18 +227,39 @@ async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Res
|
||||
if v.get("id").and_then(Value::as_u64) != Some(id) {
|
||||
continue; // stale reply from an earlier, timed-out request
|
||||
}
|
||||
return Ok(if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_owned()))
|
||||
});
|
||||
return Ok(reply_result(&v));
|
||||
}
|
||||
}
|
||||
|
||||
/// An app reply as the caller's result. A failed reply can still carry a `result`: a cancelled
|
||||
/// Batch lists the outputs it already wrote, so the error keeps it rather than dropping it.
|
||||
fn reply_result(v: &Value) -> Result<Value, AutomationError> {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
let error = v.get("error").and_then(Value::as_str).unwrap_or("unknown error");
|
||||
Err(AutomationError::App(match v.get("result") {
|
||||
Some(result) if !result.is_null() => format!("{error}; partial result: {result}"),
|
||||
_ => error.to_owned(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_failed_reply_keeps_its_partial_result() {
|
||||
assert_eq!(reply_result(&json!({"ok": true, "result": {"files": ["a.png"]}})).unwrap(), json!({"files": ["a.png"]}));
|
||||
let Err(AutomationError::App(message)) = reply_result(&json!({"ok": false, "error": "cancelled", "result": {"files": ["a.png"], "cancelled": true}}))
|
||||
else {
|
||||
panic!("a failed reply is an app error")
|
||||
};
|
||||
assert!(message.starts_with("cancelled; partial result: ") && message.contains("a.png"), "{message}");
|
||||
let Err(AutomationError::App(message)) = reply_result(&json!({"ok": false, "error": "cancelled"})) else { panic!("a failed reply is an app error") };
|
||||
assert_eq!(message, "cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_call_timeout_stays_below_the_app_reply_deadline() {
|
||||
// At or above the app's 60s deadline, callers would see their own cutoff first.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -25,56 +25,69 @@ pub fn preview_page(doc: &Document, source: Rect, factor: u32, interrupt: Interr
|
||||
if rect.width() > 1024 || rect.height() > 1024 {
|
||||
return Err("viewport page is too large".into());
|
||||
}
|
||||
fn shrink(s: &Surface, r: Rect, k: i32, i: Interrupt<'_>) -> Result<Surface, String> {
|
||||
// Cell `c` samples source pixel `c·k + k/2`, clamped to the last document pixel: a partial
|
||||
// cell at the document's edge samples inside it, not the layer's default past it. (Document
|
||||
// bounds, not this page's, so neighbouring pages agree.)
|
||||
let bounds = doc.bounds();
|
||||
let last = (bounds.x1 - 1, bounds.y1 - 1);
|
||||
fn shrink(s: &Surface, r: Rect, k: i32, last: (i32, i32), i: Interrupt<'_>) -> Result<Surface, String> {
|
||||
let mut out = Surface::with_default(s.format(), &s.default_pixel());
|
||||
let bpp = s.format().bytes_per_pixel();
|
||||
let x0 = r.x0.saturating_mul(k);
|
||||
for y in r.y0..r.y1 {
|
||||
i.check().map_err(|e| e.to_string())?;
|
||||
let sy = y.saturating_mul(k).saturating_add(k / 2);
|
||||
let row = s.to_interleaved(Rect::new(r.x0.saturating_mul(k), sy, r.x1.saturating_mul(k), sy.saturating_add(1)));
|
||||
let sy = y.saturating_mul(k).saturating_add(k / 2).min(last.1);
|
||||
let row = s.to_interleaved(Rect::new(x0, sy, r.x1.saturating_mul(k).min(last.0 + 1), sy.saturating_add(1)));
|
||||
let mut reduced = Vec::with_capacity(r.width() as usize * bpp);
|
||||
for x in 0..r.width() as usize {
|
||||
let offset = (x * k as usize + k as usize / 2) * bpp;
|
||||
for x in r.x0..r.x1 {
|
||||
let sx = x.saturating_mul(k).saturating_add(k / 2).min(last.0);
|
||||
let offset = (sx - x0) as usize * bpp;
|
||||
reduced.extend_from_slice(row.get(offset..offset + bpp).ok_or("invalid viewport sample")?);
|
||||
}
|
||||
out.write_interleaved(Rect::new(r.x0, y, r.x1, y + 1), &reduced);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn layer(l: &mut photocraft_doc::Layer, r: Rect, k: i32, i: Interrupt<'_>, depth: usize) -> Result<(), String> {
|
||||
fn layer(l: &mut photocraft_doc::Layer, r: Rect, k: i32, i: Interrupt<'_>, depth: usize, last: (i32, i32)) -> Result<(), String> {
|
||||
if depth > 256 {
|
||||
return Err("viewport layer nesting limit exceeded".into());
|
||||
}
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = shrink(&m.surface, r, k, i)?;
|
||||
m.surface = shrink(&m.surface, r, k, last, i)?;
|
||||
}
|
||||
if let Some(c) = &mut l.fill_cache {
|
||||
c.surface = shrink(&c.surface, r, k, i)?;
|
||||
c.surface = shrink(&c.surface, r, k, last, i)?;
|
||||
}
|
||||
use photocraft_doc::LayerContent;
|
||||
match &mut l.content {
|
||||
LayerContent::Raster(s) => *s = shrink(s, r, k, i)?,
|
||||
LayerContent::Raster(s) => *s = shrink(s, r, k, last, i)?,
|
||||
LayerContent::Group(g) => {
|
||||
if let Some(a) = &mut g.artboard {
|
||||
a.rect = Rect::new(a.rect.x0.div_euclid(k), a.rect.y0.div_euclid(k), a.rect.x1.div_euclid(k), a.rect.y1.div_euclid(k));
|
||||
// A cell is on the board when its sample `c·k + k/2` is: each edge maps to the
|
||||
// first cell sampling at or after it.
|
||||
let edge = |e: i32| {
|
||||
let (s, k) = (i64::from(e) - i64::from(k / 2), i64::from(k));
|
||||
(s.div_euclid(k) + i64::from(s.rem_euclid(k) != 0)) as i32
|
||||
};
|
||||
a.rect = Rect::new(edge(a.rect.x0), edge(a.rect.y0), edge(a.rect.x1), edge(a.rect.y1));
|
||||
}
|
||||
for c in &mut g.children {
|
||||
layer(c, r, k, i, depth + 1)?;
|
||||
layer(c, r, k, i, depth + 1, last)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Text(t) => {
|
||||
if let Some(s) = &mut t.cache {
|
||||
*s = shrink(s, r, k, i)?;
|
||||
*s = shrink(s, r, k, last, i)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Shape(t) => {
|
||||
if let Some(s) = &mut t.cache {
|
||||
*s = shrink(s, r, k, i)?;
|
||||
*s = shrink(s, r, k, last, i)?;
|
||||
}
|
||||
}
|
||||
LayerContent::Smart(t) => {
|
||||
if let Some(s) = &mut t.cache {
|
||||
*s = shrink(s, r, k, i)?;
|
||||
*s = shrink(s, r, k, last, i)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -84,10 +97,10 @@ pub fn preview_page(doc: &Document, source: Rect, factor: u32, interrupt: Interr
|
||||
let mut p = doc.clone();
|
||||
p.size = photocraft_doc::Size::new(doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor));
|
||||
for l in &mut p.layers {
|
||||
layer(l, rect, k, interrupt, 0)?;
|
||||
layer(l, rect, k, interrupt, 0, last)?;
|
||||
}
|
||||
for c in &mut p.channels {
|
||||
c.surface = shrink(&c.surface, rect, k, interrupt)?;
|
||||
c.surface = shrink(&c.surface, rect, k, last, interrupt)?;
|
||||
}
|
||||
interrupt.check().map_err(|e| e.to_string())?;
|
||||
Ok(render(&p, rect))
|
||||
@@ -200,6 +213,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A partial cell at the document's right and bottom edges samples the last document pixel,
|
||||
/// not the layer's (transparent) default past it (#5332 review).
|
||||
#[test]
|
||||
fn preview_edge_cells_sample_inside_the_document() {
|
||||
let mut d = Document::new("edge", Size::new(10, 10), ColorMode::Rgb, SampleType::U8);
|
||||
let mut s = photocraft_raster::Surface::new(d.pixel_format());
|
||||
s.fill_rect(d.bounds(), &[1.0, 0.0, 0.0, 1.0]);
|
||||
d.layers.push(photocraft_doc::Layer::new("red", photocraft_doc::LayerContent::Raster(s)));
|
||||
for source in [d.bounds(), Rect::new(4, 4, 10, 10)] {
|
||||
let p = preview_page(&d, source, 4, Interrupt::NONE).unwrap();
|
||||
assert_eq!(p.rect, Rect::new(source.x0 / 4, source.y0 / 4, 3, 3));
|
||||
for (i, px) in p.px.iter().enumerate() {
|
||||
assert!((px[0] - 1.0).abs() < 1e-6 && (px[3] - 1.0).abs() < 1e-6, "{source:?} cell {i}: {px:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An artboard covers the preview cells whose sample (`c·k + k/2`) lies on the board, so a
|
||||
/// narrow board, or one whose edges fall mid-cell, keeps exactly those cells (#5332 review).
|
||||
#[test]
|
||||
fn preview_artboard_covers_the_cells_it_samples() {
|
||||
let k = 4;
|
||||
// (board x0..x1, cells expected on it); cell c samples x = 4c + 2.
|
||||
for (x0, x1, on) in [(2, 3, 0..1), (0, 3, 0..1), (3, 16, 1..4), (1, 7, 0..2), (4, 14, 1..3), (-6, 6, 0..1), (-6, 7, 0..2)] {
|
||||
let mut d = Document::new("boards", Size::new(16, 4), ColorMode::Rgb, SampleType::U8);
|
||||
let board = photocraft_doc::Artboard::new(Rect::new(x0, 0, x1, 4));
|
||||
let group = photocraft_doc::Group { children: vec![], expanded: true, artboard: Some(board), frame_stroke: None };
|
||||
d.layers.push(photocraft_doc::Layer::new("board", photocraft_doc::LayerContent::Group(group)));
|
||||
let p = preview_page(&d, d.bounds(), k, Interrupt::NONE).unwrap();
|
||||
assert_eq!(p.rect, Rect::new(0, 0, 4, 1));
|
||||
for (c, px) in p.px.iter().enumerate() {
|
||||
assert_eq!(px[3] > 0.5, on.contains(&c), "board {x0}..{x1}, cell {c}: {px:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A white document with a noisy, partly transparent layer and a multiplied solid over one-colour
|
||||
/// tiles, so the reduced path takes both its one-colour and per-pixel branches.
|
||||
fn busy(depth: SampleType) -> Document {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Document-independent recording (#219).
|
||||
//!
|
||||
//! The journal is what Actions record, so an entry must mean the same thing on another document.
|
||||
//! The UI names the layer it clicked by id (`"layer": 7`); ids are per document, so a step kept
|
||||
//! that way replays only on the document it came from. Before an entry is journaled:
|
||||
//!
|
||||
//! - a `layer` that was the active (and only selected) layer when the command ran is dropped:
|
||||
//! the step acts on the active layer, as Photoshop's "current layer" does;
|
||||
//! - any other `layer` id becomes the layer's name when the name is unique in the document, and
|
||||
//! `layer.select` always does (selecting is choosing a layer by name, as Photoshop records
|
||||
//! it); an ambiguous name keeps the id, which still replays on the recording document;
|
||||
//! - the Type tool's keystrokes fold into the `type.create` that made the layer. On an existing
|
||||
//! layer, each replacement stays separate so replay keeps style runs between edited spans.
|
||||
//!
|
||||
//! Ids are still accepted everywhere; only what the journal keeps changes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_doc::{DocId, Document, LayerContent, LayerId};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The active document as it was before a command ran, captured by the dispatcher.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Before {
|
||||
doc: Option<Arc<Document>>,
|
||||
active_layer: Option<LayerId>,
|
||||
/// Every selected layer: a `layer` that is one of several stays explicit, because without it
|
||||
/// a layer command acts on the whole selection ([`crate::layer_multi_cmds::targets`]).
|
||||
selected: Vec<LayerId>,
|
||||
}
|
||||
|
||||
impl Before {
|
||||
pub(crate) fn capture(s: &Session) -> Self {
|
||||
match s.active() {
|
||||
Some(st) => Self { doc: Some(st.doc.clone()), active_layer: st.active_layer, selected: st.selected_layers() },
|
||||
None => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands whose explicit `layer` names source content rather than a default target.
|
||||
pub(crate) fn needs_source_layer(id: &str) -> bool {
|
||||
matches!(id, "layer.new.frame" | "select.loadSelection" | "type.createSelection" | "edit.checkSpelling")
|
||||
}
|
||||
|
||||
/// `params` with its `layer` id made portable (see the module doc).
|
||||
pub(crate) fn portable(s: &Session, id: &str, mut params: Value, before: &Before) -> Value {
|
||||
if id == "document.activate"
|
||||
&& let (Some(from), Some(to)) = (before.doc.as_ref().map(|doc| doc.id), s.active().map(|state| state.doc.id))
|
||||
&& let Some(offset) = s.document_offset_between(from, to)
|
||||
{
|
||||
return serde_json::json!({"offset": offset});
|
||||
}
|
||||
let Some(doc) = before.doc.as_deref() else { return params };
|
||||
if id == "type.create" && s.actions.recording.is_some() && params.get("box").is_none() && doc.size.width > 0 && doc.size.height > 0 {
|
||||
let x = params.get("x").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let y = params.get("y").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let x_percent = x / f64::from(doc.size.width) * 100.0;
|
||||
let y_percent = y / f64::from(doc.size.height) * 100.0;
|
||||
if x_percent.is_finite()
|
||||
&& y_percent.is_finite()
|
||||
&& let Some(object) = params.as_object_mut()
|
||||
{
|
||||
object.remove("x");
|
||||
object.remove("y");
|
||||
object.insert("xPercent".into(), serde_json::json!(x_percent));
|
||||
object.insert("yPercent".into(), serde_json::json!(y_percent));
|
||||
}
|
||||
}
|
||||
let Some(target) = params.get("layer").and_then(Value::as_u64).map(LayerId) else { return params };
|
||||
let Some(obj) = params.as_object_mut() else { return params };
|
||||
let only_selected = before.selected.len() <= 1;
|
||||
if id != "layer.select" && !needs_source_layer(id) && before.active_layer == Some(target) && only_selected {
|
||||
obj.remove("layer");
|
||||
return params;
|
||||
}
|
||||
if let Some(layer) = doc.layer(target)
|
||||
&& !(id == "select.loadSelection" && (layer.name.is_empty() || layer.name == "merged"))
|
||||
&& doc.walk().iter().filter(|(_, _, l)| l.name == layer.name).count() == 1
|
||||
{
|
||||
obj.insert("layer".into(), Value::String(layer.name.clone()));
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// The text layer a journaled `type.create` made or `type.edit` changed, so the keystrokes that
|
||||
/// follow fold into that entry only when they type into the same layer (the Type tool selects
|
||||
/// a clicked layer without a command, so the journal alone cannot tell).
|
||||
pub(crate) fn typed_layer(s: &Session, id: &str, params: &Value) -> Option<(DocId, LayerId)> {
|
||||
let doc_id = s.active()?.doc.id;
|
||||
match id {
|
||||
"type.create" => s.active()?.active_layer.map(|layer| (doc_id, layer)),
|
||||
"type.edit" => crate::commands::layer_param(s, params).ok().map(|layer| (doc_id, layer)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the journal keeps of a Type-tool keystroke.
|
||||
pub(crate) enum Keystroke {
|
||||
/// Not a keystroke: the entry is journaled as it is.
|
||||
None,
|
||||
/// Folded into the entry before it.
|
||||
Folded,
|
||||
}
|
||||
|
||||
/// Fold a Type-tool keystroke into its creation step only. A whole-text step on an existing
|
||||
/// layer can collapse distinct style runs between separate edited spans on replay (#4514).
|
||||
/// `previous` is the layer the last entry typed into.
|
||||
pub(crate) fn fold_keystroke(s: &mut Session, id: &str, params: &Value, previous: Option<(DocId, LayerId)>) -> Keystroke {
|
||||
if id != "type.edit" {
|
||||
return Keystroke::None;
|
||||
}
|
||||
let Some(obj) = params.as_object() else { return Keystroke::None };
|
||||
if obj.get("coalesce").and_then(Value::as_str).is_none() {
|
||||
return Keystroke::None;
|
||||
}
|
||||
if !obj.contains_key("replace") || !obj.keys().all(|k| matches!(k.as_str(), "layer" | "replace" | "coalesce")) {
|
||||
return Keystroke::None;
|
||||
}
|
||||
let Ok(target) = crate::commands::layer_param(s, params) else { return Keystroke::None };
|
||||
// The text as it is now, after this keystroke: the step sets it whole.
|
||||
let Some(text) = s.active().and_then(|st| st.doc.layer(target)).and_then(|l| match &l.content {
|
||||
LayerContent::Text(t) => Some(t.text.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
return Keystroke::None;
|
||||
};
|
||||
if previous != s.active().map(|st| (st.doc.id, target)) {
|
||||
return Keystroke::None;
|
||||
}
|
||||
// A new recording starts at a journal boundary. Its first edit cannot change an entry
|
||||
// before that boundary, or actions.stop would miss the edit altogether.
|
||||
if s.actions.recording.is_some_and(|(start, _)| s.journal.len() <= start) {
|
||||
return Keystroke::None;
|
||||
}
|
||||
let Some((last, lp)) = s.journal.last_mut() else { return Keystroke::None };
|
||||
if last != "type.create" || obj.contains_key("layer") {
|
||||
return Keystroke::None;
|
||||
}
|
||||
let Some(lo) = lp.as_object_mut() else { return Keystroke::None };
|
||||
lo.insert("text".into(), Value::String(text));
|
||||
Keystroke::Folded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1098,7 +1098,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
};
|
||||
if s.set_active(idx) { Ok(json!({"document": idx})) } else { Err(EngineError::Other(format!("no document at index {idx}"))) }
|
||||
},
|
||||
journal: false,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "document.move",
|
||||
|
||||
@@ -142,6 +142,23 @@ pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value {
|
||||
/// entry (Last Filter), a smart filter and the step's dialog never see it.
|
||||
pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let mut p = p.clone();
|
||||
if id == "type.create" && (p.get("xPercent").is_some() || p.get("yPercent").is_some()) {
|
||||
if p.get("box").is_some() || p.get("x").is_some() || p.get("y").is_some() {
|
||||
return Err(bad(id, "recorded type position cannot mix percentages with pixel coordinates or a box"));
|
||||
}
|
||||
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
|
||||
for (percent_key, pixel_key, dimension) in [("xPercent", "x", size.width), ("yPercent", "y", size.height)] {
|
||||
let percent = p.get(percent_key).and_then(Value::as_f64).ok_or_else(|| bad(id, format!("invalid `{percent_key}`")))?;
|
||||
let pixel = percent / 100.0 * f64::from(dimension);
|
||||
if !pixel.is_finite() || pixel.abs() > f64::from(f32::MAX) {
|
||||
return Err(bad(id, format!("`{percent_key}` is outside the canvas coordinate range")));
|
||||
}
|
||||
if let Some(object) = p.as_object_mut() {
|
||||
object.remove(percent_key);
|
||||
object.insert(pixel_key.into(), json!(pixel));
|
||||
}
|
||||
}
|
||||
}
|
||||
if id.starts_with("filter.gallery.")
|
||||
&& let Some(object) = p.as_object_mut()
|
||||
{
|
||||
@@ -397,10 +414,19 @@ fn record(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"action": name, "index": idx}))
|
||||
}
|
||||
|
||||
/// Whether a journal entry is recorded as an action step: a replayable command, except Check
|
||||
/// Spelling's corrections and lookups.
|
||||
fn recorded(id: &str, p: &Value) -> bool {
|
||||
replayable(id) && (id != "edit.checkSpelling" || crate::type_spell_cmds::records(p))
|
||||
}
|
||||
|
||||
/// Commands recorded with their step's dialog on: Check Spelling.
|
||||
const RECORDED_WITH_DIALOG: &[&str] = &["edit.checkSpelling"];
|
||||
|
||||
/// Replayable journal entries not yet committed to this action while recording.
|
||||
pub fn pending_steps(s: &Session, action: usize) -> impl Iterator<Item = &(String, Value)> {
|
||||
let from = s.actions.recording.filter(|(_, i)| *i == action).map_or(s.journal.len(), |(from, _)| from);
|
||||
s.journal.iter().skip(from).filter(|(id, _)| replayable(id))
|
||||
s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p))
|
||||
}
|
||||
|
||||
pub(crate) fn flush_recording(s: &mut Session) -> Result<()> {
|
||||
@@ -408,15 +434,66 @@ pub(crate) fn flush_recording(s: &mut Session) -> Result<()> {
|
||||
if idx >= s.actions.list.len() {
|
||||
return Err(bad("actions.stop", "the action being recorded no longer exists"));
|
||||
}
|
||||
let steps: Vec<_> = pending_steps(s, idx).cloned().collect();
|
||||
let steps = fold_recorded_nudges(pending_steps(s, idx).cloned());
|
||||
s.actions.recording = Some((s.journal.len(), idx));
|
||||
if !steps.is_empty() {
|
||||
s.actions.list[idx].steps.extend(steps);
|
||||
let action = &mut s.actions.list[idx];
|
||||
let first = action.steps.len();
|
||||
action.dialogs.extend(steps.iter().enumerate().filter(|(_, (id, _))| RECORDED_WITH_DIALOG.contains(&id.as_str())).map(|(i, _)| first + i));
|
||||
action.steps.extend(steps);
|
||||
s.actions.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consecutive Move-tool keystrokes in one UI nudge run are one relative action step.
|
||||
/// The UI's `coalesce` run id distinguishes a held key from a later, separate gesture.
|
||||
fn fold_recorded_nudges(steps: impl IntoIterator<Item = (String, Value)>) -> Vec<(String, Value)> {
|
||||
let mut out: Vec<(String, Value)> = Vec::new();
|
||||
let mut last_run: Option<String> = None;
|
||||
for (id, mut params) in steps {
|
||||
let run = (id == "layer.translate")
|
||||
.then(|| params.as_object())
|
||||
.flatten()
|
||||
.filter(|p| p.keys().all(|key| matches!(key.as_str(), "dx" | "dy" | "coalesce")))
|
||||
.and_then(|p| p.get("coalesce").and_then(Value::as_str).filter(|run| run.starts_with("nudge:")))
|
||||
.map(str::to_string);
|
||||
if let Some(run) = &run {
|
||||
// `layer.translate` moves each step by whole pixels, so the fold adds rounded steps.
|
||||
let dx = params.get("dx").and_then(Value::as_f64).map(f64::round);
|
||||
let dy = params.get("dy").and_then(Value::as_f64).map(f64::round);
|
||||
if let (Some(dx), Some(dy)) = (dx, dy)
|
||||
&& dx.is_finite()
|
||||
&& dy.is_finite()
|
||||
{
|
||||
if last_run.as_ref() == Some(run)
|
||||
&& let Some((last_id, previous)) = out.last_mut()
|
||||
&& last_id == &id
|
||||
{
|
||||
let sum_x = previous.get("dx").and_then(Value::as_f64).unwrap_or(0.0) + dx;
|
||||
let sum_y = previous.get("dy").and_then(Value::as_f64).unwrap_or(0.0) + dy;
|
||||
if sum_x.is_finite() && sum_y.is_finite() {
|
||||
previous["dx"] = json!(sum_x);
|
||||
previous["dy"] = json!(sum_y);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(object) = params.as_object_mut() {
|
||||
object.remove("coalesce");
|
||||
object.insert("dx".into(), json!(dx));
|
||||
object.insert("dy".into(), json!(dy));
|
||||
}
|
||||
last_run = Some(run.clone());
|
||||
out.push((id, params));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
last_run = None;
|
||||
out.push((id, params));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn stop(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
not_playing(s, "actions.stop")?;
|
||||
let Some((_, idx)) = s.actions.recording else {
|
||||
|
||||
@@ -381,6 +381,78 @@ fn record_explicit_transform_keeps_one_replayable_step() {
|
||||
assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_folds_one_run_of_move_tool_nudges() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Target"})).unwrap();
|
||||
s.execute("actions.record", json!({"name": "Nudge"})).unwrap();
|
||||
for (dx, dy, run) in [(1, 0, "nudge:1"), (1, 0, "nudge:1"), (1, 10, "nudge:1"), (0, 1, "nudge:2")] {
|
||||
s.execute("layer.translate", json!({"dx": dx, "dy": dy, "coalesce": run})).unwrap();
|
||||
}
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let steps = &s.actions.list[0].steps;
|
||||
assert_eq!(steps, &[("layer.translate".into(), json!({"dx": 3.0, "dy": 10.0})), ("layer.translate".into(), json!({"dx": 0.0, "dy": 1.0}))]);
|
||||
}
|
||||
|
||||
/// Each nudge moves by whole pixels, so two 0.6 px steps fold into the 2 px they moved, not 1.2.
|
||||
#[test]
|
||||
fn folded_nudges_add_whole_pixel_steps() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Target"})).unwrap();
|
||||
s.execute("actions.record", json!({"name": "Nudge"})).unwrap();
|
||||
for _ in 0..2 {
|
||||
s.execute("layer.translate", json!({"dx": 0.6, "dy": 0.0, "coalesce": "nudge:1"})).unwrap();
|
||||
}
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(s.actions.list[0].steps, [("layer.translate".into(), json!({"dx": 2.0, "dy": 0.0}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_document_activation_uses_opening_order_offset() {
|
||||
let mut s = Session::new();
|
||||
for name in ["First", "Second"] {
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap();
|
||||
}
|
||||
s.execute("actions.record", json!({"name": "Switch"})).unwrap();
|
||||
s.execute("document.activate", json!({"document": 0})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(s.actions.list[0].steps, [("document.activate".into(), json!({"offset": -1}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_point_text_position_scales_with_the_playback_canvas() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 100, "height": 100, "background": "transparent"})).unwrap();
|
||||
s.execute("actions.record", json!({"name": "Caption"})).unwrap();
|
||||
s.execute("type.create", json!({"x": 20, "y": 30, "text": "Hi", "size": 12})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(s.actions.list[0].steps[0].1["xPercent"], json!(20.0));
|
||||
assert_eq!(s.actions.list[0].steps[0].1["yPercent"], json!(30.0));
|
||||
s.execute("file.new", json!({"width": 200, "height": 300, "background": "transparent"})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action": "Caption"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
let info = s.execute("type.info", json!({})).unwrap();
|
||||
assert_eq!(info["transform"][4], json!(40.0));
|
||||
assert_eq!(info["transform"][5], json!(90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_point_text_rejects_invalid_relative_positions() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
for params in [
|
||||
json!({"xPercent": 20}),
|
||||
json!({"xPercent": "20", "yPercent": 30}),
|
||||
json!({"xPercent": 20, "yPercent": 30, "x": 20}),
|
||||
json!({"xPercent": 20, "yPercent": 30, "box": [0, 0, 10, 10]}),
|
||||
json!({"xPercent": 1e100, "yPercent": 30}),
|
||||
] {
|
||||
assert!(playback_params(&s, "type.create", ¶ms).is_err(), "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_single_layer_transform_replays_after_layer_ids_shift() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -18,6 +18,8 @@ use serde_json::{Value, json};
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
mod replace_color_range;
|
||||
|
||||
/// Built-in Color Lookup looks `(id, label)`.
|
||||
pub use photocraft_cms::lutfile::BUILTIN as LOOKS;
|
||||
|
||||
@@ -401,7 +403,11 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recorded Lab bounds use native profile matching; ordinary RGB samples keep their existing path.
|
||||
fn replace_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(result) = replace_color_range::apply(s, p)? {
|
||||
return Ok(result);
|
||||
}
|
||||
const CMD: &str = "image.adjustments.replaceColor";
|
||||
let c = crate::commands::color_param(CMD, p, "color", s.tools.foreground)?;
|
||||
let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0);
|
||||
@@ -562,7 +568,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"image.adjustments.replaceColor",
|
||||
"Replace Color…",
|
||||
["Image", "Adjustments"],
|
||||
r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?} (color: "#rrggbb", default the foreground colour; Localized Color Clusters: "points" where the colour was picked, "range" % of the longer side)"##,
|
||||
r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?,"labMinimum":[L,a,b]?,"labMaximum":[L,a,b]?} (Lab bounds: a finite ordered D50 box, mutually exclusive with color/points/localized/range; composite pixel-layer target only. Otherwise color defaults to the foreground; localized points use range % of the longer side.)"##,
|
||||
has_pixels,
|
||||
replace_color
|
||||
),
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Recorded Replace Color samples are a Lab box, not a single RGB colour.
|
||||
use photocraft_algo::{selection, tone};
|
||||
use photocraft_cms::{Builtin, TransformOptions};
|
||||
use photocraft_doc::{PixelFormat, SampleType};
|
||||
use photocraft_geom::{Rect, TileCoord};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{bad, num};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "image.adjustments.replaceColor";
|
||||
|
||||
/// Unstored pixels can still be selected when the mask has a nonzero default.
|
||||
fn edit_bounds(content: Rect, selected: Option<&Surface>) -> Rect {
|
||||
match selected {
|
||||
Some(mask) if mask.default_pixel().first() == Some(&0.0) => content.intersect(&mask.content_bounds()),
|
||||
_ => content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate original precision before checking order and converting to native f32.
|
||||
fn bound(p: &Value, key: &str) -> Result<[f64; 3]> {
|
||||
let values = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 3).ok_or_else(|| bad(CMD, format!("`{key}` must be three finite Lab numbers")))?;
|
||||
let mut result = [0.0; 3];
|
||||
for (out, value) in result.iter_mut().zip(values) {
|
||||
let n = value.as_f64().filter(|n| n.is_finite() && (*n as f32).is_finite()).ok_or_else(|| bad(CMD, format!("invalid `{key}` Lab number")))?;
|
||||
*out = n;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Apply the same Lab match, tone shift and selection mix to stored and implicit pixels.
|
||||
fn replace_sample(format: &PixelFormat, pixel: &mut [f32], sample: &[f32; 4], range: &selection::LabRange, fuzz: f32, shift: [f32; 3], selected: f32) -> bool {
|
||||
if sample[3].is_nan() || sample[3] <= 0.0 || selected <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let value = [sample[0] * 100.0, sample[1] * 255.0 - 128.0, sample[2] * 255.0 - 128.0];
|
||||
let coverage = if format.sample == SampleType::U8 {
|
||||
selection::lab_range_coverage_8bit(selection::quantize_lab8(value), range, fuzz)
|
||||
} else {
|
||||
selection::lab_range_coverage(value, range, fuzz)
|
||||
};
|
||||
if coverage <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let original = photocraft_raster::to_rgba(format, pixel);
|
||||
let mut edited = [original];
|
||||
tone::replace_color_masked(&mut edited, &[coverage], shift[0], shift[1], shift[2]);
|
||||
// Unchanged native samples must not take an incidental RGB roundtrip.
|
||||
if edited[0] == original {
|
||||
return false;
|
||||
}
|
||||
let mut mixed = std::array::from_fn(|c| original[c] * (1.0 - selected) + edited[0][c] * selected);
|
||||
mixed[3] = original[3];
|
||||
photocraft_raster::from_rgba_into(format, mixed, pixel);
|
||||
true
|
||||
}
|
||||
|
||||
/// A zero-default selection or an unchanged source default needs only stored tiles.
|
||||
/// Otherwise retain the full edit rectangle, subject to the existing adjustment storage budget.
|
||||
fn work_tiles(surface: &Surface, selected: Option<&Surface>, bounds: Rect, default_changes: bool) -> Result<Option<Vec<TileCoord>>> {
|
||||
let sparse = selected.filter(|mask| mask.default_pixel().first() == Some(&0.0)).or_else(|| (!default_changes).then_some(surface));
|
||||
if let Some(sparse) = sparse {
|
||||
let coords = || sparse.tiles().map(|(tile, _)| *tile).filter(|tile| !tile.rect().intersect(&bounds).is_empty());
|
||||
let mut tiles = crate::allocation::capacity(coords().count(), "Replace Color tile coordinates")?;
|
||||
tiles.extend(coords());
|
||||
return Ok(Some(tiles));
|
||||
}
|
||||
let stored = surface.tiles().filter(|(tile, _)| !tile.rect().intersect(&bounds).is_empty()).count();
|
||||
crate::pixels::check_adjustment_tile_budget(bounds, stored, &surface.format()).map_err(EngineError::Other)?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Apply explicit recorded bounds; `None` leaves the legacy RGB/sample path alone.
|
||||
/// Matching converts original surface channels through their document profile,
|
||||
/// before the existing RGBA tone shift can clip or change their colour space.
|
||||
pub(super) fn apply(s: &mut Session, p: &Value) -> Result<Option<Value>> {
|
||||
if p.get("labMinimum").is_none() && p.get("labMaximum").is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
if ["color", "colors", "points", "subtractPoints", "order", "localized", "range", "channel", "targetChannel"].iter().any(|key| p.get(*key).is_some()) {
|
||||
return Err(bad(CMD, "Lab bounds cannot be combined with RGB samples, localized settings or channel targets"));
|
||||
}
|
||||
if p.get("target").is_some_and(|t| t.as_str() != Some("pixels")) || crate::channel_cmds::edits_channel(s) || crate::color_channels::target(s, p)?.is_some()
|
||||
{
|
||||
return Err(bad(CMD, "Lab bounds require the composite pixel-layer target"));
|
||||
}
|
||||
let (lo, hi) = (bound(p, "labMinimum")?, bound(p, "labMaximum")?);
|
||||
if lo.iter().zip(hi).any(|(lo, hi)| *lo > hi) {
|
||||
return Err(bad(CMD, "Lab minimum must not exceed maximum"));
|
||||
}
|
||||
let mut range = selection::LabRange { lo: lo.map(|v| v as f32), hi: hi.map(|v| v as f32) };
|
||||
// Validate supplied shifts before the edit rather than silently defaulting.
|
||||
for key in ["fuzziness", "hue", "saturation", "lightness"] {
|
||||
if p.get(key).is_some_and(|v| !v.as_f64().is_some_and(f64::is_finite)) {
|
||||
return Err(bad(CMD, format!("`{key}` must be a finite number")));
|
||||
}
|
||||
}
|
||||
let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0);
|
||||
let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0));
|
||||
let id = crate::commands::layer_param(s, p)?;
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
super::super::enabled::check_pixels_unlocked(&st.doc, id)?;
|
||||
let surface = st.doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| bad(CMD, "Lab bounds require a pixel layer"))?;
|
||||
let format = surface.format();
|
||||
if format.mode != st.doc.mode {
|
||||
return Err(bad(CMD, "pixel layout does not match the document profile"));
|
||||
}
|
||||
let transform = photocraft_cms::cached(&crate::color_cmds::document_profile(&st.doc), Builtin::LabD50.profile(), TransformOptions::default())
|
||||
.map_err(|e| bad(CMD, format!("Lab colour transform: {e}")))?;
|
||||
if transform.inputs() != format.mode.color_channels() || transform.outputs() != 3 || format.channels() != transform.inputs() + usize::from(format.alpha) {
|
||||
return Err(bad(CMD, "unsupported Lab colour-transform layout"));
|
||||
}
|
||||
let grid = format.sample == SampleType::U8;
|
||||
if grid {
|
||||
range.lo = selection::quantize_lab8(range.lo);
|
||||
range.hi = selection::quantize_lab8(range.hi);
|
||||
}
|
||||
if h == 0.0 && sat == 0.0 && l == 0.0 {
|
||||
// Validation still runs, but a zero shift must not re-separate native inks.
|
||||
return Ok(Some(Value::Null));
|
||||
}
|
||||
s.edit("Replace Color", |doc, _| {
|
||||
let canvas = doc.bounds();
|
||||
let selected = doc.selection.clone();
|
||||
let surface = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
let transparent_default = format.alpha && surface.default_pixel().last().is_some_and(|a| a.is_nan() || *a <= 0.0);
|
||||
let content = surface.content_bounds();
|
||||
// A nontransparent default is visible across the canvas even without stored tiles.
|
||||
let extent = if transparent_default { content } else { content.union(&canvas) };
|
||||
let bounds = edit_bounds(extent, selected.as_ref());
|
||||
let mut default = surface.default_pixel();
|
||||
let mut default_lab = [[0.0, 0.0, 0.0, 1.0]];
|
||||
transform.convert_f32(&default, format.channels(), default_lab.as_flattened_mut(), 4, true);
|
||||
let default_selected = selected.as_ref().map_or(1.0, |mask| mask.default_pixel()[0]);
|
||||
let default_changes = replace_sample(&format, &mut default, &default_lab[0], &range, fuzz, [h, sat, l], default_selected);
|
||||
let tiles = work_tiles(surface, selected.as_ref(), bounds, default_changes)?;
|
||||
// Matching and tone changes are pointwise, so pixel work stays tile-sized.
|
||||
let mut process_tile = |tile: TileCoord| -> Result<()> {
|
||||
let rect = tile.rect().intersect(&bounds);
|
||||
if rect.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut raw = crate::allocation::read_region(surface, rect, "Replace Color")?;
|
||||
let mut lab = crate::allocation::filled(raw.len() / format.channels(), [0.0, 0.0, 0.0, 1.0], "Replace Color")?;
|
||||
transform.convert_f32(&raw, format.channels(), lab.as_flattened_mut(), 4, true);
|
||||
let width = rect.width() as usize;
|
||||
let mut selection_row = crate::allocation::filled(width, 1.0, "Replace Color")?;
|
||||
let mut changed = false;
|
||||
for (y, (row, lab_row)) in raw.chunks_exact_mut(width * format.channels()).zip(lab.chunks_exact(width)).enumerate() {
|
||||
if let Some(selected) = selected.as_ref() {
|
||||
selected.sample_row_strided(rect.y0.saturating_add(y as i32), rect.x0, 1, 0, &mut selection_row);
|
||||
}
|
||||
for ((pixel, sample), selected) in row.chunks_exact_mut(format.channels()).zip(lab_row).zip(&selection_row) {
|
||||
changed |= replace_sample(&format, pixel, sample, &range, fuzz, [h, sat, l], *selected);
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
surface.try_write_region(rect, &raw).map_err(|e| crate::allocation::error("Replace Color", &e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Some(tiles) = tiles {
|
||||
for tile in tiles {
|
||||
process_tile(tile)?;
|
||||
}
|
||||
} else {
|
||||
for tile in bounds.tiles() {
|
||||
process_tile(tile)?;
|
||||
}
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::PixelFormat;
|
||||
|
||||
/// Work buffers follow explicit selected pixels without cropping implicitly selected coverage.
|
||||
#[test]
|
||||
fn selected_work_bounds_preserve_implicit_selection_and_clip_explicit_pixels() {
|
||||
let content = Rect::new(-100, -100, 100, 100);
|
||||
let mut mask = Surface::new(PixelFormat::GRAY8);
|
||||
assert_eq!(edit_bounds(content, Some(&mask)), Rect::EMPTY);
|
||||
mask.fill_rect(Rect::new(98, 96, 110, 105), &[0.5]);
|
||||
assert_eq!(edit_bounds(content, Some(&mask)), Rect::new(98, 96, 100, 100));
|
||||
assert_eq!(edit_bounds(content, None), content);
|
||||
for coverage in [0.5, 1.0] {
|
||||
let mut mask = Surface::with_default(PixelFormat::GRAY8, &[coverage]);
|
||||
mask.fill_rect(Rect::new(2, 2, 3, 3), &[0.0]);
|
||||
assert_eq!(edit_bounds(content, Some(&mask)), content, "stored holes do not bound selected pixels");
|
||||
}
|
||||
}
|
||||
|
||||
/// The work list follows stored source or selection tiles rather than their distant bounds.
|
||||
#[test]
|
||||
fn sparse_work_tiles_follow_pixels_that_can_change() {
|
||||
let near = Rect::new(0, 0, 1, 1);
|
||||
let far = Rect::new(100_000, 100_000, 100_001, 100_001);
|
||||
let bounds = near.union(&far);
|
||||
for alpha in [false, true] {
|
||||
let format = PixelFormat { alpha, ..PixelFormat::RGBA8 };
|
||||
let mut surface = Surface::with_default(format, &[1.0; 4][..format.channels()]);
|
||||
surface.fill_rect(far, &[0.0; 4][..format.channels()]);
|
||||
assert_eq!(work_tiles(&surface, None, bounds, false).unwrap(), Some(vec![TileCoord::containing(far.x0, far.y0)]));
|
||||
let mut mask = Surface::new(PixelFormat::GRAY8);
|
||||
mask.fill_rect(near, &[1.0]);
|
||||
mask.fill_rect(far, &[1.0]);
|
||||
assert_eq!(
|
||||
work_tiles(&surface, Some(&mask), bounds, true).unwrap(),
|
||||
Some(vec![TileCoord::containing(0, 0), TileCoord::containing(far.x0, far.y0)])
|
||||
);
|
||||
assert!(work_tiles(&surface, None, near, true).unwrap().is_none(), "a small default-only area remains editable");
|
||||
}
|
||||
}
|
||||
|
||||
/// A matching opaque default cannot allocate gap tiles beyond the existing adjustment budget.
|
||||
#[test]
|
||||
fn opaque_gap_budget_refuses_before_pixel_allocation_or_edit() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", serde_json::json!({"width":4,"height":2,"mode":"lab","depth":32,"background":"transparent"})).unwrap();
|
||||
s.edit("Distant opaque exception", |doc, active| {
|
||||
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
*surface = Surface::with_default(surface.format(), &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
|
||||
surface.fill_rect(Rect::new(100_000, 100_000, 100_001, 100_001), &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let history = s.active().unwrap().history.past_len();
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = s.execute(CMD, serde_json::json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100}));
|
||||
// Drain the probe even on failure so another test cannot inherit it. A skipped probe
|
||||
// proves that the resource check ran before any fallible tile-buffer allocation.
|
||||
let probe_unused = crate::allocation::capacity::<u8>(0, "allocation probe").is_err();
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("too large an area")), "{result:?}");
|
||||
assert!(probe_unused);
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
}
|
||||
@@ -301,22 +301,38 @@ fn new_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
|
||||
}
|
||||
|
||||
/// Convert a plain group at its content bounds; recorded guide IDs are ignored session metadata.
|
||||
fn artboard_from_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "layer.new.artboardFromGroup";
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = p.get("layer").and_then(Value::as_u64).map(LayerId).or(d.active_layer).ok_or(EngineError::Other("no active layer".into()))?;
|
||||
let id = crate::commands::layer_param(s, p)?;
|
||||
let preset = match p.get("preset") {
|
||||
None => String::new(),
|
||||
Some(value) => value.as_str().ok_or_else(|| bad(cmd, "preset must be text"))?.to_string(),
|
||||
};
|
||||
let apply_background = match p.get("changeBackground") {
|
||||
None | Some(Value::Bool(true)) => true,
|
||||
Some(value) => value.as_i64().and_then(|n| i32::try_from(n).ok()).ok_or_else(|| bad(cmd, "changeBackground must be an integer"))? == 1,
|
||||
};
|
||||
if let Some(guides) = p.get("guideIDs")
|
||||
&& !guides.as_array().is_some_and(|ids| ids.iter().all(|id| id.as_i64().is_some_and(|n| i32::try_from(n).is_ok())))
|
||||
{
|
||||
return Err(bad(cmd, "guideIDs must be a list of integers"));
|
||||
}
|
||||
let canvas = d.doc.bounds();
|
||||
let background = background_param(p, "layer.new.artboardFromGroup")?.unwrap_or_default();
|
||||
let background = if apply_background { background_param(p, cmd)?.unwrap_or_default() } else { ArtboardBackground::White };
|
||||
let rect = s.edit("Artboard from Group", |doc, active| {
|
||||
if !doc.layers.iter().any(|t| t.id == id && t.is_group() && t.artboard().is_none()) {
|
||||
return Err(EngineError::Other("artboards are made from a top-level layer group".into()));
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let rect = crate::layer_multi_cmds::layer_bounds(l).unwrap_or(canvas);
|
||||
set_artboard(l, Artboard { rect, background, preset: String::new() });
|
||||
set_artboard(l, Artboard { rect, background, preset });
|
||||
fit_canvas(doc);
|
||||
*active = Some(id);
|
||||
Ok(rect)
|
||||
})?;
|
||||
crate::layer_multi_cmds::set_selection(s, vec![id], Some(id), Some(id))?;
|
||||
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
|
||||
}
|
||||
|
||||
@@ -377,10 +393,7 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "layer.artboard.set";
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(v) => LayerId(v),
|
||||
None => active_artboard(s).ok_or_else(|| bad(cmd, "no artboard selected"))?,
|
||||
};
|
||||
let id = crate::commands::explicit_layer(s, p)?.or_else(|| active_artboard(s)).ok_or_else(|| bad(cmd, "no artboard selected"))?;
|
||||
let old = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?;
|
||||
let preset = p.get("preset").and_then(Value::as_str).map(str::to_string);
|
||||
let mut base = xywh(old.rect);
|
||||
@@ -441,10 +454,7 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn add_beside(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "layer.artboard.addBeside";
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(v) => LayerId(v),
|
||||
None => active_artboard(s).ok_or_else(|| bad(cmd, "no artboard selected"))?,
|
||||
};
|
||||
let id = crate::commands::explicit_layer(s, p)?.or_else(|| active_artboard(s)).ok_or_else(|| bad(cmd, "no artboard selected"))?;
|
||||
let src = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?;
|
||||
let side = p.get("side").and_then(Value::as_str).unwrap_or("right");
|
||||
let duplicate = p.get("duplicate").and_then(Value::as_bool).unwrap_or(false);
|
||||
@@ -703,7 +713,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"layer.new.artboardFromGroup",
|
||||
"Artboard from Group…",
|
||||
&["Layer", "New"],
|
||||
r##"{"layer":id? (a top-level group; default active),"background":…?} → {layer, rect}"##,
|
||||
r##"{"layer":id? (a top-level group; default active),"background":…?,"color":[r,g,b]|"#rrggbb"?,"preset":str? (tag only; does not resize),"guideIDs":[int]? (ignored recording metadata),"changeBackground":int? (1 or absent applies background/color; other integers give white; legacy true = 1)} → {layer, rect}"##,
|
||||
has_plain_group,
|
||||
artboard_from_group
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -359,7 +359,13 @@ fn gradient_map_from(d: &Descriptor) -> Option<Value> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Write the legacy gradient form without absorbing settings owned by a direct recorded event.
|
||||
fn gradient_map_to(p: &Value) -> Option<Descriptor> {
|
||||
// A recorded GrMp has its own strict converter. Never fall back to the older
|
||||
// event if its origin/settings were changed into an unrepresentable form.
|
||||
if p.get(super::steps_direct_adjustments::GRADIENT_ORIGIN).is_some() {
|
||||
return None;
|
||||
}
|
||||
let stops: Option<Vec<D>> = p
|
||||
.get("stops")?
|
||||
.as_array()?
|
||||
@@ -375,16 +381,7 @@ fn gradient_map_to(p: &Value) -> Option<Descriptor> {
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let transparency = D::List(vec![
|
||||
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))),
|
||||
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))),
|
||||
]);
|
||||
let grad = desc("Grdn")
|
||||
.with("Nm ", text("Custom"))
|
||||
.with("GrdF", enm("GrdF", "CstS"))
|
||||
.with("Intr", D::Double(4096.0))
|
||||
.with("Clrs", D::List(stops?))
|
||||
.with("Trns", transparency);
|
||||
let grad = gradient_descriptor(stops?, "Custom");
|
||||
let mut d = desc("GdMp").with("Grad", D::Descriptor(grad));
|
||||
if let Some(r) = jbool(p, "reverse") {
|
||||
d = d.with("Rvrs", D::Boolean(r));
|
||||
@@ -395,6 +392,20 @@ fn gradient_map_to(p: &Value) -> Option<Descriptor> {
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// The classic, opaque gradient shared by direct and adjustment-layer mappings.
|
||||
pub(super) fn gradient_descriptor(stops: Vec<D>, name: &str) -> Descriptor {
|
||||
let transparency = D::List(vec![
|
||||
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))),
|
||||
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))),
|
||||
]);
|
||||
desc("Grdn")
|
||||
.with("Nm ", text(name))
|
||||
.with("GrdF", enm("GrdF", "CstS"))
|
||||
.with("Intr", D::Double(4096.0))
|
||||
.with("Clrs", D::List(stops))
|
||||
.with("Trns", transparency)
|
||||
}
|
||||
|
||||
/// An adjustment: its Photoshop class (usually also the direct event's code; Black & White and
|
||||
/// Photo Filter run by name), the command suffix (`image.adjustments.<suffix>`, `layer.newAdjustmentLayer.<suffix>`), and
|
||||
/// the converters between the adjustment descriptor and the command params.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly"), ("LghO", "lightenOnly"), ("Anst", "anisotropic")];
|
||||
use super::adjustments::{adjustment_by_class, adjustment_steps, levels_from, levels_to, shadows_highlights_from, shadows_highlights_to};
|
||||
use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target, ref_class};
|
||||
use super::descriptors::{
|
||||
class, code, desc, get_bool, get_obj, get_ref, jbool, layer_ref, layer_target, path_text, path_value, ref_class, target_ref, trimmed,
|
||||
};
|
||||
use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step};
|
||||
|
||||
use super::steps_artboard::{artboard_edit_from, artboard_edit_to, artboard_from, artboard_to};
|
||||
use super::steps_channels::{match_color_from, match_color_to, merge_spot_from, merge_spot_to, split_from, split_to, trap_from, trap_to};
|
||||
use super::steps_direct_adjustments::{gradient_from, gradient_to, replace_from, replace_to};
|
||||
use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, wave_from, wave_to};
|
||||
use super::steps_doc::{
|
||||
align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to,
|
||||
@@ -23,7 +26,7 @@ use super::steps_layer::{
|
||||
use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to};
|
||||
use super::steps_other::{DE_INTERLACE, HSB_HSL, bare_from, bare_to, custom_from, custom_to, enums_from, enums_to};
|
||||
use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM};
|
||||
use super::{AtnEvent, D, Descriptor, STOP_MESSAGE, Value, json};
|
||||
use super::{AtnEvent, D, Descriptor, Id, Map, ReferenceItem, STOP_MESSAGE, Value, json};
|
||||
|
||||
/// An event: code (empty when Photoshop only has a string name), string name, the panel
|
||||
/// label, the command ids it exports (the first is the one simple events import to), and the
|
||||
@@ -187,6 +190,162 @@ fn clipping_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
Some(desc("null").with("null", layer_ref(p)?))
|
||||
}
|
||||
|
||||
/// Check Spelling's `null`: the `spellCheck` property of all text layers.
|
||||
fn spell_check_ref() -> D {
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") },
|
||||
ReferenceItem::Enumerated { class: class("TxLr"), type_id: code("Ordn"), value: code("Al") },
|
||||
])
|
||||
}
|
||||
|
||||
/// Edit › Check Spelling (`spellCheck`): `null` is [`spell_check_ref`] and `Usng` holds only
|
||||
/// `checkAll`. Any other form stays a kept Photoshop step.
|
||||
fn spell_check_from(d: &Descriptor) -> Mapped {
|
||||
let same_ref = |r: &[ReferenceItem]| match r {
|
||||
[ReferenceItem::Property { class: p, key }, ReferenceItem::Enumerated { class: t, type_id, value }] => {
|
||||
trimmed(&p.class_id) == "Prpr" && key.is("spellCheck") && trimmed(&t.class_id) == "TxLr" && trimmed(type_id) == "Ordn" && trimmed(value) == "Al"
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
// Two keys, read by name: `null` and `Usng` in either order, each once.
|
||||
let using = get_obj(d, "Usng").filter(|u| u.class_id.is("spellCheck") && u.items.len() == 1);
|
||||
match (d.items.len(), get_ref(d, "null"), using.and_then(|u| get_bool(u, "checkAll"))) {
|
||||
(2, Some(r), Some(all)) if same_ref(r) => step("edit.checkSpelling", json!({"allLayers": all})),
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
}
|
||||
}
|
||||
|
||||
/// A Check Spelling step that holds nothing but `allLayers` (Check All Layers, on by default).
|
||||
fn spell_check_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
let params = p.as_object()?;
|
||||
if params.keys().any(|k| k != "allLayers") {
|
||||
return None;
|
||||
}
|
||||
let all = match params.get("allLayers") {
|
||||
None => true,
|
||||
Some(v) => v.as_bool()?,
|
||||
};
|
||||
Some(desc("null").with("null", spell_check_ref()).with("Usng", D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(all)))))
|
||||
}
|
||||
|
||||
// Adapter-only transport bit: the reference permits either no target key or the
|
||||
// current-layer target. Save must retain that distinction; native behavior is identical.
|
||||
const NO_TARGET: &str = "_atnNoTarget";
|
||||
|
||||
/// Accept only bare or current-layer targets, retaining their key-presence distinction for save.
|
||||
fn target_params(d: &Descriptor) -> Option<Map<String, Value>> {
|
||||
let mut p = Map::new();
|
||||
if d.items.is_empty() {
|
||||
p.insert(NO_TARGET.into(), Value::Bool(true));
|
||||
} else if d.items != desc("null").with("null", target_ref("Lyr ")).items {
|
||||
return None;
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Restore the bare or explicit current-layer descriptor selected by the private transport bit.
|
||||
fn target_descriptor(p: &Value) -> Option<Descriptor> {
|
||||
match p.get(NO_TARGET) {
|
||||
None => Some(desc("null").with("null", target_ref("Lyr "))),
|
||||
Some(Value::Bool(true)) => Some(desc("null")),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Native-parameter allowlist adapted from #10628; refuse unrepresented options on export.
|
||||
/// Refuse native options that the selected Photoshop form cannot represent.
|
||||
fn params(p: &Value, allowed: &[&str]) -> bool {
|
||||
p.as_object().is_some_and(|p| p.keys().all(|key| allowed.contains(&key.as_str())))
|
||||
}
|
||||
|
||||
/// Import a target-only event, refusing other targets and extra descriptor settings.
|
||||
fn target_only_from(d: &Descriptor, id: &str) -> Mapped {
|
||||
target_params(d).map_or(Mapped::Unsupported { harmless: false }, |p| step(id, Value::Object(p)))
|
||||
}
|
||||
|
||||
/// Export a current-target native step only when no unrepresented options are present.
|
||||
fn target_only_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if !params(p, &[NO_TARGET]) {
|
||||
return None;
|
||||
}
|
||||
target_descriptor(p)
|
||||
}
|
||||
|
||||
const SHAPE_BLUR: &[F] = &[F("Rds ", "radius", Kind::Px)];
|
||||
|
||||
/// Map the radius-only Shape Blur form; keep custom assets or extra keys unsupported.
|
||||
fn shape_blur_from(d: &Descriptor) -> Mapped {
|
||||
let unsupported = Mapped::Unsupported { harmless: false };
|
||||
let Some(p) = fields_from(d, SHAPE_BLUR) else { return unsupported };
|
||||
let p = Value::Object(p);
|
||||
if shape_blur_to("filter.blur.shapeBlur", &p).is_some_and(|expected| d.items == expected.items) { step("filter.blur.shapeBlur", p) } else { unsupported }
|
||||
}
|
||||
|
||||
/// Write an optional finite 5–1000 pixel radius, refusing explicit shapes and other options.
|
||||
fn shape_blur_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if !params(p, &["radius"]) {
|
||||
return None;
|
||||
}
|
||||
if let Some(radius) = p.get("radius") {
|
||||
let radius = radius.as_f64()?;
|
||||
if !radius.is_finite() || !(5.0..=1000.0).contains(&radius) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
fields_to(p, SHAPE_BLUR, desc("null"))
|
||||
}
|
||||
|
||||
// Recorded Link and Unlink need explicit state: replaying the UI toggle can
|
||||
// perform the opposite operation. Target matching follows #10628's adapter.
|
||||
/// Import Link or Unlink as an explicit operation rather than the state-dependent UI toggle.
|
||||
fn link_from(d: &Descriptor, linked: bool) -> Mapped {
|
||||
let Some(mut p) = target_params(d) else { return Mapped::Unsupported { harmless: false } };
|
||||
p.insert("linked".into(), Value::Bool(linked));
|
||||
step("layer.linkLayers", Value::Object(p))
|
||||
}
|
||||
|
||||
/// Export only the matching explicit link state and supported current-layer target form.
|
||||
fn link_to(p: &Value, linked: bool) -> Option<Descriptor> {
|
||||
if !params(p, &["linked", NO_TARGET]) || p.get("linked") != Some(&Value::Bool(linked)) {
|
||||
return None;
|
||||
}
|
||||
target_descriptor(p)
|
||||
}
|
||||
|
||||
/// Map canonical Smart Object file descriptors, refusing unrepresented paths, targets or keys.
|
||||
fn smart_path_from(d: &Descriptor, id: &str) -> Mapped {
|
||||
let unsupported = Mapped::Unsupported { harmless: false };
|
||||
let key = if id == "layer.smartObjects.convertToLinked" { "Usng" } else { "null" };
|
||||
let Some(path) = d.get(key).and_then(path_text) else { return unsupported };
|
||||
let p = json!({"path": path});
|
||||
let Some(mut expected) = smart_path_to(id, &p) else { return unsupported };
|
||||
if let Some(hint) = d.get("LyrI") {
|
||||
// The reference explicitly omits recording-session IDs on save (#10603,
|
||||
// docs/atn.md:200). They must never become PhotoCraft's playback target.
|
||||
if !matches!(id, "layer.smartObjects.relinkToFile" | "layer.smartObjects.replaceContents") || !matches!(hint, D::Integer(n) if *n > 0) {
|
||||
return unsupported;
|
||||
}
|
||||
expected = expected.with("LyrI", hint.clone());
|
||||
}
|
||||
// Normalize the top-level header as Open/Place do, but refuse unknown keys,
|
||||
// targets and path encodings rather than partly replaying them.
|
||||
if d.items == expected.items { step(id, p) } else { unsupported }
|
||||
}
|
||||
|
||||
/// Export path-only native parameters using the existing Open/Place path encoding.
|
||||
fn smart_path_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let p = p.as_object()?;
|
||||
let path = p.get("path")?.as_str()?;
|
||||
if p.len() != 1 || path.is_empty() || path.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
Some(if id == "layer.smartObjects.convertToLinked" {
|
||||
desc("null").with("null", target_ref("Lyr ")).with("Usng", path_value(path))
|
||||
} else {
|
||||
desc("null").with("null", path_value(path))
|
||||
})
|
||||
}
|
||||
|
||||
/// A clipping mask event: `clipping_from` / `clipping_to` with one command id.
|
||||
macro_rules! clipping_event {
|
||||
($code:literal, $name:literal, $label:literal, $id:literal) => {
|
||||
@@ -201,6 +360,12 @@ macro_rules! selection_event {
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! target_event {
|
||||
($name:literal, $label:literal, $id:literal) => {
|
||||
Event { code: "", name: $name, label: $label, ids: &[$id], from: |d| target_only_from(d, $id), to: target_only_to }
|
||||
};
|
||||
}
|
||||
|
||||
/// Every Photoshop event PhotoCraft maps. The order matters only for the panel label.
|
||||
pub(super) const EVENTS: &[Event] = &[
|
||||
selection_event!("AddT", "addTo", "Add to Selection", &["select.rect"], |d| super::steps_selection::selection_from(d, "add"), |_, p| {
|
||||
@@ -353,6 +518,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]),
|
||||
ev!("", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]),
|
||||
ev!("", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]),
|
||||
Event { code: "", name: "shapeBlur", label: "Shape Blur", ids: &["filter.blur.shapeBlur"], from: shape_blur_from, to: shape_blur_to },
|
||||
ev!(
|
||||
"SmrB",
|
||||
"smartBlur",
|
||||
@@ -518,7 +684,9 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"),
|
||||
adjustment_event!("", "BanW", "blackAndWhite", "Black & White", "image.adjustments.blackWhite"),
|
||||
adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"),
|
||||
Event { code: "GrMp", name: "", label: "Gradient Map", ids: &["image.adjustments.gradientMap"], from: gradient_from, to: gradient_to },
|
||||
adjustment_event!("", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"),
|
||||
Event { code: "RplC", name: "replaceColor", label: "Replace Color", ids: &["image.adjustments.replaceColor"], from: replace_from, to: replace_to },
|
||||
adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"),
|
||||
Event { code: "", name: "matchColor", label: "Match Color", ids: &["image.adjustments.matchColor"], from: match_color_from, to: match_color_to },
|
||||
ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]),
|
||||
@@ -595,11 +763,39 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
},
|
||||
Event { code: "Dplc", name: "duplicate", label: "Duplicate", ids: &["layer.duplicate", "image.duplicate"], from: duplicate_from, to: duplicate_to },
|
||||
// Layers.
|
||||
target_event!("reverse", "Reverse", "layer.arrange.reverse"),
|
||||
target_event!("selectLinkedLayers", "Select Linked Layers", "layer.selectLinkedLayers"),
|
||||
target_event!("disableLayerStyle", "Clear Layer Style", "layer.layerStyle.clear"),
|
||||
target_event!("rasterizeAll", "Rasterize All Layers", "layer.rasterize.allLayers"),
|
||||
target_event!("addBlankVideoLayer", "New Blank Video Layer", "layer.videoLayers.newBlankVideoLayer"),
|
||||
target_event!("isolateLayers", "Isolate Layers", "select.isolateLayers"),
|
||||
target_event!("textLoadDefaultTypeStyles", "Load Default Type Styles", "type.loadDefaultTypeStyles"),
|
||||
target_event!("textSaveDefaultTypeStyles", "Save Default Type Styles", "type.saveDefaultTypeStyles"),
|
||||
target_event!("recordMeasurements", "Record Measurements", "image.analysis.recordMeasurements"),
|
||||
target_event!("CropPhotosAuto0001", "Crop and Straighten Photos", "file.automate.cropAndStraightenPhotos"),
|
||||
Event {
|
||||
code: "",
|
||||
name: "artboardFromLayerGroupEvent",
|
||||
label: "Artboard from Group",
|
||||
ids: &["layer.new.artboardFromGroup"],
|
||||
from: artboard_from,
|
||||
to: artboard_to,
|
||||
},
|
||||
Event { code: "", name: "editArtboardEvent", label: "Edit Artboard", ids: &["layer.artboard.set"], from: artboard_edit_from, to: artboard_edit_to },
|
||||
Event {
|
||||
code: "",
|
||||
name: "unlinkSelectedLayers",
|
||||
label: "Unlink Layers",
|
||||
ids: &["layer.linkLayers"],
|
||||
from: |d| link_from(d, false),
|
||||
to: |_, p| link_to(p, false),
|
||||
},
|
||||
Event {
|
||||
code: "Mk ",
|
||||
name: "make",
|
||||
label: "Make",
|
||||
ids: &[
|
||||
"type.convertToShape",
|
||||
"layer.new.layer",
|
||||
"layer.new.group",
|
||||
"layer.groupLayers",
|
||||
@@ -714,6 +910,42 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]),
|
||||
ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]),
|
||||
ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]),
|
||||
target_event!("placedLayerMakeCopy", "New Smart Object via Copy", "layer.smartObjects.newSmartObjectViaCopy"),
|
||||
target_event!("placedLayerConvertToLayers", "Convert to Layers", "layer.smartObjects.convertToLayers"),
|
||||
target_event!("placedLayerConvertToEmbedded", "Convert to Embedded", "layer.smartObjects.convertToEmbedded"),
|
||||
target_event!("placedLayerUpdateAllModified", "Update All Modified Content", "layer.smartObjects.updateAllModifiedContent"),
|
||||
Event {
|
||||
code: "",
|
||||
name: "placedLayerExportContents",
|
||||
label: "Export Contents",
|
||||
ids: &["layer.smartObjects.exportContents"],
|
||||
from: |d| smart_path_from(d, "layer.smartObjects.exportContents"),
|
||||
to: smart_path_to,
|
||||
},
|
||||
Event {
|
||||
code: "",
|
||||
name: "placedLayerConvertToLinked",
|
||||
label: "Convert to Linked",
|
||||
ids: &["layer.smartObjects.convertToLinked"],
|
||||
from: |d| smart_path_from(d, "layer.smartObjects.convertToLinked"),
|
||||
to: smart_path_to,
|
||||
},
|
||||
Event {
|
||||
code: "",
|
||||
name: "placedLayerRelinkToFile",
|
||||
label: "Relink to File",
|
||||
ids: &["layer.smartObjects.relinkToFile"],
|
||||
from: |d| smart_path_from(d, "layer.smartObjects.relinkToFile"),
|
||||
to: smart_path_to,
|
||||
},
|
||||
Event {
|
||||
code: "",
|
||||
name: "placedLayerReplaceContents",
|
||||
label: "Replace Contents",
|
||||
ids: &["layer.smartObjects.replaceContents"],
|
||||
from: |d| smart_path_from(d, "layer.smartObjects.replaceContents"),
|
||||
to: smart_path_to,
|
||||
},
|
||||
Event { code: "Hd ", name: "hide", label: "Hide", ids: &["layer.hideLayers"], from: |d| visibility_from(d, "layer.hideLayers"), to: visibility_to },
|
||||
Event {
|
||||
code: "Shw ",
|
||||
@@ -841,6 +1073,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
},
|
||||
Event { code: "Rset", name: "reset", label: "Reset", ids: &["tools.defaultColors"], from: reset_from, to: reset_to },
|
||||
ev!("Exch", "exchange", "Exchange", "tools.swapColors", &[]),
|
||||
Event { code: "", name: "spellCheck", label: "Check Spelling", ids: &["edit.checkSpelling"], from: spell_check_from, to: spell_check_to },
|
||||
Event {
|
||||
code: "",
|
||||
name: "assignProfile",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Measured Artboard conversion and background-only Properties edits.
|
||||
|
||||
use super::descriptors::{desc, get_num, get_obj, get_text, target_ref, text};
|
||||
use super::fields::{Mapped, none, step};
|
||||
use super::{D, Descriptor, Value, json};
|
||||
|
||||
const FROM_GROUP: &str = "layer.new.artboardFromGroup";
|
||||
const EDIT: &str = "layer.artboard.set";
|
||||
|
||||
/// Keep recorded colors and ignored guide IDs, including an omitted background type.
|
||||
fn settings_from(board: &Descriptor, preset_key: &str) -> Option<Value> {
|
||||
let color = get_obj(board, "Clr ")?;
|
||||
let preset = get_text(board, "artboardPresetName")?;
|
||||
let background = match board.get("artboardBackgroundType") {
|
||||
None | Some(D::Integer(1)) => "white",
|
||||
Some(D::Integer(2)) => "black",
|
||||
Some(D::Integer(3)) => "transparent",
|
||||
Some(D::Integer(4)) => "custom",
|
||||
_ => return None,
|
||||
};
|
||||
let Some(D::List(guides)) = board.get("guideIDs") else { return None };
|
||||
let guides: Vec<i32> = guides.iter().map(|v| if let D::Integer(n) = v { Some(*n) } else { None }).collect::<Option<_>>()?;
|
||||
let recorded: Vec<f64> = ["Rd ", "Grn ", "Bl "].iter().map(|key| get_num(color, key)).collect::<Option<_>>()?;
|
||||
let rgb: Vec<f64> = recorded.iter().map(|n| n / 255.0).collect();
|
||||
let mut p = json!({"background": background, "color": rgb, "guideIDs": guides});
|
||||
if recorded.iter().zip(&rgb).any(|(recorded, native)| *recorded != native * 255.0) {
|
||||
p["recordedColor"] = json!(recorded);
|
||||
}
|
||||
p[preset_key] = preset.into();
|
||||
if board.get("artboardBackgroundType").is_none() {
|
||||
p["backgroundTypeOmitted"] = true.into();
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Rebuild only settings whose native meaning still agrees with the saved wire form.
|
||||
fn settings_to(p: &Value, preset_key: &str) -> Option<Descriptor> {
|
||||
let preset = p.get(preset_key)?.as_str()?;
|
||||
let background = match p.get("background")?.as_str()? {
|
||||
"white" => 1,
|
||||
"black" => 2,
|
||||
"transparent" => 3,
|
||||
"custom" => 4,
|
||||
_ => return None,
|
||||
};
|
||||
let guides = p.get("guideIDs")?.as_array()?.iter().map(|v| Some(D::Integer(i32::try_from(v.as_i64()?).ok()?))).collect::<Option<Vec<_>>>()?;
|
||||
let rgb = p.get("color")?.as_array()?;
|
||||
if rgb.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let rgb: Vec<f64> = rgb.iter().map(Value::as_f64).collect::<Option<_>>()?;
|
||||
if !rgb.iter().all(|n| n.is_finite() && (0.0..=1.0).contains(n)) {
|
||||
return None;
|
||||
}
|
||||
let recorded = match p.get("recordedColor") {
|
||||
None => rgb.iter().map(|n| n * 255.0).collect(),
|
||||
Some(value) => {
|
||||
let values = value.as_array()?.iter().map(Value::as_f64).collect::<Option<Vec<_>>>()?;
|
||||
if values.len() != 3 || values.iter().zip(&rgb).any(|(wire, native)| !(0.0..=255.0).contains(wire) || wire / 255.0 != *native) {
|
||||
return None;
|
||||
}
|
||||
values
|
||||
}
|
||||
};
|
||||
let color = ["Rd ", "Grn ", "Bl "].into_iter().zip(recorded).fold(desc("RGBC"), |d, (key, n)| d.with(key, D::Double(n)));
|
||||
let board = desc("artboard").with("guideIDs", D::List(guides)).with("artboardPresetName", text(preset)).with("Clr ", D::Descriptor(color));
|
||||
match p.get("backgroundTypeOmitted") {
|
||||
None => Some(board.with("artboardBackgroundType", D::Integer(background))),
|
||||
Some(Value::Bool(true)) if background == 1 => Some(board),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Import target-only conversion or complete recorded settings, preserving flag absence and values.
|
||||
pub(super) fn artboard_from(d: &Descriptor) -> Mapped {
|
||||
let unsupported = Mapped::Unsupported { harmless: false };
|
||||
if d.items == desc("null").with("null", target_ref("Lyr ")).items {
|
||||
return none(FROM_GROUP);
|
||||
}
|
||||
let Some(mut p) = get_obj(d, "artboard").and_then(|b| settings_from(b, "preset")) else { return unsupported };
|
||||
match d.get("changeBackground") {
|
||||
None => {}
|
||||
Some(D::Integer(n)) => p["changeBackground"] = (*n).into(),
|
||||
_ => return unsupported,
|
||||
}
|
||||
if artboard_to(FROM_GROUP, &p).is_some_and(|expected| expected.items == d.items) { step(FROM_GROUP, p) } else { unsupported }
|
||||
}
|
||||
|
||||
/// Fill behavior-neutral metadata for native settings; unknown options must not disappear.
|
||||
pub(super) fn artboard_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
let fields = p.as_object()?;
|
||||
let d = desc("null").with("null", target_ref("Lyr "));
|
||||
if fields.is_empty() {
|
||||
return Some(d);
|
||||
}
|
||||
if fields.keys().any(|k| !["background", "color", "recordedColor", "preset", "guideIDs", "changeBackground", "backgroundTypeOmitted"].contains(&k.as_str()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut settings = p.clone();
|
||||
let fields = settings.as_object_mut()?;
|
||||
if !fields.contains_key("color") && fields.get("background").and_then(Value::as_str) == Some("custom") {
|
||||
fields.insert("background".into(), json!("white"));
|
||||
}
|
||||
let background = if fields.contains_key("color") { "custom" } else { "white" };
|
||||
// Native custom-without-color means white. Other types ignore this last-used color.
|
||||
for (key, value) in [("background", json!(background)), ("color", json!([1.0, 1.0, 1.0])), ("preset", json!("")), ("guideIDs", json!([]))] {
|
||||
fields.entry(key).or_insert(value);
|
||||
}
|
||||
let d = d.with("artboard", D::Descriptor(settings_to(&settings, "preset")?));
|
||||
match p.get("changeBackground") {
|
||||
None => Some(d),
|
||||
// Older native action files used true for the recorded integer 1.
|
||||
Some(Value::Bool(true)) => Some(d.with("changeBackground", D::Integer(1))),
|
||||
Some(value) => Some(d.with("changeBackground", D::Integer(i32::try_from(value.as_i64()?).ok()?))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Background-only edits retain recorded geometry as metadata, never as native resize parameters.
|
||||
pub(super) fn artboard_edit_from(d: &Descriptor) -> Mapped {
|
||||
let unsupported = Mapped::Unsupported { harmless: false };
|
||||
let Some(board) = get_obj(d, "artboard") else { return unsupported };
|
||||
let Some(rect) = get_obj(board, "artboardRect") else { return unsupported };
|
||||
let Some(mut p) = settings_from(board, "recordedPreset") else { return unsupported };
|
||||
let Some(edges) = ["Top ", "Left", "Btom", "Rght"].iter().map(|key| get_num(rect, key)).collect::<Option<Vec<_>>>() else { return unsupported };
|
||||
p["recordedRect"] = json!(edges);
|
||||
p["changeSizes"] = 8.into();
|
||||
p["changeBackground"] = 1.into();
|
||||
if artboard_edit_to(EDIT, &p).is_some_and(|expected| expected.items == d.items) { step(EDIT, p) } else { unsupported }
|
||||
}
|
||||
|
||||
/// Export only the measured background-edit flags and exact recorded rectangle, without quantization.
|
||||
pub(super) fn artboard_edit_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
let fields = p.as_object()?;
|
||||
if fields.keys().any(|k| {
|
||||
!["background", "color", "recordedColor", "recordedPreset", "guideIDs", "recordedRect", "changeSizes", "changeBackground", "backgroundTypeOmitted"]
|
||||
.contains(&k.as_str())
|
||||
}) || p.get("changeSizes")?.as_i64()? != 8
|
||||
|| p.get("changeBackground")?.as_i64()? != 1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let edges = p.get("recordedRect")?.as_array()?;
|
||||
if edges.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let rect = ["Top ", "Left", "Btom", "Rght"]
|
||||
.into_iter()
|
||||
.zip(edges)
|
||||
.try_fold(desc("classFloatRect"), |d, (key, value)| Some(d.with(key, D::Double(value.as_f64().filter(|v| v.is_finite())?))))?;
|
||||
let mut board = desc("artboard").with("artboardRect", D::Descriptor(rect));
|
||||
board.items.extend(settings_to(p, "recordedPreset")?.items);
|
||||
Some(
|
||||
desc("null")
|
||||
.with("null", target_ref("Lyr "))
|
||||
.with("artboard", D::Descriptor(board))
|
||||
.with("changeSizes", D::Integer(8))
|
||||
.with("changeBackground", D::Integer(1)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Direct GrMp and RplC forms measured in #10603 (Group E).
|
||||
use super::adjustments::gradient_descriptor;
|
||||
use super::descriptors::{desc, enm, get_list, get_num, get_obj};
|
||||
use super::events::pixel_target;
|
||||
use super::fields::{Mapped, step};
|
||||
use super::{D, Descriptor, Value, json};
|
||||
|
||||
/// Typed source values needed to retain the direct event and fractional RGB on save.
|
||||
/// Execution uses ordinary numeric RGB stops, never a stored descriptor.
|
||||
pub(super) const GRADIENT_ORIGIN: &str = "__atnGrMp";
|
||||
|
||||
/// Refuse extra native settings instead of exporting an incomplete edit.
|
||||
fn only_params(p: &Value, allowed: &[&str]) -> bool {
|
||||
p.as_object().is_some_and(|p| p.keys().all(|k| allowed.contains(&k.as_str())))
|
||||
}
|
||||
|
||||
/// Read exactly three channels; callers enforce their colour-space limits.
|
||||
fn triple(p: &Value) -> Option<[f64; 3]> {
|
||||
let a = p.as_array().filter(|a| a.len() == 3)?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
|
||||
}
|
||||
|
||||
/// Import only a descriptor the native stop model and the strict writer retain.
|
||||
/// Comparing the reconstructed typed descriptor rejects dropped settings, unknown
|
||||
/// keys, duplicates, unsupported units/classes and lossy wire-type conversions.
|
||||
pub(super) fn gradient_from(d: &Descriptor) -> Mapped {
|
||||
let mapped = || {
|
||||
let g = get_obj(d, "Grad")?;
|
||||
let D::Text(name) = g.get("Nm ")? else { return None };
|
||||
let mut stops = Vec::new();
|
||||
let mut colors = Vec::new();
|
||||
for s in get_list(g, "Clrs").filter(|s| (2..=64).contains(&s.len()))? {
|
||||
let D::Descriptor(s) = s else { return None };
|
||||
let c = get_obj(s, "Clr ")?;
|
||||
let rgb = [get_num(c, "Rd ")?, get_num(c, "Grn ")?, get_num(c, "Bl ")?];
|
||||
colors.push(rgb);
|
||||
stops.push(json!([get_num(s, "Lctn")? / 4096.0, rgb.map(|v| v / 255.0)]));
|
||||
}
|
||||
let mut p = json!({"stops": stops, GRADIENT_ORIGIN: {"name": name.to_string_lossy(), "colors": colors}});
|
||||
if d.get("gradientsInterpolationMethod").is_none() {
|
||||
// The measured default is classic; retain omission as well as meaning.
|
||||
p[GRADIENT_ORIGIN]["omitInterpolation"] = json!(true);
|
||||
}
|
||||
for (key, param) in [("Rvrs", "reverse"), ("Dthr", "dither")] {
|
||||
if let Some(v) = d.get(key) {
|
||||
let D::Boolean(v) = v else { return None };
|
||||
p[param] = json!(v);
|
||||
}
|
||||
}
|
||||
(gradient_to("", &p)?.eq(d)).then_some(p)
|
||||
};
|
||||
mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("image.adjustments.gradientMap", p))
|
||||
}
|
||||
|
||||
/// Write the measured classic form. The exact source RGB values must still agree
|
||||
/// with the executed stops, so changing a stop never revives stale source data.
|
||||
pub(super) fn gradient_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if !pixel_target(p) || !only_params(p, &["stops", "reverse", "dither", "target", GRADIENT_ORIGIN]) {
|
||||
return None;
|
||||
}
|
||||
let origin = p.get(GRADIENT_ORIGIN)?;
|
||||
if !only_params(origin, &["name", "colors", "omitInterpolation"]) {
|
||||
return None;
|
||||
}
|
||||
let name = origin.get("name")?.as_str()?;
|
||||
let colors = origin.get("colors")?.as_array()?;
|
||||
let stops = p.get("stops")?.as_array().filter(|s| (2..=64).contains(&s.len()) && s.len() == colors.len())?;
|
||||
let mut encoded = Vec::with_capacity(stops.len());
|
||||
let mut previous = -1.0;
|
||||
for (stop, color) in stops.iter().zip(colors) {
|
||||
let a = stop.as_array().filter(|a| a.len() == 2)?;
|
||||
let position = a.first()?.as_f64()?;
|
||||
if !position.is_finite() || !(0.0..=1.0).contains(&position) || position < previous || (position * 4096.0).fract() != 0.0 {
|
||||
return None;
|
||||
}
|
||||
previous = position;
|
||||
let rgb = triple(color)?;
|
||||
if !rgb.iter().all(|v| v.is_finite() && (0.0..=255.0).contains(v)) || triple(a.get(1)?)? != rgb.map(|v| v / 255.0) {
|
||||
return None;
|
||||
}
|
||||
let rgb = desc("RGBC").with("Rd ", D::Double(rgb[0])).with("Grn ", D::Double(rgb[1])).with("Bl ", D::Double(rgb[2]));
|
||||
encoded.push(D::Descriptor(
|
||||
desc("Clrt")
|
||||
.with("Clr ", D::Descriptor(rgb))
|
||||
.with("Type", enm("Clry", "UsrS"))
|
||||
.with("Lctn", D::Integer((position * 4096.0) as i32))
|
||||
.with("Mdpn", D::Integer(50)),
|
||||
));
|
||||
}
|
||||
let mut d = desc("null");
|
||||
let omit = match origin.get("omitInterpolation") {
|
||||
None => false,
|
||||
Some(v) => v.as_bool()?,
|
||||
};
|
||||
if !omit {
|
||||
d = d.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Gcls"));
|
||||
}
|
||||
d = d.with("Grad", D::Descriptor(gradient_descriptor(encoded, name)));
|
||||
for (param, key) in [("reverse", "Rvrs"), ("dither", "Dthr")] {
|
||||
if let Some(v) = p.get(param) {
|
||||
d = d.with(key, D::Boolean(v.as_bool()?));
|
||||
}
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Retain source precision while requiring coordinates to stay finite in native f32.
|
||||
fn lab_bound(p: &Value) -> Option<[f64; 3]> {
|
||||
let values = triple(p)?;
|
||||
values.iter().all(|v| v.is_finite() && (*v as f32).is_finite()).then_some(values)
|
||||
}
|
||||
|
||||
/// Reject extra, duplicate or reordered keys that the strict writer cannot retain.
|
||||
fn ordered(d: &Descriptor, keys: &[&str]) -> bool {
|
||||
d.items.len() == keys.len() && d.items.iter().zip(keys).all(|((key, _), expected)| key.is(expected))
|
||||
}
|
||||
|
||||
/// Read the measured Lab encoding directly, without RGB clipping or type coercion.
|
||||
fn lab_from(v: &D) -> Option<[f64; 3]> {
|
||||
let D::Descriptor(d) = v else { return None };
|
||||
if !d.class_id.is("LbCl") || !d.name.0.is_empty() || !ordered(d, &["Lmnc", "A ", "B "]) {
|
||||
return None;
|
||||
}
|
||||
let values = d
|
||||
.items
|
||||
.iter()
|
||||
.map(|(_, v)| match v {
|
||||
D::Double(n) => Some(*n),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
lab_bound(&json!(values))
|
||||
}
|
||||
|
||||
/// RplC records the sampled D50 box directly. RGB conversion would lose both the
|
||||
/// box width and out-of-gamut coordinates, so pass its bounds to the native command.
|
||||
pub(super) fn replace_from(d: &Descriptor) -> Mapped {
|
||||
let mapped = || {
|
||||
if !d.class_id.is("null") || !d.name.0.is_empty() || !ordered(d, &["Fzns", "Mnm ", "Mxm ", "H ", "Strt", "Lght", "colorModel"]) {
|
||||
return None;
|
||||
}
|
||||
let (lo, hi) = (lab_from(d.get("Mnm ")?)?, lab_from(d.get("Mxm ")?)?);
|
||||
if lo.iter().zip(hi).any(|(lo, hi)| *lo > hi) || !matches!(d.get("colorModel"), Some(D::Integer(0))) {
|
||||
return None;
|
||||
}
|
||||
let mut p = json!({"labMinimum": lo, "labMaximum": hi});
|
||||
for (key, param, lo, hi) in
|
||||
[("Fzns", "fuzziness", 0, 200), ("H ", "hue", -180, 180), ("Strt", "saturation", -100, 100), ("Lght", "lightness", -100, 100)]
|
||||
{
|
||||
let D::Integer(n) = d.get(key)? else { return None };
|
||||
p[param] = json!((*n).clamp(lo, hi));
|
||||
}
|
||||
Some(p)
|
||||
};
|
||||
mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("image.adjustments.replaceColor", p))
|
||||
}
|
||||
|
||||
/// Emit all measured keys in order. Fractional native shifts have no integer ATN
|
||||
/// representation and are refused; an ordinary RGB-sample call is not a Lab box.
|
||||
pub(super) fn replace_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if !pixel_target(p) || !only_params(p, &["labMinimum", "labMaximum", "fuzziness", "hue", "saturation", "lightness", "target"]) {
|
||||
return None;
|
||||
}
|
||||
let (lo, hi) = (lab_bound(p.get("labMinimum")?)?, lab_bound(p.get("labMaximum")?)?);
|
||||
if lo.iter().zip(hi).any(|(lo, hi)| *lo > hi) {
|
||||
return None;
|
||||
}
|
||||
let lab = |v: [f64; 3]| D::Descriptor(desc("LbCl").with("Lmnc", D::Double(v[0])).with("A ", D::Double(v[1])).with("B ", D::Double(v[2])));
|
||||
let field = |key, default: f64, lo, hi| {
|
||||
let n = match p.get(key) {
|
||||
None => default,
|
||||
Some(v) => v.as_f64()?,
|
||||
};
|
||||
let n = n.is_finite().then_some(n)?.clamp(lo, hi);
|
||||
(n.fract() == 0.0).then_some(D::Integer(n as i32))
|
||||
};
|
||||
Some(
|
||||
desc("null")
|
||||
.with("Fzns", field("fuzziness", 40.0, 0.0, 200.0)?)
|
||||
.with("Mnm ", lab(lo))
|
||||
.with("Mxm ", lab(hi))
|
||||
.with("H ", field("hue", 0.0, -180.0, 180.0)?)
|
||||
.with("Strt", field("saturation", 0.0, -100.0, 100.0)?)
|
||||
.with("Lght", field("lightness", 0.0, -100.0, 100.0)?)
|
||||
.with("colorModel", D::Integer(0)),
|
||||
)
|
||||
}
|
||||
@@ -44,9 +44,12 @@ fn path_extension(path: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// `save`: a plain Save, or a Save As (`As` format, `In` destination, `Cpy` As a Copy).
|
||||
/// Only a destination that is a file of the recorded format imports: a folder (#5430), a missing
|
||||
/// or unknown format or a mismatched extension stays a kept Photoshop step, so playback stops
|
||||
/// before writing rather than saving over the played file (#7102).
|
||||
/// A destination that is a file of the recorded format imports with its path. "Save in
|
||||
/// directory" (`DIDr`, recorded when the suggested name was kept) imports as a folder step, so
|
||||
/// playback saves each document under its own name in that folder (#5305). Anything else (a
|
||||
/// missing or unknown format, a mismatched extension, a copy into a folder) stays a kept
|
||||
/// Photoshop step, so playback stops before writing rather than saving over the played file
|
||||
/// (#7102).
|
||||
pub(super) fn save_from(d: &Descriptor) -> Mapped {
|
||||
let unsupported = Mapped::Unsupported { harmless: false };
|
||||
if d.get("As ").is_none() && d.get("In ").is_none() {
|
||||
@@ -56,11 +59,16 @@ pub(super) fn save_from(d: &Descriptor) -> Mapped {
|
||||
// with a format that isn't a descriptor) the step is kept.
|
||||
let Some(format) = get_obj(d, "As ") else { return unsupported };
|
||||
let Some(path) = d.get("In ").and_then(path_text) else { return unsupported };
|
||||
let class = trimmed(&format.class_id);
|
||||
if get_bool(d, "DIDr") == Some(true) {
|
||||
return unsupported;
|
||||
// `In` is the folder alone. Save a Copy has no folder step: it stays kept.
|
||||
let Some(ext) = SAVE_FORMATS.iter().find(|(c, _)| *c == class).and_then(|(_, exts)| exts.first()) else { return unsupported };
|
||||
if get_bool(d, "Cpy ") == Some(true) {
|
||||
return unsupported;
|
||||
}
|
||||
return step("file.saveAs", json!({"folder": path, "extension": ext}));
|
||||
}
|
||||
let Some(ext) = path_extension(&path) else { return unsupported };
|
||||
let class = trimmed(&format.class_id);
|
||||
let Some((_, exts)) = SAVE_FORMATS.iter().find(|(c, _)| *c == class) else { return unsupported };
|
||||
if !exts.contains(&ext.as_str()) {
|
||||
return unsupported;
|
||||
@@ -72,7 +80,15 @@ pub(super) fn save_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
if id == "file.save" {
|
||||
return Some(desc("null"));
|
||||
}
|
||||
let path = jstr(p, "path")?;
|
||||
let Some(path) = jstr(p, "path").filter(|v| !v.is_empty()) else {
|
||||
// A Save As recorded with the document's own name: "save in directory" (#5305).
|
||||
let (folder, ext) = (jstr(p, "folder")?, jstr(p, "extension")?.to_ascii_lowercase());
|
||||
if id != "file.saveAs" {
|
||||
return None;
|
||||
}
|
||||
let (class, _) = SAVE_FORMATS.iter().find(|(_, exts)| exts.contains(&ext.as_str()))?;
|
||||
return Some(desc("null").with("As ", D::Descriptor(desc(class))).with("In ", path_value(folder)).with("DIDr", D::Boolean(true)));
|
||||
};
|
||||
let ext = path_extension(path)?;
|
||||
let (class, _) = SAVE_FORMATS.iter().find(|(_, exts)| exts.contains(&ext.as_str()))?;
|
||||
let d = desc("null").with("As ", D::Descriptor(desc(class))).with("In ", path_value(path));
|
||||
@@ -144,7 +160,8 @@ pub(super) fn export_from(d: &Descriptor) -> Mapped {
|
||||
let unsupported = Mapped::Unsupported { harmless: false };
|
||||
let Some(u) = get_obj(d, "Usng") else { return unsupported };
|
||||
let Some(path) = d.get("In ").and_then(path_text) else { return unsupported };
|
||||
let mut p = json!({"path": path});
|
||||
// "Save in directory": `In` is only the folder, and each document saves under its own name.
|
||||
let mut p = if get_bool(u, "DIDr") == Some(true) { json!({"folder": path}) } else { json!({"path": path}) };
|
||||
if let Some(f) = get_enum(u, "Fmt ") {
|
||||
match lookup(WEB_FORMATS, &f) {
|
||||
Some(f) => p["format"] = json!(f),
|
||||
@@ -171,8 +188,16 @@ pub(super) fn export_from(d: &Descriptor) -> Mapped {
|
||||
}
|
||||
|
||||
pub(super) fn export_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
let path = jstr(p, "path")?;
|
||||
// A step recorded with the document's own name keeps only its folder (#5305).
|
||||
let (path, in_folder) = match (jstr(p, "path").filter(|v| !v.is_empty()), jstr(p, "folder")) {
|
||||
(Some(path), _) => (path, false),
|
||||
(None, Some(folder)) => (folder, true),
|
||||
(None, None) => return None,
|
||||
};
|
||||
let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")).with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, jstr(p, "format").unwrap_or("png24"))?));
|
||||
if in_folder {
|
||||
u = u.with("DIDr", D::Boolean(true));
|
||||
}
|
||||
for (key, param, default) in [
|
||||
("Intr", "interlaced", false),
|
||||
("Trns", "transparency", true),
|
||||
|
||||
@@ -172,8 +172,16 @@ fn new_document_from_descriptor(d: &Descriptor) -> Option<Mapped> {
|
||||
/// New Snapshot's From: the full document, the layers merged, or the current layer.
|
||||
const SNAPSHOT_FROM: &[(&str, &str)] = &[("FllD", "fullDocument"), ("MrgL", "mergedLayers"), ("CrnL", "currentLayer")];
|
||||
|
||||
/// Import Make events only when their geometry and options can be represented faithfully.
|
||||
fn type_shape_descriptor() -> Descriptor {
|
||||
desc("null").with("null", class_ref("contentLayer")).with("Usng", target_ref("textLayer"))
|
||||
}
|
||||
|
||||
/// Import Make events only when their geometry and options can be represented faithfully,
|
||||
/// including the exact target-text-to-content-layer references.
|
||||
pub(super) fn make_from(d: &Descriptor) -> Mapped {
|
||||
if d.items == type_shape_descriptor().items {
|
||||
return none("type.convertToShape");
|
||||
}
|
||||
if let Some(mapped) = duplicate_layer_from_descriptor(d) {
|
||||
return mapped;
|
||||
}
|
||||
@@ -300,8 +308,12 @@ fn recorded_percent(opacity: f64) -> f64 {
|
||||
if (level - level.round()).abs() < 1e-6 && (whole * 255.0 / 100.0).round() == level.round() { whole } else { percent }
|
||||
}
|
||||
|
||||
/// Write a creation step, refusing native options the target descriptor cannot carry.
|
||||
/// Write a creation step, refusing native options the target descriptor cannot carry; Type to
|
||||
/// Shape exports only without native options.
|
||||
pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
if id == "type.convertToShape" {
|
||||
return p.as_object().filter(|p| p.is_empty()).map(|_| type_shape_descriptor());
|
||||
}
|
||||
let with_name = |cls: &str| {
|
||||
let mut u = desc(cls);
|
||||
if let Some(n) = jstr(p, "name") {
|
||||
|
||||
@@ -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::<Vec<_>>(), [0]);
|
||||
let (bytes, skipped) = export("S", &[action], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let saved = atn::parse(&bytes).unwrap();
|
||||
let saved = &saved.actions[0].items[0];
|
||||
assert_eq!(saved.event.id(), "spellCheck");
|
||||
assert!(saved.with_dialog);
|
||||
assert_eq!(saved.descriptor.as_ref().unwrap().to_bytes(), spell_check(all).to_bytes(), "checkAll {all}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_check_spelling_forms_stay_kept_steps_and_corrections_are_not_saved() {
|
||||
let event = event_for(&AtnEvent::name("spellCheck")).unwrap();
|
||||
let mut extra = spell_check(true);
|
||||
extra.items.push((Id::new("Fwd "), D::Boolean(true)));
|
||||
let mut other_target = spell_check(true);
|
||||
other_target.items[0].1 = D::Reference(vec![ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") }]);
|
||||
let mut other_class = spell_check(true);
|
||||
other_class.items[1].1 = D::Descriptor(desc("findReplace").with("checkAll", D::Boolean(true)));
|
||||
let mut more_using = spell_check(true);
|
||||
more_using.items[1].1 = D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(true)).with("Fwd ", D::Boolean(true)));
|
||||
let mut twice = spell_check(true);
|
||||
twice.items[1] = twice.items[0].clone();
|
||||
for d in [extra, other_target, other_class, more_using, twice, desc("null")] {
|
||||
assert!(matches!((event.from)(&d), Mapped::Unsupported { harmless: false }), "{d:?}");
|
||||
}
|
||||
for params in [
|
||||
json!({"action": "change", "start": 0, "end": 3, "replace": "The"}),
|
||||
json!({"action": "changeAll", "word": "teh", "replace": "the"}),
|
||||
json!({"allLayers": true, "layer": "Title"}),
|
||||
json!({"allLayers": "yes"}),
|
||||
] {
|
||||
assert!(export_item("edit.checkSpelling", ¶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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -345,7 +345,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Content-Aware Fill…",
|
||||
["Edit"],
|
||||
None,
|
||||
r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id?=active (the layer filled and sampled),"seed":u64=1}"##,
|
||||
r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id|name?=active (the layer filled and sampled),"seed":u64=1}"##,
|
||||
can_caf,
|
||||
content_aware_fill
|
||||
),
|
||||
|
||||
@@ -383,8 +383,8 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &
|
||||
use photocraft_algo::content_aware::fill_owned;
|
||||
// The workspace names the layer it opened on: after an Apply to a new layer that one is
|
||||
// active, but the fill still samples the original.
|
||||
let id = match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(l) => pixel_layer_id(s, LayerId(l)),
|
||||
let id = match crate::commands::explicit_layer(s, p)? {
|
||||
Some(layer) => pixel_layer_id(s, layer),
|
||||
None => pixel_layer(s),
|
||||
}
|
||||
.map_err(EngineError::Other)?;
|
||||
|
||||
@@ -226,6 +226,16 @@ fn blob_session(depth: u32) -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_fill_uses_a_named_nonactive_source_layer() {
|
||||
let mut s = blob_session(8);
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let name = s.active().unwrap().doc.layer(id).unwrap().name.clone();
|
||||
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
|
||||
let result = s.execute("edit.contentAwareFill", json!({"layer": name, "colorAdaptation": "none"})).unwrap();
|
||||
assert_eq!(result["layer"], id.0, "fill must use the recorded source rather than the active layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_fill_huge_area_neither_panics_nor_wraps() {
|
||||
let mut s = blob_session(8);
|
||||
|
||||
@@ -877,9 +877,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if all == d.doc.bounds() {
|
||||
return Ok(json!({"changed": false}));
|
||||
}
|
||||
// One History step named as in Photoshop, not the crop's "Crop".
|
||||
// One History step named as in Photoshop, not the crop's "Crop". The Background stays a
|
||||
// Background, unlike a Crop tool crop with Delete Cropped Pixels off (#10867).
|
||||
let p = json!({"x": all.x0, "y": all.y0, "width": all.width(), "height": all.height(), "deleteCroppedPixels": false});
|
||||
crate::analysis_cmds::compound(s, "Reveal All", |s| s.execute("image.crop", p))?;
|
||||
// The crop runs without `Session::execute`, so it passes the session's gate here, as a
|
||||
// command run on Reveal All's behalf would.
|
||||
if let Some(gate) = s.authorize {
|
||||
gate("image.crop", &p)?;
|
||||
}
|
||||
crate::analysis_cmds::compound(s, "Reveal All", |s| crate::image_cmds::crop_keeping_background(s, &p))?;
|
||||
Ok(json!({"changed": true}))
|
||||
}),
|
||||
spec!(
|
||||
|
||||
@@ -28,11 +28,16 @@ mod paths;
|
||||
mod processing;
|
||||
mod save;
|
||||
|
||||
pub use batch_run::OutputClaims;
|
||||
pub(crate) use batch_run::{BatchInput, batch_inputs, batch_keeps_step, batch_save_point, process_files};
|
||||
pub use batch_naming::plan as plan_batch_names;
|
||||
#[cfg(test)]
|
||||
pub(crate) use batch_run::{MAX_SUBFOLDER_DEPTH, list_images_tree};
|
||||
use batch_run::{batch, same_format};
|
||||
pub(crate) use batch_run::MAX_SUBFOLDER_DEPTH;
|
||||
use batch_run::batch;
|
||||
pub use batch_run::same_format as batch_same_format;
|
||||
use batch_run::same_format;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use batch_run::validate_flat_outputs;
|
||||
pub use batch_run::{BatchInput, OutputClaims, encode_batch_error_log, list_images_tree};
|
||||
pub(crate) use batch_run::{batch_inputs, batch_keeps_step, batch_save_point, process_files};
|
||||
pub use export::{LayerExportReport, SkippedExportLayer, bake_cube, export_layers_report_with, export_layers_with};
|
||||
use export::{color_lookup_tables, layers_to_files, push_unique};
|
||||
use info::file_info;
|
||||
@@ -46,8 +51,8 @@ use paths::is_file;
|
||||
#[cfg(test)]
|
||||
use paths::openable_name;
|
||||
pub use paths::{
|
||||
extension, holds, holds_at, is_template, layered_extension, list_images, losses_at, portable_device_name, same_file, saves_in_place, template_name,
|
||||
untitled_name,
|
||||
extension, holds, holds_at, is_template, layered_extension, list_images, losses_at, portable_device_name, same_destination, same_file, saves_in_place,
|
||||
template_name, untitled_name,
|
||||
};
|
||||
pub(crate) use paths::{file_name, forward_slashes, is_windows_device_name, join, read_file, sanitize, stem, write_file};
|
||||
use processing::{conditional_mode_change, fit_image, flatten_all_effects, flatten_all_masks, image_processor, load_files_into_stack};
|
||||
@@ -332,7 +337,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Batch…",
|
||||
&["File", "Automate"],
|
||||
None,
|
||||
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
|
||||
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; Save/Save As marks the result),"source":"folder|open"="folder" (open: run on live open documents),"input":folder|[paths] (folder source),"includeSubfolders":bool=false (up to 32 levels and 100000 files, outputs are flat under output),"output":folder (optional for open source),"format":"same|png|jpg|psd|tiff|…"="same","naming":string? (tokens: {name}/{NAME}/{Name}, {serial}/{serial:1..4}, {letter}/{LETTER}, {date} and date formats, {ext}/{EXT}; the naming template determines the whole filename),"startingSerial":1..4294967295=1,"stopOnError":bool=false,"errorLog":path? (blank disables it),"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]?} → {files, errors, stopped?, skipped?, errorLog?, documents?} (folder batches report per-file job progress and support cancellation)"##,
|
||||
native,
|
||||
batch
|
||||
),
|
||||
|
||||
@@ -9,9 +9,67 @@ fn bad(message: impl Into<String>) -> EngineError {
|
||||
|
||||
enum Part<'a> {
|
||||
Literal(&'a str),
|
||||
Name,
|
||||
Serial,
|
||||
Date,
|
||||
Name(NameCase),
|
||||
Serial(usize),
|
||||
Letter(bool),
|
||||
Date(DateFormat),
|
||||
Ext(bool),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum NameCase {
|
||||
AsIs,
|
||||
Upper,
|
||||
Title,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum DateFormat {
|
||||
Iso,
|
||||
Mmddyy,
|
||||
Mmdd,
|
||||
Yyyymmdd,
|
||||
Yymmdd,
|
||||
Yyddmm,
|
||||
Ddmmyy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Date {
|
||||
year: u32,
|
||||
month: u32,
|
||||
day: u32,
|
||||
}
|
||||
|
||||
impl Date {
|
||||
fn from_iso(value: &str) -> Option<Self> {
|
||||
Some(Self { year: value.get(..4)?.parse().ok()?, month: value.get(5..7)?.parse().ok()?, day: value.get(8..10)?.parse().ok()? })
|
||||
}
|
||||
|
||||
fn format(self, kind: DateFormat) -> String {
|
||||
let (year, month, day, short) = (self.year, self.month, self.day, self.year % 100);
|
||||
match kind {
|
||||
DateFormat::Iso => format!("{year:04}-{month:02}-{day:02}"),
|
||||
DateFormat::Mmddyy => format!("{month:02}{day:02}{short:02}"),
|
||||
DateFormat::Mmdd => format!("{month:02}{day:02}"),
|
||||
DateFormat::Yyyymmdd => format!("{year:04}{month:02}{day:02}"),
|
||||
DateFormat::Yymmdd => format!("{short:02}{month:02}{day:02}"),
|
||||
DateFormat::Yyddmm => format!("{short:02}{day:02}{month:02}"),
|
||||
DateFormat::Ddmmyy => format!("{day:02}{month:02}{short:02}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn letters(mut serial: u32, upper: bool) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
while serial > 0 {
|
||||
serial -= 1;
|
||||
let letter = b'a' + (serial % 26) as u8;
|
||||
bytes.push(if upper { letter.to_ascii_uppercase() } else { letter });
|
||||
serial /= 26;
|
||||
}
|
||||
bytes.reverse();
|
||||
String::from_utf8(bytes).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse(template: &str) -> Result<Vec<Part<'_>>> {
|
||||
@@ -25,10 +83,25 @@ fn parse(template: &str) -> Result<Vec<Part<'_>>> {
|
||||
}
|
||||
let end = rest.find('}').ok_or_else(|| bad("naming: unclosed token"))?;
|
||||
parts.push(match &rest[1..end] {
|
||||
"name" => Part::Name,
|
||||
"serial" => Part::Serial,
|
||||
"date" => Part::Date,
|
||||
token => return Err(bad(format!("naming: unknown token {{{token}}}; use {{name}}, {{serial}} or {{date}}"))),
|
||||
"name" => Part::Name(NameCase::AsIs),
|
||||
"NAME" => Part::Name(NameCase::Upper),
|
||||
"Name" => Part::Name(NameCase::Title),
|
||||
"serial" => Part::Serial(3),
|
||||
"letter" => Part::Letter(false),
|
||||
"LETTER" => Part::Letter(true),
|
||||
"date" => Part::Date(DateFormat::Iso),
|
||||
"date:mmddyy" => Part::Date(DateFormat::Mmddyy),
|
||||
"date:mmdd" => Part::Date(DateFormat::Mmdd),
|
||||
"date:yyyymmdd" => Part::Date(DateFormat::Yyyymmdd),
|
||||
"date:yymmdd" => Part::Date(DateFormat::Yymmdd),
|
||||
"date:yyddmm" => Part::Date(DateFormat::Yyddmm),
|
||||
"date:ddmmyy" => Part::Date(DateFormat::Ddmmyy),
|
||||
"ext" => Part::Ext(false),
|
||||
"EXT" => Part::Ext(true),
|
||||
token => match token.strip_prefix("serial:").and_then(|value| value.parse::<usize>().ok()) {
|
||||
Some(width) if (1..=4).contains(&width) => Part::Serial(width),
|
||||
_ => return Err(bad(format!("naming: unknown token {{{token}}}"))),
|
||||
},
|
||||
});
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
@@ -36,14 +109,35 @@ fn parse(template: &str) -> Result<Vec<Part<'_>>> {
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String {
|
||||
fn expand(parts: &[Part<'_>], name: &str, ext: &str, serial: u32, date: Date) -> String {
|
||||
let mut out = String::new();
|
||||
for part in parts {
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
match part {
|
||||
Part::Literal(s) => out.push_str(s),
|
||||
Part::Name => out.push_str(name),
|
||||
Part::Serial => out.push_str(&format!("{serial:03}")),
|
||||
Part::Date => out.push_str(date),
|
||||
Part::Name(NameCase::AsIs) => out.push_str(name),
|
||||
Part::Name(NameCase::Upper) => out.push_str(&name.to_uppercase()),
|
||||
Part::Name(NameCase::Title) => {
|
||||
let mut chars = name.chars();
|
||||
if let Some(first) = chars.next() {
|
||||
out.extend(first.to_uppercase());
|
||||
out.push_str(&chars.as_str().to_lowercase());
|
||||
}
|
||||
}
|
||||
Part::Serial(width) => out.push_str(&format!("{serial:0width$}")),
|
||||
Part::Letter(upper) => out.push_str(&letters(serial, *upper)),
|
||||
Part::Date(kind) => out.push_str(&date.format(*kind)),
|
||||
Part::Ext(upper) => {
|
||||
// The reference app adds the separator only for the final extension component.
|
||||
// A literal dot in the template already supplies that separator.
|
||||
if !parts[index + 1..].iter().any(|part| !matches!(part, Part::Literal(""))) && !out.ends_with('.') {
|
||||
out.push('.');
|
||||
}
|
||||
if *upper {
|
||||
out.push_str(&ext.to_uppercase());
|
||||
} else {
|
||||
out.push_str(ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -51,7 +145,7 @@ fn expand(parts: &[Part<'_>], name: &str, serial: u32, date: &str) -> String {
|
||||
|
||||
/// An absent or empty template preserves the old filename path exactly. An explicit template
|
||||
/// generates one component per input; the export format supplies the extension separately.
|
||||
pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result<Option<Vec<String>>> {
|
||||
pub fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &Value) -> Result<Option<Vec<String>>> {
|
||||
let start = match params.get("startingSerial") {
|
||||
None => 1,
|
||||
Some(n) => {
|
||||
@@ -70,30 +164,23 @@ pub(super) fn plan(inputs: &[BatchInput], output: &str, format: &str, params: &V
|
||||
return Err(bad("naming must be at most 255 bytes"));
|
||||
}
|
||||
let parts = parse(template)?;
|
||||
let has_serial = parts.iter().any(|p| matches!(p, Part::Serial));
|
||||
let has_serial = parts.iter().any(|p| matches!(p, Part::Serial(_) | Part::Letter(_)));
|
||||
let timestamp = crate::analysis_cmds::now_iso();
|
||||
let date = timestamp.get(..10).unwrap_or("");
|
||||
if date.is_empty() && parts.iter().any(|p| matches!(p, Part::Date)) {
|
||||
return Err(bad("naming: the current UTC date is unavailable"));
|
||||
}
|
||||
let date = Date::from_iso(×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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::open::import_at_raw_ppi;
|
||||
use super::paths::{extension, file_name, join, list_images, read_file, stem};
|
||||
use super::paths::{extension, file_name, join, list_images, read_file, stem, write_file};
|
||||
use super::processing::parse_steps;
|
||||
use super::save::{SaveOpts, save_doc};
|
||||
use super::save::{SaveOpts, encode, save_doc, save_doc_as};
|
||||
use super::{Arc, Document, EngineError, Result, Session, Value, batch_naming, json, str_param};
|
||||
|
||||
/// Inputs of a batch command: `"input"` is a folder or an array of files.
|
||||
@@ -16,16 +16,18 @@ pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
/// One input of a batch run: the file, and the folder it sits in relative to the input folder
|
||||
/// (`""` at the top level, `"a/b"` two levels down), which its result keeps under `output`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct BatchInput {
|
||||
pub struct BatchInput {
|
||||
pub path: String,
|
||||
pub rel_dir: String,
|
||||
}
|
||||
|
||||
/// Subfolders below a batch input folder are walked at most this deep ("Include All Subfolders").
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) const MAX_SUBFOLDER_DEPTH: usize = 32;
|
||||
|
||||
/// A batch over subfolders lists at most this many files; a larger tree is an error, not a run
|
||||
/// that silently stops partway.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000;
|
||||
|
||||
/// [`batch_inputs`] for the commands whose Photoshop dialog has "Include All Subfolders" (Batch,
|
||||
@@ -47,9 +49,9 @@ pub(crate) fn batch_inputs_tree(p: &Value, cmd: &str, output: &str) -> Result<Ve
|
||||
/// The images under `dir` and its subfolders: each folder's files sorted by name, then its
|
||||
/// subfolders in name order (depth first). Bounded by [`MAX_SUBFOLDER_DEPTH`] and
|
||||
/// [`MAX_SUBFOLDER_FILES`]; a folder reached twice (a symlink loop) is walked once, and `skip`
|
||||
/// (the output folder) is left out.
|
||||
/// (the output folder) is left out. An unreadable reached folder is an error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<BatchInput>> {
|
||||
pub fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<BatchInput>> {
|
||||
use std::path::{Path, PathBuf};
|
||||
let canon = |p: &Path| std::fs::canonicalize(p).ok();
|
||||
let root = Path::new(dir);
|
||||
@@ -62,12 +64,8 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<B
|
||||
let mut stack: Vec<(PathBuf, String, usize)> = vec![(root.to_path_buf(), String::new(), 0)];
|
||||
while let Some((folder, rel, depth)) = stack.pop() {
|
||||
let shown = folder.to_string_lossy().into_owned();
|
||||
// The top folder must be readable; a subfolder that isn't is skipped.
|
||||
let files = match list_images(&shown) {
|
||||
Ok(f) => f,
|
||||
Err(e) if depth == 0 => return Err(e),
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Fail before writing any output if a reached input folder cannot be read.
|
||||
let files = list_images(&shown)?;
|
||||
if out.len().saturating_add(files.len()) > MAX_SUBFOLDER_FILES {
|
||||
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} images with its subfolders") });
|
||||
}
|
||||
@@ -75,17 +73,20 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<B
|
||||
if depth >= MAX_SUBFOLDER_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&folder) else { continue };
|
||||
let mut subs: Vec<(String, PathBuf)> = rd
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.filter_map(|p| Some((p.file_name()?.to_string_lossy().into_owned(), p)))
|
||||
.collect();
|
||||
let rd = std::fs::read_dir(&folder).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let mut subs: Vec<(String, PathBuf)> = Vec::new();
|
||||
for entry in rd {
|
||||
let path = entry.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?.path();
|
||||
if path.is_dir()
|
||||
&& let Some(name) = path.file_name()
|
||||
{
|
||||
subs.push((name.to_string_lossy().into_owned(), path.clone()));
|
||||
}
|
||||
}
|
||||
subs.sort();
|
||||
// Pushed in reverse so the stack pops them in name order.
|
||||
for (name, sub) in subs.into_iter().rev() {
|
||||
let Some(c) = canon(&sub) else { continue };
|
||||
let c = canon(&sub).ok_or_else(|| EngineError::Other(format!("{}: cannot resolve the subfolder", sub.display())))?;
|
||||
if skip.as_ref() == Some(&c) || !seen.insert(c) {
|
||||
continue;
|
||||
}
|
||||
@@ -97,7 +98,7 @@ pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<B
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result<Vec<BatchInput>> {
|
||||
pub fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result<Vec<BatchInput>> {
|
||||
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
|
||||
}
|
||||
|
||||
@@ -142,7 +143,9 @@ pub(crate) fn process_tree(
|
||||
/// codec formats without a writer in this build. Those are saved as PSD, the format Photoshop's
|
||||
/// Save As offers for a raw. An input whose file name has no extension (a dot in a folder name
|
||||
/// doesn't count) is saved as PNG.
|
||||
pub(super) fn same_format(path: &str) -> String {
|
||||
/// The format Batch's "Save in the same format" writes for `path`: its own, or PSD for a file
|
||||
/// PhotoCraft only opens.
|
||||
pub fn same_format(path: &str) -> String {
|
||||
const IMPORT_ONLY: &[&str] =
|
||||
&["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm", "xcf", "xcfgz", "gz", "psdt"];
|
||||
let ext = extension(path).unwrap_or_else(|| "png".into());
|
||||
@@ -188,18 +191,7 @@ fn process_outputs<'a>(
|
||||
let mut errors = Vec::new();
|
||||
let mut written = OutputClaims::default();
|
||||
for (path, out) in outputs {
|
||||
let r = (|| -> Result<()> {
|
||||
written.check(&out).map_err(EngineError::Other)?;
|
||||
let bytes = read_file(path)?;
|
||||
let mut scratch = Session::new();
|
||||
let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?;
|
||||
scratch.add_document(doc, Some(path.to_string()));
|
||||
if !f(&mut scratch, &out)? {
|
||||
let d = scratch.active().ok_or(EngineError::NoDocument)?;
|
||||
save_doc(&d.doc, &out, save)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
let r = process_one(raw_ppi, path, &out, save, ProcessOptions { format: None, written: Some(&written), ctx: None }, f);
|
||||
match r {
|
||||
Ok(()) => {
|
||||
written.record(&out, path);
|
||||
@@ -211,6 +203,248 @@ fn process_outputs<'a>(
|
||||
json!({"files": files, "errors": errors})
|
||||
}
|
||||
|
||||
struct ProcessOptions<'a> {
|
||||
format: Option<&'a str>,
|
||||
written: Option<&'a OutputClaims>,
|
||||
ctx: Option<&'a crate::jobs::JobCtx>,
|
||||
}
|
||||
|
||||
fn process_one(raw_ppi: f64, path: &str, out: &str, save: SaveOpts, options: ProcessOptions<'_>, f: &dyn Fn(&mut Session, &str) -> Result<bool>) -> Result<()> {
|
||||
if let Some(written) = options.written {
|
||||
written.check(out).map_err(EngineError::Other)?;
|
||||
}
|
||||
let bytes = read_file(path)?;
|
||||
let mut scratch = Session::new();
|
||||
let doc = import_at_raw_ppi(raw_ppi, &file_name(path), &bytes)?;
|
||||
scratch.add_document(doc, Some(path.to_string()));
|
||||
if let Some(ctx) = options.ctx {
|
||||
ctx.publish_batch_preview(path, scratch.active().map(|state| state.doc.clone()));
|
||||
}
|
||||
let saved_by_action = f(&mut scratch, out)?;
|
||||
if saved_by_action {
|
||||
// The action's saved snapshot is already written: a cancel or Esc arriving now stops the
|
||||
// inputs after this one, not this result.
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(ctx) = options.ctx {
|
||||
ctx.check()?;
|
||||
if ctx.batch_interrupt_requested() {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
}
|
||||
let d = scratch.active().ok_or(EngineError::NoDocument)?;
|
||||
if let Some(format) = options.format {
|
||||
let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
if let Some(ctx) = options.ctx {
|
||||
save_batch_doc(&d.doc, out, &ext, save, ctx)?;
|
||||
} else {
|
||||
save_doc_as(&d.doc, out, &ext, save)?;
|
||||
}
|
||||
} else {
|
||||
save_doc(&d.doc, out, save)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode before the Batch write checkpoint. Esc accepted during an action or encode therefore
|
||||
/// leaves the current destination untouched; the worker owns the write once the checkpoint passes.
|
||||
fn save_batch_doc(doc: &Document, out: &str, format: &str, save: SaveOpts, ctx: &crate::jobs::JobCtx) -> Result<()> {
|
||||
let (bytes, _) = encode(doc, format, save)?;
|
||||
ctx.batch_before_write()?;
|
||||
write_file(out, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct BatchPolicy {
|
||||
pub(super) stop_on_error: bool,
|
||||
pub(super) error_log: Option<String>,
|
||||
}
|
||||
|
||||
impl BatchPolicy {
|
||||
fn parse(p: &Value, cmd: &str) -> Result<Self> {
|
||||
let stop_on_error = match p.get("stopOnError") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(value)) => *value,
|
||||
Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"stopOnError\" must be true or false".into() }),
|
||||
};
|
||||
let error_log = match p.get("errorLog") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(path)) if path.trim().is_empty() => None,
|
||||
Some(Value::String(path)) => Some(path.clone()),
|
||||
Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"errorLog\" must be a file path string".into() }),
|
||||
};
|
||||
Ok(Self { stop_on_error, error_log })
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop-compatible Batch log encoding, shared by the desktop command and CLI.
|
||||
pub fn encode_batch_error_log<'a>(errors: impl IntoIterator<Item = (&'a str, &'a str)>) -> Vec<u8> {
|
||||
let mut body = String::from("Batch Start\r\n\r\n");
|
||||
for (input, reason) in errors {
|
||||
body.push_str(&format!("File: \"{input}\"\r\nError: {reason}\r\n\r\n"));
|
||||
}
|
||||
body.push_str("Batch End\r\n");
|
||||
let mut bytes = vec![0xff, 0xfe];
|
||||
for word in body.encode_utf16() {
|
||||
bytes.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn write_error_log(path: &str, errors: &[Value]) -> Result<()> {
|
||||
let bytes = encode_batch_error_log(errors.iter().map(|error| {
|
||||
(error.get("file").or_else(|| error.get("document")).and_then(Value::as_str).unwrap_or("?"), error.get("error").and_then(Value::as_str).unwrap_or("?"))
|
||||
}));
|
||||
super::paths::write_file(path, &bytes).map_err(|error| EngineError::Other(format!("error log: {error}")))
|
||||
}
|
||||
|
||||
fn error_log_failure(path: Option<&str>, errors: &[Value]) -> Option<String> {
|
||||
path.and_then(|path| write_error_log(path, errors).err().map(|error| error.to_string()))
|
||||
}
|
||||
|
||||
fn batch_outcome(files: Vec<String>, errors: Vec<Value>, stopped: Option<(String, Vec<String>)>, error_log: Option<&str>) -> Value {
|
||||
let mut result = json!({"files": files, "errors": errors});
|
||||
if let Some((at, skipped)) = stopped {
|
||||
result["stopped"] = json!(at);
|
||||
result["skipped"] = json!(skipped);
|
||||
}
|
||||
if let Some(path) = error_log {
|
||||
result["errorLog"] = json!(path);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn validate_error_log(policy: &BatchPolicy, inputs: impl IntoIterator<Item = String>, outputs: impl IntoIterator<Item = String>) -> Result<()> {
|
||||
let Some(log) = &policy.error_log else { return Ok(()) };
|
||||
let conflicts = |path: String| super::paths::same_destination(log, &path);
|
||||
if inputs.into_iter().chain(outputs).any(conflicts) {
|
||||
return Err(EngineError::BadParams { cmd: "file.automate.batch".into(), msg: "\"errorLog\" must not replace an input or batch output".into() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A flat Batch result must not replace another input before it is opened. Compare resolved
|
||||
/// identities once for the whole plan, so aliases and large folder batches stay safe and linear.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn validate_flat_outputs(outputs: &[(String, String)]) -> Result<()> {
|
||||
let sources: std::collections::HashMap<_, _> =
|
||||
outputs.iter().enumerate().filter_map(|(index, (input, _))| std::fs::canonicalize(input).ok().map(|identity| (identity, index))).collect();
|
||||
for (index, (_, output)) in outputs.iter().enumerate() {
|
||||
let identity = super::paths::destination_identity(std::path::Path::new(output))
|
||||
.ok_or_else(|| EngineError::BadParams { cmd: "file.automate.batch".into(), msg: format!("output path cannot be resolved safely: {output}") })?;
|
||||
if sources.get(&identity).is_some_and(|&source_index| source_index != index) {
|
||||
return Err(EngineError::BadParams {
|
||||
cmd: "file.automate.batch".into(),
|
||||
msg: format!("output would replace another input file: {output}; choose a different destination or naming template"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn process_batch_outputs(
|
||||
raw_ppi: f64,
|
||||
outputs: Vec<(String, String)>,
|
||||
format: &str,
|
||||
save: SaveOpts,
|
||||
policy: &BatchPolicy,
|
||||
ctx: &crate::jobs::JobCtx,
|
||||
f: &dyn Fn(&mut Session, &str) -> Result<bool>,
|
||||
) -> Result<Value> {
|
||||
let total = outputs.len();
|
||||
let mut files = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut stopped = None;
|
||||
let mut done = 0;
|
||||
let mut interrupted = Vec::new();
|
||||
for (index, (path, out)) in outputs.iter().enumerate() {
|
||||
if ctx.cancelled() {
|
||||
break;
|
||||
}
|
||||
if ctx.batch_take_interrupt() && !ctx.batch_wait_for_decision() {
|
||||
ctx.publish_batch_preview(path, None);
|
||||
ctx.cancel();
|
||||
break;
|
||||
}
|
||||
ctx.progress(index as f32 / total.max(1) as f32, &format!("{} of {total}: {}", index + 1, file_name(path)));
|
||||
let result = process_one(raw_ppi, path, out, save, ProcessOptions { format: Some(format), written: None, ctx: Some(ctx) }, f);
|
||||
// An input that failed on its own (not because it was stopped) stays a failure with its
|
||||
// reason, even when a cancel or Esc lands while it runs.
|
||||
let failure =
|
||||
result.as_ref().err().filter(|error| !matches!(error, EngineError::Cancelled)).map(|error| json!({"file": path, "error": error.to_string()}));
|
||||
if ctx.cancelled() {
|
||||
ctx.publish_batch_preview(path, None);
|
||||
// A cancel that lands while the result is written still leaves that file written.
|
||||
if result.is_ok() {
|
||||
files.push(out.clone());
|
||||
done = index + 1;
|
||||
} else if let Some(failure) = failure {
|
||||
errors.push(failure);
|
||||
done = index + 1;
|
||||
} else {
|
||||
done = index;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if ctx.batch_take_interrupt() {
|
||||
let continue_next = ctx.batch_wait_for_decision();
|
||||
ctx.publish_batch_preview(path, None);
|
||||
match failure {
|
||||
Some(failure) => {
|
||||
errors.push(failure);
|
||||
done = index + 1;
|
||||
}
|
||||
None if continue_next => {
|
||||
interrupted.push(path.clone());
|
||||
done = index + 1;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if continue_next {
|
||||
continue;
|
||||
}
|
||||
ctx.cancel();
|
||||
break;
|
||||
}
|
||||
ctx.batch_after_write();
|
||||
ctx.publish_batch_preview(path, None);
|
||||
done = index + 1;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
files.push(out.clone());
|
||||
}
|
||||
Err(error) => {
|
||||
errors.push(json!({"file": path, "error": error.to_string()}));
|
||||
if policy.stop_on_error {
|
||||
stopped = Some(path.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No checkpoint follows the last input: a later Esc has nothing to interrupt.
|
||||
ctx.batch_close_interrupts();
|
||||
let log_failure = error_log_failure(policy.error_log.as_deref(), &errors);
|
||||
let cancelled = ctx.cancelled();
|
||||
if !cancelled {
|
||||
ctx.progress(1.0, "");
|
||||
}
|
||||
let mut skipped = interrupted;
|
||||
skipped.extend(outputs.iter().skip(done).map(|(path, _)| path.clone()));
|
||||
let mut result = batch_outcome(files, errors, stopped.map(|at| (at, skipped.clone())), policy.error_log.as_deref().filter(|_| log_failure.is_none()));
|
||||
if !skipped.is_empty() {
|
||||
result["skipped"] = json!(skipped);
|
||||
}
|
||||
if cancelled {
|
||||
result["cancelled"] = json!(true);
|
||||
}
|
||||
if let Some(error) = log_failure {
|
||||
result["errorLogError"] = json!(error);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The output paths a batch run has written, so a later input whose output name matches an
|
||||
/// earlier one's (`a.png` and `a.jpg` saved as JPEG, or the same name in two input folders) is
|
||||
/// reported instead of silently replacing that result (#420, #422). Names are compared ignoring
|
||||
@@ -254,8 +488,22 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some((id, _)) = steps.iter().find(|(id, _)| !batch_save_point(id) && crate::commands::find(id).is_none()) {
|
||||
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") });
|
||||
}
|
||||
let output = str_param(p, "output", cmd)?.to_string();
|
||||
let inputs = batch_inputs_tree(p, cmd, &output)?;
|
||||
let policy = BatchPolicy::parse(p, cmd)?;
|
||||
let source = match p.get("source") {
|
||||
None | Some(Value::Null) => "folder",
|
||||
Some(Value::String(source)) if matches!(source.as_str(), "folder" | "open") => source.as_str(),
|
||||
_ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"source\" must be \"folder\" or \"open\"".into() }),
|
||||
};
|
||||
let output = if source == "folder" {
|
||||
str_param(p, "output", cmd)?.to_string()
|
||||
} else {
|
||||
match p.get("output") {
|
||||
None | Some(Value::Null) => String::new(),
|
||||
Some(Value::String(path)) => path.clone(),
|
||||
Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"output\" must be a folder path".into() }),
|
||||
}
|
||||
};
|
||||
let inputs = if source == "folder" { batch_inputs_tree(p, cmd, &output)? } else { Vec::new() };
|
||||
let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string();
|
||||
// Steps that call another action play it from the caller's actions; the playback stack comes
|
||||
// along so an action that batches itself is caught as a recursive call.
|
||||
@@ -275,9 +523,10 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// decoded and saved even with no steps) to the playback tree it is in and
|
||||
// hands the count on, so a batch of batches can't multiply past the limit. A top-level batch
|
||||
// is not limited by it.
|
||||
let input_count = if source == "open" { s.documents().len() } else { inputs.len() };
|
||||
let mut playback_steps = 0;
|
||||
if s.actions.playing > 0 {
|
||||
playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(inputs.len() as u64));
|
||||
playback_steps = s.actions.playback_steps.saturating_add((steps.len().max(1) as u64).saturating_mul(input_count as u64));
|
||||
if playback_steps > crate::actions_cmds::MAX_PLAYBACK_STEPS {
|
||||
return Err(EngineError::BadParams { cmd: cmd.into(), msg: "this batch would exceed the playback step limit of the action that runs it".into() });
|
||||
}
|
||||
@@ -290,60 +539,216 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
playback_steps,
|
||||
..Default::default()
|
||||
};
|
||||
if source == "open" {
|
||||
let save = SaveOpts::from_params(s, p, cmd)?;
|
||||
if p.get("calledActions").is_some_and(|value| !value.is_null()) {
|
||||
let previous = std::mem::replace(&mut s.actions.list, actions.list.clone());
|
||||
let result = batch_open_documents(s, &steps, p, &output, &format, &policy, save);
|
||||
s.actions.list = previous;
|
||||
return result;
|
||||
}
|
||||
return batch_open_documents(s, &steps, p, &output, &format, &policy, save);
|
||||
}
|
||||
let names = batch_naming::plan(&inputs, &output, &format, p)?;
|
||||
let save = SaveOpts::from_params(s, p, cmd)?;
|
||||
let authorize = s.authorize;
|
||||
let raw_ppi = s.prefs().raw_defaults.resolution;
|
||||
let outputs: Vec<(String, String)> = match names {
|
||||
Some(names) => inputs.iter().map(|input| input.path.clone()).zip(names).collect(),
|
||||
None => inputs
|
||||
.iter()
|
||||
.map(|input| {
|
||||
let ext = if format == "same" { same_format(&input.path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
let out = join(&output, &format!("{}.{ext}", stem(&input.path)));
|
||||
(input.path.clone(), out)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
validate_flat_outputs(&outputs)?;
|
||||
validate_error_log(&policy, outputs.iter().map(|(input, _)| input.clone()), outputs.iter().map(|(_, output)| output.clone()))?;
|
||||
// Each input runs in its own scratch session, so what the played steps spend is carried from
|
||||
// one input to the next (failed or not) and back to this session, not restarted per input.
|
||||
let carried = std::cell::Cell::new(playback_steps);
|
||||
let carried = &carried;
|
||||
let run = move |scratch: &mut Session, out: &str| {
|
||||
scratch.authorize = authorize;
|
||||
scratch.actions = actions.clone();
|
||||
scratch.actions.playback_steps = carried.get();
|
||||
let result = (|| {
|
||||
// The document as the last recorded save found it, written once the action has run so a
|
||||
// failing step still writes nothing.
|
||||
let was_playing = s.actions.playing > 0;
|
||||
crate::jobs::run(
|
||||
s,
|
||||
"Batch",
|
||||
false,
|
||||
move |ctx| {
|
||||
let carried = std::cell::Cell::new(playback_steps);
|
||||
let run = |scratch: &mut Session, out: &str| {
|
||||
scratch.authorize = authorize;
|
||||
scratch.actions = actions.clone();
|
||||
scratch.actions.playback_steps = carried.get();
|
||||
let result = (|| {
|
||||
// A recorded save marks a result. If the action later closes or switches
|
||||
// documents, write the saved snapshot instead of the new active document.
|
||||
let source = scratch.active().and_then(|state| state.path.as_deref()).unwrap_or("").to_string();
|
||||
let encode_format = if format == "same" { same_format(&source) } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
let mut saved: Option<Arc<Document>> = None;
|
||||
for (id, params) in &steps {
|
||||
ctx.check()?;
|
||||
if ctx.batch_interrupt_requested() {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
if batch_save_point(id) {
|
||||
let state = scratch.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
saved = Some(state.doc.clone());
|
||||
// The destination save is performed by Batch; subsequent recorded
|
||||
// Close steps see Save / Save As as saved, but Save a Copy as changed.
|
||||
if id != "file.saveACopy" {
|
||||
state.saved_revision = state.revision;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let result = scratch.execute(id, params.clone())?;
|
||||
ctx.publish_batch_preview(&source, scratch.active().map(|state| state.doc.clone()));
|
||||
if let Some(error) = crate::actions_cmds::nested_failure(id, &result) {
|
||||
return Err(EngineError::Other(error));
|
||||
}
|
||||
}
|
||||
match saved {
|
||||
Some(doc) if scratch.active().is_none_or(|active| active.doc.id != doc.id) => {
|
||||
ctx.check()?;
|
||||
save_batch_doc(&doc, out, &encode_format, save, ctx)?;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
}
|
||||
})();
|
||||
carried.set(scratch.actions.playback_steps);
|
||||
result
|
||||
};
|
||||
let result = process_batch_outputs(raw_ppi, outputs, &format, save, &policy, ctx, &run);
|
||||
Ok((result, carried.get()))
|
||||
},
|
||||
move |s, (result, carried)| {
|
||||
if was_playing {
|
||||
s.actions.playback_steps = carried;
|
||||
}
|
||||
result
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Source: Opened Files runs on the live documents, so edits remain in their histories.
|
||||
/// Documents are identified before playback; a step that closes or reorders tabs cannot make
|
||||
/// the next input silently become a different document.
|
||||
fn batch_open_documents(
|
||||
s: &mut Session,
|
||||
steps: &[(String, Value)],
|
||||
p: &Value,
|
||||
output: &str,
|
||||
format: &str,
|
||||
policy: &BatchPolicy,
|
||||
save: SaveOpts,
|
||||
) -> Result<Value> {
|
||||
let originals: Vec<(u64, String, String)> = s
|
||||
.documents()
|
||||
.iter()
|
||||
.map(|state| {
|
||||
let path = state.path.clone().unwrap_or_else(|| format!("{}.psd", state.doc.name));
|
||||
(state.doc.id.0, state.doc.name.clone(), path)
|
||||
})
|
||||
.collect();
|
||||
if originals.is_empty() {
|
||||
return Err(EngineError::NoDocument);
|
||||
}
|
||||
let previous = s.active().map(|state| state.doc.id.0);
|
||||
let virtual_inputs: Vec<BatchInput> = originals.iter().map(|(_, _, path)| BatchInput { path: path.clone(), rel_dir: String::new() }).collect();
|
||||
let planned = if output.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let named = batch_naming::plan(&virtual_inputs, output, format, p)?;
|
||||
Some(match named {
|
||||
Some(paths) => paths,
|
||||
None => virtual_inputs
|
||||
.iter()
|
||||
.map(|input| {
|
||||
let ext = if format == "same" { same_format(&input.path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
join(output, &format!("{}.{ext}", stem(&input.path)))
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
};
|
||||
// As for a folder: no result may replace another open document's file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(paths) = &planned {
|
||||
let plan: Vec<(String, String)> = originals.iter().map(|(_, _, path)| path.clone()).zip(paths.iter().cloned()).collect();
|
||||
validate_flat_outputs(&plan)?;
|
||||
}
|
||||
validate_error_log(policy, originals.iter().map(|(_, _, path)| path.clone()), planned.iter().flat_map(|paths| paths.iter().cloned()))?;
|
||||
let mut files = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut stopped = None;
|
||||
let mut done = 0;
|
||||
for (index, (id, name, path)) in originals.iter().enumerate() {
|
||||
// The document a recorded Save / Save As marked as saved, with its earlier saved
|
||||
// revision: when Batch then writes nothing, it reads as changed again.
|
||||
let mut marked: Option<(u64, u64)> = None;
|
||||
let result = (|| -> Result<()> {
|
||||
let position = s.documents().iter().position(|state| state.doc.id.0 == *id).ok_or(EngineError::NoDocument)?;
|
||||
s.set_active(position);
|
||||
let mut saved: Option<Arc<Document>> = None;
|
||||
for (id, params) in &steps {
|
||||
if batch_save_point(id) {
|
||||
let st = scratch.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
saved = Some(st.doc.clone());
|
||||
// Batch performs the action's Save / Save As (to its destination), so the
|
||||
// document is saved there as far as the steps after it see: a Close recorded
|
||||
// without a save policy (`"save":"ask"`) then closes it. A Save a Copy leaves
|
||||
// the document as changed as it was.
|
||||
if id != "file.saveACopy" {
|
||||
st.saved_revision = st.revision;
|
||||
for (command, params) in steps {
|
||||
if batch_save_point(command) {
|
||||
let state = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
saved = Some(state.doc.clone());
|
||||
// With no destination nothing is written, so the document stays changed.
|
||||
if command != "file.saveACopy" && planned.is_some() {
|
||||
if marked.is_none() {
|
||||
marked = Some((state.doc.id.0, state.saved_revision));
|
||||
}
|
||||
state.saved_revision = state.revision;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let r = scratch.execute(id, params.clone())?;
|
||||
// A failed step inside a called action makes this file an error, not a saved result.
|
||||
if let Some(error) = crate::actions_cmds::nested_failure(id, &r) {
|
||||
let response = s.execute(command, params.clone())?;
|
||||
if let Some(error) = crate::actions_cmds::nested_failure(command, &response) {
|
||||
return Err(EngineError::Other(error));
|
||||
}
|
||||
}
|
||||
// While the saved document is still open the steps after the save reach the result too
|
||||
// (#3058), so it is saved after the action as usual. Closed after its save, or with another
|
||||
// document left active, it is written as the save found it.
|
||||
match saved {
|
||||
Some(doc) if scratch.active().is_none_or(|d| d.doc.id != doc.id) => {
|
||||
save_doc(&doc, out, save)?;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
}
|
||||
let Some(paths) = &planned else { return Ok(()) };
|
||||
let out = paths.get(index).ok_or_else(|| EngineError::Other("batch output plan is incomplete".into()))?;
|
||||
let doc = match saved {
|
||||
Some(saved) if s.active().is_some_and(|state| state.doc.id == saved.id) => s.active().ok_or(EngineError::NoDocument)?.doc.clone(),
|
||||
Some(saved) => saved,
|
||||
None => s.documents().iter().find(|state| state.doc.id.0 == *id).ok_or(EngineError::NoDocument)?.doc.clone(),
|
||||
};
|
||||
let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
|
||||
save_doc_as(&doc, out, &ext, save)?;
|
||||
files.push(out.clone());
|
||||
Ok(())
|
||||
})();
|
||||
carried.set(scratch.actions.playback_steps);
|
||||
result
|
||||
};
|
||||
let r = match names {
|
||||
Some(names) => process_outputs(s, inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run),
|
||||
None => process_tree(s, &inputs, &output, &format, save, "", &run),
|
||||
};
|
||||
if s.actions.playing > 0 {
|
||||
s.actions.playback_steps = carried.get();
|
||||
done = index + 1;
|
||||
if result.is_err()
|
||||
&& let Some((doc, revision)) = marked
|
||||
&& let Some(position) = s.documents().iter().position(|state| state.doc.id.0 == doc)
|
||||
{
|
||||
s.set_active(position);
|
||||
if let Some(state) = s.active_mut() {
|
||||
state.saved_revision = revision;
|
||||
}
|
||||
}
|
||||
if let Err(error) = result {
|
||||
errors.push(json!({"document": name, "error": error.to_string()}));
|
||||
if policy.stop_on_error {
|
||||
stopped = Some(name.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
if let Some(id) = previous
|
||||
&& let Some(position) = s.documents().iter().position(|state| state.doc.id.0 == id)
|
||||
{
|
||||
s.set_active(position);
|
||||
}
|
||||
let log_failure = error_log_failure(policy.error_log.as_deref(), &errors);
|
||||
let skipped = originals.iter().skip(done).map(|(_, name, _)| name.clone()).collect();
|
||||
let mut result = batch_outcome(files, errors, stopped.map(|at| (at, skipped)), policy.error_log.as_deref().filter(|_| log_failure.is_none()));
|
||||
if let Some(error) = log_failure {
|
||||
result["errorLogError"] = json!(error);
|
||||
}
|
||||
result["documents"] = json!(originals.len());
|
||||
Ok(result)
|
||||
}
|
||||
@@ -40,12 +40,19 @@ pub(crate) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn list_images(dir: &str) -> Result<Vec<String>> {
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?;
|
||||
let mut out: Vec<String> = rd
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && openable_name(&p.to_string_lossy()))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
for entry in rd {
|
||||
let path = entry.map_err(|e| EngineError::Other(format!("{dir}: {e}")))?.path();
|
||||
if !openable_name(&path.to_string_lossy()) {
|
||||
continue;
|
||||
}
|
||||
match std::fs::metadata(&path) {
|
||||
Ok(metadata) if metadata.is_file() => out.push(path.to_string_lossy().into_owned()),
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} // broken link or entry removed during traversal
|
||||
Err(error) => return Err(EngineError::Other(format!("{}: {error}", path.display()))),
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
@@ -181,6 +188,95 @@ pub fn same_file(a: impl AsRef<std::path::Path>, b: impl AsRef<std::path::Path>)
|
||||
false
|
||||
}
|
||||
|
||||
/// Resolve a future output path component by component. Existing symlinks are followed before
|
||||
/// applying a later `..`, while components not created yet are kept so a new `x/../file` still
|
||||
/// resolves to the file it would reach after `x` is created.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn destination_identity(path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
use std::path::{Component, PathBuf};
|
||||
let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().ok()?.join(path) };
|
||||
let mut resolved = PathBuf::new();
|
||||
for component in absolute.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
Component::Normal(_) => {
|
||||
resolved.push(component.as_os_str());
|
||||
match std::fs::symlink_metadata(&resolved) {
|
||||
Ok(_) => resolved = std::fs::canonicalize(&resolved).ok()?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
Component::Prefix(_) | Component::RootDir => resolved.push(component.as_os_str()),
|
||||
}
|
||||
}
|
||||
Some(resolved)
|
||||
}
|
||||
|
||||
/// Probe the actual destination directory. macOS volumes and Windows directories may use either
|
||||
/// case behavior; the OS name alone cannot tell whether two future names will alias.
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub(super) fn case_sensitive_destination(path: &std::path::Path) -> Option<bool> {
|
||||
let parent = path.ancestors().skip(1).find(|ancestor| ancestor.is_dir())?;
|
||||
let probe = tempfile::Builder::new().prefix(".PhotoCraftCaseProbe-").tempfile_in(parent).ok()?;
|
||||
let name = probe.path().file_name()?.to_str()?;
|
||||
let alternate = parent.join(name.replacen("PhotoCraft", "photocraft", 1));
|
||||
match std::fs::metadata(&alternate) {
|
||||
Ok(_) => Some(!same_file(probe.path(), &alternate)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(true),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
fn resolved_paths_match(a: &std::path::Path, b: &std::path::Path, case_sensitive: impl FnOnce() -> Option<bool>) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
match (a.to_str(), b.to_str()) {
|
||||
(Some(a_text), Some(b_text)) if a_text.to_lowercase() == b_text.to_lowercase() => !case_sensitive().unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, any(target_os = "macos", target_os = "windows")))]
|
||||
#[test]
|
||||
fn identical_resolved_paths_conflict_on_a_case_sensitive_volume() {
|
||||
let path = std::path::Path::new("/future/result.png");
|
||||
assert!(resolved_paths_match(path, path, || Some(true)));
|
||||
assert!(!resolved_paths_match(path, std::path::Path::new("/future/other.png"), || panic!("unrelated names must not probe the volume")));
|
||||
}
|
||||
|
||||
/// Whether two paths would name the same destination even before the file exists. This is used
|
||||
/// when a batch error log must not be allowed to replace a result that will be written later.
|
||||
pub fn same_destination(a: impl AsRef<std::path::Path>, b: impl AsRef<std::path::Path>) -> bool {
|
||||
let (a, b) = (a.as_ref(), b.as_ref());
|
||||
if same_file(a, b) {
|
||||
return true;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
match (destination_identity(a), destination_identity(b)) {
|
||||
(Some(a), Some(b)) => {
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
{
|
||||
resolved_paths_match(&a, &b, || case_sensitive_destination(&a))
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
a == b
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
false
|
||||
}
|
||||
|
||||
/// A path as command results report it: `/` between components, whatever the platform.
|
||||
///
|
||||
/// `join` already builds with `/`, but a directory from the caller or from `std::path::absolute`
|
||||
|
||||
@@ -254,6 +254,13 @@ pub(crate) fn save_doc(doc: &Document, path: &str, save: impl Into<SaveOpts>) ->
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
/// Save Batch bytes in the chosen format even when its naming rules omit or replace the suffix.
|
||||
pub(crate) fn save_doc_as(doc: &Document, path: &str, format: &str, save: impl Into<SaveOpts>) -> Result<Vec<String>> {
|
||||
let (bytes, warnings) = encode(doc, format, save)?;
|
||||
write_file(path, &bytes)?;
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
pub(super) fn save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path", "file.saveACopy")?.to_string();
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
|
||||
@@ -31,6 +31,622 @@ fn composite(s: &Session, x: i32, y: i32) -> [f32; 4] {
|
||||
b.px[0]
|
||||
}
|
||||
|
||||
fn batch_log(path: &str) -> String {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
assert!(bytes.starts_with(&[0xff, 0xfe]), "Batch log needs a UTF-16 LE BOM");
|
||||
let (pairs, remainder) = bytes[2..].as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "UTF-16 bytes must have even length");
|
||||
let words: Vec<u16> = pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect();
|
||||
String::from_utf16(&words).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_stop_on_error_reports_skipped_inputs_and_writes_the_error_log() {
|
||||
let dir = tmp("batch-stop-error-log");
|
||||
let good = png(&dir, "good.png", 8, 6, "#00ff00");
|
||||
let missing = join(&dir, "missing.png");
|
||||
let output = join(&dir, "out");
|
||||
let log = join(&dir, "errors.txt");
|
||||
let result = Session::new()
|
||||
.execute("file.automate.batch", json!({"steps": [], "input": [missing, good], "output": output, "stopOnError": true, "errorLog": log}))
|
||||
.unwrap();
|
||||
assert_eq!(result["files"], json!([]), "{result}");
|
||||
assert_eq!(result["errors"].as_array().map(Vec::len), Some(1), "{result}");
|
||||
assert_eq!(result["stopped"], json!(missing), "{result}");
|
||||
assert_eq!(result["skipped"], json!([good]), "{result}");
|
||||
let text = batch_log(&log);
|
||||
assert!(text.starts_with("Batch Start\r\n\r\n") && text.contains("missing.png") && text.contains("File: \"") && text.contains("Error: "), "{text}");
|
||||
assert!(text.ends_with("Batch End\r\n"), "{text}");
|
||||
assert!(!std::path::Path::new(&join(&output, "good.png")).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_writes_a_utf16_error_log_even_when_every_file_succeeds() {
|
||||
let dir = tmp("batch-clean-log");
|
||||
let input = png(&dir, "good.png", 8, 6, "#00ff00");
|
||||
let output = join(&dir, "out");
|
||||
let log = join(&dir, "errors.txt");
|
||||
let result = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap();
|
||||
assert_eq!(result["errors"], json!([]), "{result}");
|
||||
assert_eq!(result["errorLog"], json!(log));
|
||||
assert_eq!(batch_log(&log), "Batch Start\r\n\r\nBatch End\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_cancelled_during_an_action_does_not_save_that_input() {
|
||||
let dir = tmp("batch-cancel-current");
|
||||
let input = png(&dir, "source.png", 8, 6, "#ff0000");
|
||||
let out = join(&dir, "out/source.png");
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
|
||||
let result = super::batch_run::process_batch_outputs(72.0, vec![(input, out.clone())], "png", SaveOpts::default(), &policy, &ctx, &|_, _| {
|
||||
ctx.cancel();
|
||||
Ok(false)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result["files"], json!([]), "{result}");
|
||||
assert_eq!(result["cancelled"], json!(true), "{result}");
|
||||
assert!(!std::path::Path::new(&out).exists());
|
||||
}
|
||||
|
||||
/// An action that saved and then closed its document has its saved snapshot written by the time
|
||||
/// it returns: a cancel landing then counts that file as written, not skipped.
|
||||
#[test]
|
||||
fn batch_cancel_after_a_saved_snapshot_is_written_keeps_that_file() {
|
||||
let dir = tmp("batch-cancel-after-snapshot");
|
||||
let first = png(&dir, "first.png", 8, 6, "#ff0000");
|
||||
let second = png(&dir, "second.png", 8, 6, "#00ff00");
|
||||
let first_out = join(&dir, "out/first.png");
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
|
||||
let outputs = vec![(first, first_out.clone()), (second.clone(), join(&dir, "out/second.png"))];
|
||||
let result = super::batch_run::process_batch_outputs(72.0, outputs, "png", SaveOpts::default(), &policy, &ctx, &|s, out| {
|
||||
save_doc(&s.active().unwrap().doc, out, None).unwrap();
|
||||
ctx.cancel();
|
||||
Ok(true)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result["files"], json!([first_out]), "{result}");
|
||||
assert_eq!(result["skipped"], json!([second]), "{result}");
|
||||
assert_eq!(result["cancelled"], json!(true), "{result}");
|
||||
}
|
||||
|
||||
/// A cancel that lands while an input fails on its own keeps that failure and its reason; only
|
||||
/// the inputs after it are skipped.
|
||||
#[test]
|
||||
fn batch_cancel_during_a_failing_input_keeps_its_error() {
|
||||
let dir = tmp("batch-cancel-failing");
|
||||
let first = png(&dir, "first.png", 8, 6, "#ff0000");
|
||||
let second = png(&dir, "second.png", 8, 6, "#00ff00");
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
|
||||
let outputs = vec![(first.clone(), join(&dir, "out/first.png")), (second.clone(), join(&dir, "out/second.png"))];
|
||||
let result = super::batch_run::process_batch_outputs(72.0, outputs, "png", SaveOpts::default(), &policy, &ctx, &|_, _| {
|
||||
ctx.cancel();
|
||||
Err(EngineError::Other("boom".into()))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result["errors"], json!([{"file": first, "error": "boom"}]), "{result}");
|
||||
assert_eq!(result["skipped"], json!([second]), "{result}");
|
||||
assert_eq!(result["cancelled"], json!(true), "{result}");
|
||||
}
|
||||
|
||||
/// Esc during an input that fails on its own: Continue records the failure (not an interrupted
|
||||
/// input) and goes on with the next one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn batch_interruption_during_a_failing_input_keeps_its_error() {
|
||||
use std::time::Duration;
|
||||
|
||||
let dir = tmp("batch-interrupt-failing");
|
||||
let first = png(&dir, "first.png", 8, 6, "#ff0000");
|
||||
let second = png(&dir, "second.png", 8, 6, "#00ff00");
|
||||
let second_out = join(&dir, "out/second.png");
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
let worker_ctx = ctx.clone();
|
||||
let outputs = vec![(first.clone(), join(&dir, "out/first.png")), (second, second_out.clone())];
|
||||
let worker = std::thread::spawn(move || {
|
||||
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
|
||||
super::batch_run::process_batch_outputs(72.0, outputs, "png", SaveOpts::default(), &policy, &worker_ctx, &|_, path| {
|
||||
if path.ends_with("first.png") {
|
||||
assert!(worker_ctx.request_batch_interrupt());
|
||||
return Err(EngineError::Other("boom".into()));
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
});
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(ctx.decide_batch_interrupt(true));
|
||||
let result = worker.join().unwrap().unwrap();
|
||||
assert_eq!(result["errors"], json!([{"file": first, "error": "boom"}]), "{result}");
|
||||
assert_eq!(result["files"], json!([second_out]), "{result}");
|
||||
assert!(result.get("skipped").is_none(), "{result}");
|
||||
}
|
||||
|
||||
/// Once the last input is handled, Esc has nothing to interrupt and is refused, so the desktop
|
||||
/// never waits for a question that will not come.
|
||||
#[test]
|
||||
fn batch_refuses_esc_after_its_last_input() {
|
||||
let dir = tmp("batch-late-esc");
|
||||
let input = png(&dir, "source.png", 8, 6, "#ff0000");
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
|
||||
let result =
|
||||
super::batch_run::process_batch_outputs(72.0, vec![(input, join(&dir, "out/source.png"))], "png", SaveOpts::default(), &policy, &ctx, &|_, _| {
|
||||
Ok(false)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result["errors"], json!([]), "{result}");
|
||||
assert!(!ctx.request_batch_interrupt());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn batch_interruption_continues_with_the_original_next_output() {
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
let dir = tmp("batch-interrupt-continue");
|
||||
let first = png(&dir, "first.png", 8, 6, "#ff0000");
|
||||
let second = png(&dir, "second.png", 8, 6, "#00ff00");
|
||||
let first_out = join(&dir, "out/first_001.png");
|
||||
let second_out = join(&dir, "out/second_002.png");
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
let worker_ctx = ctx.clone();
|
||||
let (opened_tx, opened_rx) = mpsc::channel();
|
||||
let (resume_tx, resume_rx) = mpsc::channel();
|
||||
let first_action = std::sync::atomic::AtomicBool::new(true);
|
||||
let worker = std::thread::spawn(move || {
|
||||
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
|
||||
super::batch_run::process_batch_outputs(
|
||||
72.0,
|
||||
vec![(first, first_out.clone()), (second, second_out.clone())],
|
||||
"png",
|
||||
SaveOpts::default(),
|
||||
&policy,
|
||||
&worker_ctx,
|
||||
&|_, _| {
|
||||
if first_action.swap(false, std::sync::atomic::Ordering::AcqRel) {
|
||||
let _ = opened_tx.send(());
|
||||
let _ = resume_rx.recv_timeout(Duration::from_secs(10));
|
||||
}
|
||||
Ok(false)
|
||||
},
|
||||
)
|
||||
.map(|result| (result, first_out, second_out))
|
||||
});
|
||||
opened_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
let preview = ctx.batch_preview().expect("the real opened input is published before its action");
|
||||
assert_eq!(preview.path, join(&dir, "first.png"));
|
||||
assert_eq!((preview.document.size.width, preview.document.size.height), (8, 6));
|
||||
assert!(ctx.request_batch_interrupt(), "Esc is accepted while the first action runs");
|
||||
resume_tx.send(()).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(ctx.batch_waiting_for_decision(), "the worker stopped before writing");
|
||||
assert!(!std::path::Path::new(&join(&dir, "out/first_001.png")).exists());
|
||||
assert!(ctx.decide_batch_interrupt(true));
|
||||
let (result, first_out, second_out) = worker.join().unwrap().unwrap();
|
||||
assert_eq!(result["files"], json!([second_out]), "Continue keeps the original serial: {result}");
|
||||
assert!(!std::path::Path::new(&first_out).exists());
|
||||
assert!(std::path::Path::new(&second_out).exists());
|
||||
assert!(ctx.batch_preview().is_none(), "the preview closes after the last input");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn batch_stop_after_interruption_preserves_prior_outputs_and_never_saves_current() {
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
let dir = tmp("batch-interrupt-stop");
|
||||
let first = png(&dir, "first.png", 8, 6, "#ff0000");
|
||||
let second = png(&dir, "second.png", 8, 6, "#00ff00");
|
||||
let third = png(&dir, "third.png", 8, 6, "#0000ff");
|
||||
let out = join(&dir, "out");
|
||||
let first_out = join(&out, "first.png");
|
||||
let second_out = join(&out, "second.png");
|
||||
let third_out = join(&out, "third.png");
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
let worker_ctx = ctx.clone();
|
||||
let (opened_tx, opened_rx) = mpsc::channel();
|
||||
let (resume_tx, resume_rx) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
|
||||
super::batch_run::process_batch_outputs(
|
||||
72.0,
|
||||
vec![(first, first_out), (second, second_out), (third, third_out)],
|
||||
"png",
|
||||
SaveOpts::default(),
|
||||
&policy,
|
||||
&worker_ctx,
|
||||
&|_, path| {
|
||||
if path.ends_with("second.png") {
|
||||
let _ = opened_tx.send(());
|
||||
let _ = resume_rx.recv_timeout(Duration::from_secs(10));
|
||||
}
|
||||
Ok(false)
|
||||
},
|
||||
)
|
||||
});
|
||||
opened_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
assert!(ctx.request_batch_interrupt());
|
||||
resume_tx.send(()).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(ctx.batch_waiting_for_decision());
|
||||
assert!(ctx.decide_batch_interrupt(false));
|
||||
let result = worker.join().unwrap().unwrap();
|
||||
assert_eq!(result["files"], json!([join(&out, "first.png")]), "{result}");
|
||||
assert_eq!(result["cancelled"], true, "{result}");
|
||||
assert_eq!(result["skipped"], json!([join(&dir, "second.png"), join(&dir, "third.png")]), "{result}");
|
||||
assert!(std::path::Path::new(&join(&out, "first.png")).exists());
|
||||
assert!(!std::path::Path::new(&join(&out, "second.png")).exists());
|
||||
assert!(!std::path::Path::new(&join(&out, "third.png")).exists());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn batch_continue_keeps_errors_before_and_after_the_interruption_in_one_log() {
|
||||
use std::time::Duration;
|
||||
|
||||
let dir = tmp("batch-interrupt-error-log");
|
||||
let second = png(&dir, "second.png", 8, 6, "#00ff00");
|
||||
let fourth = png(&dir, "fourth.png", 8, 6, "#0000ff");
|
||||
let before = join(&dir, "missing-before.png");
|
||||
let after = join(&dir, "missing-after.png");
|
||||
let out = join(&dir, "out");
|
||||
let log = join(&dir, "errors.txt");
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
let decider_ctx = ctx.clone();
|
||||
let decider = std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !decider_ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
if !decider_ctx.batch_waiting_for_decision() {
|
||||
decider_ctx.cancel();
|
||||
return false;
|
||||
}
|
||||
decider_ctx.decide_batch_interrupt(true)
|
||||
});
|
||||
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: Some(log.clone()) };
|
||||
let result = super::batch_run::process_batch_outputs(
|
||||
72.0,
|
||||
vec![
|
||||
(before.clone(), join(&out, "missing-before.png")),
|
||||
(second.clone(), join(&out, "second.png")),
|
||||
(after.clone(), join(&out, "missing-after.png")),
|
||||
(fourth, join(&out, "fourth.png")),
|
||||
],
|
||||
"png",
|
||||
SaveOpts::default(),
|
||||
&policy,
|
||||
&ctx,
|
||||
&|_, path| {
|
||||
if path.ends_with("second.png") {
|
||||
assert!(ctx.request_batch_interrupt());
|
||||
}
|
||||
Ok(false)
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(decider.join().unwrap());
|
||||
assert_eq!(result["errors"].as_array().map(Vec::len), Some(2), "{result}");
|
||||
assert_eq!(result["skipped"], json!([second]), "{result}");
|
||||
assert_eq!(result["files"], json!([join(&out, "fourth.png")]), "{result}");
|
||||
let text = batch_log(&log);
|
||||
assert!(text.contains("missing-before.png") && text.contains("missing-after.png"), "{text}");
|
||||
assert!(!text.contains("second.png"), "a skipped input is not an error: {text}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn batch_continue_between_inputs_still_processes_the_next_input() {
|
||||
use std::time::Duration;
|
||||
|
||||
let dir = tmp("batch-interrupt-between-inputs");
|
||||
let input = png(&dir, "next.png", 8, 6, "#ff0000");
|
||||
let output = join(&dir, "out/next.png");
|
||||
let ctx = crate::jobs::JobCtx::new();
|
||||
assert!(ctx.request_batch_interrupt());
|
||||
let decider_ctx = ctx.clone();
|
||||
let decider = std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !decider_ctx.batch_waiting_for_decision() && std::time::Instant::now() < deadline {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
if !decider_ctx.batch_waiting_for_decision() {
|
||||
decider_ctx.cancel();
|
||||
return false;
|
||||
}
|
||||
decider_ctx.decide_batch_interrupt(true)
|
||||
});
|
||||
let policy = super::batch_run::BatchPolicy { stop_on_error: false, error_log: None };
|
||||
let result =
|
||||
super::batch_run::process_batch_outputs(72.0, vec![(input, output.clone())], "png", SaveOpts::default(), &policy, &ctx, &|_, _| Ok(false)).unwrap();
|
||||
assert!(decider.join().unwrap());
|
||||
assert_eq!(result["files"], json!([output]), "the unopened input is still next: {result}");
|
||||
assert!(result.get("skipped").is_none(), "{result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_error_log_cannot_replace_an_input_or_result() {
|
||||
let dir = tmp("batch-error-log-collision");
|
||||
let input = png(&dir, "source.png", 8, 6, "#ff0000");
|
||||
let original = std::fs::read(&input).unwrap();
|
||||
let output = join(&dir, "out");
|
||||
for log in [input.clone(), join(&output, "source.png"), join(&output, "./source.png"), join(&output, "../out/source.png")] {
|
||||
let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err();
|
||||
assert!(error.to_string().contains("errorLog"), "{error}");
|
||||
assert_eq!(std::fs::read(&input).unwrap(), original);
|
||||
assert!(!std::path::Path::new(&join(&output, "source.png")).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_naming_rejects_an_input_alias_through_a_missing_parent() {
|
||||
let dir = tmp("batch-missing-parent-alias");
|
||||
let first = png(&dir, "a.png", 8, 6, "#ff0000");
|
||||
let second = png(&dir, "b.png", 8, 6, "#00ff00");
|
||||
let original = std::fs::read(&second).unwrap();
|
||||
let output = join(&dir, "missing/..");
|
||||
let error =
|
||||
Session::new().execute("file.automate.batch", json!({"steps": [], "input": [first, second], "output": output, "naming": "b.{ext}"})).unwrap_err();
|
||||
assert!(error.to_string().contains("replace an input"), "{error}");
|
||||
assert_eq!(std::fs::read(&second).unwrap(), original);
|
||||
assert!(!std::path::Path::new(&join(&dir, "missing")).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_batch_cannot_replace_a_later_unread_input() {
|
||||
let dir = tmp("batch-flat-input-alias");
|
||||
let input_png = png(&dir, "a.png", 8, 6, "#0000ff");
|
||||
let mut s = session(8, 6, 8);
|
||||
save_doc(doc(&s), &join(&dir, "a.jpg"), None).unwrap();
|
||||
let before = std::fs::read(&input_png).unwrap();
|
||||
let result = s.execute("file.automate.batch", json!({"steps": [], "input": dir, "output": dir, "format": "png"}));
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("input")), "{result:?}");
|
||||
assert_eq!(std::fs::read(&input_png).unwrap(), before);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn batch_error_log_rejects_a_symlink_parent_alias() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = tmp("batch-symlink-parent-alias");
|
||||
let source_dir = join(&dir, "in");
|
||||
std::fs::create_dir_all(&source_dir).unwrap();
|
||||
let input = png(&source_dir, "result.png", 8, 6, "#ff0000");
|
||||
let other = join(&dir, "other");
|
||||
std::fs::create_dir_all(join(&other, "child")).unwrap();
|
||||
symlink(join(&other, "child"), join(&dir, "link")).unwrap();
|
||||
let output = other;
|
||||
let log = join(&dir, "link/../result.png");
|
||||
let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err();
|
||||
assert!(error.to_string().contains("errorLog"), "{error}");
|
||||
assert!(!std::path::Path::new(&join(&output, "result.png")).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_error_log_rejects_an_uncreated_output_alias() {
|
||||
let dir = tmp("batch-error-log-future-alias");
|
||||
let input = png(&dir, "source.png", 8, 6, "#ff0000");
|
||||
let output = join(&dir, "new-output");
|
||||
std::fs::create_dir_all(join(&output, "alias")).unwrap();
|
||||
let log = join(&output, "alias/../source.png");
|
||||
assert!(same_destination(&log, join(&output, "source.png")));
|
||||
let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err();
|
||||
assert!(error.to_string().contains("errorLog"), "{error}");
|
||||
assert!(!std::path::Path::new(&join(&dir, "new-output/source.png")).exists());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_distinct_case_destinations_are_not_the_same_file() {
|
||||
let dir = tmp("batch-case-sensitive-destinations");
|
||||
assert!(!same_destination(join(&dir, "Image.png"), join(&dir, "image.png")));
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
#[test]
|
||||
fn unicode_case_variants_follow_destination_volume() {
|
||||
let dir = tmp("batch-unicode-case-destinations");
|
||||
let existing = join(&dir, "Ä-probe");
|
||||
std::fs::write(&existing, b"probe").unwrap();
|
||||
let volume_aliases_case = same_file(&existing, join(&dir, "ä-probe"));
|
||||
assert_eq!(same_destination(join(&dir, "Ä.png"), join(&dir, "ä.png")), volume_aliases_case);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
#[test]
|
||||
fn future_folder_case_variants_follow_the_nearest_existing_ancestor() {
|
||||
let dir = tmp("batch-future-folder-case");
|
||||
let existing = join(&dir, "Ä-probe");
|
||||
std::fs::write(&existing, b"probe").unwrap();
|
||||
let volume_aliases_case = same_file(&existing, join(&dir, "ä-probe"));
|
||||
let first = join(&dir, "new/nested/Image.png");
|
||||
let second = join(&dir, "new/nested/image.png");
|
||||
assert_eq!(super::paths::case_sensitive_destination(std::path::Path::new(&first)), Some(!volume_aliases_case));
|
||||
assert_eq!(same_destination(first, second), volume_aliases_case);
|
||||
assert!(!std::path::Path::new(&join(&dir, "new")).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_open_source_edits_each_live_document_and_restores_the_active_one() {
|
||||
let dir = tmp("batch-open-documents");
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap();
|
||||
session.execute("file.new", json!({"name": "Second", "width": 12, "height": 5})).unwrap();
|
||||
session.set_active(0);
|
||||
let result = session
|
||||
.execute(
|
||||
"file.automate.batch",
|
||||
json!({"source": "open", "steps": [["image.imageRotation.90cw", {}]], "output": dir, "format": "png", "naming": "{serial:2}_{NAME}.{EXT}"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
|
||||
assert_eq!(result["documents"], 2);
|
||||
assert_eq!(result["files"].as_array().map(Vec::len), Some(2), "{result}");
|
||||
assert_eq!(session.active_index(), Some(0));
|
||||
assert_eq!((session.documents()[0].doc.size.width, session.documents()[0].doc.size.height), (6, 8));
|
||||
assert_eq!((session.documents()[1].doc.size.width, session.documents()[1].doc.size.height), (5, 12));
|
||||
assert!(std::path::Path::new(&join(&dir, "01_FIRST.PNG")).is_file());
|
||||
assert!(std::path::Path::new(&join(&dir, "02_SECOND.PNG")).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_open_source_exports_later_edits_if_saved_document_stays_active() {
|
||||
let dir = tmp("batch-open-save-point");
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap();
|
||||
session.execute("file.new", json!({"name": "Second", "width": 12, "height": 5})).unwrap();
|
||||
let result = session
|
||||
.execute(
|
||||
"file.automate.batch",
|
||||
json!({
|
||||
"source": "open", "steps": [["document.activate", {"document": 1}], ["file.save", {}], ["image.imageRotation.90cw", {}]],
|
||||
"output": dir, "format": "png", "naming": "{name}.{ext}"
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result["errors"].as_array().unwrap().is_empty(), "{result}");
|
||||
let mut opened = Session::new();
|
||||
opened.execute("file.openAs", json!({"path": join(&dir, "First.png"), "as": "png"})).unwrap();
|
||||
assert_eq!((doc(&opened).size.width, doc(&opened).size.height), (5, 12), "the still-active saved document includes later edits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_open_source_save_as_then_close_ask_writes_the_result() {
|
||||
let dir = tmp("batch-open-save-close-ask");
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap();
|
||||
let result = session
|
||||
.execute(
|
||||
"file.automate.batch",
|
||||
json!({"source": "open", "steps": [["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {"save": "ask"}]], "output": dir, "format": "png"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["errors"], json!([]), "{result}");
|
||||
assert_eq!(result["files"], json!([join(&dir, "First.png")]), "{result}");
|
||||
assert!(session.documents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_open_source_refuses_a_result_that_replaces_another_open_file() {
|
||||
let dir = tmp("batch-open-replaces-input");
|
||||
let mut session = Session::new();
|
||||
// Saved as PNG under its default name, `a.jpg`'s result is the open `a.png`'s file.
|
||||
for (name, format) in [("a.jpg", "jpg"), ("a.png", "png")] {
|
||||
let path = png(&dir, name, 8, 6, "#ff0000");
|
||||
session.execute("file.openAs", json!({"path": path, "as": format})).unwrap();
|
||||
}
|
||||
let before = std::fs::read(join(&dir, "a.png")).unwrap();
|
||||
let error = session
|
||||
.execute("file.automate.batch", json!({"source": "open", "steps": [["image.imageRotation.90cw", {}]], "output": dir, "format": "png"}))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("replace another input"), "{error}");
|
||||
assert_eq!(std::fs::read(join(&dir, "a.png")).unwrap(), before, "nothing is written before the refusal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_open_source_save_step_leaves_an_unwritten_document_changed() {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", json!({"name": "First", "width": 8, "height": 6})).unwrap();
|
||||
let steps = json!([["image.adjustments.invert", {}], ["file.save", {}]]);
|
||||
// No destination: nothing is written.
|
||||
let result = session.execute("file.automate.batch", json!({"source": "open", "steps": steps})).unwrap();
|
||||
assert_eq!(result["errors"], json!([]), "{result}");
|
||||
assert!(session.documents()[0].is_dirty(), "a document Batch did not write stays changed");
|
||||
// A destination that can't be written: the result's name is taken by a folder, so the path
|
||||
// resolves on every platform and the write itself fails.
|
||||
let dir = tmp("batch-open-save-fails");
|
||||
std::fs::create_dir_all(join(&dir, "First.png")).unwrap();
|
||||
let result = session.execute("file.automate.batch", json!({"source": "open", "steps": steps, "output": dir, "format": "png"})).unwrap();
|
||||
assert_eq!(result["errors"].as_array().map(Vec::len), Some(1), "{result}");
|
||||
assert!(session.documents()[0].is_dirty(), "a failed write leaves the document changed");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn batch_error_log_rejects_a_dangling_link_to_a_planned_output() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = tmp("batch-dangling-error-log-alias");
|
||||
let input = png(&dir, "source.png", 8, 6, "#ff0000");
|
||||
let output = join(&dir, "out");
|
||||
let result = join(&output, "source.png");
|
||||
let log = join(&dir, "errors.txt");
|
||||
symlink(&result, &log).unwrap();
|
||||
let error = Session::new().execute("file.automate.batch", json!({"steps": [], "input": [input], "output": output, "errorLog": log})).unwrap_err();
|
||||
assert!(error.to_string().contains("errorLog"), "{error}");
|
||||
assert!(!std::path::Path::new(&result).exists(), "the batch must not write before rejecting the alias");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_reports_file_failures_even_if_error_log_cannot_be_written() {
|
||||
let dir = tmp("batch-error-log-write-fails");
|
||||
let mut session = session(8, 6, 8);
|
||||
let result = session
|
||||
.execute(
|
||||
"file.automate.batch",
|
||||
json!({
|
||||
"source": "open", "steps": [["layer.select", {"layer": "does not exist"}]],
|
||||
"output": dir, "format": "png", "errorLog": dir
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["files"], json!([]));
|
||||
assert_eq!(result["errors"].as_array().map(Vec::len), Some(1), "{result}");
|
||||
assert!(result["errorLogError"].as_str().is_some(), "{result}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn recursive_batch_reports_an_unreadable_subfolder() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tmp("batch-unreadable-subfolder");
|
||||
let sub = join(&dir, "locked");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
png(&sub, "image.png", 8, 6, "#ff0000");
|
||||
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let inaccessible = std::fs::read_dir(&sub).is_err();
|
||||
let result = list_images_tree(&dir, "", "file.automate.batch");
|
||||
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
if inaccessible {
|
||||
assert!(result.is_err(), "an inaccessible input folder must not be omitted");
|
||||
} else {
|
||||
eprintln!("permission regression skipped: this user can read a mode-000 folder");
|
||||
assert!(result.is_ok(), "a privileged reader should still process the folder");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn folder_listing_reports_metadata_denial_and_skips_broken_links() {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
let dir = tmp("batch-list-metadata-denial");
|
||||
let image = png(&dir, "image.png", 8, 6, "#ff0000");
|
||||
symlink(join(&dir, "gone.png"), join(&dir, "broken.png")).unwrap();
|
||||
assert_eq!(list_images(&dir).unwrap(), std::slice::from_ref(&image), "broken links are not inputs");
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).unwrap();
|
||||
let denied = std::fs::metadata(&image).is_err();
|
||||
let result = list_images(&dir);
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
if denied {
|
||||
assert!(result.is_err(), "metadata denial must not turn a folder of images into an empty batch");
|
||||
} else {
|
||||
eprintln!("metadata denial regression skipped: this user can search a mode-0400 folder");
|
||||
assert_eq!(result.unwrap(), [image]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_all_and_close_others() {
|
||||
let mut s = session(8, 8, 8);
|
||||
@@ -86,7 +702,8 @@ fn batch_same_format_saves_camera_raws_as_psd() {
|
||||
let mut files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
|
||||
files.sort();
|
||||
assert_eq!(files.len(), 2, "{r}");
|
||||
assert!(files[0].contains("/IMG_0001") && files[0].ends_with(".psd") && files[1].contains("/b") && files[1].ends_with(".png"), "{files:?}");
|
||||
let suffixes = if naming.is_some() { ("_001", "_002") } else { (".psd", ".png") };
|
||||
assert!(files[0].contains("/IMG_0001") && files[0].ends_with(suffixes.0) && files[1].contains("/b") && files[1].ends_with(suffixes.1), "{files:?}");
|
||||
let psd = photocraft_io::import("IMG_0001.psd", &std::fs::read(&files[0]).unwrap()).unwrap().document;
|
||||
assert_eq!((psd.size.width, psd.size.height), (w as u32, h as u32));
|
||||
}
|
||||
@@ -1527,16 +2144,16 @@ fn batch_naming_serial_keeps_same_named_inputs_and_their_pixels() {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["errors"], json!([]), "{result}");
|
||||
assert_eq!(result["files"], json!([join(&out, "photo_007.png"), join(&out, "photo_008.png")]));
|
||||
for (name, expected) in [("photo_007.png", [1.0, 0.0, 0.0, 1.0]), ("photo_008.png", [0.0, 0.0, 1.0, 1.0])] {
|
||||
let saved = photocraft_io::import(name, &std::fs::read(join(&out, name)).unwrap()).unwrap().document;
|
||||
assert_eq!(result["files"], json!([join(&out, "photo_007"), join(&out, "photo_008")]));
|
||||
for (name, expected) in [("photo_007", [1.0, 0.0, 0.0, 1.0]), ("photo_008", [0.0, 0.0, 1.0, 1.0])] {
|
||||
let saved = photocraft_io::import("photo.png", &std::fs::read(join(&out, name)).unwrap()).unwrap().document;
|
||||
let pixel = photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0];
|
||||
assert_eq!(pixel, expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two inputs with one output name: the first result is kept and the second is an error, never
|
||||
/// a silent overwrite listed as written (#422).
|
||||
/// Batch follows Photoshop's later-file-wins behavior. Image Processor and lens correction
|
||||
/// keep their existing collision error so their outputs are never silently replaced.
|
||||
#[test]
|
||||
fn batch_reports_inputs_that_share_an_output_name() {
|
||||
let dir = tmp("collide");
|
||||
@@ -1560,12 +2177,16 @@ fn batch_reports_inputs_that_share_an_output_name() {
|
||||
let r = s.execute(cmd, p).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
let errors = r["errors"].as_array().unwrap();
|
||||
// `a.*` and `B.png`/`b.tif` (names that differ only in case) each collide once.
|
||||
assert_eq!((files.len(), errors.len()), (2, 2), "{cmd}: {r}");
|
||||
assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("not written")), "{r}");
|
||||
let mut written: Vec<_> = files.iter().map(|f| f.as_str().unwrap().to_lowercase()).collect();
|
||||
written.dedup();
|
||||
assert_eq!(written.len(), 2, "each listed file is distinct: {r}");
|
||||
if cmd == "file.automate.batch" {
|
||||
assert_eq!((files.len(), errors.len()), (4, 0), "{cmd}: {r}");
|
||||
assert_eq!(files[1], files[2], "the later a.png result replaces a.jpg: {r}");
|
||||
} else {
|
||||
assert_eq!((files.len(), errors.len()), (2, 2), "{cmd}: {r}");
|
||||
assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("not written")), "{r}");
|
||||
let mut written: Vec<_> = files.iter().map(|f| f.as_str().unwrap().to_lowercase()).collect();
|
||||
written.dedup();
|
||||
assert_eq!(written.len(), 2, "each listed file is distinct: {r}");
|
||||
}
|
||||
}
|
||||
// Keeping each file's format, nothing collides.
|
||||
let out = join(&dir, "same");
|
||||
@@ -1579,10 +2200,10 @@ fn batch_reports_inputs_that_share_an_output_name() {
|
||||
std::fs::write(&droplet, json!({"photocraftDroplet": 1, "name": "d", "action": {"steps": []}, "options": {"format": "png"}}).to_string()).unwrap();
|
||||
let out = join(&dir, "droplet");
|
||||
let r = s.execute("file.automate.runDroplet", json!({"droplet": droplet, "input": [join(&input, "a.png"), other], "output": out})).unwrap();
|
||||
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 1), "{r}");
|
||||
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (2, 0), "{r}");
|
||||
let kept = photocraft_io::import("a.png", &std::fs::read(join(&out, "a.png")).unwrap()).unwrap().document;
|
||||
let px = photocraft_compose::render(&kept, Rect::new(1, 1, 2, 2)).px[0];
|
||||
assert!(px[0] > 0.99 && px[1] < 0.01, "the first input's result is kept: {px:?}");
|
||||
assert!(px[0] > 0.99 && px[1] > 0.99, "the later input's result replaces the first: {px:?}");
|
||||
}
|
||||
|
||||
/// `file.revealInFinder` (the document tab's Reveal, UI-217-6) reveals the clicked document's
|
||||
@@ -1640,7 +2261,7 @@ fn batch_include_all_subfolders() {
|
||||
let out = join(&input, "batch");
|
||||
let r = s.execute("file.automate.batch", json!({"steps": [], "input": input, "output": out, "format": "png"})).unwrap();
|
||||
assert_eq!((r["files"].as_array().unwrap().len(), r["errors"].as_array().unwrap().len()), (1, 0), "{r}");
|
||||
// With subfolders: every image, in folder order, results kept apart by subfolder; the output
|
||||
// With subfolders: every image, in folder order, results saved flat; the output
|
||||
// folder (inside the input folder, holding last run's a.png) is not an input.
|
||||
let r = s
|
||||
.execute(
|
||||
@@ -1651,12 +2272,12 @@ fn batch_include_all_subfolders() {
|
||||
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
|
||||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
|
||||
let o = out.replace('\\', "/");
|
||||
assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/sub/b.png"), format!("{o}/sub/deeper/a.png"), format!("{o}/z/a.png")]);
|
||||
let deep = photocraft_io::import("a.png", &std::fs::read(join(&out, "sub/deeper/a.png")).unwrap()).unwrap().document;
|
||||
let px = photocraft_compose::render(&deep, Rect::new(1, 1, 2, 2)).px[0];
|
||||
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "blue inverted to yellow: {px:?}");
|
||||
assert_eq!(files, vec![format!("{o}/a.png"), format!("{o}/b.png"), format!("{o}/a.png"), format!("{o}/a.png")]);
|
||||
let last = photocraft_io::import("a.png", &std::fs::read(join(&out, "a.png")).unwrap()).unwrap().document;
|
||||
let px = photocraft_compose::render(&last, Rect::new(1, 1, 2, 2)).px[0];
|
||||
assert!(px[0] < 0.01 && px[1] < 0.01 && px[2] < 0.01, "the last white input is inverted to black: {px:?}");
|
||||
std::fs::remove_dir_all(&out).unwrap();
|
||||
// A naming template applies within each kept subfolder (output outside the input folder).
|
||||
// A naming template applies across all inputs, with serials distinguishing the flat outputs.
|
||||
let named = join(&dir, "named");
|
||||
let r = s
|
||||
.execute("file.automate.batch", json!({"steps": [], "input": input, "includeSubfolders": true, "output": named, "format": "png", "naming": "{serial}"}))
|
||||
@@ -1664,7 +2285,7 @@ fn batch_include_all_subfolders() {
|
||||
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
|
||||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().replace('\\', "/")).collect();
|
||||
let n = named.replace('\\', "/");
|
||||
assert_eq!(files, vec![format!("{n}/001.png"), format!("{n}/sub/002.png"), format!("{n}/sub/deeper/003.png"), format!("{n}/z/004.png")]);
|
||||
assert_eq!(files, vec![format!("{n}/001"), format!("{n}/002"), format!("{n}/003"), format!("{n}/004")]);
|
||||
// Image Processor shares the option (with its results out of the input folder).
|
||||
let out = join(&dir, "jpg");
|
||||
let r = s.execute("file.scripts.imageProcessor", json!({"input": input, "includeSubfolders": true, "output": out})).unwrap();
|
||||
@@ -1777,13 +2398,13 @@ fn batch_naming_keeps_serial_positions_after_errors_and_captures_the_utc_date()
|
||||
assert_eq!(files.len(), 1, "{result}");
|
||||
let name = file_name(files[0].as_str().unwrap());
|
||||
assert!(
|
||||
name == format!("{}_010_good.png", &before[..10]) || name == format!("{}_010_good.png", &after[..10]),
|
||||
name == format!("{}_010_good", &before[..10]) || name == format!("{}_010_good", &after[..10]),
|
||||
"the missing input still consumes its serial: {name}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_naming_still_reports_collisions_instead_of_overwriting() {
|
||||
fn batch_naming_later_result_replaces_the_earlier_one() {
|
||||
let dir = tmp("naming-collision");
|
||||
let red = png(&dir, "red.png", 8, 8, "#ff0000");
|
||||
let blue = png(&dir, "blue.png", 8, 8, "#0000ff");
|
||||
@@ -1797,11 +2418,10 @@ fn batch_naming_still_reports_collisions_instead_of_overwriting() {
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["files"], json!([join(&out, "constant.png")]));
|
||||
assert_eq!(result["errors"].as_array().unwrap().len(), 1, "{result}");
|
||||
assert!(result["errors"][0]["error"].as_str().unwrap().contains("not written"));
|
||||
let saved = photocraft_io::import("constant.png", &std::fs::read(join(&out, "constant.png")).unwrap()).unwrap().document;
|
||||
assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(result["files"], json!([join(&out, "constant"), join(&out, "constant")]));
|
||||
assert_eq!(result["errors"], json!([]), "{result}");
|
||||
let saved = photocraft_io::import("constant.png", &std::fs::read(join(&out, "constant")).unwrap()).unwrap().document;
|
||||
assert_eq!(photocraft_compose::render(&saved, Rect::new(1, 1, 2, 2)).px[0], [0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
#[test]
|
||||
fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() {
|
||||
@@ -1816,7 +2436,7 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() {
|
||||
.execute(
|
||||
"file.automate.batch",
|
||||
json!({
|
||||
"input": [first, second], "output": join(&dir, "."), "steps": [], "naming": "{serial}"
|
||||
"input": [first, second], "output": join(&dir, "."), "steps": [], "naming": "{serial}.{ext}"
|
||||
}),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{EngineError, Result, Session};
|
||||
/// The fill-key command ids (routed to the Channels panel target like `edit.fill`).
|
||||
pub const IDS: [&str; 5] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve", "edit.fillHistory"];
|
||||
|
||||
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##;
|
||||
const PARAMS: &str = r##"{"layer":id|name?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##;
|
||||
|
||||
/// The shape or type layer the colour fill keys recolor instead of filling pixels.
|
||||
fn target_live_layer(s: &Session, p: &Value) -> Option<LayerId> {
|
||||
@@ -31,7 +31,7 @@ fn target_live_layer(s: &Session, p: &Value) -> Option<LayerId> {
|
||||
if st.channel_view.target == crate::channel_cmds::ChannelTarget::LayerMask {
|
||||
return None;
|
||||
}
|
||||
let id = if let Some(v) = p.get("layer") { LayerId(v.as_u64()?) } else { st.active_layer? };
|
||||
let id = crate::commands::layer_param(s, p).ok()?;
|
||||
let layer = st.doc.layer(id)?;
|
||||
if matches!(layer.content, LayerContent::Shape(_) | LayerContent::Text(_)) { Some(id) } else { None }
|
||||
}
|
||||
@@ -411,4 +411,15 @@ mod tests {
|
||||
}
|
||||
assert_eq!(type_colors(&s), [[1.0, 0.0, 0.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_key_recolors_a_named_nonactive_type_layer() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"x": 1, "y": 5, "text": "Hello", "size": 4, "color": "#000000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let name = s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone();
|
||||
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
|
||||
s.execute("edit.fillForeground", json!({"layer": name})).unwrap();
|
||||
let LayerContent::Text(text) = &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content else { panic!("type layer was replaced") };
|
||||
assert_eq!(text.char_runs()[0].style.color.to_rgb(), [1.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -124,10 +124,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Some("ellipse")) => true,
|
||||
Some(_) => return Err(bad("`shape` must be \"rectangle\" or \"ellipse\"".into())),
|
||||
};
|
||||
let content = match p.get("layer") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad(format!("`layer` = {v} is not a layer id")))?)),
|
||||
};
|
||||
let content = crate::commands::explicit_layer(s, p)?;
|
||||
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
|
||||
let gid = s.edit("New Frame", |doc, active| {
|
||||
let mask =
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! hides every effect of that layer; the eye on one effect's row shows or hides that effect. The
|
||||
//! effects keep their settings either way, and each toggle is one history step.
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
@@ -32,10 +31,7 @@ fn set_visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("`index` must be an effect position (0, 1, …)"))?),
|
||||
};
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = match p.get("layer") {
|
||||
None | Some(Value::Null) => st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?,
|
||||
Some(v) => LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?),
|
||||
};
|
||||
let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer).ok_or_else(|| bad("no layer given and no active layer"))?;
|
||||
let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if l.effects.items.is_empty() {
|
||||
return Err(bad(format!("layer {} has no effects", id.0)));
|
||||
@@ -78,6 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_doc::LayerId;
|
||||
|
||||
/// A red layer with a drop shadow and a blue colour overlay, and the background (no effects).
|
||||
fn session() -> (Session, u64, u64) {
|
||||
@@ -98,6 +95,23 @@ mod tests {
|
||||
(fx.enabled, fx.items.iter().map(|e| e.enabled()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_nonactive_effects_layer_can_be_replayed() {
|
||||
let (mut s, a, _) = session();
|
||||
s.execute("layer.new.layer", json!({"name": "top"})).unwrap();
|
||||
let replay = s.active().unwrap().doc.as_ref().clone();
|
||||
s.execute("actions.record", json!({"name": "Hide effects"})).unwrap();
|
||||
s.execute("layer.setEffectsVisible", json!({"layer": a, "visible": false})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert!(!state(&s, a).0);
|
||||
let steps = s.execute("actions.get", json!({"action": "Hide effects"})).unwrap()["steps"].clone();
|
||||
assert_eq!(steps[0][1]["layer"], "a");
|
||||
s.add_document(replay, None);
|
||||
let played = s.execute("actions.play", json!({"action": "Hide effects"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
assert!(!state(&s, a).0, "the recorded name targeted the nonactive layer on replay");
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
@@ -614,6 +614,26 @@ const MAX_CROP_SIDE: i32 = 300_000;
|
||||
|
||||
/// Image → Crop (to the selection bounds).
|
||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crop_with(s, p, false)
|
||||
}
|
||||
|
||||
/// Image › Reveal All's crop: as `image.crop` with Delete Cropped Pixels off, but the Background
|
||||
/// stays a Background and an extension takes the background colour (Reveal All keeps its own
|
||||
/// behaviour; only the Crop tool's was measured, #10867).
|
||||
pub(crate) fn crop_keeping_background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crop_with(s, p, true)
|
||||
}
|
||||
|
||||
/// A Delete Cropped Pixels off crop turns the Background into a normal layer ("Layer 0", as Layer ›
|
||||
/// New › Layer from Background names it), as in Photoshop: it keeps the pixels past the canvas,
|
||||
/// and an extension is transparent on every layer (#10867).
|
||||
fn unlock_background_for_crop(doc: &mut Document) {
|
||||
if let Some(bg) = doc.background_mut() {
|
||||
crate::extra_cmds::unlock_background(bg);
|
||||
}
|
||||
}
|
||||
|
||||
fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value> {
|
||||
let delete = p.get("deleteCroppedPixels").and_then(Value::as_bool).unwrap_or(true);
|
||||
// The Crop tool's Fill: Content-Aware Fill of what the crop uncovers (#3823).
|
||||
let content_aware = match p.get("contentAware") {
|
||||
@@ -621,6 +641,8 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(v) => return Err(bad("image.crop", format!("`contentAware` must be true or false, not {v}"))),
|
||||
};
|
||||
// Content-Aware Fill with Delete off wasn't measured: it keeps filling the Background (#10867).
|
||||
let unlock = !delete && !content_aware && !keep_background;
|
||||
// Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to
|
||||
// the numbers passed; reject them (see `commands::int_i32`).
|
||||
let explicit = match (
|
||||
@@ -652,7 +674,7 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(a) = crop_angle(p)? {
|
||||
let rect = explicit.ok_or_else(|| bad("image.crop", "an `angle` needs an explicit `x`, `y`, `width` and `height`"))?;
|
||||
return rotated_crop(s, rect, a, delete, content_aware, &target);
|
||||
return rotated_crop(s, rect, a, delete, content_aware, unlock, &target);
|
||||
}
|
||||
// An explicit rectangle (the Crop tool) may extend past the canvas; a selection crop is clamped.
|
||||
let r = explicit
|
||||
@@ -665,6 +687,9 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let extension = s.tools.background;
|
||||
s.edit("Crop", |doc, active| {
|
||||
let original = doc.size;
|
||||
if unlock {
|
||||
unlock_background_for_crop(doc);
|
||||
}
|
||||
crop_doc(doc, "image.crop", r, delete, extension)?;
|
||||
// A straight crop fills all of the area past the old canvas, pixels an earlier Delete Cropped
|
||||
// Pixels off crop hid there included, as Photoshop does; only a turned frame keeps them (#8060).
|
||||
@@ -796,8 +821,10 @@ fn crop_angle(p: &Value) -> Result<Option<f64>> {
|
||||
/// background colour, other layers keep transparency there and every pixel of theirs), which makes
|
||||
/// the frame upright, then cropped to it (`deleteCroppedPixels` as for an unrotated crop), all in
|
||||
/// one undo step. With `content_aware`, the corners the rotation uncovers (and any area past the
|
||||
/// rotated image) are then filled from their surroundings, in the same step (#3823).
|
||||
fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, crop_target: &CropTarget) -> Result<Value> {
|
||||
/// rotated image) are then filled from their surroundings, in the same step (#3823). With `unlock`
|
||||
/// the Background becomes a normal layer before the turn, so it turns like the others, keeping its
|
||||
/// overhang and leaving the uncovered corners transparent (#10867).
|
||||
fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_aware: bool, unlock: bool, crop_target: &CropTarget) -> Result<Value> {
|
||||
const CMD: &str = "image.crop";
|
||||
let size = s.active().ok_or(EngineError::NoDocument)?.doc.size;
|
||||
let (w, h) = (f64::from(r.width()), f64::from(r.height()));
|
||||
@@ -860,6 +887,12 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if unlock {
|
||||
s.edit("Crop", |doc, _| {
|
||||
unlock_background_for_crop(doc);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
crate::mode_cmds::rotate_arbitrary(s, &json!({"angle": angle, "direction": "ccw"}))?;
|
||||
// Color Sampler points follow the crop's origin shift too (the rotation moved them above).
|
||||
s.set_sampler_map(crop_target.sampler_map(target));
|
||||
@@ -1042,7 +1075,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"image.crop",
|
||||
"Crop",
|
||||
["Image"],
|
||||
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##,
|
||||
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##,
|
||||
has_doc,
|
||||
crop
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -134,9 +134,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s,
|
||||
"vertically"
|
||||
)),
|
||||
spec!("layer.linkLayers", "Link Layers", &["Layer"], None, "{} (toggles: unlinks when the selection is already one link group)", can_link, |s, _| {
|
||||
link_layers(s)
|
||||
}),
|
||||
spec!(
|
||||
"layer.linkLayers",
|
||||
"Link Layers",
|
||||
&["Layer"],
|
||||
None,
|
||||
r#"{"linked":bool?} (omitted: toggle; explicit true links, false unlinks)"#,
|
||||
can_link,
|
||||
link_layers
|
||||
),
|
||||
spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)),
|
||||
spec!(
|
||||
"layer.new.groupFromLayers",
|
||||
|
||||
@@ -133,6 +133,15 @@ pub(super) fn same_named_targets(doc: &photocraft_doc::Document, name: &str, mov
|
||||
/// (omitting `layer`) so that the recorded step applies to whatever layer is targeted when
|
||||
/// replayed or exported to `.atn` (#7248).
|
||||
pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Option<&[LayerId]>) -> Value {
|
||||
if id == "select.loadSelection"
|
||||
&& let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId)
|
||||
{
|
||||
return recorded_source_layer(s, p, layer_id);
|
||||
}
|
||||
if crate::actions_record::needs_source_layer(id) {
|
||||
// An explicit layer supplies content, even when it is the sole selection.
|
||||
return p;
|
||||
}
|
||||
if id == "layer.select" {
|
||||
return recorded_selection(s, p);
|
||||
}
|
||||
@@ -159,9 +168,6 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio
|
||||
}
|
||||
return p;
|
||||
}
|
||||
if id == "select.loadSelection" {
|
||||
return recorded_source_layer(s, p, layer_id);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,28 @@ use super::{Document, EngineError, Layer, LayerContent, LayerId, PixelFormat, Re
|
||||
|
||||
// ---------- structural commands ----------
|
||||
|
||||
pub(super) fn link_layers(s: &mut Session) -> Result<Value> {
|
||||
/// Set an explicit link state idempotently, or toggle when omitted, with singleton cleanup and Undo.
|
||||
pub(super) fn link_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let requested = match p.get("linked") {
|
||||
None => None,
|
||||
Some(Value::Bool(value)) => Some(*value),
|
||||
Some(_) => return Err(bad("layer.linkLayers", "`linked` must be true or false")),
|
||||
};
|
||||
let sel = selected(s);
|
||||
let linked = s.edit("Link Layers", |doc, _| {
|
||||
let groups: Vec<Option<u64>> = sel.iter().map(|id| doc.layer(*id).and_then(|l| l.link_group)).collect();
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let groups = sel.iter().map(|id| doc.layer(*id).map(|l| l.link_group).ok_or(EngineError::NoLayer(*id))).collect::<Result<Vec<_>>>()?;
|
||||
let first = groups.first().copied().flatten();
|
||||
let same_group = first.is_some() && groups.iter().all(|group| *group == first);
|
||||
// A recorded Link/Unlink is an operation, not the UI's state-dependent toggle.
|
||||
// An explicit repeat must neither allocate a group nor add an undo entry.
|
||||
let linked_group = same_group && doc.walk().iter().filter(|(_, _, layer)| layer.link_group == first).take(2).count() == 2;
|
||||
if requested == Some(true) && linked_group || requested == Some(false) && groups.iter().all(Option::is_none) {
|
||||
return Ok(json!({"linked": requested == Some(true)}));
|
||||
}
|
||||
let label = if requested == Some(false) { "Unlink Layers" } else { "Link Layers" };
|
||||
let linked = s.edit(label, |doc, _| {
|
||||
// Already one link group (or a single linked layer): unlink, like Photoshop's toggle.
|
||||
let first = groups.first().copied().flatten();
|
||||
let unlink = first.is_some() && groups.iter().all(|g| *g == first);
|
||||
let unlink = requested.map_or(same_group, |linked| !linked) || sel.len() < 2;
|
||||
// A loaded file can store any id up to u64::MAX, so the new id must not be a bare
|
||||
// max + 1 (#961). Unlinking needs no new id at all.
|
||||
let next = if unlink { None } else { Some(doc.unused_link_group().ok_or_else(|| bad("layer.linkLayers", "no free link group id is left"))?) };
|
||||
|
||||
@@ -4,23 +4,31 @@ use super::{BlendMode, Document, EngineError, Layer, LayerId, LayerMask, Result,
|
||||
/// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that
|
||||
/// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays
|
||||
/// in another document; a name that matches no layer exactly matches regardless of case, so
|
||||
/// `"background"` finds that name). `{"background":true}` targets the actual Background,
|
||||
/// independently of its name. `None` when the param is absent.
|
||||
/// `"background"` finds that name). A recorded `{"name":"Layer"}` uses the same lookup.
|
||||
/// `{"background":true}` targets the actual Background, independently of its name. `None`
|
||||
/// when the param is absent.
|
||||
pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result<Option<LayerId>> {
|
||||
let named = |name: &str| {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let layers = d.doc.walk();
|
||||
let mut top_down = layers.iter().rev();
|
||||
top_down
|
||||
.clone()
|
||||
.find(|(_, _, l)| l.name == name)
|
||||
.or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase()))
|
||||
.map(|(_, _, l)| Some(l.id))
|
||||
.ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc))))
|
||||
};
|
||||
match p.get("layer") {
|
||||
Some(Value::Number(n)) => {
|
||||
n.as_u64().map(|id| Some(LayerId(id))).ok_or_else(|| EngineError::Other(format!("layer id {n} is not a non-negative integer")))
|
||||
}
|
||||
Some(Value::String(name)) => {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let layers = d.doc.walk();
|
||||
let mut top_down = layers.iter().rev();
|
||||
top_down
|
||||
.clone()
|
||||
.find(|(_, _, l)| l.name == *name)
|
||||
.or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase()))
|
||||
.map(|(_, _, l)| Some(l.id))
|
||||
.ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc))))
|
||||
Some(Value::String(name)) => named(name),
|
||||
Some(Value::Object(selector))
|
||||
if selector.len() == 1
|
||||
&& let Some(name) = selector.get("name").and_then(Value::as_str) =>
|
||||
{
|
||||
named(name)
|
||||
}
|
||||
// A group end is no layer: fail rather than fall back to the selection.
|
||||
Some(v @ Value::Object(fields)) if fields.contains_key("index") || fields.contains_key("ordinal") => {
|
||||
@@ -31,7 +39,7 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result<Option<LayerId>>
|
||||
d.doc.background().map(|l| Some(l.id)).ok_or_else(|| EngineError::Other("the document has no Background layer".into()))
|
||||
}
|
||||
Some(Value::Null) | None => Ok(None),
|
||||
Some(other) => Err(EngineError::Other(format!("`layer` must be an id, a name or {{\"background\":true}}, not {other}"))),
|
||||
Some(other) => Err(EngineError::Other(format!("`layer` must be an id, a name, {{\"name\":string}} or {{\"background\":true}}, not {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,8 +67,8 @@ fn edited(s: &Session, base: &Fill, p: &Value) -> Result<(Fill, Option<Pattern>)
|
||||
|
||||
/// The target layer and the fill `p` gives it, checked against the active document.
|
||||
fn target(s: &Session, p: &Value) -> Result<(photocraft_doc::LayerId, Fill, Fill, Option<Pattern>)> {
|
||||
if !p.is_object() || p.get("layer").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err(bad("params must be an object and layer an unsigned id"));
|
||||
if !p.is_object() {
|
||||
return Err(bad("params must be an object"));
|
||||
}
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
if let Some(document) = p.get("document")
|
||||
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Pattern Fill…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id? (default active; a Pattern Fill layer),"document":id? (reject a stale dialog),"pattern":id|name?,"patternName":name? (then `pattern` is an id; the name is used when no pattern has that id),"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]? (Snap to Origin: [0,0])}; omitted values keep the layer's"##,
|
||||
params: r##"{"layer":id|name? (default active; a Pattern Fill layer),"document":id? (reject a stale dialog),"pattern":id|name?,"patternName":name? (then `pattern` is an id; the name is used when no pattern has that id),"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]? (Snap to Origin: [0,0])}; omitted values keep the layer's"##,
|
||||
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
|
||||
run: set,
|
||||
journal: true,
|
||||
@@ -165,6 +165,14 @@ mod tests {
|
||||
f.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_nonactive_pattern_fill_can_be_edited() {
|
||||
let (mut s, id) = session(8);
|
||||
let name = s.active().unwrap().doc.layer(id).unwrap().name.clone();
|
||||
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
|
||||
assert_eq!(s.execute(SET, json!({"layer": name, "scale": 125})).unwrap()["layer"], id.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_keeps_masks_is_one_step_and_undoes_at_every_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
|
||||
@@ -874,7 +874,9 @@ fn mask_unselected(sel: &Surface, area: Rect, w: usize, h: usize, rgba: &mut [[f
|
||||
true
|
||||
}
|
||||
|
||||
/// Validate the selected layer, then open cropped scan photos without editing the source document.
|
||||
fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
crop_ready(s)?;
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc = st.doc.clone();
|
||||
let fmt = doc.pixel_format();
|
||||
@@ -942,13 +944,18 @@ fn crop_and_straighten(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- specs ----------
|
||||
|
||||
/// Photoshop greys Crop and Straighten Photos out for Indexed Color documents.
|
||||
fn not_indexed(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
/// Crop needs a selected, visible, pixel-unlocked layer, including inherited state.
|
||||
fn crop_ready(s: &Session) -> Result<()> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
if st.doc.mode == ColorMode::Indexed {
|
||||
return Err("not available for Indexed Color documents".into());
|
||||
return Err(EngineError::Other("not available for Indexed Color documents".into()));
|
||||
}
|
||||
Ok(())
|
||||
let id = st.active_layer.ok_or_else(|| EngineError::Other("no layer selected".into()))?;
|
||||
let path = st.doc.path_of(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if !st.doc.shown_at(&path) {
|
||||
return Err(EngineError::Other("the selected layer is hidden".into()));
|
||||
}
|
||||
crate::commands::check_pixels_unlocked(&st.doc, id)
|
||||
}
|
||||
|
||||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
@@ -983,7 +990,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
menu: &["File", "Automate"],
|
||||
shortcut: None,
|
||||
params: "{} → one new document per photo found on the scan (inside the selection when there is one)",
|
||||
enabled: not_indexed,
|
||||
enabled: |s| crop_ready(s).map_err(|e| e.to_string()),
|
||||
run: crop_and_straighten,
|
||||
journal: true,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,12 @@ fn layers_hit(doc: &Document, x: i32, y: i32, type_bounds: bool) -> Vec<LayerId>
|
||||
if x.checked_add(1).is_none() || y.checked_add(1).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Off the canvas nothing is drawn (only artboard documents show layers over the pasteboard,
|
||||
// #5712), so pixels hanging past the edge and fill layers can't be hit there: a pasteboard
|
||||
// click with Auto-Select clears the layer selection instead of picking them (#10665).
|
||||
if !doc.has_artboards() && !doc.bounds().contains(x, y) {
|
||||
return Vec::new();
|
||||
}
|
||||
let rows = doc.walk();
|
||||
let mut out = Vec::new();
|
||||
for (path, _, l) in rows.iter().rev() {
|
||||
@@ -114,13 +120,15 @@ fn pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Layers whose content touches document rect `r`, top to bottom: visible, not fully locked, not
|
||||
/// the locked Background (as Select › All Layers). A layer on an artboard counts only where the
|
||||
/// board shows it. With `group`, each counts as its outermost group (deduplicated).
|
||||
/// board shows it, and without artboards only where the canvas does (#10665). With `group`, each
|
||||
/// counts as its outermost group (deduplicated).
|
||||
pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec<LayerId> {
|
||||
let mut out = Vec::new();
|
||||
let canvas = doc.bounds();
|
||||
let r = if doc.has_artboards() { r } else { r.intersect(&canvas) };
|
||||
if r.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let canvas = doc.bounds();
|
||||
for (path, _, l) in doc.walk().iter().rev() {
|
||||
if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_))
|
||||
|| doc.is_background(l.id)
|
||||
@@ -447,6 +455,30 @@ mod tests {
|
||||
assert!(empty.execute("layer.pickAt", json!({"x": 1, "y": 1})).is_err());
|
||||
}
|
||||
|
||||
/// #10665: off the canvas nothing is drawn, so a fill layer or pixels hanging past the edge
|
||||
/// are neither picked there nor caught by a marquee that touches only the pasteboard.
|
||||
#[test]
|
||||
fn nothing_is_hit_off_the_canvas() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 40, "background": "transparent"})).unwrap();
|
||||
let fill = s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Off"})).unwrap();
|
||||
let off = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("paint past the edge", |doc, _| {
|
||||
doc.layer_mut(off).unwrap().surface_mut().unwrap().fill_rect(Rect::new(-20, 0, 10, 40), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let pick = |s: &mut Session, x: i32, y: i32| s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"].clone();
|
||||
assert_eq!(pick(&mut s, 5, 5), off.0, "on the canvas");
|
||||
assert_eq!(pick(&mut s, 20, 5), fill, "the fill layer covers the canvas");
|
||||
assert_eq!(pick(&mut s, -10, 5), Value::Null, "the pasteboard, over Off's hidden pixels and the fill");
|
||||
assert_eq!(s.execute("layer.pickAt", json!({"x": -10, "y": 5, "list": true})).unwrap()["layers"], json!([]));
|
||||
let in_rect = |s: &mut Session, x: f64| s.execute("layer.selectInRect", json!({"x": x, "y": 0, "width": 5, "height": 5})).unwrap()["layers"].clone();
|
||||
assert_eq!(in_rect(&mut s, -15.0), json!([]), "a marquee on the pasteboard only");
|
||||
assert_eq!(in_rect(&mut s, -3.0), json!([off.0, fill]), "a marquee reaching onto the canvas");
|
||||
}
|
||||
|
||||
/// A hide-all mask at density 0 hides nothing (the compositor applies density through
|
||||
/// `LayerMask::value`), so the layer stays pickable; at full density it is clicked through.
|
||||
#[test]
|
||||
|
||||
@@ -197,8 +197,8 @@ pub fn layers_to_smart(doc: &Document, name: &str, layers: Vec<Layer>) -> Result
|
||||
}
|
||||
|
||||
pub(super) fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(id) => vec![LayerId(id)],
|
||||
let ids = match crate::commands::explicit_layer(s, p)? {
|
||||
Some(id) => vec![id],
|
||||
None => crate::layer_multi_cmds::selected(s),
|
||||
};
|
||||
let new_id = s.edit("Convert to Smart Object", |doc, active| {
|
||||
|
||||
@@ -45,8 +45,8 @@ pub fn color_param(value: &Value, original: Color, mode: ColorMode) -> Result<Co
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !p.is_object() || p.get("layer").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err(bad("params must be an object and layer an unsigned id"));
|
||||
if !p.is_object() {
|
||||
return Err(bad("params must be an object"));
|
||||
}
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
if let Some(document) = p.get("document")
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Solid Color…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
|
||||
params: r##"{"layer":id|name? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
|
||||
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
|
||||
run: set,
|
||||
journal: true,
|
||||
@@ -95,6 +95,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn named_nonactive_solid_fill_can_be_edited() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let id = s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let name = s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().name.clone();
|
||||
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
|
||||
assert_eq!(s.execute(SET, json!({"layer": name, "color": "#ff0000"})).unwrap()["layer"], id);
|
||||
}
|
||||
|
||||
/// #8568: CMYK 50/50/50/100 entered for a Solid Color fill in a CMYK document stays exactly
|
||||
/// those inks, on creation and on edit, instead of what its sRGB stand-in converts back to
|
||||
/// through the document profile. RGB documents ignore the inks.
|
||||
|
||||
@@ -24,10 +24,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
|
||||
fn layer_id(s: &Session, p: &Value) -> Result<LayerId> {
|
||||
match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(id) => Ok(LayerId(id)),
|
||||
None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())),
|
||||
}
|
||||
crate::commands::layer_param(s, p)
|
||||
}
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The Type menu: anti-aliasing, orientation, point ⇄ paragraph conversion, Create Work Path,
|
||||
//! Convert to Shape, Rasterize, Warp Text, the OpenType toggles, Paste Lorem Ipsum, Update All
|
||||
//! Text Layers, missing-font replacement and the default type styles.
|
||||
//! Convert to Shape, Rasterize, Warp Text, the OpenType toggles (with the Numerals submenu),
|
||||
//! Paste Lorem Ipsum, Update All Text Layers, missing-font replacement and the default type
|
||||
//! styles.
|
||||
//!
|
||||
//! Everything edits the typed [`TextLayer`] model and re-renders through `type_cmds::refresh`,
|
||||
//! so the PSD `TySh` data (and with it warp, anti-aliasing and the OpenType flags) is regenerated
|
||||
@@ -502,6 +503,88 @@ fn warp_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- OpenType toggles ----------
|
||||
|
||||
/// OpenType figure tags Type › OpenType › Numerals clears or sets (`lnum`/`onum`/`pnum`/`tnum`).
|
||||
pub const NUMERAL_FEATURE_TAGS: [&str; 4] = ["lnum", "onum", "pnum", "tnum"];
|
||||
|
||||
/// One item of Type › OpenType › Numerals (Adobe figure-style radio set).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NumeralsStyle {
|
||||
Default,
|
||||
Lining,
|
||||
Oldstyle,
|
||||
Proportional,
|
||||
Tabular,
|
||||
ProportionalLining,
|
||||
TabularLining,
|
||||
ProportionalOldstyle,
|
||||
TabularOldstyle,
|
||||
}
|
||||
|
||||
impl NumeralsStyle {
|
||||
/// Command-id suffix after `type.openType.numerals.`.
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "default",
|
||||
Self::Lining => "lining",
|
||||
Self::Oldstyle => "oldstyle",
|
||||
Self::Proportional => "proportional",
|
||||
Self::Tabular => "tabular",
|
||||
Self::ProportionalLining => "proportionalLining",
|
||||
Self::TabularLining => "tabularLining",
|
||||
Self::ProportionalOldstyle => "proportionalOldstyle",
|
||||
Self::TabularOldstyle => "tabularOldstyle",
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu label for this numerals style.
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "Default",
|
||||
Self::Lining => "Lining",
|
||||
Self::Oldstyle => "Oldstyle",
|
||||
Self::Proportional => "Proportional",
|
||||
Self::Tabular => "Tabular",
|
||||
Self::ProportionalLining => "Proportional Lining",
|
||||
Self::TabularLining => "Tabular Lining",
|
||||
Self::ProportionalOldstyle => "Proportional Oldstyle",
|
||||
Self::TabularOldstyle => "Tabular Oldstyle",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `type.openType.numerals.*` suffix.
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|style| style.id() == id)
|
||||
}
|
||||
|
||||
/// Every Numerals menu item, in menu order.
|
||||
pub const ALL: [Self; 9] = [
|
||||
Self::Default,
|
||||
Self::Lining,
|
||||
Self::Oldstyle,
|
||||
Self::Proportional,
|
||||
Self::Tabular,
|
||||
Self::ProportionalLining,
|
||||
Self::TabularLining,
|
||||
Self::ProportionalOldstyle,
|
||||
Self::TabularOldstyle,
|
||||
];
|
||||
|
||||
/// OpenType tags this style turns on (the other [`NUMERAL_FEATURE_TAGS`] are cleared).
|
||||
pub const fn tags(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Default => &[],
|
||||
Self::Lining => &["lnum"],
|
||||
Self::Oldstyle => &["onum"],
|
||||
Self::Proportional => &["pnum"],
|
||||
Self::Tabular => &["tnum"],
|
||||
Self::ProportionalLining => &["pnum", "lnum"],
|
||||
Self::TabularLining => &["tnum", "lnum"],
|
||||
Self::ProportionalOldstyle => &["pnum", "onum"],
|
||||
Self::TabularOldstyle => &["tnum", "onum"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the OpenType toggle `tag` on for a character style? (`liga`/`dlig` have dedicated fields.)
|
||||
pub fn feature_on(st: &photocraft_doc::text::CharStyle, tag: &str) -> bool {
|
||||
match tag {
|
||||
@@ -511,6 +594,36 @@ pub fn feature_on(st: &photocraft_doc::text::CharStyle, tag: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The character style at character `ci` of `t`: its run's, or past the end the last run's.
|
||||
fn style_at(t: &TextLayer, ci: usize) -> Option<photocraft_doc::text::CharStyle> {
|
||||
let at = byte_index(&t.text, ci);
|
||||
let mut end = 0usize;
|
||||
let mut last = None;
|
||||
for run in t.char_runs() {
|
||||
end = end.saturating_add(run.len);
|
||||
if at < end {
|
||||
return Some(run.style);
|
||||
}
|
||||
last = Some(run.style);
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
/// Is the OpenType toggle `tag` on at character `ci` of `t`? Past the end it reads the last run.
|
||||
pub fn feature_on_at(t: &TextLayer, ci: usize, tag: &str) -> bool {
|
||||
style_at(t, ci).is_some_and(|st| feature_on(&st, tag))
|
||||
}
|
||||
|
||||
/// Which Numerals menu item matches `st`'s figure tags? `None` when the mix is not a menu item.
|
||||
pub fn numerals_style_of(st: &photocraft_doc::text::CharStyle) -> Option<NumeralsStyle> {
|
||||
NumeralsStyle::ALL.into_iter().find(|style| NUMERAL_FEATURE_TAGS.iter().all(|tag| feature_on(st, tag) == style.tags().contains(tag)))
|
||||
}
|
||||
|
||||
/// Numerals style at character `ci` of `t` (same run pick as [`feature_on_at`]).
|
||||
pub fn numerals_style_at(t: &TextLayer, ci: usize) -> Option<NumeralsStyle> {
|
||||
style_at(t, ci).and_then(|st| numerals_style_of(&st))
|
||||
}
|
||||
|
||||
fn set_feature(st: &mut photocraft_doc::text::CharStyle, tag: &str, on: bool) {
|
||||
match tag {
|
||||
"liga" => st.ligatures = on,
|
||||
@@ -524,13 +637,37 @@ fn set_feature(st: &mut photocraft_doc::text::CharStyle, tag: &str, on: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_numerals(st: &mut photocraft_doc::text::CharStyle, style: NumeralsStyle) {
|
||||
let on = style.tags();
|
||||
for tag in NUMERAL_FEATURE_TAGS {
|
||||
set_feature(st, tag, on.contains(&tag));
|
||||
}
|
||||
}
|
||||
|
||||
fn set_numerals(s: &mut Session, p: &Value, style: NumeralsStyle) -> Result<Value> {
|
||||
with_text_targets(s, p, "Change OpenType Numerals", |t, (a, b)| {
|
||||
style_range(t, a.min(b), a.max(b), &|st| apply_numerals(st, style));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"numerals": style.id()}))
|
||||
}
|
||||
|
||||
fn toggle_opentype(s: &mut Session, p: &Value, tag: &'static str) -> Result<Value> {
|
||||
let ids = format_targets(s, p)?;
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let representative = st.active_layer.filter(|id| ids.contains(id)).or_else(|| ids.first().copied());
|
||||
let cur = representative
|
||||
.and_then(|id| st.doc.layer(id))
|
||||
.is_some_and(|l| matches!(&l.content, LayerContent::Text(t) if t.char_runs().first().is_some_and(|r| feature_on(&r.style, tag))));
|
||||
// The toggle direction follows the style where the edit starts: the range start on a single
|
||||
// layer, else the first character.
|
||||
let start_char = if p.get("layers").is_none() || p.get("layer").is_some() {
|
||||
p.get("range").and_then(Value::as_array).filter(|r| r.len() == 2).map_or(0, |r| {
|
||||
let a = r.first().and_then(Value::as_u64).unwrap_or(0);
|
||||
let b = r.get(1).and_then(Value::as_u64).unwrap_or(a);
|
||||
usize::try_from(a.min(b)).unwrap_or(usize::MAX)
|
||||
})
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cur = representative.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(&l.content, LayerContent::Text(t) if feature_on_at(t, start_char, tag)));
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!cur);
|
||||
with_text_targets(s, p, "Change OpenType Feature", |t, (a, b)| {
|
||||
style_range(t, a.min(b), a.max(b), &|st| set_feature(st, tag, on));
|
||||
@@ -770,6 +907,41 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("type.openType.ornaments", "Ornaments", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "ornm")),
|
||||
spec!("type.openType.ordinals", "Ordinals", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "ordn")),
|
||||
spec!("type.openType.fractions", "Fractions", &["Type", "OpenType"], OT, has_format_target, |s, p| toggle_opentype(s, p, "frac")),
|
||||
spec!("type.openType.numerals.default", "Default", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
|
||||
s,
|
||||
p,
|
||||
NumeralsStyle::Default
|
||||
)),
|
||||
spec!("type.openType.numerals.lining", "Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
|
||||
set_numerals(s, p, NumeralsStyle::Lining)
|
||||
}),
|
||||
spec!("type.openType.numerals.oldstyle", "Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
|
||||
set_numerals(s, p, NumeralsStyle::Oldstyle)
|
||||
}),
|
||||
spec!("type.openType.numerals.proportional", "Proportional", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
|
||||
s,
|
||||
p,
|
||||
NumeralsStyle::Proportional
|
||||
)),
|
||||
spec!("type.openType.numerals.tabular", "Tabular", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
|
||||
set_numerals(s, p, NumeralsStyle::Tabular)
|
||||
}),
|
||||
spec!("type.openType.numerals.proportionalLining", "Proportional Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
|
||||
set_numerals(s, p, NumeralsStyle::ProportionalLining)
|
||||
}),
|
||||
spec!("type.openType.numerals.tabularLining", "Tabular Lining", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
|
||||
s,
|
||||
p,
|
||||
NumeralsStyle::TabularLining
|
||||
)),
|
||||
spec!("type.openType.numerals.proportionalOldstyle", "Proportional Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| {
|
||||
set_numerals(s, p, NumeralsStyle::ProportionalOldstyle)
|
||||
}),
|
||||
spec!("type.openType.numerals.tabularOldstyle", "Tabular Oldstyle", &["Type", "OpenType", "Numerals"], FP, has_format_target, |s, p| set_numerals(
|
||||
s,
|
||||
p,
|
||||
NumeralsStyle::TabularOldstyle
|
||||
)),
|
||||
spec!("type.createWorkPath", "Create Work Path", &["Type"], LP, has_text, create_work_path),
|
||||
spec!("type.convertToShape", "Convert to Shape", &["Type"], LP, has_text, convert_to_shape),
|
||||
spec!("type.rasterizeTypeLayer", "Rasterize Type Layer", &["Type"], LP, has_text, |s, p| s.execute("type.rasterize", p.clone())),
|
||||
|
||||
@@ -243,6 +243,138 @@ fn opentype_toggles_store_features() {
|
||||
assert!(feature_on(&back.char_runs()[0].style, "swsh") && !back.char_runs()[0].style.ligatures);
|
||||
}
|
||||
|
||||
/// Is `tag` on at character `ci` of an ASCII text layer?
|
||||
fn on_at(s: &Session, id: LayerId, ci: usize, tag: &str) -> bool {
|
||||
let mut end = 0;
|
||||
text(s, id)
|
||||
.char_runs()
|
||||
.iter()
|
||||
.find(|r| {
|
||||
end += r.len;
|
||||
ci < end
|
||||
})
|
||||
.is_some_and(|r| feature_on(&r.style, tag))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerals_are_registered_with_their_menu_path() {
|
||||
for style in NumeralsStyle::ALL {
|
||||
let spec = crate::commands::find(&format!("type.openType.numerals.{}", style.id())).unwrap();
|
||||
assert_eq!(spec.label, style.label());
|
||||
assert_eq!(spec.menu, ["Type", "OpenType", "Numerals"]);
|
||||
}
|
||||
assert!(crate::commands::find("type.openType.numerals.unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerals_replace_figure_tags_and_keep_other_features() {
|
||||
let mut s = session(8);
|
||||
let id = text_layer(&mut s, "012345");
|
||||
let before = text(&s, id).char_runs()[0].style.clone();
|
||||
s.execute("type.openType.fractions", json!({"on": true})).unwrap();
|
||||
s.execute("type.openType.numerals.tabularLining", json!({})).unwrap();
|
||||
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::TabularLining));
|
||||
assert!(on_at(&s, id, 0, "tnum") && on_at(&s, id, 0, "lnum") && !on_at(&s, id, 0, "onum") && !on_at(&s, id, 0, "pnum"));
|
||||
assert!(feature_on(&text(&s, id).char_runs()[0].style, "frac"), "non-figure features stay");
|
||||
s.execute("type.openType.numerals.proportionalOldstyle", json!({})).unwrap();
|
||||
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::ProportionalOldstyle));
|
||||
assert!(on_at(&s, id, 0, "pnum") && on_at(&s, id, 0, "onum") && !on_at(&s, id, 0, "tnum") && !on_at(&s, id, 0, "lnum"));
|
||||
s.execute("type.openType.numerals.default", json!({})).unwrap();
|
||||
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Default));
|
||||
for tag in NUMERAL_FEATURE_TAGS {
|
||||
assert!(!on_at(&s, id, 0, tag), "{tag}");
|
||||
}
|
||||
let st = text(&s, id).char_runs()[0].style.clone();
|
||||
assert!(st.ligatures, "liga untouched");
|
||||
assert_eq!((st.size_pt, st.font_family.clone(), st.color), (before.size_pt, before.font_family, before.color));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerals_range_edits_only_those_characters() {
|
||||
let mut s = session(8);
|
||||
let id = text_layer(&mut s, "abcdef");
|
||||
s.execute("type.openType.numerals.lining", json!({"range": [2, 5]})).unwrap();
|
||||
let on: Vec<bool> = (0..6).map(|i| on_at(&s, id, i, "lnum")).collect();
|
||||
assert_eq!(on, [false, false, true, true, true, false]);
|
||||
assert_eq!(numerals_style_at(text(&s, id), 2), Some(NumeralsStyle::Lining));
|
||||
assert_eq!(numerals_style_at(text(&s, id), 0), Some(NumeralsStyle::Default));
|
||||
s.execute("type.openType.numerals.tabular", json!({"range": [2, 5]})).unwrap();
|
||||
assert!(on_at(&s, id, 3, "tnum") && !on_at(&s, id, 3, "lnum"));
|
||||
assert!(!on_at(&s, id, 0, "tnum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerals_apply_to_every_listed_layer() {
|
||||
let mut s = session(8);
|
||||
let a = text_layer(&mut s, "one");
|
||||
let b = text_layer(&mut s, "two");
|
||||
s.execute("type.openType.numerals.oldstyle", json!({"layers": [a.0, b.0]})).unwrap();
|
||||
assert!(on_at(&s, a, 0, "onum") && on_at(&s, b, 2, "onum"));
|
||||
s.execute("type.openType.numerals.default", json!({"layers": [a.0, b.0]})).unwrap();
|
||||
assert!(!on_at(&s, a, 0, "onum") && !on_at(&s, b, 0, "onum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerals_are_one_undo_step_each() {
|
||||
let mut s = session(8);
|
||||
let id = text_layer(&mut s, "123");
|
||||
s.execute("type.openType.numerals.lining", json!({})).unwrap();
|
||||
s.execute("type.openType.numerals.tabularOldstyle", json!({})).unwrap();
|
||||
assert!(s.undo());
|
||||
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Lining));
|
||||
assert!(s.undo());
|
||||
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Default));
|
||||
assert!(s.redo());
|
||||
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::Lining));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerals_survive_a_pcraft_round_trip() {
|
||||
let mut s = session(8);
|
||||
let id = text_layer(&mut s, "Il36");
|
||||
s.execute("type.openType.numerals.proportionalLining", json!({})).unwrap();
|
||||
let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap();
|
||||
let doc = photocraft_format::load_from_bytes(&bytes).unwrap();
|
||||
let Some(LayerContent::Text(t)) = doc.layer(id).map(|l| &l.content) else { panic!("text layer lost") };
|
||||
assert_eq!(numerals_style_of(&t.char_runs()[0].style), Some(NumeralsStyle::ProportionalLining));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerals_fail_gracefully_on_bad_params() {
|
||||
let mut s = session(8);
|
||||
let bg = s.active().unwrap().active_layer.unwrap();
|
||||
let id = text_layer(&mut s, "abc");
|
||||
let cmd = "type.openType.numerals.tabularLining";
|
||||
assert!(s.execute(cmd, json!({"layer": 987_654})).is_err(), "unknown layer");
|
||||
assert!(s.execute(cmd, json!({"layer": bg.0})).is_err(), "not a type layer");
|
||||
assert!(s.execute(cmd, json!({"layers": ["x"]})).is_err());
|
||||
s.execute(cmd, json!({"range": [5, 2]})).unwrap();
|
||||
s.execute(cmd, json!({"range": [50, 90]})).unwrap();
|
||||
s.execute(cmd, json!({"range": [u64::MAX, 0]})).unwrap();
|
||||
s.execute(cmd, json!({"range": "all"})).unwrap();
|
||||
assert!(s.execute(cmd, json!({"layer": id.0, "range": [0, 1]})).is_ok());
|
||||
assert_eq!(numerals_style_of(&text(&s, id).char_runs()[0].style), Some(NumeralsStyle::TabularLining));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerals_tabular_changes_inter_digit_widths() {
|
||||
let mut s = session(8);
|
||||
let r = s.execute("type.create", json!({"x": 20, "y": 70, "text": "1111111111", "font": "Inter", "size": 36, "color": "#000000"})).unwrap();
|
||||
let id = LayerId(r["layer"].as_u64().unwrap());
|
||||
let width = |s: &Session| {
|
||||
let t = text(s, id).clone();
|
||||
let mut e = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let layout = e.layout(&t, 72.0);
|
||||
layout.lines.first().map_or(0.0, |line| line.x1 - line.x0)
|
||||
};
|
||||
let plain = width(&s);
|
||||
s.execute("type.openType.numerals.tabular", json!({})).unwrap();
|
||||
let tab = width(&s);
|
||||
assert!((tab - plain).abs() > 0.5, "tnum changes Inter's digit advances: {plain} → {tab}");
|
||||
s.execute("type.openType.numerals.default", json!({})).unwrap();
|
||||
assert!((width(&s) - plain).abs() < 0.5, "default restores the advances");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lorem_update_fonts_and_default_styles() {
|
||||
let mut s = session(16);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use photocraft_algo::selection::{self as sel, SelectionMode};
|
||||
use photocraft_doc::text::AntiAlias;
|
||||
use photocraft_doc::{Document, LayerContent, LayerId, TextLayer};
|
||||
use photocraft_doc::{Document, LayerContent, TextLayer};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -87,10 +87,7 @@ fn create_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("removeLayer must be true or false"))?,
|
||||
};
|
||||
let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
let layer = match p.get("layer") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad("layer must be a layer id"))?)),
|
||||
};
|
||||
let layer = crate::commands::explicit_layer(s, p)?;
|
||||
if remove && layer.is_none() {
|
||||
return Err(bad("removeLayer needs a layer"));
|
||||
}
|
||||
@@ -144,7 +141,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Type Mask",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id? (a type layer whose glyphs become the selection),"removeLayer":bool=false (delete that layer in the same step: the Type Mask tools' temporary text),"operation":"new|add|subtract|intersect"="new","antialias":"none|sharp|crisp|strong|smooth"?, …without "layer", type.create's keys: "text","x","y" or "box","orientation","font","size","align",…}"##,
|
||||
params: r##"{"layer":id|name? (a type layer whose glyphs become the selection),"removeLayer":bool=false (delete that layer in the same step: the Type Mask tools' temporary text),"operation":"new|add|subtract|intersect"="new","antialias":"none|sharp|crisp|strong|smooth"?, …without "layer", type.create's keys: "text","x","y" or "box","orientation","font","size","align",…}"##,
|
||||
enabled: has_doc,
|
||||
journal: true,
|
||||
run: create_selection,
|
||||
@@ -154,6 +151,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_doc::LayerId;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -165,6 +163,16 @@ mod tests {
|
||||
s.active().unwrap().doc.selection.as_ref().map(|m| m.content_bounds())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_type_layer_can_make_a_selection_for_action_replay() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"text": "I", "x": 40, "y": 120, "size": 48})).unwrap()["layer"].as_u64().unwrap();
|
||||
let name = s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute(CMD, json!({"layer": name})).unwrap();
|
||||
assert!(bounds(&s).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_text_selects_its_glyphs_without_a_layer() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! (`photocraft_text::spell`, SCOWL size 50) plus the user dictionary in the preferences
|
||||
//! (`userDictionary`). Headless, it lists misspellings with suggestions and changes, changes all,
|
||||
//! or adds words to the dictionary; the UI's dialog drives the same actions. Offsets are
|
||||
//! character indices.
|
||||
//! character indices. An action records Check Spelling as one `{"allLayers"}` step with its
|
||||
//! dialog on, and records none of the corrections.
|
||||
//!
|
||||
//! `type.insertText` inserts a string (a glyph) into a type layer at a character position or
|
||||
//! over a range.
|
||||
@@ -79,14 +80,14 @@ fn accepted(s: &Session, p: &Value) -> HashSet<String> {
|
||||
set
|
||||
}
|
||||
|
||||
fn layers_param(s: &Session, p: &Value) -> Vec<LayerId> {
|
||||
let Some(st) = s.active() else { return Vec::new() };
|
||||
fn layers_param(s: &Session, p: &Value) -> Result<Vec<LayerId>> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let all = type_layers(&st.doc);
|
||||
if p.get("allLayers").and_then(Value::as_bool) == Some(false) || p.get("layer").is_some() {
|
||||
let active = layer_param(s, p).ok();
|
||||
all.into_iter().filter(|id| Some(*id) == active).collect()
|
||||
let active = layer_param(s, p)?;
|
||||
Ok(all.into_iter().filter(|id| *id == active).collect())
|
||||
} else {
|
||||
all
|
||||
Ok(all)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +97,7 @@ fn list(s: &Session, p: &Value) -> Result<Value> {
|
||||
let ok = accepted(s, p);
|
||||
let max = p.get("suggestions").and_then(Value::as_u64).unwrap_or(5) as usize;
|
||||
let mut out = Vec::new();
|
||||
for id in layers_param(s, p) {
|
||||
for id in layers_param(s, p)? {
|
||||
let Some(t) = text(&st.doc, id) else { continue };
|
||||
let found = dict.misspellings(t, &ok);
|
||||
// Convert every start/end byte offset to a character offset in one pass over the
|
||||
@@ -117,7 +118,7 @@ fn list(s: &Session, p: &Value) -> Result<Value> {
|
||||
/// Replaces one occurrence (verified to still be `word` when given).
|
||||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "edit.checkSpelling";
|
||||
let id = LayerId(p.get("layer").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `layer`"))?);
|
||||
let id = layer_param(s, p)?;
|
||||
let start = p.get("start").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `start`"))? as usize;
|
||||
let end = p.get("end").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `end`"))? as usize;
|
||||
let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`change` needs `replace`"))?.to_string();
|
||||
@@ -147,7 +148,7 @@ fn change_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let word = p.get("word").and_then(Value::as_str).filter(|w| !w.is_empty()).ok_or_else(|| bad(cmd, "`changeAll` needs `word`"))?.to_string();
|
||||
let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`changeAll` needs `replace`"))?.to_string();
|
||||
let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
let plan: Vec<(LayerId, Vec<(usize, usize)>)> = layers_param(s, p)
|
||||
let plan: Vec<(LayerId, Vec<(usize, usize)>)> = layers_param(s, p)?
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let t = text(&doc, id)?;
|
||||
@@ -203,6 +204,18 @@ fn remove_word(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "removed": removed, "userDictionary": s.prefs().user_dictionary }))
|
||||
}
|
||||
|
||||
/// Whether an action records this call: only Check Spelling itself (no `action`), which the
|
||||
/// dialog runs when it closes. Its corrections, dictionary edits and lookups are not recorded.
|
||||
pub(crate) fn records(p: &Value) -> bool {
|
||||
p.get("action").is_none_or(Value::is_null)
|
||||
}
|
||||
|
||||
/// The step a recorded Check Spelling becomes: `{"allLayers"}`, `false` when it checked one layer.
|
||||
pub(crate) fn recorded_step(p: &Value) -> Value {
|
||||
let one_layer = p.get("allLayers").and_then(Value::as_bool) == Some(false) || p.get("layer").is_some();
|
||||
json!({ "allLayers": !one_layer })
|
||||
}
|
||||
|
||||
fn check_spelling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("action").and_then(Value::as_str).unwrap_or("list") {
|
||||
"list" | "check" => list(s, p),
|
||||
@@ -270,7 +283,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Check Spelling…",
|
||||
menu: &["Edit"],
|
||||
shortcut: None,
|
||||
params: r#"{"action":"list|change|changeAll|addToDictionary|removeFromDictionary|suggest"="list","allLayers":bool=true,"layer":id? (check one layer),"ignore":[word]? (Ignore All for this check),"suggestions":n=5; change: "layer","start","end" (chars),"word"? (verified),"replace"; changeAll: "word","replace"; addToDictionary/removeFromDictionary/suggest: "word"} → list: {"misspellings":[{"layer","start","end","word","suggestions"}],"count"}"#,
|
||||
params: r#"{"action":"list|change|changeAll|addToDictionary|removeFromDictionary|suggest"="list","allLayers":bool=true,"layer":id|name? (check one layer),"ignore":[word]? (Ignore All for this check),"suggestions":n=5; change: "layer"? (default: the active layer),"start","end" (chars),"word"? (verified),"replace"; changeAll: "word","replace"; addToDictionary/removeFromDictionary/suggest: "word"} → list: {"misspellings":[{"layer","start","end","word","suggestions"}],"count"} (an action records only a call without "action", as {"allLayers"} with its dialog on)"#,
|
||||
enabled: has_type,
|
||||
journal: true,
|
||||
run: check_spelling,
|
||||
@@ -314,6 +327,27 @@ mod tests {
|
||||
text(&s.active().unwrap().doc, LayerId(id)).unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_named_spelling_layer_is_an_error_for_list_and_change_all() {
|
||||
let (mut s, _) = session("Teh");
|
||||
for action in ["list", "changeAll"] {
|
||||
let result = s.execute("edit.checkSpelling", json!({"action": action, "layer": "Removed type", "word": "Teh", "replace": "The"}));
|
||||
assert!(result.is_err(), "{action} silently accepted a missing named layer: {result:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_layer_list_and_change_all_resolve_a_nonactive_layer() {
|
||||
let (mut s, id) = session("Teh");
|
||||
let name = s.active().unwrap().doc.layer(LayerId(id)).unwrap().name.clone();
|
||||
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
|
||||
let listed = s.execute("edit.checkSpelling", json!({"action": "list", "layer": name})).unwrap();
|
||||
assert_eq!(listed["count"], 1);
|
||||
let changed = s.execute("edit.checkSpelling", json!({"action": "changeAll", "layer": name, "word": "Teh", "replace": "The"})).unwrap();
|
||||
assert_eq!(changed["changed"], 1);
|
||||
assert_eq!(text_of(&s, id), "The");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_locked_layer_rejects_insert_text_and_spelling_change_without_history() {
|
||||
// #4188: Lock All stopped type.edit (#3132) but not these two text edits.
|
||||
@@ -369,6 +403,69 @@ mod tests {
|
||||
assert_eq!(r["correct"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_without_layer_edits_the_active_type_layer() {
|
||||
let (mut s, id) = session("Teh end");
|
||||
let r = s.execute("edit.checkSpelling", json!({"action": "change", "start": 0, "end": 3, "word": "Teh", "replace": "The"})).unwrap();
|
||||
assert_eq!((r["layer"].as_u64(), r["end"].as_u64()), (Some(id), Some(3)));
|
||||
assert_eq!(text_of(&s, id), "The end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_keeps_check_spelling_with_its_dialog_and_not_its_corrections() {
|
||||
let (mut s, id) = session("Teh brwon end");
|
||||
s.execute("actions.record", json!({"name": "Spell"})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"action": "list", "allLayers": true})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"action": "change", "layer": id, "start": 0, "end": 3, "word": "Teh", "replace": "The"})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"action": "changeAll", "word": "brwon", "replace": "brown"})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"action": "suggest", "word": "recieve"})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"action": "addToDictionary", "word": "Photocraft"})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"action": "removeFromDictionary", "word": "Photocraft"})).unwrap();
|
||||
// The dialog closing.
|
||||
s.execute("edit.checkSpelling", json!({"allLayers": true, "suggestions": 8})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(text_of(&s, id), "The brown end");
|
||||
let action = &s.actions.list[0];
|
||||
assert_eq!(action.steps, vec![("edit.checkSpelling".to_string(), json!({"allLayers": true}))]);
|
||||
assert_eq!(action.dialogs.iter().copied().collect::<Vec<_>>(), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_check_of_one_layer_records_all_layers_off() {
|
||||
let (mut s, id) = session("Teh end");
|
||||
s.execute("actions.record", json!({"name": "One"})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"allLayers": false})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"layer": id})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let steps: Vec<&Value> = s.actions.list[0].steps.iter().map(|(_, p)| p).collect();
|
||||
assert_eq!(steps, [&json!({"allLayers": false}), &json!({"allLayers": false})]);
|
||||
assert_eq!(s.actions.list[0].dialogs.len(), 2);
|
||||
}
|
||||
|
||||
/// `"action": null` runs Check Spelling itself, so it is recorded as one.
|
||||
#[test]
|
||||
fn a_null_action_is_recorded_as_check_spelling() {
|
||||
let (mut s, _) = session("Teh end");
|
||||
s.execute("actions.record", json!({"name": "Null"})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"action": null})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(s.actions.list[0].steps, [("edit.checkSpelling".to_string(), json!({"allLayers": true}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn played_check_spelling_without_its_dialog_changes_nothing() {
|
||||
let (mut s, id) = session("Teh end");
|
||||
s.execute("actions.record", json!({"name": "Spell"})).unwrap();
|
||||
s.execute("edit.checkSpelling", json!({"allLayers": true})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let history = s.active().unwrap().history.past_len();
|
||||
let r = s.execute("actions.play", json!({"action": "Spell"})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{r}");
|
||||
assert_eq!(r["ran"], 1);
|
||||
assert_eq!(text_of(&s, id), "Teh end");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_without_type() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -42,21 +42,21 @@ fn text_of(doc: &Document, id: LayerId) -> Option<&TextLayer> {
|
||||
}
|
||||
|
||||
/// Type layers a style command acts on: `layer`, `layers`, else the selected type layers.
|
||||
fn targets(s: &Session, p: &Value) -> Vec<LayerId> {
|
||||
let Some(st) = s.active() else { return Vec::new() };
|
||||
let ids: Vec<LayerId> = if let Some(id) = p.get("layer").and_then(Value::as_u64) {
|
||||
vec![LayerId(id)]
|
||||
fn targets(s: &Session, p: &Value) -> Result<Vec<LayerId>> {
|
||||
let Some(st) = s.active() else { return Ok(Vec::new()) };
|
||||
let ids: Vec<LayerId> = if let Some(id) = crate::commands::explicit_layer(s, p)? {
|
||||
vec![id]
|
||||
} else if let Some(a) = p.get("layers").and_then(Value::as_array) {
|
||||
a.iter().filter_map(Value::as_u64).map(LayerId).collect()
|
||||
} else {
|
||||
st.selected_layers()
|
||||
};
|
||||
ids.into_iter().filter(|id| text_of(&st.doc, *id).is_some()).collect()
|
||||
Ok(ids.into_iter().filter(|id| text_of(&st.doc, *id).is_some()).collect())
|
||||
}
|
||||
|
||||
fn has_target(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if targets(s, &Value::Null).is_empty() { Err("select a type layer".into()) } else { Ok(()) }
|
||||
if targets(s, &Value::Null).is_ok_and(|targets| !targets.is_empty()) { Ok(()) } else { Err("select a type layer".into()) }
|
||||
}
|
||||
|
||||
fn byte_at(text: &str, ci: usize) -> usize {
|
||||
@@ -317,7 +317,7 @@ type Target = (LayerId, Option<(usize, usize)>);
|
||||
/// Target list with byte ranges (`range` applies only to a single layer).
|
||||
fn target_ranges(s: &Session, p: &Value) -> Result<Vec<Target>> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids = targets(s, p);
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("no type layer to apply the style to".into()));
|
||||
}
|
||||
@@ -334,7 +334,7 @@ fn target_ranges(s: &Session, p: &Value) -> Result<Vec<Target>> {
|
||||
/// run's byte range.
|
||||
fn sample(s: &Session, p: &Value) -> Option<(CharStyle, ParagraphStyle, LayerId, (usize, usize))> {
|
||||
let st = s.active()?;
|
||||
let id = *targets(s, p).first()?;
|
||||
let id = *targets(s, p).ok()?.first()?;
|
||||
let t = text_of(&st.doc, id)?;
|
||||
let (a, _) = range_of(&t.text, p);
|
||||
let mut at = 0;
|
||||
@@ -430,6 +430,9 @@ fn new_style(s: &mut Session, p: &Value, paragraph: bool) -> Result<Value> {
|
||||
let (mut ca, mut pa) = parse_attrs(cmd, p.get("attrs").unwrap_or(&Value::Null))?;
|
||||
// With a type layer targeted, the style starts from the text's formatting (as Photoshop).
|
||||
let from_text = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true);
|
||||
if from_text {
|
||||
crate::commands::explicit_layer(s, p)?;
|
||||
}
|
||||
if from_text && let Some((run, para, _, _)) = sample(s, p) {
|
||||
if paragraph {
|
||||
let base_c = st.resolve_char(None, None, FAMILY);
|
||||
|
||||
@@ -73,6 +73,15 @@ fn character_style_lifecycle() {
|
||||
assert_eq!(run_at(&layer(&s, id), 7).style_sheet, Some(cs as u32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_a_style_rejects_an_unmatched_named_source_layer() {
|
||||
let (mut s, _) = session("Hello");
|
||||
for id in ["type.characterStyle.new", "type.paragraphStyle.new"] {
|
||||
let error = s.execute(id, json!({"name": "From missing", "layer": "Missing", "fromSelection": true})).unwrap_err();
|
||||
assert!(error.to_string().contains("no layer named"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_style_from_selection_and_redefine() {
|
||||
let (mut s, id) = session("Title");
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save for Web (Legacy)…",
|
||||
&["File", "Export"],
|
||||
Some("Cmd+Alt+Shift+S"),
|
||||
r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp|webp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##,
|
||||
r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp|webp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"folder":folder? (whole image, named after the document; ignored with "path" or "dir"),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##,
|
||||
has_doc,
|
||||
save_for_web
|
||||
),
|
||||
|
||||
@@ -158,7 +158,7 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu
|
||||
fn remember_web_settings(s: &mut Session, cmd: &str, settings: &Value) {
|
||||
let mut m = settings.clone();
|
||||
if let Some(o) = m.as_object_mut() {
|
||||
for k in ["path", "dir", "trustedSliceHtml"] {
|
||||
for k in ["path", "dir", "folder", "trustedSliceHtml"] {
|
||||
o.remove(k);
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,10 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u
|
||||
.collect();
|
||||
let single = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty());
|
||||
let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty());
|
||||
// `folder` (with no `path` or `dir`): the whole image into that folder under the document's
|
||||
// own name, so an action recorded with the suggested name saves each document it is played
|
||||
// on under that document's name, as in Photoshop (#5305).
|
||||
let folder = p.get("folder").and_then(Value::as_str).filter(|_| single.is_none() && dir.is_none());
|
||||
let mut last_web = p.clone();
|
||||
// A trusted-markup decision applies to this export only, never to another document opened
|
||||
// with the dialog's remembered settings.
|
||||
@@ -210,12 +214,22 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u
|
||||
last.remove("trustedSliceHtml");
|
||||
}
|
||||
// Estimate only (the dialog's annotations): the whole image, or each chosen slice.
|
||||
if single.is_none() && dir.is_none() {
|
||||
if single.is_none() && dir.is_none() && folder.is_none() {
|
||||
let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?;
|
||||
return Ok(
|
||||
json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::<Vec<_>>(), "transparentIndex": o.transparent_index}),
|
||||
);
|
||||
}
|
||||
if let Some(folder) = folder {
|
||||
let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?;
|
||||
let name = format!("{}.{}", portable_generated_stem(slices::base_name(&doc)), o.ext);
|
||||
write(&web_derived_path(folder, &name, native_paths)?, &o.bytes)?;
|
||||
remember_web_settings(s, cmd, &last_web);
|
||||
s.file_menu.last_web = Some(last_web);
|
||||
crate::automate_cmds::fire_event(s, "export");
|
||||
let path = join(folder, &name);
|
||||
return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "warnings": warnings}));
|
||||
}
|
||||
if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) {
|
||||
let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, exif, xmp.as_deref(), dpi, false)?;
|
||||
write(path, &o.bytes)?;
|
||||
|
||||
@@ -1317,3 +1317,25 @@ fn embed_color_profile_tags_an_untagged_document_srgb() {
|
||||
assert_eq!(profile(&off), None, "{format}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5305: `folder` writes the whole image there under the document's own name, so an action
|
||||
/// recorded with the suggested name saves each document it is played on under that name; it is
|
||||
/// a destination, never a remembered setting.
|
||||
#[test]
|
||||
fn folder_saves_under_the_documents_own_name() {
|
||||
let dir = tmp("folder_own_name");
|
||||
let mut s = session(8);
|
||||
let r = s.execute("file.export.saveForWebLegacy", json!({"folder": dir, "format": "png24"})).unwrap();
|
||||
let first = format!("{dir}/web.png");
|
||||
assert_eq!(r["files"], json!([first]));
|
||||
assert_eq!(decode(&first).dimensions(), (64, 48));
|
||||
assert!(s.prefs().dialogs.get("file.export.saveForWebLegacy").is_some_and(|m| m.get("folder").is_none()));
|
||||
s.execute("file.new", json!({"width": 8, "height": 4, "name": "second.psd"})).unwrap();
|
||||
let r = s.execute("file.export.saveForWebLegacy", json!({"folder": dir, "format": "jpeg"})).unwrap();
|
||||
assert_eq!(r["files"], json!([format!("{dir}/second.jpg")]));
|
||||
assert_eq!(decode(&format!("{dir}/second.jpg")).dimensions(), (8, 4));
|
||||
// A `path` wins over `folder`.
|
||||
let r = s.execute("file.export.saveForWebLegacy", json!({"path": format!("{dir}/named.png"), "folder": dir, "format": "png24"})).unwrap();
|
||||
assert_eq!(r["files"], json!([format!("{dir}/named.png")]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
+405
-51
@@ -14,13 +14,18 @@
|
||||
//! On wasm `start` runs inline (same API, written once).
|
||||
//! - [`Session::cancel_job`] sets the flag; the algorithms check it per tile, row band or
|
||||
//! iteration, and the result is discarded. The document is never touched by a worker.
|
||||
//! - A job with an effect outside the session (a save replacing its file) calls
|
||||
//! [`JobCtx::commit`] right before it. From then on it can't be cancelled: every cancel
|
||||
//! path ([`Session::try_cancel_job`], [`Session::cancel_all_jobs`], closing its document,
|
||||
//! `jobs.cancel`) leaves it running and says so ([`CancelRefused::Committing`]), so nothing
|
||||
//! reports a job as cancelled after its effect has happened.
|
||||
//! - A panic in the worker becomes an error; the document is unchanged.
|
||||
//! - While a job runs on a document, commands that would edit that document are disabled with a
|
||||
//! message naming the job (queries and document-independent commands still run).
|
||||
|
||||
use std::any::Any;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError, Weak};
|
||||
use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex, PoisonError, Weak};
|
||||
|
||||
use photocraft_doc::{DocId, Document, LayerId};
|
||||
use photocraft_raster::Interrupt;
|
||||
@@ -84,13 +89,51 @@ pub struct JobCtx {
|
||||
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
cancel: AtomicBool,
|
||||
/// [`CANCEL_REQUESTED`], [`WORK_FINISHED`] and [`COMMITTED`] bits. One atomic orders a cancel
|
||||
/// against [`JobCtx::commit`] and against the worker finishing, so exactly one of them wins.
|
||||
state: AtomicU8,
|
||||
/// `f32` bits of the progress fraction. Non-negative floats order like their bits, so
|
||||
/// `fetch_max` keeps progress monotonic even with several reporting threads.
|
||||
progress: AtomicU32,
|
||||
message: Mutex<String>,
|
||||
batch: Mutex<BatchInterrupt>,
|
||||
batch_decision: Condvar,
|
||||
batch_preview: Mutex<Option<BatchPreview>>,
|
||||
batch_preview_generation: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
/// The current folder Batch input, published as an immutable snapshot for the desktop canvas.
|
||||
/// It is never added to the live session and cannot receive commands or saves.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BatchPreview {
|
||||
pub path: String,
|
||||
pub document: Arc<Document>,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// The desktop Batch Esc handshake. The worker alone enters `Waiting` at a pre-save
|
||||
/// checkpoint; only the UI supplies a decision. Programmatic cancellation uses the separate
|
||||
/// atomic flag above and wakes a waiting worker without asking a question.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum BatchInterrupt {
|
||||
#[default]
|
||||
Processing,
|
||||
Requested,
|
||||
Waiting,
|
||||
Continue,
|
||||
Stop,
|
||||
Saving,
|
||||
/// Every input has been handled; Esc is no longer accepted.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Cancelled before it committed.
|
||||
const CANCEL_REQUESTED: u8 = 1;
|
||||
/// The worker has sent its result; a Batch no longer accepts a cancel.
|
||||
const WORK_FINISHED: u8 = 2;
|
||||
/// Past [`JobCtx::commit`]: the job runs to the end and can no longer be cancelled.
|
||||
const COMMITTED: u8 = 4;
|
||||
|
||||
impl std::fmt::Debug for JobCtx {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("JobCtx").field("progress", &self.fraction()).field("cancelled", &self.cancelled()).finish()
|
||||
@@ -118,12 +161,173 @@ impl JobCtx {
|
||||
|
||||
/// Has the job been cancelled? Long loops check this and stop early.
|
||||
pub fn cancelled(&self) -> bool {
|
||||
self.shared.cancel.load(Ordering::Relaxed)
|
||||
self.shared.state.load(Ordering::Acquire) & CANCEL_REQUESTED != 0
|
||||
}
|
||||
|
||||
/// Ask the job to stop (idempotent).
|
||||
/// Ask the job to stop (idempotent). Does nothing once it has [committed](Self::commit).
|
||||
pub fn cancel(&self) {
|
||||
self.shared.cancel.store(true, Ordering::Relaxed);
|
||||
let _ = self.try_cancel();
|
||||
}
|
||||
|
||||
/// Cancel unless the job has [committed](Self::commit): true if it is cancelled (now or
|
||||
/// before), false if it is past its point of no return and will run to the end.
|
||||
pub fn try_cancel(&self) -> bool {
|
||||
let r = self.shared.state.try_update(Ordering::AcqRel, Ordering::Acquire, |state| (state & COMMITTED == 0).then_some(state | CANCEL_REQUESTED));
|
||||
if r.is_ok() {
|
||||
self.wake_batch_worker();
|
||||
}
|
||||
r.is_ok()
|
||||
}
|
||||
|
||||
/// The job's point of no return, for work with an effect outside the session (a save calls
|
||||
/// it right before replacing the file). `Ok`: no cancel can stop the job any more; it must
|
||||
/// run to the end, and its result is applied even if its document was closed meanwhile.
|
||||
/// `Err(Cancelled)`: it was cancelled first, and nothing may be done.
|
||||
pub fn commit(&self) -> Result<()> {
|
||||
self.check()?;
|
||||
self.shared
|
||||
.state
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |state| (state & CANCEL_REQUESTED == 0).then_some(state | COMMITTED))
|
||||
.map(|_| ())
|
||||
.map_err(|_| EngineError::Cancelled)
|
||||
}
|
||||
|
||||
/// Has the job passed [`Self::commit`]?
|
||||
pub fn committed(&self) -> bool {
|
||||
self.shared.state.load(Ordering::Acquire) & COMMITTED != 0
|
||||
}
|
||||
|
||||
/// Wake a Batch worker waiting for the Esc decision, so it sees a cancel.
|
||||
fn wake_batch_worker(&self) {
|
||||
let _phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
self.shared.batch_decision.notify_all();
|
||||
}
|
||||
|
||||
/// Accept a cancellation only while the worker has not finished its result. One atomic state
|
||||
/// orders a late cancel against completion, including the interval before the UI polls.
|
||||
fn cancel_if_running(&self) -> bool {
|
||||
let accepted = self
|
||||
.shared
|
||||
.state
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
|
||||
(state & (CANCEL_REQUESTED | WORK_FINISHED | COMMITTED) == 0).then_some(state | CANCEL_REQUESTED)
|
||||
})
|
||||
.is_ok();
|
||||
if accepted {
|
||||
self.wake_batch_worker();
|
||||
}
|
||||
accepted
|
||||
}
|
||||
|
||||
/// Request the reference application's Esc decision for an interactive Batch. A save that
|
||||
/// passed its final write checkpoint cannot be interrupted; the caller may try the next
|
||||
/// input. This does not affect the direct `jobs.cancel` automation API.
|
||||
pub fn request_batch_interrupt(&self) -> bool {
|
||||
if self.cancelled() || self.shared.state.load(Ordering::Acquire) & WORK_FINISHED != 0 {
|
||||
return false;
|
||||
}
|
||||
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if *phase != BatchInterrupt::Processing {
|
||||
return false;
|
||||
}
|
||||
*phase = BatchInterrupt::Requested;
|
||||
true
|
||||
}
|
||||
|
||||
/// The worker has stopped before writing the current input and awaits Continue or Stop.
|
||||
pub fn batch_waiting_for_decision(&self) -> bool {
|
||||
*self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) == BatchInterrupt::Waiting
|
||||
}
|
||||
|
||||
/// Answer a waiting interactive Batch question. `continue_next` skips the current input.
|
||||
pub fn decide_batch_interrupt(&self, continue_next: bool) -> bool {
|
||||
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if *phase != BatchInterrupt::Waiting {
|
||||
return false;
|
||||
}
|
||||
*phase = if continue_next { BatchInterrupt::Continue } else { BatchInterrupt::Stop };
|
||||
self.shared.batch_decision.notify_all();
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether an action should stop before its next step because Esc was requested.
|
||||
pub fn batch_interrupt_requested(&self) -> bool {
|
||||
*self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) == BatchInterrupt::Requested
|
||||
}
|
||||
|
||||
/// Move an accepted Esc request to the question at a worker checkpoint.
|
||||
pub fn batch_take_interrupt(&self) -> bool {
|
||||
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if *phase != BatchInterrupt::Requested {
|
||||
return *phase == BatchInterrupt::Waiting;
|
||||
}
|
||||
*phase = BatchInterrupt::Waiting;
|
||||
true
|
||||
}
|
||||
|
||||
/// Wait for the UI decision without polling. Closing the session or `jobs.cancel` wakes this
|
||||
/// worker; the caller then reports its partial result.
|
||||
pub fn batch_wait_for_decision(&self) -> bool {
|
||||
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
while *phase == BatchInterrupt::Waiting && !self.cancelled() {
|
||||
phase = self.shared.batch_decision.wait(phase).unwrap_or_else(PoisonError::into_inner);
|
||||
}
|
||||
let continue_next = *phase == BatchInterrupt::Continue && !self.cancelled();
|
||||
*phase = BatchInterrupt::Processing;
|
||||
continue_next
|
||||
}
|
||||
|
||||
/// Mark the start of an input's output write. The same lock orders Esc against this
|
||||
/// checkpoint: a requested interruption never writes that input.
|
||||
pub(crate) fn batch_before_write(&self) -> Result<()> {
|
||||
self.check()?;
|
||||
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if *phase == BatchInterrupt::Requested {
|
||||
*phase = BatchInterrupt::Waiting;
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
if self.cancelled() {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
*phase = BatchInterrupt::Saving;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The worker has passed its last checkpoint. A pending request is dropped (nothing is left to
|
||||
/// interrupt) and later requests return false.
|
||||
pub(crate) fn batch_close_interrupts(&self) {
|
||||
*self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner) = BatchInterrupt::Closed;
|
||||
self.shared.batch_decision.notify_all();
|
||||
}
|
||||
|
||||
/// A completed write allows Esc on the next input.
|
||||
pub(crate) fn batch_after_write(&self) {
|
||||
let mut phase = self.shared.batch.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if *phase == BatchInterrupt::Saving {
|
||||
*phase = BatchInterrupt::Processing;
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the current input's document after it opens or an action step changes it.
|
||||
pub fn publish_batch_preview(&self, path: &str, document: Option<Arc<Document>>) {
|
||||
let mut preview = self.shared.batch_preview.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if preview.as_ref().zip(document.as_ref()).is_some_and(|(shown, next)| shown.path == path && Arc::ptr_eq(&shown.document, next))
|
||||
|| (preview.is_none() && document.is_none())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let generation = self.shared.batch_preview_generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1);
|
||||
*preview = document.map(|document| BatchPreview { path: path.to_string(), document, generation });
|
||||
}
|
||||
|
||||
/// A snapshot for rendering, if the current input still has a document open.
|
||||
pub fn batch_preview(&self) -> Option<BatchPreview> {
|
||||
self.shared.batch_preview.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn finish_work(&self) {
|
||||
self.shared.state.fetch_or(WORK_FINISHED, Ordering::Release);
|
||||
}
|
||||
|
||||
/// `Err(Cancelled)` once cancelled, for `?` between stages.
|
||||
@@ -151,6 +355,53 @@ impl JobCtx {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`Session::try_cancel_job`] left a job running.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CancelRefused {
|
||||
/// No such job is running.
|
||||
NotRunning,
|
||||
/// A print belongs to the system print queue once it has started.
|
||||
Printing,
|
||||
/// Past its [commit](JobCtx::commit): it is writing its result (a save replacing its file)
|
||||
/// and will report its real outcome when it ends.
|
||||
Committing,
|
||||
/// A Batch already asked to stop, or past its last input: it ends on its own and reports
|
||||
/// the outputs it wrote.
|
||||
Finishing,
|
||||
}
|
||||
|
||||
impl CancelRefused {
|
||||
/// The scripting name (`jobs.cancel`'s `notCancelled[].reason`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotRunning => "notRunning",
|
||||
Self::Printing => "printing",
|
||||
Self::Committing => "committing",
|
||||
Self::Finishing => "finishing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a cancel of several jobs did ([`Session::cancel_all_jobs`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CancelReport {
|
||||
pub cancelled: Vec<JobId>,
|
||||
/// The jobs left running, and why.
|
||||
pub refused: Vec<(JobId, CancelRefused)>,
|
||||
}
|
||||
|
||||
impl CancelReport {
|
||||
/// The `jobs.cancel` reply: `{cancelled: n}`, with `notCancelled: [{job, reason}]` when a
|
||||
/// job was left running.
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut v = json!({"cancelled": self.cancelled.len()});
|
||||
if !self.refused.is_empty() {
|
||||
v["notCancelled"] = self.refused.iter().map(|(id, why)| json!({"job": id.0, "reason": why.as_str()})).collect();
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Final state of a job.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum JobOutcome {
|
||||
@@ -192,7 +443,7 @@ pub struct JobInfo {
|
||||
pub progress: f32,
|
||||
pub message: String,
|
||||
pub elapsed_ms: f64,
|
||||
/// The command's result (done) or the error (failed).
|
||||
/// The command's result (done), including partial outputs from a cancelled batch.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -231,6 +482,7 @@ struct Running {
|
||||
/// later must not pick up an edit made before it.
|
||||
recorded_by: Option<u64>,
|
||||
pre_selected: Option<Vec<LayerId>>,
|
||||
before: crate::actions_record::Before,
|
||||
doc: Option<DocId>,
|
||||
/// The document as the job saw it: applying fails if it was edited meanwhile.
|
||||
snapshot: Option<Weak<Document>>,
|
||||
@@ -453,6 +705,7 @@ pub fn run<T: Send + 'static>(
|
||||
let _document = crate::doc_path::scope(document.as_deref());
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload)))
|
||||
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged"))));
|
||||
wctx.finish_work();
|
||||
// The receiver is gone when the job was cancelled: nothing to report.
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
@@ -471,6 +724,7 @@ pub fn run<T: Send + 'static>(
|
||||
journal: true,
|
||||
recorded_by: None,
|
||||
pre_selected: None,
|
||||
before: crate::actions_record::Before::capture(s),
|
||||
doc,
|
||||
snapshot,
|
||||
ctx,
|
||||
@@ -1070,31 +1324,82 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel job `id`: it stops at its next check, its result is discarded and the document
|
||||
/// stays unchanged. The document is unlocked at once. False if no such job is running.
|
||||
/// Cancel job `id`: ordinary jobs stop at their next check, discard their result and leave
|
||||
/// the document unchanged; the document is unlocked at once. A batch finishes the current
|
||||
/// input and reports outputs already written. False if no such job is running or it
|
||||
/// can't be cancelled (see [`Self::try_cancel_job`]).
|
||||
pub fn cancel_job(&mut self, id: JobId) -> bool {
|
||||
let Some(i) = self.jobs.running.iter().position(|r| r.id == id) else { return false };
|
||||
self.try_cancel_job(id).is_ok()
|
||||
}
|
||||
|
||||
/// [`Self::cancel_job`], saying why a job was not cancelled.
|
||||
pub fn try_cancel_job(&mut self, id: JobId) -> std::result::Result<(), CancelRefused> {
|
||||
let Some(i) = self.jobs.running.iter().position(|r| r.id == id) else { return Err(CancelRefused::NotRunning) };
|
||||
// Earlier batch outputs are already on disk. Let the worker stop after its current
|
||||
// input and return the completed and skipped lists.
|
||||
if self.jobs.running[i].command == "file.automate.batch" {
|
||||
return if self.jobs.running[i].ctx.cancel_if_running() { Ok(()) } else { Err(CancelRefused::Finishing) };
|
||||
}
|
||||
// A print worker can already have handed the job to the OS. Discarding its result
|
||||
// would falsely report cancellation; removal belongs to the system print queue.
|
||||
if matches!(self.jobs.running[i].command.as_str(), "file.print" | "file.printOneCopy") {
|
||||
return false;
|
||||
return Err(CancelRefused::Printing);
|
||||
}
|
||||
// Past its commit (a save replacing its file) the job runs to the end and reports its
|
||||
// real outcome; calling it cancelled would be false.
|
||||
if !self.jobs.running[i].ctx.try_cancel() {
|
||||
return Err(CancelRefused::Committing);
|
||||
}
|
||||
let job = self.jobs.running.remove(i);
|
||||
job.ctx.cancel();
|
||||
self.end_job(&job, JobOutcome::Cancelled);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(handle) = job.handle {
|
||||
self.jobs.draining.push(Draining { handle, doc: job.doc, release: false });
|
||||
}
|
||||
true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel every running job (e.g. before quitting).
|
||||
pub fn cancel_all_jobs(&mut self) {
|
||||
/// Ask an interactive Batch worker to stop its current input at the next safe checkpoint.
|
||||
/// Headless clients keep using [`Self::cancel_job`] for a direct partial result.
|
||||
pub fn request_batch_interrupt_job(&self, id: JobId) -> bool {
|
||||
self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").is_some_and(|job| job.ctx.request_batch_interrupt())
|
||||
}
|
||||
|
||||
/// Whether a Batch worker has acknowledged Esc and is waiting for the desktop decision.
|
||||
pub fn batch_interrupt_waiting(&self, id: JobId) -> bool {
|
||||
self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").is_some_and(|job| job.ctx.batch_waiting_for_decision())
|
||||
}
|
||||
|
||||
/// Continue with the next input or stop a Batch paused by the desktop Esc key.
|
||||
pub fn decide_batch_interrupt_job(&self, id: JobId, continue_next: bool) -> bool {
|
||||
self.jobs
|
||||
.running
|
||||
.iter()
|
||||
.find(|job| job.id == id && job.command == "file.automate.batch")
|
||||
.is_some_and(|job| job.ctx.decide_batch_interrupt(continue_next))
|
||||
}
|
||||
|
||||
/// The current read-only folder Batch document shown by the desktop while work runs.
|
||||
pub fn batch_preview(&self, id: JobId) -> Option<BatchPreview> {
|
||||
self.jobs.running.iter().find(|job| job.id == id && job.command == "file.automate.batch").and_then(|job| job.ctx.batch_preview())
|
||||
}
|
||||
|
||||
/// Cancel every running job (e.g. before quitting). Prints and jobs past their commit keep
|
||||
/// running; the report lists them.
|
||||
pub fn cancel_all_jobs(&mut self) -> CancelReport {
|
||||
let ids: Vec<JobId> = self.jobs.running.iter().map(|r| r.id).collect();
|
||||
self.cancel_each(ids)
|
||||
}
|
||||
|
||||
fn cancel_each(&mut self, ids: Vec<JobId>) -> CancelReport {
|
||||
let mut report = CancelReport::default();
|
||||
for id in ids {
|
||||
self.cancel_job(id);
|
||||
match self.try_cancel_job(id) {
|
||||
Ok(()) => report.cancelled.push(id),
|
||||
Err(why) => report.refused.push((id, why)),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Is any job running? (Cheap: the UI asks every frame.)
|
||||
@@ -1152,17 +1457,17 @@ impl Session {
|
||||
Some(format!("“{}” is still running on this document; wait for it to finish or cancel it (Esc)", job.label))
|
||||
}
|
||||
|
||||
/// Cancel the jobs editing document `doc` (it is closing).
|
||||
pub(crate) fn cancel_jobs_on(&mut self, doc: DocId) {
|
||||
/// Cancel the jobs editing document `doc` (it is closing). A job past its commit keeps
|
||||
/// running and its result is still applied (see [`JobCtx::commit`]).
|
||||
pub(crate) fn cancel_jobs_on(&mut self, doc: DocId) -> CancelReport {
|
||||
let ids: Vec<JobId> = self.jobs.running.iter().filter(|r| r.doc == Some(doc)).map(|r| r.id).collect();
|
||||
for id in ids {
|
||||
self.cancel_job(id);
|
||||
}
|
||||
let report = self.cancel_each(ids);
|
||||
// Workers cancelled now, or earlier and still unwinding, hold the closing document.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for d in self.jobs.draining.iter_mut().filter(|d| d.doc == Some(doc)) {
|
||||
d.release = true;
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Run the command `id`: inline, or (with `background`) as a job when it is one.
|
||||
@@ -1242,6 +1547,7 @@ impl Session {
|
||||
// Commands this one runs on its own behalf journal after this mark (#3067).
|
||||
let nested_from = self.journal.len();
|
||||
let pre_selected = if self.actions.recording.is_some() { self.active().map(|st| st.selected_layers()) } else { None };
|
||||
let mut record_before = crate::actions_record::Before::capture(self);
|
||||
// A replayable command is recorded as one step, so recording pauses while it runs: an
|
||||
// action it plays (a script step) must not flush the steps it ran so far into the action
|
||||
// being recorded, nor move the recording mark past the entries it is about to replace.
|
||||
@@ -1279,6 +1585,7 @@ impl Session {
|
||||
job.journal = spec.journal;
|
||||
job.recorded_by = self.actions.recording.map(|_| self.actions.take);
|
||||
job.pre_selected = pre_selected.clone();
|
||||
job.before = std::mem::take(&mut record_before);
|
||||
let jid = job.id;
|
||||
self.jobs.running.push(job);
|
||||
Ok(Started::Job(jid))
|
||||
@@ -1298,15 +1605,16 @@ impl Session {
|
||||
st.fx_collapsed.push(layer_id);
|
||||
}
|
||||
}
|
||||
self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref());
|
||||
self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref(), &record_before);
|
||||
Ok(Started::Done(v))
|
||||
}
|
||||
};
|
||||
// A closed document's freed buffers went to the scratch pool (`before` kept the active
|
||||
// one alive until here): hand them back to the OS (#10479). Compared by id, since Split
|
||||
// and Merge Channels close documents while opening others.
|
||||
// A closed document's freed buffers went to the scratch pool (`before` and the action
|
||||
// recording snapshot kept it alive until here). Drop both before handing the pool back
|
||||
// to the OS (#10479). Compare ids since Split/Merge Channels open replacement documents.
|
||||
if docs_before.iter().any(|id| !self.docs.iter().any(|st| st.doc.id == *id)) {
|
||||
drop(before);
|
||||
drop(record_before);
|
||||
photocraft_raster::scratch::release();
|
||||
}
|
||||
out
|
||||
@@ -1344,7 +1652,15 @@ impl Session {
|
||||
/// dropped, so a recorded action replays the command once, not its steps as well (#3067).
|
||||
/// Edit › Transform › Again (and Again on a Copy) is not replayable and keeps the concrete
|
||||
/// steps it ran instead.
|
||||
fn after_command(&mut self, id: &str, params: Value, journal: bool, nested_from: Option<usize>, pre_selected: Option<&[LayerId]>) {
|
||||
fn after_command(
|
||||
&mut self,
|
||||
id: &str,
|
||||
params: Value,
|
||||
journal: bool,
|
||||
nested_from: Option<usize>,
|
||||
pre_selected: Option<&[LayerId]>,
|
||||
before: &crate::actions_record::Before,
|
||||
) {
|
||||
// A layer-mask view ends when another layer becomes active (#196).
|
||||
if let Some(st) = self.active_mut() {
|
||||
crate::mask_view_cmds::fix(st);
|
||||
@@ -1358,16 +1674,25 @@ impl Session {
|
||||
if let Some(from) = nested_from.filter(|_| crate::actions_cmds::replayable(id)) {
|
||||
self.journal.truncate(from);
|
||||
}
|
||||
if !crate::brush_cmds::coalesce_journal(self, id, ¶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<Apply>, payload: Payload) -> Result<Value> {
|
||||
let Running { label, doc, snapshot, coalesce, color_restrict, filter_mask_layer, layers, command, params, journal, recorded_by, pre_selected, .. } =
|
||||
job;
|
||||
let Running {
|
||||
label,
|
||||
doc,
|
||||
snapshot,
|
||||
coalesce,
|
||||
color_restrict,
|
||||
filter_mask_layer,
|
||||
layers,
|
||||
command,
|
||||
params,
|
||||
journal,
|
||||
recorded_by,
|
||||
pre_selected,
|
||||
before,
|
||||
..
|
||||
} = job;
|
||||
// A recording started after the job (#7289) keeps what it has so far, and the job's step
|
||||
// goes into the journal past its start, so that recording doesn't get it.
|
||||
let foreign = self.actions.recording.is_some() && *recorded_by != Some(self.actions.take);
|
||||
@@ -1399,6 +1748,10 @@ impl Session {
|
||||
}
|
||||
let prev_active = self.active;
|
||||
let target = match doc {
|
||||
// A job past its commit has done its work outside the session (a save wrote its
|
||||
// file); a closed document doesn't undo that, so its result still applies, to no
|
||||
// document.
|
||||
Some(d) if job.ctx.committed() && !self.documents().iter().any(|st| st.doc.id == *d) => None,
|
||||
Some(d) => {
|
||||
let i =
|
||||
self.documents().iter().position(|st| st.doc.id == *d).ok_or_else(|| EngineError::Other(format!("`{label}`: the document was closed")))?;
|
||||
@@ -1447,7 +1800,7 @@ impl Session {
|
||||
// Restoring the viewed document first would attribute Fade and slice updates
|
||||
// to that document instead, when the user switched tabs mid-job.
|
||||
if r.is_ok() {
|
||||
self.after_command(command, params.clone(), *journal, Some(nested_from), pre_selected.as_deref());
|
||||
self.after_command(command, params.clone(), *journal, Some(nested_from), pre_selected.as_deref(), before);
|
||||
if foreign && let Some((start, _)) = self.actions.recording.as_mut() {
|
||||
*start = self.journal.len();
|
||||
}
|
||||
@@ -1481,6 +1834,9 @@ impl Session {
|
||||
// and discarded results whose document was closed or changed.
|
||||
self.live_commit = None;
|
||||
let (state, result, error) = match &outcome {
|
||||
JobOutcome::Done(v) if job.command == "file.automate.batch" && v.get("cancelled").and_then(Value::as_bool) == Some(true) => {
|
||||
("cancelled", Some(v.clone()), None)
|
||||
}
|
||||
JobOutcome::Done(v) => ("done", Some(v.clone()), None),
|
||||
JobOutcome::Failed(e) => ("failed", None, Some(e.clone())),
|
||||
JobOutcome::Cancelled => ("cancelled", None, None),
|
||||
@@ -1524,19 +1880,17 @@ pub fn specs() -> Vec<crate::commands::CommandSpec> {
|
||||
params: r##"{"job":u64? (default: every running job)}"##,
|
||||
enabled: |_| Ok(()),
|
||||
run: |s, p| match p.get("job") {
|
||||
None | Some(Value::Null) => {
|
||||
let n = s.jobs.running.len();
|
||||
s.cancel_all_jobs();
|
||||
Ok(json!({"cancelled": n.saturating_sub(s.jobs.running.len())}))
|
||||
}
|
||||
None | Some(Value::Null) => Ok(s.cancel_all_jobs().to_json()),
|
||||
Some(v) => {
|
||||
let id = v.as_u64().ok_or_else(|| EngineError::BadParams { cmd: "jobs.cancel".into(), msg: "`job` must be a job id".into() })?;
|
||||
if s.cancel_job(JobId(id)) {
|
||||
Ok(json!({"cancelled": 1}))
|
||||
} else if s.job(JobId(id)).is_some() {
|
||||
Err(EngineError::Other("printing cannot be cancelled here; use the system print queue".into()))
|
||||
} else {
|
||||
Err(EngineError::Other(format!("no running job {id}")))
|
||||
match s.try_cancel_job(JobId(id)) {
|
||||
Ok(()) => Ok(json!({"cancelled": 1})),
|
||||
// Not an error: the job is fine and will report how it ended.
|
||||
Err(why @ (CancelRefused::Committing | CancelRefused::Finishing)) => {
|
||||
Ok(CancelReport { cancelled: Vec::new(), refused: vec![(JobId(id), why)] }.to_json())
|
||||
}
|
||||
Err(CancelRefused::Printing) => Err(EngineError::Other("printing cannot be cancelled here; use the system print queue".into())),
|
||||
Err(CancelRefused::NotRunning) => Err(EngineError::Other(format!("no running job {id}"))),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -422,6 +422,216 @@ fn jobs_cancel_command_and_bad_params() {
|
||||
assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 0);
|
||||
}
|
||||
|
||||
/// A fake save of the active document to `target`, gated by the test: it waits for `go`, calls
|
||||
/// [`JobCtx::commit`] and reports whether it got through, then waits for `finish` and replaces
|
||||
/// the file. `done` disconnects once the worker has returned.
|
||||
struct GatedSave {
|
||||
id: JobId,
|
||||
go: std::sync::mpsc::Sender<()>,
|
||||
committed: std::sync::mpsc::Receiver<bool>,
|
||||
finish: std::sync::mpsc::Sender<()>,
|
||||
done: std::sync::mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
impl GatedSave {
|
||||
fn start(s: &mut Session, target: &std::path::Path) -> Self {
|
||||
let (go, go_rx) = std::sync::mpsc::channel();
|
||||
let (committed_tx, committed) = std::sync::mpsc::channel();
|
||||
let (finish, finish_rx) = std::sync::mpsc::channel::<()>();
|
||||
let (done_tx, done) = std::sync::mpsc::channel::<()>();
|
||||
let target = target.to_path_buf();
|
||||
let id = job(s
|
||||
.start_job(
|
||||
"file.save",
|
||||
json!({"path": target}),
|
||||
"Saving w.psd",
|
||||
true,
|
||||
move |ctx| {
|
||||
let _done = done_tx;
|
||||
go_rx.recv().ok();
|
||||
let r = ctx.commit();
|
||||
committed_tx.send(r.is_ok()).ok();
|
||||
r?;
|
||||
finish_rx.recv().ok();
|
||||
std::fs::write(&target, b"new").map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok(target.display().to_string())
|
||||
},
|
||||
|_, path: String| Ok(json!({"path": path})),
|
||||
)
|
||||
.unwrap());
|
||||
Self { id, go, committed, finish, done }
|
||||
}
|
||||
|
||||
/// Let the worker reach its commit; true if it committed.
|
||||
fn commit(&self) -> bool {
|
||||
self.go.send(()).unwrap();
|
||||
self.committed.recv_timeout(Duration::from_secs(60)).unwrap()
|
||||
}
|
||||
|
||||
/// Let a committed worker write the file.
|
||||
fn finish(&self) {
|
||||
self.finish.send(()).unwrap();
|
||||
}
|
||||
|
||||
/// Block until the worker has returned.
|
||||
fn join(&self) {
|
||||
assert!(matches!(self.done.recv_timeout(Duration::from_secs(60)), Err(std::sync::mpsc::RecvTimeoutError::Disconnected)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh directory for a [`GatedSave`] test (on every platform, unlike the Unix-only
|
||||
/// fixtures' `tmp_dir`).
|
||||
fn save_dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("photocraft-engine-gated-save-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A job on the active document that runs until it is cancelled.
|
||||
fn open_job(s: &mut Session) -> JobId {
|
||||
job(s
|
||||
.start_job(
|
||||
"test.spin",
|
||||
json!({}),
|
||||
"Spinning",
|
||||
true,
|
||||
|ctx| loop {
|
||||
ctx.check()?;
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
},
|
||||
|_, ()| Ok(json!(null)),
|
||||
)
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
fn index_of(s: &Session, doc: photocraft_doc::DocId) -> usize {
|
||||
s.documents().iter().position(|st| st.doc.id == doc).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_save_before_it_commits_stops_it_without_touching_the_target() {
|
||||
let dir = save_dir("cancel-before-commit");
|
||||
let target = dir.join("w.psd");
|
||||
std::fs::write(&target, b"old").unwrap();
|
||||
let mut s = session(64, 64);
|
||||
let save = GatedSave::start(&mut s, &target);
|
||||
assert_eq!(s.execute("jobs.cancel", json!({"job": save.id.0})).unwrap(), json!({"cancelled": 1}));
|
||||
assert_eq!(wait_event(&mut s, save.id).outcome, JobOutcome::Cancelled);
|
||||
// The worker learns it lost at its commit point and writes nothing.
|
||||
assert!(!save.commit(), "commit after a cancel must fail");
|
||||
save.join();
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"old", "target untouched");
|
||||
assert!(s.job(save.id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_committed_save_is_refused_and_it_reports_its_real_outcome() {
|
||||
let dir = save_dir("cancel-after-commit");
|
||||
let target = dir.join("w.psd");
|
||||
std::fs::write(&target, b"old").unwrap();
|
||||
let mut s = session(64, 64);
|
||||
let save = GatedSave::start(&mut s, &target);
|
||||
assert!(save.commit());
|
||||
// Every single-job entry point says it was not cancelled.
|
||||
assert!(!s.cancel_job(save.id));
|
||||
assert_eq!(s.try_cancel_job(save.id), Err(CancelRefused::Committing));
|
||||
assert_eq!(
|
||||
s.execute("jobs.cancel", json!({"job": save.id.0})).unwrap(),
|
||||
json!({"cancelled": 0, "notCancelled": [{"job": save.id.0, "reason": "committing"}]})
|
||||
);
|
||||
assert!(s.job(save.id).is_some(), "still running, document still locked");
|
||||
assert!(s.poll_jobs().is_empty(), "no Cancelled event");
|
||||
save.finish();
|
||||
let e = wait_event(&mut s, save.id);
|
||||
assert_eq!(e.outcome, JobOutcome::Done(json!({"path": target.display().to_string()})));
|
||||
save.join();
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_all_cancels_open_jobs_and_reports_a_committed_save_it_left_running() {
|
||||
let dir = save_dir("cancel-all-mixed");
|
||||
let (committed_target, open_target) = (dir.join("a.psd"), dir.join("c.psd"));
|
||||
std::fs::write(&committed_target, b"old").unwrap();
|
||||
std::fs::write(&open_target, b"old").unwrap();
|
||||
let mut s = session(64, 64);
|
||||
let committed = GatedSave::start(&mut s, &committed_target);
|
||||
assert!(committed.commit());
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let spinning = open_job(&mut s);
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let open = GatedSave::start(&mut s, &open_target);
|
||||
|
||||
let report = s.cancel_all_jobs();
|
||||
assert_eq!(report.cancelled, vec![spinning, open.id]);
|
||||
assert_eq!(report.refused, vec![(committed.id, CancelRefused::Committing)]);
|
||||
assert_eq!(report.to_json(), json!({"cancelled": 2, "notCancelled": [{"job": committed.id.0, "reason": "committing"}]}));
|
||||
// `jobs.cancel {}` again: only the committed save is left, and it is still not cancelled.
|
||||
assert_eq!(s.execute("jobs.cancel", json!({})).unwrap(), json!({"cancelled": 0, "notCancelled": [{"job": committed.id.0, "reason": "committing"}]}));
|
||||
|
||||
// One poll reports both cancellations.
|
||||
let ended: Vec<_> = s.poll_jobs().into_iter().map(|e| (e.id, e.outcome)).collect();
|
||||
assert_eq!(ended, vec![(spinning, JobOutcome::Cancelled), (open.id, JobOutcome::Cancelled)]);
|
||||
assert!(!open.commit());
|
||||
open.join();
|
||||
assert_eq!(std::fs::read(&open_target).unwrap(), b"old");
|
||||
committed.finish();
|
||||
assert!(matches!(wait_event(&mut s, committed.id).outcome, JobOutcome::Done(_)));
|
||||
committed.join();
|
||||
assert_eq!(std::fs::read(&committed_target).unwrap(), b"new");
|
||||
assert!(s.jobs().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_documents_cancels_open_saves_but_a_committed_save_finishes() {
|
||||
let dir = save_dir("cancel-on-close-mixed");
|
||||
let (committed_target, open_target) = (dir.join("a.psd"), dir.join("c.psd"));
|
||||
std::fs::write(&committed_target, b"old").unwrap();
|
||||
std::fs::write(&open_target, b"old").unwrap();
|
||||
let mut s = session(64, 64);
|
||||
let a = s.active().unwrap().doc.id;
|
||||
let committed = GatedSave::start(&mut s, &committed_target);
|
||||
assert!(committed.commit());
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let b = s.active().unwrap().doc.id;
|
||||
let spinning = open_job(&mut s);
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let c = s.active().unwrap().doc.id;
|
||||
let open = GatedSave::start(&mut s, &open_target);
|
||||
|
||||
assert_eq!(s.cancel_jobs_on(a).refused, vec![(committed.id, CancelRefused::Committing)]);
|
||||
s.close(index_of(&s, a));
|
||||
s.close(index_of(&s, c));
|
||||
assert_eq!(wait_event(&mut s, open.id).outcome, JobOutcome::Cancelled);
|
||||
assert!(!open.commit());
|
||||
open.join();
|
||||
assert_eq!(std::fs::read(&open_target).unwrap(), b"old", "the open save's target is untouched");
|
||||
// The other document's job is not affected.
|
||||
assert!(s.job(spinning).is_some());
|
||||
// The committed save outlives its document and reports what it did, not "document closed".
|
||||
committed.finish();
|
||||
assert_eq!(wait_event(&mut s, committed.id).outcome, JobOutcome::Done(json!({"path": committed_target.display().to_string()})));
|
||||
committed.join();
|
||||
assert_eq!(std::fs::read(&committed_target).unwrap(), b"new");
|
||||
assert!(s.cancel_job(spinning));
|
||||
assert_eq!(s.documents().iter().map(|st| st.doc.id).collect::<Vec<_>>(), vec![b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_and_cancel_race_with_exactly_one_winner() {
|
||||
for _ in 0..200 {
|
||||
let ctx = JobCtx::default();
|
||||
let other = ctx.clone();
|
||||
let t = std::thread::spawn(move || other.commit().is_ok());
|
||||
let cancelled = ctx.try_cancel();
|
||||
let committed = t.join().unwrap();
|
||||
assert_ne!(cancelled, committed, "exactly one of commit and cancel wins");
|
||||
assert_eq!(ctx.cancelled(), cancelled);
|
||||
assert_eq!(ctx.committed(), committed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_job_commands_still_finish_inline_with_start() {
|
||||
let mut s = session(16, 16);
|
||||
@@ -543,6 +753,142 @@ fn open_runs_as_a_job_and_cancel_adds_nothing() {
|
||||
assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_batch_job_returns_its_partial_result() {
|
||||
let mut s = Session::new();
|
||||
let id = job(s
|
||||
.start_job(
|
||||
"file.automate.batch",
|
||||
json!({}),
|
||||
"Batch",
|
||||
false,
|
||||
|ctx| {
|
||||
while !ctx.cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Ok(json!({"files": ["done.png"], "skipped": ["later.png"], "cancelled": true}))
|
||||
},
|
||||
|_, result| Ok(result),
|
||||
)
|
||||
.unwrap());
|
||||
assert!(s.cancel_job(id));
|
||||
let event = wait_event(&mut s, id);
|
||||
let JobOutcome::Done(result) = &event.outcome else { panic!("batch result was discarded: {:?}", event.outcome) };
|
||||
assert_eq!(result["files"], json!(["done.png"]));
|
||||
assert_eq!(result["skipped"], json!(["later.png"]));
|
||||
assert_eq!(result["cancelled"], true);
|
||||
let info = s.jobs_with_recent().into_iter().find(|info| info.id == id).unwrap();
|
||||
assert_eq!(info.state, "cancelled");
|
||||
assert_eq!(info.result.unwrap()["files"], json!(["done.png"]));
|
||||
assert!(matches!(s.wait_job(id), Err(EngineError::Cancelled)));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn programmatic_cancel_wakes_a_batch_waiting_for_the_desktop_decision() {
|
||||
let mut s = Session::new();
|
||||
let id = job(s
|
||||
.start_job(
|
||||
"file.automate.batch",
|
||||
json!({}),
|
||||
"Batch",
|
||||
false,
|
||||
|ctx| {
|
||||
while !ctx.batch_take_interrupt() {
|
||||
ctx.check()?;
|
||||
std::thread::yield_now();
|
||||
}
|
||||
let continued = ctx.batch_wait_for_decision();
|
||||
Ok(json!({"continued": continued, "files": [], "cancelled": ctx.cancelled()}))
|
||||
},
|
||||
|_, result| Ok(result),
|
||||
)
|
||||
.unwrap());
|
||||
assert!(s.request_batch_interrupt_job(id));
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !s.batch_interrupt_waiting(id) && Instant::now() < deadline {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(s.batch_interrupt_waiting(id));
|
||||
assert!(s.cancel_job(id), "jobs.cancel remains direct and wakes the worker");
|
||||
let JobOutcome::Done(result) = wait_event(&mut s, id).outcome else { panic!("batch partial result was lost") };
|
||||
assert_eq!(result["continued"], false);
|
||||
assert_eq!(result["cancelled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_batch_cancellation_after_result_snapshot_is_reported() {
|
||||
let mut s = Session::new();
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||
let (resume_tx, resume_rx) = std::sync::mpsc::channel();
|
||||
let id = job(s
|
||||
.start_job(
|
||||
"file.automate.batch",
|
||||
json!({}),
|
||||
"Batch",
|
||||
false,
|
||||
move |ctx| {
|
||||
let cancelled = ctx.cancelled();
|
||||
ready_tx.send(()).unwrap();
|
||||
resume_rx.recv().unwrap();
|
||||
Ok(json!({"files": ["done.png"], "cancelled": cancelled}))
|
||||
},
|
||||
|_, result| Ok(result),
|
||||
)
|
||||
.unwrap());
|
||||
ready_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
assert!(s.cancel_job(id));
|
||||
resume_tx.send(()).unwrap();
|
||||
let event = wait_event(&mut s, id);
|
||||
let JobOutcome::Done(result) = event.outcome else { panic!("batch result was discarded") };
|
||||
assert_eq!(result["cancelled"], true);
|
||||
assert_eq!(result["files"], json!(["done.png"]));
|
||||
let info = s.jobs_with_recent().into_iter().find(|info| info.id == id).unwrap();
|
||||
assert_eq!(info.state, "cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_all_counts_an_accepted_batch_cancellation() {
|
||||
let mut s = Session::new();
|
||||
let id = job(s
|
||||
.start_job(
|
||||
"file.automate.batch",
|
||||
json!({}),
|
||||
"Batch",
|
||||
false,
|
||||
|ctx| {
|
||||
while !ctx.cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Ok(json!({"files": [], "cancelled": true}))
|
||||
},
|
||||
|_, result| Ok(result),
|
||||
)
|
||||
.unwrap());
|
||||
assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 1);
|
||||
assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 0, "a repeated request is not a new cancellation");
|
||||
assert_eq!(s.execute("jobs.cancel", json!({"job": id.0})).unwrap()["cancelled"], 0, "a repeated batch request is not a print error");
|
||||
assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Done(_)));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_finished_batch_worker_rejects_late_cancellation() {
|
||||
let mut s = Session::new();
|
||||
let id = job(s
|
||||
.start_job("file.automate.batch", json!({}), "Batch", false, |_| Ok(json!({"files": ["done.png"], "cancelled": false})), |_, result| Ok(result))
|
||||
.unwrap());
|
||||
let started = Instant::now();
|
||||
while !s.jobs.running.iter().any(|running| running.id == id && running.handle.as_ref().is_some_and(|handle| handle.is_finished())) {
|
||||
assert!(started.elapsed() < Duration::from_secs(10), "batch worker did not finish");
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
assert!(!s.cancel_job(id), "a result already sent cannot be cancelled");
|
||||
let JobOutcome::Done(result) = wait_event(&mut s, id).outcome else { panic!("completed batch result was lost") };
|
||||
assert_eq!(result["files"], json!(["done.png"]));
|
||||
assert_eq!(result["cancelled"], false);
|
||||
}
|
||||
|
||||
/// A flat scanline EXR opened from a path goes through the band decoder in the open job and
|
||||
/// arrives as the same 32-bit document the whole-file decoder makes; cancelling adds nothing.
|
||||
#[cfg(any(unix, windows))]
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod actions_record;
|
||||
pub mod adjust_params;
|
||||
mod allocation;
|
||||
pub mod build_info;
|
||||
@@ -200,6 +201,8 @@ pub struct Session {
|
||||
pub tools: ToolState,
|
||||
/// Log of executed commands (for action recording and debugging).
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Text layer changed by the last journal entry, for folding Type tool keystrokes.
|
||||
typed_layer: Option<(photocraft_doc::DocId, LayerId)>,
|
||||
/// Coalescing key of the command being executed.
|
||||
coalesce_request: Option<String>,
|
||||
/// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop).
|
||||
@@ -270,6 +273,9 @@ pub struct Session {
|
||||
pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>,
|
||||
/// The stamps being taken on a worker thread ([`Session::start_link_scan`]).
|
||||
pub link_scan: Option<link_watch::LinkScan>,
|
||||
/// Each open document's linked smart objects' files that were not there when last looked at
|
||||
/// ([`Session::missing_links`]).
|
||||
link_missing: std::collections::HashSet<(u64, String)>,
|
||||
/// Select Subject's last result, reused while the pixels are unchanged (#2865).
|
||||
subject_cache: smartselect_cmds::SubjectCache,
|
||||
/// Object Selection's Object Finder results (#4087), shared by the hover preview and the
|
||||
|
||||
@@ -68,6 +68,9 @@ pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let (doc, files) = (st.doc.id.0, resolved_files(st));
|
||||
let now = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect();
|
||||
// Drop a look still running on a worker thread. It stamped the files before this one, and
|
||||
// applying it later would replace this look's result.
|
||||
s.link_scan = None;
|
||||
s.apply_link_stamps(doc, now)
|
||||
}
|
||||
|
||||
@@ -93,8 +96,10 @@ impl Session {
|
||||
}
|
||||
let Some(st) = self.active() else { return };
|
||||
let (doc, files) = (st.doc.id.0, resolved_files(st));
|
||||
// Nothing to look at: no links, or none that is followed.
|
||||
// Nothing to look at: no links, or none that is followed. None of them is listed as
|
||||
// missing either, so a link added back isn't shown missing before its first look.
|
||||
if files.iter().all(|(_, file, _)| file.is_none()) {
|
||||
self.link_missing.retain(|(d, _)| *d != doc);
|
||||
return;
|
||||
}
|
||||
let slot: LinkScan = Arc::default();
|
||||
@@ -108,10 +113,41 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The active document's linked files that were not there at the last look (the Layers
|
||||
/// panel's Missing filter). A path that is not followed ([`crate::doc_path`]) is never looked
|
||||
/// at, so it is not listed.
|
||||
pub fn missing_links(&self) -> HashSet<String> {
|
||||
let Some(doc) = self.active().map(|d| d.doc.id.0) else { return HashSet::new() };
|
||||
self.link_missing.iter().filter(|(d, _)| *d == doc).map(|(_, p)| p.clone()).collect()
|
||||
}
|
||||
|
||||
/// Remember which of document `doc`'s files in `now` were not there, and forget the files of
|
||||
/// closed documents. The web app has no files to look at, so it lists none.
|
||||
fn note_missing_links(&mut self, doc: u64, now: &[(String, Vec<u64>, Option<Stamp>)]) {
|
||||
let open: HashSet<u64> = self.documents().iter().map(|d| d.doc.id.0).collect();
|
||||
self.link_missing.retain(|(d, _)| *d != doc && open.contains(d));
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let document = self.active().and_then(|st| st.path.clone());
|
||||
for (path, _, stamp) in now {
|
||||
if stamp.is_none() && crate::doc_path::resolve_in(path, document.as_deref().map(std::path::Path::new)).is_ok() {
|
||||
self.link_missing.insert((doc, path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a finished look (`None` while there is none, or it is still running).
|
||||
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
|
||||
let (doc, stamps) = self.link_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?;
|
||||
self.link_scan = None;
|
||||
// A relink or embed while the look ran: it stamped files the document no longer links in
|
||||
// that way, so drop it; the next look takes the current links.
|
||||
if let Some(st) = self.active().filter(|st| st.doc.id.0 == doc)
|
||||
&& !linked_files(&st.doc).iter().map(|(p, ids)| (p, ids)).eq(stamps.iter().map(|(p, ids, _)| (p, ids)))
|
||||
{
|
||||
return Some(Ok(json!({"updated": []})));
|
||||
}
|
||||
Some(self.apply_link_stamps(doc, stamps))
|
||||
}
|
||||
|
||||
@@ -122,6 +158,7 @@ impl Session {
|
||||
if self.active().map(|d| d.doc.id.0) != Some(doc) {
|
||||
return Ok(json!({"updated": []}));
|
||||
}
|
||||
self.note_missing_links(doc, &now);
|
||||
// A background job holds the document: refreshing now would replace the snapshot it works
|
||||
// on and its result would be discarded. The stamps stay as they were, so the change is
|
||||
// seen again by the first look after the job ends.
|
||||
@@ -282,6 +319,124 @@ mod tests {
|
||||
assert!(is(centre(&s), BLUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_look_lists_the_linked_files_that_are_not_there() {
|
||||
let d = dir("missing");
|
||||
let path = d.join("art.png");
|
||||
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
|
||||
let (mut s, _) = placed(&path);
|
||||
let stored = super::linked_files(&s.active().unwrap().doc)[0].0.clone();
|
||||
assert!(s.missing_links().is_empty(), "nothing looked at yet");
|
||||
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
|
||||
assert!(s.missing_links().is_empty());
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
|
||||
assert_eq!(s.missing_links(), [stored.clone()].into());
|
||||
// Only the document's own files: another one in front lists none.
|
||||
s.execute("file.new", json!({"width": 10, "height": 10})).unwrap();
|
||||
assert!(s.missing_links().is_empty());
|
||||
s.set_active(0);
|
||||
assert_eq!(s.missing_links(), [stored].into());
|
||||
std::fs::write(&path, png(10, 10, "#0000ff")).unwrap();
|
||||
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
|
||||
assert!(s.missing_links().is_empty(), "the file is back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reopened_document_lists_no_file_missing_before_it_closed() {
|
||||
let d = dir("reopen");
|
||||
let (path, saved) = (d.join("art.png"), d.join("doc.pcraft"));
|
||||
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
|
||||
let (mut s, _) = placed(&path);
|
||||
s.execute("file.saveACopy", json!({"path": saved.to_string_lossy()})).unwrap();
|
||||
let id = s.active().unwrap().doc.id;
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
|
||||
assert_eq!(s.missing_links().len(), 1);
|
||||
s.close(0);
|
||||
std::fs::write(&path, png(10, 10, "#0000ff")).unwrap();
|
||||
let bytes = std::fs::read(&saved).unwrap();
|
||||
crate::file_cmds::open_bytes_as(&mut s, "doc.pcraft", &bytes, None, Some(saved.to_string_lossy().into_owned())).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.id, id, "the saved document keeps its id");
|
||||
assert!(s.missing_links().is_empty(), "nothing looked at since it opened");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_look_started_before_update_changed_does_not_replace_its_result() {
|
||||
let d = dir("stale");
|
||||
let path = d.join("art.png");
|
||||
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
|
||||
let (mut s, _) = placed(&path);
|
||||
s.start_link_scan();
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.link_scan.as_ref().is_some_and(|l| l.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_none()) {
|
||||
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
|
||||
assert_eq!(s.missing_links().len(), 1);
|
||||
let _ = s.poll_link_scan();
|
||||
assert_eq!(s.missing_links().len(), 1, "the earlier look found the file, but it is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_finished_after_close_cannot_change_a_reopened_document() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("art.png");
|
||||
let saved = directory.path().join("doc.pcraft");
|
||||
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
|
||||
let (mut s, layer) = placed(&path);
|
||||
s.execute("file.saveACopy", json!({"path": saved.to_string_lossy()})).unwrap();
|
||||
let id = s.active().unwrap().doc.id;
|
||||
let stored = super::linked_files(&s.active().unwrap().doc)[0].0.clone();
|
||||
let scan = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
s.link_scan = Some(scan.clone());
|
||||
s.close(0);
|
||||
let bytes = std::fs::read(&saved).unwrap();
|
||||
crate::file_cmds::open_bytes_as(&mut s, "doc.pcraft", &bytes, None, Some(saved.to_string_lossy().into_owned())).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.id, id);
|
||||
*scan.lock().unwrap() = Some((id.0, vec![(stored, vec![layer], None)]));
|
||||
assert!(s.poll_link_scan().is_none());
|
||||
assert!(s.missing_links().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_links_lists_none_missing() {
|
||||
let d = dir("unlinked");
|
||||
let path = d.join("art.png");
|
||||
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
|
||||
let (mut s, id) = placed(&path);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
|
||||
assert_eq!(s.missing_links().len(), 1);
|
||||
s.execute("layer.delete", json!({"layer": id})).unwrap();
|
||||
s.start_link_scan();
|
||||
assert!(s.missing_links().is_empty(), "the last linked layer is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_look_at_links_that_changed_meanwhile_is_dropped() {
|
||||
let d = dir("relinked");
|
||||
let path = d.join("art.png");
|
||||
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
|
||||
let (mut s, layer) = placed(&path);
|
||||
let doc = s.active().unwrap().doc.id.0;
|
||||
let stored = super::linked_files(&s.active().unwrap().doc)[0].0.clone();
|
||||
let inject = |s: &mut Session, file: &str| {
|
||||
let scan = std::sync::Arc::new(std::sync::Mutex::new(Some((doc, vec![(file.to_string(), vec![layer], None)]))));
|
||||
s.link_scan = Some(scan);
|
||||
};
|
||||
// Taken before a relink: its file is no longer linked.
|
||||
inject(&mut s, &d.join("before-relink.png").to_string_lossy());
|
||||
assert_eq!(s.poll_link_scan().unwrap().unwrap(), json!({"updated": []}));
|
||||
assert!(s.missing_links().is_empty(), "a file no longer linked is not listed");
|
||||
// Taken of the current links: applied.
|
||||
inject(&mut s, &stored);
|
||||
s.poll_link_scan().unwrap().unwrap();
|
||||
assert_eq!(s.missing_links(), [stored].into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_look_updates_changed_files() {
|
||||
let d = dir("scan");
|
||||
|
||||
@@ -83,6 +83,11 @@ fn tile_rects(r: Rect) -> impl Iterator<Item = Rect> {
|
||||
|
||||
const TOO_SPARSE: &str = "the adjustment would have to fill too large an area around the pixels the layer holds";
|
||||
|
||||
/// Refuse adjustments that would materialize too much previously unstored pixel data.
|
||||
pub(crate) fn check_adjustment_tile_budget(area: Rect, stored: usize, format: &photocraft_color::PixelFormat) -> Result<(), String> {
|
||||
if unstored_bytes(area, stored as u64, format) > MAX_UNSTORED_BYTES { Err(TOO_SPARSE.into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Apply an adjustment destructively to a surface, weighted by an optional selection.
|
||||
/// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the
|
||||
/// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale).
|
||||
|
||||
@@ -122,6 +122,13 @@ impl Session {
|
||||
self.docs.iter().position(|st| st.doc.id == *id).ok_or(EngineError::NoDocument)
|
||||
}
|
||||
|
||||
/// Relative opening-order distance between two open documents, for portable action steps.
|
||||
pub(crate) fn document_offset_between(&self, from: photocraft_doc::DocId, to: photocraft_doc::DocId) -> Option<i64> {
|
||||
let from = self.open_document_order.iter().position(|&id| id == from)?;
|
||||
let to = self.open_document_order.iter().position(|&id| id == to)?;
|
||||
i64::try_from(to).ok()?.checked_sub(i64::try_from(from).ok()?)
|
||||
}
|
||||
|
||||
/// Add a document (from File → New, an import, etc.) and make it active.
|
||||
/// Preserve its identity unless another open document already owns it.
|
||||
/// Identity-dependent callers must read the admitted document at the returned index.
|
||||
@@ -166,6 +173,8 @@ impl Session {
|
||||
self.cancel_jobs_on(id);
|
||||
self.subject_cache.forget_doc(id);
|
||||
self.object_finder.forget_doc(id);
|
||||
self.link_missing.retain(|(d, _)| *d != id.0);
|
||||
self.link_scan = None;
|
||||
}
|
||||
let d = self.docs.remove(index);
|
||||
self.open_document_order.retain(|&id| id != d.doc.id);
|
||||
|
||||
@@ -99,7 +99,7 @@ impl Session {
|
||||
if let Some(why) = color_channels::refusal(self, id, &run_params) {
|
||||
return Err(EngineError::Disabled(id.into(), why));
|
||||
}
|
||||
if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
|
||||
if let Err(why) = self.explicit_layer_enabled(spec, params)?.unwrap_or_else(|| self.precondition(spec, &run_params)) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
if let Some(why) = self.job_conflict(id, spec.journal) {
|
||||
@@ -160,6 +160,12 @@ impl Session {
|
||||
if spec.id == "layer.smartFilter.deleteFilterMask" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) {
|
||||
return smart_cmds::smart_layer_active(self);
|
||||
}
|
||||
// Recorded Unlink can be repeated on a single, already-unlinked layer.
|
||||
// Keep the menu toggle's two-layer requirement when no state is supplied.
|
||||
if spec.id == "layer.linkLayers" && params.get("linked").and_then(Value::as_bool) == Some(false) {
|
||||
let d = self.active().ok_or_else(|| "no document open".to_string())?;
|
||||
return if d.selected_layers().is_empty() { Err("no layer selected".into()) } else { Ok(()) };
|
||||
}
|
||||
channel_cmds::mask_target_enabled(self, spec.id, params).unwrap_or_else(|| (spec.enabled)(self))
|
||||
}
|
||||
|
||||
@@ -177,17 +183,31 @@ impl Session {
|
||||
/// than on the active one, which stays active (#466). A script, action or agent can then
|
||||
/// Replace Contents, Rasterize or Merge Down a layer without selecting it first. `None` for
|
||||
/// other commands, or without such a layer.
|
||||
pub(crate) fn explicit_layer_enabled(&mut self, spec: &commands::CommandSpec, params: &Value) -> Option<std::result::Result<(), String>> {
|
||||
pub(crate) fn explicit_layer_enabled(&mut self, spec: &commands::CommandSpec, params: &Value) -> Result<Option<std::result::Result<(), String>>> {
|
||||
if !spec.params.contains("\"layer\":id") {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let layer = params.get("layer")?;
|
||||
if !(layer.is_number() || layer.is_object()) {
|
||||
return None;
|
||||
// An ATN group-end index is a valid no-op only for Select. Let that command validate
|
||||
// its other parameters and preserve the current selection.
|
||||
if spec.id == "layer.select"
|
||||
&& let Some(selector @ Value::Object(fields)) = params.get("layer")
|
||||
&& (fields.contains_key("index") || fields.contains_key("ordinal"))
|
||||
&& commands::recorded_layer(self, selector)?.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let target = match commands::explicit_layer(self, params) {
|
||||
Ok(Some(target)) => target,
|
||||
Ok(None) => return Ok(None),
|
||||
// Read-only queries keep their own parameter-error contract; recorded commands
|
||||
// must identify a missing named receiver before checking the active layer.
|
||||
Err(_) if !spec.journal => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let Some(d) = self.active_mut() else { return Ok(None) };
|
||||
if d.doc.layer(target).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let target = commands::explicit_layer(self, params).ok()??;
|
||||
let d = self.active_mut()?;
|
||||
d.doc.layer(target)?;
|
||||
let active = d.active_layer.replace(target);
|
||||
// The checks see the resolved id: a relative ordinal (`Frwr`, `Bckw`) would otherwise
|
||||
// resolve again from the swapped-in active layer and land one layer further.
|
||||
@@ -199,7 +219,7 @@ impl Session {
|
||||
if let Some(d) = self.active_mut() {
|
||||
d.active_layer = active;
|
||||
}
|
||||
Some(r)
|
||||
Ok(Some(r))
|
||||
}
|
||||
|
||||
/// Apply an undoable edit to the active document.
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
//! Recorded direct Gradient Map and Replace Color forms from #10603 / #10607.
|
||||
use photocraft_engine::{Session, atn_cmds};
|
||||
use photocraft_psd::{
|
||||
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
|
||||
descriptor::{Descriptor, Id, UnicodeString, Value as D},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn enumeration(kind: &str, value: &str) -> D {
|
||||
D::Enumerated { type_id: Id::new(kind), value: Id::new(value) }
|
||||
}
|
||||
|
||||
fn color(red: f64, green: f64, blue: f64) -> D {
|
||||
D::Descriptor(Descriptor::new("RGBC").with("Rd ", D::Double(red)).with("Grn ", D::Double(green)).with("Bl ", D::Double(blue)))
|
||||
}
|
||||
|
||||
fn gradient() -> Descriptor {
|
||||
let stop = |location, rgb| {
|
||||
D::Descriptor(
|
||||
Descriptor::new("Clrt").with("Clr ", rgb).with("Type", enumeration("Clry", "UsrS")).with("Lctn", D::Integer(location)).with("Mdpn", D::Integer(50)),
|
||||
)
|
||||
};
|
||||
let opacity = |location| {
|
||||
D::Descriptor(
|
||||
Descriptor::new("TrnS").with("Opct", D::UnitFloat { unit: *b"#Prc", value: 100.0 }).with("Lctn", D::Integer(location)).with("Mdpn", D::Integer(50)),
|
||||
)
|
||||
};
|
||||
Descriptor::new("null").with("gradientsInterpolationMethod", enumeration("gradientInterpolationMethodType", "Gcls")).with(
|
||||
"Grad",
|
||||
D::Descriptor(
|
||||
Descriptor::new("Grdn")
|
||||
.with("Nm ", D::Text(UnicodeString::new_nul("Recorded gradient")))
|
||||
.with("GrdF", enumeration("GrdF", "CstS"))
|
||||
.with("Intr", D::Double(4096.0))
|
||||
.with("Clrs", D::List(vec![stop(0, color(0.0, 0.0, 255.0)), stop(4096, color(255.0, 0.0, 0.0))]))
|
||||
.with("Trns", D::List(vec![opacity(0), opacity(4096)])),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn replace() -> Descriptor {
|
||||
let lab = || D::Descriptor(Descriptor::new("LbCl").with("Lmnc", D::Double(40.0)).with("A ", D::Double(30.0)).with("B ", D::Double(40.0)));
|
||||
Descriptor::new("null")
|
||||
.with("Fzns", D::Integer(40))
|
||||
.with("Mnm ", lab())
|
||||
.with("Mxm ", lab())
|
||||
.with("H ", D::Integer(30))
|
||||
.with("Strt", D::Integer(0))
|
||||
.with("Lght", D::Integer(0))
|
||||
.with("colorModel", D::Integer(0))
|
||||
}
|
||||
|
||||
fn bytes(event: [u8; 4], descriptor: Descriptor) -> Vec<u8> {
|
||||
atn::write(&AtnFile {
|
||||
name: UnicodeString::new_nul("Direct adjustments"),
|
||||
expanded: false,
|
||||
actions: vec![AtnAction {
|
||||
name: UnicodeString::new_nul("Recorded"),
|
||||
items: vec![AtnItem { event: AtnEvent::Code(event), descriptor: Some(descriptor), ..Default::default() }],
|
||||
..Default::default()
|
||||
}],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn load(event: [u8; 4], descriptor: Descriptor, command: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
let bytes = bytes(event, descriptor);
|
||||
let report = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(report["unsupported"], json!([]), "recorded {} must map to {command}", String::from_utf8_lossy(&event));
|
||||
assert_eq!(s.actions.list[0].steps[0].0, command);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_grmp_imports_as_direct_gradient_map() {
|
||||
let s = load(*b"GrMp", gradient(), "image.adjustments.gradientMap");
|
||||
assert_eq!(s.actions.list[0].steps[0].1["stops"], json!([[0, [0, 0, 1]], [1, [1, 0, 0]]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_rplc_imports_as_direct_replace_color() {
|
||||
let s = load(*b"RplC", replace(), "image.adjustments.replaceColor");
|
||||
assert_eq!(s.actions.list[0].steps[0].1["hue"], json!(30));
|
||||
}
|
||||
|
||||
fn saved(s: &Session) -> AtnItem {
|
||||
let refs = s.actions.list.iter().collect::<Vec<_>>();
|
||||
let (bytes, skipped) = atn_cmds::export("Direct adjustments", &refs, &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0)
|
||||
}
|
||||
|
||||
/// Edit a known nested fixture object without replacing its surrounding descriptor.
|
||||
fn object<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut Descriptor {
|
||||
match &mut d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 {
|
||||
D::Descriptor(d) => d,
|
||||
_ => panic!("not an object"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Expose a fixture list so negative cases change only the intended wire setting.
|
||||
fn list<'a>(d: &'a mut Descriptor, key: &str) -> &'a mut Vec<D> {
|
||||
match &mut d.items.iter_mut().find(|(k, _)| k.is(key)).unwrap().1 {
|
||||
D::List(l) => l,
|
||||
_ => panic!("not a list"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the first color stop for fractional-color and unsupported-setting controls.
|
||||
fn first_stop(d: &mut Descriptor) -> &mut Descriptor {
|
||||
match &mut list(object(d, "Grad"), "Clrs")[0] {
|
||||
D::Descriptor(d) => d,
|
||||
_ => panic!("not a stop"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving retains the measured wire forms, including source colors that hex would quantize.
|
||||
#[test]
|
||||
fn direct_forms_save_exact_descriptors_including_fractional_rgb() {
|
||||
let mut fractional = gradient();
|
||||
object(first_stop(&mut fractional), "Clr ").items[0].1 = D::Double(73.1234567);
|
||||
let mut defaults = gradient();
|
||||
object(first_stop(&mut defaults), "Clr ").items[2].1 = D::Double(0.0);
|
||||
let g = object(&mut defaults, "Grad");
|
||||
let D::Descriptor(stop) = &mut list(g, "Clrs")[1] else { panic!("stop") };
|
||||
object(stop, "Clr ").items[1].1 = D::Double(255.0);
|
||||
object(stop, "Clr ").items[2].1 = D::Double(255.0);
|
||||
let mut implicit_classic = gradient();
|
||||
implicit_classic.items.remove(0);
|
||||
for descriptor in [defaults, gradient(), fractional, implicit_classic] {
|
||||
let s = load(*b"GrMp", descriptor.clone(), "image.adjustments.gradientMap");
|
||||
let item = saved(&s);
|
||||
assert_eq!(item.event, AtnEvent::Code(*b"GrMp"));
|
||||
assert_eq!(item.descriptor, Some(descriptor));
|
||||
}
|
||||
let mut changed = replace();
|
||||
object(&mut changed, "Mnm ").items[0].1 = D::Double(20.125);
|
||||
changed.items[0].1 = D::Integer(130);
|
||||
changed.items[3].1 = D::Integer(-120);
|
||||
changed.items[4].1 = D::Integer(45);
|
||||
changed.items[5].1 = D::Integer(-20);
|
||||
for descriptor in [replace(), changed] {
|
||||
let s = load(*b"RplC", descriptor.clone(), "image.adjustments.replaceColor");
|
||||
let item = saved(&s);
|
||||
assert_eq!(item.event, AtnEvent::Code(*b"RplC"));
|
||||
assert_eq!(item.descriptor, Some(descriptor));
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(mode: &str, depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":4,"height":1,"mode":mode,"depth":depth})).unwrap();
|
||||
s.edit("original pixels", |doc, active| {
|
||||
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let format = surface.format();
|
||||
let data = [[0.0, 0.0, 0.0, 1.0], [0.3, 0.8, 0.2, 1.0], [0.7, 0.2, 0.6, 0.4], [1.0, 1.0, 1.0, 1.0]]
|
||||
.into_iter()
|
||||
.flat_map(|p| photocraft_raster::from_rgba(&format, p))
|
||||
.collect::<Vec<_>>();
|
||||
surface.write_region(photocraft_doc::Rect::from_xywh(0, 0, 4, 1), &data);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn raw(s: &Session) -> Vec<f32> {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds())
|
||||
}
|
||||
|
||||
fn play(s: &mut Session) {
|
||||
let report = s.execute("actions.play", json!({"action":"Recorded","set":"Direct adjustments"})).unwrap();
|
||||
assert_eq!(report["ran"], 1, "{report}");
|
||||
assert!(report.get("failed").is_none(), "{report}");
|
||||
}
|
||||
|
||||
/// Execute fractional stops at every depth while preserving unselected samples and Undo.
|
||||
#[test]
|
||||
fn fractional_gradient_changes_pixels_and_preserves_selection_and_undo() {
|
||||
let mut descriptor = gradient();
|
||||
object(first_stop(&mut descriptor), "Clr ").items[0].1 = D::Double(73.125);
|
||||
for depth in [8, 16, 32] {
|
||||
let imported = atn_cmds::import(&bytes(*b"GrMp", descriptor.clone())).unwrap();
|
||||
assert!(imported.unsupported.is_empty());
|
||||
let mut direct = fixture("rgb", depth);
|
||||
direct.execute("select.rect", json!({"x":0,"y":0,"width":3,"height":1})).unwrap();
|
||||
let original = raw(&direct);
|
||||
direct.execute("image.adjustments.gradientMap", imported.actions[0].steps[0].1.clone()).unwrap();
|
||||
let expected = raw(&direct);
|
||||
assert_ne!(expected, original);
|
||||
assert_eq!(&expected[12..], &original[12..], "outside the selection");
|
||||
let mut replay = fixture("rgb", depth);
|
||||
replay.execute("select.rect", json!({"x":0,"y":0,"width":3,"height":1})).unwrap();
|
||||
replay.actions.list = imported.actions;
|
||||
play(&mut replay);
|
||||
assert_eq!(raw(&replay), expected);
|
||||
assert!(replay.undo());
|
||||
assert_eq!(raw(&replay), original);
|
||||
if depth == 32 {
|
||||
assert!((expected[0] - 73.125 / 255.0).abs() < 1e-6, "fractional color must not be rounded to hex");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_lab_pixels(s: &mut Session) {
|
||||
s.edit("known Lab pixels", |doc, active| {
|
||||
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
surface.write_region(
|
||||
photocraft_doc::Rect::from_xywh(0, 0, 4, 1),
|
||||
&[
|
||||
0.4,
|
||||
128.0 / 255.0,
|
||||
128.0 / 255.0,
|
||||
1.0,
|
||||
0.5,
|
||||
128.0 / 255.0,
|
||||
128.0 / 255.0,
|
||||
1.0,
|
||||
0.6,
|
||||
128.0 / 255.0,
|
||||
128.0 / 255.0,
|
||||
1.0,
|
||||
0.95,
|
||||
128.0 / 255.0,
|
||||
128.0 / 255.0,
|
||||
1.0,
|
||||
],
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Different box widths must select different pixels even when their midpoint is identical.
|
||||
#[test]
|
||||
fn lab_boxes_with_the_same_midpoint_have_different_real_effects() {
|
||||
// Independent mask anchors: at zero fuzziness a neutral Lab50 pixel is
|
||||
// inside both boxes, Lab40/60 only the wide one, and Lab95 is outside both.
|
||||
for depth in [8, 16, 32] {
|
||||
let mut results = Vec::new();
|
||||
for (minimum, maximum) in [(49, 51), (39, 61)] {
|
||||
let mut s = fixture("lab", depth);
|
||||
set_lab_pixels(&mut s);
|
||||
let before = raw(&s);
|
||||
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[minimum,0,0],"labMaximum":[maximum,0,0],"fuzziness":0,"lightness":100})).unwrap();
|
||||
let after = raw(&s);
|
||||
assert!(after[4] > 0.99, "Lab50 must become white: {after:?}");
|
||||
assert!((after[12] - before[12]).abs() < 0.005, "Lab95 stays outside");
|
||||
if minimum == 49 {
|
||||
assert!((after[0] - before[0]).abs() < 0.005);
|
||||
} else {
|
||||
assert!(after[0] > 0.99 && after[8] > 0.99, "wide box includes Lab40/60");
|
||||
}
|
||||
results.push(after);
|
||||
assert!(s.undo());
|
||||
assert_eq!(raw(&s), before);
|
||||
}
|
||||
assert_ne!(results[0], results[1], "collapsing a box to its midpoint is incorrect");
|
||||
}
|
||||
}
|
||||
|
||||
/// Conflicting or invalid Lab settings leave both pixels and history untouched.
|
||||
#[test]
|
||||
fn invalid_lab_inputs_are_rejected_before_any_edit() {
|
||||
let mut s = fixture("rgb", 32);
|
||||
for patch in [
|
||||
json!({"labMaximum":null}),
|
||||
json!({"labMinimum":[0,0]}),
|
||||
json!({"labMinimum":[1e100,0,0]}),
|
||||
json!({"labMinimum":[50.0000001,0,0],"labMaximum":[50,0,0]}),
|
||||
json!({"color":"#ff0000"}),
|
||||
json!({"points":[]}),
|
||||
json!({"localized":false}),
|
||||
json!({"target":"mask"}),
|
||||
json!({"hue":"30"}),
|
||||
json!({"layer":999999}),
|
||||
] {
|
||||
let mut p = json!({"labMinimum":[40,0,0],"labMaximum":[60,0,0],"lightness":100});
|
||||
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let history = s.active().unwrap().history.past_len();
|
||||
assert!(s.execute("image.adjustments.replaceColor", p.clone()).is_err(), "{p}");
|
||||
assert_eq!(s.active().unwrap().doc, before, "{p}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
fn kept(event: [u8; 4], d: Descriptor) {
|
||||
let imported = atn_cmds::import(&bytes(event, d.clone())).unwrap();
|
||||
assert_eq!(imported.unsupported.len(), 1, "{d:?}");
|
||||
let mut s = Session::new();
|
||||
s.actions.list = imported.actions;
|
||||
assert_eq!(saved(&s).descriptor, Some(d));
|
||||
}
|
||||
|
||||
/// Unsupported interpolation, transparency and wire types survive as complete kept steps.
|
||||
#[test]
|
||||
fn unsupported_gradient_settings_and_wire_forms_remain_kept() {
|
||||
let mut midpoint = gradient();
|
||||
first_stop(&mut midpoint).items[3].1 = D::Integer(30);
|
||||
let mut transparency = gradient();
|
||||
let D::Descriptor(opacity) = &mut list(object(&mut transparency, "Grad"), "Trns")[0] else { panic!("opacity") };
|
||||
opacity.items[0].1 = D::UnitFloat { unit: *b"#Prc", value: 50.0 };
|
||||
let mut interpolation = gradient();
|
||||
interpolation.items[0].1 = enumeration("gradientInterpolationMethodType", "Lnr ");
|
||||
let mut order = gradient();
|
||||
list(object(&mut order, "Grad"), "Clrs").reverse();
|
||||
let mut duplicate = gradient();
|
||||
duplicate.items.push(duplicate.items[0].clone());
|
||||
let mut unknown = gradient();
|
||||
unknown.items.push((Id::new("extra"), D::Boolean(true)));
|
||||
for d in [midpoint, transparency, interpolation, order, duplicate, unknown] {
|
||||
kept(*b"GrMp", d);
|
||||
}
|
||||
let mut f = atn::parse(&bytes(*b"GrMp", gradient())).unwrap();
|
||||
f.actions[0].items[0].event = AtnEvent::name("GrMp");
|
||||
assert_eq!(atn_cmds::import(&atn::write(&f).unwrap()).unwrap().unsupported.len(), 1);
|
||||
}
|
||||
|
||||
/// Invalid recorded boxes stay preserved, and edited or unrepresentable native settings cannot save lossily.
|
||||
#[test]
|
||||
fn invalid_replace_descriptors_and_lossy_exports_are_refused() {
|
||||
let mut model = replace();
|
||||
model.items[6].1 = D::Integer(1);
|
||||
let mut inverted = replace();
|
||||
object(&mut inverted, "Mnm ").items[0].1 = D::Double(80.0);
|
||||
let mut missing = replace();
|
||||
missing.items.remove(0);
|
||||
let mut unknown = replace();
|
||||
unknown.items.push((Id::new("extra"), D::Boolean(true)));
|
||||
let mut duplicate = replace();
|
||||
duplicate.items.push(duplicate.items[0].clone());
|
||||
for d in [model, inverted, missing, unknown, duplicate] {
|
||||
kept(*b"RplC", d);
|
||||
}
|
||||
let mut s = load(*b"GrMp", gradient(), "image.adjustments.gradientMap");
|
||||
s.actions.list[0].steps[0].1["stops"][0][1][0] = json!(0.2);
|
||||
assert!(atn_cmds::export("Direct adjustments", &[&s.actions.list[0]], &[], false).is_err(), "stale color metadata must not fall through to GdMp");
|
||||
for p in [json!({"color":"#ff0000"}), json!({"labMinimum":[40,0,0],"labMaximum":[60,0,0],"hue":0.5})] {
|
||||
let a = photocraft_engine::actions_cmds::Action::new("Native", vec![("image.adjustments.replaceColor".into(), p)]);
|
||||
assert!(atn_cmds::export("Direct adjustments", &[&a], &[], false).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback respects bounded and implicitly selected masks at every depth, including an offset selection and Undo.
|
||||
#[test]
|
||||
fn recorded_replace_color_clamps_shifts_and_plays_the_lab_box_at_every_depth() {
|
||||
for (depth, inverse) in [8, 16, 32].into_iter().flat_map(|depth| [(depth, false), (depth, true)]) {
|
||||
let mut d = replace();
|
||||
for key in ["Mnm ", "Mxm "] {
|
||||
let o = object(&mut d, key);
|
||||
o.items[0].1 = D::Double(if key == "Mnm " { 39.0 } else { 61.0 });
|
||||
o.items[1].1 = D::Double(0.0);
|
||||
o.items[2].1 = D::Double(0.0);
|
||||
}
|
||||
d.items[0].1 = D::Integer(-10);
|
||||
d.items[3].1 = D::Integer(0);
|
||||
d.items[5].1 = D::Integer(250);
|
||||
let imported = atn_cmds::import(&bytes(*b"RplC", d)).unwrap();
|
||||
assert!(imported.unsupported.is_empty());
|
||||
assert_eq!(imported.actions[0].steps[0].1["fuzziness"], 0);
|
||||
assert_eq!(imported.actions[0].steps[0].1["lightness"], 100);
|
||||
let mut s = fixture("lab", depth);
|
||||
set_lab_pixels(&mut s);
|
||||
s.execute("select.rect", json!({"x":1,"y":0,"width":1,"height":1})).unwrap();
|
||||
if inverse {
|
||||
let mut mask = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[1.0]);
|
||||
mask.fill_rect(photocraft_doc::Rect::new(1, 0, 2, 1), &[0.0]);
|
||||
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).selection = Some(mask);
|
||||
}
|
||||
let before = raw(&s);
|
||||
s.actions.list = imported.actions;
|
||||
play(&mut s);
|
||||
let after = raw(&s);
|
||||
if inverse {
|
||||
assert!(after[0] > 0.99 && after[8] > 0.99);
|
||||
assert_eq!(&after[4..8], &before[4..8], "the selection hole must stay unchanged");
|
||||
} else {
|
||||
assert!(after[4] > 0.99);
|
||||
assert_eq!(&after[..4], &before[..4], "pixels before the selection must stay unchanged");
|
||||
assert_eq!(&after[8..], &before[8..], "pixels after the selection must stay unchanged");
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(raw(&s), before);
|
||||
let descriptor = saved(&s).descriptor.unwrap();
|
||||
assert_eq!(descriptor.get("Fzns"), Some(&D::Integer(0)));
|
||||
assert_eq!(descriptor.get("Lght"), Some(&D::Integer(100)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Matching uses original Lab/CMYK channels and tagged RGB rather than display-converted samples.
|
||||
#[test]
|
||||
fn lab_matching_uses_native_channels_and_the_embedded_rgb_profile() {
|
||||
use photocraft_cms::{Builtin, Transform, TransformOptions};
|
||||
for depth in [8, 16, 32] {
|
||||
for (mode, profile) in [("rgb", Builtin::DisplayP3), ("gray", Builtin::SGray), ("cmyk", Builtin::CoatedCmyk), ("lab", Builtin::LabD50)] {
|
||||
let mut s = fixture(mode, depth);
|
||||
s.edit("explicit profile", |doc, _| {
|
||||
doc.icc_profile = Some(profile.profile().clone().with_encoded_bytes().to_bytes());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
|
||||
let format = surface.format();
|
||||
let before = raw(&s);
|
||||
// Independent direct transform from this explicitly chosen source profile,
|
||||
// not the command's profile resolver or its new matching helper.
|
||||
let t = Transform::with_options(profile.profile(), Builtin::LabD50.profile(), TransformOptions::default()).unwrap();
|
||||
let mut lab = [[0.0, 0.0, 0.0, 1.0]; 4];
|
||||
t.convert_f32(&before, format.channels(), lab.as_flattened_mut(), 4, true);
|
||||
let center = [lab[1][0] as f64 * 100.0, lab[1][1] as f64 * 255.0 - 128.0, lab[1][2] as f64 * 255.0 - 128.0];
|
||||
let lo = center.map(|v| v - 0.2);
|
||||
let hi = center.map(|v| v + 0.2);
|
||||
s.execute("image.adjustments.replaceColor", json!({"labMinimum":lo,"labMaximum":hi,"fuzziness":0,"lightness":100})).unwrap();
|
||||
let after = raw(&s);
|
||||
let changed = after.chunks_exact(format.channels()).nth(1).unwrap();
|
||||
let changed = photocraft_raster::to_rgba(&format, changed);
|
||||
assert!(changed[..3].iter().all(|v| *v > 0.97), "{mode}/{depth}: {changed:?}");
|
||||
assert_eq!(&after[..format.channels()], &before[..format.channels()], "black is outside {mode}/{depth}");
|
||||
assert!(s.undo());
|
||||
assert_eq!(raw(&s), before);
|
||||
if mode == "rgb" {
|
||||
let mut srgb = fixture("rgb", depth);
|
||||
let before_srgb = raw(&srgb);
|
||||
srgb.execute("image.adjustments.replaceColor", json!({"labMinimum":lo,"labMaximum":hi,"fuzziness":0,"lightness":100})).unwrap();
|
||||
assert_eq!(raw(&srgb), before_srgb, "the P3 sample must not be mistaken for an sRGB sample");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full and selected edits agree across tile edges, including the final partial tiles and Undo.
|
||||
#[test]
|
||||
fn lab_replace_crosses_tile_edges_without_changing_unselected_native_pixels() {
|
||||
let tile = photocraft_geom::TILE_SIZE;
|
||||
let area = photocraft_doc::Rect::new(0, 0, tile + 4, tile + 3);
|
||||
let window = photocraft_doc::Rect::new(tile - 1, tile - 1, tile + 2, tile + 2);
|
||||
let width = area.width() as usize;
|
||||
for depth in [8, 16, 32] {
|
||||
for selected in ["none", "window", "outside"] {
|
||||
let mut s = fixture("lab", depth);
|
||||
s.edit("Known Lab tiles", |doc, active| {
|
||||
doc.size = area.size();
|
||||
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(area, &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
|
||||
if selected != "none" {
|
||||
let default = if selected == "outside" { 1.0 } else { 0.0 };
|
||||
let mut mask = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[default]);
|
||||
mask.fill_rect(window, &[1.0 - default]);
|
||||
doc.selection = Some(mask);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let original = raw(&s);
|
||||
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap();
|
||||
let after = raw(&s);
|
||||
for (i, (pixel, original)) in after.as_chunks::<4>().0.iter().zip(original.as_chunks::<4>().0).enumerate() {
|
||||
let inside = window.contains((i % width) as i32, (i / width) as i32);
|
||||
let changed = selected == "none" || (selected == "window") == inside;
|
||||
if changed {
|
||||
assert!(pixel[0] > 0.99, "{depth}/{selected} pixel {i}: {pixel:?}");
|
||||
} else {
|
||||
assert_eq!(pixel, original, "{depth}/{selected} pixel {i}");
|
||||
}
|
||||
assert_eq!(pixel[3], original[3]);
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Matching stored pixels change without materializing gaps whose default does not change.
|
||||
#[test]
|
||||
fn lab_replace_sparse_pixels_preserve_unstored_gaps() {
|
||||
let tile = photocraft_geom::TILE_SIZE;
|
||||
let far = photocraft_doc::Rect::new(4 * tile, 4 * tile, 4 * tile + 1, 4 * tile + 1);
|
||||
let second = far.translate(2 * tile, 0);
|
||||
let gap = far.translate(tile, 0);
|
||||
for (alpha, opacity) in [(true, 0.0), (true, 1.0), (false, 1.0)] {
|
||||
let mut s = fixture("lab", 32);
|
||||
s.edit("Two distant Lab pixels", |doc, active| {
|
||||
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let format = photocraft_doc::PixelFormat { alpha, ..surface.format() };
|
||||
let channels = format.channels();
|
||||
*surface = photocraft_raster::Surface::with_default(format, &[0.25, 128.0 / 255.0, 128.0 / 255.0, opacity][..channels]);
|
||||
for rect in [far, second] {
|
||||
surface.fill_rect(rect, &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0][..channels]);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let layer = s.active().unwrap().active_layer.unwrap();
|
||||
let original = before.layer(layer).unwrap().surface().unwrap();
|
||||
assert_eq!(original.tile_count(), 2);
|
||||
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap();
|
||||
let surface = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap();
|
||||
for rect in [far, second] {
|
||||
assert!(surface.read_region(rect)[0] > 0.99);
|
||||
}
|
||||
assert_eq!(surface.read_region(gap), original.read_region(gap));
|
||||
assert_eq!(surface.default_pixel(), original.default_pixel());
|
||||
assert_eq!(surface.tile_count(), 2);
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque defaults remain visible when the surface has no tiles or only a small exception.
|
||||
#[test]
|
||||
fn lab_replace_includes_visible_default_pixels_with_and_without_alpha() {
|
||||
let tile = photocraft_geom::TILE_SIZE;
|
||||
let far = photocraft_doc::Rect::new(2 * tile + 1, 2 * tile + 1, 2 * tile + 2, 2 * tile + 2);
|
||||
for depth in [8, 16, 32] {
|
||||
for alpha in [false, true] {
|
||||
for exception in [None, Some(photocraft_doc::Rect::new(1, 0, 2, 1)), Some(far)] {
|
||||
let mut s = fixture("lab", depth);
|
||||
s.edit("Opaque default Lab", |doc, active| {
|
||||
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let format = photocraft_doc::PixelFormat { alpha, ..surface.format() };
|
||||
let channels = format.channels();
|
||||
*surface = photocraft_raster::Surface::with_default(format, &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0][..channels]);
|
||||
if let Some(rect) = exception {
|
||||
surface.fill_rect(rect, &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0][..channels]);
|
||||
} else {
|
||||
assert!(surface.content_bounds().is_empty());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let original = raw(&s);
|
||||
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap();
|
||||
for (i, (pixel, original)) in raw(&s).chunks_exact(3 + usize::from(alpha)).zip(original.chunks_exact(3 + usize::from(alpha))).enumerate() {
|
||||
if exception == Some(photocraft_doc::Rect::new(1, 0, 2, 1)) && i == 1 {
|
||||
assert_eq!(pixel, original);
|
||||
} else {
|
||||
assert!(pixel[0] > 0.99, "{depth}/{alpha}/{exception:?} pixel {i}: {pixel:?}");
|
||||
}
|
||||
}
|
||||
if exception == Some(far) {
|
||||
let layer = s.active().unwrap().active_layer.unwrap();
|
||||
let surface = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap();
|
||||
assert!(surface.read_region(far.translate(-tile, 0))[0] > 0.99, "off-canvas defaults inside the edit extent change too");
|
||||
assert_eq!(surface.read_region(far), before.layer(layer).unwrap().surface().unwrap().read_region(far));
|
||||
assert_eq!(surface.read_region(far.translate(2, 2)), surface.default_pixel(), "pixels outside the extent stay unchanged");
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Selection islands and holes retain their coverage over unstored opaque pixels.
|
||||
#[test]
|
||||
fn lab_replace_selection_controls_opaque_gap_pixels() {
|
||||
let tile = photocraft_geom::TILE_SIZE;
|
||||
let far = photocraft_doc::Rect::new(2 * tile, 2 * tile, 2 * tile + 1, 2 * tile + 1);
|
||||
let gap = far.translate(-tile, -tile);
|
||||
for implicit in [false, true] {
|
||||
let mut s = fixture("lab", 32);
|
||||
s.edit("Selected opaque gap", |doc, active| {
|
||||
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
*surface = photocraft_raster::Surface::with_default(surface.format(), &[0.5, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
|
||||
surface.fill_rect(far, &[0.25, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
|
||||
let coverage = if implicit { 1.0 } else { 0.0 };
|
||||
let mut mask = photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[coverage]);
|
||||
mask.fill_rect(gap, &[1.0 - coverage]);
|
||||
doc.selection = Some(mask);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let layer = s.active().unwrap().active_layer.unwrap();
|
||||
let original = before.layer(layer).unwrap().surface().unwrap();
|
||||
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[49,0,0],"labMaximum":[51,0,0],"fuzziness":0,"lightness":100})).unwrap();
|
||||
let surface = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap();
|
||||
let canvas_pixel = photocraft_doc::Rect::new(0, 0, 1, 1);
|
||||
for (rect, changed) in [(gap, !implicit), (canvas_pixel, implicit)] {
|
||||
if changed {
|
||||
assert!(surface.read_region(rect)[0] > 0.99);
|
||||
} else {
|
||||
assert_eq!(surface.read_region(rect), original.read_region(rect));
|
||||
}
|
||||
}
|
||||
assert_eq!(surface.read_region(far), original.read_region(far));
|
||||
assert_eq!(surface.default_pixel(), original.default_pixel());
|
||||
if !implicit {
|
||||
assert_eq!(surface.tile_count(), 2, "only the selected gap tile is added");
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Existing RGB replacement and ordinary gradient export retain their native behavior.
|
||||
#[test]
|
||||
fn legacy_rgb_replace_color_and_ordinary_gradient_exports_are_unchanged() {
|
||||
let mut s = fixture("rgb", 32);
|
||||
let before = raw(&s);
|
||||
let mut expected = before.as_chunks::<4>().0.to_vec();
|
||||
photocraft_algo::tone::replace_color(&mut expected, [0.3, 0.8, 0.2], 40.0, 60.0, 20.0, -10.0);
|
||||
s.execute("image.adjustments.replaceColor", json!({"color":[0.3,0.8,0.2],"fuzziness":40,"hue":60,"saturation":20,"lightness":-10})).unwrap();
|
||||
assert_eq!(raw(&s), expected.as_flattened());
|
||||
let action = photocraft_engine::actions_cmds::Action::new(
|
||||
"Ordinary",
|
||||
vec![("image.adjustments.gradientMap".into(), json!({"stops":[[0,"#000000"],[1,"#ffffff"]]}))],
|
||||
);
|
||||
let (b, _) = atn_cmds::export("Ordinary", &[&action], &[], false).unwrap();
|
||||
assert_eq!(
|
||||
atn::parse(&b).unwrap().actions[0].items[0].event,
|
||||
AtnEvent::name("gradientMapEvent"),
|
||||
"an ordinary Gradient Map saves as the string event (#10702)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Replacement preserves alpha and refuses locked or profile-incompatible surfaces before editing.
|
||||
#[test]
|
||||
fn lab_replace_preserves_alpha_and_rejects_locked_or_mismatched_surfaces() {
|
||||
let p = json!({"labMinimum":[0,-128,-128],"labMaximum":[100,127,127],"lightness":100});
|
||||
let mut s = fixture("rgb", 32);
|
||||
let before = raw(&s);
|
||||
s.execute("image.adjustments.replaceColor", p.clone()).unwrap();
|
||||
for (a, b) in raw(&s).as_chunks::<4>().0.iter().zip(before.as_chunks::<4>().0) {
|
||||
assert_eq!(a[3], b[3]);
|
||||
}
|
||||
assert!(s.undo());
|
||||
s.edit("pixel lock", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().locks.pixels = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let original = s.active().unwrap().doc.clone();
|
||||
assert!(s.execute("image.adjustments.replaceColor", p.clone()).is_err());
|
||||
assert_eq!(s.active().unwrap().doc, original);
|
||||
let mut mismatch = fixture("rgb", 32);
|
||||
mismatch
|
||||
.edit("inconsistent synthetic mode", |doc, _| {
|
||||
doc.mode = photocraft_doc::ColorMode::Cmyk;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let original = mismatch.active().unwrap().doc.clone();
|
||||
assert!(mismatch.execute("image.adjustments.replaceColor", p).is_err());
|
||||
assert_eq!(mismatch.active().unwrap().doc, original);
|
||||
}
|
||||
|
||||
/// Fractional selection mixing changes RGB while preserving each F32 alpha bit exactly.
|
||||
#[test]
|
||||
fn lab_replace_fractional_selection_preserves_f32_alpha_bits() {
|
||||
for coverage in [53.0 / 255.0, 127.0 / 255.0] {
|
||||
let mut s = fixture("rgb", 32);
|
||||
s.edit("fractional alpha and selection", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_region(photocraft_doc::Rect::new(2, 0, 3, 1), &[0.7, 0.2, 0.6, 0.7]);
|
||||
doc.selection = Some(photocraft_raster::Surface::with_default(photocraft_doc::PixelFormat::GRAY8, &[coverage]));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let original = raw(&s);
|
||||
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[0,-128,-128],"labMaximum":[100,127,127],"lightness":100})).unwrap();
|
||||
let after = raw(&s);
|
||||
assert_ne!(&after[8..11], &original[8..11]);
|
||||
for (after, before) in after.as_chunks::<4>().0.iter().zip(original.as_chunks::<4>().0) {
|
||||
assert_eq!(after[3].to_bits(), before[3].to_bits(), "coverage {coverage}");
|
||||
}
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero shift must not alter native ink values through an unnecessary RGB separation.
|
||||
#[test]
|
||||
fn an_unchanged_lab_tone_shift_preserves_native_cmyk_samples() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = fixture("cmyk", depth);
|
||||
s.edit("nontrivial native CMYK", |doc, active| {
|
||||
doc.layer_mut(active.unwrap())
|
||||
.unwrap()
|
||||
.surface_mut()
|
||||
.unwrap()
|
||||
.write_region(photocraft_doc::Rect::from_xywh(1, 0, 1, 1), &[0.2, 0.3, 0.4, 0.25, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = raw(&s);
|
||||
s.execute("image.adjustments.replaceColor", json!({"labMinimum":[0,-128,-128],"labMaximum":[100,127,127],"hue":0,"saturation":0,"lightness":0}))
|
||||
.unwrap();
|
||||
assert_eq!(raw(&s), before, "an unchanged tone operation must not re-separate CMYK, depth {depth}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,249 @@
|
||||
//! Crop with Delete Cropped Pixels off turns the Background into a normal layer, "Layer 0", as
|
||||
//! Photoshop does: it keeps the pixels past the canvas, and an extension is transparent on every
|
||||
//! layer. Delete on keeps the Background, and Image › Reveal All is unchanged (#10867).
|
||||
|
||||
use photocraft_engine::actions_cmds::Action;
|
||||
use photocraft_engine::{EngineError, Session, atn_cmds};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_psd::{atn, descriptor::Value as D};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const MAGENTA: [f32; 4] = [1.0, 0.0, 1.0, 1.0];
|
||||
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
|
||||
/// The issue's test document at a tenth of the size: 80 × 50 px, a white Background and a pixel
|
||||
/// layer "Quads" covering the canvas in red, with magenta as the background colour.
|
||||
fn doc() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 80, "height": 50, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Quads"})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#ff00ff"})).unwrap();
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "foreground"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn d(s: &Session) -> &photocraft_doc::Document {
|
||||
&s.active().unwrap().doc
|
||||
}
|
||||
|
||||
fn size(s: &Session) -> (u32, u32) {
|
||||
(d(s).size.width, d(s).size.height)
|
||||
}
|
||||
|
||||
fn past(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
fn bottom(s: &Session) -> &photocraft_doc::Layer {
|
||||
&d(s).layers[0]
|
||||
}
|
||||
|
||||
fn content(s: &Session, i: usize) -> Rect {
|
||||
d(s).layers[i].surface().unwrap().content_bounds()
|
||||
}
|
||||
|
||||
/// The bottom layer is no longer a Background: a normal, unlocked layer named "Layer 0".
|
||||
fn assert_layer_0(s: &Session, what: &str) {
|
||||
assert!(d(s).background().is_none(), "{what}: no Background left");
|
||||
let l = bottom(s);
|
||||
assert_eq!(l.name, "Layer 0", "{what}");
|
||||
assert!(!l.locks.transparency && !l.locks.position, "{what}: unlocked");
|
||||
}
|
||||
|
||||
fn crop(s: &mut Session, mut p: Value, delete: bool) {
|
||||
p["deleteCroppedPixels"] = json!(delete);
|
||||
let before = past(s);
|
||||
s.execute("image.crop", p).unwrap();
|
||||
assert_eq!(past(s), before + 1, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_turns_the_background_into_layer_0_keeping_the_hidden_pixels() {
|
||||
let mut s = doc();
|
||||
crop(&mut s, json!({"x": 0, "y": 0, "width": 70, "height": 50}), false);
|
||||
assert_eq!(size(&s), (70, 50));
|
||||
assert_layer_0(&s, "shrink");
|
||||
// Both layers keep their 80 px, hidden past the canvas.
|
||||
assert_eq!(content(&s, 0), Rect::new(0, 0, 80, 50), "Layer 0");
|
||||
assert_eq!(content(&s, 1), Rect::new(0, 0, 80, 50), "Quads");
|
||||
assert_eq!(bottom(&s).surface().unwrap().rgba(75, 25), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extending_leaves_the_new_area_transparent_on_every_layer() {
|
||||
let mut s = doc();
|
||||
crop(&mut s, json!({"x": 0, "y": 0, "width": 90, "height": 50}), false);
|
||||
assert_eq!(size(&s), (90, 50));
|
||||
assert_layer_0(&s, "extend");
|
||||
for i in 0..2 {
|
||||
let surf = d(&s).layers[i].surface().unwrap();
|
||||
assert_eq!(surf.rgba(85, 25)[3], 0.0, "layer {i}: the added strip is transparent");
|
||||
}
|
||||
assert_eq!(bottom(&s).surface().unwrap().rgba(40, 25), WHITE);
|
||||
assert_eq!(d(&s).layers[1].surface().unwrap().rgba(40, 25), RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turned_crop_turns_layer_0_with_the_others() {
|
||||
// The issue's 6.3° frame (687 × 429 of 800 × 500), centred.
|
||||
let mut s = doc();
|
||||
crop(&mut s, json!({"x": 6, "y": 4, "width": 68, "height": 42, "angle": 6.3}), false);
|
||||
assert_eq!(size(&s), (68, 42));
|
||||
assert_layer_0(&s, "turned");
|
||||
// Every layer turned, keeping its overhang past the canvas.
|
||||
let canvas = Rect::new(0, 0, 68, 42);
|
||||
for i in 0..2 {
|
||||
let c = content(&s, i);
|
||||
assert!(c.x0 < canvas.x0 && c.y0 < canvas.y0 && c.x1 > canvas.x1 && c.y1 > canvas.y1, "layer {i} keeps its overhang: {c:?}");
|
||||
}
|
||||
// The corners the turn uncovered are transparent, not the background colour.
|
||||
let surf = bottom(&s).surface().unwrap();
|
||||
let c = content(&s, 0);
|
||||
assert_eq!(surf.rgba(c.x0, c.y0)[3], 0.0, "an uncovered corner of Layer 0");
|
||||
assert_eq!(surf.rgba(34, 21), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_action_steps_turned_crop_unlocks_the_background() {
|
||||
// The action step `Crop` with `Dlt` false, rect (100,100)–(700,400) and angle 10°, scaled down,
|
||||
// through a binary .atn: its descriptor carries `Dlt` false, the importer decodes it and
|
||||
// `actions.play` runs the step.
|
||||
let params = json!({"x": 10, "y": 10, "width": 60, "height": 30, "angle": 10, "deleteCroppedPixels": false});
|
||||
let action = Action::new("Crop", vec![("image.crop".into(), params)]);
|
||||
let (bytes, skipped) = atn_cmds::export("Crop", &[&action], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let file = atn::parse(&bytes).unwrap();
|
||||
let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap();
|
||||
assert_eq!(descriptor.get("Dlt "), Some(&D::Boolean(false)));
|
||||
let imported = atn_cmds::import(&bytes).unwrap();
|
||||
assert!(imported.unsupported.is_empty() && imported.warnings.is_empty());
|
||||
let step = &imported.actions[0].steps[0];
|
||||
assert_eq!((step.0.as_str(), &step.1["deleteCroppedPixels"]), ("image.crop", &json!(false)));
|
||||
|
||||
let mut s = doc();
|
||||
s.actions.list = imported.actions;
|
||||
let before = past(&s);
|
||||
s.execute("actions.play", json!({"action": "Crop"})).unwrap();
|
||||
assert_eq!(past(&s), before + 1, "one undo step");
|
||||
assert_eq!(size(&s), (60, 30));
|
||||
assert_layer_0(&s, "action step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_with_delete_off_keeps_the_background() {
|
||||
// Not measured in the reference: the Background stays, and the fill goes on it, as before.
|
||||
let mut s = doc();
|
||||
let mut p = json!({"x": 0, "y": 0, "width": 90, "height": 50, "contentAware": true});
|
||||
p["deleteCroppedPixels"] = json!(false);
|
||||
s.execute("image.crop", p).unwrap();
|
||||
assert_eq!(size(&s), (90, 50));
|
||||
assert_eq!(d(&s).background().map(|l| l.name.as_str()), Some("Background"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_size_crop_unlocks_the_background() {
|
||||
let mut s = doc();
|
||||
crop(&mut s, json!({"x": 0, "y": 0, "width": 40, "height": 50, "targetWidth": 20, "targetHeight": 25, "resolution": 150}), false);
|
||||
assert_eq!(size(&s), (20, 25));
|
||||
assert_layer_0(&s, "W x H x Resolution");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_on_keeps_the_background_and_fills_an_extension() {
|
||||
for (frame, want) in [
|
||||
(json!({"x": 0, "y": 0, "width": 70, "height": 50}), (70, 50)),
|
||||
(json!({"x": 0, "y": 0, "width": 90, "height": 50}), (90, 50)),
|
||||
(json!({"x": 6, "y": 4, "width": 68, "height": 42, "angle": 6.3}), (68, 42)),
|
||||
] {
|
||||
let mut s = doc();
|
||||
crop(&mut s, frame.clone(), true);
|
||||
assert_eq!(size(&s), want, "{frame}");
|
||||
let bg = d(&s).background().unwrap_or_else(|| panic!("{frame}: the Background stays"));
|
||||
assert_eq!(bg.name, "Background", "{frame}");
|
||||
}
|
||||
let mut s = doc();
|
||||
crop(&mut s, json!({"x": 0, "y": 0, "width": 90, "height": 50}), true);
|
||||
assert_eq!(bottom(&s).surface().unwrap().rgba(85, 25), MAGENTA, "the extension takes the background colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_the_background() {
|
||||
for frame in [json!({"x": 0, "y": 0, "width": 90, "height": 50}), json!({"x": 6, "y": 4, "width": 68, "height": 42, "angle": 6.3})] {
|
||||
let mut s = doc();
|
||||
let before = bottom(&s).clone();
|
||||
crop(&mut s, frame.clone(), false);
|
||||
assert_layer_0(&s, "cropped");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(size(&s), (80, 50), "{frame}");
|
||||
let bg = d(&s).background().unwrap_or_else(|| panic!("{frame}: undo restores the Background"));
|
||||
assert_eq!(bg.id, before.id, "{frame}");
|
||||
assert_eq!(bg.name, "Background", "{frame}");
|
||||
assert!(bg.locks.transparency && bg.locks.position, "{frame}");
|
||||
let (a, b) = (bg.surface().unwrap(), before.surface().unwrap());
|
||||
let r = Rect::new(0, 0, 80, 50);
|
||||
assert_eq!(a.read_region(r), b.read_region(r), "{frame}: the pixels");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_layer_0(&s, "redo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_all_keeps_the_background() {
|
||||
let mut s = revealable();
|
||||
let before = past(&s);
|
||||
s.execute("image.revealAll", json!({})).unwrap();
|
||||
assert_eq!(past(&s), before + 1);
|
||||
assert_eq!(size(&s), (90, 50));
|
||||
let bg = d(&s).background().expect("Reveal All keeps the Background");
|
||||
assert_eq!(bg.name, "Background");
|
||||
assert_eq!(bg.surface().unwrap().rgba(85, 25), MAGENTA, "the extension takes the background colour, as before");
|
||||
}
|
||||
|
||||
/// `doc()` with Quads reaching 10 px past the right edge, so Reveal All has something to reveal.
|
||||
fn revealable() -> Session {
|
||||
let mut s = doc();
|
||||
s.edit("paint past the canvas", |doc, _| {
|
||||
let quads = doc.layers.last_mut().unwrap();
|
||||
quads.surface_mut().unwrap().fill_rect(Rect::new(80, 0, 90, 50), &RED);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_alls_crop_passes_the_session_gate() {
|
||||
// A gate that refuses `image.crop` refuses Reveal All's crop too, before anything changes.
|
||||
let mut s = revealable();
|
||||
s.authorize = Some(|id, _| if id == "image.crop" { Err(EngineError::Other("image.crop is not allowed".into())) } else { Ok(()) });
|
||||
let before = past(&s);
|
||||
let err = s.execute("image.revealAll", json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("image.crop is not allowed"), "{err}");
|
||||
assert_eq!(size(&s), (80, 50));
|
||||
assert_eq!(past(&s), before);
|
||||
assert!(d(&s).background().is_some());
|
||||
|
||||
// A gate that allows it sees the crop Reveal All runs.
|
||||
let mut s = revealable();
|
||||
// (An `Err`, not an assert: the gate runs inside the command's panic guard.)
|
||||
s.authorize = Some(|id, p| {
|
||||
let expected = json!({"x": 0, "y": 0, "width": 90, "height": 50, "deleteCroppedPixels": false});
|
||||
if id == "image.crop" && *p != expected { Err(EngineError::Other(format!("unexpected crop {p}"))) } else { Ok(()) }
|
||||
});
|
||||
s.execute("image.revealAll", json!({})).unwrap();
|
||||
assert_eq!(size(&s), (90, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_a_background_has_nothing_to_convert() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 80, "height": 50, "background": "transparent"})).unwrap();
|
||||
let names: Vec<String> = d(&s).layers.iter().map(|l| l.name.clone()).collect();
|
||||
crop(&mut s, json!({"x": 0, "y": 0, "width": 90, "height": 50}), false);
|
||||
assert_eq!(size(&s), (90, 50));
|
||||
assert!(d(&s).background().is_none());
|
||||
assert_eq!(d(&s).layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>(), names, "no layer renamed");
|
||||
}
|
||||
@@ -3,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;
|
||||
|
||||
@@ -1010,6 +1010,7 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &
|
||||
maximize_compatibility: opts.psd_maximize_compatibility,
|
||||
document_path: opts.document_path.clone(),
|
||||
read_linked_files: opts.read_linked_files,
|
||||
stage_on_scratch_from: Some(psd_export::STAGE_DOCUMENTS_FROM),
|
||||
..Default::default()
|
||||
};
|
||||
// The preview is rendered first, while only the document is in memory: its full-size
|
||||
|
||||
+25
-12
@@ -15,11 +15,14 @@ use crate::blocks;
|
||||
use crate::pixels::{deinterleave, encode_be, psd_depth};
|
||||
use crate::psd_import::REGENERATED;
|
||||
|
||||
mod banded;
|
||||
mod document;
|
||||
mod estimate;
|
||||
mod merged;
|
||||
mod resources;
|
||||
mod smart;
|
||||
mod stage;
|
||||
pub(crate) use stage::STAGE_DOCUMENTS_FROM;
|
||||
|
||||
pub(crate) use document::document_to_psd_interruptible;
|
||||
#[cfg(test)]
|
||||
@@ -41,10 +44,6 @@ const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
|
||||
/// Room kept under [`PSD_MAX_FILE_SIZE`] for what the save adds after the conversion (the image
|
||||
/// preview resource, a few KB).
|
||||
const PSD_SAVE_HEADROOM: u64 = 1024 * 1024;
|
||||
/// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and
|
||||
/// the channel planes made from them (or the band parts and the joined buffer) are alive together,
|
||||
/// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`].
|
||||
const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 };
|
||||
|
||||
/// Options for [`document_to_psd_with`].
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -74,6 +73,10 @@ pub struct PsdExportOptions {
|
||||
/// off, since a stored path is document data rather than a granted file: such a layer is
|
||||
/// then written as pixels, with a warning.
|
||||
pub read_linked_files: bool,
|
||||
/// Park each encoded channel on the scratch disk, not in memory, when the document's raw
|
||||
/// pixel bytes (layers and composite) reach this and spilling is on (`None`: never). The
|
||||
/// save then holds the file model's structure, not a second copy of its pixels (#5333).
|
||||
pub stage_on_scratch_from: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for PsdExportOptions {
|
||||
@@ -86,11 +89,15 @@ impl Default for PsdExportOptions {
|
||||
maximize_compatibility: true,
|
||||
document_path: None,
|
||||
read_linked_files: true,
|
||||
stage_on_scratch_from: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Ex {
|
||||
struct Ex<'c> {
|
||||
/// The save's interrupt: a layer saved in bands checks it between bands and channels, so a
|
||||
/// cancelled save stops within a band (#5333).
|
||||
ctl: Interrupt<'c>,
|
||||
fmt: PixelFormat,
|
||||
/// Document resolution (for generated type-layer data).
|
||||
dpi: f32,
|
||||
@@ -126,6 +133,8 @@ struct Ex {
|
||||
patterns: Vec<photocraft_doc::Pattern>,
|
||||
/// The layer written as Photoshop's Background (see [`psd_background`]).
|
||||
background: Option<photocraft_doc::LayerId>,
|
||||
/// Where encoded channels go instead of memory ([`PsdExportOptions::stage_on_scratch_from`]).
|
||||
stage: Option<std::sync::Arc<stage::Stage>>,
|
||||
}
|
||||
|
||||
/// The layer Photoshop should open as its Background: the bottom top-level layer when it is a
|
||||
@@ -175,7 +184,7 @@ fn q255(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
impl Ex {
|
||||
impl Ex<'_> {
|
||||
fn compression(&self) -> Compression {
|
||||
if self.fmt.sample == SampleType::F32 { Compression::ZipPrediction } else { Compression::Rle }
|
||||
}
|
||||
@@ -186,9 +195,13 @@ impl Ex {
|
||||
// A ZIP stream for a zero-sized channel makes readers try to decompress an
|
||||
// invalid image extent (notably psd-tools for 32-bit adjustment layers).
|
||||
let compression = if w == 0 || h == 0 { Compression::Raw } else { self.compression() };
|
||||
ChannelData::encode(id, compression, plane, w, h, depth, self.version)
|
||||
let c = ChannelData::encode(id, compression, plane, w, h, depth, self.version)
|
||||
.or_else(|_| ChannelData::encode(id, Compression::Raw, plane, w, h, depth, self.version))
|
||||
.unwrap_or(ChannelData::new(id, Some(Compression::Raw), plane.to_vec()))
|
||||
.unwrap_or(ChannelData::new(id, Some(Compression::Raw), plane.to_vec()));
|
||||
match &self.stage {
|
||||
Some(stage) => stage.channel(c),
|
||||
None => c,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_channels(&self) -> Vec<ChannelData> {
|
||||
@@ -208,11 +221,11 @@ impl Ex {
|
||||
return (PsdRect::default(), self.empty_channels(), warnings);
|
||||
}
|
||||
// A layer record is one dense rectangle, so a few pixels far apart make a box of mostly
|
||||
// nothing, gathered and then copied again. Past the cap the layer is saved empty, said so.
|
||||
// nothing, gathered and then copied again. Past `banded_from` the layer is gathered and
|
||||
// encoded a band of rows at a time instead (#5333).
|
||||
let dense = u64::from(r.width()).saturating_mul(u64::from(r.height())).saturating_mul(s.format().bytes_per_pixel() as u64);
|
||||
if dense > MAX_LAYER_GATHER_BYTES {
|
||||
warnings.push(format!("layer \"{name}\" spans {}×{} px, too large to write: saved without its pixels", r.width(), r.height()));
|
||||
return (PsdRect::default(), self.empty_channels(), warnings);
|
||||
if dense > banded::banded_from() {
|
||||
return (to_psd_rect(r), self.banded_channels(&s, r), warnings);
|
||||
}
|
||||
// The interleaved gather is the sequential pass for full-canvas layers, so it runs
|
||||
// in row bands on all cores and the band results concatenate in order.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Layers too large to gather at once, saved band by band (#5333).
|
||||
//!
|
||||
//! A layer record is one dense rectangle, gathered interleaved and split into channel planes,
|
||||
//! so a layer's save held about twice its dense size; past [`BANDED_FROM`] that was refused and the
|
||||
//! layer saved without its pixels. Now such a layer is gathered, split and encoded a band of
|
||||
//! rows at a time. RLE rows pack independently (the row-count table is kept and written ahead of
|
||||
//! the packed rows) and raw rows join, so those channels are the bytes the whole-layer encoder
|
||||
//! writes. ZIP with prediction (32-bit) predicts per row and feeds one zlib stream: the same
|
||||
//! pixels, but not always the same bytes, as zlib packs input it is given in parts differently.
|
||||
//! With a stage each band's parts go to the scratch disk as they are made (see [`Parts`]).
|
||||
//!
|
||||
//! A banded layer is minutes of work on a large save, so it checks the save's interrupt before
|
||||
//! each band and each channel's part of it: once cancelled it stops there (and doesn't retry
|
||||
//! raw), leaving an incomplete model that the caller discards before the save's commit point.
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use photocraft_psd::compression::{PlaneLayout, encode_planes, predict};
|
||||
use photocraft_psd::{ChannelData, Compression, PsdError};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use super::Ex;
|
||||
use super::stage::Parts;
|
||||
use crate::pixels::{deinterleave, psd_depth};
|
||||
|
||||
/// Dense bytes of one layer (as gathered) from which it is saved band by band. Below it a layer
|
||||
/// is gathered whole, which is faster: the peak is about twice this.
|
||||
pub(super) const BANDED_FROM: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 };
|
||||
/// Interleaved bytes gathered per band. A band's planes and encoded parts are alive with it, so a
|
||||
/// banded layer's save holds about three times this.
|
||||
const BAND_BYTES: usize = 64 << 20;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// `(BANDED_FROM, BAND_BYTES)` for this thread's exports, so tests band small layers.
|
||||
pub(crate) static BANDS_FOR_TESTS: std::cell::Cell<Option<(u64, usize)>> = const { std::cell::Cell::new(None) };
|
||||
/// Bands this thread has gathered and encoded.
|
||||
pub(crate) static BANDS_DONE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
pub(super) fn banded_from() -> u64 {
|
||||
#[cfg(test)]
|
||||
if let Some((from, _)) = BANDS_FOR_TESTS.get() {
|
||||
return from;
|
||||
}
|
||||
BANDED_FROM
|
||||
}
|
||||
|
||||
fn band_bytes() -> usize {
|
||||
#[cfg(test)]
|
||||
if let Some((_, bytes)) = BANDS_FOR_TESTS.get() {
|
||||
return bytes;
|
||||
}
|
||||
BAND_BYTES
|
||||
}
|
||||
|
||||
/// One channel's encoding state across bands.
|
||||
struct Channel {
|
||||
id: i16,
|
||||
table: Vec<u8>,
|
||||
zip: Option<flate2::write::ZlibEncoder<Vec<u8>>>,
|
||||
parts: Parts,
|
||||
}
|
||||
|
||||
impl Ex<'_> {
|
||||
/// The channels of `s` over `r` (non-empty), alpha first, encoded band by band.
|
||||
pub(super) fn banded_channels(&self, s: &Surface, r: photocraft_geom::Rect) -> Vec<ChannelData> {
|
||||
// RLE fails only on a row too long for its count (not within PSD/PSB limits); then the
|
||||
// layer is written raw, as a channel encoded whole falls back to.
|
||||
match self.banded_pass(s, r, self.compression()) {
|
||||
Ok(channels) => channels,
|
||||
// Cancelled: the caller sees the interrupt and discards the whole model, so don't
|
||||
// encode the layer again.
|
||||
Err(_) if self.ctl.cancelled() => self.empty_channels(),
|
||||
Err(_) => self.banded_pass(s, r, Compression::Raw).unwrap_or_else(|_| self.empty_channels()),
|
||||
}
|
||||
}
|
||||
|
||||
fn banded_pass(&self, s: &Surface, r: photocraft_geom::Rect, compression: Compression) -> photocraft_psd::Result<Vec<ChannelData>> {
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
let bpp = s.format().bytes_per_pixel();
|
||||
let band_rows = (band_bytes() / (w * bpp).max(1)).max(1);
|
||||
let depth = psd_depth(self.fmt.sample);
|
||||
let count = if self.version.is_psb() { 4 } else { 2 };
|
||||
let head = if compression == Compression::Rle { h.checked_mul(count).ok_or(PsdError::LimitExceeded("row count overflow"))? } else { 0 };
|
||||
let mut channels: Vec<Channel> = (0..=self.cc)
|
||||
.map(|k| Channel {
|
||||
id: k as i16 - 1,
|
||||
table: Vec::with_capacity(head),
|
||||
zip: (compression == Compression::ZipPrediction).then(|| flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default())),
|
||||
parts: Parts::new(self.stage.clone(), head),
|
||||
})
|
||||
.collect();
|
||||
let mut invert = vec![self.cmyk; self.cc];
|
||||
invert.push(false);
|
||||
let ctl = self.ctl;
|
||||
let check = || if ctl.cancelled() { Err(PsdError::Invalid("cancelled".into())) } else { Ok(()) };
|
||||
for y in (0..h).step_by(band_rows) {
|
||||
check()?;
|
||||
let rows = band_rows.min(h - y);
|
||||
let top = r.y0 + y as i32;
|
||||
let pieces = crate::pixels::par_map(crate::pixels::bands(rows), |b| {
|
||||
s.to_interleaved(photocraft_geom::Rect::new(r.x0, top + b.start as i32, r.x1, top + b.end as i32))
|
||||
});
|
||||
let mut bytes = Vec::with_capacity(w * rows * bpp);
|
||||
for piece in pieces {
|
||||
bytes.extend_from_slice(&piece);
|
||||
}
|
||||
if self.fmt.mode == photocraft_color::ColorMode::Lab && self.fmt.sample == photocraft_color::SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false);
|
||||
}
|
||||
let mut planes: Vec<Option<Vec<u8>>> = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert).into_iter().map(Some).collect();
|
||||
drop(bytes);
|
||||
let layout = PlaneLayout { planes: 1, width: w, height: rows, depth, version: self.version };
|
||||
// Alpha (planes[cc]) is channel 0, then the colour channels; each on its own thread.
|
||||
let work: Vec<(&mut Channel, Vec<u8>)> =
|
||||
channels.iter_mut().enumerate().map(|(k, c)| (c, planes[if k == 0 { self.cc } else { k - 1 }].take().unwrap_or_default())).collect();
|
||||
let done = crate::pixels::par_map(work, |(c, mut plane)| -> photocraft_psd::Result<()> {
|
||||
check()?;
|
||||
match compression {
|
||||
Compression::Rle => {
|
||||
let mut packed = encode_planes(Compression::Rle, &plane, &layout)?;
|
||||
let rest = packed.split_off(rows * count);
|
||||
c.table.extend_from_slice(&packed);
|
||||
c.parts.push(rest);
|
||||
}
|
||||
Compression::ZipPrediction => {
|
||||
predict(&mut plane, &layout)?;
|
||||
let zip = c.zip.as_mut().ok_or_else(|| PsdError::Invalid("banded ZIP channel without its stream".into()))?;
|
||||
zip.write_all(&plane).map_err(|e| PsdError::Invalid(format!("zlib: {e}")))?;
|
||||
let out = std::mem::take(zip.get_mut());
|
||||
c.parts.push(out);
|
||||
}
|
||||
_ => c.parts.push(plane),
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
done.into_iter().collect::<photocraft_psd::Result<()>>()?;
|
||||
#[cfg(test)]
|
||||
BANDS_DONE.set(BANDS_DONE.get() + 1);
|
||||
}
|
||||
channels
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
if let Some(zip) = c.zip.take() {
|
||||
c.parts.push(zip.finish().map_err(|e| PsdError::Invalid(format!("zlib: {e}")))?);
|
||||
}
|
||||
c.parts.finish(c.id, compression, &c.table)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ fn export_supported_document(
|
||||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
let version = psd_version(doc, opts.force_psb);
|
||||
let mut ex = Ex {
|
||||
ctl: *ctl,
|
||||
fmt,
|
||||
dpi: doc.resolution_dpi,
|
||||
document: opts.document_path.clone(),
|
||||
@@ -88,6 +89,10 @@ fn export_supported_document(
|
||||
smart: SmartOut::new(doc, depth),
|
||||
patterns: doc.patterns.clone(),
|
||||
background: psd_background(doc),
|
||||
stage: {
|
||||
let raw = doc.size.area().saturating_mul(fmt.bytes_per_pixel() as u64).saturating_mul(doc.walk().len() as u64 + 1);
|
||||
super::stage::Stage::for_document(raw, opts.stage_on_scratch_from)
|
||||
},
|
||||
};
|
||||
add_format_warnings(doc, opts, fmt, big, duotone_inks.is_some(), &mut ex.warnings);
|
||||
index_layer_ids_and_text(doc, &mut ex);
|
||||
@@ -307,7 +312,7 @@ struct MergedExport<'a> {
|
||||
|
||||
fn prepare_merged_planes<'a>(
|
||||
doc: &'a Document,
|
||||
ex: &mut Ex,
|
||||
ex: &mut Ex<'_>,
|
||||
opts: &PsdExportOptions,
|
||||
ctl: &Interrupt,
|
||||
fmt: PixelFormat,
|
||||
@@ -331,13 +336,17 @@ fn prepare_merged_planes<'a>(
|
||||
data: planes,
|
||||
stored: None,
|
||||
});
|
||||
let image_data = match &ex.stage {
|
||||
Some(stage) => stage.image(image_data),
|
||||
None => image_data,
|
||||
};
|
||||
ctl.progress(1.0);
|
||||
MergedExport { header, image_data, extra, has_alpha, quick_mask_id, real_merged }
|
||||
}
|
||||
|
||||
fn append_extra_channels<'a>(
|
||||
doc: &'a Document,
|
||||
ex: &mut Ex,
|
||||
ex: &mut Ex<'_>,
|
||||
planes: &mut Vec<u8>,
|
||||
has_alpha: bool,
|
||||
sample: SampleType,
|
||||
@@ -387,7 +396,7 @@ fn merged_image(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt, fmt: &
|
||||
(planes, has_alpha)
|
||||
}
|
||||
|
||||
fn index_layer_ids_and_text(doc: &Document, ex: &mut Ex) {
|
||||
fn index_layer_ids_and_text(doc: &Document, ex: &mut Ex<'_>) {
|
||||
// Assign layer ids up front (stable across round trips): keep unique
|
||||
// `psd_id`s, number the rest after them; dividers come after all.
|
||||
{
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Encoded channels parked on the scratch disk while a large PSD/PSB save is assembled (#5333).
|
||||
//!
|
||||
//! A save encodes the whole file model before it writes a byte (#8115: it can still be
|
||||
//! cancelled, and the scratch reads behind it are checked, before the file is replaced). In
|
||||
//! memory that model is a second, compressed copy of every layer. With
|
||||
//! [`PsdExportOptions::stage_on_scratch_from`](super::PsdExportOptions::stage_on_scratch_from)
|
||||
//! set and spilling on, each channel is appended to a [`StageFile`] in the scratch directory as
|
||||
//! soon as it is encoded, and the model keeps only a [`Stored`] range: the writer then copies it
|
||||
//! through in short reads (`photocraft_psd`'s `put_encoded`). A channel that cannot be staged
|
||||
//! (the disk is full, say) stays in memory: staging only ever saves memory.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_psd::source::{ReadAt, Source, Stored};
|
||||
use photocraft_psd::{ChannelData, ImageData, PsdError};
|
||||
use photocraft_raster::stage_file::StageFile;
|
||||
|
||||
/// Encoded data shorter than this stays in memory: a range costs more than it saves.
|
||||
const MIN_STAGED_BYTES: usize = 64 << 10;
|
||||
/// Raw pixel bytes (layers and composite, as sampled) from which a save made by
|
||||
/// [`crate::export_with`] stages its channels: about a 24 MP RGBA/16 document with five layers.
|
||||
pub(crate) const STAGE_DOCUMENTS_FROM: u64 = 1 << 30;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Where this thread's exports stage, instead of the process-wide scratch directory.
|
||||
pub(crate) static DIR_FOR_TESTS: std::cell::RefCell<Option<std::path::PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Backing(StageFile);
|
||||
|
||||
impl ReadAt for Backing {
|
||||
fn len(&self) -> u64 {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> {
|
||||
self.0.read_exact_at(buf, offset).map_err(|e| PsdError::Read(format!("staged export data, {} bytes at offset {offset}: {e}", buf.len())))
|
||||
}
|
||||
}
|
||||
|
||||
/// The stage of one export: a file on the scratch disk shared by every channel.
|
||||
pub(crate) struct Stage {
|
||||
file: Arc<Backing>,
|
||||
source: Source,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
/// A stage for a document of `raw_bytes` pixel bytes, when `from` asks for one at that size
|
||||
/// and a stage file can be made in the scratch directory.
|
||||
pub(crate) fn for_document(raw_bytes: u64, from: Option<u64>) -> Option<Arc<Self>> {
|
||||
if raw_bytes < from? {
|
||||
return None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(dir) = DIR_FOR_TESTS.with(|d| d.borrow().clone()) {
|
||||
return StageFile::create(&dir).ok().map(Self::new);
|
||||
}
|
||||
StageFile::in_scratch().map(Self::new)
|
||||
}
|
||||
|
||||
fn new(file: StageFile) -> Arc<Self> {
|
||||
let file = Arc::new(Backing(file));
|
||||
let source = Source::from_arc(file.clone());
|
||||
Arc::new(Self { file, source })
|
||||
}
|
||||
|
||||
/// Moves `data` to the stage; `None` (the data kept by the caller) when it is short or the
|
||||
/// write fails.
|
||||
fn park(&self, data: &[u8]) -> Option<Stored> {
|
||||
if data.len() < MIN_STAGED_BYTES {
|
||||
return None;
|
||||
}
|
||||
let offset = self.file.0.append(data).ok()?;
|
||||
Some(Stored { source: self.source.clone(), offset, len: data.len() as u64 })
|
||||
}
|
||||
|
||||
pub(crate) fn channel(&self, mut c: ChannelData) -> ChannelData {
|
||||
if c.stored.is_none()
|
||||
&& let Some(stored) = self.park(&c.data)
|
||||
{
|
||||
c.data = Vec::new();
|
||||
c.stored = Some(stored);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
pub(crate) fn image(&self, mut image: ImageData) -> ImageData {
|
||||
if image.stored.is_none()
|
||||
&& let Some(stored) = self.park(&image.data)
|
||||
{
|
||||
image.data = Vec::new();
|
||||
image.stored = Some(stored);
|
||||
}
|
||||
image
|
||||
}
|
||||
|
||||
/// Appends one part of a channel written band by band; `None` when the write fails.
|
||||
fn append(&self, data: &[u8]) -> Option<u64> {
|
||||
self.file.0.append(data).ok()
|
||||
}
|
||||
|
||||
/// Bytes on the scratch disk.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn len(&self) -> u64 {
|
||||
self.file.0.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// One channel of a layer encoded band by band (#5333): its head (an RLE row-count table, filled
|
||||
/// in when the last band is encoded) and the bands' encoded parts in order. With a stage every
|
||||
/// part goes to the scratch disk as it is made, so a layer of any size is saved holding about one
|
||||
/// band; without one the parts are joined in memory, as a channel encoded whole would be.
|
||||
pub(crate) struct Parts {
|
||||
stage: Option<Arc<Stage>>,
|
||||
head_len: usize,
|
||||
/// Without a stage: the head's room and the parts so far.
|
||||
joined: Vec<u8>,
|
||||
/// With a stage: the parts, staged or (when a write failed) kept.
|
||||
parts: Vec<Part>,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
enum Part {
|
||||
Kept(Vec<u8>),
|
||||
Staged { offset: u64, len: u64 },
|
||||
}
|
||||
|
||||
impl Part {
|
||||
fn len(&self) -> u64 {
|
||||
match self {
|
||||
Part::Kept(v) => v.len() as u64,
|
||||
Part::Staged { len, .. } => *len,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Parts {
|
||||
pub(crate) fn new(stage: Option<Arc<Stage>>, head_len: usize) -> Self {
|
||||
let joined = if stage.is_none() { vec![0; head_len] } else { Vec::new() };
|
||||
Parts { stage, head_len, joined, parts: Vec::new(), len: head_len as u64 }
|
||||
}
|
||||
|
||||
/// Adds the next encoded part.
|
||||
pub(crate) fn push(&mut self, data: Vec<u8>) {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.len += data.len() as u64;
|
||||
match &self.stage {
|
||||
None => self.joined.extend_from_slice(&data),
|
||||
Some(stage) => self.parts.push(match stage.append(&data) {
|
||||
Some(offset) => Part::Staged { offset, len: data.len() as u64 },
|
||||
None => Part::Kept(data),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel: `head` (of the length given to [`Parts::new`]) and then the parts.
|
||||
pub(crate) fn finish(mut self, id: i16, compression: photocraft_psd::Compression, head: &[u8]) -> photocraft_psd::Result<ChannelData> {
|
||||
if head.len() != self.head_len {
|
||||
return Err(PsdError::Invalid("banded channel head has the wrong length".into()));
|
||||
}
|
||||
let Some(stage) = self.stage else {
|
||||
self.joined[..head.len()].copy_from_slice(head);
|
||||
self.joined.shrink_to_fit();
|
||||
return Ok(ChannelData::new(id, Some(compression), self.joined));
|
||||
};
|
||||
let mut parts = Vec::with_capacity(self.parts.len() + 1);
|
||||
if !head.is_empty() {
|
||||
parts.push(Part::Kept(head.to_vec()));
|
||||
}
|
||||
parts.append(&mut self.parts);
|
||||
let mut starts = Vec::with_capacity(parts.len());
|
||||
let mut at = 0;
|
||||
for p in &parts {
|
||||
starts.push(at);
|
||||
at += p.len();
|
||||
}
|
||||
let joined = Joined { file: stage.file.clone(), parts, starts, len: self.len };
|
||||
let stored = Stored { source: Source::from_arc(Arc::new(joined)), offset: 0, len: self.len };
|
||||
Ok(ChannelData { id, compression: Some(compression), data: Vec::new(), stored: Some(stored) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A banded channel's bytes, in memory and in its staged parts. A read is split at the parts, so
|
||||
/// the writer's consecutive pass reads each staged append from its start to its end, and the stage
|
||||
/// file checks it.
|
||||
struct Joined {
|
||||
file: Arc<Backing>,
|
||||
parts: Vec<Part>,
|
||||
starts: Vec<u64>,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
impl ReadAt for Joined {
|
||||
fn len(&self) -> u64 {
|
||||
self.len
|
||||
}
|
||||
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> photocraft_psd::Result<()> {
|
||||
if offset.checked_add(buf.len() as u64).is_none_or(|end| end > self.len) {
|
||||
return Err(PsdError::Read(format!("staged export data, {} bytes at offset {offset} past its {} bytes", buf.len(), self.len)));
|
||||
}
|
||||
let mut pos = offset;
|
||||
let mut buf = buf;
|
||||
let mut i = self.starts.partition_point(|&s| s <= pos).saturating_sub(1);
|
||||
while !buf.is_empty() {
|
||||
let (part, start) = (&self.parts[i], self.starts[i]);
|
||||
let within = pos - start;
|
||||
let n = usize::try_from((part.len() - within).min(buf.len() as u64)).unwrap_or(buf.len());
|
||||
let (now, rest) = std::mem::take(&mut buf).split_at_mut(n);
|
||||
match part {
|
||||
Part::Kept(v) => now.copy_from_slice(&v[within as usize..within as usize + n]),
|
||||
Part::Staged { offset, .. } => self.file.read_at(offset + within, now)?,
|
||||
}
|
||||
buf = rest;
|
||||
pos += n as u64;
|
||||
i += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -619,3 +619,226 @@ fn a_linked_fifo_is_not_read() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(warnings.iter().any(|w| w.contains("smart object written as pixels") && w.contains("not a regular file")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// #5333: with staging on, every large encoded channel and the merged image are parked on the
|
||||
/// scratch disk as they are encoded, and the file written from that model is the same, byte for
|
||||
/// byte, as one assembled in memory: as PSD, as PSB, and as a PSD promoted to PSB on the way out.
|
||||
#[test]
|
||||
fn staged_channels_write_the_same_file_as_in_memory_ones() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-psd-stage-{}", std::process::id()));
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = Some(dir.clone()));
|
||||
let doc = sized_document(true, SampleType::U8);
|
||||
let ctl = Interrupt::NONE;
|
||||
for (force_psb, limit) in [(false, PSD_MAX_FILE_SIZE), (true, PSD_MAX_FILE_SIZE), (false, SIZED_LIMIT)] {
|
||||
let plain = PsdExportOptions { force_psb, ..Default::default() };
|
||||
let staged = PsdExportOptions { stage_on_scratch_from: Some(0), ..plain.clone() };
|
||||
let (want, _) = document_to_psd_sized(&doc, &plain, 0, &ctl, limit);
|
||||
let (file, _) = document_to_psd_sized(&doc, &staged, 0, &ctl, limit);
|
||||
let layer = &file.layers()[0];
|
||||
// The opaque alpha packs to a few KB and stays in memory; the noise colour channels go.
|
||||
assert!(layer.channels.iter().filter(|c| c.id >= 0).all(|c| c.stored.is_some() && c.data.is_empty()), "every noise channel is staged");
|
||||
assert!(layer.channels.iter().filter(|c| c.id == -1).all(|c| c.stored.is_none()), "the short alpha stays in memory");
|
||||
assert!(file.image_data.stored.is_some() && file.image_data.data.is_empty(), "the merged image is staged");
|
||||
assert_eq!(file.write_as_psb, want.write_as_psb);
|
||||
let bytes = want.to_bytes().unwrap();
|
||||
assert!(file.to_bytes().unwrap() == bytes, "psb {force_psb}, limit {limit}: staged bytes differ");
|
||||
let mut w = std::io::Cursor::new(Vec::new());
|
||||
file.write_to(&mut w).unwrap();
|
||||
assert!(w.into_inner() == bytes, "psb {force_psb}, limit {limit}: streamed staged bytes differ");
|
||||
}
|
||||
// Under the threshold, or with no stage asked for, nothing is staged.
|
||||
let (file, _) = document_to_psd_sized(&doc, &PsdExportOptions { stage_on_scratch_from: Some(u64::MAX), ..Default::default() }, 0, &ctl, PSD_MAX_FILE_SIZE);
|
||||
assert!(file.layers()[0].channels.iter().all(|c| c.stored.is_none()) && file.image_data.stored.is_none());
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
|
||||
#[cfg(unix)]
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the stage file has no name on disk");
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
/// Short channels stay in memory, and a stage that cannot be written keeps the data in memory.
|
||||
#[test]
|
||||
fn staging_keeps_short_or_unwritable_data_in_memory() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-psd-stage-short-{}", std::process::id()));
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = Some(dir.clone()));
|
||||
let stage = stage::Stage::for_document(1, Some(0)).unwrap();
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
|
||||
let short = stage.channel(ChannelData::new(0, Some(Compression::Raw), vec![1; 100]));
|
||||
assert!(short.stored.is_none() && short.data.len() == 100);
|
||||
let long = stage.channel(ChannelData::new(0, Some(Compression::Raw), vec![2; 1 << 17]));
|
||||
assert!(long.stored.is_some() && long.data.is_empty());
|
||||
assert_eq!(long.encoded().unwrap().as_ref(), &vec![2u8; 1 << 17][..]);
|
||||
assert_eq!(stage.len(), 1 << 17);
|
||||
assert!(stage::Stage::for_document(10, Some(11)).is_none(), "under the threshold");
|
||||
assert!(stage::Stage::for_document(10, None).is_none(), "not asked for");
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
/// The first layer's channels, decoded.
|
||||
fn decoded_channels(file: &PsdFile) -> Vec<Vec<u8>> {
|
||||
let layer = &file.layers()[0];
|
||||
let (w, h) = (layer.rect.width() as usize, layer.rect.height() as usize);
|
||||
layer.channels.iter().map(|c| c.decode(w, h, file.header.depth, file.header.version).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// A noisy, translucent layer inset from the canvas: `w`×`h` px in `mode`/`depth`.
|
||||
fn banding_document(mode: ColorMode, depth: SampleType, w: u32, h: u32) -> Document {
|
||||
let mut doc = Document::new("banded", photocraft_geom::Size::new(w, h), mode, depth);
|
||||
let mut layer = Layer::raster("Layer 1", doc.pixel_format());
|
||||
let rect = photocraft_geom::Rect::new(5, 3, w as i32 - 7, h as i32 - 2);
|
||||
let surface = layer.surface_mut().unwrap();
|
||||
let ch = surface.channels();
|
||||
let mut state = 0x2545_f491_u32;
|
||||
let n = rect.width() as usize * rect.height() as usize * ch;
|
||||
let data: Vec<f32> = (0..n)
|
||||
.map(|i| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
// Runs as well as noise, so RLE packs both kinds of row.
|
||||
let v = if (i / (ch * 40)).is_multiple_of(3) { 0.5 } else { (state >> 20) as f32 / 4095.0 };
|
||||
if i % ch == ch - 1 { v.max(0.25) } else { v }
|
||||
})
|
||||
.collect();
|
||||
surface.write_region(rect, &data);
|
||||
doc.layers.push(layer);
|
||||
doc
|
||||
}
|
||||
|
||||
/// Exports `doc` with layers past `banded` bytes saved in bands of `rows` rows.
|
||||
fn export_banded(doc: &Document, options: &PsdExportOptions, banded: Option<usize>) -> (PsdFile, Vec<String>) {
|
||||
let row_bytes = doc.size.width as usize * doc.pixel_format().bytes_per_pixel();
|
||||
banded::BANDS_FOR_TESTS.set(banded.map(|rows| (0, rows * row_bytes + 3)));
|
||||
let out = document_to_psd_sized(doc, options, 0, &Interrupt::NONE, PSD_MAX_FILE_SIZE);
|
||||
banded::BANDS_FOR_TESTS.set(None);
|
||||
out
|
||||
}
|
||||
|
||||
/// #5333: a layer saved band by band (here every layer, in bands of a few rows) writes the bytes
|
||||
/// of one gathered whole (ZIP with prediction, 32-bit, the same pixels); RGB, CMYK and Lab; PSD, PSB and a PSD
|
||||
/// promoted to PSB; in memory and staged on the scratch disk (then also streamed).
|
||||
#[test]
|
||||
fn banded_layers_write_the_bytes_of_whole_ones() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-psd-banded-{}", std::process::id()));
|
||||
let cases = [
|
||||
(ColorMode::Rgb, SampleType::U8),
|
||||
(ColorMode::Rgb, SampleType::U16),
|
||||
(ColorMode::Rgb, SampleType::F32),
|
||||
(ColorMode::Cmyk, SampleType::U8),
|
||||
(ColorMode::Lab, SampleType::U16),
|
||||
];
|
||||
for (mode, depth) in cases {
|
||||
let doc = banding_document(mode, depth, 203, 157);
|
||||
for force_psb in [false, true] {
|
||||
for staged in [false, true] {
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = staged.then(|| dir.clone()));
|
||||
let options = PsdExportOptions { force_psb, stage_on_scratch_from: staged.then_some(0), ..Default::default() };
|
||||
let (want, want_warnings) = export_banded(&doc, &options, None);
|
||||
for rows in [1, 7, 1000] {
|
||||
let what = format!("{mode:?} {depth:?}, psb {force_psb}, staged {staged}, {rows} rows a band");
|
||||
let (file, warnings) = export_banded(&doc, &options, Some(rows));
|
||||
assert_eq!(warnings, want_warnings, "{what}");
|
||||
let layer = &file.layers()[0];
|
||||
assert_eq!(layer.rect, want.layers()[0].rect, "{what}");
|
||||
if staged {
|
||||
assert!(layer.channels.iter().all(|c| c.stored.is_some() && c.data.is_empty()), "{what}: banded channels are staged");
|
||||
}
|
||||
if depth == SampleType::F32 {
|
||||
// One zlib stream fed band by band packs differently from one fed whole
|
||||
// (the encoder sees a different amount of input ahead), so the ZIP
|
||||
// channels are compared decoded, here and as the streamed file reads back.
|
||||
let mut w = std::io::Cursor::new(Vec::new());
|
||||
file.write_to(&mut w).unwrap();
|
||||
let back = PsdFile::from_bytes(&w.into_inner()).unwrap();
|
||||
assert_eq!(decoded_channels(&back), decoded_channels(&want), "{what}: banded ZIP channels differ");
|
||||
assert!(back.image_data.encoded().unwrap() == want.image_data.encoded().unwrap(), "{what}");
|
||||
continue;
|
||||
}
|
||||
let bytes = want.to_bytes().unwrap();
|
||||
assert!(file.to_bytes().unwrap() == bytes, "{what}: banded bytes differ");
|
||||
let mut w = std::io::Cursor::new(Vec::new());
|
||||
file.write_to(&mut w).unwrap();
|
||||
assert!(w.into_inner() == bytes, "{what}: streamed banded bytes differ");
|
||||
if !force_psb {
|
||||
let (mut promoted, mut whole) = (file.clone(), want.clone());
|
||||
promoted.promote_to_psb().unwrap();
|
||||
whole.promote_to_psb().unwrap();
|
||||
let mut w = std::io::Cursor::new(Vec::new());
|
||||
promoted.write_to(&mut w).unwrap();
|
||||
assert!(w.into_inner() == whole.to_bytes().unwrap(), "{what}: promoted banded bytes differ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #5333: a layer past the gather size is saved with its pixels (it used to be saved empty, with a
|
||||
/// warning), and they come back on import.
|
||||
#[test]
|
||||
fn banded_layers_round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-psd-banded-rt-{}", std::process::id()));
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let doc = banding_document(ColorMode::Rgb, depth, 181, 133);
|
||||
for staged in [false, true] {
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = staged.then(|| dir.clone()));
|
||||
let options = PsdExportOptions { stage_on_scratch_from: staged.then_some(0), ..Default::default() };
|
||||
let (file, warnings) = export_banded(&doc, &options, Some(5));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let mut w = std::io::Cursor::new(Vec::new());
|
||||
file.write_to(&mut w).unwrap();
|
||||
let back = PsdFile::from_bytes(&w.into_inner()).unwrap();
|
||||
let (read, _) = crate::psd_import::psd_to_document(&back);
|
||||
let full = photocraft_geom::Rect::new(0, 0, doc.size.width as i32, doc.size.height as i32);
|
||||
let want = doc.layers[0].surface().unwrap().read_region(full);
|
||||
let got = read.layers[0].surface().unwrap().read_region(full);
|
||||
assert!(got == want, "{depth:?}, staged {staged}: pixels differ after the round trip");
|
||||
}
|
||||
}
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #5333: a save cancelled while a layer is saved in bands stops at the next band (it neither
|
||||
/// finishes the layer nor encodes it again raw) and reports the cancel, so it never reaches its
|
||||
/// commit point and the target is never written: RLE and ZIP, PSD and PSB, in memory and staged.
|
||||
#[test]
|
||||
fn a_cancelled_save_stops_between_the_bands_of_a_layer() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-psd-banded-cancel-{}", std::process::id()));
|
||||
let this = std::thread::current().id();
|
||||
// Cancelled once two bands are done (they are counted on this thread).
|
||||
let after_two = || std::thread::current().id() == this && banded::BANDS_DONE.get() >= 2;
|
||||
let ctl = Interrupt::cancel_only(&after_two);
|
||||
for depth in [SampleType::U8, SampleType::F32] {
|
||||
let doc = banding_document(ColorMode::Rgb, depth, 203, 157);
|
||||
let row_bytes = doc.size.width as usize * doc.pixel_format().bytes_per_pixel();
|
||||
let bands = |ctl: &Interrupt, run: &dyn Fn(&Interrupt)| {
|
||||
banded::BANDS_DONE.set(0);
|
||||
banded::BANDS_FOR_TESTS.set(Some((0, row_bytes + 3)));
|
||||
run(ctl);
|
||||
banded::BANDS_FOR_TESTS.set(None);
|
||||
banded::BANDS_DONE.get()
|
||||
};
|
||||
for force_psb in [false, true] {
|
||||
for staged in [false, true] {
|
||||
let what = format!("{depth:?}, psb {force_psb}, staged {staged}");
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = staged.then(|| dir.clone()));
|
||||
let options = PsdExportOptions { force_psb, stage_on_scratch_from: staged.then_some(0), ..Default::default() };
|
||||
let export = |ctl: &Interrupt| drop(document_to_psd_sized(&doc, &options, 0, ctl, PSD_MAX_FILE_SIZE));
|
||||
let rows = doc.layers[0].surface().unwrap().content_bounds().height() as usize;
|
||||
assert_eq!(bands(&Interrupt::NONE, &export), rows, "{what}: one band a row");
|
||||
assert_eq!(bands(&ctl, &export), 2, "{what}: stops at the band after the cancel");
|
||||
}
|
||||
}
|
||||
stage::DIR_FOR_TESTS.with(|d| *d.borrow_mut() = None);
|
||||
// The save sees the cancel, so it returns before its commit point and writes nothing.
|
||||
let name = format!("banded-cancel-{depth:?}.psd");
|
||||
let save = |ctl: &Interrupt| {
|
||||
let r = crate::encode_with(&doc, &name, &crate::ExportOptions::default(), ctl);
|
||||
assert!(matches!(r, Err(crate::IoError::Cancelled)), "{depth:?}: {:?}", r.err());
|
||||
};
|
||||
assert_eq!(bands(&ctl, &save), 2, "{depth:?}: the save stops at the band after the cancel");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -366,6 +366,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
|
||||
maximize_compatibility: true,
|
||||
document_path: opts.document_path.clone(),
|
||||
read_linked_files: opts.read_linked_files,
|
||||
// The layered TIFF reads the merged image's bytes directly: they stay in memory.
|
||||
stage_on_scratch_from: None,
|
||||
};
|
||||
// TIFF has no palette, bitmap or duotone layout: the PSD model of an Indexed Color, Bitmap
|
||||
// or Duotone document is made in its pixel format (RGB or Grayscale), which the PSD exporter
|
||||
|
||||
+21
-3
@@ -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]
|
||||
|
||||
@@ -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(&[], "");
|
||||
|
||||
@@ -228,11 +228,18 @@ pub fn decode_planes(compression: Compression, data: &[u8], layout: &PlaneLayout
|
||||
/// Bytes a PSD RLE stream (two-byte row counts) of `rows` rows grows by when written as PSB
|
||||
/// (four-byte row counts); an error when `data` is too short to hold the count table.
|
||||
pub(crate) fn rle_widening(data: &[u8], rows: usize) -> Result<u64> {
|
||||
let table = rows.checked_mul(2).ok_or(PsdError::LimitExceeded("row count overflow"))?;
|
||||
if data.len() < table {
|
||||
return Err(PsdError::UnexpectedEof { offset: data.len(), needed: table - data.len() });
|
||||
rle_widening_len(data.len() as u64, rows)
|
||||
}
|
||||
|
||||
/// [`rle_widening`] from the stream's length alone, so data still in its source (or staged on
|
||||
/// the scratch disk) is not read to measure it.
|
||||
pub(crate) fn rle_widening_len(len: u64, rows: usize) -> Result<u64> {
|
||||
let table = rows.checked_mul(2).ok_or(PsdError::LimitExceeded("row count overflow"))? as u64;
|
||||
if len < table {
|
||||
let offset = usize::try_from(len).unwrap_or(usize::MAX);
|
||||
return Err(PsdError::UnexpectedEof { offset, needed: usize::try_from(table - len).unwrap_or(usize::MAX) });
|
||||
}
|
||||
Ok(table as u64)
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
/// Encodes planar data (excluding the 2-byte compression field).
|
||||
|
||||
+77
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, rle_widening, validate_planes};
|
||||
use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, rle_widening_len, validate_planes};
|
||||
use crate::error::{PsdError, Result};
|
||||
use crate::header::{ColorMode, Header, Version};
|
||||
use crate::image_data::ImageData;
|
||||
@@ -382,10 +382,9 @@ impl PsdFile {
|
||||
o.put_u16(self.image_data.compression.as_u16());
|
||||
Ok(())
|
||||
})?;
|
||||
let merged = self.image_data.encoded()?;
|
||||
match self.merged_widened_rows(widen)? {
|
||||
0 => out.put(&merged),
|
||||
rows => out.put_widened_rle(&merged, rows),
|
||||
0 => out.put_encoded(&self.image_data.data, self.image_data.stored.as_ref()),
|
||||
rows => out.put_widened_encoded(&self.image_data.data, self.image_data.stored.as_ref(), rows),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,13 +414,13 @@ impl PsdFile {
|
||||
for c in &layer.channels {
|
||||
match layer.widened_rows(c, true)? {
|
||||
0 => {}
|
||||
rows => n = n.saturating_add(rle_widening(&c.encoded()?, rows)?),
|
||||
rows => n = n.saturating_add(rle_widening_len(c.encoded_len(), rows)?),
|
||||
}
|
||||
}
|
||||
}
|
||||
match self.merged_widened_rows(true)? {
|
||||
0 => Ok(n),
|
||||
rows => Ok(n.saturating_add(rle_widening(&self.image_data.encoded()?, rows)?)),
|
||||
rows => Ok(n.saturating_add(rle_widening_len(self.image_data.encoded_len(), rows)?)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +463,7 @@ impl PsdFile {
|
||||
}
|
||||
let merged = match self.merged_widened_rows(widen)? {
|
||||
0 => 0,
|
||||
rows => rle_widening(&self.image_data.encoded()?, rows)?,
|
||||
rows => rle_widening_len(self.image_data.encoded_len(), rows)?,
|
||||
};
|
||||
Ok(n.saturating_add(2).saturating_add(self.image_data.encoded_len()).saturating_add(merged))
|
||||
}
|
||||
@@ -719,6 +718,77 @@ mod write_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel data and a merged image still in their source (an imported file's, or what an
|
||||
/// exporter spilled to scratch) are copied through in short reads, never read whole.
|
||||
#[test]
|
||||
fn source_backed_data_is_copied_in_short_reads() {
|
||||
struct Reads(Vec<u8>, std::sync::Mutex<usize>);
|
||||
impl crate::source::ReadAt for Reads {
|
||||
fn len(&self) -> u64 {
|
||||
self.0.len() as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
|
||||
let mut most = self.1.lock().unwrap();
|
||||
*most = (*most).max(buf.len());
|
||||
self.0.as_slice().read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
let mut file = testgen::small(Version::Psd, Compression::Raw);
|
||||
file.image_data = ImageData::new(Compression::Raw, vec![5; 300]);
|
||||
let info = file.layer_info.as_mut().unwrap();
|
||||
info.layers[0].channels[0] = crate::layer::ChannelData::new(0, Some(Compression::Raw), vec![6; 301]);
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
let source = std::sync::Arc::new(Reads(bytes.clone(), std::sync::Mutex::new(0)));
|
||||
let lazy = PsdFile::from_source(crate::Source::from_arc(source.clone())).unwrap();
|
||||
*source.1.lock().unwrap() = 0;
|
||||
let mut w = std::io::Cursor::new(Vec::new());
|
||||
lazy.write_to(&mut w).unwrap();
|
||||
assert!(w.into_inner() == bytes);
|
||||
assert!(*source.1.lock().unwrap() <= 7, "read {} bytes at once", source.1.lock().unwrap());
|
||||
}
|
||||
|
||||
/// #5333: RLE data still in its source widens to PSB in short reads too, the count table and
|
||||
/// then the packed rows, one consecutive pass, never the whole merged image or channel at once.
|
||||
#[test]
|
||||
fn source_backed_rle_widens_in_short_reads() {
|
||||
struct Reads(Vec<u8>, std::sync::Mutex<(usize, Vec<(u64, usize)>)>);
|
||||
impl crate::source::ReadAt for Reads {
|
||||
fn len(&self) -> u64 {
|
||||
self.0.len() as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
|
||||
let mut seen = self.1.lock().unwrap();
|
||||
seen.0 = seen.0.max(buf.len());
|
||||
seen.1.push((offset, buf.len()));
|
||||
self.0.as_slice().read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
let file = testgen::small(Version::Psd, Compression::Rle);
|
||||
assert_eq!(file.image_data.compression, Compression::Rle);
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
let mut promoted = file.clone();
|
||||
promoted.promote_to_psb().unwrap();
|
||||
let want = promoted.to_bytes().unwrap();
|
||||
let source = std::sync::Arc::new(Reads(bytes, std::sync::Mutex::new((0, Vec::new()))));
|
||||
let mut lazy = PsdFile::from_source(crate::Source::from_arc(source.clone())).unwrap();
|
||||
assert!(lazy.image_data.stored.is_some(), "the merged image stays in the source");
|
||||
lazy.promote_to_psb().unwrap();
|
||||
*source.1.lock().unwrap() = (0, Vec::new());
|
||||
let mut w = std::io::Cursor::new(Vec::new());
|
||||
lazy.write_to(&mut w).unwrap();
|
||||
assert!(w.into_inner() == want);
|
||||
let seen = source.1.lock().unwrap();
|
||||
assert!(seen.0 <= 7, "read {} bytes at once", seen.0);
|
||||
let merged = lazy.image_data.stored.as_ref().unwrap();
|
||||
let reads: Vec<_> = seen.1.iter().filter(|(at, _)| (merged.offset..merged.offset + merged.len).contains(at)).collect();
|
||||
let mut next = merged.offset;
|
||||
for &&(at, n) in &reads {
|
||||
assert_eq!(at, next, "the merged image is read in one consecutive pass");
|
||||
next += n as u64;
|
||||
}
|
||||
assert_eq!(next, merged.offset + merged.len);
|
||||
}
|
||||
|
||||
/// #6607: pixel data goes to the stream as it is, never through a staging copy, so a save
|
||||
/// holds the model and a short buffer, not a second copy of the file.
|
||||
#[test]
|
||||
|
||||
+50
-6
@@ -213,6 +213,8 @@ impl WriteExt for Vec<u8> {
|
||||
|
||||
/// Staged bytes [`Sink`] collects before writing them out in one call.
|
||||
const STAGE: usize = 64 << 10;
|
||||
/// Bytes of stored channel data read per piece by [`Sink::put_encoded`].
|
||||
const COPY_CHUNK: u64 = if cfg!(test) { 7 } else { 8 << 20 };
|
||||
#[cfg(test)]
|
||||
pub(crate) const STAGE_FOR_TESTS: usize = STAGE;
|
||||
|
||||
@@ -273,16 +275,58 @@ impl<'w, W: Write + Seek + ?Sized> Sink<'w, W> {
|
||||
let table_len = usize::try_from(crate::compression::rle_widening(data, rows)?).map_err(|_| PsdError::LimitExceeded("row count overflow"))?;
|
||||
let (table, packed) = data.split_at_checked(table_len).ok_or_else(|| PsdError::invalid("RLE row-count table out of range"))?;
|
||||
for part in table.chunks(STAGE / 2) {
|
||||
self.small(|o| {
|
||||
for count in part.as_chunks::<2>().0 {
|
||||
o.extend_from_slice(&[0, 0, count[0], count[1]]);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
self.widen_counts(part)?;
|
||||
}
|
||||
self.put(packed)
|
||||
}
|
||||
|
||||
/// [`Self::put_widened_rle`] for data held in memory (`data`) or, when `stored`, still in its
|
||||
/// source: the count table and then the packed rows are read in [`COPY_CHUNK`]-sized parts
|
||||
/// (#5333), so a staged merged image or channel is never read back whole to widen it.
|
||||
pub(crate) fn put_widened_encoded(&mut self, data: &[u8], stored: Option<&crate::source::Stored>, rows: usize) -> std::io::Result<()> {
|
||||
let Some(s) = stored else { return self.put_widened_rle(data, rows) };
|
||||
let table_len = crate::compression::rle_widening_len(s.len, rows)?;
|
||||
// Whole counts per part; the parts follow each other, so a staged range is read in one pass.
|
||||
let table_part = (COPY_CHUNK / 2).max(1) * 2;
|
||||
let mut at = 0u64;
|
||||
while at < s.len {
|
||||
let n = if at < table_len { (table_len - at).min(table_part) } else { (s.len - at).min(COPY_CHUNK) };
|
||||
let part = s.read_part(at, n)?;
|
||||
if at < table_len {
|
||||
self.widen_counts(&part)?
|
||||
} else {
|
||||
self.put(&part)?
|
||||
}
|
||||
at += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two-byte RLE row counts as four-byte ones (`00 00` and the count), staged.
|
||||
fn widen_counts(&mut self, counts: &[u8]) -> std::io::Result<()> {
|
||||
self.small(|o| {
|
||||
for count in counts.as_chunks::<2>().0 {
|
||||
o.extend_from_slice(&[0, 0, count[0], count[1]]);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends encoded bytes held in memory (`data`) or, when `stored`, still in their source:
|
||||
/// those are copied through in [`COPY_CHUNK`] pieces, so a channel or merged image kept on
|
||||
/// disk (an imported file's, or one an exporter spilled to scratch) is never read whole.
|
||||
pub(crate) fn put_encoded(&mut self, data: &[u8], stored: Option<&crate::source::Stored>) -> std::io::Result<()> {
|
||||
let Some(s) = stored else { return self.put(data) };
|
||||
let mut at = 0u64;
|
||||
while at < s.len {
|
||||
let n = (s.len - at).min(COPY_CHUNK);
|
||||
let part = s.read_part(at, n)?;
|
||||
self.put(&part)?;
|
||||
at += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `n` zero bytes (padding, at most a few).
|
||||
pub(crate) fn zeros(&mut self, n: usize) -> std::io::Result<()> {
|
||||
self.small(|b| {
|
||||
|
||||
@@ -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)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -118,35 +118,9 @@ impl std::fmt::Debug for ScratchFile {
|
||||
|
||||
impl ScratchFile {
|
||||
pub fn create(dir: &Path) -> std::io::Result<Self> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
// A reused process id must not collide with an abandoned file from an older launch.
|
||||
let n = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
.saturating_add(COUNTER.fetch_add(1, Ordering::Relaxed) as u128);
|
||||
let path = dir.join(format!("photocraft-{}-{n}.scratch", std::process::id()));
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true).write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let (main, path, linked) = open_private(dir)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
|
||||
options.custom_flags(FILE_FLAG_DELETE_ON_CLOSE);
|
||||
}
|
||||
let main = options.open(&path)?;
|
||||
#[cfg(unix)]
|
||||
reap_abandoned(dir, &main);
|
||||
// Unix: the open handle keeps the inode; nothing is left behind at any exit. A
|
||||
// filesystem that refuses the unlink keeps the name until drop or the next reap.
|
||||
#[cfg(not(windows))]
|
||||
let linked = std::fs::remove_file(&path).is_err();
|
||||
let _ = linked;
|
||||
Ok(Self {
|
||||
main,
|
||||
#[cfg(windows)]
|
||||
@@ -231,6 +205,45 @@ impl ScratchFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a file only this process can reach in `dir`, gone once its last handle closes: Unix
|
||||
/// opens it private (0600) and unlinks it right away, Windows opens it delete-on-close. Files
|
||||
/// of dead processes are reaped. Returns the file, its name, and whether the name is still
|
||||
/// there (a filesystem refused the unlink: remove it when done).
|
||||
pub(crate) fn open_private(dir: &Path) -> std::io::Result<(File, PathBuf, bool)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
// A reused process id must not collide with an abandoned file from an older launch.
|
||||
let n = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
.saturating_add(COUNTER.fetch_add(1, Ordering::Relaxed) as u128);
|
||||
let path = dir.join(format!("photocraft-{}-{n}.scratch", std::process::id()));
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true).write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
|
||||
options.custom_flags(FILE_FLAG_DELETE_ON_CLOSE);
|
||||
}
|
||||
let file = options.open(&path)?;
|
||||
#[cfg(unix)]
|
||||
reap_abandoned(dir, &file);
|
||||
// Unix: the open handle keeps the inode; nothing is left behind at any exit. A
|
||||
// filesystem that refuses the unlink keeps the name until drop or the next reap.
|
||||
#[cfg(not(windows))]
|
||||
let linked = std::fs::remove_file(&path).is_err();
|
||||
#[cfg(windows)]
|
||||
let linked = false;
|
||||
Ok((file, path, linked))
|
||||
}
|
||||
|
||||
/// Accept only the names we generate, never other temporary files or directories.
|
||||
#[cfg(any(unix, test))]
|
||||
fn scratch_pid(name: &str) -> Option<u32> {
|
||||
@@ -297,17 +310,17 @@ fn thread_slot() -> usize {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_all_at(file: &File, data: &[u8], offset: u64) -> std::io::Result<()> {
|
||||
pub(crate) fn write_all_at(file: &File, data: &[u8], offset: u64) -> std::io::Result<()> {
|
||||
std::os::unix::fs::FileExt::write_all_at(file, data, offset)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
|
||||
pub(crate) fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
|
||||
std::os::unix::fs::FileExt::read_exact_at(file, buf, offset)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> std::io::Result<()> {
|
||||
pub(crate) fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> std::io::Result<()> {
|
||||
use std::os::windows::fs::FileExt;
|
||||
while !data.is_empty() {
|
||||
let n = file.seek_write(data, offset)?;
|
||||
@@ -321,7 +334,7 @@ fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> std::io::Resul
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> {
|
||||
pub(crate) fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> {
|
||||
use std::os::windows::fs::FileExt;
|
||||
while !buf.is_empty() {
|
||||
let n = file.seek_read(buf, offset)?;
|
||||
@@ -335,12 +348,12 @@ fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::R
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn write_all_at(_file: &File, _data: &[u8], _offset: u64) -> std::io::Result<()> {
|
||||
pub(crate) fn write_all_at(_file: &File, _data: &[u8], _offset: u64) -> std::io::Result<()> {
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn read_exact_at(_file: &File, _buf: &mut [u8], _offset: u64) -> std::io::Result<()> {
|
||||
pub(crate) fn read_exact_at(_file: &File, _buf: &mut [u8], _offset: u64) -> std::io::Result<()> {
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Encoded data an exporter parks on the scratch disk instead of in memory (#5333).
|
||||
//!
|
||||
//! A large PSD/PSB save encodes every channel before it writes a byte (so it can still be
|
||||
//! cancelled, and its scratch reads are checked, before the file is replaced). Kept in memory,
|
||||
//! those channels are a second copy of the document. A [`StageFile`] holds them on the scratch
|
||||
//! disk instead: an append-only file made like the tile scratch file (private, unlinked on Unix,
|
||||
//! delete-on-close on Windows), so nothing is left behind at any exit, and the space returns as
|
||||
//! soon as the save drops it.
|
||||
//!
|
||||
//! Like the tile scratch file, every append carries a CRC-32: a read pass that covers an append
|
||||
//! from its start to its end (whole, or in consecutive parts, as a save copies it) fails on its
|
||||
//! last part if the disk handed back different bytes, so the save errors before it replaces
|
||||
//! anything instead of writing them into the document.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use crate::scratch_file::{open_private, read_exact_at, write_all_at};
|
||||
|
||||
pub struct StageFile {
|
||||
file: File,
|
||||
path: PathBuf,
|
||||
/// The directory entry still exists (the unlink was refused): remove it on drop.
|
||||
linked: bool,
|
||||
/// Bytes reserved so far; appends take their offset from it.
|
||||
end: Mutex<u64>,
|
||||
/// Each append by its offset, with its checksum and the read pass under way.
|
||||
extents: Mutex<BTreeMap<u64, Extent>>,
|
||||
}
|
||||
|
||||
struct Extent {
|
||||
len: u64,
|
||||
crc32: u32,
|
||||
/// Where the current read pass continues (`None` once it skipped or jumped: not checked).
|
||||
next: Option<u64>,
|
||||
hasher: crc32fast::Hasher,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for StageFile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("StageFile").field("path", &self.path).field("len", &self.len()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl StageFile {
|
||||
/// A new, empty stage file in `dir`.
|
||||
pub fn create(dir: &Path) -> std::io::Result<Self> {
|
||||
let (file, path, linked) = open_private(dir)?;
|
||||
Ok(Self { file, path, linked, end: Mutex::new(0), extents: Mutex::default() })
|
||||
}
|
||||
|
||||
/// A stage file in the configured scratch directory, when spilling is on (`None` otherwise,
|
||||
/// or when the file cannot be made: the caller then keeps its data in memory).
|
||||
pub fn in_scratch() -> Option<Self> {
|
||||
if !crate::spill::enabled() {
|
||||
return None;
|
||||
}
|
||||
Self::create(&crate::spill::stats().dir?).ok()
|
||||
}
|
||||
|
||||
/// Appends `data` and returns its offset. Parallel appends each get their own range.
|
||||
pub fn append(&self, data: &[u8]) -> std::io::Result<u64> {
|
||||
let offset = {
|
||||
let mut end = self.end.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let offset = *end;
|
||||
*end = offset.checked_add(data.len() as u64).ok_or_else(|| std::io::Error::other("stage file too large"))?;
|
||||
offset
|
||||
};
|
||||
write_all_at(&self.file, data, offset)?;
|
||||
let extent = Extent { len: data.len() as u64, crc32: crc32fast::hash(data), next: Some(offset), hasher: crc32fast::Hasher::new() };
|
||||
self.extents.lock().unwrap_or_else(PoisonError::into_inner).insert(offset, extent);
|
||||
Ok(offset)
|
||||
}
|
||||
|
||||
/// Fills `buf` from `offset`, which must lie within what was appended. A read that ends a
|
||||
/// pass over one append from its start fails with `InvalidData` if the append's bytes changed.
|
||||
pub fn read_exact_at(&self, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
|
||||
let Some(end) = offset.checked_add(buf.len() as u64).filter(|&end| end <= self.len()) else {
|
||||
return Err(std::io::ErrorKind::UnexpectedEof.into());
|
||||
};
|
||||
read_exact_at(&self.file, buf, offset)?;
|
||||
let mut extents = self.extents.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let Some((&start, extent)) = extents.range_mut(..=offset).next_back() else { return Ok(()) };
|
||||
let extent_end = start + extent.len;
|
||||
if end > extent_end {
|
||||
return Ok(()); // spans appends (no save reads this way): not checked
|
||||
}
|
||||
if offset == start {
|
||||
extent.hasher = crc32fast::Hasher::new();
|
||||
extent.next = Some(start);
|
||||
}
|
||||
if extent.next != Some(offset) {
|
||||
extent.next = None;
|
||||
return Ok(());
|
||||
}
|
||||
extent.hasher.update(buf);
|
||||
extent.next = Some(end);
|
||||
if end == extent_end {
|
||||
let crc32 = std::mem::take(&mut extent.hasher).finalize();
|
||||
extent.next = None;
|
||||
if crc32 != extent.crc32 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "staged save data changed on the scratch disk (checksum mismatch)"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bytes appended (reserved) so far.
|
||||
pub fn len(&self) -> u64 {
|
||||
*self.end.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StageFile {
|
||||
fn drop(&mut self) {
|
||||
if self.linked {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn appends_read_back_and_leave_nothing_behind() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-stage-{}", std::process::id()));
|
||||
let stage = StageFile::create(&dir).unwrap();
|
||||
#[cfg(unix)]
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the stage file is unlinked while open");
|
||||
let parts: Vec<Vec<u8>> = (0..8u8).map(|i| vec![i; 1000 + usize::from(i)]).collect();
|
||||
let offsets: Vec<u64> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = parts.iter().map(|p| s.spawn(|| stage.append(p).unwrap())).collect();
|
||||
handles.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
assert_eq!(stage.len(), parts.iter().map(|p| p.len() as u64).sum::<u64>());
|
||||
for (p, &at) in parts.iter().zip(&offsets) {
|
||||
let mut buf = vec![0; p.len()];
|
||||
stage.read_exact_at(&mut buf, at).unwrap();
|
||||
assert_eq!(&buf, p);
|
||||
}
|
||||
let mut past = [0u8; 2];
|
||||
assert!(stage.read_exact_at(&mut past, stage.len() - 1).is_err(), "reads stop at what was appended");
|
||||
drop(stage);
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
/// Bytes that change on the disk fail the pass that copies them, whole or in parts.
|
||||
#[test]
|
||||
fn a_changed_append_fails_its_read_pass() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-stage-crc-{}", std::process::id()));
|
||||
let stage = StageFile::create(&dir).unwrap();
|
||||
let first = stage.append(&[1; 100]).unwrap();
|
||||
let second = stage.append(&[2; 100]).unwrap();
|
||||
let pass = |at: u64, part: usize| -> std::io::Result<()> {
|
||||
let mut buf = vec![0; part];
|
||||
(0..100 / part as u64).try_for_each(|i| stage.read_exact_at(&mut buf, at + i * part as u64))
|
||||
};
|
||||
pass(first, 10).unwrap();
|
||||
pass(second, 100).unwrap();
|
||||
write_all_at(&stage.file, &[9], second + 57).unwrap();
|
||||
pass(first, 25).unwrap();
|
||||
assert_eq!(pass(second, 10).unwrap_err().kind(), std::io::ErrorKind::InvalidData, "in parts");
|
||||
assert_eq!(pass(second, 100).unwrap_err().kind(), std::io::ErrorKind::InvalidData, "whole");
|
||||
let mut part = [0; 10];
|
||||
stage.read_exact_at(&mut part, second + 50).unwrap(); // a read that starts mid-append is not a pass
|
||||
drop(stage);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -248,9 +248,10 @@ impl PhotocraftApp {
|
||||
{
|
||||
return r;
|
||||
}
|
||||
let from = app.session.journal.len();
|
||||
let r = app.save_to(path.clone(), choices)?;
|
||||
if plain_save && own.as_deref() == Some(path.as_str()) {
|
||||
app.journal_as_plain_save(&path);
|
||||
app.journal_as_plain_save(&path, from);
|
||||
}
|
||||
Ok(r)
|
||||
})
|
||||
@@ -310,8 +311,8 @@ impl PhotocraftApp {
|
||||
/// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF,
|
||||
/// Targa, DDS or JPEG answer), so an action being recorded keeps it (#2032).
|
||||
fn save_as_step(&mut self, path: String, tiff_layers: Option<bool>, choices: FormatChoices) -> Result<Value, String> {
|
||||
let r = self.write_save(path.clone(), tiff_layers, choices)?;
|
||||
let mut step = serde_json::json!({"path": path});
|
||||
let mut step = self.save_as_record(&path);
|
||||
let r = self.write_save(path, tiff_layers, choices)?;
|
||||
if let Some(layers) = tiff_layers {
|
||||
step["tiffLayers"] = layers.into();
|
||||
}
|
||||
@@ -320,11 +321,67 @@ impl PhotocraftApp {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The `file.saveAs` step that records a Save As of the active document to `path`, taken
|
||||
/// before the save renames the document. With the document's own name kept (the name Save
|
||||
/// As suggests), only the folder and the extension are recorded, so the action saves each
|
||||
/// document it is played on under that document's name, as in Photoshop (#5305); a name
|
||||
/// typed in is recorded as it is.
|
||||
pub(crate) fn save_as_record(&self, path: &str) -> Value {
|
||||
let file = std::path::Path::new(path);
|
||||
let kept = self.own_file_stem().is_some_and(|own| file.file_stem().is_some_and(|s| s.to_string_lossy() == own));
|
||||
match (kept, file.extension()) {
|
||||
(true, Some(ext)) => {
|
||||
let folder = file.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
serde_json::json!({"folder": folder, "extension": ext.to_string_lossy()})
|
||||
}
|
||||
_ => serde_json::json!({"path": path}),
|
||||
}
|
||||
}
|
||||
|
||||
/// `step` (a save answered in a format prompt, recorded with its `path`) as
|
||||
/// [`Self::save_as_record`] records it when it is a Save As of the active document.
|
||||
pub(crate) fn record_answered_save_as(&self, record: &str, step: &mut Value) {
|
||||
if record != "file.saveAs" {
|
||||
return;
|
||||
}
|
||||
let Some(path) = step.get("path").and_then(Value::as_str).map(str::to_owned) else { return };
|
||||
if let (Some(s), Value::Object(r)) = (step.as_object_mut(), self.save_as_record(&path)) {
|
||||
s.remove("path");
|
||||
s.extend(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// The name Save As suggests for the active document, without its extension: its file's,
|
||||
/// or its own for a document never saved ("Untitled" for one with no name).
|
||||
fn own_file_stem(&self) -> Option<String> {
|
||||
let st = self.session.active()?;
|
||||
let source = st.path.as_deref().unwrap_or(&st.doc.name);
|
||||
let stem = std::path::Path::new(source).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty());
|
||||
Some(stem.unwrap_or_else(|| "Untitled".into()))
|
||||
}
|
||||
|
||||
/// The file a recorded `file.saveAs` step writes: its `path`, or for a step recorded with
|
||||
/// the document's own name, that folder and extension with the active document's name.
|
||||
fn recorded_save_path(&self, params: &Value) -> Result<String, String> {
|
||||
if let Some(path) = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()) {
|
||||
return Ok(path.to_string());
|
||||
}
|
||||
let (Some(folder), Some(ext)) =
|
||||
(params.get("folder").and_then(Value::as_str), params.get("extension").and_then(Value::as_str).filter(|e| !e.is_empty()))
|
||||
else {
|
||||
return Err("Save As needs a `path`, or a `folder` and an `extension`".into());
|
||||
};
|
||||
let stem = self.own_file_stem().ok_or("no document")?;
|
||||
let name = format!("{stem}.{ext}");
|
||||
Ok(if folder.is_empty() { name } else { std::path::Path::new(folder).join(name).to_string_lossy().into_owned() })
|
||||
}
|
||||
|
||||
/// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]),
|
||||
/// keeping its Targa, DDS, JPEG or TIFF answer so playback writes the file the same way. A save
|
||||
/// parked behind Targa, DDS, JPEG or TIFF Options hasn't been journaled yet: it is recorded as
|
||||
/// File › Save once answered.
|
||||
pub(crate) fn journal_as_plain_save(&mut self, path: &str) {
|
||||
/// File › Save once answered. Only a step journaled at index `from` or later (by this save)
|
||||
/// is re-labeled.
|
||||
pub(crate) fn journal_as_plain_save(&mut self, path: &str, from: usize) {
|
||||
let parked = [
|
||||
self.targa_options.as_mut().map(|p| (&p.path, &mut p.record)),
|
||||
self.dds_options.as_mut().map(|p| (&p.path, &mut p.record)),
|
||||
@@ -337,19 +394,34 @@ impl PhotocraftApp {
|
||||
}
|
||||
// Only the path goes: a script's format answer stays, so a replay writes the file it is
|
||||
// played on the same way. Any other answer (TIFF layers) keeps the Save As.
|
||||
if let Some(last) = self.session.journal.last_mut()
|
||||
&& last.0 == "file.saveAs"
|
||||
&& last.1.get("path").and_then(Value::as_str) == Some(path)
|
||||
// A Save As that kept the document's own name was recorded as its folder (#5305); the
|
||||
// document now has that file, so it resolves back to `path`.
|
||||
let journal = &self.session.journal;
|
||||
let recorded = journal.last().filter(|last| last.0 == "file.saveAs" && journal.len() > from).and_then(|last| self.recorded_save_path(&last.1).ok());
|
||||
if recorded.is_some_and(|r| std::path::Path::new(&r) == std::path::Path::new(path))
|
||||
&& let Some(last) = self.session.journal.last_mut()
|
||||
&& let Some(step) = last.1.as_object()
|
||||
&& step.keys().all(|k| {
|
||||
matches!(
|
||||
k.as_str(),
|
||||
"path" | "tgaBits" | "ddsCompression" | "ddsMipmaps" | "quality" | "jpegFormat" | "tiffCompression" | "tiffTransparency" | "tiffPyramid"
|
||||
"path"
|
||||
| "folder"
|
||||
| "extension"
|
||||
| "tgaBits"
|
||||
| "ddsCompression"
|
||||
| "ddsMipmaps"
|
||||
| "quality"
|
||||
| "jpegFormat"
|
||||
| "tiffCompression"
|
||||
| "tiffTransparency"
|
||||
| "tiffPyramid"
|
||||
)
|
||||
})
|
||||
{
|
||||
let mut step = step.clone();
|
||||
step.remove("path");
|
||||
for k in ["path", "folder", "extension"] {
|
||||
step.remove(k);
|
||||
}
|
||||
*last = ("file.save".into(), Value::Object(step));
|
||||
}
|
||||
}
|
||||
@@ -382,14 +454,15 @@ impl PhotocraftApp {
|
||||
// Export As has its own settings, its TGA bits included (#7207).
|
||||
return crate::export_dialog::replay(self, params);
|
||||
}
|
||||
let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?;
|
||||
let choices = FormatChoices::from_step(params, id)?;
|
||||
if id == "file.saveACopy" {
|
||||
let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save a Copy needs a `path`")?;
|
||||
// A copy: the document keeps its own file and unsaved state, as when recorded (#4373).
|
||||
let doc = self.session.active().ok_or("no document")?.doc.clone();
|
||||
return crate::view_cmds::write_copy(self, &doc, path, choices);
|
||||
}
|
||||
self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), choices)
|
||||
let path = self.recorded_save_path(params)?;
|
||||
self.save_as_step(path, params.get("tiffLayers").and_then(Value::as_bool), choices)
|
||||
}
|
||||
|
||||
/// What a save writes by default under the preferences: File Handling › Image Previews
|
||||
@@ -604,9 +677,15 @@ impl PhotocraftApp {
|
||||
/// (#2032, #4373), reports a failure in the status bar, and releases a close prompt waiting on
|
||||
/// the save.
|
||||
pub(crate) fn write_answered_save(&mut self, ctx: &egui::Context, doc: DocId, path: String, settings: &ExportSettings, copy: bool, step: (&str, Value)) {
|
||||
let written = self.with_document(doc, |app| app.write_document(path, settings, copy));
|
||||
let (record, mut step) = step;
|
||||
let written = self.with_document(doc, |app| {
|
||||
// Before the save renames the document: a Save As that keeps its own name records
|
||||
// only the folder (#5305).
|
||||
app.record_answered_save_as(record, &mut step);
|
||||
app.write_document(path, settings, copy)
|
||||
});
|
||||
if written.is_ok() {
|
||||
self.session.journal.push((step.0.into(), step.1));
|
||||
self.session.journal.push((record.into(), step));
|
||||
}
|
||||
match written {
|
||||
Err(e) => {
|
||||
|
||||
@@ -687,6 +687,9 @@ fn a_canvas_over_the_texture_budget_draws_its_viewport_pages_on_the_gpu() {
|
||||
gpu.set_canvas_budget(1);
|
||||
g.harness.run_steps(3);
|
||||
assert_eq!(render_path_of(&g), (Some("canvasTooLarge"), Some("CPU Rendering")));
|
||||
// Its pages and their work are freed meanwhile (#5332 review).
|
||||
assert!(gpu.pages(key).0.is_empty(), "pages freed");
|
||||
assert!(!g.harness.state().streams.contains_key(&doc.id), "stream dropped");
|
||||
gpu.set_canvas_budget(set - 1);
|
||||
settle_pages(&mut g);
|
||||
// Closing the document cancels its work and frees its pages.
|
||||
@@ -696,6 +699,63 @@ fn a_canvas_over_the_texture_budget_draws_its_viewport_pages_on_the_gpu() {
|
||||
assert!(gpu.pages(key).0.is_empty());
|
||||
}
|
||||
|
||||
/// #5332 review: the open documents share one texture budget rather than each having all of
|
||||
/// it. A document alone on screen keeps the whole budget; one in a background tab gives its
|
||||
/// textures up when the shown one needs the room; and documents shown side by side split it,
|
||||
/// a document over its share drawing from viewport pages.
|
||||
#[test]
|
||||
fn open_documents_share_one_texture_budget_with_the_shown_ones_first() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let Some(mut g) = gpu_app() else { return };
|
||||
let gpu = g.harness.state().gpu.clone().expect("gpu canvas");
|
||||
let new = |g: &mut Gpu| {
|
||||
g.harness.state_mut().run("file.new", json!({"width": 3000, "height": 2000, "background": "#336699"})).expect("new");
|
||||
g.harness.run_steps(3);
|
||||
assert!(on_gpu_canvas(g), "{:?}", render_path_of(g));
|
||||
g.harness.state().session.active().expect("document").doc.clone()
|
||||
};
|
||||
let a = new(&mut g);
|
||||
let (set, _) = gpu.canvas_fit(&a).expect("fit");
|
||||
// Room for one texture set and a half: a document alone on screen has all of it.
|
||||
let budget = set + set / 2;
|
||||
gpu.set_canvas_budget(budget);
|
||||
assert_eq!(gpu.canvas_fit(&a), Some((set, budget)), "alone, the whole budget");
|
||||
assert!(gpu.texture_info(a.id.0).is_some());
|
||||
// A second tab: the shown document's texture set takes the room of the background one's.
|
||||
let b = new(&mut g);
|
||||
assert_eq!(gpu.canvas_fit(&b), Some((set, budget)));
|
||||
assert!(gpu.texture_info(b.id.0).is_some());
|
||||
assert!(gpu.texture_info(a.id.0).is_none(), "the background document's textures are freed");
|
||||
// Back to the first tab: it gets its texture set back and the other gives its up.
|
||||
g.harness.state_mut().session.set_active(0);
|
||||
g.harness.run_steps(3);
|
||||
assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g));
|
||||
assert!(gpu.texture_info(a.id.0).is_some());
|
||||
assert!(gpu.texture_info(b.id.0).is_none());
|
||||
// Side by side, they split the budget: neither set fits its half, so both draw from pages.
|
||||
g.harness.state_mut().ui.view.arrange = "twoUpVertical".into();
|
||||
g.harness.run_steps(3);
|
||||
assert_eq!(gpu.canvas_fit(&a), Some((set, budget / 2)));
|
||||
assert_eq!(gpu.canvas_fit(&b), Some((set, budget - budget / 2)));
|
||||
settle_pages(&mut g);
|
||||
assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g));
|
||||
let streams = &g.harness.state().streams;
|
||||
assert!(streams.contains_key(&a.id) && streams.contains_key(&b.id), "both on pages");
|
||||
assert!(gpu.texture_info(a.id.0).is_none() && gpu.texture_info(b.id.0).is_none());
|
||||
// Switching between them re-splits nothing: no page set is dropped.
|
||||
let held = gpu.pages(crate::canvas::viewport_pages::stream_key(a.id.0)).0;
|
||||
g.harness.state_mut().session.set_active(1);
|
||||
g.harness.run_steps(3);
|
||||
assert_eq!(gpu.canvas_fit(&a), Some((set, budget / 2)));
|
||||
assert_eq!(gpu.pages(crate::canvas::viewport_pages::stream_key(a.id.0)).0, held, "no churn on a switch");
|
||||
// Back to tabs: the shown document has the whole budget, and its texture set, again.
|
||||
g.harness.state_mut().ui.view.arrange = "tabs".into();
|
||||
g.harness.run_steps(3);
|
||||
assert_eq!(gpu.canvas_fit(&b), Some((set, budget)));
|
||||
assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g));
|
||||
assert!(gpu.texture_info(b.id.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flipped_view_records_why_it_draws_on_the_cpu() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ mod callback;
|
||||
mod doc_textures;
|
||||
mod pages;
|
||||
mod perf;
|
||||
mod residency;
|
||||
mod resources;
|
||||
mod setup;
|
||||
mod shaders;
|
||||
@@ -349,24 +350,43 @@ impl GpuCanvas {
|
||||
r.callback_resources.get::<Resources>()?.compositor_budget
|
||||
}
|
||||
|
||||
/// Set the GPU bytes one document's display texture set may take (see [`canvas_budget`]).
|
||||
/// Set the GPU bytes the open documents' display textures may take together (see
|
||||
/// [`canvas_budget`] and [`GpuCanvas::show_documents`]).
|
||||
pub fn set_canvas_budget(&self, bytes: u64) {
|
||||
self.canvas_budget.store(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether `doc`'s display textures fit [`canvas_budget`]; when they don't, nothing is
|
||||
/// created for it and it is drawn on the CPU canvas.
|
||||
/// Whether `doc`'s display textures fit its share of [`canvas_budget`]; when they don't,
|
||||
/// nothing is created for it and it is drawn from viewport pages or on the CPU canvas.
|
||||
pub fn fits_canvas(&self, doc: &photocraft_doc::Document) -> bool {
|
||||
self.canvas_fit(doc).is_some_and(|(bytes, budget)| bytes <= budget)
|
||||
}
|
||||
|
||||
/// The bytes `doc`'s display textures would take and [`canvas_budget`] (`None` without a GPU
|
||||
/// canvas): the numbers of a [`CpuReason::CanvasTooLarge`].
|
||||
pub fn canvas_fit(&self, doc: &photocraft_doc::Document) -> Option<(u64, u64)> {
|
||||
/// The bytes `doc`'s whole-document texture set would take.
|
||||
pub fn canvas_bytes(&self, doc: &photocraft_doc::Document) -> u64 {
|
||||
let size = [doc.size.width, doc.size.height];
|
||||
let bytes = canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size)));
|
||||
canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size)))
|
||||
}
|
||||
|
||||
/// The bytes `doc`'s display textures would take and its share of [`canvas_budget`] (`None`
|
||||
/// without a GPU canvas): the numbers of a [`CpuReason::CanvasTooLarge`]. Alone on screen a
|
||||
/// document has the whole budget; see [`GpuCanvas::show_documents`].
|
||||
pub fn canvas_fit(&self, doc: &photocraft_doc::Document) -> Option<(u64, u64)> {
|
||||
let bytes = self.canvas_bytes(doc);
|
||||
let r = self.rs.renderer.read();
|
||||
r.callback_resources.get::<Resources>().map(|res| (bytes, res.canvas_budget.load(Ordering::Relaxed)))
|
||||
r.callback_resources.get::<Resources>().map(|res| (bytes, res.residency.share(res.canvas_budget.load(Ordering::Relaxed), doc.id.0, bytes)))
|
||||
}
|
||||
|
||||
/// The documents the app's windows show now, with [`GpuCanvas::canvas_bytes`] of each: they
|
||||
/// split [`canvas_budget`] (see [`residency`]), and the textures of documents not among them
|
||||
/// are freed first when a shown one needs the room. Cheap when nothing changed.
|
||||
pub fn show_documents(&self, shown: &[(u64, u64)]) {
|
||||
if self.rs.renderer.read().callback_resources.get::<Resources>().is_none_or(|res| res.residency.shows(shown)) {
|
||||
return;
|
||||
}
|
||||
if let Some(res) = self.rs.renderer.write().callback_resources.get_mut::<Resources>() {
|
||||
res.residency.publish(shown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Act as if the driver couldn't build the compositor's pipelines (the DX12 FXC X3507 case,
|
||||
@@ -513,7 +533,7 @@ impl GpuCanvas {
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let Some(res) = renderer.callback_resources.get_mut::<Resources>() else { return };
|
||||
if res.docs.get(&doc).is_none_or(|d| d.size != size || d.format != format) {
|
||||
let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else {
|
||||
let Some(tex) = DocTextures::new_for(device, res, doc, size, self.tile, format) else {
|
||||
res.docs.remove(&doc);
|
||||
return;
|
||||
};
|
||||
@@ -646,7 +666,7 @@ impl GpuCanvas {
|
||||
}
|
||||
}
|
||||
if fresh {
|
||||
let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else {
|
||||
let Some(tex) = DocTextures::new_for(device, res, key, size, self.tile, format) else {
|
||||
res.docs.remove(&key);
|
||||
res.compositor = Some(comp);
|
||||
return Err(photocraft_gpu::Unsupported(OVER_CANVAS_BUDGET.into()));
|
||||
@@ -861,7 +881,7 @@ impl GpuCanvas {
|
||||
let mut renderer = self.rs.renderer.write();
|
||||
let res = renderer.callback_resources.get_mut::<Resources>()?;
|
||||
if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) {
|
||||
let Some(tex) = DocTextures::new(&self.rs.device, res, size, self.tile, format) else {
|
||||
let Some(tex) = DocTextures::new_for(&self.rs.device, res, key, size, self.tile, format) else {
|
||||
res.docs.remove(&key);
|
||||
return None;
|
||||
};
|
||||
@@ -886,6 +906,7 @@ impl GpuCanvas {
|
||||
// LUTs and identity-transform signatures can outlive document textures. Prune both,
|
||||
// or a preserved-ID reopen can skip rebuilding a missing LUT.
|
||||
res.luts.retain(|k, _| live.contains(&k.0));
|
||||
res.residency.retain(|d| live.contains(&d));
|
||||
res.display_lut_signatures.retain(|k, _| live.contains(&k.0));
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,24 @@ pub(super) fn tile_uniform([x, y, w, h]: [u32; 4], core: Option<[i32; 4]>) -> [f
|
||||
[x as f32, y as f32, w as f32, h as f32, cx0, cy0, cx1, cy1]
|
||||
}
|
||||
|
||||
/// Run `f` with its validation and out-of-memory errors caught, rather than reaching the
|
||||
/// device's uncaptured-error handler (which takes the GPU canvas down for the session): what `f`
|
||||
/// returned, and the first error. Native wgpu reports these synchronously; where they arrive
|
||||
/// later (WebGPU) none is seen.
|
||||
pub(super) fn catch_gpu_errors<T>(device: &wgpu::Device, f: impl FnOnce() -> T) -> (T, Option<wgpu::Error>) {
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let out = f();
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
let mut error = None;
|
||||
for pop in [memory.pop(), validation.pop()] {
|
||||
if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
(out, error)
|
||||
}
|
||||
|
||||
impl DocTextures {
|
||||
/// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit
|
||||
/// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB
|
||||
@@ -54,17 +72,7 @@ impl DocTextures {
|
||||
if bytes > res.canvas_budget.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let set = Self::create(device, res, size, tile, format);
|
||||
// Native wgpu reports these synchronously; where they arrive later (WebGPU) none is seen.
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
let mut error = None;
|
||||
for pop in [memory.pop(), validation.pop()] {
|
||||
if let std::task::Poll::Ready(Some(e)) = std::pin::pin!(pop).poll(&mut cx) {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
let (set, error) = catch_gpu_errors(device, || Self::create(device, res, size, tile, format));
|
||||
if let Some(e) = error {
|
||||
log::warn!("{} MB of canvas textures failed ({e}); larger canvases are drawn on the CPU", bytes >> 20);
|
||||
res.canvas_budget.fetch_min(bytes.saturating_sub(1), Ordering::Relaxed);
|
||||
@@ -73,6 +81,16 @@ impl DocTextures {
|
||||
Some(set)
|
||||
}
|
||||
|
||||
/// [`DocTextures::new`] for key `key`, first freeing the textures of documents no window
|
||||
/// shows when the open documents' together wouldn't fit the canvas budget.
|
||||
pub(super) fn new_for(device: &wgpu::Device, res: &mut Resources, key: u64, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option<Self> {
|
||||
let bytes = canvas_texture_bytes(size, tile, texel_bytes(format));
|
||||
if bytes <= res.canvas_budget.load(Ordering::Relaxed) {
|
||||
res.make_room(key, bytes);
|
||||
}
|
||||
Self::new(device, res, size, tile, format)
|
||||
}
|
||||
|
||||
fn create(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Self {
|
||||
let mut tiles = Vec::new();
|
||||
for ty in (0..size[1]).step_by(tile as usize) {
|
||||
|
||||
@@ -66,6 +66,37 @@ fn canvas_mips_preserve_opaque_pixels_and_partial_updates() {
|
||||
}
|
||||
// On the same device (see the top of the file).
|
||||
canvas_textures_the_gpu_refuses_leave_it_working(&gpu);
|
||||
refused_viewport_page_work_is_caught(&gpu);
|
||||
}
|
||||
|
||||
/// #5332 review: viewport page uploads run their texture, buffer and bind group creation and
|
||||
/// their write inside the same error scopes as a texture set, so an error the GPU raises there
|
||||
/// (out of memory, in use) is returned instead of disabling the GPU for the session. A texture
|
||||
/// wider than the device allows is refused the same way, synchronously, on any adapter.
|
||||
fn refused_viewport_page_work_is_caught(gpu: &GpuCanvas) {
|
||||
let device = &gpu.rs.device;
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let (texture, error) = super::doc_textures::catch_gpu_errors(device, || {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("too_wide_page"),
|
||||
size: wgpu::Extent3d { width: max + 1, height: 1, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
})
|
||||
});
|
||||
drop(texture);
|
||||
assert!(error.is_some(), "the refusal is caught");
|
||||
assert!(gpu.health.is_ok(), "{:?}", gpu.fault());
|
||||
// A page the GPU accepts is stored as before.
|
||||
let buf = Buffer::filled(Rect::from_xywh(0, 0, 64, 64), [0.5, 0.5, 0.5, 1.0]);
|
||||
let core = Rect::new(1, 1, 63, 63);
|
||||
assert!(gpu.upload_page(PageUpload { key: 9, page: 1, size: [64, 64], depth: SampleType::U8, factor: 1, core, buf: &buf }));
|
||||
assert_eq!(gpu.pages(9).0, vec![1]);
|
||||
assert!(gpu.health.is_ok(), "{:?}", gpu.fault());
|
||||
}
|
||||
|
||||
/// #4017: a 3 GP canvas's 16 GB of textures failed with Out of Memory, and the uncaptured error
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `canvas::viewport_pages`): independent single-level textures at their own reduction, drawn
|
||||
//! by the same tile pipeline. They live under their own key, never next to a texture set.
|
||||
|
||||
use super::doc_textures::{init_buffer, tile_uniform};
|
||||
use super::doc_textures::{catch_gpu_errors, init_buffer, tile_uniform};
|
||||
use super::resources::{DocTextures, Resources, Tile, f32_bytes};
|
||||
use super::texels::{texel_bytes, texels};
|
||||
use super::{GpuCanvas, Ordering, wgpu};
|
||||
@@ -108,34 +108,49 @@ impl GpuCanvas {
|
||||
if held.saturating_add(bytes) > budget {
|
||||
return false;
|
||||
}
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("pc_viewport_page"),
|
||||
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
// Room among the other documents: those no window shows give theirs up first.
|
||||
res.make_room(key, held + bytes);
|
||||
let rect = [x, y, dw, dh];
|
||||
let uniform =
|
||||
init_buffer(device, "pc_viewport_page", &f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))), wgpu::BufferUsages::UNIFORM);
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_viewport_page"),
|
||||
layout: &res.tile_bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() },
|
||||
],
|
||||
// A page the GPU refuses (out of memory) fails alone: it falls back to the CPU canvas
|
||||
// instead of the uncaptured error disabling the GPU canvas for the session.
|
||||
let ((texture, view, uniform, bind_group), error) = catch_gpu_errors(device, || {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("pc_viewport_page"),
|
||||
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let uniform = init_buffer(
|
||||
device,
|
||||
"pc_viewport_page",
|
||||
&f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))),
|
||||
wgpu::BufferUsages::UNIFORM,
|
||||
);
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("pc_viewport_page"),
|
||||
layout: &res.tile_bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&texels(format, &buf.px),
|
||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
(texture, view, uniform, bind_group)
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&texels(format, &buf.px),
|
||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
if let Some(e) = error {
|
||||
log::warn!("a {w}×{h} viewport page couldn't be stored on the GPU: {e}");
|
||||
return false;
|
||||
}
|
||||
let Some(d) = res.docs.get_mut(&key) else { return false };
|
||||
if let Some(i) = replaced {
|
||||
d.tiles.remove(i);
|
||||
@@ -176,7 +191,8 @@ impl GpuCanvas {
|
||||
(d.tiles.iter().filter_map(|t| t.page.map(|(id, _)| id)).collect(), d.page_bytes())
|
||||
}
|
||||
|
||||
/// The per-document display texture budget ([`super::canvas_budget`]) pages are held to.
|
||||
/// The display texture budget all documents share ([`super::canvas_budget`]); a document's
|
||||
/// pages are held to its share ([`GpuCanvas::canvas_fit`]).
|
||||
pub fn canvas_budget(&self) -> u64 {
|
||||
self.canvas_budget.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! One display-texture budget ([`super::canvas_budget`]) for every open document, rather than
|
||||
//! the whole budget for each (N documents could hold N times it, #5332 review).
|
||||
//!
|
||||
//! The app publishes the documents its windows show and what each one's whole-document texture
|
||||
//! set needs ([`super::GpuCanvas::show_documents`]). Those split the budget max-min fairly: a
|
||||
//! document that fits its share keeps its texture set, a larger one is drawn from viewport pages
|
||||
//! within it, and a document alone on screen still has the whole budget. The split changes only
|
||||
//! when the shown documents (or their sizes) do, never with focus, so switching between windows
|
||||
//! moves nothing. Documents no window shows have no share: they keep their textures while there
|
||||
//! is room (switching back to a tab is free) and are the first freed, least recently shown
|
||||
//! first, when a shown document needs the room.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::resources::{DocTextures, Resources};
|
||||
use super::texels::texel_bytes;
|
||||
|
||||
/// Keys tagged above this bit are previews (filter preview, adjustment proxies, gradients,
|
||||
/// thumbnails): never evicted for another document.
|
||||
const TAG_BITS: u64 = !((1u64 << 58) - 1);
|
||||
/// The tag of a document's viewport pages (`viewport_pages::stream_key`).
|
||||
const PAGES_BIT: u64 = 1u64 << 57;
|
||||
|
||||
impl DocTextures {
|
||||
/// GPU bytes of every texture (all mip levels) in this set or page set.
|
||||
pub(super) fn bytes(&self) -> u64 {
|
||||
let bpp = texel_bytes(self.format);
|
||||
let level = |w: u32, h: u32, l: u32| u64::from((w >> l).max(1)) * u64::from((h >> l).max(1)) * bpp;
|
||||
self.tiles.iter().map(|t| (0..t.texture.mip_level_count()).map(|l| level(t.texture.width(), t.texture.height(), l)).sum::<u64>()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
/// Free the textures of documents no window shows ([`Residency::victims`]) so that `key`
|
||||
/// can hold `bytes` within the canvas budget.
|
||||
pub(super) fn make_room(&mut self, key: u64, bytes: u64) {
|
||||
let budget = self.canvas_budget.load(Ordering::Relaxed);
|
||||
let held: Vec<(u64, u64)> = self.docs.iter().map(|(k, d)| (*k, d.bytes())).collect();
|
||||
for k in self.residency.victims(&held, key, bytes, budget) {
|
||||
log::debug!("freeing the display textures of background key {k:#x} for {key:#x}");
|
||||
self.docs.remove(&k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Document `doc`'s allocation when documents needing `needs` bytes split `budget` max-min
|
||||
/// fairly (`doc` indexes `needs`): those needing less than an equal share take what they need,
|
||||
/// the rest split what is left; the leftover, if any, is `doc`'s too.
|
||||
pub(super) fn fair_share(budget: u64, needs: &[u64], doc: usize) -> u64 {
|
||||
let mut order: Vec<usize> = (0..needs.len()).collect();
|
||||
order.sort_by_key(|&i| needs[i]);
|
||||
let mut left = budget;
|
||||
let mut others = 0u64;
|
||||
for (n, &i) in order.iter().enumerate() {
|
||||
let take = needs[i].min(left / (order.len() - n) as u64);
|
||||
left -= take;
|
||||
if i != doc {
|
||||
others += take;
|
||||
}
|
||||
}
|
||||
budget - others
|
||||
}
|
||||
|
||||
/// The documents on screen and when each was last shown.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Residency {
|
||||
/// Shown documents: (document id, bytes of its whole texture set).
|
||||
shown: Vec<(u64, u64)>,
|
||||
/// When each document was last among the shown (a counter of changes).
|
||||
seen: HashMap<u64, u64>,
|
||||
tick: u64,
|
||||
}
|
||||
|
||||
impl Residency {
|
||||
/// Record the shown documents. False when nothing changed.
|
||||
pub(super) fn publish(&mut self, shown: &[(u64, u64)]) -> bool {
|
||||
if self.shown == shown {
|
||||
return false;
|
||||
}
|
||||
self.tick += 1;
|
||||
for (id, _) in shown {
|
||||
self.seen.insert(*id, self.tick);
|
||||
}
|
||||
self.shown = shown.to_vec();
|
||||
true
|
||||
}
|
||||
|
||||
/// The share of `budget` document `id` (its texture set `need` bytes) may hold: the whole
|
||||
/// budget when nothing else is shown.
|
||||
pub(super) fn share(&self, budget: u64, id: u64, need: u64) -> u64 {
|
||||
let mut needs: Vec<u64> = self.shown.iter().filter(|(d, _)| *d != id).map(|(_, n)| *n).collect();
|
||||
needs.push(need);
|
||||
fair_share(budget, &needs, needs.len() - 1)
|
||||
}
|
||||
|
||||
/// Which held keys (`held`: key and bytes) to free, in order, so that `key` can hold
|
||||
/// `bytes` within `budget`: the texture sets and pages of documents no window shows, least
|
||||
/// recently shown first. Never a shown document's, nor `key`'s own document's, nor a preview
|
||||
/// or a key of a document never shown (an adjustment proxy). It may not free enough: what
|
||||
/// a shown document takes is bounded by its share instead.
|
||||
pub(super) fn victims(&self, held: &[(u64, u64)], key: u64, bytes: u64, budget: u64) -> Vec<u64> {
|
||||
let owner = |k: u64| (k & TAG_BITS == 0).then_some(k & !PAGES_BIT);
|
||||
let mut total = held.iter().filter(|(k, _)| *k != key).map(|(_, b)| *b).fold(bytes, u64::saturating_add);
|
||||
if total <= budget {
|
||||
return Vec::new();
|
||||
}
|
||||
let mine = owner(key);
|
||||
let mut cold: Vec<(u64, u64, u64)> = held
|
||||
.iter()
|
||||
.filter_map(|&(k, b)| {
|
||||
let d = owner(k)?;
|
||||
let at = *self.seen.get(&d)?;
|
||||
(Some(d) != mine && !self.shown.iter().any(|(s, _)| *s == d)).then_some((at, k, b))
|
||||
})
|
||||
.collect();
|
||||
cold.sort_unstable();
|
||||
let mut out = Vec::new();
|
||||
for (_, k, b) in cold {
|
||||
if total <= budget {
|
||||
break;
|
||||
}
|
||||
total -= b;
|
||||
out.push(k);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `shown` is what was published last.
|
||||
pub(super) fn shows(&self, shown: &[(u64, u64)]) -> bool {
|
||||
self.shown == shown
|
||||
}
|
||||
|
||||
/// Forget closed documents.
|
||||
pub(super) fn retain(&mut self, live: impl Fn(u64) -> bool) {
|
||||
self.seen.retain(|d, _| live(*d));
|
||||
self.shown.retain(|(d, _)| live(*d));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GB: u64 = 1 << 30;
|
||||
|
||||
#[test]
|
||||
fn one_document_has_the_whole_budget() {
|
||||
let r = Residency::default();
|
||||
assert_eq!(r.share(4 * GB, 1, 9 * GB), 4 * GB);
|
||||
let mut r = Residency::default();
|
||||
r.publish(&[(1, 9 * GB)]);
|
||||
assert_eq!(r.share(4 * GB, 1, 9 * GB), 4 * GB, "shown alone");
|
||||
assert_eq!(r.share(4 * GB, 1, GB), 4 * GB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shown_documents_split_the_budget_max_min_fairly() {
|
||||
let mut r = Residency::default();
|
||||
// A small document takes what it needs; the large one the rest.
|
||||
r.publish(&[(1, GB), (2, 9 * GB)]);
|
||||
assert_eq!(r.share(4 * GB, 1, GB), GB);
|
||||
assert_eq!(r.share(4 * GB, 2, 9 * GB), 3 * GB);
|
||||
// What the others leave is the asking document's too.
|
||||
r.publish(&[(1, GB), (2, 2 * GB)]);
|
||||
assert_eq!(r.share(4 * GB, 2, 2 * GB), 3 * GB, "the leftover is its own too");
|
||||
// Two large ones split it evenly, whichever has focus: no churn switching windows.
|
||||
r.publish(&[(1, 3 * GB), (2, 3 * GB)]);
|
||||
assert_eq!(r.share(4 * GB, 1, 3 * GB), 2 * GB);
|
||||
assert_eq!(r.share(4 * GB, 2, 3 * GB), 2 * GB);
|
||||
// Three, one of them small.
|
||||
r.publish(&[(1, 3 * GB), (2, 3 * GB), (3, GB / 2)]);
|
||||
assert_eq!(r.share(4 * GB, 1, 3 * GB), 7 * GB / 4);
|
||||
assert_eq!(r.share(4 * GB, 3, GB / 2), GB / 2, "needs only half a GB");
|
||||
// Everything fits: each share covers its need, together within the budget.
|
||||
r.publish(&[(1, GB), (2, GB)]);
|
||||
assert!(r.share(4 * GB, 1, GB) >= GB && r.share(4 * GB, 2, GB) >= GB);
|
||||
// A document no window shows (a refresh of a background tab) is priced as one more.
|
||||
assert_eq!(r.share(4 * GB, 7, 4 * GB), 2 * GB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn republishing_the_same_documents_changes_nothing() {
|
||||
let mut r = Residency::default();
|
||||
assert!(r.publish(&[(1, GB)]));
|
||||
assert!(!r.publish(&[(1, GB)]));
|
||||
assert!(r.publish(&[(1, 2 * GB)]), "the document was resized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_documents_are_freed_least_recently_shown_first() {
|
||||
let mut r = Residency::default();
|
||||
// Tabs 1, 2, 3 shown in turn, then 4.
|
||||
for d in 1..=4 {
|
||||
r.publish(&[(d, GB)]);
|
||||
}
|
||||
let pages = |d: u64| d ^ PAGES_BIT;
|
||||
let held = [(1, GB), (pages(2), GB / 2), (2, GB / 2), (3, GB), (1 << 61 | 3, GB), (99, GB)];
|
||||
// Room enough: nothing goes.
|
||||
assert!(r.victims(&held, 4, GB, 16 * GB).is_empty());
|
||||
// 4 needs 2 GB within 6 GB (5 held): tab 1 goes first, then tab 2's pages and set.
|
||||
assert_eq!(r.victims(&held, 4, 2 * GB, 6 * GB), vec![1]);
|
||||
assert_eq!(r.victims(&held, 4, 3 * GB, 6 * GB), vec![1, 2, pages(2)]);
|
||||
// Never a shown document's, a preview's or an unknown key's, even when that isn't enough.
|
||||
assert_eq!(r.victims(&held, 4, 10 * GB, 6 * GB), vec![1, 2, pages(2), 3]);
|
||||
// Nor the document's own: its pages make room by evicting the other tabs.
|
||||
assert_eq!(r.victims(&held, pages(3), GB, 4 * GB), vec![1, 2, pages(2)]);
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,10 @@ pub(super) struct Resources {
|
||||
pub(super) layer_fit: Option<LayerFit>,
|
||||
pub(super) compositor_cache_budget: Option<u64>,
|
||||
pub(super) compositor_focus: Option<photocraft_geom::Rect>,
|
||||
/// GPU bytes one document's display texture set may take (see [`canvas_budget`]).
|
||||
/// GPU bytes the documents' display textures may take together (see [`canvas_budget`]).
|
||||
pub(super) canvas_budget: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
/// How that budget is split between the open documents (see [`super::residency`]).
|
||||
pub(super) residency: super::residency::Residency,
|
||||
pub(super) encode_bgl: wgpu::BindGroupLayout,
|
||||
encode_pipeline: wgpu::RenderPipeline,
|
||||
pub(super) lut_bgl: wgpu::BindGroupLayout,
|
||||
@@ -369,6 +371,7 @@ impl Resources {
|
||||
compositor_cache_budget: None,
|
||||
compositor_focus: None,
|
||||
canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))),
|
||||
residency: Default::default(),
|
||||
encode_bgl,
|
||||
encode_pipeline,
|
||||
}
|
||||
|
||||
@@ -71,9 +71,13 @@ impl Span {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp one axis of the view centre with Overscroll off, as Photoshop 25.4 does: an image larger
|
||||
/// than the view can't leave its edges, and one smaller than the view stays inside it, where
|
||||
/// zooming put it (zooming out around the pointer doesn't re-centre it; Fit on Screen does).
|
||||
/// Clamp one axis of the view centre with Overscroll off: an image larger than the view can't
|
||||
/// leave its edges, and one smaller than the view is centred in it, so zooming out around the
|
||||
/// pointer keeps it in the middle (#10488, #4799).
|
||||
///
|
||||
/// #1718 measured Photoshop 25.4 leaving a smaller image where zooming put it. Two reports of
|
||||
/// Photoshop itself say it stays centred with Overscroll off, which is what turning Overscroll off
|
||||
/// is asked for, so PhotoCraft centres it; Overscroll on keeps the free view.
|
||||
pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 {
|
||||
if !(zoom.is_finite() && zoom > 0.0 && len.is_finite() && len > 0.0 && size.is_finite() && size > 0.0) {
|
||||
return center;
|
||||
@@ -82,9 +86,11 @@ pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 {
|
||||
return len / 2.0;
|
||||
}
|
||||
let half = size / zoom / 2.0;
|
||||
// Larger: the view stays within the image. Smaller: the image stays within the view.
|
||||
let (a, b) = (half, len - half);
|
||||
center.clamp(a.min(b), a.max(b))
|
||||
// Smaller than the view (or exactly its size): centred. Larger: the view stays within it.
|
||||
if 2.0 * half >= len {
|
||||
return len / 2.0;
|
||||
}
|
||||
center.clamp(half, len - half)
|
||||
}
|
||||
|
||||
/// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points on a
|
||||
@@ -112,7 +118,7 @@ pub fn clamp_view_with_aspect(view: &mut View, size: egui::Vec2, ppp: f32, aspec
|
||||
if view.center[0].is_finite() { view.center[0] } else { w as f32 / 2.0 },
|
||||
if view.center[1].is_finite() { view.center[1] } else { h as f32 / 2.0 },
|
||||
];
|
||||
let xf = crate::canvas::ViewXform { rect: egui::Rect::from_min_size(Pos2::ZERO, size), zoom, center, flip: false, rotation: view.rotation, aspect };
|
||||
let xf = crate::canvas::ViewXform { rect: Rect::from_min_size(Pos2::ZERO, size), zoom, center, flip: false, rotation: view.rotation, aspect };
|
||||
let image = xf.image_bounds(view.doc_size);
|
||||
if !image.is_finite() {
|
||||
return false;
|
||||
@@ -309,12 +315,13 @@ mod tests {
|
||||
assert_eq!(clamp_axis(-400.0, 1000.0, 500.0, 1.0), 250.0);
|
||||
assert_eq!(clamp_axis(4000.0, 1000.0, 500.0, 1.0), 750.0);
|
||||
assert_eq!(clamp_axis(600.0, 1000.0, 500.0, 1.0), 600.0);
|
||||
// Smaller than the view (2500 doc px of view for 1000 of image): it stays where it is
|
||||
// while it is inside the view, and is pushed back in at the edges.
|
||||
assert_eq!(clamp_axis(-90.0, 1000.0, 500.0, 0.2), -90.0);
|
||||
assert_eq!(clamp_axis(-400.0, 1000.0, 500.0, 0.2), -250.0, "the image's right edge on the view's");
|
||||
assert_eq!(clamp_axis(1400.0, 1000.0, 500.0, 0.2), 1250.0, "its left edge on the view's");
|
||||
// Smaller than the view (2500 doc px of view for 1000 of image): centred, wherever zooming
|
||||
// around the pointer left it (#10488).
|
||||
for center in [-90.0, -400.0, 300.0, 1400.0] {
|
||||
assert_eq!(clamp_axis(center, 1000.0, 500.0, 0.2), 500.0, "{center}");
|
||||
}
|
||||
assert_eq!(clamp_axis(500.0, 1000.0, 1000.0, 1.0), 500.0, "exactly the view's size: centred");
|
||||
assert_eq!(clamp_axis(10.0, 1000.0, 1000.0, 1.0), 500.0);
|
||||
let mut v = View {
|
||||
zoom: 1.0,
|
||||
center: [-500.0, 20.0],
|
||||
@@ -332,6 +339,20 @@ mod tests {
|
||||
assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0), "stable: no repaint loop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overscroll_off_centres_a_fitting_rotated_image() {
|
||||
// 600 px at 45 degrees is about 849 points across, so it fits a 900-point view both ways.
|
||||
// Started off centre, the clamp centres its screen-space bounds in the view (#10488).
|
||||
let mut v = View { zoom: 1.0, center: [100.0, 450.0], doc_size: [600, 600], rotation: 45.0, ..Default::default() };
|
||||
let size = vec2(900.0, 900.0);
|
||||
assert!(clamp_view_with_aspect(&mut v, size, 1.0, 1.0));
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, size);
|
||||
let xf = crate::canvas::ViewXform { rect, zoom: 1.0, center: v.center, flip: false, rotation: v.rotation, aspect: 1.0 };
|
||||
let c = xf.image_bounds(v.doc_size).center();
|
||||
assert!((c - xf.rect.center()).length() < 1e-2, "centred: {c:?}");
|
||||
assert!(!clamp_view_with_aspect(&mut v, size, 1.0, 1.0), "stable: no repaint loop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_are_harmless() {
|
||||
for z in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
@@ -516,16 +537,13 @@ mod tests {
|
||||
} else {
|
||||
assert!((x - w / 2.0).abs() < 1e-3, "clamped to the left edge: {x}");
|
||||
}
|
||||
// Zoomed out below fit (Photoshop): the image is not re-centred, it stays where it
|
||||
// is while it fits in the view, and is pushed back in at the view's edge.
|
||||
// Zoomed out below fit: the image is centred in the view on both axes, wherever
|
||||
// the zoom left it (#10488).
|
||||
set_view(&mut h, 0.1, [-50.0, 9000.0]);
|
||||
if !overscroll {
|
||||
let r = h.state().last_canvas_rect;
|
||||
let (hw, hh) = (r.width() / 0.1 / 2.0, r.height() / 0.1 / 2.0);
|
||||
let c = view(&h).center;
|
||||
assert_eq!(c[0], -50.0, "inside the view: left alone");
|
||||
assert!((c[1] - hh).abs() < 1e-2, "pushed back in: the image's top on the view's: {c:?}");
|
||||
assert!(c[0] - hw <= 0.0 && c[0] + hw >= 2000.0 && c[1] - hh <= 0.0 && c[1] + hh >= 1500.0, "{c:?}");
|
||||
assert_eq!(view(&h).center, [1000.0, 750.0], "centred");
|
||||
} else {
|
||||
assert_eq!(view(&h).center, [-50.0, 9000.0], "overscroll on: the view is free");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user