diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 6c9939607d..16781e42bb 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -347,6 +347,7 @@ fn services(inbox: Inbox) -> Services { opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; opts.tga_bits = settings.tga_bits; opts.image_previews = settings.image_previews; + opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) })), file_dialog: Some(Box::new(|request, _parent, reply| match request { diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index aee9eabca1..00d334758b 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -90,11 +90,21 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { // eframe saves native window geometry and egui panel/window sizes on exit. // Keep that state beside preferences, including config overrides and portable mode. persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")), - window_builder: Some(Box::new(at_least_min_size)), + window_builder: Some(Box::new(restored_window)), ..Default::default() } } +/// The window eframe restores from `ui.ron`, corrected before it is created: at least the +/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but +/// eframe also saves and restores full screen, and a window closed in a full-screen mode is +/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps +/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode › +/// Full Screen did nothing until `F` had cycled through Standard. +fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder { + at_least_min_size(builder).with_fullscreen(false) +} + /// Grow a restored window that is smaller than the minimum size, keeping its centre. On /// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has /// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened @@ -624,6 +634,22 @@ mod tests { assert_eq!(super::at_least_min_size(builder).inner_size, None); } + #[test] + fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() { + let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook"); + // What eframe restores from a ui.ron saved in a full-screen mode on Windows. + let saved = super::native_options(super::CUSTOM_TITLEBAR) + .viewport + .with_inner_size([2560.0, 1392.0]) + .with_position([0.0, 0.0]) + .with_fullscreen(true) + .with_maximized(true); + let opened = hook(saved); + assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts"); + assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept"); + assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0))); + } + #[test] fn the_system_title_bar_preference_keeps_the_window_decorations() { // #1271, #1316: Preferences › Interface › System Title Bar gives the window back its diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index f6695717be..fea0d34541 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -217,6 +217,7 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; opts.tga_bits = settings.tga_bits; opts.image_previews = settings.image_previews; + opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; opts } diff --git a/contributors/people.toml b/contributors/people.toml index 53bd154ace..82a9e20a49 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -124,3 +124,6 @@ sync_github_name = true [people.arthurdeka] sync_github_name = true + +[people.aweussom] +real_name = "Tommy Leonhardsen" diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 4fe3b879fb..7fe7fc3983 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -79,6 +79,9 @@ pub fn proxy_document(doc: &Document, k: u32) -> Document { c.surface = downsample(&c.surface, k); } p.selection = p.selection.as_ref().map(|s| downsample(s, k)); + if let Some(mask) = &mut p.quick_mask { + mask.surface = downsample(&mask.surface, k); + } p } @@ -172,6 +175,27 @@ mod tests { assert!(proxy_document(&Document::new("n", Size::new(8, 8), ColorMode::Rgb, SampleType::U8), 4).selection.is_none()); } + #[test] + fn proxy_scales_quick_mask_at_every_depth() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("mask", Size::new(16, 12), ColorMode::Rgb, depth); + let mut surface = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + surface.fill_rect(Rect::new(0, 0, 8, 12), &[1.0]); + let mut mask = photocraft_doc::AlphaChannel::new("Quick Mask", surface); + mask.opacity = 0.25; + doc.quick_mask = Some(mask); + for k in [1, 2, 4] { + let proxy = proxy_document(&doc, k); + let mask = proxy.quick_mask.as_ref().unwrap(); + assert_eq!(mask.surface.pixel((8 / k - 1) as i32, 0), vec![1.0]); + assert_eq!(mask.surface.pixel((8 / k) as i32, 0), vec![0.0], "depth {depth:?}, factor {k}"); + assert_eq!(mask.opacity, 0.25); + assert_eq!(mask.surface.format().sample, depth); + } + assert_eq!(doc.quick_mask.as_ref().unwrap().surface.pixel(7, 0), vec![1.0], "source stays intact"); + } + } + #[test] fn proxy_accepts_empty_paths_but_keeps_geometry_and_effect_guards() { let mut doc = two_colors(Size::new(32, 16), SampleType::F32); diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index edadde2117..3ee242d7d6 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -353,7 +353,7 @@ pub fn finish_playback(s: &mut Session, recording: Option<(usize, usize)>, actio /// Nested playback returns a structured failure even though the command itself succeeded. /// Promote it to the caller's failed step so no later parent steps run after a child fails. pub fn nested_failure(id: &str, value: &Value) -> Option { - (id == "actions.play").then(|| value.get("failed")).flatten().map(|failure| { + (id == "actions.play" || id == "actions.conditional").then(|| value.get("failed")).flatten().map(|failure| { format!( "action `{}` step {} failed: {}", value.get("action").and_then(Value::as_str).unwrap_or(""), diff --git a/crates/engine/src/actions_edit_cmds.rs b/crates/engine/src/actions_edit_cmds.rs index c7cde6869f..1f9cce3431 100644 --- a/crates/engine/src/actions_edit_cmds.rs +++ b/crates/engine/src/actions_edit_cmds.rs @@ -126,6 +126,123 @@ fn assign_set(s: &mut Session, p: &Value) -> Result { Ok(result) } +/// The conditions of Photoshop's Insert Conditional, by their string ids. +pub const CONDITIONS: &[&str] = &[ + "always", + "never", + "isOpen", + "landscape", + "square", + "portrait", + "modeIsRGB", + "modeIsCMYK", + "modeIsGrayscale", + "modeIsLab", + "modeIsBitmap", + "modeIsIndexed", + "modeIsDuotone", + "modeIsMultichannel", + "depth8", + "depth16", + "depth32", + "unsaved", + "hasSelection", + "hasLayers", + "hasAlphaChannels", + "isBackground", + "isPixelLayer", + "isAdjustmentLayer", + "isShapeLayer", + "isTypeLayer", + "isSmartObject", + "isLayerGroup", + "isLocked", + "isVisible", + "hasPixelMask", + "hasVectorMask", + "hasEffects", +]; + +/// Whether `condition` holds for the active document and layer. Layer conditions are false +/// without a document or an active layer; `isOpen` is whether a document is open. +pub fn evaluate(s: &Session, condition: &str) -> Result { + use photocraft_color::ColorMode; + use photocraft_doc::LayerContent; + if !CONDITIONS.contains(&condition) { + return Err(bad("actions.conditional", format!("unknown condition `{condition}` ({})", CONDITIONS.join("|")))); + } + let Some(d) = s.active() else { return Ok(condition == "always") }; + let doc = &d.doc; + let layer = d.active_layer.and_then(|id| doc.layer(id)); + let (w, h) = (doc.size.width, doc.size.height); + Ok(match condition { + "always" | "isOpen" => true, + "never" => false, + "landscape" => w > h, + "square" => w == h, + "portrait" => h > w, + "modeIsRGB" => doc.mode == ColorMode::Rgb, + "modeIsCMYK" => doc.mode == ColorMode::Cmyk, + "modeIsGrayscale" => doc.mode == ColorMode::Grayscale, + "modeIsLab" => doc.mode == ColorMode::Lab, + "modeIsBitmap" => doc.mode == ColorMode::Bitmap, + "modeIsIndexed" => doc.mode == ColorMode::Indexed, + "modeIsDuotone" => doc.mode == ColorMode::Duotone, + "modeIsMultichannel" => doc.mode == ColorMode::Multichannel, + "depth8" => doc.depth == photocraft_color::SampleType::U8, + "depth16" => doc.depth == photocraft_color::SampleType::U16, + "depth32" => doc.depth == photocraft_color::SampleType::F32, + "unsaved" => d.is_dirty(), + "hasSelection" => doc.selection.is_some(), + // More than a lone Background: any layer that isn't the background. + "hasLayers" => doc.walk().iter().any(|(_, _, l)| !doc.is_background(l.id)), + "hasAlphaChannels" => !doc.channels.is_empty(), + "isBackground" => layer.is_some_and(|l| doc.is_background(l.id)), + "isPixelLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_)) && !doc.is_background(l.id)), + "isAdjustmentLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Adjustment(_))), + "isShapeLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Shape(_))), + "isTypeLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Text(_))), + "isSmartObject" => layer.is_some_and(|l| matches!(l.content, LayerContent::Smart(_))), + "isLayerGroup" => layer.is_some_and(|l| l.is_group()), + "isLocked" => layer.is_some_and(|l| l.locks.all || l.locks.pixels || l.locks.position || l.locks.transparency), + "isVisible" => layer.is_some_and(|l| l.visible), + "hasPixelMask" => layer.is_some_and(|l| l.mask.is_some()), + "hasVectorMask" => layer.is_some_and(|l| l.vector_mask.is_some()), + "hasEffects" => layer.is_some_and(|l| !l.effects.items.is_empty()), + _ => false, + }) +} + +/// Photoshop's Insert Conditional as a step: test the condition, play `then` or `else`. +fn conditional(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.conditional"; + let condition = match p.get("if") { + Some(Value::String(c)) => c.clone(), + _ => return Err(bad(cmd, format!("needs `if`, one of {}", CONDITIONS.join("|")))), + }; + let branch = |key: &str| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) if !n.trim().is_empty() => Ok(Some(n.clone())), + Some(Value::String(_)) => Ok(None), + Some(_) => Err(bad(cmd, format!("`{key}` must be an action name or null"))), + } + }; + let (then, otherwise) = (branch("then")?, branch("else")?); + let holds = evaluate(s, &condition)?; + let played = if holds { then } else { otherwise }; + let mut out = json!({"if": condition, "holds": holds, "played": played}); + if let Some(name) = played { + let r = s.execute("actions.play", json!({"action": name}))?; + if let Some(f) = r.get("failed") { + out["failed"] = f.clone(); + } + out["ran"] = r.get("ran").cloned().unwrap_or(json!(0)); + out["action"] = json!(name); + } + Ok(out) +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -148,6 +265,16 @@ pub fn specs() -> Vec { run: dialog, journal: false, }, + CommandSpec { + id: "actions.conditional", + label: "Conditional", + menu: &[], + shortcut: None, + params: r##"{"if":condition, "then":action name|null, "else":action name|null} → {if, holds, played, ran?, failed?}. Photoshop's Insert Conditional: conditions always|never|isOpen|landscape|square|portrait|modeIsRGB|modeIsCMYK|modeIsGrayscale|modeIsLab|modeIsBitmap|modeIsIndexed|modeIsDuotone|modeIsMultichannel|depth8|depth16|depth32|unsaved|hasSelection|hasLayers|hasAlphaChannels|isBackground|isPixelLayer|isAdjustmentLayer|isShapeLayer|isTypeLayer|isSmartObject|isLayerGroup|isLocked|isVisible|hasPixelMask|hasVectorMask|hasEffects (layer conditions test the active layer). The branch plays as actions.play does; its failure stops the calling action."##, + enabled: always, + run: conditional, + journal: true, + }, CommandSpec { id: "actions.assignSet", label: "Move to Set", @@ -223,6 +350,68 @@ mod tests { assert_eq!(s.actions.list[0].dialogs.iter().copied().collect::>(), [0]); } + #[test] + fn conditional_tests_the_document_and_plays_a_branch() { + let mut s = session_with(&[]); + s.actions.list.push(Action::new("Yes", vec![("layer.new.layer".into(), json!({"name": "yes"}))])); + s.actions.list.push(Action::new("No", vec![("layer.new.layer".into(), json!({"name": "no"}))])); + let names = |s: &Session| s.active().unwrap().doc.walk().iter().map(|(_, _, l)| l.name.clone()).collect::>(); + // 8 × 8: square, not landscape; one Background: no layers yet. + for (cond, expect) in [ + ("square", true), + ("landscape", false), + ("portrait", false), + ("modeIsRGB", true), + ("modeIsCMYK", false), + ("depth8", true), + ("hasLayers", false), + ("hasSelection", false), + ("isBackground", true), + ("isPixelLayer", false), + ("hasEffects", false), + ("always", true), + ("never", false), + ("isOpen", true), + ] { + assert_eq!(evaluate(&s, cond).unwrap(), expect, "{cond}"); + } + let r = s.execute("actions.conditional", json!({"if": "square", "then": "Yes", "else": "No"})).unwrap(); + assert_eq!((r["holds"].as_bool(), r["played"].as_str(), r["ran"].as_u64()), (Some(true), Some("Yes"), Some(1))); + assert_eq!(names(&s).last().map(String::as_str), Some("yes")); + for (cond, expect) in [("hasLayers", true), ("isPixelLayer", true), ("isBackground", false), ("isVisible", true)] { + assert_eq!(evaluate(&s, cond).unwrap(), expect, "{cond}"); + } + let r = s.execute("actions.conditional", json!({"if": "landscape", "then": "Yes", "else": "No"})).unwrap(); + assert_eq!((r["holds"].as_bool(), r["played"].as_str()), (Some(false), Some("No"))); + assert_eq!(names(&s).last().map(String::as_str), Some("no")); + // No branch for the outcome: nothing plays, no error. + let r = s.execute("actions.conditional", json!({"if": "landscape", "then": "Yes"})).unwrap(); + assert!(r["played"].is_null()); + // A failing branch reports like a nested Play, and stops a calling action. + s.actions.list.push(Action::new("Bad", vec![("layer.delete".into(), json!({"layer": 999}))])); + let r = s.execute("actions.conditional", json!({"if": "always", "then": "Bad"})).unwrap(); + assert!(r["failed"].is_object(), "{r}"); + assert!(crate::actions_cmds::nested_failure("actions.conditional", &r).is_some()); + s.actions.list.push(Action::new( + "Caller", + vec![("actions.conditional".into(), json!({"if": "always", "then": "Bad"})), ("layer.new.layer".into(), json!({"name": "after"}))], + )); + let played = s.execute("actions.play", json!({"action": "Caller"})).unwrap(); + assert_eq!(played["failed"]["step"], 0, "{played}"); + assert_ne!(names(&s).last().map(String::as_str), Some("after")); + // Without a document only always/never answer; layer conditions are false. + let t = Session::new(); + assert!(evaluate(&t, "always").unwrap()); + assert!(!evaluate(&t, "isOpen").unwrap()); + assert!(!evaluate(&t, "isBackground").unwrap()); + for p in [json!({}), json!({"if": "sometimes"}), json!({"if": 3}), json!({"if": "always", "then": 5}), json!({"if": "always", "then": "Nope"})] { + assert!( + s.execute("actions.conditional", p.clone()).is_err() || s.execute("actions.conditional", p.clone()).unwrap().get("failed").is_some(), + "{p}" + ); + } + } + #[test] fn assign_set_moves_an_action_between_folders() { let mut s = session_with(&["select.all"]); diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 4e5de06970..e5c8e74674 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -164,8 +164,9 @@ fn class(c: &str) -> Class { Class { name: UnicodeString::new_nul(""), class_id: code(c) } } -/// The pixel unit of a `UntF` value. +/// The pixel and percent units of a `UntF` value. const PX: [u8; 4] = *b"#Pxl"; +const PCT: [u8; 4] = *b"#Prc"; fn px(v: f64) -> D { D::UnitFloat { unit: PX, value: v } @@ -270,6 +271,31 @@ fn color_desc(hex: &str) -> Option { Some(desc("RGBC").with("Rd ", D::Double(ch(0)?)).with("Grn ", D::Double(ch(2)?)).with("Bl ", D::Double(ch(4)?))) } +/// A `Pth ` value's text: Photoshop stores `"txtu"`, a little-endian total length, a +/// little-endian UTF-16 unit count and the units (NUL-terminated). +fn path_text(v: &D) -> Option { + let D::Path(b) = v else { return None }; + if b.get(0..4)? != b"txtu" { + return None; + } + let n = u32::from_le_bytes(b.get(8..12)?.try_into().ok()?) as usize; + let units: Vec = b.get(12..12usize.checked_add(n.checked_mul(2)?)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + let text = String::from_utf16_lossy(&units); + let text = text.trim_end_matches('\0'); + (!text.is_empty()).then(|| text.to_string()) +} + +fn path_value(text: &str) -> D { + let units: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); + let mut b = b"txtu".to_vec(); + b.extend_from_slice(&((12 + units.len() * 2) as u32).to_le_bytes()); + b.extend_from_slice(&(units.len() as u32).to_le_bytes()); + for u in units { + b.extend_from_slice(&u.to_le_bytes()); + } + D::Path(b) +} + /// Photoshop's `BlnM` enumeration ↔ PhotoCraft's blend mode labels. const BLEND_MODES: &[(&str, &str)] = &[ ("Nrml", "Normal"), @@ -713,6 +739,60 @@ fn selective_to(p: &Value) -> Option { ) } +/// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`. +fn gradient_map_from(d: &Descriptor) -> Option { + let grad = get_obj(d, "Grad")?; + let stops: Option> = get_list(grad, "Clrs")? + .iter() + .map(|v| match v { + D::Descriptor(stop) => Some(json!([(get_num(stop, "Lctn")? / 4096.0).clamp(0.0, 1.0), color_hex(get_obj(stop, "Clr ")?)?])), + _ => None, + }) + .collect(); + let mut out = json!({"stops": stops?}); + if let Some(r) = get_bool(d, "Rvrs") { + out["reverse"] = json!(r); + } + if let Some(dd) = get_bool(d, "Dthr") { + out["dither"] = json!(dd); + } + Some(out) +} + +fn gradient_map_to(p: &Value) -> Option { + let stops: Option> = p + .get("stops")? + .as_array()? + .iter() + .map(|st| { + let a = st.as_array()?; + Some(D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(color_desc(a.get(1)?.as_str()?)?)) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", long((a.first()?.as_f64()? * 4096.0).round())) + .with("Mdpn", long(50.0)), + )) + }) + .collect(); + let transparency = D::List(vec![ + D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))), + D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))), + ]); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(stops?)) + .with("Trns", transparency); + Some( + desc("GdMp") + .with("Grad", D::Descriptor(grad)) + .with("Rvrs", D::Boolean(jbool(p, "reverse").unwrap_or(false))) + .with("Dthr", D::Boolean(jbool(p, "dither").unwrap_or(false))), + ) +} + /// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and /// the converters between the adjustment descriptor and the command params. struct Adjustment { @@ -743,6 +823,7 @@ const ADJUSTMENTS: &[Adjustment] = &[ adj_fields!("BlnW", "blackWhite", BW), Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to }, Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to }, + Adjustment { class: "GdMp", suffix: "gradientMap", from: gradient_map_from, to: gradient_map_to }, ]; fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { @@ -1028,6 +1109,20 @@ fn set_from(d: &Descriptor) -> Mapped { (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), _ => unsupported, }, + Some(D::Descriptor(t)) if t.class_id.is("Plgn") => { + let pts: Option> = get_list(t, "Pts ") + .unwrap_or(&[]) + .iter() + .map(|v| match v { + D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])), + _ => None, + }) + .collect(); + match pts { + Some(pts) if pts.len() >= 3 => step("select.lasso", json!({"points": pts})), + _ => unsupported, + } + } Some(D::Descriptor(t)) if t.class_id.is("Rctn") || t.class_id.is("Elps") => { let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) else { @@ -1041,6 +1136,8 @@ fn set_from(d: &Descriptor) -> Mapped { } _ => unsupported, }, + // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. + (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), (Some("Lyr"), _) => { let Some(mut p) = layer_target(r) else { return unsupported }; let Some(t) = get_obj(d, "T ") else { return unsupported }; @@ -1095,6 +1192,24 @@ fn set_to(id: &str, p: &Value) -> Option { "transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])), name => sel(name_ref("Chnl", name)), }, + "select.lasso" => { + let pts: Option> = p + .get("points")? + .as_array()? + .iter() + .map(|pt| { + let a = pt.as_array()?; + Some(D::Descriptor(desc("Pnt ").with("Hrzn", px(a.first()?.as_f64()?)).with("Vrtc", px(a.get(1)?.as_f64()?)))) + }) + .collect(); + sel(D::Descriptor(desc("Plgn").with("Pts ", D::List(pts?)))) + } + "select.editInQuickMaskMode" => { + if jbool(p, "on") != Some(true) { + return None; + } + desc("null").with("null", quick_mask_ref()) + } "select.rect" => { let (x, y, w, h) = (jnum(p, "x")?, jnum(p, "y")?, jnum(p, "width")?, jnum(p, "height")?); let cls = if jbool(p, "ellipse") == Some(true) { "Elps" } else { "Rctn" }; @@ -1195,14 +1310,342 @@ fn delete_from(d: &Descriptor) -> Mapped { Some(p) => step("layer.delete", Value::Object(p)), None => Mapped::Unsupported { harmless: false }, }, + // The targeted channel of a layer is its mask (what Photoshop records after making an + // adjustment layer over a selection and discarding the mask). + Some(r) if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Trgt") => none("layer.layerMask.delete"), Some(_) => Mapped::Unsupported { harmless: false }, } } +fn quick_mask_ref() -> D { + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: code("QucM") }, + ReferenceItem::Enumerated { class: class("Dcmn"), type_id: code("Ordn"), value: code("Trgt") }, + ]) +} + +fn clear_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null").map(target) { + Some(Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": false})), + None => none("edit.clear"), + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn clear_to(id: &str, p: &Value) -> Option { + match id { + "select.editInQuickMaskMode" if jbool(p, "on") == Some(false) => Some(desc("null").with("null", quick_mask_ref())), + _ => None, + } +} + +/// `placeEvent`: File › Place Embedded / Linked, with the file and the recorded scale. +fn place_from(d: &Descriptor) -> Mapped { + let Some(path) = d.get("null").and_then(path_text) else { return Mapped::Unsupported { harmless: false } }; + let mut p = json!({"path": path}); + if let Some(w) = get_num(d, "Wdth") { + p["scale"] = json!(w); + } + step(if get_bool(d, "Lnkd") == Some(true) { "file.placeLinked" } else { "file.placeEmbedded" }, p) +} + +fn place_to(id: &str, p: &Value) -> Option { + let mut d = desc("null").with("null", path_value(jstr(p, "path")?)).with("FTcs", enm("QCSt", "Qcsa")); + if let Some(s) = jnum(p, "scale") { + d = d.with("Wdth", pct(s)).with("Hght", pct(s)); + } + Some(d.with("Lnkd", D::Boolean(id == "file.placeLinked")).with("Antl", D::Boolean(true))) +} + +/// `open`: File › Open of a recorded path. +fn open_from(d: &Descriptor) -> Mapped { + match d.get("null").and_then(path_text) { + Some(path) => step("file.openAs", json!({"path": path})), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn open_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", path_value(jstr(p, "path")?))) +} + +/// Photoshop's four-character condition codes that differ from the string ids. +const CONDITION_CODES: &[(&str, &str)] = &[("Lyrs", "hasLayers")]; + +/// `conditional`: Insert Conditional, with its Then and Else actions by name. +fn conditional_from(d: &Descriptor) -> Mapped { + let Some(c) = get_enum(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let condition = lookup(CONDITION_CODES, &c).unwrap_or(c.as_str()); + if !crate::actions_edit_cmds::CONDITIONS.contains(&condition) { + return Mapped::Unsupported { harmless: false }; + } + let branch = |key: &str| match get_ref(d, key).map(target) { + Some(Target::Name(n)) => Some(n), + _ => None, + }; + step("actions.conditional", json!({"if": condition, "then": branch("then"), "else": branch("else")})) +} + +fn conditional_to(_: &str, p: &Value) -> Option { + let condition = jstr(p, "if")?; + let code = lookup_back(CONDITION_CODES, condition).unwrap_or(condition); + let mut d = desc("null").with("null", enm("Cndt", code)); + for key in ["then", "else"] { + if let Some(name) = jstr(p, key) { + let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; + if let Some(set) = jstr(p, "set") { + items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); + } + d = d.with(key, D::Reference(items)); + } + } + Some(d) +} + +/// `close`: Photoshop's Save prompt answer becomes a Save step before the Close. +fn close_from(d: &Descriptor) -> Mapped { + if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } +} + +fn close_to(_: &str, _: &Value) -> Option { + Some(desc("null").with("Svng", enm("YsN ", "N "))) +} + +/// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre, +/// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything +/// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`. +fn transform_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let unsupported = Mapped::Unsupported { harmless: false }; + let mut p = Map::new(); + if let Some(o) = get_obj(d, "Ofst") { + let in_px = |k: &str| matches!(o.get(k), Some(D::UnitFloat { unit: PX, .. })); + if !in_px("Hrzn") || !in_px("Vrtc") { + return unsupported; + } + if let Some(x) = get_num(o, "Hrzn") { + p.insert("dx".into(), x.into()); + } + if let Some(y) = get_num(o, "Vrtc") { + p.insert("dy".into(), y.into()); + } + } + for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY"), ("Angl", "rotate")] { + if let Some(v) = get_num(d, key) { + p.insert(param.into(), v.into()); + } + } + if d.get("Skew").is_some() || d.get("Usng").is_some() { + return unsupported; + } + let geometry_only = p.keys().all(|k| k == "dx" || k == "dy"); + match (ref_class(r).as_deref(), target(r)) { + (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => step("select.transformSelection", Value::Object(p)), + (Some("Lyr"), _) => { + let Some(mut lp) = layer_target(r) else { return unsupported }; + if geometry_only { + lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + return step("layer.translate", Value::Object(lp)); + } + lp.extend(p); + if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + lp.insert("interpolation".into(), i.into()); + } + step("edit.transform", Value::Object(lp)) + } + _ => unsupported, + } +} + +fn transform_to(id: &str, p: &Value) -> Option { + if p.get("quad").is_some() || p.get("matrix").is_some() || p.get("rect").is_some() { + return None; + } + let target = match id { + "edit.transform" => layer_ref(p), + "select.transformSelection" => D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]), + _ => return None, + }; + let mut d = desc("null").with("null", target).with("FTcs", enm("QCSt", "Qcsa")); + d = d.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0))))); + for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY")] { + if let Some(v) = jnum(p, param) { + d = d.with(key, pct(v)); + } + } + if let Some(a) = jnum(p, "rotate") { + d = d.with("Angl", ang(a)); + } + if let Some(i) = jstr(p, "interpolation").and_then(|i| lookup_back(INTERPOLATION, i)) { + d = d.with("Intr", enm("Intp", i)); + } + Some(d) +} + +/// `Clcn` (Apply Image / Calculations blending) ↔ `image.applyImage`'s `blending`. +const CALC_BLEND: &[(&str, &str)] = &[ + ("Nrml", "normal"), + ("Mltp", "multiply"), + ("Scrn", "screen"), + ("Ovrl", "overlay"), + ("SftL", "softLight"), + ("HrdL", "hardLight"), + ("CDdg", "colorDodge"), + ("CBrn", "colorBurn"), + ("Drkn", "darken"), + ("Lghn", "lighten"), + ("Dfrn", "difference"), + ("Xclu", "exclusion"), + ("linearBurn", "linearBurn"), + ("linearDodge", "linearDodge"), + ("Add ", "add"), + ("Sbtr", "subtract"), +]; +const SOURCE_CHANNELS: &[(&str, &str)] = + &[("RGB", "composite"), ("CMYK", "composite"), ("Lab", "composite"), ("Trsp", "transparency"), ("Msk", "mask"), ("fsel", "selection")]; + +fn apply_image_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(with) = get_obj(d, "With") else { return unsupported }; + let Some(r) = get_ref(with, "T ") else { return unsupported }; + let mut source = Map::new(); + for item in r { + match item { + ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Chnl") => { + let v = trimmed(value); + let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| lookup(CHANNELS, &v)) else { return unsupported }; + source.insert("channel".into(), channel.into()); + } + ReferenceItem::Name { class, name } if class.class_id.is("Lyr ") => { + source.insert("layer".into(), name.to_string_lossy().into()); + } + ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Lyr ") && trimmed(value) == "Mrgd" => { + source.insert("layer".into(), "merged".into()); + } + _ => return unsupported, + } + } + if let Some(i) = get_bool(with, "Invr") { + source.insert("invert".into(), i.into()); + } + let mut p = json!({"source": source}); + if let Some(c) = get_enum(with, "Clcl") { + match lookup(CALC_BLEND, &c) { + Some(b) => p["blending"] = json!(b), + None => return unsupported, + } + } + for (key, param) in [("Scl ", "scale"), ("Ofst", "offset"), ("Opct", "opacity")] { + if let Some(v) = get_num(with, key) { + p[param] = json!(v); + } + } + if let Some(t) = get_bool(with, "PrsT") { + p["preserveTransparency"] = json!(t); + } + step("image.applyImage", p) +} + +fn apply_image_to(_: &str, p: &Value) -> Option { + let source = p.get("source").cloned().unwrap_or(json!({})); + let channel = jstr(&source, "channel").unwrap_or("composite"); + let chan = lookup_back(SOURCE_CHANNELS, channel).or_else(|| lookup_back(CHANNELS, channel))?; + let mut items = vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(chan) }]; + match jstr(&source, "layer") { + Some("merged") | None => items.push(ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Mrgd") }), + Some(name) => items.push(ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }), + } + let mut with = desc("null").with("T ", D::Reference(items)); + if let Some(i) = jbool(&source, "invert") { + with = with.with("Invr", D::Boolean(i)); + } + with = with.with("Clcl", enm("Clcn", lookup_back(CALC_BLEND, jstr(p, "blending").unwrap_or("multiply"))?)); + if let Some(v) = jnum(p, "opacity") { + with = with.with("Opct", pct(v)); + } + if let Some(v) = jnum(p, "scale") { + with = with.with("Scl ", D::Double(v)); + } + if let Some(v) = jnum(p, "offset") { + with = with.with("Ofst", D::Double(v)); + } + if let Some(t) = jbool(p, "preserveTransparency") { + with = with.with("PrsT", D::Boolean(t)); + } + Some(desc("null").with("With", D::Descriptor(with))) +} + +const WEB_FORMATS: &[(&str, &str)] = &[("PN24", "png24"), ("PN8", "png8"), ("JPEG", "jpeg"), ("GIFf", "gif"), ("WBMP", "wbmp")]; + +/// `export` (Save for Web): needs the destination path (`In`), as the command writes a file. +fn export_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(u) = get_obj(d, "Usng") else { return unsupported }; + let Some(path) = d.get("In ").and_then(path_text) else { return unsupported }; + let mut p = json!({"path": path}); + if let Some(f) = get_enum(u, "Fmt ") { + match lookup(WEB_FORMATS, &f) { + Some(f) => p["format"] = json!(f), + None => return unsupported, + } + } + for (key, param) in [("Intr", "interlaced"), ("Trns", "transparency"), ("EICC", "embedIcc"), ("Prgr", "progressive"), ("Optm", "optimized")] { + if let Some(b) = get_bool(u, key) { + p[param] = json!(b); + } + } + for (key, param) in [("Qlty", "quality"), ("NCol", "colors")] { + if let Some(v) = get_num(u, key) { + p[param] = json!(v); + } + } + if get_bool(u, "Mtt ") == Some(false) { + p["matte"] = json!("none"); + } else if let (Some(r), Some(g), Some(b)) = (get_num(u, "MttR"), get_num(u, "MttG"), get_num(u, "MttB")) { + p["matte"] = + json!(format!("#{:02x}{:02x}{:02x}", r.round().clamp(0.0, 255.0) as u8, g.round().clamp(0.0, 255.0) as u8, b.round().clamp(0.0, 255.0) as u8)); + } + step("file.export.saveForWebLegacy", p) +} + +fn export_to(_: &str, p: &Value) -> Option { + let path = jstr(p, "path")?; + let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")).with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, jstr(p, "format").unwrap_or("png24"))?)); + for (key, param, default) in [ + ("Intr", "interlaced", false), + ("Trns", "transparency", true), + ("EICC", "embedIcc", false), + ("Prgr", "progressive", false), + ("Optm", "optimized", true), + ] { + u = u.with(key, D::Boolean(jbool(p, param).unwrap_or(default))); + } + if let Some(q) = jnum(p, "quality") { + u = u.with("Qlty", long(q)); + } + if let Some(c) = jnum(p, "colors") { + u = u.with("NCol", long(c)); + } + match jstr(p, "matte") { + Some("none") => u = u.with("Mtt ", D::Boolean(false)), + matte => { + let c = color_desc(matte.unwrap_or("#ffffff"))?; + u = u + .with("Mtt ", D::Boolean(true)) + .with("MttR", get_num(&c, "Rd ").map(long)?) + .with("MttG", get_num(&c, "Grn ").map(long)?) + .with("MttB", get_num(&c, "Bl ").map(long)?); + } + } + Some(desc("null").with("Usng", D::Descriptor(u)).with("In ", path_value(path))) +} + fn delete_to(id: &str, p: &Value) -> Option { Some(match id { "edit.clear" => desc("null"), "layer.delete" => desc("null").with("null", layer_ref(p)), + "layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")), _ => return None, }) } @@ -1333,9 +1776,36 @@ fn play_to(_: &str, p: &Value) -> Option { } fn fill_from(d: &Descriptor) -> Mapped { - if d.get("From").is_some() { - // The Paint Bucket tool's click, not Edit › Fill. - return Mapped::Unsupported { harmless: false }; + if let Some(from) = get_obj(d, "From") { + // The Paint Bucket tool's click: its point is in pixels, or in percent of the canvas + // (not replayable without the document size). + let in_px = |k: &str| matches!(from.get(k), Some(D::UnitFloat { unit: PX, .. })); + let (Some(x), Some(y)) = (get_num(from, "Hrzn"), get_num(from, "Vrtc")) else { return Mapped::Unsupported { harmless: false } }; + if !in_px("Hrzn") || !in_px("Vrtc") { + return Mapped::Unsupported { harmless: false }; + } + let mut p = json!({"x": x.round(), "y": y.round()}); + match get_enum(d, "Usng").as_deref() { + Some("FrgC") | None => {} + Some("Pttn") => p["contents"] = json!("pattern"), + Some(_) => return Mapped::Unsupported { harmless: false }, + } + if let Some(t) = get_num(d, "Tlrn") { + p["tolerance"] = json!(t); + } + if let Some(c) = get_bool(d, "Cntg") { + p["contiguous"] = json!(c); + } + if let Some(a) = get_bool(d, "AntA") { + p["antiAlias"] = json!(a); + } + if let Some(m) = get_bool(d, "Mrgd") { + p["sampleAllLayers"] = json!(m); + } + if let Some(o) = get_num(d, "Opct") { + p["opacity"] = json!(o); + } + return step("paint.bucket", p); } let Some(contents) = get_enum(d, "Usng").and_then(|u| lookup(FILL_CONTENTS, &u)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"contents": contents}); @@ -1360,7 +1830,20 @@ fn fill_from(d: &Descriptor) -> Mapped { step("edit.fill", p) } -fn fill_to(_: &str, p: &Value) -> Option { +fn fill_to(id: &str, p: &Value) -> Option { + if id == "paint.bucket" { + let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); + d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); + d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" })); + if let Some(o) = jnum(p, "opacity") { + d = d.with("Opct", pct(o)); + } + d = d.with("Cntg", D::Boolean(jbool(p, "contiguous").unwrap_or(true))); + if let Some(m) = jbool(p, "sampleAllLayers") { + d = d.with("Mrgd", D::Boolean(m)); + } + return Some(d); + } let contents = jstr(p, "contents").unwrap_or("color"); let mut d = desc("null").with("Usng", enm("FlCn", lookup_back(FILL_CONTENTS, contents)?)); if contents == "color" { @@ -1556,16 +2039,25 @@ fn canvas_size_to(_: &str, p: &Value) -> Option { fn image_size_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); + let mut percent: Option = None; for (k, param) in [("Wdth", "width"), ("Hght", "height")] { match d.get(k) { Some(D::UnitFloat { unit: PX, value }) => { p.insert(param.into(), value.round().into()); } - // Percent or physical units need the document's size: not replayable headlessly. + // Percent scales both sides at playback; two different percentages, or physical + // units, need the document's size and resolution and stay unsupported. + Some(D::UnitFloat { unit: PCT, value }) => match percent { + Some(other) if (other - value).abs() > 1e-9 => return Mapped::Unsupported { harmless: false }, + _ => percent = Some(*value), + }, Some(_) => return Mapped::Unsupported { harmless: false }, None => {} } } + if let Some(pc) = percent { + p.insert("percent".into(), pc.into()); + } if let Some(r) = get_num(d, "Rslt") { p.insert("resolution".into(), r.into()); } @@ -1576,7 +2068,7 @@ fn image_size_from(d: &Descriptor) -> Mapped { } None => return Mapped::Unsupported { harmless: false }, } - } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") { + } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") && !p.contains_key("percent") { p.insert("resample".into(), "none".into()); } if p.is_empty() { @@ -1587,6 +2079,9 @@ fn image_size_from(d: &Descriptor) -> Mapped { fn image_size_to(_: &str, p: &Value) -> Option { let mut d = desc("null"); + if let Some(pc) = jnum(p, "percent") { + d = d.with("Wdth", pct(pc)).with("Hght", pct(pc)).with("CnsP", D::Boolean(true)); + } if let Some(w) = jnum(p, "width") { d = d.with("Wdth", px(w)); } @@ -1866,6 +2361,17 @@ const EVENTS: &[Event] = &[ to: |_, p| adjustment_by_class("BlnW").and_then(|a| (a.to)(p)), }, adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), + Event { + code: "GdMp", + name: "gradientMapEvent", + label: "Gradient Map", + ids: &["image.adjustments.gradientMap"], + from: |d| match adjustment_by_class("GdMp") { + Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| adjustment_by_class("GdMp").and_then(|a| (a.to)(p)), + }, adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]), ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]), @@ -1961,6 +2467,7 @@ const EVENTS: &[Event] = &[ "layer.newAdjustmentLayer.blackWhite", "layer.newAdjustmentLayer.channelMixer", "layer.newAdjustmentLayer.selectiveColor", + "layer.newAdjustmentLayer.gradientMap", "file.new", "file.newFromClipboard", ], @@ -1977,6 +2484,8 @@ const EVENTS: &[Event] = &[ "select.reselect", "select.loadSelection", "select.rect", + "select.lasso", + "select.editInQuickMaskMode", "layer.setProps", "layer.renameLayer", "layer.setLabelColor", @@ -2001,7 +2510,20 @@ const EVENTS: &[Event] = &[ from: select_from, to: select_to, }, - Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear"], from: delete_from, to: delete_to }, + Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear", "layer.layerMask.delete"], from: delete_from, to: delete_to }, + Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode"], from: clear_from, to: clear_to }, + Event { + code: "Trnf", + name: "transform", + label: "Transform", + ids: &["edit.transform", "select.transformSelection"], + from: transform_from, + to: transform_to, + }, + Event { code: "AppI", name: "applyImageEvent", label: "Apply Image", ids: &["image.applyImage"], from: apply_image_from, to: apply_image_to }, + ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]), + ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]), + ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]), Event { code: "Hd ", name: "hide", label: "Hide", ids: &["layer.hideLayers"], from: |d| visibility_from(d, "layer.hideLayers"), to: visibility_to }, Event { code: "Shw ", name: "show", label: "Show", ids: &["layer.showLayers"], from: |d| visibility_from(d, "layer.showLayers"), to: visibility_to }, Event { @@ -2053,7 +2575,7 @@ const EVENTS: &[Event] = &[ ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), // Edit. - Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill"], from: fill_from, to: fill_to }, + Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), ev!("copy", "copyEvent", "Copy", "edit.copy", &[]), ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]), @@ -2086,7 +2608,11 @@ const EVENTS: &[Event] = &[ to: profile_to, }, // File and actions. - ev!("Cls ", "close", "Close", "file.close", &[]), + Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to }, + Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to }, + Event { code: "Plc ", name: "placeEvent", label: "Place", ids: &["file.placeEmbedded", "file.placeLinked"], from: place_from, to: place_to }, + Event { code: "Opn ", name: "open", label: "Open", ids: &["file.openAs"], from: open_from, to: open_to }, + Event { code: "", name: "conditional", label: "If", ids: &["actions.conditional"], from: conditional_from, to: conditional_to }, ev!("save", "save", "Save", "file.save", &[]), ev!("Rvrt", "revert", "Revert", "file.revert", &[]), Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, @@ -2098,8 +2624,10 @@ fn event_for(item: &AtnEvent) -> Option<&'static Event> { EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id) } -fn event_for_id(id: &str) -> Option<&'static Event> { - EVENTS.iter().find(|e| e.ids.contains(&id)) +/// The event `id` exports as: the first event listing it whose converter accepts the params +/// (Quick Mask on is a `set`, off a `clearEvent`). +fn event_and_descriptor(id: &str, params: &Value) -> Option<(&'static Event, Descriptor)> { + EVENTS.iter().filter(|e| e.ids.contains(&id)).find_map(|e| (e.to)(id, params).map(|d| (e, d))) } // ------------------------------------------------------------------ import @@ -2220,12 +2748,11 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { }; return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); } - let e = event_for_id(id)?; let mut params = params.clone(); - if id == "actions.play" && params.get("set").is_none() { + if (id == "actions.play" || id == "actions.conditional") && params.get("set").is_none() { params["set"] = json!(set); } - let descriptor = (e.to)(id, ¶ms)?; + let (e, descriptor) = event_and_descriptor(id, ¶ms)?; let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) } diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index ff7e040878..fc73a18412 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -261,11 +261,48 @@ fn every_mapped_command_exports_and_imports_back() { ("edit.assignProfile", json!({"profile": "srgb"})), ("file.close", json!({})), ("file.save", json!({})), + ("layer.layerMask.delete", json!({})), + ("select.lasso", json!({"points": [[1, 2], [30, 2], [15, 40]]})), + ("select.editInQuickMaskMode", json!({"on": true})), + ("select.editInQuickMaskMode", json!({"on": false})), + ("edit.transform", json!({"dx": 10, "dy": -5, "scaleX": 50, "scaleY": 75, "rotate": 12.5, "interpolation": "bicubic"})), + ("edit.transform", json!({"layer": "Shadow", "dx": 0, "dy": 0, "scaleX": 200})), + ("select.transformSelection", json!({"dx": 3, "dy": 4, "scaleX": 110, "scaleY": 110, "rotate": -30})), + ( + "image.applyImage", + json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true}), + ), + ("image.applyImage", json!({"source": {"channel": "red", "layer": "merged", "invert": true}, "blending": "multiply", "opacity": 50})), + ( + "file.export.saveForWebLegacy", + json!({"path": "/tmp/out/banner.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}), + ), + ( + "file.export.saveForWebLegacy", + json!({"path": "C:\\shots\\a.jpg", "format": "jpeg", "interlaced": false, "transparency": false, "embedIcc": true, "progressive": true, "optimized": true, "quality": 80, "matte": "none"}), + ), + ("image.adjustments.gradientMap", json!({"stops": [[0, "#000000"], [0.5, "#ff8000"], [1, "#ffffff"]], "reverse": true, "dither": false})), + ("layer.newAdjustmentLayer.gradientMap", json!({"stops": [[0, "#102030"], [1, "#f0e0d0"]], "reverse": false, "dither": true})), + ("layer.smartObjects.convertToSmartObject", json!({})), + ("layer.smartObjects.editContents", json!({})), + ("layer.smartObjects.updateModifiedContent", json!({})), + ("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})), + ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic"})), + ("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})), + ("file.placeLinked", json!({"path": "/tmp/logo.png"})), + ("file.openAs", json!({"path": "/tmp/a.psd"})), + ("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})), + ("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), ]; for (id, params) in &cases { let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + if *id == "file.close" { + // Close exports "don't save" and imports as a plain close; the Save answer is tested below. + assert_eq!(item.event.id(), "Cls "); + continue; + } let e = event_for(&item.event).unwrap_or_else(|| panic!("{id} exported as an unknown event {}", item.event.id())); match (e.from)(item.descriptor.as_ref().unwrap()) { Mapped::Steps(steps) => { @@ -273,7 +310,7 @@ fn every_mapped_command_exports_and_imports_back() { assert_eq!(steps[0].0, *id, "{id} came back as another command"); let mut back = steps[0].1.clone(); normalise(&mut back); - if *id == "actions.play" { + if *id == "actions.play" || *id == "actions.conditional" { back["set"] = json!("S"); } assert_eq!(back, *params, "{id}"); @@ -371,10 +408,15 @@ fn every_mapped_command_exports_and_imports_back() { #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { - // Percent image size, a Paint Bucket fill, a move in points, an unknown adjustment class. + // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a + // move in points, an unknown adjustment class. let items = vec![ - item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)))), - item("Fl ", "Fill", Some(desc("null").with("From", D::Descriptor(desc("Pnt "))).with("Usng", enm("FlCn", "FrgC")))), + item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)).with("Hght", pct(150.0)))), + item( + "Fl ", + "Fill", + Some(desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0)))).with("Usng", enm("FlCn", "FrgC"))), + ), item( "move", "Move", @@ -486,3 +528,221 @@ fn colours_convert_both_ways() { assert_eq!(color_desc("#12ab9"), None); assert_eq!(color_desc("#ééééé"), None); } + +#[test] +fn remaining_events_import_in_their_photoshop_shapes() { + // Close with the Save answer becomes Save then Close. + let close_yes = item("Cls ", "Close", Some(desc("null").with("Svng", enm("YsN ", "Ys ")))); + let close_no = item("close", "Close", Some(desc("null").with("Svng", enm("YsN ", "N ")))); + // A layer only moved is a translation; scaled, it is the relative Free Transform; the + // selection's frame goes to Transform Selection; points (not pixels) stay unsupported. + let moved = desc("null") + .with("null", target_ref("Lyr ")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.4)).with("Vrtc", px(12.0)))) + .with("Intr", enm("Intp", "Bcbc")); + let scaled = desc("null") + .with("null", target_ref("Lyr ")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(10.0)).with("Vrtc", px(0.0)))) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("Lnkd", D::Boolean(true)) + .with("Intr", enm("Intp", "Bcbc")); + let sel = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])) + .with("FTcs", enm("QCSt", "Qcs0")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(1014.0)).with("Vrtc", px(0.0)))); + let points = desc("null").with("null", target_ref("Lyr ")).with( + "Ofst", + D::Descriptor(desc("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Rlt", value: 3.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Rlt", value: 1.0 })), + ); + // Apply Image as recorded by the frequency-separation action. + let apply = desc("null").with( + "With", + D::Descriptor( + desc("null") + .with( + "T ", + D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("RGB ") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("High-Frequency") }, + ]), + ) + .with("Clcl", enm("Clcn", "Sbtr")) + .with("Scl ", D::Double(2.0)) + .with("Ofst", D::Double(128.0)) + .with("PrsT", D::Boolean(true)), + ), + ); + // Save for Web with and without a destination. + let web = desc("null") + .with( + "Usng", + D::Descriptor( + desc("SaveForWeb") + .with("Fmt ", enm("IRFm", "PN24")) + .with("Intr", D::Boolean(false)) + .with("Trns", D::Boolean(true)) + .with("Mtt ", D::Boolean(true)) + .with("MttR", long(255.0)) + .with("MttG", long(255.0)) + .with("MttB", long(255.0)) + .with("EICC", D::Boolean(false)), + ), + ) + .with("In ", path_value("/Users/me/Desktop/out.png")); + let web_nowhere = desc("null").with("Usng", D::Descriptor(desc("SaveForWeb").with("Fmt ", enm("IRFm", "PN24")))); + // Quick Mask on/off, a polygon selection, the mask of the target layer deleted. + let qm_on = desc("null").with("null", quick_mask_ref()); + let polygon = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with( + "T ", + D::Descriptor(desc("Plgn").with( + "Pts ", + D::List(vec![ + D::Descriptor(desc("Pnt ").with("Hrzn", px(1.0)).with("Vrtc", px(2.0))), + D::Descriptor(desc("Pnt ").with("Hrzn", px(30.0)).with("Vrtc", px(2.0))), + D::Descriptor(desc("Pnt ").with("Hrzn", px(15.0)).with("Vrtc", px(40.0))), + ]), + )), + ); + let mask_delete = desc("null").with("null", target_ref("Chnl")); + let items = vec![ + item("GsnB", "Gaussian Blur", Some(desc("null").with("Rds ", px(1.0)))), + close_yes, + close_no, + item("transform", "Transform", Some(moved)), + item("Trnf", "Transform", Some(scaled)), + item("transform", "Transform", Some(sel)), + item("transform", "Transform", Some(points)), + item("applyImageEvent", "Apply Image", Some(apply)), + item("export", "Export", Some(web)), + item("export", "Export", Some(web_nowhere)), + item("setd", "Set", Some(qm_on.clone())), + item("clearEvent", "Clear", Some(qm_on.clone())), + item("set", "Set", Some(polygon)), + item("delete", "Delete", Some(mask_delete)), + item("newPlacedLayer", "Convert to Smart Object", None), + item("placedLayerEditContents", "Edit Contents", None), + item("updatePlacedLayer", "Update Smart Objects", None), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("More"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + let steps = &imp.actions[0].steps; + let ids: Vec<&str> = steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!( + ids, + [ + "filter.blur.gaussianBlur", + "file.save", + "file.close", + "file.close", + "layer.translate", + "edit.transform", + "select.transformSelection", + PHOTOSHOP_STEP, + "image.applyImage", + "file.export.saveForWebLegacy", + PHOTOSHOP_STEP, + "select.editInQuickMaskMode", + "select.editInQuickMaskMode", + "select.lasso", + "layer.layerMask.delete", + "layer.smartObjects.convertToSmartObject", + "layer.smartObjects.editContents", + "layer.smartObjects.updateModifiedContent", + ] + ); + assert_eq!(steps[4].1, json!({"dx": -57, "dy": 12})); + assert_eq!(steps[5].1, json!({"dx": 10, "dy": 0, "scaleX": 50, "scaleY": 50, "interpolation": "bicubic"})); + assert_eq!(steps[6].1, json!({"dx": 1014, "dy": 0})); + assert_eq!( + steps[8].1, + json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true}) + ); + assert_eq!( + steps[9].1, + json!({"path": "/Users/me/Desktop/out.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "matte": "#ffffff"}) + ); + assert_eq!(steps[11].1, json!({"on": true})); + assert_eq!(steps[12].1, json!({"on": false})); + assert_eq!(steps[13].1, json!({"points": [[1, 2], [30, 2], [15, 40]]})); + assert_eq!(imp.unsupported.len(), 2); + + // The frequency-separation set's own conditional, a percent Image Size, a Paint Bucket + // click in pixels (percent stays unsupported) and a Place. + let cond = desc("null") + .with("null", enm("Cndt", "Lyrs")) + .with( + "then", + D::Reference(vec![ + ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-Layers") }, + ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("FS") }, + ]), + ) + .with("else", D::Reference(vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-No-Layers") }])); + let size = desc("null") + .with("Wdth", pct(200.0)) + .with("scaleStyles", D::Boolean(true)) + .with("CnsP", D::Boolean(true)) + .with("Intr", enm("Intp", "bicubicAutomatic")); + let bucket_px = desc("null") + .with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(0.0)).with("Vrtc", px(0.0)))) + .with("Tlrn", long(32.0)) + .with("Usng", enm("FlCn", "FrgC")) + .with("Cntg", D::Boolean(false)); + let bucket_pct = desc("null") + .with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0)))) + .with("Tlrn", long(32.0)) + .with("Usng", enm("FlCn", "FrgC")); + let place = desc("null") + .with("null", path_value("/Users/me/logo.png")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("Lnkd", D::Boolean(false)); + let items = vec![ + item("conditional", "If", Some(cond)), + item("imageSize", "Image Size", Some(size)), + item("fill", "Fill", Some(bucket_px)), + item("fill", "Fill", Some(bucket_pct)), + item("placeEvent", "Place", Some(place)), + item("Opn ", "Open", Some(desc("null").with("null", path_value("/Users/me/in.psd")))), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Rest"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let rest = import(&atn::write(&file).unwrap()).unwrap(); + let rest_steps = &rest.actions[0].steps; + let rest_ids: Vec<&str> = rest_steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(rest_ids, ["actions.conditional", "image.imageSize", "paint.bucket", PHOTOSHOP_STEP, "file.placeEmbedded", "file.openAs"]); + assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"})); + assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic"})); + assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false})); + assert_eq!(rest_steps[4].1, json!({"path": "/Users/me/logo.png", "scale": 50})); + assert_eq!(rest_steps[5].1, json!({"path": "/Users/me/in.psd"})); + + // The Photoshop path record round-trips and rejects other shapes. + assert_eq!(path_text(&path_value("/a/b c.png")).as_deref(), Some("/a/b c.png")); + assert_eq!(path_text(&D::Path(b"txtu".to_vec())), None); + assert_eq!(path_text(&D::Path(vec![])), None); + assert_eq!(path_text(&D::Text(UnicodeString::new("x"))), None); + assert_eq!(path_text(&path_value("")), None); + + // Apply Image by layer name runs on a document; a missing name is a clear error. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "High-Frequency"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + s.execute("image.applyImage", steps[8].1.clone()).unwrap(); + let e = s.execute("image.applyImage", json!({"source": {"layer": "Nope"}})).unwrap_err().to_string(); + assert!(e.contains("Nope"), "{e}"); +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 63f8f5b626..86bf55d1a1 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -328,9 +328,20 @@ fn native_pixels(doc: &Document, layer: Option) -> Result<(Vec, us } } -/// `"layer"` param of a source: a layer id, or `"merged"` (default). -fn source_layer(p: &Value) -> Option { - p.get("layer").and_then(Value::as_u64).map(LayerId) +/// `"layer"` param of a source: a layer id, a layer name (the topmost of that name, as a loaded +/// Photoshop action refers to it), or `"merged"` (default). An unknown name is an error. +fn source_layer(doc: &Document, p: &Value, cmd: &str) -> Result> { + match p.get("layer") { + Some(Value::Number(n)) => Ok(n.as_u64().map(LayerId)), + Some(Value::String(name)) if !name.is_empty() && name != "merged" => doc + .walk() + .into_iter() + .rev() + .find(|(_, _, l)| l.name == *name) + .map(|(_, _, l)| Some(l.id)) + .ok_or_else(|| bad(cmd, format!("no source layer named \"{name}\""))), + _ => Ok(None), + } } /// Planes of channel `r` from document `doc` (`layer` = None: merged). A composite gives one @@ -425,7 +436,7 @@ fn source(s: &Session, p: &Value, cmd: &str, gray: bool) -> Result> return Err(bad(cmd, format!("source document \"{}\" is {}×{}; it must match the target size", st.doc.name, st.doc.size.width, st.doc.size.height))); } let r = p.get("channel").map_or(Some(ChanRef::Composite), |v| parse_ref(v, &st.doc)).ok_or_else(|| bad(cmd, "unknown `channel`"))?; - let layer = source_layer(p); + let layer = source_layer(&st.doc, p, cmd)?; let mut planes = if gray { vec![gray_plane(&st.doc, layer, st.active_layer, r)?] } else { ref_planes(&st.doc, layer, st.active_layer, r)? }; // Guard every caller (`.remove(0)` / `src[0]`) against an empty source. if planes.is_empty() { @@ -1716,7 +1727,7 @@ pub fn specs() -> Vec { "Apply Image…", ["Image"], None, - r##"{"source":{"document":index?,"layer":id|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, + r##"{"source":{"document":index?,"layer":id|name|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, has_apply_target, apply_image ), diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 5aa2441a1f..74069c8b8c 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -218,8 +218,8 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { Ok(r.document) } -/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits and PSD -/// previews. +/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits, PSD +/// previews and the PSD merged image. #[derive(Debug, Clone, Copy)] pub(crate) struct SaveOpts { /// Photoshop's 0–12 JPEG scale. @@ -231,25 +231,30 @@ pub(crate) struct SaveOpts { pub tga_bits: Option, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, + /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB + /// File Compatibility). + pub psd_maximize_compatibility: bool, } impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true } + SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true, psd_maximize_compatibility: true } } } impl SaveOpts { /// `quality`, `tiffLayers` and `tgaBits` from command `cmd`'s params (a `tgaBits` other than - /// 24 or 32 is an error); the image previews from the session's File Handling preferences - /// (Ask When Saving has no dialog here, so it saves them, as Photoshop's Save As checkbox - /// starts on). + /// 24 or 32 is an error); the image previews and the PSD merged image from the session's File + /// Handling preferences (Ask When Saving has no dialog here, so it saves them, as Photoshop's + /// Save As checkbox and its Maximize Compatibility checkbox both start on). pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { + let file_handling = &s.prefs().file_handling; Ok(SaveOpts { quality: f64_param(p, "quality"), tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, - image_previews: s.prefs().file_handling.image_previews != crate::prefs::Ask::Never, + image_previews: file_handling.image_previews != crate::prefs::Ask::Never, + psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) } @@ -283,8 +288,13 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result) -> Result<(Vec, Vec)> { crate::allocation::checkpoint("saving document")?; let save = save.into(); - let mut opts = - photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, image_previews: save.image_previews, ..Default::default() }; + let mut opts = photocraft_io::ExportOptions { + tiff_layers: save.tiff_layers, + tga_bits: save.tga_bits, + image_previews: save.image_previews, + psd_maximize_compatibility: save.psd_maximize_compatibility, + ..Default::default() + }; // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index a4100a138d..8cab1ce635 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -192,6 +192,26 @@ fn image_previews_preference_controls_the_psd_thumbnail() { } } +/// Preferences › File Handling › Maximize PSD and PSB File Compatibility decides whether a +/// layered PSD keeps its real merged image: Always and Ask When Saving write it, Never writes +/// Photoshop's placeholder and says so in resource 1057. +#[test] +fn maximize_compatibility_preference_controls_the_psd_merged_image() { + use crate::prefs::Ask; + let dir = tmp("maximize"); + let mut s = session(300, 200, 16); + s.execute("layer.new.layer", json!({})).unwrap(); + let real_merged = |path: &str| photocraft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap().has_real_merged_data(); + for (ask, expect) in [(Ask::Always, true), (Ask::Never, false), (Ask::Ask, true)] { + s.edit_prefs(|p| p.file_handling.maximize_psd_compatibility = ask); + for ext in ["psd", "psb"] { + let out = join(&dir, &format!("{ask:?}.{ext}")); + s.execute("file.saveACopy", json!({"path": out})).unwrap(); + assert_eq!(real_merged(&out), Some(expect), "{ask:?} {ext}"); + } + } +} + #[test] fn open_as_forces_the_decoder() { let dir = tmp("openas"); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 84474d57d1..d26d87e517 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -165,8 +165,14 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width, d.doc.size.height); let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic")); - let pw = p.get("width").and_then(Value::as_f64); - let ph = p.get("height").and_then(Value::as_f64); + let mut pw = p.get("width").and_then(Value::as_f64); + let mut ph = p.get("height").and_then(Value::as_f64); + // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. + if let Some(pc) = p.get("percent") { + let pc = pc.as_f64().filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad("image.imageSize", "`percent` must be a positive number"))?; + pw = pw.or(Some(ow as f64 * pc / 100.0)); + ph = ph.or(Some(oh as f64 * pc / 100.0)); + } // Missing dimension keeps the aspect ratio. let (nw, nh) = match (pw, ph) { (Some(w), Some(h)) => (w, h), @@ -713,7 +719,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 074835fd03..3a8b7ce695 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1054,7 +1054,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", "fileHandling.saveInBackground", - "fileHandling.maximizePsdCompatibility", "performance.cacheLevels", "performance.effectCacheMb", "performance.legacyCompositing", diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 684d308de0..44926b09f3 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -397,6 +397,33 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { dst.write_region(b, &d); } +/// The relative form (`scaleX`, `scaleY` in percent, `rotate` in degrees clockwise, `dx`, `dy` +/// in pixels), as Photoshop records a Free Transform: scale and rotate about the frame's +/// centre, then move. `None` when none of them is given. +fn relative_matrix(p: &Value, rect: [f64; 4]) -> Result> { + let num = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().filter(|f| f.is_finite()).map(Some).ok_or_else(|| bad(format!("`{k}` must be a finite number"))), + } + }; + let (sx, sy, rot, dx, dy) = (num("scaleX")?, num("scaleY")?, num("rotate")?, num("dx")?, num("dy")?); + if sx.is_none() && sy.is_none() && rot.is_none() && dx.is_none() && dy.is_none() { + return Ok(None); + } + let (sx, sy) = (sx.unwrap_or(100.0) / 100.0, sy.unwrap_or(100.0) / 100.0); + if sx.abs() < 1e-6 || sy.abs() < 1e-6 { + return Err(bad("`scaleX` and `scaleY` must not be 0")); + } + let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + let (sin, cos) = rot.unwrap_or(0.0).to_radians().sin_cos(); + // (x, y) → translate(-c) → scale → rotate → translate(c + d). + let (a, b, c, d) = (sx * cos, sx * sin, -sy * sin, sy * cos); + let e = cx + dx.unwrap_or(0.0) - a * cx - c * cy; + let f = cy + dy.unwrap_or(0.0) - b * cx - d * cy; + Ok(Some(Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]))) +} + fn quad_param(p: &Value) -> Option<[[f64; 2]; 4]> { let a = p.get("quad")?.as_array()?; if a.len() != 4 { @@ -459,8 +486,10 @@ fn transform(s: &mut Session, p: &Value) -> Result { } // Affine [a b c d e f] maps (x, y) → (a·x + c·y + e, b·x + d·y + f). Homography([v[0], v[2], v[4], v[1], v[3], v[5], 0.0, 0.0, 1.0]) + } else if let Some(m) = relative_matrix(p, rect)? { + m } else { - return Err(bad("pass `quad` (where the rect's corners go) or `matrix`")); + return Err(bad("pass `quad` (where the rect's corners go), `matrix`, or `scaleX`/`scaleY`/`rotate`/`dx`/`dy`")); }; if h.inverse().is_none() { return Err(bad("the transform collapses the layer")); @@ -515,7 +544,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, + params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform, @@ -993,4 +1022,30 @@ mod tests { s.execute("layer.setProps", json!({"locks": {"position": true}})).unwrap(); assert!(s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})).is_err(), "locked layer refuses identity transform"); } + + #[test] + fn relative_params_scale_and_rotate_about_the_frame_centre() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 30, "width": 40, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + // 50% about the centre (40, 40), then 10 px right: the content ends at x 40..60, y 35..45. + let r = s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50, "dx": 10})).unwrap(); + let rect: Vec = serde_json::from_value(r["rect"].clone()).unwrap(); + assert_eq!(rect, [20.0, 30.0, 60.0, 50.0], "the reported frame is the source frame"); + let bounds = |s: &Session| { + let d = s.active().unwrap(); + let b = transform_bounds(&d.doc, d.doc.layer(d.active_layer.unwrap()).unwrap()); + (b.x0, b.y0, b.x1, b.y1) + }; + assert_eq!(bounds(&s), (40, 35, 60, 45)); + // A quarter turn about the centre (50, 40) swaps the frame's sides. + s.execute("edit.transform", json!({"rotate": 90})).unwrap(); + assert_eq!(bounds(&s), (45, 30, 55, 50)); + for bad in [json!({"scaleX": 0}), json!({"rotate": "x"}), json!({"dx": f64::NAN})] { + assert!(s.execute("edit.transform", bad.clone()).is_err(), "{bad}"); + } + } } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c387dc4e30..6d33c332a0 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -177,11 +177,23 @@ pub struct ExportOptions { /// PSD/PSB: embed the thumbnail image resource (1036), Preferences › File Handling › Image /// Previews. On by default, as Photoshop's Always Save. pub image_previews: bool, + /// PSD/PSB: write the real merged image, Preferences › File Handling › Maximize PSD and PSB + /// File Compatibility (see [`PsdExportOptions::maximize_compatibility`]). On by default, as + /// Photoshop's Always. + pub psd_maximize_compatibility: bool, } impl Default for ExportOptions { fn default() -> Self { - ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: false, xmp: XmpEmbed::All, tga_bits: None, image_previews: true } + ExportOptions { + encode: EncodeOptions::default(), + force_psb: false, + tiff_layers: false, + xmp: XmpEmbed::All, + tga_bits: None, + image_previews: true, + psd_maximize_compatibility: true, + } } } @@ -382,7 +394,7 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }); } if ext == "psd" || ext == "psb" { - let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", ..Default::default() }; + let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", maximize_compatibility: opts.psd_maximize_compatibility, ..Default::default() }; let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); // A cancelled conversion stops early with an incomplete model: never write it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 36f5941f78..38db080873 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -35,11 +35,17 @@ pub struct PsdExportOptions { pub merged_matte: bool, /// Compression of the merged image (32-bit float documents are always written raw). pub merged_compression: Compression, + /// Write the real merged image (Preferences › File Handling › Maximize PSD and PSB File + /// Compatibility). `false` writes Photoshop's white placeholder instead and records + /// `has_real_merged_data = false` in resource 1057: a smaller file and a faster save, but + /// readers that only show the merged image see white. A document without layer records always + /// gets the real image, since its pixels live nowhere else. + pub maximize_compatibility: bool, } impl Default for PsdExportOptions { fn default() -> Self { - PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle } + PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true } } } @@ -1227,6 +1233,28 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, ctl (planes, has_alpha, translucent) } +/// The merged image of a file saved without Maximize Compatibility: Photoshop's white placeholder +/// in the document's colour channels, with no alpha. Encoded like [`merged_planes`], so CMYK white +/// is no ink and Lab white is L 100 with neutral a*/b*. +fn placeholder_planes(doc: &Document, fmt: &PixelFormat) -> Vec { + let cc = fmt.mode.color_channels(); + let cmyk = cmyk_of(fmt); + let lab16 = fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16; + let space = photocraft_compose::cmyk_space(doc); + let white = photocraft_color::convert::with_cmyk_space(space.as_ref(), || photocraft_raster::from_rgba(fmt, [1.0, 1.0, 1.0, 1.0])); + let n = doc.size.area() as usize; + let mut planes = Vec::with_capacity(n * fmt.sample.bytes() * cc); + for c in 0..cc { + let v = white.get(c).copied().unwrap_or(1.0); + let x = if cmyk { 1.0 - v } else { v }; + let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x }; + let mut sample = Vec::with_capacity(4); + encode_be(x, fmt.sample, &mut sample); + planes.extend(sample.iter().copied().cycle().take(n * sample.len())); + } + planes +} + /// Converts a document to a PSD file model (see [`document_to_psd_with`]). pub fn document_to_psd(doc: &Document) -> PsdFile { document_to_psd_with(doc, &PsdExportOptions::default()).0 @@ -1470,15 +1498,24 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let space = photocraft_compose::cmyk_space(doc); photocraft_color::convert::with_cmyk_space(space.as_ref(), || ex.emit(&doc.layers, &mut tick)); - // Merged composite, rendered and encoded in bands (no full-size float composite). Matting - // against white only changes pixels with alpha < 1; if some are slightly translucent but all - // round to opaque (so no alpha channel is written), encode once more without the matte. - let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED; - let (mut planes, has_alpha, translucent) = - crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte, ctl)); - if !has_alpha && translucent && opts.merged_matte { - planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0; - } + // Without Maximize Compatibility the merged image is a white placeholder and the composite + // isn't rendered at all. A document without layer records keeps the real image: its pixels + // live nowhere else. + let real_merged = opts.maximize_compatibility || ex.records.is_empty(); + let (mut planes, has_alpha) = if real_merged { + // Merged composite, rendered and encoded in bands (no full-size float composite). Matting + // against white only changes pixels with alpha < 1; if some are slightly translucent but + // all round to opaque (so no alpha channel is written), encode once more without the matte. + let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED; + let (mut planes, has_alpha, translucent) = + crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte, ctl)); + if !has_alpha && translucent && opts.merged_matte { + planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0; + } + (planes, has_alpha) + } else { + (placeholder_planes(doc, &fmt), false) + }; let n = doc.size.area() as usize; if !has_alpha { planes.truncate(cc * n * sample.bytes()); @@ -1623,7 +1660,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if let Some(Some(data)) = slices_resource { resources.push(ImageResource::new(crate::slices_map::SLICES, data)); } - resources.push(version_info_resource(true)); + resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); let placement = match (has_layers, sample) { diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 3142bc1a8c..0aca0fe882 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -201,7 +201,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let big = doc.size.width > 30_000 || doc.size.height > 30_000; // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF // keeps straight alpha) and uncompressed (its planes are interleaved below). - let psd_opts = PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw }; + let psd_opts = + PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true }; let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/io/tests/psd_maximize_compatibility.rs b/crates/io/tests/psd_maximize_compatibility.rs new file mode 100644 index 0000000000..c8f277f6ee --- /dev/null +++ b/crates/io/tests/psd_maximize_compatibility.rs @@ -0,0 +1,115 @@ +//! Preferences › File Handling › Maximize PSD and PSB File Compatibility: turned off, a layered +//! PSD/PSB stores Photoshop's white placeholder instead of the merged image and says so in +//! resource 1057 (`has_real_merged_data = false`). Layers, alpha channels and the Quick Mask are +//! written as usual, so the file reopens the same. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, export, import}; +use photocraft_psd::PsdFile; +use photocraft_raster::Surface; + +fn compat_off() -> ExportOptions { + ExportOptions { psd_maximize_compatibility: false, ..Default::default() } +} + +fn parse(bytes: &[u8]) -> PsdFile { + PsdFile::from_bytes(bytes).expect("parse") +} + +/// A `w`×`h` document of `mode`/`depth` holding one layer of `rgba` over the whole canvas. +fn filled(mode: ColorMode, depth: SampleType, w: u32, h: u32, rgba: [f32; 4]) -> Document { + let mut d = Document::new("compat", Size::new(w, h), mode, depth); + let fmt = d.pixel_format(); + let mut px = vec![0.0; fmt.channels()]; + photocraft_raster::from_rgba_into(&fmt, rgba, &mut px); + let mut layer = Layer::raster("pixels", fmt); + layer.surface_mut().unwrap().fill_rect(Rect::new(0, 0, w as i32, h as i32), &px); + d.layers.push(layer); + d +} + +/// An 8-bit RGB document whose layer is seeded noise, alpha included: its merged image barely +/// compresses, unlike the placeholder. +fn noisy(w: u32, h: u32) -> Document { + let mut d = Document::new("noise", Size::new(w, h), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + let mut state = 0x2545_f491_u32; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + (state & 0xff) as f32 / 255.0 + }; + let vals: Vec = (0..(w * h) as usize * fmt.channels()).map(|_| next()).collect(); + let mut layer = Layer::raster("noise", fmt); + layer.surface_mut().unwrap().write_region(Rect::new(0, 0, w as i32, h as i32), &vals); + d.layers.push(layer); + d +} + +#[test] +fn off_writes_the_white_placeholder_and_flags_it() { + let d = noisy(96, 64); + for name in ["x.psd", "x.psb"] { + let no_previews = |o: ExportOptions| ExportOptions { image_previews: false, ..o }; + let on = export(&d, name, &no_previews(ExportOptions::default())).unwrap(); + let off = export(&d, name, &no_previews(compat_off())).unwrap(); + let (real, placeholder) = (parse(&on.bytes), parse(&off.bytes)); + assert_eq!(real.has_real_merged_data(), Some(true), "{name}"); + assert_eq!(placeholder.has_real_merged_data(), Some(false), "{name}"); + assert!(real.merged_has_alpha() && !placeholder.merged_has_alpha(), "{name}: the placeholder has no alpha"); + assert!(placeholder.composite_rgba8().unwrap().data.iter().all(|&b| b == 255), "{name}: opaque white"); + assert_eq!(real.layers(), placeholder.layers(), "{name}: the layers are written the same"); + assert!(off.bytes.len() * 3 < on.bytes.len() * 2, "{name}: {} bytes without the merged image, {} with it", off.bytes.len(), on.bytes.len()); + } +} + +/// The placeholder is exactly the white a real merged image would store, in every colour model +/// and depth PSD has: CMYK white is no ink, Lab white is L 100 with neutral a*/b*. +#[test] +fn the_placeholder_is_the_white_a_real_merged_image_stores() { + use ColorMode::{Cmyk, Grayscale, Lab, Rgb}; + use SampleType::{F32, U8, U16}; + for (mode, depth) in + [(Rgb, U8), (Rgb, U16), (Rgb, F32), (Grayscale, U8), (Grayscale, U16), (Grayscale, F32), (Cmyk, U8), (Cmyk, U16), (Lab, U8), (Lab, U16)] + { + let d = filled(mode, depth, 9, 5, [1.0; 4]); + let real = parse(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes); + let placeholder = parse(&export(&d, "x.psd", &compat_off()).unwrap().bytes); + assert_eq!(placeholder.has_real_merged_data(), Some(false), "{mode:?} {depth:?}"); + assert!(!real.merged_has_alpha(), "{mode:?} {depth:?}: an opaque document"); + assert_eq!(placeholder.header, real.header, "{mode:?} {depth:?}"); + assert_eq!(placeholder.decode_merged().unwrap(), real.decode_merged().unwrap(), "{mode:?} {depth:?}"); + } +} + +#[test] +fn layers_and_alpha_channels_reopen_unchanged() { + let mut d = noisy(32, 16); + let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + selection.fill_rect(Rect::new(0, 0, 10, 16), &[1.0]); + d.channels.push(AlphaChannel::new("Alpha 1", selection.clone())); + d.quick_mask = Some(AlphaChannel::new("Quick Mask", selection)); + let reopen = |opts: &ExportOptions| { + let r = import("x.psd", &export(&d, "x.psd", opts).unwrap().bytes).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + r.document + }; + let (on, off) = (reopen(&ExportOptions::default()), reopen(&compat_off())); + assert_eq!(off.layers.len(), 1); + assert_eq!(off.channels, on.channels, "alpha channels follow the placeholder"); + assert_eq!(off.quick_mask, on.quick_mask); + let canvas = d.bounds(); + assert_eq!(photocraft_compose::render(&off, canvas).px, photocraft_compose::render(&on, canvas).px, "the same image"); +} + +/// Without layer records the merged image is the only copy of the pixels, so it stays real. +#[test] +fn a_file_without_layers_keeps_its_real_merged_image() { + let d = Document::new("empty", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let file = parse(&export(&d, "x.psd", &compat_off()).unwrap().bytes); + assert!(file.layers().is_empty()); + assert_eq!(file.has_real_merged_data(), Some(true)); +} diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 997770b5f6..7902a39bba 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -248,6 +248,11 @@ fn step_label(id: &str, params: &Value) -> String { if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) + } else if id == "actions.conditional" { + // "If hasLayers: FS8-Layers / FS8-No-Layers" + let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); + let branch = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("–").to_string(); + format!("{}: {cond} → {} / {}", label_of(id), branch("then"), branch("else")) } else if id == photocraft_engine::atn_cmds::STOP_MESSAGE { format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default()) } else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2b1f972512..73dd6d184d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1487,8 +1487,16 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let st = app.session.documents().get(idx)?; // Filters whose features don't scale with a proxy preview at full resolution (#2063). let k = crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))); - let request = - crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, revision: st.revision, dialog: d.id, active: st.active_layer, command: cmd, params, k }; + let request = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: cmd, + params, + k, + }; let key = request.doc.0 ^ (1u64 << 61); // Every filter's preview computes off the UI thread in the desktop app: Gaussian Blur at // 1000 px takes long enough to freeze the window. Once the slider settles, a preview still @@ -1513,10 +1521,13 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let task = request.clone(); if let Err(error) = app.filter_preview_worker.start(request.clone(), ctx.clone(), move |cancel| { let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document_with(&doc, task.active, &task.command, &task.params, task.k, Some(cancel)).map(|doc| { - let buffer = photocraft_compose::flatten(&doc); - (std::sync::Arc::new(doc), buffer) - }); + let result = + crate::filter_dialog::preview_document_for_target(&doc, task.active, &task.command, &task.params, task.k, Some(cancel), task.target).map( + |doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }, + ); crate::filter_preview_worker::Computed { result, ms: crate::gpu_canvas::now_ms() - t0 } }) { app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: request.clone(), result: None, committing: false }); @@ -1533,6 +1544,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e || preview.key.revision != request.revision || preview.key.dialog != request.dialog || preview.key.active != request.active + || preview.key.target != request.target || preview.key.k != request.k || preview.key.command != request.command { @@ -1547,7 +1559,9 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e if app.filter_preview.as_ref().is_none_or(|p| p.key != request) { let doc = app.session.documents().get(idx)?.doc.clone(); let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document(&doc, request.active, &request.command, &request.params, request.k).map(std::sync::Arc::new); + let result = + crate::filter_dialog::preview_document_for_target(&doc, request.active, &request.command, &request.params, request.k, None, request.target) + .map(std::sync::Arc::new); if let Some(r) = &result { let buf = photocraft_compose::flatten(r); let t1 = crate::gpu_canvas::now_ms(); @@ -1565,6 +1579,18 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e Some((preview.key.k, key, [result.size.width, result.size.height])) } +/// Channel overlays must use the filter's current proxy too, including on the CPU canvas. +/// The cache key changes with the completed request; unchanged frames reuse the overlay texture. +pub(crate) fn filter_channel_preview(app: &mut PhotocraftApp, idx: usize, ctx: &egui::Context) -> Option<(std::sync::Arc, u64)> { + use std::hash::{Hash, Hasher}; + ensure_filter_preview(app, idx, ctx)?; + let preview = app.filter_preview.as_ref()?; + let doc = preview.result.as_ref()?.clone(); + let mut key = std::collections::hash_map::DefaultHasher::new(); + format!("{:?}", preview.key).hash(&mut key); + Some((doc, key.finish() | (1 << 59))) +} + fn publish_filter_preview( app: &mut PhotocraftApp, idx: usize, @@ -5431,6 +5457,7 @@ mod tests { revision: st.revision, dialog: d.id, active: st.active_layer, + target: st.channel_view.target, command: "filter.blur.gaussianBlur".into(), params: json!({"radius": 999.0}), k: 1, @@ -5478,6 +5505,7 @@ mod tests { revision: st.revision, dialog: d.id, active: st.active_layer, + target: st.channel_view.target, command: "image.mode.indexedColor".into(), params: app.with_mask_target("image.mode.indexedColor", crate::filter_dialog::params_of(&d.fields)), k: 1, @@ -7165,6 +7193,200 @@ mod rotation_preview_isolation_tests { use super::*; use serde_json::json; + #[test] + fn channel_overlay_shows_filter_preview_and_restores_on_cancel() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + app.run("channel.new", json!({"name":"Alpha","fill":"black"})).unwrap(); + app.session + .edit("Fixture", |doc, _| { + for y in 0..16 { + for x in 0..8 { + doc.channels[0].surface.write_pixel(x, y, &[1.0]); + } + } + Ok(()) + }) + .unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + ctx.tex_manager().write().take_delta().clear(); + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + ensure_filter_preview(&mut app, 0, &ctx).unwrap(); + crate::channel_view::ensure(&mut app, &ctx, 0); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("filter preview must update channel overlay")[0].image; + let st = app.session.active().unwrap(); + let original = crate::channel_view::render(&before, &st.channel_view, before.bounds(), 1, false, st.active_layer).unwrap(); + assert_ne!(image.pixels, original, "a nontrivial filter must be visible before OK"); + delta.clear(); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(false)); + crate::channel_view::ensure(&mut app, &ctx, 0); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Preview off restores channel")[0].image; + assert_eq!(image.pixels, original); + delta.clear(); + app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(true)); + crate::channel_view::ensure(&mut app, &ctx, 0); + ctx.tex_manager().write().take_delta().clear(); + crate::dialogs::cancel(&mut app, dialog).unwrap(); + crate::channel_view::ensure(&mut app, &ctx, 0); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Cancel restores channel")[0].image; + assert_eq!(image.pixels, original); + delta.clear(); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + } + + fn assert_filter_preview_target(target: photocraft_engine::channel_cmds::ChannelTarget) { + for depth in [8, 16, 32] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":16,"height":16,"depth":depth})).unwrap(); + session.execute("channel.new", json!({"name":"Alpha","fill":"black"})).unwrap(); + session + .edit("Fixture", |doc, active| { + let layer = doc.layer_mut(active.unwrap()).unwrap(); + layer.mask = Some(photocraft_doc::LayerMask::hide_all()); + for y in 0..16 { + for x in 0..8 { + layer.surface_mut().unwrap().write_pixel(x, y, &[0.0, 0.25, 0.5, 1.0]); + layer.mask.as_mut().unwrap().surface.write_pixel(x, y, &[1.0]); + } + } + for y in 0..16 { + for x in 0..8 { + doc.channels[0].surface.write_pixel(x, y, &[1.0]); + } + } + Ok(()) + }) + .unwrap(); + session.active_mut().unwrap().channel_view.target = target; + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.background_jobs = false; + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + ensure_filter_preview(&mut app, 0, &egui::Context::default()).unwrap(); + let preview = app.filter_preview.as_ref().unwrap().result.as_ref().unwrap().clone(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let final_doc = &app.session.active().unwrap().doc; + let bounds = final_doc.bounds(); + assert_eq!( + preview.layers[0].surface().unwrap().read_region(bounds), + final_doc.layers[0].surface().unwrap().read_region(bounds), + "RGB: {depth} {target:?}" + ); + assert_eq!(preview.channels[0].surface.read_region(bounds), final_doc.channels[0].surface.read_region(bounds), "alpha: {depth} {target:?}"); + assert_eq!( + preview.layers[0].mask.as_ref().unwrap().surface.read_region(bounds), + final_doc.layers[0].mask.as_ref().unwrap().surface.read_region(bounds), + "mask: {depth} {target:?}" + ); + } + } + + #[test] + fn filter_preview_matches_applied_alpha_target() { + assert_filter_preview_target(photocraft_engine::channel_cmds::ChannelTarget::Alpha(0)); + } + + #[test] + fn filter_preview_matches_applied_color_target() { + assert_filter_preview_target(photocraft_engine::channel_cmds::ChannelTarget::Color(0)); + } + + #[test] + fn filter_preview_matches_applied_layer_mask_target() { + assert_filter_preview_target(photocraft_engine::channel_cmds::ChannelTarget::LayerMask); + } + + #[test] + fn filter_preview_refreshes_when_channel_target_changes() { + use photocraft_engine::channel_cmds::ChannelTarget; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + for name in ["First", "Second"] { + app.run("channel.new", json!({"name":name,"fill":"black"})).unwrap(); + } + app.session + .edit("Fixture", |doc, _| { + for y in 0..16 { + for x in 0..8 { + doc.channels[1].surface.write_pixel(x, y, &[1.0]); + } + } + Ok(()) + }) + .unwrap(); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(0); + let revision = app.session.active().unwrap().revision; + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + let ctx = egui::Context::default(); + let (first, first_key) = filter_channel_preview(&mut app, 0, &ctx).unwrap(); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1); + let (second, second_key) = filter_channel_preview(&mut app, 0, &ctx).unwrap(); + assert_eq!(app.session.active().unwrap().revision, revision); + assert_ne!(first_key, second_key); + assert_ne!(first.channels[1].surface.read_region(first.bounds()), second.channels[1].surface.read_region(second.bounds())); + let (unchanged, unchanged_key) = filter_channel_preview(&mut app, 0, &ctx).unwrap(); + assert!(std::sync::Arc::ptr_eq(&second, &unchanged)); + assert_eq!(second_key, unchanged_key); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let applied = &app.session.active().unwrap().doc; + assert_eq!(second.channels[1].surface.read_region(second.bounds()), applied.channels[1].surface.read_region(applied.bounds())); + } + + #[test] + fn filter_preview_overlay_matches_quick_mask_and_mixed_color_views() { + for quick_mask in [false, true] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + app.session + .edit("Fixture", |doc, active| { + if quick_mask { + doc.quick_mask = + Some(photocraft_doc::AlphaChannel::new("Quick Mask", photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8))); + } + for y in 0..16 { + for x in 0..8 { + if let Some(mask) = &mut doc.quick_mask { + mask.surface.write_pixel(x, y, &[1.0]); + } else { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_pixel(x, y, &[0.0, 0.25, 0.5, 1.0]); + } + } + } + Ok(()) + }) + .unwrap(); + if !quick_mask { + let view = &mut app.session.active_mut().unwrap().channel_view; + view.target = photocraft_engine::channel_cmds::ChannelTarget::Color(0); + view.set_color_visible(2, false); + } + let ctx = egui::Context::default(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + ctx.tex_manager().write().take_delta().clear(); + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("preview must refresh overlay")[0].image; + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let st = app.session.active().unwrap(); + let expected = crate::channel_view::render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + assert_eq!(image.pixels, expected, "quick_mask={quick_mask}"); + delta.clear(); + } + } + /// A CPU-only app must be able to generate the actual rotation preview without a /// GPU adapter; checking the result also verifies Preview off and document integrity. #[test] diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index e4642d946d..d7a8b190f5 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -222,7 +222,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // Mask and channel views are drawn over the canvas: show the same live document, // otherwise an opaque grayscale mask hides the stroke until it is committed (#2078). // The plain-composite path above needs no preview work. - let (doc, preview_key) = crate::canvas::display_doc(app, idx); + let (doc, preview_key) = crate::canvas::filter_channel_preview(app, idx, ctx).unwrap_or_else(|| crate::canvas::display_doc(app, idx)); // Interface ▸ Show Channels in Color tints single colour channel views on the canvas too, // not just the panel thumbnails (#2385). The view key carries the flag, so toggling it // rebuilds the texture. diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index e9729eab67..30752c462f 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -400,6 +400,36 @@ fn grid_mesh(rect: Rect, n: usize, color: impl Fn(f32, f32) -> [f32; 3]) -> Mesh m } +/// Cells per side of the banded Only Web Colors field: fine enough that the band edges follow the +/// colour model, coarse enough to stay cheap to draw each frame. +const WEB_CELLS: usize = 64; + +/// Snap each channel to the web-safe ramp (00, 33, 66, 99, CC, FF). +fn web_safe(c: [f32; 3]) -> [f32; 3] { + c.map(|v| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0) +} + +/// Like [`grid_mesh`], but every cell is one flat colour sampled at its centre, so a quantized +/// field shows hard bands instead of blending between the snapped corners. +fn flat_grid_mesh(rect: Rect, n: usize, color: impl Fn(f32, f32) -> [f32; 3]) -> Mesh { + let mut m = Mesh::default(); + let n = n.max(1); + let (cw, ch) = (rect.width() / n as f32, rect.height() / n as f32); + for j in 0..n { + for i in 0..n { + let c = c32(color((i as f32 + 0.5) / n as f32, (j as f32 + 0.5) / n as f32)); + let (x0, y0) = (rect.left() + i as f32 * cw, rect.top() + j as f32 * ch); + let a = m.vertices.len() as u32; + for (dx, dy) in [(0.0, 0.0), (cw, 0.0), (cw, ch), (0.0, ch)] { + m.colored_vertex(pos2(x0 + dx, y0 + dy), c); + } + m.add_triangle(a, a + 1, a + 2); + m.add_triangle(a, a + 2, a + 3); + } + } + m +} + /// Room for both component columns, including units, without overflowing the title's width; with /// the frame and title the window fits Photoshop's (about 534 x 376 points). pub(crate) const CONTENT_WIDTH: f32 = 488.0; @@ -469,7 +499,12 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { let (fx, fy, fz) = locate(&mode, hsv, rgb, lab); // Colour field. let (field, resp) = ui.allocate_exact_size(vec2(FIELD, FIELD), Sense::click_and_drag()); - ui.painter().add(grid_mesh(field, 32, |x, y| field_color(&mode, fz, x, y))); + let web_only = f.get("__webOnly").and_then(Value::as_bool).unwrap_or(false); + // Only Web Colors bands the field to the 216 web-safe colours, as in Photoshop (#4107). + let mesh = |rect: Rect, color: &dyn Fn(f32, f32) -> [f32; 3]| { + if web_only { flat_grid_mesh(rect, WEB_CELLS, |x, y| web_safe(color(x, y))) } else { grid_mesh(rect, 32, color) } + }; + ui.painter().add(mesh(field, &|x, y| field_color(&mode, fz, x, y))); ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); let marker = pos2(field.left() + fx * field.width(), field.top() + fy * field.height()); ui.painter().circle_stroke(marker, 5.0, Stroke::new(1.5, if hsv[2] > 0.6 && hsv[1] < 0.4 { Color32::BLACK } else { Color32::WHITE })); @@ -486,7 +521,7 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { } // Component slider (hue runs 360° at the top to 0° at the bottom, like Photoshop). let (strip, sresp) = ui.allocate_exact_size(vec2(18.0, FIELD), Sense::click_and_drag()); - ui.painter().add(grid_mesh(strip, 32, |_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab))); + ui.painter().add(mesh(strip, &|_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab))); let sy = strip.top() + (1.0 - fz) * strip.height(); for (x, dir) in [(strip.left() - 1.0, 1.0f32), (strip.right() + 1.0, -1.0)] { let tri = vec![pos2(x, sy), pos2(x - 6.0 * dir, sy - 4.0), pos2(x - 6.0 * dir, sy + 4.0)]; @@ -767,6 +802,23 @@ mod tests { use super::*; + /// #4107: with Only Web Colors on, the field is drawn in flat web-safe bands. + #[test] + fn web_only_field_is_flat_web_safe_bands() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 200.0)); + let m = flat_grid_mesh(rect, WEB_CELLS, |x, y| web_safe(field_color("h", 0.3, x, y))); + let ramp = [0u8, 51, 102, 153, 204, 255]; + for v in &m.vertices { + assert!([v.color.r(), v.color.g(), v.color.b()].iter().all(|c| ramp.contains(c)), "{:?}", v.color); + } + // Each cell's four corners share one colour (no blending between bands). + for q in m.vertices.chunks(4) { + assert!(q.iter().all(|v| v.color == q[0].color)); + } + let distinct: std::collections::HashSet<_> = m.vertices.iter().map(|v| v.color).collect(); + assert!(distinct.len() > 1 && distinct.len() <= 216); + } + fn picker(color: &str) -> Harness<'static, Map> { let mut f = Map::new(); f.insert("__colorPicker".into(), json!("foreground")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 73f3357a8c..7d36f29756 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -593,6 +593,7 @@ mod tests { revision: st.revision, dialog: id, active: st.active_layer, + target: st.channel_view.target, command: "filter.blur.gaussianBlur".into(), params: serde_json::json!({"radius": 4.0}), k: 1, diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 29fc436e0f..8f588fbc99 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -745,6 +745,19 @@ pub fn preview_document_with( params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>, +) -> Option { + preview_document_for_target(doc, active, command, params, k, cancel, photocraft_engine::channel_cmds::ChannelTarget::Composite) +} + +/// Run the preview with the same pixel target as the final command, including individual channels. +pub(crate) fn preview_document_for_target( + doc: &Document, + active: Option, + command: &str, + params: &Value, + k: u32, + cancel: Option<&photocraft_engine::jobs::JobCtx>, + target: photocraft_engine::channel_cmds::ChannelTarget, ) -> Option { if command == crate::select_mask_view::COMMAND { return crate::select_mask_view::preview_document(doc, active, params, k, cancel); @@ -759,6 +772,7 @@ pub fn preview_document_with( if let Some(id) = active { s.select_layer(id).ok()?; } + s.active_mut()?.channel_view.target = target; let mut p = params.clone(); if run_k > 1 && let Some(o) = p.as_object_mut() @@ -802,6 +816,7 @@ pub struct FilterPreviewKey { pub revision: u64, pub dialog: u64, pub active: Option, + pub target: photocraft_engine::channel_cmds::ChannelTarget, pub command: String, pub params: Value, pub k: u32, diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index bf2cb47b58..f2b8f89561 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -54,6 +54,12 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu let t = Tokens::get(ui.ctx()); let cache_id = egui::Id::new(("filter-preview-ui", dialog)); let mut cache: Cache = ui.ctx().data_mut(|d| std::mem::take(d.get_temp_mut_or_default::(cache_id))); + // Channel visibility and the active mask can change without a document revision. Both the + // original (press-and-hold) and filtered textures must follow the canvas's current view. + let view = (st.channel_view.clone(), app.session.prefs().interface.show_channels_in_color, st.active_layer); + if cache.view.as_ref() != Some(&view) { + cache = Cache { view: Some(view), ..Default::default() }; + } // Unfiltered proxy: built once per document revision and factor (cheap at the proxy factor). let orig_key = TexKey::Original(doc_id, revision, base_k); @@ -201,18 +207,18 @@ fn document_size(proxy: [u32; 2], k: u32) -> Vec2 { /// A composite as monitor pixels, matching the canvas (`canvas::display_image`). fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) -> egui::TextureHandle { - // Never exceed what the GPU accepts (egui panics on an oversized texture): with low-resolution - // previews off the proxy is the full document. The view maps through UV fractions, so a reduced - // texture still lines up. - let k = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side)); - let reduced; - let doc = if k > 1 { - reduced = crate::proxy::proxy_document(doc, k); - &reduced - } else { - doc - }; - let buf = photocraft_compose::flatten(doc); + // Reduce only the display texture when a full-resolution result exceeds the device limit. + // The cached document and the window's document-pixel zoom coordinates stay at full size. + let factor = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side)); + let (width, height) = (doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor)); + if let Some(st) = app.session.active() + && let Some(pixels) = + crate::channel_view::render(doc, &st.channel_view, doc.bounds(), factor, app.session.prefs().interface.show_channels_in_color, st.active_layer) + { + let image = egui::ColorImage::new([width as usize, height as usize], pixels); + return ctx.load_texture(name, image, egui::TextureOptions::LINEAR); + } + let buf = if factor == 1 { photocraft_compose::flatten(doc) } else { photocraft_compose::render_reduced(doc, width, height) }; let (display, _) = crate::canvas::canvas_display(app, doc, None); let img = crate::canvas::display_image(display.as_deref(), &buf); ctx.load_texture(name, img, egui::TextureOptions::LINEAR) @@ -244,12 +250,111 @@ struct CachedTex { struct Cache { filtered: Option, original: Option, + view: Option<(photocraft_engine::channel_cmds::ChannelView, bool, Option)>, } #[cfg(test)] mod tests { use super::*; + #[test] + fn full_resolution_preview_textures_respect_the_device_limit() { + let ctx = egui::Context::default(); + let limit = ctx.input(|i| i.max_texture_side) as u32; + let width = limit + 2; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": width, "height": 2})).unwrap(); + for alpha in [false, true] { + if alpha { + app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap(); + app.session + .edit("Fixture", |doc, _| { + doc.channels[0].surface.fill_rect(photocraft_geom::Rect::new(0, 0, (width / 2) as i32, 2), &[1.0]); + Ok(()) + }) + .unwrap(); + } + let doc = &app.session.active().unwrap().doc; + let texture = upload(&ctx, &app, "full-resolution-preview", doc); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture.id()).unwrap()[0].image; + let pixels = image.pixels.clone(); + delta.clear(); + assert_eq!(texture.size(), [width.div_ceil(2) as usize, 1]); + assert!(texture.size().into_iter().all(|side| side <= limit as usize)); + assert_eq!(doc.size.width, width, "display reduction must not resize the filter result"); + assert_eq!(pixels[0], Color32::WHITE); + assert_eq!(*pixels.last().unwrap(), if alpha { Color32::BLACK } else { Color32::WHITE }); + } + } + + #[test] + fn uploaded_preview_shows_the_filtered_alpha_at_every_depth_and_proxy_scale() { + use photocraft_engine::channel_cmds::ChannelTarget; + for depth in [8, 16, 32] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap(); + app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap(); + app.session + .edit("Fixture", |doc, _| { + doc.channels[0].surface.fill_rect(photocraft_geom::Rect::new(0, 0, 8, 16), &[1.0]); + Ok(()) + }) + .unwrap(); + let st = app.session.active().unwrap(); + for k in [1, 2, 4] { + let preview = crate::filter_dialog::preview_document_for_target( + &st.doc, + st.active_layer, + "filter.blur.gaussianBlur", + &json!({"radius": 2.0}), + k, + None, + ChannelTarget::Alpha(0), + ) + .unwrap(); + let ctx = egui::Context::default(); + let texture = upload(&ctx, &app, "alpha-preview", &preview); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture.id()).unwrap()[0].image; + let pixels = image.pixels.clone(); + delta.clear(); + let expected = crate::channel_view::render(&preview, &st.channel_view, preview.bounds(), 1, false, st.active_layer).unwrap(); + assert_eq!(pixels, expected, "depth {depth}, factor {k}"); + assert_ne!(pixels[0], pixels[pixels.len() - 1], "the alpha edge is visible, not the white RGB layer"); + assert!(pixels.iter().any(|p| p.r() > 0 && p.r() < 255), "the blurred edge is visible"); + } + } + } + + #[test] + fn original_texture_refreshes_when_the_channel_view_changes_without_an_edit() { + use photocraft_engine::channel_cmds::ChannelTarget; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.run("channel.new", json!({"name": "White", "fill": "white"})).unwrap(); + app.run("channel.new", json!({"name": "Black", "fill": "black"})).unwrap(); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(0); + let revision = app.session.active().unwrap().revision; + let id = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let mut fields = app.ui.dialogs.last().unwrap().fields.clone(); + let ctx = egui::Context::default(); + let cache_id = egui::Id::new(("filter-preview-ui", id)); + ctx.run_ui(Default::default(), |ui| window(&app, id, ui, &mut fields)).textures_delta.clear(); + let before = ctx.data(|d| d.get_temp::(cache_id).unwrap().original.unwrap().tex.id()); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1); + assert_eq!(app.session.active().unwrap().revision, revision); + let mut output = ctx.run_ui(Default::default(), |ui| window(&app, id, ui, &mut fields)); + let after = ctx.data(|d| d.get_temp::(cache_id).unwrap().original.unwrap().tex.id()); + let pixels = output.textures_delta.set.get(&after).map(|delta| { + let egui::ImageData::Color(image) = &delta[0].image; + image.pixels.clone() + }); + output.textures_delta.clear(); + assert_ne!(before, after, "press-and-hold must not reuse the old channel"); + assert!(pixels.unwrap().iter().all(|p| *p == Color32::BLACK)); + } + #[test] fn zoom_steps_walk_the_levels_and_stay_in_bounds() { assert_eq!(step_zoom(1.0, true), 2.0); diff --git a/crates/ui-egui/src/filter_preview_worker.rs b/crates/ui-egui/src/filter_preview_worker.rs index 2704f8a6ac..48f29400fb 100644 --- a/crates/ui-egui/src/filter_preview_worker.rs +++ b/crates/ui-egui/src/filter_preview_worker.rs @@ -116,6 +116,7 @@ mod tests { revision, dialog, active: None, + target: Default::default(), command: "image.mode.indexedColor".into(), params: json!({"colors": colors}), k: 1, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a8216a00f7..fcc70e7cb3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -250,11 +250,23 @@ pub struct ExportSettings { /// PSD/PSB: embed a thumbnail (Preferences › File Handling › Image Previews; see /// [`PhotocraftApp::save_settings`]). pub image_previews: bool, + /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB + /// File Compatibility; see [`PhotocraftApp::save_settings`]). + pub psd_maximize_compatibility: bool, } impl Default for ExportSettings { fn default() -> Self { - ExportSettings { jpeg_quality: None, webp_lossless: true, webp_quality: None, tiff_layers: true, xmp_all: true, tga_bits: None, image_previews: true } + ExportSettings { + jpeg_quality: None, + webp_lossless: true, + webp_quality: None, + tiff_layers: true, + xmp_all: true, + tga_bits: None, + image_previews: true, + psd_maximize_compatibility: true, + } } } @@ -1315,13 +1327,16 @@ impl PhotocraftApp { } /// What a save writes by default under the preferences: File Handling › Image Previews - /// decides whether a PSD/PSB gets its thumbnail. Ask When Saving has no Save As checkbox - /// here yet, so it saves the preview, as Photoshop's checkbox starts on. A JPEG or WebP - /// is written the way the active document's file was (its JPEG quality, a WebP lossy at - /// its quality or lossless), as Photoshop's Save and Save As options start there. + /// decides whether a PSD/PSB gets its thumbnail, and Maximize PSD and PSB File Compatibility + /// whether it gets its real merged image. Ask When Saving has no dialog here yet, so it saves + /// both, as Photoshop's checkboxes start on. A JPEG or WebP is written the way the active + /// document's file was (its JPEG quality, a WebP lossy at its quality or lossless), as + /// Photoshop's Save and Save As options start there. pub(crate) fn save_settings(&self) -> ExportSettings { - let previews = self.session.prefs().file_handling.image_previews != photocraft_engine::prefs::Ask::Never; - let mut settings = ExportSettings { image_previews: previews, ..ExportSettings::default() }; + let file_handling = &self.session.prefs().file_handling; + let previews = file_handling.image_previews != photocraft_engine::prefs::Ask::Never; + let maximize = file_handling.maximize_psd_compatibility != photocraft_engine::prefs::Ask::Never; + let mut settings = ExportSettings { image_previews: previews, psd_maximize_compatibility: maximize, ..ExportSettings::default() }; if let Some(st) = self.session.active() { let e = &st.doc.file_encoding; settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100)); @@ -1695,9 +1710,6 @@ impl eframe::App for PhotocraftApp { // system title bar this is where the document name lives, as the in-app title is hidden. panels::sync_window_title(self, &ctx); let chrome = !self.ui.view.hides_chrome(); - if !chrome && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - let _ = menus::invoke(self, &ctx, "view.screenMode.standard", serde_json::json!({})); - } if chrome && self.ui.panels.menu_bar { panels::title_bar(self, ui); } diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index c0cd85eaed..c947afc9b6 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -205,6 +205,29 @@ fn image_previews_preference_reaches_the_exporter() { } } +/// Preferences › File Handling › Maximize PSD and PSB File Compatibility reaches the exporter of +/// Save As, Save and automation saves: Always and Ask When Saving keep the PSD's real merged +/// image, Never doesn't. +#[test] +fn maximize_compatibility_preference_reaches_the_exporter() { + let seen = Rc::new(RefCell::new(Vec::new())); + let mut app = app(); + let log = seen.clone(); + app.services.export = Some(Box::new(move |_, _, settings: &ExportSettings| { + log.borrow_mut().push(settings.psd_maximize_compatibility); + Ok((b"out".to_vec(), Vec::new())) + })); + let ctx = egui::Context::default(); + for (value, expect) in [("always", true), ("never", false), ("ask", true)] { + app.run("prefs.set", json!({"path": "fileHandling.maximizePsdCompatibility", "value": value})).unwrap(); + seen.borrow_mut().clear(); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": format!("{value}.psd")})).unwrap(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.save_automation(Some(format!("{value} agent.psd"))).unwrap(); + assert_eq!(*seen.borrow(), [expect; 3], "{value}"); + } +} + /// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality, /// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults. #[test] diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 7c0c8145f6..a156a6ce3b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -531,15 +531,13 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } - // Esc with no active operation deselects (#2674), like ⌘D. The key is only taken when a - // selection exists, and never from a state that handles Esc later in the frame: menus, - // dialogs and inline type returned above, transform, pen, lasso and crop consumed it, and - // these own theirs: full screen (Esc returns), the palette, the canvas and layer menus, the - // brush picker, Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any - // open egui popup and the toolbar tool flyout. A canvas drag in progress (a marquee being - // drawn) keeps the selection too. - let esc_taken = app.ui.view.hides_chrome() - || app.drag.is_some() + // Esc with no active operation leaves a full screen mode, else deselects (#2674), like ⌘D. + // The key is never taken from a state that handles Esc itself: menus, dialogs, text fields + // and inline type returned above, transform, pen, lasso and crop consumed it, and these own + // theirs later in the frame: the palette, the canvas and layer menus, the brush picker, + // Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any open egui popup + // and the toolbar tool flyout. A canvas drag in progress (a marquee being drawn) keeps it too. + let esc_taken = app.drag.is_some() || app.ui.palette_open || app.ui.canvas_tool_menu.is_some() || app.ui.layer_menu.is_some() @@ -550,6 +548,15 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { || app.distort.gallery.is_some() || ctx.any_popup_open() || ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::new("tool-flyout")).is_some()); + // Both full screen modes (with and without the menu bar) return to Standard, as in Photoshop + // (#4116), through the menu command so the window and the Screen Mode check follow. + if !esc_taken && app.ui.view.screen_mode != "standard" && pressed(Key::Escape) { + if let Err(e) = crate::menus::invoke(app, ctx, "view.screenMode.standard", json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } if !esc_taken && app.session.active().is_some_and(|st| st.doc.selection.is_some()) && pressed(Key::Escape) { if let Err(e) = app.run("select.deselect", json!({})) { app.ui.status = e; diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 264550b884..33fc6affc5 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -167,6 +167,41 @@ const LAYOUTS: [&str; 10] = [ "tile", ]; +/// Whether the window was maximized when a screen mode took it full screen (egui temp data). +fn was_maximized_id() -> egui::Id { + egui::Id::new("pc-full-screen-was-maximized") +} + +/// The window commands for switching to screen mode `next`, and whether the window should be +/// maximized again when it leaves full screen. With `keep_apart` (Windows), full screen and +/// maximized never overlap: a maximized window is un-maximized before it goes full screen and +/// maximized again after it leaves. Why: winit on Windows keeps the maximized style on a window +/// that goes full screen from maximized, and the shell then draws the taskbar black instead of +/// hiding it; on the way out the window stays maximized at the monitor's full size, over the +/// taskbar (#3550, #4018, #4230). [OBSERVED 2026-10-12, Windows 11, eframe 0.36.2, winit 0.30.13, +/// a minimal eframe window; un-maximizing first gave a clean full screen and a work-area maximize.] +/// +/// WORKAROUND(winit#4756): remove `keep_apart` once the winit in Cargo.lock (0.30.13 today) has +/// the fix; check first with https://gist.github.com/aweussom/3f80ffb82df7f0102341858afbbffd9b +/// (`--max`, no `--fix`: full screen must have no WS_MAXIMIZE and hide the taskbar). +fn screen_mode_commands(keep_apart: bool, full_screen: bool, maximized: bool, was_maximized: bool, next: &str) -> (Vec, bool) { + let want = next != "standard"; + let mut commands = Vec::new(); + let mut remember = was_maximized; + if keep_apart && want && !full_screen && maximized { + commands.push(egui::ViewportCommand::Maximized(false)); + remember = true; + } + commands.push(egui::ViewportCommand::Fullscreen(want)); + if !want { + if keep_apart && full_screen && remember { + commands.push(egui::ViewportCommand::Maximized(true)); + } + remember = false; + } + (commands, remember) +} + /// Pixel aspect ratio (width / height of a pixel) of a preset id. pub fn pixel_aspect_ratio(id: &str) -> f32 { // View › Pixel Aspect Ratio › Custom: `custom::` (see `workspace_ui`). @@ -658,7 +693,13 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res }; o.screen_mode = next.to_string(); // Both full-screen modes take the whole display; the menu bar stays in the first. - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(next != "standard")); + let (full_screen, maximized) = ctx.input(|i| (i.viewport().fullscreen.unwrap_or(false), i.viewport().maximized.unwrap_or(false))); + let was_maximized = ctx.data(|d| d.get_temp::(was_maximized_id())).unwrap_or(false); + let (commands, was_maximized) = screen_mode_commands(cfg!(target_os = "windows"), full_screen, maximized, was_maximized, next); + ctx.data_mut(|d| d.insert_temp(was_maximized_id(), was_maximized)); + for command in commands { + ctx.send_viewport_cmd(command); + } return Ok(json!({"screenMode": next})); } if let Some(k) = id.strip_prefix("view.pixelAspectRatio.") { diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 34c93ccc25..b930e6e26e 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -59,6 +59,34 @@ fn show_snap_extras_and_screen_modes_are_state() { assert_eq!(v["view"]["show"]["layer_edges"], true); } +#[test] +fn full_screen_and_maximized_never_overlap_on_windows() { + use egui::ViewportCommand::{Fullscreen, Maximized}; + // Maximized → full screen: un-maximize first, and remember it. + let (cmds, remember) = screen_mode_commands(true, false, true, false, "fullScreenWithMenuBar"); + assert_eq!(cmds, vec![Maximized(false), Fullscreen(true)]); + assert!(remember); + // Full screen → the other full-screen mode: nothing about maximized changes. + let (cmds, remember) = screen_mode_commands(true, true, false, true, "fullScreen"); + assert_eq!(cmds, vec![Fullscreen(true)]); + assert!(remember); + // Full screen → Standard: maximize again after leaving, then forget. + let (cmds, remember) = screen_mode_commands(true, true, false, true, "standard"); + assert_eq!(cmds, vec![Fullscreen(false), Maximized(true)]); + assert!(!remember); + // A window that was not maximized goes in and out untouched. + let (cmds, remember) = screen_mode_commands(true, false, false, false, "fullScreen"); + assert_eq!((cmds, remember), (vec![Fullscreen(true)], false)); + let (cmds, remember) = screen_mode_commands(true, true, false, false, "standard"); + assert_eq!((cmds, remember), (vec![Fullscreen(false)], false)); + // Standard while already standard never maximizes. + let (cmds, _) = screen_mode_commands(true, false, false, true, "standard"); + assert_eq!(cmds, vec![Fullscreen(false)]); + // Elsewhere the commands are as before. + let (cmds, remember) = screen_mode_commands(false, false, true, false, "fullScreen"); + assert_eq!((cmds, remember), (vec![Fullscreen(true)], false)); +} + #[test] fn zoom_presets_and_fit_layers() { let (mut app, ctx) = app_with(1); @@ -369,3 +397,47 @@ fn load_selection_without_alpha_channels_offers_the_composite() { assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["channel"], "RGB"); crate::dialogs::confirm(&mut app, dialog).unwrap(); } + +/// One frame of the shortcut handler with a plain Esc press. +fn press_esc(app: &mut PhotocraftApp, ctx: &egui::Context) { + let key = egui::Key::Escape; + let input = egui::RawInput { + focused: true, + events: vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { + let (mut app, ctx) = app_with(1); + // Both full screen modes return to Standard, and the menu check follows (#4116). + for mode in ["fullScreen", "fullScreenWithMenuBar"] { + menu(&mut app, &ctx, &format!("view.screenMode.{mode}"), json!({})).unwrap(); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard", "{mode}"); + assert_eq!(checked(&app, "view.screenMode.standard"), Some(true)); + } + // In Standard, Esc leaves the mode alone. + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); + // An open dialog takes Esc first. + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + menu(&mut app, &ctx, "image.canvasSize", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.len(), 1); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "fullScreen"); + app.ui.dialogs.clear(); + // So does Free Transform: Esc cancels it and the mode stays. + app.run("select.all", json!({})).unwrap(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + assert!(app.ui.transform.is_some()); + press_esc(&mut app, &ctx); + assert!(app.ui.transform.is_none()); + assert_eq!(app.ui.view.screen_mode, "fullScreen"); + // A selection stays while Esc leaves full screen; the next Esc deselects. + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); + assert!(app.session.active().unwrap().doc.selection.is_some()); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b775728aff..f23745efdb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -535,7 +535,10 @@ step with its dialog on opens that command's dialog seeded with the recorded val the play result carries `paused: {step, id, dialog}`, OK runs the dialog's values and continues from the next step, Cancel stops the action. Agents (`automation_input`) play headlessly with the recorded values. The panel menu's **Insert Stop…** adds an `actions.stopMessage` step after the -selected step (`actions.insert` takes any command id and params). +selected step (`actions.insert` takes any command id and params). `actions.conditional {if, then, +else}` is Photoshop's Insert Conditional as a step: it tests the document or active layer +(`always`, `landscape`, `modeIsRGB`, `hasLayers`, `isBackground`, `hasPixelMask`… see its params) +and plays the matching action by name, failing the caller when that action fails. Layer-panel **Quick Export as PNG** exports every selected layer as a separate PNG. A single Save dialog chooses the first filename; remaining files are numbered siblings with sanitized diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ecda48fee7..d6e3bef284 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -130,7 +130,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (90 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (108 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | diff --git a/docs/gaps.md b/docs/gaps.md index c1d99cddda..a0c554c79d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -89,11 +89,14 @@ what an agent can't finish alone. ### G11. Actions and scripting - **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the - 90 mapped events (Transform with scale or rotation, Apply Image, Save for Web, Place, smart - object edits, Generative Fill…) load as kept Photoshop steps that fail at playback by name. + mapped events (paths, scripts, Generative Fill, offsets in points…) load as kept Photoshop + steps that fail at playback by name. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` - and `actions.load`/`actions.save`; 92 Photoshop events map to commands both ways, Play and - Stop steps included; layer steps replay by layer name in any document. The panel groups actions + and `actions.load`/`actions.save`; 108 Photoshop events map to commands both ways (Transform, + Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, + Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional since the third + and fourth PRs), Play, Stop and conditional steps included; layer steps replay by layer name in any + document. The panel groups actions in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents (AUTO-219-2 done). diff --git a/docs/scorecard.md b/docs/scorecard.md index 1068c32ea6..0d91e5a89f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 27 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 27 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f217272f2d..c4cfbfb7b8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -66,7 +66,7 @@ architecture and the PSD writer are the serial parts. | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (90 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (108 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | | **Weighted** | 100% | | **~76%** | | ## Ready for real work: ~45% (weighted) @@ -83,7 +83,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | -| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 90 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 108 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -177,7 +177,7 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (90 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (108 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping |