From b9de9c30719f8d4bc017b514d3bf79b00aba13e0 Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Mon, 5 Oct 2026 22:37:56 -0500 Subject: [PATCH] Strip gain-map metadata from linked Extended XMP Clean linked extension packets while preserving user metadata, updating the digest and standard XMP link, and removing empty packets. Validate linked chunk coverage before writing output and cover preservation and malformed input with regression tests. --- lib/include/ultrahdr/jpegrutils.h | 6 + lib/src/jpegr.cpp | 243 +++++++++++++++++++++++++ lib/src/jpegrutils.cpp | 141 +++++++++++++++ tests/ultrahdr_api_test.cpp | 291 +++++++++++++++++++++++++++++- 4 files changed, 675 insertions(+), 6 deletions(-) diff --git a/lib/include/ultrahdr/jpegrutils.h b/lib/include/ultrahdr/jpegrutils.h index 4fc66799..781f102d 100644 --- a/lib/include/ultrahdr/jpegrutils.h +++ b/lib/include/ultrahdr/jpegrutils.h @@ -155,6 +155,12 @@ std::string generateXmpForPrimaryImage(size_t secondary_image_length, */ bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp); +/* Reads or updates xmpNote:HasExtendedXMP on primary RDF descriptions. An empty replacement + * removes the link; unrelated RDF subjects and packet content are preserved. */ +bool getExtendedXmpGuidFromXmp(const std::string& xmp_data, std::string* guid); +bool replaceExtendedXmpGuidInXmp(const std::string& xmp_data, const std::string& new_guid, + std::string* rewritten_xmp); + /* * This method generates XMP metadata for the recovery map image. * Link: https://developer.android.com/media/platform/hdr-image-format#XMP-attributes diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index fe5efa3b..a90e05c8 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -20,11 +20,14 @@ #include #include #include +#include +#include #include "ultrahdr/editorhelper.h" #include "ultrahdr/gainmapmetadata.h" #include "ultrahdr/ultrahdrcommon.h" #include "ultrahdr/jpegr.h" +#include "ultrahdr/jpegrutils.h" #include "ultrahdr/icc.h" #include "ultrahdr/multipictureformat.h" @@ -3018,6 +3021,224 @@ status_t JpegR::decodeJPEGR(jr_compressed_ptr jpegr_image_ptr, jr_uncompressed_p return result.error_code == UHDR_CODEC_OK ? JPEGR_NO_ERROR : JPEGR_UNKNOWN_ERROR; } +namespace { + +struct XmpSegmentSpan { + size_t begin; + size_t end; + string xml; + size_t payload_begin; + size_t payload_size; +}; + +struct ExtendedXmpChunk { + size_t segment_index; + uint32_t offset; + size_t data_begin; + size_t data_size; +}; + +struct StripXmpEdit { + size_t begin; + size_t end; + vector replacement; +}; + +uint32_t ReadBigEndian32(const uint8_t* bytes) { + return (static_cast(bytes[0]) << 24) | (static_cast(bytes[1]) << 16) | + (static_cast(bytes[2]) << 8) | static_cast(bytes[3]); +} + +void AppendBigEndian32(uint32_t value, string* bytes) { + for (int shift = 24; shift >= 0; shift -= 8) { + bytes->push_back(static_cast((value >> shift) & 0xff)); + } +} + +bool AppendApp1Segment(const string& signature, const string& body, vector* output) { + const size_t payload_size = signature.size() + 1 + body.size(); + if (payload_size + 2 > kJpegSegmentMaxLength) return false; + const size_t length = payload_size + 2; + output->insert(output->end(), {JpegMarker::kStart, JpegMarker::kAPP1, + static_cast(length >> 8), + static_cast(length & 0xff)}); + output->insert(output->end(), signature.begin(), signature.end()); + output->push_back('\0'); + output->insert(output->end(), body.begin(), body.end()); + return true; +} + +bool RewriteLinkedExtendedXmp(vector* output, const vector& standard_xmp, + const vector& extended_xmp, const uint8_t* input, + size_t input_size, string* error) { + string linked_guid; + for (const XmpSegmentSpan& standard : standard_xmp) { + string guid; + if (!getExtendedXmpGuidFromXmp(standard.xml, &guid)) { + *error = "malformed HasExtendedXMP reference in standard XMP"; + return false; + } + if (!guid.empty() && !linked_guid.empty() && guid != linked_guid) { + *error = "standard XMP contains ambiguous Extended XMP links"; + return false; + } + if (!guid.empty()) linked_guid = guid; + } + if (linked_guid.empty()) return true; + + const size_t extension_signature_size = kExtendedXmpNameSpace.size() + 1; + vector chunks; + uint32_t total_size = 0; + size_t covered_size = 0; + for (size_t index = 0; index < extended_xmp.size(); ++index) { + const XmpSegmentSpan& segment = extended_xmp[index]; + if (segment.payload_size < extension_signature_size || + memcmp(input + segment.payload_begin, kExtendedXmpNameSpace.c_str(), + extension_signature_size) != 0 || + segment.payload_size < extension_signature_size + 32) { + continue; + } + const uint8_t* payload = input + segment.payload_begin; + if (memcmp(payload + extension_signature_size, linked_guid.data(), linked_guid.size()) != 0) { + continue; + } + if (segment.payload_size < extension_signature_size + 40) { + *error = "linked Extended XMP chunk has a truncated header"; + return false; + } + const uint32_t chunk_total = ReadBigEndian32(payload + extension_signature_size + 32); + const uint32_t chunk_offset = ReadBigEndian32(payload + extension_signature_size + 36); + const size_t data_offset = segment.payload_begin + extension_signature_size + 40; + const size_t data_size = segment.payload_size - extension_signature_size - 40; + if (chunk_total == 0 || chunk_total > input_size || chunk_offset > chunk_total || + data_size == 0 || data_size > chunk_total - chunk_offset || + data_size > input_size - covered_size || + (total_size != 0 && total_size != chunk_total)) { + *error = "linked Extended XMP chunk has invalid bounds or length"; + return false; + } + total_size = chunk_total; + covered_size += data_size; + chunks.push_back({index, chunk_offset, data_offset, data_size}); + } + if (chunks.empty()) { + *error = "standard XMP links to missing Extended XMP chunks"; + return false; + } + sort(chunks.begin(), chunks.end(), [](const ExtendedXmpChunk& left, + const ExtendedXmpChunk& right) { + return left.offset < right.offset; + }); + size_t next_offset = 0; + for (const ExtendedXmpChunk& chunk : chunks) { + if (chunk.offset != next_offset) { + *error = "linked Extended XMP chunks contain a gap or overlap"; + return false; + } + next_offset += chunk.data_size; + } + if (next_offset != total_size || covered_size != total_size) { + *error = "linked Extended XMP chunks do not cover the declared packet"; + return false; + } + + string packet; + packet.reserve(total_size); + for (const ExtendedXmpChunk& chunk : chunks) { + packet.append(reinterpret_cast(input + chunk.data_begin), chunk.data_size); + } + if (computeMd5Guid(reinterpret_cast(packet.data()), packet.size()) != linked_guid) { + *error = "linked Extended XMP packet MD5 does not match its GUID"; + return false; + } + string stripped_packet; + if (!stripGainMapFromXmp(packet, &stripped_packet)) { + *error = "unable to strip gain map properties from linked Extended XMP"; + return false; + } + if (stripped_packet == packet) return true; + + string new_guid; + if (!stripped_packet.empty()) { + new_guid = computeMd5Guid(reinterpret_cast(stripped_packet.data()), + stripped_packet.size()); + } + + vector edits; + for (const XmpSegmentSpan& standard : standard_xmp) { + string guid; + if (!getExtendedXmpGuidFromXmp(standard.xml, &guid)) { + *error = "malformed HasExtendedXMP reference in standard XMP"; + return false; + } + if (guid.empty()) continue; + if (guid != linked_guid) { + *error = "standard XMP references multiple Extended XMP packets"; + return false; + } + string rewritten_xml; + if (!replaceExtendedXmpGuidInXmp(standard.xml, new_guid, &rewritten_xml)) { + *error = "unable to update standard XMP Extended XMP reference"; + return false; + } + vector replacement; + if (!AppendApp1Segment(kXmpNameSpace, rewritten_xml, &replacement)) { + *error = "rewritten standard XMP exceeds APP1 segment capacity"; + return false; + } + edits.push_back({standard.begin, standard.end, std::move(replacement)}); + } + + size_t first_linked_segment = extended_xmp.size(); + for (const ExtendedXmpChunk& chunk : chunks) { + if (first_linked_segment == extended_xmp.size() || + extended_xmp[chunk.segment_index].begin < extended_xmp[first_linked_segment].begin) { + first_linked_segment = chunk.segment_index; + } + } + vector replacement_chunks; + if (!stripped_packet.empty()) { + for (size_t offset = 0; offset < stripped_packet.size(); offset += kExtendedXmpMaxChunkSize) { + const size_t count = (std::min)(kExtendedXmpMaxChunkSize, stripped_packet.size() - offset); + string body = new_guid; + AppendBigEndian32(static_cast(stripped_packet.size()), &body); + AppendBigEndian32(static_cast(offset), &body); + body.append(stripped_packet, offset, count); + if (!AppendApp1Segment(kExtendedXmpNameSpace, body, &replacement_chunks)) { + *error = "rewritten Extended XMP chunk exceeds APP1 segment capacity"; + return false; + } + } + } + for (const ExtendedXmpChunk& chunk : chunks) { + const XmpSegmentSpan& segment = extended_xmp[chunk.segment_index]; + vector replacement; + if (chunk.segment_index == first_linked_segment) replacement = replacement_chunks; + edits.push_back({segment.begin, segment.end, std::move(replacement)}); + } + + sort(edits.begin(), edits.end(), [](const StripXmpEdit& left, const StripXmpEdit& right) { + return left.begin < right.begin; + }); + vector rewritten; + rewritten.reserve(output->size()); + size_t cursor = 0; + for (const StripXmpEdit& edit : edits) { + if (edit.begin < cursor || edit.begin > edit.end || edit.end > output->size()) { + *error = "overlapping Extended XMP rewrite spans"; + return false; + } + rewritten.insert(rewritten.end(), output->begin() + cursor, output->begin() + edit.begin); + rewritten.insert(rewritten.end(), edit.replacement.begin(), edit.replacement.end()); + cursor = edit.end; + } + rewritten.insert(rewritten.end(), output->begin() + cursor, output->end()); + *output = std::move(rewritten); + return true; +} + +} // namespace + uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, uhdr_mem_block_t* out_stream) { if (in_stream == nullptr) { @@ -3070,8 +3291,11 @@ uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, out_buf.push_back(JpegMarker::kSOI); const size_t xmp_ns_len = kXmpNameSpace.size() + 1; + const size_t extended_xmp_ns_len = kExtendedXmpNameSpace.size() + 1; const size_t iso_ns_len = kIsoNameSpace.size() + 1; const size_t mpf_sig_len = sizeof(kMpfSig); + vector standard_xmp; + vector extended_xmp; size_t pos = 2; bool seen_sos = false; @@ -3179,6 +3403,7 @@ uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, return status; } if (!stripped_xmp.empty()) { + const size_t output_begin = out_buf.size(); const size_t new_seg_len = 2 + xmp_ns_len + stripped_xmp.size(); if (new_seg_len > 0xFFFF) { uhdr_error_info_t status; @@ -3199,7 +3424,14 @@ uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, reinterpret_cast(stripped_xmp.data()), reinterpret_cast(stripped_xmp.data()) + stripped_xmp.size()); + standard_xmp.push_back({output_begin, out_buf.size(), stripped_xmp, 0, 0}); } + } else if (payload_len >= extended_xmp_ns_len && + memcmp(payload, kExtendedXmpNameSpace.c_str(), extended_xmp_ns_len) == 0) { + const size_t output_begin = out_buf.size(); + out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); + extended_xmp.push_back({output_begin, out_buf.size(), string(), + static_cast(payload - data), payload_len}); } else { out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); } @@ -3256,6 +3488,17 @@ uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, return status; } + string extended_xmp_error; + if (!RewriteLinkedExtendedXmp(&out_buf, standard_xmp, extended_xmp, data, size, + &extended_xmp_error)) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "unable to process linked Extended XMP: %s", extended_xmp_error.c_str()); + return status; + } + out_stream->data_sz = out_buf.size(); if (out_stream->data == nullptr && out_stream->capacity == 0) { return g_no_error; diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 9b8b3bfd..0687038a 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -1037,6 +1037,7 @@ struct XmpAttributeSpan { string uri; string local_name; size_t name_begin = 0; + size_t value_begin = 0; size_t value_end = 0; bool namespace_declaration = false; }; @@ -1094,6 +1095,7 @@ class XmpMergeXmlHandler : public XmlHandler { } XmpAttributeSpan& attribute = pending_attributes_[pending_attribute_index_]; if (!context.BuildTokenValue(&attribute.value, true)) valid_ = false; + attribute.value_begin = context.GetTokenRange().GetBegin() + 1; attribute.value_end = context.GetTokenRange().GetEnd(); pending_attribute_index_ = kNoXmpElement; return context.GetResult(); @@ -1429,6 +1431,134 @@ bool ParseRdfDescriptions(const string& parse_xml, XmpMergeXmlHandler* handler, return true; } +namespace { + +constexpr char kXmpNoteUri[] = "http://ns.adobe.com/xmp/note/"; + +struct ExtendedXmpReference { + size_t value_begin; + size_t value_end; + size_t remove_begin; + size_t remove_end; +}; + +bool IsExtendedXmpGuid(const string& guid) { + if (guid.size() != 32) return false; + for (char value : guid) { + if (!((value >= '0' && value <= '9') || (value >= 'A' && value <= 'F'))) return false; + } + return true; +} + +bool ProcessExtendedXmpGuid(const string& xmp_data, const string* replacement_guid, + string* guid_out, string* rewritten_xmp) { + if (guid_out != nullptr) guid_out->clear(); + if (replacement_guid != nullptr && rewritten_xmp == nullptr) return false; + if (replacement_guid != nullptr && !replacement_guid->empty() && + !IsExtendedXmpGuid(*replacement_guid)) { + return false; + } + + size_t trimmed_size = xmp_data.size(); + while (trimmed_size > 0 && xmp_data[trimmed_size - 1] == '\0') --trimmed_size; + const string clean_xmp = xmp_data.substr(0, trimmed_size); + if (clean_xmp.empty()) { + if (rewritten_xmp != nullptr) rewritten_xmp->clear(); + return true; + } + + const string synthetic_open = ""; + const size_t offset = synthetic_open.size(); + const string parse_xml = synthetic_open + clean_xmp + ""; + XmpMergeXmlHandler handler(parse_xml); + size_t rdf_root = kNoXmpElement; + vector all_descriptions; + vector primary_descriptions; + if (!ParseRdfDescriptions(parse_xml, &handler, &rdf_root, &all_descriptions, + &primary_descriptions)) { + return false; + } + + const vector& elements = handler.elements(); + vector references; + string found_guid; + for (size_t description : primary_descriptions) { + const XmpElementSpan& primary = elements[description]; + for (const XmpAttributeSpan& attribute : primary.attributes) { + if (attribute.uri != kXmpNoteUri || attribute.local_name != "HasExtendedXMP") continue; + if (!IsExtendedXmpGuid(attribute.value)) return false; + if (!found_guid.empty() && found_guid != attribute.value) return false; + found_guid = attribute.value; + size_t remove_begin = attribute.name_begin; + while (remove_begin > primary.start_begin && IsXmlWhitespace(parse_xml[remove_begin - 1])) { + --remove_begin; + } + references.push_back({attribute.value_begin, attribute.value_end - 1, remove_begin, + attribute.value_end}); + } + + for (size_t index = 0; index < elements.size(); ++index) { + const XmpElementSpan& element = elements[index]; + if (element.parent != description || element.uri != kXmpNoteUri || + element.local_name != "HasExtendedXMP") { + continue; + } + if (element.self_closing) return false; + for (const XmpElementSpan& child : elements) { + if (child.parent == index) return false; + } + size_t value_begin = 0; + if (!FindXmlMarkupEnd(parse_xml, element.start_begin, &value_begin) || + element.end_begin < value_begin) { + return false; + } + size_t value_end = element.end_begin; + while (value_begin < value_end && IsXmlWhitespace(parse_xml[value_begin])) ++value_begin; + while (value_end > value_begin && IsXmlWhitespace(parse_xml[value_end - 1])) --value_end; + const string guid = parse_xml.substr(value_begin, value_end - value_begin); + if (!IsExtendedXmpGuid(guid) || (!found_guid.empty() && found_guid != guid)) return false; + found_guid = guid; + size_t remove_begin = element.start_begin; + while (remove_begin > primary.start_begin && IsXmlWhitespace(parse_xml[remove_begin - 1])) { + --remove_begin; + } + references.push_back({value_begin, value_end, remove_begin, element.end_end}); + } + } + + if (guid_out != nullptr) *guid_out = found_guid; + if (rewritten_xmp == nullptr || references.empty() || *replacement_guid == found_guid) { + if (rewritten_xmp != nullptr) *rewritten_xmp = clean_xmp; + return true; + } + + vector> edits; + edits.reserve(references.size()); + for (const ExtendedXmpReference& reference : references) { + const size_t begin = replacement_guid->empty() ? reference.remove_begin : reference.value_begin; + const size_t end = replacement_guid->empty() ? reference.remove_end : reference.value_end; + if (begin < offset || end < begin || end - offset > clean_xmp.size()) return false; + edits.emplace_back(begin - offset, end - offset); + } + sort(edits.begin(), edits.end()); + for (size_t index = 1; index < edits.size(); ++index) { + if (edits[index].first < edits[index - 1].second) return false; + } + + string rewritten; + size_t cursor = 0; + for (const auto& edit : edits) { + rewritten.append(clean_xmp, cursor, edit.first - cursor); + if (!replacement_guid->empty()) rewritten.append(*replacement_guid); + cursor = edit.second; + } + rewritten.append(clean_xmp, cursor, clean_xmp.size() - cursor); + *rewritten_xmp = std::move(rewritten); + return true; +} + +} // namespace + string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length, uhdr_gainmap_metadata_ext_t& metadata, const string& extended_xmp_guid) { @@ -1503,6 +1633,17 @@ string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length } // namespace +bool getExtendedXmpGuidFromXmp(const string& xmp_data, string* guid) { + if (guid == nullptr) return false; + return ProcessExtendedXmpGuid(xmp_data, nullptr, guid, nullptr); +} + +bool replaceExtendedXmpGuidInXmp(const string& xmp_data, const string& new_guid, + string* rewritten_xmp) { + if (rewritten_xmp == nullptr) return false; + return ProcessExtendedXmpGuid(xmp_data, &new_guid, nullptr, rewritten_xmp); +} + bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) { if (stripped_xmp == nullptr) return false; size_t trimmed_size = xmp_data.size(); diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index a58a0614..230aa145 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -200,6 +200,83 @@ static std::vector extractPrimaryScanBytes(const void* stream_data, siz return {}; } +struct JpegSegmentSpan { + size_t begin; + size_t end; + size_t payload_begin; + size_t payload_size; +}; + +static std::vector findApp1Segments(const std::vector& jpeg, + std::string_view signature) { + std::vector segments; + if (jpeg.size() < 4 || jpeg[0] != 0xFF || jpeg[1] != 0xD8) return segments; + size_t pos = 2; + while (pos + 4 <= jpeg.size() && jpeg[pos] == 0xFF) { + const size_t marker_begin = pos; + while (pos < jpeg.size() && jpeg[pos] == 0xFF) ++pos; + if (pos == jpeg.size()) break; + const uint8_t marker = jpeg[pos++]; + if (marker == 0xD9 || marker == 0xDA) break; + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD8)) continue; + if (pos + 2 > jpeg.size()) break; + const size_t length = (static_cast(jpeg[pos]) << 8) | jpeg[pos + 1]; + if (length < 2 || pos + length > jpeg.size()) break; + const size_t payload_begin = pos + 2; + const size_t payload_size = length - 2; + if (marker == 0xE1 && payload_size >= signature.size() + 1 && + std::string_view(reinterpret_cast(jpeg.data() + payload_begin), + signature.size()) == signature && + jpeg[payload_begin + signature.size()] == 0) { + segments.push_back({marker_begin, pos + length, payload_begin, payload_size}); + } + pos += length; + } + return segments; +} + +static std::vector findExtendedXmpSegments(const std::vector& jpeg) { + return findApp1Segments(jpeg, "http://ns.adobe.com/xmp/extension/"); +} + +static uint32_t readBigEndian32(const std::vector& bytes, size_t offset) { + return (static_cast(bytes[offset]) << 24) | + (static_cast(bytes[offset + 1]) << 16) | + (static_cast(bytes[offset + 2]) << 8) | bytes[offset + 3]; +} + +static void writeBigEndian32(std::vector* bytes, size_t offset, uint32_t value) { + for (int shift = 24; shift >= 0; shift -= 8) { + (*bytes)[offset++] = static_cast((value >> shift) & 0xFF); + } +} + +static bool reverseExtendedXmpSegments(std::vector* jpeg) { + const std::vector segments = findExtendedXmpSegments(*jpeg); + if (segments.size() < 2) return false; + for (size_t index = 1; index < segments.size(); ++index) { + if (segments[index - 1].end != segments[index].begin) return false; + } + std::vector reordered(jpeg->begin(), jpeg->begin() + segments.front().begin); + for (auto segment = segments.rbegin(); segment != segments.rend(); ++segment) { + reordered.insert(reordered.end(), jpeg->begin() + segment->begin, jpeg->begin() + segment->end); + } + reordered.insert(reordered.end(), jpeg->begin() + segments.back().end, jpeg->end()); + *jpeg = std::move(reordered); + return true; +} + +static std::string makeLargeGainMapOnlyXmp(size_t size) { + const std::string prefix = + "" + ""; + const std::string suffix = ""; + EXPECT_GE(size, prefix.size() + suffix.size()); + return prefix + std::string(size - prefix.size() - suffix.size(), '1') + suffix; +} + #if defined(UHDR_WRITE_XMP) struct ContainerDirectoryInfo { bool parsed = false; @@ -1035,8 +1112,18 @@ TEST_F(UltraHdrApiTest, StripGainMapPreservesSharedDescriptionAndExtendedXmp) { EXPECT_EQ(stripped_shared.find("hdrgm:"), std::string::npos); EXPECT_EQ(stripped_shared.find("Container:"), std::string::npos); - // Part 2: Multi-segment Extended XMP (> 64 KB) preserved through uhdr_strip_gain_map. - const std::string ext_xmp = makeLargeXmp(140000); + // Part 2: Multi-segment Extended XMP (> 64 KB) is stripped and relinked. + std::string ext_xmp = makeLargeXmp(140000); + const std::string about = "rdf:about=\"\""; + const size_t about_pos = ext_xmp.find(about); + ASSERT_NE(about_pos, std::string::npos); + ext_xmp.insert(about_pos + about.size(), + " xmlns:hdrgm=\"http://ns.adobe.com/hdr-gain-map/1.0/\" " + "hdrgm:Version=\"1.0\""); + std::string expected_ext_xmp; + ASSERT_TRUE(stripGainMapFromXmp(ext_xmp, &expected_ext_xmp)); + EXPECT_EQ(expected_ext_xmp.find("hdrgm:Version"), std::string::npos); + EXPECT_NE(expected_ext_xmp.find("(ext_xmp.data()), ext_xmp.size(), ext_xmp.size()}; EncoderPtr enc = makeEncoder(); ASSERT_NE(enc, nullptr); @@ -1049,15 +1136,207 @@ TEST_F(UltraHdrApiTest, StripGainMapPreservesSharedDescriptionAndExtendedXmp) { uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); ASSERT_NE(uhdr_stream, nullptr); - std::vector stripped_buf(uhdr_stream->data_sz); + std::vector reordered_input( + static_cast(uhdr_stream->data), + static_cast(uhdr_stream->data) + uhdr_stream->data_sz); + ASSERT_TRUE(reverseExtendedXmpSegments(&reordered_input)); + uhdr_compressed_image_t reordered_stream{reordered_input.data(), reordered_input.size(), + reordered_input.size(), UHDR_CG_UNSPECIFIED, + UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED}; + std::vector stripped_buf(reordered_input.size()); uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; - ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_strip_gain_map(&reordered_stream, &stripped_block).error_code, UHDR_CODEC_OK); EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); JpegDecoderHelper stripped_decoder; ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, UHDR_CODEC_OK); - ASSERT_EQ(stripped_decoder.getXMPSize(), ext_xmp.size()); - EXPECT_EQ(memcmp(stripped_decoder.getXMPPtr(), ext_xmp.data(), ext_xmp.size()), 0); + ASSERT_EQ(stripped_decoder.getXMPSize(), expected_ext_xmp.size()); + EXPECT_EQ(memcmp(stripped_decoder.getXMPPtr(), expected_ext_xmp.data(), expected_ext_xmp.size()), 0); + const std::vector stripped_jpeg( + static_cast(stripped_block.data), + static_cast(stripped_block.data) + stripped_block.data_sz); + const std::vector standard_segments = + findApp1Segments(stripped_jpeg, "http://ns.adobe.com/xap/1.0/"); + std::string linked_guid; + for (const JpegSegmentSpan& segment : standard_segments) { + constexpr std::string_view kStandardNamespace = "http://ns.adobe.com/xap/1.0/"; + const size_t xml_begin = segment.payload_begin + kStandardNamespace.size() + 1; + const std::string standard_xml( + reinterpret_cast(stripped_jpeg.data() + xml_begin), + segment.payload_size - kStandardNamespace.size() - 1); + std::string segment_guid; + ASSERT_TRUE(getExtendedXmpGuidFromXmp(standard_xml, &segment_guid)); + if (!segment_guid.empty()) { + ASSERT_TRUE(linked_guid.empty()); + linked_guid = segment_guid; + } + } + ASSERT_FALSE(linked_guid.empty()); + EXPECT_EQ(linked_guid, + computeMd5Guid(reinterpret_cast(expected_ext_xmp.data()), + expected_ext_xmp.size())); +} + +TEST_F(UltraHdrApiTest, StripGainMapLeavesUserOnlyExtendedXmpChunksUnchanged) { + const std::string user_xmp = makeLargeXmp(140000); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* encoded = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(encoded, nullptr); + std::vector original(static_cast(encoded->data), + static_cast(encoded->data) + encoded->data_sz); + const std::vector original_segments = findExtendedXmpSegments(original); + ASSERT_GE(original_segments.size(), 2u); + + uhdr_compressed_image_t input{original.data(), original.size(), original.size(), + UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, + UHDR_CR_UNSPECIFIED}; + std::vector output(original.size()); + uhdr_mem_block_t output_block{output.data(), 0, output.size()}; + ASSERT_EQ(uhdr_strip_gain_map(&input, &output_block).error_code, UHDR_CODEC_OK); + output.resize(output_block.data_sz); + const std::vector output_segments = findExtendedXmpSegments(output); + ASSERT_EQ(output_segments.size(), original_segments.size()); + for (size_t index = 0; index < original_segments.size(); ++index) { + const JpegSegmentSpan& before = original_segments[index]; + const JpegSegmentSpan& after = output_segments[index]; + ASSERT_EQ(before.end - before.begin, after.end - after.begin); + EXPECT_EQ(std::memcmp(original.data() + before.begin, output.data() + after.begin, + before.end - before.begin), + 0); + } + JpegDecoderHelper decoder; + ASSERT_EQ(decoder.parseImage(output.data(), output.size()).error_code, UHDR_CODEC_OK); + ASSERT_EQ(decoder.getXMPSize(), user_xmp.size()); + EXPECT_EQ(std::memcmp(decoder.getXMPPtr(), user_xmp.data(), user_xmp.size()), 0); +} + +TEST_F(UltraHdrApiTest, StripGainMapRemovesEmptyLinkedExtendedXmp) { + const std::string gainmap_only_xmp = makeLargeGainMapOnlyXmp(140000); + std::string stripped_xmp; + ASSERT_TRUE(stripGainMapFromXmp(gainmap_only_xmp, &stripped_xmp)); + ASSERT_TRUE(stripped_xmp.empty()); + uhdr_mem_block_t xmp_block{const_cast(gainmap_only_xmp.data()), gainmap_only_xmp.size(), + gainmap_only_xmp.size()}; + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* encoded = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(encoded, nullptr); + std::vector output(encoded->data_sz); + uhdr_mem_block_t output_block{output.data(), 0, output.size()}; + ASSERT_EQ(uhdr_strip_gain_map(encoded, &output_block).error_code, UHDR_CODEC_OK); + output.resize(output_block.data_sz); + EXPECT_TRUE(findExtendedXmpSegments(output).empty()); + JpegDecoderHelper decoder; + ASSERT_EQ(decoder.parseImage(output.data(), output.size()).error_code, UHDR_CODEC_OK); + ASSERT_NE(decoder.getXMPPtr(), nullptr); + uhdr_mem_block_t decoded_xmp_block{decoder.getXMPPtr(), decoder.getXMPSize(), + decoder.getXMPSize()}; + const std::string decoded_xmp = getXmpPacket(&decoded_xmp_block); + std::string guid; + ASSERT_TRUE(getExtendedXmpGuidFromXmp(decoded_xmp, &guid)) << decoded_xmp; + EXPECT_TRUE(guid.empty()); +} + +TEST_F(UltraHdrApiTest, StripGainMapRejectsMalformedLinkedExtendedXmpWithoutWriting) { + constexpr std::string_view kExtensionNamespace = "http://ns.adobe.com/xmp/extension/"; + std::string ext_xmp = makeLargeXmp(140000); + const size_t about_pos = ext_xmp.find("rdf:about=\"\""); + ASSERT_NE(about_pos, std::string::npos); + ext_xmp.insert(about_pos + std::string("rdf:about=\"\"").size(), + " xmlns:hdrgm=\"http://ns.adobe.com/hdr-gain-map/1.0/\" " + "hdrgm:Version=\"1.0\""); + uhdr_mem_block_t xmp_block{const_cast(ext_xmp.data()), ext_xmp.size(), ext_xmp.size()}; + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* encoded = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(encoded, nullptr); + const std::vector original(static_cast(encoded->data), + static_cast(encoded->data) + encoded->data_sz); + const std::vector segments = findExtendedXmpSegments(original); + ASSERT_GE(segments.size(), 2u); + const size_t extension_header = kExtensionNamespace.size() + 1 + 32 + 8; + const size_t total_offset = segments[1].payload_begin + kExtensionNamespace.size() + 1 + 32; + const size_t chunk_offset = total_offset + 4; + + std::vector> malformed; + malformed.push_back(original); + malformed.back().erase(malformed.back().begin() + segments.back().begin, + malformed.back().begin() + segments.back().end); + malformed.push_back(original); + writeBigEndian32(&malformed.back(), total_offset, + readBigEndian32(malformed.back(), total_offset) + 1); + malformed.push_back(original); + writeBigEndian32(&malformed.back(), chunk_offset, 0); + malformed.push_back(original); + const size_t changed_byte = ext_xmp.find('X'); + ASSERT_NE(changed_byte, std::string::npos); + bool digest_byte_changed = false; + for (const JpegSegmentSpan& segment : segments) { + const size_t header = segment.payload_begin + extension_header; + const size_t offset = readBigEndian32(malformed.back(), header - 4); + const size_t data_size = segment.payload_size - extension_header; + if (changed_byte >= offset && changed_byte - offset < data_size) { + malformed.back()[header + changed_byte - offset] = 'Y'; + digest_byte_changed = true; + break; + } + } + ASSERT_TRUE(digest_byte_changed); + + for (size_t index = 0; index < malformed.size(); ++index) { + SCOPED_TRACE(index); + uhdr_compressed_image_t input{malformed[index].data(), malformed[index].size(), + malformed[index].size(), UHDR_CG_UNSPECIFIED, + UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED}; + std::vector output(malformed[index].size() * 2, 0xA5); + uhdr_mem_block_t output_block{output.data(), 0, output.size()}; + EXPECT_EQ(uhdr_strip_gain_map(&input, &output_block).error_code, UHDR_CODEC_INVALID_PARAM); + EXPECT_TRUE(std::all_of(output.begin(), output.end(), [](uint8_t value) { return value == 0xA5; })); + } +} + +TEST_F(UltraHdrApiTest, ExtendedXmpElementLinkIsUpdatedAndRemovedByNamespace) { + const std::string old_guid = "0123456789ABCDEF0123456789ABCDEF"; + const std::string new_guid = "FEDCBA9876543210FEDCBA9876543210"; + const std::string xmp = + "" + "" + old_guid + + ""; + std::string found_guid; + ASSERT_TRUE(getExtendedXmpGuidFromXmp(xmp, &found_guid)); + EXPECT_EQ(found_guid, old_guid); + std::string updated; + ASSERT_TRUE(replaceExtendedXmpGuidInXmp(xmp, new_guid, &updated)); + EXPECT_NE(updated.find(new_guid), std::string::npos); + EXPECT_EQ(updated.find(old_guid), std::string::npos); + EXPECT_NE(updated.find("dc:title=\"Keep this title\""), std::string::npos); + std::string removed; + ASSERT_TRUE(replaceExtendedXmpGuidInXmp(xmp, "", &removed)); + EXPECT_EQ(removed.find("HasExtendedXMP"), std::string::npos); + EXPECT_NE(removed.find("dc:title=\"Keep this title\""), std::string::npos); } TEST_F(UltraHdrApiTest, StripGainMapKeepsNamespaceUsedByRetainedDescendant) {