/* The MIT License (MIT)
*
* Copyright (c) 2015 Reinventing Geospatial, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.rgi.store.tiles.tms;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.activation.MimeType;
import javax.activation.MimeTypeParseException;
import javax.imageio.ImageIO;
import com.rgi.common.BoundingBox;
import com.rgi.common.Dimensions;
import com.rgi.common.Range;
import com.rgi.common.coordinate.Coordinate;
import com.rgi.common.coordinate.CoordinateReferenceSystem;
import com.rgi.common.coordinate.CrsCoordinate;
import com.rgi.common.tile.TileOrigin;
import com.rgi.common.tile.scheme.TileMatrixDimensions;
import com.rgi.common.util.FileUtility;
import com.rgi.store.tiles.TileHandle;
import com.rgi.store.tiles.TileStoreException;
import com.rgi.store.tiles.TileStoreReader;
/**
* <a href="http://wiki.osgeo.org/wiki/Tile_Map_Service_Specification">TMS</a>
* implementation of {@link TileStoreReader}
*
* @author Luke Lambert
*
*/
public class TmsReader extends TmsTileStore implements TileStoreReader
{
/**
* Constructor
*
* @param coordinateReferenceSystem
* The coordinate reference system of this tile store. TMS's
* lack of metadata means the coordinate reference system
* cannot be inferred.
* @param location
* The location of this tile store on-disk
*/
public TmsReader(final CoordinateReferenceSystem coordinateReferenceSystem, final Path location)
{
// TODO look for tilemapresource.xml for metadata
super(coordinateReferenceSystem, location);
if(!location.toFile().canRead())
{
throw new IllegalArgumentException("Specified location cannot be read from");
}
}
@Override
public BoundingBox getBounds() throws TileStoreException
{
if(this.bounds == null)
{
this.calculateBounds();
}
return this.bounds;
}
@Override
public long countTiles()
{
if(this.tileCount == -1)
{
this.tileCount = this.countFiles(this.location.toFile());
}
return this.tileCount;
}
@Override
public long getByteSize() throws TileStoreException
{
if(this.storeSize == -1)
{
try
{
this.storeSize = this.calculateStoreSize();
}
catch(final IOException ex)
{
throw new TileStoreException("An error occurred while calculating the size of the tile store.\n" + ex.getMessage());
}
}
return this.storeSize;
}
@Override
public BufferedImage getTile(final int column, final int row, final int zoomLevel) throws TileStoreException
{
final Optional<File> tileFile = this.getTiles(column, row, zoomLevel).findFirst(); // TODO prioritize list based on file type suitability (prefer transparency, etc)
if(tileFile.isPresent())
{
try
{
return ImageIO.read(tileFile.get());
}
catch(final IOException ex)
{
throw new TileStoreException(ex);
}
}
return null;
}
@Override
public BufferedImage getTile(final CrsCoordinate coordinate, final int zoomLevel) throws TileStoreException
{
if(coordinate == null)
{
throw new IllegalArgumentException("Coordinate may not be null");
}
if(!coordinate.getCoordinateReferenceSystem().equals(this.getCoordinateReferenceSystem()))
{
throw new IllegalArgumentException("Coordinate's coordinate reference system does not match the tile store's coordinate reference system");
}
final Coordinate<Integer> tmsCoordinate = this.profile.crsToTileCoordinate(coordinate,
this.profile.getBounds(), // TMS uses absolute tiling, which covers the whole globe
this.tileScheme.dimensions(zoomLevel),
TmsTileStore.Origin);
return this.getTile(tmsCoordinate.getX(),
tmsCoordinate.getY(),
zoomLevel);
}
@Override
public Set<Integer> getZoomLevels() throws TileStoreException
{
if(this.zoomLevels == null)
{
this.calculateZoomLevels();
}
return this.zoomLevels;
}
@Override
public Stream<TileHandle> stream()
{
return this.stream(this.location);
}
@Override
public Stream<TileHandle> stream(final int zoomLevel)
{
return this.stream(tmsPath(this.location, zoomLevel));
}
@Override
public String getImageType()
{
try
{
return Files.walk(TmsReader.this.location)
.map(path -> { final File file = path.toFile();
final String absolutePath = file.getAbsolutePath();
if(TmsFilePattern.matcher(absolutePath).matches())
{
try
{
final MimeType mimeType = new MimeType(Files.probeContentType(path));
if(mimeType.getPrimaryType().toLowerCase().equals("image"))
{
return mimeType.getSubType();
}
}
catch(final MimeTypeParseException | IOException ex)
{
// Do nothing. Fall through and return null
}
}
return null;
})
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
catch(final IOException ex)
{
// Do nothing and fall through to return null
}
return null;
}
@Override
public Dimensions<Integer> getImageDimensions()
{
try
{
return Files.walk(TmsReader.this.location)
.map(path -> { final File file = path.toFile();
final String absolutePath = file.getAbsolutePath();
final Matcher tmsFileMatch = TmsFilePattern.matcher(absolutePath);
if(tmsFileMatch.matches())
{
try
{
final MimeType mimeType = new MimeType(Files.probeContentType(path));
if(mimeType.getPrimaryType().toLowerCase().equals("image"))
{
final int zoomLevel = Integer.parseInt(tmsFileMatch.group(1));
final int column = Integer.parseInt(tmsFileMatch.group(2));
final int row = Integer.parseInt(tmsFileMatch.group(3));
final BufferedImage image = TmsReader.this.getTile(column, row, zoomLevel);
if(image != null)
{
return new Dimensions<>(image.getWidth(), image.getHeight());
}
}
}
catch(final MimeTypeParseException | IOException | TileStoreException ex)
{
// Do nothing. Fall through and return null
}
}
return null;
})
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
catch(final IOException ex)
{
// Do nothing and fall through to return null
}
return null;
}
private void calculateBounds() throws TileStoreException
{
final int minimumZoom = TmsReader.getTmsRange(this.location.toFile()).getMinimum();
final Path pathToMinimumZoom = tmsPath(this.location, minimumZoom);
final Range<Integer> xRange = TmsReader.getTmsRange(pathToMinimumZoom.toFile());
final Range<Integer> yRange = TmsReader.getTmsRange(tmsPath(pathToMinimumZoom, xRange.getMaximum()).toFile());
final TileMatrixDimensions dimensions = this.tileScheme.dimensions(minimumZoom);
final Coordinate<Integer> transformedMinTileCoordinate = TmsTileStore.Origin.transform(TileOrigin.LowerLeft, xRange.getMinimum(), yRange.getMinimum(), dimensions);
final Coordinate<Integer> transformedMaxTileCoordinate = TmsTileStore.Origin.transform(TileOrigin.UpperRight, xRange.getMaximum(), yRange.getMaximum(), dimensions);
final Coordinate<Double> lowerLeftCorner = this.profile.tileToCrsCoordinate(transformedMinTileCoordinate.getX(), transformedMinTileCoordinate.getY(), this.profile.getBounds(), dimensions, TileOrigin.LowerLeft); // TMS uses absolute tiling, which covers the whole globe
final Coordinate<Double> upperRightCorner = this.profile.tileToCrsCoordinate(transformedMaxTileCoordinate.getX(), transformedMaxTileCoordinate.getY(), this.profile.getBounds(), dimensions, TileOrigin.UpperRight); // TMS uses absolute tiling, which covers the whole globe
this.bounds = new BoundingBox(lowerLeftCorner.getX(),
lowerLeftCorner.getY(),
upperRightCorner.getX(),
upperRightCorner.getY());
}
/**
* Counts the number of files of a certain type in an input folder.
*
* @param directory
* The folder in which the files should be counted in.
* @return The number of files of a certain type found in the input folder.
*/
private long countFiles(final File directory)
{
if(directory == null || !directory.canRead() || !directory.isDirectory())
{
return 0;
}
// Count files that have an allowed file extension
final long fileCount = Arrays.stream(directory.listFiles())
.filter(file -> file.isFile() && // Not a directory
fileIsImage(file)) // Allowed file extension
.count();
return Arrays.stream(directory.listFiles())
.filter(file -> file.isDirectory())
.map(subFolder -> this.countFiles(subFolder))
.reduce(fileCount, (a, b) -> a + b); // Sum up all of the values
}
private long calculateStoreSize() throws IOException
{
final AtomicLong size = new AtomicLong(0);
Files.walkFileTree(this.location,
new SimpleFileVisitor<Path>()
{
@Override
public FileVisitResult visitFile(final Path path, final BasicFileAttributes attrs) throws IOException
{
if(attrs.isRegularFile())
{
size.addAndGet(attrs.size());
}
return FileVisitResult.CONTINUE;
}
});
return size.get();
}
private void calculateZoomLevels()
{
this.zoomLevels = Stream.of(this.location.toFile().listFiles())
.filter(file -> file.isDirectory())
.map(file -> { try
{
return Integer.parseInt(FileUtility.nameWithoutExtension(file));
}
catch(final NumberFormatException ex)
{
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}
private TileHandle getTileHandle(final Path path)
{
if(path == null)
{
throw new IllegalArgumentException("The path may not be null");
}
final File file = path.toFile();
final String absolutePath = file.getAbsolutePath();
final Matcher tmsFileMatch = TmsFilePattern.matcher(absolutePath);
if(tmsFileMatch.matches())
{
try
{
final String mimeTypeString = Files.probeContentType(path);
if(mimeTypeString == null)
{
return null;
}
final MimeType mimeType = new MimeType(mimeTypeString);
if(mimeType.getPrimaryType().toLowerCase().equals("image"))
{
final int zoomLevel = Integer.parseInt(tmsFileMatch.group(1));
final int column = Integer.parseInt(tmsFileMatch.group(2));
final int row = Integer.parseInt(tmsFileMatch.group(3));
return new TileHandle()
{
private final TileMatrixDimensions matrix = TmsReader.this.tileScheme.dimensions(zoomLevel);
private boolean gotImage = false;
private BufferedImage image;
@Override
public int getZoomLevel()
{
return zoomLevel;
}
@Override
public int getColumn()
{
return column;
}
@Override
public int getRow()
{
return row;
}
@Override
public TileMatrixDimensions getMatrix() throws TileStoreException
{
return this.matrix;
}
@Override
public CrsCoordinate getCrsCoordinate() throws TileStoreException
{
return TmsReader.this.tileToCrsCoordinate(column,
row,
zoomLevel,
TmsTileStore.Origin);
}
@Override
public CrsCoordinate getCrsCoordinate(final TileOrigin corner) throws TileStoreException
{
return TmsReader.this.tileToCrsCoordinate(column,
row,
zoomLevel,
corner);
}
@Override
public BoundingBox getBounds() throws TileStoreException
{
return TmsReader.this.getTileBoundingBox(column, row, zoomLevel);
}
@Override
public BufferedImage getImage() throws TileStoreException
{
if(!this.gotImage)
{
this.image = TmsReader.this.getTile(column, row, zoomLevel);
this.gotImage = true;
}
return this.image;
}
};
}
}
catch(final MimeTypeParseException | IOException ex)
{
// Do nothing. Fall through to return null.
}
}
return null;
}
/**
* Gets the integer representation of the file of a certain type (lowest or
* highest).
*
* @param directory
* The directory that contains files with integer names
* @return The minimum and maximum integer value the supplied directory's file names
* @throws TileStoreException
* If a file name cannot be parsed to an integer, a
* TileStoreException is thrown.
*/
private static Range<Integer> getTmsRange(final File directory) throws TileStoreException
{
try
{
final Iterable<Integer> tmsNames = Stream.of(directory.listFiles())
.map(file -> { try
{
return Integer.parseInt(FileUtility.nameWithoutExtension(file));
}
catch(final NumberFormatException ex)
{
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
return new Range<>(tmsNames, Integer::compare);
}
catch(final IllegalArgumentException ex)
{
throw new TileStoreException(String.format("Directory %s contains no TMS entites",
directory.getName()));
}
}
private Stream<File> getTiles(final int column, final int row, final int zoomLevel)
{
final File[] files = tmsPath(this.location,
zoomLevel,
column).toFile()
.listFiles(); // All of the files in directory zoomLevel/x/
return files == null ? Stream.empty()
: Stream.of(files)
.filter(file -> file.isFile() &&
FileUtility.nameWithoutExtension(file).equals(String.valueOf(row)) &&
fileIsImage(file));
}
private Stream<TileHandle> stream(final Path startLocation)
{
try
{
return Files.walk(startLocation)
.map(path -> this.getTileHandle(path))
.filter(Objects::nonNull);
}
catch(final IOException ex)
{
// Do nothing and fall through to return an empty stream
}
return Stream.empty();
}
private static boolean fileIsImage(final File file)
{
try
{
final String mimeType = Files.probeContentType(file.toPath());
return mimeType != null && mimeType.toLowerCase().startsWith("image/");
}
catch(final IOException ex)
{
return false;
}
}
private Set<Integer> zoomLevels = null;
private BoundingBox bounds = null;
private long tileCount = -1;
private long storeSize = -1;
private static Pattern TmsFilePattern = Pattern.compile(".*(?:\\\\|/)([0-9]+)(?:\\\\|/)([0-9]+)(?:\\\\|/)([0-9]+)\\.[^\\\\/]*$");
}