From 11e5af23ad018d3c8b10a3bdcf498cc9296aa66e Mon Sep 17 00:00:00 2001 From: storybored379 Date: Sun, 4 Oct 2026 07:13:27 -0700 Subject: [PATCH] =?UTF-8?q?Layer=20Style=20=E2=80=BA=20Blending=20Options?= =?UTF-8?q?=20=E2=80=BA=20Blend=20If=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model Blend If (This Layer / Underlying Layer ranges, split points) as Layer::blend_if, read and write it as the PSD layer-record blending ranges (previously parsed but dropped on export), save it in .pcraft, render it in the CPU compositor (premultiplied mix = compositing at k × alpha), fall back to the CPU on the GPU path, and drive it from layer.layerStyle.blendingOptions {"blendIf": …}; document.inspect reports it. Co-authored-by: storybored379 Co-authored-by: Claude Opus 5.5 --- crates/compose/src/lib.rs | 116 ++++++++++++-- crates/compose/src/tests.rs | 155 ++++++++++++++++++ crates/doc/src/blend_if.rs | 175 +++++++++++++++++++++ crates/doc/src/lib.rs | 7 + crates/engine/src/inspect.rs | 12 ++ crates/engine/src/layer_menu_cmds.rs | 106 ++++++++++++- crates/engine/src/layer_menu_cmds/tests.rs | 129 +++++++++++++++ crates/format/src/convert.rs | 2 + crates/format/src/manifest.rs | 3 + crates/format/tests/roundtrip.rs | 33 ++++ crates/gpu/src/plan.rs | 42 +++++ crates/io/src/blocks.rs | 107 ++++++++++++- crates/io/src/psd_export.rs | 2 +- crates/io/src/psd_import.rs | 2 + crates/io/tests/roundtrip.rs | 70 +++++++++ 15 files changed, 941 insertions(+), 20 deletions(-) create mode 100644 crates/doc/src/blend_if.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5129aefb..60c190b4 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -470,15 +470,97 @@ pub fn transparent_outside(layer: &Layer) -> bool { } } -/// Composite `layer` (plus its clipping group) onto `backdrop`, honouring its channel restrictions. -fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { - match channel_weights(layer, cx.mode) { - Some(w) => { - let before = backdrop.clone(); - composite_layer_any(layer, clipped, backdrop, cx); - restore_channels(backdrop, &before, w); +/// Whether Blending Options › Blend If changes how `layer` composites in a `mode` document. +/// RGB documents test Gray and R, G, B; grayscale (and duotone) documents their one channel. +/// Other modes composite in display RGB, where ranges over their own channels have no exact +/// equivalent, so (like channel restrictions) the setting round-trips but isn't applied there. +pub fn blend_if_active(layer: &Layer, mode: photocraft_color::ColorMode) -> bool { + use photocraft_color::ColorMode as M; + matches!(mode, M::Rgb | M::Grayscale | M::Duotone) && !layer.blend_if.is_default() +} + +/// How much of a pixel shows through `layer`'s Blend If ranges, given the layer's own colour +/// (`this`, `None` where the layer has no content of its own there) and the colour beneath it +/// (`under`, `None` where nothing is beneath). Every range multiplies in. +fn blend_if_weight( + layer: &Layer, + mode: photocraft_color::ColorMode, + this: Option<[f32; 4]>, + under: Option<[f32; 4]>, +) -> f32 { + use photocraft_color::ColorMode as M; + let bi = &layer.blend_if; + let mut k = 1.0; + for (side, px) in [this, under].into_iter().enumerate() { + let Some(p) = px else { continue }; + let v = |c: usize| p[c].clamp(0.0, 1.0) * 255.0; + match mode { + // Gray is the colour channels' luma (Rec. 601 weights), then R, G, B. + M::Rgb => { + let gray = 0.299 * v(0) + 0.587 * v(1) + 0.114 * v(2); + k *= bi.get(0)[side].weight(gray); + for c in 0..3 { + k *= bi.get(c + 1)[side].weight(v(c)); + } + } + // One channel: the PSD spec marks the composite-gray entry irrelevant here, so the + // channel's own entry carries the setting; both are honoured. + _ => k *= bi.get(0)[side].weight(v(0)) * bi.get(1)[side].weight(v(0)), + } + if k <= 0.0 { + return 0.0; + } + } + k +} + +/// Apply `layer`'s Blend If to a finished composite: `out` (the backdrop with the layer drawn) +/// is mixed back towards `before` (the backdrop without it) where the ranges hide the layer. +/// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since +/// every blend mode's source-over result is linear in the source alpha. +fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) { + // "This Layer" is the layer's own colour; adjustment layers (no content of their own) are + // judged by their result. + let own = if matches!(layer.content, LayerContent::Adjustment(_)) { + None + } else { + render_content(layer, out.rect, cx) + }; + for (i, (p, b)) in out.px.iter_mut().zip(&before.px).enumerate() { + let this = match &own { + Some(o) => Some(o.px[i]).filter(|q| q[3] > 0.0), + None => Some(*p), + }; + let under = Some(*b).filter(|q| q[3] > 0.0); + let k = blend_if_weight(layer, cx.mode, this, under); + if k >= 1.0 { + continue; + } + let a = b[3] + (p[3] - b[3]) * k; + *p = if a > 0.0 { + let c = + |c: usize| ((b[c] * b[3] + (p[c] * p[3] - b[c] * b[3]) * k) / a).clamp(0.0, 1.0); + [c(0), c(1), c(2), a] + } else { + [0.0; 4] + }; + } +} + +/// Composite `layer` (plus its clipping group) onto `backdrop`, honouring its channel restrictions +/// and Blend If. +fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { + let w = channel_weights(layer, cx.mode); + let blend_if = blend_if_active(layer, cx.mode); + let before = (w.is_some() || blend_if).then(|| backdrop.clone()); + composite_layer_any(layer, clipped, backdrop, cx); + if let Some(before) = &before { + if let Some(w) = w { + restore_channels(backdrop, before, w); + } + if blend_if { + apply_blend_if(layer, before, backdrop, cx); } - None => composite_layer_any(layer, clipped, backdrop, cx), } } @@ -808,15 +890,19 @@ fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::Textur } /// Composite `layer` onto `base` restricted to the base's alpha (clipping mask semantics), -/// honouring its channel restrictions. +/// honouring its channel restrictions and Blend If. fn composite_atop(layer: &Layer, base: &mut Buffer, cx: &Ctx) { - match channel_weights(layer, cx.mode) { - Some(w) => { - let before = base.clone(); - composite_atop_any(layer, base, cx); - restore_channels(base, &before, w); + let w = channel_weights(layer, cx.mode); + let blend_if = blend_if_active(layer, cx.mode); + let before = (w.is_some() || blend_if).then(|| base.clone()); + composite_atop_any(layer, base, cx); + if let Some(before) = &before { + if let Some(w) = w { + restore_channels(base, before, w); + } + if blend_if { + apply_blend_if(layer, before, base, cx); } - None => composite_atop_any(layer, base, cx), } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 07c983f0..1614cac4 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -777,6 +777,161 @@ fn channel_restrictions_keep_the_backdrop() { assert_eq!(channel_weights(&d.layers[1], ColorMode::Rgb), Some([1.0, 1.0, 0.0])); } +fn range(black: [u8; 2], white: [u8; 2]) -> photocraft_doc::BlendRange { + photocraft_doc::BlendRange { black, white } +} + +const FULL: photocraft_doc::BlendRange = photocraft_doc::BlendRange::FULL; + +#[test] +fn blend_if_this_layer_hides_by_the_layers_own_value() { + // Mid-grey backdrop; the layer is black on the left, white on the right. + let mut d = doc_white(8, 8); + d.layers.push(solid_layer( + "grey", + Rect::new(0, 0, 8, 8), + [0.5, 0.5, 0.5, 1.0], + )); + let mut l = solid_layer("bw", Rect::new(0, 0, 4, 8), [0.0, 0.0, 0.0, 1.0]); + l.surface_mut() + .unwrap() + .fill_rect(Rect::new(4, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); + // Gray › This Layer: black point at 50 hides the blacks. + l.blend_if.set(0, [range([50, 50], [255, 255]), FULL]); + d.layers.push(l); + assert!( + close4(px(&d, 1, 1), [0.5, 0.5, 0.5, 1.0]), + "{:?}", + px(&d, 1, 1) + ); + assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0])); + // White point at 200 hides the whites as well. + d.layers[2] + .blend_if + .set(0, [range([50, 50], [200, 200]), FULL]); + assert!(close4(px(&d, 6, 1), [0.5, 0.5, 0.5, 1.0])); + // Back to the defaults: everything shows again. + d.layers[2].blend_if.set(0, [FULL, FULL]); + assert!(d.layers[2].blend_if.is_default()); + assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn blend_if_underlying_layer_hides_by_the_backdrop_value() { + // Backdrop black on the left, white on the right; a red layer over all of it. + let mut d = doc_white(8, 8); + d.layers.push(solid_layer( + "black", + Rect::new(0, 0, 4, 8), + [0.0, 0.0, 0.0, 1.0], + )); + let mut l = solid_layer("red", Rect::new(0, 0, 8, 8), [1.0, 0.0, 0.0, 1.0]); + // Gray › Underlying Layer: white point at 128 = only over the darks (sky-replacement style). + l.blend_if.set(0, [FULL, range([0, 0], [128, 128])]); + d.layers.push(l); + assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0])); + assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0])); +} + +#[test] +fn blend_if_split_point_fades_like_opacity() { + // A split black point (Alt-drag) at 50/150 shows a value-100 layer at half strength, which + // is exactly the layer at 50% opacity, also over a semi-transparent backdrop. + let v = 100.0 / 255.0; + let mut d = doc_white(4, 4); + d.layers[0] + .surface_mut() + .unwrap() + .fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.2, 1.0, 0.5]); + let mut l = solid_layer("v", Rect::new(0, 0, 4, 4), [v, v, v, 1.0]); + l.blend = BlendMode::Multiply; + let mut half = l.clone(); + half.opacity = 0.5; + l.blend_if.set(0, [range([50, 150], [255, 255]), FULL]); + let mut d2 = d.clone(); + d.layers.push(l); + d2.layers.push(half); + let (a, b) = (px(&d, 1, 1), px(&d2, 1, 1)); + assert!( + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), + "{a:?} vs {b:?}" + ); +} + +#[test] +fn blend_if_per_channel_ranges() { + // Blue › This Layer: hide pixels whose blue is above 100. + let mut d = doc_white(8, 8); + let mut l = solid_layer("c", Rect::new(0, 0, 4, 8), [1.0, 0.0, 0.0, 1.0]); + l.surface_mut() + .unwrap() + .fill_rect(Rect::new(4, 0, 8, 8), &[0.0, 0.0, 1.0, 1.0]); + l.blend_if.set(3, [range([0, 0], [100, 100]), FULL]); + d.layers.push(l); + assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0])); + assert!(close4(px(&d, 6, 1), [1.0, 1.0, 1.0, 1.0])); + // Red › Underlying Layer: the backdrop's red (255) is above 254 → hidden everywhere. + d.layers[1].blend_if = Default::default(); + d.layers[1] + .blend_if + .set(1, [FULL, range([0, 0], [254, 254])]); + assert!(close4(px(&d, 1, 1), [1.0; 4])); +} + +#[test] +fn blend_if_on_adjustment_and_clipped_layers() { + let mut d = doc_white(8, 8); + d.layers.push(solid_layer( + "dark", + Rect::new(0, 0, 4, 8), + [0.1, 0.1, 0.1, 1.0], + )); + // Invert, but only where the backdrop is dark (Underlying white point at 128). + let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + inv.blend_if.set(0, [FULL, range([0, 0], [128, 128])]); + d.layers.push(inv); + assert!(close4(px(&d, 1, 1), [0.9, 0.9, 0.9, 1.0])); + assert!(close4(px(&d, 6, 1), [1.0; 4])); + // A clipped layer judges "underlying" by its clipping base. + let mut d = doc_white(8, 8); + let mut base = solid_layer("base", Rect::new(0, 0, 8, 8), [0.0, 0.0, 0.0, 1.0]); + base.surface_mut() + .unwrap() + .fill_rect(Rect::new(4, 0, 8, 8), &[0.9, 0.9, 0.9, 1.0]); + let mut clip = solid_layer("clip", Rect::new(0, 0, 8, 8), [0.0, 1.0, 0.0, 1.0]); + clip.clipped = true; + clip.blend_if.set(0, [FULL, range([128, 128], [255, 255])]); + d.layers.push(base); + d.layers.push(clip); + assert!(close4(px(&d, 1, 1), [0.0, 0.0, 0.0, 1.0])); + assert!(close4(px(&d, 6, 1), [0.0, 1.0, 0.0, 1.0])); +} + +#[test] +fn blend_if_modes() { + // Grayscale documents: the single channel's entry (or the gray entry) applies. + let mut d = Document::with_background( + "g", + Size::new(4, 4), + ColorMode::Grayscale, + SampleType::U8, + Color::WHITE, + ); + let mut l = Layer::raster("k", d.pixel_format()); + l.surface_mut() + .unwrap() + .fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 1.0]); + l.blend_if.set(1, [range([10, 10], [255, 255]), FULL]); + d.layers.push(l); + assert!(close4(px(&d, 1, 1), [1.0; 4]), "{:?}", px(&d, 1, 1)); + // CMYK/Lab composite in display RGB: kept for round trip, not applied. + assert!(blend_if_active(&d.layers[1], ColorMode::Rgb)); + assert!(blend_if_active(&d.layers[1], ColorMode::Grayscale)); + assert!(!blend_if_active(&d.layers[1], ColorMode::Cmyk)); + assert!(!blend_if_active(&d.layers[1], ColorMode::Lab)); + assert!(!blend_if_active(&d.layers[0], ColorMode::Rgb)); +} + #[test] fn effect_maps_built_inside_parallel_tiles_do_not_deadlock() { // A map large enough for the blur to go multi-threaded, built while rayon renders tiles that diff --git a/crates/doc/src/blend_if.rs b/crates/doc/src/blend_if.rs new file mode 100644 index 00000000..245b2aac --- /dev/null +++ b/crates/doc/src/blend_if.rs @@ -0,0 +1,175 @@ +//! Layer Style › Blending Options › Blend If: per-channel value ranges that hide a layer's +//! pixels by their own value ("This Layer") or by the value beneath them ("Underlying Layer"). +//! +//! Each slider pair is stored as Photoshop stores it in a PSD layer record's blending ranges: a +//! black point and a white point, each split into a low and a high half (Alt-drag in the dialog), +//! on the 0..=255 scale whatever the document's depth. A value below the black low point or above +//! the white high point is hidden; between the two halves of a split point it fades linearly. + +use serde::{Deserialize, Serialize}; + +/// One Blend If slider pair: `black` = (low, high), `white` = (low, high), all on 0..=255. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BlendRange { + pub black: [u8; 2], + pub white: [u8; 2], +} + +impl Default for BlendRange { + fn default() -> Self { + Self::FULL + } +} + +impl BlendRange { + /// Every value blends (the sliders at the ends). + pub const FULL: BlendRange = BlendRange { + black: [0, 0], + white: [255, 255], + }; + + /// Whether this range hides nothing. + pub fn is_full(&self) -> bool { + *self == Self::FULL + } + + /// From PSD's byte order: black low, black high, white low, white high. + pub fn from_bytes(b: [u8; 4]) -> Self { + BlendRange { + black: [b[0], b[1]], + white: [b[2], b[3]], + } + } + + /// PSD's byte order (see [`BlendRange::from_bytes`]). + pub fn to_bytes(self) -> [u8; 4] { + [self.black[0], self.black[1], self.white[0], self.white[1]] + } + + /// How much of a pixel with value `v` (0..=255, fractional for deep documents) shows: + /// 0 below the black point or above the white point, 1 between them, and a linear ramp across + /// a split point. An unsplit black point at `b` shows `v >= b`; an unsplit white point at `w` + /// shows `v <= w`. + pub fn weight(&self, v: f32) -> f32 { + let ramp = |lo: u8, hi: u8, x: f32| -> f32 { + // 0 at or below `lo`, 1 at or above `hi`. + let (lo, hi) = (f32::from(lo), f32::from(hi)); + if hi <= lo { + if x >= lo { 1.0 } else { 0.0 } + } else { + ((x - lo) / (hi - lo)).clamp(0.0, 1.0) + } + }; + let lo = ramp(self.black[0], self.black[1], v); + // Mirror for the white point: 1 at or below its low half, 0 above its high half. + let hi = ramp(255 - self.white[1], 255 - self.white[0], 255.0 - v); + lo * hi + } +} + +/// A layer's Blend If settings. `ranges[0]` is the "Gray" entry (composite of the colour +/// channels), then one entry per colour channel of the document's mode (R, G, B / C, M, Y, K / +/// L, a, b / Gray), each as `[this layer, underlying layer]`. Empty = every pixel blends. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BlendIf { + pub ranges: Vec<[BlendRange; 2]>, +} + +impl BlendIf { + /// Whether no range hides anything (the Photoshop default). + pub fn is_default(&self) -> bool { + self.ranges.iter().flatten().all(BlendRange::is_full) + } + + /// Entry `i` (0 = Gray), full when not stored. + pub fn get(&self, i: usize) -> [BlendRange; 2] { + self.ranges.get(i).copied().unwrap_or([BlendRange::FULL; 2]) + } + + /// Set entry `i` (0 = Gray), growing the list with full ranges as needed. Trailing full + /// entries are dropped so an all-default setting compares equal to `BlendIf::default()`. + pub fn set(&mut self, i: usize, pair: [BlendRange; 2]) { + if self.ranges.len() <= i { + self.ranges.resize(i + 1, [BlendRange::FULL; 2]); + } + self.ranges[i] = pair; + while self + .ranges + .last() + .is_some_and(|p| p.iter().all(BlendRange::is_full)) + { + self.ranges.pop(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_range_shows_everything() { + let r = BlendRange::FULL; + assert!(r.is_full()); + for v in [0.0, 0.5, 127.0, 254.9, 255.0] { + assert_eq!(r.weight(v), 1.0); + } + } + + #[test] + fn unsplit_points_are_hard_thresholds() { + let r = BlendRange { + black: [50, 50], + white: [200, 200], + }; + assert_eq!(r.weight(49.0), 0.0); + assert_eq!(r.weight(49.9), 0.0); + assert_eq!(r.weight(50.0), 1.0); + assert_eq!(r.weight(200.0), 1.0); + assert_eq!(r.weight(200.1), 0.0); + assert!(!r.is_full()); + } + + #[test] + fn split_points_ramp_linearly() { + let r = BlendRange { + black: [0, 100], + white: [155, 255], + }; + // A split black point fades from its low half (hidden) to its high half (shown). + assert_eq!(r.weight(0.0), 0.0); + assert!((r.weight(50.0) - 0.5).abs() < 1e-6); + assert_eq!(r.weight(100.0), 1.0); + assert_eq!(r.weight(155.0), 1.0); + assert!((r.weight(205.0) - 0.5).abs() < 1e-6); + assert_eq!(r.weight(255.0), 0.0); + assert!(!r.is_full()); + } + + #[test] + fn bytes_round_trip_in_psd_order() { + let r = BlendRange { + black: [10, 20], + white: [230, 240], + }; + assert_eq!(r.to_bytes(), [10, 20, 230, 240]); + assert_eq!(BlendRange::from_bytes(r.to_bytes()), r); + } + + #[test] + fn set_trims_trailing_full_entries() { + let mut b = BlendIf::default(); + assert!(b.is_default()); + let r = BlendRange { + black: [30, 30], + white: [255, 255], + }; + b.set(2, [BlendRange::FULL, r]); + assert_eq!(b.ranges.len(), 3); + assert_eq!(b.get(2), [BlendRange::FULL, r]); + assert_eq!(b.get(7), [BlendRange::FULL; 2]); + assert!(!b.is_default()); + b.set(2, [BlendRange::FULL; 2]); + assert_eq!(b, BlendIf::default()); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6cb33253..8fea839b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -9,6 +9,7 @@ pub mod adjust; pub mod analysis; +pub mod blend_if; pub mod comps; pub mod effects; pub mod mode; @@ -25,6 +26,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; pub use adjust::Adjustment; pub use analysis::{CountGroup, Measurement, MeasurementScale, Note, Ruler}; +pub use blend_if::{BlendIf, BlendRange}; pub use comps::{Artboard, ArtboardBackground, CompAppearance, CompLayerState, LayerComp}; pub use effects::{ Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, @@ -422,6 +424,10 @@ pub struct Layer { /// document's mode (R, G, B / C, M, Y, K / L, a, b / Gray) is left out of blending, so the /// backdrop's value is kept there. 0 = every channel blends (the default). PSD `brst`. pub excluded_channels: u32, + /// Blending Options › Blend If: value ranges of this layer and of the layers beneath it + /// outside which the layer's pixels are hidden. Default = everything blends. PSD layer-record + /// blending ranges. + pub blend_if: BlendIf, /// Layer › Video Layers frame stack (None for a normal layer). pub video: Option, } @@ -447,6 +453,7 @@ impl Layer { fill_cache: None, link_group: None, excluded_channels: 0, + blend_if: BlendIf::default(), video: None, } } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 79fdf3b3..eaa86eb6 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -105,6 +105,18 @@ fn layer_sel(l: &Layer, selected: &[photocraft_doc::LayerId]) -> Value { if l.excluded_channels != 0 { v["channels"] = json!((0..4).map(|i| l.excluded_channels & (1 << i) == 0).collect::>()); } + // Blending Options › Blend If (only when set): range index (0 = Gray, then the mode's + // channels) with This Layer / Underlying Layer as [blackLo, blackHi, whiteLo, whiteHi]. + if !l.blend_if.is_default() { + v["blendIf"] = l + .blend_if + .ranges + .iter() + .enumerate() + .filter(|(_, r)| !r.iter().all(photocraft_doc::BlendRange::is_full)) + .map(|(i, [this, under])| json!({"channel": i, "thisLayer": this.to_bytes(), "underlying": under.to_bytes()})) + .collect(); + } // Layer styles, so agents can verify what they applied (full settings via the style commands). if !l.effects.items.is_empty() { v["effects"] = json!({ diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 1c29fe8e..9539f899 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -4,9 +4,10 @@ //! just the active layer (Quick Export as PNG, Export As). use photocraft_algo::selection::Region; -use photocraft_color::BlendMode; +use photocraft_color::{BlendMode, ColorMode}; use photocraft_doc::{ - Document, Effect, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode, + BlendIf, BlendRange, Document, Effect, Layer, LayerContent, LayerId, LayerMask, SmartSource, + StackMode, }; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; @@ -504,6 +505,11 @@ fn blending_options(s: &mut Session, p: &Value) -> Result { .get("fillOpacity") .and_then(Value::as_f64) .map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)); + let mode = s.active().ok_or(EngineError::NoDocument)?.doc.mode; + let blend_if = match p.get("blendIf") { + None => None, + Some(v) => Some(blend_if_entries(v, mode)?), + }; s.edit("Blending Options", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if let Some(b) = blend { @@ -518,11 +524,105 @@ fn blending_options(s: &mut Session, p: &Value) -> Result { if let Some(f) = fill { l.fill_opacity = f; } + match &blend_if { + // `null`: Blend If back to the defaults. + Some(None) => l.blend_if = BlendIf::default(), + Some(Some(entries)) => { + for (i, this, under) in entries { + let [cur_this, cur_under] = l.blend_if.get(*i); + l.blend_if + .set(*i, [this.unwrap_or(cur_this), under.unwrap_or(cur_under)]); + } + } + None => {} + } Ok(()) })?; Ok(Value::Null) } +/// One `blendIf` entry: (range index, This Layer, Underlying Layer); `None` = keep. +type BlendIfEntry = (usize, Option, Option); + +/// Parse `blendIf`: `null` (reset) or one or more `{"channel", "thisLayer"?, "underlying"?}`. +fn blend_if_entries(v: &Value, mode: ColorMode) -> Result>> { + const CMD: &str = "layer.layerStyle.blendingOptions"; + let items: Vec<&Value> = match v { + Value::Null => return Ok(None), + Value::Array(a) => a.iter().collect(), + Value::Object(_) => vec![v], + _ => { + return Err(bad( + CMD, + "blendIf: expected an object, a list of objects or null", + )); + } + }; + let mut out = Vec::with_capacity(items.len()); + for it in items { + let ch = it.get("channel").unwrap_or(&Value::Null); + let i = blend_if_channel(ch, mode).ok_or_else(|| { + bad( + CMD, + format!("blendIf: unknown channel {ch} for a {mode:?} document"), + ) + })?; + let range = |key: &str| -> Result> { + match it.get(key) { + None => Ok(None), + Some(r) => blend_range(r).map(Some).ok_or_else(|| { + bad(CMD, format!("blendIf.{key}: expected [black, white] or [blackLow, blackHigh, whiteLow, whiteHigh], 0..255, in increasing order (got {r})")) + }), + } + }; + out.push((i, range("thisLayer")?, range("underlying")?)); + } + Ok(Some(out)) +} + +/// Blend If channel → range index: 0 = Gray, then the mode's colour channels in order. A +/// grayscale document's Gray is its one channel (the PSD spec marks the composite entry +/// irrelevant there). +fn blend_if_channel(v: &Value, mode: ColorMode) -> Option { + let n = mode.color_channels(); + if let Some(i) = v.as_u64() { + return usize::try_from(i).ok().filter(|i| *i <= n); + } + let name = v.as_str().unwrap_or("gray").to_ascii_lowercase(); + let names: &[&str] = match mode { + ColorMode::Rgb | ColorMode::Indexed => &["red", "green", "blue"], + ColorMode::Cmyk => &["cyan", "magenta", "yellow", "black"], + ColorMode::Lab => &["lightness", "a", "b"], + _ => &[], + }; + if name == "gray" || name == "grey" { + return Some(usize::from(n == 1)); + } + names.iter().position(|c| *c == name).map(|i| i + 1) +} + +/// `[black, white]` (unsplit) or `[blackLow, blackHigh, whiteLow, whiteHigh]`, each 0..=255 and +/// in increasing order (the dialog's sliders can't cross). +fn blend_range(v: &Value) -> Option { + let a = v.as_array()?; + let n: Vec = a + .iter() + .map(|x| { + x.as_f64() + .filter(|f| (0.0..=255.0).contains(f)) + .map(|f| f.round() as u8) + }) + .collect::>()?; + let q = match n.as_slice() { + [b, w] => [*b, *b, *w, *w], + [a, b, c, d] => [*a, *b, *c, *d], + _ => return None, + }; + q.windows(2) + .all(|w| w[0] <= w[1]) + .then(|| BlendRange::from_bytes(q)) +} + fn global_light(s: &mut Session, p: &Value) -> Result { let cur = s.active().ok_or(EngineError::NoDocument)?.doc.global_light; let angle = num(p, "angle", cur.angle); @@ -893,7 +993,7 @@ pub fn specs() -> Vec { "layer.layerStyle.blendingOptions", "Blending Options…", ["Layer", "Layer Style"], - r##"{"layer":id?,"blend":"normal|multiply|…"?,"opacity":0..100?,"fillOpacity":0..100?}"##, + r##"{"layer":id?,"blend":"normal|multiply|…"?,"opacity":0..100?,"fillOpacity":0..100?,"blendIf":{"channel":"gray|red|green|blue|cyan|…"|index="gray","thisLayer":[black,white]|[blackLo,blackHi,whiteLo,whiteHi]?,"underlying":[…]?}|[{…},…]|null?} (Blend If values 0..255; split points fade; null resets)"##, has_layer, blending_options ), diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index bd828eb3..6e543a3e 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -195,6 +195,135 @@ fn global_light_blending_options_scale_effects() { } } +#[test] +fn blending_options_blend_if() { + for depth in DEPTHS { + let mut s = session(depth, "rgb"); + // Black on the left, white on the right. + paint(&mut s, |x, _| { + if x < 20 { + [0.0, 0.0, 0.0, 1.0] + } else { + [1.0; 4] + } + }); + let at = |s: &Session, x: i32| photocraft_compose::flatten(doc(s)).get(x, 5); + // Gray › This Layer: black point 50 hides the black half. + s.execute( + "layer.layerStyle.blendingOptions", + json!({"blendIf": {"channel": "gray", "thisLayer": [50, 255]}}), + ) + .unwrap(); + assert_eq!(at(&s, 5)[3], 0.0, "depth {depth}"); + assert_eq!(at(&s, 30), [1.0; 4], "depth {depth}"); + assert_eq!( + active(&s).blend_if.get(0), + [ + BlendRange { + black: [50, 50], + white: [255, 255] + }, + BlendRange::FULL + ] + ); + // Setting the other slider pair keeps this one; split points are 4 values. + s.execute( + "layer.layerStyle.blendingOptions", + json!({"blendIf": [{"channel": "gray", "underlying": [0, 10, 245, 255]}, {"channel": "blue", "thisLayer": [0, 200]}]}), + ) + .unwrap(); + let bi = &active(&s).blend_if; + assert_eq!(bi.get(0)[0].black, [50, 50]); + assert_eq!( + bi.get(0)[1], + BlendRange { + black: [0, 10], + white: [245, 255] + } + ); + assert_eq!( + bi.get(3)[0], + BlendRange { + black: [0, 0], + white: [200, 200] + } + ); + let ins = crate::inspect::layer(active(&s)); + assert_eq!( + ins["blendIf"][0], + json!({"channel": 0, "thisLayer": [50, 50, 255, 255], "underlying": [0, 10, 245, 255]}) + ); + assert_eq!(ins["blendIf"][1]["channel"], 3); + // Blending options without `blendIf` (the dialog's blend/opacity/fill) leave it alone. + s.execute("layer.layerStyle.blendingOptions", json!({"opacity": 90})) + .unwrap(); + assert!(!active(&s).blend_if.is_default()); + // One undo step per call. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(active(&s).blend_if.get(0)[1], BlendRange::FULL); + assert_eq!(active(&s).blend_if.get(0)[0].black, [50, 50]); + // null resets everything. + s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": null})) + .unwrap(); + assert!(active(&s).blend_if.is_default()); + assert!(crate::inspect::layer(active(&s)).get("blendIf").is_none()); + assert_eq!(at(&s, 5), [0.0, 0.0, 0.0, 1.0]); + } +} + +#[test] +fn blend_if_rejects_bad_params() { + let mut s = session(8, "rgb"); + paint(&mut s, disc); + let steps = s.active().unwrap().history.past_len(); + for bad in [ + json!({"channel": "cyan", "thisLayer": [10, 255]}), + json!({"channel": 4, "thisLayer": [10, 255]}), + json!({"channel": "gray", "thisLayer": [200, 100]}), + json!({"channel": "gray", "thisLayer": [0, 300]}), + json!({"channel": "gray", "thisLayer": [0, 10, 5, 255]}), + json!({"channel": "gray", "underlying": [1, 2, 3]}), + json!({"channel": "gray", "underlying": "dark"}), + json!("gray"), + ] { + assert!( + s.execute("layer.layerStyle.blendingOptions", json!({"blendIf": bad})) + .is_err(), + "{bad}" + ); + } + assert!(active(&s).blend_if.is_default()); + assert_eq!(s.active().unwrap().history.past_len(), steps); +} + +#[test] +fn blend_if_channel_names_follow_the_mode() { + let mut s = session(8, "cmyk"); + s.execute( + "layer.layerStyle.blendingOptions", + json!({"blendIf": {"channel": "black", "underlying": [0, 128]}}), + ) + .unwrap(); + assert_eq!(active(&s).blend_if.get(4)[1].white, [128, 128]); + assert!( + s.execute( + "layer.layerStyle.blendingOptions", + json!({"blendIf": {"channel": "red", "underlying": [0, 128]}}) + ) + .is_err() + ); + // A grayscale document's Gray is its one channel. + let mut s = session(16, "grayscale"); + s.execute( + "layer.layerStyle.blendingOptions", + json!({"blendIf": {"channel": "gray", "thisLayer": [30, 255]}}), + ) + .unwrap(); + assert_eq!(active(&s).blend_if.get(1)[0].black, [30, 30]); + assert_eq!(active(&s).blend_if.get(0), [BlendRange::FULL; 2]); +} + #[test] fn create_layer_splits_effects_and_keeps_the_look() { let mut s = session(8, "rgb"); diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index bace2596..fd8c581a 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -211,6 +211,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { }), link_group: l.link_group, excluded_channels: l.excluded_channels, + blend_if: l.blend_if.clone(), video: l.video.as_ref().map(|v| video_m(v, sink)), } } @@ -567,6 +568,7 @@ impl Loader<'_> { fill_cache, link_group: m.link_group, excluded_channels: m.excluded_channels, + blend_if: m.blend_if.clone(), video: m.video.as_ref().map(|v| self.video(v)).transpose()?, }) } diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index ab0d0570..8589383d 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -221,6 +221,9 @@ pub struct LayerM { /// Advanced Blending channels left out (bit per colour channel; 0 = all blend). #[serde(default, skip_serializing_if = "is_zero_u32")] pub excluded_channels: u32, + /// Blend If ranges (empty = everything blends). + #[serde(default, skip_serializing_if = "photocraft_doc::BlendIf::is_default")] + pub blend_if: photocraft_doc::BlendIf, #[serde(default)] pub video: Option, } diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 5d34dde6..4bb4b8f2 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -318,6 +318,39 @@ fn channel_restrictions_and_bevel_elements_roundtrip() { assert_eq!(back.layers[0].excluded_channels, 0b101); } +#[test] +fn blend_if_roundtrips() { + use photocraft_doc::{BlendIf, BlendRange}; + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U16); + let mut bi = BlendIf::default(); + bi.set( + 0, + [ + BlendRange { + black: [20, 60], + white: [255, 255], + }, + BlendRange::FULL, + ], + ); + bi.set( + 3, + [ + BlendRange::FULL, + BlendRange { + black: [0, 0], + white: [180, 220], + }, + ], + ); + doc.layers[0].blend_if = bi.clone(); + let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + assert_eq!(back, doc); + assert_eq!(back.layers[0].blend_if, bi); + // Layers without Blend If keep the default (the field is omitted from the manifest). + assert!(back.layers.iter().skip(1).all(|l| l.blend_if.is_default())); +} + #[test] fn video_layer_frames_survive_roundtrip() { use photocraft_doc::{Timeline, VideoData, VideoSource}; diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index 4ba87d25..642ce5f6 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -446,8 +446,20 @@ impl<'a> Planner<'a> { Some(MaskUse { layer: layer.id, surface: SurfaceRef::Doc(&m.surface), density: m.density, default: m.surface.default_pixel().first().copied().unwrap_or(1.0) }) } + /// Blending Options › Blend If has no GPU pass yet: such documents use the CPU compositor. + fn check_blend_if(&self, layer: &Layer) -> Result<(), Unsupported> { + if photocraft_compose::blend_if_active(layer, self.cx.mode) { + return Err(Unsupported(format!( + "Blend If on `{}` (composited on the CPU)", + layer.name + ))); + } + Ok(()) + } + /// composite_layer, honouring the layer's channel restrictions. fn layer(&mut self, layer: &'a Layer, clipped: &'a [Layer], backdrop: Slot) -> Result { + self.check_blend_if(layer)?; match photocraft_compose::channel_weights(layer, self.cx.mode) { Some(w) => { let before = self.retain(backdrop); @@ -728,6 +740,7 @@ impl<'a> Planner<'a> { /// composite_atop: `layer` onto `base`, restricted to the base's alpha, honouring the /// layer's channel restrictions. fn atop(&mut self, layer: &'a Layer, base: Slot) -> Result { + self.check_blend_if(layer)?; match photocraft_compose::channel_weights(layer, self.cx.mode) { Some(w) => { let before = self.retain(base); @@ -1327,4 +1340,33 @@ mod tests { assert_eq!(last.dst, p.root); assert!(p.slots <= 6, "{} slots", p.slots); } + + #[test] + fn blend_if_falls_back_to_the_cpu() { + let mut d = Document::with_background( + "t", + Size::new(8, 8), + ColorMode::Rgb, + SampleType::U8, + Color::WHITE, + ); + let mut l = Layer::raster("bi", d.pixel_format()); + l.blend_if.set( + 0, + [ + photocraft_doc::BlendRange { + black: [40, 40], + white: [255, 255], + }, + photocraft_doc::BlendRange::FULL, + ], + ); + d.layers.push(l.clone()); + assert!(plan(&d).unwrap_err().0.contains("Blend If")); + // Clipped layers too. + l.clipped = true; + d.layers[1].blend_if = Default::default(); + d.layers.push(l); + assert!(plan(&d).is_err()); + } } diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 9d790d24..6b5ca144 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -2,9 +2,10 @@ //! smart objects, locks and label colors. use photocraft_color::{Color, ColorMode}; -use photocraft_doc::{Fill, GradientStyle, LabelColor, Locks}; +use photocraft_doc::{BlendIf, BlendRange, Fill, GradientStyle, LabelColor, Locks}; use photocraft_geom::Affine; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use photocraft_psd::layer::BlendingRanges; /// `brst` (channel blending restrictions): a list of big-endian u32 channel indices left out of /// blending → a bit mask (bit `i` = channel `i`; indices above 31 are ignored). @@ -17,6 +18,47 @@ pub fn brst_data(mask: u32) -> Option> { (mask != 0).then(|| (0..32u32).filter(|i| mask & 1 << i != 0).flat_map(u32::to_be_bytes).collect()) } +/// Layer-record blending ranges (Adobe PSD spec, "Layer blending ranges data": the composite +/// gray source and destination ranges, then a source and destination range per channel, each +/// as black low, black high, white low, white high) → Blend If. Full ranges map to the +/// default, so files without Blend If keep an empty setting. +pub fn blend_if_from_ranges(r: &BlendingRanges) -> BlendIf { + let mut ranges: Vec<[BlendRange; 2]> = r + .ranges() + .iter() + .map(|e| { + [ + BlendRange::from_bytes(e.source), + BlendRange::from_bytes(e.dest), + ] + }) + .collect(); + // Trailing full entries carry nothing (export pads them back), so they are dropped, as + // `BlendIf::set` does. + let keep = ranges + .iter() + .rposition(|p| !p.iter().all(BlendRange::is_full)) + .map_or(0, |i| i + 1); + ranges.truncate(keep); + BlendIf { ranges } +} + +/// Inverse of [`blend_if_from_ranges`] for a document with `channels` colour channels: one +/// entry for gray plus one per channel (more when the setting stores more), unset entries full. +pub fn ranges_from_blend_if(b: &BlendIf, channels: usize) -> BlendingRanges { + if b.is_default() { + return BlendingRanges::full(channels); + } + let n = b.ranges.len().max(channels + 1); + let data = (0..n).flat_map(|i| { + let [src, dst] = b.get(i); + src.to_bytes().into_iter().chain(dst.to_bytes()) + }); + BlendingRanges { + data: data.collect(), + } +} + /// `lspf` bits (Adobe spec: bit 0 transparency, 1 composite, 2 position). /// Artboard (bit 3... here `0x10` as observed by ag-psd) and "all" (bit 31) /// are undocumented. @@ -419,6 +461,69 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool { #[cfg(test)] mod tests { + #[test] + fn blending_ranges_map_to_blend_if() { + use photocraft_doc::{BlendIf, BlendRange}; + use photocraft_psd::layer::BlendingRanges; + // Full ranges (what every layer without Blend If carries) → the default setting. + assert_eq!( + super::blend_if_from_ranges(&BlendingRanges::full(3)), + BlendIf::default() + ); + assert_eq!( + super::blend_if_from_ranges(&BlendingRanges::default()), + BlendIf::default() + ); + // Gray: This Layer black split 10/40; Blue (entry 3): Underlying white at 200. + let mut data = BlendingRanges::full(3).data; + data[..4].copy_from_slice(&[10, 40, 255, 255]); + data[3 * 8 + 4..3 * 8 + 8].copy_from_slice(&[0, 0, 200, 200]); + let b = super::blend_if_from_ranges(&BlendingRanges { data: data.clone() }); + assert_eq!( + b.get(0), + [ + BlendRange { + black: [10, 40], + white: [255, 255] + }, + BlendRange::FULL + ] + ); + assert_eq!( + b.get(3), + [ + BlendRange::FULL, + BlendRange { + black: [0, 0], + white: [200, 200] + } + ] + ); + assert_eq!(b.ranges.len(), 4); + assert_eq!(super::ranges_from_blend_if(&b, 3).data, data); + // The default writes full ranges for gray + each channel. + assert_eq!( + super::ranges_from_blend_if(&BlendIf::default(), 4), + BlendingRanges::full(4) + ); + // A trimmed setting is padded to the document's channel count. + let mut short = BlendIf::default(); + short.set( + 0, + [ + BlendRange { + black: [5, 5], + white: [255, 255], + }, + BlendRange::FULL, + ], + ); + let r = super::ranges_from_blend_if(&short, 3); + assert_eq!(r.data.len(), 4 * 8); + assert_eq!(&r.data[..8], &[5, 5, 255, 255, 0, 0, 255, 255]); + assert_eq!(&r.data[8..], &BlendingRanges::full(2).data[..]); + } + #[test] fn brst_round_trip() { assert_eq!(super::parse_brst(&[0, 0, 0, 2]), 0b100); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index f80dce6d..17c11c8c 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -199,7 +199,7 @@ impl Ex { flags, filler: 0, mask, - blending_ranges: BlendingRanges::full(self.cc), + blending_ranges: crate::blocks::ranges_from_blend_if(&l.blend_if, self.cc), name: legacy_name(&l.name), blocks, extra_trailing: Vec::new(), diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index b8a462ba..fab9c090 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -191,6 +191,8 @@ impl Ctx<'_> { if let Some(b) = rec.block(b"brst") { l.excluded_channels = crate::blocks::parse_brst(&b.data); } + // Blend If lives in the layer record's blending ranges. + l.blend_if = crate::blocks::blend_if_from_ranges(&rec.blending_ranges); l.psd_id = rec.layer_id(); let name = l.name.clone(); l.mask = self.record_mask(rec, &name); diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs index 94a3fd0c..2a87d3be 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/roundtrip.rs @@ -423,3 +423,73 @@ fn channel_restrictions_round_trip_as_brst() { let f = document_to_psd(&cleared); assert!(f.layers().iter().find(|r| r.name() == "a").unwrap().block(b"brst").is_none()); } + +#[test] +fn blend_if_round_trips_as_blending_ranges() { + use photocraft_doc::{BlendIf, BlendRange, Document, Layer, Size}; + let mut d = Document::new("b", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let mut a = Layer::raster("a", d.pixel_format()); + a.surface_mut().unwrap().fill_rect( + photocraft_geom::Rect::new(0, 0, 4, 4), + &[1.0, 1.0, 1.0, 1.0], + ); + let mut bi = BlendIf::default(); + // Gray › This Layer: hide the whites, fading from 200 to 230. + bi.set( + 0, + [ + BlendRange { + black: [0, 0], + white: [200, 230], + }, + BlendRange::FULL, + ], + ); + // Green › Underlying Layer: show only over values from 64 up. + bi.set( + 2, + [ + BlendRange::FULL, + BlendRange { + black: [64, 64], + white: [255, 255], + }, + ], + ); + a.blend_if = bi.clone(); + let mut g = Layer::group("g", vec![Layer::raster("c", d.pixel_format())]); + g.blend_if.set( + 1, + [ + BlendRange { + black: [1, 2], + white: [3, 4], + }, + BlendRange::FULL, + ], + ); + let b = Layer::raster("b", d.pixel_format()); + d.layers = vec![a, b, g]; + let f = document_to_psd(&d); + let rec = f.layers().iter().find(|r| r.name() == "a").unwrap(); + let full = [0u8, 0, 255, 255, 0, 0, 255, 255]; + let mut want = Vec::new(); + want.extend_from_slice(&[0, 0, 200, 230, 0, 0, 255, 255]); + want.extend_from_slice(&full); + want.extend_from_slice(&[0, 0, 255, 255, 64, 64, 255, 255]); + want.extend_from_slice(&full); + assert_eq!(rec.blending_ranges.data, want); + // Layers without Blend If keep full ranges for gray + R, G, B. + let rb = f.layers().iter().find(|r| r.name() == "b").unwrap(); + assert_eq!(rb.blending_ranges, photocraft_psd::BlendingRanges::full(3)); + let back = roundtrip(&d); + assert_eq!(back.layers[0].blend_if, bi); + assert!(back.layers[1].blend_if.is_default()); + assert_eq!( + back.layers[2].blend_if.get(1)[0], + BlendRange { + black: [1, 2], + white: [3, 4] + } + ); +}