Files
photocraft/crates/codecs/tests/it/fidelity.rs
T
FinnNkandClaude Opus 5.5 def4646c0e fix(codecs): WebP export keeps the resolution (#8775)
* fix(codecs): WebP export keeps the resolution

Every WebP export with metadata warned that the resolution would be
dropped, and lost it unless the image already had EXIF with resolution
tags. WebP has no resolution field of its own, so both encoders now
write the resolution into EXIF, adding the tags (or a minimal EXIF
block when the image has none), and WebP reports DPI support.

Part of #2399

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

* fix(codecs): report WebP DPI as dropped when the source EXIF can't be parsed

That block is written as it is rather than replaced, so its metadata
survives and the lost resolution is reported. The metadata test also
checks the rest of the EXIF (Orientation) survives.

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

* test(codecs): spell the WebP metadata test's bytes as escapes

The Orientation tag and the unparseable EXIF block were raw control
bytes, which also made git keep the file's CRLF line endings; both are
escapes now and the file is LF again.

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

* fix(codecs): no EXIF block just for WebP's default 72 ppi

A WebP without EXIF already opens at 72 ppi, so writing a minimal EXIF
for the default only turned every web export into the extended (VP8X)
container (and broke the automation test expecting a simple lossy file).
EXIF the image already has still gets its resolution tags updated.

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

* fix(codecs): WebP DPI kept by XMP isn't reported as dropped

When the image's EXIF can't be parsed but its XMP records the
resolution, the WebP keeps it through XMP, which readers check first.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:41:02 -04:00

237 lines
9.4 KiB
Rust

//! `fidelity_warnings` cases, and consistency with what `encode` does.
use crate::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] {
let w = warns(&test_image(l, s), Format::Tiff);
if l.is_lab() && s.is_float() {
// CIELab has no float form: it is written with 16 bits, and says so.
assert_eq!(w, vec![FidelityWarning::DepthReduced { from: s, to: SampleType::U16 }], "{l:?} {s:?}");
} else {
assert!(w.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:?}");
}
}
/// WebP keeps the resolution in EXIF (#2399); only free-form text has nowhere to go.
#[test]
fn webp_drops_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::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 hdr_float_to_fits_clips() {
let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap();
assert!(warns(&img, Format::Fits).contains(&W::RangeClipped));
assert!(!warns(&test_image(ChannelLayout::Gray, SampleType::F32), Format::Fits).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::BitsReduced { from: SampleType::U16, bits: 10 },
W::RangeClipped,
W::AlphaDiscarded,
W::AlphaBinarized,
W::CmykConverted { to: ChannelLayout::Rgb },
W::LabConverted { 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| match w {
W::DepthReduced { .. } => true,
W::BitsReduced { from, .. } => from.is_float(),
_ => false,
});
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());
// Cineon stores 10 bits: 16-bit samples come back changed exactly when warned.
if f == Format::Cineon && s == SampleType::U16 && back.sample_type() == s {
let bits_reduced = w.iter().any(|w| matches!(w, W::BitsReduced { .. }));
let changed = back.to_u16_samples() != img.convert(back.layout(), s).to_u16_samples();
assert_eq!(bits_reduced, changed, "{f:?} {l:?} {w:?}");
}
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:?}");
let lab_lost = l.is_lab() && !back.layout().is_lab();
assert_eq!(w.iter().any(|w| matches!(w, W::LabConverted { .. })), lab_lost, "{f:?} {l:?} {s:?}");
}
}
}
}