JPEG export no longer fails when the document's XMP is over 64 KB (#645)
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>
This commit is contained in:
parent
7a45adc913
commit
ac5338360f
|
|
@ -248,15 +248,12 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
{
|
||||
enc.set_density(jpeg_encoder::Density::Inch { x: x.round().min(65535.0) as u16, y: y.round().min(65535.0) as u16 });
|
||||
}
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
let mut seg = EXIF_HEADER.to_vec();
|
||||
seg.extend_from_slice(&upright_exif(exif));
|
||||
// Metadata too large for its one segment is left out (and reported by the fidelity
|
||||
// warnings) rather than failing the whole export.
|
||||
if let Some(seg) = img.meta.exif.as_deref().and_then(exif_segment) {
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
let mut seg = XMP_HEADER.to_vec();
|
||||
seg.extend_from_slice(upright_xmp(xmp).as_bytes());
|
||||
if let Some(seg) = img.meta.xmp.as_deref().and_then(xmp_segment) {
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
}
|
||||
|
|
@ -268,3 +265,63 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
|||
enc.encode(img.data(), w16, h16, ct).map_err(e)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The most data one APP segment holds (its 16-bit length counts its own two bytes).
|
||||
const MAX_SEGMENT: usize = 65533;
|
||||
|
||||
/// XMP properties (local names, any namespace prefix) that only describe the layered document:
|
||||
/// every document ever placed in it, the text of every type layer. A flat image doesn't need them,
|
||||
/// and they alone can outgrow a segment.
|
||||
const LAYERED_ONLY_XMP: [&str; 2] = ["DocumentAncestors", "TextLayers"];
|
||||
|
||||
/// The EXIF APP1 segment, or `None` when it doesn't fit in one segment.
|
||||
pub(crate) fn exif_segment(exif: &[u8]) -> Option<Vec<u8>> {
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
let seg = [EXIF_HEADER, upright_exif(exif).as_ref()].concat();
|
||||
(seg.len() <= MAX_SEGMENT).then_some(seg)
|
||||
}
|
||||
|
||||
/// The XMP APP1 segment. When the packet doesn't fit in one segment, the layered-document
|
||||
/// properties are left out; `None` when it still doesn't fit.
|
||||
pub(crate) fn xmp_segment(xmp: &str) -> Option<Vec<u8>> {
|
||||
let xmp = upright_xmp(xmp);
|
||||
let seg = |x: &str| [XMP_HEADER, x.as_bytes()].concat();
|
||||
if XMP_HEADER.len() + xmp.len() <= MAX_SEGMENT {
|
||||
return Some(seg(&xmp));
|
||||
}
|
||||
let trimmed = LAYERED_ONLY_XMP.iter().fold(xmp.into_owned(), |x, name| remove_element(&x, name));
|
||||
(XMP_HEADER.len() + trimmed.len() <= MAX_SEGMENT).then(|| seg(&trimmed))
|
||||
}
|
||||
|
||||
/// `xmp` without its `<prefix:name …>…</prefix:name>` and `<prefix:name …/>` elements. Unbalanced
|
||||
/// markup is kept as is.
|
||||
fn remove_element(xmp: &str, name: &str) -> String {
|
||||
let local = format!(":{name}");
|
||||
let mut out = String::with_capacity(xmp.len());
|
||||
let mut rest = xmp;
|
||||
while let Some(j) = rest.find(&local) {
|
||||
let head = rest.get(..j).unwrap_or_default();
|
||||
let prefix_len = head.bytes().rev().take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')).count();
|
||||
let lt = j - prefix_len;
|
||||
let after = rest.get(j + local.len()..).unwrap_or_default();
|
||||
// An opening tag of exactly this name (not a closing tag, an attribute or a longer name).
|
||||
let opening = prefix_len > 0 && lt > 0 && rest.get(lt - 1..lt) == Some("<");
|
||||
if !opening || !after.starts_with(['>', '/', ' ', '\t', '\r', '\n']) {
|
||||
out.push_str(rest.get(..j + local.len()).unwrap_or_default());
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
let Some(gt) = after.find('>') else { break };
|
||||
let end = if after.get(..gt).is_some_and(|s| s.ends_with('/')) {
|
||||
gt + 1
|
||||
} else {
|
||||
let close = format!("</{}{local}>", rest.get(lt..j).unwrap_or_default());
|
||||
let Some(c) = after.find(&close) else { break };
|
||||
c + close.len()
|
||||
};
|
||||
out.push_str(rest.get(..lt - 1).unwrap_or_default());
|
||||
rest = after.get(end..).unwrap_or_default();
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ pub enum FidelityWarning {
|
|||
XmpDropped,
|
||||
DpiDropped,
|
||||
TextDropped,
|
||||
/// Metadata larger than the format can hold in one piece (`what`: "EXIF", "XMP") will be
|
||||
/// dropped.
|
||||
MetadataTooLarge {
|
||||
what: &'static str,
|
||||
bytes: usize,
|
||||
},
|
||||
/// Image exceeds the format's maximum size; encoding will fail.
|
||||
DimensionsExceeded {
|
||||
max_width: u32,
|
||||
|
|
@ -86,6 +92,7 @@ impl fmt::Display for FidelityWarning {
|
|||
XmpDropped => write!(f, "XMP not supported; it will be dropped"),
|
||||
DpiDropped => write!(f, "resolution (DPI) not supported; it will be dropped"),
|
||||
TextDropped => write!(f, "text metadata not supported; it will be dropped"),
|
||||
MetadataTooLarge { what, bytes } => write!(f, "{what} ({bytes} bytes) is too large for the format; it will be dropped"),
|
||||
DimensionsExceeded { max_width, max_height } => {
|
||||
write!(f, "image exceeds the format maximum of {max_width}x{max_height}")
|
||||
}
|
||||
|
|
@ -219,6 +226,14 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
|||
if m.xmp.is_some() && !c.xmp {
|
||||
w.push(W::XmpDropped);
|
||||
}
|
||||
if format == Format::Jpeg {
|
||||
if let Some(exif) = m.exif.as_deref().filter(|e| crate::codecs::jpeg::exif_segment(e).is_none()) {
|
||||
w.push(W::MetadataTooLarge { what: "EXIF", bytes: exif.len() });
|
||||
}
|
||||
if let Some(xmp) = m.xmp.as_deref().filter(|x| crate::codecs::jpeg::xmp_segment(x).is_none()) {
|
||||
w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() });
|
||||
}
|
||||
}
|
||||
if m.dpi.is_some() && !c.dpi {
|
||||
w.push(W::DpiDropped);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,45 @@ fn jpeg_multi_segment_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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue