Merge pull request #10535 from davidmann4/integration-batch-11

Integration batch 11: 9 ATN PRs on main
This commit is contained in:
aiongg
2026-11-01 19:11:02 -05:00
committed by GitHub
38 changed files with 3311 additions and 123 deletions
+8 -1
View File
@@ -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
View File
@@ -5332,6 +5332,7 @@ dependencies = [
name = "photocraft-psd"
version = "0.19.0"
dependencies = [
"encoding_rs",
"flate2",
"photocraft-psd",
"proptest",
+1
View File
@@ -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"
+7
View File
@@ -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"
+18 -4
View File
@@ -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"]));
}
+61 -1
View File
@@ -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"]);
+3 -3
View File
@@ -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,
+30 -2
View File
@@ -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> {
+19 -9
View File
@@ -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)) {
+6 -3
View File
@@ -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"),
+22 -6
View File
@@ -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();
+189 -2
View File
@@ -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"))
}
+29 -9
View File
@@ -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 {
+631 -1
View File
@@ -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", &params, "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", &params, "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");
}
+5 -2
View File
@@ -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};
+1 -1
View File
@@ -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;
}
+30 -7
View File
@@ -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,
+2 -2
View File
@@ -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,
+1
View File
@@ -20,6 +20,7 @@ testgen = []
[dependencies]
thiserror = { workspace = true }
flate2 = { workspace = true }
encoding_rs = { workspace = true }
serde = { workspace = true, optional = true }
[dev-dependencies]
+4 -3
View File
@@ -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)),
}
}
}
+5 -7
View File
@@ -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
View File
@@ -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();
+557
View File
@@ -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));
}
}
+1
View File
@@ -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
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+239
View File
@@ -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
View File
@@ -1,5 +1,6 @@
//! The crate's integration tests, linked into one test binary.
mod atn_contract;
mod builder;
mod corpus;
mod corpus_atn;
+3
View File
@@ -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),
+288 -50
View File
@@ -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, &params, owner);
let mut played = play_owned(app, &params, 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
+642 -5
View File
@@ -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());
+4
View File
@@ -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