From cd75fa78efe47736d90542006f52e922eaeef416 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Fri, 16 Oct 2026 17:38:42 -0400 Subject: [PATCH] PSD/PSB: a 16/32-bit file's "Transparency" channel stays the merged transparency on re-save (#4654) Photoshop's 16-bit PSBs can mark the merged transparency only by naming it "Transparency" in the alpha channel names (positive Lr16 layer count, no Mt16). The importer read it as a user alpha channel, and the next save wrote a second one. merged_has_alpha now also accepts that name when the layers sit in a global Lr16/Lr32 block and the names cover every extra channel. Co-Authored-By: Claude Opus 5.5 --- crates/io/tests/psd16_merged_transparency.rs | Bin 0 -> 3511 bytes crates/io/tests/psd32_export.rs | 4 +-- crates/psd/src/file.rs | 36 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 crates/io/tests/psd16_merged_transparency.rs diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/psd16_merged_transparency.rs new file mode 100644 index 0000000000000000000000000000000000000000..3f04970626e7f21e776f84241be0ee3a46d66a99 GIT binary patch literal 3511 zcmdPbS5)vXNX^U4OIJwDOHn9F)h$je1M?IDGV)9Ei!<^I6b#Mujg54ZGD{TFGILTD z$}{qdQx$Ski_%k56iSK`^NI@+i&FEFD-|+}6><}cvQtwO^7C>k6_P5sKo(?{6f5K< z=B6rSr=}JZE0koUDkNtl=H;d4C?w~X=9O51g_J^&Oj3fY$b@Rh&n?I=&MZk)FIGs* zDac6FQOL_LQAh+^P+FW?1QCU6E6GSK0UMi_T2Z1X-Z;7ETG-^Gl18QxyyiOiVR(6jE~wN-7n~GfOh^OG^|oOZ2$X z^1!LCSOb)3trXOt7HBHy+9`ykCffv;6lLb6+bL9YDJUo?~b{oYXuGO{foGzQ|LsRVXz!va(7m%Fm5YN{vsdEJ-Z}`zt!JL{AT5M66y(QDuB) zUP-=&re0}Yc~N44hNh-MVzEMLab{I2#7>xzrFoep#a19^+LRia*(pG5kA|28Gf_cX zAx}X|!6;TQIioZ$yI8|W6A~`TH43GMX6WvTPL4K+)lo=}HjItc)YMDP&&f$mF453L z*qmQlqE}E_oS^}UDX0OZC259c@j3a$#g!UrV25eyl@w*>#;4|`#OEfKBxj@+YpBN< zsB7w#P)X)q$k6Br{85_kS#UMy$ zK_XP6QiWbYaf*_T0+^Acgsc!2H_3^`si5SVUzD3zqNt%%4Yt?HzE+_cWKOLTlGkB+ zKq)UJKUu+60hXB|$y2FB36gUmTnilqGff=@c;R4W6_lQ&1MwIZQ`2%m$u%WES+5|o zA~h#I4eTW37zNo6k_VTN#d_e#g_KMhO2rw81*u9p3Te3|ItrjN1mXrm9R))j1ydab z6HOfjV;zN(qS934*aR5~%DefcMa2rX3eZdrO)HR;ua;T?Dz!D#Qu32^6hMKZqoC#r z7V|GC$;{6ywz5h|O-n4zDbdi>)Jv@>03}2vh%O~fJ+LVV@q)yn;#BldO)M@>Eh>pm zEmYJ1xkN7`H8CZ%NDo#t>nND$C@58f;;Pn40aWUi7Aa_Bt3yEsgVQ3&V<4=rk5VUr z0@VsfZ3C)f6d?7Beu8g_u~7n~Ixd3LGoXqWl4#K_Ku*KpLNf)FZb9n7buFx#14|X_ zq38yMJt#R9fI=Va_2iuVyj1j9FDTXnM|yl_URu6hVsU(KX^93ROu=%+N}75Q_r*i3 zwN*$<%qd1Hi$LK~P^_1plb@8B6A!LJi}i|9OA<5lG-{G+6cnNPUr|FXNiRFKQb!?4 z$+x5^Pf4K$%rZ2Cv5bwB&{Gj43KcaJ3yNXR$Ve=PI7LGfDf;sAOEN%BBv3g}tdLov zP?=f+iZ+nzU>FoEpuz%LXj*{E%xHsB3mpXmFfufb)dLm2@z8oDIRoSelp3EwNiQ`I zQ~}0=i#$-}lap8y52|Xw#c4@Cyl}@}euCUm016*SO$lN_LaGQkq(Bv!j)I|qftf-L zq&7p#+9*bVvn)~r0$g=_<|d}620<%iXtp&lG1UR57hJ(iD3n2|4-|snBn)W;LJ|i` zlM@u~80j&;AT=*FMZs1fGZ&ok3ld8*bQIJIia|93I5#6DJ#e#72`S%0G%0E7rNA3H zppb-NP|SiFoRFA}2SqHzK6qw_wG}`yRg+w!kgNyt2Pn}OmlT0Y7%X)i$aGi&1evav zSsV{;(x9b01&}0KYY|k>gIlM_+1N^<8l=?99$W!n1QDcO1BDUZ5)?tR9#%< zW>G4kk`wAE{Do&Cv^AeulA(~7uaKGo>Ry2BXK)xmOI%3v8q*JGtnjbafr@?frqv{7ogYPkSt)KjVe literal 0 HcmV?d00001 diff --git a/crates/io/tests/psd32_export.rs b/crates/io/tests/psd32_export.rs index 9ff2b4113b..b4707fadb0 100644 --- a/crates/io/tests/psd32_export.rs +++ b/crates/io/tests/psd32_export.rs @@ -226,11 +226,11 @@ fn mt32_marks_the_merged_transparency() { let plane = |v: [f32; 2]| v.into_iter().flat_map(f32::to_be_bytes).collect::>(); file.image_data.data = [plane([0.0, 1.0]), plane([0.0, 1.0]), plane([0.0, 1.0]), plane([1.0, 0.0])].concat(); let name: Vec = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect(); - file.resources.retain(|r| r.id != 1045); - file.resources.push(photocraft_psd::ImageResource::new(1045, name)); + file.resources.retain(|r| !matches!(r.id, 1006 | 1045)); assert!(!file.merged_has_alpha(), "a positive layer count alone"); file.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"Mt32", Vec::new())); assert!(file.merged_has_alpha()); + file.resources.push(photocraft_psd::ImageResource::new(1045, name)); let merged = merged_composite(&file).unwrap(); assert_eq!(merged.iter().map(|p| p[3]).collect::>(), vec![1.0, 0.0]); let opened = import("transparent.psd", &file.to_bytes().unwrap()).unwrap(); diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index e14bc45aaf..fab262732b 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -354,7 +354,10 @@ impl PsdFile { /// `true` if the merged image carries a transparency channel: its first extra channel, when /// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`, /// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive - /// layer count (psd-tools transparentbg.psd). + /// layer count (psd-tools transparentbg.psd). Photoshop also names the merged transparency + /// "Transparency" and lists it first among the alpha channel names, and some of its 16-bit + /// PSBs carry no other mark (#4654): when the layers sit in a global `Lr16`/`Lr32` block, that + /// name with one name per extra channel marks it too. pub fn merged_has_alpha(&self) -> bool { let Some(color) = self.header.color_mode.color_channels() else { return false; @@ -364,11 +367,40 @@ impl PsdFile { } let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")); match &self.layer_info { - Some(li) => li.merged_alpha || marked, + Some(li) => { + let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); + li.merged_alpha || marked || (in_block && self.names_merged_transparency(self.header.channels - color)) + } None => true, } } + /// `true` when the alpha channel names (resource 1045, else 1006) hold exactly `extra` names + /// and the first is "Transparency", Photoshop's name for the merged transparency. + fn names_merged_transparency(&self, extra: u16) -> bool { + const NAME: &str = "Transparency"; + let mut names = Vec::new(); + if let Some(r) = self.resource(1045) { + let mut at = 0usize; + while let Some(len) = r.data.get(at..at.saturating_add(4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes) { + at = at.saturating_add(4); + let end = (len as usize).checked_mul(2).and_then(|n| at.checked_add(n)); + let Some(units) = end.and_then(|end| r.data.get(at..end)) else { break }; + let units: Vec = units.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + names.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()); + at = at.saturating_add(units.len() * 2); + } + } else if let Some(r) = self.resource(1006) { + let mut at = 0usize; + while let Some(&len) = r.data.get(at) { + let Some(name) = r.data.get(at + 1..at + 1 + usize::from(len)) else { break }; + names.push(String::from_utf8_lossy(name).into_owned()); + at += 1 + usize::from(len); + } + } + names.len() == usize::from(extra) && names.first().is_some_and(|n| n == NAME) + } + /// Checks structural invariants that the writer relies on. pub fn validate(&self) -> Result<()> { self.header.validate()?;