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 bdc72dedd1 TIKA-4883: AUTO OCR in PDFs must not discard extracted text 
unless OCR text replaced it (#3145)
bdc72dedd1 is described below

commit bdc72dedd182c8457afefe782875cb4916fa8085
Author: Tim Allison <[email protected]>
AuthorDate: Wed Sep 9 11:33:39 2026 -0400

    TIKA-4883: AUTO OCR in PDFs must not discard extracted text unless OCR text 
replaced it (#3145)
---
 CHANGES.txt                                        |  15 ++
 docs/modules/ROOT/pages/configuration/index.adoc   |   8 +
 .../tika/parser/enricher/ContentEnrichers.java     |  29 +++
 .../tika/parser/enricher/TextRecognizer.java       |  40 +++
 .../tika/parser/enricher/ContentEnrichersTest.java |  72 ++++++
 .../tika/parser/ocr/tess4j/Tess4JParser.java       |   3 +-
 .../apache/tika/parser/vlm/AbstractVLMParser.java  |   4 +-
 .../apache/tika/parser/ocr/TesseractOCRParser.java |  17 +-
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  | 286 ++++++++++++++++++---
 .../java/org/apache/tika/parser/pdf/PDF2XHTML.java |   1 +
 .../org/apache/tika/parser/pdf/PDFParserTest.java  | 261 +++++++++++++++++++
 11 files changed, 701 insertions(+), 35 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 95b46ec3af..b1de57d4f2 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,20 @@
 Release 4.1.0 - unreleased
 
+   * The PDF parser's AUTO OCR strategy no longer emits a page's extracted text
+     next to the OCR output that superseded it. Text that trips the AUTO 
verdict
+     is held back and replaced by OCR, and released unchanged when OCR cannot 
run
+     on that page: no engine on the classpath or in content-enrichers,
+     maxPagesToOcr exhausted, or a timeout or error. AUTO without an engine now
+     behaves as NO_OCR (TIKA-4883).
+
+   * New TextRecognizer capability for content enrichers (tika-core): a parser
+     implements it to say its output is the document's text rather than an
+     annotation. The PDF parser's AUTO strategy supersedes extracted text only
+     when a text recognizer is configured (Tesseract, Tess4J and the VLM 
parsers
+     declare it; an image-embedding enricher does not) and only when the engine
+     actually wrote text. Other enrichers still run on the rendered page
+     (TIKA-4883).
+
    * tika-app's -m and --json wrote metadata at the SAX endDocument event
      thereby dropping keys added by a parser after the endDocument (TIKA-4885).
      
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc 
b/docs/modules/ROOT/pages/configuration/index.adoc
index 76a807b913..b00b899dc8 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -158,6 +158,14 @@ unchanged — whichever OCR engine is on the classpath is 
used, exactly as befor
 logged at startup when several engines collide. Engine names: 
`tesseract-ocr-parser`, `tess4j-parser`, `openai-vlm-parser`,
 `claude-vlm-parser`, `gemini-vlm-parser`.
 
+An enricher that produces the document's *text*, such as an OCR engine or a 
VLM prompted to
+transcribe, implements `org.apache.tika.parser.enricher.TextRecognizer`. Only 
text recognizers
+count when a caller asks whether an engine can stand in for text it already 
has: the PDF parser's
+`AUTO` OCR strategy replaces a page's extracted text only if a configured 
enricher recognizes text
+and actually wrote some, and keeps the extracted text otherwise. Annotating 
enrichers such as an
+image-embedding parser still run on the rendered page; they never displace 
text. The bundled OCR
+engines and VLM parsers all declare the capability.
+
 == Windows file paths
 
 JSON treats the backslash as an escape character, so path options 
(`tesseractPath`,
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnrichers.java 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnrichers.java
index 62db23d9a6..ff448183d6 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnrichers.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnrichers.java
@@ -86,6 +86,35 @@ public final class ContentEnrichers {
         return null;
     }
 
+    /**
+     * Whether an enricher for this media type produces the document's text. 
Under a
+     * configured list, only members that are {@link TextRecognizer}s and 
recognize text
+     * for this context count. With no list, legacy {@code image/ocr-*} 
dispatch counts:
+     * that pseudo-type was the OCR contract. False while an enrichment is in 
progress,
+     * matching {@link #get}.
+     */
+    public static boolean hasTextRecognizer(CompositeContentEnricher enrichers,
+                                            MediaType mediaType, ParseContext 
context) {
+        if (mediaType == null) {
+            return false;
+        }
+        ActiveEnrichment active = context.get(ActiveEnrichment.class);
+        if (active != null && active.active) {
+            return false;
+        }
+        if (enrichers != null) {
+            for (Parser p : enrichers.getEnrichers(mediaType)) {
+                if (p instanceof TextRecognizer recognizer && 
recognizer.recognizesText(context)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+        Parser composite = EmbeddedDocumentUtil.getStatelessParser(context);
+        return composite != null && composite.getSupportedTypes(context)
+                .contains(LegacyDispatchEnricher.toOcrMediaType(mediaType));
+    }
+
     /**
      * Runs each enricher in config order, best-effort: the first failure is 
rethrown once
      * the chain completes, later ones suppressed onto it. Timeouts, 
SecurityException,
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/TextRecognizer.java 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/TextRecognizer.java
new file mode 100644
index 0000000000..73b8059a0a
--- /dev/null
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/TextRecognizer.java
@@ -0,0 +1,40 @@
+/*
+ * 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.enricher;
+
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * A content enricher whose output is the document's text: what the image, 
audio, or video
+ * says, written into the caller's body as content the caller may treat as its 
own. Other
+ * enrichers annotate (captions, embeddings, tags); a caller never substitutes 
their output
+ * for text it already has. Only text recognizers count when a caller such as 
the PDF
+ * parser's AUTO OCR strategy asks whether an engine can stand in for 
extracted text.
+ *
+ * @since Apache Tika 4.1
+ */
+public interface TextRecognizer {
+
+    /**
+     * Whether this configured instance recognizes text for this parse. A VLM 
is an OCR
+     * engine or a captioner depending on its prompt, and an OCR engine told 
to skip OCR
+     * recognizes nothing.
+     */
+    default boolean recognizesText(ParseContext context) {
+        return true;
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/parser/enricher/ContentEnrichersTest.java
 
b/tika-core/src/test/java/org/apache/tika/parser/enricher/ContentEnrichersTest.java
index 53635f8e49..a36c4325f7 100644
--- 
a/tika-core/src/test/java/org/apache/tika/parser/enricher/ContentEnrichersTest.java
+++ 
b/tika-core/src/test/java/org/apache/tika/parser/enricher/ContentEnrichersTest.java
@@ -17,6 +17,7 @@
 package org.apache.tika.parser.enricher;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -70,6 +71,77 @@ public class ContentEnrichersTest {
         }
     }
 
+    private static class RecognizingParser extends RecordingParser implements 
TextRecognizer {
+        private static final long serialVersionUID = 1L;
+        private final boolean recognizes;
+
+        RecognizingParser(Set<MediaType> types, boolean recognizes) {
+            super(types);
+            this.recognizes = recognizes;
+        }
+
+        @Override
+        public boolean recognizesText(ParseContext context) {
+            return recognizes;
+        }
+    }
+
+    private static CompositeContentEnricher listOf(Parser... enrichers) {
+        return new CompositeContentEnricher(List.of(enrichers));
+    }
+
+    @Test
+    public void testHasTextRecognizer() {
+        ParseContext context = new ParseContext();
+        RecordingParser annotator = new 
RecordingParser(Collections.singleton(PNG));
+        assertFalse(ContentEnrichers.hasTextRecognizer(listOf(annotator), PNG, 
context),
+                "an enricher that does not declare the capability is not a 
recognizer");
+        assertTrue(ContentEnrichers.hasTextRecognizer(
+                listOf(annotator, new 
RecognizingParser(Collections.singleton(PNG), true)),
+                PNG, context));
+        assertFalse(ContentEnrichers.hasTextRecognizer(
+                listOf(new RecognizingParser(Collections.singleton(PNG), 
false)), PNG, context),
+                "a recognizer that declines for this parse does not count");
+        assertFalse(ContentEnrichers.hasTextRecognizer(
+                listOf(new RecognizingParser(Collections.singleton(PNG), 
true)),
+                MediaType.image("tiff"), context));
+        assertFalse(ContentEnrichers.hasTextRecognizer(null, PNG, context));
+        assertFalse(ContentEnrichers.hasTextRecognizer(null, null, context));
+        // no list: legacy image/ocr-* dispatch is the OCR contract
+        context.set(Parser.class, new 
RecordingParser(Collections.singleton(OCR_PNG)));
+        assertTrue(ContentEnrichers.hasTextRecognizer(null, PNG, context));
+        assertFalse(ContentEnrichers.hasTextRecognizer(null, 
MediaType.image("tiff"), context));
+        // a configured list is authoritative over the legacy claim
+        assertFalse(ContentEnrichers.hasTextRecognizer(listOf(annotator), PNG, 
context));
+    }
+
+    @Test
+    public void testHasTextRecognizerRefusedDuringEnrichment() throws 
Exception {
+        ParseContext context = new ParseContext();
+        Parser reentrant = new Parser() {
+            private static final long serialVersionUID = 1L;
+
+            @Override
+            public Set<MediaType> getSupportedTypes(ParseContext ctx) {
+                return Collections.singleton(PNG);
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                              ParseContext ctx) {
+                metadata.set("nested-recognizer", 
ContentEnrichers.hasTextRecognizer(
+                        ctx.get(CompositeContentEnricher.class), PNG, ctx) ? 
"yes" : "no");
+            }
+        };
+        CompositeContentEnricher enrichers = listOf(
+                reentrant, new RecognizingParser(Collections.singleton(PNG), 
true));
+        context.set(CompositeContentEnricher.class, enrichers);
+        assertTrue(ContentEnrichers.hasTextRecognizer(enrichers, PNG, 
context));
+        Metadata metadata = new Metadata();
+        invoke(ContentEnrichers.get(enrichers, PNG, context), metadata, 
context);
+        assertEquals("no", metadata.get("nested-recognizer"));
+    }
+
     private static void invoke(Parser enricher, Metadata metadata, 
ParseContext context)
             throws IOException, SAXException, TikaException {
         try (TikaInputStream tis = TikaInputStream.get(new byte[0])) {
diff --git 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
index 2b87ec9d98..047170203a 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-parser-tess4j-module/src/main/java/org/apache/tika/parser/ocr/tess4j/Tess4JParser.java
@@ -55,6 +55,7 @@ import org.apache.tika.metadata.Metadata;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.enricher.TextRecognizer;
 import org.apache.tika.sax.XHTMLContentHandler;
 import org.apache.tika.utils.ProcessUtils;
 import org.apache.tika.utils.StringUtils;
@@ -87,7 +88,7 @@ import org.apache.tika.utils.StringUtils;
  * @since Apache Tika 4.0
  */
 @TikaComponent(name = "tess4j-parser")
-public class Tess4JParser implements Parser, Initializable {
+public class Tess4JParser implements Parser, Initializable, TextRecognizer {
 
     private static final long serialVersionUID = 1L;
 
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
index 5c46a4748c..0eb75978c5 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/AbstractVLMParser.java
@@ -47,6 +47,7 @@ import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.enricher.TextRecognizer;
 import org.apache.tika.sax.BodyContentHandler;
 import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.TeeContentHandler;
@@ -63,7 +64,8 @@ import org.apache.tika.sax.XHTMLContentHandler;
  *
  * @since Apache Tika 4.0
  */
-public abstract class AbstractVLMParser implements Parser, Initializable, 
Closeable {
+public abstract class AbstractVLMParser implements Parser, Initializable, 
Closeable,
+        TextRecognizer {
 
     private static final long serialVersionUID = 1L;
 
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
index 6312df608c..920fc978d3 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-ocr-module/src/main/java/org/apache/tika/parser/ocr/TesseractOCRParser.java
@@ -76,6 +76,7 @@ import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.AbstractExternalProcessParser;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.enricher.TextRecognizer;
 import org.apache.tika.sax.BodyContentHandler;
 import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.TeeContentHandler;
@@ -98,7 +99,8 @@ import org.apache.tika.utils.XMLReaderUtils;
  */
 // name pinned: the documented "content-enrichers" selector for this engine
 @TikaComponent(name = "tesseract-ocr-parser")
-public class TesseractOCRParser extends AbstractExternalProcessParser 
implements Initializable {
+public class TesseractOCRParser extends AbstractExternalProcessParser
+        implements Initializable, TextRecognizer {
 
     public static final String TESS_META = "tess:";
     public static final Property IMAGE_ROTATION = 
Property.externalRealSeq(TESS_META + "rotation");
@@ -300,6 +302,19 @@ public class TesseractOCRParser extends 
AbstractExternalProcessParser implements
         }
     }
 
+    @Override
+    public boolean recognizesText(ParseContext context) {
+        if (!hasTesseract) {
+            return false;
+        }
+        try {
+            return !getConfig(context).isSkipOcr();
+        } catch (TikaConfigException | IOException e) {
+            // parse() surfaces the broken config; for the question asked, 
nothing is recognized
+            return false;
+        }
+    }
+
     private TesseractOCRConfig getConfig(ParseContext parseContext) throws 
TikaConfigException, IOException {
         // Check for JSON config with component-specific runtime config
         if (parseContext.hasJsonConfig("tesseract-ocr-parser")) {
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 c3b60f8ab4..e06af48bed 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
@@ -35,6 +35,7 @@ import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Collections;
 import java.util.HashSet;
@@ -96,7 +97,9 @@ import org.apache.pdfbox.text.PDFTextStripper;
 import org.apache.pdfbox.tools.imageio.ImageIOUtil;
 import org.apache.pdfbox.util.Matrix;
 import org.apache.pdfbox.util.Vector;
+import org.xml.sax.Attributes;
 import org.xml.sax.ContentHandler;
+import org.xml.sax.Locator;
 import org.xml.sax.SAXException;
 import org.xml.sax.helpers.AttributesImpl;
 
@@ -133,6 +136,7 @@ import 
org.apache.tika.renderer.pdf.pdfbox.PDFRenderingState;
 import org.apache.tika.renderer.pdf.pdfbox.TextOnlyPDFRenderer;
 import org.apache.tika.renderer.pdf.pdfbox.VectorGraphicsOnlyPDFRenderer;
 import org.apache.tika.sax.BodyContentHandler;
+import org.apache.tika.sax.ContentHandlerDecorator;
 import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
 import org.apache.tika.utils.StringUtils;
@@ -162,6 +166,14 @@ class AbstractPDF2XHTML extends PDFTextStripper {
     final List<Exception> exceptions = new ArrayList<>();
     final PDDocument pdDocument;
     final XHTMLContentHandler xhtml;
+    // Non-null only for AUTO with a text recognizer: records each page's text 
for the verdict.
+    private final PageTextBuffer pageBuffer;
+    // Every enricher for the render type, resolved once per document; null 
skips the render.
+    final Parser ocrEngine;
+    final MediaType ocrImageMediaType;
+    private PageText pageDecision = PageText.UNDECIDED;
+    // Text held back from an OCR_WANTED page; replayed if OCR does not run.
+    private List<PageTextBuffer.SaxEvent> pendingText = 
Collections.emptyList();
     final ParseContext context;
     final Metadata metadata;
     final EmbeddedDocumentExtractor embeddedDocumentExtractor;
@@ -207,7 +219,17 @@ class AbstractPDF2XHTML extends PDFTextStripper {
                       Metadata metadata, PDFParserConfig config, Renderer 
renderer,
                       CompositeContentEnricher contentEnrichers) throws 
IOException {
         this.pdDocument = pdDocument;
-        this.xhtml = new XHTMLContentHandler(handler, metadata, context);
+        this.ocrImageMediaType =
+                
MediaType.image(config.getOcr().getImageFormat().getFormatName());
+        this.ocrEngine = ContentEnrichers.get(contentEnrichers, 
ocrImageMediaType, context);
+        if (config.getOcr().getStrategy() == AUTO && 
ContentEnrichers.hasTextRecognizer(
+                contentEnrichers, ocrImageMediaType, context)) {
+            this.pageBuffer = new PageTextBuffer(handler);
+            this.xhtml = new XHTMLContentHandler(pageBuffer, metadata, 
context);
+        } else {
+            this.pageBuffer = null;
+            this.xhtml = new XHTMLContentHandler(handler, metadata, context);
+        }
         this.context = context;
         this.metadata = metadata;
         this.config = config;
@@ -259,14 +281,65 @@ class AbstractPDF2XHTML extends PDFTextStripper {
 
     @Override
     protected void startPage(PDPage page) throws IOException {
+        pageDecision = PageText.UNDECIDED;
+        pendingText = Collections.emptyList();
         try {
             xhtml.startElement("div", "class", "page");
         } catch (SAXException e) {
             throw new IOException("Unable to start a page", e);
         }
+        if (pageBuffer != null) {
+            pageBuffer.start();
+        }
         writeParagraphStart();
     }
 
+    /**
+     * Ends the page's text phase: takes the AUTO verdict and either releases 
the recorded
+     * text in place or holds it until endPage knows whether OCR ran. 
Idempotent, so
+     * subclasses call it as soon as their text is written and endPage covers 
the rest.
+     */
+    void endPageText() throws IOException {
+        if (pageDecision != PageText.UNDECIDED) {
+            return;
+        }
+        List<PageTextBuffer.SaxEvent> captured =
+                pageBuffer == null ? Collections.emptyList() : 
pageBuffer.stop();
+        boolean wanted = config.getOcr().getStrategy() == AUTO &&
+                ocrWanted(pageBuffer == null ? null : pageBuffer.text());
+        pageDecision = wanted ? PageText.OCR_WANTED : PageText.KEEP;
+        if (wanted) {
+            pendingText = captured;
+        } else {
+            replay(captured);
+        }
+    }
+
+    private void replay(List<PageTextBuffer.SaxEvent> events) throws 
IOException {
+        if (events.isEmpty()) {
+            return;
+        }
+        try {
+            pageBuffer.replay(events);
+        } catch (SAXException e) {
+            throw new IOException("Unable to write page text", e);
+        }
+    }
+
+    /**
+     * AUTO verdict for the page whose text was just extracted. Counters only 
for now;
+     * pageText (null when nothing was recorded) is the seam for a junk 
detector (TIKA-4883).
+     */
+    boolean ocrWanted(String pageText) {
+        OcrConfig.StrategyAuto strategyAuto = 
config.getOcr().getStrategyAuto();
+        if (totalCharsPerPage <= strategyAuto.getTotalCharsPerPage()) {
+            return true;
+        }
+        float percentUnmapped = (float) unmappedUnicodeCharsPerPage / 
totalCharsPerPage;
+        float limit = strategyAuto.getUnmappedUnicodeCharsPerPage();
+        return (limit < 1) ? percentUnmapped > limit : 
unmappedUnicodeCharsPerPage > limit;
+    }
+
     private void extractXMPXFA() throws IOException, SAXException {
         Set<MediaType> supportedTypes = Collections.EMPTY_SET;
         Parser embeddedParser = context.get(Parser.class);
@@ -558,11 +631,15 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         }
     }
 
-    void doOCROnCurrentPage(PDPage pdPage, OcrConfig.Strategy ocrStrategy)
+    /**
+     * @return true if the engine wrote text for this page; false when there 
is no engine
+     * (AUTO), maxPagesToOcr is exhausted, a failure was recorded and 
swallowed, or the
+     * engine produced no text (an annotating enricher, or one told to skip 
OCR)
+     */
+    boolean doOCROnCurrentPage(PDPage pdPage, OcrConfig.Strategy ocrStrategy)
             throws IOException, TikaException, SAXException {
         if (ocrStrategy.equals(NO_OCR)) {
-            //I don't think this is reachable?
-            return;
+            return false;
         }
         //count the number of times that OCR would have been called
         OCRPageCounter c = context.get(OCRPageCounter.class);
@@ -573,31 +650,28 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         // Enforce maxPagesToOcr limit
         int maxPagesToOcr = config.getOcr().getMaxPagesToOcr();
         if (maxPagesToOcr > 0 && c != null && c.getCount() > maxPagesToOcr) {
-            return;
+            return false;
         }
-        MediaType imageMediaType =
-                
MediaType.image(config.getOcr().getImageFormat().getFormatName());
-        Parser enricher = ContentEnrichers.get(contentEnrichers, 
imageMediaType, context);
-        if (enricher == null) {
+        if (ocrEngine == null) {
             if (ocrStrategy == OCR_ONLY || ocrStrategy == 
OCR_AND_TEXT_EXTRACTION) {
                 throw new TikaException(
                         "I regret that I couldn't find an OCR engine to handle 
" +
-                                imageMediaType + ". Name one that covers it in 
" +
+                                ocrImageMediaType + ". Name one that covers it 
in " +
                                 "\"content-enrichers\" (a configured list is 
authoritative), " +
                                 "add one to the classpath when no list is 
configured, " +
                                 "or set the OCR strategy to NO_OCR.");
-            } else if (ocrStrategy == AUTO) {
-                //silently skip if there's no engine to run ocr
-                return;
             }
+            return false;
         }
 
         try (TemporaryResources tmp = new TemporaryResources()) {
             try (RenderResult renderResult = renderCurrentPage(pdPage, tmp)) {
                 Metadata renderMetadata = renderResult.getMetadata();
+                TextCounter counter = new TextCounter(xhtml);
                 try (TikaInputStream tis = renderResult.getInputStream()) {
-                    renderMetadata.set(HttpHeaders.CONTENT_TYPE, 
imageMediaType.toString());
-                    enricher.parse(tis, new EmbeddedContentHandler(new 
BodyContentHandler(xhtml)),
+                    renderMetadata.set(HttpHeaders.CONTENT_TYPE, 
ocrImageMediaType.toString());
+                    ocrEngine.parse(tis,
+                            new EmbeddedContentHandler(new 
BodyContentHandler(counter)),
                             renderMetadata, context);
                 }
                 // Propagate enrichment metadata added by the OCR parser (e.g. 
tk:chunks
@@ -609,6 +683,7 @@ class AbstractPDF2XHTML extends PDFTextStripper {
                             
mergeChunkArrays(metadata.get(TikaCoreProperties.TIKA_CHUNKS),
                                     renderChunks));
                 }
+                return counter.sawText();
             }
         } catch (IOException e) {
             handleCatchableIOE(e);
@@ -617,6 +692,7 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         } catch (SAXException e) {
             throw new IOException("error writing OCR content from PDF", e);
         }
+        return false;
     }
 
     /**
@@ -791,29 +867,16 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         metadata.add(PDF.CHARACTERS_PER_PAGE, totalCharsPerPage);
         metadata.add(PDF.UNMAPPED_UNICODE_CHARS_PER_PAGE, 
unmappedUnicodeCharsPerPage);
 
-
         try {
+            endPageText();
             for (PDAnnotation annotation : page.getAnnotations()) {
                 processPageAnnotation(annotation);
             }
             if (config.getOcr().getStrategy() == OCR_AND_TEXT_EXTRACTION) {
                 doOCROnCurrentPage(page, OCR_AND_TEXT_EXTRACTION);
-            } else if (config.getOcr().getStrategy() == AUTO) {
-                boolean unmappedExceedsLimit = false;
-                if (totalCharsPerPage > 
config.getOcr().getStrategyAuto().getTotalCharsPerPage()) {
-                    // There are enough characters to not have to do OCR.  
Check number of unmapped characters
-                    final float percentUnmapped =
-                            (float) unmappedUnicodeCharsPerPage / 
totalCharsPerPage;
-                    final float unmappedCharacterLimit =
-                            
config.getOcr().getStrategyAuto().getUnmappedUnicodeCharsPerPage();
-                    unmappedExceedsLimit = (unmappedCharacterLimit < 1) ?
-                            percentUnmapped > unmappedCharacterLimit :
-                            unmappedUnicodeCharsPerPage > 
unmappedCharacterLimit;
-                }
-                if (totalCharsPerPage <= 
config.getOcr().getStrategyAuto().getTotalCharsPerPage() ||
-                        unmappedExceedsLimit) {
-                    doOCROnCurrentPage(page, AUTO);
-                }
+            } else if (pageDecision == PageText.OCR_WANTED &&
+                    !doOCROnCurrentPage(page, AUTO)) {
+                replay(pendingText);
             }
 
             PDPageAdditionalActions pageActions = page.getActions();
@@ -829,6 +892,7 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         } finally {
             totalCharsPerPage = 0;
             unmappedUnicodeCharsPerPage = 0;
+            pendingText = Collections.emptyList();
         }
 
         if (config.isExtractFontNames() && page.getResources() != null) {
@@ -1618,6 +1682,164 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         }
     }
 
+    private enum PageText {
+        UNDECIDED, KEEP, OCR_WANTED
+    }
+
+    /** Notes whether any non-whitespace text passed through. */
+    private static class TextCounter extends ContentHandlerDecorator {
+        private boolean sawText;
+
+        TextCounter(ContentHandler handler) {
+            super(handler);
+        }
+
+        boolean sawText() {
+            return sawText;
+        }
+
+        @Override
+        public void characters(char[] ch, int start, int length) throws 
SAXException {
+            for (int i = start; !sawText && i < start + length; i++) {
+                sawText = !Character.isWhitespace(ch[i]);
+            }
+            super.characters(ch, start, length);
+        }
+    }
+
+    /**
+     * Sits under xhtml for AUTO with an engine. Records the SAX events 
between start() and
+     * stop() so the OCR verdict can replay or drop them; passes everything 
else through live.
+     */
+    private static class PageTextBuffer implements ContentHandler {
+        private final ContentHandler delegate;
+        private List<SaxEvent> buffer;
+        private StringBuilder text;
+
+        PageTextBuffer(ContentHandler delegate) {
+            this.delegate = delegate;
+        }
+
+        void start() {
+            buffer = new ArrayList<>();
+            text = new StringBuilder();
+        }
+
+        /** Stops recording and returns what was recorded (empty if not 
recording). */
+        List<SaxEvent> stop() {
+            List<SaxEvent> recorded = buffer == null ? Collections.emptyList() 
: buffer;
+            buffer = null;
+            return recorded;
+        }
+
+        /** Plain text of the last recording. */
+        String text() {
+            return text == null ? "" : text.toString();
+        }
+
+        void replay(List<SaxEvent> events) throws SAXException {
+            for (SaxEvent e : events) {
+                e.replay(delegate);
+            }
+        }
+
+        interface SaxEvent {
+            void replay(ContentHandler h) throws SAXException;
+        }
+
+        @Override
+        public void startElement(String uri, String localName, String qName, 
Attributes atts)
+                throws SAXException {
+            if (buffer != null) {
+                AttributesImpl copy = new AttributesImpl(atts);
+                buffer.add(h -> h.startElement(uri, localName, qName, copy));
+            } else {
+                delegate.startElement(uri, localName, qName, atts);
+            }
+        }
+
+        @Override
+        public void endElement(String uri, String localName, String qName) 
throws SAXException {
+            if (buffer != null) {
+                buffer.add(h -> h.endElement(uri, localName, qName));
+            } else {
+                delegate.endElement(uri, localName, qName);
+            }
+        }
+
+        @Override
+        public void characters(char[] ch, int start, int length) throws 
SAXException {
+            if (buffer != null) {
+                char[] copy = Arrays.copyOfRange(ch, start, start + length);
+                text.append(copy);
+                buffer.add(h -> h.characters(copy, 0, copy.length));
+            } else {
+                delegate.characters(ch, start, length);
+            }
+        }
+
+        @Override
+        public void ignorableWhitespace(char[] ch, int start, int length) 
throws SAXException {
+            if (buffer != null) {
+                char[] copy = Arrays.copyOfRange(ch, start, start + length);
+                buffer.add(h -> h.ignorableWhitespace(copy, 0, copy.length));
+            } else {
+                delegate.ignorableWhitespace(ch, start, length);
+            }
+        }
+
+        @Override
+        public void startPrefixMapping(String prefix, String uri) throws 
SAXException {
+            if (buffer != null) {
+                buffer.add(h -> h.startPrefixMapping(prefix, uri));
+            } else {
+                delegate.startPrefixMapping(prefix, uri);
+            }
+        }
+
+        @Override
+        public void endPrefixMapping(String prefix) throws SAXException {
+            if (buffer != null) {
+                buffer.add(h -> h.endPrefixMapping(prefix));
+            } else {
+                delegate.endPrefixMapping(prefix);
+            }
+        }
+
+        @Override
+        public void processingInstruction(String target, String data) throws 
SAXException {
+            if (buffer != null) {
+                buffer.add(h -> h.processingInstruction(target, data));
+            } else {
+                delegate.processingInstruction(target, data);
+            }
+        }
+
+        @Override
+        public void skippedEntity(String name) throws SAXException {
+            if (buffer != null) {
+                buffer.add(h -> h.skippedEntity(name));
+            } else {
+                delegate.skippedEntity(name);
+            }
+        }
+
+        @Override
+        public void setDocumentLocator(Locator locator) {
+            delegate.setDocumentLocator(locator);
+        }
+
+        @Override
+        public void startDocument() throws SAXException {
+            delegate.startDocument();
+        }
+
+        @Override
+        public void endDocument() throws SAXException {
+            delegate.endDocument();
+        }
+    }
+
     enum ActionTrigger {
         AFTER_DOCUMENT_PRINT, AFTER_DOCUMENT_SAVE, ANNOTATION_CURSOR_ENTERS, 
ANNOTATION_CURSOR_EXIT,
         ANNOTATION_LOSE_INPUT_FOCUS, ANNOTATION_MOUSE_CLICK, 
ANNOTATION_MOUSE_RELEASED,
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 1496f586d0..4be844c909 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
@@ -148,6 +148,7 @@ class PDF2XHTML extends AbstractPDF2XHTML {
     protected void endPage(PDPage page) throws IOException {
         try {
             writeParagraphEnd();
+            endPageText();
             try {
                 extractImages(page);
                 renderPage(page);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
index 8fd7eabd64..28c303e234 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
@@ -24,7 +24,9 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.Collections;
@@ -75,10 +77,13 @@ import org.apache.tika.parser.MetadataOnlyParse;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.PasswordProvider;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
+import org.apache.tika.parser.enricher.TextRecognizer;
 import org.apache.tika.sax.BodyContentHandler;
 import org.apache.tika.sax.ContentHandlerDecorator;
 import org.apache.tika.sax.ToXMLContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
+import org.apache.tika.utils.XMLReaderUtils;
 
 /**
  * Test case for parsing pdf files.
@@ -1533,6 +1538,262 @@ public class PDFParserTest extends TikaTest {
         assertEquals(1, metadataList.get(0).getInt(PDF.OCR_PAGE_COUNT));
     }
 
+    /**
+     * TIKA-4883: under AUTO with an engine, a page whose text triggers OCR 
reaches the
+     * handler as OCR output only, and every other page keeps its extracted 
text. At a 2%
+     * unmapped-glyph threshold, page 16 of testPDF_bad_page_303226.pdf is the 
only trigger.
+     */
+    @Test
+    public void testAutoOcrReplacesTriggeredPageOnly() throws Exception {
+        PDFParserConfig config = autoOcrTriggeringPage16();
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        context.set(Parser.class, mockOcrParser(config, "MOCK_OCR_CONTENT"));
+        Metadata metadata = new Metadata();
+
+        String xml = parsePdfToXml("testPDF_bad_page_303226.pdf", metadata, 
context);
+
+        assertContainsCount("MOCK_OCR_CONTENT", xml, 1);
+        assertNotContained("42936", xml);
+        assertContains("ANABOLIC", xml);
+        assertContains("1308.44", xml);
+        assertEquals(1, metadata.getInt(PDF.OCR_PAGE_COUNT));
+        assertWellFormed(xml);
+    }
+
+    /** TIKA-4883: AUTO with no engine runs as NO_OCR; the would-be page keeps 
its text. */
+    @Test
+    public void testAutoOcrWithoutEngineKeepsText() throws Exception {
+        PDFParserConfig config = autoOcrTriggeringPage16();
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        Metadata metadata = new Metadata();
+
+        String xml = parsePdfToXml("testPDF_bad_page_303226.pdf", metadata, 
context);
+
+        assertContains("42936", xml);
+        assertEquals(1, metadata.getInt(PDF.OCR_PAGE_COUNT),
+                "pages OCR would have run on are still counted");
+    }
+
+    /** TIKA-4883: an OCR timeout on the triggering page falls back to that 
page's text. */
+    @Test
+    public void testAutoOcrTimeoutFallsBackToText() throws Exception {
+        PDFParserConfig config = autoOcrTriggeringPage16();
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        context.set(Parser.class, timingOutOcrParser(config));
+        Metadata metadata = new Metadata();
+        ToXMLContentHandler handler = new ToXMLContentHandler();
+        TikaException thrown = null;
+        try (TikaInputStream tis =
+                     
getResourceAsStream("/test-documents/testPDF_bad_page_303226.pdf")) {
+            new PDFParser().parse(tis, handler, metadata, context);
+        } catch (TikaException e) {
+            thrown = e;
+        }
+        String xml = handler.toString();
+
+        assertContains("42936", xml);
+        assertContains("1308.44", xml);
+        assertNotNull(thrown, "the recorded timeout still surfaces once every 
page is done");
+        assertWellFormed(xml);
+    }
+
+    /** TIKA-4883: once maxPagesToOcr is exhausted, later triggering pages 
keep their text. */
+    @Test
+    public void testAutoOcrMaxPagesFallsBackToText() throws Exception {
+        PDFParserConfig config = new PDFParserConfig();
+        // every page is under the char threshold, so every page triggers
+        config.getOcr().setStrategyAuto(new OcrConfig.StrategyAuto(0.02f, 
1_000_000));
+        config.getOcr().setMaxPagesToOcr(1);
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        context.set(Parser.class, mockOcrParser(config, "MOCK_OCR_CONTENT"));
+        Metadata metadata = new Metadata();
+
+        String xml = parsePdfToXml("testPDF_bookmarks.pdf", metadata, context);
+
+        assertContainsCount("MOCK_OCR_CONTENT", xml, 1);
+        assertContainsCount("This is a page about", xml, 1);
+        assertContains("Denmark", xml);
+        assertEquals(2, metadata.getInt(PDF.OCR_PAGE_COUNT));
+    }
+
+    /** TIKA-4883: inline-image markers are written after the recorded text 
and survive OCR. */
+    @Test
+    public void testAutoOcrKeepsInlineImageMarkers() throws Exception {
+        PDFParserConfig config = new PDFParserConfig();
+        config.setExtractInlineImageMetadataOnly(true);
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        context.set(Parser.class, mockOcrParser(config, "MOCK_OCR_CONTENT"));
+        Metadata metadata = new Metadata();
+
+        String xml = parsePdfToXml("testOCR.pdf", metadata, context);
+
+        assertContains("MOCK_OCR_CONTENT", xml);
+        assertContains("<img src=\"embedded:", xml);
+        assertEquals(1, metadata.getInt(PDF.OCR_PAGE_COUNT));
+        assertWellFormed(xml);
+    }
+
+    /** TIKA-4883: an engine that returns without writing text leaves the 
page's text alone. */
+    @Test
+    public void testAutoOcrEngineWithoutTextKeepsText() throws Exception {
+        PDFParserConfig config = autoOcrTriggeringPage16();
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        context.set(Parser.class, mockOcrParser(config, null));
+        Metadata metadata = new Metadata();
+
+        String xml = parsePdfToXml("testPDF_bad_page_303226.pdf", metadata, 
context);
+
+        assertContains("42936", xml);
+        assertEquals(1, metadata.getInt(PDF.OCR_PAGE_COUNT));
+    }
+
+    /**
+     * TIKA-4883: a configured enricher that is not a TextRecognizer still 
runs on the
+     * rendered page (embeddings, tags) but never displaces the extracted text.
+     */
+    @Test
+    public void testAutoOcrAnnotatingEnricherKeepsText() throws Exception {
+        PageEnricher annotator = new PageEnricher(null, false);
+        PDFParser parser = new PDFParser();
+        parser.setContentEnrichers(new 
CompositeContentEnricher(List.of(annotator)));
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, autoOcrTriggeringPage16());
+        Metadata metadata = new Metadata();
+
+        String xml = parsePdfToXml(parser, "testPDF_bad_page_303226.pdf", 
metadata, context);
+
+        assertContains("42936", xml);
+        assertEquals(1, annotator.calls, "the render is still enriched");
+        assertEquals(1, metadata.getInt(PDF.OCR_PAGE_COUNT));
+    }
+
+    /** TIKA-4883: a configured TextRecognizer supersedes the triggering 
page's text. */
+    @Test
+    public void testAutoOcrConfiguredRecognizerReplacesText() throws Exception 
{
+        PageEnricher recognizer = new PageEnricher("MOCK_OCR_CONTENT", true);
+        PDFParser parser = new PDFParser();
+        parser.setContentEnrichers(new 
CompositeContentEnricher(List.of(recognizer)));
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, autoOcrTriggeringPage16());
+        Metadata metadata = new Metadata();
+
+        String xml = parsePdfToXml(parser, "testPDF_bad_page_303226.pdf", 
metadata, context);
+
+        assertContainsCount("MOCK_OCR_CONTENT", xml, 1);
+        assertNotContained("42936", xml);
+        assertContains("ANABOLIC", xml);
+        assertEquals(1, recognizer.calls);
+        assertWellFormed(xml);
+    }
+
+    /** Enricher for image/png that writes the given text (none if null). */
+    private static class PageEnricher implements Parser, TextRecognizer {
+        private static final long serialVersionUID = 1L;
+        private final String text;
+        private final boolean recognizes;
+        int calls = 0;
+
+        PageEnricher(String text, boolean recognizes) {
+            this.text = text;
+            this.recognizes = recognizes;
+        }
+
+        @Override
+        public boolean recognizesText(ParseContext context) {
+            return recognizes;
+        }
+
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return Collections.singleton(MediaType.image("png"));
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                          ParseContext context) throws IOException, 
SAXException, TikaException {
+            calls++;
+            XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata);
+            xhtml.startDocument();
+            if (text != null) {
+                xhtml.characters(text);
+            }
+            xhtml.endDocument();
+        }
+    }
+
+    private static PDFParserConfig autoOcrTriggeringPage16() {
+        PDFParserConfig config = new PDFParserConfig();
+        config.getOcr().setStrategyAuto(new OcrConfig.StrategyAuto(0.02f, 10));
+        return config;
+    }
+
+    /**
+     * Calls PDFParser directly: RecursiveParserWrapper installs its own 
Parser.class entry
+     * in the context, which would clobber the mock engine.
+     */
+    private String parsePdfToXml(String name, Metadata metadata, ParseContext 
context)
+            throws Exception {
+        return parsePdfToXml(new PDFParser(), name, metadata, context);
+    }
+
+    private String parsePdfToXml(PDFParser parser, String name, Metadata 
metadata,
+                                 ParseContext context) throws Exception {
+        ToXMLContentHandler handler = new ToXMLContentHandler();
+        try (TikaInputStream tis = getResourceAsStream("/test-documents/" + 
name)) {
+            parser.parse(tis, handler, metadata, context);
+        }
+        return handler.toString();
+    }
+
+    private static void assertWellFormed(String xml) throws Exception {
+        XMLReaderUtils.buildDOM(new 
ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
+    }
+
+    private static MediaType ocrMediaType(PDFParserConfig config) {
+        return MediaType.image("ocr-" + 
config.getOcr().getImageFormat().getFormatName());
+    }
+
+    private static Parser mockOcrParser(PDFParserConfig config, String text) {
+        return new Parser() {
+            @Override
+            public Set<MediaType> getSupportedTypes(ParseContext context) {
+                return Collections.singleton(ocrMediaType(config));
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                              ParseContext context) throws IOException, 
SAXException, TikaException {
+                XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata);
+                xhtml.startDocument();
+                if (text != null) {
+                    xhtml.characters(text);
+                }
+                xhtml.endDocument();
+            }
+        };
+    }
+
+    private static Parser timingOutOcrParser(PDFParserConfig config) {
+        return new Parser() {
+            @Override
+            public Set<MediaType> getSupportedTypes(ParseContext context) {
+                return Collections.singleton(ocrMediaType(config));
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                              ParseContext context) throws IOException, 
SAXException, TikaException {
+                throw new TikaTimeoutException("simulated OCR timeout", 1000, 
1000);
+            }
+        };
+    }
+
     /**
      * TIKA-4813 follow-up: a single page's OCR call timing out must not abort 
the whole
      * document -- earlier and later pages' content must still reach the 
handler, and the

Reply via email to