mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Merge remote-tracking branch 'origin/main' into feat/tool-213-7-live-retouch
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -124,3 +124,6 @@ sync_github_name = true
|
||||
|
||||
[people.arthurdeka]
|
||||
sync_github_name = true
|
||||
|
||||
[people.aweussom]
|
||||
real_name = "Tommy Leonhardsen"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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
@@ -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, ¶ms)?;
|
||||
let (e, descriptor) = event_and_descriptor(id, ¶ms)?;
|
||||
let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
|
||||
Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() })
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -1054,7 +1054,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
|
||||
"workspace.autoShowHiddenPanels",
|
||||
"workspace.enableFloatingDocumentWindowDocking",
|
||||
"fileHandling.saveInBackground",
|
||||
"fileHandling.maximizePsdCompatibility",
|
||||
"performance.cacheLevels",
|
||||
"performance.effectCacheMb",
|
||||
"performance.legacyCompositing",
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1487,8 +1487,16 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e
|
||||
let st = app.session.documents().get(idx)?;
|
||||
// Filters whose features don't scale with a proxy preview at full resolution (#2063).
|
||||
let k = crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app)));
|
||||
let request =
|
||||
crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, revision: st.revision, dialog: d.id, active: st.active_layer, command: cmd, params, k };
|
||||
let request = crate::filter_dialog::FilterPreviewKey {
|
||||
doc: st.doc.id,
|
||||
revision: st.revision,
|
||||
dialog: d.id,
|
||||
active: st.active_layer,
|
||||
target: st.channel_view.target,
|
||||
command: cmd,
|
||||
params,
|
||||
k,
|
||||
};
|
||||
let key = request.doc.0 ^ (1u64 << 61);
|
||||
// Every filter's preview computes off the UI thread in the desktop app: Gaussian Blur at
|
||||
// 1000 px takes long enough to freeze the window. Once the slider settles, a preview still
|
||||
@@ -1513,10 +1521,13 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e
|
||||
let task = request.clone();
|
||||
if let Err(error) = app.filter_preview_worker.start(request.clone(), ctx.clone(), move |cancel| {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let result = crate::filter_dialog::preview_document_with(&doc, task.active, &task.command, &task.params, task.k, Some(cancel)).map(|doc| {
|
||||
let buffer = photocraft_compose::flatten(&doc);
|
||||
(std::sync::Arc::new(doc), buffer)
|
||||
});
|
||||
let result =
|
||||
crate::filter_dialog::preview_document_for_target(&doc, task.active, &task.command, &task.params, task.k, Some(cancel), task.target).map(
|
||||
|doc| {
|
||||
let buffer = photocraft_compose::flatten(&doc);
|
||||
(std::sync::Arc::new(doc), buffer)
|
||||
},
|
||||
);
|
||||
crate::filter_preview_worker::Computed { result, ms: crate::gpu_canvas::now_ms() - t0 }
|
||||
}) {
|
||||
app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: request.clone(), result: None, committing: false });
|
||||
@@ -1533,6 +1544,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e
|
||||
|| preview.key.revision != request.revision
|
||||
|| preview.key.dialog != request.dialog
|
||||
|| preview.key.active != request.active
|
||||
|| preview.key.target != request.target
|
||||
|| preview.key.k != request.k
|
||||
|| preview.key.command != request.command
|
||||
{
|
||||
@@ -1547,7 +1559,9 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e
|
||||
if app.filter_preview.as_ref().is_none_or(|p| p.key != request) {
|
||||
let doc = app.session.documents().get(idx)?.doc.clone();
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let result = crate::filter_dialog::preview_document(&doc, request.active, &request.command, &request.params, request.k).map(std::sync::Arc::new);
|
||||
let result =
|
||||
crate::filter_dialog::preview_document_for_target(&doc, request.active, &request.command, &request.params, request.k, None, request.target)
|
||||
.map(std::sync::Arc::new);
|
||||
if let Some(r) = &result {
|
||||
let buf = photocraft_compose::flatten(r);
|
||||
let t1 = crate::gpu_canvas::now_ms();
|
||||
@@ -1565,6 +1579,18 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e
|
||||
Some((preview.key.k, key, [result.size.width, result.size.height]))
|
||||
}
|
||||
|
||||
/// Channel overlays must use the filter's current proxy too, including on the CPU canvas.
|
||||
/// The cache key changes with the completed request; unchanged frames reuse the overlay texture.
|
||||
pub(crate) fn filter_channel_preview(app: &mut PhotocraftApp, idx: usize, ctx: &egui::Context) -> Option<(std::sync::Arc<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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.") {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user