Commit 3546d1a6 for libheif

commit 3546d1a6fff65836910419673bb5b97d2dfb0506
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Oct 5 12:28:53 2026 +0200

    Check the result of every color conversion step against its declared state

    A color conversion operation declares in state_after_conversion() which
    image it is going to return, and the pipeline is planned with these
    declarations. The next operation reads the planes that its input state
    lists, with the bit depths given there. Nothing compared the image that
    an operation returned with what it had declared. An operation that
    contradicts its declaration makes the next one read planes that do not
    exist or that have another sample size, as in the null-pointer write
    fixed in c4769e5a, where the alpha compositing returned RGB planes for a
    YCbCr state.

    ColorConversionPipeline::convert_image() now checks the image of every
    step: it has to have the declared colorspace and chroma format, the
    declared planes with the declared bit depths, and each plane has to have
    the size that its channel implies. A contradiction is an error in the
    operation. It triggers an assertion in debug builds, so that it is seen
    during development, and fails the conversion otherwise.

    For this:
    - ColorState::from_image_planes() gives the state that describes the
      planes of an image. It is the code that convert_colorspace() used for
      its input image.
    - ColorState::has_same_planes() compares two states without their nclx.
      operator==() uses it.
    - ColorConversionPipeline::check_operation_output() is the check.
    - ColorConversionPipeline::get_operations() gives access to the
      operations, so that the test can run each of them.

    The new test runs every operation against every state that an image can
    have (including bit depths below 8, planes of different bit depths and
    Bayer images) and against a large set of target states, and compares
    each returned image with the declaration. It takes the operations from
    the pipeline, so an operation that is added later is tested as well. It
    also checks check_operation_output() with images that contradict a
    declaration, the states that Op_RGB_to_YCbCr declares, and that a
    successful conversion through convert_colorspace() returns the requested
    format.

    The contradiction that the test finds without the previous commit is the
    one in Op_RGB_to_YCbCr. With assertions enabled, the 1.38 million
    conversions mentioned there run without triggering the new assertion.

diff --git a/libheif/color-conversion/colorconversion.cc b/libheif/color-conversion/colorconversion.cc
index abebc8be..aea16af3 100644
--- a/libheif/color-conversion/colorconversion.cc
+++ b/libheif/color-conversion/colorconversion.cc
@@ -155,6 +155,38 @@ ColorState::ColorState(heif_colorspace cs, heif_chroma chr, bool with_alpha, int
 }


+ColorState ColorState::from_image_planes(const HeifPixelImage& image)
+{
+  ColorState state;
+  state.colorspace = image.get_colorspace();
+  state.chroma = image.get_chroma_format();
+
+  // Record the bit depth of every plane the image has. They may differ from each other
+  // (e.g. 'unci' declares a depth per component), which is why ColorState keeps one value
+  // per plane instead of a single image-wide depth.
+  for (heif_channel channel : {heif_channel_Y, heif_channel_Cb, heif_channel_Cr,
+                               heif_channel_R, heif_channel_G, heif_channel_B,
+                               heif_channel_Alpha, heif_channel_filter_array}) {
+    if (image.has_channel(channel)) {
+      state.set_bits_per_pixel(channel, image.get_bits_per_pixel(channel));
+    }
+  }
+
+  // Interleaved RGB formats keep all components in one plane. Represent them by their
+  // per-component depth so that operators see the same R/G/B (and alpha) fields as for
+  // planar RGB.
+  if (image.has_channel(heif_channel_interleaved)) {
+    int bpp = image.get_bits_per_pixel(heif_channel_interleaved);
+    state.set_bits_per_pixel(heif_channel_interleaved, bpp);
+    if (is_interleaved_with_alpha(image.get_chroma_format())) {
+      state.bits_per_pixel_alpha = bpp;
+    }
+  }
+
+  return state;
+}
+
+
 int ColorState::get_bits_per_pixel(heif_channel channel) const
 {
   switch (channel) {
@@ -369,20 +401,24 @@ bool ColorState::all_channels_have_bytes_per_sample(int bytes) const
 }


+bool ColorState::has_same_planes(const ColorState& b) const
+{
+  return (colorspace == b.colorspace &&
+          chroma == b.chroma &&
+          bits_per_pixel_R == b.bits_per_pixel_R &&
+          bits_per_pixel_G == b.bits_per_pixel_G &&
+          bits_per_pixel_B == b.bits_per_pixel_B &&
+          bits_per_pixel_Y == b.bits_per_pixel_Y &&
+          bits_per_pixel_Cb == b.bits_per_pixel_Cb &&
+          bits_per_pixel_Cr == b.bits_per_pixel_Cr &&
+          bits_per_pixel_alpha == b.bits_per_pixel_alpha &&
+          bits_per_pixel_filter_array == b.bits_per_pixel_filter_array);
+}
+
+
 bool ColorState::operator==(const ColorState& b) const
 {
-  bool mainParamsMatch = (colorspace == b.colorspace &&
-                          chroma == b.chroma &&
-                          bits_per_pixel_R == b.bits_per_pixel_R &&
-                          bits_per_pixel_G == b.bits_per_pixel_G &&
-                          bits_per_pixel_B == b.bits_per_pixel_B &&
-                          bits_per_pixel_Y == b.bits_per_pixel_Y &&
-                          bits_per_pixel_Cb == b.bits_per_pixel_Cb &&
-                          bits_per_pixel_Cr == b.bits_per_pixel_Cr &&
-                          bits_per_pixel_alpha == b.bits_per_pixel_alpha &&
-                          bits_per_pixel_filter_array == b.bits_per_pixel_filter_array);
-
-  if (!mainParamsMatch) {
+  if (!has_same_planes(b)) {
     return false;
   }

@@ -513,6 +549,13 @@ void ColorConversionPipeline::release_ops()
 }


+const std::vector<std::shared_ptr<ColorConversionOperation>>& ColorConversionPipeline::get_operations()
+{
+  init_ops();
+  return m_operation_pool;
+}
+
+
 bool ColorConversionPipeline::construct_pipeline(const ColorState& input_state,
                                                  const ColorState& target_state,
                                                  const heif_color_conversion_options& options,
@@ -672,6 +715,21 @@ bool ColorConversionPipeline::construct_pipeline(const ColorState& input_state,
 }


+Error ColorConversionPipeline::check_operation_output(const std::shared_ptr<HeifPixelImage>& image,
+                                                      const ColorState& declared_state)
+{
+  if (image &&
+      ColorState::from_image_planes(*image).has_same_planes(declared_state) &&
+      !image->check_plane_layout()) {
+    return Error::Ok;
+  }
+
+  return Error{heif_error_Unsupported_feature,
+               heif_suberror_Unsupported_color_conversion,
+               "Internal error: a color conversion step did not return the image format it declared"};
+}
+
+
 std::string ColorConversionPipeline::debug_dump_pipeline() const
 {
   std::ostringstream ostr;
@@ -705,6 +763,16 @@ Result<std::shared_ptr<HeifPixelImage>> ColorConversionPipeline::convert_image(c
       out = *outResult;
     }

+    // The pipeline was planned with the states that the operations declared in
+    // state_after_conversion(). The next operation reads the planes that its input state
+    // lists, with the bit depths given there, and the caller expects an image in the target
+    // state. An operation that returns an image with other planes than it declared must not
+    // get any further: this is the only place that sees both the declaration and the image.
+    if (Error err = check_operation_output(out, step.output_state)) {
+      assert(false); // fail on debug builds
+      return err;
+    }
+
     // copy metadata over to new image
     out->copy_metadata_from(*in);

@@ -777,9 +845,7 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
                  "Color conversion: " + err.message};
   }

-  ColorState input_state;
-  input_state.colorspace = input->get_colorspace();
-  input_state.chroma = input->get_chroma_format();
+  ColorState input_state = ColorState::from_image_planes(*input);
   if (input->has_nclx_color_profile()) {
     input_state.nclx = input->get_color_profile_nclx();
   }
@@ -789,28 +855,6 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
   std::set<enum heif_channel> channels = input->get_channel_set();
   assert(!channels.empty());

-  // Record the bit depth of every plane the image has. They may differ from each other
-  // (e.g. 'unci' declares a depth per component), which is why ColorState keeps one value
-  // per plane instead of a single image-wide depth.
-  for (heif_channel channel : {heif_channel_Y, heif_channel_Cb, heif_channel_Cr,
-                               heif_channel_R, heif_channel_G, heif_channel_B,
-                               heif_channel_Alpha, heif_channel_filter_array}) {
-    if (input->has_channel(channel)) {
-      input_state.set_bits_per_pixel(channel, input->get_bits_per_pixel(channel));
-    }
-  }
-
-  // Interleaved RGB formats keep all components in one plane. Represent them by their
-  // per-component depth so that operators see the same R/G/B (and alpha) fields as for
-  // planar RGB.
-  if (input->has_channel(heif_channel_interleaved)) {
-    int bpp = input->get_bits_per_pixel(heif_channel_interleaved);
-    input_state.set_bits_per_pixel(heif_channel_interleaved, bpp);
-    if (is_interleaved_with_alpha(input->get_chroma_format())) {
-      input_state.bits_per_pixel_alpha = bpp;
-    }
-  }
-
   ColorState output_state = input_state;
   output_state.colorspace = target_colorspace;
   output_state.chroma = target_chroma;
diff --git a/libheif/color-conversion/colorconversion.h b/libheif/color-conversion/colorconversion.h
index 67a38d37..35045741 100644
--- a/libheif/color-conversion/colorconversion.h
+++ b/libheif/color-conversion/colorconversion.h
@@ -58,6 +58,14 @@ struct ColorState
   // if 'with_alpha' is set, an alpha plane of the same depth is added.
   ColorState(heif_colorspace cs, heif_chroma chr, bool with_alpha, int bpp);

+  // The state that describes the planes of an image: its colorspace, its chroma format and
+  // the bit depth of every plane it has. The nclx is left at its default.
+  static ColorState from_image_planes(const HeifPixelImage& image);
+
+  // True if both states have the same colorspace, the same chroma format and the same planes
+  // with the same bit depths. Unlike operator==(), this does not look at the nclx.
+  bool has_same_planes(const ColorState&) const;
+
   bool has_alpha() const { return bits_per_pixel_alpha != 0; }

   // Bit depth of a single plane, 0 if the plane does not exist.
@@ -158,6 +166,13 @@ public:
                          const heif_color_conversion_options& options,
                          const heif_color_conversion_options_ext& options_ext) const = 0;

+  // Converts 'input', which is in 'input_state', into 'target_state', which is one of the
+  // states that state_after_conversion() returned for this input state.
+  // The returned image has to have exactly the planes of 'target_state': the pipeline is
+  // planned with the declared states, so the next operation reads the planes that the state
+  // lists, with the bit depths given there. ColorConversionPipeline::convert_image() checks
+  // this. An image that contradicts the declared state is an error in the operation: it
+  // triggers an assertion in debug builds and fails the conversion otherwise.
   virtual Result<std::shared_ptr<HeifPixelImage>>
   convert_colorspace(const std::shared_ptr<const HeifPixelImage>& input,
                      const ColorState& input_state,
@@ -174,6 +189,16 @@ public:
   static void init_ops();
   static void release_ops();

+  // All conversion operations that a pipeline can be built from.
+  static const std::vector<std::shared_ptr<ColorConversionOperation>>& get_operations();
+
+  // Checks the image that a conversion operation returned against the state that the
+  // operation declared for it in state_after_conversion(): the same colorspace, the same
+  // chroma format and the same planes with the same bit depths, each plane with the size
+  // that its channel implies.
+  static Error check_operation_output(const std::shared_ptr<HeifPixelImage>& image,
+                                      const ColorState& declared_state);
+
   bool is_nop() const { return m_conversion_steps.empty(); }

   bool construct_pipeline(const ColorState& input_state,
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 94f88de5..d64a1e31 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -49,6 +49,7 @@ else()
     add_libheif_test(fraction)
     add_libheif_test(id_creator)
     add_libheif_test(conversion)
+    add_libheif_test(conversion_operator_contract)
     add_libheif_test(plane_layout)
     add_libheif_test(fill_channel)
     add_libheif_test(duplicate_alpha_channel)
diff --git a/tests/conversion_operator_contract.cc b/tests/conversion_operator_contract.cc
new file mode 100644
index 00000000..bee7c455
--- /dev/null
+++ b/tests/conversion_operator_contract.cc
@@ -0,0 +1,648 @@
+/*
+  libheif unit tests
+
+  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.
+*/
+
+// A color conversion operator declares in state_after_conversion() which image it is going
+// to return, and the conversion pipeline is planned with these declarations: the next
+// operator reads the planes that its input state lists, with the bit depths given there.
+// An operator that returns an image with other planes than it declared makes the next
+// operator read planes that do not exist or that have another sample size.
+//
+// Op_RGB_to_YCbCr copied the chroma format of the target state into the state it declared
+// without checking that it is a chroma format of planar YCbCr. For a monochrome target it
+// declared a YCbCr state with a Y plane only and returned an image with Y, Cb and Cr planes.
+// For an interleaved RGB target it declared a YCbCr state with R, G and B.
+//
+// The tests run every operator against every state that an image can have and against a
+// large set of target states, and compare each returned image with the declaration. They
+// also check the place where the pipeline now verifies this for every conversion step.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "api_structs.h"
+#include "color-conversion/colorconversion.h"
+#include "color-conversion/rgb2yuv.h"
+#include "image/pixelimage.h"
+#include "nclx.h"
+
+#include <cstdint>
+#include <cstring>
+#include <memory>
+#include <set>
+#include <sstream>
+#include <string>
+#include <typeinfo>
+#include <utility>
+#include <vector>
+
+namespace {
+
+using OptionsExt = std::unique_ptr<heif_color_conversion_options_ext, void (*)(heif_color_conversion_options_ext*)>;
+
+OptionsExt make_options_ext(heif_alpha_composition_mode mode)
+{
+  OptionsExt ext(heif_color_conversion_options_ext_alloc(), heif_color_conversion_options_ext_free);
+  ext->alpha_composition_mode = mode;
+  ext->checkerboard_square_size = 4;
+  return ext;
+}
+
+
+const heif_chroma kChromas[] = {heif_chroma_monochrome, heif_chroma_420, heif_chroma_422, heif_chroma_444,
+                                heif_chroma_interleaved_RGB, heif_chroma_interleaved_RGBA,
+                                heif_chroma_interleaved_RRGGBB_BE, heif_chroma_interleaved_RRGGBBAA_BE,
+                                heif_chroma_interleaved_RRGGBB_LE, heif_chroma_interleaved_RRGGBBAA_LE};
+
+// Bit depths below 8, with 8 bits, with 16-bit samples and with samples that no operator reads.
+const int kDepths[] = {4, 8, 10, 16, 32};
+
+
+bool is_interleaved(heif_chroma chroma)
+{
+  return num_interleaved_components_per_plane(chroma) > 1;
+}
+
+
+nclx_profile make_nclx(int matrix_coefficients, bool full_range)
+{
+  nclx_profile nclx = nclx_profile::defaults();
+  nclx.set_matrix_coefficients(static_cast<heif_matrix_coefficients>(matrix_coefficients));
+  nclx.set_full_range_flag(full_range);
+  return nclx;
+}
+
+// The default, GBR, BT.709 with limited range and YCgCo.
+std::vector<nclx_profile> nclx_variants()
+{
+  return {nclx_profile::defaults(), make_nclx(0, true), make_nclx(1, false), make_nclx(8, true)};
+}
+
+
+std::string describe(const ColorState& state)
+{
+  std::ostringstream ostr;
+  ostr << state;
+  return ostr.str();
+}
+
+
+// Whether an image can have this state: the planes that the colorspace and the chroma format
+// imply, and no others.
+bool is_image_state(const ColorState& s)
+{
+  const bool ycbcr_planes = s.bits_per_pixel_Y || s.bits_per_pixel_Cb || s.bits_per_pixel_Cr;
+  const bool rgb_planes = s.bits_per_pixel_R || s.bits_per_pixel_G || s.bits_per_pixel_B;
+
+  if (s.colorspace == heif_colorspace_filter_array) {
+    return s.chroma == heif_chroma_planar && s.bits_per_pixel_filter_array && !ycbcr_planes && !rgb_planes &&
+           !s.bits_per_pixel_alpha;
+  }
+
+  if (s.bits_per_pixel_filter_array) {
+    return false;
+  }
+
+  switch (s.colorspace) {
+    case heif_colorspace_YCbCr:
+      return (s.chroma == heif_chroma_420 || s.chroma == heif_chroma_422 || s.chroma == heif_chroma_444) &&
+             !rgb_planes && s.bits_per_pixel_Y && s.bits_per_pixel_Cb && s.bits_per_pixel_Cr;
+
+    case heif_colorspace_monochrome:
+      return s.chroma == heif_chroma_monochrome && !rgb_planes &&
+             s.bits_per_pixel_Y && !s.bits_per_pixel_Cb && !s.bits_per_pixel_Cr;
+
+    case heif_colorspace_RGB: {
+      if (ycbcr_planes || !s.bits_per_pixel_R || !s.bits_per_pixel_G || !s.bits_per_pixel_B) {
+        return false;
+      }
+      if (s.chroma == heif_chroma_444) {
+        return true;
+      }
+      if (!is_interleaved(s.chroma)) {
+        return false;
+      }
+
+      const int bpp = s.bits_per_pixel_R;
+      if (s.bits_per_pixel_G != bpp || s.bits_per_pixel_B != bpp) {
+        return false;
+      }
+      if (s.bits_per_pixel_alpha != (is_interleaved_with_alpha(s.chroma) ? bpp : 0)) {
+        return false;
+      }
+      if (s.chroma == heif_chroma_interleaved_RGB || s.chroma == heif_chroma_interleaved_RGBA) {
+        return bpp == 8;
+      }
+      return bpp >= 9 && bpp <= 16;
+    }
+
+    default:
+      return false;
+  }
+}
+
+
+uint32_t g_random_state = 1;
+
+uint32_t next_random()
+{
+  g_random_state = g_random_state * 1664525u + 1013904223u;
+  return g_random_state >> 8;
+}
+
+// Fills a plane with samples that are within the range of the bit depth.
+void fill_plane(uint8_t* mem, size_t stride, uint32_t samples_per_row, uint32_t rows, int bits, bool big_endian)
+{
+  const int bytes = bytes_per_sample_for_bit_depth(bits);
+
+  for (uint32_t y = 0; y < rows; y++) {
+    for (uint32_t x = 0; x < samples_per_row; x++) {
+      uint64_t value = next_random();
+      if (bits < 24) {
+        value &= (uint64_t{1} << bits) - 1;
+      }
+
+      uint8_t* p = mem + y * stride + static_cast<size_t>(x) * bytes;
+      if (bytes == 2 && big_endian) {
+        p[0] = static_cast<uint8_t>(value >> 8);
+        p[1] = static_cast<uint8_t>(value);
+      }
+      else if (bytes == 2) {
+        auto v16 = static_cast<uint16_t>(value);
+        memcpy(p, &v16, 2);
+      }
+      else {
+        memcpy(p, &value, bytes < 8 ? bytes : 8);
+      }
+    }
+  }
+}
+
+
+std::shared_ptr<HeifPixelImage> make_bayer_image(const ColorState& state, uint32_t w, uint32_t h)
+{
+  const int bits = state.bits_per_pixel_filter_array;
+
+  heif_image* image = nullptr;
+  REQUIRE(heif_image_create(static_cast<int>(w), static_cast<int>(h),
+                            heif_colorspace_filter_array, heif_chroma_planar, &image).code == heif_error_Ok);
+
+  uint32_t filter_array_id = 0;
+  REQUIRE(heif_image_add_component(image, w, h, heif_cmpd_component_type_filter_array,
+                                   heif_component_datatype_unsigned_integer, bits, &filter_array_id).code == heif_error_Ok);
+
+  uint32_t r_id = 0, g_id = 0, b_id = 0;
+  REQUIRE(heif_image_add_bayer_component(image, heif_cmpd_component_type_red, &r_id).code == heif_error_Ok);
+  REQUIRE(heif_image_add_bayer_component(image, heif_cmpd_component_type_green, &g_id).code == heif_error_Ok);
+  REQUIRE(heif_image_add_bayer_component(image, heif_cmpd_component_type_blue, &b_id).code == heif_error_Ok);
+
+  heif_bayer_pattern_pixel pattern[4] = {{r_id, 1.0f}, {g_id, 1.0f}, {g_id, 1.0f}, {b_id, 1.0f}};
+  REQUIRE(heif_image_set_bayer_pattern(image, filter_array_id, 2, 2, pattern).code == heif_error_Ok);
+
+  std::shared_ptr<HeifPixelImage> img = image->image;
+  heif_image_release(image);
+
+  size_t stride = 0;
+  uint8_t* mem = img->get_channel_memory(heif_channel_filter_array, &stride);
+  REQUIRE(mem != nullptr);
+  fill_plane(mem, stride, w, h, bits, false);
+
+  return img;
+}
+
+
+// An image with the planes of 'state'. Returns null if HeifPixelImage refuses to create it.
+std::shared_ptr<HeifPixelImage> make_image(const ColorState& state, uint32_t w, uint32_t h)
+{
+  if (state.colorspace == heif_colorspace_filter_array) {
+    return make_bayer_image(state, w, h);
+  }
+
+  const heif_security_limits* limits = heif_get_disabled_security_limits();
+
+  auto img = std::make_shared<HeifPixelImage>();
+  img->create(w, h, state.colorspace, state.chroma);
+
+  auto add_plane = [&](heif_channel channel, uint32_t plane_w, uint32_t plane_h, int bits, int components) {
+    if (img->add_channel(channel, plane_w, plane_h, bits, limits)) {
+      return false;
+    }
+
+    const bool big_endian = (state.chroma == heif_chroma_interleaved_RRGGBB_BE ||
+                             state.chroma == heif_chroma_interleaved_RRGGBBAA_BE);
+    // Planar 16-bit samples are in the byte order of the machine.
+    const bool store_big_endian = (channel == heif_channel_interleaved) ? big_endian : (std::endian::native == std::endian::big);
+
+    size_t stride = 0;
+    uint8_t* mem = img->get_channel_memory(channel, &stride);
+    fill_plane(mem, stride, plane_w * components, plane_h, bits, store_big_endian);
+    return true;
+  };
+
+  if (is_interleaved(state.chroma)) {
+    if (!add_plane(heif_channel_interleaved, w, h, state.bits_per_pixel_R, num_interleaved_components_per_plane(state.chroma))) {
+      return nullptr;
+    }
+  }
+  else {
+    for (heif_channel channel : {heif_channel_Y, heif_channel_Cb, heif_channel_Cr,
+                                 heif_channel_R, heif_channel_G, heif_channel_B, heif_channel_Alpha}) {
+      const int bits = state.get_bits_per_pixel(channel);
+      if (bits == 0) {
+        continue;
+      }
+
+      if (!add_plane(channel, channel_width(w, state.chroma, channel), channel_height(h, state.chroma, channel), bits, 1)) {
+        return nullptr;
+      }
+    }
+  }
+
+  img->set_color_profile_nclx(state.nclx);
+  return img;
+}
+
+
+// colorspace x chroma format x bit depth x alpha x nclx, whether an image can have that
+// combination or not. A target state can be any of these.
+std::vector<ColorState> all_states()
+{
+  std::vector<ColorState> states;
+
+  for (heif_colorspace colorspace : {heif_colorspace_YCbCr, heif_colorspace_RGB, heif_colorspace_monochrome,
+                                     heif_colorspace_filter_array, heif_colorspace_undefined}) {
+    for (heif_chroma chroma : kChromas) {
+      for (int bpp : kDepths) {
+        for (int alpha_bpp : {0, bpp, bpp == 8 ? 10 : 8}) {
+          for (const nclx_profile& nclx : nclx_variants()) {
+            ColorState state;
+            state.colorspace = colorspace;
+            state.chroma = chroma;
+            state.set_color_bits_per_pixel(bpp);
+            state.bits_per_pixel_alpha = alpha_bpp;
+            state.nclx = nclx;
+            states.push_back(state);
+          }
+        }
+      }
+    }
+  }
+
+  return states;
+}
+
+
+// The states that an image can have.
+std::vector<ColorState> image_states()
+{
+  const nclx_profile default_nclx = nclx_profile::defaults();
+
+  std::vector<ColorState> states;
+
+  for (const ColorState& state : all_states()) {
+    if (!is_image_state(state)) {
+      continue;
+    }
+
+    // The nclx of a state is only looked at for YCbCr.
+    if (state.colorspace != heif_colorspace_YCbCr &&
+        (state.nclx.get_matrix_coefficients() != default_nclx.get_matrix_coefficients() ||
+         state.nclx.get_full_range_flag() != default_nclx.get_full_range_flag())) {
+      continue;
+    }
+
+    states.push_back(state);
+  }
+
+  // planes of different bit depths, as an 'unci' image can have them
+  for (heif_chroma chroma : {heif_chroma_420, heif_chroma_444}) {
+    ColorState state;
+    state.colorspace = heif_colorspace_YCbCr;
+    state.chroma = chroma;
+    state.nclx = default_nclx;
+
+    state.bits_per_pixel_Y = 10;
+    state.bits_per_pixel_Cb = 8;
+    state.bits_per_pixel_Cr = 8;
+    states.push_back(state);
+
+    state.bits_per_pixel_Y = 8;
+    state.bits_per_pixel_Cb = 12;
+    state.bits_per_pixel_Cr = 10;
+    states.push_back(state);
+  }
+
+  {
+    ColorState state;
+    state.colorspace = heif_colorspace_RGB;
+    state.chroma = heif_chroma_444;
+    state.nclx = default_nclx;
+
+    state.bits_per_pixel_R = 5;
+    state.bits_per_pixel_G = 6;
+    state.bits_per_pixel_B = 5;
+    states.push_back(state);
+
+    state.bits_per_pixel_R = 8;
+    state.bits_per_pixel_G = 10;
+    state.bits_per_pixel_B = 8;
+    state.bits_per_pixel_alpha = 8;
+    states.push_back(state);
+  }
+
+  return states;
+}
+
+
+struct Options
+{
+  heif_color_conversion_options options;
+  heif_alpha_composition_mode alpha_mode;
+};
+
+std::vector<Options> option_sets()
+{
+  std::vector<Options> sets;
+
+  heif_color_conversion_options options;
+  heif_color_conversion_options_set_defaults(&options);
+
+  // only the preferred chroma algorithms, alpha is kept
+  options.only_use_preferred_chroma_algorithm = true;
+  sets.push_back({options, heif_alpha_composition_mode_none});
+
+  // any chroma algorithm, alpha is composited onto a background
+  options.only_use_preferred_chroma_algorithm = false;
+  sets.push_back({options, heif_alpha_composition_mode_solid_color});
+
+  return sets;
+}
+
+} // namespace
+
+
+TEST_CASE("conversion operators return the image that they declared")
+{
+  const std::vector<ColorState> targets = all_states();
+  const std::vector<ColorState> inputs = image_states();
+  const heif_security_limits* limits = heif_get_disabled_security_limits();
+
+  size_t num_operators_used = 0;
+  size_t num_conversions = 0;
+  size_t num_contradictions = 0;
+  std::string examples;
+
+  for (const auto& op : ColorConversionPipeline::get_operations()) {
+    const auto& op_ref = *op;
+    const std::string op_name = typeid(op_ref).name();
+    size_t num_conversions_of_operator = 0;
+
+    for (const Options& opt : option_sets()) {
+      OptionsExt ext = make_options_ext(opt.alpha_mode);
+
+      for (const ColorState& input_state : inputs) {
+        // The same output state is declared for many target states. Run each conversion once.
+        std::set<std::string> converted;
+
+        for (const ColorState& target_state : targets) {
+          for (const ColorStateWithCost& declared : op->state_after_conversion(input_state, target_state, opt.options, *ext)) {
+            if (!converted.insert(describe(declared.color_state)).second) {
+              continue;
+            }
+
+            // an even and an odd image size
+            for (auto size : {std::pair<uint32_t, uint32_t>{18, 10}, std::pair<uint32_t, uint32_t>{7, 5}}) {
+              auto image = make_image(input_state, size.first, size.second);
+              REQUIRE(image != nullptr);
+
+              auto result = op->convert_colorspace(image, input_state, declared.color_state, opt.options, *ext, limits);
+              if (!result) {
+                // Refusing the conversion does not contradict the declaration.
+                continue;
+              }
+
+              num_conversions++;
+              num_conversions_of_operator++;
+
+              if (ColorConversionPipeline::check_operation_output(*result, declared.color_state)) {
+                num_contradictions++;
+                if (num_contradictions <= 5) {
+                  examples += op_name + "\n  input:    " + describe(input_state) +
+                              "\n  declared: " + describe(declared.color_state) +
+                              "\n  returned: " + describe(ColorState::from_image_planes(**result)) + "\n";
+                }
+              }
+              else {
+                CHECK((*result)->get_width() == size.first);
+                CHECK((*result)->get_height() == size.second);
+              }
+            }
+          }
+        }
+      }
+    }
+
+    if (num_conversions_of_operator > 0) {
+      num_operators_used++;
+    }
+  }
+
+  INFO("first contradictions:\n" << examples);
+  CHECK(num_contradictions == 0);
+
+  // The test is of no use if the operators decline everything they are offered.
+  CHECK(num_operators_used >= 25);
+  CHECK(num_conversions >= 2000);
+}
+
+
+TEST_CASE("Op_RGB_to_YCbCr only declares planar YCbCr chroma formats")
+{
+  heif_color_conversion_options options;
+  heif_color_conversion_options_set_defaults(&options);
+  options.only_use_preferred_chroma_algorithm = false;
+  OptionsExt ext = make_options_ext(heif_alpha_composition_mode_none);
+
+  for (int bpp : {8, 10}) {
+    ColorState input(heif_colorspace_RGB, heif_chroma_444, false, bpp);
+    input.nclx = nclx_profile::defaults();
+
+    auto declared_states = [&](heif_colorspace colorspace, heif_chroma chroma) {
+      ColorState target(colorspace, chroma, false, bpp);
+      target.nclx = nclx_profile::defaults();
+
+      if (bpp == 8) {
+        return Op_RGB_to_YCbCr<uint8_t>().state_after_conversion(input, target, options, *ext);
+      }
+      else {
+        return Op_RGB_to_YCbCr<uint16_t>().state_after_conversion(input, target, options, *ext);
+      }
+    };
+
+    for (heif_chroma chroma : {heif_chroma_420, heif_chroma_422, heif_chroma_444}) {
+      auto states = declared_states(heif_colorspace_YCbCr, chroma);
+      REQUIRE(states.size() == 1);
+      CHECK(states[0].color_state.colorspace == heif_colorspace_YCbCr);
+      CHECK(states[0].color_state.chroma == chroma);
+      CHECK(is_image_state(states[0].color_state));
+    }
+
+    // It used to declare a YCbCr state with the chroma format of these targets.
+    CHECK(declared_states(heif_colorspace_monochrome, heif_chroma_monochrome).empty());
+    CHECK(declared_states(heif_colorspace_RGB, heif_chroma_interleaved_RGB).empty());
+    CHECK(declared_states(heif_colorspace_RGB, heif_chroma_interleaved_RGBA).empty());
+    CHECK(declared_states(heif_colorspace_RGB, heif_chroma_interleaved_RRGGBB_LE).empty());
+    CHECK(declared_states(heif_colorspace_RGB, heif_chroma_interleaved_RRGGBBAA_BE).empty());
+  }
+}
+
+
+TEST_CASE("the pipeline refuses an operator result that contradicts the declared state")
+{
+  const heif_security_limits* limits = heif_get_disabled_security_limits();
+  const uint32_t w = 18, h = 10;
+
+  // An image with exactly these planes.
+  auto image_with_planes = [&](heif_colorspace colorspace, heif_chroma chroma,
+                               std::initializer_list<std::pair<heif_channel, int>> planes,
+                               uint32_t chroma_w, uint32_t chroma_h) {
+    auto img = std::make_shared<HeifPixelImage>();
+    img->create(w, h, colorspace, chroma);
+    for (const auto& plane : planes) {
+      const bool is_chroma = (plane.first == heif_channel_Cb || plane.first == heif_channel_Cr);
+      REQUIRE(!img->add_channel(plane.first, is_chroma ? chroma_w : w, is_chroma ? chroma_h : h, plane.second, limits));
+    }
+    return img;
+  };
+
+  const ColorState ycbcr420(heif_colorspace_YCbCr, heif_chroma_420, false, 8);
+  const ColorState ycbcr420_alpha(heif_colorspace_YCbCr, heif_chroma_420, true, 8);
+
+  auto good = image_with_planes(heif_colorspace_YCbCr, heif_chroma_420,
+                                {{heif_channel_Y, 8}, {heif_channel_Cb, 8}, {heif_channel_Cr, 8}}, 9, 5);
+
+  SECTION("an image with the declared planes is accepted") {
+    CHECK(!ColorConversionPipeline::check_operation_output(good, ycbcr420));
+  }
+
+  SECTION("no image") {
+    CHECK(ColorConversionPipeline::check_operation_output(nullptr, ycbcr420));
+  }
+
+  SECTION("what Op_RGB_to_YCbCr returned for a monochrome target") {
+    // declared: YCbCr with the monochrome chroma format and a Y plane only
+    ColorState declared;
+    declared.colorspace = heif_colorspace_YCbCr;
+    declared.chroma = heif_chroma_monochrome;
+    declared.set_color_bits_per_pixel(8);
+    REQUIRE(declared.bits_per_pixel_Cb == 0);
+
+    auto returned = image_with_planes(heif_colorspace_YCbCr, heif_chroma_monochrome,
+                                      {{heif_channel_Y, 8}, {heif_channel_Cb, 8}, {heif_channel_Cr, 8}}, w, h);
+    CHECK(ColorConversionPipeline::check_operation_output(returned, declared));
+  }
+
+  SECTION("a plane that was not declared") {
+    auto with_alpha = image_with_planes(heif_colorspace_YCbCr, heif_chroma_420,
+                                        {{heif_channel_Y, 8}, {heif_channel_Cb, 8}, {heif_channel_Cr, 8}, {heif_channel_Alpha, 8}}, 9, 5);
+    CHECK(ColorConversionPipeline::check_operation_output(with_alpha, ycbcr420));
+    CHECK(!ColorConversionPipeline::check_operation_output(with_alpha, ycbcr420_alpha));
+  }
+
+  SECTION("a declared plane is missing") {
+    CHECK(ColorConversionPipeline::check_operation_output(good, ycbcr420_alpha));
+  }
+
+  SECTION("another bit depth") {
+    // A plane with 8-bit samples that the next operator would read with 16-bit samples.
+    CHECK(ColorConversionPipeline::check_operation_output(good, ColorState(heif_colorspace_YCbCr, heif_chroma_420, false, 10)));
+
+    auto mixed = image_with_planes(heif_colorspace_YCbCr, heif_chroma_420,
+                                   {{heif_channel_Y, 10}, {heif_channel_Cb, 8}, {heif_channel_Cr, 8}}, 9, 5);
+    CHECK(ColorConversionPipeline::check_operation_output(mixed, ColorState(heif_colorspace_YCbCr, heif_chroma_420, false, 10)));
+  }
+
+  SECTION("another chroma format or colorspace") {
+    CHECK(ColorConversionPipeline::check_operation_output(good, ColorState(heif_colorspace_YCbCr, heif_chroma_422, false, 8)));
+    CHECK(ColorConversionPipeline::check_operation_output(good, ColorState(heif_colorspace_RGB, heif_chroma_444, false, 8)));
+  }
+
+  SECTION("planes that do not have the size of the image") {
+    // The planes and their bit depths are the declared ones, but one plane is larger.
+    auto img = std::make_shared<HeifPixelImage>();
+    img->create(w, h, heif_colorspace_RGB, heif_chroma_444);
+    REQUIRE(!img->add_channel(heif_channel_R, w, h, 8, limits));
+    REQUIRE(!img->add_channel(heif_channel_G, w, h, 8, limits));
+    REQUIRE(!img->add_channel(heif_channel_B, 2 * w, 2 * h, 8, limits));
+
+    CHECK(ColorConversionPipeline::check_operation_output(img, ColorState(heif_colorspace_RGB, heif_chroma_444, false, 8)));
+  }
+}
+
+
+TEST_CASE("a successful color conversion returns the requested format")
+{
+  const heif_security_limits* limits = heif_get_disabled_security_limits();
+
+  size_t num_converted = 0;
+
+  for (const Options& opt : option_sets()) {
+    OptionsExt ext = make_options_ext(opt.alpha_mode);
+
+    for (const ColorState& input_state : image_states()) {
+      auto image = make_image(input_state, 18, 10);
+      REQUIRE(image != nullptr);
+
+      // Any combination of colorspace and chroma format can be requested, also those that
+      // no image can have.
+      for (heif_colorspace colorspace : {heif_colorspace_YCbCr, heif_colorspace_RGB, heif_colorspace_monochrome}) {
+        for (heif_chroma chroma : kChromas) {
+          for (int output_bpp : {0, 8, 10}) {
+            auto result = convert_colorspace(image, colorspace, chroma, nclx_profile::undefined(), output_bpp,
+                                             opt.options, ext.get(), limits);
+            if (!result) {
+              // An unsupported conversion is refused, but no conversion step may have
+              // returned something else than it declared.
+              if (result.error().message.find("did not return the image format it declared") != std::string::npos) {
+                INFO("input: " << describe(input_state) << " requested colorspace " << colorspace << " chroma " << chroma);
+                FAIL_CHECK(result.error().message);
+              }
+              continue;
+            }
+
+            num_converted++;
+
+            const HeifPixelImage& out = **result;
+            if (out.get_colorspace() != colorspace || out.get_chroma_format() != chroma || out.check_plane_layout()) {
+              INFO("input: " << describe(input_state) << " requested colorspace " << colorspace << " chroma " << chroma);
+              FAIL_CHECK("the converted image does not have the requested format");
+            }
+          }
+        }
+      }
+    }
+  }
+
+  CHECK(num_converted >= 2000);
+}