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) {