Skip to content

Commit 54a8ed6

Browse files
authored
TIKA-4872 - improve ocr wiring (#3119)
1 parent 4a563d6 commit 54a8ed6

38 files changed

Lines changed: 1869 additions & 114 deletions

File tree

CHANGES.txt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,38 @@
11
Release 4.1.0 - unreleased
22

3+
* New "content-enrichers" config list (TIKA-4872): select the OCR engine
4+
("tesseract-ocr-parser", "tess4j-parser", "openai-vlm-parser", ...) by
5+
name instead of by classpath registration of the image/ocr-* pseudo
6+
media types. Enrichers advertise real media types (legacy engines that
7+
still advertise image/ocr-* are mapped to the real type, so all are
8+
nameable) and are invoked by the image and PDF parsers rather than
9+
dispatched to by the composite, so an enricher no longer displaces the
10+
parser registered for the same type. Enricher selection uses the
11+
detected media type, captured before a parser can refine Content-Type.
12+
Every enricher matching a media type runs, in config order (e.g. an
13+
OCR engine then a VLM tagger for the same image), best-effort: one
14+
enricher's failure does not stop the others and is still reported;
15+
timeouts abort the chain. The list is authoritative: a media type no
16+
configured enricher matches gets no enrichment -- never a classpath
17+
engine that was not named -- and a named engine that reports no media
18+
types at load (missing binary, unreachable inference server) fails
19+
config load instead of going silently inert. With no
20+
"content-enrichers" configured, the legacy ocr-* dispatch applies
21+
unchanged; a WARN at config load now names colliding OCR engines and
22+
the winner. TesseractOCRParser's
23+
component name is pinned as "tesseract-ocr-parser".
24+
25+
* Inference/OCR hardening (TIKA-4871): OpenAIVLMParser no longer
26+
auto-registers via SPI, matching its Claude/Gemini siblings; select
27+
it by name ("openai-vlm-parser") in config. Per-request parse-context
28+
config for the embedding filters now works and is validated:
29+
{"openai-embedding-filter": {"skipEmbedding": true}} (likewise
30+
"jina-embedding-filter") merges over the server config, and
31+
baseUrl/apiKey/model may not be changed at runtime. The embedding
32+
filters release their HTTP client resources on close(). Inline PDF
33+
page OCR now accumulates tk:chunks from every OCR'd page onto the
34+
parent document instead of keeping only the first page's.
35+
336
* Placeholder streams -- the empty stand-ins parsers hand parseEmbedded
437
for content that is never extracted -- report an unknown length rather
538
than their own zero, and the macro-failure entry is registered without

docs/modules/ROOT/pages/configuration/index.adoc

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ optional; anything you omit uses its defaults.
3838
"encoding-detectors": [ /* encoding detector declarations */ ],
3939
"metadata-filters": [ /* metadata filter declarations */ ],
4040
"renderers": [ /* page renderer declarations */ ],
41+
"content-enrichers": [ /* OCR engines etc., selected by name; see below */ ],
4142
"translator": { /* translator declaration */ },
4243
"content-handler-factory": { /* handler type for emitted content */ },
4344
"auto-detect-parser": { /* AutoDetectParser options */ },
@@ -125,6 +126,36 @@ Configuring a parser automatically excludes its default copy, so there is no dup
125126
`default-encoding-detector`, but it must not be mixed with explicit detector entries — see
126127
xref:configuration/encoding-detectors.adoc[Encoding Detectors].
127128

129+
== The `content-enrichers` list (4.1.0+)
130+
131+
Content enrichers are ordinary parsers that a container parser *invokes* on bytes it has already
132+
parsed — an OCR engine run on an embedded image or a rendered PDF page. Naming one here selects
133+
the engine explicitly instead of relying on which OCR module happens to be on the classpath:
134+
135+
[source,json]
136+
----
137+
{
138+
"content-enrichers": [
139+
{ "tesseract-ocr-parser": { "language": "eng" } }
140+
]
141+
}
142+
----
143+
144+
An enricher advertises its *real* media types (`image/png`, ...) and does not compete with the
145+
parser registered for those types: `image-parser` still parses the image and calls the enricher.
146+
(The bundled OCR engines still advertise legacy `image/ocr-*` types; those are mapped to the
147+
real type, so naming them here just works.) *Every* enricher matching a media type runs, in the
148+
order listed — so an OCR engine followed by a VLM that tags images is two entries, both invoked
149+
per image. The list is authoritative: a media type no configured enricher matches gets no
150+
enrichment — never a classpath engine you did not name — and a named engine that reports no
151+
media types at startup (missing native binary, unreachable inference server) fails config load
152+
rather than going silently inert. Failures are best-effort: one enricher failing does not stop
153+
the others, and every failure is still reported through the parser's normal exception handling
154+
(timeouts abort the chain immediately). With no `content-enrichers` configured, behavior is
155+
unchanged — whichever OCR engine is on the classpath is used, exactly as before, and a WARN is
156+
logged at startup when several engines collide. Engine names: `tesseract-ocr-parser`, `tess4j-parser`, `openai-vlm-parser`,
157+
`claude-vlm-parser`, `gemini-vlm-parser`.
158+
128159
== Windows file paths
129160

130161
JSON treats the backslash as an escape character, so path options (`tesseractPath`,

docs/modules/ROOT/pages/configuration/metadata-filters.adoc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ around.
233233
`parse-context` can carry a per-request `metadata-filters` list, which *replaces* the configured
234234
one for that request rather than adding to it.
235235

236+
Since 4.1.0, the embedding filters also accept per-request config under their own name --
237+
e.g. `{"parse-context": {"openai-embedding-filter": {"skipEmbedding": true}}}` (likewise
238+
`jina-embedding-filter`) -- which is merged over the server-side config for that request.
239+
Endpoint fields (`baseUrl`, `apiKey`, `model`) cannot be changed per request.
240+
236241
NOTE: In `CONTENT_ONLY` xref:pipes/parse-modes.adoc[parse mode], Tika applies an
237242
`include-field-metadata-filter` for `tk:content` and `tk:exception:container-exception` when you have
238243
configured no filter of your own. Only a filter that reaches the `parse-context` replaces it —

docs/modules/ROOT/pages/configuration/parsers/tess4j-parser.adoc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ with a measured need for in-process OCR throughput *and* the expertise to run na
3232
safely.
3333
====
3434

35+
Component name: `tess4j-parser`. Adding `tika-parser-tess4j-module` to the classpath is a
36+
deliberate opt-in and is intended to make Tess4J the OCR engine — but when both engines are
37+
live, the winner is decided by registration order, which is not guaranteed. Since 4.1.0 a WARN
38+
at startup names any engine collision and the winner; to pin the engine deterministically, name
39+
it in the top-level `content-enrichers` list (`tess4j-parser` or `tesseract-ocr-parser`; see
40+
xref:configuration/index.adoc[Configuration]).
41+
3542
`Tess4JParser` calls the Tesseract native library in-process via
3643
https://github.com/nguyenq/tess4j[Tess4J] and JNA instead of spawning a `tesseract` child process
3744
per image. That removes the per-file process-spawn overhead and can be significantly faster on

docs/modules/ROOT/pages/configuration/parsers/tesseract-ocr-parser.adoc

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,23 @@
1818
= TesseractOCRParser Configuration
1919

2020
Configuration options for `TesseractOCRParser`, which runs the `tesseract` command-line program in
21-
a separate process.
21+
a separate process. Component name: `tesseract-ocr-parser`.
22+
23+
== Selecting the OCR engine (4.1.0+)
24+
25+
With no configuration, Tesseract is the OCR engine whenever the `tesseract` binary is found.
26+
When more than one OCR engine is on the classpath (e.g. Tess4J or a VLM parser), a WARN at
27+
startup names the collision and the winner. To pin the engine explicitly, name it in the
28+
top-level `content-enrichers` list:
29+
30+
[source,json]
31+
----
32+
{
33+
"content-enrichers": [ { "tesseract-ocr-parser": { "language": "eng" } } ]
34+
}
35+
----
36+
37+
See xref:configuration/index.adoc[Configuration] for how `content-enrichers` works.
2238

2339
== Basic Configuration
2440

docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,27 +22,27 @@ to remote Vision-Language Model (VLM) endpoints. These parsers send images
2222
(or PDFs) to an external API and convert the model's markdown response into
2323
structured XHTML.
2424

25-
Three implementations are provided out of the box. Only `openai-vlm-parser` is auto-loaded via SPI;
26-
the other two must be named explicitly in your configuration.
25+
Three implementations are provided out of the box. None is auto-loaded: each must be
26+
named explicitly in your configuration. (Changed in 4.1.0: `openai-vlm-parser` previously
27+
auto-registered via SPI.) To use a VLM as the OCR engine for embedded images and rendered
28+
PDF pages, name it in the `content-enrichers` list — see
29+
xref:configuration/index.adoc[Configuration].
2730

28-
[cols="1,2,1,1"]
31+
[cols="1,2,1"]
2932
|===
30-
|Parser |Endpoint |Config key |Auto-loaded
33+
|Parser |Endpoint |Config key
3134

3235
|`OpenAIVLMParser`
3336
|Any OpenAI-compatible chat completions endpoint (vLLM, Ollama, local FastAPI, OpenAI)
3437
|`openai-vlm-parser`
35-
|Yes
3638

3739
|`ClaudeVLMParser`
3840
|Anthropic Messages API
3941
|`claude-vlm-parser`
40-
|No
4142

4243
|`GeminiVLMParser`
4344
|Google Gemini `generateContent` API
4445
|`gemini-vlm-parser`
45-
|No
4646
|===
4747

4848
All three handle the standard OCR image types (`image/ocr-png`, `image/ocr-jpeg`, ...).
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.parser.enricher;
18+
19+
import java.io.Serializable;
20+
import java.util.ArrayList;
21+
import java.util.Collections;
22+
import java.util.HashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.Set;
26+
27+
import org.apache.tika.mime.MediaType;
28+
import org.apache.tika.parser.ParseContext;
29+
import org.apache.tika.parser.Parser;
30+
31+
/**
32+
* Media-type-keyed registry of content enrichers: ordinary {@link Parser}s that a container
33+
* parser <em>invokes</em> on bytes it has already parsed (OCR text for an image or a
34+
* rendered PDF page), rather than being dispatched to by the composite parser. Configured
35+
* as the top-level {@code "content-enrichers"} list, mirroring {@code "renderers"}.
36+
* <p>
37+
* Members advertise their <em>real</em> media types ({@code image/png}); legacy engines
38+
* still advertising the {@code image/ocr-*} pseudo-types are keyed under the real type, so
39+
* they are nameable here unmodified. An enricher does not compete with the parser
40+
* registered for the same type: that parser still runs and calls the enricher.
41+
*
42+
* @since Apache Tika 4.1
43+
*/
44+
public class CompositeContentEnricher implements Serializable {
45+
46+
private static final long serialVersionUID = 1L;
47+
48+
private final Map<MediaType, List<Parser>> enricherMap;
49+
50+
public CompositeContentEnricher(List<Parser> enrichers) {
51+
Map<MediaType, List<Parser>> tmp = new HashMap<>();
52+
ParseContext empty = new ParseContext();
53+
for (Parser enricher : enrichers) {
54+
for (MediaType mediaType : enricher.getSupportedTypes(empty)) {
55+
// legacy engines advertise image/ocr-*; key under the real type
56+
MediaType keyType = stripLegacyOcrPrefix(mediaType.getBaseType());
57+
List<Parser> forType = tmp.computeIfAbsent(keyType, k -> new ArrayList<>());
58+
if (!forType.contains(enricher)) {
59+
forType.add(enricher);
60+
}
61+
}
62+
}
63+
tmp.replaceAll((k, v) -> Collections.unmodifiableList(v));
64+
this.enricherMap = Collections.unmodifiableMap(tmp);
65+
}
66+
67+
private static MediaType stripLegacyOcrPrefix(MediaType mediaType) {
68+
String subtype = mediaType.getSubtype();
69+
if (subtype.startsWith(LegacyDispatchEnricher.OCR_MEDIATYPE_PREFIX)) {
70+
return new MediaType(mediaType.getType(),
71+
subtype.substring(LegacyDispatchEnricher.OCR_MEDIATYPE_PREFIX.length()));
72+
}
73+
return mediaType;
74+
}
75+
76+
/**
77+
* @return the enrichers for this media type in config order, empty when none;
78+
* parameters are ignored, alias normalization is the caller's job
79+
*/
80+
public List<Parser> getEnrichers(MediaType mediaType) {
81+
List<Parser> enrichers = enricherMap.get(mediaType.getBaseType());
82+
return enrichers == null ? Collections.emptyList() : enrichers;
83+
}
84+
85+
public Set<MediaType> getSupportedTypes() {
86+
return enricherMap.keySet();
87+
}
88+
}

0 commit comments

Comments
 (0)