Browse / github.com/storytold/photocraft / patch
fix(raster): layer bounds ignore the colour under clear pixels (#9823)
Layer bounds and Free Transform measure the colour under clear pixels, so a PNG with white transparency opens as the whole canvas ## What happens A PNG whose fully transparent pixels carry a colour (encoders commonly leave white there with unassociated alpha) opens at the file's size, as it should, but the layer measures as the whole canvas instead of the artwork. Free Transform's box is the canvas, `layer.list` reports the canvas, and Align/Distribute treat the canvas edges as the layer's. Photoshop bounds a raster layer by the pixels whose **alpha** is not zero: the colour under a clear pixel is invisible, so it is not content. ## Reproduction A file 6851 x 11693 px, 300 dpi, whose clear pixels are `(255, 255, 255,
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- stoktiks (imported from pull request #9824, open)
- Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035- Size
- +135 −7 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
ec8b92993d1d51662ea3a592
How it went
No other operator has reported using it yet.
Diff
raw · crates/compose/src/bounds.rs crates/compose/src/filter_mask.rs crates/engine/src/cmd/mode_cmds.rs crates/io/tests/it/flat.rs crates/raster/src/lib.rs crates/raster/src/tests.rs
@@ -41,7 +41,14 @@ pub fn content_bounds(s: &Surface) -> Rect { } let fmt = s.format(); let mut dp = vec![0u8; fmt.bytes_per_pixel()];- photocraft_raster::encode_pixel(&fmt, &s.default_pixel(), &mut dp);+ let default = s.default_pixel();+ photocraft_raster::encode_pixel(&fmt, &default, &mut dp);+ // As in `Surface::content_bounds`: a clear pixel's colour is not content, so a surface that+ // has an alpha channel and defaults to transparent measures by its alpha.+ let sample = fmt.sample;+ let alpha_at = fmt.mode.color_channels() * sample.bytes();+ let by_alpha = fmt.alpha && default.get(fmt.mode.color_channels()).is_some_and(|&alpha| alpha == 0.0);+ let bpp = fmt.bytes_per_pixel(); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); if c.entries.len() > c.sweep_at { c.entries.retain(|_, e| e.tile.strong_count() > 0);@@ -54,7 +61,7 @@ pub fn content_bounds(s: &Surface) -> Rect { let b = match hit { Some(b) => b, None => {- let b = t.content_bounds(&dp);+ let b = if by_alpha { t.alpha_bounds(alpha_at, bpp, sample) } else { t.content_bounds(&dp) }; c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); b }@@ -88,4 +95,15 @@ mod tests { assert_eq!(content_bounds(&s), Rect::new(-300, 10, 1300, 900)); assert_eq!(content_bounds(&s), s.content_bounds(), "cached"); }++ #[test]+ fn a_clear_pixels_colour_is_not_content_here_either() {+ // The cached path measures by alpha like `Surface::content_bounds`, so the transform box+ // and the layer listing agree on a file whose clear pixels hold white.+ let mut s = Surface::new(photocraft_color::PixelFormat::RGBA8);+ s.fill_rect(Rect::new(0, 0, 300, 300), &[1.0, 1.0, 1.0, 0.0]);+ s.fill_rect(Rect::new(70, 41, 270, 241), &[0.0, 0.0, 0.0, 1.0]);+ assert_eq!(content_bounds(&s), Rect::new(70, 41, 270, 241));+ assert_eq!(content_bounds(&s), s.content_bounds(), "cached and surface agree");+ } }@@ -15,7 +15,11 @@ use photocraft_raster::Surface; /// channel layout. Pixels the mask hides entirely are `base`'s. pub fn mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface { let fmt = base.format();- let area = base.content_bounds().union(&filtered.content_bounds());+ // Every tile that holds data, not the exact content: Photoshop's cache carries white under+ // the clear pixels, and where the mask hides the filter those have to be written empty, so a+ // tile holding only clear-but-white pixels still needs visiting (`content_bounds` no longer+ // reports them — a clear pixel's colour is not content).+ let area = base.tile_bounds().union(&filtered.tile_bounds()); let mut out = filtered.clone(); if area.is_empty() || filtered.format() != fmt { return out;@@ -66,7 +70,8 @@ const HIDDEN: f32 = 1.0 / 1024.0; /// doesn't show there. pub fn unmix(base: &Surface, mixed: &Surface, mask: &LayerMask) -> Surface { let fmt = base.format();- let area = base.content_bounds().union(&mixed.content_bounds());+ // As in [`mix`]: tiles, since a tile of clear-but-white pixels still has to be written.+ let area = base.tile_bounds().union(&mixed.tile_bounds()); let mut out = mixed.clone(); if area.is_empty() || mixed.format() != fmt { return out;@@ -371,7 +371,9 @@ fn color_table(s: &mut Session, p: &Value) -> Result<Value> { s.edit("Color Table", |doc, _| { let old = cur.clone(); for surf in doc.layers.iter_mut().filter_map(Layer::surface_mut) {- let r = surf.content_bounds();+ // Every stored pixel, not just the visible ones: a pixel the old transparent entry+ // cleared holds its colour with a zero alpha, and clearing the entry restores it.+ let r = surf.stored_bounds(); if r.is_empty() { continue; }@@ -61,6 +61,31 @@ codec_rt!(cin_gray16, "cin", ColorMode::Grayscale, SampleType::U16, false, 0.5 / codec_rt!(cin_rgb16, "cin", ColorMode::Rgb, SampleType::U16, false, 0.5 / 1023.0); codec_rt!(iff_rgb8_opaque, "iff", ColorMode::Rgb, SampleType::U8, false, 0.0); +#[test]+fn a_clear_pixels_colour_is_not_the_layers_bounds() {+ // A PNG may store white under its clear pixels (unassociated alpha). Photoshop opens the+ // document at the file's size but bounds the layer by the pixels whose alpha is not zero;+ // measuring whole pixels instead made the layer span the canvas and Free Transform report+ // the canvas instead of the artwork.+ use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType as CS};+ let (w, h) = (8u32, 6u32);+ let mut px = Vec::new();+ for y in 0..h {+ for x in 0..w {+ let painted = (2..6).contains(&x) && (1..5).contains(&y);+ px.extend_from_slice(if painted { &[0u8, 0, 0, 255] } else { &[255, 255, 255, 0] });+ }+ }+ let img = Image::from_u8(w, h, ChannelLayout::Rgba, px).expect("image");+ let bytes = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).expect("encode");+ let d = import("clear.png", &bytes).expect("import").document;+ assert_eq!(d.size, photocraft_geom::Size::new(w, h), "the canvas is the file's");+ let s = d.layers[0].surface().expect("raster layer");+ assert_eq!(s.content_bounds(), photocraft_geom::Rect::new(2, 1, 6, 5), "the artwork's box");+ // The colour under the clear pixels is kept: only the bounds ignore it.+ assert_eq!(s.pixel(0, 0), vec![1.0, 1.0, 1.0, 0.0]);+}+ fn indexed_bmp_8() -> Vec<u8> { const FILE_HEADER_SIZE: usize = 14; const INFO_HEADER_SIZE: usize = 40;@@ -113,6 +113,37 @@ impl Tile { } (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) }++ /// Tile-local half-open bounds `(x0, y0, x1, y1)` of the pixels whose alpha sample is not+ /// zero, or `None` when every pixel is clear. `alpha_at` is the alpha sample's byte offset+ /// within a pixel of `bpp` bytes.+ ///+ /// The colour stored under a clear pixel is invisible, so it is not content: comparing whole+ /// pixels against a transparent default (`content_bounds`) would count the white an encoder+ /// leaves there, and a layer whose clear pixels are white would measure as its whole canvas+ /// where Photoshop measures the artwork.+ pub fn alpha_bounds(&self, alpha_at: usize, bpp: usize, sample: SampleType) -> Option<(u16, u16, u16, u16)> {+ if bpp == 0 || alpha_at + sample.bytes() > bpp {+ return None;+ }+ let clear = |px: &[u8]| match sample {+ SampleType::U8 => px[alpha_at] == 0,+ SampleType::U16 => px[alpha_at] == 0 && px[alpha_at + 1] == 0,+ SampleType::F32 => f32::from_ne_bytes([px[alpha_at], px[alpha_at + 1], px[alpha_at + 2], px[alpha_at + 3]]) == 0.0,+ };+ let ts = TILE_SIZE as usize;+ let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);+ for (y, row) in self.data.chunks_exact(ts * bpp).enumerate() {+ let pixels = row.chunks_exact(bpp);+ let Some(first) = pixels.clone().position(|px| !clear(px)) else { continue };+ let last = pixels.clone().rposition(|px| !clear(px)).unwrap_or(first);+ x0 = x0.min(first);+ x1 = x1.max(last + 1);+ y0 = y0.min(y);+ y1 = y + 1;+ }+ (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16))+ } } /// Bytes compared at once while looking for a difference in a tile row.@@ -438,11 +469,32 @@ impl Surface { self.tiles.keys().fold(Rect::EMPTY, |r, c| r.union(&c.rect())) } - /// Exact bounds of pixels that differ from the default pixel.+ /// Exact bounds of the pixels that differ from the default pixel — everything the surface+ /// stores, clear or not. A reader that re-derives something from the stored samples (the+ /// alpha an Indexed document's colour table implies, say) needs these: [`content_bounds`]+ /// leaves out a clear pixel whose colour an encoder left behind, and that pixel is stored.+ pub fn stored_bounds(&self) -> Rect {+ self.scan_bounds(false)+ }++ /// Exact bounds of pixels that differ from the default pixel; for a surface that carries an+ /// alpha channel and defaults to transparent, the bounds of the pixels that are not clear+ /// (see [`Tile::alpha_bounds`]). pub fn content_bounds(&self) -> Rect {+ self.scan_bounds(true)+ }++ fn scan_bounds(&self, content: bool) -> Rect { if self.tiles.is_empty() { return Rect::EMPTY; }+ // A clear pixel's colour is invisible, so it is not content: a layer whose clear pixels+ // hold white measures as the artwork, as Photoshop's bounds have it, instead of as its+ // whole canvas.+ let bpp = self.format.bytes_per_pixel();+ let sample = self.format.sample;+ let alpha_at = self.format.mode.color_channels() * sample.bytes();+ let by_alpha = content && self.format.alpha && self.default_pixel[alpha_at..alpha_at + sample.bytes()].iter().all(|&b| b == 0); // Scan tiles from the outside of the tile grid inwards; a tile lying entirely inside the // bounds found so far cannot extend them and is skipped (so a full-canvas selection scans // roughly its outer ring of tiles instead of every pixel).@@ -458,7 +510,8 @@ impl Surface { if !out.is_empty() && out.contains_rect(&origin) { continue; }- if let Some((x0, y0, x1, y1)) = t.content_bounds(&self.default_pixel) {+ let found = if by_alpha { t.alpha_bounds(alpha_at, bpp, sample) } else { t.content_bounds(&self.default_pixel) };+ if let Some((x0, y0, x1, y1)) = found { out = out.union(&Rect::new(x0.into(), y0.into(), x1.into(), y1.into()).translate(origin.x0, origin.y0)); } }@@ -392,6 +392,31 @@ fn content_bounds_and_prune() { assert_eq!(s.content_bounds(), Rect::new(300, 10, 310, 20)); } +#[test]+fn a_clear_pixels_colour_is_not_content() {+ // An encoder may leave white under the clear pixels (unassociated alpha). The layer's bounds+ // are the pixels whose alpha is not zero, as Photoshop measures them, not the white's box.+ let mut s = Surface::new(PixelFormat::RGBA8);+ s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 1.0, 1.0, 0.0]);+ s.fill_rect(Rect::new(40, 30, 60, 50), &[0.0, 0.0, 0.0, 1.0]);+ assert_eq!(s.content_bounds(), Rect::new(40, 30, 60, 50));+ // The colour is still there: only the bounds ignore it.+ assert_eq!(s.pixel(0, 0), vec![1.0, 1.0, 1.0, 0.0]);+ // The sample type decides what "clear" is.+ let mut s16 = Surface::new(PixelFormat::RGBA16);+ s16.fill_rect(Rect::new(0, 0, 20, 20), &[1.0, 1.0, 1.0, 0.0]);+ s16.fill_rect(Rect::new(3, 4, 9, 10), &[0.0, 0.0, 0.0, 1.0]);+ assert_eq!(s16.content_bounds(), Rect::new(3, 4, 9, 10));+ // A surface with no alpha channel has only its colour to measure.+ let mut g = Surface::new(PixelFormat::GRAY8);+ g.fill_rect(Rect::new(0, 0, 10, 10), &[1.0]);+ assert_eq!(g.content_bounds(), Rect::new(0, 0, 10, 10));+ // Nor does an opaque default pixel turn a colour into a clear one.+ let mut opaque = Surface::with_default(PixelFormat::RGBA8, &[1.0, 1.0, 1.0, 1.0]);+ opaque.fill_rect(Rect::new(2, 2, 5, 5), &[0.0, 0.0, 0.0, 0.0]);+ assert_eq!(opaque.content_bounds(), Rect::new(2, 2, 5, 5));+}+ #[test] fn interleaved_roundtrip_crossing_tiles() { let r = Rect::new(-20, 250, 280, 262);