collective

Browse / github.com/storytold/photocraft / patch

Offset layer copies carry unlinked masks with the content (#9684)

Cross-document layer copies leave unlinked bitmap masks at source coordinates Copying a complete layer into another document with a nonzero offset moves its text/raster content but leaves an **unlinked bitmap mask at the source coordinates**. A masked source showing an 11-pixel-wide crop becomes the full 28-pixel-wide H immediately after copying. The unlink flag survives; the mask's position relative to its content is lost. Actual Photoshop 27.11.0 Copy/Paste preserves the original crop and the unlinked flag. Ordinary Move is a separate operation: it correctly leaves an unlinked mask stationary. ### Reproduce PhotoCraft CLI **0.16.0 (`b1794c6e0`)**, Windows, static A

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

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/layer_copy_cmds.rs crates/engine/src/cmd/layer_copy_cmds/tests.rs

diff --git a/crates/engine/src/cmd/layer_copy_cmds.rs b/crates/engine/src/cmd/layer_copy_cmds.rs--- a/crates/engine/src/cmd/layer_copy_cmds.rs+++ b/crates/engine/src/cmd/layer_copy_cmds.rs@@ -135,6 +135,23 @@ pub(crate) fn translate_copies(copies: &mut Document, destination: &Document, dx         for l in &mut copies.layers {             crate::commands::translate_layer(&snapshot, l, dx, dy);             crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy));+            translate_unlinked_masks(l, dx, dy);+        }+    }+}++/// Moves the masks the Move tool leaves in place: a copy carries all of a layer, so an unlinked+/// mask keeps its place relative to the content, as Photoshop's Copy/Paste keeps it (#9684).+fn translate_unlinked_masks(l: &mut Layer, dx: i32, dy: i32) {+    if let Some(m) = l.mask.as_mut().filter(|m| !m.linked) {+        m.surface = crate::layer_multi_cmds::shift_surface(&m.surface, dx, dy);+    }+    if let Some(vm) = l.vector_mask.as_mut().filter(|vm| !vm.linked) {+        vm.path = vm.path.transform(&photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy)));+    }+    if let LayerContent::Group(g) = &mut l.content {+        for c in &mut g.children {+            translate_unlinked_masks(c, dx, dy);         }     } }diff --git a/crates/engine/src/cmd/layer_copy_cmds/tests.rs b/crates/engine/src/cmd/layer_copy_cmds/tests.rs--- a/crates/engine/src/cmd/layer_copy_cmds/tests.rs+++ b/crates/engine/src/cmd/layer_copy_cmds/tests.rs@@ -88,6 +88,30 @@ fn places_the_copies_centred_on_the_canvas_on_a_point_or_offset() {     assert_eq!(doc(&s, 1).layers.len(), 4); } +/// #9684: an unlinked mask stays put when the layer moves, but a copy carries the whole layer, so+/// an offset copy keeps its masks where they were on the content, as Photoshop's Copy/Paste does.+#[test]+fn an_offset_copy_carries_unlinked_masks_with_the_content() {+    let (mut s, paint) = two_docs(40, 30, 8);+    s.execute("select.rect", json!({"x": 9, "y": 0, "width": 31, "height": 30})).unwrap();+    s.execute("layer.layerMask.revealSelection", json!({})).unwrap();+    s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();+    let square = json!({"subpaths": [{"closed": true, "knots": [[0, 8], [40, 8], [40, 30], [0, 30]]}]});+    s.execute("layer.vectorMask.add", json!({"path": square})).unwrap();+    s.execute("layer.vectorMask.linked", json!({"linked": false})).unwrap();+    let r = s.execute("layer.copyToDocument", json!({"document": 1, "layers": [paint.0], "offset": [20, 10]})).unwrap();+    let l = doc(&s, 1).layer(copied(&r)).unwrap();+    let mask = l.mask.as_ref().unwrap();+    let reveals = |x: i32, y: i32| mask.surface.read_region(Rect::new(x, y, x + 1, y + 1))[0] > 0.5;+    assert!(!reveals(28, 20) && reveals(29, 20), "the bitmap mask's edge moved from x 9 to 29");+    assert!(!mask.linked, "and it stays unlinked");+    let vm = l.vector_mask.as_ref().unwrap();+    assert_eq!(vm.path.control_bounds(), Some((20.0, 18.0, 60.0, 40.0)), "the vector mask moved with it");+    assert!(!vm.linked);+    let src = doc(&s, 0).layer(paint).unwrap();+    assert!(src.mask.as_ref().unwrap().surface.read_region(Rect::new(9, 20, 10, 21))[0] > 0.5, "the source is untouched");+}+ #[test] fn a_copied_background_is_an_ordinary_layer_and_several_layers_keep_their_order() {     let (mut s, paint) = two_docs(40, 30, 8);