View Javadoc
1   /*
2    * SmartSprites Project
3    *
4    * Copyright (C) 2007-2009, Stanisław Osiński.
5    * All rights reserved.
6    *
7    * Redistribution and use in source and binary forms, with or without modification,
8    * are permitted provided that the following conditions are met:
9    *
10   * - Redistributions of  source code must  retain the above  copyright notice, this
11   *   list of conditions and the following disclaimer.
12   *
13   * - Redistributions in binary form must reproduce the above copyright notice, this
14   *   list of conditions and the following  disclaimer in  the documentation  and/or
15   *   other materials provided with the distribution.
16   *
17   * - Neither the name of the SmartSprites Project nor the names of its contributors
18   *   may  be used  to endorse  or  promote  products derived   from  this  software
19   *   without specific prior written permission.
20   *
21   * - We kindly request that you include in the end-user documentation provided with
22   *   the redistribution and/or in the software itself an acknowledgement equivalent
23   *   to  the  following: "This product includes software developed by the SmartSprites
24   *   Project."
25   *
26   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"  AND
27   * ANY EXPRESS OR  IMPLIED WARRANTIES, INCLUDING,  BUT NOT LIMITED  TO, THE IMPLIED
28   * WARRANTIES  OF  MERCHANTABILITY  AND  FITNESS  FOR  A  PARTICULAR  PURPOSE   ARE
29   * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE  FOR
30   * ANY DIRECT, INDIRECT, INCIDENTAL,  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL  DAMAGES
31   * (INCLUDING, BUT  NOT LIMITED  TO, PROCUREMENT  OF SUBSTITUTE  GOODS OR SERVICES;
32   * LOSS OF USE, DATA, OR PROFITS;  OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND  ON
33   * ANY  THEORY  OF  LIABILITY,  WHETHER  IN  CONTRACT,  STRICT  LIABILITY,  OR TORT
34   * (INCLUDING NEGLIGENCE OR OTHERWISE)  ARISING IN ANY WAY  OUT OF THE USE  OF THIS
35   * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36   */
37  package org.carrot2.labs.smartsprites;
38  
39  import com.google.common.collect.LinkedListMultimap;
40  import com.google.common.collect.Multimap;
41  
42  import java.awt.image.BufferedImage;
43  import java.io.ByteArrayOutputStream;
44  import java.io.IOException;
45  import java.io.InputStream;
46  import java.io.OutputStream;
47  import java.time.Instant;
48  import java.util.Collection;
49  import java.util.Collections;
50  import java.util.HashMap;
51  import java.util.LinkedHashMap;
52  import java.util.Map;
53  
54  import javax.imageio.ImageIO;
55  
56  import org.apache.commons.math3.util.ArithmeticUtils;
57  import org.carrot2.labs.smartsprites.SpriteImageDirective.SpriteImageFormat;
58  import org.carrot2.labs.smartsprites.SpriteImageDirective.SpriteImageLayout;
59  import org.carrot2.labs.smartsprites.SpriteLayoutProperties.SpriteAlignment;
60  import org.carrot2.labs.smartsprites.message.Message.MessageType;
61  import org.carrot2.labs.smartsprites.message.MessageLog;
62  import org.carrot2.labs.smartsprites.resource.ResourceHandler;
63  import org.carrot2.util.BufferedImageUtils;
64  import org.carrot2.util.FileUtils;
65  
66  /**
67   * Lays out and builds sprite images based on the collected SmartSprites directives.
68   */
69  public class SpriteImageBuilder {
70  
71      /** This builder's configuration. */
72      public final SmartSpritesParameters parameters;
73  
74      /** This builder's message log. */
75      private final MessageLog messageLog;
76  
77      /** Image merger for this builder. */
78      private SpriteImageRenderer spriteImageRenderer;
79  
80      /** The resource handler. */
81      private ResourceHandler resourceHandler;
82  
83      /**
84       * A timestamp to use for timestamp-based sprite image UIDs. We need this time stamp as a field to make sure the
85       * timestamp is the same for all sprite image replacements.
86       */
87      private Instant timestamp;
88  
89      /**
90       * Creates a {@link SpriteImageBuilder} with the provided parameters and log.
91       *
92       * @param parameters
93       *            the parameters
94       * @param messageLog
95       *            the message log
96       * @param resourceHandler
97       *            the resource handler
98       */
99      SpriteImageBuilder(SmartSpritesParameters parameters, MessageLog messageLog, ResourceHandler resourceHandler) {
100         this.messageLog = messageLog;
101         this.parameters = parameters;
102         this.resourceHandler = resourceHandler;
103         spriteImageRenderer = new SpriteImageRenderer(parameters, messageLog);
104     }
105 
106     /**
107      * Builds all sprite images based on the collected directives.
108      *
109      * @param spriteImageOccurrencesBySpriteId
110      *            the sprite image occurrences by sprite id
111      * @param spriteReferenceOccurrencesBySpriteId
112      *            the sprite reference occurrences by sprite id
113      *
114      * @return the multimap
115      *
116      * @throws IOException
117      *             Signals that an I/O exception has occurred.
118      */
119     Multimap<String, SpriteReferenceReplacement> buildSpriteImages(
120             Map<String, SpriteImageOccurrence> spriteImageOccurrencesBySpriteId,
121             Multimap<String, SpriteReferenceOccurrence> spriteReferenceOccurrencesBySpriteId) throws IOException {
122         timestamp = Instant.now();
123 
124         final Multimap<String, SpriteReferenceReplacement> spriteReplacementsByFile = LinkedListMultimap.create();
125         for (final Map.Entry<String, Collection<SpriteReferenceOccurrence>> spriteReferenceOccurrences : spriteReferenceOccurrencesBySpriteId
126                 .asMap().entrySet()) {
127             final Map<SpriteReferenceOccurrence, SpriteReferenceReplacement> spriteReferenceReplacements = buildSpriteReplacements(
128                     spriteImageOccurrencesBySpriteId.get(spriteReferenceOccurrences.getKey()),
129                     spriteReferenceOccurrences.getValue());
130 
131             for (final SpriteReferenceReplacement spriteReferenceReplacement : spriteReferenceReplacements.values()) {
132                 spriteReplacementsByFile.put(spriteReferenceReplacement.spriteReferenceOccurrence.cssFile,
133                         spriteReferenceReplacement);
134             }
135         }
136 
137         return spriteReplacementsByFile;
138     }
139 
140     /**
141      * Builds sprite image for a single sprite image directive.
142      *
143      * @param spriteImageOccurrence
144      *            the sprite image occurrence
145      * @param spriteReferenceOccurrences
146      *            the sprite reference occurrences
147      *
148      * @return the map
149      *
150      * @throws IOException
151      *             Signals that an I/O exception has occurred.
152      */
153     Map<SpriteReferenceOccurrence, SpriteReferenceReplacement> buildSpriteReplacements(
154             SpriteImageOccurrence spriteImageOccurrence,
155             Collection<SpriteReferenceOccurrence> spriteReferenceOccurrences) throws IOException {
156         // Load images into memory. TODO: impose some limit here?
157         final Map<SpriteReferenceOccurrence, BufferedImage> images = new LinkedHashMap<>();
158         for (final SpriteReferenceOccurrence spriteReferenceOccurrence : spriteReferenceOccurrences) {
159             messageLog.setCssFile(spriteReferenceOccurrence.cssFile);
160             messageLog.setLine(spriteReferenceOccurrence.line);
161 
162             final String realImagePath = resourceHandler.getResourcePath(spriteReferenceOccurrence.cssFile,
163                     spriteReferenceOccurrence.imagePath);
164 
165             try (InputStream is = resourceHandler.getResourceAsInputStream(realImagePath)) {
166 
167                 // Load image
168                 if (is == null) {
169                     messageLog.warning(MessageType.CANNOT_NOT_LOAD_IMAGE, realImagePath, "Can't read input file!");
170                     continue;
171                 }
172                 messageLog.info(MessageType.READING_IMAGE, realImagePath);
173                 final BufferedImage image = ImageIO.read(is);
174                 if (image != null) {
175                     images.put(spriteReferenceOccurrence, image);
176                 } else {
177                     messageLog.warning(MessageType.UNSUPPORTED_INDIVIDUAL_IMAGE_FORMAT, realImagePath);
178                 }
179             } catch (final IOException e) {
180                 messageLog.warning(MessageType.CANNOT_NOT_LOAD_IMAGE, realImagePath, "Can't read input file!");
181                 continue;
182             }
183 
184             messageLog.setCssFile(null);
185         }
186 
187         // Build the sprite image bitmap
188         final SpriteImage spriteImage = SpriteImageBuilder.buildSpriteImage(spriteImageOccurrence, images, messageLog);
189         if (spriteImage == null) {
190             return Collections.<SpriteReferenceOccurrence, SpriteReferenceReplacement>emptyMap();
191         }
192 
193         // Render the sprite into the required formats, perform quantization if needed
194         final BufferedImage[] mergedImages = spriteImageRenderer.render(spriteImage);
195 
196         writeSprite(spriteImage, mergedImages[0]);
197 
198         return spriteImage.spriteReferenceReplacements;
199     }
200 
201     /**
202      * Writes sprite image to the disk.
203      *
204      * @param spriteImage
205      *            the sprite image
206      * @param mergedImage
207      *            the merged image
208      *
209      * @throws IOException
210      *             Signals that an I/O exception has occurred.
211      */
212     private void writeSprite(SpriteImage spriteImage, final BufferedImage mergedImage) throws IOException {
213         final SpriteImageOccurrence spriteImageOccurrence = spriteImage.spriteImageOccurrence;
214         final SpriteImageDirective spriteImageDirective = spriteImageOccurrence.spriteImageDirective;
215 
216         // Write the image to a byte array first. We need the data to compute an sha512 hash.
217         final ByteArrayOutputStream spriteImageByteArrayOutputStream = new ByteArrayOutputStream();
218 
219         // If writing to a JPEG, we need to make a 3-byte-encoded image
220         final BufferedImage imageToWrite;
221         if (SpriteImageFormat.JPG.equals(spriteImageDirective.format)) {
222             imageToWrite = new BufferedImage(mergedImage.getWidth(), mergedImage.getHeight(),
223                     BufferedImage.TYPE_3BYTE_BGR);
224             BufferedImageUtils.drawImage(mergedImage, imageToWrite, 0, 0);
225         } else {
226             imageToWrite = mergedImage;
227         }
228 
229         try {
230             ImageIO.write(imageToWrite, spriteImageDirective.format.toString(), spriteImageByteArrayOutputStream);
231         } catch (IOException e) {
232             // Unlikely to happen.
233             messageLog.warning(MessageType.CANNOT_WRITE_SPRITE_IMAGE, spriteImageDirective.imagePath, e.getMessage());
234         }
235 
236         // Build file name
237         byte[] spriteImageBytes = spriteImageByteArrayOutputStream.toByteArray();
238         String resolvedImagePath = spriteImage.resolveImagePath(spriteImageBytes, timestamp.toString());
239         if (resolvedImagePath.indexOf('?') >= 0) {
240             resolvedImagePath = resolvedImagePath.substring(0, resolvedImagePath.indexOf('?'));
241         }
242 
243         // Save the image to the disk
244         final String mergedImageFile = getImageFile(spriteImageOccurrence.cssFile, resolvedImagePath);
245 
246         try (OutputStream spriteImageOutputStream = resourceHandler.getResourceAsOutputStream(mergedImageFile)) {
247             messageLog.info(MessageType.WRITING_SPRITE_IMAGE, mergedImage.getWidth(), mergedImage.getHeight(),
248                     spriteImageDirective.spriteId, mergedImageFile);
249 
250             spriteImageOutputStream.write(spriteImageBytes);
251         } catch (final IOException e) {
252             messageLog.warning(MessageType.CANNOT_WRITE_SPRITE_IMAGE, mergedImageFile, e.getMessage());
253         }
254     }
255 
256     /**
257      * Computes the image path. If the imagePath is relative, it's taken relative to the cssFile. If imagePath is
258      * absolute (starts with '/') and documentRootDir is not null, it's taken relative to documentRootDir.
259      *
260      * @param cssFile
261      *            the css file
262      * @param imagePath
263      *            the image path
264      *
265      * @return the image file
266      */
267     String getImageFile(String cssFile, String imagePath) {
268         // Absolute path resolution is done by resourceHandler
269         final String path = resourceHandler.getResourcePath(cssFile, imagePath);
270 
271         // Just handle the root directory changing
272         if (!imagePath.startsWith("/") && parameters.hasOutputDir()) {
273             return FileUtils.changeRoot(path, parameters.getRootDir(), parameters.getOutputDir());
274         }
275         return path;
276     }
277 
278     /**
279      * Calculates total dimensions and lays out a single sprite image.
280      *
281      * @param spriteImageOccurrence
282      *            the sprite image occurrence
283      * @param images
284      *            the images
285      * @param messageLog
286      *            the message log
287      *
288      * @return the sprite image
289      */
290     static SpriteImage buildSpriteImage(SpriteImageOccurrence spriteImageOccurrence,
291             Map<SpriteReferenceOccurrence, BufferedImage> images, MessageLog messageLog) {
292         // First find the least common multiple of the images with 'repeat' alignment
293         final SpriteImageLayout layout = spriteImageOccurrence.spriteImageDirective.layout;
294         final float spriteScale = spriteImageOccurrence.spriteImageDirective.scaleRatio;
295         final int leastCommonMultiple = SpriteImageBuilder.calculateLeastCommonMultiple(images, layout);
296 
297         // Compute sprite dimension (width for vertical, height for horizontal sprites)
298         final boolean vertical = layout.equals(SpriteImageLayout.VERTICAL);
299         int dimension = leastCommonMultiple;
300         for (final Map.Entry<SpriteReferenceOccurrence, BufferedImage> entry : images.entrySet()) {
301             final BufferedImage image = entry.getValue();
302             final SpriteReferenceOccurrence spriteReferenceOccurrence = entry.getKey();
303 
304             // Compute dimensions
305             dimension = Math.max(dimension, vertical ? spriteReferenceOccurrence.getRequiredWidth(image, layout)
306                     : spriteReferenceOccurrence.getRequiredHeight(image, layout));
307         }
308 
309         // Correct for least common multiple
310         if (dimension % leastCommonMultiple != 0) {
311             dimension += leastCommonMultiple - dimension % leastCommonMultiple;
312         }
313 
314         // Compute the other sprite dimension.
315         int currentOffset = 0;
316         final Map<SpriteReferenceOccurrence, SpriteReferenceReplacement> spriteReplacements = new LinkedHashMap<>();
317         final Map<BufferedImageEqualsWrapper, Integer> renderedImageToOffset = new LinkedHashMap<>();
318         for (final Map.Entry<SpriteReferenceOccurrence, BufferedImage> entry : images.entrySet()) {
319             final SpriteReferenceOccurrence spriteReferenceOccurrence = entry.getKey();
320             final BufferedImage image = entry.getValue();
321 
322             final BufferedImage rendered = spriteReferenceOccurrence.render(image, layout, dimension);
323             final BufferedImageEqualsWrapper imageWrapper = new BufferedImageEqualsWrapper(rendered);
324             Integer imageOffset = renderedImageToOffset.get(imageWrapper);
325             if (imageOffset == null) {
326                 // Draw a new image
327                 imageOffset = currentOffset;
328                 renderedImageToOffset.put(imageWrapper, imageOffset);
329                 currentOffset += vertical ? rendered.getHeight() : rendered.getWidth();
330             }
331 
332             final float scaledImageWidth = spriteReferenceOccurrence.getRequiredWidth(image, layout) / spriteScale;
333             final float scaledImageHeight = spriteReferenceOccurrence.getRequiredHeight(image, layout) / spriteScale;
334             if (Math.round(scaledImageWidth) != scaledImageWidth
335                     || Math.round(scaledImageHeight) != scaledImageHeight) {
336                 messageLog.warning(MessageType.IMAGE_FRACTIONAL_SCALE_VALUE, spriteReferenceOccurrence.imagePath,
337                         scaledImageWidth, scaledImageHeight);
338             }
339 
340             final int adjustedImageOffset = Math.round(imageOffset / spriteScale);
341             spriteReplacements.put(spriteReferenceOccurrence,
342                     spriteReferenceOccurrence.buildReplacement(layout, adjustedImageOffset));
343         }
344 
345         // Render the sprite image and build sprite reference replacements
346         final int spriteWidth = vertical ? dimension : currentOffset;
347         final int spriteHeight = vertical ? currentOffset : dimension;
348         if (spriteWidth == 0 || spriteHeight == 0) {
349             return null;
350         }
351 
352         final float scaledWidth = spriteWidth / spriteScale;
353         final float scaledHeight = spriteHeight / spriteScale;
354         if (Math.round(scaledWidth) != scaledWidth || Math.round(scaledHeight) != scaledHeight) {
355             messageLog.warning(MessageType.FRACTIONAL_SCALE_VALUE, spriteImageOccurrence.spriteImageDirective.spriteId,
356                     scaledWidth, scaledHeight);
357         }
358 
359         final BufferedImage sprite = new BufferedImage(spriteWidth, spriteHeight, BufferedImage.TYPE_4BYTE_ABGR);
360 
361         for (final Map.Entry<BufferedImageEqualsWrapper, Integer> entry : renderedImageToOffset.entrySet()) {
362 
363             BufferedImageUtils.drawImage(entry.getKey().image, sprite, vertical ? 0 : entry.getValue(),
364                     vertical ? entry.getValue() : 0);
365         }
366 
367         return new SpriteImage(sprite, spriteImageOccurrence, spriteReplacements, spriteWidth, spriteHeight,
368                 spriteScale);
369     }
370 
371     /**
372      * Calculates the width/ height of "repeated" sprites.
373      *
374      * @param images
375      *            the images
376      * @param layout
377      *            the layout
378      *
379      * @return the int
380      */
381     static int calculateLeastCommonMultiple(Map<SpriteReferenceOccurrence, BufferedImage> images,
382             SpriteImageLayout layout) {
383         int leastCommonMultiple = 1;
384         for (final Map.Entry<SpriteReferenceOccurrence, BufferedImage> entry : images.entrySet()) {
385             final BufferedImage image = entry.getValue();
386             final SpriteReferenceOccurrence spriteReferenceOccurrence = entry.getKey();
387             if (image != null && SpriteAlignment.REPEAT
388                     .equals(spriteReferenceOccurrence.spriteReferenceDirective.spriteLayoutProperties.alignment)) {
389                 if (SpriteImageLayout.VERTICAL.equals(layout)) {
390                     leastCommonMultiple = ArithmeticUtils.lcm(leastCommonMultiple,
391                             spriteReferenceOccurrence.getRequiredWidth(image, layout));
392                 } else {
393                     leastCommonMultiple = ArithmeticUtils.lcm(leastCommonMultiple,
394                             spriteReferenceOccurrence.getRequiredHeight(image, layout));
395                 }
396             }
397         }
398         return leastCommonMultiple;
399     }
400 
401     /**
402      * Groups {@link SpriteReferenceReplacement}s by the line number of their corresponding directives.
403      *
404      * @param spriteReferenceReplacements
405      *            the sprite reference replacements
406      *
407      * @return the sprite replacements by line number
408      */
409     static Map<Integer, SpriteReferenceReplacement> getSpriteReplacementsByLineNumber(
410             Collection<SpriteReferenceReplacement> spriteReferenceReplacements) {
411         final Map<Integer, SpriteReferenceReplacement> result = new HashMap<>();
412 
413         for (final SpriteReferenceReplacement spriteReferenceReplacement : spriteReferenceReplacements) {
414             result.put(spriteReferenceReplacement.spriteReferenceOccurrence.line, spriteReferenceReplacement);
415         }
416 
417         return result;
418     }
419 
420     /**
421      * Groups {@link SpriteImageOccurrence}s by the line number of their corresponding directives.
422      *
423      * @param spriteImageOccurrences
424      *            the sprite image occurrences
425      *
426      * @return the sprite image occurrences by line number
427      */
428     static Map<Integer, SpriteImageOccurrence> getSpriteImageOccurrencesByLineNumber(
429             Collection<SpriteImageOccurrence> spriteImageOccurrences) {
430         final Map<Integer, SpriteImageOccurrence> result = new HashMap<>();
431 
432         for (final SpriteImageOccurrence spriteImageOccurrence : spriteImageOccurrences) {
433             result.put(spriteImageOccurrence.line, spriteImageOccurrence);
434         }
435 
436         return result;
437     }
438 
439     /**
440      * A wrapper that implements content-aware {@link Object#equals(Object)} and {@link Object#hashCode()} on
441      * {@link BufferedImage}s.
442      */
443     static final class BufferedImageEqualsWrapper {
444 
445         /** The image. */
446         BufferedImage image;
447 
448         /**
449          * Instantiates a new buffered image equals wrapper.
450          *
451          * @param image
452          *            the image
453          */
454         BufferedImageEqualsWrapper(BufferedImage image) {
455             this.image = image;
456         }
457 
458         @Override
459         public boolean equals(Object obj) {
460             if (!(obj instanceof BufferedImageEqualsWrapper)) {
461                 return false;
462             }
463 
464             if (obj == this) {
465                 return true;
466             }
467 
468             final BufferedImage other = ((BufferedImageEqualsWrapper) obj).image;
469 
470             boolean equal = other.getWidth() == image.getWidth() && other.getHeight() == image.getHeight()
471                     && other.getType() == image.getType();
472 
473             if (equal) {
474                 for (int y = 0; y < image.getHeight(); y++) {
475                     for (int x = 0; x < image.getWidth(); x++) {
476                         if (ignoreFullTransparency(image.getRGB(x, y)) != ignoreFullTransparency(other.getRGB(x, y))) {
477                             return false;
478                         }
479                     }
480                 }
481             }
482 
483             return equal;
484         }
485 
486         @Override
487         public int hashCode() {
488             if (image == null) {
489                 return 0;
490             }
491 
492             int hash = image.getWidth() ^ image.getHeight() << 16;
493 
494             // Computes the hashCode based on an 4 x 4 to 7 x 7 grid of image's pixels
495             final int xIncrement = image.getWidth() > 7 ? image.getWidth() >> 2 : 1;
496             final int yIncrement = image.getHeight() > 7 ? image.getHeight() >> 2 : 1;
497 
498             for (int y = 0; y < image.getHeight(); y += yIncrement) {
499                 for (int x = 0; x < image.getWidth(); x += xIncrement) {
500                     hash ^= ignoreFullTransparency(image.getRGB(x, y));
501                 }
502             }
503 
504             return hash;
505         }
506 
507         /**
508          * If the pixel is fully transparent, returns 0. Otherwise, returns the pixel. This is useful in
509          * {@link #equals(Object)} and {@link #hashCode()} to ignore pixels that have different colors but are invisible
510          * anyway because of full transparency.
511          *
512          * @param pixel
513          *            the pixel
514          *
515          * @return the int
516          */
517         private static int ignoreFullTransparency(int pixel) {
518             if ((pixel & 0xff000000) == 0x00000000) {
519                 return 0;
520             }
521             return pixel;
522         }
523     }
524 }