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 5f61c86003 TIKA-4895: inference results land on the one object of the
single-obj… (#3175)
5f61c86003 is described below
commit 5f61c86003c039c57439dfcb1640d710f034fb55
Author: Tim Allison <[email protected]>
AuthorDate: Tue Sep 15 13:49:00 2026 -0400
TIKA-4895: inference results land on the one object of the single-obj…
(#3175)
---
CHANGES.txt | 18 ++++-
docs/modules/ROOT/pages/configuration/index.adoc | 9 +++
.../pages/configuration/inference-recipes.adoc | 5 ++
.../ROOT/pages/configuration/inference.adoc | 46 +++++++----
.../pages/configuration/parsers/pdf-parser.adoc | 3 +
.../ROOT/pages/developers/custom-ocr-engine.adoc | 7 ++
.../pages/migration-to-4x/chunk-strategies.adoc | 9 ++-
.../apache/tika/parser/enricher/package-info.java | 26 ++++++
.../org/apache/tika/parser/hook/ParseHooks.java | 39 +++++++--
.../org/apache/tika/parser/hook/package-info.java | 22 +++++
.../tika/parser/inference/InferenceDispatcher.java | 52 ++++++++----
.../tika/parser/inference/InferenceTask.java | 4 +-
.../tika/parser/inference/InferenceUnit.java | 57 ++++++++++---
.../{InferenceTask.java => package-info.java} | 27 ++-----
.../parser/inference/InferenceDispatcherTest.java | 93 ++++++++++++++++++++--
.../java/org/apache/tika/parser/mock/MockTask.java | 4 +-
.../org/apache/tika/inference/ChunkTarget.java | 38 +++++----
.../java/org/apache/tika/inference/EmbedTask.java | 15 ++--
.../org/apache/tika/inference/ChunkTargetTest.java | 50 ++++++++++--
.../apache/tika/pipes/core/PipesClientTest.java | 30 +++++++
.../server/standard/InferenceBindingsWireTest.java | 49 +++++++++++-
21 files changed, 486 insertions(+), 117 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 31bfb2e562..44ec131a74 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
Release 4.1.0 - unreleased
+ * Inference results in the single-object outputs: with /tika as JSON,
+ tika-app -j, or pipes CONCATENATE, the vectors of an attachment's pages
+ and pictures now land on the one metadata object handed back, each
+ with an embedded locator naming the part, instead of being discarded
+ with the attachment's own metadata. InferenceUnit gains
+ getDestination(), where a task writes; ChunkTarget.of(unit) replaces
+ resolve(target, parent) (TIKA-4895).
+
* A request can no longer supply openai-embedding-filter or
jina-embedding-filter in a per-request metadata-filters list: 4.0.x
built the filter from the request, endpoint and key included, and
@@ -154,7 +162,15 @@ Release 4.1.0 - unreleased
* tika-inference and tika-vlm are experimental: classes, config keys and
metadata output may change in minor releases without deprecation or
- compatibility shims (TIKA-4884).
+ compatibility shims (TIKA-4884). So are the "inference" config section,
+ pdf-parser "inference", the org.apache.tika.parser.enricher, .inference
+ and .hook packages, and the tk:chunks and tk:inference-released output:
+ 4.2 batches recognition per document, meant as an opt-in addition (an
+ engine that takes one image at a time keeps working unchanged), and adds
+ media inputs and document-level tasks.
+ Stable: the "engines" map, the "text-recognizers" list, and pdf-parser
+ "text"; a 4.1 spelling of those loads as an alias for at least one minor
+ release if it changes (TIKA-4895).
* A missing OOXML relationship target no longer aborts the whole file:
the threaded-comment and person lookups in xlsx and the page lookups in
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc
b/docs/modules/ROOT/pages/configuration/index.adoc
index d88a9310d5..de6d6ddb2c 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -133,6 +133,15 @@ Text recognizers are engines that produce a document's
text from its pixels: an
on an embedded image or a rendered PDF page, a VLM prompted to transcribe. A
container parser
*invokes* them on bytes it has already parsed. Two recipes cover almost every
deployment.
+NOTE: This list and the `engines` map it names are stable: an entry keeps
loading and meaning
+"this engine recognizes text for these types", and if 4.2 changes the spelling
the 4.1 form
+loads as an alias for at least one minor release. What may change is when the
engine is called
+and where its text lands: 4.2 batches recognition per document, so text may be
placed after
+the walk rather than as each image is met. The Java contract behind an engine
+(`TextRecognizer`) is experimental, with batching meant as an opt-in addition
an engine that
+takes one image at a time never sees; see
+xref:developers/custom-ocr-engine.adoc[Wiring in Your Own OCR Engine].
+
*Choose the engine.* Configure it once under `engines` and name it here; every
default parser
stays loaded, and `image-parser` and `pdf-parser` call it:
diff --git a/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
b/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
index 777326a4a1..0d3296ddfc 100644
--- a/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference-recipes.adoc
@@ -23,6 +23,11 @@ pixels), xref:configuration/inference.adoc[`engines` and
`inference`] (what is c
and `pdf-parser.inference.input` (what a PDF releases). Every default parser
stays loaded in
all of them.
+NOTE: `engines` and `text-recognizers` are stable; `inference` (and the PDF's
+`inference.input`) is experimental in 4.1.0 and may change in a minor release
without a
+deprecation cycle as 4.2 adds batched recognition, media inputs and
document-level tasks. The
+recipes are correct for the release they ship with; check the release notes
before moving.
+
== Scanned pages only
OCR a PDF page only when its extracted text is poor, and every embedded image.
Tesseract is
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc
b/docs/modules/ROOT/pages/configuration/inference.adoc
index 4eb115027d..a868057578 100644
--- a/docs/modules/ROOT/pages/configuration/inference.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -23,6 +23,14 @@ embeddings, captions, tags, classifications. (Engines that
produce text, OCR and
are xref:configuration/index.adoc#text-recognizers[text recognizers].) Three
sections
configure it, each answering one question.
+NOTE: Inference is experimental in 4.1.0. The `inference` section, the keys of
an embedding
+or VLM engine's entry, the `tika-inference` and `tika-vlm` classes, and the
chunk and vector
+output (`tk:chunks`, `tk:inference-released`) may change in a minor release
without a
+deprecation cycle, and no compatibility shims are kept for earlier shapes; 4.2
adds batched
+recognition, media inputs and document-level tasks. The `engines` map itself,
and the
+`text-recognizers` list, are stable. Pin the Tika version you validated
against and read the
+release notes before moving.
+
== `engines`: what can be called
A map of names you choose to one engine each: an endpoint or a local binary
with its settings.
@@ -74,7 +82,9 @@ A list of bindings. Each names an engine, says what it is
fed, and what to ask:
document is not one. `PAGES` is one image per rendered page of a document
whose parser
renders pages for inference: the PDF parser, when its `inference.input` says
so (see
<<pages>>). `TEXT` is the extracted text of every document in the tree (see
<<text>>).
- (`MEDIA` follows.)
+ `MEDIA` (audio and video) is accepted by a binding but no task takes it in
4.1: 4.2 adds
+ media chunkers (time windows for transcription, sampled frames or clips for
an image or
+ video model) that run at the flush, and a transcribing task.
`tasks`:: One call per task on the same engine and input. `embed` writes one
vector chunk per
unit into `tk:chunks`, with the page locator when the unit has a page.
`maxChunks`:: How many units one document tree may send; for `PAGES`, how many
pages of one
@@ -99,15 +109,22 @@ dispatcher keeps the units of the whole document tree
until the top-level parse
sends each binding's units to its engine in `maxBatchSize` requests: a docx
with twelve
pictures is one embeddings request, not twelve; if one image in a batch is
rejected, the rest
are retried one by one. Results land on the document a unit belongs to, as it
appears in the
-output: a picture in the body of a docx or an email puts its vector on the
docx or the email,
-with an `embedded` locator naming the picture, whether the docx is the file
itself or inside
-a zip; an attachment keeps its own. A failed request marks
-the top-level document with a warning and never fails the parse.
-
-The units are held as files the dispatcher owns, bounded by each binding's
`maxChunks` and
-`maxBytes`, and deleted when the flush ends. Vectors are visible in the
recursive outputs
-(`/rmeta`, `-J`, pipes); a streaming `/tika` parse keeps only what lands on
the top-level
-document.
+output. In the recursive outputs (`/rmeta`, `-J`, pipes `RMETA`), where every
document has
+its own metadata object, a picture in the body of a docx or an email puts its
vector on the
+docx or the email, with an `embedded` locator naming the picture, whether the
docx is the
+file itself or inside a zip; an attachment keeps its own. In the single-object
outputs
+(`/tika` as JSON, `-j`, pipes `CONCATENATE`) there is one object, so
everything lands on it:
+an attachment's pages and pictures put their vectors on the top-level document
too, each
+with an `embedded` locator whose id path and name say which part they came
from. The chunks
+are the same either way; only the object they sit on differs. A failed request
marks the
+top-level document with a warning and never fails the parse.
+
+The flush is the end of the top-level walk, still inside `parse()`: every byte
has been read
+and the last document closed, the engines are called under the parse's
timeout, and the
+units, held as files the dispatcher owns and bounded by each binding's
`maxChunks` and
+`maxBytes`, are deleted when it ends. The `TEXT` stage alone runs after
`parse()` returns,
+over the finished metadata list (see <<text>>). A chunker runs where its input
is: a media
+chunker at the flush, on the file; the text chunker in the `TEXT` stage, on
the text.
[#pages]
== Pages
@@ -190,10 +207,11 @@ narrow by document type. Use a `MARKDOWN` content handler
for the chunker to see
A document that released other inputs to inference is not embedded twice: a
PDF parsed with
`inference.input: ["PAGES"]` records `tk:inference-released: PAGES` and the
TEXT stage skips
-it; list `["TEXT", "PAGES"]` to get both. Because this stage runs over the
metadata list, it
-covers `-J`, `/rmeta` and pipes; the streaming `/tika` endpoint has no list
and no text
-vectors. It is not a metadata filter: a per-request `metadata-filters` list
does not touch it,
-and the per-request `inference` block below is the way to switch it off.
+it; list `["TEXT", "PAGES"]` to get both. This stage runs over the metadata
list, so it
+covers `-J`, `/rmeta` and pipes, and `/tika` as JSON, whose list is the one
object with the
+concatenated text; the plain-text `/tika` output carries no metadata and so no
vectors. It is
+not a metadata filter: a per-request `metadata-filters` list does not touch
it, and the
+per-request `inference` block below is the way to switch it off.
The 4.0 `openai-embedding-filter` and `jina-embedding-filter` keep working and
embed one
document per request; the binding batches the tree and shares the engine with
the other
diff --git a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
index b42dd60ad5..83f8e2ec59 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
@@ -152,6 +152,9 @@ The old boolean `extractMarkedContent` still loads: `true`
is `TAGS`, `false` is
== Pages for inference (`inference`)
+NOTE: Experimental in 4.1.0, with the
xref:configuration/inference.adoc[inference bindings]
+it feeds: may change in a minor release without a deprecation cycle. `text`
above is stable.
+
`input`::
What the parser releases to the
xref:configuration/inference.adoc#pages[inference bindings]:
`["TEXT"]` by default. Add `PAGES` to render every page for a `PAGES` binding;
the render is
diff --git a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
index 401966e6dc..99c3731bf1 100644
--- a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
+++ b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
@@ -26,6 +26,13 @@ Before writing code, check whether you need to. An engine
reachable through an
OpenAI-compatible chat completions endpoint is already covered by
`openai-vlm-parser`; see
xref:configuration/parsers/vlm-parsers.adoc[VLM Parsers].
+NOTE: The Java contract on this page is experimental in 4.1.0. Today an engine
is called once
+per image and writes into the caller's body as it runs; 4.2 batches
recognition per document
+and places the text after the walk. The intent is additive: an engine that
handles one image
+at a time keeps working unchanged, called once per image into a buffer, and an
engine whose
+service takes several images opts in by declaring a batch size and
implementing the list call
+that 4.2 adds. The JSON that names your engine will not change.
+
== The class
[source,java]
diff --git a/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
b/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
index c4e7698c19..1f0a15966e 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
@@ -145,9 +145,12 @@ The rules:
locator it already had.
* `"liftToParent": false` on the embedder keeps every picture's vector on the
picture, for an
index where each picture is a hit of its own.
-* The parent is known only in the recursive (`/rmeta`, `-J`, pipes) parse,
where the
- recursive wrapper exposes it to each child's parse as `ParentMetadata`; a
component that
- writes chunks for a child resolves the target through `ChunkTarget`
(`tika-inference`).
+* In the recursive (`/rmeta`, `-J`, pipes `RMETA`) parse the wrapper keeps
every document's
+ metadata, so a chunk lands on its parent's. In the single-object parse
(`/tika` as JSON,
+ `-j`, pipes `CONCATENATE`) only the top-level document's metadata is handed
back, so every
+ chunk lands there, with the same `embedded` locator naming the part it came
from. A task
+ writes where the dispatcher aims it (`InferenceUnit.getDestination()`),
through
+ `ChunkTarget` (`tika-inference`).
PDF page renders follow the same rule by a different route: the PDF parser
enriches its own
renders and their vectors land on the PDF directly, one per page with a
`paginated` locator,
diff --git
a/tika-core/src/main/java/org/apache/tika/parser/enricher/package-info.java
b/tika-core/src/main/java/org/apache/tika/parser/enricher/package-info.java
new file mode 100644
index 0000000000..d95d40213b
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/enricher/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+/**
+ * Engines a container parser invokes on bytes it has already parsed: text
recognizers (OCR,
+ * transcription) and annotators. Experimental in 4.1: the contract is one
image per call,
+ * written into the caller's body as it runs; 4.2 batches recognition per
document. The intent
+ * is additive: an engine that takes one image at a time keeps working
unchanged, and one whose
+ * service batches opts in by declaring a batch size and implementing the list
call 4.2 adds.
+ * Until then these interfaces may change in a minor release without a
deprecation cycle. The
+ * {@code "engines"} and {@code "text-recognizers"} configuration that names
an engine is stable.
+ */
+package org.apache.tika.parser.enricher;
diff --git
a/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java
b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java
index 24b1d928c5..b54bc80502 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHooks.java
@@ -46,13 +46,25 @@ public final class ParseHooks implements
TransientParseState {
/** Marks a top-level parse in progress; tracks the documents being
parsed, outermost last. */
public static final class Run implements TransientParseState {
- private final Deque<Metadata> open = new ArrayDeque<>();
+ private record Frame(Metadata metadata, String idPath) {
+ }
+
+ private final Deque<Frame> open = new ArrayDeque<>();
+ private int embedded;
- /** Enters a document; returns the one whose parser embedded it, null
at the top. */
+ /**
+ * Enters a document; returns the one whose parser embedded it, null
at the top. A
+ * document the recursive wrapper did not number gets an id path in
the wrapper's
+ * scheme, so a result written outside the wrapper still names where
it came from.
+ */
public Metadata enter(Metadata metadata) {
- Metadata parent = open.peek();
- open.push(metadata);
- return parent;
+ Frame parent = open.peek();
+ String idPath = metadata.get(TikaCoreProperties.EMBEDDED_ID_PATH);
+ if (idPath == null && parent != null) {
+ idPath = (parent.idPath == null ? "" : parent.idPath) + "/" +
++embedded;
+ }
+ open.push(new Frame(metadata, idPath));
+ return parent == null ? null : parent.metadata;
}
public void exit() {
@@ -61,17 +73,28 @@ public final class ParseHooks implements
TransientParseState {
/** The document whose parser is running; null between documents. */
public Metadata current() {
- return open.peek();
+ Frame top = open.peek();
+ return top == null ? null : top.metadata;
}
/** The document the current one is embedded in; null at the top. */
public Metadata parent() {
- Iterator<Metadata> it = open.iterator();
+ Iterator<Frame> it = open.iterator();
if (!it.hasNext()) {
return null;
}
it.next();
- return it.hasNext() ? it.next() : null;
+ return it.hasNext() ? it.next().metadata : null;
+ }
+
+ /** The id path of an open document; null for the top-level one or a
closed one. */
+ public String idPath(Metadata metadata) {
+ for (Frame frame : open) {
+ if (frame.metadata == metadata) {
+ return frame.idPath;
+ }
+ }
+ return null;
}
}
diff --git
a/tika-core/src/main/java/org/apache/tika/parser/hook/package-info.java
b/tika-core/src/main/java/org/apache/tika/parser/hook/package-info.java
new file mode 100644
index 0000000000..eb76795b20
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/hook/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+/**
+ * The seam that sees every document a parse touches, after its own parser has
run.
+ * Experimental in 4.1: 4.2 routes batched recognition through the same seam
and may change
+ * these types in a minor release without a deprecation cycle.
+ */
+package org.apache.tika.parser.hook;
diff --git
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
index 6db6bd0d26..5b566de340 100644
---
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
+++
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
@@ -43,6 +43,7 @@ import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.hook.ParseHook;
+import org.apache.tika.parser.hook.ParseHooks;
/**
* Matches offered units against the inference bindings and runs the tasks.
Built once at
@@ -50,11 +51,13 @@ import org.apache.tika.parser.hook.ParseHook;
* offered once by the auto-detect parser. Units are buffered per binding for
the whole top-level parse and flushed at its end, so a
* document is one request per binding, not one per unit; the buffer is
bounded by each
* binding's {@code maxChunks} and {@code maxBytes}, and its bytes live in
files the
- * dispatcher owns until the flush. At the flush every unit is re-aimed at the
metadata the
- * recursive wrapper kept for its document, since the parser's own copy is no
longer read. A
- * request narrows the bindings through {@link InferenceSelection}. Failures
mark the document
- * and never fail the parse. TEXT is not offered during the parse: whoever
holds the finished
- * metadata list runs {@link #text} over it.
+ * dispatcher owns until the flush. At the flush every unit is aimed at the
metadata that is
+ * still read: under the recursive wrapper, the copy it kept for the unit's
document (an
+ * inline part or a page render lands on its parent); outside it, where the
parse hands back
+ * one metadata object and the embedded documents' own are discarded, the
top-level document.
+ * A request narrows the bindings through {@link InferenceSelection}. Failures
mark the
+ * document and never fail the parse. TEXT is not offered during the parse:
whoever holds the
+ * finished metadata list runs {@link #text} over it.
*
* @since Apache Tika 4.1
*/
@@ -177,7 +180,9 @@ public final class InferenceDispatcher implements
ParseHook, TransientParseState
if (unit == null) {
Path copy = state.tmp.createTempFile();
Files.copy(source, copy, StandardCopyOption.REPLACE_EXISTING);
- unit = new InferenceUnit(kind, type, target, parent, copy,
page);
+ ParseHooks.Run run = context.get(ParseHooks.Run.class);
+ unit = new InferenceUnit(kind, type, target, parent,
idPath(target, run),
+ parent == null ? null : idPath(parent, run), copy,
page);
}
units.add(unit);
}
@@ -197,7 +202,7 @@ public final class InferenceDispatcher implements
ParseHook, TransientParseState
if (units == null || units.isEmpty()) {
continue;
}
- List<InferenceUnit> kept = retarget(units, lookup);
+ List<InferenceUnit> kept = aim(units, root, lookup);
for (InferenceTask task : b.tasks()) {
try {
task.run(b.binding(), kept, b.engine(), context);
@@ -235,20 +240,33 @@ public final class InferenceDispatcher implements
ParseHook, TransientParseState
}
}
- /** Re-aims units at the metadata the wrapper kept; the live object where
nothing is kept. */
- private static List<InferenceUnit> retarget(List<InferenceUnit> units,
- EmbeddedMetadataLookup lookup)
{
- if (lookup == null) {
- return units;
- }
- List<InferenceUnit> kept = new ArrayList<>(units.size());
+ /** The wrapper's id path, or the run's for a document it did not number;
null at the top. */
+ private static String idPath(Metadata metadata, ParseHooks.Run run) {
+ String idPath = metadata.get(TikaCoreProperties.EMBEDDED_ID_PATH);
+ return idPath != null || run == null ? idPath : run.idPath(metadata);
+ }
+
+ /**
+ * Aims units at the metadata still read. Under the wrapper that is the
copy it kept for
+ * the unit's document and parent, the live object where nothing is kept;
without one the
+ * parse hands back {@code root} alone, so everything lands there.
+ */
+ private static List<InferenceUnit> aim(List<InferenceUnit> units, Metadata
root,
+ EmbeddedMetadataLookup lookup) {
+ List<InferenceUnit> aimed = new ArrayList<>(units.size());
for (InferenceUnit unit : units) {
+ if (lookup == null) {
+ aimed.add(unit.aimed(unit.getTarget(), unit.getParent(),
root));
+ continue;
+ }
Metadata target = lookup.kept(unit.getTargetIdPath());
Metadata parent = lookup.kept(unit.getParentIdPath());
- kept.add(unit.retargeted(target != null ? target :
unit.getTarget(),
- parent != null ? parent : unit.getParent()));
+ target = target != null ? target : unit.getTarget();
+ parent = parent != null ? parent : unit.getParent();
+ aimed.add(unit.aimed(target, parent,
+ InferenceUnit.placed(target, parent,
unit.getTargetIdPath())));
}
- return kept;
+ return aimed;
}
/**
diff --git
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
index 7feb80c60a..95494c993d 100644
---
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
+++
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
@@ -26,7 +26,9 @@ import org.apache.tika.parser.ParseContext;
/**
* What a binding asks of its engine, named in the binding's {@code "tasks"}:
one call per
* unit for {@code embed}, all units in one call for a document-level task.
The task owns
- * its output shape and writes it onto each unit's target (or parent).
+ * its output shape and writes it on each unit's {@link
InferenceUnit#getDestination()
+ * destination}, never on the target directly: outside the recursive wrapper
the target's
+ * metadata is discarded with the embedded document.
*/
public interface InferenceTask {
diff --git
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
index 4724bbc1a1..cbd7b0bb34 100644
---
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
+++
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
@@ -20,6 +20,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Set;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
@@ -30,14 +31,22 @@ import org.apache.tika.mime.MediaType;
* with the document they came from and, when known, its parent. Captured at
the offer so
* a task can run after the document has closed; the bytes live in a file the
dispatcher
* owns until the flush, and the id paths let the flush find the metadata the
recursive
- * wrapper kept once the parser's own objects are no longer read.
+ * wrapper kept once the parser's own objects are no longer read. A task
writes its result
+ * on the {@link #getDestination() destination}, which the dispatcher decides:
the parent
+ * for an inline part or a page render, the document itself otherwise, and the
top-level
+ * document for everything when the parse produces one metadata object.
*/
public final class InferenceUnit {
+ private static final Set<String> LIFTED = Set.of(
+ TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
+ TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
+
private final InputKind kind;
private final MediaType type;
private final Metadata target;
private final Metadata parent;
+ private final Metadata destination;
private final String targetIdPath;
private final String parentIdPath;
private final Path path;
@@ -55,24 +64,33 @@ public final class InferenceUnit {
Path path, int page) throws IOException {
this(kind, type, target, parent,
target.get(TikaCoreProperties.EMBEDDED_ID_PATH),
parent == null ? null :
parent.get(TikaCoreProperties.EMBEDDED_ID_PATH), path,
- null, Files.size(path), page);
+ page);
+ }
+
+ /** As above, with id paths the dispatcher resolved for documents the
wrapper did not number. */
+ InferenceUnit(InputKind kind, MediaType type, Metadata target, Metadata
parent,
+ String targetIdPath, String parentIdPath, Path path, int
page)
+ throws IOException {
+ this(kind, type, target, parent, placed(target, parent, targetIdPath),
targetIdPath,
+ parentIdPath, path, null, Files.size(path), page);
}
/** A {@link InputKind#TEXT} unit: the target's extracted text, held in
memory. */
public InferenceUnit(MediaType type, Metadata target, Metadata parent,
String text) {
- this(InputKind.TEXT, type, target, parent,
+ this(InputKind.TEXT, type, target, parent, target,
target.get(TikaCoreProperties.EMBEDDED_ID_PATH),
parent == null ? null :
parent.get(TikaCoreProperties.EMBEDDED_ID_PATH), null,
text, text.getBytes(StandardCharsets.UTF_8).length, -1);
}
private InferenceUnit(InputKind kind, MediaType type, Metadata target,
Metadata parent,
- String targetIdPath, String parentIdPath, Path path,
String text,
- long size, int page) {
+ Metadata destination, String targetIdPath, String
parentIdPath,
+ Path path, String text, long size, int page) {
this.kind = kind;
this.type = type;
this.target = target;
this.parent = parent;
+ this.destination = destination;
this.targetIdPath = targetIdPath;
this.parentIdPath = parentIdPath;
this.path = path;
@@ -81,10 +99,31 @@ public final class InferenceUnit {
this.page = page;
}
- /** The same unit aimed at the metadata objects that are still read. */
- InferenceUnit retargeted(Metadata target, Metadata parent) {
- return new InferenceUnit(kind, type, target, parent, targetIdPath,
parentIdPath, path,
- text, size, page);
+ /** The same unit aimed at the metadata still read, writing on {@code
destination}. */
+ InferenceUnit aimed(Metadata target, Metadata parent, Metadata
destination) {
+ return new InferenceUnit(kind, type, target, parent, destination,
targetIdPath,
+ parentIdPath, path, text, size, page);
+ }
+
+ /** Whether results for this document belong on the one it is part of, not
on it. */
+ public static boolean lifts(Metadata target) {
+ String type = target.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE);
+ return type != null && LIFTED.contains(type);
+ }
+
+ /** The parent for an inline part or a page render that has one, the
target otherwise. */
+ static Metadata placed(Metadata target, Metadata parent, String
targetIdPath) {
+ return parent != null && targetIdPath != null && lifts(target) ?
parent : target;
+ }
+
+ /** Where a task writes this unit's results. */
+ public Metadata getDestination() {
+ return destination;
+ }
+
+ /** Whether the results go on another document than the target, which a
locator then names. */
+ public boolean isLifted() {
+ return destination != target;
}
/** The text of a {@link InputKind#TEXT} unit; null for the rest. */
diff --git
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
b/tika-core/src/main/java/org/apache/tika/parser/inference/package-info.java
similarity index 50%
copy from
tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
copy to
tika-core/src/main/java/org/apache/tika/parser/inference/package-info.java
index 7feb80c60a..8ea4ba997b 100644
---
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceTask.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/package-info.java
@@ -14,26 +14,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.tika.parser.inference;
-
-import java.io.IOException;
-import java.util.List;
-
-import org.apache.tika.exception.TikaConfigException;
-import org.apache.tika.exception.TikaException;
-import org.apache.tika.parser.ParseContext;
-
/**
- * What a binding asks of its engine, named in the binding's {@code "tasks"}:
one call per
- * unit for {@code embed}, all units in one call for a document-level task.
The task owns
- * its output shape and writes it onto each unit's target (or parent).
+ * Inference bindings: what is offered to which engine, buffered per document
tree and run at
+ * its end. Experimental in 4.1: the {@code "inference"} configuration, the
unit and task
+ * contracts, and the metadata they write ({@code tk:chunks}, {@code
tk:inference-released})
+ * may change in a minor release without a deprecation cycle as 4.2 adds
batched recognition,
+ * media inputs, and document-level tasks. The {@code "engines"} map is stable.
*/
-public interface InferenceTask {
-
- /** Rejects an engine this task cannot use; called once at config load. */
- default void validate(InferenceBinding binding, Engine engine) throws
TikaConfigException {
- }
-
- void run(InferenceBinding binding, List<InferenceUnit> units, Engine
engine,
- ParseContext context) throws IOException, TikaException;
-}
+package org.apache.tika.parser.inference;
diff --git
a/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
index 179ccb452f..c59c79ae16 100644
---
a/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
+++
b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceDispatcherTest.java
@@ -30,7 +30,9 @@ import java.nio.file.Files;
import java.nio.file.Path;
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.junit.jupiter.api.Test;
@@ -62,6 +64,7 @@ public class InferenceDispatcherTest {
private static final MediaType PNG = MediaType.image("png");
private static final MediaType CONTAINER =
MediaType.application("x-test-container");
+ private static final MediaType INNER = MediaType.image("x-test-inner");
static final class RecordingEngine implements Engine {
}
@@ -105,11 +108,25 @@ public class InferenceDispatcherTest {
}
}
- /** A container whose one child is a PNG, parsed through the embedded
extractor. */
+ /**
+ * A container parsed through the embedded extractor: "CONTAINER" holds
one inline PNG;
+ * "NESTED" holds a "CONTAINER" of the {@link #INNER} type as an
attachment, like a zip
+ * holding a docx with a picture. INNER is an image type so the attachment
is offered too.
+ */
static final class ContainerParser implements Parser {
+ private final MediaType type;
+
+ ContainerParser() {
+ this(CONTAINER);
+ }
+
+ ContainerParser(MediaType type) {
+ this.type = type;
+ }
+
@Override
public Set<MediaType> getSupportedTypes(ParseContext context) {
- return Collections.singleton(CONTAINER);
+ return Collections.singleton(type);
}
@Override
@@ -117,11 +134,13 @@ public class InferenceDispatcherTest {
ParseContext context) throws IOException,
SAXException, TikaException {
XHTMLContentHandler xhtml = new XHTMLContentHandler(handler,
metadata, context);
xhtml.startDocument();
+ boolean nested = "NESTED".equals(new String(tis.readAllBytes(),
UTF_8));
Metadata child = new Metadata();
- child.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
- child.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "INLINE");
- child.set(TikaCoreProperties.RESOURCE_NAME_KEY, "image1.png");
- try (TikaInputStream childStream =
TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
+ child.set(HttpHeaders.CONTENT_TYPE, nested ? INNER.toString() :
PNG.toString());
+ child.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, nested ?
"ATTACHMENT" : "INLINE");
+ child.set(TikaCoreProperties.RESOURCE_NAME_KEY, nested ?
"inner.docx" : "image1.png");
+ byte[] bytes = (nested ? "CONTAINER" :
"PNG-BYTES").getBytes(UTF_8);
+ try (TikaInputStream childStream = TikaInputStream.get(bytes)) {
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context)
.parseEmbedded(childStream, xhtml, child, context,
false);
}
@@ -257,6 +276,7 @@ public class InferenceDispatcherTest {
InferenceUnit unit = task.runs.get(0).get(0);
assertSame(handler.kept, unit.getParent(), "the parent the wrapper
kept, not the live one");
assertSame(liveChild, unit.getTarget(), "nothing kept for the child:
the live object");
+ assertSame(liveChild, unit.getDestination(), "untyped: not lifted");
assertEquals("/1/2", unit.getTargetIdPath());
}
@@ -304,11 +324,69 @@ public class InferenceDispatcherTest {
parser.parse(tis, new DefaultHandler(), root, context);
}
assertEquals(1, task.runs.size());
- assertSame(root, task.runs.get(0).get(0).getParent(),
+ InferenceUnit unit = task.runs.get(0).get(0);
+ assertSame(root, unit.getParent(),
"the parent is named in every mode, not only under the
wrapper");
+ assertSame(root, unit.getDestination(), "one output object: results
land on it");
+ assertEquals("/1", unit.getTargetIdPath(), "numbered as the wrapper
would have");
+ assertNull(unit.getParentIdPath());
assertNull(context.get(ParseHooks.class), "seeding is undone at the
top level");
}
+ /** Outside the wrapper the embedded documents' metadata is discarded:
every unit lands on the root. */
+ @Test
+ public void testDeepChildOutsideTheWrapperLandsOnTheRoot() throws
Exception {
+ RecordingTask task = new RecordingTask();
+ AutoDetectParser parser = hooked(pngDispatcher(task), new
ContainerParser(),
+ new ContainerParser(INNER), new TypedParser(PNG));
+ Metadata root = new Metadata();
+ root.set(HttpHeaders.CONTENT_TYPE, CONTAINER.toString());
+ try (TikaInputStream tis =
TikaInputStream.get("NESTED".getBytes(UTF_8))) {
+ parser.parse(tis, new DefaultHandler(), root, new ParseContext());
+ }
+ assertEquals(2, task.runs.get(0).size(), "the picture and the
attachment");
+ InferenceUnit unit = task.runs.get(0).get(0);
+ assertEquals("image1.png",
unit.getTarget().get(TikaCoreProperties.RESOURCE_NAME_KEY));
+ assertEquals("inner.docx",
unit.getParent().get(TikaCoreProperties.RESOURCE_NAME_KEY));
+ assertSame(root, unit.getDestination());
+ assertTrue(unit.isLifted());
+ assertEquals("/1/2", unit.getTargetIdPath());
+ assertEquals("/1", unit.getParentIdPath());
+ InferenceUnit attachment = task.runs.get(0).get(1);
+ assertSame(root, attachment.getDestination(), "an attachment too:
there is nowhere else");
+ assertTrue(attachment.isLifted());
+ assertEquals("/1", attachment.getTargetIdPath());
+ }
+
+ /** Under the wrapper an attachment keeps its own results, on the copy the
wrapper kept. */
+ @Test
+ public void testAttachmentUnderTheWrapperKeepsItsOwn() throws Exception {
+ RecordingTask task = new RecordingTask();
+ AutoDetectParser parser = hooked(pngDispatcher(task), new
ContainerParser(),
+ new ContainerParser(INNER), new TypedParser(PNG));
+ RecursiveParserWrapper wrapper = new RecursiveParserWrapper(parser);
+ Metadata root = new Metadata();
+ root.set(HttpHeaders.CONTENT_TYPE, CONTAINER.toString());
+ RecursiveParserWrapperHandler handler = new
RecursiveParserWrapperHandler(
+ new
BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1));
+ try (TikaInputStream tis =
TikaInputStream.get("NESTED".getBytes(UTF_8))) {
+ wrapper.parse(tis, handler, root, new ParseContext());
+ }
+ assertEquals(3, handler.getMetadataList().size());
+ Metadata keptDocx = handler.getEmbeddedMetadata("/1");
+ Map<String, InferenceUnit> byName = new HashMap<>();
+ for (InferenceUnit unit : task.runs.get(0)) {
+
byName.put(unit.getTarget().get(TikaCoreProperties.RESOURCE_NAME_KEY), unit);
+ }
+ InferenceUnit picture = byName.get("image1.png");
+ InferenceUnit attachment = byName.get("inner.docx");
+ assertSame(keptDocx, attachment.getDestination(), "the attachment's
kept copy");
+ assertFalse(attachment.isLifted());
+ assertSame(keptDocx, picture.getDestination(), "lifted onto the docx
it is part of");
+ assertTrue(picture.isLifted());
+ assertEquals("/1/2", picture.getTargetIdPath());
+ }
+
@Test
public void testOffersAndFlushesAtTopLevel() throws Exception {
RecordingTask task = new RecordingTask();
@@ -344,6 +422,7 @@ public class InferenceDispatcherTest {
assertArrayEquals("PNG-BYTES".getBytes(UTF_8),
task.bytes.get(0).get(0));
assertEquals("image1.png",
unit.getTarget().get(TikaCoreProperties.RESOURCE_NAME_KEY));
assertSame(root, unit.getParent());
+ assertSame(root, unit.getDestination(), "inline: lifted onto its
parent");
assertEquals(2, handler.getMetadataList().size());
}
diff --git a/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java
b/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java
index ea7266c946..b5d920faa7 100644
--- a/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java
+++ b/tika-core/src/test/java/org/apache/tika/parser/mock/MockTask.java
@@ -28,7 +28,7 @@ import org.apache.tika.parser.inference.InferenceUnit;
/**
* Fixture for {@code "inference"} tasks: nameable as {@code mock-task}, marks
each unit's
- * parent (or the unit itself) with the binding id and the number of units it
saw at once.
+ * destination with the binding id and the number of units it saw at once.
*/
@TikaComponent(name = "mock-task", spi = false)
public class MockTask implements InferenceTask {
@@ -40,7 +40,7 @@ public class MockTask implements InferenceTask {
public void run(InferenceBinding binding, List<InferenceUnit> units,
Engine engine,
ParseContext context) {
for (InferenceUnit unit : units) {
- Metadata onto = unit.getParent() != null ? unit.getParent() :
unit.getTarget();
+ Metadata onto = unit.getDestination();
onto.set(MARKER_KEY, binding.getId());
onto.set(UNITS_KEY, Integer.toString(units.size()));
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
index 53592873cc..a279552396 100644
---
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
@@ -18,26 +18,22 @@ package org.apache.tika.inference;
import java.io.IOException;
import java.util.List;
-import java.util.Set;
import org.apache.tika.extractor.ParentMetadata;
import org.apache.tika.inference.locator.EmbeddedLocator;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.inference.InferenceUnit;
/**
- * Where the chunks produced for a document land. A picture in the body of a
docx or an
- * email, or a rendering of a page, is part of its parent, so its chunks go
onto the parent
- * with an {@link EmbeddedLocator} naming the child. An attachment, a
top-level file, or a
- * child parsed outside the recursive wrapper keeps its own.
+ * Where the chunks produced for a unit land and how they name where they came
from. The
+ * dispatcher decides the destination ({@link
InferenceUnit#getDestination()}); when that is
+ * not the unit's own document, an {@link EmbeddedLocator} names the child the
chunks came
+ * from. The in-parse form is for a parser that writes during its own
document's parse.
*/
public final class ChunkTarget {
- private static final Set<String> LIFTED = Set.of(
- TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
- TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
-
private final Metadata metadata;
private final EmbeddedLocator locator;
@@ -46,19 +42,27 @@ public final class ChunkTarget {
this.locator = locator;
}
- public static ChunkTarget resolve(Metadata target, ParseContext context) {
- ParentMetadata parent = context.get(ParentMetadata.class);
- return resolve(target, parent == null ? null : parent.getMetadata());
+ /** The unit's destination, with a locator naming the unit's document when
lifted off it. */
+ public static ChunkTarget of(InferenceUnit unit) {
+ if (!unit.isLifted()) {
+ return new ChunkTarget(unit.getDestination(), null);
+ }
+ return new ChunkTarget(unit.getDestination(), new
EmbeddedLocator(unit.getTargetIdPath(),
+ unit.getTarget().get(TikaCoreProperties.RESOURCE_NAME_KEY)));
}
- /** As above, with the parent captured earlier (the dispatcher runs after
the walk). */
- public static ChunkTarget resolve(Metadata target, Metadata parent) {
+ /**
+ * During a parse: the parent for an inline part or a page render the
wrapper numbered,
+ * the document itself otherwise.
+ */
+ public static ChunkTarget resolve(Metadata target, ParseContext context) {
+ ParentMetadata parent = context.get(ParentMetadata.class);
String idPath = target.get(TikaCoreProperties.EMBEDDED_ID_PATH);
- String type = target.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE);
- if (parent == null || idPath == null || type == null ||
!LIFTED.contains(type)) {
+ if (parent == null || parent.getMetadata() == null || idPath == null
+ || !InferenceUnit.lifts(target)) {
return new ChunkTarget(target, null);
}
- return new ChunkTarget(parent,
+ return new ChunkTarget(parent.getMetadata(),
new EmbeddedLocator(idPath,
target.get(TikaCoreProperties.RESOURCE_NAME_KEY)));
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
index 58f96a5c86..63fbe001b6 100644
---
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
@@ -38,11 +38,11 @@ import org.apache.tika.parser.inference.InferenceUnit;
import org.apache.tika.parser.inference.InputKind;
/**
- * The {@code embed} task. Images and pages: one vector chunk per unit,
written where the
- * unit's chunks belong (its parent for an inline picture or a page render,
itself otherwise),
- * with the page locator when the unit is a page or has one. Text: the unit's
text is chunked
- * and every chunk gets a vector, on the unit's own document. Requests are
filled to the
- * engine's batch size; every chunk names the binding as its producer.
+ * The {@code embed} task. Images and pages: one vector chunk per unit,
written on the
+ * unit's destination with the page locator when the unit is a page or has
one. Text: the
+ * unit's text is chunked and every chunk gets a vector, on the unit's own
document.
+ * Requests are filled to the engine's batch size; every chunk names the
binding as its
+ * producer.
*/
@TikaComponent(name = "embed", spi = false)
public class EmbedTask implements InferenceTask {
@@ -114,7 +114,7 @@ public class EmbedTask implements InferenceTask {
for (int[] span : spans) {
Chunk chunk = new Chunk(text.substring(span[0], span[1]),
span[0], span[1]);
chunk.setProducer(binding.getId());
- owned.add(new Owned(chunk, unit.getTarget()));
+ owned.add(new Owned(chunk, unit.getDestination()));
}
}
if (binding.getMaxChunks() >= 0 && owned.size() >
binding.getMaxChunks()) {
@@ -206,8 +206,7 @@ public class EmbedTask implements InferenceTask {
Chunk chunk = new Chunk(null, locators);
chunk.setVector(vectors.get(i));
chunk.setProducer(binding.getId());
- ChunkTarget.resolve(unit.getTarget(), unit.getParent())
- .write(List.of(chunk),
TikaCoreProperties.TIKA_CHUNKS.getName());
+ ChunkTarget.of(unit).write(List.of(chunk),
TikaCoreProperties.TIKA_CHUNKS.getName());
}
}
}
diff --git
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
index fbd7df4ca0..5cebc511d3 100644
---
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
+++
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
@@ -20,18 +20,28 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.apache.tika.extractor.ParentMetadata;
import org.apache.tika.inference.locator.Locators;
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.inference.InferenceUnit;
+import org.apache.tika.parser.inference.InputKind;
public class ChunkTargetTest {
+ @TempDir
+ Path tmp;
+
private static Metadata child(String type, String idPath, String name) {
Metadata m = new Metadata();
if (type != null) {
@@ -50,8 +60,33 @@ public class ChunkTargetTest {
return context;
}
+ private InferenceUnit unit(Metadata target, Metadata parent) throws
IOException {
+ Path file = Files.createTempFile(tmp, "unit", ".png");
+ return new InferenceUnit(InputKind.IMAGES, MediaType.image("png"),
target, parent, file);
+ }
+
+ /** The unit's destination is the dispatcher's call; a lifted unit names
its document. */
+ @Test
+ public void testOfFollowsTheUnit() throws Exception {
+ Metadata parent = new Metadata();
+ for (String type : List.of("INLINE", "RENDERING")) {
+ ChunkTarget target = ChunkTarget.of(unit(child(type, "/1",
"image1.png"), parent));
+ assertSame(parent, target.getMetadata());
+ assertEquals("/1", target.getLocator().getIdPath());
+ assertEquals("image1.png", target.getLocator().getName());
+ }
+ Metadata attachment = child("ATTACHMENT", "/2", "report.pdf");
+ ChunkTarget own = ChunkTarget.of(unit(attachment, parent));
+ assertSame(attachment, own.getMetadata());
+ assertNull(own.getLocator());
+
+ Metadata topLevel = new Metadata();
+ assertSame(topLevel, ChunkTarget.of(unit(topLevel,
null)).getMetadata());
+ assertSame(topLevel, ChunkTarget.self(topLevel).getMetadata());
+ }
+
@Test
- public void testInlineAndRenderingLiftToParent() throws Exception {
+ public void testInParseInlineAndRenderingLiftToParent() {
Metadata parent = new Metadata();
for (String type : List.of("INLINE", "RENDERING")) {
Metadata child = child(type, "/1", "image1.png");
@@ -63,7 +98,7 @@ public class ChunkTargetTest {
}
@Test
- public void testAttachmentTopLevelAndNoParentKeepTheirOwn() {
+ public void testInParseAttachmentUnnumberedAndOrphanKeepTheirOwn() {
Metadata parent = new Metadata();
Metadata attachment = child("ATTACHMENT", "/2", "report.pdf");
assertSame(attachment, ChunkTarget.resolve(attachment,
withParent(parent)).getMetadata());
@@ -72,22 +107,21 @@ public class ChunkTargetTest {
Metadata untyped = child(null, "/3", "mystery.bin");
assertSame(untyped, ChunkTarget.resolve(untyped,
withParent(parent)).getMetadata());
+ Metadata unnumbered = child("INLINE", null, "image3.png");
+ assertSame(unnumbered, ChunkTarget.resolve(unnumbered,
withParent(parent)).getMetadata());
+
Metadata orphan = child("INLINE", "/4", "image4.png");
assertSame(orphan, ChunkTarget.resolve(orphan, new
ParseContext()).getMetadata());
-
- Metadata topLevel = new Metadata();
- assertSame(topLevel, ChunkTarget.resolve(topLevel,
withParent(parent)).getMetadata());
- assertSame(topLevel, ChunkTarget.self(topLevel).getMetadata());
+ assertSame(orphan, ChunkTarget.resolve(orphan,
withParent(null)).getMetadata());
}
@Test
public void testWriteTagsAndAppends() throws Exception {
Metadata parent = new Metadata();
- ParseContext context = withParent(parent);
for (int i = 1; i <= 2; i++) {
Chunk chunk = new Chunk(null, new Locators());
chunk.setVector(new float[]{i});
- ChunkTarget.resolve(child("INLINE", "/" + i, "image" + i +
".png"), context)
+ ChunkTarget.of(unit(child("INLINE", "/" + i, "image" + i +
".png"), parent))
.write(List.of(chunk),
TikaCoreProperties.TIKA_CHUNKS.getName());
}
List<Chunk> chunks =
ChunkSerializer.fromJson(parent.get(TikaCoreProperties.TIKA_CHUNKS));
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 575905e088..60924e58e5 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
@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
@@ -34,6 +35,8 @@ import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -127,6 +130,33 @@ public class PipesClientTest {
FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
metadata = pipesResult.emitData().getMetadataList().get(0);
assertEquals("MOCK-TEXT", metadata.get(MockTask.MARKER_KEY));
+
+ // CONCATENATE hands back one object: a result two levels down
lands on it
+ ByteArrayOutputStream inner = new ByteArrayOutputStream();
+ try (ZipOutputStream out = new ZipOutputStream(inner)) {
+ out.putNextEntry(new ZipEntry("test.png"));
+ Files.copy(inputDir.resolve("test.png"), out);
+ out.closeEntry();
+ }
+ Path zip = inputDir.resolve("pictures.zip");
+ try (ZipOutputStream out = new
ZipOutputStream(Files.newOutputStream(zip))) {
+ out.putNextEntry(new ZipEntry("inner.zip"));
+ out.write(inner.toByteArray());
+ out.closeEntry();
+ }
+ ParseContext concatenate = new ParseContext();
+ concatenate.set(ParseMode.class, ParseMode.CONCATENATE);
+ // the zip's entry names are text: keep the TEXT stage from
overwriting the marker
+ concatenate.setJsonConfig("inference", "{\"bindings\":
[\"mock-images\"]}");
+ pipesResult = pipesClient.process(
+ new FetchEmitTuple("pictures.zip", new
FetchKey(fetcherName, "pictures.zip"),
+ new EmitKey(), new Metadata(), concatenate,
+ FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+ assertEquals(1, pipesResult.emitData().getMetadataList().size());
+ metadata = pipesResult.emitData().getMetadataList().get(0);
+ assertEquals("application/zip",
metadata.get(HttpHeaders.CONTENT_TYPE));
+ assertEquals("mock-images", metadata.get(MockTask.MARKER_KEY));
+ assertEquals("1", metadata.get(MockTask.UNITS_KEY));
}
}
diff --git
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
index 9d1b83ae2a..5ee2346ebf 100644
---
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
+++
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
@@ -48,12 +48,16 @@ import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.Test;
+import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.mock.MockTask;
+import org.apache.tika.serialization.JsonMetadata;
import org.apache.tika.serialization.JsonMetadataList;
import org.apache.tika.server.core.CXFTestBase;
import org.apache.tika.server.core.resource.RecursiveMetadataResource;
+import org.apache.tika.server.core.resource.TikaResource;
+import org.apache.tika.server.core.writer.JSONMessageBodyWriter;
import org.apache.tika.server.core.writer.MetadataListMessageBodyWriter;
/** A config-named enricher reaches the forked worker and its output comes
back (TIKA-4872). */
@@ -63,15 +67,17 @@ public class InferenceBindingsWireTest extends CXFTestBase {
@Override
protected void setUpResources(JAXRSServerFactoryBean sf) {
- sf.setResourceClasses(RecursiveMetadataResource.class);
+ sf.setResourceClasses(RecursiveMetadataResource.class,
TikaResource.class);
sf.setResourceProvider(RecursiveMetadataResource.class,
new SingletonResourceProvider(new
RecursiveMetadataResource(tikaResource)));
+ sf.setResourceProvider(TikaResource.class, new
SingletonResourceProvider(tikaResource));
}
@Override
protected void setUpProviders(JAXRSServerFactoryBean sf) {
List<Object> providers = new ArrayList<>();
providers.add(new MetadataListMessageBodyWriter());
+ providers.add(new JSONMessageBodyWriter());
sf.setProviders(providers);
}
@@ -160,6 +166,47 @@ public class InferenceBindingsWireTest extends CXFTestBase
{
}
}
+ /** /tika as JSON is one object: a picture two containers down puts its
result on it. */
+ @Test
+ public void testSingleObjectOutputCarriesEmbeddedResults() throws
Exception {
+ ByteArrayOutputStream png = new ByteArrayOutputStream();
+ ImageIO.write(new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB),
"png", png);
+ ByteArrayOutputStream inner = new ByteArrayOutputStream();
+ try (ZipOutputStream out = new ZipOutputStream(inner)) {
+ out.putNextEntry(new ZipEntry("picture.png"));
+ out.write(png.toByteArray());
+ out.closeEntry();
+ }
+ ByteArrayOutputStream outer = new ByteArrayOutputStream();
+ try (ZipOutputStream out = new ZipOutputStream(outer)) {
+ out.putNextEntry(new ZipEntry("inner.zip"));
+ out.write(inner.toByteArray());
+ out.closeEntry();
+ }
+ // the entry names are text: select the images binding so TEXT does
not overwrite the marker
+ String config = """
+ { "parse-context": { "inference": { "bindings":
["mock-images"] } } }
+ """;
+ ContentDisposition fileCd = new ContentDisposition(
+ "form-data; name=\"file\"; filename=\"outer.zip\"");
+ Attachment fileAtt = new Attachment("file", new
ByteArrayInputStream(outer.toByteArray()), fileCd);
+ Attachment configAtt = new Attachment("config", "application/json",
+ new ByteArrayInputStream(config.getBytes(UTF_8)));
+
+ Response response = WebClient
+ .create(endPoint + "/tika/config/json")
+ .type("multipart/form-data")
+ .accept("application/json")
+ .post(new MultipartBody(Arrays.asList(fileAtt, configAtt)));
+
+ assertEquals(200, response.getStatus());
+ Metadata metadata = JsonMetadata.fromJson(
+ new InputStreamReader((InputStream) response.getEntity(),
UTF_8));
+ assertEquals("application/zip",
metadata.get(HttpHeaders.CONTENT_TYPE));
+ assertEquals("mock-images", metadata.get(MockTask.MARKER_KEY));
+ assertEquals("1", metadata.get(MockTask.UNITS_KEY));
+ }
+
private static byte[] twoPagePdf() throws IOException {
try (PDDocument document = new PDDocument()) {
for (int i = 1; i <= 2; i++) {