diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index 365d551504..18b3f47abc 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -168,7 +168,7 @@ pub(crate) fn layerless_file( let global_blocks: Vec = crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc)) .into_iter() .filter(|(_, key, _)| !matches!(key, b"Layr" | b"Lr16" | b"Lr32")) - .map(|(sig, key, data)| crate::psd_export::global_block(sig, key, &data)) + .map(|(sig, key, data)| crate::psd_export::global_block(crate::psd_export::block_signature(sig, header.version), key, &data)) .collect(); PsdFile { header, diff --git a/crates/io/src/palette_map.rs b/crates/io/src/palette_map.rs index a279af0f4a..b268efcb9c 100644 --- a/crates/io/src/palette_map.rs +++ b/crates/io/src/palette_map.rs @@ -17,10 +17,15 @@ pub(crate) const TRANSPARENCY_INDEX: u16 = 1047; /// Channel resources (names, display info, alpha identifiers): these modes have no extra channels. const CHANNEL_RESOURCES: [u16; 4] = [1006, 1045, 1053, crate::channel_map::DISPLAY_INFO]; -/// The PSD for an Indexed Color document with a colour table of at most 256 entries, or a Bitmap -/// document; None for any other document (it is written like the others). +/// The PSD for an Indexed Color document with a colour table of at most 256 entries, one of them +/// opaque, or a Bitmap document; None for any other document (it is written like the others). A +/// document with alpha channels or a Quick Mask is written like the others too: the importer does +/// not read the extra channels of these modes back, so they would be lost. pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFile, Vec)> { - let table = doc.color_table.as_ref().filter(|t| !t.colors.is_empty() && t.colors.len() <= 256); + if !doc.channels.is_empty() || doc.quick_mask.is_some() { + return None; + } + let table = doc.color_table.as_ref().filter(|t| t.colors.len() <= 256 && (0..t.colors.len()).any(|i| Some(i) != t.transparent.map(usize::from))); let (mode, plane, color_mode_data) = match doc.mode { ColorMode::Indexed => { let table = table?; @@ -52,6 +57,11 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFil if let Some(i) = t.transparent { resources.push(ImageResource::new(TRANSPARENCY_INDEX, u16::from(i).to_be_bytes().to_vec())); } + // Photoshop keeps the RGB profile of an Indexed document (Bitmap has none). A profile of + // another colour space (kept from a Grayscale or CMYK source) does not describe the table. + if let Some(icc) = doc.icc_profile.as_ref().filter(|icc| icc.get(16..20) == Some(b"RGB ".as_slice())) { + resources.push(ImageResource::new(ids::ICC_PROFILE, icc.to_vec())); + } } // An imported transparency index that no longer applies is not copied either. let keep = |id: u16, _: &[u8]| !CHANNEL_RESOURCES.contains(&id) && id != TRANSPARENCY_INDEX && id != COLOR_TABLE_COUNT; @@ -70,14 +80,23 @@ fn palette(t: &ColorTable) -> Vec { m } -/// The flattened document as palette indices (the nearest entry; the transparent one where the -/// document is less than half covered). +/// The flattened document as palette indices: the transparent entry where the document is less +/// than half covered, else the nearest other entry (the transparent one may duplicate an opaque +/// colour, as in many PNG-8 files). Without a transparent entry the document is flattened over +/// white, as Photoshop does. fn indices(doc: &Document, t: &ColorTable) -> Vec { + let (opaque_indices, colors): (Vec, Vec<_>) = + t.colors.iter().copied().enumerate().filter(|(i, _)| Some(*i) != t.transparent.map(usize::from)).map(|(i, c)| (i as u8, c)).unzip(); + let opaque = ColorTable { colors, transparent: None }; let mut idx = Vec::with_capacity(doc.size.area() as usize); let _ = photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { idx.extend(band.px.iter().map(|p| match t.transparent { Some(i) if p[3] < 0.5 => i, - _ => t.nearest([p[0], p[1], p[2]]) as u8, + Some(_) => opaque_indices.get(opaque.nearest([p[0], p[1], p[2]])).copied().unwrap_or(0), + None => { + let over_white = |c: f32| c * p[3] + (1.0 - p[3]); + t.nearest([over_white(p[0]), over_white(p[1]), over_white(p[2])]) as u8 + } })); Ok(()) }); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index b916fd59ea..7501473036 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1513,7 +1513,7 @@ pub(crate) fn estimate_psd_size(doc: &Document) -> Option { /// The signature a kept tagged block is written with. `8B64` exists only in PSB files: a PSD /// with an `8B64` block (kept from a PSB source, e.g. its global `FMsk` or `cinf`) is refused by /// Photoshop as "not compatible with this version" (measured in Photoshop 27.11). -fn block_signature(sig: [u8; 4], version: Version) -> [u8; 4] { +pub(crate) fn block_signature(sig: [u8; 4], version: Version) -> [u8; 4] { if version == Version::Psd && sig == *b"8B64" { *b"8BIM" } else { sig } } diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index cf860c15ff..f490c84e8f 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -1015,7 +1015,19 @@ pub(crate) fn psd_to_document_decoded( cx.warn("color mode data is not preserved"); } match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) { - Ok(img) => { + Ok(mut img) => { + // Resource 1047: pixels of the transparent entry are transparent. + if h.color_mode == PsdMode::Indexed + && let Some(t) = doc.color_table.as_ref().and_then(|t| t.transparent) + && let Some(Ok(all)) = &merged + && let Some(indices) = sizes.and_then(|(_, plane)| merged_plane(all, 0, plane)) + { + for (px, &i) in img.data.as_chunks_mut::<4>().0.iter_mut().zip(indices) { + if i == t { + px[3] = 0; + } + } + } let mut s = Surface::new(fmt); write_rgba8(&mut s, canvas, &img.data); drop(img); diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/psd_structure.rs index 1cf51eeb0b..650aab3d93 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/psd_structure.rs @@ -218,6 +218,103 @@ fn indexed_psd_keeps_the_table_length_and_transparent_entry() { assert_eq!(back.color_table, d.color_table); } +/// A 4x2 document filled with `fill` in Indexed Color mode with `colors` and `transparent`. +fn indexed_doc(fill: photocraft_color::Color, colors: Vec<[u8; 3]>, transparent: Option) -> photocraft_doc::Document { + let mut d = photocraft_doc::Document::with_background( + "i", + photocraft_doc::Size::new(4, 2), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + fill, + ); + d.mode = photocraft_color::ColorMode::Indexed; + d.color_table = Some(photocraft_doc::ColorTable { colors, transparent }); + d +} + +#[test] +fn indexed_psd_never_writes_an_opaque_pixel_as_the_transparent_entry() { + // The transparent entry duplicates opaque black (common in PNG-8 files) and comes first. + let d = indexed_doc(photocraft_color::Color::rgba(0.0, 0.0, 0.0, 1.0), vec![[0, 0, 0], [0, 0, 0], [255, 255, 255]], Some(0)); + let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "opaque black is entry 1, not the transparent entry 0"); +} + +#[test] +fn indexed_psd_transparency_survives_a_round_trip() { + let d = indexed_doc(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 0.0), vec![[255, 0, 0], [255, 255, 255]], Some(1)); + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + let f = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!(f.decode_merged().unwrap(), vec![1; 8]); + let back = import("x.psd", &out.bytes).unwrap().document; + let s = back.layers[0].surface().unwrap(); + let px = s.read_region(back.bounds()); + assert!(px.chunks_exact(s.channels()).all(|p| p.last() == Some(&0.0)), "transparent pixels came back opaque"); + assert!(!back.layers[0].locks.transparency); +} + +#[test] +fn indexed_psd_without_a_transparent_entry_flattens_over_white() { + let d = indexed_doc(photocraft_color::Color::rgba(0.0, 0.0, 0.0, 0.0), vec![[0, 0, 0], [255, 255, 255]], None); + let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "empty areas are white, as Photoshop flattens them"); +} + +#[test] +fn indexed_psd_keeps_the_icc_profile() { + // Bytes 16..20 of a profile name its colour space; only an RGB one describes the table. + let profile = |space: &[u8; 4]| { + let mut p = vec![7u8; 132]; + p[16..20].copy_from_slice(space); + p + }; + for (space, kept) in [(b"RGB ", true), (b"GRAY", false)] { + let mut d = indexed_doc(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0), vec![[255, 0, 0]], None); + d.icc_profile = Some(std::sync::Arc::new(profile(space))); + let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert_eq!(f.header.color_mode, ColorMode::Indexed); + assert_eq!(f.resource(1039).map(|r| r.data.clone()), kept.then(|| profile(space))); + } +} + +#[test] +fn indexed_document_whose_only_entry_is_transparent_is_not_written_as_indexed() { + // Every opaque pixel would have to take the transparent entry and vanish: written as RGB. + let d = indexed_doc(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0), vec![[255, 0, 0]], Some(0)); + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + let f = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!(f.header.color_mode, ColorMode::Rgb, "{:?}", out.warnings); + assert_eq!(&f.composite_rgba8().unwrap().data[..4], &[255, 0, 0, 255]); +} + +#[test] +fn indexed_and_bitmap_documents_with_channels_keep_them() { + // PhotoCraft does not read extra channels of a palette-mode PSD back, so these documents keep + // the RGB / Grayscale file that holds their alpha channels and Quick Mask. + for mode in [photocraft_color::ColorMode::Indexed, photocraft_color::ColorMode::Bitmap] { + let mut d = indexed_doc(photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0), vec![[255, 255, 255]], None); + d.mode = mode; + let alpha = || photocraft_doc::AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8)); + d.channels.push(alpha()); + d.quick_mask = Some(alpha()); + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + let back = import("x.psd", &out.bytes).unwrap().document; + assert_eq!((back.channels.len(), back.quick_mask.is_some()), (1, true), "{mode:?}: {:?}", out.warnings); + } +} + +#[test] +fn layerless_psd_never_keeps_psb_only_block_signatures() { + for mode in [photocraft_color::ColorMode::Indexed, photocraft_color::ColorMode::Bitmap, photocraft_color::ColorMode::Multichannel] { + let mut d = indexed_doc(photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0), vec![[255, 255, 255]], None); + d.mode = mode; + d.metadata.psd_global_blocks.push((*b"8B64", *b"FMsk", std::sync::Arc::new(vec![0; 12]))); + let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert!(f.global_blocks.iter().any(|b| &b.key == b"FMsk"), "{mode:?}"); + assert!(f.global_blocks.iter().all(|b| &b.signature == b"8BIM"), "{mode:?}"); + } +} + #[test] fn multichannel_imports_ink_channels() { for depth in testgen::mode_depths(ColorMode::Multichannel) {