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 4d1a8092ea9198c46ab80d3c91a9e6dfdb550517
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Sat Feb 14 23:48:14 2026 +0100

    Initial implementation of the `TileMatrixSet` interface.
    Tests are still superficial for now.
---
 .../sis/coverage/grid/GridCoverageBuilder.java     |   7 +-
 .../sis/image/internal/shared/ReshapedImage.java   |  25 ++
 .../apache/sis/storage/geotiff/GeoTiffStore.java   |   2 +-
 .../sis/storage/geotiff/ImageFileDirectory.java    |   5 +-
 .../org/apache/sis/storage/geotiff/Reader.java     |   4 +-
 .../apache/sis/storage/geotiff/package-info.java   |   2 +-
 .../org/apache/sis/storage/geotiff/ReaderTest.java | 125 ++++++
 .../org/apache/sis/storage/geotiff/WriterTest.java |   1 +
 .../org/apache/sis/storage/geotiff/untiled.tiff    | Bin 0 -> 2284 bytes
 .../sis/storage/netcdf/base/RasterResource.java    |   2 +
 .../apache/sis/storage/GridCoverageResource.java   |  10 +-
 .../apache/sis/storage/image/SingleImageStore.java |   2 +-
 .../org/apache/sis/storage/internal/Resources.java |   5 +
 .../sis/storage/internal/Resources.properties      |   1 +
 .../sis/storage/internal/Resources_fr.properties   |   1 +
 .../apache/sis/storage/tiling/ImagePyramid.java    | 437 ++++++++++++++++++
 .../apache/sis/storage/tiling/ImageTileMatrix.java | 492 +++++++++++++++++++++
 .../apache/sis/storage/tiling/IterationDomain.java | 220 +++++++++
 .../apache/sis/storage/tiling/TileMatrixSet.java   |  12 +-
 .../sis/storage/tiling/TiledGridCoverage.java      |   8 +-
 .../storage/tiling/TiledGridCoverageResource.java  | 177 +++++++-
 .../apache/sis/storage/tiling/TiledResource.java   |   4 +-
 .../main/org/apache/sis/io/TableAppender.java      |  16 +-
 .../main/org/apache/sis/io/package-info.java       |   2 +-
 .../main/org/apache/sis/math/DecimalFunctions.java |  27 +-
 .../sis/util/internal/shared/AbstractMap.java      |   2 +-
 .../org/apache/sis/math/DecimalFunctionsTest.java  |  32 +-
 .../org/apache/sis/storage/gdal/GDALStoreTest.java |   3 +-
 28 files changed, 1578 insertions(+), 46 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverageBuilder.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverageBuilder.java
index b99bb4ce9d..be3de3d5bd 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverageBuilder.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/coverage/grid/GridCoverageBuilder.java
@@ -76,9 +76,12 @@ import org.apache.sis.util.resources.Errors;
  *             }
  *         }
  *         var builder = new GridCoverageBuilder();
- *         builder.setValues(data).flixAxis(1);
+ *         builder.setValues(data).flipGridAxis(1);
  *
- *         Envelope domain = ...;                       // Specify here the 
"real world" coordinates.
+ *         // Real world coordinates, around Tokyo in this example.
+ *         var domain = new 
GeneralEnvelope(CommonCRS.WGS84.normalizedGeographic());
+ *         domain.setRange(1,  32,  40);    // Range of latitude values.
+ *         domain.setRange(0, 137, 140);    // Range of longitude values.
  *         return builder.setDomain(domain).build();
  *     }
  * }
diff --git 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java
 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java
index ee40136e8f..7db4d35660 100644
--- 
a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java
+++ 
b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/image/internal/shared/ReshapedImage.java
@@ -28,6 +28,7 @@ import static java.lang.Math.min;
 import static java.lang.Math.max;
 import static java.lang.Math.addExact;
 import static java.lang.Math.subtractExact;
+import static java.lang.Math.multiplyFull;
 import static java.lang.Math.floorDiv;
 import static java.lang.Math.toIntExact;
 import org.apache.sis.image.PlanarImage;
@@ -107,6 +108,30 @@ public final class ReshapedImage extends PlanarImage {
         this.minTileY = minTileY;
     }
 
+    /**
+     * Returns an image for a single tile.
+     * The indices of the unique tile are set to (0,0).
+     * The coordinate of the image upper-left corner is set to (0,0).
+     *
+     * @param  source  the image for which to wrap a single tile.
+     * @param  tileX   column index of the tile.
+     * @param  tileY   row index of the tile.
+     * @return the single tile. May be the given source or one of its sources.
+     * @throws ArithmeticException if image indices overflow 32-bits integer 
capacity.
+     */
+    public static RenderedImage singleTile(final RenderedImage source, final 
int tileX, final int tileY) {
+        final int tileWidth  = source.getTileWidth();
+        final int tileHeight = source.getTileHeight();
+        final var image = new ReshapedImage(source,
+                -(multiplyFull(tileX, tileWidth)  + 
source.getTileGridXOffset()),   // This negate cannot overflow.
+                -(multiplyFull(tileY, tileHeight) + 
source.getTileGridYOffset()),
+                0, 0,
+                tileWidth,
+                tileHeight,
+                0, 0);
+        return image.isIdentity() ? image.source : image;
+    }
+
     /**
      * Returns an image with the data of the given image translated by the 
given amount.
      * The number of tiles and their indexes are unchanged.
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoTiffStore.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoTiffStore.java
index f806703e7d..dffd88becf 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoTiffStore.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoTiffStore.java
@@ -82,7 +82,7 @@ import org.apache.sis.util.resources.Errors;
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Thi Phuong Hao Nguyen (VNSC)
  * @author  Alexis Manin (Geomatys)
- * @version 1.5
+ * @version 1.7
  * @since   0.8
  */
 public class GeoTiffStore extends DataStore implements Aggregate {
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/ImageFileDirectory.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/ImageFileDirectory.java
index 25570a7b9c..990a2d49be 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/ImageFileDirectory.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/ImageFileDirectory.java
@@ -1540,7 +1540,10 @@ final class ImageFileDirectory extends DataCube {
                     domain = new GridGeometry(new GridExtent(imageWidth, 
imageHeight), null, null);
                 }
                 final CoverageModifier.Source source = source();
-                gridGeometry = (source != null) ? 
reader.store.customizer.customize(source, domain) : domain;
+                if (source != null) {
+                    domain = reader.store.customizer.customize(source, domain);
+                }
+                gridGeometry = domain;
             }
             return domain;
         }
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Reader.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Reader.java
index 164cfb6c56..0eaeffe620 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Reader.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Reader.java
@@ -419,7 +419,7 @@ final class Reader extends IOBase {
             lastIFD = null;     // Clear now in case of error.
             imageIndex++;       // In case next image is full-resolution.
             ImageFileDirectory image;
-            final List<ImageFileDirectory> overviews = new ArrayList<>();
+            final var overviews = new ArrayList<ImageFileDirectory>();
             while ((image = readNextIFD(imageIndex)) != null) {
                 if (image.isReducedResolution()) {
                     overviews.add(image);
@@ -430,7 +430,7 @@ final class Reader extends IOBase {
             }
             /*
              * All pyramid levels have been read. If there is only one level,
-             * use the image directly. Otherwise create the pyramid.
+             * use the image directly. Otherwise, create the pyramid.
              */
             if (overviews.isEmpty()) {
                 images.add(fullResolution);
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/package-info.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/package-info.java
index 603b212edc..76accbbda4 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/package-info.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/package-info.java
@@ -33,7 +33,7 @@
  * @author  Thi Phuong Hao Nguyen (VNSC)
  * @author  Minh Chinh Vu (VNSC)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.6
+ * @version 1.7
  * @since   0.8
  */
 package org.apache.sis.storage.geotiff;
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/ReaderTest.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/ReaderTest.java
new file mode 100644
index 0000000000..360d928fa8
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/ReaderTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.geotiff;
+
+import java.util.SortedMap;
+import java.awt.image.Raster;
+import java.awt.image.RenderedImage;
+import org.opengis.util.GenericName;
+import org.opengis.referencing.crs.ProjectedCRS;
+import org.apache.sis.storage.StorageConnector;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.GridCoverageResource;
+import org.apache.sis.storage.NoSuchDataException;
+import org.apache.sis.storage.tiling.Tile;
+import org.apache.sis.storage.tiling.TileMatrix;
+import org.apache.sis.storage.tiling.TileMatrixSet;
+import org.apache.sis.storage.tiling.TileStatus;
+import org.apache.sis.storage.tiling.TiledResource;
+
+// Test dependencies
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.apache.sis.test.Assertions.assertSingleton;
+import static org.apache.sis.test.Assertions.assertMessageContains;
+import org.apache.sis.test.TestCase;
+
+
+/**
+ * Tests a few read operations.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ *
+ * @todo We should rewrite {@code "untiled.tiff"} as a tiled image.
+ */
+@SuppressWarnings("exports")
+public class ReaderTest extends TestCase {
+    /**
+     * Name of the test file.
+     */
+    private static final String FILENAME = "untiled.tiff";
+
+    /**
+     * Creates a new test case.
+     */
+    public ReaderTest() {
+    }
+
+    /**
+     * Creates a new data store for the test file.
+     *
+     * @return the data store to test.
+     * @throws DataStoreException if an error occurred while creating the data 
store.
+     */
+    private static GeoTiffStore createStore() throws DataStoreException {
+        return new GeoTiffStore(null, new 
StorageConnector(ReaderTest.class.getResource(FILENAME)));
+    }
+
+    /**
+     * Returns the single raster of the rendered image of the given tile.
+     */
+    private static Raster raster(final Tile tile) throws DataStoreException {
+        RenderedImage image = assertInstanceOf(GridCoverageResource.class, 
tile.getResource()).read(null, null).render(null);
+        assertEquals(1, image.getNumXTiles());
+        assertEquals(1, image.getNumYTiles());
+        return image.getTile(image.getMinTileX(), image.getMinTileY());
+    }
+
+    /**
+     * Tests the tile matrix set.
+     *
+     * @todo Need to be updated if we rewrite {@code "untiled.tiff"} as a more 
interesting image.
+     *
+     * @throws DataStoreException if an error occurred while creating the data 
store.
+     */
+    @Test
+    public void testTileMatrixSet() throws DataStoreException {
+        try (GeoTiffStore ds = createStore()) {
+            final GridCoverageResource resource = 
assertInstanceOf(GridCoverageResource.class, assertSingleton(ds.components()));
+            assertInstanceOf(ProjectedCRS.class, 
resource.getGridGeometry().getCoordinateReferenceSystem());
+
+            final TileMatrixSet pyramid = 
assertSingleton(assertInstanceOf(TiledResource.class, 
resource).getTileMatrixSets());
+            
assertSame(resource.getGridGeometry().getCoordinateReferenceSystem(), 
pyramid.getCoordinateReferenceSystem());
+            assertEquals("untiled:1:TMS", pyramid.getIdentifier().toString());
+            assertFalse(pyramid.getEnvelope().isEmpty());
+
+            final SortedMap<GenericName, ? extends TileMatrix> matrices = 
pyramid.getTileMatrices();
+            assertFalse(matrices.isEmpty());
+            assertEquals(1, matrices.size());
+            assertFalse(matrices.isEmpty());    // Test again because code 
path changed.
+            assertTrue(matrices.subMap(matrices.firstKey(), 
matrices.lastKey()).isEmpty());
+
+            final TileMatrix matrix = assertSingleton(matrices.values());
+            assertEquals("untiled:1:TMS:L0", 
matrix.getIdentifier().toString());
+            assertEquals(assertSingleton(matrices.keySet()), 
matrix.getIdentifier());
+            assertArrayEquals(resource.getGridGeometry().getResolution(false), 
matrix.getResolution());
+            assertSame(TileStatus.OUTSIDE_EXTENT, matrix.getTileStatus(1, 0));
+            assertSame(TileStatus.OUTSIDE_EXTENT, matrix.getTileStatus(0, 1));
+            assertSame(TileStatus.UNKNOWN,        matrix.getTileStatus(0, 0)); 
 // Because the tile has not yet been loaded.
+
+            assertMessageContains(assertThrows(NoSuchDataException.class, () 
-> matrix.getTile(1, 0)));
+            final Tile tile = matrix.getTile(0, 0).orElseThrow();
+            assertArrayEquals(new long[] {0, 0}, tile.getIndices());
+            assertEquals(TileStatus.EXISTS, tile.getStatus());
+            assertTrue(tile.getContentPath().isEmpty());
+
+            final Raster raster = raster(tile);
+            assertArrayEquals(tile.getIndices(), 
assertSingleton(matrix.getTiles(null, false).toList()).getIndices());
+            assertSame(raster, raster(assertSingleton(matrix.getTiles(null, 
false).toList())));
+        }
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java
index edb7c694d0..71f318e57c 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/WriterTest.java
@@ -61,6 +61,7 @@ import 
org.apache.sis.referencing.operation.HardCodedConversions;
  * @author  Erwan Roussel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  */
+@SuppressWarnings("exports")
 public final class WriterTest extends TestCase {
     /**
      * Arbitrary size (in pixels) of tiles in the image to test. The TIFF 
specification restricts those sizes
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/untiled.tiff
 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/untiled.tiff
new file mode 100644
index 0000000000..96cf0ae509
Binary files /dev/null and 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/untiled.tiff
 differ
diff --git 
a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/RasterResource.java
 
b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/RasterResource.java
index 3e02b53b18..ee90326291 100644
--- 
a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/RasterResource.java
+++ 
b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/RasterResource.java
@@ -775,6 +775,8 @@ public final class RasterResource extends 
AbstractGridCoverageResource implement
 
     /**
      * Returns a string representation of this resource for debugging purposes.
+     *
+     * @return a string representation of this resource for debugging purposes.
      */
     @Override
     public String toString() {
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/GridCoverageResource.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/GridCoverageResource.java
index 6c10e81d73..eec1a421cb 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/GridCoverageResource.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/GridCoverageResource.java
@@ -131,13 +131,13 @@ public interface GridCoverageResource extends DataSet {
     /**
      * Returns the preferred resolutions (in units of CRS axes) for read 
operations in this data store.
      * If the storage supports pyramid, then the list should contain the 
resolution at each pyramid level
-     * ordered from finest (smallest numbers) to coarsest (largest numbers) 
resolution.
-     * Otherwise the list contains a single element which is the {@linkplain 
#getGridGeometry() grid geometry}
+     * ordered from finest (smallest numerical values) to coarsest (largest 
numerical values) resolution.
+     * Otherwise, the list contains a single element which is the {@linkplain 
#getGridGeometry() grid geometry}
      * resolution, or an empty list if no resolution is applicable to the 
coverage (e.g. because non-constant).
      *
-     * <p>Each element shall be an array with a length equals to the number of 
CRS dimensions.
-     * In each array, value at index <var>i</var> is the cell size along CRS 
dimension <var>i</var>
-     * in units of the CRS axis <var>i</var>.</p>
+     * <p>Each element shall be an array with a length equals to the number of 
<abbr>CRS</abbr> dimensions.
+     * In each array, value at index <var>i</var> is the cell size along 
<abbr>CRS</abbr> dimension <var>i</var>
+     * in units of the <abbr>CRS</abbr> axis <var>i</var>.</p>
      *
      * <p>Note that arguments given to {@link #subset(CoverageQuery) 
subset(…)} or {@link #read read(…)} methods
      * are <em>not</em> constrained to the resolutions returned by this 
method. Those resolutions are only hints
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/SingleImageStore.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/SingleImageStore.java
index b27693f864..db400d1b36 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/SingleImageStore.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/SingleImageStore.java
@@ -34,7 +34,7 @@ import org.apache.sis.util.collection.BackingStoreException;
 /**
  * A world file store which is expected to contain exactly one image.
  * This class is used for image formats that are restricted to one image per 
file.
- * Examples: PNG and BMP image formats.
+ * Examples: <abbr>PNG</abbr> and <abbr>BMP</abbr> image formats.
  *
  * <p>See {@link WritableSingleImageStore} for the writable variant of this 
class.</p>
  *
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
index 15b79f539f..4f451dffab 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
@@ -453,6 +453,11 @@ public class Resources extends IndexedResourceBundle {
          */
         public static final short SubsetQuery_1 = 77;
 
+        /**
+         * Tile indexes out of bounds.
+         */
+        public static final short TileIndexesOutOfBounds = 87;
+
         /**
          * Cannot open {0} data store without “{1}” parameter.
          */
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
index 9c688036fb..8d78743ee0 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
@@ -98,6 +98,7 @@ StreamIsNotWritable_1             = Stream \u201c{0}\u201d is 
not writable.
 StreamIsReadOnce_1                = The \u201c{0}\u201d data store can be read 
only once.
 StreamIsWriteOnce_1               = Cannot modify previously written data in 
\u201c{0}\u201d.
 SubsetQuery_1                     = Query a subset of \u201c{0}\u201d.
+TileIndexesOutOfBounds            = Tile indexes out of bounds.
 UndefinedParameter_2              = Cannot open {0} data store without 
\u201c{1}\u201d parameter.
 UnexpectedNumberOfCoordinates_4   = The \u201c{0}\u201d feature at {1} has a 
{3} coordinate values, while we expected a multiple of {2}.
 UnexpectedSliceResolution_3       = Expected a resolution of {0} but got {1} 
for the slice or tile \u201c{2}\u201d.
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
index 8d63ff1001..187097a201 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
@@ -103,6 +103,7 @@ StreamIsNotWritable_1             = Le flux de donn\u00e9es 
\u00ab\u202f{0}\u202
 StreamIsReadOnce_1                = Les donn\u00e9es de 
\u00ab\u202f{0}\u202f\u00bb ne peuvent \u00eatre lues qu\u2019une seule fois.
 StreamIsWriteOnce_1               = Ne peut pas revenir sur les donn\u00e9es 
d\u00e9j\u00e0 \u00e9crites dans \u00ab\u202f{0}\u202f\u00bb.
 SubsetQuery_1                     = Requ\u00eate d\u2019un sous-ensemble de 
\u00ab\u202f{0}\u202f\u00bb.
+TileIndexesOutOfBounds            = Les indexes de la tuile sont en dehors des 
limites permises.
 UndefinedParameter_2              = Ne peut pas ouvrir une source de 
donn\u00e9es {0} sans le param\u00e8tre \u00ab\u202f{1}\u202f\u00bb.
 UnexpectedNumberOfCoordinates_4   = L\u2019entit\u00e9 nomm\u00e9e 
\u00ab\u202f{0}\u202f\u00bb \u00e0 {1} contient {3} coordonn\u00e9es, alors 
qu\u2019on attendait un multiple de {2}.
 UnexpectedSliceResolution_3       = Une r\u00e9solution de {0} \u00e9tait 
attendue mais une r\u00e9solution de {1} a \u00e9t\u00e9 trouv\u00e9e dans la 
tranche ou tuile \u00ab\u202f{2}\u202f\u00bb.
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImagePyramid.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImagePyramid.java
new file mode 100644
index 0000000000..cd1ca756c2
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImagePyramid.java
@@ -0,0 +1,437 @@
+/*
+ * 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.tiling;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.SortedMap;
+import org.opengis.util.LocalName;
+import org.opengis.util.GenericName;
+import org.opengis.geometry.Envelope;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.coverage.grid.GridCoverageProcessor;
+import org.apache.sis.coverage.grid.IncompleteGridGeometryException;
+import org.apache.sis.geometry.GeneralEnvelope;
+import org.apache.sis.geometry.ImmutableEnvelope;
+import org.apache.sis.measure.NumberRange;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.base.StoreUtilities;
+import org.apache.sis.util.collection.BackingStoreException;
+import org.apache.sis.util.iso.Names;
+import org.apache.sis.util.logging.Logging;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.internal.shared.AbstractMap;
+import org.apache.sis.util.internal.shared.Strings;
+
+
+/**
+ * Default implementation of {@code TileMatrixSet} as a wrapper for {@code 
GridCoverage} instances.
+ * This is a pyramid of two-dimensional slices represented as {@link 
java.awt.image.RenderedImage}s.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class ImagePyramid extends AbstractMap<GenericName, ImageTileMatrix>
+        implements TileMatrixSet, SortedMap<GenericName, ImageTileMatrix>
+{
+    /**
+     * An alphanumeric identifier unique in the {@code TiledResource} that 
contains this {@code TileMatrixSet}.
+     * A tiled resource may contain more than one tile matrix set if the 
resource provides different set of tiles
+     * for different <abbr>CRS</abbr>.
+     *
+     * @see #getIdentifier()
+     */
+    private final GenericName identifier;
+
+    /**
+     * The provider of pyramid levels.
+     */
+    private final TiledGridCoverageResource.Pyramid provider;
+
+    /**
+     * The tile matrices at each level, ordered from coarser resolution to 
most detailed resolution.
+     * This list is initially empty. Pyramid levels are created when first 
requested.
+     */
+    private final List<ImageTileMatrix> matrices;
+
+    /**
+     * First valid index value (inclusive) in {@link #matrices}.
+     */
+    private final int lowerMatrixIndex;
+
+    /**
+     * First index value which is known to be invalid.
+     * This is the lowest value for which {@link #getOrCreateLevel(int)} 
returned {@code null}.
+     * This value may be higher than the true number of levels if we didn't 
tested all values.
+     */
+    private int upperMatrixIndex;
+
+    /**
+     * The union of the envelopes of all tile matrix sets, or {@code null} if 
not yet computed.
+     *
+     * @see #getEnvelope()
+     */
+    private Envelope envelope;
+
+    /**
+     * The grid coverage processor to use when tiles use a subset of the bands.
+     *
+     * @see #createResourceView(long[], RenderedImage)
+     */
+    private final GridCoverageProcessor processor;
+
+    /**
+     * Creates a new tile matrix set.
+     *
+     * @param  parent     identifier of the {@code TiledResource} that 
contains this {@code TileMatrixSet}.
+     * @param  provider   information about the tile matrices to create, and 
provider of pyramid levels.
+     * @param  processor  the grid coverage processor to use when tiles use a 
subset of the bands.
+     */
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
+    ImagePyramid(final GenericName parent,
+                 final TiledGridCoverageResource.Pyramid provider,
+                 final GridCoverageProcessor processor)
+    {
+        this.provider  = provider;
+        this.processor = processor;
+        identifier = Names.createScopedName(parent, null, 
provider.identifier());
+        matrices = new ArrayList<>();
+        lowerMatrixIndex = 0;
+        upperMatrixIndex = Integer.MAX_VALUE;
+    }
+
+    /**
+     * Creates a new tile matrix set as a subset of the given one.
+     */
+    private ImagePyramid(final ImagePyramid parent, final int 
lowerMatrixIndex, final int upperMatrixIndex) {
+        this.identifier = parent.identifier;
+        this.provider   = parent.provider;
+        this.processor  = parent.processor;
+        this.matrices   = parent.matrices;
+        this.lowerMatrixIndex = lowerMatrixIndex;
+        this.upperMatrixIndex = upperMatrixIndex;
+    }
+
+    /**
+     * Returns the tile matrix at the given index.
+     * If that tile matrix was not already created, it is created now.
+     * This method shall be invoked with a synchronization lock on {@link 
#matrices}.
+     *
+     * @param  level  the level for which to get the tile matrix.
+     * @return tile matrix at the given level, or {@code null} if the given 
level is too high.
+     * @throws BackingStoreException if an error occurred while fetching 
information
+     */
+    private ImageTileMatrix getOrCreateLevel(final int level) {
+        if (level < 0 || level >= upperMatrixIndex) {
+            return null;
+        }
+        if (level < matrices.size()) {
+            final ImageTileMatrix tm = matrices.get(level);
+            if (tm != null) {
+                return tm;
+            }
+        }
+        final ImageTileMatrix tm;
+        try {
+            final TiledGridCoverageResource resource = 
provider.forPyramidLevel(level);
+            if (resource == null) {
+                upperMatrixIndex = level;
+                return null;
+            }
+            GenericName id = Names.createScopedName(identifier, null, 
provider.identifierOfLevel(level));
+            tm = new ImageTileMatrix(id, resource, processor);
+        } catch (DataStoreException | TransformException e) {
+            throw new BackingStoreException(e);
+        }
+        while (matrices.size() <= level) {
+            matrices.add(null);
+        }
+        matrices.set(level, tm);
+        return tm;
+    }
+
+    /**
+     * Returns the index of the pyramid name for the given identifier, or -1 
if none.
+     * This method shall be invoked with a synchronization lock on {@link 
#matrices}.
+     * The returned value is an index in the {@link #matrices} list.
+     *
+     * @param  name      identifier of the desired level.
+     * @param  required  whether to thrown an exception if the identifier is 
not recognized.
+     * @return index of the desired level, or -1 if none and {@code required} 
is {@code false}.
+     * @throws IllegalArgumentException if the given name is not recognized 
and {@code required} is {@code true}.
+     */
+    private int indexOf(final GenericName name, final boolean required) {
+        final LocalName tip = name.tip();
+        if (tip.scope().name().equals(identifier)) {
+            final int level;
+            try {
+                level = provider.levelOfIdentifier(tip.toString());
+            } catch (IllegalArgumentException e) {
+                if (required) throw e;
+                Logging.ignorableException(StoreUtilities.LOGGER, 
ImagePyramid.class, "indexOf", e);
+                return -1;
+            }
+            if (level >= lowerMatrixIndex && level < upperMatrixIndex) {
+                if (!required || getOrCreateLevel(level) != null) {
+                    return level;
+                }
+            }
+        }
+        if (required) {
+            throw new 
IllegalArgumentException(Errors.format(Errors.Keys.NoSuchValue_1, name));
+        }
+        return -1;
+    }
+
+    /**
+     * Compares two generic names for order based on the resolution of the 
associated tile matrix.
+     * If a call to {@code compare(o1, o2)}, the comparator returns a positive 
number of {@code o1}
+     * is the identifier of a tile matrix having a finer resolution than 
{@code o2}.
+     */
+    @Override
+    public Comparator<GenericName> comparator() {
+        return (GenericName o1, GenericName o2) -> indexOf(o1, false) - 
indexOf(o2, false);
+    }
+
+    /**
+     * Returns whether this Tile Matrix Set has no elements.
+     * Empty tile matrix sets should not be returned to users.
+     */
+    @Override
+    public boolean isEmpty() {
+        synchronized (matrices) {
+            if (upperMatrixIndex <= lowerMatrixIndex) {
+                return true;
+            }
+            if (!matrices.isEmpty()) {
+                return false;
+            }
+            return getOrCreateLevel(lowerMatrixIndex) == null;
+        }
+    }
+
+    /**
+     * Returns the number of elements in this tile matrix set.
+     *
+     * <b>Note:</b> if this implementation is modified, revisit {@link 
#lastKey()}.
+     */
+    @Override
+    public int size() {
+        synchronized (matrices) {
+            int level;
+            while ((level = matrices.size()) < upperMatrixIndex) {
+                getOrCreateLevel(level);    // Force fetching.
+            }
+            return upperMatrixIndex - lowerMatrixIndex;
+        }
+    }
+
+    /**
+     * Returns a unique (within {@link TiledResource}) identifier.
+     */
+    @Override
+    public GenericName getIdentifier() {
+        return identifier;
+    }
+
+    /**
+     * Returns the coordinate reference system of all {@code TileMatrix} 
instances in this set.
+     *
+     * @throws IncompleteGridGeometryException if the tile matrices have no 
<abbr>CRS</abbr>.
+     */
+    @Override
+    public CoordinateReferenceSystem getCoordinateReferenceSystem() {
+        final ImageTileMatrix tm;
+        synchronized (matrices) {
+            tm = getOrCreateLevel(0);   // Really 0, not `lowerMatrixIndex`.
+        }
+        return tm.getTilingScheme().getCoordinateReferenceSystem();
+    }
+
+    /**
+     * Returns an envelope that encompasses all {@code TileMatrix} instances 
in this set.
+     *
+     * @throws IncompleteGridGeometryException if a tiling scheme has no 
envelope. While not strictly mandatory,
+     *         for now we consider that missing extent or missing "grid to 
CRS" transform is probably an error.
+     */
+    @Override
+    public Optional<Envelope> getEnvelope() {
+        synchronized (matrices) {
+            if (envelope == null) {
+                int i = lowerMatrixIndex;
+                Envelope mayReuse = 
getOrCreateLevel(i).getTilingScheme().getEnvelope();
+                final var union = new GeneralEnvelope(mayReuse);
+                ImageTileMatrix tm;
+                while ((tm = getOrCreateLevel(++i)) != null) {
+                    final Envelope e = tm.getTilingScheme().getEnvelope();
+                    union.add(e);
+                    if (union.equals(e, 0, false)) {
+                        mayReuse = e;
+                    }
+                }
+                envelope = (union == null || union.equals(mayReuse, 0, false)) 
? mayReuse : new ImmutableEnvelope(union);
+            }
+            return Optional.of(envelope);
+        }
+    }
+
+    /**
+     * Returns all {@code TileMatrix} instances in this set, together with 
their identifiers.
+     * Entries are sorted from coarser resolution to most detailed resolution.
+     */
+    @Override
+    @SuppressWarnings("ReturnOfCollectionOrArrayField")
+    public SortedMap<GenericName, ? extends TileMatrix> getTileMatrices() {
+        return this;
+    }
+
+    /**
+     * Returns an iterator over the entries in this map.
+     */
+    @Override
+    protected EntryIterator<GenericName, ImageTileMatrix> entryIterator() {
+        return new EntryIterator<GenericName, ImageTileMatrix>() {
+            /** Index of the next element to return. */
+            private int index;
+
+            /** Next element to return. */
+            private ImageTileMatrix next;
+
+            @Override protected GenericName     getKey()   {return 
next.getIdentifier();}
+            @Override protected ImageTileMatrix getValue() {return next;}
+            @Override protected boolean next() {
+                synchronized (matrices) {
+                    return (next = getOrCreateLevel(index++)) != null;
+                }
+            }
+        };
+    }
+
+    /**
+     * Returns the tile matrix for the given identifier.
+     *
+     * @param  key  tile matrix identifier.
+     * @return tile matrix for the given identifier, or {@code null} if none.
+     */
+    @Override
+    public ImageTileMatrix get(final Object key) {
+        if (key instanceof GenericName) {
+            synchronized (matrices) {
+                return getOrCreateLevel(indexOf((GenericName) key, false));
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns the first key of the map.
+     */
+    @Override
+    public GenericName firstKey() {
+        final ImageTileMatrix tm;
+        synchronized (matrices) {
+            tm = getOrCreateLevel(lowerMatrixIndex);
+        }
+        if (tm != null) {
+            return tm.getIdentifier();
+        }
+        throw new NoSuchElementException();
+    }
+
+    /**
+     * Returns the last key of the map.
+     */
+    @Override
+    public GenericName lastKey() {
+        final ImageTileMatrix tm;
+        synchronized (matrices) {
+            final int size = size();     // Implementation of `size()` fills 
the list.
+            tm = (size != 0) ? matrices.get(size - 1) : null;
+        }
+        if (tm != null) {
+            return tm.getIdentifier();
+        }
+        throw new NoSuchElementException();
+    }
+
+    /**
+     * Returns a view of this map whose keys are strictly less than {@code 
toKey}.
+     *
+     * @param  toKey  high endpoint (exclusive) of the keys in the returned 
map.
+     */
+    @Override
+    public SortedMap<GenericName, ImageTileMatrix> headMap(GenericName toKey) {
+        return subMap(lowerMatrixIndex, indexOf(toKey, true));
+    }
+
+    /**
+     * Returns a view of this map whose keys are greater than or equal to 
{@code fromKey}.
+     *
+     * @param  fromKey  low endpoint (inclusive) of the keys in the returned 
map.
+     */
+    @Override
+    public SortedMap<GenericName, ImageTileMatrix> tailMap(GenericName 
fromKey) {
+        return subMap(indexOf(fromKey, true), upperMatrixIndex);
+    }
+
+    /**
+     * Returns a view this map whose keys range from {@code fromKey}, 
inclusive, to {@code toKey}, exclusive.
+     *
+     * @param  fromKey  low endpoint (inclusive) of the keys in the returned 
map.
+     * @param  toKey    high endpoint (exclusive) of the keys in the returned 
map.
+     */
+    @Override
+    public SortedMap<GenericName, ImageTileMatrix> subMap(GenericName fromKey, 
GenericName toKey) {
+        return subMap(indexOf(fromKey, true), indexOf(toKey, true));
+    }
+
+    /**
+     * Returns a view this map whose keys range from {@code fromKey}, 
inclusive, to {@code toKey}, exclusive.
+     *
+     * @param  fromKey  low endpoint (inclusive) of the keys in the returned 
map.
+     * @param  toKey    high endpoint (exclusive) of the keys in the returned 
map.
+     */
+    @SuppressWarnings("ReturnOfCollectionOrArrayField")
+    private SortedMap<GenericName, ImageTileMatrix> subMap(final int fromKey, 
final int toKey) {
+        if (fromKey >= toKey) {
+            return Collections.emptySortedMap();
+        }
+        if (fromKey == lowerMatrixIndex && toKey == upperMatrixIndex) {
+            return this;
+        }
+        return new ImagePyramid(this, fromKey, toKey);
+    }
+
+    /**
+     * Returns a string representation for debugging purposes.
+     *
+     * @return a string representation for debugging purposes.
+     */
+    @Override
+    public String toString() {
+        final Object upper;
+        synchronized (matrices) {
+            final Integer max = (upperMatrixIndex == Integer.MAX_VALUE) ? null 
: upperMatrixIndex;
+            upper = (matrices.size() >= upperMatrixIndex) ? max : new 
NumberRange<>(Integer.class, lowerMatrixIndex, true, max, true);
+        }
+        return Strings.toString(getClass(), null, identifier.toString(), 
"lowerMatrixIndex", lowerMatrixIndex, "upperMatrixIndex", upper);
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java
new file mode 100644
index 0000000000..51feda835d
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/ImageTileMatrix.java
@@ -0,0 +1,492 @@
+/*
+ * 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.tiling;
+
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+import java.awt.image.RenderedImage;
+import java.nio.file.Path;
+import org.opengis.util.GenericName;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.referencing.operation.matrix.Matrices;
+import org.apache.sis.referencing.operation.matrix.MatrixSIS;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.storage.MemoryGridCoverageResource;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.NoSuchDataException;
+import org.apache.sis.storage.UnsupportedQueryException;
+import org.apache.sis.storage.InternalDataStoreException;
+import org.apache.sis.storage.Resource;
+import org.apache.sis.storage.base.StoreUtilities;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.GridCoverage2D;
+import org.apache.sis.coverage.grid.GridCoverageProcessor;
+import org.apache.sis.image.internal.shared.ReshapedImage;
+import org.apache.sis.pending.jdk.JDK18;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.iso.Names;
+import org.apache.sis.util.logging.Logging;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.collection.BackingStoreException;
+import org.apache.sis.math.DecimalFunctions;
+import org.apache.sis.storage.internal.Resources;
+
+
+/**
+ * Default implementation of {@code TileMatrix} as a wrapper for a {@code 
GridCoverage}.
+ * The tile size must be specified at construction time and must be equal to 
the size of
+ * the tiles of the rendered image.
+ *
+ * <p>This class is needed only when the application needs details about the 
tiling scheme,
+ * for example in order to implement a Web Map Tile Service 
(<abbr>WMTS</abbr>).</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class ImageTileMatrix implements TileMatrix {
+    /**
+     * An alphanumeric identifier which is unique in the {@code TileMatrixSet} 
that contains this {@code TileMatrix}.
+     * The identifier contains the zoom level as a number encoded in 
<abbr>ASCII</abbr>.
+     */
+    private final GenericName identifier;
+
+    /**
+     * The resource for reading the tiles of this tile matrix.
+     */
+    private final TiledGridCoverageResource resource;
+
+    /**
+     * The coverage from which to get the rendered image which contains the 
tiles.
+     * This coverage uses deferred reading of tiles. Created when first 
requested.
+     *
+     * @see #coverage()
+     */
+    private TiledGridCoverage coverage;
+
+    /**
+     * Pattern for formatting tile indices using {@link java.util.Formatter}.
+     * The number of digits and the presence of a sign depend on the 
{@linkplain #tilingScheme tiling scheme}.
+     *
+     * @see #getTileIdentifier(long[])
+     */
+    private final String tileIndicesPattern;
+
+    /**
+     * Extent of valid tile indices and their relationship with "real world" 
coordinates.
+     * The (0, 0) tile indices should map to the tile in the upper-left 
corner. The last
+     * row and last column of tiles may contain partial tiles if the coverage 
size is not
+     * a divisor of the tile size.
+     */
+    private final GridGeometry tilingScheme;
+
+    /**
+     * Size of tiles, in number of {@linkplain #coverage} cells.
+     */
+    private final int[] tileSize;
+
+    /**
+     * Values to add to tile coordinates, after multiplication by {@link 
#tileSize}, for getting cell coordinates.
+     *
+     * @see #tileToCell(long, int)
+     */
+    private final long[] tileToCell;
+
+    /**
+     * Values to add to tile coordinates in {@link #image} for getting a tile 
coordinates in {@link #tilingScheme}.
+     * Those values are updated when a new image is rendered.
+     */
+    private long imageToTileX, imageToTileY;
+
+    /**
+     * The image containing the tiles of the tile matrix. Computed when first 
needed, and may be
+     * recomputed multiple times with different offsets if the tile indices 
are larger than the
+     * capacity of 32-bits integers.
+     */
+    private RenderedImage image;
+
+    /**
+     * The grid coverage processor to use when tiles use a subset of the bands.
+     *
+     * @see #createResourceView(long[], RenderedImage)
+     */
+    private final GridCoverageProcessor processor;
+
+    /**
+     * Creates a new tile matrix for the given coverage.
+     *
+     * @param  identifier  identifier unique in the {@code TileMatrixSet} that 
contains this {@code TileMatrix}.
+     * @param  resource    the resource for reading the tiles of this tile 
matrix.
+     * @param  processor   the grid coverage processor to use when tiles use a 
subset of the bands.
+     * @throws TransformException if the "tile indices to CRS" transform 
cannot be computed.
+     */
+    ImageTileMatrix(final GenericName identifier,
+                    final TiledGridCoverageResource resource,
+                    final GridCoverageProcessor processor)
+            throws DataStoreException, TransformException
+    {
+        this.identifier = identifier;
+        this.processor  = processor;
+        this.resource   = resource;
+        this.tileSize   = resource.getTileSize();
+        final GridGeometry cellGrid = resource.getGridGeometry();
+        final GridExtent extent     = cellGrid.getExtent();
+        final int        dimension  = extent.getDimension();
+        final long[]     tileCount  = new long[dimension];
+        final MatrixSIS  toCells    = Matrices.createIdentity(dimension + 1);
+        this.tileToCell = new long[dimension];
+        final var pattern = new StringBuilder(6 * dimension);
+        for (int i=0; i<dimension; i++) {
+            final long offset = extent.getLow(i);
+            final int  scale  = tileSize[i];
+            toCells.setNumber(i, i, scale);
+            toCells.setNumber(i, dimension, offset);
+            tileToCell[i] = offset;
+            tileCount [i] = JDK18.ceilDiv(extent.getSize(i), scale);
+            /*
+             * Prepare a pattern for formatting the tile indices.
+             * Indices are formatted with fixed number of digits,
+             * using the minimum number needed for the largest index.
+             */
+            if (i != 0) pattern.append(',');
+            
pattern.append("%0").append(DecimalFunctions.floorLog10(Math.max(tileCount[i] - 
1, 1)) + 1).append('d');
+        }
+        tilingScheme = new GridGeometry(cellGrid, extent.reshape(null, 
tileCount, false), MathTransforms.linear(toCells));
+        tileIndicesPattern = pattern.toString();
+    }
+
+    /**
+     * Returns an identifier which is unique in the {@code TileMatrixSet} that 
contains this {@code TileMatrix}.
+     * The identifier contains the zoom level as a number encoded in 
<abbr>ASCII</abbr>.
+     */
+    @Override
+    public GenericName getIdentifier() {
+        return identifier;
+    }
+
+    /**
+     * Returns the resolution (in units of CRS axes) at which tiles in this 
matrix should be used.
+     * The array length is the number of <abbr>CRS</abbr> dimensions, and 
value at index <var>i</var>
+     * is the resolution along CRS dimension <var>i</var> in units of the CRS 
axis <var>i</var>.
+     */
+    @Override
+    public double[] getResolution() {
+        try {
+            return resource.getGridGeometry().getResolution(false);
+        } catch (DataStoreException e) {
+            throw new BackingStoreException(e);
+        }
+    }
+
+    /**
+     * Returns a description about how space is partitioned into individual 
tiled units.
+     * The description contains the extent of valid tile indices, the spatial 
reference system,
+     * and the conversion from tile indices to the spatial reference system 
coordinates.
+     *
+     * @return extent of valid tile indices and their relationship with "real 
world" coordinates.
+     */
+    @Override
+    public GridGeometry getTilingScheme() {
+        return tilingScheme;
+    }
+
+    /**
+     * Returns the coverage, which is read when first needed.
+     * This coverage uses deferred reading of tiles.
+     *
+     * @return the coverage from which to get the rendered image which 
contains the tiles.
+     * @throws DataStoreException if an error occurred during the construction 
of the coverage.
+     */
+    private synchronized TiledGridCoverage coverage() throws 
DataStoreException {
+        if (coverage == null) {
+            coverage = resource.readAtGetTileTime();
+        }
+        return coverage;
+    }
+
+    /**
+     * Fetches information about whether a tile exists, is missing or failed 
to load.
+     *
+     * @param  indices  indices of the requested tile (may be outside the tile 
matrix extent).
+     * @return information about the availability of the specified tile.
+     * @throws DataStoreException if fetching the tile status failed.
+     */
+    @Override
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
+    public TileStatus getTileStatus(final long... indices) throws 
DataStoreException {
+        if (tilingScheme.getExtent().contains(indices)) try {
+            final TiledGridCoverage coverage;
+            final RenderedImage image;
+            final long imageToTileX;
+            final long imageToTileY;
+            synchronized (this) {
+                coverage     = this.coverage;   // Never null if `image` is 
non-null.
+                image        = this.image;
+                imageToTileX = this.imageToTileX;
+                imageToTileY = this.imageToTileY;
+            }
+            if (image != null) {
+                final long tileX = 
Math.subtractExact(indices[coverage.xDimension], imageToTileX);
+                final long x0 = image.getMinTileX();
+                if (tileX >= x0 && tileX < x0 + image.getNumXTiles()) {
+                    final long tileY = 
Math.subtractExact(indices[coverage.yDimension], imageToTileY);
+                    final long y0 = image.getMinTileY();
+                    if (tileY >= y0 && tileY < y0 + image.getNumYTiles()) {
+                        return getTileStatus(image, Math.toIntExact(tileX), 
Math.toIntExact(tileY));
+                    }
+                }
+            }
+            return TileStatus.UNKNOWN;
+        } catch (ArithmeticException e) {
+            Logging.ignorableException(StoreUtilities.LOGGER, 
ImageTileMatrix.class, "getTileStatus", e);
+        }
+        return TileStatus.OUTSIDE_EXTENT;
+    }
+
+    /**
+     * Returns the status of the tile at the given index.
+     *
+     * @param  image  image from which to get a tile status.
+     * @param  tileX  row index of the tile for which to get the status.
+     * @param  tileY  column index of the tile for which to get the status.
+     * @return status of the tile at the specified indexes.
+     *
+     * @todo We should check if the image is an instance of {@code 
ComputedImage},
+     *       then check if the tile is in the cache. But it would require that 
we merge
+     *       the feature and storage modules if we want to reuse {@link 
TileStatus} enumeration.
+     */
+    private static TileStatus getTileStatus(final RenderedImage image, final 
int tileX, final int tileY) {
+        return TileStatus.EXISTS;
+    }
+
+    /**
+     * Gets a tile at the given indices if not missing.
+     *
+     * @param  indices  indices of the tile to fetch, as coordinates inside 
the matrix extent.
+     * @return the tile if it exists, or an empty value if the tile is missing.
+     * @throws NoSuchDataException if the given indices are outside the matrix 
extent.
+     * @throws DataStoreException if fetching the tile failed for another 
reason.
+     */
+    @Override
+    public Optional<Tile> getTile(final long... indices) throws 
DataStoreException {
+        final GridExtent extent = tilingScheme.getExtent();
+        if (extent.contains(indices)) try {
+            final Tile tile = iterator(extent.reshape(indices, indices, 
true)).createFirstTile();
+            return (tile.getStatus() == TileStatus.MISSING) ? Optional.empty() 
: Optional.of(tile);
+        } catch (ArithmeticException e) {
+            throw new UnsupportedQueryException(e);
+        }
+        throw new 
NoSuchDataException(Resources.format(Resources.Keys.TileIndexesOutOfBounds));
+    }
+
+    /**
+     * Retrieves a stream of existing tiles in the specified region.
+     * The stream contains the existing tiles that are inside the given region 
and excludes missing tiles.
+     * If a tile is {@linkplain TileStatus#IN_ERROR in error}, then the stream 
nevertheless return a tile
+     * but its {@link Tile#getResource()} method should throw the exception.
+     *
+     * <h4>Limitations</h4>
+     * The current implementation limits the size of the given extent to 
{@link Integer#MAX_VALUE}
+     * in each dimension. Note that this is a maximum size in tile indices, 
not in pixel coordinates.
+     *
+     * @param  indiceRanges  ranges of tile indices in all dimensions, or 
{@code null} for all tiles.
+     * @param  parallel  {@code true} for a parallel stream (if supported), or 
{@code false} for a sequential stream.
+     * @return stream of tiles, excluding missing tiles.
+     * @throws DataStoreException if the tiles can not be fetched in the given 
ranges of tile indexes.
+     */
+    @Override
+    public Stream<Tile> getTiles(GridExtent indiceRanges, final boolean 
parallel) throws DataStoreException {
+        ArgumentChecks.ensureDimensionMatches("indiceRanges", 
tilingScheme.getDimension(), indiceRanges);
+        if (indiceRanges == null) {
+            indiceRanges = tilingScheme.getExtent();
+        }
+        try {
+            return StreamSupport.stream(iterator(indiceRanges).iterator(), 
parallel);
+        } catch (ArithmeticException e) {
+            throw new UnsupportedQueryException(e);
+        }
+    }
+
+    /**
+     * Creates an object which can be used for retrieving a single tile or a 
stream tiles.
+     *
+     * @param  indiceRanges  ranges of tile indices in all dimensions, or 
{@code null} for all tiles.
+     * @return a request which can be used for getting a tile or a stream of 
tiles in the given region.
+     * @throws DataStoreException if the tiles can not be fetched in the given 
ranges of tile indexes.
+     * @throws ArithmeticException if coordinate computation exceeds the 
capacity of 64-bits integers.
+     */
+    private synchronized IterationDomain<Tile> iterator(final GridExtent 
indiceRanges) throws DataStoreException {
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
+        final TiledGridCoverage coverage = coverage();
+        boolean retry = false;
+        do {    // This loop will be executed only 1 or 2 times.
+            if (image != null) {
+                final long xmin, ymin, xmax, ymax;
+                xmin = Math.subtractExact(indiceRanges.getLow 
(coverage.xDimension), imageToTileX);
+                xmax = 
Math.subtractExact(indiceRanges.getHigh(coverage.xDimension), imageToTileX);
+                final long x0 = image.getMinTileX();
+                if (xmin >= x0 && xmax < x0 + image.getNumXTiles()) {
+                    ymin = Math.subtractExact(indiceRanges.getLow 
(coverage.yDimension), imageToTileY);
+                    ymax = 
Math.subtractExact(indiceRanges.getHigh(coverage.yDimension), imageToTileY);
+                    final long y0 = image.getMinTileY();
+                    if (ymin >= y0 && ymax < y0 + image.getNumYTiles()) {
+                        return new Iterator(Math.toIntExact(xmin),
+                                            Math.toIntExact(ymin),
+                                            Math.toIntExact(xmax),
+                                            Math.toIntExact(ymax));
+                    }
+                }
+            }
+            /*
+             * Gets the bounds of the image to read. If deferred reading is 
supported,
+             * we can expand to the bounds of the whole coverage in order to 
perform a
+             * read operation (deferred) only once.
+             */
+            final GridExtent extent = coverage.getGridGeometry().getExtent();
+            final int dimension = extent.getDimension();
+            final var low  = new long[dimension];
+            final var high = new long[dimension];
+            for (int i=0; i<dimension; i++) {
+                final long limit = Math.incrementExact(extent.getHigh(i));
+                high[i] = Math.min(limit, 
tileToCell(Math.incrementExact(indiceRanges.getHigh(i)), i));
+                low [i] = Math.max(extent.getLow(i), 
tileToCell(indiceRanges.getLow(i), i));
+                final long span = high[i] - low[i];
+                if (span < 0 || span > Integer.MAX_VALUE) {
+                    throw new 
ArithmeticException(Errors.format(Errors.Keys.IntegerOverflow_1, Integer.SIZE));
+                }
+                if (coverage.deferredTileReading) {
+                    final long remain = Math.min(extent.getSize(i), 
Integer.MAX_VALUE) - span;
+                    final long after  = Math.min(remain >> 1, limit - high[i]);
+                    final long before = Math.min(remain - after, low[i] - 
extent.getLow(i));
+                    low [i] -= before;
+                    high[i] += after;
+                }
+            }
+            image = coverage.render(extent.reshape(low, high, false));
+            imageToTileX = low[coverage.xDimension];
+            imageToTileY = low[coverage.yDimension];
+        } while ((retry = !retry) == true);
+        throw new InternalDataStoreException();     // Should never happen.
+    }
+
+    /**
+     * Converts the give tile coordinate in the given dimension to cell 
coordinates.
+     *
+     * @param  coordinate  the tile coordinate to convert.
+     * @param  dimension   the dimension of the coordinate to convert.
+     * @return the cell coordinate.
+     * @throws ArithmeticException if the result overflows the capacity of 
64-bits integers.
+     */
+    private long tileToCell(final long coordinate, final int dimension) {
+        return Math.addExact(tileToCell[dimension], 
Math.multiplyExact(coordinate, tileSize[dimension]));
+    }
+
+    /**
+     * Factory for an iterator over tiles in ranges of user-specified tile 
indices.
+     */
+    private final class Iterator extends IterationDomain<Tile> {
+        /**
+         * Snapshot of {@link ImageTileMatrix#image}.
+         */
+        private final RenderedImage tiles;
+
+        /**
+         * Snapshot of {@link ImageTileMatrix#imageToTileX} and {@link 
ImageTileMatrix#imageToTileY}.
+         */
+        private final long offsetX, offsetY;
+
+        /**
+         * Creates a new request for tile iterators.
+         *
+         * @param xmin  first column index of tiles, inclusive.
+         * @param xmin  first row index of tiles, inclusive.
+         * @param xmax  last column index of tiles, inclusive.
+         * @param ymax  last row index of tiles, inclusive.
+         */
+        Iterator(final int xmin, final int ymin, final int xmax, final int 
ymax) {
+            super(xmin, ymin, xmax, ymax);
+            tiles   = image;
+            offsetX = imageToTileX;
+            offsetY = imageToTileY;
+        }
+
+        /**
+         * Creates the tile at the given indexes.
+         */
+        @Override
+        protected Tile createTile(final int tileX, final int tileY) {
+            return new Tile() {
+                /** This tile viewed as a resource, created when first 
requested. */
+                private Resource resourceView;
+
+                /** Returns the path to content of the tile if known. */
+                @Override public Optional<Path> getContentPath() throws 
DataStoreException {
+                    return 
Optional.ofNullable(coverage().getContentPath(getIndices()));
+                }
+
+                /** Returns the indices of this tile in the {@code 
TileMatrix}. */
+                @Override public long[] getIndices() {
+                    return new long[] {offsetX + tileX, offsetY + tileY};
+                }
+
+                /** Returns information about whether the tile failed to load. 
*/
+                @Override public TileStatus getStatus() {
+                    return getTileStatus(tiles, tileX, tileY);
+                }
+
+                /** Returns the tile content as a resource. */
+                @Override public synchronized Resource getResource() throws 
DataStoreException {
+                    if (resourceView == null) {
+                        resourceView = createResourceView(getIndices(), 
ReshapedImage.singleTile(tiles, tileX, tileY));
+                    }
+                    return resourceView;
+                }
+            };
+        }
+    }
+
+    /**
+     * Creates a resource for the tile at the given indices.
+     * The resource wraps a grid coverage, which is itself wrapping the given 
image.
+     * The given image should contains only the desired tile. The caller 
currently sets
+     * the tile indexes and image coordinates to (0,0), but this is not 
mandatory.
+     *
+     * @param  indices  indices of the tile, as coordinates inside the matrix 
extent.
+     * @param  tile     a rendered image which contains only the tile.
+     * @return resource for the specified tile.
+     */
+    private Resource createResourceView(final long[] indices, final 
RenderedImage tile) throws DataStoreException {
+        final Object[] args = new Object[indices.length];
+        Arrays.setAll(args, (i) -> indices[i]);
+        final GenericName id = Names.createScopedName(identifier, null, 
String.format(tileIndicesPattern, args));
+        final long[] low  = new long[indices.length];
+        final long[] high = new long[indices.length];
+        for (int i=0; i<indices.length; i++) {
+            final long size = tileSize[i];
+            low [i] = Math.addExact(tileToCell[i], 
Math.multiplyExact(indices[i], size));
+            high[i] = Math.addExact(low[i], size - 1);
+        }
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
+        final TiledGridCoverage coverage = coverage();
+        GridGeometry cellGrid = coverage.getGridGeometry();
+        final GridExtent extent = cellGrid.getExtent().reshape(low, high, 
true);
+        cellGrid = cellGrid.derive().subgrid(extent, null).build();
+        final var subset = new GridCoverage2D(cellGrid, 
coverage.getSampleDimensions(), tile);
+        return new MemoryGridCoverageResource(resource, id, subset, processor);
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/IterationDomain.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/IterationDomain.java
new file mode 100644
index 0000000000..c4a0aa39e4
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/IterationDomain.java
@@ -0,0 +1,220 @@
+/*
+ * 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.tiling;
+
+import java.util.Spliterator;
+import java.util.function.Consumer;
+import java.awt.image.RenderedImage;
+
+
+/**
+ * A request for tiles from a rendered image.
+ * Contrarily to {@link TiledGridCoverage.AOI}, this request works with 
arbitrary {@link RenderedImage} and does
+ * not manage a tile cache. Tile caching is assumed to be managed by the 
{@code RenderedImage} implementation.
+ * This class is designed for use with {@link Spliterator} with parallelism.
+ *
+ * <h2>Iteration order</h2>
+ * Iteration order is unspecified. Current implementation iterates from left 
to right, then top to bottom.
+ * But future implementations may support Hilbert order. If the iteration is 
split, the iteration order is
+ * interleaved: for example with two threads, the first tile is given to the 
first thread, the second tile
+ * is given to the second thread, the third tile is given to the first thread, 
<i>etc.</i> The intend is to
+ * stay relatively close to a reading of tiles in sequential order when all 
threads are executed in parallel.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ *
+ * @param  <T>  type of tiles on which the {@link Spliterator} will iterate.
+ */
+abstract class IterationDomain<T> {
+    /**
+     * Minimal column index of the region on which to iterate.
+     */
+    private final int xmin;
+
+    /**
+     * Minimal row index of the region on which to iterate.
+     */
+    private final int ymin;
+
+    /**
+     * Number of columns on which to iterate.
+     * This is {@code (xmax + 1) - xmin} where {@code xmax} is inclusive.
+     */
+    private final long width;
+
+    /**
+     * Maximum value (exclusive) of the indexes of the tiles on which to 
iterate.
+     * Index is computed using the row-major convention.
+     */
+    private final long limit;
+
+    /**
+     * Creates a new request for tile iterators.
+     *
+     * @param xmin  first column index of tiles, inclusive.
+     * @param xmin  first row index of tiles, inclusive.
+     * @param xmax  last column index of tiles, inclusive.
+     * @param ymax  last row index of tiles, inclusive.
+     */
+    protected IterationDomain(final int xmin, final int ymin, final int xmax, 
final int ymax) {
+        this.xmin  = xmin;
+        this.ymin  = ymin;
+        this.width = (xmax + 1L) - xmin;
+        this.limit = Math.multiplyExact(width, (ymax + 1L) - ymin);
+    }
+
+    /**
+     * Creates a new item for the tile at the given indexes. If this method 
returns {@code null},
+     * then the tile is assumed missing and this iterator searches for the 
next tile.
+     *
+     * @param  tileX  column index of the tile.
+     * @param  tileY  row index of the tile.
+     * @return item for the tile at the given indexes, or {@code null} if 
missing.
+     */
+    protected abstract T createTile(int tileX, int tileY);
+
+    /**
+     * Creates the first tile, or returns {@code null} if the tile is missing.
+     */
+    final T createFirstTile() {
+        return createTile(xmin, ymin);
+    }
+
+    /**
+     * Returns a new iterator over the tiles.
+     *
+     * @return a new iterator.
+     */
+    public final Spliterator<T> iterator() {
+        return new Iterator();
+    }
+
+    /**
+     * Iterator over the tiles.
+     */
+    private final class Iterator implements Spliterator<T> {
+        /**
+         * Row-major index of the next tile to return.
+         */
+        private long index;
+
+        /**
+         * Increment to apply on {@link #index} between each iteration.
+         */
+        private int increment;
+
+        /**
+         * Creates a new iterator which will initially traverse all tiles.
+         */
+        Iterator() {
+            index = Math.multiplyExact(width, ymin);
+            increment = 1;
+        }
+
+        /**
+         * Creates a new iterator which will iterate over half of the tiles 
covered by the given iterator.
+         * This constructor modifies the supplied iterator for covering the 
other half.
+         *
+         * @param  parent  the iterator to split. It will be modified by this 
method call.
+         */
+        private Iterator(final Iterator parent) {
+            index = parent.index;
+            parent.index += parent.increment;
+            increment = parent.increment *= 2;
+        }
+
+        /**
+         * Returns, if possible, an iterator covering half of the tiles 
covered by this iterator.
+         * On return, this iterator will cover the other half.
+         *
+         * @return an iterator covering half of the tiles, or {@code null} if 
this iterator cannot be partitioned.
+         */
+        @Override
+        public Spliterator<T> trySplit() {
+            return (limit - index) > increment ? new Iterator(this) : null;
+        }
+
+        /**
+         * Returns the number of remaining tiles in the iteration.
+         */
+        @Override
+        public long estimateSize() {
+            return (limit - index) / increment;
+        }
+
+        /**
+         * In the context of this iterator, this is synonymous of {@link 
#estimateSize()}.
+         */
+        @Override
+        public long getExactSizeIfKnown() {
+            return estimateSize();
+        }
+
+        /**
+         * Returns the characteristics of the iteration. The number of tiles 
is known in advance ({@link #SIZED}),
+         * null values are not allowed ({@link #NONNULL}), there will be no 
tiles at same indexes ({@link #DISTINCT}),
+         * and the tile matrix will not change ({@link #IMMUTABLE}). This 
iterator makes no guaranteed about iteration
+         * order (i.e., not {@link #ORDERED}).
+         *
+         * <p>Note that {@link #IMMUTABLE} is not a promise that the pixel 
values will not change,
+         * as the image may be writable. But this iterator is concerned only 
about the set of tiles,
+         * not the content of those tiles.</p>
+         */
+        @Override
+        public int characteristics() {
+            return SIZED | SUBSIZED | NONNULL | DISTINCT | IMMUTABLE;
+        }
+
+        /**
+         * If a remaining tile exists, performs the given action on it.
+         *
+         * @param  action  the action to execute on the next tile.
+         * @return whether the action has been performed.
+         */
+        @Override
+        public boolean tryAdvance(final Consumer<? super T> action) {
+            while (index < limit) {
+                final int tileY = Math.toIntExact(index / width);
+                final int tileX = Math.toIntExact(index % width + xmin);
+                final T   tile  = createTile(tileX, tileY);
+                index += increment;
+                if (tile != null) {
+                    action.accept(tile);
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * Performs the given action on all remaining tiles.
+         *
+         * @param  action  the action to execute on all remaining tiles.
+         */
+        @Override
+        public void forEachRemaining(final Consumer<? super T> action) {
+            while (index < limit) {
+                final int tileY = Math.toIntExact(index / width);
+                final int tileX = Math.toIntExact(index % width + xmin);
+                final T   tile  = createTile(tileX, tileY);
+                index += increment;
+                if (tile != null) {
+                    action.accept(tile);
+                }
+            }
+        }
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileMatrixSet.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileMatrixSet.java
index 4799f8c0f0..7699b7b470 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileMatrixSet.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TileMatrixSet.java
@@ -26,13 +26,13 @@ import org.apache.sis.storage.base.MetadataBuilder;
 
 
 /**
- * A collection of {@code TileMatrix} in the same CRS but at different scale 
levels.
+ * A collection of {@code TileMatrix} in the same <abbr>CRS</abbr> but at 
different scale levels.
  * Each {@code TileMatrix} is optimized for a particular scale and is 
identified by a tile matrix identifier.
  * Tile matrices usually have 2 dimensions (width and height), but this API 
allows any number of dimensions.
  * However, the number of dimensions must be the same for all tile matrices.
  *
- * <p>The {@code TileMatrixSet} concept is derived from OGC standards. The 
same concept is called
- * <i>image pyramid</i> or <i>resolution levels</i> in some other standards.
+ * <p>The {@code TileMatrixSet} concept is derived from <abbr>OGC</abbr> 
standards.
+ * The same concept is called <i>image pyramid</i> or <i>resolution levels</i> 
in some other standards.
  * Some standards require that all scales must be related by a power of 2,
  * but {@code TileMatrixSet} does not have this restriction.</p>
  *
@@ -110,7 +110,7 @@ public interface TileMatrixSet {
      * Returns the coordinate reference system of all {@code TileMatrix} 
instances in this set.
      * This is the value returned by {@code 
TileMatrix.getTilingScheme().getCoordinateReferenceSystem()}.
      *
-     * @return the CRS used by all {@code TileMatrix} instances in this set.
+     * @return the <abbr>CRS</abbr> used by all {@code TileMatrix} instances 
in this set.
      *
      * @see TileMatrix#getTilingScheme()
      */
@@ -122,12 +122,12 @@ public interface TileMatrixSet {
      * of all values returned by {@code 
TileMatrix.getTilingScheme().getEnvelope()}.
      * May be empty if too costly to compute.
      *
-     * @return the bounding box for all tile matrices in CRS coordinates, if 
available.
+     * @return the bounding box for all tile matrices in <abbr>CRS</abbr> 
coordinates, if available.
      */
     Optional<Envelope> getEnvelope();
 
     /**
-     * Returns all {@link TileMatrix} instances in this set, together with 
their identifiers.
+     * Returns all {@code TileMatrix} instances in this set, together with 
their identifiers.
      * For each value in the map, the associated key is {@link 
TileMatrix#getIdentifier()}.
      * Entries are sorted from coarser resolution (highest scale denominator) 
to most detailed
      * resolution (lowest scale denominator).
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverage.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverage.java
index 68a989962a..151beac5fe 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverage.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverage.java
@@ -208,7 +208,8 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
     /**
      * The dimension of the grid which is mapped to the <var>x</var> axis 
(column indexes) in rendered images.
      * This is the value of the {@code xDimension} argument in the last call 
to the
-     * {@link TiledGridCoverageResource#setRasterDimension(int, int)} method 
before this coverage has been constructed.
+     * {@link TiledGridCoverageResource#setRasterSubspaceDimensions(int, int)} 
method
+     * before this coverage has been constructed.
      * This value is usually 0.
      *
      * @see #readTiles(TileIterator)
@@ -218,7 +219,8 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
     /**
      * The dimension of the grid which is mapped to the <var>y</var> axis (row 
indexes) in rendered images.
      * This is the value of the {@code yDimension} argument in the last call 
to the
-     * {@link TiledGridCoverageResource#setRasterDimension(int, int)} method 
before this coverage has been constructed.
+     * {@link TiledGridCoverageResource#setRasterSubspaceDimensions(int, int)} 
method
+     * before this coverage has been constructed.
      * This value is usually 1.
      *
      * @see #readTiles(TileIterator)
@@ -266,7 +268,7 @@ public abstract class TiledGridCoverage extends 
GridCoverage {
      * This is true if the user explicitly {@linkplain 
TiledGridCoverageResource#setLoadingStrategy requested
      * such deferred loading strategy} and the resource considers that it is 
worth to do so.
      */
-    private final boolean deferredTileReading;
+    final boolean deferredTileReading;
 
     /**
      * Creates a new tiled grid coverage. This constructor does not load any 
tile.
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverageResource.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverageResource.java
index 3507103dac..9ace524095 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverageResource.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledGridCoverageResource.java
@@ -19,6 +19,7 @@ package org.apache.sis.storage.tiling;
 import java.util.List;
 import java.util.Arrays;
 import java.util.Objects;
+import java.util.Collection;
 import java.lang.reflect.Array;
 import java.awt.image.DataBuffer;
 import java.awt.image.ColorModel;
@@ -29,6 +30,8 @@ import java.awt.image.IndexColorModel;
 import java.awt.image.Raster;
 import java.awt.image.RenderedImage;
 import java.awt.image.RasterFormatException;
+import org.opengis.util.GenericName;
+import org.opengis.util.NameFactory;
 import org.apache.sis.coverage.SampleDimension;
 import org.apache.sis.coverage.grid.GridCoverage;
 import org.apache.sis.coverage.grid.GridCoverage2D;
@@ -52,6 +55,7 @@ import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.internal.shared.Numerics;
 import org.apache.sis.util.collection.WeakValueHashMap;
+import org.apache.sis.util.iso.DefaultNameFactory;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
 import org.opengis.coverage.CannotEvaluateException;
@@ -76,7 +80,7 @@ import org.opengis.coverage.CannotEvaluateException;
  * @version 1.7
  * @since   1.7
  */
-public abstract class TiledGridCoverageResource extends 
AbstractGridCoverageResource {
+public abstract class TiledGridCoverageResource extends 
AbstractGridCoverageResource implements TiledResource {
     /**
      * Number of dimensions in a rendered image.
      * Used for identifying codes where a two-dimensional slice is assumed.
@@ -147,6 +151,13 @@ public abstract class TiledGridCoverageResource extends 
AbstractGridCoverageReso
      */
     private RasterLoadingStrategy loadingStrategy;
 
+    /**
+     * The tile matrix sets, created when first requested.
+     *
+     * @see #getTileMatrixSets()
+     */
+    private Collection<TileMatrixSet> tileMatrixSets;
+
     /**
      * The dimension of the grid which is mapped to the <var>x</var> axis 
(column indexes) in rendered images.
      * The default value is 0.
@@ -159,11 +170,6 @@ public abstract class TiledGridCoverageResource extends 
AbstractGridCoverageReso
      */
     private int yDimension;
 
-    /**
-     * The grid coverage processor to use when tiles use a subset of the bands.
-     */
-    final GridCoverageProcessor processor;
-
     /**
      * Creates a new resource.
      *
@@ -172,7 +178,6 @@ public abstract class TiledGridCoverageResource extends 
AbstractGridCoverageReso
     protected TiledGridCoverageResource(final Resource parent) {
         super(parent);
         yDimension = 1;
-        processor = new GridCoverageProcessor();
     }
 
     /**
@@ -191,8 +196,9 @@ public abstract class TiledGridCoverageResource extends 
AbstractGridCoverageReso
      *
      * @see TiledGridCoverage#xDimension
      * @see TiledGridCoverage#yDimension
+     * @see GridExtent#getSubspaceDimensions(int)
      */
-    protected void setRasterDimension(final int xDimension, final int 
yDimension) throws DataStoreException {
+    protected void setRasterSubspaceDimensions(final int xDimension, final int 
yDimension) throws DataStoreException {
         final int max = getGridGeometry().getDimension() - 1;
         ArgumentChecks.ensureBetween("xDimension", 0, max, xDimension);
         ArgumentChecks.ensureBetween("yDimension", 0, max, yDimension);
@@ -827,6 +833,26 @@ check:  if (dataType.isInteger()) {
         return loaded;
     }
 
+    /**
+     * Returns a coverage which will read the tiles as late as possible.
+     *
+     * @return the coverage.
+     * @throws DataStoreException if an error occurred while reading the grid 
coverage data.
+     */
+    final TiledGridCoverage readAtGetTileTime() throws DataStoreException {
+        synchronized (getSynchronizationLock()) {
+            final RasterLoadingStrategy old = loadingStrategy;
+            try {
+                loadingStrategy = RasterLoadingStrategy.AT_GET_TILE_TIME;
+                return read(new Subset(null, null));
+            } catch (RuntimeException e) {
+                throw canNotRead(listeners.getSourceName(), null, e);
+            } finally {
+                loadingStrategy = old;
+            }
+        }
+    }
+
     /**
      * Whether this resource supports immediate loading of raster data.
      * Current implementation does not support immediate loading if the data 
cube has more than 2 dimensions.
@@ -882,4 +908,139 @@ check:  if (dataType.isInteger()) {
         loadingStrategy = loadAtReadTime ? RasterLoadingStrategy.AT_READ_TIME
                                          : 
RasterLoadingStrategy.AT_RENDER_TIME;
     }
+
+    /**
+     * Returns the collection of all available tile matrix sets in this 
resource.
+     * The returned collection typically contains exactly one instance.
+     *
+     * <p>The default implementation uses the information provided by {@link 
#getPyramids()}
+     * for creating default {@link TileMatrixSet} instances.
+     * It is generally easier for subclasses to override {@link 
#getPyramids()} instead of this method.</p>
+     *
+     * @return all available {@link TileMatrixSet} instances, or an empty 
collection if none.
+     * @throws DataStoreException if an error occurred while fetching the tile 
matrix sets.
+     */
+    @Override
+    @SuppressWarnings("ReturnOfCollectionOrArrayField")     // The collection 
is unmodifiable.
+    public Collection<? extends TileMatrixSet> getTileMatrixSets() throws 
DataStoreException {
+        synchronized (getSynchronizationLock()) {
+            if (tileMatrixSets == null) {
+                final List<Pyramid> pyramids = getPyramids();
+                final var sets = new TileMatrixSet[pyramids.size()];
+                final GenericName scope = getIdentifier().orElseGet(
+                            () -> 
pyramids.get(0).nameFactory().createLocalName(null, listeners.getSourceName()));
+                final var processor = new GridCoverageProcessor();
+                for (int i=0; i<sets.length; i++) {
+                    sets[i] = new ImagePyramid(scope, pyramids.get(i), 
processor);
+                }
+                tileMatrixSets = List.of(sets);
+            }
+            return tileMatrixSets;
+        }
+    }
+
+    /**
+     * Returns information about the {@code TileMatrixSet} instances to create.
+     * This method is invoked by the default implementation of {@link 
#getTileMatrixSets()} when first needed.
+     * By default, this method returns a list of only one element, which 
itself describes a pyramid of only one level.
+     * This single level describes a {@link TileMatrix} at the resolution of 
this {@code TiledGridCoverageResource}.
+     *
+     * @return information about the tile matrix sets to create.
+     */
+    protected List<Pyramid> getPyramids() {
+        return List.of((level) -> (level == 0) ? this : null);
+    }
+
+    /**
+     * Description of a {@code TileMatrixSet} implemented as an image pyramid.
+     * This interface is used by the default implementation of {@link 
#getTileMatrixSets()}.
+     * There is usually only one pyramid per {@link TiledGridCoverageResource} 
instance,
+     * but many pyramids may exist, for example, if data are offered in 
different
+     * Coordinate Reference System (<abbr>CRS</abbr>).
+     *
+     * <p>Each pyramid can have an arbitrary number of levels.
+     * It is recommended to have one pyramid level for each {@linkplain 
#getResolutions() preferred resolutions}.
+     * The pyramid levels must be sorted from finest resolution (at level 0) 
to coarsest resolution.</p>
+     *
+     * <p>The number of levels is unspecified because some data stores cannot 
provide this information in advance.
+     * Instead, the {@link #forPyramidLevel(int)} method will be invoked with 
different argument values when each
+     * level is first requested, until that method returns {@code null} for a 
level too high.</p>
+     *
+     * @author  Martin Desruisseaux (Geomatys)
+     * @version 1.7
+     * @since   1.7
+     */
+    protected static interface Pyramid {
+        /**
+         * Returns an identifier for this pyramid. The default implementation 
returns <abbr>TMS</abbr>
+         * as the abbreviation of "Tile Matrix Set". This is often sufficient 
in the common case where
+         * there is only one Tile Matrix Set per Grid Coverage Resource.
+         *
+         * <p>This value is used for building the value of {@link 
TileMatrixSet#getIdentifier()}.</p>
+         *
+         * @return an identifier for this pyramid. Default is {@code "TMS"}.
+         *
+         * @see TileMatrixSet#getIdentifier()
+         */
+        default String identifier() {
+            return "TMS";
+        }
+
+        /**
+         * Returns an identifier for the given level of this pyramid. The 
returned identifier
+         * will be local in the namespace of the pyramid {@linkplain 
#identifier() identifier}.
+         *
+         * @param  level  the pyramid level where 0 is the level with the 
finest resolution.
+         * @return a local identifier for the specified level.
+         */
+        default String identifierOfLevel(int level) {
+            return "L" + level;
+        }
+
+        /**
+         * Returns the level in this pyramid for the given local identifier.
+         * This method is the converse of {@link #identifierOfLevel(int)}.
+         *
+         * @param  identifier  the identifier for which to get the pyramid 
level.
+         * @return pyramid level associated to the given identifier.
+         * @throws IllegalArgumentException if the given identifier is not 
recognized by this pyramid.
+         */
+        default int levelOfIdentifier(final String identifier) {
+            if (identifier.isEmpty() || identifier.charAt(0) != 'L') {
+                throw new IllegalArgumentException(identifier);
+            }
+            // Note: `NumberFormatException` is a subtype of 
`IllegalArgumentException`.
+            return Integer.parseInt(identifier.substring(1));
+        }
+
+        /**
+         * Returns a resource for the same data as this resource but at a 
different resolution level.
+         * The resource at index 0 shall be the resource with the finest 
resolution, and resources at
+         * increasing index values shall be resources with increasingly 
coarser resolutions.
+         * If the specified level is equal or greater than the number of 
levels in this pyramid,
+         * then this method shall return {@code null}.
+         *
+         * <p>If this method returns a non-null instance <var>r</var>, then 
the following condition should hold:
+         * {@code r.getGridGeometry().getResolution(false)} should be equal, 
ignoring NaN values and rounding
+         * errors, to {@code getResolutions().get(level)}.</p>
+         *
+         * @param  level  the pyramid level where 0 is the level with the 
finest resolution.
+         * @return a resource for data at the specified pyramid level, or 
{@code null} if the given level is too high.
+         * @throws DataStoreException if an error occurred while creating the 
resource.
+         *
+         * @see #getResolutions()
+         */
+        TiledGridCoverageResource forPyramidLevel(int level) throws 
DataStoreException;
+
+        /**
+         * Returns the name factory to use for creating identifiers of tiles 
and tile matrices.
+         * The default implementation returns {@link 
DefaultNameFactory#provider()}.
+         * Subclasses can override for more control on the identifiers to 
create.
+         *
+         * @return the name factory to use for creating identifiers of tiles 
and tile matrices.
+         */
+        default NameFactory nameFactory() {
+            return DefaultNameFactory.provider();
+        }
+    }
 }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledResource.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledResource.java
index bc7c7f72ff..4c9d6e338a 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledResource.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/tiling/TiledResource.java
@@ -30,9 +30,7 @@ import org.apache.sis.storage.Resource;
  * <p>A {@code TiledResource} may contain multiple {@link TileMatrixSet} 
instances,
  * each one for a different {@link 
org.opengis.referencing.crs.CoordinateReferenceSystem}.
  * Most format specifications only support a single {@link TileMatrixSet},
- * but a few ones like WMTS may have several.</p>
- *
- * <p>All methods in this interface return non-null values.</p>
+ * but a few ones like <abbr>WMTS</abbr> may have several.</p>
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/TableAppender.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/TableAppender.java
index e11a99bde9..fff3fe23a0 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/TableAppender.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/TableAppender.java
@@ -68,7 +68,7 @@ import static 
org.apache.sis.util.Characters.isLineOrParagraphSeparator;
  *   ╚═════════╧═════════╧════════╝</pre>
  *
  * @author  Martin Desruisseaux (MPO, IRD, Geomatys)
- * @version 1.0
+ * @version 1.7
  *
  * @see org.apache.sis.util.collection.TreeTableFormat
  *
@@ -227,6 +227,20 @@ public class TableAppender extends Appender implements 
Flushable {
         ownOut = true;
     }
 
+    /**
+     * Creates a new table formatter writing in an internal buffer with the 
specified column separator and border.
+     *
+     * @param leftBorder   string to write on the left side of the table.
+     * @param separator    string to write between columns.
+     * @param rightBorder  string to write on the right side of the table.
+     *
+     * @since 1.7
+     */
+    public TableAppender(final String leftBorder, final String separator, 
final String rightBorder) {
+        this(new StringBuilder(256), leftBorder, separator, rightBorder);
+        ownOut = true;
+    }
+
     /**
      * Creates a new table formatter writing in the given output with a 
default column separator.
      * The default is a vertical double line for the left and right table 
borders, and a single
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java
index 571953421a..7e54a9c545 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/io/package-info.java
@@ -41,7 +41,7 @@
  * Unicode supplementary characters}.
  *
  * @author  Martin Desruisseaux (IRD, Geomatys)
- * @version 1.6
+ * @version 1.7
  * @since   0.3
  */
 package org.apache.sis.io;
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/DecimalFunctions.java
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/DecimalFunctions.java
index 70bc3e7271..3a5bfac210 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/DecimalFunctions.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/math/DecimalFunctions.java
@@ -45,7 +45,7 @@ import static 
org.apache.sis.pending.jdk.JDK19.DOUBLE_PRECISION;
  * since base 10 is not more "real" than base 2 for natural phenomenon.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.7
  *
  * @see MathFunctions#pow10(int)
  * @see Math#log10(double)
@@ -497,6 +497,31 @@ public final class DecimalFunctions {
         throw new ArithmeticException(String.valueOf(x));
     }
 
+    /**
+     * Computes {@code (int) floor(log10(x))} from an integer value. For 
values greater than one,
+     * this is the number of digits - 1 in the decimal representation of the 
given number.
+     * Values smaller than 1 are not allowed.
+     *
+     * @param  x  the value for which to compute the logarithm. Must be 
greater than zero.
+     * @return logarithm of the given value, rounded toward zero.
+     * @throws ArithmeticException if the given value is zero or negative.
+     *
+     * @since 1.7
+     */
+    public static int floorLog10(final long x) {
+        if (x <= 0) {
+            throw new ArithmeticException(String.valueOf(x));
+        }
+        // It would be possible to do something clever, but this method is not 
invoked very often.
+        int p = 0;
+        long limit = 10;
+        while (x >= limit) {
+            limit *= 10;
+            p++;
+        }
+        return p;
+    }
+
     /**
      * Returns {@code true} if the given numbers or equal or differ only by 
{@code accurate}
      * having more non-zero trailing decimal fraction digits than {@code 
approximate}.
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/shared/AbstractMap.java
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/shared/AbstractMap.java
index 2da8ee66e8..0104706f87 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/shared/AbstractMap.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/shared/AbstractMap.java
@@ -708,7 +708,7 @@ public abstract class AbstractMap<K,V> implements Map<K,V> {
      */
     @Override
     public String toString() {
-        final var buffer = new TableAppender(" = ");
+        final var buffer = new TableAppender("", " = ", "");
         buffer.setMultiLinesCells(true);
         final EntryIterator<K,V> it = entryIterator();
         if (it != null) while (it.next()) {
diff --git 
a/endorsed/src/org.apache.sis.util/test/org/apache/sis/math/DecimalFunctionsTest.java
 
b/endorsed/src/org.apache.sis.util/test/org/apache/sis/math/DecimalFunctionsTest.java
index a5c71ba2ad..6c092803ed 100644
--- 
a/endorsed/src/org.apache.sis.util/test/org/apache/sis/math/DecimalFunctionsTest.java
+++ 
b/endorsed/src/org.apache.sis.util/test/org/apache/sis/math/DecimalFunctionsTest.java
@@ -33,6 +33,7 @@ import org.apache.sis.test.TestUtilities;
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
+@SuppressWarnings("exports")
 public final class DecimalFunctionsTest extends TestCase {
     /**
      * Creates a new test case.
@@ -269,14 +270,14 @@ public final class DecimalFunctionsTest extends TestCase {
      */
     @Test
     public void testFloorLog10() {
-        assertEquals(   0, floorLog10(   1));
-        assertEquals(   0, floorLog10(   9));
-        assertEquals(   1, floorLog10(  10));
-        assertEquals(   1, floorLog10(  11));
-        assertEquals(   1, floorLog10(  99));
-        assertEquals(   2, floorLog10( 100));
-        assertEquals(   2, floorLog10( 999));
-        assertEquals(   3, floorLog10(1000));
+        assertEquals(   0, floorLog10(   1d));
+        assertEquals(   0, floorLog10(   9d));
+        assertEquals(   1, floorLog10(  10d));
+        assertEquals(   1, floorLog10(  11d));
+        assertEquals(   1, floorLog10(  99d));
+        assertEquals(   2, floorLog10( 100d));
+        assertEquals(   2, floorLog10( 999d));
+        assertEquals(   3, floorLog10(1000d));
         assertEquals(  -1, floorLog10(0.100));
         assertEquals(  -2, floorLog10(0.099));
         assertEquals(  -2, floorLog10(0.010));
@@ -291,6 +292,21 @@ public final class DecimalFunctionsTest extends TestCase {
         try {floorLog10(POSITIVE_INFINITY); fail("Expected 
ArithmeticException.");} catch (ArithmeticException e) {}
     }
 
+    /**
+     * Tests {@link DecimalFunctions#floorLog10(long)} method.
+     */
+    @Test
+    public void testFloorLog10OnFromInteger() {
+        assertEquals(0, floorLog10(   1L));
+        assertEquals(0, floorLog10(   9L));
+        assertEquals(1, floorLog10(  10L));
+        assertEquals(1, floorLog10(  11L));
+        assertEquals(1, floorLog10(  99L));
+        assertEquals(2, floorLog10( 100L));
+        assertEquals(2, floorLog10( 999L));
+        assertEquals(3, floorLog10(1000L));
+    }
+
     /**
      * Tests {@link DecimalFunctions#equalsIgnoreMissingFractionDigits(double, 
double)}.
      * This test uses the conversion factor from degrees to radians as a use 
case.
diff --git 
a/optional/src/org.apache.sis.storage.gdal/test/org/apache/sis/storage/gdal/GDALStoreTest.java
 
b/optional/src/org.apache.sis.storage.gdal/test/org/apache/sis/storage/gdal/GDALStoreTest.java
index 2bd9814ce1..60eddd1638 100644
--- 
a/optional/src/org.apache.sis.storage.gdal/test/org/apache/sis/storage/gdal/GDALStoreTest.java
+++ 
b/optional/src/org.apache.sis.storage.gdal/test/org/apache/sis/storage/gdal/GDALStoreTest.java
@@ -50,8 +50,9 @@ import static org.junit.jupiter.api.Assertions.*;
  * Tests {@link GDALStore}.
  *
  * @author Johann Sorel (Geomatys)
- * @author Quentin BIALOTA (Geomatys)
+ * @author Quentin Bialota (Geomatys)
  */
+@SuppressWarnings("exports")
 public final class GDALStoreTest {
     /**
      * Name of the test file.

Reply via email to