/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2002-2008, 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.renderer.style;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints.Key;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.AbstractMap.SimpleImmutableEntry;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.swing.Icon;
import org.apache.batik.bridge.BridgeContext;
import org.apache.batik.bridge.DocumentLoader;
import org.apache.batik.bridge.GVTBuilder;
import org.apache.batik.bridge.UserAgent;
import org.apache.batik.bridge.UserAgentAdapter;
import org.apache.batik.dom.svg.SAXSVGDocumentFactory;
import org.apache.batik.gvt.GraphicsNode;
import org.apache.batik.util.XMLResourceDescriptor;
import org.geotools.factory.Factory;
import org.geotools.factory.GeoTools;
import org.geotools.factory.Hints;
import org.geotools.util.Converters;
import org.geotools.util.SoftValueHashMap;
import org.geotools.xml.NullEntityResolver;
import org.geotools.xml.PreventLocalEntityResolver;
import org.opengis.feature.Feature;
import org.opengis.filter.expression.Expression;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.EntityResolver;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
/**
* External graphic factory accepting an Expression that can be evaluated to a URL pointing to a SVG
* file. The <code>format</code> must be <code>image/svg+xml</code>, thought for backwards
* compatibility <code>image/svg-xml</code> and <code>image/svg</code> are accepted as well.
*
* @author Andrea Aime - TOPP
*
*
*
* @source $URL$
* http://svn.osgeo.org/geotools/branches/2.6.x/modules/plugin/svg/src/main/java/org/geotools
* /renderer/style/SVGGraphicFactory.java $
*/
public class SVGGraphicFactory implements Factory, ExternalGraphicFactory, GraphicCache {
private static final Pattern PARAMETER_PATTERN = Pattern.compile("param\\((.+)\\).*");
/** Parsed SVG glyphs cache */
static Map<String, RenderableSVG> glyphCache = Collections.synchronizedMap(new SoftValueHashMap<String, RenderableSVG>());
/** The possible mime types for SVG */
static final Set<String> formats = new HashSet<String>();
static {
formats.add("image/svg");
formats.add("image/svg-xml");
formats.add("image/svg+xml");
}
/** Hints we care about */
final private Map<Key, Object> implementationHints = new HashMap<>();
private EntityResolver resolver;
public SVGGraphicFactory(){
this( null );
}
public SVGGraphicFactory(Map<Key, Object> hints){
if( hints != null && hints.containsKey(Hints.ENTITY_RESOLVER)){
// use entity resolver provided (even if null)
this.resolver = (EntityResolver) hints.get(Hints.ENTITY_RESOLVER);
this.implementationHints.put( Hints.ENTITY_RESOLVER, this.resolver );
if( this.resolver == null ){ // use null instance rather than check each time
this.resolver = NullEntityResolver.INSTANCE;
}
}
else {
this.resolver = GeoTools.getEntityResolver(null);
}
}
@Override
public Map<Key, ?> getImplementationHints() {
return implementationHints;
}
public Icon getIcon(Feature feature, Expression url, String format, int size) throws Exception {
// check we do support the declared format
if (format == null || !formats.contains(format.toLowerCase()))
return null;
// grab the url
String svgfile = url.evaluate(feature, String.class);
if (svgfile == null) {
throw new IllegalArgumentException(
"The specified expression could not be turned into an URL");
} else {
// just for validation parse the URL
if(Converters.convert(svgfile, URL.class) == null) {
throw new IllegalArgumentException(
"Invalid URL: " + svgfile);
}
}
// turn the svg into a document and cache results
RenderableSVG svg = glyphCache.get(svgfile);
if(svg == null) {
String parser = XMLResourceDescriptor.getXMLParserClassName();
SAXSVGDocumentFactory f = new SAXSVGDocumentFactory(parser) {
@Override
public InputSource resolveEntity(String publicId, String systemId)
throws SAXException {
InputSource source = super.resolveEntity(publicId, systemId);
if (source == null) {
try {
return resolver.resolveEntity(publicId, systemId);
} catch (IOException e) {
throw new SAXException(e);
}
}
return source;
}
};
Document doc = f.createDocument(svgfile);
Map<String, String> parameters = getParametersFromUrl(svgfile);
if(!parameters.isEmpty() || hasParameters(doc.getDocumentElement())) {
replaceParameters(doc.getDocumentElement(), parameters);
}
svg = new RenderableSVG(doc);
glyphCache.put(svgfile, svg);
}
return new SVGIcon(svg, size);
}
/**
* Splits the query string in
* @param url
* @return
*/
Map<String, String> getParametersFromUrl(String url) {
// url.getQuery won't work on file addresses
int idx = url.indexOf("?");
if(idx == -1 || idx == url.length() - 1) {
return Collections.emptyMap();
}
String query = url.substring(idx + 1);
return Arrays.stream(query.split("&")).map(this::splitQueryParameter).filter(e -> e.getValue() != null)
.collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue(), (v1, v2) -> v2));
}
SimpleImmutableEntry<String, String> splitQueryParameter(String parameter) {
final int idx = parameter.indexOf("=");
final String key = idx > 0 ? parameter.substring(0, idx) : parameter;
try {
String value = null;
if(idx > 0 && parameter.length() > idx + 1) {
final String encodedValue = parameter.substring(idx + 1);
value = URLDecoder.decode(encodedValue, "UTF-8");
}
return new SimpleImmutableEntry<>(key, value);
} catch (UnsupportedEncodingException e) {
// UTF-8 not supported??
throw new RuntimeException(e);
}
}
private boolean hasParameters(Element root) {
// check if any attribute is parametric
NamedNodeMap attributes = root.getAttributes();
for(int i = 0; i < attributes.getLength(); i++) {
Node attribute = attributes.item(i);
final String nv = attribute.getNodeValue();
if(nv != null && nv.contains("param(")) {
return true;
}
}
// recurse
if(root.hasChildNodes()) {
NodeList childNodes = root.getChildNodes();
for(int i = 0; i < childNodes.getLength(); i++) {
Node n = childNodes.item(i);
if (n != null && n instanceof Element) {
if(hasParameters((Element) n)) {
return true;
}
}
}
}
return true;
}
private void replaceParameters(Element root, Map<String, String> parameters) {
if(root.hasChildNodes()) {
NodeList childNodes = root.getChildNodes();
for(int i = 0; i < childNodes.getLength(); i++) {
Node n = childNodes.item(i);
if (n != null && n instanceof Element) {
replaceParameters((Element) n, parameters);
}
}
}
NamedNodeMap attributes = root.getAttributes();
for(int i = 0; i < attributes.getLength(); i++) {
Node attribute = attributes.item(i);
if("style".equalsIgnoreCase(attribute.getNodeName())) {
String[] keyValues = attribute.getNodeValue().split("\\s*;\\s*");
StringBuilder newAttribute = new StringBuilder();
for (String keyValue : keyValues) {
String[] kv = keyValue.split("\\s*:\\s*");
if(kv.length < 2) {
continue;
}
String key = kv[0];
String value = replaceValue(kv[1], parameters);
newAttribute.append(key).append(":").append(value).append(";");
}
attribute.setNodeValue(newAttribute.toString());
} else {
String value = replaceValue(attribute.getNodeValue(), parameters);
attribute.setNodeValue(value);
}
}
}
private String replaceValue(String value, Map<String, String> parameters) {
Matcher m = PARAMETER_PATTERN.matcher(value);
String newValue;
if(m.matches()) {
final String key = m.group(1);
newValue = parameters.get(key);
// Batik unfortunately writes to log.err when it finds property it does not
// recognize, that's pretty bad, so we try to fit in some valid value
// for the "well known" cases
if(newValue != null) {
value = newValue;
} else if(key.contains("width")) {
value = "0";
} else if(key.contains("opacity") || key.contains("alpha")) {
value = "1";
} else {
value = "#000000";
}
}
return value;
}
private static class SVGIcon implements Icon {
private int width;
private int height;
private RenderableSVG svg;
public SVGIcon(RenderableSVG svg, int size) {
this.svg = svg;
// defines target width and height for render, based on the SVG bounds
// and the specified desired height (if height is not provided, then
// SVG bounds are used)
Rectangle2D bounds = svg.bounds;
double targetWidth = bounds.getWidth();
double targetHeight = bounds.getHeight();
if (size > 0) {
double shapeAspectRatio = (bounds.getHeight() > 0 && bounds.getWidth() > 0) ? bounds
.getWidth()
/ bounds.getHeight()
: 1.0;
targetWidth = shapeAspectRatio * size;
targetHeight = size;
}
this.width = (int) Math.round(targetWidth);
this.height = (int) Math.round(targetHeight);
}
public int getIconHeight() {
return height;
}
public int getIconWidth() {
return width;
}
public void paintIcon(Component c, Graphics g, int x, int y) {
svg.paint((Graphics2D) g, width, height, x, y);
}
}
private static class RenderableSVG {
Rectangle2D bounds;
private GraphicsNode node;
public RenderableSVG(Document doc) {
this.node = getGraphicNode(doc);
this.bounds = getSvgDocBounds(doc);
if (bounds == null)
bounds = node.getBounds();
}
/**
* Retrieves an SVG document's specified bounds.
*
* @param svgLocation
* an URL that specifies the SVG.
* @return a {@link Rectangle2D} with the corresponding bounds. If the SVG document does not
* specify any bounds, then null is returned.
* @throws IOException
*
*/
private Rectangle2D getSvgDocBounds(Document doc) {
NodeList list = doc.getElementsByTagName("svg");
Node svgNode = list.item(0);
NamedNodeMap attributes = svgNode.getAttributes();
Node widthNode = attributes.getNamedItem("width");
Node heightNode = attributes.getNamedItem("height");
if (widthNode != null && heightNode != null) {
double width = parseDouble(widthNode.getNodeValue());
double height = parseDouble(heightNode.getNodeValue());
return new Rectangle2D.Double(0.0, 0.0, width, height);
}
return null;
}
private double parseDouble(String value) {
try {
return Double.parseDouble(value);
}
catch(NumberFormatException e) {
//strip off any units
return Double.parseDouble(value.replaceAll("\\D*$", ""));
}
}
/**
* Retrieves a Batik {@link GraphicsNode} for a given SVG.
*
* @param svgLocation
* an URL that specifies the SVG.
* @return the corresponding GraphicsNode.
* @throws IOException
* @throws URISyntaxException
*/
private GraphicsNode getGraphicNode(Document doc) {
// instantiates objects needed for building the node
UserAgent userAgent = new UserAgentAdapter();
DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamic(true);
// creates node builder and builds node
GVTBuilder builder = new GVTBuilder();
return builder.build(ctx, doc);
}
public void paint(Graphics2D g, int width, int height, int x, int y) {
// saves the old transform;
AffineTransform oldTransform = g.getTransform();
try {
if (oldTransform == null)
oldTransform = new AffineTransform();
AffineTransform transform = new AffineTransform(oldTransform);
// adds scaling to the transform so that we respect the declared size
transform.translate(x, y);
transform.scale(width / bounds.getWidth(), height / bounds.getHeight());
g.setTransform(transform);
node.paint(g);
} finally {
g.setTransform(oldTransform);
}
}
}
/**
* Forcefully drops the SVG cache
*/
public static void resetCache() {
glyphCache.clear();
}
@Override
public void clearCache() {
resetCache();
}
}