JPEGImageReader.java

/*
 * Copyright (c) 2011, Harald Kuhr
 * 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 copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * 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 HOLDER 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 com.twelvemonkeys.imageio.plugins.jpeg;

import com.twelvemonkeys.imageio.ImageReaderBase;
import com.twelvemonkeys.imageio.color.ColorProfiles;
import com.twelvemonkeys.imageio.color.ColorSpaces;
import com.twelvemonkeys.imageio.color.YCbCrConverter;
import com.twelvemonkeys.imageio.metadata.CompoundDirectory;
import com.twelvemonkeys.imageio.metadata.jpeg.JPEG;
import com.twelvemonkeys.imageio.metadata.jpeg.JPEGSegment;
import com.twelvemonkeys.imageio.metadata.jpeg.JPEGSegmentUtil;
import com.twelvemonkeys.imageio.metadata.tiff.TIFFReader;
import com.twelvemonkeys.imageio.stream.SubImageInputStream;
import com.twelvemonkeys.imageio.util.ImageTypeSpecifiers;
import com.twelvemonkeys.imageio.util.ProgressListenerBase;
import com.twelvemonkeys.lang.Validate;
import com.twelvemonkeys.xml.XMLSerializer;

import javax.imageio.*;
import javax.imageio.event.IIOReadUpdateListener;
import javax.imageio.event.IIOReadWarningListener;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.metadata.IIOMetadataFormatImpl;
import javax.imageio.metadata.IIOMetadataNode;
import javax.imageio.spi.ImageReaderSpi;
import javax.imageio.stream.ImageInputStream;
import java.awt.*;
import java.awt.color.ColorSpace;
import java.awt.color.ICC_ColorSpace;
import java.awt.color.ICC_Profile;
import java.awt.image.*;
import java.io.*;
import java.util.List;
import java.util.*;

/**
 * A JPEG {@code ImageReader} implementation based on the JRE {@code JPEGImageReader},
 * that adds support and properly handles cases where the JRE version throws exceptions.
 * <br>
 * Main features:
 * <ul>
 * <li>Support for YCbCr JPEGs without JFIF segment (converted to RGB, using the embedded ICC profile if applicable)</li>
 * <li>Support for CMYK JPEGs (converted to RGB by default or as CMYK, using the embedded ICC profile if applicable)</li>
 * <li>Support for Adobe YCCK JPEGs (converted to RGB by default or as CMYK, using the embedded ICC profile if applicable)</li>
 * <li>Support for JPEGs containing ICC profiles with interpretation other than 'Perceptual' (profile is assumed to be 'Perceptual' and used)</li>
 * <li>Support for JPEGs containing ICC profiles with class other than 'Display' (profile is assumed to have class 'Display' and used)</li>
 * <li>Support for JPEGs containing ICC profiles that are incompatible with stream data (image data is read, profile is ignored)</li>
 * <li>Support for JPEGs with corrupted ICC profiles (image data is read, profile is ignored)</li>
 * <li>Support for JPEGs with corrupted {@code ICC_PROFILE} segments (image data is read, profile is ignored)</li>
 * <li>Support for JPEGs using non-standard color spaces, unsupported by Java 2D (image data is read, profile is ignored)</li>
 * <li>Issues warnings instead of throwing exceptions in cases of corrupted data where ever the image data can still be read in a reasonable way</li>
 * </ul>
 * Thumbnail support:
 * <ul>
 * <li>Support for JFIF thumbnails (even if stream contains inconsistent metadata)</li>
 * <li>Support for JFXX thumbnails (JPEG, Indexed and RGB)</li>
 * <li>Support for EXIF thumbnails (JPEG, RGB and YCbCr)</li>
 * </ul>
 * Metadata support:
 * <ul>
 * <li>Support for JPEG metadata in both standard and native formats (even if stream contains inconsistent metadata)</li>
 * <li>Support for {@code javax_imageio_jpeg_image_1.0} format (currently as native format, may change in the future)</li>
 * <li>Support for illegal combinations of JFIF, Exif and Adobe markers, using "unknown" segments in the
 * "MarkerSequence" tag for the unsupported segments (for {@code javax_imageio_jpeg_image_1.0} format)</li>
 * </ul>
 *
 * @author <a href="mailto:harald.kuhr@gmail.com">Harald Kuhr</a>
 * @author LUT-based YCbCR conversion by Werner Randelshofer
 * @author last modified by $Author: haraldk$
 * @version $Id: JPEGImageReader.java,v 1.0 24.01.11 16.37 haraldk Exp$
 */
public final class JPEGImageReader extends ImageReaderBase {
    // TODO: Allow automatic rotation based on EXIF rotation field?
    // TODO: Create a simplified native metadata format that is closer to the actual JPEG stream AND supports EXIF in a sensible way
    // TODO: As we already parse the SOF segments, maybe we should stop delegating getWidth/getHeight etc?

    final static boolean DEBUG = "true".equalsIgnoreCase(System.getProperty("com.twelvemonkeys.imageio.plugins.jpeg.debug"));
    final static boolean FORCE_RASTER_CONVERSION = "force".equalsIgnoreCase(System.getProperty("com.twelvemonkeys.imageio.plugins.jpeg.raster"));

    /** Internal constant for referring all APP segments */
    static final int ALL_APP_MARKERS = -1;

    /** Our JPEG reading delegate */
    private final ImageReader delegate;

    /** Listens to progress updates in the delegate, and delegates back to this instance */
    private final ProgressDelegator progressDelegator;

    /** Extra delegate for reading JPEG encoded thumbnails */
    private ImageReader thumbnailReader;
    private List<ThumbnailReader> thumbnails;

    /** Cached list of JPEG segments we filter from the underlying stream */
    private List<Segment> segments;

    private int currentStreamIndex = 0;
    private final List<Long> streamOffsets = new ArrayList<>();

    JPEGImageReader(final ImageReaderSpi provider, final ImageReader delegate) {
        super(provider);

        this.delegate = Validate.notNull(delegate);
        progressDelegator = new ProgressDelegator();
    }

    private void installListeners() {
        delegate.addIIOReadProgressListener(progressDelegator);
        delegate.addIIOReadUpdateListener(progressDelegator);
        delegate.addIIOReadWarningListener(progressDelegator);
    }

    @Override
    protected void resetMembers() {
        delegate.reset();

        currentStreamIndex = 0;
        streamOffsets.clear();

        segments = null;
        thumbnails = null;

        if (thumbnailReader != null) {
            thumbnailReader.reset();
        }

        installListeners();
    }

    @Override
    public void dispose() {
        super.dispose();

        if (thumbnailReader != null) {
            thumbnailReader.dispose();
            thumbnailReader = null;
        }

        delegate.dispose();
    }

    @Override
    public String getFormatName() throws IOException {
        return delegate.getFormatName();
    }

    private boolean isLossless() throws IOException {
        assertInput();

        return getSOF().marker == JPEG.SOF3;
    }

    @Override
    public int getWidth(int imageIndex) throws IOException {
        checkBounds(imageIndex);
        initHeader(imageIndex);

        return getSOF().samplesPerLine;
    }

    @Override
    public int getHeight(int imageIndex) throws IOException {
        checkBounds(imageIndex);
        initHeader(imageIndex);

        return getSOF().lines;
    }

    @Override
    public Iterator<ImageTypeSpecifier> getImageTypes(int imageIndex) throws IOException {
        ImageTypeSpecifier rawImageType = getRawImageType(imageIndex);
        ColorModel rawColorModel = rawImageType.getColorModel();
        JPEGColorSpace sourceCSType = getSourceCSType(getJFIF(), getAdobeDCT(), getSOF());

        Set<ImageTypeSpecifier> types = new LinkedHashSet<>();

        if (rawColorModel.getColorSpace().getType() != ColorSpace.TYPE_GRAY) {
            // Add the standard types, we can always convert to these, except for gray
            if (rawColorModel.hasAlpha()) {
                types.add(ImageTypeSpecifiers.createFromBufferedImageType(BufferedImage.TYPE_INT_ARGB));
                types.add(ImageTypeSpecifiers.createFromBufferedImageType(BufferedImage.TYPE_4BYTE_ABGR));
                types.add(ImageTypeSpecifiers.createFromBufferedImageType(BufferedImage.TYPE_INT_ARGB_PRE));
                types.add(ImageTypeSpecifiers.createFromBufferedImageType(BufferedImage.TYPE_4BYTE_ABGR_PRE));
            }

            types.add(ImageTypeSpecifiers.createFromBufferedImageType(BufferedImage.TYPE_3BYTE_BGR));
            types.add(ImageTypeSpecifiers.createFromBufferedImageType(BufferedImage.TYPE_INT_RGB));
            types.add(ImageTypeSpecifiers.createFromBufferedImageType(BufferedImage.TYPE_INT_BGR));
        }

        types.add(rawImageType);

        // If the source type has a luminance (Y) component, we can also convert to gray
        if (sourceCSType != JPEGColorSpace.RGB && sourceCSType != JPEGColorSpace.RGBA && sourceCSType != JPEGColorSpace.CMYK) {
            if (rawColorModel.hasAlpha()) {
                types.add(ImageTypeSpecifiers.createGrayscale(8, DataBuffer.TYPE_BYTE, false));
            }

            types.add(ImageTypeSpecifiers.createFromBufferedImageType(BufferedImage.TYPE_BYTE_GRAY));
        }

        return types.iterator();
    }

    @Override
    public ImageTypeSpecifier getRawImageType(int imageIndex) throws IOException {
        checkBounds(imageIndex);
        initHeader(imageIndex);

        // Consult the image metadata
        JPEGColorSpace csType = getSourceCSType(getJFIF(), getAdobeDCT(), getSOF());
        ICC_Profile profile = getEmbeddedICCProfile(false);

        ColorSpace cs;
        boolean hasAlpha = false;

        switch (csType) {
            case GrayA:
                hasAlpha = true;
            case Gray:
                // Create based on embedded profile if exists, otherwise create from Gray
                cs = profile != null && profile.getNumComponents() == 1
                     ? ColorSpaces.createColorSpace(profile)
                     : ColorSpaces.getColorSpace(ColorSpace.CS_GRAY);
                return ImageTypeSpecifiers.createInterleaved(cs, hasAlpha ? new int[] {1, 0} : new int[] {0}, DataBuffer.TYPE_BYTE, hasAlpha, false);

            case YCbCrA:
            case RGBA:
            case PhotoYCCA:
                hasAlpha = true;
            case YCbCr:
            case RGB:
            case PhotoYCC:
                // Create based on PhotoYCC profile...
                if (csType == JPEGColorSpace.PhotoYCC || csType == JPEGColorSpace.PhotoYCCA) {
                    cs = ColorSpaces.getColorSpace(ColorSpace.CS_PYCC);
                }
                else {
                    // ...or create based on embedded profile if exists, otherwise create from sRGB
                    cs = profile != null && profile.getNumComponents() == 3
                         ? ColorSpaces.createColorSpace(profile)
                         : ColorSpaces.getColorSpace(ColorSpace.CS_sRGB);
                }

                return ImageTypeSpecifiers.createInterleaved(cs, hasAlpha ? new int[] {3, 2, 1, 0} : new int[] {2, 1, 0}, DataBuffer.TYPE_BYTE, hasAlpha, false);

            case YCCK:
            case CMYK:
                // Create based on embedded profile if exists, otherwise create from "Generic CMYK"
                cs = profile != null && profile.getNumComponents() == 4
                     ? ColorSpaces.createColorSpace(profile)
                     : ColorSpaces.getColorSpace(ColorSpaces.CS_GENERIC_CMYK);

                return ImageTypeSpecifiers.createInterleaved(cs, new int[] {3, 2, 1, 0}, DataBuffer.TYPE_BYTE, false, false);

            default:
                // For other types, we probably can't give a proper type
                throw new IIOException("Could not determine JPEG source color space");
        }
    }

    @Override
    public BufferedImage read(int imageIndex, ImageReadParam param) throws IOException {
        checkBounds(imageIndex);
        initHeader(imageIndex);

        Frame sof = getSOF();
        ICC_Profile profile = getEmbeddedICCProfile(false);
        AdobeDCT adobeDCT = getAdobeDCT();
        boolean bogusAdobeDCT = false;

        if (adobeDCT != null && (adobeDCT.transform == AdobeDCT.YCC && sof.componentsInFrame() != 3 ||
                adobeDCT.transform == AdobeDCT.YCCK && sof.componentsInFrame() != 4)) {
            processWarningOccurred(String.format(
                    "Invalid Adobe App14 marker. Indicates %s data, but SOF%d has %d color component(s). " +
                            "Ignoring Adobe App14 marker.",
                    adobeDCT.transform == AdobeDCT.YCCK ? "YCCK/CMYK" : "YCC/RGB",
                    sof.marker & 0xf, sof.componentsInFrame()
            ));

            bogusAdobeDCT = true;
            adobeDCT = null;
        }

        JFIF jfif = getJFIF();
        JPEGColorSpace sourceCSType = getSourceCSType(jfif, adobeDCT, sof);

        if (sof.marker == JPEG.SOF3) {
            // Read image as lossless
            if (DEBUG) {
                System.out.println("Reading using Lossless decoder");
            }

            // TODO: What about stream position?
            // TODO: Param handling: Source region, offset, subsampling, destination, destination type, etc....
            BufferedImage bufferedImage = new JPEGLosslessDecoderWrapper(this).readImage(segments, imageInput);

            // TODO: This is QnD, move param handling to lossless wrapper
            // TODO: Create test!
            BufferedImage destination = param != null ? param.getDestination() : null;
            if (destination != null) {
                destination.getRaster().setDataElements(0, 0, bufferedImage.getRaster());
                return destination;
            }

            return bufferedImage;
        }

        // We need to apply ICC profile unless the profile is sRGB/default gray (whatever that is)
        // - or only filter out the bad ICC profiles in the JPEGSegmentImageInputStream.
        else if (FORCE_RASTER_CONVERSION || bogusAdobeDCT
                || profile != null && !ColorProfiles.isCS_sRGB(profile)
                || (long) sof.lines * sof.samplesPerLine > Integer.MAX_VALUE
                || delegateCSTypeMismatch(jfif, adobeDCT, sof, sourceCSType)) {
            if (DEBUG) {
                System.out.println("Reading using raster and extra conversion");
                System.out.println("ICC color profile: " + profile);
            }

            return readImageAsRasterAndReplaceColorProfile(imageIndex, param, sof, sourceCSType, profile);
        }

        if (DEBUG) {
            System.out.println("Reading using delegate");
        }

        return delegate.read(0, param);
    }

    private boolean delegateCSTypeMismatch(final JFIF jfif, final AdobeDCT adobeDCT, final Frame startOfFrame, final JPEGColorSpace sourceCSType) throws IOException {
        switch (sourceCSType) {
            case GrayA:
            case RGBA:
            case YCbCrA:
            case PhotoYCC:
            case PhotoYCCA:
            case CMYK:
            case YCCK:
                // These are no longer supported by the delegate, we'll handle ourselves
                return true;
        }

        try {
            ImageTypeSpecifier rawImageType = delegate.getRawImageType(0);

            switch (sourceCSType) {
                case Gray:
                    return rawImageType == null || rawImageType.getColorModel().getColorSpace().getType() != ColorSpace.TYPE_GRAY;
                case YCbCr:
                    // NOTE: For backwards compatibility, null is allowed for YCbCr
                    if (rawImageType == null) {
                        return false;
                    }

                    //  If We have a JFIF, but with non-standard component Ids, the standard reader mistakes it for RGB
                    if (jfif != null && (startOfFrame.components[0].id != 1 || startOfFrame.components[1].id != 2 || startOfFrame.components[2].id != 3)) {
                        return true;
                    }
                    // Else, if we have no Adobe marker and no subsampling, the standard reader mistakes it for RGB
                    else if (adobeDCT == null
                            && (startOfFrame.components[0].id != 1 || startOfFrame.components[1].id != 2 || startOfFrame.components[2].id != 3)
                            && (startOfFrame.components[0].hSub == 1 || startOfFrame.components[0].vSub == 1
                            || startOfFrame.components[1].hSub == 1 || startOfFrame.components[1].vSub == 1
                            || startOfFrame.components[2].hSub == 1 || startOfFrame.components[2].vSub == 1)) {
                        return true;
                    }
                case RGB:
                    return rawImageType == null || rawImageType.getColorModel().getColorSpace().getType() != ColorSpace.TYPE_RGB;
                default:
                    // Probably needs special handling, but we don't know what to do...
                    return false;
            }
        }
        catch (IIOException | NullPointerException | ArrayIndexOutOfBoundsException | NegativeArraySizeException ignore) {
            // An exception here is a clear indicator we need to handle conversion
            return true;
        }
    }

    private BufferedImage readImageAsRasterAndReplaceColorProfile(int imageIndex, ImageReadParam param, Frame startOfFrame, JPEGColorSpace csType, ICC_Profile profile) throws IOException {
        int origWidth = getWidth(imageIndex);
        int origHeight = getHeight(imageIndex);

        Iterator<ImageTypeSpecifier> imageTypes = getImageTypes(imageIndex);
        // TODO: Avoid creating destination here, if possible (as it saves time and memory)
        // If YCbCr or RGB, we could instead create a BufferedImage around the converted raster directly.
        // If YCCK or CMYK, we could instead create a BufferedImage around the converted raster,
        // leaving the fourth band as alpha (or pretend it's not there, by creating a child raster).
        BufferedImage image = getDestination(param, imageTypes, origWidth, origHeight);
        WritableRaster destination = image.getRaster();

        // TODO: checkReadParamBandSettings(param, );

        RasterOp convert = null;
        ICC_ColorSpace intendedCS = profile != null ? ColorSpaces.createColorSpace(profile) : null;

        if (destination.getNumBands() <= 2 && (csType != JPEGColorSpace.Gray && csType != JPEGColorSpace.GrayA)) {
            convert = new LuminanceToGray();
        }
        else if (profile != null && (csType == JPEGColorSpace.Gray || csType == JPEGColorSpace.GrayA)) {
            // com.sun. reader does not do ColorConvertOp for CS_GRAY, even if embedded ICC profile,
            // probably because IJG native part does it already...? If applied, color looks wrong (too dark)...
//            convert = new ColorConvertOp(intendedCS, image.getColorModel().getColorSpace(), null);
        }
        else if (intendedCS != null) {
            // Handle inconsistencies
            if (startOfFrame.componentsInFrame() != intendedCS.getNumComponents()) {
                // If ICC profile number of components and startOfFrame does not match, ignore ICC profile
                processWarningOccurred(String.format("Embedded ICC color profile is incompatible with image data. " +
                                "Profile indicates %d components, but SOF%d has %d color components. " +
                                "Ignoring ICC profile, assuming source color space %s.",
                        intendedCS.getNumComponents(), startOfFrame.marker & 0xf, startOfFrame.componentsInFrame(), csType
                ));

                if (csType == JPEGColorSpace.CMYK && image.getColorModel().getColorSpace().getType() != ColorSpace.TYPE_CMYK) {
                    convert = new ColorConvertOp(ColorSpaces.getColorSpace(ColorSpaces.CS_GENERIC_CMYK), image.getColorModel().getColorSpace(), null);
                }
            }
            // NOTE: Avoid using CCOp if same color space, as it's more compatible that way
            else if (intendedCS != image.getColorModel().getColorSpace()) {
                if (DEBUG) {
                    System.err.println("Converting from " + intendedCS + " to " + (image.getColorModel().getColorSpace().isCS_sRGB() ? "sRGB" : image.getColorModel().getColorSpace()));
                }

                convert = new ColorConvertOp(intendedCS, image.getColorModel().getColorSpace(), null);
            }
            // Else, pass through with no conversion
        }
        else if (csType == JPEGColorSpace.YCCK || csType == JPEGColorSpace.CMYK) {
            ColorSpace cmykCS = ColorSpaces.getColorSpace(ColorSpaces.CS_GENERIC_CMYK);

            if (cmykCS instanceof ICC_ColorSpace) {
                processWarningOccurred("No embedded ICC color profile, defaulting to \"generic\" CMYK ICC profile. Colors may look incorrect.");

                // NOTE: Avoid using CCOp if same color space, as it's more compatible that way
                if (cmykCS != image.getColorModel().getColorSpace()) {
                    convert = new ColorConvertOp(cmykCS, image.getColorModel().getColorSpace(), null);
                }
            }
            else {
                // ColorConvertOp using non-ICC CS is deadly slow, fall back to fast conversion instead
                processWarningOccurred("No embedded ICC color profile, will convert using inaccurate CMYK to RGB conversion. Colors may look incorrect.");

                convert = new FastCMYKToRGB();
            }
        }

        // We'll need a read param
        if (param == null) {
            param = delegate.getDefaultReadParam();
        }

        Rectangle origSourceRegion = param.getSourceRegion();

        Rectangle srcRegion = new Rectangle();
        Rectangle dstRegion = new Rectangle();
        computeRegions(param, origWidth, origHeight, image, srcRegion, dstRegion);

        // Need to undo the subsampling offset translations, as they are applied again in delegate.readRaster
        int gridX = param.getSubsamplingXOffset();
        int gridY = param.getSubsamplingYOffset();
        srcRegion.translate(-gridX, -gridY);
        srcRegion.width += gridX;
        srcRegion.height += gridY;

        // Unfortunately, reading the image in steps, is increasingly slower
        // for each iteration, so we'll read all at once.
        try {
            param.setSourceRegion(srcRegion);
            Raster raster = delegate.readRaster(0, param); // non-converted

            // Apply source color conversion from implicit color space
            if (csType == JPEGColorSpace.YCbCr) {
                convertYCbCr2RGB(raster, 3);
            }
            else if (csType == JPEGColorSpace.YCbCrA) {
                convertYCbCr2RGB(raster, 4);
            }
            else if (csType == JPEGColorSpace.YCCK) {
                // TODO: Need to rethink this (non-) inversion, see #147
                // TODO: Allow param to specify inversion, or possibly the PDF decode array
                // flag0 bit 15, blend = 1 see http://graphicdesign.stackexchange.com/questions/12894/cmyk-jpegs-extracted-from-pdf-appear-inverted
                convertYCCK2CMYK(raster);
            }
            else if (csType == JPEGColorSpace.CMYK) {
                invertCMYK(raster);
            }
            // ...else assume the raster is already converted

            WritableRaster dest = destination.createWritableChild(dstRegion.x, dstRegion.y, raster.getWidth(), raster.getHeight(), 0, 0, param.getDestinationBands());

            // Apply further color conversion for explicit color space, or just copy the pixels into place
            if (convert != null) {
                convert.filter(raster, dest);
            }
            else {
                dest.setRect(0, 0, raster);
            }
        }
        finally {
            // NOTE: Would be cleaner to clone the param, unfortunately it can't be done easily...
            param.setSourceRegion(origSourceRegion);
        }

        return image;
    }

    static JPEGColorSpace getSourceCSType(final JFIF jfif, final AdobeDCT adobeDCT, final Frame startOfFrame) throws IIOException {
        // Adapted from libjpeg jdapimin.c:
        // Guess the input colorspace
        // (Wish JPEG committee had provided a real way to specify this...)
        switch (startOfFrame.componentsInFrame()) {
            case 1:
                return JPEGColorSpace.Gray;
            case 2:
                return JPEGColorSpace.GrayA; // Java special case: Gray + Alpha
            case 3:
                if (jfif != null) {
                    return JPEGColorSpace.YCbCr; // JFIF implies YCbCr
                }
                else if (adobeDCT != null) {
                    switch (adobeDCT.transform) {
                        case AdobeDCT.Unknown:
                            return JPEGColorSpace.RGB;
                        default:
                            // TODO: Warning!
                        case AdobeDCT.YCC:
                            return JPEGColorSpace.YCbCr; // assume it's YCbCr
                    }
                }
                else {
                    // Saw no special markers, try to guess from the component IDs
                    int cid0 = startOfFrame.components[0].id;
                    int cid1 = startOfFrame.components[1].id;
                    int cid2 = startOfFrame.components[2].id;

                    if (cid0 == 1 && cid1 == 2 && cid2 == 3) {
                        return JPEGColorSpace.YCbCr; // assume JFIF w/out marker
                    }
                    else if (cid0 == 'R' && cid1 == 'G' && cid2 == 'B') {
                        return JPEGColorSpace.RGB; // ASCII 'R', 'G', 'B'
                    }
                    else if (cid0 == 'Y' && cid1 == 'C' && cid2 == 'c') {
                        return JPEGColorSpace.PhotoYCC; // Java special case: YCc
                    }
                    else {
                        // TODO: Warning!
                        return JPEGColorSpace.YCbCr; // assume it's YCbCr
                    }
                }

            case 4:
                if (adobeDCT != null) {
                    switch (adobeDCT.transform) {
                        case AdobeDCT.Unknown:
                            return JPEGColorSpace.CMYK;
                        default:
                            // TODO: Warning!
                        case AdobeDCT.YCCK:
                            return JPEGColorSpace.YCCK; // assume it's YCCK
                    }
                }
                else {
                    // Saw no special markers, try to guess from the component IDs
                    int cid0 = startOfFrame.components[0].id;
                    int cid1 = startOfFrame.components[1].id;
                    int cid2 = startOfFrame.components[2].id;
                    int cid3 = startOfFrame.components[3].id;

                    if (cid0 == 1 && cid1 == 2 && cid2 == 3 && cid3 == 4) {
                        return JPEGColorSpace.YCbCrA; // Java special case: YCbCrA
                    }
                    else if (cid0 == 'R' && cid1 == 'G' && cid2 == 'B' && cid3 == 'A') {
                        return JPEGColorSpace.RGBA; // Java special case: RGBA
                    }
                    else if (cid0 == 'Y' && cid1 == 'C' && cid2 == 'c' && cid3 == 'A') {
                        return JPEGColorSpace.PhotoYCCA; // Java special case: YCcA
                    }
                    else {
                        // TODO: Warning!
                        // No special markers, assume straight CMYK.
                        return JPEGColorSpace.CMYK;
                    }
                }

            default:
                throw new IIOException("Cannot determine source color space");
        }
    }

    private ICC_Profile ensureDisplayProfile(final ICC_Profile profile) throws IOException {
        // NOTE: This is probably not the right way to do it... :-P
        // TODO: Consider moving method to ColorSpaces class or new class in imageio.color package

        // NOTE: Workaround for the ColorConvertOp treating the input as relative colorimetric,
        // if the FIRST profile has class OUTPUT, regardless of the actual rendering intent in that profile...
        // See ColorConvertOp#filter(Raster, WritableRaster)

        if (profile != null && profile.getProfileClass() != ICC_Profile.CLASS_DISPLAY) {
            byte[] profileData = profile.getData(); // Need to clone entire profile, due to a OpenJDK bug

            if (profileData[ICC_Profile.icHdrRenderingIntent] == ICC_Profile.icPerceptual) {
                processWarningOccurred("ICC profile is Perceptual, ignoring, treating as Display class");

                intToBigEndian(ICC_Profile.icSigDisplayClass, profileData, ICC_Profile.icHdrDeviceClass); // Header is first

                return ColorProfiles.createProfile(profileData);
            }
        }

        return profile;
    }

    // TODO: Move to some common util
    static int intFromBigEndian(final byte[] array, final int index) {
        return ((array[index     ] & 0xff) << 24) |
                ((array[index + 1] & 0xff) << 16) |
                ((array[index + 2] & 0xff) <<  8) |
                ((array[index + 3] & 0xff)      );
    }

    // TODO: Move to some common util
    static void intToBigEndian(final int value, final byte[] array, final int index) {
        array[index    ] = (byte) (value >> 24);
        array[index + 1] = (byte) (value >> 16);
        array[index + 2] = (byte) (value >>  8);
        array[index + 3] = (byte) (value      );
    }

    @Override
    public void setInput(Object input, boolean seekForwardOnly, boolean ignoreMetadata) {
        super.setInput(input, seekForwardOnly, ignoreMetadata);

        try {
            if (imageInput != null) {
                // Need to wrap stream to avoid messing with the byte order of the underlying stream
                // in the case we are operating as a delegate for ie. TIFFImageReader.
                if (!(imageInput instanceof SubImageInputStream)) {
                    imageInput = new SubImageInputStream(imageInput, Long.MAX_VALUE);
                }

                streamOffsets.add(imageInput.getStreamPosition());
            }

            initDelegate(seekForwardOnly, ignoreMetadata);
        }
        catch (IOException e) {
            // TODO: This should ideally be reported as an IOException, but I don't see how
            throw new IllegalStateException(e.getMessage(), e);
        }
    }

    private void initDelegate(boolean seekForwardOnly, boolean ignoreMetadata) throws IOException {
        // JPEGSegmentImageInputStream that filters out/skips bad/unnecessary segments
        delegate.setInput(imageInput != null
                          ? new JPEGSegmentImageInputStream(imageInput, new JPEGSegmentWarningDelegate())
                          : null, seekForwardOnly, ignoreMetadata);
    }

    private void initHeader() throws IOException {
        if (segments == null) {
            long start = DEBUG ? System.currentTimeMillis() : 0;

            // TODO: Consider just reading the segments directly, for better performance...
            List<JPEGSegment> jpegSegments = readSegments();

            List<Segment> segments = new ArrayList<>(jpegSegments.size());

            for (JPEGSegment segment : jpegSegments) {
                try (DataInputStream data = new DataInputStream(segment.segmentData())) {
                    segments.add(Segment.read(segment.marker(), segment.identifier(), segment.segmentLength(), data));
                }
                catch (IOException e) {
                    // TODO: Handle bad segments better, for now, just ignore any bad APP markers
                    if (segment.marker() >= JPEG.APP0 && JPEG.APP15 >= segment.marker()) {
                        processWarningOccurred("Bogus APP" + (segment.marker() & 0x0f) + "/" + segment.identifier() + " segment, ignoring");
                        continue;
                    }

                    throw e;
                }
            }

            this.segments = segments;

            if (DEBUG) {
                System.out.println("segments: " + segments);
                System.out.println("Read metadata in " + (System.currentTimeMillis() - start) + " ms");
            }
        }
    }

    private void initHeader(final int imageIndex) throws IOException {
        assertInput();
        if (imageIndex < 0) {
            throw new IndexOutOfBoundsException("imageIndex < 0: " + imageIndex);
        }

        if (imageIndex == currentStreamIndex) {
            initHeader();
            return;
        }

        gotoImage(imageIndex);

        // Reset segments and re-init the header
        segments = null;
        thumbnails = null;

        initDelegate(seekForwardOnly, ignoreMetadata);
        initHeader();
    }

    private void gotoImage(final int imageIndex) throws IOException {
        if (imageIndex < streamOffsets.size()) {
            imageInput.seek(streamOffsets.get(imageIndex));
        }
        else {
            long lastKnownSOIOffset = streamOffsets.get(streamOffsets.size() - 1);
            imageInput.seek(lastKnownSOIOffset);

            try {
                for (int i = streamOffsets.size() - 1; i < imageIndex; i++) {
                    long start = 0;

                    if (DEBUG) {
                        start = System.currentTimeMillis();
                        System.out.printf("Start seeking for image index %d%n", i + 1);
                    }

                    // Need to skip over segments, as they may contain JPEG markers (eg. JFXX or EXIF thumbnail)
                    JPEGSegmentUtil.readSegments(imageInput, Collections.<Integer, List<String>>emptyMap());

                    // Now, search for EOI and following SOI...
                    int marker;
                    while ((marker = imageInput.read()) != -1) {
                        if (marker == 0xFF && (0xFF00 | imageInput.readUnsignedByte()) == JPEG.EOI) {
                            // Found EOI, now the SOI should be nearby...
                            while ((marker = imageInput.read()) != -1) {
                                if (marker == 0xFF && (0xFF00 | imageInput.readUnsignedByte()) == JPEG.SOI) {
                                    long nextSOIOffset = imageInput.getStreamPosition() - 2;
                                    imageInput.seek(nextSOIOffset);
                                    streamOffsets.add(nextSOIOffset);

                                    break;
                                }
                            }

                            // ...or we may have missed it, but at least we tried
                            break;
                        }
                    }

                    if (DEBUG) {
                        System.out.printf("Seek in %d ms%n", System.currentTimeMillis() - start);
                    }
                }
            }
            catch (EOFException eof) {
                IndexOutOfBoundsException ioobe = new IndexOutOfBoundsException("Image index " + imageIndex + " not found in stream");
                ioobe.initCause(eof);
                throw ioobe;
            }

            if (imageIndex >= streamOffsets.size()) {
                throw new IndexOutOfBoundsException("Image index " + imageIndex + " not found in stream");
            }
        }

        currentStreamIndex = imageIndex;
    }

    @Override
    public int getNumImages(boolean allowSearch) throws IOException {
        assertInput();

        if (allowSearch) {
            if (seekForwardOnly) {
                throw new IllegalStateException("seekForwardOnly and allowSearch are both true");
            }

            int index = 0;
            int count = 0;
            while (true) {
                try {
                    gotoImage(index++);
                }
                catch (IndexOutOfBoundsException e) {
                    break;
                }

                // TODO: We should probably optimize this
                try {
                    segments = null;
                    getSOF(); // No SOF, no image
                    count++;
                }
                catch (IIOException ignore) {}
            }

            imageInput.seek(streamOffsets.get(currentStreamIndex));

            return count;
        }

        // We can't possibly know without searching
        return -1;
    }

    private List<JPEGSegment> readSegments() throws IOException {
        imageInput.mark();

        try {
            imageInput.seek(streamOffsets.get(currentStreamIndex));

            return JPEGSegmentUtil.readSegments(imageInput, JPEGSegmentUtil.ALL_SEGMENTS);
        }
        catch (IIOException | IllegalArgumentException e) {
            if (DEBUG) {
                e.printStackTrace();
            }
        }
        finally {
            imageInput.reset();
        }

        // In case of an exception, avoid NPE when referencing segments later
        return Collections.emptyList();
    }

    List<Application> getAppSegments(final int marker, final String identifier) throws IOException {
        initHeader();

        List<Application> appSegments = Collections.emptyList();

        for (Segment segment : segments) {
            if (segment instanceof Application
                    && (marker == ALL_APP_MARKERS || marker == segment.marker)
                    && (identifier == null || identifier.equals(((Application) segment).identifier))) {
                if (appSegments == Collections.EMPTY_LIST) {
                    appSegments = new ArrayList<>(segments.size());
                }

                appSegments.add((Application) segment);
            }
        }

        return appSegments;
    }

    Frame getSOF() throws IOException {
        initHeader();

        for (Segment segment : segments) {
            if (segment instanceof Frame) {
                return (Frame) segment;
            }
        }

        throw new IIOException("No SOF segment in stream");
    }

    private Application lastAppSegment(int marker, String identifier) throws IOException {
        List<Application> appSegments = getAppSegments(marker, identifier);
        return appSegments.isEmpty() ? null : appSegments.get(appSegments.size() - 1);
    }

    AdobeDCT getAdobeDCT() throws IOException {
        return (AdobeDCT) lastAppSegment(JPEG.APP14, "Adobe");
    }

    JFIF getJFIF() throws IOException{
        return (JFIF) lastAppSegment(JPEG.APP0, "JFIF");
    }

    JFXX getJFXX() throws IOException {
        return (JFXX) lastAppSegment(JPEG.APP0, "JFXX");
    }

    private EXIF getExif() throws IOException {
        List<Application> exif = getAppSegments(JPEG.APP1, "Exif");
        return exif.isEmpty() ? null : (EXIF) exif.get(0); // TODO: Can there actually be more Exif segments?
    }

    private CompoundDirectory parseExif(final EXIF exif) throws IOException {
        if (exif != null) {
            // Identifier is "Exif\0" + 1 byte pad
            if (exif.data.length > exif.identifier.length() + 2) {
                try (ImageInputStream stream = exif.exifData()) {
                    return (CompoundDirectory) new TIFFReader().read(stream);
                }
                catch (IIOException e) {
                    processWarningOccurred("Exif chunk is present, but can't be read: " + e.getMessage());
                }
            }
            else {
                processWarningOccurred("Exif chunk has no data.");
            }
        }

        return null;
    }

    ICC_Profile getEmbeddedICCProfile(final boolean allowBadIndexes) throws IOException {
        // ICC v 1.42 (2006) annex B:
        // APP2 marker (0xFFE2) + 2 byte length + ASCII 'ICC_PROFILE' + 0 (termination)
        // + 1 byte chunk number + 1 byte chunk count (allows ICC profiles chunked in multiple APP2 segments)

        // TODO: Allow metadata to contain the wrongly indexed profiles, if readable
        // NOTE: We ignore any profile with wrong index for reading and image types, just to be on the safe side

        List<Application> segments = getAppSegments(JPEG.APP2, "ICC_PROFILE");

        // TODO: Possibly move this logic to the ICCProfile class...

        if (segments.size() == 1) {
            // Faster code for the common case
            Application segment = segments.get(0);
            DataInputStream stream = new DataInputStream(segment.data());
            int chunkNumber = stream.readUnsignedByte();
            int chunkCount = stream.readUnsignedByte();

            if (chunkNumber != 1 && chunkCount != 1) {
                processWarningOccurred(String.format("Unexpected number of 'ICC_PROFILE' chunks: %d of %d. Ignoring ICC profile.", chunkNumber, chunkCount));
                return null;
            }

            return readICCProfileSafe(stream, allowBadIndexes);
        }
        else if (!segments.isEmpty()) {
            // NOTE: This is probably over-complicated, as I've never encountered ICC_PROFILE chunks out of order...
            DataInputStream stream = new DataInputStream(segments.get(0).data());
            int chunkNumber = stream.readUnsignedByte();
            int chunkCount = stream.readUnsignedByte();

            // TODO: Most of the time the ICC profiles are readable and should be obtainable from metadata...
            boolean badICC = false;
            if (chunkCount != segments.size()) {
                // Some weird JPEGs use 0-based indexes... count == 0 and all numbers == 0.
                // Others use count == 1, and all numbers == 1.
                // Handle these by issuing warning
                processWarningOccurred(String.format("Bad 'ICC_PROFILE' chunk count: %d. Ignoring ICC profile.", chunkCount));
                badICC = true;

                if (!allowBadIndexes) {
                    return null;
                }
            }

            if (!badICC && chunkNumber < 1) {
                // Anything else is just ignored
                processWarningOccurred(String.format("Invalid 'ICC_PROFILE' chunk index: %d. Ignoring ICC profile.", chunkNumber));

                if (!allowBadIndexes) {
                    return null;
                }
            }

            int count = badICC ? segments.size() : chunkCount;
            InputStream[] streams = new InputStream[count];
            streams[badICC ? 0 : chunkNumber - 1] = stream;

            for (int i = 1; i < count; i++) {
                Application segment = segments.get(i);
                stream = new DataInputStream(segment.data());

                chunkNumber = stream.readUnsignedByte();

                if (stream.readUnsignedByte() != chunkCount && !badICC) {
                    throw new IIOException(String.format("Bad number of 'ICC_PROFILE' chunks: %d of %d.", chunkNumber, chunkCount));
                }

                int index = badICC ? i : chunkNumber - 1;
                streams[index] = stream;
            }

            return readICCProfileSafe(new SequenceInputStream(Collections.enumeration(Arrays.asList(streams))), allowBadIndexes);
        }

        return null;
    }

    private ICC_Profile readICCProfileSafe(final InputStream stream, final boolean allowBadProfile) {
        try {
            // NOTE: Need to ensure we have a display profile *before* validating, for the caching to work
            return allowBadProfile ? ColorProfiles.readProfileRaw(stream) : ensureDisplayProfile(ColorProfiles.readProfile(stream));
        }
        catch (IOException | RuntimeException e) {
            // NOTE: Throws either IllegalArgumentException or CMMException, depending on platform.
            // Usual reason: Broken tools store truncated ICC profiles in a single ICC_PROFILE chunk...
            processWarningOccurred(String.format("Bad 'ICC_PROFILE' chunk(s): %s. Ignoring ICC profile.", e.getMessage()));
            return null;
        }
    }

    @Override
    public boolean canReadRaster() {
        return delegate.canReadRaster();
    }

    @Override
    public Raster readRaster(final int imageIndex, final ImageReadParam param) throws IOException {
        checkBounds(imageIndex);
        initHeader(imageIndex);

        if (isLossless()) {
            // TODO: What about stream position?
            // TODO: Param handling: Reading as raster should support source region, subsampling etc.
            return new JPEGLosslessDecoderWrapper(this).readRaster(segments, imageInput);
        }

        try {
            return delegate.readRaster(0, param);
        }
        catch (IndexOutOfBoundsException knownIssue) {
            // com.sun.imageio.plugins.jpeg.JPEGBuffer doesn't do proper sanity check of input data.
            throw new IIOException("Corrupt JPEG data: Bad segment length", knownIssue);
        }
    }

    @Override
    public RenderedImage readAsRenderedImage(int imageIndex, ImageReadParam param) throws IOException {
        return read(imageIndex, param);
    }

    @Override
    public void abort() {
        super.abort();

        delegate.abort();
    }

    @Override
    public ImageReadParam getDefaultReadParam() {
        return delegate.getDefaultReadParam();
    }

    @Override
    public boolean readerSupportsThumbnails() {
        return true; // We support EXIF, JFIF and JFXX style thumbnails
    }

    private void readThumbnailMetadata(int imageIndex) throws IOException {
        checkBounds(imageIndex);
        initHeader(imageIndex);

        if (thumbnails == null) {
            thumbnails = new ArrayList<>();

            // Read JFIF thumbnails if present
            try {
                ThumbnailReader thumbnail = JFIFThumbnail.from(getJFIF());
                if (thumbnail != null) {
                    thumbnails.add(thumbnail);
                }
            }
            catch (IOException e) {
                processWarningOccurred(e.getMessage());
            }

            // Read JFXX thumbnails if present
            try {
                ThumbnailReader thumbnail = JFXXThumbnail.from(getJFXX(), getThumbnailReader());
                if (thumbnail != null) {
                    thumbnails.add(thumbnail);
                }
            }
            catch (IOException e) {
                processWarningOccurred(e.getMessage());
            }

            // Read Exif thumbnails if present
            try {
                EXIF exif = getExif();
                ThumbnailReader thumbnailReader = EXIFThumbnail.from(exif, parseExif(exif), getThumbnailReader());
                if (thumbnailReader != null) {
                    thumbnails.add(thumbnailReader);
                }
            }
            catch (IOException e) {
                processWarningOccurred(e.getMessage());
            }
        }
    }

    ImageReader getThumbnailReader() throws IOException {
        if (thumbnailReader == null) {
            thumbnailReader = delegate.getOriginatingProvider().createReaderInstance();
        }

        return thumbnailReader;
    }

    @Override
    public int getNumThumbnails(final int imageIndex) throws IOException {
        readThumbnailMetadata(imageIndex);

        return thumbnails.size();
    }

    private void checkThumbnailBounds(int imageIndex, int thumbnailIndex) throws IOException {
        Validate.isTrue(thumbnailIndex >= 0, thumbnailIndex, "thumbnailIndex < 0; %d");
        Validate.isTrue(getNumThumbnails(imageIndex) > thumbnailIndex, thumbnailIndex, "thumbnailIndex >= numThumbnails; %d");
    }

    @Override
    public int getThumbnailWidth(int imageIndex, int thumbnailIndex) throws IOException {
        checkThumbnailBounds(imageIndex, thumbnailIndex);

        return thumbnails.get(thumbnailIndex).getWidth();
    }

    @Override
    public int getThumbnailHeight(int imageIndex, int thumbnailIndex) throws IOException {
        checkThumbnailBounds(imageIndex, thumbnailIndex);

        return thumbnails.get(thumbnailIndex).getHeight();
    }

    @Override
    public BufferedImage readThumbnail(int imageIndex, int thumbnailIndex) throws IOException {
        checkThumbnailBounds(imageIndex, thumbnailIndex);

        processThumbnailStarted(imageIndex, thumbnailIndex);
        processThumbnailProgress(0f);

        BufferedImage thumbnail = thumbnails.get(thumbnailIndex).read();

        processThumbnailProgress(100f);
        processThumbnailComplete();

        return thumbnail;
    }

    // Metadata

    @Override
    public IIOMetadata getImageMetadata(int imageIndex) throws IOException {
        initHeader(imageIndex);

        return new JPEGImage10Metadata(segments, getSOF(), getJFIF(), getJFXX(), getEmbeddedICCProfile(true), getAdobeDCT(), parseExif(getExif()));
    }

    @Override
    public IIOMetadata getStreamMetadata() throws IOException {
        return delegate.getStreamMetadata();
    }

    @Override
    protected void processWarningOccurred(String warning) {
        super.processWarningOccurred(warning);
    }

    private static void invertCMYK(final Raster raster) {
        byte[] data = ((DataBufferByte) raster.getDataBuffer()).getData();

        for (int i = 0, dataLength = data.length; i < dataLength; i++) {
            data[i] = (byte) (255 - data[i] & 0xff);
        }
    }

    private static void convertYCbCr2RGB(final Raster raster, final int numComponents) {
        final int height = raster.getHeight();
        final int width = raster.getWidth();
        final byte[] data = ((DataBufferByte) raster.getDataBuffer()).getData();

        for (int y = 0; y < height; y++) {
            for (int x = 0; x < width; x++) {
                YCbCrConverter.convertJPEGYCbCr2RGB(data, data, (x + y * width) * numComponents);
            }
        }
    }

    private static void convertYCCK2CMYK(final Raster raster) {
        final int height = raster.getHeight();
        final int width = raster.getWidth();
        final byte[] data = ((DataBufferByte) raster.getDataBuffer()).getData();

        for (int y = 0; y < height; y++) {
            for (int x = 0; x < width; x++) {
                int offset = (x + y * width) * 4;
                // YCC -> CMY
                YCbCrConverter.convertJPEGYCbCr2RGB(data, data, offset);
                // Inverse K
                data[offset + 3] = (byte) (0xff - data[offset + 3] & 0xff);
            }
        }
    }

    private class ProgressDelegator extends ProgressListenerBase implements IIOReadUpdateListener, IIOReadWarningListener {

        @Override
        public void imageComplete(ImageReader source) {
            processImageComplete();
        }

        @Override
        public void imageProgress(ImageReader source, float percentageDone) {
            processImageProgress(percentageDone);
        }

        @Override
        public void imageStarted(ImageReader source, int imageIndex) {
            processImageStarted(currentStreamIndex);
        }

        @Override
        public void readAborted(ImageReader source) {
            processReadAborted();
        }

        @Override
        public void sequenceComplete(ImageReader source) {
            processSequenceComplete();
        }

        @Override
        public void sequenceStarted(ImageReader source, int minIndex) {
            processSequenceStarted(minIndex);
        }

        @Override
        public void thumbnailComplete(ImageReader source) {
            processThumbnailComplete();
        }

        @Override
        public void thumbnailProgress(ImageReader source, float percentageDone) {
            processThumbnailProgress(percentageDone);
        }

        @Override
        public void thumbnailStarted(ImageReader source, int imageIndex, int thumbnailIndex) {
            processThumbnailStarted(currentStreamIndex, thumbnailIndex);
        }

        public void passStarted(ImageReader source, BufferedImage theImage, int pass, int minPass, int maxPass, int minX, int minY, int periodX, int periodY, int[] bands) {
            processPassStarted(theImage, pass, minPass, maxPass, minX, minY, periodX, periodY, bands);
        }

        public void imageUpdate(ImageReader source, BufferedImage theImage, int minX, int minY, int width, int height, int periodX, int periodY, int[] bands) {
            processImageUpdate(theImage, minX, minY, width, height, periodX, periodY, bands);
        }

        public void passComplete(ImageReader source, BufferedImage theImage) {
            processPassComplete(theImage);
        }

        public void thumbnailPassStarted(ImageReader source, BufferedImage theThumbnail, int pass, int minPass, int maxPass, int minX, int minY, int periodX, int periodY, int[] bands) {
            processThumbnailPassStarted(theThumbnail, pass, minPass, maxPass, minX, minY, periodX, periodY, bands);
        }

        public void thumbnailUpdate(ImageReader source, BufferedImage theThumbnail, int minX, int minY, int width, int height, int periodX, int periodY, int[] bands) {
            processThumbnailUpdate(theThumbnail, minX, minY, width, height, periodX, periodY, bands);
        }

        public void thumbnailPassComplete(ImageReader source, BufferedImage theThumbnail) {
            processThumbnailPassComplete(theThumbnail);
        }

        public void warningOccurred(ImageReader source, String warning) {
            processWarningOccurred(warning);
        }
    }

    private class JPEGSegmentWarningDelegate implements JPEGSegmentWarningListener {
        @Override
        public void warningOccurred(String warning) {
            processWarningOccurred(warning);
        }
    }

    protected static void showIt(final BufferedImage pImage, final String pTitle) {
        ImageReaderBase.showIt(pImage, pTitle);
    }

    public static void main(final String[] args) throws IOException {
        ImageIO.setUseCache(false);

        int subX = 1;
        int subY = 1;
        int xOff = 0;
        int yOff = 0;
        Rectangle roi = null;
        boolean metadata = false;
        boolean thumbnails = false;

        for (int argIdx = 0; argIdx < args.length; argIdx++) {
            final String arg = args[argIdx];

            if (arg.charAt(0) == '-') {
                if (arg.equals("-s") || arg.equals("--subsample") && args.length > argIdx + 1) {
                    String[] sub = args[++argIdx].split(",");

                    try {
                        if (sub.length >= 4) {
                            subX = Integer.parseInt(sub[0]);
                            subY = Integer.parseInt(sub[1]);
                            xOff = Integer.parseInt(sub[2]);
                            yOff = Integer.parseInt(sub[3]);
                        }
                        else {
                            subX = Integer.parseInt(sub[0]);
                            subY = sub.length > 1 ? Integer.parseInt(sub[1]) : subX;
                        }
                    }
                    catch (NumberFormatException e) {
                        System.err.println("Bad sub sampling (x,y): '" + args[argIdx] + "'");
                    }
                }
                else if (arg.equals("-r") || arg.equals("--roi") && args.length > argIdx + 1) {
                    String[] region = args[++argIdx].split(",");

                    try {
                        if (region.length >= 4) {
                            roi = new Rectangle(Integer.parseInt(region[0]), Integer.parseInt(region[1]), Integer.parseInt(region[2]), Integer.parseInt(region[3]));
                        }
                        else {
                            roi = new Rectangle(Integer.parseInt(region[0]), Integer.parseInt(region[1]));
                        }
                    }
                    catch (IndexOutOfBoundsException | NumberFormatException e) {
                        System.err.println("Bad source region ([x,y,]w, h): '" + args[argIdx] + "'");
                    }
                }
                else if (arg.equals("-m") || arg.equals("--metadata")) {
                    metadata = true;
                }
                else if (arg.equals("-t") || arg.equals("--thumbnails")) {
                    thumbnails = true;
                }
                else {
                    System.err.println("Unknown argument: '" + arg + "'");
                    System.exit(-1);
                }

                continue;
            }

            File file = new File(arg);

            ImageInputStream input = ImageIO.createImageInputStream(file);
            if (input == null) {
                System.err.println("Could not read file: " + file);
                continue;
            }

            Iterator<ImageReader> readers = ImageIO.getImageReaders(input);

            if (!readers.hasNext()) {
                System.err.println("No reader for: " + file);
                continue;
            }

            final ImageReader reader = readers.next();
            System.err.println("Reading using: " + reader);

            reader.addIIOReadWarningListener(new IIOReadWarningListener() {
                public void warningOccurred(ImageReader source, String warning) {
                    System.err.println("Warning: " + arg + ": " + warning);
                }
            });
            final ProgressListenerBase listener = new ProgressListenerBase() {
                private static final int MAX_W = 78;
                int lastProgress = 0;

                @Override
                public void imageStarted(ImageReader source, int imageIndex) {
                    System.out.print("[");
                }

                @Override
                public void imageProgress(ImageReader source, float percentageDone) {
                    int steps = ((int) (percentageDone * MAX_W) / 100);

                    for (int i = lastProgress; i < steps; i++) {
                        System.out.print(".");
                    }

                    System.out.flush();
                    lastProgress = steps;
                }

                @Override
                public void imageComplete(ImageReader source) {
                    for (int i = lastProgress; i < MAX_W; i++) {
                        System.out.print(".");
                    }

                    System.out.println("]");
                }
            };
            reader.addIIOReadProgressListener(listener);

            reader.setInput(input);

            try {
                // For a tables-only image, we can't read image, but we should get metadata.
                if (reader.getNumImages(true) == 0) {
                    IIOMetadata streamMetadata = reader.getStreamMetadata();
                    IIOMetadataNode streamNativeTree = (IIOMetadataNode) streamMetadata.getAsTree(streamMetadata.getNativeMetadataFormatName());
                    new XMLSerializer(System.out, System.getProperty("file.encoding")).serialize(streamNativeTree, false);
                    continue;
                }

                BufferedImage image;
                ImageReadParam param = reader.getDefaultReadParam();
                if (subX > 1 || subY > 1 || roi != null) {
                    param.setSourceSubsampling(subX, subY, xOff, yOff);
                    param.setSourceRegion(roi);

//                    image = reader.getImageTypes(0).next().createBufferedImage((reader.getWidth(0) + subX - 1)/ subX, (reader.getHeight(0) + subY - 1) / subY);
                    image = null;
                }
                else {
//                    image = reader.getImageTypes(0).next().createBufferedImage(reader.getWidth(0), reader.getHeight(0));
                    image = null;
                }
                param.setDestination(image);

                long start = DEBUG ? System.currentTimeMillis() : 0;

                try {
                    image = reader.read(0, param);
                }
                catch (IOException e) {
                    e.printStackTrace();

                    if (image == null) {
                        continue;
                    }
                }

                if (DEBUG) {
                    System.err.println("Read time: " + (System.currentTimeMillis() - start) + " ms");
                    System.err.println("image: " + image);
                }

                /*
                int maxW = 1280;
                int maxH = 800;
                if (image.getWidth() > maxW || image.getHeight() > maxH) {
//                    start = System.currentTimeMillis();
                    float aspect = reader.getAspectRatio(0);
                    if (aspect >= 1f) {
                        image = ImageUtil.createResampled(image, maxW, Math.round(maxW / aspect), Image.SCALE_SMOOTH);
                    }
                    else {
                        image = ImageUtil.createResampled(image, Math.round(maxH * aspect), maxH, Image.SCALE_SMOOTH);
                    }
//                    System.err.println("Scale time: " + (System.currentTimeMillis() - start) + " ms");
                }
                */

                showIt(image, String.format("Image: %s [%d x %d]", file.getName(), reader.getWidth(0), reader.getHeight(0)));

                if (metadata) {
                    try {
                        IIOMetadata imageMetadata = reader.getImageMetadata(0);
                        System.out.println("Metadata for File: " + file.getName());

                        if (imageMetadata.getNativeMetadataFormatName() != null) {
                            System.out.println("Native:");
                            new XMLSerializer(System.out, System.getProperty("file.encoding")).serialize(imageMetadata.getAsTree(imageMetadata.getNativeMetadataFormatName()), false);
                        }
                        if (imageMetadata.isStandardMetadataFormatSupported()) {
                            System.out.println("Standard:");
                            new XMLSerializer(System.out, System.getProperty("file.encoding")).serialize(imageMetadata.getAsTree(IIOMetadataFormatImpl.standardMetadataFormatName), false);
                        }

                        System.out.println();
                    }
                    catch (IIOException e) {
                        System.err.println("Could not read thumbnails: " + arg + ": " + e.getMessage());
                        e.printStackTrace();
                    }
                }

                if (thumbnails) {
                    try {
                        int numThumbnails = reader.getNumThumbnails(0);
                        for (int i = 0; i < numThumbnails; i++) {
                            BufferedImage thumbnail = reader.readThumbnail(0, i);
                            //                        System.err.println("thumbnail: " + thumbnail);
                            showIt(thumbnail, String.format("Thumbnail: %s [%d x %d]", file.getName(), thumbnail.getWidth(), thumbnail.getHeight()));
                        }
                    }
                    catch (IIOException e) {
                        System.err.println("Could not read thumbnails: " + arg + ": " + e.getMessage());
                        e.printStackTrace();
                    }
                }
            }
            catch (Throwable t) {
                System.err.println(file);
                t.printStackTrace();
            }
            finally {
                input.close();
            }
        }
    }
}