This is an automated email from the ASF dual-hosted git repository. desruisseaux pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
The following commit(s) were added to refs/heads/geoapi-4.0 by this push: new 81614ecb87 Add a "World File" writer. 81614ecb87 is described below commit 81614ecb870fec12f2b11260603b51401d456c5a Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Tue Apr 19 19:22:57 2022 +0200 Add a "World File" writer. https://issues.apache.org/jira/browse/SIS-541 --- .../org/apache/sis/coverage/grid/GridGeometry.java | 4 +- .../sis/internal/referencing/j2d/AffineMatrix.java | 3 +- .../sis/internal/util/ListOfUnknownSize.java | 4 +- .../apache/sis/internal/storage/PRJDataStore.java | 2 +- .../org/apache/sis/internal/storage/Resources.java | 10 + .../sis/internal/storage/Resources.properties | 2 + .../sis/internal/storage/Resources_fr.properties | 2 + .../sis/internal/storage/image/FormatFilter.java | 8 +- .../apache/sis/internal/storage/image/Image.java | 141 +++--- .../apache/sis/internal/storage/image/Store.java | 337 ++++++++++++-- .../sis/internal/storage/image/StoreProvider.java | 13 +- .../internal/storage/image/WarningListener.java | 18 +- .../sis/internal/storage/image/WritableImage.java | 76 +++ .../sis/internal/storage/image/WritableStore.java | 515 +++++++++++++++++++++ .../storage/image/SelfConsistencyTest.java | 2 +- .../sis/internal/storage/image/StoreTest.java | 2 +- 16 files changed, 1018 insertions(+), 121 deletions(-) diff --git a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java index 16c57d9f77..7e5ea0705c 100644 --- a/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java +++ b/core/sis-feature/src/main/java/org/apache/sis/coverage/grid/GridGeometry.java @@ -1266,8 +1266,8 @@ public class GridGeometry implements LenientComparable, Serializable { * * @param bitmask any combination of {@link #CRS}, {@link #ENVELOPE}, {@link #EXTENT}, * {@link #GRID_TO_CRS} and {@link #RESOLUTION}. - * @return {@code true} if all specified attributes are defined (i.e. invoking the - * corresponding method will not thrown an {@link IncompleteGridGeometryException}). + * @return {@code true} if all specified properties are defined (i.e. invoking the + * corresponding getter methods will not throw {@link IncompleteGridGeometryException}). * @throws IllegalArgumentException if the specified bitmask is not a combination of known masks. * * @see #getCoordinateReferenceSystem() diff --git a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineMatrix.java b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineMatrix.java index 3c2067e819..c934204fff 100644 --- a/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineMatrix.java +++ b/core/sis-referencing/src/main/java/org/apache/sis/internal/referencing/j2d/AffineMatrix.java @@ -124,7 +124,8 @@ final class AffineMatrix implements ExtendedPrecisionMatrix, Serializable, Clone } /** - * Returns all matrix elements. + * Returns all matrix elements in row-major order. + * Note that this is not the same order than {@link AffineTransform} constructor. */ @Override public double[] getExtendedElements() { diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/util/ListOfUnknownSize.java b/core/sis-utility/src/main/java/org/apache/sis/internal/util/ListOfUnknownSize.java index fd28e24f2c..6f5c0f33ce 100644 --- a/core/sis-utility/src/main/java/org/apache/sis/internal/util/ListOfUnknownSize.java +++ b/core/sis-utility/src/main/java/org/apache/sis/internal/util/ListOfUnknownSize.java @@ -49,11 +49,11 @@ public abstract class ListOfUnknownSize<E> extends AbstractSequentialList<E> { } /** - * Returns {@link #size()} if its value is already known, or -1 if the size is still unknown. + * Returns {@link #size()} if its value is already known, or a negative value if the size is still unknown. * The size may become known for example if it has been cached by the subclass. In such case, * some {@code ListOfUnknownSize} methods will take a more efficient path. * - * @return {@link #size()} if its value is already known, or -1 if it still costly to compute. + * @return {@link #size()} if its value is already known, or any negative value if it still costly to compute. */ protected int sizeIfKnown() { return -1; diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/PRJDataStore.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/PRJDataStore.java index f373297557..da82d2fa69 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/PRJDataStore.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/PRJDataStore.java @@ -308,7 +308,7 @@ public abstract class PRJDataStore extends URIDataStore { final StoreFormat format = new StoreFormat(locale, timezone, null, listeners); format.setConvention(Convention.WKT1_COMMON_UNITS); format.format(crs, out); - out.write(System.lineSeparator()); + out.newLine(); } } catch (IOException e) { Object identifier = getIdentifier().orElse(null); diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.java index 34e207be31..d27cddc3e1 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.java @@ -270,6 +270,11 @@ public final class Resources extends IndexedResourceBundle { */ public static final short IllegalOutputTypeForWriter_2 = 9; + /** + * All coverages must have the same grid geometry. + */ + public static final short IncompatibleGridGeometry = 72; + /** * Components of the “{1}” name are inconsistent with those of the name previously binded in * “{0}” data store. @@ -346,6 +351,11 @@ public final class Resources extends IndexedResourceBundle { */ public static final short ResourceNotFound_2 = 24; + /** + * This resource has been removed from its data store. + */ + public static final short ResourceRemoved = 73; + /** * The “{0}” format does not support rotations. */ diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.properties b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.properties index f6bbcfd450..cd6a783721 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.properties +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources.properties @@ -61,6 +61,7 @@ IllegalEventType_1 = This resource should not fire events of type IllegalFeatureType_2 = The {0} data store does not accept features of type \u201c{1}\u201d. IllegalInputTypeForReader_2 = The {0} reader does not accept inputs of type \u2018{1}\u2019. IllegalOutputTypeForWriter_2 = The {0} writer does not accept outputs of type \u2018{1}\u2019. +IncompatibleGridGeometry = All coverages must have the same grid geometry. InconsistentNameComponents_2 = Components of the \u201c{1}\u201d name are inconsistent with those of the name previously binded in \u201c{0}\u201d data store. InvalidExpression_2 = Invalid or unsupported \u201c{1}\u201d expression at index {0}. InvalidSampleDimensionIndex_2 = Sample dimension index {1} is invalid. Expected an index from 0 to {0} inclusive. @@ -75,6 +76,7 @@ ProcessingExecutedOn_1 = Processing executed on {0}. ResourceAlreadyExists_1 = A resource already exists at \u201c{0}\u201d. ResourceIdentifierCollision_2 = More than one resource have the \u201c{1}\u201d identifier in the \u201c{0}\u201d data store. ResourceNotFound_2 = No resource found for the \u201c{1}\u201d identifier in the \u201c{0}\u201d data store. +ResourceRemoved = This resource has been removed from its data store. RequestOutOfBounds_5 = The request [{3} \u2026 {4}] is outside the [{1} \u2026 {2}] domain for \u201c{0}\u201d axis. RotationNotSupported_1 = The \u201c{0}\u201d format does not support rotations. ShallBeDeclaredBefore_2 = The \u201c{1}\u201d element must be declared before \u201c{0}\u201d. diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources_fr.properties b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources_fr.properties index 0dcaf8238a..6f87021ddc 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources_fr.properties +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/Resources_fr.properties @@ -66,6 +66,7 @@ IllegalEventType_1 = Cette ressource ne devrait pas lancer des \u IllegalFeatureType_2 = Le format {0} ne stocke pas de donn\u00e9es de type \u00ab\u202f{1}\u202f\u00bb. IllegalInputTypeForReader_2 = Le lecteur {0} n\u2019accepte pas des entr\u00e9s de type \u2018{1}\u2019. IllegalOutputTypeForWriter_2 = L\u2019encodeur {0} n\u2019accepte pas des sorties de type \u2018{1}\u2019. +IncompatibleGridGeometry = Toutes les couvertures de donn\u00e9es doivent avoir la m\u00eame g\u00e9om\u00e9trie de grille. InvalidExpression_2 = Expression \u00ab\u202f{1}\u202f\u00bb invalide ou non-support\u00e9e \u00e0 l\u2019index {0}. InvalidSampleDimensionIndex_2 = L\u2019index de dimension d\u2019\u00e9chantillonnage {1} est invalide. On attendait un index de 0 \u00e0 {0} inclusif. InconsistentNameComponents_2 = Les \u00e9l\u00e9ments qui composent le nom \u00ab\u202f{1}\u202f\u00bb ne sont pas coh\u00e9rents avec ceux du nom qui avait \u00e9t\u00e9 pr\u00e9c\u00e9demment li\u00e9 dans les donn\u00e9es de \u00ab\u202f{0}\u202f\u00bb. @@ -80,6 +81,7 @@ ProcessingExecutedOn_1 = Traitement ex\u00e9cut\u00e9 sur {0}. ResourceAlreadyExists_1 = Une ressource existe d\u00e9j\u00e0 \u00e0 l\u2019emplacement \u00ab\u202f{0}\u202f\u00bb. ResourceIdentifierCollision_2 = Plusieurs ressources utilisent l\u2019identifiant \u00ab\u202f{1}\u202f\u00bb dans les donn\u00e9es de \u00ab\u202f{0}\u202f\u00bb. ResourceNotFound_2 = Aucune ressource n\u2019a \u00e9t\u00e9 trouv\u00e9e pour l\u2019identifiant \u00ab\u202f{1}\u202f\u00bb dans les donn\u00e9es de \u00ab\u202f{0}\u202f\u00bb. +ResourceRemoved = Cette ressource a \u00e9t\u00e9 supprim\u00e9e de sa source de donn\u00e9es. RequestOutOfBounds_5 = La demande [{3} \u2026 {4}] est en dehors du domaine [{1} \u2026 {2}] pour l\u2019axe \u00ab\u202f{0}\u202f\u00bb. RotationNotSupported_1 = Le format \u00ab\u202f{0}\u202f\u00bb ne supporte pas les rotations. ShallBeDeclaredBefore_2 = L\u2019\u00e9l\u00e9ment \u00ab\u202f{1}\u202f\u00bb doit \u00eatre d\u00e9clar\u00e9 avant \u00ab\u202f{0}\u202f\u00bb. diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/FormatFilter.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/FormatFilter.java index 8b66e05ce0..97d8a7e50c 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/FormatFilter.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/FormatFilter.java @@ -89,7 +89,8 @@ enum FormatFilter { * if an image reader requests a sub-type, we can probably not provide it ourselves. */ private static final Class<?>[] VALID_OUTPUTS = { - ImageOutputStream.class, DataOutput.class, OutputStream.class, File.class, Path.class, URL.class, URI.class + // `ImageOutputStream` intentionally excluded because not handled by `StorageConnector`. + DataOutput.class, OutputStream.class, File.class, Path.class, URL.class, URI.class }; /** @@ -231,10 +232,11 @@ enum FormatFilter { final ImageWriter writer = provider.createWriterInstance(); writer.setOutput(output); return writer; - } else if (type == ImageOutputStream.class) { - deferred.put(provider, Boolean.TRUE); } } + if (type == ImageOutputStream.class) { + deferred.put(provider, Boolean.TRUE); + } } } } diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Image.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Image.java index a212a1fe9e..268f8619d1 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Image.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Image.java @@ -39,6 +39,7 @@ import org.apache.sis.storage.AbstractGridCoverageResource; import org.apache.sis.storage.DataStore; import org.apache.sis.storage.DataStoreException; import org.apache.sis.storage.event.StoreListeners; +import org.apache.sis.internal.storage.Resources; import org.apache.sis.internal.storage.StoreResource; import org.apache.sis.internal.storage.RangeArgument; import org.apache.sis.internal.coverage.j2d.ImageUtilities; @@ -63,17 +64,17 @@ class Image extends AbstractGridCoverageResource implements StoreResource { * The dimensions of <var>x</var> and <var>y</var> axes. * Static constants for now, may become configurable fields in the future. */ - private static final int X_DIMENSION = 0, Y_DIMENSION = 1; + static final int X_DIMENSION = 0, Y_DIMENSION = 1; /** - * The parent data store. + * The parent data store, or {@code null} if this resource is not valid anymore. */ - private final Store store; + private volatile Store store; /** - * Index of the image to read. + * Index of the image to read or write in the image file. This is usually 0. */ - private final int imageIndex; + int imageIndex; /** * The identifier as a sequence number in the namespace of the {@link Store}. @@ -116,12 +117,26 @@ class Image extends AbstractGridCoverageResource implements StoreResource { return store; } + /** + * Returns the data store. + * + * @throws DataStoreException if this resource is not valid anymore. + */ + final Store store() throws DataStoreException { + final Store store = this.store; + if (store != null) { + return store; + } + throw new DataStoreException(Resources.format(Resources.Keys.ResourceRemoved)); + } + /** * Returns the resource identifier. The name space is the file name and * the local part of the name is the image index number, starting at 1. */ @Override - public Optional<GenericName> getIdentifier() throws DataStoreException { + public final Optional<GenericName> getIdentifier() throws DataStoreException { + final Store store = store(); synchronized (store) { if (identifier == null) { identifier = Names.createLocalName(store.getDisplayName(), null, String.valueOf(imageIndex + 1)); @@ -147,6 +162,7 @@ class Image extends AbstractGridCoverageResource implements StoreResource { @Override @SuppressWarnings("ReturnOfCollectionOrArrayField") public final List<SampleDimension> getSampleDimensions() throws DataStoreException { + final Store store = store(); synchronized (store) { if (sampleDimensions == null) try { final ImageReader reader = store.reader(); @@ -188,46 +204,47 @@ class Image extends AbstractGridCoverageResource implements StoreResource { */ @Override public final GridCoverage read(GridGeometry domain, int... range) throws DataStoreException { - synchronized (store) { - final ImageReader reader = store.reader(); - final ImageReadParam param = reader.getDefaultReadParam(); - if (domain == null) { - domain = gridGeometry; - } else { - final GridDerivation gd = gridGeometry.derive().rounding(GridRoundingMode.ENCLOSING).subgrid(domain); - final GridExtent extent = gd.getIntersection(); - final int[] subsampling = gd.getSubsampling(); - final int[] offsets = gd.getSubsamplingOffsets(); - final int subX = subsampling[X_DIMENSION]; - final int subY = subsampling[Y_DIMENSION]; - final Rectangle region = new Rectangle( - toIntExact(extent.getLow (X_DIMENSION)), - toIntExact(extent.getLow (Y_DIMENSION)), - toIntExact(extent.getSize(X_DIMENSION)), - toIntExact(extent.getSize(Y_DIMENSION))); - /* - * Ths subsampling offset Δx is defined differently in Image I/O and `GridGeometry`. - * The conversion from coordinate x in subsampled image to xₒ in original image is: - * - * Image I/O: xₒ = xᵣ + (x⋅s + Δx′) - * GridGeometry: xₒ = (truncate(xᵣ/s) + x)⋅s + Δx - * - * Where xᵣ is the the lower coordinate of `region`, s is the subsampling and - * `truncate(xᵣ/s)` is given by the lower coordinate of subsampled extent. - * Rearranging equations: - * - * Δx′ = truncate(xᵣ/s)⋅s + Δx - xᵣ - */ - domain = gd.build(); - GridExtent subExtent = domain.getExtent(); - param.setSourceRegion(region); - param.setSourceSubsampling(subX, subY, - toIntExact(subExtent.getLow(X_DIMENSION) * subX + offsets[X_DIMENSION] - region.x), - toIntExact(subExtent.getLow(Y_DIMENSION) * subY + offsets[Y_DIMENSION] - region.y)); - } - RenderedImage image; - List<SampleDimension> sampleDimensions = getSampleDimensions(); - try { + RenderedImage image; + List<SampleDimension> bands; + final Store store = store(); + try { + synchronized (store) { + final ImageReader reader = store.reader(); + final ImageReadParam param = reader.getDefaultReadParam(); + if (domain == null) { + domain = gridGeometry; + } else { + final GridDerivation gd = gridGeometry.derive().rounding(GridRoundingMode.ENCLOSING).subgrid(domain); + final GridExtent extent = gd.getIntersection(); + final int[] subsampling = gd.getSubsampling(); + final int[] offsets = gd.getSubsamplingOffsets(); + final int subX = subsampling[X_DIMENSION]; + final int subY = subsampling[Y_DIMENSION]; + final Rectangle region = new Rectangle( + toIntExact(extent.getLow (X_DIMENSION)), + toIntExact(extent.getLow (Y_DIMENSION)), + toIntExact(extent.getSize(X_DIMENSION)), + toIntExact(extent.getSize(Y_DIMENSION))); + /* + * Ths subsampling offset Δx is defined differently in Image I/O and `GridGeometry`. + * The conversion from coordinate x in subsampled image to xₒ in original image is: + * + * Image I/O: xₒ = xᵣ + (x⋅s + Δx′) + * GridGeometry: xₒ = (truncate(xᵣ/s) + x)⋅s + Δx + * + * Where xᵣ is the the lower coordinate of `region`, s is the subsampling and + * `truncate(xᵣ/s)` is given by the lower coordinate of subsampled extent. + * Rearranging equations: + * + * Δx′ = truncate(xᵣ/s)⋅s + Δx - xᵣ + */ + domain = gd.build(); + GridExtent subExtent = domain.getExtent(); + param.setSourceRegion(region); + param.setSourceSubsampling(subX, subY, + toIntExact(subExtent.getLow(X_DIMENSION) * subX + offsets[X_DIMENSION] - region.x), + toIntExact(subExtent.getLow(Y_DIMENSION) * subY + offsets[Y_DIMENSION] - region.y)); + } /* * If a subset of the bands is requested, ideally we should forward this request to the `ImageReader`. * But experience suggests that not all `ImageReader` implementations support band subsetting well. @@ -235,13 +252,14 @@ class Image extends AbstractGridCoverageResource implements StoreResource { * be the easiest cases. More difficult cases will be handled after the reading. * Those heuristic rules may be changed in any future version. */ + bands = getSampleDimensions(); if (range != null) { final ImageTypeSpecifier type = reader.getRawImageType(imageIndex); final RangeArgument args = RangeArgument.validate(type.getNumBands(), range, listeners); if (args.isIdentity()) { range = null; } else { - sampleDimensions = UnmodifiableArrayList.wrap(args.select(sampleDimensions)); + bands = UnmodifiableArrayList.wrap(args.select(bands)); if (args.hasAllBands || type.getSampleModel() instanceof BandedSampleModel) { range = args.getSelectedBands(); param.setSourceBands(range); @@ -251,18 +269,25 @@ class Image extends AbstractGridCoverageResource implements StoreResource { } } image = reader.readAsRenderedImage(imageIndex, param); - } catch (IOException e) { - throw new DataStoreException(e); - } - /* - * If the reader was presumed unable to handle the band subsetting, apply it now. - * It waste some memory because unused bands still in memory. But we do that as a - * workaround for limitations in some `ImageReader` implementations. - */ - if (range != null) { - image = new ImageProcessor().selectBands(image, range); } - return new GridCoverage2D(domain, sampleDimensions, image); + } catch (IOException | RuntimeException e) { + throw canNotRead(store.getDisplayName(), domain, e); } + /* + * If the reader was presumed unable to handle the band subsetting, apply it now. + * It waste some memory because unused bands still in memory. But we do that as a + * workaround for limitations in some `ImageReader` implementations. + */ + if (range != null) { + image = new ImageProcessor().selectBands(image, range); + } + return new GridCoverage2D(domain, bands, image); + } + + /** + * Notifies this resource that it should not be used anymore. + */ + final void dispose() { + store = null; } } diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Store.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Store.java index c3ac161e47..9b4846d6d8 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Store.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Store.java @@ -19,13 +19,14 @@ package org.apache.sis.internal.storage.image; import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; 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; @@ -38,6 +39,7 @@ 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; @@ -45,6 +47,7 @@ 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; @@ -69,7 +72,7 @@ import org.apache.sis.setup.OptionKey; * @since 1.2 * @module */ -final class Store extends PRJDataStore implements Aggregate { +class Store extends PRJDataStore implements Aggregate { /** * Image I/O format names (ignoring case) for which we have an entry in the {@code SpatialMetadata} database. */ @@ -85,7 +88,7 @@ final class Store extends PRJDataStore implements Aggregate { * @see #width * @see #height */ - private static final int MAIN_IMAGE = 0; + static final int MAIN_IMAGE = 0; /** * The default World File suffix when it can not be determined from {@link #location}. @@ -93,14 +96,31 @@ final class Store extends PRJDataStore implements Aggregate { */ 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. */ - private final String suffix; + 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 no longer needed. + * 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; @@ -130,7 +150,7 @@ final class Store extends PRJDataStore implements Aggregate { * * @see #components() */ - private List<Image> components; + private Components components; /** * The metadata object, or {@code null} if not yet created. @@ -144,21 +164,30 @@ final class Store extends PRJDataStore implements Aggregate { * * @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. */ - public Store(final StoreProvider provider, final StorageConnector connector) + Store(final StoreProvider provider, final StorageConnector connector, final boolean readOnly) throws DataStoreException, IOException { super(provider, connector); - final Map<ImageReaderSpi,Boolean> deferred = new LinkedHashMap<>(); final Object storage = connector.getStorage(); 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); } @@ -174,14 +203,20 @@ fallback: if (reader == null) { for (final Map.Entry<ImageReaderSpi,Boolean> entry : deferred.entrySet()) { if (entry.getValue()) { if (stream == null) { - stream = ImageIO.createImageInputStream(storage); - if (stream == null) break; + 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, false, true); + reader.setInput(stream); break fallback; } } @@ -190,15 +225,43 @@ fallback: if (reader == null) { storage, connector.getOption(OptionKey.OPEN_OPTIONS)); } } + configureReader(); /* - * 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. + * 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; } /** @@ -267,7 +330,7 @@ loop: for (int convention=0;; convention++) { } } if (warning != null) { - listeners.warning(Resources.format(Resources.Keys.CanNotReadAuxiliaryFile_1, preferred), warning); + listeners.warning(resources().getString(Resources.Keys.CanNotReadAuxiliaryFile_1, preferred), warning); } return null; } @@ -281,11 +344,12 @@ loop: for (int convention=0;; convention++) { * @throws DataStoreException if the file content can not be parsed. */ private AffineTransform2D readWorldFile(final String wld) throws IOException, DataStoreException { - final AuxiliaryContent content = readAuxiliaryFile(wld, encoding); - final CharSequence[] lines = CharSequences.splitOnEOL(readAuxiliaryFile(wld, encoding)); - int count = 0; - final int expected = 6; // Expected number of elements. - final double[] elements = new double[expected]; + final AuxiliaryContent content = readAuxiliaryFile(wld, encoding); + final String filename = content.getFilename(); + final CharSequence[] lines = CharSequences.splitOnEOL(readAuxiliaryFile(wld, encoding)); + 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) != '#') { @@ -295,16 +359,29 @@ loop: for (int convention=0;; convention++) { try { elements[count++] = Double.parseDouble(line); } catch (NumberFormatException e) { - throw new DataStoreContentException(errors().getString(Errors.Keys.ErrorInFileAtLine_2, content.getFilename(), i), 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, content.getFilename())); + 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. */ @@ -312,6 +389,22 @@ loop: for (int convention=0;; convention++) { 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. @@ -322,7 +415,7 @@ loop: for (int convention=0;; convention++) { * @throws IOException if an I/O error occurred. * @throws DataStoreException if the {@code *.prj} or {@code *.tfw} auxiliary file content can not be parsed. */ - private GridGeometry getGridGeometry(final int index) throws IOException, DataStoreException { + final GridGeometry getGridGeometry(final int index) throws IOException, DataStoreException { assert Thread.holdsLock(this); final ImageReader reader = reader(); if (gridGeometry == null) { @@ -331,23 +424,48 @@ loop: for (int convention=0;; convention++) { height = reader.getHeight(MAIN_IMAGE); gridToCRS = readWorldFile(); readPRJ(); - gridGeometry = new GridGeometry(new GridExtent(width, height), PixelInCell.CELL_CENTER, gridToCRS, crs); + 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) { - return new GridGeometry(new GridExtent(w, h), PixelInCell.CELL_CENTER, null, null); + // 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(Image.X_DIMENSION)); + final int h = Math.toIntExact(extent.getSize(Image.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 synchronized Metadata getMetadata() throws DataStoreException { + public final synchronized Metadata getMetadata() throws DataStoreException { if (metadata == null) try { final MetadataBuilder builder = new MetadataBuilder(); String format = reader().getFormatName(); @@ -381,40 +499,58 @@ loop: for (int convention=0;; convention++) { /** * 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(); + 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 Image} instance is initialized when first needed. * Fetching the list size may be a costly operation and will be done only if requested. */ - private final class Components extends ListOfUnknownSize<Image> { + final class Components extends ListOfUnknownSize<Image> { /** - * Size of this list, or -1 if unknown. + * Size of this list, or any negative value if unknown. */ private int size; /** - * All elements in this list. Some array element may be {@code null} if the image - * as never been requested. + * All elements in this list. Some array elements may be {@code null} if the image + * has never been requested. */ private Image[] images; /** * Creates a new list of images. + * + * @param numImages number of images, or any negative value if unknown. */ - private Components() throws DataStoreException, IOException { - size = reader().getNumImages(false); - images = new Image[size >= 0 ? size : 1]; + private Components(final int numImages) { + size = numImages; + images = new Image[Math.max(numImages, 1)]; } /** @@ -437,7 +573,7 @@ loop: for (int convention=0;; convention++) { } /** - * Returns the number of images if this information is known, or -1 otherwise. + * 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 @@ -457,12 +593,20 @@ loop: for (int convention=0;; convention++) { if (size >= 0) { return index >= 0 && index < size; } - return get(index) != null; + 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 Image get(final int index) { @@ -472,7 +616,7 @@ loop: for (int convention=0;; convention++) { image = images[index]; } if (image == null) try { - image = new Image(Store.this, listeners, index, getGridGeometry(index)); + image = createImageResource(index); if (index >= images.length) { images = Arrays.copyOf(images, Math.max(images.length * 2, index + 1)); } @@ -485,17 +629,113 @@ loop: for (int convention=0;; convention++) { 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 Image image) { + size = image.imageIndex; + 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) { + final int last = images.length - 1; + System.arraycopy(images, index+1, images, index, last - index); + images[last] = null; + size--; + while (index < last) { + final Image image = images[index++]; + if (image != null) image.imageIndex--; + } + } + + /** + * Removes the element at the specified position in this list. + */ + @Override + public Image remove(final int index) { + final Image image = get(index); + try { + Store.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. + */ + Image createImageResource(final int index) throws DataStoreException, IOException { + return new Image(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 WritableImage}. + * + * @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 { - final ImageReader in = reader; - if (in == null) { - throw new DataStoreClosedException(getLocale(), StoreProvider.NAME, StandardOpenOption.READ); + 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(), StoreProvider.NAME, StandardOpenOption.READ); + } + configureReader(); } - return in; + return current; } /** @@ -505,12 +745,15 @@ loop: for (int convention=0;; convention++) { */ @Override public synchronized void close() throws DataStoreException { - final ImageReader r = reader; - reader = null; - if (r != null) try { - final Object input = r.getInput(); - r.setInput(null); - r.dispose(); + 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(); } diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/StoreProvider.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/StoreProvider.java index 435264a72d..d987c2ca45 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/StoreProvider.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/StoreProvider.java @@ -27,6 +27,7 @@ import org.apache.sis.internal.storage.Capability; import org.apache.sis.internal.storage.StoreMetadata; import org.apache.sis.internal.storage.PRJDataStore; import org.apache.sis.internal.storage.io.IOUtilities; +import org.apache.sis.storage.GridCoverageResource; import org.apache.sis.storage.ProbeResult; @@ -38,8 +39,10 @@ import org.apache.sis.storage.ProbeResult; * @since 1.2 * @module */ -@StoreMetadata(formatName = StoreProvider.NAME, - capabilities = Capability.READ) +@StoreMetadata(formatName = StoreProvider.NAME, + fileSuffixes = {"jpeg", "jpg", "png", "gif", "bmp"}, // Non-exhaustive list. + capabilities = {Capability.READ, Capability.WRITE, Capability.CREATE}, + resourceTypes = GridCoverageResource.class) public final class StoreProvider extends PRJDataStore.Provider { /** * The format name. @@ -72,7 +75,11 @@ public final class StoreProvider extends PRJDataStore.Provider { @Override public DataStore open(final StorageConnector connector) throws DataStoreException { try { - return new Store(this, connector); + if (isWritable(connector)) { + return new WritableStore(this, connector); + } else { + return new Store(this, connector, true); + } } catch (IOException e) { throw new DataStoreException(e); } diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WarningListener.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WarningListener.java index fd0e389270..c879d00a06 100644 --- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WarningListener.java +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WarningListener.java @@ -17,7 +17,9 @@ package org.apache.sis.internal.storage.image; import javax.imageio.ImageReader; +import javax.imageio.ImageWriter; import javax.imageio.event.IIOReadWarningListener; +import javax.imageio.event.IIOWriteWarningListener; import org.apache.sis.storage.event.StoreListeners; @@ -30,7 +32,7 @@ import org.apache.sis.storage.event.StoreListeners; * @since 1.2 * @module */ -final class WarningListener implements IIOReadWarningListener { +final class WarningListener implements IIOReadWarningListener, IIOWriteWarningListener { /** * The set of registered {@link StoreListener}s for the data store. */ @@ -50,7 +52,19 @@ final class WarningListener implements IIOReadWarningListener { * @param message the warning. */ @Override - public void warningOccurred(final ImageReader reader, final String message) { + public void warningOccurred(final ImageReader source, final String message) { + listeners.warning(message); + } + + /** + * Reports a non-fatal error in encoding. + * + * @param source the writer calling this method. + * @param imageIndex index of the image being written. + * @param message the warning. + */ + @Override + public void warningOccurred(final ImageWriter source, final int imageIndex, final String message) { listeners.warning(message); } } diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableImage.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableImage.java new file mode 100644 index 0000000000..fcc92c97da --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableImage.java @@ -0,0 +1,76 @@ +/* + * 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.awt.image.RenderedImage; +import javax.imageio.ImageWriter; +import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.coverage.grid.GridGeometry; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.storage.WritableGridCoverageResource; +import org.apache.sis.internal.storage.WritableResourceSupport; +import org.apache.sis.internal.storage.Resources; +import org.apache.sis.storage.event.StoreListeners; + + +/** + * An image which can be replaced or updated. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +final class WritableImage extends Image implements WritableGridCoverageResource { + /** + * Creates a new resource. + */ + WritableImage(final WritableStore store, final StoreListeners parent, final int imageIndex, + final GridGeometry gridGeometry) throws DataStoreException + { + super(store, parent, imageIndex, gridGeometry); + } + + /** + * 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 void write(GridCoverage coverage, final Option... options) throws DataStoreException { + final WritableResourceSupport h = new WritableResourceSupport(this, options); // Does argument validation. + final WritableStore store = (WritableStore) store(); + try { + synchronized (store) { + if (imageIndex != Store.MAIN_IMAGE || (store.isMultiImages() != 0 && !h.replace(null))) { + // TODO: we should use `ImageWriter.replacePixels(…)` methods instead. + coverage = h.update(coverage); + } + final RenderedImage data = coverage.render(null); // Fail if not two-dimensional. + store.setGridGeometry(imageIndex, coverage.getGridGeometry()); // May use the image reader. + final ImageWriter writer = store.writer(); // Should be after `setGridGeometry(…)`. + writer.write(data); + } + } catch (IOException | RuntimeException e) { + throw new DataStoreException(store.resources().getString(Resources.Keys.CanNotWriteResource_1, store.getDisplayName()), e); + } + } +} diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableStore.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableStore.java new file mode 100644 index 0000000000..986105f849 --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WritableStore.java @@ -0,0 +1,515 @@ +/* + * 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.Map; +import java.util.LinkedHashMap; +import java.util.function.BiConsumer; +import java.io.File; +import java.io.IOException; +import java.io.BufferedWriter; +import java.nio.file.StandardOpenOption; +import java.awt.geom.AffineTransform; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.ImageWriter; +import javax.imageio.spi.IIORegistry; +import javax.imageio.spi.ImageReaderSpi; +import javax.imageio.spi.ImageWriterSpi; +import javax.imageio.spi.ImageReaderWriterSpi; +import javax.imageio.stream.ImageInputStream; +import javax.imageio.stream.ImageOutputStream; +import javax.imageio.stream.FileImageOutputStream; +import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.coverage.grid.GridGeometry; +import org.apache.sis.coverage.grid.GridExtent; +import org.apache.sis.internal.storage.Resources; +import org.apache.sis.storage.Resource; +import org.apache.sis.storage.GridCoverageResource; +import org.apache.sis.storage.WritableAggregate; +import org.apache.sis.storage.StorageConnector; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.storage.DataStoreClosedException; +import org.apache.sis.storage.UnsupportedStorageException; +import org.apache.sis.storage.IncompatibleResourceException; +import org.apache.sis.referencing.operation.matrix.AffineTransforms2D; +import org.apache.sis.util.ComparisonMode; +import org.apache.sis.setup.OptionKey; + + +/** + * A data store with writing capabilities. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +final class WritableStore extends Store implements WritableAggregate { + /** + * Position of the input/output stream beginning. This is usually 0. + */ + private final long streamBeginning; + + /** + * The image writer, created when first needed and cleared when the store is closed. + * Only one of {@link #reader} and {@link #writer} should have its input or output set + * at a given time. + * + * @see #writer() + */ + private ImageWriter writer; + + /** + * Number of images in this store, or any negative value if unknown. This information is redundant + * with {@link ImageReader#getNumImages(boolean)} but is stored here because {@link #reader} may be + * null and {@link ImageWriter} does not have a {@code getNumImages(…)} method. + * + * @see #isMultiImages() + */ + private int numImages; + + /** + * 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>). + * @throws DataStoreException if an error occurred while opening the stream. + * @throws IOException if an error occurred while creating the image reader instance. + */ + WritableStore(final StoreProvider provider, final StorageConnector connector) + throws DataStoreException, IOException + { + super(provider, connector, false); + final ImageReader reader = getCurrentReader(); + final Object inout; + if (reader != null) { + inout = reader.getInput(); + numImages = -1; + } else { + /* + * If it was possible to initialize an image reader, wait to see if an image writer is needed. + * Otherwise (i.e. if the destination file does not exist), create the image writer immediately. + * The code below is a copy of the code in parent class constructor (for creating `ImageReader`), + * but adapted to the case of creating an `ImageWriter`. + */ + final Map<ImageWriterSpi,Boolean> deferred = new LinkedHashMap<>(); + if (suffix != null) { + writer = FormatFilter.SUFFIX.createWriter(suffix, connector, null, deferred); + } + if (writer == null) { + writer = FormatFilter.SUFFIX.createWriter(null, connector, null, deferred); +fallback: if (writer == null) { + ImageOutputStream stream = null; + final Object storage = connector.getStorage(); + for (final Map.Entry<ImageWriterSpi,Boolean> entry : deferred.entrySet()) { + if (entry.getValue()) { + if (stream == null) { + final File file = connector.getStorageAs(File.class); + if (file != null) { + stream = new FileImageOutputStream(file); + } else { + stream = ImageIO.createImageOutputStream(storage); + if (stream == null) break; + } + } + final ImageWriterSpi p = entry.getKey(); + connector.closeAllExcept(storage); + writer = p.createWriterInstance(); + writer.setOutput(stream); + break fallback; + } + } + throw new UnsupportedStorageException(super.getLocale(), StoreProvider.NAME, + storage, connector.getOption(OptionKey.OPEN_OPTIONS)); + } + } + configureWriter(); + inout = writer.getOutput(); + // Leave `numImages` to 0 because we know that the stream is empty. + } + streamBeginning = (inout instanceof ImageInputStream) ? ((ImageInputStream) inout).getStreamPosition() : 0; + } + + /** + * Sets the locale to use for warning messages, if supported. If the writer + * does not support the locale, the writer's default locale will be used. + */ + private void configureWriter() { + try { + writer.setLocale(listeners.getLocale()); + } catch (IllegalArgumentException e) { + // Ignore + } + writer.addIIOWriteWarningListener(new WarningListener(listeners)); + } + + /** + * Returns whether this data store contains more than one image. + * This is used for deciding if {@link WritableStore} can overwrite a grid geometry. + * + * @return 0 if this store is empty, 1 if it contains exactly one image, + * or a value greater than 1 if it contains more than one image. + * The returned value is not necessarily the number of images. + * @see #setGridGeometry(int, GridGeometry) + */ + final int isMultiImages() throws IOException, DataStoreException { + assert Thread.holdsLock(this); + if (numImages < 0) { + // This case happens only when we opened an existing file. + final Components components = components(true, numImages); + if (components.isEmpty()) { + numImages = 0; + } else if (components.exists(1)) { + return 2; + } else { + numImages = 1; + } + } + return numImages; + } + + /** + * Sets the store-wide grid geometry. Only one grid geometry can be set for a data store. + * If a grid geometry already exists and the specified grid geometry is incompatible, + * then an {@link IncompatibleResourceException} is thrown. + * + * <p>This method may use the {@link ImageReader} for checking the number of images, + * so it is better to invoke this method before {@link #writer()}.</p> + * + * @param index index of the image for which to read the grid geometry. + * @param gg the new grid geometry. + * @return suffix of the "world file", or {@code null} if this method wrote nothing. + * @throws IncompatibleResourceException if the "grid to CRS" is not affine, + * or if a different grid geometry already exists. + * + * @see #getGridGeometry(int) + */ + @Override + String setGridGeometry(final int index, GridGeometry gg) throws IOException, DataStoreException { + /* + * Make sure that the grid geometry starts at (0,0). + * Must be done before to compare with existing grid. + */ + final GridExtent extent = gg.getExtent(); + final long[] translation = new long[extent.getDimension()]; + for (int i=0; i<translation.length; i++) { + translation[i] = Math.negateExact(extent.getLow(i)); + } + gg = gg.translate(translation); + /* + * If the data store already contains a coverage, then the given grid geometry + * must be identical to the existing one, in which case there is nothing to do. + */ + if (index != MAIN_IMAGE || isMultiImages() > 1) { + if (!getGridGeometry(MAIN_IMAGE).equals(gg, ComparisonMode.IGNORE_METADATA)) { + throw new IncompatibleResourceException( + resources().getString(Resources.Keys.IncompatibleGridGeometry)); + } + } + /* + * Get the two-dimensional affine transform (it provides the "World file" content). + * Only after we successfully got all the information, assign the grid geometry to + * this store. + */ + AffineTransform gridToCRS = null; + if (gg.isDefined(GridGeometry.GRID_TO_CRS)) try { + gridToCRS = AffineTransforms2D.castOrCopy(gg.getGridToCRS(CELL_ANCHOR)); + } catch (IllegalArgumentException e) { + throw new IncompatibleResourceException(e.getLocalizedMessage(), e); + } + final String suffix = super.setGridGeometry(index, gg); // May throw `ArithmeticException`. + /* + * If the image is the main one, overwrite (possibly with same content) the previous auxiliary files. + * Otherwise above checks should have ensured that the existing auxiliary files are applicable. + */ + if (suffix != null) { + if (gridToCRS == null) { + deleteAuxiliaryFile(suffix); + } else try (BufferedWriter out = writeAuxiliaryFile(suffix, encoding)) { +writeCoeffs: for (int i=0;; i++) { + final double c; + switch (i) { + case 0: c = gridToCRS.getScaleX(); break; + case 1: c = gridToCRS.getShearY(); break; + case 2: c = gridToCRS.getShearX(); break; + case 3: c = gridToCRS.getScaleY(); break; + case 4: c = gridToCRS.getTranslateX(); break; + case 5: c = gridToCRS.getTranslateY(); break; + default: break writeCoeffs; + } + out.write(Double.toString(c)); + out.newLine(); + } + } + writePRJ(); + } + return suffix; + } + + /** + * 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. + */ + @Override + Image createImageResource(final int index) throws DataStoreException, IOException { + return new WritableImage(this, listeners, index, getGridGeometry(index)); + } + + /** + * Adds a new {@code Resource} in this {@code Aggregate}. + * The given {@link Resource} will be copied, and the <cite>effectively added</cite> resource returned. + * + * @param resource the resource to copy in this {@code Aggregate}. + * @return the effectively added resource. + * @throws DataStoreException if the given resource can not be stored in this {@code Aggregate}. + */ + @Override + public synchronized Resource add(final Resource resource) throws DataStoreException { + Exception cause = null; + if (resource instanceof GridCoverageResource) try { + final Components components = components(true, numImages); + if (numImages < 0) { + numImages = components.size(); // For this method, we need an accurate count. + } + /* + * If we are adding the first image, the grid geometry of the coverage will determine + * the new grid geometry of the data store. Otherwise (if we are adding more images) + * the coverage grid geometry must be the same as the current data store grid geometry. + */ + GridGeometry domain = null; + if (numImages != 0) { + domain = getGridGeometry(MAIN_IMAGE); + } + final GridCoverage coverage = ((GridCoverageResource) resource).read(domain, null); + if (domain == null) { + domain = coverage.getGridGeometry(); // We are adding the first image. + } + final WritableImage image = new WritableImage(this, listeners, numImages, domain); + image.write(coverage); + components.added(image); // Must be invoked only after above succeeded. + numImages++; + return image; + } catch (IOException | RuntimeException e) { + cause = e; + } + throw new DataStoreException(resources().getString(Resources.Keys.CanNotWriteResource_1, label(resource)), cause); + } + + /** + * Removes a {@code Resource} from this {@code Aggregate}. + * The given resource should be one of the instances returned by {@link #components()}. + * + * @param resource child resource to remove from this {@code Aggregate}. + * @throws DataStoreException if the given resource could not be removed. + */ + @Override + public synchronized void remove(final Resource resource) throws DataStoreException { + Exception cause = null; + if (resource instanceof WritableImage) { + final WritableImage image = (WritableImage) resource; + if (image.store() == this) try { + final int imageIndex = image.imageIndex; + writer().removeImage(imageIndex); + final Components components = components(false, numImages); + if (components != null) { + components.removed(imageIndex); + image.dispose(); + numImages--; // Okay if negative. + } + } catch (IOException | RuntimeException e) { + cause = e; + } + } + throw new DataStoreException(resources().getString( + Resources.Keys.CanNotRemoveResource_2, getDisplayName(), label(resource)), cause); + } + + /** + * Returns a label for the given resource in error messages. + */ + private static String label(final Resource resource) throws DataStoreException { + return resource.getIdentifier().map(Object::toString).orElse("?"); + } + + /** + * Prepares an image reader compatible with the writer and sets its input. + * This method is invoked for switching from write mode to read mode. + * + * @param current the current image reader, or {@code null} if none. + * @return the image reader to use, or {@code null} if none. + */ + @Override + ImageReader prepareReader(ImageReader current) throws IOException { + final ImageWriter writer = this.writer; + if (writer != null) { + final Object output = writer.getOutput(); + if (output != null) { + if (current == null) { + final ImageWriterSpi wp = writer.getOriginatingProvider(); + if (wp != null) { + final ImageReaderSpi rp = getProviderByClass(ImageReaderSpi.class, wp.getImageReaderSpiNames(), wp); + if (rp != null) { + current = rp.createReaderInstance(); + } + } + } + if (current != null) { + writer.setOutput(null); + setStream(current, output, ImageReader::setInput); + return current; + } + } + } + return null; + } + + /** + * Returns the writer if it has not been closed. + * If the data store was in read mode, invoking this method switch to write mode. + * + * @throws DataStoreClosedException if this data store is closed. + * @throws IOException if an error occurred while preparing the writer. + */ + final ImageWriter writer() throws DataStoreException, IOException { + assert Thread.holdsLock(this); + ImageWriter current = writer; + if (current != null && current.getOutput() != null) { + return current; + } + final ImageReader reader = getCurrentReader(); + if (reader != null) { + final Object input = reader.getInput(); + if (input != null) { + if (current == null) { + final ImageReaderSpi rp = reader.getOriginatingProvider(); + if (rp != null) { + final ImageWriterSpi wp = getProviderByClass(ImageWriterSpi.class, rp.getImageWriterSpiNames(), rp); + if (wp != null) { + current = wp.createWriterInstance(); + } + } + } + if (current != null) { + reader.setInput(null); + setStream(current, input, ImageWriter::setOutput); + writer = current; + configureWriter(); + return current; + } + } + } + throw new DataStoreClosedException(getLocale(), StoreProvider.NAME, StandardOpenOption.WRITE); + } + + /** + * Sets the input or output stream on the given image reader or writer. + * If the operation fails, the stream is closed. + * + * @param <T> class of the {@code codec} argument. + * @param codec the {@link ImageReader} or {@link ImageWriter} on which to set the stream. + * @param stream the input or output to set on the specified {@code codec}. + * @param setter for calling the {@code setInput(Object)} or {@code setOutput(Object)} method. + */ + private <T> void setStream(final T codec, final Object stream, final BiConsumer<T,Object> setter) throws IOException { + try { + /* + * `ImageOutputStream` extends `ImageInputStream`, + * so there is no need to check the output stream case. + */ + if (stream instanceof ImageInputStream) { + ((ImageInputStream) stream).seek(streamBeginning); + } + setter.accept(codec, stream); + } catch (Throwable exception) { + if (stream instanceof AutoCloseable) try { + ((AutoCloseable) stream).close(); + } catch (Throwable s) { + exception.addSuppressed(s); + } + throw exception; + } + } + + /** + * Returns the first service provider that we can get from the given list of class names. + * + * @param <T> compile-time value of {@code type} argument. + * @param type type of the provider to get. + * @param classNames class names of provider implementations, or {@code null} if none. + * @param originating the originating provider, used for fetching the class loader. + * @return first provider found, or {@code null} if none. + */ + private <T extends ImageReaderWriterSpi> T getProviderByClass(final Class<T> type, + final String[] classNames, final ImageReaderWriterSpi originating) + { + if (classNames != null) { + final IIORegistry registry = IIORegistry.getDefaultInstance(); + final ClassLoader loader = originating.getClass().getClassLoader(); + for (final String name : classNames) { + final Class<? extends T> impl; + try { + impl = Class.forName(name, true, loader).asSubclass(type); + } catch (ClassNotFoundException | ClassCastException e) { + listeners.warning(e); + continue; + } + final T candidate = registry.getServiceProviderByClass(impl); + if (candidate != null) { + return candidate; + } + } + } + return null; + } + + /** + * 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 { + try { + final ImageWriter codec = writer; + writer = null; + if (codec != null) try { + final Object output = codec.getOutput(); + codec.setOutput(null); + codec.dispose(); + if (output instanceof AutoCloseable) { + ((AutoCloseable) output).close(); + } + } catch (Exception e) { + throw new DataStoreException(e); + } + } catch (Throwable e) { + try { + super.close(); + } catch (Throwable s) { + e.addSuppressed(s); + } + throw e; + } + super.close(); + } +} diff --git a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/SelfConsistencyTest.java b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/SelfConsistencyTest.java index bf7eebff62..ea99ffe540 100644 --- a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/SelfConsistencyTest.java +++ b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/SelfConsistencyTest.java @@ -55,7 +55,7 @@ public final strictfp class SelfConsistencyTest extends CoverageReadConsistency public static void openFile() throws IOException, DataStoreException { final URL url = StoreTest.class.getResource("gradient.png"); assumeNotNull(url); - store = new Store(null, new StorageConnector(url)); + store = new Store(null, new StorageConnector(url), true); } /** diff --git a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/StoreTest.java b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/StoreTest.java index 42feef4317..d3f6c02ddb 100644 --- a/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/StoreTest.java +++ b/storage/sis-storage/src/test/java/org/apache/sis/internal/storage/image/StoreTest.java @@ -67,7 +67,7 @@ public final strictfp class StoreTest extends TestCase { */ @Test public void testMetadata() throws DataStoreException, IOException { - try (Store store = new Store(null, testData())) { + try (Store store = new Store(null, testData(), true)) { assertEquals("gradient", store.getIdentifier().get().toString()); final Metadata metadata = store.getMetadata(); final Identification id = getSingleton(metadata.getIdentificationInfo());