mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge pull request #10535 from davidmann4/integration-batch-11
Integration batch 11: 9 ATN PRs on main
This commit is contained in:
+8
-1
@@ -120,7 +120,8 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
|
||||
|
||||
## Test data (not committed, not shipped)
|
||||
|
||||
`corpus/` is gitignored and no test fixtures are committed to the repository.
|
||||
`corpus/` is gitignored and no test fixtures are committed to the repository, except the small
|
||||
synthetic parser fixtures listed under *Synthetic parser test fixtures* below.
|
||||
`cargo xtask corpus --all` fetches every corpus at the pinned commits in `xtask/src/corpus_pins.rs`
|
||||
and verifies each file against the sha256 lists in `xtask/*.sha256`, with the upstream licence
|
||||
next to the files:
|
||||
@@ -140,6 +141,12 @@ next to the files:
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
|
||||
## Synthetic parser test fixtures
|
||||
|
||||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `crates/psd/tests/fixtures/atn/*.atn` | Independently encoded ATN version-16 parser fixtures | PhotoCraft contributors | Original synthetic test data; wire layout documented in `crates/psd/src/atn.rs`; provenance in the adjacent README | MIT OR Apache-2.0 |
|
||||
|
||||
## Embedded-vector editor source
|
||||
|
||||
The external SVG watcher, engine/UI handoff and bounded SVG rendering in this change
|
||||
|
||||
Generated
+1
@@ -5332,6 +5332,7 @@ dependencies = [
|
||||
name = "photocraft-psd"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
"proptest",
|
||||
|
||||
@@ -53,6 +53,7 @@ clap_mangen = { version = "0.3.3", features = ["env"] }
|
||||
arc-swap = "1"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
encoding_rs = { version = "0.8", default-features = false, features = ["alloc"] }
|
||||
image = { version = "0.25", default-features = false }
|
||||
# Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg.
|
||||
zune-core = "0.5"
|
||||
|
||||
@@ -291,6 +291,13 @@ sync_github_name = true
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.sn-o-w]
|
||||
real_name = "Cristian Silaghi"
|
||||
sync_github_name = false
|
||||
|
||||
[people.regexer]
|
||||
sync_github_name = true
|
||||
|
||||
[people.SimoneRecchia]
|
||||
real_name = "Simone Recchia"
|
||||
display_name = "Simone Recchia"
|
||||
|
||||
@@ -106,6 +106,12 @@ pub struct ActionState {
|
||||
/// Steps run by the current top-level script and everything it calls.
|
||||
#[serde(skip)]
|
||||
pub script_steps: u64,
|
||||
/// Sets made with New Set (`actions.newSet`), or loaded from an `.atn` with no actions, that
|
||||
/// hold no action yet: the Actions panel shows them as empty folders. Save Actions… writes one
|
||||
/// to an `.atn` as Photoshop does, but the preset store keeps sets through their actions, so
|
||||
/// an empty one isn't remembered past the session.
|
||||
#[serde(skip)]
|
||||
pub sets: Vec<String>,
|
||||
/// Bumped whenever `list` changes, so the preset store can skip unchanged writes.
|
||||
#[serde(skip)]
|
||||
pub rev: u64,
|
||||
@@ -333,7 +339,7 @@ fn from_step(p: &Value, len: usize) -> Result<usize> {
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let actions: Vec<Value> = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect();
|
||||
let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone()));
|
||||
Ok(json!({"actions": actions, "recording": recording}))
|
||||
Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording}))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -366,7 +372,15 @@ fn record(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.actions.list.iter().any(|action| action.name == name) {
|
||||
return Err(bad("actions.record", "an action with this name already exists"));
|
||||
}
|
||||
s.actions.list.push(Action::new(name, Vec::new()));
|
||||
let set = match p.get("set") {
|
||||
None | Some(Value::Null) => None,
|
||||
// An existing set, empty ones included, keeps its exact name (an older actions.json or
|
||||
// a `.atn` may pad it); a new one is trimmed.
|
||||
Some(Value::String(n)) if crate::actions_edit_cmds::set_names(s).contains(n) => Some(n.clone()),
|
||||
Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()),
|
||||
Some(_) => return Err(bad("actions.record", "\"set\" must be a set name or null")),
|
||||
};
|
||||
s.actions.list.push(Action { set, ..Action::new(name, Vec::new()) });
|
||||
s.actions.touch();
|
||||
s.actions.list.len() - 1
|
||||
};
|
||||
@@ -746,7 +760,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "List Actions",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{} → {actions:[{name, steps:count, set:name|null}], recording:name|null}",
|
||||
params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null}",
|
||||
enabled: always,
|
||||
run: list,
|
||||
journal: false,
|
||||
@@ -766,7 +780,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Record Action",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"name":str? (new action, default "Action N")} or {"action":name|index} (append) → {action, index}"##,
|
||||
params: r##"{"name":str? (new action, default "Action N"),"set":name? (the set it goes in, as New Action records into the selected set)} or {"action":name|index} (append) → {action, index}"##,
|
||||
enabled: always,
|
||||
run: record,
|
||||
journal: false,
|
||||
|
||||
@@ -1176,3 +1176,35 @@ fn arrange_that_cannot_move_is_a_noop_in_playback() {
|
||||
assert_eq!(played["ran"], 0, "{played}");
|
||||
assert!(played["failed"].is_object(), "{played}");
|
||||
}
|
||||
|
||||
/// #10459 review: recording into an existing set keeps that set's exact name, even one with
|
||||
/// surrounding spaces (from an older actions.json); a new set name is trimmed.
|
||||
#[test]
|
||||
fn record_keeps_an_existing_sets_exact_name() {
|
||||
let mut s = Session::new();
|
||||
s.actions.list.push(Action { set: Some(" Web ".into()), ..Action::new("Old", Vec::new()) });
|
||||
s.execute("actions.record", json!({"name": "New", "set": " Web "})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let set_of = |s: &Session, n: &str| s.actions.list.iter().find(|a| a.name == n).and_then(|a| a.set.clone());
|
||||
assert_eq!(set_of(&s, "New").as_deref(), Some(" Web "), "the existing set");
|
||||
s.execute("actions.record", json!({"name": "Fresh", "set": " Print "})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(set_of(&s, "Fresh").as_deref(), Some("Print"), "a new set is trimmed");
|
||||
}
|
||||
|
||||
/// CodeRabbit review on #10459: an empty set loaded from a `.atn` (or made by New Set) keeps its
|
||||
/// exact name too, so the first action recorded into it lands in that folder and saves with it.
|
||||
#[test]
|
||||
fn record_keeps_an_empty_loaded_sets_exact_name() {
|
||||
let mut src = Session::new();
|
||||
src.actions.sets.push(" Web ".into());
|
||||
let saved = src.execute("actions.save", json!({"set": " Web "})).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("actions.load", json!({"data": saved["data"]})).unwrap()["set"], " Web ");
|
||||
s.execute("actions.record", json!({"name": "First", "set": " Web "})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let set = s.actions.list.iter().find(|a| a.name == "First").and_then(|a| a.set.clone());
|
||||
assert_eq!(set.as_deref(), Some(" Web "));
|
||||
assert_eq!(crate::actions_edit_cmds::set_names(&s), [" Web "], "one folder, not a trimmed second one");
|
||||
assert_eq!(s.execute("actions.save", json!({"set": " Web "})).unwrap()["actions"], json!(["First"]));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Editing an action's steps and its set, as the Actions panel does beyond recording:
|
||||
//! `actions.insert` (Photoshop's Insert Stop… and Insert Menu Item…), `actions.dialog` (the
|
||||
//! panel's dialog toggle column) and `actions.assignSet` (which set folder an action sits in).
|
||||
//! panel's dialog toggle column), `actions.assignSet` (which set folder an action sits in) and
|
||||
//! `actions.newSet` (New Set…, an empty set folder).
|
||||
//!
|
||||
//! All three refuse to run during playback, commit pending recorded steps first, and keep the
|
||||
//! per-step flag sets (`disabled`, `dialogs`) in step with the moved indices, like
|
||||
@@ -126,6 +127,33 @@ fn assign_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Every set name, in the Actions panel's order: the sets of the actions as they first appear,
|
||||
/// then the empty ones New Set made.
|
||||
pub fn set_names(s: &Session) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
s.actions.list.iter().filter_map(|a| a.set.as_ref()).chain(&s.actions.sets).filter(|set| seen.insert(set.as_str())).cloned().collect()
|
||||
}
|
||||
|
||||
/// New Set…: an empty set folder, named `name` or "Set N" (the first number no set has), that New
|
||||
/// Action can record into.
|
||||
fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "actions.newSet";
|
||||
not_playing(s, cmd)?;
|
||||
let names = set_names(s);
|
||||
let name = match p.get("name") {
|
||||
None | Some(Value::Null) => (1u32..).map(|n| format!("Set {n}")).find(|n| !names.contains(n)).ok_or_else(|| bad(cmd, "no free set name"))?,
|
||||
Some(Value::String(n)) if !n.trim().is_empty() => n.trim().to_string(),
|
||||
Some(Value::String(_)) => return Err(bad(cmd, "`name` is empty")),
|
||||
Some(_) => return Err(bad(cmd, "`name` must be a string")),
|
||||
};
|
||||
if names.contains(&name) {
|
||||
return Err(bad(cmd, format!("a set named `{name}` already exists")));
|
||||
}
|
||||
s.actions.sets.push(name.clone());
|
||||
s.actions.touch();
|
||||
Ok(json!({"set": name}))
|
||||
}
|
||||
|
||||
/// The conditions of Photoshop's Insert Conditional, by their string ids.
|
||||
pub const CONDITIONS: &[&str] = &[
|
||||
"always",
|
||||
@@ -285,6 +313,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
run: assign_set,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "actions.newSet",
|
||||
label: "New Set",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"name":str? (default "Set N")} → {set}. An empty set folder in the Actions panel, as Photoshop's New Set… makes; New Action records into the selected set (actions.record's `set`). Save Actions… (actions.save's `set`) writes it even while empty; it is remembered between sessions once it holds an action."##,
|
||||
enabled: always,
|
||||
run: new_set,
|
||||
journal: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -412,6 +450,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_set_makes_an_empty_folder_that_new_actions_record_into() {
|
||||
let mut s = session_with(&["select.all"]);
|
||||
s.execute("actions.assignSet", json!({"action": "A", "set": "Set 1"})).unwrap();
|
||||
// Numbered past the sets the actions already have.
|
||||
assert_eq!(s.execute("actions.newSet", json!({})).unwrap()["set"], "Set 2");
|
||||
assert_eq!(s.execute("actions.newSet", json!({"name": " Retouch "})).unwrap()["set"], "Retouch");
|
||||
assert_eq!(set_names(&s), ["Set 1", "Set 2", "Retouch"]);
|
||||
// CodeRabbit on batch 11: Actions List names the empty sets too, so an agent can find them.
|
||||
assert_eq!(s.execute("actions.list", json!({})).unwrap()["sets"], json!(["Set 1", "Set 2", "Retouch"]));
|
||||
assert!(s.execute("actions.newSet", json!({"name": "Set 1"})).is_err(), "a set an action is in");
|
||||
assert!(s.execute("actions.newSet", json!({"name": "Retouch"})).is_err(), "an empty set");
|
||||
assert!(s.execute("actions.newSet", json!({"name": " "})).is_err());
|
||||
// New Action into the empty set.
|
||||
let r = s.execute("actions.record", json!({"set": "Retouch"})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let i = r["index"].as_u64().unwrap() as usize;
|
||||
assert_eq!(s.actions.list[i].set.as_deref(), Some("Retouch"));
|
||||
assert_eq!(set_names(&s), ["Set 1", "Retouch", "Set 2"], "Retouch now holds an action");
|
||||
assert!(s.execute("actions.record", json!({"set": 3})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_set_moves_an_action_between_folders() {
|
||||
let mut s = session_with(&["select.all"]);
|
||||
|
||||
@@ -40,7 +40,7 @@ mod tables;
|
||||
|
||||
#[cfg(test)]
|
||||
use adjustments::{levels_from, selective_from};
|
||||
pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, parse_function_key};
|
||||
pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key};
|
||||
#[cfg(test)]
|
||||
use convert::{MAX_NAME_CHARS, export_item, normalise};
|
||||
#[cfg(test)]
|
||||
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Load Actions…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"path":".atn file"?,"data":base64 bytes?,"set":name? (override the file's set name),"replace":bool=true (replace the actions of a set of the same name)} → {set, actions:[names], steps, unsupported:[{action, step, event, name}], functionKeys:[{action, key}], warnings}. Photoshop steps PhotoCraft cannot play become actions.photoshopStep steps (kept for Save Actions; they fail at playback with the step's name); tool selections and other steps with no document effect are imported switched off. Layer references by name replay in any document."##,
|
||||
params: r##"{"path":".atn file"?,"data":base64 bytes?,"set":name? (override the file's set name),"replace":bool=true (replace the actions of a set of the same name),"encoding":"auto"|label="auto" (the encoding of 8-bit step names: shift_jis, gbk, big5, euc-kr, windows-1252, macintosh…; auto works it out)} → {set, actions:[names], steps, unsupported:[{action, step, event, name}], functionKeys:[{action, key}], warnings, encoding}. Photoshop steps PhotoCraft cannot play become actions.photoshopStep steps (kept for Save Actions; they fail at playback with the step's name); tool selections and other steps with no document effect are imported switched off. Layer references by name replay in any document."##,
|
||||
enabled: always,
|
||||
run: load,
|
||||
journal: false,
|
||||
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Save Actions…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"path":".atn file"? (else the bytes come back as base64 `data`),"set":name? (that set's actions; default: every action, as the set "PhotoCraft Actions"),"action":name|index? (one action),"skipUnsupported":bool=false (leave out steps with no Photoshop equivalent instead of failing)} → {set, actions:[names], steps, bytes, skipped:[{action, step, id}], path|data}"##,
|
||||
params: r##"{"path":".atn file"? (else the bytes come back as base64 `data`),"set":name? (that set's actions, none for an empty New Set set; default: every action, as the set "PhotoCraft Actions"),"action":name|index? (one action),"skipUnsupported":bool=false (leave out steps with no Photoshop equivalent instead of failing)} → {set, actions:[names], steps, bytes, skipped:[{action, step, id}], path|data}"##,
|
||||
enabled: always,
|
||||
run: save,
|
||||
journal: false,
|
||||
|
||||
@@ -66,6 +66,20 @@ fn levels_entry(chan: Option<&str>, p: &Value) -> Descriptor {
|
||||
e
|
||||
}
|
||||
|
||||
/// Whether the Levels settings in `p` (the master curve and every channel object) name both ends
|
||||
/// of each range they touch, or neither. `levels_entry` writes a whole `Inpt` or `Otpt` pair and
|
||||
/// fills the end a partial edit left out with 0 or 255, so replaying `inBlack: 31` alone would
|
||||
/// also reset a retained Input White (#8530). A value given in a shape the writer can't read (a
|
||||
/// text endpoint, a channel that isn't an object) would be left out, so it isn't complete either.
|
||||
pub(super) fn levels_complete(p: &Value) -> bool {
|
||||
let shaped =
|
||||
|v: &Value| ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].iter().all(|k| v.get(*k).is_none_or(|x| x.is_null() || jnum(v, k).is_some()));
|
||||
let pairs_whole =
|
||||
|v: &Value| [("inBlack", "inWhite"), ("outBlack", "outWhite")].iter().all(|(low, high)| jnum(v, low).is_some() == jnum(v, high).is_some());
|
||||
let whole = |v: &Value| shaped(v) && pairs_whole(v);
|
||||
whole(p) && CHANNELS.iter().filter_map(|(_, k)| p.get(*k).filter(|v| !v.is_null())).all(|v| v.is_object() && whole(v))
|
||||
}
|
||||
|
||||
pub(super) fn levels_to(p: &Value) -> Option<Descriptor> {
|
||||
let mut entries = vec![D::Descriptor(levels_entry(None, p))];
|
||||
for (_, k) in CHANNELS {
|
||||
@@ -395,6 +409,18 @@ macro_rules! adj_fields {
|
||||
};
|
||||
}
|
||||
|
||||
const VIBRANCE: &[F] = &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)];
|
||||
|
||||
/// Vibrance. Photoshop's recording also carries `temperature` and `tint`, which our Vibrance has
|
||||
/// no params for: with either one non-zero the step stays a kept Photoshop step (saved back
|
||||
/// unchanged) instead of playing and saving without them.
|
||||
fn vibrance_from(d: &Descriptor) -> Option<Value> {
|
||||
if ["temperature", "tint"].iter().any(|k| get_num(d, k).is_some_and(|v| v != 0.0)) {
|
||||
return None;
|
||||
}
|
||||
fields_from(d, VIBRANCE).map(Value::Object)
|
||||
}
|
||||
|
||||
const ADJUSTMENTS: &[Adjustment] = &[
|
||||
Adjustment { class: "Lvls", suffix: "levels", from: levels_from, to: levels_to },
|
||||
Adjustment { class: "Crvs", suffix: "curves", from: curves_from, to: curves_to },
|
||||
@@ -404,7 +430,7 @@ const ADJUSTMENTS: &[Adjustment] = &[
|
||||
adj_fields!("Pstr", "posterize", &[F("Lvls", "levels", Kind::Long)]),
|
||||
adj_fields!("Invr", "invert", &[]),
|
||||
Adjustment { class: "ClrB", suffix: "colorBalance", from: color_balance_from, to: color_balance_to },
|
||||
adj_fields!("Vbrn", "vibrance", &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)]),
|
||||
Adjustment { class: "vibrance", suffix: "vibrance", from: vibrance_from, to: |p| fields_to(p, VIBRANCE, desc("vibrance")) },
|
||||
adj_fields!("Exps", "exposure", &[F("Exps", "exposure", Kind::Dbl), F("Ofst", "offset", Kind::Dbl), F("gammaCorrection", "gamma", Kind::Dbl)]),
|
||||
Adjustment { class: "PhtF", suffix: "photoFilter", from: |d| fields_from(d, PHOTO_FILTER_FIELDS).map(Value::Object), to: photo_filter_to },
|
||||
adj_fields!("BanW", "blackWhite", BW),
|
||||
@@ -417,7 +443,9 @@ const ADJUSTMENTS: &[Adjustment] = &[
|
||||
/// Photoshop (#8481): a Make whose Black & White type is `BlnW` fails ("the command Make is not
|
||||
/// currently available"), `BanW` runs. Import keeps accepting them so actions saved before still
|
||||
/// load; export never writes them.
|
||||
const LEGACY_CLASSES: &[(&str, &str)] = &[("BlnW", "BanW")];
|
||||
/// `Vbrn` is a code PhotoCraft invented for Vibrance: Photoshop records the class by its string id
|
||||
/// `vibrance` (#8534), and refuses the `Vbrn` code (#8383).
|
||||
const LEGACY_CLASSES: &[(&str, &str)] = &[("BlnW", "BanW"), ("Vbrn", "vibrance")];
|
||||
|
||||
/// The adjustment with Photoshop class `c`, or the one an old PhotoCraft class stands for.
|
||||
pub(super) fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> {
|
||||
|
||||
@@ -23,15 +23,18 @@ pub struct Imported {
|
||||
pub function_keys: Vec<(String, String)>,
|
||||
pub unsupported: Vec<Unsupported>,
|
||||
pub warnings: Vec<String>,
|
||||
/// The encoding the file's 8-bit step names were read in (`"GBK"`, `"Shift_JIS"`,
|
||||
/// `"macintosh"`…); `None` when none needed one.
|
||||
pub encoding: Option<String>,
|
||||
}
|
||||
|
||||
fn photoshop_step(item: &AtnItem) -> (String, Value) {
|
||||
fn photoshop_step(item: &AtnItem, label: String) -> (String, Value) {
|
||||
let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes()));
|
||||
let event_kind = match &item.event {
|
||||
AtnEvent::Code(_) => "code",
|
||||
AtnEvent::Name(_) => "name",
|
||||
};
|
||||
(PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "eventKind": event_kind, "name": item.label(), "descriptor": descriptor}))
|
||||
(PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "eventKind": event_kind, "name": label, "descriptor": descriptor}))
|
||||
}
|
||||
|
||||
/// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings
|
||||
@@ -61,7 +64,7 @@ pub(super) fn clip_name(s: &str) -> String {
|
||||
s.chars().take(MAX_NAME_CHARS).collect()
|
||||
}
|
||||
|
||||
fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec<Unsupported>) -> Action {
|
||||
fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Vec<Unsupported>) -> Action {
|
||||
let name = clip_name(&a.name.to_string_lossy());
|
||||
let mut action = Action::new(name.clone(), Vec::new());
|
||||
action.set = Some(clip_name(set));
|
||||
@@ -74,8 +77,9 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec<Unsupported>)
|
||||
let (new_steps, enabled) = match mapped {
|
||||
Mapped::Steps(s) => (s, item.enabled),
|
||||
Mapped::Unsupported { harmless } => {
|
||||
unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.label() });
|
||||
(vec![photoshop_step(item)], item.enabled && !harmless)
|
||||
let label = file.item_label(item);
|
||||
unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: label.clone() });
|
||||
(vec![photoshop_step(item, label)], item.enabled && !harmless)
|
||||
}
|
||||
};
|
||||
for mut s in new_steps {
|
||||
@@ -96,12 +100,18 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec<Unsupported>)
|
||||
/// Convert a `.atn` file. Fails only when the file is unreadable: every step imports, as a
|
||||
/// command or as a kept Photoshop step.
|
||||
pub fn import(bytes: &[u8]) -> std::result::Result<Imported, String> {
|
||||
let file = atn::parse(bytes).map_err(|e| e.to_string())?;
|
||||
import_with_encoding(bytes, None)
|
||||
}
|
||||
|
||||
/// [`import`], reading 8-bit step names in `encoding` (a label such as `"shift_jis"` or
|
||||
/// `"gbk"`) instead of working it out. An unknown label fails.
|
||||
pub fn import_with_encoding(bytes: &[u8], encoding: Option<&str>) -> std::result::Result<Imported, String> {
|
||||
let file = atn::parse_with_encoding(bytes, encoding).map_err(|e| e.to_string())?;
|
||||
let set = file.name.to_string_lossy();
|
||||
let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { clip_name(&set) };
|
||||
let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() };
|
||||
let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), encoding: file.encoding.clone(), ..Default::default() };
|
||||
for a in &file.actions {
|
||||
let action = convert_action(a, &set, &mut out.unsupported);
|
||||
let action = convert_action(&file, a, &set, &mut out.unsupported);
|
||||
if a.function_key > 0 {
|
||||
out.function_keys.push((action.name.clone(), function_key_name(a.function_key, a.shift, a.command)));
|
||||
}
|
||||
@@ -205,7 +215,7 @@ pub fn export(
|
||||
skip_unsupported: bool,
|
||||
) -> std::result::Result<(Vec<u8>, Vec<Skipped>), String> {
|
||||
let mut skipped = Vec::new();
|
||||
let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new() };
|
||||
let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new(), ..Default::default() };
|
||||
for a in actions {
|
||||
let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, ..Default::default() };
|
||||
if let Some((n, shift, command)) = function_keys.iter().find(|(n, _)| n == &a.name).and_then(|(_, key)| parse_function_key(key)) {
|
||||
|
||||
@@ -8,8 +8,8 @@ use super::steps_doc::{
|
||||
place_from, place_to, profile_from, profile_to, purge_from, purge_to, reset_from, reset_to, save_from, save_to, stop_from, stop_to,
|
||||
};
|
||||
use super::steps_image::{
|
||||
GALLERY_IDS, REFINE_EDGE, RIPPLE_SIZE, SMART_BRUSH, apply_image_from, apply_image_to, color_range_from, color_range_to, gallery_from, gallery_to,
|
||||
has_refine_strokes, radial_blur_from, radial_blur_to, refine_edge_to, transform_from, transform_to,
|
||||
GALLERY_IDS, REFINE_EDGE, RIPPLE_SIZE, SMART_BRUSH, apply_image_from, apply_image_to, camera_raw_from, camera_raw_to, color_range_from, color_range_to,
|
||||
gallery_from, gallery_to, has_refine_strokes, radial_blur_from, radial_blur_to, refine_edge_to, transform_from, transform_to,
|
||||
};
|
||||
use super::steps_layer::{
|
||||
STROKE, apply_comp_from, apply_comp_to, clear_from, clear_to, delete_from, delete_to, duplicate_from, duplicate_to, fill_from, fill_to, flip_from, flip_to,
|
||||
@@ -251,6 +251,9 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]),
|
||||
ev!("Dfs ", "diffuse", "Diffuse", "filter.stylize.diffuse", &[F("Md ", "mode", Kind::Enum("DfsM", DIFFUSE_MODE)), F("FlRs", "seed", Kind::Long)]),
|
||||
Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to },
|
||||
// What Photoshop needs in a written step (process version, the full settings set) is
|
||||
// unverified, so only an imported step saves back, as its recorded descriptor while unedited.
|
||||
Event { code: "", name: "Adobe Camera Raw Filter", label: "Camera Raw Filter", ids: &[crate::lens_cmds::RAW], from: camera_raw_from, to: camera_raw_to },
|
||||
// Adjustments (Image › Adjustments).
|
||||
Event {
|
||||
code: "",
|
||||
@@ -275,7 +278,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
adjustment_event!("Pstr", "Pstr", "posterize", "Posterize"),
|
||||
adjustment_event!("Invr", "Invr", "invert", "Invert"),
|
||||
adjustment_event!("ClrB", "ClrB", "colorBalance", "Color Balance"),
|
||||
adjustment_event!("Vbrn", "Vbrn", "vibrance", "Vibrance"),
|
||||
adjustment_event!("Vbrn", "vibrance", "vibrance", "Vibrance"),
|
||||
adjustment_event!("Exps", "Exps", "exposure", "Exposure"),
|
||||
// Photoshop runs Photo Filter by name only: `PhtF` is the class, not an event (#8301).
|
||||
adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::convert::{UniqueNames, export, import};
|
||||
use super::convert::{UniqueNames, export, import_with_encoding};
|
||||
use super::{Action, DEFAULT_SET, EngineError, Result, Session, Value, bad, json};
|
||||
|
||||
pub(super) fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -6,8 +6,14 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.actions.recording.is_some() || s.actions.playing > 0 {
|
||||
return Err(bad(cmd, "stop recording or playing before loading actions"));
|
||||
}
|
||||
let encoding = match p.get("encoding") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(e)) if e == "auto" => None,
|
||||
Some(Value::String(e)) if !e.is_empty() => Some(e.as_str()),
|
||||
Some(_) => return Err(bad(cmd, "encoding must be \"auto\" or a label such as shift_jis, gbk, big5, euc-kr, windows-1252, macintosh")),
|
||||
};
|
||||
let (bytes, _) = crate::preset_import_cmds::file_bytes(p, cmd, ".atn file")?;
|
||||
let mut imp = import(&bytes).map_err(|e| bad(cmd, e))?;
|
||||
let mut imp = import_with_encoding(&bytes, encoding).map_err(|e| bad(cmd, e))?;
|
||||
match p.get("set") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(n)) if !n.trim().is_empty() => {
|
||||
@@ -59,6 +65,11 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
s.actions.list.extend(added);
|
||||
// A set saved with no actions (Photoshop saves an empty set) is an empty folder, like one New
|
||||
// Set made.
|
||||
if !s.actions.list.iter().any(|a| a.set.as_deref() == Some(imp.set.as_str())) && !s.actions.sets.contains(&imp.set) {
|
||||
s.actions.sets.push(imp.set.clone());
|
||||
}
|
||||
let keys: Vec<(String, String)> = imp.function_keys.into_iter().map(|(n, k)| (renamed.get(&n).cloned().unwrap_or(n), k)).collect();
|
||||
if !keys.is_empty() {
|
||||
s.edit_prefs(|prefs| {
|
||||
@@ -77,6 +88,7 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"unsupported": imp.unsupported,
|
||||
"functionKeys": keys.iter().map(|(n, k)| json!({"action": n, "key": k})).collect::<Vec<_>>(),
|
||||
"warnings": imp.warnings,
|
||||
"encoding": imp.encoding.as_deref().unwrap_or("UTF-8"),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -90,6 +102,9 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let set_param = match p.get("set") {
|
||||
None | Some(Value::Null) => None,
|
||||
// A set is found by its exact name, as `actions.record` finds it: one loaded from a
|
||||
// Photoshop file or an older actions.json may have surrounding spaces. Others are trimmed.
|
||||
Some(Value::String(n)) if crate::actions_edit_cmds::set_names(s).contains(n) => Some(n.clone()),
|
||||
Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()),
|
||||
Some(_) => return Err(bad(cmd, "`set` must be a non-empty string")),
|
||||
};
|
||||
@@ -99,16 +114,17 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(set_param.or_else(|| a.set.clone()).unwrap_or_else(|| a.name.clone()), vec![a])
|
||||
} else if let Some(set) = set_param {
|
||||
let list: Vec<&Action> = s.actions.list.iter().filter(|a| a.set.as_deref() == Some(set.as_str())).collect();
|
||||
if list.is_empty() {
|
||||
// An empty set New Set made is written with no actions, as Photoshop saves one.
|
||||
if list.is_empty() && !s.actions.sets.contains(&set) {
|
||||
return Err(bad(cmd, format!("no actions in a set named `{set}`")));
|
||||
}
|
||||
(set, list)
|
||||
} else {
|
||||
if s.actions.list.is_empty() {
|
||||
return Err(bad(cmd, "there are no actions to save"));
|
||||
}
|
||||
(DEFAULT_SET.to_string(), s.actions.list.iter().collect())
|
||||
};
|
||||
if actions.is_empty() {
|
||||
return Err(bad(cmd, "there are no actions to save"));
|
||||
}
|
||||
let names: Vec<String> = actions.iter().map(|a| a.name.clone()).collect();
|
||||
let keys: Vec<(String, String)> =
|
||||
s.prefs().shortcuts.iter().filter_map(|(k, v)| k.strip_prefix(crate::actions_cmds::PLAY_SHORTCUT_PREFIX).map(|n| (n.to_string(), v.clone()))).collect();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::descriptors::{
|
||||
PX, Target, ang, class, code, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, long, pct, px,
|
||||
ref_class, target, trimmed,
|
||||
PX, Target, ang, class, code, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, long, num,
|
||||
pct, px, ref_class, target, trimmed,
|
||||
};
|
||||
use super::events::pixel_target;
|
||||
use super::fields::{F, Kind, Mapped, fields_from, fields_to, step};
|
||||
@@ -249,6 +249,193 @@ pub(super) fn apply_image_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
|
||||
pub(super) const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")];
|
||||
|
||||
/// Camera Raw Filter's scalar sliders. Every one is an integer in recorded steps except `Ex12`
|
||||
/// and `ShpR` (doubles); the layout is from 62 recorded steps in 14 sets (#6745) and matches the
|
||||
/// verified PSD Smart Filter mapping (`photocraft-io`'s `camera_raw_map.rs`).
|
||||
const CAMERA_RAW: &[F] = &[
|
||||
F("Temp", "temperature", Kind::Long),
|
||||
F("Tint", "tint", Kind::Long),
|
||||
F("Ex12", "exposure", Kind::Dbl),
|
||||
F("Cr12", "contrast", Kind::Long),
|
||||
F("Hi12", "highlights", Kind::Long),
|
||||
F("Sh12", "shadows", Kind::Long),
|
||||
F("Wh12", "whites", Kind::Long),
|
||||
F("Bk12", "blacks", Kind::Long),
|
||||
F("CrTx", "texture", Kind::Long),
|
||||
F("Cl12", "clarity", Kind::Long),
|
||||
F("Dhze", "dehaze", Kind::Long),
|
||||
F("Vibr", "vibrance", Kind::Long),
|
||||
F("Strt", "saturation", Kind::Long),
|
||||
F("PC_H", "curveHighlights", Kind::Long),
|
||||
F("PC_L", "curveLights", Kind::Long),
|
||||
F("PC_D", "curveDarks", Kind::Long),
|
||||
F("PC_S", "curveShadows", Kind::Long),
|
||||
F("CgBl", "gradeBlending", Kind::Long),
|
||||
F("STB ", "gradeBalance", Kind::Long),
|
||||
F("Shrp", "sharpenAmount", Kind::Long),
|
||||
F("ShpR", "sharpenRadius", Kind::Dbl),
|
||||
F("ShpD", "sharpenDetail", Kind::Long),
|
||||
F("ShpM", "sharpenMasking", Kind::Long),
|
||||
F("LNR ", "noiseLuminance", Kind::Long),
|
||||
F("LNRD", "noiseLuminanceDetail", Kind::Long),
|
||||
F("CNR ", "noiseColor", Kind::Long),
|
||||
F("CNRD", "noiseColorDetail", Kind::Long),
|
||||
F("GRNA", "grainAmount", Kind::Long),
|
||||
F("GRNS", "grainSize", Kind::Long),
|
||||
F("GRNF", "grainRoughness", Kind::Long),
|
||||
F("PCVA", "vignetteAmount", Kind::Long),
|
||||
F("PCVM", "vignetteMidpoint", Kind::Long),
|
||||
F("PCVR", "vignetteRoundness", Kind::Long),
|
||||
F("PCVF", "vignetteFeather", Kind::Long),
|
||||
F("PCVH", "vignetteHighlights", Kind::Long),
|
||||
];
|
||||
|
||||
/// The range a Camera Raw Filter descriptor key's value can take, as `filter.cameraRaw` and the
|
||||
/// PSD Smart Filter mapping (`photocraft-io`'s `camera_raw_map.rs`) accept it.
|
||||
fn camera_raw_range(key: &str) -> std::ops::RangeInclusive<f64> {
|
||||
match key {
|
||||
"Ex12" => -5.0..=5.0,
|
||||
"ShpR" => 0.5..=3.0,
|
||||
"Shrp" => 0.0..=150.0,
|
||||
"STSH" | "STHH" | "CgMH" | "CgGH" => 0.0..=360.0,
|
||||
"PC_1" | "PC_2" | "PC_3" | "ShpD" | "ShpM" | "LNR " | "LNRD" | "CNR " | "CNRD" | "STSS" | "STHS" | "CgMS" | "CgGS" | "CgBl" | "GRNA" | "GRNS"
|
||||
| "GRNF" | "PCVM" | "PCVF" | "PCVH" => 0.0..=100.0,
|
||||
_ => -100.0..=100.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The hidden param an imported Camera Raw Filter step keeps its recorded descriptor in, so
|
||||
/// Save Actions writes the step back unchanged while its settings are the ones it imported with.
|
||||
const CAMERA_RAW_DESCRIPTOR: &str = "__atnDescriptor";
|
||||
|
||||
/// The colour grading wheels' keys, the tone curve splits and the vignette style.
|
||||
const CAMERA_RAW_GRADES: &[&str] =
|
||||
&["STSH", "STSS", "CgSL", "CgMH", "CgMS", "CgML", "STHH", "STHS", "CgHL", "CgGH", "CgGS", "CgGL", "PC_1", "PC_2", "PC_3", "PCVS"];
|
||||
|
||||
/// A recorded Camera Raw Filter step (`Adobe Camera Raw Filter`) as `filter.cameraRaw` (#6745).
|
||||
/// Settings the command has no counterpart for refuse the step rather than replaying a different
|
||||
/// look: the Calibration panel, a white balance other than Custom, the controls the PSD mapping
|
||||
/// keeps only at their neutral values, and any key outside this table (lens, geometry, profile,
|
||||
/// masks…), as the PSD mapping's `known_key` does, and a process version the PSD mapping hasn't
|
||||
/// verified (Camera Raw renders each version differently).
|
||||
pub(super) fn camera_raw_from(d: &Descriptor) -> Mapped {
|
||||
const CALIBRATION: &[&str] = &["RHue", "RSat", "GHue", "GSat", "BHue", "BSat", "BlkB"];
|
||||
const NEUTRAL_ONLY: &[(&str, f64)] =
|
||||
&[("LNRC", 0.0), ("CNRS", 50.0), ("MDis", 0.0), ("VigA", 0.0), ("VigM", 50.0), ("crfs", 100.0), ("TMMs", 0.0), ("PGTM", 0.0), ("RGBt", 0.0)];
|
||||
const OTHER: &[&str] = &["CrVe", "PrVe", "PrVN", "WBal", "Crv ", "CrvR", "CrvG", "CrvB"];
|
||||
let known = |k: &str| {
|
||||
(k.len() == 4 && ["HA_", "SA_", "LA_"].iter().any(|p| k.starts_with(p)) && ["R", "O", "Y", "G", "A", "B", "P", "M"].iter().any(|c| k.ends_with(c)))
|
||||
|| CAMERA_RAW.iter().any(|F(f, ..)| *f == k)
|
||||
|| CAMERA_RAW_GRADES.contains(&k)
|
||||
|| OTHER.contains(&k)
|
||||
|| CALIBRATION.contains(&k)
|
||||
|| NEUTRAL_ONLY.iter().any(|(n, _)| *n == k)
|
||||
};
|
||||
// An empty step (no descriptor) would play as a no-op, and a key the table doesn't know may
|
||||
// be active processing (lens, geometry, profile, masks) that playback would leave out.
|
||||
if d.items.is_empty() || d.items.iter().any(|(k, _)| !std::str::from_utf8(k.as_bytes()).is_ok_and(known)) {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
let version = (get_num(d, "PrVN"), get_num(d, "PrVe"));
|
||||
let verified = [(5.0, 184_549_376.0), (6.0, 251_920_384.0)].iter().any(|(n, v)| version == (Some(*n), Some(*v)));
|
||||
if !verified || !matches!(d.get("CrVe"), Some(D::Text(_))) {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
let active = |k: &str, neutral: f64| d.get(k).is_some() && get_num(d, k) != Some(neutral);
|
||||
let custom_wb = d.get("WBal").is_none() || get_enum(d, "WBal").as_deref() == Some("Cst");
|
||||
if !custom_wb || CALIBRATION.iter().any(|k| active(k, 0.0)) || NEUTRAL_ONLY.iter().any(|(k, n)| active(k, *n)) {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
// A value that is recorded but isn't a number, or lies outside the slider's range, can't
|
||||
// replay: `fields_from` and `nums` would drop it (or read it as 0), and playback would clamp
|
||||
// it, so the step would play a different look. The ranges are the PSD mapping's.
|
||||
let unreadable = |k: &str| match (d.get(k), get_num(d, k)) {
|
||||
(None, _) => false,
|
||||
(Some(_), None) => true,
|
||||
(Some(_), Some(v)) => k != "PCVS" && !(v.is_finite() && camera_raw_range(k).contains(&v)),
|
||||
};
|
||||
let grouped = ["HA_", "SA_", "LA_"].iter().flat_map(|p| ["R", "O", "Y", "G", "A", "B", "P", "M"].map(|c| format!("{p}{c}")));
|
||||
if CAMERA_RAW.iter().any(|F(k, ..)| unreadable(k)) || grouped.into_iter().any(|k| unreadable(&k)) || CAMERA_RAW_GRADES.iter().any(|k| unreadable(k)) {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
let Some(mut m) = fields_from(d, CAMERA_RAW) else { return Mapped::Unsupported { harmless: false } };
|
||||
let nums = |keys: &[&str]| -> Option<Vec<Value>> {
|
||||
let v: Vec<Option<f64>> = keys.iter().map(|k| get_num(d, k)).collect();
|
||||
// Absent members keep the command's defaults only when the whole group is absent.
|
||||
if v.iter().all(Option::is_none) { None } else { Some(v.into_iter().map(|n| Value::from(n.unwrap_or(0.0))).collect()) }
|
||||
};
|
||||
// The splits are one setting: all three, or none (the defaults); a partial set can't replay,
|
||||
// nor can splits outside 0..=100 or out of order, which would build a different curve.
|
||||
match (get_num(d, "PC_1"), get_num(d, "PC_2"), get_num(d, "PC_3")) {
|
||||
(Some(a), Some(b), Some(c)) if (0.0..=100.0).contains(&a) && a < b && b < c && c <= 100.0 => {
|
||||
m.insert("curveSplits".into(), json!([a, b, c]));
|
||||
}
|
||||
(Some(_), Some(_), Some(_)) => return Mapped::Unsupported { harmless: false },
|
||||
_ if ["PC_1", "PC_2", "PC_3"].iter().any(|k| d.get(k).is_some()) => return Mapped::Unsupported { harmless: false },
|
||||
_ => {}
|
||||
}
|
||||
for (prefix, param) in [("HA_", "hslHue"), ("SA_", "hslSat"), ("LA_", "hslLum")] {
|
||||
let keys: Vec<String> = ["R", "O", "Y", "G", "A", "B", "P", "M"].iter().map(|c| format!("{prefix}{c}")).collect();
|
||||
if let Some(v) = nums(&keys.iter().map(String::as_str).collect::<Vec<_>>()) {
|
||||
m.insert(param.into(), Value::Array(v));
|
||||
}
|
||||
}
|
||||
for (keys, param) in [
|
||||
(["STSH", "STSS", "CgSL"], "gradeShadows"),
|
||||
(["CgMH", "CgMS", "CgML"], "gradeMidtones"),
|
||||
(["STHH", "STHS", "CgHL"], "gradeHighlights"),
|
||||
(["CgGH", "CgGS", "CgGL"], "gradeGlobal"),
|
||||
] {
|
||||
if let Some(v) = nums(&keys) {
|
||||
m.insert(param.into(), json!({"hue": v[0], "sat": v[1], "lum": v[2]}));
|
||||
}
|
||||
}
|
||||
match get_num(d, "PCVS") {
|
||||
None => {}
|
||||
Some(n) => match [(1.0, "highlightPriority"), (2.0, "colorPriority"), (3.0, "paintOverlay")].iter().find(|(k, _)| *k == n) {
|
||||
Some((_, style)) => {
|
||||
m.insert("vignetteStyle".into(), json!(style));
|
||||
}
|
||||
None => return Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
}
|
||||
// Point curves: flat `[in0, out0, in1, out1, …]` lists on 0..255, as the command's pairs.
|
||||
for (key, param) in [("Crv ", "pointCurve"), ("CrvR", "pointCurveRed"), ("CrvG", "pointCurveGreen"), ("CrvB", "pointCurveBlue")] {
|
||||
if d.get(key).is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(list) = get_list(d, key) else { return Mapped::Unsupported { harmless: false } };
|
||||
let Some(flat) = list.iter().map(num).collect::<Option<Vec<f64>>>() else { return Mapped::Unsupported { harmless: false } };
|
||||
// Odd lengths, and curves `filter.cameraRaw` refuses (one point, out of range, inputs not
|
||||
// increasing), stay Photoshop steps rather than failing on playback.
|
||||
let points: Vec<[f32; 2]> = flat.as_chunks::<2>().0.iter().map(|[a, b]| [*a as f32, *b as f32]).collect();
|
||||
if flat.len() % 2 != 0 || photocraft_algo::camera_raw::validate_curve(&points).is_err() {
|
||||
return Mapped::Unsupported { harmless: false };
|
||||
}
|
||||
let identity = flat == [0.0, 0.0, 255.0, 255.0];
|
||||
if !identity {
|
||||
m.insert(param.into(), Value::Array(flat.as_chunks::<2>().0.iter().map(|[a, b]| json!([a, b])).collect()));
|
||||
}
|
||||
}
|
||||
m.insert(CAMERA_RAW_DESCRIPTOR.into(), json!(photocraft_paint::tile::b64_encode(&d.to_bytes())));
|
||||
step(crate::lens_cmds::RAW, Value::Object(m))
|
||||
}
|
||||
|
||||
/// An imported Camera Raw Filter step back as its recorded descriptor, unchanged. Only while the
|
||||
/// params are still what that descriptor imports as: an edited step, or one PhotoCraft recorded,
|
||||
/// has no verified Photoshop form and Save Actions refuses it.
|
||||
pub(super) fn camera_raw_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
let d = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, CAMERA_RAW_DESCRIPTOR)?)?).ok()?;
|
||||
let Mapped::Steps(steps) = camera_raw_from(&d) else { return None };
|
||||
let [(_, imported)] = steps.as_slice() else { return None };
|
||||
let (mut a, mut b) = (imported.clone(), p.clone());
|
||||
for v in [&mut a, &mut b] {
|
||||
super::convert::normalise(v);
|
||||
v.as_object_mut()?.remove(CAMERA_RAW_DESCRIPTOR);
|
||||
}
|
||||
(a == b).then_some(d)
|
||||
}
|
||||
|
||||
/// Whether a Select and Mask step carries Refine Edge Brush strokes or the workspace's tool
|
||||
/// edits (Quick Selection, Brush, Lasso…), which no descriptor holds.
|
||||
pub(super) fn has_refine_strokes(p: &Value) -> bool {
|
||||
|
||||
@@ -499,6 +499,11 @@ pub(super) fn stroke_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
}
|
||||
let mut params = if p.is_null() { Map::new() } else { p.as_object()?.clone() };
|
||||
params.insert("width".into(), Value::from(crate::extra_cmds::stroke_width(p)));
|
||||
// Photoshop opens the Stroke dialog for a descriptor that leaves out a setting, even with
|
||||
// dialogs off (#8303), so the command's defaults for the ones left out are written too.
|
||||
for (key, default) in [("location", json!("center")), ("opacity", json!(100)), ("mode", json!("Normal")), ("preserveTransparency", json!(false))] {
|
||||
params.entry(key).or_insert(default);
|
||||
}
|
||||
fields_to(&Value::Object(params), STROKE, desc("null"))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::adjustments::{HUE_RANGES, adjustment_by_class, adjustment_by_suffix, adjustment_steps};
|
||||
use super::adjustments::{HUE_RANGES, adjustment_by_class, adjustment_by_suffix, adjustment_steps, levels_complete};
|
||||
use super::descriptors::{
|
||||
PX, Target, ang, class, class_ref, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, get_text, jbool, jnum,
|
||||
jstr, layer_ref, layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed,
|
||||
@@ -9,14 +9,23 @@ use super::tables::{LABEL_COLORS, MODES, blend_label, blend_ps, lookup, lookup_b
|
||||
use super::{D, Descriptor, Id, Map, ReferenceItem, Value, json};
|
||||
|
||||
/// A `patternLayer` descriptor's pattern, scale, angle and link as the params of
|
||||
/// `layer.newFillLayer.pattern` / `layer.fill.pattern.set`. The pattern goes by name: ids differ
|
||||
/// between Photoshop's library and one rebuilt from the same `.pat` file. None for a pattern
|
||||
/// recorded without a name or id.
|
||||
/// `layer.newFillLayer.pattern` / `layer.fill.pattern.set`. A pattern recorded with both an id
|
||||
/// and a name goes by the id with the name as `patternName`: ids differ between Photoshop's
|
||||
/// library and one rebuilt from the same `.pat` file, while names may collide. None for a
|
||||
/// pattern recorded without a name or id.
|
||||
fn pattern_params(t: &Descriptor) -> Option<Map<String, Value>> {
|
||||
let mut p = Map::new();
|
||||
if let Some(ptrn) = get_obj(t, "Ptrn") {
|
||||
let key = get_text(ptrn, "Nm ").filter(|n| !n.is_empty()).or_else(|| get_text(ptrn, "Idnt").filter(|i| !i.is_empty()))?;
|
||||
p.insert("pattern".into(), key.into());
|
||||
let name = get_text(ptrn, "Nm ").filter(|n| !n.is_empty());
|
||||
match (get_text(ptrn, "Idnt").filter(|i| !i.is_empty()), name) {
|
||||
(Some(id), Some(name)) => {
|
||||
p.insert("pattern".into(), id.into());
|
||||
p.insert("patternName".into(), name.into());
|
||||
}
|
||||
(key, name) => {
|
||||
p.insert("pattern".into(), key.or(name)?.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = get_num(t, "Scl ") {
|
||||
p.insert("scale".into(), s.into());
|
||||
@@ -33,8 +42,10 @@ fn pattern_params(t: &Descriptor) -> Option<Map<String, Value>> {
|
||||
/// The `patternLayer` descriptor for [`pattern_params`]' params.
|
||||
fn pattern_desc(p: &Value) -> Option<Descriptor> {
|
||||
let mut t = desc("patternLayer");
|
||||
if let Some(n) = jstr(p, "pattern") {
|
||||
t = t.with("Ptrn", D::Descriptor(desc("Ptrn").with("Nm ", text(n))));
|
||||
match (jstr(p, "pattern"), jstr(p, "patternName")) {
|
||||
(Some(id), Some(n)) => t = t.with("Ptrn", D::Descriptor(desc("Ptrn").with("Nm ", text(n)).with("Idnt", text(id)))),
|
||||
(Some(n), None) | (None, Some(n)) => t = t.with("Ptrn", D::Descriptor(desc("Ptrn").with("Nm ", text(n)))),
|
||||
(None, None) => {}
|
||||
}
|
||||
if let Some(s) = jnum(p, "scale") {
|
||||
t = t.with("Scl ", pct(s));
|
||||
@@ -757,12 +768,19 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
desc("null").with("null", r).with("filterFX", D::Descriptor(desc("filterFX").with("blendOptions", D::Descriptor(opts))))
|
||||
}
|
||||
"layer.setAdjustment" => {
|
||||
// The event edits the current adjustment layer: a step that names its layer or
|
||||
// document would play back on whichever one is active instead (#8532). A null
|
||||
// `layer` or `document` is the target one, as the command reads it.
|
||||
if ["layer", "document"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return None;
|
||||
}
|
||||
// The adjustment kind isn't in the params: Photoshop needs it, so only steps that
|
||||
// name one (`kind`) export.
|
||||
let kind = jstr(p, "kind")?;
|
||||
// `layer.setAdjustment` changes only the settings it names. Color Balance and
|
||||
// Hue/Saturation descriptors carry every setting, filling absent ones with defaults,
|
||||
// so a partial edit would replay as one that also resets the rest (#8531).
|
||||
// so a partial edit would replay as one that also resets the rest (#8531). Levels writes
|
||||
// each range whole, so one end alone resets the other (#8530).
|
||||
// Present and of the shape the writer reads: anything else it would write as 0.
|
||||
let number = |p: &Value, k: &str| p.get(k).and_then(Value::as_f64).is_some_and(f64::is_finite);
|
||||
let numbers = |p: &Value, keys: &[&str]| keys.iter().all(|k| number(p, k));
|
||||
@@ -784,6 +802,8 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
r.is_object() && numbers(r, &["hue", "saturation", "lightness"]) && bounds
|
||||
})
|
||||
}
|
||||
// Both ends of each range it names (#8530).
|
||||
"levels" => levels_complete(p),
|
||||
_ => true,
|
||||
};
|
||||
if !complete {
|
||||
|
||||
@@ -45,6 +45,7 @@ fn layer_steps_with_string_id_blend_modes_import_with_their_mode() {
|
||||
..Default::default()
|
||||
}],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let imp = import(&atn::write(&file).unwrap()).unwrap();
|
||||
assert!(imp.unsupported.is_empty(), "{:?}", imp.unsupported.iter().map(|u| &u.event).collect::<Vec<_>>());
|
||||
@@ -116,6 +117,7 @@ fn sample() -> AtnFile {
|
||||
AtnAction { name: UnicodeString::new_nul("Finish"), items: vec![item("Invs", "Inverse", None)], ..Default::default() },
|
||||
],
|
||||
warnings: Vec::new(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,7 +430,8 @@ fn a_selective_color_step_in_the_scalar_form_keeps_its_values() {
|
||||
|
||||
/// A setting Photoshop's event has no field for is dropped by export, and the step then plays back
|
||||
/// as a different edit: Crystallize's `seed` (#7668), Radial Blur's centre, Minimum's and
|
||||
/// Maximum's `preserve`. Such a step is refused, as one with no equivalent event already is.
|
||||
/// Maximum's `preserve`. Such a step is refused, as one with no equivalent event already is. So is
|
||||
/// an adjustment edit that names its layer: the event edits the active one (#8532).
|
||||
#[test]
|
||||
fn a_setting_the_event_cannot_hold_is_refused() {
|
||||
for (id, params) in [
|
||||
@@ -436,6 +439,8 @@ fn a_setting_the_event_cannot_hold_is_refused() {
|
||||
("filter.blur.radialBlur", json!({"amount": 10, "centerX": 0.25, "centerY": 0.5})),
|
||||
("filter.other.minimum", json!({"radius": 4, "preserve": "roundness"})),
|
||||
("filter.other.maximum", json!({"radius": 4, "preserve": "roundness"})),
|
||||
("layer.setAdjustment", json!({"kind": "exposure", "layer": "Target_existing", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})),
|
||||
("layer.setAdjustment", json!({"kind": "exposure", "document": 2, "exposure": -0.75})),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.actions.list.push(Action::new("A", vec![(id.into(), params)]));
|
||||
@@ -460,6 +465,9 @@ fn settings_at_the_value_the_event_plays_still_export() {
|
||||
("filter.blur.radialBlur", json!({"amount": 10})),
|
||||
("filter.other.minimum", json!({"radius": 4, "preserve": "squareness"})),
|
||||
("filter.other.maximum", json!({"radius": 4})),
|
||||
("layer.setAdjustment", json!({"kind": "exposure", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})),
|
||||
("layer.setAdjustment", json!({"kind": "exposure", "layer": null, "exposure": -0.75})),
|
||||
("layer.setAdjustment", json!({"kind": "exposure", "document": null, "exposure": -0.75})),
|
||||
// `preserve` reaches the filter through a comparison with the exact string "roundness",
|
||||
// so these play as squareness -- replay's own value -- and lose nothing by export (#7759
|
||||
// review).
|
||||
@@ -659,6 +667,7 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("select.loadSelection", json!({"channel": "targeted"})),
|
||||
("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})),
|
||||
("layer.fill.pattern.set", json!({"pattern": "1 -R2.jpg"})),
|
||||
("layer.newFillLayer.pattern", json!({"pattern": "b7a3c1d2-0000-4000-8000-000000000001", "patternName": "Twin", "scale": 50})),
|
||||
("image.mode.bitmap", json!({"method": "threshold"})),
|
||||
("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})),
|
||||
("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})),
|
||||
@@ -774,6 +783,8 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
"filter.sharpen.sharpen",
|
||||
"filter.sharpen.sharpenEdges",
|
||||
"filter.sharpen.sharpenMore",
|
||||
// Saves back only as the descriptor it was loaded from (loaded_camera_raw_filter_steps_save_back_unchanged).
|
||||
"filter.cameraRaw",
|
||||
];
|
||||
let unexpected: Vec<&str> = missing.iter().copied().filter(|id| !allowed.contains(id)).collect();
|
||||
assert!(unexpected.is_empty(), "add round-trip cases for {unexpected:?}");
|
||||
@@ -836,6 +847,27 @@ fn bucket_keeps_an_explicit_color_through_atn() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Stroke step that leaves settings at their defaults still writes them (#8303): Photoshop
|
||||
/// opens the Stroke dialog for a descriptor without its blend mode, even with dialogs off.
|
||||
#[test]
|
||||
fn stroke_writes_the_defaults_it_leaves_out() {
|
||||
let exported = |params: Value| export_item("edit.stroke", ¶ms, "S").and_then(|item| item.descriptor).unwrap();
|
||||
let d = exported(json!({"width": 2, "location": "inside", "color": "#d03060", "opacity": 63}));
|
||||
assert_eq!(get_enum(&d, "Md ").as_deref(), Some("Nrml"), "{d:?}");
|
||||
assert_eq!(get_enum(&d, "Lctn").as_deref(), Some("Insd"), "an explicit setting is kept");
|
||||
assert_eq!(get_num(&d, "Opct"), Some(63.0));
|
||||
let d = exported(json!({"width": 2}));
|
||||
assert_eq!(get_enum(&d, "Lctn").as_deref(), Some("Cntr"), "{d:?}");
|
||||
assert_eq!(get_num(&d, "Opct"), Some(100.0));
|
||||
assert_eq!(get_enum(&d, "Md ").as_deref(), Some("Nrml"));
|
||||
assert!(get_bool(&d, "PrsT") == Some(false), "{d:?}");
|
||||
// An explicit mode is kept, and the step imports back to the same stroke.
|
||||
let p = json!({"width": 3, "location": "outside", "mode": "Multiply", "opacity": 40, "color": "#000000", "preserveTransparency": true});
|
||||
let d = exported(p.clone());
|
||||
let Mapped::Steps(steps) = (event_for(&AtnEvent::Code(*b"Strk")).unwrap().from)(&d) else { panic!("{d:?}") };
|
||||
assert_eq!(steps[0].1, json!({"width": 3.0, "location": "outside", "mode": "Multiply", "opacity": 40.0, "color": "#000000", "preserveTransparency": true}));
|
||||
}
|
||||
|
||||
/// `image.adjustments.photoFilter` exported to .atn and imported back → its params, or None.
|
||||
fn photo_filter_round_trip(params: Value) -> Option<Value> {
|
||||
let item = export_item("image.adjustments.photoFilter", ¶ms, "S")?;
|
||||
@@ -962,6 +994,7 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let imp = import(&atn::write(&file).unwrap()).unwrap();
|
||||
let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect();
|
||||
@@ -985,6 +1018,7 @@ fn save_preserves_the_kind_of_unsupported_atn_events() {
|
||||
..Default::default()
|
||||
}],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = Session::new();
|
||||
session.execute("actions.load", json!({"data": b64(&atn::write(&source).unwrap())})).unwrap();
|
||||
@@ -1060,6 +1094,7 @@ fn retouching_set_events_map_to_their_commands() {
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let imp = import(&atn::write(&file).unwrap()).unwrap();
|
||||
let steps = &imp.actions[0].steps;
|
||||
@@ -1352,6 +1387,7 @@ fn remaining_events_import_in_their_photoshop_shapes() {
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let imp = import(&atn::write(&file).unwrap()).unwrap();
|
||||
let steps = &imp.actions[0].steps;
|
||||
@@ -1440,6 +1476,7 @@ fn remaining_events_import_in_their_photoshop_shapes() {
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let rest = import(&atn::write(&file).unwrap()).unwrap();
|
||||
let rest_steps = &rest.actions[0].steps;
|
||||
@@ -1478,6 +1515,7 @@ fn remaining_events_import_in_their_photoshop_shapes() {
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let more = import(&atn::write(&file).unwrap()).unwrap();
|
||||
let more_steps = &more.actions[0].steps;
|
||||
@@ -1596,6 +1634,7 @@ fn set_of(name: &str, actions: Vec<(&str, Vec<AtnItem>)>) -> String {
|
||||
expanded: false,
|
||||
actions: actions.into_iter().map(|(n, items)| AtnAction { name: UnicodeString::new_nul(n), items, ..Default::default() }).collect(),
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
b64(&atn::write(&file).unwrap())
|
||||
}
|
||||
@@ -2280,6 +2319,54 @@ fn partial_color_balance_and_hue_saturation_edits_refuse_strict_export() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels, like Color Balance and Hue/Saturation: the descriptor writes both ends of the Input and
|
||||
/// of the Output range, filling the end an edit did not name with 0 or 255, so replaying
|
||||
/// `inBlack: 31` alone would also reset a retained Input White (#8530). Strict export refuses it;
|
||||
/// a pair given whole, or none of it, still exports and round-trips.
|
||||
#[test]
|
||||
fn partial_levels_edits_refuse_strict_export() {
|
||||
let one = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.setAdjustment".into(), p)], ..Default::default() };
|
||||
for p in [
|
||||
json!({"kind": "levels", "inBlack": 31}),
|
||||
json!({"kind": "levels", "inWhite": 239}),
|
||||
json!({"kind": "levels", "outBlack": 10}),
|
||||
json!({"kind": "levels", "outWhite": 240}),
|
||||
json!({"kind": "levels", "inBlack": 31, "inWhite": 239, "outWhite": 240}),
|
||||
json!({"kind": "levels", "red": {"inBlack": 2}}),
|
||||
json!({"kind": "levels", "inBlack": 1, "inWhite": 250, "blue": {"outBlack": 3}}),
|
||||
// A value of the wrong shape is no value: the other end is then alone.
|
||||
json!({"kind": "levels", "inBlack": 31, "inWhite": "239"}),
|
||||
// CodeRabbit on batch 11: both ends of the wrong shape, or a channel that isn't an object,
|
||||
// would be written as nothing too.
|
||||
json!({"kind": "levels", "inBlack": "31", "inWhite": "239"}),
|
||||
json!({"kind": "levels", "gamma": "1.2"}),
|
||||
json!({"kind": "levels", "red": 5}),
|
||||
json!({"kind": "levels", "green": [0, 255]}),
|
||||
json!({"kind": "levels", "blue": {"outBlack": "3", "outWhite": "250"}}),
|
||||
] {
|
||||
let err = export("Audit8", &[&one(p.clone())], &[], false).unwrap_err();
|
||||
assert!(err.contains("`layer.setAdjustment`"), "{p}: {err}");
|
||||
let (_, skipped) = export("Audit8", &[&one(p.clone())], &[], true).unwrap();
|
||||
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.setAdjustment".into() }], "{p}");
|
||||
}
|
||||
for p in [
|
||||
json!({"kind": "levels", "inBlack": 31, "inWhite": 239}),
|
||||
json!({"kind": "levels", "outBlack": 10, "outWhite": 240}),
|
||||
json!({"kind": "levels", "inBlack": 31, "inWhite": 239, "outBlack": 10, "outWhite": 240, "gamma": 1.2}),
|
||||
json!({"kind": "levels", "gamma": 0.8}),
|
||||
json!({"kind": "levels", "inBlack": 1, "inWhite": 250, "red": {"inBlack": 2, "inWhite": 200}}),
|
||||
] {
|
||||
let (bytes, skipped) = export("Audit8", &[&one(p.clone())], &[], false).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(skipped.is_empty(), "{p}");
|
||||
let steps = import(&bytes).unwrap().actions[0].steps.clone();
|
||||
let (id, back) = steps.first().unwrap();
|
||||
assert_eq!(id, "layer.setAdjustment");
|
||||
for k in ["inBlack", "inWhite", "outBlack", "outWhite"] {
|
||||
assert_eq!(back.get(k), p.get(k), "{p}: {k} in {back}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's Close has no document target: a Close of a specific document must not be
|
||||
/// exported as a plain Close, which would close the active document on playback.
|
||||
#[test]
|
||||
@@ -2306,6 +2393,7 @@ fn fractional_translation_only_transform_keeps_offset_and_interpolation() {
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("transform", "Transform", Some(d))], ..Default::default() }],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let imp = import(&atn::write(&file).unwrap()).unwrap();
|
||||
assert!(imp.unsupported.is_empty());
|
||||
@@ -2718,12 +2806,105 @@ fn adjustment_layer_reset_still_resets() {
|
||||
..Default::default()
|
||||
}],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let steps = import(&atn::write(&file).unwrap()).unwrap().actions.remove(0).steps;
|
||||
assert_eq!(steps[0], ("layer.setAdjustment".to_string(), json!({})));
|
||||
assert_eq!(steps[1].1, json!({"midtones": [10, 0, 0], "kind": "colorBalance"}));
|
||||
}
|
||||
|
||||
/// Photoshop's Properties panel records an edit of an existing Vibrance layer with the string
|
||||
/// class `vibrance`, not the `Vbrn` code: both import as the same `layer.setAdjustment` (#8534).
|
||||
#[test]
|
||||
fn adjustment_layer_set_accepts_the_vibrance_string_class() {
|
||||
let set = |class: &str| {
|
||||
let t = desc(class).with("temperature", long(0.0)).with("tint", long(0.0)).with("vibrance", long(28.0)).with("Strt", long(-7.0));
|
||||
desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t))
|
||||
};
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Vibrance"),
|
||||
expanded: false,
|
||||
actions: vec![AtnAction {
|
||||
name: UnicodeString::new_nul("A"),
|
||||
items: vec![item("set", "Set", Some(set("vibrance"))), item("set", "Set", Some(set("Vbrn")))],
|
||||
..Default::default()
|
||||
}],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let action = import(&atn::write(&file).unwrap()).unwrap().actions.remove(0);
|
||||
assert_eq!(action.steps[0].0, "layer.setAdjustment", "{:?}", action.steps);
|
||||
assert_eq!(action.steps[0].1, json!({"vibrance": 28, "saturation": -7, "kind": "vibrance"}));
|
||||
assert_eq!(action.steps[0], action.steps[1]);
|
||||
}
|
||||
|
||||
/// A Vibrance Set and Make as Photoshop records them (class `vibrance`) save back with that
|
||||
/// class, not the `Vbrn` code Photoshop refuses (#8383); old PhotoCraft files with `Vbrn` still
|
||||
/// load and now save with `vibrance` too.
|
||||
#[test]
|
||||
fn vibrance_adjustment_layer_steps_save_with_the_string_class() {
|
||||
let set = |class: &str| {
|
||||
let t = desc(class).with("temperature", long(0.0)).with("tint", long(0.0)).with("vibrance", long(28.0)).with("Strt", long(-7.0));
|
||||
desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t))
|
||||
};
|
||||
let make = |class: &str| {
|
||||
let t = desc(class).with("vibrance", long(10.0)).with("Strt", long(5.0));
|
||||
desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(t))))
|
||||
};
|
||||
let data = set_of(
|
||||
"Vibrance",
|
||||
vec![(
|
||||
"A",
|
||||
vec![
|
||||
item("set", "Set", Some(set("vibrance"))),
|
||||
item("Mk ", "Make", Some(make("vibrance"))),
|
||||
item("set", "Set", Some(set("Vbrn"))),
|
||||
item("Mk ", "Make", Some(make("Vbrn"))),
|
||||
],
|
||||
)],
|
||||
);
|
||||
let mut s = Session::new();
|
||||
let loaded = s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
|
||||
let steps = &s.actions.list[0].steps;
|
||||
assert_eq!(steps[1], ("layer.newAdjustmentLayer.vibrance".to_string(), json!({"vibrance": 10, "saturation": 5})), "{steps:?}");
|
||||
assert_eq!((&steps[0], &steps[1]), (&steps[2], &steps[3]), "the old `Vbrn` class imports the same");
|
||||
let saved = s.execute("actions.save", json!({"set": "Vibrance"})).unwrap();
|
||||
assert_eq!(saved["skipped"], json!([]));
|
||||
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
|
||||
let file = atn::parse(&bytes).unwrap();
|
||||
let items = &file.actions[0].items;
|
||||
assert_eq!(items.len(), 4);
|
||||
for pair in items.chunks(2) {
|
||||
let t = get_obj(pair[0].descriptor.as_ref().unwrap(), "T ").unwrap();
|
||||
assert!(t.class_id.is("vibrance"), "Set class {:?}", t.class_id);
|
||||
let ty = get_obj(pair[1].descriptor.as_ref().unwrap(), "Usng").and_then(|u| get_obj(u, "Type")).unwrap();
|
||||
assert!(ty.class_id.is("vibrance"), "Make class {:?}", ty.class_id);
|
||||
}
|
||||
let mut reloaded = Session::new();
|
||||
reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap();
|
||||
assert_eq!(reloaded.actions.list[0].steps, s.actions.list[0].steps);
|
||||
}
|
||||
|
||||
/// Our Vibrance has no `temperature` or `tint`: a recorded Vibrance Set or Make with either one
|
||||
/// non-zero stays a kept Photoshop step and saves back unchanged instead of losing them.
|
||||
#[test]
|
||||
fn vibrance_steps_with_temperature_or_tint_save_back_unchanged() {
|
||||
let t = || desc("vibrance").with("temperature", long(0.0)).with("tint", long(12.0)).with("vibrance", long(28.0)).with("Strt", long(-7.0));
|
||||
let set = desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t()));
|
||||
let warm = desc("vibrance").with("temperature", long(-20.0)).with("vibrance", long(5.0));
|
||||
let make = desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(warm))));
|
||||
let data = set_of("Vibrance", vec![("A", vec![item("set", "Set", Some(set.clone())), item("Mk ", "Make", Some(make.clone()))])]);
|
||||
let mut s = Session::new();
|
||||
s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
assert!(s.actions.list[0].steps.iter().all(|(id, _)| id == "actions.photoshopStep"), "{:?}", s.actions.list[0].steps);
|
||||
let saved = s.execute("actions.save", json!({"set": "Vibrance"})).unwrap();
|
||||
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
|
||||
let file = atn::parse(&bytes).unwrap();
|
||||
assert_eq!(file.actions[0].items[0].descriptor, Some(set));
|
||||
assert_eq!(file.actions[0].items[1].descriptor, Some(make));
|
||||
}
|
||||
|
||||
/// A Free Transform recorded against an unlinked mask or an alpha channel must not export as a
|
||||
/// layer transform that moves the layer's pixels on replay (#7195).
|
||||
#[test]
|
||||
@@ -3063,12 +3244,207 @@ fn hue_saturation_uses_photoshops_hue_key() {
|
||||
assert_eq!(p["hue"], json!(30), "{p}");
|
||||
}
|
||||
|
||||
/// #6745: a recorded Camera Raw Filter step (`Adobe Camera Raw Filter`, the layout of 62 real
|
||||
/// recordings) imports as `filter.cameraRaw` with its sliders, colour mixer, grading wheels,
|
||||
/// vignette style and point curves, and plays. One that uses the Calibration panel (or noise
|
||||
/// settings the command lacks) stays a Photoshop step rather than replaying a different look.
|
||||
#[test]
|
||||
fn camera_raw_filter_steps_import_as_filter_camera_raw() {
|
||||
let int = |v: i32| D::Integer(v);
|
||||
// Every step carries the version keys a recording has (a verified process version).
|
||||
let cr = || desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", int(5)).with("PrVe", int(184_549_376));
|
||||
let mut d = cr()
|
||||
.with("WBal", enm("WBal", "Cst "))
|
||||
.with("Temp", int(12))
|
||||
.with("Tint", int(-4))
|
||||
.with("Ex12", D::Double(0.35))
|
||||
.with("Cr12", int(10))
|
||||
.with("Hi12", int(-30))
|
||||
.with("Sh12", int(25))
|
||||
.with("Cl12", int(8))
|
||||
.with("Vibr", int(15))
|
||||
.with("PC_1", int(20))
|
||||
.with("PC_2", int(50))
|
||||
.with("PC_3", int(80))
|
||||
.with("STSH", int(210))
|
||||
.with("STSS", int(12))
|
||||
.with("CgSL", int(0))
|
||||
.with("CgBl", int(60))
|
||||
.with("STB ", int(-10))
|
||||
.with("Shrp", int(40))
|
||||
.with("ShpR", D::Double(1.2))
|
||||
.with("LNR ", int(20))
|
||||
.with("LNRC", int(0))
|
||||
.with("CNR ", int(25))
|
||||
.with("CNRS", int(50))
|
||||
.with("PCVA", int(-20))
|
||||
.with("PCVS", int(2))
|
||||
.with("Crv ", D::List([0, 0, 64, 50, 255, 255].map(int).to_vec()))
|
||||
.with("CrvR", D::List([0, 0, 255, 255].map(int).to_vec()));
|
||||
for (k, v) in ["R", "O", "Y", "G", "A", "B", "P", "M"].iter().zip(1..) {
|
||||
d = d.with(&format!("HA_{k}"), int(v));
|
||||
}
|
||||
d = d.with("SA_O", int(-15));
|
||||
let calibrated = cr().with("Ex12", D::Double(0.2)).with("RHue", int(8));
|
||||
let smoothed = cr().with("CNR ", int(25)).with("CNRS", int(70));
|
||||
let auto_wb = cr().with("WBal", enm("WBal", "Atmt")).with("Temp", int(5));
|
||||
let partial_splits = cr().with("PC_1", int(20));
|
||||
let odd_curve = cr().with("Crv ", int(3));
|
||||
// Review of #8714: values that would replay differently, or fail to play, stay Photoshop steps.
|
||||
let reversed_splits = cr().with("PC_1", int(80)).with("PC_2", int(50)).with("PC_3", int(20));
|
||||
let split_past_100 = cr().with("PC_1", int(20)).with("PC_2", int(50)).with("PC_3", int(120));
|
||||
let one_point_curve = cr().with("Crv ", D::List([0, 0].map(int).to_vec()));
|
||||
let unordered_curve = cr().with("CrvG", D::List([0, 0, 128, 90, 64, 50, 255, 255].map(int).to_vec()));
|
||||
let text_contrast = cr().with("Cr12", D::Text(UnicodeString::new_nul("ten")));
|
||||
let text_hue = cr().with("HA_R", D::Text(UnicodeString::new_nul("red")));
|
||||
let text_grade = cr().with("STSH", D::Text(UnicodeString::new_nul("blue")));
|
||||
let exposure_past_5 = cr().with("Ex12", D::Double(6.0));
|
||||
let negative_sharpening = cr().with("Shrp", int(-10));
|
||||
// CodeRabbit on batch 11: a process version the PSD mapping hasn't verified renders differently.
|
||||
let unknown_version =
|
||||
desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", int(4)).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2));
|
||||
let no_version_number = desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2));
|
||||
let mismatched_pair =
|
||||
desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", int(6)).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2));
|
||||
let no_camera_raw_version = desc("null").with("PrVN", int(5)).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2));
|
||||
let items = vec![
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(d)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(calibrated)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(smoothed)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(auto_wb)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(partial_splits)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(odd_curve)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(reversed_splits)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(split_past_100)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(one_point_curve)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(unordered_curve)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(text_contrast)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(text_hue)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(text_grade)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(exposure_past_5)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(negative_sharpening)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(unknown_version)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(no_version_number)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(mismatched_pair)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(no_camera_raw_version)),
|
||||
];
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Looks"),
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("Warm"), items, ..Default::default() }],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let imp = import(&atn::write(&file).unwrap()).unwrap();
|
||||
let steps = &imp.actions[0].steps;
|
||||
let (id, p) = &steps[0];
|
||||
assert_eq!(id, "filter.cameraRaw");
|
||||
// The recorded descriptor rides along, so Save Actions can write the step back.
|
||||
let mut p = p.clone();
|
||||
assert!(p.as_object_mut().unwrap().remove("__atnDescriptor").is_some(), "{p}");
|
||||
assert_eq!(
|
||||
p,
|
||||
json!({
|
||||
"temperature": 12, "tint": -4, "exposure": 0.35, "contrast": 10, "highlights": -30, "shadows": 25,
|
||||
"clarity": 8, "vibrance": 15, "curveSplits": [20, 50, 80],
|
||||
"hslHue": [1, 2, 3, 4, 5, 6, 7, 8], "hslSat": [0, -15, 0, 0, 0, 0, 0, 0],
|
||||
"gradeShadows": {"hue": 210, "sat": 12, "lum": 0}, "gradeBlending": 60, "gradeBalance": -10,
|
||||
"sharpenAmount": 40, "sharpenRadius": 1.2, "noiseLuminance": 20, "noiseColor": 25,
|
||||
"vignetteAmount": -20, "vignetteStyle": "colorPriority", "pointCurve": [[0, 0], [64, 50], [255, 255]]
|
||||
})
|
||||
);
|
||||
for (k, (id, _)) in steps.iter().enumerate().skip(1) {
|
||||
assert_eq!(id, PHOTOSHOP_STEP, "step {k}");
|
||||
}
|
||||
assert_eq!(imp.unsupported.iter().map(|u| u.step).collect::<Vec<_>>(), (1..=18).collect::<Vec<_>>());
|
||||
// The imported step plays.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 12})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
|
||||
s.execute(id, p.clone()).unwrap();
|
||||
}
|
||||
|
||||
/// Review of #8714: a Camera Raw Filter step that was loaded saves back as the recorded step,
|
||||
/// byte for byte, as it did while it was a kept Photoshop step; once edited (or when PhotoCraft
|
||||
/// recorded it) Save Actions refuses it as before. The loaded step plays.
|
||||
#[test]
|
||||
fn loaded_camera_raw_filter_steps_save_back_unchanged() {
|
||||
let recorded = desc("null")
|
||||
.with("CrVe", D::Text(UnicodeString::new_nul("16.1.1")))
|
||||
.with("PrVN", D::Integer(5))
|
||||
.with("PrVe", D::Integer(184_549_376))
|
||||
.with("WBal", enm("WBal", "Cst "))
|
||||
.with("Ex12", D::Double(0.35))
|
||||
.with("Cr12", D::Integer(10))
|
||||
.with("Crv ", D::List([0, 0, 64, 50, 255, 255].map(D::Integer).to_vec()));
|
||||
let file = one_action(vec![item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(recorded.clone()))]);
|
||||
let mut s = Session::new();
|
||||
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
|
||||
assert_eq!(s.actions.list[0].steps[0].0, "filter.cameraRaw");
|
||||
let saved_step = |r: &Value| {
|
||||
let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
saved.actions[0].items.first().map(|i| (i.event.clone(), i.descriptor.clone()))
|
||||
};
|
||||
// Strict save, and the Actions panel's save (which skips what can't be written).
|
||||
for skip in [false, true] {
|
||||
let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": skip})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
assert_eq!(saved_step(&r), Some((AtnEvent::name("Adobe Camera Raw Filter"), Some(recorded.clone()))));
|
||||
}
|
||||
// It plays.
|
||||
s.execute("file.new", json!({"width": 16, "height": 12})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
|
||||
assert_eq!(played["ran"], 1, "{played}");
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
// Edited, it no longer matches what was recorded.
|
||||
let original = s.actions.list[0].steps[0].1.clone();
|
||||
s.actions.list[0].steps[0].1["exposure"] = json!(1.5);
|
||||
let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string();
|
||||
assert!(e.contains("filter.cameraRaw"), "{e}");
|
||||
// Recorded by PhotoCraft: no descriptor to write back.
|
||||
s.actions.list[0].steps[0].1 = original;
|
||||
s.actions.list[0].steps[0].1.as_object_mut().unwrap().remove("__atnDescriptor");
|
||||
let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap();
|
||||
assert_eq!(r["skipped"].as_array().unwrap().len(), 1, "{r}");
|
||||
}
|
||||
|
||||
/// Review of #8714: a Camera Raw Filter step with a key the table doesn't map (an Upright
|
||||
/// setting, a profile name, any other panel) or with no settings at all stays a Photoshop step
|
||||
/// and is reported, rather than replaying without that processing.
|
||||
#[test]
|
||||
fn camera_raw_filter_steps_with_unknown_keys_stay_photoshop_steps() {
|
||||
let cr = || desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", D::Integer(5)).with("PrVe", D::Integer(184_549_376));
|
||||
let upright = cr().with("Ex12", D::Double(0.2)).with("PerV", D::Integer(20));
|
||||
let profile = cr().with("Cr12", D::Integer(10)).with("LookName", D::Text(UnicodeString::new_nul("B&W 05")));
|
||||
let file = one_action(vec![
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(upright.clone())),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(profile)),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", None),
|
||||
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(cr().with("Ex12", D::Double(0.2)))),
|
||||
]);
|
||||
let mut s = Session::new();
|
||||
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap();
|
||||
let unsupported: Vec<u64> = loaded["unsupported"].as_array().unwrap().iter().map(|u| u["step"].as_u64().unwrap()).collect();
|
||||
assert_eq!(unsupported, [0, 1, 2], "{loaded}");
|
||||
let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.cameraRaw"]);
|
||||
// Kept, it still saves back as recorded.
|
||||
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
|
||||
let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert_eq!(saved.actions[0].items[0].descriptor, Some(upright));
|
||||
}
|
||||
|
||||
fn one_action(items: Vec<AtnItem>) -> AtnFile {
|
||||
AtnFile {
|
||||
name: UnicodeString::new_nul("Saves"),
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3167,6 +3543,7 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() {
|
||||
expanded: false,
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul(&long), items: unknown(50), ..Default::default() }],
|
||||
warnings: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
let imp = import(&atn::write(&file).unwrap()).unwrap();
|
||||
assert_eq!(imp.set.chars().count(), MAX_NAME_CHARS);
|
||||
@@ -3175,3 +3552,256 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() {
|
||||
assert_eq!(imp.unsupported.len(), 50);
|
||||
assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS));
|
||||
}
|
||||
|
||||
/// A Pattern Fill step whose `Ptrn` holds `id` and `name`, as the reference application records it.
|
||||
fn pattern_fill_step(id: &str, name: &str) -> AtnItem {
|
||||
let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text(name))));
|
||||
item(
|
||||
"Mk ",
|
||||
"Make",
|
||||
Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Defines a 4×4 pattern of `color` named `name` in `s`'s library from its active document.
|
||||
fn define_pattern(s: &mut Session, color: &str, name: &str) -> String {
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap();
|
||||
s.execute("edit.definePattern", json!({"name": name})).unwrap()["pattern"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn pixel(s: &mut Session) -> Vec<f64> {
|
||||
let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap();
|
||||
v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// A Pattern Fill step whose `Ptrn` names both an installed id and a display name shared by
|
||||
/// another pattern plays the pattern with that id; a foreign id falls back to the name.
|
||||
#[test]
|
||||
fn pattern_fill_prefers_an_installed_id_over_a_colliding_name() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
|
||||
let mut define = |color: &str| {
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap();
|
||||
s.execute("edit.definePattern", json!({"name": "Twin"})).unwrap()["pattern"].as_str().unwrap().to_string()
|
||||
};
|
||||
let red = define("#ff0000");
|
||||
let blue = define("#0000ff");
|
||||
assert_ne!(red, blue);
|
||||
s.patterns.items.retain(|p| p.id == red || p.id == blue);
|
||||
s.patterns.items.sort_by_key(|p| p.id != blue);
|
||||
let fill_step = |id: &str| {
|
||||
let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text("Twin"))));
|
||||
item(
|
||||
"Mk ",
|
||||
"Make",
|
||||
Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))),
|
||||
)
|
||||
};
|
||||
let data = set_of("Pat", vec![("Installed", vec![fill_step(&red)]), ("Foreign", vec![fill_step("not-installed-id")])]);
|
||||
let r = s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
assert!(r["unsupported"].as_array().is_none_or(|u| u.is_empty()), "{r}");
|
||||
for (action, want) in [("Installed", [1.0, 0.0, 0.0, 1.0]), ("Foreign", [0.0, 0.0, 1.0, 1.0])] {
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
|
||||
let r = s.execute("actions.play", json!({"action": action})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{r}");
|
||||
let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap();
|
||||
let got: Vec<f64> = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect();
|
||||
assert_eq!(got, want, "{action}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded id is looked up by id alone, in the document and then the library, before any
|
||||
/// name: a document pattern *named* like the id does not beat the library pattern with that id,
|
||||
/// and a foreign id that happens to be another pattern's name falls back to the recorded name.
|
||||
/// An id and a name of the same pattern play it.
|
||||
#[test]
|
||||
fn pattern_fill_ids_beat_names_across_document_and_library() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
|
||||
let red = define_pattern(&mut s, "#ff0000", "Red");
|
||||
define_pattern(&mut s, "#0000ff", "Blue");
|
||||
let green = define_pattern(&mut s, "#00ff00", &red);
|
||||
let data = set_of(
|
||||
"Pat",
|
||||
vec![
|
||||
("IdWins", vec![pattern_fill_step(&red, "Blue")]),
|
||||
("NameFallback", vec![pattern_fill_step("Red", "Blue")]),
|
||||
("Same", vec![pattern_fill_step(&red, "Red")]),
|
||||
],
|
||||
);
|
||||
s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
// Only the playback document holds the green pattern named like the red one's id.
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
|
||||
s.execute("layer.newFillLayer.pattern", json!({"pattern": green})).unwrap();
|
||||
s.execute("pattern.delete", json!({"pattern": green})).unwrap();
|
||||
assert!(s.active().unwrap().doc.patterns.iter().any(|p| p.id == green && p.name == red));
|
||||
assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]);
|
||||
for (action, want) in [("IdWins", [1.0, 0.0, 0.0, 1.0]), ("NameFallback", [0.0, 0.0, 1.0, 1.0]), ("Same", [1.0, 0.0, 0.0, 1.0])] {
|
||||
let r = s.execute("actions.play", json!({"action": action})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{action}: {r}");
|
||||
assert_eq!(pixel(&mut s), want, "{action}");
|
||||
}
|
||||
}
|
||||
|
||||
/// CodeRabbit on batch 11: a pattern step that names its pattern only by `patternName` exports
|
||||
/// that name, rather than a step with no pattern (which plays the current one).
|
||||
#[test]
|
||||
fn pattern_steps_named_only_by_pattern_name_export_the_name() {
|
||||
let one = Action { name: "P".into(), steps: vec![("layer.newFillLayer.pattern".into(), json!({"patternName": "Checkerboard"}))], ..Default::default() };
|
||||
let (bytes, skipped) = export("Pat", &[&one], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let steps = import(&bytes).unwrap().actions[0].steps.clone();
|
||||
assert_eq!(steps[0].0, "layer.newFillLayer.pattern");
|
||||
assert_eq!(steps[0].1["pattern"], json!("Checkerboard"), "{}", steps[0].1);
|
||||
}
|
||||
|
||||
/// CodeRabbit on batch 11: when the recorded id is installed nowhere, the recorded name is looked
|
||||
/// up as a name only. A pattern whose id equals that name text doesn't beat the one named so.
|
||||
#[test]
|
||||
fn pattern_fill_name_fallback_matches_names_not_ids() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
|
||||
let red = define_pattern(&mut s, "#ff0000", "Red");
|
||||
// Green is named like the red pattern's id.
|
||||
define_pattern(&mut s, "#00ff00", &red);
|
||||
let data = set_of("Pat", vec![("NameIsAnId", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", &red)])]);
|
||||
s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
|
||||
let r = s.execute("actions.play", json!({"action": "NameIsAnId"})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{r}");
|
||||
assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// A recorded pattern installed neither by id nor by name fails with an error that names the
|
||||
/// pattern, from the imported step and from the Pattern Fill dialog's command alike.
|
||||
#[test]
|
||||
fn pattern_fill_missing_everywhere_names_the_pattern() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
|
||||
define_pattern(&mut s, "#ff0000", "Red");
|
||||
let data = set_of("Pat", vec![("Missing", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", "My Wood")])]);
|
||||
s.execute("actions.load", json!({"data": data})).unwrap();
|
||||
let r = s.execute("actions.play", json!({"action": "Missing"})).unwrap();
|
||||
let err = r["failed"]["error"].as_str().unwrap_or_default();
|
||||
assert!(err.contains("\"My Wood\""), "{r}");
|
||||
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Red"})).unwrap();
|
||||
let e = s.execute("layer.fill.pattern.set", json!({"pattern": "5e1c0000-aaaa-4bbb-8ccc-000000000000", "patternName": "My Wood"})).unwrap_err();
|
||||
assert!(e.to_string().contains("\"My Wood\""), "{e}");
|
||||
}
|
||||
|
||||
/// FredApps review on #10459: a set whose name has surrounding spaces (a Photoshop `.atn` or an
|
||||
/// older actions.json can have one, and New Action records into it under that exact name) saves
|
||||
/// by that name. `actions.save` used to trim it and then find no set.
|
||||
#[test]
|
||||
fn save_finds_a_set_by_its_exact_name_with_spaces() {
|
||||
let mut s = Session::new();
|
||||
s.actions.list.push(Action { set: Some(" Web ".into()), ..Action::new("Old", Vec::new()) });
|
||||
s.execute("actions.record", json!({"name": "New", "set": " Web "})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let saved = s.execute("actions.save", json!({"set": " Web "})).unwrap();
|
||||
assert_eq!((&saved["set"], &saved["actions"]), (&json!(" Web "), &json!(["Old", "New"])));
|
||||
// The file keeps the name, as Photoshop's does.
|
||||
let mut reloaded = Session::new();
|
||||
let r = reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap();
|
||||
assert_eq!(r["set"], " Web ");
|
||||
// Other names are still trimmed.
|
||||
s.execute("actions.assignSet", json!({"action": "Old", "set": "Print"})).unwrap();
|
||||
assert_eq!(s.execute("actions.save", json!({"set": " Print "})).unwrap()["set"], "Print");
|
||||
}
|
||||
|
||||
/// FredApps review on #10459: Save Actions… on an empty set made with New Set writes it, as
|
||||
/// Photoshop saves an empty set to the `.atn`, and loading that file brings the empty set back.
|
||||
#[test]
|
||||
fn an_empty_new_set_saves_and_loads_back() {
|
||||
let mut s = Session::new();
|
||||
s.execute("actions.newSet", json!({"name": "Later"})).unwrap();
|
||||
let saved = s.execute("actions.save", json!({"set": "Later"})).unwrap();
|
||||
assert_eq!((&saved["set"], &saved["actions"], &saved["steps"]), (&json!("Later"), &json!([]), &json!(0)));
|
||||
let mut other = Session::new();
|
||||
let r = other.execute("actions.load", json!({"data": saved["data"]})).unwrap();
|
||||
assert_eq!((&r["set"], &r["actions"]), (&json!("Later"), &json!([])));
|
||||
assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "an empty folder");
|
||||
other.execute("actions.load", json!({"data": saved["data"]})).unwrap();
|
||||
assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "loaded twice, still one folder");
|
||||
// New Action records into it as into any set, and it saves with that action.
|
||||
other.execute("actions.record", json!({"name": "Now", "set": "Later"})).unwrap();
|
||||
other.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(other.execute("actions.save", json!({"set": "Later"})).unwrap()["actions"], json!(["Now"]));
|
||||
// A set that doesn't exist is still refused, and so is saving every action when there is none.
|
||||
let err = s.execute("actions.save", json!({"set": "Nope"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no actions in a set named `Nope`"), "{err}");
|
||||
let err = Session::new().execute("actions.save", json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("there are no actions to save"), "{err}");
|
||||
}
|
||||
|
||||
/// A set from Chinese Photoshop: UTF-16 set and action names, a step name in GBK (可选颜色,
|
||||
/// the bytes from #6743) on an event PhotoCraft has no command for, with no descriptor.
|
||||
fn gbk_set() -> Vec<u8> {
|
||||
let step = AtnItem { dictionary: b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab".to_vec(), ..item("syntheticFill", "", None) };
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("相馆"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("调白"), items: vec![step], ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
atn::write(&file).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_decodes_legacy_step_names() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("actions.load", json!({"data": b64(&gbk_set())})).unwrap();
|
||||
assert_eq!(r["encoding"], "GBK", "{r}");
|
||||
assert_eq!(r["unsupported"][0]["name"], "可选颜色", "{r}");
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action": "调白"})).unwrap();
|
||||
assert!(played["failed"]["error"].as_str().unwrap().contains("可选颜色"), "{played}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_keeps_windows_1252_symbols() {
|
||||
// Windows Photoshop step names with no Unicode copy: Mac Roman reads ° and » as ∞ and ª,
|
||||
// which must not win ("50 ∞", "Grˆfle ª"). Save Actions writes the names back as that text.
|
||||
let steps = vec![
|
||||
AtnItem { dictionary: b"50 \xb0".to_vec(), ..item("syntheticFill", "", None) },
|
||||
AtnItem { dictionary: b"Gr\xf6\xdfe \xbb".to_vec(), ..item("syntheticFill", "", None) },
|
||||
];
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Größe ändern"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("Logo"), items: steps, ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap();
|
||||
assert_eq!(r["encoding"], "windows-1252", "{r}");
|
||||
assert_eq!(r["unsupported"][0]["name"], "50 °", "{r}");
|
||||
assert_eq!(r["unsupported"][1]["name"], "Größe »", "{r}");
|
||||
let saved = s.execute("actions.save", json!({"set": "Größe ändern"})).unwrap();
|
||||
let saved = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let names: Vec<String> = saved.actions[0].items.iter().map(|i| saved.item_label(i)).collect();
|
||||
assert_eq!(names, ["50 °", "Größe »"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_encoding_override() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "shift_jis"})).unwrap();
|
||||
assert_eq!(r["encoding"], "Shift_JIS", "{r}");
|
||||
let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "auto"})).unwrap();
|
||||
assert_eq!(r["encoding"], "GBK", "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reports_utf8() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("actions.load", json!({"data": b64(&atn::write(&sample()).unwrap())})).unwrap();
|
||||
assert_eq!(r["encoding"], "UTF-8", "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_bad_encodings() {
|
||||
let mut s = Session::new();
|
||||
for bad in [json!(3), json!(""), json!("nope"), json!("replacement"), json!(["gbk"])] {
|
||||
assert!(s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.actions.list.is_empty(), "nothing is loaded when the encoding is bad");
|
||||
}
|
||||
|
||||
@@ -45,8 +45,11 @@ pub(crate) use open::{import, tag_exr_colorspace};
|
||||
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, same_file, saves_in_place, template_name, untitled_name};
|
||||
pub(crate) use paths::{file_name, forward_slashes, is_windows_device_name, join, portable_device_name, read_file, sanitize, stem, write_file};
|
||||
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,
|
||||
};
|
||||
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};
|
||||
use save::save_a_copy;
|
||||
pub(crate) use save::{SaveOpts, encode, save_doc};
|
||||
|
||||
@@ -212,7 +212,7 @@ pub(crate) fn sanitize(name: &str) -> String {
|
||||
|
||||
/// `name` with a `_` before its first dot when it is a Windows device name (`nul` → `nul_`,
|
||||
/// `com1.v2` → `com1_.v2`); a `_` at the end would leave the device stem in place.
|
||||
pub(crate) fn portable_device_name(name: String) -> String {
|
||||
pub fn portable_device_name(name: String) -> String {
|
||||
if !is_windows_device_name(&name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -182,12 +182,35 @@ pub fn resolve(s: &Session, key: &str) -> Option<Pattern> {
|
||||
|
||||
pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result<Pattern> {
|
||||
let key = p.get("pattern").and_then(Value::as_str).unwrap_or("");
|
||||
if let Some(pat) = resolve(s, key) {
|
||||
return paintable(cmd, pat);
|
||||
}
|
||||
match library_pattern(s, key).map(|lp| full(s, lp)) {
|
||||
Some(Err(e)) => Err(bad(cmd, e)),
|
||||
_ => Err(bad(cmd, format!("no pattern \"{key}\" (see pattern.list)"))),
|
||||
resolve_or_named(s, key, p).map_err(|e| bad(cmd, e)).and_then(|pat| paintable(cmd, pat))
|
||||
}
|
||||
|
||||
/// The pattern `key` names, or with a `patternName` in `p` (an imported action's pattern recorded
|
||||
/// with both an id and a name) the pattern with id `key`: the active document's, then the
|
||||
/// library's, by id alone, and only when that id is installed nowhere the one `patternName`
|
||||
/// names, as the reference application plays such a step. The error says why: no such pattern,
|
||||
/// or a saved library pattern whose pixels can't be read.
|
||||
pub(crate) fn resolve_or_named(s: &Session, key: &str, p: &Value) -> std::result::Result<Pattern, String> {
|
||||
use photocraft_doc::pattern::find;
|
||||
let name = p.get("patternName").and_then(Value::as_str).filter(|n| !n.is_empty());
|
||||
// `find` with an empty name looks up the id alone; both empty find nothing.
|
||||
let in_doc = |id: &str, name: &str| s.active().and_then(|d| find(&d.doc.patterns, id, name)).map(|p| (p, false));
|
||||
let by_key = |k: &str| in_doc(k, k).or_else(|| library_pattern(s, k).map(|p| (p, true)));
|
||||
let found = match name {
|
||||
// The name is a name: another pattern whose id happens to equal it doesn't count.
|
||||
Some(n) => in_doc(key, "")
|
||||
.or_else(|| find(&s.patterns.items, key, "").map(|p| (p, true)))
|
||||
.or_else(|| in_doc("", n))
|
||||
.or_else(|| find(&s.patterns.items, "", n).map(|p| (p, true))),
|
||||
None => by_key(key),
|
||||
};
|
||||
match found {
|
||||
Some((pat, false)) => Ok(pat.clone()),
|
||||
Some((pat, true)) => full(s, pat),
|
||||
None => Err(match name {
|
||||
Some(n) => format!("no pattern \"{n}\" (id \"{key}\"; see pattern.list)"),
|
||||
None => format!("no pattern \"{key}\" (see pattern.list)"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,7 +611,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Pattern…",
|
||||
menu: &["Layer", "New Fill Layer"],
|
||||
shortcut: None,
|
||||
params: r##"{"pattern":id|name?=first library pattern,"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phase":[x,y]?,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##,
|
||||
params: r##"{"pattern":id|name?=first library pattern,"patternName":name? (then `pattern` is an id; the name is used when no pattern has that id),"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phase":[x,y]?,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##,
|
||||
enabled: has_doc,
|
||||
journal: true,
|
||||
run: new_fill_layer,
|
||||
|
||||
@@ -37,7 +37,7 @@ fn edited(s: &Session, base: &Fill, p: &Value) -> Result<(Fill, Option<Pattern>)
|
||||
let (mut scale, mut angle, mut link, mut phase, mut pat) = (*scale, *angle, *link, *phase, None);
|
||||
if let Some(v) = p.get("pattern").filter(|v| !v.is_null()) {
|
||||
let key = v.as_str().filter(|k| !k.is_empty()).ok_or_else(|| bad("`pattern` is a pattern id or name"))?;
|
||||
pat = Some(crate::pattern_cmds::resolve(s, key).ok_or_else(|| bad(format!("no pattern \"{key}\" (see pattern.list)")))?);
|
||||
pat = Some(crate::pattern_cmds::resolve_or_named(s, key, p).map_err(bad)?);
|
||||
}
|
||||
if let Some(v) = num("scale", SCALE_RANGE)? {
|
||||
scale = v / 100.0;
|
||||
@@ -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?,"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? (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,
|
||||
|
||||
@@ -20,6 +20,7 @@ testgen = []
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -56,9 +56,10 @@ fn main() {
|
||||
match atn::parse(&bytes) {
|
||||
Ok(f) => {
|
||||
println!(
|
||||
"== {path}: set {:?}, {} actions{}",
|
||||
"== {path}: set {:?}, {} actions{}{}",
|
||||
f.name.to_string_lossy(),
|
||||
f.actions.len(),
|
||||
f.encoding.as_deref().map(|e| format!(", names in {e}")).unwrap_or_default(),
|
||||
if f.warnings.is_empty() { String::new() } else { format!(" ({})", f.warnings.join("; ")) }
|
||||
);
|
||||
for a in &f.actions {
|
||||
@@ -66,8 +67,8 @@ fn main() {
|
||||
for item in &a.items {
|
||||
let flags = format!("{}{}", if item.enabled { "" } else { " off" }, if item.with_dialog { " dialog" } else { "" });
|
||||
match &item.descriptor {
|
||||
Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.label(), keys(d)),
|
||||
None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.label()),
|
||||
Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), f.item_label(item), keys(d)),
|
||||
None => println!(" {:<22} {:<28}{flags}", item.event.id(), f.item_label(item)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
//! Fuzz the `.atn` actions parser: any input must return `Ok` or `Err`, never panic, and
|
||||
//! anything that parses must survive a write and parse back equal.
|
||||
//! anything that parses must survive writing, reparsing and stable canonical writing.
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::atn;
|
||||
|
||||
#[path = "../../tests/it/common/atn_roundtrip.rs"]
|
||||
mod atn_roundtrip;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(f) = atn::parse(data) {
|
||||
let bytes = atn::write(&f).expect("a parsed set is writable");
|
||||
let again = atn::parse(&bytes).expect("a written set parses");
|
||||
assert_eq!(again.actions, f.actions);
|
||||
}
|
||||
atn_roundtrip::check(data);
|
||||
});
|
||||
|
||||
+376
-5
@@ -22,10 +22,19 @@
|
||||
//! [`AtnItem::label`] gives a readable name. Photoshop 5-era files (version 12) use a
|
||||
//! different, undocumented layout and are rejected as unsupported.
|
||||
//!
|
||||
//! Which legacy encoding is not recorded: Windows Photoshop uses the system code page (GBK in
|
||||
//! Chinese, Shift_JIS in Japanese…), Mac Photoshop the Mac one. [`parse`] works it out once per
|
||||
//! file into [`AtnFile::encoding`], first from steps that also store their name as Unicode (the
|
||||
//! descriptor's class name), else by guessing from the bytes; [`parse_with_encoding`] takes it
|
||||
//! from the caller. [`AtnFile::item_label`] reads names with it. The bytes themselves stay as
|
||||
//! they are.
|
||||
//!
|
||||
//! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor
|
||||
//! nesting is bounded, and allocations are capped. [`write`] produces the same layout, so
|
||||
//! `parse(write(f)) == f`, and `write(parse(b)) == b` for files Photoshop wrote.
|
||||
//! `parse(write(f)) == f` for files [`parse`] read, and `write(parse(b)) == b` for files Photoshop
|
||||
//! wrote.
|
||||
|
||||
use crate::codepage;
|
||||
use crate::descriptor::{Descriptor, UnicodeString};
|
||||
use crate::error::{PsdError, Result};
|
||||
use crate::io::{Reader, WriteExt, decode_legacy_name};
|
||||
@@ -50,6 +59,37 @@ pub struct AtnFile {
|
||||
/// Problems that did not stop the parse (trailing bytes…).
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub warnings: Vec<String>,
|
||||
/// The encoding of the 8-bit names that are not ASCII or UTF-8 ("GBK", "Shift_JIS",
|
||||
/// "macintosh"…): the one given to [`parse_with_encoding`], else the one that turns such
|
||||
/// names into the Unicode class names stored with them, else a guess from the names shown
|
||||
/// from their bytes alone. `None` when there are no such names, or nothing tells and no
|
||||
/// name depends on it. [`write`] ignores it: names are written as stored.
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub encoding: Option<String>,
|
||||
}
|
||||
|
||||
impl AtnFile {
|
||||
/// [`AtnItem::label`], with 8-bit names read in this file's [`encoding`](Self::encoding).
|
||||
/// A name that is valid UTF-8 is read in that encoding only if it reads entirely as common
|
||||
/// text there (GBK 去色 is also valid UTF-8, "ȥɫ").
|
||||
pub fn item_label(&self, item: &AtnItem) -> String {
|
||||
let Some(enc) = self.encoding.as_deref().and_then(|label| codepage::resolve_label(label).ok()) else { return item.label() };
|
||||
let read = |bytes: &[u8]| match std::str::from_utf8(bytes) {
|
||||
Ok(s) if s.is_ascii() || !codepage::reads_as_common(bytes, enc) => s.to_owned(),
|
||||
_ => codepage::decode(bytes, enc),
|
||||
};
|
||||
if let Some(class) = class_name(item) {
|
||||
return class;
|
||||
}
|
||||
let dict = read(&item.dictionary);
|
||||
if !dict.trim().is_empty() {
|
||||
return dict;
|
||||
}
|
||||
match &item.event {
|
||||
AtnEvent::Name(n) => read(n),
|
||||
AtnEvent::Code(_) => item.event.id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One action of a set.
|
||||
@@ -116,8 +156,7 @@ impl AtnItem {
|
||||
/// A readable name for the step: the descriptor's class name when Photoshop stored one
|
||||
/// (Unicode, so a localised name survives), else [`Self::dictionary`], else the event id.
|
||||
pub fn label(&self) -> String {
|
||||
let class = self.descriptor.as_ref().map(|d| d.name.to_string_lossy()).unwrap_or_default();
|
||||
if !class.trim().is_empty() {
|
||||
if let Some(class) = class_name(self) {
|
||||
return class;
|
||||
}
|
||||
let dict = self.dictionary();
|
||||
@@ -125,6 +164,12 @@ impl AtnItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// The descriptor's class name, when Photoshop stored one: the step's name as Unicode.
|
||||
fn class_name(item: &AtnItem) -> Option<String> {
|
||||
let class = item.descriptor.as_ref().map(|d| d.name.to_string_lossy())?;
|
||||
(!class.trim().is_empty()).then_some(class)
|
||||
}
|
||||
|
||||
/// A Photoshop event id: a four-character code (`GsnB`) or a string name (`trim`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
@@ -208,8 +253,25 @@ fn read_action(r: &mut Reader<'_>) -> Result<AtnAction> {
|
||||
Ok(AtnAction { function_key, shift, command, color, name, expanded, items })
|
||||
}
|
||||
|
||||
/// Parses an `.atn` file.
|
||||
/// Parses an `.atn` file, working out the encoding of its 8-bit names (see the module docs).
|
||||
pub fn parse(data: &[u8]) -> Result<AtnFile> {
|
||||
parse_with_encoding(data, None)
|
||||
}
|
||||
|
||||
/// [`parse`], reading 8-bit names in `encoding` (a label such as `shift_jis`, `gbk`, `big5`,
|
||||
/// `euc-kr`, `windows-1252`, `macintosh`) instead of working it out. An unknown label, or one
|
||||
/// for an encoding that cannot hold an 8-bit name (UTF-16, `replacement`), is an error.
|
||||
pub fn parse_with_encoding(data: &[u8], encoding: Option<&str>) -> Result<AtnFile> {
|
||||
let forced = encoding.map(codepage::resolve_label).transpose()?;
|
||||
let mut f = parse_layout(data)?;
|
||||
let mut warnings = Vec::new();
|
||||
let enc = forced.or_else(|| name_encoding(&f, &mut warnings));
|
||||
f.warnings.extend(warnings);
|
||||
f.encoding = enc.map(|e| e.name().to_owned());
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
fn parse_layout(data: &[u8]) -> Result<AtnFile> {
|
||||
let mut r = Reader::new(data);
|
||||
let version = r.u32()?;
|
||||
if version != VERSION {
|
||||
@@ -231,7 +293,57 @@ pub fn parse(data: &[u8]) -> Result<AtnFile> {
|
||||
if !r.is_empty() {
|
||||
warnings.push(format!("{} trailing bytes ignored", r.remaining()));
|
||||
}
|
||||
Ok(AtnFile { name, expanded, actions, warnings })
|
||||
Ok(AtnFile { name, expanded, actions, warnings, encoding: None })
|
||||
}
|
||||
|
||||
/// The encoding of `f`'s 8-bit names that are not ASCII or UTF-8, or `None` if no name shown
|
||||
/// from them needs one; adds a warning when the guess is uncertain.
|
||||
fn name_encoding(f: &AtnFile, warnings: &mut Vec<String>) -> Option<&'static encoding_rs::Encoding> {
|
||||
let items = || f.actions.iter().flat_map(|a| &a.items);
|
||||
let legacy = |b: &[u8]| !b.is_ascii();
|
||||
// Steps that store their name as Unicode too: the encoding that turns one into the other.
|
||||
let pairs: Vec<(&[u8], String)> = items()
|
||||
.filter(|i| legacy(&i.dictionary))
|
||||
.filter_map(|i| class_name(i).filter(|c| std::str::from_utf8(&i.dictionary) != Ok(c.as_str())).map(|c| (i.dictionary.as_slice(), c)))
|
||||
.take(codepage::KNOWN_PAIRS)
|
||||
.collect();
|
||||
let pairs: Vec<(&[u8], &str)> = pairs.iter().map(|(b, c)| (*b, c.as_str())).collect();
|
||||
if let Some(enc) = codepage::match_known(&pairs) {
|
||||
return Some(enc);
|
||||
}
|
||||
// Otherwise guess from the names shown from bytes alone.
|
||||
let invalid = |b: &[u8]| std::str::from_utf8(b).is_err();
|
||||
let mut orphans: Vec<&[u8]> = Vec::new();
|
||||
for i in items() {
|
||||
if class_name(i).is_none() && invalid(&i.dictionary) {
|
||||
orphans.push(&i.dictionary);
|
||||
}
|
||||
if let AtnEvent::Name(n) = &i.event
|
||||
&& invalid(n)
|
||||
{
|
||||
orphans.push(n);
|
||||
}
|
||||
}
|
||||
if orphans.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The set, action and class names are UTF-16: their script points at the writer's locale.
|
||||
let mut hint = f.name.to_string_lossy();
|
||||
for name in f.actions.iter().map(|a| a.name.to_string_lossy()).chain(items().filter_map(class_name)) {
|
||||
if hint.len() > codepage::GUESS_SAMPLE {
|
||||
break;
|
||||
}
|
||||
hint.push('\n');
|
||||
hint.push_str(&name);
|
||||
}
|
||||
let guess = codepage::guess(&orphans, &hint);
|
||||
if guess.uncertain {
|
||||
warnings.push(format!(
|
||||
"step names aren't UTF-8 and their encoding couldn't be determined; read as {}; load with an explicit encoding to choose another",
|
||||
guess.encoding.name()
|
||||
));
|
||||
}
|
||||
Some(guess.encoding)
|
||||
}
|
||||
|
||||
fn write_item(out: &mut Vec<u8>, item: &AtnItem) -> Result<()> {
|
||||
@@ -336,6 +448,7 @@ mod tests {
|
||||
AtnAction { name: UnicodeString::new_nul("Empty"), ..Default::default() },
|
||||
],
|
||||
warnings: Vec::new(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +503,7 @@ mod tests {
|
||||
..Default::default()
|
||||
}],
|
||||
warnings: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut expect = Vec::new();
|
||||
expect.extend_from_slice(&[0, 0, 0, 16]); // version
|
||||
@@ -447,6 +561,263 @@ mod tests {
|
||||
assert!(matches!(parse(&bad_flag), Err(PsdError::Invalid(_))));
|
||||
}
|
||||
|
||||
const GBK_SELECTIVE_COLOR: &[u8] = b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab"; // 可选颜色 (from #6743)
|
||||
const GBK_DESATURATE: &[u8] = b"\xc8\xa5\xc9\xab"; // 去色: valid UTF-8 by accident ("ȥɫ")
|
||||
const LABELS: [Option<&str>; 5] = [None, Some("shift_jis"), Some("gbk"), Some("utf-8"), Some("macintosh")];
|
||||
|
||||
/// A step stored with no descriptor, so nothing but its 8-bit names tells what it is called.
|
||||
fn bare(event: AtnEvent, dictionary: &[u8]) -> AtnItem {
|
||||
AtnItem { event, dictionary: dictionary.to_vec(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A step whose descriptor also stores its panel name as Unicode, as Photoshop does for most.
|
||||
fn known(dictionary: &[u8], class: &str) -> AtnItem {
|
||||
let descriptor = Descriptor { name: UnicodeString::new_nul(class), class_id: crate::descriptor::Id::new("setd"), items: Vec::new() };
|
||||
AtnItem { event: AtnEvent::name("set"), dictionary: dictionary.to_vec(), descriptor: Some(descriptor), ..Default::default() }
|
||||
}
|
||||
|
||||
fn one_action(set: &str, action: &str, items: Vec<AtnItem>) -> Vec<u8> {
|
||||
let f = AtnFile {
|
||||
name: UnicodeString::new_nul(set),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul(action), items, ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
write(&f).unwrap()
|
||||
}
|
||||
|
||||
/// The issue's set: Chinese Photoshop's Selective Color step, named in GBK.
|
||||
fn gbk_set() -> Vec<u8> {
|
||||
one_action("相馆", "调白", vec![bare(AtnEvent::name("syntheticFill"), GBK_SELECTIVE_COLOR)])
|
||||
}
|
||||
|
||||
fn labels(f: &AtnFile) -> Vec<String> {
|
||||
f.actions.iter().flat_map(|a| a.items.iter().map(|i| f.item_label(i))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gbk_step_names_read_as_gbk() {
|
||||
let f = parse(&gbk_set()).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("GBK"));
|
||||
assert_eq!(labels(&f), ["可选颜色"]);
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
// The item itself, without the file's code page, reads as before.
|
||||
assert_ne!(f.actions[0].items[0].label(), "可选颜色");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_names_are_kept_byte_for_byte() {
|
||||
let bytes = gbk_set();
|
||||
let f = parse(&bytes).unwrap();
|
||||
assert_eq!(f.actions[0].items[0].dictionary, GBK_SELECTIVE_COLOR);
|
||||
assert_eq!(write(&f).unwrap(), bytes);
|
||||
assert_eq!(parse(&write(&f).unwrap()).unwrap(), f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_event_names_decode_too() {
|
||||
let f = parse(&one_action("相馆", "调白", vec![bare(AtnEvent::Name(GBK_SELECTIVE_COLOR.to_vec()), b"")])).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("GBK"));
|
||||
assert_eq!(labels(&f), ["可选颜色"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_names_decide_the_code_page_of_the_rest() {
|
||||
// French Photoshop on a Mac: "Définir" is stored as Mac Roman and as Unicode; the bare
|
||||
// step's "Répéter" is only Mac Roman, which also reads as windows-1252 ("RŽpŽter").
|
||||
let items = vec![known(b"D\x8efinir", "Définir"), bare(AtnEvent::name("x"), b"R\x8ep\x8eter")];
|
||||
let f = parse(&one_action("Set 1", "Action 1", items)).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("macintosh"));
|
||||
assert_eq!(labels(&f), ["Définir", "Répéter"]);
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_names_hint_the_script() {
|
||||
// GBK 保存 reads as common text in GBK, Big5 and EUC-KR alike; only the Chinese class
|
||||
// name of another step (whose panel name is ASCII, so it settles nothing itself) says GBK.
|
||||
let items = vec![known(b"Adjust", "调整"), bare(AtnEvent::name("x"), b"\xb1\xa3\xb4\xe6")];
|
||||
let f = parse(&one_action("Set 1", "Action 1", items)).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("GBK"));
|
||||
assert_eq!(labels(&f), ["调整", "保存"]);
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin1_symbol_names_read_as_windows_1252() {
|
||||
// Windows Photoshop "Größe »" and "50 °" with no Unicode copy: Mac Roman reads these
|
||||
// bytes as letters and symbols ("Grˆfle ª", "50 ∞"), but they are windows-1252, as before
|
||||
// #6743.
|
||||
let f = parse(&one_action("Größe ändern", "Aktion", vec![bare(AtnEvent::name("x"), b"Gr\xf6\xdfe \xbb")])).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("windows-1252"));
|
||||
assert_eq!(labels(&f), ["Größe »"]);
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
let f = parse(&one_action("Größe ändern", "Aktion", vec![bare(AtnEvent::name("x"), b"50 \xb0")])).unwrap();
|
||||
assert_eq!(labels(&f), ["50 °"]);
|
||||
// Known limitation: "Logo ®" alone reads as Mac Roman "Logo Æ", as Mac "Æble" needs that
|
||||
// byte (codepage's symbols_on_mac_roman_letters_stay_ambiguous).
|
||||
let f = parse(&one_action("Set 1", "Action 1", vec![bare(AtnEvent::name("x"), b"Logo \xae")])).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("macintosh"));
|
||||
assert_eq!(labels(&f), ["Logo Æ"]);
|
||||
// Mac Roman bytes that windows-1252 reads as controls or rare letters stay Mac Roman.
|
||||
let f =
|
||||
parse(&one_action("Définir", "Action 1", vec![bare(AtnEvent::name("x"), b"Gr\x9a\xa7e"), bare(AtnEvent::name("y"), b"Arri\x8fre-plan")])).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("macintosh"));
|
||||
assert_eq!(labels(&f), ["Größe", "Arrière-plan"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_roman_sharp_s_and_slashed_o_stay_mac_roman() {
|
||||
// Mac Roman ß ø æ Æ are the bytes of windows-1252 § ¿ ¾ ®: German and Danish Mac names
|
||||
// with no Unicode copy still read as Mac Roman, each alone and as a set.
|
||||
let words: [&[u8]; 5] = [b"Wei\xa7", b"Gau\xa7scher Weichzeichner", b"St\xbfrrelse", b"L\xberredst\xbfrrelse", b"\xaeble"];
|
||||
let text = ["Weiß", "Gaußscher Weichzeichner", "Størrelse", "Lærredstørrelse", "Æble"];
|
||||
for (bytes, text) in words.iter().zip(text) {
|
||||
let f = parse(&one_action("Größe ändern", "Aktion 1", vec![bare(AtnEvent::name("x"), bytes)])).unwrap();
|
||||
assert_eq!((f.encoding.as_deref(), labels(&f)), (Some("macintosh"), vec![text.to_string()]));
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
}
|
||||
let items = words.iter().map(|w| bare(AtnEvent::name("x"), w)).collect();
|
||||
let f = parse(&one_action("Größe ändern", "Aktion 1", items)).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("macintosh"));
|
||||
assert_eq!(labels(&f), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_roman_bullets_stay_mac_roman() {
|
||||
// Mac Roman • (0xA5) is windows-1252 ¥, which must not count for windows-1252.
|
||||
for (set, bytes, text) in [
|
||||
("Größe ändern", &b"Ebene \xa5 Kopie"[..], "Ebene • Kopie"),
|
||||
("Définir", b"Niveaux \xa5 Courbes", "Niveaux • Courbes"),
|
||||
("Tamaño de imagen", b"Tama\x96o \xa5 50%", "Tamaño • 50%"),
|
||||
] {
|
||||
let f = parse(&one_action(set, "Aktion 1", vec![bare(AtnEvent::name("x"), bytes)])).unwrap();
|
||||
assert_eq!((f.encoding.as_deref(), labels(&f)), (Some("macintosh"), vec![text.to_string()]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_sets_with_latin_compounds_stay_windows_1251() {
|
||||
// Ordinary windows-1251 names plus one glued to a Latin word ("CMYKканал"): the whole
|
||||
// set reads as Cyrillic, with no warning.
|
||||
let words: [&[u8]; 5] = [
|
||||
b"\xcd\xee\xe2\xfb\xe9 \xf1\xeb\xee\xe9",
|
||||
b"\xd0\xe0\xe7\xec\xe5\xf0 \xe8\xe7\xee\xe1\xf0\xe0\xe6\xe5\xed\xe8\xff",
|
||||
b"\xca\xee\xef\xe8\xff \xf1\xeb\xee\xff 2",
|
||||
b"\xca\xe0\xed\xe0\xeb RGB",
|
||||
b"CMYK\xea\xe0\xed\xe0\xeb",
|
||||
];
|
||||
let text = ["Новый слой", "Размер изображения", "Копия слоя 2", "Канал RGB", "CMYKканал"];
|
||||
let items = words.iter().map(|w| bare(AtnEvent::name("x"), w)).collect();
|
||||
let f = parse(&one_action("Набор 1", "Операция 1", items)).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("windows-1251"));
|
||||
assert_eq!(labels(&f), text);
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_names_alone_never_warn() {
|
||||
// Nothing decodes to the Unicode name, but nothing is shown from the bytes either.
|
||||
let f = parse(&one_action("Set 1", "Action 1", vec![known(b"\x80\xff\x80", "Something")])).unwrap();
|
||||
assert_eq!(f.encoding, None);
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
assert_eq!(labels(&f), ["Something"]);
|
||||
}
|
||||
|
||||
#[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.warnings.len(), 1, "{:?}", f.warnings);
|
||||
assert!(f.warnings[0].contains("windows-1252"), "{:?}", f.warnings);
|
||||
assert_eq!(labels(&f), ["€ÿ€"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_files_have_no_encoding() {
|
||||
let f = parse(&write(&set()).unwrap()).unwrap();
|
||||
assert_eq!(f.encoding, None);
|
||||
let items: Vec<&AtnItem> = f.actions.iter().flat_map(|a| &a.items).collect();
|
||||
assert_eq!(labels(&f), items.iter().map(|i| i.label()).collect::<Vec<_>>());
|
||||
let f = parse(&one_action("Set", "A", vec![bare(AtnEvent::name("x"), "高斯模糊 ✓".as_bytes())])).unwrap();
|
||||
assert_eq!((f.encoding.clone(), labels(&f)), (None, vec!["高斯模糊 ✓".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_lookalike_names_decode_with_the_file() {
|
||||
let items = vec![
|
||||
bare(AtnEvent::name("x"), GBK_SELECTIVE_COLOR),
|
||||
bare(AtnEvent::name("y"), GBK_DESATURATE),
|
||||
bare(AtnEvent::name("z"), "Gaussian Blur ✓".as_bytes()),
|
||||
];
|
||||
let f = parse(&one_action("相馆", "调白", items)).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("GBK"));
|
||||
assert_eq!(labels(&f), ["可选颜色", "去色", "Gaussian Blur ✓"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_names_stay_in_single_byte_files() {
|
||||
// windows-1252 "Größe" beside a UTF-8 "Weiß"; windows-1251 "Уровни" beside a UTF-8 "Привет".
|
||||
let latin = vec![bare(AtnEvent::name("x"), b"Gr\xf6\xdfe"), bare(AtnEvent::name("y"), "Weiß".as_bytes())];
|
||||
let f = parse(&one_action("Größe ändern", "Aktion", latin)).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("windows-1252"));
|
||||
assert_eq!(labels(&f), ["Größe", "Weiß"]);
|
||||
let cyrillic = vec![bare(AtnEvent::name("x"), b"\xd3\xf0\xee\xe2\xed\xe8"), bare(AtnEvent::name("y"), "Привет".as_bytes())];
|
||||
let f = parse(&one_action("Набор", "Операция", cyrillic)).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("windows-1251"));
|
||||
assert_eq!(labels(&f), ["Уровни", "Привет"]);
|
||||
// A forced single-byte encoding leaves UTF-8 names alone too.
|
||||
let f = parse_with_encoding(&one_action("S", "A", vec![bare(AtnEvent::name("y"), "Weiß".as_bytes())]), Some("windows-1252")).unwrap();
|
||||
assert_eq!(labels(&f), ["Weiß"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_encoding_wins() {
|
||||
let f = parse_with_encoding(&gbk_set(), Some("big5")).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("Big5"));
|
||||
assert_ne!(labels(&f), ["可选颜色"]);
|
||||
// A wrong override still loads, and saves the bytes it read.
|
||||
let f = parse_with_encoding(&gbk_set(), Some("shift_jis")).unwrap();
|
||||
assert_eq!(f.encoding.as_deref(), Some("Shift_JIS"));
|
||||
assert_eq!(write(&f).unwrap(), gbk_set());
|
||||
// Forcing applies to all-UTF-8 files too.
|
||||
assert_eq!(parse_with_encoding(&write(&set()).unwrap(), Some("gbk")).unwrap().encoding.as_deref(), Some("GBK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_labels_are_errors() {
|
||||
for bad in ["nope", "replacement", "utf-16be", ""] {
|
||||
assert!(matches!(parse_with_encoding(&gbk_set(), Some(bad)), Err(PsdError::Unsupported(_))), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_files_never_panic() {
|
||||
let items = vec![known(b"D\x8efinir", "Définir"), bare(AtnEvent::Name(GBK_SELECTIVE_COLOR.to_vec()), GBK_DESATURATE)];
|
||||
let seed = one_action("相馆", "调白", items);
|
||||
let check = |bytes: &[u8]| {
|
||||
for label in LABELS {
|
||||
if let Ok(f) = parse_with_encoding(bytes, label) {
|
||||
let _ = labels(&f);
|
||||
}
|
||||
}
|
||||
};
|
||||
for cut in 0..seed.len() {
|
||||
check(&seed[..cut]);
|
||||
}
|
||||
let mut x: u32 = 0x85eb_ca6b;
|
||||
for i in 0..500 {
|
||||
let mut bytes = seed.clone();
|
||||
for _ in 0..(i % 5 + 1) {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
let at = (x as usize) % bytes.len();
|
||||
bytes[at] = (x >> 8) as u8;
|
||||
}
|
||||
check(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_never_panics() {
|
||||
let seed = write(&set()).unwrap();
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
//! Guessing the legacy code page of 8-bit names.
|
||||
//!
|
||||
//! Some formats store names as bytes in the writer's system ANSI code page: an `.atn` file's
|
||||
//! event and dictionary names are CP936 (GBK) from Chinese Photoshop, CP932 (Shift_JIS) from
|
||||
//! Japanese, Mac Roman from French Photoshop on a Mac, and so on. Nothing in the file says
|
||||
//! which. When the file also stores some of those names as Unicode, [`match_known`] finds the
|
||||
//! code page that turns the bytes into them. Otherwise [`guess`] infers one code page per file
|
||||
//! from the names that are not valid UTF-8:
|
||||
//!
|
||||
//! 1. Candidates: Shift_JIS, GBK, Big5, EUC-KR, windows-1252, windows-1251, macintosh (Mac
|
||||
//! Roman). A candidate that cannot decode every name without errors is dropped.
|
||||
//! 2. Each survivor is scored by the share of non-ASCII bytes that fall in its common-text
|
||||
//! ranges (GB2312 hanzi, JIS level-1 kanji and kana, Big5 level 1, KS X 1001 Hangul,
|
||||
//! letters, and each page's punctuation). The highest share wins.
|
||||
//! 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`].
|
||||
//! 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.
|
||||
//!
|
||||
//! Only the first [`GUESS_SAMPLE`] bytes of names are examined.
|
||||
|
||||
use encoding_rs::{
|
||||
BIG5, DecoderResult, EUC_KR, Encoding, GBK, ISO_2022_JP, MACINTOSH, REPLACEMENT, SHIFT_JIS, UTF_8, UTF_16BE, UTF_16LE, WINDOWS_1251, WINDOWS_1252,
|
||||
X_USER_DEFINED,
|
||||
};
|
||||
|
||||
use crate::error::{PsdError, Result};
|
||||
|
||||
/// At most this many bytes of legacy names are examined by [`guess`].
|
||||
pub(crate) const GUESS_SAMPLE: usize = 64 * 1024;
|
||||
/// At most this many `(bytes, text)` pairs are examined by [`match_known`].
|
||||
pub(crate) const KNOWN_PAIRS: usize = 256;
|
||||
|
||||
/// The code page [`guess`] settled on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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.
|
||||
pub(crate) uncertain: bool,
|
||||
}
|
||||
|
||||
/// Candidates in tie order (encoding_rs exposes encodings as statics, hence a function).
|
||||
fn candidates() -> [&'static Encoding; 7] {
|
||||
[SHIFT_JIS, GBK, BIG5, EUC_KR, WINDOWS_1252, WINDOWS_1251, MACINTOSH]
|
||||
}
|
||||
|
||||
/// The candidate that decodes the most of `pairs` (a name's bytes and the same name as Unicode)
|
||||
/// exactly; ties go to the earlier candidate. `None` when it decodes none of them. Only the first
|
||||
/// [`KNOWN_PAIRS`] pairs are examined.
|
||||
pub(crate) fn match_known(pairs: &[(&[u8], &str)]) -> Option<&'static Encoding> {
|
||||
let pairs = pairs.get(..KNOWN_PAIRS).unwrap_or(pairs);
|
||||
let mut best = None;
|
||||
let mut best_count = 0usize;
|
||||
for enc in candidates() {
|
||||
let count = pairs.iter().filter(|(bytes, text)| enc.decode_without_bom_handling_and_without_replacement(bytes).is_some_and(|d| d == *text)).count();
|
||||
if count > best_count {
|
||||
best = Some(enc);
|
||||
best_count = count;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Guesses the one code page that `legacy` (names that are not valid UTF-8, in file order) were
|
||||
/// written in. `hint` is text from the same file whose script points at the writer's locale.
|
||||
pub(crate) fn guess(legacy: &[&[u8]], hint: &str) -> Guess {
|
||||
let sample = sample(legacy);
|
||||
if sample.is_empty() {
|
||||
return Guess { encoding: UTF_8, uncertain: false };
|
||||
}
|
||||
let mut best: Vec<&'static Encoding> = Vec::new();
|
||||
let mut best_score = (0u64, 1u64);
|
||||
for enc in candidates() {
|
||||
if !sample.iter().all(|(name, truncated)| decodes_cleanly(enc, name, *truncated)) {
|
||||
continue;
|
||||
}
|
||||
let score = sample.iter().fold((0u64, 0u64), |(c, h), (name, _)| {
|
||||
let (nc, nh) = score(enc, name);
|
||||
(c.saturating_add(nc), h.saturating_add(nh))
|
||||
});
|
||||
let score = if score.1 == 0 { (0, 1) } else { score };
|
||||
// Compare common/high shares without division: a/b vs c/d ⇔ a·d vs c·b.
|
||||
let lhs = u128::from(score.0) * u128::from(best_score.1);
|
||||
let rhs = u128::from(best_score.0) * u128::from(score.1);
|
||||
if best.is_empty() || lhs > rhs {
|
||||
best = vec![enc];
|
||||
best_score = score;
|
||||
} else if lhs == rhs {
|
||||
best.push(enc);
|
||||
}
|
||||
}
|
||||
let picked = match best.as_slice() {
|
||||
[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 },
|
||||
},
|
||||
};
|
||||
if picked.encoding != MACINTOSH {
|
||||
return picked;
|
||||
}
|
||||
// windows-1252 maps every byte, so it is always a survivor here.
|
||||
let latin = |enc| {
|
||||
sample.iter().fold((0u64, 0u64), |(c, h), (name, _)| {
|
||||
let (nc, nh) = single_byte_score(enc, name, is_latin_common);
|
||||
(c.saturating_add(nc), h.saturating_add(nh))
|
||||
})
|
||||
};
|
||||
let (windows, mac) = (latin(WINDOWS_1252), latin(MACINTOSH));
|
||||
let lhs = u128::from(windows.0) * u128::from(mac.1.max(1));
|
||||
let rhs = u128::from(mac.0) * u128::from(windows.1.max(1));
|
||||
if lhs < rhs {
|
||||
picked
|
||||
} else if lhs > rhs || hinted(hint).contains(&WINDOWS_1252) {
|
||||
Guess { encoding: WINDOWS_1252, uncertain: false }
|
||||
} else {
|
||||
Guess { encoding: WINDOWS_1252, uncertain: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes `bytes` with `enc`; invalid sequences become U+FFFD.
|
||||
pub(crate) fn decode(bytes: &[u8], enc: &'static Encoding) -> String {
|
||||
enc.decode_without_bom_handling(bytes).0.into_owned()
|
||||
}
|
||||
|
||||
/// Whether `name` decodes cleanly in the double-byte page `enc` and every non-ASCII byte of it is
|
||||
/// common text there. A name in such a page can be valid UTF-8 by accident (GBK 去色 is
|
||||
/// `C8 A5 C9 AB`, "ȥɫ"); real UTF-8 rarely passes, as its continuation bytes fall outside the
|
||||
/// common ranges. Single-byte pages are never asked: their letters cover UTF-8's lead and
|
||||
/// continuation bytes ("Weiß" is "Weiß" in windows-1252), and a legacy name there is valid UTF-8
|
||||
/// by accident only for unlikely pairs such as "é".
|
||||
pub(crate) fn reads_as_common(name: &[u8], enc: &'static Encoding) -> bool {
|
||||
if ![SHIFT_JIS, GBK, BIG5, EUC_KR].contains(&enc) {
|
||||
return false;
|
||||
}
|
||||
let (common, high) = score(enc, name);
|
||||
high > 0 && common == high && decodes_cleanly(enc, name, false)
|
||||
}
|
||||
|
||||
/// Resolves an encoding label (`shift_jis`, `gbk`, `big5`, `euc-kr`, `windows-1252`, `utf-8`…;
|
||||
/// case and surrounding whitespace are ignored). Labels for encodings that cannot hold an 8-bit
|
||||
/// name are rejected, and so is ISO-2022-JP: its names are 7-bit escape sequences, which would
|
||||
/// pass through as ASCII.
|
||||
pub(crate) fn resolve_label(label: &str) -> Result<&'static Encoding> {
|
||||
match Encoding::for_label(label.as_bytes()) {
|
||||
Some(enc) if ![REPLACEMENT, UTF_16LE, UTF_16BE, X_USER_DEFINED, ISO_2022_JP].contains(&enc) => Ok(enc),
|
||||
_ => Err(PsdError::Unsupported(format!("text encoding `{label}`: use a label such as shift_jis, gbk, big5, euc-kr, windows-1252 or utf-8"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The names [`guess`] examines, each with whether it was cut short: names in order while they
|
||||
/// fit in [`GUESS_SAMPLE`] bytes, and at least the first (truncated if it alone is longer).
|
||||
fn sample<'a>(legacy: &[&'a [u8]]) -> Vec<(&'a [u8], bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for name in legacy {
|
||||
let next = total.saturating_add(name.len());
|
||||
if next > GUESS_SAMPLE {
|
||||
if out.is_empty() {
|
||||
out.push((name.get(..GUESS_SAMPLE).unwrap_or(name), true));
|
||||
}
|
||||
break;
|
||||
}
|
||||
out.push((*name, false));
|
||||
total = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `enc` decodes `bytes` without a single error. A `truncated` name may end inside a
|
||||
/// character, which is not held against it.
|
||||
fn decodes_cleanly(enc: &'static Encoding, bytes: &[u8], truncated: bool) -> bool {
|
||||
let mut decoder = enc.new_decoder_without_bom_handling();
|
||||
// Every candidate decodes one byte to at most three UTF-8 bytes, so this is linear in
|
||||
// `bytes` (a sampled name, or one name the file already holds).
|
||||
let mut out = String::with_capacity(bytes.len().saturating_mul(3).saturating_add(16));
|
||||
let mut rest = bytes;
|
||||
loop {
|
||||
let (result, read) = decoder.decode_to_string_without_replacement(rest, &mut out, !truncated);
|
||||
match result {
|
||||
DecoderResult::InputEmpty => return true,
|
||||
DecoderResult::Malformed(..) => return false,
|
||||
DecoderResult::OutputFull => {
|
||||
rest = rest.get(read..).unwrap_or_default();
|
||||
out.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(common, high)`: how many of a name's non-ASCII bytes `enc` reads as common text, and how
|
||||
/// many non-ASCII bytes it has.
|
||||
fn score(enc: &'static Encoding, name: &[u8]) -> (u64, u64) {
|
||||
if enc == WINDOWS_1252 || enc == WINDOWS_1251 || enc == MACINTOSH {
|
||||
// Letters and typographic punctuation are common, as the double-byte pages' punctuation
|
||||
// rows are.
|
||||
return single_byte_score(enc, name, |ch| ch.is_alphabetic() || TYPOGRAPHIC.contains(&ch));
|
||||
}
|
||||
let (mut common, mut high) = (0u64, 0u64);
|
||||
let mut i = 0usize;
|
||||
while let Some(&lead) = name.get(i) {
|
||||
if lead < 0x80 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Shift_JIS reads 0x80 and 0xA0–0xDF (half-width katakana, rare in real text) as one
|
||||
// byte; elsewhere a lone non-ASCII byte is an error the decoder already rejected.
|
||||
let single = if enc == SHIFT_JIS { lead == 0x80 || (0xA0..=0xDF).contains(&lead) || lead >= 0xFD } else { lead == 0x80 || lead == 0xFF };
|
||||
if single {
|
||||
high += 1;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// A lead byte with nothing after it is where the sample was cut (the decoder rejects one
|
||||
// at the real end of a name), so it is not scored.
|
||||
let Some(&trail) = name.get(i + 1) else { break };
|
||||
// GB18030 four-byte sequences (second byte a digit) are never common text.
|
||||
let len = if enc == GBK && trail.is_ascii_digit() { 4 } else { 2 };
|
||||
let len = len.min(name.len().saturating_sub(i));
|
||||
high += len as u64;
|
||||
if len == 2 && is_common(enc, lead, trail) {
|
||||
common += 2;
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
(common, high)
|
||||
}
|
||||
|
||||
/// [`score`] for a single-byte page, where one byte is one character and a non-ASCII byte is a
|
||||
/// non-ASCII character: `common` decides which of them count.
|
||||
///
|
||||
/// Known limitation: windows-1251 has Cyrillic letters (І і Ј д) where windows-1252 has ² ³ £ ä,
|
||||
/// so a windows-1252 name like "Fläche m²" or "Preis £5" scores higher as windows-1251 ("Flдche
|
||||
/// mІ", "Preis Ј5"). Discounting a Cyrillic letter glued to a Latin word would fix those but
|
||||
/// break ordinary Russian names such as "CMYKканал" or Ukrainian "Копiя" (Latin i), which turn a
|
||||
/// whole set into windows-1252.
|
||||
fn single_byte_score(enc: &'static Encoding, name: &[u8], common: impl Fn(char) -> bool) -> (u64, u64) {
|
||||
decode(name, enc).chars().filter(|c| !c.is_ascii()).fold((0, 0), |(c, h), ch| (c.saturating_add(u64::from(common(ch))), h.saturating_add(1)))
|
||||
}
|
||||
|
||||
/// Whether `ch` is common in Western names, for weighing windows-1252 against Mac Roman: letters
|
||||
/// (but not those windows-1252 has only at 0x80–0x9F, where Mac Roman keeps its accented
|
||||
/// lowercase: Mac "Größe" is 1252 "Grš§e"), typographic punctuation and [`LATIN_SYMBOLS`].
|
||||
fn is_latin_common(ch: char) -> bool {
|
||||
(ch.is_alphabetic() && !['Š', 'š', 'Ž', 'ž', 'Œ', 'œ', 'Ÿ', 'ƒ', 'ˆ'].contains(&ch)) || TYPOGRAPHIC.contains(&ch) || LATIN_SYMBOLS.contains(&ch)
|
||||
}
|
||||
|
||||
/// Latin-1 symbols people type in names (windows-1252 0xA1–0xB7) whose byte is not a letter in
|
||||
/// Mac Roman. Left out because Mac Roman has a letter there: § ® ¹ » ¼ ½ ¾ ¿ (ß Æ π ª º Ω æ ø,
|
||||
/// as in Mac "Weiß", "Størrelse", "Æble"), € and ™ (0x80, 0x99: Ä, ô), and ¥ (0xA5, Mac Roman's
|
||||
/// bullet in [`TYPOGRAPHIC`]: Mac "Ebene • Kopie"). So a windows-1252 name whose only non-ASCII
|
||||
/// character is one of those ("Logo ®", "¿Tamaño?", "Preis ¥100") still reads as Mac Roman
|
||||
/// ("Logo Æ").
|
||||
const LATIN_SYMBOLS: [char; 11] = ['¡', '¢', '£', '©', '«', '°', '±', '²', '³', '¶', '·'];
|
||||
|
||||
/// Quotes, dashes, ellipsis and bullet: windows-1252 and windows-1251 share them at 0x82–0x97;
|
||||
/// Mac Roman has them too.
|
||||
const TYPOGRAPHIC: [char; 10] = ['‘', '’', '‚', '“', '”', '„', '–', '—', '…', '•'];
|
||||
|
||||
/// Whether the two-byte character `lead trail` is in `enc`'s common-text ranges.
|
||||
fn is_common(enc: &'static Encoding, lead: u8, trail: u8) -> bool {
|
||||
let both = u16::from_be_bytes([lead, trail]);
|
||||
if enc == GBK {
|
||||
// GB2312 hanzi, and the punctuation rows 0xA1 and 0xA3.
|
||||
(0xA1..=0xFE).contains(&trail) && ((0xB0..=0xF7).contains(&lead) || lead == 0xA1 || lead == 0xA3)
|
||||
} else if enc == BIG5 {
|
||||
// Level-1 hanzi and punctuation.
|
||||
(0xA4..=0xC6).contains(&lead) || lead == 0xA1
|
||||
} else if enc == SHIFT_JIS {
|
||||
// Kana, level-1 kanji and punctuation.
|
||||
(0x829F..=0x8396).contains(&both) || (0x889F..=0x9872).contains(&both) || (0x8140..=0x81FC).contains(&both)
|
||||
} else if enc == EUC_KR {
|
||||
// KS X 1001 Hangul and punctuation.
|
||||
trail >= 0xA1 && ((0xB0..=0xC8).contains(&lead) || lead == 0xA1)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The candidates the script of `hint` points at, strongest first.
|
||||
fn hinted(hint: &str) -> Vec<&'static Encoding> {
|
||||
let has = |lo: char, hi: char| hint.chars().any(|c| (lo..=hi).contains(&c) && c.is_alphabetic());
|
||||
if has('\u{3040}', '\u{30FF}') {
|
||||
vec![SHIFT_JIS]
|
||||
} else if has('\u{AC00}', '\u{D7A3}') {
|
||||
vec![EUC_KR]
|
||||
} else if has('\u{0400}', '\u{04FF}') {
|
||||
vec![WINDOWS_1251]
|
||||
} else if has('\u{4E00}', '\u{9FFF}') {
|
||||
vec![GBK, BIG5, SHIFT_JIS]
|
||||
} else if has('\u{00C0}', '\u{024F}') {
|
||||
vec![WINDOWS_1252, MACINTOSH]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GBK_ISSUE: &[&[u8]] = &[b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab"]; // 可选颜色 (from #6743)
|
||||
const SJIS_COMMENT: &[&[u8]] = &[
|
||||
b"\x91\x49\x91\xf0\x20\x3a", // 選択 :
|
||||
b"\x90\xdd\x92\xe8\x20\x3a", // 設定 :
|
||||
b"\x83\x52\x83\x73\x81\x5b", // コピー
|
||||
b"\x95\xdb\x91\xb6", // 保存
|
||||
];
|
||||
|
||||
fn encoded(enc: &'static Encoding, words: &[&str]) -> Vec<Vec<u8>> {
|
||||
words.iter().map(|w| enc.encode(w).0.into_owned()).collect()
|
||||
}
|
||||
|
||||
fn guess_words(enc: &'static Encoding, words: &[&str], hint: &str) -> Guess {
|
||||
let bytes = encoded(enc, words);
|
||||
let refs: Vec<&[u8]> = bytes.iter().map(Vec::as_slice).collect();
|
||||
guess(&refs, hint)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_gbk_bytes_read_as_gbk() {
|
||||
let g = guess(GBK_ISSUE, "调白");
|
||||
assert_eq!(g.encoding, GBK);
|
||||
assert!(!g.uncertain);
|
||||
assert_eq!(decode(GBK_ISSUE[0], g.encoding), "可选颜色");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kanji_only_hint_still_reads_shift_jis() {
|
||||
let g = guess(SJIS_COMMENT, "保存");
|
||||
assert_eq!(g.encoding, SHIFT_JIS);
|
||||
assert_eq!(decode(SJIS_COMMENT[0], g.encoding), "選択 :");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big5_reads_as_big5() {
|
||||
assert_eq!(guess_words(BIG5, &["色彩範圍", "移動", "選取", "儲存"], "").encoding, BIG5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hangul_hint_reads_euc_kr() {
|
||||
let g = guess_words(EUC_KR, &["선택", "이동", "저장", "색상 범위"], "동작 1");
|
||||
assert_eq!(g.encoding, EUC_KR);
|
||||
assert!(!g.uncertain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyrillic_hint_reads_1251() {
|
||||
let g = guess_words(WINDOWS_1251, &["Выделить", "Переместить", "Сохранить"], "Набор 1");
|
||||
assert_eq!(g.encoding, WINDOWS_1251);
|
||||
assert!(!g.uncertain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_hint_reads_1252() {
|
||||
let g = guess_words(WINDOWS_1252, &["Größe"], "Größe ändern");
|
||||
assert_eq!(g.encoding, WINDOWS_1252);
|
||||
assert!(!g.uncertain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tie_without_hint_is_1252_and_uncertain() {
|
||||
let g = guess_words(WINDOWS_1252, &["Größe"], "Set 1");
|
||||
assert_eq!(g.encoding, WINDOWS_1252);
|
||||
assert!(g.uncertain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn han_tie_prefers_gbk() {
|
||||
// GBK 保存: GBK, Big5 and EUC-KR all read both characters as common text.
|
||||
let names: &[&[u8]] = &[b"\xb1\xa3\xb4\xe6"];
|
||||
let g = guess(names, "动作");
|
||||
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 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typographic_punctuation_reads_as_1252() {
|
||||
// ’ (0x92) then a letter is also a level-1 kanji pair in Shift_JIS.
|
||||
let names: &[&[u8]] = &[b"Taille de l\x92image"];
|
||||
let g = guess(names, "Actions par défaut");
|
||||
assert_eq!(g.encoding, WINDOWS_1252);
|
||||
assert!(!g.uncertain);
|
||||
assert_eq!(decode(names[0], g.encoding), "Taille de l’image");
|
||||
assert_eq!(guess(names, "Set 1"), Guess { encoding: WINDOWS_1252, uncertain: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_lookalikes_read_as_common_text() {
|
||||
// GBK 去色 and EUC-KR 칠 are also valid UTF-8 ("ȥɫ", "ĥ").
|
||||
assert!(reads_as_common(b"\xc8\xa5\xc9\xab", GBK));
|
||||
assert!(reads_as_common(b"\xc4\xa5", EUC_KR));
|
||||
// Real UTF-8 text does not: its continuation bytes fall outside the common ranges.
|
||||
assert!(!reads_as_common("高斯模糊".as_bytes(), GBK));
|
||||
assert!(!reads_as_common("Gaussian Blur ✓".as_bytes(), GBK));
|
||||
assert!(!reads_as_common("Größe".as_bytes(), WINDOWS_1252));
|
||||
assert!(!reads_as_common(b"Blur", GBK), "ASCII has nothing to re-read");
|
||||
// Nor does UTF-8 in a single-byte page, though "Weiß" is à and Ÿ there and "Привет" is
|
||||
// all Р, С and letters: accidental UTF-8 is a double-byte page's problem.
|
||||
assert!(!reads_as_common("Weiß".as_bytes(), WINDOWS_1252));
|
||||
assert!(!reads_as_common("Привет".as_bytes(), WINDOWS_1251));
|
||||
assert!(!reads_as_common("Weiß".as_bytes(), MACINTOSH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin1_symbols_read_as_1252_not_mac_roman() {
|
||||
// Mac Roman has letters where windows-1252 has some symbols: 1252 "Größe »" is Mac Roman
|
||||
// "Grˆfle ª", "50 °" is "50 ∞". Latin-1 (what a code-page-blind reader shows) wins.
|
||||
for words in [&["Größe »"][..], &["50 °"], &["Contraste « auto »", "© Größe", "± 5"]] {
|
||||
let g = guess_words(WINDOWS_1252, words, "Größe ändern");
|
||||
assert_eq!(g, Guess { encoding: WINDOWS_1252, uncertain: false }, "{words:?}");
|
||||
assert_eq!(guess_words(WINDOWS_1252, words, "Set 1").encoding, WINDOWS_1252, "{words:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_on_mac_roman_letters_stay_ambiguous() {
|
||||
// Known limitation: 1252 "Logo ®" is Mac Roman "Logo Æ", and ® is not counted for
|
||||
// windows-1252 because Mac "Æble" and "Weiß" use those bytes, so the name reads as Mac
|
||||
// Roman whatever the hint, as it did before the symbols counted at all.
|
||||
let logo: &[&[u8]] = &[b"Logo \xae"];
|
||||
assert_eq!(guess(logo, "Größe ändern"), Guess { encoding: MACINTOSH, uncertain: false });
|
||||
assert_eq!(guess(logo, "Set 1"), Guess { encoding: MACINTOSH, uncertain: false });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_roman_letters_still_read_as_mac_roman() {
|
||||
// Mac Roman "Größe" is 1252 "Grš§e", "Ändern" "€ndern", "Contrôle" "Contr™le": Mac Roman
|
||||
// still wins once § counts as common text in windows-1252.
|
||||
// Mac Roman ß ø æ Æ are 1252 § ¿ ¾ ®, which windows-1252 must not count as common.
|
||||
let words = [
|
||||
"Größe",
|
||||
"Ändern",
|
||||
"Contrôle",
|
||||
"Weiß",
|
||||
"Schwarzweiß",
|
||||
"Gaußscher Weichzeichner",
|
||||
"Schließen",
|
||||
"Størrelse",
|
||||
"Lærredstørrelse",
|
||||
"Fjern støy",
|
||||
"Æble",
|
||||
];
|
||||
for word in words {
|
||||
let g = guess_words(MACINTOSH, &[word], "Définir l’arrière-plan");
|
||||
assert_eq!(g, Guess { encoding: MACINTOSH, uncertain: false }, "{word}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_hint_reads_mac_roman_when_1252_does_not_fit() {
|
||||
// Mac Roman "Arrière-plan": è is 0x8F, a control character in windows-1252.
|
||||
let names: &[&[u8]] = &[b"Arri\x8fre-plan"];
|
||||
let g = guess(names, "Définir l’arrière-plan");
|
||||
assert_eq!(g.encoding, MACINTOSH);
|
||||
assert!(!g.uncertain);
|
||||
assert_eq!(decode(names[0], g.encoding), "Arrière-plan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_names_pick_the_code_page() {
|
||||
// Every step of the corpus/atn sets that has a non-ASCII panel name also stores it as
|
||||
// Unicode (the descriptor's class name); here, Mac Roman "Définir".
|
||||
let pairs: &[(&[u8], &str)] = &[(b"D\x8efinir", "Définir")];
|
||||
assert_eq!(match_known(pairs), Some(MACINTOSH));
|
||||
// GBK 保存 is a three-way tie for the statistics; the known name settles it.
|
||||
assert_eq!(match_known(&[(b"\xb1\xa3\xb4\xe6", "保存")]), Some(GBK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn most_matching_names_win() {
|
||||
let pairs: &[(&[u8], &str)] = &[(b"\xb1\xa3\xb4\xe6", "保存"), (b"D\x8efinir", "Définir"), (b"Arri\x8fre-plan", "Arrière-plan")];
|
||||
assert_eq!(match_known(pairs), Some(MACINTOSH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_name_ties_take_candidate_order() {
|
||||
// Curly quotes are 0x93/0x94 in both windows-1252 and windows-1251.
|
||||
assert_eq!(match_known(&[(b"\x93x\x94", "“x”")]), Some(WINDOWS_1252));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_names_that_match_nothing_decide_nothing() {
|
||||
assert_eq!(match_known(&[(b"D\x8efinir", "Set")]), None);
|
||||
assert_eq!(match_known(&[]), None);
|
||||
// Past the cap, pairs are not examined.
|
||||
let mut pairs = vec![(&b"D\x8efinir"[..], "x"); KNOWN_PAIRS];
|
||||
pairs.push((b"\xb1\xa3\xb4\xe6", "保存"));
|
||||
assert_eq!(match_known(&pairs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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.
|
||||
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!(guess(names, "Набор").encoding, WINDOWS_1251);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_utf8_input_is_utf8() {
|
||||
let g = guess(&[], "");
|
||||
assert_eq!(g.encoding, UTF_8);
|
||||
assert!(!g.uncertain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_code_pages_pick_one_and_never_fail() {
|
||||
let names: &[&[u8]] = &[GBK_ISSUE[0], SJIS_COMMENT[0]];
|
||||
let g = guess(names, "");
|
||||
for name in names {
|
||||
assert!(!decode(name, g.encoding).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_input_is_sampled() {
|
||||
let names = vec![GBK_ISSUE[0]; 10_000];
|
||||
assert_eq!(guess(&names, "").encoding, GBK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_name_over_the_sample_cap_is_truncated_not_rejected() {
|
||||
// "a" then 可 repeated past the cap, so the cut lands inside a two-byte character.
|
||||
let mut name = b"a".to_vec();
|
||||
name.extend(b"\xbf\xc9".repeat(GUESS_SAMPLE / 2 + 1));
|
||||
assert_eq!(guess(&[&name], "可").encoding, GBK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_resolve_and_reject() {
|
||||
for ok in ["shift_jis", "gbk", "big5", "euc-kr", "windows-1252", "utf-8"] {
|
||||
assert!(resolve_label(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in ["nope", "replacement", "utf-16le", "x-user-defined", "", "iso-2022-kr", "iso-2022-jp", "csISO2022JP"] {
|
||||
let Err(e) = resolve_label(bad) else { panic!("{bad} should be rejected") };
|
||||
assert!(e.to_string().contains("shift_jis"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_match_loosely() {
|
||||
assert_eq!(resolve_label("Shift_JIS").ok(), Some(SHIFT_JIS));
|
||||
assert_eq!(resolve_label(" gbk ").ok(), Some(GBK));
|
||||
assert_eq!(resolve_label("gb2312").ok(), Some(GBK));
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ pub mod asl;
|
||||
pub mod atn;
|
||||
pub mod blend;
|
||||
pub mod builder;
|
||||
mod codepage;
|
||||
pub mod compression;
|
||||
pub mod descriptor;
|
||||
pub mod error;
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Synthetic ATN parser fixtures
|
||||
|
||||
These four tiny files were independently encoded field by field from the version-16 layout
|
||||
documented in `src/atn.rs`, without calling PhotoCraft's writer. They are original test data,
|
||||
licensed MIT OR Apache-2.0 under the repository's licenses. They contain no user artwork,
|
||||
Photoshop program code or proprietary assets. They are not Photoshop-authored interoperability
|
||||
oracles.
|
||||
|
||||
| File | Bytes | Purpose | SHA-256 |
|
||||
|---|---:|---|---|
|
||||
| `empty.atn` | 13 | Empty set and explicit big-endian version | `6fd936f91296a13b0e7456a38b139a100fd17407d1c582d4502ce76413d01539` |
|
||||
| `metadata.atn` | 214 | UTF-16 surrogate pairs/unpaired units, action order, function key, modifiers, panel flags, disabled step, unknown string event, dialog options | `cec2ff5a25e003aa29e790ea6b110f06ba848f0afd7901f3f8ad4740f782ede2` |
|
||||
| `descriptors.atn` | 859 | Every descriptor value variant and every reference form currently represented by the parser | `6c76502822c1f36c8060a71adb227c4540663518785bbab1b70951dac0ef58ad` |
|
||||
| `float-bits.atn` | 428 | NaN payload, negative zero, infinities, and floats inside lists, objects and arrays | `0aa6278a569c777f249b305e889896132c2a87e44a24cb1a55d1d3fc0928fd0c` |
|
||||
|
||||
`tests/it/atn_contract.rs` checks the independently encoded bytes and expected public structures.
|
||||
A fixture change should explain
|
||||
the changed wire fields and update this table; do not regenerate expectations merely to make a
|
||||
failing parser pass.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,239 @@
|
||||
//! Public-API contract for the `.atn` parser, against independently encoded wire fixtures.
|
||||
use photocraft_psd::PsdError;
|
||||
use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem};
|
||||
use photocraft_psd::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value};
|
||||
|
||||
#[path = "common/atn_roundtrip.rs"]
|
||||
mod atn_roundtrip;
|
||||
|
||||
const FIXTURES: &[(&str, &[u8])] = &[
|
||||
("empty", include_bytes!("../fixtures/atn/empty.atn")),
|
||||
("metadata", include_bytes!("../fixtures/atn/metadata.atn")),
|
||||
("descriptors", include_bytes!("../fixtures/atn/descriptors.atn")),
|
||||
("float bits", include_bytes!("../fixtures/atn/float-bits.atn")),
|
||||
];
|
||||
|
||||
fn file_with(desc: Descriptor) -> AtnFile {
|
||||
AtnFile {
|
||||
actions: vec![AtnAction {
|
||||
items: vec![AtnItem { event: AtnEvent::Code(*b"test"), descriptor: Some(desc), ..Default::default() }],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn class(id: &str) -> Class {
|
||||
Class { name: UnicodeString::default(), class_id: Id::new(id) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_wire_fixtures_parse_and_write_byte_exactly() {
|
||||
for (name, bytes) in FIXTURES {
|
||||
let parsed = atn::parse(bytes).unwrap();
|
||||
assert!(parsed.warnings.is_empty(), "{name}");
|
||||
assert_eq!(atn::write(&parsed).unwrap(), *bytes, "{name}");
|
||||
atn_roundtrip::check(bytes);
|
||||
}
|
||||
assert_eq!(atn::write(&AtnFile::default()).unwrap(), FIXTURES[0].1);
|
||||
assert_eq!(&FIXTURES[0].1[..4], &[0, 0, 0, 16], "the wire version is big-endian on every host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_metadata_unknown_events_and_dialog_flags_are_preserved() {
|
||||
let file = atn::parse(FIXTURES[1].1).unwrap();
|
||||
assert_eq!(file.name.0, vec![0x0053, 0x0065, 0x0074, 0x0020, 0xd83d, 0xde80, 0xd800, 0]);
|
||||
assert!(file.expanded);
|
||||
assert_eq!(file.actions.len(), 2);
|
||||
let action = &file.actions[0];
|
||||
assert_eq!(action.name, UnicodeString::new_nul("Action Ω"));
|
||||
assert_eq!((action.function_key, action.shift, action.command, action.color, action.expanded), (12, true, true, 7, true));
|
||||
let blur = &action.items[0];
|
||||
assert_eq!((blur.expanded, blur.enabled, blur.with_dialog, blur.dialog_options), (true, true, true, 2));
|
||||
assert_eq!(blur.event, AtnEvent::Code(*b"GsnB"));
|
||||
assert_eq!(blur.descriptor, Some(Descriptor::new("null").with("Rds ", Value::UnitFloat { unit: *b"#Pxl", value: 2.5 })));
|
||||
let future = &action.items[1];
|
||||
assert_eq!(future.event, AtnEvent::Name("syntheticFutureEvent".into()));
|
||||
assert_eq!((future.expanded, future.enabled, future.with_dialog, future.dialog_options), (false, false, true, 255));
|
||||
assert_eq!(future.dictionary, b"Future event");
|
||||
assert!(future.descriptor.is_none());
|
||||
assert_eq!(file.actions[1].name, UnicodeString::new_nul("Empty"));
|
||||
assert!(file.actions[1].items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_descriptor_variant_and_reference_form_is_retained() {
|
||||
let expected = Descriptor::new("syntheticRoot")
|
||||
.with(
|
||||
"reference",
|
||||
Value::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Lyr "), key: Id::new("Nm ") },
|
||||
ReferenceItem::Class(class("Dcmn")),
|
||||
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
ReferenceItem::Offset { class: class("Lyr "), offset: -3 },
|
||||
ReferenceItem::Identifier { class: class("Lyr "), id: 0x1234_5678 },
|
||||
ReferenceItem::Index { class: class("Lyr "), index: 4 },
|
||||
ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Layer Ω") },
|
||||
]),
|
||||
)
|
||||
.with("nested", Value::Descriptor(Descriptor::new("child").with("flag", Value::Boolean(true))))
|
||||
.with("global", Value::GlobalObject(Descriptor::new("global")))
|
||||
.with("list", Value::List(vec![Value::Boolean(false), Value::Double(-3.5), Value::Text(UnicodeString::new("Ω"))]))
|
||||
.with("double", Value::Double(1.25))
|
||||
.with("unit", Value::UnitFloat { unit: *b"#Prc", value: 37.5 })
|
||||
.with("units", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.25, -0.0, -2.5] })
|
||||
.with("text", Value::Text(UnicodeString(vec![0x0054, 0x0065, 0x0078, 0x0074, 0x0020, 0xd83d, 0xde80, 0xdfff, 0])))
|
||||
.with("enum", Value::Enumerated { type_id: Id::new("Ordn"), value: Id::new("syntheticValue") })
|
||||
.with("integer", Value::Integer(i32::MIN))
|
||||
.with("large", Value::LargeInteger(0x0123_4567_89ab_cdef))
|
||||
.with("boolean", Value::Boolean(true))
|
||||
.with("class", Value::Class(class("Lyr ")))
|
||||
.with("globalClass", Value::GlobalClass(class("Dcmn")))
|
||||
.with("alias", Value::Alias(vec![0, 255, 1, 128]))
|
||||
.with("path", Value::Path(b"C:\\synthetic\\image.psd\0".to_vec()))
|
||||
.with("raw", Value::RawData(vec![0, 127, 128, 255]))
|
||||
.with(
|
||||
"objectArray",
|
||||
Value::ObjectArray(ObjectArray {
|
||||
prefix: 16,
|
||||
body: Descriptor::new("rationalPoint").with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.0, 2.0] }),
|
||||
}),
|
||||
);
|
||||
let file = atn::parse(FIXTURES[2].1).unwrap();
|
||||
assert_eq!(file.actions[0].items[0].descriptor, Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_payloads_signed_zero_and_infinities_survive_without_false_fuzz_failures() {
|
||||
let file = atn::parse(FIXTURES[3].1).unwrap();
|
||||
let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap();
|
||||
for (key, bits) in [
|
||||
("nan", 0x7ff8_0000_0000_1234),
|
||||
("negativeZero", 0x8000_0000_0000_0000),
|
||||
("positiveInfinity", 0x7ff0_0000_0000_0000),
|
||||
("negativeInfinity", 0xfff0_0000_0000_0000),
|
||||
] {
|
||||
let Some(Value::Double(value)) = descriptor.get(key) else { panic!("missing {key}") };
|
||||
assert_eq!(value.to_bits(), bits, "{key}");
|
||||
}
|
||||
// Ordinary AST PartialEq fails for NaN, despite this valid byte-exact round trip.
|
||||
assert_ne!(file.actions.clone(), file.actions);
|
||||
atn_roundtrip::check(FIXTURES[3].1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncated_fixture_is_refused() {
|
||||
for (name, bytes) in FIXTURES {
|
||||
for end in 0..bytes.len() {
|
||||
assert!(atn::parse(&bytes[..end]).is_err(), "{name}: cut at {end}/{}", bytes.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_bytes_only_add_a_diagnostic() {
|
||||
for (name, bytes) in FIXTURES {
|
||||
let mut input = bytes.to_vec();
|
||||
input.extend_from_slice(b"extra");
|
||||
let mut parsed = atn::parse(&input).unwrap();
|
||||
assert_eq!(parsed.warnings, ["5 trailing bytes ignored"], "{name}");
|
||||
parsed.warnings.clear();
|
||||
assert_eq!(atn::write(&parsed).unwrap(), *bytes, "{name}");
|
||||
atn_roundtrip::check(&input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_versions_and_invalid_item_tags_are_refused() {
|
||||
for version in [0_u32, 12, 15, 17, u32::MAX] {
|
||||
let mut input = FIXTURES[0].1.to_vec();
|
||||
input[..4].copy_from_slice(&version.to_be_bytes());
|
||||
assert!(matches!(atn::parse(&input), Err(PsdError::Unsupported(_))));
|
||||
}
|
||||
let file = file_with(Descriptor::new("null"));
|
||||
let valid = atn::write(&file).unwrap();
|
||||
// The empty set/action names put the first step at offset 28.
|
||||
for kind in [*b"bad!", *b"LONG", [0; 4]] {
|
||||
let mut input = valid.clone();
|
||||
input[32..36].copy_from_slice(&kind);
|
||||
assert!(matches!(atn::parse(&input), Err(PsdError::Invalid(_))));
|
||||
}
|
||||
for flag in [-2_i32, 1, i32::MAX] {
|
||||
let mut input = valid.clone();
|
||||
input[44..48].copy_from_slice(&flag.to_be_bytes());
|
||||
assert!(matches!(atn::parse(&input), Err(PsdError::Invalid(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_action_and_step_limits_are_enforced() {
|
||||
let mut actions = FIXTURES[0].1.to_vec();
|
||||
let count = (atn::MAX_ACTIONS + 1) as u32;
|
||||
actions[9..13].copy_from_slice(&count.to_be_bytes());
|
||||
actions.resize(13 + count as usize * 15, 0);
|
||||
assert!(matches!(atn::parse(&actions), Err(PsdError::LimitExceeded("too many actions in one set"))));
|
||||
|
||||
let mut steps = atn::write(&AtnFile { actions: vec![AtnAction::default()], ..Default::default() }).unwrap();
|
||||
let count = (atn::MAX_ITEMS + 1) as u32;
|
||||
steps[24..28].copy_from_slice(&count.to_be_bytes());
|
||||
steps.resize(28 + count as usize * 20, 0);
|
||||
assert!(matches!(atn::parse(&steps), Err(PsdError::LimitExceeded("too many steps in one action"))));
|
||||
|
||||
let mut short = FIXTURES[0].1.to_vec();
|
||||
short[9..13].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert!(matches!(atn::parse(&short), Err(PsdError::UnexpectedEof { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_descriptors_obey_the_parser_depth_limit() {
|
||||
let mut desc = Descriptor::new("leaf").with("flag", Value::Boolean(true));
|
||||
for _ in 0..2 {
|
||||
desc = Descriptor::new("node").with("next", Value::Descriptor(desc));
|
||||
}
|
||||
let shallow = atn::write(&file_with(desc.clone())).unwrap();
|
||||
atn_roundtrip::check(&shallow);
|
||||
assert!(atn::parse(&shallow).is_ok());
|
||||
for _ in 0..MAX_DEPTH + 2 {
|
||||
desc = Descriptor::new("node").with("next", Value::Descriptor(desc));
|
||||
}
|
||||
let deep = atn::write(&file_with(desc)).unwrap();
|
||||
assert!(matches!(atn::parse(&deep), Err(PsdError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_metadata_and_float_patterns_round_trip() {
|
||||
let mut seed = 0x9e37_79b9_7f4a_7c15_u64;
|
||||
for index in 0..256_u16 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
let mut file = file_with(Descriptor::new("generated").with("value", Value::Double(f64::from_bits(seed))));
|
||||
file.name = UnicodeString(vec![index, 0xd800 | index, 0, 0xffff]);
|
||||
file.expanded = index & 1 != 0;
|
||||
let action = &mut file.actions[0];
|
||||
action.function_key = index;
|
||||
action.shift = index & 2 != 0;
|
||||
action.command = index & 4 != 0;
|
||||
action.color = index;
|
||||
action.expanded = index & 8 != 0;
|
||||
action.name = UnicodeString(vec![0xdc00 | index, index]);
|
||||
let item = &mut action.items[0];
|
||||
item.expanded = index & 16 != 0;
|
||||
item.enabled = index & 32 != 0;
|
||||
item.with_dialog = index & 64 != 0;
|
||||
item.dialog_options = index as u8;
|
||||
atn_roundtrip::check(&atn::write(&file).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_fuzz_invariant_handles_deterministic_seed_mutations() {
|
||||
for (_, bytes) in FIXTURES {
|
||||
for index in 0..128 {
|
||||
let mut mutated = bytes.to_vec();
|
||||
let at = index * 47 % mutated.len();
|
||||
mutated[at] ^= (index as u8).wrapping_mul(13).wrapping_add(1);
|
||||
atn_roundtrip::check(&mutated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Shared round-trip invariant for the `.atn` parser contract suite.
|
||||
use photocraft_psd::atn::{self, AtnFile};
|
||||
use photocraft_psd::descriptor::{Descriptor, Value};
|
||||
|
||||
fn scalar(value: &mut f64, bits: &mut Vec<u64>) {
|
||||
bits.push(value.to_bits());
|
||||
// AST equality must remain meaningful when a valid descriptor contains NaN.
|
||||
if value.is_nan() {
|
||||
*value = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor(desc: &mut Descriptor, bits: &mut Vec<u64>) {
|
||||
for (_, item) in &mut desc.items {
|
||||
value(item, bits);
|
||||
}
|
||||
}
|
||||
|
||||
fn value(item: &mut Value, bits: &mut Vec<u64>) {
|
||||
match item {
|
||||
Value::Double(v) | Value::UnitFloat { value: v, .. } => scalar(v, bits),
|
||||
Value::UnitFloats { values, .. } => {
|
||||
for v in values {
|
||||
scalar(v, bits);
|
||||
}
|
||||
}
|
||||
Value::Descriptor(d) | Value::GlobalObject(d) => descriptor(d, bits),
|
||||
Value::ObjectArray(array) => descriptor(&mut array.body, bits),
|
||||
Value::List(items) => {
|
||||
for item in items {
|
||||
value(item, bits);
|
||||
}
|
||||
}
|
||||
Value::Reference(_)
|
||||
| Value::Text(_)
|
||||
| Value::Enumerated { .. }
|
||||
| Value::Integer(_)
|
||||
| Value::LargeInteger(_)
|
||||
| Value::Boolean(_)
|
||||
| Value::Class(_)
|
||||
| Value::GlobalClass(_)
|
||||
| Value::Alias(_)
|
||||
| Value::Path(_)
|
||||
| Value::RawData(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn floats(file: &mut AtnFile) -> Vec<u64> {
|
||||
let mut bits = Vec::new();
|
||||
for action in &mut file.actions {
|
||||
for item in &mut action.items {
|
||||
if let Some(d) = &mut item.descriptor {
|
||||
descriptor(d, &mut bits);
|
||||
}
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
pub fn check(data: &[u8]) {
|
||||
if let Ok(mut original) = atn::parse(data) {
|
||||
let encoded = atn::write(&original).expect("a parsed set is writable");
|
||||
let mut again = atn::parse(&encoded).expect("a written set parses");
|
||||
assert_eq!(atn::write(&again).expect("a reparsed set is writable"), encoded);
|
||||
assert_eq!(floats(&mut original), floats(&mut again), "every floating-point bit pattern survives");
|
||||
// Trailing-input diagnostics are not serialized into an action set.
|
||||
original.warnings.clear();
|
||||
again.warnings.clear();
|
||||
assert_eq!(again, original, "set metadata, action metadata and every descriptor value survive");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod atn_contract;
|
||||
mod builder;
|
||||
mod corpus;
|
||||
mod corpus_atn;
|
||||
|
||||
@@ -296,6 +296,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields:
|
||||
_ if crate::replace_prompt::owns(fields) => crate::replace_prompt::body(ui, fields),
|
||||
_ if crate::preset_group_prompt::owns(fields) => crate::preset_group_prompt::body(ui, fields),
|
||||
_ if crate::actions::owns_insert_stop(fields) => crate::actions::insert_stop_body(ui, fields),
|
||||
_ if crate::actions::owns_new_set(fields) => crate::actions::new_set_body(ui, fields),
|
||||
_ if crate::new_layer_ui::owns(fields) => crate::new_layer_ui::body(ui, fields),
|
||||
_ if crate::mask_trash_prompt::owns(fields) => crate::mask_trash_prompt::body(ui, fields),
|
||||
_ if crate::rasterize_prompt::owns(fields) => crate::rasterize_prompt::body(ui, fields),
|
||||
@@ -845,6 +846,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[
|
||||
crate::delete_layer_prompt::owns,
|
||||
crate::disk_change_prompt::owns,
|
||||
crate::actions::owns_insert_stop,
|
||||
crate::actions::owns_new_set,
|
||||
crate::new_layer_ui::owns,
|
||||
crate::new_group_ui::owns,
|
||||
crate::mask_trash_prompt::owns,
|
||||
@@ -1003,6 +1005,7 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result<Value, String>
|
||||
_ if crate::replace_prompt::owns(&d.fields) => crate::replace_prompt::confirm(app),
|
||||
_ if crate::preset_group_prompt::owns(&d.fields) => crate::preset_group_prompt::confirm(app, &d.fields),
|
||||
_ if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields),
|
||||
_ if crate::actions::owns_new_set(&d.fields) => crate::actions::new_set_confirm(app, &d.fields),
|
||||
_ if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields),
|
||||
_ if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields),
|
||||
_ if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields),
|
||||
|
||||
@@ -27,6 +27,10 @@ pub struct ActionsUi {
|
||||
/// Photoshop's Button Mode: each action is a coloured button, and a click plays it.
|
||||
#[serde(default)]
|
||||
pub button_mode: bool,
|
||||
/// The set whose folder row was clicked last (or New Set made), while no action is selected:
|
||||
/// New Action records into it, as Photoshop's does into the selected set (#7535).
|
||||
#[serde(skip)]
|
||||
pub selected_set: Option<String>,
|
||||
#[serde(skip)]
|
||||
reveal_recording: Option<(usize, usize)>,
|
||||
/// The action whose name is being edited in place (double-click on its name).
|
||||
@@ -34,6 +38,16 @@ pub struct ActionsUi {
|
||||
renaming: Option<RenameAction>,
|
||||
}
|
||||
|
||||
impl ActionsUi {
|
||||
/// Select an action (and one of its steps): it replaces a selected set, so a later deselect
|
||||
/// (Delete, a removed row) doesn't bring back a set the user moved away from.
|
||||
fn select(&mut self, action: usize, step: Option<usize>) {
|
||||
self.selected = Some(action);
|
||||
self.selected_step = step;
|
||||
self.selected_set = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct RenameAction {
|
||||
index: usize,
|
||||
@@ -51,11 +65,74 @@ pub(crate) fn label_of(id: &str) -> String {
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// The selected action, or the first one when nothing is selected.
|
||||
/// The selected action, else the first action of the selected set (Batch and Create Droplet start
|
||||
/// on the panel's selection), else the first one.
|
||||
pub fn selected_action(app: &PhotocraftApp) -> Option<&Action> {
|
||||
let list = &app.session.actions.list;
|
||||
let idx = app.ui.actions.selected.filter(|i| *i < list.len());
|
||||
idx.and_then(|i| list.get(i)).or_else(|| list.first())
|
||||
idx.and_then(|i| list.get(i)).or_else(|| selected_set_actions(app).first().and_then(|i| list.get(*i))).or_else(|| list.first())
|
||||
}
|
||||
|
||||
/// The selected set folder, if no action is selected and the set still exists.
|
||||
fn selected_set(app: &PhotocraftApp) -> Option<&str> {
|
||||
if app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len()) {
|
||||
return None;
|
||||
}
|
||||
let set = app.ui.actions.selected_set.as_deref()?;
|
||||
photocraft_engine::actions_edit_cmds::set_names(&app.session).iter().any(|n| n == set).then_some(set)
|
||||
}
|
||||
|
||||
/// The actions of the selected set, in list order (empty when no set is selected, or it is empty).
|
||||
fn selected_set_actions(app: &PhotocraftApp) -> Vec<usize> {
|
||||
let Some(set) = selected_set(app) else { return Vec::new() };
|
||||
app.session.actions.list.iter().enumerate().filter(|(_, a)| a.set.as_deref() == Some(set)).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// What Play plays: the selected action; with a set folder selected, every action of that set in
|
||||
/// order, as Photoshop plays a selected set (nothing for an empty set); else the first action.
|
||||
fn playback_targets(app: &PhotocraftApp) -> Vec<usize> {
|
||||
if selected_set(app).is_some() {
|
||||
return selected_set_actions(app);
|
||||
}
|
||||
playback_selection(app).into_iter().collect()
|
||||
}
|
||||
|
||||
/// Play `targets` one after another, stopping at the first that fails or can't start. One that
|
||||
/// pauses for a step dialog of its own hands the rest to that dialog, which plays them after its OK
|
||||
/// (Cancel stops them all, as it stops one action). A pause inside an action it calls resumes only
|
||||
/// the called action, so the set stops there rather than run its next action on an unfinished one. Returns the last result with `ran` counting the steps
|
||||
/// of all of them plus `before` (steps the same Play ran before a pause), or `None` when there was nothing to play.
|
||||
fn play_targets(app: &mut PhotocraftApp, targets: &[usize], before: u64) -> Result<Option<Value>, String> {
|
||||
let (mut last, mut ran) = (None, before);
|
||||
for (n, &i) in targets.iter().enumerate() {
|
||||
let name = app.session.actions.list.get(i).map(|a| a.name.clone());
|
||||
let mut v = app.run("actions.play", json!({"action": i}))?;
|
||||
ran = ran.saturating_add(v.get("ran").and_then(Value::as_u64).unwrap_or(0));
|
||||
let failed = v.get("failed").is_some_and(Value::is_object);
|
||||
let paused = v.get("paused").filter(|p| p.is_object()).and_then(|p| p.get("dialog")).and_then(Value::as_u64);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("ran".into(), json!(ran));
|
||||
}
|
||||
last = Some(v);
|
||||
if let Some(dialog) = paused.filter(|_| !failed) {
|
||||
// As they are now, not by index: the list can be reordered while the dialog is open
|
||||
// (`actions.move`), and one deleted and made again under its name isn't the same action.
|
||||
let list = &app.session.actions.list;
|
||||
let rest: Vec<Action> = targets.get(n + 1..).unwrap_or_default().iter().filter_map(|i| list.get(*i).cloned()).collect();
|
||||
let token = app.ui.dialog_mut(dialog).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64);
|
||||
if let Some(entry) = token.and_then(|t| app.paused_playbacks.paused.get_mut(&t))
|
||||
&& name.as_ref() == Some(&entry.action)
|
||||
{
|
||||
entry.then = rest;
|
||||
entry.ran = ran;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if failed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(last)
|
||||
}
|
||||
|
||||
/// Index of the playback target. Photoshop's Play button uses the first action when
|
||||
@@ -253,19 +330,35 @@ pub(crate) fn run_view(app: &mut PhotocraftApp, id: &str, params: Value) -> Resu
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The set New Action records into: the selected action's, else the selected set folder's.
|
||||
fn new_action_set(app: &PhotocraftApp) -> Option<String> {
|
||||
match app.ui.actions.selected.and_then(|i| app.session.actions.list.get(i)) {
|
||||
Some(action) => action.set.clone(),
|
||||
None => app.ui.actions.selected_set.clone().filter(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set)),
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_recording(app: &mut PhotocraftApp, append: bool) {
|
||||
if app.session.actions.recording.is_some() {
|
||||
return;
|
||||
}
|
||||
let params = if append { app.ui.actions.selected.map_or(json!({}), |i| json!({"action": i})) } else { json!({}) };
|
||||
// Recording appends to the selected action; with none (a set folder selected, say) it makes a
|
||||
// new action like New Action does, in the selected set.
|
||||
let params = match app.ui.actions.selected {
|
||||
Some(i) if append => json!({"action": i}),
|
||||
_ => new_action_set(app).map_or(json!({}), |set| json!({"set": set})),
|
||||
};
|
||||
let Ok(v) = app.run("actions.record", params) else { return };
|
||||
let Some(i) = v.get("index").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()) else { return };
|
||||
app.ui.actions.selected = Some(i);
|
||||
app.ui.actions.selected_step = None;
|
||||
app.ui.actions.select(i, None);
|
||||
if app.ui.actions.expanded.len() <= i {
|
||||
app.ui.actions.expanded.resize(i + 1, false);
|
||||
}
|
||||
app.ui.actions.expanded[i] = true;
|
||||
// Its set too, in case its folder is collapsed.
|
||||
if let Some(set) = app.session.actions.list.get(i).and_then(|a| a.set.as_ref()) {
|
||||
app.ui.actions.collapsed_sets.retain(|c| c != set);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn report_play(app: &mut PhotocraftApp, v: &Value) {
|
||||
@@ -396,8 +489,7 @@ pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result<V
|
||||
if let Some(first) = r["actions"][0].as_str()
|
||||
&& let Some(i) = list.iter().position(|a| a.name == first)
|
||||
{
|
||||
app.ui.actions.selected = Some(i);
|
||||
app.ui.actions.selected_step = None;
|
||||
app.ui.actions.select(i, None);
|
||||
app.ui.actions.expanded.resize(list.len(), false);
|
||||
if let Some(e) = app.ui.actions.expanded.get_mut(i) {
|
||||
*e = true;
|
||||
@@ -411,10 +503,12 @@ fn load_dialog(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
app.pick_file_bytes_filtered(&["atn"], |app, name, bytes| load_bytes(app, &name, &bytes))
|
||||
}
|
||||
|
||||
/// Save Actions…: the selected action's set (every action of it), or every action when none
|
||||
/// came from a set, as a `.atn` file Photoshop loads.
|
||||
/// Save Actions…: the selected action's set (every action of it), else the selected set folder,
|
||||
/// or every action when none came from a set, as a `.atn` file Photoshop loads.
|
||||
fn save_dialog(app: &mut PhotocraftApp, set: Option<String>) -> Result<Value, String> {
|
||||
let set = set.or_else(|| selected_action(app).and_then(|a| a.set.clone()));
|
||||
let selected_set = app.ui.actions.selected.is_none().then(|| app.ui.actions.selected_set.clone()).flatten();
|
||||
let selected_set = selected_set.filter(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set));
|
||||
let set = set.or(selected_set).or_else(|| selected_action(app).and_then(|a| a.set.clone()));
|
||||
let params = match &set {
|
||||
Some(set) => json!({"set": set, "skipUnsupported": true}),
|
||||
None => json!({"skipUnsupported": true}),
|
||||
@@ -423,7 +517,22 @@ fn save_dialog(app: &mut PhotocraftApp, set: Option<String>) -> Result<Value, St
|
||||
let bytes = r["data"].as_str().and_then(photocraft_engine::paint::tile::b64_decode).ok_or("the export returned no data")?;
|
||||
let n = r["actions"].as_array().map_or(0, Vec::len) as u64;
|
||||
let skipped = r["skipped"].as_array().map_or(0, Vec::len);
|
||||
let file = format!("{}.atn", r["set"].as_str().unwrap_or("Actions"));
|
||||
// A set name can hold characters a file name can't (as in Keyboard Shortcuts' Save), or be a
|
||||
// Windows device name (`CON`); the file keeps the set's own name.
|
||||
let stem: String = r["set"]
|
||||
.as_str()
|
||||
.unwrap_or("Actions")
|
||||
.chars()
|
||||
.map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c })
|
||||
.collect();
|
||||
// A file name holds at most 255 bytes: the stem gets what `.atn` leaves, cut at a character.
|
||||
let mut stem = photocraft_engine::file_cmds::portable_device_name(stem);
|
||||
let mut cut = 255 - ".atn".len();
|
||||
while !stem.is_char_boundary(cut.min(stem.len())) {
|
||||
cut -= 1;
|
||||
}
|
||||
stem.truncate(cut);
|
||||
let file = format!("{stem}.atn");
|
||||
app.pick_save(&file, move |app, path| {
|
||||
let write = app.services.write.as_mut().ok_or("no writer configured")?;
|
||||
write(&path, &bytes)?;
|
||||
@@ -449,6 +558,40 @@ fn save_dialog(app: &mut PhotocraftApp, set: Option<String>) -> Result<Value, St
|
||||
|
||||
const INSERT_STOP: &str = "__insertStop";
|
||||
|
||||
/// The field marking a New Set… dialog.
|
||||
const NEW_SET: &str = "__actionsNewSet";
|
||||
|
||||
pub fn owns_new_set(fields: &serde_json::Map<String, Value>) -> bool {
|
||||
fields.contains_key(NEW_SET)
|
||||
}
|
||||
|
||||
/// New Set…: name an empty set folder (Photoshop's default "Set N").
|
||||
fn new_set_dialog(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
let names = photocraft_engine::actions_edit_cmds::set_names(&app.session);
|
||||
let name = (1u32..).map(|n| format!("Set {n}")).find(|n| !names.contains(n)).unwrap_or_default();
|
||||
let fields = json!({NEW_SET: true, "__command": "actions.newSet", "__label": "New Set", "name": name});
|
||||
let id = app.ui.open_dialog(crate::state::DialogKind::Command, fields.as_object().cloned().unwrap_or_default());
|
||||
Ok(json!({"dialog": id}))
|
||||
}
|
||||
|
||||
pub fn new_set_body(ui: &mut egui::Ui, fields: &mut serde_json::Map<String, Value>) {
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Name:"));
|
||||
let mut name = fields.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
ui.add(egui::TextEdit::singleline(&mut name).desired_width(220.0)).labelled_by(label.id);
|
||||
fields.insert("name".into(), json!(name));
|
||||
});
|
||||
}
|
||||
|
||||
/// Make the set and select it, so New Action records into it.
|
||||
pub fn new_set_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>) -> Result<Value, String> {
|
||||
let r = app.run("actions.newSet", json!({"name": fields.get("name").cloned().unwrap_or(Value::Null)}))?;
|
||||
app.ui.actions.selected_set = r["set"].as_str().map(str::to_string);
|
||||
app.ui.actions.selected = None;
|
||||
app.ui.actions.selected_step = None;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub fn owns_insert_stop(fields: &serde_json::Map<String, Value>) -> bool {
|
||||
fields.contains_key(INSERT_STOP)
|
||||
}
|
||||
@@ -485,8 +628,7 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map<Str
|
||||
});
|
||||
let r = app.run("actions.insert", params)?;
|
||||
if let (Some(a), Some(step)) = (usize::try_from(action).ok(), r["step"].as_u64().and_then(|s| usize::try_from(s).ok())) {
|
||||
app.ui.actions.selected = Some(a);
|
||||
app.ui.actions.selected_step = Some(step);
|
||||
app.ui.actions.select(a, Some(step));
|
||||
app.ui.actions.expanded.resize(app.session.actions.list.len(), false);
|
||||
if let Some(e) = app.ui.actions.expanded.get_mut(a) {
|
||||
*e = true;
|
||||
@@ -559,6 +701,13 @@ pub(crate) struct PausedPlayback {
|
||||
/// box, automation), which end the call through `resume`, `abandoned` or `stopped`, so the
|
||||
/// recording comes back exactly once.
|
||||
record: Option<Value>,
|
||||
/// The actions still to play after this one, as they were when Play started a whole set.
|
||||
/// Actions have no id, so the whole action stands in for one: an action deleted and made again
|
||||
/// under the same name, or edited, while the dialog is open no longer matches.
|
||||
then: Vec<Action>,
|
||||
/// The steps that Play ran before this pause (earlier actions of the set, and this one's
|
||||
/// steps before the dialog), so the final count covers the whole Play.
|
||||
ran: u64,
|
||||
}
|
||||
|
||||
/// The paused playbacks, by the token their dialog carries. The dialog's fields hold only the
|
||||
@@ -572,7 +721,7 @@ pub(crate) struct PausedPlaybacks {
|
||||
impl PausedPlaybacks {
|
||||
fn pause(&mut self, action: String, from: usize, gate: Option<Gate>) -> u64 {
|
||||
self.last = self.last.wrapping_add(1);
|
||||
self.paused.insert(self.last, PausedPlayback { action, from, gate, record: None });
|
||||
self.paused.insert(self.last, PausedPlayback { action, from, gate, record: None, then: Vec::new(), ran: 0 });
|
||||
self.last
|
||||
}
|
||||
|
||||
@@ -605,7 +754,41 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>)
|
||||
let params = json!({"action": paused.action, "from": paused.from});
|
||||
let saved = app.session.authorize;
|
||||
app.session.authorize = paused.gate.or(saved);
|
||||
let played = play_owned(app, ¶ms, owner);
|
||||
let mut played = play_owned(app, ¶ms, owner);
|
||||
// Paused again at a later dialog of the same action (not of one it calls, which resumes
|
||||
// alone): that dialog carries the rest of the set.
|
||||
if let Ok(v) = &played
|
||||
&& let Some(dialog) = v.get("paused").and_then(|p| p.get("dialog")).and_then(Value::as_u64)
|
||||
&& let Some(token) = app.ui.dialog_mut(dialog).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64)
|
||||
&& let Some(entry) = app.paused_playbacks.paused.get_mut(&token)
|
||||
&& entry.action == paused.action
|
||||
{
|
||||
entry.then = paused.then.clone();
|
||||
entry.ran = paused.ran.saturating_add(1).saturating_add(v.get("ran").and_then(Value::as_u64).unwrap_or(0));
|
||||
}
|
||||
// The count covers the steps this Play ran before the pause, and the step the dialog ran.
|
||||
let ran = paused.ran.saturating_add(1).saturating_add(played.as_ref().ok().and_then(|v| v.get("ran")).and_then(Value::as_u64).unwrap_or(0));
|
||||
if let Ok(Some(o)) = played.as_mut().map(Value::as_object_mut)
|
||||
&& o.contains_key("ran")
|
||||
{
|
||||
o.insert("ran".into(), json!(ran));
|
||||
}
|
||||
// The rest of a set Play started: under the same gate, and only if this action finished. An
|
||||
// action of it deleted, renamed, edited or replaced by a new one of the same name meanwhile
|
||||
// ends the set there.
|
||||
if let Ok(v) = &played
|
||||
&& !paused.then.is_empty()
|
||||
&& v.get("failed").is_none()
|
||||
&& v.get("paused").is_none()
|
||||
{
|
||||
let list = &app.session.actions.list;
|
||||
let rest: Vec<usize> = paused.then.iter().map_while(|then| list.iter().position(|a| a == then)).collect();
|
||||
match play_targets(app, &rest, ran) {
|
||||
Ok(Some(r)) => played = Ok(r),
|
||||
Ok(None) => {}
|
||||
Err(e) => played = Err(e),
|
||||
}
|
||||
}
|
||||
app.session.authorize = saved;
|
||||
match played {
|
||||
Ok(v) => {
|
||||
@@ -640,6 +823,9 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if ui.add_enabled(!recording, egui::Button::new(tl!("New Action…"))).clicked() {
|
||||
act = Some("new");
|
||||
}
|
||||
if ui.button(tl!("New Set…")).clicked() {
|
||||
act = Some("newSet");
|
||||
}
|
||||
if ui.add_enabled(any, egui::Button::new(tl!("Play"))).clicked() {
|
||||
act = Some("play");
|
||||
}
|
||||
@@ -661,7 +847,9 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if ui.button(tl!("Load Actions…")).clicked() {
|
||||
act = Some("load");
|
||||
}
|
||||
if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() {
|
||||
// A selected set folder saves even when empty, as Photoshop saves an empty set.
|
||||
let set_selected = app.ui.actions.selected_set.as_ref().is_some_and(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set));
|
||||
if ui.add_enabled(any || set_selected, egui::Button::new(tl!("Save Actions…"))).clicked() {
|
||||
act = Some("save");
|
||||
}
|
||||
let Some(a) = act else { return };
|
||||
@@ -676,16 +864,21 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"stopRec" => app.run("actions.stop", json!({})),
|
||||
"play" => playback_selection(app).map_or(Ok(Value::Null), |i| {
|
||||
let v = app.run("actions.play", json!({"action": i}))?;
|
||||
report_play(app, &v);
|
||||
Ok(v)
|
||||
}),
|
||||
"play" => {
|
||||
let targets = playback_targets(app);
|
||||
play_targets(app, &targets, 0).map(|v| {
|
||||
if let Some(v) = &v {
|
||||
report_play(app, v);
|
||||
}
|
||||
v.unwrap_or(Value::Null)
|
||||
})
|
||||
}
|
||||
"delete" => {
|
||||
delete_selection(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"load" => load_dialog(app),
|
||||
"newSet" => new_set_dialog(app),
|
||||
"stop" => insert_stop_dialog(app),
|
||||
_ => save_dialog(app, None),
|
||||
};
|
||||
@@ -729,16 +922,14 @@ fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) {
|
||||
return;
|
||||
}
|
||||
if source.step.is_some() {
|
||||
app.ui.actions.selected = Some(source.action);
|
||||
app.ui.actions.selected_step = Some(to);
|
||||
app.ui.actions.select(source.action, Some(to));
|
||||
} else {
|
||||
app.ui.actions.expanded.resize(app.session.actions.list.len(), false);
|
||||
if source.action < app.ui.actions.expanded.len() && to < app.ui.actions.expanded.len() {
|
||||
let expanded = app.ui.actions.expanded.remove(source.action);
|
||||
app.ui.actions.expanded.insert(to, expanded);
|
||||
}
|
||||
app.ui.actions.selected = Some(to);
|
||||
app.ui.actions.selected_step = None;
|
||||
app.ui.actions.select(to, None);
|
||||
// Dropped among another set's actions: the action joins that set (the row above wins).
|
||||
let list = &app.session.actions.list;
|
||||
let neighbour = to.checked_sub(1).and_then(|j| list.get(j)).or_else(|| list.get(to + 1)).and_then(|a| a.set.clone());
|
||||
@@ -833,14 +1024,19 @@ fn display_order(rows: &[Row]) -> Vec<usize> {
|
||||
}
|
||||
|
||||
struct SetHeaderResult {
|
||||
/// A click on the disclosure triangle (or an activation without a pointer): collapse or expand.
|
||||
toggled: bool,
|
||||
/// A click on the rest of the row (or an activation without a pointer): select the set.
|
||||
picked: bool,
|
||||
save: bool,
|
||||
}
|
||||
|
||||
fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, tokens: &Tokens) -> SetHeaderResult {
|
||||
fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, selected: bool, tokens: &Tokens) -> SetHeaderResult {
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, !collapsed, set));
|
||||
if response.hovered() {
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, set));
|
||||
if selected {
|
||||
ui.painter().rect_filled(rect, 0.0, tokens.row_selected);
|
||||
} else if response.hovered() {
|
||||
ui.painter().rect_filled(rect, 0.0, tokens.hover.gamma_multiply(0.5));
|
||||
}
|
||||
let triangle = Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(8.0, 8.0));
|
||||
@@ -860,7 +1056,12 @@ fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, to
|
||||
ui.painter().text(pos2(rect.left() + 44.0, rect.center().y), Align2::LEFT_CENTER, set, egui::FontId::proportional(12.0), tokens.text);
|
||||
let summary = crate::i18n::trn(crate::i18n::current(), count, "{n} action", "{n} actions");
|
||||
ui.painter().text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, summary, egui::FontId::proportional(11.0), tokens.text_faint);
|
||||
let toggled = response.clicked();
|
||||
// As on an action row, a click in the triangle's column collapses or expands and one on the
|
||||
// rest of the row selects. An activation without a pointer (keyboard, accessibility) does
|
||||
// both, so the set can still be collapsed and expanded without a mouse.
|
||||
let pointer = response.interact_pointer_pos();
|
||||
let toggled = response.clicked() && pointer.is_none_or(|p| p.x < rect.left() + 20.0);
|
||||
let picked = response.clicked() && pointer.is_none_or(|p| p.x >= rect.left() + 20.0);
|
||||
let mut save = false;
|
||||
response.context_menu(|ui| {
|
||||
if ui.button(tl!("Save Set…")).clicked() {
|
||||
@@ -868,7 +1069,26 @@ fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, to
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
SetHeaderResult { toggled, save }
|
||||
SetHeaderResult { toggled, picked, save }
|
||||
}
|
||||
|
||||
/// The sets New Set made that hold no action yet: empty folder rows after the rest. Returns the
|
||||
/// set whose row was clicked (whether it toggles, whether it selects) and the set whose Save Set…
|
||||
/// was chosen.
|
||||
fn draw_empty_sets(app: &PhotocraftApp, ui: &mut egui::Ui, rows: &[Row], t: &Tokens) -> (Option<(String, bool, bool)>, Option<String>) {
|
||||
let (mut clicked, mut save) = (None, None);
|
||||
for set in app.session.actions.sets.iter().filter(|set| !rows.iter().any(|r| r.set.as_ref() == Some(*set))) {
|
||||
let selected = app.ui.actions.selected.is_none() && app.ui.actions.selected_set.as_ref() == Some(set);
|
||||
let collapsed = app.ui.actions.collapsed_sets.contains(set);
|
||||
let result = draw_set_header(ui, set, 0, collapsed, selected, t);
|
||||
if result.toggled || result.picked {
|
||||
clicked = Some((set.clone(), result.toggled, result.picked));
|
||||
}
|
||||
if result.save {
|
||||
save = Some(set.clone());
|
||||
}
|
||||
}
|
||||
(clicked, save)
|
||||
}
|
||||
|
||||
fn action_context_menu(app: &mut PhotocraftApp, response: &egui::Response, index: usize, name: &str, swatch: &str) {
|
||||
@@ -949,10 +1169,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let mut renamed = None;
|
||||
let mut toggled = None;
|
||||
let mut dialog_toggled = None;
|
||||
let mut set_toggled: Option<String> = None;
|
||||
let mut set_toggled: Option<(String, bool, bool)> = None;
|
||||
let mut save_set: Option<String> = None;
|
||||
let max_h = (ui.available_height() - 38.0).max(0.0);
|
||||
let mut play_idx = None;
|
||||
let mut play_idx: Option<Vec<usize>> = None;
|
||||
let mut moved = None;
|
||||
let recording_row = recording.and_then(|(_, i)| rows.get(i).map(|r| (i, r.steps.len())));
|
||||
let reveal = recording_row.filter(|row| app.ui.actions.reveal_recording != Some(*row));
|
||||
@@ -978,9 +1198,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
shown_set = Some(set);
|
||||
let collapsed = app.ui.actions.collapsed_sets.iter().any(|c| c == set);
|
||||
let count = rows.iter().filter(|r| r.set.as_deref() == Some(set)).count() as u64;
|
||||
let result = draw_set_header(ui, set, count, collapsed, &t);
|
||||
if result.toggled {
|
||||
set_toggled = Some(set.to_string());
|
||||
let selected = app.ui.actions.selected.is_none() && app.ui.actions.selected_set.as_deref() == Some(set);
|
||||
let result = draw_set_header(ui, set, count, collapsed, selected, &t);
|
||||
if result.toggled || result.picked {
|
||||
set_toggled = Some((set.to_string(), result.toggled, result.picked));
|
||||
}
|
||||
if result.save {
|
||||
save_set = Some(set.to_string());
|
||||
@@ -1050,11 +1271,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.painter().circle_filled(pos2(rect.right() - 64.0, rect.center().y), 4.0, Color32::from_rgb(230, 60, 60));
|
||||
}
|
||||
if resp.clicked() {
|
||||
app.ui.actions.selected = Some(i);
|
||||
app.ui.actions.selected_step = None;
|
||||
app.ui.actions.select(i, None);
|
||||
if button_mode {
|
||||
if !resp.double_clicked() {
|
||||
play_idx = Some(i);
|
||||
play_idx = Some(vec![i]);
|
||||
}
|
||||
} else if resp.interact_pointer_pos().is_some_and(|p| p.x < x0 + 20.0) {
|
||||
if app.ui.actions.expanded.len() <= i {
|
||||
@@ -1071,7 +1291,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if resp.interact_pointer_pos().is_some_and(|p| name_rect.expand2(vec2(4.0, 13.0)).contains(p)) {
|
||||
app.ui.actions.renaming = Some(RenameAction { index: i, text: name.clone(), focused: false });
|
||||
} else {
|
||||
play_idx = Some(i);
|
||||
play_idx = Some(vec![i]);
|
||||
}
|
||||
}
|
||||
if expanded {
|
||||
@@ -1082,6 +1302,9 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
dialog_toggled = dialog_toggled.or(events.dialog_toggled);
|
||||
}
|
||||
}
|
||||
let (empty_toggled, empty_save) = draw_empty_sets(app, ui, &rows, &t);
|
||||
set_toggled = set_toggled.take().or(empty_toggled);
|
||||
save_set = save_set.take().or(empty_save);
|
||||
});
|
||||
if let Some((action, step)) = toggled {
|
||||
let _ = app.run("actions.toggle", json!({"action": action, "step": step}));
|
||||
@@ -1089,12 +1312,21 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if let Some((action, step)) = dialog_toggled {
|
||||
let _ = app.run("actions.dialog", json!({"action": action, "step": step}));
|
||||
}
|
||||
if let Some(set) = set_toggled {
|
||||
match app.ui.actions.collapsed_sets.iter().position(|c| *c == set) {
|
||||
Some(i) => {
|
||||
app.ui.actions.collapsed_sets.remove(i);
|
||||
if let Some((set, toggle, pick)) = set_toggled {
|
||||
// The rest of the folder row selects the set, for New Action and Play.
|
||||
if pick {
|
||||
app.ui.actions.selected = None;
|
||||
app.ui.actions.selected_step = None;
|
||||
app.ui.actions.selected_set = Some(set.clone());
|
||||
}
|
||||
// The disclosure triangle collapses or expands the set and leaves the selection alone.
|
||||
if toggle {
|
||||
match app.ui.actions.collapsed_sets.iter().position(|c| *c == set) {
|
||||
Some(i) => {
|
||||
app.ui.actions.collapsed_sets.remove(i);
|
||||
}
|
||||
None => app.ui.actions.collapsed_sets.push(set),
|
||||
}
|
||||
None => app.ui.actions.collapsed_sets.push(set),
|
||||
}
|
||||
}
|
||||
if let Some(set) = save_set
|
||||
@@ -1128,7 +1360,13 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let play = crate::icons::button(ui, "play", 24.0, false, tl!("Play selection"));
|
||||
play.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Play selection")));
|
||||
if play.clicked() {
|
||||
play_idx = playback_selection(app);
|
||||
play_idx = Some(playback_targets(app));
|
||||
}
|
||||
if crate::icons::button(ui, "folder", 24.0, false, tl!("Create new set")).clicked()
|
||||
&& let Err(e) = new_set_dialog(app)
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
if crate::icons::button(ui, "plus", 24.0, false, tl!("Create new action")).clicked() && !recording_now {
|
||||
begin_recording(app, false);
|
||||
@@ -1148,9 +1386,10 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
app.ui.actions.button_mode = !app.ui.actions.button_mode;
|
||||
}
|
||||
});
|
||||
if let Some(i) = play_idx {
|
||||
match app.run("actions.play", json!({"action": i})) {
|
||||
Ok(v) => report_play(app, &v),
|
||||
if let Some(targets) = play_idx {
|
||||
match play_targets(app, &targets, 0) {
|
||||
Ok(Some(v)) => report_play(app, &v),
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let template = "Action playback failed: {error}";
|
||||
app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]);
|
||||
@@ -1213,8 +1452,7 @@ fn draw_step_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, context: &StepRows
|
||||
} else if dialog.clicked() {
|
||||
events.dialog_toggled = Some((context.action, step));
|
||||
} else if response.clicked() {
|
||||
app.ui.actions.selected = Some(context.action);
|
||||
app.ui.actions.selected_step = Some(step);
|
||||
app.ui.actions.select(context.action, Some(step));
|
||||
}
|
||||
}
|
||||
events
|
||||
|
||||
@@ -643,18 +643,655 @@ fn set_folders_group_actions_and_collapse() {
|
||||
assert!(h.get_by_label("Sharpen").rect().top() > folder.top());
|
||||
assert!(h.get_by_label("Soften").rect().top() > h.get_by_label("Sharpen").rect().top());
|
||||
assert_eq!(display_order(&[]), Vec::<usize>::new());
|
||||
// Clicking the folder collapses it; its actions disappear and the state is remembered.
|
||||
h.get_by_label("Retouch").click();
|
||||
h.run_steps(3);
|
||||
// Clicking the folder's triangle collapses it; its actions disappear and the state is remembered.
|
||||
click_set_triangle(&mut h, "Retouch");
|
||||
assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"]);
|
||||
assert!(h.query_by_label("Sharpen").is_none());
|
||||
assert!(h.query_by_label("Loose").is_some());
|
||||
h.get_by_label("Retouch").click();
|
||||
h.run_steps(3);
|
||||
click_set_triangle(&mut h, "Retouch");
|
||||
assert!(h.state().ui.actions.collapsed_sets.is_empty());
|
||||
assert!(h.query_by_label("Sharpen").is_some());
|
||||
}
|
||||
|
||||
/// Two sets, Web listed first: "Resize" (Web) adds a layer "w", "Sharpen" and "Soften" (Retouch)
|
||||
/// add "r1" and "r2".
|
||||
fn two_set_panel() -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
for (name, set, layer) in [("Resize", "Web", "w"), ("Sharpen", "Retouch", "r1"), ("Soften", "Retouch", "r2")] {
|
||||
let mut a = Action::new(name, vec![("layer.new.layer".into(), json!({"name": layer}))]);
|
||||
a.set = Some(set.into());
|
||||
app.session.actions.list.push(a);
|
||||
}
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
fn layer_names(app: &PhotocraftApp) -> Vec<String> {
|
||||
app.session.active().unwrap().doc.walk().iter().map(|(_, _, l)| l.name.clone()).filter(|n| n != "Background").collect()
|
||||
}
|
||||
|
||||
/// Click a set folder row's disclosure triangle (a plain `click()` lands on the row's middle,
|
||||
/// which selects the set).
|
||||
fn click_set_triangle(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, set: &str) {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let pos = h.get_by_label(set).rect().left_center() + vec2(12.0, 0.0);
|
||||
h.event(egui::Event::PointerMoved(pos));
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// #10459 fix-up: a click on a set folder row (not its triangle) selects the set, and Play
|
||||
/// then plays every action of that set in order, as Photoshop plays a selected set, not the first
|
||||
/// action of the whole list.
|
||||
#[test]
|
||||
fn play_with_a_set_folder_selected_plays_that_sets_actions() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = two_set_panel();
|
||||
h.get_by_label("Resize").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Retouch").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.actions.collapsed_sets.is_empty(), "a click on the row selects, it doesn't collapse");
|
||||
h.get_by_label("Play selection").click();
|
||||
h.run_steps(2);
|
||||
let mut names = layer_names(h.state());
|
||||
names.sort();
|
||||
assert_eq!(names, ["r1", "r2"], "the Retouch set played, not Resize");
|
||||
assert!(!h.state().ui.status_error, "{}", h.state().ui.status);
|
||||
// Batch and Create Droplet start on the selected set too.
|
||||
assert_eq!(selected_action(h.state()).map(|a| a.name.as_str()), Some("Sharpen"));
|
||||
}
|
||||
|
||||
/// #10459 fix-up: Play with an empty set selected plays nothing.
|
||||
#[test]
|
||||
fn play_with_an_empty_set_selected_plays_nothing() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = two_set_panel();
|
||||
h.state_mut().run("actions.newSet", json!({"name": "Print"})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Print").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Print"));
|
||||
h.get_by_label("Play selection").click();
|
||||
h.run_steps(2);
|
||||
assert!(layer_names(h.state()).is_empty(), "{:?}", layer_names(h.state()));
|
||||
}
|
||||
|
||||
/// CodeRabbit on batch 11: with an empty set selected, Batch and Create Droplet start on the first
|
||||
/// action, as `selected_action` documents.
|
||||
#[test]
|
||||
fn an_empty_selected_set_falls_back_to_the_first_action() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = two_set_panel();
|
||||
h.state_mut().run("actions.newSet", json!({"name": "Print"})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Print").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(selected_action(h.state()).map(|a| a.name.as_str()), Some("Resize"));
|
||||
}
|
||||
|
||||
/// #10459 fix-up: the common case is unchanged: a selected action plays alone, also after a set
|
||||
/// folder was selected before it, and a later Delete doesn't bring that set back.
|
||||
#[test]
|
||||
fn play_with_an_action_selected_plays_only_that_action() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = two_set_panel();
|
||||
h.get_by_label("Retouch").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Retouch").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Sharpen").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Play selection").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["r1"]);
|
||||
assert_eq!(selected_action(h.state()).map(|a| a.name.as_str()), Some("Sharpen"));
|
||||
h.get_by_label("Delete").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, None));
|
||||
}
|
||||
|
||||
/// #10459 fix-up: the disclosure triangle collapses and expands a set and leaves the selection
|
||||
/// alone, so Play still plays the selected action, not the set that was looked into.
|
||||
#[test]
|
||||
fn collapsing_a_set_keeps_the_selected_action_for_play() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = two_set_panel();
|
||||
h.get_by_label("Resize").click();
|
||||
h.run_steps(2);
|
||||
click_set_triangle(&mut h, "Retouch");
|
||||
assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"]);
|
||||
assert!(h.query_by_label("Sharpen").is_none());
|
||||
click_set_triangle(&mut h, "Retouch");
|
||||
assert!(h.state().ui.actions.collapsed_sets.is_empty());
|
||||
assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (Some(0), None));
|
||||
h.get_by_label("Play selection").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["w"]);
|
||||
}
|
||||
|
||||
/// #10459 fix-up: selecting a step (or any other way an action becomes the selection) drops a
|
||||
/// selected set, so deleting that step doesn't bring the set back as Play's target.
|
||||
#[test]
|
||||
fn a_selected_step_replaces_the_selected_set() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = two_set_panel();
|
||||
h.state_mut().session.actions.list[0].steps.push(("select.all".into(), json!({})));
|
||||
h.state_mut().ui.actions.expanded = vec![true, false, false];
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Retouch").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Retouch"));
|
||||
h.get_by_label(&label_of("select.all")).click();
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_step), (Some(0), Some(1)));
|
||||
h.get_by_label("Delete").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.actions.list[0].steps.len(), 1, "the step went");
|
||||
assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, None));
|
||||
h.get_by_label("Play selection").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["w"], "the first action, not the Retouch set");
|
||||
}
|
||||
|
||||
/// #10459 fix-up: a set whose first action pauses at step dialogs plays the rest of the set
|
||||
/// after OK, and Cancel stops the whole set, as it stops one action.
|
||||
#[test]
|
||||
fn a_set_paused_at_a_step_dialog_plays_its_other_actions_after_ok() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
for ok in [true, false] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let mut a = Action::new(
|
||||
"A",
|
||||
vec![
|
||||
("layer.new.layer".into(), json!({"name": "a"})),
|
||||
("filter.blur.gaussianBlur".into(), json!({"radius": 1})),
|
||||
("filter.blur.gaussianBlur".into(), json!({"radius": 2})),
|
||||
("layer.new.layer".into(), json!({"name": "a2"})),
|
||||
],
|
||||
);
|
||||
a.set = Some("S".into());
|
||||
a.dialogs.extend([1, 2]);
|
||||
let mut b = Action::new("B", vec![("layer.new.layer".into(), json!({"name": "b"}))]);
|
||||
b.set = Some("S".into());
|
||||
app.session.actions.list.extend([a, b]);
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("S").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Play selection").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["a"]);
|
||||
assert!(h.state().ui.status.contains("dialog"), "{}", h.state().ui.status);
|
||||
let first = h.state().ui.dialogs.last().map(|d| d.id).expect("the first blur's dialog");
|
||||
if !ok {
|
||||
crate::dialogs::cancel(h.state_mut(), first).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["a"], "Cancel stops the set");
|
||||
continue;
|
||||
}
|
||||
crate::dialogs::confirm(h.state_mut(), first).unwrap();
|
||||
h.run_steps(2);
|
||||
// The second dialog of A pauses again; the rest of the set waits behind it.
|
||||
let second = h.state().ui.dialogs.last().map(|d| d.id).expect("the second blur's dialog");
|
||||
assert_ne!(first, second);
|
||||
crate::dialogs::confirm(h.state_mut(), second).unwrap();
|
||||
h.run_steps(2);
|
||||
let mut names = layer_names(h.state());
|
||||
names.sort();
|
||||
assert_eq!(names, ["a", "a2", "b"], "B played after A's dialogs");
|
||||
// CodeRabbit on batch 11: the count covers the whole Play, also the steps before each pause.
|
||||
assert_eq!(h.state().ui.status, "Played 5 steps");
|
||||
}
|
||||
}
|
||||
|
||||
/// CodeRabbit on batch 11: the rest of a paused set is kept by name, so an action moved into the
|
||||
/// middle of the list while the dialog is open neither plays in its place nor skips one of the set.
|
||||
#[test]
|
||||
fn a_set_paused_at_a_dialog_plays_its_own_actions_after_a_move() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
for (name, set, layer) in [("A", "S", "a"), ("B", "S", "b"), ("C", "S", "c"), ("X", "Other", "x")] {
|
||||
let mut a = Action::new(name, vec![("layer.new.layer".into(), json!({"name": layer}))]);
|
||||
a.set = Some(set.into());
|
||||
app.session.actions.list.push(a);
|
||||
}
|
||||
app.session.actions.list[0].steps.push(("filter.blur.gaussianBlur".into(), json!({"radius": 1})));
|
||||
app.session.actions.list[0].dialogs.insert(1);
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("S").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Play selection").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["a"]);
|
||||
let dialog = h.state().ui.dialogs.last().map(|d| d.id).expect("the blur's dialog");
|
||||
h.state_mut().run("actions.move", json!({"action": "X", "to": 1})).unwrap();
|
||||
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
h.run_steps(2);
|
||||
let mut names = layer_names(h.state());
|
||||
names.sort();
|
||||
assert_eq!(names, ["a", "b", "c"]);
|
||||
}
|
||||
|
||||
/// CodeRabbit on batch 11: an action of the paused set deleted and made again under its name while
|
||||
/// the dialog is open is a different action, so the set ends there instead of playing it.
|
||||
#[test]
|
||||
fn a_set_paused_at_a_dialog_ends_at_an_action_replaced_under_its_name() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
for (name, layer) in [("A", "a"), ("B", "b"), ("C", "c")] {
|
||||
let mut a = Action::new(name, vec![("layer.new.layer".into(), json!({"name": layer}))]);
|
||||
a.set = Some("S".into());
|
||||
app.session.actions.list.push(a);
|
||||
}
|
||||
app.session.actions.list[0].steps.push(("filter.blur.gaussianBlur".into(), json!({"radius": 1})));
|
||||
app.session.actions.list[0].dialogs.insert(1);
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("S").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Play selection").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["a"]);
|
||||
let dialog = h.state().ui.dialogs.last().map(|d| d.id).expect("the blur's dialog");
|
||||
h.state_mut().run("actions.delete", json!({"action": "B"})).unwrap();
|
||||
let mut b = Action::new("B", vec![("layer.new.layer".into(), json!({"name": "new b"}))]);
|
||||
b.set = Some("S".into());
|
||||
h.state_mut().session.actions.list.insert(1, b);
|
||||
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["a"], "neither the new B nor C after it played");
|
||||
}
|
||||
|
||||
/// #10459 fix-up: a set whose action pauses at a dialog inside an action it calls stops there:
|
||||
/// the called action resumes alone, so the next action of the set must not run on a caller that
|
||||
/// never finished.
|
||||
#[test]
|
||||
fn a_set_paused_inside_a_called_action_stops_there() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let mut a = Action::new("A", vec![("actions.play".into(), json!({"action": "C"})), ("layer.new.layer".into(), json!({"name": "a-tail"}))]);
|
||||
a.set = Some("S".into());
|
||||
let mut b = Action::new("B", vec![("layer.new.layer".into(), json!({"name": "b"}))]);
|
||||
b.set = Some("S".into());
|
||||
let mut c = Action::new("C", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1})), ("layer.new.layer".into(), json!({"name": "c"}))]);
|
||||
c.dialogs.insert(0);
|
||||
app.session.actions.list.extend([a, b, c]);
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("S").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Play selection").click();
|
||||
h.run_steps(2);
|
||||
let dialog = h.state().ui.dialogs.last().map(|d| d.id).expect("C's blur dialog");
|
||||
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_names(h.state()), ["c"], "C finished; B didn't run ahead of A's tail");
|
||||
}
|
||||
|
||||
/// #10459 fix-up: a set folder activated without a pointer (keyboard, accessibility) still
|
||||
/// collapses and expands, and selects the set; the row reports whether it is selected.
|
||||
#[test]
|
||||
fn a_set_folder_toggles_without_a_pointer() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = two_set_panel();
|
||||
use egui::accesskit::Toggled;
|
||||
use egui_kittest::kittest::NodeT;
|
||||
let selected = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.get_by_label("Retouch").accesskit_node().toggled();
|
||||
assert_eq!(selected(&h), Some(Toggled::False));
|
||||
h.get_by_label("Retouch").click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"]);
|
||||
assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Retouch"));
|
||||
assert_eq!(selected(&h), Some(Toggled::True), "selected and collapsed reads as selected");
|
||||
h.get_by_label("Retouch").click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.actions.collapsed_sets.is_empty());
|
||||
}
|
||||
|
||||
/// #7535: the folder button (and the panel menu's New Set…) names an empty set, shown as a folder
|
||||
/// row with no actions; New Action then records into it, as into any selected set.
|
||||
#[test]
|
||||
fn new_set_makes_an_empty_folder_that_new_action_records_into() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let mut retouch = Action::new("Sharpen", vec![]);
|
||||
retouch.set = Some("Retouch".into());
|
||||
app.session.actions.list.push(retouch);
|
||||
let mut h = Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Create new set").click();
|
||||
h.run_steps(2);
|
||||
let id = h.state().ui.dialogs.last().map(|d| d.id).expect("the New Set dialog");
|
||||
let d = h.state_mut().ui.dialog_mut(id).unwrap();
|
||||
assert!(owns_new_set(&d.fields));
|
||||
assert_eq!(d.fields["name"], "Set 1");
|
||||
d.fields.insert("name".into(), json!("Web"));
|
||||
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||
h.run_steps(2);
|
||||
// An empty folder row, selected.
|
||||
assert!(h.query_by_label("Web").is_some());
|
||||
assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Web"));
|
||||
h.get_by_label("Create new action").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("actions.stop", json!({})).unwrap();
|
||||
let list = &h.state().session.actions.list;
|
||||
assert_eq!((list[1].name.as_str(), list[1].set.as_deref()), ("Action 2", Some("Web")));
|
||||
// With an action selected, New Action records into that action's set.
|
||||
h.state_mut().ui.actions.selected = Some(0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Create new action").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(h.state().session.actions.list[2].set.as_deref(), Some("Retouch"));
|
||||
}
|
||||
|
||||
/// FredApps review on #10459: with no action and no set selected, New Action makes an action
|
||||
/// outside every set, even when sets (empty ones included) exist.
|
||||
#[test]
|
||||
fn new_action_with_no_set_selected_is_in_no_set() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let mut retouch = Action::new("Sharpen", vec![]);
|
||||
retouch.set = Some("Retouch".into());
|
||||
app.session.actions.list.push(retouch);
|
||||
app.run("actions.newSet", json!({"name": "Web"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, None));
|
||||
h.get_by_label("Create new action").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("actions.stop", json!({})).unwrap();
|
||||
let list = &h.state().session.actions.list;
|
||||
assert_eq!((list.len(), list[1].name.as_str(), list[1].set.as_deref()), (2, "Action 2", None));
|
||||
}
|
||||
|
||||
/// FredApps review on #10459: closing the Actions panel and opening it again keeps the selected
|
||||
/// set and the empty sets New Set made, so New Action still records into the selected one.
|
||||
#[test]
|
||||
fn reopening_the_actions_panel_keeps_the_selected_set_and_empty_sets() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let mut retouch = Action::new("Sharpen", vec![]);
|
||||
retouch.set = Some("Retouch".into());
|
||||
app.session.actions.list.push(retouch);
|
||||
app.run("actions.newSet", json!({"name": "Web"})).unwrap();
|
||||
app.run("actions.newSet", json!({"name": "Print"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(
|
||||
|ui, app| {
|
||||
// The Actions panel is the History group's second tab.
|
||||
if app.ui.panels.history && app.ui.dock_tabs.history == 1 {
|
||||
panel(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
let ctx = h.ctx.clone();
|
||||
let toggle = |h: &mut Harness<'_, PhotocraftApp>| {
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.actions", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
};
|
||||
if !(h.state().ui.panels.history && h.state().ui.dock_tabs.history == 1) {
|
||||
toggle(&mut h);
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Web").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Web"));
|
||||
toggle(&mut h);
|
||||
assert!(!h.state().ui.panels.history, "closed");
|
||||
assert!(h.query_by_label("Web").is_none());
|
||||
toggle(&mut h);
|
||||
assert!(h.state().ui.panels.history && h.state().ui.dock_tabs.history == 1, "open again");
|
||||
assert!(h.query_by_label("Web").is_some() && h.query_by_label("Print").is_some(), "the empty sets");
|
||||
assert_eq!(h.state().session.actions.sets, ["Web", "Print"]);
|
||||
assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, Some("Web")));
|
||||
h.get_by_label("Create new action").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(h.state().session.actions.list[1].set.as_deref(), Some("Web"));
|
||||
}
|
||||
|
||||
/// #7535: with a set's folder row selected and no action, Start Recording (like the Record button)
|
||||
/// has no action to append to, so it records a new action into that set, as New Action does.
|
||||
#[test]
|
||||
fn start_recording_with_a_set_selected_records_a_new_action_into_it() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let mut retouch = Action::new("Sharpen", vec![]);
|
||||
retouch.set = Some("Retouch".into());
|
||||
app.session.actions.list.push(retouch);
|
||||
app.run("actions.newSet", json!({"name": "Web"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(360.0, 600.0)).build_ui_state(
|
||||
|ui, app| {
|
||||
panel_menu(app, ui);
|
||||
panel(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Web").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, Some("Web")));
|
||||
h.get_by_label("Start Recording").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("actions.stop", json!({})).unwrap();
|
||||
let list = &h.state().session.actions.list;
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[1].set.as_deref(), Some("Web"));
|
||||
}
|
||||
|
||||
/// Recording into a collapsed, selected set (New Action or Start Recording) expands the set
|
||||
/// again, so the new action and its steps show.
|
||||
#[test]
|
||||
fn recording_into_a_collapsed_set_expands_it() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
for label in ["Create new action", "Start Recording"] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let mut retouch = Action::new("Sharpen", vec![]);
|
||||
retouch.set = Some("Retouch".into());
|
||||
app.session.actions.list.push(retouch);
|
||||
let mut h = Harness::builder().with_size(vec2(360.0, 600.0)).build_ui_state(
|
||||
|ui, app| {
|
||||
panel_menu(app, ui);
|
||||
panel(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(2);
|
||||
click_set_triangle(&mut h, "Retouch");
|
||||
h.get_by_label("Retouch").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"], "{label}");
|
||||
assert!(h.query_by_label("Sharpen").is_none(), "{label}");
|
||||
h.get_by_label(label).click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.actions.collapsed_sets.is_empty(), "{label}");
|
||||
assert_eq!(h.state().ui.actions.selected, Some(1), "{label}");
|
||||
assert!(h.query_by_label("Action 2").is_some(), "{label}");
|
||||
h.state_mut().run("actions.stop", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Save Actions… after a click on set B's folder row saves set B, not the first action's set.
|
||||
#[test]
|
||||
fn save_actions_with_a_set_selected_saves_that_set() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let (file_dialog, asked) = crate::file_dialog::fake(vec![None]);
|
||||
let services = crate::Services { file_dialog: Some(file_dialog), ..Default::default() };
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
for (name, set) in [("Sharpen", "Retouch"), ("Resize", "Web")] {
|
||||
let mut action = Action::new(name, vec![("layer.new.layer".into(), json!({}))]);
|
||||
action.set = Some(set.into());
|
||||
app.session.actions.list.push(action);
|
||||
}
|
||||
let mut h = Harness::builder().with_size(vec2(360.0, 600.0)).build_ui_state(
|
||||
|ui, app| {
|
||||
panel_menu(app, ui);
|
||||
panel(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Web").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().ui.actions.selected, h.state().ui.actions.selected_set.as_deref()), (None, Some("Web")));
|
||||
h.get_by_label("Save Actions…").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().poll_file_dialog(&egui::Context::default(), None);
|
||||
assert!(matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested }] if suggested == "Web.atn"), "{:?}", asked.borrow());
|
||||
}
|
||||
|
||||
/// FredApps review on #10459: a set name can hold characters a file name can't. Save Actions…
|
||||
/// suggests the file with them replaced, and the set keeps its name in the file. The set here
|
||||
/// is an empty one New Set made, which Save Actions… writes as Photoshop does.
|
||||
#[test]
|
||||
fn save_actions_suggests_a_file_name_without_path_characters() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let (file_dialog, asked) = crate::file_dialog::fake(vec![None]);
|
||||
let services = crate::Services { file_dialog: Some(file_dialog), ..Default::default() };
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
app.session.actions.list.push(Action::new("Loose", vec![]));
|
||||
let set = "Web/Print\\Mail: v2";
|
||||
app.run("actions.newSet", json!({"name": set})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(360.0, 600.0)).build_ui_state(
|
||||
|ui, app| {
|
||||
panel_menu(app, ui);
|
||||
panel(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.get_by_label(set).click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Save Actions…").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().poll_file_dialog(&egui::Context::default(), None);
|
||||
assert!(
|
||||
matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested }] if suggested == "Web_Print_Mail_ v2.atn"),
|
||||
"{:?}",
|
||||
asked.borrow()
|
||||
);
|
||||
let saved = h.state_mut().run("actions.save", json!({"set": set})).unwrap();
|
||||
assert_eq!(saved["set"], set, "the file keeps the set's name");
|
||||
}
|
||||
|
||||
/// An app with no actions and one empty set New Set made, its folder row shown with the panel
|
||||
/// menu, and a fake Save dialog whose requests the test reads.
|
||||
fn empty_set_save_harness(
|
||||
set: &str,
|
||||
) -> (egui_kittest::Harness<'static, PhotocraftApp>, std::rc::Rc<std::cell::RefCell<Vec<crate::file_dialog::FileDialogRequest>>>) {
|
||||
let (file_dialog, asked) = crate::file_dialog::fake(vec![None]);
|
||||
let services = crate::Services { file_dialog: Some(file_dialog), ..Default::default() };
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
app.run("actions.newSet", json!({"name": set})).unwrap();
|
||||
// With no action, the panel shows its "Record ● … play ▶" hint, which the test fonts lack.
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 600.0)).allow_missing_glyphs().build_ui_state(
|
||||
|ui, app| {
|
||||
panel_menu(app, ui);
|
||||
panel(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(2);
|
||||
(h, asked)
|
||||
}
|
||||
|
||||
/// CodeRabbit review on #10459: with no action at all, Save Actions… is still enabled for a
|
||||
/// selected empty set (the engine saves it), and stays disabled with nothing to save.
|
||||
#[test]
|
||||
fn save_actions_is_enabled_for_a_selected_empty_set_with_no_actions() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let (mut h, asked) = empty_set_save_harness("Later");
|
||||
h.get_by_label("Save Actions…").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().poll_file_dialog(&egui::Context::default(), None);
|
||||
assert!(asked.borrow().is_empty(), "no set selected, nothing to save: {:?}", asked.borrow());
|
||||
h.get_by_label("Later").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Later"));
|
||||
h.get_by_label("Save Actions…").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().poll_file_dialog(&egui::Context::default(), None);
|
||||
assert!(
|
||||
matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested }] if suggested == "Later.atn"),
|
||||
"{:?}",
|
||||
asked.borrow()
|
||||
);
|
||||
}
|
||||
|
||||
/// CodeRabbit review on #10459: Save Set… in an empty set folder's context menu saves that set,
|
||||
/// as it does for a folder with actions.
|
||||
#[test]
|
||||
fn save_set_on_an_empty_set_folder_opens_the_save_dialog() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let (mut h, asked) = empty_set_save_harness("Later");
|
||||
h.get_by_label("Later").click_secondary();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Save Set…").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().poll_file_dialog(&egui::Context::default(), None);
|
||||
assert!(
|
||||
matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested }] if suggested == "Later.atn"),
|
||||
"{:?}",
|
||||
asked.borrow()
|
||||
);
|
||||
}
|
||||
|
||||
/// CodeRabbit review on #10459: a set named after a Windows device (`CON`, `com1.v2`) or holding a
|
||||
/// control character gets a suggested file name Windows can create.
|
||||
#[test]
|
||||
fn save_actions_suggests_a_windows_safe_name_for_device_and_control_character_sets() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
for (set, suggested) in [("CON", "CON_.atn"), ("com1.v2", "com1_.v2.atn"), ("Web\u{1}Print\tMail", "Web_Print_Mail.atn"), ("Print", "Print.atn")] {
|
||||
let (mut h, asked) = empty_set_save_harness(set);
|
||||
h.get_by_label(set).click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Save Actions…").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().poll_file_dialog(&egui::Context::default(), None);
|
||||
assert!(
|
||||
matches!(asked.borrow().as_slice(), [crate::file_dialog::FileDialogRequest::Save { suggested: s }] if s == suggested),
|
||||
"{set:?}: {:?}",
|
||||
asked.borrow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CodeRabbit on batch 11: a set name longer than a file name may be is cut to 255 bytes with its
|
||||
/// suffix, at a character boundary.
|
||||
#[test]
|
||||
fn save_actions_suggests_a_file_name_of_at_most_255_bytes() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let set = "é".repeat(200);
|
||||
let (mut h, asked) = empty_set_save_harness(&set);
|
||||
h.get_by_label(set.as_str()).click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Save Actions…").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().poll_file_dialog(&egui::Context::default(), None);
|
||||
let asked = asked.borrow();
|
||||
let [crate::file_dialog::FileDialogRequest::Save { suggested }] = asked.as_slice() else { panic!("{asked:?}") };
|
||||
assert_eq!(*suggested, format!("{}.atn", "é".repeat(125)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_an_action_next_to_another_set_joins_it() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -631,6 +631,10 @@ PhotoCraft equivalent.
|
||||
Events outside the table load as `actions.photoshopStep` steps that keep the descriptor
|
||||
verbatim (so Save Actions writes them back) and fail at playback with the step's Photoshop name.
|
||||
|
||||
`crates/psd/tests/it/atn_contract.rs` checks the parser's public API against four small,
|
||||
independently encoded fixtures in `crates/psd/tests/fixtures/atn/` (provenance and checksums in
|
||||
its README): `cargo test -p photocraft-psd --test it atn_contract::`.
|
||||
|
||||
## Remove Tool quality
|
||||
|
||||
`cargo run --release -p photocraft-engine --example remove_quality -- <dir> [out dir]` fills
|
||||
|
||||
Reference in New Issue
Block a user