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 e97299fd6765b136a4dae4d4c438fd2a7238a526
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Thu Sep 21 18:35:37 2023 +0200

    Initial GeoKeys support.
    
    https://issues.apache.org/jira/browse/SIS-589
---
 .../apache/sis/referencing/IdentifiedObjects.java  |   2 +
 .../org/apache/sis/storage/geotiff/GeoKeys.java    |  13 +
 .../apache/sis/storage/geotiff/GeoKeysWriter.java  | 790 +++++++++++++++++++++
 .../org/apache/sis/storage/geotiff/Writer.java     |  53 +-
 .../sis/storage/geotiff/internal/Resources.java    |  20 +
 .../storage/geotiff/internal/Resources.properties  |   4 +
 .../geotiff/internal/Resources_fr.properties       |   4 +
 .../apache/sis/storage/geotiff/GeoKeysTest.java    |  10 +
 .../org/apache/sis/storage/geotiff/WriterTest.java |  50 +-
 .../apache/sis/storage/base/MetadataBuilder.java   |   6 +-
 .../apache/sis/storage/base/MetadataFetcher.java   |  56 ++
 .../main/org/apache/sis/util/resources/Errors.java |   5 +
 .../apache/sis/util/resources/Errors.properties    |   1 +
 .../apache/sis/util/resources/Errors_fr.properties |   1 +
 14 files changed, 994 insertions(+), 21 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/IdentifiedObjects.java
 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/IdentifiedObjects.java
index d4b50835d8..db3442e7c3 100644
--- 
a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/IdentifiedObjects.java
+++ 
b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/referencing/IdentifiedObjects.java
@@ -35,6 +35,7 @@ import 
org.opengis.referencing.operation.ConcatenatedOperation;
 import org.apache.sis.util.Static;
 import org.apache.sis.util.CharSequences;
 import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.OptionalCandidate;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.xml.IdentifierSpace;
 import org.apache.sis.util.internal.Strings;
@@ -556,6 +557,7 @@ public final class IdentifiedObjects extends Static {
      *
      * @since 0.7
      */
+    @OptionalCandidate
     public static Integer lookupEPSG(final IdentifiedObject object) throws 
FactoryException {
         Integer code = null;
         if (object != null) {
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeys.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeys.java
index 2740943ba1..2762793adc 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeys.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeys.java
@@ -105,6 +105,19 @@ final class GeoKeys {
     /** For user-defined CRS.  */ public static final short VerticalDatum      
    = 4098;
     /** For vertical axis.     */ public static final short VerticalUnits      
    = 4099;
 
+    /**
+     * Number of keys. Because keys cannot be repeated, this is the maximal
+     * number of entries that {@link GeoKeysWriter#keyDirectory} can contain.
+     * This value is verified by the {@code GeoKeysTest.verifyNumKeys()}.
+     */
+    static final int NUM_KEYS = 46;
+
+    /**
+     * Number of parameters that are of type {@code double}.
+     * This is the maximal length of {@link GeoKeysWriter#doubleParams}.
+     */
+    static final int NUM_DOUBLES = 25;
+
     /**
      * Returns the name of the given key. Implementation of this method is 
inefficient,
      * but it should rarely be invoked (mostly for formatting error messages).
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeysWriter.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeysWriter.java
new file mode 100644
index 0000000000..38f00f0067
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/GeoKeysWriter.java
@@ -0,0 +1,790 @@
+/*
+ * 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.List;
+import java.util.EnumMap;
+import javax.measure.Unit;
+import javax.measure.quantity.Angle;
+import javax.measure.quantity.Length;
+import javax.measure.UnitConverter;
+import javax.measure.IncommensurableException;
+import static javax.imageio.plugins.tiff.GeoTIFFTagSet.TAG_GEO_ASCII_PARAMS;
+import static javax.imageio.plugins.tiff.GeoTIFFTagSet.TAG_GEO_DOUBLE_PARAMS;
+import org.opengis.util.FactoryException;
+import org.opengis.metadata.Identifier;
+import org.opengis.metadata.spatial.CellGeometry;
+import org.opengis.referencing.IdentifiedObject;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.opengis.referencing.crs.GeodeticCRS;
+import org.opengis.referencing.crs.ProjectedCRS;
+import org.opengis.referencing.crs.VerticalCRS;
+import org.opengis.referencing.cs.AxisDirection;
+import org.opengis.referencing.cs.CoordinateSystem;
+import org.opengis.referencing.cs.CartesianCS;
+import org.opengis.referencing.cs.EllipsoidalCS;
+import org.opengis.referencing.datum.Ellipsoid;
+import org.opengis.referencing.datum.PrimeMeridian;
+import org.opengis.referencing.datum.GeodeticDatum;
+import org.opengis.referencing.datum.VerticalDatum;
+import org.opengis.referencing.datum.PixelInCell;
+import org.opengis.referencing.operation.Conversion;
+import org.opengis.referencing.operation.Matrix;
+import org.opengis.parameter.GeneralParameterValue;
+import org.opengis.parameter.ParameterValue;
+import org.apache.sis.measure.Units;
+import org.apache.sis.util.ArraysExt;
+import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.internal.Strings;
+import org.apache.sis.util.internal.CollectionsExt;
+import org.apache.sis.referencing.CRS;
+import org.apache.sis.referencing.IdentifiedObjects;
+import org.apache.sis.referencing.cs.CoordinateSystems;
+import org.apache.sis.referencing.operation.matrix.Matrices;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.referencing.util.ReferencingUtilities;
+import org.apache.sis.referencing.util.WKTKeywords;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.IncompleteGridGeometryException;
+import org.apache.sis.storage.base.MetadataFetcher;
+import org.apache.sis.storage.geotiff.internal.Resources;
+import org.apache.sis.metadata.iso.citation.Citations;
+
+
+/**
+ * Helper class for writing GeoKeys.
+ * This class decomposes a CRS into entries written by calls to {@code 
writeShort(…)}, {@code writeDouble(…)}
+ * or {@code writeString(…)} methods. The order in which those methods are 
invoked matter, because the GeoTIFF
+ * specification requires that keys are sorted in increasing order. We do not 
sort them after writing.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ */
+final class GeoKeysWriter {
+    /**
+     * Size of the model transformation matrix, in number of rows and columns.
+     * This size is fixed by the GeoTIFF specification.
+     */
+    private static final int MATRIX_SIZE = 4;
+
+    /**
+     * The store for which we are writing GeoTIFF keys.
+     * Used for logging warnings.
+     */
+    private final GeoTiffStore store;
+
+    /**
+     * Overall configuration of the GeoTIFF file, or {@code null} if none.
+     * This is the value to store in {@link GeoKeys#Citation}.
+     */
+    private String citation;
+
+    /**
+     * The coordinate reference system of the grid geometry, or {@code null} 
if none.
+     * This CRS may contain more dimensions than the 3 dimensions allowed by 
GeoTIFF.
+     * Axis order and axis directions may be different than the (east, north, 
up) directions mandated by GeoTIFF.
+     */
+    private CoordinateReferenceSystem fullCRS;
+
+    /**
+     * Whether the coordinate system has a vertical component.
+     */
+    private boolean hasVerticalAxis;
+
+    /**
+     * The conversion from grid coordinates to full CRS, which determines the 
model transformation.
+     * This conversion may operate on more dimensions than the three 
dimensions mandated by GeoTIFF.
+     * Furthermore the output may need to be reordered for the (east, north, 
up) axis order mandated by GeoTIFF.
+     *
+     * @see #modelTransformation()
+     */
+    private Matrix gridToCRS;
+
+    /**
+     * Whether the raster model is "point" or "area".
+     * The default is area ({@code false}).
+     */
+    private boolean isPoint;
+
+    /**
+     * Units of measurement found by the analysis of coordinate system axes.
+     * Should be filled as soon as possible because it determines also the
+     * units of measurement to use for encoding map projection parameters.
+     */
+    private final EnumMap<UnitKey, Unit<?>> units;
+
+    /**
+     * The key directory, including the header.
+     * Each entry is a record of {@value GeoKeysLoader#ENTRY_LENGTH} values.
+     * The first record is a header of the same length.
+     *
+     * @see #keyCount
+     * @see #keyDirectory()
+     */
+    private final short[] keyDirectory;
+
+    /**
+     * Number of valid elements in {@link #keyDirectory}, not counting the 
header.
+     */
+    private int keyCount;
+
+    /**
+     * Parameters to encode as IEEE-754 floating point values.
+     *
+     * @see #doubleCount
+     * @see #doubleParams()
+     */
+    private final double[] doubleParams;
+
+    /**
+     * Number of valid elements in {@link #doubleParams}.
+     */
+    private int doubleCount;
+
+    /**
+     * Parameters to encode as ASCII character strings.
+     * Strings are separated by the {@value GeoKeysLoader#SEPARATOR} character.
+     *
+     * @see #asciiParams()
+     */
+    private final StringBuilder asciiParams;
+
+    /**
+     * If multiple names are packed in a single citation GeoKey, the citation 
key of the main object.
+     * This is a sub-encoding applied inside {@link #asciiParams} for the 
citation. Example:
+     *
+     * <pre>GCS Name=Moon 
2000|Datum=D_Moon_2000|Ellipsoid=Moon_2000_IAU_IAG|Primem=Reference_Meridian|AUnits=Decimal_Degree|</pre>
+     *
+     * Above sub-encoding is applied only if necessary. In such case, this 
field is the first key to prepend.
+     * Currently the only accepted value is: "GCS Name".
+     */
+    private String citationMainKey;
+
+    /**
+     * Index in the {@link #keyDirectory} array where the length (in number of 
characters) of current citation is stored.
+     * The {@code keyDirectory[citationLengthIndex]} value is the number of 
characters, excluding the trailing separator.
+     * The {@code keyDirectory[citationLengthIndex+1]} value is the offset 
where the citation starts.
+     * This information is used for modifying in-place the ASCII entry of a 
citation for inserting more names.
+     */
+    private int citationLengthIndex;
+
+    /**
+     * Prepares information for writing GeoTIFF tags for the given grid 
geometry.
+     * Caller shall invoke {@link #write(GridGeometry, MetadataFetcher)} 
exactly once after construction.
+     *
+     * @param  store     the store for which to write GeoTIFF keys.
+     */
+    GeoKeysWriter(final GeoTiffStore store) {
+        this.store   = store;
+        units        = new EnumMap<>(UnitKey.class);
+        asciiParams  = new StringBuilder(100);
+        doubleParams = new double[GeoKeys.NUM_DOUBLES];
+        keyDirectory = new short[(GeoKeys.NUM_KEYS + 1) * 
GeoKeysLoader.ENTRY_LENGTH];
+        keyDirectory[0] = 1;            // Directory version.
+        keyDirectory[1] = 1;            // Revision major number. We implement 
GeoTIFF 1.1.
+        keyDirectory[2] = 1;            // Revision minor number. We implement 
GeoTIFF 1.1.
+    }
+
+    /**
+     * Writes GeoTIFF keys for the given grid geometry.
+     * This method should be invoked exactly once.
+     *
+     * @param  store     the store for which to write GeoTIFF keys.
+     * @param  grid      grid geometry of the image to write.
+     * @param  metadata  overall configuration information.
+     * @throws FactoryException if an error occurred while fetching the EPSG 
code.
+     * @throws ArithmeticException if a short value cannot be stored as an 
unsigned 16 bits integer.
+     * @throws IncommensurableException if a measure uses an unexpected unit 
of measurement.
+     * @throws IncompleteGridGeometryException if the grid geometry is 
incomplete.
+     */
+    final void write(final GridGeometry grid, final MetadataFetcher<?> 
metadata)
+                  throws FactoryException, IncommensurableException
+    {
+        citation  = CollectionsExt.first(metadata.transformationDimension);
+        isPoint   = 
CellGeometry.POINT.equals(CollectionsExt.first(metadata.cellGeometry));
+        gridToCRS = MathTransforms.getMatrix(grid.getGridToCRS(isPoint ? 
PixelInCell.CELL_CENTER : PixelInCell.CELL_CORNER));
+        if (gridToCRS == null) {
+            
warning(resources().getString(Resources.Keys.CanNotEncodeNonLinearModel), null);
+        }
+        if (grid.isDefined(GridGeometry.CRS)) {
+            fullCRS = grid.getCoordinateReferenceSystem();
+            final CoordinateReferenceSystem crs = 
CRS.getHorizontalComponent(fullCRS);
+            if ((crs instanceof ProjectedCRS && writeCRS((ProjectedCRS) crs)) 
||
+                (crs instanceof GeodeticCRS  && writeCRS((GeodeticCRS) crs, 
false)))
+            {
+                writeCRS(CRS.getVerticalComponent(fullCRS, true));
+            } else {
+                unsupportedType(fullCRS);
+                writeModelType(GeoCodes.userDefined);
+            }
+        } else {
+            writeModelType(GeoCodes.undefined);
+        }
+    }
+
+    /**
+     * Writes the first keys (model type, raster type, citation).
+     * This method shall be the first write operation, before to write any 
other keys.
+     *
+     * @param  type  value of {@link GeoKeys#ModelType}.
+     */
+    private void writeModelType(final short type) {
+        writeShort(GeoKeys.ModelType, type);
+        writeShort(GeoKeys.RasterType, isPoint ? GeoCodes.RasterPixelIsPoint : 
GeoCodes.RasterPixelIsArea);
+        if (citation != null) {
+            writeString(GeoKeys.Citation, citation);
+            citation = null;
+        }
+    }
+
+    /**
+     * Writes the vertical component of the CRS.
+     * The horizontal component must have been written before this method is 
invoked.
+     *
+     * @param  crs  the CRS to write, or {@code null} if none.
+     * @throws FactoryException if an error occurred while fetching an EPSG 
code.
+     */
+    private void writeCRS(final VerticalCRS crs) throws FactoryException {
+        if (crs != null) {
+            hasVerticalAxis = true;
+            if (writeEPSG(GeoKeys.Vertical, crs)) {
+                writeName(GeoKeys.VerticalCitation, null, crs);
+                addUnits(UnitKey.VERTICAL, crs.getCoordinateSystem());
+                final VerticalDatum datum = crs.getDatum();
+                if (writeEPSG(GeoKeys.VerticalDatum, datum)) {
+                    /*
+                     * OGC requirement 25.5 said "VerticalCitationGeoKey SHALL 
be populated."
+                     * But how? Using the same multiple-names convention as 
for geodetic CRS?
+                     *
+                     * https://github.com/opengeospatial/geotiff/issues/59
+                     */
+                }
+                writeUnit(UnitKey.VERTICAL);
+            }
+        }
+    }
+
+    /**
+     * Writes entries for a geographic or geocentric CRS.
+     * The CRS type is inferred from the coordinate system type.
+     * This method may be invoked for writing the base CRS of a projected CRS.
+     *
+     * @param  crs        the CRS to write.
+     * @param  isBaseCRS  whether to write the base CRS of a projected CRS.
+     * @return whether this method has been able to write the CRS.
+     * @throws FactoryException if an error occurred while fetching an EPSG 
code.
+     * @throws IncommensurableException if a measure uses an unexpected unit 
of measurement.
+     */
+    private boolean writeCRS(final GeodeticCRS crs, final boolean isBaseCRS) 
throws FactoryException, IncommensurableException {
+        final short type;
+        final CoordinateSystem cs = crs.getCoordinateSystem();
+        addUnits(UnitKey.ANGULAR, cs);
+        if (cs instanceof EllipsoidalCS) {
+            type = GeoCodes.ModelTypeGeographic;
+        } else if (isBaseCRS) {
+            
warning(resources().getString(Resources.Keys.CanNotEncodeNonGeographicBase), 
null);
+            return false;
+        } else if (cs instanceof CartesianCS) {
+            type = GeoCodes.ModelTypeGeocentric;
+        } else {
+            unsupportedType(cs);
+            return false;
+        }
+        /*
+         * Start writing GeoTIFF keys for the geodetic CRS, potentially 
followed by datum, prime meridian and ellipsoid
+         * in that order. The order matter because GeoTIFF specification 
requires keys to be sorted in increasing order.
+         * A difficulty is that units of measurement are between prime 
meridian and ellipsoid, and the angular unit is
+         * needed for projected CRS too.
+         */
+        writeModelType(isBaseCRS ? GeoCodes.ModelTypeProjected : type);
+        if (writeEPSG(GeoKeys.GeodeticCRS, crs)) {
+            writeName(GeoKeys.GeodeticCitation, "GCS Name", crs);
+            final GeodeticDatum datum = crs.getDatum();
+            if (writeEPSG(GeoKeys.GeodeticDatum, datum)) {
+                appendName(WKTKeywords.Datum, datum);
+                final PrimeMeridian primem = datum.getPrimeMeridian();
+                final double longitude;
+                if (writeEPSG(GeoKeys.PrimeMeridian, primem)) {
+                    appendName(WKTKeywords.PrimeM, datum);
+                    longitude = primem.getGreenwichLongitude();
+                } else {
+                    longitude = 0;                                      // 
Means "do not write prime meridian".
+                }
+                final Ellipsoid     ellipsoid  = datum.getEllipsoid();
+                final Unit<Length>  axisUnit   = ellipsoid.getAxisUnit();
+                final Unit<?>       linearUnit = 
units.putIfAbsent(UnitKey.LINEAR, axisUnit);
+                final UnitConverter toLinear   = 
axisUnit.getConverterToAny(linearUnit != null ? linearUnit : axisUnit);
+                writeUnit(UnitKey.LINEAR);     // Must be after the `units` 
map have been updated.
+                writeUnit(UnitKey.ANGULAR);
+                if (writeEPSG(GeoKeys.Ellipsoid, ellipsoid)) {
+                    appendName(WKTKeywords.Ellipsoid, ellipsoid);
+                    writeDouble(GeoKeys.SemiMajorAxis, 
toLinear.convert(ellipsoid.getSemiMajorAxis()));
+                    if (ellipsoid.isSphere() || !ellipsoid.isIvfDefinitive()) {
+                        writeDouble(GeoKeys.SemiMinorAxis, 
toLinear.convert(ellipsoid.getSemiMinorAxis()));
+                    } else {
+                        writeDouble(GeoKeys.InvFlattening, 
ellipsoid.getInverseFlattening());
+                    }
+                }
+                if (longitude != 0) {
+                    Unit<Angle> unit = primem.getAngularUnit();
+                    UnitConverter c = 
unit.getConverterToAny(units.getOrDefault(UnitKey.ANGULAR, Units.DEGREE));
+                    writeDouble(GeoKeys.PrimeMeridianLongitude, 
c.convert(longitude));
+                }
+            }
+        } else if (isBaseCRS) {
+            writeUnit(UnitKey.ANGULAR);         // Map projection parameters 
may need this unit.
+        }
+        return true;
+    }
+
+    /**
+     * Writes entries for a projected CRS.
+     * If the CRS is user-specified, then this method writes the geodetic CRS 
first.
+     *
+     * @return whether this method has been able to write the CRS.
+     * @throws FactoryException if an error occurred while fetching an EPSG or 
GeoTIFF code.
+     * @throws IncommensurableException if a measure uses an unexpected unit 
of measurement.
+     */
+    private boolean writeCRS(final ProjectedCRS crs) throws FactoryException, 
IncommensurableException {
+        if (!writeCRS(crs.getBaseCRS(), true)) {
+            return false;
+        }
+        if (writeEPSG(GeoKeys.ProjectedCRS, crs)) {
+            writeName(GeoKeys.ProjectedCitation, null, crs);
+            addUnits(UnitKey.PROJECTED, crs.getCoordinateSystem());
+            final Conversion projection = crs.getConversionFromBase();
+            if (writeEPSG(GeoKeys.Projection, projection)) {
+                final var method = projection.getMethod();
+                final short projCode = getGeoCode(method);
+                writeShort(GeoKeys.ProjMethod, projCode);
+                writeUnit(UnitKey.PROJECTED);
+                switch (projCode) {
+                    case GeoCodes.undefined:   
missingValue(GeoKeys.ProjMethod); return true;
+                    case GeoCodes.userDefined: cannotEncode(0, name(method), 
null); break;
+                    /*
+                     * TODO: GeoTIFF requirement 27.4 said that 
ProjectedCitationGeoKey shall be provided,
+                     * But how? Using the same multiple-names convention ("GCS 
Name") as for geodetic CRS?
+                     *
+                     * https://github.com/opengeospatial/geotiff/issues/59
+                     */
+                }
+            }
+            for (final GeneralParameterValue p : 
projection.getParameterValues().values()) {
+                RuntimeException cause = null;
+                final var descriptor = p.getDescriptor();
+                if (p instanceof ParameterValue<?>) {
+                    final short key = getGeoCode(descriptor);
+                    if (key != GeoCodes.undefined && key != 
GeoCodes.userDefined) {
+                        final var pv = (ParameterValue<?>) p;
+                        final UnitKey type = 
UnitKey.ofProjectionParameter(key);
+                        if (type == UnitKey.LINEAR) {
+                            continue;                   // Skip the "cannot 
encode" warning.
+                        }
+                        if (type != UnitKey.NULL) try {
+                            final Unit<?> unit = units.getOrDefault(type, 
type.defaultUnit());
+                            writeDouble(key, (unit != null) ? 
pv.doubleValue(unit) : pv.doubleValue());
+                            continue;
+                        } catch (IllegalArgumentException | 
IllegalStateException e) {
+                            cause = e;
+                        }
+                    }
+                }
+                cannotEncode(1, name(descriptor), cause);
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Remembers the units of measurement found in all coordinate system axes.
+     * The units are stored in the {@link #units} map.
+     *
+     * @param  main  the main kind of units expected in the coordinate system.
+     * @param  cs    the coordinate system to analyze.
+     */
+    private void addUnits(final UnitKey main, final CoordinateSystem cs) {
+        for (int i = cs.getDimension(); --i >= 0;) {
+            final Unit<?> unit = cs.getAxis(i).getUnit();
+            final UnitKey type = main.validate(unit);
+            if (type != null) {
+                final Unit<?> previous = units.putIfAbsent(type, unit);
+                if (previous != null && !previous.equals(unit)) {
+                    
warning(store.errors().getString(Errors.Keys.HeterogynousUnitsIn_1, name(cs)), 
null);
+                }
+            } else {
+                cannotEncode(2, unit.toString(), null);
+            }
+        }
+    }
+
+    /**
+     * Writes the entries for the specified unit of measurement.
+     * This method should be invoked only once per unit key.
+     *
+     * @param  key  identification of the unit to write.
+     */
+    private void writeUnit(final UnitKey key) {
+        final Unit<?> unit = units.get(key);
+        if (unit != null) {
+            final short epsg = toShortEPSG(Units.getEpsgCode(unit, 
key.isAxis));
+            if (epsg != GeoCodes.userDefined) {
+                writeShort(key.codeKey, epsg);
+            } else if (key.scaleKey != 0) {
+                writeShort(key.codeKey, epsg);
+                writeDouble(key.scaleKey, Units.toStandardUnit(unit));
+            } else {
+                cannotEncode(2, unit.toString(), null);
+            }
+        }
+    }
+
+    /**
+     * Writes the name of the specified object.
+     *
+     * @param  key     the numeric identifier of the GeoTIFF key.
+     * @param  type    type of object for which to write the name, or {@code 
null} for no multiple-names citation.
+     * @param  object  the object for which to write the name.
+     */
+    private void writeName(final short key, final String type, final 
IdentifiedObject object) {
+        String name = IdentifiedObjects.getName(object, null);
+        if (name == null) {
+            name = "Unnamed";
+        }
+        writeString(key, name);
+        citationMainKey = type;
+        citationLengthIndex = keyCount * GeoKeysLoader.ENTRY_LENGTH + 2;       
 // Length is the field #2.
+    }
+
+    /**
+     * Writes the name of the specified object using the "multi-names in 
single citation" convention.
+     * The {@link #writeName(short, String, IdentifiedObject)} method must 
have been invoked for the
+     * main object before this method call.
+     *
+     * @param  type    type of object for which to write the name.
+     * @param  object  the object for which to write the name.
+     */
+    private void appendName(final String type, final IdentifiedObject object) {
+        final String name = IdentifiedObjects.getName(object, null);
+        if (name != null) {
+            int i      = citationLengthIndex;
+            int offset = Short.toUnsignedInt(keyDirectory[i+1]);
+            int length = Short.toUnsignedInt(keyDirectory[i]);
+            int start  = length;
+            if (citationMainKey != null) {
+                final String value = citationMainKey + '=';
+                asciiParams.insert(offset, value);
+                length += value.length();
+                citationMainKey = null;
+            }
+            final String value = GeoKeysLoader.SEPARATOR + type + '=' + name;
+            asciiParams.insert(offset + length, value);
+            keyDirectory[i] = toShort(length += value.length());
+            /*
+             * After we inserted the name, adjust the offsets of all ASCII 
entries written after the citation.
+             * Note that in the following loop, (i < limit) must be tested 
before increment because the limit
+             * is inclusive. This loop will do nothing with GeoTIFF 1.1 
because there is no other ASCII entry
+             * after citation, but we keep it in case a future GeoTIFF version 
adds more ASCII entries.
+             */
+            final int shift = length - start;
+            final int limit = keyCount * GeoKeysLoader.ENTRY_LENGTH;    // 
Inclusive.
+            i++;                                                        // 
Offset is the field after length.
+            while (i < limit) {
+                i += GeoKeysLoader.ENTRY_LENGTH;                        // 
Really after (i < limit) test.
+                if (keyDirectory[i-2] == (short) TAG_GEO_ASCII_PARAMS) {
+                    offset = Short.toUnsignedInt(keyDirectory[i]);
+                    keyDirectory[i] = toShort(offset + shift);
+                }
+            }
+        }
+    }
+
+    /**
+     * Fetches the GeoTIFF code of the given object. If {@code null}, returns 
{@link GeoCodes#undefined}.
+     * If the object has no GeTIFF identifier, returns {@value 
GeoCodes#userDefined}.
+     *
+     * @param  object  the object for which to get the GeoTIFF code.
+     * @return the GeoTIFF code, or {@link GeoCodes#undefined} or {@link 
GeoCodes#userDefined} if none.
+     * @throws FactoryException if an error occurred while fetching the 
GeoTIFF code.
+     */
+    private short getGeoCode(final IdentifiedObject object) throws 
FactoryException {
+        if (object == null) {
+            return GeoCodes.undefined;
+        }
+        final Identifier id = IdentifiedObjects.getIdentifier(object, 
Citations.GEOTIFF);
+        if (id != null) try {
+            return Short.parseShort(id.getCode());
+        } catch (NumberFormatException e) {
+            warning(store.errors().getString(Errors.Keys.CanNotParse_1, 
IdentifiedObjects.toString(id)), e);
+        }
+        return GeoCodes.userDefined;
+    }
+
+    /**
+     * Writes the EPSG code of the given object, or {@value 
GeoCodes#userDefined} if none.
+     * Returns whether the caller should write user-defined object in 
replacement or in addition to EPSG code.
+     *
+     * @param  key     the numeric identifier of the GeoTIFF key.
+     * @param  object  the object for which to get the EPSG code.
+     * @return whether the caller should write user-defined object.
+     * @throws FactoryException if an error occurred while fetching the EPSG 
code.
+     */
+    private boolean writeEPSG(final short key, final IdentifiedObject object) 
throws FactoryException {
+        if (object == null) {
+            writeShort(key, GeoCodes.undefined);
+            missingValue(key);
+            return false;
+        }
+        /*
+         * Note `lookupEPSG(…)` will return a value only if the axes have the 
same order and units.
+         * We could ignore axis order because GeoTIFF specification fixes it 
to (east, north, up),
+         * but we shall not ignore axis units. The `IdentifiedObjectFinder` 
API does not currently
+         * allow ignoring only axis order, so we fallback on strict equality 
(ignoring metadata).
+         * This is not necessarily a bad thing, because there is a possibility 
that future GeoTIFF
+         * specifications become stricter, so we are already "strict" 
regarding usages of EPSG codes.
+         */
+        final short epsg = toShortEPSG(IdentifiedObjects.lookupEPSG(object));
+        writeShort(key, epsg);
+        return (epsg == GeoCodes.userDefined);
+    }
+
+    /**
+     * Returns an optional EPSG code as a short code that can be stored in a 
GeoTIFF key.
+     *
+     * @param  epsg  the optional EPSG code.
+     * @return the code as a short integer, or {@link GeoCodes#userDefined} if 
none.
+     *
+     * @see #toShort(int)
+     */
+    private static short toShortEPSG(final Integer epsg) {
+        if (epsg != null) {
+            final int c = epsg;
+            if (c >= 1024 && c <= 32766) {      // This range is defined by 
the GeoTIFF specification.
+                return (short) c;
+            }
+        }
+        return GeoCodes.userDefined;
+    }
+
+    /**
+     * Appends an entry for a 16 bits integer value. This method uses a TIFF 
tag location of 0,
+     * which implies that value is {@code SHORT}, and is contained in the 
"ValueOffset" entry
+     *
+     * @param  key    the numeric identifier of the GeoTIFF key.
+     * @param  value  the value to store.
+     */
+    private void writeShort(final short key, final short value) {
+        int i = ++keyCount * GeoKeysLoader.ENTRY_LENGTH;
+        keyDirectory[i++] = key;            // Key identifier.
+        keyDirectory[++i] = 1;              // Number of values in this key.
+        keyDirectory[++i] = value;          // Value offset. In this 
particular case, contains directly the value.
+    }
+
+    /**
+     * Appends an entry for a floating point value.
+     *
+     * @param  key    the numeric identifier of the Key.
+     * @param  value  the value to store.
+     */
+    private void writeDouble(final short key, final double value) {
+        int i = ++keyCount * GeoKeysLoader.ENTRY_LENGTH;
+        keyDirectory[i++] = key;                                // Key 
identifier.
+        keyDirectory[i++] = (short) TAG_GEO_DOUBLE_PARAMS;      // TIFF tag 
location.
+        keyDirectory[i++] = 1;                                  // Number of 
values in this key.
+        keyDirectory[i  ] = toShort(doubleCount);
+        doubleParams[doubleCount++] = value;
+    }
+
+    /**
+     * Appends an entry for a character string.
+     *
+     * @param  key    the numeric identifier of the GeoTIFF key.
+     * @param  value  the value to store.
+     */
+    private void writeString(final short key, final String value) {
+        int i = ++keyCount * GeoKeysLoader.ENTRY_LENGTH;
+        keyDirectory[i++] = key;                                // Key 
identifier.
+        keyDirectory[i++] = (short) TAG_GEO_ASCII_PARAMS;       // TIFF tag 
location.
+        keyDirectory[i++] = toShort(value.length());            // Number of 
values in this key.
+        keyDirectory[i  ] = toShort(asciiParams.length());      // Offset of 
the first character.
+        asciiParams.append(value).append(GeoKeysLoader.SEPARATOR);
+    }
+
+    /**
+     * Ensures that the given value can be represented as an unsigned 16 bits 
integer.
+     *
+     * @param  value  the value to cast to an unsigned short.
+     * @return the value as an unsigned short.
+     * @throws ArithmeticException if the given value can not be stored as an 
unsigned 16 bits integer.
+     *
+     * @see #toShortEPSG(Integer)
+     */
+    private static short toShort(final int value) {
+        if ((value & ~0xFFFF) == 0) {
+            return (short) value;
+        }
+        throw new 
ArithmeticException(Errors.format(Errors.Keys.IntegerOverflow_1, Short.SIZE));
+    }
+
+    /**
+     * {@return the values to write in the "GeoTIFF keys directory" tag}.
+     */
+    final short[] keyDirectory() {
+        if (keyCount == 0) return null;
+        keyDirectory[GeoKeysLoader.ENTRY_LENGTH - 1] = (short) keyCount;
+        return ArraysExt.resize(keyDirectory, (keyCount + 1) * 
GeoKeysLoader.ENTRY_LENGTH);
+    }
+
+    /**
+     * {@return the values to write in the "GeoTIFF double-precision 
parameters" tag}.
+     */
+    final double[] doubleParams() {
+        if (doubleCount == 0) return null;
+        return ArraysExt.resize(doubleParams, doubleCount);
+    }
+
+    /**
+     * {@return the values to write in the "GeoTIFF ASCII strings" tag}.
+     */
+    final List<String> asciiParams() {
+        if (asciiParams.length() == 0) return null;     // TODO: replace by 
isEmpty() with JDK15.
+        return List.of(asciiParams.toString());
+    }
+
+    /**
+     * Returns the coefficients of the affine transform, or {@code null} if 
none.
+     * Array length is fixed to 16 elements, for a 4×4 matrix in row-major 
order.
+     * Axis order is fixed to (longitude, latitude, height).
+     */
+    final double[] modelTransformation() {
+        if (gridToCRS == null) {
+            return null;
+        }
+        /*
+         * The CRS stored in GeoTIFF files have axis directions fixed to 
(east, north, up).
+         * If the CRS of the grid geometry has different axis order, we need 
to adjust the
+         * "grid to CRS" transform.
+         */
+        if (fullCRS != null) {
+            final AxisDirection[] source = 
CoordinateSystems.getAxisDirections(fullCRS.getCoordinateSystem());
+            final AxisDirection[] target = new AxisDirection[hasVerticalAxis ? 
3 : 2];
+            target[0] = AxisDirection.EAST;
+            target[1] = AxisDirection.NORTH;
+            if (hasVerticalAxis) {
+                target[2] = AxisDirection.UP;
+            }
+            gridToCRS = Matrices.createTransform(source, 
target).multiply(gridToCRS);
+            fullCRS   = null;       // For avoiding to do the multiplication 
again.
+        }
+        /*
+         * Copy matrix coefficients. This matrix size is always 4×4, no matter 
the size of the `gridToCRS` matrix.
+         * So we cannot invoke `MatrixSIS.getElements()`.
+         */
+        final double[] cf = new double[MATRIX_SIZE * MATRIX_SIZE];
+        final int lastRow = gridToCRS.getNumRow() - 1;
+        final int lastCol = gridToCRS.getNumCol() - 1;
+        final int maxRow  = Math.min(lastRow, MATRIX_SIZE-1);
+        int offset = 0;
+        for (int row = 0; row < maxRow; row++) {
+            copyRow(gridToCRS, row, lastCol, cf, offset);
+            offset += MATRIX_SIZE;
+        }
+        copyRow(gridToCRS, lastRow, lastCol, cf, MATRIX_SIZE * (MATRIX_SIZE - 
1));
+        return cf;
+    }
+
+    /**
+     * Copies a matrix row into the model transformation array.
+     *
+     * @param gridToCRS  the source of model transformation coefficients.
+     * @param row        row of the matrix to copy.
+     * @param lastCol    value of {@code gridToCRS.getNumCol() - 1}.
+     * @param target     where to write the coefficients.
+     * @param offset     index of the first element to write in the 
destination array.
+     */
+    private static void copyRow(final Matrix gridToCRS, final int row, final 
int lastCol, final double[] target, final int offset) {
+        target[offset + (MATRIX_SIZE - 1)] = gridToCRS.getElement(row, 
lastCol);
+        for (int i = Math.min(lastCol, MATRIX_SIZE-1); --i >= 0;) {
+            target[offset + i] = gridToCRS.getElement(row, i);
+        }
+    }
+
+    /**
+     * Returns the name of the given object. Used for formatting error 
messages.
+     *
+     * @param  object  the object for which to get a name to insert in error 
message.
+     * @return the object name.
+     */
+    private String name(final IdentifiedObject object) {
+        return IdentifiedObjects.getDisplayName(object, store.getLocale());
+    }
+
+    /**
+     * {@return the resources in the current locale}.
+     */
+    private Resources resources() {
+        return Resources.forLocale(store.getLocale());
+    }
+
+    /**
+     * Logs a warning saying that no value is associated to the given key.
+     *
+     * @param  key  the GeoKey for which we found no value.
+     */
+    private void missingValue(final short key) {
+        warning(resources().getString(Resources.Keys.MissingGeoValue_1, 
GeoKeys.name(key)), null);
+    }
+
+    /**
+     * Logs a warning saying that the given object cannot be encoded becasuse 
of its type.
+     *
+     * @param  object  object that cannot be encoded.
+     */
+    private void unsupportedType(final IdentifiedObject object) {
+        warning(resources().getString(Resources.Keys.CanNotEncodeObjectType_1, 
ReferencingUtilities.getInterface(object)), null);
+    }
+
+    /**
+     * Logs a warning saying that an object of the given name cannot be 
encoded.
+     *
+     * @param  type   object type: 0 = operation method, 1 = parameter, 2 = 
unit of measurement.
+     * @param  name   name of the object that cannot be encoded.
+     * @param  cause  the reason why a warning occurred, or {@code null} if 
none.
+     */
+    private void cannotEncode(final int type, final String name, final 
Exception cause) {
+        
warning(resources().getString(Resources.Keys.CanNotEncodeNamedObject_2, type, 
name), cause);
+    }
+
+    /**
+     * Reports a warning that occurred while analyzing the CRS.
+     * This warning may prevent readers to reconstruct the CRS correctly.
+     *
+     * @param  message  the warning message.
+     * @param  cause    the reason why a warning occurred, or {@code null} if 
none.
+     */
+    private void warning(final String message, final Exception cause) {
+        store.listeners().warning(message, cause);
+    }
+
+    /**
+     * Returns a string representation for debugging purpose.
+     *
+     * @return a string representation of this keys writer.
+     */
+    @Override
+    public String toString() {
+        return Strings.toString(getClass(), "citation", citation);
+    }
+}
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
index f86f7bfb61..360504e823 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/Writer.java
@@ -32,10 +32,14 @@ import java.awt.image.SampleModel;
 import java.awt.image.BandedSampleModel;
 import java.awt.image.IndexColorModel;
 import javax.imageio.plugins.tiff.TIFFTag;
+import javax.measure.IncommensurableException;
+import org.opengis.util.FactoryException;
 import org.opengis.metadata.Metadata;
 import org.apache.sis.image.DataType;
 import org.apache.sis.image.ImageProcessor;
+import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.DataStoreReferencingException;
 import org.apache.sis.storage.base.MetadataFetcher;
 import org.apache.sis.io.stream.ChannelDataOutput;
 import org.apache.sis.io.stream.UpdatableWrite;
@@ -46,6 +50,7 @@ import org.apache.sis.util.ArraysExt;
 import org.apache.sis.math.Fraction;
 
 import static javax.imageio.plugins.tiff.BaselineTIFFTagSet.*;
+import static javax.imageio.plugins.tiff.GeoTIFFTagSet.*;
 
 
 /**
@@ -205,17 +210,18 @@ final class Writer extends GeoTIFF implements Flushable {
      * It is caller responsibility to append image overviews if a pyramid is 
wanted.
      *
      * @param  image     the image to encode.
+     * @param  grid      mapping from pixel coordinates to "real world" 
coordinates, or {@code null} if none.
      * @param  metadata  title, author and other information, or {@code null} 
if none.
      * @throws IOException if an error occurred while writing to the output.
      * @throws DataStoreException if the given {@code image} has a property
      *         which is not supported by TIFF specification or by this writer.
      */
-    final void append(final RenderedImage image, final Metadata metadata)
+    final void append(final RenderedImage image, final GridGeometry grid, 
final Metadata metadata)
             throws IOException, DataStoreException
     {
         final TileMatrixWriter tiles;
         try {
-            tiles = writeImageFileDirectory(new ReformattedImage(this, image), 
metadata, false);
+            tiles = writeImageFileDirectory(new ReformattedImage(this, image), 
grid, metadata, false);
         } finally {
             largeTagData.clear();       // For making sure that there is no 
memory retention.
         }
@@ -230,6 +236,7 @@ final class Writer extends GeoTIFF implements Flushable {
      * This separation makes possible to write directories in any order 
compared to pixel data.
      *
      * @param  image       the image for which to write the IFD.
+     * @param  grid        mapping from pixel coordinates to "real world" 
coordinates, or {@code null} if none.
      * @param  metadata    title, author and other information, or {@code 
null} if none.
      * @param  oveverview  whether the image is an overview of another image.
      * @return handler for writing offsets and lengths of the tiles to write.
@@ -237,7 +244,7 @@ final class Writer extends GeoTIFF implements Flushable {
      * @throws DataStoreException if the given {@code image} has a property
      *         which is not supported by TIFF specification or by this writer.
      */
-    private TileMatrixWriter writeImageFileDirectory(final ReformattedImage 
image, final Metadata metadata,
+    private TileMatrixWriter writeImageFileDirectory(final ReformattedImage 
image, final GridGeometry grid, final Metadata metadata,
             final boolean overview) throws IOException, DataStoreException
     {
         /*
@@ -277,13 +284,20 @@ final class Writer extends GeoTIFF implements Flushable {
          * Metadata (optional) and GeoTIFF. They are managed by separated 
classes.
          */
         final double[][] statistics = image.statistics(numBands);
-        final    int[][] shortStats = toShorts(statistics, sampleFormat);
+        final  short[][] shortStats = toShorts(statistics, sampleFormat);
         final MetadataFetcher<String> mf = new 
MetadataFetcher<>(store.dataLocale) {
             @Override protected String parseDate(final Date date) {
                 return getDateFormat().format(date);
             }
         };
         mf.accept(metadata);
+        GeoKeysWriter geoKeys = null;
+        if (grid != null && grid.isDefined(GridGeometry.GRID_TO_CRS)) try {
+            geoKeys = new GeoKeysWriter(store);
+            geoKeys.write(grid, mf);
+        } catch (FactoryException | IncommensurableException | 
RuntimeException e) {
+            throw new DataStoreReferencingException(e);
+        }
         /*
          * Conversion factor from physical size to pixel size. "Physical size" 
here should be understood as
          * paper size, as suggested by the units of measurement which are 
restricted to inch or centimeters.
@@ -311,8 +325,8 @@ final class Writer extends GeoTIFF implements Flushable {
         writeTag((short) TAG_IMAGE_DESCRIPTION,          /* TIFF_ASCII */      
      mf.title);
         writeTag((short) TAG_MODEL,                      /* TIFF_ASCII */      
      mf.instrument);
         writeTag((short) TAG_SAMPLES_PER_PIXEL,          (short) 
TIFFTag.TIFF_SHORT, numBands);
-        writeTag((short) TAG_MIN_SAMPLE_VALUE,           (short) 
TIFFTag.TIFF_SHORT, shortStats[0]);
-        writeTag((short) TAG_MAX_SAMPLE_VALUE,           (short) 
TIFFTag.TIFF_SHORT, shortStats[1]);
+        writeTag((short) TAG_MIN_SAMPLE_VALUE,           /* TIFF_SHORT */      
      shortStats[0]);
+        writeTag((short) TAG_MAX_SAMPLE_VALUE,           /* TIFF_SHORT */      
      shortStats[1]);
         writeTag((short) TAG_X_RESOLUTION,               /* TIFF_RATIONAL */   
      xres);
         writeTag((short) TAG_Y_RESOLUTION,               /* TIFF_RATIONAL */   
      yres);
         writeTag((short) TAG_PLANAR_CONFIGURATION,       (short) 
TIFFTag.TIFF_SHORT, planarConfiguration);
@@ -332,6 +346,12 @@ final class Writer extends GeoTIFF implements Flushable {
         writeTag((short) TAG_SAMPLE_FORMAT,      (short) TIFFTag.TIFF_SHORT, 
sampleFormat);
         writeTag((short) TAG_S_MIN_SAMPLE_VALUE, (short) TIFFTag.TIFF_FLOAT, 
statistics[0]);
         writeTag((short) TAG_S_MAX_SAMPLE_VALUE, (short) TIFFTag.TIFF_FLOAT, 
statistics[1]);
+        if (geoKeys != null) {
+            writeTag((short) TAG_MODEL_TRANSFORMATION, (short) 
TIFFTag.TIFF_DOUBLE, geoKeys.modelTransformation());
+            writeTag((short) TAG_GEO_KEY_DIRECTORY,    /* TIFF_SHORT */        
     geoKeys.keyDirectory());
+            writeTag((short) TAG_GEO_DOUBLE_PARAMS,    (short) 
TIFFTag.TIFF_DOUBLE, geoKeys.doubleParams());
+            writeTag((short) TAG_GEO_ASCII_PARAMS,     /* TIFF_ASCII */        
     geoKeys.asciiParams());
+        }
         /*
          * At this point, all tags have been written. Update the number of 
tags,
          * then write all values that couldn't be written directly in the tags.
@@ -383,8 +403,8 @@ final class Writer extends GeoTIFF implements Flushable {
      * @param  sampleFormat  the sample format.
      * @return statistics for the tags restricted to integer types.
      */
-    private static int[][] toShorts(final double[][] statistics, final int 
sampleFormat) {
-        final int[][] c = new int[statistics.length][];
+    private static short[][] toShorts(final double[][] statistics, final int 
sampleFormat) {
+        final short[][] c = new short[statistics.length][];
         final long min, max;
         switch (sampleFormat) {
             case SAMPLE_FORMAT_UNSIGNED_INTEGER: min = 0;               max = 
0xFFFF;          break;
@@ -394,9 +414,10 @@ final class Writer extends GeoTIFF implements Flushable {
         for (int j=0; j < c.length; j++) {
             final double[] source = statistics[j];
             if (source != null) {
-                final int[] target = new int[source.length];
+                final short[] target = new short[source.length];
                 for (int i=0; i < source.length; i++) {
-                    target[i] = (int) Math.max(min, Math.min(max, 
Math.round(source[i])));
+                    target[i] = (short) Math.max(min, Math.min(max, 
Math.round(source[i])));
+                    // Unsigned values may look signed after the cast, but 
this is okay.
                 }
                 c[j] = target;
             }
@@ -504,10 +525,14 @@ final class Writer extends GeoTIFF implements Flushable {
             if (StandardCharsets.US_ASCII.equals(store.encoding)) {
                 value = CharSequences.toASCII(value).toString();
             }
-            final byte[] c = value.getBytes(store.encoding);
-            if (c.length != 0) {
-                count += c.length + 1L;             // Count shall include the 
trailing NUL character.
-                chars[i] = c;
+            final byte[] ascii = value.getBytes(store.encoding);
+            int length = 0;
+            for (final byte c : ascii) {
+                if (c != 0) ascii[length++] = c;        // Remove any NUL 
character that may appear in the string.
+            }
+            if (length != 0) {
+                count += length + 1L;                   // Count shall include 
the trailing NUL character.
+                chars[i] = ArraysExt.resize(ascii, length);
             }
         }
         if (count != 0) {
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.java
index 6c4e0fb8a7..832cc46ce8 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.java
@@ -58,6 +58,26 @@ public class Resources extends IndexedResourceBundle {
          */
         public static final short CanNotComputeGridGeometry_1 = 26;
 
+        /**
+         * Cannot encode {0,choice,0#projection method|1#parameter|2#unit} 
“{1}” in a GeoTIFF file.
+         */
+        public static final short CanNotEncodeNamedObject_2 = 35;
+
+        /**
+         * Cannot encode non-geographic base CRS in a GeoTIFF file.
+         */
+        public static final short CanNotEncodeNonGeographicBase = 34;
+
+        /**
+         * Cannot encode a non-linear model transformation.
+         */
+        public static final short CanNotEncodeNonLinearModel = 36;
+
+        /**
+         * Cannot encode referencing objects of type ‘{0}’ in a GeoTIFF file.
+         */
+        public static final short CanNotEncodeObjectType_1 = 33;
+
         /**
          * TIFF file “{0}” has circular references in its chain of images.
          */
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.properties
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.properties
index 5c320a471e..2d468741eb 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.properties
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources.properties
@@ -20,6 +20,10 @@
 # For resources shared by all modules in the Apache SIS project, see 
"org.apache.sis.util.resources" package.
 #
 CanNotComputeGridGeometry_1       = Cannot compute the grid geometry of 
\u201c{0}\u201d GeoTIFF file.
+CanNotEncodeNonGeographicBase     = Cannot encode non-geographic base CRS in a 
GeoTIFF file.
+CanNotEncodeNamedObject_2         = Cannot encode {0,choice,0#projection 
method|1#parameter|2#unit} \u201c{1}\u201d in a GeoTIFF file.
+CanNotEncodeNonLinearModel        = Cannot encode a non-linear model 
transformation.
+CanNotEncodeObjectType_1          = Cannot encode referencing objects of type 
\u2018{0}\u2019 in a GeoTIFF file.
 CircularImageReference_1          = TIFF file \u201c{0}\u201d has circular 
references in its chain of images.
 ConstantValueRequired_3           = Apache SIS implementation requires that 
all \u201c{0}\u201d elements have the same value, but the element found in 
\u201c{1}\u201d are {2}.
 ComputedValueForAttribute_2       = No value specified for the \u201c{0}\u201d 
TIFF tag. Computed the {1} value from other tags.
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources_fr.properties
 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources_fr.properties
index 3193a52fd0..54f8030cd0 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources_fr.properties
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/main/org/apache/sis/storage/geotiff/internal/Resources_fr.properties
@@ -25,6 +25,10 @@
 #   U+00A0 NO-BREAK SPACE         before  :
 #
 CanNotComputeGridGeometry_1       = Ne peut pas calculer la 
g\u00e9om\u00e9trie de la grille du fichier GeoTIFF \u00ab\u202f{0}\u202f\u00bb.
+CanNotEncodeNonGeographicBase     = Ne peut pas encoder un CRS de base 
non-g\u00e9ographique dans un fichier GeoTIFF.
+CanNotEncodeNamedObject_2         = Ne peut pas encoder {0,choice,0#la 
m\u00e9thode de projection|1#le param\u00e8tre|2#l\u2019unit\u00e9} 
\u00ab\u202f{1}\u202f\u00bb dans un fichier GeoTIFF.
+CanNotEncodeNonLinearModel        = Ne peut pas encoder une transformation de 
mod\u00e8le non-lin\u00e9aire.
+CanNotEncodeObjectType_1          = Ne peut pas encoder des objets 
g\u00e9od\u00e9tiques de type \u2018{0}\u2019 dans un fichier GeoTIFF.
 CircularImageReference_1          = Le fichier TIFF 
\u00ab\u202f{0}\u202f\u00bb a des r\u00e9f\u00e9rences circulaires dans sa 
cha\u00eene d\u2019images.
 ConstantValueRequired_3           = L\u2019impl\u00e9mentation de Apache SIS 
requiert que tous les \u00e9l\u00e9ments de \u00ab\u202f{0}\u202f\u00bb aient 
la m\u00eame valeur, mais les \u00e9l\u00e9ments trouv\u00e9s dans 
\u00ab\u202f{1}\u202f\u00bb sont {2}.
 ComputedValueForAttribute_2       = Aucune valeur n\u2019a \u00e9t\u00e9 
sp\u00e9cifi\u00e9e pour le tag TIFF \u00ab\u202f{0}\u202f\u00bb. La valeur {1} 
a \u00e9t\u00e9 calcul\u00e9e \u00e0 partir des autres tags.
diff --git 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoKeysTest.java
 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoKeysTest.java
index e3611298da..ef149eede8 100644
--- 
a/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoKeysTest.java
+++ 
b/endorsed/src/org.apache.sis.storage.geotiff/test/org/apache/sis/storage/geotiff/GeoKeysTest.java
@@ -17,6 +17,7 @@
 package org.apache.sis.storage.geotiff;
 
 import java.util.Set;
+import java.lang.reflect.Field;
 import org.opengis.metadata.Identifier;
 import org.opengis.parameter.GeneralParameterDescriptor;
 import org.opengis.referencing.operation.MathTransformFactory;
@@ -126,4 +127,13 @@ public final class GeoKeysTest extends TestCase {
             throw new AssertionError(e);
         }
     }
+
+    /**
+     * Verifies the value of {@link GeoKeys#NUM_KEYS}.
+     */
+    @Test
+    public void verifyNumKeys() {
+        final Field[] fields = GeoKeys.class.getFields();       // Include 
only public fields.
+        assertEquals(fields.length, GeoKeys.NUM_KEYS);
+    }
 }
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 0f4dcaac3b..3fce1da04a 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
@@ -31,18 +31,27 @@ import java.nio.ByteOrder;
 import java.awt.image.DataBuffer;
 import java.awt.image.SampleModel;
 import javax.imageio.plugins.tiff.TIFFTag;
+import static javax.imageio.plugins.tiff.BaselineTIFFTagSet.*;
+import org.opengis.referencing.crs.ProjectedCRS;
 import org.apache.sis.io.stream.ByteArrayChannel;
 import org.apache.sis.io.stream.ChannelDataOutput;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.StorageConnector;
+import org.apache.sis.coverage.grid.GridExtent;
+import org.apache.sis.coverage.grid.GridGeometry;
+import org.apache.sis.coverage.grid.GridOrientation;
 import org.apache.sis.coverage.grid.j2d.ColorModelFactory;
+import org.apache.sis.geometry.Envelope2D;
 import org.apache.sis.image.DataType;
+
+// Test dependencies
+import org.apache.sis.referencing.operation.HardCodedConversions;
+import org.apache.sis.referencing.crs.HardCodedCRS;
 import org.apache.sis.image.TiledImageMock;
 import org.apache.sis.test.TestUtilities;
 import org.apache.sis.test.TestCase;
 import org.junit.Test;
 
-import static javax.imageio.plugins.tiff.BaselineTIFFTagSet.*;
 import static org.junit.jupiter.api.Assertions.*;
 
 
@@ -66,6 +75,11 @@ public final class WriterTest extends TestCase {
      */
     private TiledImageMock image;
 
+    /**
+     * Mapping from pixel coordinates to "real world" coordinates, or {@code 
null} if none.
+     */
+    private GridGeometry gridGeometry;
+
     /**
      * The channel where the image is written.
      * The data can be obtained by a call to {@link 
ByteArrayChannel#toBuffer()}.
@@ -127,7 +141,7 @@ public final class WriterTest extends TestCase {
 
         image.validate();
         image.initializeAllTiles();
-        output = new ByteArrayChannel(new byte[image.getWidth() * 
image.getHeight() * numBands * type.bytes() + 400], false);
+        output = new ByteArrayChannel(new byte[image.getWidth() * 
image.getHeight() * numBands * type.bytes() + 800], false);
         var d = new ChannelDataOutput("TIFF", output, 
ByteBuffer.allocate(random.nextInt(128) + 20).order(order));
         var c = new StorageConnector(d);
         c.setOption(GeoTiffOption.OPTION_KEY, options);
@@ -135,6 +149,15 @@ public final class WriterTest extends TestCase {
         data  = output.toBuffer().order(order);
     }
 
+    /**
+     * Creates a grid geometry to associate with the image. This is used for 
testing GeoTIFF tags.
+     */
+    private void createGridGeometry() {
+        final ProjectedCRS crs = 
HardCodedConversions.mercator(HardCodedCRS.WGS84);
+        final var env = new Envelope2D(crs, -23, -10, TILE_WIDTH * 2, 
TILE_HEIGHT * 4);
+        gridGeometry = new GridGeometry(new GridExtent(TILE_WIDTH, 
TILE_HEIGHT), env, GridOrientation.REFLECTION_Y);
+    }
+
     /**
      * Writes a single image and updates the data buffer limit.
      * After this method call, the {@linkplain #data} position is 0
@@ -147,7 +170,7 @@ public final class WriterTest extends TestCase {
     private void writeImage() throws IOException, DataStoreException {
         synchronized (store) {
             final Writer writer = store.writer();
-            writer.append(image, null);
+            writer.append(image, gridGeometry, null);
             writer.flush();
         }
         data.clear().limit(Math.toIntExact(output.size()));
@@ -224,6 +247,24 @@ public final class WriterTest extends TestCase {
         store.close();
     }
 
+    /**
+     * Tests writing an image with GeoTIFF data.
+     *
+     * @throws IOException should never happen since the tests are writing in 
memory.
+     * @throws DataStoreException if the image is incompatible with writer 
capability.
+     */
+    @Test
+    public void testGeoTIFF() throws IOException, DataStoreException {
+        initialize(DataType.BYTE, ByteOrder.LITTLE_ENDIAN, false, 1, 1, 1);
+        createGridGeometry();
+        writeImage();
+        verifyHeader(false, GeoTIFF.LITTLE_ENDIAN);
+        verifyImageFileDirectory(Writer.MINIMAL_NUMBER_OF_TAGS + 3,            
         // GeoTIFF adds 3 tags.
+                PHOTOMETRIC_INTERPRETATION_BLACK_IS_ZERO, new short[] 
{Byte.SIZE});
+        verifySampleValues(1);
+        store.close();
+    }
+
     /**
      * Verifies the TIFF header, before the first Image File Directory (IFD).
      *
@@ -297,7 +338,8 @@ public final class WriterTest extends TestCase {
             long    count = isBigTIFF ? data.getLong() : data.getInt();
             long    value = isBigTIFF ? data.getLong() : data.getInt();
             Object  expected;       // The Number class will define the 
expected type.
-            assertTrue(tag > previousTag, "Tags shall be sorted in increasing 
order.");
+            assertTrue(Short.toUnsignedInt(tag) > 
Short.toUnsignedInt(previousTag),
+                       "Tags shall be sorted in increasing order.");
             expectedTags.remove(Integer.valueOf(tag));
             previousTag = tag;
             switch (tag) {
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
index 36916a9066..b63afc7f6d 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
@@ -2257,9 +2257,9 @@ parse:      for (int i = 0; i < length;) {
     public final void setGridToCRS(final CharSequence value) {
         final InternationalString i18n = trim(value);
         if (i18n != null) {
-            final DefaultGridSpatialRepresentation gridRepresentation = 
gridRepresentation();
-            if (gridRepresentation instanceof DefaultGeorectified) {
-                ((DefaultGeorectified) 
gridRepresentation).setTransformationDimensionDescription(i18n);
+            final DefaultGridSpatialRepresentation r = gridRepresentation();
+            if (r instanceof DefaultGeorectified) {
+                ((DefaultGeorectified) 
r).setTransformationDimensionDescription(i18n);
             }
         }
     }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataFetcher.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataFetcher.java
index 879b76c1e9..0784362639 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataFetcher.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataFetcher.java
@@ -20,9 +20,12 @@ import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 import java.util.function.BiPredicate;
 import java.util.function.Function;
+import org.opengis.util.CodeList;
 import org.opengis.util.InternationalString;
+import org.apache.sis.util.collection.CodeListSet;
 import org.opengis.metadata.Metadata;
 import org.opengis.metadata.citation.Citation;
 import org.opengis.metadata.citation.CitationDate;
@@ -36,6 +39,10 @@ import org.opengis.metadata.lineage.ProcessStep;
 import org.opengis.metadata.acquisition.AcquisitionInformation;
 import org.opengis.metadata.acquisition.Instrument;
 import org.opengis.metadata.acquisition.Platform;
+import org.opengis.metadata.spatial.CellGeometry;
+import org.opengis.metadata.spatial.Georectified;
+import org.opengis.metadata.spatial.SpatialRepresentation;
+import org.opengis.metadata.spatial.GridSpatialRepresentation;
 
 
 /**
@@ -106,6 +113,18 @@ public abstract class MetadataFetcher<T> {
      */
     public List<String> procedure;
 
+    /**
+     * General description of the transformation to a georectified grid, or 
{@code null} if none.
+     *
+     * <p>Path: {@code 
metadata/spatialRepresentationInfo/transformationDimensionDescription}</p>
+     */
+    public List<String> transformationDimension;
+
+    /**
+     * Whether grid data are representative of pixel areas or points, or 
{@code null} if none.
+     */
+    public Set<CellGeometry> cellGeometry;
+
     /**
      * The locale to use for converting international strings to {@link 
String} objects.
      * May also be used for date or number formatting.
@@ -151,6 +170,7 @@ public abstract class MetadataFetcher<T> {
             forEach(MetadataFetcher::accept, info.getIdentificationInfo());
             forEach(MetadataFetcher::accept, info.getResourceLineages());
             forEach(MetadataFetcher::accept, info.getAcquisitionInformation());
+            forEach(MetadataFetcher::accept, 
info.getSpatialRepresentationInfo());
         }
     }
 
@@ -274,6 +294,23 @@ public abstract class MetadataFetcher<T> {
         return false;
     }
 
+    /**
+     * Fetches some properties from the given spatial representation object.
+     * Subclasses can override if they need to fetch more details.
+     *
+     * @param  info  the spatial representation object (not null).
+     * @return whether to stop iteration after the given object.
+     */
+    protected boolean accept(final SpatialRepresentation info) {
+        if (info instanceof GridSpatialRepresentation) {
+            addCode(CellGeometry.class, cellGeometry, 
((GridSpatialRepresentation) info).getCellGeometry());
+            if (info instanceof Georectified) {
+                addString(transformationDimension, ((Georectified) 
info).getTransformationDimensionDescription());
+            }
+        }
+        return false;
+    }
+
     /**
      * Adds all international strings in the given collection.
      *
@@ -327,6 +364,25 @@ public abstract class MetadataFetcher<T> {
         return target;
     }
 
+    /**
+     * Adds the given code in the given collection.
+     *
+     * @param  <E>     compile-time value of {@code type} argument.
+     * @param  type    type of code list elements.
+     * @param  target  collection where to add the code.
+     * @param  value   the code to add.
+     * @return the collection where to code was added.
+     */
+    private static <E extends CodeList<E>> Set<E> addCode(final Class<E> type, 
Set<E> target, final E value) {
+        if (value != null) {
+            if (target == null) {
+                target = new CodeListSet<>(type);
+            }
+            target.add(value);
+        }
+        return target;
+    }
+
     /**
      * Converts the given date into the object to store.
      * The {@code <T>} type may be for example {@code <String>}
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java
index c8306d0c0a..2a8f5fe0aa 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.java
@@ -323,6 +323,11 @@ public class Errors extends IndexedResourceBundle {
          */
         public static final short ForbiddenProperty_1 = 41;
 
+        /**
+         * “{0}” uses two or more different units of measurement.
+         */
+        public static final short HeterogynousUnitsIn_1 = 202;
+
         /**
          * Identifier “{1}” is not in “{0}” namespace.
          */
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties
index 209af64dd2..031bec00d6 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors.properties
@@ -77,6 +77,7 @@ FactoryNotFound_1                 = No factory of kind 
\u2018{0}\u2019 found.
 FileNotFound_1                    = File \u201c{0}\u201d has not been found.
 ForbiddenAttribute_2              = Attribute \u201c{0}\u201d is not allowed 
for an object of type \u2018{1}\u2019.
 ForbiddenProperty_1               = Property \u201c{0}\u201d is not allowed.
+HeterogynousUnitsIn_1             = \u201c{0}\u201d uses two or more different 
units of measurement.
 IdentifierNotInNamespace_2        = Identifier \u201c{1}\u201d is not in 
\u201c{0}\u201d namespace.
 IllegalArgumentClass_2            = Argument \u2018{0}\u2019 cannot be an 
instance of \u2018{1}\u2019.
 IllegalArgumentClass_3            = Argument \u2018{0}\u2019 cannot be an 
instance of \u2018{2}\u2019. Expected an instance of \u2018{1}\u2019 or derived 
type.
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties
index 31480f3820..ff8a5066cb 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/resources/Errors_fr.properties
@@ -74,6 +74,7 @@ FactoryNotFound_1                 = Aucune fabrique de type 
\u2018{0}\u2019 n\u2
 FileNotFound_1                    = Le fichier \u00ab\u202f{0}\u202f\u00bb 
n\u2019a pas \u00e9t\u00e9 trouv\u00e9.
 ForbiddenAttribute_2              = L\u2019attribut 
\u00ab\u202f{0}\u202f\u00bb n\u2019est pas autoris\u00e9 pour un objet de type 
\u2018{1}\u2019.
 ForbiddenProperty_1               = La propri\u00e9t\u00e9 
\u00ab\u202f{0}\u202f\u00bb n\u2019est pas autoris\u00e9e.
+HeterogynousUnitsIn_1             = \u00ab\u202f{0}\u202f\u00bb utilise 
plusieurs unit\u00e9s de mesures diff\u00e9rentes.
 IdentifierNotInNamespace_2        = L\u2019identifiant \u201c{1}\u201d 
n\u2019est pas dans l\u2019espace de noms \u201c{0}\u201d.
 IllegalArgumentClass_2            = L\u2019argument \u2018{0}\u2019 ne peut 
pas \u00eatre de type \u2018{1}\u2019.
 IllegalArgumentClass_3            = L\u2019argument \u2018{0}\u2019 ne peut 
pas \u00eatre de type \u2018{2}\u2019. Une instance de \u2018{1}\u2019 ou 
d\u2019un type d\u00e9riv\u00e9 \u00e9tait attendue.

Reply via email to