diff --git a/CMakeLists.txt b/CMakeLists.txt index b5f748e9..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) @@ -752,6 +753,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/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/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/fuzzer/ultrahdr_dec_fuzzer.cpp b/fuzzer/ultrahdr_dec_fuzzer.cpp index 33c93144..f24f96e9 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..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])) @@ -427,6 +440,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 +458,9 @@ 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); } } 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/include/ultrahdr/jpegr.h b/lib/include/ultrahdr/jpegr.h index 72d03121..9cf0813b 100644 --- a/lib/include/ultrahdr/jpegr.h +++ b/lib/include/ultrahdr/jpegr.h @@ -22,6 +22,17 @@ 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; + +// 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 */ @@ -78,7 +89,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 +107,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 +132,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 +156,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 +178,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. * @@ -280,6 +304,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: uhdr_error_info_t getJPEGRInfo(uhdr_compressed_image_t* uhdr_compressed_img, jr_info_ptr uhdr_image_info, bool copy_image_data, @@ -343,14 +377,18 @@ 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, 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..2a7127df 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. * @@ -124,10 +134,38 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t * * * @param secondary_image_length length of secondary image - * @return XMP metadata in type of string + * @param user_xmp optional XMP packet from the primary image. Its unrelated XML is preserved while + * the encoder-owned primary-image gain-map description is replaced. + * @return XMP metadata in type of string. An empty string means that a supplied packet could not be + * safely merged. */ std::string generateXmpForPrimaryImage(size_t secondary_image_length, - uhdr_gainmap_metadata_ext_t& metadata); + 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); + +/* 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); + +/* 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. 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/avifultrahdr.cpp b/lib/src/avifultrahdr.cpp index 779e95c1..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) @@ -341,8 +342,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 +397,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 +428,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 +437,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 +528,46 @@ 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; + } + 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))); + } + } // encode the gain map image if (isUsingMultiChannelGainMap()) { diff --git a/lib/src/heifultrahdr.cpp b/lib/src/heifultrahdr.cpp index 0bb9a421..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) @@ -341,8 +342,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 +397,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 +428,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 +437,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 +528,46 @@ 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; + } + 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))); + } + } // encode the gain map image if (isUsingMultiChannelGainMap()) { diff --git a/lib/src/jpegdecoderhelper.cpp b/lib/src/jpegdecoderhelper.cpp index 7132d600..7b8a8b1f 100644 --- a/lib/src/jpegdecoderhelper.cpp +++ b/lib/src/jpegdecoderhelper.cpp @@ -11,11 +11,13 @@ #include #include +#include #include #include #include #include "ultrahdr/ultrahdrcommon.h" +#include "ultrahdr/jpegrutils.h" #include "ultrahdr/jpegdecoderhelper.h" using namespace std; @@ -140,6 +142,94 @@ 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 + + struct ExtXmpChunk { + uint32_t offset; + const JOCTET* data; + size_t size; + }; + + uint32_t total_length = 0; + 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); + if (memcmp(guid_ptr, expected_guid.data(), 32) != 0) { + 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_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; + } else if (seg_total_len != total_length) { + return; + } + + 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 || 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 // by design and is not a reliable source of byte offsets when other segments precede EXIF. static long find_marker_payload_offset(const uint8_t* image, size_t length, uint8_t marker_code, @@ -175,7 +265,6 @@ static long find_marker_payload_offset(const uint8_t* image, size_t length, uint } return -1; } - static uhdr_img_fmt_t getOutputSamplingFormat(const j_decompress_ptr cinfo) { if (cinfo->num_components == 1) return UHDR_IMG_FMT_8bppYCbCr400; @@ -294,6 +383,34 @@ uhdr_error_info_t JpegDecoderHelper::decode(const void* image, size_t length, de sizeof kIsoMetadataNameSpace / sizeof kIsoMetadataNameSpace[0], mIsoMetadataBuffer, payloadOffset); + // 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); + } + } + } + } + } + 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 ad286900..e1afedd0 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" @@ -60,6 +63,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, @@ -183,7 +187,8 @@ static bool 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; @@ -245,14 +250,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; @@ -292,14 +298,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)); @@ -326,13 +333,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, @@ -371,8 +379,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; } @@ -387,14 +395,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; @@ -419,7 +428,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) { @@ -433,7 +442,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; @@ -1112,7 +1121,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; @@ -1229,6 +1239,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 @@ -1262,17 +1284,116 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr } // Prepare and write XMP + std::string xmp_primary_str; + 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) { + offset++; + } + user_xmp_str = std::string(static_cast(pXmp->data) + offset, + pXmp->data_sz - offset); + } else { + user_xmp_str = std::string(static_cast(pXmp->data), pXmp->data_sz); + } + } + + bool spill_user_xmp_to_extended = false; 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)); + 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); + 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; + } + 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 (!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); + 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 @@ -3073,4 +3194,499 @@ 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) { + 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.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; + 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; + 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 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; + 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()); + 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); + } + 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; + } + + 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; + } + 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 3f064ade..9c0565ab 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"; @@ -873,11 +877,123 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t return uhdr_validate_gainmap_metadata_descriptor(metadata); } -string generateXmpForPrimaryImage(size_t secondary_image_length, - uhdr_gainmap_metadata_ext_t& metadata) { - const vector kConDirSeq({kConDirectory, string("rdf:Seq")}); - const vector kLiItem({string("rdf:li"), kConItem}); +/* + * 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"); @@ -886,29 +1002,962 @@ string generateXmpForPrimaryImage(size_t secondary_image_length, 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); - writer.WriteAttributeNameAndValue(kMapVersion, metadata.version); + writer.WriteXmlns("xmpNote", "http://ns.adobe.com/xmp/note/"); + writer.WriteAttributeNameAndValue("xmpNote:HasExtendedXMP", guid); + writer.FinishWriting(); + return ss.str(); +} +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"; - writer.StartWritingElements(kConDirSeq); +bool IsXmlWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} - 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); +string TrimTrailingNulls(const string& xmp) { + size_t end = xmp.size(); + while (end > 0 && xmp[end - 1] == '\0') --end; + return xmp.substr(0, end); +} - 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); +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; +} - writer.FinishWriting(); +struct XmpAttributeSpan { + string qualified_name; + string value; + string uri; + string local_name; + size_t name_begin = 0; + size_t value_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_begin = context.GetTokenRange().GetBegin() + 1; + 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 = kXmlNamespaceUri; + 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; + } + } + namespace_declarations.push_back({prefix, attribute.value}); + } + 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; +} + +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")}); + 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", 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(); +} + +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(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 = 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) { + 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; + + 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; + } + packet->all_descriptions.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)) { + packet->primary_descriptions.push_back(index); + } + } + packet->rdf_root = rdf_root; + return true; +} + +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; + 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; + } + + const string clean_xmp = TrimTrailingNulls(xmp_data); + if (clean_xmp.empty()) { + if (rewritten_xmp != nullptr) rewritten_xmp->clear(); + return true; + } + + ParsedXmpPacket pkt; + if (!ParseXmpPacket(clean_xmp, &pkt)) return false; + + const vector& elements = pkt.elements(); + vector> edits; + string found_guid; + 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) || + (!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(pkt.parse_xml[remove_begin - 1])) { + --remove_begin; + } + 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) { + 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(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(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(pkt.parse_xml[remove_begin - 1])) { + --remove_begin; + } + 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 || edits.empty() || *replacement_guid == found_guid) { + if (rewritten_xmp != nullptr) *rewritten_xmp = clean_xmp; + return true; + } + + 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) { + if (edits[index].first < edits[index - 1].second) return false; + } + + string rewritten; + size_t cursor = 0; + for (const auto& [begin, end] : edits) { + rewritten.append(clean_xmp, cursor, begin - cursor); + if (!replacement_guid->empty()) rewritten.append(*replacement_guid); + cursor = end; + } + rewritten.append(clean_xmp, cursor, clean_xmp.size() - cursor); + *rewritten_xmp = std::move(rewritten); + return true; +} + +string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length, + uhdr_gainmap_metadata_ext_t& metadata, + const string& extended_xmp_guid) { + 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 : pkt.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); + } + } + } + + 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(); + } + + 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) { + 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(ss.str()); + merged.push_back('\n'); + merged.append(existing_xmp, insertion, existing_xmp.size() - insertion); + return merged; +} + +} // 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; + const string clean_xmp = TrimTrailingNulls(xmp_data); + if (clean_xmp.empty()) { + stripped_xmp->clear(); + return true; + } + + ParsedXmpPacket pkt; + if (!ParseXmpPacket(clean_xmp, &pkt)) return false; + + const vector& elements = pkt.elements(); + vector> removals; + size_t removed_descriptions = 0; + + for (const size_t description : pkt.primary_descriptions) { + const XmpElementSpan& primary = elements[description]; + bool has_removable = false; + bool has_user = false; + ClassifyPrimaryDescription(elements, description, &has_removable, &has_user); + if (!has_removable) continue; + + if (!has_user) { + ++removed_descriptions; + size_t remove_end = primary.end_end; + 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); + continue; + } + + for (const XmpAttributeSpan& attribute : primary.attributes) { + // 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(pkt.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 && + (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 < pkt.parse_xml.size() && pkt.parse_xml[child_end] == '\r' && + pkt.parse_xml[child_end + 1] == '\n') { + child_end += 2; + } else if (child_end < pkt.parse_xml.size() && pkt.parse_xml[child_end] == '\n') { + child_end += 1; + } + removals.emplace_back(child_begin, child_end); + } + } + } + + 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& [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; + 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; +} + +bool mergeStandardAndExtendedXmp(const std::string& standard_xmp, const std::string& extended_xmp, + std::string* merged_xmp) { + if (merged_xmp == nullptr) return false; + const string clean_ext = TrimTrailingNulls(extended_xmp); + + string std_without_guid; + string std_user_only; + if (!replaceExtendedXmpGuidInXmp(standard_xmp, "", &std_without_guid) || + !stripGainMapFromXmp(std_without_guid, &std_user_only)) { + return false; + } + if (std_user_only.empty()) { + *merged_xmp = clean_ext; + return true; + } + + ParsedXmpPacket std_pkt; + if (!ParseXmpPacket(std_user_only, &std_pkt)) return false; + + const vector& std_elements = std_pkt.elements(); + vector retained_descriptions; + for (size_t desc_idx : std_pkt.all_descriptions) { + const bool is_primary = + 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()) { + *merged_xmp = clean_ext; + return true; + } + if (clean_ext.empty()) { + *merged_xmp = std_user_only; + return true; + } + + 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_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. + 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 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; + }; + 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_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); + } + } + } + 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_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_pkt.parse_xml, desc.start_begin, &desc_open_end) || + desc.end_end < desc.start_begin) { + return false; + } + string extra_attrs; + 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 (!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 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); + } + } + 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_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_pkt.parse_xml[insert_at - 1])) { + --insert_at; + } + if (insert_at > desc.start_begin && std_pkt.parse_xml[insert_at - 1] == '/') { + --insert_at; + } + 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_pkt.parse_xml, insert_at, desc.end_end - insert_at); + } + } + + 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); + 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, + const std::string& extended_xmp_guid) { + 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); + } + return MergePrimaryXmp(existing_xmp, secondary_image_length, metadata, extended_xmp_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", kRdfUri); + WritePrimaryDescriptionElement(&writer, false, secondary_image_length, metadata, + extended_xmp_guid); return ss.str(); } diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index bbd3f699..0a8487a4 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -1389,6 +1389,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; @@ -1502,6 +1541,38 @@ 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(); + } + + 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"); + }; + 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, @@ -1515,6 +1586,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); @@ -1522,11 +1597,16 @@ 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; 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); @@ -1535,14 +1615,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; @@ -1551,13 +1633,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 { @@ -1584,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()) { @@ -1604,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; @@ -1633,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()) { @@ -1653,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; @@ -1704,6 +1802,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; @@ -1907,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; @@ -1982,6 +2094,78 @@ 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_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) { + 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) { + 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 = + 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; + } + } + } + } } } } else { @@ -2039,6 +2223,10 @@ 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); + 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); handle->m_base_img_block.data = handle->m_base_img.data(); handle->m_base_img_block.data_sz = handle->m_base_img_block.capacity = @@ -2130,6 +2318,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; @@ -2328,6 +2529,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(); @@ -2484,3 +2687,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 f8b9d213..0e4796e5 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -7,17 +7,24 @@ #else #include #endif +#include #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/gainmapmath.h" #include "ultrahdr/gainmapmetadata.h" #include "ultrahdr/heifultrahdr.h" @@ -25,6 +32,10 @@ #if defined(UHDR_ENABLE_HEIF) #include "libheif/heif.h" #endif +#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 { @@ -35,6 +46,350 @@ static const char* kHevcGainmapFileName = "gainmap_hevc_16x16.heic"; 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 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 {}; +} + +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; + 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(); + 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, @@ -754,6 +1109,1547 @@ 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); + + // 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); + 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 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(&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(), 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, 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, 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 = + "" + "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 = + "" + ""; + + 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); + 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, 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); + + 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 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); + + // 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); +} + +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, 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; + 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(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); +} + // ============================================================================ // HEIF / HEIC Tests (API-0, API-1, and Unsupported APIs) // ============================================================================ @@ -1522,6 +3418,445 @@ TEST_F(UltraHdrApiTest, AvifCompressedIntentsUnsupported) { uhdr_release_encoder(enc); } +} // 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 = + "" + "" + "" + "" + ""; + const std::string kApp1Header("http://ns.adobe.com/xap/1.0/\0", 29); + const std::string kApp1PrefixedXmp = kApp1Header + kXmpPacket; + const std::string kLargeXmpPacket = + "" + "" + "" + "" + ""; + const std::string kMixedGainMapAndUserXmp = + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; + const std::string kGainMapOnlyXmp = + "" + "" + "" + "" + ""; + + 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}, + {"Api0StripsJpegGainMapXmpKeepsUserXmp", kMixedGainMapAndUserXmp, kXmpPacket, false}, + {"Api0GainMapOnlyXmpOmitted", kGainMapOnlyXmp, "", false}, + }; + + for (uhdr_codec_t codec : {UHDR_CODEC_HEIF, UHDR_CODEC_AVIF}) { + SCOPED_TRACE(codec == UHDR_CODEC_AVIF ? "AVIF" : "HEIF"); + 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); + 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); + + 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_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); + } 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); + } + + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); + } + + // 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); + } + } +} + +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); + + // 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, + 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, 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; + 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) { AlphaTestImages images; std::vector encoded; diff --git a/ultrahdr_api.h b/ultrahdr_api.h index 4a112359..04b2bc67 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) @@ -404,6 +409,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 for JPEG output). + * + * \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. @@ -783,6 +801,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. @@ -938,4 +964,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