mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 22:30:20 -06:00
* 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>
175 lines
7.3 KiB
Rust
175 lines
7.3 KiB
Rust
//! 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);
|
||
}
|