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 db1e2e6b94 TIKA-4889: a text recognizer is an engine (#3156)
db1e2e6b94 is described below

commit db1e2e6b945c553df0ff23080a81acab77845eb3
Author: Tim Allison <[email protected]>
AuthorDate: Sat Sep 12 07:00:54 2026 -0400

    TIKA-4889: a text recognizer is an engine (#3156)
---
 CHANGES.txt                                        |   6 ++
 .../ROOT/pages/advanced/local-vlm-server.adoc      |   7 +-
 docs/modules/ROOT/pages/configuration/index.adoc   |  16 ++-
 .../ROOT/pages/configuration/inference.adoc        |   6 ++
 .../parsers/tesseract-ocr-parser.adoc              |   3 +-
 .../ROOT/pages/developers/custom-ocr-engine.adoc   |  21 ++--
 .../tika/parser/enricher/ContentEnricher.java      |   7 +-
 .../resources/config-examples/tesseract-basic.json |  10 +-
 .../tika/config/loader/ComponentInstantiator.java  |  10 ++
 .../tika/config/loader/ContentEnricherLoader.java  | 109 ++++++++++++++++-----
 .../org/apache/tika/config/loader/TikaLoader.java  |   6 ++
 .../config/loader/ContentEnricherLoaderTest.java   |  47 +++++++++
 .../tika/config/loader/InferenceLoaderTest.java    |  10 ++
 .../configs/tika-config-text-recognizers.json      |   3 +-
 14 files changed, 220 insertions(+), 41 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 7d9d547f0d..5c269968e1 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,11 @@
 Release 4.1.0 - unreleased
 
+   * A text recognizer is an engine: configure Tesseract, Tess4J or a VLM once
+     under "engines" and name it from "text-recognizers" with
+     {"engine": "<name>"} (plus _mime-include/_mime-exclude). The 4.0 form with
+     the engine inline still works. An inference binding cannot use a
+     recognizer for a task it does not fit (TIKA-4889).
+     
    * PAGES input for inference bindings: pdf-parser.inference.input lists what
      the PDF releases (TEXT by default; PAGES renders every page for a PAGES
      binding). One render per page feeds OCR and the bindings, and each page's
diff --git a/docs/modules/ROOT/pages/advanced/local-vlm-server.adoc 
b/docs/modules/ROOT/pages/advanced/local-vlm-server.adoc
index 42965309e4..9ebf5cf8d5 100644
--- a/docs/modules/ROOT/pages/advanced/local-vlm-server.adoc
+++ b/docs/modules/ROOT/pages/advanced/local-vlm-server.adoc
@@ -344,15 +344,16 @@ transcribes embedded images and rendered PDF pages while 
every default parser st
 [source,json]
 ----
 {
-  "text-recognizers": [
-    {
+  "engines": {
+    "local-vlm": {
       "openai-vlm-parser": {
         "baseUrl": "http://127.0.0.1:8000";,
         "model": "jinaai/jina-vlm",
         "timeoutMillis": 600000
       }
     }
-  ]
+  },
+  "text-recognizers": [ { "engine": "local-vlm" } ]
 }
 ----
 
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc 
b/docs/modules/ROOT/pages/configuration/index.adoc
index 34f4219083..507015ff37 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -133,18 +133,26 @@ Text recognizers are engines that produce a document's 
text from its pixels: an
 on an embedded image or a rendered PDF page, a VLM prompted to transcribe. A 
container parser
 *invokes* them on bytes it has already parsed. Two recipes cover almost every 
deployment.
 
-*Choose the engine.* Name it here; every default parser stays loaded, and 
`image-parser` and
-`pdf-parser` call it:
+*Choose the engine.* Configure it once under `engines` and name it here; every 
default parser
+stays loaded, and `image-parser` and `pdf-parser` call it:
 
 [source,json]
 ----
 {
+  "engines": {
+    "tesseract": { "tesseract-ocr-parser": { "language": "eng" } }
+  },
   "text-recognizers": [
-    { "tesseract-ocr-parser": { "language": "eng" } }
+    { "engine": "tesseract" }
   ]
 }
 ----
 
+An entry naming an engine takes only `engine`, `_mime-include` and 
`_mime-exclude`; the engine's
+own settings live under `engines`, where an
+xref:configuration/inference.adoc[inference binding] can name the same engine. 
The 4.0 form,
+the engine inline, still works: `{ "tesseract-ocr-parser": { "language": "eng" 
} }`.
+
 *Turn OCR off.* An empty list is authoritative:
 
 [source,json]
@@ -152,7 +160,7 @@ on an embedded image or a rendered PDF page, a VLM prompted 
to transcribe. A con
 { "text-recognizers": [] }
 ----
 
-Engine names: `tesseract-ocr-parser`, `tess4j-parser`, `openai-vlm-parser`, 
`claude-vlm-parser`,
+Engine types: `tesseract-ocr-parser`, `tess4j-parser`, `openai-vlm-parser`, 
`claude-vlm-parser`,
 `gemini-vlm-parser`. At startup Tika logs one line per engine with the media 
types it enriches, so
 the effective engine is never a guess.
 
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc 
b/docs/modules/ROOT/pages/configuration/inference.adoc
index 5b242d20f8..c2069243e6 100644
--- a/docs/modules/ROOT/pages/configuration/inference.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -43,6 +43,12 @@ different models. Secrets and paths live here and only here.
 `maxBatchSize` images per request. Options: `baseUrl`, `model`, `apiKey`, 
`timeoutMillis`
 (120000), `maxBatchSize` (32), `embeddingsPath`, `apiKeyHeaderName`, 
`apiKeyPrefix`.
 
+A text recognizer is an engine too: `"tesseract": { "tesseract-ocr-parser": { 
"language": "eng" } }`
+here, and `{ "engine": "tesseract" }` in
+xref:configuration/index.adoc#text-recognizers[`text-recognizers`], so an OCR 
engine or a VLM is
+configured in one place. A binding cannot use a recognizer for a task it does 
not fit; `embed`
+on Tesseract fails config load.
+
 == `inference`: what runs on what
 
 A list of bindings. Each names an engine, says what it is fed, and what to ask:
diff --git 
a/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc
index 7257d0040d..18f6da8b2a 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc
@@ -28,7 +28,8 @@ Name the engine in the top-level `text-recognizers` list; 
every default parser s
 [source,json]
 ----
 {
-  "text-recognizers": [ { "tesseract-ocr-parser": { "language": "eng" } } ]
+  "engines": { "tesseract": { "tesseract-ocr-parser": { "language": "eng" } } 
},
+  "text-recognizers": [ { "engine": "tesseract" } ]
 }
 ----
 
diff --git a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc 
b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
index 4691e89ea8..401966e6dc 100644
--- a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
+++ b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
@@ -202,22 +202,31 @@ xref:configuration/index.adoc#tika-extras-dir[Adding 
extra jars].
 [source,json]
 ----
 {
+  "engines": {
+    "jina": { "jina-ocr-parser": { "endpoint": "http://jina:9000";, "apiKey": 
"..." } }
+  },
   "text-recognizers": [
-    { "jina-ocr-parser": { "endpoint": "http://jina:9000";, "apiKey": "..." } }
+    { "engine": "jina" }
   ]
 }
 ----
 
-Every default parser stays loaded, and Tesseract on the classpath is ignored: 
the list is
-authoritative. To keep Tesseract for the formats your engine does not read, or 
to run an annotator
-next to it, add entries with filters:
+A `ContentEnricher` is an engine, so it is configured under `engines` like any 
other and named
+from `text-recognizers` (the 4.0 form, the engine inline in the list, still 
works). Every default
+parser stays loaded, and Tesseract on the classpath is ignored: the list is 
authoritative. To
+keep Tesseract for the formats your engine does not read, or to run an 
annotator next to it, add
+entries with filters:
 
 [source,json]
 ----
 {
+  "engines": {
+    "jina": { "jina-ocr-parser": { "endpoint": "http://jina:9000"; } },
+    "tesseract": { "tesseract-ocr-parser": {} }
+  },
   "text-recognizers": [
-    { "jina-ocr-parser": { "endpoint": "http://jina:9000"; } },
-    { "tesseract-ocr-parser": { "_mime-exclude": ["image/png", "image/jpeg", 
"image/tiff"] } }
+    { "engine": "jina" },
+    { "engine": "tesseract", "_mime-exclude": ["image/png", "image/jpeg", 
"image/tiff"] }
   ]
 }
 ----
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnricher.java 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnricher.java
index c02fca9c74..c29cb97206 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnricher.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnricher.java
@@ -16,14 +16,19 @@
  */
 package org.apache.tika.parser.enricher;
 
+import org.apache.tika.parser.inference.Engine;
+
 /**
  * A {@link org.apache.tika.parser.Parser} a container parser <em>invokes</em> 
on bytes it
  * has already parsed (OCR on an image, an embedding of a rendered page) 
rather than one the
  * composite dispatches to; its supported types are the types it can enrich. 
An enricher the
  * classpath supplied is never dispatched to; named under {@code "parsers"} it 
also parses
  * the types no other parser there claims. See {@link ContentEnrichers#get}.
+ * <p>
+ * An enricher is an {@link Engine}: configured once under {@code "engines"}, a
+ * {@code "text-recognizers"} entry names it.
  *
  * @since Apache Tika 4.1
  */
-public interface ContentEnricher {
+public interface ContentEnricher extends Engine {
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/tesseract-basic.json
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/tesseract-basic.json
index 63d6ebc372..d46d134ff9 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/tesseract-basic.json
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/tesseract-basic.json
@@ -1,12 +1,16 @@
 {
   // Tesseract enriches every image Tika's parsers meet (embedded images, 
rendered PDF
-  // pages) with OCR text. Every default parser stays loaded: "parsers" is not 
needed.
-  "text-recognizers": [
-    {
+  // pages) with OCR text. Configured once under "engines", named in 
"text-recognizers".
+  // Every default parser stays loaded: "parsers" is not needed.
+  "engines": {
+    "tesseract": {
       "tesseract-ocr-parser": {
         "language": "eng",
         "timeoutMillis": 120000
       }
     }
+  },
+  "text-recognizers": [
+    { "engine": "tesseract" }
   ]
 }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ComponentInstantiator.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ComponentInstantiator.java
index 4f82f9d7b5..84126311b2 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ComponentInstantiator.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ComponentInstantiator.java
@@ -225,6 +225,16 @@ public class ComponentInstantiator {
         }
     }
 
+    /** The parser behind the entry's {@code _mime-include}/{@code 
_mime-exclude}, if any. */
+    static Parser withMimeFilters(Parser parser, JsonNode entry) throws 
TikaConfigException {
+        Set<MediaType> includeTypes = extractMimeTypes(entry, "_mime-include");
+        Set<MediaType> excludeTypes = extractMimeTypes(entry, "_mime-exclude");
+        if (includeTypes.isEmpty() && excludeTypes.isEmpty()) {
+            return parser;
+        }
+        return ParserDecorator.withMimeFilters(parser, includeTypes, 
excludeTypes);
+    }
+
     private static Set<MediaType> extractMimeTypes(JsonNode configNode, String 
fieldName)
             throws TikaConfigException {
         Set<MediaType> types = new HashSet<>();
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
index af935de82d..54abb2cb53 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
@@ -17,8 +17,10 @@
 package org.apache.tika.config.loader;
 
 import java.util.ArrayList;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -31,57 +33,120 @@ import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.ParserDecorator;
 import org.apache.tika.parser.enricher.CompositeContentEnricher;
 import org.apache.tika.parser.enricher.ContentEnrichers;
+import org.apache.tika.parser.inference.Engine;
+import org.apache.tika.parser.inference.EngineRegistry;
 
 /**
- * Loads the top-level {@code "text-recognizers"} list: parsers selected by 
component name
- * that container parsers invoke for derived content (OCR, ...). Members come 
from the same
- * registry as {@code "parsers"} entries but never join the composite's 
media-type dispatch.
- * Null when the key is absent: {@link ParserLoader} then resolves the 
enrichers from the
- * loaded parsers.
+ * Loads {@code "text-recognizers"}. An entry either names an engine from 
{@code "engines"}
+ * ({@code {"engine": "tesseract", "_mime-include": [...]}}) or carries the 
engine inline
+ * ({@code {"tesseract-ocr-parser": {...}}}), the 4.0 form. An empty list is 
authoritative;
+ * an absent key means the recognizers are resolved from the loaded parsers.
  */
 class ContentEnricherLoader implements 
ComponentLoader<CompositeContentEnricher> {
 
+    static final String KEY = "text-recognizers";
     private static final Logger LOG = 
LoggerFactory.getLogger(ContentEnricherLoader.class);
+    private static final Set<String> REFERENCE_KEYS = Set.of("engine", 
"_mime-include",
+            "_mime-exclude");
 
     @Override
     public CompositeContentEnricher load(TikaJsonConfig config, LoaderContext 
context)
             throws TikaConfigException {
-        List<Map.Entry<String, JsonNode>> entries = 
config.getArrayComponents("text-recognizers");
-        if (entries.isEmpty()) {
-            // [] is an explicit "nothing", authoritative; an absent key means 
"find them"
-            return config.hasComponentSection("text-recognizers")
-                    ? new CompositeContentEnricher(List.of()) : null;
+        JsonNode node = config.getRootNode().get(KEY);
+        if (node == null) {
+            return null;
+        }
+        if (!node.isArray()) {
+            throw new TikaConfigException("\"" + KEY + "\" must be an array");
         }
         List<Parser> enrichers = new ArrayList<>();
         ParseContext empty = new ParseContext();
-        for (Map.Entry<String, JsonNode> entry : entries) {
+        for (JsonNode item : node) {
+            String label;
             Parser enricher;
-            try {
-                ObjectNode wrapper = 
context.getObjectMapper().createObjectNode();
-                wrapper.set(entry.getKey(), entry.getValue());
-                enricher = context.getObjectMapper().treeToValue(wrapper, 
Parser.class);
-            } catch (Exception e) {
-                throw new TikaConfigException(
-                        "Failed to load text recognizer: " + entry.getKey(), 
e);
+            if (item.isObject() && item.has("engine")) {
+                label = "engine \"" + item.get("engine").asText() + "\"";
+                enricher = referenced(item, context);
+            } else {
+                Map.Entry<String, JsonNode> entry = inline(item, context);
+                label = "\"" + entry.getKey() + "\"";
+                enricher = instantiate(entry, context);
             }
             // lifetime snapshot: an empty engine must fail load, not go 
inert; ask the
             // engine itself, since a _mime-include answers for the decorator
             if (unwrap(enricher).getSupportedTypes(empty).isEmpty()) {
-                throw new TikaConfigException("Text recognizer \"" + 
entry.getKey()
-                        + "\" advertises no media types (a _mime-include list 
does not "
+                throw new TikaConfigException("Text recognizer " + label
+                        + " advertises no media types (a _mime-include list 
does not "
                         + "count). Is the engine unavailable (missing native 
binary, "
                         + "unreachable inference server) or configured to skip 
enrichment?");
             }
             if (ContentEnrichers.asTextRecognizer(enricher) == null && 
!advertisesLegacyOcr(enricher)) {
-                LOG.warn("\"text-recognizers\" entry \"{}\" recognizes no 
text; it runs as an "
+                LOG.warn("\"text-recognizers\" entry {} recognizes no text; it 
runs as an "
                         + "annotator on the images and pages it is offered 
(4.2 gives annotators "
-                        + "a list of their own)", entry.getKey());
+                        + "a list of their own)", label);
             }
             enrichers.add(enricher);
         }
         return new CompositeContentEnricher(enrichers);
     }
 
+    /** The engine the entry names, from {@code "engines"}, behind the entry's 
mime filters. */
+    private static Parser referenced(JsonNode item, LoaderContext context)
+            throws TikaConfigException {
+        Iterator<String> names = item.fieldNames();
+        while (names.hasNext()) {
+            String name = names.next();
+            if (!REFERENCE_KEYS.contains(name)) {
+                throw new TikaConfigException("\"" + KEY + "\" entry naming an 
engine has "
+                        + "unknown key \"" + name + "\"; known: " + 
REFERENCE_KEYS
+                        + ". The engine's own settings belong under 
\"engines\".");
+            }
+        }
+        JsonNode nameNode = item.get("engine");
+        if (!nameNode.isTextual()) {
+            throw new TikaConfigException("\"" + KEY + "\" entry: \"engine\" 
must be a name "
+                    + "from \"engines\"");
+        }
+        String engineName = nameNode.asText();
+        EngineRegistry engines = context.get(EngineRegistry.class);
+        Engine engine = engines == null ? null : engines.get(engineName);
+        if (engine == null) {
+            throw new TikaConfigException("\"" + KEY + "\" entry names engine 
\"" + engineName
+                    + "\", which is not in \"engines\"");
+        }
+        if (!(engine instanceof Parser parser) || 
!ContentEnrichers.isEnricher(parser)) {
+            throw new TikaConfigException("\"" + KEY + "\" entry names engine 
\"" + engineName
+                    + "\" (" + engine.getClass().getName() + "), which is not 
a text "
+                    + "recognizer: a recognizer implements ContentEnricher");
+        }
+        return ComponentInstantiator.withMimeFilters(parser, item);
+    }
+
+    /** The 4.0 form: {@code "name"} or {@code {"name": {...}}}, first field 
only. */
+    private static Map.Entry<String, JsonNode> inline(JsonNode item, 
LoaderContext context)
+            throws TikaConfigException {
+        if (item.isTextual()) {
+            return Map.entry(item.asText(), (JsonNode) 
context.getObjectMapper().createObjectNode());
+        }
+        if (item.isObject() && !item.isEmpty()) {
+            Map.Entry<String, JsonNode> first = item.fields().next();
+            return Map.entry(first.getKey(), first.getValue());
+        }
+        throw new TikaConfigException("\"" + KEY + "\" entries are 
{\"engine\": \"<name>\"} "
+                + "or {\"<recognizer>\": {...}}; got " + item);
+    }
+
+    private static Parser instantiate(Map.Entry<String, JsonNode> entry, 
LoaderContext context)
+            throws TikaConfigException {
+        try {
+            ObjectNode wrapper = context.getObjectMapper().createObjectNode();
+            wrapper.set(entry.getKey(), entry.getValue());
+            return context.getObjectMapper().treeToValue(wrapper, 
Parser.class);
+        } catch (Exception e) {
+            throw new TikaConfigException("Failed to load text recognizer: " + 
entry.getKey(), e);
+        }
+    }
+
     private static Parser unwrap(Parser parser) {
         while (parser instanceof ParserDecorator decorator) {
             parser = decorator.getWrappedParser();
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
index 8cf2b44c91..13d9d55bc6 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
@@ -842,6 +842,12 @@ public class TikaLoader {
         if (config.hasComponentSection("text-recognizers")) {
             output.set("text-recognizers", 
config.getRootNode().get("text-recognizers"));
         }
+        // a text-recognizers entry may name an engine, so both travel with it
+        for (String section : new String[]{EngineLoader.KEY, 
InferenceLoader.KEY}) {
+            if (config.hasComponentSection(section)) {
+                output.set(section, config.getRootNode().get(section));
+            }
+        }
 
         // Preserve auto-detect-parser config if present
         JsonNode adpNode = config.getRootNode().get("auto-detect-parser");
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/ContentEnricherLoaderTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/ContentEnricherLoaderTest.java
index 1136b5ad07..8b0c5ae05a 100644
--- 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/ContentEnricherLoaderTest.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/ContentEnricherLoaderTest.java
@@ -19,17 +19,22 @@ package org.apache.tika.config.loader;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 import org.xml.sax.helpers.DefaultHandler;
 
+import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -37,8 +42,10 @@ import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.CompositeParser;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.ParserDecorator;
 import org.apache.tika.parser.enricher.CompositeContentEnricher;
 import org.apache.tika.parser.enricher.ContentEnrichers;
+import org.apache.tika.parser.inference.EngineRegistry;
 import org.apache.tika.utils.ParserUtils;
 
 public class ContentEnricherLoaderTest {
@@ -82,6 +89,46 @@ public class ContentEnricherLoaderTest {
         assertEquals(enrichers, enrichingParser.getContentEnrichers());
     }
 
+    /** An entry names an engine configured once under "engines"; one instance 
serves both. */
+    @Test
+    public void testEntryNamesAnEngine() throws Exception {
+        TikaLoader loader = load("""
+                {
+                  "engines": { "png": { "mock-enricher": {} } },
+                  "text-recognizers": [ { "engine": "png", "_mime-include": 
["image/png"] } ]
+                }
+                """);
+        CompositeContentEnricher enrichers = 
loader.get(CompositeContentEnricher.class);
+        List<Parser> matched = enrichers.getEnrichers(MediaType.image("png"));
+        assertEquals(1, matched.size());
+        Parser entry = matched.get(0);
+        assertTrue(entry instanceof ParserDecorator, "the entry's mime filter 
wraps the engine");
+        assertSame(loader.get(EngineRegistry.class).get("png"), unwrap(entry));
+        assertTrue(ContentEnrichers.isEnricher(entry));
+    }
+
+    @ParameterizedTest
+    @CsvSource(delimiter = '|', value = {
+            "{ \"engines\": { \"png\": { \"mock-enricher\": {} } }, 
\"text-recognizers\": [ { \"engine\": \"nope\" } ] }| is not in \"engines\"",
+            "{ \"text-recognizers\": [ { \"engine\": \"png\" } ] }| is not in 
\"engines\"",
+            "{ \"engines\": { \"plain\": { \"test-engine\": {} } }, 
\"text-recognizers\": [ { \"engine\": \"plain\" } ] }| is not a text 
recognizer",
+            "{ \"engines\": { \"png\": { \"mock-enricher\": {} } }, 
\"text-recognizers\": [ { \"engine\": \"png\", \"language\": \"eng\" } ] }| 
unknown key \"language\"",
+            "{ \"engines\": { \"png\": { \"mock-enricher\": {} } }, 
\"text-recognizers\": [ { \"engine\": 3 } ] }| must be a name",
+            "{ \"text-recognizers\": [ 3 ] }| entries are"
+    })
+    public void testEngineReferenceMisconfigurationsFailLoad(String json, 
String message) {
+        TikaConfigException e = assertThrows(TikaConfigException.class,
+                () -> load(json).get(CompositeContentEnricher.class));
+        assertTrue(e.getMessage().contains(message), e.getMessage());
+    }
+
+    private static Parser unwrap(Parser parser) {
+        while (parser instanceof ParserDecorator decorator) {
+            parser = decorator.getWrappedParser();
+        }
+        return parser;
+    }
+
     @Test
     public void testZeroTypeEnricherFailsLoad() throws Exception {
         // a named engine that cannot run must fail load, not become a silent 
no-op
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
index 167e16d39f..04c0c0c80b 100644
--- 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
@@ -90,6 +90,16 @@ public class InferenceLoaderTest {
                 "the dispatcher rides every parse as a hook");
     }
 
+    /** A recognizer is an engine a binding may name; the task decides whether 
it fits. */
+    @Test
+    public void testRecognizerEngineIsValidatedByTheTask() {
+        TikaConfigException e = assertThrows(TikaConfigException.class, () -> 
load("{"
+                + " \"engines\": { \"ocr\": { \"mock-enricher\": {} } },"
+                + " \"inference\": [ { \"engine\": \"ocr\", \"input\": 
\"IMAGES\","
+                + "   \"tasks\": [\"test-task\"] } ] 
}").get(InferenceDispatcher.class));
+        assertTrue(e.getMessage().contains("test-task needs a test-engine"), 
e.getMessage());
+    }
+
     @Test
     public void testPagesBindingLoads() throws Exception {
         TikaLoader loader = load("{" + ENGINES + ", \"inference\": ["
diff --git 
a/tika-server/tika-server-standard/src/test/resources/configs/tika-config-text-recognizers.json
 
b/tika-server/tika-server-standard/src/test/resources/configs/tika-config-text-recognizers.json
index c89790d11a..0b84b3e60c 100644
--- 
a/tika-server/tika-server-standard/src/test/resources/configs/tika-config-text-recognizers.json
+++ 
b/tika-server/tika-server-standard/src/test/resources/configs/tika-config-text-recognizers.json
@@ -1,5 +1,6 @@
 {
-  "text-recognizers": [ { "mock-enricher": {} } ],
+  "engines": { "mock": { "mock-enricher": {} } },
+  "text-recognizers": [ { "engine": "mock" } ],
   "auto-detect-parser": {
     "throwOnZeroBytes": false
   },

Reply via email to