From bf7dd539f5cb323cdae972000cedf8fae966f964 Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 11:25:23 -0400 Subject: [PATCH 01/19] codecs: read and write TIFF extra samples (spot and alpha channels) A TIFF's extra samples other than the image alpha (spot colours and further alpha channels, ExtraSamples 0) were dropped on decode, and the encoder could write only one alpha sample. They now travel through `Metadata::extra_samples`, one plane per sample with its ExtraSamples value as `kind`. - Decode: chunky and planar, strips and tiles, every depth, Gray, RGB, CMYK and Lab. At most 16 (`MAX_EXTRA_SAMPLES`) are kept; the planes count against `Limits::max_alloc` and are allocated fallibly. Layout and pixel data are unchanged: the first alpha sample (1 or 2) is still the image alpha. - Encode: extra samples follow the colour and alpha samples, with their kinds in the ExtraSamples tag; Lab as CIELab (float Lab at 16 bits). More than 16, or a plane of the wrong size, is an error. - `Image::convert` converts the planes with the sample type, and `Image::oriented` turns them with the image. - main's `extra_samples_that_are_not_alpha_are_dropped` becomes `extra_samples_that_are_not_alpha_are_kept_apart`: same inputs, same layout and data, and it now checks the extra planes. From #3121 (split out of #7415). Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/codecs/README.md | 11 +- crates/codecs/src/codecs/tiff.rs | 528 ++++++++++++++++++++++++-- crates/codecs/src/image.rs | 32 +- crates/codecs/src/lib.rs | 3 +- crates/codecs/src/orientation.rs | 6 + crates/codecs/tests/orientation.rs | 19 + crates/codecs/tests/tiff_lab.rs | 33 ++ crates/codecs/tests/tiff_pages.rs | 16 +- crates/codecs/tests/tiff_photoshop.rs | 181 +++++++++ 9 files changed, 790 insertions(+), 39 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 539365c95b..e3799d357b 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -187,8 +187,12 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them planar configuration 1 or 2. * Supports 1, 2 and 4-bit gray, 8/16/32/64-bit integers (32 and 64-bit are reduced to U16), F16, F32 and F64 (converted to F32), WhiteIsZero (#691: the tones are no longer inverted), - palette images (opened as RGB), associated alpha (made straight) and extra samples (dropped - unless the first is alpha). + palette images (opened as RGB) and associated alpha (made straight). + * Extra samples: the first alpha sample (ExtraSamples 1 or 2, wherever it sits) is the image's + alpha. Every other extra sample, such as a spot colour or a further alpha channel + (ExtraSamples 0), comes back in `Metadata::extra_samples`: one plane per sample in the + image's sample type, with its ExtraSamples value as `kind`. At most `MAX_EXTRA_SAMPLES` (16) + are kept, and they count against `Limits::max_alloc` with the image. * Every directory is reachable: `tiff_info` lists the main IFD chain and its SubIFDs (tag 330) with each page's size, kind (page, reduced-resolution copy, mask) and storage, walked with a seen-set, a depth limit and a 10 000-directory cap; `decode_tiff_page` opens any of them. @@ -202,7 +206,8 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them * YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT, JPEG-in-TIFF) return `Unsupported`. * **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or - the file could pass 4 GiB. + the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour + and alpha samples, with their `kind` in the ExtraSamples tag; more than 16 is an error. * **EXR** * Multi-part files (a Maya/Arnold render writes one part per AOV) open the part that looks most like a colour image — RGBA beats RGB beats YA beats a single channel, so a diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 9528fec9c8..1cfa38bc10 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -249,7 +249,9 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option, limits: &Limits) -> .ok_or_else(|| CodecError::InvalidImage(format!("TIFF page {index} does not exist (the file has {} directories)", info.pages.len())))?; let dir = file.ifd(p.ifd_offset).ok_or_else(|| err("image directory cut off"))?; let mut img = decode_ifd(&file, &dir, limits)?; + let extra_samples = std::mem::take(&mut img.meta.extra_samples); img.meta = metadata(&file, &dir, &mut img.icc); + img.meta.extra_samples = extra_samples; let pages = info.page_count(); if pages > 1 { img.warnings.push(DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) }); @@ -423,6 +425,8 @@ struct Layout { palette: Option>, /// Upper bound for one chunk's scratch buffer. scratch_cap: usize, + /// File sample indices that are neither colour nor the chosen alpha (spot / extra alpha). + extra_at: Vec, } impl Layout { @@ -436,6 +440,9 @@ impl Layout { fn out_row(&self) -> usize { self.w * self.out_channels() * self.out.bytes() } + fn extra_row(&self) -> usize { + self.w.saturating_mul(self.extra_at.len()).saturating_mul(self.out.bytes()) + } /// A chunky strip already in the output's layout: decoded in place. fn direct(&self) -> bool { self.copyable() && self.across == 1 @@ -543,13 +550,16 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 3, 5 => 4, _ => 1, }; - let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp); - let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0); + let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color as u64).saturating_add(a as u64) < spp); + // Samples the colour model does not keep. RGB, CMYK and Lab only hand back every sample when + // asked (photometric patched to black-is-zero below); gray already comes back as multiband. + let unused = !extra.is_empty() && (spp as usize) > color.saturating_add(usize::from(alpha_at.is_some())); + let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && (alpha_at.is_some_and(|a| a > 0) || (!planar && unused)); let mut patches: Vec<(u64, Vec)> = Vec::new(); let encode_u = |v: u64, len: usize| -> Vec { let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; @@ -592,7 +602,31 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result (ChannelLayout::Rgb, 1), tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1), tiff::ColorType::Multiband { num_samples, .. } if all_samples => { - (if photometric == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples)) + // Lab reads as RGB here and is relabelled below. + let layout = match photometric { + 2 | 8 | 9 => { + if alpha_at.is_some() { + ChannelLayout::Rgba + } else { + ChannelLayout::Rgb + } + } + 5 => { + if alpha_at.is_some() { + ChannelLayout::CmykA + } else { + ChannelLayout::Cmyk + } + } + _ => { + if gray_alpha { + ChannelLayout::GrayA + } else { + ChannelLayout::Gray + } + } + }; + (layout, usize::from(num_samples)) } tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => { (if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples)) @@ -661,6 +695,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result, dir: &Ifd, limits: &Limits) -> Result, dir: &Ifd, limits: &Limits) -> Result 0 { return Err(err("the image data is missing (cut off or past the end of the file)")); } @@ -693,6 +739,10 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 0 { img.warnings.push(DecodeWarning::Truncated { format: F }); } @@ -728,6 +778,52 @@ fn convert_ab(data: &mut [u8], channels: usize, sample: SampleType, signed: bool } } +/// File sample indices that are not a colour channel and not the one alpha sample, capped at +/// [`crate::MAX_EXTRA_SAMPLES`]. +fn extra_sample_indices(spp: usize, color: usize, alpha_at: Option, has_alpha: bool) -> Vec { + let mut used = vec![false; spp]; + for i in 0..color.min(spp) { + if let Some(slot) = used.get_mut(i) { + *slot = true; + } + } + if has_alpha { + let a = alpha_at.map(|k| color.saturating_add(k)).unwrap_or(color); + if let Some(slot) = used.get_mut(a) { + *slot = true; + } + } + used.into_iter().enumerate().filter(|(_, on)| !*on).map(|(i, _)| i).take(crate::MAX_EXTRA_SAMPLES).collect() +} + +/// Interleaved extra samples (`extra_at.len()` per pixel) → one plane per sample. Each plane is +/// allocated fallibly (the caller has checked their total against the limits). +fn split_extra_samples( + buf: &[u8], + w: usize, + h: usize, + at: &[usize], + sample: SampleType, + mut kind_of: impl FnMut(usize) -> u16, +) -> Result, CodecError> { + let b = sample.bytes(); + let n = w.saturating_mul(h); + let spp = at.len(); + let mut out = Vec::with_capacity(spp); + for (ei, &si) in at.iter().enumerate() { + let mut data = zeroed(n.saturating_mul(b))?; + for i in 0..n { + let src = (i.saturating_mul(spp).saturating_add(ei)).saturating_mul(b); + let dst = i.saturating_mul(b); + if let (Some(s), Some(d)) = (buf.get(src..src.saturating_add(b)), data.get_mut(dst..dst.saturating_add(b))) { + d.copy_from_slice(s); + } + } + out.push(crate::ExtraSample { kind: kind_of(si), data }); + } + Ok(out) +} + /// A zero-filled buffer, allocated fallibly. On native targets the zeroing (and the first /// touch of every page, which dominates on a fresh multi-hundred-megabyte buffer) is spread over /// all cores. @@ -760,20 +856,43 @@ impl std::ops::Add for Tally { } /// Decodes every band into `data`, in parallel on native targets (one decoder and scratch -/// buffer per worker). +/// buffer per worker). `extra` receives samples that are not part of the colour image, one +/// band of rows at a time, when it is non-empty. #[cfg(not(target_arch = "wasm32"))] -fn decode_bands<'a, O>(lay: &Layout, data: &mut [u8], band_bytes: usize, _first: &mut Decoder>, open: &O) -> Result +fn decode_bands<'a, O>( + lay: &Layout, + data: &mut [u8], + band_bytes: usize, + extra: &mut [u8], + extra_band: usize, + _first: &mut Decoder>, + open: &O, +) -> Result where O: Fn() -> Result>, CodecError> + Sync, { use rayon::prelude::*; + if extra.is_empty() { + return data + .par_chunks_mut(band_bytes.max(1)) + .enumerate() + .map_init( + || open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()), + |worker, (b, band)| { + let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?; + decode_band(lay, dec, scratch, b, band, None) + }, + ) + .try_reduce(Tally::default, |a, b| Ok(a + b)); + } data.par_chunks_mut(band_bytes.max(1)) + .zip(extra.par_chunks_mut(extra_band.max(1))) .enumerate() .map_init( || open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()), - |worker, (b, band)| { + |worker, (b, (band, extra_band))| { let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?; - decode_band(lay, dec, scratch, b, band) + decode_band(lay, dec, scratch, b, band, Some(extra_band)) }, ) .try_reduce(Tally::default, |a, b| Ok(a + b)) @@ -781,20 +900,41 @@ where /// Decodes every band into `data`, one after the other. #[cfg(target_arch = "wasm32")] -fn decode_bands<'a, O>(lay: &Layout, data: &mut [u8], band_bytes: usize, first: &mut Decoder>, _open: &O) -> Result +fn decode_bands<'a, O>( + lay: &Layout, + data: &mut [u8], + band_bytes: usize, + extra: &mut [u8], + extra_band: usize, + first: &mut Decoder>, + _open: &O, +) -> Result where O: Fn() -> Result>, CodecError> + Sync, { let mut scratch = Vec::new(); let mut tally = Tally::default(); - for (b, band) in data.chunks_mut(band_bytes.max(1)).enumerate() { - tally = tally + decode_band(lay, first, &mut scratch, b, band)?; + if extra.is_empty() { + for (b, band) in data.chunks_mut(band_bytes.max(1)).enumerate() { + tally = tally + decode_band(lay, first, &mut scratch, b, band, None)?; + } + return Ok(tally); + } + for (b, (band, extra_band)) in data.chunks_mut(band_bytes.max(1)).zip(extra.chunks_mut(extra_band.max(1))).enumerate() { + tally = tally + decode_band(lay, first, &mut scratch, b, band, Some(extra_band))?; } Ok(tally) } /// Decodes band `b` (one strip, or one row of tiles, in every plane) into its output rows. -fn decode_band(lay: &Layout, dec: &mut Decoder>, scratch: &mut Vec, b: usize, band: &mut [u8]) -> Result { +fn decode_band( + lay: &Layout, + dec: &mut Decoder>, + scratch: &mut Vec, + b: usize, + band: &mut [u8], + mut extra: Option<&mut [u8]>, +) -> Result { let out_row = lay.out_row(); let rows = band.len() / out_row.max(1); let px = lay.out_channels() * lay.out.bytes(); @@ -836,16 +976,95 @@ fn decode_band(lay: &Layout, dec: &mut Decoder>, scratch: &mut Vec>, + scratch: &mut Vec, + b: usize, + rows: usize, + extra: Option<&mut [u8]>, + tally: &mut Tally, +) -> Result<(), CodecError> { + let Some(extra) = extra else { return Ok(()) }; + let ech = lay.extra_at.len(); + let erow = lay.extra_row(); + if ech == 0 || erow == 0 { + return Ok(()); + } + for (ei, &file_plane) in lay.extra_at.iter().enumerate() { + for col in 0..lay.across { + let chunk = file_plane + .checked_mul(lay.chunks_per_plane) + .and_then(|c| c.checked_add(b.checked_mul(lay.across)?)) + .and_then(|c| c.checked_add(col)) + .and_then(|c| u32::try_from(c).ok()) + .ok_or_else(|| err("chunk index out of range"))?; + let pref = dec.image_chunk_buffer_layout(chunk).map_err(map_err)?; + let stride = pref.row_stride.map(|s| s.get()).ok_or_else(|| err("empty chunk row"))?; + if pref.len > lay.scratch_cap { + return Err(CodecError::LimitExceeded(format!("a {} byte strip or tile", pref.len))); + } + if scratch.len() < pref.len { + scratch.resize(pref.len, 0); + } + let buf = scratch.get_mut(..pref.len).ok_or_else(|| err("scratch buffer"))?; + match dec.read_chunk_bytes(chunk, buf) { + Ok(()) => tally.decoded += 1, + Err(e) if is_eof(&e) => { + tally.missing += 1; + continue; + } + Err(e) => return Err(map_err(e)), + } + let x0 = col * lay.cw; + let n = lay.cw.min(lay.w.saturating_sub(x0)); + for (ri, src_row) in buf.chunks_exact(stride).take(rows).enumerate() { + let start = ri.saturating_mul(erow); + let Some(er) = extra.get_mut(start..start.saturating_add(erow)) else { continue }; + let px = ech * lay.out.bytes(); + let Some(dst) = er.get_mut(x0.saturating_mul(px)..(x0.saturating_add(n)).saturating_mul(px)) else { continue }; + transfer(lay.src, src_row, 1, 0, dst, lay.out, ech, ei, n); + } + } + } + Ok(()) +} + /// Converts `n` pixels of one decoded chunk row into output pixels (`plane` for planar data). fn convert_row(lay: &Layout, src: &[u8], dst: &mut [u8], n: usize, plane: usize) { let och = lay.out_channels(); @@ -1072,6 +1291,214 @@ custom_colortype!(Lab16, u16, PI::CIELab, &[16; 3], &[SampleFormat::Uint; 3], in custom_colortype!(LabA8, u8, PI::CIELab, &[8; 4], &[SampleFormat::Uint; 4], int); custom_colortype!(LabA16, u16, PI::CIELab, &[16; 4], &[SampleFormat::Uint; 4], int); +// Colour types with 1..=MAX_EXTRA_SAMPLES trailing extra samples (spot channels, extra alpha). +// One generic type per (sample, photometric, bit depth, total sample count) instead of a +// hand-written type for every layout. `N` is the total sample count, not the extra count. +const fn extra_photo(v: u16) -> PhotometricInterpretation { + match v { + 1 => PhotometricInterpretation::BlackIsZero, + 2 => PhotometricInterpretation::RGB, + 5 => PhotometricInterpretation::CMYK, + 8 => PhotometricInterpretation::CIELab, + _ => PhotometricInterpretation::RGB, + } +} + +trait ExtraKind: Copy + TiffValue + 'static { + const FORMAT: SampleFormat; + fn predict(row: &[Self], out: &mut Vec, samples: usize); +} + +impl ExtraKind for u8 { + const FORMAT: SampleFormat = SampleFormat::Uint; + fn predict(row: &[Self], out: &mut Vec, samples: usize) { + let n = samples.min(row.len()); + out.extend_from_slice(&row[..n]); + out.extend(row.iter().zip(&row[n..]).map(|(prev, current)| current.wrapping_sub(*prev))); + } +} + +impl ExtraKind for u16 { + const FORMAT: SampleFormat = SampleFormat::Uint; + fn predict(row: &[Self], out: &mut Vec, samples: usize) { + let n = samples.min(row.len()); + out.extend_from_slice(&row[..n]); + out.extend(row.iter().zip(&row[n..]).map(|(prev, current)| current.wrapping_sub(*prev))); + } +} + +impl ExtraKind for f32 { + const FORMAT: SampleFormat = SampleFormat::IEEEFP; + fn predict(row: &[Self], out: &mut Vec, _samples: usize) { + out.extend_from_slice(row); + } +} + +struct Extras(std::marker::PhantomData); + +impl TiffColorType for Extras +where + T: ExtraKind, +{ + type Inner = T; + const TIFF_VALUE: PhotometricInterpretation = extra_photo(PHOTO); + const BITS_PER_SAMPLE: &'static [u16] = &[BITS; N]; + const SAMPLE_FORMAT: &'static [SampleFormat] = &[T::FORMAT; N]; + fn horizontal_predict(row: &[Self::Inner], result: &mut Vec) { + T::predict(row, result, N); + } +} + +macro_rules! write_extras { + ($n:expr, $enc:expr, $w:expr, $h:expr, $data:expr, $tags:expr, $sample:ty, $photo:literal, $bits:literal, $base:literal) => { + match $n { + 1 => write_one::, K>($enc, $w, $h, $data, $tags), + 2 => write_one::, K>($enc, $w, $h, $data, $tags), + 3 => write_one::, K>($enc, $w, $h, $data, $tags), + 4 => write_one::, K>($enc, $w, $h, $data, $tags), + 5 => write_one::, K>($enc, $w, $h, $data, $tags), + 6 => write_one::, K>($enc, $w, $h, $data, $tags), + 7 => write_one::, K>($enc, $w, $h, $data, $tags), + 8 => write_one::, K>($enc, $w, $h, $data, $tags), + 9 => write_one::, K>($enc, $w, $h, $data, $tags), + 10 => write_one::, K>($enc, $w, $h, $data, $tags), + 11 => write_one::, K>($enc, $w, $h, $data, $tags), + 12 => write_one::, K>($enc, $w, $h, $data, $tags), + 13 => write_one::, K>($enc, $w, $h, $data, $tags), + 14 => write_one::, K>($enc, $w, $h, $data, $tags), + 15 => write_one::, K>($enc, $w, $h, $data, $tags), + 16 => write_one::, K>($enc, $w, $h, $data, $tags), + other => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({other})")).into()), + } + }; +} + +fn bytes_as_u16(b: &[u8]) -> Vec { + b.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).collect() +} + +fn bytes_as_f32(b: &[u8]) -> Vec { + b.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect() +} + +/// Interleaves colour (and alpha) samples with [`Metadata::extra_samples`]. +fn interleave_extras(img: &Image) -> Result, String> { + let (w, h) = img.dimensions(); + let n = (w as usize).checked_mul(h as usize).ok_or_else(|| "image size overflows".to_string())?; + let sb = img.sample_type().bytes(); + let ch = img.layout().channels(); + let extras = &img.meta.extra_samples; + let total = ch.checked_add(extras.len()).ok_or_else(|| "too many samples".to_string())?; + let px = total.checked_mul(sb).ok_or_else(|| "sample size overflows".to_string())?; + let mut out = vec![0u8; n.checked_mul(px).ok_or_else(|| "image size overflows".to_string())?]; + let base = img.data(); + let base_px = ch.saturating_mul(sb); + for i in 0..n { + let src = i.saturating_mul(base_px); + let dst = i.saturating_mul(px); + let Some(s) = base.get(src..src.saturating_add(base_px)) else { + return Err("image buffer is shorter than its dimensions".into()); + }; + let Some(d) = out.get_mut(dst..dst.saturating_add(base_px)) else { + return Err("extra-sample buffer is short".into()); + }; + d.copy_from_slice(s); + for (k, e) in extras.iter().enumerate() { + let es = i.saturating_mul(sb); + let ed = dst.saturating_add(base_px).saturating_add(k.saturating_mul(sb)); + let Some(s) = e.data.get(es..es.saturating_add(sb)) else { + return Err(format!("extra sample {k} is shorter than the image")); + }; + let Some(d) = out.get_mut(ed..ed.saturating_add(sb)) else { + return Err("extra-sample buffer is short".into()); + }; + d.copy_from_slice(s); + } + } + Ok(out) +} + +fn write_with_extras(enc: &mut TiffEncoder<&mut Cursor>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> { + use ChannelLayout as L; + use SampleType as S; + let (w, h) = img.dimensions(); + let n = img.meta.extra_samples.len(); + if img.layout().is_lab() { + let file = lab_file(img); + let bytes = interleave_extras(&file).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + return match (file.layout(), file.sample_type()) { + (L::Lab, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 3), + (L::Lab, _) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 3) + } + (_, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 4), + _ => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 4) + } + }; + } + let bytes = interleave_extras(img).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + match (img.layout(), img.sample_type()) { + (L::Gray, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 1), + (L::Gray, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 1) + } + (L::Gray, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 1) + } + (L::GrayA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 2), + (L::GrayA, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 2) + } + (L::GrayA, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 2) + } + (L::Rgb, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 3), + (L::Rgb, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 3) + } + (L::Rgb, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 3) + } + (L::Rgba, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 4), + (L::Rgba, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 4) + } + (L::Rgba, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 4) + } + (L::Cmyk, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 4), + (L::Cmyk, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 4) + } + (L::Cmyk, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 4) + } + (L::CmykA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 5), + (L::CmykA, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 5) + } + (L::CmykA, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 5) + } + _ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({n})")).into()), + } +} + struct TagSet<'a> { icc: Option<&'a [u8]>, /// The EXIF block's directories and IFD0 tags, written as the TIFF's own. @@ -1079,7 +1506,8 @@ struct TagSet<'a> { xmp: Option<&'a [u8]>, dpi: Option<(f32, f32)>, text: Vec<(Tag, &'a str)>, - alpha: bool, + /// ExtraSamples tag, including the unassociated-alpha marker when the image has alpha. + extra_samples: &'a [u16], photoshop_resources: Option<&'a [u8]>, photoshop_layers: Option<&'a [u8]>, } @@ -1109,9 +1537,8 @@ where im.y_resolution(Rational { n: (y * 1000.0).round() as u32, d: 1000 }); } let d = im.encoder(); - if tags.alpha { - // 2 = unassociated alpha - d.write_tag(Tag::ExtraSamples, &[2u16][..])?; + if !tags.extra_samples.is_empty() { + d.write_tag(Tag::ExtraSamples, tags.extra_samples)?; } if let Some(icc) = tags.icc { d.write_tag(Tag::IccProfile, Undefined(icc))?; @@ -1142,10 +1569,24 @@ where im.write_data(data) } +/// A Lab image as CIELab stores it: a* and b* signed, 8 or 16 bits (CIELab has no float form: +/// 32-bit Lab is written with 16 bits). Extra samples follow the new sample type. +fn lab_file(img: &Image) -> Image { + let sample = if img.sample_type() == SampleType::U8 { SampleType::U8 } else { SampleType::U16 }; + // `convert` brings the extra samples to the new sample type with the image. + let mut file = img.convert(img.layout(), sample); + let channels = file.layout().channels(); + convert_ab(file.data_mut(), channels, sample, true, true); + file +} + /// Writes the image with the colour type matching its layout and sample type. fn write_image(enc: &mut TiffEncoder<&mut Cursor>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> { use ChannelLayout as L; use SampleType as S; + if !img.meta.extra_samples.is_empty() { + return write_with_extras::(enc, img, tags); + } let (w, h) = img.dimensions(); let u16s = || img.to_u16_samples().unwrap_or_default(); let f32s = || img.to_f32_samples().unwrap_or_default(); @@ -1170,13 +1611,9 @@ fn write_image(enc: &mut TiffEncoder<&mut Cursor>, K>, img: (L::CmykA, S::U16) => write_one::(enc, w, h, &u16s(), tags), (L::CmykA, S::F32) => write_one::(enc, w, h, &f32s(), tags), (L::Lab | L::LabA, _) => { - // CIELab has no float form: 32-bit Lab is written with 16 bits. - let sample = if img.sample_type() == S::U8 { S::U8 } else { S::U16 }; - let mut file = img.convert(img.layout(), sample); - let channels = file.layout().channels(); - convert_ab(file.data_mut(), channels, sample, true, true); + let file = lab_file(img); let u16s = || file.to_u16_samples().unwrap_or_default(); - match (file.layout(), sample) { + match (file.layout(), file.sample_type()) { (L::Lab, S::U8) => write_one::(enc, w, h, file.data(), tags), (L::Lab, _) => write_one::(enc, w, h, &u16s(), tags), (_, S::U8) => write_one::(enc, w, h, file.data(), tags), @@ -1198,11 +1635,16 @@ fn estimated_size(img: &Image, tags: &TagSet<'_>) -> u64 { + tags.exif.iter().flat_map(|e| e.ifd0.iter().chain(&e.exif).chain(&e.gps)).map(|f| f.value.len() as u64 + 12).sum::(); // Strip tables: one offset and one count per strip of about 1 MB. let tables = (img.data().len() as u64 / (1 << 20) + 1) * 16; - (img.data().len() as u64).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20) + let extras: u64 = img.meta.extra_samples.iter().map(|e| e.data.len() as u64).sum(); + (img.data().len() as u64).saturating_add(extras).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20) } pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { - let img = src.converted(plan.layout, plan.sample); + // `converted` brings the extra samples to the plan's sample type with the image. + encode_ready(&src.converted(plan.layout, plan.sample), opts) +} + +fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result, CodecError> { let compression = match opts.tiff_compression { TiffCompression::None => tiff::encoder::Compression::Uncompressed, TiffCompression::Lzw => tiff::encoder::Compression::Lzw, @@ -1226,13 +1668,23 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result crate::MAX_EXTRA_SAMPLES { + return Err(CodecError::encode(F, format!("TIFF stores at most {} extra samples", crate::MAX_EXTRA_SAMPLES))); + } + let mut kinds = Vec::with_capacity(img.meta.extra_samples.len().saturating_add(1)); + if img.layout().has_alpha() { + kinds.push(2); + } + for e in &img.meta.extra_samples { + kinds.push(e.kind); + } let tags = TagSet { icc: if opts.embed_icc { img.icc.as_deref() } else { None }, exif: if opts.embed_metadata { img.meta.exif.as_deref().and_then(|e| tiff_exif::read(crate::orientation::tiff_body(e))) } else { None }, xmp: xmp.as_deref().map(str::as_bytes), dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None }, text, - alpha: img.layout().has_alpha(), + extra_samples: &kinds, // Document content, not metadata: written whenever present. photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()), photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()), @@ -1245,17 +1697,33 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result(&mut enc, &img, &tags)?; + write_image::(&mut enc, img, &tags)?; } else { let mut enc = TiffEncoder::new(&mut cursor)?.with_compression(compression).with_predictor(predictor); - write_image::(&mut enc, &img, &tags)?; + write_image::(&mut enc, img, &tags)?; } Ok(cursor.into_inner()) }; - let big = opts.tiff_bigtiff || estimated_size(&img, &tags) > CLASSIC_LIMIT; + let big = opts.tiff_bigtiff || estimated_size(img, &tags) > CLASSIC_LIMIT; match write(big) { Ok(v) => Ok(v), Err(tiff::TiffError::IntSizeError) if !big => write(true).map_err(|e| CodecError::encode(F, e)), Err(e) => Err(CodecError::encode(F, e)), } } + +#[cfg(test)] +mod bits_len { + use super::Extras; + use tiff::encoder::colortype::ColorType as TiffColorType; + + #[test] + fn const_generic_sample_tables_have_one_entry_per_sample() { + // A repeat expression in an associated const (`&[BITS; N]`) has, on some compilers, + // promoted to a slice shorter than `N`. The encoder sizes every strip from that length. + assert_eq!( as TiffColorType>::BITS_PER_SAMPLE.len(), 4, "{:?}", as TiffColorType>::BITS_PER_SAMPLE); + assert_eq!( as TiffColorType>::BITS_PER_SAMPLE.len(), 5); + assert_eq!( as TiffColorType>::BITS_PER_SAMPLE.len(), 19); + assert_eq!( as TiffColorType>::SAMPLE_FORMAT.len(), 6); + } +} diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 5a03e7d283..a15ab72779 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -145,6 +145,24 @@ pub struct Metadata { /// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept /// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`. pub photoshop_layers: Option>, + /// Samples per pixel beyond the colour channels and the one alpha sample. TIFF extra + /// samples (spot colours, extra alpha channels) the pixel layout does not hold. Each entry + /// is one sample per pixel, native endian, in the image's [`SampleType`], row-major. + pub extra_samples: Vec, +} + +/// How many extra TIFF samples an encoded file may carry. Decoders keep at most this many. +/// Sixteen covers a UV-DTF stack (white, varnish, gloss, primer, and further spots) without +/// approaching the PSD 56-channel limit. +pub const MAX_EXTRA_SAMPLES: usize = 16; + +/// One TIFF extra sample (a spot channel or an alpha channel that is not the image alpha). +#[derive(Debug, Clone, PartialEq)] +pub struct ExtraSample { + /// TIFF ExtraSamples value: 0 unspecified, 1 associated alpha, 2 unassociated alpha. + pub kind: u16, + /// One sample per pixel, native endian, same sample type as the image. + pub data: Vec, } impl Metadata { @@ -155,6 +173,7 @@ impl Metadata { && self.text.is_empty() && self.photoshop_resources.is_none() && self.photoshop_layers.is_none() + && self.extra_samples.is_empty() } } @@ -639,6 +658,17 @@ impl Image { let values = convert_layout(&src, self.layout, layout); quantize(&values, sample) }; + let mut meta = self.meta.clone(); + if sample != self.sample { + // Extra samples are stored in the image's sample type, so they convert with it. A + // plane of the wrong size is left as it is: importers drop it with a warning. + let plane_len = (self.width as usize).saturating_mul(self.height as usize).saturating_mul(self.sample.bytes()); + for e in meta.extra_samples.iter_mut().filter(|e| e.data.len() == plane_len) { + if let Ok(plane) = Image::from_raw(self.width, self.height, ChannelLayout::Gray, self.sample, std::mem::take(&mut e.data)) { + e.data = plane.convert(ChannelLayout::Gray, sample).data; + } + } + } Image { width: self.width, height: self.height, @@ -646,7 +676,7 @@ impl Image { sample, data, icc: if layout.same_model(self.layout) { self.icc.clone() } else { None }, - meta: self.meta.clone(), + meta, warnings: self.warnings.clone(), } } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 8c06694b9a..5c0e37b0db 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -36,7 +36,8 @@ pub use crate::exif_color::{EXIF_COLOR_SPACE_SRGB, exif_color_space}; pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with}; pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension}; pub use crate::image::{ - ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, + ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, ExtraSample, Image, + MAX_EXTRA_SAMPLES, Metadata, SampleType, }; pub use crate::options::{ DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression, diff --git a/crates/codecs/src/orientation.rs b/crates/codecs/src/orientation.rs index 3d10d5ddea..7f31a6b271 100644 --- a/crates/codecs/src/orientation.rs +++ b/crates/codecs/src/orientation.rs @@ -331,6 +331,12 @@ impl Image { } let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?; let mut meta = self.meta.clone(); + // Extra samples (a TIFF's alpha and spot planes, one sample a pixel) turn with the image. + // A plane of the wrong size is left as it is: importers drop it with a warning. + let plane_len = w.saturating_mul(h).saturating_mul(sample.bytes()); + for e in meta.extra_samples.iter_mut().filter(|e| e.data.len() == plane_len) { + e.data = orient_bytes(&e.data, sample.bytes(), o, (w, h), ow)?; + } if let Some(e) = &mut meta.exif && let Cow::Owned(fixed) = upright_exif(e) { diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/orientation.rs index c1f176b87c..0532b44066 100644 --- a/crates/codecs/tests/orientation.rs +++ b/crates/codecs/tests/orientation.rs @@ -211,6 +211,25 @@ fn png_opens_upright_for_every_orientation() { } } +/// Extra samples (a TIFF's alpha and spot planes) turn with the image, so a channel stays +/// aligned with the pixels for every orientation. +#[test] +fn extra_samples_turn_with_the_image() { + let (w, h) = (4, 7); + let up = exact_scene(w, h); + let up_extra: Vec = up.iter().map(|v| 255 - v).collect(); + for o in 1..=8u16 { + let (s, sw, sh) = stored(&up, w, h, o); + let (s_extra, _, _) = stored(&up_extra, w, h, o); + let mut img = gray(sw, sh, s); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: s_extra }]; + let turned = img.oriented(o).unwrap(); + assert_eq!(turned.data(), &up[..], "o={o}"); + assert_eq!(turned.meta.extra_samples.len(), 1, "o={o}"); + assert_eq!(turned.meta.extra_samples[0].data, up_extra, "o={o}"); + } +} + #[test] fn tiff_opens_upright_for_every_orientation() { let (w, h) = (4, 7); diff --git a/crates/codecs/tests/tiff_lab.rs b/crates/codecs/tests/tiff_lab.rs index 7076633ef5..9e41ef80b2 100644 --- a/crates/codecs/tests/tiff_lab.rs +++ b/crates/codecs/tests/tiff_lab.rs @@ -74,3 +74,36 @@ fn lab_converts_to_rgb_for_other_formats() { assert_eq!(&p[4..7], &[255, 255, 255], "white"); assert!(p[0] > 180 && p[1] < 120 && p[2] > 140, "L 50, a 50, b −30 is a magenta-ish purple: {:?}", &p[..3]); } + +#[test] +fn lab_keeps_extra_samples() { + // A spot plate next to Lab: written as CIELab plus one unspecified extra sample, and read + // back as Lab with the spot (not as LabA). + for (layout, sample) in [(ChannelLayout::Lab, SampleType::U8), (ChannelLayout::LabA, SampleType::U16)] { + let px: Vec = + [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].iter().flat_map(|p| p.iter().copied().chain(layout.has_alpha().then_some(0.5))).collect(); + let mut img = Image::from_normalized(2, 1, layout, sample, &px).unwrap(); + let spot: Vec = (0..2 * sample.bytes()).map(|i| (i * 37 + 5) as u8).collect(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: spot.clone() }]; + let file = uncompressed(&img); + if layout == ChannelLayout::Lab { + assert!(find(&file, &[128, 50, (-30i8) as u8, spot[0], 255, 0, 0, spot[1]]).is_some(), "CIELab samples with the spot"); + } + let back = decode(&file).unwrap(); + assert_eq!((back.layout(), back.sample_type()), (layout, sample)); + let (a, b) = (img.to_normalized(), back.to_normalized()); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 1.5 / 65535.0), "{layout:?}: {a:?} {b:?}"); + assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: spot }], "{layout:?}"); + } +} + +#[test] +fn float_lab_writes_its_extra_samples_with_16_bits() { + let px: Vec = [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].concat(); + let mut img = Image::from_normalized(2, 1, ChannelLayout::Lab, SampleType::F32, &px).unwrap(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: [0.25f32, 1.0].iter().flat_map(|v| v.to_ne_bytes()).collect() }]; + let back = decode(&uncompressed(&img)).unwrap(); + assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U16)); + let spot: Vec = [16384u16, 65535].iter().flat_map(|v| v.to_ne_bytes()).collect(); + assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: spot }]); +} diff --git a/crates/codecs/tests/tiff_pages.rs b/crates/codecs/tests/tiff_pages.rs index 60885e527d..dce4317a8c 100644 --- a/crates/codecs/tests/tiff_pages.rs +++ b/crates/codecs/tests/tiff_pages.rs @@ -128,13 +128,16 @@ fn planar_strips_at_every_depth() { } } +/// Extra samples that are not the alpha (ExtraSamples 0: a spot colour or an extra channel) are +/// not part of the image: they come back in `meta.extra_samples`, one plane each. #[test] -fn extra_samples_that_are_not_alpha_are_dropped() { +fn extra_samples_that_are_not_alpha_are_kept_apart() { let (w, h) = (5usize, 3usize); let t = truth(w * h * 5, 4); let rgb_of = |spp: usize, keep: usize| -> Vec { t.u8s[..w * h * spp].chunks(spp).flat_map(|p| p[..keep].to_vec()).collect() }; + let extra = |spp: usize, at: usize| vec![ExtraSample { kind: 0, data: t.u8s[..w * h * spp].chunks(spp).map(|p| p[at]).collect() }]; for planar in [false, true] { - // RGB + one unspecified extra sample: RGB. + // RGB + one unspecified extra sample: RGB, and the sample as an extra plane. let px = &t.u8s[..w * h * 4]; let chunks = if planar { planar_strips(px, w, h, 4, 1, 2) } else { px.chunks(w * 4 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 4, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[0]); @@ -144,7 +147,8 @@ fn extra_samples_that_are_not_alpha_are_dropped() { let img = decode_ok(&build(true, false, &[d], &[0]).bytes); assert_eq!(img.layout(), ChannelLayout::Rgb); assert_eq!(img.data(), &rgb_of(4, 3)[..], "planar {planar}"); - // RGB + alpha + an unspecified extra sample: RGBA. + assert_eq!(img.meta.extra_samples, extra(4, 3), "planar {planar}"); + // RGB + alpha + an unspecified extra sample: RGBA and one extra plane. let px = &t.u8s[..w * h * 5]; let chunks = if planar { planar_strips(px, w, h, 5, 1, 2) } else { px.chunks(w * 5 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 5, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[2, 0]); @@ -154,18 +158,22 @@ fn extra_samples_that_are_not_alpha_are_dropped() { let img = decode_ok(&build(false, true, &[d], &[0]).bytes); assert_eq!(img.layout(), ChannelLayout::Rgba); assert_eq!(img.data(), &rgb_of(5, 4)[..], "planar {planar}"); + assert_eq!(img.meta.extra_samples, extra(5, 4), "planar {planar}"); } - // Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. + // Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. Each keeps the + // extra sample as a plane. let px = &t.u8s[..w * h * 3]; let d = strips(w as u32, h as u32, 8, 3, 1, 3, px).tag(338, SHORT, &[2, 0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); assert_eq!(img.layout(), ChannelLayout::GrayA); assert_eq!(img.data(), &rgb_of(3, 2)[..]); + assert_eq!(img.meta.extra_samples, extra(3, 2)); let px = &t.u8s[..w * h * 2]; let d = strips(w as u32, h as u32, 8, 2, 1, 3, px).tag(338, SHORT, &[0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); assert_eq!(img.layout(), ChannelLayout::Gray); assert_eq!(img.data(), &rgb_of(2, 1)[..]); + assert_eq!(img.meta.extra_samples, extra(2, 1)); } #[test] diff --git a/crates/codecs/tests/tiff_photoshop.rs b/crates/codecs/tests/tiff_photoshop.rs index 400b72eb0c..82f4b74e7d 100644 --- a/crates/codecs/tests/tiff_photoshop.rs +++ b/crates/codecs/tests/tiff_photoshop.rs @@ -41,6 +41,187 @@ fn tags_round_trip_through_the_encoder() { } } +#[test] +fn extra_samples_round_trip() { + for (layout, sample) in [ + (ChannelLayout::Rgb, SampleType::U8), + (ChannelLayout::Rgba, SampleType::U8), + (ChannelLayout::Gray, SampleType::U16), + (ChannelLayout::Cmyk, SampleType::F32), + ] { + let mut img = synth(4, 3, layout, sample, 2, 0.4); + let n = 4 * 3; + let b = sample.bytes(); + let data: Vec = (0..n * b).map(|i| (i * 17) as u8).collect(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: data.clone() }]; + let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let back = decode(&bytes).unwrap(); + assert_eq!(back.layout(), layout, "{layout:?} {sample:?}"); + assert_eq!(back.data(), img.data(), "{layout:?} {sample:?}"); + assert_eq!(back.meta.extra_samples.len(), 1, "{layout:?} {sample:?}"); + assert_eq!(back.meta.extra_samples[0].kind, 0, "{layout:?} {sample:?}"); + assert_eq!(back.meta.extra_samples[0].data, data, "{layout:?} {sample:?}"); + } +} + +/// Tall enough that the default strip size (about 1 MB) cannot hold every row, so a writer +/// that only fills the first strip, or predicts across the wrong sample count, cannot hide. +/// The spot plane is checked with the `tiff` crate, not PhotoCraft's decoder. +#[test] +fn multi_strip_spot_round_trip_and_dump() { + let w = 800u32; + let h = 400u32; + let n = (w as usize) * (h as usize); + for (layout, channels) in [(ChannelLayout::Rgb, 3usize), (ChannelLayout::Rgba, 4usize)] { + let mut px = vec![0u8; n * channels]; + let mut spot = vec![0u8; n]; + for y in 0..h as usize { + for x in 0..w as usize { + let i = y * w as usize + x; + px[i * channels] = (x % 251) as u8; + px[i * channels + 1] = (y % 251) as u8; + px[i * channels + 2] = 40; + if channels == 4 { + px[i * channels + 3] = if x > 20 && x < 780 && y > 10 && y + 10 < h as usize { 255 } else { 0 }; + } + spot[i] = ((x * 3 + y * 17) % 251) as u8; + } + } + let mut img = Image::from_u8(w, h, layout, px).unwrap(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: spot.clone() }]; + for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::Lzw, TiffCompression::PackBits] { + let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap(); + let label = format!("{layout:?} {compression:?}"); + let back = decode(&bytes).unwrap(); + assert_eq!(back.meta.extra_samples.len(), 1, "{label}"); + assert_eq!(back.meta.extra_samples[0].data, spot, "own decoder {label}"); + assert_eq!(back.data(), img.data(), "own decoder rgb {label}"); + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + let spp: u16 = dec.get_tag_unsigned(tiff::tags::Tag::SamplesPerPixel).unwrap(); + let bps = dec.get_tag_u16_vec(tiff::tags::Tag::BitsPerSample).unwrap(); + let extra = dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap(); + let rows = dec.find_tag_unsigned::(tiff::tags::Tag::RowsPerStrip).unwrap(); + assert_eq!(bps.len(), spp as usize, "{label}"); + assert_eq!(spp as usize, channels + 1, "{label}"); + assert_eq!(*extra.last().unwrap(), 0, "{label} spot ExtraSamples {extra:?}"); + assert!(rows.is_some_and(|r| r < h) || compression == TiffCompression::PackBits, "{label} rows {rows:?}"); + let patched = min_is_black(&bytes); + let mut indep = tiff::decoder::Decoder::new(std::io::Cursor::new(patched.as_slice())).unwrap(); + let tiff::decoder::DecodingResult::U8(samples) = indep.read_image().unwrap() else { panic!("{label} not u8") }; + let spp = spp as usize; + assert_eq!(samples.len(), n * spp, "{label}"); + let mut bad = 0usize; + let mut first = None; + for (i, want) in spot.iter().enumerate() { + let got = samples.get(i * spp + spp - 1).copied(); + if got != Some(*want) { + bad += 1; + first.get_or_insert((i, got)); + } + } + assert_eq!(bad, 0, "{label} independent reader, first {first:?}"); + } + } +} + +/// Retag photometric interpretation 262 as MinIsBlack so `tiff`'s decoder returns every sample. +fn min_is_black(src: &[u8]) -> Vec { + let mut bytes = src.to_vec(); + let le = bytes.first().copied() == Some(b'I'); + let ru16 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 2)?; + Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }) + }; + let ru32 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 4)?; + Some(if le { u32::from_le_bytes(s.try_into().ok()?) } else { u32::from_be_bytes(s.try_into().ok()?) }) + }; + let Some(ifd) = ru32(&bytes, 4) else { return bytes }; + let Some(n) = ru16(&bytes, ifd as usize) else { return bytes }; + for e in 0..n as usize { + let at = ifd as usize + 2 + e * 12; + if ru16(&bytes, at) == Some(262) { + let value = if le { 1u16.to_le_bytes() } else { 1u16.to_be_bytes() }; + if let Some(slot) = bytes.get_mut(at + 8..at + 10) { + slot.copy_from_slice(&value); + } + break; + } + } + bytes +} + +#[test] +fn more_than_sixteen_extra_samples_is_refused() { + let mut img = synth(2, 2, ChannelLayout::Rgb, SampleType::U8, 1, 0.0); + img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES + 1).map(|_| ExtraSample { kind: 0, data: vec![0; 4] }).collect(); + assert!(encode(&img, Format::Tiff, &EncodeOptions::default()).is_err()); +} + +#[test] +fn sixteen_extra_samples_round_trip() { + for (layout, sample, base) in [ + (ChannelLayout::Rgb, SampleType::U8, 3usize), + (ChannelLayout::Rgba, SampleType::U8, 4), + (ChannelLayout::Gray, SampleType::U16, 1), + (ChannelLayout::Cmyk, SampleType::F32, 4), + ] { + let mut img = synth(2, 2, layout, sample, 1, 0.0); + let n = 2 * 2 * sample.bytes(); + img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES).map(|k| ExtraSample { kind: 0, data: vec![k as u8; n] }).collect(); + let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let back = decode(&bytes).unwrap(); + assert_eq!(back.layout(), layout, "{layout:?} {sample:?} base {base}"); + assert_eq!(back.data(), img.data(), "{layout:?}"); + assert_eq!(back.meta.extra_samples.len(), MAX_EXTRA_SAMPLES, "{layout:?}"); + assert_eq!(back.meta.extra_samples[7].kind, 0, "{layout:?}"); + assert_eq!(back.meta.extra_samples[15].data, vec![15; n], "{layout:?}"); + } +} + +/// Extra samples count against `max_alloc` with the image (their interleaved buffer and the +/// planes split from it): a budget that holds the RGB image but not sixteen extra planes refuses +/// the file instead of allocating past it. +#[test] +fn extra_samples_count_against_the_allocation_limit() { + let (w, h) = (64u32, 64u32); + let n = (w * h) as usize; + let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0); + img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES).map(|k| ExtraSample { kind: 0, data: vec![k as u8; n] }).collect(); + let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let with = |max_alloc: u64| decode_with(&bytes, &DecodeOptions { limits: Limits { max_alloc, ..Limits::default() }, ..DecodeOptions::default() }); + // RGB is 3 bytes a pixel; the extra samples add 2 x 16 more. + let image_only = 3 * n as u64; + let with_extras = (3 + 2 * MAX_EXTRA_SAMPLES as u64) * n as u64; + assert!(matches!(with(image_only), Err(CodecError::LimitExceeded(_))), "the image fits, the extra samples don't"); + assert!(matches!(with(with_extras - 1), Err(CodecError::LimitExceeded(_)))); + let back = with(with_extras).expect("everything fits"); + assert_eq!(back.meta.extra_samples.len(), MAX_EXTRA_SAMPLES); +} + +/// Converting an image's sample type converts its extra samples with it, so they keep matching +/// the image: a 16-bit float TIFF's spot planes survive the 32-bit float a layered import uses. +#[test] +fn extra_samples_follow_a_sample_type_conversion() { + let (w, h) = (3u32, 2u32); + let n = (w * h) as usize; + let values = [0.0f32, 0.25, 0.5, 0.75, 1.0, 0.125]; + let half = |v: f32| f16::from_f32(v).to_bits().to_ne_bytes(); + let rgb: Vec = (0..n * 3).flat_map(|_| half(0.5)).collect(); + let mut img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::F16, rgb).unwrap(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: values.iter().flat_map(|v| half(*v)).collect() }]; + + let wide = img.convert(ChannelLayout::Rgb, SampleType::F32); + let plane: Vec = wide.meta.extra_samples[0].data.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect(); + assert_eq!(plane, values); + + // To 8 bits, the plane quantizes exactly as a gray image of the same values does. + let narrow = wide.convert(ChannelLayout::Rgb, SampleType::U8); + let gray = Image::from_raw(w, h, ChannelLayout::Gray, SampleType::F32, wide.meta.extra_samples[0].data.clone()).unwrap(); + assert_eq!(narrow.meta.extra_samples[0].data, gray.convert(ChannelLayout::Gray, SampleType::U8).data()); + assert_eq!(narrow.meta.extra_samples[0].data.len(), n); +} + #[test] fn absent_and_empty_tags_are_not_written() { let mut img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0); From ef754074b504b6194234f2947fd30e3c985e1628 Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 11:25:52 -0400 Subject: [PATCH 02/19] contributors: credit jasallen Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index b3cab8e04f..2526e9c96c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -191,3 +191,8 @@ sync_github_name = true [people.ha1des] sync_github_name = true + +[people.jasallen] +real_name = "Jason DeBoever" +display_name = "jasallen" +sync_github_name = true From da58af974a405d52db31d09ac72b6399e55e143d Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 11:19:55 -0400 Subject: [PATCH 03/19] io: port TIFF alpha and spot channels from #7415 Flat and layered TIFFs write and read alpha channels, spot channels and a Quick Mask as extra samples, with Photoshop's channel resources. This is the io part of #7415 as it stands, on current main; the next commit keeps PSD output as it is on main. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> --- Cargo.lock | 1 + book/src/formats/raster-formats.md | 2 +- crates/io/Cargo.toml | 1 + crates/io/src/channel_map.rs | 93 ++++- crates/io/src/flat.rs | 4 + crates/io/src/pixels.rs | 27 ++ crates/io/src/psd_export.rs | 23 +- crates/io/src/psd_import.rs | 28 +- crates/io/src/tiff_layers.rs | 632 ++++++++++++++++++++++++++++- crates/io/tests/tiff_layers.rs | 606 ++++++++++++++++++++++++++- crates/psd/src/file.rs | 8 + crates/psd/tests/roundtrip.rs | 1 + docs/file-format-parity.md | 5 +- docs/gaps.md | 8 +- docs/target-app-parity.md | 5 +- scorecard/file_compat.toml | 8 +- 16 files changed, 1396 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0ff196c55..6743a397de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4924,6 +4924,7 @@ dependencies = [ "roxmltree 0.20.0", "serde_json", "thiserror 2.0.21", + "tiff", ] [[package]] diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index c333cbc737..2173feccad 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -4,7 +4,7 @@ ## Layered TIFF -A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. +A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. ## WebP diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index eb8c0b00d7..37ac195d7c 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -55,3 +55,4 @@ flate2 = { workspace = true } photocraft-psd = { workspace = true, features = ["testgen"] } photocraft-raw = { workspace = true, features = ["testgen"] } proptest = { workspace = true } +tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] } diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index bafde26f30..60b671101a 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -1,10 +1,12 @@ //! Alpha / spot channel options <-> PSD `DisplayInfo` image resources (1077, and the older 1007). //! -//! Layout (Adobe PSD spec, "DisplayInfo"): resource 1077 starts with a 4-byte version (1), then -//! one 13-byte record per extra channel: colour space (u16), four u16 colour components, opacity -//! (u16, 0..100) and kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected") -//! areas, 2 = spot channel). Resource 1007 has the same records without the version and the spot -//! kind, each followed by a padding byte (14 bytes). +//! Layout (Adobe PSD spec, "DisplayInfo", and a Photoshop 27 layered TIFF): resource 1077 +//! starts with a 4-byte version (1), then one record per extra channel: colour space (u16), +//! four u16 colour components, opacity (u16, 0..100) and kind (u8: 0 = colour indicates +//! selected areas, 1 = masked ("protected") areas, 2 = spot channel). Photoshop writes that +//! 13-byte record with no pad. Older files pad each record to 14 bytes; both are read. +//! Resource 1007 has the same records without the version, each followed by a padding byte. +//! When the merged image has transparency, Photoshop writes that channel's record first. use photocraft_color::{Color, ColorMode}; use photocraft_doc::{AlphaChannel, ColorIndicates}; @@ -13,6 +15,8 @@ pub(crate) const DISPLAY_INFO: u16 = 1077; pub(crate) const DISPLAY_INFO_OLD: u16 = 1007; /// Quick Mask information: channel id (u16) and an "initially empty" flag (u8). pub(crate) const QUICK_MASK_INFO: u16 = 1022; +/// Alpha channel identifiers (Photoshop 6): one big-endian u32 per extra channel, no count prefix. +pub(crate) const ALPHA_IDENTIFIERS: u16 = 1053; const SPACE_RGB: u16 = 0; const SPACE_HSB: u16 = 1; @@ -20,6 +24,23 @@ const SPACE_CMYK: u16 = 2; const SPACE_LAB: u16 = 7; const SPACE_GRAY: u16 = 8; +/// Resource 1053: channel ids `1..=count`, one big-endian u32 each, with no length prefix. +/// Photoshop, ag-psd and psd-tools read u32s until the resource ends. +pub(crate) fn alpha_identifiers(count: usize) -> Vec { + alpha_identifiers_from(1, count) +} + +/// Resource 1053 starting at `first` (a layered TIFF uses the sample's index in the IFD: +/// 3 for the spot after RGB, not 1). +pub(crate) fn alpha_identifiers_from(first: u32, count: usize) -> Vec { + let mut out = Vec::with_capacity(count.saturating_mul(4)); + for i in 0..count { + let id = first.saturating_add(u32::try_from(i).unwrap_or(u32::MAX)); + out.extend_from_slice(&id.to_be_bytes()); + } + out +} + /// Resource 1077 for `channels` (in file order): a version, then 13 bytes per channel (colour /// space, four components, opacity, kind). Every corpus file, Photoshop's included, uses 13; /// 14-byte records (the older 1007 layout) shift every channel after the first. @@ -46,12 +67,41 @@ pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Ve out } -/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the -/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip` -/// records (the merged transparency channel's). +/// 13 when every record is Photoshop's unpadded form, otherwise the 14-byte padded form. +/// +/// Lengths divisible by both (14, 28, … unpadded records, or 13, 26, … padded ones) take +/// the unpadded stride for resource 1077, which is what Photoshop 27 writes, and the padded +/// stride for the older 1007 resource, whose record includes the pad byte. +fn display_stride(body: &[u8], versioned: bool) -> usize { + let unpadded = !body.is_empty() && body.len().is_multiple_of(13); + let padded = !body.is_empty() && body.len().is_multiple_of(14); + if unpadded && (!padded || versioned) { 13 } else { 14 } +} + +/// Which of the first `count` DisplayInfo records are spot channels (kind 2). +/// Missing records are not spots. Alpha channels (kind 0 or 1) stay false: their samples +/// are stored straight, while a spot's samples are stored inverted. +pub(crate) fn spot_flags(data: &[u8], versioned: bool, count: usize) -> Vec { + let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data }; + let stride = display_stride(body, versioned); + let mut out = vec![false; count]; + for (i, rec) in body.chunks(stride).take(count).enumerate() { + if rec.len() >= 13 { + out[i] = rec[12] == 2; + } + } + out +} + +/// Applies a DisplayInfo resource (`versioned` = 1077: records after a version; else the older +/// 1007) to the imported channels in order, after skipping `skip` records (the merged +/// transparency channel's). Both the 13-byte records Photoshop writes and the 14-byte padded +/// form are read. pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) { - let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) }; - for (c, rec) in channels.iter_mut().zip(body.chunks_exact(size).skip(skip)) { + let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data }; + let stride = display_stride(body, versioned); + for (c, rec) in channels.iter_mut().zip(body.chunks(stride).skip(skip)) { + let Some(rec) = rec.get(..13) else { break }; let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]); let comps = [u(2), u(4), u(6), u(8)]; let color = decode_color(u(0), comps); @@ -149,6 +199,14 @@ mod tests { assert!((back[0].opacity - 0.3).abs() < 1e-6); assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); + assert_eq!(alpha_identifiers(2), [0, 0, 0, 1, 0, 0, 0, 2]); + assert!(alpha_identifiers(0).is_empty()); + assert_eq!(alpha_identifiers_from(3, 1), [0, 0, 0, 3]); + // Photoshop 27 writes a 13-byte record (no pad): red spot, solidity 60. + let ps = [0, 0, 0, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0x3c, 2]; + let mut spot = vec![AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))]; + apply_display_info(&ps, true, 0, &mut spot); + assert_eq!(spot[0].spot.map(|(c, s)| (c.to_rgb(), (s * 100.0).round() as u16)), Some(([1.0, 0.0, 0.0], 60))); // With the merged transparency's record first, the channels still get their own. let with_t = display_info(&[&a, &s], true); assert_eq!(with_t.len(), 4 + 3 * 13); @@ -160,6 +218,21 @@ mod tests { let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))]; apply_display_info(&bytes[..10], true, 0, &mut c); assert_eq!(c[0].opacity, 0.5); + + // 14 unpadded records are a multiple of 14 as well (14×13 = 13×14). Resource 1077 + // still reads them as 13-byte records; the last spot's solidity must survive. + let mut many = Vec::new(); + for i in 0..14 { + let mut ch = AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8)); + ch.spot = Some((Color::rgb(1.0, 0.0, 0.0), (i as f32) / 14.0)); + many.push(ch); + } + let refs: Vec<&AlphaChannel> = many.iter().collect(); + let packed = display_info(&refs, false); + assert_eq!(packed.len(), 4 + 14 * 13); + let mut got: Vec = (0..14).map(|i| AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8))).collect(); + apply_display_info(&packed, true, 0, &mut got); + assert_eq!(got[13].spot.map(|(_, s)| (s * 100.0).round() as u16), many[13].spot.map(|(_, s)| (s * 100.0).round() as u16)); } #[test] diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index c9e3a410ae..8139a36a4c 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -237,6 +237,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result SampleType { } } +/// Complements every sample in `bytes`. Photoshop stores a spot channel as ink density +/// inverted: 0 is solid ink and the maximum sample is bare paper (the same convention as a +/// CMYK plate and as Multichannel). An alpha channel is not stored this way: 0 is empty and +/// the maximum sample is selected. `big_endian` is the PSD merged-image order; TIFF extra +/// samples are native-endian. +pub fn invert_plane(bytes: &mut [u8], s: SampleType, big_endian: bool) { + let bps = s.bytes(); + if bps == 0 { + return; + } + for c in bytes.chunks_exact_mut(bps) { + match s { + SampleType::U8 => c[0] = 255 - c[0], + SampleType::U16 => { + let v = if big_endian { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_ne_bytes([c[0], c[1]]) }; + let w = 65535 - v; + c.copy_from_slice(&(if big_endian { w.to_be_bytes() } else { w.to_ne_bytes() })); + } + SampleType::F32 => { + let v = if big_endian { f32::from_be_bytes([c[0], c[1], c[2], c[3]]) } else { f32::from_ne_bytes([c[0], c[1], c[2], c[3]]) }; + let w = 1.0 - v; + c.copy_from_slice(&(if big_endian { w.to_be_bytes() } else { w.to_ne_bytes() })); + } + } + } +} + fn invert_sample(bytes: &mut [u8], s: SampleType) { match s { SampleType::U8 => bytes[0] = 255 - bytes[0], diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 454f1c5dd8..81d3ebe5a4 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -484,6 +484,8 @@ impl Ex { } flags.set_hidden(!l.visible); flags.set_transparency_protected(l.locks.transparency); + // Bit 3 (with bit 4 clear) is the flag Photoshop sets on every layer it writes. + flags.0 |= LayerFlags::BIT4_USEFUL; let mut blocks = vec![TaggedBlock::unicode_name(&l.name), TaggedBlock::layer_id(id)]; let lspf = blocks::lspf_from_locks(&l.locks); if lspf != 0 { @@ -1075,6 +1077,8 @@ impl Ex { match &l.content { LayerContent::Group(g) => { let id = self.fresh_id(); + let mut divider_flags = LayerFlags(0); + divider_flags.0 |= LayerFlags::BIT4_USEFUL; self.records.push(LayerRecord { channels: self.empty_channels(), blending_ranges: BlendingRanges::full(self.cc), @@ -1084,6 +1088,7 @@ impl Ex { TaggedBlock::layer_id(id), TaggedBlock::section_divider(SectionType::BoundingDivider, None, None), ], + flags: divider_flags, ..Default::default() }); self.emit(&g.children, tick); @@ -1205,9 +1210,12 @@ pub(crate) fn pascal_names_resource(names: &[&str]) -> Vec { let mut v = Vec::new(); for n in names { let b = legacy_name(n); - v.push(b.len() as u8); - v.extend_from_slice(&b); + let n = b.len().min(255); + v.push(n as u8); + v.extend_from_slice(&b[..n]); } + // No per-string pad: Photoshop's resource 1006 for "Spot Color 1" is 13 bytes + // (length byte + 12 characters). The 8BIM container pads an odd resource separately. v } @@ -1703,7 +1711,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c for a in &extra { let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; let bytes = s.to_interleaved(canvas); - planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); + // Spot ink is stored inverted (0 = solid, max = paper), the same way Multichannel + // and a Photoshop TIFF do. An alpha channel stays straight (max = selected). + planes.extend(deinterleave(&bytes, 1, sample, &[a.spot.is_some()]).remove(0)); } let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; let mode = if duotone_inks.is_some() { PsdMode::Duotone } else { psd_mode(fmt.mode) }; @@ -1734,6 +1744,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(ImageResource::new(1006, pascal_names_resource(&names))); resources.push(ImageResource::new(1045, unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(extra.len()))); } if let Some(id) = quick_mask_id { let mut data = id.to_be_bytes().to_vec(); @@ -1816,6 +1827,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !keep_imported_resource(doc, *id, data) { continue; } + // A fresh 1053 was written with the extra channels. A preserved copy would be a second one. + if *id == crate::channel_map::ALPHA_IDENTIFIERS && !extra.is_empty() { + continue; + } let mut r = ImageResource::new(*id, data.to_vec()); r.name = legacy_name(name); resources.push(r); @@ -1866,7 +1881,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources, layer_info, layer_info_placement: placement, - global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::default), + global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::photoshop_default), global_blocks, layer_mask_trailing: Vec::new(), image_data, diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 0eca6d704f..82f998143b 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -603,7 +603,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec { interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) } -fn unicode_names(data: &[u8]) -> Vec { +pub(crate) fn unicode_names(data: &[u8]) -> Vec { let mut out = Vec::new(); let mut at = 0; while at + 4 <= data.len() { @@ -832,6 +832,10 @@ pub(crate) fn psd_to_document_decoded( doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 }); doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))); } + // Resource 1053 is rewritten from the extra channels on a layered save, so keeping + // the copy just written would show up as new metadata. Multichannel has no such + // rewrite: the imported bytes are the channel ids, copied back when they still match. + crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {} id if MAPPED_RESOURCES.contains(&id) => {} id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } @@ -1048,13 +1052,31 @@ pub(crate) fn psd_to_document_decoded( { let first = cc + usize::from(file.merged_has_alpha()); let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); + let extra_n = usize::from(h.channels).saturating_sub(first); // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); - let names = names.get(skip..).unwrap_or_default(); + let names = names.get(skip..).unwrap_or(&[]); + // Spot planes are ink-inverted in the file (0 = solid). Alpha planes are not. + // DisplayInfo lists the transparency record first when `skip` is 1. + let spots = { + let n = extra_n.saturating_add(skip); + let all = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { + crate::channel_map::spot_flags(&r.data, true, n) + } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::spot_flags(&r.data, false, n) + } else { + vec![false; n] + }; + all.into_iter().skip(skip).collect::>() + }; for (k, idx) in (first..usize::from(h.channels)).enumerate() { let Some(p) = merged_plane(all, idx, plane) else { break }; + let mut raw = be_to_ne_plane(p, depth, n); + if spots.get(k).copied().unwrap_or(false) { + crate::pixels::invert_plane(&mut raw, depth, false); + } let mut s = Surface::new(cx.mask_fmt); - s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n)); + s.write_interleaved(canvas, &raw); s.prune(); let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1)); doc.channels.push(AlphaChannel::new(name, s)); diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 91011c6eb1..826c1557f7 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -11,11 +11,13 @@ use std::sync::Arc; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample}; -use photocraft_color::ColorMode; -use photocraft_doc::Document; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document}; +use photocraft_geom::Rect; use photocraft_psd::resources::ids; use photocraft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes, resources_to_bytes}; use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, ImageResource, ResolutionInfo}; +use photocraft_raster::Surface; use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer}; use crate::psd_export::PsdExportOptions; @@ -101,11 +103,31 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result 32, }; let (w, h) = img.dimensions(); - let channels = u16::try_from(img.layout().channels()).map_err(|_| IoError::Unsupported("too many channels".into()))?; + let mut data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?; + let bps = img.sample_type().bytes(); + let plane_len = (w as usize).saturating_mul(h as usize).saturating_mul(bps); + let mut added = 0usize; + for e in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES) { + match native_plane_to_be(&e.data, bps) { + Ok(be) if be.len() == plane_len => { + data.extend(be); + added += 1; + } + _ => warnings.push("an extra TIFF sample did not match the image and was dropped".into()), + } + } + let channels = u16::try_from(img.layout().channels().saturating_add(added)).map_err(|_| IoError::Unsupported("too many channels".into()))?; let header = Header::new(isd.version, w, h, channels, depth, mode); - let data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?; let image_data = ImageData { compression: Compression::Raw, data }; - let file = isd.into_psd(header, resources, image_data); + let mut file = isd.into_psd(header, resources, image_data); + // Empty `Patt` and the default filter mask are written so Photoshop's block list matches + // a file it saved. They carry nothing the document stores, so they are not kept: a + // round trip would otherwise grow `metadata.psd_global_blocks` on every open. + file.global_blocks.retain(|b| { + let empty_pattern = matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3") && b.data.is_empty(); + let default_filter_mask = &b.key == b"FMsk" && b.data.as_slice() == FILTER_MASK_DEFAULT; + !empty_pattern && !default_filter_mask + }); // The same nesting limit as opening a PSD (#2940). crate::psd_import::check_group_depth(&file)?; let (mut doc, w) = psd_to_document(&file); @@ -200,19 +222,24 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr } let mut warnings = Vec::new(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; - // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF - // keeps straight alpha) and uncompressed (its planes are interleaved below). + // The PSD exporter renders the composite we need for the TIFF itself, uncompressed (its + // planes are interleaved below). A spot (or any user channel) is saved the way Photoshop's + // TIFF dialog does with "Save Transparency" off: the composite is colour plus those + // channels, matted on white, and each layer keeps its own transparency. With no user + // channel the composite stays unmatted, straight RGBA, which is what a plain transparent + // TIFF already round-trips as. // Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as // Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a // TIFF and opens only the composite, as one locked Background. The exporter's size estimate // counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB // while its real layer data is a small fraction of that; it is not used here. PSB-style data // is written only past 30000 px, when asked for, or when a length really overflows 32 bits. + let user_channels = !doc.channels.is_empty() || doc.quick_mask.is_some(); let force_psb = opts.force_psb || big; let psd_opts = PsdExportOptions { force_psb, size_limit_psb: false, - merged_matte: false, + merged_matte: user_channels, merged_compression: Compression::Raw, maximize_compatibility: true, document_path: opts.document_path.clone(), @@ -232,7 +259,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr ctl.check().map_err(|_| IoError::Cancelled)?; warnings.extend(w); let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let (layers, w) = match ImageSourceData::from_psd(&file).to_bytes(order) { + let (layers, w) = match tiff_image_source(doc, &file).to_bytes(order) { Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); @@ -240,7 +267,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr warnings.extend(w); warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into()); file = psb; - ImageSourceData::from_psd(&file).to_bytes(order)? + tiff_image_source(doc, &file).to_bytes(order)? } r => r?, }; @@ -250,24 +277,58 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // The profile and XMP are written as TIFF tags, not as resources. EXIF stays a resource // (1058), which the PSD importer reads back unchanged; a block rebuilt from the TIFF's own // Exif directory keeps only the Exif, GPS and descriptive tags. - let resources: Vec = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect(); - let resources = resources_to_bytes(&resources)?; + let mut resources: Vec = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect(); let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); let channels = cc + usize::from(has_alpha); - let extra = usize::from(file.header.channels).saturating_sub(channels); - if extra > 0 { - warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)")); + let extra_n = usize::from(file.header.channels).saturating_sub(channels); + let kept = extra_n.min(codecs::MAX_EXTRA_SAMPLES); + if extra_n > kept { + warnings.push(format!("{extra_n} extra channels; TIFF keeps the first {kept}")); } + // With spot or alpha channels, the IFD image is colour + those channels. The merged + // transparency plane stays out of the IFD (see above); a file with no extra channels keeps + // it, so a plain transparent TIFF still opens with alpha. + let write_alpha = has_alpha && kept == 0; + // One identifier per extra sample actually stored, replacing any preserved 1053 whose count + // described the PSD merged image (which still included transparency). + if kept > 0 { + resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + // Photoshop's spot TIFF (RGB, Save Transparency off) stores identifier 3: the spot's + // index among the IFD samples, not a 1-based count of user channels. + let first = u32::try_from(cc.saturating_add(usize::from(write_alpha))).unwrap_or(u32::MAX); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers_from(first, kept))); + } + // Resource 1022 names a channel of the PSD merged image, which counts the transparency plane. + // The IFD below does not, so the id moves back by one. + if has_alpha + && kept > 0 + && let Some(r) = resources.iter_mut().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO) + && r.data.len() >= 2 + { + let id = u16::from_be_bytes([r.data[0], r.data[1]]).saturating_sub(1); + let be = id.to_be_bytes(); + r.data[0] = be[0]; + r.data[1] = be[1]; + } + // The name and DisplayInfo resources list the merged transparency plane first. The IFD + // leaves that plane out, so the resources must too: Photoshop pairs each name with the + // next sample past the colour channels. + if has_alpha && !write_alpha { + drop_merged_transparency(&mut resources); + } + let resources = resources_to_bytes(&resources)?; + let ifd_channels = cc + usize::from(write_alpha); let sample = csample(fmt.sample); let (w, h) = (doc.size.width, doc.size.height); let cmyk = fmt.mode == ColorMode::Cmyk; - let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?; + let data = interleave(&file.image_data.data, w, h, ifd_channels, cc, sample, cmyk)?; + let extras = native_extra_planes(&file.image_data.data, w, h, channels, kept, sample)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_COMPOSITE_DONE); - let layout = layout_for(fmt.mode, has_alpha); + let layout = layout_for(fmt.mode, write_alpha); let mut img = Image::from_raw(w, h, layout, sample, data)?; img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); img.meta = codecs::Metadata { @@ -280,11 +341,133 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), photoshop_resources: Some(resources), photoshop_layers: Some(layers), + extra_samples: extras, ..Default::default() }; encode_image(&img, Format::Tiff, opts, warnings) } +/// The layer data (`ImageSourceData`) of a layered TIFF saved from `file`, adjusted the way +/// Photoshop writes it. +fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSourceData { + // Photoshop pairs alpha-name resources with samples past the colour channels and ignores + // the ExtraSamples kind. The merged transparency must not occupy that first slot when a + // spot is also stored. Layer transparency stays in each layer's channel −1. + let mut isd = ImageSourceData::from_psd(file); + let spot_extras = file.header.channels as usize > { + let cc = doc.pixel_format().mode.color_channels(); + let alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); + cc + usize::from(alpha) + }; + // Positive layer count: the merged image's first extra sample is not transparency + // (Photoshop's own spot TIFF does this; a negative count would claim the spot plane is alpha). + if spot_extras && let Some(info) = &mut isd.layer_info { + info.merged_alpha = false; + } + // A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make the + // first spot read back as transparency: keep it only while the composite keeps that sample. + if spot_extras || !file.layer_info.as_ref().is_some_and(|l| l.merged_alpha) { + isd.global_blocks.retain(|b| !b.is_merged_transparency_marker()); + } + if let Some(info) = &mut isd.layer_info { + for layer in &mut info.layers { + photoshop_layer_blocks(&mut layer.blocks); + } + } + // Photoshop reads a fixed 14-byte user-mask record from `LMsk` and an empty `Patt` + // before the filter mask. A zero-length `LMsk` is what makes it report that the + // layer data could not be read. + if let Some(mask) = &mut isd.global_layer_mask + && mask.data.len() < 14 + { + *mask = photocraft_psd::GlobalLayerMask::photoshop_default(); + } + if !isd.global_blocks.iter().any(|b| matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3")) { + isd.global_blocks.insert(0, photocraft_psd::TaggedBlock::new(*b"Patt", Vec::new())); + } + if !isd.global_blocks.iter().any(|b| &b.key == b"FMsk") { + isd.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"FMsk", FILTER_MASK_DEFAULT.to_vec())); + } + isd +} + +/// Removes the leading transparency entry from the channel-name resources (1006, 1045) and +/// from DisplayInfo (1077). Each is left unchanged when it is too short to hold that entry. +fn drop_merged_transparency(resources: &mut [ImageResource]) { + for r in resources { + r.data = match r.id { + 1006 => drop_first_pascal(&r.data), + 1045 => drop_first_unicode(&r.data), + id if id == crate::channel_map::DISPLAY_INFO => drop_first_display_record(&r.data), + _ => continue, + }; + } +} + +fn drop_first_pascal(data: &[u8]) -> Vec { + let Some(&n) = data.first() else { return data.to_vec() }; + let end = 1usize.saturating_add(usize::from(n)); + data.get(end..).unwrap_or(data).to_vec() +} + +fn drop_first_unicode(data: &[u8]) -> Vec { + let Some(nbe) = data.get(..4) else { return data.to_vec() }; + let n = u32::from_be_bytes([nbe[0], nbe[1], nbe[2], nbe[3]]) as usize; + let end = 4usize.saturating_add(n.saturating_mul(2)); + data.get(end..).unwrap_or(data).to_vec() +} + +/// Resource 1077: a 4-byte version, then 13-byte records. Drops the first record. +fn drop_first_display_record(data: &[u8]) -> Vec { + const RECORD: usize = 13; + if data.len() < 4 + RECORD { + return data.to_vec(); + } + let mut out = Vec::with_capacity(data.len() - RECORD); + out.extend_from_slice(&data[..4]); + out.extend_from_slice(&data[4 + RECORD..]); + out +} + +/// Filter-mask block (`FMsk`) Photoshop writes next to a layered TIFF: the same red 50% +/// overlay as [`photocraft_psd::GlobalLayerMask::photoshop_default`], without the kind byte. +const FILTER_MASK_DEFAULT: &[u8] = &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50]; + +/// Blocks Photoshop writes on every layer of a layered TIFF, when the layer does not +/// already carry them. Defaults match a plain pixel layer (clipped-as-group on, nothing +/// locked, no label). +fn photoshop_layer_blocks(blocks: &mut Vec) { + let has = |k: &[u8; 4]| blocks.iter().any(|b| &b.key == k); + let mut add = Vec::new(); + if !has(b"lnsr") { + add.push(photocraft_psd::TaggedBlock::name_source(*b"layr")); + } + if !has(b"clbl") { + add.push(photocraft_psd::TaggedBlock::blend_clipped_as_group(true)); + } + if !has(b"infx") { + add.push(photocraft_psd::TaggedBlock::blend_interior_elements(false)); + } + if !has(b"knko") { + add.push(photocraft_psd::TaggedBlock::knockout(0)); + } + if !has(b"lspf") { + add.push(photocraft_psd::TaggedBlock::protection(0)); + } + if !has(b"lclr") { + add.push(photocraft_psd::TaggedBlock::sheet_color(0)); + } + let at = blocks + .iter() + .position(|b| &b.key == b"lyid") + .map(|i| i + 1) + .or_else(|| blocks.iter().position(|b| &b.key == b"luni").map(|i| i + 1)) + .unwrap_or(blocks.len()); + for (i, block) in add.into_iter().enumerate() { + blocks.insert(at.saturating_add(i), block); + } +} + /// Removes the blocks a document kept verbatim from a little-endian TIFF because their layout /// is unknown ([`photocraft_psd::TaggedBlock::is_foreign_order`]) before `file` is written as a /// PSD: their bytes are in the wrong order for a PSD and cannot be converted. Returns a warning @@ -314,6 +497,421 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec Result, IoError> { + if bps == 1 { + return Ok(src.to_vec()); + } + if !matches!(bps, 2 | 4) || !src.len().is_multiple_of(bps) { + return Err(IoError::Unsupported("an extra channel plane is incomplete".into())); + } + let mut out = Vec::with_capacity(src.len()); + for c in src.chunks_exact(bps) { + match (bps, to_be, c) { + (2, true, [a, b]) => { + let v = u16::from_ne_bytes([*a, *b]); + out.extend_from_slice(&v.to_be_bytes()); + } + (2, false, [a, b]) => { + let v = u16::from_be_bytes([*a, *b]); + out.extend_from_slice(&v.to_ne_bytes()); + } + (4, true, [a, b, c, d]) => { + let v = f32::from_ne_bytes([*a, *b, *c, *d]); + out.extend_from_slice(&v.to_be_bytes()); + } + (4, false, [a, b, c, d]) => { + let v = f32::from_be_bytes([*a, *b, *c, *d]); + out.extend_from_slice(&v.to_ne_bytes()); + } + _ => return Err(IoError::Unsupported("an extra channel plane is incomplete".into())), + } + } + Ok(out) +} + +/// Native-endian extra-sample plane → big-endian, for the PSD merged image a layered TIFF rebuilds. +fn native_plane_to_be(src: &[u8], bps: usize) -> Result, IoError> { + swap_plane(src, bps, true) +} + +/// Extra planes of a PSD merged image (`skip` colour-and-alpha planes already consumed), as +/// native-endian TIFF extra samples. A spot plane is already Photoshop ink (0 = solid, max = +/// paper); an alpha plane is already straight. Neither is inverted again here. +fn native_extra_planes(planes: &[u8], w: u32, h: u32, skip: usize, count: usize, sample: CSample) -> Result, IoError> { + let bps = sample.bytes(); + let n = (w as usize).checked_mul(h as usize).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?; + let plane = n.checked_mul(bps).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let at = skip.checked_add(i).and_then(|k| k.checked_mul(plane)).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?; + let Some(src) = planes.get(at..at.saturating_add(plane)) else { + return Err(IoError::Unsupported("the merged image is missing an extra channel".into())); + }; + if src.len() != plane { + return Err(IoError::Unsupported("the merged image is missing an extra channel".into())); + } + out.push(codecs::ExtraSample { kind: 0, data: swap_plane(src, bps, false)? }); + } + Ok(out) +} + +/// One grayscale channel as native-endian samples in `img`'s sample type. +fn channel_plane(channel: &AlphaChannel, canvas: Rect, sample: CSample) -> Result, String> { + let depth = match sample { + CSample::U8 => SampleType::U8, + CSample::U16 => SampleType::U16, + CSample::F16 | CSample::F32 => SampleType::F32, + }; + let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false); + let owned; + let surface = if channel.surface.format() == fmt { + &channel.surface + } else { + owned = channel.surface.convert(fmt); + &owned + }; + let mut bytes = surface.to_interleaved(canvas); + let n = (canvas.width() as usize).saturating_mul(canvas.height() as usize); + let expect = n.saturating_mul(depth.bytes()); + if bytes.len() != expect { + return Err(format!("channel \"{}\" does not cover the canvas", channel.name)); + } + // Document density is 1 = solid ink. Photoshop's TIFF stores the opposite for a spot. + if channel.spot.is_some() { + crate::pixels::invert_plane(&mut bytes, depth, false); + } + if sample == CSample::F16 { + let mut out = Vec::with_capacity(n.saturating_mul(2)); + for arr in bytes.as_chunks::<4>().0 { + let v = f32::from_ne_bytes(*arr); + out.extend_from_slice(&codecs::f16::from_f32(v).to_ne_bytes()); + } + return Ok(out); + } + Ok(bytes) +} + +/// Composites straight alpha over white and drops it. Photoshop's TIFF save with +/// "Save Transparency" off stores that matte in the colour samples and keeps transparency +/// only inside each layer. Fully opaque pixels are copied unchanged, so they stay identical +/// to a composite that was quantized before the matte. `false` when there is no alpha or +/// the buffer is not one sample per channel. +fn matte_and_drop_alpha(img: &mut Image) -> bool { + if !img.layout().has_alpha() { + return false; + } + let (w, h) = img.dimensions(); + let sample = img.sample_type(); + let dst = img.layout().without_alpha(); + let bps = sample.bytes(); + let keep = dst.channels().saturating_mul(bps); + let stride = img.layout().channels().saturating_mul(bps); + if stride == 0 || keep == 0 || bps == 0 || stride <= keep || !img.data().len().is_multiple_of(stride) { + return false; + } + let cmyk = img.layout().is_cmyk(); + let white_byte: u8 = if cmyk { 0 } else { 255 }; + let white_u16: u16 = if cmyk { 0 } else { u16::MAX }; + let white_f: f32 = if cmyk { 0.0 } else { 1.0 }; + // Lab's white is L* 100 with a* and b* 0, which PhotoCraft holds mid-range (`(0 + 128) / 255`). + let lab = img.layout().is_lab(); + let mut out = Vec::with_capacity(img.data().len() / stride * keep); + for px in img.data().chunks_exact(stride) { + let Some(color) = px.get(..keep) else { return false }; + let Some(alpha) = px.get(keep..keep.saturating_add(bps)) else { return false }; + let ok = if lab { + let (Some(l), Some(ab)) = (color.get(..bps), color.get(bps..)) else { return false }; + matte_pixel(sample, l, alpha, white_byte, white_u16, white_f, &mut out) && matte_pixel(sample, ab, alpha, 128, 128 * 257, 128.0 / 255.0, &mut out) + } else { + matte_pixel(sample, color, alpha, white_byte, white_u16, white_f, &mut out) + }; + if !ok { + return false; + } + } + let icc = img.icc.clone(); + let meta = img.meta.clone(); + let warnings = img.warnings.clone(); + let Ok(mut next) = Image::from_raw(w, h, dst, sample, out) else { return false }; + next.icc = icc; + next.meta = meta; + next.warnings = warnings; + *img = next; + true +} + +/// One pixel of [`matte_and_drop_alpha`]. Opaque samples are copied; a clear pixel becomes +/// `white`; anything in between is `c·a + white·(1−a)` in normalized space. +fn matte_pixel(sample: CSample, color: &[u8], alpha: &[u8], white_byte: u8, white_u16: u16, white_f: f32, out: &mut Vec) -> bool { + match sample { + CSample::U8 => { + let Some(&a) = alpha.first() else { return false }; + if a == 255 { + out.extend_from_slice(color); + } else if a == 0 { + out.resize(out.len().saturating_add(color.len()), white_byte); + } else { + let af = f32::from(a) / 255.0; + for &c in color { + let m = crate::pixels::matte(f32::from(c) / 255.0, af, white_f); + out.push((m.clamp(0.0, 1.0) * 255.0).round() as u8); + } + } + } + CSample::U16 => { + let Some(a) = ne_u16(alpha) else { return false }; + if a == u16::MAX { + out.extend_from_slice(color); + } else if a == 0 { + for _ in 0..color.len() / 2 { + out.extend_from_slice(&white_u16.to_ne_bytes()); + } + } else { + let af = f32::from(a) / 65535.0; + for pair in color.as_chunks::<2>().0 { + let c = u16::from_ne_bytes(*pair); + let m = crate::pixels::matte(f32::from(c) / 65535.0, af, white_f); + out.extend_from_slice(&((m.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()); + } + } + } + CSample::F32 => { + let Some(a) = ne_f32(alpha) else { return false }; + if a == 1.0 { + out.extend_from_slice(color); + } else if a == 0.0 || !a.is_finite() { + for _ in 0..color.len() / 4 { + out.extend_from_slice(&white_f.to_ne_bytes()); + } + } else { + let af = a.clamp(0.0, 1.0); + for quad in color.as_chunks::<4>().0 { + let m = crate::pixels::matte(f32::from_ne_bytes(*quad), af, white_f); + out.extend_from_slice(&m.to_ne_bytes()); + } + } + } + CSample::F16 => { + let Some(bits) = ne_u16(alpha) else { return false }; + let a = codecs::f16::from_bits(bits).to_f32(); + if a == 1.0 { + out.extend_from_slice(color); + } else if a == 0.0 || !a.is_finite() { + let w = codecs::f16::from_f32(white_f).to_bits().to_ne_bytes(); + for _ in 0..color.len() / 2 { + out.extend_from_slice(&w); + } + } else { + let af = a.clamp(0.0, 1.0); + for pair in color.as_chunks::<2>().0 { + let c = codecs::f16::from_bits(u16::from_ne_bytes(*pair)).to_f32(); + let m = codecs::f16::from_f32(crate::pixels::matte(c, af, white_f)).to_bits(); + out.extend_from_slice(&m.to_ne_bytes()); + } + } + } + } + true +} + +fn ne_u16(b: &[u8]) -> Option { + let arr: [u8; 2] = b.get(..2)?.try_into().ok()?; + Some(u16::from_ne_bytes(arr)) +} + +fn ne_f32(b: &[u8]) -> Option { + let arr: [u8; 4] = b.get(..4)?.try_into().ok()?; + Some(f32::from_ne_bytes(arr)) +} + +/// Writes alpha and spot channels (and a Quick Mask) into a flat TIFF: extra samples in the +/// IFD, plus Photoshop resources for the name, ink colour and solidity. At most +/// [`codecs::MAX_EXTRA_SAMPLES`] are kept. A TIFF with no extra channels is left unchanged. +/// When any are written, the composite's own transparency is left out of the IFD: readers pair +/// the channel resources with every sample past the colour channels, in order. +pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec { + let total = doc.channels.len().saturating_add(usize::from(doc.quick_mask.is_some())); + if total == 0 { + return Vec::new(); + } + let mut warnings = Vec::new(); + let (w, h) = img.dimensions(); + if w != doc.size.width || h != doc.size.height { + warnings.push("extra channels were not written: the image size differs from the document".into()); + return warnings; + } + let max = codecs::MAX_EXTRA_SAMPLES; + let mut picked: Vec<&AlphaChannel> = doc.channels.iter().take(max).collect(); + if let Some(q) = &doc.quick_mask + && picked.len() < max + { + picked.push(q); + } + if total > picked.len() { + warnings.push(format!("{total} extra channels; TIFF keeps the first {}", picked.len())); + } + let canvas = Rect::new(0, 0, w as i32, h as i32); + let mut planes = Vec::with_capacity(picked.len()); + for ch in &picked { + match channel_plane(ch, canvas, img.sample_type()) { + Ok(data) => planes.push(data), + Err(msg) => { + warnings.push(msg); + break; + } + } + } + picked.truncate(planes.len()); + if picked.is_empty() { + return warnings; + } + let names: Vec<&str> = picked.iter().map(|c| c.name.as_str()).collect(); + let quick_last = matches!((picked.last(), doc.quick_mask.as_ref()), (Some(p), Some(q)) if std::ptr::eq(*p, q)); + let color_channels = img.layout().color_channels(); + // Save Transparency off: the colour samples are the composite over white, and the + // alpha plane is not an extra sample (it would be named as the first spot). The + // composite transparency is gone after this; say so, because the user did not pick + // that option themselves. + if img.layout().has_alpha() { + if !matte_and_drop_alpha(img) { + warnings.push("extra channels were not written: the composite transparency could not be separated from them".into()); + return warnings; + } + warnings.push( + "transparency composited over white; a TIFF with alpha or spot channels stores them as extra samples and drops image transparency (Save Transparency off)" + .into(), + ); + } + let quick_id = if quick_last { u16::try_from(color_channels.saturating_add(picked.len()).saturating_sub(1)).ok() } else { None }; + let first_id = u32::try_from(color_channels).unwrap_or(u32::MAX); + match channel_resources(img.meta.photoshop_resources.as_deref(), &names, &picked, quick_id, first_id) { + Ok(bytes) => img.meta.photoshop_resources = Some(bytes), + Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")), + } + img.meta.extra_samples = planes.into_iter().map(|data| codecs::ExtraSample { kind: 0, data }).collect(); + warnings +} + +/// Existing Photoshop resources with the channel-name, display-info and quick-mask blocks replaced. +fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&AlphaChannel], quick_id: Option, first_id: u32) -> Result, IoError> { + let mut resources = match existing.filter(|b| !b.is_empty()) { + Some(b) => resources_from_bytes(b)?.0, + None => Vec::new(), + }; + resources.retain(|r| { + !matches!(r.id, 1006 | 1045 | crate::channel_map::DISPLAY_INFO | crate::channel_map::QUICK_MASK_INFO | crate::channel_map::ALPHA_IDENTIFIERS) + }); + resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names))); + resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names))); + resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers_from(first_id, names.len()))); + if let Some(id) = quick_id { + let mut data = id.to_be_bytes().to_vec(); + data.push(0); + resources.push(ImageResource::new(crate::channel_map::QUICK_MASK_INFO, data)); + } + Ok(resources_to_bytes(&resources)?) +} + +/// Extra samples of a flat TIFF become alpha / spot channels. Layered TIFFs rebuild those +/// through the PSD importer instead, so this is only for a file with no layer tag. +pub(crate) fn channels_from_extras(doc: &mut Document, img: &Image) -> Vec { + if img.meta.extra_samples.is_empty() { + return Vec::new(); + } + let mut warnings = Vec::new(); + let (w, h) = img.dimensions(); + if w != doc.size.width || h != doc.size.height { + warnings.push("extra TIFF samples were dropped: their size differs from the image".into()); + return warnings; + } + let parsed = img.meta.photoshop_resources.as_deref().filter(|b| !b.is_empty()).and_then(|b| resources_from_bytes(b).ok()); + let none = Vec::new(); + let resources = parsed.as_ref().map(|(r, _)| r.as_slice()).unwrap_or(&none); + let all_names = resources.iter().find(|r| r.id == 1045).map(|r| crate::psd_import::unicode_names(&r.data)).unwrap_or_default(); + // As in the PSD importer: Photoshop lists the image transparency first ("Transparency", with + // its own display record), ahead of the channels the extra samples hold. + let skip = usize::from(img.layout().has_alpha() && all_names.len() == img.meta.extra_samples.len().saturating_add(1)); + let names = all_names.get(skip..).unwrap_or_default(); + let kept = img.meta.extra_samples.len().min(codecs::MAX_EXTRA_SAMPLES); + let spots = if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) { + crate::channel_map::spot_flags(&r.data, true, kept.saturating_add(skip)).split_off(skip) + } else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::spot_flags(&r.data, false, kept.saturating_add(skip)).split_off(skip) + } else { + vec![false; kept] + }; + let start = doc.channels.len(); + let n_px = (w as usize).saturating_mul(h as usize); + for (k, e) in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES).enumerate() { + let invert = spots.get(k).copied().unwrap_or(false); + match mask_from_plane(&e.data, w, h, n_px, img.sample_type(), doc.depth, invert) { + Ok(surface) => { + let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Alpha {}", k + 1)); + doc.channels.push(AlphaChannel::new(name, surface)); + } + Err(msg) => warnings.push(msg), + } + } + if doc.channels.len() == start { + return warnings; + } + { + let slice = &mut doc.channels[start..]; + if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) { + crate::channel_map::apply_display_info(&r.data, true, skip, slice); + } else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::apply_display_info(&r.data, false, skip, slice); + } + } + let first = img.layout().color_channels().saturating_add(usize::from(img.layout().has_alpha())); + if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO) + && r.data.len() >= 2 + && let Some(k) = usize::from(u16::from_be_bytes([r.data[0], r.data[1]])).checked_sub(first) + && start.saturating_add(k) < doc.channels.len() + { + doc.quick_mask = Some(doc.channels.remove(start + k)); + } + warnings +} + +/// Native-endian extra-sample bytes → a grayscale surface at the document depth. +/// `invert` turns Photoshop spot ink (0 = solid) into document density (1 = solid). +fn mask_from_plane(data: &[u8], w: u32, h: u32, n: usize, from: CSample, depth: SampleType, invert: bool) -> Result { + let rect = Rect::new(0, 0, w as i32, h as i32); + let mut bytes = match (from, depth) { + (CSample::U8, SampleType::U8) | (CSample::U16, SampleType::U16) | (CSample::F32, SampleType::F32) => { + if data.len() != n.saturating_mul(from.bytes()) { + return Err("an extra TIFF sample did not match the image and was dropped".into()); + } + data.to_vec() + } + (CSample::F16, SampleType::F32) => { + if data.len() != n.saturating_mul(2) { + return Err("an extra TIFF sample did not match the image and was dropped".into()); + } + let mut out = Vec::with_capacity(n.saturating_mul(4)); + for arr in data.as_chunks::<2>().0 { + let v = codecs::f16::from_bits(u16::from_ne_bytes(*arr)).to_f32(); + out.extend_from_slice(&v.to_ne_bytes()); + } + out + } + _ => return Err(format!("an extra TIFF sample ({from:?}) does not match the document depth and was dropped")), + }; + if invert { + crate::pixels::invert_plane(&mut bytes, depth, false); + } + if bytes.len() != n.saturating_mul(depth.bytes()) { + return Err("an extra TIFF sample did not match the image and was dropped".into()); + } + let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + s.write_interleaved(rect, &bytes); + s.prune(); + Ok(s) +} + #[cfg(test)] mod tests { use photocraft_doc::{Color, Document, Fill, FillCache, Layer, LayerContent}; diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 4cf78aca68..f19ccff7dc 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -34,19 +34,18 @@ fn roundtrip(doc: &Document) -> Document { // The TIFF is a plain image to any other reader: the composite at the document's size. let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode"); assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height)); - // A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not - // kept, and the export says so. + // Extra alpha channels and a Quick Mask travel as TIFF extra samples (at most MAX_EXTRA_SAMPLES). let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some()); - assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{:?}", r.warnings); + if extra > 0 && extra <= photocraft_codecs::MAX_EXTRA_SAMPLES { + assert_eq!(flat.meta.extra_samples.len(), extra, "{:?}", r.warnings); + } import("x.tif", &r.bytes).expect("import").document } -/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask. +/// `doc` as a layered TIFF round trip returns it, including extra channels. fn expected(doc: &Document) -> Document { - let mut d = doc.clone(); - d.channels.clear(); - d.quick_mask = None; - d + doc.clone() } macro_rules! rt_case { @@ -302,12 +301,11 @@ fn psdtags_fixtures_open_with_their_layers() { // tifffile writes a Software tag; nothing else is worth a warning. assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings); // psdtags wrote the composite's alpha sample without the transparency flag, so by the - // PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps - // everything else; the file is then in the encoder's byte order. + // PSD rules it is an alpha channel. A re-save keeps it as a TIFF extra sample. assert_eq!(d.channels.len(), 1, "{name}"); let out = tiff(d, true); let back = import(name, &out.bytes).unwrap().document; - assert_docs_eq(&expected(d), &back); + assert_docs_eq(d, &back); } } @@ -396,6 +394,592 @@ fn damaged_layer_data_opens_flattened() { } } +/// Every sample of a TIFF, read by the `tiff` crate rather than PhotoCraft's decoder. +/// +/// That decoder drops unspecified extra samples when the photometric interpretation is RGB, so +/// the file is retagged MinIsBlack first. The result is then a multiband image of every sample, +/// which is what libtiff's `TIFFReadScanline` returns and what a RIP walks. +fn independent_samples(bytes: &[u8]) -> (u16, Vec, Vec, u32, Vec) { + let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).expect("tiff tags"); + let spp: u16 = tags.get_tag_unsigned(tiff::tags::Tag::SamplesPerPixel).expect("spp"); + let bps = tags.get_tag_u16_vec(tiff::tags::Tag::BitsPerSample).expect("bps"); + let extra = tags.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap_or_default(); + let rows = tags.find_tag_unsigned::(tiff::tags::Tag::RowsPerStrip).ok().flatten().unwrap_or(0); + let patched = photometric_min_is_black(bytes); + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(patched.as_slice())).expect("tiff decode"); + let tiff::decoder::DecodingResult::U8(samples) = dec.read_image().expect("read image") else { + panic!("expected 8-bit samples"); + }; + (spp, bps, extra, rows, samples) +} + +/// Classic-TIFF photometric tag 262 → 1 (MinIsBlack), value stored inline in the IFD entry. +fn photometric_min_is_black(src: &[u8]) -> Vec { + let mut bytes = src.to_vec(); + let le = bytes.first().copied() == Some(b'I'); + let ru16 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 2)?; + Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }) + }; + let ru32 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 4)?; + Some(if le { u32::from_le_bytes(s.try_into().ok()?) } else { u32::from_be_bytes(s.try_into().ok()?) }) + }; + let Some(ifd) = ru32(&bytes, 4) else { return bytes }; + let Some(n) = ru16(&bytes, ifd as usize) else { return bytes }; + for e in 0..n as usize { + let at = ifd as usize + 2 + e * 12; + if ru16(&bytes, at) == Some(262) { + let value = if le { 1u16.to_le_bytes() } else { 1u16.to_be_bytes() }; + if let Some(slot) = bytes.get_mut(at + 8..at + 10) { + slot.copy_from_slice(&value); + } + break; + } + } + bytes +} + +fn assert_plane(samples: &[u8], spp: usize, plane: usize, expected: &[u8]) { + assert_eq!(samples.len(), expected.len().saturating_mul(spp)); + let mut bad = 0usize; + let mut first = None; + for (i, want) in expected.iter().enumerate() { + let got = samples.get(i * spp + plane).copied(); + if got != Some(*want) { + bad += 1; + first.get_or_insert((i, got)); + } + } + assert_eq!(bad, 0, "{bad} mismatches, first {first:?}"); +} + +/// A multi-layer transparent document, large enough for several TIFF strips and several +/// raster tiles, with a spot whose every pixel is a known value. +#[test] +fn dtf_like_spot_is_written_in_full() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_psd::tiff::resources_from_bytes; + use photocraft_raster::Surface; + + let (w, h) = (900u32, 360u32); + let mut doc = Document::new("dtf", Size::new(w, h), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + let px = photocraft_raster::from_rgba(&fmt, [0.2, 0.5, 0.9, 1.0]); + art.fill_rect(Rect::new(30, 20, 870, 340), &px); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let mut ink = Surface::new(fmt); + let px = photocraft_raster::from_rgba(&fmt, [0.9, 0.1, 0.2, 0.5]); + ink.fill_rect(Rect::new(80, 40, 700, 300), &px); + let mut layer = Layer::new("Ink", LayerContent::Raster(ink)); + layer.opacity = 0.8; + doc.layers.push(layer); + + let n = (w as usize) * (h as usize); + let mut spot_bytes = vec![0u8; n]; + for y in 0..h as usize { + for x in 0..w as usize { + spot_bytes[y * w as usize + x] = ((x * 3 + y * 17) % 251) as u8; + } + } + // Outside the artwork the composite is transparent, but the spot is not zero: a reader that + // binds "Spot Color 1" to the alpha plane fails here. + assert_eq!(spot_bytes[10 * w as usize + 10], 200); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let spot_surface = Surface::from_interleaved(gray, Rect::new(0, 0, w as i32, h as i32), &spot_bytes); + let mut channel = AlphaChannel::new("Spot Color 1", spot_surface); + channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6)); + doc.channels.push(channel); + + let mut seen = Vec::new(); + for (name, layers) in [("flat", false), ("layered", true)] { + let opts = ExportOptions { tiff_layers: layers, ..ExportOptions::default() }; + let r = export(&doc, "dtf.tif", &opts).unwrap(); + assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{name}: {:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha(), "{name}: {:?}", img.layout()); + assert_eq!(img.layout().color_channels(), 3, "{name}"); + assert_eq!(img.meta.extra_samples.len(), 1, "{name}"); + assert_eq!(img.meta.extra_samples[0].kind, 0, "{name}"); + // Photoshop stores spot ink inverted: document 255 (solid) is file 0. + let stored_spot: Vec = spot_bytes.iter().map(|b| 255 - b).collect(); + assert_eq!(img.meta.extra_samples[0].data, stored_spot, "{name} own decoder"); + + let (spp, bps, extra, rows, samples) = independent_samples(&r.bytes); + assert_eq!(spp, 4, "{name}"); + assert_eq!(bps, vec![8, 8, 8, 8], "{name}"); + assert_eq!(extra, vec![0], "{name} ExtraSamples"); + assert!(rows > 0 && rows < h, "{name} rows_per_strip {rows}"); + assert_plane(&samples, 4, 3, &stored_spot); + seen.push(samples.clone()); + + // Outside the artwork the composite is matted to white (Save Transparency off). The + // spot at that pixel is not zero, so a reader that binds the name to the alpha fails. + let at = (10 * w as usize + 10) * 4; + assert_eq!(&samples[at..at + 3], &[255, 255, 255], "{name} matted rgb"); + let (resources, layer_tag) = tags(&r.bytes); + let resources = resources.expect("photoshop resources"); + let (blocks, warnings) = resources_from_bytes(&resources).unwrap(); + assert!(warnings.is_empty(), "{name} {warnings:?}"); + let block = |id: u16| blocks.iter().find(|b| b.id == id).unwrap_or_else(|| panic!("{name} missing {id}")); + // "Spot Color 1" is 12 characters: Photoshop stores 13 bytes and pads the 8BIM block, not the string. + assert_eq!(block(1006).data, b"\x0cSpot Color 1", "{name}"); + assert_eq!(block(1045).data.len(), 30, "{name}"); + let info = &block(1077).data; + assert_eq!(info.len(), 17, "{name}"); + assert_eq!(u16::from_be_bytes([info[14], info[15]]), 60, "{name} solidity"); + assert_eq!(info[16], 2, "{name} spot kind"); + // RGB occupies samples 0..2, so the spot's identifier is 3. + assert_eq!(block(1053).data, 3u32.to_be_bytes(), "{name} channel id"); + + let back = import("dtf.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.len(), 1, "{name}"); + assert_eq!(back.channels[0].name, "Spot Color 1"); + let (ink, sol) = back.channels[0].spot.expect("spot"); + assert_eq!(ink.to_rgb(), [1.0, 0.0, 0.0], "{name}"); + assert!((sol - 0.6).abs() < 1e-4, "{name}"); + let area = Rect::new(0, 0, w as i32, h as i32); + assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{name} reimport"); + if layers { + let layer_tag = layer_tag.expect("layer tag"); + let (isd, w) = ImageSourceData::from_bytes(&layer_tag).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let info = isd.layer_info.expect("layers"); + assert!(!info.merged_alpha, "{name}"); + assert!(info.layers.iter().all(|l| l.flags.0 & 0x08 != 0), "{name} photoshop flag"); + assert!(info.layers.iter().all(|l| l.blocks.iter().any(|b| &b.key == b"lnsr")), "{name} lnsr"); + let mask = isd.global_layer_mask.expect("LMsk"); + assert_eq!(mask.data.len(), 14, "{name}"); + assert_eq!(mask.opacity(), Some(50)); + assert_eq!(mask.kind(), Some(128)); + assert_photoshop_layered_tiff(&r.bytes, true); + assert_eq!(back.layers.len(), 2, "{name}"); + let ink = back.layers.iter().find(|l| l.name == "Ink").expect("ink layer"); + let px = ink.surface().expect("ink pixels").pixel(100, 100); + assert!((px[3] - 0.5).abs() < 0.02, "{name} layer alpha {px:?}"); + let outside = ink.surface().expect("ink pixels").pixel(0, 0); + assert!(outside[3] < 0.02, "{name} transparent pixel {outside:?}"); + } else { + assert!(layer_tag.is_none(), "{name}"); + } + } + assert_eq!(seen[0], seen[1], "flat and layered IFD samples differ"); +} + +/// A transparent document with no user channels still stores image alpha. +#[test] +fn transparent_tiff_without_channels_keeps_image_alpha() { + use photocraft_doc::Layer; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + for layers in [false, true] { + let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.layout().has_alpha(), "{layers}"); + assert!(img.meta.extra_samples.is_empty(), "{layers}"); + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(r.bytes.as_slice())).unwrap(); + let extra = dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap(); + assert_eq!(extra, vec![2], "{layers}"); + assert!(!r.warnings.iter().any(|w| w.contains("Save Transparency")), "{layers}: {:?}", r.warnings); + if layers { + assert_photoshop_layered_tiff(&r.bytes, false); + } + } +} + +/// A flat TIFF that also stores an alpha or spot channel mattes the composite on white +/// and drops image transparency. The export says so. +#[test] +fn flat_tiff_warns_when_extra_channels_matte_transparency() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + // (0, 0) stays transparent. (1, 1) is half-red, so the matte is halfway to white. + art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false))); + spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0)); + doc.channels.push(spot); + + let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: false, ..ExportOptions::default() }).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("composited over white") && w.contains("Save Transparency")), "{:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha(), "{:?}", img.layout()); + assert_eq!(img.meta.extra_samples.len(), 1); + let (_spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); + assert_eq!(extra, vec![0]); + // 4 samples per pixel: RGB + spot. (0, 0) was transparent and is now white. + assert_eq!(&samples[..3], &[255, 255, 255]); + // Half-red over white is about (255, 128, 128). Pixel (1, 1), 4 samples per pixel. + let (x, y, width, spp) = (1usize, 1usize, 4usize, 4usize); + let at = (y * width + x) * spp; + let rgb = &samples[at..at + 3]; + assert!(rgb[0] > 250 && (rgb[1] as i16 - 128).abs() <= 2 && (rgb[2] as i16 - 128).abs() <= 2, "{rgb:?}"); +} + +/// A 4×4 RGB document whose one layer is transparent outside its middle, plus a "White" spot +/// channel with solid ink in its left half: the shape of a UV print's underbase. +fn transparent_art_with_white_spot() -> Document { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(1, 1, 3, 3), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut plate = Surface::new(gray); + plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + let mut white = AlphaChannel::new("White", plate); + white.spot = Some((Color::rgb(0.8, 0.2, 0.2), 1.0)); + doc.channels.push(white); + doc +} + +/// The first channel's ink at (0, 0), inked, and at (3, 0), bare. +fn ink(d: &Document) -> (f32, f32) { + (d.channels[0].surface.pixel(0, 0)[0], d.channels[0].surface.pixel(3, 0)[0]) +} + +/// `doc` laid out as Photoshop 25.12 saves a TIFF with Save Transparency on: R, G, B, +/// transparency and spot samples, with "Transparency" first in the channel names and display +/// records. With `layers`, the layer data has a positive layer count and an `MTrn` block (the +/// specification's key is `Mtrn`); without, the file is flat. Built from the PSD model of `doc`. +fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec { + use photocraft_psd::{Compression, TaggedBlock}; + + let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() }; + let (file, _) = document_to_psd_with(doc, &opts); + let planes = file.decode_merged().unwrap(); + let n = 16; + assert_eq!(planes.len(), 5 * n); + let rgba: Vec = (0..n).flat_map(|i| (0..4).map(move |c| (i, c))).map(|(i, c)| planes[c * n + i]).collect(); + let mut img = photocraft_codecs::Image::from_raw(4, 4, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, rgba).unwrap(); + img.meta.extra_samples = vec![photocraft_codecs::ExtraSample { kind: 0, data: planes[4 * n..].to_vec() }]; + img.meta.photoshop_resources = Some(photocraft_psd::tiff::resources_to_bytes(&file.resources).unwrap()); + if layers { + let mut isd = ImageSourceData::from_psd(&file); + if let Some(info) = &mut isd.layer_info { + info.merged_alpha = false; + } + isd.global_blocks.insert(0, TaggedBlock::new(*b"MTrn", Vec::new())); + let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; + img.meta.photoshop_layers = Some(isd.to_bytes(order).unwrap().0); + } + photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).unwrap() +} + +/// Photoshop's layered TIFF with transparency next to a spot channel opens with the spot as the +/// only channel. A layered re-save leaves the transparency sample out next to the spot, so it +/// must also drop the `MTrn` marker, or the spot would read back as transparency. +#[test] +fn photoshop_mtrn_tiff_opens_without_a_transparency_channel() { + use photocraft_psd::TaggedBlock; + + let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), true); + let opened = import("t.tif", &bytes).unwrap().document; + let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"]); + assert!(opened.channels[0].spot.is_some()); + assert_eq!(ink(&opened), (1.0, 0.0)); + + let r = tiff(&opened, true); + let (isd, _) = ImageSourceData::from_bytes(&tags(&r.bytes).1.unwrap()).unwrap(); + assert!(!isd.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker)); + let back = import("t.tif", &r.bytes).unwrap().document; + let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"]); + assert_eq!(ink(&back), (1.0, 0.0)); +} + +/// The same Photoshop layout in a flat TIFF (no layer data): the image keeps its transparency, +/// and the spot gets its own name and display record, not the leading "Transparency" ones, so +/// it opens as a spot with its ink the right way round. +#[test] +fn photoshop_flat_tiff_with_transparency_opens_its_spot_as_a_spot() { + let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), false); + assert!(tags(&bytes).1.is_none(), "flat: no layer data"); + let opened = import("t.tif", &bytes).unwrap().document; + let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"]); + assert!(opened.channels[0].spot.is_some(), "a spot, not an alpha channel"); + assert_eq!(ink(&opened), (1.0, 0.0)); + let LayerContent::Raster(s) = &opened.layers[0].content else { panic!("raster") }; + assert_eq!(s.pixel(0, 0)[3], 0.0, "the image keeps its transparency"); +} + +/// A Lab document with a spot channel keeps the spot as a CIELab extra sample, and its +/// transparency is matted on Lab white (L* 100, a* and b* 0), not on the maximum of every sample. +#[test] +fn lab_tiff_keeps_spot_and_mattes_on_lab_white() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + for sample in [SampleType::U8, SampleType::U16] { + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Lab, sample); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + // (0, 0) stays transparent. + art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let mut plate = Surface::new(PixelFormat::new(ColorMode::Grayscale, sample, false)); + plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&plate.format(), [1.0, 1.0, 1.0, 1.0])); + let mut spot = AlphaChannel::new("Spot Color 1", plate); + spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0)); + doc.channels.push(spot); + + // Lab is always saved flat, whether layers were asked for or not. + for layers in [false, true] { + let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Lab, "{sample:?} {layers}"); + assert_eq!(img.meta.extra_samples.len(), 1, "{sample:?} {layers}"); + let white = &img.to_normalized()[..3]; + assert!((white[0] - 1.0).abs() < 1e-3 && (white[1] - 128.0 / 255.0).abs() < 1e-3 && (white[2] - 128.0 / 255.0).abs() < 1e-3, "{white:?}"); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.pixel_format().mode, ColorMode::Lab, "{sample:?} {layers}"); + assert_eq!(back.channels.len(), 1, "{sample:?} {layers}"); + assert_eq!(back.channels[0].name, "Spot Color 1"); + assert!(back.channels[0].spot.is_some(), "{sample:?} {layers}"); + let plate = |c: &AlphaChannel| c.surface.to_interleaved(Rect::new(0, 0, 4, 4)); + assert_eq!(plate(&back.channels[0]), plate(&doc.channels[0]), "{sample:?} {layers}"); + } + } +} + +/// Tag 37724 walked as raw bytes (not through [`ImageSourceData`]). +/// +/// Matches a Photoshop 27 layered TIFF: classic 4-byte block lengths, `Layr` then a 14-byte +/// `LMsk`, no `MTrn` (that block is only for a composite that keeps both transparency and a +/// spot). A 4320×4320 RGB spot plate is 74_649_600 bytes and still fits those u32 lengths; +/// this check uses the same layout on a smaller image. +fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) { + assert!(bytes.starts_with(b"II"), "little-endian TIFF"); + let u16_at = |i: usize| u16::from_le_bytes([bytes[i], bytes[i + 1]]); + let u32_at = |i: usize| u32::from_le_bytes(bytes[i..i + 4].try_into().unwrap()); + let ifd = u32_at(4) as usize; + let n = u16_at(ifd) as usize; + let mut layers = None; + let mut spp = 0u16; + let mut extra = Vec::new(); + for e in 0..n { + let at = ifd + 2 + e * 12; + let tag = u16_at(at); + let typ = u16_at(at + 2); + let count = u32_at(at + 4) as usize; + let val = u32_at(at + 8) as usize; + let unit: usize = match typ { + 3 => 2, + 4 => 4, + _ => 1, + }; + let size = unit.saturating_mul(count); + let inline = at + 8; + let raw = if size <= 4 { &bytes[inline..inline + size] } else { &bytes[val..val + size] }; + match tag { + 277 => spp = u16::from_le_bytes([raw[0], raw[1]]), + 338 => extra = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(), + 37724 => layers = Some(raw), + _ => {} + } + } + let isd = layers.expect("37724"); + let sig = b"Adobe Photoshop Document Data Block\0"; + assert!(isd.starts_with(sig), "PSB-sized lengths"); + let mut pos = sig.len(); + let mut keys = Vec::new(); + let mut layr: Option> = None; + while pos + 12 <= isd.len() { + assert_eq!(&isd[pos..pos + 4], b"MIB8", "block at {pos}"); + let key = [isd[pos + 7], isd[pos + 6], isd[pos + 5], isd[pos + 4]]; + let len = u32::from_le_bytes(isd[pos + 8..pos + 12].try_into().unwrap()) as usize; + let body = &isd[pos + 12..pos + 12 + len]; + if &key == b"Layr" { + layr = Some(body.to_vec()); + } + if &key == b"LMsk" { + assert_eq!(body, &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0x32, 0, 0x80, 0]); + } + keys.push(key); + pos += 12 + len; + let rest = &isd[pos..]; + let pad = (0..=3).find(|&k| k == rest.len() || rest.get(k..k + 4) == Some(b"MIB8")).unwrap_or(0); + pos += pad; + } + assert_eq!(pos, isd.len(), "trailing bytes after the last block"); + assert_eq!(keys.first(), Some(b"Layr")); + assert_eq!(keys.get(1), Some(b"LMsk")); + assert!(!keys.iter().any(|k| k == b"MTrn" || k == b"Mtrn")); + let layr = layr.expect("Layr"); + let count = i16::from_le_bytes([layr[0], layr[1]]); + if with_spot { + assert!(count > 0, "layer count {count}"); + assert_eq!((spp, extra.as_slice()), (4, [0u16].as_slice())); + } else { + assert!(count < 0, "layer count {count}"); + assert_eq!(extra, vec![2]); + } + let nch = u16::from_le_bytes([layr[18], layr[19]]) as usize; + let mut ids = Vec::new(); + let mut p = 20; + for _ in 0..nch { + ids.push(i16::from_le_bytes([layr[p], layr[p + 1]])); + p += 6; + } + assert!(ids.contains(&-1), "channel ids {ids:?}"); +} + +/// Photoshop 27's Kato-Karate TIFF stores spot ink inverted: every non-white artwork pixel +/// has spot 0, and the white background is spot 255. An alpha channel is the other way +/// around (255 = selected). Both polarities round-trip, in TIFF and in PSD. +#[test] +fn spot_ink_is_stored_as_zero_and_alpha_is_not() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_psd::PsdFile; + use photocraft_raster::Surface; + + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let (w, h) = (8u32, 8u32); + let mut doc = Document::new("pol", Size::new(w, h), ColorMode::Rgb, depth); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(2, 2, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.2, 0.1, 1.0])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let n = (w as usize) * (h as usize); + let mut ink = vec![0.0f32; n]; + for y in 2..6 { + for x in 2..6 { + ink[y * w as usize + x] = 1.0; + } + } + let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(gray)); + spot.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink); + spot.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6)); + doc.channels.push(spot); + let mut alpha = AlphaChannel::new("Alpha 1", Surface::new(gray)); + alpha.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink); + doc.channels.push(alpha); + + let psd = export(&doc, "pol.psd", &ExportOptions::default()).unwrap(); + let file = PsdFile::from_bytes(&psd.bytes).unwrap(); + let merged = file.decode_merged().unwrap(); + let bps = depth.bytes(); + let plane = n * bps; + let first = 3 + usize::from(file.merged_has_alpha()); + let spot_plane = &merged[first * plane..(first + 1) * plane]; + let alpha_plane = &merged[(first + 1) * plane..(first + 2) * plane]; + // Background (0,0) and artwork (3,3). Spot: 0 = solid ink, max = paper. + // Alpha: max = selected, 0 = empty. + assert_stored(spot_plane, 0, depth, false, "psd spot background"); + assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, true, "psd spot art"); + assert_stored(alpha_plane, 0, depth, true, "psd alpha background"); + assert_stored(alpha_plane, (3 * w as usize + 3) * bps, depth, false, "psd alpha art"); + let back = import("pol.psd", &psd.bytes).unwrap().document; + let area = Rect::new(0, 0, w as i32, h as i32); + assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{depth:?} psd spot"); + assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{depth:?} psd alpha"); + assert!(back.channels[0].spot.is_some(), "{depth:?}"); + assert!(back.channels[1].spot.is_none(), "{depth:?}"); + + if depth == SampleType::U8 { + for layers in [false, true] { + let r = export(&doc, "pol.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); + assert_eq!((spp, extra.as_slice()), (5, [0u16, 0].as_slice()), "{layers}"); + // Reference TIFF: artwork spot sample is 0, background spot sample is 255. + assert_eq!(samples[3], 255, "{layers} background spot"); + assert_eq!(samples[(3 * w as usize + 3) * 5 + 3], 0, "{layers} art spot"); + assert_eq!(samples[4], 0, "{layers} background alpha"); + assert_eq!(samples[(3 * w as usize + 3) * 5 + 4], 255, "{layers} art alpha"); + let back = import("pol.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); + assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); + } + } + } +} + +/// `at` addresses one sample of a big-endian PSD plane. `zero` expects 0; otherwise the +/// maximum sample (255 / 65535 / 1.0). +fn assert_stored(plane: &[u8], at: usize, depth: SampleType, zero: bool, what: &str) { + match depth { + SampleType::U8 => { + let v = plane[at]; + assert_eq!(v, if zero { 0 } else { 255 }, "{what} {v}"); + } + SampleType::U16 => { + let v = u16::from_be_bytes([plane[at], plane[at + 1]]); + assert_eq!(v, if zero { 0 } else { 65535 }, "{what} {v}"); + } + SampleType::F32 => { + let v = f32::from_be_bytes([plane[at], plane[at + 1], plane[at + 2], plane[at + 3]]); + assert_eq!(v, if zero { 0.0 } else { 1.0 }, "{what} {v}"); + } + } +} + +/// Quick Mask stays the last channel after the merged transparency plane is omitted. +#[test] +fn spot_tiff_keeps_quick_mask_when_composite_alpha_is_omitted() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("q", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let area = Rect::new(0, 0, 8, 8); + let mut spot_bytes = vec![9u8; 64]; + spot_bytes[0] = 3; + let mut channel = AlphaChannel::new("Spot Color 1", Surface::from_interleaved(gray, area, &spot_bytes)); + channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6)); + doc.channels.push(channel); + let mut qbytes = vec![7u8; 64]; + qbytes[1] = 4; + doc.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::from_interleaved(gray, area, &qbytes))); + + for layers in [false, true] { + let r = export(&doc, "q.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); + assert_eq!((spp, extra.as_slice()), (5, [0, 0].as_slice()), "{layers}"); + let stored_spot: Vec = spot_bytes.iter().map(|b| 255 - b).collect(); + assert_plane(&samples, 5, 3, &stored_spot); + // A Quick Mask is an alpha channel: stored straight, not as spot ink. + assert_plane(&samples, 5, 4, &qbytes); + let back = import("q.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.len(), 1, "{layers}"); + assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{layers}"); + let qm = back.quick_mask.as_ref().unwrap_or_else(|| panic!("{layers}: no quick mask")); + assert_eq!(qm.surface.to_interleaved(area), qbytes, "{layers}"); + } +} + #[test] fn default_export_options_write_a_flat_tiff() { // Scripted and agent saves (CLI, batch, MCP) build on the defaults: layers only on request. diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 740d44f8bf..eec73ed072 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -39,6 +39,14 @@ pub struct GlobalLayerMask { } impl GlobalLayerMask { + /// The mask Photoshop writes for a layered file that has no global mask of its own: + /// an RGB red overlay at 50% opacity, kind 128 (use the per-layer setting), plus the + /// filler byte. A zero-length `LMsk` block in a TIFF makes Photoshop give up on the + /// layer data (it reads this 14-byte record from the block). + pub fn photoshop_default() -> Self { + GlobalLayerMask { data: vec![0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] } + } + fn be16(&self, at: usize) -> Option { self.data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]])) } diff --git a/crates/psd/tests/roundtrip.rs b/crates/psd/tests/roundtrip.rs index 1f23ee6284..49ad412e7c 100644 --- a/crates/psd/tests/roundtrip.rs +++ b/crates/psd/tests/roundtrip.rs @@ -232,6 +232,7 @@ fn global_mask_accessors() { assert_eq!(gm.opacity(), Some(50)); assert_eq!(gm.kind(), Some(128)); assert_eq!(GlobalLayerMask::default().kind(), None); + assert_eq!(GlobalLayerMask::photoshop_default().data, gm.data); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 63fef83486..e2fc72c8ed 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** patch (Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; earlier the same day: Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | TIFF: alpha and spot channels are written and read as extra samples (at most 16) with Photoshop's channel resources (1006, 1045, 1077, 1053), spot ink stored 0 = solid as Photoshop 27.11's TIFFs store it; PSD output is unchanged. FILE-215-7 done, FILE-215-17 partial (its TIFF part) (#7415, supersedes #3121) | | 2026-10-21 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice. A PSD save with an opaque composite drops any of these markers kept from the opened file, so its first alpha channel no longer reopens as transparency and vanishes (split from #7415; credit #3121) | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | | 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) | diff --git a/docs/gaps.md b/docs/gaps.md index ab42d0f8ef..647c121998 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -230,9 +230,12 @@ what an agent can't finish alone. write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, + Raw; spot plates beyond TIFF: spot ink preview in RGB/CMYK documents and PDF Separation + (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-21, #7415):** TIFF writes and reads alpha and spot channels as extra samples + with Photoshop's names, ink colours and solidity (FILE-215-7 done; FILE-215-17's TIFF part). - **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the DPX header/unpacking (`crates/codecs/src/codecs/dpx.rs`); writing is still open. - **Estimate:** 120–220 h. @@ -274,6 +277,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | G10: TIFF alpha and spot channels written and read as extra samples with Photoshop's channel resources, PSD output unchanged (#7415); FILE-215-7 leaves the list, FILE-215-17 narrows to RGB/CMYK spot preview and PDF Separation | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3eca518c27..6e8449e38e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (TIFF spot plates, FILE-215-17's TIFF part, #7415; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -175,7 +175,7 @@ parity for the area and are already contained in the dimension rows above. | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | -| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | +| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..84738c1455 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -50,9 +50,9 @@ note = "#426: SmartObject.perspective holds the projective map (transform keeps [[item]] id = "FILE-215-7" target = "Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref" -status = "partial" +status = "done" issue = 215 -note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample)" +note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha/spot channels and a Quick Mask are written as TIFF extra samples (at most 16) plus Photoshop resources for name, colour and solidity; Photoshop's MTrn marker is read (#7415). The TIFF Options Save Transparency choice isn't offered: channels are written as Photoshop does with it off" [[item]] id = "FILE-215-8" @@ -120,9 +120,9 @@ note = "doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm v [[item]] id = "FILE-215-17" target = "CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF)" -status = "missing" +status = "partial" issue = 215 -note = "compose multichannel.rs:1 spot preview for Multichannel only" +note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" [[item]] id = "FILE-215-18" From f7f8fdcd89196ee98cdca46aed546a38ecb78160 Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 11:36:39 -0400 Subject: [PATCH 04/19] io: keep PSD output as on main; Photoshop spot polarity for TIFF only #7415 changed every PSD export (spot planes inverted, bit 3 on group dividers, a 14-byte LMsk, a fresh 1053) and dropped 1053 on PSD open. PSD export and import are back to main's behaviour; the layered TIFF asks for Photoshop's spot polarity through an opt-in switch (PsdExportOptions/PsdImportOptions::photoshop_spot_polarity), and the LMsk and flag bit 3 move into tiff_layers. - One helper, channel_map::photoshop_spot_plane, converts every spot plane in Photoshop's polarity (flat TIFF save/open, layered TIFF via the PSD paths). - DisplayInfo is read as on main (13-byte 1077, 14-byte 1007 records); Photoshop's PSDs and TIFFs all use 13. - 1053 is numbered from 3 in every mode, as all 12 Photoshop TIFFs are. A layered TIFF's 1053 is not kept on open, and a TIFF save never copies a PSD's. - Tests: PSD keeps spot and alpha planes straight with no 1053, an empty LMsk and main's flags; a PSD's 1053 survives open and save; a TIFF saved as PSD writes straight planes and no 1053. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> --- crates/io/src/channel_map.rs | 111 +++++++++++++++++++-------------- crates/io/src/lib.rs | 3 +- crates/io/src/pixels.rs | 29 ++------- crates/io/src/psd_export.rs | 90 +++++++++++++++++++++----- crates/io/src/psd_import.rs | 45 ++++++------- crates/io/src/tiff_layers.rs | 55 ++++++++-------- crates/io/tests/tiff_layers.rs | 19 +++--- 7 files changed, 211 insertions(+), 141 deletions(-) diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index 60b671101a..990027441a 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -1,14 +1,13 @@ //! Alpha / spot channel options <-> PSD `DisplayInfo` image resources (1077, and the older 1007). //! -//! Layout (Adobe PSD spec, "DisplayInfo", and a Photoshop 27 layered TIFF): resource 1077 -//! starts with a 4-byte version (1), then one record per extra channel: colour space (u16), -//! four u16 colour components, opacity (u16, 0..100) and kind (u8: 0 = colour indicates -//! selected areas, 1 = masked ("protected") areas, 2 = spot channel). Photoshop writes that -//! 13-byte record with no pad. Older files pad each record to 14 bytes; both are read. -//! Resource 1007 has the same records without the version, each followed by a padding byte. -//! When the merged image has transparency, Photoshop writes that channel's record first. +//! Layout (Adobe PSD spec, "DisplayInfo"): resource 1077 starts with a 4-byte version (1), then +//! one 13-byte record per extra channel: colour space (u16), four u16 colour components, opacity +//! (u16, 0..100) and kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected") +//! areas, 2 = spot channel). Resource 1007 has the same records without the version and the spot +//! kind, each followed by a padding byte (14 bytes). Photoshop 27's TIFFs use the same 13-byte +//! 1077 records. -use photocraft_color::{Color, ColorMode}; +use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_doc::{AlphaChannel, ColorIndicates}; pub(crate) const DISPLAY_INFO: u16 = 1077; @@ -24,18 +23,24 @@ const SPACE_CMYK: u16 = 2; const SPACE_LAB: u16 = 7; const SPACE_GRAY: u16 = 8; -/// Resource 1053: channel ids `1..=count`, one big-endian u32 each, with no length prefix. -/// Photoshop, ag-psd and psd-tools read u32s until the resource ends. -pub(crate) fn alpha_identifiers(count: usize) -> Vec { - alpha_identifiers_from(1, count) +/// Converts one spot channel's plane (native-endian samples) between the document's ink density +/// (1 = solid ink) and the way Photoshop stores a spot channel (0 = solid ink, the maximum = +/// bare paper; every TIFF Photoshop 27 saves stores it so). The conversion is its own inverse, +/// so it serves both open and save. Every reader and writer of Photoshop-polarity spot planes +/// goes through here: a change to the in-memory convention changes only this function. Alpha +/// channels are stored straight (the maximum = selected) and never come here. +pub(crate) fn photoshop_spot_plane(plane: &mut [u8], sample: SampleType) { + crate::pixels::invert_plane(plane, sample); } -/// Resource 1053 starting at `first` (a layered TIFF uses the sample's index in the IFD: -/// 3 for the spot after RGB, not 1). -pub(crate) fn alpha_identifiers_from(first: u32, count: usize) -> Vec { +/// Resource 1053 for `count` channels: one big-endian u32 identifier each, with no length prefix +/// (Photoshop, ag-psd and psd-tools read u32s until the resource ends). Photoshop 27.11 numbers a +/// new document's channels 3, 4, 5, … in every colour mode (RGB, CMYK, Gray and Lab, in its PSDs +/// and TIFFs alike), so these do too. +pub(crate) fn alpha_identifiers(count: usize) -> Vec { let mut out = Vec::with_capacity(count.saturating_mul(4)); for i in 0..count { - let id = first.saturating_add(u32::try_from(i).unwrap_or(u32::MAX)); + let id = 3u32.saturating_add(u32::try_from(i).unwrap_or(u32::MAX)); out.extend_from_slice(&id.to_be_bytes()); } out @@ -67,41 +72,25 @@ pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Ve out } -/// 13 when every record is Photoshop's unpadded form, otherwise the 14-byte padded form. -/// -/// Lengths divisible by both (14, 28, … unpadded records, or 13, 26, … padded ones) take -/// the unpadded stride for resource 1077, which is what Photoshop 27 writes, and the padded -/// stride for the older 1007 resource, whose record includes the pad byte. -fn display_stride(body: &[u8], versioned: bool) -> usize { - let unpadded = !body.is_empty() && body.len().is_multiple_of(13); - let padded = !body.is_empty() && body.len().is_multiple_of(14); - if unpadded && (!padded || versioned) { 13 } else { 14 } +/// The records of a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; +/// else the older 1007 with 14-byte records). A trailing partial record is left out. +fn display_records(data: &[u8], versioned: bool) -> std::slice::ChunksExact<'_, u8> { + let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) }; + body.chunks_exact(size) } -/// Which of the first `count` DisplayInfo records are spot channels (kind 2). -/// Missing records are not spots. Alpha channels (kind 0 or 1) stay false: their samples -/// are stored straight, while a spot's samples are stored inverted. -pub(crate) fn spot_flags(data: &[u8], versioned: bool, count: usize) -> Vec { - let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data }; - let stride = display_stride(body, versioned); - let mut out = vec![false; count]; - for (i, rec) in body.chunks(stride).take(count).enumerate() { - if rec.len() >= 13 { - out[i] = rec[12] == 2; - } - } - out +/// Which of the extra channels are spot channels (kind 2), after skipping `skip` records (the +/// merged transparency channel's), in the same records [`apply_display_info`] reads. A channel +/// without a record is not a spot. +pub(crate) fn spot_flags(data: &[u8], versioned: bool, skip: usize) -> Vec { + display_records(data, versioned).skip(skip).map(|rec| rec.get(12) == Some(&2)).collect() } -/// Applies a DisplayInfo resource (`versioned` = 1077: records after a version; else the older -/// 1007) to the imported channels in order, after skipping `skip` records (the merged -/// transparency channel's). Both the 13-byte records Photoshop writes and the 14-byte padded -/// form are read. +/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the +/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip` +/// records (the merged transparency channel's). pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) { - let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data }; - let stride = display_stride(body, versioned); - for (c, rec) in channels.iter_mut().zip(body.chunks(stride).skip(skip)) { - let Some(rec) = rec.get(..13) else { break }; + for (c, rec) in channels.iter_mut().zip(display_records(data, versioned).skip(skip)) { let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]); let comps = [u(2), u(4), u(6), u(8)]; let color = decode_color(u(0), comps); @@ -199,9 +188,9 @@ mod tests { assert!((back[0].opacity - 0.3).abs() < 1e-6); assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); - assert_eq!(alpha_identifiers(2), [0, 0, 0, 1, 0, 0, 0, 2]); + assert_eq!(spot_flags(&bytes, true, 0), [false, true]); + assert_eq!(alpha_identifiers(2), [0, 0, 0, 3, 0, 0, 0, 4]); assert!(alpha_identifiers(0).is_empty()); - assert_eq!(alpha_identifiers_from(3, 1), [0, 0, 0, 3]); // Photoshop 27 writes a 13-byte record (no pad): red spot, solidity 60. let ps = [0, 0, 0, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0x3c, 2]; let mut spot = vec![AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))]; @@ -214,10 +203,13 @@ mod tests { apply_display_info(&with_t, true, 1, &mut back); assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); + assert_eq!(spot_flags(&with_t, true, 1), [false, true]); // Truncated data is ignored rather than misread. let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))]; apply_display_info(&bytes[..10], true, 0, &mut c); assert_eq!(c[0].opacity, 0.5); + assert!(spot_flags(&bytes[..10], true, 0).is_empty()); + assert!(spot_flags(&bytes, true, 5).is_empty()); // 14 unpadded records are a multiple of 14 as well (14×13 = 13×14). Resource 1077 // still reads them as 13-byte records; the last spot's solidity must survive. @@ -235,6 +227,29 @@ mod tests { assert_eq!(got[13].spot.map(|(_, s)| (s * 100.0).round() as u16), many[13].spot.map(|(_, s)| (s * 100.0).round() as u16)); } + /// Photoshop's spot ink (0 = solid) and the document's density (1 = solid) at every depth, + /// both ways: the 25 % / 75 % columns of the Photoshop oracles store 191 / 64. + #[test] + fn photoshop_spot_plane_converts_both_ways() { + let mut p8 = vec![255u8, 64, 191, 0]; + photoshop_spot_plane(&mut p8, SampleType::U8); + assert_eq!(p8, [0, 191, 64, 255]); + photoshop_spot_plane(&mut p8, SampleType::U8); + assert_eq!(p8, [255, 64, 191, 0]); + let mut p16: Vec = [65535u16, 16448, 0].iter().flat_map(|v| v.to_ne_bytes()).collect(); + photoshop_spot_plane(&mut p16, SampleType::U16); + let back: Vec = p16.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).collect(); + assert_eq!(back, [0, 49087, 65535]); + let mut p32: Vec = [1.0f32, 0.25].iter().flat_map(|v| v.to_ne_bytes()).collect(); + photoshop_spot_plane(&mut p32, SampleType::F32); + let back: Vec = p32.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect(); + assert_eq!(back, [0.0, 0.75]); + // A trailing partial sample is left alone rather than misread. + let mut odd = vec![0u8, 0, 7]; + photoshop_spot_plane(&mut odd, SampleType::U16); + assert_eq!(odd, [0xff, 0xff, 7]); + } + #[test] fn decodes_other_spaces() { let g = decode_color(SPACE_GRAY, [10000, 0, 0, 0]); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 946abfbc75..70d66f8c5c 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -321,7 +321,8 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; + let (mut document, warnings) = + psd_import::psd_to_document_decoded(&file, ctl, merged, psd_import::PsdImportOptions::default()).ok_or(IoError::Cancelled)?; document.name = name.to_string(); text_import::prepare(&mut document); return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 7d58109714..23ecf88053 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -21,30 +21,11 @@ pub fn sample_for_depth(depth: u16) -> SampleType { } } -/// Complements every sample in `bytes`. Photoshop stores a spot channel as ink density -/// inverted: 0 is solid ink and the maximum sample is bare paper (the same convention as a -/// CMYK plate and as Multichannel). An alpha channel is not stored this way: 0 is empty and -/// the maximum sample is selected. `big_endian` is the PSD merged-image order; TIFF extra -/// samples are native-endian. -pub fn invert_plane(bytes: &mut [u8], s: SampleType, big_endian: bool) { - let bps = s.bytes(); - if bps == 0 { - return; - } - for c in bytes.chunks_exact_mut(bps) { - match s { - SampleType::U8 => c[0] = 255 - c[0], - SampleType::U16 => { - let v = if big_endian { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_ne_bytes([c[0], c[1]]) }; - let w = 65535 - v; - c.copy_from_slice(&(if big_endian { w.to_be_bytes() } else { w.to_ne_bytes() })); - } - SampleType::F32 => { - let v = if big_endian { f32::from_be_bytes([c[0], c[1], c[2], c[3]]) } else { f32::from_ne_bytes([c[0], c[1], c[2], c[3]]) }; - let w = 1.0 - v; - c.copy_from_slice(&(if big_endian { w.to_be_bytes() } else { w.to_ne_bytes() })); - } - } +/// Complements every native-endian sample of one plane (`max − v`, `1 − v` for float). A +/// trailing partial sample is left unchanged. +pub(crate) fn invert_plane(bytes: &mut [u8], s: SampleType) { + for c in bytes.chunks_exact_mut(s.bytes().max(1)) { + invert_sample(c, s); } } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 81d3ebe5a4..483b305f00 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -50,6 +50,11 @@ pub struct PsdExportOptions { /// The path of the document's own file: linked smart objects are resolved against it /// ([`crate::doc_path::resolve`]) before they are read and embedded. pub document_path: Option, + /// Store spot channels as Photoshop's TIFFs do, 0 = solid ink (see + /// [`crate::channel_map::photoshop_spot_plane`]). Only the layered TIFF sets it: a PSD keeps + /// writing the document's density as it is (1 = solid ink), so files saved by earlier + /// versions keep reading the same. + pub photoshop_spot_polarity: bool, } impl Default for PsdExportOptions { @@ -61,6 +66,7 @@ impl Default for PsdExportOptions { merged_compression: Compression::Rle, maximize_compatibility: true, document_path: None, + photoshop_spot_polarity: false, } } } @@ -484,8 +490,6 @@ impl Ex { } flags.set_hidden(!l.visible); flags.set_transparency_protected(l.locks.transparency); - // Bit 3 (with bit 4 clear) is the flag Photoshop sets on every layer it writes. - flags.0 |= LayerFlags::BIT4_USEFUL; let mut blocks = vec![TaggedBlock::unicode_name(&l.name), TaggedBlock::layer_id(id)]; let lspf = blocks::lspf_from_locks(&l.locks); if lspf != 0 { @@ -1077,8 +1081,6 @@ impl Ex { match &l.content { LayerContent::Group(g) => { let id = self.fresh_id(); - let mut divider_flags = LayerFlags(0); - divider_flags.0 |= LayerFlags::BIT4_USEFUL; self.records.push(LayerRecord { channels: self.empty_channels(), blending_ranges: BlendingRanges::full(self.cc), @@ -1088,7 +1090,6 @@ impl Ex { TaggedBlock::layer_id(id), TaggedBlock::section_divider(SectionType::BoundingDivider, None, None), ], - flags: divider_flags, ..Default::default() }); self.emit(&g.children, tick); @@ -1710,10 +1711,11 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c }; for a in &extra { let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; - let bytes = s.to_interleaved(canvas); - // Spot ink is stored inverted (0 = solid, max = paper), the same way Multichannel - // and a Photoshop TIFF do. An alpha channel stays straight (max = selected). - planes.extend(deinterleave(&bytes, 1, sample, &[a.spot.is_some()]).remove(0)); + let mut bytes = s.to_interleaved(canvas); + if opts.photoshop_spot_polarity && a.spot.is_some() { + crate::channel_map::photoshop_spot_plane(&mut bytes, sample); + } + planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); } let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; let mode = if duotone_inks.is_some() { PsdMode::Duotone } else { psd_mode(fmt.mode) }; @@ -1744,7 +1746,6 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(ImageResource::new(1006, pascal_names_resource(&names))); resources.push(ImageResource::new(1045, unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha))); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(extra.len()))); } if let Some(id) = quick_mask_id { let mut data = id.to_be_bytes().to_vec(); @@ -1827,10 +1828,6 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !keep_imported_resource(doc, *id, data) { continue; } - // A fresh 1053 was written with the extra channels. A preserved copy would be a second one. - if *id == crate::channel_map::ALPHA_IDENTIFIERS && !extra.is_empty() { - continue; - } let mut r = ImageResource::new(*id, data.to_vec()); r.name = legacy_name(name); resources.push(r); @@ -1881,7 +1878,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources, layer_info, layer_info_placement: placement, - global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::photoshop_default), + global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::default), global_blocks, layer_mask_trailing: Vec::new(), image_data, @@ -1958,6 +1955,69 @@ mod tests { assert!(back.layers.len() == 1 && bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); } + /// A layered document with a spot and an alpha channel. Each channel holds 100 %, 25 %, 75 % + /// and 0 % in four columns, the layout of the Photoshop spot oracles. + fn spot_and_alpha_document() -> Document { + let mut doc = document(4, 1); + doc.layers.push(Layer::group("Group", vec![Layer::raster("Layer 1", doc.pixel_format())])); + let channel = |name: &str| { + let mut s = Surface::new(PixelFormat::GRAY8); + s.write_interleaved(doc.bounds(), &[255, 64, 191, 0]); + photocraft_doc::AlphaChannel::new(name, s) + }; + let mut spot = channel("Spot Color 1"); + spot.spot = Some((photocraft_doc::Color::rgb(0.0, 0.6, 1.0), 0.0)); + let alpha = channel("Alpha 1"); + doc.channels.extend([spot, alpha]); + doc + } + + /// Photoshop's spot polarity is for the layered TIFF only: a PSD stores a spot plane and an + /// alpha plane straight (255 = solid ink, 255 = selected) as before, so files saved by earlier + /// versions and this one read the same. It writes no resource 1053, an empty global layer + /// mask, and flag bit 3 on the layers but not on the group's closing divider. Opening it gives + /// the same ink back. + #[test] + fn psd_stores_spot_and_alpha_planes_straight() { + let doc = spot_and_alpha_document(); + let written = |photoshop_spot_polarity| { + let opts = PsdExportOptions { photoshop_spot_polarity, ..Default::default() }; + PsdFile::from_bytes(&document_to_psd_with(&doc, &opts).0.to_bytes().unwrap()).unwrap() + }; + let extra_planes = |file: &PsdFile| { + let planes = file.decode_merged().unwrap(); + planes[planes.len() - 2 * 4..].to_vec() + }; + let file = written(false); + assert_eq!(extra_planes(&file), [255, 64, 191, 0, 255, 64, 191, 0], "spot, then alpha"); + assert!(file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_none()); + assert_eq!(file.global_layer_mask.as_ref().map(|m| m.data.len()), Some(0)); + let flags: Vec = file.layers().iter().map(|l| l.flags.0 & LayerFlags::BIT4_USEFUL).collect(); + assert_eq!(flags, [0, LayerFlags::BIT4_USEFUL, LayerFlags::BIT4_USEFUL], "closing divider, layer, group"); + let back = crate::import("spot.psd", &file.to_bytes().unwrap()).unwrap().document; + assert!(back.channels[0].spot.is_some() && back.channels[1].spot.is_none()); + for c in &back.channels { + assert_eq!(c.surface.to_interleaved(back.bounds()), [255, 64, 191, 0], "{}", c.name); + } + // The layered TIFF's switch inverts the spot plane only. + assert_eq!(extra_planes(&written(true)), [0, 191, 64, 255, 255, 64, 191, 0]); + } + + /// Resource 1053 (alpha identifiers, in every Photoshop file with an extra channel) is kept + /// from an opened PSD and written back unchanged, as before. + #[test] + fn psd_alpha_identifiers_survive_open_and_save() { + let mut file = document_to_psd(&spot_and_alpha_document()); + let ids = [0, 0, 0, 3, 0, 0, 0, 2]; + file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, ids.to_vec())); + let opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document; + let saved = crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap(); + let again = PsdFile::from_bytes(&saved.bytes).unwrap(); + let kept: Vec<&ImageResource> = again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).collect(); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].data, ids); + } + #[test] fn group_divider_id_wraps_without_colliding_with_imported_layer_ids() { let mut doc = document(8, 8); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 82f998143b..cf7c522e41 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -751,7 +751,16 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { - psd_to_document_decoded(file, ctl, None) + psd_to_document_decoded(file, ctl, None, PsdImportOptions::default()) +} + +/// Options for [`psd_to_document_decoded`]. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct PsdImportOptions { + /// Read spot channels as Photoshop's TIFFs store them, 0 = solid ink (see + /// [`crate::channel_map::photoshop_spot_plane`]). Only the layered TIFF sets it: a PSD's spot + /// planes are read as the document's density, as earlier versions saved them. + pub photoshop_spot_polarity: bool, } /// Whether the import reads the merged composite: for a flattened or multichannel file (it *is* @@ -776,6 +785,7 @@ pub(crate) fn psd_to_document_decoded( file: &PsdFile, ctl: &photocraft_raster::Interrupt, decoded: Option>>, + opts: PsdImportOptions, ) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); @@ -832,10 +842,6 @@ pub(crate) fn psd_to_document_decoded( doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 }); doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))); } - // Resource 1053 is rewritten from the extra channels on a layered save, so keeping - // the copy just written would show up as new metadata. Multichannel has no such - // rewrite: the imported bytes are the channel ids, copied back when they still match. - crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {} id if MAPPED_RESOURCES.contains(&id) => {} id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } @@ -1052,28 +1058,25 @@ pub(crate) fn psd_to_document_decoded( { let first = cc + usize::from(file.merged_has_alpha()); let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); - let extra_n = usize::from(h.channels).saturating_sub(first); // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); - let names = names.get(skip..).unwrap_or(&[]); - // Spot planes are ink-inverted in the file (0 = solid). Alpha planes are not. - // DisplayInfo lists the transparency record first when `skip` is 1. - let spots = { - let n = extra_n.saturating_add(skip); - let all = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { - crate::channel_map::spot_flags(&r.data, true, n) - } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { - crate::channel_map::spot_flags(&r.data, false, n) - } else { - vec![false; n] - }; - all.into_iter().skip(skip).collect::>() + let names = names.get(skip..).unwrap_or_default(); + // Which planes hold spot ink in Photoshop's polarity: the DisplayInfo records the + // channel options are read from below, the transparency's first when `skip` is 1. + let photoshop_spots = if !opts.photoshop_spot_polarity { + Vec::new() + } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { + crate::channel_map::spot_flags(&r.data, true, skip) + } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::spot_flags(&r.data, false, skip) + } else { + Vec::new() }; for (k, idx) in (first..usize::from(h.channels)).enumerate() { let Some(p) = merged_plane(all, idx, plane) else { break }; let mut raw = be_to_ne_plane(p, depth, n); - if spots.get(k).copied().unwrap_or(false) { - crate::pixels::invert_plane(&mut raw, depth, false); + if photoshop_spots.get(k).copied().unwrap_or(false) { + crate::channel_map::photoshop_spot_plane(&mut raw, depth); } let mut s = Surface::new(cx.mask_fmt); s.write_interleaved(canvas, &raw); diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 826c1557f7..932d88c82b 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -21,7 +21,7 @@ use photocraft_raster::Surface; use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer}; use crate::psd_export::PsdExportOptions; -use crate::psd_import::psd_to_document; +use crate::psd_import::{PsdImportOptions, psd_to_document_decoded}; use crate::{ExportOptions, ExportResult, ImportResult, IoError}; /// `true` when saving `doc` as TIFF writes layer data: anything beyond a single Background @@ -130,8 +130,13 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result 0 { - resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); - // Photoshop's spot TIFF (RGB, Save Transparency off) stores identifier 3: the spot's - // index among the IFD samples, not a 1-based count of user channels. - let first = u32::try_from(cc.saturating_add(usize::from(write_alpha))).unwrap_or(u32::MAX); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers_from(first, kept))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(kept))); } // Resource 1022 names a channel of the PSD merged image, which counts the transparency plane. // The IFD below does not, so the id moves back by one. @@ -371,6 +375,10 @@ fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSou } if let Some(info) = &mut isd.layer_info { for layer in &mut info.layers { + // Photoshop sets flag bit 3 on every record it writes, group dividers included (299 of + // the 305 dividers in the corpus's Photoshop files). PhotoCraft's PSDs set it on + // layers only, unchanged here. + layer.flags.0 |= photocraft_psd::layer::LayerFlags::BIT4_USEFUL; photoshop_layer_blocks(&mut layer.blocks); } } @@ -577,9 +585,8 @@ fn channel_plane(channel: &AlphaChannel, canvas: Rect, sample: CSample) -> Resul if bytes.len() != expect { return Err(format!("channel \"{}\" does not cover the canvas", channel.name)); } - // Document density is 1 = solid ink. Photoshop's TIFF stores the opposite for a spot. if channel.spot.is_some() { - crate::pixels::invert_plane(&mut bytes, depth, false); + crate::channel_map::photoshop_spot_plane(&mut bytes, depth); } if sample == CSample::F16 { let mut out = Vec::with_capacity(n.saturating_mul(2)); @@ -784,8 +791,7 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec img.meta.photoshop_resources = Some(bytes), Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")), } @@ -794,7 +800,7 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec, names: &[&str], channels: &[&AlphaChannel], quick_id: Option, first_id: u32) -> Result, IoError> { +fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&AlphaChannel], quick_id: Option) -> Result, IoError> { let mut resources = match existing.filter(|b| !b.is_empty()) { Some(b) => resources_from_bytes(b)?.0, None => Vec::new(), @@ -805,7 +811,7 @@ fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&Alpha resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names))); resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false))); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers_from(first_id, names.len()))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(names.len()))); if let Some(id) = quick_id { let mut data = id.to_be_bytes().to_vec(); data.push(0); @@ -834,19 +840,18 @@ pub(crate) fn channels_from_extras(doc: &mut Document, img: &Image) -> Vec { let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Alpha {}", k + 1)); doc.channels.push(AlphaChannel::new(name, surface)); @@ -877,8 +882,8 @@ pub(crate) fn channels_from_extras(doc: &mut Document, img: &Image) -> Vec Result { +/// `photoshop_spot`: the sample is a spot channel in Photoshop's polarity (0 = solid ink). +fn mask_from_plane(data: &[u8], w: u32, h: u32, n: usize, from: CSample, depth: SampleType, photoshop_spot: bool) -> Result { let rect = Rect::new(0, 0, w as i32, h as i32); let mut bytes = match (from, depth) { (CSample::U8, SampleType::U8) | (CSample::U16, SampleType::U16) | (CSample::F32, SampleType::F32) => { @@ -900,8 +905,8 @@ fn mask_from_plane(data: &[u8], w: u32, h: u32, n: usize, from: CSample, depth: } _ => return Err(format!("an extra TIFF sample ({from:?}) does not match the document depth and was dropped")), }; - if invert { - crate::pixels::invert_plane(&mut bytes, depth, false); + if photoshop_spot { + crate::channel_map::photoshop_spot_plane(&mut bytes, depth); } if bytes.len() != n.saturating_mul(depth.bytes()) { return Err("an extra TIFF sample did not match the image and was dropped".into()); diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index f19ccff7dc..49f0076728 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -532,7 +532,7 @@ fn dtf_like_spot_is_written_in_full() { assert_eq!(info.len(), 17, "{name}"); assert_eq!(u16::from_be_bytes([info[14], info[15]]), 60, "{name} solidity"); assert_eq!(info[16], 2, "{name} spot kind"); - // RGB occupies samples 0..2, so the spot's identifier is 3. + // Photoshop numbers a new document's channels from 3. assert_eq!(block(1053).data, 3u32.to_be_bytes(), "{name} channel id"); let back = import("dtf.tif", &r.bytes).unwrap().document; @@ -665,7 +665,7 @@ fn ink(d: &Document) -> (f32, f32) { fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec { use photocraft_psd::{Compression, TaggedBlock}; - let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() }; + let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, photoshop_spot_polarity: true, ..Default::default() }; let (file, _) = document_to_psd_with(doc, &opts); let planes = file.decode_merged().unwrap(); let n = 16; @@ -850,7 +850,8 @@ fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) { /// Photoshop 27's Kato-Karate TIFF stores spot ink inverted: every non-white artwork pixel /// has spot 0, and the white background is spot 255. An alpha channel is the other way -/// around (255 = selected). Both polarities round-trip, in TIFF and in PSD. +/// around (255 = selected). A PSD keeps storing both planes straight, as earlier versions did, +/// and a TIFF opened and saved as PSD does the same. Everything round-trips. #[test] fn spot_ink_is_stored_as_zero_and_alpha_is_not() { use photocraft_color::{Color, PixelFormat}; @@ -890,10 +891,9 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { let first = 3 + usize::from(file.merged_has_alpha()); let spot_plane = &merged[first * plane..(first + 1) * plane]; let alpha_plane = &merged[(first + 1) * plane..(first + 2) * plane]; - // Background (0,0) and artwork (3,3). Spot: 0 = solid ink, max = paper. - // Alpha: max = selected, 0 = empty. - assert_stored(spot_plane, 0, depth, false, "psd spot background"); - assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, true, "psd spot art"); + // Background (0,0) and artwork (3,3), both straight: max = solid ink, max = selected. + assert_stored(spot_plane, 0, depth, true, "psd spot background"); + assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, false, "psd spot art"); assert_stored(alpha_plane, 0, depth, true, "psd alpha background"); assert_stored(alpha_plane, (3 * w as usize + 3) * bps, depth, false, "psd alpha art"); let back = import("pol.psd", &psd.bytes).unwrap().document; @@ -916,6 +916,11 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { let back = import("pol.tif", &r.bytes).unwrap().document; assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); + // Saved as PSD: straight planes, and no 1053 (the TIFF's isn't kept on open). + let psd = PsdFile::from_bytes(&export(&back, "pol.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + let again = psd.decode_merged().unwrap(); + assert_eq!(again[again.len() - 2 * plane..], merged[(first * plane)..(first + 2) * plane], "{layers} tiff to psd"); + assert!(psd.resource(1053).is_none(), "{layers} tiff to psd"); } } } From 3db1ae122335d2313400303a63459ff5f1122512 Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 14:56:01 -0400 Subject: [PATCH 05/19] docs: regenerate the scorecard for TIFF channels FILE-215-7 (layered TIFF) done, FILE-215-17 (spot plates) partial. Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 42ae824b4d..f92ff987b2 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 25 | 59 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 20 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 8 | 14 | 5 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 170 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -107,7 +107,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | -| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) | +| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha/spot channels and a Quick Mask are written as TIFF extra samples (at most 16) plus Photoshop resources for name, colour and solidity; Photoshop's MTrn marker is read (#7415). The TIFF Options Save Transparency choice isn't offered: channels are written as Photoshop does with it off | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain | @@ -117,7 +117,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | -| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | +| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | From 04211f3bf0a80441725ad7ea0f058723530f7ebc Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 18:12:41 -0400 Subject: [PATCH 06/19] io: leave the opaque-composite marker drop to the PSD export PR 2's psd_export already drops a kept merged-transparency marker when the composite is opaque. The layered TIFF still drops it when extra channels are stored, because there the composite keeps its transparency in the PSD model but the TIFF leaves that sample out of the IFD. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/tiff_layers.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 932d88c82b..4d9489d54a 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -369,8 +369,9 @@ fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSou info.merged_alpha = false; } // A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make the - // first spot read back as transparency: keep it only while the composite keeps that sample. - if spot_extras || !file.layer_info.as_ref().is_some_and(|l| l.merged_alpha) { + // first spot read back as transparency. The PSD export already drops it over an opaque + // composite; a transparent one keeps it there, but the TIFF leaves that sample out here. + if spot_extras { isd.global_blocks.retain(|b| !b.is_merged_transparency_marker()); } if let Some(info) = &mut isd.layer_info { From b92ac6fab075025799558b7880f4b82f9a10b23b Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 13:31:01 -0400 Subject: [PATCH 07/19] PSD: spot channels in Photoshop's polarity; earlier PhotoCraft PSDs keep their ink Photoshop 27.11 stores spot ink 0 = solid in every PSD it saves (its 7 spot PSDs store the 100/25/75/0 % columns as 0/191/64/255) and numbers the extra channels 3, 4, 5 in resource 1053, after 0 for the merged transparency. PhotoCraft's PSDs stored the ink straight and wrote no 1053. - PSD save and open use Photoshop's polarity through channel_map::photoshop_spot_plane; alpha channels stay straight and the in-memory convention (1 = solid ink) is unchanged. - A fresh 1053 is written with the extra channels; a kept one is never written next to it. - psd_import::earlier_photocraft_spot_planes: a PSD whose 1077 marks a spot, with no 1053 and the exact 1057 PhotoCraft 0.1.0-0.7.0 (CS6, NUL) or 0.8.2-0.11.1 (2026) wrote, is read straight, so it keeps its ink. - Part 3's switch (PsdExportOptions::photoshop_spot_polarity, PsdImportOptions) is gone: PSD and the layered TIFF share one path. - PSD keeps main's empty LMsk and layer flags; Multichannel is unchanged. - Docs: file-format parity, gaps G10, target-app parity, FILE-215-17 note. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> --- crates/io/src/channel_map.rs | 59 ++++++++-- crates/io/src/lib.rs | 3 +- crates/io/src/psd_export.rs | 96 +++++++++------ crates/io/src/psd_import.rs | 209 +++++++++++++++++++++++++++++++-- crates/io/src/tiff_layers.rs | 22 ++-- crates/io/tests/tiff_layers.rs | 51 ++++++-- docs/file-format-parity.md | 5 +- docs/gaps.md | 8 +- docs/target-app-parity.md | 5 +- scorecard/file_compat.toml | 2 +- 10 files changed, 369 insertions(+), 91 deletions(-) diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index 990027441a..c32553324f 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -25,10 +25,10 @@ const SPACE_GRAY: u16 = 8; /// Converts one spot channel's plane (native-endian samples) between the document's ink density /// (1 = solid ink) and the way Photoshop stores a spot channel (0 = solid ink, the maximum = -/// bare paper; every TIFF Photoshop 27 saves stores it so). The conversion is its own inverse, -/// so it serves both open and save. Every reader and writer of Photoshop-polarity spot planes -/// goes through here: a change to the in-memory convention changes only this function. Alpha -/// channels are stored straight (the maximum = selected) and never come here. +/// bare paper; every PSD and TIFF Photoshop 27.11 saves stores it so). The conversion is its own +/// inverse, so it serves both open and save. Every reader and writer of Photoshop-polarity spot +/// planes goes through here: a change to the in-memory convention changes only this function. +/// Alpha channels are stored straight (the maximum = selected) and never come here. pub(crate) fn photoshop_spot_plane(plane: &mut [u8], sample: SampleType) { crate::pixels::invert_plane(plane, sample); } @@ -36,9 +36,13 @@ pub(crate) fn photoshop_spot_plane(plane: &mut [u8], sample: SampleType) { /// Resource 1053 for `count` channels: one big-endian u32 identifier each, with no length prefix /// (Photoshop, ag-psd and psd-tools read u32s until the resource ends). Photoshop 27.11 numbers a /// new document's channels 3, 4, 5, … in every colour mode (RGB, CMYK, Gray and Lab, in its PSDs -/// and TIFFs alike), so these do too. -pub(crate) fn alpha_identifiers(count: usize) -> Vec { - let mut out = Vec::with_capacity(count.saturating_mul(4)); +/// and TIFFs alike), so these do too. `transparency`: the merged image's transparency channel +/// comes first, with identifier 0 (Photoshop's transparent PSD lists `0, 3, 4, 5`). +pub(crate) fn alpha_identifiers(transparency: bool, count: usize) -> Vec { + let mut out = Vec::with_capacity(count.saturating_add(1).saturating_mul(4)); + if transparency { + out.extend_from_slice(&0u32.to_be_bytes()); + } for i in 0..count { let id = 3u32.saturating_add(u32::try_from(i).unwrap_or(u32::MAX)); out.extend_from_slice(&id.to_be_bytes()); @@ -86,6 +90,14 @@ pub(crate) fn spot_flags(data: &[u8], versioned: bool, skip: usize) -> Vec display_records(data, versioned).skip(skip).map(|rec| rec.get(12) == Some(&2)).collect() } +/// Whether a DisplayInfo resource 1077 marks any channel as a spot (kind 2), in 13-byte records +/// (Photoshop's, and PhotoCraft's since 0.8.2) or in the 14-byte padded records PhotoCraft wrote +/// up to 0.7.0. +pub(crate) fn display_info_marks_spot(data: &[u8]) -> bool { + let body = data.get(4..).unwrap_or_default(); + [13, 14].into_iter().any(|size| body.chunks_exact(size).any(|rec| rec.get(12) == Some(&2))) +} + /// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the /// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip` /// records (the merged transparency channel's). @@ -189,8 +201,9 @@ mod tests { assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); assert_eq!(spot_flags(&bytes, true, 0), [false, true]); - assert_eq!(alpha_identifiers(2), [0, 0, 0, 3, 0, 0, 0, 4]); - assert!(alpha_identifiers(0).is_empty()); + assert!(display_info_marks_spot(&bytes)); + assert!(!display_info_marks_spot(&display_info(&[&a], true)), "alpha and transparency records only"); + assert!(!display_info_marks_spot(&bytes[..10])); // Photoshop 27 writes a 13-byte record (no pad): red spot, solidity 60. let ps = [0, 0, 0, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0x3c, 2]; let mut spot = vec![AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))]; @@ -227,6 +240,34 @@ mod tests { assert_eq!(got[13].spot.map(|(_, s)| (s * 100.0).round() as u16), many[13].spot.map(|(_, s)| (s * 100.0).round() as u16)); } + /// Resource 1053 as Photoshop 27.11 numbers a new document's channels: 3, 4, 5 in every colour + /// mode, with the merged transparency first as 0 (its transparent PSD lists `0, 3, 4, 5`). + #[test] + fn alpha_identifiers_are_numbered_as_photoshop_does() { + let ids = |transparency, count| alpha_identifiers(transparency, count).as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect::>(); + assert_eq!(ids(false, 3), [3, 4, 5]); + assert_eq!(ids(true, 3), [0, 3, 4, 5]); + assert_eq!(ids(false, 1), [3]); + assert!(ids(false, 0).is_empty()); + assert_eq!(ids(true, 0), [0]); + assert_eq!(alpha_identifiers(false, 2), [0, 0, 0, 3, 0, 0, 0, 4]); + } + + /// PhotoCraft 0.1.0 to 0.7.0 padded each 1077 record to 14 bytes, so a spot after the first + /// record sits where a 13-byte reader finds no kind 2; it is still recognised. + #[test] + fn a_spot_is_found_in_padded_records() { + let alpha = |kind: u8| [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, kind, 0]; + let mut old = 1u32.to_be_bytes().to_vec(); + old.extend_from_slice(&alpha(1)); + old.extend_from_slice(&alpha(0)); + old.extend_from_slice(&alpha(2)); + assert_eq!(spot_flags(&old, true, 0), [false, false, false], "13-byte records misread the padded ones"); + assert!(display_info_marks_spot(&old)); + old.truncate(old.len() - 14); + assert!(!display_info_marks_spot(&old), "two alpha records"); + } + /// Photoshop's spot ink (0 = solid) and the document's density (1 = solid) at every depth, /// both ways: the 25 % / 75 % columns of the Photoshop oracles store 191 / 64. #[test] diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 70d66f8c5c..946abfbc75 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -321,8 +321,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings) = - psd_import::psd_to_document_decoded(&file, ctl, merged, psd_import::PsdImportOptions::default()).ok_or(IoError::Cancelled)?; + let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; document.name = name.to_string(); text_import::prepare(&mut document); return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 483b305f00..daa25ca2bc 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -50,11 +50,6 @@ pub struct PsdExportOptions { /// The path of the document's own file: linked smart objects are resolved against it /// ([`crate::doc_path::resolve`]) before they are read and embedded. pub document_path: Option, - /// Store spot channels as Photoshop's TIFFs do, 0 = solid ink (see - /// [`crate::channel_map::photoshop_spot_plane`]). Only the layered TIFF sets it: a PSD keeps - /// writing the document's density as it is (1 = solid ink), so files saved by earlier - /// versions keep reading the same. - pub photoshop_spot_polarity: bool, } impl Default for PsdExportOptions { @@ -66,7 +61,6 @@ impl Default for PsdExportOptions { merged_compression: Compression::Rle, maximize_compatibility: true, document_path: None, - photoshop_spot_polarity: false, } } } @@ -1712,7 +1706,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c for a in &extra { let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; let mut bytes = s.to_interleaved(canvas); - if opts.photoshop_spot_polarity && a.spot.is_some() { + // Spot ink as Photoshop stores it (0 = solid); an alpha channel stays straight. + if a.spot.is_some() { crate::channel_map::photoshop_spot_plane(&mut bytes, sample); } planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); @@ -1746,6 +1741,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(ImageResource::new(1006, pascal_names_resource(&names))); resources.push(ImageResource::new(1045, unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha))); + // Photoshop writes 1053 in every file with an extra channel. Its absence is how a file from + // an earlier PhotoCraft is told apart (`psd_import::earlier_photocraft_spot_planes`). + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(has_alpha, extra.len()))); } if let Some(id) = quick_mask_id { let mut data = id.to_be_bytes().to_vec(); @@ -1828,6 +1826,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !keep_imported_resource(doc, *id, data) { continue; } + // A fresh 1053 was written with the extra channels; a preserved copy would be a second one. + if *id == crate::channel_map::ALPHA_IDENTIFIERS && !extra.is_empty() { + continue; + } let mut r = ImageResource::new(*id, data.to_vec()); r.name = legacy_name(name); resources.push(r); @@ -1972,25 +1974,28 @@ mod tests { doc } - /// Photoshop's spot polarity is for the layered TIFF only: a PSD stores a spot plane and an - /// alpha plane straight (255 = solid ink, 255 = selected) as before, so files saved by earlier - /// versions and this one read the same. It writes no resource 1053, an empty global layer - /// mask, and flag bit 3 on the layers but not on the group's closing divider. Opening it gives - /// the same ink back. + /// The extra planes after the colour channels (and the merged transparency) of `file`. + fn extra_planes(file: &PsdFile, count: usize) -> Vec { + let planes = file.decode_merged().unwrap(); + let plane = (file.header.width * file.header.height) as usize * usize::from(file.header.depth / 8); + planes[planes.len() - count * plane..].to_vec() + } + + /// Resource 1053's identifiers. + fn alpha_ids(file: &PsdFile) -> Option> { + file.resource(crate::channel_map::ALPHA_IDENTIFIERS).map(|r| r.data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()) + } + + /// A PSD stores spot ink as Photoshop's own PSDs do (0 = solid ink: the 100, 25, 75 and 0 % + /// columns are 0, 191, 64, 255) and an alpha channel straight (255 = selected). The global + /// layer mask stays empty and flag bit 3 stays on the layers only, not on the group's closing + /// divider: Photoshop opens those as they are, and 509 of the corpus's 721 Photoshop PSDs have + /// an empty mask too. Opening the file gives the same ink back. #[test] - fn psd_stores_spot_and_alpha_planes_straight() { + fn psd_stores_spot_ink_as_photoshop_does() { let doc = spot_and_alpha_document(); - let written = |photoshop_spot_polarity| { - let opts = PsdExportOptions { photoshop_spot_polarity, ..Default::default() }; - PsdFile::from_bytes(&document_to_psd_with(&doc, &opts).0.to_bytes().unwrap()).unwrap() - }; - let extra_planes = |file: &PsdFile| { - let planes = file.decode_merged().unwrap(); - planes[planes.len() - 2 * 4..].to_vec() - }; - let file = written(false); - assert_eq!(extra_planes(&file), [255, 64, 191, 0, 255, 64, 191, 0], "spot, then alpha"); - assert!(file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_none()); + let file = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + assert_eq!(extra_planes(&file, 2), [0, 191, 64, 255, 255, 64, 191, 0], "spot, then alpha"); assert_eq!(file.global_layer_mask.as_ref().map(|m| m.data.len()), Some(0)); let flags: Vec = file.layers().iter().map(|l| l.flags.0 & LayerFlags::BIT4_USEFUL).collect(); assert_eq!(flags, [0, LayerFlags::BIT4_USEFUL, LayerFlags::BIT4_USEFUL], "closing divider, layer, group"); @@ -1999,23 +2004,46 @@ mod tests { for c in &back.channels { assert_eq!(c.surface.to_interleaved(back.bounds()), [255, 64, 191, 0], "{}", c.name); } - // The layered TIFF's switch inverts the spot plane only. - assert_eq!(extra_planes(&written(true)), [0, 191, 64, 255, 255, 64, 191, 0]); } - /// Resource 1053 (alpha identifiers, in every Photoshop file with an extra channel) is kept - /// from an opened PSD and written back unchanged, as before. + /// Resource 1053 numbers the extra channels as Photoshop 27.11 does in a new document: 3, 4, 5 + /// in every colour mode, after 0 for the merged transparency when there is one (its + /// transparent PSD lists `0, 3, 4, 5`). A Quick Mask is an extra channel too. Without extra + /// channels there is none. #[test] - fn psd_alpha_identifiers_survive_open_and_save() { + fn psd_alpha_identifiers_number_the_channels_as_photoshop_does() { + use photocraft_color::Color; + let size = photocraft_geom::Size::new(4, 1); + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] { + let mut doc = Document::with_background("bg", size, mode, SampleType::U8, Color::rgb(1.0, 1.0, 1.0)); + assert_eq!(alpha_ids(&document_to_psd(&doc)), None, "{mode:?} without channels"); + doc.channels = spot_and_alpha_document().channels; + let file = document_to_psd(&doc); + assert!(!file.merged_has_alpha(), "{mode:?}"); + assert_eq!(alpha_ids(&file), Some(vec![3, 4]), "{mode:?}"); + doc.quick_mask = Some(photocraft_doc::AlphaChannel::new("Quick Mask", Surface::new(PixelFormat::GRAY8))); + assert_eq!(alpha_ids(&document_to_psd(&doc)), Some(vec![3, 4, 5]), "{mode:?} with a Quick Mask"); + } + // The empty layer leaves the composite transparent. + let file = document_to_psd(&spot_and_alpha_document()); + assert!(file.merged_has_alpha()); + assert_eq!(alpha_ids(&file), Some(vec![0, 3, 4])); + } + + /// A Photoshop file's own 1053 is kept on open (as every unmapped resource is), but a save + /// writes one fresh 1053 for the channels the document has, never the kept one as well. The + /// identifiers here, 3 and 2, are those of psd-tools' `cmyk-alpha-spot.psd` (Photoshop 24.7). + #[test] + fn a_kept_alpha_identifiers_resource_is_not_written_twice() { let mut file = document_to_psd(&spot_and_alpha_document()); - let ids = [0, 0, 0, 3, 0, 0, 0, 2]; - file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, ids.to_vec())); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, [0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 2].to_vec())); let opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document; + assert!(opened.metadata.psd_resources.iter().any(|(id, ..)| *id == crate::channel_map::ALPHA_IDENTIFIERS)); let saved = crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap(); let again = PsdFile::from_bytes(&saved.bytes).unwrap(); - let kept: Vec<&ImageResource> = again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).collect(); - assert_eq!(kept.len(), 1); - assert_eq!(kept[0].data, ids); + assert_eq!(again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).count(), 1); + assert_eq!(alpha_ids(&again), Some(vec![0, 3, 4])); } #[test] diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index cf7c522e41..14118c2c86 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -751,16 +751,48 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { - psd_to_document_decoded(file, ctl, None, PsdImportOptions::default()) + psd_to_document_decoded(file, ctl, None) } -/// Options for [`psd_to_document_decoded`]. -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct PsdImportOptions { - /// Read spot channels as Photoshop's TIFFs store them, 0 = solid ink (see - /// [`crate::channel_map::photoshop_spot_plane`]). Only the layered TIFF sets it: a PSD's spot - /// planes are read as the document's density, as earlier versions saved them. - pub photoshop_spot_polarity: bool, +/// Whether `file` stores its spot planes the way earlier PhotoCraft versions (releases 0.1.0 to +/// 0.11.1) wrote them: straight (the maximum = solid ink), the opposite of Photoshop, which +/// stores 0 = solid ink in every PSD and TIFF it saves. Their files are recognised by three +/// things together: +/// +/// - DisplayInfo (1077) marks at least one spot channel; +/// - there is no Alpha Identifiers resource (1053). Photoshop has written it in every file with +/// an extra channel since version 6, and PhotoCraft writes it now, but no earlier PhotoCraft did; +/// - the version info (1057) is byte for byte what those versions wrote: writer "Adobe Photoshop" +/// with reader "Adobe Photoshop CS6", both NUL-terminated (0.1.0 to 0.7.0), or reader "Adobe +/// Photoshop 2026" without a NUL (0.8.2 on), with either merged-data flag. +/// +/// Any other file is read in Photoshop's polarity. psd-tools' `cmyk-spot.psd`, for one, also has +/// spots and no 1053, but names psd-tools in 1057 and stores Photoshop's polarity. A Photoshop +/// file that an earlier PhotoCraft opened and saved again kept Photoshop's 1053 and its planes as +/// Photoshop stored them, so it is read in Photoshop's polarity too. +/// +/// Keeping these files' ink is the only reason this exists: deleting it (and its one caller) +/// reads every file in Photoshop's polarity, at the cost of inverting the spot ink of PSDs saved +/// by those versions. +pub(crate) fn earlier_photocraft_spot_planes(file: &PsdFile) -> bool { + use photocraft_psd::descriptor::UnicodeString; + use photocraft_psd::resources::VersionInfo; + + let marks_spot = file.resource(crate::channel_map::DISPLAY_INFO).is_some_and(|r| crate::channel_map::display_info_marks_spot(&r.data)); + if !marks_spot || file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some() { + return false; + } + let Some(r) = file.resource(ids::VERSION_INFO) else { return false }; + // Byte for byte: a resource with anything after the fields is someone else's. + let Ok(v) = VersionInfo::from_bytes(&r.data) else { return false }; + if v.to_bytes() != r.data { + return false; + } + let writers = [ + (UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6")), + (UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop 2026")), + ]; + v.version == 1 && v.file_version == 1 && writers.iter().any(|(writer, reader)| v.writer == *writer && v.reader == *reader) } /// Whether the import reads the merged composite: for a flattened or multichannel file (it *is* @@ -785,7 +817,6 @@ pub(crate) fn psd_to_document_decoded( file: &PsdFile, ctl: &photocraft_raster::Interrupt, decoded: Option>>, - opts: PsdImportOptions, ) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); @@ -1061,9 +1092,10 @@ pub(crate) fn psd_to_document_decoded( // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); let names = names.get(skip..).unwrap_or_default(); - // Which planes hold spot ink in Photoshop's polarity: the DisplayInfo records the - // channel options are read from below, the transparency's first when `skip` is 1. - let photoshop_spots = if !opts.photoshop_spot_polarity { + // Which planes hold spot ink in Photoshop's polarity (0 = solid): the DisplayInfo records + // the channel options are read from below, the transparency's first when `skip` is 1. + // A file from an earlier PhotoCraft stores them straight and is read as it was written. + let photoshop_spots = if earlier_photocraft_spot_planes(file) { Vec::new() } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { crate::channel_map::spot_flags(&r.data, true, skip) @@ -1121,7 +1153,9 @@ pub(crate) fn psd_to_document_decoded( #[cfg(test)] mod tests { use super::*; - use photocraft_psd::{Compression, PsdError, Version, testgen}; + use photocraft_psd::descriptor::UnicodeString; + use photocraft_psd::resources::VersionInfo; + use photocraft_psd::{Compression, ImageData, ImageResource, PsdError, Version, testgen}; /// #5200: the Multichannel fallback checks its RGBA output against the budget before /// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes). @@ -1167,4 +1201,153 @@ mod tests { assert_eq!(merged_plane(&all, 2, 2), None); assert_eq!(merged_plane(&all, usize::MAX, 2), None); } + + /// Resource 1057's data. + fn version_info(writer: UnicodeString, reader: UnicodeString, has_real_merged_data: bool) -> Vec { + VersionInfo { version: 1, has_real_merged_data, writer, reader, file_version: 1 }.to_bytes() + } + + /// The 1057 PhotoCraft 0.1.0 to 0.7.0 wrote: Photoshop CS6's names, NUL-terminated. + fn version_info_cs6(has_real_merged_data: bool) -> Vec { + version_info(UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6"), has_real_merged_data) + } + + /// The 1057 PhotoCraft writes since 0.8.2: Photoshop 2026's bytes, no NUL. + fn version_info_2026(has_real_merged_data: bool) -> Vec { + photocraft_psd::resources::version_info_resource(has_real_merged_data).data + } + + /// An opaque 4 × 1 RGB document with the Photoshop spot oracles' channels in their order: + /// "Spot A", "Alpha 1" and "Spot B", each holding 100, 25, 75 and 0 % in four columns. + fn channels_document(depth: SampleType) -> Document { + let mut doc = Document::with_background("old", Size::new(4, 1), ColorMode::Rgb, depth, photocraft_color::Color::rgb(1.0, 1.0, 1.0)); + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let canvas = doc.bounds(); + let channel = |name: &str, spot: Option<(photocraft_color::Color, f32)>| { + let mut s = Surface::new(gray); + s.write_region(canvas, &[1.0, 0.25, 0.75, 0.0]); + AlphaChannel { spot, ..AlphaChannel::new(name, s) } + }; + doc.channels.push(channel("Spot A", Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), 0.0)))); + doc.channels.push(channel("Alpha 1", None)); + doc.channels.push(channel("Spot B", Some((photocraft_color::Color::rgb(1.0, 0.0, 0.627), 0.5)))); + doc + } + + /// Each channel's values on an 8-bit scale. + fn ink(doc: &Document) -> Vec> { + doc.channels.iter().map(|c| c.surface.read_region(doc.bounds()).iter().map(|v| (v * 255.0).round() as u8).collect()).collect() + } + + fn spots(doc: &Document) -> Vec { + doc.channels.iter().map(|c| c.spot.is_some()).collect() + } + + /// `doc` (opaque, 8 or 16-bit) saved the way PhotoCraft 0.1.0 to 0.11.1 saved it: spot planes + /// straight (the maximum = solid ink), no 1053, and `version_info` as resource 1057. `padded` + /// lays DisplayInfo out in the 14-byte records of 0.1.0 to 0.7.0. + fn as_earlier_photocraft_saved(doc: &Document, version_info: Vec, padded: bool) -> Vec { + let mut file = crate::psd_export::document_to_psd(doc); + assert!(!file.merged_has_alpha()); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + for r in &mut file.resources { + match r.id { + ids::VERSION_INFO => r.data = version_info.clone(), + crate::channel_map::DISPLAY_INFO if padded => { + let (version, records) = r.data.split_at(4); + r.data = version.iter().copied().chain(records.chunks(13).flat_map(|rec| rec.iter().copied().chain([0]))).collect(); + } + _ => {} + } + } + let mut planes = file.decode_merged().unwrap(); + let plane = planes.len() / usize::from(file.header.channels); + for (k, c) in doc.channels.iter().enumerate() { + if c.spot.is_some() { + // At 8 and 16 bits the complement of a sample is that of each of its bytes. + planes[(3 + k) * plane..(4 + k) * plane].iter_mut().for_each(|b| *b = !*b); + } + } + file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap(); + file.to_bytes().unwrap() + } + + /// PSDs that PhotoCraft 0.1.0 to 0.11.1 saved with spot channels store them straight. They + /// open with the ink they had in those versions (which read every plane straight), whichever + /// 1057 they carry. Saved again, they get Photoshop's polarity and a 1053, and reopen with the + /// same ink. (A 0.1.0 to 0.7.0 file's padded records misread every channel's options after + /// the first, here Spot B's kind, as those versions' successors always have; its ink is kept.) + #[test] + fn spot_planes_from_earlier_photocraft_keep_their_ink() { + for depth in [SampleType::U8, SampleType::U16] { + let doc = channels_document(depth); + let cases = [ + ("0.1.0 to 0.7.0", version_info_cs6(true), true), + ("0.8.2 on", version_info_2026(true), false), + ("0.8.2 on, Maximize Compatibility off", version_info_2026(false), false), + ("0.1.0 to 0.7.0, 13-byte records", version_info_cs6(true), false), + ]; + for (what, version_info, padded) in cases { + let bytes = as_earlier_photocraft_saved(&doc, version_info, padded); + assert!(earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}"); + let opened = crate::import("old.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{depth:?} {what}: the ink earlier versions opened"); + assert!(spots(&opened)[0], "{depth:?} {what}"); + if !padded { + assert_eq!(spots(&opened), spots(&doc), "{depth:?} {what}"); + } + let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap(); + let file = PsdFile::from_bytes(&saved.bytes).unwrap(); + assert!(file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some() && !earlier_photocraft_spot_planes(&file), "{what}"); + let again = crate::import("again.psd", &saved.bytes).unwrap().document; + assert_eq!(ink(&again), ink(&doc), "{depth:?} {what}: saved again"); + assert_eq!(spots(&again), spots(&opened), "{depth:?} {what}: saved again"); + } + } + } + + /// A file with spots and no 1053 that no earlier PhotoCraft wrote is read in Photoshop's + /// polarity (0 = solid ink). psd-tools' `cmyk-spot.psd` is one: its 1057 names psd-tools as + /// writer and reader. So is a file with no 1057, with Photoshop CS6's names without the NUL, + /// or with bytes after PhotoCraft's. + #[test] + fn other_files_without_alpha_identifiers_use_photoshop_polarity() { + let doc = channels_document(SampleType::U8); + let mut trailing = version_info_2026(true); + trailing.extend([0, 0]); + let cases = [ + ("psd-tools", Some(version_info(UnicodeString::new("psd-tools 1.10.0"), UnicodeString::new("psd-tools 1.10.0"), true))), + ("no 1057", None), + ("CS6 without a NUL", Some(version_info(UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop CS6"), true))), + ("bytes after PhotoCraft's 1057", Some(trailing)), + ]; + for (what, info) in cases { + // Photoshop's polarity, as this version writes it, without 1053. + let mut file = crate::psd_export::document_to_psd(&doc); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS && r.id != ids::VERSION_INFO); + file.resources.extend(info.map(|d| ImageResource::new(ids::VERSION_INFO, d))); + let bytes = file.to_bytes().unwrap(); + assert!(!earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}"); + let opened = crate::import("x.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{what}"); + assert_eq!(spots(&opened), spots(&doc), "{what}"); + } + } + + /// The rule needs a spot in DisplayInfo and no 1053, besides PhotoCraft's 1057. + #[test] + fn the_earlier_photocraft_rule_needs_a_spot_and_no_alpha_identifiers() { + let mut doc = channels_document(SampleType::U8); + let old = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap(); + assert!(earlier_photocraft_spot_planes(&old)); + let mut with_ids = old.clone(); + with_ids.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, 3))); + assert!(!earlier_photocraft_spot_planes(&with_ids), "a 1053"); + let mut no_info = old.clone(); + no_info.resources.retain(|r| r.id != crate::channel_map::DISPLAY_INFO); + assert!(!earlier_photocraft_spot_planes(&no_info), "no DisplayInfo"); + doc.channels.retain(|c| c.spot.is_none()); + let alpha_only = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap(); + assert!(!earlier_photocraft_spot_planes(&alpha_only), "alpha channels only"); + } } diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 4d9489d54a..2764ad83ce 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -21,7 +21,7 @@ use photocraft_raster::Surface; use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer}; use crate::psd_export::PsdExportOptions; -use crate::psd_import::{PsdImportOptions, psd_to_document_decoded}; +use crate::psd_import::psd_to_document; use crate::{ExportOptions, ExportResult, ImportResult, IoError}; /// `true` when saving `doc` as TIFF writes layer data: anything beyond a single Background @@ -130,13 +130,11 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result 0 { - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(kept))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept))); } // Resource 1022 names a channel of the PSD merged image, which counts the transparency plane. // The IFD below does not, so the id moves back by one. @@ -812,7 +808,7 @@ fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&Alpha resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names))); resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false))); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(names.len()))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, names.len()))); if let Some(id) = quick_id { let mut data = id.to_be_bytes().to_vec(); data.push(0); diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 49f0076728..8caf15c7c8 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -665,7 +665,7 @@ fn ink(d: &Document) -> (f32, f32) { fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec { use photocraft_psd::{Compression, TaggedBlock}; - let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, photoshop_spot_polarity: true, ..Default::default() }; + let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() }; let (file, _) = document_to_psd_with(doc, &opts); let planes = file.decode_merged().unwrap(); let n = 16; @@ -850,8 +850,8 @@ fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) { /// Photoshop 27's Kato-Karate TIFF stores spot ink inverted: every non-white artwork pixel /// has spot 0, and the white background is spot 255. An alpha channel is the other way -/// around (255 = selected). A PSD keeps storing both planes straight, as earlier versions did, -/// and a TIFF opened and saved as PSD does the same. Everything round-trips. +/// around (255 = selected). Photoshop's PSDs store both the same way, and so does a PSD saved +/// here. Everything round-trips, PSD → TIFF → PSD included. #[test] fn spot_ink_is_stored_as_zero_and_alpha_is_not() { use photocraft_color::{Color, PixelFormat}; @@ -891,11 +891,14 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { let first = 3 + usize::from(file.merged_has_alpha()); let spot_plane = &merged[first * plane..(first + 1) * plane]; let alpha_plane = &merged[(first + 1) * plane..(first + 2) * plane]; - // Background (0,0) and artwork (3,3), both straight: max = solid ink, max = selected. - assert_stored(spot_plane, 0, depth, true, "psd spot background"); - assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, false, "psd spot art"); + // Background (0,0) and artwork (3,3). Spot: 0 = solid ink, max = paper. + // Alpha: max = selected, 0 = empty. + assert_stored(spot_plane, 0, depth, false, "psd spot background"); + assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, true, "psd spot art"); assert_stored(alpha_plane, 0, depth, true, "psd alpha background"); assert_stored(alpha_plane, (3 * w as usize + 3) * bps, depth, false, "psd alpha art"); + // The merged transparency (0), then the spot and the alpha channel (3, 4). + assert_eq!(file.resource(1053).map(|r| identifiers(&r.data)), Some(vec![0, 3, 4]), "{depth:?}"); let back = import("pol.psd", &psd.bytes).unwrap().document; let area = Rect::new(0, 0, w as i32, h as i32); assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{depth:?} psd spot"); @@ -905,7 +908,8 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { if depth == SampleType::U8 { for layers in [false, true] { - let r = export(&doc, "pol.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + // The document as opened from the PSD, so the PSD's own resources come along. + let r = export(&back, "pol.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); assert_eq!((spp, extra.as_slice()), (5, [0u16, 0].as_slice()), "{layers}"); // Reference TIFF: artwork spot sample is 0, background spot sample is 255. @@ -913,19 +917,40 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { assert_eq!(samples[(3 * w as usize + 3) * 5 + 3], 0, "{layers} art spot"); assert_eq!(samples[4], 0, "{layers} background alpha"); assert_eq!(samples[(3 * w as usize + 3) * 5 + 4], 255, "{layers} art alpha"); - let back = import("pol.tif", &r.bytes).unwrap().document; - assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); - assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); - // Saved as PSD: straight planes, and no 1053 (the TIFF's isn't kept on open). - let psd = PsdFile::from_bytes(&export(&back, "pol.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + // One identifier per extra sample, numbered from 3 (no transparency sample). + assert_eq!(tiff_alpha_identifiers(&r.bytes), [3, 4], "{layers}"); + let from_tiff = import("pol.tif", &r.bytes).unwrap().document; + assert_eq!(from_tiff.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); + assert_eq!(from_tiff.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); + assert!(from_tiff.channels[0].spot.is_some() && from_tiff.channels[1].spot.is_none(), "{layers}"); + // Saved as PSD again: the first PSD's planes, and one 1053 of its own. The flat TIFF's + // composite was matted on white, so only the layered one brings transparency back. + let psd = PsdFile::from_bytes(&export(&from_tiff, "pol.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); let again = psd.decode_merged().unwrap(); assert_eq!(again[again.len() - 2 * plane..], merged[(first * plane)..(first + 2) * plane], "{layers} tiff to psd"); - assert!(psd.resource(1053).is_none(), "{layers} tiff to psd"); + assert_eq!(psd.merged_has_alpha(), layers, "{layers} tiff to psd"); + let want: Vec = if layers { vec![0, 3, 4] } else { vec![3, 4] }; + assert_eq!(psd.resources.iter().filter(|r| r.id == 1053).map(|r| identifiers(&r.data)).collect::>(), [want], "{layers} tiff to psd"); + let reopened = import("pol.psd", &psd.to_bytes().unwrap()).unwrap().document; + assert_eq!(reopened.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); + assert_eq!(reopened.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); } } } } +/// The big-endian u32s of a resource 1053. +fn identifiers(data: &[u8]) -> Vec { + data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect() +} + +/// The identifiers in a TIFF's resource 1053 (none without one). +fn tiff_alpha_identifiers(bytes: &[u8]) -> Vec { + let resources = tags(bytes).0.expect("photoshop resources"); + let (blocks, _) = photocraft_psd::tiff::resources_from_bytes(&resources).unwrap(); + blocks.iter().find(|b| b.id == 1053).map(|b| identifiers(&b.data)).unwrap_or_default() +} + /// `at` addresses one sample of a big-endian PSD plane. `zero` expects 0; otherwise the /// maximum sample (255 / 65535 / 1.0). fn assert_stored(plane: &[u8], at: usize, depth: SampleType, zero: bool, what: &str) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e2fc72c8ed..a2752b1346 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; earlier the same day: Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053; PSDs from earlier PhotoCraft versions keep their ink; earlier the same day: TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; earlier the same day: Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending; alpha and spot channels as Photoshop stores them (spot ink 0 = solid, channel identifiers in 1053), and PSDs with spot channels from PhotoCraft 0.1.0–0.11.1, which stored the ink the other way round, keep their ink. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels and no 1053 whose 1057 is exactly what PhotoCraft 0.1.0–0.11.1 wrote is read as those versions stored it (straight), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) | | 2026-10-21 | minor | TIFF: alpha and spot channels are written and read as extra samples (at most 16) with Photoshop's channel resources (1006, 1045, 1077, 1053), spot ink stored 0 = solid as Photoshop 27.11's TIFFs store it; PSD output is unchanged. FILE-215-7 done, FILE-215-17 partial (its TIFF part) (#7415, supersedes #3121) | | 2026-10-21 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice. A PSD save with an opaque composite drops any of these markers kept from the opened file, so its first alpha channel no longer reopens as transparency and vanishes (split from #7415; credit #3121) | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | diff --git a/docs/gaps.md b/docs/gaps.md index 647c121998..bfeb37cd29 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink; earlier the same day: G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -230,10 +230,13 @@ what an agent can't finish alone. write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; spot plates beyond TIFF: spot ink preview in RGB/CMYK documents and PDF Separation + Raw; spot plates beyond PSD and TIFF: spot ink preview in RGB/CMYK documents and PDF Separation (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-21, #7415 split):** PSD stores and reads spot channels as Photoshop does (ink + 0 = solid) and writes their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.11.1, which stored + spot ink the other way round, keep their ink. - **Progress (2026-10-21, #7415):** TIFF writes and reads alpha and spot channels as extra samples with Photoshop's names, ink colours and solidity (FILE-215-7 done; FILE-215-17's TIFF part). - **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the @@ -277,6 +280,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | G10: PSD stores and reads spot channels as Photoshop does (ink 0 = solid) with their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.11.1 keep their ink (#7415 split, part 4); FILE-215-17 stays partial; estimate unchanged | | 2026-10-21 | minor | G10: TIFF alpha and spot channels written and read as extra samples with Photoshop's channel resources, PSD output unchanged (#7415); FILE-215-7 leaves the list, FILE-215-17 narrows to RGB/CMYK spot preview and PDF Separation | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6e8449e38e..b76db16467 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (TIFF spot plates, FILE-215-17's TIFF part, #7415; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (PSD spot plates in Photoshop's polarity, #7415 split; earlier the same day: TIFF spot plates, FILE-215-17's TIFF part, #7415; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -175,7 +175,7 @@ parity for the area and are already contained in the dimension rows above. | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | -| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | +| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in PSD and TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged | | 2026-10-21 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 84738c1455..b9a2c1a744 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -122,7 +122,7 @@ id = "FILE-215-17" target = "CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF)" status = "partial" issue = 215 -note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" +note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.11.1 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" [[item]] id = "FILE-215-18" From 9ec668e86d7395e92a6b5e0e41c7d0b46e6030cb Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 13:33:58 -0400 Subject: [PATCH 08/19] psd_import: test an early PhotoCraft PSD whose first extra channel is an alpha In 0.1.0-0.7.0's 14-byte DisplayInfo records the spots after an alpha channel are found only by the padded reading; the planes are read straight and keep their ink. A 2 % spot's opacity byte reads as kind 2 in 13-byte records: without the rule that plane would be inverted. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/psd_import.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 14118c2c86..0fa5470f4b 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -1306,6 +1306,38 @@ mod tests { } } + /// A 0.1.0 to 0.7.0 file whose first extra channel is an alpha channel, spots after it. In its + /// 14-byte DisplayInfo records the spots' kind bytes sit where a 13-byte reader (main's, and + /// this one's) doesn't look, so only the padded reading finds a spot for the rule. Every plane + /// is then read straight, as those versions read it, and keeps its ink. The 13-byte reader + /// opens each channel's options from shifted bytes, as on main: here every channel opens as + /// an alpha channel. A spot of 2 % solidity puts a 2 where that reader looks for the kind, so + /// that channel opens as a spot: without the rule its plane would be inverted. Saved again and + /// reopened, every channel keeps its ink and kind. + #[test] + fn an_early_file_with_an_alpha_channel_first_keeps_its_ink() { + for solidity in [0.0, 0.02] { + let mut doc = channels_document(SampleType::U8); + doc.channels.swap(0, 1); + doc.channels[1].spot = Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), solidity)); + let names: Vec<&str> = doc.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["Alpha 1", "Spot A", "Spot B"]); + let bytes = as_earlier_photocraft_saved(&doc, version_info_cs6(true), true); + let file = PsdFile::from_bytes(&bytes).unwrap(); + let info = &file.resource(crate::channel_map::DISPLAY_INFO).unwrap().data; + let misread = solidity > 0.0; + assert_eq!(crate::channel_map::spot_flags(info, true, 0), [false, misread, false], "{solidity}: 13-byte records"); + assert!(earlier_photocraft_spot_planes(&file), "{solidity}: found in the 14-byte records"); + let opened = crate::import("old.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{solidity}: the ink 0.7.0 opened"); + assert_eq!(spots(&opened), [false, misread, false], "{solidity}: kinds as main reads them"); + let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap(); + let again = crate::import("again.psd", &saved.bytes).unwrap().document; + assert_eq!(ink(&again), ink(&doc), "{solidity}: saved again"); + assert_eq!(spots(&again), spots(&opened), "{solidity}: saved again"); + } + } + /// A file with spots and no 1053 that no earlier PhotoCraft wrote is read in Photoshop's /// polarity (0 = solid ink). psd-tools' `cmyk-spot.psd` is one: its 1057 names psd-tools as /// writer and reader. So is a file with no 1057, with Photoshop CS6's names without the NUL, From 6e3fe3dd9e3f0b147cc6512b4e4b1300a7e2455a Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 16:26:58 -0400 Subject: [PATCH 09/19] io: drop 1053 on PSD open; never copy a kept one on save Every save now writes 1053 for the extra channels, so a copy kept on open grew the document's metadata on each round trip (the roundtrip and tiff_layers suites caught it), as #7415 found. Multichannel keeps its 1053. The layered writer never copies a kept 1053: next to the fresh one it would be a second, and without channels it would list channels that are gone. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/psd_export.rs | 27 +++++++++++++++++---------- crates/io/src/psd_import.rs | 4 ++++ crates/io/src/tiff_layers.rs | 2 +- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index daa25ca2bc..256f578236 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1826,8 +1826,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !keep_imported_resource(doc, *id, data) { continue; } - // A fresh 1053 was written with the extra channels; a preserved copy would be a second one. - if *id == crate::channel_map::ALPHA_IDENTIFIERS && !extra.is_empty() { + // 1053 is written afresh with the extra channels. A kept copy (from a Multichannel file, or + // a document saved by an earlier version) would be a second one, or list channels that + // are gone. + if *id == crate::channel_map::ALPHA_IDENTIFIERS { continue; } let mut r = ImageResource::new(*id, data.to_vec()); @@ -2030,20 +2032,25 @@ mod tests { assert_eq!(alpha_ids(&file), Some(vec![0, 3, 4])); } - /// A Photoshop file's own 1053 is kept on open (as every unmapped resource is), but a save - /// writes one fresh 1053 for the channels the document has, never the kept one as well. The + /// A save writes one fresh 1053 for the channels the document has. A Photoshop file's own is + /// not kept on open (it would be new metadata after every round trip), and one a document + /// carries anyway (kept by an earlier version) is never written: next to the fresh one it + /// would be a second, and without channels it would list channels that are gone. The /// identifiers here, 3 and 2, are those of psd-tools' `cmyk-alpha-spot.psd` (Photoshop 24.7). #[test] - fn a_kept_alpha_identifiers_resource_is_not_written_twice() { + fn a_kept_alpha_identifiers_resource_is_not_written() { + let kept = || (crate::channel_map::ALPHA_IDENTIFIERS, String::new(), std::sync::Arc::new([0, 0, 0, 3, 0, 0, 0, 2].to_vec())); let mut file = document_to_psd(&spot_and_alpha_document()); file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); - file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, [0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 2].to_vec())); - let opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document; - assert!(opened.metadata.psd_resources.iter().any(|(id, ..)| *id == crate::channel_map::ALPHA_IDENTIFIERS)); - let saved = crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap(); - let again = PsdFile::from_bytes(&saved.bytes).unwrap(); + file.resources.push(ImageResource::new(kept().0, kept().2.to_vec())); + let mut opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document; + assert!(!opened.metadata.psd_resources.iter().any(|(id, ..)| *id == crate::channel_map::ALPHA_IDENTIFIERS)); + opened.metadata.psd_resources.push(kept()); + let again = PsdFile::from_bytes(&crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap().bytes).unwrap(); assert_eq!(again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).count(), 1); assert_eq!(alpha_ids(&again), Some(vec![0, 3, 4])); + opened.channels.clear(); + assert_eq!(alpha_ids(&document_to_psd(&opened)), None, "no channels left"); } #[test] diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 0fa5470f4b..fbfa691c62 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -873,6 +873,10 @@ pub(crate) fn psd_to_document_decoded( doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 }); doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))); } + // Resource 1053 is written afresh with the extra channels on every save, so a kept copy + // would be new metadata after each round trip. Multichannel has no such rewrite: the + // imported bytes are the channel ids, copied back while they still match. + crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {} id if MAPPED_RESOURCES.contains(&id) => {} id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 2764ad83ce..401495a1ec 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -296,7 +296,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // it, so a plain transparent TIFF still opens with alpha. let write_alpha = has_alpha && kept == 0; // One identifier per extra sample actually stored. The PSD model's 1053 lists the merged - // transparency too, and a kept one lists an opened file's channels, so neither is copied. + // transparency too, which the IFD leaves out next to extra samples, so it is not copied. resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); if kept > 0 { resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept))); From 9fc9fad8178a930d5214833f05e5483e59b79437 Mon Sep 17 00:00:00 2001 From: Jason D Date: Thu, 22 Oct 2026 08:52:13 -0400 Subject: [PATCH 10/19] docs: regenerate the scorecard FILE-215-17's note: PSD spot planes in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink. Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index f92ff987b2..96dd55efdb 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -117,7 +117,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | -| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | +| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.11.1 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | From f53aa0b9a2a612b68e4ffde3f1d7e65afc41cbd0 Mon Sep 17 00:00:00 2001 From: Jason D Date: Fri, 23 Oct 2026 09:05:25 -0400 Subject: [PATCH 11/19] docs: regenerate the scorecard on current main FILE-215-7 done and FILE-215-17 partial, as before the merge. Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 6e84b9847e..b6455893c1 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 26 | 60 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 22 not run, 4 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 9 | 13 | 5 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 10 | 13 | 4 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/256, round trip ≥ 258/256 | | [Tools](#tools) | 36 | 8 | 0 | | | [Workspace and UI](#workspace-and-ui) | 14 | 6 | 3 | settings that do nothing: 21 of 176 (target 0) | | [Type](#type) | 5 | 4 | 0 | | @@ -108,7 +108,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | -| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) | +| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha/spot channels and a Quick Mask are written as TIFF extra samples (at most 16) plus Photoshop resources for name, colour and solidity; Photoshop's MTrn marker is read (#7415). The TIFF Options Save Transparency choice isn't offered: channels are written as Photoshop does with it off | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain | @@ -118,7 +118,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | -| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | +| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | From ee261f4690f098e0fe3f719808854c0bae415e27 Mon Sep 17 00:00:00 2001 From: Jason D Date: Fri, 23 Oct 2026 09:12:11 -0400 Subject: [PATCH 12/19] docs: regenerate the scorecard on current main Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index b6455893c1..074ecfa8e5 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -118,7 +118,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | -| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | +| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.11.1 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | From c56fb1c066ec84b13f8e8c85025241818f04b0dd Mon Sep 17 00:00:00 2001 From: Jason D Date: Fri, 23 Oct 2026 09:52:58 -0400 Subject: [PATCH 13/19] io: rustfmt the PSD import call after the merge Co-Authored-By: Claude Opus 5.5 --- crates/io/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 28ce7693a7..f24987fb42 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -366,7 +366,8 @@ fn import_psd(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> R psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged, psd_import::PsdImportOptions::default()).ok_or(IoError::Cancelled)?; + let (mut document, warnings) = + psd_import::psd_to_document_decoded(&file, ctl, merged, psd_import::PsdImportOptions::default()).ok_or(IoError::Cancelled)?; document.name = name.to_string(); text_import::prepare(&mut document); Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }) From 009329b10dfa6bff8cea930269d98113ac37759c Mon Sep 17 00:00:00 2001 From: Jason D Date: Fri, 23 Oct 2026 10:42:43 -0400 Subject: [PATCH 14/19] codecs: allocate the TIFF extra-sample write buffers fallibly interleave_extras and the 16-bit and float sample copies used infallible allocations sized by the image and its extra planes, so a save too large for memory aborted the process. They now reserve with try_reserve_exact and return an OutOfMemory error, which the encoder reports, as the decoder already does through `zeroed`. Raised by CodeRabbit on #8885. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/codecs/tiff.rs | 69 +++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 1cfa38bc10..7fef6f17b2 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -1373,12 +1373,26 @@ macro_rules! write_extras { }; } -fn bytes_as_u16(b: &[u8]) -> Vec { - b.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).collect() +/// An empty buffer with room for `len` elements, allocated fallibly: a save too large for memory +/// is an error, not an abort. +fn try_with_capacity(len: usize) -> io::Result> { + let mut v = Vec::new(); + v.try_reserve_exact(len).map_err(|_| io::Error::new(io::ErrorKind::OutOfMemory, format!("cannot allocate {len} samples for the extra samples")))?; + Ok(v) } -fn bytes_as_f32(b: &[u8]) -> Vec { - b.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect() +fn bytes_as_u16(b: &[u8]) -> io::Result> { + let chunks = b.as_chunks::<2>().0; + let mut v = try_with_capacity(chunks.len())?; + v.extend(chunks.iter().map(|c| u16::from_ne_bytes(*c))); + Ok(v) +} + +fn bytes_as_f32(b: &[u8]) -> io::Result> { + let chunks = b.as_chunks::<4>().0; + let mut v = try_with_capacity(chunks.len())?; + v.extend(chunks.iter().map(|c| f32::from_ne_bytes(*c))); + Ok(v) } /// Interleaves colour (and alpha) samples with [`Metadata::extra_samples`]. @@ -1390,7 +1404,9 @@ fn interleave_extras(img: &Image) -> Result, String> { let extras = &img.meta.extra_samples; let total = ch.checked_add(extras.len()).ok_or_else(|| "too many samples".to_string())?; let px = total.checked_mul(sb).ok_or_else(|| "sample size overflows".to_string())?; - let mut out = vec![0u8; n.checked_mul(px).ok_or_else(|| "image size overflows".to_string())?]; + let len = n.checked_mul(px).ok_or_else(|| "image size overflows".to_string())?; + let mut out = try_with_capacity::(len).map_err(|e| e.to_string())?; + out.resize(len, 0); let base = img.data(); let base_px = ch.saturating_mul(sb); for i in 0..n { @@ -1429,12 +1445,12 @@ fn write_with_extras(enc: &mut TiffEncoder<&mut Cursor>, K> return match (file.layout(), file.sample_type()) { (L::Lab, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 3), (L::Lab, _) => { - let v = bytes_as_u16(&bytes); + let v = bytes_as_u16(&bytes)?; write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 3) } (_, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 4), _ => { - let v = bytes_as_u16(&bytes); + let v = bytes_as_u16(&bytes)?; write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 4) } }; @@ -1443,56 +1459,56 @@ fn write_with_extras(enc: &mut TiffEncoder<&mut Cursor>, K> match (img.layout(), img.sample_type()) { (L::Gray, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 1), (L::Gray, S::U16) => { - let v = bytes_as_u16(&bytes); + let v = bytes_as_u16(&bytes)?; write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 1) } (L::Gray, S::F32) => { - let v = bytes_as_f32(&bytes); + let v = bytes_as_f32(&bytes)?; write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 1) } (L::GrayA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 2), (L::GrayA, S::U16) => { - let v = bytes_as_u16(&bytes); + let v = bytes_as_u16(&bytes)?; write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 2) } (L::GrayA, S::F32) => { - let v = bytes_as_f32(&bytes); + let v = bytes_as_f32(&bytes)?; write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 2) } (L::Rgb, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 3), (L::Rgb, S::U16) => { - let v = bytes_as_u16(&bytes); + let v = bytes_as_u16(&bytes)?; write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 3) } (L::Rgb, S::F32) => { - let v = bytes_as_f32(&bytes); + let v = bytes_as_f32(&bytes)?; write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 3) } (L::Rgba, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 4), (L::Rgba, S::U16) => { - let v = bytes_as_u16(&bytes); + let v = bytes_as_u16(&bytes)?; write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 4) } (L::Rgba, S::F32) => { - let v = bytes_as_f32(&bytes); + let v = bytes_as_f32(&bytes)?; write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 4) } (L::Cmyk, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 4), (L::Cmyk, S::U16) => { - let v = bytes_as_u16(&bytes); + let v = bytes_as_u16(&bytes)?; write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 4) } (L::Cmyk, S::F32) => { - let v = bytes_as_f32(&bytes); + let v = bytes_as_f32(&bytes)?; write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 4) } (L::CmykA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 5), (L::CmykA, S::U16) => { - let v = bytes_as_u16(&bytes); + let v = bytes_as_u16(&bytes)?; write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 5) } (L::CmykA, S::F32) => { - let v = bytes_as_f32(&bytes); + let v = bytes_as_f32(&bytes)?; write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 5) } _ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({n})")).into()), @@ -1727,3 +1743,18 @@ mod bits_len { assert_eq!( as TiffColorType>::SAMPLE_FORMAT.len(), 6); } } + +#[cfg(test)] +mod extra_sample_buffers { + use super::try_with_capacity; + + /// The extra-sample writer's buffers are allocated fallibly: a size no allocation can serve + /// is an out-of-memory error the save reports, not an abort. + #[test] + fn an_impossible_buffer_is_an_error() { + let err = try_with_capacity::(usize::MAX).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::OutOfMemory); + assert!(try_with_capacity::(usize::MAX / 2).is_err()); + assert!(try_with_capacity::(64).is_ok_and(|v| v.is_empty() && v.capacity() >= 64)); + } +} From 02384d30ce62fb535fb27c72b5a190aea469d0b5 Mon Sep 17 00:00:00 2001 From: Jason D Date: Fri, 23 Oct 2026 10:51:22 -0400 Subject: [PATCH 15/19] io: past 16 extra channels, layered TIFF resources describe only the stored ones A layered TIFF stores at most MAX_EXTRA_SAMPLES (16) extra samples, but the channel names (1006, 1045) and DisplayInfo (1077) still listed every channel, and a Quick Mask past the cap kept its 1022 record pointing at a sample that wasn't there. They now describe the first 16, and 1022 goes when its channel wasn't stored. Raised by CodeRabbit on #8878. Test: channels_past_sixteen_leave_no_resources_behind (18 alpha channels and a Quick Mask); it fails without the fix (1006 lists 19 names). Co-Authored-By: Claude Opus 5.5 --- crates/io/src/tiff_layers.rs | 47 +++++++++++++++++++++++++++ crates/io/tests/it/tiff_layers.rs | 53 +++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 35eca440f6..90018ba544 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -325,6 +325,11 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr if has_alpha && !write_alpha { drop_merged_transparency(&mut resources); } + // Past MAX_EXTRA_SAMPLES the TIFF stores only the first `kept` channels: the name and + // DisplayInfo resources describe just those, and a Quick Mask among the rest is dropped. + if extra_n > kept { + keep_first_channels(&mut resources, kept, cc); + } let resources = resources_to_bytes(&resources)?; let ifd_channels = cc + usize::from(write_alpha); let sample = csample(fmt.sample); @@ -428,6 +433,48 @@ fn drop_first_unicode(data: &[u8]) -> Vec { data.get(end..).unwrap_or(data).to_vec() } +/// Trims the channel-name resources (1006, 1045) and DisplayInfo (1077) to their first `kept` +/// entries, and drops Quick Mask info (1022) when its channel is not one of the `kept` extra +/// samples, which start at sample `first_extra`. A resource too short for `kept` entries is +/// left as it is. +fn keep_first_channels(resources: &mut Vec, kept: usize, first_extra: usize) { + for r in resources.iter_mut() { + r.data = match r.id { + 1006 => first_pascal_names(&r.data, kept), + 1045 => first_unicode_names(&r.data, kept), + id if id == crate::channel_map::DISPLAY_INFO => first_display_records(&r.data, kept), + _ => continue, + }; + } + let end = first_extra.saturating_add(kept); + resources.retain(|r| r.id != crate::channel_map::QUICK_MASK_INFO || r.data.get(..2).is_none_or(|b| usize::from(u16::from_be_bytes([b[0], b[1]])) < end)); +} + +fn first_pascal_names(data: &[u8], n: usize) -> Vec { + let mut at = 0usize; + for _ in 0..n { + let Some(&len) = data.get(at) else { return data.to_vec() }; + at = at.saturating_add(1).saturating_add(usize::from(len)); + } + data.get(..at).unwrap_or(data).to_vec() +} + +fn first_unicode_names(data: &[u8], n: usize) -> Vec { + let mut at = 0usize; + for _ in 0..n { + let Some(b) = data.get(at..at.saturating_add(4)) else { return data.to_vec() }; + let len = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize; + at = at.saturating_add(4).saturating_add(len.saturating_mul(2)); + } + data.get(..at).unwrap_or(data).to_vec() +} + +/// Resource 1077: a 4-byte version, then 13-byte records. Keeps the first `n` records. +fn first_display_records(data: &[u8], n: usize) -> Vec { + let end = 4usize.saturating_add(n.saturating_mul(13)); + data.get(..end).unwrap_or(data).to_vec() +} + /// Resource 1077: a 4-byte version, then 13-byte records. Drops the first record. fn drop_first_display_record(data: &[u8]) -> Vec { const RECORD: usize = 13; diff --git a/crates/io/tests/it/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs index ae51a87a7a..dd9d6ddc19 100644 --- a/crates/io/tests/it/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -1022,3 +1022,56 @@ fn layered_tiff_nested_past_the_cap_is_rejected() { let r = import("hundred.tif", &tiff_nested(100)).unwrap(); assert_eq!(r.document.max_group_depth(), 100); } + +/// Past MAX_EXTRA_SAMPLES (16), a layered TIFF stores the first 16 extra channels. Its channel +/// resources must describe those 16 and no more: 16 names in 1006 and 1045, 16 DisplayInfo +/// records and 16 identifiers, and no Quick Mask record for a Quick Mask that wasn't stored. +#[test] +fn channels_past_sixteen_leave_no_resources_behind() { + use photocraft_color::PixelFormat; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(0, 0, 8, 8), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + for i in 1..=18 { + doc.channels.push(AlphaChannel::new(format!("Alpha {i}"), Surface::new(gray))); + } + doc.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::new(gray))); + + let r = tiff(&doc, true); + assert!(r.warnings.iter().any(|w| w.contains("19 extra channels") && w.contains("first 16")), "{:?}", r.warnings); + let (resources, _) = tags(&r.bytes); + let (resources, _) = photocraft_psd::tiff::resources_from_bytes(&resources.expect("resources")).expect("parse resources"); + let data = |id: u16| resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap_or_default(); + let pascal = data(1006); + let (mut at, mut names) = (0usize, 0usize); + while let Some(&len) = pascal.get(at) { + at += 1 + usize::from(len); + names += 1; + } + assert_eq!(names, 16, "1006"); + let unicode = data(1045); + let (mut at, mut names) = (0usize, 0usize); + while let Some(b) = unicode.get(at..at + 4) { + at += 4 + 2 * u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize; + names += 1; + } + assert_eq!(names, 16, "1045"); + assert_eq!(data(1077).len(), 4 + 16 * 13, "1077"); + assert_eq!(data(1053).len(), 16 * 4, "1053"); + assert!(resources.iter().all(|r| r.id != 1022), "the Quick Mask was not stored, so its record goes"); + + let flat = photocraft_codecs::decode(&r.bytes).expect("decode"); + assert_eq!(flat.meta.extra_samples.len(), 16); + let back = import("x.tif", &r.bytes).expect("import").document; + let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect(); + let want: Vec = (1..=16).map(|i| format!("Alpha {i}")).collect(); + assert_eq!(names, want.iter().map(String::as_str).collect::>()); + assert!(back.quick_mask.is_none()); +} From 6bcfd06b60289c0faada888ca695d12d6d26729c Mon Sep 17 00:00:00 2001 From: Jason D Date: Fri, 23 Oct 2026 11:25:19 -0400 Subject: [PATCH 16/19] contributors: let jasallen's display name follow the GitHub profile display_name took precedence over sync_github_name, so the sync had no effect. Without it, About shows the public GitHub profile name. Raised by CodeRabbit on #8885. Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/contributors/people.toml b/contributors/people.toml index 97aeaf5d68..43cb25f19b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -254,5 +254,4 @@ real_name = "Martin Ball" [people.jasallen] real_name = "Jason DeBoever" -display_name = "jasallen" sync_github_name = true From e2ecad744228a129e865a8494c40161addc40007 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 15:32:54 -0600 Subject: [PATCH 17/19] codecs: refuse TIFF extra samples that are not one sample per pixel The public `ExtraSample` contract is one sample per pixel in the image's sample type, but the writer only stopped when a requested slice was missing, so an oversized plane was silently truncated and a short one was dropped mid-image. `Image::convert` leaves a plane whose length does not match the image alone (deliberately: importers drop such a plane with a warning), so encoding a Lab F32 image also reinterpreted a wrong-sized plane at the file's 16 bits. Check every plane against width * height * sample.bytes(), with checked arithmetic, at both ends: on the source image before `converted` brings it to the plan's sample type, and on the image actually written, which the Lab path re-types. A plane of any other length is a CodecError::Encode naming the plane, its length and the length the image needs. Regressions: too long, too short and empty planes at 8 bits, and a 7- or 9-byte plane for a two-pixel Lab F32 image, which the U16 conversion used to accept. --- crates/codecs/README.md | 4 ++- crates/codecs/src/codecs/tiff.rs | 34 +++++++++++++++++++++ crates/codecs/tests/it/tiff_photoshop.rs | 38 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index fa66e8db1e..6a8f2e0b4e 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -239,7 +239,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them JPEG-in-TIFF) return `Unsupported`. * **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour - and alpha samples, with their `kind` in the ExtraSamples tag; more than 16 is an error. + and alpha samples, with their `kind` in the ExtraSamples tag. Each plane must be exactly + `width × height × sample.bytes()` bytes; a plane of any other length, and more than 16 planes, + are encode errors rather than silently truncated or reinterpreted. * **EXR** * Multi-part files (a Maya/Arnold render writes one part per AOV) open the part that looks most like a colour image — RGBA beats RGB beats YA beats a single channel, so a diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 7fef6f17b2..6de1e72389 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -1655,7 +1655,39 @@ fn estimated_size(img: &Image, tags: &TagSet<'_>) -> u64 { (img.data().len() as u64).saturating_add(extras).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20) } +/// The byte length of one extra-sample plane: one sample per pixel of `img`, in its sample type. +/// `None` when the pixel count overflows a `usize` (no such image buffer can exist). +fn extra_plane_len(img: &Image) -> Option { + let (w, h) = img.dimensions(); + (w as usize).checked_mul(h as usize)?.checked_mul(img.sample_type().bytes()) +} + +/// Every extra plane must hold exactly one sample per pixel of `img`, in its sample type. +/// +/// The writer copies one sample per pixel out of each plane, so a plane of any other length +/// would be silently truncated (long) or read past its end (short). A sample-type conversion +/// reinterprets the bytes in the new width instead, so an unchecked plane would also change +/// meaning rather than fail. Refuse the image instead of writing either. +fn check_extra_planes(img: &Image) -> Result<(), CodecError> { + let (w, h) = img.dimensions(); + let sample = img.sample_type(); + let plane_len = extra_plane_len(img).ok_or_else(|| CodecError::encode(F, format!("the extra-sample size of a {w}x{h} image overflows")))?; + for (k, e) in img.meta.extra_samples.iter().enumerate() { + if e.data.len() != plane_len { + return Err(CodecError::encode( + F, + format!("extra sample {k} is {} bytes; a {w}x{h} {sample:?} image needs exactly {plane_len} (one sample per pixel)", e.data.len()), + )); + } + } + Ok(()) +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + // Check the source planes before `converted`, which brings them to the plan's sample type + // with the image but deliberately leaves a wrong-sized plane alone: past that point a bad + // plane would be reinterpreted in the new width rather than rejected. + check_extra_planes(src)?; // `converted` brings the extra samples to the plan's sample type with the image. encode_ready(&src.converted(plan.layout, plan.sample), opts) } @@ -1687,6 +1719,8 @@ fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result, CodecError if img.meta.extra_samples.len() > crate::MAX_EXTRA_SAMPLES { return Err(CodecError::encode(F, format!("TIFF stores at most {} extra samples", crate::MAX_EXTRA_SAMPLES))); } + // The planes must match the image actually written: the Lab path re-types it to 8 or 16 bits. + check_extra_planes(img)?; let mut kinds = Vec::with_capacity(img.meta.extra_samples.len().saturating_add(1)); if img.layout().has_alpha() { kinds.push(2); diff --git a/crates/codecs/tests/it/tiff_photoshop.rs b/crates/codecs/tests/it/tiff_photoshop.rs index 9f7644810f..0d4ebaf479 100644 --- a/crates/codecs/tests/it/tiff_photoshop.rs +++ b/crates/codecs/tests/it/tiff_photoshop.rs @@ -179,6 +179,44 @@ fn sixteen_extra_samples_round_trip() { } } +/// An extra plane holds exactly one sample per pixel of the image's sample type. A plane of any +/// other length is refused rather than written: the writer copies one sample per pixel, so a +/// longer plane would lose its tail and a shorter one would run out mid-image. +#[test] +fn wrong_sized_extra_planes_are_refused() { + let (w, h) = (2u32, 1u32); + for (label, plane) in [("too long", vec![10u8, 20, 30]), ("too short", vec![10u8]), ("empty", Vec::new())] { + let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: plane.clone() }]; + let err = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap_err(); + assert!(matches!(err, CodecError::Encode { .. }), "{label}: {err}"); + assert!(err.to_string().contains("extra sample 0") && err.to_string().contains("exactly 2"), "{label}: {err}"); + } + // The exact length still writes, and comes back unchanged. + let plane = vec![10u8, 20]; + let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: plane.clone() }]; + let back = decode(&encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap()).unwrap(); + assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: plane }]); +} + +/// The sample-type conversion an encode performs must not launder a wrong-sized plane: float Lab +/// is stored as CIELab at 16 bits, and `Image::convert` leaves a plane whose length does not match +/// the image alone, so a 7- or 9-byte plane for two pixels would otherwise reach the writer +/// reinterpreted at the new width. +#[test] +fn a_wrong_sized_plane_is_refused_before_a_sample_type_conversion() { + let (w, h) = (2u32, 1u32); + // Two pixels at 32-bit float are 8 bytes; the file's 16-bit samples are 4. + for (label, len) in [("short", 7usize), ("long", 9)] { + let mut img = synth(w, h, ChannelLayout::Lab, SampleType::F32, 1, 0.0); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: vec![0; len] }]; + let err = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap_err(); + assert!(matches!(err, CodecError::Encode { .. }), "{label}: {err}"); + assert!(err.to_string().contains("exactly 8"), "{label}: {err}"); + } +} + /// Extra samples count against `max_alloc` with the image (their interleaved buffer and the /// planes split from it): a budget that holds the RGB image but not sixteen extra planes refuses /// the file instead of allocating past it. From 1c5343d5f7c4a72b430ad4ba86584d34084db8c6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 24 Oct 2026 15:43:33 -0600 Subject: [PATCH 18/19] codecs: say what the second extra-plane check guarantees The first comment claimed the check covers the Lab re-type, which happens later in `write_image`. Say instead what the check actually adds: the planes are validated in the sample type they now hold after `converted`, and a plane that `Image::convert` took but could not rebuild arrives here empty rather than retyped. --- crates/codecs/src/codecs/tiff.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 6de1e72389..e13e4fd1bd 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -1719,7 +1719,10 @@ fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result, CodecError if img.meta.extra_samples.len() > crate::MAX_EXTRA_SAMPLES { return Err(CodecError::encode(F, format!("TIFF stores at most {} extra samples", crate::MAX_EXTRA_SAMPLES))); } - // The planes must match the image actually written: the Lab path re-types it to 8 or 16 bits. + // Validate again in the sample type the planes now hold: `Image::convert` takes a plane's + // bytes before it rebuilds them, so one it could not rebuild would arrive here empty. The Lab + // path's later re-type through `lab_file` converts a plane only when its length already + // matches the image, which this guarantees. check_extra_planes(img)?; let mut kinds = Vec::with_capacity(img.meta.extra_samples.len().saturating_add(1)); if img.layout().has_alpha() { From 8f6863f536e69039747ba04f042ff92b9c031bef Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 16:14:33 +0100 Subject: [PATCH 19/19] Integration: a kept 1053 doesn't hide an earlier PhotoCraft's spot ink (#8885) Earlier PhotoCraft versions kept the 1053 of a Photoshop file they opened and wrote it back unchanged, while storing spot channels added there straight. A transparent Photoshop document carries 1053 = [0], so a white plate added to it in 0.16.0 or earlier sat behind a 1053, and the rule read that file in Photoshop's polarity: the added spot opened inverted. A 1053 that lists fewer channels (non-zero ids; 0 is the transparency's) than the file has after its transparency now counts as kept, with the PhotoCraft 1057 and DisplayInfo spot conditions unchanged. Planes the kept 1053 lists stay in Photoshop's polarity (they are the Photoshop file's own); the ones after them are read straight. Photoshop and this version write one id per extra channel, so their files are unaffected. Known limit, as before: channels reordered there, or as many deleted as added, can't be told apart from the Photoshop file's own. The earlier versions are 0.1.0 to 0.16.0, not 0.11.1: every release since writes the same 1057 and straight spot planes without a 1053. Co-authored-by: jasallen <615848+jasallen@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/io/src/psd_import.rs | 129 ++++++++++++++++++++++++++++++++---- docs/file-format-parity.md | 4 +- docs/gaps.md | 4 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 5 files changed, 122 insertions(+), 19 deletions(-) diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 8f2bcf9c79..090f6e1aba 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -876,14 +876,18 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) psd_to_document_decoded(file, ctl, None) } -/// Whether `file` stores its spot planes the way earlier PhotoCraft versions (releases 0.1.0 to -/// 0.11.1) wrote them: straight (the maximum = solid ink), the opposite of Photoshop, which +/// Whether `file` stores spot planes the way earlier PhotoCraft versions (releases 0.1.0 to +/// 0.16.0) wrote them: straight (the maximum = solid ink), the opposite of Photoshop, which /// stores 0 = solid ink in every PSD and TIFF it saves. Their files are recognised by three /// things together: /// /// - DisplayInfo (1077) marks at least one spot channel; -/// - there is no Alpha Identifiers resource (1053). Photoshop has written it in every file with -/// an extra channel since version 6, and PhotoCraft writes it now, but no earlier PhotoCraft did; +/// - there is no Alpha Identifiers resource (1053), or one that lists fewer channels than the +/// file has ([`photoshop_identified_channels`]). Photoshop has written it, one identifier per +/// extra channel, in every file with an extra channel since version 6, and PhotoCraft writes it +/// now. No earlier PhotoCraft wrote one of its own, but each kept the 1053 of a Photoshop file +/// it opened (a transparent document's `[0]`, for one) and wrote it back unchanged, after the +/// channels added in it, which it stored straight; /// - the version info (1057) is byte for byte what those versions wrote: writer "Adobe Photoshop" /// with reader "Adobe Photoshop CS6", both NUL-terminated (0.1.0 to 0.7.0), or reader "Adobe /// Photoshop 2026" without a NUL (0.8.2 on), with either merged-data flag. @@ -891,7 +895,8 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) /// Any other file is read in Photoshop's polarity. psd-tools' `cmyk-spot.psd`, for one, also has /// spots and no 1053, but names psd-tools in 1057 and stores Photoshop's polarity. A Photoshop /// file that an earlier PhotoCraft opened and saved again kept Photoshop's 1053 and its planes as -/// Photoshop stored them, so it is read in Photoshop's polarity too. +/// Photoshop stored them, so it is read in Photoshop's polarity too; when channels were added +/// there, only the planes after the ones the kept 1053 lists are read straight. /// /// Keeping these files' ink is the only reason this exists: deleting it (and its one caller) /// reads every file in Photoshop's polarity, at the cost of inverting the spot ink of PSDs saved @@ -901,7 +906,15 @@ pub(crate) fn earlier_photocraft_spot_planes(file: &PsdFile) -> bool { use photocraft_psd::resources::VersionInfo; let marks_spot = file.resource(crate::channel_map::DISPLAY_INFO).is_some_and(|r| crate::channel_map::display_info_marks_spot(&r.data)); - if !marks_spot || file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some() { + let identifies_every_channel = match file.header.color_mode.color_channels() { + Some(color) => { + let channels = file.header.channels.saturating_sub(color).saturating_sub(u16::from(file.merged_has_alpha())); + photoshop_identified_channels(file) >= usize::from(channels) + } + // Multichannel has no fixed number of colour channels to count from. + None => file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some(), + }; + if !marks_spot || identifies_every_channel { return false; } let Some(r) = file.resource(ids::VERSION_INFO) else { return false }; @@ -917,6 +930,15 @@ pub(crate) fn earlier_photocraft_spot_planes(file: &PsdFile) -> bool { v.version == 1 && v.file_version == 1 && writers.iter().any(|(writer, reader)| v.writer == *writer && v.reader == *reader) } +/// How many extra channels, after the merged transparency, the Alpha Identifiers resource (1053) +/// lists: its identifiers other than 0, which Photoshop gives the merged transparency (first, and +/// in some files with no transparency plane at all). 0 without a 1053. In a file from an earlier +/// PhotoCraft ([`earlier_photocraft_spot_planes`]) these are the channels of the Photoshop file +/// it opened, stored as Photoshop stored them; the channels after them were added there. +pub(crate) fn photoshop_identified_channels(file: &PsdFile) -> usize { + file.resource(crate::channel_map::ALPHA_IDENTIFIERS).map_or(0, |r| r.data.as_chunks::<4>().0.iter().filter(|id| **id != [0; 4]).count()) +} + /// Whether the import reads the merged composite: for a flattened or multichannel file (it *is* /// the image), a file without layers, or extra alpha/spot channels behind the colour ones. pub(crate) fn merged_needed(file: &PsdFile) -> bool { @@ -1244,10 +1266,10 @@ pub(crate) fn psd_to_document_decoded_integrity( let names = names.get(skip..).unwrap_or_default(); // Which planes hold spot ink in Photoshop's polarity (0 = solid): the DisplayInfo records // the channel options are read from below, the transparency's first when `skip` is 1. - // A file from an earlier PhotoCraft stores them straight and is read as it was written. - let photoshop_spots = if earlier_photocraft_spot_planes(file) { - Vec::new() - } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { + // A file from an earlier PhotoCraft stores them straight and is read as it was written, + // past the channels of the Photoshop file it opened, if any. + let straight_from = if earlier_photocraft_spot_planes(file) { photoshop_identified_channels(file) } else { usize::MAX }; + let photoshop_spots = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { crate::channel_map::spot_flags(&r.data, true, skip) } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { crate::channel_map::spot_flags(&r.data, false, skip) @@ -1260,7 +1282,7 @@ pub(crate) fn psd_to_document_decoded_integrity( break; }; let mut raw = be_to_ne_plane(p, depth, n); - if photoshop_spots.get(k).copied().unwrap_or(false) { + if k < straight_from && photoshop_spots.get(k).copied().unwrap_or(false) { crate::channel_map::photoshop_spot_plane(&mut raw, depth); } let mut s = Surface::new(cx.mask_fmt); @@ -1454,7 +1476,7 @@ mod tests { doc.channels.iter().map(|c| c.spot.is_some()).collect() } - /// `doc` (opaque, 8 or 16-bit) saved the way PhotoCraft 0.1.0 to 0.11.1 saved it: spot planes + /// `doc` (opaque, 8 or 16-bit) saved the way PhotoCraft 0.1.0 to 0.16.0 saved it: spot planes /// straight (the maximum = solid ink), no 1053, and `version_info` as resource 1057. `padded` /// lays DisplayInfo out in the 14-byte records of 0.1.0 to 0.7.0. fn as_earlier_photocraft_saved(doc: &Document, version_info: Vec, padded: bool) -> Vec { @@ -1483,7 +1505,7 @@ mod tests { file.to_bytes().unwrap() } - /// PSDs that PhotoCraft 0.1.0 to 0.11.1 saved with spot channels store them straight. They + /// PSDs that PhotoCraft 0.1.0 to 0.16.0 saved with spot channels store them straight. They /// open with the ink they had in those versions (which read every plane straight), whichever /// 1057 they carry. Saved again, they get Photoshop's polarity and a 1053, and reopen with the /// same ink. (A 0.1.0 to 0.7.0 file's padded records misread every channel's options after @@ -1593,4 +1615,85 @@ mod tests { let alpha_only = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap(); assert!(!earlier_photocraft_spot_planes(&alpha_only), "alpha channels only"); } + + /// `doc` as an earlier PhotoCraft saved a Photoshop file it had opened with the first `kept` + /// of `doc`'s channels: it wrote back the Photoshop file's 1053, `stale_ids`, unchanged and + /// those channels' planes as Photoshop stored them (it had read and written them straight), + /// and stored the channels added after them straight. + fn as_earlier_photocraft_resaved(doc: &Document, stale_ids: &[u32], kept: usize) -> Vec { + let mut file = crate::psd_export::document_to_psd(doc); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS && r.id != ids::VERSION_INFO); + file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, stale_ids.iter().flat_map(|id| id.to_be_bytes()).collect())); + file.resources.push(ImageResource::new(ids::VERSION_INFO, version_info_2026(true))); + let mut planes = file.decode_merged().unwrap(); + let plane = planes.len() / usize::from(file.header.channels); + let first = usize::from(file.header.channels) - doc.channels.len(); + for (k, c) in doc.channels.iter().enumerate().skip(kept) { + if c.spot.is_some() { + planes[(first + k) * plane..(first + k + 1) * plane].iter_mut().for_each(|b| *b = !*b); + } + } + file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap(); + file.to_bytes().unwrap() + } + + /// An earlier PhotoCraft kept the 1053 of a Photoshop file it opened and wrote it back + /// unchanged, so a spot channel added there sits behind a 1053 that doesn't list it: a + /// transparent Photoshop document's `[0]` (the white plate of a DTF print, added before + /// saving), the `[0, 3]` of one with a spot channel already, or a `[0]` kept through a + /// flatten that removed the transparency. The added spot is stored straight and keeps its + /// ink; the Photoshop file's own spot keeps Photoshop's polarity. Saved again, both reopen + /// the same. + #[test] + fn a_spot_added_behind_a_kept_alpha_identifiers_resource_keeps_its_ink() { + let transparent = |names: &[&str]| { + let mut doc = channels_document(SampleType::U8); + doc.layers = vec![Layer::raster("Layer 1", doc.pixel_format())]; + doc.channels.retain(|c| names.contains(&c.name.as_str())); + doc + }; + let mut flattened = channels_document(SampleType::U8); + flattened.channels.retain(|c| c.name == "Spot A"); + let cases = [ + ("transparent, one spot added", transparent(&["Spot A"]), vec![0], 0), + ("transparent, a Photoshop spot and one added", transparent(&["Spot A", "Spot B"]), vec![0, 3], 1), + ("flattened, one spot added", flattened, vec![0], 0), + ]; + for (what, doc, stale_ids, kept) in cases { + let bytes = as_earlier_photocraft_resaved(&doc, &stale_ids, kept); + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(file.merged_has_alpha(), what.starts_with("transparent"), "{what}"); + assert!(earlier_photocraft_spot_planes(&file), "{what}"); + let opened = crate::import("old.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{what}: the ink earlier versions opened"); + assert_eq!(spots(&opened), spots(&doc), "{what}"); + let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap(); + let again = crate::import("again.psd", &saved.bytes).unwrap().document; + assert_eq!(ink(&again), ink(&doc), "{what}: saved again"); + } + } + + /// A 1053 that lists every extra channel, as Photoshop and this version write it, keeps the + /// file in Photoshop's polarity, with PhotoCraft's (Photoshop 2026's) 1057 too: a transparent + /// file with a spot, and Photoshop's own `[0]` in a file without a transparency plane. + #[test] + fn a_complete_alpha_identifiers_resource_keeps_photoshop_polarity() { + let mut doc = channels_document(SampleType::U8); + doc.layers = vec![Layer::raster("Layer 1", doc.pixel_format())]; + let bytes = as_earlier_photocraft_resaved(&doc, &[0, 3, 4, 5], 3); + let file = PsdFile::from_bytes(&bytes).unwrap(); + assert!(file.merged_has_alpha()); + assert!(!earlier_photocraft_spot_planes(&file)); + assert_eq!(ink(&crate::import("ps.psd", &bytes).unwrap().document), ink(&doc)); + let mut opaque = channels_document(SampleType::U8); + opaque.channels.clear(); + let mut file = PsdFile::from_bytes(&as_earlier_photocraft_resaved(&opaque, &[0], 0)).unwrap(); + file.resources.retain(|r| r.id != crate::channel_map::DISPLAY_INFO); + file.resources.push(ImageResource::new( + crate::channel_map::DISPLAY_INFO, + crate::channel_map::display_info(&channels_document(SampleType::U8).channels.iter().collect::>(), false), + )); + assert_eq!(photoshop_identified_channels(&file), 0, "0 is the transparency's"); + assert!(!earlier_photocraft_spot_planes(&file), "no channel left unlisted"); + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b6c3ee89fd..682b3881da 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2 with their warp: the bend presets, a custom mesh and, since #4112, Cylinder; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (53 mapped, among them Invert, the Distort filters but Shear, Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending, each layer's XMP packet (`layer.xmpMetadata`); alpha and spot channels as Photoshop stores them (spot ink 0 = solid, channel identifiers in 1053), and PSDs with spot channels from PhotoCraft 0.1.0–0.11.1, which stored the ink the other way round, keep their ink. Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2 with their warp: the bend presets, a custom mesh and, since #4112, Cylinder; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (53 mapped, among them Invert, the Distort filters but Shear, Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending, each layer's XMP packet (`layer.xmpMetadata`); alpha and spot channels as Photoshop stores them (spot ink 0 = solid, channel identifiers in 1053), and PSDs with spot channels from PhotoCraft 0.1.0–0.16.0, which stored the ink the other way round, keep their ink. Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order type blocks (#4758); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | @@ -142,7 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| -| 2026-10-23 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels and no 1053 whose 1057 is exactly what PhotoCraft 0.1.0–0.11.1 wrote is read as those versions stored it (straight), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) | +| 2026-10-23 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels whose 1057 is exactly what PhotoCraft 0.1.0–0.16.0 wrote, and with no 1053 or one kept from an opened Photoshop file that doesn't list every channel, is read as those versions stored it (straight past the channels that 1053 lists), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) | | 2026-10-23 | minor | TIFF: alpha and spot channels are written and read as extra samples (at most 16) with Photoshop's channel resources (1006, 1045, 1077, 1053), spot ink stored 0 = solid as Photoshop 27.11's TIFFs store it; PSD output is unchanged. FILE-215-7 done, FILE-215-17 partial (its TIFF part) (#7415, supersedes #3121) | | 2026-10-23 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice. A PSD save with an opaque composite drops any of these markers kept from the opened file, so its first alpha channel no longer reopens as transparency and vanishes (split from #7415; credit #3121) | | 2026-10-23 | minor | Amiga IFF write: Save As `.iff` stores a 24-plane ILBM (ByteRun1, alpha as a 1-bit mask plane), and 24-plane ILBMs open | diff --git a/docs/gaps.md b/docs/gaps.md index f80e40abe5..b6c859017f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -310,7 +310,7 @@ what an agent can't finish alone. .shc, .csh presets (.asl landed 2026-10-20) (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). - **Progress (2026-10-23, #7415 split):** PSD stores and reads spot channels as Photoshop does (ink - 0 = solid) and writes their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.11.1, which stored + 0 = solid) and writes their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.16.0, which stored spot ink the other way round, keep their ink. - **Progress (2026-10-23, #7415):** TIFF writes and reads alpha and spot channels as extra samples with Photoshop's names, ink colours and solidity (FILE-215-7 done; FILE-215-17's TIFF part). @@ -364,7 +364,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| -| 2026-10-23 | minor | G10: PSD stores and reads spot channels as Photoshop does (ink 0 = solid) with their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.11.1 keep their ink (#7415 split, part 4); FILE-215-17 stays partial; estimate unchanged | +| 2026-10-23 | minor | G10: PSD stores and reads spot channels as Photoshop does (ink 0 = solid) with their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.16.0 keep their ink (#7415 split, part 4); FILE-215-17 stays partial; estimate unchanged | | 2026-10-23 | minor | G10: TIFF alpha and spot channels written and read as extra samples with Photoshop's channel resources, PSD output unchanged (#7415); FILE-215-7 leaves the list, FILE-215-17 narrows to RGB/CMYK spot preview and PDF Separation | | 2026-10-23 | minor | G10: Amiga IFF write landed (24-plane ILBM, Save As `.iff`) and 24-plane ILBMs open | | 2026-10-22 | minor | G17: Help › Check for Updates and an opt-out check once per launch (#5485), DIST-220-1 partial; estimate unchanged | diff --git a/docs/scorecard.md b/docs/scorecard.md index 89a964116a..8fe2a9f751 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -122,7 +122,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | -| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.11.1 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | +| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index f3b5adbd17..977712b611 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -122,7 +122,7 @@ id = "FILE-215-17" target = "CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF)" status = "partial" issue = 215 -note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.11.1 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" +note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" [[item]] id = "FILE-215-18"