You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2025/04/11 17:09:24 UTC

(sis) 01/04: Allow to read tiles through HTTP using HTTP range requests. It requires some reorganization for allowing to know the ranges in advance.

This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit ec8d89adde13533ad3dd197a7cd7244a7dab48fc
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Thu Apr 10 16:40:32 2025 +0200

    Allow to read tiles through HTTP using HTTP range requests.
    It requires some reorganization for allowing to know the ranges in advance.
---
 .../org/apache/sis/io/stream/ChannelDataInput.java |  15 ++
 .../sis/storage/base/TiledDeferredImage.java       |   1 +
 .../apache/sis/storage/base/TiledGridCoverage.java |  19 ++-
 .../apache/sis/storage/geoheif/FromImageIO.java    |  65 ++++----
 .../main/org/apache/sis/storage/geoheif/Image.java |  43 ++++--
 .../apache/sis/storage/geoheif/ImageResource.java  | 152 +++++++++++++------
 .../sis/storage/geoheif/ResourceBuilder.java       |   6 +-
 .../sis/storage/geoheif/UncompressedImage.java     |  69 ++++-----
 .../org/apache/sis/storage/isobmff/ByteRanges.java | 163 +++++++++++++++++++++
 .../org/apache/sis/storage/isobmff/ByteReader.java |  92 ------------
 .../org/apache/sis/storage/isobmff/Reader.java     |  14 +-
 .../apache/sis/storage/isobmff/base/ItemData.java  |  32 ++--
 .../sis/storage/isobmff/base/ItemLocation.java     |  96 +++++++-----
 13 files changed, 481 insertions(+), 286 deletions(-)

diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/io/stream/ChannelDataInput.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/io/stream/ChannelDataInput.java
index 8616626267..2abd181a59 100644
--- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/io/stream/ChannelDataInput.java
+++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/io/stream/ChannelDataInput.java
@@ -1221,6 +1221,8 @@ loop:   while (hasRemaining()) {
      *
      * @param  lower  position (inclusive) of the first byte to be requested.
      * @param  upper  position (exclusive) of the last byte to be requested.
+     *
+     * @see #useRangeOfInterest()
      */
     public final void rangeOfInterest(long lower, long upper) {
         if (channel instanceof ByteRangeChannel) {
@@ -1230,6 +1232,19 @@ loop:   while (hasRemaining()) {
         }
     }
 
+    /**
+     * Returns whether calls to {@code rangeOfInterest(…)} has any effect.
+     * This method is useful when {@code rangeOfInterest(…)} is invoked in a loop.
+     * If this method returns {@code false}, then the loop can be omitted completly.
+     *
+     * @return whether calls to {@code rangeOfInterest(…)} has any effect.
+     *
+     * @see #rangeOfInterest(long, long)
+     */
+    public final boolean useRangeOfInterest() {
+        return (channel instanceof ByteRangeChannel);
+    }
+
     /**
      * Forgets the given number of bytes in the buffer.
      * This is invoked for making room for more bytes.
diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/TiledDeferredImage.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/TiledDeferredImage.java
index 1e6a7f10b9..eb4b50d2c3 100644
--- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/TiledDeferredImage.java
+++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/TiledDeferredImage.java
@@ -97,6 +97,7 @@ final class TiledDeferredImage extends BatchComputedImage {
      *
      * @param  tiles  range of tile indices for which to load tiles.
      * @return loaded tiles for the given indices, in row-major fashion.
+     * @throws Exception if an error occurred while loading a tile.
      */
     @Override
     protected Raster[] computeTiles(final Rectangle tiles) throws Exception {
diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/TiledGridCoverage.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/TiledGridCoverage.java
index f5604cfb34..cada7ef2ac 100644
--- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/TiledGridCoverage.java
+++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/TiledGridCoverage.java
@@ -19,7 +19,6 @@ package org.apache.sis.storage.base;
 import java.util.Map;
 import java.util.Locale;
 import java.util.Optional;
-import java.io.IOException;
 import java.awt.Point;
 import java.awt.Rectangle;
 import java.awt.image.DataBuffer;
@@ -43,7 +42,6 @@ import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.DisjointExtentException;
 import org.apache.sis.image.privy.DeferredProperty;
 import org.apache.sis.image.privy.TiledImage;
-import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.tiling.TileMatrixSet;
 import org.apache.sis.storage.internal.Resources;
 import org.apache.sis.util.collection.WeakValueHashMap;
@@ -1329,17 +1327,22 @@ public abstract class TiledGridCoverage extends GridCoverage {
      * corner of the image in the {@link TiledGridResource}).
      *
      * The {@link Raster#getMinX()} and {@code getMinY()} coordinates of returned rasters
-     * shall start at the given {@code iterator.offsetAOI} values.
+     * shall start at the values given by {@link TileIterator#getTileOrigin(int)}.
+     * Each tile in the returned array shall be stored at the index given by
+     * {@link TileIterator#getTileIndexInResultArray()}.
      *
      * <p>This method must be thread-safe. It is implementer responsibility to ensure synchronization,
      * for example using {@link TiledGridResource#getSynchronizationLock()}.</p>
      *
      * @param  iterator  an iterator over the tiles that intersect the Area Of Interest specified by user.
      * @return tiles decoded from the {@link TiledGridResource}.
-     * @throws IOException if an I/O error occurred.
-     * @throws DataStoreException if a logical error occurred.
-     * @throws RuntimeException if the Java2D image cannot be created for another reason
-     *         (too many exception types to list them all).
+     * @throws Exception if the tile cannot be created. There is too many possible exceptions for listing all types,
+     *         but the main ones are {@link java.io.IOException} for I/O errors and various {@link RuntimeException}
+     *         subtypes for Java2D errors.
+     *
+     * @see TileIterator#createRaster()
+     * @see TileIterator#getTileOrigin(int)
+     * @see TileIterator#getTileIndexInResultArray()
      */
-    protected abstract Raster[] readTiles(TileIterator iterator) throws IOException, DataStoreException;
+    protected abstract Raster[] readTiles(TileIterator iterator) throws Exception;
 }
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/FromImageIO.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/FromImageIO.java
index d8e55e8e93..2db0d56ebb 100644
--- a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/FromImageIO.java
+++ b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/FromImageIO.java
@@ -17,7 +17,6 @@
 package org.apache.sis.storage.geoheif;
 
 import java.io.IOException;
-import java.awt.image.Raster;
 import java.awt.image.SampleModel;
 import java.awt.image.BufferedImage;
 import java.awt.image.RasterFormatException;
@@ -25,11 +24,12 @@ import javax.imageio.ImageReader;
 import javax.imageio.ImageTypeSpecifier;
 import javax.imageio.spi.IIORegistry;
 import javax.imageio.spi.ImageReaderSpi;
+import org.apache.sis.io.stream.ChannelDataInput;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.storage.IllegalOpenParameterException;
 import org.apache.sis.storage.UnsupportedEncodingException;
-import org.apache.sis.storage.isobmff.ByteReader;
+import org.apache.sis.storage.isobmff.ByteRanges;
 import org.apache.sis.util.ArraysExt;
 
 
@@ -60,7 +60,7 @@ final class FromImageIO extends Image {
      * @param  name      a name that identifies this image, for debugging purpose.
      * @throws RasterFormatException if the sample model cannot be created.
      */
-    FromImageIO(final CoverageBuilder builder, final ByteReader locator, final ImageReaderSpi provider, final String name) {
+    FromImageIO(final CoverageBuilder builder, final ByteRanges.Reader locator, final ImageReaderSpi provider, final String name) {
         super(builder, locator, name);
         this.provider = provider;
     }
@@ -86,22 +86,21 @@ final class FromImageIO extends Image {
     }
 
     /**
-     * Sets the input of the given reader to an input stream positioned to the beginning of the image.
+     * Sets the input of the given reader to an input stream positioned at the beginning of the image.
      *
-     * @param  store   the store that opened the <abbr>HEIF</abbr> file.
      * @param  reader  the image reader for which to set the input.
+     * @param  input   the input from which to read bytes.
+     * @param  ranges  the ranges of bytes to read.
      * @throws DataStoreException if the input cannot be set because of its type.
      * @throws IOException if an I/O error occurred while setting the input.
      */
-    private void setReaderInput(final GeoHeifStore store, final ImageReader reader) throws DataStoreException, IOException {
-        final var request = new ByteReader.FileRegion();
-        request.input  = store.ensureOpen();
-        request.length = -1;
-        locator.resolve(request);
-        request.input.seek(request.offset);
-        request.input.buffer.order(byteOrder);
+    private void setReaderInput(final ImageReader reader, final ChannelDataInput input, final ByteRanges ranges)
+            throws DataStoreException, IOException
+    {
+        input.seek(ranges.offset());
+        input.buffer.order(byteOrder);
         try {
-            reader.setInput(request.input, true, true);
+            reader.setInput(input, true, true);
         } catch (IllegalArgumentException e) {
             throw new IllegalOpenParameterException("Not an image input stream.", e);
         }
@@ -119,7 +118,9 @@ final class FromImageIO extends Image {
     @Override
     protected ImageTypeSpecifier getImageType(final GeoHeifStore store) throws DataStoreException, IOException {
         final ImageReader reader = provider.createReaderInstance();
-        setReaderInput(store, reader);
+        final var ranges = new ByteRanges();
+        locator.resolve(0, -1, ranges);
+        setReaderInput(reader, ranges.viewAsConsecutiveBytes(store.ensureOpen()), ranges);
         final var it = reader.getImageTypes(IMAGE_INDEX);
         ImageTypeSpecifier specifier;
         if (it.hasNext()) {
@@ -139,26 +140,26 @@ final class FromImageIO extends Image {
     }
 
     /**
-     * Reads a single tile.
+     * Computes the range of bytes that will be needed for reading a single tile of this image.
      *
-     * @param  store    the data store reading a tile.
-     * @param  tileX    0-based column index of the tile to read, starting from image left.
-     * @param  tileY    0-based column index of the tile to read, starting from image top.
-     * @param  context  contains the target raster or the image reader to use.
-     * @return tile filled with the pixel values read by this method.
+     * @param  context  where to store the ranges of bytes.
+     * @throws DataStoreException if an error occurred while computing the range of bytes.
      */
     @Override
-    protected Raster readTile(final GeoHeifStore store, final long tileX, final long tileY,
-            final ImageResource.Coverage.ReadContext context) throws IOException, DataStoreException
-    {
-        final ImageReader reader = context.getReader(provider);
-        setReaderInput(store, reader);
-        final BufferedImage image;
-        try {
-            image = reader.readTile(IMAGE_INDEX, Math.toIntExact(tileX), Math.toIntExact(tileY));
-        } finally {
-            reader.setInput(null);
-        }
-        return image.getRaster();
+    protected Reader computeByteRanges(final ImageResource.Coverage.ReadContext context) throws DataStoreException {
+        locator.resolve(0, -1, context);
+        return (final ChannelDataInput input) -> {
+            final ImageReader reader = context.getReader(provider);
+            setReaderInput(reader, input, context);
+            final BufferedImage image;
+            try {
+                image = reader.readTile(IMAGE_INDEX,
+                        Math.toIntExact(context.subTileX),
+                        Math.toIntExact(context.subTileY));
+            } finally {
+                reader.setInput(null);
+            }
+            return image.getRaster();
+        };
     }
 }
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/Image.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/Image.java
index 75742b29f3..b04b221e54 100644
--- a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/Image.java
+++ b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/Image.java
@@ -24,7 +24,7 @@ import javax.imageio.ImageTypeSpecifier;
 import org.apache.sis.io.stream.ChannelDataInput;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.DataStoreContentException;
-import org.apache.sis.storage.isobmff.ByteReader;
+import org.apache.sis.storage.isobmff.ByteRanges;
 
 
 /**
@@ -51,7 +51,7 @@ abstract class Image {
      * The provider of bytes to read from the <abbr>ISOBMFF</abbr> box.
      * The bytes are read from the {@link ChannelDataInput} at a position specified by the box.
      */
-    protected final ByteReader locator;
+    protected final ByteRanges.Reader locator;
 
     /**
      * The byte order to use for reading the sample values of the image.
@@ -66,7 +66,7 @@ abstract class Image {
      * @param  name     a name that identifies this image, for debugging purpose.
      * @throws RasterFormatException if the sample model cannot be created.
      */
-    protected Image(final CoverageBuilder builder, final ByteReader locator, final String name) {
+    protected Image(final CoverageBuilder builder, final ByteRanges.Reader locator, final String name) {
         this.locator = locator;
         this.name    = name;
         byteOrder    = builder.byteOrder();
@@ -99,16 +99,37 @@ abstract class Image {
     }
 
     /**
-     * Reads a single tile.
+     * Computes the range of bytes that will be needed for reading a single tile of this image.
+     * There will be usually a single range of bytes per tile, but this method allows the bytes
+     * to be spread over more than one extent.
      *
-     * @param  store    the data store reading a tile.
-     * @param  tileX    0-based column index of the tile to read, starting from image left.
-     * @param  tileY    0-based column index of the tile to read, starting from image top.
-     * @param  context  contains the target raster or the image reader to use.
-     * @return tile filled with the pixel values read by this method.
+     * <p>This method does not read the bytes immediately.
+     * Instead, it returns a function which will be executed later for finishing the read operation.</p>
+     *
+     * @param  context  where to store the ranges of bytes.
+     * @return the function to invoke later for reading the tile.
+     * @throws DataStoreException if an error occurred while computing the range of bytes.
+     */
+    protected abstract Reader computeByteRanges(ImageResource.Coverage.ReadContext context) throws DataStoreException;
+
+    /**
+     * Reads a single tile from a sequence of bytes.
+     * Instances are prepared and returned by {@link #computeByteRanges computeByteRanges(…)}.
      */
-    protected abstract Raster readTile(final GeoHeifStore store, final long tileX, final long tileY,
-            final ImageResource.Coverage.ReadContext context) throws IOException, DataStoreException;
+    @FunctionalInterface
+    protected interface Reader {
+        /**
+         * Reads a single tile from a sequence of bytes in the given input.
+         * The implementation is responsible for setting the stream position before to start reading bytes.
+         * The given {@code input} will view all bytes after the initial position as if they were stored in
+         * one single large extent.
+         * Implementations should ignore the fact that the sequence of bytes may be spread in many extents.
+         *
+         * @param  input  a view of the byte sequences as if they were stored in one single large extent.
+         * @return tile filled with the pixel values read by this method.
+         */
+        Raster readTile(ChannelDataInput input) throws Exception;
+    }
 
     /**
      * Returns the name of this image, for debugging purposes.
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/ImageResource.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/ImageResource.java
index d5f35f50a0..32439c4d07 100644
--- a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/ImageResource.java
+++ b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/ImageResource.java
@@ -16,11 +16,13 @@
  */
 package org.apache.sis.storage.geoheif;
 
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import java.util.HashMap;
 import java.util.Optional;
-import java.util.IdentityHashMap;
 import java.io.IOException;
+import java.io.UncheckedIOException;
 import static java.lang.Math.addExact;
 import static java.lang.Math.multiplyExact;
 import java.awt.image.ColorModel;
@@ -41,6 +43,8 @@ import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.base.StoreResource;
 import org.apache.sis.storage.base.TiledGridCoverage;
 import org.apache.sis.storage.base.TiledGridResource;
+import org.apache.sis.storage.isobmff.ByteRanges;
+import org.apache.sis.io.stream.ChannelDataInput;
 
 
 /**
@@ -109,18 +113,19 @@ final class ImageResource extends TiledGridResource implements StoreResource {
     private final ColorModel colorModel;
 
     /**
-     * The images that constitute the tiles of this grid. Shall contain at least 1 element.
+     * Information about the images that constitute the tiles of this grid. Shall contain at least 1 element.
+     * The {@link Image} elements contain only metadata such as position in the file, not actual pixel values.
      * All tiles shall have the same size.
      */
     private final Image[] tiles;
 
     /**
      * Creates a new grid coverage resource for an image.
-     * Exactly one of {@code tiles} and {@code reader} argument shall be non-null.
+     * Exactly one of {@code tiles} and {@code image} arguments shall be non-null.
      *
      * @param  builder  helper class for building the grid geometry and sample dimensions.
      * @param  tiles    the images that constitute the tiles, or {@code null} if {@code reader} is provided.
-     * @param  image    the single tile for the wole image, or {@code null} if {@code tiles} is provided.
+     * @param  image    the single tile for the whole image, or {@code null} if {@code tiles} is provided.
      * @throws RasterFormatException if the sample dimensions or sample model cannot be created.
      * @throws DataStoreException if the "grid to <abbr>CRS</abbr>" transform cannot be created.
      */
@@ -299,19 +304,36 @@ final class ImageResource extends TiledGridResource implements StoreResource {
          * @return tiles decoded from the enclosing resource.
          */
         @Override
-        protected Raster[] readTiles(final TileIterator iterator) throws IOException, DataStoreException {
-            final Raster[] result = new Raster[iterator.tileCountInQuery];
-            try (final var context = new ReadContext(iterator)) {
-                synchronized (getSynchronizationLock()) {
+        protected Raster[] readTiles(final TileIterator iterator) throws Exception {
+            final var result = new Raster[iterator.tileCountInQuery];
+            final var requests = new ReadContext[result.length];
+            int count = 0;
+            synchronized (getSynchronizationLock()) {
+                try (final var context = new ReadContext(iterator)) {
                     do {
-                        Raster tile = iterator.getCachedTile();
-                        if (tile == null) {
-                            long[] tileCoord = iterator.getTileCoordinatesInResource();
-                            tile = readTile(tileCoord[0], tileCoord[1], context);
-                            tile = iterator.cache(iterator.moveRaster(tile));
+                        Raster raster = iterator.getCachedTile();
+                        if (raster != null) {
+                            result[iterator.getTileIndexInResultArray()] = raster;
+                        } else {
+                            requests[count++] = new ReadContext(context, ImageResource.this);
                         }
-                        result[iterator.getTileIndexInResultArray()] = tile;
                     } while (iterator.next());
+                    /*
+                     * For any tile that was not in the cache, read them in the order they appear in the file.
+                     * The ranges of bytes of all tiles should be declared before to start the reading process,
+                     * for giving a chance to emit a single "HTTP range" request. Then, for each tile, `input`
+                     * may be replaced by a view hiding the fact that byte may be spread in more than one extent.
+                     */
+                    Arrays.sort(requests, 0, count);
+                    final ChannelDataInput input = store.ensureOpen();
+                    if (input.useRangeOfInterest()) {
+                        for (int i=0; i<count; i++) {
+                            requests[i].notify(input);  // Implementation should take care of merging the ranges.
+                        }
+                    }
+                    for (int i=0; i<count; i++) {
+                        requests[i].readTile(input, result);        // Implementation may create an `input` view.
+                    }
                 }
             }
             return result;
@@ -321,17 +343,27 @@ final class ImageResource extends TiledGridResource implements StoreResource {
          * Context about a {@code readTile(…)} operation. Contains the tile to create, or
          * the image reader to use in the case of read operations delegated to Image I/O.
          */
-        static final class ReadContext implements AutoCloseable {
+        static final class ReadContext extends ByteRanges implements AutoCloseable {
             /**
              * Iterator over the tiles to read.
              */
             private final AOI iterator;
 
             /**
-             * The image reader created for reading the tiles,
-             * or {@code null} if image readers are not used.
+             * The image readers created for reading the tiles.
+             */
+            private final Map<ImageReaderSpi, ImageReader> readers;
+
+            /**
+             * The function to execute for reading the image as a tile.
              */
-            private Map<ImageReaderSpi, ImageReader> readers;
+            private final Image.Reader reader;
+
+            /**
+             * 0-based index (column and row) of the tile to read inside the image to read.
+             * This is a tile inside the {@linkplain #tile}, i.e. a second level of tiling.
+             */
+            final long subTileX, subTileY;
 
             /**
              * Creates a new read context.
@@ -340,6 +372,42 @@ final class ImageResource extends TiledGridResource implements StoreResource {
              */
             private ReadContext(final AOI iterator) {
                 this.iterator = iterator;
+                this.readers  = new HashMap<>();
+                this.reader   = null;
+                this.subTileX = 0;
+                this.subTileY = 0;
+            }
+
+            /**
+             * Creates a context which is a snapshot of the given context at the current iterator position.
+             *
+             * @param  parent  the parent from which to create a snapshot.
+             * @param  tileX   0-based column index of the tile to read, starting from image left.
+             * @param  tileY   0-based row index of the tile to read, starting from image top.
+             */
+            private ReadContext(final ReadContext parent, final ImageResource owner) throws DataStoreException {
+                this.iterator = new Snapshot(parent.iterator);
+                this.readers  = parent.readers;
+                final long[] tileCoord = iterator.getTileCoordinatesInResource();
+                final Image tile = owner.getTile(tileCoord[0], tileCoord[1]);
+                subTileX = tileCoord[0] % tile.numXTiles;
+                subTileY = tileCoord[1] % tile.numYTiles;
+                reader   = tile.computeByteRanges(this);
+            }
+
+            /**
+             * Reads the tile and store the result in the given array.
+             * The given input is the stream over the <abbr>HEIF</abbr> file
+             * (not yet the view showing all bytes as if they were stored in a single extent).
+             *
+             * @param  input   the input from which to read bytes.
+             * @param  result  where to store the result.
+             */
+            final void readTile(ChannelDataInput input, final Raster[] result) throws Exception {
+                input = viewAsConsecutiveBytes(input);
+                Raster raster = reader.readTile(input);
+                raster = iterator.cache(iterator.moveRaster(raster));
+                result[iterator.getTileIndexInResultArray()] = raster;
             }
 
             /**
@@ -363,15 +431,17 @@ final class ImageResource extends TiledGridResource implements StoreResource {
              * @throws IOException if an error occurred while creating the image reader.
              */
             public ImageReader getReader(final ImageReaderSpi spi) throws IOException {
-                if (readers == null) {
-                    readers = new IdentityHashMap<>();
-                }
-                ImageReader reader = readers.get(spi);
-                if (reader == null) {
-                    reader = spi.createReaderInstance();
-                    readers.put(spi, reader);
+                try {
+                    return readers.computeIfAbsent(spi, (factory) -> {
+                        try {
+                            return factory.createReaderInstance();
+                        } catch (IOException e) {
+                            throw new UncheckedIOException(e);
+                        }
+                    });
+                } catch (UncheckedIOException e) {
+                    throw e.getCause();
                 }
-                return reader;
             }
 
             /**
@@ -380,30 +450,24 @@ final class ImageResource extends TiledGridResource implements StoreResource {
              */
             @Override
             public void close() {
-                if (readers != null) {
-                    readers.values().forEach(ImageReader::dispose);
-                }
+                readers.values().forEach(ImageReader::dispose);
             }
         }
     }
 
     /**
-     * Reads a single tile.
+     * Returns information (not pixel values) about the tile at the given tile coordinates.
+     * The tile coordinates are in the tile matrix including the sub-divisions in each tile.
+     * This method returns the image which contains the requested tile.
      *
-     * @param  tileX    0-based column index of the tile to read, starting from image left.
-     * @param  tileY    0-based column index of the tile to read, starting from image top.
-     * @param  context  contains the target raster or the image reader to use.
-     * @return tile filled with the pixel values read by this method.
+     * @param  tileX  0-based column index of the tile to read, starting from image left.
+     * @param  tileY  0-based row index of the tile to read, starting from image top.
+     * @return information about the tile at the specified tile coordinates.
      */
-    private Raster readTile(long tileX, long tileY, final Coverage.ReadContext context)
-            throws IOException, DataStoreException
-    {
-        Image tile = tiles[0];
-        final long tx = tileX / tile.numXTiles;
-        final long ty = tileY / tile.numYTiles;
-        tile = tiles[Math.toIntExact(addExact(tx, multiplyExact(ty, tileMatrixRowStride)))];
-        tileX %= tile.numXTiles;
-        tileY %= tile.numYTiles;
-        return tile.readTile(store, tileX, tileY, context);
+    final Image getTile(long tileX, long tileY) {
+        Image template = tiles[0];      // First tile taken as a template for all tiles.
+        tileX /= template.numXTiles;
+        tileY /= template.numYTiles;
+        return tiles[Math.toIntExact(addExact(tileX, multiplyExact(tileY, tileMatrixRowStride)))];
     }
 }
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/ResourceBuilder.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/ResourceBuilder.java
index e3bc44b1d1..087cabb903 100644
--- a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/ResourceBuilder.java
+++ b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/ResourceBuilder.java
@@ -36,7 +36,7 @@ import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.storage.UnsupportedEncodingException;
 import org.apache.sis.storage.isobmff.Box;
-import org.apache.sis.storage.isobmff.ByteReader;
+import org.apache.sis.storage.isobmff.ByteRanges;
 import org.apache.sis.storage.isobmff.Root;
 import org.apache.sis.storage.isobmff.base.EntityToGroup;
 import org.apache.sis.storage.isobmff.base.GroupList;
@@ -257,7 +257,7 @@ final class ResourceBuilder {
      * @return the item for locating the identified data, or {@code null} if none.
      * @throws DataStoreContentException if there is two ore more items for the same identifier.
      */
-    private ByteReader getLocationByIdentifier(final int itemID) throws DataStoreContentException {
+    private ByteRanges.Reader getLocationByIdentifier(final int itemID) throws DataStoreContentException {
         final Object item = itemLocations.get(itemID);
         if (item == null) {
             return data;    // May be null.
@@ -335,7 +335,7 @@ final class ResourceBuilder {
             if (firstBuilder == null) {
                 firstBuilder = coverage;
             }
-            final ByteReader locator;
+            final ByteRanges.Reader locator;
             final Image.Supplier image;
             switch (entry.itemType) {
                 default: {
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/UncompressedImage.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/UncompressedImage.java
index 945520ff7e..2e9a05c964 100644
--- a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/UncompressedImage.java
+++ b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/geoheif/UncompressedImage.java
@@ -16,10 +16,8 @@
  */
 package org.apache.sis.storage.geoheif;
 
-import java.io.IOException;
 import static java.lang.Math.addExact;
 import static java.lang.Math.multiplyExact;
-import java.awt.image.Raster;
 import java.awt.image.DataBuffer;
 import java.awt.image.SampleModel;
 import java.awt.image.WritableRaster;
@@ -27,10 +25,11 @@ import java.awt.image.RasterFormatException;
 import org.apache.sis.image.DataType;
 import org.apache.sis.image.privy.ImageUtilities;
 import org.apache.sis.image.privy.RasterFactory;
+import org.apache.sis.io.stream.ChannelDataInput;
 import org.apache.sis.io.stream.HyperRectangleReader;
 import org.apache.sis.io.stream.Region;
 import org.apache.sis.storage.DataStoreException;
-import org.apache.sis.storage.isobmff.ByteReader;
+import org.apache.sis.storage.isobmff.ByteRanges;
 
 
 /**
@@ -66,25 +65,22 @@ final class UncompressedImage extends Image {
      * @param  name     a name that identifies this image, for debugging purpose.
      * @throws RasterFormatException if the sample model cannot be created.
      */
-    UncompressedImage(final CoverageBuilder builder, final ByteReader locator, final String name) throws DataStoreException {
+    UncompressedImage(final CoverageBuilder builder, final ByteRanges.Reader locator, final String name)
+            throws DataStoreException
+    {
         super(builder, locator, name);
         sampleModel = builder.sampleModel();
         dataType    = builder.dataType();     // Shall be after `sampleModel()`.
     }
 
     /**
-     * Reads a single tile. The default implementation assumes an uncompressed tile.
+     * Computes the range of bytes that will be needed for reading a single tile of this image.
      *
-     * @param  store    the data store reading a tile.
-     * @param  tileX    0-based column index of the tile to read, starting from image left.
-     * @param  tileY    0-based column index of the tile to read, starting from image top.
-     * @param  context  contains the target raster or the image reader to use.
-     * @return tile filled with the pixel values read by this method.
+     * @param  context  where to store the ranges of bytes.
+     * @throws DataStoreException if an error occurred while computing the range of bytes.
      */
     @Override
-    protected Raster readTile(final GeoHeifStore store, final long tileX, final long tileY,
-            final ImageResource.Coverage.ReadContext context) throws IOException, DataStoreException
-    {
+    protected Reader computeByteRanges(final ImageResource.Coverage.ReadContext context) throws DataStoreException {
         final long[] sourceSize = {
             // Note: the following ignores `MultiPixelPackedSampleModel`, but that model does not seem used by HEIF.
             sampleModel.getWidth() * (long) sampleModel.getNumDataElements(),
@@ -99,30 +95,29 @@ final class UncompressedImage extends Image {
         final int  dataSize  = dataType.bytes();
         final long tileSize  = multiplyExact(region.length, dataSize);
         final long skipBytes = region.getStartByteOffset(dataSize);
-        final var  request   = new ByteReader.FileRegion();
-        request.input  = store.ensureOpen();
-        request.offset = multiplyExact(addExact(multiplyExact(tileY, numXTiles), tileX), tileSize);
-        request.length = tileSize;
-        request.skip(skipBytes);
-        locator.resolve(request);
-        /*
-         * Now read all banks and store the values in the image buffer.
-         * If there is many banks (`InterleavingMode.COMPONENT`), these
-         * banks are assumed consecutive.
-         */
-        request.input.buffer.order(byteOrder);
-        final var hr = new HyperRectangleReader(ImageUtilities.toNumberEnum(dataType), request.input);
-        hr.setOrigin(request.offset - skipBytes);
-        final WritableRaster raster = context.createRaster();
-        final DataBuffer data = raster.getDataBuffer();
-        final int numBanks = data.getNumBanks();
-        for (int b=0; b<numBanks; b++) {
-            if (b != 0) {
-                hr.setOrigin(addExact(hr.getOrigin(), tileSize));
+        final long tileIndex = addExact(multiplyExact(context.subTileY, numXTiles), context.subTileX);
+        final long offset    = addExact(multiplyExact(tileIndex, tileSize), skipBytes);
+        locator.resolve(offset, tileSize - skipBytes, context);
+        return (final ChannelDataInput input) -> {
+            /*
+             * Now read all banks and store the values in the image buffer.
+             * If there is many banks (`InterleavingMode.COMPONENT`), these
+             * banks are assumed consecutive.
+             */
+            input.buffer.order(byteOrder);
+            final var hr = new HyperRectangleReader(ImageUtilities.toNumberEnum(dataType), input);
+            hr.setOrigin(context.offset() - skipBytes);
+            final WritableRaster raster = context.createRaster();
+            final DataBuffer data = raster.getDataBuffer();
+            final int numBanks = data.getNumBanks();
+            for (int b=0; b<numBanks; b++) {
+                if (b != 0) {
+                    hr.setOrigin(addExact(hr.getOrigin(), tileSize));
+                }
+                hr.setDestination(RasterFactory.wrapAsBuffer(data, b));
+                hr.readAsBuffer(region, 0);
             }
-            hr.setDestination(RasterFactory.wrapAsBuffer(data, b));
-            hr.readAsBuffer(region, 0);
-        }
-        return raster;
+            return raster;
+        };
     }
 }
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/ByteRanges.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/ByteRanges.java
new file mode 100644
index 0000000000..2d2df96047
--- /dev/null
+++ b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/ByteRanges.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.storage.isobmff;
+
+import java.util.Arrays;
+import org.apache.sis.io.stream.ChannelDataInput;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.DataStoreContentException;
+import org.apache.sis.storage.UnsupportedEncodingException;
+
+
+/**
+ * Ranges of bytes to read from a <abbr>HEIF</abbr> file.
+ * Instances are given to {@link Reader#resolve(long, long, ByteRanges)} for
+ * converting values relative to a box item to values relative to the stream.
+ *
+ * @author Martin Desruisseaux (Geomatys)
+ */
+public class ByteRanges implements Comparable<ByteRanges> {
+    /**
+     * Interface implemented by the {@link TreeNode} subclasses which read bytes only when requested.
+     * Contrarily to the constructors of {@link Box} subclasses which read all the payload immediately,
+     * the method provided by this interface allow access to a subset of a potentially large sequence of bytes.
+     *
+     * @author Martin Desruisseaux (Geomatys)
+     */
+    public interface Reader {
+        /**
+         * Converts an offset relative to the data of this item to offsets relative to the origin of the input stream.
+         * The subset is specified by the {@code offset} and {@code length} arguments, where an {@code offset} of zero
+         * is for the first byte stored by this item. Implementation will typically add to {@code offset} the position
+         * in the file of the first byte.
+         *
+         * @param  offset  offset of the first byte to read relative to the data stored is this item.
+         * @param  length  maximum number of bytes to read, starting at the offset, or a negative value for reading all.
+         * @param  addTo   where to add the ranges of bytes to read as offsets relatives to the beginning of the file.
+         * @throws UnsupportedEncodingException if this item uses an unsupported construction method.
+         * @throws DataStoreContentException if the <abbr>HEIF</abbr> file is malformed.
+         * @throws DataStoreException if another logical error occurred.
+         * @throws ArithmeticException if an integer overflow occurred.
+         *
+         * @see #addRange(long, long)
+         */
+        void resolve(long offset, long length, ByteRanges addTo) throws DataStoreException;
+    }
+
+    /**
+     * Ranges of bytes to read, relative to the file and in the order they were added.
+     * Consecutive ranges are merged in a single range, but no other simplification is performed.
+     * Offsets at even indexes are starting point (inclusive) and offsets at odd indexes are end point (exclusive).
+     */
+    private long[] offsets;
+
+    /**
+     * Creates an initially empty set of byte ranges.
+     */
+    public ByteRanges() {
+    }
+
+    /**
+     * Adds a range of byte offsets relative to the beginning of the <abbr>HEIF</abbr> file.
+     * this method is invoked by {@link Reader#resolve(long, long, ByteRanges)} implementations
+     * for specifying all range of bytes to read. This method is usually invoked exactly once.
+     * It may be invoked more often if the bytes are spread over many regions of the file.
+     *
+     * @param start  offset of the first byte to read, inclusive.
+     * @param end    offset after the last byte to read.
+     */
+    public void addRange(final long start, final long end) {
+        if (start < end) {
+            if (offsets == null) {
+                offsets = new long[] {start, end};
+            } else {
+                int i = offsets.length - 1;
+                if (offsets[i] == start) {
+                    offsets[i] = end;
+                } else {
+                    offsets = Arrays.copyOf(offsets, ++i + 2);
+                    offsets[i] = start;
+                    offsets[i+1] = end;
+                }
+            }
+        }
+    }
+
+    /**
+     * Returns the offset of the first byte to read, or 0 if unknown.
+     * Note that this is not necessarily the smallest offset, because nothing prevent the
+     * ranges to be declared out-of-order. However, out-of-order ranges are assumed rare.
+     */
+    public final long offset() {
+        return (offsets != null) ? offsets[0] : 0;      // A non-null array should be non-empty.
+    }
+
+    /**
+     * Compares this set of ranges with the given ranges for the order in which they appear in the file.
+     * In the current implementation, the ordering is based on the offset of the first byte to read.
+     * It may be revised in a future version if useful.
+     *
+     * @param  other  the other range of bytes to compare with this instance.
+     * @return negative is this range of bytes is before {@code other}, positive if after, 0 if same offset.
+     */
+    @Override
+    public final int compareTo(final ByteRanges other) {
+        return Long.compare(offset(), other.offset());
+    }
+
+    /**
+     * Notifies the given input about the range of bytes which will be requested. This method should be
+     * invoked for the {@code ByteRanges} associated to all tiles to read before the actual reading starts.
+     * This notification is only a hint. It can be used for preparing a <abbr>HTTP</abbr> range request.
+     *
+     * @param  input  the input stream to notify about the ranges of bytes that will be requested.
+     */
+    public final void notify(final ChannelDataInput input) {
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
+        final long[] offsets = this.offsets;
+        if (offsets != null) {
+            for (int i=0; i < offsets.length;) {
+                // Note: FileCacheByteChannel uses a RangeSet for merging efficiently those ranges.
+                input.rangeOfInterest(offsets[i++], offsets[i++]);
+            }
+        }
+    }
+
+    /**
+     * Returns a stream of bytes for the given request as if all bytes were stored in one sequence.
+     * If all data are stored in the same extent, then this method returns {@code input} directly.
+     * Otherwise, this method returns a view over the given input as if all extents were consecutive.
+     *
+     * @todo The view is not yet implemented.
+     *
+     * @param  input    the channel opened on the <abbr>HEIF</abbr> file.
+     * @param  request  the region to read, as adjusted by {@link #resolve(long, long, ByteRanges)}.
+     * @return the input to use. By default, this is {@code input} directly.
+     * @throws DataStoreException if the input cannot be created.
+     */
+    public final ChannelDataInput viewAsConsecutiveBytes(ChannelDataInput input) throws DataStoreException {
+        if (offsets != null && offsets.length == 2) {
+            return input;
+        }
+        /*
+         * There is at least two extents to read. Create a channel
+         * which will read each extent as if they were consecutive.
+         * TODO: not yet implemented.
+         */
+        throw new UnsupportedEncodingException("Not supported yet");
+    }
+}
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/ByteReader.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/ByteReader.java
deleted file mode 100644
index d32e68297f..0000000000
--- a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/ByteReader.java
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.sis.storage.isobmff;
-
-import org.apache.sis.io.stream.ChannelDataInput;
-import org.apache.sis.storage.DataStoreException;
-import org.apache.sis.storage.DataStoreContentException;
-import org.apache.sis.storage.UnsupportedEncodingException;
-
-
-/**
- * Interface implemented by {@link TreeNode} which read bytes only when requested.
- * Contrarily to {@link Box} constructors which are read all the payload immediately,
- * the method provided by this interface reads only a subset of a potentially large
- * array of bytes.
- *
- * @author Martin Desruisseaux (Geomatys)
- */
-public interface ByteReader {
-    /**
-     * Converts an offset relative to the data of this item to an offset relative to the origin of the input stream.
-     * This method updates the {@link FileRegion#offset} value in-place, typically by adding the stream position of
-     * the first byte of the data stored in this item. The {@link FileRegion#input} is usually unmodified, but this
-     * method may also replace it by a temporary instance if the bytes to read are spread in different regions of the file.
-     *
-     * @param  request  the input stream, offset and length of the region to read. Modified in-place by this method.
-     * @throws UnsupportedEncodingException if this item uses an unsupported construction method.
-     * @throws DataStoreContentException if the <abbr>HEIF</abbr> file is malformed.
-     * @throws DataStoreException if another logical error occurred.
-     * @throws ArithmeticException if an integer overflow occurred.
-     */
-    void resolve(FileRegion request) throws DataStoreException;
-
-    /**
-     * A sub-region of the item to read. Instances of this class are given to {@link #resolve(FileRegion)}
-     * and modified in-place for converting values relative to the item to values relative to the stream.
-     */
-    static final class FileRegion {
-        /**
-         * The input stream from which to read the bytes. After the call to {@link #resolve(FileRegion)},
-         * it may be a temporary instance providing a view of distinct extents as if they were a single
-         * stream of consecutive bytes.
-         */
-        public ChannelDataInput input;
-
-        /**
-         * Offset of the first byte to read. Before the call to {@link #resolve(FileRegion)},
-         * this offset is relative to the data stored by the item (i.e., offset zero is the
-         * first byte stored by the item). After the call to {@code resolve(FileRegion)},
-         * the offset is relative to the origin of {@link #input}.
-         */
-        public long offset;
-
-        /**
-         * Maximum number of bytes to read, starting at the offset, or a negative value for reading all.
-         * After the call to {@link #resolve(FileRegion)}, should be updated to a value not greater than
-         * the number of bytes actually available.
-         */
-        public long length;
-
-        /**
-         * Creates an initially empty file region.
-         */
-        public FileRegion() {
-        }
-
-        /**
-         * Skips the given number of bytes at the beginning.
-         * This method increments {@link #offset} and decrements {@link #length} by the given amount.
-         *
-         * @param  bytes  the number of bytes to skip at the beginning.
-         */
-        public void skip(final long bytes) {
-            offset  = Math.addExact(offset, bytes);
-            length -= bytes;
-        }
-    }
-}
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/Reader.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/Reader.java
index ee7389b883..b37bf0b435 100644
--- a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/Reader.java
+++ b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/Reader.java
@@ -22,6 +22,7 @@ import java.util.List;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.ArrayList;
+import java.util.OptionalLong;
 import java.util.logging.Level;
 import java.util.logging.LogRecord;
 import java.net.URI;
@@ -253,7 +254,10 @@ public final class Reader implements Cloneable {
      * @throws IOException if an error occurred while reading the integers.
      */
     public final int[] readRemainingInts() throws IOException {
-        int n = Math.toIntExact((endOfCurrentBox() - input.getStreamPosition()) / Integer.BYTES);
+        if (endOfCurrentBox < 0) {
+            throw new IOException("Stream of unknown length.");
+        }
+        int n = Math.toIntExact((endOfCurrentBox - input.getStreamPosition()) / Integer.BYTES);
         return (n != 0) ? input.readInts(n) : ArraysExt.EMPTY_INT;
     }
 
@@ -309,13 +313,9 @@ public final class Reader implements Cloneable {
      * Returns the stream position after the last byte of the current box.
      *
      * @return stream position after the last byte of the current box.
-     * @throws IOException if the box size is unknown.
      */
-    public final long endOfCurrentBox() throws IOException {
-        if (endOfCurrentBox >= 0) {
-           return endOfCurrentBox;
-        }
-        throw new IOException("Stream of unknown length.");
+    public final OptionalLong endOfCurrentBox() {
+        return (endOfCurrentBox >= 0) ? OptionalLong.of(endOfCurrentBox) : OptionalLong.empty();
     }
 
     /**
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/base/ItemData.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/base/ItemData.java
index bd2a2c4433..c044b827f8 100644
--- a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/base/ItemData.java
+++ b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/base/ItemData.java
@@ -18,7 +18,8 @@ package org.apache.sis.storage.isobmff.base;
 
 import java.io.IOException;
 import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.storage.isobmff.ByteReader;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.isobmff.ByteRanges;
 import org.apache.sis.storage.isobmff.Reader;
 import org.apache.sis.storage.isobmff.Box;
 
@@ -34,7 +35,7 @@ import org.apache.sis.storage.isobmff.Box;
  * @author Johann Sorel (Geomatys)
  * @author Martin Desruisseaux (Geomatys)
  */
-public class ItemData extends Box implements ByteReader {
+public class ItemData extends Box implements ByteRanges.Reader {
     /**
      * Numerical representation of the {@code "idat"} box type.
      */
@@ -56,7 +57,8 @@ public class ItemData extends Box implements ByteReader {
     public final long payloadOffset;
 
     /**
-     * Number of bytes to read.
+     * Number of bytes to read, or -1 for reading until the end of file.
+     * Note that -1 is also the maximal unsigned value.
      */
     @Interpretation(Type.UNSIGNED)
     public final long size;
@@ -70,23 +72,29 @@ public class ItemData extends Box implements ByteReader {
      */
     public ItemData(final Reader reader) throws IOException {
         payloadOffset = reader.input.getStreamPosition();
-        size = reader.endOfCurrentBox() - payloadOffset;
+        size = reader.endOfCurrentBox().orElse(payloadOffset - 1) - payloadOffset;
     }
 
     /**
      * Converts an offset relative to the data of this item to an offset relative to the origin of the input stream.
-     * This method updates the {@link FileRegion#offset} value in-place by adding the stream position of the first
-     * byte of the data stored in this item.
+     * This method adds the stream position of the first byte of the data stored in this item.
      *
-     * @param  request  the input stream, offset and length of the region to read. Modified in-place by this method.
+     * @param  offset  offset of the first byte to read relative to the data stored in this item.
+     * @param  length  maximum number of bytes to read, starting at the offset, or a negative value for reading all.
+     * @param  addTo   where to add the range of bytes to read as offsets relatives to the beginning of the file.
      * @throws ArithmeticException if an integer overflow occurred.
      */
     @Override
-    public void resolve(final FileRegion request) {
-        ArgumentChecks.ensureBetween("offset", 0, size - Math.max(0, request.length), request.offset);
-        if (request.length > size || request.length < 0) {
-            request.length = size;
+    public void resolve(long offset, long length, ByteRanges addTo) throws DataStoreException {
+        if (size >= 0) {
+            ArgumentChecks.ensureBetween("offset", 0, size - Math.max(0, length), offset);
+            if (length > size || length < 0) {
+                length = size;
+            }
+        } else if (length < 0) {
+            throw new DataStoreException("Stream of unknown length.");
         }
-        request.offset = Math.addExact(payloadOffset, request.offset);
+        offset = Math.addExact(payloadOffset, offset);
+        addTo.addRange(offset, Math.addExact(offset, length));
     }
 }
diff --git a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/base/ItemLocation.java b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/base/ItemLocation.java
index 879bb652a8..e2fd25011b 100644
--- a/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/base/ItemLocation.java
+++ b/incubator/src/org.apache.sis.storage.geoheif/main/org/apache/sis/storage/isobmff/base/ItemLocation.java
@@ -24,7 +24,7 @@ import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.storage.UnsupportedEncodingException;
 import org.apache.sis.storage.isobmff.UnsupportedVersionException;
 import org.apache.sis.storage.isobmff.VectorReader;
-import org.apache.sis.storage.isobmff.ByteReader;
+import org.apache.sis.storage.isobmff.ByteRanges;
 import org.apache.sis.storage.isobmff.TreeNode;
 import org.apache.sis.storage.isobmff.FullBox;
 import org.apache.sis.storage.isobmff.Reader;
@@ -89,7 +89,7 @@ public final class ItemLocation extends FullBox {
         }
         items = new Item[count];
         for (int i=0; i<count; i++) {
-            items[i] = new Item(input, version, sizes);
+            items[i] = new Item(reader, input, version, sizes);
         }
     }
 
@@ -98,7 +98,7 @@ public final class ItemLocation extends FullBox {
      * An extent is a continuous subset of the bytes of the resource. The full resource is formed by the
      * concatenation of the extents in the order specified in this {@code Item} object.
      */
-    public static final class Item extends TreeNode implements ByteReader {
+    public static final class Item extends TreeNode implements ByteRanges.Reader {
         /**
          * Identifier of this item, as an unsigned integer.
          */
@@ -180,13 +180,16 @@ public final class ItemLocation extends FullBox {
         /**
          * Decodes a single item.
          *
+         * @param  reader   the reader from which to read the payload.
          * @param  input    the input stream from which to read the item.
          * @param  version  version of the enclosing {@link ItemLocation}.
          * @param  sizes    sizes of (offset, length, base, index) in that order on 4 bits each.
          * @throws IOException if an error occurred while reading the payload.
          * @throws DataStoreContentException if the <abbr>HEIF</abbr> file is malformed.
          */
-        Item(final ChannelDataInput input, final int version, int sizes) throws IOException, DataStoreContentException {
+        Item(final Reader reader, final ChannelDataInput input, final int version, int sizes)
+                throws IOException, DataStoreContentException
+        {
             itemID = (version < 2) ? input.readUnsignedShort() : input.readInt();
             constructionMethod = (version == 0) ? 0 : (byte) (input.readShort() & 0x0F);
             dataReferenceIndex = input.readShort();
@@ -200,9 +203,18 @@ public final class ItemLocation extends FullBox {
                 if (offsetReader != null) offsetReader.read(input, i);
                 if (lengthReader != null) lengthReader.read(input, i);
             }
+            @SuppressWarnings("LocalVariableHidesMemberVariable")
+            Vector extentLength;
             itemReferenceIndex = result( indexReader);
             extentOffset       = result(offsetReader);
             extentLength       = result(lengthReader);
+            if (extentLength == null) {
+                final var endOfCurrentBox = reader.endOfCurrentBox();
+                if (endOfCurrentBox.isPresent()) {
+                    extentLength = Vector.create(new long[] {endOfCurrentBox.getAsLong() - baseOffset}, true);
+                }
+            }
+            this.extentLength = extentLength;
         }
 
         /**
@@ -224,57 +236,61 @@ public final class ItemLocation extends FullBox {
         }
 
         /**
-         * Converts an offset relative to the data of this item to an offset relative to the origin of the input stream.
-         * This method updates the {@link FileRegion#offset} value in-place. It may also replace {@link FileRegion#input}
-         * if the bytes to read are spread in different regions of the item.
+         * Converts an offset relative to the data of this item to offsets relative to the origin of the input stream.
          *
-         * @param  request  the input stream, offset and length of the region to read. Modified in-place by this method.
+         * @param  offset  offset of the first byte to read relative to the data stored in this item.
+         * @param  length  maximum number of bytes to read, starting at the offset, or a negative value for reading all.
+         * @param  addTo   where to add the range of bytes to read as offsets relatives to the beginning of the file.
          * @throws UnsupportedEncodingException if this item uses an unsupported construction method.
          * @throws DataStoreContentException if the <abbr>HEIF</abbr> file is malformed.
          * @throws ArithmeticException if an integer overflow occurred.
          */
         @Override
-        public void resolve(final FileRegion request) throws DataStoreException {
+        public void resolve(long offset, long length, ByteRanges addTo) throws DataStoreException {
             switch (constructionMethod) {
-                default: throw new DataStoreContentException("Unexpected construction method.");
+                default: {
+                    throw new DataStoreContentException("Unexpected construction method.");
+                }
                 case IDAT_OFFSET:
-                case ITEM_OFFSET: throw new UnsupportedEncodingException("Not supported yet");
+                case ITEM_OFFSET: {
+                    throw new UnsupportedEncodingException("Not supported yet");
+                }
                 case FILE_OFFSET: {
                     if (dataReferenceIndex != 0) {
                         throw new UnsupportedEncodingException("Not supported yet");
                     }
-                    long start = 0;
-                    if (extentLength != null) {
-                        final int n = extentLength.size();
-                        for (int i=0; i<n; i++) {
-                            long available = extentLength.longValue(i);
-                            if (request.offset >= available) {
-                                request.offset -= available;
-                                continue;           // Extent is before requested data.
-                            }
-                            available -= request.offset;
-                            if (request.length > available && i < n-1) {
-                                /*
-                                 * There is at least two extents to read. Create a channel
-                                 * which will read each extent as if they were consecutive.
-                                 * TODO: assign a temporary instance to `request.input`.
-                                 */
-                                throw new UnsupportedEncodingException("Not supported yet");
-                            }
-                            if (extentOffset != null && i < extentOffset.size()) {
-                                start = extentOffset.longValue(i);
-                            }
-                            if (request.length > available || request.length < 0) {
-                                request.length = available;
+                    final int offsetCount = (extentOffset != null) ? extentOffset.size() : 0;
+                    final int lengthCount = (extentLength != null) ? extentLength.size() : 0;
+                    final int n = Math.max(1, Math.min(offsetCount, lengthCount));
+                    for (int i=0; i<n; i++) {
+                        long available;
+                        if (i < lengthCount) {
+                            available = extentLength.longValue(i);
+                        } else if (length >= 0) {
+                            available = length;
+                        } else {
+                            throw new DataStoreException("Stream of unknown length.");
+                        }
+                        if (available < offset) {
+                            offset -= available;    // Make offset relative to the next extent.
+                            continue;               // Skip all extents before the first byte.
+                        }
+                        long position = Math.addExact(baseOffset, offset);
+                        if (i < offsetCount) {
+                            position = Math.addExact(position, extentOffset.longValue(i));
+                        }
+                        offset = 0;                 // Next extents will start from the first byte.
+                        if (length >= 0) {
+                            if (length < available) {
+                                available = length;
+                                length = 0;
+                            } else {
+                                length -= available;
                             }
-                            break;
                         }
+                        addTo.addRange(position, Math.addExact(position, available));
+                        if (length == 0) break;     // Short circuit if not reading everything.
                     }
-                    /*
-                     * All the bytes to read are in a single extent. We can
-                     * use the existing channel directly, without wrapping.
-                     */
-                    request.offset = Math.addExact(request.offset, Math.addExact(baseOffset, start));
                 }
             }
         }