PopplerRenderer.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.poppler;

import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;

import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.exception.TikaException;
import org.apache.tika.exception.TikaTimeoutException;
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.TikaCoreProperties;
import org.apache.tika.metadata.TikaPagedText;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.renderer.ImageType;
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.Renderer;
import org.apache.tika.renderer.RenderingTracker;
import org.apache.tika.utils.FileProcessResult;
import org.apache.tika.utils.ProcessUtils;

/**
 * Renders PDF pages through Poppler's {@code pdftoppm}, one call per page at the dpi the
 * {@link RenderSettings} the parse scoped (else this renderer's own) resolve to for that page:
 * the target dpi, lowered so the page fits the box and its long side stays within
 * {@code maxScaleTo}, never raised. A page over {@code maxImagePixels} is skipped with a
 * warning. Page sizes come from the {@link PDDocument} the parser leaves as the stream's open
 * container, else from a load of the file.
 * <p>
 * Poppler is pre-installed on most Linux distributions and is the fastest widely-available
 * PDF renderer. On macOS it can be installed via {@code brew install poppler}; on Windows via
 * MSYS2 or Chocolatey.
 * <p>
 * Configuration key: {@code "poppler-renderer"}
 *
 * @since Apache Tika 4.0
 */
@TikaComponent(name = "poppler-renderer", spi = false)
public class PopplerRenderer implements Renderer {

    private static final Set<MediaType> SUPPORTED_TYPES =
            Collections.singleton(MediaType.application("pdf"));

    /** pdftoppm names its output {@code prefix-N.png} (zero-padded) or .jpg / .tif. */
    private static final Pattern PAGE_FILE_PATTERN =
            Pattern.compile("tika-poppler-(\\d+)\\.(png|jpg|tif)");

    private String pdftoppmPath = "pdftoppm";
    private int timeoutMillis = 120000;

    /** What this renderer draws when the parse scopes no settings: 300 dpi gray. */
    private final RenderSettings defaults = RenderSettings.defaults();

    /**
     * Longest side of a rendered page, in pixels; a page that would be longer at the dpi is
     * rendered smaller. The guard against posters and maps that would otherwise be gigabytes
     * of pixels. 4096 by default; -1 for none (not recommended).
     */
    private int maxScaleTo = 4096;

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

    @Override
    public RenderResults render(TikaInputStream tis, Metadata metadata,
                                ParseContext parseContext,
                                RenderRequest... requests)
            throws IOException, TikaException {
        TemporaryResources tmp = new TemporaryResources();
        PageBasedRenderResults results = new PageBasedRenderResults(tmp);
        Path path = tis.getPath();
        PDDocument open = tis.getOpenContainer() instanceof PDDocument d ? d : null;
        try (PDDocument loaded = open == null ? Loader.loadPDF(path.toFile()) : null) {
            PDDocument document = open == null ? loaded : open;
            Path dir = Files.createTempDirectory("tika-render-");
            tmp.addResource((Closeable) () -> Files.delete(dir));
            for (RenderRequest request : requests) {
                if (!(request instanceof PageRangeRequest range)) {
                    throw new TikaException("I regret that this renderer can only handle "
                            + "PageRangeRequests, not " + request.getClass());
                }
                int last = document.getNumberOfPages();
                int from = range == PageRangeRequest.RENDER_ALL ? 1 : range.getFrom();
                int to = range == PageRangeRequest.RENDER_ALL ? last : Math.min(range.getTo(), last);
                for (int page = from; page <= to; page++) {
                    results.add(renderPage(path, dir, document, page, parseContext));
                }
            }
        } 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;
        }
        return results;
    }

    private RenderResult renderPage(Path pdf, Path dir, PDDocument document, int page,
                                    ParseContext parseContext) throws IOException, TikaException {
        RenderingTracker tracker = parseContext.get(RenderingTracker.class);
        if (tracker == null) {
            tracker = new RenderingTracker();
            parseContext.set(RenderingTracker.class, tracker);
        }
        int id = tracker.getNextId();
        Metadata m = Metadata.newInstance(parseContext);
        m.set(TikaPagedText.PAGE_NUMBER, page);
        m.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
        RenderSettings settings = settings(parseContext);
        double[] size = pageSize(document.getPage(page - 1));
        double dpi = dpi(settings, size[0], size[1]);
        long pixels = RenderSettings.estimatedPixels(size[0], size[1], dpi);
        if (settings.exceedsMaxPixels(pixels)) {
            EmbeddedDocumentUtil.recordException(new IOException("page " + page
                    + " would render to " + pixels + " pixels at " + dpi
                    + " dpi, above maxImagePixels " + settings.getMaxImagePixels()),
                    m, parseContext);
            return new RenderResult(RenderResult.STATUS.EXCEPTION, id, null, m);
        }

        ProcessBuilder builder = new ProcessBuilder();
        builder.command(createCommandLine(pdf, dir, page, dpi, settings));
        FileProcessResult result = ProcessUtils.execute(
                builder, parseContext, timeoutMillis, 10, 1000);
        if (result.isTimeout()) {
            throw new TikaTimeoutException("pdftoppm timed out",
                    result.getRequestedTimeoutMillis(), result.getGrantedTimeoutMillis());
        } else if (result.getExitValue() != 0) {
            throw new TikaException("pdftoppm failed (exit " + result.getExitValue()
                    + "): " + result.getStderr());
        }
        Matcher matcher = PAGE_FILE_PATTERN.matcher("");
        File[] files = dir.toFile().listFiles();
        for (File f : files == null ? new File[0] : files) {
            if (matcher.reset(f.getName()).find() && Integer.parseInt(matcher.group(1)) == page) {
                return new RenderResult(RenderResult.STATUS.SUCCESS, id, f.toPath(), m);
            }
        }
        throw new TikaException("pdftoppm wrote no image for page " + page + ": "
                + result.getStderr());
    }

    /**
     * The size in points of the image pdftoppm writes: the media box (its default, unlike
     * PDFBox's crop box), width and height swapped when the page is rotated a quarter turn.
     */
    static double[] pageSize(PDPage pdPage) {
        PDRectangle mediaBox = pdPage.getMediaBox();
        int rotation = pdPage.getRotation();
        boolean quarterTurn = rotation == 90 || rotation == 270;
        return quarterTurn ? new double[] {mediaBox.getHeight(), mediaBox.getWidth()}
                : new double[] {mediaBox.getWidth(), mediaBox.getHeight()};
    }

    /** The target dpi, lowered so the page fits the box and {@code maxScaleTo}, never raised. */
    double dpi(RenderSettings settings, double widthPoints, double heightPoints) {
        double dpi = settings.effectiveDpi(widthPoints, heightPoints);
        double longSide = Math.max(widthPoints, heightPoints) / 72.0 * dpi;
        if (maxScaleTo > 0 && longSide > maxScaleTo) {
            dpi *= maxScaleTo / longSide;
        }
        return dpi;
    }

    String[] createCommandLine(Path pdf, Path dir, int page, double dpi, RenderSettings settings) {
        List<String> args = new ArrayList<>();
        args.add(pdftoppmPath);
        switch (settings.getImageFormat()) {
            case JPEG:
                args.add("-jpeg");
                args.add("-jpegopt");
                args.add("quality=" + Math.round(settings.getImageQuality() * 100));
                break;
            case TIFF:
                args.add("-tiff");
                break;
            default:
                args.add("-png");
                break;
        }
        args.add("-r");
        args.add(String.format(Locale.ROOT, "%.2f", dpi));
        if (settings.getImageType() == ImageType.GRAY) {
            args.add("-gray");
        }
        args.add("-f");
        args.add(String.valueOf(page));
        args.add("-l");
        args.add(String.valueOf(page));
        args.add(ProcessUtils.escapeCommandLine(pdf.toAbsolutePath().toString()));
        args.add(ProcessUtils.escapeCommandLine(dir.toAbsolutePath().toString() + "/tika-poppler"));
        return args.toArray(new String[0]);
    }

    /** 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);
    }

    // ---- config getters/setters -------------------------------------------

    public String getPdftoppmPath() {
        return pdftoppmPath;
    }

    /** The {@code pdftoppm} executable; {@code "pdftoppm"} (on the path) by default. */
    public void setPdftoppmPath(String pdftoppmPath) {
        this.pdftoppmPath = pdftoppmPath;
    }

    public int getDpi() {
        return defaults.getDpi();
    }

    /**
     * @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 this. Configure {@code "pages": {"render": {"dpi": ...}}}.
     */
    @Deprecated
    public void setDpi(int dpi) {
        defaults.setDpi(dpi);
    }

    public boolean isGray() {
        return defaults.getImageType() == ImageType.GRAY;
    }

    /** @deprecated since 4.1.0; see {@link #setDpi}: {@code pages.render.imageType}. */
    @Deprecated
    public void setGray(boolean gray) {
        defaults.setImageType(gray ? ImageType.GRAY : ImageType.RGB);
    }

    public int getTimeoutMillis() {
        return timeoutMillis;
    }

    /** Timeout for one {@code pdftoppm} call (one page); 120000 by default. */
    public void setTimeoutMillis(int timeoutMillis) {
        this.timeoutMillis = timeoutMillis;
    }

    public int getMaxScaleTo() {
        return maxScaleTo;
    }

    /** Longest side of a rendered page in pixels; 4096 by default, -1 for none. */
    public void setMaxScaleTo(int maxScaleTo) {
        if (maxScaleTo < 1 && maxScaleTo != -1) {
            throw new IllegalArgumentException(
                    "maxScaleTo must be -1 (disabled) or at least 1, got: "
                            + maxScaleTo);
        }
        this.maxScaleTo = maxScaleTo;
    }
}