View Javadoc

1   package com.atlassian.core.util.thumbnail;
2   
3   import com.atlassian.core.exception.FailedPredicateException;
4   import com.atlassian.core.util.ImageInfo;
5   import com.atlassian.core.util.ReusableBufferedInputStream;
6   import com.atlassian.core.util.thumbnail.loader.ImageFactory;
7   import com.google.common.base.Predicate;
8   import com.google.common.base.Predicates;
9   import org.apache.commons.io.FileUtils;
10  import org.apache.commons.io.IOUtils;
11  import org.apache.log4j.Logger;
12  import org.j3d.util.ImageGenerator;
13  
14  import javax.annotation.Nonnull;
15  import javax.imageio.IIOImage;
16  import javax.imageio.ImageIO;
17  import javax.imageio.ImageWriteParam;
18  import javax.imageio.ImageWriter;
19  import javax.imageio.stream.FileImageOutputStream;
20  import javax.swing.ImageIcon;
21  import java.awt.Graphics;
22  import java.awt.Image;
23  import java.awt.Toolkit;
24  import java.awt.image.AreaAveragingScaleFilter;
25  import java.awt.image.BufferedImage;
26  import java.awt.image.FilteredImageSource;
27  import java.awt.image.ImageProducer;
28  import java.awt.image.PixelGrabber;
29  import java.io.File;
30  import java.io.FileInputStream;
31  import java.io.FileNotFoundException;
32  import java.io.IOException;
33  import java.io.InputStream;
34  import java.net.MalformedURLException;
35  import java.util.Arrays;
36  import java.util.Collections;
37  import java.util.List;
38  
39  /**
40   * A class to create and retrieve thumbnail of images.
41   */
42  public class Thumber
43  {
44      private static final Logger log = Logger.getLogger(Thumber.class);
45      private final ImageInfo imageInfo = new ImageInfo();
46  
47      // According to RFC 2045, mime types are not case sensitive. You can't just use contains. You MUST use equalsIgnoreCase.
48      public static final List<String> THUMBNAIL_MIME_TYPES = Collections.unmodifiableList(Arrays.asList(ImageIO.getReaderMIMETypes()));
49      public static final List<String> THUMBNAIL_FORMATS = Collections.unmodifiableList(Arrays.asList(ImageIO.getReaderFormatNames()));
50      private Thumbnail.MimeType mimeType;
51  
52      /**
53       * Legacy compatible behaviour, all thumnails generated will be of type
54       * {@link com.atlassian.core.util.thumbnail.Thumbnail.MimeType#JPG} which does not support transparency.
55       */
56      public Thumber()
57      {
58          this(Thumbnail.MimeType.JPG);
59      }
60  
61      /**
62       * Thumbnails will be generated of the given type and, if the type permits it (PNG), preserve transparency.
63       *
64       * @param mimeType the type of all thumbnails generated and retrieved.
65       */
66      public Thumber(Thumbnail.MimeType mimeType)
67      {
68          if (mimeType == null)
69          {
70              throw new IllegalArgumentException("mimeType cannot be null");
71          }
72          this.mimeType = mimeType;
73      }
74  
75      // According to RFC 2045, mime types are not case sensitive. You can't just use contains. You MUST use equalsIgnoreCase.
76      public static List<String> getThumbnailMimeTypes()
77      {
78          return THUMBNAIL_MIME_TYPES;
79      }
80  
81      public static List<String> getThumbnailFormats()
82      {
83          return THUMBNAIL_FORMATS;
84      }
85  
86      private float encodingQuality = 0.80f; // default to 0.80f, seems reasonable enough, and still provides good result.
87  
88      /**
89       * @return True if the AWT default toolkit exists, false (with an error logged) if it does not.
90       */
91      public boolean checkToolkit()
92      {
93          try
94          {
95              Toolkit.getDefaultToolkit();
96          }
97          catch (Throwable e)
98          {
99              log.error("Unable to acquire AWT default toolkit - thumbnails will not be displayed. Check DISPLAY variable or use setting -Djava.awt.headless=true.", e);
100             return false;
101         }
102         return true;
103     }
104 
105     /**
106      * Retrieves an existing thumbnail, or creates a new one.
107      *
108      * @param originalFile  The file which is being thumbnailed.
109      * @param thumbnailFile The location of the existing thumbnail (if it exists), or the location to create a new
110      *                      thumbnail.
111      * @param maxWidth      The max width of the thumbnail.
112      * @param maxHeight     The max height of the thumbnail.
113      */
114     public Thumbnail retrieveOrCreateThumbNail(File originalFile, File thumbnailFile, int maxWidth, int maxHeight, long thumbnailId)
115             throws MalformedURLException
116     {
117         return retrieveOrCreateThumbNail(originalFile, thumbnailFile, maxWidth, maxHeight, thumbnailId, new AdaptiveThresholdPredicate());
118     }
119 
120     /**
121      * Retrieves an existing thumbnail, or creates a new one.
122      * Creating of Thumbnail image will be processed in memory if specified rasterBasedRenderingPredicate is match
123      *
124      * @param originalFile  The file which is being thumbnailed.
125      * @param thumbnailFile The location of the existing thumbnail (if it exists), or the location to create a new
126      *                      thumbnail.
127      * @param maxWidth      The max width of the thumbnail.
128      * @param maxHeight     The max height of the thumbnail.
129      */
130     public Thumbnail retrieveOrCreateThumbNail(final File originalFile, final File thumbnailFile, final int maxWidth,
131                                                final int maxHeight, final long thumbnailId, Predicate<Dimensions> rasterBasedRenderingPredicate) throws MalformedURLException {
132         FileInputStream originalFileStream = null;
133         try
134         {
135             originalFileStream = new FileInputStream(originalFile);
136             return retrieveOrCreateThumbNail(originalFileStream, originalFile.getName(), thumbnailFile, maxWidth, maxHeight, thumbnailId, rasterBasedRenderingPredicate);
137         }
138         catch (FileNotFoundException e)
139         {
140             log.error("Unable to create thumbnail: file not found: " + originalFile.getAbsolutePath());
141         }
142         finally
143         {
144             IOUtils.closeQuietly(originalFileStream);
145         }
146 
147         return null;
148     }
149 
150     /**
151      * Store Image in format defined by constructor
152      * @param scaledImage
153      * @param file
154      * @throws FileNotFoundException
155      */
156     public void storeImage(BufferedImage scaledImage, File file) throws FileNotFoundException
157     {
158         if (scaledImage == null)
159         {
160             log.warn("Can't store a null scaledImage.");
161             return;
162         }
163         checkOutputFileCreation(file);
164         if(mimeType == Thumbnail.MimeType.JPG)
165         {
166             storeJPEG(scaledImage, file);
167         }
168         else
169         {
170             storeImageAsPng(scaledImage, file);
171         }
172     }
173 
174     private void checkOutputFileCreation(final File outputFile)
175     {
176         try
177         {
178             FileUtils.touch(outputFile);
179             FileUtils.deleteQuietly(outputFile);
180         }
181         catch (IOException ioe)
182         {
183             throw new ThumbnailRenderException(ioe);
184         }
185     }
186 
187     private void storeJPEG(final BufferedImage scaledImage, final File file) {
188         FileImageOutputStream fout = null;
189         ImageWriter writer = null;
190         try
191         {
192             fout = new FileImageOutputStream((file));
193 
194             writer = ImageIO.getImageWritersByFormatName("jpeg").next();
195 
196             ImageWriteParam param = writer.getDefaultWriteParam();
197             param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
198             param.setCompressionQuality(encodingQuality);
199             writer.setOutput(fout);
200             writer.write(null, new IIOImage(scaledImage, null, null), param);
201         }
202         catch (IOException e)
203         {
204             log.error("Error encoding the thumbnail image to JPEG", e);
205         }
206         finally
207         {
208             if (writer != null)
209             {
210                 writer.dispose();
211             }
212             try
213             {
214                  if(fout != null)
215                  {
216                      fout.close();
217                  }
218             }
219             catch (IOException ignore)
220             {
221 
222             }
223         }
224     }
225 
226     private void storeImageAsPng(BufferedImage image, File file) throws FileNotFoundException
227     {
228         try
229         {
230             ImageIO.write(image, "png", file);
231         }
232         catch (IOException e)
233         {
234             log.error("Error encoding the thumbnail image to PNG", e);
235         }
236     }
237 
238     /**
239      * This method should take BufferedImage argument, but takes just Image for backward compatibility (so that the
240      * client code can stay intact). Normally anyway a BufferedImage instance will be provided and the image will be
241      * directly processed without transforming it to BufferedImage first.
242      *
243      * @param imageToScale  image to scale (BufferedImage is welcome, other image types will be transformed to
244      *                      BufferedImage first)
245      * @param newDimensions desired max. dimensions
246      * @return scaled image
247      */
248     public BufferedImage scaleImage(Image imageToScale, WidthHeightHelper newDimensions)
249     {
250         return MemoryImageScale.scaleImage(Pictures.toBufferedImage(imageToScale), newDimensions);
251     }
252 
253 
254     /**
255      * This method provides extremely slow way to scale your image. There are generally much better alternatives (order
256      * of magnitude faster with similar quality).
257      * Consider using {@link Thumber#scaleImage(java.awt.Image, com.atlassian.core.util.thumbnail.Thumber.WidthHeightHelper)} instead
258      *
259      * @param imageToScale  input image
260      * @param newDimensions desired max. dimensions (the ratio will be kept)
261      * @return scaled image
262      */
263     @Deprecated
264     public BufferedImage scaleImageOld(Image imageToScale, WidthHeightHelper newDimensions)
265     {
266 
267 
268         // If the original image is an instance of BufferedImage, we need to make sure
269         // that it is an sRGB image. If it is not, we need to convert it before scaling it
270         // as we run into these issue otherwise:
271         // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4886071
272         // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4705399
273 
274         if (imageToScale instanceof BufferedImage)
275         {
276             BufferedImage bufferedImage = (BufferedImage) imageToScale;
277             if (!bufferedImage.getColorModel().getColorSpace().isCS_sRGB())
278             {
279                 BufferedImage sRGBImage = new BufferedImage(bufferedImage.getWidth(), bufferedImage.getHeight(), BufferedImage.TYPE_INT_RGB);
280                 Graphics g = sRGBImage.getGraphics();
281                 g.drawImage(bufferedImage, 0, 0, null);
282                 g.dispose();
283                 imageToScale = sRGBImage;
284             }
285         }
286 
287         AreaAveragingScaleFilter scaleFilter =
288                 new AreaAveragingScaleFilter(newDimensions.getWidth(), newDimensions.getHeight());
289         ImageProducer producer = new FilteredImageSource(imageToScale.getSource(),
290                 scaleFilter);
291 
292         ImageGenerator generator = new ImageGenerator();
293         producer.startProduction(generator);
294         BufferedImage scaled = generator.getImage();
295 
296         // CORE-101 getImage may return null
297         if (scaled == null)
298         {
299             log.warn("Unabled to create scaled image.");
300         }
301         else
302         {
303             scaled.flush();
304         }
305 
306         return scaled;
307     }
308 
309     /**
310      * Retrieves an existing thumbnail, or creates a new one.
311      *
312      * @param originalFileStream  The image stream
313      * @param fileName            Filename for specified image
314      * @param thumbnailFile       The location of the existing thumbnail (if it exists), or the location to create a new
315      *                            thumbnail.
316      * @param maxWidth            The max width of the thumbnail.
317      * @param maxHeight           The max height of the thumbnail.
318      */
319     public Thumbnail retrieveOrCreateThumbNail(InputStream originalFileStream, String fileName, File thumbnailFile, int maxWidth, int maxHeight, long thumbnailId)
320             throws MalformedURLException
321     {
322         return retrieveOrCreateThumbNail(originalFileStream, fileName, thumbnailFile, maxWidth, maxHeight, thumbnailId, new AdaptiveThresholdPredicate());
323     }
324 
325     /**
326      * Retrieves an existing thumbnail, or creates a new one.
327      * Creating of Thumbnail image will be processed in memory if specified rasterBasedRenderingPredicate is match
328      *
329      * @param originalFileStream  The image stream
330      * @param fileName            Filename for specified image
331      * @param thumbnailFile       The location of the existing thumbnail (if it exists), or the location to create a new
332      *                            thumbnail.
333      * @param maxWidth            The max width of the thumbnail.
334      * @param maxHeight           The max height of the thumbnail.
335      */
336 
337     public Thumbnail retrieveOrCreateThumbNail(final InputStream originalFileStream, final String fileName, final File thumbnailFile,
338                                                 final int maxWidth, final int maxHeight, final long thumbnailId,
339                                                 Predicate<Dimensions> rasterBasedRenderingThreshold) {
340         Thumbnail thumbnail = null;
341         try
342         {
343             thumbnail = getThumbnail(thumbnailFile, fileName, thumbnailId);
344         }
345         catch (IOException e)
346         {
347             log.error("Unable to get thumbnail image for id " + thumbnailId, e);
348             return null;
349         }
350 
351         if (thumbnail == null)
352         {
353             try
354             {
355                 thumbnail = createThumbnail(originalFileStream, thumbnailFile, maxWidth, maxHeight, thumbnailId, fileName, rasterBasedRenderingThreshold);
356             }
357             catch (ThumbnailRenderException e)
358             {
359                 log.error("Unable to create thumbnail image for id " + thumbnailId, e);
360                 return null;
361             }
362             catch (IOException e)
363             {
364                 log.error("Unable to create thumbnail image for id " + thumbnailId, e);
365                 return null;
366             }
367         }
368 
369         return thumbnail;
370     }
371 
372     // PRIVATE METHODS -------------------------------------------------------------------------------------------
373     private WidthHeightHelper determineScaleSize(int maxWidth, int maxHeight, Image image)
374     {
375         return determineScaleSize(maxWidth, maxHeight, image.getWidth(null), image.getHeight(null));
376     }
377 
378     private Thumbnail createThumbnail(InputStream inputStream, File thumbnailFile, int maxWidth, int maxHeight, long thumbId, String fileName, Predicate<Dimensions> rasterBasedRenderingThreshold)
379             throws FileNotFoundException
380     {
381         final ThumbnailRenderer thumbnailRenderer = createThumbnailRenderer(rasterBasedRenderingThreshold);
382         final BufferedImage thumbnailImage = thumbnailRenderer.createThumbnailImage(inputStream, maxWidth, maxHeight);
383 
384         final int height = thumbnailImage.getHeight();
385         final int width = thumbnailImage.getWidth();
386 
387         storeImage(thumbnailImage, thumbnailFile);
388 
389         return new Thumbnail(height, width, fileName, thumbId, mimeType);
390     }
391 
392     private ThumbnailRenderer createThumbnailRenderer(Predicate<Dimensions> rasterBasedRenderingThreshold) {
393         final DimensionsHelper dimensionsHelper = new DimensionsHelper();
394         final ImageFactory imageFactory = new ImageFactory();
395         final MemoryImageScale rasterScaler = new MemoryImageScale(dimensionsHelper, imageFactory);
396         final StreamImageScale streamScaler = new StreamImageScale(dimensionsHelper);
397         return new ThumbnailRenderer(rasterScaler, streamScaler, dimensionsHelper, rasterBasedRenderingThreshold);
398     }
399 
400 
401     private Thumbnail getThumbnail(File thumbnailFile, String filename, long thumbId) throws IOException
402     {
403         if (thumbnailFile.exists())
404         {
405             final Image thumbImage = getImage(thumbnailFile);
406             return new Thumbnail(thumbImage.getHeight(null), thumbImage.getWidth(null), filename, thumbId, mimeType);
407         }
408         return null;
409     }
410 
411     /**
412      * @deprecated since 4.6.3.
413      *
414      * Use {@link Thumber#getImage(java.io.File, com.google.common.base.Predicate)} instead so that you can supply a predicate
415      * to (for example) check that the image has reasonable dimensions that are unlikely to cause an {@link OutOfMemoryError}
416      *
417      * @return An Image object or null if there was no suitable ImageReader for the given data
418      */
419     public Image getImage(File file) throws IOException
420     {
421         return getImage(file, Predicates.<ReusableBufferedInputStream>alwaysTrue());
422     }
423 
424     /**
425      * Retrieves an image for the specified file if the supplied predicate is met.
426      *
427      * @param file  File to read an image from
428      * @param predicate Predicate that needs to be met before attempting to read an image from the file (e.g. image
429      *                  dimensions that shouldn't be exceeded)
430      * @return An Image object or null if there was no suitable ImageReader for the given data
431      * @throws FailedPredicateException If predicate was supplied but not met
432      */
433     public BufferedImage getImage(File file, @Nonnull Predicate<ReusableBufferedInputStream> predicate) throws IOException
434     {
435         ReusableBufferedInputStream reusableInputStream = new ReusableBufferedInputStream(new FileInputStream(file));
436 
437         try
438         {
439             return getImage(reusableInputStream, predicate);
440         }
441         finally
442         {
443             reusableInputStream.destroy();
444         }
445     }
446 
447     /**
448      * @deprecated since 4.6.3.
449      *
450      * Use {@link Thumber#getImage(com.atlassian.core.util.ReusableBufferedInputStream, com.google.common.base.Predicate)}
451      * instead so that you can supply a predicate to (for example) check that the image has reasonable dimensions that are
452      * unlikely to cause an {@link OutOfMemoryError}
453      *
454      * @return An Image object or null if there was no suitable ImageReader for the given data
455      */
456     public BufferedImage getImage(InputStream is) throws IOException
457     {
458         ReusableBufferedInputStream reusableInputStream = new ReusableBufferedInputStream(is);
459 
460         try
461         {
462             return getImage(reusableInputStream, Predicates.<ReusableBufferedInputStream>alwaysTrue());
463         }
464         finally
465         {
466             reusableInputStream.destroy();
467         }
468     }
469 
470     /**
471      * Reads an image from the specified input stream if the supplied predicate is met.
472      * @param is Input stream to read an image from
473      * @param predicate Predicate that needs to be met before attempting to read an image from the file (e.g. image
474      *                  dimensions that shouldn't be exceeded)
475      * @return An Image object or null if there was no suitable ImageReader for the given data
476      * @throws FailedPredicateException If predicate was supplied but not met
477      */
478     public BufferedImage getImage(ReusableBufferedInputStream is, @Nonnull Predicate<ReusableBufferedInputStream> predicate) throws IOException
479     {
480         if (!predicate.apply(is))
481         {
482             throw new FailedPredicateException();
483         }
484 
485         return new ImageFactory().loadImage(is);
486     }
487 
488     /**
489      * Set the default encoding quality used by the thumber to encode jpegs.
490      */
491     public void setEncodingQuality(float f)
492     {
493         if (f > 1.0f || f < 0.0f)
494         {
495             throw new IllegalArgumentException("Invalid quality setting '" + f + "', value must be between 0 and 1. ");
496         }
497         encodingQuality = f;
498     }
499 
500     public WidthHeightHelper determineScaleSize(int maxWidth, int maxHeight, int imageWidth, int imageHeight)
501     {
502         final Dimensions dimensions = new DimensionsHelper().determineScaledDimensions(maxWidth, maxHeight, imageWidth, imageHeight);
503         return new WidthHeightHelper(dimensions.getWidth(), dimensions.getHeight());
504     }
505 
506     public boolean isFileSupportedImage(File file)
507     {
508         try
509         {
510             return isFileSupportedImage(new FileInputStream(file));
511         }
512         catch (FileNotFoundException e)
513         {
514             return false;
515         }
516     }
517 
518     public boolean isFileSupportedImage(InputStream inputStream)
519     {
520         try
521         {
522             imageInfo.setInput(inputStream);
523             imageInfo.check();
524             for (String format : THUMBNAIL_FORMATS)
525             {
526                 if (format.equalsIgnoreCase(imageInfo.getFormatName()))
527                 {
528                     return true;
529                 }
530             }
531             return false;
532         }
533         finally
534         {
535             try
536             {
537                 if (inputStream != null)
538                 {
539                     inputStream.close();
540                 }
541             }
542             catch (Exception e)
543             {
544                 log.error(e, e);
545             }
546         }
547     }
548 
549     public static class WidthHeightHelper
550     {
551         private int width;
552         private int height;
553 
554         public WidthHeightHelper(int width, int height)
555         {
556             this.width = width;
557             this.height = height;
558         }
559 
560         public int getWidth()
561         {
562             return width;
563         }
564 
565         public void setWidth(int width)
566         {
567             this.width = width;
568         }
569 
570         public int getHeight()
571         {
572             return height;
573         }
574 
575         public void setHeight(int height)
576         {
577             this.height = height;
578         }
579     }
580 
581     /**
582      * Code based on http://www.dreamincode.net/code/snippet1076.htm Looks like public domain
583      * The change: I don't use screen-compatible image creation - I assume JIRA runs in headless mode anyway
584      */
585     static class Pictures
586     {
587         public static BufferedImage toBufferedImage(Image image)
588         {
589             if (image instanceof BufferedImage)
590             {
591                 return (BufferedImage) image;
592             }
593 
594             // This code ensures that all the pixels in the image are loaded
595             image = new ImageIcon(image).getImage();
596 
597             // Determine if the image has transparent pixels
598             boolean hasAlpha = hasAlpha(image);
599 
600             // Create a buffered image using the default color model
601             int type = hasAlpha ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
602             BufferedImage bimage = new BufferedImage(image.getWidth(null), image.getHeight(null), type);
603 
604             // Copy image to buffered image
605             Graphics g = bimage.createGraphics();
606 
607             // Paint the image onto the buffered image
608             g.drawImage(image, 0, 0, null);
609             g.dispose();
610 
611             return bimage;
612         }
613 
614         public static boolean hasAlpha(Image image)
615         {
616             // If buffered image, the color model is readily available
617             if (image instanceof BufferedImage)
618             {
619                 return ((BufferedImage) image).getColorModel().hasAlpha();
620             }
621 
622             // Use a pixel grabber to retrieve the image's color model;
623             // grabbing a single pixel is usually sufficient
624             PixelGrabber pg = new PixelGrabber(image, 0, 0, 1, 1, false);
625             try
626             {
627                 pg.grabPixels();
628             }
629             catch (InterruptedException ignored)
630             {
631             }
632 
633             // Get the image's color model
634             return pg.getColorModel().hasAlpha();
635         }
636     }
637 
638 }