Java tutorial
/** * Copyright (C) 2010 Orbeon, Inc. * * This program 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; either version * 2.1 of the License, or (at your option) any later version. * * This program 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. * * The full text of the license is available at http://www.gnu.org/copyleft/lesser.html */ package org.orbeon.oxf.processor; import org.apache.log4j.Logger; import org.dom4j.Document; import org.dom4j.Element; import org.dom4j.Node; import org.orbeon.exception.OrbeonFormatter; import org.orbeon.oxf.cache.CacheKey; import org.orbeon.oxf.cache.InternalCacheKey; import org.orbeon.oxf.cache.ObjectCache; import org.orbeon.oxf.cache.SoftCacheImpl; import org.orbeon.oxf.common.OXFException; import org.orbeon.oxf.pipeline.api.ExternalContext; import org.orbeon.oxf.pipeline.api.PipelineContext; import org.orbeon.oxf.xml.XMLReceiver; import org.orbeon.oxf.processor.impl.CacheableTransformerOutputImpl; import org.orbeon.oxf.resources.URLFactory; import org.orbeon.oxf.util.ContentHandlerOutputStream; import org.orbeon.oxf.util.LoggerFactory; import org.orbeon.oxf.util.NetUtils; import org.orbeon.oxf.util.NumberUtils; import org.orbeon.oxf.xml.XPathUtils; import org.orbeon.oxf.xml.dom4j.Dom4jUtils; import org.orbeon.oxf.xml.dom4j.NonLazyUserDataDocument; import javax.imageio.ImageIO; import javax.imageio.ImageWriteParam; import javax.imageio.ImageWriter; import java.awt.*; import java.awt.geom.Rectangle2D; import java.awt.image.*; import java.io.*; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.util.*; import java.util.List; /** * ImageServer directly serves or converts to its "data" output images from URLs while performing * various operations on them such as scaling or cropping. It also handles a disk cache of * transformed images. * * NOTE: The JPEG quality parameter only applies when a transformation is done. There is no * provision to do a quality conversion only. */ public class ImageServer extends ProcessorImpl { private static Logger logger = LoggerFactory.createLogger(ImageServer.class); public static final String IMAGE_SERVER_CONFIG_NAMESPACE_URI = "http://orbeon.org/oxf/xml/image-server-config"; public static final String IMAGE_SERVER_IMAGE_NAMESPACE_URI = "http://orbeon.org/oxf/xml/image-server-image"; private static final String INPUT_IMAGE = "image"; private static final float DEFAULT_QUALITY = 0.5f; private static final boolean DEFAULT_USE_SANDBOX = true; private static final boolean DEFAULT_USE_CACHE = true; private static final boolean DEFAULT_SCALE_UP = true; private SoftCacheImpl cache; public ImageServer() { addInputInfo(new ProcessorInputOutputInfo(INPUT_CONFIG, IMAGE_SERVER_CONFIG_NAMESPACE_URI)); addInputInfo(new ProcessorInputOutputInfo(INPUT_IMAGE, IMAGE_SERVER_IMAGE_NAMESPACE_URI)); //addOutputInfo(new ProcessorInputOutputInfo(OUTPUT_DATA)); // optional } private static class Config { public URL imageDirectoryURL; public File cacheDir; public float defaultQuality; public boolean useSandbox; public String cachePathEncoding; } private static class ImageConfig { public String urlString; public Float quality; public Boolean useCache; public Object transforms; public int transformCount; public Iterator transformIterator; } public void processImage(PipelineContext pipelineContext, ImageResponse imageResponse) { try { // Read global configuration final Config config = readCacheInputAsObject(pipelineContext, getInputByName(INPUT_CONFIG), new CacheableInputReader<Config>() { public Config read(PipelineContext pipelineContext, ProcessorInput processorInput) { final Document configDocument = readInputAsDOM4J(pipelineContext, processorInput); final Config result = new Config(); String imageDirectoryString = XPathUtils.selectStringValueNormalize(configDocument, "/config/image-directory"); imageDirectoryString = imageDirectoryString.replace('\\', '/'); // Make sure this ends with a '/' so that it is considered a directory if (!imageDirectoryString.endsWith("/")) imageDirectoryString = imageDirectoryString + '/'; result.imageDirectoryURL = URLFactory.createURL(imageDirectoryString); final String cacheDirectoryString = XPathUtils .selectStringValueNormalize(configDocument, "/config/cache/directory"); result.cacheDir = (cacheDirectoryString == null) ? null : new File(cacheDirectoryString); if (result.cacheDir != null && !result.cacheDir.isDirectory()) throw new IllegalArgumentException( "Invalid cache directory: " + cacheDirectoryString); result.defaultQuality = selectFloatValue(configDocument, "/config/default-quality", DEFAULT_QUALITY); if (result.defaultQuality < 0.0f || result.defaultQuality > 1.0f) throw new IllegalArgumentException( "default-quality must be comprised between 0.0 and 1.0"); result.useSandbox = selectBooleanValue(configDocument, "/config/use-sandbox", DEFAULT_USE_SANDBOX); result.cachePathEncoding = XPathUtils.selectStringValueNormalize(configDocument, "/config/cache/path-encoding"); return result; } }); // Read image configuration final ImageConfig imageConfig = readCacheInputAsObject(pipelineContext, getInputByName(INPUT_IMAGE), new CacheableInputReader<ImageConfig>() { public ImageConfig read(PipelineContext pipelineContext, ProcessorInput processorInput) { final Document imageConfigDocument = readCacheInputAsDOM4J(pipelineContext, INPUT_IMAGE); final ImageConfig result = new ImageConfig(); // Read URL result.urlString = XPathUtils.selectStringValueNormalize(imageConfigDocument, "/image/url"); // For backward compatibility, try to get path element (which also contained an URL!) if (result.urlString == null) { result.urlString = XPathUtils.selectStringValueNormalize(imageConfigDocument, "/image/path"); } String qualityString = XPathUtils.selectStringValueNormalize(imageConfigDocument, "/image/quality"); result.quality = (qualityString == null) ? null : new Float(qualityString); String useCacheString = XPathUtils.selectStringValueNormalize(imageConfigDocument, "/image/use-cache"); result.useCache = (useCacheString == null) ? null : Boolean.valueOf(useCacheString); result.transformCount = XPathUtils.selectIntegerValue(imageConfigDocument, "count(/image/transform)"); Object transforms = XPathUtils.selectObjectValue(imageConfigDocument, "/image/transform"); if (transforms != null && transforms instanceof Node) transforms = Collections.singletonList(transforms); result.transforms = transforms; result.transformIterator = XPathUtils.selectIterator(imageConfigDocument, "/image/transform"); return result; } }); final float quality = (imageConfig.quality == null) ? config.defaultQuality : imageConfig.quality; final boolean useCache = config.cacheDir != null && ((imageConfig.useCache == null) ? DEFAULT_USE_CACHE : imageConfig.useCache); URLConnection urlConnection = null; InputStream urlConnectionInputStream = null; try { // Make sure the requested resource exists and is valid final URL newURL; try { newURL = URLFactory.createURL(config.imageDirectoryURL, imageConfig.urlString); // Check if new URL is relative to image directory URL boolean relative = NetUtils.relativeURL(config.imageDirectoryURL, newURL); if (config.useSandbox && !relative) { imageResponse.setStatus(ExternalContext.SC_NOT_FOUND); return; } // Try to open the connection urlConnection = newURL.openConnection(); // Get InputStream and make sure it supports marks urlConnectionInputStream = urlConnection.getInputStream(); if (!urlConnectionInputStream.markSupported()) urlConnectionInputStream = new BufferedInputStream(urlConnectionInputStream); // Make sure the resource looks like a JPEG file String contentType = URLConnection.guessContentTypeFromStream(urlConnectionInputStream); if (!"image/jpeg".equals(contentType)) { imageResponse.setStatus(ExternalContext.SC_NOT_FOUND); return; } } catch (IOException e) { imageResponse.setStatus(ExternalContext.SC_NOT_FOUND); return; } // Get date of last modification of resource long lastModified = NetUtils.getLastModified(urlConnection); // Cache handling String cacheFileName = useCache ? computeCacheFileName(config.cachePathEncoding, imageConfig.urlString, (List<Element>) imageConfig.transforms) : null; File cacheFile = useCache ? new File(config.cacheDir, cacheFileName) : null; boolean cacheInvalid = !useCache || !cacheFile.exists() || lastModified == 0 || lastModified > cacheFile.lastModified() || cacheFile.length() == 0; boolean mustProcess = cacheInvalid; boolean updateCache = useCache && cacheInvalid; // Set Last-Modified, required for caching and conditional get imageResponse.setResourceCaching(lastModified, 0); // Check If-Modified-Since and don't return content if condition is met if ((imageConfig.transformCount == 0 || !mustProcess) && !imageResponse.checkIfModifiedSince(lastModified, false)) { imageResponse.setStatus(ExternalContext.SC_NOT_MODIFIED); return; } // Set Content-Type imageResponse.setContentType("image/jpeg"); // Optimize if no transformation is specified if (imageConfig.transformCount == 0) { NetUtils.copyStream(urlConnectionInputStream, imageResponse.getOutputStream()); return; } // Process image if needed if (mustProcess) { boolean closeOutputStream = false; OutputStream os = null; try { // Try to obtain decoded image from cache first Long cacheValidity = lastModified; String cacheKey = "[" + newURL.toExternalForm() + "][" + cacheValidity + "]"; BufferedImage img1; // Decode one image at a time to try to minimize the memory impact // NOTE: This should probably be configurable synchronized (ImageServer.this) { img1 = (cache == null) ? null : (BufferedImage) cache.get(cacheKey); // If this failed (most common case) decode the image if (img1 == null) { // Decode image into BufferedImage img1 = ImageIO.read(urlConnectionInputStream); // Store the image into the soft cache if (cache == null) cache = new SoftCacheImpl(0); cache.put(cacheKey, img1); } else { cache.refresh(cacheKey); //logger.info("Found image in cache with key: " + cacheKey); logger.info("Found decoded image in cache"); } } // Filter image BufferedImage img2 = filter(img1, imageConfig.transformIterator); // Create OutputStream if (updateCache) { File outputDir = cacheFile.getParentFile(); if (!outputDir.exists() && !outputDir.mkdirs()) { logger.info("Cannot create cache directory: " + outputDir.getCanonicalPath()); imageResponse.setStatus(ExternalContext.SC_INTERNAL_SERVER_ERROR); return; } os = new FileOutputStream(cacheFile); closeOutputStream = true; } else { os = imageResponse.getOutputStream(); } // Encode image to OutputStream final Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); final ImageWriter writer = (ImageWriter) writers.next(); writer.setOutput(ImageIO.createImageOutputStream(os)); final ImageWriteParam params = writer.getDefaultWriteParam(); // Set quality params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); params.setCompressionQuality(quality); writer.write(img2); } catch (OXFException e) { logger.error(OrbeonFormatter.format(e)); imageResponse.setStatus(ExternalContext.SC_INTERNAL_SERVER_ERROR); return; } finally { if (os != null && closeOutputStream) os.close(); } } // Send cached image if relevant if (useCache) { InputStream is = new FileInputStream(cacheFile); OutputStream os = imageResponse.getOutputStream(); try { NetUtils.copyStream(is, os); } finally { is.close(); } } } finally { // Make sure the connection is closed because when getting the // last modified date, the stream is actually opened. When using // the file: protocol, the file can be locked on disk. if (urlConnection != null && "file".equalsIgnoreCase(urlConnection.getURL().getProtocol())) { if (urlConnectionInputStream != null) urlConnectionInputStream.close(); } } } catch (OutOfMemoryError e) { logger.info("Ran out of memory while processing image"); throw e; } catch (Exception e) { throw new OXFException(e); } } /** * This processor supports having no output. In this mode, it serializes the image data directly * to an ExternalContext.Response. */ public void start(PipelineContext pipelineContext) { final ExternalContext externalContext = (ExternalContext) pipelineContext .getAttribute(PipelineContext.EXTERNAL_CONTEXT); final ExternalContext.Response response = externalContext.getResponse(); processImage(pipelineContext, new ImageResponse() { public void setStatus(int status) { response.setStatus(status); } public void setResourceCaching(long lastModified, long expires) { response.setResourceCaching(lastModified, expires); } public OutputStream getOutputStream() throws IOException { return response.getOutputStream(); } public void setContentType(String contentType) { response.setContentType(contentType); } public boolean checkIfModifiedSince(long lastModified, boolean allowOverride) { return response.checkIfModifiedSince(lastModified); } }); } /** * This processor supports a "data" output. In this mode, it streams the resulting image data to * that output. */ @Override public ProcessorOutput createOutput(String name) { final ProcessorOutput output = new CacheableTransformerOutputImpl(ImageServer.this, name) { public void readImpl(final PipelineContext pipelineContext, final XMLReceiver xmlReceiver) { final ContentHandlerOutputStream contentHandlerOutputStream = new ContentHandlerOutputStream( xmlReceiver, true); // Start processing processImage(pipelineContext, new ImageResponse() { public boolean checkIfModifiedSince(long lastModified, boolean allowOverride) { // Always modified return true; } public OutputStream getOutputStream() { return contentHandlerOutputStream; } public void setResourceCaching(long lastModified, long expires) { // NOP } public void setContentType(String contentType) { contentHandlerOutputStream.setContentType(contentType); } public void setStatus(int status) { if (status == ExternalContext.SC_NOT_FOUND) { throw new OXFException("Image not not found."); } else if (status == ExternalContext.SC_INTERNAL_SERVER_ERROR) { throw new OXFException("Error while processing image."); } } }); try { // End document and close contentHandlerOutputStream.close(); } catch (Exception e) { throw new OXFException(e); } } protected boolean supportsLocalKeyValidity() { return true; } protected CacheKey getLocalKey(PipelineContext pipelineContext) { final URL newURL = getLocalURL(pipelineContext); return (newURL == null) ? null : new InternalCacheKey(ImageServer.this, "local-file-path", newURL.toExternalForm()); } protected Object getLocalValidity(PipelineContext pipelineContext) { try { final URL newURL = getLocalURL(pipelineContext); return NetUtils.getLastModifiedAsLong(newURL); } catch (IOException e) { throw new OXFException(e); } } private URL getLocalURL(PipelineContext pipelineContext) { // Find Config object if any final Config config; { KeyValidity keyValidity = getInputKeyValidity(pipelineContext, INPUT_CONFIG); if (keyValidity == null) return null; config = (Config) ObjectCache.instance().findValid(keyValidity.key, keyValidity.validity); if (config == null) return null; } // Find ImageConfig object if any final ImageConfig imageConfig; { KeyValidity keyValidity = getInputKeyValidity(pipelineContext, INPUT_IMAGE); if (keyValidity == null) return null; imageConfig = (ImageConfig) ObjectCache.instance().findValid(keyValidity.key, keyValidity.validity); if (imageConfig == null) return null; } return URLFactory.createURL(config.imageDirectoryURL, imageConfig.urlString); } }; addOutput(name, output); return output; } private static interface ImageResponse { public void setStatus(int status); public void setResourceCaching(long lastModified, long expires); public boolean checkIfModifiedSince(long lastModified, boolean allowOverride); public void setContentType(String contentType); public OutputStream getOutputStream() throws IOException; } private String computeCacheFileName(String type, String path, List<Element> nodes) { // Create digest document and digest Document document = new NonLazyUserDataDocument(); Element rootElement = document.addElement("image"); for (Element element : nodes) { rootElement.add(element.createCopy()); } String digest = NumberUtils.toHexString(Dom4jUtils.getDigest(document)); // Create file name if ("flat".equals(type)) return computePathNameFlat(path) + "-" + digest; else return computePathNameHierarchical(path) + "-" + digest; } private String computePathNameHierarchical(String path) { StringTokenizer st = new StringTokenizer(path, "/\\:"); StringBuilder sb = new StringBuilder(); while (st.hasMoreElements()) { if (sb.length() > 0) sb.append(File.separatorChar); try { sb.append(URLEncoder.encode(st.nextToken(), "utf-8").replace('+', ' ')); } catch (UnsupportedEncodingException e) { throw new OXFException(e); } } return sb.toString(); } private String computePathNameFlat(String path) { try { return URLEncoder.encode(path, "utf-8"); } catch (UnsupportedEncodingException e) { throw new OXFException(e); } } private synchronized BufferedImage filter(BufferedImage img, Iterator transformIterator) { // Copy the image to RGB if necessary (is there another way? Otherwise some images fail) BufferedImage srcImage = img; if (img.getType() != BufferedImage.TYPE_INT_RGB) { srcImage = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_RGB); Graphics2D graphics = srcImage.createGraphics(); graphics.drawImage(img, null, 0, 0); graphics.dispose(); } ImageProducer producer = srcImage.getSource(); int currentWidth = img.getWidth(null); int currentHeight = img.getHeight(null); // There may be one drawing operation List<Node> drawConfiguration = new ArrayList<Node>(); // Iterate through all transforms while (transformIterator.hasNext()) { Node node = (Node) transformIterator.next(); String transformType = XPathUtils.selectStringValueNormalize(node, "@type"); if ("scale".equals(transformType)) { // Scale image String qualityString = XPathUtils.selectStringValueNormalize(node, "quality"); boolean lowQuality = "low".equals(qualityString); boolean scaleUp = selectBooleanValue(node, "scale-up", DEFAULT_SCALE_UP); String widthString = XPathUtils.selectStringValueNormalize(node, "width"); int width; int height; if (widthString == null) { // There must be a maximum, use it to compute width and height String maxSizeString = XPathUtils.selectStringValueNormalize(node, "max-size"); String maxWidthString = XPathUtils.selectStringValueNormalize(node, "max-width"); String maxHeightString = XPathUtils.selectStringValueNormalize(node, "max-height"); if (maxSizeString != null) { int maxSize = Integer.parseInt(maxSizeString); double scale = (currentWidth > currentHeight) ? ((double) maxSize / (double) currentWidth) : ((double) maxSize / (double) currentHeight); width = (int) (scale * currentWidth); height = (int) (scale * currentHeight); } else if (maxWidthString != null) { int maxWidth = Integer.parseInt(maxWidthString); double scale = (double) maxWidth / (double) currentWidth; width = (int) (scale * currentWidth); height = (int) (scale * currentHeight); } else { int maxHeight = Integer.parseInt(maxHeightString); double scale = (double) maxHeight / (double) currentHeight; width = (int) (scale * currentWidth); height = (int) (scale * currentHeight); } } else { // Width and height are specified directly String heightString = XPathUtils.selectStringValueNormalize(node, "height"); width = Integer.parseInt(widthString); height = Integer.parseInt(heightString); } // Make sure we don't scale up if not allowed to if (!scaleUp && (width > currentWidth || height > currentHeight)) { width = currentWidth; height = currentHeight; } // Chain filter if needed if (currentWidth != width || currentHeight != height) { ImageFilter scaleFilter = lowQuality ? new ReplicateScaleFilter(width, height) : new AreaAveragingScaleFilter(width, height); producer = new FilteredImageSource(producer, scaleFilter); // Remember current width and height currentWidth = width; currentHeight = height; } } else if ("crop".equals(transformType)) { // Crop image int x = selectIntValue(node, "x", 0); int y = selectIntValue(node, "y", 0); int width = selectIntValue(node, "width", currentWidth - x); int height = selectIntValue(node, "height", currentHeight - y); // Calculate actual size Rectangle2D rect = new Rectangle(x, y, width, height); Rectangle2D imageRect = new Rectangle(0, 0, currentWidth, currentHeight); Rectangle2D intersection = rect.createIntersection(imageRect); // Make sure image is not empty if (intersection.getWidth() < 0 || intersection.getHeight() < 0) { logger.info("Resulting image is empty after crop!"); throw new OXFException("Resulting image is empty after crop!"); } // Chain filter if needed if (!imageRect.equals(intersection)) { ImageFilter cropFilter = new CropImageFilter((int) intersection.getX(), (int) intersection.getY(), (int) intersection.getWidth(), (int) intersection.getHeight()); producer = new FilteredImageSource(producer, cropFilter); // Remember current width and height currentWidth = (int) intersection.getWidth(); currentHeight = (int) intersection.getHeight(); } } else if ("draw".equals(transformType)) { // Don't do anything for now, this must be the last step drawConfiguration.add(node); } } Image filteredImg = Toolkit.getDefaultToolkit().createImage(producer); // Create resulting image BufferedImage newImage = new BufferedImage(currentWidth, currentHeight, srcImage.getType()); Graphics2D graphics = newImage.createGraphics(); graphics.drawImage(filteredImg, null, null); // Check for drawing operation for (Node drawConfigNode : drawConfiguration) { for (Iterator i = XPathUtils.selectIterator(drawConfigNode, "rect | fill | line"); i.hasNext();) { Node node = (Node) i.next(); String operation = XPathUtils.selectStringValueNormalize(node, "name()"); if ("rect".equals(operation)) { int x = XPathUtils.selectIntegerValue(node, "@x"); int y = XPathUtils.selectIntegerValue(node, "@y"); int width = XPathUtils.selectIntegerValue(node, "@width") - 1; int height = XPathUtils.selectIntegerValue(node, "@height") - 1; Node colorNode = XPathUtils.selectSingleNode(node, "color"); if (colorNode != null) { graphics.setColor(getColor(colorNode)); } graphics.drawRect(x, y, width, height); } else if ("fill".equals(operation)) { int x = XPathUtils.selectIntegerValue(node, "@x"); int y = XPathUtils.selectIntegerValue(node, "@y"); int width = XPathUtils.selectIntegerValue(node, "@width"); int height = XPathUtils.selectIntegerValue(node, "@height"); Node colorNode = XPathUtils.selectSingleNode(node, "color"); if (colorNode != null) { graphics.setColor(getColor(colorNode)); } graphics.fillRect(x, y, width, height); } else if ("line".equals(operation)) { int x1 = XPathUtils.selectIntegerValue(node, "@x1"); int y1 = XPathUtils.selectIntegerValue(node, "@y1"); int x2 = XPathUtils.selectIntegerValue(node, "@x2"); int y2 = XPathUtils.selectIntegerValue(node, "@y2"); Node colorNode = XPathUtils.selectSingleNode(node, "color"); if (colorNode != null) { graphics.setColor(getColor(colorNode)); } graphics.drawLine(x1, y1, x2, y2); } } } graphics.dispose(); return newImage; } private Color getColor(Node colorNode) { String rgb = XPathUtils.selectStringValueNormalize(colorNode, "@rgb"); String alpha = XPathUtils.selectStringValueNormalize(colorNode, "@alpha"); Color color = null; if (rgb != null) { try { color = new Color(Integer.parseInt(rgb.substring(1), 16)); } catch (NumberFormatException e) { throw new OXFException("Can't parse RGB color: " + rgb, e); } } if (color != null && alpha != null) { try { color = new Color(color.getRed(), color.getGreen(), color.getBlue(), Integer.parseInt(alpha, 16)); } catch (NumberFormatException e) { throw new OXFException("Can't parse alpha color: " + alpha, e); } } return color; } private boolean selectBooleanValue(Node node, String expr, boolean def) { String defaultString = def ? "false" : "true"; return !defaultString.equals(XPathUtils.selectStringValueNormalize(node, expr)); } private float selectFloatValue(Node node, String expr, float def) { String stringValue = XPathUtils.selectStringValueNormalize(node, expr); return (stringValue == null) ? def : Float.parseFloat(stringValue); } private int selectIntValue(Node node, String expr, int def) { Integer integerValue = XPathUtils.selectIntegerValue(node, expr); return (integerValue == null) ? def : integerValue; } }