mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge PR #8885: PSD: spot channels in Photoshop's polarity; PSDs from earlier PhotoCraft versions keep their ink
Merged with our fix-up branch pr-8885-fixup (8f6863f53) on the author's head996b55eb4. Conflicts resolved: - crates/io/src/tiff_layers.rs: #9148's tiff_source (an empty LMsk gets Photoshop's 14 bytes) gives way to #8885's tiff_image_source, which writes the same bytes for an empty or short LMsk; #9148's test stays. - crates/io/src/flat.rs: #8776's DDS alpha step and #8885's TIFF channel stamp both run. - crates/io/Cargo.toml, Cargo.lock: #8402's zune dev-deps and #8885's tiff dev-dep. - crates/io/tests/it/tiff_layers.rs, docs: unions; docs/scorecard.md kept as is, regenerated at the batch gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -5049,6 +5049,7 @@ dependencies = [
|
||||
"roxmltree 0.21.1",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-core 0.5.3",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -278,3 +278,7 @@ sync_github_name = false
|
||||
[people.erenova]
|
||||
display_name = "Erenova"
|
||||
sync_github_name = false
|
||||
|
||||
[people.jasallen]
|
||||
real_name = "Jason DeBoever"
|
||||
sync_github_name = true
|
||||
|
||||
+10
-3
@@ -224,8 +224,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.
|
||||
@@ -239,7 +243,10 @@ 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. 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
|
||||
|
||||
@@ -249,7 +249,9 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option<usize>, 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<Vec<[u16; 3]>>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
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<Image, CodecEr
|
||||
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
|
||||
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
|
||||
// position past the samples the file has is ignored.
|
||||
let color = match photometric {
|
||||
let color: usize = match photometric {
|
||||
2 | 8 | 9 => 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<u8>)> = Vec::new();
|
||||
let encode_u = |v: u64, len: usize| -> Vec<u8> {
|
||||
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<Image, CodecEr
|
||||
_ if palette.is_some() => (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<Image, CodecEr
|
||||
.and_then(|s| s.checked_mul(src.bytes()))
|
||||
.ok_or_else(|| CodecError::LimitExceeded("tile size".into()))?;
|
||||
let scratch_cap = chunk_bytes.saturating_mul(2).max(64 << 20).min(limits.alloc_usize());
|
||||
let extra_at = extra_sample_indices(spp as usize, color, alpha_at, out_layout.has_alpha());
|
||||
let lay = Layout {
|
||||
w: wu,
|
||||
src,
|
||||
@@ -677,7 +712,14 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
premultiplied: out_layout.has_alpha() && alpha_at.and_then(|a| extra.get(a)) == Some(&1),
|
||||
palette,
|
||||
scratch_cap,
|
||||
extra_at,
|
||||
};
|
||||
// Extra samples share the allocation budget with the image: their interleaved buffer and the
|
||||
// planes split from it are held next to the image, so both count against `max_alloc`.
|
||||
if !lay.extra_at.is_empty() {
|
||||
let per_pixel = out_layout.channels().saturating_add(lay.extra_at.len().saturating_mul(2)).saturating_mul(out.bytes());
|
||||
limits.check_bytes(w, h, per_pixel as u64)?;
|
||||
}
|
||||
|
||||
// The output, allocated once and fallibly.
|
||||
let out_row = lay.out_row();
|
||||
@@ -685,7 +727,11 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
let mut data = zeroed(len)?;
|
||||
let band_bytes = out_row.checked_mul(ch.min(hu)).ok_or_else(|| CodecError::LimitExceeded("band size".into()))?;
|
||||
|
||||
let tally = decode_bands(&lay, &mut data, band_bytes, &mut dec, &open)?;
|
||||
let extra_row = lay.extra_row();
|
||||
let extra_len = extra_row.checked_mul(hu).ok_or_else(|| CodecError::LimitExceeded("extra samples".into()))?;
|
||||
let mut extra_data = if lay.extra_at.is_empty() { Vec::new() } else { zeroed(extra_len)? };
|
||||
let extra_band = extra_row.saturating_mul(ch.min(hu));
|
||||
let tally = decode_bands(&lay, &mut data, band_bytes, extra_data.as_mut_slice(), extra_band, &mut dec, &open)?;
|
||||
if tally.decoded == 0 && tally.missing > 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<Image, CodecEr
|
||||
convert_ab(&mut data, out_layout.channels(), out, photometric == 8, false);
|
||||
}
|
||||
let mut img = Image::from_raw(w, h, out_layout, out, data)?;
|
||||
if !lay.extra_at.is_empty() {
|
||||
let kind_of = |si: usize| si.checked_sub(color).and_then(|k| extra.get(k)).copied().unwrap_or(0) as u16;
|
||||
img.meta.extra_samples = split_extra_samples(&extra_data, wu, hu, &lay.extra_at, out, kind_of)?;
|
||||
}
|
||||
if tally.missing > 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<usize>, has_alpha: bool) -> Vec<usize> {
|
||||
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<Vec<crate::ExtraSample>, 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<Patched<'a>>, open: &O) -> Result<Tally, CodecError>
|
||||
fn decode_bands<'a, O>(
|
||||
lay: &Layout,
|
||||
data: &mut [u8],
|
||||
band_bytes: usize,
|
||||
extra: &mut [u8],
|
||||
extra_band: usize,
|
||||
_first: &mut Decoder<Patched<'a>>,
|
||||
open: &O,
|
||||
) -> Result<Tally, CodecError>
|
||||
where
|
||||
O: Fn() -> Result<Decoder<Patched<'a>>, 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<Patched<'a>>, _open: &O) -> Result<Tally, CodecError>
|
||||
fn decode_bands<'a, O>(
|
||||
lay: &Layout,
|
||||
data: &mut [u8],
|
||||
band_bytes: usize,
|
||||
extra: &mut [u8],
|
||||
extra_band: usize,
|
||||
first: &mut Decoder<Patched<'a>>,
|
||||
_open: &O,
|
||||
) -> Result<Tally, CodecError>
|
||||
where
|
||||
O: Fn() -> Result<Decoder<Patched<'a>>, 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<Patched<'_>>, scratch: &mut Vec<u8>, b: usize, band: &mut [u8]) -> Result<Tally, CodecError> {
|
||||
fn decode_band(
|
||||
lay: &Layout,
|
||||
dec: &mut Decoder<Patched<'_>>,
|
||||
scratch: &mut Vec<u8>,
|
||||
b: usize,
|
||||
band: &mut [u8],
|
||||
mut extra: Option<&mut [u8]>,
|
||||
) -> Result<Tally, CodecError> {
|
||||
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<Patched<'_>>, scratch: &mut Vec<u
|
||||
}
|
||||
let x0 = col * lay.cw;
|
||||
let n = lay.cw.min(lay.w.saturating_sub(x0));
|
||||
for (src_row, dst_row) in buf.chunks_exact(stride).zip(band.chunks_exact_mut(out_row)).take(rows) {
|
||||
for (ri, (src_row, dst_row)) in buf.chunks_exact(stride).zip(band.chunks_exact_mut(out_row)).take(rows).enumerate() {
|
||||
let Some(dst) = dst_row.get_mut(x0 * px..(x0 + n) * px) else { continue };
|
||||
convert_row(lay, src_row, dst, n, plane);
|
||||
if !lay.planar && plane == 0 && !lay.extra_at.is_empty() {
|
||||
let erow = lay.extra_row();
|
||||
if let Some(extra) = extra.as_deref_mut()
|
||||
&& let Some(er) = extra.get_mut(ri.saturating_mul(erow)..ri.saturating_mul(erow).saturating_add(erow))
|
||||
{
|
||||
deposit_extras(lay, src_row, er, n, x0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if lay.planar && !lay.extra_at.is_empty() {
|
||||
read_planar_extras(lay, dec, scratch, b, rows, extra, &mut tally)?;
|
||||
}
|
||||
finish_band(lay, band);
|
||||
Ok(tally)
|
||||
}
|
||||
|
||||
/// Copies the non-colour samples of a chunky row into the extra-sample band.
|
||||
fn deposit_extras(lay: &Layout, src: &[u8], dst: &mut [u8], n: usize, x0: usize) {
|
||||
let ech = lay.extra_at.len();
|
||||
if ech == 0 {
|
||||
return;
|
||||
}
|
||||
let px = ech * lay.out.bytes();
|
||||
let Some(dst) = dst.get_mut(x0.saturating_mul(px)..(x0.saturating_add(n)).saturating_mul(px)) else { return };
|
||||
for (ei, &si) in lay.extra_at.iter().enumerate() {
|
||||
transfer(lay.src, src, lay.chunk_spp, si, dst, lay.out, ech, ei, n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Planar files store each extra sample as its own plane. Read those planes into `extra`.
|
||||
fn read_planar_extras(
|
||||
lay: &Layout,
|
||||
dec: &mut Decoder<Patched<'_>>,
|
||||
scratch: &mut Vec<u8>,
|
||||
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,230 @@ 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<Self>, samples: usize);
|
||||
}
|
||||
|
||||
impl ExtraKind for u8 {
|
||||
const FORMAT: SampleFormat = SampleFormat::Uint;
|
||||
fn predict(row: &[Self], out: &mut Vec<Self>, 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<Self>, 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<Self>, _samples: usize) {
|
||||
out.extend_from_slice(row);
|
||||
}
|
||||
}
|
||||
|
||||
struct Extras<T, const PHOTO: u16, const BITS: u16, const N: usize>(std::marker::PhantomData<T>);
|
||||
|
||||
impl<T, const PHOTO: u16, const BITS: u16, const N: usize> TiffColorType for Extras<T, PHOTO, BITS, N>
|
||||
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<Self::Inner>) {
|
||||
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::<Extras<$sample, $photo, $bits, { $base + 1 }>, K>($enc, $w, $h, $data, $tags),
|
||||
2 => write_one::<Extras<$sample, $photo, $bits, { $base + 2 }>, K>($enc, $w, $h, $data, $tags),
|
||||
3 => write_one::<Extras<$sample, $photo, $bits, { $base + 3 }>, K>($enc, $w, $h, $data, $tags),
|
||||
4 => write_one::<Extras<$sample, $photo, $bits, { $base + 4 }>, K>($enc, $w, $h, $data, $tags),
|
||||
5 => write_one::<Extras<$sample, $photo, $bits, { $base + 5 }>, K>($enc, $w, $h, $data, $tags),
|
||||
6 => write_one::<Extras<$sample, $photo, $bits, { $base + 6 }>, K>($enc, $w, $h, $data, $tags),
|
||||
7 => write_one::<Extras<$sample, $photo, $bits, { $base + 7 }>, K>($enc, $w, $h, $data, $tags),
|
||||
8 => write_one::<Extras<$sample, $photo, $bits, { $base + 8 }>, K>($enc, $w, $h, $data, $tags),
|
||||
9 => write_one::<Extras<$sample, $photo, $bits, { $base + 9 }>, K>($enc, $w, $h, $data, $tags),
|
||||
10 => write_one::<Extras<$sample, $photo, $bits, { $base + 10 }>, K>($enc, $w, $h, $data, $tags),
|
||||
11 => write_one::<Extras<$sample, $photo, $bits, { $base + 11 }>, K>($enc, $w, $h, $data, $tags),
|
||||
12 => write_one::<Extras<$sample, $photo, $bits, { $base + 12 }>, K>($enc, $w, $h, $data, $tags),
|
||||
13 => write_one::<Extras<$sample, $photo, $bits, { $base + 13 }>, K>($enc, $w, $h, $data, $tags),
|
||||
14 => write_one::<Extras<$sample, $photo, $bits, { $base + 14 }>, K>($enc, $w, $h, $data, $tags),
|
||||
15 => write_one::<Extras<$sample, $photo, $bits, { $base + 15 }>, K>($enc, $w, $h, $data, $tags),
|
||||
16 => write_one::<Extras<$sample, $photo, $bits, { $base + 16 }>, K>($enc, $w, $h, $data, $tags),
|
||||
other => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({other})")).into()),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// 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<T>(len: usize) -> io::Result<Vec<T>> {
|
||||
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_u16(b: &[u8]) -> io::Result<Vec<u16>> {
|
||||
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<Vec<f32>> {
|
||||
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`].
|
||||
fn interleave_extras(img: &Image) -> Result<Vec<u8>, 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 len = n.checked_mul(px).ok_or_else(|| "image size overflows".to_string())?;
|
||||
let mut out = try_with_capacity::<u8>(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 {
|
||||
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<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, 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 +1522,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 +1553,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 +1585,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<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, 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::<K>(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 +1627,9 @@ fn write_image<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img:
|
||||
(L::CmykA, S::U16) => write_one::<CmykA16, K>(enc, w, h, &u16s(), tags),
|
||||
(L::CmykA, S::F32) => write_one::<CmykA32F, K>(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::<Lab8, K>(enc, w, h, file.data(), tags),
|
||||
(L::Lab, _) => write_one::<Lab16, K>(enc, w, h, &u16s(), tags),
|
||||
(_, S::U8) => write_one::<LabA8, K>(enc, w, h, file.data(), tags),
|
||||
@@ -1198,11 +1651,48 @@ 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::<u64>();
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
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<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
// 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)
|
||||
}
|
||||
|
||||
fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let compression = match opts.tiff_compression {
|
||||
TiffCompression::None => tiff::encoder::Compression::Uncompressed,
|
||||
TiffCompression::Lzw => tiff::encoder::Compression::Lzw,
|
||||
@@ -1226,13 +1716,28 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
};
|
||||
// TIFF writes no Orientation tag (= 1): keep the XMP from contradicting it.
|
||||
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
|
||||
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)));
|
||||
}
|
||||
// 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() {
|
||||
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 +1750,48 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
if big {
|
||||
let mut enc = TiffEncoder::new_big(&mut cursor)?.with_compression(compression).with_predictor(predictor);
|
||||
write_image::<TiffKindBig>(&mut enc, &img, &tags)?;
|
||||
write_image::<TiffKindBig>(&mut enc, img, &tags)?;
|
||||
} else {
|
||||
let mut enc = TiffEncoder::new(&mut cursor)?.with_compression(compression).with_predictor(predictor);
|
||||
write_image::<TiffKindStandard>(&mut enc, &img, &tags)?;
|
||||
write_image::<TiffKindStandard>(&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!(<Extras<u8, 2, 8, 4> as TiffColorType>::BITS_PER_SAMPLE.len(), 4, "{:?}", <Extras<u8, 2, 8, 4> as TiffColorType>::BITS_PER_SAMPLE);
|
||||
assert_eq!(<Extras<u8, 2, 8, 5> as TiffColorType>::BITS_PER_SAMPLE.len(), 5);
|
||||
assert_eq!(<Extras<u8, 2, 8, 19> as TiffColorType>::BITS_PER_SAMPLE.len(), 19);
|
||||
assert_eq!(<Extras<u16, 5, 16, 6> 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::<u8>(usize::MAX).unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::OutOfMemory);
|
||||
assert!(try_with_capacity::<u16>(usize::MAX / 2).is_err());
|
||||
assert!(try_with_capacity::<f32>(64).is_ok_and(|v| v.is_empty() && v.capacity() >= 64));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
/// 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<ExtraSample>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +661,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,
|
||||
@@ -650,7 +680,7 @@ impl Image {
|
||||
data,
|
||||
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
|
||||
cicp: if layout.same_model(self.layout) { self.cicp } else { None },
|
||||
meta: self.meta.clone(),
|
||||
meta,
|
||||
warnings: self.warnings.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,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, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<u8> = 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);
|
||||
|
||||
@@ -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<f32> =
|
||||
[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<u8> = (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<f32> = [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<u8> = [16384u16, 65535].iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: spot }]);
|
||||
}
|
||||
|
||||
@@ -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<u8> { 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]
|
||||
|
||||
@@ -41,6 +41,225 @@ 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<u8> = (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::<u32>(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<u8> {
|
||||
let mut bytes = src.to_vec();
|
||||
let le = bytes.first().copied() == Some(b'I');
|
||||
let ru16 = |b: &[u8], i: usize| -> Option<u16> {
|
||||
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<u32> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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<u8> = (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<f32> = 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);
|
||||
|
||||
@@ -59,3 +59,4 @@ proptest = { workspace = true }
|
||||
# Lossless JPEG XL encoder for the JPEG XL DNG preview test (tests only), as in photocraft-raw.
|
||||
zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] }
|
||||
zune-core = { workspace = true }
|
||||
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
//! 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).
|
||||
//! 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;
|
||||
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 +23,33 @@ const SPACE_CMYK: u16 = 2;
|
||||
const SPACE_LAB: u16 = 7;
|
||||
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 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);
|
||||
}
|
||||
|
||||
/// 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. `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<u8> {
|
||||
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());
|
||||
}
|
||||
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 +76,33 @@ pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Ve
|
||||
out
|
||||
}
|
||||
|
||||
/// 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 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<bool> {
|
||||
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).
|
||||
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)) {
|
||||
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);
|
||||
@@ -149,6 +200,15 @@ 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!(spot_flags(&bytes, true, 0), [false, true]);
|
||||
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))];
|
||||
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);
|
||||
@@ -156,10 +216,79 @@ 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.
|
||||
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<AlphaChannel> = (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));
|
||||
}
|
||||
|
||||
/// 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::<Vec<_>>();
|
||||
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]
|
||||
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<u8> = [65535u16, 16448, 0].iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
photoshop_spot_plane(&mut p16, SampleType::U16);
|
||||
let back: Vec<u16> = p16.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).collect();
|
||||
assert_eq!(back, [0, 49087, 65535]);
|
||||
let mut p32: Vec<u8> = [1.0f32, 0.25].iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
photoshop_spot_plane(&mut p32, SampleType::F32);
|
||||
let back: Vec<f32> = 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]
|
||||
|
||||
@@ -280,6 +280,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
|
||||
bg
|
||||
};
|
||||
doc.layers.push(layer);
|
||||
warnings.extend(crate::tiff_layers::channels_from_extras(&mut doc, img));
|
||||
doc.icc_profile = img.icc.clone().map(Arc::new);
|
||||
doc.metadata.exif = img.meta.exif.clone().map(Arc::new);
|
||||
doc.metadata.xmp = img.meta.xmp.as_deref().map(Arc::from);
|
||||
@@ -671,6 +672,9 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti
|
||||
if format == Format::Dds {
|
||||
img = dds_image(doc, img, &mut warnings)?;
|
||||
}
|
||||
if format == Format::Tiff {
|
||||
warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img));
|
||||
}
|
||||
encode_image(&img, format, opts, warnings)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@ pub fn sample_for_depth(depth: u16) -> SampleType {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
fn invert_sample(bytes: &mut [u8], s: SampleType) {
|
||||
match s {
|
||||
SampleType::U8 => bytes[0] = 255 - bytes[0],
|
||||
|
||||
+113
-3
@@ -1270,9 +1270,12 @@ pub(crate) fn pascal_names_resource(names: &[&str]) -> Vec<u8> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1824,7 +1827,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);
|
||||
let mut bytes = s.to_interleaved(canvas);
|
||||
// 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));
|
||||
}
|
||||
let channels = (cc + usize::from(has_alpha) + extra.len()) as u16;
|
||||
@@ -1856,6 +1863,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();
|
||||
@@ -1938,6 +1948,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
if !keep_imported_resource(doc, *id, data) {
|
||||
continue;
|
||||
}
|
||||
// 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());
|
||||
r.name = legacy_name(name);
|
||||
resources.push(r);
|
||||
@@ -2065,6 +2081,100 @@ 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
|
||||
}
|
||||
|
||||
/// The extra planes after the colour channels (and the merged transparency) of `file`.
|
||||
fn extra_planes(file: &PsdFile, count: usize) -> Vec<u8> {
|
||||
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<Vec<u32>> {
|
||||
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_ink_as_photoshop_does() {
|
||||
let doc = spot_and_alpha_document();
|
||||
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<u8> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_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 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() {
|
||||
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(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]
|
||||
fn group_divider_id_wraps_without_colliding_with_imported_layer_ids() {
|
||||
let mut doc = document(8, 8);
|
||||
|
||||
+350
-3
@@ -731,7 +731,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec<u8> {
|
||||
interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false])
|
||||
}
|
||||
|
||||
fn unicode_names(data: &[u8]) -> Vec<String> {
|
||||
pub(crate) fn unicode_names(data: &[u8]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
while at + 4 <= data.len() {
|
||||
@@ -883,6 +883,69 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
psd_to_document_decoded(file, ctl, None)
|
||||
}
|
||||
|
||||
/// 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), 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.
|
||||
///
|
||||
/// 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; 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
|
||||
/// 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));
|
||||
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 };
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -972,6 +1035,10 @@ pub(crate) fn psd_to_document_decoded_integrity(
|
||||
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()))),
|
||||
}
|
||||
@@ -1204,13 +1271,29 @@ pub(crate) fn psd_to_document_decoded_integrity(
|
||||
// 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 (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,
|
||||
// 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)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for (k, idx) in (first..usize::from(h.channels)).enumerate() {
|
||||
let Some(p) = merged_plane(all, idx, plane) else {
|
||||
cx.warn_unreadable_data(format!("merged extra channel {idx} is incomplete and was not imported"));
|
||||
break;
|
||||
};
|
||||
let mut raw = be_to_ne_plane(p, depth, n);
|
||||
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);
|
||||
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));
|
||||
@@ -1252,7 +1335,9 @@ pub(crate) fn psd_to_document_decoded_integrity(
|
||||
#[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).
|
||||
@@ -1356,4 +1441,266 @@ 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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<Vec<u8>> {
|
||||
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<bool> {
|
||||
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.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<u8>, padded: bool) -> Vec<u8> {
|
||||
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.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
|
||||
/// 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 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,
|
||||
/// 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");
|
||||
}
|
||||
|
||||
/// `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<u8> {
|
||||
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::<Vec<_>>(), false),
|
||||
));
|
||||
assert_eq!(photoshop_identified_channels(&file), 0, "0 is the transparency's");
|
||||
assert!(!earlier_photocraft_spot_planes(&file), "no channel left unlisted");
|
||||
}
|
||||
}
|
||||
|
||||
+662
-29
@@ -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, PsdFile, ResolutionInfo};
|
||||
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,13 +103,36 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<I
|
||||
CSample::F16 | CSample::F32 => 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)?;
|
||||
// Spot planes are read in Photoshop's polarity (0 = solid ink), as from a PSD. The importer's
|
||||
// rule for PSDs from earlier PhotoCraft versions never applies: those versions stored no
|
||||
// extra samples in a TIFF, and a TIFF with them carries 1053.
|
||||
let (mut doc, w) = psd_to_document(&file);
|
||||
warnings.extend(w);
|
||||
doc.name = name.to_string();
|
||||
@@ -200,19 +225,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(),
|
||||
@@ -234,7 +264,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 tiff_source(&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));
|
||||
@@ -242,7 +272,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;
|
||||
tiff_source(&file).to_bytes(order)?
|
||||
tiff_image_source(doc, &file).to_bytes(order)?
|
||||
}
|
||||
r => r?,
|
||||
};
|
||||
@@ -252,24 +282,60 @@ 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<ImageResource> = 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<ImageResource> = 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. The PSD model's 1053 lists the merged
|
||||
// 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)));
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
// 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);
|
||||
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 {
|
||||
@@ -282,11 +348,180 @@ 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. 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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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<u8> {
|
||||
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<u8> {
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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<ImageResource>, 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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<photocraft_psd::TaggedBlock>) {
|
||||
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
|
||||
@@ -316,18 +551,416 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec<Str
|
||||
warnings
|
||||
}
|
||||
|
||||
/// The global layer mask Photoshop writes when none is set (big-endian, as [`ImageSourceData`]
|
||||
/// holds it): overlay colour space 0, colour (65535, 0, 0, 0), opacity 50, kind 128 and a filler.
|
||||
const PHOTOSHOP_GLOBAL_MASK: [u8; 14] = [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0];
|
||||
|
||||
/// The PSD model's layer data for tag 37724. An empty `LMsk` block is fine in a PSD, but
|
||||
/// Photoshop rejects it in a TIFF and opens only the composite (#9095), so it gets Photoshop's own.
|
||||
fn tiff_source(file: &PsdFile) -> ImageSourceData {
|
||||
let mut d = ImageSourceData::from_psd(file);
|
||||
if let Some(m) = d.global_layer_mask.as_mut().filter(|m| m.data.is_empty()) {
|
||||
m.data = PHOTOSHOP_GLOBAL_MASK.to_vec();
|
||||
/// Big-endian planar samples (a PSD merged-image plane) to native endian, or the reverse.
|
||||
fn swap_plane(src: &[u8], bps: usize, to_be: bool) -> Result<Vec<u8>, IoError> {
|
||||
if bps == 1 {
|
||||
return Ok(src.to_vec());
|
||||
}
|
||||
d
|
||||
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<Vec<u8>, 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<Vec<codecs::ExtraSample>, 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<Vec<u8>, 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));
|
||||
}
|
||||
if channel.spot.is_some() {
|
||||
crate::channel_map::photoshop_spot_plane(&mut bytes, depth);
|
||||
}
|
||||
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<u8>) -> 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<u16> {
|
||||
let arr: [u8; 2] = b.get(..2)?.try_into().ok()?;
|
||||
Some(u16::from_ne_bytes(arr))
|
||||
}
|
||||
|
||||
fn ne_f32(b: &[u8]) -> Option<f32> {
|
||||
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<String> {
|
||||
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 };
|
||||
match channel_resources(img.meta.photoshop_resources.as_deref(), &names, &picked, quick_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<u16>) -> Result<Vec<u8>, 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(false, 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<String> {
|
||||
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 spots = if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) {
|
||||
crate::channel_map::spot_flags(&r.data, true, 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, skip)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
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 spot = spots.get(k).copied().unwrap_or(false);
|
||||
match mask_from_plane(&e.data, w, h, n_px, img.sample_type(), doc.depth, spot) {
|
||||
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.
|
||||
/// `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<Surface, String> {
|
||||
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 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());
|
||||
}
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false));
|
||||
s.write_interleaved(rect, &bytes);
|
||||
s.prune();
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +401,622 @@ 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<u16>, Vec<u16>, u32, Vec<u8>) {
|
||||
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::<u32>(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<u8> {
|
||||
let mut bytes = src.to_vec();
|
||||
let le = bytes.first().copied() == Some(b'I');
|
||||
let ru16 = |b: &[u8], i: usize| -> Option<u16> {
|
||||
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<u32> {
|
||||
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<u8> = 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");
|
||||
// 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;
|
||||
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<u8> {
|
||||
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<u8> = (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<Vec<u8>> = 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). 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};
|
||||
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");
|
||||
// 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");
|
||||
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] {
|
||||
// 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.
|
||||
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");
|
||||
// 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_eq!(psd.merged_has_alpha(), layers, "{layers} tiff to psd");
|
||||
let want: Vec<u32> = 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::<Vec<_>>(), [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<u32> {
|
||||
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<u32> {
|
||||
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) {
|
||||
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<u8> = 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.
|
||||
@@ -454,3 +1068,56 @@ fn layered_tiff_writes_photoshops_global_layer_mask() {
|
||||
assert_eq!(mask.data, [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0], "{depth:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String> = (1..=16).map(|i| format!("Alpha {i}")).collect();
|
||||
assert_eq!(names, want.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
assert!(back.quick_mask.is_none());
|
||||
}
|
||||
|
||||
@@ -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<u16> {
|
||||
self.data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; 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; 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; Amiga IFF write: 24-plane ILBM, Save As `.iff`) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; 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; 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; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; 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; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **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,10 +21,10 @@ 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`). Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/258. 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/258. 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 on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (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, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); 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) |
|
||||
| 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); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). 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. No Matte, progressive Scans (3–5) or size preview |
|
||||
| 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 |
|
||||
@@ -144,6 +144,8 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Layered TIFF: the `LMsk` block in tag 37724 carries Photoshop's default global layer mask (14 bytes) instead of none; with an empty block Photoshop read only the composite (#9095). PSD output unchanged |
|
||||
| 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. |
|
||||
| 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 |
|
||||
| 2026-10-22 | minor | 32-bit Hue/Saturation as Photoshop does it: the raised saturation isn't clipped (saturated colours leave 0..1), the chroma is scaled around the lightness by Photoshop's saturation factor (measured at every whole percent), so colours at or above lightness 1 keep their hue, and Colorize builds its colour with the saturation times 1 − \|Lightness\| (8 and 16 bits unchanged): photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) |
|
||||
|
||||
+10
-2
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# PhotoCraft vs Adobe Photoshop: target-app parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; 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-23 · **Change:** minor (Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; 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; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **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; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); type on a path only places glyphs rigidly (TYPE-218-1 partial); no RTL paragraphs, OpenType panel UI; 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); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); 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 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); compound layer properties export as separate Set events with rename last (#8304, 2026-10-21; independent Photoshop replay pending); no ExtendScript/UXP |
|
||||
| File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) |
|
||||
@@ -574,6 +574,8 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | 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-23 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged |
|
||||
| 2026-10-22 | minor | Platforms: Help › Check for Updates and an opt-out check once per launch (#5485), DIST-220-1 partial; percentages unchanged |
|
||||
| 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged |
|
||||
| 2026-10-19 | minor | JPEG Options dialog landed (FILE-215-19 partial: no Matte, progressive Scans or size preview); percentages unchanged |
|
||||
|
||||
@@ -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). 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"
|
||||
|
||||
Reference in New Issue
Block a user