SpriteImageBuilder.java
/*
* SmartSprites Project
*
* Copyright (C) 2007-2009, Stanisław Osiński.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* - Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above copyright notice, this
* list of conditions and the following disclaimer in the documentation and/or
* other materials provided with the distribution.
*
* - Neither the name of the SmartSprites Project nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* - We kindly request that you include in the end-user documentation provided with
* the redistribution and/or in the software itself an acknowledgement equivalent
* to the following: "This product includes software developed by the SmartSprites
* Project."
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.carrot2.labs.smartsprites;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.Multimap;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.imageio.ImageIO;
import org.apache.commons.math3.util.ArithmeticUtils;
import org.carrot2.labs.smartsprites.SpriteImageDirective.SpriteImageFormat;
import org.carrot2.labs.smartsprites.SpriteImageDirective.SpriteImageLayout;
import org.carrot2.labs.smartsprites.SpriteLayoutProperties.SpriteAlignment;
import org.carrot2.labs.smartsprites.message.Message.MessageType;
import org.carrot2.labs.smartsprites.message.MessageLog;
import org.carrot2.labs.smartsprites.resource.ResourceHandler;
import org.carrot2.util.BufferedImageUtils;
import org.carrot2.util.FileUtils;
/**
* Lays out and builds sprite images based on the collected SmartSprites directives.
*/
public class SpriteImageBuilder {
/** This builder's configuration. */
public final SmartSpritesParameters parameters;
/** This builder's message log. */
private final MessageLog messageLog;
/** Image merger for this builder. */
private SpriteImageRenderer spriteImageRenderer;
/** The resource handler. */
private ResourceHandler resourceHandler;
/**
* A timestamp to use for timestamp-based sprite image UIDs. We need this time stamp as a field to make sure the
* timestamp is the same for all sprite image replacements.
*/
private Instant timestamp;
/**
* Creates a {@link SpriteImageBuilder} with the provided parameters and log.
*
* @param parameters
* the parameters
* @param messageLog
* the message log
* @param resourceHandler
* the resource handler
*/
SpriteImageBuilder(SmartSpritesParameters parameters, MessageLog messageLog, ResourceHandler resourceHandler) {
this.messageLog = messageLog;
this.parameters = parameters;
this.resourceHandler = resourceHandler;
spriteImageRenderer = new SpriteImageRenderer(parameters, messageLog);
}
/**
* Builds all sprite images based on the collected directives.
*
* @param spriteImageOccurrencesBySpriteId
* the sprite image occurrences by sprite id
* @param spriteReferenceOccurrencesBySpriteId
* the sprite reference occurrences by sprite id
*
* @return the multimap
*
* @throws IOException
* Signals that an I/O exception has occurred.
*/
Multimap<String, SpriteReferenceReplacement> buildSpriteImages(
Map<String, SpriteImageOccurrence> spriteImageOccurrencesBySpriteId,
Multimap<String, SpriteReferenceOccurrence> spriteReferenceOccurrencesBySpriteId) throws IOException {
timestamp = Instant.now();
final Multimap<String, SpriteReferenceReplacement> spriteReplacementsByFile = LinkedListMultimap.create();
for (final Map.Entry<String, Collection<SpriteReferenceOccurrence>> spriteReferenceOccurrences : spriteReferenceOccurrencesBySpriteId
.asMap().entrySet()) {
final Map<SpriteReferenceOccurrence, SpriteReferenceReplacement> spriteReferenceReplacements = buildSpriteReplacements(
spriteImageOccurrencesBySpriteId.get(spriteReferenceOccurrences.getKey()),
spriteReferenceOccurrences.getValue());
for (final SpriteReferenceReplacement spriteReferenceReplacement : spriteReferenceReplacements.values()) {
spriteReplacementsByFile.put(spriteReferenceReplacement.spriteReferenceOccurrence.cssFile,
spriteReferenceReplacement);
}
}
return spriteReplacementsByFile;
}
/**
* Builds sprite image for a single sprite image directive.
*
* @param spriteImageOccurrence
* the sprite image occurrence
* @param spriteReferenceOccurrences
* the sprite reference occurrences
*
* @return the map
*
* @throws IOException
* Signals that an I/O exception has occurred.
*/
Map<SpriteReferenceOccurrence, SpriteReferenceReplacement> buildSpriteReplacements(
SpriteImageOccurrence spriteImageOccurrence,
Collection<SpriteReferenceOccurrence> spriteReferenceOccurrences) throws IOException {
// Load images into memory. TODO: impose some limit here?
final Map<SpriteReferenceOccurrence, BufferedImage> images = new LinkedHashMap<>();
for (final SpriteReferenceOccurrence spriteReferenceOccurrence : spriteReferenceOccurrences) {
messageLog.setCssFile(spriteReferenceOccurrence.cssFile);
messageLog.setLine(spriteReferenceOccurrence.line);
final String realImagePath = resourceHandler.getResourcePath(spriteReferenceOccurrence.cssFile,
spriteReferenceOccurrence.imagePath);
try (InputStream is = resourceHandler.getResourceAsInputStream(realImagePath)) {
// Load image
if (is == null) {
messageLog.warning(MessageType.CANNOT_NOT_LOAD_IMAGE, realImagePath, "Can't read input file!");
continue;
}
messageLog.info(MessageType.READING_IMAGE, realImagePath);
final BufferedImage image = ImageIO.read(is);
if (image != null) {
images.put(spriteReferenceOccurrence, image);
} else {
messageLog.warning(MessageType.UNSUPPORTED_INDIVIDUAL_IMAGE_FORMAT, realImagePath);
}
} catch (final IOException e) {
messageLog.warning(MessageType.CANNOT_NOT_LOAD_IMAGE, realImagePath, "Can't read input file!");
continue;
}
messageLog.setCssFile(null);
}
// Build the sprite image bitmap
final SpriteImage spriteImage = SpriteImageBuilder.buildSpriteImage(spriteImageOccurrence, images, messageLog);
if (spriteImage == null) {
return Collections.<SpriteReferenceOccurrence, SpriteReferenceReplacement>emptyMap();
}
// Render the sprite into the required formats, perform quantization if needed
final BufferedImage[] mergedImages = spriteImageRenderer.render(spriteImage);
writeSprite(spriteImage, mergedImages[0]);
return spriteImage.spriteReferenceReplacements;
}
/**
* Writes sprite image to the disk.
*
* @param spriteImage
* the sprite image
* @param mergedImage
* the merged image
*
* @throws IOException
* Signals that an I/O exception has occurred.
*/
private void writeSprite(SpriteImage spriteImage, final BufferedImage mergedImage) throws IOException {
final SpriteImageOccurrence spriteImageOccurrence = spriteImage.spriteImageOccurrence;
final SpriteImageDirective spriteImageDirective = spriteImageOccurrence.spriteImageDirective;
// Write the image to a byte array first. We need the data to compute an sha512 hash.
final ByteArrayOutputStream spriteImageByteArrayOutputStream = new ByteArrayOutputStream();
// If writing to a JPEG, we need to make a 3-byte-encoded image
final BufferedImage imageToWrite;
if (SpriteImageFormat.JPG.equals(spriteImageDirective.format)) {
imageToWrite = new BufferedImage(mergedImage.getWidth(), mergedImage.getHeight(),
BufferedImage.TYPE_3BYTE_BGR);
BufferedImageUtils.drawImage(mergedImage, imageToWrite, 0, 0);
} else {
imageToWrite = mergedImage;
}
try {
ImageIO.write(imageToWrite, spriteImageDirective.format.toString(), spriteImageByteArrayOutputStream);
} catch (IOException e) {
// Unlikely to happen.
messageLog.warning(MessageType.CANNOT_WRITE_SPRITE_IMAGE, spriteImageDirective.imagePath, e.getMessage());
}
// Build file name
byte[] spriteImageBytes = spriteImageByteArrayOutputStream.toByteArray();
String resolvedImagePath = spriteImage.resolveImagePath(spriteImageBytes, timestamp.toString());
if (resolvedImagePath.indexOf('?') >= 0) {
resolvedImagePath = resolvedImagePath.substring(0, resolvedImagePath.indexOf('?'));
}
// Save the image to the disk
final String mergedImageFile = getImageFile(spriteImageOccurrence.cssFile, resolvedImagePath);
try (OutputStream spriteImageOutputStream = resourceHandler.getResourceAsOutputStream(mergedImageFile)) {
messageLog.info(MessageType.WRITING_SPRITE_IMAGE, mergedImage.getWidth(), mergedImage.getHeight(),
spriteImageDirective.spriteId, mergedImageFile);
spriteImageOutputStream.write(spriteImageBytes);
} catch (final IOException e) {
messageLog.warning(MessageType.CANNOT_WRITE_SPRITE_IMAGE, mergedImageFile, e.getMessage());
}
}
/**
* Computes the image path. If the imagePath is relative, it's taken relative to the cssFile. If imagePath is
* absolute (starts with '/') and documentRootDir is not null, it's taken relative to documentRootDir.
*
* @param cssFile
* the css file
* @param imagePath
* the image path
*
* @return the image file
*/
String getImageFile(String cssFile, String imagePath) {
// Absolute path resolution is done by resourceHandler
final String path = resourceHandler.getResourcePath(cssFile, imagePath);
// Just handle the root directory changing
if (!imagePath.startsWith("/") && parameters.hasOutputDir()) {
return FileUtils.changeRoot(path, parameters.getRootDir(), parameters.getOutputDir());
}
return path;
}
/**
* Calculates total dimensions and lays out a single sprite image.
*
* @param spriteImageOccurrence
* the sprite image occurrence
* @param images
* the images
* @param messageLog
* the message log
*
* @return the sprite image
*/
static SpriteImage buildSpriteImage(SpriteImageOccurrence spriteImageOccurrence,
Map<SpriteReferenceOccurrence, BufferedImage> images, MessageLog messageLog) {
// First find the least common multiple of the images with 'repeat' alignment
final SpriteImageLayout layout = spriteImageOccurrence.spriteImageDirective.layout;
final float spriteScale = spriteImageOccurrence.spriteImageDirective.scaleRatio;
final int leastCommonMultiple = SpriteImageBuilder.calculateLeastCommonMultiple(images, layout);
// Compute sprite dimension (width for vertical, height for horizontal sprites)
final boolean vertical = layout.equals(SpriteImageLayout.VERTICAL);
int dimension = leastCommonMultiple;
for (final Map.Entry<SpriteReferenceOccurrence, BufferedImage> entry : images.entrySet()) {
final BufferedImage image = entry.getValue();
final SpriteReferenceOccurrence spriteReferenceOccurrence = entry.getKey();
// Compute dimensions
dimension = Math.max(dimension, vertical ? spriteReferenceOccurrence.getRequiredWidth(image, layout)
: spriteReferenceOccurrence.getRequiredHeight(image, layout));
}
// Correct for least common multiple
if (dimension % leastCommonMultiple != 0) {
dimension += leastCommonMultiple - dimension % leastCommonMultiple;
}
// Compute the other sprite dimension.
int currentOffset = 0;
final Map<SpriteReferenceOccurrence, SpriteReferenceReplacement> spriteReplacements = new LinkedHashMap<>();
final Map<BufferedImageEqualsWrapper, Integer> renderedImageToOffset = new LinkedHashMap<>();
for (final Map.Entry<SpriteReferenceOccurrence, BufferedImage> entry : images.entrySet()) {
final SpriteReferenceOccurrence spriteReferenceOccurrence = entry.getKey();
final BufferedImage image = entry.getValue();
final BufferedImage rendered = spriteReferenceOccurrence.render(image, layout, dimension);
final BufferedImageEqualsWrapper imageWrapper = new BufferedImageEqualsWrapper(rendered);
Integer imageOffset = renderedImageToOffset.get(imageWrapper);
if (imageOffset == null) {
// Draw a new image
imageOffset = currentOffset;
renderedImageToOffset.put(imageWrapper, imageOffset);
currentOffset += vertical ? rendered.getHeight() : rendered.getWidth();
}
final float scaledImageWidth = spriteReferenceOccurrence.getRequiredWidth(image, layout) / spriteScale;
final float scaledImageHeight = spriteReferenceOccurrence.getRequiredHeight(image, layout) / spriteScale;
if (Math.round(scaledImageWidth) != scaledImageWidth
|| Math.round(scaledImageHeight) != scaledImageHeight) {
messageLog.warning(MessageType.IMAGE_FRACTIONAL_SCALE_VALUE, spriteReferenceOccurrence.imagePath,
scaledImageWidth, scaledImageHeight);
}
final int adjustedImageOffset = Math.round(imageOffset / spriteScale);
spriteReplacements.put(spriteReferenceOccurrence,
spriteReferenceOccurrence.buildReplacement(layout, adjustedImageOffset));
}
// Render the sprite image and build sprite reference replacements
final int spriteWidth = vertical ? dimension : currentOffset;
final int spriteHeight = vertical ? currentOffset : dimension;
if (spriteWidth == 0 || spriteHeight == 0) {
return null;
}
final float scaledWidth = spriteWidth / spriteScale;
final float scaledHeight = spriteHeight / spriteScale;
if (Math.round(scaledWidth) != scaledWidth || Math.round(scaledHeight) != scaledHeight) {
messageLog.warning(MessageType.FRACTIONAL_SCALE_VALUE, spriteImageOccurrence.spriteImageDirective.spriteId,
scaledWidth, scaledHeight);
}
final BufferedImage sprite = new BufferedImage(spriteWidth, spriteHeight, BufferedImage.TYPE_4BYTE_ABGR);
for (final Map.Entry<BufferedImageEqualsWrapper, Integer> entry : renderedImageToOffset.entrySet()) {
BufferedImageUtils.drawImage(entry.getKey().image, sprite, vertical ? 0 : entry.getValue(),
vertical ? entry.getValue() : 0);
}
return new SpriteImage(sprite, spriteImageOccurrence, spriteReplacements, spriteWidth, spriteHeight,
spriteScale);
}
/**
* Calculates the width/ height of "repeated" sprites.
*
* @param images
* the images
* @param layout
* the layout
*
* @return the int
*/
static int calculateLeastCommonMultiple(Map<SpriteReferenceOccurrence, BufferedImage> images,
SpriteImageLayout layout) {
int leastCommonMultiple = 1;
for (final Map.Entry<SpriteReferenceOccurrence, BufferedImage> entry : images.entrySet()) {
final BufferedImage image = entry.getValue();
final SpriteReferenceOccurrence spriteReferenceOccurrence = entry.getKey();
if (image != null && SpriteAlignment.REPEAT
.equals(spriteReferenceOccurrence.spriteReferenceDirective.spriteLayoutProperties.alignment)) {
if (SpriteImageLayout.VERTICAL.equals(layout)) {
leastCommonMultiple = ArithmeticUtils.lcm(leastCommonMultiple,
spriteReferenceOccurrence.getRequiredWidth(image, layout));
} else {
leastCommonMultiple = ArithmeticUtils.lcm(leastCommonMultiple,
spriteReferenceOccurrence.getRequiredHeight(image, layout));
}
}
}
return leastCommonMultiple;
}
/**
* Groups {@link SpriteReferenceReplacement}s by the line number of their corresponding directives.
*
* @param spriteReferenceReplacements
* the sprite reference replacements
*
* @return the sprite replacements by line number
*/
static Map<Integer, SpriteReferenceReplacement> getSpriteReplacementsByLineNumber(
Collection<SpriteReferenceReplacement> spriteReferenceReplacements) {
final Map<Integer, SpriteReferenceReplacement> result = new HashMap<>();
for (final SpriteReferenceReplacement spriteReferenceReplacement : spriteReferenceReplacements) {
result.put(spriteReferenceReplacement.spriteReferenceOccurrence.line, spriteReferenceReplacement);
}
return result;
}
/**
* Groups {@link SpriteImageOccurrence}s by the line number of their corresponding directives.
*
* @param spriteImageOccurrences
* the sprite image occurrences
*
* @return the sprite image occurrences by line number
*/
static Map<Integer, SpriteImageOccurrence> getSpriteImageOccurrencesByLineNumber(
Collection<SpriteImageOccurrence> spriteImageOccurrences) {
final Map<Integer, SpriteImageOccurrence> result = new HashMap<>();
for (final SpriteImageOccurrence spriteImageOccurrence : spriteImageOccurrences) {
result.put(spriteImageOccurrence.line, spriteImageOccurrence);
}
return result;
}
/**
* A wrapper that implements content-aware {@link Object#equals(Object)} and {@link Object#hashCode()} on
* {@link BufferedImage}s.
*/
static final class BufferedImageEqualsWrapper {
/** The image. */
BufferedImage image;
/**
* Instantiates a new buffered image equals wrapper.
*
* @param image
* the image
*/
BufferedImageEqualsWrapper(BufferedImage image) {
this.image = image;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof BufferedImageEqualsWrapper)) {
return false;
}
if (obj == this) {
return true;
}
final BufferedImage other = ((BufferedImageEqualsWrapper) obj).image;
boolean equal = other.getWidth() == image.getWidth() && other.getHeight() == image.getHeight()
&& other.getType() == image.getType();
if (equal) {
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
if (ignoreFullTransparency(image.getRGB(x, y)) != ignoreFullTransparency(other.getRGB(x, y))) {
return false;
}
}
}
}
return equal;
}
@Override
public int hashCode() {
if (image == null) {
return 0;
}
int hash = image.getWidth() ^ image.getHeight() << 16;
// Computes the hashCode based on an 4 x 4 to 7 x 7 grid of image's pixels
final int xIncrement = image.getWidth() > 7 ? image.getWidth() >> 2 : 1;
final int yIncrement = image.getHeight() > 7 ? image.getHeight() >> 2 : 1;
for (int y = 0; y < image.getHeight(); y += yIncrement) {
for (int x = 0; x < image.getWidth(); x += xIncrement) {
hash ^= ignoreFullTransparency(image.getRGB(x, y));
}
}
return hash;
}
/**
* If the pixel is fully transparent, returns 0. Otherwise, returns the pixel. This is useful in
* {@link #equals(Object)} and {@link #hashCode()} to ignore pixels that have different colors but are invisible
* anyway because of full transparency.
*
* @param pixel
* the pixel
*
* @return the int
*/
private static int ignoreFullTransparency(int pixel) {
if ((pixel & 0xff000000) == 0x00000000) {
return 0;
}
return pixel;
}
}
}