Commit 756b8647 for libheif

commit 756b8647331c2276d9f0f736f4273c11eb857cb8
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Sep 21 00:45:07 2026 +0200

    Fix clipping of overlay images with negative offsets

    HeifPixelImage::overlay() clipped the overlay against the right and
    bottom canvas borders by shrinking in_w/in_h to an end coordinate and
    against the left and top borders by converting them into a count, but
    the copy loops kept treating them as end coordinates. An overlay whose
    negative offset was at least half its size was therefore not drawn at
    all, and smaller negative offsets drew a truncated part. The alpha path
    additionally added the source x offset twice and wrote to the wrong
    destination column.

    Compute the intersection of the overlay rectangle with the canvas in
    int64 arithmetic and describe the copy region by its size and its
    top-left corner in both images. This also removes the now unused
    negate_negative_int32() helper.

    Introduced by 85e21ad4 (v1.20.0); b8c12a7b (v1.21.0) fixed only the
    memcpy width.

    Add tests/pixelimage_overlay.cc, which composites a small overlay at a
    set of offsets (inside, partially outside on each side, completely
    outside, and the int32 extremes), with and without alpha, against a
    reference computation. Add tests/overlay_offsets.cc, which builds 'iovl'
    files from an 'mski' base with 16-bit and 32-bit fields, covering
    negative offsets, the same input image referenced twice (the structure
    of conformance file C021), and the same overlay written through the
    public API and read back.

diff --git a/libheif/image/pixelimage.cc b/libheif/image/pixelimage.cc
index 2442c1af..3a5f43f5 100644
--- a/libheif/image/pixelimage.cc
+++ b/libheif/image/pixelimage.cc
@@ -1931,19 +1931,6 @@ Error HeifPixelImage::fill_RGB_16bit(uint16_t r, uint16_t g, uint16_t b, uint16_
 }


-uint32_t negate_negative_int32(int32_t x)
-{
-  assert(x <= 0);
-
-  if (x == INT32_MIN) {
-    return static_cast<uint32_t>(INT32_MAX) + 1;
-  }
-  else {
-    return static_cast<uint32_t>(-x);
-  }
-}
-
-
 Error HeifPixelImage::overlay(std::shared_ptr<HeifPixelImage>& overlay, int32_t dx, int32_t dy)
 {
   // This function places the overlay using the full-resolution (dx,dy) offset
@@ -2007,95 +1994,52 @@ Error HeifPixelImage::overlay(std::shared_ptr<HeifPixelImage>& overlay, int32_t
     uint32_t out_h = get_height(channel);


-    // --- check whether overlay image overlaps with current image
+    // --- compute the overlapping area
+    //
+    // The overlay covers [dx, dx+in_w) x [dy, dy+in_h) in canvas coordinates and
+    // may start outside the canvas on any side (ISO/IEC 23008-12 6.6.2.2.3 allows
+    // negative offsets; pixels outside the canvas are simply not shown). Intersect
+    // it with the canvas [0, out_w) x [0, out_h). All terms fit into int64_t, so
+    // this cannot overflow for int32 offsets and uint32 sizes. The copy region is
+    // then described by its size and by its top-left corner in both images, which
+    // keeps the loop below free of any "end coordinate vs. count" ambiguity.
     // Note: all components share the logical image size, so if the overlay
     // image lies completely outside for one component it does so for all of
     // them -> we can return instead of just skipping the current component.

-    if (dx > 0 && static_cast<uint32_t>(dx) >= out_w) {
-      // the overlay image is completely outside the right border -> skip overlaying
-      return Error::Ok;
-    }
-    else if (dx < 0 && in_w <= negate_negative_int32(dx)) {
-      // the overlay image is completely outside the left border -> skip overlaying
-      return Error::Ok;
-    }
+    const int64_t x0 = std::max<int64_t>(dx, 0);
+    const int64_t y0 = std::max<int64_t>(dy, 0);
+    const int64_t x1 = std::min<int64_t>(static_cast<int64_t>(dx) + in_w, out_w);
+    const int64_t y1 = std::min<int64_t>(static_cast<int64_t>(dy) + in_h, out_h);

-    if (dy > 0 && static_cast<uint32_t>(dy) >= out_h) {
-      // the overlay image is completely outside the bottom border -> skip overlaying
-      return Error::Ok;
-    }
-    else if (dy < 0 && in_h <= negate_negative_int32(dy)) {
-      // the overlay image is completely outside the top border -> skip overlaying
+    if (x1 <= x0 || y1 <= y0) {
+      // the overlay image is completely outside the canvas -> nothing to draw
       return Error::Ok;
     }

+    const uint32_t copy_w = static_cast<uint32_t>(x1 - x0);
+    const uint32_t copy_h = static_cast<uint32_t>(y1 - y0);

-    // --- compute overlapping area
+    // top-left corner of the copied region in the canvas (out_*) and in the overlay (in_*)
+    const uint32_t out_x0 = static_cast<uint32_t>(x0);
+    const uint32_t out_y0 = static_cast<uint32_t>(y0);
+    const uint32_t in_x0 = static_cast<uint32_t>(x0 - dx);
+    const uint32_t in_y0 = static_cast<uint32_t>(y0 - dy);

-    // top-left points where to start copying in source and destination
-    uint32_t in_x0;
-    uint32_t in_y0;
-    uint32_t out_x0;
-    uint32_t out_y0;
-
-    // right border
-    if (dx + static_cast<int64_t>(in_w) > out_w) {
-      // overlay image extends partially outside of right border
-      // Notes:
-      // - (out_w-dx) cannot underflow because dx<out_w is ensured above
-      // - (out_w-dx) cannot overflow (for dx<0) because, as just checked, out_w-dx < in_w
-      //              and in_w fits into uint32_t
-      in_w = static_cast<uint32_t>(static_cast<int64_t>(out_w) - dx);
-    }
-
-    // bottom border
-    if (dy + static_cast<int64_t>(in_h) > out_h) {
-      // overlay image extends partially outside of bottom border
-      in_h = static_cast<uint32_t>(static_cast<int64_t>(out_h) - dy);
-    }
-
-    // left border
-    if (dx < 0) {
-      // overlay image starts partially outside of left border
-
-      in_x0 = negate_negative_int32(dx);
-      out_x0 = 0;
-      in_w = in_w - in_x0; // in_x0 < in_w because in_w > -dx = in_x0
-    }
-    else {
-      in_x0 = 0;
-      out_x0 = static_cast<uint32_t>(dx);
-    }
-
-    // top border
-    if (dy < 0) {
-      // overlay image started partially outside of top border
-
-      in_y0 = negate_negative_int32(dy);
-      out_y0 = 0;
-      in_h = in_h - in_y0; // in_y0 < in_h because in_h > -dy = in_y0
-    }
-    else {
-      in_y0 = 0;
-      out_y0 = static_cast<uint32_t>(dy);
-    }
+    // --- composite the overlay in the overlapping area

-    // --- computer overlay in overlapping area
+    for (uint32_t y = 0; y < copy_h; y++) {
+      const uint8_t* in_row = in_p + in_x0 + static_cast<size_t>(in_y0 + y) * in_stride;
+      uint8_t* out_row = out_p + out_x0 + static_cast<size_t>(out_y0 + y) * out_stride;

-    for (uint32_t y = in_y0; y < in_h; y++) {
       if (!has_alpha) {
-        memcpy(out_p + out_x0 + (out_y0 + y - in_y0) * out_stride,
-               in_p + in_x0 + y * in_stride,
-               in_w);
+        memcpy(out_row, in_row, copy_w);
       }
       else {
-        for (uint32_t x = in_x0; x < in_w; x++) {
-          uint8_t* outptr = &out_p[out_x0 + (out_y0 + y - in_y0) * out_stride + x];
-          uint8_t in_val = in_p[in_x0 + y * in_stride + x];
-          uint8_t alpha_val = alpha_p[in_x0 + y * alpha_stride + x];
+        const uint8_t* alpha_row = alpha_p + in_x0 + static_cast<size_t>(in_y0 + y) * alpha_stride;

-          *outptr = (uint8_t) ((in_val * alpha_val + *outptr * (255 - alpha_val)) / 255);
+        for (uint32_t x = 0; x < copy_w; x++) {
+          out_row[x] = static_cast<uint8_t>((in_row[x] * alpha_row[x] + out_row[x] * (255 - alpha_row[x])) / 255);
         }
       }
     }
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 1e15a2ea..933a4f91 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -56,6 +56,7 @@ else()
     add_libheif_test(crop_plane_checks)
     add_libheif_test(extract_area_plane_checks)
     add_libheif_test(extend_to_size_checks)
+    add_libheif_test(pixelimage_overlay)
     add_libheif_test(add_channel_checks)
     add_libheif_test(jpeg2000)
     add_libheif_test(avc_box)
@@ -100,6 +101,7 @@ add_libheif_test(jpeg2000_openjpeg_grid_limit)
 add_libheif_test(item_properties)
 add_libheif_test(item_writing)
 add_libheif_test(overlay_amplification)
+add_libheif_test(overlay_offsets)
 add_libheif_test(error_item_decode)
 add_libheif_test(alpha_cycle_deadlock)
 add_libheif_test(alpha_composite_decode)
diff --git a/tests/overlay_offsets.cc b/tests/overlay_offsets.cc
new file mode 100644
index 00000000..c0613760
--- /dev/null
+++ b/tests/overlay_offsets.cc
@@ -0,0 +1,465 @@
+/*
+  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.
+*/
+
+// Regression tests for three defects found with the HEIF conformance file C021,
+// an 'iovl' that composites the same coded image twice: at (0,0) and, on top of
+// it, at (-640,-360) on a canvas of half the image size.
+//
+// 1. Box_iref rejected every 'dimg' entry that lists the same item twice
+//    ("'iref' has double references"). Neither ISO/IEC 14496-12 nor 23008-12
+//    forbids this, and an overlay needs it to place one image at two positions,
+//    because the offsets are paired with the references.
+// 2. ImageOverlay::parse() sign-extended 16-bit offsets incorrectly, so every
+//    negative offset in the common 16-bit form placed the image far outside the
+//    canvas, where it was silently skipped.
+// 3. HeifPixelImage::overlay() mis-clipped input images with negative offsets:
+//    a layer whose negative offset was at least half its size was not drawn at
+//    all, and smaller negative offsets were truncated.
+//
+// The files are built by hand from an 'mski' base image, which needs no codec,
+// so the tests run in every build configuration. Each test decodes the base
+// image and the overlay through the public API and compares the overlay with a
+// straightforward reference composition of the decoded base.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <cstdint>
+#include <cstring>
+#include <string>
+#include <vector>
+
+namespace {
+
+const uint32_t BASE_W = 8;
+const uint32_t BASE_H = 8;
+
+const heif_item_id BASE_ID = 1;
+const heif_item_id IOVL_ID = 2;
+
+struct Layer {
+  int32_t x;
+  int32_t y;
+};
+
+
+// ImageOverlay payload (ISO/IEC 23008-12 6.6.2.2): version, flags (bit 0
+// selects 32-bit instead of 16-bit size and offset fields), background RGBA
+// (opaque white), canvas size, and one signed (x,y) offset per input image.
+std::vector<uint8_t> make_overlay_spec(uint16_t canvas_w, uint16_t canvas_h, const std::vector<Layer>& layers,
+                                       bool long_fields)
+{
+  auto put_field = [long_fields](std::vector<uint8_t>& out, int32_t v) {
+    if (long_fields) {
+      put_u32_be(out, static_cast<uint32_t>(v));
+    }
+    else {
+      put_u16_be(out, static_cast<uint16_t>(static_cast<int16_t>(v)));
+    }
+  };
+
+  std::vector<uint8_t> s;
+  s.push_back(0);
+  s.push_back(long_fields ? 1 : 0);
+  for (int i = 0; i < 4; i++) { put_u16_be(s, 0xFFFF); }
+  put_field(s, canvas_w);
+  put_field(s, canvas_h);
+  for (const auto& l : layers) {
+    put_field(s, l.x);
+    put_field(s, l.y);
+  }
+  return s;
+}
+
+
+// A file with two items: item 1 is an 8x8 'mski' base image whose pixel value is
+// 8*y+x, item 2 is the primary 'iovl' that references the base once per layer.
+// With two or more layers, the 'dimg' entry therefore lists item 1 repeatedly.
+std::vector<uint8_t> build_file(uint16_t canvas_w, uint16_t canvas_h, const std::vector<Layer>& layers, bool long_fields)
+{
+  std::vector<uint8_t> ftyp_payload;
+  append_fourcc(ftyp_payload, "mif1");
+  put_u32_be(ftyp_payload, 0);
+  append_fourcc(ftyp_payload, "mif1");
+  append_fourcc(ftyp_payload, "heic");
+  auto ftyp = make_box("ftyp", ftyp_payload);
+
+  std::vector<uint8_t> hdlr_payload;
+  put_u32_be(hdlr_payload, 0);
+  append_fourcc(hdlr_payload, "pict");
+  put_u32_be(hdlr_payload, 0);
+  put_u32_be(hdlr_payload, 0);
+  put_u32_be(hdlr_payload, 0);
+  hdlr_payload.push_back(0);
+  auto hdlr = make_box("hdlr", hdlr_payload, /*full=*/true);
+
+  std::vector<uint8_t> pitm_payload;
+  put_u16_be(pitm_payload, static_cast<uint16_t>(IOVL_ID));
+  auto pitm = make_box("pitm", pitm_payload, /*full=*/true);
+
+  // iinf
+  std::vector<uint8_t> iinf_payload;
+  put_u16_be(iinf_payload, 2);
+  for (const auto& item : {std::make_pair(BASE_ID, "mski"), std::make_pair(IOVL_ID, "iovl")}) {
+    std::vector<uint8_t> infe_payload;
+    put_u16_be(infe_payload, static_cast<uint16_t>(item.first));
+    put_u16_be(infe_payload, 0);
+    append_fourcc(infe_payload, item.second);
+    infe_payload.push_back(0);
+    append(iinf_payload, make_box("infe", infe_payload, /*full=*/true, /*version=*/2));
+  }
+  auto iinf = make_box("iinf", iinf_payload, /*full=*/true);
+
+  // iprp: property 1 = ispe of the base, 2 = mskC, 3 = ispe of the canvas
+  std::vector<uint8_t> ispe_base_payload;
+  put_u32_be(ispe_base_payload, BASE_W);
+  put_u32_be(ispe_base_payload, BASE_H);
+  auto ispe_base = make_box("ispe", ispe_base_payload, /*full=*/true);
+
+  std::vector<uint8_t> mskC_payload;
+  mskC_payload.push_back(8);  // bits_per_pixel
+  auto mskC = make_box("mskC", mskC_payload, /*full=*/true);
+
+  std::vector<uint8_t> ispe_canvas_payload;
+  put_u32_be(ispe_canvas_payload, canvas_w);
+  put_u32_be(ispe_canvas_payload, canvas_h);
+  auto ispe_canvas = make_box("ispe", ispe_canvas_payload, /*full=*/true);
+
+  std::vector<uint8_t> ipco_payload;
+  append(ipco_payload, ispe_base);
+  append(ipco_payload, mskC);
+  append(ipco_payload, ispe_canvas);
+  auto ipco = make_box("ipco", ipco_payload);
+
+  std::vector<uint8_t> ipma_payload;
+  put_u32_be(ipma_payload, 2);                  // entry_count
+  put_u16_be(ipma_payload, static_cast<uint16_t>(BASE_ID));
+  ipma_payload.push_back(2);                    // association_count
+  ipma_payload.push_back(0x80 | 1);             // essential, ispe (base)
+  ipma_payload.push_back(0x80 | 2);             // essential, mskC
+  put_u16_be(ipma_payload, static_cast<uint16_t>(IOVL_ID));
+  ipma_payload.push_back(1);
+  ipma_payload.push_back(0x80 | 3);             // essential, ispe (canvas)
+  auto ipma = make_box("ipma", ipma_payload, /*full=*/true);
+
+  std::vector<uint8_t> iprp_payload;
+  append(iprp_payload, ipco);
+  append(iprp_payload, ipma);
+  auto iprp = make_box("iprp", iprp_payload);
+
+  // idat: base pixels, then the overlay spec
+  std::vector<uint8_t> base_data(BASE_W * BASE_H);
+  for (uint32_t i = 0; i < BASE_W * BASE_H; i++) {
+    base_data[i] = static_cast<uint8_t>(i);
+  }
+  auto spec = make_overlay_spec(canvas_w, canvas_h, layers, long_fields);
+
+  std::vector<uint8_t> idat_payload;
+  append(idat_payload, base_data);
+  append(idat_payload, spec);
+  auto idat = make_box("idat", idat_payload);
+
+  std::vector<uint8_t> iloc_payload;
+  iloc_payload.push_back((4 << 4) | 4);         // offset_size=4, length_size=4
+  iloc_payload.push_back((0 << 4) | 0);         // base_offset_size=0, index_size=0
+  put_u16_be(iloc_payload, 2);                  // item_count
+  struct Extent { heif_item_id id; uint32_t off; uint32_t len; };
+  for (const Extent& e : {Extent{BASE_ID, 0, static_cast<uint32_t>(base_data.size())},
+                          Extent{IOVL_ID, static_cast<uint32_t>(base_data.size()), static_cast<uint32_t>(spec.size())}}) {
+    put_u16_be(iloc_payload, static_cast<uint16_t>(e.id));
+    put_u16_be(iloc_payload, 0x0001);           // reserved(12) + construction_method=1 (idat)
+    put_u16_be(iloc_payload, 0);                // data_reference_index
+    put_u16_be(iloc_payload, 1);                // extent_count
+    put_u32_be(iloc_payload, e.off);
+    put_u32_be(iloc_payload, e.len);
+  }
+  auto iloc = make_box("iloc", iloc_payload, /*full=*/true, /*version=*/1);
+
+  // iref: one 'dimg' entry listing the base once per layer
+  std::vector<uint8_t> dimg_payload;
+  put_u16_be(dimg_payload, static_cast<uint16_t>(IOVL_ID));
+  put_u16_be(dimg_payload, static_cast<uint16_t>(layers.size()));
+  for (size_t i = 0; i < layers.size(); i++) {
+    put_u16_be(dimg_payload, static_cast<uint16_t>(BASE_ID));
+  }
+  auto iref = make_box("iref", make_box("dimg", dimg_payload), /*full=*/true);
+
+  std::vector<uint8_t> meta_payload;
+  append(meta_payload, hdlr);
+  append(meta_payload, pitm);
+  append(meta_payload, iinf);
+  append(meta_payload, iprp);
+  append(meta_payload, iloc);
+  append(meta_payload, iref);
+  append(meta_payload, idat);
+  auto meta = make_box("meta", meta_payload, /*full=*/true);
+
+  std::vector<uint8_t> file;
+  append(file, ftyp);
+  append(file, meta);
+  return file;
+}
+
+
+struct Pixels {
+  uint32_t w = 0;
+  uint32_t h = 0;
+  std::vector<uint8_t> rgb;  // interleaved, tightly packed
+};
+
+Pixels decode_rgb(heif_image_handle* handle)
+{
+  heif_image* img = nullptr;
+  heif_error err = heif_decode_image(handle, &img, heif_colorspace_RGB, heif_chroma_interleaved_RGB, nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  Pixels px;
+  px.w = static_cast<uint32_t>(heif_image_get_width(img, heif_channel_interleaved));
+  px.h = static_cast<uint32_t>(heif_image_get_height(img, heif_channel_interleaved));
+
+  size_t stride = 0;
+  const uint8_t* p = heif_image_get_plane_readonly2(img, heif_channel_interleaved, &stride);
+  REQUIRE(p != nullptr);
+
+  px.rgb.resize(static_cast<size_t>(px.w) * px.h * 3);
+  for (uint32_t y = 0; y < px.h; y++) {
+    memcpy(&px.rgb[static_cast<size_t>(y) * px.w * 3], p + y * stride, static_cast<size_t>(px.w) * 3);
+  }
+
+  heif_image_release(img);
+  return px;
+}
+
+Pixels decode_item(heif_context* ctx, heif_item_id id)
+{
+  heif_image_handle* handle = nullptr;
+  REQUIRE(heif_context_get_image_handle(ctx, id, &handle).code == heif_error_Ok);
+  Pixels px = decode_rgb(handle);
+  heif_image_handle_release(handle);
+  return px;
+}
+
+Pixels decode_primary(heif_context* ctx)
+{
+  heif_image_handle* handle = nullptr;
+  REQUIRE(heif_context_get_primary_image_handle(ctx, &handle).code == heif_error_Ok);
+  Pixels px = decode_rgb(handle);
+  heif_image_handle_release(handle);
+  return px;
+}
+
+
+// Reference composition: an opaque white canvas with the base image painted at
+// every layer offset in order, pixels outside the canvas dropped.
+std::vector<uint8_t> composite(const Pixels& base, uint32_t canvas_w, uint32_t canvas_h, const std::vector<Layer>& layers)
+{
+  std::vector<uint8_t> out(static_cast<size_t>(canvas_w) * canvas_h * 3, 255);
+
+  for (const auto& l : layers) {
+    for (uint32_t y = 0; y < base.h; y++) {
+      for (uint32_t x = 0; x < base.w; x++) {
+        int64_t cx = static_cast<int64_t>(l.x) + x;
+        int64_t cy = static_cast<int64_t>(l.y) + y;
+        if (cx < 0 || cy < 0 || cx >= canvas_w || cy >= canvas_h) {
+          continue;
+        }
+
+        memcpy(&out[(static_cast<size_t>(cy) * canvas_w + static_cast<size_t>(cx)) * 3],
+               &base.rgb[(static_cast<size_t>(y) * base.w + x) * 3],
+               3);
+      }
+    }
+  }
+
+  return out;
+}
+
+
+// Compare pixel by pixel. (A REQUIRE on the whole vectors would print hundreds
+// of bytes on failure, and the first mismatch is what matters.)
+void require_same_pixels(const Pixels& actual, const std::vector<uint8_t>& expected)
+{
+  REQUIRE(actual.rgb.size() == expected.size());
+
+  for (size_t i = 0; i < expected.size(); i++) {
+    if (actual.rgb[i] != expected[i]) {
+      size_t pixel = i / 3;
+      uint32_t x = static_cast<uint32_t>(pixel % actual.w);
+      uint32_t y = static_cast<uint32_t>(pixel / actual.w);
+
+      std::string actual_row, expected_row;
+      for (uint32_t xx = 0; xx < actual.w; xx++) {
+        size_t idx = (static_cast<size_t>(y) * actual.w + xx) * 3;
+        actual_row += std::to_string(actual.rgb[idx]) + " ";
+        expected_row += std::to_string(expected[idx]) + " ";
+      }
+
+      INFO("first mismatch at pixel (" << x << "," << y << "), channel " << (i % 3));
+      INFO("decoded row " << y << ":  " << actual_row);
+      INFO("expected row " << y << ": " << expected_row);
+      REQUIRE(static_cast<int>(actual.rgb[i]) == static_cast<int>(expected[i]));
+    }
+  }
+}
+
+
+void check_composition_with_field_size(uint16_t canvas_w, uint16_t canvas_h, const std::vector<Layer>& layers,
+                                       bool long_fields)
+{
+  INFO("canvas " << canvas_w << "x" << canvas_h << ", " << layers.size() << " layer(s), first offset ("
+                 << layers[0].x << "," << layers[0].y << "), " << (long_fields ? 32 : 16) << "-bit fields");
+
+  auto data = build_file(canvas_w, canvas_h, layers, long_fields);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+  heif_error err = heif_context_read_from_memory_without_copy(ctx, data.data(), data.size(), nullptr);
+  INFO("read error: " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+
+  Pixels base = decode_item(ctx, BASE_ID);
+  REQUIRE(base.w == BASE_W);
+  REQUIRE(base.h == BASE_H);
+
+  Pixels canvas = decode_primary(ctx);
+  REQUIRE(canvas.w == canvas_w);
+  REQUIRE(canvas.h == canvas_h);
+  require_same_pixels(canvas, composite(base, canvas_w, canvas_h, layers));
+
+  heif_context_free(ctx);
+}
+
+// Check the composition with both overlay field sizes. Negative 16-bit offsets
+// were mis-read as huge negative values (the sign extension was only correct
+// for 32-bit fields), which also made the image disappear from the canvas.
+void check_composition(uint16_t canvas_w, uint16_t canvas_h, const std::vector<Layer>& layers)
+{
+  check_composition_with_field_size(canvas_w, canvas_h, layers, false);
+  check_composition_with_field_size(canvas_w, canvas_h, layers, true);
+}
+
+} // namespace
+
+
+TEST_CASE("overlay offsets: negative offset of at least half the image size") {
+  // Used to be dropped entirely.
+  check_composition(8, 8, {{-4, -4}});
+  check_composition(8, 8, {{-6, -2}});
+  check_composition(8, 8, {{0, -5}});
+}
+
+TEST_CASE("overlay offsets: small negative offset") {
+  // Used to be truncated.
+  check_composition(8, 8, {{-2, -3}});
+  check_composition(8, 8, {{-1, 0}});
+}
+
+TEST_CASE("overlay offsets: positive offset partially outside the canvas") {
+  check_composition(8, 8, {{5, 6}});
+  check_composition(8, 8, {{7, 7}});
+}
+
+TEST_CASE("overlay offsets: input image completely outside the canvas") {
+  check_composition(8, 8, {{-8, 0}});
+  check_composition(8, 8, {{0, 8}});
+  check_composition(4, 4, {{-8, -8}});
+  check_composition(4, 4, {{4, 0}});
+}
+
+TEST_CASE("overlay offsets: canvas smaller than the input image") {
+  check_composition(4, 4, {{0, 0}});
+  check_composition(4, 4, {{-2, -2}});
+  check_composition(4, 4, {{-4, -4}});
+}
+
+TEST_CASE("overlay offsets: the same input image placed twice (C021 structure)") {
+  // The 'dimg' entry lists item 1 twice. These files were rejected while
+  // parsing with "'iref' has double references".
+  check_composition(8, 8, {{0, 0}, {-4, -4}});
+  check_composition(4, 4, {{0, 0}, {-4, -4}});
+  check_composition(8, 8, {{-4, -4}, {0, 0}});
+  check_composition(8, 8, {{0, 0}, {2, 2}, {-6, -6}});
+}
+
+TEST_CASE("overlay offsets: overlay written through the API may reference one image twice") {
+  heif_encoder* encoder = get_encoder_or_skip_test(heif_compression_mask);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  heif_image* base = nullptr;
+  REQUIRE(heif_image_create(BASE_W, BASE_H, heif_colorspace_monochrome, heif_chroma_monochrome, &base).code == heif_error_Ok);
+  REQUIRE(heif_image_add_plane(base, heif_channel_Y, BASE_W, BASE_H, 8).code == heif_error_Ok);
+  size_t stride = 0;
+  uint8_t* p = heif_image_get_plane2(base, heif_channel_Y, &stride);
+  for (uint32_t y = 0; y < BASE_H; y++) {
+    for (uint32_t x = 0; x < BASE_W; x++) {
+      p[y * stride + x] = static_cast<uint8_t>(BASE_W * y + x);
+    }
+  }
+
+  heif_image_handle* base_handle = nullptr;
+  REQUIRE(heif_context_encode_image(ctx, base, encoder, nullptr, &base_handle).code == heif_error_Ok);
+  heif_item_id base_id = heif_image_handle_get_item_id(base_handle);
+
+  const std::vector<Layer> layers = {{0, 0}, {-4, -4}};
+  heif_item_id ids[2] = {base_id, base_id};
+  int32_t offsets[4] = {0, 0, -4, -4};
+  uint16_t background[4] = {0xFFFF, 0xFFFF, 0xFFFF, 0xFFFF};
+
+  heif_image_handle* iovl_handle = nullptr;
+  REQUIRE(heif_context_add_overlay_image(ctx, 8, 8, 2, ids, offsets, background, &iovl_handle).code == heif_error_Ok);
+  REQUIRE(heif_context_set_primary_image(ctx, iovl_handle).code == heif_error_Ok);
+
+  // Writing used to fail here with "'iref' has double references".
+  std::string path = get_tests_output_file_path("overlay_same_image_twice.heif");
+  heif_error err = heif_context_write_to_file(ctx, path.c_str());
+  INFO("write error: " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_image_handle_release(iovl_handle);
+  heif_image_handle_release(base_handle);
+  heif_image_release(base);
+  heif_encoder_release(encoder);
+  heif_context_free(ctx);
+
+  // Read the file back and check the composition.
+  ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+  REQUIRE(heif_context_read_from_file(ctx, path.c_str(), nullptr).code == heif_error_Ok);
+
+  Pixels base_px = decode_item(ctx, base_id);
+  REQUIRE(base_px.w == BASE_W);
+  REQUIRE(base_px.h == BASE_H);
+
+  Pixels canvas = decode_primary(ctx);
+  REQUIRE(canvas.w == 8);
+  REQUIRE(canvas.h == 8);
+  require_same_pixels(canvas, composite(base_px, 8, 8, layers));
+
+  heif_context_free(ctx);
+}
diff --git a/tests/pixelimage_overlay.cc b/tests/pixelimage_overlay.cc
new file mode 100644
index 00000000..5f73cea4
--- /dev/null
+++ b/tests/pixelimage_overlay.cc
@@ -0,0 +1,223 @@
+/*
+  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.
+*/
+
+#include "image/pixelimage.h"
+#include "catch_amalgamated.hpp"
+
+#include <climits>
+#include <cstring>
+#include <memory>
+#include <vector>
+
+// Regression tests for HeifPixelImage::overlay() with the overlay image placed
+// partially or completely outside of the canvas.
+//
+// The former overlap computation clipped the overlay against the right and
+// bottom canvas borders by shrinking in_w/in_h to an end coordinate, then
+// against the left and top borders by converting them into a count, but the
+// copy loops kept using them as end coordinates. An overlay with a negative
+// offset of at least half its size was therefore not drawn at all, and smaller
+// negative offsets drew a truncated part. The alpha path additionally added the
+// source x offset twice and wrote to the wrong destination column.
+//
+// Every test composites a small overlay onto a uniformly filled canvas at some
+// offset and compares each canvas pixel with a straightforward reference
+// computation.
+
+namespace {
+
+const uint32_t CANVAS = 8;
+
+// Non-square and not a divisor of the canvas size, to catch swapped axes.
+const uint32_t OVL_W = 5;
+const uint32_t OVL_H = 4;
+
+const uint8_t BACKGROUND = 100;
+
+
+// Overlay pixel values. Every (channel, x, y) combination is unique and none of
+// the color values equals BACKGROUND.
+uint8_t alpha_value(uint32_t x, uint32_t y)
+{
+  switch ((x + y) % 3) {
+    case 0: return 0;
+    case 1: return 255;
+    default: return 128;
+  }
+}
+
+uint8_t overlay_value(heif_channel ch, uint32_t x, uint32_t y)
+{
+  uint8_t idx = static_cast<uint8_t>(16 * y + x);
+  switch (ch) {
+    case heif_channel_R: return static_cast<uint8_t>(1 + idx);
+    case heif_channel_G: return static_cast<uint8_t>(200 - idx);
+    case heif_channel_B: return static_cast<uint8_t>(128 + idx);
+    case heif_channel_Alpha: return alpha_value(x, y);
+    default: return 0;
+  }
+}
+
+
+std::shared_ptr<HeifPixelImage> make_canvas()
+{
+  auto img = std::make_shared<HeifPixelImage>();
+  img->create(CANVAS, CANVAS, heif_colorspace_RGB, heif_chroma_444);
+
+  for (heif_channel ch : {heif_channel_R, heif_channel_G, heif_channel_B}) {
+    REQUIRE(img->add_channel(ch, CANVAS, CANVAS, 8, heif_get_global_security_limits()).error_code == heif_error_Ok);
+
+    size_t stride = 0;
+    uint8_t* p = img->get_channel_memory(ch, &stride);
+    for (uint32_t y = 0; y < CANVAS; y++) {
+      memset(p + y * stride, BACKGROUND, CANVAS);
+    }
+  }
+
+  return img;
+}
+
+
+std::shared_ptr<HeifPixelImage> make_overlay(bool with_alpha)
+{
+  auto img = std::make_shared<HeifPixelImage>();
+  img->create(OVL_W, OVL_H, heif_colorspace_RGB, heif_chroma_444);
+
+  std::vector<heif_channel> channels = {heif_channel_R, heif_channel_G, heif_channel_B};
+  if (with_alpha) {
+    channels.push_back(heif_channel_Alpha);
+  }
+
+  for (heif_channel ch : channels) {
+    REQUIRE(img->add_channel(ch, OVL_W, OVL_H, 8, heif_get_global_security_limits()).error_code == heif_error_Ok);
+
+    size_t stride = 0;
+    uint8_t* p = img->get_channel_memory(ch, &stride);
+    for (uint32_t y = 0; y < OVL_H; y++) {
+      for (uint32_t x = 0; x < OVL_W; x++) {
+        p[y * stride + x] = overlay_value(ch, x, y);
+      }
+    }
+  }
+
+  return img;
+}
+
+
+// The value that canvas pixel (cx,cy) must have after compositing the overlay at (dx,dy).
+uint8_t expected_value(heif_channel ch, uint32_t cx, uint32_t cy, int32_t dx, int32_t dy, bool with_alpha)
+{
+  int64_t ox = static_cast<int64_t>(cx) - dx;
+  int64_t oy = static_cast<int64_t>(cy) - dy;
+
+  if (ox < 0 || oy < 0 || ox >= OVL_W || oy >= OVL_H) {
+    return BACKGROUND;
+  }
+
+  uint8_t in = overlay_value(ch, static_cast<uint32_t>(ox), static_cast<uint32_t>(oy));
+  if (!with_alpha) {
+    return in;
+  }
+
+  int a = alpha_value(static_cast<uint32_t>(ox), static_cast<uint32_t>(oy));
+  return static_cast<uint8_t>((in * a + BACKGROUND * (255 - a)) / 255);
+}
+
+
+void check_overlay_at(int32_t dx, int32_t dy, bool with_alpha)
+{
+  INFO("offset (" << dx << "," << dy << "), alpha=" << with_alpha);
+
+  auto canvas = make_canvas();
+  auto overlay = make_overlay(with_alpha);
+
+  REQUIRE(canvas->overlay(overlay, dx, dy).error_code == heif_error_Ok);
+
+  for (heif_channel ch : {heif_channel_R, heif_channel_G, heif_channel_B}) {
+    size_t stride = 0;
+    const uint8_t* p = canvas->get_channel_memory(ch, &stride);
+
+    for (uint32_t cy = 0; cy < CANVAS; cy++) {
+      for (uint32_t cx = 0; cx < CANVAS; cx++) {
+        INFO("channel " << static_cast<int>(ch) << ", canvas pixel (" << cx << "," << cy << ")");
+        REQUIRE(p[cy * stride + cx] == expected_value(ch, cx, cy, dx, dy, with_alpha));
+      }
+    }
+  }
+}
+
+
+struct Offset {
+  int32_t dx;
+  int32_t dy;
+};
+
+// Offsets that leave part of the overlay on the canvas.
+const std::vector<Offset> partially_visible_offsets = {
+    {2, 3},    // completely inside
+    {0, 0},    // aligned with the top-left corner
+    {-1, -1},  // slightly outside on the top-left; used to draw a truncated part
+    {-3, -2},  // outside by at least half its size on both axes; used to be dropped
+    {-4, 0},   // one column left
+    {0, -3},   // one row left
+    {-4, 3},   // negative x only
+    {3, -3},   // negative y only
+    {6, 5},    // partially outside on the right and bottom
+    {7, 7},    // one pixel visible at the bottom-right corner
+    {-2, 6},   // outside on the left and bottom
+    {5, -1},   // outside on the right and top
+};
+
+// Offsets that place the overlay completely outside of the canvas, including
+// the extreme values, which must neither draw anything nor overflow.
+const std::vector<Offset> invisible_offsets = {
+    {-5, 0}, {0, -4}, {8, 0}, {0, 8}, {-5, -4}, {8, 8},
+    {INT32_MIN, 0}, {0, INT32_MIN}, {INT32_MIN, INT32_MIN},
+    {INT32_MAX, 0}, {0, INT32_MAX}, {INT32_MAX, INT32_MAX},
+    {INT32_MIN, INT32_MAX},
+};
+
+} // namespace
+
+
+TEST_CASE("overlay without alpha at offsets partially outside the canvas") {
+  for (const auto& o : partially_visible_offsets) {
+    check_overlay_at(o.dx, o.dy, false);
+  }
+}
+
+TEST_CASE("overlay with alpha at offsets partially outside the canvas") {
+  for (const auto& o : partially_visible_offsets) {
+    check_overlay_at(o.dx, o.dy, true);
+  }
+}
+
+TEST_CASE("overlay completely outside the canvas leaves it unchanged") {
+  for (const auto& o : invisible_offsets) {
+    check_overlay_at(o.dx, o.dy, false);
+    check_overlay_at(o.dx, o.dy, true);
+  }
+}