From 59418fbb4a92a3a1a7258cad829558e3650486a0 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 11 Sep 2026 21:59:09 +0000 Subject: [PATCH 01/10] feat(jpeg): preserve and expose XMP metadata in libultrahdr - Expose uhdr_enc_set_xmp_data and uhdr_dec_get_xmp in public C API - Support passing custom XMP metadata into encodeJPEGR and appendGainMap - In ISO metadata mode, pass through user/base XMP packet directly - In XMP metadata mode, merge Ultra HDR container directory into user/base XMP RDF - Automatically extract and preserve base JPEG XMP in API-2/3/4 encoding workflows - Wire -X and probe XMP display into ultrahdr_app CLI - Add unit tests verifying XMP encoding, decoding, and preservation across API-0 and API-2 --- examples/ultrahdr_app.cpp | 49 +++++++++- lib/include/ultrahdr/jpegr.h | 26 +++-- lib/include/ultrahdr/jpegrutils.h | 3 +- lib/include/ultrahdr/ultrahdrcommon.h | 3 + lib/src/jpegr.cpp | 85 +++++++++++----- lib/src/jpegrutils.cpp | 53 +++++++++- lib/src/ultrahdr_api.cpp | 79 ++++++++++++++- tests/ultrahdr_api_test.cpp | 135 ++++++++++++++++++++++++++ ultrahdr_api.h | 21 ++++ 9 files changed, 412 insertions(+), 42 deletions(-) diff --git a/examples/ultrahdr_app.cpp b/examples/ultrahdr_app.cpp index f047c2f1..ee442d75 100644 --- a/examples/ultrahdr_app.cpp +++ b/examples/ultrahdr_app.cpp @@ -258,8 +258,9 @@ class UltraHdrAppInput { public: UltraHdrAppInput(const char* hdrIntentRawFile, const char* sdrIntentRawFile, const char* sdrIntentCompressedFile, const char* gainmapCompressedFile, - const char* gainmapMetadataCfgFile, const char* exifFile, const char* outputFile, - int width, int height, uhdr_img_fmt_t hdrCf = UHDR_IMG_FMT_32bppRGBA1010102, + const char* gainmapMetadataCfgFile, const char* exifFile, const char* xmpFile, + const char* outputFile, int width, int height, + uhdr_img_fmt_t hdrCf = UHDR_IMG_FMT_32bppRGBA1010102, uhdr_img_fmt_t sdrCf = UHDR_IMG_FMT_32bppRGBA8888, uhdr_color_gamut_t hdrCg = UHDR_CG_DISPLAY_P3, uhdr_color_gamut_t sdrCg = UHDR_CG_BT_709, @@ -277,6 +278,7 @@ class UltraHdrAppInput { mGainMapCompressedFile(gainmapCompressedFile), mGainMapMetadataCfgFile(gainmapMetadataCfgFile), mExifFile(exifFile), + mXmpFile(xmpFile), mUhdrFile(nullptr), mOutputFile(outputFile), mWidth(width), @@ -312,6 +314,7 @@ class UltraHdrAppInput { mGainMapCompressedFile(nullptr), mGainMapMetadataCfgFile(gainmapMetadataCfgFile), mExifFile(nullptr), + mXmpFile(nullptr), mUhdrFile(uhdrFile), mOutputFile(outputFile), mWidth(0), @@ -370,6 +373,7 @@ class UltraHdrAppInput { } } if (mExifBlock.data) free(mExifBlock.data); + if (mXmpBlock.data) free(mXmpBlock.data); if (mUhdrImage.data) free(mUhdrImage.data); } @@ -385,6 +389,7 @@ class UltraHdrAppInput { bool fillGainMapCompressedImageHandle(); bool fillGainMapMetadataDescriptor(); bool fillExifMemoryBlock(); + bool fillXmpMemoryBlock(); void writeGainMapMetadataToFile(uhdr_gainmap_metadata_t* metadata, std::ostream& file); bool convertRgba8888ToYUV444Image(); bool convertRgba1010102ToYUV444Image(); @@ -401,6 +406,7 @@ class UltraHdrAppInput { const char* mGainMapCompressedFile; const char* mGainMapMetadataCfgFile; const char* mExifFile; + const char* mXmpFile; const char* mUhdrFile; const char* mOutputFile; const int mWidth; @@ -435,6 +441,7 @@ class UltraHdrAppInput { uhdr_compressed_image_t mGainMapCompressedImage{}; uhdr_gainmap_metadata mGainMapMetadata{}; uhdr_mem_block_t mExifBlock{}; + uhdr_mem_block_t mXmpBlock{}; uhdr_compressed_image_t mUhdrImage{}; uhdr_raw_image_t mDecodedUhdrRgbImage{}; uhdr_raw_image_t mDecodedUhdrYuv444Image{}; @@ -614,6 +621,19 @@ bool UltraHdrAppInput::fillExifMemoryBlock() { return false; } +bool UltraHdrAppInput::fillXmpMemoryBlock() { + std::ifstream ifd(mXmpFile, std::ios::binary | std::ios::ate); + if (ifd.good()) { + auto size = ifd.tellg(); + mXmpBlock.data = nullptr; + mXmpBlock.data_sz = size; + mXmpBlock.capacity = size; + ifd.close(); + return loadFile(mXmpFile, mXmpBlock.data, size); + } + return false; +} + void UltraHdrAppInput::writeGainMapMetadataToFile(uhdr_gainmap_metadata_t* metadata, std::ostream& file) { bool allChannelsIdentical = metadata->max_content_boost[0] == metadata->max_content_boost[1] && @@ -725,6 +745,12 @@ bool UltraHdrAppInput::encode() { return false; } } + if (mXmpFile != nullptr) { + if (!fillXmpMemoryBlock()) { + std::cerr << " failed to load file " << mXmpFile << std::endl; + return false; + } + } #define RET_IF_ERR(x) \ { \ @@ -766,6 +792,9 @@ bool UltraHdrAppInput::encode() { if (mExifFile != nullptr) { RET_IF_ERR(uhdr_enc_set_exif_data(handle, &mExifBlock)) } + if (mXmpFile != nullptr) { + RET_IF_ERR(uhdr_enc_set_xmp_data(handle, &mXmpBlock)) + } if (mOutputFile != nullptr) { std::string outStr(mOutputFile); @@ -856,6 +885,11 @@ bool UltraHdrAppInput::decode() { if (mProbe) { std::cout << "Ultra HDR Image: Yes \nGainMap Metadata: " << std::endl; writeGainMapMetadataToFile(metadata, std::cout); + uhdr_mem_block_t* xmp = uhdr_dec_get_xmp(handle); + if (xmp != nullptr && xmp->data != nullptr && xmp->data_sz > 0) { + std::cout << "XMP Metadata (" << xmp->data_sz << " bytes): \n" + << std::string(static_cast(xmp->data), xmp->data_sz) << std::endl; + } uhdr_release_decoder(handle); return true; } @@ -1501,6 +1535,7 @@ static void usage(const char* name) { " For PQ content, this defaults to 10000 nits. \n" " any real number in range [203, 10000]. \n"); fprintf(stderr, " -x binary input resource containing exif data to insert, optional. \n"); + fprintf(stderr, " -X resource containing xmp data to insert, optional. \n"); fprintf(stderr, "\n## decoder options : \n"); fprintf(stderr, " -j ultra hdr compressed input resource, required. \n"); fprintf(stderr, @@ -1594,10 +1629,11 @@ static void usage(const char* name) { } int main(int argc, char* argv[]) { - char opt_string[] = "p:y:i:g:f:w:h:C:c:t:q:o:O:m:j:e:a:b:z:R:s:M:Q:G:x:u:D:k:K:L:P"; + char opt_string[] = "p:y:i:g:f:w:h:C:c:t:q:o:O:m:j:e:a:b:z:R:s:M:Q:G:x:X:u:D:k:K:L:P"; char *hdr_intent_raw_file = nullptr, *sdr_intent_raw_file = nullptr, *uhdr_file = nullptr, *sdr_intent_compressed_file = nullptr, *gainmap_compressed_file = nullptr, - *gainmap_metadata_cfg_file = nullptr, *output_file = nullptr, *exif_file = nullptr; + *gainmap_metadata_cfg_file = nullptr, *output_file = nullptr, *exif_file = nullptr, + *xmp_file = nullptr; int width = 0, height = 0; uhdr_color_gamut_t hdr_cg = UHDR_CG_DISPLAY_P3; uhdr_color_gamut_t sdr_cg = UHDR_CG_BT_709; @@ -1702,6 +1738,9 @@ int main(int argc, char* argv[]) { case 'x': exif_file = optarg_s; break; + case 'X': + xmp_file = optarg_s; + break; case 'u': enable_gles = atoi(optarg_s) == 1 ? true : false; break; @@ -1744,7 +1783,7 @@ int main(int argc, char* argv[]) { } UltraHdrAppInput appInput( hdr_intent_raw_file, sdr_intent_raw_file, sdr_intent_compressed_file, - gainmap_compressed_file, gainmap_metadata_cfg_file, exif_file, + gainmap_compressed_file, gainmap_metadata_cfg_file, exif_file, xmp_file, output_file ? output_file : "out.jpeg", width, height, hdr_cf, sdr_cf, hdr_cg, sdr_cg, hdr_tf, quality, out_tf, out_cf, use_full_range_color_hdr, gainmap_scale_factor, gainmap_compression_quality, use_multi_channel_gainmap, gamma, enable_gles, enc_preset, diff --git a/lib/include/ultrahdr/jpegr.h b/lib/include/ultrahdr/jpegr.h index abf32933..48eb8991 100644 --- a/lib/include/ultrahdr/jpegr.h +++ b/lib/include/ultrahdr/jpegr.h @@ -78,7 +78,8 @@ class JpegR : public UltraHdr { * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* dest, - int quality, uhdr_mem_block_t* exif); + int quality, uhdr_mem_block_t* exif, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Encode API-1. * @@ -95,11 +96,14 @@ class JpegR : public UltraHdr { * \param[in] quality quality factor for sdr intent jpeg compression * \param[in] exif optional exif metadata that needs to be inserted in * compressed output + * \param[in] xmp optional xmp metadata that needs to be inserted in + * compressed output * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_image_t* sdr_intent, - uhdr_compressed_image_t* dest, int quality, uhdr_mem_block_t* exif); + uhdr_compressed_image_t* dest, int quality, uhdr_mem_block_t* exif, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Encode API-2. * @@ -117,12 +121,15 @@ class JpegR : public UltraHdr { * \param[in] sdr_intent_compressed sdr intent compressed input image descriptor * \param[in, out] dest output image descriptor to store compressed ultrahdr * image + * \param[in] xmp optional xmp metadata that needs to be inserted in + * compressed output * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_image_t* sdr_intent, uhdr_compressed_image_t* sdr_intent_compressed, - uhdr_compressed_image_t* dest); + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Encode API-3. * @@ -138,12 +145,15 @@ class JpegR : public UltraHdr { * \param[in] sdr_intent_compressed sdr intent compressed input image descriptor * \param[in, out] dest output image descriptor to store compressed ultrahdr * image + * \param[in] xmp optional xmp metadata that needs to be inserted in + * compressed output * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* sdr_intent_compressed, - uhdr_compressed_image_t* dest); + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Encode API-4. * @@ -157,13 +167,16 @@ class JpegR : public UltraHdr { * \param[in] metadata gainmap metadata descriptor * \param[in, out] dest output image descriptor to store compressed ultrahdr * image + * \param[in] xmp optional xmp metadata that needs to be inserted in + * compressed output * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_compressed_image_t* base_img_compressed, uhdr_compressed_image_t* gainmap_img_compressed, uhdr_gainmap_metadata_ext_t* metadata, - uhdr_compressed_image_t* dest); + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Decode API. * @@ -340,7 +353,8 @@ class JpegR : public UltraHdr { uhdr_compressed_image_t* gainmap_compressed, uhdr_mem_block_t* pExif, void* pIcc, size_t icc_size, uhdr_gainmap_metadata_ext_t* metadata, - uhdr_compressed_image_t* dest); + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* pXmp = nullptr); /* * This method will check the validity of the input arguments. diff --git a/lib/include/ultrahdr/jpegrutils.h b/lib/include/ultrahdr/jpegrutils.h index dad76240..8539f269 100644 --- a/lib/include/ultrahdr/jpegrutils.h +++ b/lib/include/ultrahdr/jpegrutils.h @@ -127,7 +127,8 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t * @return XMP metadata in type of string */ std::string generateXmpForPrimaryImage(size_t secondary_image_length, - uhdr_gainmap_metadata_ext_t& metadata); + uhdr_gainmap_metadata_ext_t& metadata, + uhdr_mem_block_t* user_xmp = nullptr); /* * This method generates XMP metadata for the recovery map image. diff --git a/lib/include/ultrahdr/ultrahdrcommon.h b/lib/include/ultrahdr/ultrahdrcommon.h index 6063781f..16ab8c0a 100644 --- a/lib/include/ultrahdr/ultrahdrcommon.h +++ b/lib/include/ultrahdr/ultrahdrcommon.h @@ -375,6 +375,7 @@ struct uhdr_encoder_private : uhdr_codec_private { m_compressed_images; std::map m_quality; std::vector m_exif; + std::vector m_xmp; uhdr_gainmap_metadata_t m_metadata; uhdr_codec_t m_output_format; int m_gainmap_scale_factor; @@ -408,6 +409,8 @@ struct uhdr_decoder_private : uhdr_codec_private { uhdr_mem_block_t m_exif_block; std::vector m_icc; uhdr_mem_block_t m_icc_block; + std::vector m_xmp; + uhdr_mem_block_t m_xmp_block; std::vector m_base_img; uhdr_mem_block_t m_base_img_block; std::vector m_gainmap_img; diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index 4a5ba667..561cc6ac 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -177,7 +177,8 @@ static void copyJpegWithoutExif(uhdr_compressed_image_t* pDest, uhdr_compressed_ /* Encode API-0 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* dest, - int quality, uhdr_mem_block_t* exif) { + int quality, uhdr_mem_block_t* exif, + uhdr_mem_block_t* xmp) { uhdr_img_fmt_t sdr_intent_fmt; if (hdr_intent->fmt == UHDR_IMG_FMT_24bppYCbCrP010) { sdr_intent_fmt = UHDR_IMG_FMT_12bppYCbCr420; @@ -239,14 +240,15 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compress // append gain map, no ICC since JPEG encode already did it UHDR_ERR_CHECK(appendGainMap(&sdr_intent_compressed, &gainmap_compressed, exif, /* icc */ nullptr, - /* icc size */ 0, &metadata, dest)); + /* icc size */ 0, &metadata, dest, xmp)); return g_no_error; } /* Encode API-1 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_image_t* sdr_intent, uhdr_compressed_image_t* dest, int quality, - uhdr_mem_block_t* exif) { + uhdr_mem_block_t* exif, + uhdr_mem_block_t* xmp) { // generate gain map uhdr_gainmap_metadata_ext_t metadata(kJpegrVersion); std::unique_ptr gainmap; @@ -286,14 +288,15 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_imag // append gain map, no ICC since JPEG encode already did it UHDR_ERR_CHECK(appendGainMap(&sdr_intent_compressed, &gainmap_compressed, exif, /* icc */ nullptr, - /* icc size */ 0, &metadata, dest)); + /* icc size */ 0, &metadata, dest, xmp)); return g_no_error; } /* Encode API-2 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_image_t* sdr_intent, uhdr_compressed_image_t* sdr_intent_compressed, - uhdr_compressed_image_t* dest) { + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp) { JpegDecoderHelper jpeg_dec_obj_sdr; UHDR_ERR_CHECK(jpeg_dec_obj_sdr.decompressImage(sdr_intent_compressed->data, sdr_intent_compressed->data_sz, PARSE_STREAM)); @@ -320,13 +323,14 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_imag UHDR_ERR_CHECK(compressGainMap(gainmap.get(), &jpeg_enc_obj_gm)); uhdr_compressed_image_t gainmap_compressed = jpeg_enc_obj_gm.getCompressedImage(); - return encodeJPEGR(sdr_intent_compressed, &gainmap_compressed, &metadata, dest); + return encodeJPEGR(sdr_intent_compressed, &gainmap_compressed, &metadata, dest, xmp); } /* Encode API-3 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* sdr_intent_compressed, - uhdr_compressed_image_t* dest) { + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp) { // decode input jpeg, gamut is going to be bt601. JpegDecoderHelper jpeg_dec_obj_sdr; UHDR_ERR_CHECK(jpeg_dec_obj_sdr.decompressImage(sdr_intent_compressed->data, @@ -365,8 +369,8 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, status.error_code = UHDR_CODEC_INVALID_PARAM; status.has_detail = 1; snprintf(status.detail, sizeof status.detail, - "sdr intent resolution %dx%d and hdr intent resolution %dx%d do not match", - sdr_intent.w, sdr_intent.h, hdr_intent->w, hdr_intent->h); + "sdr intent resolution %dx%d and hdr intent resolution %dx%d do not match", + sdr_intent.w, sdr_intent.h, hdr_intent->w, hdr_intent->h); return status; } @@ -381,14 +385,15 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, UHDR_ERR_CHECK(compressGainMap(gainmap.get(), &jpeg_enc_obj_gm)); uhdr_compressed_image_t gainmap_compressed = jpeg_enc_obj_gm.getCompressedImage(); - return encodeJPEGR(sdr_intent_compressed, &gainmap_compressed, &metadata, dest); + return encodeJPEGR(sdr_intent_compressed, &gainmap_compressed, &metadata, dest, xmp); } /* Encode API-4 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_compressed_image_t* base_img_compressed, uhdr_compressed_image_t* gainmap_img_compressed, uhdr_gainmap_metadata_ext_t* metadata, - uhdr_compressed_image_t* dest) { + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp) { // We just want to check if ICC is present, so don't do a full decode. Note, // this doesn't verify that the ICC is valid. JpegDecoderHelper decoder; @@ -413,7 +418,7 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_compressed_image_t* base_img_compresse // Add ICC if not already present. if (decoder.getICCSize() > 0) { UHDR_ERR_CHECK(appendGainMap(base_img_compressed, gainmap_img_compressed, /* exif */ nullptr, - /* icc */ nullptr, /* icc size */ 0, metadata, dest)); + /* icc */ nullptr, /* icc size */ 0, metadata, dest, xmp)); } else { if (base_img_compressed->cg <= UHDR_CG_UNSPECIFIED || base_img_compressed->cg > UHDR_CG_BT_2100) { @@ -427,7 +432,7 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_compressed_image_t* base_img_compresse std::shared_ptr newIcc = IccHelper::writeIccProfile(UHDR_CT_SRGB, base_img_compressed->cg); UHDR_ERR_CHECK(appendGainMap(base_img_compressed, gainmap_img_compressed, /* exif */ nullptr, - newIcc->getData(), newIcc->getLength(), metadata, dest)); + newIcc->getData(), newIcc->getLength(), metadata, dest, xmp)); } return g_no_error; @@ -1106,7 +1111,8 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr uhdr_compressed_image_t* gainmap_compressed, uhdr_mem_block_t* pExif, void* pIcc, size_t icc_size, uhdr_gainmap_metadata_ext_t* metadata, - uhdr_compressed_image_t* dest) { + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* pXmp) { if (kWriteXmpMetadata && !metadata->use_base_cg) { uhdr_error_info_t status; status.error_code = UHDR_CODEC_UNSUPPORTED_FEATURE; @@ -1216,6 +1222,18 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr icc_size = decoder.getICCSize(); } + // If the primary JPEG already carries XMP and none was passed in, reuse it so the + // "Write XMP" block below emits/merges it; the APP marker skip in the reorder loop would otherwise + // silently drop it from the output. + uhdr_mem_block_t xmp_from_jpg; + xmp_from_jpg.data = nullptr; + xmp_from_jpg.data_sz = 0; + if (pXmp == nullptr && decoder.getXMPSize() > 0) { + xmp_from_jpg.data = decoder.getXMPPtr(); + xmp_from_jpg.data_sz = decoder.getXMPSize(); + pXmp = &xmp_from_jpg; + } + size_t pos = 0; // Begin primary image // Write SOI @@ -1249,17 +1267,36 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr } // Prepare and write XMP + std::string xmp_primary_str; if (kWriteXmpMetadata) { - const string xmp_primary = generateXmpForPrimaryImage(secondary_image_size, *metadata); - const size_t length = 2 + xmpNameSpaceLength + xmp_primary.size(); - const uint8_t lengthH = ((length >> 8) & 0xff); - const uint8_t lengthL = (length & 0xff); - UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &lengthH, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &lengthL, 1, pos)); - UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); - UHDR_ERR_CHECK(Write(dest, (void*)xmp_primary.c_str(), xmp_primary.size(), pos)); + xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, pXmp); + } else if (pXmp != nullptr && pXmp->data != nullptr && pXmp->data_sz > 0) { + const std::string kXmpHeader = "http://ns.adobe.com/xap/1.0/"; + if (pXmp->data_sz > kXmpHeader.size() && + memcmp(pXmp->data, kXmpHeader.c_str(), kXmpHeader.size()) == 0) { + size_t offset = kXmpHeader.size(); + if (static_cast(pXmp->data)[offset] == '\0') { + offset++; + } + xmp_primary_str = std::string(static_cast(pXmp->data) + offset, + pXmp->data_sz - offset); + } else { + xmp_primary_str = std::string(static_cast(pXmp->data), pXmp->data_sz); + } + } + + if (!xmp_primary_str.empty()) { + const size_t length = 2 + xmpNameSpaceLength + xmp_primary_str.size(); + if (length <= 65535) { + const uint8_t lengthH = ((length >> 8) & 0xff); + const uint8_t lengthL = (length & 0xff); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &lengthH, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &lengthL, 1, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)xmp_primary_str.c_str(), xmp_primary_str.size(), pos)); + } } // Write ICC diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 3f064ade..032b03d2 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -874,10 +874,61 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t } string generateXmpForPrimaryImage(size_t secondary_image_length, - uhdr_gainmap_metadata_ext_t& metadata) { + uhdr_gainmap_metadata_ext_t& metadata, + uhdr_mem_block_t* user_xmp) { const vector kConDirSeq({kConDirectory, string("rdf:Seq")}); const vector kLiItem({string("rdf:li"), kConItem}); + if (user_xmp != nullptr && user_xmp->data != nullptr && user_xmp->data_sz > 0) { + const std::string kXmpHeader = "http://ns.adobe.com/xap/1.0/"; + std::string existing_xmp; + if (user_xmp->data_sz > kXmpHeader.size() && + memcmp(user_xmp->data, kXmpHeader.c_str(), kXmpHeader.size()) == 0) { + size_t offset = kXmpHeader.size(); + if (static_cast(user_xmp->data)[offset] == '\0') { + offset++; + } + existing_xmp = std::string(static_cast(user_xmp->data) + offset, + user_xmp->data_sz - offset); + } else { + existing_xmp = std::string(static_cast(user_xmp->data), user_xmp->data_sz); + } + + std::stringstream desc_ss; + photos_editing_formats::image_io::XmlWriter writer(desc_ss); + writer.StartWritingElement("rdf:Description"); + writer.WriteXmlns(kContainerPrefix, kContainerUri); + writer.WriteXmlns(kItemPrefix, kItemUri); + writer.WriteXmlns(kGainMapPrefix, kGainMapUri); + writer.WriteAttributeNameAndValue(kMapVersion, metadata.version); + + writer.StartWritingElements(kConDirSeq); + + size_t item_depth = writer.StartWritingElement("rdf:li"); + writer.WriteAttributeNameAndValue("rdf:parseType", "Resource"); + writer.StartWritingElement(kConItem); + writer.WriteAttributeNameAndValue(kItemSemantic, kSemanticPrimary); + writer.WriteAttributeNameAndValue(kItemMime, kMimeImageJpeg); + writer.FinishWritingElementsToDepth(item_depth); + + writer.StartWritingElement("rdf:li"); + writer.WriteAttributeNameAndValue("rdf:parseType", "Resource"); + writer.StartWritingElement(kConItem); + writer.WriteAttributeNameAndValue(kItemSemantic, kSemanticGainMap); + writer.WriteAttributeNameAndValue(kItemMime, kMimeImageJpeg); + writer.WriteAttributeNameAndValue(kItemLength, secondary_image_length); + + writer.FinishWriting(); + + std::string desc_str = desc_ss.str(); + size_t rdf_close_pos = existing_xmp.rfind(""); + if (rdf_close_pos != std::string::npos) { + std::string merged = existing_xmp; + merged.insert(rdf_close_pos, desc_str + "\n"); + return merged; + } + } + std::stringstream ss; photos_editing_formats::image_io::XmlWriter writer(ss); writer.StartWritingElement("x:xmpmeta"); diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 74420083..2283ae3e 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -1156,6 +1156,45 @@ uhdr_error_info_t uhdr_enc_set_exif_data(uhdr_codec_private_t* enc, uhdr_mem_blo return status; } +uhdr_error_info_t uhdr_enc_set_xmp_data(uhdr_codec_private_t* enc, uhdr_mem_block_t* xmp) { + uhdr_error_info_t status = g_no_error; + if (dynamic_cast(enc) == nullptr) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for uhdr codec instance"); + } else if (xmp == nullptr) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for xmp image handle"); + } else if (xmp->data == nullptr) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for xmp->data field"); + } else if (xmp->capacity < xmp->data_sz) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "xmp->capacity %zd is less than xmp->data_sz %zd", xmp->capacity, xmp->data_sz); + } + if (status.error_code != UHDR_CODEC_OK) return status; + + uhdr_encoder_private* handle = dynamic_cast(enc); + if (handle->m_sailed) { + status.error_code = UHDR_CODEC_INVALID_OPERATION; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "An earlier call to uhdr_encode() has switched the context from configurable state to " + "end state. The context is no longer configurable. To reuse, call reset()"); + return status; + } + + uint8_t* data = static_cast(xmp->data); + std::vector entry(data, data + xmp->data_sz); + handle->m_xmp = std::move(entry); + + return status; +} + uhdr_error_info_t uhdr_enc_set_output_format(uhdr_codec_private_t* enc, uhdr_codec_t media_type) { uhdr_error_info_t status = g_no_error; @@ -1269,6 +1308,12 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { exif.capacity = exif.data_sz = handle->m_exif.size(); } + uhdr_mem_block_t xmp{}; + if (handle->m_xmp.size() > 0) { + xmp.data = handle->m_xmp.data(); + xmp.capacity = xmp.data_sz = handle->m_xmp.size(); + } + if (handle->m_output_format == UHDR_CODEC_JPG) { ultrahdr::JpegR jpegr(nullptr, handle->m_gainmap_scale_factor, handle->m_quality.find(UHDR_GAIN_MAP_IMG)->second, @@ -1289,7 +1334,8 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { // api - 4 status = jpegr.encodeJPEGR(base_entry.get(), gainmap_entry.get(), &metadata, - handle->m_compressed_output_buffer.get()); + handle->m_compressed_output_buffer.get(), + handle->m_xmp.size() > 0 ? &xmp : nullptr); } else if (handle->m_raw_images.find(UHDR_HDR_IMG) != handle->m_raw_images.end()) { auto& hdr_raw_entry = handle->m_raw_images.find(UHDR_HDR_IMG)->second; @@ -1302,14 +1348,16 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { // api - 0 status = jpegr.encodeJPEGR(hdr_raw_entry.get(), handle->m_compressed_output_buffer.get(), handle->m_quality.find(UHDR_BASE_IMG)->second, - handle->m_exif.size() > 0 ? &exif : nullptr); + handle->m_exif.size() > 0 ? &exif : nullptr, + handle->m_xmp.size() > 0 ? &xmp : nullptr); } else if (handle->m_compressed_images.find(UHDR_SDR_IMG) != handle->m_compressed_images.end() && handle->m_raw_images.find(UHDR_SDR_IMG) == handle->m_raw_images.end()) { auto& sdr_compressed_entry = handle->m_compressed_images.find(UHDR_SDR_IMG)->second; // api - 3 status = jpegr.encodeJPEGR(hdr_raw_entry.get(), sdr_compressed_entry.get(), - handle->m_compressed_output_buffer.get()); + handle->m_compressed_output_buffer.get(), + handle->m_xmp.size() > 0 ? &xmp : nullptr); } else if (handle->m_raw_images.find(UHDR_SDR_IMG) != handle->m_raw_images.end()) { auto& sdr_raw_entry = handle->m_raw_images.find(UHDR_SDR_IMG)->second; @@ -1318,13 +1366,15 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { status = jpegr.encodeJPEGR(hdr_raw_entry.get(), sdr_raw_entry.get(), handle->m_compressed_output_buffer.get(), handle->m_quality.find(UHDR_BASE_IMG)->second, - handle->m_exif.size() > 0 ? &exif : nullptr); + handle->m_exif.size() > 0 ? &exif : nullptr, + handle->m_xmp.size() > 0 ? &xmp : nullptr); } else { auto& sdr_compressed_entry = handle->m_compressed_images.find(UHDR_SDR_IMG)->second; // api - 2 status = jpegr.encodeJPEGR(hdr_raw_entry.get(), sdr_raw_entry.get(), sdr_compressed_entry.get(), - handle->m_compressed_output_buffer.get()); + handle->m_compressed_output_buffer.get(), + handle->m_xmp.size() > 0 ? &xmp : nullptr); } } } else { @@ -1471,6 +1521,7 @@ void uhdr_reset_encoder(uhdr_codec_private_t* enc) { handle->m_quality.emplace(UHDR_BASE_IMG, ultrahdr::kBaseCompressQualityDefault); handle->m_quality.emplace(UHDR_GAIN_MAP_IMG, ultrahdr::kMapCompressQualityDefault); handle->m_exif.clear(); + handle->m_xmp.clear(); handle->m_output_format = UHDR_CODEC_JPG; handle->m_gainmap_scale_factor = ultrahdr::kMapDimensionScaleFactorDefault; handle->m_use_multi_channel_gainmap = ultrahdr::kUseMultiChannelGainMapDefault; @@ -1802,6 +1853,9 @@ uhdr_error_info_t uhdr_dec_probe(uhdr_codec_private_t* dec) { handle->m_icc = std::move(primary_image.iccData); handle->m_icc_block.data = handle->m_icc.data(); handle->m_icc_block.data_sz = handle->m_icc_block.capacity = handle->m_icc.size(); + handle->m_xmp = std::move(primary_image.xmpData); + handle->m_xmp_block.data = handle->m_xmp.data(); + handle->m_xmp_block.data_sz = handle->m_xmp_block.capacity = handle->m_xmp.size(); handle->m_base_img = std::move(primary_image.imgData); handle->m_base_img_block.data = handle->m_base_img.data(); handle->m_base_img_block.data_sz = handle->m_base_img_block.capacity = @@ -1893,6 +1947,19 @@ uhdr_mem_block_t* uhdr_dec_get_icc(uhdr_codec_private_t* dec) { return &handle->m_icc_block; } +uhdr_mem_block_t* uhdr_dec_get_xmp(uhdr_codec_private_t* dec) { + if (dynamic_cast(dec) == nullptr) { + return nullptr; + } + + uhdr_decoder_private* handle = dynamic_cast(dec); + if (!handle->m_probed || handle->m_probe_call_status.error_code != UHDR_CODEC_OK) { + return nullptr; + } + + return handle->m_xmp.size() > 0 ? &handle->m_xmp_block : nullptr; +} + uhdr_mem_block_t* uhdr_dec_get_base_image(uhdr_codec_private_t* dec) { if (dynamic_cast(dec) == nullptr) { return nullptr; @@ -2091,6 +2158,8 @@ void uhdr_reset_decoder(uhdr_codec_private_t* dec) { memset(&handle->m_exif_block, 0, sizeof handle->m_exif_block); handle->m_icc.clear(); memset(&handle->m_icc_block, 0, sizeof handle->m_icc_block); + handle->m_xmp.clear(); + memset(&handle->m_xmp_block, 0, sizeof handle->m_xmp_block); handle->m_base_img.clear(); memset(&handle->m_base_img_block, 0, sizeof handle->m_base_img_block); handle->m_gainmap_img.clear(); diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index b9b2f005..4422365d 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -284,6 +284,141 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi2AndDecode) { uhdr_release_encoder(enc); } +TEST_F(UltraHdrApiTest, JpegEncodeWithXmpAndDecode) { + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + + EXPECT_EQ(uhdr_enc_set_raw_image(enc, &mHdrRaw, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_quality(enc, 85, UHDR_BASE_IMG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_quality(enc, 85, UHDR_GAIN_MAP_IMG).error_code, UHDR_CODEC_OK); + + const std::string kUserXmp = + "\n" + " \n" + " \n" + " \n" + " \n" + " Greg Benz\n" + " \n" + " \n" + " \n" + " \n" + " Copyright 2026 Greg Benz\n" + " \n" + " \n" + " \n" + " \n" + "\n"; + + uhdr_mem_block_t xmp_block{}; + xmp_block.data = const_cast(kUserXmp.data()); + xmp_block.data_sz = xmp_block.capacity = kUserXmp.size(); + + EXPECT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + + ASSERT_EQ(uhdr_encode(enc).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + ASSERT_GT(output->data_sz, 0u); + + // Decode and verify XMP preservation + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + EXPECT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + ASSERT_GT(decoded_xmp->data_sz, 0u); + + std::string decoded_xmp_str(static_cast(decoded_xmp->data), decoded_xmp->data_sz); + + // Verify that user custom metadata was preserved + EXPECT_NE(decoded_xmp_str.find("Greg Benz"), std::string::npos); + EXPECT_NE(decoded_xmp_str.find("Copyright 2026 Greg Benz"), std::string::npos); + + // Verify gain map metadata was decoded and valid + uhdr_gainmap_metadata_t* gm_meta = uhdr_dec_get_gainmap_metadata(dec); + ASSERT_NE(gm_meta, nullptr); + + EXPECT_EQ(uhdr_decode(dec).error_code, UHDR_CODEC_OK); + + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, JpegEncodeApi2WithXmpAndDecode) { + // First, produce a base JPEG that carries XMP + uhdr_codec_private_t* enc1 = uhdr_create_encoder(); + ASSERT_NE(enc1, nullptr); + EXPECT_EQ(uhdr_enc_set_raw_image(enc1, &mHdrRaw, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_output_format(enc1, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const std::string kUserXmp = + "\n" + " \n" + " \n" + " Base Image With XMP\n" + " \n" + " \n" + "\n"; + + uhdr_mem_block_t xmp_block{}; + xmp_block.data = const_cast(kUserXmp.data()); + xmp_block.data_sz = xmp_block.capacity = kUserXmp.size(); + EXPECT_EQ(uhdr_enc_set_xmp_data(enc1, &xmp_block).error_code, UHDR_CODEC_OK); + + ASSERT_EQ(uhdr_encode(enc1).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* enc1_out = uhdr_get_encoded_stream(enc1); + ASSERT_NE(enc1_out, nullptr); + + // Probe and extract the base image with its embedded XMP + uhdr_codec_private_t* dec1 = uhdr_create_decoder(); + ASSERT_NE(dec1, nullptr); + EXPECT_EQ(uhdr_dec_set_image(dec1, enc1_out).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_probe(dec1).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* base_img = uhdr_dec_get_base_image(dec1); + ASSERT_NE(base_img, nullptr); + + uhdr_compressed_image_t sdr_compressed_with_xmp{}; + sdr_compressed_with_xmp.data = base_img->data; + sdr_compressed_with_xmp.data_sz = base_img->data_sz; + sdr_compressed_with_xmp.capacity = base_img->capacity; + sdr_compressed_with_xmp.cg = UHDR_CG_DISPLAY_P3; + sdr_compressed_with_xmp.ct = UHDR_CT_SRGB; + sdr_compressed_with_xmp.range = UHDR_CR_FULL_RANGE; + + // Now use API-2 with this compressed SDR image carrying XMP, WITHOUT explicitly setting XMP + uhdr_codec_private_t* enc2 = uhdr_create_encoder(); + ASSERT_NE(enc2, nullptr); + EXPECT_EQ(uhdr_enc_set_raw_image(enc2, &mHdrRaw, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_compressed_image(enc2, &sdr_compressed_with_xmp, UHDR_SDR_IMG).error_code, + UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_output_format(enc2, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + ASSERT_EQ(uhdr_encode(enc2).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* enc2_out = uhdr_get_encoded_stream(enc2); + ASSERT_NE(enc2_out, nullptr); + + // Decode and verify that the base image's XMP was preserved automatically + uhdr_codec_private_t* dec2 = uhdr_create_decoder(); + ASSERT_NE(dec2, nullptr); + EXPECT_EQ(uhdr_dec_set_image(dec2, enc2_out).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_probe(dec2).error_code, UHDR_CODEC_OK); + + uhdr_mem_block_t* dec2_xmp = uhdr_dec_get_xmp(dec2); + ASSERT_NE(dec2_xmp, nullptr); + std::string dec2_xmp_str(static_cast(dec2_xmp->data), dec2_xmp->data_sz); + EXPECT_NE(dec2_xmp_str.find("Base Image With XMP"), std::string::npos); + + uhdr_release_decoder(dec2); + uhdr_release_encoder(enc2); + uhdr_release_decoder(dec1); + uhdr_release_encoder(enc1); +} + // ============================================================================ // HEIF / HEIC Tests (API-0, API-1, and Unsupported APIs) // ============================================================================ diff --git a/ultrahdr_api.h b/ultrahdr_api.h index 39e529f5..334c8ed7 100644 --- a/ultrahdr_api.h +++ b/ultrahdr_api.h @@ -404,6 +404,19 @@ UHDR_EXTERN uhdr_error_info_t uhdr_enc_set_quality(uhdr_codec_private_t* enc, in UHDR_EXTERN uhdr_error_info_t uhdr_enc_set_exif_data(uhdr_codec_private_t* enc, uhdr_mem_block_t* exif); +/*!\brief Set XMP data that needs to be inserted in the output compressed stream. This function + * does not generate or validate xmp data on its own. It merely copies the supplied information + * into the bitstream (or merges with gain map metadata in xmp mode). + * + * \param[in] enc encoder instance + * \param[in] xmp xmp data memory block. + * + * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, + * #UHDR_CODEC_INVALID_PARAM otherwise. + */ +UHDR_EXTERN uhdr_error_info_t uhdr_enc_set_xmp_data(uhdr_codec_private_t* enc, + uhdr_mem_block_t* xmp); + /*!\brief Enable/Disable multi-channel gainmap. By default multi-channel gainmap is enabled. * * \param[in] enc encoder instance. @@ -746,6 +759,14 @@ UHDR_EXTERN uhdr_mem_block_t* uhdr_dec_get_exif(uhdr_codec_private_t* dec); */ UHDR_EXTERN uhdr_mem_block_t* uhdr_dec_get_icc(uhdr_codec_private_t* dec); +/*!\brief Get xmp information + * + * \param[in] dec decoder instance. + * + * \return nullptr if probe call is unsuccessful, memory block with xmp data otherwise + */ +UHDR_EXTERN uhdr_mem_block_t* uhdr_dec_get_xmp(uhdr_codec_private_t* dec); + /*!\brief Get base image (compressed) * * \param[in] dec decoder instance. From 8ff0b83f9210cac69852653699aa0732a5b6a0cd Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Sat, 12 Sep 2026 00:04:26 -0500 Subject: [PATCH 02/10] Fix JPEG XMP output sizing and reject oversized packets --- lib/src/jpegr.cpp | 29 +++++--- lib/src/ultrahdr_api.cpp | 29 ++++++++ tests/ultrahdr_api_test.cpp | 137 ++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 10 deletions(-) diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index 561cc6ac..f778f534 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -1286,17 +1286,26 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr } if (!xmp_primary_str.empty()) { - const size_t length = 2 + xmpNameSpaceLength + xmp_primary_str.size(); - if (length <= 65535) { - const uint8_t lengthH = ((length >> 8) & 0xff); - const uint8_t lengthL = (length & 0xff); - UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &lengthH, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &lengthL, 1, pos)); - UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); - UHDR_ERR_CHECK(Write(dest, (void*)xmp_primary_str.c_str(), xmp_primary_str.size(), pos)); + constexpr size_t kJpegSegmentMaxLength = 0xffff; + if (xmpNameSpaceLength > kJpegSegmentMaxLength - 2 || + xmp_primary_str.size() > kJpegSegmentMaxLength - 2 - xmpNameSpaceLength) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "serialized XMP metadata exceeds the JPEG APP1 segment size limit"); + return status; } + + const size_t length = 2 + xmpNameSpaceLength + xmp_primary_str.size(); + const uint8_t lengthH = ((length >> 8) & 0xff); + const uint8_t lengthL = (length & 0xff); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &lengthH, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &lengthL, 1, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)xmp_primary_str.c_str(), xmp_primary_str.size(), pos)); } // Write ICC diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 2283ae3e..f9a55ca5 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -10,6 +10,7 @@ #include #include +#include #include "ultrahdr_api.h" #include "ultrahdr/ultrahdrcommon.h" @@ -42,6 +43,14 @@ using namespace photos_editing_formats::image_io; namespace ultrahdr { +namespace { + +// A standard XMP APP1 segment can contain 65,504 bytes of packet data, in addition to its +// marker, length field, and Adobe namespace identifier. +constexpr size_t kJpegXmpApp1Allowance = 0xffff + 2; + +} // namespace + uhdr_memory_block::uhdr_memory_block(size_t capacity) { m_buffer = std::make_unique(capacity); m_capacity = capacity; @@ -1315,6 +1324,18 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { } if (handle->m_output_format == UHDR_CODEC_JPG) { + const bool mayWriteXmp = !handle->m_xmp.empty() || !handle->m_compressed_images.empty(); + const size_t xmpAllowance = mayWriteXmp ? ultrahdr::kJpegXmpApp1Allowance : 0; + auto addXmpAllowance = [xmpAllowance](size_t base_size, size_t* output_size) { + if (xmpAllowance > (std::numeric_limits::max)() - base_size) return false; + *output_size = base_size + xmpAllowance; + return true; + }; + auto reportOutputSizeOverflow = [&status]() { + status.error_code = UHDR_CODEC_ERROR; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "compressed output size calculation overflowed"); + }; ultrahdr::JpegR jpegr(nullptr, handle->m_gainmap_scale_factor, handle->m_quality.find(UHDR_GAIN_MAP_IMG)->second, handle->m_use_multi_channel_gainmap, handle->m_gamma, @@ -1327,6 +1348,10 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { size_t size = (std::max)(((size_t)64 * 1024), 2 * (base_entry->data_sz + gainmap_entry->data_sz)); + if (!addXmpAllowance(size, &size)) { + reportOutputSizeOverflow(); + return status; + } handle->m_compressed_output_buffer = std::make_unique( UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED, size); @@ -1340,6 +1365,10 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { auto& hdr_raw_entry = handle->m_raw_images.find(UHDR_HDR_IMG)->second; size_t size = (std::max)((64u * 1024), hdr_raw_entry->w * hdr_raw_entry->h * 3 * 2); + if (!addXmpAllowance(size, &size)) { + reportOutputSizeOverflow(); + return status; + } handle->m_compressed_output_buffer = std::make_unique( UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED, size); diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 4422365d..a0ce994d 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -8,9 +8,11 @@ #include #endif #include +#include #include #include #include +#include #include #include #include @@ -29,6 +31,39 @@ static const char* kSdrJpgFileName = "jpeg_image.jpg"; static const size_t kImageWidth = 1280; static const size_t kImageHeight = 720; +static std::string makeLargeXmp(size_t size) { + const std::string prefix = + ""; + EXPECT_GE(size, prefix.size() + suffix.size()); + return prefix + std::string(size - prefix.size() - suffix.size(), 'X') + suffix; +} + +static std::vector makeSmallHdrData() { + const uint16_t pixel[] = {0x3c00, 0x4000, 0x3800, 0x3c00}; + std::vector data(8 * 8 * sizeof(pixel)); + for (size_t i = 0; i < 8 * 8; ++i) { + memcpy(data.data() + i * sizeof(pixel), pixel, sizeof(pixel)); + } + return data; +} + +static void expectEncodedXmpDecodes(uhdr_compressed_image_t* output, size_t min_xmp_size) { + ASSERT_NE(output, nullptr); + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + EXPECT_GE(decoded_xmp->data_sz, min_xmp_size); + ASSERT_EQ(uhdr_decode(dec).error_code, UHDR_CODEC_OK); + uhdr_release_decoder(dec); +} + static bool loadFile(const char* filename, std::vector& buffer) { std::vector candidates = { filename, @@ -419,6 +454,108 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi2WithXmpAndDecode) { uhdr_release_encoder(enc1); } +TEST_F(UltraHdrApiTest, LargeXmpFitsSmallRawApiOutput) { +#ifdef UHDR_WRITE_XMP + // The dual writer adds its container directory to the supplied packet. + constexpr size_t kXmpSize = 64000; +#else + constexpr size_t kXmpSize = 65504; +#endif + const std::vector hdr_data = makeSmallHdrData(); + uhdr_raw_image_t hdr{}; + hdr.fmt = UHDR_IMG_FMT_64bppRGBAHalfFloat; + hdr.cg = UHDR_CG_DISPLAY_P3; + hdr.ct = UHDR_CT_LINEAR; + hdr.range = UHDR_CR_FULL_RANGE; + hdr.w = hdr.h = 8; + hdr.planes[UHDR_PLANE_PACKED] = const_cast(hdr_data.data()); + hdr.stride[UHDR_PLANE_PACKED] = 8; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(uhdr_enc_set_raw_image(enc, &hdr, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + const std::string xmp = makeLargeXmp(kXmpSize); + uhdr_mem_block_t xmp_block{const_cast(xmp.data()), xmp.size(), xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const uhdr_error_info_t status = uhdr_encode(enc); + ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + EXPECT_GT(output->data_sz, static_cast(64 * 1024)); + expectEncodedXmpDecodes(output, kXmpSize); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, LargeXmpFitsCompressedBaseGainmapApiOutput) { +#ifdef UHDR_WRITE_XMP + constexpr size_t kXmpSize = 64000; +#else + constexpr size_t kXmpSize = 65504; +#endif + uhdr_gainmap_metadata_t metadata{}; + for (int channel = 0; channel < 3; ++channel) { + metadata.max_content_boost[channel] = 2.0f; + metadata.min_content_boost[channel] = 1.0f; + metadata.gamma[channel] = 1.0f; + } + metadata.hdr_capacity_min = 1.0f; + metadata.hdr_capacity_max = 2.0f; + metadata.use_base_cg = 1; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + const std::string xmp = makeLargeXmp(kXmpSize); + uhdr_mem_block_t xmp_block{const_cast(xmp.data()), xmp.size(), xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const uhdr_error_info_t status = uhdr_encode(enc); + ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + EXPECT_GT(output->data_sz, static_cast(64 * 1024)); + expectEncodedXmpDecodes(output, kXmpSize); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, OversizedFinalXmpIsRejected) { + uhdr_gainmap_metadata_t metadata{}; + for (int channel = 0; channel < 3; ++channel) { + metadata.max_content_boost[channel] = 2.0f; + metadata.min_content_boost[channel] = 1.0f; + metadata.gamma[channel] = 1.0f; + } + metadata.hdr_capacity_min = 1.0f; + metadata.hdr_capacity_max = 2.0f; + metadata.use_base_cg = 1; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); +#ifdef UHDR_WRITE_XMP + const std::string xmp = makeLargeXmp(65504); +#else + const std::string xmp = makeLargeXmp(65505); +#endif + uhdr_mem_block_t xmp_block{const_cast(xmp.data()), xmp.size(), xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const uhdr_error_info_t status = uhdr_encode(enc); + EXPECT_NE(status.error_code, UHDR_CODEC_OK); + EXPECT_TRUE(status.has_detail); + uhdr_release_encoder(enc); +} + // ============================================================================ // HEIF / HEIC Tests (API-0, API-1, and Unsupported APIs) // ============================================================================ From 271a3d77fbf7bc1748a7636f983d1ec521790b0a Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Wed, 16 Sep 2026 00:41:01 +0000 Subject: [PATCH 03/10] refactor(jpeg): unify JPEG segment size constants in jpegr.h --- lib/include/ultrahdr/jpegr.h | 5 +++++ lib/src/jpegr.cpp | 1 - lib/src/ultrahdr_api.cpp | 10 +--------- tests/ultrahdr_api_test.cpp | 1 - 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/include/ultrahdr/jpegr.h b/lib/include/ultrahdr/jpegr.h index 48eb8991..f9047d50 100644 --- a/lib/include/ultrahdr/jpegr.h +++ b/lib/include/ultrahdr/jpegr.h @@ -22,6 +22,11 @@ namespace ultrahdr { +// Maximum length of a JPEG marker segment payload (including the 2-byte length field). +constexpr size_t kJpegSegmentMaxLength = 0xffff; +// Maximum byte footprint for a JPEG application segment including the 2-byte marker code. +constexpr size_t kJpegAppSegmentTotalMaxBytes = kJpegSegmentMaxLength + 2; + /* * Holds information of jpeg image */ diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index f778f534..d93396a1 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -1286,7 +1286,6 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr } if (!xmp_primary_str.empty()) { - constexpr size_t kJpegSegmentMaxLength = 0xffff; if (xmpNameSpaceLength > kJpegSegmentMaxLength - 2 || xmp_primary_str.size() > kJpegSegmentMaxLength - 2 - xmpNameSpaceLength) { uhdr_error_info_t status; diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index f9a55ca5..5b3c196c 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -43,14 +43,6 @@ using namespace photos_editing_formats::image_io; namespace ultrahdr { -namespace { - -// A standard XMP APP1 segment can contain 65,504 bytes of packet data, in addition to its -// marker, length field, and Adobe namespace identifier. -constexpr size_t kJpegXmpApp1Allowance = 0xffff + 2; - -} // namespace - uhdr_memory_block::uhdr_memory_block(size_t capacity) { m_buffer = std::make_unique(capacity); m_capacity = capacity; @@ -1325,7 +1317,7 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { if (handle->m_output_format == UHDR_CODEC_JPG) { const bool mayWriteXmp = !handle->m_xmp.empty() || !handle->m_compressed_images.empty(); - const size_t xmpAllowance = mayWriteXmp ? ultrahdr::kJpegXmpApp1Allowance : 0; + const size_t xmpAllowance = mayWriteXmp ? ultrahdr::kJpegAppSegmentTotalMaxBytes : 0; auto addXmpAllowance = [xmpAllowance](size_t base_size, size_t* output_size) { if (xmpAllowance > (std::numeric_limits::max)() - base_size) return false; *output_size = base_size + xmpAllowance; diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index a0ce994d..152c9eb9 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -15,7 +15,6 @@ #include #include #include -#include #include "ultrahdr_api.h" #include "ultrahdr/ultrahdrcommon.h" From 53f740b914c913127892fcb3c5c6c6f2fd896d93 Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Wed, 16 Sep 2026 13:46:14 -0500 Subject: [PATCH 04/10] Fix primary JPEG XMP merging across namespace prefixes and reencoding --- lib/include/ultrahdr/jpegr.h | 3 + lib/include/ultrahdr/jpegrutils.h | 5 +- lib/src/jpegr.cpp | 9 + lib/src/jpegrutils.cpp | 543 ++++++++++++++++++++++++++++-- tests/ultrahdr_api_test.cpp | 520 ++++++++++++++++++++++++++++ 5 files changed, 1042 insertions(+), 38 deletions(-) diff --git a/lib/include/ultrahdr/jpegr.h b/lib/include/ultrahdr/jpegr.h index f9047d50..a4da8a83 100644 --- a/lib/include/ultrahdr/jpegr.h +++ b/lib/include/ultrahdr/jpegr.h @@ -351,8 +351,11 @@ class JpegR : public UltraHdr { * \param[in] metadata gainmap metadata descriptor * \param[in, out] dest output image descriptor to store compressed ultrahdr * image + * \param[in] pXmp optional primary-image XMP packet to preserve and + * merge when XMP metadata is enabled * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. + * An unsupported or unmergeable XMP packet returns #UHDR_CODEC_INVALID_PARAM in XMP mode. */ uhdr_error_info_t appendGainMap(uhdr_compressed_image_t* sdr_intent_compressed, uhdr_compressed_image_t* gainmap_compressed, diff --git a/lib/include/ultrahdr/jpegrutils.h b/lib/include/ultrahdr/jpegrutils.h index 8539f269..6c217dd5 100644 --- a/lib/include/ultrahdr/jpegrutils.h +++ b/lib/include/ultrahdr/jpegrutils.h @@ -124,7 +124,10 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t * * * @param secondary_image_length length of secondary image - * @return XMP metadata in type of string + * @param user_xmp optional XMP packet from the primary image. Its unrelated XML is preserved while + * the encoder-owned primary-image gain-map description is replaced. + * @return XMP metadata in type of string. An empty string means that a supplied packet could not be + * safely merged. */ std::string generateXmpForPrimaryImage(size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index d93396a1..ffc2e4ff 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -1270,6 +1270,15 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr std::string xmp_primary_str; if (kWriteXmpMetadata) { xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, pXmp); + if (pXmp != nullptr && pXmp->data != nullptr && pXmp->data_sz > 0 && + xmp_primary_str.empty()) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "unable to safely merge supplied XMP metadata into the primary image"); + return status; + } } else if (pXmp != nullptr && pXmp->data != nullptr && pXmp->data_sz > 0) { const std::string kXmpHeader = "http://ns.adobe.com/xap/1.0/"; if (pXmp->data_sz > kXmpHeader.size() && diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 032b03d2..389d46b5 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -10,6 +10,8 @@ #include #include +#include +#include #include "ultrahdr/ultrahdrcommon.h" #include "ultrahdr/jpegr.h" @@ -424,6 +426,7 @@ class XMPXmlHandler : public XmlHandler { }; // GContainer XMP constants - URI and namespace prefix +const string kRdfUri = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; const string kContainerUri = "http://ns.google.com/photos/1.0/container/"; const string kContainerPrefix = "Container"; @@ -873,12 +876,510 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t return uhdr_validate_gainmap_metadata_descriptor(metadata); } +namespace { + +constexpr size_t kNoXmpElement = (std::numeric_limits::max)(); + +bool IsXmlWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +bool FindXmlMarkupEnd(const string& xml, size_t begin, size_t* end) { + char quote = 0; + for (size_t pos = begin; pos < xml.size(); ++pos) { + const char c = xml[pos]; + if (quote != 0) { + if (c == quote) quote = 0; + } else if (c == '\'' || c == '"') { + quote = c; + } else if (c == '>') { + *end = pos + 1; + return true; + } + } + return false; +} + +struct XmpAttributeSpan { + string qualified_name; + string value; + string uri; + string local_name; + size_t name_begin = 0; + size_t value_end = 0; + bool namespace_declaration = false; +}; + +struct XmpNamespaceDeclaration { + string prefix; + string uri; +}; + +struct XmpElementSpan { + string qualified_name; + string uri; + string local_name; + vector namespace_declarations; + vector attributes; + size_t parent = kNoXmpElement; + size_t start_begin = 0; + size_t start_end = 0; + size_t end_begin = 0; + size_t end_end = 0; + bool self_closing = false; +}; + +class XmpMergeXmlHandler : public XmlHandler { + public: + explicit XmpMergeXmlHandler(const string& xml) : xml_(xml) {} + + DataMatchResult StartElement(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + pending_name_.clear(); + pending_attributes_.clear(); + pending_attribute_name_.clear(); + pending_attribute_value_.clear(); + pending_attribute_waiting_value_ = false; + pending_name_begin_ = context.GetTokenRange().GetBegin(); + if (!context.BuildTokenValue(&pending_name_)) valid_ = false; + pending_element_ = true; + return context.GetResult(); + } + + DataMatchResult AttributeName(const XmlTokenContext& context) override { + if (!pending_element_ || pending_attribute_waiting_value_) { + valid_ = false; + return context.GetResult(); + } + pending_attribute_name_.clear(); + pending_attribute_name_begin_ = context.GetTokenRange().GetBegin(); + if (!context.BuildTokenValue(&pending_attribute_name_)) valid_ = false; + pending_attribute_waiting_value_ = true; + return context.GetResult(); + } + + DataMatchResult AttributeValue(const XmlTokenContext& context) override { + if (!pending_element_ || !pending_attribute_waiting_value_) { + valid_ = false; + return context.GetResult(); + } + pending_attribute_value_.clear(); + if (!context.BuildTokenValue(&pending_attribute_value_, true)) valid_ = false; + XmpAttributeSpan attribute; + attribute.qualified_name = pending_attribute_name_; + attribute.value = pending_attribute_value_; + attribute.name_begin = pending_attribute_name_begin_; + attribute.value_end = context.GetTokenRange().GetEnd(); + pending_attributes_.push_back(std::move(attribute)); + pending_attribute_name_.clear(); + pending_attribute_value_.clear(); + pending_attribute_waiting_value_ = false; + return context.GetResult(); + } + + DataMatchResult FinishElement(const XmlTokenContext& context) override { + const bool had_pending_element = pending_element_; + if (!FinalizePendingElement()) valid_ = false; + if (!context.GetTokenRange().IsValid()) { + if (!had_pending_element || !last_element_was_self_closing_) valid_ = false; + last_element_was_self_closing_ = false; + return context.GetResult(); + } + + string closing_name; + if (!context.BuildTokenValue(&closing_name)) valid_ = false; + if (stack_.empty()) { + valid_ = false; + return context.GetResult(); + } + XmpElementSpan& element = elements_[stack_.back()]; + const size_t close_name_begin = context.GetTokenRange().GetBegin(); + const size_t close_begin = xml_.rfind('<', close_name_begin); + size_t close_end = 0; + if (close_begin == string::npos || !FindXmlMarkupEnd(xml_, close_begin, &close_end) || + closing_name != element.qualified_name) { + valid_ = false; + } else { + element.end_begin = close_begin; + element.end_end = close_end; + stack_.pop_back(); + if (stack_.empty()) root_closed_ = true; + } + return context.GetResult(); + } + + DataMatchResult ElementContent(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + if (!stack_.empty() && stack_.back() == root_index_) { + string value; + if (!context.BuildTokenValue(&value) || !IsOuterWhitespace(value)) valid_ = false; + } + return context.GetResult(); + } + + DataMatchResult Comment(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + return context.GetResult(); + } + + DataMatchResult Cdata(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + if (!stack_.empty() && stack_.back() == root_index_) valid_ = false; + return context.GetResult(); + } + + DataMatchResult Pi(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + return context.GetResult(); + } + + bool IsComplete() const { + return valid_ && !pending_element_ && stack_.empty() && root_closed_ && + root_index_ != kNoXmpElement; + } + + const vector& elements() const { return elements_; } + size_t root_index() const { return root_index_; } + + private: + bool LookupNamespace(size_t parent, const vector& local, + const string& prefix, string* uri) const { + for (auto it = local.rbegin(); it != local.rend(); ++it) { + if (it->prefix == prefix) { + *uri = it->uri; + return true; + } + } + for (size_t index = parent; index != kNoXmpElement; index = elements_[index].parent) { + const vector& declarations = elements_[index].namespace_declarations; + for (auto it = declarations.rbegin(); it != declarations.rend(); ++it) { + if (it->prefix == prefix) { + *uri = it->uri; + return true; + } + } + } + if (prefix == "xml") { + *uri = "http://www.w3.org/XML/1998/namespace"; + return true; + } + if (prefix == "xmlns") { + *uri = "http://www.w3.org/2000/xmlns/"; + return true; + } + return false; + } + + bool IsOuterWhitespace(const string& value) const { + size_t begin = 0; + if (value.compare(0, 3, "\xef\xbb\xbf") == 0) begin = 3; + for (; begin < value.size(); ++begin) { + if (!IsXmlWhitespace(value[begin])) return false; + } + return true; + } + + bool ResolveName(const string& qualified_name, size_t parent, + const vector& local, bool attribute, + string* uri, string* local_name) const { + const size_t colon = qualified_name.find(':'); + if (colon == string::npos) { + *local_name = qualified_name; + if (attribute) { + uri->clear(); + return true; + } + if (!LookupNamespace(parent, local, "", uri)) uri->clear(); + return true; + } + if (colon == 0 || colon + 1 >= qualified_name.size() || + qualified_name.find(':', colon + 1) != string::npos) { + return false; + } + const string prefix = qualified_name.substr(0, colon); + if (!LookupNamespace(parent, local, prefix, uri)) return false; + *local_name = qualified_name.substr(colon + 1); + return true; + } + + bool FinalizePendingElement() { + if (!pending_element_) return true; + if (pending_name_.empty() || pending_attribute_waiting_value_) { + pending_element_ = false; + return false; + } + const size_t start_begin = xml_.rfind('<', pending_name_begin_); + size_t start_end = 0; + if (start_begin == string::npos || !FindXmlMarkupEnd(xml_, start_begin, &start_end)) { + pending_element_ = false; + return false; + } + const size_t parent = stack_.empty() ? kNoXmpElement : stack_.back(); + vector namespace_declarations; + for (auto& attribute : pending_attributes_) { + string prefix; + if (attribute.qualified_name == "xmlns") { + attribute.namespace_declaration = true; + } else if (attribute.qualified_name.compare(0, 6, "xmlns:") == 0) { + prefix = attribute.qualified_name.substr(6); + if (prefix.empty() || prefix.find(':') != string::npos) { + pending_element_ = false; + return false; + } + attribute.namespace_declaration = true; + } else { + continue; + } + if (attribute.value.find('&') != string::npos) { + pending_element_ = false; + return false; + } + for (const XmpNamespaceDeclaration& declaration : namespace_declarations) { + if (declaration.prefix == prefix) { + pending_element_ = false; + return false; + } + } + XmpNamespaceDeclaration declaration; + declaration.prefix = prefix; + declaration.uri = attribute.value; + namespace_declarations.push_back(std::move(declaration)); + } + + XmpElementSpan element; + element.qualified_name = pending_name_; + element.parent = parent; + element.start_begin = start_begin; + element.start_end = start_end; + element.namespace_declarations = namespace_declarations; + if (!ResolveName(element.qualified_name, parent, namespace_declarations, false, &element.uri, + &element.local_name)) { + pending_element_ = false; + return false; + } + for (auto& attribute : pending_attributes_) { + if (!attribute.namespace_declaration && + !ResolveName(attribute.qualified_name, parent, namespace_declarations, true, + &attribute.uri, &attribute.local_name)) { + pending_element_ = false; + return false; + } + element.attributes.push_back(std::move(attribute)); + } + size_t last = start_end - 1; + while (last > start_begin && IsXmlWhitespace(xml_[last - 1])) --last; + element.self_closing = last > start_begin && xml_[last - 1] == '/'; + element.end_begin = element.self_closing ? start_begin : 0; + element.end_end = element.self_closing ? start_end : 0; + elements_.push_back(std::move(element)); + const size_t index = elements_.size() - 1; + if (parent == kNoXmpElement) { + if (root_index_ != kNoXmpElement) { + pending_element_ = false; + return false; + } + root_index_ = index; + } + last_element_was_self_closing_ = elements_[index].self_closing; + if (!elements_[index].self_closing) stack_.push_back(index); + else if (stack_.empty()) root_closed_ = true; + pending_element_ = false; + return true; + } + + const string& xml_; + vector elements_; + vector stack_; + size_t root_index_ = kNoXmpElement; + bool root_closed_ = false; + bool valid_ = true; + bool pending_element_ = false; + string pending_name_; + size_t pending_name_begin_ = 0; + vector pending_attributes_; + string pending_attribute_name_; + string pending_attribute_value_; + size_t pending_attribute_name_begin_ = 0; + bool pending_attribute_waiting_value_ = false; + bool last_element_was_self_closing_ = false; +}; + +string GeneratePrimaryDescription(size_t secondary_image_length, + uhdr_gainmap_metadata_ext_t& metadata) { + const vector con_dir_seq({kConDirectory, string("rdf:Seq")}); + stringstream ss; + photos_editing_formats::image_io::XmlWriter writer(ss); + writer.StartWritingElement("rdf:Description"); + writer.WriteXmlns("rdf", kRdfUri); + writer.WriteXmlns(kContainerPrefix, kContainerUri); + writer.WriteXmlns(kItemPrefix, kItemUri); + writer.WriteXmlns(kGainMapPrefix, kGainMapUri); + writer.WriteAttributeNameAndValue(kMapVersion, metadata.version); + writer.StartWritingElements(con_dir_seq); + + const size_t item_depth = writer.StartWritingElement("rdf:li"); + writer.WriteAttributeNameAndValue("rdf:parseType", "Resource"); + writer.StartWritingElement(kConItem); + writer.WriteAttributeNameAndValue(kItemSemantic, kSemanticPrimary); + writer.WriteAttributeNameAndValue(kItemMime, kMimeImageJpeg); + writer.FinishWritingElementsToDepth(item_depth); + + writer.StartWritingElement("rdf:li"); + writer.WriteAttributeNameAndValue("rdf:parseType", "Resource"); + writer.StartWritingElement(kConItem); + writer.WriteAttributeNameAndValue(kItemSemantic, kSemanticGainMap); + writer.WriteAttributeNameAndValue(kItemMime, kMimeImageJpeg); + writer.WriteAttributeNameAndValue(kItemLength, secondary_image_length); + writer.FinishWriting(); + return ss.str(); +} + +string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length, + uhdr_gainmap_metadata_ext_t& metadata) { + const string synthetic_open = ""; + const string synthetic_close = ""; + const size_t offset = synthetic_open.size(); + const string parse_xml = synthetic_open + existing_xmp + synthetic_close; + + XmpMergeXmlHandler handler(parse_xml); + MessageHandler message_handler; + unique_ptr rule(new XmlElementRule); + XmlReader reader(&handler, &message_handler); + if (!reader.StartParse(std::move(rule)) || !reader.Parse(parse_xml) || !reader.FinishParse() || + reader.HasErrors() || !handler.IsComplete()) { + return string(); + } + + const vector& elements = handler.elements(); + const size_t synthetic_root = handler.root_index(); + size_t packet_root = kNoXmpElement; + for (size_t index = 0; index < elements.size(); ++index) { + if (elements[index].parent == synthetic_root) { + if (packet_root != kNoXmpElement) return string(); + packet_root = index; + } + } + if (packet_root == kNoXmpElement) return string(); + + size_t rdf_root = kNoXmpElement; + if (elements[packet_root].uri == kRdfUri && elements[packet_root].local_name == "RDF") { + rdf_root = packet_root; + } else if (elements[packet_root].local_name == "xmpmeta" && + elements[packet_root].uri == "adobe:ns:meta/") { + for (size_t index = 0; index < elements.size(); ++index) { + if (elements[index].parent == packet_root && elements[index].uri == kRdfUri && + elements[index].local_name == "RDF") { + if (rdf_root != kNoXmpElement) return string(); + rdf_root = index; + } + } + } else { + return string(); + } + if (rdf_root == kNoXmpElement || elements[rdf_root].self_closing || + elements[rdf_root].end_begin <= elements[rdf_root].start_begin) { + return string(); + } + + vector primary_descriptions; + for (size_t index = 0; index < elements.size(); ++index) { + const XmpElementSpan& element = elements[index]; + if (element.parent != rdf_root || element.uri != kRdfUri || + element.local_name != "Description") { + continue; + } + bool has_about = false; + bool about_empty = true; + bool has_non_primary_id = false; + for (const XmpAttributeSpan& attribute : element.attributes) { + if (attribute.uri == kRdfUri && attribute.local_name == "about") { + has_about = true; + about_empty = attribute.value.empty(); + } else if (attribute.uri == kRdfUri && + (attribute.local_name == "ID" || attribute.local_name == "nodeID")) { + has_non_primary_id = true; + } + } + if (!has_non_primary_id && (!has_about || about_empty)) primary_descriptions.push_back(index); + } + + vector> removals; + for (const size_t description : primary_descriptions) { + for (size_t index = 0; index < elements.size(); ++index) { + const XmpElementSpan& element = elements[index]; + if (element.parent == description && element.uri == kContainerUri && + element.local_name == "Directory") { + removals.emplace_back(element.start_begin, element.end_end); + } + } + for (const XmpAttributeSpan& attribute : elements[description].attributes) { + if (attribute.uri == kContainerUri && attribute.local_name == "Directory") { + removals.emplace_back(attribute.name_begin, attribute.value_end); + } + } + } + for (const size_t description : primary_descriptions) { + const XmpElementSpan& element = elements[description]; + for (const XmpAttributeSpan& attribute : element.attributes) { + if (attribute.uri == kGainMapUri && attribute.local_name == "Version") { + removals.emplace_back(attribute.name_begin, attribute.value_end); + } + } + for (size_t index = 0; index < elements.size(); ++index) { + const XmpElementSpan& child = elements[index]; + if (child.parent == description && child.uri == kGainMapUri && + child.local_name == "Version") { + removals.emplace_back(child.start_begin, child.end_end); + } + } + } + + if (elements[rdf_root].end_begin < offset || + elements[rdf_root].end_begin - offset > existing_xmp.size()) { + return string(); + } + const size_t insertion = elements[rdf_root].end_begin - offset; + for (auto& removal : removals) { + if (removal.first < offset || removal.second < removal.first) return string(); + removal.first -= offset; + removal.second -= offset; + } + sort(removals.begin(), removals.end()); + for (size_t index = 1; index < removals.size(); ++index) { + if (removals[index].first < removals[index - 1].second) return string(); + } + + const string generated_description = + GeneratePrimaryDescription(secondary_image_length, metadata); + string merged; + size_t cursor = 0; + size_t removal_index = 0; + while (removal_index < removals.size() && removals[removal_index].first < insertion) { + const auto [begin, end] = removals[removal_index++]; + if (begin < cursor || end > existing_xmp.size()) return string(); + merged.append(existing_xmp, cursor, begin - cursor); + cursor = end; + } + if (cursor > insertion) return string(); + merged.append(existing_xmp, cursor, insertion - cursor); + merged.append(generated_description); + merged.push_back('\n'); + cursor = insertion; + while (removal_index < removals.size()) { + const auto [begin, end] = removals[removal_index++]; + if (begin < cursor || end > existing_xmp.size()) return string(); + merged.append(existing_xmp, cursor, begin - cursor); + cursor = end; + } + merged.append(existing_xmp, cursor, existing_xmp.size() - cursor); + return merged; +} + +} // namespace + string generateXmpForPrimaryImage(size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, uhdr_mem_block_t* user_xmp) { - const vector kConDirSeq({kConDirectory, string("rdf:Seq")}); - const vector kLiItem({string("rdf:li"), kConItem}); - if (user_xmp != nullptr && user_xmp->data != nullptr && user_xmp->data_sz > 0) { const std::string kXmpHeader = "http://ns.adobe.com/xap/1.0/"; std::string existing_xmp; @@ -893,42 +1394,10 @@ string generateXmpForPrimaryImage(size_t secondary_image_length, } else { existing_xmp = std::string(static_cast(user_xmp->data), user_xmp->data_sz); } - - std::stringstream desc_ss; - photos_editing_formats::image_io::XmlWriter writer(desc_ss); - writer.StartWritingElement("rdf:Description"); - writer.WriteXmlns(kContainerPrefix, kContainerUri); - writer.WriteXmlns(kItemPrefix, kItemUri); - writer.WriteXmlns(kGainMapPrefix, kGainMapUri); - writer.WriteAttributeNameAndValue(kMapVersion, metadata.version); - - writer.StartWritingElements(kConDirSeq); - - size_t item_depth = writer.StartWritingElement("rdf:li"); - writer.WriteAttributeNameAndValue("rdf:parseType", "Resource"); - writer.StartWritingElement(kConItem); - writer.WriteAttributeNameAndValue(kItemSemantic, kSemanticPrimary); - writer.WriteAttributeNameAndValue(kItemMime, kMimeImageJpeg); - writer.FinishWritingElementsToDepth(item_depth); - - writer.StartWritingElement("rdf:li"); - writer.WriteAttributeNameAndValue("rdf:parseType", "Resource"); - writer.StartWritingElement(kConItem); - writer.WriteAttributeNameAndValue(kItemSemantic, kSemanticGainMap); - writer.WriteAttributeNameAndValue(kItemMime, kMimeImageJpeg); - writer.WriteAttributeNameAndValue(kItemLength, secondary_image_length); - - writer.FinishWriting(); - - std::string desc_str = desc_ss.str(); - size_t rdf_close_pos = existing_xmp.rfind(""); - if (rdf_close_pos != std::string::npos) { - std::string merged = existing_xmp; - merged.insert(rdf_close_pos, desc_str + "\n"); - return merged; - } + return MergePrimaryXmp(existing_xmp, secondary_image_length, metadata); } + const vector kConDirSeq({kConDirectory, string("rdf:Seq")}); std::stringstream ss; photos_editing_formats::image_io::XmlWriter writer(ss); writer.StartWritingElement("x:xmpmeta"); diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 152c9eb9..8f60818f 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -7,20 +7,28 @@ #else #include #endif +#include #include #include #include #include #include +#include #include +#include #include #include #include "ultrahdr_api.h" #include "ultrahdr/ultrahdrcommon.h" #include "ultrahdr/jpegr.h" +#include "ultrahdr/jpegencoderhelper.h" #include "ultrahdr/heifultrahdr.h" #include "ultrahdr/avifultrahdr.h" +#include "image_io/base/message_handler.h" +#include "image_io/xml/xml_element_rules.h" +#include "image_io/xml/xml_handler.h" +#include "image_io/xml/xml_reader.h" namespace ultrahdr { @@ -49,6 +57,125 @@ static std::vector makeSmallHdrData() { return data; } +static std::string getXmpPacket(const uhdr_mem_block_t* xmp) { + constexpr std::string_view kXmpNamespace = "http://ns.adobe.com/xap/1.0/"; + if (xmp == nullptr || xmp->data == nullptr) return {}; + const auto* bytes = static_cast(xmp->data); + size_t offset = 0; + if (xmp->data_sz >= kXmpNamespace.size() && + std::string_view(bytes, kXmpNamespace.size()) == kXmpNamespace) { + offset = kXmpNamespace.size(); + if (offset < xmp->data_sz && bytes[offset] == '\0') ++offset; + } + return std::string(bytes + offset, xmp->data_sz - offset); +} + +#if defined(UHDR_WRITE_XMP) +static uhdr_gainmap_metadata_t makeTestGainmapMetadata() { + uhdr_gainmap_metadata_t metadata{}; + for (int channel = 0; channel < 3; ++channel) { + metadata.max_content_boost[channel] = 2.0f; + metadata.min_content_boost[channel] = 1.0f; + metadata.gamma[channel] = 1.0f; + } + metadata.hdr_capacity_min = 1.0f; + metadata.hdr_capacity_max = 2.0f; + metadata.use_base_cg = 1; + return metadata; +} + +struct ContainerDirectoryInfo { + bool parsed = false; + size_t directory_count = 0; + std::vector gainmap_lengths; +}; + +class ContainerDirectoryHandler : public photos_editing_formats::image_io::XmlHandler { + public: + photos_editing_formats::image_io::DataMatchResult StartElement( + const photos_editing_formats::image_io::XmlTokenContext& context) override { + std::string name; + if (context.BuildTokenValue(&name)) { + element_stack_.push_back(name); + current_attribute_.clear(); + if (name == "Container:Directory") ++directory_count_; + if (name == "Container:Item") { + current_item_is_gainmap_ = false; + current_item_length_.reset(); + } + } + return context.GetResult(); + } + + photos_editing_formats::image_io::DataMatchResult AttributeName( + const photos_editing_formats::image_io::XmlTokenContext& context) override { + context.BuildTokenValue(¤t_attribute_); + return context.GetResult(); + } + + photos_editing_formats::image_io::DataMatchResult AttributeValue( + const photos_editing_formats::image_io::XmlTokenContext& context) override { + std::string value; + if (context.BuildTokenValue(&value, true) && !element_stack_.empty() && + element_stack_.back() == "Container:Item") { + if (current_attribute_ == "Item:Semantic") { + current_item_is_gainmap_ = value == "GainMap"; + } else if (current_attribute_ == "Item:Length") { + size_t parsed = 0; + const auto parse_result = + std::from_chars(value.data(), value.data() + value.size(), parsed); + if (parse_result.ec == std::errc() && parse_result.ptr == value.data() + value.size()) { + current_item_length_ = parsed; + } else { + current_item_length_.reset(); + } + } + } + return context.GetResult(); + } + + photos_editing_formats::image_io::DataMatchResult FinishElement( + const photos_editing_formats::image_io::XmlTokenContext& context) override { + if (!element_stack_.empty()) { + if (element_stack_.back() == "Container:Item" && current_item_is_gainmap_ && + current_item_length_.has_value()) { + gainmap_lengths_.push_back(*current_item_length_); + } + element_stack_.pop_back(); + } + current_attribute_.clear(); + return context.GetResult(); + } + + size_t directoryCount() const { return directory_count_; } + const std::vector& gainmapLengths() const { return gainmap_lengths_; } + + private: + std::vector element_stack_; + std::string current_attribute_; + bool current_item_is_gainmap_ = false; + std::optional current_item_length_; + size_t directory_count_ = 0; + std::vector gainmap_lengths_; +}; + +static ContainerDirectoryInfo getContainerDirectoryInfo(std::string_view xmp) { + ContainerDirectoryInfo info; + ContainerDirectoryHandler handler; + photos_editing_formats::image_io::MessageHandler messages; + photos_editing_formats::image_io::XmlReader reader(&handler, &messages); + if (!reader.StartParse(std::make_unique()) || + !reader.Parse(std::string("") + std::string(xmp) + "") || + !reader.FinishParse() || reader.HasErrors()) { + return info; + } + info.parsed = true; + info.directory_count = handler.directoryCount(); + info.gainmap_lengths = handler.gainmapLengths(); + return info; +} +#endif + static void expectEncodedXmpDecodes(uhdr_compressed_image_t* output, size_t min_xmp_size) { ASSERT_NE(output, nullptr); uhdr_codec_private_t* dec = uhdr_create_decoder(); @@ -318,6 +445,399 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi2AndDecode) { uhdr_release_encoder(enc); } +#if defined(UHDR_WRITE_XMP) +TEST_F(UltraHdrApiTest, JpegApi4ExplicitXmpPreservesRdfNamespacePrefixes) { + struct PrefixControl { + const char* name; + const char* packet; + }; + const PrefixControl controls[] = { + {"conventional", + "semantic-probe" + "keep-unrelated" + ""}, + {"alias", + "semantic-probe" + "keep-unrelated" + ""}, + {"default-rdf", + "semantic-probe" + "keep-unrelated" + ""}, + }; + + for (const PrefixControl& control : controls) { + SCOPED_TRACE(control.name); + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + uhdr_mem_block_t xmp_block{const_cast(control.packet), strlen(control.packet), + strlen(control.packet)}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + const uhdr_error_info_t enc_status = uhdr_encode(enc); + ASSERT_EQ(enc_status.error_code, UHDR_CODEC_OK) << enc_status.detail; + + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + const std::string decoded_packet = getXmpPacket(decoded_xmp); + const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(decoded_packet); + ASSERT_TRUE(directory_info.parsed); + EXPECT_EQ(directory_info.directory_count, 1u); + const size_t generated_description = decoded_packet.rfind("', generated_description); + ASSERT_NE(opening_end, std::string::npos); + const std::string generated_opening = + decoded_packet.substr(generated_description, opening_end - generated_description); + EXPECT_NE(generated_opening.find( + "xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\""), + std::string::npos); + EXPECT_NE(decoded_packet.find("semantic-probe"), std::string::npos); + EXPECT_NE(decoded_packet.find("keep-unrelated"), std::string::npos); + + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); + } +} + +TEST_F(UltraHdrApiTest, JpegApi4XmpMergeReplacesVersionAttributeAndElement) { + struct VersionForm { + const char* name; + const char* packet; + const char* stale_value; + }; + const VersionForm forms[] = { + {"attribute", + "", + "old-attribute"}, + {"element", + "" + "old-element" + "", + "old-element"}, + }; + + for (const VersionForm& form : forms) { + SCOPED_TRACE(form.name); + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + uhdr_mem_block_t xmp_block{const_cast(form.packet), strlen(form.packet), + strlen(form.packet)}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + const uhdr_error_info_t status = uhdr_encode(enc); + ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; + + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + const std::string decoded_packet = getXmpPacket(decoded_xmp); + const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(decoded_packet); + ASSERT_TRUE(directory_info.parsed); + EXPECT_EQ(directory_info.directory_count, 1u); + EXPECT_EQ(decoded_packet.find(form.stale_value), std::string::npos); + EXPECT_NE(decoded_packet.find("hdrgm:Version=\"1.0\""), std::string::npos); + + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); + } +} + +TEST_F(UltraHdrApiTest, JpegApi4GetterSetterReencodeUsesSingleCurrentGainmapDirectory) { + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_codec_private_t* first_enc = uhdr_create_encoder(); + ASSERT_NE(first_enc, nullptr); + ASSERT_EQ(uhdr_enc_set_compressed_image(first_enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(first_enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(first_enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(first_enc).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* first_output = uhdr_get_encoded_stream(first_enc); + ASSERT_NE(first_output, nullptr); + + uhdr_codec_private_t* first_dec = uhdr_create_decoder(); + ASSERT_NE(first_dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(first_dec, first_output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(first_dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* first_xmp = uhdr_dec_get_xmp(first_dec); + ASSERT_NE(first_xmp, nullptr); + ASSERT_NE(first_xmp->data, nullptr); + uhdr_mem_block_t* first_base = uhdr_dec_get_base_image(first_dec); + ASSERT_NE(first_base, nullptr); + uhdr_mem_block_t* first_gainmap = uhdr_dec_get_gainmap_image(first_dec); + ASSERT_NE(first_gainmap, nullptr); + ASSERT_NE(first_gainmap->data, nullptr); + const std::string first_packet = getXmpPacket(first_xmp); + ASSERT_FALSE(first_packet.empty()); + + JpegEncoderHelper changed_gainmap_encoder; + ASSERT_EQ(changed_gainmap_encoder.compressImage(&mSdrRaw, 50, nullptr, 0).error_code, + UHDR_CODEC_OK); + uhdr_compressed_image_t changed_gainmap = changed_gainmap_encoder.getCompressedImage(); + ASSERT_NE(changed_gainmap.data, nullptr); + ASSERT_NE(changed_gainmap.data_sz, first_gainmap->data_sz); + + uhdr_compressed_image_t base_input{ + first_base->data, first_base->data_sz, first_base->capacity, UHDR_CG_BT_709, UHDR_CT_SRGB, + UHDR_CR_FULL_RANGE}; + uhdr_compressed_image_t gainmap_input = changed_gainmap; + uhdr_gainmap_metadata_t* decoded_metadata = uhdr_dec_get_gainmap_metadata(first_dec); + ASSERT_NE(decoded_metadata, nullptr); + + const std::string xpacket_wrapped = + "\n" + first_packet + + "\n"; + const std::string raw_getter(static_cast(first_xmp->data), first_xmp->data_sz); + const std::string xmp_inputs[] = {raw_getter, xpacket_wrapped}; + for (size_t input_index = 0; input_index < std::size(xmp_inputs); ++input_index) { + SCOPED_TRACE(input_index == 0 ? "raw getter bytes" : "xpacket wrapped XML"); + uhdr_codec_private_t* second_enc = uhdr_create_encoder(); + ASSERT_NE(second_enc, nullptr); + ASSERT_EQ(uhdr_enc_set_compressed_image(second_enc, &base_input, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(second_enc, &gainmap_input, decoded_metadata).error_code, + UHDR_CODEC_OK); + uhdr_mem_block_t xmp_input{const_cast(xmp_inputs[input_index].data()), + xmp_inputs[input_index].size(), xmp_inputs[input_index].size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(second_enc, &xmp_input).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(second_enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + const uhdr_error_info_t second_status = uhdr_encode(second_enc); + ASSERT_EQ(second_status.error_code, UHDR_CODEC_OK) << second_status.detail; + uhdr_compressed_image_t* second_output = uhdr_get_encoded_stream(second_enc); + ASSERT_NE(second_output, nullptr); + + uhdr_codec_private_t* second_dec = uhdr_create_decoder(); + ASSERT_NE(second_dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(second_dec, second_output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(second_dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* second_gainmap = uhdr_dec_get_gainmap_image(second_dec); + ASSERT_NE(second_gainmap, nullptr); + ASSERT_NE(second_gainmap->data, nullptr); + ASSERT_NE(second_gainmap->data_sz, first_gainmap->data_sz); + uhdr_mem_block_t* second_xmp = uhdr_dec_get_xmp(second_dec); + ASSERT_NE(second_xmp, nullptr); + ASSERT_NE(second_xmp->data, nullptr); + const std::string second_packet = getXmpPacket(second_xmp); + const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(second_packet); + EXPECT_TRUE(directory_info.parsed); + EXPECT_EQ(directory_info.directory_count, 1u); + EXPECT_EQ(directory_info.gainmap_lengths.size(), 1u); + if (!directory_info.gainmap_lengths.empty()) { + EXPECT_EQ(directory_info.gainmap_lengths.front(), second_gainmap->data_sz); + } + if (input_index == 1) { + EXPECT_NE(second_packet.find( + ""), + std::string::npos); + EXPECT_NE(second_packet.find(""), std::string::npos); + } + + uhdr_release_decoder(second_dec); + uhdr_release_encoder(second_enc); + } + uhdr_release_decoder(first_dec); + uhdr_release_encoder(first_enc); +} + +TEST_F(UltraHdrApiTest, JpegApi4XmpMergeIgnoresFakeRdfClosersInCommentsAndCdata) { + const std::string user_xmp = + "semantic-probe" + "]]>" + "" + ""; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + const uhdr_error_info_t enc_status = uhdr_encode(enc); + ASSERT_EQ(enc_status.error_code, UHDR_CODEC_OK) << enc_status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + const std::string decoded_packet = getXmpPacket(decoded_xmp); + const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(decoded_packet); + ASSERT_TRUE(directory_info.parsed); + EXPECT_EQ(directory_info.directory_count, 1u); + EXPECT_NE(decoded_packet.find("semantic-probe"), std::string::npos); + EXPECT_NE(decoded_packet.find("]]>"), std::string::npos); + EXPECT_NE(decoded_packet.find(""), std::string::npos); + ASSERT_EQ(directory_info.gainmap_lengths.size(), 1u); + uhdr_mem_block_t* decoded_gainmap = uhdr_dec_get_gainmap_image(dec); + ASSERT_NE(decoded_gainmap, nullptr); + ASSERT_NE(decoded_gainmap->data, nullptr); + EXPECT_EQ(directory_info.gainmap_lengths.front(), decoded_gainmap->data_sz); + + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, JpegApi4XmpMergeKeepsOwnedPropertiesOnNodeIdDescription) { + const std::string user_xmp = + "" + "keep-id-directory" + "" + "keep-node-directory" + "" + "primary-probe"; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + const uhdr_error_info_t enc_status = uhdr_encode(enc); + ASSERT_EQ(enc_status.error_code, UHDR_CODEC_OK) << enc_status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + const std::string decoded_packet = getXmpPacket(decoded_xmp); + EXPECT_NE(decoded_packet.find("keep-id-directory"), std::string::npos); + EXPECT_NE(decoded_packet.find("keep-node-directory"), std::string::npos); + EXPECT_NE(decoded_packet.find("primary-probe"), std::string::npos); + + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, JpegApi4RejectsMalformedOrUnmergeableXmp) { + const char* packets[] = { + "", + "" + "", + "" + "unmergeable", + }; + + for (const char* packet : packets) { + SCOPED_TRACE(packet); + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + uhdr_mem_block_t xmp_block{const_cast(packet), strlen(packet), strlen(packet)}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + const uhdr_error_info_t status = uhdr_encode(enc); + EXPECT_EQ(status.error_code, UHDR_CODEC_INVALID_PARAM) << status.detail; + uhdr_release_encoder(enc); + } +} +#endif + +#if !defined(UHDR_WRITE_XMP) && defined(UHDR_WRITE_ISO) +TEST_F(UltraHdrApiTest, JpegApi4IsoOnlyPassesThroughXmpExactly) { + const std::string user_xmp = + "iso-passthrough" + ""; + uhdr_gainmap_metadata_t metadata{}; + for (int channel = 0; channel < 3; ++channel) { + metadata.max_content_boost[channel] = 2.0f; + metadata.min_content_boost[channel] = 1.0f; + metadata.gamma[channel] = 1.0f; + } + metadata.hdr_capacity_min = 1.0f; + metadata.hdr_capacity_max = 2.0f; + metadata.use_base_cg = 1; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + EXPECT_EQ(getXmpPacket(decoded_xmp), user_xmp); + + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); +} +#endif + TEST_F(UltraHdrApiTest, JpegEncodeWithXmpAndDecode) { uhdr_codec_private_t* enc = uhdr_create_encoder(); ASSERT_NE(enc, nullptr); From 77ab722a1ca77c3420f9b59d7df3ac690e1afdc0 Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Wed, 16 Sep 2026 14:06:30 -0500 Subject: [PATCH 05/10] Simplify XMP merge bookkeeping and regression test setup --- lib/src/jpegrutils.cpp | 85 ++++------- tests/ultrahdr_api_test.cpp | 288 +++++++++++++++--------------------- 2 files changed, 145 insertions(+), 228 deletions(-) diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 389d46b5..cde05810 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -923,7 +923,6 @@ struct XmpElementSpan { vector attributes; size_t parent = kNoXmpElement; size_t start_begin = 0; - size_t start_end = 0; size_t end_begin = 0; size_t end_end = 0; bool self_closing = false; @@ -937,9 +936,7 @@ class XmpMergeXmlHandler : public XmlHandler { if (!FinalizePendingElement()) valid_ = false; pending_name_.clear(); pending_attributes_.clear(); - pending_attribute_name_.clear(); - pending_attribute_value_.clear(); - pending_attribute_waiting_value_ = false; + pending_attribute_index_ = kNoXmpElement; pending_name_begin_ = context.GetTokenRange().GetBegin(); if (!context.BuildTokenValue(&pending_name_)) valid_ = false; pending_element_ = true; @@ -947,33 +944,27 @@ class XmpMergeXmlHandler : public XmlHandler { } DataMatchResult AttributeName(const XmlTokenContext& context) override { - if (!pending_element_ || pending_attribute_waiting_value_) { + if (!pending_element_ || pending_attribute_index_ != kNoXmpElement) { valid_ = false; return context.GetResult(); } - pending_attribute_name_.clear(); - pending_attribute_name_begin_ = context.GetTokenRange().GetBegin(); - if (!context.BuildTokenValue(&pending_attribute_name_)) valid_ = false; - pending_attribute_waiting_value_ = true; + XmpAttributeSpan attribute; + attribute.name_begin = context.GetTokenRange().GetBegin(); + if (!context.BuildTokenValue(&attribute.qualified_name)) valid_ = false; + pending_attributes_.push_back(std::move(attribute)); + pending_attribute_index_ = pending_attributes_.size() - 1; return context.GetResult(); } DataMatchResult AttributeValue(const XmlTokenContext& context) override { - if (!pending_element_ || !pending_attribute_waiting_value_) { + if (!pending_element_ || pending_attribute_index_ == kNoXmpElement) { valid_ = false; return context.GetResult(); } - pending_attribute_value_.clear(); - if (!context.BuildTokenValue(&pending_attribute_value_, true)) valid_ = false; - XmpAttributeSpan attribute; - attribute.qualified_name = pending_attribute_name_; - attribute.value = pending_attribute_value_; - attribute.name_begin = pending_attribute_name_begin_; + XmpAttributeSpan& attribute = pending_attributes_[pending_attribute_index_]; + if (!context.BuildTokenValue(&attribute.value, true)) valid_ = false; attribute.value_end = context.GetTokenRange().GetEnd(); - pending_attributes_.push_back(std::move(attribute)); - pending_attribute_name_.clear(); - pending_attribute_value_.clear(); - pending_attribute_waiting_value_ = false; + pending_attribute_index_ = kNoXmpElement; return context.GetResult(); } @@ -1104,7 +1095,7 @@ class XmpMergeXmlHandler : public XmlHandler { bool FinalizePendingElement() { if (!pending_element_) return true; - if (pending_name_.empty() || pending_attribute_waiting_value_) { + if (pending_name_.empty() || pending_attribute_index_ != kNoXmpElement) { pending_element_ = false; return false; } @@ -1150,7 +1141,6 @@ class XmpMergeXmlHandler : public XmlHandler { element.qualified_name = pending_name_; element.parent = parent; element.start_begin = start_begin; - element.start_end = start_end; element.namespace_declarations = namespace_declarations; if (!ResolveName(element.qualified_name, parent, namespace_declarations, false, &element.uri, &element.local_name)) { @@ -1197,10 +1187,7 @@ class XmpMergeXmlHandler : public XmlHandler { string pending_name_; size_t pending_name_begin_ = 0; vector pending_attributes_; - string pending_attribute_name_; - string pending_attribute_value_; - size_t pending_attribute_name_begin_ = 0; - bool pending_attribute_waiting_value_ = false; + size_t pending_attribute_index_ = kNoXmpElement; bool last_element_was_self_closing_ = false; }; @@ -1305,30 +1292,17 @@ string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length vector> removals; for (const size_t description : primary_descriptions) { - for (size_t index = 0; index < elements.size(); ++index) { - const XmpElementSpan& element = elements[index]; - if (element.parent == description && element.uri == kContainerUri && - element.local_name == "Directory") { - removals.emplace_back(element.start_begin, element.end_end); - } - } - for (const XmpAttributeSpan& attribute : elements[description].attributes) { - if (attribute.uri == kContainerUri && attribute.local_name == "Directory") { + const XmpElementSpan& primary = elements[description]; + for (const XmpAttributeSpan& attribute : primary.attributes) { + if ((attribute.uri == kContainerUri && attribute.local_name == "Directory") || + (attribute.uri == kGainMapUri && attribute.local_name == "Version")) { removals.emplace_back(attribute.name_begin, attribute.value_end); } } - } - for (const size_t description : primary_descriptions) { - const XmpElementSpan& element = elements[description]; - for (const XmpAttributeSpan& attribute : element.attributes) { - if (attribute.uri == kGainMapUri && attribute.local_name == "Version") { - removals.emplace_back(attribute.name_begin, attribute.value_end); - } - } - for (size_t index = 0; index < elements.size(); ++index) { - const XmpElementSpan& child = elements[index]; - if (child.parent == description && child.uri == kGainMapUri && - child.local_name == "Version") { + for (const XmpElementSpan& child : elements) { + if (child.parent == description && + ((child.uri == kContainerUri && child.local_name == "Directory") || + (child.uri == kGainMapUri && child.local_name == "Version"))) { removals.emplace_back(child.start_begin, child.end_end); } } @@ -1343,6 +1317,7 @@ string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length if (removal.first < offset || removal.second < removal.first) return string(); removal.first -= offset; removal.second -= offset; + if (removal.second > insertion) return string(); } sort(removals.begin(), removals.end()); for (size_t index = 1; index < removals.size(); ++index) { @@ -1353,25 +1328,15 @@ string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length GeneratePrimaryDescription(secondary_image_length, metadata); string merged; size_t cursor = 0; - size_t removal_index = 0; - while (removal_index < removals.size() && removals[removal_index].first < insertion) { - const auto [begin, end] = removals[removal_index++]; - if (begin < cursor || end > existing_xmp.size()) return string(); + for (const auto [begin, end] : removals) { + if (begin < cursor || end > insertion || end > existing_xmp.size()) return string(); merged.append(existing_xmp, cursor, begin - cursor); cursor = end; } - if (cursor > insertion) return string(); merged.append(existing_xmp, cursor, insertion - cursor); merged.append(generated_description); merged.push_back('\n'); - cursor = insertion; - while (removal_index < removals.size()) { - const auto [begin, end] = removals[removal_index++]; - if (begin < cursor || end > existing_xmp.size()) return string(); - merged.append(existing_xmp, cursor, begin - cursor); - cursor = end; - } - merged.append(existing_xmp, cursor, existing_xmp.size() - cursor); + merged.append(existing_xmp, insertion, existing_xmp.size() - insertion); return merged; } diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 8f60818f..1ef50bc7 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -70,7 +70,6 @@ static std::string getXmpPacket(const uhdr_mem_block_t* xmp) { return std::string(bytes + offset, xmp->data_sz - offset); } -#if defined(UHDR_WRITE_XMP) static uhdr_gainmap_metadata_t makeTestGainmapMetadata() { uhdr_gainmap_metadata_t metadata{}; for (int channel = 0; channel < 3; ++channel) { @@ -84,6 +83,85 @@ static uhdr_gainmap_metadata_t makeTestGainmapMetadata() { return metadata; } +using EncoderPtr = std::unique_ptr; +using DecoderPtr = std::unique_ptr; + +static EncoderPtr makeEncoder() { + return EncoderPtr(uhdr_create_encoder(), &uhdr_release_encoder); +} + +static DecoderPtr makeDecoder() { + return DecoderPtr(uhdr_create_decoder(), &uhdr_release_decoder); +} + +// Configures the API-4 inputs used by the focused JPEG XMP tests. The XMP block is optional for +// the first encode in the getter/setter re-encode test, which relies on generated XMP. +static uhdr_error_info_t configureJpegGainmapEncoder(uhdr_codec_private_t* encoder, + uhdr_compressed_image_t* base_image, + uhdr_compressed_image_t* gainmap_image, + uhdr_gainmap_metadata_t* metadata, + uhdr_mem_block_t* xmp) { + uhdr_error_info_t status = + uhdr_enc_set_compressed_image(encoder, base_image, UHDR_BASE_IMG); + if (status.error_code != UHDR_CODEC_OK) return status; + status = uhdr_enc_set_gainmap_image(encoder, gainmap_image, metadata); + if (status.error_code != UHDR_CODEC_OK) return status; + if (xmp != nullptr) { + status = uhdr_enc_set_xmp_data(encoder, xmp); + if (status.error_code != UHDR_CODEC_OK) return status; + } + return uhdr_enc_set_output_format(encoder, UHDR_CODEC_JPG); +} + +struct JpegXmpRoundTrip { + EncoderPtr encoder; + DecoderPtr decoder; + + JpegXmpRoundTrip() + : encoder(nullptr, &uhdr_release_encoder), decoder(nullptr, &uhdr_release_decoder) {} +}; + +static testing::AssertionResult encodeAndProbeJpegXmp( + uhdr_compressed_image_t* base_image, uhdr_compressed_image_t* gainmap_image, + uhdr_gainmap_metadata_t* metadata, uhdr_mem_block_t* xmp, JpegXmpRoundTrip& round_trip) { + round_trip.encoder = makeEncoder(); + if (round_trip.encoder == nullptr) { + return testing::AssertionFailure() << "uhdr_create_encoder returned nullptr"; + } + + const uhdr_error_info_t setup_status = configureJpegGainmapEncoder( + round_trip.encoder.get(), base_image, gainmap_image, metadata, xmp); + if (setup_status.error_code != UHDR_CODEC_OK) { + return testing::AssertionFailure() << "JPEG XMP encoder setup failed: " + << setup_status.detail; + } + + const uhdr_error_info_t encode_status = uhdr_encode(round_trip.encoder.get()); + if (encode_status.error_code != UHDR_CODEC_OK) { + return testing::AssertionFailure() << "JPEG XMP encode failed: " << encode_status.detail; + } + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(round_trip.encoder.get()); + if (output == nullptr) { + return testing::AssertionFailure() << "uhdr_get_encoded_stream returned nullptr"; + } + + round_trip.decoder = makeDecoder(); + if (round_trip.decoder == nullptr) { + return testing::AssertionFailure() << "uhdr_create_decoder returned nullptr"; + } + uhdr_error_info_t probe_status = uhdr_dec_set_image(round_trip.decoder.get(), output); + if (probe_status.error_code != UHDR_CODEC_OK) { + return testing::AssertionFailure() << "JPEG XMP decoder setup failed: " + << probe_status.detail; + } + probe_status = uhdr_dec_probe(round_trip.decoder.get()); + if (probe_status.error_code != UHDR_CODEC_OK) { + return testing::AssertionFailure() << "JPEG XMP probe failed: " << probe_status.detail; + } + return testing::AssertionSuccess(); +} + +#if defined(UHDR_WRITE_XMP) struct ContainerDirectoryInfo { bool parsed = false; size_t directory_count = 0; @@ -474,27 +552,13 @@ TEST_F(UltraHdrApiTest, JpegApi4ExplicitXmpPreservesRdfNamespacePrefixes) { for (const PrefixControl& control : controls) { SCOPED_TRACE(control.name); - uhdr_codec_private_t* enc = uhdr_create_encoder(); - ASSERT_NE(enc, nullptr); uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); - ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, - UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, - UHDR_CODEC_OK); uhdr_mem_block_t xmp_block{const_cast(control.packet), strlen(control.packet), strlen(control.packet)}; - ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); - const uhdr_error_info_t enc_status = uhdr_encode(enc); - ASSERT_EQ(enc_status.error_code, UHDR_CODEC_OK) << enc_status.detail; - - uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); - ASSERT_NE(output, nullptr); - uhdr_codec_private_t* dec = uhdr_create_decoder(); - ASSERT_NE(dec, nullptr); - ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); - uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); ASSERT_NE(decoded_xmp, nullptr); ASSERT_NE(decoded_xmp->data, nullptr); const std::string decoded_packet = getXmpPacket(decoded_xmp); @@ -512,9 +576,6 @@ TEST_F(UltraHdrApiTest, JpegApi4ExplicitXmpPreservesRdfNamespacePrefixes) { std::string::npos); EXPECT_NE(decoded_packet.find("semantic-probe"), std::string::npos); EXPECT_NE(decoded_packet.find("keep-unrelated"), std::string::npos); - - uhdr_release_decoder(dec); - uhdr_release_encoder(enc); } } @@ -542,27 +603,13 @@ TEST_F(UltraHdrApiTest, JpegApi4XmpMergeReplacesVersionAttributeAndElement) { for (const VersionForm& form : forms) { SCOPED_TRACE(form.name); - uhdr_codec_private_t* enc = uhdr_create_encoder(); - ASSERT_NE(enc, nullptr); uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); - ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, - UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, - UHDR_CODEC_OK); uhdr_mem_block_t xmp_block{const_cast(form.packet), strlen(form.packet), strlen(form.packet)}; - ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); - const uhdr_error_info_t status = uhdr_encode(enc); - ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; - - uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); - ASSERT_NE(output, nullptr); - uhdr_codec_private_t* dec = uhdr_create_decoder(); - ASSERT_NE(dec, nullptr); - ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); - uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); ASSERT_NE(decoded_xmp, nullptr); ASSERT_NE(decoded_xmp->data, nullptr); const std::string decoded_packet = getXmpPacket(decoded_xmp); @@ -571,35 +618,20 @@ TEST_F(UltraHdrApiTest, JpegApi4XmpMergeReplacesVersionAttributeAndElement) { EXPECT_EQ(directory_info.directory_count, 1u); EXPECT_EQ(decoded_packet.find(form.stale_value), std::string::npos); EXPECT_NE(decoded_packet.find("hdrgm:Version=\"1.0\""), std::string::npos); - - uhdr_release_decoder(dec); - uhdr_release_encoder(enc); } } TEST_F(UltraHdrApiTest, JpegApi4GetterSetterReencodeUsesSingleCurrentGainmapDirectory) { uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); - uhdr_codec_private_t* first_enc = uhdr_create_encoder(); - ASSERT_NE(first_enc, nullptr); - ASSERT_EQ(uhdr_enc_set_compressed_image(first_enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, - UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_gainmap_image(first_enc, &mSdrCompressed, &metadata).error_code, - UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_output_format(first_enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_encode(first_enc).error_code, UHDR_CODEC_OK); - uhdr_compressed_image_t* first_output = uhdr_get_encoded_stream(first_enc); - ASSERT_NE(first_output, nullptr); - - uhdr_codec_private_t* first_dec = uhdr_create_decoder(); - ASSERT_NE(first_dec, nullptr); - ASSERT_EQ(uhdr_dec_set_image(first_dec, first_output).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_dec_probe(first_dec).error_code, UHDR_CODEC_OK); - uhdr_mem_block_t* first_xmp = uhdr_dec_get_xmp(first_dec); + JpegXmpRoundTrip first_round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, nullptr, + first_round_trip)); + uhdr_mem_block_t* first_xmp = uhdr_dec_get_xmp(first_round_trip.decoder.get()); ASSERT_NE(first_xmp, nullptr); ASSERT_NE(first_xmp->data, nullptr); - uhdr_mem_block_t* first_base = uhdr_dec_get_base_image(first_dec); + uhdr_mem_block_t* first_base = uhdr_dec_get_base_image(first_round_trip.decoder.get()); ASSERT_NE(first_base, nullptr); - uhdr_mem_block_t* first_gainmap = uhdr_dec_get_gainmap_image(first_dec); + uhdr_mem_block_t* first_gainmap = uhdr_dec_get_gainmap_image(first_round_trip.decoder.get()); ASSERT_NE(first_gainmap, nullptr); ASSERT_NE(first_gainmap->data, nullptr); const std::string first_packet = getXmpPacket(first_xmp); @@ -616,7 +648,8 @@ TEST_F(UltraHdrApiTest, JpegApi4GetterSetterReencodeUsesSingleCurrentGainmapDire first_base->data, first_base->data_sz, first_base->capacity, UHDR_CG_BT_709, UHDR_CT_SRGB, UHDR_CR_FULL_RANGE}; uhdr_compressed_image_t gainmap_input = changed_gainmap; - uhdr_gainmap_metadata_t* decoded_metadata = uhdr_dec_get_gainmap_metadata(first_dec); + uhdr_gainmap_metadata_t* decoded_metadata = + uhdr_dec_get_gainmap_metadata(first_round_trip.decoder.get()); ASSERT_NE(decoded_metadata, nullptr); const std::string xpacket_wrapped = @@ -626,30 +659,17 @@ TEST_F(UltraHdrApiTest, JpegApi4GetterSetterReencodeUsesSingleCurrentGainmapDire const std::string xmp_inputs[] = {raw_getter, xpacket_wrapped}; for (size_t input_index = 0; input_index < std::size(xmp_inputs); ++input_index) { SCOPED_TRACE(input_index == 0 ? "raw getter bytes" : "xpacket wrapped XML"); - uhdr_codec_private_t* second_enc = uhdr_create_encoder(); - ASSERT_NE(second_enc, nullptr); - ASSERT_EQ(uhdr_enc_set_compressed_image(second_enc, &base_input, UHDR_BASE_IMG).error_code, - UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_gainmap_image(second_enc, &gainmap_input, decoded_metadata).error_code, - UHDR_CODEC_OK); uhdr_mem_block_t xmp_input{const_cast(xmp_inputs[input_index].data()), xmp_inputs[input_index].size(), xmp_inputs[input_index].size()}; - ASSERT_EQ(uhdr_enc_set_xmp_data(second_enc, &xmp_input).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_output_format(second_enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); - const uhdr_error_info_t second_status = uhdr_encode(second_enc); - ASSERT_EQ(second_status.error_code, UHDR_CODEC_OK) << second_status.detail; - uhdr_compressed_image_t* second_output = uhdr_get_encoded_stream(second_enc); - ASSERT_NE(second_output, nullptr); - - uhdr_codec_private_t* second_dec = uhdr_create_decoder(); - ASSERT_NE(second_dec, nullptr); - ASSERT_EQ(uhdr_dec_set_image(second_dec, second_output).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_dec_probe(second_dec).error_code, UHDR_CODEC_OK); - uhdr_mem_block_t* second_gainmap = uhdr_dec_get_gainmap_image(second_dec); + JpegXmpRoundTrip second_round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&base_input, &gainmap_input, decoded_metadata, &xmp_input, + second_round_trip)); + uhdr_mem_block_t* second_gainmap = + uhdr_dec_get_gainmap_image(second_round_trip.decoder.get()); ASSERT_NE(second_gainmap, nullptr); ASSERT_NE(second_gainmap->data, nullptr); ASSERT_NE(second_gainmap->data_sz, first_gainmap->data_sz); - uhdr_mem_block_t* second_xmp = uhdr_dec_get_xmp(second_dec); + uhdr_mem_block_t* second_xmp = uhdr_dec_get_xmp(second_round_trip.decoder.get()); ASSERT_NE(second_xmp, nullptr); ASSERT_NE(second_xmp->data, nullptr); const std::string second_packet = getXmpPacket(second_xmp); @@ -666,12 +686,7 @@ TEST_F(UltraHdrApiTest, JpegApi4GetterSetterReencodeUsesSingleCurrentGainmapDire std::string::npos); EXPECT_NE(second_packet.find(""), std::string::npos); } - - uhdr_release_decoder(second_dec); - uhdr_release_encoder(second_enc); } - uhdr_release_decoder(first_dec); - uhdr_release_encoder(first_enc); } TEST_F(UltraHdrApiTest, JpegApi4XmpMergeIgnoresFakeRdfClosersInCommentsAndCdata) { @@ -683,26 +698,12 @@ TEST_F(UltraHdrApiTest, JpegApi4XmpMergeIgnoresFakeRdfClosersInCommentsAndCdata) "" ""; - uhdr_codec_private_t* enc = uhdr_create_encoder(); - ASSERT_NE(enc, nullptr); uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); - ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, - UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, - UHDR_CODEC_OK); uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; - ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); - const uhdr_error_info_t enc_status = uhdr_encode(enc); - ASSERT_EQ(enc_status.error_code, UHDR_CODEC_OK) << enc_status.detail; - uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); - ASSERT_NE(output, nullptr); - - uhdr_codec_private_t* dec = uhdr_create_decoder(); - ASSERT_NE(dec, nullptr); - ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); - uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); ASSERT_NE(decoded_xmp, nullptr); ASSERT_NE(decoded_xmp->data, nullptr); const std::string decoded_packet = getXmpPacket(decoded_xmp); @@ -713,13 +714,10 @@ TEST_F(UltraHdrApiTest, JpegApi4XmpMergeIgnoresFakeRdfClosersInCommentsAndCdata) EXPECT_NE(decoded_packet.find("]]>"), std::string::npos); EXPECT_NE(decoded_packet.find(""), std::string::npos); ASSERT_EQ(directory_info.gainmap_lengths.size(), 1u); - uhdr_mem_block_t* decoded_gainmap = uhdr_dec_get_gainmap_image(dec); + uhdr_mem_block_t* decoded_gainmap = uhdr_dec_get_gainmap_image(round_trip.decoder.get()); ASSERT_NE(decoded_gainmap, nullptr); ASSERT_NE(decoded_gainmap->data, nullptr); EXPECT_EQ(directory_info.gainmap_lengths.front(), decoded_gainmap->data_sz); - - uhdr_release_decoder(dec); - uhdr_release_encoder(enc); } TEST_F(UltraHdrApiTest, JpegApi4XmpMergeKeepsOwnedPropertiesOnNodeIdDescription) { @@ -733,35 +731,18 @@ TEST_F(UltraHdrApiTest, JpegApi4XmpMergeKeepsOwnedPropertiesOnNodeIdDescription) "" "primary-probe"; - uhdr_codec_private_t* enc = uhdr_create_encoder(); - ASSERT_NE(enc, nullptr); uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); - ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, - UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, - UHDR_CODEC_OK); uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; - ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); - const uhdr_error_info_t enc_status = uhdr_encode(enc); - ASSERT_EQ(enc_status.error_code, UHDR_CODEC_OK) << enc_status.detail; - uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); - ASSERT_NE(output, nullptr); - - uhdr_codec_private_t* dec = uhdr_create_decoder(); - ASSERT_NE(dec, nullptr); - ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); - uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); ASSERT_NE(decoded_xmp, nullptr); ASSERT_NE(decoded_xmp->data, nullptr); const std::string decoded_packet = getXmpPacket(decoded_xmp); EXPECT_NE(decoded_packet.find("keep-id-directory"), std::string::npos); EXPECT_NE(decoded_packet.find("keep-node-directory"), std::string::npos); EXPECT_NE(decoded_packet.find("primary-probe"), std::string::npos); - - uhdr_release_decoder(dec); - uhdr_release_encoder(enc); } TEST_F(UltraHdrApiTest, JpegApi4RejectsMalformedOrUnmergeableXmp) { @@ -777,19 +758,15 @@ TEST_F(UltraHdrApiTest, JpegApi4RejectsMalformedOrUnmergeableXmp) { for (const char* packet : packets) { SCOPED_TRACE(packet); - uhdr_codec_private_t* enc = uhdr_create_encoder(); + EncoderPtr enc = makeEncoder(); ASSERT_NE(enc, nullptr); uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); - ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, - UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, - UHDR_CODEC_OK); uhdr_mem_block_t xmp_block{const_cast(packet), strlen(packet), strlen(packet)}; - ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); - const uhdr_error_info_t status = uhdr_encode(enc); + const uhdr_error_info_t setup_status = configureJpegGainmapEncoder( + enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block); + ASSERT_EQ(setup_status.error_code, UHDR_CODEC_OK) << setup_status.detail; + const uhdr_error_info_t status = uhdr_encode(enc.get()); EXPECT_EQ(status.error_code, UHDR_CODEC_INVALID_PARAM) << status.detail; - uhdr_release_encoder(enc); } } #endif @@ -801,40 +778,15 @@ TEST_F(UltraHdrApiTest, JpegApi4IsoOnlyPassesThroughXmpExactly) { "xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">iso-passthrough" ""; - uhdr_gainmap_metadata_t metadata{}; - for (int channel = 0; channel < 3; ++channel) { - metadata.max_content_boost[channel] = 2.0f; - metadata.min_content_boost[channel] = 1.0f; - metadata.gamma[channel] = 1.0f; - } - metadata.hdr_capacity_min = 1.0f; - metadata.hdr_capacity_max = 2.0f; - metadata.use_base_cg = 1; - - uhdr_codec_private_t* enc = uhdr_create_encoder(); - ASSERT_NE(enc, nullptr); - ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, - UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, - UHDR_CODEC_OK); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; - ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_encode(enc).error_code, UHDR_CODEC_OK); - uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); - ASSERT_NE(output, nullptr); - - uhdr_codec_private_t* dec = uhdr_create_decoder(); - ASSERT_NE(dec, nullptr); - ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); - ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); - uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); ASSERT_NE(decoded_xmp, nullptr); ASSERT_NE(decoded_xmp->data, nullptr); EXPECT_EQ(getXmpPacket(decoded_xmp), user_xmp); - - uhdr_release_decoder(dec); - uhdr_release_encoder(enc); } #endif From 7a27202465c9447fc4003e63f90891010d3156ad Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Wed, 16 Sep 2026 15:29:54 -0500 Subject: [PATCH 06/10] Verify inherited JPEG XMP reuse and fix test includes --- CMakeLists.txt | 1 + tests/ultrahdr_api_test.cpp | 121 +++++++++++++++++++++++++++--------- 2 files changed, 92 insertions(+), 30 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 37c184fd..191300da 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -823,6 +823,7 @@ if(UHDR_BUILD_TESTS) target_include_directories(ultrahdr_unit_test PRIVATE ${PRIVATE_INCLUDE_DIR} ${GTEST_INCLUDE_DIRS} + "${THIRD_PARTY_DIR}/image_io/includes" ) if(UHDR_BUILD_FUZZERS) target_link_options(ultrahdr_unit_test PRIVATE -fsanitize=fuzzer-no-link) diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 1ef50bc7..91c2395f 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -94,8 +94,8 @@ static DecoderPtr makeDecoder() { return DecoderPtr(uhdr_create_decoder(), &uhdr_release_decoder); } -// Configures the API-4 inputs used by the focused JPEG XMP tests. The XMP block is optional for -// the first encode in the getter/setter re-encode test, which relies on generated XMP. +// Configures the API-4 inputs used by the focused JPEG XMP tests. The XMP block is optional when +// an API-4 re-encode should reuse XMP embedded in its compressed base image. static uhdr_error_info_t configureJpegGainmapEncoder(uhdr_codec_private_t* encoder, uhdr_compressed_image_t* base_image, uhdr_compressed_image_t* gainmap_image, @@ -622,10 +622,24 @@ TEST_F(UltraHdrApiTest, JpegApi4XmpMergeReplacesVersionAttributeAndElement) { } TEST_F(UltraHdrApiTest, JpegApi4GetterSetterReencodeUsesSingleCurrentGainmapDirectory) { + const std::string descriptive_seed = + "" + "retained-description" + "" + "stale-container-element" + "stale-hdrgm-element" + ""; + uhdr_mem_block_t descriptive_seed_block{const_cast(descriptive_seed.data()), + descriptive_seed.size(), descriptive_seed.size()}; uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); JpegXmpRoundTrip first_round_trip; - ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, nullptr, - first_round_trip)); + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, + &descriptive_seed_block, first_round_trip)); uhdr_mem_block_t* first_xmp = uhdr_dec_get_xmp(first_round_trip.decoder.get()); ASSERT_NE(first_xmp, nullptr); ASSERT_NE(first_xmp->data, nullptr); @@ -636,6 +650,47 @@ TEST_F(UltraHdrApiTest, JpegApi4GetterSetterReencodeUsesSingleCurrentGainmapDire ASSERT_NE(first_gainmap->data, nullptr); const std::string first_packet = getXmpPacket(first_xmp); ASSERT_FALSE(first_packet.empty()); + const char* stale_sentinels[] = { + "stale-container-attribute", "stale-container-element", "stale-hdrgm-attribute", + "stale-hdrgm-element", + }; + + auto checkOutput = [&](JpegXmpRoundTrip& round_trip, bool expect_changed_gainmap, + const char* expected_description, const char* absent_description, + std::string* packet_out) { + uhdr_mem_block_t* output_gainmap = uhdr_dec_get_gainmap_image(round_trip.decoder.get()); + ASSERT_NE(output_gainmap, nullptr); + ASSERT_NE(output_gainmap->data, nullptr); + if (expect_changed_gainmap) { + EXPECT_NE(output_gainmap->data_sz, first_gainmap->data_sz); + } + + uhdr_mem_block_t* output_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); + ASSERT_NE(output_xmp, nullptr); + ASSERT_NE(output_xmp->data, nullptr); + const std::string output_packet = getXmpPacket(output_xmp); + ASSERT_FALSE(output_packet.empty()); + EXPECT_NE(output_packet.find(expected_description), std::string::npos); + if (absent_description != nullptr) { + EXPECT_EQ(output_packet.find(absent_description), std::string::npos); + } + for (const char* stale : stale_sentinels) { + EXPECT_EQ(output_packet.find(stale), std::string::npos) << stale; + } + + const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(output_packet); + ASSERT_TRUE(directory_info.parsed); + EXPECT_EQ(directory_info.directory_count, 1u); + ASSERT_EQ(directory_info.gainmap_lengths.size(), 1u); + EXPECT_EQ(directory_info.gainmap_lengths.front(), output_gainmap->data_sz); + ASSERT_EQ(uhdr_decode(round_trip.decoder.get()).error_code, UHDR_CODEC_OK); + if (packet_out != nullptr) *packet_out = output_packet; + }; + + uhdr_gainmap_metadata_t* decoded_metadata = + uhdr_dec_get_gainmap_metadata(first_round_trip.decoder.get()); + ASSERT_NE(decoded_metadata, nullptr); + checkOutput(first_round_trip, false, "retained-description", nullptr, nullptr); JpegEncoderHelper changed_gainmap_encoder; ASSERT_EQ(changed_gainmap_encoder.compressImage(&mSdrRaw, 50, nullptr, 0).error_code, @@ -648,39 +703,45 @@ TEST_F(UltraHdrApiTest, JpegApi4GetterSetterReencodeUsesSingleCurrentGainmapDire first_base->data, first_base->data_sz, first_base->capacity, UHDR_CG_BT_709, UHDR_CT_SRGB, UHDR_CR_FULL_RANGE}; uhdr_compressed_image_t gainmap_input = changed_gainmap; - uhdr_gainmap_metadata_t* decoded_metadata = - uhdr_dec_get_gainmap_metadata(first_round_trip.decoder.get()); - ASSERT_NE(decoded_metadata, nullptr); const std::string xpacket_wrapped = "\n" + first_packet + "\n"; const std::string raw_getter(static_cast(first_xmp->data), first_xmp->data_sz); - const std::string xmp_inputs[] = {raw_getter, xpacket_wrapped}; - for (size_t input_index = 0; input_index < std::size(xmp_inputs); ++input_index) { - SCOPED_TRACE(input_index == 0 ? "raw getter bytes" : "xpacket wrapped XML"); - uhdr_mem_block_t xmp_input{const_cast(xmp_inputs[input_index].data()), - xmp_inputs[input_index].size(), xmp_inputs[input_index].size()}; + const std::string replacement_xmp = + "" + "replacement-description"; + struct ReencodeControl { + const char* name; + const std::string* xmp; + const char* expected_description; + const char* absent_description; + bool expect_xpacket_wrapper; + }; + const ReencodeControl controls[] = { + {"raw getter bytes", &raw_getter, "retained-description", nullptr, false}, + {"xpacket wrapped XML", &xpacket_wrapped, "retained-description", nullptr, true}, + {"no XMP setter", nullptr, "retained-description", nullptr, false}, + {"explicit replacement", &replacement_xmp, "replacement-description", + "retained-description", false}, + }; + for (const ReencodeControl& control : controls) { + SCOPED_TRACE(control.name); + uhdr_mem_block_t xmp_input{}; + if (control.xmp != nullptr) { + xmp_input = {const_cast(control.xmp->data()), control.xmp->size(), + control.xmp->size()}; + } JpegXmpRoundTrip second_round_trip; - ASSERT_TRUE(encodeAndProbeJpegXmp(&base_input, &gainmap_input, decoded_metadata, &xmp_input, + ASSERT_TRUE(encodeAndProbeJpegXmp(&base_input, &gainmap_input, decoded_metadata, + control.xmp != nullptr ? &xmp_input : nullptr, second_round_trip)); - uhdr_mem_block_t* second_gainmap = - uhdr_dec_get_gainmap_image(second_round_trip.decoder.get()); - ASSERT_NE(second_gainmap, nullptr); - ASSERT_NE(second_gainmap->data, nullptr); - ASSERT_NE(second_gainmap->data_sz, first_gainmap->data_sz); - uhdr_mem_block_t* second_xmp = uhdr_dec_get_xmp(second_round_trip.decoder.get()); - ASSERT_NE(second_xmp, nullptr); - ASSERT_NE(second_xmp->data, nullptr); - const std::string second_packet = getXmpPacket(second_xmp); - const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(second_packet); - EXPECT_TRUE(directory_info.parsed); - EXPECT_EQ(directory_info.directory_count, 1u); - EXPECT_EQ(directory_info.gainmap_lengths.size(), 1u); - if (!directory_info.gainmap_lengths.empty()) { - EXPECT_EQ(directory_info.gainmap_lengths.front(), second_gainmap->data_sz); - } - if (input_index == 1) { + std::string second_packet; + checkOutput(second_round_trip, true, control.expected_description, + control.absent_description, &second_packet); + if (control.expect_xpacket_wrapper) { EXPECT_NE(second_packet.find( ""), std::string::npos); From b252467c38c5abd0724bd7bf2fe5f9fd81e889a2 Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Wed, 16 Sep 2026 17:51:47 -0500 Subject: [PATCH 07/10] Avoid copying XMP removal ranges in structured binding --- lib/src/jpegrutils.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index cde05810..511cea13 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -1328,7 +1328,7 @@ string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length GeneratePrimaryDescription(secondary_image_length, metadata); string merged; size_t cursor = 0; - for (const auto [begin, end] : removals) { + for (const auto& [begin, end] : removals) { if (begin < cursor || end > insertion || end > existing_xmp.size()) return string(); merged.append(existing_xmp, cursor, begin - cursor); cursor = end; From 5dbaf8ef1ba8d54d9d295827fcf51b9299e8954a Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Thu, 17 Sep 2026 21:31:13 +0000 Subject: [PATCH 08/10] feat(jpeg): support Adobe XMP Part 3 multi-segment Extended XMP - Support encoding and decoding Extended XMP metadata (> 64 KB) in JPEG - Compute 128-bit MD5 GUID and inject xmpNote:HasExtendedXMP into Standard XMP - Chunk serialized Extended XMP payload across multiple http://ns.adobe.com/xmp/extension/\0 APP1 segments - Reassemble Extended XMP chunks during JPEG header parsing and expose reconstructed buffer - Calculate dynamic encoder output buffer allowance based on total XMP size and chunk count - Add unit tests validating multi-segment Extended XMP encoding, decoding, and bit-for-bit roundtrip integrity --- lib/include/ultrahdr/jpegr.h | 6 ++ lib/include/ultrahdr/jpegrutils.h | 13 ++- lib/src/jpegdecoderhelper.cpp | 88 +++++++++++++++++++ lib/src/jpegr.cpp | 108 +++++++++++++++++------ lib/src/jpegrutils.cpp | 141 +++++++++++++++++++++++++++++- lib/src/ultrahdr_api.cpp | 12 ++- tests/ultrahdr_api_test.cpp | 70 +++++++++++++-- 7 files changed, 402 insertions(+), 36 deletions(-) diff --git a/lib/include/ultrahdr/jpegr.h b/lib/include/ultrahdr/jpegr.h index f9047d50..8d8a9a07 100644 --- a/lib/include/ultrahdr/jpegr.h +++ b/lib/include/ultrahdr/jpegr.h @@ -27,6 +27,12 @@ constexpr size_t kJpegSegmentMaxLength = 0xffff; // Maximum byte footprint for a JPEG application segment including the 2-byte marker code. constexpr size_t kJpegAppSegmentTotalMaxBytes = kJpegSegmentMaxLength + 2; +// Adobe XMP namespaces and segment length limits +constexpr size_t kMaxStandardXmpPayload = kJpegSegmentMaxLength - 2 - 29; // 65504 bytes +// Extended XMP segment header: 2 (length) + 35 (signature) + 32 (GUID) + 4 (totalLength) + 4 (offset) = 77 bytes +constexpr size_t kExtendedXmpHeaderSize = 77; +constexpr size_t kExtendedXmpMaxChunkSize = kJpegSegmentMaxLength - kExtendedXmpHeaderSize; // 65458 bytes + /* * Holds information of jpeg image */ diff --git a/lib/include/ultrahdr/jpegrutils.h b/lib/include/ultrahdr/jpegrutils.h index 8539f269..101fbfaa 100644 --- a/lib/include/ultrahdr/jpegrutils.h +++ b/lib/include/ultrahdr/jpegrutils.h @@ -86,6 +86,16 @@ uhdr_error_info_t Write(uhdr_compressed_image_t* destination, const void* source uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t* exif_data, int exif_size, uhdr_gainmap_metadata_ext_t* metadata); +/* + * Computes 128-bit MD5 digest formatted as a 32-character uppercase hexadecimal GUID. + */ +std::string computeMd5Guid(const uint8_t* data, size_t len); + +/* + * Generates a minimal standard XMP packet referencing Extended XMP via xmpNote:HasExtendedXMP. + */ +std::string generateStandardXmpWithExtendedGuid(const std::string& guid); + /* * This method generates XMP metadata for the primary image. * @@ -128,7 +138,8 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t */ std::string generateXmpForPrimaryImage(size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, - uhdr_mem_block_t* user_xmp = nullptr); + uhdr_mem_block_t* user_xmp = nullptr, + const std::string& extended_xmp_guid = ""); /* * This method generates XMP metadata for the recovery map image. diff --git a/lib/src/jpegdecoderhelper.cpp b/lib/src/jpegdecoderhelper.cpp index c23b88e2..2e306f40 100644 --- a/lib/src/jpegdecoderhelper.cpp +++ b/lib/src/jpegdecoderhelper.cpp @@ -15,6 +15,7 @@ #include #include "ultrahdr/ultrahdrcommon.h" +#include "ultrahdr/jpegrutils.h" #include "ultrahdr/jpegdecoderhelper.h" using namespace std; @@ -139,6 +140,75 @@ static void jpeg_extract_marker_payload(const j_decompress_ptr cinfo, const uint } } +static void jpeg_extract_extended_xmp(const j_decompress_ptr cinfo, + const std::string& expected_guid, + std::vector& destination) { + const uint8_t kExtNamespace[] = "http://ns.adobe.com/xmp/extension/\0"; + const size_t kExtNamespaceLen = 35; + const size_t kHeaderLen = kExtNamespaceLen + 32 + 4 + 4; // 75 bytes + + std::string active_guid = expected_guid; + uint32_t total_length = 0; + std::vector reassembled; + std::vector received_bytes; + + for (jpeg_marker_struct* marker = cinfo->marker_list; marker; marker = marker->next) { + if (marker->marker == kAPP1Marker && marker->data_length > kHeaderLen && + !memcmp(marker->data, kExtNamespace, kExtNamespaceLen)) { + const char* guid_ptr = reinterpret_cast(marker->data + kExtNamespaceLen); + std::string segment_guid(guid_ptr, 32); + + if (!active_guid.empty() && segment_guid != active_guid) { + continue; + } + + uint32_t total_len_be = 0; + memcpy(&total_len_be, marker->data + kExtNamespaceLen + 32, 4); + uint32_t seg_total_len = EndianSwap32(total_len_be); + + uint32_t offset_be = 0; + memcpy(&offset_be, marker->data + kExtNamespaceLen + 36, 4); + uint32_t seg_offset = EndianSwap32(offset_be); + + const size_t chunk_data_len = marker->data_length - kHeaderLen; + if (seg_offset + chunk_data_len > seg_total_len) { + continue; + } + + if (active_guid.empty()) { + active_guid = segment_guid; + } + + if (total_length == 0) { + total_length = seg_total_len; + reassembled.resize(total_length, 0); + received_bytes.resize(total_length, false); + } else if (seg_total_len != total_length) { + continue; + } + + memcpy(reassembled.data() + seg_offset, marker->data + kHeaderLen, chunk_data_len); + for (size_t i = 0; i < chunk_data_len; ++i) { + received_bytes[seg_offset + i] = true; + } + } + } + + if (total_length > 0) { + bool complete = true; + for (bool b : received_bytes) { + if (!b) { + complete = false; + break; + } + } + if (complete) { + destination = std::move(reassembled); + } + } +} + + static uhdr_img_fmt_t getOutputSamplingFormat(const j_decompress_ptr cinfo) { if (cinfo->num_components == 1) return UHDR_IMG_FMT_8bppYCbCr400; @@ -244,6 +314,24 @@ uhdr_error_info_t JpegDecoderHelper::decode(const void* image, size_t length, de sizeof kIsoMetadataNameSpace / sizeof kIsoMetadataNameSpace[0], mIsoMetadataBuffer, payloadOffset); + std::string expected_guid; + if (!mXMPBuffer.empty()) { + std::string std_xmp(reinterpret_cast(mXMPBuffer.data()), mXMPBuffer.size()); + const std::string kTag = "HasExtendedXMP="; + size_t pos = std_xmp.find(kTag); + if (pos != std::string::npos && pos + kTag.size() + 33 <= std_xmp.size()) { + char quote = std_xmp[pos + kTag.size()]; + if (quote == '"' || quote == '\'') { + expected_guid = std_xmp.substr(pos + kTag.size() + 1, 32); + } + } + } + std::vector extXmpBuffer; + jpeg_extract_extended_xmp(&cinfo, expected_guid, extXmpBuffer); + if (!extXmpBuffer.empty()) { + mXMPBuffer = std::move(extXmpBuffer); + } + if (cinfo.image_width < 1 || cinfo.image_height < 1) { status.error_code = UHDR_CODEC_ERROR; status.has_detail = 1; diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index d93396a1..6e91a228 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -60,6 +60,7 @@ static const bool kWriteIso21496_1Metadata = false; #endif static const string kXmpNameSpace = "http://ns.adobe.com/xap/1.0/"; +static const string kExtendedXmpNameSpace = "http://ns.adobe.com/xmp/extension/"; static const string kIsoNameSpace = "urn:iso:std:iso:ts:21496:-1"; static_assert(kWriteXmpMetadata || kWriteIso21496_1Metadata, @@ -1268,43 +1269,100 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr // Prepare and write XMP std::string xmp_primary_str; - if (kWriteXmpMetadata) { - xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, pXmp); - } else if (pXmp != nullptr && pXmp->data != nullptr && pXmp->data_sz > 0) { + std::string user_xmp_str; + if (pXmp != nullptr && pXmp->data != nullptr && pXmp->data_sz > 0) { const std::string kXmpHeader = "http://ns.adobe.com/xap/1.0/"; if (pXmp->data_sz > kXmpHeader.size() && memcmp(pXmp->data, kXmpHeader.c_str(), kXmpHeader.size()) == 0) { size_t offset = kXmpHeader.size(); - if (static_cast(pXmp->data)[offset] == '\0') { + if (static_cast(pXmp->data)[offset] == 0) { offset++; } - xmp_primary_str = std::string(static_cast(pXmp->data) + offset, - pXmp->data_sz - offset); + user_xmp_str = std::string(static_cast(pXmp->data) + offset, + pXmp->data_sz - offset); } else { - xmp_primary_str = std::string(static_cast(pXmp->data), pXmp->data_sz); + user_xmp_str = std::string(static_cast(pXmp->data), pXmp->data_sz); } } - if (!xmp_primary_str.empty()) { - if (xmpNameSpaceLength > kJpegSegmentMaxLength - 2 || - xmp_primary_str.size() > kJpegSegmentMaxLength - 2 - xmpNameSpaceLength) { - uhdr_error_info_t status; - status.error_code = UHDR_CODEC_INVALID_PARAM; - status.has_detail = 1; - snprintf(status.detail, sizeof status.detail, - "serialized XMP metadata exceeds the JPEG APP1 segment size limit"); - return status; + if (kWriteXmpMetadata) { + if (user_xmp_str.empty()) { + xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, nullptr); + } else if (user_xmp_str.size() <= kMaxStandardXmpPayload) { + uhdr_mem_block_t user_block{const_cast(user_xmp_str.data()), user_xmp_str.size(), + user_xmp_str.size()}; + xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, &user_block); + } else { + xmp_primary_str = user_xmp_str; } + } else if (!user_xmp_str.empty()) { + xmp_primary_str = user_xmp_str; + } - const size_t length = 2 + xmpNameSpaceLength + xmp_primary_str.size(); - const uint8_t lengthH = ((length >> 8) & 0xff); - const uint8_t lengthL = (length & 0xff); - UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &lengthH, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &lengthL, 1, pos)); - UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); - UHDR_ERR_CHECK(Write(dest, (void*)xmp_primary_str.c_str(), xmp_primary_str.size(), pos)); + if (!xmp_primary_str.empty()) { + if (xmp_primary_str.size() <= kMaxStandardXmpPayload) { + // Write single standard XMP segment + const size_t length = 2 + xmpNameSpaceLength + xmp_primary_str.size(); + const uint8_t lengthH = ((length >> 8) & 0xff); + const uint8_t lengthL = (length & 0xff); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &lengthH, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &lengthL, 1, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)xmp_primary_str.c_str(), xmp_primary_str.size(), pos)); + } else { + // Extended XMP (Adobe XMP Specification Part 3) + const std::string guid = + computeMd5Guid(reinterpret_cast(xmp_primary_str.data()), + xmp_primary_str.size()); + + // 1. Write Standard XMP segment with xmpNote:HasExtendedXMP referencing the GUID + std::string standard_xmp; + if (kWriteXmpMetadata) { + standard_xmp = + generateXmpForPrimaryImage(secondary_image_size, *metadata, nullptr, guid); + } else { + standard_xmp = generateStandardXmpWithExtendedGuid(guid); + } + + const size_t std_length = 2 + xmpNameSpaceLength + standard_xmp.size(); + const uint8_t std_lengthH = ((std_length >> 8) & 0xff); + const uint8_t std_lengthL = (std_length & 0xff); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &std_lengthH, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &std_lengthL, 1, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)standard_xmp.c_str(), standard_xmp.size(), pos)); + + // 2. Write Extended XMP segments + const size_t extNamespaceLength = kExtendedXmpNameSpace.size() + 1; // 35 bytes + const uint32_t totalLen = static_cast(xmp_primary_str.size()); + const uint32_t totalLenBE = EndianSwap32(totalLen); + size_t ext_offset = 0; + while (ext_offset < xmp_primary_str.size()) { + const size_t chunk_size = + (std::min)(xmp_primary_str.size() - ext_offset, kExtendedXmpMaxChunkSize); + const size_t seg_length = 2 + extNamespaceLength + 32 + 4 + 4 + chunk_size; + const uint8_t seg_lengthH = ((seg_length >> 8) & 0xff); + const uint8_t seg_lengthL = (seg_length & 0xff); + const uint32_t offsetBE = EndianSwap32(static_cast(ext_offset)); + + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &seg_lengthH, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &seg_lengthL, 1, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)kExtendedXmpNameSpace.c_str(), extNamespaceLength, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)guid.c_str(), 32, pos)); + UHDR_ERR_CHECK(Write(dest, &totalLenBE, 4, pos)); + UHDR_ERR_CHECK(Write(dest, &offsetBE, 4, pos)); + UHDR_ERR_CHECK( + Write(dest, (void*)(xmp_primary_str.data() + ext_offset), chunk_size, pos)); + + ext_offset += chunk_size; + } + } } // Write ICC diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 032b03d2..173407f5 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -873,9 +873,140 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t return uhdr_validate_gainmap_metadata_descriptor(metadata); } + +/* + * Computes 128-bit MD5 digest formatted as a 32-character uppercase hexadecimal GUID. + * + * Specification References: + * 1. Adobe XMP Specification Part 3 (Storage in Files - ExtendedXMP in JPEG): + * https://developer.adobe.com/xmp/docs/XMPSpecifications/ + * Mandates linking Extended XMP segments to Standard XMP using a 128-bit GUID computed + * as the MD5 message digest of the serialized Extended XMP payload. + * + * 2. IETF RFC 1321 (The MD5 Message-Digest Algorithm): + * https://www.ietf.org/rfc/rfc1321.txt + */ +std::string computeMd5Guid(const uint8_t* data, size_t len) { + // Initial 128-bit chaining state registers (RFC 1321, Section 3.3) + uint32_t h0 = 0x67452301; + uint32_t h1 = 0xEFCDAB89; + uint32_t h2 = 0x98BADCFE; + uint32_t h3 = 0x10325476; + + // Additive constant table T[1..64] computed as floor(2^32 * abs(sin(i + 1))) (RFC 1321, Section 3.4 / Appendix A.3) + static const uint32_t k[64] = { + // Round 1 + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, + 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, + // Round 2 + 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, + 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, + // Round 3 + 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, + 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, + // Round 4 + 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391}; + + // Per-round bitwise left rotation shift amounts S[1..64] (RFC 1321, Section 3.4) + static const uint32_t r[64] = { + // Round 1 shifts: 7, 12, 17, 22 + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, + // Round 2 shifts: 5, 9, 14, 20 + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, + // Round 3 shifts: 4, 11, 16, 23 + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, + // Round 4 shifts: 6, 10, 15, 21 + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21}; + + uint64_t bit_len = static_cast(len) * 8; + size_t padded_len = ((len + 8) / 64 + 1) * 64; + std::vector msg(padded_len, 0); + if (len > 0) { + memcpy(msg.data(), data, len); + } + msg[len] = 0x80; + for (int i = 0; i < 8; ++i) { + msg[padded_len - 8 + i] = static_cast((bit_len >> (i * 8)) & 0xFF); + } + + for (size_t chunk = 0; chunk < padded_len; chunk += 64) { + uint32_t w[16]; + for (int i = 0; i < 16; ++i) { + w[i] = static_cast(msg[chunk + i * 4]) | + (static_cast(msg[chunk + i * 4 + 1]) << 8) | + (static_cast(msg[chunk + i * 4 + 2]) << 16) | + (static_cast(msg[chunk + i * 4 + 3]) << 24); + } + + uint32_t a = h0; + uint32_t b = h1; + uint32_t c = h2; + uint32_t d = h3; + + for (int i = 0; i < 64; ++i) { + uint32_t f, g; + if (i < 16) { + f = (b & c) | ((~b) & d); + g = i; + } else if (i < 32) { + f = (d & b) | ((~d) & c); + g = (5 * i + 1) % 16; + } else if (i < 48) { + f = b ^ c ^ d; + g = (3 * i + 5) % 16; + } else { + f = c ^ (b | (~d)); + g = (7 * i) % 16; + } + uint32_t temp = d; + d = c; + c = b; + uint32_t sum = a + f + k[i] + w[g]; + uint32_t rot = (sum << r[i]) | (sum >> (32 - r[i])); + b = b + rot; + a = temp; + } + + h0 += a; + h1 += b; + h2 += c; + h3 += d; + } + + uint8_t digest[16]; + for (int i = 0; i < 4; ++i) { + digest[i] = (h0 >> (i * 8)) & 0xFF; + digest[4 + i] = (h1 >> (i * 8)) & 0xFF; + digest[8 + i] = (h2 >> (i * 8)) & 0xFF; + digest[12 + i] = (h3 >> (i * 8)) & 0xFF; + } + + char hex[33]; + for (int i = 0; i < 16; ++i) { + snprintf(hex + i * 2, 3, "%02X", digest[i]); + } + return std::string(hex, 32); +} + +std::string generateStandardXmpWithExtendedGuid(const std::string& guid) { + std::stringstream ss; + photos_editing_formats::image_io::XmlWriter writer(ss); + writer.StartWritingElement("x:xmpmeta"); + writer.WriteXmlns("x", "adobe:ns:meta/"); + writer.WriteAttributeNameAndValue("x:xmptk", "Adobe XMP Core 5.1.2"); + writer.StartWritingElement("rdf:RDF"); + writer.WriteXmlns("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"); + writer.StartWritingElement("rdf:Description"); + writer.WriteXmlns("xmpNote", "http://ns.adobe.com/xmp/note/"); + writer.WriteAttributeNameAndValue("xmpNote:HasExtendedXMP", guid); + writer.FinishWriting(); + return ss.str(); +} string generateXmpForPrimaryImage(size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, - uhdr_mem_block_t* user_xmp) { + uhdr_mem_block_t* user_xmp, + const std::string& extended_xmp_guid) { const vector kConDirSeq({kConDirectory, string("rdf:Seq")}); const vector kLiItem({string("rdf:li"), kConItem}); @@ -900,6 +1031,10 @@ string generateXmpForPrimaryImage(size_t secondary_image_length, writer.WriteXmlns(kContainerPrefix, kContainerUri); writer.WriteXmlns(kItemPrefix, kItemUri); writer.WriteXmlns(kGainMapPrefix, kGainMapUri); + if (!extended_xmp_guid.empty()) { + writer.WriteXmlns("xmpNote", "http://ns.adobe.com/xmp/note/"); + writer.WriteAttributeNameAndValue("xmpNote:HasExtendedXMP", extended_xmp_guid); + } writer.WriteAttributeNameAndValue(kMapVersion, metadata.version); writer.StartWritingElements(kConDirSeq); @@ -940,6 +1075,10 @@ string generateXmpForPrimaryImage(size_t secondary_image_length, writer.WriteXmlns(kContainerPrefix, kContainerUri); writer.WriteXmlns(kItemPrefix, kItemUri); writer.WriteXmlns(kGainMapPrefix, kGainMapUri); + if (!extended_xmp_guid.empty()) { + writer.WriteXmlns("xmpNote", "http://ns.adobe.com/xmp/note/"); + writer.WriteAttributeNameAndValue("xmpNote:HasExtendedXMP", extended_xmp_guid); + } writer.WriteAttributeNameAndValue(kMapVersion, metadata.version); writer.StartWritingElements(kConDirSeq); diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 5b3c196c..5cfc56eb 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -1317,7 +1317,17 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { if (handle->m_output_format == UHDR_CODEC_JPG) { const bool mayWriteXmp = !handle->m_xmp.empty() || !handle->m_compressed_images.empty(); - const size_t xmpAllowance = mayWriteXmp ? ultrahdr::kJpegAppSegmentTotalMaxBytes : 0; + size_t xmpAllowance = 0; + if (mayWriteXmp) { + if (handle->m_xmp.size() <= ultrahdr::kMaxStandardXmpPayload) { + xmpAllowance = ultrahdr::kJpegAppSegmentTotalMaxBytes; + } else { + const size_t xmp_sz = handle->m_xmp.size(); + const size_t num_chunks = + (xmp_sz + ultrahdr::kExtendedXmpMaxChunkSize - 1) / ultrahdr::kExtendedXmpMaxChunkSize; + xmpAllowance = ultrahdr::kJpegAppSegmentTotalMaxBytes + xmp_sz + num_chunks * 79; + } + } auto addXmpAllowance = [xmpAllowance](size_t base_size, size_t* output_size) { if (xmpAllowance > (std::numeric_limits::max)() - base_size) return false; *output_size = base_size + xmpAllowance; diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 152c9eb9..ebdf7bce 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -523,7 +523,50 @@ TEST_F(UltraHdrApiTest, LargeXmpFitsCompressedBaseGainmapApiOutput) { uhdr_release_encoder(enc); } -TEST_F(UltraHdrApiTest, OversizedFinalXmpIsRejected) { +TEST_F(UltraHdrApiTest, ExtendedXmpMultiSegmentRawApiOutput) { + // 150 KB XMP metadata spans across 3 Extended XMP APP1 segments in JPEG + constexpr size_t kLargeXmpSize = 150 * 1024; + const std::vector hdr_data = makeSmallHdrData(); + uhdr_raw_image_t hdr{}; + hdr.fmt = UHDR_IMG_FMT_64bppRGBAHalfFloat; + hdr.cg = UHDR_CG_DISPLAY_P3; + hdr.ct = UHDR_CT_LINEAR; + hdr.range = UHDR_CR_FULL_RANGE; + hdr.w = hdr.h = 8; + hdr.planes[UHDR_PLANE_PACKED] = const_cast(hdr_data.data()); + hdr.stride[UHDR_PLANE_PACKED] = 8; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(uhdr_enc_set_raw_image(enc, &hdr, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + const std::string xmp = makeLargeXmp(kLargeXmpSize); + uhdr_mem_block_t xmp_block{const_cast(xmp.data()), xmp.size(), xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const uhdr_error_info_t status = uhdr_encode(enc); + ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + EXPECT_GT(output->data_sz, kLargeXmpSize); + expectEncodedXmpDecodes(output, kLargeXmpSize); + + // Validate exact bit-for-bit decoded XMP content + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + EXPECT_EQ(decoded_xmp->data_sz, kLargeXmpSize); + EXPECT_EQ(memcmp(decoded_xmp->data, xmp.data(), kLargeXmpSize), 0); + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, ExtendedXmpMultiSegmentCompressedBaseGainmapApiOutput) { + constexpr size_t kLargeXmpSize = 200 * 1024; uhdr_gainmap_metadata_t metadata{}; for (int channel = 0; channel < 3; ++channel) { metadata.max_content_boost[channel] = 2.0f; @@ -540,18 +583,29 @@ TEST_F(UltraHdrApiTest, OversizedFinalXmpIsRejected) { UHDR_CODEC_OK); ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, UHDR_CODEC_OK); -#ifdef UHDR_WRITE_XMP - const std::string xmp = makeLargeXmp(65504); -#else - const std::string xmp = makeLargeXmp(65505); -#endif + const std::string xmp = makeLargeXmp(kLargeXmpSize); uhdr_mem_block_t xmp_block{const_cast(xmp.data()), xmp.size(), xmp.size()}; ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); const uhdr_error_info_t status = uhdr_encode(enc); - EXPECT_NE(status.error_code, UHDR_CODEC_OK); - EXPECT_TRUE(status.has_detail); + ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + EXPECT_GT(output->data_sz, kLargeXmpSize); + expectEncodedXmpDecodes(output, kLargeXmpSize); + + // Validate exact bit-for-bit decoded XMP content + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + EXPECT_EQ(decoded_xmp->data_sz, kLargeXmpSize); + EXPECT_EQ(memcmp(decoded_xmp->data, xmp.data(), kLargeXmpSize), 0); + uhdr_release_decoder(dec); uhdr_release_encoder(enc); } From c27ef261845cec9edd2b7864b623cc7c6a73b551 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 18 Sep 2026 23:46:38 +0000 Subject: [PATCH 09/10] Fix build errors from duplicated hunks in XMP merge change --- lib/src/jpegr.cpp | 22 ++++++++++------------ lib/src/jpegrutils.cpp | 5 ----- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index 87654882..e801c653 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -1269,18 +1269,8 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr // Prepare and write XMP std::string xmp_primary_str; - if (kWriteXmpMetadata) { - xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, pXmp); - if (pXmp != nullptr && pXmp->data != nullptr && pXmp->data_sz > 0 && - xmp_primary_str.empty()) { - uhdr_error_info_t status; - status.error_code = UHDR_CODEC_INVALID_PARAM; - status.has_detail = 1; - snprintf(status.detail, sizeof status.detail, - "unable to safely merge supplied XMP metadata into the primary image"); - return status; - } - } else if (pXmp != nullptr && pXmp->data != nullptr && pXmp->data_sz > 0) { + std::string user_xmp_str; + if (pXmp != nullptr && pXmp->data != nullptr && pXmp->data_sz > 0) { const std::string kXmpHeader = "http://ns.adobe.com/xap/1.0/"; if (pXmp->data_sz > kXmpHeader.size() && memcmp(pXmp->data, kXmpHeader.c_str(), kXmpHeader.size()) == 0) { @@ -1302,6 +1292,14 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr uhdr_mem_block_t user_block{const_cast(user_xmp_str.data()), user_xmp_str.size(), user_xmp_str.size()}; xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, &user_block); + if (xmp_primary_str.empty()) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "unable to safely merge supplied XMP metadata into the primary image"); + return status; + } } else { xmp_primary_str = user_xmp_str; } diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 3138a21a..2b41c306 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -1342,10 +1342,6 @@ string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length } // namespace -string generateXmpForPrimaryImage(size_t secondary_image_length, - uhdr_gainmap_metadata_ext_t& metadata, - uhdr_mem_block_t* user_xmp) { - /* * Computes 128-bit MD5 digest formatted as a 32-character uppercase hexadecimal GUID. * @@ -1499,7 +1495,6 @@ string generateXmpForPrimaryImage(size_t secondary_image_length, return MergePrimaryXmp(existing_xmp, secondary_image_length, metadata); } - const vector kConDirSeq({kConDirectory, string("rdf:Seq")}); std::stringstream ss; photos_editing_formats::image_io::XmlWriter writer(ss); writer.StartWritingElement("x:xmpmeta"); From 30edd5bb0a183a18e31b5bf8fd3140a442c2098e Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Sat, 19 Sep 2026 00:20:41 +0000 Subject: [PATCH 10/10] fuzzer: add ultrahdr_xmp_fuzzer for the primary image XMP merge The primary image XMP packet is untrusted input. When a gain map is appended to an already compressed base image, jpegr.cpp inherits that image's APP1 packet verbatim: if (pXmp == nullptr && decoder.getXMPSize() > 0) { xmp_from_jpg.data = decoder.getXMPPtr(); pXmp = &xmp_from_jpg; } and hands it to generateXmpForPrimaryImage(), which now parses the packet, resolves namespace prefixes, locates and rewrites the encoder owned gain map description by byte offset, and reserializes the result. That is a few hundred lines of hand rolled XML span arithmetic reachable from attacker controlled bytes, and none of the existing fuzz targets reach it: ultrahdr_enc_fuzzer only ever feeds the encoder base images it generated itself, whose XMP is well formed by construction. Add a dedicated target that drives: * generateXmpForPrimaryImage() with an arbitrary user packet (and, for contrast, with no user packet at all), * getMetadataFromXMP() on the merged result, so the packet we emit has to survive our own reader, * getMetadataFromXMP() on the raw untrusted packet. Wired into CMake, ossfuzz.sh and Android.bp alongside the existing enc/dec/legacy targets. No new build options are required: generateXmpForPrimaryImage() is compiled unconditionally, so the target builds in the default OSS-Fuzz configuration. Smoke tested locally against a stub FuzzedDataProvider (this host has no clang) over well formed, truncated, fragmented and NUL bearing packets: no crashes, and the target compiles clean under -Wall -Wextra -Werror. --- CMakeLists.txt | 11 ++++ fuzzer/Android.bp | 8 +++ fuzzer/ossfuzz.sh | 3 +- fuzzer/ultrahdr_xmp_fuzzer.cpp | 97 ++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 fuzzer/ultrahdr_xmp_fuzzer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 191300da..982b09e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -926,6 +926,17 @@ if(UHDR_BUILD_FUZZERS) endif() target_link_libraries(ultrahdr_dec_fuzzer ${UHDR_CORE_LIB_NAME}) + add_executable(ultrahdr_xmp_fuzzer ${FUZZERS_DIR}/ultrahdr_xmp_fuzzer.cpp) + add_dependencies(ultrahdr_xmp_fuzzer ${UHDR_CORE_LIB_NAME}) + target_compile_options(ultrahdr_xmp_fuzzer PRIVATE ${UHDR_WERROR_FLAGS}) + target_include_directories(ultrahdr_xmp_fuzzer PRIVATE ${PRIVATE_INCLUDE_DIR}) + if(DEFINED ENV{LIB_FUZZING_ENGINE}) + target_link_options(ultrahdr_xmp_fuzzer PRIVATE $ENV{LIB_FUZZING_ENGINE}) + else() + target_link_options(ultrahdr_xmp_fuzzer PRIVATE -fsanitize=fuzzer) + endif() + target_link_libraries(ultrahdr_xmp_fuzzer ${UHDR_CORE_LIB_NAME}) + add_executable(ultrahdr_legacy_fuzzer ${FUZZERS_DIR}/ultrahdr_legacy_fuzzer.cpp) add_dependencies(ultrahdr_legacy_fuzzer ${UHDR_CORE_LIB_NAME}) target_compile_options(ultrahdr_legacy_fuzzer PRIVATE ${UHDR_WERROR_FLAGS}) diff --git a/fuzzer/Android.bp b/fuzzer/Android.bp index 2c15fa8b..68091884 100644 --- a/fuzzer/Android.bp +++ b/fuzzer/Android.bp @@ -75,3 +75,11 @@ cc_fuzz { "ultrahdr_legacy_fuzzer.cpp", ], } + +cc_fuzz { + name: "ultrahdr_xmp_fuzzer", + defaults: ["ultrahdr_fuzzer_defaults"], + srcs: [ + "ultrahdr_xmp_fuzzer.cpp", + ], +} diff --git a/fuzzer/ossfuzz.sh b/fuzzer/ossfuzz.sh index 2b1fd35e..402bb24e 100755 --- a/fuzzer/ossfuzz.sh +++ b/fuzzer/ossfuzz.sh @@ -18,8 +18,9 @@ mkdir -p ${build_dir} pushd ${build_dir} cmake $SRC/libultrahdr -DUHDR_BUILD_FUZZERS=1 -DUHDR_MAX_DIMENSION=1280 -DUHDR_BUILD_DEPS=1 -DBUILD_SHARED_LIBS=OFF -make -j$(nproc) ultrahdr_dec_fuzzer ultrahdr_enc_fuzzer ultrahdr_legacy_fuzzer +make -j$(nproc) ultrahdr_dec_fuzzer ultrahdr_enc_fuzzer ultrahdr_legacy_fuzzer ultrahdr_xmp_fuzzer cp ${build_dir}/ultrahdr_dec_fuzzer $OUT/ cp ${build_dir}/ultrahdr_enc_fuzzer $OUT/ cp ${build_dir}/ultrahdr_legacy_fuzzer $OUT/ +cp ${build_dir}/ultrahdr_xmp_fuzzer $OUT/ popd diff --git a/fuzzer/ultrahdr_xmp_fuzzer.cpp b/fuzzer/ultrahdr_xmp_fuzzer.cpp new file mode 100644 index 00000000..b7b834cb --- /dev/null +++ b/fuzzer/ultrahdr_xmp_fuzzer.cpp @@ -0,0 +1,97 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 or the MIT license + * , at your + * option. This file may not be copied, modified, or distributed + * except according to those terms. + */ + +#include + +#include + +#include "ultrahdr/jpegrutils.h" +#include "ultrahdr/ultrahdrcommon.h" + +using namespace ultrahdr; + +// APP1 XMP identifier, as it appears on the wire ahead of the XML payload. +static const std::string kXmpNameSpace = std::string("http://ns.adobe.com/xap/1.0/\0", 29); + +// The primary image XMP packet of a JPEG is attacker controlled: when a gain +// map is appended to an already compressed base image, that image's APP1 packet +// is inherited verbatim and handed to generateXmpForPrimaryImage(), which +// parses it, resolves namespace prefixes, rewrites the encoder owned gain map +// description in place and serializes the result. This target drives that code, +// and the XMP reader, with arbitrary bytes. +class UltraHdrXmpFuzzer { + public: + UltraHdrXmpFuzzer(const uint8_t* data, size_t size) : mFdp(data, size){}; + void process(); + + private: + void parseXmpPacket(const std::string& xml); + + FuzzedDataProvider mFdp; +}; + +void UltraHdrXmpFuzzer::parseXmpPacket(const std::string& xml) { + std::string packet = kXmpNameSpace; + packet.append(xml); + uhdr_gainmap_metadata_ext_t parsed; + (void)getMetadataFromXMP(reinterpret_cast(packet.data()), packet.size(), + /* exif_data= */ nullptr, /* exif_size= */ 0, &parsed); +} + +void UltraHdrXmpFuzzer::process() { + uhdr_gainmap_metadata_ext_t metadata("1.0"); + bool are_all_channels_identical = mFdp.ConsumeBool(); + int channels = are_all_channels_identical ? 1 : 3; + for (int i = 0; i < channels; i++) { + metadata.max_content_boost[i] = mFdp.ConsumeFloatingPointInRange(-4.0f, 64.0f); + metadata.min_content_boost[i] = mFdp.ConsumeFloatingPointInRange(-4.0f, 64.0f); + metadata.gamma[i] = mFdp.ConsumeFloatingPointInRange(-1.0f, 5.0f); + metadata.offset_sdr[i] = mFdp.ConsumeFloatingPointInRange(-1.0f, 1.0f); + metadata.offset_hdr[i] = mFdp.ConsumeFloatingPointInRange(-1.0f, 1.0f); + } + if (are_all_channels_identical) { + for (int i = 1; i < 3; i++) { + metadata.max_content_boost[i] = metadata.max_content_boost[0]; + metadata.min_content_boost[i] = metadata.min_content_boost[0]; + metadata.gamma[i] = metadata.gamma[0]; + metadata.offset_sdr[i] = metadata.offset_sdr[0]; + metadata.offset_hdr[i] = metadata.offset_hdr[0]; + } + } + metadata.hdr_capacity_min = mFdp.ConsumeFloatingPointInRange(-4.0f, 48.0f); + metadata.hdr_capacity_max = mFdp.ConsumeFloatingPointInRange(-4.0f, 48.0f); + metadata.use_base_cg = mFdp.ConsumeBool(); + + size_t secondary_image_length = mFdp.ConsumeIntegral(); + bool supply_user_xmp = mFdp.ConsumeBool(); + std::string user_xmp = mFdp.ConsumeRemainingBytesAsString(); + + // Merge path. An empty return value means the packet was rejected; the + // encoder turns that into UHDR_CODEC_INVALID_PARAM. + std::string merged; + if (supply_user_xmp) { + uhdr_mem_block_t user_block{user_xmp.data(), user_xmp.size(), user_xmp.size()}; + merged = generateXmpForPrimaryImage(secondary_image_length, metadata, &user_block); + } else { + merged = generateXmpForPrimaryImage(secondary_image_length, metadata, nullptr); + } + + // Whatever the merge emits must survive our own reader. + if (!merged.empty()) parseXmpPacket(merged); + + // Also drive the reader directly with the untrusted packet. + if (!user_xmp.empty()) parseXmpPacket(user_xmp); +} + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + UltraHdrXmpFuzzer fuzzHandle(data, size); + fuzzHandle.process(); + return 0; +}