mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
feat(atn): load and save Blend If and Gradient Fill layers
Blend If in a recorded layer Set loads as layer.layerStyle.blendingOptions and plays (it was dropped without a word). The Blending Options dialog's Set (opacity, fill, mode, Blend If, Advanced) stays one step and saves back as one Set; with other keys those go through layer.setProps. Gradient Fill Make and Set load as layer.newFillLayer.gradient and gradient.fill.set with stops, transparency, midpoints, scale, offset, dither and align. Midpoint k is the Mdpn of stop k + 1, as the PSD reader reads it. A loaded step saves back as its recorded descriptor until it is edited; a Noise gradient, smoothness below 100 %, a non-classic interpolation or a transparency midpoint stay Photoshop steps. Fixes #10771
This commit is contained in:
1 parent
38bf95795d
commit
10876cb20d
5 files changed
+706
-21
No files matched your search
@@ -870,7 +870,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Gradient…",
|
||||
["Layer", "New Fill Layer"],
|
||||
None,
|
||||
r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##,
|
||||
r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask),"stops"?,"transparency"?,"midpoints"?,"scale"?,"offset"?,"dither"?,"align"? (as gradient.fill.set; stops replace from/to)}"##,
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?;
|
||||
@@ -879,15 +879,26 @@ fn build() -> Vec<CommandSpec> {
|
||||
let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear"));
|
||||
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
|
||||
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
|
||||
// The rest of a recorded Gradient Fill (a loaded action's Make): applied as
|
||||
// `gradient.fill.set` would, in the same step.
|
||||
let rest: serde_json::Map<String, Value> = ["stops", "transparency", "midpoints", "scale", "offset", "dither", "align"]
|
||||
.iter()
|
||||
.filter_map(|k| p.get(*k).map(|v| ((*k).to_string(), v.clone())))
|
||||
.collect();
|
||||
let (fg, bg) = (s.tools.foreground, s.tools.background);
|
||||
let id = s.edit("New Gradient Fill Layer", |doc, active| {
|
||||
let fill = Fill::gradient(
|
||||
let mut fill = Fill::gradient(
|
||||
vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))],
|
||||
angle,
|
||||
1.0,
|
||||
style,
|
||||
reverse,
|
||||
)
|
||||
.in_mode(doc.mode);
|
||||
);
|
||||
if !rest.is_empty() {
|
||||
let l = Layer::new(String::new(), LayerContent::Fill(fill.clone()));
|
||||
fill = crate::gradient_fill_cmds::apply_set(&l, &fill, doc.bounds(), &Value::Object(rest.clone()), fg, bg)?;
|
||||
}
|
||||
let fill = fill.in_mode(doc.mode);
|
||||
let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill));
|
||||
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?;
|
||||
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
|
||||
|
||||
@@ -808,6 +808,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
"layer.new.layerViaCut",
|
||||
"layer.newFillLayer.solidColor",
|
||||
"layer.newFillLayer.pattern",
|
||||
"layer.newFillLayer.gradient",
|
||||
"layer.newAdjustmentLayer.levels",
|
||||
"layer.newAdjustmentLayer.curves",
|
||||
"layer.newAdjustmentLayer.hueSaturation",
|
||||
@@ -852,6 +853,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
"layer.setLabelColor",
|
||||
"layer.setAdjustment",
|
||||
"layer.fill.pattern.set",
|
||||
"gradient.fill.set",
|
||||
"layer.new.layerFromBackground",
|
||||
"layer.layerStyle.blendingOptions",
|
||||
"layer.smartFilter.blendingOptions",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::adjustments::{HUE_RANGES, adjustment_by_class, adjustment_by_suffix, adjustment_steps, levels_complete};
|
||||
use super::descriptors::{
|
||||
PCT, PX, Target, ang, class, class_ref, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, get_text, jbool,
|
||||
jnum, jstr, layer_ref, layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed,
|
||||
PCT, PX, Target, ang, boolean, class, class_ref, code, color_desc, color_hex, desc, enm, enumerated, get_bool, get_enum, get_list, get_num, get_obj,
|
||||
get_ref, get_text, jbool, jnum, jstr, layer_ref, layer_target, long, name_ref, num, pct, px, ref_class, target, target_ref, text, trimmed,
|
||||
};
|
||||
use super::fields::{Mapped, none, step, steps};
|
||||
use super::steps_layer::quick_mask_ref;
|
||||
use super::tables::{LABEL_COLORS, MODES, blend_label, blend_ps, lookup, lookup_back};
|
||||
use super::tables::{CHANNELS, LABEL_COLORS, MODES, blend_label, blend_ps, channel_of, channel_ref, lookup, lookup_back};
|
||||
use super::{D, Descriptor, Id, Map, ReferenceItem, Value, json};
|
||||
|
||||
/// A `patternLayer` descriptor's pattern, scale, angle and link as the params of
|
||||
@@ -62,6 +62,330 @@ fn pattern_desc(p: &Value) -> Option<Descriptor> {
|
||||
Some(t)
|
||||
}
|
||||
|
||||
/// Gradient Fill layers: `GrdT` ↔ `style`.
|
||||
const GRADIENT_STYLES: &[(&str, &str)] = &[("Lnr", "linear"), ("Rdl", "radial"), ("Angl", "angle"), ("Rflt", "reflected"), ("Dmnd", "diamond")];
|
||||
|
||||
/// The `gradientLayer` keys [`gradient_params`] reads; a descriptor with any other key stays a
|
||||
/// kept Photoshop step.
|
||||
const GRADIENT_LAYER_KEYS: &[&str] = &["Grad", "Type", "Angl", "Rvrs", "Dthr", "Algn", "Scl ", "Ofst", "gradientsInterpolationMethod"];
|
||||
|
||||
/// The hidden param an imported Gradient Fill step keeps its recorded `gradientLayer` descriptor
|
||||
/// in, so Save Actions writes it back unchanged while its params are the ones it loaded with.
|
||||
const GRADIENT_DESCRIPTOR: &str = "__atnDescriptor";
|
||||
|
||||
/// A `gradientLayer` descriptor as the params of `layer.newFillLayer.gradient` /
|
||||
/// `gradient.fill.set`. Midpoint k belongs to the segment that ends at stop k + 1, as the PSD
|
||||
/// reader reads it; all-default midpoints give `[]`, which clears custom ones on a Set. None for
|
||||
/// what the fill does not play: a Noise gradient, a smoothness below 100 %, a non-classic
|
||||
/// interpolation, a transparency midpoint or stops out of order.
|
||||
fn gradient_params(t: &Descriptor) -> Option<Map<String, Value>> {
|
||||
if t.items.iter().any(|(k, _)| !GRADIENT_LAYER_KEYS.iter().any(|g| k.is(g))) {
|
||||
return None;
|
||||
}
|
||||
let mut p = Map::new();
|
||||
if let Some(s) = t.get("Type") {
|
||||
p.insert("style".into(), lookup(GRADIENT_STYLES, &enumerated(s)?)?.into());
|
||||
}
|
||||
if let Some(a) = t.get("Angl") {
|
||||
p.insert("angle".into(), num(a)?.into());
|
||||
}
|
||||
for (key, param) in [("Rvrs", "reverse"), ("Dthr", "dither"), ("Algn", "align")] {
|
||||
if let Some(v) = t.get(key) {
|
||||
p.insert(param.into(), boolean(v)?.into());
|
||||
}
|
||||
}
|
||||
if let Some(s) = t.get("Scl ") {
|
||||
p.insert("scale".into(), num(s)?.into());
|
||||
}
|
||||
if let Some(o) = t.get("Ofst") {
|
||||
let D::Descriptor(o) = o else { return None };
|
||||
p.insert("offset".into(), json!([get_num(o, "Hrzn")?, get_num(o, "Vrtc")?]));
|
||||
}
|
||||
if t.get("gradientsInterpolationMethod").is_some_and(|m| enumerated(m).as_deref() != Some("Gcls")) {
|
||||
return None;
|
||||
}
|
||||
let g = get_obj(t, "Grad")?;
|
||||
if get_enum(g, "GrdF").as_deref() != Some("CstS") || g.get("Intr").is_some_and(|i| num(i) != Some(4096.0)) {
|
||||
return None;
|
||||
}
|
||||
let (mut stops, mut mids, mut previous) = (Vec::new(), Vec::new(), 0.0);
|
||||
for (i, s) in get_list(g, "Clrs").filter(|c| (2..=1024).contains(&c.len()))?.iter().enumerate() {
|
||||
let D::Descriptor(s) = s else { return None };
|
||||
let color = match get_enum(s, "Type").as_deref() {
|
||||
None | Some("UsrS") => json!(color_hex(get_obj(s, "Clr ")?)?),
|
||||
Some("FrgC") => json!("foreground"),
|
||||
Some("BckC") => json!("background"),
|
||||
Some(_) => return None,
|
||||
};
|
||||
let at = get_num(s, "Lctn")?;
|
||||
if !(previous..=4096.0).contains(&at) {
|
||||
return None;
|
||||
}
|
||||
previous = at;
|
||||
stops.push(json!([at / 4096.0, color]));
|
||||
if i > 0 {
|
||||
mids.push(get_num(s, "Mdpn").unwrap_or(50.0) / 100.0);
|
||||
}
|
||||
}
|
||||
p.insert("stops".into(), Value::Array(stops));
|
||||
if let Some(trns) = get_list(g, "Trns") {
|
||||
let mut out = Vec::new();
|
||||
for s in trns {
|
||||
let D::Descriptor(s) = s else { return None };
|
||||
if get_num(s, "Mdpn").is_some_and(|m| m != 50.0) {
|
||||
return None;
|
||||
}
|
||||
out.push(json!([get_num(s, "Lctn")? / 4096.0, get_num(s, "Opct")?]));
|
||||
}
|
||||
p.insert("transparency".into(), Value::Array(out));
|
||||
}
|
||||
if mids.iter().all(|m| *m == 0.5) {
|
||||
mids.clear();
|
||||
}
|
||||
p.insert("midpoints".into(), json!(mids));
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// [`gradient_params`] plus the recorded descriptor, for Save Actions.
|
||||
fn gradient_step(t: &Descriptor) -> Option<Value> {
|
||||
let mut p = gradient_params(t)?;
|
||||
p.insert(GRADIENT_DESCRIPTOR.into(), photocraft_paint::tile::b64_encode(&t.to_bytes()).into());
|
||||
Some(Value::Object(p))
|
||||
}
|
||||
|
||||
/// The `gradientLayer` descriptor for a Gradient Fill step: the recorded one while the params are
|
||||
/// still what it imports as, else one written from the params. That needs every stop as
|
||||
/// `#rrggbb` on a 1/4096 location, whole-percent midpoints and, for a Set (`set`), the
|
||||
/// transparency stops: Photoshop's Set replaces the whole gradient, where the command keeps the
|
||||
/// layer's own transparency.
|
||||
fn gradient_desc(p: &Value, set: bool) -> Option<Descriptor> {
|
||||
if let Some(t) = jstr(p, GRADIENT_DESCRIPTOR).and_then(photocraft_paint::tile::b64_decode).and_then(|b| Descriptor::from_bytes(&b).ok()) {
|
||||
let (mut a, mut b) = (Value::Object(gradient_params(&t)?), p.clone());
|
||||
b.as_object_mut()?.remove(GRADIENT_DESCRIPTOR);
|
||||
super::convert::normalise(&mut a);
|
||||
super::convert::normalise(&mut b);
|
||||
if a == b {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
const KEYS: &[&str] = &["style", "angle", "reverse", "dither", "align", "scale", "offset", "stops", "transparency", "midpoints", GRADIENT_DESCRIPTOR];
|
||||
if p.as_object()?.iter().any(|(k, v)| !v.is_null() && !KEYS.contains(&k.as_str())) || (set && p.get("transparency").is_none()) {
|
||||
return None;
|
||||
}
|
||||
let location = |v: &Value| {
|
||||
let t = v.as_f64()? * 4096.0;
|
||||
((0.0..=4096.0).contains(&t) && t.fract() == 0.0).then(|| long(t))
|
||||
};
|
||||
let stops = p.get("stops")?.as_array().filter(|s| (2..=1024).contains(&s.len()))?;
|
||||
let mids: Vec<f64> = match p.get("midpoints") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(m) => m.as_array()?.iter().map(Value::as_f64).collect::<Option<_>>()?,
|
||||
};
|
||||
if mids.len() >= stops.len() {
|
||||
return None;
|
||||
}
|
||||
let (mut clrs, mut previous) = (Vec::new(), 0.0);
|
||||
for (i, s) in stops.iter().enumerate() {
|
||||
let [at, color] = s.as_array()?.as_slice() else { return None };
|
||||
if at.as_f64()? < previous {
|
||||
return None;
|
||||
}
|
||||
previous = at.as_f64()?;
|
||||
// A foreground or background stop is left out: Photoshop records the colour it had too.
|
||||
let color = color_desc(color.as_str()?)?;
|
||||
let mid = if i == 0 { 50.0 } else { mids.get(i - 1).copied().unwrap_or(0.5) * 100.0 };
|
||||
if (mid - mid.round()).abs() > 1e-9 || !(5.0..=95.0).contains(&mid.round()) {
|
||||
return None;
|
||||
}
|
||||
clrs.push(D::Descriptor(
|
||||
desc("Clrt").with("Clr ", D::Descriptor(color)).with("Type", enm("Clry", "UsrS")).with("Lctn", location(at)?).with("Mdpn", long(mid)),
|
||||
));
|
||||
}
|
||||
let default_trns = vec![json!([0, 100]), json!([1, 100])];
|
||||
let trns: Option<Vec<D>> = p
|
||||
.get("transparency")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap_or(&default_trns)
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let [at, o] = s.as_array()?.as_slice() else { return None };
|
||||
Some(D::Descriptor(desc("TrnS").with("Opct", pct(o.as_f64()?)).with("Lctn", location(at)?).with("Mdpn", long(50.0))))
|
||||
})
|
||||
.collect();
|
||||
let grad = desc("Grdn")
|
||||
.with("Nm ", text("Custom"))
|
||||
.with("GrdF", enm("GrdF", "CstS"))
|
||||
.with("Intr", D::Double(4096.0))
|
||||
.with("Clrs", D::List(clrs))
|
||||
.with("Trns", D::List(trns?));
|
||||
let mut t = desc("gradientLayer");
|
||||
if let Some(a) = jnum(p, "angle") {
|
||||
t = t.with("Angl", ang(a));
|
||||
}
|
||||
if let Some(s) = jstr(p, "style") {
|
||||
t = t.with("Type", enm("GrdT", lookup_back(GRADIENT_STYLES, s)?));
|
||||
}
|
||||
for (param, key) in [("reverse", "Rvrs"), ("dither", "Dthr"), ("align", "Algn")] {
|
||||
if let Some(v) = p.get(param).filter(|v| !v.is_null()) {
|
||||
t = t.with(key, D::Boolean(v.as_bool()?));
|
||||
}
|
||||
}
|
||||
if let Some(s) = jnum(p, "scale") {
|
||||
t = t.with("Scl ", pct(s));
|
||||
}
|
||||
if let Some(o) = p.get("offset").filter(|v| !v.is_null()) {
|
||||
let [x, y] = o.as_array()?.as_slice() else { return None };
|
||||
t = t.with("Ofst", D::Descriptor(desc("Pnt ").with("Hrzn", pct(x.as_f64()?)).with("Vrtc", pct(y.as_f64()?))));
|
||||
}
|
||||
Some(t.with("Grad", D::Descriptor(grad)))
|
||||
}
|
||||
|
||||
/// `layer.newFillLayer.gradient` as a Make of a `contentLayer`. The two-colour form (`from`,
|
||||
/// `to`) becomes the stops it plays as; without them it plays the toolbox colours, which
|
||||
/// Photoshop records with the colour they had.
|
||||
fn gradient_make_to(p: &Value) -> Option<Descriptor> {
|
||||
let mut p = p.clone();
|
||||
let o = p.as_object_mut()?;
|
||||
if o.remove("path").is_some_and(|v| !v.is_null()) {
|
||||
return None;
|
||||
}
|
||||
match (o.remove("from").filter(|v| !v.is_null()), o.remove("to").filter(|v| !v.is_null())) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) if !o.contains_key("stops") => {
|
||||
o.insert("stops".into(), json!([[0, a], [1, b]]));
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
let t = gradient_desc(&p, false)?;
|
||||
Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))))
|
||||
}
|
||||
|
||||
/// `layer.layerStyle.blendingOptions` as a Set of the target layer.
|
||||
fn blending_options_to(p: &Value) -> Option<Descriptor> {
|
||||
let o = p.as_object()?;
|
||||
let blend_if = p.get("blendIf");
|
||||
let mut t = desc("Lyr ");
|
||||
if blend_if.is_some() {
|
||||
// With Blend If, the dialog's opacities and mode are in the same `set`; without it,
|
||||
// Photoshop's `set` of them loads as `layer.setProps`.
|
||||
for (param, key) in [("opacity", "Opct"), ("fillOpacity", "fillOpacity")] {
|
||||
if let Some(v) = jnum(p, param) {
|
||||
t = t.with(key, pct(v));
|
||||
}
|
||||
}
|
||||
if let Some(m) = jstr(p, "blend") {
|
||||
t = t.with("Md ", enm("BlnM", blend_ps(m)?));
|
||||
}
|
||||
}
|
||||
for (key, param) in ADVANCED_BLENDING {
|
||||
if let Some(b) = jbool(p, param) {
|
||||
t = t.with(key, D::Boolean(b));
|
||||
}
|
||||
}
|
||||
if let Some(b) = blend_if {
|
||||
t = t.with("Blnd", blend_if_to(b)?);
|
||||
}
|
||||
// Only these export: Photoshop records the dialog's other options differently.
|
||||
if t.items.is_empty() || t.items.len() != o.len() {
|
||||
return None;
|
||||
}
|
||||
Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t)))
|
||||
}
|
||||
|
||||
/// Blend If: a `Blnd` list's `Chnl` ↔ `blendIf` channel (`Gry` is the composite).
|
||||
fn blend_if_channel(r: &[ReferenceItem]) -> Option<&'static str> {
|
||||
match target(r) {
|
||||
Target::Ordinal(o) if o == "Cmps" => Some("gray"),
|
||||
Target::Ordinal(o) => channel_of(&o),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The slider keys of one `Blnd` entry: This Layer, then Underlying Layer, each black low and
|
||||
/// high, white low and high.
|
||||
const BLEND_IF_KEYS: [[&str; 4]; 2] = [["SrcB", "Srcl", "SrcW", "Srcm"], ["DstB", "Dstl", "DstW", "Dstt"]];
|
||||
|
||||
/// A `Blnd` list as `layer.layerStyle.blendingOptions`' `blendIf`. Each entry's sliders are
|
||||
/// whole levels 0..255 (also in a 16-bit document); Underlying Layer may be missing. None for
|
||||
/// anything else, so the step stays a kept Photoshop step.
|
||||
fn blend_if_from(list: &[D]) -> Option<Value> {
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for e in list {
|
||||
let D::Descriptor(e) = e else { return None };
|
||||
if e.items.iter().any(|(k, _)| !k.is("Chnl") && !BLEND_IF_KEYS.iter().flatten().any(|b| k.is(b))) {
|
||||
return None;
|
||||
}
|
||||
let channel = blend_if_channel(get_ref(e, "Chnl")?)?;
|
||||
let range = |keys: &[&str; 4]| -> Option<Option<Value>> {
|
||||
let v: Vec<Option<i32>> = keys
|
||||
.iter()
|
||||
.map(|k| match e.get(k) {
|
||||
Some(D::Integer(i)) if (0..=255).contains(i) => Some(*i),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
match v.iter().flatten().count() {
|
||||
0 if keys.iter().all(|k| e.get(k).is_none()) => Some(None),
|
||||
4 => Some(Some(json!(v.into_iter().flatten().collect::<Vec<_>>()))),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let mut m = json!({"channel": channel, "thisLayer": range(&BLEND_IF_KEYS[0])??});
|
||||
if let Some(u) = range(&BLEND_IF_KEYS[1])? {
|
||||
m["underlying"] = u;
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
(!out.is_empty()).then_some(Value::Array(out))
|
||||
}
|
||||
|
||||
/// `blendIf` back as a `Blnd` list: named channels and whole levels only (an index or `null`
|
||||
/// reset has no Photoshop form).
|
||||
fn blend_if_to(v: &Value) -> Option<D> {
|
||||
let entries = match v {
|
||||
Value::Array(a) => a.iter().collect::<Vec<_>>(),
|
||||
Value::Object(_) => vec![v],
|
||||
_ => return None,
|
||||
};
|
||||
let mut list = Vec::with_capacity(entries.len());
|
||||
for e in entries {
|
||||
let o = e.as_object()?;
|
||||
if o.keys().any(|k| !matches!(k.as_str(), "channel" | "thisLayer" | "underlying")) {
|
||||
return None;
|
||||
}
|
||||
let channel = match e.get("channel") {
|
||||
None | Some(Value::Null) => "gray",
|
||||
Some(Value::String(c)) if c == "grey" => "gray",
|
||||
Some(Value::String(c)) => c.as_str(),
|
||||
_ => return None,
|
||||
};
|
||||
lookup_back(CHANNELS, channel)?;
|
||||
let mut d = desc("Blnd").with("Chnl", channel_ref(Some(channel)));
|
||||
for (param, keys) in [("thisLayer", &BLEND_IF_KEYS[0]), ("underlying", &BLEND_IF_KEYS[1])] {
|
||||
let Some(r) = e.get(param) else {
|
||||
if param == "thisLayer" {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let r: Vec<f64> = r.as_array()?.iter().map(Value::as_f64).collect::<Option<_>>()?;
|
||||
let q = match r.as_slice() {
|
||||
[b, w] => [*b, *b, *w, *w],
|
||||
[a, b, c, w] => [*a, *b, *c, *w],
|
||||
_ => return None,
|
||||
};
|
||||
if !q.iter().all(|x| (0.0..=255.0).contains(x) && x.fract() == 0.0) || !q.windows(2).all(|w| w[0] <= w[1]) {
|
||||
return None;
|
||||
}
|
||||
for (k, x) in keys.iter().zip(q) {
|
||||
d = d.with(k, long(x));
|
||||
}
|
||||
}
|
||||
list.push(D::Descriptor(d));
|
||||
}
|
||||
Some(D::List(list))
|
||||
}
|
||||
|
||||
/// Layer › Layer Mask: the `UsrM` value of an Add Layer Mask `Mk` ↔ its command.
|
||||
const MASK_USING: &[(&str, &str)] = &[
|
||||
("RvlA", "layer.layerMask.revealAll"),
|
||||
@@ -259,6 +583,10 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped {
|
||||
Some(p) => step("layer.newFillLayer.pattern", Value::Object(p)),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t) {
|
||||
Some(p) => step("layer.newFillLayer.gradient", p),
|
||||
None => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
_ => Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
Some("layerSection") => {
|
||||
@@ -382,6 +710,7 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let t = pattern_desc(p)?;
|
||||
desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))
|
||||
}
|
||||
"layer.newFillLayer.gradient" => gradient_make_to(p)?,
|
||||
"layer.duplicate" => {
|
||||
// Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`).
|
||||
// Anything else falls through to the `Dplc` event below.
|
||||
@@ -573,6 +902,10 @@ const LOCKS: &[(&str, &str)] = &[
|
||||
("protectAll", "all"),
|
||||
];
|
||||
|
||||
/// The Blending Options keys besides the Advanced checkboxes that `layer.layerStyle.blendingOptions`
|
||||
/// takes from a `set` of the layer: opacity, fill opacity, mode and Blend If.
|
||||
const BLENDING_DIALOG: &[&str] = &["Opct", "fillOpacity", "Md ", "Blnd"];
|
||||
|
||||
/// Blending Options' Advanced checkboxes ↔ `layer.layerStyle.blendingOptions` params.
|
||||
const ADVANCED_BLENDING: &[(&str, &str)] = &[("blendClipped", "blendClippedLayersAsGroup"), ("transparencyShapesLayer", "transparencyShapesLayer")];
|
||||
|
||||
@@ -654,6 +987,24 @@ fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped {
|
||||
advanced.insert((*param).into(), b.into());
|
||||
}
|
||||
}
|
||||
if let Some(list) = get_list(t, "Blnd") {
|
||||
let Some(b) = blend_if_from(list) else { return unsupported };
|
||||
advanced.insert("blendIf".into(), b);
|
||||
// The Blending Options dialog's OK: with nothing but its own options, the opacities and
|
||||
// the mode go with Blend If into one step, saved back as the one `set`.
|
||||
if t.items.iter().all(|(k, _)| BLENDING_DIALOG.iter().any(|b| k.is(b)) || ADVANCED_BLENDING.iter().any(|(a, _)| k.is(a))) {
|
||||
for (key, param) in [("Opct", "opacity"), ("fillOpacity", "fillOpacity")] {
|
||||
if let Some(v) = get_num(t, key) {
|
||||
advanced.insert(param.into(), v.into());
|
||||
}
|
||||
}
|
||||
p.remove("opacity");
|
||||
p.remove("fill");
|
||||
if let Some(b) = p.remove("blend") {
|
||||
advanced.insert("blend".into(), b);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !advanced.is_empty() {
|
||||
if !targeted {
|
||||
return unsupported;
|
||||
@@ -723,6 +1074,10 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped {
|
||||
Some(p) if !p.is_empty() => step("layer.fill.pattern.set", Value::Object(p)),
|
||||
_ => unsupported,
|
||||
},
|
||||
Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t) {
|
||||
Some(p) => step("gradient.fill.set", p),
|
||||
None => unsupported,
|
||||
},
|
||||
_ => unsupported,
|
||||
},
|
||||
(Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => {
|
||||
@@ -869,21 +1224,14 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
}
|
||||
desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?))
|
||||
}
|
||||
"layer.layerStyle.blendingOptions" => {
|
||||
let o = p.as_object()?;
|
||||
let mut t = desc("Lyr ");
|
||||
for (key, param) in ADVANCED_BLENDING {
|
||||
if let Some(b) = jbool(p, param) {
|
||||
t = t.with(key, D::Boolean(b));
|
||||
}
|
||||
}
|
||||
// Only the Advanced checkboxes export: Photoshop records the dialog's other options
|
||||
// differently.
|
||||
if t.items.is_empty() || t.items.len() != o.len() {
|
||||
"gradient.fill.set" => {
|
||||
// A named layer or dragged handles have no place in the event.
|
||||
if ["layer", "from", "to"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return None;
|
||||
}
|
||||
desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t))
|
||||
desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(gradient_desc(p, true)?))
|
||||
}
|
||||
"layer.layerStyle.blendingOptions" => blending_options_to(p)?,
|
||||
"layer.smartFilter.blendingOptions" => {
|
||||
if p.get("layer").is_some() {
|
||||
return None;
|
||||
|
||||
@@ -845,6 +845,11 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})),
|
||||
("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.5, "blend": "Darken"})),
|
||||
("layer.layerStyle.blendingOptions", json!({"blendClippedLayersAsGroup": false, "transparencyShapesLayer": false})),
|
||||
("layer.layerStyle.blendingOptions", json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [0, 0, 255, 255]}]})),
|
||||
(
|
||||
"layer.layerStyle.blendingOptions",
|
||||
json!({"opacity": 40, "fillOpacity": 70, "blend": "Multiply", "blendIf": [{"channel": "blue", "thisLayer": [5, 5, 250, 250]}]}),
|
||||
),
|
||||
("layer.createClippingMask", json!({})),
|
||||
("layer.lockLayers", json!({"transparency": true})),
|
||||
("filter.stylize.diffuse", json!({"mode": "normal", "seed": 321654})),
|
||||
@@ -980,6 +985,9 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
"filter.sharpen.sharpenMore",
|
||||
// Saves back only as the descriptor it was loaded from (loaded_camera_raw_filter_steps_save_back_unchanged).
|
||||
"filter.cameraRaw",
|
||||
// Load with the recorded descriptor riding along (gradient_fill_steps_export_and_import_back).
|
||||
"gradient.fill.set",
|
||||
"layer.newFillLayer.gradient",
|
||||
];
|
||||
let unexpected: Vec<&str> = missing.iter().copied().filter(|id| !allowed.contains(id)).collect();
|
||||
assert!(unexpected.is_empty(), "add round-trip cases for {unexpected:?}");
|
||||
@@ -6063,3 +6071,309 @@ fn shear_nonfinite_coordinates_stay_kept() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #10771: a Gradient Fill as Photoshop records it: three stops, a transparency stop at 40 % and
|
||||
/// one midpoint per stop (`Mdpn` of stop k is the segment that ends at it).
|
||||
fn recorded_gradient(mids: [i32; 3]) -> Descriptor {
|
||||
let stop = |hex: &str, at: i32, mid: i32| {
|
||||
D::Descriptor(
|
||||
desc("Clrt")
|
||||
.with("Clr ", D::Descriptor(color_desc(hex).unwrap()))
|
||||
.with("Type", enm("Clry", "UsrS"))
|
||||
.with("Lctn", D::Integer(at))
|
||||
.with("Mdpn", D::Integer(mid)),
|
||||
)
|
||||
};
|
||||
let trn = |at: i32, o: f64| D::Descriptor(desc("TrnS").with("Opct", pct(o)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50)));
|
||||
let grad = desc("Grdn")
|
||||
.with("Nm ", text("Custom"))
|
||||
.with("GrdF", enm("GrdF", "CstS"))
|
||||
.with("Intr", D::Double(4096.0))
|
||||
.with("Clrs", D::List(vec![stop("#ff0000", 0, mids[0]), stop("#00ff00", 2048, mids[1]), stop("#0000ff", 4096, mids[2])]))
|
||||
.with("Trns", D::List(vec![trn(0, 100.0), trn(4096, 40.0)]));
|
||||
desc("gradientLayer").with("Angl", ang(30.0)).with("Type", enm("GrdT", "Rdl ")).with("Grad", D::Descriptor(grad))
|
||||
}
|
||||
|
||||
fn gradient_set(t: Descriptor) -> AtnItem {
|
||||
item("setd", "Set", Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(t))))
|
||||
}
|
||||
|
||||
fn gradient_make(t: Descriptor) -> AtnItem {
|
||||
let d = desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))));
|
||||
item("Mk ", "Make", Some(d))
|
||||
}
|
||||
|
||||
/// The first saved step of `action` (strict save).
|
||||
fn saved_first(s: &mut Session) -> (AtnEvent, Option<Descriptor>) {
|
||||
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let i = &saved.actions[0].items[0];
|
||||
(i.event.clone(), i.descriptor.clone())
|
||||
}
|
||||
|
||||
fn without_hidden(p: &Value) -> Value {
|
||||
let mut p = p.clone();
|
||||
p.as_object_mut().unwrap().remove("__atnDescriptor");
|
||||
normalise(&mut p);
|
||||
p
|
||||
}
|
||||
|
||||
fn active_fill(s: &Session) -> photocraft_doc::Fill {
|
||||
let st = s.active().unwrap();
|
||||
match &st.doc.layer(st.active_layer.unwrap()).unwrap().content {
|
||||
photocraft_doc::LayerContent::Fill(f) => f.clone(),
|
||||
c => panic!("not a fill layer: {}", c.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// #10771: a recorded Set of a Gradient Fill loads as `gradient.fill.set` with its midpoints on
|
||||
/// the segment they belong to, plays them, and saves back as the recorded descriptor.
|
||||
#[test]
|
||||
fn gradient_fill_set_imports_midpoints_plays_and_saves_back_unchanged() {
|
||||
let recorded = recorded_gradient([50, 25, 70]);
|
||||
let mut s = Session::new();
|
||||
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded.clone())])).unwrap())})).unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
|
||||
let (id, p) = s.actions.list[0].steps[0].clone();
|
||||
assert_eq!(id, "gradient.fill.set");
|
||||
let mut expected = json!({
|
||||
"style": "radial", "angle": 30,
|
||||
"stops": [[0, "#ff0000"], [0.5, "#00ff00"], [1, "#0000ff"]],
|
||||
"transparency": [[0, 100], [1, 40]],
|
||||
"midpoints": [0.25, 0.7]
|
||||
});
|
||||
normalise(&mut expected);
|
||||
assert_eq!(without_hidden(&p), expected);
|
||||
assert_eq!(
|
||||
saved_first(&mut s),
|
||||
(AtnEvent::Code(*b"setd"), Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(recorded))))
|
||||
);
|
||||
// It plays on a Gradient Fill layer.
|
||||
s.execute("file.new", json!({"width": 16, "height": 12})).unwrap();
|
||||
s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff"})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") };
|
||||
assert_eq!(stops.len(), 3);
|
||||
assert_eq!(midpoints, vec![0.25, 0.7]);
|
||||
assert_eq!(opacity_stops.len(), 2);
|
||||
// Default midpoints load as `[]`, which clears the custom ones when played.
|
||||
let mut s2 = Session::new();
|
||||
s2.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 50, 50]))])).unwrap())})).unwrap();
|
||||
assert_eq!(s2.actions.list[0].steps[0].1["midpoints"], json!([]));
|
||||
s.actions = s2.actions;
|
||||
s.execute("actions.play", json!({"action": "A"})).unwrap();
|
||||
let photocraft_doc::Fill::Gradient { midpoints, .. } = active_fill(&s) else { panic!("not a gradient") };
|
||||
assert!(midpoints.is_empty() || midpoints.iter().all(|m| *m == 0.5), "{midpoints:?}");
|
||||
}
|
||||
|
||||
/// #10771: an edited loaded step writes a fresh descriptor from its params, midpoints included.
|
||||
#[test]
|
||||
fn edited_gradient_fill_set_writes_a_fresh_descriptor() {
|
||||
let mut s = Session::new();
|
||||
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 25, 70]))])).unwrap())})).unwrap();
|
||||
s.actions.list[0].steps[0].1["midpoints"] = json!([0.4, 0.6]);
|
||||
let (_, d) = saved_first(&mut s);
|
||||
let t = get_obj(d.as_ref().unwrap(), "T ").unwrap();
|
||||
let clrs = get_list(get_obj(t, "Grad").unwrap(), "Clrs").unwrap();
|
||||
let mids: Vec<f64> = clrs.iter().map(|c| if let D::Descriptor(c) = c { get_num(c, "Mdpn").unwrap() } else { panic!() }).collect();
|
||||
assert_eq!(mids, vec![50.0, 40.0, 60.0]);
|
||||
// Its own import is the edited params.
|
||||
let Mapped::Steps(steps) = (event_for(&AtnEvent::Code(*b"setd")).unwrap().from)(d.as_ref().unwrap()) else { panic!("did not import") };
|
||||
let mut edited = without_hidden(&s.actions.list[0].steps[0].1);
|
||||
edited["midpoints"] = json!([0.4, 0.6]);
|
||||
normalise(&mut edited);
|
||||
assert_eq!(without_hidden(&steps[0].1), edited);
|
||||
}
|
||||
|
||||
/// #10771: a recorded Make of a Gradient Fill layer plays its stops, transparency and midpoints
|
||||
/// and saves back unchanged.
|
||||
#[test]
|
||||
fn gradient_fill_make_imports_plays_and_saves_back_unchanged() {
|
||||
let recorded = recorded_gradient([50, 25, 70]);
|
||||
let mut s = Session::new();
|
||||
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_make(recorded.clone())])).unwrap())})).unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
|
||||
assert_eq!(s.actions.list[0].steps[0].0, "layer.newFillLayer.gradient");
|
||||
let (event, d) = saved_first(&mut s);
|
||||
assert_eq!(event, AtnEvent::Code(*b"Mk "));
|
||||
assert_eq!(get_obj(get_obj(d.as_ref().unwrap(), "Usng").unwrap(), "Type"), Some(&recorded));
|
||||
for depth in [8, 16] {
|
||||
s.execute("file.new", json!({"width": 16, "height": 12, "depth": depth})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") };
|
||||
assert_eq!((stops.len(), midpoints, opacity_stops.len()), (3, vec![0.25, 0.7], 2));
|
||||
// One undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!matches!(s.active().unwrap().doc.layers.last().unwrap().content, photocraft_doc::LayerContent::Fill(_)));
|
||||
}
|
||||
}
|
||||
|
||||
/// #10771: PhotoCraft's own Gradient Fill steps export and load back as the same fill.
|
||||
#[test]
|
||||
fn gradient_fill_steps_export_and_import_back() {
|
||||
let set = json!({"style": "linear", "angle": 45, "stops": [[0, "#ff0000"], [0.25, "#00ff00"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 50]], "midpoints": [0.3, 0.6]});
|
||||
let item = export_item("gradient.fill.set", &set, "S").unwrap();
|
||||
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") };
|
||||
let mut expected = set.clone();
|
||||
normalise(&mut expected);
|
||||
assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("gradient.fill.set", expected));
|
||||
// The two-colour Make plays as the same two stops at full opacity.
|
||||
let item = export_item("layer.newFillLayer.gradient", &json!({"from": "#ff0000", "to": "#0000ff", "angle": 45}), "S").unwrap();
|
||||
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") };
|
||||
let mut expected = json!({"angle": 45, "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": []});
|
||||
normalise(&mut expected);
|
||||
assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("layer.newFillLayer.gradient", expected));
|
||||
// What the event cannot hold is refused: a Set without its transparency, toolbox colours,
|
||||
// dragged handles, a path, a midpoint off a whole percent.
|
||||
for (id, p) in [
|
||||
("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]]})),
|
||||
("gradient.fill.set", json!({"stops": [[0, "foreground"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})),
|
||||
("gradient.fill.set", json!({"from": [0, 0], "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})),
|
||||
("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": [0.333]})),
|
||||
("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "path": "work"})),
|
||||
("layer.newFillLayer.gradient", json!({"from": "#ff0000"})),
|
||||
] {
|
||||
assert!(export_item(id, &p, "S").is_none(), "{id}: {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #10771: a Noise gradient, smoothness below 100 % or a transparency midpoint stay Photoshop
|
||||
/// steps rather than playing as a different fill.
|
||||
#[test]
|
||||
fn gradient_fills_the_command_cannot_play_stay_photoshop_steps() {
|
||||
// `with` appends; these replace the recorded value.
|
||||
fn replaced(mut d: Descriptor, key: &str, v: D) -> Descriptor {
|
||||
d.items.retain(|(k, _)| !k.is(key));
|
||||
d.with(key, v)
|
||||
}
|
||||
let base = recorded_gradient([50, 50, 50]);
|
||||
let with_grad = |f: &dyn Fn(Descriptor) -> Descriptor| {
|
||||
let g = get_obj(&base, "Grad").unwrap().clone();
|
||||
replaced(base.clone(), "Grad", D::Descriptor(f(g)))
|
||||
};
|
||||
let trns_mid = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(0)).with("Mdpn", D::Integer(30));
|
||||
let trns_end = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(4096)).with("Mdpn", D::Integer(50));
|
||||
for t in [
|
||||
with_grad(&|g| replaced(g, "GrdF", enm("GrdF", "ClNs"))),
|
||||
with_grad(&|g| replaced(g, "Intr", D::Double(2048.0))),
|
||||
with_grad(&|g| replaced(g, "Trns", D::List(vec![D::Descriptor(trns_mid.clone()), D::Descriptor(trns_end.clone())]))),
|
||||
base.clone().with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Perc")),
|
||||
] {
|
||||
let imp = import(&atn::write(&one_action(vec![gradient_set(t)])).unwrap()).unwrap();
|
||||
assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP);
|
||||
}
|
||||
}
|
||||
|
||||
fn blend_if_entry(channel: &str, this: [i32; 4], under: Option<[i32; 4]>) -> D {
|
||||
let mut d = desc("Blnd").with("Chnl", channel_ref(Some(channel)));
|
||||
for (k, v) in ["SrcB", "Srcl", "SrcW", "Srcm"].into_iter().zip(this) {
|
||||
d = d.with(k, D::Integer(v));
|
||||
}
|
||||
if let Some(u) = under {
|
||||
for (k, v) in ["DstB", "Dstl", "DstW", "Dstt"].into_iter().zip(u) {
|
||||
d = d.with(k, D::Integer(v));
|
||||
}
|
||||
}
|
||||
D::Descriptor(d)
|
||||
}
|
||||
|
||||
fn layer_set(t: Descriptor) -> AtnItem {
|
||||
item("setd", "Set", Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t))))
|
||||
}
|
||||
|
||||
fn blend_layers(depth: u8) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn active_info(s: &Session) -> Value {
|
||||
let st = s.active().unwrap();
|
||||
crate::inspect::layer(st.doc.layer(st.active_layer.unwrap()).unwrap())
|
||||
}
|
||||
|
||||
/// #10771: Blend If loads as `layer.layerStyle.blendingOptions` and plays (it used to be a
|
||||
/// silent no-op), on 8- and 16-bit documents, Gray and a colour channel, split sliders.
|
||||
#[test]
|
||||
fn blend_if_imports_and_plays() {
|
||||
let blnd = D::List(vec![blend_if_entry("gray", [0, 30, 200, 255], Some([10, 10, 240, 250])), blend_if_entry("red", [20, 40, 255, 255], None)]);
|
||||
for depth in [8, 16] {
|
||||
let mut s = blend_layers(depth);
|
||||
let loaded = s
|
||||
.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", blnd.clone()))])).unwrap())}))
|
||||
.unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
|
||||
let mut expected = json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}, {"channel": "red", "thisLayer": [20, 40, 255, 255]}]});
|
||||
normalise(&mut expected);
|
||||
let (id, p) = &s.actions.list[0].steps[0];
|
||||
assert_eq!((id.as_str(), p), ("layer.layerStyle.blendingOptions", &expected));
|
||||
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
let b = active_info(&s)["blendIf"].clone();
|
||||
assert_eq!(b[0], json!({"channel": 0, "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}), "{b}");
|
||||
assert_eq!(b[1], json!({"channel": 1, "thisLayer": [20, 40, 255, 255], "underlying": [0, 0, 255, 255]}), "{b}");
|
||||
// It saves back as the same `Blnd`.
|
||||
let (_, d) = saved_first(&mut s);
|
||||
assert_eq!(get_obj(d.as_ref().unwrap(), "T ").unwrap().get("Blnd"), Some(&blnd));
|
||||
}
|
||||
}
|
||||
|
||||
/// #10771: the Blending Options dialog's OK (opacity, fill, mode and Blend If in one Set) is one
|
||||
/// step that applies them all; a Set that also renames splits into setProps and Blend If.
|
||||
#[test]
|
||||
fn blend_if_with_the_dialog_options_is_one_step_and_a_rename_splits() {
|
||||
let blnd = D::List(vec![blend_if_entry("gray", [0, 0, 128, 255], None)]);
|
||||
let dialog = desc("Lyr ").with("Opct", pct(40.0)).with("fillOpacity", pct(70.0)).with("Md ", enm("BlnM", "Mltp")).with("Blnd", blnd.clone());
|
||||
let mut s = blend_layers(8);
|
||||
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(dialog.clone())])).unwrap())})).unwrap();
|
||||
let steps = &s.actions.list[0].steps;
|
||||
assert_eq!(steps.len(), 1, "{steps:?}");
|
||||
assert_eq!(steps[0].0, "layer.layerStyle.blendingOptions");
|
||||
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
let info = active_info(&s);
|
||||
assert!((info["opacity"].as_f64().unwrap() - 0.4).abs() < 1e-3, "{info}");
|
||||
assert_eq!(info["blend"], json!("Multiply"), "{info}");
|
||||
assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}");
|
||||
let (_, d) = saved_first(&mut s);
|
||||
assert_eq!(get_obj(d.as_ref().unwrap(), "T "), Some(&dialog));
|
||||
// With a rename the Set is not the dialog's: the name and opacity go through setProps.
|
||||
let mut s = blend_layers(8);
|
||||
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Nm ", text("Renamed")).with("Opct", pct(40.0)).with("Blnd", blnd))])).unwrap())}))
|
||||
.unwrap();
|
||||
let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert!(ids.contains(&"layer.setProps") && ids.contains(&"layer.layerStyle.blendingOptions"), "{ids:?}");
|
||||
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
let info = active_info(&s);
|
||||
assert_eq!(info["name"], json!("Renamed"));
|
||||
assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}");
|
||||
}
|
||||
|
||||
/// #10771: Blend If entries that are not whole levels, out of order, with half the Underlying
|
||||
/// sliders or an unknown key stay Photoshop steps.
|
||||
#[test]
|
||||
fn blend_if_the_command_cannot_play_stays_a_photoshop_step() {
|
||||
let with = |key: &str| match blend_if_entry("gray", [0, 0, 255, 255], None) {
|
||||
D::Descriptor(d) => D::Descriptor(d.with(key, D::Integer(3))),
|
||||
e => e,
|
||||
};
|
||||
let (half, unknown) = (with("DstB"), with("Zzzz"));
|
||||
for e in [blend_if_entry("gray", [0, 0, 300, 255], None), half, unknown] {
|
||||
let imp = import(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", D::List(vec![e])))])).unwrap()).unwrap();
|
||||
assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP);
|
||||
}
|
||||
for p in [
|
||||
json!({"blendIf": null}),
|
||||
json!({"blendIf": {"channel": 2, "thisLayer": [0, 255]}}),
|
||||
json!({"blendIf": {"channel": "gray", "thisLayer": [200, 100]}}),
|
||||
json!({"blendIf": {"channel": "gray", "underlying": [0, 255]}}),
|
||||
] {
|
||||
assert!(export_item("layer.layerStyle.blendingOptions", &p, "S").is_none(), "{p}");
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -99,7 +99,7 @@ the reference application.
|
||||
| `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` |
|
||||
| `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` |
|
||||
| `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` |
|
||||
| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` |
|
||||
| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newFillLayer.gradient`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` |
|
||||
| (string ID only) | animationFramesFromLayers | Make Frames from Layers | |
|
||||
| (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` |
|
||||
| `Mxm ` | maximum | Maximum | `filter.other.maximum` |
|
||||
@@ -142,7 +142,7 @@ the reference application.
|
||||
| (string ID only) | smartBrushWorkspace | Select and Mask | `select.refineEdge` |
|
||||
| (string ID only) | autoCutout | Select Subject | `select.subject` |
|
||||
| `SlcC` | selectiveColor | Selective Color | `image.adjustments.selectiveColor` |
|
||||
| `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` |
|
||||
| `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `gradient.fill.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` |
|
||||
| (string ID only) | adaptCorrect | Shadows/Highlights | `image.adjustments.shadowsHighlights` |
|
||||
| `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` |
|
||||
| `ShrE` | sharpenEdges | Sharpen Edges | `filter.sharpen.sharpenEdges` |
|
||||
@@ -438,6 +438,16 @@ differences that do not change what a step does.
|
||||
`forceNotify`) and default flags (`MkVs` false, `Adjs` false) are not written back.
|
||||
- An adjustment layer's values equal to the defaults are not written back (other values are).
|
||||
- Colours are kept as 8-bit values (51.0031 becomes 51); gradient names become "Custom".
|
||||
- A loaded Gradient Fill Make or Set (`gradientLayer`) saves back as the recorded descriptor until
|
||||
it is edited. Otherwise it is written from the params: `#rrggbb` stops on 1/4096 locations and
|
||||
whole-percent midpoints; a Set also needs its transparency stops, as the event replaces the whole
|
||||
gradient. Midpoint k is the `Mdpn` of stop k + 1 (the first stop's is 50). A Noise gradient,
|
||||
smoothness below 100 %, a non-classic interpolation or a transparency midpoint stay Photoshop steps.
|
||||
- Blend If (`Blnd` in a layer Set) loads as `layer.layerStyle.blendingOptions`. With only the
|
||||
Blending Options dialog's keys (`Opct`, `fillOpacity`, `Md `, `Blnd`, the Advanced checkboxes) the
|
||||
Set is one step and saves back as one Set; with any other key, those go through `layer.setProps`.
|
||||
Slider values are whole levels 0..255, `SrcB`/`Srcl`/`SrcW`/`Srcm` for This Layer and
|
||||
`DstB`/`Dstl`/`DstW`/`Dstt` for Underlying Layer (black low, black high, white low, white high).
|
||||
- Levels and Curves write the composite as `Chnl.Cmps` (the application skips `Ordn.Trgt`, #8543).
|
||||
|
||||
## Reference forms
|
||||
|
||||
Reference in new issue
Block a user