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