/* Copyright (c) 2001 - 2007 TOPP - www.openplans.org. All rights reserved. * This code is licensed under the GPL 2.0 license, availible at the root * application directory. */ package org.geoserver.wms.legendgraphic; import java.awt.Color; import java.awt.Font; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.awt.image.IndexColorModel; import java.awt.image.RenderedImage; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import org.geoserver.platform.ServiceException; import org.geoserver.wms.GetLegendGraphicRequest; import org.geoserver.wms.map.ImageUtils; import org.geotools.data.DataUtilities; import org.geotools.feature.SchemaException; import org.geotools.feature.simple.SimpleFeatureBuilder; import org.geotools.geometry.jts.LiteShape2; import org.geotools.renderer.lite.StyledShapePainter; import org.geotools.renderer.style.SLDStyleFactory; import org.geotools.renderer.style.Style2D; import org.geotools.styling.Description; import org.geotools.styling.FeatureTypeStyle; import org.geotools.styling.LineSymbolizer; import org.geotools.styling.PointSymbolizer; import org.geotools.styling.PolygonSymbolizer; import org.geotools.styling.RasterSymbolizer; import org.geotools.styling.Rule; import org.geotools.styling.Style; import org.geotools.styling.Symbolizer; import org.geotools.styling.TextSymbolizer; import org.geotools.util.NumberRange; import org.opengis.feature.Feature; import org.opengis.feature.IllegalAttributeException; import org.opengis.feature.simple.SimpleFeature; import org.opengis.feature.simple.SimpleFeatureType; import org.opengis.feature.type.FeatureType; import org.opengis.util.InternationalString; import com.vividsolutions.jts.geom.Coordinate; import com.vividsolutions.jts.geom.GeometryFactory; import com.vividsolutions.jts.geom.LineString; import com.vividsolutions.jts.geom.LinearRing; import com.vividsolutions.jts.geom.Polygon; /** * Template {@linkPlain org.vfny.geoserver.responses.wms.GetLegendGraphicProducer} based on * GeoTools' {@link GeoTools' {@link http * ://svn.geotools.org/geotools/trunk/gt/module/main/src/org/geotools * /renderer/lite/StyledShapePainter.java StyledShapePainter} that produces a BufferedImage with the * appropiate legend graphic for a given GetLegendGraphic WMS request. * * <p> * It should be enough for a subclass to implement {@linkPlain * org.vfny.geoserver.responses.wms.GetLegendGraphicProducer#writeTo(OutputStream)} and * <code>getContentType()</code> in order to encode the BufferedImage produced by this class to the * appropiate output format. * </p> * * <p> * This class takes literally the fact that the arguments <code>WIDTH</code> and <code>HEIGHT</code> * are just <i>hints</i> about the desired dimensions of the produced graphic, and the need to * produce a legend graphic representative enough of the SLD style for which it is being generated. * Thus, if no <code>RULE</code> parameter was passed and the style has more than one applicable * Rule for the actual scale factor, there will be generated a legend graphic of the specified * width, but with as many stacked graphics as applicable rules were found, providing by this way a * representative enough legend. * </p> * * @author Gabriel Roldan * @author Simone Giannecchini, GeoSolutions SAS * @version $Id$ */ public class BufferedImageLegendGraphicBuilder { /** Tolerance used to compare doubles for equality */ public static final double TOLERANCE = 1e-6; /** * Singleton shape painter to serve all legend requests. We can use a single shape painter * instance as long as it remains thread safe. */ private static final StyledShapePainter shapePainter = new StyledShapePainter(); /** * used to create sample point shapes with LiteShape (not lines nor polygons) */ private static final GeometryFactory geomFac = new GeometryFactory(); /** * set to <code>true</code> when <code>abort()</code> gets called, indicates that the rendering * of the legend graphic should stop gracefully as soon as possible */ private boolean renderingStopRequested; /** * Just a holder to avoid creating many polygon shapes from inside <code>getSampleShape()</code> */ private LiteShape2 sampleRect; /** * Just a holder to avoid creating many line shapes from inside <code>getSampleShape()</code> */ private LiteShape2 sampleLine; /** * Just a holder to avoid creating many point shapes from inside <code>getSampleShape()</code> */ private LiteShape2 samplePoint; /** * Default constructor. Subclasses may provide its own with a String parameter to establish its * desired output format, if they support more than one (e.g. a JAI based one) */ public BufferedImageLegendGraphicBuilder() { super(); } /** * Takes a GetLegendGraphicRequest and produces a BufferedImage that then can be used by a * subclass to encode it to the appropiate output format. * * @param request * the "parsed" request, where "parsed" means that it's values are already validated * so this method must not take care of verifying the requested layer exists and the * like. * @return * * @throws ServiceException * if there are problems creating a "sample" feature instance for the FeatureType * <code>request</code> returns as the required layer (which should not occur). */ public BufferedImage buildLegendGraphic(GetLegendGraphicRequest request) throws ServiceException { final Style gt2Style = request.getStyle(); if (gt2Style == null) { throw new NullPointerException("request.getStyle()"); } final FeatureType layer = request.getLayer(); boolean strict = request.isStrict(); final boolean buildRasterLegend = (!strict && layer == null && LegendUtils .checkRasterSymbolizer(gt2Style)) || LegendUtils.checkGridLayer(layer); if (buildRasterLegend) { final RasterLayerLegendHelper rasterLegendHelper = new RasterLayerLegendHelper(request); final BufferedImage image = rasterLegendHelper.getLegend(); return image; } final SimpleFeature sampleFeature; if (layer == null) { sampleFeature = createSampleFeature(); } else { final Feature temp = createSampleFeature(layer); if (!(temp instanceof SimpleFeature)) { throw new UnsupportedOperationException("not a SimpleFeature"); } sampleFeature = (SimpleFeature) temp; } final FeatureTypeStyle[] ftStyles = gt2Style.featureTypeStyles().toArray( new FeatureTypeStyle[0]); final double scaleDenominator = request.getScale(); final Rule[] applicableRules; if (request.getRule() != null) { applicableRules = new Rule[] { request.getRule() }; } else { applicableRules = LegendUtils.getApplicableRules(ftStyles, scaleDenominator); } final NumberRange<Double> scaleRange = NumberRange.create(scaleDenominator, scaleDenominator); final int ruleCount = applicableRules.length; /** * A legend graphic is produced for each applicable rule. They're being held here until the * process is done and then painted on a "stack" like legend. */ final List<RenderedImage> legendsStack = new ArrayList<RenderedImage>(ruleCount); final int w = request.getWidth(); final int h = request.getHeight(); final SLDStyleFactory styleFactory = new SLDStyleFactory(); final Color bgColor = LegendUtils.getBackgroundColor(request); for (int i = 0; i < ruleCount; i++) { final Symbolizer[] symbolizers = applicableRules[i].getSymbolizers(); // BufferedImage image = prepareImage(w, h, request.isTransparent()); final boolean transparent = request.isTransparent(); final RenderedImage image = ImageUtils.createImage(w, h, (IndexColorModel) null, transparent); final Map<RenderingHints.Key, Object> hintsMap = new HashMap<RenderingHints.Key, Object>(); final Graphics2D graphics = ImageUtils.prepareTransparency(transparent, bgColor, image, hintsMap); graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); for (int sIdx = 0; sIdx < symbolizers.length; sIdx++) { final Symbolizer symbolizer = symbolizers[sIdx]; if (symbolizer instanceof RasterSymbolizer) { throw new IllegalStateException( "It is not legal to have a RasterSymbolizer here"); } else { Style2D style2d = styleFactory.createStyle(sampleFeature, symbolizer, scaleRange); LiteShape2 shape = getSampleShape(symbolizer, w, h); if (style2d != null) { shapePainter.paint(graphics, shape, style2d, scaleDenominator); } } } legendsStack.add(image); graphics.dispose(); } // JD: changed legend behavior, see GEOS-812 // this.legendGraphic = scaleImage(mergeLegends(legendsStack), request); BufferedImage image = mergeLegends(legendsStack, applicableRules, request); return image; } /** * Recieves a list of <code>BufferedImages</code> and produces a new one which holds all the * images in <code>imageStack</code> one above the other. * * @param imageStack * the list of BufferedImages, one for each applicable Rule * @param rules * The applicable rules, one for each image in the stack * @param request * The request. * * @return the stack image with all the images on the argument list. * * @throws IllegalArgumentException * if the list is empty */ private static BufferedImage mergeLegends(List<RenderedImage> imageStack, Rule[] rules, GetLegendGraphicRequest req) { Font labelFont = LegendUtils.getLabelFont(req); boolean useAA = false; if (req.getLegendOptions().get("fontAntiAliasing") instanceof String) { String aaVal = (String) req.getLegendOptions().get("fontAntiAliasing"); if (aaVal.equalsIgnoreCase("on") || aaVal.equalsIgnoreCase("true") || aaVal.equalsIgnoreCase("yes") || aaVal.equalsIgnoreCase("1")) { useAA = true; } } boolean forceLabelsOn = false; boolean forceLabelsOff = false; if (req.getLegendOptions().get("forceLabels") instanceof String) { String forceLabelsOpt = (String) req.getLegendOptions().get("forceLabels"); if (forceLabelsOpt.equalsIgnoreCase("on")) { forceLabelsOn = true; } else if (forceLabelsOpt.equalsIgnoreCase("off")) { forceLabelsOff = true; } } if (imageStack.size() == 0) { throw new IllegalArgumentException("No legend graphics passed"); } final BufferedImage finalLegend; if (imageStack.size() == 1 && !forceLabelsOn) { finalLegend = (BufferedImage) imageStack.get(0); } else { final int imgCount = imageStack.size(); final String[] labels = new String[imgCount]; BufferedImage img = ((BufferedImage) imageStack.get(0)); int totalHeight = 0; int totalWidth = 0; int[] rowHeights = new int[imgCount]; BufferedImage labelsGraphics[] = new BufferedImage[imgCount]; for (int i = 0; i < imgCount; i++) { img = (BufferedImage) imageStack.get(i); if (forceLabelsOff) { totalWidth = (int) Math.ceil(Math.max(img.getWidth(), totalWidth)); rowHeights[i] = img.getHeight(); totalHeight += img.getHeight(); } else { Rule rule = rules[i]; // What's the label on this rule? We prefer to use // the 'title' if it's available, but fall-back to 'name' final Description description = rule.getDescription(); if (description != null && description.getTitle() != null) { final InternationalString title = description.getTitle(); labels[i] = title.toString(); } else if (rule.getName() == null) { labels[i] = rule.getName(); } else { labels[i] = ""; } Graphics2D g = img.createGraphics(); g.setFont(labelFont); if (useAA) { g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); } else { g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); } if (labels[i] != null && labels[i].length() > 0) { final BufferedImage renderedLabel = LegendUtils.renderLabel(labels[i], g, req); labelsGraphics[i] = renderedLabel; final Rectangle2D bounds = new Rectangle2D.Double(0, 0, renderedLabel.getWidth(), renderedLabel.getHeight()); totalWidth = (int) Math.ceil(Math.max(img.getWidth() + bounds.getWidth(), totalWidth)); rowHeights[i] = (int) Math.ceil(Math.max(img.getHeight(), bounds.getHeight())); } else { totalWidth = (int) Math.ceil(Math.max(img.getWidth(), totalWidth)); rowHeights[i] = (int) Math.ceil(img.getHeight()); labelsGraphics[i] = null; } totalHeight += rowHeights[i]; } } // buffer the width a bit totalWidth += 2; final boolean transparent = req.isTransparent(); final Color backgroundColor = LegendUtils.getBackgroundColor(req); final Map<RenderingHints.Key, Object> hintsMap = new HashMap<RenderingHints.Key, Object>(); // create the final image finalLegend = ImageUtils.createImage(totalWidth, totalHeight, (IndexColorModel) null, transparent); Graphics2D finalGraphics = ImageUtils.prepareTransparency(transparent, backgroundColor, finalLegend, hintsMap); int topOfRow = 0; for (int i = 0; i < imgCount; i++) { img = (BufferedImage) imageStack.get(i); // draw the image int y = topOfRow; if (img.getHeight() < rowHeights[i]) { // move the image to the center of the row y += (int) ((rowHeights[i] - img.getHeight()) / 2d); } finalGraphics.drawImage(img, 0, y, null); if (forceLabelsOff) { topOfRow += rowHeights[i]; continue; } finalGraphics.setFont(labelFont); if (useAA) { finalGraphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); } else { finalGraphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); } // draw the label if (labels[i] != null && labels[i].length() > 0) { // first create the actual overall label image. final BufferedImage renderedLabel = labelsGraphics[i]; y = topOfRow; if (renderedLabel.getHeight() < rowHeights[i]) { y += (int) ((rowHeights[i] - renderedLabel.getHeight()) / 2d); } finalGraphics.drawImage(renderedLabel, img.getWidth(), y, null); // cleanup renderedLabel.flush(); labelsGraphics[i] = null; } topOfRow += rowHeights[i]; } finalGraphics.dispose(); } return finalLegend; } /** * Returns a <code>java.awt.Shape</code> appropiate to render a legend graphic given the * symbolizer type and the legend dimensions. * * @param symbolizer * the Symbolizer for whose type a sample shape will be created * @param legendWidth * the requested width, in output units, of the legend graphic * @param legendHeight * the requested height, in output units, of the legend graphic * * @return an appropiate Line2D, Rectangle2D or LiteShape(Point) for the symbolizer, wether it * is a LineSymbolizer, a PolygonSymbolizer, or a Point ot Text Symbolizer * * @throws IllegalArgumentException * if an unknown symbolizer impl was passed in. */ private LiteShape2 getSampleShape(Symbolizer symbolizer, int legendWidth, int legendHeight) { LiteShape2 sampleShape; final float hpad = (legendWidth * LegendUtils.hpaddingFactor); final float vpad = (legendHeight * LegendUtils.vpaddingFactor); if (symbolizer instanceof LineSymbolizer) { if (this.sampleLine == null) { Coordinate[] coords = { new Coordinate(hpad, legendHeight - vpad), new Coordinate(legendWidth - hpad, vpad) }; LineString geom = geomFac.createLineString(coords); try { this.sampleLine = new LiteShape2(geom, null, null, false); } catch (Exception e) { this.sampleLine = null; } } sampleShape = this.sampleLine; } else if ((symbolizer instanceof PolygonSymbolizer) || (symbolizer instanceof RasterSymbolizer)) { if (this.sampleRect == null) { final float w = legendWidth - (2 * hpad); final float h = legendHeight - (2 * vpad); Coordinate[] coords = { new Coordinate(hpad, vpad), new Coordinate(hpad, vpad + h), new Coordinate(hpad + w, vpad + h), new Coordinate(hpad + w, vpad), new Coordinate(hpad, vpad) }; LinearRing shell = geomFac.createLinearRing(coords); Polygon geom = geomFac.createPolygon(shell, null); try { this.sampleRect = new LiteShape2(geom, null, null, false); } catch (Exception e) { this.sampleRect = null; } } sampleShape = this.sampleRect; } else if (symbolizer instanceof PointSymbolizer || symbolizer instanceof TextSymbolizer) { if (this.samplePoint == null) { Coordinate coord = new Coordinate(legendWidth / 2, legendHeight / 2); try { this.samplePoint = new LiteShape2(geomFac.createPoint(coord), null, null, false); } catch (Exception e) { this.samplePoint = null; } } sampleShape = this.samplePoint; } else { throw new IllegalArgumentException("Unknown symbolizer: " + symbolizer); } return sampleShape; } private SimpleFeature createSampleFeature() { SimpleFeatureType type; try { type = DataUtilities.createType("Sample", "the_geom:Geometry"); } catch (SchemaException e) { throw new RuntimeException(e); } return SimpleFeatureBuilder.template((SimpleFeatureType) type, null); } /** * Creates a sample Feature instance in the hope that it can be used in the rendering of the * legend graphic. * * @param schema * the schema for which to create a sample Feature instance * * @return * * @throws ServiceException */ private Feature createSampleFeature(FeatureType schema) throws ServiceException { SimpleFeature sampleFeature; try { if (schema instanceof SimpleFeatureType) { sampleFeature = SimpleFeatureBuilder.template((SimpleFeatureType) schema, null); } else { // TODO: implement support for GSIP 31 (DataAccess API) throw new UnsupportedOperationException( "Sample non-simple feature not yet supported."); } } catch (IllegalAttributeException e) { throw new ServiceException(e); } return sampleFeature; } }