ICOParser.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.tika.parser.image;

import java.io.IOException;
import java.util.Locale;
import java.util.Set;

import org.apache.commons.io.IOUtils;
import org.xml.sax.ContentHandler;
import org.xml.sax.SAXException;

import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaException;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.io.BoundedInputStream;
import org.apache.tika.io.EndianUtils;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Icon;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TIFF;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.sax.XHTMLContentHandler;

/**
 * Parser for Windows icon (ICO) and cursor (CUR) files. Reads the ICONDIR
 * and the header of every image to report the dimensions and colour depth of
 * the largest image, the list of all images and, for cursors, the hotspot.
 * The images themselves are not decoded.
 * <p>
 * The directory's own width, height and colour fields are unreliable (256 px
 * is stored as 0, many tools leave the bit count empty), so the values come
 * from each image's PNG IHDR or BITMAPINFOHEADER and the directory is only
 * the fallback. Colour depth is reported the TIFF way, bits per sample and
 * samples per pixel; the per-image list carries the total bits per pixel.
 */
@TikaComponent
public class ICOParser implements Parser {

    private static final long serialVersionUID = 4212837190215395123L;

    static final MediaType ICO_TYPE = MediaType.image("vnd.microsoft.icon");
    static final MediaType ICO_ALIAS = MediaType.image("x-icon");
    static final MediaType CUR_TYPE = MediaType.image("x-win-bitmap");

    private static final Set<MediaType> SUPPORTED_TYPES = Set.of(ICO_TYPE, ICO_ALIAS, CUR_TYPE);

    private static final int TYPE_ICON = 1;
    private static final int TYPE_CURSOR = 2;
    private static final int COUNT_OFFSET = 4;
    private static final int HEADER_SIZE = 6;
    private static final int ENTRY_SIZE = 16;
    private static final int BITMAP_INFO_HEADER_SIZE = 40;
    // the signature, then the first chunk's length and type: an IHDR of 13 bytes
    private static final byte[] PNG_HEADER_START =
            {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 13, 'I', 'H', 'D', 'R'};
    private static final int PNG_IHDR_SIZE = 8 + 8 + 13;
    // Icons are small; anything bigger is read only this far
    static final int MAX_FILE_SIZE = 16 * 1024 * 1024;
    // Far beyond any real icon; a header that claims more is not believed
    private static final int MAX_DIMENSION = 65535;

    private enum Encoding {
        PNG, BMP, UNKNOWN
    }

    @Override
    public Set<MediaType> getSupportedTypes(ParseContext context) {
        return SUPPORTED_TYPES;
    }

    @Override
    public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                      ParseContext context) throws IOException, SAXException, TikaException {
        byte[] file = IOUtils.toByteArray(new BoundedInputStream(MAX_FILE_SIZE, tis));
        extractMetadata(file, metadata, context);

        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
        xhtml.startDocument();
        xhtml.endDocument();
    }

    private static void extractMetadata(byte[] file, Metadata metadata, ParseContext context)
            throws TikaException {
        if (file.length < COUNT_OFFSET || EndianUtils.getUShortLE(file, 0) != 0) {
            throw new TikaException("Not an ICO or CUR file");
        }
        int type = EndianUtils.getUShortLE(file, 2);
        if (type != TYPE_ICON && type != TYPE_CURSOR) {
            throw new TikaException("Not an ICO or CUR file: type " + type);
        }
        boolean cursor = type == TYPE_CURSOR;
        metadata.set(HttpHeaders.CONTENT_TYPE, (cursor ? CUR_TYPE : ICO_TYPE).toString());
        if (file.length < HEADER_SIZE) {
            warn("The header ends before the image count", metadata, context);
            return;
        }

        int count = EndianUtils.getUShortLE(file, COUNT_OFFSET);
        if (count == 0) {
            metadata.set(Icon.IMAGE_COUNT, 0);
            warn("The directory lists no images", metadata, context);
            return;
        }
        Image largest = null;
        int listed = 0;
        int unreadable = 0;
        for (int i = 0; i < count; i++) {
            int entryOffset = HEADER_SIZE + i * ENTRY_SIZE;
            if (entryOffset + ENTRY_SIZE > file.length) {
                unreadable += count - i;
                break;
            }
            Image image = Image.read(file, entryOffset, cursor);
            if (image == null) {
                unreadable++;
                continue;
            }
            if (image.encoding == Encoding.UNKNOWN) {
                // still listed with what the directory says, but worth a warning
                unreadable++;
            }
            metadata.add(Icon.IMAGES, image.describe());
            listed++;
            if (largest == null || image.outranks(largest)) {
                largest = image;
            }
        }
        // what the header claims beyond that is in the warning
        metadata.set(Icon.IMAGE_COUNT, listed);
        if (largest != null) {
            metadata.set(TIFF.IMAGE_WIDTH, largest.width);
            metadata.set(TIFF.IMAGE_LENGTH, largest.height);
            if (largest.bitsPerSample > 0) {
                metadata.set(TIFF.BITS_PER_SAMPLE, Integer.toString(largest.bitsPerSample));
                metadata.set(TIFF.SAMPLES_PER_PIXEL, largest.samplesPerPixel);
            }
            if (cursor) {
                metadata.set(Icon.HOTSPOT_X, largest.hotspotX);
                metadata.set(Icon.HOTSPOT_Y, largest.hotspotY);
            }
        }
        if (unreadable > 0) {
            warn(unreadable + " of " + count + " images lie outside the file or have no" +
                    " readable header", metadata, context);
        }
    }

    private static void warn(String message, Metadata metadata, ParseContext context) {
        EmbeddedDocumentUtil.recordException(new TikaException(message), metadata, context);
    }

    private static boolean startsWithPngHeader(byte[] file, int offset) {
        for (int i = 0; i < PNG_HEADER_START.length; i++) {
            if (file[offset + i] != PNG_HEADER_START[i]) {
                return false;
            }
        }
        return true;
    }

    /**
     * @return the samples per pixel, or 0 for a colour type PNG does not define
     */
    private static int pngSamplesPerPixel(int colorType) {
        switch (colorType) {
            case 0: // greyscale
            case 3: // palette
                return 1;
            case 2: // truecolour
                return 3;
            case 4: // greyscale with alpha
                return 2;
            case 6: // truecolour with alpha
                return 4;
            default:
                return 0;
        }
    }

    private static final class Image {
        int width;
        int height;
        int bitsPerPixel;
        int bitsPerSample;
        int samplesPerPixel;
        int hotspotX;
        int hotspotY;
        Encoding encoding = Encoding.UNKNOWN;

        /**
         * Reads one ICONDIRENTRY and the header of the image it points to.
         * Without a header that has a usable size, the image keeps the
         * directory's values and an unknown encoding.
         *
         * @return the image, or null if its data lies outside the file
         */
        static Image read(byte[] file, int entryOffset, boolean cursor) {
            Image image = new Image();
            image.width = directorySize(file[entryOffset]);
            image.height = directorySize(file[entryOffset + 1]);
            if (cursor) {
                // a cursor's directory holds the hotspot where an icon's holds planes and bit count
                image.hotspotX = EndianUtils.getUShortLE(file, entryOffset + 4);
                image.hotspotY = EndianUtils.getUShortLE(file, entryOffset + 6);
            } else {
                image.setDepth(EndianUtils.getUShortLE(file, entryOffset + 6));
            }
            long size = EndianUtils.getUIntLE(file, entryOffset + 8);
            long offset = EndianUtils.getUIntLE(file, entryOffset + 12);
            if (offset < HEADER_SIZE || offset >= file.length) {
                return null;
            }
            int dataOffset = (int) offset;
            long available = Math.min(size, file.length - offset);
            if (available >= PNG_IHDR_SIZE && startsWithPngHeader(file, dataOffset)) {
                if (image.trySetSize(EndianUtils.getUIntBE(file, dataOffset + 16),
                        EndianUtils.getUIntBE(file, dataOffset + 20))) {
                    image.encoding = Encoding.PNG;
                    image.setPngDepth(file[dataOffset + 24] & 0xff, file[dataOffset + 25] & 0xff);
                }
            } else if (available >= BITMAP_INFO_HEADER_SIZE &&
                    EndianUtils.getUIntLE(file, dataOffset) == BITMAP_INFO_HEADER_SIZE) {
                // the height covers the XOR bitmap and the AND mask; a negative one means top-down
                long height = Math.abs((long) EndianUtils.getIntLE(file, dataOffset + 8)) / 2;
                if (image.trySetSize(EndianUtils.getIntLE(file, dataOffset + 4), height)) {
                    image.encoding = Encoding.BMP;
                    image.setDepth(EndianUtils.getUShortLE(file, dataOffset + 14));
                }
            }
            return image;
        }

        private static int directorySize(byte size) {
            return size == 0 ? 256 : size & 0xff;
        }

        /**
         * @return false, leaving the size as it was, unless both lie between 1 and
         *         {@code MAX_DIMENSION}
         */
        boolean trySetSize(long width, long height) {
            if (width <= 0 || width > MAX_DIMENSION || height <= 0 || height > MAX_DIMENSION) {
                return false;
            }
            this.width = (int) width;
            this.height = (int) height;
            return true;
        }

        /**
         * Splits a DIB colour depth into samples: 32 and 24 bit images are
         * 8 bits per channel, 16 bit ones 5, anything below is palette or mono.
         * A bit count no DIB has leaves the depth as it was.
         */
        void setDepth(int bitsPerPixel) {
            switch (bitsPerPixel) {
                case 32:
                case 24:
                    bitsPerSample = 8;
                    samplesPerPixel = bitsPerPixel / 8;
                    break;
                case 16:
                    bitsPerSample = 5;
                    samplesPerPixel = 3;
                    break;
                case 8:
                case 4:
                case 1:
                    bitsPerSample = bitsPerPixel;
                    samplesPerPixel = 1;
                    break;
                default:
                    return;
            }
            this.bitsPerPixel = bitsPerPixel;
        }

        /**
         * A bit depth or colour type PNG does not define leaves the depth as it was.
         */
        void setPngDepth(int bitDepth, int colorType) {
            int samples = pngSamplesPerPixel(colorType);
            if (samples == 0 || bitDepth > 16 || Integer.bitCount(bitDepth) != 1) {
                return;
            }
            bitsPerSample = bitDepth;
            samplesPerPixel = samples;
            bitsPerPixel = bitDepth * samples;
        }

        /**
         * Larger area wins, then the higher colour depth, like Windows' own choice.
         */
        boolean outranks(Image other) {
            long area = (long) width * height;
            long otherArea = (long) other.width * other.height;
            return area > otherArea || area == otherArea && bitsPerPixel > other.bitsPerPixel;
        }

        String describe() {
            String depth = bitsPerPixel > 0 ? "@" + bitsPerPixel + "bpp" : "";
            return width + "x" + height + depth + " " + encoding.name().toLowerCase(Locale.ROOT);
        }
    }
}