001/*
002 *  Copyright 2012 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.core.util;
017
018import java.awt.image.BufferedImage;
019import java.io.ByteArrayInputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.io.OutputStream;
023import java.util.Collection;
024import java.util.Set;
025
026import org.apache.commons.io.IOUtils;
027
028import org.ametys.core.util.POIHolder.Point;
029
030import net.coobird.thumbnailator.ThumbnailParameter;
031import net.coobird.thumbnailator.Thumbnails;
032import net.coobird.thumbnailator.filters.ImageFilter;
033import net.coobird.thumbnailator.geometry.Positions;
034import net.coobird.thumbnailator.resizers.DefaultResizerFactory;
035import net.coobird.thumbnailator.tasks.io.InputStreamImageSource;
036import net.coobird.thumbnailator.tasks.io.OutputStreamImageSink;
037
038/**
039 * Helper for manipulating images.
040 */
041public final class ImageHelper
042{
043    /** The unresizable images formats */
044    public static final Collection<String> UNRESIZABLE_FORMATS = Set.of("svg", "svg+xml");
045    private static final float IMAGE_QUALITY = 0.9f;
046    
047    private ImageHelper()
048    {
049        // empty constructor
050    }
051    
052    private static boolean _isUnresizableFormat(String format)
053    {
054        return UNRESIZABLE_FORMATS.contains(format);
055    }
056    
057    private static boolean _needsChanges(String format, int height, int width, int maxHeight, int maxWidth, int cropHeight, int cropWidth)
058    {
059        return !_isUnresizableFormat(format) && (height > 0 || width > 0 || maxHeight > 0 || maxWidth > 0 || cropHeight > 0 || cropWidth > 0);
060    }
061    
062    /**
063     * Returns a BufferedImage as from the supplied input stream
064     * @param is The input stream
065     * @return The buffered image
066     * @throws IOException if an error occurs during reading.
067     */
068    public static BufferedImage read(InputStream is) throws IOException
069    {
070        InputStreamImageSource imageSource = new InputStreamImageSource(is);
071        ThumbnailParameter param = new ThumbnailParameter(1.0f, 1.0f, null, true, null, null, 1.0f, 0, null, DefaultResizerFactory.getInstance(), true, true);
072        imageSource.setThumbnailParameter(param);
073        
074        BufferedImage src = imageSource.read();
075        
076        // Perform the image filters
077        for (ImageFilter filter : param.getImageFilters())
078        {
079            src = filter.apply(src);
080        }
081        
082        return src;
083    }
084    
085    /**
086     * Generates a thumbnail from a source InputStream. Note that if final width and height are equals to source width and height, the stream is just copied.
087     * If the image should be both cropped and resized, the resizing will be done after the cropping.
088     * @param is the source.
089     * @param os the destination.
090     * @param format the image format. Must be one of "gif", "png", "svg" or "jpg". "svg" will not be resized.
091     * @param height the specified height. Ignored if negative.
092     * @param width the specified width. Ignored if negative.
093     * @param maxHeight the maximum image height. Ignored if height or width is specified.
094     * @param maxWidth the maximum image width. Ignored if height or width is specified.
095     * @param cropHeight the height of the cropped image. Ignore if negative.
096     * @param cropWidth the width of the cropped image. Ignore if negative.
097     * @throws IOException if an error occurs when manipulating streams.
098     */
099    public static void generateThumbnail(InputStream is, OutputStream os, String format, int height, int width, int maxHeight, int maxWidth, int cropHeight, int cropWidth) throws IOException
100    {
101        generateThumbnail(is, os, format, height, width, maxHeight, maxWidth, cropHeight, cropWidth, null);
102    }
103    
104    /**
105     * Generates a thumbnail from a source InputStream. Note that if final width and height are equals to source width and height, the stream is just copied.
106     * If the image should be both cropped and resized, the resizing will be done after the cropping.
107     * @param is the source.
108     * @param os the destination.
109     * @param format the image format. Must be one of "gif", "png", "svg" or "jpg". "svg" will not be resized.
110     * @param height the specified height. Ignored if negative.
111     * @param width the specified width. Ignored if negative.
112     * @param maxHeight the maximum image height. Ignored if height or width is specified.
113     * @param maxWidth the maximum image width. Ignored if height or width is specified.
114     * @param cropHeight the height of the cropped image. Ignore if negative.
115     * @param cropWidth the width of the cropped image. Ignore if negative.
116     * @param poi the Point of Interest to center the crop around. Ignore if crop sizes are negative. Can be null to use the center as POI.
117     * @throws IOException if an error occurs when manipulating streams.
118     */
119    public static void generateThumbnail(InputStream is, OutputStream os, String format, int height, int width, int maxHeight, int maxWidth, int cropHeight, int cropWidth, Point poi) throws IOException
120    {
121        if (!_needsChanges(format, height, width, maxHeight, maxWidth, cropHeight, cropWidth))
122        {
123            // no resizing nor cropping needed
124            IOUtils.copy(is, os);
125            return;
126        }
127        
128        byte[] data = IOUtils.toByteArray(is);
129        BufferedImage src = read(new ByteArrayInputStream(data));
130        BufferedImage dest = src;
131        
132        if (cropHeight > 0 || cropWidth > 0)
133        {
134            dest = _cropImage(dest, cropHeight, cropWidth, poi);
135        }
136        
137        if (height > 0 || width > 0 || maxHeight > 0 || maxWidth > 0)
138        {
139            dest = _resizeImage(dest, height, width, maxHeight, maxWidth);
140        }
141        
142        if (src == dest)
143        {
144            // source and destination have same dimensions, so preserve the initial image
145            IOUtils.copy(new ByteArrayInputStream(data), os);
146            return;
147        }
148        
149        OutputStreamImageSink imageSink = new OutputStreamImageSink(os);
150        imageSink.setOutputFormatName(format);
151        ThumbnailParameter param = new ThumbnailParameter(1.0f, 1.0f, null, true, null, null, IMAGE_QUALITY, 0, null, DefaultResizerFactory.getInstance(), true, true);
152        imageSink.setThumbnailParameter(param);
153        imageSink.write(dest);
154    }
155    
156    /**
157     * Generates a BufferedImage with specified size instructions, scaling if necessary.<br>
158     * If the image should be both cropped and resized, the resizing will be done after the cropping.
159     * @param src the source image.
160     * @param height the specified height. Ignored if negative.
161     * @param width the specified width. Ignored if negative.
162     * @param maxHeight the maximum image height. Ignored if height or width is specified.
163     * @param maxWidth the maximum image width. Ignored if height or width is specified.
164     * @param cropHeight the height of the cropped image. Ignore if negative.
165     * @param cropWidth the width of the cropped image. Ignore if negative.
166     * @return a scaled BufferedImage. If no size modification is required, this will return the src image.
167     * @throws IOException If the source image is not readable
168     */
169    public static BufferedImage generateThumbnail(BufferedImage src, int height, int width, int maxHeight, int maxWidth, int cropHeight, int cropWidth) throws IOException
170    {
171        return generateThumbnail(src, height, width, maxHeight, maxWidth, cropHeight, cropWidth, null);
172    }
173    /**
174     * Generates a BufferedImage with specified size instructions, scaling if necessary.<br>
175     * If the image should be both cropped and resized, the resizing will be done after the cropping.
176     * @param src the source image.
177     * @param height the specified height. Ignored if negative.
178     * @param width the specified width. Ignored if negative.
179     * @param maxHeight the maximum image height. Ignored if height or width is specified.
180     * @param maxWidth the maximum image width. Ignored if height or width is specified.
181     * @param cropHeight the height of the cropped image. Ignore if negative.
182     * @param cropWidth the width of the cropped image. Ignore if negative.
183     * @param poi the Point of Interest to center the crop around. Ignore if crop sizes are negative. Can be null to use the center as POI.
184     * @return a scaled BufferedImage. If no size modification is required, this will return the src image.
185     * @throws IOException If the source image is not readable
186     */
187    public static BufferedImage generateThumbnail(BufferedImage src, int height, int width, int maxHeight, int maxWidth, int cropHeight, int cropWidth, Point poi) throws IOException
188    {
189        BufferedImage dest = src;
190        
191        if (cropHeight > 0 || cropWidth > 0)
192        {
193            dest = _cropImage(src, cropHeight, cropWidth, poi);
194        }
195        
196        if (height > 0 || width > 0 || maxHeight > 0 || maxWidth > 0)
197        {
198            dest = _resizeImage(src, height, width, maxHeight, maxWidth);
199        }
200        
201        return dest;
202    }
203    
204    /**
205     * Generates a thumbnail from a source InputStream. Note that if final width and height are equals to source width and height, the stream is just copied.
206     * @param is the source.
207     * @param os the destination.
208     * @param format the image format. Must be one of "gif", "png", "svg" or "jpg". "svg" will not be resized.
209     * @param height the specified height. Ignored if negative.
210     * @param width the specified width. Ignored if negative.
211     * @param maxHeight the maximum image height. Ignored if height or width is specified.
212     * @param maxWidth the maximum image width. Ignored if height or width is specified.
213     * @throws IOException if an error occurs when manipulating streams.
214     */
215    public static void generateThumbnail(InputStream is, OutputStream os, String format, int height, int width, int maxHeight, int maxWidth) throws IOException
216    {
217        generateThumbnail(is, os, format, height, width, maxHeight, maxWidth, 0, 0);
218    }
219    
220    /**
221     * Generates a BufferedImage with specified size instructions, scaling if necessary.<br>
222     * @param src the source image.
223     * @param height the specified height. Ignored if negative.
224     * @param width the specified width. Ignored if negative.
225     * @param maxHeight the maximum image height. Ignored if height or width is specified.
226     * @param maxWidth the maximum image width. Ignored if height or width is specified.
227     * @return a scaled BufferedImage. If no size modification is required, this will return the src image.
228     * @throws IOException If the source image is not readable
229     */
230    public static BufferedImage generateThumbnail(BufferedImage src, int height, int width, int maxHeight, int maxWidth) throws IOException
231    {
232        return _resizeImage(src, height, width, maxHeight, maxWidth);
233    }
234    
235    /**
236     * Generates a thumbnail from a source InputStream. Note that if final width and height are equals to source width and height, the stream is just copied.
237     * @param is the source.
238     * @param os the destination.
239     * @param format the image format. Must be one of "gif", "png", "svg" or "jpg". "svg" will not be resized.
240     * @param ratio ratio to resize the image (1 = same size)
241     * @throws IOException if an error occurs when manipulating streams.
242     */
243    public static void generateThumbnail(InputStream is, OutputStream os, String format, double ratio) throws IOException
244    {
245        if (_isUnresizableFormat(format) || ratio == 1)
246        {
247            // no resizing needed
248            IOUtils.copy(is, os);
249            return;
250        }
251        
252        BufferedImage src = read(is);
253        BufferedImage dest = _resizeImage(src, ratio);
254        
255        OutputStreamImageSink imageSink = new OutputStreamImageSink(os);
256        imageSink.setOutputFormatName(format);
257        ThumbnailParameter param = new ThumbnailParameter(1.0f, 1.0f, null, true, null, null, IMAGE_QUALITY, 0, null, DefaultResizerFactory.getInstance(), true, true);
258        imageSink.setThumbnailParameter(param);
259        imageSink.write(dest);
260    }
261    
262    /**
263     * Generates a BufferedImage with specified size instructions, scaling if necessary.<br>
264     * @param src the source image.
265     * @param ratio ratio to resize the image (1 = same size)
266     * @return a scaled BufferedImage. If no size modification is required, this will return the src image.
267     * @throws IOException If the source image is not readable
268     */
269    public static BufferedImage generateThumbnail(BufferedImage src, double ratio) throws IOException
270    {
271        return _resizeImage(src, ratio);
272    }
273
274    /**
275     * Crop an image from a source InputStream. Note that if any of the coordinate and size are negatives, the image will just be copied.
276     * @param is the source.
277     * @param os the destination.
278     * @param format the image format. Must be one of "gif", "png", "svg" or "jpg". "svg" will not be resized.
279     * @param x The X coordinate of the upper-left corner of the specified rectangular region
280     * @param y the Y coordinate of the upper-left corner of the specified rectangular region
281     * @param height the width of the specified rectangular region
282     * @param width the height of the specified rectangular region
283     * @throws IOException If an error occurs
284     */
285    public static void generateCroppedImage(InputStream is, OutputStream os, String format, int x, int y, int height, int width) throws IOException
286    {
287        if (_isUnresizableFormat(format))
288        {
289            // no resizing needed
290            IOUtils.copy(is, os);
291            return;
292        }
293        
294        BufferedImage src = read(is);
295        BufferedImage dest = _cropImage(src, x, y, height, width);
296        
297        OutputStreamImageSink imageSink = new OutputStreamImageSink(os);
298        imageSink.setOutputFormatName(format);
299        ThumbnailParameter param = new ThumbnailParameter(1.0f, 1.0f, null, true, null, null, IMAGE_QUALITY, 0, null, DefaultResizerFactory.getInstance(), true, true);
300        imageSink.setThumbnailParameter(param);
301        imageSink.write(dest);
302    }
303    
304    /**
305     * Crop the image in the center, at the specified dimensions. The returned <code>BufferedImage</code> shares the same data array as the original image.
306     * @param src the source image.
307     * @param height the width of the specified rectangular region
308     * @param width the height of the specified rectangular region
309     * @return a scaled BufferedImage. If no size modification is required, this will return the src image.
310     * @throws IOException If the source image is not readable
311     */
312    public static BufferedImage generateCroppedImage(BufferedImage src, int height, int width) throws IOException
313    {
314        return _cropImage(src, height, width);
315    }
316    /**
317     * Crop the image by a specified rectangular region. The returned <code>BufferedImage</code> shares the same data array as the original image.
318     * @param src the source image.
319     * @param x The X coordinate of the upper-left corner of the specified rectangular region
320     * @param y the Y coordinate of the upper-left corner of the specified rectangular region
321     * @param height the width of the specified rectangular region
322     * @param width the height of the specified rectangular region
323     * @return a scaled BufferedImage. If no size modification is required, this will return the src image.
324     * @throws IOException If the source image is not readable
325     */
326    public static BufferedImage generateCroppedImage(BufferedImage src, int x, int y, int height, int width) throws IOException
327    {
328        return _cropImage(src, x, y, height, width);
329    }
330    
331    /**
332     * Crop the image to the size specified to the center of the image.
333     * @param src the source image.
334     * @param height the width of the specified rectangular region
335     * @param width the height of the specified rectangular region
336     * @return The cropped image as a BufferedImage
337     * @throws IOException If the source image is not readable
338     */
339    protected static BufferedImage _cropImage(BufferedImage src, int height, int width) throws IOException
340    {
341        return Thumbnails.of(src).size(width, height).crop(Positions.CENTER).asBufferedImage();
342    }
343    
344    /**
345     * Crop the image to the size specified, centered around the given Point of Interest.
346     * If POI is null, the center of the image is used
347     * @param src the source image.
348     * @param height the width of the specified rectangular region
349     * @param width the height of the specified rectangular region
350     * @param poi the Point of Interest to center the crop around, or {@code null} to use the center
351     * @return The cropped image as a BufferedImage
352     * @throws IOException If the source image is not readable
353     */
354    protected static BufferedImage _cropImage(BufferedImage src, int height, int width, Point poi) throws IOException
355    {
356        if (poi == null)
357        {
358            return Thumbnails.of(src).size(width, height).crop(Positions.CENTER).asBufferedImage();
359        }
360
361        // Scale src to COVER target dimensions (maintain aspect ratio)
362        double scaleX = (double) width / src.getWidth();
363        double scaleY = (double) height / src.getHeight();
364        double scale = Math.max(scaleX, scaleY);
365
366        int scaledWidth = (int) Math.round(src.getWidth() * scale);
367        int scaledHeight = (int) Math.round(src.getHeight() * scale);
368
369        BufferedImage scaled = Thumbnails.of(src)
370                .size(scaledWidth, scaledHeight)
371                .keepAspectRatio(false)
372                .asBufferedImage();
373
374        // Project POI into the scaled image
375        int scaledPoiX = (int) Math.round((double) poi.x() / src.getWidth() * scaledWidth);
376        int scaledPoiY = (int) Math.round((double) poi.y() / src.getHeight() * scaledHeight);
377
378        // Top-left corner centered on POI, clamped to bounds
379        int x = Math.max(0, Math.min(scaledPoiX - width / 2, scaledWidth - width));
380        int y = Math.max(0, Math.min(scaledPoiY - height / 2, scaledHeight - height));
381
382        return scaled.getSubimage(x, y, width, height);
383    }
384    
385    /**
386     * Crop the image by a specified rectangular region. The returned <code>BufferedImage</code> shares the same data array as the original image.
387     * @param src the source image.
388     * @param x The X coordinate of the upper-left corner of the specified rectangular region.
389     * @param y the Y coordinate of the upper-left corner of the specified rectangular region.
390     * @param height the width of the specified rectangular region
391     * @param width the height of the specified rectangular region
392     * @return a scaled BufferedImage. If no size modification is required, this will return the src image.
393     * @throws IOException If the source image is not readable
394     */
395    protected static BufferedImage _cropImage(BufferedImage src, int x, int y, int height, int width) throws IOException
396    {
397        return Thumbnails.of(src).scale(1).sourceRegion(x, y, width, height).asBufferedImage();
398    }
399    
400    /**
401     * Resize the buffered image
402     * @param src the source image
403     * @param height the specified height. Ignored if negative.
404     * @param width the specified width. Ignored if negative.
405     * @param maxHeight the maximum image height. Ignored if height or width is specified.
406     * @param maxWidth the maximum image width. Ignored if height or width is specified.
407     * @return a scaled BufferedImage. If no size modification is required, this will return the src image.
408     * @throws IOException If the source image is not readable
409     */
410    protected static BufferedImage _resizeImage(BufferedImage src, int height, int width, int maxHeight, int maxWidth) throws IOException
411    {
412        int srcHeight = src.getHeight();
413        int srcWidth = src.getWidth();
414        
415        int destHeight = 0;
416        int destWidth = 0;
417        
418        boolean keepAspectRatio = true;
419        
420        if (height > 0)
421        {
422            // heigth is specified
423            destHeight = height;
424            
425            if (width > 0)
426            {
427                // additionnally, width is also specified
428                destWidth = width;
429                keepAspectRatio = false;
430            }
431            else
432            {
433                // width is computed
434                destWidth = srcWidth * destHeight / srcHeight;
435            }
436        }
437        else if (width > 0)
438        {
439            // width is specified, height is computed
440            destWidth = width;
441            destHeight = srcHeight * destWidth / srcWidth;
442        }
443        else if (maxHeight > 0)
444        {
445            if (maxWidth > 0)
446            {
447                if (srcHeight <= maxHeight && srcWidth <= maxWidth)
448                {
449                    // the source image is already smaller than the destination box
450                    return src;
451                }
452                
453                destWidth = maxWidth;
454                destHeight = maxHeight;
455            }
456            else
457            {
458                if (srcHeight <= maxHeight)
459                {
460                    // the source image is already smaller than the destination box
461                    return src;
462                }
463                
464                destHeight = maxHeight;
465                destWidth = srcWidth * destHeight / srcHeight;
466            }
467        }
468        else if (maxWidth > 0)
469        {
470            if (srcWidth <= maxWidth)
471            {
472                // the source image is already smaller than the destination box
473                return src;
474            }
475            
476            destWidth = maxWidth;
477            destHeight = srcHeight * destWidth / srcWidth;
478        }
479        else
480        {
481            // No resize is required
482            return src;
483        }
484        
485        if (destHeight == srcHeight && destWidth == srcWidth)
486        {
487            // already the good format, don't change anything
488            return src;
489        }
490        
491        return Thumbnails.of(src).size(destWidth, destHeight).keepAspectRatio(keepAspectRatio).imageType(src.getColorModel().hasAlpha() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB).asBufferedImage();
492    }
493    
494    /**
495     * Resize the buffered image
496     * @param src the source image
497     * @param ratio ratio to resize the image (1 = same size)
498     * @return a scaled BufferedImage. If no size modification is required, this will return the src image.
499     * @throws IOException If the source image is not readable
500     */
501    protected static BufferedImage _resizeImage(BufferedImage src, double ratio) throws IOException
502    {
503        if (ratio == 1)
504        {
505            return src;
506        }
507        return Thumbnails.of(src).scale(ratio).imageType(src.getColorModel().hasAlpha() ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB).asBufferedImage();
508    }
509}