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

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

commit 8c20152d6a996d359025b8d7678f9a933554f2b7
Author: tallison <[email protected]>
AuthorDate: Wed Sep 2 15:43:11 2026 -0400

    TIKA-4872: review fixes
---
 CHANGES.txt                                        | 11 ++-
 docs/modules/ROOT/pages/configuration/index.adoc   |  5 +-
 .../parser/enricher/CompositeContentEnricher.java  |  8 +-
 .../tika/parser/enricher/ContentEnrichers.java     | 56 +++++++++----
 .../parser/enricher/LegacyDispatchEnricher.java    | 17 ++--
 .../tika/parser/enricher/ContentEnrichersTest.java | 92 ++++++++++++++++++++++
 .../tika/parser/image/AbstractImageParser.java     |  5 --
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  |  6 +-
 .../tika/config/loader/ContentEnricherLoader.java  | 15 +++-
 .../apache/tika/config/loader/ParserLoader.java    |  8 +-
 .../config/loader/ContentEnricherLoaderTest.java   | 17 ++++
 .../config/loader/TestUnavailableEnricher.java     | 49 ++++++++++++
 12 files changed, 249 insertions(+), 40 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 782bf4700d..fb54122b1e 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -12,9 +12,14 @@ Release 4.1.0 - unreleased
      Every enricher matching a media type runs, in config order (e.g. an
      OCR engine then a VLM tagger for the same image), best-effort: one
      enricher's failure does not stop the others and is still reported;
-     timeouts abort the chain. With no "content-enrichers" configured, the
-     legacy ocr-* dispatch applies unchanged; a WARN at config load now
-     names colliding OCR engines and the winner. TesseractOCRParser's
+     timeouts abort the chain. The list is authoritative: a media type no
+     configured enricher matches gets no enrichment -- never a classpath
+     engine that was not named -- and a named engine that reports no media
+     types at load (missing binary, unreachable inference server) fails
+     config load instead of going silently inert. With no
+     "content-enrichers" configured, the legacy ocr-* dispatch applies
+     unchanged; a WARN at config load now names colliding OCR engines and
+     the winner. TesseractOCRParser's
      component name is pinned as "tesseract-ocr-parser".
 
    * Inference/OCR hardening (TIKA-4871): OpenAIVLMParser no longer
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc 
b/docs/modules/ROOT/pages/configuration/index.adoc
index c2804aa51d..8a1c25f468 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -146,7 +146,10 @@ parser registered for those types: `image-parser` still 
parses the image and cal
 (The bundled OCR engines still advertise legacy `image/ocr-*` types; those are 
mapped to the
 real type, so naming them here just works.) *Every* enricher matching a media 
type runs, in the
 order listed — so an OCR engine followed by a VLM that tags images is two 
entries, both invoked
-per image. Failures are best-effort: one enricher failing does not stop the 
others, and every
+per image. The list is authoritative: a media type no configured enricher 
matches gets no
+enrichment — never a classpath engine you did not name — and a named engine 
that reports no
+media types at startup (missing native binary, unreachable inference server) 
fails config load
+rather than going silently inert. Failures are best-effort: one enricher 
failing does not stop the others, and every
 failure is still reported through the parser's normal exception handling 
(timeouts abort the
 chain immediately). With no `content-enrichers` configured, behavior is 
unchanged — whichever
 OCR engine is on the classpath is used, exactly as before, and a WARN is 
logged at startup when
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/CompositeContentEnricher.java
 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/CompositeContentEnricher.java
index db614d3118..767ab9d3cf 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/CompositeContentEnricher.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/CompositeContentEnricher.java
@@ -63,7 +63,7 @@ public class CompositeContentEnricher implements Serializable 
{
             for (MediaType mediaType : enricher.getSupportedTypes(empty)) {
                 // legacy engines (Tesseract, VLM, ...) still advertise the 
image/ocr-*
                 // pseudo-types; key them under the real type so they are 
nameable here
-                MediaType keyType = stripLegacyOcrPrefix(mediaType);
+                MediaType keyType = 
stripLegacyOcrPrefix(mediaType.getBaseType());
                 List<Parser> forType = tmp.computeIfAbsent(keyType, k -> new 
ArrayList<>());
                 if (!forType.contains(enricher)) {
                     forType.add(enricher);
@@ -84,11 +84,11 @@ public class CompositeContentEnricher implements 
Serializable {
     }
 
     /**
-     * @return the enrichers configured for this exact media type, in config 
order;
-     *         empty when none
+     * @return the enrichers configured for this media type (parameters 
ignored; alias
+     *         normalization is the caller's job), in config order; empty when 
none
      */
     public List<Parser> getEnrichers(MediaType mediaType) {
-        List<Parser> enrichers = enricherMap.get(mediaType);
+        List<Parser> enrichers = enricherMap.get(mediaType.getBaseType());
         return enrichers == null ? Collections.emptyList() : enrichers;
     }
 
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 f2177c75e0..81abc69863 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
@@ -28,6 +28,7 @@ import org.apache.tika.exception.TikaException;
 import org.apache.tika.exception.TikaTimeoutException;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
 import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
@@ -48,6 +49,9 @@ import org.apache.tika.parser.Parser;
  *       must still fire the enricher selected for the type it was dispatched 
on).</li>
  *   <li>The enricher writes into the caller's {@link Metadata}; the caller 
must not assume
  *       the metadata is untouched beyond the derived content.</li>
+ *   <li>An enricher that re-enters parsing must propagate the caller's
+ *       {@link ParseContext}: the recursion guard (like Tika's other in-parse 
limits)
+ *       rides the context, so a fresh context defeats it.</li>
  * </ul>
  *
  * @since Apache Tika 4.1
@@ -59,12 +63,14 @@ public final class ContentEnrichers {
 
     /**
      * Returns the enricher to invoke for one media type, or null when none 
applies.
-     * Explicitly configured enrichers win: every one matching the type runs, 
in config
-     * order, behind the single Parser returned here. Otherwise the legacy
-     * {@code image/ocr-*} dispatch through the composite parser applies when 
an engine
-     * claims the synthetic type. Returns null while a enrichment is already 
in progress
-     * in this context, so an enricher that is (or invokes) a container parser 
cannot
-     * recurse into enrichment.
+     * A configured {@code "content-enrichers"} list is authoritative: every 
configured
+     * enricher matching the type runs, in config order, behind the single 
Parser
+     * returned here, and a type no configured enricher matches gets no 
enrichment --
+     * never a classpath engine the user did not name. Only when no list is 
configured
+     * at all does the legacy {@code image/ocr-*} dispatch through the 
composite parser
+     * apply. Returns null while an enrichment is already in progress in this 
context,
+     * so an enricher that is (or invokes) a container parser cannot recurse 
into
+     * enrichment.
      *
      * @param enrichers  the injected composite; may be null when none is 
configured
      * @param mediaType the real, normalized media type of the bytes; may be 
null
@@ -81,15 +87,16 @@ public final class ContentEnrichers {
         }
         if (enrichers != null) {
             List<Parser> matched = enrichers.getEnrichers(mediaType);
-            if (!matched.isEmpty()) {
-                return new GuardedEnricher(matched.size() == 1
-                        ? matched.get(0) : new SequentialEnricher(matched));
+            if (matched.isEmpty()) {
+                return null;
             }
+            return new GuardedEnricher(matched.size() == 1
+                    ? matched.get(0) : new SequentialEnricher(matched));
         }
         Parser composite = EmbeddedDocumentUtil.getStatelessParser(context);
         if (composite != null && composite.getSupportedTypes(context)
                 .contains(LegacyDispatchEnricher.toOcrMediaType(mediaType))) {
-            return new GuardedEnricher(new LegacyDispatchEnricher(mediaType));
+            return new GuardedEnricher(new LegacyDispatchEnricher(mediaType, 
composite));
         }
         return null;
     }
@@ -98,9 +105,10 @@ public final class ContentEnrichers {
      * Runs each enricher in config order, best-effort: one enricher's failure 
does not
      * stop the others. The first failure is rethrown after the chain 
completes, with
      * later failures attached as suppressed, so call sites report every 
failure through
-     * their existing exception handling. Timeouts, SecurityException and 
SAXException
-     * (incl. write-limit aborts) propagate immediately -- a spent budget or a 
suspect
-     * handler must not fund further enrichments.
+     * their existing exception handling. Timeouts, SecurityException, 
SAXException
+     * (incl. write-limit aborts) and other runtime exceptions propagate 
immediately --
+     * a spent budget or a suspect handler must not fund further enrichments 
-- with any
+     * earlier recorded failure attached as suppressed.
      */
     private static final class SequentialEnricher implements Parser {
 
@@ -137,6 +145,11 @@ public final class ContentEnrichers {
                     } else {
                         first.addSuppressed(e);
                     }
+                } catch (RuntimeException e) {
+                    if (first != null) {
+                        e.addSuppressed(first);
+                    }
+                    throw e;
                 }
             }
             if (first instanceof IOException e) {
@@ -153,7 +166,11 @@ public final class ContentEnrichers {
         boolean active;
     }
 
-    /** Marks enrichment in progress around the delegate so {@link #get} 
refuses re-entry. */
+    /**
+     * Marks enrichment in progress around the delegate so {@link #get} 
refuses re-entry,
+     * and restores Content-Type afterwards: an enricher derives content, it 
does not get
+     * to re-type the caller's document.
+     */
     private static final class GuardedEnricher implements Parser {
 
         private static final long serialVersionUID = 1L;
@@ -177,11 +194,20 @@ public final class ContentEnrichers {
                 active = new ActiveEnrichment();
                 context.set(ActiveEnrichment.class, active);
             }
+            String contentType = metadata.get(HttpHeaders.CONTENT_TYPE);
+            // restore rather than clear: a nested invocation must not strip 
the
+            // outer enrichment's re-entry protection when it completes
+            boolean wasActive = active.active;
             active.active = true;
             try {
                 delegate.parse(tis, handler, metadata, context);
             } finally {
-                active.active = false;
+                active.active = wasActive;
+                if (contentType == null) {
+                    metadata.remove(HttpHeaders.CONTENT_TYPE);
+                } else {
+                    metadata.set(HttpHeaders.CONTENT_TYPE, contentType);
+                }
             }
         }
     }
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/LegacyDispatchEnricher.java
 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/LegacyDispatchEnricher.java
index 7b40938fb5..f880250e2a 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/enricher/LegacyDispatchEnricher.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/LegacyDispatchEnricher.java
@@ -24,7 +24,6 @@ import org.xml.sax.ContentHandler;
 import org.xml.sax.SAXException;
 
 import org.apache.tika.exception.TikaException;
-import org.apache.tika.extractor.EmbeddedDocumentUtil;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
@@ -34,8 +33,8 @@ import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 
 /**
- * Reproduces the pre-4.1 {@code image/ocr-*} dispatch when no enricher is 
configured for
- * a media type: mints the synthetic {@code ocr-} media type, sets
+ * Reproduces the pre-4.1 {@code image/ocr-*} dispatch when no {@code 
"content-enrichers"}
+ * list is configured: mints the synthetic {@code ocr-} media type, sets
  * {@link TikaCoreProperties#CONTENT_TYPE_PARSER_OVERRIDE} and re-enters the 
composite
  * parser, restoring the metadata afterwards. Whichever engine won the {@code 
ocr-*}
  * registration in the composite still wins here, so precedence-by-presence 
(adding
@@ -55,11 +54,17 @@ public class LegacyDispatchEnricher implements Parser {
 
     private final MediaType mediaType;
 
+    private final Parser composite;
+
     /**
      * @param mediaType the real (already normalized) media type of the bytes 
to derive from
+     * @param composite the composite parser to re-enter; the caller has 
already verified
+     *                  it claims the synthetic {@code ocr-} type 
(re-verifying here would
+     *                  rebuild the composite's full supported-types map per 
invocation)
      */
-    public LegacyDispatchEnricher(MediaType mediaType) {
+    public LegacyDispatchEnricher(MediaType mediaType, Parser composite) {
         this.mediaType = mediaType;
+        this.composite = composite;
     }
 
     /**
@@ -81,9 +86,7 @@ public class LegacyDispatchEnricher implements Parser {
     public void parse(TikaInputStream tis, ContentHandler handler, Metadata 
metadata,
                       ParseContext context) throws IOException, SAXException, 
TikaException {
         MediaType ocrMediaType = toOcrMediaType(mediaType);
-        Parser composite = EmbeddedDocumentUtil.getStatelessParser(context);
-        if (composite == null
-                || 
!composite.getSupportedTypes(context).contains(ocrMediaType)) {
+        if (composite == null) {
             throw new TikaException("No parser is registered for " + 
ocrMediaType);
         }
         String originalOverride = 
metadata.get(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE);
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 8f2dbdc22f..884a9972b5 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
@@ -122,6 +122,98 @@ public class ContentEnrichersTest {
         assertNull(ContentEnrichers.get(null, null, context));
     }
 
+    @Test
+    public void testConfiguredListIsAuthoritative() throws Exception {
+        // the composite claims ocr-tiff, but a configured list that doesn't 
cover tiff
+        // must yield no enricher -- never a classpath engine the user did not 
name
+        RecordingParser explicit = new 
RecordingParser(Collections.singleton(PNG));
+        RecordingParser composite =
+                new 
RecordingParser(Collections.singleton(MediaType.image("ocr-tiff")));
+        CompositeContentEnricher enrichers = new 
CompositeContentEnricher(List.of(explicit));
+        ParseContext context = new ParseContext();
+        context.set(Parser.class, composite);
+
+        assertNull(ContentEnrichers.get(enrichers, MediaType.image("tiff"), 
context));
+        // with no list configured, the same composite is reachable via legacy 
dispatch
+        assertNotNull(ContentEnrichers.get(null, MediaType.image("tiff"), 
context));
+    }
+
+    @Test
+    public void testParametersIgnoredInMatching() throws Exception {
+        RecordingParser explicit = new 
RecordingParser(Collections.singleton(PNG));
+        CompositeContentEnricher enrichers = new 
CompositeContentEnricher(List.of(explicit));
+        ParseContext context = new ParseContext();
+
+        Parser enricher = ContentEnrichers.get(enrichers,
+                MediaType.parse("image/png; charset=binary"), context);
+        assertNotNull(enricher, "parameterized type must match the base-type 
registration");
+        invoke(enricher, new Metadata(), context);
+        assertEquals(1, explicit.calls);
+    }
+
+    @Test
+    public void testEnricherCannotRewriteContentType() throws Exception {
+        Parser rewriting = new Parser() {
+            private static final long serialVersionUID = 1L;
+
+            @Override
+            public Set<MediaType> getSupportedTypes(ParseContext context) {
+                return Collections.singleton(PNG);
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                              ParseContext context) {
+                metadata.set(HttpHeaders.CONTENT_TYPE, "application/pdf");
+            }
+        };
+        CompositeContentEnricher enrichers = new 
CompositeContentEnricher(List.of(rewriting));
+        ParseContext context = new ParseContext();
+
+        Metadata metadata = new Metadata();
+        metadata.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
+        invoke(ContentEnrichers.get(enrichers, PNG, context), metadata, 
context);
+        assertEquals(PNG.toString(), metadata.get(HttpHeaders.CONTENT_TYPE));
+
+        Metadata unset = new Metadata();
+        invoke(ContentEnrichers.get(enrichers, PNG, context), unset, context);
+        assertNull(unset.get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    @Test
+    public void testRuntimeFailureAbortsChainWithEarlierFailureSuppressed() 
throws Exception {
+        List<String> order = new java.util.ArrayList<>();
+        Parser failing = namedEnricher("failing", order, true);
+        Parser blowingUp = new Parser() {
+            private static final long serialVersionUID = 1L;
+
+            @Override
+            public Set<MediaType> getSupportedTypes(ParseContext context) {
+                return Collections.singleton(PNG);
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                              ParseContext context) {
+                order.add("blowingUp");
+                throw new NullPointerException("boom");
+            }
+        };
+        Parser third = namedEnricher("third", order, false);
+        CompositeContentEnricher enrichers =
+                new CompositeContentEnricher(List.of(failing, blowingUp, 
third));
+        ParseContext context = new ParseContext();
+
+        Parser enricher = ContentEnrichers.get(enrichers, PNG, context);
+        assertNotNull(enricher);
+        NullPointerException thrown = 
org.junit.jupiter.api.Assertions.assertThrows(
+                NullPointerException.class, () -> invoke(enricher, new 
Metadata(), context));
+        assertEquals(List.of("failing", "blowingUp"), order);
+        // the recorded checked failure rides along instead of vanishing
+        assertEquals(1, thrown.getSuppressed().length);
+        assertEquals("failing failed", thrown.getSuppressed()[0].getMessage());
+    }
+
     @Test
     public void testAllMatchingEnrichersRunInOrder() throws Exception {
         List<String> order = new java.util.ArrayList<>();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
index 2e53f2a2c6..76f9af40e0 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/AbstractImageParser.java
@@ -34,17 +34,12 @@ import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.enricher.CompositeContentEnricher;
 import org.apache.tika.parser.enricher.ContentEnrichers;
 import org.apache.tika.parser.enricher.EnrichingParser;
-import org.apache.tika.parser.enricher.LegacyDispatchEnricher;
 import org.apache.tika.sax.BodyContentHandler;
 import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
 
 public abstract class AbstractImageParser implements Parser, EnrichingParser {
 
-    /** @deprecated use {@link LegacyDispatchEnricher#OCR_MEDIATYPE_PREFIX} */
-    @Deprecated
-    public static String OCR_MEDIATYPE_PREFIX = 
LegacyDispatchEnricher.OCR_MEDIATYPE_PREFIX;
-
     private CompositeContentEnricher contentEnrichers;
 
     abstract void extractMetadata(InputStream is, ContentHandler 
contentHandler, Metadata metadata,
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 b7cde47b4a..7502d29c16 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
@@ -576,8 +576,10 @@ class AbstractPDF2XHTML extends PDFTextStripper {
             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 + ". Configure one in 
\"content-enrichers\", " +
-                                "add one to the classpath, or set the OCR 
strategy to NO_OCR.");
+                                imageMediaType + ". 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;
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 56330a790f..98aeb535cf 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
@@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 
 import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.enricher.CompositeContentEnricher;
 
@@ -43,15 +44,27 @@ class ContentEnricherLoader implements 
ComponentLoader<CompositeContentEnricher>
             return null;
         }
         List<Parser> enrichers = new ArrayList<>();
+        ParseContext empty = new ParseContext();
         for (Map.Entry<String, JsonNode> entry : entries) {
+            Parser enricher;
             try {
                 ObjectNode wrapper = 
context.getObjectMapper().createObjectNode();
                 wrapper.set(entry.getKey(), entry.getValue());
-                enrichers.add(context.getObjectMapper().treeToValue(wrapper, 
Parser.class));
+                enricher = context.getObjectMapper().treeToValue(wrapper, 
Parser.class);
             } catch (Exception e) {
                 throw new TikaConfigException(
                         "Failed to load content enricher: " + entry.getKey(), 
e);
             }
+            // engines report no types when unusable (missing binary, 
unreachable server);
+            // the media-type snapshot taken here lasts the life of the 
process, so an
+            // explicitly named engine that can never run must fail load, not 
go silent
+            if (enricher.getSupportedTypes(empty).isEmpty()) {
+                throw new TikaConfigException("Content enricher \"" + 
entry.getKey()
+                        + "\" advertises no media types. Is the engine 
unavailable "
+                        + "(missing native binary, unreachable inference 
server) or "
+                        + "configured to skip enrichment?");
+            }
+            enrichers.add(enricher);
         }
         return new CompositeContentEnricher(enrichers);
     }
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ParserLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ParserLoader.java
index 5e44cf5cf7..9fb1cdbf64 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/ParserLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ParserLoader.java
@@ -138,7 +138,9 @@ public class ParserLoader extends 
AbstractSpiComponentLoader<Parser> {
         Renderer renderer = context.getRenderer();
         CompositeContentEnricher contentEnrichers = 
context.getContentEnrichers();
         injectDependenciesRecursively(parser, encodingDetector, renderer, 
contentEnrichers);
-        warnOnAmbiguousOcrRegistrations(parser);
+        if (contentEnrichers == null) {
+            warnOnAmbiguousOcrRegistrations(parser);
+        }
         return parser;
     }
 
@@ -171,7 +173,9 @@ public class ParserLoader extends 
AbstractSpiComponentLoader<Parser> {
      * The image/ocr-* pseudo-types are claimed by several OCR engines whose 
availability
      * is environmental, and the composite resolves a collision by last 
registration with
      * no warning. Name the collision and the winner once at load so engine 
selection is
-     * debuggable; select an engine explicitly with "content-enrichers".
+     * debuggable; select an engine explicitly with "content-enrichers". 
Skipped when
+     * content-enrichers is configured: the list is authoritative and legacy 
ocr-*
+     * dispatch never runs, so the collision is moot and the advice already 
taken.
      */
     private void warnOnAmbiguousOcrRegistrations(Parser parser) {
         if (!(parser instanceof CompositeParser cp)) {
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 46fcfeff84..372987f348 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
@@ -67,6 +67,23 @@ public class ContentEnricherLoaderTest {
         assertEquals(enrichers, enrichingParser.getContentEnrichers());
     }
 
+    @Test
+    public void testZeroTypeEnricherFailsLoad() throws Exception {
+        // an explicitly named engine that cannot run (missing binary, 
unreachable
+        // server) must fail config load, not become a silent no-op
+        TikaLoader loader = load("""
+                {
+                  "content-enrichers": [ {"test-unavailable-enricher": {}} ]
+                }
+                """);
+        org.apache.tika.exception.TikaConfigException e =
+                org.junit.jupiter.api.Assertions.assertThrows(
+                        org.apache.tika.exception.TikaConfigException.class,
+                        () -> loader.get(CompositeContentEnricher.class));
+        assertTrue(e.getMessage().contains("advertises no media types"),
+                "unexpected message: " + e.getMessage());
+    }
+
     @Test
     public void testNoContentEnrichersConfigured() throws Exception {
         TikaLoader loader = load("""
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/TestUnavailableEnricher.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestUnavailableEnricher.java
new file mode 100644
index 0000000000..4f3f28d02e
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestUnavailableEnricher.java
@@ -0,0 +1,49 @@
+/*
+ * 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.config.loader;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.xml.sax.ContentHandler;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+
+/**
+ * Content-enricher fixture mimicking an engine that is unusable at load time
+ * (missing binary, unreachable server): it advertises no media types.
+ */
+@TikaComponent(name = "test-unavailable-enricher", spi = false)
+public class TestUnavailableEnricher implements Parser {
+
+    private static final long serialVersionUID = 1L;
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return Collections.emptySet();
+    }
+
+    @Override
+    public void parse(TikaInputStream stream, ContentHandler handler, Metadata 
metadata,
+                      ParseContext context) {
+    }
+}

Reply via email to