Integration: transform anchor codes, Close save:false, Batch Save As + Close, opacity and thumbnail-click regressions (#10702)

- FTcs reference points used a 3x3 grid (Qcs0..Qcs8). The reference application has
  Qcsa centre, Qcs0..Qcs3 corners clockwise from top-left and Qcs4..Qcs7 the sides
  top/right/bottom/left (measured: 50 % scale, Qcs6 keeps the bottom centre, Qcs1 the
  top-right corner). Qcs8 and Qcsi stay kept steps; nothing writes Qcs8.
- file.close {"save": false} (used by ui-egui tests) became an error; it discards again
  and saves as Svng N.
- Batch never performs the action's Save As, so the scratch document stayed dirty and the
  recorded Close (no Svng -> "save":"ask") failed every file of "...; Save As; Close".
  A Save / Save As save point now marks the document saved.
- Adjustment-layer opacity is quantised to 8 bits on load; a recorded 70 % saved back as
  70.196 %. The whole percent that quantises to the same level is saved again.
- Cmd-clicking a group's thumbnail errored; it loads the whole canvas again as before.
  A recorded Cmd-click on another layer's thumbnail kept the numeric id and failed strict
  Save Actions; it records the layer by name (or as the Background).

Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-11-02 02:53:47 +01:00
co-authored by yoriswind Claude Opus 5.5
parent 0ae7356064
commit 78cf0cf91f
11 changed files with 184 additions and 39 deletions
+4 -2
View File
@@ -311,7 +311,7 @@ fn build() -> Vec<CommandSpec> {
"Close",
["File"],
Some("Cmd+W"),
r##"{"document":index?,"save":"discard|ask"="discard"} (ask refuses to close a changed document in headless playback)"##,
r##"{"document":index?,"save":"discard|ask"|false="discard"} (false means discard; ask refuses to close a changed document in headless playback)"##,
has_doc,
|s, p| {
let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
@@ -321,7 +321,9 @@ fn build() -> Vec<CommandSpec> {
return Err(bad("file.close", "the document has unsaved changes; save it or explicitly discard them"));
}
}
Some(v) if v != "discard" => return Err(bad("file.close", "`save` must be ask or discard")),
// `false` (don't save) is the older spelling of discard.
Some(Value::Bool(false)) => {}
Some(v) if v != "discard" => return Err(bad("file.close", "`save` must be ask, discard or false")),
_ => {}
}
s.close(i).ok_or(EngineError::NoDocument)?;
@@ -114,6 +114,48 @@ fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() {
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(target)).unwrap().visible);
}
/// ⌘-clicking a layer's thumbnail that is not the selected layer records the layer by name (the
/// Background as the Background), so the step saves in strict Save Actions and replays on the
/// same-named layer of another document.
#[test]
fn recorded_thumbnail_click_on_another_layer_saves_and_replays_by_name() {
use photocraft_geom::Rect;
let fixture = |s: &mut Session| {
s.execute("file.new", json!({"width": 4, "height": 1})).unwrap();
let source = s.execute("layer.new.layer", json!({"name": "Source"})).unwrap()["layer"].as_u64().unwrap();
s.edit("Pixels", |doc, _| {
doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("layer.new.layer", json!({"name": "Active"})).unwrap();
source
};
let mut s = Session::new();
let source = fixture(&mut s);
let background = s.active().unwrap().doc.background().unwrap().id.0;
s.execute("actions.record", json!({"name": "Load"})).unwrap();
s.execute("select.loadSelection", json!({"channel": "transparency", "layer": source, "operation": "new"})).unwrap();
s.execute("select.loadSelection", json!({"channel": "transparency", "layer": background, "operation": "new"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let action = s.actions.list.iter().find(|a| a.name == "Load").unwrap().clone();
assert_eq!(
action.steps,
vec![
("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"name": "Source"}, "operation": "new"})),
("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"background": true}, "operation": "new"})),
]
);
crate::atn_cmds::export("Set", &[&action], &[], false).unwrap();
// Replayed in another document, the first step loads its Source layer.
let mut other = Session::new();
fixture(&mut other);
let (id, params) = &action.steps[0];
other.execute(id, params.clone()).unwrap();
let doc = &other.active().unwrap().doc;
assert_eq!(doc.selection.as_ref().unwrap().read_region(Rect::new(0, 0, 4, 1)), vec![1.0, 0.0, 0.0, 0.0]);
}
#[test]
fn rename_keeps_calls_and_function_key_and_rejects_bad_names() {
let mut s = Session::new();
@@ -131,6 +131,7 @@ pub(super) fn close_to(_: &str, p: &Value) -> Option<Descriptor> {
}
match p.get("save").filter(|v| !v.is_null()) {
Some(v) if v == "ask" => Some(desc("null")),
Some(Value::Bool(false)) => Some(desc("null").with("Svng", enm("YsN ", "N "))),
Some(v) if v != "discard" => None,
_ => Some(desc("null").with("Svng", enm("YsN ", "N "))),
}
@@ -7,18 +7,19 @@ use super::fields::{F, Kind, Mapped, fields_from, fields_to, step};
use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, channel_of, lookup, lookup_back};
use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json};
/// Photoshop's nine frame anchors and the average/centre anchor.
/// The reference point of a Free Transform (`QCSt`): `Qcsa` the centre (average), `Qcs0`..`Qcs3`
/// the corners clockwise from the top left, `Qcs4`..`Qcs7` the sides top, right, bottom, left.
/// `Qcsi` (independent) and unknown codes have no mapping and stay kept steps.
const TRANSFORM_REFERENCE: &[(&str, &str)] = &[
("Qcsa", "center"),
("Qcs0", "topLeft"),
("Qcs1", "top"),
("Qcs2", "topRight"),
("Qcs3", "left"),
("Qcs4", "center"),
("Qcs4", "top"),
("Qcs1", "topRight"),
("Qcs7", "left"),
("Qcs5", "right"),
("Qcs6", "bottomLeft"),
("Qcs7", "bottom"),
("Qcs8", "bottomRight"),
("Qcs3", "bottomLeft"),
("Qcs6", "bottom"),
("Qcs2", "bottomRight"),
];
/// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre,
+11 -1
View File
@@ -279,6 +279,16 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped {
}
}
/// The percent to save for an adjustment layer's opacity. Loading quantises a recorded percent
/// to 8 bits (70 % becomes 179/255); a whole percent that quantises to that same level is the
/// one that was recorded, so it is saved back (load then save keeps `70`, not `70.196`).
fn recorded_percent(opacity: f64) -> f64 {
let level = opacity * 255.0;
let percent = opacity * 100.0;
let whole = percent.round();
if (level - level.round()).abs() < 1e-6 && (whole * 255.0 / 100.0).round() == level.round() { whole } else { percent }
}
/// Write a creation step, refusing native options the target descriptor cannot carry.
pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
let with_name = |cls: &str| {
@@ -425,7 +435,7 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
};
let mut using = desc("AdjL").with("Type", D::Descriptor(t));
if let Some(opacity) = opacity {
using = using.with("Opct", pct(opacity * 100.0));
using = using.with("Opct", pct(recorded_percent(opacity)));
}
desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using))
}
+38 -9
View File
@@ -3237,14 +3237,13 @@ fn mapped_transform_reference_points_round_trip() {
for (code, reference) in [
("Qcsa", "center"),
("Qcs0", "topLeft"),
("Qcs1", "top"),
("Qcs2", "topRight"),
("Qcs3", "left"),
("Qcs4", "center"),
("Qcs4", "top"),
("Qcs1", "topRight"),
("Qcs7", "left"),
("Qcs5", "right"),
("Qcs6", "bottomLeft"),
("Qcs7", "bottom"),
("Qcs8", "bottomRight"),
("Qcs3", "bottomLeft"),
("Qcs6", "bottom"),
("Qcs2", "bottomRight"),
] {
for target in [target_ref("Lyr "), D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])] {
let d = desc("null")
@@ -3268,8 +3267,19 @@ fn mapped_transform_reference_points_round_trip() {
}
}
}
let d = desc("null").with("null", target_ref("Lyr ")).with("FTcs", enm("QCSt", "unknown")).with("Wdth", pct(50.0));
assert_eq!(mapped_recording("Trnf", d).actions[0].steps[0].0, PHOTOSHOP_STEP);
// The reference application never records `Qcs8` or `Qcsi` (independent): kept steps.
for code in ["unknown", "Qcs8", "Qcsi"] {
let d = desc("null").with("null", target_ref("Lyr ")).with("FTcs", enm("QCSt", code)).with("Wdth", pct(50.0));
assert_eq!(mapped_recording("Trnf", d).actions[0].steps[0].0, PHOTOSHOP_STEP, "{code}");
}
// A native anchor saves as the reference application's code; nothing writes `Qcs8`.
for (reference, code) in [("top", "Qcs4"), ("topRight", "Qcs1"), ("bottom", "Qcs6"), ("bottomRight", "Qcs2")] {
let native = Action::new("Native", vec![("edit.transform".into(), json!({"scaleX":50,"scaleY":50,"reference":reference}))]);
let (bytes, _) = export("Anchors", &[&native], &[], false).unwrap();
let saved = atn::parse(&bytes).unwrap();
let d = saved.actions[0].items[0].descriptor.as_ref().unwrap();
assert!(matches!(d.get("FTcs"), Some(D::Enumerated { value, .. }) if value.is(code)), "{reference}: {:?}", d.get("FTcs"));
}
}
/// Alt-clicking an eye twice must isolate and then restore visibility, not just show a layer.
@@ -3450,6 +3460,25 @@ fn mapped_close_keeps_ask_and_discard_distinct() {
assert!(s.documents().is_empty());
}
/// `"save": false` is the older spelling of discard: it closes a changed document and saves as
/// "don't save" (`Svng N`); `true` and other values stay refused.
#[test]
fn close_save_false_discards_and_saves_as_dont_save() {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
for refused in [json!(true), json!("yes"), json!(0)] {
assert!(s.execute("file.close", json!({"save": refused})).is_err(), "{refused}");
assert_eq!(s.documents().len(), 1);
}
s.execute("file.close", json!({"save": false})).unwrap();
assert!(s.documents().is_empty());
let native = Action::new("Close", vec![("file.close".into(), json!({"save": false}))]);
let (bytes, _) = export("Close", &[&native], &[], false).unwrap();
let d = atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.clone().unwrap();
assert!(matches!(d.get("Svng"), Some(D::Enumerated { type_id, value }) if type_id.is("YsN ") && value.is("N ")), "{:?}", d.get("Svng"));
}
/// Lab Photo Filter colours use the CMS conversion and remain mapped after saving as RGB.
#[test]
fn mapped_lab_photo_filter_imports_and_round_trips() {
@@ -171,7 +171,6 @@ fn ref_planes(doc: &Document, layer: Option<LayerId>, active: Option<LayerId>, r
.map(|p| p[3])
.collect(),
],
LayerContent::Group(_) => return Err(EngineError::Other("group transparency sources are not supported".into())),
// Adjustment and fill layers cover the whole canvas.
_ => vec![vec![1.0; area.width() as usize * area.height() as usize]],
},
+9 -1
View File
@@ -307,7 +307,15 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let mut saved: Option<Arc<Document>> = None;
for (id, params) in &steps {
if batch_save_point(id) {
saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone());
let st = scratch.active_mut().ok_or(EngineError::NoDocument)?;
saved = Some(st.doc.clone());
// Batch performs the action's Save / Save As (to its destination), so the
// document is saved there as far as the steps after it see: a Close recorded
// without a save policy (`"save":"ask"`) then closes it. A Save a Copy leaves
// the document as changed as it was.
if id != "file.saveACopy" {
st.saved_revision = st.revision;
}
continue;
}
let r = scratch.execute(id, params.clone())?;
+22
View File
@@ -1828,6 +1828,28 @@ fn batch_saves_at_the_recorded_save_so_a_close_after_it_keeps_the_result() {
assert!(r["errors"][0]["error"].as_str().unwrap().contains("no active document"), "{r}");
}
/// "…; Save As; Close" with the Close recorded on a clean document (no `Svng`, `"save":"ask"`):
/// Batch performs the Save As, so the document is saved when the Close asks, and every file is
/// written. The Close used to refuse the still-changed scratch document and fail each file.
#[test]
fn batch_save_as_then_close_that_asks_processes_every_file() {
let dir = tmp("batch-save-close-ask");
let input = vec![png(&dir, "red.png", 8, 8, "#ff0000"), png(&dir, "blue.png", 8, 8, "#0000ff")];
let out = join(&dir, "out");
let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {"save": "ask"}]]);
let r = Session::new().execute("file.automate.batch", json!({"input": input, "output": out, "format": "png", "steps": steps})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}");
let red = photocraft_io::import("red.png", &std::fs::read(join(&out, "red.png")).unwrap()).unwrap().document;
assert_eq!(photocraft_compose::render(&red, Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0], "the inverted red");
// A Save a Copy doesn't save the document itself: the Close that asks still refuses it.
let out = join(&dir, "copy");
let steps = json!([["image.adjustments.invert", {}], ["file.saveACopy", {}], ["file.close", {"save": "ask"}]]);
let r = Session::new().execute("file.automate.batch", json!({"input": input, "output": out, "format": "png", "steps": steps})).unwrap();
assert_eq!(r["files"], json!([]), "{r}");
assert_eq!(r["errors"].as_array().unwrap().len(), 2, "{r}");
}
/// The document a recorded save marked is the result, not the one left active after it closed.
/// Here the action saves and closes a second document it made, then inverts the input; the output
/// used to be the inverted input, written in place of the result without an error.
@@ -159,6 +159,29 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio
}
return p;
}
if id == "select.loadSelection" {
return recorded_source_layer(s, p, layer_id);
}
p
}
/// ⌘-clicking another layer's thumbnail loads that layer's transparency. The step refers to the
/// layer the way the reference application records it, by name (or as the Background), so it
/// replays in another document and saves to `.atn`. A layer that the name would not find (a
/// same-named layer above it) keeps its id.
fn recorded_source_layer(s: &Session, mut p: Value, id: LayerId) -> Value {
let Some(doc) = s.active().map(|st| &st.doc) else { return p };
let Some(layer) = doc.layer(id) else { return p };
let reference = if doc.background().is_some_and(|b| b.id == id) {
json!({"background": true})
} else if doc.walk().into_iter().rev().find(|(_, _, l)| l.name == layer.name).is_some_and(|(_, _, l)| l.id == id) {
json!({"name": layer.name})
} else {
return p;
};
if let Some(fields) = p.as_object_mut() {
fields.insert("layer".into(), reference);
}
p
}
+25 -17
View File
@@ -150,7 +150,7 @@ fn review_copy_cut_opacity_is_validated_before_any_step_runs() {
fn review_layer_transform_uses_the_recorded_anchor() {
for depth in [8, 16, 32] {
for (code, fx, fy) in
[("Qcs0", 0, 0), ("Qcs1", 1, 0), ("Qcs2", 2, 0), ("Qcs3", 0, 1), ("Qcs4", 1, 1), ("Qcs5", 2, 1), ("Qcs6", 0, 2), ("Qcs7", 1, 2), ("Qcs8", 2, 2)]
[("Qcs0", 0, 0), ("Qcs4", 1, 0), ("Qcs1", 2, 0), ("Qcs7", 0, 1), ("Qcsa", 1, 1), ("Qcs5", 2, 1), ("Qcs3", 0, 2), ("Qcs6", 1, 2), ("Qcs2", 2, 2)]
{
let d = Descriptor::new("null")
.with("FTcs", enm("QCSt", code))
@@ -176,11 +176,15 @@ fn review_layer_transform_uses_the_recorded_anchor() {
.unwrap();
replay_saved(&mut s, &action);
let state = s.active().unwrap();
assert_eq!(
state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(),
photocraft_geom::Rect::new(20 + 5 * fx, 20 + 5 * fy, 30 + 5 * fx, 30 + 5 * fy),
"{code}, depth {depth}"
);
let bounds = state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
assert_eq!(bounds, photocraft_geom::Rect::new(20 + 5 * fx, 20 + 5 * fy, 30 + 5 * fx, 30 + 5 * fy), "{code}, depth {depth}");
// Measured in the reference application: Qcs6 keeps the bottom centre fixed, Qcs1 the
// top-right corner.
match code {
"Qcs6" => assert_eq!(bounds, photocraft_geom::Rect::new(25, 30, 35, 40), "depth {depth}"),
"Qcs1" => assert_eq!(bounds, photocraft_geom::Rect::new(30, 20, 40, 30), "depth {depth}"),
_ => {}
}
s.execute("edit.undo", json!({})).unwrap();
if code == "Qcs0" {
s.edit("Mark rotation", |doc, active| {
@@ -377,9 +381,10 @@ fn review_10702_transparency_names_resolve_at_playback() {
}
}
/// A group has no supported transparency plane and must not overwrite a real selection.
/// A group's transparency loads the whole canvas, as ⌘-clicking its thumbnail always did (the
/// group's combined transparency is not composited yet), rather than failing.
#[test]
fn group_transparency_sources_fail_without_changing_selection() {
fn group_transparency_sources_load_the_canvas() {
use photocraft_doc::LayerId;
use photocraft_geom::Rect;
@@ -409,19 +414,21 @@ fn group_transparency_sources_fail_without_changing_selection() {
]),
);
let action = recorded(AtnEvent::Code(*b"setd"), descriptor.clone());
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
let history = s.active().unwrap().history.past_len();
let (id, params) = &action.steps[0];
let result = s.execute(id, params.clone());
let alpha = s.active().unwrap().doc.selection.as_ref().map(|mask| mask.read_region(Rect::from_xywh(0, 0, 2, 1)));
assert!(result.is_err(), "group {name:?} returned {result:?} with selection {alpha:?}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().revision, revision);
assert_eq!(s.active().unwrap().history.past_len(), history);
let doc = &s.active().unwrap().doc;
let alpha = doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]);
assert!(result.is_ok(), "group {name:?} returned {result:?}");
assert_eq!(alpha, vec![1.0, 1.0], "group {name:?}");
// ⌘-click on the group's thumbnail (by id) loads the same.
s.execute("select.deselect", json!({})).unwrap();
s.execute("select.loadSelection", json!({"channel":"transparency","layer":group.0})).unwrap();
let doc = &s.active().unwrap().doc;
assert_eq!(doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]), vec![1.0, 1.0]);
assert_eq!(saved(&action).descriptor, Some(descriptor));
// The group's explicit mask remains a valid source.
s.execute("select.loadSelection", json!({"channel":"transparency","layer":"Child"})).unwrap();
s.execute("layer.layerMask.revealSelection", json!({"layer":group.0})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none());
s.execute("select.loadSelection", json!({"channel":"mask","layer":params["layer"]})).unwrap();
@@ -557,7 +564,8 @@ fn recorded_adjustment_opacity_keeps_gradient_settings_and_defaults() {
}
let written = saved(&action).descriptor.unwrap();
let Some(D::Descriptor(using)) = written.get("Usng") else { panic!("missing adjustment metadata") };
assert!(matches!(using.get("Opct"),Some(D::UnitFloat{unit,value}) if unit==b"#Prc" && (*value-179.0/255.0*100.0).abs()<1e-10));
// The recorded 70 % saves back as 70 %, not as the 8-bit level 179/255 (70.196 %).
assert!(matches!(using.get("Opct"), Some(D::UnitFloat { unit, value }) if unit == b"#Prc" && *value == 70.0), "{:?}", using.get("Opct"));
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":3,"depth":depth})).unwrap();