blob: d52a8bc59c3ca1468990b161de43b4fb221b088d [file] [log] [blame]
/*
* Copyright 2008 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.google.gwt.resources.rg;
import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.core.ext.UnableToCompleteException;
import com.google.gwt.dev.util.Util;
import com.google.gwt.dev.util.log.PrintWriterTreeLogger;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.MemoryCacheImageInputStream;
/**
* Accumulates state for the bundled image.
*/
class ImageBundleBuilder {
/**
* Abstracts the process of arranging a number of images into a composite
* image.
*/
interface Arranger {
/**
* Determine the total area required to store a composite image.
*/
Size arrangeImages(Collection<HasRect> rects);
}
/**
* Arranges the images to try to decrease the overall area of the resulting
* bundle. This uses a strategy that is basically Next-Fit Decreasing Height
* Decreasing Width (NFDHDW). The rectangles to be packed are sorted in
* decreasing order by height. The tallest rectangle is placed at the far
* left. We attempt to stack the remaining rectangles on top of one another to
* construct as many columns as necessary. After finishing each column, we
* also attempt to do some horizontal packing to fill up the space left due to
* widths of rectangles differing in the column.
*/
static class BestFitArranger implements Arranger {
private static final Comparator<HasRect> decreasingHeightComparator = new Comparator<HasRect>() {
public int compare(HasRect a, HasRect b) {
final int c = b.getHeight() - a.getHeight();
// If we encounter equal heights, use the name to keep things
// deterministic.
return (c != 0) ? c : b.getName().compareTo(a.getName());
}
};
private static final Comparator<HasRect> decreasingWidthComparator = new Comparator<HasRect>() {
public int compare(HasRect a, HasRect b) {
final int c = b.getWidth() - a.getWidth();
// If we encounter equal heights, use the name to keep things
// deterministic.
return (c != 0) ? c : b.getName().compareTo(a.getName());
}
};
public Size arrangeImages(Collection<HasRect> rects) {
if (rects.size() == 0) {
return new Size(0, 0);
}
// Create a list of ImageRects ordered by decreasing height used for
// constructing columns.
final ArrayList<HasRect> rectsOrderedByHeight = new ArrayList<HasRect>(
rects);
Collections.sort(rectsOrderedByHeight, decreasingHeightComparator);
// Create a list of ImageRects ordered by decreasing width used for
// packing
// individual columns.
final ArrayList<HasRect> rectsOrderedByWidth = new ArrayList<HasRect>(
rects);
Collections.sort(rectsOrderedByWidth, decreasingWidthComparator);
// Place the first, tallest image as the first column.
final HasRect first = rectsOrderedByHeight.get(0);
first.setPosition(0, 0);
// Setup state for laying things cumulatively.
int curX = first.getWidth();
final int colH = first.getHeight();
for (int i = 1, n = rectsOrderedByHeight.size(); i < n; i++) {
// If this ImageRect has been positioned already, move on.
if (rectsOrderedByHeight.get(i).hasBeenPositioned()) {
continue;
}
int colW = 0;
int curY = 0;
final ArrayList<HasRect> rectsInColumn = new ArrayList<HasRect>();
for (int j = i; j < n; j++) {
final HasRect current = rectsOrderedByHeight.get(j);
// Look for rects that have not been positioned with a small enough
// height to go in this column.
if (!current.hasBeenPositioned()
&& (curY + current.getHeight()) <= colH) {
// Set the horizontal position here, the top field will be set in
// arrangeColumn after we've collected a full set of ImageRects.
current.setPosition(curX, 0);
colW = Math.max(colW, current.getWidth());
curY += current.getHeight();
// Keep the ImageRects in this column in decreasing order by width.
final int pos = Collections.binarySearch(rectsInColumn, current,
decreasingWidthComparator);
assert pos < 0;
rectsInColumn.add(-1 - pos, current);
}
}
// Having selected a set of ImageRects that fill out this column
// vertical,
// now we'll scan the remaining ImageRects to try to fit some in the
// horizontal gaps.
if (!rectsInColumn.isEmpty()) {
arrangeColumn(rectsInColumn, rectsOrderedByWidth);
}
// We're done with that column, so move the horizontal accumulator by
// the
// width of the column we just finished.
curX += colW;
}
return new Size(curX, colH);
}
/**
* Companion method to {@link #arrangeImages()}. This method does a best
* effort horizontal packing of a column after it was packed vertically.
* This is the Decreasing Width part of Next-Fit Decreasing Height
* Decreasing Width. The basic strategy is to sort the remaining rectangles
* by decreasing width and try to fit them to the left of each of the
* rectangles we've already picked for this column.
*
* @param rectsInColumn the ImageRects that were already selected for this
* column
* @param remainingRectsOrderedByWidth the sub list of ImageRects that may
* not have been positioned yet
*/
private void arrangeColumn(List<HasRect> rectsInColumn,
List<HasRect> remainingRectsOrderedByWidth) {
final HasRect first = rectsInColumn.get(0);
final int columnWidth = first.getWidth();
int curY = first.getHeight();
// Skip this first ImageRect because it is guaranteed to consume the full
// width of the column.
for (int i = 1, m = rectsInColumn.size(); i < m; i++) {
final HasRect r = rectsInColumn.get(i);
// The ImageRect was previously positioned horizontally, now set the top
// field.
r.setPosition(r.getLeft(), curY);
int curX = r.getWidth();
// Search for ImageRects that are shorter than the left most ImageRect
// and
// narrow enough to fit in the column.
for (int j = 0, n = remainingRectsOrderedByWidth.size(); j < n; j++) {
final HasRect current = remainingRectsOrderedByWidth.get(j);
if (!current.hasBeenPositioned()
&& (curX + current.getWidth()) <= columnWidth
&& (current.getHeight() <= r.getHeight())) {
current.setPosition(r.getLeft() + curX, r.getTop());
curX += current.getWidth();
}
}
// Update the vertical accumulator so we'll know where to place the next
// ImageRect.
curY += r.getHeight();
}
}
}
/**
* A mockable interface to test the image arrangement algorithms.
*/
interface HasRect {
int getHeight();
BufferedImage getImage();
BufferedImage[] getImages();
int getLeft();
String getName();
int getTop();
AffineTransform getTransform();
int getWidth();
boolean hasBeenPositioned();
void setPosition(int left, int top);
AffineTransform transform();
}
/**
* Performs a simple horizontal arrangement of rectangles. Images will be
* tiled vertically to fill to fill the full height of the image.
*/
static class HorizontalArranger implements Arranger {
public Size arrangeImages(Collection<HasRect> rects) {
int height = 1;
int width = 0;
for (HasRect rect : rects) {
rect.setPosition(width, 0);
width += rect.getWidth();
height = lcm(height, rect.getHeight());
}
List<HasRect> toAdd = new ArrayList<HasRect>();
for (HasRect rect : rects) {
int y = rect.getHeight();
while (y < height) {
ImageRect newRect = new ImageRect(rect);
newRect.setPosition(rect.getLeft(), y);
y += rect.getHeight();
toAdd.add(newRect);
}
}
rects.addAll(toAdd);
return new Size(width, height);
}
}
/**
* Does not rearrange the rectangles, but simply computes the size of the
* canvas needed to hold the images in their current positions.
*/
static class IdentityArranger implements Arranger {
public Size arrangeImages(Collection<HasRect> rects) {
int height = 0;
int width = 0;
for (HasRect rect : rects) {
height = Math.max(height, rect.getTop() + rect.getHeight());
width = Math.max(width, rect.getLeft() + rect.getWidth());
}
return new Size(width, height);
}
}
/**
* The rectangle at which the original image is placed into the composite
* image.
*/
static class ImageRect implements HasRect {
private boolean hasBeenPositioned;
private final int height, width;
private final BufferedImage[] images;
private int left, top;
private final String name;
private final AffineTransform transform = new AffineTransform();
/**
* Copy constructor.
*/
public ImageRect(HasRect other) {
this.name = other.getName();
this.height = other.getHeight();
this.width = other.getWidth();
this.images = other.getImages();
this.left = other.getLeft();
this.top = other.getTop();
setTransform(other.getTransform());
}
public ImageRect(String name, BufferedImage image) {
this.name = name;
this.images = new BufferedImage[] {image};
this.width = image.getWidth();
this.height = image.getHeight();
}
public ImageRect(String name, BufferedImage[] images) {
this.name = name;
this.images = images;
this.width = images[0].getWidth();
this.height = images[0].getHeight();
}
public int getHeight() {
return height;
}
public BufferedImage getImage() {
return images[0];
}
public BufferedImage[] getImages() {
return images;
}
public int getLeft() {
return left;
}
public String getName() {
return name;
}
public int getTop() {
return top;
}
public AffineTransform getTransform() {
return new AffineTransform(transform);
}
public int getWidth() {
return width;
}
public boolean hasBeenPositioned() {
return hasBeenPositioned;
}
public boolean isAnimated() {
return images.length > 1;
}
public void setPosition(int left, int top) {
hasBeenPositioned = true;
this.left = left;
this.top = top;
}
public void setTransform(AffineTransform transform) {
this.transform.setTransform(transform);
}
public AffineTransform transform() {
AffineTransform toReturn = new AffineTransform();
toReturn.translate(left, top);
toReturn.concatenate(transform);
assert checkTransform(toReturn);
return toReturn;
}
private boolean checkTransform(AffineTransform tx) {
double[] in = {0, 0, width, height};
double[] out = {0, 0, 0, 0};
tx.transform(in, 0, out, 0, 2);
assert width == Math.abs(out[0] - out[2]);
assert height == Math.abs(out[1] - out[3]);
assert out[0] >= 0;
assert out[1] >= 0;
assert out[2] >= 0;
assert out[3] >= 0;
return true;
}
}
/**
* Used to return the size of the resulting image from the method
* {@link ImageBundleBuilder#arrangeImages()}.
*/
static class Size {
private final int width, height;
Size(int width, int height) {
this.width = width;
this.height = height;
}
}
/**
* Performs a simple vertical arrangement of rectangles. Images will be tiled
* horizontally to fill the full width of the image.
*/
static class VerticalArranger implements Arranger {
public Size arrangeImages(Collection<HasRect> rects) {
int height = 0;
int width = 1;
for (HasRect rect : rects) {
rect.setPosition(0, height);
width = lcm(width, rect.getWidth());
height += rect.getHeight();
}
List<HasRect> toAdd = new ArrayList<HasRect>();
for (HasRect rect : rects) {
int x = rect.getWidth();
while (x < width) {
ImageRect newRect = new ImageRect(rect);
newRect.setPosition(x, rect.getTop());
x += rect.getWidth();
toAdd.add(newRect);
}
}
rects.addAll(toAdd);
return new Size(width, height);
}
}
/*
* Only PNG is supported right now. In the future, we may be able to infer the
* best output type, and get rid of this constant.
*/
static final String BUNDLE_FILE_TYPE = "png";
static final String BUNDLE_MIME_TYPE = "image/png";
private static final int IMAGE_MAX_SIZE = Integer.getInteger(
"gwt.imageResource.maxBundleSize", 256);
public static void main(String[] args) {
final TreeLogger logger = new PrintWriterTreeLogger(new PrintWriter(
System.out));
if (args.length < 2) {
logger.log(TreeLogger.ERROR, ImageBundleBuilder.class.getSimpleName()
+ " <output file> <input file> ...");
System.exit(-1);
}
ImageBundleBuilder builder = new ImageBundleBuilder();
boolean fail = false;
for (int i = 1, j = args.length; i < j; i++) {
TreeLogger loopLogger = logger.branch(TreeLogger.DEBUG,
"Processing argument " + args[i]);
File file = new File(args[i]);
Exception ex = null;
try {
builder.assimilate(loopLogger, args[i], file.toURL());
} catch (MalformedURLException e) {
ex = e;
} catch (UnableToCompleteException e) {
ex = e;
} catch (UnsuitableForStripException e) {
ex = e;
}
if (ex != null) {
loopLogger.log(TreeLogger.ERROR, "Unable to assimilate image", ex);
fail = true;
}
}
if (fail) {
System.exit(-1);
}
final String outFile = args[0];
try {
BufferedImage bundledImage = builder.drawBundledImage(new BestFitArranger());
byte[] bytes = createImageBytes(logger, bundledImage);
FileOutputStream out = new FileOutputStream(outFile);
out.write(bytes);
out.close();
} catch (IOException e) {
logger.log(TreeLogger.ERROR, "Unable to write output file", e);
System.exit(-2);
} catch (UnableToCompleteException e) {
logger.log(TreeLogger.ERROR, "Unable to draw output image", e);
System.exit(-2);
}
System.exit(0);
}
public static byte[] toPng(TreeLogger logger, HasRect rect)
throws UnableToCompleteException {
// Create the bundled image.
BufferedImage bundledImage = new BufferedImage(rect.getWidth(),
rect.getHeight(), BufferedImage.TYPE_INT_ARGB_PRE);
Graphics2D g2d = bundledImage.createGraphics();
g2d.drawImage(rect.getImage(), rect.transform(), null);
g2d.dispose();
byte[] imageBytes = createImageBytes(logger, bundledImage);
return imageBytes;
}
/**
* Write the bundled image into a byte array, so that we can compute its
* strong name.
*/
private static byte[] createImageBytes(TreeLogger logger,
BufferedImage bundledImage) throws UnableToCompleteException {
byte[] imageBytes;
try {
ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
ImageIO.write(bundledImage, BUNDLE_FILE_TYPE, byteOutputStream);
imageBytes = byteOutputStream.toByteArray();
} catch (IOException e) {
logger.log(TreeLogger.ERROR,
"Unable to generate file name for image bundle file", null);
throw new UnableToCompleteException();
}
return imageBytes;
}
/**
* Compute the greatest common denominator of two numbers.
*/
private static int gcd(int a, int b) {
while (b != 0) {
int t = b;
b = a % b;
a = t;
}
return a;
}
/**
* Compute the least common multiple of two numbers. This is used by
* {@link HorizontalArranger} and {@link VerticalArranger} to determine how
* large the composite image should be to allow every image to line up when
* repeated.
*/
private static int lcm(int a, int b) {
return b / gcd(a, b) * a;
}
private final Map<String, ImageRect> imageNameToImageRectMap = new HashMap<String, ImageRect>();
/**
* This map is used to de-duplicate images in generated image strips.
*/
private final Map<String, String> strongNametoCanonicalImageNameMap = new HashMap<String, String>();
public ImageBundleBuilder() {
}
/**
* Copy constructor.
*/
public ImageBundleBuilder(ImageBundleBuilder other) {
for (Map.Entry<String, ImageRect> entry : other.imageNameToImageRectMap.entrySet()) {
imageNameToImageRectMap.put(entry.getKey(), new ImageRect(
entry.getValue()));
}
}
/**
* Assimilates the image associated with a particular image method into the
* master composite. If the method names an image that has already been
* assimilated, the existing image rectangle is reused.
*
* @param logger a hierarchical logger which logs to the hosted console
* @param imageName the name of an image that can be found on the classpath
* @param resource the URL from which the image data wil be loaded
* @throws UnableToCompleteException if the image with name
* <code>imageName</code> cannot be added to the master composite
* image
*/
public ImageRect assimilate(TreeLogger logger, String imageName, URL resource)
throws UnableToCompleteException, UnsuitableForStripException {
/*
* Decide whether or not we need to add to the composite image. Either way,
* we associated it with the rectangle of the specified image as it exists
* within the composite image. Note that the coordinates of the rectangle
* aren't computed until the composite is written.
*/
ImageRect rect = getMapping(imageName);
if (rect == null) {
String strongName = Util.computeStrongName(Util.readURLAsBytes(resource));
if (strongNametoCanonicalImageNameMap.containsKey(strongName)) {
String previousImageName = strongNametoCanonicalImageNameMap.get(strongName);
rect = getMapping(previousImageName);
assert rect != null;
imageNameToImageRectMap.put(imageName, rect);
} else {
// Assimilate the image into the composite.
rect = addImage(logger, imageName, resource);
imageNameToImageRectMap.put(imageName, rect);
strongNametoCanonicalImageNameMap.put(strongName, imageName);
}
}
return rect;
}
public int getImageCount() {
return imageNameToImageRectMap.size();
}
public ImageRect getMapping(String imageName) {
return imageNameToImageRectMap.get(imageName);
}
/**
* Remove an image from the builder.
*/
public ImageRect removeMapping(String imageName) {
strongNametoCanonicalImageNameMap.values().remove(imageName);
return imageNameToImageRectMap.remove(imageName);
}
/**
* Render the composited image into an array of bytes.
*/
public byte[] render(TreeLogger logger, Arranger arranger)
throws UnableToCompleteException {
if (imageNameToImageRectMap.isEmpty()) {
return null;
}
// Create the bundled image from all of the constituent images.
BufferedImage bundledImage = drawBundledImage(arranger);
byte[] imageBytes = createImageBytes(logger, bundledImage);
return imageBytes;
}
private ImageRect addImage(TreeLogger logger, String imageName, URL imageUrl)
throws UnableToCompleteException, UnsuitableForStripException {
logger = logger.branch(TreeLogger.TRACE,
"Adding image '" + imageName + "'", null);
BufferedImage image = null;
// Load the image
try {
/*
* ImageIO uses an SPI pattern API. We don't care about the particulars of
* the implementation, so just choose the first ImageReader.
*/
MemoryCacheImageInputStream input = new MemoryCacheImageInputStream(
imageUrl.openStream());
Iterator<ImageReader> it = ImageIO.getImageReaders(input);
readers : while (it.hasNext()) {
ImageReader reader = it.next();
reader.setInput(input);
int numImages = reader.getNumImages(true);
if (numImages == 0) {
// Fall through
} else if (numImages == 1) {
try {
image = reader.read(0);
} catch (Exception e) {
// Hope we have another reader that can handle the image
continue readers;
}
} else {
// Read all contained images
BufferedImage[] images = new BufferedImage[numImages];
try {
for (int i = 0; i < numImages; i++) {
images[i] = reader.read(i);
}
} catch (Exception e) {
// Hope we have another reader that can handle the image
continue readers;
}
ImageRect rect = new ImageRect(imageName, images);
throw new UnsuitableForStripException(rect);
}
}
} catch (IllegalArgumentException iex) {
if (imageName.toLowerCase().endsWith("png")
&& iex.getMessage() != null
&& iex.getStackTrace()[0].getClassName().equals(
"javax.imageio.ImageTypeSpecifier$Indexed")) {
logger.log(TreeLogger.ERROR,
"Unable to read image. The image may not be in valid PNG format. "
+ "This problem may also be due to a bug in versions of the "
+ "JRE prior to 1.6. See "
+ "http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=5098176 "
+ "for more information. If this bug is the cause of the "
+ "error, try resaving the image using a different image "
+ "program, or upgrade to a newer JRE.", null);
throw new UnableToCompleteException();
} else {
throw iex;
}
} catch (IOException e) {
logger.log(TreeLogger.ERROR, "Unable to read image resource", e);
throw new UnableToCompleteException();
}
if (image == null) {
logger.log(TreeLogger.ERROR, "Unrecognized image file format", null);
throw new UnableToCompleteException();
}
ImageRect toReturn = new ImageRect(imageName, image);
if (toReturn.height > IMAGE_MAX_SIZE || toReturn.width > IMAGE_MAX_SIZE) {
throw new UnsuitableForStripException(toReturn);
}
return toReturn;
}
/**
* This method creates the bundled image through the composition of the other
* images.
*
* In this particular implementation, we use NFDHDW (see
* {@link #arrangeImages()}) to get an approximate optimal image packing.
*
* The most important aspect of drawing the bundled image is that it be drawn
* in a deterministic way. The drawing of the image should not rely on
* implementation details of the Generator system which may be subject to
* change.
*/
private BufferedImage drawBundledImage(Arranger arranger) {
/*
* There is no need to impose any order here, because arrangeImages will
* position the ImageRects in a deterministic fashion, even though we might
* paint them in a non-deterministic order.
*/
Collection<HasRect> imageRects = new LinkedList<HasRect>(
imageNameToImageRectMap.values());
// Arrange images and determine the size of the resulting bundle.
Size size = arranger.arrangeImages(imageRects);
// Create the bundled image.
BufferedImage bundledImage = new BufferedImage(size.width, size.height,
BufferedImage.TYPE_INT_ARGB_PRE);
Graphics2D g2d = bundledImage.createGraphics();
for (HasRect imageRect : imageRects) {
g2d.drawImage(imageRect.getImage(), imageRect.transform(), null);
}
g2d.dispose();
return bundledImage;
}
}