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);
+}