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 54a8ed6892 TIKA-4872 - improve ocr wiring (#3119)
54a8ed6892 is described below

commit 54a8ed68921b0bf8711ac13b119e44122def8e30
Author: Tim Allison <[email protected]>
AuthorDate: Thu Sep 3 15:54:17 2026 -0400

    TIKA-4872 - improve ocr wiring (#3119)
---
 CHANGES.txt                                        |  33 ++
 docs/modules/ROOT/pages/configuration/index.adoc   |  31 ++
 .../ROOT/pages/configuration/metadata-filters.adoc |   5 +
 .../pages/configuration/parsers/tess4j-parser.adoc |   7 +
 .../parsers/tesseract-ocr-parser.adoc              |  18 +-
 .../pages/configuration/parsers/vlm-parsers.adoc   |  14 +-
 .../parser/enricher/CompositeContentEnricher.java  |  88 +++++
 .../tika/parser/enricher/ContentEnrichers.java     | 194 +++++++++++
 .../tika/parser/enricher/EnrichingParser.java      |  29 ++
 .../parser/enricher/LegacyDispatchEnricher.java    | 106 ++++++
 .../tika/parser/enricher/ContentEnrichersTest.java | 371 +++++++++++++++++++++
 .../tika/inference/AbstractEmbeddingFilter.java    |  68 +++-
 .../apache/tika/inference/JinaEmbeddingFilter.java |   5 +
 .../tika/inference/OpenAIEmbeddingFilter.java      |  12 +
 .../tika/inference/OpenAIEmbeddingFilterTest.java  |  57 ++++
 .../apache/tika/parser/vlm/OpenAIVLMParser.java    |   3 +-
 .../tika/parser/vlm/OpenAIVLMParserTest.java       |  17 +
 .../tika/parser/image/AbstractImageParser.java     |  58 +---
 .../apache/tika/parser/image/ImageParserTest.java  |  80 ++++-
 .../apache/tika/parser/ocr/TesseractOCRParser.java |   3 +-
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  |  67 ++--
 .../java/org/apache/tika/parser/pdf/OCR2XHTML.java |  11 +-
 .../java/org/apache/tika/parser/pdf/PDF2XHTML.java |  19 +-
 .../tika/parser/pdf/PDFMarkedContent2XHTML.java    |  11 +-
 .../java/org/apache/tika/parser/pdf/PDFParser.java |  20 +-
 .../org/apache/tika/parser/pdf/PDFParserTest.java  |  94 ++++++
 .../org/apache/tika/pipes/core/MockEnricher.java   |  56 ++++
 .../apache/tika/pipes/core/PipesClientTest.java    |  30 ++
 .../configs/tika-config-content-enrichers.json     |  56 ++++
 .../tika/config/loader/ContentEnricherLoader.java  |  69 ++++
 .../apache/tika/config/loader/LoaderContext.java   |  11 +
 .../apache/tika/config/loader/ParserLoader.java    |  64 +++-
 .../apache/tika/config/loader/TikaJsonConfig.java  |   1 +
 .../org/apache/tika/config/loader/TikaLoader.java  |   9 +
 .../config/loader/ContentEnricherLoaderTest.java   | 113 +++++++
 .../tika/config/loader/EnrichingTestParser.java    |  60 ++++
 .../apache/tika/config/loader/TestPngEnricher.java |  47 +++
 .../config/loader/TestUnavailableEnricher.java     |  46 +++
 38 files changed, 1869 insertions(+), 114 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index e6714ec615..b1791047ec 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,38 @@
 Release 4.1.0 - unreleased
 
+   * New "content-enrichers" config list (TIKA-4872): select the OCR engine
+     ("tesseract-ocr-parser", "tess4j-parser", "openai-vlm-parser", ...) by
+     name instead of by classpath registration of the image/ocr-* pseudo
+     media types. Enrichers advertise real media types (legacy engines that
+     still advertise image/ocr-* are mapped to the real type, so all are
+     nameable) and are invoked by the image and PDF parsers rather than
+     dispatched to by the composite, so an enricher no longer displaces the
+     parser registered for the same type. Enricher selection uses the
+     detected media type, captured before a parser can refine Content-Type.
+     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. 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
+     auto-registers via SPI, matching its Claude/Gemini siblings; select
+     it by name ("openai-vlm-parser") in config. Per-request parse-context
+     config for the embedding filters now works and is validated:
+     {"openai-embedding-filter": {"skipEmbedding": true}} (likewise
+     "jina-embedding-filter") merges over the server config, and
+     baseUrl/apiKey/model may not be changed at runtime. The embedding
+     filters release their HTTP client resources on close(). Inline PDF
+     page OCR now accumulates tk:chunks from every OCR'd page onto the
+     parent document instead of keeping only the first page's.
+
    * Placeholder streams -- the empty stand-ins parsers hand parseEmbedded
      for content that is never extracted -- report an unknown length rather
      than their own zero, and the macro-failure entry is registered without
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc 
b/docs/modules/ROOT/pages/configuration/index.adoc
index c525061962..dc90e9fee9 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -38,6 +38,7 @@ optional; anything you omit uses its defaults.
   "encoding-detectors": [ /* encoding detector declarations */ ],
   "metadata-filters": [ /* metadata filter declarations */ ],
   "renderers": [ /* page renderer declarations */ ],
+  "content-enrichers": [ /* OCR engines etc., selected by name; see below */ ],
   "translator": { /* translator declaration */ },
   "content-handler-factory": { /* handler type for emitted content */ },
   "auto-detect-parser": { /* AutoDetectParser options */ },
@@ -125,6 +126,36 @@ Configuring a parser automatically excludes its default 
copy, so there is no dup
 `default-encoding-detector`, but it must not be mixed with explicit detector 
entries — see
 xref:configuration/encoding-detectors.adoc[Encoding Detectors].
 
+== The `content-enrichers` list (4.1.0+)
+
+Content enrichers are ordinary parsers that a container parser *invokes* on 
bytes it has already
+parsed — an OCR engine run on an embedded image or a rendered PDF page. Naming 
one here selects
+the engine explicitly instead of relying on which OCR module happens to be on 
the classpath:
+
+[source,json]
+----
+{
+  "content-enrichers": [
+    { "tesseract-ocr-parser": { "language": "eng" } }
+  ]
+}
+----
+
+An enricher advertises its *real* media types (`image/png`, ...) and does not 
compete with the
+parser registered for those types: `image-parser` still parses the image and 
calls the enricher.
+(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. 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 several engines collide. Engine names: 
`tesseract-ocr-parser`, `tess4j-parser`, `openai-vlm-parser`,
+`claude-vlm-parser`, `gemini-vlm-parser`.
+
 == Windows file paths
 
 JSON treats the backslash as an escape character, so path options 
(`tesseractPath`,
diff --git a/docs/modules/ROOT/pages/configuration/metadata-filters.adoc 
b/docs/modules/ROOT/pages/configuration/metadata-filters.adoc
index c7b1eeae30..262fee13a9 100644
--- a/docs/modules/ROOT/pages/configuration/metadata-filters.adoc
+++ b/docs/modules/ROOT/pages/configuration/metadata-filters.adoc
@@ -233,6 +233,11 @@ around.
 `parse-context` can carry a per-request `metadata-filters` list, which 
*replaces* the configured
 one for that request rather than adding to it.
 
+Since 4.1.0, the embedding filters also accept per-request config under their 
own name --
+e.g. `{"parse-context": {"openai-embedding-filter": {"skipEmbedding": true}}}` 
(likewise
+`jina-embedding-filter`) -- which is merged over the server-side config for 
that request.
+Endpoint fields (`baseUrl`, `apiKey`, `model`) cannot be changed per request.
+
 NOTE: In `CONTENT_ONLY` xref:pipes/parse-modes.adoc[parse mode], Tika applies 
an
 `include-field-metadata-filter` for `tk:content` and 
`tk:exception:container-exception` when you have
 configured no filter of your own. Only a filter that reaches the 
`parse-context` replaces it —
diff --git a/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
index 882abbbca4..251f4a0081 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc
@@ -32,6 +32,13 @@ with a measured need for in-process OCR throughput *and* the 
expertise to run na
 safely.
 ====
 
+Component name: `tess4j-parser`. Adding `tika-parser-tess4j-module` to the 
classpath is a
+deliberate opt-in and is intended to make Tess4J the OCR engine — but when 
both engines are
+live, the winner is decided by registration order, which is not guaranteed. 
Since 4.1.0 a WARN
+at startup names any engine collision and the winner; to pin the engine 
deterministically, name
+it in the top-level `content-enrichers` list (`tess4j-parser` or 
`tesseract-ocr-parser`; see
+xref:configuration/index.adoc[Configuration]).
+
 `Tess4JParser` calls the Tesseract native library in-process via
 https://github.com/nguyenq/tess4j[Tess4J] and JNA instead of spawning a 
`tesseract` child process
 per image. That removes the per-file process-spawn overhead and can be 
significantly faster on
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 73f93b0fcc..0942a774e0 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc
@@ -18,7 +18,23 @@
 = TesseractOCRParser Configuration
 
 Configuration options for `TesseractOCRParser`, which runs the `tesseract` 
command-line program in
-a separate process.
+a separate process. Component name: `tesseract-ocr-parser`.
+
+== Selecting the OCR engine (4.1.0+)
+
+With no configuration, Tesseract is the OCR engine whenever the `tesseract` 
binary is found.
+When more than one OCR engine is on the classpath (e.g. Tess4J or a VLM 
parser), a WARN at
+startup names the collision and the winner. To pin the engine explicitly, name 
it in the
+top-level `content-enrichers` list:
+
+[source,json]
+----
+{
+  "content-enrichers": [ { "tesseract-ocr-parser": { "language": "eng" } } ]
+}
+----
+
+See xref:configuration/index.adoc[Configuration] for how `content-enrichers` 
works.
 
 == Basic Configuration
 
diff --git a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
index 895c162529..65b55f7daf 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -22,27 +22,27 @@ to remote Vision-Language Model (VLM) endpoints. These 
parsers send images
 (or PDFs) to an external API and convert the model's markdown response into
 structured XHTML.
 
-Three implementations are provided out of the box. Only `openai-vlm-parser` is 
auto-loaded via SPI;
-the other two must be named explicitly in your configuration.
+Three implementations are provided out of the box. None is auto-loaded: each 
must be
+named explicitly in your configuration. (Changed in 4.1.0: `openai-vlm-parser` 
previously
+auto-registered via SPI.) To use a VLM as the OCR engine for embedded images 
and rendered
+PDF pages, name it in the `content-enrichers` list — see
+xref:configuration/index.adoc[Configuration].
 
-[cols="1,2,1,1"]
+[cols="1,2,1"]
 |===
-|Parser |Endpoint |Config key |Auto-loaded
+|Parser |Endpoint |Config key
 
 |`OpenAIVLMParser`
 |Any OpenAI-compatible chat completions endpoint (vLLM, Ollama, local FastAPI, 
OpenAI)
 |`openai-vlm-parser`
-|Yes
 
 |`ClaudeVLMParser`
 |Anthropic Messages API
 |`claude-vlm-parser`
-|No
 
 |`GeminiVLMParser`
 |Google Gemini `generateContent` API
 |`gemini-vlm-parser`
-|No
 |===
 
 All three handle the standard OCR image types (`image/ocr-png`, 
`image/ocr-jpeg`, ...).
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
new file mode 100644
index 0000000000..398b3fb225
--- /dev/null
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/CompositeContentEnricher.java
@@ -0,0 +1,88 @@
+/*
+ * 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 java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+
+/**
+ * Media-type-keyed registry of content enrichers: ordinary {@link Parser}s 
that a container
+ * parser <em>invokes</em> on bytes it has already parsed (OCR text for an 
image or a
+ * rendered PDF page), rather than being dispatched to by the composite 
parser. Configured
+ * as the top-level {@code "content-enrichers"} list, mirroring {@code 
"renderers"}.
+ * <p>
+ * Members advertise their <em>real</em> media types ({@code image/png}); 
legacy engines
+ * still advertising the {@code image/ocr-*} pseudo-types are keyed under the 
real type, so
+ * they are nameable here unmodified. An enricher does not compete with the 
parser
+ * registered for the same type: that parser still runs and calls the enricher.
+ *
+ * @since Apache Tika 4.1
+ */
+public class CompositeContentEnricher implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private final Map<MediaType, List<Parser>> enricherMap;
+
+    public CompositeContentEnricher(List<Parser> enrichers) {
+        Map<MediaType, List<Parser>> tmp = new HashMap<>();
+        ParseContext empty = new ParseContext();
+        for (Parser enricher : enrichers) {
+            for (MediaType mediaType : enricher.getSupportedTypes(empty)) {
+                // legacy engines advertise image/ocr-*; key under the real 
type
+                MediaType keyType = 
stripLegacyOcrPrefix(mediaType.getBaseType());
+                List<Parser> forType = tmp.computeIfAbsent(keyType, k -> new 
ArrayList<>());
+                if (!forType.contains(enricher)) {
+                    forType.add(enricher);
+                }
+            }
+        }
+        tmp.replaceAll((k, v) -> Collections.unmodifiableList(v));
+        this.enricherMap = Collections.unmodifiableMap(tmp);
+    }
+
+    private static MediaType stripLegacyOcrPrefix(MediaType mediaType) {
+        String subtype = mediaType.getSubtype();
+        if (subtype.startsWith(LegacyDispatchEnricher.OCR_MEDIATYPE_PREFIX)) {
+            return new MediaType(mediaType.getType(),
+                    
subtype.substring(LegacyDispatchEnricher.OCR_MEDIATYPE_PREFIX.length()));
+        }
+        return mediaType;
+    }
+
+    /**
+     * @return the enrichers for this media type in config order, empty when 
none;
+     *         parameters are ignored, alias normalization is the caller's job
+     */
+    public List<Parser> getEnrichers(MediaType mediaType) {
+        List<Parser> enrichers = enricherMap.get(mediaType.getBaseType());
+        return enrichers == null ? Collections.emptyList() : enrichers;
+    }
+
+    public Set<MediaType> getSupportedTypes() {
+        return enricherMap.keySet();
+    }
+}
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
new file mode 100644
index 0000000000..62db23d9a6
--- /dev/null
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnrichers.java
@@ -0,0 +1,194 @@
+/*
+ * 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 java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+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;
+import org.apache.tika.parser.Parser;
+
+/**
+ * Resolves the content enricher for a media type.
+ * <p>
+ * Call sites: wrap the handler (an {@code EmbeddedContentHandler} over a
+ * {@code BodyContentHandler}) so the enricher cannot dump structure or 
metadata into the
+ * caller's XHTML; resolve on the <em>detected</em> type, captured before a 
parser can
+ * refine Content-Type mid-parse; and pass the caller's own {@link 
ParseContext} through --
+ * the recursion guard rides it, so a fresh context defeats it.
+ *
+ * @since Apache Tika 4.1
+ */
+public final class ContentEnrichers {
+
+    private ContentEnrichers() {
+    }
+
+    /**
+     * Returns the enricher to invoke for one media type, or null when none 
applies.
+     * A configured list is authoritative: every matching enricher runs, in 
config order,
+     * behind the Parser returned here, and an uncovered type gets no 
enrichment -- never a
+     * classpath engine nobody named. Legacy {@code image/ocr-*} dispatch 
applies only when
+     * no list is configured. Null while an enrichment is already in progress 
in this
+     * context, so an enricher that is (or invokes) a container parser cannot 
recurse.
+     *
+     * @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
+     */
+    public static Parser get(CompositeContentEnricher enrichers, MediaType 
mediaType,
+                             ParseContext context) {
+        if (mediaType == null) {
+            return null;
+        }
+        ActiveEnrichment active = context.get(ActiveEnrichment.class);
+        if (active != null && active.active) {
+            return null;
+        }
+        if (enrichers != null) {
+            List<Parser> matched = enrichers.getEnrichers(mediaType);
+            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, 
composite));
+        }
+        return null;
+    }
+
+    /**
+     * Runs each enricher in config order, best-effort: the first failure is 
rethrown once
+     * the chain completes, later ones suppressed onto it. Timeouts, 
SecurityException,
+     * SAXException (incl. write-limit aborts) and runtime exceptions abort 
immediately,
+     * carrying any earlier failure -- a spent budget must not fund more 
enrichments.
+     */
+    private static final class SequentialEnricher implements Parser {
+
+        private static final long serialVersionUID = 1L;
+
+        private final List<Parser> delegates;
+
+        private SequentialEnricher(List<Parser> delegates) {
+            this.delegates = delegates;
+        }
+
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return delegates.get(0).getSupportedTypes(context);
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                          ParseContext context) throws IOException, 
SAXException, TikaException {
+            // each delegate gets the bytes from the start; getPath() spools 
once at most
+            Path path = tis.getPath();
+            Exception first = null;
+            for (Parser delegate : delegates) {
+                try (TikaInputStream fresh = TikaInputStream.get(path)) {
+                    delegate.parse(fresh, handler, metadata, context);
+                } catch (SecurityException | TikaTimeoutException | 
SAXException e) {
+                    if (first != null) {
+                        e.addSuppressed(first);
+                    }
+                    throw e;
+                } catch (IOException | TikaException e) {
+                    if (first == null) {
+                        first = e;
+                    } else {
+                        first.addSuppressed(e);
+                    }
+                } catch (RuntimeException e) {
+                    if (first != null) {
+                        e.addSuppressed(first);
+                    }
+                    throw e;
+                }
+            }
+            if (first instanceof IOException e) {
+                throw e;
+            }
+            if (first instanceof TikaException e) {
+                throw e;
+            }
+        }
+    }
+
+    /** Mutable per-parse marker; single-threaded within one parse. */
+    static final class ActiveEnrichment {
+        boolean active;
+    }
+
+    /**
+     * Marks enrichment in progress so {@link #get} refuses re-entry, and 
restores
+     * Content-Type: an enricher derives content, it does not re-type the 
document.
+     */
+    private static final class GuardedEnricher implements Parser {
+
+        private static final long serialVersionUID = 1L;
+
+        private final Parser delegate;
+
+        private GuardedEnricher(Parser delegate) {
+            this.delegate = delegate;
+        }
+
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return delegate.getSupportedTypes(context);
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                          ParseContext context) throws IOException, 
SAXException, TikaException {
+            ActiveEnrichment active = context.get(ActiveEnrichment.class);
+            if (active == null) {
+                active = new ActiveEnrichment();
+                context.set(ActiveEnrichment.class, active);
+            }
+            String contentType = metadata.get(HttpHeaders.CONTENT_TYPE);
+            // restore, don't clear: a nested call must not lift the outer 
guard
+            boolean wasActive = active.active;
+            active.active = true;
+            try {
+                delegate.parse(tis, handler, metadata, context);
+            } finally {
+                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/EnrichingParser.java 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/EnrichingParser.java
new file mode 100644
index 0000000000..f1b542e47a
--- /dev/null
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/EnrichingParser.java
@@ -0,0 +1,29 @@
+/*
+ * 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;
+
+/**
+ * A parser that invokes content enrichers (OCR on its images or rendered 
pages). The
+ * configured {@link CompositeContentEnricher} is injected at load time, as
+ * {@link org.apache.tika.parser.RenderingParser} receives its renderer.
+ *
+ * @since Apache Tika 4.1
+ */
+public interface EnrichingParser {
+
+    void setContentEnrichers(CompositeContentEnricher contentEnrichers);
+}
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
new file mode 100644
index 0000000000..1f32bff1e0
--- /dev/null
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/LegacyDispatchEnricher.java
@@ -0,0 +1,106 @@
+/*
+ * 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 java.io.IOException;
+import java.util.Collections;
+import java.util.Set;
+
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+
+/**
+ * Reproduces the pre-4.1 {@code image/ocr-*} dispatch when no {@code 
"content-enrichers"}
+ * list is configured: mints the synthetic {@code ocr-} type, sets
+ * {@link TikaCoreProperties#CONTENT_TYPE_PARSER_OVERRIDE}, re-enters the 
composite parser
+ * and restores the metadata. Whichever engine won the {@code ocr-*} 
registration still wins,
+ * so precedence-by-presence is preserved exactly. Confines the pseudo-mime 
dance formerly
+ * hand-rolled in {@code AbstractImageParser} and {@code AbstractPDF2XHTML} to 
one class;
+ * retire it once every engine is selected by name.
+ *
+ * @since Apache Tika 4.1
+ */
+public class LegacyDispatchEnricher implements Parser {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final String OCR_MEDIATYPE_PREFIX = "ocr-";
+
+    private final MediaType mediaType;
+
+    private final Parser composite;
+
+    /**
+     * @param mediaType the real (already normalized) media type of the bytes
+     * @param composite the composite to re-enter; the caller has already 
verified it claims
+     *                  the {@code ocr-} type -- re-checking rebuilds its full 
type map
+     */
+    public LegacyDispatchEnricher(MediaType mediaType, Parser composite) {
+        this.mediaType = mediaType;
+        this.composite = composite;
+    }
+
+    /**
+     * @return the synthetic dispatch type for a real media type, or null if 
mediaType is null
+     */
+    public static MediaType toOcrMediaType(MediaType mediaType) {
+        if (mediaType == null) {
+            return null;
+        }
+        return new MediaType(mediaType.getType(), OCR_MEDIATYPE_PREFIX + 
mediaType.getSubtype());
+    }
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return Collections.singleton(mediaType);
+    }
+
+    @Override
+    public void parse(TikaInputStream tis, ContentHandler handler, Metadata 
metadata,
+                      ParseContext context) throws IOException, SAXException, 
TikaException {
+        MediaType ocrMediaType = toOcrMediaType(mediaType);
+        if (composite == null) {
+            throw new TikaException("No parser is registered for " + 
ocrMediaType);
+        }
+        String originalOverride = 
metadata.get(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE);
+        String originalContentType = metadata.get(HttpHeaders.CONTENT_TYPE);
+        metadata.set(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE, 
ocrMediaType.toString());
+        try {
+            composite.parse(tis, handler, metadata, context);
+        } finally {
+            if (originalOverride == null) {
+                
metadata.remove(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE.getName());
+            } else {
+                metadata.set(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE, 
originalOverride);
+            }
+            if (originalContentType == null) {
+                metadata.remove(HttpHeaders.CONTENT_TYPE);
+            } else {
+                metadata.set(HttpHeaders.CONTENT_TYPE, originalContentType);
+            }
+        }
+    }
+}
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
new file mode 100644
index 0000000000..416e08993e
--- /dev/null
+++ 
b/tika-core/src/test/java/org/apache/tika/parser/enricher/ContentEnrichersTest.java
@@ -0,0 +1,371 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+
+public class ContentEnrichersTest {
+
+    private static final MediaType PNG = MediaType.image("png");
+    private static final MediaType OCR_PNG = MediaType.image("ocr-png");
+
+    private static class RecordingParser implements Parser {
+        private static final long serialVersionUID = 1L;
+        private final Set<MediaType> types;
+        int calls = 0;
+        String overrideSeenDuringParse;
+
+        RecordingParser(Set<MediaType> types) {
+            this.types = types;
+        }
+
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return types;
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                          ParseContext context) {
+            calls++;
+            overrideSeenDuringParse =
+                    
metadata.get(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE);
+        }
+    }
+
+    private static void invoke(Parser enricher, Metadata metadata, 
ParseContext context)
+            throws IOException, SAXException, TikaException {
+        try (TikaInputStream tis = TikaInputStream.get(new byte[0])) {
+            enricher.parse(tis, new DefaultHandler(), metadata, context);
+        }
+    }
+
+    @Test
+    public void testExplicitEnricherWinsOverLegacy() throws Exception {
+        RecordingParser explicit = new 
RecordingParser(Collections.singleton(PNG));
+        RecordingParser composite = new 
RecordingParser(Collections.singleton(OCR_PNG));
+        CompositeContentEnricher enrichers =
+                new CompositeContentEnricher(List.of(explicit));
+        ParseContext context = new ParseContext();
+        context.set(Parser.class, composite);
+
+        Parser enricher = ContentEnrichers.get(enrichers, PNG, context);
+        assertNotNull(enricher);
+        invoke(enricher, new Metadata(), context);
+        assertEquals(1, explicit.calls);
+        assertEquals(0, composite.calls);
+        // the explicit path never mints the pseudo-mime
+        assertNull(explicit.overrideSeenDuringParse);
+    }
+
+    @Test
+    public void testLegacyFallbackMintsAndRestores() throws Exception {
+        RecordingParser composite = new 
RecordingParser(Collections.singleton(OCR_PNG));
+        ParseContext context = new ParseContext();
+        context.set(Parser.class, composite);
+
+        Parser enricher = ContentEnrichers.get(null, PNG, context);
+        assertNotNull(enricher);
+
+        Metadata metadata = new Metadata();
+        metadata.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
+        invoke(enricher, metadata, context);
+
+        assertEquals(1, composite.calls);
+        assertEquals(OCR_PNG.toString(), composite.overrideSeenDuringParse);
+        
assertNull(metadata.get(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE));
+        assertEquals(PNG.toString(), metadata.get(HttpHeaders.CONTENT_TYPE));
+    }
+
+    @Test
+    public void testNoneAvailable() {
+        ParseContext context = new ParseContext();
+        assertNull(ContentEnrichers.get(null, PNG, context));
+        // composite that claims nothing
+        context.set(Parser.class, new RecordingParser(Collections.emptySet()));
+        assertNull(ContentEnrichers.get(null, PNG, context));
+        assertNull(ContentEnrichers.get(null, null, context));
+    }
+
+    @Test
+    public void testConfiguredListIsAuthoritative() throws Exception {
+        // the composite claims ocr-tiff, but a list that doesn't cover tiff 
wins anyway
+        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<>();
+        Parser first = namedEnricher("first", order, false);
+        Parser second = namedEnricher("second", order, false);
+        CompositeContentEnricher enrichers = new 
CompositeContentEnricher(List.of(first, second));
+        ParseContext context = new ParseContext();
+
+        Parser enricher = ContentEnrichers.get(enrichers, PNG, context);
+        assertNotNull(enricher);
+        invoke(enricher, new Metadata(), context);
+        assertEquals(List.of("first", "second"), order);
+    }
+
+    @Test
+    public void testChainIsBestEffortAndStillReportsFailure() throws Exception 
{
+        List<String> order = new java.util.ArrayList<>();
+        Parser failing = namedEnricher("failing", order, true);
+        Parser second = namedEnricher("second", order, false);
+        CompositeContentEnricher enrichers = new 
CompositeContentEnricher(List.of(failing, second));
+        ParseContext context = new ParseContext();
+
+        Parser enricher = ContentEnrichers.get(enrichers, PNG, context);
+        assertNotNull(enricher);
+        TikaException thrown = 
org.junit.jupiter.api.Assertions.assertThrows(TikaException.class,
+                () -> invoke(enricher, new Metadata(), context));
+        // the failure did not stop the second enricher, and was still 
rethrown at the end
+        assertEquals(List.of("failing", "second"), order);
+        assertEquals("failing failed", thrown.getMessage());
+        // the guard is released even when the chain throws
+        assertNotNull(ContentEnrichers.get(enrichers, PNG, context));
+    }
+
+    @Test
+    public void testTimeoutAbortsChainImmediately() throws Exception {
+        List<String> order = new java.util.ArrayList<>();
+        Parser timingOut = 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) throws TikaException {
+                order.add("timingOut");
+                throw new 
org.apache.tika.exception.TikaTimeoutException("budget spent", 1, 1);
+            }
+        };
+        Parser second = namedEnricher("second", order, false);
+        CompositeContentEnricher enrichers =
+                new CompositeContentEnricher(List.of(timingOut, second));
+        ParseContext context = new ParseContext();
+
+        Parser enricher = ContentEnrichers.get(enrichers, PNG, context);
+        assertNotNull(enricher);
+        org.junit.jupiter.api.Assertions.assertThrows(
+                org.apache.tika.exception.TikaTimeoutException.class,
+                () -> invoke(enricher, new Metadata(), context));
+        assertEquals(List.of("timingOut"), order);
+    }
+
+    private static Parser namedEnricher(String name, List<String> order, 
boolean fail) {
+        return 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) throws TikaException {
+                order.add(name);
+                if (fail) {
+                    throw new TikaException(name + " failed");
+                }
+            }
+        };
+    }
+
+    /**
+     * A legacy engine's image/ocr-* advertisement must match the real type, 
and an engine
+     * advertising both forms must run once, not twice.
+     */
+    @Test
+    public void testLegacyOcrTypeAdvertisementsMatchRealTypes() throws 
Exception {
+        List<String> order = new java.util.ArrayList<>();
+        Parser legacyEngine = new Parser() {
+            private static final long serialVersionUID = 1L;
+
+            @Override
+            public Set<MediaType> getSupportedTypes(ParseContext context) {
+                return Set.of(OCR_PNG, MediaType.image("jp2"), 
MediaType.image("ocr-jp2"));
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                              ParseContext context) {
+                order.add("legacyEngine");
+            }
+        };
+        CompositeContentEnricher enrichers =
+                new CompositeContentEnricher(List.of(legacyEngine));
+        ParseContext context = new ParseContext();
+
+        Parser forPng = ContentEnrichers.get(enrichers, PNG, context);
+        assertNotNull(forPng, "ocr-png advertisement must be nameable for 
image/png");
+        invoke(forPng, new Metadata(), context);
+        assertEquals(List.of("legacyEngine"), order);
+
+        order.clear();
+        Parser forJp2 = ContentEnrichers.get(enrichers, 
MediaType.image("jp2"), context);
+        assertNotNull(forJp2);
+        invoke(forJp2, new Metadata(), context);
+        assertEquals(List.of("legacyEngine"), order,
+                "real + pseudo advertisement of the same type must run once");
+    }
+
+    @Test
+    public void testRecursionGuard() throws Exception {
+        ParseContext context = new ParseContext();
+        // an enricher that tries to re-enter enrichment from inside its own 
parse
+        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-enricher",
+                        ContentEnrichers.get(
+                                ctx.get(CompositeContentEnricher.class), PNG, 
ctx) == null
+                                ? "refused" : "allowed");
+            }
+        };
+        CompositeContentEnricher enrichers = new 
CompositeContentEnricher(List.of(reentrant));
+        context.set(CompositeContentEnricher.class, enrichers);
+
+        Parser enricher = ContentEnrichers.get(enrichers, PNG, context);
+        assertNotNull(enricher);
+        Metadata metadata = new Metadata();
+        invoke(enricher, metadata, context);
+        assertEquals("refused", metadata.get("nested-enricher"));
+
+        // and enrichment is available again once the first one completes
+        assertNotNull(ContentEnrichers.get(enrichers, PNG, context));
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
index 52cf9187a1..b134dd0da8 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/AbstractEmbeddingFilter.java
@@ -23,6 +23,8 @@ import java.util.Locale;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import org.apache.tika.config.ParseContextConfig;
+import org.apache.tika.config.SelfConfiguring;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.metadata.Metadata;
@@ -54,7 +56,7 @@ import org.apache.tika.parser.ParseContext;
  * fully constructed. Setters must not be called concurrently with
  * {@link #filter}.
  */
-public abstract class AbstractEmbeddingFilter extends MetadataFilter {
+public abstract class AbstractEmbeddingFilter extends MetadataFilter 
implements SelfConfiguring {
 
     private static final long serialVersionUID = 1L;
 
@@ -88,24 +90,60 @@ public abstract class AbstractEmbeddingFilter extends 
MetadataFilter {
     protected abstract void embed(List<Chunk> chunks, InferenceConfig config, 
ParseContext parseContext)
             throws IOException, TikaException;
 
+    /**
+     * The {@code @TikaComponent} name this filter's per-request JSON config 
is keyed by
+     * in parse-context (e.g. {@code {"openai-embedding-filter": {...}}}).
+     */
+    protected abstract String getComponentName();
+
     @Override
     protected void doFilter(List<Metadata> metadataList, ParseContext 
parseContext) throws TikaException {
-        InferenceConfig requestConfig = 
parseContext.get(InferenceConfig.class);
-        if (requestConfig != null && requestConfig.isSkipEmbedding()) {
+        InferenceConfig config = resolveConfig(parseContext);
+        if (config.isSkipEmbedding()) {
             return;
         }
         for (Metadata metadata : metadataList) {
-            processOne(metadata, parseContext);
+            processOne(metadata, config, parseContext);
+        }
+    }
+
+    /**
+     * Per-request JSON config, validated through {@link 
InferenceConfig.RuntimeConfig}
+     * (which rejects baseUrl/apiKey/model changes) and merged over the 
init-time defaults.
+     * With no JSON, a class-keyed programmatic {@link InferenceConfig} is 
honored for
+     * skipEmbedding only, preserving the pre-4.1 contract.
+     */
+    private InferenceConfig resolveConfig(ParseContext parseContext) throws 
TikaException {
+        try {
+            if (ParseContextConfig.hasConfig(parseContext, 
getComponentName())) {
+                InferenceConfig.RuntimeConfig runtimeConfig = 
ParseContextConfig.getConfig(
+                        parseContext, getComponentName(),
+                        InferenceConfig.RuntimeConfig.class, new 
InferenceConfig.RuntimeConfig());
+                if (runtimeConfig.isSkipEmbedding()) {
+                    return runtimeConfig;
+                }
+                return ParseContextConfig.getConfig(parseContext, 
getComponentName(),
+                        InferenceConfig.class, defaultConfig);
+            }
+        } catch (TikaConfigException | IOException e) {
+            throw new TikaException("Failed to resolve per-request config for 
'"
+                    + getComponentName() + "'", e);
+        }
+        InferenceConfig programmatic = parseContext.get(InferenceConfig.class);
+        if (programmatic != null && programmatic.isSkipEmbedding()) {
+            return programmatic;
         }
+        return defaultConfig;
     }
 
-    private void processOne(Metadata metadata, ParseContext parseContext) 
throws TikaException {
-        String content = metadata.get(defaultConfig.getContentField());
+    private void processOne(Metadata metadata, InferenceConfig config, 
ParseContext parseContext)
+            throws TikaException {
+        String content = metadata.get(config.getContentField());
         if (content == null) {
             LOG.debug("No content found at field '{}'; skipping embedding. "
                     + "If using this filter standalone, populate metadata 
using "
                     + "TikaCoreProperties.TIKA_CONTENT as the key.",
-                    defaultConfig.getContentField());
+                    config.getContentField());
             return;
         }
         if (content.isBlank()) {
@@ -124,15 +162,15 @@ public abstract class AbstractEmbeddingFilter extends 
MetadataFilter {
         }
 
         MarkdownChunker chunker = new MarkdownChunker(
-                defaultConfig.getMaxChunkChars(),
-                defaultConfig.getOverlapChars());
+                config.getMaxChunkChars(),
+                config.getOverlapChars());
 
         List<Chunk> chunks = chunker.chunk(content);
         if (chunks.isEmpty()) {
             return;
         }
 
-        int maxChunks = defaultConfig.getMaxChunks();
+        int maxChunks = config.getMaxChunks();
         if (maxChunks > 0 && chunks.size() > maxChunks) {
             LOG.warn("Document produced {} chunks, truncating to maxChunks={}",
                     chunks.size(), maxChunks);
@@ -140,20 +178,20 @@ public abstract class AbstractEmbeddingFilter extends 
MetadataFilter {
         }
 
         try {
-            int batchSize = defaultConfig.getMaxBatchSize();
+            int batchSize = config.getMaxBatchSize();
             for (int i = 0; i < chunks.size(); i += batchSize) {
                 List<Chunk> batch = chunks.subList(
                         i, Math.min(i + batchSize, chunks.size()));
-                embed(batch, defaultConfig, parseContext);
+                embed(batch, config, parseContext);
             }
-            ChunkSerializer.mergeInto(metadata, chunks, 
defaultConfig.getOutputField());
+            ChunkSerializer.mergeInto(metadata, chunks, 
config.getOutputField());
         } catch (IOException e) {
             throw new TikaException(
                     "Embedding inference failed: " + e.getMessage(), e);
         }
 
-        if (defaultConfig.isClearContentAfterChunking()) {
-            metadata.remove(defaultConfig.getContentField());
+        if (config.isClearContentAfterChunking()) {
+            metadata.remove(config.getContentField());
         }
     }
 
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/JinaEmbeddingFilter.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/JinaEmbeddingFilter.java
index 2cf3671d4f..2985c77498 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/JinaEmbeddingFilter.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/JinaEmbeddingFilter.java
@@ -61,6 +61,11 @@ public class JinaEmbeddingFilter extends 
OpenAIEmbeddingFilter {
         super(config);
     }
 
+    @Override
+    protected String getComponentName() {
+        return "jina-embedding-filter";
+    }
+
     @Override
     String buildRequest(List<Chunk> chunks, InferenceConfig config) {
         ObjectNode root = MAPPER.createObjectNode();
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingFilter.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingFilter.java
index d0b7e00902..54afc1712b 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingFilter.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingFilter.java
@@ -78,6 +78,18 @@ public class OpenAIEmbeddingFilter extends 
AbstractEmbeddingFilter {
         this.httpClient = TikaHttpClient.build(30);
     }
 
+    @Override
+    protected String getComponentName() {
+        return "openai-embedding-filter";
+    }
+
+    @Override
+    public void close() throws IOException {
+        if (httpClient != null) {
+            httpClient.close();
+        }
+    }
+
     @Override
     protected void embed(List<Chunk> chunks, InferenceConfig config, 
ParseContext parseContext)
             throws IOException, TikaException {
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingFilterTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingFilterTest.java
index a9087e6b64..fcf2c2d0d1 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingFilterTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingFilterTest.java
@@ -35,6 +35,7 @@ import org.apache.tika.exception.TikaException;
 import org.apache.tika.http.TikaTestHttpServer;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
 
 public class OpenAIEmbeddingFilterTest {
 
@@ -230,6 +231,62 @@ public class OpenAIEmbeddingFilterTest {
         assertNotNull(merged.get(1).getVector());
     }
 
+    @Test
+    void testPerRequestSkipEmbedding() throws Exception {
+        Metadata metadata = new Metadata();
+        metadata.set(TikaCoreProperties.TIKA_CONTENT, "# Section A\n\nSome 
text.");
+        List<Metadata> list = new ArrayList<>();
+        list.add(metadata);
+
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("openai-embedding-filter", "{\"skipEmbedding\": 
true}");
+        filter.filter(list, context);
+
+        assertNull(metadata.get(TikaCoreProperties.TIKA_CHUNKS));
+        assertEquals(0, server.getRequestCount());
+    }
+
+    @Test
+    void testPerRequestBaseUrlRejected() {
+        Metadata metadata = new Metadata();
+        metadata.set(TikaCoreProperties.TIKA_CONTENT, "# Section A\n\nSome 
text.");
+        List<Metadata> list = new ArrayList<>();
+        list.add(metadata);
+
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("openai-embedding-filter",
+                "{\"baseUrl\": \"http://attacker.example.com\"}";);
+        assertThrows(TikaException.class, () -> filter.filter(list, context));
+        assertEquals(0, server.getRequestCount());
+    }
+
+    @Test
+    void testPerRequestConfigMergesOverDefaults() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200,
+                buildEmbeddingResponse(1, 3)));
+
+        Metadata metadata = new Metadata();
+        metadata.set(TikaCoreProperties.TIKA_CONTENT, "# Section A\n\nSome 
text.");
+        List<Metadata> list = new ArrayList<>();
+        list.add(metadata);
+
+        // an allowed runtime override; baseUrl/model must survive from the 
init-time config
+        ParseContext context = new ParseContext();
+        context.setJsonConfig("openai-embedding-filter", "{\"maxChunkChars\": 
5000}");
+        filter.filter(list, context);
+
+        assertNotNull(metadata.get(TikaCoreProperties.TIKA_CHUNKS));
+        assertEquals(1, server.getRequestCount());
+        TikaTestHttpServer.RecordedRequest request = server.takeRequest();
+        assertEquals("/v1/embeddings", request.path());
+    }
+
+    @Test
+    void testCloseReleasesClient() throws Exception {
+        filter.close();
+        filter.close();
+    }
+
     /**
      * Build a mock OpenAI embeddings response.
      */
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/OpenAIVLMParser.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/OpenAIVLMParser.java
index bf5d824e7b..517fe5f0ab 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/OpenAIVLMParser.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/main/java/org/apache/tika/parser/vlm/OpenAIVLMParser.java
@@ -57,7 +57,8 @@ import org.apache.tika.utils.StringUtils;
  *
  * @since Apache Tika 4.0
  */
-@TikaComponent(name = "openai-vlm-parser")
+// spi = false: VLM parsers are selected by name in config, never 
auto-registered (TIKA-4871)
+@TikaComponent(name = "openai-vlm-parser", spi = false)
 public class OpenAIVLMParser extends AbstractVLMParser {
 
     private static final long serialVersionUID = 1L;
diff --git 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/OpenAIVLMParserTest.java
 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/OpenAIVLMParserTest.java
index 71f6d84deb..3fc7039e64 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/OpenAIVLMParserTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-vlm/src/test/java/org/apache/tika/parser/vlm/OpenAIVLMParserTest.java
@@ -285,6 +285,23 @@ public class OpenAIVLMParserTest {
         assertEquals(0, parser.getSupportedTypes(new ParseContext()).size());
     }
 
+    /**
+     * No VLM parser may auto-register via SPI; VLM parsers must be selected 
by name
+     * in config (TIKA-4871).
+     */
+    @Test
+    void testNoVlmParserAutoRegisters() throws Exception {
+        java.util.Enumeration<java.net.URL> resources = 
getClass().getClassLoader()
+                
.getResources("META-INF/services/org.apache.tika.parser.Parser");
+        while (resources.hasMoreElements()) {
+            java.net.URL url = resources.nextElement();
+            String content = new String(url.openStream().readAllBytes(),
+                    java.nio.charset.StandardCharsets.UTF_8);
+            assertTrue(!content.contains("org.apache.tika.parser.vlm."),
+                    "VLM parser auto-registered via SPI in " + url + ":\n" + 
content);
+        }
+    }
+
     private String buildChatResponse(String content, int prompt, int 
completion) {
         return String.format(java.util.Locale.ROOT,
                 "{\"choices\":[{\"message\":{\"content\":\"%s\"}}],"
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 7cbbe440f8..e6275b768a 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
@@ -24,34 +24,23 @@ 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.TemporaryResources;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
-import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
 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.sax.BodyContentHandler;
 import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
 
-public abstract class AbstractImageParser implements Parser {
+public abstract class AbstractImageParser implements Parser, EnrichingParser {
 
-    public static String OCR_MEDIATYPE_PREFIX = "ocr-";
-
-    /**
-     *
-     * @param mediaType
-     * @return ocr media type if mediatype is not null; returns null if 
mediatype is null
-     */
-    static MediaType convertToOCRMediaType(MediaType mediaType) {
-        if (mediaType == null) {
-            return null;
-        }
-        return new MediaType(mediaType.getType(), OCR_MEDIATYPE_PREFIX + 
mediaType.getSubtype());
-    }
+    private CompositeContentEnricher contentEnrichers;
 
     abstract void extractMetadata(InputStream is, ContentHandler 
contentHandler, Metadata metadata,
                                   ParseContext parseContext)
@@ -63,6 +52,11 @@ public abstract class AbstractImageParser implements Parser {
         return mediaType;
     }
 
+    @Override
+    public void setContentEnrichers(CompositeContentEnricher contentEnrichers) 
{
+        this.contentEnrichers = contentEnrichers;
+    }
+
     @Override
     public void parse(TikaInputStream tis, ContentHandler handler, Metadata 
metadata,
                       ParseContext context) throws IOException, SAXException, 
TikaException {
@@ -71,10 +65,8 @@ public abstract class AbstractImageParser implements Parser {
         //note: mediaType can be null if mediaTypeString is null or
         //not parseable.
         MediaType mediaType = 
normalizeMediaType(MediaType.parse(mediaTypeString));
-        MediaType ocrMediaType = convertToOCRMediaType(mediaType);
-        Parser ocrParser = EmbeddedDocumentUtil.getStatelessParser(context);
-        if (ocrMediaType == null ||
-                ocrParser == null || 
!ocrParser.getSupportedTypes(context).contains(ocrMediaType)) {
+        Parser enricher = ContentEnrichers.get(contentEnrichers, mediaType, 
context);
+        if (enricher == null) {
             extractMetadata(tis, handler, metadata, context);
             XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata, context);
             xhtml.startDocument();
@@ -100,31 +92,11 @@ public abstract class AbstractImageParser implements 
Parser {
                 metadataException = e;
             }
             try (TikaInputStream pathStream = TikaInputStream.get(path)) {
-                //specify ocr content type
-                String originalParserOverride =
-                        
metadata.get(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE);
-                String originalContentType = 
metadata.get(HttpHeaders.CONTENT_TYPE);
-                metadata.set(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE,
-                        ocrMediaType.toString());
                 //need to use bodycontenthandler to filter out re-dumping of 
metadata
                 //in xhtmlhandler
-                try {
-                    ocrParser.parse(pathStream,
-                            new EmbeddedContentHandler(new 
BodyContentHandler(xhtml)), metadata,
-                            context);
-                } finally {
-                    if (originalParserOverride == null) {
-                        
metadata.remove(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE.getName());
-                    } else {
-                        
metadata.set(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE,
-                                originalParserOverride);
-                    }
-                    if (originalContentType == null) {
-                        metadata.remove(HttpHeaders.CONTENT_TYPE);
-                    } else {
-                        metadata.set(HttpHeaders.CONTENT_TYPE, 
originalContentType);
-                    }
-                }
+                enricher.parse(pathStream,
+                        new EmbeddedContentHandler(new 
BodyContentHandler(xhtml)), metadata,
+                        context);
             }
             MotionPhoto.extract(tis, metadata, xhtml, context);
             xhtml.endDocument();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ImageParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ImageParserTest.java
index 87899d0a47..dd0596bdf9 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ImageParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ImageParserTest.java
@@ -30,6 +30,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.LegacyDispatchEnricher;
 
 public class ImageParserTest extends TikaTest {
 
@@ -222,7 +223,84 @@ public class ImageParserTest extends TikaTest {
     @Test
     public void testMimeTypeToOCRMimeTypeConversion() throws Exception {
         assertEquals(new MediaType("image", "OCR-png"),
-                
AbstractImageParser.convertToOCRMediaType(MediaType.image("png")));
+                LegacyDispatchEnricher.toOcrMediaType(MediaType.image("png")));
+    }
+
+    /**
+     * A named enricher is invoked by the image parser, which keeps extracting 
its own
+     * metadata -- the enricher does not displace it (TIKA-4872).
+     */
+    @Test
+    public void testExplicitContentEnricher() throws Exception {
+        Parser enricher = new Parser() {
+            @Override
+            public java.util.Set<MediaType> getSupportedTypes(ParseContext 
context) {
+                return java.util.Collections.singleton(MediaType.image("png"));
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, org.xml.sax.ContentHandler 
handler,
+                              Metadata metadata, ParseContext context) {
+                metadata.set("derived-by", "test-enricher");
+            }
+        };
+        ImageParser imageParser = new ImageParser();
+        imageParser.setContentEnrichers(
+                new org.apache.tika.parser.enricher.CompositeContentEnricher(
+                        java.util.List.of(enricher)));
+
+        Metadata metadata = new Metadata();
+        metadata.set(HttpHeaders.CONTENT_TYPE, "image/png");
+        try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testPNG.png")) {
+            imageParser.parse(tis, new DefaultHandler(), metadata, new 
ParseContext());
+        }
+        assertEquals("test-enricher", metadata.get("derived-by"));
+        // the image parser still ran and extracted its own metadata
+        assertEquals("100", metadata.get(TIFF.IMAGE_WIDTH));
+    }
+
+    /**
+     * A parser that re-types the document mid-parse must still fire the 
enricher chosen
+     * for the DETECTED type it was dispatched on (TIKA-4872).
+     */
+    @Test
+    public void testEnricherSelectedOnDetectedTypeNotRefinedType() throws 
Exception {
+        Parser enricher = new Parser() {
+            @Override
+            public java.util.Set<MediaType> getSupportedTypes(ParseContext 
context) {
+                return java.util.Collections.singleton(MediaType.image("png"));
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, org.xml.sax.ContentHandler 
handler,
+                              Metadata metadata, ParseContext context) {
+                metadata.set("enriched-for", "image/png");
+            }
+        };
+        AbstractImageParser retypingParser = new AbstractImageParser() {
+            @Override
+            public java.util.Set<MediaType> getSupportedTypes(ParseContext 
context) {
+                return java.util.Collections.singleton(MediaType.image("png"));
+            }
+
+            @Override
+            void extractMetadata(java.io.InputStream is, 
org.xml.sax.ContentHandler handler,
+                                 Metadata metadata, ParseContext context) {
+                // simulates a parser refining detection mid-parse
+                metadata.set(HttpHeaders.CONTENT_TYPE, 
"application/illustrator");
+            }
+        };
+        retypingParser.setContentEnrichers(
+                new org.apache.tika.parser.enricher.CompositeContentEnricher(
+                        java.util.List.of(enricher)));
+
+        Metadata metadata = new Metadata();
+        metadata.set(HttpHeaders.CONTENT_TYPE, "image/png");
+        try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testPNG.png")) {
+            retypingParser.parse(tis, new DefaultHandler(), metadata, new 
ParseContext());
+        }
+        assertEquals("image/png", metadata.get("enriched-for"));
+        assertEquals("application/illustrator", 
metadata.get(HttpHeaders.CONTENT_TYPE));
     }
 
     @Test
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 02d49142b2..6312df608c 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
@@ -96,7 +96,8 @@ import org.apache.tika.utils.XMLReaderUtils;
  * parseContext.set(TesseractOCRConfig.class, config);<br>
  * </p>
  */
-@TikaComponent
+// name pinned: the documented "content-enrichers" selector for this engine
+@TikaComponent(name = "tesseract-ocr-parser")
 public class TesseractOCRParser extends AbstractExternalProcessParser 
implements Initializable {
 
     public static final String TESS_META = "tess:";
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 da4a51ed43..7ad8d52e56 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
@@ -116,6 +116,8 @@ import org.apache.tika.metadata.TikaPagedText;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
+import org.apache.tika.parser.enricher.ContentEnrichers;
 import org.apache.tika.parser.pdf.updates.IncrementalUpdateRecord;
 import org.apache.tika.parser.pdf.updates.IsIncrementalUpdate;
 import org.apache.tika.parser.pdf.updates.StartXRefOffset;
@@ -164,8 +166,8 @@ class AbstractPDF2XHTML extends PDFTextStripper {
     final Metadata metadata;
     final EmbeddedDocumentExtractor embeddedDocumentExtractor;
     final PDFParserConfig config;
-    final Parser ocrParser;
     final Renderer renderer;
+    final CompositeContentEnricher contentEnrichers;
     /**
      * Format used for signature dates
      * TODO Make this thread-safe
@@ -202,19 +204,16 @@ class AbstractPDF2XHTML extends PDFTextStripper {
     int num3DAnnotations = 0;
 
     AbstractPDF2XHTML(PDDocument pdDocument, ContentHandler handler, 
ParseContext context,
-                      Metadata metadata, PDFParserConfig config, Renderer 
renderer) throws IOException {
+                      Metadata metadata, PDFParserConfig config, Renderer 
renderer,
+                      CompositeContentEnricher contentEnrichers) throws 
IOException {
         this.pdDocument = pdDocument;
         this.xhtml = new XHTMLContentHandler(handler, metadata, context);
         this.context = context;
         this.metadata = metadata;
         this.config = config;
         this.renderer = renderer;
+        this.contentEnrichers = contentEnrichers;
         embeddedDocumentExtractor = 
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
-        if (config.getOcr().getStrategy() == NO_OCR) {
-            ocrParser = null;
-        } else {
-            ocrParser = EmbeddedDocumentUtil.getStatelessParser(context);
-        }
     }
 
     private static void addNonNullAttribute(String name, String value, 
AttributesImpl attributes) {
@@ -570,17 +569,19 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         if (maxPagesToOcr > 0 && c != null && c.getCount() > maxPagesToOcr) {
             return;
         }
-        MediaType ocrImageMediaType = MediaType.image("ocr-" + 
config.getOcr().getImageFormat().getFormatName());
-        Set<MediaType> supportedTypes = ocrParser.getSupportedTypes(context);
-        if (supportedTypes == null || 
!supportedTypes.contains(ocrImageMediaType)) {
+        MediaType imageMediaType =
+                
MediaType.image(config.getOcr().getImageFormat().getFormatName());
+        Parser enricher = ContentEnrichers.get(contentEnrichers, 
imageMediaType, context);
+        if (enricher == null) {
             if (ocrStrategy == OCR_ONLY || ocrStrategy == 
OCR_AND_TEXT_EXTRACTION) {
                 throw new TikaException(
-                        "" + "I regret that I couldn't find an OCR parser to 
handle " +
-                                ocrImageMediaType + "." +
-                                "Please set the OCR_STRATEGY to NO_OCR or 
configure your" +
-                                "OCR parser correctly");
+                        "I regret that I couldn't find an OCR engine to handle 
" +
+                                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 parser to run ocr
+                //silently skip if there's no engine to run ocr
                 return;
             }
         }
@@ -589,17 +590,18 @@ class AbstractPDF2XHTML extends PDFTextStripper {
             try (RenderResult renderResult = renderCurrentPage(pdPage, tmp)) {
                 Metadata renderMetadata = renderResult.getMetadata();
                 try (TikaInputStream tis = renderResult.getInputStream()) {
-                    
renderMetadata.set(TikaCoreProperties.CONTENT_TYPE_PARSER_OVERRIDE,
-                            ocrImageMediaType.toString());
-                    ocrParser.parse(tis, new EmbeddedContentHandler(new 
BodyContentHandler(xhtml)),
+                    renderMetadata.set(HttpHeaders.CONTENT_TYPE, 
imageMediaType.toString());
+                    enricher.parse(tis, new EmbeddedContentHandler(new 
BodyContentHandler(xhtml)),
                             renderMetadata, context);
                 }
                 // Propagate enrichment metadata added by the OCR parser (e.g. 
tk:chunks
                 // from image embedding parsers) back to the parent document 
so it isn't
                 // silently discarded when the renderMetadata goes out of 
scope.
                 String renderChunks = 
renderMetadata.get(TikaCoreProperties.TIKA_CHUNKS);
-                if (renderChunks != null && 
metadata.get(TikaCoreProperties.TIKA_CHUNKS) == null) {
-                    metadata.set(TikaCoreProperties.TIKA_CHUNKS, renderChunks);
+                if (renderChunks != null) {
+                    metadata.set(TikaCoreProperties.TIKA_CHUNKS,
+                            
mergeChunkArrays(metadata.get(TikaCoreProperties.TIKA_CHUNKS),
+                                    renderChunks));
                 }
             }
         } catch (IOException e) {
@@ -611,6 +613,31 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         }
     }
 
+    /**
+     * Appends two serialized tk:chunks JSON arrays without a JSON dependency, 
so each
+     * OCR'd page's chunks accumulate on the parent instead of first-page-wins.
+     * Falls back to the new value if either side is not an array.
+     */
+    static String mergeChunkArrays(String existing, String added) {
+        if (existing == null || existing.isBlank()) {
+            return added;
+        }
+        String e = existing.trim();
+        String a = added.trim();
+        if (!e.startsWith("[") || !e.endsWith("]") || !a.startsWith("[") || 
!a.endsWith("]")) {
+            return a;
+        }
+        String eBody = e.substring(1, e.length() - 1).trim();
+        String aBody = a.substring(1, a.length() - 1).trim();
+        if (eBody.isEmpty()) {
+            return a;
+        }
+        if (aBody.isEmpty()) {
+            return e;
+        }
+        return "[" + eBody + "," + aBody + "]";
+    }
+
     private RenderResult renderCurrentPage(PDPage pdPage, TemporaryResources 
tmpResources)
             throws IOException, TikaException {
         PDFRenderingState renderingState = 
context.get(PDFRenderingState.class);
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/OCR2XHTML.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/OCR2XHTML.java
index 8eff5c597e..3e8a2bd70d 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/OCR2XHTML.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/OCR2XHTML.java
@@ -29,6 +29,7 @@ import org.xml.sax.SAXException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
 import org.apache.tika.renderer.Renderer;
 
 
@@ -39,8 +40,9 @@ import org.apache.tika.renderer.Renderer;
 class OCR2XHTML extends AbstractPDF2XHTML {
 
     private OCR2XHTML(PDDocument document, ContentHandler handler, 
ParseContext context,
-                      Metadata metadata, PDFParserConfig config, Renderer 
renderer) throws IOException {
-        super(document, handler, context, metadata, config, renderer);
+                      Metadata metadata, PDFParserConfig config, Renderer 
renderer,
+                               CompositeContentEnricher contentEnrichers) 
throws IOException {
+        super(document, handler, context, metadata, config, renderer, 
contentEnrichers);
     }
 
     /**
@@ -57,12 +59,13 @@ class OCR2XHTML extends AbstractPDF2XHTML {
      */
     public static void process(PDDocument document, ContentHandler handler, 
ParseContext context,
                                Metadata metadata,
-                               PDFParserConfig config, Renderer renderer)
+                               PDFParserConfig config, Renderer renderer,
+                               CompositeContentEnricher contentEnrichers)
             throws SAXException, TikaException {
         OCR2XHTML ocr2XHTML = null;
 
         try {
-            ocr2XHTML = new OCR2XHTML(document, handler, context, metadata, 
config, renderer);
+            ocr2XHTML = new OCR2XHTML(document, handler, context, metadata, 
config, renderer, contentEnrichers);
             ocr2XHTML.writeText(document, new Writer() {
                 @Override
                 public void write(char[] cbuf, int off, int len) {
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 bbf4e0fbd7..1496f586d0 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
@@ -42,6 +42,7 @@ import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
 import org.apache.tika.parser.pdf.image.ImageGraphicsEngine;
 import org.apache.tika.renderer.PageRangeRequest;
 import org.apache.tika.renderer.RenderRequest;
@@ -73,8 +74,9 @@ class PDF2XHTML extends AbstractPDF2XHTML {
     private AtomicInteger inlineImageCounter = new AtomicInteger(0);
 
     PDF2XHTML(PDDocument document, ContentHandler handler, ParseContext 
context, Metadata metadata,
-              PDFParserConfig config, Renderer renderer) throws IOException {
-        super(document, handler, context, metadata, config, renderer);
+              PDFParserConfig config, Renderer renderer,
+              CompositeContentEnricher contentEnrichers) throws IOException {
+        super(document, handler, context, metadata, config, renderer, 
contentEnrichers);
     }
 
     /**
@@ -89,7 +91,8 @@ class PDF2XHTML extends AbstractPDF2XHTML {
      * @throws TikaException if there was an exception outside of per page 
processing
      */
     public static void process(PDDocument document, ContentHandler handler, 
ParseContext context,
-                               Metadata metadata, PDFParserConfig config, 
Renderer renderer)
+                               Metadata metadata, PDFParserConfig config, 
Renderer renderer,
+                               CompositeContentEnricher contentEnrichers)
             throws SAXException, TikaException {
         PDF2XHTML pdf2XHTML = null;
         try {
@@ -98,9 +101,10 @@ class PDF2XHTML extends AbstractPDF2XHTML {
             // handler.
             if (config.isDetectAngles()) {
                 pdf2XHTML =
-                        new AngleDetectingPDF2XHTML(document, handler, 
context, metadata, config, renderer);
+                        new AngleDetectingPDF2XHTML(document, handler, 
context, metadata,
+                                config, renderer, contentEnrichers);
             } else {
-                pdf2XHTML = new PDF2XHTML(document, handler, context, 
metadata, config, renderer);
+                pdf2XHTML = new PDF2XHTML(document, handler, context, 
metadata, config, renderer, contentEnrichers);
             }
             config.configure(pdf2XHTML);
 
@@ -270,8 +274,9 @@ class PDF2XHTML extends AbstractPDF2XHTML {
 
         private AngleDetectingPDF2XHTML(PDDocument document, ContentHandler 
handler,
                                         ParseContext context, Metadata 
metadata,
-                                        PDFParserConfig config, Renderer 
renderer) throws IOException {
-            super(document, handler, context, metadata, config, renderer);
+                                        PDFParserConfig config, Renderer 
renderer,
+              CompositeContentEnricher contentEnrichers) throws IOException {
+            super(document, handler, context, metadata, config, renderer, 
contentEnrichers);
         }
 
         @Override
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFMarkedContent2XHTML.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFMarkedContent2XHTML.java
index ca016ac4d9..570c1c282d 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFMarkedContent2XHTML.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFMarkedContent2XHTML.java
@@ -47,6 +47,7 @@ import org.xml.sax.SAXException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
 import org.apache.tika.renderer.Renderer;
 
 /**
@@ -91,9 +92,10 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
 
     private PDFMarkedContent2XHTML(PDDocument document, ContentHandler handler,
                                    ParseContext context, Metadata metadata, 
PDFParserConfig config,
-                                   Renderer renderer)
+                                   Renderer renderer,
+                                   CompositeContentEnricher contentEnrichers)
             throws IOException {
-        super(document, handler, context, metadata, config, renderer);
+        super(document, handler, context, metadata, config, renderer, 
contentEnrichers);
     }
 
     /**
@@ -111,14 +113,15 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
      */
     public static void process(PDDocument pdDocument, ContentHandler handler,
                                ParseContext context,
-                               Metadata metadata, PDFParserConfig config, 
Renderer renderer)
+                               Metadata metadata, PDFParserConfig config, 
Renderer renderer,
+                               CompositeContentEnricher contentEnrichers)
             throws SAXException, TikaException {
 
         PDFMarkedContent2XHTML pdfMarkedContent2XHTML = null;
         try {
             pdfMarkedContent2XHTML =
                     new PDFMarkedContent2XHTML(pdDocument, handler, context, 
metadata, config,
-                            renderer);
+                            renderer, contentEnrichers);
         } catch (IOException e) {
             throw new TikaException("couldn't initialize 
PDFMarkedContent2XHTML", e);
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
index 4ad0e95401..5c987e1e64 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java
@@ -79,6 +79,8 @@ import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.PasswordProvider;
 import org.apache.tika.parser.RenderingParser;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
+import org.apache.tika.parser.enricher.EnrichingParser;
 import org.apache.tika.parser.pdf.updates.IncrementalUpdateRecord;
 import org.apache.tika.parser.pdf.updates.IsIncrementalUpdate;
 import org.apache.tika.parser.pdf.updates.StartXRefOffset;
@@ -122,7 +124,7 @@ import org.apache.tika.sax.XHTMLContentHandler;
  * {@link PDFParserConfig#setExtractMarkedContent(boolean)}
  */
 @TikaComponent
-public class PDFParser implements Parser, RenderingParser {
+public class PDFParser implements Parser, RenderingParser, EnrichingParser {
 
     public static final MediaType MEDIA_TYPE = MediaType.application("pdf");
     /**
@@ -136,6 +138,7 @@ public class PDFParser implements Parser, RenderingParser {
     private static COSName ENCRYPTED_PAYLOAD = 
COSName.getPDFName("EncryptedPayload");
     private PDFParserConfig defaultConfig = new PDFParserConfig();
     private Renderer renderer;
+    private CompositeContentEnricher contentEnrichers;
 
     public PDFParser() {
     }
@@ -220,14 +223,14 @@ public class PDFParser implements Parser, RenderingParser 
{
                 } else if (localConfig.getOcr().getStrategy()
                         .equals(OcrConfig.Strategy.OCR_ONLY)) {
                     OCR2XHTML.process(pdfDocument, handler, context, metadata,
-                            localConfig, renderer);
+                            localConfig, renderer, contentEnrichers);
                 } else if (hasMarkedContent && 
localConfig.isExtractMarkedContent()) {
                     PDFMarkedContent2XHTML
                             .process(pdfDocument, handler, context, metadata,
-                                    localConfig, renderer);
+                                    localConfig, renderer, contentEnrichers);
                 } else {
                     PDF2XHTML.process(pdfDocument, handler, context, metadata,
-                            localConfig, renderer);
+                            localConfig, renderer, contentEnrichers);
                 }
             }
         } catch (InvalidPasswordException e) {
@@ -792,6 +795,15 @@ public class PDFParser implements Parser, RenderingParser {
         this.renderer = renderer;
     }
 
+    @Override
+    public void setContentEnrichers(CompositeContentEnricher contentEnrichers) 
{
+        this.contentEnrichers = contentEnrichers;
+    }
+
+    public CompositeContentEnricher getContentEnrichers() {
+        return contentEnrichers;
+    }
+
     public Renderer getRenderer() {
         return renderer;
     }
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 da6d0a7282..8fd7eabd64 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
@@ -1619,6 +1619,100 @@ public class PDFParserTest extends TikaTest {
         assertTrue(sawTimeoutWarning,
                 "the timeout must be recorded, not silently dropped, once 
caught and continued past");
     }
+
+    /**
+     * tk:chunks written by the OCR-slot parser (e.g. an image-embedding 
parser) on each
+     * rendered page must all reach the parent metadata, not just the first 
page's.
+     */
+    @Test
+    public void testChunksFromAllOcrPagesReachParent() throws Exception {
+        PDFParserConfig config = new PDFParserConfig();
+        config.getOcr().setStrategy(OcrConfig.Strategy.OCR_ONLY);
+
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        context.set(Parser.class, new Parser() {
+            @Override
+            public Set<MediaType> getSupportedTypes(ParseContext context) {
+                return Collections.singleton(
+                        MediaType.image("ocr-" + 
config.getOcr().getImageFormat().getFormatName()));
+            }
+
+            @Override
+            public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                              ParseContext context) throws IOException, 
SAXException, TikaException {
+                int currentPage = context.get(OCRPageCounter.class).getCount();
+                metadata.setTrusted(TikaCoreProperties.TIKA_CHUNKS.getName(),
+                        "[{\"text\":\"chunk-page-" + currentPage + "\"}]");
+                XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata);
+                xhtml.startDocument();
+                xhtml.endDocument();
+            }
+        });
+
+        Metadata metadata = new Metadata();
+        try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testPDF_bookmarks.pdf")) {
+            new PDFParser().parse(tis, new ToXMLContentHandler(), metadata, 
context);
+        }
+
+        String chunks = metadata.get(TikaCoreProperties.TIKA_CHUNKS);
+        assertNotNull(chunks);
+        assertContains("chunk-page-1", chunks);
+        assertContains("chunk-page-2", chunks);
+    }
+
+    /**
+     * A named enricher advertising real image types, with no composite 
registration at all,
+     * receives every rendered page when OCR runs (TIKA-4872).
+     */
+    @Test
+    public void testExplicitContentEnricherReceivesRenderedPages() throws 
Exception {
+        PDFParserConfig config = new PDFParserConfig();
+        config.getOcr().setStrategy(OcrConfig.Strategy.OCR_ONLY);
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+
+        Parser enricher = new Parser() {
+            @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 {
+                assertEquals("image/png",
+                        
metadata.get(org.apache.tika.metadata.HttpHeaders.CONTENT_TYPE));
+                XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata);
+                xhtml.startDocument();
+                xhtml.characters("DERIVED-PAGE-" + 
context.get(OCRPageCounter.class).getCount());
+                xhtml.endDocument();
+            }
+        };
+        PDFParser parser = new PDFParser();
+        parser.setContentEnrichers(
+                new 
org.apache.tika.parser.enricher.CompositeContentEnricher(List.of(enricher)));
+
+        Metadata metadata = new Metadata();
+        ToXMLContentHandler xmlHandler = new ToXMLContentHandler();
+        try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testPDF_bookmarks.pdf")) {
+            parser.parse(tis, xmlHandler, metadata, context);
+        }
+        String xml = xmlHandler.toString();
+        assertContains("DERIVED-PAGE-1", xml);
+        assertContains("DERIVED-PAGE-2", xml);
+    }
+
+    @Test
+    public void testMergeChunkArrays() {
+        assertEquals("[b]", AbstractPDF2XHTML.mergeChunkArrays(null, "[b]"));
+        assertEquals("[b]", AbstractPDF2XHTML.mergeChunkArrays(" ", "[b]"));
+        assertEquals("[a,b]", AbstractPDF2XHTML.mergeChunkArrays("[a]", 
"[b]"));
+        assertEquals("[a]", AbstractPDF2XHTML.mergeChunkArrays("[a]", "[]"));
+        assertEquals("[b]", AbstractPDF2XHTML.mergeChunkArrays("[]", "[b]"));
+        assertEquals("[b]", AbstractPDF2XHTML.mergeChunkArrays("not-an-array", 
"[b]"));
+    }
+
     /**
      * TODO -- need to test signature extraction
      */
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/MockEnricher.java
 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/MockEnricher.java
new file mode 100644
index 0000000000..73b499b270
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/MockEnricher.java
@@ -0,0 +1,56 @@
+/*
+ * 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.pipes.core;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+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;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/** Fixture: proves the invocation and config path with no OCR binary 
installed. */
+@TikaComponent(name = "mock-enricher", spi = false)
+public class MockEnricher implements Parser {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final String MARKER_KEY = "mock-enricher";
+    public static final String MARKER_TEXT = "MOCK-ENRICHED-TEXT";
+
+    @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 SAXException {
+        metadata.set(MARKER_KEY, "ENRICHED");
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata);
+        xhtml.startDocument();
+        xhtml.characters(MARKER_TEXT);
+        xhtml.endDocument();
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
index 3a1002976d..8233f939eb 100644
--- 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
@@ -83,6 +83,36 @@ public class PipesClientTest {
         }
     }
 
+    /**
+     * Wire test for the content-enrichers slot (TIKA-4872): a config-named 
enricher is
+     * injected into the fork's parsers and its output survives the fork 
boundary.
+     */
+    @Test
+    public void testContentEnricherInFork(@TempDir Path tmp) throws Exception {
+        Path tikaConfigPath = PluginsTestHelper.getFileSystemFetcherConfig(
+                "tika-config-content-enrichers.json", tmp, 
tmp.resolve("input"),
+                tmp.resolve("output"), false);
+        Path inputDir = tmp.resolve("input");
+        Files.createDirectories(inputDir);
+        java.awt.image.BufferedImage image =
+                new java.awt.image.BufferedImage(10, 10, 
java.awt.image.BufferedImage.TYPE_INT_RGB);
+        javax.imageio.ImageIO.write(image, "png", 
inputDir.resolve("test.png").toFile());
+
+        TikaJsonConfig tikaJsonConfig = TikaJsonConfig.load(tikaConfigPath);
+        PipesConfig pipesConfig = PipesConfig.load(tikaJsonConfig);
+        try (PipesClient pipesClient = new PipesClient(pipesConfig, 
tikaConfigPath)) {
+            PipesResult pipesResult = pipesClient.process(
+                    new FetchEmitTuple("test.png", new FetchKey(fetcherName, 
"test.png"),
+                            new EmitKey(), new Metadata(), new ParseContext(),
+                            FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+            Assertions.assertNotNull(pipesResult.emitData().getMetadataList());
+            Metadata metadata = 
pipesResult.emitData().getMetadataList().get(0);
+            assertEquals("ENRICHED", metadata.get(MockEnricher.MARKER_KEY));
+            assertTrue(metadata.get(TikaCoreProperties.TIKA_CONTENT)
+                    .contains(MockEnricher.MARKER_TEXT));
+        }
+    }
+
     @Test
     public void testMetadataFilter(@TempDir Path tmp) throws Exception {
         ParseContext parseContext = new ParseContext();
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-content-enrichers.json
 
b/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-content-enrichers.json
new file mode 100644
index 0000000000..798fe4ff1d
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-content-enrichers.json
@@ -0,0 +1,56 @@
+{
+  "content-enrichers": [ { "mock-enricher": {} } ],
+  "content-handler-factory": {
+    "basic-content-handler-factory": {
+      "type": "TEXT",
+      "writeLimit": -1,
+      "throwOnWriteLimitReached": true
+    }
+  },
+  "fetchers": {
+    "fsf": {
+      "file-system-fetcher": {
+        "basePath": "FETCHER_BASE_PATH",
+        "extractFileSystemMetadata": false
+      }
+    }
+  },
+  "emitters": {
+    "fse": {
+      "file-system-emitter": {
+        "basePath": "EMITTER_BASE_PATH",
+        "fileExtension": "json",
+        "onExists": "EXCEPTION"
+      }
+    }
+  },
+  "pipes-iterator": {
+    "file-system-pipes-iterator": {
+      "basePath": "FETCHER_BASE_PATH",
+      "countTotal": true,
+      "fetcherId": "fsf",
+      "emitterId": "fse"
+    }
+  },
+  "pipes": {
+    "parseMode": "RMETA",
+    "onParseException": "EMIT",
+    "numClients": 4,
+    "emitIntermediateResults": "EMIT_INTERMEDIATE_RESULTS",
+    "forkedJvmArgs": ["-Xmx512m"],
+    "emitStrategy": {
+      "type": "DYNAMIC",
+      "thresholdBytes": 1000000
+    }
+  },
+  "auto-detect-parser": {
+    "throwOnZeroBytes": false
+  },
+  "parse-context": {
+    "mock-digester-factory": {},
+    "timeout-limits": {
+      "progressTimeoutMillis": 5000
+    }
+  },
+  "plugin-roots": "PLUGINS_PATHS"
+}
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
new file mode 100644
index 0000000000..781b3feb93
--- /dev/null
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
@@ -0,0 +1,69 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+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;
+
+/**
+ * Loads the top-level {@code "content-enrichers"} 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.
+ */
+class ContentEnricherLoader implements 
ComponentLoader<CompositeContentEnricher> {
+
+    @Override
+    public CompositeContentEnricher load(TikaJsonConfig config, LoaderContext 
context)
+            throws TikaConfigException {
+        List<Map.Entry<String, JsonNode>> entries = 
config.getArrayComponents("content-enrichers");
+        if (entries.isEmpty()) {
+            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());
+                enricher = context.getObjectMapper().treeToValue(wrapper, 
Parser.class);
+            } catch (Exception e) {
+                throw new TikaConfigException(
+                        "Failed to load content enricher: " + entry.getKey(), 
e);
+            }
+            // this type snapshot lasts the life of the process, so an engine 
reporting
+            // nothing (missing binary, dead server) must fail load, not go 
silently inert
+            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/LoaderContext.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/LoaderContext.java
index 8d240d2e28..592de5bbbd 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/LoaderContext.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/LoaderContext.java
@@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 
 import org.apache.tika.detect.EncodingDetector;
 import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
 import org.apache.tika.renderer.Renderer;
 
 /**
@@ -98,6 +99,16 @@ public class LoaderContext {
         return get(Renderer.class);
     }
 
+    /**
+     * Get the configured content enrichers for injection into enriching 
parsers.
+     *
+     * @return the composite, or null when no "content-enrichers" are 
configured
+     * @throws TikaConfigException if loading fails
+     */
+    public CompositeContentEnricher getContentEnrichers() throws 
TikaConfigException {
+        return get(CompositeContentEnricher.class);
+    }
+
     /**
      * Instantiate a component by name and config.
      *
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 1c37c68fff..383e908552 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
@@ -19,9 +19,12 @@ package org.apache.tika.config.loader;
 import java.io.IOException;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 import com.fasterxml.jackson.databind.JsonNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import org.apache.tika.config.ServiceLoader;
 import org.apache.tika.detect.EncodingDetector;
@@ -30,9 +33,12 @@ import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.AbstractEncodingDetectorParser;
 import org.apache.tika.parser.CompositeParser;
 import org.apache.tika.parser.DefaultParser;
+import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.ParserDecorator;
 import org.apache.tika.parser.RenderingParser;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
+import org.apache.tika.parser.enricher.EnrichingParser;
 import org.apache.tika.renderer.Renderer;
 
 /**
@@ -40,11 +46,13 @@ import org.apache.tika.renderer.Renderer;
  * <ul>
  *   <li>SPI fallback via "default-parser" marker with exclusions</li>
  *   <li>Mime type filtering decorations (_mime-include, _mime-exclude)</li>
- *   <li>EncodingDetector and Renderer dependency injection</li>
+ *   <li>EncodingDetector, Renderer and content-enricher dependency 
injection</li>
  * </ul>
  */
 public class ParserLoader extends AbstractSpiComponentLoader<Parser> {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(ParserLoader.class);
+
     public ParserLoader() {
         super("parsers", "default-parser", Parser.class);
     }
@@ -125,10 +133,13 @@ public class ParserLoader extends 
AbstractSpiComponentLoader<Parser> {
     @Override
     protected Parser postProcess(Parser parser, LoaderContext context)
             throws TikaConfigException {
-        // Inject EncodingDetector and Renderer into parsers that need them
         EncodingDetector encodingDetector = context.getEncodingDetector();
         Renderer renderer = context.getRenderer();
-        injectDependenciesRecursively(parser, encodingDetector, renderer);
+        CompositeContentEnricher contentEnrichers = 
context.getContentEnrichers();
+        injectDependenciesRecursively(parser, encodingDetector, renderer, 
contentEnrichers);
+        if (contentEnrichers == null) {
+            warnOnAmbiguousOcrRegistrations(parser);
+        }
         return parser;
     }
 
@@ -136,19 +147,60 @@ public class ParserLoader extends 
AbstractSpiComponentLoader<Parser> {
      * Recursively inject dependencies into a parser and its children.
      */
     private void injectDependenciesRecursively(Parser parser, EncodingDetector 
encodingDetector,
-                                                Renderer renderer) {
+                                                Renderer renderer,
+                                                CompositeContentEnricher 
contentEnrichers) {
         if (encodingDetector != null && parser instanceof 
AbstractEncodingDetectorParser aedp) {
             aedp.setEncodingDetector(encodingDetector);
         }
         if (renderer != null && parser instanceof RenderingParser rp) {
             rp.setRenderer(renderer);
         }
+        if (contentEnrichers != null && parser instanceof EnrichingParser dp) {
+            dp.setContentEnrichers(contentEnrichers);
+        }
         if (parser instanceof CompositeParser cp) {
             for (Parser child : cp.getAllComponentParsers()) {
-                injectDependenciesRecursively(child, encodingDetector, 
renderer);
+                injectDependenciesRecursively(child, encodingDetector, 
renderer, contentEnrichers);
             }
         } else if (parser instanceof ParserDecorator pd) {
-            injectDependenciesRecursively(pd.getWrappedParser(), 
encodingDetector, renderer);
+            injectDependenciesRecursively(pd.getWrappedParser(), 
encodingDetector, renderer,
+                    contentEnrichers);
+        }
+    }
+
+    /**
+     * Several OCR engines can claim the same image/ocr-* pseudo-type -- 
availability is
+     * environmental -- and the composite resolves the collision silently by 
last
+     * registration; name the collision and the winner once at load. The 
caller skips this
+     * when content-enrichers is configured: that list is authoritative, so 
legacy dispatch
+     * never runs and the advice is already taken.
+     */
+    private void warnOnAmbiguousOcrRegistrations(Parser parser) {
+        if (!(parser instanceof CompositeParser cp)) {
+            return;
+        }
+        ParseContext empty = new ParseContext();
+        Map<MediaType, List<Parser>> duplicates = 
cp.findDuplicateParsers(empty);
+        if (duplicates.isEmpty()) {
+            return;
+        }
+        Map<MediaType, Parser> winners = cp.getParsers(empty);
+        for (Map.Entry<MediaType, List<Parser>> e : duplicates.entrySet()) {
+            if (!e.getKey().getSubtype().startsWith("ocr-")) {
+                continue;
+            }
+            StringBuilder claimants = new StringBuilder();
+            for (Parser p : e.getValue()) {
+                if (claimants.length() > 0) {
+                    claimants.append(", ");
+                }
+                claimants.append(p.getClass().getName());
+            }
+            Parser winner = winners.get(e.getKey());
+            LOG.warn("Multiple OCR engines claim {}: [{}]; {} wins by 
registration order. "
+                            + "Select one explicitly with 
\"content-enrichers\".",
+                    e.getKey(), claimants,
+                    winner == null ? "unknown" : winner.getClass().getName());
         }
     }
 
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
index d15815b4ac..04af3fb1c9 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
@@ -113,6 +113,7 @@ public class TikaJsonConfig {
             "metadata-filters",
             "content-handler-factory",
             "renderers",
+            "content-enrichers",
             "translator",
             "auto-detect-parser",
             "parse-context",
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 a1ee066b15..e117b71553 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
@@ -48,6 +48,7 @@ import org.apache.tika.parser.AutoDetectParserConfig;
 import org.apache.tika.parser.CompositeParser;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
 import org.apache.tika.renderer.CompositeRenderer;
 import org.apache.tika.renderer.Renderer;
 import org.apache.tika.sax.BasicContentHandlerFactory;
@@ -125,6 +126,10 @@ public class TikaLoader {
                 .wrapWith(list -> new CompositeRenderer((List<Renderer>) list))
                 .register();
 
+        ComponentConfig.builder("content-enrichers", 
CompositeContentEnricher.class)
+                .customLoader(new ContentEnricherLoader())
+                .register();
+
         ComponentConfig.builder("translator", Translator.class)
                 .loadAsList()
                 .wrapWith(list -> list.isEmpty() ? null : (Translator) 
list.get(0))
@@ -789,6 +794,10 @@ public class TikaLoader {
             output.set("renderers", config.getRootNode().get("renderers"));
         }
 
+        if (config.hasArrayComponents("content-enrichers")) {
+            output.set("content-enrichers", 
config.getRootNode().get("content-enrichers"));
+        }
+
         // Preserve auto-detect-parser config if present
         JsonNode adpNode = config.getRootNode().get("auto-detect-parser");
         if (adpNode != null && !adpNode.isNull()) {
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
new file mode 100644
index 0000000000..49acce1070
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/ContentEnricherLoaderTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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 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.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.CompositeParser;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
+
+public class ContentEnricherLoaderTest {
+
+    @TempDir
+    Path tmp;
+
+    private TikaLoader load(String json) throws Exception {
+        Path config = tmp.resolve("tika-config.json");
+        Files.writeString(config, json);
+        return TikaLoader.load(config);
+    }
+
+    @Test
+    public void testContentEnrichersLoadAndInject() throws Exception {
+        TikaLoader loader = load("""
+                {
+                  "parsers": [ {"enriching-test-parser": {}} ],
+                  "content-enrichers": [ {"test-png-enricher": {}} ]
+                }
+                """);
+
+        CompositeContentEnricher enrichers = 
loader.get(CompositeContentEnricher.class);
+        assertNotNull(enrichers);
+        java.util.List<Parser> matched = 
enrichers.getEnrichers(MediaType.image("png"));
+        assertEquals(1, matched.size());
+        assertTrue(matched.get(0) instanceof TestPngEnricher,
+                "expected TestPngEnricher, got " + matched.get(0));
+        assertEquals(1, enrichers.getSupportedTypes().size());
+
+        EnrichingTestParser enrichingParser = 
findEnrichingParser(loader.get(Parser.class));
+        assertNotNull(enrichingParser, "enriching-test-parser not found in 
loaded parsers");
+        assertNotNull(enrichingParser.getContentEnrichers(),
+                "content enrichers were not injected into the 
EnrichingParser");
+        assertEquals(enrichers, enrichingParser.getContentEnrichers());
+    }
+
+    @Test
+    public void testZeroTypeEnricherFailsLoad() throws Exception {
+        // a named engine that cannot run must fail 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("""
+                {
+                  "parsers": [ {"enriching-test-parser": {}} ]
+                }
+                """);
+        assertNull(loader.get(CompositeContentEnricher.class));
+        EnrichingTestParser enrichingParser = 
findEnrichingParser(loader.get(Parser.class));
+        assertNotNull(enrichingParser);
+        assertNull(enrichingParser.getContentEnrichers());
+    }
+
+    private EnrichingTestParser findEnrichingParser(Parser parser) {
+        if (parser instanceof EnrichingTestParser dtp) {
+            return dtp;
+        }
+        if (parser instanceof CompositeParser cp) {
+            for (Parser child : cp.getAllComponentParsers()) {
+                EnrichingTestParser found = findEnrichingParser(child);
+                if (found != null) {
+                    return found;
+                }
+            }
+        }
+        return null;
+    }
+}
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/EnrichingTestParser.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/EnrichingTestParser.java
new file mode 100644
index 0000000000..aca501d486
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/EnrichingTestParser.java
@@ -0,0 +1,60 @@
+/*
+ * 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;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
+import org.apache.tika.parser.enricher.EnrichingParser;
+
+/** Fixture: asserts ParserLoader injects the enrichers into an {@link 
EnrichingParser}. */
+@TikaComponent(name = "enriching-test-parser", spi = false)
+public class EnrichingTestParser implements Parser, EnrichingParser {
+
+    private static final long serialVersionUID = 1L;
+
+    private transient CompositeContentEnricher contentEnrichers;
+
+    @Override
+    public void setContentEnrichers(CompositeContentEnricher contentEnrichers) 
{
+        this.contentEnrichers = contentEnrichers;
+    }
+
+    public CompositeContentEnricher getContentEnrichers() {
+        return contentEnrichers;
+    }
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return 
Collections.singleton(MediaType.parse("application/test+deriving"));
+    }
+
+    @Override
+    public void parse(TikaInputStream stream, ContentHandler handler, Metadata 
metadata,
+                      ParseContext context) {
+        metadata.set("parser-type", "deriving");
+    }
+}
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/TestPngEnricher.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestPngEnricher.java
new file mode 100644
index 0000000000..8e76753420
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestPngEnricher.java
@@ -0,0 +1,47 @@
+/*
+ * 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;
+
+/** Fixture: an ordinary parser advertising a real media type, nameable in 
"content-enrichers". */
+@TikaComponent(name = "test-png-enricher", spi = false)
+public class TestPngEnricher implements Parser {
+
+    private static final long serialVersionUID = 1L;
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return Collections.singleton(MediaType.image("png"));
+    }
+
+    @Override
+    public void parse(TikaInputStream stream, ContentHandler handler, Metadata 
metadata,
+                      ParseContext context) {
+        metadata.set("derived-by", "test-png-enricher");
+    }
+}
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..313413e2e4
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestUnavailableEnricher.java
@@ -0,0 +1,46 @@
+/*
+ * 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;
+
+/** Fixture: an engine unusable at load time (missing binary, dead server) 
advertises nothing. */
+@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