diff --git a/sdk/src/asset_handlers/jpeg_io.rs b/sdk/src/asset_handlers/jpeg_io.rs index 9155a8ad8..c23a180b2 100644 --- a/sdk/src/asset_handlers/jpeg_io.rs +++ b/sdk/src/asset_handlers/jpeg_io.rs @@ -620,7 +620,7 @@ impl RemoteRefEmbed for JpegIO { .unwrap_or((None, MIN_XMP)); // add provenance and JPEG XMP prefix - let xmp = format!("{XMP_SIGNATURE}\0 {}", add_provenance(xmp, &manifest_uri)?); + let xmp = format!("{XMP_SIGNATURE}\0{}", add_provenance(xmp, &manifest_uri)?); let segment = JpegSegment::new_with_contents(markers::APP1, Bytes::from(xmp)); // insert or add the segment match xmp_index { diff --git a/sdk/src/utils/xmp_inmemory_utils.rs b/sdk/src/utils/xmp_inmemory_utils.rs index e1addf7c5..44f4ae8c3 100644 --- a/sdk/src/utils/xmp_inmemory_utils.rs +++ b/sdk/src/utils/xmp_inmemory_utils.rs @@ -23,6 +23,7 @@ use quick_xml::{ use crate::{asset_io::CAIRead, jumbf_io::get_cailoader_handler, Error, Result}; const RDF_DESCRIPTION: &[u8] = b"rdf:Description"; +const XMP_END: &[u8] = b""; pub const MIN_XMP: &str = r#" "#; @@ -90,11 +91,30 @@ fn extract_xmp_key(xmp: &str, key: &str) -> Option { } // writes the event to the writer) -/// Add a value to XMP using a key, replaces the value if the key exists +/// Add a value to XMP using a key, replaces the value if the key exists, or adds it only once fn add_xmp_key(xmp: &str, key: &str, value: &str) -> Result { - let mut reader = Reader::from_str(xmp); - reader.config_mut().trim_text(true); - let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2); + let orig_length = xmp.len(); + + // Minimal padding should be 2 KB to 4 KB. This is used if no XMP packet end is found. + // If no packet end is found and the original length is less than 4096, the packet will be + // extended. + let mut target_length = orig_length.max(4096); + // Remove the ending xpacket if present for easier manipulation + let xpacket_end = " Result { .map_err(|e| Error::XmpReadError(e.to_string()))?; // println!("{:?}", event); match event { - Event::Start(ref e) if e.name() == QName(RDF_DESCRIPTION) => { + Event::Start(ref e) if e.name() == QName(RDF_DESCRIPTION) && !added => { // creates a new element let mut elem = BytesStart::from_content( String::from_utf8_lossy(RDF_DESCRIPTION), @@ -130,13 +150,14 @@ fn add_xmp_key(xmp: &str, key: &str, value: &str) -> Result { if !added { // didn't exist, so add it elem.push_attribute((key, value)); + added = true; } // writes the event to the writer writer .write_event(Event::Start(elem)) .map_err(|e| Error::XmpWriteError(e.to_string()))?; } - Event::Empty(ref e) if e.name() == QName(RDF_DESCRIPTION) => { + Event::Empty(ref e) if e.name() == QName(RDF_DESCRIPTION) && !added => { // creates a new element let mut elem = BytesStart::from_content( String::from_utf8_lossy(RDF_DESCRIPTION), @@ -163,6 +184,7 @@ fn add_xmp_key(xmp: &str, key: &str, value: &str) -> Result { if !added { // didn't exist, so add it elem.push_attribute((key, value)); + added = true; } // writes the event to the writer writer @@ -177,6 +199,9 @@ fn add_xmp_key(xmp: &str, key: &str, value: &str) -> Result { } } } + // Maintain XMP packet length with padding if possible. + let padding_length = target_length.saturating_sub(writer.get_ref().get_ref().len()); + write_xmp_padding(&mut writer.get_mut(), padding_length)?; let result = writer.into_inner().into_inner(); String::from_utf8(result).map_err(|e| Error::XmpWriteError(e.to_string())) } @@ -202,13 +227,46 @@ pub fn add_provenance(xmp: &str, provenance: &str) -> Result { add_xmp_key(&xmp, "dcterms:provenance", provenance) } +// According to the XMP Spec, the recommended practice is to use the ASCII space +// character (U+0020) in the appropriate encoding, with a newline about every 100 characters +fn write_xmp_padding(writer: &mut W, len: usize) -> std::io::Result<()> { + let mut remaining = len; + // Add newline before trailer no matter what. + writer.write_all(b"\n")?; + remaining = remaining.saturating_sub(1); + + if remaining > 0 { + // Account for final newline since we are adding whitespace. + remaining -= 1; + while remaining > 0 { + let chunk = remaining.min(99); + writer.write_all(&vec![b' '; chunk])?; + remaining -= chunk; + if remaining > 0 { + writer.write_all(b"\n")?; + remaining -= 1; + } + } + writer.write_all(b"\n")?; + } + writer.write_all(XMP_END)?; + Ok(()) +} + #[cfg(test)] mod tests { #![allow(clippy::expect_used)] #![allow(clippy::unwrap_used)] //use env_logger; + #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + use wasm_bindgen_test::wasm_bindgen_test; + use super::*; + use crate::{ + asset_handlers::jpeg_io::JpegIO, + asset_io::{AssetIO, RemoteRefEmbedType}, + }; const XMP_DATA: &str = r#" @@ -261,4 +319,54 @@ mod tests { println!("{xmp}"); assert_eq!(unicorn, Some(PROVENANCE.to_string())); } + + #[cfg_attr(not(target_arch = "wasm32"), actix::test)] + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test + )] + #[cfg_attr(target_os = "wasi", wstd::test)] + async fn test_broken_xmp_read_write_stream() { + let source_bytes = include_bytes!("../../tests/fixtures/IMG_0003.jpg"); + let test_msg = "https://cai-manifests-stage.adobe.com/manifests/urn-c2pa-0ab6e8b8-5c28-4ef1-8f58-86c21f0349bf-adobe"; + + let handler = JpegIO::new(""); + + let assetio_handler = handler.get_handler("jpg"); + + let remote_ref_handler = assetio_handler.remote_ref_writer_ref().unwrap(); + + let mut source_stream = Cursor::new(source_bytes.to_vec()); + let mut output_stream = Cursor::new(Vec::new()); + + let original_xmp_length = assetio_handler + .get_reader() + .read_xmp(&mut source_stream) + .unwrap() + .len(); + source_stream.set_position(0); + + remote_ref_handler + .embed_reference_to_stream( + &mut source_stream, + &mut output_stream, + RemoteRefEmbedType::Xmp(test_msg.to_string()), + ) + .unwrap(); + + output_stream.set_position(0); + + // read back in XMP + let read_xmp = assetio_handler + .get_reader() + .read_xmp(&mut output_stream) + .unwrap(); + + output_stream.set_position(0); + + //std::fs::write("../target/xmp_write.jpg", output_stream.into_inner()).unwrap(); + + assert!(read_xmp.contains(test_msg)); + assert_eq!(read_xmp.len(), original_xmp_length); + } }