Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (FILE-215-16, part of #215) (#903)

* Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (part of #215)

Blending Options > Advanced Blending now works end to end:

- doc: AdvancedBlending on every layer (knockout none/shallow/deep, blend
  interior effects as group, blend clipped layers as group, transparency
  shapes layer, layer/vector mask hides effects), saved in .pcraft.
- PSD and layered TIFF: knko, infx, clbl, tsly, lmgm, vmgm read into the
  model and written back (in place, or added when not at the defaults);
  the TIFF byte-order transcoder handles lmgm/vmgm as byte flags.
- CPU compositor (compose/src/advanced.rs): shallow and deep knockouts
  (isolated groups and clipping groups stop a deep knockout), clipped
  layers blended individually, effects over the whole layer when
  Transparency Shapes is off, effects built from an unmasked copy when a
  mask hides them, interior effects under fill opacity.
- GPU: documents using Advanced Blending fall back to the CPU compositor.
- engine: layer.layerStyle.blendingOptions takes channels, knockout and the
  five switches; inspect reports them; Copy/Paste Layer Style carries them.
- UI: the Blending Options page laid out like Photoshop's (General,
  Advanced Blending, Blend If with split sliders), translated.

psd-tools corpus oracle 229 -> 236 (seven knockout files now pass; floor
raised); other corpora and all round trips unchanged. Scorecard
FILE-215-16 -> partial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Polish catalog rows for Advanced Blending; regenerate docs/scorecard.md

pl.tsv landed on main and the coverage test needs every catalog to translate new UI strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Undo damage: compare Layer.advanced in layer_pixels_equal; regenerate scorecard

#810's exhaustive Layer destructure must classify the new advanced-blending
field; it changes the composite, so it is compared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-08 20:08:03 -04:00
1 parent 4cb7cf3931
commit b0680fd421
42 files changed
+1858 -87

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
//! Layer Style › Blending Options › Advanced Blending in the CPU compositor (the oracle):
//!
//! - **Knockout.** Inside the layer's shape the backdrop is replaced by the knockout target
//! before the layer composites: for Shallow the bottom of the enclosing group (the backdrop the
//! group's stack started from: transparent in an isolated group, the layers beneath in a
//! pass-through one) or of the clipping group (the base layer's content); for Deep, and for
//! Shallow at the top level, the Background layer (transparency without one). Isolated groups
//! and grouped clipping layers stop a deep knockout at their bottom, as Photoshop renders it
//! (see [`Scope`]); pass-through groups let it through. Fill opacity sets how much of the layer
//! shows over the knocked-out area, layer opacity how strong the whole knockout is.
//! - **Blend Clipped Layers as Group off.** The base composites alone; each clipped layer then
//! composites onto the result in its own mode, kept inside the base's shape (its alpha with
//! masks, times its opacity).
//! - **Transparency Shapes Layer off.** The layer's effects and knockout cover the whole layer
//! (its masks still apply) instead of following its transparency.
//! - **Layer / Vector Mask Hides Effects.** The effects are built from the layer without that
//! mask, and the mask is applied to the finished layer and effects.
//!
//! Blend Interior Effects as Group lives in [`crate::effects`], where interior effects are
//! painted. Every path here is skipped for layers at Photoshop's defaults, so documents that
//! don't use these options render exactly as before at no extra cost.
//!
//! Measured on the psd-tools knockout files (deep/shallow, nested, pass-through, no Background,
//! groups with knockout). Not yet measured against Photoshop: the clip of individually blended
//! clipped layers (base alpha × opacity, ignoring its fill), Transparency Shapes Layer off, and the
//! masks hiding effects.
use std::borrow::Cow;
use photocraft_doc::{Knockout, Layer, LayerContent};
use photocraft_geom::Rect;
use crate::{Buffer, Ctx, composite_atop, composite_layer, effects, mask_vals, render_content};
/// Where a layer stack sits, for knockouts: at the document's top level, and/or inside an
/// isolated (non-pass-through) group. Photoshop stops a deep knockout at the bottom of the
/// innermost isolated group around it (psd-tools knockout-deep-nested renders exactly like
/// knockout-shallow-nested), while pass-through groups let it reach the Background
/// (knockout-deep-nested-pt renders like knockout-deep-normal).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Scope {
/// The document's top-level stack: a shallow knockout there reaches the Background.
pub root: bool,
/// Inside an isolated group: deep knockouts stop at its (transparent) bottom.
pub isolated: bool,
}
impl Scope {
pub const ROOT: Scope = Scope { root: true, isolated: false };
/// An isolated group's own stack (it starts from a transparent buffer).
pub const ISOLATED: Scope = Scope { root: false, isolated: true };
/// The stack of a pass-through group inside this one.
pub fn pass_through(self) -> Scope {
Scope { root: false, isolated: self.isolated }
}
}
/// Premultiplied linear interpolation between two straight-alpha pixels.
#[inline]
fn mix(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
let alpha = a[3] + (b[3] - a[3]) * k;
if alpha <= 0.0 {
return [0.0; 4];
}
let c = |c: usize| ((a[c] * a[3] + (b[c] * b[3] - a[c] * a[3]) * k) / alpha).clamp(0.0, 1.0);
[c(0), c(1), c(2), alpha.min(1.0)]
}
/// Whether any Advanced Blending option changes how `layer` (with its clipping group `clipped`)
/// composites. The GPU compositor sends such documents to this CPU path.
pub fn advanced_active(layer: &Layer, clipped: &[Layer]) -> bool {
let a = &layer.advanced;
if a.is_default() {
return false;
}
let fx = effects::has_effects(layer);
a.knockout != Knockout::None || clips_individually(layer, clipped) || (a.blend_interior && fx) || (!a.transparency_shapes && fx) || hides_effects(layer)
}
/// Whether the layer's effects (or knockout) cover the whole layer: Transparency Shapes Layer
/// off on a layer that has either.
pub fn shapeless(layer: &Layer) -> bool {
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
}
/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer
/// named "Background" (how PSD import and File › New create it).
pub fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
}
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
/// layer (transparency when it has none, or when it is hidden).
pub(crate) fn deep_target(cx: &Ctx, rect: Rect) -> Buffer {
let Some(doc) = cx.doc else { return Buffer::transparent(rect) };
let mut b = crate::multichannel::backdrop(doc, rect);
if let Some(bg) = doc.layers.first().filter(|l| l.visible && is_background(l)) {
composite_layer(bg, &[], &mut b, cx, None, Scope::ROOT);
}
b
}
/// The knockout target of a layer of a stack in `scope`, if it knocks out. `floor` is the
/// stack's starting backdrop (`None` at the top level, where a shallow knockout reaches the
/// Background too).
pub(crate) fn knockout_target<'b>(layer: &Layer, floor: Option<&'b Buffer>, rect: Rect, cx: &Ctx, scope: Scope) -> Option<Cow<'b, Buffer>> {
let deep = || if scope.isolated { Buffer::transparent(rect) } else { deep_target(cx, rect) };
match layer.advanced.knockout {
Knockout::None => None,
Knockout::Shallow => Some(floor.map_or_else(|| Cow::Owned(deep()), Cow::Borrowed)),
Knockout::Deep => Some(Cow::Owned(deep())),
}
}
/// Whether `layers` holds a visible unclipped shallow knockout (its stack then keeps a copy of
/// its starting backdrop).
pub(crate) fn needs_floor(layers: &[Layer]) -> bool {
layers.iter().any(|l| l.visible && !l.clipped && l.advanced.knockout == Knockout::Shallow)
}
/// The shape a layer knocks out over `rect` (row-major): its content's alpha with its masks;
/// with Transparency Shapes Layer off (and for adjustment layers) just the masks.
fn knockout_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec<f32> {
let n = rect.width() as usize * rect.height() as usize;
let masks = || mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]);
if !layer.advanced.transparency_shapes {
return masks();
}
match render_content(layer, rect, cx) {
Some(b) => b.px.iter().map(|p| p[3]).collect(),
None => masks(),
}
}
/// Composite `layer` with `draw` as a knockout onto `target`: inside its shape the backdrop
/// becomes the target, then the layer draws (at its fill opacity), and layer opacity mixes the
/// result with the untouched backdrop. Compositing at opacity `o` is linear in `o`
/// (premultiplied), so with `B` the backdrop, `B'` the knocked-out one and `R'` what `draw`
/// produced at the layer's opacity, the result is `R' + (1 − o)(B − B')`.
///
/// `clip_alpha` keeps alpha from growing (clipped layers stay inside their base's alpha; a
/// knockout to transparency may still lower it).
pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, clip_alpha: bool, cx: &Ctx, draw: impl FnOnce(&mut Buffer)) {
// Coverage knocks out proportionally. (Photoshop knocks out a little more at anti-aliased
// shape edges: psd-tools knockout-isolated-groups differs on 0.45 % of its pixels, along
// ellipse edges; a binary shape matched worse.)
let shape = knockout_shape(layer, backdrop.rect, cx);
if target.rect != backdrop.rect || shape.len() != backdrop.px.len() {
// Never reached (targets are built over the backdrop's rect); compositing without the
// knockout beats a wrong pixel or a panic.
draw(backdrop);
return;
}
let before = backdrop.clone();
for ((p, t), k) in backdrop.px.iter_mut().zip(&target.px).zip(&shape) {
if *k > 0.0 {
let a = p[3];
*p = mix(*p, *t, k.min(1.0));
if clip_alpha {
p[3] = p[3].min(a);
}
}
}
let knocked = (layer.opacity < 1.0).then(|| backdrop.clone());
draw(backdrop);
let Some(knocked) = knocked else { return };
let rest = 1.0 - layer.opacity.clamp(0.0, 1.0);
for (((p, b), k), s) in backdrop.px.iter_mut().zip(&before.px).zip(&knocked.px).zip(&shape) {
if *s <= 0.0 {
continue;
}
let mut pm = [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]];
for c in 0..3 {
pm[c] += rest * (b[c] * b[3] - k[c] * k[3]);
}
pm[3] += rest * (b[3] - k[3]);
let a = pm[3].clamp(0.0, 1.0);
*p = if a > 0.0 { [(pm[0] / a).clamp(0.0, 1.0), (pm[1] / a).clamp(0.0, 1.0), (pm[2] / a).clamp(0.0, 1.0), a] } else { [0.0; 4] };
}
}
/// Composite a clipping group's clipped layers atop `base` (the base's isolated content, Blend
/// Clipped Layers as Group on). The grouped clipping layers composite in isolation, like an
/// isolated group, so a clipped layer's knockout (shallow or deep) stops at the bottom of the
/// clipping group: the base's content.
pub(crate) fn composite_clipped(clipped: &[Layer], base: &mut Buffer, cx: &Ctx) {
let floor = clipped.iter().any(|c| c.visible && c.advanced.knockout != Knockout::None).then(|| base.clone());
for c in clipped.iter().filter(|c| c.visible) {
match &floor {
Some(f) if c.advanced.knockout != Knockout::None => knockout(c, base, f, true, cx, |b| composite_atop(c, b, cx)),
_ => composite_atop(c, base, cx),
}
}
}
/// Whether `layer`'s clipped layers blend one by one (Blend Clipped Layers as Group off).
/// Adjustment layers keep the grouped path (they have no pixels of their own to clip to).
pub(crate) fn clips_individually(layer: &Layer, clipped: &[Layer]) -> bool {
!layer.advanced.blend_clipped && !matches!(layer.content, LayerContent::Adjustment(_)) && clipped.iter().any(|c| c.visible)
}
/// Blend Clipped Layers as Group off: the base alone (with its knockout `ko`), then each visible
/// clipped layer composited in its own mode onto the result, inside the base's shape (its alpha
/// with masks, times its opacity). A clipped layer's shallow knockout stops at the backdrop
/// beneath the base (the bottom of the clipping group).
pub(crate) fn composite_clipped_individually(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, ko: Option<&Buffer>, scope: Scope) {
let rect = backdrop.rect;
let n = backdrop.px.len();
let floor = clipped.iter().any(|c| c.visible && c.advanced.knockout == Knockout::Shallow).then(|| backdrop.clone());
composite_layer(layer, &[], backdrop, cx, ko, scope);
let op = layer.opacity.clamp(0.0, 1.0);
let clip: Vec<f32> = match render_content(layer, rect, cx) {
Some(b) => b.px.iter().map(|p| p[3] * op).collect(),
None => mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]).iter().map(|m| m * op).collect(),
};
if clip.len() != n {
return;
}
for c in clipped.iter().filter(|c| c.visible) {
let before = backdrop.clone();
let t = knockout_target(c, floor.as_ref(), rect, cx, Scope { root: false, ..scope });
composite_layer(c, &[], backdrop, cx, t.as_deref(), scope);
for ((p, b), k) in backdrop.px.iter_mut().zip(&before.px).zip(&clip) {
if *k < 1.0 {
*p = mix(*b, *p, k.max(0.0));
}
}
}
}
/// Whether a mask of `layer` hides its effects instead of shaping them (Layer / Vector Mask
/// Hides Effects on an enabled mask of a layer with effects).
pub(crate) fn hides_effects(layer: &Layer) -> bool {
let a = &layer.advanced;
effects::has_effects(layer)
&& ((a.layer_mask_hides_effects && layer.mask.as_ref().is_some_and(|m| m.enabled))
|| (a.vector_mask_hides_effects && layer.vector_mask.as_ref().is_some_and(|m| m.enabled)))
}
/// The layer whose content and effect maps are composited: `layer` itself, or (when a mask hides
/// its effects) a copy without that mask. Pixels are shared copy-on-write, so the copy costs a
/// tile table, and its effect maps cache under their own key.
pub(crate) fn effects_source(layer: &Layer) -> Cow<'_, Layer> {
if !hides_effects(layer) {
return Cow::Borrowed(layer);
}
let mut l = layer.clone();
if l.advanced.layer_mask_hides_effects {
l.mask = None;
}
if l.advanced.vector_mask_hides_effects {
l.vector_mask = None;
}
Cow::Owned(l)
}
/// The masks that hide `layer`'s effects over `rect` (row-major; `None` when none do).
pub(crate) fn hiding_mask(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>> {
if !hides_effects(layer) {
return None;
}
let a = &layer.advanced;
let mut only = Layer::new(layer.name.clone(), LayerContent::Raster(photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA32F)));
only.id = layer.id;
only.mask = layer.mask.clone().filter(|_| a.layer_mask_hides_effects);
only.vector_mask = layer.vector_mask.clone().filter(|_| a.vector_mask_hides_effects);
mask_vals(&only, rect, cx)
}
/// Mix `out` back towards `before` where `mask` hides the layer: `out ← mix(before, out, mask)`.
pub(crate) fn apply_hiding_mask(out: &mut Buffer, before: &Buffer, mask: &[f32]) {
for ((p, b), k) in out.px.iter_mut().zip(&before.px).zip(mask) {
if *k < 1.0 {
*p = mix(*b, *p, k.max(0.0));
}
}
}
+351
View File
@@ -0,0 +1,351 @@
//! Advanced Blending (knockout, blend clipped / interior as group, transparency shapes, masks
//! hide effects) on small synthetic documents with hand-computed pixels, at 8, 16 and 32 bits.
use super::*;
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Effect, FxCommon, FxPaint, Knockout, LayerMask, StrokeFx, StrokePosition};
use photocraft_geom::Size;
const E: f32 = 2.0 / 255.0;
const DEPTHS: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32];
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
const GRAY: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= E)
}
#[track_caller]
fn check(d: &Document, x: i32, y: i32, want: [f32; 4], what: &str) {
let got = render(d, Rect::from_xywh(x, y, 1, 1)).px[0];
assert!(close(got, want), "{what} ({:?}) at ({x},{y}): got {got:?}, want {want:?}", d.depth);
// Tiled and whole-canvas renders agree (knockout targets are per tile).
let tiled = render_tiled(d, d.bounds(), 3);
let whole = render(d, d.bounds());
assert!(tiled.px.iter().zip(&whole.px).all(|(a, b)| close(*a, *b)), "{what}: tiles differ from the whole render");
}
/// A 12×8 document at `depth` with a white Background (or none).
fn doc(depth: SampleType, background: bool) -> Document {
let size = Size::new(12, 8);
if background { Document::with_background("t", size, ColorMode::Rgb, depth, Color::WHITE) } else { Document::new("t", size, ColorMode::Rgb, depth) }
}
fn solid(d: &Document, name: &str, r: Rect, c: [f32; 4]) -> Layer {
let mut l = Layer::raster(name, d.pixel_format());
l.surface_mut().unwrap().fill_rect(r, &photocraft_raster::from_rgba(&d.pixel_format(), c));
l
}
fn full(d: &Document) -> Rect {
d.bounds()
}
/// The knocking-out layer: blue over the left half (x < 6), fill 0 unless set.
fn knocker(d: &Document, k: Knockout) -> Layer {
let mut l = solid(d, "knock", Rect::new(0, 0, 6, 8), BLUE);
l.advanced.knockout = k;
l.fill_opacity = 0.0;
l
}
#[test]
fn shallow_knockout_stops_at_the_bottom_of_an_isolated_group() {
for depth in DEPTHS {
let mut d = doc(depth, true);
let green = solid(&d, "green", full(&d), GREEN);
let red = solid(&d, "red", full(&d), RED);
let mut g = Layer::group("g", vec![red, knocker(&d, Knockout::Shallow)]);
g.blend = BlendMode::Normal;
d.layers.push(green);
d.layers.push(g);
// The group's own buffer starts transparent: the layers beneath the group show.
check(&d, 2, 2, GREEN, "shallow, fill 0");
check(&d, 9, 2, RED, "outside the shape");
d.layers[2].children_mut().unwrap()[1].fill_opacity = 0.5;
// Blue at 50 % over the knocked-out (transparent) area, then the group over green.
check(&d, 2, 2, [0.0, 0.5, 0.5, 1.0], "shallow, fill 50 %");
// No knockout: blue at 50 % over red.
d.layers[2].children_mut().unwrap()[1].advanced.knockout = Knockout::None;
check(&d, 2, 2, [0.5, 0.0, 0.5, 1.0], "no knockout");
}
}
#[test]
fn deep_knockout_reaches_the_background_through_groups() {
for depth in DEPTHS {
let mut d = doc(depth, true);
let green = solid(&d, "green", full(&d), GREEN);
let red = solid(&d, "red", full(&d), RED);
// Pass-through groups let a deep knockout through to the Background.
let inner = Layer::group("inner", vec![knocker(&d, Knockout::Deep)]);
let outer = Layer::group("outer", vec![red, inner]);
d.layers.push(green);
d.layers.push(outer);
check(&d, 2, 2, WHITE, "deep reveals the Background");
check(&d, 9, 2, RED, "outside the shape");
// An isolated group stops it at its own (transparent) bottom, like a shallow knockout
// (psd-tools knockout-deep-nested): the layers beneath the group show.
d.layers[2].blend = BlendMode::Normal;
check(&d, 2, 2, GREEN, "deep inside an isolated group");
d.layers[2].blend = BlendMode::PassThrough;
d.layers[2].children_mut().unwrap()[1].blend = BlendMode::Normal;
// The knockout is the inner group's only layer: its buffer stays transparent, red shows.
check(&d, 2, 2, RED, "deep inside an isolated inner group");
}
}
#[test]
fn top_level_shallow_knockout_reaches_the_background_or_transparency() {
for depth in DEPTHS {
let mut d = doc(depth, true);
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
d.layers.push(knocker(&d, Knockout::Shallow));
check(&d, 2, 2, WHITE, "top-level shallow");
// Layer opacity sets the knockout's strength.
d.layers[2].opacity = 0.5;
check(&d, 2, 2, [0.5, 1.0, 0.5, 1.0], "half-strength knockout");
// Without a Background a deep knockout reveals transparency.
let mut nobg = doc(depth, false);
let green = solid(&nobg, "green", full(&nobg), GREEN);
nobg.layers.push(green);
nobg.layers.push(knocker(&nobg, Knockout::Deep));
check(&nobg, 2, 2, [0.0; 4], "deep without a Background");
check(&nobg, 9, 2, GREEN, "outside the shape");
// A hidden Background doesn't show through either.
d.layers[0].visible = false;
d.layers[2].opacity = 1.0;
check(&d, 2, 2, [0.0; 4], "hidden Background");
}
}
#[test]
fn shallow_knockout_in_a_pass_through_group_reveals_what_is_beneath_it() {
for depth in DEPTHS {
let mut d = doc(depth, true);
let green = solid(&d, "green", full(&d), GREEN);
let red = solid(&d, "red", full(&d), RED);
let g = Layer::group("pt", vec![red, knocker(&d, Knockout::Shallow)]);
assert_eq!(g.blend, BlendMode::PassThrough);
d.layers.push(green);
d.layers.push(g);
check(&d, 2, 2, GREEN, "pass-through shallow");
d.layers[2].children_mut().unwrap()[1].advanced.knockout = Knockout::Deep;
check(&d, 2, 2, WHITE, "pass-through deep");
}
}
#[test]
fn knockout_on_a_group_uses_its_composite_shape_and_fill() {
// psd-tools knockout-deep-normal: a group with Deep knockout at 50 % fill.
for depth in DEPTHS {
let mut d = doc(depth, true);
let green = solid(&d, "green", full(&d), GREEN);
let blue = solid(&d, "blue", Rect::new(0, 0, 6, 8), BLUE);
let mut g = Layer::group("g", vec![blue]);
g.blend = BlendMode::Normal;
g.fill_opacity = 0.5;
g.advanced.knockout = Knockout::Deep;
d.layers.push(green);
d.layers.push(g);
check(&d, 2, 2, [0.5, 0.5, 1.0, 1.0], "blue at 50 % over the Background");
check(&d, 9, 2, GREEN, "outside the group's pixels");
}
}
#[test]
fn knockout_follows_masks_and_transparency_shapes() {
for depth in DEPTHS {
let mut d = doc(depth, true);
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
let mut k = knocker(&d, Knockout::Deep);
// A mask hiding x >= 3: only x < 3 knocks out.
let mut m = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
m.fill_rect(Rect::new(0, 0, 3, 8), &photocraft_raster::from_rgba(&m.format(), [1.0; 4]));
k.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 });
d.layers.push(k);
check(&d, 1, 2, WHITE, "inside the mask");
check(&d, 4, 2, GREEN, "masked out");
// Transparency Shapes Layer off: the knockout covers the whole layer (masks still apply).
d.layers[2].mask = None;
d.layers[2].advanced.transparency_shapes = false;
check(&d, 9, 2, WHITE, "beyond the pixels, tsly off");
}
}
#[test]
fn clipped_layer_shallow_knockout_stops_at_the_base() {
for depth in DEPTHS {
let mut d = doc(depth, true);
let base = solid(&d, "base", full(&d), RED);
let mut mid = solid(&d, "mid", full(&d), GREEN);
mid.clipped = true;
let mut k = knocker(&d, Knockout::Shallow);
k.clipped = true;
d.layers.push(base);
d.layers.push(mid);
d.layers.push(k);
check(&d, 2, 2, RED, "the base shows through the clipped layer above it");
check(&d, 9, 2, GREEN, "outside");
d.layers[3].advanced.knockout = Knockout::Deep;
check(&d, 2, 2, RED, "clipped deep stops at the base too");
}
}
#[test]
fn blend_clipped_layers_individually() {
for depth in DEPTHS {
let mut d = doc(depth, false);
let gray = solid(&d, "gray", full(&d), GRAY);
let mut base = solid(&d, "base", Rect::new(0, 0, 6, 8), RED);
base.blend = BlendMode::Screen;
let mut clip = solid(&d, "clip", full(&d), BLUE);
clip.clipped = true;
clip.blend = BlendMode::Multiply;
d.layers.push(gray);
d.layers.push(base);
d.layers.push(clip);
// As a group: red × blue = black, screened onto gray = gray.
check(&d, 2, 2, GRAY, "as group");
// One by one: red screened onto gray = (1, .5, .5), then × blue = (0, 0, .5).
d.layers[1].advanced.blend_clipped = false;
check(&d, 2, 2, [0.0, 0.0, 0.5, 1.0], "individually");
check(&d, 9, 2, GRAY, "outside the base");
// The base's opacity limits the clipped layers too.
d.layers[1].opacity = 0.5;
// Base at 50 %: (.75, .5, .5); the multiply then mixes in at 50 %: (.375, .25, .5).
check(&d, 2, 2, [0.375, 0.25, 0.5, 1.0], "half-opacity base");
}
}
fn overlay(c: [f32; 3]) -> Effect {
Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(c[0], c[1], c[2]) }
}
#[test]
fn blend_interior_effects_as_group_puts_overlays_under_fill_opacity() {
for depth in DEPTHS {
let mut d = doc(depth, true);
let mut l = solid(&d, "l", Rect::new(0, 0, 6, 8), BLUE);
l.effects.items = vec![overlay([1.0, 0.0, 0.0])];
l.fill_opacity = 0.0;
d.layers.push(l);
check(&d, 2, 2, RED, "default: overlays ignore fill");
d.layers[1].advanced.blend_interior = true;
check(&d, 2, 2, WHITE, "as group at fill 0");
d.layers[1].fill_opacity = 0.5;
check(&d, 2, 2, [1.0, 0.5, 0.5, 1.0], "as group at fill 50 %");
d.layers[1].advanced.blend_interior = false;
check(&d, 2, 2, RED, "default at fill 50 %");
}
}
#[test]
fn transparency_shapes_off_spreads_effects_over_the_whole_layer() {
for depth in DEPTHS {
let mut d = doc(depth, true);
let mut l = solid(&d, "l", Rect::new(0, 0, 6, 8), BLUE);
l.effects.items = vec![overlay([1.0, 0.0, 0.0])];
d.layers.push(l);
check(&d, 9, 2, WHITE, "default: the overlay follows the pixels");
d.layers[1].advanced.transparency_shapes = false;
check(&d, 9, 2, RED, "tsly off: the overlay covers the layer");
check(&d, 2, 2, RED, "inside");
// At fill 0 the content's transparency no longer matters.
d.layers[1].fill_opacity = 0.0;
check(&d, 9, 2, RED, "tsly off, fill 0");
}
}
fn inside_stroke() -> Effect {
Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 2.0,
position: StrokePosition::Inside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
})
}
#[test]
fn layer_mask_hides_effects() {
for depth in DEPTHS {
let mut d = doc(depth, true);
let mut l = solid(&d, "l", full(&d), BLUE);
l.effects.items = vec![inside_stroke()];
// The mask shows x < 8.
let mut m = photocraft_raster::Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
m.fill_rect(Rect::new(0, 0, 8, 8), &photocraft_raster::from_rgba(&m.format(), [1.0; 4]));
l.mask = Some(LayerMask { surface: m, enabled: true, linked: true, density: 1.0, feather: 0.0 });
d.layers.push(l);
// Default: the stroke follows the masked shape, so it runs along the mask's edge.
check(&d, 7, 4, RED, "stroke along the mask edge");
check(&d, 9, 4, WHITE, "masked out");
check(&d, 4, 4, BLUE, "interior");
// Layer Mask Hides Effects: the stroke is built from the whole layer (canvas edges only)
// and the mask then hides the result.
d.layers[1].advanced.layer_mask_hides_effects = true;
check(&d, 7, 4, BLUE, "no stroke at the mask edge");
check(&d, 4, 0, RED, "the canvas-edge stroke stays");
check(&d, 9, 4, WHITE, "still masked out");
check(&d, 11, 0, WHITE, "the stroke beyond the mask is hidden");
// The vector-mask switch alone changes nothing for a pixel mask.
d.layers[1].advanced.layer_mask_hides_effects = false;
d.layers[1].advanced.vector_mask_hides_effects = true;
check(&d, 7, 4, RED, "vmgm without a vector mask");
}
}
#[test]
fn vector_mask_hides_effects() {
use photocraft_doc::{Path, Subpath, VectorMask};
for depth in DEPTHS {
let mut d = doc(depth, true);
let mut l = solid(&d, "l", full(&d), BLUE);
l.effects.items = vec![inside_stroke()];
let path = Path::new(vec![Subpath::polygon(&[(-4.0, -4.0), (8.0, -4.0), (8.0, 12.0), (-4.0, 12.0)])]);
l.vector_mask = Some(VectorMask::new(path));
d.layers.push(l);
check(&d, 7, 4, RED, "stroke along the vector mask");
d.layers[1].advanced.vector_mask_hides_effects = true;
check(&d, 7, 4, BLUE, "vector mask hides the stroke");
check(&d, 9, 4, WHITE, "masked out");
}
}
#[test]
fn defaults_take_the_plain_paths() {
let d = doc(SampleType::U8, true);
let mut l = solid(&d, "l", full(&d), BLUE);
assert!(!advanced_active(&l, &[]));
l.advanced.knockout = Knockout::Shallow;
assert!(advanced_active(&l, &[]));
l.advanced.knockout = Knockout::None;
// Switches that change nothing for this layer (no effects, no clipped layers) stay inactive.
l.advanced.blend_interior = true;
l.advanced.layer_mask_hides_effects = true;
l.advanced.blend_clipped = false;
assert!(!advanced_active(&l, &[]));
let mut c = solid(&d, "c", full(&d), RED);
c.clipped = true;
assert!(advanced_active(&l, std::slice::from_ref(&c)));
c.visible = false;
assert!(!advanced_active(&l, std::slice::from_ref(&c)));
l.effects.items = vec![overlay([1.0, 0.0, 0.0])];
assert!(advanced_active(&l, &[]), "blend interior with effects");
}
#[test]
fn a_knockout_background_does_not_recurse() {
// A Background carrying a knockout (only reachable through a file) renders without looping.
let mut d = doc(SampleType::U8, true);
d.layers[0].advanced.knockout = Knockout::Deep;
d.layers[0].fill_opacity = 0.0;
let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0];
assert!(got[3] <= 1.0);
}
+18 -4
View File
@@ -1206,13 +1206,18 @@ pub(crate) fn composite_with_effects_prepared(
// unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline.
let kmask = |i: usize| vstroke.as_ref().and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0);
let union = |i: usize, a: f32| vstroke.as_ref().and_then(|v| v.stroke.px.get(i)).map_or(a, |s| kmask(i) * (a + s[3] * (1.0 - a)));
let shape = if maps.outline {
// Transparency Shapes Layer off: the shape is the whole layer (its masks, as the maps were
// built), and the content's own transparency acts like fill opacity within it.
let shapeless = !layer.advanced.transparency_shapes;
let shape = if shapeless {
maps.crop(&maps.shape, big, 0.0)
} else if maps.outline {
let o = maps.crop(&maps.shape, big, 0.0);
Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(union(i, p[3]))).collect() }
} else {
Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| union(i, p[3])).collect() }
};
let relative = maps.outline || vstroke.is_some();
let relative = shapeless || maps.outline || vstroke.is_some();
let fx = |i: usize, k: usize| maps.crop(&maps.per[i][k], big, 0.0);
// Layer bounds (gradients aligned with the layer use the whole layer,
// independent of the render rect).
@@ -1259,6 +1264,10 @@ pub(crate) fn composite_with_effects_prepared(
// applies: a colour overlay at 100 % replaces the colour of a half-transparent edge pixel and
// keeps its alpha, as in Photoshop.
let fill = layer.fill_opacity;
// Blend Interior Effects as Group: the interior effects (overlays, satin, inner glow) are
// combined with the content first, and fill opacity applies to the combination.
let interior_group = layer.advanced.blend_interior && fill < 1.0;
let content_fill = if interior_group { 1.0 } else { fill };
let inside = |a: f32| a > INSIDE_EPS;
// Within an outline (or a split-off vector stroke) the content's own transparency (a fading
// gradient fill) acts like fill opacity: the effects still cover the whole shape.
@@ -1266,9 +1275,9 @@ pub(crate) fn composite_with_effects_prepared(
if !inside(a) {
0.0
} else if relative {
fill * (kmask(i) * p[3] / a).min(1.0)
content_fill * (kmask(i) * p[3] / a).min(1.0)
} else {
fill
content_fill
}
};
let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() };
@@ -1303,6 +1312,11 @@ pub(crate) fn composite_with_effects_prepared(
paint_glow(&mut lay, &rel(fx(i, 0)), g, sb, anchor, big, patterns);
}
}
if interior_group {
for p in &mut lay.px {
p[3] *= fill.max(0.0);
}
}
for (i, e) in rev() {
if let Effect::InnerShadow(s) = e {
let m = rel(fx(i, 0));
+104 -54
View File
@@ -17,6 +17,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod adjust;
pub mod advanced;
pub mod bounds;
pub mod effects;
pub mod fill_layout;
@@ -34,6 +35,8 @@ use photocraft_geom::Rect;
use photocraft_raster::{Rgba8Image, Surface};
use psblend as blend;
pub use advanced::advanced_active;
/// Straight-alpha RGBA float buffer covering a rectangle.
#[derive(Clone, Debug, PartialEq)]
pub struct Buffer {
@@ -105,7 +108,7 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
return photocraft_color::convert::with_cmyk_space(cmyk, || {
let mut buf = multichannel::backdrop(doc, rect);
psblend::LAB_MIX.with(|l| l.set(lab));
composite_stack(&doc.layers, &mut buf, cx);
composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT);
psblend::LAB_MIX.with(|l| l.set(false));
buf
});
@@ -122,7 +125,7 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
photocraft_color::convert::with_cmyk_space(cmyk, || {
let mut b = multichannel::backdrop(doc, t);
psblend::LAB_MIX.with(|l| l.set(lab));
composite_stack(&doc.layers, &mut b, cx);
composite_stack_at(&doc.layers, &mut b, cx, advanced::Scope::ROOT);
psblend::LAB_MIX.with(|l| l.set(false));
b
})
@@ -257,7 +260,7 @@ pub fn flatten(doc: &Document) -> Buffer {
pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
let mut buf = Buffer::transparent(rect);
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
composite_stack(
composite_stack_at(
std::slice::from_ref(layer),
&mut buf,
&Ctx {
@@ -269,7 +272,9 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
depth: photocraft_color::SampleType::F32,
vector_masks: RenderVectorMasks::default(),
fx_maps: Default::default(),
doc: None,
},
advanced::Scope::ROOT,
);
buf
}
@@ -439,11 +444,14 @@ struct Ctx<'a> {
/// Effect maps used by this render, by cache key: built before the parallel tiles (see
/// [`prepare_effects`]) and held for the whole render, so eviction can't force a rebuild.
fx_maps: std::sync::Mutex<std::collections::HashMap<u64, std::sync::Arc<effects::FxMaps>>>,
/// The document being rendered (deep knockouts reveal its Background); `None` for a lone layer.
doc: Option<&'a Document>,
}
impl<'a> Ctx<'a> {
fn for_doc(doc: &Document, patterns: &'a pattern::PreparedPatterns<'a>) -> Self {
fn for_doc(doc: &'a Document, patterns: &'a pattern::PreparedPatterns<'a>) -> Self {
Self {
doc: Some(doc),
canvas: doc.bounds(),
transfer: adjust::Transfer::for_document(doc.mode, doc.depth),
light: doc.global_light,
@@ -467,7 +475,7 @@ const PREPARE_DEPTH: u32 = 64;
fn prepare_effects(layers: &[Layer], rect: Rect, cx: &Ctx, setting: impl Fn(&mut dyn FnMut()) + Sync) {
let mut todo = Vec::new();
effect_layers(layers, rect, 0, &mut todo);
let build = |l: &&Layer| {
let build = |l: &std::borrow::Cow<Layer>| {
setting(&mut || {
let _ = effect_maps(l, cx);
});
@@ -482,7 +490,7 @@ fn prepare_effects(layers: &[Layer], rect: Rect, cx: &Ctx, setting: impl Fn(&mut
}
/// The visible layers of `layers` (groups included) with effects that can reach `rect`.
fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<&'l Layer>) {
fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<std::borrow::Cow<'l, Layer>>) {
let mut base_visible = true;
for l in layers {
if !l.clipped {
@@ -493,7 +501,8 @@ fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<
continue;
}
if effects::has_effects(l) && !empty_in(l, rect) {
out.push(l);
// A mask that hides the effects is left out of the shape they are built from.
out.push(advanced::effects_source(l));
}
if let LayerContent::Group(g) = &l.content
&& depth < PREPARE_DEPTH
@@ -503,8 +512,16 @@ fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<
}
}
/// Composite a sibling list (bottom→top) onto `backdrop`.
/// Composite an isolated group's sibling list (bottom→top) onto `backdrop` (its own buffer).
fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
composite_stack_at(layers, backdrop, cx, advanced::Scope::ISOLATED);
}
/// Composite a sibling list (bottom→top) onto `backdrop`; `scope` says where the stack sits (see
/// [`advanced::Scope`]: it decides what knockouts reveal).
fn composite_stack_at(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
// Shallow knockouts stop at the bottom of this stack: the backdrop it starts from.
let floor = (!scope.root && advanced::needs_floor(layers)).then(|| backdrop.clone());
let mut i = 0;
while i < layers.len() {
let base = &layers[i];
@@ -515,7 +532,8 @@ fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
}
let clipped = &layers[i + 1..j];
if base.visible {
composite_layer(base, clipped, backdrop, cx);
let ko = advanced::knockout_target(base, floor.as_ref(), backdrop.rect, cx, scope);
composite_layer(base, clipped, backdrop, cx, ko.as_deref(), scope);
if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, adjustment_quantum(cx.depth)) {
quantize(backdrop, q);
}
@@ -559,6 +577,10 @@ fn dissolve_noise(x: i32, y: i32) -> f32 {
/// Bounds of a layer's own pixels (union over group children; the canvas
/// for fill layers; an artboard's board).
pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
// Transparency Shapes Layer off: effects and knockout cover the whole layer (the canvas).
if advanced::shapeless(layer) {
return canvas;
}
match &layer.content {
LayerContent::Group(g) if g.artboard.is_some() => g.artboard.as_ref().map_or(Rect::EMPTY, |a| a.rect),
LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| {
@@ -603,6 +625,11 @@ pub fn effect_outline(layer: &Layer) -> Option<&photocraft_doc::vector::Path> {
/// path where the fill fades out (psd-tools stroke-effects).
fn effect_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec<f32> {
let n = rect.width() as usize * rect.height() as usize;
// Transparency Shapes Layer off: the effects take the whole layer as their shape (its masks
// still apply).
if !layer.advanced.transparency_shapes {
return mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]);
}
let Some(path) = effect_outline(layer).filter(|_| n > 0) else {
return render_content(layer, rect, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; n]);
};
@@ -994,12 +1021,20 @@ fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
}
/// 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) {
/// and Blend If, and knocking out to `ko` when it has a knockout (see [`advanced`]).
fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, ko: Option<&Buffer>, scope: advanced::Scope) {
// Blend Clipped Layers as Group off: the base alone, then each clipped layer in its own mode.
if advanced::clips_individually(layer, clipped) {
advanced::composite_clipped_individually(layer, clipped, backdrop, cx, ko, scope);
return;
}
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);
match ko {
Some(target) => advanced::knockout(layer, backdrop, target, false, cx, |b| composite_layer_any(layer, clipped, b, cx, scope)),
None => composite_layer_any(layer, clipped, backdrop, cx, scope),
}
if let Some(before) = &before {
if let Some(w) = w {
restore_channels(backdrop, before, w);
@@ -1010,19 +1045,19 @@ fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx:
}
}
fn composite_layer_any(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
fn composite_layer_any(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
if let LayerContent::Group(g) = &layer.content
&& let Some(ab) = &g.artboard
{
composite_artboard(layer, ab, clipped, backdrop, cx);
composite_artboard(layer, ab, clipped, backdrop, cx, scope);
return;
}
composite_layer_plain(layer, clipped, backdrop, cx);
composite_layer_plain(layer, clipped, backdrop, cx, scope);
}
/// An artboard: its background and the group, composited only inside the board (contents and
/// effects outside it are clipped away; the backdrop there is untouched).
fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
let board = ab.rect.intersect(&backdrop.rect);
if board.is_empty() {
return;
@@ -1038,7 +1073,7 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L
if let Some(bg) = ab.background.rgba() {
blend_into(&mut sub, &Buffer::filled(board, bg), BlendMode::Normal, 1.0);
}
composite_layer_plain(layer, clipped, &mut sub, cx);
composite_layer_plain(layer, clipped, &mut sub, cx, scope);
for y in board.y0..board.y1 {
let o = row0(y);
let so = (y - board.y0) as usize * bw;
@@ -1046,7 +1081,7 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L
}
}
fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
let rect = backdrop.rect;
if empty_in(layer, rect) {
return;
@@ -1065,7 +1100,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
let has_clipped = clipped.iter().any(|c| c.visible);
// Children can draw directly unless mixing or clipping needs the original backdrop.
let before = (needs_mix || has_clipped).then(|| backdrop.clone());
composite_stack(&g.children, backdrop, cx);
composite_stack_at(&g.children, backdrop, cx, scope.pass_through());
if needs_mix && let Some(before) = &before {
let mv = mask_vals(layer, rect, cx);
for (i, (p, a)) in backdrop.px.iter_mut().zip(&before.px).enumerate() {
@@ -1090,9 +1125,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
// otherwise (matches psd-tools clipping-mask3/4/5).
if has_clipped && let (Some(before), Some(iso)) = (before, render_content(layer, rect, cx)) {
let mut clipped_iso = iso.clone();
for c in clipped.iter().filter(|c| c.visible) {
composite_atop(c, &mut clipped_iso, cx);
}
advanced::composite_clipped(clipped, &mut clipped_iso, cx);
let mut without = before.clone();
blend_into(&mut without, &iso, BlendMode::Normal, opacity);
let mut with = before;
@@ -1117,9 +1150,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
let mut adjusted = backdrop.clone();
adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth));
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
for c in clipped.iter().filter(|c| c.visible) {
composite_atop(c, &mut adjusted, cx);
}
advanced::composite_clipped(clipped, &mut adjusted, cx);
let mv = mask_vals(layer, rect, cx);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
@@ -1139,36 +1170,19 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
}
if effects::has_effects(layer) {
// Neighbourhoods are already captured by the full-region effect maps;
// content and effect application only need the output rectangle.
// A stroked shape's vector stroke stays above its clipped layers and interior effects.
let (mut content, vstroke) = match split_parts(layer, rect, cx) {
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
None => {
let Some(content) = render_content(layer, rect, cx) else { return };
(content, None)
}
};
for c in clipped.iter().filter(|c| c.visible) {
composite_atop(c, &mut content, cx);
// Layer / Vector Mask Hides Effects: the effects are built without that mask, which then
// hides the finished layer and effects.
let src = advanced::effects_source(layer);
let hide = advanced::hiding_mask(layer, rect, cx);
let before = hide.as_ref().map(|_| backdrop.clone());
composite_layer_effects(&src, clipped, backdrop, cx);
if let (Some(m), Some(b)) = (&hide, &before) {
advanced::apply_hiding_mask(backdrop, b, m);
}
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
let maps = effect_maps(layer, cx);
effects::composite_with_effects_prepared(
layer,
&content,
backdrop,
&maps,
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
cx.patterns,
vstroke,
);
return;
}
if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) {
for c in clipped.iter().filter(|c| c.visible) {
composite_atop(c, &mut content, cx);
}
advanced::composite_clipped(clipped, &mut content, cx);
for (i, (p, s)) in content.px.iter_mut().zip(&stroke.px).enumerate() {
*p = psblend::composite(BlendMode::Normal, *p, *s, 1.0);
p[3] *= mask_k(&mask, i);
@@ -1177,12 +1191,37 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
return;
}
let Some(mut content) = render_content(layer, rect, cx) else { return };
for c in clipped.iter().filter(|c| c.visible) {
composite_atop(c, &mut content, cx);
}
advanced::composite_clipped(clipped, &mut content, cx);
blend_into_g(backdrop, &content, layer.blend, opacity, text_gamma(layer));
}
/// A layer with effects (and its clipping group) onto `backdrop`.
fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
let rect = backdrop.rect;
// Neighbourhoods are already captured by the full-region effect maps;
// content and effect application only need the output rectangle.
// A stroked shape's vector stroke stays above its clipped layers and interior effects.
let (mut content, vstroke) = match split_parts(layer, rect, cx) {
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
None => {
let Some(content) = render_content(layer, rect, cx) else { return };
(content, None)
}
};
advanced::composite_clipped(clipped, &mut content, cx);
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
let maps = effect_maps(layer, cx);
effects::composite_with_effects_prepared(
layer,
&content,
backdrop,
&maps,
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
cx.patterns,
vstroke,
);
}
/// The coverage-mixing gamma of a layer: the text blending gamma
/// ([`psblend::set_text_gamma`], Photoshop's default 1.45) for type layers, else 1.
pub fn text_gamma(layer: &Layer) -> f32 {
@@ -1443,6 +1482,10 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
// Effects of a clipped layer are clipped to the base too: render
// them over the base (treated as opaque) and keep the base's alpha.
// The full-region effect maps already capture their neighbourhoods.
// A mask that hides the effects applies to the finished result instead.
let hide = advanced::hiding_mask(layer, rect, cx);
let src = advanced::effects_source(layer);
let layer = src.as_ref();
let (content, vstroke) = match split_parts(layer, rect, cx) {
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
None => {
@@ -1462,6 +1505,10 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
cx.patterns,
vstroke,
);
if let Some(m) = &hide {
let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
advanced::apply_hiding_mask(&mut opaque, &plain, m);
}
for (p, o) in base.px.iter_mut().zip(&opaque.px) {
if p[3] > 0.0 {
*p = [o[0], o[1], o[2], p[3]];
@@ -1515,3 +1562,6 @@ fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f
mod stroke_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod advanced_tests;
+152
View File
@@ -0,0 +1,152 @@
//! Layer Style › Blending Options › Advanced Blending: knockout and the "as group" / "hides
//! effects" switches. Fill opacity ([`crate::Layer::fill_opacity`]), the channel restrictions
//! ([`crate::Layer::excluded_channels`]) and Blend If ([`crate::Layer::blend_if`]) live on the
//! layer itself; this struct holds the rest of the section.
//!
//! Defaults are Photoshop's: no knockout, interior effects blended separately, clipped layers
//! blended as a group, transparency shaping the layer's effects and knockout, and masks applied
//! before the effects are built (so the effects follow the masked shape).
//!
//! PSD: `knko` (0 none, 1 shallow, 2 deep), `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`, each a
//! one-byte flag followed by three bytes of padding (Adobe Photoshop File Format specification,
//! "Additional Layer Information").
use serde::{Deserialize, Serialize};
/// Blending Options › Advanced Blending › Knockout: which layers beneath this one its shape
/// punches through. Fill opacity controls the strength (0 % fill shows the knocked-out area bare).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Knockout {
/// The layer blends with everything beneath it (the default).
#[default]
None,
/// Knocks out to the bottom of the enclosing layer group or clipping group (for a layer at
/// the top level: to the Background layer, or to transparency without one).
Shallow,
/// Knocks out to the Background layer (to transparency without one), through every group.
Deep,
}
impl Knockout {
/// The PSD `knko` value (0 none, 1 shallow, 2 deep).
pub fn to_psd(self) -> u8 {
match self {
Knockout::None => 0,
Knockout::Shallow => 1,
Knockout::Deep => 2,
}
}
/// From a PSD `knko` value; values above 2 (not written by Photoshop) read as deep.
pub fn from_psd(v: u8) -> Self {
match v {
0 => Knockout::None,
1 => Knockout::Shallow,
_ => Knockout::Deep,
}
}
/// The parameter / UI name (`none`, `shallow`, `deep`).
pub fn name(self) -> &'static str {
match self {
Knockout::None => "none",
Knockout::Shallow => "shallow",
Knockout::Deep => "deep",
}
}
/// Inverse of [`Knockout::name`] (case-insensitive).
pub fn from_name(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"none" => Some(Knockout::None),
"shallow" => Some(Knockout::Shallow),
"deep" => Some(Knockout::Deep),
_ => None,
}
}
}
/// The Advanced Blending switches of a layer (see the module docs). `Default` is Photoshop's.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AdvancedBlending {
/// Knockout: None / Shallow / Deep. PSD `knko`.
pub knockout: Knockout,
/// Blend Interior Effects as Group: Inner Glow, Satin and the Color, Gradient and Pattern
/// Overlays are combined with the layer's content before its fill opacity and blend mode
/// apply (off: fill opacity leaves them alone). PSD `infx`, default off.
pub blend_interior: bool,
/// Blend Clipped Layers as Group: the clipping group composites as a whole in the base
/// layer's blend mode (off: each clipped layer blends with the layers beneath the base in its
/// own mode, inside the base's shape). PSD `clbl`, default on.
pub blend_clipped: bool,
/// Transparency Shapes Layer: the layer's transparency limits its effects and knockout (off:
/// they cover the whole layer). PSD `tsly`, default on.
pub transparency_shapes: bool,
/// Layer Mask Hides Effects: the layer mask hides the effects instead of shaping them. PSD
/// `lmgm` ("layer mask as global mask"), default off.
pub layer_mask_hides_effects: bool,
/// Vector Mask Hides Effects: the vector mask hides the effects instead of shaping them. PSD
/// `vmgm`, default off.
pub vector_mask_hides_effects: bool,
}
impl Default for AdvancedBlending {
fn default() -> Self {
AdvancedBlending {
knockout: Knockout::None,
blend_interior: false,
blend_clipped: true,
transparency_shapes: true,
layer_mask_hides_effects: false,
vector_mask_hides_effects: false,
}
}
}
impl AdvancedBlending {
/// Whether every switch is at Photoshop's default.
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_photoshop() {
let a = AdvancedBlending::default();
assert_eq!(a.knockout, Knockout::None);
assert!(!a.blend_interior);
assert!(a.blend_clipped);
assert!(a.transparency_shapes);
assert!(!a.layer_mask_hides_effects);
assert!(!a.vector_mask_hides_effects);
assert!(a.is_default());
assert!(!AdvancedBlending { blend_clipped: false, ..a }.is_default());
}
#[test]
fn knockout_psd_values_and_names() {
for k in [Knockout::None, Knockout::Shallow, Knockout::Deep] {
assert_eq!(Knockout::from_psd(k.to_psd()), k);
assert_eq!(Knockout::from_name(k.name()), Some(k));
}
assert_eq!(Knockout::from_psd(7), Knockout::Deep);
assert_eq!(Knockout::from_name("DEEP"), Some(Knockout::Deep));
assert_eq!(Knockout::from_name("medium"), None);
}
#[test]
fn partial_json_keeps_photoshop_defaults() {
let a: AdvancedBlending = serde_json::from_str(r#"{"knockout":"deep"}"#).unwrap();
assert_eq!(a, AdvancedBlending { knockout: Knockout::Deep, ..Default::default() });
let b: AdvancedBlending = serde_json::from_str("{}").unwrap();
assert!(b.is_default());
let json = serde_json::to_string(&AdvancedBlending { layer_mask_hides_effects: true, ..Default::default() }).unwrap();
assert!(json.contains("\"layerMaskHidesEffects\":true"), "{json}");
}
}
+6
View File
@@ -9,6 +9,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod adjust;
pub mod advanced;
pub mod analysis;
pub mod blend_if;
pub mod comps;
@@ -26,6 +27,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
pub use adjust::Adjustment;
pub use advanced::{AdvancedBlending, Knockout};
pub use analysis::{CountGroup, Measurement, MeasurementScale, Note, Ruler};
pub use blend_if::{BlendIf, BlendRange};
pub use comps::{Artboard, ArtboardBackground, CompAppearance, CompLayerState, LayerComp};
@@ -495,6 +497,9 @@ pub struct Layer {
/// outside which the layer's pixels are hidden. Default = everything blends. PSD layer-record
/// blending ranges.
pub blend_if: BlendIf,
/// Blending Options › Advanced Blending: knockout, blend interior effects / clipped layers as
/// group, transparency shapes layer, layer / vector mask hides effects. Default = Photoshop's.
pub advanced: AdvancedBlending,
/// Layer › Video Layers frame stack (None for a normal layer).
pub video: Option<VideoData>,
}
@@ -521,6 +526,7 @@ impl Layer {
link_group: None,
excluded_channels: 0,
blend_if: BlendIf::default(),
advanced: AdvancedBlending::default(),
video: None,
}
}
+3 -2
View File
@@ -664,7 +664,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| {
let id = layer_param(s, p)?;
let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?;
let style = (l.effects.clone(), l.blend, l.fill_opacity);
let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced);
s.style_clipboard = Some(style);
Ok(Value::Null)
}),
@@ -680,12 +680,13 @@ pub fn specs() -> Vec<CommandSpec> {
},
|s, p| {
let id = layer_param(s, p)?;
let (fx, blend, fill) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
let (fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
s.edit("Paste Layer Style", |doc, _| {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects = fx;
l.blend = blend;
l.fill_opacity = fill;
l.advanced = advanced;
Ok(())
})?;
Ok(Value::Null)
+4
View File
@@ -112,6 +112,10 @@ 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::<Vec<_>>());
}
// Blending Options › Advanced Blending switches (only when one differs from Photoshop's default).
if !l.advanced.is_default() {
v["advancedBlending"] = serde_json::to_value(l.advanced).unwrap_or(Value::Null);
}
// 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() {
+84 -2
View File
@@ -5,7 +5,7 @@
use photocraft_algo::selection::Region;
use photocraft_color::{BlendMode, ColorMode};
use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode};
use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Knockout, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode};
use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
use serde_json::{Value, json};
@@ -396,6 +396,7 @@ fn blending_options(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
Some(v) => Some(blend_if_entries(v, mode)?),
};
let adv = advanced_params(p, mode)?;
s.edit("Blending Options", |doc, _| {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let Some(b) = blend {
@@ -421,11 +422,92 @@ fn blending_options(s: &mut Session, p: &Value) -> Result<Value> {
}
None => {}
}
adv.apply(l);
Ok(())
})?;
Ok(Value::Null)
}
/// The Advanced Blending parameters of `layer.layerStyle.blendingOptions`; `None` = keep.
#[derive(Default)]
struct AdvancedParams {
knockout: Option<Knockout>,
blend_interior: Option<bool>,
blend_clipped: Option<bool>,
transparency_shapes: Option<bool>,
layer_mask_hides: Option<bool>,
vector_mask_hides: Option<bool>,
/// Excluded-channel bit mask (from `channels`).
excluded: Option<u32>,
}
impl AdvancedParams {
fn apply(&self, l: &mut Layer) {
let a = &mut l.advanced;
if let Some(k) = self.knockout {
a.knockout = k;
}
let set = |dst: &mut bool, v: Option<bool>| {
if let Some(v) = v {
*dst = v;
}
};
set(&mut a.blend_interior, self.blend_interior);
set(&mut a.blend_clipped, self.blend_clipped);
set(&mut a.transparency_shapes, self.transparency_shapes);
set(&mut a.layer_mask_hides_effects, self.layer_mask_hides);
set(&mut a.vector_mask_hides_effects, self.vector_mask_hides);
if let Some(x) = self.excluded {
l.excluded_channels = x;
}
}
}
/// Parse and validate the Advanced Blending keys (wrong types and unknown values are errors).
fn advanced_params(p: &Value, mode: ColorMode) -> Result<AdvancedParams> {
const CMD: &str = "layer.layerStyle.blendingOptions";
let flag = |key: &str| -> Result<Option<bool>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(v) => Err(bad(CMD, format!("{key}: expected true or false (got {v})"))),
}
};
let knockout = match p.get("knockout") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(Knockout::from_name(s).ok_or_else(|| bad(CMD, format!("knockout: expected none, shallow or deep (got `{s}`)")))?),
Some(v) => return Err(bad(CMD, format!("knockout: expected \"none\", \"shallow\" or \"deep\" (got {v})"))),
};
let excluded = match p.get("channels") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) => {
let n = mode.color_channels().max(1);
if a.len() > n {
return Err(bad(CMD, format!("channels: a {mode:?} document has {n} colour channel(s), got {}", a.len())));
}
let mut mask = 0u32;
for (i, v) in a.iter().enumerate() {
match v {
Value::Bool(true) => {}
Value::Bool(false) => mask |= 1 << i,
other => return Err(bad(CMD, format!("channels[{i}]: expected true or false (got {other})"))),
}
}
Some(mask)
}
Some(v) => return Err(bad(CMD, format!("channels: expected a list of true/false per colour channel (got {v})"))),
};
Ok(AdvancedParams {
knockout,
blend_interior: flag("blendInteriorEffectsAsGroup")?,
blend_clipped: flag("blendClippedLayersAsGroup")?,
transparency_shapes: flag("transparencyShapesLayer")?,
layer_mask_hides: flag("layerMaskHidesEffects")?,
vector_mask_hides: flag("vectorMaskHidesEffects")?,
excluded,
})
}
/// One `blendIf` entry: (range index, This Layer, Underlying Layer); `None` = keep.
type BlendIfEntry = (usize, Option<BlendRange>, Option<BlendRange>);
@@ -761,7 +843,7 @@ pub fn specs() -> Vec<CommandSpec> {
"layer.layerStyle.blendingOptions",
"Blending Options…",
["Layer", "Layer Style"],
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)"##,
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?,"channels":[bool,…]?,"knockout":"none|shallow|deep"?,"blendInteriorEffectsAsGroup":bool?,"blendClippedLayersAsGroup":bool?,"transparencyShapesLayer":bool?,"layerMaskHidesEffects":bool?,"vectorMaskHidesEffects":bool?} (Blend If values 0..255; split points fade; null resets. Advanced Blending: channels = which colour channels blend (R G B / C M Y K / L a b); Photoshop's defaults are knockout none, interior effects off, clipped layers as group on, transparency shapes on, masks hide effects off)"##,
has_layer,
blending_options
),
@@ -202,6 +202,101 @@ fn blend_if_rejects_bad_params() {
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
#[test]
fn blending_options_advanced_blending() {
use photocraft_doc::{AdvancedBlending, Knockout};
for depth in DEPTHS {
let mut s = session(depth, "rgb");
paint(&mut s, |x, _| if x < 20 { [0.0, 0.0, 1.0, 1.0] } else { [0.0; 4] });
assert!(active(&s).advanced.is_default());
s.execute(
"layer.layerStyle.blendingOptions",
json!({
"knockout": "deep",
"fillOpacity": 0,
"blendInteriorEffectsAsGroup": true,
"blendClippedLayersAsGroup": false,
"transparencyShapesLayer": false,
"layerMaskHidesEffects": true,
"vectorMaskHidesEffects": true,
"channels": [true, false, true],
}),
)
.unwrap();
let l = active(&s);
assert_eq!(
l.advanced,
AdvancedBlending {
knockout: Knockout::Deep,
blend_interior: true,
blend_clipped: false,
transparency_shapes: false,
layer_mask_hides_effects: true,
vector_mask_hides_effects: true,
}
);
assert_eq!(l.excluded_channels, 0b010);
// A deep knockout at fill 0 with no Background reveals transparency where the layer has
// pixels.
let p = photocraft_compose::flatten(doc(&s)).get(5, 5);
assert!(p[3] < 2.0 / 255.0, "{depth}: {p:?}");
// Inspect reports the switches; omitted keys keep their values; one undo step each.
let info = crate::inspect::layer(active(&s));
assert_eq!(info["advancedBlending"]["knockout"], json!("deep"), "{info}");
s.execute("layer.layerStyle.blendingOptions", json!({"knockout": "Shallow"})).unwrap();
assert_eq!(active(&s).advanced.knockout, Knockout::Shallow);
assert!(!active(&s).advanced.blend_clipped);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(active(&s).advanced.knockout, Knockout::Deep);
s.execute("edit.undo", json!({})).unwrap();
assert!(active(&s).advanced.is_default());
assert_eq!(active(&s).excluded_channels, 0);
}
}
#[test]
fn blending_options_advanced_rejects_bad_params() {
let mut s = session(8, "rgb");
paint(&mut s, |_, _| [1.0; 4]);
let steps = s.active().unwrap().history.past_len();
for bad in [
json!({"knockout": "medium"}),
json!({"knockout": 2}),
json!({"knockout": true}),
json!({"blendInteriorEffectsAsGroup": "yes"}),
json!({"blendClippedLayersAsGroup": 1}),
json!({"transparencyShapesLayer": []}),
json!({"layerMaskHidesEffects": {}}),
json!({"vectorMaskHidesEffects": 0.5}),
json!({"channels": [true, true, true, true, true]}),
json!({"channels": [1, 0, 1]}),
json!({"channels": "rgb"}),
// A bad key fails the whole call: nothing else is applied.
json!({"fillOpacity": 10, "knockout": "nope"}),
] {
assert!(s.execute("layer.layerStyle.blendingOptions", bad.clone()).is_err(), "{bad}");
}
assert!(active(&s).advanced.is_default());
assert_eq!(active(&s).fill_opacity, 1.0);
assert_eq!(s.active().unwrap().history.past_len(), steps);
// `null` keeps the current value.
s.execute("layer.layerStyle.blendingOptions", json!({"knockout": null, "channels": null})).unwrap();
assert!(active(&s).advanced.is_default());
}
#[test]
fn copy_paste_layer_style_carries_advanced_blending() {
let mut s = session(8, "rgb");
paint(&mut s, |_, _| [1.0; 4]);
s.execute("layer.layerStyle.blendingOptions", json!({"knockout": "shallow", "blendClippedLayersAsGroup": false})).unwrap();
let a = active(&s).advanced;
s.execute("layer.layerStyle.copyLayerStyle", json!({})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
assert!(active(&s).advanced.is_default());
s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap();
assert_eq!(active(&s).advanced, a);
}
#[test]
fn blend_if_channel_names_follow_the_mode() {
let mut s = session(8, "cmyk");
+2
View File
@@ -477,6 +477,7 @@ fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool {
link_group: _,
excluded_channels,
blend_if,
advanced,
video: _,
} = a;
*id == b.id
@@ -490,6 +491,7 @@ fn layer_pixels_equal(a: &Layer, b: &Layer) -> bool {
&& *effects == b.effects
&& *excluded_channels == b.excluded_channels
&& *blend_if == b.blend_if
&& *advanced == b.advanced
&& match (content, &b.content) {
(LayerContent::Raster(x), LayerContent::Raster(y)) => surfaces_equal(x, y),
// Children are walked separately; only the group's own clipping frame counts.
+3 -2
View File
@@ -272,8 +272,9 @@ pub struct Session {
coalesce_request: Option<String>,
/// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop).
pub clipboard: Option<edit_cmds::Clip>,
/// Layer › Layer Style › Copy Layer Style: effects, blend mode and fill opacity.
pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32)>,
/// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced
/// Blending switches.
pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>,
/// Shape path context menu: copied vector fill and stroke styles.
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>,
+2
View File
@@ -197,6 +197,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(),
advanced: l.advanced,
video: l.video.as_ref().map(|v| video_m(v, sink)),
}
}
@@ -488,6 +489,7 @@ impl Loader<'_> {
link_group: m.link_group,
excluded_channels: m.excluded_channels,
blend_if: m.blend_if.clone(),
advanced: m.advanced,
video: m.video.as_ref().map(|v| self.video(v)).transpose()?,
})
}
+4
View File
@@ -223,6 +223,10 @@ pub struct LayerM {
/// Blend If ranges (empty = everything blends).
#[serde(default, skip_serializing_if = "photocraft_doc::BlendIf::is_default")]
pub blend_if: photocraft_doc::BlendIf,
/// Advanced Blending switches (knockout, as-group, transparency shapes, masks hide effects);
/// omitted at Photoshop's defaults, and missing in older files = the defaults.
#[serde(default, skip_serializing_if = "photocraft_doc::AdvancedBlending::is_default")]
pub advanced: photocraft_doc::AdvancedBlending,
#[serde(default)]
pub video: Option<VideoDataM>,
}
+39
View File
@@ -391,6 +391,45 @@ fn blend_if_roundtrips() {
assert!(back.layers.iter().skip(1).all(|l| l.blend_if.is_default()));
}
/// Advanced Blending (knockout, as-group switches, transparency shapes, masks hide effects)
/// survives .pcraft at every depth; manifests written before the field existed load with
/// Photoshop's defaults, and default layers don't write it.
#[test]
fn advanced_blending_roundtrips_and_defaults_when_absent() {
use photocraft_doc::{AdvancedBlending, Knockout};
for depth in SampleType::ALL {
let mut doc = rich_doc(ColorMode::Rgb, depth);
let a = AdvancedBlending {
knockout: Knockout::Deep,
blend_interior: true,
blend_clipped: false,
transparency_shapes: false,
layer_mask_hides_effects: true,
vector_mask_hides_effects: true,
};
doc.layers[0].advanced = a;
if let Some(l) = doc.layers.get_mut(1) {
l.advanced.knockout = Knockout::Shallow;
}
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
let back = load_from_bytes(&bytes).unwrap();
assert_eq!(back, doc, "{depth:?}");
assert_eq!(back.layers[0].advanced, a);
// Only the non-default layers carry the field.
let m = read_manifest(&bytes).unwrap();
let v = serde_json::to_value(&m.document).unwrap();
let with = v["layers"].as_array().unwrap().iter().filter(|l| l.get("advanced").is_some()).count();
assert_eq!(with, if doc.layers.len() > 1 { 2 } else { 1 });
// An older manifest (no `advanced`) loads with the defaults.
let mut old = v.clone();
for l in old["layers"].as_array_mut().unwrap() {
l.as_object_mut().unwrap().remove("advanced");
}
let old: photocraft_format::manifest::DocM = serde_json::from_value(old).unwrap();
assert!(old.layers.iter().all(|l| l.advanced.is_default()));
}
}
#[test]
fn video_layer_frames_survive_roundtrip() {
use photocraft_doc::{Timeline, VideoData, VideoSource};
+45
View File
@@ -489,9 +489,21 @@ impl<'a> Planner<'a> {
Ok(())
}
/// Blending Options › Advanced Blending (knockout, clipped layers blended individually, blend
/// interior effects as group, transparency shapes off, masks hiding effects) has no GPU pass:
/// documents using it are composited on the CPU (`photocraft_compose::advanced`). Layers at
/// Photoshop's defaults pass this check for free.
fn check_advanced(&self, layer: &Layer, clipped: &[Layer]) -> Result<(), Unsupported> {
if photocraft_compose::advanced_active(layer, clipped) {
return Err(Unsupported(format!("Advanced Blending 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<Slot, Unsupported> {
self.check_blend_if(layer)?;
self.check_advanced(layer, clipped)?;
match photocraft_compose::channel_weights(layer, self.cx.mode) {
Some(w) => {
let before = self.retain(backdrop);
@@ -665,6 +677,7 @@ impl<'a> Planner<'a> {
// Nothing to draw (an empty layer, or a group of them).
for c in &visible_clipped {
self.check_blend_if(c)?;
self.check_advanced(c, &[])?;
}
return Ok(backdrop);
}
@@ -836,6 +849,7 @@ impl<'a> Planner<'a> {
/// layer's channel restrictions.
fn atop(&mut self, layer: &'a Layer, base: Slot) -> Result<Slot, Unsupported> {
self.check_blend_if(layer)?;
self.check_advanced(layer, &[])?;
match photocraft_compose::channel_weights(layer, self.cx.mode) {
Some(w) => {
let before = self.retain(base);
@@ -1739,4 +1753,35 @@ mod tests {
d.layers.push(l);
assert!(plan(&d).is_err());
}
#[test]
fn advanced_blending_falls_back_to_the_cpu() {
use photocraft_doc::Knockout;
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut l = Layer::raster("ko", d.pixel_format());
l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
d.layers.push(l.clone());
assert!(plan(&d).is_ok(), "defaults stay on the GPU");
// Switches with nothing to act on (no effects, no clipped layers) stay on the GPU too.
d.layers[1].advanced.blend_interior = true;
d.layers[1].advanced.blend_clipped = false;
assert!(plan(&d).is_ok());
d.layers[1].advanced.knockout = Knockout::Deep;
assert!(plan(&d).unwrap_err().0.contains("Advanced Blending"));
// Inside groups and clipping groups.
d.layers[1].advanced.knockout = Knockout::None;
let mut c = l.clone();
c.clipped = true;
d.layers.push(c.clone());
assert!(plan(&d).is_err(), "clipped layers blended individually");
d.layers[1].advanced.blend_clipped = true;
assert!(plan(&d).is_ok());
d.layers[2].advanced.knockout = Knockout::Shallow;
assert!(plan(&d).is_err(), "clipped knockout");
d.layers.truncate(1);
let mut inner = l;
inner.advanced.knockout = Knockout::Shallow;
d.layers.push(Layer::group("g", vec![inner]));
assert!(plan(&d).is_err(), "knockout inside a group");
}
}
+86 -1
View File
@@ -2,7 +2,7 @@
//! smart objects, locks and label colors.
use photocraft_color::{Color, ColorMode};
use photocraft_doc::{BlendIf, BlendRange, Fill, GradientStyle, LabelColor, Locks};
use photocraft_doc::{AdvancedBlending, BlendIf, BlendRange, Fill, GradientStyle, Knockout, LabelColor, Locks};
use photocraft_geom::Affine;
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
use photocraft_psd::layer::BlendingRanges;
@@ -18,6 +18,60 @@ pub fn brst_data(mask: u32) -> Option<Vec<u8>> {
(mask != 0).then(|| (0..32u32).filter(|i| mask & 1 << i != 0).flat_map(u32::to_be_bytes).collect())
}
/// The Advanced Blending blocks, as (key, value for `a`, Photoshop's default value): `knko`
/// (0 none, 1 shallow, 2 deep), `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`. Each is stored as one
/// byte followed by three bytes of padding (Adobe PSD spec, "Additional Layer Information").
pub fn advanced_flags(a: &AdvancedBlending) -> [([u8; 4], u8, u8); 6] {
[
(*b"knko", a.knockout.to_psd(), 0),
(*b"infx", u8::from(a.blend_interior), 0),
(*b"clbl", u8::from(a.blend_clipped), 1),
(*b"tsly", u8::from(a.transparency_shapes), 1),
(*b"lmgm", u8::from(a.layer_mask_hides_effects), 0),
(*b"vmgm", u8::from(a.vector_mask_hides_effects), 0),
]
}
/// Advanced Blending from a layer record's blocks (`block(key)` gives a block's data). A missing
/// or empty block keeps Photoshop's default.
pub fn advanced_from_blocks<'a>(block: impl Fn(&[u8; 4]) -> Option<&'a [u8]>) -> AdvancedBlending {
let mut a = AdvancedBlending::default();
let flag = |k: &[u8; 4]| block(k).and_then(|d| d.first().copied());
if let Some(v) = flag(b"knko") {
a.knockout = Knockout::from_psd(v);
}
let set = |k: &[u8; 4], f: &mut bool| {
if let Some(v) = flag(k) {
*f = v != 0;
}
};
set(b"infx", &mut a.blend_interior);
set(b"clbl", &mut a.blend_clipped);
set(b"tsly", &mut a.transparency_shapes);
set(b"lmgm", &mut a.layer_mask_hides_effects);
set(b"vmgm", &mut a.vector_mask_hides_effects);
a
}
/// Writes the Advanced Blending blocks into an exported layer's preserved block list: a block
/// already there is rewritten in place (keeping Photoshop's block order); a missing one is added
/// only when its value differs from the default, so plain layers gain no blocks.
pub fn put_advanced(a: &AdvancedBlending, raw: &mut Vec<([u8; 4], Vec<u8>)>) {
for (key, v, default) in advanced_flags(a) {
match raw.iter_mut().find(|(k, _)| *k == key) {
Some(e) => {
// Keep any extra bytes a reader wrote; the flag is the first byte.
if e.1.len() < 4 {
e.1.resize(4, 0);
}
e.1[0] = v;
}
None if v != default => raw.push((key, vec![v, 0, 0, 0])),
None => {}
}
}
}
/// 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
@@ -637,6 +691,37 @@ mod tests {
assert_eq!(&r.data[8..], &BlendingRanges::full(2).data[..]);
}
#[test]
fn advanced_blending_blocks_round_trip() {
use photocraft_doc::{AdvancedBlending, Knockout};
let a = AdvancedBlending {
knockout: Knockout::Deep,
blend_interior: true,
blend_clipped: false,
transparency_shapes: false,
layer_mask_hides_effects: true,
vector_mask_hides_effects: true,
};
let mut raw = Vec::new();
super::put_advanced(&a, &mut raw);
assert_eq!(raw.len(), 6);
assert!(raw.contains(&(*b"knko", vec![2, 0, 0, 0])));
assert!(raw.contains(&(*b"clbl", vec![0, 0, 0, 0])));
let get = |k: &[u8; 4]| raw.iter().find(|(bk, _)| bk == k).map(|(_, d)| d.as_slice());
assert_eq!(super::advanced_from_blocks(get), a);
// Defaults: nothing added to a plain layer; existing blocks rewritten in place, in order.
let mut plain = Vec::new();
super::put_advanced(&AdvancedBlending::default(), &mut plain);
assert!(plain.is_empty());
let mut kept = vec![(*b"luni", vec![0; 4]), (*b"knko", vec![1, 0, 0, 0]), (*b"clbl", vec![1])];
super::put_advanced(&AdvancedBlending::default(), &mut kept);
assert_eq!(kept, vec![(*b"luni", vec![0; 4]), (*b"knko", vec![0, 0, 0, 0]), (*b"clbl", vec![1, 0, 0, 0])]);
// Missing and empty blocks read as Photoshop's defaults.
assert!(super::advanced_from_blocks(|_| None).is_default());
assert!(super::advanced_from_blocks(|_| Some(&[][..])).is_default());
assert_eq!(super::advanced_from_blocks(|k| (k == b"knko").then_some(&[1u8][..])).knockout, Knockout::Shallow);
}
#[test]
fn brst_round_trip() {
assert_eq!(super::parse_brst(&[0, 0, 0, 2]), 0b100);
+2
View File
@@ -497,6 +497,8 @@ impl Ex {
},
None => raw.retain(|(k, _)| k != b"brst"),
}
// Advanced Blending: written from the field (in place when the block was imported).
crate::blocks::put_advanced(&l.advanced, &mut raw);
if !matches!(l.content, LayerContent::Shape(_)) {
self.vector_mask_block(l, &mut raw);
}
+3
View File
@@ -246,6 +246,9 @@ impl Ctx<'_> {
if let Some(b) = rec.block(b"brst") {
l.excluded_channels = crate::blocks::parse_brst(&b.data);
}
// Advanced Blending (`knko`, `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`); the blocks stay in
// `psd_blocks`, where export rewrites them from the field in place.
l.advanced = crate::blocks::advanced_from_blocks(|k| rec.block(k).map(|b| b.data.as_slice()));
// 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();
+11
View File
@@ -89,6 +89,9 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document {
neg.fill_opacity = g(128);
neg.blend = BlendMode::Multiply;
neg.label = LabelColor::Blue;
// Advanced Blending switches round-trip as `infx` / `tsly` (and the rest below).
neg.advanced.blend_interior = true;
neg.advanced.transparency_shapes = false;
d.layers.push(neg);
let mut hidden = raster("Hidden", fmt, Rect::new(3, 3, 9, 9), 3, true);
@@ -105,6 +108,8 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document {
if f.masks {
let mut m = raster("Masked", fmt, Rect::new(2, 2, 14, 12), 5, true);
m.mask = Some(LayerMask { surface: mask_surface(depth, Rect::new(4, 4, 10, 9), 1.0, 1), enabled: true, linked: true, density: 1.0, feather: 0.0 });
m.advanced.layer_mask_hides_effects = true;
m.advanced.vector_mask_hides_effects = true;
d.layers.push(m);
let mut m2 = raster("Masked2", fmt, Rect::new(-2, 1, 6, 5), 6, true);
m2.mask =
@@ -120,10 +125,13 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document {
if let LayerContent::Group(gr) = &mut inner.content {
gr.expanded = false;
}
inner.advanced.knockout = photocraft_doc::Knockout::Shallow;
let a = raster("Outer child", fmt, Rect::new(6, 2, 12, 8), 8, true);
let mut outer = Layer::group("Outer", vec![a, inner]);
outer.locks.position = true;
outer.label = LabelColor::Red;
outer.advanced.knockout = photocraft_doc::Knockout::Deep;
outer.advanced.blend_clipped = false;
d.layers.push(outer);
d.layers.push(Layer::group("Empty group", vec![]));
}
@@ -235,6 +243,9 @@ pub fn assert_layers_eq(a: &Layer, b: &Layer, path: &str) {
assert_eq!(a.fill_opacity, b.fill_opacity, "{ctx}: fill");
assert_eq!(a.clipped, b.clipped, "{ctx}: clipped");
assert_eq!(a.label, b.label, "{ctx}: label");
assert_eq!(a.advanced, b.advanced, "{ctx}: advanced blending");
assert_eq!(a.excluded_channels, b.excluded_channels, "{ctx}: channel restrictions");
assert_eq!(a.blend_if, b.blend_if, "{ctx}: blend if");
assert_eq!(a.effects.psd_raw, b.effects.psd_raw, "{ctx}: effects raw");
assert_eq!(a.effects.enabled, b.effects.enabled, "{ctx}: effects enabled");
if a.psd_id.is_some() {
+2 -2
View File
@@ -151,8 +151,8 @@ struct Source {
const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_depth: 0, pass_floor: 146, roundtrip_floor: 169 };
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
/// 219 vs the merged image + 10 vs the thumbnail.
const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 229, roundtrip_floor: 307 };
/// 226 vs the merged image + 10 vs the thumbnail (Advanced Blending knockouts: +7).
const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 236, roundtrip_floor: 307 };
/// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus),
/// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/<mode><bits>`).
+52
View File
@@ -161,6 +161,8 @@ fn raster_layer_blocks_preserved() {
let blocks =
vec![(*b"vmsk", std::sync::Arc::new(vec![0u8; 12])), (*b"clbl", std::sync::Arc::new(vec![0u8, 0, 0, 0])), (*b"Zzzz", std::sync::Arc::new(vec![1u8]))];
d.layers[1].psd_blocks = blocks.clone();
// `clbl` is written from the field (in place), so the field agrees with the preserved block.
d.layers[1].advanced = photocraft_doc::AdvancedBlending { blend_clipped: false, ..Default::default() };
d.layers[1].vector_mask = photocraft_io::vector_map::vector_mask_from_block(&[0; 12], d.size.width, d.size.height);
let back = roundtrip(&d);
// An odd-length layer block comes back with its pad byte inside the length (as Photoshop
@@ -464,6 +466,56 @@ fn channel_restrictions_round_trip_as_brst() {
assert!(f.layers().iter().find(|r| r.name() == "a").unwrap().block(b"brst").is_none());
}
/// Advanced Blending maps to `knko` / `infx` / `clbl` / `tsly` / `lmgm` / `vmgm` (one byte +
/// three of padding), at every depth: non-default values are written, default layers gain no
/// blocks, and imported blocks are rewritten in place.
#[test]
fn advanced_blending_round_trips_as_tagged_blocks() {
use photocraft_doc::{AdvancedBlending, Document, Knockout, Layer, Size};
for depth in SampleType::ALL {
let mut d = Document::new("a", Size::new(8, 8), ColorMode::Rgb, depth);
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, 0.0, 0.0, 1.0]);
let adv = AdvancedBlending {
knockout: Knockout::Deep,
blend_interior: true,
blend_clipped: false,
transparency_shapes: false,
layer_mask_hides_effects: true,
vector_mask_hides_effects: true,
};
a.advanced = adv;
let mut g = Layer::group("g", vec![Layer::raster("c", d.pixel_format())]);
g.advanced.knockout = Knockout::Shallow;
g.fill_opacity = 128.0 / 255.0;
let b = Layer::raster("b", d.pixel_format());
d.layers = vec![a, b, g];
let f = document_to_psd(&d);
let rec = |name: &str| f.layers().iter().find(|r| r.name() == name).unwrap().clone();
let ra = rec("a");
for (k, v) in [(b"knko", 2u8), (b"infx", 1), (b"clbl", 0), (b"tsly", 0), (b"lmgm", 1), (b"vmgm", 1)] {
assert_eq!(ra.block(k).unwrap().data, vec![v, 0, 0, 0], "{depth:?} {}", String::from_utf8_lossy(k));
}
for k in [b"knko", b"infx", b"clbl", b"tsly", b"lmgm", b"vmgm"] {
assert!(rec("b").block(k).is_none(), "default layer gained {}", String::from_utf8_lossy(k));
}
assert_eq!(rec("g").block(b"knko").unwrap().data, vec![1, 0, 0, 0]);
let back = roundtrip(&d);
assert_eq!(back.layers[0].advanced, adv, "{depth:?}");
assert!(back.layers[1].advanced.is_default());
assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow);
assert_eq!(back.layers[2].fill_opacity, 128.0 / 255.0);
// Reset to the defaults: the imported blocks stay (in place) with default values.
let mut reset = back.clone();
reset.layers[0].advanced = AdvancedBlending::default();
let f = document_to_psd(&reset);
let ra = f.layers().iter().find(|r| r.name() == "a").unwrap();
assert_eq!(ra.block(b"knko").unwrap().data, vec![0, 0, 0, 0]);
assert_eq!(ra.block(b"clbl").unwrap().data, vec![1, 0, 0, 0]);
assert!(roundtrip(&reset).layers[0].advanced.is_default());
}
}
#[test]
fn blend_if_round_trips_as_blending_ranges() {
use photocraft_doc::{BlendIf, BlendRange, Document, Layer, Size};
+48
View File
@@ -228,6 +228,54 @@ fn psdtags_fixtures_open_with_their_layers() {
}
}
/// Advanced Blending (`knko`, `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`), fill opacity (`iOpa`),
/// channel restrictions (`brst`) and Blend If (the records' blending ranges) survive a layered
/// TIFF with its layer data in either byte order: the encoder's own, and the other one made by
/// transcoding the tag (the same length, spliced in place).
#[test]
fn advanced_blending_survives_layered_tiff_in_both_byte_orders() {
use photocraft_doc::{AdvancedBlending, BlendRange, Knockout};
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = gen_doc(ColorMode::Rgb, depth, Features::PIXELS);
let adv = AdvancedBlending {
knockout: Knockout::Deep,
blend_interior: true,
blend_clipped: false,
transparency_shapes: false,
layer_mask_hides_effects: true,
vector_mask_hides_effects: true,
};
d.layers[1].advanced = adv;
d.layers[1].fill_opacity = 64.0 / 255.0;
d.layers[1].excluded_channels = 0b010;
d.layers[1].blend_if.set(0, [BlendRange { black: [10, 40], white: [200, 250] }, BlendRange { black: [0, 0], white: [128, 128] }]);
d.layers[2].advanced.knockout = Knockout::Shallow;
let r = tiff(&d, true);
let (_, layers) = tags(&r.bytes);
let layers = layers.expect("layer data");
let (isd, w) = ImageSourceData::from_bytes(&layers).expect("parse");
assert!(w.is_empty(), "{w:?}");
let native = isd.byte_order;
let other = if native == ByteOrder::Little { ByteOrder::Big } else { ByteOrder::Little };
let (swapped, w) = isd.to_bytes(other).expect("transcode");
assert!(w.is_empty(), "{depth:?}: blocks dropped while transcoding: {w:?}");
assert_eq!(swapped.len(), layers.len());
let at = r.bytes.windows(layers.len()).position(|x| x == &layers[..]).expect("tag data in the file");
let mut flipped = r.bytes.clone();
flipped[at..at + layers.len()].copy_from_slice(&swapped);
for (order, bytes) in [(native, &r.bytes), (other, &flipped)] {
let back = import("x.tif", bytes).unwrap_or_else(|e| panic!("{depth:?} {order:?}: {e}")).document;
let l = &back.layers[1];
assert_eq!(l.advanced, adv, "{depth:?} {order:?}");
assert_eq!(l.fill_opacity, 64.0 / 255.0, "{depth:?} {order:?}");
assert_eq!(l.excluded_channels, 0b010, "{depth:?} {order:?}");
assert_eq!(l.blend_if, d.layers[1].blend_if, "{depth:?} {order:?}");
assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow, "{depth:?} {order:?}");
assert_docs_eq(&expected(&d), &back);
}
}
}
#[test]
fn damaged_layer_data_opens_flattened() {
let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
+27 -1
View File
@@ -115,8 +115,14 @@ pub enum BlockData<'a> {
BlendClippedAsGroup(bool),
/// `infx`
BlendInteriorElements(bool),
/// `knko`
/// `knko`: 0 none, 1 shallow, 2 deep.
Knockout(u8),
/// `tsly`: Transparency Shapes Layer.
TransparencyShapes(bool),
/// `lmgm`: Layer Mask Hides Effects ("layer mask as global mask").
LayerMaskHidesEffects(bool),
/// `vmgm`: Vector Mask Hides Effects.
VectorMaskHidesEffects(bool),
/// `lspf`: bit 0 transparency, bit 1 composite, bit 2 position locked.
Protection(u32),
/// `lclr`: sheet color index (0 none, 1 red, 2 orange, 3 yellow, 4 green,
@@ -170,6 +176,9 @@ impl TaggedBlock {
b"clbl" => r.u8().map(|v| BlockData::BlendClippedAsGroup(v != 0)),
b"infx" => r.u8().map(|v| BlockData::BlendInteriorElements(v != 0)),
b"knko" => r.u8().map(BlockData::Knockout),
b"tsly" => r.u8().map(|v| BlockData::TransparencyShapes(v != 0)),
b"lmgm" => r.u8().map(|v| BlockData::LayerMaskHidesEffects(v != 0)),
b"vmgm" => r.u8().map(|v| BlockData::VectorMaskHidesEffects(v != 0)),
b"lspf" => r.u32().map(BlockData::Protection),
b"lclr" => r.u16().map(BlockData::SheetColor),
b"iOpa" => r.u8().map(BlockData::FillOpacity),
@@ -225,6 +234,18 @@ impl TaggedBlock {
pub fn knockout(v: u8) -> Self {
Self::flag(*b"knko", v)
}
/// `tsly` block.
pub fn transparency_shapes(v: bool) -> Self {
Self::flag(*b"tsly", u8::from(v))
}
/// `lmgm` block.
pub fn layer_mask_hides_effects(v: bool) -> Self {
Self::flag(*b"lmgm", u8::from(v))
}
/// `vmgm` block.
pub fn vector_mask_hides_effects(v: bool) -> Self {
Self::flag(*b"vmgm", u8::from(v))
}
/// `lspf` block.
pub fn protection(flags: u32) -> Self {
Self::new(*b"lspf", flags.to_be_bytes().to_vec())
@@ -507,6 +528,11 @@ mod tests {
assert_eq!(TaggedBlock::blend_clipped_as_group(true).parsed(), Some(Ok(BlockData::BlendClippedAsGroup(true))));
assert_eq!(TaggedBlock::blend_interior_elements(false).parsed(), Some(Ok(BlockData::BlendInteriorElements(false))));
assert_eq!(TaggedBlock::knockout(2).parsed(), Some(Ok(BlockData::Knockout(2))));
assert_eq!(TaggedBlock::transparency_shapes(false).parsed(), Some(Ok(BlockData::TransparencyShapes(false))));
assert_eq!(TaggedBlock::layer_mask_hides_effects(true).parsed(), Some(Ok(BlockData::LayerMaskHidesEffects(true))));
assert_eq!(TaggedBlock::vector_mask_hides_effects(true).parsed(), Some(Ok(BlockData::VectorMaskHidesEffects(true))));
assert_eq!(TaggedBlock::vector_mask_hides_effects(true).data, vec![1, 0, 0, 0]);
assert!(matches!(TaggedBlock::new(*b"tsly", vec![]).parsed(), Some(Err(_))), "truncated flag is an error, not a panic");
assert_eq!(TaggedBlock::protection(0x8000_0000).parsed(), Some(Ok(BlockData::Protection(0x8000_0000))));
assert_eq!(TaggedBlock::sheet_color(3).parsed(), Some(Ok(BlockData::SheetColor(3))));
assert_eq!(TaggedBlock::fill_opacity(128).parsed(), Some(Ok(BlockData::FillOpacity(128))));
+12 -3
View File
@@ -776,8 +776,8 @@ pub mod transcode {
self.u32()?;
}
}
// lyvr layer version, sn2P and vowv (shape and vector flags), lmgm.
b"lyid" | b"lspf" | b"lyvr" | b"sn2P" | b"vowv" | b"lmgm" => {
// lyvr layer version, sn2P and vowv (shape and vector flags).
b"lyid" | b"lspf" | b"lyvr" | b"sn2P" | b"vowv" => {
self.u32()?;
}
// Text-engine global data: a textual structure, no byte order.
@@ -785,7 +785,9 @@ pub mod transcode {
b"lnsr" => {
self.key()?;
}
b"clbl" | b"infx" | b"knko" | b"tsly" | b"iOpa" => {
// Advanced Blending flags and fill opacity: one byte, then padding (Adobe spec,
// "Additional Layer Information"; `lmgm`/`vmgm` = layer/vector mask hides effects).
b"clbl" | b"infx" | b"knko" | b"tsly" | b"lmgm" | b"vmgm" | b"iOpa" => {
self.u8()?;
}
b"lclr" => self.u16s_upto(4)?,
@@ -1895,6 +1897,13 @@ mod tests {
(*b"lspf", vec![0, 0, 0, 5]),
(*b"lnsr", b"layr".to_vec()),
(*b"knko", vec![1, 0, 0, 0]),
(*b"knko", vec![2, 0, 0, 0]),
(*b"infx", vec![1, 0, 0, 0]),
(*b"clbl", vec![0, 0, 0, 0]),
(*b"tsly", vec![0, 0, 0, 0]),
(*b"lmgm", vec![1, 0, 0, 0]),
(*b"vmgm", vec![1, 0, 0, 0]),
(*b"iOpa", vec![128, 0, 0, 0]),
] {
let (big, little) = block_rt(key, data.clone());
if data.len() >= 2 && key != *b"nvrt" {
+4
View File
@@ -2145,6 +2145,10 @@ cameraRaw Color Barvy
% of all commits % všech commitů
Lines +/− Řádky +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, commity: {commits}, řádky +{added} / −{deleted} (Δ {delta}), binární soubory +{binAdded} / −{binDeleted}, {first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: Prolnout, pokud:
Channels: Kanály:
Knockout Vyřazení
Apply At Canvas Bounds Použít efekt na hranicích plátna
# Fill dialog, layer style contours and Image Size
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2184,6 +2184,11 @@
% of all commits % aller Commits
Lines +/− Zeilen +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{binAdded} / −{binDeleted} Binärdateien, {first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: Farbbereich:
Channels: Kanäle:
Knockout Aussparung
Apply At Canvas Bounds Effekt an Arbeitsflächenbegrenzungen anwenden
# Fill dialog, layer style contours and Image Size
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2216,6 +2216,10 @@ cameraRaw Amount Cantidad
% of all commits % de todos los commits
Lines +/− Líneas +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{binAdded} / −{binDeleted} archivos binarios, {first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: Combinar si:
Channels: Canales:
Knockout Cobertura
Apply At Canvas Bounds Aplicar efecto en los límites del lienzo
# Fill dialog, layer style contours and Image Size
Can't render this file because it contains an unexpected character in line 4 and column 47.
+4
View File
@@ -2576,6 +2576,10 @@ cameraRaw Blending Fusion
% of all commits % de tous les commits
Lines +/− Lignes +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{binAdded} / −{binDeleted} fichiers binaires, {first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: Fusion si :
Channels: Couches :
Knockout Masquage
Apply At Canvas Bounds Appliquer l’effet aux limites de la zone de travail
# Fill dialog, layer style contours and Image Size
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2563,6 +2563,10 @@ cameraRaw Blending Pembauran
% of all commits % dari semua commit
Lines +/− Baris +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{binAdded} / −{binDeleted} berkas biner, {first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: Blend If:
Channels: Channel:
Knockout Knockout
Apply At Canvas Bounds Terapkan efek di batas kanvas
# Fill dialog, layer style contours and Image Size
Can't render this file because it contains an unexpected character in line 4 and column 47.
+4
View File
@@ -2217,6 +2217,10 @@ cameraRaw Amount Quantità
% of all commits % di tutti i commit
Lines +/− Righe +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{binAdded} / −{binDeleted} risorse binarie, {first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: Fondi se:
Channels: Canali:
Knockout Foratura
Convert to Layers Converti in livelli
Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
+4
View File
@@ -2128,6 +2128,10 @@ cameraRaw Color カラー
% of all commits 全コミットに占める割合 (%)
Lines +/− 行数 +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs} 件、コミット {commits} 件、行 +{added} / −{deleted} (Δ {delta})、バイナリ +{binAdded} / −{binDeleted}、{first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: 条件付き合成:
Channels: チャンネル:
Knockout ノックアウト
Apply At Canvas Bounds カンバスの境界に効果を適用
# Fill dialog, layer style contours and Image Size
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2729,6 +2729,10 @@ cameraRaw Color 색상
% of all commits 전체 커밋 대비 %
Lines +/− 줄 +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs}개, 커밋 {commits}개, 줄 +{added} / −{deleted} (Δ {delta}), 바이너리 +{binAdded} / −{binDeleted}, {first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: 혼합 조건:
Channels: 채널:
Knockout 녹아웃
Apply At Canvas Bounds 캔버스 경계에 효과 적용
Good 좋음
Best 최적
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
+3
View File
@@ -3184,6 +3184,9 @@ cameraRaw Roughness Chropowatość
More documents Więcej dokumentów
Developing {name} Wywoływanie {name}
Camera Raw ({name}) Camera Raw ({name})
Blend If: Mieszaj, jeśli:
Channels: Kanały:
Knockout Wycięcie
New Brush Preset… Nowe ustawienie wstępne pędzla…
Brush Preset Options Opcje ustawień wstępnych pędzla
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2583,6 +2583,11 @@ cameraRaw Whites Brancos
% of all commits % de todos os commits
Lines +/− Linhas +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{binAdded} / −{binDeleted} arquivos binários, {first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: Mesclar se:
Channels: Canais:
Knockout Vazado
Apply At Canvas Bounds Aplicar efeito nos limites da tela
# Fill dialog, layer style contours and Image Size
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2422,6 +2422,10 @@ cameraRaw Roughness Неровность
% of all commits % от всех коммитов
Lines +/− Строки +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, коммиты: {commits}, строки +{added} / −{deleted} (Δ {delta}), двоичные файлы +{binAdded} / −{binDeleted}, {first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: Изображение если:
Channels: Каналы:
Knockout Просвечивание
Apply At Canvas Bounds Применить эффект на границах холста
# Fill dialog, layer style contours and Image Size
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2153,6 +2153,10 @@ cameraRaw Blending 混合
% of all commits 占全部提交的 %
Lines +/− 行数 +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 个 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二进制文件 +{binAdded} / −{binDeleted},{first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: 混合颜色带:
Channels: 通道:
Knockout 挖空
Apply At Canvas Bounds 应用画布边界的效果
# Fill dialog, layer style contours and Image Size
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2153
2154 # Fill dialog, layer style contours and Image Size
2155
2156
2157
2158
2159
2160
2161
2162
+4
View File
@@ -2122,6 +2122,10 @@ cameraRaw Blending 混合
% of all commits 佔全部提交的 %
Lines +/− 行數 +/−
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 個 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二進位檔 +{binAdded} / −{binDeleted},{first} – {last}
# Layer Style > Blending Options (Advanced Blending)
Blend If: 混合範圍:
Channels: 色版:
Knockout 去底色
Apply At Canvas Bounds 套用效果至版面邊界
# Fill dialog, layer style contours and Image Size
Can't render this file because it contains an unexpected character in line 6 and column 47.
+364 -9
View File
@@ -9,7 +9,9 @@
//! snapshot with `params` (engine-side overlay), so everything the dialog
//! doesn't model — gradient strokes, imported contours, PSD data — survives.
//! An entry without `fx` is a new instance built from `params`.
//! - `p:blendingOptions`: the layer's blend mode, opacity and fill opacity.
//! - `p:blendingOptions`: the parameters of `layer.layerStyle.blendingOptions` (blend mode,
//! opacity, fill opacity, channels, knockout, the Advanced Blending switches, Blend If) plus
//! the page-only `blendIfChannel`; `channelNames`: the document's colour channels.
use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2};
use photocraft_doc::effects::{FxPaint, StrokePosition};
@@ -445,10 +447,7 @@ pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map<St
f.insert("layer".into(), json!(layer.id.0));
f.insert("globalLight".into(), json!(light));
f.insert("preview".into(), json!(true));
f.insert(
format!("p:{BLENDING}"),
json!({"blend": layer.blend.label(), "opacity": (layer.opacity * 100.0).round(), "fillOpacity": (layer.fill_opacity * 100.0).round()}),
);
set_blending_fields(&mut f, layer, photocraft_color::ColorMode::Rgb);
let mut effects = Vec::new();
for (i, e) in layer.effects.items.iter().enumerate() {
effects.push(json!({
@@ -491,7 +490,10 @@ pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option<u64> {
let st = app.session.active()?;
let layer = st.doc.layer(st.active_layer?)?.clone();
let light = st.doc.global_light.angle;
let mode = st.doc.mode;
let mut f = initial_fields(&layer, select, light);
// Channels and Blend If list the document's own colour channels.
set_blending_fields(&mut f, &layer, mode);
f.insert("patternList".into(), pattern_list(app));
Some(app.ui.open_dialog(crate::state::DialogKind::LayerStyle, f))
}
@@ -548,10 +550,11 @@ fn apply(f: &Map<String, Value>, mut run: impl FnMut(&str, Value) -> Result<Valu
let layer = f.get("layer").cloned().unwrap_or(Value::Null);
let initial_light_angle = f.get("globalLight").and_then(Value::as_f64);
if let Some(bo) = f.get(&format!("p:{BLENDING}")).and_then(Value::as_object) {
let mut p = Value::Object(bo.clone());
if let Some(params) = p.as_object_mut() {
params.insert("layer".into(), layer.clone());
}
let mut bo = bo.clone();
// Which Blend If channel the page shows is dialog state, not a parameter.
bo.remove("blendIfChannel");
bo.insert("layer".into(), layer.clone());
let p = Value::Object(bo);
run("layer.layerStyle.blendingOptions", p)?;
}
let mut entries = Vec::new();
@@ -849,6 +852,15 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
ui.colored_label(t.danger, error);
return;
}
if selected == BLENDING {
// General Blending, Advanced Blending and Blend If, laid out like Photoshop's page.
let names = channel_names_of(f);
let key = format!("p:{BLENDING}");
let Some(mut p) = f.get(&key).filter(|v| v.is_object()).cloned() else { return };
blending_page(ui, &mut p, &names);
f.insert(key, p);
return;
}
// The page shows the factory defaults under the instance's values,
// but only real edits reach the stored params.
let disp = if selected == BLENDING {
@@ -985,6 +997,274 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
}
}
/// Colour channel names of a document mode, as Blending Options lists them (Channels, Blend If).
pub fn channel_names(mode: photocraft_color::ColorMode) -> Vec<&'static str> {
use photocraft_color::ColorMode as M;
match mode {
M::Rgb | M::Indexed | M::Multichannel => vec!["R", "G", "B"],
M::Cmyk => vec!["C", "M", "Y", "K"],
M::Lab => vec!["L", "a", "b"],
_ => vec!["Gray"],
}
}
const KNOCKOUT: &[(&str, &str)] = &[("none", "None"), ("shallow", "Shallow"), ("deep", "Deep")];
/// (parameter key, label) of the Advanced Blending check boxes, in Photoshop's order.
const ADVANCED_CHECKS: &[(&str, &str)] = &[
("blendInteriorEffectsAsGroup", "Blend Interior Effects as Group"),
("blendClippedLayersAsGroup", "Blend Clipped Layers as Group"),
("transparencyShapesLayer", "Transparency Shapes Layer"),
("layerMaskHidesEffects", "Layer Mask Hides Effects"),
("vectorMaskHidesEffects", "Vector Mask Hides Effects"),
];
/// The Blending Options page's fields for `layer` in a `mode` document (the parameters of
/// `layer.layerStyle.blendingOptions`, plus the page's Blend If channel), and the channel names.
pub fn set_blending_fields(f: &mut Map<String, Value>, layer: &Layer, mode: photocraft_color::ColorMode) {
let names = channel_names(mode);
let n = names.len();
let a = &layer.advanced;
// Blend If entries in the command's form: index 0 is Gray for colour documents; a one-channel
// document's Gray is its channel's own entry (index 1).
let first = if n == 1 { 1 } else { 0 };
let blend_if: Vec<Value> = (first..=n)
.map(|i| {
let [this, under] = layer.blend_if.get(i);
json!({"channel": i, "thisLayer": this.to_bytes(), "underlying": under.to_bytes()})
})
.collect();
f.insert(
format!("p:{BLENDING}"),
json!({
"blend": layer.blend.label(),
"opacity": (layer.opacity * 100.0).round(),
"fillOpacity": (layer.fill_opacity * 100.0).round(),
"channels": (0..n).map(|i| layer.excluded_channels & (1 << i) == 0).collect::<Vec<_>>(),
"knockout": a.knockout.name(),
"blendInteriorEffectsAsGroup": a.blend_interior,
"blendClippedLayersAsGroup": a.blend_clipped,
"transparencyShapesLayer": a.transparency_shapes,
"layerMaskHidesEffects": a.layer_mask_hides_effects,
"vectorMaskHidesEffects": a.vector_mask_hides_effects,
"blendIfChannel": 0,
"blendIf": blend_if,
}),
);
f.insert("channelNames".into(), json!(names));
}
fn channel_names_of(f: &Map<String, Value>) -> Vec<String> {
f.get("channelNames")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect::<Vec<_>>())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| ["R", "G", "B"].iter().map(|s| (*s).to_string()).collect())
}
/// The Blending Options page: General Blending, Advanced Blending and Blend If, as in Photoshop.
fn blending_page(ui: &mut egui::Ui, p: &mut Value, names: &[String]) {
let t = Tokens::get(ui.ctx());
let num = |p: &Value, k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d) as f32;
widgets::section_label(ui, "General Blending");
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Blend Mode")).color(t.text_dim));
let mut cur = p.get("blend").and_then(Value::as_str).unwrap_or("Normal").to_string();
let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect();
if widgets::dropdown(ui, "fx-blend-blendingOptions", &mut cur, &opts, 150.0) {
p["blend"] = json!(cur);
}
});
let mut v = num(p, "opacity", 100.0);
if widgets::slider_row(ui, "Opacity", &mut v, 0.0..=100.0, "%", None).changed() {
p["opacity"] = json!(v.round());
}
widgets::hairline(ui);
widgets::section_label(ui, "Advanced Blending");
let mut v = num(p, "fillOpacity", 100.0);
if widgets::slider_row(ui, "Fill Opacity", &mut v, 0.0..=100.0, "%", None).changed() {
p["fillOpacity"] = json!(v.round());
}
// Channels: one check box per colour channel (checked = blends).
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Channels:")).color(t.text_dim));
let mut ch: Vec<bool> =
p.get("channels").and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_bool().unwrap_or(true)).collect()).unwrap_or_default();
ch.resize(names.len(), true);
let mut changed = false;
for (i, name) in names.iter().enumerate() {
if let Some(c) = ch.get_mut(i) {
changed |= widgets::checkbox(ui, c, name).changed();
}
}
if changed {
p["channels"] = json!(ch);
}
});
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Knockout")).color(t.text_dim));
let mut cur = p.get("knockout").and_then(Value::as_str).unwrap_or("none").to_string();
let opts: Vec<(String, &str)> = KNOCKOUT.iter().map(|(v, l)| (v.to_string(), *l)).collect();
if widgets::dropdown(ui, "fx-blendingOptions-knockout", &mut cur, &opts, 150.0) {
p["knockout"] = json!(cur);
}
});
for &(key, label) in ADVANCED_CHECKS {
let default = matches!(key, "blendClippedLayersAsGroup" | "transparencyShapesLayer");
let mut b = p.get(key).and_then(Value::as_bool).unwrap_or(default);
if widgets::checkbox(ui, &mut b, label).changed() {
p[key] = json!(b);
}
}
widgets::hairline(ui);
// Blend If: the channel, then This Layer / Underlying Layer split sliders (Alt-drag splits).
let entries = p.get("blendIf").and_then(Value::as_array).map_or(0, Vec::len);
if entries == 0 {
return;
}
let sel = p.get("blendIfChannel").and_then(Value::as_u64).map_or(0, |v| v as usize).min(entries - 1);
ui.horizontal(|ui| {
ui.label(RichText::new(tl!("Blend If:")).color(t.text_dim));
// A colour document lists Gray first; a one-channel document only its channel (Gray).
let label = |i: usize| -> String {
if entries == names.len() {
names.get(i).cloned().unwrap_or_default()
} else if i == 0 {
"Gray".to_string()
} else {
names.get(i - 1).cloned().unwrap_or_default()
}
};
let labels: Vec<String> = (0..entries).map(label).collect();
let opts: Vec<(usize, &str)> = labels.iter().enumerate().map(|(i, l)| (i, l.as_str())).collect();
let mut cur = sel;
if widgets::dropdown(ui, "fx-blendingOptions-blendIf", &mut cur, &opts, 110.0) {
p["blendIfChannel"] = json!(cur);
}
});
for (key, label) in [("thisLayer", "This Layer:"), ("underlying", "Underlying Layer:")] {
let mut q = blend_quad(p.get("blendIf").and_then(|a| a.get(sel)).and_then(|e| e.get(key)));
ui.horizontal(|ui| {
ui.label(RichText::new(tl!(label)).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let side = |lo: u8, hi: u8| if lo == hi { lo.to_string() } else { format!("{lo} / {hi}") };
ui.label(RichText::new(format!("{} {}", side(q[0], q[1]), side(q[2], q[3]))).color(t.text));
});
});
if blend_if_bar(ui, ui.id().with(("blendIf", key)), &mut q)
&& let Some(e) = p.get_mut("blendIf").and_then(|a| a.get_mut(sel))
{
e[key] = json!(q);
}
ui.add_space(4.0);
}
}
/// A Blend If range from the dialog's JSON (`[blackLo, blackHi, whiteLo, whiteHi]`), full when absent.
fn blend_quad(v: Option<&Value>) -> [u8; 4] {
let mut q = [0u8, 0, 255, 255];
if let Some(a) = v.and_then(Value::as_array) {
for (dst, x) in q.iter_mut().zip(a) {
*dst = x.as_f64().map_or(*dst, |f| f.clamp(0.0, 255.0).round() as u8);
}
}
// The sliders never cross.
for i in 1..4 {
q[i] = q[i].max(q[i - 1]);
}
q
}
/// A Blend If slider: a black-to-white ramp with a black and a white handle, each split in two
/// halves by Alt-dragging (the layer fades between them). Returns whether `q` changed.
fn blend_if_bar(ui: &mut egui::Ui, id: egui::Id, q: &mut [u8; 4]) -> bool {
let t = Tokens::get(ui.ctx());
let w = ui.available_width().clamp(120.0, 320.0);
let (rect, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click_and_drag());
let ramp = egui::Rect::from_min_size(rect.min + vec2(6.0, 0.0), vec2(w - 12.0, 12.0));
let x_of = |v: u8| ramp.left() + ramp.width() * f32::from(v) / 255.0;
let v_of = |x: f32| (((x - ramp.left()) / ramp.width().max(1.0)) * 255.0).round().clamp(0.0, 255.0) as u8;
// The ramp, drawn as vertical strips.
let strips = 32;
for s in 0..strips {
let x0 = ramp.left() + ramp.width() * s as f32 / strips as f32;
let x1 = ramp.left() + ramp.width() * (s + 1) as f32 / strips as f32;
let g = (255.0 * (s as f32 + 0.5) / strips as f32) as u8;
ui.painter().rect_filled(egui::Rect::from_x_y_ranges(x0..=x1, ramp.y_range()), 0.0, Color32::from_gray(g));
}
ui.painter().rect_stroke(ramp, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
// Handles: triangles under the ramp; a split handle shows two half triangles.
let mut changed = false;
let drag_key = id.with("handle");
if resp.drag_started()
&& let Some(pos) = resp.interact_pointer_pos()
{
let near = (0..4).min_by(|a, b| (x_of(q[*a]) - pos.x).abs().total_cmp(&(x_of(q[*b]) - pos.x).abs())).unwrap_or(0);
// Between two coinciding halves, pick by the side of the pointer.
let near = match near {
0 | 1 if q[0] == q[1] => usize::from(pos.x > x_of(q[0])),
2 | 3 if q[2] == q[3] => 2 + usize::from(pos.x > x_of(q[2])),
n => n,
};
ui.data_mut(|d| d.insert_temp(drag_key, near));
}
if resp.dragged()
&& let (Some(h), Some(pos)) = (ui.data(|d| d.get_temp::<usize>(drag_key)), resp.interact_pointer_pos())
&& let Some(cur) = q.get(h).copied()
{
let target = v_of(pos.x);
let split = ui.input(|i| i.modifiers.alt);
let mut n = *q;
let pair = if h < 2 { 0 } else { 2 };
if split {
n[h] = target;
} else {
// Unsplit: the pair moves together, keeping its spread.
let delta = i16::from(target) - i16::from(cur);
for v in &mut n[pair..pair + 2] {
*v = (i16::from(*v) + delta).clamp(0, 255) as u8;
}
}
// Keep the order black low <= black high <= white low <= white high, pushing the others.
for i in 0..4 {
if i < h {
n[i] = n[i].min(n[h]);
} else if i > h {
n[i] = n[i].max(n[h]);
}
}
for i in 1..4 {
n[i] = n[i].max(n[i - 1]);
}
if n != *q {
*q = n;
changed = true;
}
}
let base = ramp.bottom() + 1.0;
let tri = |x: f32, half: i8, fill: Color32| {
let (l, r) = match half {
-1 => (x - 5.0, x),
1 => (x, x + 5.0),
_ => (x - 5.0, x + 5.0),
};
let pts = vec![egui::pos2(x, base), egui::pos2(r, base + 10.0), egui::pos2(l, base + 10.0)];
ui.painter().add(egui::Shape::convex_polygon(pts, fill, Stroke::new(1.0, t.text_dim)));
};
let dark = Color32::from_gray(20);
let light = Color32::from_gray(240);
for (pair, fill) in [(0usize, dark), (2, light)] {
let (a, b) = (q[pair], q[pair + 1]);
if a == b {
tri(x_of(a), 0, fill);
} else {
tri(x_of(a), -1, fill);
tri(x_of(b), 1, fill);
}
}
changed
}
/// Light direction uses document angles: counter-clockwise from three o'clock.
fn angle_row(ui: &mut egui::Ui, label: &str, angle: &mut f32) -> egui::Response {
let t = Tokens::get(ui.ctx());
@@ -1267,6 +1547,32 @@ mod tests {
}
}
#[test]
fn blending_page_renders_for_every_mode_and_odd_fields() {
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let mut layer = photocraft_doc::Layer::new("L", photocraft_doc::LayerContent::Adjustment(photocraft_doc::Adjustment::Invert));
layer.advanced.knockout = photocraft_doc::Knockout::Deep;
for mode in
[photocraft_color::ColorMode::Rgb, photocraft_color::ColorMode::Cmyk, photocraft_color::ColorMode::Lab, photocraft_color::ColorMode::Grayscale]
{
let mut f = initial_fields(&layer, Some(BLENDING), 120.0);
set_blending_fields(&mut f, &layer, mode);
let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut f));
out.textures_delta.clear();
assert_eq!(f["p:blendingOptions"]["knockout"], "deep", "{mode:?}");
// Hostile dialog state (only reachable through `ui.dialog.set`) still renders.
let bo = f.get_mut("p:blendingOptions").unwrap();
bo["blendIfChannel"] = json!(99);
bo["channels"] = json!("x");
bo["blendIf"] = json!([{"thisLayer": [300, -5, "a"]}]);
f.insert("channelNames".into(), json!([]));
let mut out = ctx.run_ui(Default::default(), |ui| body(&mut app, ui, &mut f));
out.textures_delta.clear();
}
}
#[test]
fn confirm_rejects_invalid_params_and_applies_valid_effects() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
@@ -1284,6 +1590,55 @@ mod tests {
assert_eq!(app.session.active().unwrap().doc.layer(layer.id).unwrap().effects.items.len(), 1);
}
#[test]
fn blending_page_round_trips_advanced_blending_and_blend_if() {
use photocraft_doc::{BlendRange, Knockout};
for mode in ["rgb", "cmyk", "grayscale"] {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "mode": mode})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let mut l = st.doc.layer(id).unwrap().clone();
l.advanced.knockout = Knockout::Shallow;
l.advanced.blend_clipped = false;
l.excluded_channels = 0b1;
let gray = if st.doc.mode.color_channels() == 1 { 1 } else { 0 };
l.blend_if.set(gray, [BlendRange { black: [10, 30], white: [255, 255] }, BlendRange::FULL]);
let mut f = initial_fields(&l, None, 120.0);
set_blending_fields(&mut f, &l, st.doc.mode);
let names = channel_names(st.doc.mode);
assert_eq!(f["channelNames"], json!(names), "{mode}");
let p = &f[&format!("p:{BLENDING}")];
assert_eq!(p["knockout"], "shallow");
assert_eq!(p["blendClippedLayersAsGroup"], false);
assert_eq!(p["transparencyShapesLayer"], true);
assert_eq!(p["channels"].as_array().unwrap().len(), names.len());
assert_eq!(p["channels"][0], false);
assert_eq!(p["blendIf"][0]["thisLayer"], json!([10, 30, 255, 255]), "{mode}");
// Applying the page sets every switch through the engine command.
let mut f2 = f.clone();
let bo = f2.get_mut(&format!("p:{BLENDING}")).unwrap();
bo["knockout"] = json!("deep");
bo["vectorMaskHidesEffects"] = json!(true);
bo["blendIfChannel"] = json!(names.len().min(1));
let shown = preview_document(&st.doc, &s.patterns, &f2).unwrap();
let got = shown.layer(id).unwrap();
assert_eq!(got.advanced.knockout, Knockout::Deep, "{mode}");
assert!(got.advanced.vector_mask_hides_effects);
assert!(!got.advanced.blend_clipped);
assert_eq!(got.excluded_channels, 0b1);
assert_eq!(got.blend_if.get(gray)[0], BlendRange { black: [10, 30], white: [255, 255] }, "{mode}");
}
}
#[test]
fn blend_if_values_stay_ordered() {
assert_eq!(blend_quad(Some(&json!([200, 10, 5, 300]))), [200, 200, 200, 255]);
assert_eq!(blend_quad(None), [0, 0, 255, 255]);
assert_eq!(blend_quad(Some(&json!(["x", null]))), [0, 0, 255, 255]);
}
#[test]
fn percent_fields_round_trip_through_the_engine() {
let mut s = session();
+1 -1
View File
@@ -65,7 +65,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
| Dimension | Measured / evidence (2026-10-05) | Grade | Notes |
|---|---|---|---|
| Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. |
| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). |
| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error. 2026-10-07: io corpus 146/170, psd-tools corpus 236/309 (was 229: Advanced Blending knockouts), Photoshop oracles 132/258 | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). |
| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). |
| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. |
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
+5 -4
View File
@@ -7,8 +7,8 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| Area | Done | Partial | Missing | Key numbers |
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 3 | 11 | 13 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 8 | 14 | 8 | |
| [File compatibility](#file-compatibility) | 3 | 12 | 12 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 9 | 14 | 8 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 56 of 140 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
@@ -84,7 +84,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |
|---|---|---:|---:|---:|
| io corpus | `corpus/psd` | 170 | 146 | 169 |
| psd-tools corpus | `corpus/psd-tools` | 309 | 229 | 307 |
| psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 307 |
| photoshop oracles | `corpus/photoshop` | 256 | 133 | 258 |
| Id | Target | Status | Issue | Note |
@@ -104,7 +104,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping |
| FILE-215-14 | User swatch model with ACO/ASE import and export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | panels.rs:954 const SWATCHES: [[u8; 3]; 40] |
| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc effects.rs:57 Contour model only |
| FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No knockout / blend_interior / transparency_shapes in doc, compose or io |
| FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ |
| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only |
| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults |
| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) |
@@ -151,6 +151,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog |
| TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog |
| TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo |
| TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error |
## Workspace and UI
+2 -2
View File
@@ -113,9 +113,9 @@ note = "doc effects.rs:57 Contour model only"
[[item]]
id = "FILE-215-16"
target = "Advanced Blending (knockout, blend as group, mask hides effects) end to end"
status = "missing"
status = "partial"
issue = 215
note = "No knockout / blend_interior / transparency_shapes in doc, compose or io"
note = "doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ"
[[item]]
id = "FILE-215-17"