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