Commit 468369af for libheif

commit 468369af250dde54699f2cc5b36e3bd1209da4e7
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Sep 20 15:40:08 2026 +0200

    Check the plane layout at the color-conversion and encoder entry points

    The conversion operators read the planes that an image's colorspace and
    chroma format imply, at the sizes they imply, and the encoder plugins do
    the same. Nothing verified that the image actually has those planes. An
    RGB image without its B plane failed with InternalError deep inside an
    operator; a YCbCr 4:2:0 image without its Cr plane passed the pipeline as
    a no-op (same layout as the encoder's request) and aborted in the x265
    plugin, whose only guard is an assert that release builds compile out;
    foreign planes (depth), duplicate colour planes and stray chroma planes
    were silently dropped or ignored, with the outcome depending on which
    operator touched them first.

    HeifPixelImage::check_plane_layout() verifies that the known planes are
    exactly the colour planes of the layout, optionally one separate alpha
    plane on the planar layouts, each present once and with the size its
    channel implies. Planes with channel heif_channel_unknown (the padding
    components of 'unci') carry no colour meaning and are tolerated; the
    pipeline does not carry them into its output. A custom colorspace has no
    defined layout.

    The check is made at two entry points. convert_colorspace() refuses a
    non-canonical image with Unsupported_image_type, and refuses custom
    colorspaces, which have nothing to convert. Encoder::check_input_image_layout()
    runs before the image is converted for, or handed unchanged to, a plugin
    and returns a Usage_error; custom colorspaces pass through because the
    uncompressed encoder writes their components as they are. It is virtual so
    that an encoder can widen or narrow it, and the plugin interface is
    unchanged; a plugin that supports only a subset of the layouts still
    checks its input itself.

    HeifPixelImage itself keeps accepting any set of planes, including several
    of the same channel: multi-spectral images consist of several monochrome
    planes.

    Tests: plane_layout drives the check through every canonical layout and
    each kind of defect and confirms that convert_colorspace() refuses them
    while tolerating unknown planes; encode_plane_layout feeds the defective
    images to heif_context_encode_image through the public API.

diff --git a/libheif/codecs/encoder.cc b/libheif/codecs/encoder.cc
index 257e3fe8..17927d7f 100644
--- a/libheif/codecs/encoder.cc
+++ b/libheif/codecs/encoder.cc
@@ -113,6 +113,16 @@ static bool nclx_profile_matches_spec(heif_colorspace colorspace,

 extern void fill_default_color_conversion_options_ext(heif_color_conversion_options_ext& options);

+Error Encoder::check_input_image_layout(const HeifPixelImage& image) const
+{
+  if (image.get_colorspace() == heif_colorspace_custom) {
+    return Error::Ok;
+  }
+
+  return image.check_plane_layout();
+}
+
+
 Result<std::shared_ptr<HeifPixelImage>> Encoder::convert_colorspace_for_encoding(const std::shared_ptr<HeifPixelImage>& image,
                                                                                  heif_encoder* encoder,
                                                                                  const heif_color_profile_nclx* user_requested_output_nclx,
@@ -128,6 +138,10 @@ Result<std::shared_ptr<HeifPixelImage>> Encoder::convert_colorspace_for_encoding
   }


+  if (Error err = check_input_image_layout(*image)) {
+    return err;
+  }
+
   heif_colorspace colorspace = image->get_colorspace();
   heif_chroma chroma = image->get_chroma_format();

diff --git a/libheif/codecs/encoder.h b/libheif/codecs/encoder.h
index ba51d0e0..a10d7ed7 100644
--- a/libheif/codecs/encoder.h
+++ b/libheif/codecs/encoder.h
@@ -60,6 +60,16 @@ public:
   // If the output format requires a specific nclx (like JPEG), return this. Otherwise, return NULL.
   virtual const heif_color_profile_nclx* get_forced_output_nclx() const { return nullptr; }

+  // Checks that an image handed to this encoder has a plane layout the encoder plugins can rely
+  // on: exactly the planes of its colorspace and chroma format, each once and at the size its
+  // channel implies (HeifPixelImage::check_plane_layout()). Planes with channel
+  // heif_channel_unknown are tolerated. Images with a custom colorspace pass unchecked: they
+  // have no defined layout, and the uncompressed encoder writes their components as they are.
+  // This is the libheif-side check, made before the image is colour-converted for the plugin
+  // (or handed over unchanged when no conversion is needed). A plugin that supports only a
+  // subset of the layouts checks its input itself (see plugins/encoder_input_check.h).
+  virtual Error check_input_image_layout(const HeifPixelImage& image) const;
+
   Result<std::shared_ptr<HeifPixelImage>> convert_colorspace_for_encoding(const std::shared_ptr<HeifPixelImage>& image,
                                                                           heif_encoder* encoder,
                                                                           const heif_color_profile_nclx* user_requested_output_nclx,
diff --git a/libheif/color-conversion/colorconversion.cc b/libheif/color-conversion/colorconversion.cc
index a13dd2f6..abebc8be 100644
--- a/libheif/color-conversion/colorconversion.cc
+++ b/libheif/color-conversion/colorconversion.cc
@@ -765,6 +765,18 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr

   // --- prepare conversion

+  // The operators read the planes that the colorspace and chroma format imply, with the sizes
+  // they imply. Refuse anything else up front instead of letting an operator run into a missing,
+  // duplicate or undersized plane: a plane set that does not match the format, or a colorspace
+  // without a defined layout (custom multi-component data has nothing to convert). Planes with
+  // channel heif_channel_unknown (the padding components of 'unci') are tolerated; they belong
+  // to no colour model and are not carried into the output.
+  if (Error err = input->check_plane_layout()) {
+    return Error{heif_error_Unsupported_feature,
+                 heif_suberror_Unsupported_image_type,
+                 "Color conversion: " + err.message};
+  }
+
   ColorState input_state;
   input_state.colorspace = input->get_colorspace();
   input_state.chroma = input->get_chroma_format();
diff --git a/libheif/image/pixelimage.cc b/libheif/image/pixelimage.cc
index bae4912c..8d294459 100644
--- a/libheif/image/pixelimage.cc
+++ b/libheif/image/pixelimage.cc
@@ -344,6 +344,25 @@ void HeifPixelImage::register_component_descriptions(ComponentStorage& plane,
 }


+static const char* channel_name(heif_channel channel)
+{
+  switch (channel) {
+    case heif_channel_Y: return "Y";
+    case heif_channel_Cb: return "Cb";
+    case heif_channel_Cr: return "Cr";
+    case heif_channel_R: return "R";
+    case heif_channel_G: return "G";
+    case heif_channel_B: return "B";
+    case heif_channel_Alpha: return "alpha";
+    case heif_channel_interleaved: return "interleaved";
+    case heif_channel_filter_array: return "filter_array";
+    case heif_channel_depth: return "depth";
+    case heif_channel_disparity: return "disparity";
+    default: return "unknown";
+  }
+}
+
+
 Error HeifPixelImage::add_channel(heif_channel channel, uint32_t width, uint32_t height, int bit_depth,
                                 const heif_security_limits* limits,
                                 heif_component_datatype datatype)
@@ -901,6 +920,118 @@ bool HeifPixelImage::has_standard_plane_sizes() const
 }


+Error HeifPixelImage::check_plane_layout() const
+{
+  std::vector<heif_channel> colour_planes;
+  bool separate_alpha_allowed = true;
+
+  auto layout_error = [this](const std::string& what) {
+    std::stringstream sstr;
+    sstr << what << " (colorspace " << static_cast<int>(m_colorspace)
+         << ", chroma " << static_cast<int>(m_chroma) << ")";
+    return Error{heif_error_Usage_error, heif_suberror_Invalid_parameter_value, sstr.str()};
+  };
+
+  switch (m_colorspace) {
+    case heif_colorspace_RGB:
+      switch (m_chroma) {
+        case heif_chroma_444:
+          colour_planes = {heif_channel_R, heif_channel_G, heif_channel_B};
+          break;
+        case heif_chroma_interleaved_RGB:
+        case heif_chroma_interleaved_RGBA:
+        case heif_chroma_interleaved_RRGGBB_BE:
+        case heif_chroma_interleaved_RRGGBB_LE:
+        case heif_chroma_interleaved_RRGGBBAA_BE:
+        case heif_chroma_interleaved_RRGGBBAA_LE:
+          colour_planes = {heif_channel_interleaved};
+          separate_alpha_allowed = false; // alpha, if any, is inside the interleaved plane
+          break;
+        default:
+          return layout_error("Chroma format is not valid for an RGB image");
+      }
+      break;
+
+    case heif_colorspace_YCbCr:
+      switch (m_chroma) {
+        case heif_chroma_444:
+        case heif_chroma_422:
+        case heif_chroma_420:
+          colour_planes = {heif_channel_Y, heif_channel_Cb, heif_channel_Cr};
+          break;
+        case heif_chroma_monochrome:
+          colour_planes = {heif_channel_Y};
+          break;
+        default:
+          return layout_error("Chroma format is not valid for a YCbCr image");
+      }
+      break;
+
+    case heif_colorspace_monochrome:
+      if (m_chroma != heif_chroma_monochrome) {
+        return layout_error("Chroma format is not valid for a monochrome image");
+      }
+      colour_planes = {heif_channel_Y};
+      break;
+
+    case heif_colorspace_filter_array:
+      if (m_chroma != heif_chroma_planar) {
+        return layout_error("Chroma format is not valid for a filter-array image");
+      }
+      colour_planes = {heif_channel_filter_array};
+      separate_alpha_allowed = false;
+      break;
+
+    default:
+      return layout_error("Colorspace has no defined plane layout");
+  }
+
+  // Every known plane has to belong to the layout, appear once, and have the size of its channel.
+
+  std::set<heif_channel> seen;
+
+  for (const auto& component : m_storage) {
+    heif_channel channel = component.m_channel;
+
+    if (channel == heif_channel_unknown) {
+      continue; // multi-component data without colour meaning, tolerated and ignored
+    }
+
+    bool belongs = (channel == heif_channel_Alpha && separate_alpha_allowed);
+    for (heif_channel c : colour_planes) {
+      if (c == channel) {
+        belongs = true;
+      }
+    }
+
+    if (!belongs) {
+      return layout_error(std::string("Image has a ") + channel_name(channel) + " plane that does not belong to its format");
+    }
+
+    if (!seen.insert(channel).second) {
+      return layout_error(std::string("Image has more than one ") + channel_name(channel) + " plane");
+    }
+
+    uint32_t expected_w = channel_width(m_width, m_chroma, channel);
+    uint32_t expected_h = channel_height(m_height, m_chroma, channel);
+    if (component.m_width != expected_w || component.m_height != expected_h) {
+      std::stringstream sstr;
+      sstr << "The " << channel_name(channel) << " plane has size " << component.m_width << "x" << component.m_height
+           << ", expected " << expected_w << "x" << expected_h;
+      return layout_error(sstr.str());
+    }
+  }
+
+  for (heif_channel channel : colour_planes) {
+    if (seen.count(channel) == 0) {
+      return layout_error(std::string("Image has no ") + channel_name(channel) + " plane");
+    }
+  }
+
+  return Error::Ok;
+}
+
+
 std::set<heif_channel> HeifPixelImage::get_channel_set() const
 {
   std::set<heif_channel> channels;
diff --git a/libheif/image/pixelimage.h b/libheif/image/pixelimage.h
index ca151c15..583ff46f 100644
--- a/libheif/image/pixelimage.h
+++ b/libheif/image/pixelimage.h
@@ -148,6 +148,21 @@ public:
   // undersized single plane.
   bool has_standard_plane_sizes() const;

+  // Checks that the stored planes are exactly those of the image's colorspace and chroma
+  // format: the colour planes of that layout (R/G/B, Y/Cb/Cr, Y alone, the interleaved plane,
+  // or the filter array), optionally one separate alpha plane on the planar layouts, each
+  // present exactly once and with the size its channel implies (chroma-subsampled for Cb/Cr,
+  // the image size otherwise). Planes with channel heif_channel_unknown carry multi-component
+  // data without a colour meaning (e.g. the padding components of 'unci'); they are tolerated
+  // and ignored. A custom colorspace has no defined layout and is rejected; a caller that
+  // accepts such images (the uncompressed encoder) has to test for it first.
+  //
+  // This is a check at the entry of the color conversion pipeline and of the encoders, not a
+  // constraint on HeifPixelImage itself: an image may hold any set of planes, including several
+  // of the same channel (multi-spectral images consist of several monochrome planes).
+  // Returns a Usage_error naming the offending plane.
+  Error check_plane_layout() const;
+
   heif_chroma get_chroma_format() const { return m_chroma; }

   heif_colorspace get_colorspace() const { return m_colorspace; }
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 4608196f..4df9937f 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(plane_layout)
     add_libheif_test(duplicate_alpha_channel)
     add_libheif_test(idat)
     add_libheif_test(scale_plane_checks)
@@ -101,6 +102,7 @@ add_libheif_test(overlay_amplification)
 add_libheif_test(error_item_decode)
 add_libheif_test(alpha_cycle_deadlock)
 add_libheif_test(alpha_composite_decode)
+add_libheif_test(encode_plane_layout)
 add_libheif_test(parallel_grid_deadlock)
 # The deadlock regression tests decode on a worker thread guarded by a timeout.
 find_package(Threads REQUIRED)
diff --git a/tests/encode_plane_layout.cc b/tests/encode_plane_layout.cc
new file mode 100644
index 00000000..29c124ad
--- /dev/null
+++ b/tests/encode_plane_layout.cc
@@ -0,0 +1,180 @@
+/*
+  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.
+*/
+
+// An image handed to heif_context_encode_image() has to have exactly the planes of its
+// colorspace and chroma format. Before this check, an RGB image without its B plane failed
+// deep inside a conversion operator, a YCbCr 4:2:0 image without its Cr plane passed the
+// pipeline as a no-op and aborted inside the x265 plugin (a release build would have read a
+// null plane), and foreign or duplicate planes were silently dropped or ignored. The check is
+// made on the libheif side in Encoder::check_input_image_layout(), before the image is
+// converted for, or handed unchanged to, the plugin. Planes with channel heif_channel_unknown
+// (custom multi-component data) are tolerated.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <cstdint>
+#include <cstring>
+
+namespace {
+
+constexpr uint32_t W = 32;
+constexpr uint32_t H = 32;
+
+heif_compression_format pick_encoder_format()
+{
+  for (heif_compression_format format : {heif_compression_HEVC, heif_compression_AV1, heif_compression_uncompressed}) {
+    if (heif_have_encoder_for_format(format)) {
+      return format;
+    }
+  }
+  return heif_compression_undefined;
+}
+
+heif_image* create_image(heif_colorspace cs, heif_chroma chroma)
+{
+  heif_image* img = nullptr;
+  heif_error err = heif_image_create(W, H, cs, chroma, &img);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(img != nullptr);
+  return img;
+}
+
+void add_plane(heif_image* img, heif_channel channel, uint32_t w, uint32_t h)
+{
+  heif_error err = heif_image_add_plane(img, channel, w, h, 8);
+  INFO("add_plane error: " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+
+  size_t stride = 0;
+  uint8_t* p = heif_image_get_plane2(img, channel, &stride);
+  REQUIRE(p != nullptr);
+  for (uint32_t y = 0; y < h; y++) {
+    memset(p + y * stride, 0x80, w);
+  }
+}
+
+heif_error encode(heif_image* img, heif_compression_format format)
+{
+  heif_context* ctx = heif_context_alloc();
+  heif_encoder* encoder = nullptr;
+  heif_error err = heif_context_get_encoder_for_format(ctx, format, &encoder);
+  REQUIRE(err.code == heif_error_Ok);
+
+  err = heif_context_encode_image(ctx, img, encoder, nullptr, nullptr);
+
+  heif_encoder_release(encoder);
+  heif_context_free(ctx);
+  return err;
+}
+
+void expect_refused(heif_image* img, heif_compression_format format)
+{
+  heif_error err = encode(img, format);
+  INFO("encode error (" << err.code << "/" << err.subcode << "): " << err.message);
+  REQUIRE(err.code != heif_error_Ok);
+  CHECK(err.code == heif_error_Usage_error);
+  heif_image_release(img);
+}
+
+} // namespace
+
+
+TEST_CASE("encoding refuses images with a non-canonical plane layout")
+{
+  heif_compression_format format = pick_encoder_format();
+  if (format == heif_compression_undefined) {
+    SKIP("no HEVC, AV1 or uncompressed encoder available");
+  }
+
+  SECTION("control: a complete RGB image encodes") {
+    heif_image* img = create_image(heif_colorspace_RGB, heif_chroma_444);
+    for (heif_channel ch : {heif_channel_R, heif_channel_G, heif_channel_B}) {
+      add_plane(img, ch, W, H);
+    }
+    heif_error err = encode(img, format);
+    INFO("encode error (" << err.code << "/" << err.subcode << "): " << err.message);
+    CHECK(err.code == heif_error_Ok);
+    heif_image_release(img);
+  }
+
+  SECTION("RGB without its B plane") {
+    heif_image* img = create_image(heif_colorspace_RGB, heif_chroma_444);
+    add_plane(img, heif_channel_R, W, H);
+    add_plane(img, heif_channel_G, W, H);
+    expect_refused(img, format);
+  }
+
+  SECTION("YCbCr 4:2:0 without its Cr plane (reached the x265 plugin before)") {
+    heif_image* img = create_image(heif_colorspace_YCbCr, heif_chroma_420);
+    add_plane(img, heif_channel_Y, W, H);
+    add_plane(img, heif_channel_Cb, W / 2, H / 2);
+    expect_refused(img, format);
+  }
+
+  SECTION("RGB with a depth plane") {
+    heif_image* img = create_image(heif_colorspace_RGB, heif_chroma_444);
+    for (heif_channel ch : {heif_channel_R, heif_channel_G, heif_channel_B, heif_channel_depth}) {
+      add_plane(img, ch, W, H);
+    }
+    expect_refused(img, format);
+  }
+
+  SECTION("monochrome with a stray Cb plane") {
+    heif_image* img = create_image(heif_colorspace_monochrome, heif_chroma_monochrome);
+    add_plane(img, heif_channel_Y, W, H);
+    add_plane(img, heif_channel_Cb, W, H);
+    expect_refused(img, format);
+  }
+
+  SECTION("duplicate colour plane (R twice)") {
+    // heif_image_add_plane() allows this (multi-spectral images consist of several monochrome
+    // planes); the encoder entry is where it is refused.
+    heif_image* img = create_image(heif_colorspace_RGB, heif_chroma_444);
+    for (heif_channel ch : {heif_channel_R, heif_channel_R, heif_channel_G, heif_channel_B}) {
+      add_plane(img, ch, W, H);
+    }
+    expect_refused(img, format);
+  }
+
+  SECTION("a component without colour meaning next to RGB is tolerated") {
+    heif_image* img = create_image(heif_colorspace_RGB, heif_chroma_444);
+    for (heif_channel ch : {heif_channel_R, heif_channel_G, heif_channel_B}) {
+      add_plane(img, ch, W, H);
+    }
+    uint32_t id = 0;
+    heif_error err = heif_image_add_component(img, W, H, heif_cmpd_component_type_padded,
+                                              heif_component_datatype_unsigned_integer, 8, &id);
+    REQUIRE(err.code == heif_error_Ok);
+
+    err = encode(img, format);
+    INFO("encode error (" << err.code << "/" << err.subcode << "): " << err.message);
+    CHECK(err.code == heif_error_Ok);
+    heif_image_release(img);
+  }
+}
+
diff --git a/tests/plane_layout.cc b/tests/plane_layout.cc
new file mode 100644
index 00000000..1c760c44
--- /dev/null
+++ b/tests/plane_layout.cc
@@ -0,0 +1,253 @@
+/*
+  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.
+*/
+
+// HeifPixelImage::check_plane_layout() is the gate through which images enter the color
+// conversion pipeline and the encoders. The operators read the planes their colorspace and
+// chroma format imply, at the sizes they imply; an image with a missing, duplicate, foreign or
+// undersized plane used to fail somewhere inside an operator, or not at all (a YCbCr image
+// without a Cr plane passed the pipeline as a no-op and reached the x265 plugin). Planes with
+// channel heif_channel_unknown carry multi-component data without a colour meaning (the
+// padding components of 'unci') and are tolerated and ignored.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "image/pixelimage.h"
+#include "color-conversion/colorconversion.h"
+
+#include <initializer_list>
+#include <memory>
+#include <string>
+#include <vector>
+
+namespace {
+
+constexpr uint32_t W = 16;
+constexpr uint32_t H = 8;
+
+std::shared_ptr<HeifPixelImage> make_image(heif_colorspace cs, heif_chroma chroma,
+                                           std::initializer_list<heif_channel> channels, int bpp = 8)
+{
+  auto img = std::make_shared<HeifPixelImage>();
+  img->create(W, H, cs, chroma);
+  for (heif_channel ch : channels) {
+    Error err = img->add_channel(ch, channel_width(W, chroma, ch), channel_height(H, chroma, ch), bpp, nullptr);
+    INFO("add_channel: " << err.message);
+    REQUIRE(!err);
+  }
+  return img;
+}
+
+void add_unknown_components(const std::shared_ptr<HeifPixelImage>& img, int count)
+{
+  for (int i = 0; i < count; i++) {
+    auto result = img->add_component(W, H, heif_cmpd_component_type_padded,
+                                     heif_component_datatype_unsigned_integer, 8, nullptr);
+    REQUIRE(result);
+  }
+  REQUIRE(img->has_channel(heif_channel_unknown));
+}
+
+bool mentions(const Error& err, const char* text)
+{
+  return err.message.find(text) != std::string::npos;
+}
+
+} // namespace
+
+
+TEST_CASE("check_plane_layout accepts the canonical layouts")
+{
+  struct Layout
+  {
+    const char* name;
+    heif_colorspace cs;
+    heif_chroma chroma;
+    std::vector<heif_channel> planes;
+    int bpp;
+  };
+
+  const Layout layouts[] = {
+      {"RGB 4:4:4", heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G, heif_channel_B}, 8},
+      {"RGB 4:4:4 + alpha", heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G, heif_channel_B, heif_channel_Alpha}, 8},
+      {"interleaved RGB", heif_colorspace_RGB, heif_chroma_interleaved_RGB, {heif_channel_interleaved}, 8},
+      {"interleaved RGBA", heif_colorspace_RGB, heif_chroma_interleaved_RGBA, {heif_channel_interleaved}, 8},
+      {"interleaved RRGGBB_LE", heif_colorspace_RGB, heif_chroma_interleaved_RRGGBB_LE, {heif_channel_interleaved}, 10},
+      {"interleaved RRGGBBAA_BE", heif_colorspace_RGB, heif_chroma_interleaved_RRGGBBAA_BE, {heif_channel_interleaved}, 12},
+      {"YCbCr 4:4:4", heif_colorspace_YCbCr, heif_chroma_444, {heif_channel_Y, heif_channel_Cb, heif_channel_Cr}, 8},
+      {"YCbCr 4:2:2 + alpha", heif_colorspace_YCbCr, heif_chroma_422, {heif_channel_Y, heif_channel_Cb, heif_channel_Cr, heif_channel_Alpha}, 8},
+      {"YCbCr 4:2:0", heif_colorspace_YCbCr, heif_chroma_420, {heif_channel_Y, heif_channel_Cb, heif_channel_Cr}, 8},
+      {"YCbCr 4:2:0 10-bit + alpha", heif_colorspace_YCbCr, heif_chroma_420, {heif_channel_Y, heif_channel_Cb, heif_channel_Cr, heif_channel_Alpha}, 10},
+      {"YCbCr luma only", heif_colorspace_YCbCr, heif_chroma_monochrome, {heif_channel_Y}, 8},
+      {"monochrome", heif_colorspace_monochrome, heif_chroma_monochrome, {heif_channel_Y}, 8},
+      {"monochrome + alpha", heif_colorspace_monochrome, heif_chroma_monochrome, {heif_channel_Y, heif_channel_Alpha}, 8},
+      {"filter array", heif_colorspace_filter_array, heif_chroma_planar, {heif_channel_filter_array}, 12},
+  };
+
+  for (const Layout& l : layouts) {
+    INFO(l.name);
+    auto img = std::make_shared<HeifPixelImage>();
+    img->create(W, H, l.cs, l.chroma);
+    for (heif_channel ch : l.planes) {
+      REQUIRE(!img->add_channel(ch, channel_width(W, l.chroma, ch), channel_height(H, l.chroma, ch), l.bpp, nullptr));
+    }
+    Error err = img->check_plane_layout();
+    INFO(err.message);
+    CHECK(!err);
+  }
+
+  SECTION("planes with channel unknown are tolerated") {
+    auto img = make_image(heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G, heif_channel_B});
+    add_unknown_components(img, 2);
+    CHECK(!img->check_plane_layout());
+  }
+}
+
+
+TEST_CASE("check_plane_layout rejects non-canonical layouts")
+{
+  SECTION("missing colour plane") {
+    auto img = make_image(heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G});
+    Error err = img->check_plane_layout();
+    REQUIRE(err);
+    CHECK(err.error_code == heif_error_Usage_error);
+    CHECK(mentions(err, "no B plane"));
+  }
+
+  SECTION("missing chroma plane") {
+    auto img = make_image(heif_colorspace_YCbCr, heif_chroma_420, {heif_channel_Y, heif_channel_Cb});
+    Error err = img->check_plane_layout();
+    REQUIRE(err);
+    CHECK(mentions(err, "no Cr plane"));
+  }
+
+  SECTION("foreign plane") {
+    auto img = make_image(heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G, heif_channel_B, heif_channel_depth});
+    Error err = img->check_plane_layout();
+    REQUIRE(err);
+    CHECK(mentions(err, "depth plane"));
+  }
+
+  SECTION("stray chroma plane on a monochrome image") {
+    auto img = make_image(heif_colorspace_monochrome, heif_chroma_monochrome, {heif_channel_Y, heif_channel_Cb});
+    Error err = img->check_plane_layout();
+    REQUIRE(err);
+    CHECK(mentions(err, "Cb plane"));
+  }
+
+  SECTION("duplicate colour plane") {
+    // HeifPixelImage itself allows this (multi-spectral images consist of several monochrome
+    // planes); the layout check is what refuses it for colour conversion and encoding.
+    auto img = make_image(heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G, heif_channel_B});
+    REQUIRE(!img->add_channel(heif_channel_R, W, H, 8, nullptr));
+    Error err = img->check_plane_layout();
+    REQUIRE(err);
+    CHECK(mentions(err, "more than one R plane"));
+  }
+
+  SECTION("chroma plane with the wrong size") {
+    auto img = std::make_shared<HeifPixelImage>();
+    img->create(W, H, heif_colorspace_YCbCr, heif_chroma_420);
+    REQUIRE(!img->add_channel(heif_channel_Y, W, H, 8, nullptr));
+    REQUIRE(!img->add_channel(heif_channel_Cb, W, H, 8, nullptr)); // should be W/2 x H/2
+    REQUIRE(!img->add_channel(heif_channel_Cr, W / 2, H / 2, 8, nullptr));
+    Error err = img->check_plane_layout();
+    REQUIRE(err);
+    CHECK(mentions(err, "Cb plane has size"));
+  }
+
+  SECTION("alpha next to a filter array") {
+    auto img = make_image(heif_colorspace_filter_array, heif_chroma_planar, {heif_channel_filter_array, heif_channel_Alpha});
+    CHECK(img->check_plane_layout());
+  }
+
+  SECTION("colorspace and chroma do not fit") {
+    auto rgb420 = make_image(heif_colorspace_RGB, heif_chroma_420, {});
+    CHECK(rgb420->check_plane_layout());
+
+    auto ycc_interleaved = make_image(heif_colorspace_YCbCr, heif_chroma_interleaved_RGB, {});
+    CHECK(ycc_interleaved->check_plane_layout());
+  }
+
+  SECTION("custom colorspace has no layout") {
+    auto img = std::make_shared<HeifPixelImage>();
+    img->create(W, H, heif_colorspace_custom, heif_chroma_planar);
+    for (int i = 0; i < 3; i++) {
+      REQUIRE(img->add_component(W, H, heif_cmpd_component_type_monochrome,
+                                 heif_component_datatype_unsigned_integer, 8, nullptr));
+    }
+    Error err = img->check_plane_layout();
+    REQUIRE(err);
+    CHECK(mentions(err, "no defined plane layout"));
+  }
+}
+
+
+TEST_CASE("convert_colorspace refuses images with a non-canonical plane layout")
+{
+  heif_color_conversion_options options{};
+
+  SECTION("RGB without B") {
+    auto img = make_image(heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G});
+    auto result = convert_colorspace(img, heif_colorspace_RGB, heif_chroma_interleaved_RGB,
+                                     nclx_profile::defaults(), 0, options, nullptr,
+                                     heif_get_disabled_security_limits());
+    REQUIRE(!result);
+    CHECK(result.error().error_code == heif_error_Unsupported_feature);
+    CHECK(result.error().sub_error_code == heif_suberror_Unsupported_image_type);
+  }
+
+  SECTION("YCbCr 4:2:0 without Cr, same-layout target (used to be a no-op)") {
+    auto img = make_image(heif_colorspace_YCbCr, heif_chroma_420, {heif_channel_Y, heif_channel_Cb});
+    auto result = convert_colorspace(img, heif_colorspace_YCbCr, heif_chroma_420,
+                                     nclx_profile::defaults(), 0, options, nullptr,
+                                     heif_get_disabled_security_limits());
+    REQUIRE(!result);
+    CHECK(result.error().sub_error_code == heif_suberror_Unsupported_image_type);
+  }
+
+  SECTION("custom colorspace") {
+    auto img = std::make_shared<HeifPixelImage>();
+    img->create(W, H, heif_colorspace_custom, heif_chroma_planar);
+    REQUIRE(img->add_component(W, H, heif_cmpd_component_type_monochrome,
+                               heif_component_datatype_unsigned_integer, 8, nullptr));
+    auto result = convert_colorspace(img, heif_colorspace_RGB, heif_chroma_interleaved_RGB,
+                                     nclx_profile::defaults(), 0, options, nullptr,
+                                     heif_get_disabled_security_limits());
+    REQUIRE(!result);
+    CHECK(result.error().sub_error_code == heif_suberror_Unsupported_image_type);
+  }
+
+  SECTION("unknown planes are tolerated and not carried into the output") {
+    auto img = make_image(heif_colorspace_RGB, heif_chroma_444, {heif_channel_R, heif_channel_G, heif_channel_B});
+    add_unknown_components(img, 2);
+    auto result = convert_colorspace(img, heif_colorspace_RGB, heif_chroma_interleaved_RGB,
+                                     nclx_profile::defaults(), 0, options, nullptr,
+                                     heif_get_disabled_security_limits());
+    REQUIRE(result);
+    CHECK((*result)->has_channel(heif_channel_interleaved));
+    CHECK(!(*result)->has_channel(heif_channel_unknown));
+  }
+}