mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 19:36:56 -06:00
A JPEG holds XMP in one APP1 segment, at most 65,533 bytes. Documents from the source application often carry far more: their XMP lists every document ever placed in them (DocumentAncestors, thousands of ids) and the text of every type layer (TextLayers). The encoder passed the packet through whole, the segment was refused, and the export failed with "App segment exceeds maximum allowed data length of 65533". That stopped every JPEG path: Export As, Save a Copy, convert, Layers and Artboards to Files, Batch and Image Processor. 3 of 16 real designer documents in a sweep hit it. When the packet doesn't fit, the encoder now leaves out those two layered-document properties (any namespace prefix), which a flat image doesn't need; everything else in the packet is kept. If it still doesn't fit, the XMP is left out, and so is an EXIF block over 64 KB. Both cases are reported ahead of time by a new fidelity warning, MetadataTooLarge, so the export shows what was dropped. Formats without a segment limit (PNG, TIFF, WebP) still keep the packet whole. Test: jpeg_with_oversized_metadata_still_encodes (fails before: encode error), at 8, 16 and 32 bits. Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
12 KiB
Rust
289 lines
12 KiB
Rust
//! ICC / EXIF / XMP / DPI / text preservation, per format, per caps.
|
|
|
|
mod 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())],
|
|
};
|
|
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 {
|
|
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 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);
|
|
}
|