Files
photocraft/crates/codecs/tests/fidelity.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 <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

210 lines
7.8 KiB
Rust

//! `fidelity_warnings` cases, and consistency with what `encode` does.
mod common;
use FidelityWarning as W;
use common::*;
use photocraft_codecs::*;
fn warns(img: &Image, f: Format) -> Vec<FidelityWarning> {
fidelity_warnings(img, f)
}
#[test]
fn png_u8_rgb_is_clean() {
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::U8), Format::Png).is_empty());
}
#[test]
fn png_u16_rgba_is_clean() {
assert!(warns(&test_image(ChannelLayout::Rgba, SampleType::U16), Format::Png).is_empty());
}
#[test]
fn tiff_is_clean_for_all_integer_and_f32_layouts() {
for l in ChannelLayout::ALL {
for s in [SampleType::U8, SampleType::U16, SampleType::F32, SampleType::F16] {
assert!(warns(&test_image(l, s), Format::Tiff).is_empty(), "{l:?} {s:?}");
}
}
}
#[test]
fn sixteen_bit_to_jpeg_reduces() {
let w = warns(&test_image(ChannelLayout::Rgb, SampleType::U16), Format::Jpeg);
assert!(w.contains(&W::DepthReduced { from: SampleType::U16, to: SampleType::U8 }));
assert!(w.contains(&W::LossyCompression));
}
#[test]
fn sixteen_bit_message_text() {
let w = W::DepthReduced { from: SampleType::U16, to: SampleType::U8 };
assert_eq!(w.to_string(), "16-bit will be reduced to 8-bit");
}
#[test]
fn alpha_to_jpeg_discarded() {
let w = warns(&test_image(ChannelLayout::Rgba, SampleType::U8), Format::Jpeg);
assert!(w.contains(&W::AlphaDiscarded));
}
#[test]
fn opaque_alpha_not_reported() {
let mut img = test_image(ChannelLayout::Rgba, SampleType::U8);
for px in img.data_mut().chunks_mut(4) {
px[3] = 255;
}
assert!(!warns(&img, Format::Jpeg).contains(&W::AlphaDiscarded));
}
#[test]
fn alpha_to_hdr_discarded() {
assert!(warns(&test_image(ChannelLayout::Rgba, SampleType::F32), Format::Hdr).contains(&W::AlphaDiscarded));
}
#[test]
fn cmyk_to_png_converted() {
let w = warns(&test_image(ChannelLayout::Cmyk, SampleType::U8), Format::Png);
assert!(w.contains(&W::CmykConverted { to: ChannelLayout::Rgb }));
}
#[test]
fn cmyka_to_webp_converted_keeps_alpha() {
let w = warns(&test_image(ChannelLayout::CmykA, SampleType::U8), Format::WebP);
assert!(w.contains(&W::CmykConverted { to: ChannelLayout::Rgba }));
assert!(!w.contains(&W::AlphaDiscarded));
}
#[test]
fn cmyk_to_jpeg_and_tiff_not_converted() {
for f in [Format::Jpeg, Format::Tiff, Format::Pnm] {
let w = warns(&test_image(ChannelLayout::Cmyk, SampleType::U8), f);
assert!(!w.iter().any(|w| matches!(w, W::CmykConverted { .. })), "{f:?}");
}
}
#[test]
fn icc_dropped_for_bmp() {
let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(100)));
assert!(warns(&img, Format::Bmp).contains(&W::IccDropped));
assert!(!warns(&img, Format::Png).contains(&W::IccDropped));
}
#[test]
fn icc_warning_suppressed_when_not_embedding() {
let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(100)));
let opts = EncodeOptions { embed_icc: false, ..Default::default() };
assert!(!fidelity_warnings_with(&img, Format::Bmp, &opts).contains(&W::IccDropped));
}
#[test]
fn exif_xmp_dpi_text_dropped_for_qoi() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta =
Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() };
let w = warns(&img, Format::Qoi);
for x in [W::ExifDropped, W::XmpDropped, W::DpiDropped, W::TextDropped] {
assert!(w.contains(&x), "{x:?}");
}
}
#[test]
fn webp_drops_dpi_and_text_only() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta =
Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() };
let w = warns(&img, Format::WebP);
assert_eq!(w, vec![W::DpiDropped, W::TextDropped]);
}
#[test]
fn float_to_png_reduces_and_clips() {
let vals: Vec<f32> = (0..12).map(|i| i as f32 * 0.3 - 0.5).collect();
let img = Image::from_f32(2, 2, ChannelLayout::Rgb, &vals).unwrap();
let w = warns(&img, Format::Png);
assert!(w.contains(&W::DepthReduced { from: SampleType::F32, to: SampleType::U16 }));
assert!(w.contains(&W::RangeClipped));
}
#[test]
fn in_range_float_not_clipped() {
let w = warns(&test_image(ChannelLayout::Rgb, SampleType::F32), Format::Png);
assert!(!w.contains(&W::RangeClipped));
}
#[test]
fn float_to_exr_and_tiff_is_clean() {
for f in [Format::OpenExr, Format::Tiff, Format::Pnm] {
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), "{f:?}");
}
}
#[test]
fn f16_to_exr_is_clean() {
assert!(warns(&test_image(ChannelLayout::Rgba, SampleType::F16), Format::OpenExr).is_empty());
}
#[test]
fn integer_to_exr_is_lossless() {
for s in [SampleType::U8, SampleType::U16] {
assert!(warns(&test_image(ChannelLayout::Rgb, s), Format::OpenExr).is_empty(), "{s:?}");
}
}
#[test]
fn float_pnm_drops_alpha() {
let w = warns(&test_image(ChannelLayout::Rgba, SampleType::F32), Format::Pnm);
assert!(w.contains(&W::AlphaDiscarded));
}
#[test]
fn gif_quantizes_and_binarizes() {
let w = warns(&test_image(ChannelLayout::Rgba, SampleType::U8), Format::Gif);
assert!(w.contains(&W::PaletteQuantized));
assert!(w.contains(&W::AlphaBinarized));
assert!(!w.contains(&W::LossyCompression));
}
#[test]
fn hdr_is_lossy() {
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), Format::Hdr).contains(&W::LossyCompression));
}
#[test]
fn ico_oversize_is_fatal() {
let img = Image::new(512, 16, ChannelLayout::Rgba, SampleType::U8).unwrap();
let w = warns(&img, Format::Ico);
assert!(w.iter().any(|w| w.is_fatal()));
assert!(w.contains(&W::DimensionsExceeded { max_width: 256, max_height: 256 }));
}
#[test]
fn avif_default_build_write_unsupported() {
let w = warns(&test_image(ChannelLayout::Rgb, SampleType::U8), Format::Avif);
if cfg!(feature = "avif") {
assert!(w.contains(&W::LossyCompression));
} else {
assert_eq!(w, vec![W::WriteUnsupported { format: Format::Avif }]);
assert!(w[0].is_fatal());
}
}
#[test]
fn every_warning_has_a_message() {
let all = [
W::WriteUnsupported { format: Format::Avif },
W::DepthReduced { from: SampleType::F32, to: SampleType::F16 },
W::RangeClipped,
W::AlphaDiscarded,
W::AlphaBinarized,
W::CmykConverted { to: ChannelLayout::Rgb },
W::ColorToGray,
W::PaletteQuantized,
W::LossyCompression,
W::IccDropped,
W::ExifDropped,
W::XmpDropped,
W::DpiDropped,
W::TextDropped,
W::DimensionsExceeded { max_width: 1, max_height: 1 },
];
for w in all {
assert!(w.to_string().len() > 5);
}
}
/// The warnings must agree with what actually comes back from the file.
#[test]
fn warnings_match_actual_roundtrip_behaviour() {
for f in rw_formats() {
for l in ChannelLayout::ALL {
for s in SampleType::ALL {
let img = test_image(l, s);
let w = warns(&img, f);
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
let depth_reduced = w.iter().any(|w| matches!(w, W::DepthReduced { .. }));
let lost_depth = back.sample_type() != s && back.sample_type() != SampleType::F32 && s != SampleType::U8;
assert_eq!(depth_reduced, lost_depth, "{f:?} {l:?} {s:?} {w:?} -> {:?}", back.sample_type());
let alpha_lost = l.has_alpha() && !back.layout().has_alpha();
assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}");
let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk();
assert_eq!(w.iter().any(|w| matches!(w, W::CmykConverted { .. })), cmyk_lost, "{f:?} {l:?} {s:?}");
}
}
}
}