package org.geoserver.tiles; import static java.lang.String.format; import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.io.IOUtils; import org.geoserver.gwc.GWC; import org.geoserver.ows.util.OwsUtils; import org.geoserver.platform.GeoServerExtensions; import org.geoserver.platform.ServiceException; import org.geoserver.wms.GetMapRequest; import org.geoserver.wms.MapLayerInfo; import org.geoserver.wms.MapProducerCapabilities; import org.geoserver.wms.RasterCleaner; import org.geoserver.wms.WMS; import org.geoserver.wms.WMSMapContent; import org.geoserver.wms.WebMap; import org.geoserver.wms.WebMapService; import org.geoserver.wms.map.AbstractMapOutputFormat; import org.geoserver.wms.map.JPEGMapResponse; import org.geoserver.wms.map.PNGMapResponse; import org.geoserver.wms.map.RawMap; import org.geoserver.wms.map.RenderedImageMap; import org.geoserver.wms.map.RenderedImageMapResponse; import org.geotools.geometry.jts.ReferencedEnvelope; import org.geotools.map.Layer; import org.geotools.referencing.CRS; import org.geotools.renderer.lite.RendererUtilities; import org.geotools.util.logging.Logging; import org.geowebcache.grid.BoundingBox; import org.geowebcache.grid.Grid; import org.geowebcache.grid.GridSet; import org.geowebcache.grid.GridSetBroker; import org.geowebcache.grid.GridSubset; import org.geowebcache.grid.GridSubsetFactory; import org.geowebcache.grid.SRS; import org.geowebcache.layer.TileLayer; import org.opengis.referencing.crs.CoordinateReferenceSystem; import com.google.common.base.Preconditions; import com.vividsolutions.jts.geom.Envelope; /** * * Abstract class for tiles style GetMapOutputFormat (mbtiles && geopackage) * * @author Justin Deoliveira, Boundless * @author Niels Charlier * */ public abstract class AbstractTilesGetMapOutputFormat extends AbstractMapOutputFormat { /** * Wrapper class for tiles file, allows generic access * */ protected static interface TilesFile { public void setMetadata(String name, ReferencedEnvelope box, String imageFormat, int srid, List<MapLayerInfo> mapLayers, int[] minmax, GridSubset gridSubset) throws IOException, ServiceException; public void addTile(int zoom, int x, int y, byte[] data) throws IOException, ServiceException; public File getFile(); public void close(); } static final protected int TILE_CLEANUP_INTERVAL; static { // calculate the number of tiles we can generate before having to cleanup, value is // 25% of total memory / approximte size of single tile TILE_CLEANUP_INTERVAL = (int) (Runtime.getRuntime().maxMemory() * 0.05 / (256.0 * 256 * 4)); } protected static Logger LOGGER = Logging.getLogger(AbstractTilesGetMapOutputFormat.class); protected static final String PNG_MIME_TYPE = "image/png"; protected static final String JPEG_MIME_TYPE = "image/jpeg"; protected WebMapService webMapService; protected WMS wms; protected GWC gwc; protected String extension; public AbstractTilesGetMapOutputFormat(String mimeType, String extension, Set<String> names, WebMapService webMapService, WMS wms, GWC gwc) { super(mimeType, names); this.webMapService = webMapService; this.wms = wms; this.gwc = gwc; this.extension = extension; } @Override public MapProducerCapabilities getCapabilities(String format) { return new MapProducerCapabilities(false, false, false, true, null); } @Override public WebMap produceMap(WMSMapContent map) throws ServiceException, IOException { TilesFile tiles = createTilesFile(); addTiles(tiles, map); tiles.close(); final File dbFile = tiles.getFile(); final BufferedInputStream bin = new BufferedInputStream(new FileInputStream(dbFile)); RawMap result = new RawMap(map, bin, getMimeType()) { @Override public void writeTo(OutputStream out) throws IOException { String dbFilename = getAttachmentFileName(); if (dbFilename != null) { dbFilename = dbFilename.substring(0, dbFilename.length() - 4) + extension; } else { // this shouldn't really ever happen, but fallback anyways dbFilename = "tiles" + extension; } IOUtils.copy(bin, out); out.flush(); bin.close(); try { dbFile.delete(); } catch (Exception e) { LOGGER.log(Level.WARNING, "Error deleting file: " + dbFile.getAbsolutePath(), e); } } }; result.setContentDispositionHeader(map, extension, true); return result; } /** * Factory method for Tiles File * * * @throws IOException */ protected abstract TilesFile createTilesFile() throws IOException; protected void addTiles(TilesFile tiles, WMSMapContent map) throws ServiceException, IOException { GetMapRequest req = map.getRequest(); List<Layer> layers = map.layers(); List<MapLayerInfo> mapLayers = req.getLayers(); Preconditions.checkState(layers.size() == mapLayers.size(), "Number of map layers not same as number of rendered layers"); addTiles(tiles, req, map.getTitle()); } protected void addTiles(TilesFile tiles, GetMapRequest req, String name) throws ServiceException, IOException { List<MapLayerInfo> mapLayers = req.getLayers(); // list of layers to render directly and include as tiles List<MapLayerInfo> tileLayers = new ArrayList<MapLayerInfo>(); // tiled mode means render all as map tile layer tileLayers.addAll(mapLayers); addTiles(tiles, tileLayers, req, name); } /** * Add the tiles * * @param tiles * @param mapLayers * @param map * @throws IOException * @throws ServiceException */ protected void addTiles(TilesFile tiles, List<MapLayerInfo> mapLayers, GetMapRequest request, String name) throws IOException, ServiceException { if (mapLayers.isEmpty()) { return; } // Get the RasterCleaner object RasterCleaner cleaner = GeoServerExtensions.bean(RasterCleaner.class); // figure out a name for the file entry String tileEntryName = null; Map formatOpts = request.getFormatOptions(); if (formatOpts.containsKey("tileset_name")) { tileEntryName = (String) formatOpts.get("tileset_name"); } if (name != null) { tileEntryName = name; } if (tileEntryName == null) { Iterator<MapLayerInfo> it = mapLayers.iterator(); tileEntryName = ""; while (it.hasNext()){ tileEntryName += it.next().getLayerInfo().getName() + "_"; } tileEntryName = tileEntryName.substring(0, tileEntryName.length()-1); } // figure out the actual bounds of the tiles to be renderered BoundingBox bbox = bbox(request); GridSubset gridSubset = findBestGridSubset(request); int[] minmax = findMinMaxZoom(gridSubset, request); //ReferencedEnvelope bounds = new ReferencedEnvelope(findTileBounds(gridSubset, bbox, // minmax[0]), getCoordinateReferenceSystem(map)); // create a prototype getmap request GetMapRequest req = new GetMapRequest(); OwsUtils.copy(request, req, GetMapRequest.class); req.setLayers(mapLayers); String imageFormat = formatOpts.containsKey("format") ? parseFormatFromOpts(formatOpts) : findBestFormat(request); req.setFormat(imageFormat); req.setWidth(gridSubset.getTileWidth()); req.setHeight(gridSubset.getTileHeight()); req.setCrs(getCoordinateReferenceSystem(request)); // store metadata tiles.setMetadata(tileEntryName, bounds(request), imageFormat, srid(request), mapLayers, minmax, gridSubset); //column and row bounds Integer minColumn = null, maxColumn = null, minRow = null, maxRow = null; if (formatOpts.containsKey("min_column")) { minColumn = Integer.parseInt(formatOpts.get("min_column").toString()); } if (formatOpts.containsKey("max_column")) { maxColumn = Integer.parseInt(formatOpts.get("max_column").toString()); } if (formatOpts.containsKey("min_row")) { minRow = Integer.parseInt(formatOpts.get("min_row").toString()); } if (formatOpts.containsKey("max_row")) { maxRow = Integer.parseInt(formatOpts.get("max_row").toString()); } // flag determining if tile row indexes we store in database should be inverted boolean flipy = Boolean.valueOf((String) formatOpts.get("flipy")); for (int z = minmax[0]; z < minmax[1]; z++) { long[] intersect = gridSubset.getCoverageIntersection(z, bbox); long minX = minColumn == null? intersect[0] : Math.max(minColumn, intersect[0]); long maxX = maxColumn == null? intersect[2] : Math.min(maxColumn, intersect[2]); long minY = minRow == null? intersect[1] : Math.max(minRow, intersect[1]); long maxY = maxRow == null? intersect[3] : Math.min(maxRow, intersect[3]); for (long x = minX; x <= maxX; x++) { for (long y = minY; y <= maxY; y++) { BoundingBox box = gridSubset.boundsFromIndex(new long[] { x, y, z }); req.setBbox(new Envelope(box.getMinX(), box.getMaxX(), box.getMinY(), box.getMaxY())); WebMap result = webMapService.getMap(req); tiles.addTile(z, (int) x, (int) (flipy ? gridSubset.getNumTilesHigh(z) - (y + 1) : y), toBytes(result)); // Cleanup cleaner.finished(null); } } } } protected ReferencedEnvelope bounds(GetMapRequest req) { return new ReferencedEnvelope(req.getBbox(), req.getCrs()); } protected CoordinateReferenceSystem getCoordinateReferenceSystem(GetMapRequest req) { return req.getCrs(); } protected String getSRS(GetMapRequest req) { return req.getSRS() != null? req.getSRS().toUpperCase() : null; } // utility methods: protected BoundingBox bbox(GetMapRequest req) { Envelope bnds = bounds(req); return new BoundingBox(bnds.getMinX(), bnds.getMinY(), bnds.getMaxX(), bnds.getMaxY()); } protected Integer srid(GetMapRequest req) { Integer srid = null; try { if (getCoordinateReferenceSystem(req) != null){ srid = CRS.lookupEpsgCode(getCoordinateReferenceSystem(req), false); } if (srid == null) { srid = Integer.parseInt(getSRS(req).split(":")[1]); } } catch (Exception ex) { LOGGER.log(Level.WARNING, "Error determining srid", ex); } return srid; } protected Envelope findTileBounds(GridSubset gridSubset, BoundingBox bbox, int z) { long[] i = gridSubset.getCoverageIntersection(z, bbox); BoundingBox b1 = gridSubset.boundsFromIndex(new long[] { i[0], i[1], i[4] }); BoundingBox b2 = gridSubset.boundsFromIndex(new long[] { i[2], i[3], i[4] }); return new Envelope(Math.min(b1.getMinX(), b2.getMinX()), Math.max(b1.getMaxX(), b2.getMaxX()), Math.min(b1.getMinY(), b2.getMinY()), Math.max(b1.getMaxY(), b2.getMaxY())); } protected GridSubset findBestGridSubset(GetMapRequest req) { Map formatOpts = req.getFormatOptions(); GridSetBroker gridSetBroker = gwc.getGridSetBroker(); GridSet gridSet = null; // first check format options to see if explicitly specified if (formatOpts.containsKey("gridset")) { gridSet = gridSetBroker.get(formatOpts.get("gridset").toString()); } // next check srs if (gridSet == null && getSRS(req) != null) { gridSet = gridSetBroker.get( getSRS(req) ); } if (gridSet != null) { return GridSubsetFactory.createGridSubSet(gridSet); } CoordinateReferenceSystem crs = getCoordinateReferenceSystem(req); // look up epsg code Integer epsgCode = null; try { epsgCode = CRS.lookupEpsgCode(crs, false); } catch (Exception e) { throw new ServiceException("Unable to determine epsg code for " + crs, e); } if (epsgCode == null) { throw new ServiceException("Unable to determine epsg code for " + crs); } SRS srs = SRS.getSRS(epsgCode); // figure out the appropriate grid sub set Set<GridSubset> gridSubsets = new LinkedHashSet<GridSubset>(); for (MapLayerInfo l : req.getLayers()) { TileLayer tl = gwc.getTileLayerByName(l.getName()); if (tl == null) { throw new ServiceException("No tile layer for " + l.getName()); } List<GridSubset> theseGridSubsets = tl.getGridSubsetsForSRS(srs); if (gridSubsets.isEmpty()) { gridSubsets.addAll(theseGridSubsets); } else { gridSubsets.retainAll(theseGridSubsets); } if (gridSubsets.isEmpty()) { throw new ServiceException("No suitable " + epsgCode + " grid subset for " + req.getLayers()); } } if (gridSubsets.size() > 1) { if (LOGGER.isLoggable(Level.WARNING)) { StringBuilder msg = new StringBuilder("Found multiple grid subsets: "); for (GridSubset gs : gridSubsets) { msg.append(gs.getName()).append(", "); } msg.setLength(msg.length() - 2); msg.append(". Choosing first."); LOGGER.warning(msg.toString()); } } return gridSubsets.iterator().next(); } protected int[] findMinMaxZoom(GridSubset gridSubset, GetMapRequest req) { GridSet gridSet = gridSubset.getGridSet(); Map formatOpts = req.getFormatOptions(); Integer minZoom = null; if (formatOpts.containsKey("min_zoom")) { minZoom = Integer.parseInt(formatOpts.get("min_zoom").toString()); } if (minZoom == null) { minZoom = findClosestZoom(gridSet, req); } Integer maxZoom = null; if (formatOpts.containsKey("max_zoom")) { maxZoom = Integer.parseInt(formatOpts.get("max_zoom").toString()); } else if (formatOpts.containsKey("num_zooms")) { maxZoom = minZoom + Integer.parseInt(formatOpts.get("num_zooms").toString()); } if (maxZoom == null) { // walk down until we hit too many tiles maxZoom = findMaxZoomAuto(gridSubset, minZoom, req); } if (maxZoom < minZoom) { throw new ServiceException(format("maxZoom (%d) can not be less than minZoom (%d)", maxZoom, minZoom)); } // end index if (maxZoom > gridSet.getNumLevels()) { LOGGER.warning(format("Max zoom (%d) can't be greater than number of zoom levels (%d)", maxZoom, gridSet.getNumLevels())); maxZoom = gridSet.getNumLevels(); } return new int[] { minZoom, maxZoom }; } protected Integer findClosestZoom(GridSet gridSet, GetMapRequest req) { double reqScale = RendererUtilities.calculateOGCScale(bounds(req), gridSet.getTileWidth(), null); int i = 0; double error = Math.abs(gridSet.getGrid(i).getScaleDenominator() - reqScale); while (i < gridSet.getNumLevels() - 1) { Grid g = gridSet.getGrid(i + 1); double e = Math.abs(g.getScaleDenominator() - reqScale); if (e > error) { break; } else { error = e; } i++; } return Math.max(i, 0); } protected Integer findMaxZoomAuto(GridSubset gridSubset, Integer minZoom, GetMapRequest req) { BoundingBox bbox = bbox(req); int zoom = minZoom; int ntiles = 0; while (ntiles < 256 && zoom < gridSubset.getGridSet().getNumLevels()) { long[] intersect = gridSubset.getCoverageIntersection(zoom, bbox); ntiles += (intersect[2] - intersect[0] + 1) * (intersect[3] - intersect[1] + 1); zoom++; } return zoom; } protected String parseFormatFromOpts(Map formatOpts) { String format = (String) formatOpts.get("format"); return format.contains("/") ? format : "image/" + format; } protected String findBestFormat(GetMapRequest req) { // if request is a single coverage layer return jpeg, otherwise use just png List<MapLayerInfo> layers = req.getLayers(); if (layers.size() == 1 && layers.get(0).getType() == MapLayerInfo.TYPE_RASTER) { return JPEG_MIME_TYPE; } return PNG_MIME_TYPE; } protected byte[] toBytes(WebMap map) throws IOException { ByteArrayOutputStream bout = new ByteArrayOutputStream(); if (map instanceof RenderedImageMap) { RenderedImageMapResponse response = JPEG_MIME_TYPE.equals(map.getMimeType()) ? new JPEGMapResponse( wms) : new PNGMapResponse(wms); response.write(map, bout, null); } else if (map instanceof RawMap) { ((RawMap) map).writeTo(bout); } bout.flush(); return bout.toByteArray(); } }