From a2722537fc060aacdd8b2bd461f78bbd5f6cb6e5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 14 Nov 2026 06:43:36 -0500 Subject: [PATCH 1/2] image-codecs: the web JPEG encoder trims or leaves out an XMP packet past one segment encode_jpeg_rgb8 wrote the XMP packet straight into an APP1 segment, so a packet past the limit failed the whole encode. It now builds the segment with the shared jpeg::xmp_segment, as the other JPEG paths do, and jpeg_xmp_fits tells callers whether the XMP will be written. --- crates/image-codecs/src/web.rs | 39 ++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/image-codecs/src/web.rs b/crates/image-codecs/src/web.rs index dfd0cd9b9..8e6f6af81 100644 --- a/crates/image-codecs/src/web.rs +++ b/crates/image-codecs/src/web.rs @@ -286,7 +286,8 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> { /// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and /// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and -/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]). +/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]); XMP that doesn't fit loses its layered-document properties, +/// else is left out too (see [`jpeg_xmp_fits`]). #[allow(clippy::too_many_arguments)] pub fn encode_jpeg_rgb8( width: u32, @@ -326,11 +327,10 @@ pub fn encode_jpeg_rgb8( if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) { enc.add_app_segment(1, &seg).map_err(e)?; } - if let Some(x) = xmp { - // Its `tiff:` resolution, if any, says what the JFIF density says (#1691). - let x = crate::resolution::export_xmp(x, ppi); - let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec(); - seg.extend_from_slice(x.as_bytes()); + // Its `tiff:` resolution, if any, says what the JFIF density says. A packet past one segment + // loses its layered-document properties, else is left out, as the other JPEG paths do; see + // [`jpeg_xmp_fits`]. + if let Some(seg) = xmp.and_then(|x| crate::codecs::jpeg::xmp_segment(x, ppi)) { enc.add_app_segment(1, &seg).map_err(e)?; } if let Some(icc) = icc { @@ -345,6 +345,12 @@ pub fn jpeg_exif_fits(exif: &[u8], dpi: Option) -> bool { crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some() } +/// Whether [`encode_jpeg_rgb8`] writes `xmp` at `dpi`: it must fit in one APP1 segment, if need +/// be without its layered-document properties. +pub fn jpeg_xmp_fits(xmp: &str, dpi: Option) -> bool { + crate::codecs::jpeg::xmp_segment(xmp, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some() +} + #[cfg(test)] mod tests { use super::*; @@ -355,6 +361,27 @@ mod tests { (w, h, img.convert(crate::ChannelLayout::Rgba, crate::SampleType::U8).data().to_vec()) } + /// XMP past one APP1 segment loses its layered-document properties, and a packet still too + /// large is left out; the JPEG is written either way. + #[test] + fn web_jpeg_oversized_xmp_is_trimmed_or_left_out() { + let rgb = vec![100u8; 8 * 8 * 3]; + let ancestors = format!( + "{}", + "id".repeat(5_000) + ); + assert!(ancestors.len() > 70_000); + assert!(jpeg_xmp_fits(&ancestors, Some(72.0)), "trimmed, it fits"); + let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(&ancestors)).unwrap(); + let xmp = crate::decode(&out).unwrap().meta.xmp.unwrap_or_default(); + assert!(xmp.contains("dc:title=\"kept\"") && !xmp.contains("DocumentAncestors"), "{xmp}"); + let big = format!("", "x".repeat(70_000)); + assert!(!jpeg_xmp_fits(&big, Some(72.0))); + let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(&big)).unwrap(); + let img = crate::decode(&out).unwrap(); + assert_eq!((img.dimensions(), img.meta.xmp), ((8, 8), None), "written without the XMP"); + } + fn pattern(w: usize, h: usize, n: usize) -> Vec { (0..w * h).map(|i| (((i % w) * 7 + (i / w) * 13 + (i * i) % 5) % n) as u8).collect() } From db474747d05fea717709d66e99b589b37524f103 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 14 Nov 2026 06:44:08 -0500 Subject: [PATCH 2/2] image-codecs: wrap the encode_jpeg_rgb8 doc comment like the rest of the file --- crates/image-codecs/src/web.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/image-codecs/src/web.rs b/crates/image-codecs/src/web.rs index 8e6f6af81..098462ad7 100644 --- a/crates/image-codecs/src/web.rs +++ b/crates/image-codecs/src/web.rs @@ -286,8 +286,8 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec), CodecError> { /// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and /// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and -/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]); XMP that doesn't fit loses its layered-document properties, -/// else is left out too (see [`jpeg_xmp_fits`]). +/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]); XMP that +/// doesn't fit loses its layered-document properties, else is left out too (see [`jpeg_xmp_fits`]). #[allow(clippy::too_many_arguments)] pub fn encode_jpeg_rgb8( width: u32,