/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2017, Open Source Geospatial Foundation (OSGeo)
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotools.mbstyle.sprite;
import java.awt.geom.AffineTransform;
import java.awt.image.AffineTransformOp;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import java.util.Map;
import java.util.logging.Logger;
import javax.imageio.ImageIO;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import org.geotools.mbstyle.transform.MBStyleTransformer;
import org.geotools.renderer.style.GraphicCache;
import org.geotools.renderer.style.ExternalGraphicFactory;
import org.geotools.styling.ExternalGraphic;
import org.geotools.util.SoftValueHashMap;
import org.geotools.util.logging.Logging;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.opengis.feature.Feature;
import org.opengis.filter.expression.Expression;
/**
*
* <p>
* Implementation of an {@link ExternalGraphicFactory} that takes the address of a Mapbox-style sprite sheet resource and an icon name, and retrieves
* the icon from the sprite sheet.
* </p>
*
* <p>
* Note that this factory expects the {@link MBStyleTransformer} to produce {@link ExternalGraphic} instances with slightly modified URLs of the
* following form:
* </p>
*
* <code>{baseUrl}#{iconName}</code>
*
* <p>
* Only the baseUrl is used to retrieve the sprite sheet (at {baseUrl}.png) and sprite index (at {baseUrl}.json). The iconName is then used by this
* factory to select the correct icon from the spritesheet.
* </p>
*
*
* For example, for the following Mapbox style:
*
* <pre>
* {
* "version": 8,
* "name": "A Mapbox Style",
* "sprite": "file:/GeoServerDataDirs/release/styles/testSpritesheet",
* "glyphs": "...",
* "sources": {...},
* "layers": [...]
* }
* </pre>
*
* <p>
* If a layer in this style references an icon in the spritesheet, e.g. iconName, then the constructed URL for the external graphic should be
* <code>file:/GeoServerDataDirs/release/styles/testSpritesheet#iconName</code>
* </p>
*
* @see <a href="https://www.mapbox.com/mapbox-gl-js/style-spec/#sprite">https://www.mapbox.com/mapbox-gl-js/style-spec/#sprite</a>
*
*/
public class SpriteGraphicFactory implements ExternalGraphicFactory,GraphicCache {
/**
* {@link ExternalGraphic} instances with this format will be handled by the {@link SpriteGraphicFactory}.
*/
public static final String FORMAT = "mbsprite";
JSONParser jsonParser = new JSONParser();
protected static Map<URL, BufferedImage> imageCache = Collections
.synchronizedMap(new SoftValueHashMap<>());
protected static Map<URL, SpriteIndex> indexCache = Collections
.synchronizedMap(new SoftValueHashMap<>());
private static final Logger LOGGER = Logging.getLogger(SpriteGraphicFactory.class);
private static final String ICON_NAME_DELIMITER = "#";
@Override
public Icon getIcon(Feature feature, Expression url, String format, int size) throws Exception {
// Only handle the correct format
if (!FORMAT.equalsIgnoreCase(format.trim())) {
return null;
}
URL loc = url.evaluate(feature, URL.class);
URL baseUrl = parseBaseUrl(loc);
String iconName = parseIconName(loc);
// Retrieve and parse the sprite index file.
SpriteIndex spriteIndex = getSpriteIndex(baseUrl);
SpriteIndex.IconInfo iconInfo = spriteIndex.getIcon(iconName);
// Retrieve the sprite sheet and get the icon as a sub image
BufferedImage spriteImg = getSpriteSheet(baseUrl);
BufferedImage iconSubImg = spriteImg.getSubimage(iconInfo.getX(), iconInfo.getY(),
iconInfo.getWidth(), iconInfo.getHeight());
// Use "size" to scale the image, if > 0
if (size > 0 && iconSubImg.getHeight() != size) {
double scaleY = ((double) size) / iconSubImg.getHeight(); // >1 if you're magnifying
double scaleX = scaleY; // keep aspect ratio!
AffineTransform scaleTx = AffineTransform.getScaleInstance(scaleX, scaleY);
AffineTransformOp ato = new AffineTransformOp(scaleTx, AffineTransformOp.TYPE_BILINEAR);
iconSubImg = ato.filter(iconSubImg, null);
}
return new ImageIcon(iconSubImg);
}
/**
* Parse the icon name from the provided {@link URL}. E.g.,
*
* <code>/path/to/sprite#iconName</code> will return <code>iconName</code>.
*
* @param url The url from which to parse the icon name.
* @return The icon name.
* @throws IllegalArgumentException If the icon name could not be parsed.
*/
protected static String parseIconName(URL url) {
String urlStr = url.toExternalForm();
if (urlStr.indexOf(ICON_NAME_DELIMITER) == -1) {
throw new IllegalArgumentException(
"Mapbox-style sprite external graphics must have url#{iconName}. URL was: "
+ urlStr);
}
String[] splitStr = url.toExternalForm().split(ICON_NAME_DELIMITER);
String iconName = splitStr[splitStr.length - 1];
if (iconName.trim().length() == 0) {
throw new IllegalArgumentException(
"Mapbox-style sprite external graphics must have non-empty url#{iconName}. URL was: "
+ urlStr);
}
return iconName;
}
/**
* Return the base URL (without an appended icon name) from the provided URL.
*
* <code>/path/to/sprite#iconName</code> will return <code>path/to/sprite</code>
*
* @param loc The URL.
* @return The URL, without an appended icon name.
* @throws MalformedURLException
*/
protected static URL parseBaseUrl(URL loc) throws MalformedURLException {
String urlStr = loc.toExternalForm();
int idx = urlStr.indexOf(ICON_NAME_DELIMITER);
if (idx == -1) {
return new URL(urlStr);
} else {
return new URL(urlStr.substring(0, idx));
}
}
/**
*
* Retrieve the sprite sheet index for the provided sprite base url. The base url should have no extension.
*
* @param baseUrl The base URL of the Mapbox sprite source (no extension).
* @return The sprite sheet index
* @throws IOException
*/
protected SpriteIndex getSpriteIndex(URL baseUrl) throws IOException {
SpriteIndex spriteIndex = indexCache.get(baseUrl);
if (spriteIndex == null) {
String indexUrlStr = baseUrl.toExternalForm() + ".json";
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new URL(indexUrlStr).openStream()))) {
Object parsed = jsonParser.parse(reader);
if (parsed instanceof JSONObject) {
spriteIndex = new SpriteIndex(indexUrlStr, (JSONObject) parsed);
indexCache.put(baseUrl, spriteIndex);
} else {
throw new MBSpriteException("Exception parsing sprite index file from: "
+ indexUrlStr + ". Expected JSONObject, but was: "
+ parsed.getClass().getSimpleName());
}
} catch (ParseException e) {
throw new MBSpriteException(
"Exception parsing sprite index file from: " + indexUrlStr, e);
}
}
return spriteIndex;
}
/**
* Retrieve the sprite sheet for the provided sprite base url. The base url should have no extension.
*
* @param baseUrl The base URL of the Mapbox sprite source (no extension).
* @return A {@link BufferedImage} for the sprite sheet.
*/
private BufferedImage getSpriteSheet(URL baseUrl) {
BufferedImage image = imageCache.get(baseUrl);
if (image == null) {
try {
URL spriteSheetUrl = new URL(baseUrl.toExternalForm() + ".png");
image = ImageIO.read(spriteSheetUrl);
} catch (Exception e) {
LOGGER.warning("Unable to retrieve sprite sheet from location: "
+ baseUrl.toExternalForm() + " (" + e.getMessage() + ")");
throw new MBSpriteException(
"Failed to retrieve sprite sheet for baseUrl: " + baseUrl.toExternalForm(),
e);
}
imageCache.put(baseUrl, image);
}
return image;
}
/**
* Images are cached by this factory. This method can be used to drop the cache.
*/
@Override
public void clearCache() {
imageCache.clear();
indexCache.clear();
}
}