PageEmitter.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.parser.pdf;

import java.io.IOException;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.xml.sax.ContentHandler;
import org.xml.sax.SAXException;

import org.apache.tika.exception.EmbeddedLimitReachedException;
import org.apache.tika.exception.TikaException;
import org.apache.tika.exception.WriteLimitReachedException;
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
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.metadata.TikaPagedText;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.enricher.ContentEnrichers;
import org.apache.tika.parser.pages.PagesConfig;
import org.apache.tika.renderer.PageRangeRequest;
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.pdf.pdfbox.PDFBoxRenderer;
import org.apache.tika.renderer.pdf.pdfbox.PDFRenderingState;

/**
 * One parse's page renders: draws a page through the configured engine, and emits renders as
 * RENDERING embedded documents under {@code pages.emit} at the end of each page the writer
 * walks. The OCR render is reused when the emitted image is the same; otherwise the page is
 * rendered again with the emitted settings.
 */
final class PageEmitter {

    private final PDDocument pdDocument;
    private final PagesConfig pages;
    private final Renderer renderer;
    private final Metadata metadata;
    private final ParseContext context;
    private final boolean enabled;
    private int startedThrough = 0;
    private int emittedThrough = 0;

    PageEmitter(PDDocument pdDocument, PagesConfig pages, Renderer renderer, Metadata metadata,
                ParseContext context) {
        this.pdDocument = pdDocument;
        this.pages = pages;
        this.renderer = renderer;
        this.metadata = metadata;
        this.context = context;
        this.enabled = pages.getEmit().applies(metadata, context);
    }

    /**
     * The page drawn by the engine under these settings, OCR-only strategies through PDFBox.
     * The engine gets the open document and, when spooled, the file: each takes what it reads.
     */
    RenderResults render(int pageNo, RenderSettings settings, OcrConfig.RenderingStrategy strategy,
                         Metadata pageMetadata) throws IOException, TikaException {
        PDFRenderingState state = context.get(PDFRenderingState.class);
        TikaInputStream tis = state == null ? TikaInputStream.getPlaceholder()
                : state.getTikaInputStream();
        Object container = tis.getOpenContainer();
        RenderSettings outer = context.get(RenderSettings.class);
        Renderer engine = strategy == OcrConfig.RenderingStrategy.ALL ? renderer : pdfBox();
        tis.setOpenContainer(pdDocument);
        context.set(RenderSettings.class, settings);
        context.set(OcrConfig.RenderingStrategy.class, strategy);
        try {
            return engine.render(tis, pageMetadata, context, new PageRangeRequest(pageNo, pageNo));
        } finally {
            context.set(OcrConfig.RenderingStrategy.class, null);
            context.set(RenderSettings.class, outer);
            tis.setOpenContainer(container);
        }
    }

    private Renderer pdfBox() {
        return renderer instanceof PDFBoxRenderer ? renderer : new PDFBoxRenderer();
    }

    /** The writer started this 1-based page: the one in flight if it never ends. */
    void started(int pageNo) {
        startedThrough = pageNo;
    }

    /** Whether this 1-based page is emitted: on for this document and within {@code maxPages}. */
    boolean wants(int pageNo) {
        return enabled && pages.getEmit().withinBudget(pageNo);
    }

    /**
     * {@link #wants}, and the embedded-document extractor accepts what the child will be: asked
     * before anything is drawn, so a selector that refuses page images costs no render.
     */
    boolean accepts(int pageNo) {
        return wants(pageNo) && extractor().shouldParseEmbedded(probe(pageNo), context);
    }

    /**
     * Emits the page: {@code shared} when it is the OCR render of the same image (a failed one
     * was already reported, nothing is emitted), else a render with the emitted settings.
     * Failures are recorded on the PDF, never thrown: an unrenderable page is not a failed parse.
     * A caller passing {@code shared} has asked {@link #accepts} already.
     */
    void emit(int pageNo, RenderResult shared, ContentHandler handler) throws SAXException {
        if (!wants(pageNo)) {
            return;
        }
        emittedThrough = Math.max(emittedThrough, pageNo);
        try {
            if (shared == null && !accepts(pageNo)) {
                return;
            }
            if (shared != null) {
                if (shared.getStatus() == RenderResult.STATUS.SUCCESS) {
                    emitShared(pageNo, shared, handler);
                }
                return;
            }
            // too small to hold anything: a policy skip, not a failure
            double[] size = PDFBoxRenderer.pageSize(pdDocument.getPage(pageNo - 1));
            if (pages.emittedRender().belowMinimum(size[0], size[1])) {
                return;
            }
            Metadata pageMetadata = Metadata.newInstance(context);
            pageMetadata.set(TikaCoreProperties.TYPE, PDFParser.MEDIA_TYPE.toString());
            try (RenderResults results = render(pageNo, pages.emittedRender(),
                    OcrConfig.RenderingStrategy.ALL, pageMetadata)) {
                for (RenderResult result : results.getResults()) {
                    if (result.getStatus() == RenderResult.STATUS.SUCCESS) {
                        try (TikaInputStream tis = result.getInputStream()) {
                            emit(tis, result.getMetadata(), handler);
                        }
                    } else {
                        PDFParser.carryRenderWarnings(result, metadata);
                    }
                }
            }
        } catch (SecurityException | EmbeddedLimitReachedException e) {
            throw e;
        } catch (Exception e) {
            if (WriteLimitReachedException.isWriteLimitReached(e)) {
                throw e instanceof SAXException sax ? sax : new SAXException(e);
            }
            EmbeddedDocumentUtil.recordException(e, metadata, context);
            metadata.add(Rendering.RENDER_FAILED_PAGE, pageNo);
        }
    }

    /**
     * The page in flight when the writer died: started, never ended. Nothing when the caller
     * asked to stop; what it throws rides on the failure.
     */
    void emitUnfinished(ContentHandler handler, Throwable failure) {
        if (failure == null || failure instanceof SecurityException
                || failure instanceof EmbeddedLimitReachedException
                || WriteLimitReachedException.isWriteLimitReached(failure)
                || startedThrough <= emittedThrough) {
            return;
        }
        try {
            emit(startedThrough, null, handler);
        } catch (SecurityException e) {
            throw e;
        } catch (Exception e) {
            failure.addSuppressed(e);
        }
    }

    /** Every page within the budgets, for a writer that walks none. */
    void emitAll(ContentHandler handler, int maxPages) throws SAXException {
        int last = pdDocument.getNumberOfPages();
        if (maxPages > 0) {
            last = Math.min(last, maxPages);
        }
        for (int pageNo = 1; pageNo <= last && wants(pageNo); pageNo++) {
            emit(pageNo, null, handler);
        }
    }

    /** The OCR render's file under the embedded document's own metadata, not the OCR step's. */
    private void emitShared(int pageNo, RenderResult ocrRender, ContentHandler handler)
            throws IOException, SAXException {
        Metadata embedded = Metadata.newInstance(context);
        embedded.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
        embedded.set(TikaPagedText.PAGE_NUMBER, pageNo);
        String rotation = ocrRender.getMetadata().get(TikaPagedText.PAGE_ROTATION);
        if (rotation != null) {
            embedded.set(TikaPagedText.PAGE_ROTATION, rotation);
        }
        try (TikaInputStream source = ocrRender.getInputStream();
                TikaInputStream tis = TikaInputStream.get(source.getPath(), embedded)) {
            emit(tis, embedded, handler);
        }
    }

    private void emit(TikaInputStream tis, Metadata embedded, ContentHandler handler)
            throws IOException, SAXException {
        // the page step enriches the render itself; the embedded copy is bytes and metadata
        try (ContentEnrichers.Suspension suspension = ContentEnrichers.suspend(context)) {
            extractor().parseEmbedded(tis, handler, embedded, context, false);
        }
    }

    private EmbeddedDocumentExtractor extractor() {
        return EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
    }

    /** What the child will be, for a selector to accept or refuse before the render. */
    private Metadata probe(int pageNo) {
        Metadata probe = Metadata.newInstance(context);
        probe.set(TikaCoreProperties.TYPE, PDFParser.MEDIA_TYPE.toString());
        probe.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
        probe.set(TikaPagedText.PAGE_NUMBER, pageNo);
        probe.set(HttpHeaders.CONTENT_TYPE,
                "image/" + pages.emittedRender().getImageFormat().getFormatName());
        return probe;
    }
}