mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-26 19:07:06 -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>
210 lines
7.8 KiB
Rust
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:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|