PNGConverter.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.pdfbox.pdmodel.graphics.image;

import java.awt.color.ColorSpace;
import java.awt.color.ICC_Profile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.imageio.stream.ImageInputStream;
import javax.imageio.stream.MemoryCacheImageInputStream;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSInteger;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.filter.Filter;
import org.apache.pdfbox.filter.FilterFactory;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.apache.pdfbox.pdmodel.graphics.color.PDColorSpace;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceGray;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.graphics.color.PDICCBased;
import org.apache.pdfbox.pdmodel.graphics.color.PDIndexed;

/**
 * This factory tries to encode a PNG given as byte array into a PDImageXObject
 * by directly coping the image data into the PDF streams without
 * decoding/encoding and re-compressing the PNG data.
 * <p>
 * If this is for any reason not possible, the factory will return null. You
 * must then encode the image by loading it and using the LosslessFactory.
 * <p>
 * The W3C PNG spec was used to implement this class:
 * https://www.w3.org/TR/2003/REC-PNG-20031110
 *
 * @author Emmeran Seehuber
 */
final class PNGConverter
{
    private static final Log LOG = LogFactory.getLog(PNGConverter.class);

    // Chunk Type definitions. The bytes in the comments are the bytes in the spec.
    private static final int CHUNK_IHDR = 0x49484452; // IHDR: 73 72 68 82
    private static final int CHUNK_IDAT = 0x49444154; // IDAT: 73 68 65 84
    private static final int CHUNK_PLTE = 0x504C5445; // PLTE: 80 76 84 69
    private static final int CHUNK_IEND = 0x49454E44; // IEND: 73 69 78 68
    private static final int CHUNK_TRNS = 0x74524E53; // tRNS: 116 82 78 83
    private static final int CHUNK_CHRM = 0x6348524D; // cHRM: 99 72 82 77
    private static final int CHUNK_GAMA = 0x67414D41; // gAMA: 103 65 77 65
    private static final int CHUNK_ICCP = 0x69434350; // iCCP: 105 67 67 80
    private static final int CHUNK_SBIT = 0x73424954; // sBIT: 115 66 73 84
    private static final int CHUNK_SRGB = 0x73524742; // sRGB: 115 82 71 66
    private static final int CHUNK_TEXT = 0x74455874; // tEXt: 116 69 88 116
    private static final int CHUNK_ZTXT = 0x7A545874; // zTXt: 122 84 88 116
    private static final int CHUNK_ITXT = 0x69545874; // iTXt: 105 84 88 116
    private static final int CHUNK_KBKG = 0x6B424B47; // kBKG: 107 66 75 71
    private static final int CHUNK_HIST = 0x68495354; // hIST: 104 73 83 84
    private static final int CHUNK_PHYS = 0x70485973; // pHYs: 112 72 89 115
    private static final int CHUNK_SPLT = 0x73504C54; // sPLT: 115 80 76 84
    private static final int CHUNK_TIME = 0x74494D45; // tIME: 116 73 77 69

    // CRC Reference Implementation, see
    // https://www.w3.org/TR/2003/REC-PNG-20031110/#D-CRCAppendix
    // for details

    /* Table of CRCs of all 8-bit messages. */
    private static final int[] CRC_TABLE = new int[256];

    static
    {
        makeCrcTable();
    }

    private PNGConverter()
    {
    }

    /**
     * Try to convert a PNG into a PDImageXObject. If for any reason the PNG can not
     * be converted, null is returned.
     * <p>
     * This usually means the PNG structure is damaged (CRC error, etc.) or it uses
     * some features which can not be mapped to PDF.
     *
     * @param doc       the document to put the image in
     * @param imageData the byte data of the PNG
     * @return null or the PDImageXObject built from the png
     */
    static PDImageXObject convertPNGImage(PDDocument doc, byte[] imageData) throws IOException
    {
        PNGConverterState state = parsePNGChunks(imageData);
        if (!checkConverterState(state))
        {
            // There is something wrong, we can't convert this PNG
            return null;
        }

        return convertPng(doc, state);
    }

    /**
     * Convert the image using the state.
     *
     * @param doc   the document to put the image in
     * @param state the parser state containing the PNG chunks.
     * @return null or the converted image
     */
    private static PDImageXObject convertPng(PDDocument doc, PNGConverterState state)
            throws IOException
    {
        Chunk ihdr = state.IHDR;
        int ihdrStart = ihdr.start;
        int width = readInt(ihdr.bytes, ihdrStart);
        int height = readInt(ihdr.bytes, ihdrStart + 4);
        int bitDepth = ihdr.bytes[ihdrStart + 8] & 0xFF;
        int colorType = ihdr.bytes[ihdrStart + 9] & 0xFF;
        int compressionMethod = ihdr.bytes[ihdrStart + 10] & 0xFF;
        int filterMethod = ihdr.bytes[ihdrStart + 11] & 0xFF;
        int interlaceMethod = ihdr.bytes[ihdrStart + 12] & 0xFF;

        if (bitDepth != 1 && bitDepth != 2 && bitDepth != 4 && bitDepth != 8 && bitDepth != 16)
        {
            LOG.error(String.format("Invalid bit depth %d.", bitDepth));
            return null;
        }
        if (width <= 0 || height <= 0)
        {
            LOG.error(String.format("Invalid image size %d x %d", width, height));
            return null;
        }
        if (compressionMethod != 0)
        {
            LOG.error(String.format("Unknown PNG compression method %d.", compressionMethod));
            return null;
        }
        if (filterMethod != 0)
        {
            LOG.error(String.format("Unknown PNG filtering method %d.", compressionMethod));
            return null;
        }
        if (interlaceMethod != 0)
        {
            LOG.debug(String.format("Can't handle interlace method %d.", interlaceMethod));
            return null;
        }

        state.width = width;
        state.height = height;
        state.bitsPerComponent = bitDepth;

        switch (colorType)
        {
        case 0:
            // Grayscale
            LOG.debug("Can't handle grayscale yet.");
            return null;
        case 2:
            // Truecolor
            if (state.tRNS != null)
            {
                LOG.debug("Can't handle images with transparent colors.");
                return null;
            }
            return buildImageObject(doc, state);
        case 3:
            // Indexed image
            return buildIndexImage(doc, state);
        case 4:
            // Grayscale with alpha.
            LOG.debug(
                    "Can't handle grayscale with alpha, would need to separate alpha from image data");
            return null;
        case 6:
            // Truecolor with alpha.
            LOG.debug(
                    "Can't handle truecolor with alpha, would need to separate alpha from image data");
            return null;
        default:
            LOG.error("Unknown PNG color type " + colorType);
            return null;
        }
    }

    /**
     * Build a indexed image
     */
    private static PDImageXObject buildIndexImage(PDDocument doc, PNGConverterState state)
            throws IOException
    {
        Chunk plte = state.PLTE;
        if (plte == null)
        {
            LOG.error("Indexed image without PLTE chunk.");
            return null;
        }
        if (plte.length % 3 != 0)
        {
            LOG.error("PLTE table corrupted, last (r,g,b) tuple is not complete.");
            return null;
        }
        if (state.bitsPerComponent > 8)
        {
            LOG.debug(String.format("Can only convert indexed images with bit depth <= 8, not %d.",
                    state.bitsPerComponent));
            return null;
        }

        PDImageXObject image = buildImageObject(doc, state);
        if (image == null)
        {
            return null;
        }

        int highVal = (plte.length / 3) - 1;
        if (highVal > 255)
        {
            LOG.error(String.format("Too much colors in PLTE, only 256 allowed, found %d colors.",
                    highVal + 1));
            return null;
        }

        setupIndexedColorSpace(doc, plte, image, highVal);

        if (state.tRNS != null)
        {
            image.getCOSObject().setItem(COSName.SMASK,
                    buildTransparencyMaskFromIndexedData(doc, image, state));
        }

        return image;
    }

    private static PDImageXObject buildTransparencyMaskFromIndexedData(PDDocument doc,
            PDImageXObject image, PNGConverterState state) throws IOException
    {
        Filter flateDecode = FilterFactory.INSTANCE.getFilter(COSName.FLATE_DECODE);
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        COSDictionary decodeParams = buildDecodeParams(state, PDDeviceGray.INSTANCE);
        COSDictionary imageDict = new COSDictionary();
        imageDict.setItem(COSName.FILTER, COSName.FLATE_DECODE);
        imageDict.setItem(COSName.DECODE_PARMS, decodeParams);
        flateDecode.decode(getIDATInputStream(state), outputStream, imageDict, 0);
        int length = image.getWidth() * image.getHeight();
        byte[] bytes = new byte[length];
        byte[] transparencyTable = state.tRNS.getData();
        byte[] decodedIDAT = outputStream.toByteArray();
        try (ImageInputStream iis = new MemoryCacheImageInputStream(
                new ByteArrayInputStream(decodedIDAT)))
        {
            int bitsPerComponent = state.bitsPerComponent;
            int w = 0;
            int neededBits = bitsPerComponent * state.width;
            int bitPadding = neededBits % 8;
            for (int i = 0; i < bytes.length; i++)
            {
                int idx = (int) iis.readBits(bitsPerComponent);
                if (idx < transparencyTable.length)
                {
                    // Inside the table, use the transparency value
                    bytes[i] = transparencyTable[idx];
                }
                else
                {
                    // Outside the table -> transparent value is 0xFF here.
                    bytes[i] = (byte) 0xFF;
                }
                w++;
                if (w == state.width)
                {
                    w = 0;
                    iis.readBits(bitPadding);
                }
            }
        }
        return LosslessFactory
                .prepareImageXObject(doc, bytes, image.getWidth(), image.getHeight(), 8,
                        PDDeviceGray.INSTANCE);
    }

    private static void setupIndexedColorSpace(PDDocument doc, Chunk lookupTable,
            PDImageXObject image, int highVal) throws IOException
    {
        COSArray indexedArray = new COSArray();
        indexedArray.add(COSName.INDEXED);
        indexedArray.add(image.getColorSpace());
        ((COSDictionary) image.getCOSObject().getItem(COSName.DECODE_PARMS))
                .setItem(COSName.COLORS, COSInteger.ONE);

        indexedArray.add(COSInteger.get(highVal));

        PDStream colorTable = new PDStream(doc);
        try (OutputStream colorTableStream = colorTable.createOutputStream(COSName.FLATE_DECODE))
        {
            colorTableStream.write(lookupTable.bytes, lookupTable.start, lookupTable.length);
        }
        indexedArray.add(colorTable);

        PDIndexed indexed = new PDIndexed(indexedArray);
        image.setColorSpace(indexed);
    }

    /**
     * Build the base image object from the IDATs and profile information
     */
    private static PDImageXObject buildImageObject(PDDocument document, PNGConverterState state)
            throws IOException
    {
        InputStream encodedByteStream = getIDATInputStream(state);

        PDColorSpace colorSpace = PDDeviceRGB.INSTANCE;

        PDImageXObject imageXObject = new PDImageXObject(document, encodedByteStream,
                COSName.FLATE_DECODE, state.width, state.height, state.bitsPerComponent,
                colorSpace);

        COSDictionary decodeParams = buildDecodeParams(state, colorSpace);
        imageXObject.getCOSObject().setItem(COSName.DECODE_PARMS, decodeParams);

        // We ignore gAMA and cHRM chunks if we have a ICC profile, as the ICC profile
        // takes preference
        boolean hasICCColorProfile = state.sRGB != null || state.iCCP != null;

        if (state.gAMA != null && !hasICCColorProfile)
        {
            if (state.gAMA.length != 4)
            {
                LOG.error("Invalid gAMA chunk length " + state.gAMA.length);
                return null;
            }
            float gamma = readPNGFloat(state.gAMA.bytes, state.gAMA.start);
            // If the gamma is 2.2 for sRGB everything is fine. Otherwise bail out.
            // The gamma is stored as 1 / gamma.
            if (Math.abs(gamma - (1 / 2.2f)) > 0.00001)
            {
                LOG.debug(String.format("We can't handle gamma of %f yet.", gamma));
                return null;
            }
        }

        if (state.sRGB != null)
        {
            if (state.sRGB.length != 1)
            {
                LOG.error(
                        String.format("sRGB chunk has an invalid length of %d", state.sRGB.length));
                return null;
            }

            // Store the specified rendering intent
            int renderIntent = state.sRGB.bytes[state.sRGB.start];
            COSName value = mapPNGRenderIntent(renderIntent);
            imageXObject.getCOSObject().setItem(COSName.INTENT, value);
        }

        if (state.cHRM != null && !hasICCColorProfile)
        {
            if (state.cHRM.length != 32)
            {
                LOG.error("Invalid cHRM chunk length " + state.cHRM.length);
                return null;
            }
            LOG.debug("We can not handle cHRM chunks yet.");
            return null;
        }

        // If possible we prefer a ICCBased color profile, just because its way faster
        // to decode ...
        if (state.iCCP != null || state.sRGB != null)
        {
            // We have got a color profile, which we must attach
            COSStream cosStream = createCOSStreamwithIccProfile(document, colorSpace, state);
            if (cosStream == null)
            {
                return null;
            }
            COSArray array = new COSArray();
            array.add(COSName.ICCBASED);
            array.add(cosStream);
            PDICCBased profile = PDICCBased.create(array, null);
            imageXObject.setColorSpace(profile);
        }
        return imageXObject;
    }

    private static COSStream createCOSStreamwithIccProfile
        (PDDocument document, PDColorSpace colorSpace, PNGConverterState state) throws IOException
    {
        COSStream cosStream = document.getDocument().createCOSStream();
        cosStream.setInt(COSName.N, colorSpace.getNumberOfComponents());
        cosStream.setItem(COSName.ALTERNATE, colorSpace.getNumberOfComponents()
                == 1 ? COSName.DEVICEGRAY : COSName.DEVICERGB);
        cosStream.setItem(COSName.FILTER, COSName.FLATE_DECODE);
        if (state.iCCP != null)
        {
            // We need to skip over the name
            int iccProfileDataStart = 0;
            while (iccProfileDataStart < 80 && iccProfileDataStart < state.iCCP.length)
            {
                if (state.iCCP.bytes[state.iCCP.start + iccProfileDataStart] == 0)
                {
                    break;
                }
                iccProfileDataStart++;
            }
            iccProfileDataStart++;
            if (iccProfileDataStart >= state.iCCP.length)
            {
                LOG.error("Invalid iCCP chunk, to few bytes");
                return null;
            }
            byte compressionMethod = state.iCCP.bytes[state.iCCP.start + iccProfileDataStart];
            if (compressionMethod != 0)
            {
                LOG.error(String.format("iCCP chunk: invalid compression method %d",
                        compressionMethod));
                return null;
            }
            // Skip over the compression method
            iccProfileDataStart++;
            try (OutputStream rawOutputStream = cosStream.createRawOutputStream())
            {
                rawOutputStream.write(state.iCCP.bytes, state.iCCP.start + iccProfileDataStart,
                        state.iCCP.length - iccProfileDataStart);
            }
        }
        else
        {
            // We tag the image with the sRGB profile
            ICC_Profile rgbProfile = ICC_Profile.getInstance(ColorSpace.CS_sRGB);
            try (OutputStream outputStream = cosStream.createOutputStream())
            {
                outputStream.write(rgbProfile.getData());
            }
        }
        return cosStream;
    }

    private static COSDictionary buildDecodeParams(PNGConverterState state, PDColorSpace colorSpace)
    {
        COSDictionary decodeParms = new COSDictionary();
        decodeParms.setItem(COSName.BITS_PER_COMPONENT, COSInteger.get(state.bitsPerComponent));
        decodeParms.setItem(COSName.PREDICTOR, COSInteger.get(15));
        decodeParms.setItem(COSName.COLUMNS, COSInteger.get(state.width));
        decodeParms.setItem(COSName.COLORS, COSInteger.get(colorSpace.getNumberOfComponents()));
        return decodeParms;
    }

    /**
     * Build an input stream for the IDAT data. May need to concat multiple IDAT
     * chunks.
     *
     * @param state the converter state.
     * @return a input stream with the IDAT data.
     */
    private static InputStream getIDATInputStream(PNGConverterState state)
    {
        MultipleInputStream inputStream = new MultipleInputStream();
        for (Chunk idat : state.IDATs)
        {
            inputStream.inputStreams
                    .add(new ByteArrayInputStream(idat.bytes, idat.start, idat.length));
        }
        return inputStream;
    }

    private static class MultipleInputStream extends InputStream
    {

        final List<InputStream> inputStreams = new ArrayList<>();
        int currentStreamIdx;
        InputStream currentStream;

        private boolean ensureStream()
        {
            if (currentStream == null)
            {
                if (currentStreamIdx >= inputStreams.size())
                {
                    return false;
                }
                currentStream = inputStreams.get(currentStreamIdx++);
            }
            return true;
        }

        @Override
        public int read() throws IOException
        {
            if (!ensureStream())
            {
                return -1;
            }
            int ret = currentStream.read();
            if (ret == -1)
            {
                currentStream = null;
                return read();
            }
            return ret;
        }

        @Override
        public int available() throws IOException
        {
            if (!ensureStream())
            {
                return 0;
            }
            return 1;
        }

        @Override
        public int read(byte[] b, int off, int len) throws IOException
        {
            if (!ensureStream())
            {
                return -1;
            }
            int ret = currentStream.read(b, off, len);
            if (ret == -1)
            {
                currentStream = null;
                return read(b, off, len);
            }
            return ret;
        }
    }

    /**
     * Map the renderIntent int to a PDF render intent. See also
     * https://www.w3.org/TR/2003/REC-PNG-20031110/#11sRGB
     *
     * @param renderIntent the PNG render intent
     * @return the matching PDF Render Intent or null
     */
    static COSName mapPNGRenderIntent(int renderIntent)
    {
        COSName value;
        switch (renderIntent)
        {
        case 0:
            value = COSName.PERCEPTUAL;
            break;
        case 1:
            value = COSName.RELATIVE_COLORIMETRIC;
            break;
        case 2:
            value = COSName.SATURATION;
            break;
        case 3:
            value = COSName.ABSOLUTE_COLORIMETRIC;
            break;
        default:
            value = null;
            break;
        }
        return value;
    }

    /**
     * Check if the converter state is sane.
     *
     * @param state the parsed converter state
     * @return true if the state seems plausible
     */
    static boolean checkConverterState(PNGConverterState state)
    {
        if (state == null)
        {
            return false;
        }
        if (state.IHDR == null || !checkChunkSane(state.IHDR))
        {
            LOG.error("Invalid IHDR chunk.");
            return false;
        }
        if (!checkChunkSane(state.PLTE))
        {
            LOG.error("Invalid PLTE chunk.");
            return false;
        }
        if (!checkChunkSane(state.iCCP))
        {
            LOG.error("Invalid iCCP chunk.");
            return false;
        }
        if (!checkChunkSane(state.tRNS))
        {
            LOG.error("Invalid tRNS chunk.");
            return false;
        }
        if (!checkChunkSane(state.sRGB))
        {
            LOG.error("Invalid sRGB chunk.");
            return false;
        }
        if (!checkChunkSane(state.cHRM))
        {
            LOG.error("Invalid cHRM chunk.");
            return false;
        }
        if (!checkChunkSane(state.gAMA))
        {
            LOG.error("Invalid gAMA chunk.");
            return false;
        }

        // Check the IDATs
        if (state.IDATs.isEmpty())
        {
            LOG.error("No IDAT chunks.");
            return false;
        }
        for (Chunk idat : state.IDATs)
        {
            if (!checkChunkSane(idat))
            {
                LOG.error("Invalid IDAT chunk.");
                return false;
            }
        }
        return true;
    }

    /**
     * Check if the chunk is sane, i.e. CRC matches and offsets and lengths in the
     * byte array
     */
    static boolean checkChunkSane(Chunk chunk)
    {
        if (chunk == null)
        {
            // If the chunk does not exist, it can not be wrong...
            return true;
        }

        if (chunk.start + chunk.length > chunk.bytes.length)
        {
            return false;
        }

        if (chunk.start < 4)
        {
            return false;
        }

        // We must include the chunk type in the CRC calculation
        int ourCRC = crc(chunk.bytes, chunk.start - 4, chunk.length + 4);
        if (ourCRC != chunk.crc)
        {
            LOG.error(String.format("Invalid CRC %08X on chunk %08X, expected %08X.", ourCRC,
                    chunk.chunkType, chunk.crc));
            return false;
        }
        return true;
    }

    /**
     * Holds the information about a chunks
     */
    static final class Chunk
    {
        /**
         * This field holds the whole byte array; In that it's redundant, as all chunks
         * will have the same byte array. But have this byte array per chunk makes it
         * easier to validate and pass around. And we won't have that many chunks, so
         * those 8 bytes for the pointer (on 64-bit systems) don't matter.
         */
        byte[] bytes;
        /**
         * The chunk type, see the CHUNK_??? constants.
         */
        int chunkType;
        /**
         * The crc of the chunk data, as stored in the PNG stream.
         */
        int crc;
        /**
         * The start index of the chunk data within bytes.
         */
        int start;
        /**
         * The length of the data within the byte array.
         */
        int length;

        /**
         * Get the data of this chunk as a byte array
         *
         * @return a byte-array with only the data of the chunk
         */
        byte[] getData()
        {
            return Arrays.copyOfRange(bytes, start, start + length);
        }
    }

    /**
     * Holds all relevant chunks of the PNG
     */
    static final class PNGConverterState
    {
        List<Chunk> IDATs = new ArrayList<>();
        @SuppressWarnings("SpellCheckingInspection") Chunk IHDR;
        @SuppressWarnings("SpellCheckingInspection") Chunk PLTE;
        Chunk iCCP;
        Chunk tRNS;
        Chunk sRGB;
        Chunk gAMA;
        Chunk cHRM;

        // Parsed header fields
        int width;
        int height;
        int bitsPerComponent;
    }

    private static int readInt(byte[] data, int offset)
    {
        int b1 = (data[offset] & 0xFF) << 24;
        int b2 = (data[offset + 1] & 0xFF) << 16;
        int b3 = (data[offset + 2] & 0xFF) << 8;
        int b4 = (data[offset + 3] & 0xFF);
        return b1 | b2 | b3 | b4;
    }

    private static float readPNGFloat(byte[] bytes, int offset)
    {
        int v = readInt(bytes, offset);
        return v / 100000f;
    }

    /**
     * Parse the PNG structure into the PNGConverterState. If we can't handle
     * something, this method will return null.
     *
     * @param imageData the byte array with the PNG data
     * @return null or the converter state with all relevant chunks
     */
    private static PNGConverterState parsePNGChunks(byte[] imageData)
    {
        if (imageData.length < 20)
        {
            LOG.error("ByteArray way to small: " + imageData.length);
            return null;
        }

        PNGConverterState state = new PNGConverterState();
        int ptr = 8;
        int firstChunkType = readInt(imageData, ptr + 4);

        if (firstChunkType != CHUNK_IHDR)
        {
            LOG.error(String.format("First Chunktype was %08X, not IHDR", firstChunkType));
            return null;
        }

        while (ptr + 12 <= imageData.length)
        {
            int chunkLength = readInt(imageData, ptr);
            int chunkType = readInt(imageData, ptr + 4);
            ptr += 8;

            if (ptr + chunkLength + 4 > imageData.length)
            {
                LOG.error("Not enough bytes. At offset " + ptr + " are " + chunkLength
                        + " bytes expected. Overall length is " + imageData.length);
                return null;
            }

            Chunk chunk = new Chunk();
            chunk.chunkType = chunkType;
            chunk.bytes = imageData;
            chunk.start = ptr;
            chunk.length = chunkLength;

            switch (chunkType)
            {
            case CHUNK_IHDR:
                if (state.IHDR != null)
                {
                    LOG.error("Two IHDR chunks? There is something wrong.");
                    return null;
                }
                state.IHDR = chunk;
                break;
            case CHUNK_IDAT:
                // The image data itself
                state.IDATs.add(chunk);
                break;
            case CHUNK_PLTE:
                // For indexed images the palette table
                if (state.PLTE != null)
                {
                    LOG.error("Two PLTE chunks? There is something wrong.");
                    return null;
                }
                state.PLTE = chunk;
                break;
            case CHUNK_IEND:
                // We are done, return the state
                return state;
            case CHUNK_TRNS:
                // For indexed images the alpha transparency table
                if (state.tRNS != null)
                {
                    LOG.error("Two tRNS chunks? There is something wrong.");
                    return null;
                }
                state.tRNS = chunk;
                break;
            case CHUNK_GAMA:
                // Gama
                state.gAMA = chunk;
                break;
            case CHUNK_CHRM:
                // Chroma
                state.cHRM = chunk;
                break;
            case CHUNK_ICCP:
                // ICC Profile
                state.iCCP = chunk;
                break;
            case CHUNK_SBIT:
                LOG.debug("Can't convert PNGs with sBIT chunk.");
                break;
            case CHUNK_SRGB:
                // We use the rendering intent from the chunk
                state.sRGB = chunk;
                break;
            case CHUNK_TEXT:
            case CHUNK_ZTXT:
            case CHUNK_ITXT:
                // We don't care about this text infos / metadata
                break;
            case CHUNK_KBKG:
                // As we can handle transparency we don't need the background color information.
                break;
            case CHUNK_HIST:
                // We don't need the color histogram
                break;
            case CHUNK_PHYS:
                // The PDImageXObject will be placed by the user however he wants,
                // so we can not enforce the physical dpi information stored here.
                // We just ignore it.
                break;
            case CHUNK_SPLT:
                // This palette stuff seems editor related, we don't need it.
                break;
            case CHUNK_TIME:
                // We don't need the last image change time either
                break;
            default:
                LOG.debug(String.format("Unknown chunk type %08X, skipping.", chunkType));
                break;
            }
            ptr += chunkLength;

            // Read the CRC
            chunk.crc = readInt(imageData, ptr);
            ptr += 4;
        }
        LOG.error("No IEND chunk found.");
        return null;
    }

    /* Make the table for a fast CRC. */
    private static void makeCrcTable()
    {
        int c;

        for (int n = 0; n < 256; n++)
        {
            c = n;
            for (int k = 0; k < 8; k++)
            {
                if ((c & 1) != 0)
                {
                    c = 0xEDB88320 ^ (c >>> 1);
                }
                else
                {
                    c = c >>> 1;
                }
            }
            CRC_TABLE[n] = c;
        }
    }

    /*
     * Update a running CRC with the bytes buf[0..len-1]--the CRC should be
     * initialized to all 1's, and the transmitted value is the 1's complement of
     * the final running CRC (see the crc() routine below).
     */
    private static int updateCrc(byte[] buf, int offset, int len)
    {
        int c = -1;
        int end = offset + len;
        for (int n = offset; n < end; n++)
        {
            c = CRC_TABLE[(c ^ buf[n]) & 0xff] ^ (c >>> 8);
        }
        return c;
    }

    /* Return the CRC of the bytes buf[offset..(offset+len-1)]. */
    static int crc(byte[] buf, int offset, int len)
    {
        return ~updateCrc(buf, offset, len);
    }
}