Integration: Indexed PSD keeps transparency, ICC and channels (#4816)

Saving an Indexed Color document in its own PSD mode lost data the RGB
file used to keep (the five open CodeRabbit findings, all reproduced):

- an opaque pixel whose colour matched the transparent entry (opaque
  black next to a black transparent entry, common in PNG-8 files) was
  written as that entry, so it turned transparent; a table whose only
  entry is the transparent one now keeps the RGB file;
- PhotoCraft reopened the file with resource 1047 on the table only, so
  transparent pixels came back opaque (Photoshop stores transparent
  pixels of an Indexed file as the 1047 entry, checked on a GIF it
  opened and saved as PSD);
- the ICC profile was dropped (Photoshop writes 1039 for Indexed); only
  an RGB profile is written, as it describes the table;
- alpha channels and the Quick Mask were dropped without a warning; the
  importer doesn't read extra channels of these modes back, so such
  documents keep the RGB / Grayscale file;
- without a transparent entry, empty areas became the nearest colour to
  transparent black instead of white;
- a PSB-sourced 8B64 global block stayed 8B64 in a PSD (Multichannel
  too), which Photoshop refuses.

Tests: one per point in psd_structure.rs; each fails without this commit.

Co-authored-by: Gert Pellin <8284204+switch87@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-20 22:14:08 +02:00
co-authored by Gert Pellin Claude Opus 5.5
parent 3f3d61a7ae
commit baa854341b
5 changed files with 137 additions and 9 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ pub(crate) fn layerless_file(
let global_blocks: Vec<TaggedBlock> = 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,
+25 -6
View File
@@ -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<String>)> {
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<u8> {
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<u8> {
let (opaque_indices, colors): (Vec<u8>, 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(())
});
+1 -1
View File
@@ -1513,7 +1513,7 @@ pub(crate) fn estimate_psd_size(doc: &Document) -> Option<u64> {
/// 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 }
}
+13 -1
View File
@@ -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);
+97
View File
@@ -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<u8>) -> 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) {