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

341 lines
15 KiB
Rust

//! ICC / EXIF / XMP / DPI / text preservation, per format, per caps.
use crate::common;
use common::*;
use photocraft_codecs::*;
fn rich_image(layout: ChannelLayout) -> Image {
let mut img = test_image(layout, SampleType::U8);
img.icc = Some(sample_icc(3000));
img.meta = Metadata {
exif: Some(sample_exif()),
xmp: Some(SAMPLE_XMP.to_owned()),
dpi: Some((300.0, 150.0)),
text: vec![("Description".into(), "synthetic test image".into()), ("Software".into(), "photocraft".into())],
..Default::default()
};
img
}
fn check_format(format: Format) {
let c = caps(format);
if !(c.read && c.write) {
return;
}
let layout = if c.layouts.contains(&ChannelLayout::Rgb) { ChannelLayout::Rgb } else { c.layouts[0] };
let img = rich_image(layout);
let bytes = encode(&img, format, &EncodeOptions::default()).unwrap();
let back = decode(&bytes).unwrap();
let warnings = fidelity_warnings(&img, format);
assert_eq!(back.icc.is_some(), c.icc, "{format:?} icc presence");
if c.icc {
assert_eq!(back.icc, img.icc, "{format:?} icc bytes");
} else {
assert!(warnings.contains(&FidelityWarning::IccDropped), "{format:?}");
}
if c.exif && format == Format::Tiff {
// TIFF writes the block's directories as its own and rebuilds a block from them on
// reading (tests/it/tiff_exif.rs); IFD0's orientation here is the image's.
assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}");
} else if c.exif && format == Format::WebP {
// WebP's resolution lives in EXIF, so the block comes back with resolution tags added.
let back_exif = back.meta.exif.as_deref().unwrap_or_else(|| panic!("{format:?} lost exif"));
assert_eq!(photocraft_codecs::resolution::exif_resolution(back_exif).map(|(x, y)| (x.round(), y.round())), Some((300.0, 150.0)));
// The rest of the block survives too: the Orientation tag (0x0112, little-endian).
assert!(back_exif.windows(2).any(|w| w == b"\x12\x01"), "{format:?} lost Orientation");
} else if c.exif {
assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif");
} else {
assert!(warnings.contains(&FidelityWarning::ExifDropped), "{format:?}");
}
if c.xmp {
assert_eq!(back.meta.xmp, img.meta.xmp, "{format:?} xmp");
} else {
assert!(back.meta.xmp.is_none());
assert!(warnings.contains(&FidelityWarning::XmpDropped), "{format:?}");
}
if c.dpi {
let (x, y) = back.meta.dpi.unwrap_or_else(|| panic!("{format:?} lost dpi"));
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "{format:?} dpi {x},{y}");
} else {
assert!(warnings.contains(&FidelityWarning::DpiDropped), "{format:?}");
}
if c.text {
for kv in &img.meta.text {
assert!(back.meta.text.contains(kv), "{format:?} lost text {kv:?}: {:?}", back.meta.text);
}
} else {
assert!(back.meta.text.is_empty());
assert!(warnings.contains(&FidelityWarning::TextDropped), "{format:?}");
}
}
#[test]
fn png_metadata() {
check_format(Format::Png);
}
#[test]
fn jpeg_metadata() {
check_format(Format::Jpeg);
}
#[test]
fn tiff_metadata() {
check_format(Format::Tiff);
}
#[test]
fn webp_metadata() {
check_format(Format::WebP);
}
#[test]
fn gif_metadata() {
check_format(Format::Gif);
}
#[test]
fn bmp_metadata() {
check_format(Format::Bmp);
}
#[test]
fn tga_metadata() {
check_format(Format::Tga);
}
#[test]
fn ico_metadata() {
check_format(Format::Ico);
}
#[test]
fn fits_metadata() {
check_format(Format::Fits);
}
#[test]
fn pnm_metadata() {
check_format(Format::Pnm);
}
#[test]
fn qoi_metadata() {
check_format(Format::Qoi);
}
#[test]
fn exr_metadata() {
check_format(Format::OpenExr);
}
#[test]
fn hdr_metadata() {
check_format(Format::Hdr);
}
#[test]
fn metadata_on_16bit_png_and_tiff() {
for f in [Format::Png, Format::Tiff] {
let mut img = test_image(ChannelLayout::Rgba, SampleType::U16);
img.icc = Some(sample_icc(512));
img.meta.dpi = Some((72.0, 72.0));
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
assert_eq!(back.icc, img.icc);
assert_eq!(back.sample_type(), SampleType::U16);
}
}
#[test]
fn icc_on_cmyk_tiff_and_jpeg() {
for f in [Format::Tiff, Format::Jpeg] {
let img = test_image(ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(sample_icc(900)));
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
assert_eq!(back.layout(), ChannelLayout::Cmyk, "{f:?}");
assert_eq!(back.icc, img.icc, "{f:?}");
}
}
#[test]
fn cmyk_icc_not_written_to_rgb_format() {
let img = test_image(ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(sample_icc(900)));
let back = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap();
assert!(back.icc.is_none(), "a CMYK profile must not be attached to converted RGB data");
assert!(fidelity_warnings(&img, Format::Png).contains(&FidelityWarning::IccDropped));
}
#[test]
fn jpeg_multi_segment_icc() {
// > 65519 bytes forces several APP2 segments.
for len in [65519, 65520, 150_000, 300_001] {
let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(len)));
let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
let app2 = bytes.windows(14).filter(|w| w[0] == 0xFF && w[1] == 0xE2 && &w[4..14] == b"ICC_PROFIL").count();
assert_eq!(app2, len.div_ceil(65519), "segments for {len}");
assert_eq!(decode(&bytes).unwrap().icc.map(|v| v.len()), Some(len));
// Oracle: the image crate reads the same profile.
use image::ImageDecoder;
let mut d = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&bytes)).unwrap();
assert_eq!(d.icc_profile().unwrap(), img.icc);
}
}
/// A layered document's XMP can list every document ever placed in it and the text of every type
/// layer: far more than the 64 KB one JPEG segment holds. The JPEG is still written, with that
/// bookkeeping left out of the XMP; metadata too large even then is dropped with a warning.
#[test]
fn jpeg_with_oversized_metadata_still_encodes() {
let ancestors: String = (0..3000).map(|i| format!("<rdf:li>xmp.did:{i:032x}</rdf:li>")).collect();
let texts: String = (0..400)
.map(|i| format!("<rdf:li rdf:parseType=\"Resource\"><ps:LayerName>Layer {i}</ps:LayerName><ps:LayerText>Words on layer {i}</ps:LayerText></rdf:li>"))
.collect();
let xmp = format!(
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:ps=\"http://example.com/ps/1.0/\" xmp:CreatorTool=\"synthetic\"><ps:DocumentAncestors>\n<rdf:Bag>{ancestors}</rdf:Bag></ps:DocumentAncestors><ps:TextLayers><rdf:Bag>{texts}</rdf:Bag></ps:TextLayers><ps:DocumentAncestorsNote/></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>"
);
assert!(xmp.len() > 120_000, "{}", xmp.len());
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut img = test_image(ChannelLayout::Rgb, sample);
img.meta.xmp = Some(xmp.clone());
let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
let back = decode(&bytes).unwrap().meta.xmp.unwrap();
assert!(back.contains("xmp:CreatorTool=\"synthetic\"") && back.ends_with("<?xpacket end=\"w\"?>"), "{sample:?}");
assert!(!back.contains("DocumentAncestors>") && !back.contains("TextLayers"), "{sample:?}");
assert!(back.contains("<ps:DocumentAncestorsNote/>"), "{sample:?}: a longer name is not the element");
assert!(!fidelity_warnings(&img, Format::Jpeg).iter().any(|w| matches!(w, FidelityWarning::MetadataTooLarge { .. })));
// Formats without a segment limit keep the packet whole.
assert_eq!(decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap().meta.xmp.as_deref(), Some(xmp.as_str()));
}
// Still too large: the JPEG is written without it.
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
let big = format!("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><!-- {} --></x:xmpmeta>", "x".repeat(70_000));
let mut exif = sample_exif();
exif.resize(70_000, 0);
img.meta.xmp = Some(big.clone());
img.meta.exif = Some(exif);
let back = decode(&encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap()).unwrap();
assert!(back.meta.xmp.is_none() && back.meta.exif.is_none());
let w = fidelity_warnings(&img, Format::Jpeg);
assert!(w.contains(&FidelityWarning::MetadataTooLarge { what: "XMP", bytes: big.len() }), "{w:?}");
assert!(w.contains(&FidelityWarning::MetadataTooLarge { what: "EXIF", bytes: 70_000 }), "{w:?}");
}
#[test]
fn embed_flags_disable_metadata() {
let img = rich_image(ChannelLayout::Rgb);
let opts = EncodeOptions { embed_icc: false, embed_metadata: false, ..Default::default() };
for f in [Format::Png, Format::Jpeg, Format::Tiff, Format::WebP] {
let back = decode(&encode(&img, f, &opts).unwrap()).unwrap();
assert!(back.icc.is_none(), "{f:?}");
assert!(back.meta.exif.is_none() && back.meta.xmp.is_none(), "{f:?}");
assert!(back.meta.text.is_empty(), "{f:?}");
assert!(fidelity_warnings_with(&img, Format::Bmp, &opts).iter().all(|w| !matches!(w, FidelityWarning::IccDropped | FidelityWarning::ExifDropped)));
}
}
#[test]
fn png_unicode_text_uses_itxt() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta.text = vec![("Title".into(), "Ünïcödé ☃ 雪".into()), ("Author".into(), "latin1 ok é".into())];
let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
assert!(bytes.windows(4).any(|w| w == b"iTXt"));
assert!(bytes.windows(4).any(|w| w == b"tEXt"));
let mut back = decode(&bytes).unwrap().meta.text;
let mut want = img.meta.text.clone();
back.sort();
want.sort();
assert_eq!(back, want);
}
#[test]
fn png_xmp_in_itxt_keyword() {
let mut img = test_image(ChannelLayout::Gray, SampleType::U8);
img.meta.xmp = Some(SAMPLE_XMP.into());
let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
assert!(bytes.windows(17).any(|w| w == b"XML:com.adobe.xmp"));
let back = decode(&bytes).unwrap();
assert_eq!(back.meta.xmp.as_deref(), Some(SAMPLE_XMP));
assert!(back.meta.text.is_empty(), "XMP must not leak into text");
}
#[test]
fn png_exif_chunk_written() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta.exif = Some(sample_exif());
let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
assert!(bytes.windows(4).any(|w| w == b"eXIf"));
assert!(!bytes.windows(4).any(|w| w == b"iCCP"));
}
#[test]
fn jpeg_exif_readable_by_oracle() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta.exif = Some(sample_exif());
let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
use image::ImageDecoder;
let mut d = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&bytes)).unwrap();
let exif = d.exif_metadata().unwrap().unwrap();
assert!(exif.ends_with(&sample_exif()));
}
#[test]
fn jpeg_dpi_in_jfif() {
let mut img = test_image(ChannelLayout::Gray, SampleType::U8);
img.meta.dpi = Some((96.0, 200.0));
let back = decode(&encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap()).unwrap();
assert_eq!(back.meta.dpi, Some((96.0, 200.0)));
}
#[test]
fn tiff_icc_readable_by_oracle() {
let img = test_image(ChannelLayout::Rgb, SampleType::U16).with_icc(Some(sample_icc(1234)));
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
use image::ImageDecoder;
let mut d = image::codecs::tiff::TiffDecoder::new(std::io::Cursor::new(&bytes)).unwrap();
assert_eq!(d.icc_profile().unwrap(), img.icc);
}
#[test]
fn png_icc_readable_by_oracle() {
let img = test_image(ChannelLayout::Rgba, SampleType::U8).with_icc(Some(sample_icc(777)));
let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
use image::ImageDecoder;
let mut d = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&bytes)).unwrap();
assert_eq!(d.icc_profile().unwrap(), img.icc);
}
#[test]
fn metadata_survives_depth_conversion() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::F32).with_icc(Some(sample_icc(300)));
img.meta.xmp = Some(SAMPLE_XMP.into());
let back = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap();
assert_eq!(back.icc, img.icc);
assert_eq!(back.meta.xmp, img.meta.xmp);
}
/// #2399: a WebP export keeps the resolution even when the image has no EXIF (WebP has no
/// resolution field of its own, so a minimal EXIF block carries it), lossless and lossy alike,
/// and no longer warns that it is dropped.
#[test]
fn webp_keeps_dpi_without_source_exif() {
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
img.meta = Metadata { dpi: Some((300.0, 150.0)), ..Default::default() };
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
for lossless in [true, false] {
let opts = EncodeOptions { webp_lossless: lossless, ..Default::default() };
let back = decode(&encode(&img, Format::WebP, &opts).unwrap()).unwrap();
let (x, y) = back.meta.dpi.unwrap_or_else(|| panic!("lossless {lossless}: dpi lost"));
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "lossless {lossless}: {x},{y}");
}
// EXIF that can't be parsed is written as it is, so the resolution is reported as dropped.
img.meta = Metadata { exif: Some(b"Exif\0\0garbage".to_vec()), dpi: Some((300.0, 150.0)), ..Default::default() };
assert!(fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
assert!(back.meta.exif.is_some());
// ...unless the XMP written beside it records the resolution, which readers look at first.
img.meta.xmp = Some(r#"<x:xmpmeta><rdf:Description tiff:XResolution="300/1" tiff:YResolution="150/1" tiff:ResolutionUnit="2"/></x:xmpmeta>"#.into());
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
let (x, y) = back.meta.dpi.expect("dpi from XMP");
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "{x},{y}");
// The default 72 ppi needs no EXIF (a WebP without one opens at 72), so a plain export stays
// a simple-format file; nor does an image without a resolution.
img.meta = Metadata { dpi: Some((72.0, 72.0)), ..Default::default() };
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).unwrap();
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]));
// Without a resolution or EXIF there is still no EXIF chunk.
img.meta = Metadata::default();
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
assert!(back.meta.exif.is_none());
}