collective

Browse / github.com/storytold/photocraft / patch

Convert to Layers keeps a knockout layer isolated in a group (#9753)

Convert to Layers loses isolation for single-layer knockout contents **Convert to Layers removes a required isolation boundary when a Smart Object contains one knockout layer.** The child is promoted directly into the parent stack, so its Shallow/Deep knockout begins punching through unrelated parent layers. Photoshop keeps the child in a Normal group and preserves the image. Reproduced on Windows 11 with PhotoCraft CLI **0.16.0 (`b1794c6e0`, 2026-10-25)** and actual Photoshop **27.11.0**. The Type, knockout and Fill settings survive; this is a compositing-scope change. ## Reproduction [Self-contained original regression and numeric observations](https

Status
open
Score
0.00
Adopters
0 other operators
Author
NaufalDzz (imported from pull request #9858, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+36 −2 in 2 files
Review
pending
Shared
2026-10-28
Id
ec1cd3e40e274f9cb69517d5

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/smart_cmds/tests.rs crates/engine/src/cmd/smart_cmds/unpack.rs

diff --git a/crates/engine/src/cmd/smart_cmds/tests.rs b/crates/engine/src/cmd/smart_cmds/tests.rs--- a/crates/engine/src/cmd/smart_cmds/tests.rs+++ b/crates/engine/src/cmd/smart_cmds/tests.rs@@ -929,6 +929,38 @@ fn convert_to_layers_keeps_a_smart_layer_mask_on_a_group() {     assert_eq!(max_diff(&flat(&s), &before), 0.0); } +/// #9753: a smart object isolates its contents, so a knockout inside it stops at its bottom.+/// Folded into the parent stack, it would punch through the layers beneath to the Background; a+/// Normal group keeps the look, as Photoshop's Convert to Layers does.+#[test]+fn convert_to_layers_keeps_a_knockout_inside_a_group() {+    let fill = |s: &mut Session, r: Rect, rgba: [f32; 4]| {+        s.edit("paint", |doc, active| {+            doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(r, &rgba);+            Ok(())+        })+        .unwrap();+    };+    for knockout in ["shallow", "deep"] {+        let mut s = Session::new();+        s.execute("file.new", json!({"width": W, "height": H})).unwrap();+        fill(&mut s, Rect::new(0, 0, W, H), [0.0, 0.0, 1.0, 1.0]);+        s.execute("layer.new.layer", json!({})).unwrap();+        fill(&mut s, Rect::new(0, 0, W, H), [1.0, 1.0, 0.0, 1.0]);+        s.execute("layer.new.layer", json!({})).unwrap();+        fill(&mut s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);+        s.execute("layer.layerStyle.blendingOptions", json!({"knockout": knockout, "fillOpacity": 50})).unwrap();+        convert(&mut s);+        let before = flat(&s);+        let r = convert_to_layers(&mut s);+        assert_eq!(r["group"], true, "{knockout}: the contents stay isolated");+        let g = active_layer(&s);+        assert_eq!(g.blend, BlendMode::Normal, "{knockout}");+        assert!(max_diff(&flat(&s), &before) <= 1.0 / 255.0 + 1e-6, "{knockout}: the look is kept");+        assert_eq!(g.children().unwrap()[0].advanced.knockout.name(), knockout, "the knockout setting survives");+    }+}+ #[test] fn convert_to_layers_applies_the_placement() {     let mut s = session(8);diff --git a/crates/engine/src/cmd/smart_cmds/unpack.rs b/crates/engine/src/cmd/smart_cmds/unpack.rs--- a/crates/engine/src/cmd/smart_cmds/unpack.rs+++ b/crates/engine/src/cmd/smart_cmds/unpack.rs@@ -9,7 +9,7 @@ use std::sync::Arc; use photocraft_algo::transform::Interp; use photocraft_cms::Intent; use photocraft_color::BlendMode;-use photocraft_doc::{Document, Group, Layer, LayerContent, LayerId, Metadata, SmartObject, SmartSource};+use photocraft_doc::{Document, Group, Knockout, Layer, LayerContent, LayerId, Metadata, SmartObject, SmartSource}; use serde_json::{Value, json};  use super::{authorize_source, authorized_source_bytes, decode_source, detach_psd, other, placement, shift_layer, smart};@@ -125,7 +125,8 @@ fn released(l: &mut Layer, f: impl FnOnce(&mut Layer) -> Result<()>) -> Result<( /// Whether the smart layer's own properties fold into its one unpacked layer `c` without /// changing the look: nothing on the smart layer acts on `c`'s composite (masks, style, fill /// opacity, Blend If, channel exclusions), and `c` blends normally and doesn't look at what's-/// beneath it (alone in the smart object, it lands on transparency).+/// beneath it (alone in the smart object, it lands on transparency; a knockout there stops at+/// the smart object's bottom instead of punching through to the Background, #9753). fn foldable(so: &Layer, c: &Layer) -> bool {     so.mask.is_none()         && so.vector_mask.is_none()@@ -137,6 +138,7 @@ fn foldable(so: &Layer, c: &Layer) -> bool {         && matches!(c.blend, BlendMode::Normal | BlendMode::PassThrough)         && c.blend_if.is_default()         && c.excluded_channels == 0+        && c.advanced.knockout == Knockout::None }  /// What replaces smart layer `so`: its one unpacked layer with `so`'s properties folded in, or a