POIMetafileRenderer.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.renderer.microsoft;

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Dimension2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.function.Consumer;
import javax.imageio.ImageIO;

import org.apache.poi.hemf.usermodel.HemfPicture;
import org.apache.poi.hwmf.record.HwmfFill;
import org.apache.poi.hwmf.record.HwmfRecord;
import org.apache.poi.hwmf.usermodel.HwmfPicture;

import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaException;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.io.TemporaryResources;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.Rendering;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.microsoft.EMFParser;
import org.apache.tika.renderer.ImageFormat;
import org.apache.tika.renderer.ImageType;
import org.apache.tika.renderer.RenderRequest;
import org.apache.tika.renderer.RenderResult;
import org.apache.tika.renderer.RenderResults;
import org.apache.tika.renderer.RenderSettings;
import org.apache.tika.renderer.Renderer;
import org.apache.tika.renderer.RenderingTracker;

/**
 * Renders EMF and WMF images to a raster image through POI's HEMF and HWMF,
 * the way {@code PDFBoxRenderer} renders PDF pages: at the {@code RenderSettings}
 * the parse scopes into the context (dpi, box, colour, format), else this
 * renderer's own, on a white canvas with the picture's aspect ratio; a canvas
 * over {@code maxImagePixels} is refused. Metafiles have no pages, so the
 * render requests are ignored and a single result is returned.
 * <p>
 * The WMF thumbnails that Word stores in the SummaryInformation of a .doc
 * consist of a window extent and a single {@code dibStretchBlt} record, for
 * which POI cannot compute bounds; those are rendered from the record's
 * bitmap directly.
 */
@TikaComponent(name = "poi-metafile-renderer")
public class POIMetafileRenderer implements Renderer {

    public static final String RENDERED_BY = "poi-metafile-renderer";

    public static final MediaType EMF = MediaType.image("emf");
    public static final MediaType WMF = MediaType.image("wmf");

    private static final Set<MediaType> SUPPORTED_TYPES =
            Collections.unmodifiableSet(new HashSet<>(Arrays.asList(EMF, WMF)));

    static {
        EMFParser.raiseCommentRecordLength();
    }

    /** What this renderer draws when the parse scopes no settings: 800 px wide, RGB PNG. */
    private final RenderSettings defaults = RenderSettings.defaults();

    public POIMetafileRenderer() {
        defaults.setMaxWidth(800);
        defaults.setImageType(ImageType.RGB);
    }

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

    /**
     * Renders the metafile in the stream, or the {@link HemfPicture} or
     * {@link HwmfPicture} set as the stream's open container. The metadata's
     * {@link TikaCoreProperties#TYPE} tells EMF from WMF when a stream is
     * parsed; it defaults to EMF.
     */
    @Override
    public RenderResults render(TikaInputStream tis, Metadata metadata, ParseContext parseContext,
                                RenderRequest... requests) throws IOException, TikaException {
        Object picture = tis.getOpenContainer();
        if (!(picture instanceof HemfPicture) && !(picture instanceof HwmfPicture)) {
            picture = WMF.toString().equals(metadata.get(TikaCoreProperties.TYPE))
                    ? new HwmfPicture(tis) : new HemfPicture(tis);
        }
        RenderingTracker tracker = parseContext.get(RenderingTracker.class);
        if (tracker == null) {
            tracker = new RenderingTracker();
            parseContext.set(RenderingTracker.class, tracker);
        }
        int id = tracker.getNextId();
        RenderSettings settings = settings(parseContext);
        String imageFormatName = settings.getImageFormat().getFormatName();
        Metadata renderingMetadata = Metadata.newInstance(parseContext);
        renderingMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
        RenderResults results = new RenderResults(new TemporaryResources());
        try {
            long start = System.currentTimeMillis();
            BufferedImage image = picture instanceof HemfPicture
                    ? draw((HemfPicture) picture, settings) : draw((HwmfPicture) picture, settings);
            Path tmpFile = write(image, id, imageFormatName);
            renderingMetadata.set(Rendering.RENDERED_MS, System.currentTimeMillis() - start);
            renderingMetadata.add(Rendering.RENDERED_BY, RENDERED_BY);
            renderingMetadata.set(HttpHeaders.CONTENT_TYPE, "image/" + imageFormatName);
            results.add(new RenderResult(RenderResult.STATUS.SUCCESS, id, tmpFile,
                    renderingMetadata));
        } catch (SecurityException e) {
            throw e;
        } catch (Exception e) {
            //record the cause, as PDFBoxRenderer does, so the failure is diagnosable
            EmbeddedDocumentUtil.recordException(e, renderingMetadata, parseContext);
            results.add(new RenderResult(RenderResult.STATUS.EXCEPTION, id, null,
                    renderingMetadata));
        }
        return results;
    }

    private BufferedImage draw(HemfPicture picture, RenderSettings settings) throws IOException {
        BufferedImage image = canvas(picture.getSize(), settings);
        return paint(image, graphics -> picture.draw(graphics, bounds(image)));
    }

    private BufferedImage draw(HwmfPicture picture, RenderSettings settings) throws IOException {
        Dimension2D size;
        try {
            size = picture.getSize();
        } catch (IllegalStateException e) {
            //POI throws this for "window records are incomplete": a bitmap
            //wrapped in a metafile, as Word's .doc thumbnails are
            BufferedImage bitmap = firstBitmap(picture);
            if (bitmap == null) {
                throw new IOException("WMF without bounds and without a bitmap", e);
            }
            return scale(bitmap, settings);
        }
        BufferedImage image = canvas(size, settings);
        return paint(image, graphics -> picture.draw(graphics, bounds(image)));
    }

    private static BufferedImage firstBitmap(HwmfPicture picture) {
        for (HwmfRecord record : picture.getRecords()) {
            if (record instanceof HwmfFill.HwmfImageRecord) {
                BufferedImage image = ((HwmfFill.HwmfImageRecord) record).getImage();
                if (image != null) {
                    return image;
                }
            }
        }
        return null;
    }

    /** The settings the parser scoped around this render, else this renderer's own. */
    private RenderSettings settings(ParseContext parseContext) {
        RenderSettings scoped = parseContext.get(RenderSettings.class);
        return scoped == null ? defaults : defaults.over(scoped);
    }

    /** A white canvas for the picture at the settings' dpi, scaled down to fit the box. */
    private static BufferedImage canvas(Dimension2D size, RenderSettings settings)
            throws IOException {
        if (size == null || size.getWidth() <= 0 || size.getHeight() <= 0) {
            throw new IOException("metafile without a usable size: " + size);
        }
        int dpi = settings.getDpi();
        return canvas(Math.max(1, Math.round(size.getWidth() / 72.0 * dpi)),
                Math.max(1, Math.round(size.getHeight() / 72.0 * dpi)), settings);
    }

    /** A white canvas of this size fitted into the box; {@code maxImagePixels} refuses bigger. */
    private static BufferedImage canvas(long width, long height, RenderSettings settings)
            throws IOException {
        long[] fit = settings.fit(width, height);
        double pixels = (double) fit[0] * fit[1];
        if (settings.exceedsMaxPixels(pixels >= Long.MAX_VALUE ? Long.MAX_VALUE : (long) pixels)
                || fit[0] > Integer.MAX_VALUE || fit[1] > Integer.MAX_VALUE) {
            throw new IOException("metafile asks for a " + fit[0] + " x " + fit[1]
                    + " pixel rendering, above maxImagePixels " + settings.getMaxImagePixels());
        }
        BufferedImage image = new BufferedImage((int) fit[0], (int) fit[1], imageType(settings));
        return paint(image, graphics -> {
            graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON);
            graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
                    RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
            graphics.setRenderingHint(RenderingHints.KEY_RENDERING,
                    RenderingHints.VALUE_RENDER_QUALITY);
            graphics.setColor(Color.WHITE);
            graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
        });
    }

    private static BufferedImage paint(BufferedImage image, Consumer<Graphics2D> painter) {
        Graphics2D graphics = image.createGraphics();
        try {
            painter.accept(graphics);
        } finally {
            graphics.dispose();
        }
        return image;
    }

    private static Rectangle2D bounds(BufferedImage image) {
        return new Rectangle2D.Double(0, 0, image.getWidth(), image.getHeight());
    }

    /** A bitmap has no dpi: the box is its ceiling and it is never enlarged. */
    private static BufferedImage scale(BufferedImage bitmap, RenderSettings settings)
            throws IOException {
        BufferedImage image = canvas(bitmap.getWidth(), bitmap.getHeight(), settings);
        return paint(image, graphics -> {
            graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
                    RenderingHints.VALUE_INTERPOLATION_BILINEAR);
            graphics.drawImage(bitmap, 0, 0, image.getWidth(), image.getHeight(), null);
        });
    }

    private static int imageType(RenderSettings settings) {
        return settings.getImageType() == ImageType.GRAY
                ? BufferedImage.TYPE_BYTE_GRAY : BufferedImage.TYPE_INT_RGB;
    }

    private Path write(BufferedImage image, int id, String imageFormatName) throws IOException {
        Path tmpFile = Files.createTempFile("tika-metafile-rendering-",
                "-" + id + "." + imageFormatName);
        try (OutputStream os = Files.newOutputStream(tmpFile)) {
            if (!ImageIO.write(image, imageFormatName, os)) {
                throw new IOException("no ImageIO writer for " + imageFormatName);
            }
        } catch (IOException | RuntimeException e) {
            Files.deleteIfExists(tmpFile);
            throw e;
        }
        return tmpFile;
    }

    /** The width the box allows when the parse scopes no settings; 800 by default. */
    public int getWidth() {
        return defaults.getMaxWidth();
    }

    /**
     * @deprecated since 4.1.0: applies only to a direct {@link #render} call with no
     * {@link RenderSettings} in the context. The EMF/WMF parsers always scope {@code pages.render}
     * (300 dpi of the drawing's physical size, grayscale PNG, unless the block says otherwise),
     * which replaces this. Configure {@code "pages": {"render": {"maxWidth": ...}}}.
     */
    @Deprecated
    public void setWidth(int width) {
        defaults.setMaxWidth(width);
    }

    public String getImageFormatName() {
        return defaults.getImageFormat().getFormatName();
    }

    /** @deprecated since 4.1.0; see {@link #setWidth}: {@code pages.render.imageFormat}. */
    @Deprecated
    public void setImageFormatName(String imageFormatName) {
        defaults.setImageFormat(ImageFormat.valueOf(imageFormatName.toUpperCase(Locale.ROOT)));
    }
}