This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new d8fe4bc7ac update ocr configs for PDFs (#3180)
d8fe4bc7ac is described below

commit d8fe4bc7ac3784141cdd1c9cf07f634e5cf4d969
Author: Tim Allison <[email protected]>
AuthorDate: Tue Sep 15 15:04:56 2026 -0400

    update ocr configs for PDFs (#3180)
---
 CHANGES.txt                                        |   9 +
 .../pages/configuration/parsers/pdf-parser.adoc    |  23 ++
 .../resources/config-examples/pdf-parser-full.json |  12 +
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  |  11 +-
 .../java/org/apache/tika/parser/pdf/PDF2XHTML.java |  29 +-
 .../java/org/apache/tika/parser/pdf/PDFParser.java |  34 ++-
 .../apache/tika/parser/pdf/PDFParserConfig.java    |  10 +
 .../apache/tika/parser/pdf/RenderingConfig.java    | 132 +++++++++
 .../tika/renderer/pdf/pdfbox/PDFBoxRenderer.java   |  72 +++--
 .../tika/parser/pdf/PDFRenderingConfigTest.java    | 303 +++++++++++++++++++++
 10 files changed, 586 insertions(+), 49 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 27a3d6d8af..bf78778206 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,14 @@
 Release 4.1.0 - unreleased
 
+   * PDF: "pdf-parser": {"rendering": {"dpi", "imageType", "imageFormat",
+     "imageQuality", "maxImagePixels"}} sets the page images emitted under
+     RENDER_PAGES_BEFORE_PARSE and RENDER_PAGES_AT_PAGE_END; a field left out
+     takes the "ocr" value. OCR, annotators and PAGES inference input keep
+     rendering with "ocr", and share the emitted render only when it is the
+     same image. maxImagePixels now also bounds pages rendered through
+     pdfbox-renderer; a page it skips leaves a warning on the PDF instead of
+     vanishing (TIKA-4889).
+
    * Tagged PDFs: text drawn twice for a bold effect with one copy tagged
      came out as letters with spaces between them; each copy now reads as
      its words. A tree that puts every glyph in its own paragraph (some form
diff --git a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
index 039beacf62..5a1714f04c 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
@@ -95,6 +95,29 @@ twice, and `EXTRACT` means no page OCR anywhere while the 
other enrichers still
 
 `pdf:ocr-page-count` records how many pages tripped the verdict, whether or 
not an engine ran.
 
+=== Page images (`rendering`)
+
+The page images the parser emits as embedded documents under 
`RENDER_PAGES_BEFORE_PARSE` or
+`RENDER_PAGES_AT_PAGE_END` have their own `rendering` block: `dpi`, 
`imageType`, `imageFormat`,
+`imageQuality` and `maxImagePixels`. A field left out takes the `ocr` value, 
so with no block
+the emitted pages match the OCR renders as in 4.0. OCR, page annotators and 
PAGES inference
+input always render with the `ocr` settings; when the two blocks would produce 
the same image,
+one render per page serves both, otherwise OCR renders again.
+
+[source,json]
+----
+{
+  "pdf-parser": {
+    "imageStrategy": "RENDER_PAGES_AT_PAGE_END",
+    "maxRenderedPages": 1,
+    "rendering": { "dpi": 96, "imageType": "RGB" }
+  }
+}
+----
+
+renders a colour first-page preview at 96 dpi while OCR keeps its 300 dpi 
grayscale pages. A
+page that would exceed `maxImagePixels` is not rendered and leaves a warning 
on the PDF.
+
 == Tagged PDFs (`markedContent`)
 
 A tagged PDF carries a structure tree: paragraphs, headings, lists, tables, 
links, and which
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/pdf-parser-full.json
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/pdf-parser-full.json
index 2e1ae9e0d3..b3619743f6 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/pdf-parser-full.json
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/pdf-parser-full.json
@@ -103,6 +103,18 @@
         },
         // Parse prior incremental-update versions as embedded documents.
         "parseIncrementalUpdates": false,
+        // The page images emitted under RENDER_PAGES_BEFORE_PARSE / 
RENDER_PAGES_AT_PAGE_END.
+        // A field left out takes the "ocr" value; OCR itself always renders 
with "ocr".
+        "rendering": {
+          "dpi": 96,
+          // Options: RGB, GRAY
+          "imageType": "RGB",
+          // Options: PNG, TIFF, JPEG
+          "imageFormat": "PNG",
+          "imageQuality": 0.5,
+          // Skip pages that would render larger than this area (w x h); -1 = 
no limit.
+          "maxImagePixels": 100000000
+        },
         // EXPERT: set the Sun KCMS color-management system property. Default 
false.
         "setKCMS": false,
         // Sort text by x/y position; helps some PDFs, can interleave columns 
in others.
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
index 835192d828..5fa8ed36c1 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
@@ -842,11 +842,8 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         //if the full document has already been rendered, then reuse that file
         //TODO: we need to prevent this if only a portion of the page or 
portions
         //of the page have been rendered.
-        //TODO: we should also figure out how to not reuse the rendering if
-        //the user wants to render twice (say, full color to display to users, 
but
-        //grayscale for (notionally?) better OCR).
         PageBasedRenderResults results = (PageBasedRenderResults) 
renderingState.getRenderResults();
-        if (results != null) {
+        if (results != null && pageImageIsOcrImage()) {
             List<RenderResult> pageResults = 
results.getPage(getCurrentPageNo());
             if (pageResults.size() == 1) {
                 return pageResults.get(0);
@@ -879,6 +876,12 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         }
     }
 
+    /** A page image rendered before the parse serves OCR only if OCR would 
render it the same. */
+    private boolean pageImageIsOcrImage() {
+        return config.getRendering().resolve(config.getOcr())
+                .rendersSameImageAs(RenderingConfig.from(config.getOcr()));
+    }
+
     private Renderer getPDFRenderer() {
         if (renderer == null) {
             return null;
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDF2XHTML.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDF2XHTML.java
index 033619eddd..7663c7d28b 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDF2XHTML.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDF2XHTML.java
@@ -164,6 +164,18 @@ class PDF2XHTML extends AbstractPDF2XHTML {
         }
     }
 
+    /** The page image for the consumer: rendered with the "rendering" 
settings, not OCR's. */
+    private RenderResults renderPageImage(TikaInputStream tis, Metadata 
renderedMetadata,
+                                          RenderRequest request) throws 
IOException, TikaException {
+        RenderingConfig outer = context.get(RenderingConfig.class);
+        context.set(RenderingConfig.class, 
config.getRendering().resolve(config.getOcr()));
+        try {
+            return renderer.render(tis, renderedMetadata, context, request);
+        } finally {
+            context.set(RenderingConfig.class, outer);
+        }
+    }
+
     private void renderPage(PDPage page) throws IOException {
         if (config.getImageStrategy() != 
PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_AT_PAGE_END) {
             return;
@@ -181,17 +193,16 @@ class PDF2XHTML extends AbstractPDF2XHTML {
         Metadata renderedMetadata = Metadata.newInstance(context);
         renderedMetadata.set(TikaCoreProperties.TYPE, 
PDFParser.MEDIA_TYPE.toString());
         // the page step enriches the render itself; the embedded copy is 
bytes and metadata
-        try (RenderResults results = renderer.render(tis, renderedMetadata, 
context, request);
+        try (RenderResults results = renderPageImage(tis, renderedMetadata, 
request);
                 ContentEnrichers.Suspension suspension = 
ContentEnrichers.suspend(context)) {
             for (RenderResult result : results.getResults()) {
-                if (result.getStatus() == RenderResult.STATUS.SUCCESS) {
-                    if 
(embeddedDocumentExtractor.shouldParseEmbedded(result.getMetadata(), context)) {
-
-                        try (TikaInputStream resultInputStream = 
result.getInputStream()) {
-                            //TODO: add markup here?
-                            
embeddedDocumentExtractor.parseEmbedded(resultInputStream, xhtml,
-                                    result.getMetadata(), context, true);
-                        }
+                if (result.getStatus() != RenderResult.STATUS.SUCCESS) {
+                    PDFParser.carryRenderWarnings(result, metadata);
+                } else if 
(embeddedDocumentExtractor.shouldParseEmbedded(result.getMetadata(), context)) {
+                    try (TikaInputStream resultInputStream = 
result.getInputStream()) {
+                        //TODO: add markup here?
+                        
embeddedDocumentExtractor.parseEmbedded(resultInputStream, xhtml,
+                                result.getMetadata(), context, true);
                     }
                 }
             }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
index 4572bd48d5..ab2f6ca499 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
@@ -474,21 +474,29 @@ public class PDFParser implements Parser, 
RenderingParser, EnrichingParser {
         // the page step enriches each render itself; the embedded copies are 
bytes and metadata
         try (ContentEnrichers.Suspension suspension = 
ContentEnrichers.suspend(context)) {
             for (RenderResult result : renderResults.getResults()) {
-                if (result.getStatus() == RenderResult.STATUS.SUCCESS) {
-                    if 
(embeddedDocumentExtractor.shouldParseEmbedded(result.getMetadata(), context)) {
-                        try (TikaInputStream tis = result.getInputStream()) {
-                            embeddedDocumentExtractor.parseEmbedded(tis, 
xhtml, result.getMetadata(), context, false);
-                        } catch (SecurityException e) {
-                            throw e;
-                        } catch (Exception e) {
-                            EmbeddedDocumentUtil.recordException(e, 
parentMetadata, context);
-                        }
+                if (result.getStatus() != RenderResult.STATUS.SUCCESS) {
+                    carryRenderWarnings(result, parentMetadata);
+                } else if 
(embeddedDocumentExtractor.shouldParseEmbedded(result.getMetadata(), context)) {
+                    try (TikaInputStream tis = result.getInputStream()) {
+                        embeddedDocumentExtractor.parseEmbedded(tis, xhtml, 
result.getMetadata(), context, false);
+                    } catch (SecurityException e) {
+                        throw e;
+                    } catch (Exception e) {
+                        EmbeddedDocumentUtil.recordException(e, 
parentMetadata, context);
                     }
                 }
             }
         }
     }
 
+    /** A page the renderer could not make is a warning on the PDF, not a 
silent gap. */
+    static void carryRenderWarnings(RenderResult result, Metadata 
parentMetadata) {
+        for (String warning : result.getMetadata()
+                .getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)) {
+            parentMetadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, 
warning);
+        }
+    }
+
     private RenderResults renderPDF(TikaInputStream tstream,
                                     ParseContext parseContext, PDFParserConfig 
localConfig)
             throws IOException, TikaException {
@@ -497,7 +505,13 @@ public class PDFParser implements Parser, RenderingParser, 
EnrichingParser {
         int maxRenderedPages = localConfig.getMaxRenderedPages();
         PageRangeRequest pages = maxRenderedPages > 0
                 ? new PageRangeRequest(1, maxRenderedPages) : 
PageRangeRequest.RENDER_ALL;
-        return renderer.render(tstream, metadata, parseContext, pages);
+        RenderingConfig outer = parseContext.get(RenderingConfig.class);
+        parseContext.set(RenderingConfig.class, 
localConfig.getRendering().resolve(localConfig.getOcr()));
+        try {
+            return renderer.render(tstream, metadata, parseContext, pages);
+        } finally {
+            parseContext.set(RenderingConfig.class, outer);
+        }
     }
 
     protected PDDocument getPDDocument(TikaInputStream tis, String password,
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
index d7948bef97..0d53a3b3e5 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
@@ -135,6 +135,7 @@ public class PDFParserConfig implements Serializable {
     private boolean ifXFAExtractOnlyXFA = false;
 
     private OcrConfig ocr = new OcrConfig();
+    private RenderingConfig rendering = new RenderingConfig();
     /** Null until set; {@link #getText()} then falls back to the {@code 
ocr.strategy} alias. */
     private TextPolicy text;
 
@@ -552,6 +553,15 @@ public class PDFParserConfig implements Serializable {
         this.ocr = ocr;
     }
 
+    /** Settings for the page images emitted under RENDER_PAGES_*; unset 
fields follow {@code ocr}. */
+    public RenderingConfig getRendering() {
+        return rendering;
+    }
+
+    public void setRendering(RenderingConfig rendering) {
+        this.rendering = rendering == null ? new RenderingConfig() : rendering;
+    }
+
     /** The text policy: {@code "text"} if set, else the {@code ocr.strategy} 
alias, else AUTO. */
     public TextPolicy getText() {
         if (text != null) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/RenderingConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/RenderingConfig.java
new file mode 100644
index 0000000000..8b76a20543
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/RenderingConfig.java
@@ -0,0 +1,132 @@
+/*
+ * 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.Serializable;
+import java.util.Objects;
+
+/**
+ * Settings for the page images the parser emits as embedded documents under
+ * {@code imageStrategy} {@code RENDER_PAGES_BEFORE_PARSE} or {@code 
RENDER_PAGES_AT_PAGE_END}:
+ * {@code "pdf-parser": {"rendering": {"dpi": 96, "imageType": "RGB"}}}. A 
field left unset
+ * takes the {@code ocr} value, so with no block at all these renders keep 
matching the OCR
+ * renders as they did in 4.0. OCR, page annotators and PAGES inference input 
always render
+ * with the {@code ocr} settings.
+ *
+ * @since Apache Tika 4.1.0
+ */
+public class RenderingConfig implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Integer dpi;
+    private OcrConfig.ImageType imageType;
+    private OcrConfig.ImageFormat imageFormat;
+    private Float imageQuality;
+    private Long maxImagePixels;
+
+    /** The OCR settings, as a fully set block. */
+    public static RenderingConfig from(OcrConfig ocr) {
+        RenderingConfig config = new RenderingConfig();
+        config.dpi = ocr.getDpi();
+        config.imageType = ocr.getImageType();
+        config.imageFormat = ocr.getImageFormat();
+        config.imageQuality = ocr.getImageQuality();
+        config.maxImagePixels = ocr.getMaxImagePixels();
+        return config;
+    }
+
+    /** This block with every unset field taken from {@code ocr}. */
+    public RenderingConfig resolve(OcrConfig ocr) {
+        RenderingConfig resolved = from(ocr);
+        if (dpi != null) {
+            resolved.dpi = dpi;
+        }
+        if (imageType != null) {
+            resolved.imageType = imageType;
+        }
+        if (imageFormat != null) {
+            resolved.imageFormat = imageFormat;
+        }
+        if (imageQuality != null) {
+            resolved.imageQuality = imageQuality;
+        }
+        if (maxImagePixels != null) {
+            resolved.maxImagePixels = maxImagePixels;
+        }
+        return resolved;
+    }
+
+    /** True when a page rendered with either block is the same image. */
+    public boolean rendersSameImageAs(RenderingConfig other) {
+        return Objects.equals(dpi, other.dpi) && imageType == other.imageType
+                && imageFormat == other.imageFormat
+                && Objects.equals(imageQuality, other.imageQuality);
+    }
+
+    public Integer getDpi() {
+        return dpi;
+    }
+
+    public void setDpi(Integer dpi) {
+        if (dpi != null && dpi < 1) {
+            throw new IllegalArgumentException("dpi must be at least 1, got: " 
+ dpi);
+        }
+        this.dpi = dpi;
+    }
+
+    public OcrConfig.ImageType getImageType() {
+        return imageType;
+    }
+
+    public void setImageType(OcrConfig.ImageType imageType) {
+        this.imageType = imageType;
+    }
+
+    public OcrConfig.ImageFormat getImageFormat() {
+        return imageFormat;
+    }
+
+    public void setImageFormat(OcrConfig.ImageFormat imageFormat) {
+        this.imageFormat = imageFormat;
+    }
+
+    public Float getImageQuality() {
+        return imageQuality;
+    }
+
+    public void setImageQuality(Float imageQuality) {
+        if (imageQuality != null && (imageQuality < 0f || imageQuality > 1f)) {
+            throw new IllegalArgumentException(
+                    "imageQuality must be between 0.0 and 1.0, got: " + 
imageQuality);
+        }
+        this.imageQuality = imageQuality;
+    }
+
+    public Long getMaxImagePixels() {
+        return maxImagePixels;
+    }
+
+    /** Pages that would render larger than this many pixels are skipped; -1 
for no limit. */
+    public void setMaxImagePixels(Long maxImagePixels) {
+        if (maxImagePixels != null && maxImagePixels < 1 && maxImagePixels != 
-1) {
+            throw new IllegalArgumentException(
+                    "maxImagePixels must be -1 (no limit) or at least 1, got: 
" + maxImagePixels);
+        }
+        this.maxImagePixels = maxImagePixels;
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
index d00c6e1297..49caee0a4a 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/renderer/pdf/pdfbox/PDFBoxRenderer.java
@@ -27,6 +27,8 @@ 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;
@@ -48,6 +50,7 @@ import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.pdf.PDFParser;
 import org.apache.tika.parser.pdf.PDFParserConfig;
 import org.apache.tika.parser.pdf.PDFRandomAccess;
+import org.apache.tika.parser.pdf.RenderingConfig;
 import org.apache.tika.renderer.PageBasedRenderResults;
 import org.apache.tika.renderer.PageRangeRequest;
 import org.apache.tika.renderer.RenderRequest;
@@ -182,7 +185,8 @@ public class PDFBoxRenderer implements PDDocumentRenderer {
             try {
                 m.set(TikaPagedText.PAGE_NUMBER, i);
                 m.set(TikaPagedText.PAGE_ROTATION, 
(double)pdDocument.getPage(i - 1).getRotation());
-                results.add(renderPage(renderer, id, i, m, parseContext));
+                results.add(renderPage(renderer, pdDocument.getPage(i - 1), 
id, i, m,
+                        parseContext));
             } catch (IOException e) {
                 EmbeddedDocumentUtil.recordException(e, m, parseContext);
                 results.add(new RenderResult(RenderResult.STATUS.EXCEPTION, 
id, null, m));
@@ -190,24 +194,33 @@ public class PDFBoxRenderer implements PDDocumentRenderer 
{
         }
     }
 
-    protected RenderResult renderPage(PDFRenderer renderer, int id, int 
pageNumber,
+    protected RenderResult renderPage(PDFRenderer renderer, PDPage page, int 
id, int pageNumber,
                                       Metadata metadata, ParseContext 
parseContext)
             throws IOException {
-
+        int dpi = getDPI(parseContext);
+        long maxPixels = getMaxImagePixels(parseContext);
+        if (maxPixels > 0) {
+            PDRectangle mediaBox = page.getMediaBox();
+            long estWidth = (long) Math.ceil(mediaBox.getWidth() / 72.0 * dpi);
+            long estHeight = (long) Math.ceil(mediaBox.getHeight() / 72.0 * 
dpi);
+            if (estWidth * estHeight > maxPixels) {
+                throw new IOException("page " + pageNumber + " would render to 
" + estWidth + "x"
+                        + estHeight + " pixels at " + dpi + " dpi, above 
maxImagePixels "
+                        + maxPixels);
+            }
+        }
         Path tmpFile = Files.createTempFile("tika-pdfbox-rendering-",
                 "-" + id + "-" + pageNumber + "." + 
getImageFormatName(parseContext));
         try {
             long start = System.currentTimeMillis();
             //TODO: parameterize whether or not to un-rotate page?
             BufferedImage image = renderer.renderImageWithDPI(
-                    pageNumber - 1,
-                    getDPI(parseContext),
-                    getImageType(parseContext));
+                    pageNumber - 1, dpi, getImageType(parseContext));
             long renderingElapsed = System.currentTimeMillis() - start;
             metadata.set(PDFBOX_RENDERING_TIME_MS, renderingElapsed);
             start = System.currentTimeMillis();
             try (OutputStream os = Files.newOutputStream(tmpFile)) {
-                ImageIOUtil.writeImage(image, 
getImageFormatName(parseContext), os, getDPI(parseContext),
+                ImageIOUtil.writeImage(image, 
getImageFormatName(parseContext), os, dpi,
                         getImageQuality(parseContext));
             }
             long elapsedWrite = System.currentTimeMillis() - start;
@@ -244,34 +257,41 @@ public class PDFBoxRenderer implements PDDocumentRenderer 
{
     }
 
     protected float getImageQuality(ParseContext parseContext) {
-        PDFParserConfig pdfParserConfig = 
parseContext.get(PDFParserConfig.class);
-        if (pdfParserConfig == null) {
-            return defaultImageQuality;
-        }
-        return pdfParserConfig.getOcr().getImageQuality();
+        RenderingConfig settings = settings(parseContext);
+        return settings == null ? defaultImageQuality : 
settings.getImageQuality();
     }
 
     protected int getDPI(ParseContext parseContext) {
-        PDFParserConfig pdfParserConfig = 
parseContext.get(PDFParserConfig.class);
-        if (pdfParserConfig == null) {
-            return defaultDPI;
-        }
-        return pdfParserConfig.getOcr().getDpi();
+        RenderingConfig settings = settings(parseContext);
+        return settings == null ? defaultDPI : settings.getDpi();
     }
 
     protected ImageType getImageType(ParseContext parseContext) {
-        PDFParserConfig pdfParserConfig = 
parseContext.get(PDFParserConfig.class);
-        if (pdfParserConfig == null) {
-            return defaultImageType;
-        }
-        return pdfParserConfig.getOcr().getImageType().getPdfBoxImageType();
+        RenderingConfig settings = settings(parseContext);
+        return settings == null ? defaultImageType : 
settings.getImageType().getPdfBoxImageType();
     }
 
     protected String getImageFormatName(ParseContext parseContext) {
-        PDFParserConfig pdfParserConfig = 
parseContext.get(PDFParserConfig.class);
-        if (pdfParserConfig == null) {
-            return defaultImageFormatName;
+        RenderingConfig settings = settings(parseContext);
+        return settings == null ? defaultImageFormatName : 
settings.getImageFormat().getFormatName();
+    }
+
+    /** -1 for no limit. */
+    protected long getMaxImagePixels(ParseContext parseContext) {
+        RenderingConfig settings = settings(parseContext);
+        return settings == null ? -1 : settings.getMaxImagePixels();
+    }
+
+    /**
+     * The page-image settings the PDF parser scopes around an emitted render, 
else the OCR
+     * settings; null when no PDF parser config is in the context.
+     */
+    private static RenderingConfig settings(ParseContext parseContext) {
+        RenderingConfig emitted = parseContext.get(RenderingConfig.class);
+        if (emitted != null) {
+            return emitted;
         }
-        return pdfParserConfig.getOcr().getImageFormat().getFormatName();
+        PDFParserConfig pdfParserConfig = 
parseContext.get(PDFParserConfig.class);
+        return pdfParserConfig == null ? null : 
RenderingConfig.from(pdfParserConfig.getOcr());
     }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFRenderingConfigTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFRenderingConfigTest.java
new file mode 100644
index 0000000000..3f97462bce
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFRenderingConfigTest.java
@@ -0,0 +1,303 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import javax.imageio.ImageIO;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.rendering.PDFRenderer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
+import org.apache.tika.parser.enricher.TextRecognizer;
+import org.apache.tika.renderer.RenderResult;
+import org.apache.tika.renderer.pdf.pdfbox.PDFBoxRenderer;
+import org.apache.tika.sax.ToXMLContentHandler;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * {@code "rendering"} sets the page images the parser emits; OCR keeps 
rendering with the
+ * {@code ocr} settings, and the two share one render only when it would be 
the same image.
+ */
+public class PDFRenderingConfigTest extends TikaTest {
+
+    private static final String TWO_PAGES = "testPDF_bookmarks.pdf";
+
+    @ParameterizedTest
+    @EnumSource(value = PDFParserConfig.IMAGE_STRATEGY.class,
+            names = {"RENDER_PAGES_BEFORE_PARSE", "RENDER_PAGES_AT_PAGE_END"})
+    public void testPageImageUsesRenderingBlock(PDFParserConfig.IMAGE_STRATEGY 
strategy)
+            throws Exception {
+        PDFParserConfig config = new PDFParserConfig();
+        config.setImageStrategy(strategy);
+        config.getRendering().setDpi(36);
+        config.getRendering().setImageType(OcrConfig.ImageType.RGB);
+        ImageSink pages = new ImageSink();
+
+        parse(config, pages, null);
+
+        assertEquals(2, pages.images.size());
+        for (Shape page : pages.images) {
+            assertEquals(pixelsAcross(36), page.width, "36 dpi, not ocr's 
300");
+            assertEquals(3, page.colorComponents, "RGB, not ocr's GRAY");
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(value = PDFParserConfig.IMAGE_STRATEGY.class,
+            names = {"RENDER_PAGES_BEFORE_PARSE", "RENDER_PAGES_AT_PAGE_END"})
+    public void testNoBlockFollowsOcr(PDFParserConfig.IMAGE_STRATEGY strategy) 
throws Exception {
+        PDFParserConfig config = new PDFParserConfig();
+        config.setImageStrategy(strategy);
+        config.getOcr().setDpi(36);
+        ImageSink pages = new ImageSink();
+
+        parse(config, pages, null);
+
+        assertEquals(2, pages.images.size());
+        for (Shape page : pages.images) {
+            assertEquals(pixelsAcross(36), page.width, "the 4.0 behaviour: 
ocr's dpi");
+            assertEquals(1, page.colorComponents, "the 4.0 behaviour: ocr's 
GRAY");
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(value = PDFParserConfig.IMAGE_STRATEGY.class,
+            names = {"RENDER_PAGES_BEFORE_PARSE", "RENDER_PAGES_AT_PAGE_END"})
+    public void testOcrKeepsItsOwnSettings(PDFParserConfig.IMAGE_STRATEGY 
strategy)
+            throws Exception {
+        PDFParserConfig config = new PDFParserConfig();
+        config.setImageStrategy(strategy);
+        // OCR-only text goes through OCR2XHTML, which emits no page images at 
page end
+        config.setText(PDFParserConfig.TextPolicy.EXTRACT_AND_OCR);
+        config.getOcr().setDpi(72);
+        config.getRendering().setDpi(36);
+        config.getRendering().setImageType(OcrConfig.ImageType.RGB);
+        ImageSink pages = new ImageSink();
+        ImageSink ocr = new ImageSink();
+
+        parse(config, pages, ocr);
+
+        assertEquals(2, ocr.images.size(), "the recognizer saw each page");
+        for (Shape page : ocr.images) {
+            assertEquals(pixelsAcross(72), page.width, "OCR renders at 
ocr.dpi");
+            assertEquals(1, page.colorComponents, "OCR renders in 
ocr.imageType");
+        }
+        assertEquals(2, pages.images.size(), "each page was emitted");
+        for (Shape page : pages.images) {
+            assertEquals(pixelsAcross(36), page.width);
+            assertEquals(3, page.colorComponents);
+        }
+    }
+
+    @Test
+    public void testBeforeParseRenderServesOcrOnlyWhenItIsTheSameImage() 
throws Exception {
+        PDFParserConfig same = new PDFParserConfig();
+        
same.setImageStrategy(PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_BEFORE_PARSE);
+        same.setText(PDFParserConfig.TextPolicy.EXTRACT_AND_OCR);
+        same.getOcr().setDpi(36);
+        assertEquals(2, renders(same), "one render per page feeds both the 
output and OCR");
+
+        PDFParserConfig different = new PDFParserConfig();
+        
different.setImageStrategy(PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_BEFORE_PARSE);
+        different.setText(PDFParserConfig.TextPolicy.EXTRACT_AND_OCR);
+        different.getOcr().setDpi(36);
+        different.getRendering().setImageType(OcrConfig.ImageType.RGB);
+        assertEquals(4, renders(different), "OCR renders again rather than 
read the RGB page");
+    }
+
+    @ParameterizedTest
+    @EnumSource(value = PDFParserConfig.IMAGE_STRATEGY.class,
+            names = {"RENDER_PAGES_BEFORE_PARSE", "RENDER_PAGES_AT_PAGE_END"})
+    public void testMaxImagePixelsSkipsThePageWithAWarning(
+            PDFParserConfig.IMAGE_STRATEGY strategy) throws Exception {
+        PDFParserConfig config = new PDFParserConfig();
+        config.setImageStrategy(strategy);
+        config.getRendering().setDpi(36);
+        config.getRendering().setMaxImagePixels(1000L);
+        ImageSink pages = new ImageSink();
+
+        Metadata metadata = parse(config, pages, null);
+
+        assertEquals(0, pages.images.size(), "no page fits in 1000 pixels");
+        String[] warnings = 
metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING);
+        assertEquals(2, warnings.length, "one warning per skipped page");
+        assertTrue(warnings[0].contains("maxImagePixels"), warnings[0]);
+    }
+
+    @Test
+    public void testJsonConfig() throws Exception {
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("pdf-parser", "{\"imageStrategy\": 
\"RENDER_PAGES_AT_PAGE_END\","
+                + " \"rendering\": {\"dpi\": 36, \"imageType\": \"RGB\", 
\"imageFormat\": \"JPEG\","
+                + " \"imageQuality\": 0.9, \"maxImagePixels\": -1}}");
+        List<Metadata> metadataList = getRecursiveMetadata(TWO_PAGES, context);
+        List<Metadata> renderings = new ArrayList<>();
+        for (Metadata m : metadataList) {
+            if (TikaCoreProperties.EmbeddedResourceType.RENDERING.name()
+                    .equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE))) 
{
+                renderings.add(m);
+            }
+        }
+        assertEquals(2, renderings.size());
+        assertEquals("image/jpeg", 
renderings.get(0).get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    @Test
+    public void testValidation() {
+        RenderingConfig config = new RenderingConfig();
+        assertThrows(IllegalArgumentException.class, () -> config.setDpi(0));
+        assertThrows(IllegalArgumentException.class, () -> 
config.setImageQuality(1.5f));
+        assertThrows(IllegalArgumentException.class, () -> 
config.setMaxImagePixels(0L));
+        config.setDpi(null);
+        config.setMaxImagePixels(-1L);
+    }
+
+    @Test
+    public void testResolveFallsBackPerField() {
+        OcrConfig ocr = new OcrConfig();
+        ocr.setDpi(150);
+        RenderingConfig block = new RenderingConfig();
+        block.setImageType(OcrConfig.ImageType.RGB);
+        RenderingConfig resolved = block.resolve(ocr);
+        assertEquals(150, resolved.getDpi());
+        assertEquals(OcrConfig.ImageType.RGB, resolved.getImageType());
+        assertEquals(ocr.getImageFormat(), resolved.getImageFormat());
+        assertTrue(new 
RenderingConfig().resolve(ocr).rendersSameImageAs(RenderingConfig.from(ocr)));
+        assertTrue(!resolved.rendersSameImageAs(RenderingConfig.from(ocr)));
+    }
+
+    /** Parses with a PDFParser whose embedded image parser and (optional) 
recognizer record what they see. */
+    private Metadata parse(PDFParserConfig config, ImageSink pages, ImageSink 
ocr)
+            throws Exception {
+        PDFParser parser = new PDFParser();
+        if (ocr != null) {
+            parser.setContentEnrichers(new 
CompositeContentEnricher(List.of(ocr)));
+        }
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        context.set(Parser.class, new AutoDetectParser(pages));
+        Metadata metadata = new Metadata();
+        try (TikaInputStream tis = TikaInputStream.get(
+                getResourceAsStream("/test-documents/" + TWO_PAGES))) {
+            parser.parse(tis, new ToXMLContentHandler(), metadata, context);
+        }
+        return metadata;
+    }
+
+    private int renders(PDFParserConfig config) throws Exception {
+        CountingRenderer renderer = new CountingRenderer();
+        PDFParser parser = new PDFParser();
+        parser.setRenderer(renderer);
+        parser.setContentEnrichers(new CompositeContentEnricher(List.of(new 
ImageSink())));
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        context.set(Parser.class, new AutoDetectParser(new ImageSink()));
+        try (TikaInputStream tis = TikaInputStream.get(
+                getResourceAsStream("/test-documents/" + TWO_PAGES))) {
+            parser.parse(tis, new ToXMLContentHandler(), new Metadata(), 
context);
+        }
+        return renderer.pages;
+    }
+
+    private int pixelsAcross(int dpi) throws IOException {
+        try (InputStream is = getResourceAsStream("/test-documents/" + 
TWO_PAGES);
+                PDDocument document = Loader.loadPDF(is.readAllBytes())) {
+            // PDFBox floors the scaled page width
+            PDPage page = document.getPage(0);
+            return (int) Math.max(1, Math.floor(page.getMediaBox().getWidth() 
* dpi / 72f));
+        }
+    }
+
+    private static final class Shape {
+        final int width;
+        final int colorComponents;
+
+        Shape(BufferedImage image) {
+            this.width = image.getWidth();
+            this.colorComponents = 
image.getColorModel().getNumColorComponents();
+        }
+    }
+
+    /** Reads every image it is handed, as an embedded-document parser or as 
the OCR engine. */
+    private static final class ImageSink implements Parser, TextRecognizer {
+        private static final long serialVersionUID = 1L;
+        final List<Shape> images = new ArrayList<>();
+
+        @Override
+        public boolean recognizesText(ParseContext context) {
+            return true;
+        }
+
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return Set.of(MediaType.image("png"), MediaType.image("jpeg"));
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                          ParseContext context) throws IOException, 
SAXException, TikaException {
+            BufferedImage image = ImageIO.read(tis);
+            assertNotNull(image, "not an image: " + 
metadata.get(HttpHeaders.CONTENT_TYPE));
+            images.add(new Shape(image));
+            XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata);
+            xhtml.startDocument();
+            xhtml.characters("seen");
+            xhtml.endDocument();
+        }
+    }
+
+    private static final class CountingRenderer extends PDFBoxRenderer {
+        private static final long serialVersionUID = 1L;
+        int pages;
+
+        @Override
+        protected RenderResult renderPage(PDFRenderer renderer, PDPage page, 
int id,
+                                          int pageNumber, Metadata metadata,
+                                          ParseContext parseContext) throws 
IOException {
+            pages++;
+            return super.renderPage(renderer, page, id, pageNumber, metadata, 
parseContext);
+        }
+    }
+}

Reply via email to