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.Set;
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.config.ParseContextConfig;
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.MetafileParserConfig;
import org.apache.tika.renderer.RenderRequest;
import org.apache.tika.renderer.RenderResult;
import org.apache.tika.renderer.RenderResults;
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. The rendering has the
* configured width, its height follows the image's aspect ratio, and it is
* drawn on a white canvas. 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)));
private static final int MAX_WIDTH = 10000;
/**
* A metafile declares its own aspect ratio, so a hostile one could ask
* for an arbitrarily tall canvas at any width. The renderer refuses
* beyond this height; an OutOfMemoryError would escape every catch in
* the parse.
*/
private static final int MAX_HEIGHT = 10000;
private int width = 800;
private String imageFormatName = "png";
@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();
int width = width(parseContext, metadata);
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, width) : draw((HwmfPicture) picture, width);
Path tmpFile = write(image, id);
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, int width) throws IOException {
Dimension2D size = picture.getSize();
BufferedImage image = canvas(size, width);
Graphics2D graphics = image.createGraphics();
try {
picture.draw(graphics, new Rectangle2D.Double(0, 0, image.getWidth(),
image.getHeight()));
} finally {
graphics.dispose();
}
return image;
}
private BufferedImage draw(HwmfPicture picture, int width) 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, width);
}
BufferedImage image = canvas(size, width);
Graphics2D graphics = image.createGraphics();
try {
picture.draw(graphics, new Rectangle2D.Double(0, 0, image.getWidth(),
image.getHeight()));
} finally {
graphics.dispose();
}
return 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 width of the rendering: the {@code renderWidth} of the metafile
* parser's configuration where the parse has one, else this renderer's
* own {@link #setWidth(int)}. The parser configuration wins so that a
* request configuring {@code "emf-parser": {"renderWidth": N}} reaches
* the renderer the parser was handed, which need not be this instance.
*/
private int width(ParseContext parseContext, Metadata metadata)
throws IOException, TikaException {
MetafileParserConfig config = parseContext.get(MetafileParserConfig.class);
if (config == null) {
String component = WMF.toString().equals(metadata.get(TikaCoreProperties.TYPE))
? "wmf-parser" : "emf-parser";
if (parseContext.getJsonConfig(component) == null) {
return width;
}
config = ParseContextConfig.getConfig(parseContext, component,
MetafileParserConfig.class, new MetafileParserConfig());
}
return config.getRenderWidth();
}
private BufferedImage canvas(Dimension2D size, int width) throws IOException {
if (size == null || size.getWidth() <= 0 || size.getHeight() <= 0) {
throw new IOException("metafile without a usable size: " + size);
}
BufferedImage image = new BufferedImage(width, height(size.getWidth(), size.getHeight(),
width), BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
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());
} finally {
graphics.dispose();
}
return image;
}
/**
* The height that keeps the aspect ratio at the rendering's width.
*
* @throws IOException if the ratio asks for an image taller than
* {@link #MAX_HEIGHT}
*/
private static int height(double sourceWidth, double sourceHeight, int width)
throws IOException {
long height = Math.max(1, Math.round(sourceHeight * width / sourceWidth));
if (height > MAX_HEIGHT) {
throw new IOException("metafile aspect ratio asks for a " + height
+ " pixel high rendering at width " + width + ", the maximum is " + MAX_HEIGHT);
}
return (int) height;
}
private BufferedImage scale(BufferedImage bitmap, int width) throws IOException {
int height = height(bitmap.getWidth(), bitmap.getHeight(), width);
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, width, height);
graphics.drawImage(bitmap, 0, 0, width, height, null);
} finally {
graphics.dispose();
}
return image;
}
private Path write(BufferedImage image, int id) 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;
}
public int getWidth() {
return width;
}
/**
* @param width the rendering's width in pixels, 1 to 10000; the height
* follows the image's aspect ratio. Default 800.
*/
public void setWidth(int width) {
if (width < 1 || width > MAX_WIDTH) {
throw new IllegalArgumentException(
"width must be between 1 and " + MAX_WIDTH + ", got: " + width);
}
this.width = width;
}
public String getImageFormatName() {
return imageFormatName;
}
/**
* @param imageFormatName an ImageIO format name, "png" (default) or "jpeg"
*/
public void setImageFormatName(String imageFormatName) {
this.imageFormatName = imageFormatName;
}
}