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

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

commit 0beabad7541b35a0e29e3a5bfb485d321262f369
Merge: 3841c7aeae 15f1d671c9
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Tue Apr 26 18:19:41 2022 +0200

    Merge branch 'geoapi-3.1'

 .../java/org/apache/sis/console/AboutCommand.java  |   2 +-
 application/sis-javafx/pom.xml                     |   5 +
 .../main/java/org/apache/sis/gui/DataViewer.java   |  64 +-
 .../main/java/org/apache/sis/gui/RecentFiles.java  |   2 +-
 .../apache/sis/gui/coverage/BandRangeTable.java    |   5 +-
 .../apache/sis/gui/coverage/CoverageCanvas.java    |  35 +-
 .../apache/sis/gui/coverage/CoverageExplorer.java  |   2 +-
 .../org/apache/sis/gui/coverage/GridViewSkin.java  | 127 +++-
 .../org/apache/sis/gui/coverage/ImageRequest.java  |   2 +-
 .../apache/sis/gui/coverage/IsolineRenderer.java   |   4 +-
 .../sis/gui/coverage/StyledRenderingData.java      |   2 +-
 .../org/apache/sis/gui/dataset/DataWindow.java     |   8 +-
 .../org/apache/sis/gui/dataset/ExpandableList.java |   2 +-
 .../org/apache/sis/gui/dataset/FeatureList.java    |   2 +-
 .../org/apache/sis/gui/dataset/ResourceTree.java   |   4 +-
 .../org/apache/sis/gui/dataset/SelectedData.java   |  11 +-
 .../org/apache/sis/gui/dataset/WindowManager.java  |  13 +-
 .../java/org/apache/sis/gui/map/MapCanvas.java     |   7 +-
 .../java/org/apache/sis/gui/map/MapCanvasAWT.java  |   2 +-
 .../org/apache/sis/gui/map/OperationFinder.java    |   2 +-
 .../java/org/apache/sis/gui/map/StatusBar.java     |   3 +-
 .../apache/sis/gui/referencing/AuthorityCodes.java |   2 +-
 .../org/apache/sis/internal/gui/MouseDrags.java    |  55 ++
 .../org/apache/sis/internal/gui/RecentChoices.java |  30 +-
 .../org/apache/sis/internal/gui/Resources.java     |  12 +-
 .../apache/sis/internal/gui/Resources.properties   |   2 +
 .../sis/internal/gui/Resources_fr.properties       |   2 +
 .../apache/sis/internal/gui/control/ColorCell.java |   2 +-
 .../apache/sis/internal/gui/io/FileAccessView.java |  20 +-
 .../org/apache/sis/internal/doclet/Rewriter.java   |   2 +-
 .../sis/util/resources/ResourceCompilerMojo.java   |   4 +-
 .../java/org/apache/sis/coverage/CategoryList.java |   2 +-
 .../org/apache/sis/coverage/SampleDimension.java   |  50 +-
 .../main/java/org/apache/sis/coverage/ToNaN.java   |   2 +-
 .../org/apache/sis/coverage/grid/GridCoverage.java |   4 +-
 .../apache/sis/coverage/grid/GridCoverage2D.java   |   7 +-
 .../sis/coverage/grid/GridCoverageBuilder.java     |   8 +-
 .../apache/sis/coverage/grid/GridDerivation.java   |   6 +-
 .../org/apache/sis/coverage/grid/GridExtent.java   |  41 +-
 .../org/apache/sis/coverage/grid/GridGeometry.java |  10 +-
 .../apache/sis/coverage/grid/ImageRenderer.java    |   3 +-
 .../apache/sis/feature/CharacteristicTypeMap.java  |   2 +-
 .../apache/sis/feature/DefaultAssociationRole.java |   4 +-
 .../org/apache/sis/feature/DefaultFeatureType.java |   4 +-
 .../java/org/apache/sis/feature/FeatureFormat.java |   2 +-
 .../main/java/org/apache/sis/feature/Features.java |   4 +-
 .../org/apache/sis/feature/NamedFeatureType.java   |   2 +-
 .../apache/sis/feature/StringJoinOperation.java    |   2 +-
 .../java/org/apache/sis/image/BandSelectImage.java |  10 +-
 .../java/org/apache/sis/image/ComputedImage.java   |   4 +-
 .../java/org/apache/sis/image/ComputedTiles.java   |   2 +-
 .../main/java/org/apache/sis/image/DataType.java   |  55 +-
 .../java/org/apache/sis/image/ImageCombiner.java   |  51 +-
 .../java/org/apache/sis/image/ImageProcessor.java  |  23 +-
 .../java/org/apache/sis/image/PixelIterator.java   |  18 +-
 .../java/org/apache/sis/image/RecoloredImage.java  |  97 ++-
 .../org/apache/sis/image/StatisticsCalculator.java |   2 +-
 .../main/java/org/apache/sis/image/Transferer.java |   2 +-
 .../java/org/apache/sis/image/Visualization.java   |  20 +-
 .../org/apache/sis/index/tree/NodeIterator.java    |   4 +-
 .../sis/internal/coverage/CoverageCombiner.java    | 307 ++++++++
 .../internal/coverage/j2d/ColorModelFactory.java   | 110 ++-
 .../sis/internal/coverage/j2d/Colorizer.java       |  86 ++-
 .../sis/internal/coverage/j2d/ColorsForRange.java  |  61 +-
 .../sis/internal/coverage/j2d/ImageUtilities.java  |  17 +-
 .../sis/internal/coverage/j2d/TileOpExecutor.java  |   4 +-
 .../apache/sis/internal/feature/GeometryType.java  |   2 +-
 .../sis/internal/feature/GeometryWrapper.java      |   2 +-
 .../org/apache/sis/internal/feature/Resources.java |   7 +-
 .../sis/internal/feature/Resources.properties      |   1 +
 .../sis/internal/feature/Resources_fr.properties   |   1 +
 .../internal/feature/SpatialOperationContext.java  |   2 +-
 .../sis/internal/feature/j2d/PathBuilder.java      |   2 +-
 .../apache/sis/internal/feature/j2d/Polyline.java  |   2 +-
 .../internal/feature/jts/PathIteratorAdapter.java  |   4 +-
 .../sis/internal/filter/FunctionRegister.java      |   2 +-
 .../internal/processing/image/IsolineTracer.java   |   4 +-
 .../sis/internal/processing/image/Isolines.java    |   2 +-
 .../apache/sis/coverage/SampleDimensionTest.java   |   4 +-
 .../apache/sis/coverage/grid/GridExtentTest.java   |  10 +
 .../apache/sis/feature/FeatureOperationsTest.java  |   2 +-
 .../feature/builder/AttributeTypeBuilderTest.java  |   2 +-
 .../builder/CharacteristicTypeBuilderTest.java     |   2 +-
 .../java/org/apache/sis/image/DataTypeTest.java    |  25 +-
 .../sis/internal/coverage/j2d/ColorizerTest.java   |   4 +-
 .../internal/coverage/j2d/ImageUtilitiesTest.java  |   8 +-
 .../java/org/apache/sis/internal/jaxb/Context.java |   4 +-
 .../apache/sis/internal/jaxb/TypeRegistration.java |   2 +-
 .../sis/internal/jaxb/gco/GO_CharacterString.java  |   2 +-
 .../apache/sis/internal/jaxb/gco/GO_DateTime.java  |   4 +-
 .../internal/jaxb/gco/ObjectIdentification.html    |   2 +-
 .../apache/sis/internal/jaxb/gco/PropertyType.java |   4 +-
 .../metadata/replace/ReferenceSystemMetadata.java  |   2 +-
 .../apache/sis/internal/metadata/Identifiers.java  |  20 +-
 .../sis/internal/metadata/MetadataUtilities.java   |   6 +-
 .../apache/sis/internal/metadata/Resources.java    |   2 +-
 .../apache/sis/internal/metadata/package-info.java |   2 +-
 .../sis/internal/metadata/sql/SQLUtilities.java    |   2 +-
 .../sis/internal/metadata/sql/ScriptRunner.java    |   2 +-
 .../org/apache/sis/metadata/MetadataStandard.java  |   4 +-
 .../org/apache/sis/metadata/MetadataVisitor.java   |   2 +-
 .../main/java/org/apache/sis/metadata/Pruner.java  |   2 +-
 .../org/apache/sis/metadata/TreeNodeChildren.java  |   2 +-
 .../org/apache/sis/metadata/iso/ISOMetadata.java   |   2 +-
 .../sis/metadata/iso/citation/DefaultContact.java  |   4 +
 .../iso/extent/DefaultGeographicBoundingBox.java   |  12 +-
 .../org/apache/sis/metadata/sql/Dispatcher.java    |  19 +-
 .../sis/metadata/sql/IdentifierGenerator.java      |   2 +-
 .../apache/sis/metadata/sql/MetadataSource.java    | 116 ++-
 .../apache/sis/metadata/sql/TableHierarchy.java    |   4 +
 .../main/java/org/apache/sis/xml/Transformer.java  |   4 +-
 .../org/apache/sis/xml/TransformingReader.java     |   2 +-
 .../org/apache/sis/xml/TransformingWriter.java     |   4 +-
 .../org/apache/sis/metadata/sql/Contents.sql       |  18 +-
 .../sis/metadata/sql/MetadataFallbackVerifier.java |   2 +-
 .../java/org/apache/sis/util/iso/NamesTest.java    |   2 +-
 .../sis/internal/map/coverage/RenderingData.java   |   2 +-
 .../main/java/org/apache/sis/portrayal/Canvas.java |   8 +-
 .../MultiResolutionCoverageLoaderTest.java         |   4 +-
 .../apache/sis/internal/gazetteer/Resources.java   |   2 +-
 .../gazetteer/MilitaryGridReferenceSystem.java     |   2 +-
 .../sis/geometry/AbstractDirectPosition.java       |   2 +-
 .../org/apache/sis/geometry/AbstractEnvelope.java  |   2 +-
 .../org/apache/sis/geometry/ArrayEnvelope.java     |   2 +-
 .../org/apache/sis/geometry/CoordinateFormat.java  |  12 +-
 .../org/apache/sis/geometry/DirectPosition2D.java  |   2 +-
 .../java/org/apache/sis/geometry/Envelopes.java    |   2 +-
 .../apache/sis/geometry/GeneralDirectPosition.java |   2 +-
 .../org/apache/sis/geometry/GeneralEnvelope.java   |   5 +-
 .../referencing/CC_GeneralOperationParameter.java  |  18 +-
 .../jaxb/referencing/CC_OperationMethod.java       |   8 +-
 .../internal/referencing/CoordinateOperations.java |   4 +-
 .../sis/internal/referencing/ExtentSelector.java   |   4 +-
 .../referencing/PositionalAccuracyConstant.java    |   2 +-
 .../apache/sis/internal/referencing/Resources.java |   2 +-
 .../sis/internal/referencing/WKTKeywords.java      |   2 +-
 .../sis/internal/referencing/j2d/AffineMatrix.java |   3 +-
 .../referencing/j2d/AffineTransform2D.java         |  36 +-
 .../referencing/j2d/ImmutableAffineTransform.java  |  14 +-
 .../referencing/j2d/ParameterizedAffine.java       |   2 +-
 .../internal/referencing/j2d/TileOrganizer.java    |   2 +-
 .../referencing/provider/AbstractProvider.java     |   2 +-
 .../provider/MolodenskyInterpolation.java          |   2 +-
 .../java/org/apache/sis/io/wkt/AbstractParser.java |   2 +-
 .../java/org/apache/sis/io/wkt/Convention.java     |   2 +-
 .../main/java/org/apache/sis/io/wkt/Element.java   |   6 +-
 .../main/java/org/apache/sis/io/wkt/Formatter.java |  12 +-
 .../apache/sis/io/wkt/GeodeticObjectParser.java    |  24 +-
 .../org/apache/sis/io/wkt/MathTransformParser.java |   2 +-
 .../java/org/apache/sis/io/wkt/StoredTree.java     |   4 +-
 .../java/org/apache/sis/io/wkt/WKTDictionary.java  |   4 +-
 .../sis/parameter/DefaultParameterDescriptor.java  |   2 +-
 .../sis/parameter/DefaultParameterValue.java       |   2 +-
 .../java/org/apache/sis/parameter/Parameters.java  |   6 +-
 .../sis/referencing/AbstractIdentifiedObject.java  |   4 +-
 .../java/org/apache/sis/referencing/Builder.java   |   8 +-
 .../sis/referencing/GeodesicsOnEllipsoid.java      |   2 +-
 .../apache/sis/referencing/GeodeticCalculator.java |   6 +-
 .../sis/referencing/ImmutableIdentifier.java       |   2 +-
 .../apache/sis/referencing/NamedIdentifier.java    |   2 +-
 .../sis/referencing/crs/AbstractDerivedCRS.java    |  15 +-
 .../sis/referencing/crs/DefaultDerivedCRS.java     |   6 +-
 .../sis/referencing/crs/DefaultProjectedCRS.java   |   2 +-
 .../org/apache/sis/referencing/cs/AbstractCS.java  |   2 +-
 .../sis/referencing/cs/CoordinateSystems.java      |   6 +-
 .../cs/DefaultCoordinateSystemAxis.java            |   2 +-
 .../factory/ConcurrentAuthorityFactory.java        |   2 +-
 .../factory/GeodeticAuthorityFactory.java          |   2 +-
 .../referencing/factory/IdentifiedObjectSet.java   |   2 +-
 .../operation/AbstractSingleOperation.java         |   6 +-
 .../operation/CoordinateOperationRegistry.java     |   6 +-
 .../referencing/operation/DefaultConversion.java   |   2 +-
 .../operation/builder/LinearTransformBuilder.java  |   4 +-
 .../operation/projection/AlbersEqualArea.java      |   2 +-
 .../operation/projection/CassiniSoldner.java       |   2 +-
 .../operation/projection/CylindricalEqualArea.java |   2 +-
 .../projection/LambertConicConformal.java          |   2 +-
 .../referencing/operation/projection/Mercator.java |   4 +-
 .../projection/ModifiedAzimuthalEquidistant.java   |   2 +-
 .../operation/projection/NormalizedProjection.java |   6 +-
 .../operation/projection/ObliqueMercator.java      |   2 +-
 .../operation/projection/ObliqueStereographic.java |   2 +-
 .../operation/projection/Orthographic.java         |   2 +-
 .../operation/projection/PolarStereographic.java   |   2 +-
 .../operation/projection/Polyconic.java            |   2 +-
 .../operation/projection/Sinusoidal.java           |   2 +-
 .../operation/projection/TransverseMercator.java   |   2 +-
 .../operation/transform/AbstractMathTransform.java |  12 +-
 .../transform/AbstractMathTransform2D.java         |   4 +-
 .../operation/transform/ConcatenatedTransform.java |   4 +-
 .../operation/transform/ContextualParameters.java  |   2 +-
 .../transform/CoordinateSystemTransform.java       |   2 +-
 .../transform/InterpolatedGeocentricTransform.java |   2 +-
 .../operation/transform/MathTransforms.java        |   2 +-
 .../sis/io/wkt/GeodeticObjectParserTest.java       |  10 +-
 .../factory/ConcurrentAuthorityFactoryTest.java    |   2 +-
 .../transform/AbstractMathTransformTest.java       |   2 +-
 .../operation/transform/MathTransformTestCase.java |   4 +-
 .../sis/internal/system/DelayedRunnable.java       |   2 +-
 .../org/apache/sis/internal/system/Fallback.java   |   2 +-
 .../sis/internal/system/OptionalDependency.java    |   2 +-
 .../apache/sis/internal/util/AbstractIterator.java |   2 +-
 .../apache/sis/internal/util/CollectionsExt.java   |   2 +-
 .../apache/sis/internal/util/DefinitionURI.java    |   2 +-
 .../org/apache/sis/internal/util/DoubleDouble.java |   2 +-
 .../sis/internal/util/ListOfUnknownSize.java       |   4 +-
 .../org/apache/sis/internal/util/Numerics.java     |  49 ++
 .../main/java/org/apache/sis/io/LineAppender.java  |   2 +-
 .../java/org/apache/sis/math/FunctionProperty.java |   2 +-
 .../java/org/apache/sis/math/MathFunctions.java    |   2 +-
 .../main/java/org/apache/sis/math/Statistics.java  |  68 +-
 .../src/main/java/org/apache/sis/math/Vector.java  |   2 +-
 .../main/java/org/apache/sis/measure/Angle.java    |   2 +-
 .../java/org/apache/sis/measure/AngleFormat.java   |   2 +-
 .../sis/measure/FormattedCharacterIterator.java    |   2 +-
 .../main/java/org/apache/sis/measure/Prefixes.java |   2 +-
 .../main/java/org/apache/sis/measure/Scalar.java   |  17 +-
 .../java/org/apache/sis/measure/SystemUnit.java    |   2 +-
 .../java/org/apache/sis/measure/UnitFormat.java    |   4 +-
 .../java/org/apache/sis/measure/UnitRegistry.java  |  14 +-
 .../java/org/apache/sis/measure/UnitServices.java  |   1 +
 .../main/java/org/apache/sis/measure/Units.java    |  80 +-
 .../java/org/apache/sis/measure/package-info.java  |   1 +
 .../main/java/org/apache/sis/setup/OptionKey.java  |   4 +-
 .../main/java/org/apache/sis/util/ArraysExt.java   |  20 +-
 .../java/org/apache/sis/util/CharSequences.java    |   6 +-
 .../src/main/java/org/apache/sis/util/Locales.java |   4 +-
 .../main/java/org/apache/sis/util/Utilities.java   |   4 +-
 .../java/org/apache/sis/util/collection/Cache.java |   6 +-
 .../org/apache/sis/util/collection/RangeSet.java   |   2 +-
 .../sis/util/collection/TreeTableFormat.java       |   2 +-
 .../sis/util/collection/WeakValueHashMap.java      |   2 +-
 .../org/apache/sis/util/logging/LoggerAdapter.java |   2 +-
 .../apache/sis/util/logging/PerformanceLevel.java  |   4 +-
 .../java/org/apache/sis/util/resources/Errors.java |  17 +-
 .../apache/sis/util/resources/Errors.properties    |   5 +-
 .../apache/sis/util/resources/Errors_fr.properties |   5 +-
 .../sis/util/resources/IndexedResourceBundle.java  |   6 +-
 .../org/apache/sis/util/resources/Messages.java    |   7 +-
 .../apache/sis/util/resources/Messages.properties  |   1 +
 .../sis/util/resources/Messages_fr.properties      |   1 +
 .../org/apache/sis/util/resources/Vocabulary.java  |   2 +-
 .../org/apache/sis/measure/UnitNames.properties    |   2 +
 .../org/apache/sis/measure/UnitNames_fr.properties |   3 +-
 .../org/apache/sis/internal/util/NumericsTest.java |  22 +
 .../org/apache/sis/math/DecimalFunctionsTest.java  |   2 +-
 .../java/org/apache/sis/math/StatisticsTest.java   |  18 +-
 .../org/apache/sis/measure/UnitFormatTest.java     |   5 +-
 .../java/org/apache/sis/measure/UnitsTest.java     |  87 ++-
 ide-project/NetBeans/build.xml                     |   7 +
 ide-project/NetBeans/nbproject/project.properties  |   4 +-
 .../org/apache/sis/storage/landsat/BandGroup.java  |  13 +-
 .../apache/sis/storage/landsat/LandsatStore.java   |   3 +
 .../sis/storage/landsat/LandsatStoreProvider.java  |   3 +-
 .../apache/sis/storage/landsat/MetadataReader.java |   6 +-
 .../storage/landsat/doc-files/MetadataMapping.html |   2 +-
 .../apache/sis/storage/landsat/package-info.java   |   2 +-
 .../org/apache/sis/internal/geotiff/Resources.java |   2 +-
 .../sis/internal/geotiff/SchemaModifier.java       |   5 +-
 .../org/apache/sis/storage/geotiff/CRSBuilder.java |   8 +-
 .../org/apache/sis/storage/geotiff/DataCube.java   |  24 +
 .../org/apache/sis/storage/geotiff/DataSubset.java |  10 +-
 .../apache/sis/storage/geotiff/GeoTiffStore.java   |  26 +-
 .../sis/storage/geotiff/GeoTiffStoreProvider.java  |  11 +-
 .../sis/storage/geotiff/GridGeometryBuilder.java   |   7 +-
 .../sis/storage/geotiff/ImageFileDirectory.java    |  52 +-
 .../sis/storage/geotiff/ImageMetadataBuilder.java  |   5 +-
 .../sis/storage/geotiff/MultiResolutionImage.java  |  28 +-
 .../org/apache/sis/storage/geotiff/Reader.java     |   4 +-
 .../internal/storage/inflater/CCITTRLETest.java    |   2 +-
 .../apache/sis/storage/geotiff/GeoKeysTest.java    |   4 +-
 .../sis/storage/geotiff/XMLMetadataTest.java       |   4 +-
 .../java/org/apache/sis/internal/netcdf/Axis.java  |   2 +-
 .../org/apache/sis/internal/netcdf/CRSBuilder.java |   2 +-
 .../org/apache/sis/internal/netcdf/DataType.java   |   2 +-
 .../sis/internal/netcdf/DiscreteSampling.java      |   4 +-
 .../org/apache/sis/internal/netcdf/FeatureSet.java |   4 +-
 .../apache/sis/internal/netcdf/NamedElement.java   |   4 +-
 .../apache/sis/internal/netcdf/RasterResource.java |  29 +-
 .../org/apache/sis/internal/netcdf/Resources.java  |   2 +-
 .../org/apache/sis/internal/netcdf/Variable.java   |   2 +-
 .../sis/internal/netcdf/ucar/LogAdapter.java       |  13 +-
 .../apache/sis/storage/netcdf/MetadataReader.java  |  14 +-
 .../org/apache/sis/storage/netcdf/NetcdfStore.java |   3 +
 .../sis/storage/netcdf/NetcdfStoreProvider.java    |  11 +-
 .../org/apache/sis/internal/netcdf/TestCase.java   |  28 +-
 .../internal/netcdf/impl/ChannelDecoderTest.java   |   3 +-
 .../sis/storage/netcdf/MetadataReaderTest.java     |   2 +
 .../storage/netcdf/NetcdfStoreProviderTest.java    |   5 +-
 .../sis/internal/shapefile/jdbc/AbstractJDBC.java  |   2 +-
 storage/sis-sqlstore/pom.xml                       |   2 +-
 .../apache/sis/internal/sql/feature/Analyzer.java  |   2 +-
 .../apache/sis/internal/sql/feature/Column.java    |  73 +-
 .../apache/sis/internal/sql/feature/Database.java  |  53 +-
 .../sis/internal/sql/feature/FeatureAdapter.java   |   2 +-
 .../sis/internal/sql/feature/FeatureAnalyzer.java  |   2 +-
 .../sis/internal/sql/feature/FeatureIterator.java  |   2 +-
 .../sis/internal/sql/feature/InfoStatements.java   |  17 +-
 .../apache/sis/internal/sql/feature/Relation.java  |   2 +-
 .../apache/sis/internal/sql/feature/Resources.java |   2 +-
 .../sis/internal/sql/feature/SelectionClause.java  |   2 +-
 .../org/apache/sis/internal/sql/feature/Table.java |   8 +-
 .../sis/internal/sql/feature/ValueGetter.java      |  81 +-
 .../sis/internal/sql/postgis/ExtentEstimator.java  |   8 +-
 .../sis/internal/sql/postgis/ObjectGetter.java     |  76 ++
 .../apache/sis/internal/sql/postgis/Postgres.java  |  35 +-
 .../java/org/apache/sis/storage/sql/SQLStore.java  |   5 +-
 .../apache/sis/storage/sql/SQLStoreProvider.java   |   2 +-
 .../sis/internal/storage/AbstractGridResource.java | 578 --------------
 .../sis/internal/storage/AggregatedFeatureSet.java |  10 +-
 .../org/apache/sis/internal/storage/CodeType.java  |   2 +-
 .../internal/storage/ConcatenatedFeatureSet.java   |   7 +-
 .../internal/storage/DocumentedStoreProvider.java  |  45 +-
 .../sis/internal/storage/MemoryFeatureSet.java     |   5 +-
 .../sis/internal/storage/MemoryGridResource.java   |   5 +-
 .../sis/internal/storage/MetadataBuilder.java      | 148 +++-
 .../apache/sis/internal/storage/PRJDataStore.java  | 511 +++++++++++++
 .../apache/sis/internal/storage/RangeArgument.java | 386 ++++++++++
 .../sis/internal/storage/ResourceOnFileSystem.java |   6 +-
 .../org/apache/sis/internal/storage/Resources.java |  47 +-
 .../sis/internal/storage/Resources.properties      |   9 +
 .../sis/internal/storage/Resources_fr.properties   |   9 +
 .../sis/internal/storage/StoreUtilities.java       |  22 +-
 .../sis/internal/storage/TiledGridCoverage.java    |  10 +-
 .../sis/internal/storage/TiledGridResource.java    |   9 +-
 .../apache/sis/internal/storage/URIDataStore.java  | 161 ++--
 .../internal/storage/WritableResourceSupport.java  | 236 ++++++
 .../sis/internal/storage/csv/FeatureIterator.java  |   6 +-
 .../storage/csv/MovingFeatureIterator.java         |   2 +-
 .../org/apache/sis/internal/storage/csv/Store.java |   5 +-
 .../sis/internal/storage/csv/StoreProvider.java    |  12 +-
 .../sis/internal/storage/esri/AsciiGridStore.java  | 570 ++++++++++++++
 .../storage/esri/AsciiGridStoreProvider.java       | 139 ++++
 .../sis/internal/storage/esri/CharactersView.java  | 239 ++++++
 .../sis/internal/storage/esri/RasterStore.java     | 520 +++++++++++++
 .../RawRasterLayout.java}                          |  30 +-
 .../sis/internal/storage/esri/RawRasterReader.java | 264 +++++++
 .../sis/internal/storage/esri/RawRasterStore.java  | 544 ++++++++++++++
 .../storage/esri/RawRasterStoreProvider.java       | 115 +++
 .../sis/internal/storage/esri/WritableStore.java   | 311 ++++++++
 .../sis/internal/storage/esri/package-info.java    |  57 ++
 .../apache/sis/internal/storage/folder/Store.java  |   5 +-
 .../sis/internal/storage/folder/StoreProvider.java |   4 +-
 .../sis/internal/storage/image/FormatFilter.java   | 246 ++++++
 .../internal/storage/image/WarningListener.java    |  70 ++
 .../internal/storage/image/WorldFileResource.java  | 363 +++++++++
 .../sis/internal/storage/image/WorldFileStore.java | 829 +++++++++++++++++++++
 .../storage/image/WorldFileStoreProvider.java      | 134 ++++
 .../internal/storage/image/WritableResource.java   |  77 ++
 .../sis/internal/storage/image/WritableStore.java  | 535 +++++++++++++
 .../sis/internal/storage/image/package-info.java   |  50 ++
 .../sis/internal/storage/io/ChannelDataInput.java  |  34 +-
 .../sis/internal/storage/io/ChannelDataOutput.java |   4 +-
 .../sis/internal/storage/io/ChannelFactory.java    | 126 +++-
 .../storage/io/ChannelImageInputStream.java        |  17 +-
 .../storage/io/ChannelImageOutputStream.java       |   2 +-
 .../internal/storage/io/HyperRectangleReader.java  |  22 +-
 .../sis/internal/storage/io/IOUtilities.java       |  40 +-
 .../org/apache/sis/internal/storage/io/Region.java |  22 +-
 .../internal/storage/io/RewindableLineReader.java  |   2 +-
 .../org/apache/sis/internal/storage/wkt/Store.java |  33 +-
 .../sis/internal/storage/wkt/StoreFormat.java      |  14 +-
 .../sis/internal/storage/wkt/StoreProvider.java    |  22 +-
 .../sis/internal/storage/wkt/package-info.java     |   2 +-
 .../sis/internal/storage/xml/AbstractProvider.java |  13 +-
 .../org/apache/sis/internal/storage/xml/Store.java |   3 +-
 .../sis/internal/storage/xml/StoreProvider.java    |   4 +-
 .../{internal => }/storage/AbstractFeatureSet.java |  49 +-
 .../sis/storage/AbstractGridCoverageResource.java  | 229 ++++++
 .../{internal => }/storage/AbstractResource.java   | 223 +++---
 .../org/apache/sis/storage/CoverageSubset.java     |   9 +-
 .../java/org/apache/sis/storage/DataOptionKey.java |  19 +-
 .../java/org/apache/sis/storage/DataStore.java     |  10 +-
 .../org/apache/sis/storage/DataStoreException.java |   4 +-
 .../org/apache/sis/storage/DataStoreProvider.java  |  10 +-
 .../java/org/apache/sis/storage/FeatureQuery.java  |   2 +-
 .../java/org/apache/sis/storage/FeatureSubset.java |   5 +-
 ...ion.java => IncompatibleResourceException.java} |  36 +-
 .../sis/storage/ReadOnlyStorageException.java      |   2 +-
 .../main/java/org/apache/sis/storage/Resource.java |   6 +-
 ...on.java => ResourceAlreadyExistsException.java} |  37 +-
 .../org/apache/sis/storage/StorageConnector.java   | 187 ++++-
 .../sis/storage/WritableGridCoverageResource.java  |  60 +-
 .../apache/sis/storage/event/StoreListeners.java   | 217 ++++--
 .../java/org/apache/sis/storage/tiling/Tile.java   |  93 +++
 .../org/apache/sis/storage/tiling/TileMatrix.java  | 166 +++++
 .../apache/sis/storage/tiling/TileMatrixSet.java   |  91 +++
 .../org/apache/sis/storage/tiling/TileStatus.java  |  69 ++
 .../apache/sis/storage/tiling/TiledResource.java   |  52 ++
 .../sis/storage/tiling/WritableTileMatrix.java     |  61 ++
 .../sis/storage/tiling/WritableTileMatrixSet.java  |  84 +++
 .../sis/storage/tiling/WritableTiledResource.java  |  76 ++
 .../apache/sis/storage/tiling/package-info.java    |  75 ++
 .../org.apache.sis.storage.DataStoreProvider       |   5 +-
 .../internal/storage/MemoryGridResourceTest.java   |   2 +-
 .../sis/internal/storage/MetadataBuilderTest.java  |   4 +-
 ...ridResourceTest.java => RangeArgumentTest.java} |  29 +-
 .../internal/storage/esri/AsciiGridStoreTest.java  | 131 ++++
 .../internal/storage/esri/BILConsistencyTest.java  |  79 ++
 .../internal/storage/esri/BIPConsistencyTest.java  |  79 ++
 .../internal/storage/esri/BSQConsistencyTest.java  |  79 ++
 .../internal/storage/esri/WritableStoreTest.java   | 176 +++++
 .../storage/image/SelfConsistencyTest.java         |  83 +++
 .../internal/storage/image/WorldFileStoreTest.java | 165 ++++
 .../sis/internal/storage/io/IOUtilitiesTest.java   |  14 +-
 .../internal/storage/wkt/StoreProviderTest.java    |   2 +-
 .../java/org/apache/sis/storage/DataStoreMock.java |   2 +-
 .../org/apache/sis/storage/GridResourceMock.java   |   3 +-
 .../apache/sis/storage/StorageConnectorTest.java   |   2 +-
 .../sis/storage/event/StoreListenersTest.java      |  24 +-
 .../sis/test/storage/CoverageReadConsistency.java  |   6 +-
 .../apache/sis/test/storage/SubsampledImage.java   | 109 ++-
 .../apache/sis/test/suite/StorageTestSuite.java    |   9 +-
 .../org/apache/sis/internal/storage/esri/BIL.hdr   |   9 +
 .../org/apache/sis/internal/storage/esri/BIL.raw   | Bin 0 -> 243 bytes
 .../org/apache/sis/internal/storage/esri/BIP.hdr   |  14 +
 .../org/apache/sis/internal/storage/esri/BIP.raw   | Bin 0 -> 243 bytes
 .../org/apache/sis/internal/storage/esri/BIP.stx   |   7 +
 .../org/apache/sis/internal/storage/esri/BSQ.hdr   |  13 +
 .../org/apache/sis/internal/storage/esri/BSQ.raw   | Bin 0 -> 243 bytes
 .../org/apache/sis/internal/storage/esri/grid.asc  |  34 +
 .../org/apache/sis/internal/storage/esri/grid.clr  |  14 +
 .../org/apache/sis/internal/storage/esri/grid.prj  |   9 +
 .../apache/sis/internal/storage/image/README.md    |  11 +
 .../apache/sis/internal/storage/image/gradient.pgw |   6 +
 .../apache/sis/internal/storage/image/gradient.png | Bin 0 -> 176 bytes
 .../apache/sis/internal/storage/image/gradient.prj |   8 +
 .../apache/sis/internal/storage/gpx/Reader.java    |   4 +-
 .../org/apache/sis/internal/storage/gpx/Store.java |   1 +
 .../sis/internal/storage/gpx/StoreProvider.java    |   9 +-
 .../org/apache/sis/internal/storage/gpx/Types.java |   2 +-
 .../internal/storage/xml/stream/StaxDataStore.java |   2 +-
 .../storage/xml/stream/StaxStreamReader.java       |   4 +-
 433 files changed, 12149 insertions(+), 1992 deletions(-)

diff --cc 
core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java
index 4ba06beffb,ef079ac668..4fdc3e3ff2
--- 
a/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java
@@@ -306,10 -320,10 +306,10 @@@ public class DefaultAssociationRole ext
      }
  
      /**
 -     * Potentially invoked after {@link #search(FeatureType, Collection, 
GenericName, List)} for searching
 +     * Potentially invoked after {@code search(FeatureType, Collection, 
GenericName, List)} for searching
       * in associations of associations.
       *
-      * <p>Current implementation does not check that there is no duplicated 
names. Even if we did so,
+      * <p>Current implementation does not check that there are no duplicated 
names. Even if we did so,
       * a graph of feature types may have no duplicated names at this time but 
some duplicated names
       * later. We rather put a warning in {@link #DefaultAssociationRole(Map, 
GenericName, int, int)}
       * javadoc.</p>
diff --cc 
core/sis-feature/src/main/java/org/apache/sis/feature/DefaultFeatureType.java
index 882be37f69,bd814c2dc6..fa0484864b
--- 
a/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultFeatureType.java
+++ 
b/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultFeatureType.java
@@@ -678,13 -689,13 +678,13 @@@ public class DefaultFeatureType extend
          }
          /*
           * Ensures that all properties defined in this feature type is also 
defined
-          * in the given property, and that the former is assignable from the 
later.
+          * in the given property, and that the former is assignable from the 
latter.
           */
 -        for (final Map.Entry<String, PropertyType> entry : byName.entrySet()) 
{
 -            final PropertyType other;
 +        for (final Map.Entry<String, AbstractIdentifiedType> entry : 
byName.entrySet()) {
 +            final AbstractIdentifiedType other;
              try {
                  other = type.getProperty(entry.getKey());
 -            } catch (PropertyNotFoundException e) {
 +            } catch (IllegalArgumentException e) {
                  /*
                   * A property in this FeatureType does not exist in the given 
FeatureType.
                   * Catching exceptions is not an efficient way to perform 
this check, but
diff --cc 
core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/citation/DefaultContact.java
index ea56c4e5ec,eafc326697..f36f8f3d50
--- 
a/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/citation/DefaultContact.java
+++ 
b/core/sis-metadata/src/main/java/org/apache/sis/metadata/iso/citation/DefaultContact.java
@@@ -262,8 -244,14 +262,12 @@@ public class DefaultContact extends ISO
                      }
                  }
                  if (ignored != null) {
+                     /*
+                      * Log a warning for ignored property using a call to 
`ignored.toString()` instead of `ignored`
+                      * because we want the property to appear as 
"TelephoneType[FOO]" instead of "FOO".
+                      */
                      Context.warningOccured(Context.current(), 
DefaultContact.class, "getPhone",
 -                            Messages.class, 
Messages.Keys.IgnoredPropertyAssociatedTo_1, ignored.toString());
 +                            Messages.class, 
Messages.Keys.IgnoredPropertyAssociatedTo_1, ignored);
                  }
              }
          }
diff --cc 
core/sis-metadata/src/main/java/org/apache/sis/metadata/sql/Dispatcher.java
index 3a3b1e4218,adb318d941..c2d7184cad
--- 
a/core/sis-metadata/src/main/java/org/apache/sis/metadata/sql/Dispatcher.java
+++ 
b/core/sis-metadata/src/main/java/org/apache/sis/metadata/sql/Dispatcher.java
@@@ -161,13 -161,9 +161,14 @@@ final class Dispatcher implements Invoc
                   */
                  Object value;
                  try {
 -                    method = supercede(method);
 -                    if (method == null) return null;
 +                    final long nb = nullValues;
                      value = 
fetchValue(source.getLookupInfo(method.getDeclaringClass()), method);
 +                    if (value == null) {
 +                        nullValues = nb;
 +                        method = supercede(method);
++                        if (method == null) return null;
 +                        value = 
fetchValue(source.getLookupInfo(method.getDeclaringClass()), method);
 +                    }
                  } catch (ReflectiveOperationException | SQLException | 
MetadataStoreException e) {
                      throw new BackingStoreException(error(method), e);
                  }
@@@ -334,8 -330,16 +335,16 @@@
       * confuses this {@code Dispatcher} class. We need the method in the base 
interface.
       */
      private static Method supercede(Method method) throws 
NoSuchMethodException {
-         if (method.getDeclaringClass() == ResponsibleParty.class && 
"getRole".equals(method.getName())) {
-             method = DefaultResponsibility.class.getMethod("getRole");
+         if (method.getDeclaringClass() == ResponsibleParty.class) {
+             if ("getRole".equals(method.getName())) {
 -                method = Responsibility.class.getMethod("getRole");
++                method = DefaultResponsibility.class.getMethod("getRole");
+             } else {
+                 /*
+                  * `getIndividualName()`, `getOrganisationName()`, 
`getPositionName()` and
+                  * `getContactInfo()` has no direct equivalence in 
`Responsibility` class.
+                  */
+                 return null;
+             }
          }
          return method;
      }
diff --cc 
core/sis-metadata/src/main/java/org/apache/sis/metadata/sql/MetadataSource.java
index 8607b5f6a3,4cf0c3509c..996f32f332
--- 
a/core/sis-metadata/src/main/java/org/apache/sis/metadata/sql/MetadataSource.java
+++ 
b/core/sis-metadata/src/main/java/org/apache/sis/metadata/sql/MetadataSource.java
@@@ -844,8 -853,29 +855,29 @@@ public class MetadataSource implements 
      public <T> T lookup(final Class<T> type, final String identifier) throws 
MetadataStoreException {
          ArgumentChecks.ensureNonNull("type", type);
          ArgumentChecks.ensureNonEmpty("identifier", identifier);
+         return type.cast(lookup(type, identifier, true));
+     }
+ 
+     /**
+      * Implementation of public {@link #lookup(Class, String)} method.
+      *
+      * <h4>Deferred database access</h4>
+      * This method may or may not query the database immediately, at 
implementation choice.
+      * It the database is not queried immediately, invalid identifiers may 
not be detected
+      * during this method invocation. Instead, an invalid identifier may be 
detected only
+      * when a getter method is invoked on the returned metadata object. In 
such case,
+      * an {@link org.apache.sis.util.collection.BackingStoreException} will 
be thrown
+      * at getter method invocation time.
+      *
+      * @param  type        the interface to implement or the {@link 
ControlledVocabulary} type.
+      * @param  identifier  the identifier of the record for the metadata 
entity to be created.
+      * @param  verify      whether to check for record existence.
+      * @return an implementation of the required interface, or the code list 
element.
+      * @throws MetadataStoreException if a SQL query failed or if the 
metadata has not been found.
+      */
+     private Object lookup(final Class<?> type, final String identifier, 
boolean verify) throws MetadataStoreException {
          Object value;
 -        if (ControlledVocabulary.class.isAssignableFrom(type)) {
 +        if (CodeList.class.isAssignableFrom(type)) {
              value = getCodeList(type, identifier);
          } else {
              final CacheKey key = new CacheKey(type, identifier);
@@@ -924,10 -973,10 +975,10 @@@
      {
          /*
           * If the identifier is prefixed with a table name as in 
"{Organisation}identifier",
-          * the name between bracket is a subtype of the given 'type' argument.
+          * the name between bracket is a subtype of the given `type` argument.
           */
          final Class<?> type           = 
TableHierarchy.subType(info.getMetadataType(), toSearch.identifier);
 -        final Class<?> returnType     = method.getReturnType();
 +        final Class<?> returnType     = Interim.getReturnType(method);
          final boolean  wantCollection = 
Collection.class.isAssignableFrom(returnType);
          final Class<?> elementType    = wantCollection ? 
Classes.boundOfParameterizedProperty(method) : returnType;
          final boolean  isMetadata     = standard.isMetadata(elementType);
diff --cc 
core/sis-referencing/src/main/java/org/apache/sis/io/wkt/GeodeticObjectParser.java
index 5807f11d94,e6be6798b2..fb6a77b17f
--- 
a/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/GeodeticObjectParser.java
+++ 
b/core/sis-referencing/src/main/java/org/apache/sis/io/wkt/GeodeticObjectParser.java
@@@ -2032,9 -2028,9 +2032,9 @@@ class GeodeticObjectParser extends Math
          final Unit<?> unit = parseUnit(element);
          /*
           * A ParametricCRS can be either a "normal" one (with a non-null 
datum), or a DerivedCRS of kind ParametricCRS.
-          * In the later case, the datum is null and we have instead 
DerivingConversion element from a BaseParametricCRS.
+          * In the latter case, the datum is null and we have instead 
DerivingConversion element from a BaseParametricCRS.
           */
 -        ParametricDatum datum    = null;
 +        Datum           datum    = null;
          SingleCRS       baseCRS  = null;
          Conversion      fromBase = null;
          if (!isBaseCRS) {
diff --cc 
storage/sis-netcdf/src/test/java/org/apache/sis/internal/netcdf/TestCase.java
index 2db1bb0ce7,5cbab55a27..5d6dd435e7
--- 
a/storage/sis-netcdf/src/test/java/org/apache/sis/internal/netcdf/TestCase.java
+++ 
b/storage/sis-netcdf/src/test/java/org/apache/sis/internal/netcdf/TestCase.java
@@@ -22,10 -22,12 +22,11 @@@ import java.util.EnumMap
  import java.util.Iterator;
  import java.io.IOException;
  import java.lang.reflect.UndeclaredThrowableException;
+ import org.apache.sis.storage.AbstractResource;
  import org.apache.sis.storage.DataStoreException;
- import org.apache.sis.internal.storage.AbstractResource;
  import org.apache.sis.internal.netcdf.ucar.DecoderWrapper;
  import org.apache.sis.setup.GeometryLibrary;
+ import org.apache.sis.storage.event.StoreListeners;
 -import org.opengis.test.dataset.TestData;
  import ucar.nc2.dataset.NetcdfDataset;
  import ucar.nc2.NetcdfFile;
  import org.junit.AfterClass;
diff --cc 
storage/sis-netcdf/src/test/java/org/apache/sis/internal/netcdf/impl/ChannelDecoderTest.java
index 2488cac3a5,d426ecd587..19c45b421f
--- 
a/storage/sis-netcdf/src/test/java/org/apache/sis/internal/netcdf/impl/ChannelDecoderTest.java
+++ 
b/storage/sis-netcdf/src/test/java/org/apache/sis/internal/netcdf/impl/ChannelDecoderTest.java
@@@ -20,10 -20,8 +20,9 @@@ import java.io.IOException
  import java.io.InputStream;
  import java.nio.ByteBuffer;
  import java.nio.channels.Channels;
 +import org.apache.sis.internal.netcdf.TestData;
  import org.apache.sis.internal.netcdf.Decoder;
  import org.apache.sis.internal.netcdf.DecoderTest;
- import org.apache.sis.internal.storage.AbstractResource;
  import org.apache.sis.internal.storage.io.ChannelDataInput;
  import org.apache.sis.storage.DataStoreException;
  import org.apache.sis.setup.GeometryLibrary;
diff --cc 
storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Table.java
index 2511f268d2,05b889d543..b8079a82ce
--- 
a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Table.java
+++ 
b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Table.java
@@@ -190,10 -191,10 +190,10 @@@ final class Table extends AbstractFeatu
       *
       * @todo This constructor is not yet used because it is an unfinished 
work.
       *       We need to invent some mechanism for using a subset of the 
columns.
 -     *       A starting point is {@link 
org.apache.sis.storage.FeatureQuery#expectedType(FeatureType)}.
 +     *       A starting point is {@link 
org.apache.sis.storage.FeatureQuery#expectedType(DefaultFeatureType)}.
       */
      Table(final Table parent) {
-         super(parent);
+         super(parent.listeners);
          database = parent.database;
          query    = parent.query;
          name     = parent.name;
diff --cc 
storage/sis-storage/src/main/java/org/apache/sis/internal/storage/AggregatedFeatureSet.java
index 4c66e43eb0,f71a6dc628..20901a02fc
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/AggregatedFeatureSet.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/AggregatedFeatureSet.java
@@@ -28,9 -29,10 +29,10 @@@ import org.apache.sis.geometry.Envelope
  import org.apache.sis.storage.FeatureSet;
  import org.apache.sis.storage.DataStoreException;
  import org.apache.sis.storage.event.StoreListeners;
+ import org.apache.sis.storage.AbstractFeatureSet;
  
  // Branch-dependent imports
 -import org.opengis.feature.FeatureType;
 +import org.apache.sis.feature.DefaultFeatureType;
  
  
  /**
@@@ -141,10 -143,11 +143,11 @@@ abstract class AggregatedFeatureSet ext
       * @throws DataStoreException if an error occurred while reading metadata 
from the data stores.
       */
      @Override
-     protected void createMetadata(final MetadataBuilder metadata) throws 
DataStoreException {
-         super.createMetadata(metadata);
+     protected Metadata createMetadata() throws DataStoreException {
+         final MetadataBuilder metadata = new MetadataBuilder();
+         metadata.addDefaultMetadata(this, listeners);
          for (final FeatureSet fs : dependencies()) {
 -            final FeatureType type = fs.getType();
 +            final DefaultFeatureType type = fs.getType();
              metadata.addSource(fs.getMetadata(), ScopeCode.FEATURE_TYPE,
                      (type == null) ? null : new CharSequence[] 
{type.getName().toInternationalString()});
          }
diff --cc 
storage/sis-storage/src/main/java/org/apache/sis/internal/storage/TiledGridCoverage.java
index b2b47db179,1f6a737326..b2bc097536
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/TiledGridCoverage.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/TiledGridCoverage.java
@@@ -26,6 -26,8 +26,7 @@@ import java.awt.image.SampleModel
  import java.awt.image.MultiPixelPackedSampleModel;
  import java.awt.image.RenderedImage;
  import java.awt.image.Raster;
+ import org.opengis.util.GenericName;
 -import org.opengis.coverage.CannotEvaluateException;
  import org.opengis.geometry.MismatchedDimensionException;
  import org.apache.sis.coverage.grid.GridCoverage;
  import org.apache.sis.coverage.grid.GridExtent;
diff --cc 
storage/sis-storage/src/main/java/org/apache/sis/internal/storage/TiledGridResource.java
index 74985689a6,200f531923..bccca7a201
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/TiledGridResource.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/TiledGridResource.java
@@@ -33,7 -34,7 +33,8 @@@ import org.apache.sis.coverage.grid.Gri
  import org.apache.sis.coverage.grid.GridExtent;
  import org.apache.sis.coverage.grid.GridGeometry;
  import org.apache.sis.coverage.grid.GridRoundingMode;
 +import org.apache.sis.coverage.CannotEvaluateException;
+ import org.apache.sis.storage.AbstractGridCoverageResource;
  import org.apache.sis.storage.DataStoreException;
  import org.apache.sis.storage.RasterLoadingStrategy;
  import org.apache.sis.storage.event.StoreListeners;
diff --cc 
storage/sis-storage/src/main/java/org/apache/sis/internal/storage/WritableResourceSupport.java
index 0000000000,326a0ea3ee..27b59aaace
mode 000000,100644..100644
--- 
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
@@@ -1,0 -1,236 +1,236 @@@
+ /*
+  * 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;
++import org.apache.sis.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, or {@code null} if 
unknown.
+      * @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 isEmpty(input);
+         } else if (replace || isEmpty(input)) {
+             return true;
+         } else {
+             Object identifier = resource.getIdentifier().orElse(null);
+             if (identifier == null && input != null) identifier = 
input.filename;
+             throw new 
ResourceAlreadyExistsException(Resources.forLocale(getLocale())
+                     .getString(Resources.Keys.ResourceAlreadyExists_1, 
identifier));
+         }
+     }
+ 
+     /**
+      * Returns {@code true} if the given channel is empty.
+      * In case of doubt, this method conservatively returns {@code false}.
+      *
+      * @param  input  the channel to test for emptiness, or {@code null} if 
unknown.
+      * @return {@code true} if the channel is empty, or {@code false} if not 
or if unknown.
+      */
+     private static boolean isEmpty(final ChannelDataInput input) throws 
IOException {
+         return (input != null) && input.length() == 0;
+     }
+ 
+     /**
+      * 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.apply(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 --cc 
storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java
index 0000000000,0e588da647..c4948e86a5
mode 000000,100644..100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/RasterStore.java
@@@ -1,0 -1,520 +1,520 @@@
+ /*
+  * 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.esri;
+ 
+ import java.util.List;
+ import java.util.Arrays;
+ import java.util.Optional;
+ import java.util.Hashtable;
+ import java.util.logging.Level;
+ import java.io.IOException;
+ import java.io.FileNotFoundException;
+ import java.nio.file.NoSuchFileException;
+ import java.nio.file.Path;
+ import java.awt.image.ColorModel;
+ import java.awt.image.DataBuffer;
+ import java.awt.image.SampleModel;
+ import java.awt.image.BufferedImage;
+ import java.awt.image.WritableRaster;
+ import org.opengis.geometry.Envelope;
+ import org.opengis.metadata.Metadata;
+ import org.opengis.metadata.maintenance.ScopeCode;
+ import org.opengis.referencing.operation.TransformException;
+ import org.apache.sis.metadata.sql.MetadataStoreException;
+ import org.apache.sis.coverage.SampleDimension;
+ import org.apache.sis.coverage.grid.GridGeometry;
+ import org.apache.sis.coverage.grid.GridCoverage2D;
+ import org.apache.sis.image.PlanarImage;
+ import org.apache.sis.storage.GridCoverageResource;
+ import org.apache.sis.storage.DataStoreProvider;
+ import org.apache.sis.storage.DataStoreException;
+ import org.apache.sis.storage.DataStoreReferencingException;
+ import org.apache.sis.storage.StorageConnector;
+ import org.apache.sis.internal.storage.PRJDataStore;
+ import org.apache.sis.internal.storage.MetadataBuilder;
+ import org.apache.sis.internal.coverage.j2d.ColorModelFactory;
+ import org.apache.sis.internal.coverage.j2d.ImageUtilities;
+ import org.apache.sis.internal.storage.RangeArgument;
+ import org.apache.sis.internal.storage.Resources;
+ import org.apache.sis.internal.util.UnmodifiableArrayList;
+ import org.apache.sis.internal.util.Numerics;
+ import org.apache.sis.util.resources.Vocabulary;
+ import org.apache.sis.util.CharSequences;
+ import org.apache.sis.util.ArraysExt;
+ import org.apache.sis.math.Statistics;
+ 
+ 
+ /**
+  * Base class for the implementation of ASCII Grid or raw binary store.
+  * This base class manages the reading of following auxiliary files:
+  *
+  * <ul>
+  *   <li>{@code *.stx} for statistics about bands.</li>
+  *   <li>{@code *.clr} for the image color map.</li>
+  *   <li>{@code *.prj} for the CRS definition.</li>
+  * </ul>
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  * @version 1.2
+  * @since   1.2
+  * @module
+  */
+ abstract class RasterStore extends PRJDataStore implements 
GridCoverageResource {
+     /**
+      * Band to make visible if an image contains many bands
+      * but a color map is defined for only one band.
+      */
+     private static final int VISIBLE_BAND = 0;
+ 
+     /**
+      * Keyword for the number of rows in the image.
+      */
+     static final String NROWS = "NROWS";
+ 
+     /**
+      * Keyword for the number of columns in the image.
+      */
+     static final String NCOLS = "NCOLS";
+ 
+     /**
+      * The filename extension of {@code "*.stx"} and {@code "*.clr"} files.
+      *
+      * @see #getComponentFiles()
+      */
+     private static final String STX = "stx", CLR = "clr";
+ 
+     /**
+      * The color model, created from the {@code "*.clr"} file content when 
first needed.
+      * The color model and sample dimensions are created together because 
they depend on
+      * the same properties.
+      */
+     private ColorModel colorModel;
+ 
+     /**
+      * The sample dimensions, created from the {@code "*.stx"} file content 
when first needed.
+      * The sample dimensions and color model are created together because 
they depend on the same properties.
+      * This list is unmodifiable.
+      *
+      * @see #getSampleDimensions()
+      */
+     private List<SampleDimension> sampleDimensions;
+ 
+     /**
+      * The value to replace by NaN values, or {@link Double#NaN} if none.
+      */
+     double nodataValue;
+ 
+     /**
+      * The metadata object, or {@code null} if not yet created.
+      */
+     Metadata metadata;
+ 
+     /**
+      * Creates a new raster 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 (file, URL, stream, 
<i>etc</i>).
+      * @throws DataStoreException if an error occurred while creating the 
data store for the given storage.
+      */
+     RasterStore(final DataStoreProvider provider, final StorageConnector 
connector) throws DataStoreException {
+         super(provider, connector);
+         nodataValue = Double.NaN;
+         listeners.useWarningEventsOnly();
+     }
+ 
+     /**
+      * Returns the {@linkplain #location} as a {@code Path} component 
together with auxiliary files.
+      *
+      * @return the main file and auxiliary files as paths, or an empty array 
if unknown.
+      * @throws DataStoreException if the URI can not be converted to a {@link 
Path}.
+      */
+     @Override
+     public Path[] getComponentFiles() throws DataStoreException {
+         return listComponentFiles(PRJ, STX, CLR);
+     }
+ 
+     /**
+      * Returns the spatiotemporal extent of the raster file.
+      *
+      * @return the spatiotemporal resource extent.
+      * @throws DataStoreException if an error occurred while computing the 
envelope.
+      * @hidden
+      */
+     @Override
+     public Optional<Envelope> getEnvelope() throws DataStoreException {
+         return Optional.ofNullable(getGridGeometry().getEnvelope());
+     }
+ 
+     /**
+      * Builds metadata and assigns the result to the {@link #metadata} field.
+      *
+      * @param  formatName  name of the raster format.
+      * @param  formatKey   key of format description in the {@code 
SpatialMetadata} database.
+      * @throws DataStoreException if an error occurred during the parsing 
process.
+      */
+     final void createMetadata(final String formatName, final String 
formatKey) throws DataStoreException {
+         final GridGeometry gridGeometry = getGridGeometry();        // May 
cause parsing of header.
+         final MetadataBuilder builder = new MetadataBuilder();
+         try {
+             builder.setPredefinedFormat(formatKey);
+         } catch (MetadataStoreException e) {
+             builder.addFormatName(formatName);
+             listeners.warning(e);
+         }
 -        builder.addResourceScope(ScopeCode.COVERAGE, null);
++        builder.addResourceScope(ScopeCode.valueOf("COVERAGE"), null);
+         builder.addEncoding(encoding, MetadataBuilder.Scope.METADATA);
+         builder.addSpatialRepresentation(null, gridGeometry, true);
+         try {
+             builder.addExtent(gridGeometry.getEnvelope());
+         } catch (TransformException e) {
+             throw new DataStoreReferencingException(getLocale(), formatName, 
getDisplayName(), null).initCause(e);
+         }
+         /*
+          * Do not invoke `getSampleDimensions()` because computing sample 
dimensions without statistics
+          * may cause the loading of the full image. Even if 
`GridCoverage.getSampleDimensions()` exists
+          * and could be used opportunistically, we do not use it in order to 
keep a deterministic behavior
+          * (we do not want the metadata to vary depending on the order in 
which methods are invoked).
+          */
+         if (sampleDimensions != null) {
+             for (final SampleDimension band : sampleDimensions) {
+                 builder.addNewBand(band);
+             }
+         }
+         addTitleOrIdentifier(builder);
+         builder.setISOStandards(false);
+         metadata = builder.buildAndFreeze();
+     }
+ 
+     /**
+      * Reads the {@code "*.clr"} auxiliary file. Syntax is as below, with one 
line per color:
+      *
+      * <pre>value red green blue</pre>
+      *
+      * The specification said that lines that do not start with a number 
shall be ignored as comment.
+      * Any characters after the fourth number shall also be ignored and can 
be used as comment.
+      *
+      * <h4>Limitations</h4>
+      * Current implementation requires the data type to be {@link 
DataBuffer#TYPE_BYTE} or
+      * {@link DataBuffer#TYPE_SHORT}. A future version could create scaled 
color model for
+      * floating point values as well.
+      *
+      * @param  mapSize   minimal size of index color model map. The actual 
size may be larger.
+      * @param  numBands  number of bands in the sample model. Only one of 
them will be visible.
+      * @return the color model, or {@code null} if the file does not contain 
enough entries.
+      * @throws NoSuchFileException if the auxiliary file has not been found 
(when opened from path).
+      * @throws FileNotFoundException if the auxiliary file has not been found 
(when opened from URL).
+      * @throws IOException if another error occurred while opening the stream.
+      * @throws NumberFormatException if a number can not be parsed.
+      */
+     private ColorModel readColorMap(final int dataType, final int mapSize, 
final int numBands)
+             throws DataStoreException, IOException
+     {
+         final int maxSize;
+         switch (dataType) {
+             case DataBuffer.TYPE_BYTE:   maxSize = 0xFF;   break;
+             case DataBuffer.TYPE_USHORT: maxSize = 0xFFFF; break;
+             default: return null;                           // Limitation 
documented in above javadoc.
+         }
+         int count = 0;
+         long[] indexAndColors = ArraysExt.EMPTY_LONG;       // Index in 
highest 32 bits, ARGB in lowest 32 bits.
+         for (final CharSequence line : 
CharSequences.splitOnEOL(readAuxiliaryFile(CLR))) {
+             final int end   = CharSequences.skipTrailingWhitespaces(line, 0, 
line.length());
+             final int start = CharSequences.skipLeadingWhitespaces(line, 0, 
end);
+             if (start < end && Character.isDigit(Character.codePointAt(line, 
start))) {
+                 int column = 0;
+                 long code = 0;
+                 for (final CharSequence item : 
CharSequences.split(line.subSequence(start, end), ' ')) {
+                     if (item.length() != 0) {
+                         int value = Integer.parseInt(item.toString());
+                         if (column == 0) {
+                             code = ((long) value) << Integer.SIZE;
+                         } else {
+                             value = Math.max(0, Math.min(255, value));
+                             code |= value << ((3 - column) * Byte.SIZE);
+                         }
+                         if (++column >= 4) break;
+                     }
+                 }
+                 if (count >= indexAndColors.length) {
+                     indexAndColors = Arrays.copyOf(indexAndColors, 
Math.max(count*2, 64));
+                 }
+                 indexAndColors[count++] = code | 0xFF000000L;
+             }
+         }
+         if (count <= 1) {
+             return null;
+         }
+         /*
+          * Sort the color entries in increasing index order. Because we put 
the value in the highest bits,
+          * we can sort the `long` entries directly. If the file contains more 
entries than what the color
+          * map can contains, the last entries are discarded.
+          */
+         Arrays.sort(indexAndColors, 0, count);
+         int[] ARGB = new int[Math.max(mapSize, 
Math.toIntExact((indexAndColors[count-1] >>> Integer.SIZE) + 1))];
+         final int[] colors = new int[2];
+         for (int i=1; i<count; i++) {
+             final int lower = (int) (indexAndColors[i-1] >>> Integer.SIZE);
+             final int upper = (int) (indexAndColors[i  ] >>> Integer.SIZE);
+             if (upper >= lower) {
+                 colors[0] = (int) indexAndColors[i-1];
+                 colors[1] = (int) indexAndColors[i  ];
+                 ColorModelFactory.expand(colors, ARGB, lower, upper + 1);
+             }
+             if (upper > maxSize) {
+                 ARGB = Arrays.copyOf(ARGB, maxSize + 1);
+                 break;
+             }
+         }
+         return ColorModelFactory.createIndexColorModel(numBands, 
VISIBLE_BAND, ARGB, true, -1);
+     }
+ 
+     /**
+      * Reads the {@code "*.stx"} auxiliary file. Syntax is as below, with one 
line per band.
+      * Value between {…} are optional and can be skipped with a # sign in 
place of the number.
+      *
+      * <pre>band minimum maximum {mean} {std_deviation} {linear_stretch_min} 
{linear_stretch_max}</pre>
+      *
+      * The specification said that lines that do not start with a number 
shall be ignored as comment.
+      *
+      * @todo Stretch values are not yet stored.
+      *
+      * @return statistics for each band. Some elements may be null if not 
specified in the file.
+      * @throws NoSuchFileException if the auxiliary file has not been found 
(when opened from path).
+      * @throws FileNotFoundException if the auxiliary file has not been found 
(when opened from URL).
+      * @throws IOException if another error occurred while opening the stream.
+      * @throws NumberFormatException if a number can not be parsed.
+      */
+     private Statistics[] readStatistics(final String name, final SampleModel 
sm)
+             throws DataStoreException, IOException
+     {
+         final Statistics[] stats = new Statistics[sm.getNumBands()];
+         for (final CharSequence line : 
CharSequences.splitOnEOL(readAuxiliaryFile(STX))) {
+             final int end   = CharSequences.skipTrailingWhitespaces(line, 0, 
line.length());
+             final int start = CharSequences.skipLeadingWhitespaces(line, 0, 
end);
+             if (start < end && Character.isDigit(Character.codePointAt(line, 
start))) {
+                 int column     = 0;
+                 int band       = 0;
+                 double minimum = Double.NaN;
+                 double maximum = Double.NaN;
+                 double mean    = Double.NaN;
+                 double stdev   = Double.NaN;
+                 for (final CharSequence item : 
CharSequences.split(line.subSequence(start, end), ' ')) {
+                     if (item.length() != 0) {
+                         if (column == 0) {
+                             band = Integer.parseInt(item.toString());
+                         } else if (item.charAt(0) != '#') {
+                             final double value = 
Double.parseDouble(item.toString());
+                             switch (column) {
+                                 case 1: minimum = value; break;
+                                 case 2: maximum = value; break;
+                                 case 3: mean    = value; break;
+                                 case 4: stdev   = value; break;
+                             }
+                         }
+                         column++;
+                     }
+                 }
+                 if (band >= 1 && band <= stats.length) {
+                     final int count = Math.multiplyExact(sm.getWidth(), 
sm.getHeight());
+                     stats[band - 1] = new Statistics(name, 0, count, minimum, 
maximum, mean, stdev, false);
+                 }
+             }
+         }
+         return stats;
+     }
+ 
+     /**
+      * Loads {@code "*.stx"} and {@code "*.clr"} files if present then builds 
{@link #sampleDimensions} and
+      * {@link #colorModel} from those information. If no color map is found, 
a grayscale color model is created.
+      *
+      * @param  name   name to use for the sample dimension, or {@code null} 
if untitled.
+      * @param  sm     the sample model to use for creating a default color 
model if no {@code "*.clr"} file is found.
+      * @param  stats  if the caller collected statistics by itself, those 
statistics for each band. Otherwise empty.
+      * @throws DataStoreException if an error occurred while loading an 
auxiliary file.
+      */
+     final void loadBandDescriptions(String name, final SampleModel sm, 
Statistics... stats) throws DataStoreException {
+         final SampleDimension[] bands = new SampleDimension[sm.getNumBands()];
+         /*
+          * If the "*.stx" file is found, the statistics read from that file 
will replace the specified one.
+          * Otherwise the `stats` parameter will be left unchanged. We read 
statistics even if a color map
+          * overwrite them because we need the minimum/maximum values for 
building the sample dimensions.
+          */
+         try {
+             stats = readStatistics(name, sm);
+         } catch (IOException | NumberFormatException e) {
+             canNotReadAuxiliaryFile(STX, e);
+         }
+         /*
+          * Build the sample dimensions and the color model.
+          * Some minimum/maximum values will be used as fallback if no 
statistics were found.
+          */
+         final int     dataType   = sm.getDataType();
+         final boolean isInteger  = ImageUtilities.isIntegerType(dataType);
+         final boolean isUnsigned = isInteger && 
ImageUtilities.isUnsignedType(sm);
+         final boolean isRGB      = isInteger && (bands.length == 3 || 
bands.length == 4);
+         final SampleDimension.Builder builder = new SampleDimension.Builder();
+         for (int band=0; band < bands.length; band++) {
+             double minimum = Double.NaN;
+             double maximum = Double.NaN;
+             if (band < stats.length) {
+                 final Statistics s = stats[band];
+                 if (s != null) {                    // `readStatistics()` may 
have left some values to null.
+                     minimum = s.minimum();
+                     maximum = s.maximum();
+                 }
+             }
+             /*
+              * If statistics were not specified and the sample type is 
integer,
+              * the minimum and maximum values may change for each band because
+              * the sample size (in bits) can vary.
+              */
+             if (!(minimum <= maximum)) {        // Use `!` for catching NaN.
+                 minimum = 0;
+                 maximum = 1;
+                 if (isInteger) {
+                     long max = Numerics.bitmask(sm.getSampleSize(band)) - 1;
+                     if (!isUnsigned) {
+                         max >>>= 1;
+                         minimum = ~max;         // Tild operator, not minus.
+                     }
+                     maximum = max;
+                 }
+             }
+             /*
+              * Create the sample dimension for this band. The same "no data" 
value is used for all bands.
+              * The sample dimension is considered "converted" on the 
assumption that caller will replace
+              * all "no data" value by NaN before to return the raster to the 
user.
+              */
+             if (isRGB) {
+                 
builder.setName(Vocabulary.formatInternational(RGB_BAND_NAMES[band]));
+             } else {
+                 if (name != null) {
+                     builder.setName(name);
+                     name = null;                // Use the name only for the 
first band.
+                 }
+                 builder.addQuantitative(null, minimum, maximum, null);
+                 if (nodataValue < minimum || nodataValue > maximum) {
+                     builder.mapQualitative(null, nodataValue, Float.NaN);
+                 }
+             }
+             bands[band] = builder.build().forConvertedValues(!isInteger);
+             builder.clear();
+             /*
+              * Create the color model using the statistics of the band that 
we choose to make visible,
+              * or using a RGB color model if the number of bands or the data 
type is compatible.
+              * The color file is optional and will be used if present.
+              */
+             if (band == VISIBLE_BAND) {
+                 if (isRGB) {
+                     colorModel = ColorModelFactory.createRGB(sm);
+                 } else {
+                     try {
+                         colorModel = readColorMap(dataType, (int) (maximum + 
1), bands.length);
+                     } catch (IOException | NumberFormatException e) {
+                         canNotReadAuxiliaryFile(CLR, e);
+                     }
+                     if (colorModel == null) {
+                         colorModel = 
ColorModelFactory.createGrayScale(dataType, bands.length, band, minimum, 
maximum);
+                     }
+                 }
+             }
+         }
+         sampleDimensions = UnmodifiableArrayList.wrap(bands);
+     }
+ 
+     /**
+      * Sends a warning about a failure to read an optional auxiliary file.
+      * This is used for errors that affect only the rendering, not the 
georeferencing.
+      *
+      * @param  suffix     suffix of the auxiliary file.
+      * @param  exception  error that occurred while reading the auxiliary 
file.
+      */
+     private void canNotReadAuxiliaryFile(final String suffix, final Exception 
exception) {
+         Level level = Level.WARNING;
+         if (exception instanceof NoSuchFileException || exception instanceof 
FileNotFoundException) {
+             level = Level.FINE;
+         }
+         listeners.warning(level, 
Resources.format(Resources.Keys.CanNotReadAuxiliaryFile_1, suffix), exception);
+     }
+ 
+     /**
+      * Default names of bands when the color model is RGB or RGBA.
+      */
+     private static final short[] RGB_BAND_NAMES = {
+         Vocabulary.Keys.Red,
+         Vocabulary.Keys.Green,
+         Vocabulary.Keys.Blue,
+         Vocabulary.Keys.Transparency
+     };
+ 
+     /**
+      * Creates the grid coverage resulting from a {@link #read(GridGeometry, 
int...)} operation.
+      *
+      * @param  domain  the effective domain after intersection and 
subsampling.
+      * @param  range   indices of selected bands.
+      * @param  data    the loaded data.
+      * @param  stats   statistics to save as a property, or {@code null} if 
none.
+      * @return the grid coverage.
+      */
+     @SuppressWarnings("UseOfObsoleteCollectionType")
+     final GridCoverage2D createCoverage(final GridGeometry domain, final 
RangeArgument range,
+                                         final WritableRaster data, final 
Statistics stats)
+     {
+         Hashtable<String,Object> properties = null;
+         if (stats != null) {
+             final Statistics[] as = new Statistics[range.getNumBands()];
+             Arrays.fill(as, stats);
+             properties = new Hashtable<>();
+             properties.put(PlanarImage.STATISTICS_KEY, as);
+         }
+         List<SampleDimension> bands = sampleDimensions;
+         ColorModel cm = colorModel;
+         if (!range.isIdentity()) {
+             bands = Arrays.asList(range.select(sampleDimensions));
+             cm = range.select(cm).orElse(null);
+             if (cm == null) {
+                 final SampleDimension band = bands.get(VISIBLE_BAND);
+                 cm = ColorModelFactory.createGrayScale(data.getSampleModel(), 
VISIBLE_BAND, band.getSampleRange().orElse(null));
+             }
+         }
+         return new GridCoverage2D(domain, bands, new BufferedImage(cm, data, 
false, properties));
+     }
+ 
+     /**
+      * Returns the sample dimensions computed by {@code 
loadBandDescriptions(…)}.
+      * Shall be overridden by subclasses in a synchronized method. The 
subclass
+      * must ensure that {@code loadBandDescriptions(…)} has been invoked once.
+      *
+      * @return the sample dimensions, or {@code null} if not yet computed.
+      */
+     @Override
+     @SuppressWarnings("ReturnOfCollectionOrArrayField")
+     public List<SampleDimension> getSampleDimensions() throws 
DataStoreException {
+         return sampleDimensions;
+     }
+ 
+     /**
+      * Closes this data store and releases any underlying resources.
+      * Shall be overridden by subclasses in a synchronized method.
+      *
+      * @throws DataStoreException if an error occurred while closing this 
data store.
+      */
+     @Override
+     public void close() throws DataStoreException {
+         metadata = null;
+     }
+ }
diff --cc 
storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/WritableStore.java
index 0000000000,5e57c11003..14aca45fbb
mode 000000,100644..100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/WritableStore.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/esri/WritableStore.java
@@@ -1,0 -1,309 +1,311 @@@
+ /*
+  * 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.esri;
+ 
+ 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.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;
+ 
++// Branch-dependent imports
++import org.apache.sis.image.SequenceType;
++
+ 
+ /**
+  * An ASCII Grid store with writing capabilities.
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  * @version 1.2
+  * @since   1.2
+  * @module
+  */
+ final class WritableStore extends AsciiGridStore implements 
WritableGridCoverageResource {
+     /**
+      * The line separator for writing the ASCII file.
+      */
+     private final String lineSeparator;
+ 
+     /**
+      * The output if this store is write-only, or {@code null} if this store 
is read/write.
+      * This is set to {@code null} when the store is closed.
+      */
+     private ChannelDataOutput output;
+ 
+     /**
+      * 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 AsciiGridStoreProvider provider, final 
StorageConnector connector) throws DataStoreException {
+         super(provider, connector, false);
+         lineSeparator = System.lineSeparator();
+         if (!super.canReadOrWrite(false)) {
+             output = connector.commit(ChannelDataOutput.class, 
AsciiGridStoreProvider.NAME);
+         }
+     }
+ 
+     /**
+      * Returns whether this store can read or write.
+      */
+     @Override
+     boolean canReadOrWrite(final boolean write) {
+         return write || super.canReadOrWrite(write);
+     }
+ 
+     /**
+      * 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(AsciiGridStoreProvider.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 int band = 0;                                 // May become 
configurable in a future version.
+         try {
+             /*
+              * If `output` is non-null, we are in write-only mode and there 
is no previously existing image.
+              * Otherwise an image may exist and the behavior will depend on 
which options were supplied.
+              */
+             if (output == null && !h.replace(input().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 = (output != null) ? output : 
h.channel(input().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();
+             /*
+              * If the channel is write-only (e.g. if we are writing in an 
`OutputStream`),
+              * we will not be able to write a second time.
+              */
+             if (output != null) {
+                 output = null;
+                 out.channel.close();
+             }
+         } 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));
+         }
+     }
+ 
+     /**
+      * Closes this data store and releases any underlying resources.
+      *
+      * @throws DataStoreException if an error occurred while closing this 
data store.
+      */
+     @Override
+     public synchronized void close() throws DataStoreException {
+         final ChannelDataOutput out = output;
+         output = null;
+         if (out != null) try {
+             out.channel.close();
+         } catch (IOException e) {
+             throw new DataStoreException(e);
+         }
+         /*
+          * No need for try-with-resource because only one
+          * of `input` and `output` should be non-null.
+          */
+         super.close();
+     }
+ }
diff --cc 
storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStore.java
index 0000000000,4ff7398186..cab79cb71e
mode 000000,100644..100644
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStore.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WorldFileStore.java
@@@ -1,0 -1,829 +1,829 @@@
+ /*
+  * 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.image;
+ 
+ import java.util.Arrays;
+ import java.util.Collection;
+ import java.util.Map;
+ import java.util.HashMap;
+ import java.util.LinkedHashMap;
+ import java.util.logging.Level;
+ import java.io.IOException;
+ import java.io.EOFException;
+ import java.io.FileNotFoundException;
+ import java.io.UncheckedIOException;
+ import java.nio.file.Files;
+ import java.nio.file.Path;
+ import java.nio.file.NoSuchFileException;
+ import java.nio.file.StandardOpenOption;
+ import javax.imageio.ImageIO;
+ import javax.imageio.ImageReader;
+ import javax.imageio.ImageWriter;
+ import javax.imageio.spi.ImageReaderSpi;
+ import javax.imageio.stream.ImageInputStream;
+ import org.opengis.metadata.Metadata;
+ import org.opengis.metadata.maintenance.ScopeCode;
+ import org.opengis.referencing.datum.PixelInCell;
+ import org.opengis.referencing.operation.TransformException;
+ import org.apache.sis.coverage.grid.GridExtent;
+ import org.apache.sis.coverage.grid.GridGeometry;
+ import org.apache.sis.storage.Resource;
+ import org.apache.sis.storage.Aggregate;
+ import org.apache.sis.storage.StorageConnector;
+ import org.apache.sis.storage.GridCoverageResource;
+ import org.apache.sis.storage.DataStoreException;
+ import org.apache.sis.storage.DataStoreClosedException;
+ import org.apache.sis.storage.DataStoreContentException;
+ import org.apache.sis.storage.DataStoreReferencingException;
+ import org.apache.sis.storage.ReadOnlyStorageException;
+ import org.apache.sis.storage.UnsupportedStorageException;
+ import org.apache.sis.internal.storage.Resources;
+ import org.apache.sis.internal.storage.PRJDataStore;
+ import org.apache.sis.internal.storage.io.IOUtilities;
+ import org.apache.sis.internal.referencing.j2d.AffineTransform2D;
+ import org.apache.sis.internal.storage.MetadataBuilder;
+ import org.apache.sis.internal.util.ListOfUnknownSize;
+ import org.apache.sis.metadata.sql.MetadataStoreException;
+ import org.apache.sis.util.collection.BackingStoreException;
+ import org.apache.sis.util.resources.Errors;
+ import org.apache.sis.util.CharSequences;
+ import org.apache.sis.util.ArraysExt;
+ import org.apache.sis.setup.OptionKey;
+ 
+ 
+ /**
+  * A data store which creates grid coverages from Image I/O readers using 
<cite>World File</cite> convention.
+  * Georeferencing is defined by two auxiliary files having the same name than 
the image file but different suffixes:
+  *
+  * <ul class="verbose">
+  *   <li>A text file containing the coefficients of the affine transform 
mapping pixel coordinates to geodesic coordinates.
+  *     The reader expects one coefficient per line, in the same order than 
the order expected by the
+  *     {@link java.awt.geom.AffineTransform#AffineTransform(double[]) 
AffineTransform(double[])} constructor, which is
+  *     <var>scaleX</var>, <var>shearY</var>, <var>shearX</var>, 
<var>scaleY</var>, <var>translateX</var>, <var>translateY</var>.
+  *     The reader looks for a file having the following suffixes, in 
preference order:
+  *     <ol>
+  *       <li>The first letter of the image file extension, followed by the 
last letter of
+  *         the image file extension, followed by {@code 'w'}. Example: {@code 
"tfw"} for
+  *         {@code "tiff"} images, and {@code "jgw"} for {@code "jpeg"} 
images.</li>
+  *       <li>The extension of the image file with a {@code 'w'} appended.</li>
+  *       <li>The {@code "wld"} extension.</li>
+  *     </ol>
+  *   </li>
+  *   <li>A text file containing the <cite>Coordinate Reference System</cite> 
(CRS) definition
+  *     in <cite>Well Known Text</cite> (WKT) syntax.
+  *     The reader looks for a file having the {@code ".prj"} extension.</li>
+  * </ul>
+  *
+  * Every auxiliary text file are expected to be encoded in UTF-8
+  * and every numbers are expected to be formatted in US locale.
+  *
+  * <h2>Type of input objects</h2>
+  * The {@link StorageConnector} input should be an instance of the following 
types:
+  * {@link java.nio.file.Path}, {@link java.io.File}, {@link java.net.URL} or 
{@link java.net.URI}.
+  * Other types such as {@link ImageInputStream} are also accepted but in 
those cases the auxiliary files can not be read.
+  * For any input of unknown type, this data store first checks if an {@link 
ImageReader} accepts the input type directly.
+  * If none is found, this data store tries to {@linkplain 
ImageIO#createImageInputStream(Object) create an input stream}
+  * from the input object.
+  *
+  * <p>The storage input object may also be an {@link ImageReader} instance 
ready for use
+  * (i.e. with its {@linkplain ImageReader#setInput(Object) input set} to a 
non-null value).
+  * In that case, this data store will use the given image reader as-is.
+  * The image reader will be {@linkplain ImageReader#dispose() disposed}
+  * and its input closed (if {@link AutoCloseable}) when this data store is 
{@linkplain #close() closed}.</p>
+  *
+  * <h2>Handling of multi-image files</h2>
+  * Because some image formats can store an arbitrary amount of images,
+  * this data store is considered as an aggregate with one resource per image.
+  * All image should have the same size and all resources will share the same 
{@link GridGeometry}.
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  * @version 1.2
+  * @since   1.2
+  * @module
+  */
+ class WorldFileStore extends PRJDataStore implements Aggregate {
+     /**
+      * Image I/O format names (ignoring case) for which we have an entry in 
the {@code SpatialMetadata} database.
+      */
+     private static final String[] KNOWN_FORMATS = {
+         "PNG"
+     };
+ 
+     /**
+      * Index of the main image. This is relevant only with formats capable to 
store an arbitrary amount of images.
+      * Current implementation assumes that the main image is always the first 
one, but it may become configurable
+      * in a future version if useful.
+      *
+      * @see #width
+      * @see #height
+      */
+     static final int MAIN_IMAGE = 0;
+ 
+     /**
+      * The default World File suffix when it can not be determined from 
{@link #location}.
+      * This is a GDAL convention.
+      */
+     private static final String DEFAULT_SUFFIX = "wld";
+ 
+     /**
+      * The "cell center" versus "cell corner" interpretation of translation 
coefficients.
+      * The ESRI specification said that the coefficients map to pixel center.
+      */
+     static final PixelInCell CELL_ANCHOR = PixelInCell.CELL_CENTER;
+ 
+     /**
+      * The filename extension (may be an empty string), or {@code null} if 
unknown.
+      * It does not include the leading dot.
+      */
+     final String suffix;
+ 
+     /**
+      * The filename extension for the auxiliary "world file".
+      * For the TIFF format, this is typically {@code "tfw"}.
+      * This is computed as a side-effect of {@link #readWorldFile()}.
+      */
+     private String suffixWLD;
+ 
+     /**
+      * The image reader, set by the constructor and cleared when the store is 
closed.
+      * May also be null if the store is initially write-only, in which case a 
reader
+      * may be created the first time than an image is read.
+      *
+      * @see #reader()
+      */
+     private ImageReader reader;
+ 
+     /**
+      * Width and height of the main image.
+      * The {@link #gridGeometry} is assumed valid only for images having this 
size.
+      *
+      * @see #MAIN_IMAGE
+      * @see #gridGeometry
+      */
+     private int width, height;
+ 
+     /**
+      * The conversion from pixel center to CRS, or {@code null} if none or 
not yet computed.
+      * The grid extent has the size given by {@link #width} and {@link 
#height}.
+      *
+      * @see #crs
+      * @see #width
+      * @see #height
+      * @see #getGridGeometry(int)
+      */
+     private GridGeometry gridGeometry;
+ 
+     /**
+      * All images in this resource, created when first needed.
+      * Elements in this list will also be created when first needed.
+      *
+      * @see #components()
+      */
+     private Components components;
+ 
+     /**
+      * The metadata object, or {@code null} if not yet created.
+      *
+      * @see #getMetadata()
+      */
+     private Metadata metadata;
+ 
+     /**
+      * Identifiers used by a resource. Identifiers must be unique in the data 
store,
+      * so after an identifier has been used it can not be reused anymore even 
if the
+      * resource having that identifier has been removed.
+      * Values associated to identifiers tell whether the resource still exist.
+      *
+      * @see WorldFileResource#getIdentifier()
+      */
+     final Map<String,Boolean> identifiers;
+ 
+     /**
+      * Creates a new 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>).
+      * @param  readOnly   whether to fail if the channel can not be opened at 
least in read mode.
+      * @throws DataStoreException if an error occurred while opening the 
stream.
+      * @throws IOException if an error occurred while creating the image 
reader instance.
+      */
+     WorldFileStore(final WorldFileStoreProvider provider, final 
StorageConnector connector, final boolean readOnly)
+             throws DataStoreException, IOException
+     {
+         super(provider, connector);
+         identifiers = new HashMap<>();
+         listeners.useWarningEventsOnly();
+         final Object storage = connector.getStorage();
+         if (storage instanceof ImageReader) {
+             reader = (ImageReader) storage;
+             suffix = IOUtilities.extension(reader.getInput());
+             configureReader();
+             return;
+         }
+         if (storage instanceof ImageWriter) {
+             suffix = IOUtilities.extension(((ImageWriter) 
storage).getOutput());
+             return;
+         }
+         suffix = IOUtilities.extension(storage);
+         if (!(readOnly || fileExists(connector))) {
+             /*
+              * If the store is opened in read-write mode, create the image 
reader only
+              * if the file exists and is non-empty. Otherwise we let `reader` 
to null
+              * and the caller will create an image writer instead.
+              */
+             return;
+         }
+         /*
+          * Search for a reader that claim to be able to read the storage 
input.
+          * First we try readers associated to the file suffix. If no reader is
+          * found, we try all other readers.
+          */
+         final Map<ImageReaderSpi,Boolean> deferred = new LinkedHashMap<>();
+         if (suffix != null) {
+             reader = FormatFilter.SUFFIX.createReader(suffix, connector, 
deferred);
+         }
+         if (reader == null) {
+             reader = FormatFilter.SUFFIX.createReader(null, connector, 
deferred);
+ fallback:   if (reader == null) {
+                 /*
+                  * If no reader has been found, maybe `StorageConnector` has 
not been able to create
+                  * an `ImageInputStream`. It may happen if the storage object 
is of unknown type.
+                  * Check if it is the case, then try all providers that we 
couldn't try because of that.
+                  */
+                 ImageInputStream stream = null;
+                 for (final Map.Entry<ImageReaderSpi,Boolean> entry : 
deferred.entrySet()) {
+                     if (entry.getValue()) {
+                         if (stream == null) {
+                             if (!readOnly) {
+                                 // ImageOutputStream is both read and write.
+                                 stream = 
ImageIO.createImageOutputStream(storage);
+                             }
+                             if (stream == null) {
+                                 stream = 
ImageIO.createImageInputStream(storage);
+                                 if (stream == null) break;
+                             }
+                         }
+                         final ImageReaderSpi p = entry.getKey();
+                         if (p.canDecodeInput(stream)) {
+                             connector.closeAllExcept(storage);
+                             reader = p.createReaderInstance();
+                             reader.setInput(stream);
+                             break fallback;
+                         }
+                     }
+                 }
+                 throw new UnsupportedStorageException(super.getLocale(), 
WorldFileStoreProvider.NAME,
+                             storage, 
connector.getOption(OptionKey.OPEN_OPTIONS));
+             }
+         }
+         configureReader();
+         /*
+          * Do not invoke any method that may cause the image reader to start 
reading the stream,
+          * because the `WritableStore` subclass will want to save the initial 
stream position.
+          */
+     }
+ 
+     /**
+      * Sets the locale to use for warning messages, if supported. If the 
reader
+      * does not support the locale, the reader's default locale will be used.
+      */
+     private void configureReader() {
+         try {
+             reader.setLocale(listeners.getLocale());
+         } catch (IllegalArgumentException e) {
+             // Ignore
+         }
+         reader.addIIOReadWarningListener(new WarningListener(listeners));
+     }
+ 
+     /**
+      * Returns {@code true} if the image file exists and is non-empty.
+      * This is used for checking if an {@link ImageReader} should be created.
+      * If the file is going to be truncated, then it is considered already 
empty.
+      *
+      * @param  connector  the connector to use for opening the file.
+      * @return whether the image file exists and is non-empty.
+      */
+     private boolean fileExists(final StorageConnector connector) throws 
DataStoreException, IOException {
+         if (!ArraysExt.contains(connector.getOption(OptionKey.OPEN_OPTIONS), 
StandardOpenOption.TRUNCATE_EXISTING)) {
+             for (Path path : super.getComponentFiles()) {
+                 if (Files.isRegularFile(path) && Files.size(path) > 0) {
+                     return true;
+                 }
+             }
+         }
+         return false;
+     }
+ 
+     /**
+      * Returns the preferred suffix for the auxiliary world file. For TIFF 
images, this is {@code "tfw"}.
+      * This method tries to use the same case (lower-case or upper-case) than 
the suffix of the main file.
+      */
+     private String getWorldFileSuffix() {
+         if (suffix != null) {
+             final int length = suffix.length();
+             if (suffix.codePointCount(0, length) >= 2) {
+                 boolean lower = true;
+                 for (int i = length; i > 0;) {
+                     final int c = suffix.codePointBefore(i);
+                     lower =  Character.isLowerCase(c); if ( lower) break;
+                     lower = !Character.isUpperCase(c); if (!lower) break;
+                     i -= Character.charCount(c);
+                 }
+                 // If the case can not be determined, `lower` will default to 
`true`.
+                 return new StringBuilder(3)
+                         .appendCodePoint(suffix.codePointAt(0))
+                         .appendCodePoint(suffix.codePointBefore(length))
+                         .append(lower ? 'w' : 'W').toString();
+             }
+         }
+         return DEFAULT_SUFFIX;
+     }
+ 
+     /**
+      * Reads the "World file" by searching for an auxiliary file with a 
suffix inferred from
+      * the suffix of the main file. This method tries suffixes with the 
following conventions,
+      * in preference order.
+      *
+      * <ol>
+      *   <li>First letter of main file suffix, followed by last letter, 
followed by {@code 'w'}.</li>
+      *   <li>Full suffix of the main file followed by {@code 'w'}.</li>
+      *   <li>{@value #DEFAULT_SUFFIX}.</li>
+      * </ol>
+      *
+      * @return the "World file" content as an affine transform, or {@code 
null} if none was found.
+      * @throws IOException if an I/O error occurred.
+      * @throws DataStoreException if the auxiliary file content can not be 
parsed.
+      */
+     private AffineTransform2D readWorldFile() throws IOException, 
DataStoreException {
+         IOException warning = null;
+         final String preferred = getWorldFileSuffix();
+ loop:   for (int convention=0;; convention++) {
+             final String wld;
+             switch (convention) {
+                 default: break loop;
+                 case 0:  wld = preferred;      break;       // First file 
suffix to search.
+                 case 2:  wld = DEFAULT_SUFFIX; break;       // File suffix to 
search in last resort.
+                 case 1: {
+                     if (preferred.equals(DEFAULT_SUFFIX)) break loop;
+                     wld = suffix + preferred.charAt(preferred.length() - 1);
+                     break;
+                 }
+             }
+             try {
+                 return readWorldFile(wld);
+             } catch (NoSuchFileException | FileNotFoundException e) {
+                 if (warning == null) {
+                     warning = e;
+                 } else {
+                     warning.addSuppressed(e);
+                 }
+             }
+         }
+         if (warning != null) {
+             
listeners.warning(resources().getString(Resources.Keys.CanNotReadAuxiliaryFile_1,
 preferred), warning);
+         }
+         return null;
+     }
+ 
+     /**
+      * Reads the "World file" by parsing an auxiliary file with the given 
suffix.
+      *
+      * @param  wld  suffix of the auxiliary file.
+      * @return the "World file" content as an affine transform.
+      * @throws IOException if an I/O error occurred.
+      * @throws DataStoreException if the file content can not be parsed.
+      */
+     private AffineTransform2D readWorldFile(final String wld) throws 
IOException, DataStoreException {
+         final AuxiliaryContent content  = readAuxiliaryFile(wld);
+         final String           filename = content.getFilename();
+         final CharSequence[]   lines    = 
CharSequences.splitOnEOL(readAuxiliaryFile(wld));
+         final int              expected = 6;        // Expected number of 
elements.
+         int                    count    = 0;        // Actual number of 
elements.
+         final double[]         elements = new double[expected];
+         for (int i=0; i<expected; i++) {
+             final String line = lines[i].toString().trim();
+             if (!line.isEmpty() && line.charAt(0) != '#') {
+                 if (count >= expected) {
+                     throw new 
DataStoreContentException(errors().getString(Errors.Keys.TooManyOccurrences_2, 
expected, "coefficient"));
+                 }
+                 try {
+                     elements[count++] = Double.parseDouble(line);
+                 } catch (NumberFormatException e) {
+                     throw new 
DataStoreContentException(errors().getString(Errors.Keys.ErrorInFileAtLine_2, 
filename, i), e);
+                 }
+             }
+         }
+         if (count != expected) {
+             throw new 
EOFException(errors().getString(Errors.Keys.UnexpectedEndOfFile_1, filename));
+         }
+         if (filename != null) {
+             final int s = filename.lastIndexOf('.');
+             if (s >= 0) {
+                 suffixWLD = filename.substring(s+1);
+             }
+         }
+         return new AffineTransform2D(elements);
+     }
+ 
+     /**
+      * Returns the localized resources for producing warnings or error 
messages.
+      */
+     final Resources resources() {
+         return Resources.forLocale(listeners.getLocale());
+     }
+ 
+     /**
+      * Returns the localized resources for producing error messages.
+      */
+     private Errors errors() {
+         return Errors.getResources(listeners.getLocale());
+     }
+ 
+     /**
+      * Returns paths to the main file together with auxiliary files.
+      *
+      * @return paths to the main file and auxiliary files, or an empty array 
if unknown.
+      * @throws DataStoreException if the URI can not be converted to a {@link 
Path}.
+      */
+     @Override
+     public final synchronized Path[] getComponentFiles() throws 
DataStoreException {
+         if (suffixWLD == null) try {
+             getGridGeometry(MAIN_IMAGE);                // Will compute 
`suffixWLD` as a side effect.
+         } catch (IOException e) {
+             throw new DataStoreException(e);
+         }
+         return listComponentFiles(suffixWLD, PRJ);      // `suffixWLD` still 
null if file was not found.
+     }
+ 
+     /**
+      * Gets the grid geometry for image at the given index.
+      * This method should be invoked only once per image, and the result 
cached.
+      *
+      * @param  index  index of the image for which to read the grid geometry.
+      * @return grid geometry of the image at the given index.
+      * @throws IndexOutOfBoundsException if the image index is out of bounds.
+      * @throws IOException if an I/O error occurred.
+      * @throws DataStoreException if the {@code *.prj} or {@code *.tfw} 
auxiliary file content can not be parsed.
+      */
+     final GridGeometry getGridGeometry(final int index) throws IOException, 
DataStoreException {
+         assert Thread.holdsLock(this);
+         final ImageReader reader = reader();
+         if (gridGeometry == null) {
+             final AffineTransform2D gridToCRS;
+             width     = reader.getWidth (MAIN_IMAGE);
+             height    = reader.getHeight(MAIN_IMAGE);
+             gridToCRS = readWorldFile();
+             readPRJ();
+             gridGeometry = new GridGeometry(new GridExtent(width, height), 
CELL_ANCHOR, gridToCRS, crs);
+         }
+         if (index != MAIN_IMAGE) {
+             final int w = reader.getWidth (index);
+             final int h = reader.getHeight(index);
+             if (w != width || h != height) {
+                 // Can not use `gridToCRS` and `crs` because they may not 
apply.
+                 return new GridGeometry(new GridExtent(w, h), CELL_ANCHOR, 
null, null);
+             }
+         }
+         return gridGeometry;
+     }
+ 
+     /**
+      * Sets the store-wide grid geometry when a new coverage is written. The 
{@link WritableStore} implementation
+      * is responsible for making sure that the new grid geometry is 
compatible with preexisting grid geometry.
+      *
+      * @param  index  index of the image for which to set the grid geometry.
+      * @param  gg     the new grid geometry.
+      * @return suffix of the "world file", or {@code null} if the image can 
not be written.
+      */
+     String setGridGeometry(final int index, final GridGeometry gg) throws 
IOException, DataStoreException {
+         if (index != MAIN_IMAGE) {
+             return null;
+         }
+         final GridExtent extent = gg.getExtent();
+         final int w = 
Math.toIntExact(extent.getSize(WorldFileResource.X_DIMENSION));
+         final int h = 
Math.toIntExact(extent.getSize(WorldFileResource.Y_DIMENSION));
+         final String s = (suffixWLD != null) ? suffixWLD : 
getWorldFileSuffix();
+         crs = gg.isDefined(GridGeometry.CRS) ? 
gg.getCoordinateReferenceSystem() : null;
+         gridGeometry = gg;                  // Set only after success of all 
the above.
+         width        = w;
+         height       = h;
+         suffixWLD    = s;
+         return s;
+     }
+ 
+     /**
+      * Returns information about the data store as a whole.
+      */
+     @Override
+     public final synchronized Metadata getMetadata() throws 
DataStoreException {
+         if (metadata == null) try {
+             final MetadataBuilder builder = new MetadataBuilder();
+             String format = reader().getFormatName();
+             for (final String key : KNOWN_FORMATS) {
+                 if (key.equalsIgnoreCase(format)) {
+                     try {
+                         builder.setPredefinedFormat(key);
+                         format = null;
+                     } catch (MetadataStoreException e) {
+                         listeners.warning(Level.FINE, null, e);
+                     }
+                     break;
+                 }
+             }
+             builder.addFormatName(format);                          // Does 
nothing if `format` is null.
 -            builder.addResourceScope(ScopeCode.COVERAGE, null);
++            builder.addResourceScope(ScopeCode.valueOf("COVERAGE"), null);
+             builder.addSpatialRepresentation(null, 
getGridGeometry(MAIN_IMAGE), true);
+             if (gridGeometry.isDefined(GridGeometry.ENVELOPE)) {
+                 builder.addExtent(gridGeometry.getEnvelope());
+             }
+             addTitleOrIdentifier(builder);
+             builder.setISOStandards(false);
+             metadata = builder.buildAndFreeze();
+         } catch (IOException e) {
+             throw new DataStoreException(e);
+         } catch (TransformException e) {
+             throw new DataStoreReferencingException(e);
+         }
+         return metadata;
+     }
+ 
+     /**
+      * Returns all images in this store. Note that fetching the size of the 
list is a potentially costly operation.
+      *
+      * @return list of images in this store.
+      */
+     @Override
+     @SuppressWarnings("ReturnOfCollectionOrArrayField")
+     public final synchronized Collection<? extends GridCoverageResource> 
components() throws DataStoreException {
+         if (components == null) try {
+             components = new Components(reader().getNumImages(false));
+         } catch (IOException e) {
+             throw new DataStoreException(e);
+         }
+         return components;
+     }
+ 
+     /**
+      * Returns all images in this store, or {@code null} if none and {@code 
create} is false.
+      *
+      * @param  create     whether to create the component list if it was not 
already created.
+      * @param  numImages  number of images, or any negative value if unknown.
+      */
+     @SuppressWarnings("ReturnOfCollectionOrArrayField")
+     final Components components(final boolean create, final int numImages) {
+         if (components == null && create) {
+             components = new Components(numImages);
+         }
+         return components;
+     }
+ 
+     /**
+      * A list of images where each {@link WorldFileResource} instance is 
initialized when first needed.
+      * Fetching the list size may be a costly operation and will be done only 
if requested.
+      */
+     final class Components extends ListOfUnknownSize<WorldFileResource> {
+         /**
+          * Size of this list, or any negative value if unknown.
+          */
+         private int size;
+ 
+         /**
+          * All elements in this list. Some array elements may be {@code null} 
if the image
+          * has never been requested.
+          */
+         private WorldFileResource[] images;
+ 
+         /**
+          * Creates a new list of images.
+          *
+          * @param  numImages  number of images, or any negative value if 
unknown.
+          */
+         private Components(final int numImages) {
+             size = numImages;
+             images = new WorldFileResource[Math.max(numImages, 1)];
+         }
+ 
+         /**
+          * Returns the number of images in this list.
+          * This method may be costly when invoked for the first time.
+          */
+         @Override
+         public int size() {
+             synchronized (WorldFileStore.this) {
+                 if (size < 0) try {
+                     size   = reader().getNumImages(true);
+                     images = ArraysExt.resize(images, size);
+                 } catch (IOException e) {
+                     throw new UncheckedIOException(e);
+                 } catch (DataStoreException e) {
+                     throw new BackingStoreException(e);
+                 }
+                 return size;
+             }
+         }
+ 
+         /**
+          * Returns the number of images if this information is known, or any 
negative value otherwise.
+          * This is used by {@link ListOfUnknownSize} for optimizing some 
operations.
+          */
+         @Override
+         protected int sizeIfKnown() {
+             synchronized (WorldFileStore.this) {
+                 return size;
+             }
+         }
+ 
+         /**
+          * Returns {@code true} if an element exists at the given index.
+          * Current implementations is not more efficient than {@link 
#get(int)}.
+          */
+         @Override
+         protected boolean exists(final int index) {
+             synchronized (WorldFileStore.this) {
+                 if (size >= 0) {
+                     return index >= 0 && index < size;
+                 }
+                 try {
+                     return get(index) != null;
+                 } catch (IndexOutOfBoundsException e) {
+                     return false;
+                 }
+             }
+         }
+ 
+         /**
+          * Returns the image at the given index. New instances are created 
when first requested.
+          *
+          * @param  index  index of the image for which to get a resource.
+          * @return resource for the image identified by the given index.
+          * @throws IndexOutOfBoundsException if the image index is out of 
bounds.
+          */
+         @Override
+         public WorldFileResource get(final int index) {
+             synchronized (WorldFileStore.this) {
+                 WorldFileResource image = null;
+                 if (index < images.length) {
+                     image = images[index];
+                 }
+                 if (image == null) try {
+                     image = createImageResource(index);
+                     if (index >= images.length) {
+                         images = Arrays.copyOf(images, Math.max(images.length 
* 2, index + 1));
+                     }
+                     images[index] = image;
+                 } catch (IOException e) {
+                     throw new UncheckedIOException(e);
+                 } catch (DataStoreException e) {
+                     throw new BackingStoreException(e);
+                 }
+                 return image;
+             }
+         }
+ 
+         /**
+          * Invoked <em>after</em> an image has been added to the image file.
+          * This method adds in this list a reference to the newly added file.
+          *
+          * @param  image  the image to add to this list.
+          */
+         final void added(final WorldFileResource image) {
+             size = image.getImageIndex();
+             if (size >= images.length) {
+                 images = Arrays.copyOf(images, size * 2);
+             }
+             images[size++] = image;
+         }
+ 
+         /**
+          * Invoked <em>after</em> an image has been removed from the image 
file.
+          * This method performs no bounds check (it must be done by the 
caller).
+          *
+          * @param  index  index of the image that has been removed.
+          */
+         final void removed(int index) throws DataStoreException {
+             final int last = images.length - 1;
+             System.arraycopy(images, index+1, images, index, last - index);
+             images[last] = null;
+             size--;
+             while (index < last) {
+                 final WorldFileResource image = images[index++];
+                 if (image != null) image.decrementImageIndex();
+             }
+         }
+ 
+         /**
+          * Removes the element at the specified position in this list.
+          */
+         @Override
+         public WorldFileResource remove(final int index) {
+             final WorldFileResource image = get(index);
+             try {
+                 WorldFileStore.this.remove(image);
+             } catch (DataStoreException e) {
+                 throw new UnsupportedOperationException(e);
+             }
+             return image;
+         }
+     }
+ 
+     /**
+      * Invoked by {@link Components} when the caller want to remove a 
resource.
+      * The actual implementation is provided by {@link WritableStore}.
+      */
+     void remove(final Resource resource) throws DataStoreException {
+         throw new ReadOnlyStorageException();
+     }
+ 
+     /**
+      * Creates a {@link GridCoverageResource} for the specified image.
+      * This method is invoked by {@link Components} when first needed
+      * and the result is cached by the caller.
+      *
+      * @param  index  index of the image for which to create a resource.
+      * @return resource for the image identified by the given index.
+      * @throws IndexOutOfBoundsException if the image index is out of bounds.
+      */
+     WorldFileResource createImageResource(final int index) throws 
DataStoreException, IOException {
+         return new WorldFileResource(this, listeners, index, 
getGridGeometry(index));
+     }
+ 
+     /**
+      * Prepares an image reader compatible with the writer and sets its input.
+      * This method is invoked for switching from write mode to read mode.
+      * Its actual implementation is provided by {@link WritableResource}.
+      *
+      * @param  current  the current image reader, or {@code null} if none.
+      * @return the image reader to use, or {@code null} if none.
+      * @throws IOException if an error occurred while preparing the reader.
+      */
+     ImageReader prepareReader(ImageReader current) throws IOException {
+         return null;
+     }
+ 
+     /**
+      * Returns the reader without doing any validation. The reader may be 
{@code null} either
+      * because the store is closed or because the store is initially opened 
in write-only mode.
+      * The reader may have a {@code null} input.
+      */
+     final ImageReader getCurrentReader() {
+         return reader;
+     }
+ 
+     /**
+      * Returns the reader if it has not been closed.
+      *
+      * @throws DataStoreClosedException if this data store is closed.
+      * @throws IOException if an error occurred while preparing the reader.
+      */
+     final ImageReader reader() throws DataStoreException, IOException {
+         assert Thread.holdsLock(this);
+         ImageReader current = reader;
+         if (current == null || current.getInput() == null) {
+             reader = current = prepareReader(current);
+             if (current == null) {
+                 throw new DataStoreClosedException(getLocale(), 
WorldFileStoreProvider.NAME, StandardOpenOption.READ);
+             }
+             configureReader();
+         }
+         return current;
+     }
+ 
+     /**
+      * Closes this data store and releases any underlying resources.
+      *
+      * @throws DataStoreException if an error occurred while closing this 
data store.
+      */
+     @Override
+     public synchronized void close() throws DataStoreException {
+         final ImageReader codec = reader;
+         reader       = null;
+         metadata     = null;
+         components   = null;
+         gridGeometry = null;
+         if (codec != null) try {
+             final Object input = codec.getInput();
+             codec.setInput(null);
+             codec.dispose();
+             if (input instanceof AutoCloseable) {
+                 ((AutoCloseable) input).close();
+             }
+         } catch (Exception e) {
+             throw new DataStoreException(e);
+         }
+     }
+ }
diff --cc 
storage/sis-storage/src/main/java/org/apache/sis/storage/AbstractFeatureSet.java
index 1122b57fbe,e8c094b156..3cde789dbe
--- 
a/storage/sis-storage/src/main/java/org/apache/sis/storage/AbstractFeatureSet.java
+++ 
b/storage/sis-storage/src/main/java/org/apache/sis/storage/AbstractFeatureSet.java
@@@ -19,13 -19,12 +19,12 @@@ package org.apache.sis.storage
  import java.util.Optional;
  import java.util.OptionalLong;
  import org.opengis.util.GenericName;
- import org.apache.sis.storage.DataStore;
- import org.apache.sis.storage.DataStoreException;
- import org.apache.sis.storage.FeatureSet;
+ import org.opengis.metadata.Metadata;
  import org.apache.sis.storage.event.StoreListeners;
+ import org.apache.sis.internal.storage.MetadataBuilder;
  
  // Branch-dependent imports
 -import org.opengis.feature.FeatureType;
 +import org.apache.sis.feature.DefaultFeatureType;
  
  
  /**
diff --cc 
storage/sis-storage/src/test/java/org/apache/sis/internal/storage/MetadataBuilderTest.java
index 5b4473178e,24dfdf538f..6c2c8eabbb
--- 
a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/MetadataBuilderTest.java
+++ 
b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/MetadataBuilderTest.java
@@@ -81,8 -83,8 +81,8 @@@ public final strictfp class MetadataBui
      private static void verifyCopyrightParsing(final String notice) {
          final MetadataBuilder builder = new MetadataBuilder();
          builder.parseLegalNotice(notice);
 -        final LegalConstraints constraints = (LegalConstraints) 
getSingleton(getSingleton(
 +        final DefaultLegalConstraints constraints = (DefaultLegalConstraints) 
getSingleton(getSingleton(
-                 
builder.build(false).getIdentificationInfo()).getResourceConstraints());
+                 
builder.build().getIdentificationInfo()).getResourceConstraints());
  
          assertEquals("useConstraints", Restriction.COPYRIGHT, 
getSingleton(constraints.getUseConstraints()));
          final Citation ref = getSingleton(constraints.getReferences());
diff --cc 
storage/sis-storage/src/test/java/org/apache/sis/internal/storage/esri/AsciiGridStoreTest.java
index 0000000000,ee5d64bd2a..6c84085b8d
mode 000000,100644..100644
--- 
a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/esri/AsciiGridStoreTest.java
+++ 
b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/esri/AsciiGridStoreTest.java
@@@ -1,0 -1,133 +1,131 @@@
+ /*
+  * 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.esri;
+ 
+ import java.util.List;
+ import java.awt.image.Raster;
+ import java.awt.image.RenderedImage;
+ import org.opengis.metadata.Metadata;
+ import org.opengis.metadata.extent.GeographicBoundingBox;
 -import org.opengis.metadata.identification.Identification;
++import org.opengis.metadata.identification.DataIdentification;
+ import org.apache.sis.coverage.Category;
+ import org.apache.sis.coverage.grid.GridCoverage;
+ import org.apache.sis.storage.DataStoreException;
+ import org.apache.sis.storage.StorageConnector;
+ import org.apache.sis.storage.ProbeResult;
+ import org.apache.sis.test.TestCase;
+ import org.junit.Test;
+ 
+ import static org.junit.Assert.*;
+ import static org.apache.sis.test.TestUtilities.getSingleton;
+ 
+ 
+ /**
+  * Tests {@link AsciiGridStore} and {@link AsciiGridStoreProvider}.
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  * @version 1.2
+  * @since   1.2
+  * @module
+  */
+ public final strictfp class AsciiGridStoreTest extends TestCase {
+     /**
+      * Returns a storage connector with the URL to the test data.
+      */
+     private static StorageConnector testData() {
+         return new 
StorageConnector(AsciiGridStoreTest.class.getResource("grid.asc"));
+     }
+ 
+     /**
+      * Tests {@link AsciiGridStoreProvider#probeContent(StorageConnector)} 
method.
+      *
+      * @throws DataStoreException if en error occurred while reading the CSV 
file.
+      */
+     @Test
+     public void testProbeContent() throws DataStoreException {
+         final AsciiGridStoreProvider p = new AsciiGridStoreProvider();
+         final ProbeResult r = p.probeContent(testData());
+         assertTrue(r.isSupported());
+         assertEquals("text/plain", r.getMimeType());
+     }
+ 
+     /**
+      * Tests the metadata of the {@code "grid.asc"} file. This test reads 
only the header.
+      * It should not test sample dimensions or pixel values, because doing so 
read the full
+      * image and is the purpose of {@link #testRead()}.
+      *
+      * @throws DataStoreException if an error occurred while reading the file.
+      */
+     @Test
+     public void testMetadata() throws DataStoreException {
+         try (AsciiGridStore store = new AsciiGridStore(null, testData(), 
true)) {
+             assertEquals("grid", store.getIdentifier().get().toString());
+             final Metadata metadata = store.getMetadata();
+             /*
+              * Format information is hard-coded in "SpatialMetadata" 
database. Complete string should
+              * be "ESRI ArcInfo ASCII Grid format" but it depends on the 
presence of Derby dependency.
+              */
 -            final Identification id = 
getSingleton(metadata.getIdentificationInfo());
 -            final String format = 
getSingleton(id.getResourceFormats()).getFormatSpecificationCitation().getTitle().toString();
 -            assertTrue(format, format.contains("ASCII Grid"));
++            final DataIdentification id = (DataIdentification) 
getSingleton(metadata.getIdentificationInfo());
+             /*
+              * This information should have been read from the PRJ file.
+              */
+             assertEquals("WGS 84 / World Mercator",
+                     
getSingleton(metadata.getReferenceSystemInfo()).getName().getCode());
+             final GeographicBoundingBox bbox = (GeographicBoundingBox)
+                     
getSingleton(getSingleton(id.getExtents()).getGeographicElements());
+             assertEquals(-84, bbox.getSouthBoundLatitude(), 1);
+             assertEquals(+85, bbox.getNorthBoundLatitude(), 1);
+             /*
+              * Verify that the metadata is cached.
+              */
+             assertSame(metadata, store.getMetadata());
+         }
+     }
+ 
+     /**
+      * Tests reading a few values from the {@code "grid.asc"} file.
+      *
+      * @throws DataStoreException if an error occurred while reading the file.
+      */
+     @Test
+     public void testRead() throws DataStoreException {
+         try (AsciiGridStore store = new AsciiGridStore(null, testData(), 
true)) {
+             final List<Category> categories = 
getSingleton(store.getSampleDimensions()).getCategories();
+             assertEquals(2, categories.size());
+             assertEquals(   -2, 
categories.get(0).getSampleRange().getMinDouble(), 1);
+             assertEquals(   30, 
categories.get(0).getSampleRange().getMaxDouble(), 1);
+             assertEquals(-9999, 
categories.get(1).forConvertedValues(false).getSampleRange().getMinDouble(), 0);
+             /*
+              * Check sample values.
+              */
+             final GridCoverage coverage = store.read(null, null);
+             final RenderedImage image = coverage.render(null);
+             assertEquals(10, image.getWidth());
+             assertEquals(20, image.getHeight());
+             final Raster tile = image.getTile(0,0);
+             assertEquals(   1.061f, tile.getSampleFloat(0,  0, 0), 0f);
+             assertEquals(Float.NaN, tile.getSampleFloat(9,  0, 0), 0f);
+             assertEquals(Float.NaN, tile.getSampleFloat(9, 19, 0), 0f);
+             assertEquals(  -1.075f, tile.getSampleFloat(0, 19, 0), 0f);
+             assertEquals(  27.039f, tile.getSampleFloat(4, 10, 0), 0f);
+             /*
+              * Verify that the coverage is cached.
+              */
+             assertSame(coverage, store.read(null, null));
+         }
+     }
+ }
diff --cc 
storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/WorldFileStoreTest.java
index 0000000000,ec967d2d67..7ff00be156
mode 000000,100644..100644
--- 
a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/WorldFileStoreTest.java
+++ 
b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/WorldFileStoreTest.java
@@@ -1,0 -1,167 +1,165 @@@
+ /*
+  * 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.image;
+ 
+ import java.io.IOException;
+ import java.nio.file.Path;
+ import java.nio.file.Files;
+ import java.nio.file.DirectoryStream;
+ import java.nio.file.StandardOpenOption;
+ import org.opengis.metadata.Metadata;
+ import org.opengis.metadata.extent.GeographicBoundingBox;
 -import org.opengis.metadata.identification.Identification;
++import org.opengis.metadata.identification.DataIdentification;
+ import org.apache.sis.coverage.grid.GridCoverage;
+ import org.apache.sis.storage.DataStoreException;
+ import org.apache.sis.storage.GridCoverageResource;
+ import org.apache.sis.storage.StorageConnector;
+ import org.apache.sis.storage.ProbeResult;
+ import org.apache.sis.storage.ResourceAlreadyExistsException;
+ import org.apache.sis.setup.OptionKey;
+ import org.apache.sis.test.TestCase;
+ import org.junit.Test;
+ 
+ import static org.junit.Assert.*;
+ import static org.apache.sis.test.TestUtilities.getSingleton;
+ 
+ 
+ /**
+  * Tests {@link WorldFileStore} and {@link WorldFileStoreProvider}.
+  *
+  * @author  Martin Desruisseaux (Geomatys)
+  * @version 1.2
+  * @since   1.2
+  * @module
+  */
+ public final strictfp class WorldFileStoreTest extends TestCase {
+     /**
+      * Returns a storage connector with the URL to the test data.
+      */
+     private static StorageConnector testData() {
+         return new 
StorageConnector(WorldFileStoreTest.class.getResource("gradient.png"));
+     }
+ 
+     /**
+      * Tests {@link WorldFileStoreProvider#probeContent(StorageConnector)} 
method.
+      *
+      * @throws DataStoreException if en error occurred while reading the CSV 
file.
+      */
+     @Test
+     public void testProbeContent() throws DataStoreException {
+         final WorldFileStoreProvider provider = new WorldFileStoreProvider();
+         final ProbeResult result = provider.probeContent(testData());
+         assertTrue(result.isSupported());
+         assertEquals("image/png", result.getMimeType());
+     }
+ 
+     /**
+      * Tests the metadata of the {@code "gradient.png"} file.
+      *
+      * @throws DataStoreException if an error occurred during Image I/O or 
data store operations.
+      */
+     @Test
+     public void testMetadata() throws DataStoreException {
+         final WorldFileStoreProvider provider = new WorldFileStoreProvider();
+         try (WorldFileStore store = provider.open(testData())) {
+             assertFalse(store instanceof WritableStore);
+             assertEquals("gradient", store.getIdentifier().get().toString());
+             final Metadata metadata = store.getMetadata();
 -            final Identification id = 
getSingleton(metadata.getIdentificationInfo());
 -            final String format = 
getSingleton(id.getResourceFormats()).getFormatSpecificationCitation().getTitle().toString();
 -            assertTrue(format, format.contains("PNG"));
++            final DataIdentification id = (DataIdentification) 
getSingleton(metadata.getIdentificationInfo());
+             assertEquals("WGS 84", 
getSingleton(metadata.getReferenceSystemInfo()).getName().getCode());
+             final GeographicBoundingBox bbox = (GeographicBoundingBox)
+                     
getSingleton(getSingleton(id.getExtents()).getGeographicElements());
+             assertEquals( -90, bbox.getSouthBoundLatitude(), STRICT);
+             assertEquals( +90, bbox.getNorthBoundLatitude(), STRICT);
+             assertEquals(-180, bbox.getWestBoundLongitude(), STRICT);
+             assertEquals(+180, bbox.getEastBoundLongitude(), STRICT);
+             /*
+              * Verify that the metadata is cached.
+              */
+             assertSame(metadata, store.getMetadata());
+         }
+     }
+ 
+     /**
+      * Tests reading the coverage and writing it in a new file.
+      *
+      * @throws DataStoreException if an error occurred during Image I/O or 
data store operations.
+      * @throws IOException if an error occurred when creating, reading or 
deleting temporary files.
+      */
+     @Test
+     public void testReadWrite() throws DataStoreException, IOException {
+         final Path directory = Files.createTempDirectory("SIS-");
+         try {
+             final WorldFileStoreProvider provider = new 
WorldFileStoreProvider();
+             try (WorldFileStore source = provider.open(testData())) {
+                 assertFalse(source instanceof WritableStore);
+                 final GridCoverageResource resource = 
getSingleton(source.components());
+                 assertEquals("identifier", "1", 
resource.getIdentifier().get().toString());
+                 /*
+                  * Above `resource` is the content of "gradient.png" file.
+                  * Write the resource in a new file using a different format.
+                  */
+                 final Path targetPath = directory.resolve("copy.jpg");
+                 final StorageConnector connector = new 
StorageConnector(targetPath);
+                 connector.setOption(OptionKey.OPEN_OPTIONS, new 
StandardOpenOption[] {
+                     StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE
+                 });
+                 try (WritableStore target = (WritableStore) 
provider.open(connector)) {
+                     assertEquals(0, target.isMultiImages());
+                     final WritableResource copy = (WritableResource) 
target.add(resource);
+                     assertEquals(1, target.isMultiImages());
+                     assertNotSame(resource, copy);
+                     assertEquals (resource.getGridGeometry(),     
copy.getGridGeometry());
+                     assertEquals (resource.getSampleDimensions(), 
copy.getSampleDimensions());
+                     /*
+                      * Verify that attempt to write again without `REPLACE` 
mode fails.
+                      */
+                     final GridCoverage coverage = resource.read(null, null);
+                     try {
+                         copy.write(coverage);
+                         fail("Should not have replaced existing resource.");
+                     } catch (ResourceAlreadyExistsException e) {
+                         final String message = e.getMessage();
+                         assertTrue(message, message.contains("1"));     // 
"1" is the image identifier.
+                     }
+                     /*
+                      * Try to write again in `REPLACE` mode.
+                      */
+                     copy.write(coverage, 
WritableResource.CommonOption.REPLACE);
+                     assertEquals(1, target.isMultiImages());
+                 }
+                 /*
+                  * Verify that the 3 files have been written. The JGW file 
content is verified,
+                  * but the PRJ file content is not fully verified because it 
may vary.
+                  */
+                 assertTrue(Files.size(targetPath) > 0);
+                 assertTrue(Files.readAllLines(directory.resolve("copy.prj"))
+                                 .stream().anyMatch((line) -> 
line.contains("WGS 84")));
+                 assertArrayEquals(new String[] {
+                     "2.8125", "0.0", "0.0", "-2.8125", "-178.59375", 
"88.59375"
+                 }, 
Files.readAllLines(directory.resolve("copy.jgw")).toArray());
+             }
+         } finally {
+             try (DirectoryStream<Path> entries = 
Files.newDirectoryStream(directory)) {
+                 for (Path entry : entries) {
+                     Files.delete(entry);
+                 }
+             }
+             Files.delete(directory);
+         }
+     }
+ }

Reply via email to