WebPImageParser.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.commons.imaging.formats.webp;

import static org.apache.commons.imaging.common.BinaryFunctions.read4Bytes;
import static org.apache.commons.imaging.common.BinaryFunctions.readBytes;
import static org.apache.commons.imaging.common.BinaryFunctions.skipBytes;

import java.awt.Dimension;
import java.awt.image.BufferedImage;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.nio.ByteOrder;
import java.util.ArrayList;

import org.apache.commons.imaging.AbstractImageParser;
import org.apache.commons.imaging.ImageFormat;
import org.apache.commons.imaging.ImageFormats;
import org.apache.commons.imaging.ImageInfo;
import org.apache.commons.imaging.ImagingException;
import org.apache.commons.imaging.bytesource.ByteSource;
import org.apache.commons.imaging.common.XmpEmbeddable;
import org.apache.commons.imaging.common.XmpImagingParameters;
import org.apache.commons.imaging.formats.tiff.TiffImageMetadata;
import org.apache.commons.imaging.formats.tiff.TiffImageParser;
import org.apache.commons.imaging.formats.webp.chunks.WebPChunk;
import org.apache.commons.imaging.formats.webp.chunks.WebPChunkVp8;
import org.apache.commons.imaging.formats.webp.chunks.WebPChunkVp8l;
import org.apache.commons.imaging.formats.webp.chunks.WebPChunkVp8x;
import org.apache.commons.imaging.formats.webp.chunks.WebPChunkXml;
import org.apache.commons.imaging.internal.SafeOperations;

/**
 * WebP image parser.
 *
 * @since 1.0-alpha4
 */
public class WebPImageParser extends AbstractImageParser<WebPImagingParameters> implements XmpEmbeddable<WebPImagingParameters> {

    private static final class ChunksReader implements Closeable {
        private final InputStream is;
        private final WebPChunkType[] chunkTypes;
        private int sizeCount = 4;
        private boolean firstChunk = true;

        final int fileSize;

        ChunksReader(final ByteSource byteSource) throws IOException, ImagingException {
            this(byteSource, (WebPChunkType[]) null);
        }

        ChunksReader(final ByteSource byteSource, final WebPChunkType... chunkTypes) throws ImagingException, IOException {
            this.is = byteSource.getInputStream();
            this.chunkTypes = chunkTypes;
            this.fileSize = readFileHeader(is);
        }

        @Override
        public void close() throws IOException {
            is.close();
        }

        int getOffset() {
            return SafeOperations.add(sizeCount, 8); // File Header
        }

        WebPChunk readChunk() throws ImagingException, IOException {
            while (sizeCount < fileSize) {
                final int type = read4Bytes("Chunk Type", is, "Not a valid WebP file", ByteOrder.LITTLE_ENDIAN);
                final int payloadSize = read4Bytes("Chunk Size", is, "Not a valid WebP file", ByteOrder.LITTLE_ENDIAN);
                if (payloadSize < 0) {
                    throw new ImagingException("Chunk Payload is too long:" + payloadSize);
                }
                final boolean padding = payloadSize % 2 != 0;
                final int chunkSize = SafeOperations.add(8, padding ? 1 : 0, payloadSize);

                if (firstChunk) {
                    firstChunk = false;
                    if (type != WebPChunkType.VP8.value && type != WebPChunkType.VP8L.value && type != WebPChunkType.VP8X.value) {
                        throw new ImagingException("First Chunk must be VP8, VP8L or VP8X");
                    }
                }

                if (chunkTypes != null) {
                    boolean skip = true;
                    for (final WebPChunkType t : chunkTypes) {
                        if (t.value == type) {
                            skip = false;
                            break;
                        }
                    }
                    if (skip) {
                        skipBytes(is, payloadSize + (padding ? 1 : 0));
                        sizeCount = SafeOperations.add(sizeCount, chunkSize);
                        continue;
                    }
                }

                final byte[] bytes = readBytes("Chunk Payload", is, payloadSize);
                final WebPChunk chunk = WebPChunkType.makeChunk(type, payloadSize, bytes);
                if (padding) {
                    skipBytes(is, 1);
                }

                sizeCount = SafeOperations.add(sizeCount, chunkSize);
                return chunk;
            }

            if (firstChunk) {
                throw new ImagingException("No WebP chunks found");
            }
            return null;
        }
    }

    private static final String DEFAULT_EXTENSION = ImageFormats.WEBP.getDefaultExtension();

    private static final String[] ACCEPTED_EXTENSIONS = ImageFormats.WEBP.getExtensions();

    /**
     * Read the file header of WebP file.
     *
     * @return file size in file header (including the WebP signature, excluding the TIFF signature and the file size field).
     */
    private static int readFileHeader(final InputStream is) throws IOException, ImagingException {
        final byte[] buffer = new byte[4];
        if (is.read(buffer) < 4 || !WebPConstants.RIFF_SIGNATURE.equals(buffer)) {
            throw new ImagingException("Not a valid WebP file");
        }

        final int fileSize = read4Bytes("File Size", is, "Not a valid WebP file", ByteOrder.LITTLE_ENDIAN);
        if (fileSize < 0) {
            throw new ImagingException("File size is too long:" + fileSize);
        }

        if (is.read(buffer) < 4 || !WebPConstants.WEBP_SIGNATURE.equals(buffer)) {
            throw new ImagingException("Not a valid WebP file");
        }

        return fileSize;
    }

    @Override
    public boolean dumpImageFile(final PrintWriter pw, final ByteSource byteSource) throws ImagingException, IOException {
        pw.println("webp.dumpImageFile");
        try (ChunksReader reader = new ChunksReader(byteSource)) {
            int offset = reader.getOffset();
            WebPChunk chunk = reader.readChunk();
            if (chunk == null) {
                throw new ImagingException("No WebP chunks found");
            }

            // TODO: this does not look too risky; a user could craft an image
            // with millions of chunks, that are really expensive to dump,
            // but that should result in a large image, where we can short-
            // -circuit the operation somewhere else - if needed.
            do {
                chunk.dump(pw, offset);

                offset = reader.getOffset();
                chunk = reader.readChunk();
            } while (chunk != null);
        }
        return true;
    }

    @Override
    protected String[] getAcceptedExtensions() {
        return ACCEPTED_EXTENSIONS;
    }

    @Override
    protected ImageFormat[] getAcceptedTypes() {
        return new ImageFormat[] { ImageFormats.WEBP };
    }

    @Override
    public BufferedImage getBufferedImage(final ByteSource byteSource, final WebPImagingParameters params) throws ImagingException, IOException {
        throw new ImagingException("Reading WebP files is currently not supported");
    }

    @Override
    public String getDefaultExtension() {
        return DEFAULT_EXTENSION;
    }

    @Override
    public WebPImagingParameters getDefaultParameters() {
        return new WebPImagingParameters();
    }

    @Override
    public byte[] getIccProfileBytes(final ByteSource byteSource, final WebPImagingParameters params) throws ImagingException, IOException {
        try (ChunksReader reader = new ChunksReader(byteSource, WebPChunkType.ICCP)) {
            final WebPChunk chunk = reader.readChunk();
            return chunk == null ? null : chunk.getBytes();
        }
    }

    @Override
    public ImageInfo getImageInfo(final ByteSource byteSource, final WebPImagingParameters params) throws ImagingException, IOException {
        try (ChunksReader reader = new ChunksReader(byteSource, WebPChunkType.VP8, WebPChunkType.VP8L, WebPChunkType.VP8X, WebPChunkType.ANMF)) {
            String formatDetails;
            int width;
            int height;
            int numberOfImages;
            boolean hasAlpha = false;
            ImageInfo.ColorType colorType = ImageInfo.ColorType.RGB;

            WebPChunk chunk = reader.readChunk();
            if (chunk instanceof WebPChunkVp8) {
                formatDetails = "WebP/Lossy";
                numberOfImages = 1;

                final WebPChunkVp8 vp8 = (WebPChunkVp8) chunk;
                width = vp8.getWidth();
                height = vp8.getHeight();
                colorType = ImageInfo.ColorType.YCbCr;
            } else if (chunk instanceof WebPChunkVp8l) {
                formatDetails = "WebP/Lossless";
                numberOfImages = 1;

                final WebPChunkVp8l vp8l = (WebPChunkVp8l) chunk;
                width = vp8l.getImageWidth();
                height = vp8l.getImageHeight();
            } else if (chunk instanceof WebPChunkVp8x) {
                final WebPChunkVp8x vp8x = (WebPChunkVp8x) chunk;
                width = vp8x.getCanvasWidth();
                height = vp8x.getCanvasHeight();
                hasAlpha = ((WebPChunkVp8x) chunk).hasAlpha();

                if (vp8x.hasAnimation()) {
                    formatDetails = "WebP/Animation";

                    numberOfImages = 0;
                    while ((chunk = reader.readChunk()) != null) {
                        if (chunk.getType() == WebPChunkType.ANMF.value) {
                            numberOfImages++;
                        }
                    }

                } else {
                    numberOfImages = 1;
                    chunk = reader.readChunk();

                    if (chunk == null) {
                        throw new ImagingException("Image has no content");
                    }

                    if (chunk.getType() == WebPChunkType.ANMF.value) {
                        throw new ImagingException("Non animated image should not contain ANMF chunks");
                    }

                    if (chunk.getType() == WebPChunkType.VP8.value) {
                        formatDetails = "WebP/Lossy (Extended)";
                        colorType = ImageInfo.ColorType.YCbCr;
                    } else if (chunk.getType() == WebPChunkType.VP8L.value) {
                        formatDetails = "WebP/Lossless (Extended)";
                    } else {
                        throw new ImagingException("Unknown WebP chunk type: " + chunk);
                    }
                }
            } else {
                throw new ImagingException("Unknown WebP chunk type: " + chunk);
            }

            return new ImageInfo(formatDetails, 32, new ArrayList<>(), ImageFormats.WEBP, "webp", height, "image/webp", numberOfImages, -1, -1, -1, -1, width,
                    false, hasAlpha, false, colorType, ImageInfo.CompressionAlgorithm.UNKNOWN);
        }
    }

    @Override
    public Dimension getImageSize(final ByteSource byteSource, final WebPImagingParameters params) throws ImagingException, IOException {
        try (ChunksReader reader = new ChunksReader(byteSource)) {
            final WebPChunk chunk = reader.readChunk();
            if (chunk instanceof WebPChunkVp8) {
                final WebPChunkVp8 vp8 = (WebPChunkVp8) chunk;
                return new Dimension(vp8.getWidth(), vp8.getHeight());
            }
            if (chunk instanceof WebPChunkVp8l) {
                final WebPChunkVp8l vp8l = (WebPChunkVp8l) chunk;
                return new Dimension(vp8l.getImageWidth(), vp8l.getImageHeight());
            }
            if (chunk instanceof WebPChunkVp8x) {
                final WebPChunkVp8x vp8x = (WebPChunkVp8x) chunk;
                return new Dimension(vp8x.getCanvasWidth(), vp8x.getCanvasHeight());
            }
            throw new ImagingException("Unknown WebP chunk type: " + chunk);
        }
    }

    @Override
    public WebPImageMetadata getMetadata(final ByteSource byteSource, final WebPImagingParameters params) throws ImagingException, IOException {
        try (ChunksReader reader = new ChunksReader(byteSource, WebPChunkType.EXIF)) {
            final WebPChunk chunk = reader.readChunk();
            return chunk == null ? null : new WebPImageMetadata((TiffImageMetadata) new TiffImageParser().getMetadata(chunk.getBytes()));
        }
    }

    @Override
    public String getName() {
        return "WebP-Custom";
    }

    @Override
    public String getXmpXml(final ByteSource byteSource, final XmpImagingParameters<WebPImagingParameters> params) throws ImagingException, IOException {
        try (ChunksReader reader = new ChunksReader(byteSource, WebPChunkType.XMP)) {
            final WebPChunkXml chunk = (WebPChunkXml) reader.readChunk();
            return chunk == null ? null : chunk.getXml();
        }
    }
}