From 59418fbb4a92a3a1a7258cad829558e3650486a0 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 11 Sep 2026 21:59:09 +0000 Subject: [PATCH 01/20] 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/20] 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/20] 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 5dbaf8ef1ba8d54d9d295827fcf51b9299e8954a Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Thu, 17 Sep 2026 21:31:13 +0000 Subject: [PATCH 04/20] 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 09a39363b3421f856cbba6ed5292672d85b55e9c Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 2 Oct 2026 20:18:37 +0000 Subject: [PATCH 05/20] feat(api): add uhdr_strip_gain_map and namespace-aware XMP merging/stripping - Add public C API uhdr_strip_gain_map and JpegR::stripGainMap to losslessly strip secondary gain map bitstream, MPF APP2, ISO 21496-1 APP2, and Ultra HDR XMP tags - Preserve primary JPEG entropy-coded scan data bit-for-bit along with Exif, ICC, APP13, Extended XMP, and user XMP metadata - Support size-query mode when out_stream->data is nullptr and out_stream->capacity is 0 - Add namespace-aware XML parsing for primary XMP merging and gain-map tag stripping (MergePrimaryXmp and stripGainMapFromXmp) - Add unit tests covering uhdr_strip_gain_map and primary XMP merging across namespace prefixes and re-encoding --- CMakeLists.txt | 1 + lib/include/ultrahdr/jpegr.h | 13 + lib/include/ultrahdr/jpegrutils.h | 16 +- lib/src/jpegr.cpp | 264 ++++++++++ lib/src/jpegrutils.cpp | 684 +++++++++++++++++++++++-- lib/src/ultrahdr_api.cpp | 6 + tests/ultrahdr_api_test.cpp | 803 ++++++++++++++++++++++++++++++ ultrahdr_api.h | 16 + 8 files changed, 1764 insertions(+), 39 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/lib/include/ultrahdr/jpegr.h b/lib/include/ultrahdr/jpegr.h index 8d8a9a07..d3771b17 100644 --- a/lib/include/ultrahdr/jpegr.h +++ b/lib/include/ultrahdr/jpegr.h @@ -299,6 +299,16 @@ class JpegR : public UltraHdr { */ status_t getJPEGRInfo(jr_compressed_ptr jpegr_image_ptr, jr_info_ptr jpegr_image_info_ptr); + /*!\brief Losslessly strip gain map bitstream and gain map metadata from a JPEG/R stream. + * + * \param[in] in_stream input compressed JPEG/R stream + * \param[in, out] out_stream output memory block receiving the stripped SDR JPEG + * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, #UHDR_CODEC_INVALID_PARAM + * otherwise. + */ + uhdr_error_info_t stripGainMap(uhdr_compressed_image_t* in_stream, + uhdr_mem_block_t* out_stream); + private: /*!\brief compress gainmap image * @@ -357,8 +367,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 101fbfaa..4fc66799 100644 --- a/lib/include/ultrahdr/jpegrutils.h +++ b/lib/include/ultrahdr/jpegrutils.h @@ -134,13 +134,27 @@ std::string generateStandardXmpWithExtendedGuid(const std::string& guid); * * * @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, uhdr_mem_block_t* user_xmp = nullptr, const std::string& extended_xmp_guid = ""); +/* + * Strips Ultra HDR gain-map and GContainer directory properties from a primary-image XMP packet + * while preserving all unrelated XMP metadata and packet wrappers. + * + * @param xmp_data Input XMP packet XML string (without the JPEG APP1 namespace header). + * @param stripped_xmp Output string receiving the sanitized XMP packet, or empty string if the + * packet contained only gain-map metadata and no remaining descriptions. + * @return true if the XMP packet was valid and processed, false if the XML could not be parsed. + */ +bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp); + /* * This method generates XMP metadata for the recovery map image. * Link: https://developer.android.com/media/platform/hdr-image-format#XMP-attributes diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index 6e91a228..6c282909 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -1292,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; } @@ -3003,4 +3011,260 @@ status_t JpegR::decodeJPEGR(jr_compressed_ptr jpegr_image_ptr, jr_uncompressed_p return result.error_code == UHDR_CODEC_OK ? JPEGR_NO_ERROR : JPEGR_UNKNOWN_ERROR; } +uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, + uhdr_mem_block_t* out_stream) { + if (in_stream == nullptr) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for input stream"); + return status; + } + if (in_stream->data == nullptr || in_stream->data_sz < 4) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "invalid input stream buffer (data=%p, data_sz=%zu)", + in_stream->data, in_stream->data_sz); + return status; + } + if (out_stream == nullptr) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for output stream"); + return status; + } + if (out_stream->data == nullptr && out_stream->capacity != 0) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "received nullptr for output stream data buffer with non-zero capacity (%zu)", + out_stream->capacity); + return status; + } + + const uint8_t* data = static_cast(in_stream->data); + const size_t size = in_stream->data_sz; + if (data[0] != JpegMarker::kStart || data[1] != JpegMarker::kSOI) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "input stream does not start with JPEG SOI marker"); + return status; + } + + std::vector out_buf; + out_buf.reserve(size); + out_buf.push_back(JpegMarker::kStart); + out_buf.push_back(JpegMarker::kSOI); + + const size_t xmp_ns_len = kXmpNameSpace.size() + 1; + const size_t iso_ns_len = kIsoNameSpace.size() + 1; + const size_t mpf_sig_len = sizeof(kMpfSig); + + size_t pos = 2; + bool seen_sos = false; + bool seen_eoi = false; + + while (pos < size) { + const size_t marker_start = pos; + if (data[pos] != JpegMarker::kStart) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "expected JPEG marker prefix 0xFF at offset %zu", pos); + return status; + } + while (pos < size && data[pos] == JpegMarker::kStart) { + ++pos; + } + if (pos >= size) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "truncated JPEG marker at end of stream"); + return status; + } + + const uint8_t marker = data[pos++]; + if (marker == 0x00 || marker == JpegMarker::kSOI) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "unexpected JPEG marker 0xFF%02X at offset %zu", marker, marker_start); + return status; + } + + if (marker == JpegMarker::kEOI) { + if (!seen_sos) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "encountered EOI before SOS in primary JPEG stream"); + return status; + } + out_buf.push_back(JpegMarker::kStart); + out_buf.push_back(JpegMarker::kEOI); + seen_eoi = true; + break; + } + + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { + out_buf.insert(out_buf.end(), data + marker_start, data + pos); + continue; + } + + if (pos + 2 > size) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "truncated segment length for marker 0xFF%02X at offset %zu", marker, marker_start); + return status; + } + const size_t seg_len = (static_cast(data[pos]) << 8) | data[pos + 1]; + if (seg_len < 2 || pos + seg_len > size) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "invalid segment length %zu for marker 0xFF%02X at offset %zu", + seg_len, marker, marker_start); + return status; + } + + const uint8_t* payload = data + pos + 2; + const size_t payload_len = seg_len - 2; + const size_t next_pos = pos + seg_len; + + if (marker == JpegMarker::kAPP2) { + const bool is_mpf = + payload_len >= mpf_sig_len && memcmp(payload, kMpfSig, mpf_sig_len) == 0; + const bool is_iso = + payload_len >= iso_ns_len && memcmp(payload, kIsoNameSpace.c_str(), iso_ns_len) == 0; + if (!is_mpf && !is_iso) { + out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); + } + pos = next_pos; + continue; + } + + if (marker == JpegMarker::kAPP1) { + const bool is_standard_xmp = + payload_len >= xmp_ns_len && memcmp(payload, kXmpNameSpace.c_str(), xmp_ns_len) == 0; + if (is_standard_xmp) { + const std::string xmp_xml(reinterpret_cast(payload + xmp_ns_len), + payload_len - xmp_ns_len); + std::string stripped_xmp; + if (!stripGainMapFromXmp(xmp_xml, &stripped_xmp)) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "unable to parse or strip gain map metadata from primary XMP packet"); + return status; + } + if (!stripped_xmp.empty()) { + const size_t new_seg_len = 2 + xmp_ns_len + stripped_xmp.size(); + if (new_seg_len > 0xFFFF) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "stripped XMP segment exceeds maximum APP1 segment size"); + return status; + } + out_buf.push_back(JpegMarker::kStart); + out_buf.push_back(JpegMarker::kAPP1); + out_buf.push_back(static_cast((new_seg_len >> 8) & 0xFF)); + out_buf.push_back(static_cast(new_seg_len & 0xFF)); + out_buf.insert(out_buf.end(), + reinterpret_cast(kXmpNameSpace.c_str()), + reinterpret_cast(kXmpNameSpace.c_str()) + xmp_ns_len); + out_buf.insert(out_buf.end(), + reinterpret_cast(stripped_xmp.data()), + reinterpret_cast(stripped_xmp.data()) + + stripped_xmp.size()); + } + } else { + out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); + } + pos = next_pos; + continue; + } + + out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); + pos = next_pos; + + if (marker == JpegMarker::kSOS) { + seen_sos = true; + const size_t scan_data_start = pos; + bool found_post_scan_marker = false; + while (pos < size) { + if (data[pos] != JpegMarker::kStart) { + ++pos; + continue; + } + const size_t ff_start = pos; + while (pos < size && data[pos] == JpegMarker::kStart) { + ++pos; + } + if (pos >= size) { + break; + } + const uint8_t next_marker = data[pos]; + if (next_marker == 0x00 || (next_marker >= 0xD0 && next_marker <= 0xD7)) { + ++pos; + continue; + } + out_buf.insert(out_buf.end(), data + scan_data_start, data + ff_start); + pos = ff_start; + found_post_scan_marker = true; + break; + } + if (!found_post_scan_marker) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "truncated entropy-coded scan data before EOI in primary JPEG stream"); + return status; + } + } + } + + if (!seen_sos || !seen_eoi) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "incomplete primary JPEG stream (missing SOS or EOI marker)"); + return status; + } + + out_stream->data_sz = out_buf.size(); + if (out_stream->data == nullptr && out_stream->capacity == 0) { + return g_no_error; + } + if (out_stream->capacity < out_buf.size()) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "output buffer capacity (%zu) is smaller than required size (%zu)", + out_stream->capacity, out_buf.size()); + return status; + } + + memcpy(out_stream->data, out_buf.data(), out_buf.size()); + return g_no_error; +} + } // namespace ultrahdr diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 173407f5..2115043c 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,8 @@ 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 kAppleGainMapUri = "http://ns.apple.com/HDRGainMap/1.0/"; const string kContainerUri = "http://ns.google.com/photos/1.0/container/"; const string kContainerPrefix = "Container"; @@ -1003,6 +1007,647 @@ std::string generateStandardXmpWithExtendedGuid(const std::string& guid) { writer.FinishWriting(); return ss.str(); } +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 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_index_ = kNoXmpElement; + 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_index_ != kNoXmpElement) { + valid_ = false; + return context.GetResult(); + } + 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_index_ == kNoXmpElement) { + valid_ = false; + return context.GetResult(); + } + XmpAttributeSpan& attribute = pending_attributes_[pending_attribute_index_]; + if (!context.BuildTokenValue(&attribute.value, true)) valid_ = false; + attribute.value_end = context.GetTokenRange().GetEnd(); + pending_attribute_index_ = kNoXmpElement; + 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_index_ != kNoXmpElement) { + 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.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_; + size_t pending_attribute_index_ = kNoXmpElement; + bool last_element_was_self_closing_ = false; +}; + +bool IsGainMapPropertyUri(const string& uri) { + return uri == kContainerUri || uri == kItemUri || uri == kGainMapUri || uri == kAppleGainMapUri; +} + +string GeneratePrimaryDescription(size_t secondary_image_length, + uhdr_gainmap_metadata_ext_t& metadata, + const string& extended_xmp_guid) { + 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); + 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(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(); +} + +bool ParseRdfDescriptions(const string& parse_xml, XmpMergeXmlHandler* handler, size_t* rdf_root_out, + vector* all_descriptions_out, + vector* primary_descriptions_out) { + 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 false; + } + + 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 false; + packet_root = index; + } + } + if (packet_root == kNoXmpElement) return false; + + 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 false; + rdf_root = index; + } + } + } else { + return false; + } + if (rdf_root == kNoXmpElement) return false; + + all_descriptions_out->clear(); + primary_descriptions_out->clear(); + 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; + } + all_descriptions_out->push_back(index); + 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_out->push_back(index); + } + } + *rdf_root_out = rdf_root; + return true; +} + +string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length, + uhdr_gainmap_metadata_ext_t& metadata, + const string& extended_xmp_guid) { + 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); + size_t rdf_root = kNoXmpElement; + vector all_descriptions; + vector primary_descriptions; + if (!ParseRdfDescriptions(parse_xml, &handler, &rdf_root, &all_descriptions, + &primary_descriptions)) { + return string(); + } + const vector& elements = handler.elements(); + if (elements[rdf_root].self_closing || + elements[rdf_root].end_begin <= elements[rdf_root].start_begin) { + return string(); + } + + vector> removals; + for (const size_t description : primary_descriptions) { + 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 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); + } + } + } + + 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; + if (removal.second > insertion) return string(); + } + 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, extended_xmp_guid); + string merged; + size_t cursor = 0; + 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; + } + merged.append(existing_xmp, cursor, insertion - cursor); + merged.append(generated_description); + merged.push_back('\n'); + merged.append(existing_xmp, insertion, existing_xmp.size() - insertion); + return merged; +} + +} // namespace + +bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) { + if (stripped_xmp == nullptr) return false; + size_t trimmed_size = xmp_data.size(); + while (trimmed_size > 0 && xmp_data[trimmed_size - 1] == '\0') { + --trimmed_size; + } + const string clean_xmp = xmp_data.substr(0, trimmed_size); + if (clean_xmp.empty()) { + stripped_xmp->clear(); + return true; + } + + const string synthetic_open = ""; + const string synthetic_close = ""; + const size_t offset = synthetic_open.size(); + const string parse_xml = synthetic_open + clean_xmp + synthetic_close; + + XmpMergeXmlHandler handler(parse_xml); + size_t rdf_root = kNoXmpElement; + vector all_descriptions; + vector primary_descriptions; + if (!ParseRdfDescriptions(parse_xml, &handler, &rdf_root, &all_descriptions, + &primary_descriptions)) { + return false; + } + + const vector& elements = handler.elements(); + vector> removals; + size_t removed_descriptions = 0; + + for (const size_t description : primary_descriptions) { + const XmpElementSpan& primary = elements[description]; + bool has_removable = false; + bool has_user_attribute = false; + bool has_user_child = false; + + for (const XmpAttributeSpan& attribute : primary.attributes) { + if (attribute.namespace_declaration) continue; + if (attribute.uri == kRdfUri && attribute.local_name == "about" && + attribute.value.empty()) { + continue; + } + if (IsGainMapPropertyUri(attribute.uri)) { + has_removable = true; + } else { + has_user_attribute = true; + } + } + + for (const XmpElementSpan& child : elements) { + if (child.parent != description) continue; + if (IsGainMapPropertyUri(child.uri)) { + has_removable = true; + } else { + has_user_child = true; + } + } + + if (!has_removable) continue; + + if (!has_user_attribute && !has_user_child) { + ++removed_descriptions; + size_t remove_end = primary.end_end; + while (remove_end < parse_xml.size() && + (parse_xml[remove_end] == '\n' || parse_xml[remove_end] == '\r')) { + ++remove_end; + } + removals.emplace_back(primary.start_begin, remove_end); + continue; + } + + for (const XmpAttributeSpan& attribute : primary.attributes) { + bool should_remove = false; + if (!attribute.namespace_declaration && IsGainMapPropertyUri(attribute.uri)) { + should_remove = true; + } else if (attribute.namespace_declaration && IsGainMapPropertyUri(attribute.value)) { + should_remove = true; + } + if (should_remove) { + size_t attr_begin = attribute.name_begin; + while (attr_begin > primary.start_begin && IsXmlWhitespace(parse_xml[attr_begin - 1])) { + --attr_begin; + } + removals.emplace_back(attr_begin, attribute.value_end); + } + } + + for (const XmpElementSpan& child : elements) { + if (child.parent == description && IsGainMapPropertyUri(child.uri)) { + size_t child_begin = child.start_begin; + while (child_begin > primary.start_begin && + (parse_xml[child_begin - 1] == ' ' || parse_xml[child_begin - 1] == '\t')) { + --child_begin; + } + size_t child_end = child.end_end; + if (child_end + 1 < parse_xml.size() && parse_xml[child_end] == '\r' && + parse_xml[child_end + 1] == '\n') { + child_end += 2; + } else if (child_end < parse_xml.size() && parse_xml[child_end] == '\n') { + child_end += 1; + } + removals.emplace_back(child_begin, child_end); + } + } + } + + if (!all_descriptions.empty() && removed_descriptions == all_descriptions.size()) { + stripped_xmp->clear(); + return true; + } + + if (removals.empty()) { + *stripped_xmp = clean_xmp; + return true; + } + + for (auto& removal : removals) { + if (removal.first < offset || removal.second < removal.first) return false; + removal.first -= offset; + removal.second -= offset; + if (removal.second > clean_xmp.size()) return false; + } + sort(removals.begin(), removals.end()); + vector> coalesced; + for (const auto& [begin, end] : removals) { + if (!coalesced.empty() && begin <= coalesced.back().second) { + coalesced.back().second = (std::max)(coalesced.back().second, end); + } else { + coalesced.emplace_back(begin, end); + } + } + + string result; + result.reserve(clean_xmp.size()); + size_t cursor = 0; + for (const auto& [begin, end] : coalesced) { + if (begin < cursor || end > clean_xmp.size()) return false; + result.append(clean_xmp, cursor, begin - cursor); + cursor = end; + } + result.append(clean_xmp, cursor, clean_xmp.size() - cursor); + *stripped_xmp = std::move(result); + return true; +} + string generateXmpForPrimaryImage(size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, uhdr_mem_block_t* user_xmp, @@ -1024,44 +1669,7 @@ 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); - 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); - - 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, extended_xmp_guid); } std::stringstream ss; diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 5cfc56eb..90440a96 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -2347,3 +2347,9 @@ uhdr_error_info_t uhdr_add_effect_resize(uhdr_codec_private_t* codec, int width, return status; } + +uhdr_error_info_t uhdr_strip_gain_map(uhdr_compressed_image_t* in_stream, + uhdr_mem_block_t* out_stream) { + ultrahdr::JpegR jpegr; + return jpegr.stripGainMap(in_stream, out_stream); +} diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index ebdf7bce..745b950b 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -7,20 +7,30 @@ #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/jpegrutils.h" +#include "ultrahdr/jpegdecoderhelper.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 +59,240 @@ 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); +} + +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; +} + +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); +} + +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(); +} + +static std::vector extractPrimaryScanBytes(const void* stream_data, size_t stream_size) { + const auto* data = static_cast(stream_data); + if (data == nullptr || stream_size < 4 || data[0] != 0xFF || data[1] != 0xD8) return {}; + size_t pos = 2; + while (pos + 4 <= stream_size) { + if (data[pos] != 0xFF) return {}; + while (pos < stream_size && data[pos] == 0xFF) ++pos; + if (pos >= stream_size) return {}; + uint8_t marker = data[pos++]; + if (marker == 0xD9) return {}; + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) continue; + if (pos + 2 > stream_size) return {}; + size_t seg_len = (static_cast(data[pos]) << 8) | data[pos + 1]; + if (seg_len < 2 || pos + seg_len > stream_size) return {}; + size_t marker_begin = pos - 2; + pos += seg_len; + if (marker == 0xDA) { + size_t scan_end = pos; + while (scan_end < stream_size) { + if (data[scan_end] != 0xFF) { + ++scan_end; + continue; + } + size_t ff_pos = scan_end; + while (scan_end < stream_size && data[scan_end] == 0xFF) ++scan_end; + if (scan_end >= stream_size) return {}; + uint8_t next_marker = data[scan_end]; + if (next_marker == 0x00 || (next_marker >= 0xD0 && next_marker <= 0xD7)) { + ++scan_end; + continue; + } + return std::vector(data + marker_begin, data + ff_pos); + } + return {}; + } + } + return {}; +} + +#if defined(UHDR_WRITE_XMP) +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 +562,565 @@ 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_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(control.packet), strlen(control.packet), + strlen(control.packet)}; + 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); + 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); + } +} + +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_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(form.packet), strlen(form.packet), + strlen(form.packet)}; + 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); + 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); + } +} + +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, + &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); + 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_round_trip.decoder.get()); + ASSERT_NE(first_gainmap, nullptr); + 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, + 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; + + 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 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, + control.xmp != nullptr ? &xmp_input : nullptr, + second_round_trip)); + 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); + EXPECT_NE(second_packet.find(""), std::string::npos); + } + } +} + +TEST_F(UltraHdrApiTest, JpegApi4XmpMergeIgnoresFakeRdfClosersInCommentsAndCdata) { + const std::string user_xmp = + "semantic-probe" + "]]>" + "" + ""; + + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + 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); + 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(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); +} + +TEST_F(UltraHdrApiTest, JpegApi4XmpMergeKeepsOwnedPropertiesOnNodeIdDescription) { + const std::string user_xmp = + "" + "keep-id-directory" + "" + "keep-node-directory" + "" + "primary-probe"; + + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + 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); +} + +TEST_F(UltraHdrApiTest, JpegApi4RejectsMalformedOrUnmergeableXmp) { + const char* packets[] = { + "", + "" + "", + "" + "unmergeable", + }; + + for (const char* packet : packets) { + SCOPED_TRACE(packet); + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(packet), strlen(packet), strlen(packet)}; + 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; + } +} +#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 = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + 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); +} +#endif + +TEST_F(UltraHdrApiTest, StripGainMapPreservesPrimaryScanExifIccAndUserXmp) { + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + + const std::string kExifPayload = "Exif\0\0II*\0\x08\0\0\0\0\0"; + uhdr_mem_block_t exif_block{const_cast(kExifPayload.data()), kExifPayload.size(), + kExifPayload.size()}; + + const std::string kUserXmp = + "\n" + "\n" + " \n" + " \n" + " Photographer\n" + " \n" + " \n" + " Lossless SDR export test\n" + " \n" + " \n" + " \n" + " \n" + "\n" + ""; + uhdr_mem_block_t xmp_block{const_cast(kUserXmp.data()), kUserXmp.size(), kUserXmp.size()}; + + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_exif_data(enc.get(), &exif_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + ASSERT_EQ(is_uhdr_image(uhdr_stream->data, static_cast(uhdr_stream->data_sz)), 1); + + // 1. Query required output size via {nullptr, 0, 0}. + uhdr_mem_block_t size_query{nullptr, 0, 0}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &size_query).error_code, UHDR_CODEC_OK); + ASSERT_GT(size_query.data_sz, 0u); + ASSERT_LT(size_query.data_sz, uhdr_stream->data_sz); + + // 2. Strip gain map into caller-owned buffer. + std::vector stripped_buf(size_query.data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(stripped_block.data_sz, size_query.data_sz); + + // 3. Stripped output is a clean standard SDR JPEG, not recognized as Ultra HDR. + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + // 4. Primary entropy-coded scan data is preserved bit-for-bit (no DCT re-encoding). + const std::vector orig_scan = + extractPrimaryScanBytes(uhdr_stream->data, uhdr_stream->data_sz); + const std::vector stripped_scan = + extractPrimaryScanBytes(stripped_block.data, stripped_block.data_sz); + ASSERT_FALSE(orig_scan.empty()); + EXPECT_EQ(orig_scan, stripped_scan); + + // 5. Verify Exif, ICC, ISO metadata removal, and user XMP via JpegDecoderHelper. + JpegDecoderHelper orig_decoder; + ASSERT_EQ(orig_decoder.parseImage(uhdr_stream->data, uhdr_stream->data_sz).error_code, UHDR_CODEC_OK); + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, UHDR_CODEC_OK); + + EXPECT_EQ(stripped_decoder.getIsoMetadataSize(), 0u); + ASSERT_EQ(stripped_decoder.getEXIFSize(), orig_decoder.getEXIFSize()); + EXPECT_EQ(memcmp(stripped_decoder.getEXIFPtr(), orig_decoder.getEXIFPtr(), + orig_decoder.getEXIFSize()), + 0); + ASSERT_EQ(stripped_decoder.getICCSize(), orig_decoder.getICCSize()); + if (orig_decoder.getICCSize() > 0) { + EXPECT_EQ( + memcmp(stripped_decoder.getICCPtr(), orig_decoder.getICCPtr(), orig_decoder.getICCSize()), + 0); + } + + ASSERT_NE(stripped_decoder.getXMPPtr(), nullptr); + uhdr_mem_block_t stripped_xmp_mem{stripped_decoder.getXMPPtr(), stripped_decoder.getXMPSize(), + stripped_decoder.getXMPSize()}; + const std::string stripped_xmp_str = getXmpPacket(&stripped_xmp_mem); + EXPECT_EQ(stripped_xmp_str, kUserXmp); + EXPECT_EQ(stripped_xmp_str.find("Container:Directory"), std::string::npos); + EXPECT_EQ(stripped_xmp_str.find("hdrgm:"), std::string::npos); + + // 6. Verify idempotency when calling uhdr_strip_gain_map again on the stripped SDR JPEG. + uhdr_compressed_image_t stripped_input{stripped_block.data, stripped_block.data_sz, + stripped_block.capacity, UHDR_CG_BT_709, UHDR_CT_SRGB, + UHDR_CR_FULL_RANGE}; + std::vector second_buf(stripped_block.data_sz); + uhdr_mem_block_t second_block{second_buf.data(), 0, second_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(&stripped_input, &second_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(second_block.data_sz, stripped_block.data_sz); + EXPECT_EQ(memcmp(second_block.data, stripped_block.data, stripped_block.data_sz), 0); +} + +TEST_F(UltraHdrApiTest, StripGainMapDropsGeneratedOnlyXmpSegment) { + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + nullptr) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + + std::vector stripped_buf(uhdr_stream->data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, UHDR_CODEC_OK); + EXPECT_EQ(stripped_decoder.getIsoMetadataSize(), 0u); + EXPECT_EQ(stripped_decoder.getXMPSize(), 0u); +} + +TEST_F(UltraHdrApiTest, StripGainMapPreservesSharedDescriptionAndExtendedXmp) { + // Part 1: Shared containing both user properties and gain-map properties. + const std::string shared_xmp = + "" + "Shared description photo" + "" + "" + "" + ""; + std::string stripped_shared; + ASSERT_TRUE(stripGainMapFromXmp(shared_xmp, &stripped_shared)); + EXPECT_NE(stripped_shared.find("dc:format=\"image/jpeg\""), std::string::npos); + EXPECT_NE(stripped_shared.find("Shared description photo"), + std::string::npos); + EXPECT_EQ(stripped_shared.find("hdrgm:"), std::string::npos); + EXPECT_EQ(stripped_shared.find("Container:"), std::string::npos); + EXPECT_EQ(stripped_shared.find("xmlns:hdrgm"), std::string::npos); + EXPECT_EQ(stripped_shared.find("xmlns:Container"), std::string::npos); + EXPECT_EQ(stripped_shared.find("xmlns:Item"), std::string::npos); + + // Part 2: Multi-segment Extended XMP (> 64 KB) preserved through uhdr_strip_gain_map. + const std::string ext_xmp = makeLargeXmp(140000); + uhdr_mem_block_t ext_xmp_block{const_cast(ext_xmp.data()), ext_xmp.size(), ext_xmp.size()}; + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &ext_xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + + std::vector stripped_buf(uhdr_stream->data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, UHDR_CODEC_OK); + ASSERT_EQ(stripped_decoder.getXMPSize(), ext_xmp.size()); + EXPECT_EQ(memcmp(stripped_decoder.getXMPPtr(), ext_xmp.data(), ext_xmp.size()), 0); +} + +TEST_F(UltraHdrApiTest, StripGainMapStripsTrailingBinaryTrailerAfterPrimaryEoi) { + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + nullptr) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + + // Append a synthetic binary trailer containing arbitrary 0xFF bytes (e.g. Samsung SEF trailer). + std::vector with_trailer( + static_cast(uhdr_stream->data), + static_cast(uhdr_stream->data) + uhdr_stream->data_sz); + const uint8_t sef_trailer[] = {0x53, 0x45, 0x46, 0x54, 0xFF, 0xE1, 0xFF, 0xD8, 0xFF, 0xFE, 0x00}; + with_trailer.insert(with_trailer.end(), std::begin(sef_trailer), std::end(sef_trailer)); + + uhdr_compressed_image_t trailer_input{with_trailer.data(), with_trailer.size(), + with_trailer.size(), UHDR_CG_BT_709, UHDR_CT_SRGB, + UHDR_CR_FULL_RANGE}; + std::vector stripped_buf(with_trailer.size()); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(&trailer_input, &stripped_block).error_code, UHDR_CODEC_OK); + ASSERT_GE(stripped_block.data_sz, 4u); + EXPECT_EQ(stripped_buf[stripped_block.data_sz - 2], 0xFF); + EXPECT_EQ(stripped_buf[stripped_block.data_sz - 1], 0xD9); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); +} + +TEST_F(UltraHdrApiTest, StripGainMapInvalidParamsAndCorruptInput) { + uhdr_mem_block_t out{nullptr, 0, 0}; + EXPECT_EQ(uhdr_strip_gain_map(nullptr, &out).error_code, UHDR_CODEC_INVALID_PARAM); + EXPECT_EQ(uhdr_strip_gain_map(&mSdrCompressed, nullptr).error_code, UHDR_CODEC_INVALID_PARAM); + + uhdr_mem_block_t bad_out{nullptr, 0, 16}; + EXPECT_EQ(uhdr_strip_gain_map(&mSdrCompressed, &bad_out).error_code, UHDR_CODEC_INVALID_PARAM); + + uint8_t tiny_buf[4]{}; + uhdr_mem_block_t tiny_out{tiny_buf, 0, sizeof(tiny_buf)}; + EXPECT_EQ(uhdr_strip_gain_map(&mSdrCompressed, &tiny_out).error_code, UHDR_CODEC_INVALID_PARAM); + EXPECT_GT(tiny_out.data_sz, sizeof(tiny_buf)); + + uint8_t non_jpeg[] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + uhdr_compressed_image_t non_jpeg_in{non_jpeg, sizeof(non_jpeg), sizeof(non_jpeg), + UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, + UHDR_CR_UNSPECIFIED}; + EXPECT_EQ(uhdr_strip_gain_map(&non_jpeg_in, &out).error_code, UHDR_CODEC_INVALID_PARAM); + + // Truncated JPEG (SOI + EOI without SOS) + uint8_t no_sos_jpeg[] = {0xFF, 0xD8, 0xFF, 0xD9}; + uhdr_compressed_image_t no_sos_in{no_sos_jpeg, sizeof(no_sos_jpeg), sizeof(no_sos_jpeg), + UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, + UHDR_CR_UNSPECIFIED}; + EXPECT_EQ(uhdr_strip_gain_map(&no_sos_in, &out).error_code, UHDR_CODEC_INVALID_PARAM); +} + TEST_F(UltraHdrApiTest, JpegEncodeWithXmpAndDecode) { uhdr_codec_private_t* enc = uhdr_create_encoder(); ASSERT_NE(enc, nullptr); diff --git a/ultrahdr_api.h b/ultrahdr_api.h index 334c8ed7..2bfa9725 100644 --- a/ultrahdr_api.h +++ b/ultrahdr_api.h @@ -922,4 +922,20 @@ UHDR_EXTERN uhdr_error_info_t uhdr_add_effect_crop(uhdr_codec_private_t* codec, UHDR_EXTERN uhdr_error_info_t uhdr_add_effect_resize(uhdr_codec_private_t* codec, int width, int height); +/*!\brief Losslessly strip gain map bitstream and gain map metadata from an Ultra HDR image. + * + * Produces a clean standard SDR compressed stream: + * - Strips secondary gain map JPEG image data and MPF pointers. + * - Strips ISO 21496-1 gain map metadata from APP2 (urn:iso:std:iso:ts:21496:-1). + * - Strips legacy Ultra HDR tags (Container:Directory, hdrgm:*) from APP1 XMP. + * - Preserves primary image scan data bit-for-bit (lossless, no DCT re-encoding). + * - Preserves Exif, ICC profile, and all user/application XMP metadata intact. + * + * \param[in] in_stream Input compressed Ultra HDR stream. + * \param[out] out_stream Output descriptor receiving the clean SDR compressed stream. + * \return uhdr_error_info_t #UHDR_CODEC_OK on success, #UHDR_CODEC_INVALID_PARAM otherwise. + */ +UHDR_EXTERN uhdr_error_info_t uhdr_strip_gain_map(uhdr_compressed_image_t* in_stream, + uhdr_mem_block_t* out_stream); + #endif // ULTRAHDR_API_H From 259f3ed5e8337f26dca3c78a9a7ec8f1463938ec Mon Sep 17 00:00:00 2001 From: Dichen Zhang <140119224+DichenZhang1@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:32:00 +0000 Subject: [PATCH 06/20] fix(xmp): handle near-limit XMP spill to Extended XMP, add fuzzer and fixture coverage (#494) --- fuzzer/ultrahdr_dec_fuzzer.cpp | 9 ++++ fuzzer/ultrahdr_enc_fuzzer.cpp | 8 +++ lib/src/jpegr.cpp | 9 +++- lib/src/ultrahdr_api.cpp | 19 ++----- tests/ultrahdr_api_test.cpp | 90 ++++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 16 deletions(-) diff --git a/fuzzer/ultrahdr_dec_fuzzer.cpp b/fuzzer/ultrahdr_dec_fuzzer.cpp index e1008b47..45ef120c 100644 --- a/fuzzer/ultrahdr_dec_fuzzer.cpp +++ b/fuzzer/ultrahdr_dec_fuzzer.cpp @@ -69,6 +69,14 @@ void UltraHdrDecFuzzer::process() { (void)is_uhdr_image(buffer.data(), buffer.size()); + uhdr_mem_block_t stripped_size{nullptr, 0, 0}; + if (uhdr_strip_gain_map(&jpegImgR, &stripped_size).error_code == UHDR_CODEC_OK && + stripped_size.data_sz > 0 && stripped_size.data_sz <= buffer.size() + 65536) { + std::vector stripped_buf(stripped_size.data_sz); + uhdr_mem_block_t stripped_out{stripped_buf.data(), 0, stripped_buf.size()}; + (void)uhdr_strip_gain_map(&jpegImgR, &stripped_out); + } + uhdr_codec_private_t* dec_handle = uhdr_create_decoder(); if (dec_handle) { ON_ERR(uhdr_dec_set_image(dec_handle, &jpegImgR)) @@ -106,6 +114,7 @@ void UltraHdrDecFuzzer::process() { uhdr_dec_get_exif(dec_handle); uhdr_dec_get_icc(dec_handle); + uhdr_dec_get_xmp(dec_handle); uhdr_dec_get_base_image(dec_handle); uhdr_dec_get_gainmap_image(dec_handle); uhdr_dec_get_gainmap_metadata(dec_handle); diff --git a/fuzzer/ultrahdr_enc_fuzzer.cpp b/fuzzer/ultrahdr_enc_fuzzer.cpp index 92794682..71e3576f 100644 --- a/fuzzer/ultrahdr_enc_fuzzer.cpp +++ b/fuzzer/ultrahdr_enc_fuzzer.cpp @@ -427,6 +427,13 @@ void UltraHdrEncFuzzer::process() { if (status.error_code == UHDR_CODEC_OK) { auto output = uhdr_get_encoded_stream(enc_handle); if (output != nullptr) { + uhdr_mem_block_t stripped_size{nullptr, 0, 0}; + if (uhdr_strip_gain_map(output, &stripped_size).error_code == UHDR_CODEC_OK && + stripped_size.data_sz > 0) { + std::vector stripped_buf(stripped_size.data_sz); + uhdr_mem_block_t stripped_out{stripped_buf.data(), 0, stripped_buf.size()}; + ON_ERR(uhdr_strip_gain_map(output, &stripped_out)) + } uhdr_codec_private_t* dec_handle = uhdr_create_decoder(); if (dec_handle) { ON_ERR(uhdr_dec_set_image(dec_handle, output)) @@ -438,6 +445,7 @@ void UltraHdrEncFuzzer::process() { else ON_ERR(uhdr_dec_set_out_img_format(dec_handle, UHDR_IMG_FMT_32bppRGBA1010102)) ON_ERR(uhdr_decode(dec_handle)) + uhdr_dec_get_xmp(dec_handle); uhdr_release_decoder(dec_handle); } } diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index 6c282909..fe5efa3b 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -1285,6 +1285,7 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr } } + bool spill_user_xmp_to_extended = false; if (kWriteXmpMetadata) { if (user_xmp_str.empty()) { xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, nullptr); @@ -1300,15 +1301,21 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr "unable to safely merge supplied XMP metadata into the primary image"); return status; } + if (xmp_primary_str.size() > kMaxStandardXmpPayload) { + xmp_primary_str = user_xmp_str; + spill_user_xmp_to_extended = true; + } } else { xmp_primary_str = user_xmp_str; + spill_user_xmp_to_extended = true; } } else if (!user_xmp_str.empty()) { xmp_primary_str = user_xmp_str; + spill_user_xmp_to_extended = xmp_primary_str.size() > kMaxStandardXmpPayload; } if (!xmp_primary_str.empty()) { - if (xmp_primary_str.size() <= kMaxStandardXmpPayload) { + if (!spill_user_xmp_to_extended && 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); diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 90440a96..fd5e9e12 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -18,13 +18,6 @@ #include "ultrahdr/editorhelper.h" #include "ultrahdr/jpegr.h" -#ifdef UHDR_ENABLE_HEIF -#include "ultrahdr/heifultrahdr.h" -#include "ultrahdr/avifultrahdr.h" -#include "ultrahdr/gainmapmetadata.h" -#endif - - #ifdef UHDR_ENABLE_HEIF #include "ultrahdr/heifultrahdr.h" #include "ultrahdr/avifultrahdr.h" @@ -1319,14 +1312,10 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { const bool mayWriteXmp = !handle->m_xmp.empty() || !handle->m_compressed_images.empty(); 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; - } + 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; diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 745b950b..8c9f3006 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -1121,6 +1121,96 @@ TEST_F(UltraHdrApiTest, StripGainMapInvalidParamsAndCorruptInput) { EXPECT_EQ(uhdr_strip_gain_map(&no_sos_in, &out).error_code, UHDR_CODEC_INVALID_PARAM); } +TEST_F(UltraHdrApiTest, StripGainMapOnAppleGainMapFixtures) { + for (const char* filename : {"apple_gainmap_new.jpg", "apple_gainmap_old.jpg"}) { + SCOPED_TRACE(filename); + std::vector apple_data; + ASSERT_TRUE(loadFile(filename, apple_data)); + ASSERT_EQ(is_uhdr_image(apple_data.data(), static_cast(apple_data.size())), 1); + + uhdr_compressed_image_t apple_in{apple_data.data(), apple_data.size(), apple_data.size(), + UHDR_CG_DISPLAY_P3, UHDR_CT_SRGB, UHDR_CR_FULL_RANGE}; + uhdr_mem_block_t size_query{nullptr, 0, 0}; + ASSERT_EQ(uhdr_strip_gain_map(&apple_in, &size_query).error_code, UHDR_CODEC_OK); + ASSERT_GT(size_query.data_sz, 0u); + ASSERT_LT(size_query.data_sz, apple_data.size()); + + std::vector stripped_buf(size_query.data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(&apple_in, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(stripped_block.data_sz, size_query.data_sz); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + // Primary entropy-coded scan (including DRI and RST0..RST7 markers) must be bit-identical. + const std::vector orig_scan = + extractPrimaryScanBytes(apple_data.data(), apple_data.size()); + const std::vector stripped_scan = + extractPrimaryScanBytes(stripped_block.data, stripped_block.data_sz); + ASSERT_FALSE(orig_scan.empty()); + EXPECT_EQ(orig_scan, stripped_scan); + + // Verify EXIF and ICC profile are preserved and stripped JPEG decodes cleanly. + JpegDecoderHelper orig_decoder; + ASSERT_EQ(orig_decoder.parseImage(apple_data.data(), apple_data.size()).error_code, + UHDR_CODEC_OK); + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder + .decompressImage(stripped_block.data, stripped_block.data_sz, DECODE_TO_RGB_CS) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(stripped_decoder.getEXIFSize(), orig_decoder.getEXIFSize()); + EXPECT_EQ(memcmp(stripped_decoder.getEXIFPtr(), orig_decoder.getEXIFPtr(), + orig_decoder.getEXIFSize()), + 0); + ASSERT_EQ(stripped_decoder.getICCSize(), orig_decoder.getICCSize()); + EXPECT_EQ(memcmp(stripped_decoder.getICCPtr(), orig_decoder.getICCPtr(), + orig_decoder.getICCSize()), + 0); + } +} + +TEST_F(UltraHdrApiTest, NearLimitStandardXmpSpillsCleanlyToExtendedXmp) { + // 65,200 bytes is <= kMaxStandardXmpPayload (65,503 B), but when UHDR_WRITE_XMP is enabled, + // merging the ~600-byte Ultra HDR container directory pushes it over 65,503 B into Extended XMP. + constexpr size_t kNearLimitSize = 65200; + const std::string near_limit_xmp = makeLargeXmp(kNearLimitSize); + uhdr_mem_block_t xmp_block{const_cast(near_limit_xmp.data()), near_limit_xmp.size(), + near_limit_xmp.size()}; + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + + // Verify uhdr_dec_get_xmp recovers the exact 65,200-byte user XMP packet. + DecoderPtr dec = makeDecoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec.get(), uhdr_stream).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec.get()).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec.get()); + ASSERT_NE(decoded_xmp, nullptr); + EXPECT_EQ(getXmpPacket(decoded_xmp), near_limit_xmp); + + // Verify uhdr_strip_gain_map also preserves the 65,200-byte user XMP packet cleanly. + std::vector stripped_buf(uhdr_stream->data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, + UHDR_CODEC_OK); + uhdr_mem_block_t stripped_xmp_mem{stripped_decoder.getXMPPtr(), stripped_decoder.getXMPSize(), + stripped_decoder.getXMPSize()}; + EXPECT_EQ(getXmpPacket(&stripped_xmp_mem), near_limit_xmp); +} + TEST_F(UltraHdrApiTest, JpegEncodeWithXmpAndDecode) { uhdr_codec_private_t* enc = uhdr_create_encoder(); ASSERT_NE(enc, nullptr); From a4e9fa24a942c4dd2115231328cbfd98846288a9 Mon Sep 17 00:00:00 2001 From: Greg B <64932474+gregbenz@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:51:47 -0500 Subject: [PATCH 07/20] Preserve namespace declarations used by retained XMP (#495) Keep namespace bindings on mixed descriptions when stripping gain-map properties so retained descendants do not acquire unbound prefixes. Continue removing descriptions containing only gain-map metadata. Add a regression and retain existing property-removal coverage. Focused tests pass in dual-metadata and ISO-only builds; independent XML parsing validates the reproduced output. --- lib/src/jpegrutils.cpp | 7 +------ tests/ultrahdr_api_test.cpp | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 2115043c..9b8b3bfd 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -1575,13 +1575,8 @@ bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) } for (const XmpAttributeSpan& attribute : primary.attributes) { - bool should_remove = false; + // Namespace bindings may still be used by retained descendants in a mixed description. if (!attribute.namespace_declaration && IsGainMapPropertyUri(attribute.uri)) { - should_remove = true; - } else if (attribute.namespace_declaration && IsGainMapPropertyUri(attribute.value)) { - should_remove = true; - } - if (should_remove) { size_t attr_begin = attribute.name_begin; while (attr_begin > primary.start_begin && IsXmlWhitespace(parse_xml[attr_begin - 1])) { --attr_begin; diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 8c9f3006..a58a0614 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -1034,9 +1034,6 @@ TEST_F(UltraHdrApiTest, StripGainMapPreservesSharedDescriptionAndExtendedXmp) { std::string::npos); EXPECT_EQ(stripped_shared.find("hdrgm:"), std::string::npos); EXPECT_EQ(stripped_shared.find("Container:"), std::string::npos); - EXPECT_EQ(stripped_shared.find("xmlns:hdrgm"), std::string::npos); - EXPECT_EQ(stripped_shared.find("xmlns:Container"), std::string::npos); - EXPECT_EQ(stripped_shared.find("xmlns:Item"), std::string::npos); // Part 2: Multi-segment Extended XMP (> 64 KB) preserved through uhdr_strip_gain_map. const std::string ext_xmp = makeLargeXmp(140000); @@ -1063,6 +1060,22 @@ TEST_F(UltraHdrApiTest, StripGainMapPreservesSharedDescriptionAndExtendedXmp) { EXPECT_EQ(memcmp(stripped_decoder.getXMPPtr(), ext_xmp.data(), ext_xmp.size()), 0); } +TEST_F(UltraHdrApiTest, StripGainMapKeepsNamespaceUsedByRetainedDescendant) { + const std::string shared_xmp = + "" + ""; + + std::string stripped; + ASSERT_TRUE(stripGainMapFromXmp(shared_xmp, &stripped)); + EXPECT_EQ(stripped.find("hdrgm:Version=\"1.0\""), std::string::npos); + EXPECT_NE(stripped.find("xmlns:hdrgm=\"http://ns.adobe.com/hdr-gain-map/1.0/\""), + std::string::npos); + EXPECT_NE(stripped.find(""), std::string::npos); +} + TEST_F(UltraHdrApiTest, StripGainMapStripsTrailingBinaryTrailerAfterPrimaryEoi) { EncoderPtr enc = makeEncoder(); ASSERT_NE(enc, nullptr); From 262cb763be6342043bafa52f2dccd3c25f3d9113 Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Tue, 6 Oct 2026 03:37:56 +0000 Subject: [PATCH 08/20] Strip gain-map metadata from linked Extended XMP (#496) Clean linked extension packets while preserving user metadata, updating the digest and standard XMP link, and removing empty packets. Validate linked chunk coverage before writing output and cover preservation and malformed input with regression tests. --- lib/include/ultrahdr/jpegrutils.h | 6 + lib/src/jpegr.cpp | 243 +++++++++++++++++++++++++ lib/src/jpegrutils.cpp | 141 +++++++++++++++ tests/ultrahdr_api_test.cpp | 291 +++++++++++++++++++++++++++++- 4 files changed, 675 insertions(+), 6 deletions(-) diff --git a/lib/include/ultrahdr/jpegrutils.h b/lib/include/ultrahdr/jpegrutils.h index 4fc66799..781f102d 100644 --- a/lib/include/ultrahdr/jpegrutils.h +++ b/lib/include/ultrahdr/jpegrutils.h @@ -155,6 +155,12 @@ std::string generateXmpForPrimaryImage(size_t secondary_image_length, */ bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp); +/* Reads or updates xmpNote:HasExtendedXMP on primary RDF descriptions. An empty replacement + * removes the link; unrelated RDF subjects and packet content are preserved. */ +bool getExtendedXmpGuidFromXmp(const std::string& xmp_data, std::string* guid); +bool replaceExtendedXmpGuidInXmp(const std::string& xmp_data, const std::string& new_guid, + std::string* rewritten_xmp); + /* * This method generates XMP metadata for the recovery map image. * Link: https://developer.android.com/media/platform/hdr-image-format#XMP-attributes diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index fe5efa3b..a90e05c8 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -20,11 +20,14 @@ #include #include #include +#include +#include #include "ultrahdr/editorhelper.h" #include "ultrahdr/gainmapmetadata.h" #include "ultrahdr/ultrahdrcommon.h" #include "ultrahdr/jpegr.h" +#include "ultrahdr/jpegrutils.h" #include "ultrahdr/icc.h" #include "ultrahdr/multipictureformat.h" @@ -3018,6 +3021,224 @@ status_t JpegR::decodeJPEGR(jr_compressed_ptr jpegr_image_ptr, jr_uncompressed_p return result.error_code == UHDR_CODEC_OK ? JPEGR_NO_ERROR : JPEGR_UNKNOWN_ERROR; } +namespace { + +struct XmpSegmentSpan { + size_t begin; + size_t end; + string xml; + size_t payload_begin; + size_t payload_size; +}; + +struct ExtendedXmpChunk { + size_t segment_index; + uint32_t offset; + size_t data_begin; + size_t data_size; +}; + +struct StripXmpEdit { + size_t begin; + size_t end; + vector replacement; +}; + +uint32_t ReadBigEndian32(const uint8_t* bytes) { + return (static_cast(bytes[0]) << 24) | (static_cast(bytes[1]) << 16) | + (static_cast(bytes[2]) << 8) | static_cast(bytes[3]); +} + +void AppendBigEndian32(uint32_t value, string* bytes) { + for (int shift = 24; shift >= 0; shift -= 8) { + bytes->push_back(static_cast((value >> shift) & 0xff)); + } +} + +bool AppendApp1Segment(const string& signature, const string& body, vector* output) { + const size_t payload_size = signature.size() + 1 + body.size(); + if (payload_size + 2 > kJpegSegmentMaxLength) return false; + const size_t length = payload_size + 2; + output->insert(output->end(), {JpegMarker::kStart, JpegMarker::kAPP1, + static_cast(length >> 8), + static_cast(length & 0xff)}); + output->insert(output->end(), signature.begin(), signature.end()); + output->push_back('\0'); + output->insert(output->end(), body.begin(), body.end()); + return true; +} + +bool RewriteLinkedExtendedXmp(vector* output, const vector& standard_xmp, + const vector& extended_xmp, const uint8_t* input, + size_t input_size, string* error) { + string linked_guid; + for (const XmpSegmentSpan& standard : standard_xmp) { + string guid; + if (!getExtendedXmpGuidFromXmp(standard.xml, &guid)) { + *error = "malformed HasExtendedXMP reference in standard XMP"; + return false; + } + if (!guid.empty() && !linked_guid.empty() && guid != linked_guid) { + *error = "standard XMP contains ambiguous Extended XMP links"; + return false; + } + if (!guid.empty()) linked_guid = guid; + } + if (linked_guid.empty()) return true; + + const size_t extension_signature_size = kExtendedXmpNameSpace.size() + 1; + vector chunks; + uint32_t total_size = 0; + size_t covered_size = 0; + for (size_t index = 0; index < extended_xmp.size(); ++index) { + const XmpSegmentSpan& segment = extended_xmp[index]; + if (segment.payload_size < extension_signature_size || + memcmp(input + segment.payload_begin, kExtendedXmpNameSpace.c_str(), + extension_signature_size) != 0 || + segment.payload_size < extension_signature_size + 32) { + continue; + } + const uint8_t* payload = input + segment.payload_begin; + if (memcmp(payload + extension_signature_size, linked_guid.data(), linked_guid.size()) != 0) { + continue; + } + if (segment.payload_size < extension_signature_size + 40) { + *error = "linked Extended XMP chunk has a truncated header"; + return false; + } + const uint32_t chunk_total = ReadBigEndian32(payload + extension_signature_size + 32); + const uint32_t chunk_offset = ReadBigEndian32(payload + extension_signature_size + 36); + const size_t data_offset = segment.payload_begin + extension_signature_size + 40; + const size_t data_size = segment.payload_size - extension_signature_size - 40; + if (chunk_total == 0 || chunk_total > input_size || chunk_offset > chunk_total || + data_size == 0 || data_size > chunk_total - chunk_offset || + data_size > input_size - covered_size || + (total_size != 0 && total_size != chunk_total)) { + *error = "linked Extended XMP chunk has invalid bounds or length"; + return false; + } + total_size = chunk_total; + covered_size += data_size; + chunks.push_back({index, chunk_offset, data_offset, data_size}); + } + if (chunks.empty()) { + *error = "standard XMP links to missing Extended XMP chunks"; + return false; + } + sort(chunks.begin(), chunks.end(), [](const ExtendedXmpChunk& left, + const ExtendedXmpChunk& right) { + return left.offset < right.offset; + }); + size_t next_offset = 0; + for (const ExtendedXmpChunk& chunk : chunks) { + if (chunk.offset != next_offset) { + *error = "linked Extended XMP chunks contain a gap or overlap"; + return false; + } + next_offset += chunk.data_size; + } + if (next_offset != total_size || covered_size != total_size) { + *error = "linked Extended XMP chunks do not cover the declared packet"; + return false; + } + + string packet; + packet.reserve(total_size); + for (const ExtendedXmpChunk& chunk : chunks) { + packet.append(reinterpret_cast(input + chunk.data_begin), chunk.data_size); + } + if (computeMd5Guid(reinterpret_cast(packet.data()), packet.size()) != linked_guid) { + *error = "linked Extended XMP packet MD5 does not match its GUID"; + return false; + } + string stripped_packet; + if (!stripGainMapFromXmp(packet, &stripped_packet)) { + *error = "unable to strip gain map properties from linked Extended XMP"; + return false; + } + if (stripped_packet == packet) return true; + + string new_guid; + if (!stripped_packet.empty()) { + new_guid = computeMd5Guid(reinterpret_cast(stripped_packet.data()), + stripped_packet.size()); + } + + vector edits; + for (const XmpSegmentSpan& standard : standard_xmp) { + string guid; + if (!getExtendedXmpGuidFromXmp(standard.xml, &guid)) { + *error = "malformed HasExtendedXMP reference in standard XMP"; + return false; + } + if (guid.empty()) continue; + if (guid != linked_guid) { + *error = "standard XMP references multiple Extended XMP packets"; + return false; + } + string rewritten_xml; + if (!replaceExtendedXmpGuidInXmp(standard.xml, new_guid, &rewritten_xml)) { + *error = "unable to update standard XMP Extended XMP reference"; + return false; + } + vector replacement; + if (!AppendApp1Segment(kXmpNameSpace, rewritten_xml, &replacement)) { + *error = "rewritten standard XMP exceeds APP1 segment capacity"; + return false; + } + edits.push_back({standard.begin, standard.end, std::move(replacement)}); + } + + size_t first_linked_segment = extended_xmp.size(); + for (const ExtendedXmpChunk& chunk : chunks) { + if (first_linked_segment == extended_xmp.size() || + extended_xmp[chunk.segment_index].begin < extended_xmp[first_linked_segment].begin) { + first_linked_segment = chunk.segment_index; + } + } + vector replacement_chunks; + if (!stripped_packet.empty()) { + for (size_t offset = 0; offset < stripped_packet.size(); offset += kExtendedXmpMaxChunkSize) { + const size_t count = (std::min)(kExtendedXmpMaxChunkSize, stripped_packet.size() - offset); + string body = new_guid; + AppendBigEndian32(static_cast(stripped_packet.size()), &body); + AppendBigEndian32(static_cast(offset), &body); + body.append(stripped_packet, offset, count); + if (!AppendApp1Segment(kExtendedXmpNameSpace, body, &replacement_chunks)) { + *error = "rewritten Extended XMP chunk exceeds APP1 segment capacity"; + return false; + } + } + } + for (const ExtendedXmpChunk& chunk : chunks) { + const XmpSegmentSpan& segment = extended_xmp[chunk.segment_index]; + vector replacement; + if (chunk.segment_index == first_linked_segment) replacement = replacement_chunks; + edits.push_back({segment.begin, segment.end, std::move(replacement)}); + } + + sort(edits.begin(), edits.end(), [](const StripXmpEdit& left, const StripXmpEdit& right) { + return left.begin < right.begin; + }); + vector rewritten; + rewritten.reserve(output->size()); + size_t cursor = 0; + for (const StripXmpEdit& edit : edits) { + if (edit.begin < cursor || edit.begin > edit.end || edit.end > output->size()) { + *error = "overlapping Extended XMP rewrite spans"; + return false; + } + rewritten.insert(rewritten.end(), output->begin() + cursor, output->begin() + edit.begin); + rewritten.insert(rewritten.end(), edit.replacement.begin(), edit.replacement.end()); + cursor = edit.end; + } + rewritten.insert(rewritten.end(), output->begin() + cursor, output->end()); + *output = std::move(rewritten); + return true; +} + +} // namespace + uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, uhdr_mem_block_t* out_stream) { if (in_stream == nullptr) { @@ -3070,8 +3291,11 @@ uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, out_buf.push_back(JpegMarker::kSOI); const size_t xmp_ns_len = kXmpNameSpace.size() + 1; + const size_t extended_xmp_ns_len = kExtendedXmpNameSpace.size() + 1; const size_t iso_ns_len = kIsoNameSpace.size() + 1; const size_t mpf_sig_len = sizeof(kMpfSig); + vector standard_xmp; + vector extended_xmp; size_t pos = 2; bool seen_sos = false; @@ -3179,6 +3403,7 @@ uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, return status; } if (!stripped_xmp.empty()) { + const size_t output_begin = out_buf.size(); const size_t new_seg_len = 2 + xmp_ns_len + stripped_xmp.size(); if (new_seg_len > 0xFFFF) { uhdr_error_info_t status; @@ -3199,7 +3424,14 @@ uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, reinterpret_cast(stripped_xmp.data()), reinterpret_cast(stripped_xmp.data()) + stripped_xmp.size()); + standard_xmp.push_back({output_begin, out_buf.size(), stripped_xmp, 0, 0}); } + } else if (payload_len >= extended_xmp_ns_len && + memcmp(payload, kExtendedXmpNameSpace.c_str(), extended_xmp_ns_len) == 0) { + const size_t output_begin = out_buf.size(); + out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); + extended_xmp.push_back({output_begin, out_buf.size(), string(), + static_cast(payload - data), payload_len}); } else { out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); } @@ -3256,6 +3488,17 @@ uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, return status; } + string extended_xmp_error; + if (!RewriteLinkedExtendedXmp(&out_buf, standard_xmp, extended_xmp, data, size, + &extended_xmp_error)) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "unable to process linked Extended XMP: %s", extended_xmp_error.c_str()); + return status; + } + out_stream->data_sz = out_buf.size(); if (out_stream->data == nullptr && out_stream->capacity == 0) { return g_no_error; diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 9b8b3bfd..0687038a 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -1037,6 +1037,7 @@ struct XmpAttributeSpan { string uri; string local_name; size_t name_begin = 0; + size_t value_begin = 0; size_t value_end = 0; bool namespace_declaration = false; }; @@ -1094,6 +1095,7 @@ class XmpMergeXmlHandler : public XmlHandler { } XmpAttributeSpan& attribute = pending_attributes_[pending_attribute_index_]; if (!context.BuildTokenValue(&attribute.value, true)) valid_ = false; + attribute.value_begin = context.GetTokenRange().GetBegin() + 1; attribute.value_end = context.GetTokenRange().GetEnd(); pending_attribute_index_ = kNoXmpElement; return context.GetResult(); @@ -1429,6 +1431,134 @@ bool ParseRdfDescriptions(const string& parse_xml, XmpMergeXmlHandler* handler, return true; } +namespace { + +constexpr char kXmpNoteUri[] = "http://ns.adobe.com/xmp/note/"; + +struct ExtendedXmpReference { + size_t value_begin; + size_t value_end; + size_t remove_begin; + size_t remove_end; +}; + +bool IsExtendedXmpGuid(const string& guid) { + if (guid.size() != 32) return false; + for (char value : guid) { + if (!((value >= '0' && value <= '9') || (value >= 'A' && value <= 'F'))) return false; + } + return true; +} + +bool ProcessExtendedXmpGuid(const string& xmp_data, const string* replacement_guid, + string* guid_out, string* rewritten_xmp) { + if (guid_out != nullptr) guid_out->clear(); + if (replacement_guid != nullptr && rewritten_xmp == nullptr) return false; + if (replacement_guid != nullptr && !replacement_guid->empty() && + !IsExtendedXmpGuid(*replacement_guid)) { + return false; + } + + size_t trimmed_size = xmp_data.size(); + while (trimmed_size > 0 && xmp_data[trimmed_size - 1] == '\0') --trimmed_size; + const string clean_xmp = xmp_data.substr(0, trimmed_size); + if (clean_xmp.empty()) { + if (rewritten_xmp != nullptr) rewritten_xmp->clear(); + return true; + } + + const string synthetic_open = ""; + const size_t offset = synthetic_open.size(); + const string parse_xml = synthetic_open + clean_xmp + ""; + XmpMergeXmlHandler handler(parse_xml); + size_t rdf_root = kNoXmpElement; + vector all_descriptions; + vector primary_descriptions; + if (!ParseRdfDescriptions(parse_xml, &handler, &rdf_root, &all_descriptions, + &primary_descriptions)) { + return false; + } + + const vector& elements = handler.elements(); + vector references; + string found_guid; + for (size_t description : primary_descriptions) { + const XmpElementSpan& primary = elements[description]; + for (const XmpAttributeSpan& attribute : primary.attributes) { + if (attribute.uri != kXmpNoteUri || attribute.local_name != "HasExtendedXMP") continue; + if (!IsExtendedXmpGuid(attribute.value)) return false; + if (!found_guid.empty() && found_guid != attribute.value) return false; + found_guid = attribute.value; + size_t remove_begin = attribute.name_begin; + while (remove_begin > primary.start_begin && IsXmlWhitespace(parse_xml[remove_begin - 1])) { + --remove_begin; + } + references.push_back({attribute.value_begin, attribute.value_end - 1, remove_begin, + attribute.value_end}); + } + + for (size_t index = 0; index < elements.size(); ++index) { + const XmpElementSpan& element = elements[index]; + if (element.parent != description || element.uri != kXmpNoteUri || + element.local_name != "HasExtendedXMP") { + continue; + } + if (element.self_closing) return false; + for (const XmpElementSpan& child : elements) { + if (child.parent == index) return false; + } + size_t value_begin = 0; + if (!FindXmlMarkupEnd(parse_xml, element.start_begin, &value_begin) || + element.end_begin < value_begin) { + return false; + } + size_t value_end = element.end_begin; + while (value_begin < value_end && IsXmlWhitespace(parse_xml[value_begin])) ++value_begin; + while (value_end > value_begin && IsXmlWhitespace(parse_xml[value_end - 1])) --value_end; + const string guid = parse_xml.substr(value_begin, value_end - value_begin); + if (!IsExtendedXmpGuid(guid) || (!found_guid.empty() && found_guid != guid)) return false; + found_guid = guid; + size_t remove_begin = element.start_begin; + while (remove_begin > primary.start_begin && IsXmlWhitespace(parse_xml[remove_begin - 1])) { + --remove_begin; + } + references.push_back({value_begin, value_end, remove_begin, element.end_end}); + } + } + + if (guid_out != nullptr) *guid_out = found_guid; + if (rewritten_xmp == nullptr || references.empty() || *replacement_guid == found_guid) { + if (rewritten_xmp != nullptr) *rewritten_xmp = clean_xmp; + return true; + } + + vector> edits; + edits.reserve(references.size()); + for (const ExtendedXmpReference& reference : references) { + const size_t begin = replacement_guid->empty() ? reference.remove_begin : reference.value_begin; + const size_t end = replacement_guid->empty() ? reference.remove_end : reference.value_end; + if (begin < offset || end < begin || end - offset > clean_xmp.size()) return false; + edits.emplace_back(begin - offset, end - offset); + } + sort(edits.begin(), edits.end()); + for (size_t index = 1; index < edits.size(); ++index) { + if (edits[index].first < edits[index - 1].second) return false; + } + + string rewritten; + size_t cursor = 0; + for (const auto& edit : edits) { + rewritten.append(clean_xmp, cursor, edit.first - cursor); + if (!replacement_guid->empty()) rewritten.append(*replacement_guid); + cursor = edit.second; + } + rewritten.append(clean_xmp, cursor, clean_xmp.size() - cursor); + *rewritten_xmp = std::move(rewritten); + return true; +} + +} // namespace + string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, const string& extended_xmp_guid) { @@ -1503,6 +1633,17 @@ string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length } // namespace +bool getExtendedXmpGuidFromXmp(const string& xmp_data, string* guid) { + if (guid == nullptr) return false; + return ProcessExtendedXmpGuid(xmp_data, nullptr, guid, nullptr); +} + +bool replaceExtendedXmpGuidInXmp(const string& xmp_data, const string& new_guid, + string* rewritten_xmp) { + if (rewritten_xmp == nullptr) return false; + return ProcessExtendedXmpGuid(xmp_data, &new_guid, nullptr, rewritten_xmp); +} + bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) { if (stripped_xmp == nullptr) return false; size_t trimmed_size = xmp_data.size(); diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index a58a0614..230aa145 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -200,6 +200,83 @@ static std::vector extractPrimaryScanBytes(const void* stream_data, siz return {}; } +struct JpegSegmentSpan { + size_t begin; + size_t end; + size_t payload_begin; + size_t payload_size; +}; + +static std::vector findApp1Segments(const std::vector& jpeg, + std::string_view signature) { + std::vector segments; + if (jpeg.size() < 4 || jpeg[0] != 0xFF || jpeg[1] != 0xD8) return segments; + size_t pos = 2; + while (pos + 4 <= jpeg.size() && jpeg[pos] == 0xFF) { + const size_t marker_begin = pos; + while (pos < jpeg.size() && jpeg[pos] == 0xFF) ++pos; + if (pos == jpeg.size()) break; + const uint8_t marker = jpeg[pos++]; + if (marker == 0xD9 || marker == 0xDA) break; + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD8)) continue; + if (pos + 2 > jpeg.size()) break; + const size_t length = (static_cast(jpeg[pos]) << 8) | jpeg[pos + 1]; + if (length < 2 || pos + length > jpeg.size()) break; + const size_t payload_begin = pos + 2; + const size_t payload_size = length - 2; + if (marker == 0xE1 && payload_size >= signature.size() + 1 && + std::string_view(reinterpret_cast(jpeg.data() + payload_begin), + signature.size()) == signature && + jpeg[payload_begin + signature.size()] == 0) { + segments.push_back({marker_begin, pos + length, payload_begin, payload_size}); + } + pos += length; + } + return segments; +} + +static std::vector findExtendedXmpSegments(const std::vector& jpeg) { + return findApp1Segments(jpeg, "http://ns.adobe.com/xmp/extension/"); +} + +static uint32_t readBigEndian32(const std::vector& bytes, size_t offset) { + return (static_cast(bytes[offset]) << 24) | + (static_cast(bytes[offset + 1]) << 16) | + (static_cast(bytes[offset + 2]) << 8) | bytes[offset + 3]; +} + +static void writeBigEndian32(std::vector* bytes, size_t offset, uint32_t value) { + for (int shift = 24; shift >= 0; shift -= 8) { + (*bytes)[offset++] = static_cast((value >> shift) & 0xFF); + } +} + +static bool reverseExtendedXmpSegments(std::vector* jpeg) { + const std::vector segments = findExtendedXmpSegments(*jpeg); + if (segments.size() < 2) return false; + for (size_t index = 1; index < segments.size(); ++index) { + if (segments[index - 1].end != segments[index].begin) return false; + } + std::vector reordered(jpeg->begin(), jpeg->begin() + segments.front().begin); + for (auto segment = segments.rbegin(); segment != segments.rend(); ++segment) { + reordered.insert(reordered.end(), jpeg->begin() + segment->begin, jpeg->begin() + segment->end); + } + reordered.insert(reordered.end(), jpeg->begin() + segments.back().end, jpeg->end()); + *jpeg = std::move(reordered); + return true; +} + +static std::string makeLargeGainMapOnlyXmp(size_t size) { + const std::string prefix = + "" + ""; + const std::string suffix = ""; + EXPECT_GE(size, prefix.size() + suffix.size()); + return prefix + std::string(size - prefix.size() - suffix.size(), '1') + suffix; +} + #if defined(UHDR_WRITE_XMP) struct ContainerDirectoryInfo { bool parsed = false; @@ -1035,8 +1112,18 @@ TEST_F(UltraHdrApiTest, StripGainMapPreservesSharedDescriptionAndExtendedXmp) { EXPECT_EQ(stripped_shared.find("hdrgm:"), std::string::npos); EXPECT_EQ(stripped_shared.find("Container:"), std::string::npos); - // Part 2: Multi-segment Extended XMP (> 64 KB) preserved through uhdr_strip_gain_map. - const std::string ext_xmp = makeLargeXmp(140000); + // Part 2: Multi-segment Extended XMP (> 64 KB) is stripped and relinked. + std::string ext_xmp = makeLargeXmp(140000); + const std::string about = "rdf:about=\"\""; + const size_t about_pos = ext_xmp.find(about); + ASSERT_NE(about_pos, std::string::npos); + ext_xmp.insert(about_pos + about.size(), + " xmlns:hdrgm=\"http://ns.adobe.com/hdr-gain-map/1.0/\" " + "hdrgm:Version=\"1.0\""); + std::string expected_ext_xmp; + ASSERT_TRUE(stripGainMapFromXmp(ext_xmp, &expected_ext_xmp)); + EXPECT_EQ(expected_ext_xmp.find("hdrgm:Version"), std::string::npos); + EXPECT_NE(expected_ext_xmp.find("(ext_xmp.data()), ext_xmp.size(), ext_xmp.size()}; EncoderPtr enc = makeEncoder(); ASSERT_NE(enc, nullptr); @@ -1049,15 +1136,207 @@ TEST_F(UltraHdrApiTest, StripGainMapPreservesSharedDescriptionAndExtendedXmp) { uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); ASSERT_NE(uhdr_stream, nullptr); - std::vector stripped_buf(uhdr_stream->data_sz); + std::vector reordered_input( + static_cast(uhdr_stream->data), + static_cast(uhdr_stream->data) + uhdr_stream->data_sz); + ASSERT_TRUE(reverseExtendedXmpSegments(&reordered_input)); + uhdr_compressed_image_t reordered_stream{reordered_input.data(), reordered_input.size(), + reordered_input.size(), UHDR_CG_UNSPECIFIED, + UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED}; + std::vector stripped_buf(reordered_input.size()); uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; - ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_strip_gain_map(&reordered_stream, &stripped_block).error_code, UHDR_CODEC_OK); EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); JpegDecoderHelper stripped_decoder; ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, UHDR_CODEC_OK); - ASSERT_EQ(stripped_decoder.getXMPSize(), ext_xmp.size()); - EXPECT_EQ(memcmp(stripped_decoder.getXMPPtr(), ext_xmp.data(), ext_xmp.size()), 0); + ASSERT_EQ(stripped_decoder.getXMPSize(), expected_ext_xmp.size()); + EXPECT_EQ(memcmp(stripped_decoder.getXMPPtr(), expected_ext_xmp.data(), expected_ext_xmp.size()), 0); + const std::vector stripped_jpeg( + static_cast(stripped_block.data), + static_cast(stripped_block.data) + stripped_block.data_sz); + const std::vector standard_segments = + findApp1Segments(stripped_jpeg, "http://ns.adobe.com/xap/1.0/"); + std::string linked_guid; + for (const JpegSegmentSpan& segment : standard_segments) { + constexpr std::string_view kStandardNamespace = "http://ns.adobe.com/xap/1.0/"; + const size_t xml_begin = segment.payload_begin + kStandardNamespace.size() + 1; + const std::string standard_xml( + reinterpret_cast(stripped_jpeg.data() + xml_begin), + segment.payload_size - kStandardNamespace.size() - 1); + std::string segment_guid; + ASSERT_TRUE(getExtendedXmpGuidFromXmp(standard_xml, &segment_guid)); + if (!segment_guid.empty()) { + ASSERT_TRUE(linked_guid.empty()); + linked_guid = segment_guid; + } + } + ASSERT_FALSE(linked_guid.empty()); + EXPECT_EQ(linked_guid, + computeMd5Guid(reinterpret_cast(expected_ext_xmp.data()), + expected_ext_xmp.size())); +} + +TEST_F(UltraHdrApiTest, StripGainMapLeavesUserOnlyExtendedXmpChunksUnchanged) { + const std::string user_xmp = makeLargeXmp(140000); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* encoded = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(encoded, nullptr); + std::vector original(static_cast(encoded->data), + static_cast(encoded->data) + encoded->data_sz); + const std::vector original_segments = findExtendedXmpSegments(original); + ASSERT_GE(original_segments.size(), 2u); + + uhdr_compressed_image_t input{original.data(), original.size(), original.size(), + UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, + UHDR_CR_UNSPECIFIED}; + std::vector output(original.size()); + uhdr_mem_block_t output_block{output.data(), 0, output.size()}; + ASSERT_EQ(uhdr_strip_gain_map(&input, &output_block).error_code, UHDR_CODEC_OK); + output.resize(output_block.data_sz); + const std::vector output_segments = findExtendedXmpSegments(output); + ASSERT_EQ(output_segments.size(), original_segments.size()); + for (size_t index = 0; index < original_segments.size(); ++index) { + const JpegSegmentSpan& before = original_segments[index]; + const JpegSegmentSpan& after = output_segments[index]; + ASSERT_EQ(before.end - before.begin, after.end - after.begin); + EXPECT_EQ(std::memcmp(original.data() + before.begin, output.data() + after.begin, + before.end - before.begin), + 0); + } + JpegDecoderHelper decoder; + ASSERT_EQ(decoder.parseImage(output.data(), output.size()).error_code, UHDR_CODEC_OK); + ASSERT_EQ(decoder.getXMPSize(), user_xmp.size()); + EXPECT_EQ(std::memcmp(decoder.getXMPPtr(), user_xmp.data(), user_xmp.size()), 0); +} + +TEST_F(UltraHdrApiTest, StripGainMapRemovesEmptyLinkedExtendedXmp) { + const std::string gainmap_only_xmp = makeLargeGainMapOnlyXmp(140000); + std::string stripped_xmp; + ASSERT_TRUE(stripGainMapFromXmp(gainmap_only_xmp, &stripped_xmp)); + ASSERT_TRUE(stripped_xmp.empty()); + uhdr_mem_block_t xmp_block{const_cast(gainmap_only_xmp.data()), gainmap_only_xmp.size(), + gainmap_only_xmp.size()}; + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* encoded = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(encoded, nullptr); + std::vector output(encoded->data_sz); + uhdr_mem_block_t output_block{output.data(), 0, output.size()}; + ASSERT_EQ(uhdr_strip_gain_map(encoded, &output_block).error_code, UHDR_CODEC_OK); + output.resize(output_block.data_sz); + EXPECT_TRUE(findExtendedXmpSegments(output).empty()); + JpegDecoderHelper decoder; + ASSERT_EQ(decoder.parseImage(output.data(), output.size()).error_code, UHDR_CODEC_OK); + ASSERT_NE(decoder.getXMPPtr(), nullptr); + uhdr_mem_block_t decoded_xmp_block{decoder.getXMPPtr(), decoder.getXMPSize(), + decoder.getXMPSize()}; + const std::string decoded_xmp = getXmpPacket(&decoded_xmp_block); + std::string guid; + ASSERT_TRUE(getExtendedXmpGuidFromXmp(decoded_xmp, &guid)) << decoded_xmp; + EXPECT_TRUE(guid.empty()); +} + +TEST_F(UltraHdrApiTest, StripGainMapRejectsMalformedLinkedExtendedXmpWithoutWriting) { + constexpr std::string_view kExtensionNamespace = "http://ns.adobe.com/xmp/extension/"; + std::string ext_xmp = makeLargeXmp(140000); + const size_t about_pos = ext_xmp.find("rdf:about=\"\""); + ASSERT_NE(about_pos, std::string::npos); + ext_xmp.insert(about_pos + std::string("rdf:about=\"\"").size(), + " xmlns:hdrgm=\"http://ns.adobe.com/hdr-gain-map/1.0/\" " + "hdrgm:Version=\"1.0\""); + uhdr_mem_block_t xmp_block{const_cast(ext_xmp.data()), ext_xmp.size(), ext_xmp.size()}; + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* encoded = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(encoded, nullptr); + const std::vector original(static_cast(encoded->data), + static_cast(encoded->data) + encoded->data_sz); + const std::vector segments = findExtendedXmpSegments(original); + ASSERT_GE(segments.size(), 2u); + const size_t extension_header = kExtensionNamespace.size() + 1 + 32 + 8; + const size_t total_offset = segments[1].payload_begin + kExtensionNamespace.size() + 1 + 32; + const size_t chunk_offset = total_offset + 4; + + std::vector> malformed; + malformed.push_back(original); + malformed.back().erase(malformed.back().begin() + segments.back().begin, + malformed.back().begin() + segments.back().end); + malformed.push_back(original); + writeBigEndian32(&malformed.back(), total_offset, + readBigEndian32(malformed.back(), total_offset) + 1); + malformed.push_back(original); + writeBigEndian32(&malformed.back(), chunk_offset, 0); + malformed.push_back(original); + const size_t changed_byte = ext_xmp.find('X'); + ASSERT_NE(changed_byte, std::string::npos); + bool digest_byte_changed = false; + for (const JpegSegmentSpan& segment : segments) { + const size_t header = segment.payload_begin + extension_header; + const size_t offset = readBigEndian32(malformed.back(), header - 4); + const size_t data_size = segment.payload_size - extension_header; + if (changed_byte >= offset && changed_byte - offset < data_size) { + malformed.back()[header + changed_byte - offset] = 'Y'; + digest_byte_changed = true; + break; + } + } + ASSERT_TRUE(digest_byte_changed); + + for (size_t index = 0; index < malformed.size(); ++index) { + SCOPED_TRACE(index); + uhdr_compressed_image_t input{malformed[index].data(), malformed[index].size(), + malformed[index].size(), UHDR_CG_UNSPECIFIED, + UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED}; + std::vector output(malformed[index].size() * 2, 0xA5); + uhdr_mem_block_t output_block{output.data(), 0, output.size()}; + EXPECT_EQ(uhdr_strip_gain_map(&input, &output_block).error_code, UHDR_CODEC_INVALID_PARAM); + EXPECT_TRUE(std::all_of(output.begin(), output.end(), [](uint8_t value) { return value == 0xA5; })); + } +} + +TEST_F(UltraHdrApiTest, ExtendedXmpElementLinkIsUpdatedAndRemovedByNamespace) { + const std::string old_guid = "0123456789ABCDEF0123456789ABCDEF"; + const std::string new_guid = "FEDCBA9876543210FEDCBA9876543210"; + const std::string xmp = + "" + "" + old_guid + + ""; + std::string found_guid; + ASSERT_TRUE(getExtendedXmpGuidFromXmp(xmp, &found_guid)); + EXPECT_EQ(found_guid, old_guid); + std::string updated; + ASSERT_TRUE(replaceExtendedXmpGuidInXmp(xmp, new_guid, &updated)); + EXPECT_NE(updated.find(new_guid), std::string::npos); + EXPECT_EQ(updated.find(old_guid), std::string::npos); + EXPECT_NE(updated.find("dc:title=\"Keep this title\""), std::string::npos); + std::string removed; + ASSERT_TRUE(replaceExtendedXmpGuidInXmp(xmp, "", &removed)); + EXPECT_EQ(removed.find("HasExtendedXMP"), std::string::npos); + EXPECT_NE(removed.find("dc:title=\"Keep this title\""), std::string::npos); } TEST_F(UltraHdrApiTest, StripGainMapKeepsNamespaceUsedByRetainedDescendant) { From b3581bba36f4aeaa7d9f11a1e396e64ae375787e Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 9 Oct 2026 16:54:07 +0000 Subject: [PATCH 09/20] fix(build): add libheif to libuhdr.pc Requires.private and fix GCC 15 -Wfree-nonheap-object --- cmake/libuhdr.pc.in | 2 +- lib/src/jpegr.cpp | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cmake/libuhdr.pc.in b/cmake/libuhdr.pc.in index 920d1cf5..1b3b7943 100644 --- a/cmake/libuhdr.pc.in +++ b/cmake/libuhdr.pc.in @@ -5,7 +5,7 @@ includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@ Name: @PROJECT_NAME@ Description: @CMAKE_PROJECT_DESCRIPTION@ Version: @PROJECT_VERSION@ -Requires.private: libjpeg +Requires.private: libjpeg@UHDR_PC_REQUIRES_PRIVATE@ Cflags: -I${includedir} Libs: -L${libdir} -l@UHDR_TARGET_NAME@ Libs.private: @CMAKE_THREAD_LIBS_INIT@ @UHDR_GL_DEPS@ diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index 0a79ccfe..e1afedd0 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -3460,8 +3460,7 @@ uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, std::vector out_buf; out_buf.reserve(size); - out_buf.push_back(JpegMarker::kStart); - out_buf.push_back(JpegMarker::kSOI); + out_buf.insert(out_buf.end(), data, data + 2); const size_t xmp_ns_len = kXmpNameSpace.size() + 1; const size_t extended_xmp_ns_len = kExtendedXmpNameSpace.size() + 1; From ea4d7e40b5e1756b509efbb3d7cbf41be1ea4672 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 9 Oct 2026 16:54:07 +0000 Subject: [PATCH 10/20] chore(release): bump version to 2.1.0 and include libheif in pkg-config --- CMakeLists.txt | 5 +++-- ultrahdr_api.h | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 20670ca3..55f31bb8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,8 +12,8 @@ cmake_minimum_required(VERSION 3.15) cmake_policy(SET CMP0091 NEW) set(UHDR_MAJOR_VERSION 2) -set(UHDR_MINOR_VERSION 0) -set(UHDR_PATCH_VERSION 2) +set(UHDR_MINOR_VERSION 1) +set(UHDR_PATCH_VERSION 0) project(libuhdr VERSION ${UHDR_MAJOR_VERSION}.${UHDR_MINOR_VERSION}.${UHDR_PATCH_VERSION} LANGUAGES C CXX @@ -651,6 +651,7 @@ if(UHDR_ENABLE_HEIF) if(LIBHEIF_FOUND) message(STATUS "Found libheif: ${LIBHEIF_LIBRARIES}") add_compile_options(-DUHDR_ENABLE_HEIF) + set(UHDR_PC_REQUIRES_PRIVATE " libheif") if(LIBHEIF_HAS_ITEM_API) message(STATUS "libheif item inspection API - Available") add_compile_options(-DUHDR_HAS_HEIF_ITEM_API) diff --git a/ultrahdr_api.h b/ultrahdr_api.h index cb65b15e..fcfe257b 100644 --- a/ultrahdr_api.h +++ b/ultrahdr_api.h @@ -83,12 +83,17 @@ * 2.0.2 2.0.2 fix static libheif feature probing in Findlibheif.cmake; * make core target linking PRIVATE to prevent overlinking * downstream consumers. + * 2.1.0 2.1.0 add Extended XMP support and XMP metadata preservation + * APIs (uhdr_enc_set_xmp_data, uhdr_dec_get_xmp, + * uhdr_strip_gain_map); add runtime gain-map routing + * predicate (uhdr_is_supported_gainmap_image); odd-dimension + * 4:2:0 and EXIF offset hardening fixes. */ // This needs to be kept in sync with version in CMakeLists.txt #define UHDR_LIB_VER_MAJOR 2 -#define UHDR_LIB_VER_MINOR 0 -#define UHDR_LIB_VER_PATCH 2 +#define UHDR_LIB_VER_MINOR 1 +#define UHDR_LIB_VER_PATCH 0 #define UHDR_LIB_VERSION \ ((UHDR_LIB_VER_MAJOR * 10000) + (UHDR_LIB_VER_MINOR * 100) + UHDR_LIB_VER_PATCH) From 94eeda436232f65602be300d725e1cc41794c0c0 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Tue, 13 Oct 2026 00:24:39 +0000 Subject: [PATCH 11/20] Address PR #497 review feedback on Extended XMP and codec XMP validation --- lib/include/ultrahdr/jpegrutils.h | 6 + lib/src/jpegdecoderhelper.cpp | 106 ++++++---- lib/src/jpegrutils.cpp | 162 +++++++++++++++ lib/src/ultrahdr_api.cpp | 16 +- tests/ultrahdr_api_test.cpp | 317 ++++++++++++++++++++++++++++++ ultrahdr_api.h | 5 + 6 files changed, 571 insertions(+), 41 deletions(-) diff --git a/lib/include/ultrahdr/jpegrutils.h b/lib/include/ultrahdr/jpegrutils.h index 781f102d..2a7127df 100644 --- a/lib/include/ultrahdr/jpegrutils.h +++ b/lib/include/ultrahdr/jpegrutils.h @@ -161,6 +161,12 @@ bool getExtendedXmpGuidFromXmp(const std::string& xmp_data, std::string* guid); bool replaceExtendedXmpGuidInXmp(const std::string& xmp_data, const std::string& new_guid, std::string* rewritten_xmp); +/* Merges user RDF descriptions from a standard XMP packet (removing xmpNote:HasExtendedXMP and + * encoder-generated gain-map properties) into a linked Extended XMP packet so user properties + * split across Standard and Extended XMP packets are both preserved. */ +bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::string& extended_xmp, + std::string* merged_xmp); + /* * This method generates XMP metadata for the recovery map image. * Link: https://developer.android.com/media/platform/hdr-image-format#XMP-attributes diff --git a/lib/src/jpegdecoderhelper.cpp b/lib/src/jpegdecoderhelper.cpp index c3fed3a8..582c3f33 100644 --- a/lib/src/jpegdecoderhelper.cpp +++ b/lib/src/jpegdecoderhelper.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -144,22 +145,31 @@ 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) { + if (expected_guid.size() != 32) { + return; + } 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; + struct ExtXmpChunk { + uint32_t offset; + const JOCTET* data; + size_t size; + }; + uint32_t total_length = 0; - std::vector reassembled; - std::vector received_bytes; + size_t covered_size = 0; + std::vector chunks; + const size_t max_possible_len = + cinfo->src != nullptr ? static_cast(cinfo->src)->mBufferLength + : 0; 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) { + if (memcmp(guid_ptr, expected_guid.data(), 32) != 0) { continue; } @@ -172,41 +182,52 @@ static void jpeg_extract_extended_xmp(const j_decompress_ptr cinfo, 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 (seg_total_len == 0 || seg_total_len > max_possible_len || chunk_data_len == 0 || + seg_offset > seg_total_len || chunk_data_len > seg_total_len - seg_offset || + chunk_data_len > max_possible_len - covered_size) { + return; } 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; + return; } - 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 (chunk_data_len > total_length - covered_size) { + return; } + covered_size += chunk_data_len; + chunks.push_back({seg_offset, marker->data + kHeaderLen, chunk_data_len}); } } - if (total_length > 0) { - bool complete = true; - for (bool b : received_bytes) { - if (!b) { - complete = false; - break; - } - } - if (complete) { - destination = std::move(reassembled); + if (total_length == 0 || covered_size != total_length || chunks.empty()) { + return; + } + + std::sort(chunks.begin(), chunks.end(), + [](const ExtXmpChunk& a, const ExtXmpChunk& b) { return a.offset < b.offset; }); + + size_t next_offset = 0; + for (const ExtXmpChunk& chunk : chunks) { + if (chunk.offset != next_offset) { + return; } + next_offset += chunk.size; + } + if (next_offset != total_length) { + return; + } + + std::vector reassembled(total_length); + for (const ExtXmpChunk& chunk : chunks) { + memcpy(reassembled.data() + chunk.offset, chunk.data, chunk.size); } + if (computeMd5Guid(reassembled.data(), reassembled.size()) != expected_guid) { + return; + } + destination = std::move(reassembled); } // Locate the selected payload in the original JPEG stream. The saved-marker list is incomplete @@ -362,22 +383,27 @@ uhdr_error_info_t JpegDecoderHelper::decode(const void* image, size_t length, de sizeof kIsoMetadataNameSpace / sizeof kIsoMetadataNameSpace[0], mIsoMetadataBuffer, payloadOffset); + std::string std_xmp; 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); - } - } + constexpr size_t kXmpNsLen = sizeof(kXmpNameSpace) / sizeof(kXmpNameSpace[0]); + if (mXMPBuffer.size() > kXmpNsLen) { + std_xmp.assign(reinterpret_cast(mXMPBuffer.data()) + kXmpNsLen, + mXMPBuffer.size() - kXmpNsLen); + getExtendedXmpGuidFromXmp(std_xmp, &expected_guid); } std::vector extXmpBuffer; - jpeg_extract_extended_xmp(&cinfo, expected_guid, extXmpBuffer); + if (!expected_guid.empty()) { + jpeg_extract_extended_xmp(&cinfo, expected_guid, extXmpBuffer); + } if (!extXmpBuffer.empty()) { - mXMPBuffer = std::move(extXmpBuffer); + std::string ext_xmp(reinterpret_cast(extXmpBuffer.data()), extXmpBuffer.size()); + std::string merged_xmp; + if (mergeStandardAndExtendedXmp(std_xmp, ext_xmp, &merged_xmp)) { + mXMPBuffer.assign(reinterpret_cast(merged_xmp.data()), + reinterpret_cast(merged_xmp.data()) + merged_xmp.size()); + } else { + mXMPBuffer = std::move(extXmpBuffer); + } } if (cinfo.image_width < 1 || cinfo.image_height < 1) { diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 0687038a..b84c0e15 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -1784,6 +1784,168 @@ bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) return true; } +bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::string& extended_xmp, + std::string* merged_xmp) { + if (merged_xmp == nullptr) return false; + size_t ext_trimmed = extended_xmp.size(); + while (ext_trimmed > 0 && extended_xmp[ext_trimmed - 1] == '\0') { + --ext_trimmed; + } + const string clean_ext = extended_xmp.substr(0, ext_trimmed); + + string std_without_guid; + if (!replaceExtendedXmpGuidInXmp(standard_xmp, "", &std_without_guid)) { + return false; + } + string std_user_only; + if (!stripGainMapFromXmp(std_without_guid, &std_user_only)) { + return false; + } + if (std_user_only.empty()) { + *merged_xmp = clean_ext; + return true; + } + + const string synthetic_open = ""; + const string synthetic_close = ""; + const size_t offset = synthetic_open.size(); + const string std_parse_xml = synthetic_open + std_user_only + synthetic_close; + + XmpMergeXmlHandler std_handler(std_parse_xml); + size_t std_rdf_root = kNoXmpElement; + vector std_all_descriptions; + vector std_primary_descriptions; + if (!ParseRdfDescriptions(std_parse_xml, &std_handler, &std_rdf_root, &std_all_descriptions, + &std_primary_descriptions)) { + return false; + } + + const vector& std_elements = std_handler.elements(); + vector retained_descriptions; + for (size_t desc_idx : std_all_descriptions) { + const XmpElementSpan& desc = std_elements[desc_idx]; + const bool is_primary = + std::find(std_primary_descriptions.begin(), std_primary_descriptions.end(), desc_idx) != + std_primary_descriptions.end(); + if (!is_primary) { + retained_descriptions.push_back(desc_idx); + continue; + } + bool has_user_attribute = false; + for (const XmpAttributeSpan& attribute : desc.attributes) { + if (attribute.namespace_declaration) continue; + if (attribute.uri == kRdfUri && attribute.local_name == "about" && attribute.value.empty()) { + continue; + } + has_user_attribute = true; + break; + } + bool has_user_child = false; + for (const XmpElementSpan& child : std_elements) { + if (child.parent == desc_idx) { + has_user_child = true; + break; + } + } + if (has_user_attribute || has_user_child) { + retained_descriptions.push_back(desc_idx); + } + } + + if (retained_descriptions.empty()) { + *merged_xmp = clean_ext; + return true; + } + if (clean_ext.empty()) { + *merged_xmp = std_user_only; + return true; + } + + const string ext_parse_xml = synthetic_open + clean_ext + synthetic_close; + XmpMergeXmlHandler ext_handler(ext_parse_xml); + size_t ext_rdf_root = kNoXmpElement; + vector ext_all_descriptions; + vector ext_primary_descriptions; + if (!ParseRdfDescriptions(ext_parse_xml, &ext_handler, &ext_rdf_root, &ext_all_descriptions, + &ext_primary_descriptions)) { + return false; + } + const vector& ext_elements = ext_handler.elements(); + if (ext_elements[ext_rdf_root].self_closing || + ext_elements[ext_rdf_root].end_begin <= ext_elements[ext_rdf_root].start_begin) { + return false; + } + size_t ext_rdf_open_end = 0; + if (!FindXmlMarkupEnd(ext_parse_xml, ext_elements[ext_rdf_root].start_begin, &ext_rdf_open_end) || + ext_rdf_open_end < offset || ext_rdf_open_end - offset > clean_ext.size()) { + return false; + } + + // Collect ancestor namespace declarations in standard XMP that may be inherited by descriptions. + vector std_ancestor_ns; + for (size_t ancestor = std_rdf_root; ancestor != kNoXmpElement; + ancestor = std_elements[ancestor].parent) { + for (const XmpNamespaceDeclaration& decl : std_elements[ancestor].namespace_declarations) { + bool exists = false; + for (const XmpNamespaceDeclaration& existing : std_ancestor_ns) { + if (existing.prefix == decl.prefix) { + exists = true; + break; + } + } + if (!exists) std_ancestor_ns.push_back(decl); + } + } + + string descriptions_to_insert; + for (size_t desc_idx : retained_descriptions) { + const XmpElementSpan& desc = std_elements[desc_idx]; + size_t desc_open_end = 0; + if (!FindXmlMarkupEnd(std_parse_xml, desc.start_begin, &desc_open_end) || + desc.end_end < desc.start_begin) { + return false; + } + string extra_xmlns; + for (const XmpNamespaceDeclaration& decl : std_ancestor_ns) { + if (decl.prefix.empty() || decl.prefix == "rdf" || decl.prefix == "x") continue; + bool declared_locally = false; + for (const XmpNamespaceDeclaration& local_decl : desc.namespace_declarations) { + if (local_decl.prefix == decl.prefix) { + declared_locally = true; + break; + } + } + if (!declared_locally) { + extra_xmlns += " xmlns:" + decl.prefix + "=\"" + decl.uri + "\""; + } + } + if (extra_xmlns.empty()) { + descriptions_to_insert.append(std_parse_xml, desc.start_begin, + desc.end_end - desc.start_begin); + } else { + size_t insert_at = desc_open_end - 1; + while (insert_at > desc.start_begin && IsXmlWhitespace(std_parse_xml[insert_at - 1])) { + --insert_at; + } + if (insert_at > desc.start_begin && std_parse_xml[insert_at - 1] == '/') { + --insert_at; + } + descriptions_to_insert.append(std_parse_xml, desc.start_begin, insert_at - desc.start_begin); + descriptions_to_insert.append(extra_xmlns); + descriptions_to_insert.append(std_parse_xml, insert_at, desc.end_end - insert_at); + } + } + + const size_t insert_pos = ext_rdf_open_end - offset; + string result; + result.reserve(clean_ext.size() + descriptions_to_insert.size()); + result.append(clean_ext, 0, insert_pos); + result.append(descriptions_to_insert); + result.append(clean_ext, insert_pos, clean_ext.size() - insert_pos); + *merged_xmp = std::move(result); + return true; +} + string generateXmpForPrimaryImage(size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, uhdr_mem_block_t* user_xmp, diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 9d63a6dc..60b7c178 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -1543,6 +1543,13 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { uhdr_mem_block_t xmp{}; if (handle->m_xmp.size() > 0) { + if (handle->m_output_format != UHDR_CODEC_JPG) { + status.error_code = UHDR_CODEC_UNSUPPORTED_FEATURE; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "xmp metadata insertion is currently supported only for UHDR_CODEC_JPG output"); + return status; + } xmp.data = handle->m_xmp.data(); xmp.capacity = xmp.data_sz = handle->m_xmp.size(); } @@ -1551,7 +1558,14 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { const bool mayWriteXmp = !handle->m_xmp.empty() || !handle->m_compressed_images.empty(); size_t xmpAllowance = 0; if (mayWriteXmp) { - const size_t xmp_sz = handle->m_xmp.size(); + size_t xmp_sz = handle->m_xmp.size(); + if (xmp_sz == 0) { + for (const auto& entry : handle->m_compressed_images) { + if (entry.second != nullptr) { + xmp_sz = (std::max)(xmp_sz, entry.second->data_sz); + } + } + } const size_t num_chunks = (xmp_sz + ultrahdr::kExtendedXmpMaxChunkSize - 1) / ultrahdr::kExtendedXmpMaxChunkSize; xmpAllowance = ultrahdr::kJpegAppSegmentTotalMaxBytes + xmp_sz + num_chunks * 79; diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index cfa3183b..66e8d671 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -1809,6 +1809,296 @@ TEST_F(UltraHdrApiTest, ExtendedXmpElementLinkIsUpdatedAndRemovedByNamespace) { EXPECT_NE(removed.find("dc:title=\"Keep this title\""), std::string::npos); } +TEST_F(UltraHdrApiTest, SplitStandardAndExtendedXmpPreservesBothOnProbeAndApi4Reencode) { + const std::string std_title = "Standard title payload " + std::string(40000, 'S'); + const std::string ext_desc = "Extended description payload " + std::string(40000, 'E'); + const std::string ext_xmp = + "" + "" + + ext_desc + + "" + ""; + const std::string ext_guid = + computeMd5Guid(reinterpret_cast(ext_xmp.data()), ext_xmp.size()); + // Declare xmlns:dc on the ancestor element rather than to verify + // that mergeStandardAndExtendedXmp propagates inherited ancestor namespaces onto merged blocks. + const std::string std_xmp = + ""; + + // Encode a normal Ultra HDR JPEG with std_xmp, then insert the linked Extended XMP segment. + uhdr_mem_block_t std_xmp_block{const_cast(std_xmp.data()), std_xmp.size(), + std_xmp.size()}; + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &std_xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* encoded = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(encoded, nullptr); + + std::vector jpeg(static_cast(encoded->data), + static_cast(encoded->data) + encoded->data_sz); + const std::vector std_segments = + findApp1Segments(jpeg, "http://ns.adobe.com/xap/1.0/"); + ASSERT_FALSE(std_segments.empty()); + + auto makeApp1 = [](std::string_view ns, const std::string& body) { + std::vector seg; + const size_t len = 2 + ns.size() + 1 + body.size(); + seg.push_back(0xFF); + seg.push_back(0xE1); + seg.push_back(static_cast((len >> 8) & 0xFF)); + seg.push_back(static_cast(len & 0xFF)); + seg.insert(seg.end(), ns.begin(), ns.end()); + seg.push_back(0); + seg.insert(seg.end(), body.begin(), body.end()); + return seg; + }; + + std::string ext_body = ext_guid; + const uint32_t ext_len = static_cast(ext_xmp.size()); + ext_body.push_back(static_cast((ext_len >> 24) & 0xFF)); + ext_body.push_back(static_cast((ext_len >> 16) & 0xFF)); + ext_body.push_back(static_cast((ext_len >> 8) & 0xFF)); + ext_body.push_back(static_cast(ext_len & 0xFF)); + ext_body.append(4, '\0'); // offset = 0 + ext_body.append(ext_xmp); + + const std::vector new_std_seg = makeApp1("http://ns.adobe.com/xap/1.0/", std_xmp); + const std::vector new_ext_seg = makeApp1("http://ns.adobe.com/xmp/extension/", ext_body); + std::vector replacement_segs = new_std_seg; + replacement_segs.insert(replacement_segs.end(), new_ext_seg.begin(), new_ext_seg.end()); + + jpeg.erase(jpeg.begin() + std_segments[0].begin, jpeg.begin() + std_segments[0].end); + jpeg.insert(jpeg.begin() + std_segments[0].begin, replacement_segs.begin(), + replacement_segs.end()); + + uhdr_compressed_image_t split_uhdr{jpeg.data(), jpeg.size(), jpeg.size(), UHDR_CG_UNSPECIFIED, + UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED}; + DecoderPtr dec = makeDecoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec.get(), &split_uhdr).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec.get()).error_code, UHDR_CODEC_OK); + + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec.get()); + ASSERT_NE(decoded_xmp, nullptr); + const std::string decoded_packet = getXmpPacket(decoded_xmp); + EXPECT_NE(decoded_packet.find(std_title), std::string::npos); + EXPECT_NE(decoded_packet.find(ext_desc), std::string::npos); + + // Re-encode the extracted compressed base through API-4 without explicit XMP setter. + // Because ~40 KB (Standard) + ~40 KB (Extended) > 65,458 bytes (kExtendedXmpMaxChunkSize), + // re-encoding must split the combined payload across 2 Extended XMP APP1 blocks. + uhdr_mem_block_t* base_block = uhdr_dec_get_base_image(dec.get()); + uhdr_mem_block_t* gainmap_block = uhdr_dec_get_gainmap_image(dec.get()); + uhdr_gainmap_metadata_t* gm_meta = uhdr_dec_get_gainmap_metadata(dec.get()); + ASSERT_NE(base_block, nullptr); + ASSERT_NE(gainmap_block, nullptr); + ASSERT_NE(gm_meta, nullptr); + + uhdr_compressed_image_t base_img{base_block->data, base_block->data_sz, base_block->capacity, + UHDR_CG_BT_709, UHDR_CT_SRGB, UHDR_CR_FULL_RANGE}; + uhdr_compressed_image_t gm_img{gainmap_block->data, gainmap_block->data_sz, + gainmap_block->capacity, UHDR_CG_UNSPECIFIED, + UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED}; + JpegXmpRoundTrip reencoded; + ASSERT_TRUE(encodeAndProbeJpegXmp(&base_img, &gm_img, gm_meta, nullptr, reencoded)); + uhdr_compressed_image_t* reencoded_stream = uhdr_get_encoded_stream(reencoded.encoder.get()); + ASSERT_NE(reencoded_stream, nullptr); + const std::vector reencoded_bytes( + static_cast(reencoded_stream->data), + static_cast(reencoded_stream->data) + reencoded_stream->data_sz); + EXPECT_EQ(findApp1Segments(reencoded_bytes, "http://ns.adobe.com/xmp/extension/").size(), 2u); + uhdr_mem_block_t* reencoded_xmp = uhdr_dec_get_xmp(reencoded.decoder.get()); + ASSERT_NE(reencoded_xmp, nullptr); + const std::string reencoded_packet = getXmpPacket(reencoded_xmp); + EXPECT_NE(reencoded_packet.find(std_title), std::string::npos); + EXPECT_NE(reencoded_packet.find(ext_desc), std::string::npos); +} + +TEST_F(UltraHdrApiTest, ElementFormExtendedXmpLinkSelectsCorrectExtensionOverUnrelatedFirst) { + const std::string unrelated_ext = + "" + "Unrelated extension packet" + ""; + const std::string linked_ext = + "" + "Correctly linked extension packet" + ""; + const std::string unrelated_guid = + computeMd5Guid(reinterpret_cast(unrelated_ext.data()), unrelated_ext.size()); + const std::string linked_guid = + computeMd5Guid(reinterpret_cast(linked_ext.data()), linked_ext.size()); + const std::string std_xmp = + "" + "" + + linked_guid + ""; + + std::vector jpeg(static_cast(mSdrCompressed.data), + static_cast(mSdrCompressed.data) + + mSdrCompressed.data_sz); + auto makeApp1 = [](std::string_view ns, const std::string& body) { + std::vector seg; + const size_t len = 2 + ns.size() + 1 + body.size(); + seg.push_back(0xFF); + seg.push_back(0xE1); + seg.push_back(static_cast((len >> 8) & 0xFF)); + seg.push_back(static_cast(len & 0xFF)); + seg.insert(seg.end(), ns.begin(), ns.end()); + seg.push_back(0); + seg.insert(seg.end(), body.begin(), body.end()); + return seg; + }; + auto makeExtBody = [](const std::string& guid, const std::string& payload) { + std::string body = guid; + const uint32_t len = static_cast(payload.size()); + body.push_back(static_cast((len >> 24) & 0xFF)); + body.push_back(static_cast((len >> 16) & 0xFF)); + body.push_back(static_cast((len >> 8) & 0xFF)); + body.push_back(static_cast(len & 0xFF)); + body.append(4, '\0'); + body.append(payload); + return body; + }; + + const std::vector std_seg = makeApp1("http://ns.adobe.com/xap/1.0/", std_xmp); + const std::vector unrelated_seg = + makeApp1("http://ns.adobe.com/xmp/extension/", makeExtBody(unrelated_guid, unrelated_ext)); + const std::vector linked_seg = + makeApp1("http://ns.adobe.com/xmp/extension/", makeExtBody(linked_guid, linked_ext)); + std::vector all_segs = std_seg; + all_segs.insert(all_segs.end(), unrelated_seg.begin(), unrelated_seg.end()); + all_segs.insert(all_segs.end(), linked_seg.begin(), linked_seg.end()); + jpeg.insert(jpeg.begin() + 2, all_segs.begin(), all_segs.end()); + + JpegDecoderHelper decoder; + ASSERT_EQ(decoder.parseImage(jpeg.data(), jpeg.size()).error_code, UHDR_CODEC_OK); + ASSERT_NE(decoder.getXMPPtr(), nullptr); + uhdr_mem_block_t decoded_block{decoder.getXMPPtr(), decoder.getXMPSize(), decoder.getXMPSize()}; + const std::string packet = getXmpPacket(&decoded_block); + EXPECT_NE(packet.find("Correctly linked extension packet"), std::string::npos) << packet; + EXPECT_EQ(packet.find("Unrelated extension packet"), std::string::npos) << packet; +} + +TEST_F(UltraHdrApiTest, TinyInputWithHugeDeclaredExtendedXmpLengthDoesNotOverAllocate) { + const std::string guid = "0123456789ABCDEF0123456789ABCDEF"; + const std::string std_xmp = generateStandardXmpWithExtendedGuid(guid); + uhdr_mem_block_t std_xmp_block{const_cast(std_xmp.data()), std_xmp.size(), + std_xmp.size()}; + + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &std_xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* encoded = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(encoded, nullptr); + + std::vector jpeg(static_cast(encoded->data), + static_cast(encoded->data) + encoded->data_sz); + auto makeApp1 = [](std::string_view ns, const std::string& body) { + std::vector seg; + const size_t len = 2 + ns.size() + 1 + body.size(); + seg.push_back(0xFF); + seg.push_back(0xE1); + seg.push_back(static_cast((len >> 8) & 0xFF)); + seg.push_back(static_cast(len & 0xFF)); + seg.insert(seg.end(), ns.begin(), ns.end()); + seg.push_back(0); + seg.insert(seg.end(), body.begin(), body.end()); + return seg; + }; + + // Craft an Extended XMP segment declaring 1 GiB (0x40000000) total_length with a 16-byte chunk. + std::string huge_ext_body = guid; + huge_ext_body.append("\x40\x00\x00\x00", 4); // 1 GiB declared total_length + huge_ext_body.append("\x00\x00\x00\x00", 4); // offset = 0 + huge_ext_body.append(16, 'A'); + + const std::vector std_seg = makeApp1("http://ns.adobe.com/xap/1.0/", std_xmp); + const std::vector ext_seg = + makeApp1("http://ns.adobe.com/xmp/extension/", huge_ext_body); + const std::vector std_segments = + findApp1Segments(jpeg, "http://ns.adobe.com/xap/1.0/"); + ASSERT_FALSE(std_segments.empty()); + std::vector replacement = std_seg; + replacement.insert(replacement.end(), ext_seg.begin(), ext_seg.end()); + jpeg.erase(jpeg.begin() + std_segments[0].begin, jpeg.begin() + std_segments[0].end); + jpeg.insert(jpeg.begin() + std_segments[0].begin, replacement.begin(), replacement.end()); + + uhdr_compressed_image_t crafted{jpeg.data(), jpeg.size(), jpeg.size(), UHDR_CG_UNSPECIFIED, + UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED}; + DecoderPtr dec = makeDecoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec.get(), &crafted).error_code, UHDR_CODEC_OK); + // Must not throw std::bad_alloc or accept the truncated/oversized Extended XMP chunk. + EXPECT_EQ(uhdr_dec_probe(dec.get()).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* probed_xmp = uhdr_dec_get_xmp(dec.get()); + ASSERT_NE(probed_xmp, nullptr); + EXPECT_EQ(getXmpPacket(probed_xmp), std_xmp); + + // Also verify that a valid-sized Extended XMP with a corrupted MD5 digest is rejected on probe. + const std::string valid_ext_payload = + "" + "Tampered payload" + ""; + std::string bad_md5_body = guid; // Does not match MD5(valid_ext_payload) + const uint32_t ext_len = static_cast(valid_ext_payload.size()); + bad_md5_body.push_back(static_cast((ext_len >> 24) & 0xFF)); + bad_md5_body.push_back(static_cast((ext_len >> 16) & 0xFF)); + bad_md5_body.push_back(static_cast((ext_len >> 8) & 0xFF)); + bad_md5_body.push_back(static_cast(ext_len & 0xFF)); + bad_md5_body.append(4, '\0'); + bad_md5_body.append(valid_ext_payload); + + std::vector bad_md5_jpeg(static_cast(encoded->data), + static_cast(encoded->data) + encoded->data_sz); + const std::vector bad_md5_seg = + makeApp1("http://ns.adobe.com/xmp/extension/", bad_md5_body); + std::vector bad_md5_replacement = std_seg; + bad_md5_replacement.insert(bad_md5_replacement.end(), bad_md5_seg.begin(), bad_md5_seg.end()); + bad_md5_jpeg.erase(bad_md5_jpeg.begin() + std_segments[0].begin, + bad_md5_jpeg.begin() + std_segments[0].end); + bad_md5_jpeg.insert(bad_md5_jpeg.begin() + std_segments[0].begin, bad_md5_replacement.begin(), + bad_md5_replacement.end()); + + uhdr_compressed_image_t bad_md5_img{bad_md5_jpeg.data(), bad_md5_jpeg.size(), + bad_md5_jpeg.size(), UHDR_CG_UNSPECIFIED, + UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED}; + DecoderPtr dec2 = makeDecoder(); + ASSERT_NE(dec2, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec2.get(), &bad_md5_img).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_probe(dec2.get()).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* probed_xmp2 = uhdr_dec_get_xmp(dec2.get()); + ASSERT_NE(probed_xmp2, nullptr); + EXPECT_EQ(getXmpPacket(probed_xmp2), std_xmp); +} + TEST_F(UltraHdrApiTest, StripGainMapKeepsNamespaceUsedByRetainedDescendant) { const std::string shared_xmp = "" + "" + "" + "" + ""; + uhdr_mem_block_t xmp_block{const_cast(kXmpPacket.data()), kXmpPacket.size(), + kXmpPacket.size()}; + + for (uhdr_codec_t codec : {UHDR_CODEC_HEIF, UHDR_CODEC_AVIF}) { + SCOPED_TRACE(codec == UHDR_CODEC_AVIF ? "AVIF" : "HEIF"); + 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, codec).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + + uhdr_error_info_t err = uhdr_encode(enc); + EXPECT_EQ(err.error_code, UHDR_CODEC_UNSUPPORTED_FEATURE); + + uhdr_release_encoder(enc); + } +} + TEST_F(UltraHdrApiTest, AvifPreservesRawAlpha) { AlphaTestImages images; std::vector encoded; diff --git a/ultrahdr_api.h b/ultrahdr_api.h index fcfe257b..afd053a7 100644 --- a/ultrahdr_api.h +++ b/ultrahdr_api.h @@ -413,6 +413,11 @@ UHDR_EXTERN uhdr_error_info_t uhdr_enc_set_exif_data(uhdr_codec_private_t* enc, * 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). * + * NOTE: XMP metadata insertion is currently supported only when the output compression format is + * #UHDR_CODEC_JPG. Attempting to encode with XMP data configured when the output format is + * #UHDR_CODEC_HEIF or #UHDR_CODEC_AVIF will cause uhdr_encode() to return + * #UHDR_CODEC_UNSUPPORTED_FEATURE. + * * \param[in] enc encoder instance * \param[in] xmp xmp data memory block. * From 19808d4631f6cb2954d6a367863dcc107d138ab5 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Wed, 14 Oct 2026 04:27:23 +0000 Subject: [PATCH 12/20] fix(jpeg): scope temporary XMP strings before libjpeg decompress setjmp/longjmp --- lib/src/jpegdecoderhelper.cpp | 45 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/lib/src/jpegdecoderhelper.cpp b/lib/src/jpegdecoderhelper.cpp index 582c3f33..7b8a8b1f 100644 --- a/lib/src/jpegdecoderhelper.cpp +++ b/lib/src/jpegdecoderhelper.cpp @@ -383,26 +383,31 @@ uhdr_error_info_t JpegDecoderHelper::decode(const void* image, size_t length, de sizeof kIsoMetadataNameSpace / sizeof kIsoMetadataNameSpace[0], mIsoMetadataBuffer, payloadOffset); - std::string std_xmp; - std::string expected_guid; - constexpr size_t kXmpNsLen = sizeof(kXmpNameSpace) / sizeof(kXmpNameSpace[0]); - if (mXMPBuffer.size() > kXmpNsLen) { - std_xmp.assign(reinterpret_cast(mXMPBuffer.data()) + kXmpNsLen, - mXMPBuffer.size() - kXmpNsLen); - getExtendedXmpGuidFromXmp(std_xmp, &expected_guid); - } - std::vector extXmpBuffer; - if (!expected_guid.empty()) { - jpeg_extract_extended_xmp(&cinfo, expected_guid, extXmpBuffer); - } - if (!extXmpBuffer.empty()) { - std::string ext_xmp(reinterpret_cast(extXmpBuffer.data()), extXmpBuffer.size()); - std::string merged_xmp; - if (mergeStandardAndExtendedXmp(std_xmp, ext_xmp, &merged_xmp)) { - mXMPBuffer.assign(reinterpret_cast(merged_xmp.data()), - reinterpret_cast(merged_xmp.data()) + merged_xmp.size()); - } else { - mXMPBuffer = std::move(extXmpBuffer); + // Scope all temporary heap-owning XMP objects (std::string, std::vector) so their destructors + // run before jpeg_start_decompress() / scanline reads, which may longjmp via jpegrerror_exit. + { + constexpr size_t kXmpNsLen = sizeof(kXmpNameSpace) / sizeof(kXmpNameSpace[0]); + if (mXMPBuffer.size() > kXmpNsLen) { + std::string std_xmp(reinterpret_cast(mXMPBuffer.data()) + kXmpNsLen, + mXMPBuffer.size() - kXmpNsLen); + std::string expected_guid; + getExtendedXmpGuidFromXmp(std_xmp, &expected_guid); + if (!expected_guid.empty()) { + std::vector extXmpBuffer; + jpeg_extract_extended_xmp(&cinfo, expected_guid, extXmpBuffer); + if (!extXmpBuffer.empty()) { + std::string ext_xmp(reinterpret_cast(extXmpBuffer.data()), + extXmpBuffer.size()); + std::string merged_xmp; + if (mergeStandardAndExtendedXmp(std_xmp, ext_xmp, &merged_xmp)) { + mXMPBuffer.assign( + reinterpret_cast(merged_xmp.data()), + reinterpret_cast(merged_xmp.data()) + merged_xmp.size()); + } else { + mXMPBuffer = std::move(extXmpBuffer); + } + } + } } } From 4dc964826c956375aba20685d59e774399b10405 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 16 Oct 2026 18:24:43 +0000 Subject: [PATCH 13/20] fix(xmp): preserve inherited rdf prefix, default xmlns, and xml:lang in mergeStandardAndExtendedXmp --- lib/src/jpegrutils.cpp | 160 ++++++++++++++++++++++++++++++++---- tests/ultrahdr_api_test.cpp | 93 +++++++++++++++++++++ 2 files changed, 235 insertions(+), 18 deletions(-) diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index b84c0e15..0641b3f7 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -1881,21 +1881,65 @@ bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::str return false; } - // Collect ancestor namespace declarations in standard XMP that may be inherited by descriptions. - vector std_ancestor_ns; - for (size_t ancestor = std_rdf_root; ancestor != kNoXmpElement; - ancestor = std_elements[ancestor].parent) { - for (const XmpNamespaceDeclaration& decl : std_elements[ancestor].namespace_declarations) { - bool exists = false; - for (const XmpNamespaceDeclaration& existing : std_ancestor_ns) { - if (existing.prefix == decl.prefix) { - exists = true; - break; + // Collect ancestor namespace declarations and xml:* scoped attributes (e.g. xml:lang) in both + // packets so moved elements retain their original XML context. + constexpr char kXmlNamespaceUri[] = "http://www.w3.org/XML/1998/namespace"; + auto collect_ancestor_namespaces = [](const vector& elements, size_t start_elem, + size_t stop_root) { + vector result; + for (size_t ancestor = start_elem; ancestor != kNoXmpElement && ancestor != stop_root; + ancestor = elements[ancestor].parent) { + for (const XmpNamespaceDeclaration& decl : elements[ancestor].namespace_declarations) { + bool exists = false; + for (const XmpNamespaceDeclaration& existing : result) { + if (existing.prefix == decl.prefix) { + exists = true; + break; + } } + if (!exists) result.push_back(decl); } - if (!exists) std_ancestor_ns.push_back(decl); } - } + return result; + }; + + struct XmlScopedAttribute { + string local_name; + string value; + }; + auto collect_ancestor_xml_attributes = [&](const vector& elements, + size_t start_elem, size_t stop_root) { + vector result; + for (size_t ancestor = start_elem; ancestor != kNoXmpElement && ancestor != stop_root; + ancestor = elements[ancestor].parent) { + for (const XmpAttributeSpan& attr : elements[ancestor].attributes) { + if (attr.namespace_declaration || attr.uri != kXmlNamespaceUri) continue; + bool exists = false; + for (const XmlScopedAttribute& existing : result) { + if (existing.local_name == attr.local_name) { + exists = true; + break; + } + } + if (!exists) result.push_back({attr.local_name, attr.value}); + } + } + return result; + }; + + auto format_xml_attr = [](const string& name, const string& value) { + const char quote = (value.find('"') == string::npos) ? '"' : '\''; + return string(" ") + name + "=" + quote + value + quote; + }; + + const vector std_ancestor_ns = + collect_ancestor_namespaces(std_elements, std_rdf_root, std_handler.root_index()); + const vector ext_ancestor_ns = + collect_ancestor_namespaces(ext_elements, ext_rdf_root, ext_handler.root_index()); + const vector std_ancestor_xml_attrs = + collect_ancestor_xml_attributes(std_elements, std_rdf_root, std_handler.root_index()); + const vector ext_ancestor_xml_attrs = + collect_ancestor_xml_attributes(ext_elements, ext_rdf_root, ext_handler.root_index()); string descriptions_to_insert; for (size_t desc_idx : retained_descriptions) { @@ -1905,9 +1949,8 @@ bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::str desc.end_end < desc.start_begin) { return false; } - string extra_xmlns; + string extra_attrs; for (const XmpNamespaceDeclaration& decl : std_ancestor_ns) { - if (decl.prefix.empty() || decl.prefix == "rdf" || decl.prefix == "x") continue; bool declared_locally = false; for (const XmpNamespaceDeclaration& local_decl : desc.namespace_declarations) { if (local_decl.prefix == decl.prefix) { @@ -1915,11 +1958,92 @@ bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::str break; } } - if (!declared_locally) { - extra_xmlns += " xmlns:" + decl.prefix + "=\"" + decl.uri + "\""; + if (declared_locally) continue; + + bool same_in_ext = false; + for (const XmpNamespaceDeclaration& ext_decl : ext_ancestor_ns) { + if (ext_decl.prefix == decl.prefix) { + same_in_ext = (ext_decl.uri == decl.uri); + break; + } + } + if (!same_in_ext) { + const string attr_name = decl.prefix.empty() ? "xmlns" : ("xmlns:" + decl.prefix); + extra_attrs += format_xml_attr(attr_name, decl.uri); + } + } + + bool has_default_ns_in_std = false; + for (const XmpNamespaceDeclaration& local_decl : desc.namespace_declarations) { + if (local_decl.prefix.empty()) { + has_default_ns_in_std = true; + break; + } + } + if (!has_default_ns_in_std) { + for (const XmpNamespaceDeclaration& decl : std_ancestor_ns) { + if (decl.prefix.empty()) { + has_default_ns_in_std = true; + break; + } } } - if (extra_xmlns.empty()) { + if (!has_default_ns_in_std) { + for (const XmpNamespaceDeclaration& ext_decl : ext_ancestor_ns) { + if (ext_decl.prefix.empty() && !ext_decl.uri.empty()) { + extra_attrs += format_xml_attr("xmlns", ""); + break; + } + } + } + + for (const XmlScopedAttribute& xml_attr : std_ancestor_xml_attrs) { + bool declared_locally = false; + for (const XmpAttributeSpan& local_attr : desc.attributes) { + if (!local_attr.namespace_declaration && local_attr.uri == kXmlNamespaceUri && + local_attr.local_name == xml_attr.local_name) { + declared_locally = true; + break; + } + } + if (declared_locally) continue; + + bool same_in_ext = false; + for (const XmlScopedAttribute& ext_attr : ext_ancestor_xml_attrs) { + if (ext_attr.local_name == xml_attr.local_name) { + same_in_ext = (ext_attr.value == xml_attr.value); + break; + } + } + if (!same_in_ext) { + extra_attrs += format_xml_attr("xml:" + xml_attr.local_name, xml_attr.value); + } + } + + for (const XmlScopedAttribute& ext_attr : ext_ancestor_xml_attrs) { + if (ext_attr.local_name != "lang" || ext_attr.value.empty()) continue; + bool has_lang_in_std = false; + for (const XmpAttributeSpan& local_attr : desc.attributes) { + if (!local_attr.namespace_declaration && local_attr.uri == kXmlNamespaceUri && + local_attr.local_name == "lang") { + has_lang_in_std = true; + break; + } + } + if (!has_lang_in_std) { + for (const XmlScopedAttribute& std_attr : std_ancestor_xml_attrs) { + if (std_attr.local_name == "lang") { + has_lang_in_std = true; + break; + } + } + } + if (!has_lang_in_std) { + extra_attrs += format_xml_attr("xml:lang", ""); + } + } + + if (extra_attrs.empty()) { descriptions_to_insert.append(std_parse_xml, desc.start_begin, desc.end_end - desc.start_begin); } else { @@ -1931,7 +2055,7 @@ bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::str --insert_at; } descriptions_to_insert.append(std_parse_xml, desc.start_begin, insert_at - desc.start_begin); - descriptions_to_insert.append(extra_xmlns); + descriptions_to_insert.append(extra_attrs); descriptions_to_insert.append(std_parse_xml, insert_at, desc.end_end - insert_at); } } diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 66e8d671..17b8aeb3 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -1930,6 +1930,99 @@ TEST_F(UltraHdrApiTest, SplitStandardAndExtendedXmpPreservesBothOnProbeAndApi4Re EXPECT_NE(reencoded_packet.find(ext_desc), std::string::npos); } +TEST_F(UltraHdrApiTest, MergeStandardAndExtendedXmpPreservesRdfAliasDefaultNamespaceAndXmlLang) { + // Case 1: Standard XMP uses prefix `rdf`, while Extended XMP uses prefix `r`. + { + const std::string ext_xmp = + "" + "Extended via r prefix" + ""; + const std::string ext_guid = + computeMd5Guid(reinterpret_cast(ext_xmp.data()), ext_xmp.size()); + const std::string std_xmp = + ""; + + std::string merged; + ASSERT_TRUE(mergeStandardAndExtendedXmp(std_xmp, ext_xmp, &merged)); + EXPECT_NE(merged.find("xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\""), + std::string::npos) + << merged; + // Verify the merged XML is namespace-valid and accepted by the strict XMP parser/stripper. + std::string re_stripped; + ASSERT_TRUE(stripGainMapFromXmp(merged, &re_stripped)) << merged; + EXPECT_NE(re_stripped.find("Standard via rdf prefix"), std::string::npos); + EXPECT_NE(re_stripped.find("Extended via r prefix"), std::string::npos); + } + + // Case 2: A property in Standard XMP inherits the default namespace (`xmlns="..."`). + { + const std::string ext_xmp = + "" + "Extended description" + ""; + const std::string ext_guid = + computeMd5Guid(reinterpret_cast(ext_xmp.data()), ext_xmp.size()); + const std::string std_xmp = + "Inherited default namespace title" + ""; + + std::string merged; + ASSERT_TRUE(mergeStandardAndExtendedXmp(std_xmp, ext_xmp, &merged)); + EXPECT_NE(merged.find("xmlns=\"http://purl.org/dc/elements/1.1/\""), std::string::npos) + << merged; + std::string re_stripped; + ASSERT_TRUE(stripGainMapFromXmp(merged, &re_stripped)) << merged; + EXPECT_NE(re_stripped.find("Inherited default namespace title"), + std::string::npos); + } + + // Case 3: A title in Standard XMP inherits `xml:lang="en"`, while Extended XMP has `xml:lang="fr"`. + { + const std::string ext_xmp = + "" + "" + "Description en francais" + ""; + const std::string ext_guid = + computeMd5Guid(reinterpret_cast(ext_xmp.data()), ext_xmp.size()); + const std::string std_xmp = + "" + "English title" + ""; + + std::string merged; + ASSERT_TRUE(mergeStandardAndExtendedXmp(std_xmp, ext_xmp, &merged)); + EXPECT_NE(merged.find("xml:lang=\"en\""), std::string::npos) << merged; + EXPECT_NE(merged.find("xml:lang=\"fr\""), std::string::npos) << merged; + std::string re_stripped; + ASSERT_TRUE(stripGainMapFromXmp(merged, &re_stripped)) << merged; + EXPECT_NE(re_stripped.find("English title"), std::string::npos); + EXPECT_NE(re_stripped.find("Description en francais"), std::string::npos); + } +} + TEST_F(UltraHdrApiTest, ElementFormExtendedXmpLinkSelectsCorrectExtensionOverUnrelatedFirst) { const std::string unrelated_ext = " Date: Fri, 16 Oct 2026 19:01:19 +0000 Subject: [PATCH 14/20] Consolidate XMP packet parsing and editing helpers --- lib/src/jpegrutils.cpp | 659 +++++++++++++++-------------------------- 1 file changed, 244 insertions(+), 415 deletions(-) diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 0641b3f7..9c0565ab 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -1010,11 +1010,22 @@ std::string generateStandardXmpWithExtendedGuid(const std::string& guid) { namespace { constexpr size_t kNoXmpElement = (std::numeric_limits::max)(); +constexpr char kSyntheticOpen[] = ""; +constexpr char kSyntheticClose[] = ""; +constexpr size_t kSyntheticOffset = sizeof(kSyntheticOpen) - 1; +constexpr char kXmpNoteUri[] = "http://ns.adobe.com/xmp/note/"; +constexpr char kXmlNamespaceUri[] = "http://www.w3.org/XML/1998/namespace"; bool IsXmlWhitespace(char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; } +string TrimTrailingNulls(const string& xmp) { + size_t end = xmp.size(); + while (end > 0 && xmp[end - 1] == '\0') --end; + return xmp.substr(0, end); +} + bool FindXmlMarkupEnd(const string& xml, size_t begin, size_t* end) { char quote = 0; for (size_t pos = begin; pos < xml.size(); ++pos) { @@ -1184,7 +1195,7 @@ class XmpMergeXmlHandler : public XmlHandler { } } if (prefix == "xml") { - *uri = "http://www.w3.org/XML/1998/namespace"; + *uri = kXmlNamespaceUri; return true; } if (prefix == "xmlns") { @@ -1264,10 +1275,7 @@ class XmpMergeXmlHandler : public XmlHandler { return false; } } - XmpNamespaceDeclaration declaration; - declaration.prefix = prefix; - declaration.uri = attribute.value; - namespace_declarations.push_back(std::move(declaration)); + namespace_declarations.push_back({prefix, attribute.value}); } XmpElementSpan element; @@ -1328,54 +1336,62 @@ bool IsGainMapPropertyUri(const string& uri) { return uri == kContainerUri || uri == kItemUri || uri == kGainMapUri || uri == kAppleGainMapUri; } -string GeneratePrimaryDescription(size_t secondary_image_length, - uhdr_gainmap_metadata_ext_t& metadata, - const string& extended_xmp_guid) { +void WritePrimaryDescriptionElement(photos_editing_formats::image_io::XmlWriter* writer, + bool write_rdf_xmlns, size_t secondary_image_length, + uhdr_gainmap_metadata_ext_t& metadata, + const string& extended_xmp_guid) { 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->StartWritingElement("rdf:Description"); + if (write_rdf_xmlns) writer->WriteXmlns("rdf", kRdfUri); + 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(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(); + writer->WriteXmlns("xmpNote", kXmpNoteUri); + writer->WriteAttributeNameAndValue("xmpNote:HasExtendedXMP", extended_xmp_guid); + } + 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(); } -bool ParseRdfDescriptions(const string& parse_xml, XmpMergeXmlHandler* handler, size_t* rdf_root_out, - vector* all_descriptions_out, - vector* primary_descriptions_out) { +struct ParsedXmpPacket { + string parse_xml; + unique_ptr handler; + size_t rdf_root = kNoXmpElement; + vector all_descriptions; + vector primary_descriptions; + const vector& elements() const { return handler->elements(); } + size_t root_index() const { return handler->root_index(); } +}; + +bool ParseXmpPacket(const string& clean_xmp, ParsedXmpPacket* packet) { + packet->parse_xml = string(kSyntheticOpen) + clean_xmp + kSyntheticClose; + packet->handler = std::make_unique(packet->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()) { + XmlReader reader(packet->handler.get(), &message_handler); + if (!reader.StartParse(std::move(rule)) || !reader.Parse(packet->parse_xml) || + !reader.FinishParse() || reader.HasErrors() || !packet->handler->IsComplete()) { return false; } - const vector& elements = handler->elements(); - const size_t synthetic_root = handler->root_index(); + const vector& elements = packet->elements(); + const size_t synthetic_root = packet->root_index(); size_t packet_root = kNoXmpElement; for (size_t index = 0; index < elements.size(); ++index) { if (elements[index].parent == synthetic_root) { @@ -1402,15 +1418,15 @@ bool ParseRdfDescriptions(const string& parse_xml, XmpMergeXmlHandler* handler, } if (rdf_root == kNoXmpElement) return false; - all_descriptions_out->clear(); - primary_descriptions_out->clear(); + packet->all_descriptions.clear(); + packet->primary_descriptions.clear(); 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; } - all_descriptions_out->push_back(index); + packet->all_descriptions.push_back(index); bool has_about = false; bool about_empty = true; bool has_non_primary_id = false; @@ -1424,23 +1440,31 @@ bool ParseRdfDescriptions(const string& parse_xml, XmpMergeXmlHandler* handler, } } if (!has_non_primary_id && (!has_about || about_empty)) { - primary_descriptions_out->push_back(index); + packet->primary_descriptions.push_back(index); } } - *rdf_root_out = rdf_root; + packet->rdf_root = rdf_root; return true; } -namespace { - -constexpr char kXmpNoteUri[] = "http://ns.adobe.com/xmp/note/"; - -struct ExtendedXmpReference { - size_t value_begin; - size_t value_end; - size_t remove_begin; - size_t remove_end; -}; +void ClassifyPrimaryDescription(const vector& elements, size_t desc_idx, + bool* has_removable, bool* has_user) { + *has_removable = false; + *has_user = false; + for (const XmpAttributeSpan& attribute : elements[desc_idx].attributes) { + if (attribute.namespace_declaration) continue; + if (attribute.uri == kRdfUri && attribute.local_name == "about" && attribute.value.empty()) { + continue; + } + if (IsGainMapPropertyUri(attribute.uri)) *has_removable = true; + else *has_user = true; + } + for (const XmpElementSpan& child : elements) { + if (child.parent != desc_idx) continue; + if (IsGainMapPropertyUri(child.uri)) *has_removable = true; + else *has_user = true; + } +} bool IsExtendedXmpGuid(const string& guid) { if (guid.size() != 32) return false; @@ -1459,42 +1483,37 @@ bool ProcessExtendedXmpGuid(const string& xmp_data, const string* replacement_gu return false; } - size_t trimmed_size = xmp_data.size(); - while (trimmed_size > 0 && xmp_data[trimmed_size - 1] == '\0') --trimmed_size; - const string clean_xmp = xmp_data.substr(0, trimmed_size); + const string clean_xmp = TrimTrailingNulls(xmp_data); if (clean_xmp.empty()) { if (rewritten_xmp != nullptr) rewritten_xmp->clear(); return true; } - const string synthetic_open = ""; - const size_t offset = synthetic_open.size(); - const string parse_xml = synthetic_open + clean_xmp + ""; - XmpMergeXmlHandler handler(parse_xml); - size_t rdf_root = kNoXmpElement; - vector all_descriptions; - vector primary_descriptions; - if (!ParseRdfDescriptions(parse_xml, &handler, &rdf_root, &all_descriptions, - &primary_descriptions)) { - return false; - } + ParsedXmpPacket pkt; + if (!ParseXmpPacket(clean_xmp, &pkt)) return false; - const vector& elements = handler.elements(); - vector references; + const vector& elements = pkt.elements(); + vector> edits; string found_guid; - for (size_t description : primary_descriptions) { + for (size_t description : pkt.primary_descriptions) { const XmpElementSpan& primary = elements[description]; for (const XmpAttributeSpan& attribute : primary.attributes) { if (attribute.uri != kXmpNoteUri || attribute.local_name != "HasExtendedXMP") continue; - if (!IsExtendedXmpGuid(attribute.value)) return false; - if (!found_guid.empty() && found_guid != attribute.value) return false; + if (!IsExtendedXmpGuid(attribute.value) || + (!found_guid.empty() && found_guid != attribute.value)) { + return false; + } found_guid = attribute.value; size_t remove_begin = attribute.name_begin; - while (remove_begin > primary.start_begin && IsXmlWhitespace(parse_xml[remove_begin - 1])) { + while (remove_begin > primary.start_begin && + IsXmlWhitespace(pkt.parse_xml[remove_begin - 1])) { --remove_begin; } - references.push_back({attribute.value_begin, attribute.value_end - 1, remove_begin, - attribute.value_end}); + if (replacement_guid != nullptr) { + edits.emplace_back( + replacement_guid->empty() ? remove_begin : attribute.value_begin, + replacement_guid->empty() ? attribute.value_end : attribute.value_end - 1); + } } for (size_t index = 0; index < elements.size(); ++index) { @@ -1508,37 +1527,40 @@ bool ProcessExtendedXmpGuid(const string& xmp_data, const string* replacement_gu if (child.parent == index) return false; } size_t value_begin = 0; - if (!FindXmlMarkupEnd(parse_xml, element.start_begin, &value_begin) || + if (!FindXmlMarkupEnd(pkt.parse_xml, element.start_begin, &value_begin) || element.end_begin < value_begin) { return false; } size_t value_end = element.end_begin; - while (value_begin < value_end && IsXmlWhitespace(parse_xml[value_begin])) ++value_begin; - while (value_end > value_begin && IsXmlWhitespace(parse_xml[value_end - 1])) --value_end; - const string guid = parse_xml.substr(value_begin, value_end - value_begin); + while (value_begin < value_end && IsXmlWhitespace(pkt.parse_xml[value_begin])) ++value_begin; + while (value_end > value_begin && IsXmlWhitespace(pkt.parse_xml[value_end - 1])) --value_end; + const string guid = pkt.parse_xml.substr(value_begin, value_end - value_begin); if (!IsExtendedXmpGuid(guid) || (!found_guid.empty() && found_guid != guid)) return false; found_guid = guid; size_t remove_begin = element.start_begin; - while (remove_begin > primary.start_begin && IsXmlWhitespace(parse_xml[remove_begin - 1])) { + while (remove_begin > primary.start_begin && + IsXmlWhitespace(pkt.parse_xml[remove_begin - 1])) { --remove_begin; } - references.push_back({value_begin, value_end, remove_begin, element.end_end}); + if (replacement_guid != nullptr) { + edits.emplace_back(replacement_guid->empty() ? remove_begin : value_begin, + replacement_guid->empty() ? element.end_end : value_end); + } } } if (guid_out != nullptr) *guid_out = found_guid; - if (rewritten_xmp == nullptr || references.empty() || *replacement_guid == found_guid) { + if (rewritten_xmp == nullptr || edits.empty() || *replacement_guid == found_guid) { if (rewritten_xmp != nullptr) *rewritten_xmp = clean_xmp; return true; } - vector> edits; - edits.reserve(references.size()); - for (const ExtendedXmpReference& reference : references) { - const size_t begin = replacement_guid->empty() ? reference.remove_begin : reference.value_begin; - const size_t end = replacement_guid->empty() ? reference.remove_end : reference.value_end; - if (begin < offset || end < begin || end - offset > clean_xmp.size()) return false; - edits.emplace_back(begin - offset, end - offset); + for (auto& [begin, end] : edits) { + if (begin < kSyntheticOffset || end < begin || end - kSyntheticOffset > clean_xmp.size()) { + return false; + } + begin -= kSyntheticOffset; + end -= kSyntheticOffset; } sort(edits.begin(), edits.end()); for (size_t index = 1; index < edits.size(); ++index) { @@ -1547,42 +1569,32 @@ bool ProcessExtendedXmpGuid(const string& xmp_data, const string* replacement_gu string rewritten; size_t cursor = 0; - for (const auto& edit : edits) { - rewritten.append(clean_xmp, cursor, edit.first - cursor); + for (const auto& [begin, end] : edits) { + rewritten.append(clean_xmp, cursor, begin - cursor); if (!replacement_guid->empty()) rewritten.append(*replacement_guid); - cursor = edit.second; + cursor = end; } rewritten.append(clean_xmp, cursor, clean_xmp.size() - cursor); *rewritten_xmp = std::move(rewritten); return true; } -} // namespace - string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, const string& extended_xmp_guid) { - 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); - size_t rdf_root = kNoXmpElement; - vector all_descriptions; - vector primary_descriptions; - if (!ParseRdfDescriptions(parse_xml, &handler, &rdf_root, &all_descriptions, - &primary_descriptions)) { - return string(); - } - const vector& elements = handler.elements(); - if (elements[rdf_root].self_closing || - elements[rdf_root].end_begin <= elements[rdf_root].start_begin) { + ParsedXmpPacket pkt; + if (!ParseXmpPacket(existing_xmp, &pkt)) return string(); + + const vector& elements = pkt.elements(); + if (elements[pkt.rdf_root].self_closing || + elements[pkt.rdf_root].end_begin <= elements[pkt.rdf_root].start_begin || + elements[pkt.rdf_root].end_begin < kSyntheticOffset || + elements[pkt.rdf_root].end_begin - kSyntheticOffset > existing_xmp.size()) { return string(); } vector> removals; - for (const size_t description : primary_descriptions) { + for (const size_t description : pkt.primary_descriptions) { const XmpElementSpan& primary = elements[description]; for (const XmpAttributeSpan& attribute : primary.attributes) { if ((attribute.uri == kContainerUri && attribute.local_name == "Directory") || @@ -1599,24 +1611,22 @@ string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length } } - 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; - if (removal.second > insertion) return string(); + const size_t insertion = elements[pkt.rdf_root].end_begin - kSyntheticOffset; + for (auto& [begin, end] : removals) { + if (begin < kSyntheticOffset || end < begin) return string(); + begin -= kSyntheticOffset; + end -= kSyntheticOffset; + if (end > insertion) return string(); } 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, extended_xmp_guid); + stringstream ss; + photos_editing_formats::image_io::XmlWriter writer(ss); + WritePrimaryDescriptionElement(&writer, true, secondary_image_length, metadata, + extended_xmp_guid); string merged; size_t cursor = 0; for (const auto& [begin, end] : removals) { @@ -1625,7 +1635,7 @@ string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length cursor = end; } merged.append(existing_xmp, cursor, insertion - cursor); - merged.append(generated_description); + merged.append(ss.str()); merged.push_back('\n'); merged.append(existing_xmp, insertion, existing_xmp.size() - insertion); return merged; @@ -1646,69 +1656,31 @@ bool replaceExtendedXmpGuidInXmp(const string& xmp_data, const string& new_guid, bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) { if (stripped_xmp == nullptr) return false; - size_t trimmed_size = xmp_data.size(); - while (trimmed_size > 0 && xmp_data[trimmed_size - 1] == '\0') { - --trimmed_size; - } - const string clean_xmp = xmp_data.substr(0, trimmed_size); + const string clean_xmp = TrimTrailingNulls(xmp_data); if (clean_xmp.empty()) { stripped_xmp->clear(); return true; } - const string synthetic_open = ""; - const string synthetic_close = ""; - const size_t offset = synthetic_open.size(); - const string parse_xml = synthetic_open + clean_xmp + synthetic_close; + ParsedXmpPacket pkt; + if (!ParseXmpPacket(clean_xmp, &pkt)) return false; - XmpMergeXmlHandler handler(parse_xml); - size_t rdf_root = kNoXmpElement; - vector all_descriptions; - vector primary_descriptions; - if (!ParseRdfDescriptions(parse_xml, &handler, &rdf_root, &all_descriptions, - &primary_descriptions)) { - return false; - } - - const vector& elements = handler.elements(); + const vector& elements = pkt.elements(); vector> removals; size_t removed_descriptions = 0; - for (const size_t description : primary_descriptions) { + for (const size_t description : pkt.primary_descriptions) { const XmpElementSpan& primary = elements[description]; bool has_removable = false; - bool has_user_attribute = false; - bool has_user_child = false; - - for (const XmpAttributeSpan& attribute : primary.attributes) { - if (attribute.namespace_declaration) continue; - if (attribute.uri == kRdfUri && attribute.local_name == "about" && - attribute.value.empty()) { - continue; - } - if (IsGainMapPropertyUri(attribute.uri)) { - has_removable = true; - } else { - has_user_attribute = true; - } - } - - for (const XmpElementSpan& child : elements) { - if (child.parent != description) continue; - if (IsGainMapPropertyUri(child.uri)) { - has_removable = true; - } else { - has_user_child = true; - } - } - + bool has_user = false; + ClassifyPrimaryDescription(elements, description, &has_removable, &has_user); if (!has_removable) continue; - if (!has_user_attribute && !has_user_child) { + if (!has_user) { ++removed_descriptions; size_t remove_end = primary.end_end; - while (remove_end < parse_xml.size() && - (parse_xml[remove_end] == '\n' || parse_xml[remove_end] == '\r')) { + while (remove_end < pkt.parse_xml.size() && + (pkt.parse_xml[remove_end] == '\n' || pkt.parse_xml[remove_end] == '\r')) { ++remove_end; } removals.emplace_back(primary.start_begin, remove_end); @@ -1719,7 +1691,8 @@ bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) // Namespace bindings may still be used by retained descendants in a mixed description. if (!attribute.namespace_declaration && IsGainMapPropertyUri(attribute.uri)) { size_t attr_begin = attribute.name_begin; - while (attr_begin > primary.start_begin && IsXmlWhitespace(parse_xml[attr_begin - 1])) { + while (attr_begin > primary.start_begin && + IsXmlWhitespace(pkt.parse_xml[attr_begin - 1])) { --attr_begin; } removals.emplace_back(attr_begin, attribute.value_end); @@ -1730,14 +1703,14 @@ bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) if (child.parent == description && IsGainMapPropertyUri(child.uri)) { size_t child_begin = child.start_begin; while (child_begin > primary.start_begin && - (parse_xml[child_begin - 1] == ' ' || parse_xml[child_begin - 1] == '\t')) { + (pkt.parse_xml[child_begin - 1] == ' ' || pkt.parse_xml[child_begin - 1] == '\t')) { --child_begin; } size_t child_end = child.end_end; - if (child_end + 1 < parse_xml.size() && parse_xml[child_end] == '\r' && - parse_xml[child_end + 1] == '\n') { + if (child_end + 1 < pkt.parse_xml.size() && pkt.parse_xml[child_end] == '\r' && + pkt.parse_xml[child_end + 1] == '\n') { child_end += 2; - } else if (child_end < parse_xml.size() && parse_xml[child_end] == '\n') { + } else if (child_end < pkt.parse_xml.size() && pkt.parse_xml[child_end] == '\n') { child_end += 1; } removals.emplace_back(child_begin, child_end); @@ -1745,21 +1718,20 @@ bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) } } - if (!all_descriptions.empty() && removed_descriptions == all_descriptions.size()) { + if (!pkt.all_descriptions.empty() && removed_descriptions == pkt.all_descriptions.size()) { stripped_xmp->clear(); return true; } - if (removals.empty()) { *stripped_xmp = clean_xmp; return true; } - for (auto& removal : removals) { - if (removal.first < offset || removal.second < removal.first) return false; - removal.first -= offset; - removal.second -= offset; - if (removal.second > clean_xmp.size()) return false; + for (auto& [begin, end] : removals) { + if (begin < kSyntheticOffset || end < begin) return false; + begin -= kSyntheticOffset; + end -= kSyntheticOffset; + if (end > clean_xmp.size()) return false; } sort(removals.begin(), removals.end()); vector> coalesced; @@ -1787,18 +1759,12 @@ bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::string& extended_xmp, std::string* merged_xmp) { if (merged_xmp == nullptr) return false; - size_t ext_trimmed = extended_xmp.size(); - while (ext_trimmed > 0 && extended_xmp[ext_trimmed - 1] == '\0') { - --ext_trimmed; - } - const string clean_ext = extended_xmp.substr(0, ext_trimmed); + const string clean_ext = TrimTrailingNulls(extended_xmp); string std_without_guid; - if (!replaceExtendedXmpGuidInXmp(standard_xmp, "", &std_without_guid)) { - return false; - } string std_user_only; - if (!stripGainMapFromXmp(std_without_guid, &std_user_only)) { + if (!replaceExtendedXmpGuidInXmp(standard_xmp, "", &std_without_guid) || + !stripGainMapFromXmp(std_without_guid, &std_user_only)) { return false; } if (std_user_only.empty()) { @@ -1806,50 +1772,21 @@ bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::str return true; } - const string synthetic_open = ""; - const string synthetic_close = ""; - const size_t offset = synthetic_open.size(); - const string std_parse_xml = synthetic_open + std_user_only + synthetic_close; - - XmpMergeXmlHandler std_handler(std_parse_xml); - size_t std_rdf_root = kNoXmpElement; - vector std_all_descriptions; - vector std_primary_descriptions; - if (!ParseRdfDescriptions(std_parse_xml, &std_handler, &std_rdf_root, &std_all_descriptions, - &std_primary_descriptions)) { - return false; - } + ParsedXmpPacket std_pkt; + if (!ParseXmpPacket(std_user_only, &std_pkt)) return false; - const vector& std_elements = std_handler.elements(); + const vector& std_elements = std_pkt.elements(); vector retained_descriptions; - for (size_t desc_idx : std_all_descriptions) { - const XmpElementSpan& desc = std_elements[desc_idx]; + for (size_t desc_idx : std_pkt.all_descriptions) { const bool is_primary = - std::find(std_primary_descriptions.begin(), std_primary_descriptions.end(), desc_idx) != - std_primary_descriptions.end(); - if (!is_primary) { - retained_descriptions.push_back(desc_idx); - continue; - } - bool has_user_attribute = false; - for (const XmpAttributeSpan& attribute : desc.attributes) { - if (attribute.namespace_declaration) continue; - if (attribute.uri == kRdfUri && attribute.local_name == "about" && attribute.value.empty()) { - continue; - } - has_user_attribute = true; - break; - } - bool has_user_child = false; - for (const XmpElementSpan& child : std_elements) { - if (child.parent == desc_idx) { - has_user_child = true; - break; - } - } - if (has_user_attribute || has_user_child) { - retained_descriptions.push_back(desc_idx); + std::find(std_pkt.primary_descriptions.begin(), std_pkt.primary_descriptions.end(), + desc_idx) != std_pkt.primary_descriptions.end(); + bool has_removable = false; + bool has_user = false; + if (is_primary) { + ClassifyPrimaryDescription(std_elements, desc_idx, &has_removable, &has_user); } + if (!is_primary || has_user) retained_descriptions.push_back(desc_idx); } if (retained_descriptions.empty()) { @@ -1861,206 +1798,127 @@ bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::str return true; } - const string ext_parse_xml = synthetic_open + clean_ext + synthetic_close; - XmpMergeXmlHandler ext_handler(ext_parse_xml); - size_t ext_rdf_root = kNoXmpElement; - vector ext_all_descriptions; - vector ext_primary_descriptions; - if (!ParseRdfDescriptions(ext_parse_xml, &ext_handler, &ext_rdf_root, &ext_all_descriptions, - &ext_primary_descriptions)) { - return false; - } - const vector& ext_elements = ext_handler.elements(); - if (ext_elements[ext_rdf_root].self_closing || - ext_elements[ext_rdf_root].end_begin <= ext_elements[ext_rdf_root].start_begin) { + ParsedXmpPacket ext_pkt; + if (!ParseXmpPacket(clean_ext, &ext_pkt)) return false; + const vector& ext_elements = ext_pkt.elements(); + if (ext_elements[ext_pkt.rdf_root].self_closing || + ext_elements[ext_pkt.rdf_root].end_begin <= ext_elements[ext_pkt.rdf_root].start_begin) { return false; } size_t ext_rdf_open_end = 0; - if (!FindXmlMarkupEnd(ext_parse_xml, ext_elements[ext_rdf_root].start_begin, &ext_rdf_open_end) || - ext_rdf_open_end < offset || ext_rdf_open_end - offset > clean_ext.size()) { + if (!FindXmlMarkupEnd(ext_pkt.parse_xml, ext_elements[ext_pkt.rdf_root].start_begin, + &ext_rdf_open_end) || + ext_rdf_open_end < kSyntheticOffset || + ext_rdf_open_end - kSyntheticOffset > clean_ext.size()) { return false; } // Collect ancestor namespace declarations and xml:* scoped attributes (e.g. xml:lang) in both // packets so moved elements retain their original XML context. - constexpr char kXmlNamespaceUri[] = "http://www.w3.org/XML/1998/namespace"; - auto collect_ancestor_namespaces = [](const vector& elements, size_t start_elem, - size_t stop_root) { + auto find_ns = [](const vector& list, + const string& prefix) -> const string* { + for (const auto& d : list) { + if (d.prefix == prefix) return &d.uri; + } + return nullptr; + }; + auto collect_ancestor_ns = [&](const ParsedXmpPacket& pkt) { vector result; - for (size_t ancestor = start_elem; ancestor != kNoXmpElement && ancestor != stop_root; - ancestor = elements[ancestor].parent) { - for (const XmpNamespaceDeclaration& decl : elements[ancestor].namespace_declarations) { - bool exists = false; - for (const XmpNamespaceDeclaration& existing : result) { - if (existing.prefix == decl.prefix) { - exists = true; - break; - } - } - if (!exists) result.push_back(decl); + for (size_t anc = pkt.rdf_root; anc != kNoXmpElement && anc != pkt.root_index(); + anc = pkt.elements()[anc].parent) { + for (const auto& decl : pkt.elements()[anc].namespace_declarations) { + if (!find_ns(result, decl.prefix)) result.push_back(decl); } } return result; }; - - struct XmlScopedAttribute { - string local_name; - string value; + auto find_xml_attr = [](const vector& list, + const string& local_name) -> const string* { + for (const auto& a : list) { + if (!a.namespace_declaration && a.uri == kXmlNamespaceUri && a.local_name == local_name) { + return &a.value; + } + } + return nullptr; }; - auto collect_ancestor_xml_attributes = [&](const vector& elements, - size_t start_elem, size_t stop_root) { - vector result; - for (size_t ancestor = start_elem; ancestor != kNoXmpElement && ancestor != stop_root; - ancestor = elements[ancestor].parent) { - for (const XmpAttributeSpan& attr : elements[ancestor].attributes) { - if (attr.namespace_declaration || attr.uri != kXmlNamespaceUri) continue; - bool exists = false; - for (const XmlScopedAttribute& existing : result) { - if (existing.local_name == attr.local_name) { - exists = true; - break; - } + auto collect_ancestor_xml_attrs = [&](const ParsedXmpPacket& pkt) { + vector result; + for (size_t anc = pkt.rdf_root; anc != kNoXmpElement && anc != pkt.root_index(); + anc = pkt.elements()[anc].parent) { + for (const auto& attr : pkt.elements()[anc].attributes) { + if (!attr.namespace_declaration && attr.uri == kXmlNamespaceUri && + !find_xml_attr(result, attr.local_name)) { + result.push_back(attr); } - if (!exists) result.push_back({attr.local_name, attr.value}); } } return result; }; - auto format_xml_attr = [](const string& name, const string& value) { const char quote = (value.find('"') == string::npos) ? '"' : '\''; return string(" ") + name + "=" + quote + value + quote; }; - const vector std_ancestor_ns = - collect_ancestor_namespaces(std_elements, std_rdf_root, std_handler.root_index()); - const vector ext_ancestor_ns = - collect_ancestor_namespaces(ext_elements, ext_rdf_root, ext_handler.root_index()); - const vector std_ancestor_xml_attrs = - collect_ancestor_xml_attributes(std_elements, std_rdf_root, std_handler.root_index()); - const vector ext_ancestor_xml_attrs = - collect_ancestor_xml_attributes(ext_elements, ext_rdf_root, ext_handler.root_index()); + const vector std_ancestor_ns = collect_ancestor_ns(std_pkt); + const vector ext_ancestor_ns = collect_ancestor_ns(ext_pkt); + const vector std_ancestor_xml_attrs = collect_ancestor_xml_attrs(std_pkt); + const vector ext_ancestor_xml_attrs = collect_ancestor_xml_attrs(ext_pkt); string descriptions_to_insert; for (size_t desc_idx : retained_descriptions) { const XmpElementSpan& desc = std_elements[desc_idx]; size_t desc_open_end = 0; - if (!FindXmlMarkupEnd(std_parse_xml, desc.start_begin, &desc_open_end) || + if (!FindXmlMarkupEnd(std_pkt.parse_xml, desc.start_begin, &desc_open_end) || desc.end_end < desc.start_begin) { return false; } string extra_attrs; - for (const XmpNamespaceDeclaration& decl : std_ancestor_ns) { - bool declared_locally = false; - for (const XmpNamespaceDeclaration& local_decl : desc.namespace_declarations) { - if (local_decl.prefix == decl.prefix) { - declared_locally = true; - break; - } - } - if (declared_locally) continue; - - bool same_in_ext = false; - for (const XmpNamespaceDeclaration& ext_decl : ext_ancestor_ns) { - if (ext_decl.prefix == decl.prefix) { - same_in_ext = (ext_decl.uri == decl.uri); - break; - } - } - if (!same_in_ext) { - const string attr_name = decl.prefix.empty() ? "xmlns" : ("xmlns:" + decl.prefix); - extra_attrs += format_xml_attr(attr_name, decl.uri); - } - } - - bool has_default_ns_in_std = false; - for (const XmpNamespaceDeclaration& local_decl : desc.namespace_declarations) { - if (local_decl.prefix.empty()) { - has_default_ns_in_std = true; - break; - } - } - if (!has_default_ns_in_std) { - for (const XmpNamespaceDeclaration& decl : std_ancestor_ns) { - if (decl.prefix.empty()) { - has_default_ns_in_std = true; - break; - } + for (const auto& decl : std_ancestor_ns) { + if (find_ns(desc.namespace_declarations, decl.prefix)) continue; + const string* ext_uri = find_ns(ext_ancestor_ns, decl.prefix); + if (!ext_uri || *ext_uri != decl.uri) { + extra_attrs += + format_xml_attr(decl.prefix.empty() ? "xmlns" : "xmlns:" + decl.prefix, decl.uri); } } - if (!has_default_ns_in_std) { - for (const XmpNamespaceDeclaration& ext_decl : ext_ancestor_ns) { - if (ext_decl.prefix.empty() && !ext_decl.uri.empty()) { - extra_attrs += format_xml_attr("xmlns", ""); - break; - } + if (!find_ns(desc.namespace_declarations, "") && !find_ns(std_ancestor_ns, "")) { + if (const string* ext_def = find_ns(ext_ancestor_ns, ""); ext_def && !ext_def->empty()) { + extra_attrs += format_xml_attr("xmlns", ""); } } - - for (const XmlScopedAttribute& xml_attr : std_ancestor_xml_attrs) { - bool declared_locally = false; - for (const XmpAttributeSpan& local_attr : desc.attributes) { - if (!local_attr.namespace_declaration && local_attr.uri == kXmlNamespaceUri && - local_attr.local_name == xml_attr.local_name) { - declared_locally = true; - break; - } - } - if (declared_locally) continue; - - bool same_in_ext = false; - for (const XmlScopedAttribute& ext_attr : ext_ancestor_xml_attrs) { - if (ext_attr.local_name == xml_attr.local_name) { - same_in_ext = (ext_attr.value == xml_attr.value); - break; - } - } - if (!same_in_ext) { + for (const auto& xml_attr : std_ancestor_xml_attrs) { + if (find_xml_attr(desc.attributes, xml_attr.local_name)) continue; + const string* ext_val = find_xml_attr(ext_ancestor_xml_attrs, xml_attr.local_name); + if (!ext_val || *ext_val != xml_attr.value) { extra_attrs += format_xml_attr("xml:" + xml_attr.local_name, xml_attr.value); } } - - for (const XmlScopedAttribute& ext_attr : ext_ancestor_xml_attrs) { - if (ext_attr.local_name != "lang" || ext_attr.value.empty()) continue; - bool has_lang_in_std = false; - for (const XmpAttributeSpan& local_attr : desc.attributes) { - if (!local_attr.namespace_declaration && local_attr.uri == kXmlNamespaceUri && - local_attr.local_name == "lang") { - has_lang_in_std = true; - break; - } - } - if (!has_lang_in_std) { - for (const XmlScopedAttribute& std_attr : std_ancestor_xml_attrs) { - if (std_attr.local_name == "lang") { - has_lang_in_std = true; - break; - } - } - } - if (!has_lang_in_std) { + if (!find_xml_attr(desc.attributes, "lang") && !find_xml_attr(std_ancestor_xml_attrs, "lang")) { + if (const string* ext_lang = find_xml_attr(ext_ancestor_xml_attrs, "lang"); + ext_lang && !ext_lang->empty()) { extra_attrs += format_xml_attr("xml:lang", ""); } } if (extra_attrs.empty()) { - descriptions_to_insert.append(std_parse_xml, desc.start_begin, + descriptions_to_insert.append(std_pkt.parse_xml, desc.start_begin, desc.end_end - desc.start_begin); } else { size_t insert_at = desc_open_end - 1; - while (insert_at > desc.start_begin && IsXmlWhitespace(std_parse_xml[insert_at - 1])) { + while (insert_at > desc.start_begin && IsXmlWhitespace(std_pkt.parse_xml[insert_at - 1])) { --insert_at; } - if (insert_at > desc.start_begin && std_parse_xml[insert_at - 1] == '/') { + if (insert_at > desc.start_begin && std_pkt.parse_xml[insert_at - 1] == '/') { --insert_at; } - descriptions_to_insert.append(std_parse_xml, desc.start_begin, insert_at - desc.start_begin); + descriptions_to_insert.append(std_pkt.parse_xml, desc.start_begin, + insert_at - desc.start_begin); descriptions_to_insert.append(extra_attrs); - descriptions_to_insert.append(std_parse_xml, insert_at, desc.end_end - insert_at); + descriptions_to_insert.append(std_pkt.parse_xml, insert_at, desc.end_end - insert_at); } } - const size_t insert_pos = ext_rdf_open_end - offset; + const size_t insert_pos = ext_rdf_open_end - kSyntheticOffset; string result; result.reserve(clean_ext.size() + descriptions_to_insert.size()); result.append(clean_ext, 0, insert_pos); @@ -2074,9 +1932,6 @@ string generateXmpForPrimaryImage(size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, 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}); - 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; @@ -2100,35 +1955,9 @@ string generateXmpForPrimaryImage(size_t secondary_image_length, 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(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); - - 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(); - + writer.WriteXmlns("rdf", kRdfUri); + WritePrimaryDescriptionElement(&writer, false, secondary_image_length, metadata, + extended_xmp_guid); return ss.str(); } From 4d137cfb602b307d09c8f36fff2c7b2b1c220ba7 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 16 Oct 2026 19:57:03 +0000 Subject: [PATCH 15/20] Support XMP metadata in HEIF and AVIF codecs --- lib/include/ultrahdr/avifultrahdr.h | 15 +- lib/include/ultrahdr/heifultrahdr.h | 15 +- lib/src/avifultrahdr.cpp | 53 +++- lib/src/heifultrahdr.cpp | 53 +++- lib/src/ultrahdr_api.cpp | 125 +++++---- tests/ultrahdr_api_test.cpp | 380 +++++++++++++++++++++++++++- ultrahdr_api.h | 7 +- 7 files changed, 565 insertions(+), 83 deletions(-) diff --git a/lib/include/ultrahdr/avifultrahdr.h b/lib/include/ultrahdr/avifultrahdr.h index f5b6f1bd..59fc2aa0 100644 --- a/lib/include/ultrahdr/avifultrahdr.h +++ b/lib/include/ultrahdr/avifultrahdr.h @@ -66,11 +66,14 @@ class AvifUltraHdr : public UltraHdr { * \param[in] quality quality factor for sdr intent heif/avif 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 encodeAvifUltraHdr(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. * @@ -88,11 +91,14 @@ class AvifUltraHdr : public UltraHdr { * \param[in] quality quality factor for sdr intent heif/avif 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 encodeAvifUltraHdr(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 Decode API. * @@ -159,6 +165,8 @@ class AvifUltraHdr : public UltraHdr { * compressed output * \param[in] baseIcc base image icc * \param[in] alternateIcc alternate image icc + * \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. */ @@ -168,7 +176,8 @@ class AvifUltraHdr : public UltraHdr { uhdr_gainmap_metadata_ext_t* metadata, uhdr_compressed_image_t* dest, int quality, uhdr_mem_block_t* exif, DataStruct* baseIcc, - DataStruct* alternateIcc); + DataStruct* alternateIcc, + uhdr_mem_block_t* xmp = nullptr); uhdr_codec_t mCodec; }; diff --git a/lib/include/ultrahdr/heifultrahdr.h b/lib/include/ultrahdr/heifultrahdr.h index f7056554..83992118 100644 --- a/lib/include/ultrahdr/heifultrahdr.h +++ b/lib/include/ultrahdr/heifultrahdr.h @@ -66,11 +66,14 @@ class HeifUltraHdr : public UltraHdr { * \param[in] quality quality factor for sdr intent heif/avif 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 encodeHeicUltraHdr(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. * @@ -88,11 +91,14 @@ class HeifUltraHdr : public UltraHdr { * \param[in] quality quality factor for sdr intent heif/avif 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 encodeHeicUltraHdr(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 Decode API. * @@ -159,6 +165,8 @@ class HeifUltraHdr : public UltraHdr { * compressed output * \param[in] baseIcc base image icc * \param[in] alternateIcc alternate image icc + * \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. */ @@ -168,7 +176,8 @@ class HeifUltraHdr : public UltraHdr { uhdr_gainmap_metadata_ext_t* metadata, uhdr_compressed_image_t* dest, int quality, uhdr_mem_block_t* exif, DataStruct* baseIcc, - DataStruct* alternateIcc); + DataStruct* alternateIcc, + uhdr_mem_block_t* xmp = nullptr); uhdr_codec_t mCodec; }; diff --git a/lib/src/avifultrahdr.cpp b/lib/src/avifultrahdr.cpp index 779e95c1..d4ae1609 100644 --- a/lib/src/avifultrahdr.cpp +++ b/lib/src/avifultrahdr.cpp @@ -341,8 +341,10 @@ AvifUltraHdr::AvifUltraHdr(void* uhdrGLESCtxt, int mapDimensionScaleFactor, int mCodec(codec) {} /* Encode API-0 */ -uhdr_error_info_t AvifUltraHdr::encodeAvifUltraHdr(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* dest, - int quality, uhdr_mem_block_t* exif) { +uhdr_error_info_t AvifUltraHdr::encodeAvifUltraHdr(uhdr_raw_image_t* hdr_intent, + uhdr_compressed_image_t* dest, 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; @@ -394,13 +396,15 @@ uhdr_error_info_t AvifUltraHdr::encodeAvifUltraHdr(uhdr_raw_image_t* hdr_intent, IccHelper::writeIccProfile(gainmap->ct, gainmap->cg); return encodeAvifUltraHdr(sdr_intent_yuv, hdr_intent, gainmap.get(), &metadata, dest, quality, - exif, baseIcc.get(), alternateIcc.get()); + exif, baseIcc.get(), alternateIcc.get(), xmp); } /* Encode API-1 */ -uhdr_error_info_t AvifUltraHdr::encodeAvifUltraHdr(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_error_info_t AvifUltraHdr::encodeAvifUltraHdr(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* xmp) { // generate gain map uhdr_gainmap_metadata_ext_t metadata(kJpegrVersion); std::unique_ptr gainmap; @@ -423,7 +427,7 @@ uhdr_error_info_t AvifUltraHdr::encodeAvifUltraHdr(uhdr_raw_image_t* hdr_intent, IccHelper::writeIccProfile(gainmap->ct, gainmap->cg); return encodeAvifUltraHdr(sdr_intent_yuv, sdr_intent, gainmap.get(), &metadata, dest, quality, - exif, baseIcc.get(), alternateIcc.get()); + exif, baseIcc.get(), alternateIcc.get(), xmp); } uhdr_error_info_t AvifUltraHdr::encodeAvifUltraHdr(uhdr_raw_image_t* sdr_intent, @@ -432,7 +436,8 @@ uhdr_error_info_t AvifUltraHdr::encodeAvifUltraHdr(uhdr_raw_image_t* sdr_intent, uhdr_gainmap_metadata_ext_t* metadata, uhdr_compressed_image_t* dest, int quality, uhdr_mem_block_t* exif, DataStruct* baseIcc, - DataStruct* alternateIcc) { + DataStruct* alternateIcc, + uhdr_mem_block_t* xmp) { uhdr_error_info_t status = g_no_error; heif_encoder* encoder = nullptr; heif_encoding_options* options = nullptr; @@ -522,6 +527,38 @@ uhdr_error_info_t AvifUltraHdr::encodeAvifUltraHdr(uhdr_raw_image_t* sdr_intent, if (exif != nullptr) { HEIF_ERR_CHECK(heif_context_add_exif_metadata(ctx, baseHandle, exif->data, exif->data_sz)); } + if (xmp != nullptr) { + if (xmp->data == nullptr && xmp->data_sz > 0) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for xmp->data"); + goto CleanUp; + } + const char* xmp_data = static_cast(xmp->data); + size_t xmp_size = xmp->data_sz; + constexpr char kXmpHeader[] = "http://ns.adobe.com/xap/1.0/"; + constexpr size_t kXmpHeaderLen = sizeof(kXmpHeader) - 1; + if (xmp_data != nullptr && xmp_size > kXmpHeaderLen && + memcmp(xmp_data, kXmpHeader, kXmpHeaderLen) == 0) { + size_t offset = kXmpHeaderLen; + if (xmp_data[offset] == '\0') { + offset++; + } + xmp_data += offset; + xmp_size -= offset; + } + if (xmp_size > static_cast(std::numeric_limits::max())) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "xmp size %zu exceeds maximum supported size", xmp_size); + goto CleanUp; + } + if (xmp_data != nullptr && xmp_size > 0) { + HEIF_ERR_CHECK( + heif_context_add_XMP_metadata(ctx, baseHandle, xmp_data, static_cast(xmp_size))); + } + } // encode the gain map image if (isUsingMultiChannelGainMap()) { diff --git a/lib/src/heifultrahdr.cpp b/lib/src/heifultrahdr.cpp index 0bb9a421..52cbe42c 100644 --- a/lib/src/heifultrahdr.cpp +++ b/lib/src/heifultrahdr.cpp @@ -341,8 +341,10 @@ HeifUltraHdr::HeifUltraHdr(void* uhdrGLESCtxt, int mapDimensionScaleFactor, int mCodec(codec) {} /* Encode API-0 */ -uhdr_error_info_t HeifUltraHdr::encodeHeicUltraHdr(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* dest, - int quality, uhdr_mem_block_t* exif) { +uhdr_error_info_t HeifUltraHdr::encodeHeicUltraHdr(uhdr_raw_image_t* hdr_intent, + uhdr_compressed_image_t* dest, 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; @@ -394,13 +396,15 @@ uhdr_error_info_t HeifUltraHdr::encodeHeicUltraHdr(uhdr_raw_image_t* hdr_intent, IccHelper::writeIccProfile(gainmap->ct, gainmap->cg); return encodeHeicUltraHdr(sdr_intent_yuv, hdr_intent, gainmap.get(), &metadata, dest, quality, - exif, baseIcc.get(), alternateIcc.get()); + exif, baseIcc.get(), alternateIcc.get(), xmp); } /* Encode API-1 */ -uhdr_error_info_t HeifUltraHdr::encodeHeicUltraHdr(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_error_info_t HeifUltraHdr::encodeHeicUltraHdr(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* xmp) { // generate gain map uhdr_gainmap_metadata_ext_t metadata(kJpegrVersion); std::unique_ptr gainmap; @@ -423,7 +427,7 @@ uhdr_error_info_t HeifUltraHdr::encodeHeicUltraHdr(uhdr_raw_image_t* hdr_intent, IccHelper::writeIccProfile(gainmap->ct, gainmap->cg); return encodeHeicUltraHdr(sdr_intent_yuv, sdr_intent, gainmap.get(), &metadata, dest, quality, - exif, baseIcc.get(), alternateIcc.get()); + exif, baseIcc.get(), alternateIcc.get(), xmp); } uhdr_error_info_t HeifUltraHdr::encodeHeicUltraHdr(uhdr_raw_image_t* sdr_intent, @@ -432,7 +436,8 @@ uhdr_error_info_t HeifUltraHdr::encodeHeicUltraHdr(uhdr_raw_image_t* sdr_intent, uhdr_gainmap_metadata_ext_t* metadata, uhdr_compressed_image_t* dest, int quality, uhdr_mem_block_t* exif, DataStruct* baseIcc, - DataStruct* alternateIcc) { + DataStruct* alternateIcc, + uhdr_mem_block_t* xmp) { uhdr_error_info_t status = g_no_error; heif_encoder* encoder = nullptr; heif_encoding_options* options = nullptr; @@ -522,6 +527,38 @@ uhdr_error_info_t HeifUltraHdr::encodeHeicUltraHdr(uhdr_raw_image_t* sdr_intent, if (exif != nullptr) { HEIF_ERR_CHECK(heif_context_add_exif_metadata(ctx, baseHandle, exif->data, exif->data_sz)); } + if (xmp != nullptr) { + if (xmp->data == nullptr && xmp->data_sz > 0) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for xmp->data"); + goto CleanUp; + } + const char* xmp_data = static_cast(xmp->data); + size_t xmp_size = xmp->data_sz; + constexpr char kXmpHeader[] = "http://ns.adobe.com/xap/1.0/"; + constexpr size_t kXmpHeaderLen = sizeof(kXmpHeader) - 1; + if (xmp_data != nullptr && xmp_size > kXmpHeaderLen && + memcmp(xmp_data, kXmpHeader, kXmpHeaderLen) == 0) { + size_t offset = kXmpHeaderLen; + if (xmp_data[offset] == '\0') { + offset++; + } + xmp_data += offset; + xmp_size -= offset; + } + if (xmp_size > static_cast(std::numeric_limits::max())) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "xmp size %zu exceeds maximum supported size", xmp_size); + goto CleanUp; + } + if (xmp_data != nullptr && xmp_size > 0) { + HEIF_ERR_CHECK( + heif_context_add_XMP_metadata(ctx, baseHandle, xmp_data, static_cast(xmp_size))); + } + } // encode the gain map image if (isUsingMultiChannelGainMap()) { diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 60b7c178..5121d427 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -1543,43 +1543,37 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { uhdr_mem_block_t xmp{}; if (handle->m_xmp.size() > 0) { - if (handle->m_output_format != UHDR_CODEC_JPG) { - status.error_code = UHDR_CODEC_UNSUPPORTED_FEATURE; - status.has_detail = 1; - snprintf(status.detail, sizeof status.detail, - "xmp metadata insertion is currently supported only for UHDR_CODEC_JPG output"); - return status; - } xmp.data = handle->m_xmp.data(); xmp.capacity = xmp.data_sz = handle->m_xmp.size(); } - if (handle->m_output_format == UHDR_CODEC_JPG) { - const bool mayWriteXmp = !handle->m_xmp.empty() || !handle->m_compressed_images.empty(); - size_t xmpAllowance = 0; - if (mayWriteXmp) { - size_t xmp_sz = handle->m_xmp.size(); - if (xmp_sz == 0) { - for (const auto& entry : handle->m_compressed_images) { - if (entry.second != nullptr) { - xmp_sz = (std::max)(xmp_sz, entry.second->data_sz); - } + const bool mayWriteXmp = !handle->m_xmp.empty() || !handle->m_compressed_images.empty(); + size_t xmpAllowance = 0; + if (mayWriteXmp) { + size_t xmp_sz = handle->m_xmp.size(); + if (xmp_sz == 0) { + for (const auto& entry : handle->m_compressed_images) { + if (entry.second != nullptr) { + xmp_sz = (std::max)(xmp_sz, entry.second->data_sz); } } - 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; - 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"); - }; + 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; + 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"); + }; + + 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, handle->m_use_multi_channel_gainmap, handle->m_gamma, @@ -1674,15 +1668,21 @@ 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); 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()) { // api - 0 - status = avifultrahdr.encodeAvifUltraHdr(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); + status = avifultrahdr.encodeAvifUltraHdr( + 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_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()) { @@ -1694,10 +1694,11 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { auto& sdr_raw_entry = handle->m_raw_images.find(UHDR_SDR_IMG)->second; if (handle->m_compressed_images.find(UHDR_SDR_IMG) == handle->m_compressed_images.end()) { // api - 1 - status = avifultrahdr.encodeAvifUltraHdr(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); + status = avifultrahdr.encodeAvifUltraHdr( + 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_xmp.size() > 0 ? &xmp : nullptr); } else { status.error_code = UHDR_CODEC_UNSUPPORTED_FEATURE; status.has_detail = 1; @@ -1723,15 +1724,21 @@ 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); 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()) { // api - 0 - status = heifultrahdr.encodeHeicUltraHdr(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); + status = heifultrahdr.encodeHeicUltraHdr( + 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_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()) { @@ -1743,10 +1750,11 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { auto& sdr_raw_entry = handle->m_raw_images.find(UHDR_SDR_IMG)->second; if (handle->m_compressed_images.find(UHDR_SDR_IMG) == handle->m_compressed_images.end()) { // api - 1 - status = heifultrahdr.encodeHeicUltraHdr(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); + status = heifultrahdr.encodeHeicUltraHdr( + 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_xmp.size() > 0 ? &xmp : nullptr); } else { status.error_code = UHDR_CODEC_UNSUPPORTED_FEATURE; status.has_detail = 1; @@ -2073,6 +2081,35 @@ uhdr_error_info_t uhdr_dec_probe(uhdr_codec_private_t* dec) { handle->m_metadata.hdr_capacity_min = metadata.hdr_capacity_min; handle->m_metadata.hdr_capacity_max = metadata.hdr_capacity_max; handle->m_metadata.use_base_cg = metadata.use_base_cg; + + const int num_mime_blocks = + heif_image_handle_get_number_of_metadata_blocks(base_handle, "mime"); + if (num_mime_blocks > 0) { + std::vector mime_ids(num_mime_blocks); + const int fetched_blocks = heif_image_handle_get_list_of_metadata_block_IDs( + base_handle, "mime", mime_ids.data(), num_mime_blocks); + for (int i = 0; i < fetched_blocks; ++i) { + const char* content_type = + heif_image_handle_get_metadata_content_type(base_handle, mime_ids[i]); + if (content_type != nullptr && + strcmp(content_type, "application/rdf+xml") == 0) { + const size_t xmp_size = + heif_image_handle_get_metadata_size(base_handle, mime_ids[i]); + if (xmp_size > 0) { + std::vector xmp_buf(xmp_size); + heif_error xmp_err = heif_image_handle_get_metadata( + base_handle, mime_ids[i], xmp_buf.data()); + if (xmp_err.code == heif_error_Ok) { + handle->m_xmp = std::move(xmp_buf); + handle->m_xmp_block.data = handle->m_xmp.data(); + handle->m_xmp_block.data_sz = handle->m_xmp_block.capacity = + handle->m_xmp.size(); + } + } + break; + } + } + } } } } else { diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 17b8aeb3..43de1ee0 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -3415,7 +3415,181 @@ TEST_F(UltraHdrApiTest, AvifCompressedIntentsUnsupported) { uhdr_release_encoder(enc); } -TEST_F(UltraHdrApiTest, HeifAndAvifXmpDataUnsupported) { +} // namespace ultrahdr +#include "libheif/heif_plugin.h" +namespace ultrahdr { + +namespace { + +bool g_mock_heif_encoder_enabled = false; + +struct MockHeifEncoderState { + int step = 0; +}; + +constexpr uint8_t kMockHevcVpsNal[] = { + 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x03, 0x70, 0x00, 0x00, 0x03, 0x00, + 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x1e, 0xba, 0x02, 0x40, +}; +constexpr uint8_t kMockHevcSpsNal[] = { + 0x42, 0x01, 0x01, 0x03, 0x70, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, + 0x00, 0x03, 0x00, 0x1e, 0xa0, 0x20, 0x81, 0x05, 0x96, 0xea, 0xae, 0x9a, 0xe6, 0xe1, + 0x80, 0x20, 0xc0, 0x80, 0x00, 0x00, 0x03, 0x00, 0x80, 0x00, 0x00, 0x03, 0x00, 0x84, +}; +constexpr uint8_t kMockHevcPpsNal[] = {0x44, 0x01, 0xc1, 0x73, 0xc1, 0x89}; +constexpr uint8_t kMockHevcSliceNal[] = {0x26, 0x01, 0xaf, 0x08}; +constexpr uint8_t kMockAv1Obu[] = {0x12, 0x00}; + +heif_error mockEncoderNew(void** out) { + *out = new MockHeifEncoderState(); + return {heif_error_Ok, heif_suberror_Unspecified, ""}; +} + +void mockEncoderFree(void* raw) { delete static_cast(raw); } + +heif_error mockEncoderNoopInt(void*, int) { return {heif_error_Ok, heif_suberror_Unspecified, ""}; } +heif_error mockEncoderNoopStr(void*, const char*, const char*) { + return {heif_error_Ok, heif_suberror_Unspecified, ""}; +} +void mockEncoderQueryInputColorspace(heif_colorspace* inout_cs, heif_chroma* inout_ch) { + if (*inout_cs == heif_colorspace_monochrome) { + *inout_ch = heif_chroma_monochrome; + } else { + *inout_cs = heif_colorspace_YCbCr; + if (*inout_ch != heif_chroma_444) *inout_ch = heif_chroma_420; + } +} +void mockEncoderQueryInputColorspace2(void*, heif_colorspace* inout_cs, heif_chroma* inout_ch) { + mockEncoderQueryInputColorspace(inout_cs, inout_ch); +} + +heif_error mockEncoderEncodeImage(void* raw, const heif_image* img, heif_image_input_class) { + if (!g_mock_heif_encoder_enabled && + heif_image_get_colorspace(img) != heif_colorspace_RGB && + !heif_image_has_channel(img, heif_channel_Alpha)) { + return {heif_error_Unsupported_filetype, heif_suberror_Unspecified, + "Unsupported file-type: No encoder available"}; + } + static_cast(raw)->step = 0; + return {heif_error_Ok, heif_suberror_Unspecified, ""}; +} + +heif_error mockHevcGetCompressedData(void* raw, uint8_t** data, int* size, + heif_encoded_data_type*) { + auto* st = static_cast(raw); + switch (st->step++) { + case 0: + *data = const_cast(kMockHevcVpsNal); + *size = sizeof(kMockHevcVpsNal); + break; + case 1: + *data = const_cast(kMockHevcSpsNal); + *size = sizeof(kMockHevcSpsNal); + break; + case 2: + *data = const_cast(kMockHevcPpsNal); + *size = sizeof(kMockHevcPpsNal); + break; + case 3: + *data = const_cast(kMockHevcSliceNal); + *size = sizeof(kMockHevcSliceNal); + break; + default: + *data = nullptr; + *size = 0; + break; + } + return {heif_error_Ok, heif_suberror_Unspecified, ""}; +} + +heif_error mockAv1GetCompressedData(void* raw, uint8_t** data, int* size, + heif_encoded_data_type*) { + auto* st = static_cast(raw); + if (st->step++ == 0) { + *data = const_cast(kMockAv1Obu); + *size = sizeof(kMockAv1Obu); + } else { + *data = nullptr; + *size = 0; + } + return {heif_error_Ok, heif_suberror_Unspecified, ""}; +} + +class ScopedMockHeifEncoders { + public: + ScopedMockHeifEncoders() { + static const bool kRealHevc = heif_have_encoder_for_format(heif_compression_HEVC) != 0; + static const bool kRealAv1 = heif_have_encoder_for_format(heif_compression_AV1) != 0; + had_real_hevc_ = kRealHevc; + had_real_av1_ = kRealAv1; + + static bool hevc_registered = false; + static bool av1_registered = false; + static heif_encoder_plugin hevc_plugin{}; + static heif_encoder_plugin av1_plugin{}; + + if (!kRealHevc && !hevc_registered) { + hevc_plugin.plugin_api_version = 3; + hevc_plugin.compression_format = heif_compression_HEVC; + hevc_plugin.id_name = "uhdr-test-hevc"; + hevc_plugin.priority = 1; + hevc_plugin.get_plugin_name = []() { return "uhdr-test-hevc"; }; + hevc_plugin.new_encoder = mockEncoderNew; + hevc_plugin.free_encoder = mockEncoderFree; + hevc_plugin.set_parameter_quality = mockEncoderNoopInt; + hevc_plugin.set_parameter_lossless = mockEncoderNoopInt; + hevc_plugin.set_parameter_logging_level = mockEncoderNoopInt; + hevc_plugin.set_parameter_integer = [](void*, const char*, int) -> heif_error { + return {heif_error_Ok, heif_suberror_Unspecified, ""}; + }; + hevc_plugin.set_parameter_boolean = [](void*, const char*, int) -> heif_error { + return {heif_error_Ok, heif_suberror_Unspecified, ""}; + }; + hevc_plugin.set_parameter_string = mockEncoderNoopStr; + hevc_plugin.list_parameters = [](void*) -> const heif_encoder_parameter** { + static const heif_encoder_parameter* kEmpty[] = {nullptr}; + return kEmpty; + }; + hevc_plugin.query_input_colorspace = mockEncoderQueryInputColorspace; + hevc_plugin.query_input_colorspace2 = mockEncoderQueryInputColorspace2; + hevc_plugin.encode_image = mockEncoderEncodeImage; + hevc_plugin.get_compressed_data = mockHevcGetCompressedData; + heif_register_encoder_plugin(&hevc_plugin); + hevc_registered = true; + } + if (!kRealAv1 && !av1_registered) { + av1_plugin = hevc_plugin; + av1_plugin.compression_format = heif_compression_AV1; + av1_plugin.id_name = "uhdr-test-av1"; + av1_plugin.get_plugin_name = []() { return "uhdr-test-av1"; }; + av1_plugin.get_compressed_data = mockAv1GetCompressedData; + heif_register_encoder_plugin(&av1_plugin); + av1_registered = true; + } + g_mock_heif_encoder_enabled = true; + } + + ~ScopedMockHeifEncoders() { g_mock_heif_encoder_enabled = false; } + + bool hadRealHevcEncoder() const { return had_real_hevc_; } + bool hadRealAv1Encoder() const { return had_real_av1_; } + + private: + bool had_real_hevc_ = false; + bool had_real_av1_ = false; +}; + +} // namespace + +class UltraHdrHeifXmpTest : public UltraHdrApiTest {}; + +TEST_F(UltraHdrHeifXmpTest, HeicAndAvifEncodeAndProbeXmpMetadata) { + const bool have_real_hevc_decoder = heif_have_decoder_for_format(heif_compression_HEVC); + const bool have_real_av1_decoder = heif_have_decoder_for_format(heif_compression_AV1); + ScopedMockHeifEncoders mock_encoders; + const bool have_real_hevc_encoder = mock_encoders.hadRealHevcEncoder(); + const bool have_real_av1_encoder = mock_encoders.hadRealAv1Encoder(); + const std::string kXmpPacket = "" "" @@ -3423,23 +3597,207 @@ TEST_F(UltraHdrApiTest, HeifAndAvifXmpDataUnsupported) { "dc:creator=\"test\"/>" "" ""; - uhdr_mem_block_t xmp_block{const_cast(kXmpPacket.data()), kXmpPacket.size(), - kXmpPacket.size()}; + const std::string kApp1Header("http://ns.adobe.com/xap/1.0/\0", 29); + const std::string kApp1PrefixedXmp = kApp1Header + kXmpPacket; + const std::string kLargeXmpPacket = + "" + "" + "" + "" + ""; + + struct XmpTestCase { + const char* name; + std::string input_xmp; + std::string expected_xmp; + bool use_api1; + }; + const std::vector cases = { + {"Api0StandardXmp", kXmpPacket, kXmpPacket, false}, + {"Api1StandardXmp", kXmpPacket, kXmpPacket, true}, + {"Api0App1PrefixedXmp", kApp1PrefixedXmp, kXmpPacket, false}, + {"Api0LargeXmpOver64KB", kLargeXmpPacket, kLargeXmpPacket, false}, + }; for (uhdr_codec_t codec : {UHDR_CODEC_HEIF, UHDR_CODEC_AVIF}) { SCOPED_TRACE(codec == UHDR_CODEC_AVIF ? "AVIF" : "HEIF"); - uhdr_codec_private_t* enc = uhdr_create_encoder(); - ASSERT_NE(enc, nullptr); + const bool can_decode = (codec == UHDR_CODEC_AVIF) + ? (have_real_av1_encoder && have_real_av1_decoder) + : (have_real_hevc_encoder && have_real_hevc_decoder); + for (const auto& tc : cases) { + SCOPED_TRACE(tc.name); + 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); + if (tc.use_api1) { + EXPECT_EQ(uhdr_enc_set_raw_image(enc, &mSdrRaw, UHDR_SDR_IMG).error_code, UHDR_CODEC_OK); + } + EXPECT_EQ(uhdr_enc_set_output_format(enc, codec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t xmp_block{const_cast(tc.input_xmp.data()), tc.input_xmp.size(), + tc.input_xmp.size()}; + EXPECT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + + uhdr_error_info_t err = uhdr_encode(enc); + ASSERT_EQ(err.error_code, UHDR_CODEC_OK) << (err.has_detail ? err.detail : "no detail"); + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + ASSERT_GT(output->data_sz, 0u); + + 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(std::string(static_cast(decoded_xmp->data), decoded_xmp->data_sz), + tc.expected_xmp); + + if (can_decode) { + EXPECT_EQ(uhdr_decode(dec).error_code, UHDR_CODEC_OK); + } - 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, codec).error_code, UHDR_CODEC_OK); - EXPECT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); + } - uhdr_error_info_t err = uhdr_encode(enc); - EXPECT_EQ(err.error_code, UHDR_CODEC_UNSUPPORTED_FEATURE); + // Also verify direct HeifUltraHdr / AvifUltraHdr rejects invalid non-empty nullptr XMP block. + std::vector backing_store(64 * 1024); + uhdr_compressed_image_t dest{}; + dest.data = backing_store.data(); + dest.capacity = backing_store.size(); + uhdr_mem_block_t bad_xmp{nullptr, 16, 16}; + if (codec == UHDR_CODEC_HEIF) { + HeifUltraHdr heif_codec; + EXPECT_EQ(heif_codec.encodeHeicUltraHdr(&mHdrRaw, &dest, 85, nullptr, &bad_xmp).error_code, + UHDR_CODEC_INVALID_PARAM); + } else { + AvifUltraHdr avif_codec; + EXPECT_EQ(avif_codec.encodeAvifUltraHdr(&mHdrRaw, &dest, 85, nullptr, &bad_xmp).error_code, + UHDR_CODEC_INVALID_PARAM); + } + } +} - uhdr_release_encoder(enc); +TEST_F(UltraHdrApiTest, HeifProbeExtractsXmpMetadataFromContainer) { + std::vector hevc_gainmap; + ASSERT_TRUE(loadFile(kHevcGainmapFileName, hevc_gainmap)); + + // Verify uhdr_dec_get_xmp returns nullptr when the HEIC container has no XMP item. + { + uhdr_compressed_image_t input{}; + input.data = hevc_gainmap.data(); + input.data_sz = hevc_gainmap.size(); + input.capacity = hevc_gainmap.size(); + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, &input).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_get_xmp(dec), nullptr); + uhdr_release_decoder(dec); } + + // Attach an XMP ("mime" / "application/rdf+xml") item to the primary image and re-serialize. + const std::string kXmpPacket = + "" + "" + "" + "" + ""; + + HeifContextPtr ctx(heif_context_alloc(), heif_context_free); + ASSERT_NE(ctx, nullptr); + ASSERT_EQ(heif_context_read_from_memory_without_copy(ctx.get(), hevc_gainmap.data(), + hevc_gainmap.size(), nullptr) + .code, + heif_error_Ok); + + heif_image_handle* raw_primary = nullptr; + ASSERT_EQ(heif_context_get_primary_image_handle(ctx.get(), &raw_primary).code, heif_error_Ok); + HeifHandlePtr primary(raw_primary, heif_image_handle_release); + ASSERT_NE(primary, nullptr); + + ASSERT_EQ(heif_context_add_XMP_metadata(ctx.get(), primary.get(), kXmpPacket.data(), + static_cast(kXmpPacket.size())) + .code, + heif_error_Ok); + + std::vector hevc_with_xmp; + heif_writer writer{}; + writer.writer_api_version = 1; + writer.write = [](heif_context*, const void* data, size_t size, void* userdata) -> heif_error { + auto* out = static_cast*>(userdata); + const auto* bytes = static_cast(data); + out->insert(out->end(), bytes, bytes + size); + return {heif_error_Ok, heif_suberror_Unspecified, ""}; + }; + ASSERT_EQ(heif_context_write(ctx.get(), &writer, &hevc_with_xmp).code, heif_error_Ok); + ASSERT_GT(hevc_with_xmp.size(), kXmpPacket.size() + 8); + + // libheif's writer only serializes in-memory item extents (the newly added XMP item) into mdat + // while accounting for all item lengths in the mdat box header and iloc table. Splice the + // original mdat payload bytes from the fixture and update the running iloc base_offsets so all + // items resolve in the serialized container without requiring an HEVC encoder plugin. + const std::string kMdatFourcc("mdat", 4); + const auto orig_mdat_it = + std::search(hevc_gainmap.begin(), hevc_gainmap.end(), kMdatFourcc.begin(), kMdatFourcc.end()); + ASSERT_NE(orig_mdat_it, hevc_gainmap.end()); + const size_t orig_mdat_payload_offset = + static_cast(std::distance(hevc_gainmap.begin(), orig_mdat_it)) + 4; + ASSERT_LT(orig_mdat_payload_offset, hevc_gainmap.size()); + + const size_t new_mdat_payload_offset = hevc_with_xmp.size() - kXmpPacket.size(); + hevc_with_xmp.insert(hevc_with_xmp.begin() + static_cast(new_mdat_payload_offset), + hevc_gainmap.begin() + static_cast(orig_mdat_payload_offset), + hevc_gainmap.end()); + + const std::string kIlocFourcc("iloc", 4); + const auto iloc_it = + std::search(hevc_with_xmp.begin(), hevc_with_xmp.end(), kIlocFourcc.begin(), kIlocFourcc.end()); + ASSERT_NE(iloc_it, hevc_with_xmp.end()); + const size_t iloc_offset = static_cast(std::distance(hevc_with_xmp.begin(), iloc_it)) - 4; + const uint16_t item_count = + (static_cast(hevc_with_xmp[iloc_offset + 14]) << 8) | + static_cast(hevc_with_xmp[iloc_offset + 15]); + ASSERT_EQ(item_count, 4u); + uint32_t running_offset = static_cast(new_mdat_payload_offset); + for (uint16_t i = 0; i < item_count; ++i) { + const size_t entry_offset = iloc_offset + 16 + static_cast(i) * 18; + hevc_with_xmp[entry_offset + 4] = static_cast((running_offset >> 24) & 0xFF); + hevc_with_xmp[entry_offset + 5] = static_cast((running_offset >> 16) & 0xFF); + hevc_with_xmp[entry_offset + 6] = static_cast((running_offset >> 8) & 0xFF); + hevc_with_xmp[entry_offset + 7] = static_cast(running_offset & 0xFF); + const uint32_t item_len = + (static_cast(hevc_with_xmp[entry_offset + 14]) << 24) | + (static_cast(hevc_with_xmp[entry_offset + 15]) << 16) | + (static_cast(hevc_with_xmp[entry_offset + 16]) << 8) | + static_cast(hevc_with_xmp[entry_offset + 17]); + running_offset += item_len; + } + + uhdr_compressed_image_t input_with_xmp{}; + input_with_xmp.data = hevc_with_xmp.data(); + input_with_xmp.data_sz = hevc_with_xmp.size(); + input_with_xmp.capacity = hevc_with_xmp.size(); + + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, &input_with_xmp).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(std::string(static_cast(decoded_xmp->data), decoded_xmp->data_sz), + kXmpPacket); + + uhdr_release_decoder(dec); } TEST_F(UltraHdrApiTest, AvifPreservesRawAlpha) { diff --git a/ultrahdr_api.h b/ultrahdr_api.h index afd053a7..04b2bc67 100644 --- a/ultrahdr_api.h +++ b/ultrahdr_api.h @@ -411,12 +411,7 @@ UHDR_EXTERN uhdr_error_info_t uhdr_enc_set_exif_data(uhdr_codec_private_t* enc, /*!\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). - * - * NOTE: XMP metadata insertion is currently supported only when the output compression format is - * #UHDR_CODEC_JPG. Attempting to encode with XMP data configured when the output format is - * #UHDR_CODEC_HEIF or #UHDR_CODEC_AVIF will cause uhdr_encode() to return - * #UHDR_CODEC_UNSUPPORTED_FEATURE. + * into the bitstream (or merges with gain map metadata in xmp mode for JPEG output). * * \param[in] enc encoder instance * \param[in] xmp xmp data memory block. From fa2046629ebc5872e251c53c488abc440c10dfd2 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 16 Oct 2026 21:03:01 +0000 Subject: [PATCH 16/20] fix(xmp): normalize uhdr_dec_get_xmp to bare XML --- lib/src/ultrahdr_api.cpp | 15 +++++++++++++++ tests/ultrahdr_api_test.cpp | 3 +++ 2 files changed, 18 insertions(+) diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 5121d427..7ff27d04 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -2006,6 +2006,19 @@ uhdr_error_info_t uhdr_dec_set_out_max_display_boost(uhdr_codec_private_t* dec, return status; } +static void stripXmpApp1Header(std::vector& xmp_buf) { + constexpr char kXmpHeader[] = "http://ns.adobe.com/xap/1.0/"; + constexpr size_t kXmpHeaderLen = sizeof(kXmpHeader) - 1; + if (xmp_buf.size() >= kXmpHeaderLen && + memcmp(xmp_buf.data(), kXmpHeader, kXmpHeaderLen) == 0) { + size_t offset = kXmpHeaderLen; + if (offset < xmp_buf.size() && xmp_buf[offset] == '\0') { + ++offset; + } + xmp_buf.erase(xmp_buf.begin(), xmp_buf.begin() + static_cast(offset)); + } +} + uhdr_error_info_t uhdr_dec_probe(uhdr_codec_private_t* dec) { if (dynamic_cast(dec) == nullptr) { uhdr_error_info_t status; @@ -2100,6 +2113,7 @@ uhdr_error_info_t uhdr_dec_probe(uhdr_codec_private_t* dec) { heif_error xmp_err = heif_image_handle_get_metadata( base_handle, mime_ids[i], xmp_buf.data()); if (xmp_err.code == heif_error_Ok) { + stripXmpApp1Header(xmp_buf); handle->m_xmp = std::move(xmp_buf); handle->m_xmp_block.data = handle->m_xmp.data(); handle->m_xmp_block.data_sz = handle->m_xmp_block.capacity = @@ -2168,6 +2182,7 @@ uhdr_error_info_t uhdr_dec_probe(uhdr_codec_private_t* dec) { 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); + stripXmpApp1Header(handle->m_xmp); 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); diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 43de1ee0..0f3d7be8 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -2407,6 +2407,9 @@ TEST_F(UltraHdrApiTest, JpegEncodeWithXmpAndDecode) { std::string decoded_xmp_str(static_cast(decoded_xmp->data), decoded_xmp->data_sz); + // Verify uhdr_dec_get_xmp returns bare XML without the 29-byte JPEG APP1 header prefix + EXPECT_EQ(decoded_xmp_str.find("http://ns.adobe.com/xap/1.0/"), std::string::npos); + // 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); From b78ec525be004743e8b4dadf9bb4de475c3dfa10 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 16 Oct 2026 21:05:14 +0000 Subject: [PATCH 17/20] fix(heif): strip gainmap XMP when encoding XMP --- lib/src/avifultrahdr.cpp | 9 ++++++++ lib/src/heifultrahdr.cpp | 9 ++++++++ tests/ultrahdr_api_test.cpp | 44 +++++++++++++++++++++++++++++++++---- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/lib/src/avifultrahdr.cpp b/lib/src/avifultrahdr.cpp index d4ae1609..40d1793f 100644 --- a/lib/src/avifultrahdr.cpp +++ b/lib/src/avifultrahdr.cpp @@ -25,6 +25,7 @@ #include "ultrahdr/avifultrahdr.h" #include "ultrahdr/gainmapmath.h" #include "ultrahdr/gainmapmetadata.h" +#include "ultrahdr/jpegrutils.h" #define UHDR_CG_BT_601 static_cast(3) @@ -554,6 +555,14 @@ uhdr_error_info_t AvifUltraHdr::encodeAvifUltraHdr(uhdr_raw_image_t* sdr_intent, "xmp size %zu exceeds maximum supported size", xmp_size); goto CleanUp; } + std::string cleaned_xmp; + if (xmp_data != nullptr && xmp_size > 0) { + std::string raw_xmp(xmp_data, xmp_size); + if (stripGainMapFromXmp(raw_xmp, &cleaned_xmp)) { + xmp_data = cleaned_xmp.data(); + xmp_size = cleaned_xmp.size(); + } + } if (xmp_data != nullptr && xmp_size > 0) { HEIF_ERR_CHECK( heif_context_add_XMP_metadata(ctx, baseHandle, xmp_data, static_cast(xmp_size))); diff --git a/lib/src/heifultrahdr.cpp b/lib/src/heifultrahdr.cpp index 52cbe42c..1a820bf2 100644 --- a/lib/src/heifultrahdr.cpp +++ b/lib/src/heifultrahdr.cpp @@ -25,6 +25,7 @@ #include "ultrahdr/heifultrahdr.h" #include "ultrahdr/gainmapmath.h" #include "ultrahdr/gainmapmetadata.h" +#include "ultrahdr/jpegrutils.h" #define UHDR_CG_BT_601 static_cast(3) @@ -554,6 +555,14 @@ uhdr_error_info_t HeifUltraHdr::encodeHeicUltraHdr(uhdr_raw_image_t* sdr_intent, "xmp size %zu exceeds maximum supported size", xmp_size); goto CleanUp; } + std::string cleaned_xmp; + if (xmp_data != nullptr && xmp_size > 0) { + std::string raw_xmp(xmp_data, xmp_size); + if (stripGainMapFromXmp(raw_xmp, &cleaned_xmp)) { + xmp_data = cleaned_xmp.data(); + xmp_size = cleaned_xmp.size(); + } + } if (xmp_data != nullptr && xmp_size > 0) { HEIF_ERR_CHECK( heif_context_add_XMP_metadata(ctx, baseHandle, xmp_data, static_cast(xmp_size))); diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 0f3d7be8..72a117ff 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -3611,6 +3611,36 @@ TEST_F(UltraHdrHeifXmpTest, HeicAndAvifEncodeAndProbeXmpMetadata) { "\"/>" "" ""; + const std::string kMixedGainMapAndUserXmp = + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; + const std::string kGainMapOnlyXmp = + "" + "" + "" + "" + ""; struct XmpTestCase { const char* name; @@ -3623,6 +3653,8 @@ TEST_F(UltraHdrHeifXmpTest, HeicAndAvifEncodeAndProbeXmpMetadata) { {"Api1StandardXmp", kXmpPacket, kXmpPacket, true}, {"Api0App1PrefixedXmp", kApp1PrefixedXmp, kXmpPacket, false}, {"Api0LargeXmpOver64KB", kLargeXmpPacket, kLargeXmpPacket, false}, + {"Api0StripsJpegGainMapXmpKeepsUserXmp", kMixedGainMapAndUserXmp, kXmpPacket, false}, + {"Api0GainMapOnlyXmpOmitted", kGainMapOnlyXmp, "", false}, }; for (uhdr_codec_t codec : {UHDR_CODEC_HEIF, UHDR_CODEC_AVIF}) { @@ -3656,10 +3688,14 @@ TEST_F(UltraHdrHeifXmpTest, HeicAndAvifEncodeAndProbeXmpMetadata) { 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(std::string(static_cast(decoded_xmp->data), decoded_xmp->data_sz), - tc.expected_xmp); + if (tc.expected_xmp.empty()) { + EXPECT_EQ(decoded_xmp, nullptr); + } else { + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + EXPECT_EQ(std::string(static_cast(decoded_xmp->data), decoded_xmp->data_sz), + tc.expected_xmp); + } if (can_decode) { EXPECT_EQ(uhdr_decode(dec).error_code, UHDR_CODEC_OK); From f0e4619902fd0b0d8f84b15af72790283e1fc3eb Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 16 Oct 2026 21:08:26 +0000 Subject: [PATCH 18/20] fix(heif): skip empty XMP and propagate read err --- lib/src/ultrahdr_api.cpp | 6 +++++- tests/ultrahdr_api_test.cpp | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 7ff27d04..2b11ade6 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -2118,9 +2118,13 @@ uhdr_error_info_t uhdr_dec_probe(uhdr_codec_private_t* dec) { handle->m_xmp_block.data = handle->m_xmp.data(); handle->m_xmp_block.data_sz = handle->m_xmp_block.capacity = handle->m_xmp.size(); + } else { + status.error_code = UHDR_CODEC_ERROR; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "%s", xmp_err.message); } + break; } - break; } } } diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 72a117ff..bea3f135 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -3762,6 +3762,9 @@ TEST_F(UltraHdrApiTest, HeifProbeExtractsXmpMetadataFromContainer) { HeifHandlePtr primary(raw_primary, heif_image_handle_release); ASSERT_NE(primary, nullptr); + // Attach an empty XMP item followed by the valid XMP ("mime" / "application/rdf+xml") item to + // verify probe skips empty "application/rdf+xml" blocks and extracts the non-empty XMP packet. + ASSERT_EQ(heif_context_add_XMP_metadata(ctx.get(), primary.get(), "", 0).code, heif_error_Ok); ASSERT_EQ(heif_context_add_XMP_metadata(ctx.get(), primary.get(), kXmpPacket.data(), static_cast(kXmpPacket.size())) .code, @@ -3804,7 +3807,7 @@ TEST_F(UltraHdrApiTest, HeifProbeExtractsXmpMetadataFromContainer) { const uint16_t item_count = (static_cast(hevc_with_xmp[iloc_offset + 14]) << 8) | static_cast(hevc_with_xmp[iloc_offset + 15]); - ASSERT_EQ(item_count, 4u); + ASSERT_EQ(item_count, 5u); uint32_t running_offset = static_cast(new_mdat_payload_offset); for (uint16_t i = 0; i < item_count; ++i) { const size_t entry_offset = iloc_offset + 16 + static_cast(i) * 18; From 491b4444aa366ff61ba79bbfdfed903b59addec4 Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 16 Oct 2026 21:10:11 +0000 Subject: [PATCH 19/20] feat(heif): extract EXIF and ICC in uhdr_dec_probe --- lib/src/ultrahdr_api.cpp | 38 +++++++++++++++++++++++++++++++++++++ tests/ultrahdr_api_test.cpp | 15 +++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 2b11ade6..0a8487a4 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -2095,6 +2095,44 @@ uhdr_error_info_t uhdr_dec_probe(uhdr_codec_private_t* dec) { handle->m_metadata.hdr_capacity_max = metadata.hdr_capacity_max; handle->m_metadata.use_base_cg = metadata.use_base_cg; + const int num_exif_blocks = + heif_image_handle_get_number_of_metadata_blocks(base_handle, "Exif"); + if (num_exif_blocks > 0) { + std::vector exif_ids(num_exif_blocks); + const int fetched_exif = heif_image_handle_get_list_of_metadata_block_IDs( + base_handle, "Exif", exif_ids.data(), num_exif_blocks); + for (int i = 0; i < fetched_exif; ++i) { + const size_t exif_size = + heif_image_handle_get_metadata_size(base_handle, exif_ids[i]); + if (exif_size > 4) { + std::vector exif_buf(exif_size); + heif_error exif_err = heif_image_handle_get_metadata( + base_handle, exif_ids[i], exif_buf.data()); + if (exif_err.code == heif_error_Ok) { + handle->m_exif.assign(exif_buf.begin() + 4, exif_buf.end()); + handle->m_exif_block.data = handle->m_exif.data(); + handle->m_exif_block.data_sz = handle->m_exif_block.capacity = + handle->m_exif.size(); + } + break; + } + } + } + + const size_t icc_size = + heif_image_handle_get_raw_color_profile_size(base_handle); + if (icc_size > 0) { + std::vector icc_buf(icc_size); + heif_error icc_err = + heif_image_handle_get_raw_color_profile(base_handle, icc_buf.data()); + if (icc_err.code == heif_error_Ok) { + handle->m_icc = std::move(icc_buf); + handle->m_icc_block.data = handle->m_icc.data(); + handle->m_icc_block.data_sz = handle->m_icc_block.capacity = + handle->m_icc.size(); + } + } + const int num_mime_blocks = heif_image_handle_get_number_of_metadata_blocks(base_handle, "mime"); if (num_mime_blocks > 0) { diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index bea3f135..0e4796e5 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -3672,6 +3672,10 @@ TEST_F(UltraHdrHeifXmpTest, HeicAndAvifEncodeAndProbeXmpMetadata) { EXPECT_EQ(uhdr_enc_set_raw_image(enc, &mSdrRaw, UHDR_SDR_IMG).error_code, UHDR_CODEC_OK); } EXPECT_EQ(uhdr_enc_set_output_format(enc, codec).error_code, UHDR_CODEC_OK); + const std::string kExifPayload("Exif\0\0II*\0\x08\0\0\0\0\0", 16); + uhdr_mem_block_t exif_block{const_cast(kExifPayload.data()), kExifPayload.size(), + kExifPayload.size()}; + EXPECT_EQ(uhdr_enc_set_exif_data(enc, &exif_block).error_code, UHDR_CODEC_OK); uhdr_mem_block_t xmp_block{const_cast(tc.input_xmp.data()), tc.input_xmp.size(), tc.input_xmp.size()}; EXPECT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); @@ -3687,6 +3691,17 @@ TEST_F(UltraHdrHeifXmpTest, HeicAndAvifEncodeAndProbeXmpMetadata) { 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_exif = uhdr_dec_get_exif(dec); + ASSERT_NE(decoded_exif, nullptr); + ASSERT_NE(decoded_exif->data, nullptr); + EXPECT_EQ(std::string(static_cast(decoded_exif->data), decoded_exif->data_sz), + kExifPayload); + + uhdr_mem_block_t* decoded_icc = uhdr_dec_get_icc(dec); + ASSERT_NE(decoded_icc, nullptr); + EXPECT_NE(decoded_icc->data, nullptr); + EXPECT_GT(decoded_icc->data_sz, 0u); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); if (tc.expected_xmp.empty()) { EXPECT_EQ(decoded_xmp, nullptr); From 1b7639e784f1ba45344b596c1a00d79dadf87c8f Mon Sep 17 00:00:00 2001 From: Dichen Zhang Date: Fri, 16 Oct 2026 21:10:50 +0000 Subject: [PATCH 20/20] test(fuzzer): fuzz uhdr_enc_set_xmp_data --- fuzzer/ultrahdr_enc_fuzzer.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/fuzzer/ultrahdr_enc_fuzzer.cpp b/fuzzer/ultrahdr_enc_fuzzer.cpp index 71e3576f..f2f253b3 100644 --- a/fuzzer/ultrahdr_enc_fuzzer.cpp +++ b/fuzzer/ultrahdr_enc_fuzzer.cpp @@ -194,6 +194,15 @@ void UltraHdrEncFuzzer::process() { uhdr_mem_block_t exif{greeting, mFdp.ConsumeIntegralInRange(0, sizeof greeting * 2), sizeof greeting}; + // xmp + std::string xmp_str = mFdp.ConsumeRandomLengthString(128 * 1024); + uhdr_mem_block_t xmp{xmp_str.empty() ? nullptr : const_cast(xmp_str.data()), + xmp_str.size(), xmp_str.size()}; + + // output format + uhdr_codec_t output_codec = + mFdp.PickValueInArray({UHDR_CODEC_JPG, UHDR_CODEC_HEIF, UHDR_CODEC_AVIF}); + ALOGV("encoding configuration options : "); ALOGV("encoding api - %d ", (int)muxSwitch); ALOGV("image dimensions %d x %d ", (int)width, (int)height); @@ -376,6 +385,10 @@ void UltraHdrEncFuzzer::process() { ON_ERR(uhdr_enc_set_quality(enc_handle, base_quality, UHDR_BASE_IMG)) ON_ERR(uhdr_enc_set_quality(enc_handle, gainmap_quality, UHDR_GAIN_MAP_IMG)) ON_ERR(uhdr_enc_set_exif_data(enc_handle, &exif)) + if (mFdp.ConsumeBool()) { + ON_ERR(uhdr_enc_set_xmp_data(enc_handle, &xmp)) + } + ON_ERR(uhdr_enc_set_output_format(enc_handle, output_codec)) ON_ERR(uhdr_enc_set_using_multi_channel_gainmap(enc_handle, multi_channel_gainmap)) ON_ERR(uhdr_enc_set_gainmap_scale_factor(enc_handle, gm_scale_factor)) ON_ERR(uhdr_enc_set_gainmap_gamma(enc_handle, gamma[0])) @@ -445,6 +458,8 @@ void UltraHdrEncFuzzer::process() { else ON_ERR(uhdr_dec_set_out_img_format(dec_handle, UHDR_IMG_FMT_32bppRGBA1010102)) ON_ERR(uhdr_decode(dec_handle)) + uhdr_dec_get_exif(dec_handle); + uhdr_dec_get_icc(dec_handle); uhdr_dec_get_xmp(dec_handle); uhdr_release_decoder(dec_handle); }