PDFBoxRenderer.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.pdf.pdfbox;
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.Collections;
import java.util.Locale;
import java.util.Set;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.io.RandomAccessRead;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.tools.imageio.ImageIOUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.Metadata;
import org.apache.tika.metadata.Property;
import org.apache.tika.metadata.Rendering;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.metadata.TikaPagedText;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.pdf.OcrConfig;
import org.apache.tika.parser.pdf.PDFParser;
import org.apache.tika.parser.pdf.PDFRandomAccess;
import org.apache.tika.renderer.ImageFormat;
import org.apache.tika.renderer.PageBasedRenderResults;
import org.apache.tika.renderer.PageRangeRequest;
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.RenderingTracker;
@TikaComponent(name = "pdfbox-renderer")
public class PDFBoxRenderer implements PDDocumentRenderer {
Set<MediaType> SUPPORTED_TYPES = Collections.singleton(PDFParser.MEDIA_TYPE);
protected static final Logger LOG = LoggerFactory.getLogger(PDFBoxRenderer.class);
/**
* This is the amount of time it takes for PDFBox to render the page
* to a BufferedImage
*
* @see Rendering#PDFBOX_RENDERING_TIME_MS the curated Property this aliases; {@code tk:}
* Properties mint only in org.apache.tika.metadata (see Property mint-time validation)
*/
public static final Property PDFBOX_RENDERING_TIME_MS = Rendering.PDFBOX_RENDERING_TIME_MS;
/**
* This is the amount of time it takes for PDFBox/java to write the image after
* it has been rendered into a BufferedImage. Some formats take much longer
* to encode than others.
*
* @see Rendering#PDFBOX_IMAGE_WRITING_TIME_MS the curated Property this aliases
*/
public static final Property PDFBOX_IMAGE_WRITING_TIME_MS =
Rendering.PDFBOX_IMAGE_WRITING_TIME_MS;
@Override
public Set<MediaType> getSupportedTypes(ParseContext context) {
return SUPPORTED_TYPES;
}
/** What this renderer draws when the parse scopes no settings: the entry's own. */
private final RenderSettings defaults = RenderSettings.defaults();
/** PDFBox's image type for a core one. */
public static ImageType toPdfBox(org.apache.tika.renderer.ImageType imageType) {
return imageType == org.apache.tika.renderer.ImageType.RGB ? ImageType.RGB : ImageType.GRAY;
}
@Override
public RenderResults render(TikaInputStream tis, Metadata metadata, ParseContext parseContext,
RenderRequest... requests) throws IOException, TikaException {
PDDocument pdDocument;
boolean mustClose = false;
if (tis.getOpenContainer() != null) {
pdDocument = (PDDocument) tis.getOpenContainer();
} else {
// a fresh reader from byte 0 each time, so per-page renders do not depend on
// where the stream was left; the document closes it -- but only once it exists,
// so a failed load must close the reader itself or its channel pin (and the
// budget behind it) leaks for the life of the stream
RandomAccessRead ra = PDFRandomAccess.open(tis, parseContext);
try {
pdDocument = Loader.loadPDF(ra);
} catch (IOException | RuntimeException e) {
try {
ra.close();
} catch (IOException closeFailure) {
e.addSuppressed(closeFailure);
}
throw e;
}
mustClose = true;
}
PageBasedRenderResults results = new PageBasedRenderResults(new TemporaryResources());
try {
for (RenderRequest renderRequest : requests) {
processRequest(renderRequest, pdDocument, metadata, parseContext, results);
}
} catch (Throwable t) {
// results never reach the caller; nothing else would delete the pages
try {
results.close();
} catch (IOException e) {
t.addSuppressed(e);
}
throw t;
} finally {
if (mustClose) {
pdDocument.close();
}
}
return results;
}
private void processRequest(RenderRequest renderRequest, PDDocument pdDocument,
Metadata metadata, ParseContext parseContext,
PageBasedRenderResults results) {
if (renderRequest == PageRangeRequest.RENDER_ALL || renderRequest.equals(PageRangeRequest.RENDER_ALL)) {
renderRange(pdDocument, 1, pdDocument.getNumberOfPages(),
metadata, parseContext, results);
} else if (renderRequest instanceof PageRangeRequest) {
int start = ((PageRangeRequest)renderRequest).getFrom();
int toInclusive = ((PageRangeRequest)renderRequest).getTo();
int numberOfPages = pdDocument.getNumberOfPages();
// a range that runs past the last page ends there: "the first N pages" of a
// shorter document are all of its pages. A range that starts past the last
// page asks for a page that does not exist: the caller's error, not a page's
if (start > numberOfPages) {
throw new IllegalArgumentException("page " + start + " is past the last page, "
+ numberOfPages);
}
renderRange(pdDocument, start, Math.min(toInclusive, numberOfPages), metadata,
parseContext, results);
}
}
private void renderRange(PDDocument pdDocument, int start, int endInclusive, Metadata metadata,
ParseContext parseContext, PageBasedRenderResults results) {
PDFRenderer renderer = renderer(pdDocument, parseContext);
RenderingTracker tracker = parseContext.get(RenderingTracker.class);
if (tracker == null) {
tracker = new RenderingTracker();
parseContext.set(RenderingTracker.class, tracker);
}
for (int i = start; i <= endInclusive; i++) {
int id = tracker.getNextId();
Metadata m = Metadata.newInstance(parseContext);
m.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
try {
m.set(TikaPagedText.PAGE_NUMBER, i);
m.set(TikaPagedText.PAGE_ROTATION, (double)pdDocument.getPage(i - 1).getRotation());
results.add(renderPage(renderer, pdDocument.getPage(i - 1), id, i, m,
parseContext));
} catch (SecurityException e) {
throw e;
} catch (IOException | RuntimeException e) {
// a broken page tree throws from getPage/getMediaBox: one page's failure, not the parse's
EmbeddedDocumentUtil.recordException(e, m, parseContext);
results.add(new RenderResult(RenderResult.STATUS.EXCEPTION, id, null, m));
}
}
}
/**
* The size in points of the image PDFBox draws for the page: the crop box, with width and
* height swapped when the page is rotated a quarter turn. What the box and minimum apply to.
*/
public static double[] pageSize(PDPage page) {
PDRectangle cropBox = page.getCropBox();
int rotation = page.getRotation();
boolean quarterTurn = rotation == 90 || rotation == 270;
return quarterTurn ? new double[] {cropBox.getHeight(), cropBox.getWidth()}
: new double[] {cropBox.getWidth(), cropBox.getHeight()};
}
/** What is drawn: everything, unless the parser scoped an OCR-only strategy. */
private static PDFRenderer renderer(PDDocument pdDocument, ParseContext parseContext) {
OcrConfig.RenderingStrategy strategy = parseContext.get(OcrConfig.RenderingStrategy.class);
if (strategy == null) {
return new PDFRenderer(pdDocument);
}
switch (strategy) {
case NO_TEXT:
return new NoTextPDFRenderer(pdDocument);
case TEXT_ONLY:
return new TextOnlyPDFRenderer(pdDocument);
case VECTOR_GRAPHICS_ONLY:
return new VectorGraphicsOnlyPDFRenderer(pdDocument);
default:
return new PDFRenderer(pdDocument);
}
}
protected RenderResult renderPage(PDFRenderer renderer, PDPage page, int id, int pageNumber,
Metadata metadata, ParseContext parseContext)
throws IOException {
// the minimum is the parser's policy, applied before it asks; the cap is a safety net
RenderSettings settings = settings(parseContext);
double[] size = pageSize(page);
double width = size[0];
double height = size[1];
float dpi = settings.effectiveDpi(width, height);
long estPixels = settings.estimatedPixels(width, height);
if (settings.exceedsMaxPixels(estPixels)) {
throw new IOException("page " + pageNumber + " would render to " + estPixels
+ " pixels at " + dpi + " dpi, above maxImagePixels "
+ settings.getMaxImagePixels());
}
String formatName = settings.getImageFormat().getFormatName();
Path tmpFile = Files.createTempFile("tika-pdfbox-rendering-",
"-" + id + "-" + pageNumber + "." + formatName);
try {
long start = System.currentTimeMillis();
//TODO: parameterize whether or not to un-rotate page?
BufferedImage image = renderer.renderImageWithDPI(
pageNumber - 1, dpi, toPdfBox(settings.getImageType()));
long renderingElapsed = System.currentTimeMillis() - start;
metadata.set(PDFBOX_RENDERING_TIME_MS, renderingElapsed);
start = System.currentTimeMillis();
try (OutputStream os = Files.newOutputStream(tmpFile)) {
ImageIOUtil.writeImage(image, formatName, os, Math.round(dpi),
settings.getImageQuality());
}
long elapsedWrite = System.currentTimeMillis() - start;
metadata.set(PDFBOX_IMAGE_WRITING_TIME_MS, elapsedWrite);
metadata.set(Rendering.RENDERED_MS, renderingElapsed + elapsedWrite);
} catch (SecurityException e) {
//throw SecurityExceptions immediately
throw e;
} catch (Exception e) {
try {
Files.delete(tmpFile);
} catch (IOException ex) {
LOG.warn("couldn't delete " + tmpFile, ex);
}
throw new IOException(e);
}
return new RenderResult(RenderResult.STATUS.SUCCESS, id, tmpFile, metadata);
}
/**
* @deprecated since 4.1.0: applies only to a direct {@link #render} call with no
* {@link RenderSettings} in the context. A parse always scopes {@code pages.render}, which
* replaces every value set here. Configure {@code "pages": {"render": {...}}}, or set a
* {@code RenderSettings} on the context when calling the renderer yourself.
*/
@Deprecated
public void setDPI(int dpi) {
defaults.setDpi(dpi);
}
/** @deprecated since 4.1.0; see {@link #setDPI}. */
@Deprecated
public void setImageType(ImageType imageType) {
defaults.setImageType(imageType == ImageType.RGB
? org.apache.tika.renderer.ImageType.RGB : org.apache.tika.renderer.ImageType.GRAY);
}
/** @deprecated since 4.1.0; see {@link #setDPI}. */
@Deprecated
public void setImageFormatName(String imageFormatName) {
defaults.setImageFormat(ImageFormat.valueOf(imageFormatName.toUpperCase(Locale.ROOT)));
}
/** @deprecated since 4.1.0; see {@link #setDPI}. */
@Deprecated
public void setImageQuality(float imageQuality) {
defaults.setImageQuality(imageQuality);
}
/** @deprecated since 4.1.0; see {@link #setDPI}. */
@Deprecated
public void setMaxImagePixels(long maxImagePixels) {
defaults.setMaxImagePixels(maxImagePixels);
}
/** 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);
}
}