Files
photocraft/crates/io/tests/tiff_corpus.rs
T
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 <[email protected]>

* 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 <[email protected]>

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Mythos 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 17:10:35 -04:00

142 lines
6.7 KiB
Rust

//! Layered TIFF against the real-file corpora (feature `corpus`; `cargo xtask test-corpus`):
//! every PSD that imports is written as a layered TIFF and read back, and the result must equal
//! the PSD round trip of the same document: the same layer structure (count, names, kinds) and
//! the same render (within one 8-bit step). The layers go through the same PSD mapping in both
//! directions, so any difference is a bug in the layer-data transcoder or the TIFF glue, not in
//! the mapping. Documents a TIFF stores flat (a lone Background, Multichannel) only compare the
//! render. A panic anywhere is a failure (Rule 9).
//!
//! The byte-order conversion is exercised for real: the TIFF encoder writes Intel order, so
//! every block of every corpus file (effects, type, smart objects, adjustments, paths, …) is
//! converted to little-endian on the way out and back on the way in. Blocks the transcoder
//! doesn't know are dropped with a warning; the test lists the keys it saw dropped, and fails
//! if any of them is one `photocraft-io` maps (a loss of fidelity we can fix).
#![cfg(feature = "corpus")]
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use photocraft_doc::{Document, Layer};
use photocraft_io::*;
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
collect(&p, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) {
out.push(p);
}
}
}
fn structure(layers: &[Layer], out: &mut Vec<String>, depth: usize) {
for l in layers {
out.push(format!(
"{}{} [{}] {:?} {:.3} mask={} fx={}",
" ".repeat(depth),
l.name,
l.content.kind_name(),
l.blend,
l.opacity,
l.mask.is_some(),
l.effects.items.len()
));
if let photocraft_doc::LayerContent::Group(g) = &l.content {
structure(&g.children, out, depth + 1);
}
}
}
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
a.iter().zip(b).map(|(p, q)| (0..4).map(|c| (p[c] * p[3] - q[c] * q[3]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
}
/// Keys the io crate reads from layer records or global blocks: dropping one of these loses
/// something a user can see.
const MAPPED: &[&[u8; 4]] = &[
b"lfx2", b"lmfx", b"lrFX", b"TySh", b"SoLd", b"SoLE", b"PlLd", b"vmsk", b"vsms", b"vogk", b"vscg", b"vstk", b"SoCo", b"GdFl", b"PtFl", b"levl", b"curv",
b"hue2", b"brit", b"thrs", b"post", b"expA", b"vibA", b"blnc", b"mixr", b"grdm", b"phfl", b"selc", b"blwh", b"clrL", b"luni", b"lsct", b"lyid", b"lclr",
b"iOpa", b"clbl", b"infx", b"knko", b"lspf", b"fxrp", b"brst", b"shmd", b"Patt", b"FEid", b"FXid", b"FMsk", b"artb", b"artd", b"cinf",
];
#[test]
fn corpus_round_trips_through_layered_tiff() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus");
assert!(root.join("psd").is_dir(), "corpus missing: run `cargo xtask corpus --all`");
let mut files = Vec::new();
for sub in ["psd", "psd-tools", "photoshop"] {
collect(&root.join(sub), &mut files);
}
files.sort();
assert!(!files.is_empty());
let (mut tested, mut crashes, mut failures) = (0usize, Vec::new(), Vec::new());
let mut dropped: std::collections::BTreeMap<String, usize> = Default::default();
for path in &files {
let name = path.strip_prefix(&root).unwrap_or(path).to_string_lossy().replace('\\', "/");
let Ok(bytes) = std::fs::read(path) else { continue };
let Ok(imported) = import(&name, &bytes) else { continue };
let doc = imported.document;
// Only modes a TIFF holds; everything else is saved flat by design. Multichannel documents
// have no layers at all (their channels are the image): nothing here to test.
if !matches!(doc.pixel_format().mode, photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Rgb | photocraft_color::ColorMode::Cmyk)
|| doc.mode == photocraft_color::ColorMode::Multichannel
{
continue;
}
if doc.size.width.max(doc.size.height) > 4096 {
continue;
}
tested += 1;
let res = catch_unwind(AssertUnwindSafe(|| -> Result<(), String> {
let psd = export(&doc, "x.psd", &ExportOptions::default()).map_err(|e| format!("psd export: {e}"))?;
let oracle = import("x.psd", &psd.bytes).map_err(|e| format!("psd re-import: {e}"))?.document;
let out = export(&doc, "x.tif", &ExportOptions::default()).map_err(|e| format!("export: {e}"))?;
for w in &out.warnings {
if let Some(rest) = w.strip_prefix("layer data: ")
&& let Some((key, _)) = rest.split_once(" block (")
{
*dropped.entry(key.to_string()).or_default() += 1;
if MAPPED.iter().any(|k| &key.as_bytes() == k) {
return Err(format!("mapped block dropped: {w}"));
}
}
}
let back = import("x.tif", &out.bytes).map_err(|e| format!("re-import: {e}"))?.document;
let flat = !tiff_layers::would_write_layers(&doc);
let (mut a, mut b) = (Vec::new(), Vec::new());
structure(&oracle.layers, &mut a, 0);
structure(&back.layers, &mut b, 0);
if a != b && !flat {
return Err(format!("layer structure changed:\n {}\n {}", a.join("\n "), b.join("\n ")));
}
let expected: Document = {
let mut d = oracle.clone();
d.channels.clear();
d
};
let x = photocraft_compose::flatten(&expected).px;
let y = photocraft_compose::flatten(&back).px;
let diff = max_diff(&x, &y);
if diff > 1.0 / 255.0 + 1e-5 {
return Err(format!("render differs by {diff}"));
}
Ok(())
}));
match res {
Ok(Ok(())) => {}
Ok(Err(e)) => failures.push(format!("{name}: {e}")),
Err(_) => crashes.push(name.clone()),
}
}
println!("layered TIFF round trips: {} tested, {} failed, {} crashed", tested, failures.len(), crashes.len());
println!("blocks dropped on the little-endian path: {dropped:?}");
for f in &failures {
println!("FAIL {f}");
}
assert!(crashes.is_empty(), "crashes: {crashes:?}");
assert!(failures.is_empty(), "{} failures (see above)", failures.len());
assert!(tested > 100, "only {tested} files tested");
}