From 2807deb20971cf84f52bf7397fbafafa89ac2d26 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Wed, 14 Oct 2026 23:03:59 +0100 Subject: [PATCH] fix(psd): write the layer block after a kept merged-transparency marker (#5673) A 16/32-bit document saved as PSD has its layers in a global Lr16/Lr32 block, which the writer put first among the global blocks. A Mtrn/Mt16/Mt32 marker kept from the opened file then followed it, and Photoshop refuses such a file (verified in Photoshop 2026 with psd-tools' transparentbg.psd re-saved both ways: Mt32 first opens, Lr32 first doesn't). The layer block now goes right after the last kept marker, as Photoshop orders them. Fixes #5068. Co-authored-by: Claude Opus 5.5 --- crates/io/src/psd_export.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 47861dbcc3..4fb952ba25 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1746,9 +1746,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); + // Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`) before the layer + // block, and won't open a file whose `Lr32` comes first (#5068): the layer block goes right + // after the last kept marker, else first. + let index = global_blocks.iter().rposition(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")).map_or(0, |i| i + 1); let placement = match (has_layers, sample) { - (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None }, - (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr32", padding: None }, + (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr16", padding: None }, + (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr32", padding: None }, _ => LayerInfoPlacement::Section, }; let records = std::mem::take(&mut ex.records); @@ -1865,6 +1869,31 @@ mod tests { assert_eq!(unique, reloaded_ids); } + /// #5068: Photoshop won't open a file whose `Lr32` comes before the `Mt32` marker it keeps for + /// a 32-bit document's merged transparency (verified in Photoshop 2026 on psd-tools' + /// `transparentbg.psd` re-saved both ways). The layer block follows a kept marker. + #[test] + fn layer_block_follows_a_kept_merged_transparency_marker() { + for (depth, key, marker) in [(SampleType::F32, *b"Lr32", *b"Mt32"), (SampleType::U16, *b"Lr16", *b"Mt16"), (SampleType::F32, *b"Lr32", *b"Mtrn")] { + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(photocraft_geom::Rect::new(1, 1, 5, 4), &[0.5, 0.2, 0.9, 1.0]); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())), (*b"8BIM", marker, std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + let LayerInfoPlacement::GlobalBlock { index, key: written, .. } = saved.layer_info_placement else { panic!("{depth:?}: a global layer block") }; + assert_eq!(written, key); + let at = saved.global_blocks.iter().position(|b| b.key == marker).expect("the marker is kept"); + assert!(at < index, "{depth:?}: {marker:?} at {at}, the layer block at {index}"); + } + // Without a marker the layer block stays first. + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, SampleType::F32); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. })); + } + #[test] fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() { // #2700: a 16-bit file whose Lr16 block claims five layers but holds none.