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