Merge remote-tracking branch 'origin/main' into feat/tool-213-7-live-retouch

This commit is contained in:
David Mann
2026-10-13 01:15:02 +02:00
37 changed files with 1968 additions and 109 deletions
+1
View File
@@ -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 {
+27 -1
View File
@@ -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
+1
View File
@@ -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
}
+3
View File
@@ -124,3 +124,6 @@ sync_github_name = true
[people.arthurdeka]
sync_github_name = true
[people.aweussom]
real_name = "Tommy Leonhardsen"
+24
View File
@@ -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);
+1 -1
View File
@@ -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<String> {
(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(""),
+189
View File
@@ -126,6 +126,123 @@ fn assign_set(s: &mut Session, p: &Value) -> Result<Value> {
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<bool> {
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<Value> {
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<Option<String>> {
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<CommandSpec> {
vec![
CommandSpec {
@@ -148,6 +265,16 @@ pub fn specs() -> Vec<CommandSpec> {
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::<Vec<_>>(), [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::<Vec<_>>();
// 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"]);
+542 -15
View File
@@ -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<Descriptor> {
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<String> {
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<u16> = 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<u16> = 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<Descriptor> {
)
}
/// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`.
fn gradient_map_from(d: &Descriptor) -> Option<Value> {
let grad = get_obj(d, "Grad")?;
let stops: Option<Vec<Value>> = 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<Descriptor> {
let stops: Option<Vec<D>> = 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.<suffix>`, `layer.newAdjustmentLayer.<suffix>`), 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<Vec<Value>> = 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<Descriptor> {
"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<Vec<D>> = 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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
}
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<Descriptor> {
fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
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<Descriptor> {
fn image_size_from(d: &Descriptor) -> Mapped {
let mut p = Map::new();
let mut percent: Option<f64> = 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<Descriptor> {
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<AtnItem> {
};
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, &params)?;
let (e, descriptor) = event_and_descriptor(id, &params)?;
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() })
}
+264 -4
View File
@@ -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}");
}
+16 -5
View File
@@ -328,9 +328,20 @@ fn native_pixels(doc: &Document, layer: Option<LayerId>) -> Result<(Vec<f32>, us
}
}
/// `"layer"` param of a source: a layer id, or `"merged"` (default).
fn source_layer(p: &Value) -> Option<LayerId> {
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<Option<LayerId>> {
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<Vec<Vec<f32>>
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<CommandSpec> {
"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
),
+19 -9
View File
@@ -218,8 +218,8 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
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<photocraft_io::TgaBits>,
/// 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<Self> {
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<Option<photocraft_io::TgaB
pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> Result<(Vec<u8>, Vec<String>)> {
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);
+20
View File
@@ -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");
+9 -3
View File
@@ -165,8 +165,14 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
"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
),
-1
View File
@@ -1054,7 +1054,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
"workspace.autoShowHiddenPanels",
"workspace.enableFloatingDocumentWindowDocking",
"fileHandling.saveInBackground",
"fileHandling.maximizePsdCompatibility",
"performance.cacheLevels",
"performance.effectCacheMb",
"performance.legacyCompositing",
+57 -2
View File
@@ -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<Option<Homography>> {
let num = |k: &str| -> Result<Option<f64>> {
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<Value> {
}
// 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<CommandSpec> {
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<f64> = 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}");
}
}
}
+14 -2
View File
@@ -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)?;
+48 -11
View File
@@ -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<u8> {
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) {
+2 -1
View File
@@ -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)?;
@@ -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<f32> = (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));
}
+5
View File
@@ -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 {
+229 -7
View File
@@ -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, &params, 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<Document>, 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]
+1 -1
View File
@@ -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.
+54 -2
View File
@@ -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<String, Value>) {
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<String, Value>) {
}
// 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<String, Value>> {
let mut f = Map::new();
f.insert("__colorPicker".into(), json!("foreground"));
+1
View File
@@ -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,
+15
View File
@@ -745,6 +745,19 @@ pub fn preview_document_with(
params: &Value,
k: u32,
cancel: Option<&photocraft_engine::jobs::JobCtx>,
) -> Option<Document> {
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<photocraft_doc::LayerId>,
command: &str,
params: &Value,
k: u32,
cancel: Option<&photocraft_engine::jobs::JobCtx>,
target: photocraft_engine::channel_cmds::ChannelTarget,
) -> Option<Document> {
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<photocraft_doc::LayerId>,
pub target: photocraft_engine::channel_cmds::ChannelTarget,
pub command: String,
pub params: Value,
pub k: u32,
+117 -12
View File
@@ -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>(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<CachedTex>,
original: Option<CachedTex>,
view: Option<(photocraft_engine::channel_cmds::ChannelView, bool, Option<photocraft_doc::LayerId>)>,
}
#[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>(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>(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);
@@ -116,6 +116,7 @@ mod tests {
revision,
dialog,
active: None,
target: Default::default(),
command: "image.mode.indexedColor".into(),
params: json!({"colors": colors}),
k: 1,
+22 -10
View File
@@ -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);
}
+23
View File
@@ -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]
+16 -9
View File
@@ -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;
+42 -1
View File
@@ -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<egui::ViewportCommand>, 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:<ratio>:<name>` (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::<bool>(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.") {
+72
View File
@@ -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());
}
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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 | |
+7 -4
View File
@@ -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).
+3 -3
View File
@@ -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 |
|---|---|---|---|---|
+3 -3
View File
@@ -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 |