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

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

commit 8a19875df82fe8c341476d05df9e8d905fbe5072
Author: tallison <[email protected]>
AuthorDate: Wed Sep 2 14:37:50 2026 -0400

    TIKA-4872: content-enrichers
---
 CHANGES.txt                                        |  17 ++
 docs/modules/ROOT/pages/configuration/index.adoc   |  28 ++
 .../pages/configuration/parsers/tess4j-parser.adoc |   7 +
 .../parsers/tesseract-ocr-parser.adoc              |  18 +-
 .../pages/configuration/parsers/vlm-parsers.adoc   |   4 +-
 .../parser/enricher/CompositeContentEnricher.java  |  98 +++++++
 .../tika/parser/enricher/ContentEnrichers.java     | 188 ++++++++++++++
 .../tika/parser/enricher/EnrichingParser.java      |  29 +++
 .../parser/enricher/LegacyDispatchEnricher.java    | 107 ++++++++
 .../tika/parser/enricher/ContentEnrichersTest.java | 281 +++++++++++++++++++++
 .../tika/parser/image/AbstractImageParser.java     |  61 ++---
 .../apache/tika/parser/image/ImageParserTest.java  |  82 +++++-
 .../apache/tika/parser/ocr/TesseractOCRParser.java |   3 +-
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  |  36 ++-
 .../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  |  43 ++++
 .../org/apache/tika/pipes/core/MockEnricher.java   |  59 +++++
 .../apache/tika/pipes/core/PipesClientTest.java    |  31 +++
 .../configs/tika-config-content-enrichers.json     |  56 ++++
 .../tika/config/loader/ContentEnricherLoader.java  |  58 +++++
 .../apache/tika/config/loader/LoaderContext.java   |  11 +
 .../apache/tika/config/loader/ParserLoader.java    |  62 ++++-
 .../apache/tika/config/loader/TikaJsonConfig.java  |   1 +
 .../org/apache/tika/config/loader/TikaLoader.java  |   9 +
 .../config/loader/ContentEnricherLoaderTest.java   |  97 +++++++
 .../tika/config/loader/EnrichingTestParser.java    |  63 +++++
 .../apache/tika/config/loader/TestPngEnricher.java |  50 ++++
 30 files changed, 1470 insertions(+), 90 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index b632080e80..782bf4700d 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,22 @@
 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. 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
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc 
b/docs/modules/ROOT/pages/configuration/index.adoc
index c525061962..c2804aa51d 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,33 @@ 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. 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/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 8c68b7e7d4..65b55f7daf 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -24,7 +24,9 @@ structured XHTML.
 
 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.)
+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"]
 |===
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..db614d3118
--- /dev/null
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/CompositeContentEnricher.java
@@ -0,0 +1,98 @@
+/*
+ * 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 to obtain 
derived
+ * content (OCR text for an image, for a rendered PDF page, ...), rather than 
being
+ * dispatched to by the composite parser.
+ * <p>
+ * Configured as the top-level {@code "content-enrichers"} list, mirroring
+ * {@code "renderers"}; members advertise their <em>real</em> media types
+ * ({@code image/png}). Legacy OCR engines that still advertise the {@code 
image/ocr-*}
+ * pseudo-types are keyed under the corresponding real type, so they are 
nameable here
+ * without modification. An enricher registered here does not
+ * compete with the parser registered for the same type: the parser still runs 
and calls
+ * the enricher.
+ * <p>
+ * <b>Every</b> enricher matching a media type runs, in config order — e.g. an 
OCR engine
+ * followed by a VLM tagger for the same image. Output lands at the caller's 
chosen
+ * position in that order. Failures are best-effort: one enricher's failure 
does not stop
+ * the others; the first failure is rethrown after the chain completes with 
later ones
+ * suppressed. Timeouts, SecurityException and SAXException abort the chain 
immediately.
+ *
+ * @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 (Tesseract, VLM, ...) still advertise the 
image/ocr-*
+                // pseudo-types; key them under the real type so they are 
nameable here
+                MediaType keyType = stripLegacyOcrPrefix(mediaType);
+                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 configured for this exact media type, in config 
order;
+     *         empty when none
+     */
+    public List<Parser> getEnrichers(MediaType mediaType) {
+        List<Parser> enrichers = enricherMap.get(mediaType);
+        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..f2177c75e0
--- /dev/null
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/ContentEnrichers.java
@@ -0,0 +1,188 @@
+/*
+ * 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.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>
+ * Contract for call sites:
+ * <ul>
+ *   <li>The caller owns placement: wrap the handler (e.g. an
+ *       {@code EmbeddedContentHandler} over a {@code BodyContentHandler}) so 
the enricher
+ *       cannot emit its own document structure or metadata dump into the 
caller's XHTML.</li>
+ *   <li>The caller owns invocation granularity: once per image, per rendered 
page, per
+ *       segment; the slot does not dictate.</li>
+ *   <li>Resolve against the <em>detected</em> media type, captured at parse 
entry before
+ *       the parser can refine Content-Type (e.g. a PDF re-typed to 
Illustrator mid-parse
+ *       must still fire the enricher selected for the type it was dispatched 
on).</li>
+ *   <li>The enricher writes into the caller's {@link Metadata}; the caller 
must not assume
+ *       the metadata is untouched beyond the derived content.</li>
+ * </ul>
+ *
+ * @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.
+     * Explicitly configured enrichers win: every one matching the type runs, 
in config
+     * order, behind the single Parser returned here. Otherwise the legacy
+     * {@code image/ocr-*} dispatch through the composite parser applies when 
an engine
+     * claims the synthetic type. Returns null while a enrichment is already 
in progress
+     * in this context, so an enricher that is (or invokes) a container parser 
cannot
+     * recurse into enrichment.
+     *
+     * @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
+     * @param context   the parse context
+     */
+    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 new GuardedEnricher(matched.size() == 1
+                        ? matched.get(0) : new SequentialEnricher(matched));
+            }
+        }
+        Parser composite = EmbeddedDocumentUtil.getStatelessParser(context);
+        if (composite != null && composite.getSupportedTypes(context)
+                .contains(LegacyDispatchEnricher.toOcrMediaType(mediaType))) {
+            return new GuardedEnricher(new LegacyDispatchEnricher(mediaType));
+        }
+        return null;
+    }
+
+    /**
+     * Runs each enricher in config order, best-effort: one enricher's failure 
does not
+     * stop the others. The first failure is rethrown after the chain 
completes, with
+     * later failures attached as suppressed, so call sites report every 
failure through
+     * their existing exception handling. Timeouts, SecurityException and 
SAXException
+     * (incl. write-limit aborts) propagate immediately -- a spent budget or a 
suspect
+     * handler must not fund further enrichments.
+     */
+    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);
+                    }
+                }
+            }
+            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 around the delegate so {@link #get} 
refuses re-entry. */
+    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);
+            }
+            active.active = true;
+            try {
+                delegate.parse(tis, handler, metadata, context);
+            } finally {
+                active.active = false;
+            }
+        }
+    }
+}
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..087f34e184
--- /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 (e.g. OCR on its images or rendered 
pages).
+ * The configured {@link CompositeContentEnricher} is injected at load time, 
the way
+ * {@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..7b40938fb5
--- /dev/null
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/enricher/LegacyDispatchEnricher.java
@@ -0,0 +1,107 @@
+/*
+ * 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.extractor.EmbeddedDocumentUtil;
+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 enricher is 
configured for
+ * a media type: mints the synthetic {@code ocr-} media type, sets
+ * {@link TikaCoreProperties#CONTENT_TYPE_PARSER_OVERRIDE} and re-enters the 
composite
+ * parser, restoring the metadata afterwards. Whichever engine won the {@code 
ocr-*}
+ * registration in the composite still wins here, so precedence-by-presence 
(adding
+ * e.g. tika-parser-tess4j-module to the classpath) is preserved exactly.
+ * <p>
+ * This confines the pseudo-mime dance formerly hand-rolled in both
+ * {@code AbstractImageParser} and {@code AbstractPDF2XHTML} to one class, to 
be retired
+ * once OCR engines are 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;
+
+    /**
+     * @param mediaType the real (already normalized) media type of the bytes 
to derive from
+     */
+    public LegacyDispatchEnricher(MediaType mediaType) {
+        this.mediaType = mediaType;
+    }
+
+    /**
+     * @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);
+        Parser composite = EmbeddedDocumentUtil.getStatelessParser(context);
+        if (composite == null
+                || 
!composite.getSupportedTypes(context).contains(ocrMediaType)) {
+            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..8f2dbdc22f
--- /dev/null
+++ 
b/tika-core/src/test/java/org/apache/tika/parser/enricher/ContentEnrichersTest.java
@@ -0,0 +1,281 @@
+/*
+ * 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 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");
+                }
+            }
+        };
+    }
+
+    /**
+     * Legacy engines (Tesseract, the VLM parsers) advertise image/ocr-* 
pseudo-types;
+     * naming one as an enricher must still match the real type — and an engine
+     * advertising both the real and the pseudo form of a type 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-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 954649b2b0..2e53f2a2c6 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,28 @@ 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.parser.enricher.LegacyDispatchEnricher;
 import org.apache.tika.sax.BodyContentHandler;
 import org.apache.tika.sax.EmbeddedContentHandler;
 import org.apache.tika.sax.XHTMLContentHandler;
 
-public abstract class AbstractImageParser implements Parser {
+public abstract class AbstractImageParser implements Parser, EnrichingParser {
 
-    public static String OCR_MEDIATYPE_PREFIX = "ocr-";
+    /** @deprecated use {@link LegacyDispatchEnricher#OCR_MEDIATYPE_PREFIX} */
+    @Deprecated
+    public static String OCR_MEDIATYPE_PREFIX = 
LegacyDispatchEnricher.OCR_MEDIATYPE_PREFIX;
 
-    /**
-     *
-     * @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 +57,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 +70,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 +97,11 @@ public abstract class AbstractImageParser implements 
Parser {
             }
 
             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);
             }
             xhtml.endDocument();
         } finally {
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..c8549f31d3 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,86 @@ 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 content enricher selected by name advertises real types and 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));
+    }
+
+    /**
+     * The enricher is selected on the DETECTED media type, captured before 
the parser
+     * can refine Content-Type mid-parse (TIKA-4872): a parser that re-types 
the document
+     * during metadata extraction must still fire the enricher chosen at 
dispatch.
+     */
+    @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..266abd7df8 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: it is 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 fb8a725e31..b7cde47b4a 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,17 @@ 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 + ". Configure one in 
\"content-enrichers\", " +
+                                "add one to the classpath, 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,9 +588,8 @@ 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
@@ -599,7 +597,7 @@ class AbstractPDF2XHTML extends PDFTextStripper {
                 // silently discarded when the renderMetadata goes out of 
scope.
                 String renderChunks = 
renderMetadata.get(TikaCoreProperties.TIKA_CHUNKS);
                 if (renderChunks != null) {
-                    
metadata.setTrusted(TikaCoreProperties.TIKA_CHUNKS.getName(),
+                    metadata.set(TikaCoreProperties.TIKA_CHUNKS,
                             
mergeChunkArrays(metadata.get(TikaCoreProperties.TIKA_CHUNKS),
                                     renderChunks));
                 }
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 b44ed01df7..02ebd1ffe0 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
@@ -1661,6 +1661,49 @@ public class PDFParserTest extends TikaTest {
         assertContains("chunk-page-2", chunks);
     }
 
+    /**
+     * A content enricher selected by name -- advertising real image types, 
never the
+     * ocr- pseudo types, with no composite-parser 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]"));
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..c51cf77238
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/MockEnricher.java
@@ -0,0 +1,59 @@
+/*
+ * 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;
+
+/**
+ * A content enricher selectable by name in "content-enrichers": proves the 
invocation
+ * and config path (is it run, does its output arrive) 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..ec1b7dc8ad 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,37 @@ public class PipesClientTest {
         }
     }
 
+    /**
+     * Wire test for the content-enrichers slot (TIKA-4872): a config-named 
enricher must
+     * be injected into the fork's parsers and its output must survive the 
fork boundary.
+     * Uses MockEnricher, so no OCR binary is needed.
+     */
+    @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..56330a790f
--- /dev/null
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/ContentEnricherLoader.java
@@ -0,0 +1,58 @@
+/*
+ * 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.Parser;
+import org.apache.tika.parser.enricher.CompositeContentEnricher;
+
+/**
+ * Loads the top-level {@code "content-enrichers"} list: ordinary parsers, 
selected by
+ * component name, that container parsers invoke for derived content (OCR, 
...). Members
+ * come from the same registry as {@code "parsers"} entries but do not join 
the composite
+ * parser'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<>();
+        for (Map.Entry<String, JsonNode> entry : entries) {
+            try {
+                ObjectNode wrapper = 
context.getObjectMapper().createObjectNode();
+                wrapper.set(entry.getKey(), entry.getValue());
+                enrichers.add(context.getObjectMapper().treeToValue(wrapper, 
Parser.class));
+            } catch (Exception e) {
+                throw new TikaConfigException(
+                        "Failed to load content enricher: " + entry.getKey(), 
e);
+            }
+        }
+        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..5e44cf5cf7 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,12 @@ 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
+        // Inject EncodingDetector, Renderer and content enrichers 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);
+        warnOnAmbiguousOcrRegistrations(parser);
         return parser;
     }
 
@@ -136,19 +146,59 @@ 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);
+        }
+    }
+
+    /**
+     * The image/ocr-* pseudo-types are claimed by several OCR engines whose 
availability
+     * is environmental, and the composite resolves a collision by last 
registration with
+     * no warning. Name the collision and the winner once at load so engine 
selection is
+     * debuggable; select an engine explicitly with "content-enrichers".
+     */
+    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..46fcfeff84
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/ContentEnricherLoaderTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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 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..f3cbc2fe8b
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/EnrichingTestParser.java
@@ -0,0 +1,63 @@
+/*
+ * 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 for asserting that ParserLoader injects the configured content 
enrichers
+ * into parsers implementing {@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..6dee97ef65
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestPngEnricher.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.xml.sax.ContentHandler;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+
+/**
+ * Content-enricher fixture: an ordinary parser advertising a real media type,
+ * selectable by name in the "content-enrichers" list.
+ */
+@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");
+    }
+}

Reply via email to