Files
photocraft/crates/codecs/tests/tiff_photoshop.rs
bd632e7eb3 Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215) (#672)
* Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215)

Photoshop keeps a layered TIFF's layers in two private tags: 37724 (ImageSourceData, the same
layer section a PSD has) and 34377 (the image resources). PhotoCraft now reads and writes both,
so a TIFF opens with its groups, masks, layer styles, type, shapes, smart objects and adjustment
layers exactly as the PSD path would, and saves them back.

* photocraft-psd: a `tiff` module with `ImageSourceData` (parse/write, PSD- and PSB-sized),
  the 34377 resource helpers, and a field-by-field byte-order transcoder. In Intel-order TIFFs
  Photoshop byte-swaps the whole block structure (reversed keys, little-endian integers and
  doubles) while pixel samples stay big-endian; the transcoder knows the layer-info structure,
  masks, blending ranges and every block photocraft-io maps (descriptors, TySh, lrFX, the
  binary adjustments, paths, patterns, filter effects, metadata settings, linked layers,
  annotations...), recognizes descriptor-shaped unknown blocks, and drops the rest with a
  warning instead of passing byte-swapped data to other readers. The reader gained an optional
  little-endian mode. 23 unit tests; a fuzz target in the CI matrix.
* photocraft-codecs: the two tags travel as opaque bytes on `Metadata`, read by a
  bounds-checked walk of IFD0 (classic and BigTIFF, both byte orders) and written back as
  BYTE / UNDEFINED tags. `tiff_writes_little_endian()` tells which order the encoder produces.
* photocraft-io: `tiff_layers` rebuilds a PSD model from the TIFF's pixels plus the tags on
  import, and on export runs the PSD exporter (new `merged_matte` / `merged_compression`
  options), interleaves its merged planes for the TIFF image and stores the layer section in
  the encoder's byte order. `ExportOptions.tiff_layers` (default on). Lab and Multichannel
  documents save flat; extra alpha channels are not written (one transparency sample).
* UI: the TIFF Options prompt ("Including layers will increase file size." / "Discard Layers
  and Save a Copy" / "Don't show again") behind Preferences > File Handling > Ask Before
  Saving Layered TIFF Files; `ExportSettings.tiff_layers`; catalogs updated.
* Tests: io round trips at 8/16/32 bits in Gray/RGB/CMYK, equivalence with the PSD path, every
  TIFF compression, the flat fall-backs, malformed input, and files written by psdtags in both
  byte orders (fixtures from scripts/layered_tiff_fixtures.py); a corpus test (feature
  `corpus`) writes every corpus PSD as a layered TIFF and compares the result with the PSD
  round trip (700 files, 0 differences, 0 crashes). Codecs tests cover the tag walk on
  hand-made files.
* Scorecard FILE-215-7 done, BUG-204-3 partial; README and book updated.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* Layered TIFF: de and pt-br strings; neutral wording in the psd tiff docs

The four psdtags fixtures stay out of the repository (no binary test data here): the
generator now writes them to the gitignored corpus/layered-tiff/, and the tests that read
them skip with a message when absent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 17:10:35 -04:00

175 lines
7.3 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Photoshop's private TIFF tags (34377 image resources, 37724 ImageSourceData) travel through
//! `Metadata` as opaque bytes: written when present, read from either byte order and from
//! BigTIFF, and never a panic or an allocation on malformed input.
mod common;
use common::*;
use photocraft_codecs::*;
fn with_tags(layout: ChannelLayout, sample: SampleType) -> Image {
let mut img = synth(9, 7, layout, sample, 5, 0.3);
img.meta.photoshop_resources = Some(b"8BIM\x03\xed\x00\x00\x00\x00\x00\x10\x00\x48\x00\x00\x00\x01\x00\x01\x00\x48\x00\x00\x00\x01\x00\x01".to_vec());
let mut layers = b"Adobe Photoshop Document Data Block\0".to_vec();
layers.extend_from_slice(b"MIB8ryaL");
layers.extend_from_slice(&4u32.to_le_bytes());
layers.extend_from_slice(&[0, 0, 0, 0]);
img.meta.photoshop_layers = Some(layers);
img
}
#[test]
fn tags_round_trip_through_the_encoder() {
for (layout, sample) in [
(ChannelLayout::Rgb, SampleType::U8),
(ChannelLayout::Rgba, SampleType::U16),
(ChannelLayout::Gray, SampleType::F32),
(ChannelLayout::CmykA, SampleType::U8),
] {
for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::Lzw, TiffCompression::PackBits] {
let img = with_tags(layout, sample);
let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap();
// The encoder writes the running target's byte order.
assert_eq!(bytes.starts_with(b"II"), tiff_writes_little_endian());
let (res, layers) = tiff_photoshop_tags(&bytes);
assert_eq!(res, img.meta.photoshop_resources.as_deref());
assert_eq!(layers, img.meta.photoshop_layers.as_deref());
let back = decode(&bytes).unwrap();
assert_eq!(back.meta.photoshop_resources, img.meta.photoshop_resources, "{layout:?} {sample:?} {compression:?}");
assert_eq!(back.meta.photoshop_layers, img.meta.photoshop_layers);
assert_eq!(back.data(), img.data());
}
}
}
#[test]
fn absent_and_empty_tags_are_not_written() {
let mut img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
assert_eq!(tiff_photoshop_tags(&bytes), (None, None));
assert!(decode(&bytes).unwrap().meta.photoshop_layers.is_none());
img.meta.photoshop_layers = Some(Vec::new());
img.meta.photoshop_resources = Some(Vec::new());
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
assert_eq!(tiff_photoshop_tags(&bytes), (None, None));
}
/// A minimal uncompressed 2×1 gray TIFF with a 37724 tag, in either byte order, classic or
/// BigTIFF: `payload` is stored inline when it fits the value field, at an offset otherwise.
fn hand_made(little: bool, big: bool, payload: &[u8]) -> Vec<u8> {
let w16 = |v: u16| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
let w32 = |v: u32| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
let w64 = |v: u64| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
let mut f = Vec::new();
f.extend_from_slice(if little { b"II" } else { b"MM" });
let header_len = if big { 16 } else { 8 };
let pixels_at = header_len;
let ifd_at = pixels_at + 2;
f.extend(w16(if big { 43 } else { 42 }));
if big {
f.extend(w16(8));
f.extend(w16(0));
f.extend(w64(ifd_at as u64));
} else {
f.extend(w32(ifd_at as u32));
}
f.extend_from_slice(&[0x40, 0xc0]);
// Entries: width, length, bits, compression, photometric, strip offsets, samples, rows per
// strip, strip byte counts, 37724.
let tags: [(u16, u16, u64, u64); 10] = [
(256, 3, 1, 2),
(257, 3, 1, 1),
(258, 3, 1, 8),
(259, 3, 1, 1),
(262, 3, 1, 1),
(273, 4, 1, pixels_at as u64),
(277, 3, 1, 1),
(278, 3, 1, 1),
(279, 4, 1, 2),
(37724, 7, payload.len() as u64, 0),
];
let entry_len = if big { 20 } else { 12 };
let inline = if big { 8 } else { 4 };
let count_len = if big { 8 } else { 2 };
let next_len = if big { 8 } else { 4 };
let data_at = ifd_at + count_len + tags.len() * entry_len + next_len;
let mut entries = Vec::new();
for (tag, ty, count, value) in tags {
entries.extend(w16(tag));
entries.extend(w16(ty));
entries.extend(if big { w64(count) } else { w32(count as u32) });
let mut v = Vec::new();
if tag == 37724 {
if payload.len() <= inline {
v.extend_from_slice(payload);
} else {
v.extend(if big { w64(data_at as u64) } else { w32(data_at as u32) });
}
} else if ty == 3 {
v.extend(w16(value as u16));
} else {
v.extend(if big { w64(value) } else { w32(value as u32) });
}
v.resize(inline, 0);
entries.extend(v);
}
f.extend(if big { w64(tags.len() as u64) } else { w16(tags.len() as u16) });
f.extend(entries);
f.extend(if big { w64(0) } else { w32(0) });
assert_eq!(f.len(), data_at);
if payload.len() > inline {
f.extend_from_slice(payload);
}
f
}
#[test]
fn hand_made_files_in_every_layout() {
let long = b"Adobe Photoshop Document Data Block\0MIB8".to_vec();
for little in [false, true] {
for big in [false, true] {
for payload in [&b"ab"[..], &b"abcd"[..], &b"abcdefgh"[..], &long[..]] {
let f = hand_made(little, big, payload);
let (res, layers) = tiff_photoshop_tags(&f);
assert_eq!(res, None);
assert_eq!(layers, Some(payload), "little {little} big {big} len {}", payload.len());
let img = decode(&f).unwrap();
assert_eq!(img.dimensions(), (2, 1));
assert_eq!(img.meta.photoshop_layers.as_deref(), Some(payload));
}
}
}
}
#[test]
fn malformed_tags_read_as_absent_without_panicking() {
let long = vec![7u8; 40];
for little in [false, true] {
for big in [false, true] {
let f = hand_made(little, big, &long);
// Truncated anywhere: never a panic; the payload is absent once it is cut.
for cut in 0..f.len() {
let (_, layers) = tiff_photoshop_tags(&f[..cut]);
if cut < f.len() {
assert!(layers.is_none_or(|l| l.len() == 40));
}
let _ = decode(&f[..cut]);
}
// Bit flips in the directory.
for i in 0..f.len() - 40 {
let mut g = f.clone();
g[i] ^= 0xff;
let _ = tiff_photoshop_tags(&g);
let _ = decode(&g);
}
}
}
assert_eq!(tiff_photoshop_tags(b""), (None, None));
assert_eq!(tiff_photoshop_tags(b"II*\0"), (None, None));
assert_eq!(tiff_photoshop_tags(b"MM\0*\xff\xff\xff\xff"), (None, None));
// A count that overflows the file.
let mut f = hand_made(true, false, &long);
let at = f.windows(2).position(|w| w == 37724u16.to_le_bytes()).unwrap();
f[at + 4..at + 8].copy_from_slice(&u32::MAX.to_le_bytes());
assert_eq!(tiff_photoshop_tags(&f).1, None);
}