Commit 23f9d3aa for libheif
commit 23f9d3aaffd3837a500b3296553f6d1737c6ed8a
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Wed May 13 22:51:59 2026 +0200
add function to copy over all metadata to new image
diff --git a/libheif/color-conversion/colorconversion.cc b/libheif/color-conversion/colorconversion.cc
index 184f0038..aad2a5b0 100644
--- a/libheif/color-conversion/colorconversion.cc
+++ b/libheif/color-conversion/colorconversion.cc
@@ -469,7 +469,7 @@ Result<std::shared_ptr<HeifPixelImage>> ColorConversionPipeline::convert_image(c
}
// copy metadata over to new image
- out->forward_all_metadata_from(in);
+ out->copy_metadata_from(*in);
// overwrite color profile nclx from color conversion
out->set_color_profile_nclx(step.output_state.nclx);
diff --git a/libheif/image-items/grid.cc b/libheif/image-items/grid.cc
index 7b1b2fd4..a0c25e51 100644
--- a/libheif/image-items/grid.cc
+++ b/libheif/image-items/grid.cc
@@ -555,7 +555,7 @@ Error ImageItem_Grid::decode_and_paste_tile_image(heif_item_id tileID, uint32_t
grid_image->fill_plane(heif_channel_Alpha, alpha_default_value);
}
- grid_image->forward_all_metadata_from(tile_img);
+ grid_image->copy_metadata_from(*tile_img);
inout_image = grid_image; // We have to set this at the very end because of the unlocked check to `inout_image` above.
}
diff --git a/libheif/image/image_description.cc b/libheif/image/image_description.cc
index 225199dc..723c6ef3 100644
--- a/libheif/image/image_description.cc
+++ b/libheif/image/image_description.cc
@@ -119,6 +119,42 @@ ImageDescription::~ImageDescription()
}
+void ImageDescription::copy_metadata_from(const ImageDescription& other)
+{
+ m_premultiplied_alpha = other.m_premultiplied_alpha;
+ m_color_profile_nclx = other.m_color_profile_nclx;
+ m_color_profile_icc = other.m_color_profile_icc;
+
+ m_PixelAspectRatio_h = other.m_PixelAspectRatio_h;
+ m_PixelAspectRatio_v = other.m_PixelAspectRatio_v;
+
+ m_clli = other.m_clli;
+ m_mdcv = other.m_mdcv;
+
+ heif_tai_timestamp_packet_release(m_tai_timestamp);
+ m_tai_timestamp = nullptr;
+ if (other.m_tai_timestamp) {
+ m_tai_timestamp = heif_tai_timestamp_packet_alloc();
+ heif_tai_timestamp_packet_copy(m_tai_timestamp, other.m_tai_timestamp);
+ }
+
+ m_gimi_sample_content_id = other.m_gimi_sample_content_id;
+
+ m_bayer_pattern = other.m_bayer_pattern;
+ m_polarization_patterns = other.m_polarization_patterns;
+ m_sensor_bad_pixels_maps = other.m_sensor_bad_pixels_maps;
+ m_sensor_nuc = other.m_sensor_nuc;
+
+ m_chroma_location = other.m_chroma_location;
+
+ m_sample_duration = other.m_sample_duration;
+
+#if HEIF_WITH_OMAF
+ m_omaf_image_projection = other.m_omaf_image_projection;
+#endif
+}
+
+
bool ImageDescription::has_nclx_color_profile() const
{
return m_color_profile_nclx != nclx_profile::undefined();
diff --git a/libheif/image/image_description.h b/libheif/image/image_description.h
index e5aa620a..e3cec27d 100644
--- a/libheif/image/image_description.h
+++ b/libheif/image/image_description.h
@@ -396,6 +396,14 @@ public:
virtual void set_chroma_location(uint8_t loc) { m_chroma_location = loc; }
+ // --- sample duration (for images that are frames in a sequence)
+ // 0 means "no duration assigned" (the default for still images).
+
+ void set_sample_duration(uint32_t d) { m_sample_duration = d; }
+
+ uint32_t get_sample_duration() const { return m_sample_duration; }
+
+
#if HEIF_WITH_OMAF
bool has_omaf_image_projection() const {
return (m_omaf_image_projection != heif_omaf_image_projection_flat);
@@ -410,6 +418,19 @@ public:
}
#endif
+ // Copies all per-image metadata from `other` (color profiles, premultiplied
+ // alpha, pixel aspect ratio, clli, mdcv, tai timestamp, gimi sample content
+ // id, bayer pattern, polarization patterns, sensor maps, sensor nuc, chroma
+ // location, omaf projection). Per-component descriptions
+ // (m_components / m_next_component_id) are intentionally not copied; those
+ // are managed separately by callers (via add_plane / add_component, or
+ // set_component_descriptions on the destination).
+ //
+ // Bayer / polarization / sensor-map metadata refers to image geometry;
+ // transforms that change orientation or position copy them verbatim and
+ // would need separate geometry adjustment to remain semantically correct.
+ void copy_metadata_from(const ImageDescription& other);
+
private:
bool m_premultiplied_alpha = false;
nclx_profile m_color_profile_nclx = nclx_profile::undefined();
@@ -444,6 +465,8 @@ private:
std::optional<uint8_t> m_chroma_location;
+ uint32_t m_sample_duration = 0; // duration of a sequence frame, 0 for stills
+
#if HEIF_WITH_OMAF
heif_omaf_image_projection m_omaf_image_projection = heif_omaf_image_projection::heif_omaf_image_projection_flat;
#endif
diff --git a/libheif/image/pixelimage.cc b/libheif/image/pixelimage.cc
index d75ddd9a..b9c12063 100644
--- a/libheif/image/pixelimage.cc
+++ b/libheif/image/pixelimage.cc
@@ -1179,6 +1179,14 @@ void HeifPixelImage::zero_region(uint32_t x0, uint32_t y0, uint32_t w, uint32_t
Result<std::shared_ptr<HeifPixelImage>> HeifPixelImage::rotate_ccw(int angle_degrees, const heif_security_limits* limits)
{
+ // TODO: Bayer pattern, polarization patterns and sensor maps reference
+ // image geometry and are currently copied verbatim by
+ // forward_all_metadata_from(). For 90/270° rotations the layout is
+ // transposed and for 180° it is flipped, so the copied metadata is no
+ // longer semantically valid. Either rotate these structures along with
+ // the pixels, or return an error when such metadata is present and
+ // rotation would invalidate it.
+
// --- for some subsampled chroma colorspaces, we have to transform to 4:4:4 before rotation
bool need_conversion = false;
@@ -1233,6 +1241,7 @@ Result<std::shared_ptr<HeifPixelImage>> HeifPixelImage::rotate_ccw(int angle_deg
std::shared_ptr<HeifPixelImage> out_img = std::make_shared<HeifPixelImage>();
out_img->create(out_width, out_height, m_colorspace, m_chroma);
+ out_img->copy_metadata_from(*this);
// --- rotate all channels
@@ -1284,10 +1293,6 @@ Result<std::shared_ptr<HeifPixelImage>> HeifPixelImage::rotate_ccw(int angle_deg
out_img->m_planes.push_back(std::move(out_component));
}
- // --- pass the color profiles to the new image
-
- out_img->set_color_profile_nclx(get_color_profile_nclx());
- out_img->set_color_profile_icc(get_color_profile_icc());
out_img->add_warnings(get_warnings());
@@ -1351,6 +1356,13 @@ void HeifPixelImage::ImageComponent::mirror_inplace(heif_transform_mirror_direct
Result<std::shared_ptr<HeifPixelImage>> HeifPixelImage::mirror_inplace(heif_transform_mirror_direction direction,
const heif_security_limits* limits)
{
+ // TODO: Bayer pattern, polarization patterns and sensor maps reference
+ // image geometry. This function mirrors the pixel data in place but
+ // leaves those structures untouched, so a horizontal/vertical mirror
+ // leaves them out of sync with the pixel layout. Either mirror these
+ // structures along with the pixels, or return an error when such
+ // metadata is present and the mirror would invalidate it.
+
// --- for some subsampled chroma colorspaces, we have to transform to 4:4:4 before rotation
bool need_conversion = false;
@@ -1419,6 +1431,15 @@ int HeifPixelImage::ImageComponent::get_bytes_per_pixel() const
Result<std::shared_ptr<HeifPixelImage>> HeifPixelImage::crop(uint32_t left, uint32_t right, uint32_t top, uint32_t bottom,
const heif_security_limits* limits) const
{
+ // TODO: Bayer pattern, polarization patterns and sensor maps reference
+ // image geometry and are currently copied verbatim by
+ // forward_all_metadata_from(). A crop shifts the (0,0) origin and
+ // changes the image dimensions, so the copied metadata may no longer
+ // match the cropped image (e.g. a 2x2 Bayer pattern with an odd
+ // left/top offset, or a sensor NUC map sized to the original image).
+ // Either translate / resample these structures to the crop region, or
+ // return an error when the crop would invalidate them.
+
// --- for some subsampled chroma colorspaces, we have to transform to 4:4:4 before cropping
bool need_conversion = false;
@@ -1450,6 +1471,7 @@ Result<std::shared_ptr<HeifPixelImage>> HeifPixelImage::crop(uint32_t left, uint
auto out_img = std::make_shared<HeifPixelImage>();
out_img->create(right - left + 1, bottom - top + 1, m_colorspace, m_chroma);
+ out_img->copy_metadata_from(*this);
// --- crop all channels
@@ -1490,11 +1512,6 @@ Result<std::shared_ptr<HeifPixelImage>> HeifPixelImage::crop(uint32_t left, uint
out_img->m_planes.push_back(std::move(out_plane));
}
- // --- pass the color profiles to the new image
-
- out_img->set_color_profile_nclx(get_color_profile_nclx());
- out_img->set_color_profile_icc(get_color_profile_icc());
-
out_img->add_warnings(get_warnings());
return out_img;
@@ -1925,52 +1942,6 @@ Error HeifPixelImage::scale_nearest_neighbor(std::shared_ptr<HeifPixelImage>& ou
}
-void HeifPixelImage::forward_all_metadata_from(const std::shared_ptr<const HeifPixelImage>& src_image)
-{
- // --- pass the color profiles to the new image
-
- set_color_profile_nclx(src_image->get_color_profile_nclx());
- set_color_profile_icc(src_image->get_color_profile_icc());
-
- set_premultiplied_alpha(src_image->is_premultiplied_alpha());
-
- // pass through HDR information
- if (src_image->has_clli()) {
- set_clli(src_image->get_clli());
- }
-
- if (src_image->has_mdcv()) {
- set_mdcv(src_image->get_mdcv());
- }
-
- if (src_image->has_nonsquare_pixel_ratio()) {
- uint32_t h,v;
- src_image->get_pixel_ratio(&h,&v);
- set_pixel_ratio(h,v);
- }
-
- if (src_image->has_gimi_sample_content_id()) {
- set_gimi_sample_content_id(src_image->get_gimi_sample_content_id());
- }
-
- if (auto* tai = src_image->get_tai_timestamp()) {
- set_tai_timestamp(tai);
- }
-
- set_sample_duration(src_image->get_sample_duration());
-
-#if HEIF_WITH_OMAF
- set_omaf_image_projection(src_image->get_omaf_image_projection());
-#endif
-
- // TODO: should we also forward the warnings? It might be better to do that in ImageItem_Grid.
-
-#if HEIF_WITH_OMAF
- set_omaf_image_projection(src_image->get_omaf_image_projection());
-#endif
-}
-
-
void HeifPixelImage::debug_dump() const
{
auto channels = get_channel_set();
@@ -2016,6 +1987,8 @@ Error HeifPixelImage::create_clone_image_at_new_size(const std::shared_ptr<const
set_component_descriptions(source->get_component_descriptions(),
source->peek_next_component_id());
+ copy_metadata_from(*source);
+
return Error::Ok;
}
diff --git a/libheif/image/pixelimage.h b/libheif/image/pixelimage.h
index 630419be..7785a04d 100644
--- a/libheif/image/pixelimage.h
+++ b/libheif/image/pixelimage.h
@@ -285,7 +285,6 @@ public:
Error scale_nearest_neighbor(std::shared_ptr<HeifPixelImage>& output, uint32_t width, uint32_t height,
const heif_security_limits* limits) const;
- void forward_all_metadata_from(const std::shared_ptr<const HeifPixelImage>& src_image);
void debug_dump() const;
@@ -298,12 +297,6 @@ public:
const heif_security_limits* limits) const;
- // --- sequences
-
- void set_sample_duration(uint32_t d) { m_sample_duration = d; }
-
- uint32_t get_sample_duration() const { return m_sample_duration; }
-
// --- warnings
void add_warning(Error warning) { m_warnings.emplace_back(std::move(warning)); }
@@ -399,9 +392,8 @@ private:
// (component_type now lives on each ComponentDescription in
// ImageDescription::m_components, indexed by component_id.)
- uint32_t m_sample_duration = 0; // duration of a sequence frame
-
- // m_next_component_id moved to ImageDescription (inherited).
+ // m_next_component_id and m_sample_duration moved to ImageDescription
+ // (inherited).
std::vector<Error> m_warnings;
};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index b71a9ba3..4e7da1ea 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -43,6 +43,7 @@ else()
add_libheif_test(jpeg2000)
add_libheif_test(avc_box)
add_libheif_test(file_layout)
+ add_libheif_test(image_description_metadata)
endif()
if (ENABLE_EXPERIMENTAL_FEATURES AND NOT WITH_REDUCED_VISIBILITY)
diff --git a/tests/image_description_metadata.cc b/tests/image_description_metadata.cc
new file mode 100644
index 00000000..b32ab7e4
--- /dev/null
+++ b/tests/image_description_metadata.cc
@@ -0,0 +1,291 @@
+/*
+ libheif unit tests for ImageDescription metadata preservation across
+ HeifPixelImage transforms (rotation, crop, clone).
+
+ MIT License
+
+ Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+*/
+
+#include "catch_amalgamated.hpp"
+#include "image/pixelimage.h"
+#include "image/image_description.h"
+#include "libheif/heif.h"
+#include "libheif/heif_uncompressed.h"
+#include <cstring>
+#include <memory>
+
+
+// Holds the non-default values for every ImageDescription metadata field so
+// that the same values can be applied to a fresh image and then checked on
+// transform outputs.
+struct MetadataFixture
+{
+ nclx_profile nclx;
+ uint32_t pixel_ratio_h = 16;
+ uint32_t pixel_ratio_v = 9;
+ heif_content_light_level clli{1000, 400};
+ heif_mastering_display_colour_volume mdcv{};
+ heif_tai_timestamp_packet tai{};
+ std::string sample_gimi = "urn:uuid:sample-id";
+ std::string comp1_gimi = "urn:uuid:comp1";
+ std::string comp2_gimi = "urn:uuid:comp2";
+ BayerPattern bayer;
+ PolarizationPattern polar;
+ SensorBadPixelsMap badpix;
+ SensorNonUniformityCorrection nuc;
+ uint8_t chroma_location = 2;
+ uint32_t sample_duration = 33; // sequence frame duration
+#if HEIF_WITH_OMAF
+ heif_omaf_image_projection omaf = heif_omaf_image_projection_equirectangular;
+#endif
+
+ MetadataFixture()
+ {
+ nclx.set_colour_primaries(heif_color_primaries_ITU_R_BT_2020_2_and_2100_0);
+ nclx.set_transfer_characteristics(heif_transfer_characteristic_ITU_R_BT_2100_0_PQ);
+ nclx.set_matrix_coefficients(heif_matrix_coefficients_ITU_R_BT_2020_2_non_constant_luminance);
+ nclx.set_full_range_flag(false);
+
+ mdcv.display_primaries_x[0] = 100; mdcv.display_primaries_y[0] = 200;
+ mdcv.display_primaries_x[1] = 300; mdcv.display_primaries_y[1] = 400;
+ mdcv.display_primaries_x[2] = 500; mdcv.display_primaries_y[2] = 600;
+ mdcv.white_point_x = 700; mdcv.white_point_y = 800;
+ mdcv.max_display_mastering_luminance = 90000;
+ mdcv.min_display_mastering_luminance = 100;
+
+ tai.version = 1;
+ tai.tai_timestamp = 1234567890ULL;
+ tai.synchronization_state = 1;
+ tai.timestamp_generation_failure = 0;
+ tai.timestamp_is_modified = 0;
+
+ polar.pattern_width = 2;
+ polar.pattern_height = 2;
+ polar.polarization_angles = {0.0f, 45.0f, 90.0f, 135.0f};
+
+ badpix.correction_applied = false;
+ badpix.bad_rows = {2};
+ badpix.bad_columns = {3};
+ badpix.bad_pixels.push_back({1, 1});
+
+ nuc.nuc_is_applied = true;
+ nuc.image_width = 4;
+ nuc.image_height = 2;
+ nuc.nuc_gains.assign(8, 1.5f);
+ nuc.nuc_offsets.assign(8, 0.25f);
+ }
+};
+
+
+// Builds an 8x4 monochrome image with two components added via
+// add_component(). Sets every available ImageDescription metadata field.
+// Returns the image and the two minted component ids.
+static std::shared_ptr<HeifPixelImage>
+build_image(MetadataFixture& fix, uint32_t& comp1, uint32_t& comp2)
+{
+ auto img = std::make_shared<HeifPixelImage>();
+ img->create(8, 4, heif_colorspace_monochrome, heif_chroma_planar);
+
+ const heif_security_limits* limits = heif_get_global_security_limits();
+
+ auto r1 = img->add_component(8, 4, heif_unci_component_type_monochrome,
+ heif_component_datatype_unsigned_integer, 8, limits);
+ REQUIRE(r1);
+ comp1 = *r1;
+
+ auto r2 = img->add_component(8, 4, heif_unci_component_type_monochrome,
+ heif_component_datatype_unsigned_integer, 8, limits);
+ REQUIRE(r2);
+ comp2 = *r2;
+
+ // Per-component gimi content IDs
+ img->find_component_description(comp1)->gimi_content_id = fix.comp1_gimi;
+ img->find_component_description(comp2)->gimi_content_id = fix.comp2_gimi;
+
+ // Image-level metadata
+ img->set_premultiplied_alpha(true);
+ img->set_color_profile_nclx(fix.nclx);
+ img->set_pixel_ratio(fix.pixel_ratio_h, fix.pixel_ratio_v);
+ img->set_clli(fix.clli);
+ img->set_mdcv(fix.mdcv);
+ img->set_tai_timestamp(&fix.tai);
+ img->set_gimi_sample_content_id(fix.sample_gimi);
+ img->set_sample_duration(fix.sample_duration);
+
+ // Bayer pattern referencing the two component IDs (2x2 GBRG-style).
+ fix.bayer.pattern_width = 2;
+ fix.bayer.pattern_height = 2;
+ fix.bayer.pixels.resize(4);
+ fix.bayer.pixels[0].component_id = comp1; fix.bayer.pixels[0].component_gain = 1.0f;
+ fix.bayer.pixels[1].component_id = comp2; fix.bayer.pixels[1].component_gain = 1.0f;
+ fix.bayer.pixels[2].component_id = comp2; fix.bayer.pixels[2].component_gain = 1.0f;
+ fix.bayer.pixels[3].component_id = comp1; fix.bayer.pixels[3].component_gain = 1.0f;
+ img->set_bayer_pattern(fix.bayer);
+
+ fix.polar.component_ids = {comp1};
+ img->add_polarization_pattern(fix.polar);
+
+ fix.badpix.component_ids = {comp1};
+ img->add_sensor_bad_pixels_map(fix.badpix);
+
+ fix.nuc.component_ids = {comp2};
+ img->add_sensor_nuc(fix.nuc);
+
+ img->set_chroma_location(fix.chroma_location);
+
+#if HEIF_WITH_OMAF
+ img->set_omaf_image_projection(fix.omaf);
+#endif
+
+ return img;
+}
+
+
+// Asserts that every metadata field from `fix` is present on `img` and that
+// the two monochrome components (with their gimi content IDs) survived.
+static void check_metadata(const std::shared_ptr<HeifPixelImage>& img,
+ const MetadataFixture& fix)
+{
+ REQUIRE(img->is_premultiplied_alpha() == true);
+ REQUIRE(img->get_color_profile_nclx() == fix.nclx);
+
+ uint32_t h = 0, v = 0;
+ img->get_pixel_ratio(&h, &v);
+ REQUIRE(h == fix.pixel_ratio_h);
+ REQUIRE(v == fix.pixel_ratio_v);
+
+ REQUIRE(img->has_clli());
+ REQUIRE(img->get_clli().max_content_light_level == fix.clli.max_content_light_level);
+ REQUIRE(img->get_clli().max_pic_average_light_level == fix.clli.max_pic_average_light_level);
+
+ REQUIRE(img->has_mdcv());
+ const auto& m = img->get_mdcv();
+ REQUIRE(m.display_primaries_x[0] == fix.mdcv.display_primaries_x[0]);
+ REQUIRE(m.display_primaries_y[2] == fix.mdcv.display_primaries_y[2]);
+ REQUIRE(m.white_point_x == fix.mdcv.white_point_x);
+ REQUIRE(m.max_display_mastering_luminance == fix.mdcv.max_display_mastering_luminance);
+
+ const heif_tai_timestamp_packet* tai = img->get_tai_timestamp();
+ REQUIRE(tai != nullptr);
+ REQUIRE(tai->tai_timestamp == fix.tai.tai_timestamp);
+ REQUIRE(tai->synchronization_state == fix.tai.synchronization_state);
+
+ REQUIRE(img->has_gimi_sample_content_id());
+ REQUIRE(img->get_gimi_sample_content_id() == fix.sample_gimi);
+
+ REQUIRE(img->has_any_bayer_pattern());
+ const BayerPattern& bp = img->get_any_bayer_pattern();
+ REQUIRE(bp.pattern_width == fix.bayer.pattern_width);
+ REQUIRE(bp.pattern_height == fix.bayer.pattern_height);
+ REQUIRE(bp.pixels.size() == fix.bayer.pixels.size());
+
+ REQUIRE(img->has_polarization_patterns());
+ REQUIRE(img->get_polarization_patterns().size() == 1);
+ REQUIRE(img->get_polarization_patterns()[0].pattern_width == fix.polar.pattern_width);
+ REQUIRE(img->get_polarization_patterns()[0].polarization_angles == fix.polar.polarization_angles);
+
+ REQUIRE(img->has_sensor_bad_pixels_maps());
+ REQUIRE(img->get_sensor_bad_pixels_maps().size() == 1);
+ REQUIRE(img->get_sensor_bad_pixels_maps()[0].bad_rows == fix.badpix.bad_rows);
+ REQUIRE(img->get_sensor_bad_pixels_maps()[0].bad_columns == fix.badpix.bad_columns);
+
+ REQUIRE(img->has_sensor_nuc());
+ REQUIRE(img->get_sensor_nuc().size() == 1);
+ REQUIRE(img->get_sensor_nuc()[0].image_width == fix.nuc.image_width);
+ REQUIRE(img->get_sensor_nuc()[0].nuc_gains == fix.nuc.nuc_gains);
+
+ REQUIRE(img->has_chroma_location());
+ REQUIRE(img->get_chroma_location() == fix.chroma_location);
+
+ REQUIRE(img->get_sample_duration() == fix.sample_duration);
+
+#if HEIF_WITH_OMAF
+ REQUIRE(img->get_omaf_image_projection() == fix.omaf);
+#endif
+
+ // Two monochrome components must still be there, with their gimi content IDs.
+ // Rotate / crop re-mint component IDs; create_clone_image_at_new_size reuses
+ // them. In both cases the order in m_components is preserved.
+ std::vector<std::string> mono_gimi_ids;
+ for (const auto& c : img->get_component_descriptions()) {
+ if (c.component_type == heif_unci_component_type_monochrome) {
+ mono_gimi_ids.push_back(c.gimi_content_id);
+ }
+ }
+ REQUIRE(mono_gimi_ids.size() == 2);
+ REQUIRE(mono_gimi_ids[0] == fix.comp1_gimi);
+ REQUIRE(mono_gimi_ids[1] == fix.comp2_gimi);
+}
+
+
+TEST_CASE("rotate_ccw 90 preserves metadata and components", "[image_description_metadata]")
+{
+ MetadataFixture fix;
+ uint32_t c1, c2;
+ auto src = build_image(fix, c1, c2);
+
+ auto rotated_r = src->rotate_ccw(90, heif_get_global_security_limits());
+ REQUIRE(rotated_r);
+ auto rotated = *rotated_r;
+
+ REQUIRE(rotated->get_width() == 4);
+ REQUIRE(rotated->get_height() == 8);
+
+ check_metadata(rotated, fix);
+}
+
+
+TEST_CASE("crop preserves metadata and components", "[image_description_metadata]")
+{
+ MetadataFixture fix;
+ uint32_t c1, c2;
+ auto src = build_image(fix, c1, c2);
+
+ // crop to a 4x2 region (left=0, right=3, top=0, bottom=1)
+ auto cropped_r = src->crop(0, 3, 0, 1, heif_get_global_security_limits());
+ REQUIRE(cropped_r);
+ auto cropped = *cropped_r;
+
+ REQUIRE(cropped->get_width() == 4);
+ REQUIRE(cropped->get_height() == 2);
+
+ check_metadata(cropped, fix);
+}
+
+
+TEST_CASE("create_clone_image_at_new_size preserves metadata and components",
+ "[image_description_metadata]")
+{
+ MetadataFixture fix;
+ uint32_t c1, c2;
+ auto src = build_image(fix, c1, c2);
+
+ auto clone = std::make_shared<HeifPixelImage>();
+ Error err = clone->create_clone_image_at_new_size(src, 16, 8,
+ heif_get_global_security_limits());
+ REQUIRE(!err);
+
+ REQUIRE(clone->get_width() == 16);
+ REQUIRE(clone->get_height() == 8);
+
+ check_metadata(clone, fix);
+}