/*
* 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.core.shared.impl.StringCase;
import com.google.gwt.dev.util.log.PrintWriterTreeLogger;
import com.google.gwt.dev.util.log.speedtracer.CompilerEventType;
import com.google.gwt.dev.util.log.speedtracer.SpeedTracerLogger;
import org.w3c.dom.Node;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
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.metadata.IIOMetadata;
import javax.imageio.metadata.IIOMetadataFormatImpl;
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<ImageRect> 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<ImageRect> decreasingHeightComparator = new Comparator<ImageRect>() {
public int compare(ImageRect a, ImageRect 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<ImageRect> decreasingWidthComparator = new Comparator<ImageRect>() {
public int compare(ImageRect a, ImageRect 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<ImageRect> 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<ImageRect> rectsOrderedByHeight = new ArrayList<ImageRect>(
rects);
Collections.sort(rectsOrderedByHeight, decreasingHeightComparator);
// Create a list of ImageRects ordered by decreasing width used for
// packing
// individual columns.
final ArrayList<ImageRect> rectsOrderedByWidth = new ArrayList<ImageRect>(
rects);
Collections.sort(rectsOrderedByWidth, decreasingWidthComparator);
// Place the first, tallest image as the first column.
final ImageRect 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<ImageRect> rectsInColumn = new ArrayList<ImageRect>();
for (int j = i; j < n; j++) {
final ImageRect 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<ImageRect> rectsInColumn,
List<ImageRect> remainingRectsOrderedByWidth) {
final ImageRect 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 ImageRect 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 ImageRect 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();
}
}
}
/**
* 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<ImageRect> rects) {
int height = 1;
int width = 0;
for (ImageRect rect : rects) {
rect.setPosition(width, 0);
width += rect.getWidth();
height = lcm(height, rect.getHeight());
}
List<ImageRect> toAdd = new ArrayList<ImageRect>();
for (ImageRect 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<ImageRect> rects) {
int height = 0;
int width = 0;
for (ImageRect 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 {
private boolean hasBeenPositioned, lossy;
private int height, width;
private final int intrinsicHeight, intrinsicWidth;
private final BufferedImage[] images;
private int left, top;
private final String name;
private final AffineTransform transform = new AffineTransform();
/**
* Copy constructor.
*/
public ImageRect(ImageRect other) {
this.name = other.getName();
this.height = other.height;
this.width = other.width;
this.images = other.getImages();
this.left = other.getLeft();
this.top = other.getTop();
this.intrinsicHeight = other.intrinsicHeight;
this.intrinsicWidth = other.intrinsicWidth;
setTransform(other.getTransform());
}
public ImageRect(String name, BufferedImage... images) {
this.name = name;
this.images = images;
this.intrinsicWidth = images[0].getWidth();
this.intrinsicHeight = images[0].getHeight();
this.height = this.width = -1;
}
public int getHeight() {
return height > 0 ? height : intrinsicHeight;
}
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 > 0 ? width : intrinsicWidth;
}
public boolean hasBeenPositioned() {
return hasBeenPositioned;
}
public boolean isAnimated() {
return images.length > 1;
}
public boolean isLossy() {
return lossy;
}
public void setHeight(int height) {
this.height = height;
if (width <= 0) {
width = (int) Math.round((double) height / intrinsicHeight
* intrinsicWidth);
}
}
public void setLossy(boolean lossy) {
this.lossy = lossy;
}
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 void setWidth(int width) {
this.width = width;
if (height <= 0) {
height = (int) Math.round((double) width / intrinsicWidth
* intrinsicHeight);
}
}
public AffineTransform transform() {
AffineTransform toReturn = new AffineTransform();
// Translate
toReturn.translate(left, top);
// Scale
assert !(height > 0 ^ width > 0);
if (height > 0) {
toReturn.scale((double) height / intrinsicHeight, (double) width
/ intrinsicWidth);
}
// Use the base concatenation
toReturn.concatenate(transform);
assert checkTransform(toReturn);
return toReturn;
}
private boolean checkTransform(AffineTransform tx) {
double[] in = {0, 0, intrinsicWidth, intrinsicHeight};
double[] out = {0, 0, 0, 0};
tx.transform(in, 0, out, 0, 2);
// Sanity check on bounds
assert out[0] >= 0;
assert out[1] >= 0;
assert out[2] >= 0;
assert out[3] >= 0;
// Check scaling
assert getWidth() == Math.round(Math.abs(out[0] - out[2])) : "Width "
+ getWidth() + " != " + Math.round(Math.abs(out[0] - out[2]));
assert getHeight() == Math.round(Math.abs(out[1] - out[3])) : "Height "
+ getHeight() + "!=" + Math.round(Math.abs(out[1] - out[3]));
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<ImageRect> rects) {
int height = 0;
int width = 1;
for (ImageRect rect : rects) {
rect.setPosition(0, height);
width = lcm(width, rect.getWidth());
height += rect.getHeight();
}
List<ImageRect> toAdd = new ArrayList<ImageRect>();
for (ImageRect 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.toURI().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, ImageRect rect)
throws UnableToCompleteException {
// Create the bundled image.
BufferedImage bundledImage = new BufferedImage(rect.getWidth(),
rect.getHeight(), BufferedImage.TYPE_INT_ARGB_PRE);
SpeedTracerLogger.Event createGraphicsEvent =
SpeedTracerLogger.start(CompilerEventType.GRAPHICS_INIT,
"java.awt.headless", System.getProperty("java.awt.headless"));
Graphics2D g2d = bundledImage.createGraphics();
createGraphicsEvent.end();
setBetterRenderingQuality(g2d);
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();
boolean writerAvailable = ImageIO.write(bundledImage, BUNDLE_FILE_TYPE,
byteOutputStream);
if (!writerAvailable) {
logger.log(TreeLogger.ERROR, "No " + BUNDLE_FILE_TYPE
+ " writer available");
throw new UnableToCompleteException();
}
imageBytes = byteOutputStream.toByteArray();
} catch (IOException e) {
logger.log(TreeLogger.ERROR,
"An error occurred while trying to write the image bundle.", e);
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 static void setBetterRenderingQuality(Graphics2D g2d) {
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION,
RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
}
private final Map<String, ImageRect> imageNameToImageRectMap = new HashMap<String, ImageRect>();
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) {
// Assimilate the image into the composite.
rect = addImage(logger, imageName, resource);
imageNameToImageRectMap.put(imageName, rect);
}
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) {
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;
// Be safe by default and assume that the incoming image is lossy
boolean lossy = true;
// 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);
IIOMetadata metadata = reader.getImageMetadata(0);
if (metadata != null
&& metadata.isStandardMetadataFormatSupported()) {
// http://java.sun.com/j2se/1.5.0/docs/api/javax/imageio/metadata/doc-files/standard_metadata.html
Node data = metadata.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName);
metadata : for (int i = 0, j = data.getChildNodes().getLength(); i < j; i++) {
Node child = data.getChildNodes().item(i);
if (child.getLocalName().equalsIgnoreCase("compression")) {
for (int k = 0, l = child.getChildNodes().getLength(); k < l; k++) {
Node child2 = child.getChildNodes().item(k);
if (child2.getLocalName().equalsIgnoreCase("lossless")) {
Node value = child2.getAttributes().getNamedItem("value");
if (value == null) {
// The default is true, according to the DTD
lossy = false;
} else {
lossy = !Boolean.parseBoolean(value.getNodeValue());
}
break metadata;
}
}
}
}
}
} 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 (StringCase.toLower(imageName).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);
toReturn.setLossy(lossy);
// Don't composite the image if it's lossy or if it is too big
if (lossy || toReturn.getHeight() > IMAGE_MAX_SIZE
|| toReturn.getWidth() > 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<ImageRect> imageRects = new LinkedList<ImageRect>(
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);
SpeedTracerLogger.Event graphicsEvent = SpeedTracerLogger.start(CompilerEventType.GRAPHICS_INIT,
"java.awt.headless", System.getProperty("java.awt.headless"));
Graphics2D g2d = bundledImage.createGraphics();
setBetterRenderingQuality(g2d);
graphicsEvent.end();
for (ImageRect imageRect : imageRects) {
g2d.drawImage(imageRect.getImage(), imageRect.transform(), null);
}
g2d.dispose();
return bundledImage;
}
}