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


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 287923cbd5 Initial `WritableStore` for ASCII Grid format. A large part 
of this commit is about providing some generic support methods, not only for 
ASCII Grid format.
287923cbd5 is described below

commit 287923cbd5e311354fdbf37ec97adeae79df79f9
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Fri Apr 8 20:05:39 2022 +0200

    Initial `WritableStore` for ASCII Grid format.
    A large part of this commit is about providing some generic support 
methods, not only for ASCII Grid format.
---
 .../java/org/apache/sis/image/ImageCombiner.java   |  45 ++-
 .../java/org/apache/sis/image/PixelIterator.java   |  16 ++
 .../sis/internal/coverage/CoverageCombiner.java    | 307 +++++++++++++++++++++
 .../org/apache/sis/internal/util/Numerics.java     |  36 +++
 .../org/apache/sis/internal/util/NumericsTest.java |  22 ++
 .../apache/sis/internal/storage/PRJDataStore.java  |  71 ++++-
 .../org/apache/sis/internal/storage/Resources.java |  10 +
 .../sis/internal/storage/Resources.properties      |   2 +
 .../sis/internal/storage/Resources_fr.properties   |   2 +
 .../apache/sis/internal/storage/URIDataStore.java  |  15 +-
 .../internal/storage/WritableResourceSupport.java  | 225 +++++++++++++++
 .../apache/sis/internal/storage/ascii/Store.java   |  48 +++-
 .../sis/internal/storage/ascii/StoreProvider.java  |   6 +-
 .../sis/internal/storage/ascii/WritableStore.java  | 270 ++++++++++++++++++
 .../sis/internal/storage/io/ChannelDataInput.java  |  23 +-
 .../sis/internal/storage/io/ChannelDataOutput.java |   4 +-
 .../sis/storage/WritableGridCoverageResource.java  |  34 +--
 17 files changed, 1104 insertions(+), 32 deletions(-)

diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java
index 1ee69e78b9..22f4608df7 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/ImageCombiner.java
@@ -52,8 +52,17 @@ import org.apache.sis.measure.Units;
  * In current implementation, the last pixel values win even if those pixels 
are transparent
  * (i.e. {@code ImageCombiner} does not yet handle alpha values).
  *
+ * <h2>Limitations</h2>
+ * Current implementation does not try to map source bands to target bands for 
the same colors.
+ * For example it does not verify if band order needs to be reversed because 
an image is RGB and
+ * the other image is BVR. It is caller responsibility to ensure that bands 
are in the same order.
+ *
+ * <p>Current implementation does not expand the destination image for 
accommodating
+ * any area of a given image that appear outside the destination image bounds.
+ * Only the intersection of both images is used.</p>
+ *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.1
  * @module
  */
@@ -68,6 +77,13 @@ public class ImageCombiner implements 
Consumer<RenderedImage> {
      */
     private final WritableRenderedImage destination;
 
+    /**
+     * The value to use in calls to {@link 
ImageProcessor#setImageLayout(ImageLayout)}.
+     * We set this property before use of {@link #processor} because the value 
may change
+     * for each slice processed by {@link 
org.apache.sis.internal.coverage.CoverageCombiner}.
+     */
+    private final Layout layout;
+
     /**
      * Creates an image combiner which will write in the given image. That 
image is not cleared;
      * pixels that are not overwritten by calls to the {@code accept(…)} or 
{@code resample(…)}
@@ -76,10 +92,25 @@ public class ImageCombiner implements 
Consumer<RenderedImage> {
      * @param  destination  the image where to combine images.
      */
     public ImageCombiner(final WritableRenderedImage destination) {
+        this(destination,  new ImageProcessor());
+    }
+
+    /**
+     * Creates an image combiner which will use the given processor for 
resampling operations.
+     * The given destination image is not cleared; pixels that are not 
overwritten by calls to
+     * the {@code accept(…)} or {@code resample(…)} methods will be left 
unchanged.
+     *
+     * @param  destination  the image where to combine images.
+     * @param  processor    the processor to use for resampling operations.
+     *
+     * @since 1.2
+     */
+    public ImageCombiner(final WritableRenderedImage destination, final 
ImageProcessor processor) {
         ArgumentChecks.ensureNonNull("destination", destination);
+        ArgumentChecks.ensureNonNull("processor", processor);
         this.destination = destination;
-        processor = new ImageProcessor();
-        processor.setImageLayout(new Layout(destination.getSampleModel()));
+        this.processor = processor;
+        layout = new Layout(destination.getSampleModel());
     }
 
     /**
@@ -250,9 +281,10 @@ public class ImageCombiner implements 
Consumer<RenderedImage> {
          */
         final RenderedImage result;
         synchronized (processor) {
-            final Point minTile = ((Layout) 
processor.getImageLayout()).minTile;
+            final Point minTile = layout.minTile;
             minTile.x = minTileX;
             minTile.y = minTileY;
+            processor.setImageLayout(layout);
             result = processor.resample(source, bounds, toSource);
         }
         if (result instanceof ComputedImage) {
@@ -268,7 +300,10 @@ public class ImageCombiner implements 
Consumer<RenderedImage> {
      * This may be the destination image specified at construction time, but 
may also be a larger image if the
      * destination has been dynamically expanded for accommodating larger 
sources.
      *
-     * <p><b>Note:</b> dynamic expansion is not yet implemented in current 
version.</p>
+     * <p><b>Note:</b> dynamic expansion is not yet implemented in current 
version.
+     * If a future version implements it, we shall guarantee that the 
coordinate of each pixel is unchanged
+     * (i.e. the image {@code minX} and {@code minY} may become negative, but 
the pixel identified by
+     * coordinates (0,0) for instance will stay the same pixel.)</p>
      *
      * @return the combination of destination image with all source images.
      */
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/image/PixelIterator.java 
b/core/sis-feature/src/main/java/org/apache/sis/image/PixelIterator.java
index 007077928d..d6c4177a4e 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/image/PixelIterator.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/image/PixelIterator.java
@@ -553,6 +553,20 @@ public class PixelIterator {
         return false;
     }
 
+    /**
+     * Returns the type used for storing data in the raster buffer.
+     * The data type identifies the {@link DataBuffer} subclass used for 
storage.
+     *
+     * @return the type used for storing data in the raster buffer.
+     *
+     * @see SampleModel#getDataType()
+     *
+     * @since 1.2
+     */
+    public DataType getDataType() {
+        return DataType.forDataBufferType(getSampleModel().getDataType());
+    }
+
     /**
      * Returns the most efficient type ({@code int}, {@code float} or {@code 
double}) for transferring data between the
      * underlying rasters and this iterator. The transfer type is not 
necessarily the storage type used by the rasters.
@@ -565,6 +579,8 @@ public class PixelIterator {
      * {@link TransferType#DOUBLE}, then {@link #getSampleDouble(int)} will be 
both more efficient and avoid accuracy lost.</p>
      *
      * @return the most efficient data type for transferring data.
+     *
+     * @see SampleModel#getTransferType()
      */
     public TransferType<?> getTransferType() {
         return TransferType.valueOf(getSampleModel().getTransferType());
diff --git 
a/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/CoverageCombiner.java
 
b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/CoverageCombiner.java
new file mode 100644
index 0000000000..d191b99a23
--- /dev/null
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/internal/coverage/CoverageCombiner.java
@@ -0,0 +1,307 @@
+/*
+ * 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.internal.coverage;
+
+import java.awt.Dimension;
+import java.awt.image.RenderedImage;
+import java.awt.image.WritableRenderedImage;
+import org.opengis.geometry.Envelope;
+import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.ImageRenderer;
+import org.apache.sis.coverage.grid.DisjointExtentException;
+import org.apache.sis.image.ImageProcessor;
+import org.apache.sis.image.ImageCombiner;
+import org.apache.sis.image.PlanarImage;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.resources.Errors;
+
+import static java.lang.Math.addExact;
+import static java.lang.Math.subtractExact;
+import static java.lang.Math.multiplyExact;
+import static java.lang.Math.toIntExact;
+import static java.lang.Math.round;
+import static java.lang.Math.min;
+import static java.lang.Math.max;
+import static org.apache.sis.internal.util.Numerics.saturatingAdd;
+import static org.apache.sis.internal.util.Numerics.saturatingSubtract;
+
+
+/**
+ * Combines an arbitrary amount of coverages into a single one.
+ * The combined coverages may use different coordinate systems.
+ * The workflow is as below:
+ *
+ * <ol>
+ *   <li>Creates a {@code CoverageCombiner} with the destination coverage 
where to write.</li>
+ *   <li>Configure with methods such as {@link #setInterpolation 
setInterpolation(…)}.</li>
+ *   <li>Invoke {@link #accept accept(…)} or {@link #resample resample(…)}
+ *       methods for each coverage to combine.</li>
+ *   <li>Get the combined coverage with {@link #result()}.</li>
+ * </ol>
+ *
+ * Coverages are combined in the order they are specified.
+ *
+ * <h2>Limitations</h2>
+ * Current implementation does not apply interpolations except in the two 
dimensions
+ * specified at construction time. For all other dimensions, data are taken 
from the
+ * nearest neighbor two-dimensional slice.
+ *
+ * <p>In addition, current implementation does not verify if sample dimensions 
are in the same order,
+ * and does not expand the destination coverage for accommodating data in 
given coverages that would
+ * be outside the bounds of destination coverage.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.2
+ *
+ * @see ImageCombiner
+ *
+ * @since 1.2
+ * @module
+ */
+public final class CoverageCombiner {
+    /**
+     * The {@value} value for identifying code expecting exactly 2 dimensions.
+     */
+    private static final int BIDIMENSIONAL = 2;
+
+    /**
+     * The image processor for resampling operation.
+     * The same processor is used for all slices.
+     */
+    private final ImageProcessor processor;
+
+    /**
+     * The destination coverage where to write the coverages given to this 
{@code CoverageCombiner}.
+     */
+    private final GridCoverage destination;
+
+    /**
+     * The image combiners to use for combining two-dimensional slices.
+     * There is one {@link ImageCombiner} instance per slice, created when 
first needed.
+     */
+    private final ImageCombiner[] sliceCombiners;
+
+    /**
+     * Number of dimensions of the destination grid coverage.
+     */
+    private final int dimension;
+
+    /**
+     * The dimension to extract as {@link RenderedImage}s.
+     * This is usually 0 for <var>x</var> and 1 for <var>y</var>.
+     */
+    private final int xdim, ydim;
+
+    /**
+     * The offset to subtract to grid index before to compute the index of a 
slice.
+     */
+    private final long[] sliceIndexOffsets;
+
+    /**
+     * The multiplication factor to apply of grid coordinates (after 
subtracting the offset)
+     * for computing slice coordinates.
+     */
+    private final int[] sliceIndexSpans;
+
+    /**
+     * Creates a coverage combiner which will write in the given coverage.
+     * The coverage is not cleared; cells that are not overwritten by calls
+     * to the {@code accept(…)} method will be left unchanged.
+     *
+     * @param  destination  the coverage where to combine coverages.
+     * @param  xdim         the dimension to extract as {@link RenderedImage} 
<var>x</var> axis. This is usually 0.
+     * @param  ydim         the dimension to extract as {@link RenderedImage} 
<var>y</var> axis. This is usually 1.
+     */
+    public CoverageCombiner(final GridCoverage destination, final int xdim, 
final int ydim) {
+        this.destination = destination;
+        final GridExtent extent = destination.getGridGeometry().getExtent();
+        dimension = extent.getDimension();
+        ArgumentChecks.ensureBetween("xdim", 0, dimension-1, xdim);
+        ArgumentChecks.ensureBetween("ydim", 0, dimension-1, ydim);
+        if (xdim == ydim) {
+            throw new 
IllegalArgumentException(Errors.format(Errors.Keys.DuplicatedNumber_1, xdim));
+        }
+        this.xdim = xdim;
+        this.ydim = ydim;
+        sliceIndexOffsets = new long[dimension - BIDIMENSIONAL];
+        sliceIndexSpans   = new int [dimension - BIDIMENSIONAL];
+        int sliceCount    = 1;
+        for (int j=0,i=0; i<dimension; i++) {
+            if (i != xdim && i != ydim) {
+                final int span;
+                sliceIndexOffsets[j] = extent.getLow(i);
+                sliceIndexSpans[j++] = span = toIntExact(extent.getSize(i));
+                sliceCount = multiplyExact(sliceCount, span);
+            }
+        }
+        sliceCombiners = new ImageCombiner[sliceCount];
+        processor = new ImageProcessor();
+    }
+
+    /**
+     * Returns information about conversion from pixel coordinates to "real 
world" coordinates.
+     * This is taken from {@link PlanarImage#GRID_GEOMETRY_KEY} if available, 
or computed otherwise.
+     *
+     * @param  image     the image from which to get the conversion.
+     * @param  coverage  the coverage to use as a fallback if the information 
is not provided with the image.
+     * @param  slice     identification of the slice to read in the coverage.
+     * @return information about conversion from pixel to "real world" 
coordinates.
+     */
+    private static GridGeometry getGridGeometry(final RenderedImage image,
+            final GridCoverage coverage, final GridExtent slice)
+    {
+        final Object value = image.getProperty(PlanarImage.GRID_GEOMETRY_KEY);
+        if (value instanceof GridGeometry) {
+            return (GridGeometry) value;
+        }
+        return new ImageRenderer(coverage, 
slice).getImageGeometry(BIDIMENSIONAL);
+    }
+
+    /**
+     * Writes the given coverage on top of destination coverage.
+     * The given coverage is resampled to the grid geometry of the destination 
coverage.
+     *
+     * @param  source  the coverage to write on top of destination coverage.
+     * @return {@code true} on success, or {@code false} if at least one slice
+     *         in the destination coverage is not writable.
+     * @throws TransformException if the coordinates of given coverage can not 
be transformed
+     *         to the coordinates of destination coverage.
+     */
+    public boolean accept(final GridCoverage source) throws TransformException 
{
+        final Dimension margin = processor.getInterpolation().getSupportSize();
+        margin.width  = ((margin.width  + 1) >> 1) + 1;
+        margin.height = ((margin.height + 1) >> 1) + 1;
+        final long[] minIndices = new long[dimension];      // Will be 
expanded by above margin.
+        final long[] maxIndices = new long[dimension];      // Inclusive.
+        /*
+         * Compute the intersection between `source` and `destination`, in 
units
+         * of destination cell indices. A margin is added for interpolations.
+         * This block also verifies that the intersection exists.
+         */
+        final double[] centerIndices;
+        final double[] centerSourceIndices;
+        final MathTransform toSourceSliceCorner;
+        final MathTransform toSourceSliceCenter;
+        {   // For keeping following variables in a local scope.
+            final GridGeometry targetGG = destination.getGridGeometry();
+            final GridGeometry sourceGG = source.getGridGeometry();
+            final GridExtent   sourceEx = sourceGG.getExtent();
+            final GridExtent   targetEx = targetGG.getExtent();
+            centerIndices       = targetEx.getPointOfInterest();
+            centerSourceIndices = new double[sourceEx.getDimension()];
+            toSourceSliceCorner = targetGG.createTransformTo(sourceGG, 
PixelInCell.CELL_CORNER);
+            toSourceSliceCenter = targetGG.createTransformTo(sourceGG, 
PixelInCell.CELL_CENTER);
+            final Envelope env  = 
sourceEx.toEnvelope(toSourceSliceCorner.inverse());
+            for (int i=0; i<dimension; i++) {
+                minIndices[i] = max(targetEx.getLow (i), 
round(env.getMinimum(i)));
+                maxIndices[i] = min(targetEx.getHigh(i), 
round(env.getMaximum(i) - 1));
+                if (minIndices[i] >= maxIndices[i]) {
+                    throw new DisjointExtentException();
+                }
+            }
+        }
+        /*
+         * Now apply `ImageCombiner` for each two-dimensional slice. We will 
iterate on all destination slices
+         * in the intersection area, and locate the corresponding source 
slices (this is a strategy similar to
+         * the resampling of pixel values in rasters).
+         */
+        final long[] minSliceIndices  = minIndices.clone();
+        final long[] maxSliceIndices  = maxIndices.clone();
+        final long[] minSourceIndices = new long[centerSourceIndices.length];
+        final long[] maxSourceIndices = new long[centerSourceIndices.length];
+        boolean success = true;
+next:   for (;;) {
+            /*
+             * Compute the index in `sliceCombiners` array for the 
two-dimensional
+             * slice identified by the current value of the `slice` 
coordinates.
+             */
+            int sliceIndex = 0;
+            for (int j=0,i=0; i<dimension; i++) {
+                if (i != xdim && i != ydim) {
+                    maxSliceIndices[i] = minSliceIndices[i];
+                    int offset = toIntExact(subtractExact(minSliceIndices[i], 
sliceIndexOffsets[j]));
+                    offset = multiplyExact(offset, sliceIndexSpans[j++]);
+                    sliceIndex = addExact(sliceIndex, offset);
+                }
+            }
+            /*
+             * Get the image for the current slice. It may be the result of a 
previous combination.
+             * If the image is not writable, we skip that slice and try the 
next one.
+             * A flag will report to the user that at least one slice was 
non-writable.
+             */
+            final GridExtent targetSliceExtent = new GridExtent(null, 
minSliceIndices, maxSliceIndices, true);
+            final RenderedImage targetSlice;
+            ImageCombiner combiner = sliceCombiners[sliceIndex];
+            if (combiner != null) {
+                targetSlice = combiner.result();
+            } else {
+                targetSlice = destination.render(targetSliceExtent);
+                if (targetSlice instanceof WritableRenderedImage) {
+                    combiner = new ImageCombiner((WritableRenderedImage) 
targetSlice, processor);
+                    sliceCombiners[sliceIndex] = combiner;
+                } else {
+                    success = false;
+                }
+            }
+            /*
+             * Compute the bounds of the source image to load (with a margin 
for rounding and interpolations).
+             * For all dimensions other than the slice dimensions, we take the 
center of the slice to read.
+             */
+            if (combiner != null) {
+                toSourceSliceCenter.transform(centerIndices, 0, 
centerSourceIndices, 0, 1);
+                final Envelope sourceArea = 
targetSliceExtent.toEnvelope(toSourceSliceCorner);
+                for (int i=0; i<minSourceIndices.length; i++) {
+                    if (i == xdim || i == ydim) {
+                        final int m = (i == xdim) ? margin.width : 
margin.height;
+                        minSourceIndices[i] = 
saturatingSubtract(round(sourceArea.getMinimum(i)), m  );
+                        maxSourceIndices[i] = saturatingAdd     
(round(sourceArea.getMaximum(i)), m-1);
+                    } else {
+                        minSourceIndices[i] = round(centerSourceIndices[i]);
+                        maxSourceIndices[i] = minSourceIndices[i];
+                    }
+                }
+                GridExtent sourceSliceExtent = new GridExtent(null, 
minSourceIndices, maxSourceIndices, true);
+                /*
+                 * Get the source image and combine with the corresponding 
slice of destination coverage.
+                 */
+                RenderedImage sourceSlice = source.render(sourceSliceExtent);
+                MathTransform toSource =
+                        getGridGeometry(targetSlice, destination, 
targetSliceExtent).createTransformTo(
+                        getGridGeometry(sourceSlice, source,      
sourceSliceExtent), PixelInCell.CELL_CENTER);
+                combiner.resample(sourceSlice, null, toSource);
+            }
+            /*
+             * Increment indices to the next slice.
+             */
+            for (int i=0; i<dimension; i++) {
+                if (i != xdim && i != ydim) {
+                    if (minSliceIndices[i]++ <= maxIndices[i]) {
+                        continue next;
+                    }
+                    minSliceIndices[i] = minIndices[i];
+                }
+            }
+            break;
+        }
+        return success;
+    }
+}
diff --git 
a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java 
b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
index f9864241da..bb68a2b420 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
@@ -240,6 +240,42 @@ public final class Numerics extends Static {
         return Math.multiplyExact(value, multiplier) / divisor;
     }
 
+    /**
+     * Returns {@code x+y} with saturation if the result overflows long 
capacity.
+     * This is <cite>saturation arithmetic</cite>.
+     *
+     * @param  x  the value for which to add something.
+     * @param  y  the value to add to {@code x}.
+     * @return {@code x+y} computed with saturation arithmetic.
+     */
+    public static long saturatingAdd(final long x, final int y) {
+        final long result = x + y;
+        if (y >= 0) {
+            if (result < x) return Long.MAX_VALUE;
+        } else {
+            if (result > x) return Long.MIN_VALUE;
+        }
+        return result;
+    }
+
+    /**
+     * Returns {@code x-y} with saturation if the result overflows long 
capacity.
+     * This is <cite>saturation arithmetic</cite>.
+     *
+     * @param  x  the value for which to add something.
+     * @param  y  the value to subtract from {@code x}.
+     * @return {@code x-y} computed with saturation arithmetic.
+     */
+    public static long saturatingSubtract(final long x, final int y) {
+        final long result = x - y;
+        if (y < 0) {
+            if (result < x) return Long.MAX_VALUE;
+        } else {
+            if (result > x) return Long.MIN_VALUE;
+        }
+        return result;
+    }
+
     /**
      * Returns the given value clamped to the range on 32 bits integer.
      *
diff --git 
a/core/sis-utility/src/test/java/org/apache/sis/internal/util/NumericsTest.java 
b/core/sis-utility/src/test/java/org/apache/sis/internal/util/NumericsTest.java
index d6ec734419..de5c9a953a 100644
--- 
a/core/sis-utility/src/test/java/org/apache/sis/internal/util/NumericsTest.java
+++ 
b/core/sis-utility/src/test/java/org/apache/sis/internal/util/NumericsTest.java
@@ -83,6 +83,28 @@ public final strictfp class NumericsTest extends TestCase {
         assertEquals(-2L, ceilDiv( -8L, 3L));
     }
 
+    /**
+     * Tests {@link Numerics#saturatingAdd(long, int)}.
+     */
+    @Test
+    public void testSaturatingAdd() {
+        assertEquals(1234 + 56, Numerics.saturatingAdd(1234,  56));
+        assertEquals(1234 - 56, Numerics.saturatingAdd(1234, -56));
+        assertEquals(Long.MAX_VALUE, Numerics.saturatingAdd(Long.MAX_VALUE - 
10,  56));
+        assertEquals(Long.MIN_VALUE, Numerics.saturatingAdd(Long.MIN_VALUE + 
10, -56));
+    }
+
+    /**
+     * Tests {@link Numerics#saturatingSubtract(long, int)}.
+     */
+    @Test
+    public void testSaturatingSubtract() {
+        assertEquals(1234 - 56, Numerics.saturatingSubtract(1234,  56));
+        assertEquals(1234 + 56, Numerics.saturatingSubtract(1234, -56));
+        assertEquals(Long.MAX_VALUE, 
Numerics.saturatingSubtract(Long.MAX_VALUE - 10, -56));
+        assertEquals(Long.MIN_VALUE, 
Numerics.saturatingSubtract(Long.MIN_VALUE + 10, +56));
+    }
+
     /**
      * Tests the {@link Numerics#cached(Object)} method.
      */
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/PRJDataStore.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/PRJDataStore.java
index 82f8f6556e..a55a8209ab 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/PRJDataStore.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/PRJDataStore.java
@@ -20,7 +20,9 @@ import java.net.URL;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.io.BufferedWriter;
 import java.io.FileNotFoundException;
+import java.net.UnknownServiceException;
 import java.nio.charset.Charset;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -47,6 +49,7 @@ import org.apache.sis.parameter.Parameters;
 import org.apache.sis.util.resources.Vocabulary;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.Classes;
 
 
 /**
@@ -173,7 +176,7 @@ public abstract class PRJDataStore extends URIDataStore {
         Path path = getSpecifiedPath();
         if (path != null) {
             final String base = getBaseFilename(path);
-            path = path.resolveSibling(base.concat(PRJ));
+            path = path.resolveSibling(base.concat(extension));
             stream = Files.newInputStream(path);
         } else {
             final URL url = IOUtilities.toAuxiliaryURL(location, extension);
@@ -198,6 +201,72 @@ public abstract class PRJDataStore extends URIDataStore {
         }
     }
 
+    /**
+     * Writes the {@code "*.prj"} auxiliary file if {@link #crs} is non-null.
+     * If {@link #crs} is null and the auxiliary file exists, it is deleted.
+     *
+     * @throws DataStoreException if an error occurred while writing the file.
+     */
+    protected final void writePRJ() throws DataStoreException {
+        try {
+            if (crs == null) {
+                deleteAuxiliaryFile(PRJ);
+            } else try (BufferedWriter out = writeAuxiliaryFile(PRJ, 
encoding)) {
+                final StoreFormat format = new StoreFormat(locale, timezone, 
null, listeners);
+                format.setConvention(Convention.WKT1_COMMON_UNITS);
+                format.format(crs, out);
+            }
+        } catch (IOException e) {
+            Object identifier = getIdentifier().orElse(null);
+            if (identifier == null) identifier = 
Classes.getShortClassName(this);
+            throw new 
DataStoreException(Resources.format(Resources.Keys.CanNotWriteResource_1, 
identifier), e);
+        }
+    }
+
+    /**
+     * Creates a writer for an auxiliary file with the specified extension.
+     * This method uses the same path than {@link #location},
+     * except for the extension which is replaced by the given value.
+     *
+     * @param  extension  the filename extension of the auxiliary file to 
write.
+     * @param  encoding   the encoding to use for writing the file content, or 
{@code null} for default.
+     * @return a stream opened on the specified file.
+     * @throws UnknownServiceException if no {@link Path} or {@link 
java.net.URI} is available.
+     * @throws DataStoreException if the auxiliary file can not be created.
+     * @throws IOException if another error occurred while opening the stream.
+     */
+    protected final BufferedWriter writeAuxiliaryFile(final String extension, 
Charset encoding)
+            throws IOException, DataStoreException
+    {
+        final Path[] paths = super.getComponentFiles();
+        if (paths.length == 0) {
+            throw new UnknownServiceException();
+        }
+        if (encoding == null) {
+            encoding = Charset.defaultCharset();
+        }
+        Path path = paths[0];
+        final String base = getBaseFilename(path);
+        path = path.resolveSibling(base.concat(extension));
+        return Files.newBufferedWriter(path, encoding);
+    }
+
+    /**
+     * Deletes the auxiliary file with the given extension if it exists.
+     * If the auxiliary file does not exist, then this method does nothing.
+     *
+     * @param  extension  the filename extension of the auxiliary file to 
delete.
+     * @throws DataStoreException if the auxiliary file is not on a supported 
file system.
+     * @throws IOException if an error occurred while deleting the file.
+     */
+    protected final void deleteAuxiliaryFile(final String extension) throws 
DataStoreException, IOException {
+        for (Path path : super.getComponentFiles()) {
+            final String base = getBaseFilename(path);
+            path = path.resolveSibling(base.concat(extension));
+            Files.deleteIfExists(path);
+        }
+    }
+
     /**
      * Returns the {@linkplain #location} as a {@code Path} component together 
with auxiliary files.
      * The default implementation does the same computation as the 
super-class, then adds the sibling
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.java
index 70e56b5a31..4e65e5a169 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.java
@@ -134,6 +134,11 @@ public final class Resources extends IndexedResourceBundle 
{
          */
         public static final short CanNotStoreResourceType_2 = 41;
 
+        /**
+         * Can not write the “{0}” resource.
+         */
+        public static final short CanNotWriteResource_1 = 69;
+
         /**
          * This {0} reader is closed.
          */
@@ -336,6 +341,11 @@ public final class Resources extends IndexedResourceBundle 
{
          */
         public static final short ResourceNotFound_2 = 24;
 
+        /**
+         * The “{0}” format does not support rotations.
+         */
+        public static final short RotationNotSupported_1 = 70;
+
         /**
          * The “{1}” element must be declared before “{0}”.
          */
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.properties
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.properties
index 821d1ccc51..e0118b5299 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.properties
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.properties
@@ -34,6 +34,7 @@ CanNotReadPixel_3                 = Can not read pixel at 
({0}, {1}) indices in
 CanNotRemoveResource_2            = Can not remove resource \u201c{1}\u201d 
from aggregate \u201c{0}\u201d.
 CanNotRenderImage_1               = Can not render an image for the 
\u201c{0}\u201d coverage.
 CanNotStoreResourceType_2         = Can not save resources of type 
\u2018{1}\u2019 in a \u201c{0}\u201d store.
+CanNotWriteResource_1             = Can not write the \u201c{0}\u201d resource.
 ClosedStorageConnector            = This storage connector is closed.
 ClosedReader_1                    = This {0} reader is closed.
 ClosedWriter_1                    = This {0} writer is closed.
@@ -74,6 +75,7 @@ ResourceAlreadyExists_1           = A resource already exists 
at \u201c{0}\u201d
 ResourceIdentifierCollision_2     = More than one resource have the 
\u201c{1}\u201d identifier in the \u201c{0}\u201d data store.
 ResourceNotFound_2                = No resource found for the \u201c{1}\u201d 
identifier in the \u201c{0}\u201d data store.
 RequestOutOfBounds_5              = The request [{3} \u2026 {4}] is outside 
the [{1} \u2026 {2}] domain for \u201c{0}\u201d axis.
+RotationNotSupported_1            = The \u201c{0}\u201d format does not 
support rotations.
 ShallBeDeclaredBefore_2           = The \u201c{1}\u201d element must be 
declared before \u201c{0}\u201d.
 SharedDirectory_1                 = The \u201c{0}\u201d directory is used more 
than once because of symbolic links.
 StoreIsReadOnly                   = Write operations are not supported.
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources_fr.properties
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources_fr.properties
index d04def0316..8595b62a4a 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources_fr.properties
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources_fr.properties
@@ -38,6 +38,7 @@ CanNotReadFile_4                  = Ne peut pas lire 
apr\u00e8s la colonne {3} d
 CanNotReadPixel_3                 = Ne peut pas lire le pixel aux indices 
({0}, {1}) dans le fichier \u00ab\u202f{2}\u202f\u00bb.
 CanNotRemoveResource_2            = Ne peut pas supprimer la ressource 
\u00ab\u202f{1}\u202f\u00bb de l\u2019agr\u00e9gat \u00ab\u202f{0}\u202f\u00bb.
 CanNotRenderImage_1               = Ne peut pas produire une image pour la 
couverture de donn\u00e9es \u00ab\u202f{0}\u202f\u00bb.
+CanNotWriteResource_1             = Ne peut pas \u00e9crire la ressource 
\u00ab\u202f{0}\u202f\u00bb.
 CanNotStoreResourceType_2         = Ne peut pas enregistrer des ressources de 
type \u2018{1}\u2019 dans un entrep\u00f4t de donn\u00e9es 
\u00ab\u202f{0}\u202f\u00bb.
 ClosedStorageConnector            = Ce connecteur est ferm\u00e9.
 ClosedReader_1                    = Ce lecteur {0} est ferm\u00e9.
@@ -79,6 +80,7 @@ ResourceAlreadyExists_1           = Une ressource existe 
d\u00e9j\u00e0 \u00e0 l
 ResourceIdentifierCollision_2     = Plusieurs ressources utilisent 
l\u2019identifiant \u00ab\u202f{1}\u202f\u00bb dans les donn\u00e9es de 
\u00ab\u202f{0}\u202f\u00bb.
 ResourceNotFound_2                = Aucune ressource n\u2019a \u00e9t\u00e9 
trouv\u00e9e pour l\u2019identifiant \u00ab\u202f{1}\u202f\u00bb dans les 
donn\u00e9es de \u00ab\u202f{0}\u202f\u00bb.
 RequestOutOfBounds_5              = La demande [{3} \u2026 {4}] est en dehors 
du domaine [{1} \u2026 {2}] pour l\u2019axe \u00ab\u202f{0}\u202f\u00bb.
+RotationNotSupported_1            = Le format \u00ab\u202f{0}\u202f\u00bb ne 
supporte pas les rotations.
 ShallBeDeclaredBefore_2           = L\u2019\u00e9l\u00e9ment 
\u00ab\u202f{1}\u202f\u00bb doit \u00eatre d\u00e9clar\u00e9 avant 
\u00ab\u202f{0}\u202f\u00bb.
 SharedDirectory_1                 = Le r\u00e9pertoire 
\u00ab\u202f{0}\u202f\u00bb est utilis\u00e9 plus d\u2019une fois \u00e0 cause 
des liens symboliques.
 StoreIsReadOnly                   = Les op\u00e9rations d\u2019\u00e9criture 
ne sont pas support\u00e9es.
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/URIDataStore.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/URIDataStore.java
index 8aef150aaa..1242721b7c 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/URIDataStore.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/URIDataStore.java
@@ -21,6 +21,7 @@ import java.net.URI;
 import java.util.Optional;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
 import java.nio.file.FileSystemNotFoundException;
 import java.nio.charset.Charset;
 import org.opengis.util.GenericName;
@@ -36,6 +37,8 @@ import org.apache.sis.storage.DataStoreProvider;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.IllegalOpenParameterException;
 import org.apache.sis.internal.storage.io.IOUtilities;
+import org.apache.sis.setup.OptionKey;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.iso.Names;
 import org.apache.sis.util.logging.Logging;
 
@@ -199,7 +202,7 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
      *
      * @author  Johann Sorel (Geomatys)
      * @author  Martin Desruisseaux (Geomatys)
-     * @version 1.0
+     * @version 1.2
      * @since   0.8
      * @module
      */
@@ -311,6 +314,16 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
             throw new 
IllegalOpenParameterException(Resources.format(Resources.Keys.UndefinedParameter_2,
                         provider.getShortName(), LOCATION), cause);
         }
+
+        /**
+         * Returns {@code true} if the open options contains {@link 
StandardOpenOption#WRITE}.
+         *
+         * @param  connector  the connector to use for opening a file.
+         * @return whether the specified connector should open a writable data 
store.
+         */
+        public static boolean isWritable(final StorageConnector connector) {
+            return 
ArraysExt.contains(connector.getOption(OptionKey.OPEN_OPTIONS), 
StandardOpenOption.WRITE);
+        }
     }
 
     /**
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
new file mode 100644
index 0000000000..e41d83c960
--- /dev/null
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
@@ -0,0 +1,225 @@
+/*
+ * 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.internal.storage;
+
+import java.util.Locale;
+import java.io.IOException;
+import java.nio.channels.WritableByteChannel;
+import java.awt.geom.AffineTransform;
+import org.opengis.util.FactoryException;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.storage.GridCoverageResource;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.DataStoreReferencingException;
+import org.apache.sis.storage.ReadOnlyStorageException;
+import org.apache.sis.storage.ResourceAlreadyExistsException;
+import org.apache.sis.storage.IncompatibleResourceException;
+import org.apache.sis.storage.WritableGridCoverageResource;
+import org.apache.sis.internal.storage.io.ChannelDataInput;
+import org.apache.sis.internal.storage.io.ChannelDataOutput;
+import org.apache.sis.internal.coverage.CoverageCombiner;
+import org.apache.sis.referencing.operation.matrix.AffineTransforms2D;
+import org.apache.sis.referencing.operation.transform.TransformSeparator;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.Classes;
+import org.apache.sis.util.Localized;
+
+// Branch-dependent imports
+import org.opengis.coverage.CannotEvaluateException;
+
+
+/**
+ * Helper classes for the management of {@link 
WritableGridCoverageResource.CommonOption}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.2
+ * @since   1.2
+ * @module
+ */
+public final class WritableResourceSupport implements Localized {
+    /**
+     * The resource where to write.
+     */
+    private final GridCoverageResource resource;
+
+    /**
+     * {@code true} if the {@link 
WritableGridCoverageResource.CommonOption.REPLACE} option has been specified.
+     * At most one of {@code replace} and {@link #update} can be {@code true}.
+     */
+    private boolean replace;
+
+    /**
+     * {@code true} if the {@link 
WritableGridCoverageResource.CommonOption.UPDATE} option has been specified.
+     * At most one of {@link #replace} and {@code update} can be {@code true}.
+     */
+    private boolean update;
+
+    /**
+     * Creates a new helper class for the given options.
+     *
+     * @param  resource  the resource where to write.
+     * @param  options   configuration of the write operation.
+     */
+    public WritableResourceSupport(final GridCoverageResource resource, final 
WritableGridCoverageResource.Option[] options) {
+        this.resource = resource;
+        ArgumentChecks.ensureNonNull("options", options);
+        for (final WritableGridCoverageResource.Option option : options) {
+            replace |= 
WritableGridCoverageResource.CommonOption.REPLACE.equals(option);
+            update  |= WritableGridCoverageResource.CommonOption.UPDATE 
.equals(option);
+        }
+        if (replace & update) {
+            throw new 
IllegalArgumentException(Errors.format(Errors.Keys.MutuallyExclusiveOptions_2,
+                    WritableGridCoverageResource.CommonOption.REPLACE,
+                    WritableGridCoverageResource.CommonOption.UPDATE));
+        }
+    }
+
+    /**
+     * Returns the locale used by the resource for error messages, or {@code 
null} if unknown.
+     *
+     * @return the locale used by the resource for error messages, or {@code 
null} if unknown.
+     */
+    @Override
+    public final Locale getLocale() {
+        return (resource instanceof Localized) ? ((Localized) 
resource).getLocale() : null;
+    }
+
+    /**
+     * Returns the writable channel positioned at the beginning of the stream.
+     * The returned channel should <em>not</em> be closed
+     * because it is the same channel than the one used by {@code input}.
+     * Caller should invoke {@link ChannelDataOutput#flush()} after usage.
+     *
+     * @param  input  the input from which to get the writable channel.
+     * @return the writable channel.
+     * @throws IOException if the stream position can not be reset.
+     * @throws DataStoreException if the channel is read-only.
+     */
+    public final ChannelDataOutput channel(final ChannelDataInput input) 
throws IOException, DataStoreException {
+        if (input.channel instanceof WritableByteChannel && input.rewind()) {
+            return new ChannelDataOutput(input.filename, (WritableByteChannel) 
input.channel, input.buffer);
+        } else {
+            throw new ReadOnlyStorageException(canNotWrite());
+        }
+    }
+
+    /**
+     * Returns {@code true} if the caller should add or replace the resource
+     * or {@code false} if it needs to update an existing resource.
+     * Current heuristic:
+     *
+     * <ul>
+     *   <li>If the given channel is empty, then this method always returns 
{@code true}.</li>
+     *   <li>Otherwise this method returns {@code true} if the {@code REPLACE} 
option was specified,
+     *       or returns {@code false} if the {@code UPDATE} option was 
specified,
+     *       or thrown a {@link ResourceAlreadyExistsException} otherwise.</li>
+     * </ul>
+     *
+     * @param  input  the channel to test for emptiness.
+     * @return whether the caller should replace ({@code true}) or update 
({@code false}) the resource.
+     * @throws IOException if an error occurred while checking the channel 
length.
+     * @throws ResourceAlreadyExistsException if the resource exists and the 
writer
+     *         should neither updating or replacing it.
+     * @throws DataStoreException if another kind of error occurred with the 
resource.
+     */
+    public final boolean replace(final ChannelDataInput input) throws 
IOException, DataStoreException {
+        if (update) {
+            return input.length() == 0;
+        } else if (replace || input.length() == 0) {
+            return true;
+        } else {
+            Object identifier = resource.getIdentifier().orElse(null);
+            if (identifier == null) identifier = input.filename;
+            throw new 
ResourceAlreadyExistsException(Resources.forLocale(getLocale())
+                    .getString(Resources.Keys.ResourceAlreadyExists_1, 
identifier));
+        }
+    }
+
+    /**
+     * Reads the current coverage in the resource and updates its content with 
cell values from the given coverage.
+     * This method can be used as a simple implementation of {@link 
WritableGridCoverageResource.CommonOption#UPDATE}.
+     * This method returns the updated coverage; it is caller responsibility 
to write it.
+     *
+     * <p>This method can be used when updating the coverage requires to read 
it fully, then write if fully.
+     * Advanced writers should try to update only the modified parts 
(typically some tiles) instead.</p>
+     *
+     * @param  coverage  the coverage to use for updating the currently 
existing coverage.
+     * @return the updated coverage that the caller should write.
+     * @throws DataStoreException if an error occurred while reading or 
updating the coverage.
+     */
+    public final GridCoverage update(final GridCoverage coverage) throws 
DataStoreException {
+        final GridCoverage existing = resource.read(null, null);
+        final CoverageCombiner combiner = new CoverageCombiner(existing, 0, 1);
+        try {
+            if (!combiner.accept(coverage)) {
+                throw new ReadOnlyStorageException(canNotWrite());
+            }
+        } catch (TransformException e) {
+            throw new DataStoreReferencingException(canNotWrite(), e);
+        }
+        return existing;
+    }
+
+    /**
+     * Returns the "grid to CRS" transform as a two-dimensional affine 
transform.
+     * This is a convenience method for writers that support only this kind of 
transform.
+     *
+     * @param  extent     the extent of the grid coverage to write.
+     * @param  gridToCRS  the "grid to CRS" transform of the coverage to write.
+     * @return the given "grid to CRS" as a two-dimensional affine transform.
+     * @throws DataStoreException if the affine transform can not be extracted 
from the given "grid to CRS" transform.
+     */
+    public final AffineTransform getAffineTransform2D(final GridExtent extent, 
final MathTransform gridToCRS)
+            throws DataStoreException
+    {
+        final TransformSeparator s = new TransformSeparator(gridToCRS);
+        try {
+            s.addSourceDimensions(extent.getSubspaceDimensions(2));
+            return AffineTransforms2D.castOrCopy(s.separate());
+        } catch (FactoryException | CannotEvaluateException e) {
+            throw new DataStoreReferencingException(canNotWrite(), e);
+        } catch (IllegalArgumentException e) {
+            throw new IncompatibleResourceException(canNotWrite(), e);
+        }
+    }
+
+    /**
+     * Returns the message for an exception saying that we can not write the 
resource.
+     *
+     * @return a localized "Can not write resource" message.
+     * @throws DataStoreException if an error occurred while preparing the 
error message.
+     */
+    public final String canNotWrite() throws DataStoreException {
+        Object identifier = resource.getIdentifier().orElse(null);
+        if (identifier == null) identifier = 
Classes.getShortClassName(resource);
+        return 
Resources.forLocale(getLocale()).getString(Resources.Keys.CanNotWriteResource_1,
 identifier);
+    }
+
+    /**
+     * Returns the message for an exception saying that rotations are not 
supported.
+     *
+     * @param  format  name of the format that does not support rotations.
+     * @return a localized "rotation not supported" message.
+     */
+    public final String rotationNotSupported(final String format) {
+        return 
Resources.forLocale(getLocale()).getString(Resources.Keys.RotationNotSupported_1,
 format);
+    }
+}
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/Store.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/Store.java
index 0c485df201..ed7531792f 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/Store.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/Store.java
@@ -22,6 +22,7 @@ import java.util.Optional;
 import java.util.StringJoiner;
 import java.io.IOException;
 import java.nio.file.StandardOpenOption;
+import java.awt.image.RenderedImage;
 import java.awt.image.DataBufferFloat;
 import org.opengis.geometry.Envelope;
 import org.opengis.metadata.Metadata;
@@ -46,6 +47,7 @@ import org.apache.sis.internal.storage.PRJDataStore;
 import org.apache.sis.internal.storage.RangeArgument;
 import org.apache.sis.internal.storage.Resources;
 import org.apache.sis.internal.storage.io.ChannelDataInput;
+import org.apache.sis.measure.NumberRange;
 import org.apache.sis.metadata.iso.DefaultMetadata;
 import org.apache.sis.metadata.sql.MetadataStoreException;
 import org.apache.sis.referencing.operation.matrix.Matrix3;
@@ -165,6 +167,14 @@ class Store extends PRJDataStore implements 
GridCoverageResource {
         listeners.useWarningEventsOnly();
     }
 
+    /**
+     * Returns whether this store is read-only. If {@code true}, we can close 
the channel
+     * as soon as the coverage has been fully read. Otherwise we need to keep 
it open.
+     */
+    boolean isReadOnly() {
+        return true;
+    }
+
     /**
      * Reads the {@code "*.prj"} file and the header if not already done.
      * This method does nothing if the data store is already initialized.
@@ -418,8 +428,10 @@ cellsize:       if (value != null) {
              * TODO: a future version could try to convert the image to 
integer values.
              * In this case only we may need to declare the NODATA_VALUE.
              */
-            input = null;
-            view.input.channel.close();
+            if (isReadOnly()) {
+                input = null;
+                view.input.channel.close();
+            }
             double minimum = stats.minimum();
             double maximum = stats.maximum();
             if (!(minimum <= maximum)) {
@@ -455,11 +467,39 @@ cellsize:       if (value != null) {
     /**
      * Replaces all data by the given coverage.
      * This is used for write operations only.
+     *
+     * @param  replacement  the new coverage.
+     * @param  data         the image wrapped by the given coverage.
+     * @param  band         index of the band to write (usually 0).
+     * @return the "no data" value, or {@link Double#NaN} if none.
      */
-    final void setCoverage(final GridCoverage replacement) {
-        gridGeometry = replacement.getGridGeometry();
+    final Number setCoverage(final GridCoverage replacement, final 
RenderedImage data, final int band) {
         coverage     = replacement;
+        gridGeometry = replacement.getGridGeometry();
+        crs          = gridGeometry.isDefined(GridGeometry.CRS) ? 
gridGeometry.getCoordinateReferenceSystem() : null;
+        width        = data.getWidth();
+        height       = data.getHeight();
         metadata     = null;
+        nodataText   = "null";
+        nodataValue  = Double.NaN;
+        final SampleDimension sd = replacement.getSampleDimensions().get(band);
+        final NumberRange<?> range = sd.getSampleRange().orElse(null);
+        if (range != null) {
+            try {
+                for (final Number nodata : 
sd.forConvertedValues(false).getNoDataValues()) {
+                    if (!range.containsAny(nodata)) {
+                        nodataValue = nodata.doubleValue();
+                        return nodata;
+                    }
+                }
+            } catch (IllegalStateException e) {
+                listeners.warning(e);
+            }
+            if (range.containsAny(DEFAULT_NODATA)) {
+                nodataValue = DEFAULT_NODATA;
+            }
+        }
+        return nodataValue;
     }
 
     /**
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/StoreProvider.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/StoreProvider.java
index 86bca8e59c..feb70066ca 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/StoreProvider.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/StoreProvider.java
@@ -128,6 +128,10 @@ cellsize:           if 
(!header.containsKey(Store.CELLSIZE)) {
      */
     @Override
     public DataStore open(final StorageConnector connector) throws 
DataStoreException {
-        return new Store(this, connector);
+        if (isWritable(connector)) {
+            return new WritableStore(this, connector);
+        } else {
+            return new Store(this, connector);
+        }
     }
 }
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/WritableStore.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/WritableStore.java
new file mode 100644
index 0000000000..7dd32f37a0
--- /dev/null
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/ascii/WritableStore.java
@@ -0,0 +1,270 @@
+/*
+ * 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.internal.storage.ascii;
+
+import java.util.Map;
+import java.util.LinkedHashMap;
+import java.io.IOException;
+import java.awt.image.DataBuffer;
+import java.awt.image.RenderedImage;
+import java.awt.geom.AffineTransform;
+import org.opengis.coverage.grid.SequenceType;
+import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.referencing.operation.Matrix;
+import org.opengis.referencing.operation.MathTransform;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.coverage.grid.GridCoverage;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.storage.StorageConnector;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.DataStoreReferencingException;
+import org.apache.sis.storage.WritableGridCoverageResource;
+import org.apache.sis.internal.storage.WritableResourceSupport;
+import org.apache.sis.internal.storage.io.ChannelDataInput;
+import org.apache.sis.internal.storage.io.ChannelDataOutput;
+import org.apache.sis.referencing.operation.matrix.Matrices;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.storage.IncompatibleResourceException;
+import org.apache.sis.image.PixelIterator;
+import org.apache.sis.util.CharSequences;
+import org.apache.sis.util.StringBuilders;
+
+
+/**
+ * An ASCII Grid store with writing capabilities.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.2
+ * @since   1.2
+ * @module
+ */
+final class WritableStore extends Store implements 
WritableGridCoverageResource {
+    /**
+     * The line separator for writing the ASCII file.
+     */
+    private final String lineSeparator;
+
+    /**
+     * Creates a new ASCII Grid store from the given file, URL or stream.
+     *
+     * @param  provider   the factory that created this {@code DataStore} 
instance, or {@code null} if unspecified.
+     * @param  connector  information about the storage (URL, stream, 
<i>etc</i>).
+     * @throws DataStoreException if an error occurred while opening the 
stream.
+     */
+    public WritableStore(final StoreProvider provider, final StorageConnector 
connector) throws DataStoreException {
+        super(provider, connector);
+        lineSeparator = System.lineSeparator();
+    }
+
+    /**
+     * Returns whether this store is read-only.
+     */
+    @Override
+    boolean isReadOnly() {
+        return false;
+    }
+
+    /**
+     * Returns an estimation of how close the "CRS to grid" transform is to 
integer values.
+     * This is used for choosing whether to map pixel centers or pixel centers.
+     */
+    private static double distanceFromIntegers(final MathTransform gridToCRS) 
throws TransformException {
+        final Matrix m = MathTransforms.getMatrix(gridToCRS.inverse());
+        if (m != null && Matrices.isAffine(m)) {
+            final int last = m.getNumCol() - 1;
+            double sum = 0;
+            for (int j=0; j<last; j++) {
+                final double e = m.getElement(j, last);
+                sum += Math.abs(Math.rint(e) - e);
+            }
+            return sum;
+        }
+        return Double.NaN;
+    }
+
+    /**
+     * Gets the coefficients of the affine transform.
+     *
+     * @param  header  the map where to put the affine transform coefficients.
+     * @param  gg      the grid geometry from which to get the affine 
transform.
+     * @param  h       set of helper methods.
+     * @return the iteration order (e.g. from left to right, then top to 
bottom).
+     * @throws DataStoreException if the header can not be written.
+     */
+    private static SequenceType getAffineCoefficients(
+            final Map<String,Object> header, final GridGeometry gg,
+            final WritableResourceSupport h) throws DataStoreException
+    {
+        String xll = XLLCORNER;
+        String yll = YLLCORNER;
+        MathTransform gridToCRS = gg.getGridToCRS(PixelInCell.CELL_CORNER);
+        try {
+            final MathTransform alternative = 
gg.getGridToCRS(PixelInCell.CELL_CENTER);
+            if (distanceFromIntegers(alternative) < 
distanceFromIntegers(gridToCRS)) {
+                gridToCRS = alternative;
+                xll = XLLCENTER;
+                yll = YLLCENTER;
+            }
+        } catch (TransformException e) {
+            throw new DataStoreReferencingException(h.canNotWrite(), e);
+        }
+        final AffineTransform at = h.getAffineTransform2D(gg.getExtent(), 
gridToCRS);
+        if (at.getShearX() != 0 || at.getShearY() != 0) {
+            throw new 
IncompatibleResourceException(h.rotationNotSupported(StoreProvider.NAME));
+        }
+        double scaleX =  at.getScaleX();
+        double scaleY = -at.getScaleY();
+        double x = at.getTranslateX();
+        double y = at.getTranslateY();
+        if (scaleX > 0 && scaleY > 0) {
+            y -= scaleY * (Integer) header.get(NROWS);
+        } else {
+            /*
+             * TODO: future version could support other signs, provided that
+             * we implement `PixelIterator` for other `SequenceType` values.
+             */
+            throw new IncompatibleResourceException(h.canNotWrite());
+        }
+
+        header.put(xll, x);
+        header.put(yll, y);
+        if (scaleX == scaleY) {
+            header.put(CELLSIZE, scaleX);
+        } else {
+            header.put(CELLSIZES[0], scaleX);
+            header.put(CELLSIZES[1], scaleY);
+        }
+        return SequenceType.LINEAR;
+    }
+
+    /**
+     * Writes the content of the given map as the header of ASCII Grid file.
+     */
+    private void writeHeader(final Map<String,Object> header, final 
ChannelDataOutput out) throws IOException {
+        int maxKeyLength = 0;
+        int maxValLength = 0;
+        for (final Map.Entry<String,Object> entry : header.entrySet()) {
+            final String text = entry.getValue().toString();
+            entry.setValue(text);
+            maxValLength = Math.max(maxValLength, text.length());
+            maxKeyLength = Math.max(maxKeyLength, entry.getKey().length());
+        }
+        for (final Map.Entry<String,Object> entry : header.entrySet()) {
+            String text = entry.getKey();
+            write(text, out);
+            write(CharSequences.spaces(maxKeyLength - text.length() + 1), out);
+            text = (String) entry.getValue();
+            write(CharSequences.spaces(maxValLength - text.length()), out);
+            write(text, out);
+            write(lineSeparator, out);
+        }
+    }
+
+    /**
+     * Writes a new coverage in the data store for this resource. If a 
coverage already exists for this resource,
+     * then it will be overwritten only if the {@code TRUNCATE} or {@code 
UPDATE} option is specified.
+     *
+     * @param  coverage  new data to write in the data store for this resource.
+     * @param  options   configuration of the write operation.
+     * @throws DataStoreException if an error occurred while writing data in 
the underlying data store.
+     */
+    @Override
+    public synchronized void write(GridCoverage coverage, final Option... 
options) throws DataStoreException {
+        final WritableResourceSupport h = new WritableResourceSupport(this, 
options);   // Does argument validation.
+        final ChannelDataInput input = input().input;
+        final int band = 0;                                 // May become 
configurable in a future version.
+        try {
+            if (!h.replace(input)) {
+                coverage = h.update(coverage);
+            }
+            final RenderedImage data = coverage.render(null);               // 
Fail if not two-dimensional.
+            final Map<String,Object> header = new LinkedHashMap<>();
+            header.put(NCOLS, data.getWidth());
+            header.put(NROWS, data.getHeight());
+            final SequenceType order = getAffineCoefficients(header, 
coverage.getGridGeometry(), h);
+            /*
+             * Open the destination channel only after the coverage has been 
validated by above method calls.
+             * After this point we should not have any validation errors. 
Write the nodata value even if it is
+             * "NaN" because the default is -9999, and we need to overwrite 
that default if it can not be used.
+             */
+            final ChannelDataOutput out = h.channel(input);
+            final Number nodataValue = setCoverage(coverage, data, band);
+            header.put(NODATA_VALUE, nodataValue);
+            writeHeader(header, out);
+            /*
+             * Writes all sample values.
+             */
+            final float  nodataAsFloat  = nodataValue.floatValue();
+            final double nodataAsDouble = nodataValue.doubleValue();
+            final StringBuilder buffer  = new StringBuilder();
+            final PixelIterator it      = new 
PixelIterator.Builder().setIteratorOrder(order).create(data);
+            final int dataType          = it.getDataType().toDataBufferType();
+            final int width             = it.getDomain().width;
+            int remaining = width;
+            while (it.next()) {
+                switch (dataType) {
+                    case DataBuffer.TYPE_DOUBLE: {
+                        double value = it.getSampleDouble(band);
+                        if (Double.isNaN(value)) {
+                            value = nodataAsDouble;
+                        }
+                        buffer.append(value);
+                        StringBuilders.trimFractionalPart(buffer);
+                        break;
+                    }
+                    case DataBuffer.TYPE_FLOAT: {
+                        float value = it.getSampleFloat(band);
+                        if (Float.isNaN(value)) {
+                            value = nodataAsFloat;
+                        }
+                        buffer.append(value);
+                        StringBuilders.trimFractionalPart(buffer);
+                        break;
+                    }
+                    default: {
+                        buffer.append(it.getSample(band));
+                        break;
+                    }
+                }
+                write(buffer, out);
+                buffer.setLength(0);
+                if (--remaining != 0) {
+                    out.writeByte(' ');
+                } else {
+                    write(lineSeparator, out);
+                    remaining = width;
+                }
+            }
+            out.flush();
+            writePRJ();
+        } catch (IOException e) {
+            closeOnError(e);
+            throw new DataStoreException(e);
+        }
+    }
+
+    /**
+     * Writes the given text to the output. All characters must be US-ASCII 
(this is not verified).
+     */
+    private static void write(final CharSequence text, final ChannelDataOutput 
out) throws IOException {
+        final int length = text.length();
+        out.ensureBufferAccepts(length);
+        for (int i=0; i<length; i++) {
+            out.buffer.put((byte) text.charAt(i));
+        }
+    }
+}
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/ChannelDataInput.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/ChannelDataInput.java
index 19d918af63..335667359b 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/ChannelDataInput.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/ChannelDataInput.java
@@ -108,13 +108,14 @@ public class ChannelDataInput extends ChannelData {
 
     /**
      * Returns the length of the stream (in bytes), or -1 if unknown.
+     * The length is relative to the channel position at {@linkplain 
#ChannelDataInput construction time}.
      *
-     * @return the length of the stream (in bytes), or -1 if unknown.
+     * @return the length of the stream (in bytes) relative to {@link 
#channelOffset}, or -1 if unknown.
      * @throws IOException if an error occurred while fetching the stream 
length.
      */
     public final long length() throws IOException {     // Method signature 
must match ImageInputStream.length().
         if (channel instanceof SeekableByteChannel) {
-            return ((SeekableByteChannel) channel).size();
+            return Math.subtractExact(((SeekableByteChannel) channel).size(), 
channelOffset);
         }
         return -1;
     }
@@ -923,4 +924,22 @@ public class ChannelDataInput extends ChannelData {
         }
         clearBitOffset();
     }
+
+    /**
+     * Empties the buffer and reset the channel position at the beginning of 
the stream.
+     * This method is similar to {@code seek(0)} except that the buffer 
content is discarded.
+     *
+     * @return {@code true} on success, or {@code false} if it is not possible 
to reset the position.
+     * @throws IOException if the stream can not be moved to the original 
position.
+     */
+    public final boolean rewind() throws IOException {
+        if (channel instanceof SeekableByteChannel) {
+            ((SeekableByteChannel) channel).position(channelOffset);
+            buffer.clear().limit(0);
+            bufferOffset = 0;
+            clearBitOffset();
+            return true;
+        }
+        return false;
+    }
 }
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/ChannelDataOutput.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/ChannelDataOutput.java
index bf31ecc424..84244c68df 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/ChannelDataOutput.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/ChannelDataOutput.java
@@ -60,7 +60,7 @@ import static 
org.apache.sis.util.ArgumentChecks.ensureBetween;
  *
  * @author  Rémi Maréchal (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.5
+ * @version 1.2
  * @since   0.5
  * @module
  */
@@ -98,7 +98,7 @@ public class ChannelDataOutput extends ChannelData implements 
Flushable {
      * @param  n  the minimal number of additional bytes that the {@linkplain 
#buffer buffer} shall accept.
      * @throws IOException if an error occurred while writing to the channel.
      */
-    private void ensureBufferAccepts(final int n) throws IOException {
+    public final void ensureBufferAccepts(final int n) throws IOException {
         final int capacity = buffer.capacity();
         assert n >= 0 && n <= capacity : n;
         int after = buffer.position() + n;
diff --git 
a/storage/sis-storage/src/main/java/org/apache/sis/storage/WritableGridCoverageResource.java
 
b/storage/sis-storage/src/main/java/org/apache/sis/storage/WritableGridCoverageResource.java
index 254884b6e3..b4a3337067 100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/storage/WritableGridCoverageResource.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/storage/WritableGridCoverageResource.java
@@ -43,8 +43,8 @@ public interface WritableGridCoverageResource extends 
GridCoverageResource {
      * Other options may be defined by the {@linkplain DataStoreProvider} of 
specific formats.</p>
      *
      * @author  Johann Sorel (Geomatys)
-     * @version 1.0
-     * @since   1.0
+     * @version 1.2
+     * @since   1.2
      * @module
      */
     interface Option {}
@@ -55,21 +55,23 @@ public interface WritableGridCoverageResource extends 
GridCoverageResource {
      * This {@code CommonOption} enumeration provides options that do not 
depend on the data store.
      *
      * @author  Johann Sorel (Geomatys)
-     * @version 1.0
-     * @since   1.0
+     * @version 1.2
+     * @since   1.2
      * @module
      */
     enum CommonOption implements Option {
         /**
          * Instructs the write operation to replace existing coverage if one 
exists.
-         * By default the {@linkplain #write write operation} does not 
overwrite existing data.
+         * By default (when no option is specified) the {@linkplain #write 
write operation}
+         * will only add new coverages and never modify existing ones.
          * If this option is specified, then there is a choice:
          *
-         * <ul>
-         *   <li>If a coverage already exists in the {@link 
GridCoverageResource}, then it will be erased.
-         *       The existing data will be replaced by the new coverage.</li>
+         * <ul class="verbose">
+         *   <li>If a coverage already exists in the {@link 
GridCoverageResource}, then it will be deleted.
+         *       The existing coverage will be replaced by the new coverage.
+         *       The old and new coverages may have different grid 
geometries.</li>
          *   <li>If there are no existing coverages in the {@link 
GridCoverageResource},
-         *       then the new coverage will be inserted as if this option was 
not provided.</li>
+         *       then the new coverage will be added as if this option was not 
provided.</li>
          * </ul>
          *
          * This option is mutually exclusive with {@link #UPDATE}.
@@ -77,20 +79,20 @@ public interface WritableGridCoverageResource extends 
GridCoverageResource {
         REPLACE,
 
         /**
-         * Updates or appends existing coverage with new data.
+         * Instructs the write operation to update existing coverage if one 
exists.
          * If this option is specified, then there is a choice:
          *
-         * <ul>
+         * <ul class="verbose">
          *   <li>If a coverage already exists in the {@link 
GridCoverageResource}, then:
          *     <ul>
-         *       <li>Areas of the provided {@link GridCoverage} that are 
within the existing {@link GridGeometry}
-         *           will overwrite the existing data.</li>
-         *       <li>Areas outside the existing {@link GridGeometry} will 
result in expanding the grid geometry
-         *           with the new data.</li>
+         *       <li>Cells of the provided {@link GridCoverage} that are 
within the {@link GridGeometry}
+         *           of the existing coverage will overwrite the existing 
cells. The provided coverage
+         *           may be resampled to the grid geometry of the existing 
coverage in this process.</li>
+         *       <li>Cells outside the {@link GridGeometry} of the existing 
coverage are ignored.</li>
          *     </ul>
          *   </li>
          *   <li>If there are no existing coverages in the {@link 
GridCoverageResource},
-         *       then the new coverage is inserted as if this option was not 
provided.</li>
+         *       then the new coverage will be added as if this option was not 
provided.</li>
          * </ul>
          *
          * This option is mutually exclusive with {@link #REPLACE}.

Reply via email to