This is an automated email from the ASF dual-hosted git repository. desruisseaux pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
commit 9252ad9e1d6941540009f5f5e65ab61848807479 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Wed Apr 13 19:21:30 2022 +0200 First draft of World File reader as a wrapper around standard Java Image I/O. https://issues.apache.org/jira/browse/SIS-541 --- .../apache/sis/internal/storage/PRJDataStore.java | 92 +++- .../sis/internal/storage/image/FormatFilter.java | 202 +++++++++ .../apache/sis/internal/storage/image/Image.java | 208 +++++++++ .../apache/sis/internal/storage/image/Store.java | 502 +++++++++++++++++++++ .../sis/internal/storage/image/StoreProvider.java | 81 ++++ .../internal/storage/image/WarningListener.java | 56 +++ .../sis/internal/storage/image/package-info.java | 57 +++ 7 files changed, 1192 insertions(+), 6 deletions(-) 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 ad49f1d563..d0175e3e3d 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 @@ -143,7 +143,7 @@ public abstract class PRJDataStore extends URIDataStore { */ protected final void readPRJ() throws DataStoreException { try { - final String wkt = readAuxiliaryFile(PRJ, encoding); + final String wkt = readAuxiliaryFile(PRJ, encoding).toString(); if (wkt != null) { final StoreFormat format = new StoreFormat(locale, timezone, null, listeners); format.setConvention(Convention.WKT1_COMMON_UNITS); @@ -162,16 +162,17 @@ public abstract class PRJDataStore extends URIDataStore { * This method uses the same URI than {@link #location}, * except for the extension which is replaced by the given value. * This method is suitable for reasonably small files. + * An arbitrary size limit is applied for safety. * - * @param extension the filename extension of the auxiliary file to open. - * @param encoding the encoding to use for reading the file content, or {@code null} for default. - * @return a stream opened on the specified file. + * @param extension the filename extension of the auxiliary file to open. + * @param encoding the encoding to use for reading the file content, or {@code null} for default. + * @return the file content together with the source. Should be short-lived. * @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 DataStoreException if the auxiliary file content seems too large. */ - protected final String readAuxiliaryFile(final String extension, Charset encoding) + protected final AuxiliaryContent readAuxiliaryFile(final String extension, Charset encoding) throws IOException, DataStoreException { if (encoding == null) { @@ -215,7 +216,81 @@ public abstract class PRJDataStore extends URIDataStore { buffer = Arrays.copyOf(buffer, offset*2); } } - return new String(buffer, 0, offset); + return new AuxiliaryContent(source, buffer, 0, offset); + } + } + + /** + * Content of a file read by {@link #readAuxiliaryFile(String, Charset)}. + * This is used as a workaround for not being able to return multiple values from a single method. + * Instances of this class should be short lived, because they hold larger arrays than necessary. + */ + protected static final class AuxiliaryContent implements CharSequence { + /** {@link Path} or {@link URL} that have been read. */ + private final Object source; + + /** The textual content of the auxiliary file. */ + private final char[] buffer; + + /** Index of the first valid character in {@link #buffer}. */ + private final int offset; + + /** Number of valid characters in {@link #buffer}. */ + private final int length; + + /** Wraps (without copying) the given array as the content of an auxiliary file. */ + private AuxiliaryContent(final Object source, final char[] buffer, final int offset, final int length) { + this.source = source; + this.buffer = buffer; + this.offset = offset; + this.length = length; + } + + /** + * Returns the filename (without path) of the auxiliary file. + * This information is mainly for producing error messages. + * + * @return name of the auxiliary file that have been read. + */ + public String getFilename() { + return IOUtilities.filename(source); + } + + /** + * Returns the number of valid characters in this sequence. + */ + @Override + public int length() { + return length; + } + + /** + * Returns the character at the given index. For performance reasons this method does not check index bounds. + * The behavior of this method is undefined if the given index is not smaller than {@link #length()}. + * We skip bounds check because this class should be used for Apache SIS internal purposes only. + */ + @Override + public char charAt(final int index) { + return buffer[offset + index]; + } + + /** + * Returns a sub-sequence of this auxiliary file content. For performance reasons this method does not + * perform bound checks. The behavior of this method is undefined if arguments are out of bounds. + * We skip bounds check because this class should be used for Apache SIS internal purposes only. + */ + @Override + public CharSequence subSequence(final int start, final int end) { + return new AuxiliaryContent(source, buffer, offset + start, end - start); + } + + /** + * Copies this auxiliary file content in a {@link String}. + * This method does not cache the result; caller should invoke at most once. + */ + @Override + public String toString() { + return new String(buffer, offset, length); } } @@ -330,6 +405,11 @@ public abstract class PRJDataStore extends URIDataStore { return paths; } + /** + * Returns the filename of the given path without the file suffix. + * The returned string always ends in {@code '.'}, making it ready + * for concatenation of a new suffix. + */ private static String getBaseFilename(final Path path) { final String base = path.getFileName().toString(); final int s = base.lastIndexOf('.'); 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 new file mode 100644 index 0000000000..603852c9ab --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/FormatFilter.java @@ -0,0 +1,202 @@ +/* + * 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.Iterator; +import java.util.function.Function; +import java.net.URI; +import java.net.URL; +import java.io.File; +import java.io.DataInput; +import java.io.DataOutput; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.awt.image.RenderedImage; +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 org.apache.sis.storage.StorageConnector; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.util.ArraysExt; +import org.apache.sis.util.Classes; + + +/** + * Specify the property to use as a filtering criterion for choosing an image reader or writer. + * This is used for providing utility methods about image formats. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +enum FormatFilter { + /** + * Filter the providers by format name. + */ + NAME(ImageReaderWriterSpi::getFormatNames), + + /** + * Filter the providers by file extension. + */ + SUFFIX(ImageReaderWriterSpi::getFileSuffixes), + + /** + * Filter the providers by MIME type. + */ + MIME(ImageReaderWriterSpi::getMIMETypes); + + /** + * The method to invoke for getting the property values + * (name, suffix or MIME type) to use for filtering. + */ + private final Function<ImageReaderWriterSpi, String[]> property; + + /** + * Valid types of inputs accepted by this class. + */ + private static final Class<?>[] VALID_INPUTS = { + // ImageInputStream case included by DataInput. + DataInput.class, InputStream.class, File.class, Path.class, URL.class, URI.class + }; + + /** + * Valid types of outputs accepted by this class. + */ + private static final Class<?>[] VALID_OUTPUTS = { + // ImageOutputStream case included by DataOutput. + DataOutput.class, OutputStream.class, File.class, Path.class, URL.class, URI.class + }; + + /** + * Creates a new enumeration value. + */ + private FormatFilter(final Function<ImageReaderWriterSpi, String[]> property) { + this.property = property; + } + + /** + * Returns an iterator over all providers of the given category having the given name, + * suffix or MIME type. + * + * @param <T> the compile-time type of the {@code category} argument. + * @param category either {@link ImageReaderSpi} or {@link ImageWriterSpi}. + * @param identifier the property value to use as a filtering criterion, or {@code null} if none. + * @return an iterator over the requested providers. + */ + private <T extends ImageReaderWriterSpi> Iterator<T> getServiceProviders(final Class<T> category, final String identifier) { + final IIORegistry registry = IIORegistry.getDefaultInstance(); + if (identifier != null) { + final IIORegistry.Filter filter = (provider) -> { + final String[] identifiers = property.apply((ImageReaderWriterSpi) provider); + return ArraysExt.contains(identifiers, identifier); + }; + return registry.getServiceProviders(category, filter, true); + } else { + return registry.getServiceProviders(category, true); + } + } + + /** + * Creates a new reader for the given input. Caller needs to invoke this method with an initially empty + * {@code deferred} map, which will be populated by this method. Providers associated to {@code TRUE} + * should be tested again by the caller with an {@link ImageInputStream} created by the caller. + * This is intentionally not done automatically by {@link StorageConnector}. + * + * @param identifier the property value to use as a filtering criterion, or {@code null} if none. + * @param input the input to be given to the new reader instance. + * @param deferred initially empty map to be populated with providers tested by this method. + * @return the new image reader instance with its input initialized, or {@code null} if none was found. + * @throws DataStoreException if an error occurred while opening a stream from the storage connector. + * @throws IOException if an error occurred while creating the image reader instance. + */ + final ImageReader createReader(final String identifier, final StorageConnector connector, + final Map<ImageReaderSpi,Boolean> deferred) throws IOException, DataStoreException + { + final Iterator<ImageReaderSpi> it = getServiceProviders(ImageReaderSpi.class, identifier); + while (it.hasNext()) { + final ImageReaderSpi provider = it.next(); + if (deferred.putIfAbsent(provider, Boolean.FALSE) == null) { + for (final Class<?> type : provider.getInputTypes()) { + if (Classes.isAssignableToAny(type, VALID_INPUTS)) { + final Object input = connector.getStorageAs(type); + if (input != null) { + if (provider.canDecodeInput(input)) { + connector.closeAllExcept(input); + final ImageReader reader = provider.createReaderInstance(); + reader.setInput(input, false, true); + return reader; + } + } else if (type == ImageInputStream.class) { + deferred.put(provider, Boolean.TRUE); + } + } + } + } + } + return null; + } + + /** + * Creates a new writer for the given output. Caller needs to invoke this method with an initially empty + * {@code deferred} map, which will be populated by this method. Providers associated to {@code TRUE} + * should be tested again by the caller with an {@link ImageOutputStream} created by the caller. + * This is intentionally not done automatically by {@link StorageConnector}. + * + * @param identifier the property value to use as a filtering criterion, or {@code null} if none. + * @param output the output to be given to the new reader instance. + * @param image the image to write. + * @param deferred initially empty map to be populated with providers tested by this method. + * @return the new image writer instance with its output initialized, or {@code null} if none was found. + * @throws DataStoreException if an error occurred while opening a stream from the storage connector. + * @throws IOException if an error occurred while creating the image writer instance. + */ + final ImageWriter createWriter(final String identifier, final StorageConnector connector, final RenderedImage image, + final Map<ImageWriterSpi,Boolean> deferred) throws IOException, DataStoreException + { + final Iterator<ImageWriterSpi> it = getServiceProviders(ImageWriterSpi.class, identifier); + while (it.hasNext()) { + final ImageWriterSpi provider = it.next(); + if (deferred.putIfAbsent(provider, Boolean.FALSE) == null) { + if (provider.canEncodeImage(image)) { + for (final Class<?> type : provider.getOutputTypes()) { + if (Classes.isAssignableToAny(type, VALID_OUTPUTS)) { + final Object output = connector.getStorageAs(type); + if (output != null) { + connector.closeAllExcept(output); + final ImageWriter writer = provider.createWriterInstance(); + writer.setOutput(output); + return writer; + } else if (type == ImageOutputStream.class) { + deferred.put(provider, Boolean.TRUE); + } + } + } + } + } + } + return null; + } +} 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 new file mode 100644 index 0000000000..0b6b8805e0 --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Image.java @@ -0,0 +1,208 @@ +/* + * 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.List; +import java.util.Optional; +import java.io.IOException; +import java.awt.Rectangle; +import java.awt.image.RenderedImage; +import javax.imageio.ImageReader; +import javax.imageio.ImageReadParam; +import javax.imageio.ImageTypeSpecifier; +import org.opengis.util.GenericName; +import org.apache.sis.coverage.SampleDimension; +import org.apache.sis.coverage.grid.GridCoverage; +import org.apache.sis.coverage.grid.GridDerivation; +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.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.StoreResource; +import org.apache.sis.internal.util.UnmodifiableArrayList; +import org.apache.sis.util.iso.Names; + +import static java.lang.Math.toIntExact; +import org.apache.sis.coverage.grid.GridCoverage2D; + + +/** + * A single image in a {@link Store}. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +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; + + /** + * The parent data store. + */ + private final Store store; + + /** + * Index of the image to read. + */ + private final int imageIndex; + + /** + * The identifier as a sequence number in the namespace of the {@link Store}. + * The first image has the sequence number "1". This is computed when first needed. + * + * @see #getIdentifier() + */ + private GenericName identifier; + + /** + * The grid geometry of this resource. The grid extent is the image size. + * + * @see #getGridGeometry() + */ + private final GridGeometry gridGeometry; + + /** + * The ranges of sample values, computed when first needed. Shall be an unmodifiable list. + * + * @see #getSampleDimensions() + */ + private List<SampleDimension> sampleDimensions; + + /** + * Creates a new resource. This resource will have its own set of listeners, + * but the listeners of the data store that created this resource will be notified as well. + */ + Image(final Store store, final StoreListeners parent, final int imageIndex, final GridGeometry gridGeometry) { + super(parent); + this.store = store; + this.imageIndex = imageIndex; + this.gridGeometry = gridGeometry; + } + + /** + * Returns the data store that produced this resource. + */ + @Override + public final DataStore getOriginator() { + return store; + } + + /** + * 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 { + synchronized (store) { + if (identifier == null) { + identifier = Names.createLocalName(store.getDisplayName(), null, String.valueOf(imageIndex + 1)); + } + return Optional.of(identifier); + } + } + + /** + * Returns the valid extent of grid coordinates together with the conversion from those grid coordinates + * to real world coordinates. The CRS and "pixels to CRS" conversion may be unknown if this image is not + * the {@linkplain Store#MAIN_IMAGE main image}, or if the {@code *.prj} and/or world auxiliary file has + * not been found. + */ + @Override + public final GridGeometry getGridGeometry() throws DataStoreException { + return gridGeometry; + } + + /** + * Returns the ranges of sample values. + */ + @Override + @SuppressWarnings("ReturnOfCollectionOrArrayField") + public final List<SampleDimension> getSampleDimensions() throws DataStoreException { + synchronized (store) { + if (sampleDimensions == null) try { + final ImageReader reader = store.reader(); + final ImageTypeSpecifier type = reader.getRawImageType(imageIndex); + final SampleDimension[] bands = new SampleDimension[type.getNumBands()]; + final SampleDimension.Builder b = new SampleDimension.Builder(); + for (int i=0; i<bands.length; i++) { + /* + * TODO: we could consider a mechanism similar to org.apache.sis.internal.geotiff.SchemaModifier + * if there is a need to customize the sample dimensions. `SchemaModifier` could become a shared + * public interface. + */ + bands[i] = b.setName(i + 1).build(); + b.clear(); + } + sampleDimensions = UnmodifiableArrayList.wrap(bands); + } catch (IOException e) { + throw new DataStoreException(e); + } + return sampleDimensions; + } + } + + /** + * Loads a subset of the image wrapped by this resource. + * + * @param domain desired grid extent and resolution, or {@code null} for reading the whole domain. + * @param range 0-based indices of sample dimensions to read, or {@code null} or an empty sequence for reading them all. + * @return the grid coverage for the specified domain and range. + * @throws DataStoreException if an error occurred while reading the grid coverage data. + */ + @Override + public final GridCoverage read(GridGeometry domain, final 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(); + domain = gd.build(); + param.setSourceSubsampling(subsampling[X_DIMENSION], subsampling[Y_DIMENSION], + offsets[X_DIMENSION], offsets[Y_DIMENSION]); + param.setSourceRegion(new Rectangle( + toIntExact(extent.getLow (X_DIMENSION)), + toIntExact(extent.getLow (Y_DIMENSION)), + toIntExact(extent.getSize(X_DIMENSION)), + toIntExact(extent.getSize(Y_DIMENSION)))); + } + if (range != null) { + param.setSourceBands(range); + } + final List<SampleDimension> sampleDimensions = getSampleDimensions(); + final RenderedImage image; + try { + image = reader.readAsRenderedImage(imageIndex, param); + } catch (IOException e) { + throw new DataStoreException(e); + } + return new GridCoverage2D(domain, sampleDimensions, image); + } + } +} 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 new file mode 100644 index 0000000000..299b1db493 --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/Store.java @@ -0,0 +1,502 @@ +/* + * 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.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.NoSuchFileException; +import java.nio.file.StandardOpenOption; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +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.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.DataStoreException; +import org.apache.sis.storage.DataStoreClosedException; +import org.apache.sis.storage.DataStoreContentException; +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. + * The store is considered as an aggregate, with one resource per image. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +final class Store extends PRJDataStore implements Aggregate { + /** + * 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 + */ + private 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 filename extension (may be an empty string), or {@code null} if unknown. + * It does not include the leading dot. + */ + private final String suffix; + + /** + * The image reader, set by the constructor and cleared when no longer needed. + */ + 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 List<Image> components; + + /** + * The metadata object, or {@code null} if not yet created. + * + * @see #getMetadata() + */ + private Metadata metadata; + + /** + * 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. + */ + public Store(final StoreProvider provider, final StorageConnector connector) + throws DataStoreException, IOException + { + super(provider, connector); + final Map<ImageReaderSpi,Boolean> deferred = new LinkedHashMap<>(); + final Object storage = connector.getStorage(); + suffix = IOUtilities.extension(storage); + /* + * 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. + */ + 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) { + 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); + break fallback; + } + } + } + throw new UnsupportedStorageException(super.getLocale(), StoreProvider.NAME, + storage, connector.getOption(OptionKey.OPEN_OPTIONS)); + } + } + /* + * 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. + */ + try { + reader.setLocale(listeners.getLocale()); + } catch (IllegalArgumentException e) { + // Ignore + } + } + + /** + * 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.format(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, encoding); + final CharSequence[] lines = CharSequences.splitOnEOL(readAuxiliaryFile(wld, encoding)); + int count = 0; + final int expected = 6; // Expected number of elements. + final double[] m = 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 { + m[count++] = Double.parseDouble(line); + } catch (NumberFormatException e) { + throw new DataStoreContentException(errors().getString(Errors.Keys.ErrorInFileAtLine_2, content.getFilename(), i), e); + } + } + } + if (count != expected) { + throw new EOFException(errors().getString(Errors.Keys.UnexpectedEndOfFile_1, content.getFilename())); + } + // TODO: provide a more direct way. + return new AffineTransform2D(new java.awt.geom.AffineTransform(m)); + } + + /** + * Returns the localized resources for producing error messages. + */ + private Errors errors() { + return Errors.getResources(listeners.getLocale()); + } + + /** + * 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. + */ + private 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), PixelInCell.CELL_CENTER, 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); + } + } + return gridGeometry; + } + + /** + * Returns information about the data store as a whole. + */ + @Override + public synchronized Metadata getMetadata() throws DataStoreException { + if (metadata == null) try { + final MetadataBuilder builder = new MetadataBuilder(); + final String format = reader().getFormatName(); + try { + builder.setFormat(format); + } catch (MetadataStoreException e) { + builder.addFormatName(format); + listeners.warning(Level.FINE, null, e); + } + builder.addResourceScope(ScopeCode.COVERAGE, null); + builder.addSpatialRepresentation(null, getGridGeometry(MAIN_IMAGE), true); + addTitleOrIdentifier(builder); + builder.setISOStandards(false); + metadata = builder.buildAndFreeze(); + } catch (IOException e) { + throw new DataStoreException(e); + } + return metadata; + } + + /** + * Returns all images in this store. Note that fetching the size of the list is a potentially costly operation. + */ + @Override + @SuppressWarnings("ReturnOfCollectionOrArrayField") + public final synchronized Collection<? extends Resource> components() throws DataStoreException { + if (components == null) try { + components = new Components(); + } catch (IOException e) { + throw new DataStoreException(e); + } + 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> { + /** + * Size of this list, or -1 if unknown. + */ + private int size; + + /** + * All elements in this list. Some array element may be {@code null} if the image + * as never been requested. + */ + private Image[] images; + + /** + * Creates a new list of images. + */ + private Components() throws DataStoreException, IOException { + size = reader().getNumImages(false); + images = new Image[size >= 0 ? size : 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 (Store.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 -1 otherwise. + * This is used by {@link ListOfUnknownSize} for optimizing some operations. + */ + @Override + protected int sizeIfKnown() { + synchronized (Store.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 (Store.this) { + if (size >= 0) { + return index >= 0 && index < size; + } + return get(index) != null; + } + } + + /** + * Returns the image at the given index. New instances are created when first requested. + */ + @Override + public Image get(final int index) { + synchronized (Store.this) { + Image image = null; + if (index < images.length) { + image = images[index]; + } + if (image == null) try { + image = new Image(Store.this, listeners, index, getGridGeometry(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; + } + } + } + + /** + * Returns the reader if it has not been closed. + */ + final ImageReader reader() throws DataStoreException { + final ImageReader in = reader; + if (in == null) { + throw new DataStoreClosedException(getLocale(), StoreProvider.NAME, StandardOpenOption.READ); + } + return in; + } + + /** + * 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 r = reader; + reader = null; + if (r != null) try { + final Object input = r.getInput(); + r.setInput(null); + r.dispose(); + if (input instanceof AutoCloseable) { + ((AutoCloseable) input).close(); + } + } catch (Exception e) { + throw new DataStoreException(e); + } + } +} 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 new file mode 100644 index 0000000000..795e6fe4bb --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/StoreProvider.java @@ -0,0 +1,81 @@ +/* + * 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 org.apache.sis.storage.DataStore; +import org.apache.sis.storage.DataStoreException; +import org.apache.sis.storage.StorageConnector; +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.storage.ProbeResult; + + +/** + * The provider of {@link Store} instances. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +@StoreMetadata(formatName = StoreProvider.NAME, + capabilities = Capability.READ) +public final class StoreProvider extends PRJDataStore.Provider { + /** + * The format name. + */ + static final String NAME = "World file"; + + /** + * Creates a new provider. + */ + public StoreProvider() { + } + + /** + * Returns a generic name for this data store, used mostly in warnings or error messages. + * + * @return a short name or abbreviation for the data format. + */ + @Override + public String getShortName() { + return NAME; + } + + /** + * Returns a {@link Store} implementation associated with this provider. + * + * @param connector information about the storage (URL, stream, <i>etc</i>). + * @return a data store implementation associated with this provider for the given storage. + * @throws DataStoreException if an error occurred while creating the data store instance. + */ + @Override + public DataStore open(final StorageConnector connector) throws DataStoreException { + try { + return new Store(this, connector); + } catch (IOException e) { + throw new DataStoreException(e); + } + } + + @Override + public ProbeResult probeContent(StorageConnector connector) throws DataStoreException { + throw new UnsupportedOperationException("Not supported yet."); + } +} 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 new file mode 100644 index 0000000000..fd0e389270 --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/WarningListener.java @@ -0,0 +1,56 @@ +/* + * 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 javax.imageio.ImageReader; +import javax.imageio.event.IIOReadWarningListener; +import org.apache.sis.storage.event.StoreListeners; + + +/** + * A listener for warnings emitted during read or write operations. + * This class forwards the warnings to the listeners associated to the data store. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * @since 1.2 + * @module + */ +final class WarningListener implements IIOReadWarningListener { + /** + * The set of registered {@link StoreListener}s for the data store. + */ + private final StoreListeners listeners; + + /** + * Creates a new image I/O listener. + */ + WarningListener(final StoreListeners listeners) { + this.listeners = listeners; + } + + /** + * Reports a non-fatal error in decoding. + * + * @param source the reader calling this method. + * @param message the warning. + */ + @Override + public void warningOccurred(final ImageReader reader, final String message) { + listeners.warning(message); + } +} diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/package-info.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/package-info.java new file mode 100644 index 0000000000..2912efae95 --- /dev/null +++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/image/package-info.java @@ -0,0 +1,57 @@ +/* + * 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. + */ + +/** + * {@link org.apache.sis.storage.DataStore} implementation for Image I/O. + * This data store wraps Image I/O reader and wrapper for image format such as TIFF, PNG or JPEG. + * The data store delegates the reading and writing of pixel values to the wrapped reader or writer, + * and additionally looks for two small text files in the same directory than the image file + * with the same filename but a different extension: + * + * <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 one 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 extensions, 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 text file are expected to be encoded in ISO-8859-1 (a.k.a. ISO-LATIN-1) + * and every numbers are expected to be formatted in US locale. + * + * @author Martin Desruisseaux (Geomatys) + * @version 1.2 + * + * @see <a href="https://en.wikipedia.org/wiki/World_file">World File Format Description</a> + * + * @since 1.2 + * @module + */ +package org.apache.sis.internal.storage.image;