/* * $Id$ * * Copyright (c) 2009-2013 by Joel Uckelman * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Library General Public * License (LGPL) as published by the Free Software Foundation. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Library General Public License for more details. * * You should have received a copy of the GNU Library General Public * License along with this library; if not, copies are available * at http://www.opensource.org. */ package VASSAL.tools.image; import java.awt.Dimension; import java.awt.color.CMMException; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; import java.io.DataInputStream; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Collections; import java.util.Iterator; import java.util.Set; import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.imageio.stream.ImageInputStream; import javax.imageio.stream.MemoryCacheImageInputStream; import VASSAL.tools.io.IOUtils; import VASSAL.tools.io.RereadableInputStream; import VASSAL.tools.lang.Reference; /** * An image loader which wraps {@link ImageIO}. * * This class handles the assorted problems with various versions of * {@link ImageIO}, ensuring that we can reliably load image files to * {@link BufferedImages} with a predictable type. * * @since 3.1.0 * @author Joel Uckelman */ public class ImageIOImageLoader implements ImageLoader { protected final ImageTypeConverter tconv; /** * Create an image loader. * * @param tconv the <code>ImageTypeConverter</code> to use for type * conversions */ public ImageIOImageLoader(ImageTypeConverter tconv) { this.tconv = tconv; } // Used to indicate whether this version of Java has the PNG iTXt bug. // This can be removed once we no longer support Java 1.5. protected static final boolean iTXtBug; static { final String jvmver = System.getProperty("java.version"); iTXtBug = jvmver == null || jvmver.startsWith("1.5"); } protected static final Set<Integer> skip_iTXt = Collections.singleton(PNGDecoder.iTXt); // Used to indicate whether this version of Java has the JPEG color // correction bug. protected static final boolean YCbCrBug; static { BufferedImage img = null; InputStream in = null; try { // We intentionally bypass the normal image loading system // in order to see how ImageIO loads the test image. in = ImageIOImageLoader.class.getResourceAsStream("/images/black.jpg"); img = ImageIO.read(new MemoryCacheImageInputStream(in)); in.close(); } catch (IOException e) { // this should not happen throw new IllegalStateException(); } finally { IOUtils.closeQuietly(in); } if (img == null) { // this should not happen throw new IllegalStateException(); } // The pixel in the image is supposed to be black. If the pixel is // green, then ImageIO is misinterpreting the YCbCr data as RGB. If // the pixel is turquoise, then ImageIO is misinterpreting the color // in yet another way, which will also lead to same YCbCr mangling. // (So far the turquoise pixel happens only with 1.7.0_21 and 1.7.0_25 // JVMs on Linux...) final int pixel = img.getRGB(0,0); switch (pixel) { case 0xFF000000: YCbCrBug = false; break; case 0xFF008080: case 0xFF008700: YCbCrBug = true; break; default: // This JVM is broken in an unexpected way! throw new IllegalStateException( "Unexpected pixel value 0x" + String.format("%08x", pixel) ); } } // Workaround for Sun Bug 6986863: // // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6986863 // // java.awt.color.ICC_Profile.getInstance() is static but isn't thread // safe (!) and is called from JPEGImageReader.getWidth(). This means that // not only is JPEGImageReader.getWidth() not thread-safe, but it's not // even thread-safe across different instances of JPEGImageReader. Nobody // will ever be trying to load more than one JPEG at a time, right? WTF?! // // To mitigate this, we attempt to turn off ProfileDeferralMgr, which will // stop calls to ProfileDeferralMgr.activateProfiles(), which is where the // race happens. static { // Try to find the ProfileDeferralMgr. It's is not a public part of the // JDK and it changed packages at some point, so we look both places, // newer location first. Class <?> c = null; try { c = Class.forName("sun.java2d.cmm.ProfileDeferralMgr"); } catch (ClassNotFoundException e) { try { c = Class.forName("sun.awt.color.ProfileDeferralMgr"); } catch (ClassNotFoundException ignore) { // No ProfileDeferralMgr, so probably no bug either. } } if (c != null) { Field df = null; try { df = c.getField("deferring"); } catch (NoSuchFieldException ignore) { // Nothing we can do } if (df != null) { try { if (df.getBoolean(null)) { Method am = null; try { am = c.getMethod("activateProfiles"); } catch (NoSuchMethodException ignore) { // Nothing we can do } if (am != null) { try { am.invoke(null); df.setBoolean(null, false); } catch (InvocationTargetException ignore) { // Nothing we can do } } } } catch (IllegalAccessException ignore) { // Nothing we can do } } } } /** * Loads an image. * * @param name the image name * @param in the input stream * @param typeIfOpaque the requested image type for opaque images * @param typeIfTransparent the requested image type for transparent images * @param managed <code>true</code> if a managed image should be returned * @return the image * * @throws BrokenImageException if the image is faulty * @throws UnrecognizedImageTypeException if the image type is not recognized * @throws ImageIOException if reading the image goes wrong */ public BufferedImage load( String name, InputStream in, int typeIfOpaque, int typeIfTransparent, boolean managed ) throws ImageIOException { // // ImageIO fails on the following types of images: // // Sun Bug 6788458: 8-bit/channel color type 2 (RGB) PNGs with tRNS chunks // Sun Bug 6541476: PNGs with iTXt chunks on Java 1.5 // Sun Bug 6444360: JPEGs with corrupt color profiles // Sun Bug 6404011: JPEGs with corrupt color profiles on Java 1.5 // Sun Bug 4712797: YCbCr JPEGs with no JFIF marker // Sun Bug 4776576: YCbCr JPEFs with no JFIF marker // // http://bugs.sun.com/view_bug.do?bug_id=6788458 // http://bugs.sun.com/view_bug.do?bug_id=6541476 // http://bugs.sun.com/view_bug.do?bug_id=6444360 // http://bugs.sun.com/view_bug.do?bug_id=6404011 // http://bugs.sun.com/view_bug.do?bug_id=4712797 // http://bugs.sun.com/view_bug.do?bug_id=4776576 // // Someday, when both ImageIO is fixed and everyone's JRE contains // that fix, we can do this the simple way. // boolean fix_tRNS = false; int tRNS = 0x00000000; boolean fix_YCbCr = false; BufferedImage img = null; RereadableInputStream rin = null; try { rin = new RereadableInputStream(in); rin.mark(512); DataInputStream din = new DataInputStream(rin); // Is this a PNG? if (PNGDecoder.decodeSignature(din)) { // The PNG chunks refered to here are defined in the PNG // standard, found at http://www.w3.org/TR/PNG/ PNGDecoder.Chunk ch = PNGDecoder.decodeChunk(din); // Sanity check: This is not a PNG if IHDR is not the first chunk. if (ch.type == PNGDecoder.IHDR) { // At present, ImageIO does not honor the tRNS chunk in 8-bit color // type 2 (RGB) PNGs. This is not a bug per se, as the PNG standard // the does not require compliant decoders to use ancillary chunks. // However, every other PNG decoder we can find *does* honor the // tRNS chunk for this type of image, and so the appearance for // users is that VASSAL is broken when their 8-bit RGB PNGs don't // show the correct transparency. // We check for type-2 8-bit PNGs with tRNS chunks. if (ch.data[8] == 8 && ch.data[9] == 2) { // This is an 8-bit-per-channel Truecolor image; we must check // whether there is a tRNS chunk, and if so, record the color // so that we can manually set transparency later. // // IHDR is required to be first, and tRNS is required to appear // before the first IDAT chunk; therefore, if we find an IDAT // we're done. DONE_PNG: for (;;) { ch = PNGDecoder.decodeChunk(din); switch (ch.type) { case PNGDecoder.tRNS: fix_tRNS = true; break DONE_PNG; case PNGDecoder.IDAT: fix_tRNS = false; break DONE_PNG; default: } } if (fix_tRNS) { if (ch.data.length != 6) { // There is at least one piece of software (SplitImage) which // writes tRNS chunks for type 2 images which are only 3 bytes // long, and because this kind of thing is used by module // designers for slicing up scans of countersheets, we can // expect to see such crap from time to time. throw new BrokenImageException(name, "bad tRNS chunk length"); } // // tRNS chunk: PNG Standard, 11.3.2.1 // // tRNS data is stored as three 2-byte samples, but the high // byte of each sample is empty because we are dealing with // 8-bit-per-channel images. tRNS = 0xff000000 | ((ch.data[1] & 0xff) << 16) | ((ch.data[3] & 0xff) << 8) | (ch.data[5] & 0xff); } } if (iTXtBug) { // Filter out iTXt chunks on JVMs with the iTXt bug. rin.reset(); rin = new RereadableInputStream( new PNGChunkSkipInputStream(skip_iTXt, rin)); rin.mark(1); } } } else if (YCbCrBug) { rin.reset(); rin.mark(512); din = new DataInputStream(rin); // Is this a JPEG? if (JPEGDecoder.decodeSignature(din)) { // The case where ImageIO fails is when there is no JFIF marker, // no color profile, and three color components with the same // subsampling. In this case, ImageIO incorrectly assumes that // this image is RGB instead of YCbCr. JPEGDecoder.Chunk ch; fix_YCbCr = true; DONE_JPEG: for (;;) { ch = JPEGDecoder.decodeChunk(din); switch (ch.type) { case JPEGDecoder.SOF0: case JPEGDecoder.SOF1: case JPEGDecoder.SOF2: case JPEGDecoder.SOF3: case JPEGDecoder.SOF4: case JPEGDecoder.SOF5: case JPEGDecoder.SOF6: case JPEGDecoder.SOF7: case JPEGDecoder.SOF9: case JPEGDecoder.SOF10: case JPEGDecoder.SOF11: case JPEGDecoder.SOF12: case JPEGDecoder.SOF13: case JPEGDecoder.SOF14: case JPEGDecoder.SOF15: // The JPEG standard requires any APPn markers to appear before // the first SOF marker, so if we see an SOF marker, we know // there are no APPn markers to find. Hence, we can decide now // whether this JPEG triggers the bug. fix_YCbCr = ch.data.length == 15 && ch.data[5] == 3 && // color components ch.data[7] == ch.data[10] && ch.data[7] == ch.data[13]; break DONE_JPEG; case JPEGDecoder.APP0: if (ch.data.length >= 4 && ch.data[0] == 'J' && ch.data[1] == 'F' && ch.data[2] == 'I' && ch.data[3] == 'F') { // We've seen a JFIF, this image is ok. fix_YCbCr = false; break DONE_JPEG; } break; case JPEGDecoder.APP2: // Check whether we have a color profile. If so, then ImageIO // can handle decoding the image. if (ch.data.length >= 12 && ch.data[0] == 'I' && ch.data[1] == 'C' && ch.data[2] == 'C' && ch.data[3] == '_' && ch.data[4] == 'P' && ch.data[5] == 'R' && ch.data[6] == 'O' && ch.data[7] == 'F' && ch.data[8] == 'I' && ch.data[9] == 'L' && ch.data[10] == 'E' && ch.data[11] == 0x00) { // We have a color profile, this image is ok. fix_YCbCr = false; break DONE_JPEG; } break; case JPEGDecoder.APP13: case JPEGDecoder.APP14: // Created by Photoshop, this image is ok. fix_YCbCr = false; break DONE_JPEG; case JPEGDecoder.SOS: // We've reached a Start of Scan marker. Following this // is not a normal segment, but instead a lot of raw data. // This probably shouldn't happen with a valid JPEG. case JPEGDecoder.EOI: // We've reached the end. This probably shouldn't happen. break DONE_JPEG; default: } } } } // Load the image rin.reset(); img = wrapImageIO(name, rin, readImage); rin.close(); } catch (ImageIOException e) { // Don't wrap ImageIOExceptions. throw e; } catch (IOException e) { throw new ImageIOException(name, e); } finally { IOUtils.closeQuietly(rin); } final int type = img.getTransparency() == BufferedImage.OPAQUE && !fix_tRNS ? typeIfOpaque : typeIfTransparent; final Reference<BufferedImage> ref = new Reference<BufferedImage>(img); if (fix_tRNS) { // Fix up transparency in type 2 Truecolor images. img = null; img = fix_tRNS(ref, tRNS, type); ref.obj = img; } else if (fix_YCbCr) { // Fix up color space in misinterpreted JPEGs. img = null; img = fix_YCbCr(ref, type); ref.obj = img; } // We convert the image in two cases: // 1) the image is not yet the requested type, or // 2) a managed image was requested, but the image // was unmanaged by the transparency fix. if (img.getType() != type || (fix_tRNS && managed)) { img = null; img = tconv.convert(ref, type); } return img; } protected static interface Wrapper<T> { T run(String name, InputStream in) throws IOException; } protected <T> T wrapImageIO(String name, InputStream in, Wrapper<T> w) throws ImageIOException { try { return w.run(name, in); } catch (ArrayIndexOutOfBoundsException e) { // Note: ImageIO can throw an ArrayIndexOutOfBoundsException for // some corrupt JPEGs. This problem is noted in Sun Bug 6351707, // // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6351707 // throw new BrokenImageException(name, e); } catch (CMMException e) { // Note: ImageIO can throw a CMMException for JPEGs which have // broken color profiles. This problem is noted in Sun Bugs 6444360 // and 6839133. // // http://bugs.sun.com/view_bug.do?bug_id=6444360 // http://bugs.sun.com/view_bug.do?bug_id=6839133 // throw new BrokenImageException(name, e); } catch (IllegalArgumentException e) { // Note: ImageIO can throw IllegalArgumentExceptions for certain // kinds of broken images, e.g., JPEGs which are in the RGB color // space but have non-RGB color profiles (see Bug 2673589 for an // example of this). This problem is noted in Sun Bug 6404011, // // http://bugs.sun.com/view_bug.do?bug_id=6404011 // throw new BrokenImageException(name, e); } catch (ImageIOException e) { // Don't wrap ImageIOExceptions. throw e; } catch (IOException e) { throw new ImageIOException(name, e); } } /** A functor for reading images. */ protected static Wrapper<BufferedImage> readImage = new Wrapper<BufferedImage>() { /** * Loads an image. * * @param name the image name * @param in the input stream * @return the image * * @throws UnrecognizedImageTypeException if the image type is unknown * @throws IOException if reading the image goes wrong */ public BufferedImage run(String name, InputStream in) throws IOException { final BufferedImage img = ImageIO.read(new MemoryCacheImageInputStream(in)); if (img == null) throw new UnrecognizedImageTypeException(name); return img; } }; /** A functor for reading image dimensions. */ protected static Wrapper<Dimension> readSize = new Wrapper<Dimension>() { /** * Gets the size of an image. * * @param name the image name * @param in the input stream * @return the size of the image * * @throws BrokenImageException if the image is faulty * @throws UnrecognizedImageTypeException if the image type is unknown * @throws IOException if reading the image goes wrong */ public Dimension run(String name, InputStream in) throws IOException { final ImageInputStream stream = new MemoryCacheImageInputStream(in); final Iterator<ImageReader> i = ImageIO.getImageReaders(stream); if (!i.hasNext()) throw new UnrecognizedImageTypeException(name); final ImageReader reader = i.next(); try { reader.setInput(stream); return new Dimension(reader.getWidth(0), reader.getHeight(0)); } finally { reader.dispose(); } } }; protected BufferedImage fix_tRNS(Reference<BufferedImage> ref, int tRNS, int type) throws ImageIOException { BufferedImage img = ref.obj; // Ensure that we are working with integer ARGB data. Whether it's // premultiplied doesn't matter, since fully transparent black pixels // are the same in both. if (img.getType() != BufferedImage.TYPE_INT_ARGB && img.getType() != BufferedImage.TYPE_INT_ARGB_PRE) { // If the requested type is not an ARGB one, then we convert to ARGB // for applying this fix. if (type != BufferedImage.TYPE_INT_ARGB && type != BufferedImage.TYPE_INT_ARGB_PRE) { type = BufferedImage.TYPE_INT_ARGB; } img = null; img = tconv.convert(ref, type); } // NB: This unmanages the image. final DataBufferInt db = (DataBufferInt) img.getRaster().getDataBuffer(); final int[] data = db.getData(); // Set all pixels of the transparent color to have alpha 0. for (int i = 0; i < data.length; ++i) { if (data[i] == tRNS) data[i] = 0x00000000; } return img; } protected BufferedImage fix_YCbCr(Reference<BufferedImage> ref, int type) throws ImageIOException { BufferedImage img = ref.obj; // Ensure that we are working with RGB or ARGB data. if (img.getType() != BufferedImage.TYPE_INT_RGB && img.getType() != BufferedImage.TYPE_INT_ARGB) { if (type != BufferedImage.TYPE_INT_RGB && type != BufferedImage.TYPE_INT_ARGB) { type = BufferedImage.TYPE_INT_ARGB; } img = null; img = tconv.convert(ref, type); } // NB: This unmanages the image. final DataBufferInt db = (DataBufferInt) img.getRaster().getDataBuffer(); final int[] data = db.getData(); for (int i = 0; i < data.length; ++i) { final int y = (data[i] >> 16) & 0xFF; final int pb = ((data[i] >> 8) & 0xFF) - 128; final int pr = ( data[i] & 0xFF) - 128; final int a = (data[i] >> 24) & 0xFF; final int r = (int) Math.round(y + 1.402*pr); final int g = (int) Math.round(y - 0.34414*pb - 0.71414*pr); final int b = (int) Math.round(y + 1.772*pb); data[i] = (a << 24) | ((r < 0 ? 0 : (r > 0xFF ? 0xFF : r)) << 16) | ((g < 0 ? 0 : (g > 0xFF ? 0xFF : g)) << 8) | (b < 0 ? 0 : (b > 0xFF ? 0xFF : b)); } return img; } /** * Gets the size of an image. * * @param name the image name * @param in the input stream * @return the size of the image * * @throws BrokenImageException if the image is faulty * @throws UnrecognizedImageTypeException if the image type is not recognized * @throws ImageIOException if reading the image goes wrong */ public Dimension size(String name, InputStream in) throws ImageIOException { return wrapImageIO(name, in, readSize); } }