/* (c) 2014 - 2015 Open Source Geospatial Foundation - all rights reserved * (c) 2001 - 2013 OpenPlans * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.wms.map; import java.awt.Transparency; import java.awt.image.BufferedImage; import java.awt.image.IndexColorModel; import java.awt.image.RenderedImage; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import javax.imageio.IIOImage; import javax.imageio.ImageIO; import javax.imageio.ImageTypeSpecifier; import javax.imageio.ImageWriteParam; import javax.imageio.metadata.IIOMetadata; import javax.imageio.metadata.IIOMetadataNode; import javax.imageio.stream.ImageOutputStream; import javax.media.jai.PlanarImage; import javax.media.jai.RenderedImageList; import org.geoserver.platform.ServiceException; import org.geoserver.wms.GetMapRequest; import org.geoserver.wms.MapProducerCapabilities; import org.geoserver.wms.RasterCleaner; import org.geoserver.wms.WMS; import org.geoserver.wms.WMSMapContent; import org.geotools.image.ImageWorker; import org.geotools.resources.image.ImageUtilities; import org.geotools.util.logging.Logging; import com.sun.media.imageioimpl.plugins.gif.GIFImageWriter; import com.sun.media.imageioimpl.plugins.gif.GIFImageWriterSpi; /** * Handles a GetMap request that spects a map in GIF format. * * @author Didier Richard * @author Simone Giannecchini - GeoSolutions * @author Alessio Fabiani - GeoSolutions * @version $Id */ public final class GIFMapResponse extends RenderedImageMapResponse { private static final String GIF_FRAMES_DELAY = "gif_frames_delay"; /** * Old name, with typo, kept for backwards compatibility */ private static final String GIF_LOOP_CONTINUOSLY = "gif_loop_continuosly"; private static final String GIF_LOOP_CONTINUOUSLY = "gif_loop_continuously"; private static final String GIF_DISPOSAL_METHOD = "gif_disposal"; private final static Logger LOGGER = Logging.getLogger(GIFMapResponse.class); public static final String IMAGE_GIF_SUBTYPE_ANIMATED = "image/gif;subtype=animated"; private static final GIFImageWriterSpi ORIGINATING_PROVIDER = new GIFImageWriterSpi(); /** the only MIME type this map producer supports */ static final String MIME_TYPE = "image/gif"; static final String[] OUTPUT_FORMATS = {MIME_TYPE, IMAGE_GIF_SUBTYPE_ANIMATED }; /** * Default capabilities for GIF . * * <p> * <ol> * <li>tiled = supported</li> * <li>multipleValues = unsupported</li> * <li>paletteSupported = supported</li> * <li>transparency = supported</li> * </ol> * * <p> * We should soon support multipage tiff. */ private static MapProducerCapabilities CAPABILITIES = new MapProducerCapabilities(true, false, true, true, MIME_TYPE); /** * Default capabilities for GIF animated. * * <p> * <ol> * <li>tiled = supported</li> * <li>multipleValues = supported</li> * <li>paletteSupported = supported</li> * <li>transparency = supported</li> * </ol> * * <p> * We should soon support multipage tiff. */ private static MapProducerCapabilities CAPABILITIES_ANIM = new MapProducerCapabilities(true, true, true, true, MIME_TYPE); public GIFMapResponse(WMS wms) { super(OUTPUT_FORMATS, wms); } /** * Transforms the rendered image into the appropriate format, streaming to the output stream. * * @param image The image to be formatted. * @param outStream The stream to write to. * * @throws ServiceException not really. * @throws IOException if encoding to <code>outStream</code> fails. */ @Override public void formatImageOutputStream(RenderedImage originalImage, OutputStream outStream, WMSMapContent mapContent) throws ServiceException, IOException { if (LOGGER.isLoggable(Level.FINE)) { LOGGER.fine("Writing gif image ..."); } // get the one required by the MultidimensionalGetMapRequest final String format = mapContent.getRequest().getFormat(); boolean animatedGIF = false; if (format.equalsIgnoreCase(IMAGE_GIF_SUBTYPE_ANIMATED)) { animatedGIF = true; } // the original image should always be a list of rendered images unless metatiling is // activated int numfiles = 1; final RenderedImageList ril; if (originalImage instanceof RenderedImageList) { // convert to list ril = (RenderedImageList) originalImage; // get number of images numfiles = ril.size(); } else { ril = new RenderedImageList(Arrays.asList(originalImage)); } if (numfiles == 1 || !animatedGIF) { if (LOGGER.isLoggable(Level.FINE)) LOGGER.fine("Preparing to write a gif..."); // // Now the magic // try { originalImage = applyPalette(originalImage, mapContent, MIME_TYPE, false); ImageWorker iw = new ImageWorker(originalImage); iw.writeGIF(outStream, "LZW", 0.75f); RasterCleaner.addImage(iw.getRenderedImage()); } catch (IOException e) { throw new ServiceException(e); } return; } // check the number is >1 if (numfiles <= 0) { throw new ServiceException("The number of frames for this GIF is less than 1"); } final GIFImageWriter gifWriter = new GIFImageWriter(ORIGINATING_PROVIDER); // write param final ImageWriteParam param = gifWriter.getDefaultWriteParam(); param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionType("LZW"); param.setCompressionQuality(0.75f); ImageOutputStream otStream = null; List<RenderedImage> images = new ArrayList<RenderedImage>(); try { otStream = ImageIO.createImageOutputStream(outStream); gifWriter.setOutput(otStream); gifWriter.prepareWriteSequence(null); // gif params final GetMapRequest request = mapContent.getRequest(); Object loopContinuouslyString = request.getFormatOptions().get(GIF_LOOP_CONTINUOUSLY); if (loopContinuouslyString == null) { loopContinuouslyString = request.getFormatOptions().get(GIF_LOOP_CONTINUOSLY); } final Boolean loopContinuosly = (loopContinuouslyString != null ? Boolean .valueOf((String) loopContinuouslyString) : wms.getLoopContinuously()); Object frameDelayString = request.getFormatOptions().get(GIF_FRAMES_DELAY); final Integer delay = (frameDelayString != null ? Integer.valueOf((String) frameDelayString) : wms.getFramesDelay()); final String requestedDisposalMethod = (String) request.getFormatOptions().get(GIF_DISPOSAL_METHOD); String disposalMethod = wms.getDisposalMethod(); if (requestedDisposalMethod != null) { for (String method : WMS.DISPOSAL_METHODS) { if (method.equalsIgnoreCase(requestedDisposalMethod.trim())) { disposalMethod = method; } } } // assign the proper well-known value for disposal method option if(disposalMethod == "backgroundColor") { disposalMethod = "restoreToBackgroundColor"; } else if(disposalMethod == "previous") { disposalMethod = "restoreToPrevious"; } // check value if (delay <= 0) throw new ServiceException("Animate GIF delay invalid: " + delay); // // Getting input files // for (int i = 0; i < numfiles; i++) { if (LOGGER.isLoggable(Level.FINE)) LOGGER.fine("Writing image " + i); // get the image RenderedImage ri = (RenderedImage) ril.get(i); // convert it to gif compatible ri = applyPalette(ri, mapContent, MIME_TYPE, false); if (ri != null) { // prepare metadata and write param final IIOMetadata imageMetadata = gifWriter.getDefaultImageMetadata( new ImageTypeSpecifier(ri), param); prepareMetadata(ri, imageMetadata, loopContinuosly, delay, disposalMethod); // write gifWriter.writeToSequence(new IIOImage(ri, null, imageMetadata), param); images.add(ri); } } // close writing sequence gifWriter.endWriteSequence(); } catch (IOException e) { throw new ServiceException(e); } finally { try { otStream.flush(); } catch (Exception e) { // swallow } try { otStream.close(); } catch (Exception e) { // swallow } try { gifWriter.dispose(); } catch (Exception e) { // swallow } // let go of the image chain as soon as possible to free memory for (RenderedImage image : images) { if (image instanceof PlanarImage) { ImageUtilities.disposePlanarImageChain((PlanarImage) image); } else if (image instanceof BufferedImage) { ((BufferedImage) image).flush(); } } } if (LOGGER.isLoggable(Level.FINE)) LOGGER.fine("Done writing animated gif"); } public String getContentDisposition() { // can be null return null; } /** * Prepare imagemetadata for writing an animated GIF. * <p> * This process involves setting the continuos looping mode as well the delay between frames * @param ri The {@link RenderedImage} for which we are setting metadata. * * @param imageMetadata original {@link IIOMetadata} instance to modify. * @param loopContinuously <code>yes</code> in case we want to loop continuosly, * <code>false</code> otherwise. * @param timeBetweenFramesMS the delay in ms between two frames when looping. * @param disposalMethod the disposal method for this image. * @throws IOException in case an error occurs. */ private static void prepareMetadata(RenderedImage ri, IIOMetadata imageMetadata, boolean loopContinuously, int timeBetweenFramesMS, String disposalMethod) throws IOException { String metaFormatName = imageMetadata.getNativeMetadataFormatName(); IIOMetadataNode root = (IIOMetadataNode) imageMetadata.getAsTree(metaFormatName); IIOMetadataNode graphicsControlExtensionNode = getNode(root, "GraphicControlExtension"); graphicsControlExtensionNode.setAttribute("disposalMethod", disposalMethod); graphicsControlExtensionNode.setAttribute("userInputFlag", "FALSE"); graphicsControlExtensionNode.setAttribute("delayTime", Integer.toString(timeBetweenFramesMS / 10)); // transparency support final IndexColorModel icm= (IndexColorModel) ri.getColorModel(); int transparentColorIndex=-1; if(icm.getTransparency()==Transparency.BITMASK&&(transparentColorIndex=icm.getTransparentPixel())>=0){ graphicsControlExtensionNode.setAttribute("transparentColorIndex", String.valueOf(transparentColorIndex)); graphicsControlExtensionNode.setAttribute("transparentColorFlag", "TRUE"); } else { graphicsControlExtensionNode.setAttribute("transparentColorIndex", "0"); graphicsControlExtensionNode.setAttribute("transparentColorFlag", "FALSE"); } IIOMetadataNode commentsNode = getNode(root, "CommentExtensions"); commentsNode.setAttribute("CommentExtension", "Created by MAH"); IIOMetadataNode appEntensionsNode = getNode(root, "ApplicationExtensions"); IIOMetadataNode child = new IIOMetadataNode("ApplicationExtension"); child.setAttribute("applicationID", "NETSCAPE"); child.setAttribute("authenticationCode", "2.0"); int loop = loopContinuously ? 0 : 1; final byte[] userObject=new byte[] { 0x1, (byte) (loop & 0xFF), (byte) ((loop >> 8) & 0xFF) }; child.setUserObject(userObject); appEntensionsNode.appendChild(child); imageMetadata.setFromTree(metaFormatName, root); } /** * Returns an existing child node, or creates and returns a new child node (if the requested * node does not exist). * * @param rootNode the <tt>IIOMetadataNode</tt> to search for the child node. * @param nodeName the name of the child node. * @return the child node, if found or a new node created with the given name. */ private static IIOMetadataNode getNode(IIOMetadataNode rootNode, String nodeName) { int nNodes = rootNode.getLength(); for (int i = 0; i < nNodes; i++) { if (rootNode.item(i).getNodeName().compareToIgnoreCase(nodeName) == 0) { return ((IIOMetadataNode) rootNode.item(i)); } } IIOMetadataNode node = new IIOMetadataNode(nodeName); rootNode.appendChild(node); return (node); } @Override public MapProducerCapabilities getCapabilities(String outputFormat) { if(IMAGE_GIF_SUBTYPE_ANIMATED.equals(outputFormat)) { return CAPABILITIES_ANIM; } else { return CAPABILITIES; } } }