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 dccc702fca TIKA-4889: PAGES input for inference bindings (#3155)
dccc702fca is described below

commit dccc702fca054c47f43fa3c7ce73cc328ace1ebf
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 11 17:27:55 2026 -0400

    TIKA-4889: PAGES input for inference bindings (#3155)
---
 CHANGES.txt                                        |   8 ++
 .../ROOT/pages/configuration/inference.adoc        |  58 +++++++-
 .../pages/configuration/parsers/pdf-parser.adoc    |   9 ++
 .../apache/tika/cli/TikaCLIPageVectorsTest.java    | 147 +++++++++++++++++++++
 .../org/apache/tika/parser/AutoDetectParser.java   |   2 +-
 .../org/apache/tika/parser/hook/ParseHook.java     |  17 +++
 .../org/apache/tika/parser/hook/ParseHooks.java    |  65 ++++++++-
 .../tika/parser/inference/InferenceDispatcher.java |  36 ++++-
 .../tika/parser/inference/InferenceUnit.java       |  20 ++-
 .../apache/tika/parser/hook/ParseHooksTest.java    | 109 ++++++++++++++-
 .../parser/inference/InferenceDispatcherTest.java  |  45 +++++++
 .../java/org/apache/tika/inference/EmbedTask.java  |  10 +-
 .../org/apache/tika/inference/EmbedTaskTest.java   |  26 ++++
 .../resources/config-examples/pdf-parser-full.json |   3 +
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  | 116 ++++++++++++----
 .../apache/tika/parser/pdf/InferenceConfig.java    |  41 ++++++
 .../java/org/apache/tika/parser/pdf/OCR2XHTML.java |   5 +-
 .../apache/tika/parser/pdf/PDFParserConfig.java    |  11 ++
 .../org/apache/tika/parser/pdf/PDFParserTest.java  | 129 ++++++++++++++++++
 .../apache/tika/parser/pdf/tika-inline-config.json |   1 +
 .../tika/config/loader/InferenceLoaderTest.java    |  18 +++
 .../server/standard/InferenceBindingsWireTest.java |  66 +++++++++
 .../configs/tika-config-inference-bindings.json    |   5 +-
 23 files changed, 896 insertions(+), 51 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 617757c94a..7d9d547f0d 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
 Release 4.1.0 - unreleased
 
+   * PAGES input for inference bindings: pdf-parser.inference.input lists what
+     the PDF releases (TEXT by default; PAGES renders every page for a PAGES
+     binding). One render per page feeds OCR and the bindings, and each page's
+     vector lands on the PDF with a page locator. A page render emitted as an
+     embedded document is no longer an IMAGES unit. OCR_ONLY annotated an OCR'd
+     page twice when pages were rendered as embedded documents; once now
+     (TIKA-4889).
+
    * New "inference" config section: bind a named engine to an input (IMAGES)
      and tasks (embed), and every image document Tika parses is embedded in
      one request per document tree; vectors land on the document the image
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc 
b/docs/modules/ROOT/pages/configuration/inference.adoc
index 1757489bb0..5b242d20f8 100644
--- a/docs/modules/ROOT/pages/configuration/inference.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -59,10 +59,14 @@ A list of bindings. Each names an engine, says what it is 
fed, and what to ask:
 
 `id`:: Names the binding; defaults to `<engine>-<input>`. Unique within the 
list.
 `input`:: What the binding is fed. `IMAGES` is every image document Tika 
parses: an inline
-  picture, an image attachment, a top-level image file. (`PAGES`, `TEXT` and 
`MEDIA` follow.)
+  picture, an image attachment, a top-level image file; a page render emitted 
as an embedded
+  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` and `MEDIA` follow.)
 `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; -1 for no limit.
+`maxChunks`:: How many units one document tree may send; for `PAGES`, how many 
pages of one
+  document; -1 for no limit.
 `maxBytes`:: Largest unit the binding accepts (20 MB unless set; -1 for no 
limit); larger
   ones are skipped and counted.
 `enabled`:: `false` keeps the binding configured but idle until a request 
selects it.
@@ -91,6 +95,56 @@ The units are held as files the dispatcher owns, bounded by 
each binding's `maxC
 (`/rmeta`, `-J`, pipes); a streaming `/tika` parse keeps only what lands on 
the top-level
 document.
 
+[#pages]
+== Pages
+
+A `PAGES` binding is fed by the parsers that render pages. Today that is the 
PDF parser, and
+only when its own config releases pages:
+
+[source,json]
+----
+{
+  "engines": {
+    "clip": { "openai-embedding-engine": { "baseUrl": "http://clip:8000/v1";, 
"model": "clip" } }
+  },
+  "inference": [
+    { "id": "page-vectors", "engine": "clip", "input": "PAGES", "tasks": 
["embed"], "maxChunks": 50 }
+  ],
+  "parsers": [
+    { "default-parser": {} },
+    { "pdf-parser": { "inference": { "input": ["PAGES"] },
+                      "ocr": { "strategy": "NO_OCR", "dpi": 100, "imageType": 
"RGB" } } }
+  ]
+}
+----
+
+`pdf-parser.inference.input` lists what the PDF releases to inference: `TEXT`, 
the default
+(text bindings follow), and `PAGES`. With `PAGES`, and a `PAGES` binding that 
runs for the
+request, the PDF renders every page once and hands the same image to the OCR 
engine, when
+`ocr.strategy` asks for it, and to the bindings: a scanned page costs one 
render whether one
+consumer or two see it. Each page is one unit. Its vector lands on the PDF's 
own `tk:chunks`
+with a `paginated` locator naming the page, and `maxChunks` is the number of 
pages one document
+sends. Nothing is rendered for inference unless a `PAGES` binding runs, so the 
block can stay on
+a config whose bindings are on standby.
+
+The render is the OCR render: `ocr.dpi`, `imageType`, `imageFormat` and 
`imageQuality` shape
+it, and the defaults (300 dpi grayscale PNG) suit Tesseract, not an embedding 
endpoint. Set
+`dpi` and `imageType` to what the engine wants; when OCR and page vectors run 
together the one
+setting serves both. `renderingStrategy` and `maxImagePixels` apply as for 
OCR; a page skipped
+for its size is not offered.
+
+Page renders emitted as embedded documents (`imageStrategy` set to 
`RENDER_PAGES_AT_PAGE_END`
+or `RENDER_PAGES_BEFORE_PARSE`) are not images to an `IMAGES` binding: pages 
reach inference
+through `PAGES` only, so no page is embedded twice.
+
+Per request, like every PDF setting:
+
+[source,json]
+----
+{ "parse-context": { "pdf-parser": { "inference": { "input": ["PAGES"] } },
+                     "inference": { "bindings": ["page-vectors"] } } }
+----
+
 == Per request
 
 [source,json]
diff --git a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
index 16d386b406..1216768113 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
@@ -73,5 +73,14 @@ render is parsed for its metadata but not enriched again, so 
no page reaches the
 twice, and `NO_OCR` means no page OCR anywhere while the other enrichers still 
see every page.
 
 `pdf:ocr-page-count` records how many pages tripped the verdict, whether or 
not an engine ran.
+
+== Pages for inference (`inference`)
+
+`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
+the `ocr` render, made once per page for OCR and inference alike, and nothing 
is rendered
+unless a `PAGES` binding runs for the request. Per request under `pdf-parser` 
in
+`parse-context`, like the `ocr` block.
 Every `ocr` option can be overridden per request through `parse-context` under 
`pdf-parser`; the
 xref:using-tika/server/index.adoc[server documentation] shows the `NO_OCR` 
override as a preset.
diff --git 
a/tika-app/src/test/java/org/apache/tika/cli/TikaCLIPageVectorsTest.java 
b/tika-app/src/test/java/org/apache/tika/cli/TikaCLIPageVectorsTest.java
new file mode 100644
index 0000000000..89bcd2daac
--- /dev/null
+++ b/tika-app/src/test/java/org/apache/tika/cli/TikaCLIPageVectorsTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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.cli;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+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.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.http.TikaTestHttpServer;
+import org.apache.tika.inference.Chunk;
+import org.apache.tika.inference.ChunkSerializer;
+
+/**
+ * The consumer's path for pages: a PAGES binding and 
pdf-parser.inference.input, and -J shows
+ * one vector per page on the PDF itself, each naming its page, from one 
request.
+ */
+public class TikaCLIPageVectorsTest {
+
+    private static final String EMBEDDING_RESPONSE = "{\"data\":["
+            + "{\"index\":1,\"embedding\":[1.0,0.2,0.3]},"
+            + "{\"index\":0,\"embedding\":[0.0,0.2,0.3]}]}";
+
+    @TempDir
+    Path tmp;
+
+    @Test
+    public void testOneVectorPerPageOnThePdf() throws Exception {
+        Path pdf = tmp.resolve("two-pages.pdf");
+        writePdf(pdf, 2);
+        try (TikaTestHttpServer server = new TikaTestHttpServer()) {
+            server.enqueue(new TikaTestHttpServer.MockResponse(200, 
EMBEDDING_RESPONSE));
+            Path config = tmp.resolve("tika-config.json");
+            Files.writeString(config, """
+                    {
+                      "engines": {
+                        "clip": { "openai-embedding-engine": { "baseUrl": 
"BASE_URL", "model": "clip" } }
+                      },
+                      "inference": [
+                        { "id": "page-vectors", "engine": "clip", "input": 
"PAGES",
+                          "tasks": ["embed"], "maxChunks": 50 }
+                      ],
+                      "parsers": [
+                        { "default-parser": {} },
+                        { "pdf-parser": { "ocr": { "strategy": "NO_OCR", 
"dpi": 20 },
+                                          "inference": { "input": ["PAGES"] } 
} }
+                      ]
+                    }
+                    """.replace("BASE_URL", server.url()));
+
+            JsonNode output = new ObjectMapper().readTree(
+                    run("--config=" + config, "-J", pdf.toUri().toString()));
+
+            assertEquals(1, server.getRequestCount(), "both pages in one 
request");
+            JsonNode request = new 
ObjectMapper().readTree(server.takeRequest().body());
+            assertEquals(2, request.get("input").size());
+            assertTrue(request.get("input").get(0).get("image").asText()
+                    .startsWith("data:image/png;base64,"));
+
+            assertEquals(1, output.size(), "the pages are the PDF's: no 
embedded documents");
+            JsonNode root = output.get(0);
+            assertTrue(text(root, "tk:content").contains("This is page 2"),
+                    "text extraction is untouched");
+            List<Chunk> chunks = ChunkSerializer.fromJson(text(root, 
"tk:chunks"));
+            assertEquals(2, chunks.size());
+            Set<Integer> pages = new HashSet<>();
+            for (Chunk chunk : chunks) {
+                assertEquals(3, chunk.getVector().length);
+                pages.add(chunk.getLocators().getPaginated().get(0).getPage());
+                assertNull(chunk.getLocators().getEmbedded());
+            }
+            assertEquals(Set.of(1, 2), pages);
+        }
+    }
+
+    private static void writePdf(Path path, int pages) throws IOException {
+        try (PDDocument document = new PDDocument()) {
+            for (int i = 1; i <= pages; i++) {
+                PDPage page = new PDPage(PDRectangle.LETTER);
+                document.addPage(page);
+                try (PDPageContentStream content = new 
PDPageContentStream(document, page)) {
+                    content.beginText();
+                    content.setFont(new 
PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
+                    content.newLineAtOffset(72, 700);
+                    content.showText("This is page " + i + " of the test 
document.");
+                    content.endText();
+                }
+            }
+            document.save(path.toFile());
+        }
+    }
+
+    private static String text(JsonNode node, String field) {
+        JsonNode value = node.get(field);
+        assertNotNull(value, field);
+        return value.isArray() ? value.get(0).asText() : value.asText();
+    }
+
+    private static String run(String... args) throws Exception {
+        PrintStream stdout = System.out;
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try {
+            System.setOut(new PrintStream(out, true, UTF_8.name()));
+            TikaCLI.main(args);
+        } finally {
+            System.setOut(stdout);
+        }
+        String json = out.toString(UTF_8);
+        assertTrue(json.startsWith("["), json);
+        return json;
+    }
+}
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/AutoDetectParser.java 
b/tika-core/src/main/java/org/apache/tika/parser/AutoDetectParser.java
index 9fe2a5fcb1..fb49df0194 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/AutoDetectParser.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/AutoDetectParser.java
@@ -209,7 +209,7 @@ public class AutoDetectParser extends CompositeParser {
             }
         } finally {
             if (run != null) {
-                run.exit(parent);
+                run.exit();
             }
             if (topLevel) {
                 try {
diff --git a/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHook.java 
b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHook.java
index 2165044b19..c6b5180823 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHook.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/hook/ParseHook.java
@@ -54,6 +54,23 @@ public interface ParseHook {
                        ParseContext context) throws IOException, TikaException 
{
     }
 
+    /**
+     * Whether this hook wants the pages of the document being parsed rendered 
as images of
+     * this type. Its parser asks once, before rendering anything for the 
hooks.
+     */
+    default boolean wantsPages(MediaType renderType, Metadata document, 
ParseContext context)
+            throws TikaException {
+        return false;
+    }
+
+    /**
+     * A page of {@code document} rendered by its own parser; {@code page} is 
1-based,
+     * {@code bytes} and {@code parent} as in {@link #offer}.
+     */
+    default void offerPage(MediaType type, Metadata document, Metadata parent, 
int page,
+                           Path bytes, ParseContext context) throws 
IOException, TikaException {
+    }
+
     /** End of the top-level parse; {@code failed} when the parse threw. */
     default void end(Metadata root, boolean failed, ParseContext context) {
     }
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 53492addd2..24b1d928c5 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
@@ -18,6 +18,9 @@ package org.apache.tika.parser.hook;
 
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Iterator;
 import java.util.List;
 
 import org.slf4j.Logger;
@@ -41,19 +44,34 @@ public final class ParseHooks implements 
TransientParseState {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(ParseHooks.class);
 
-    /** Marks a top-level parse in progress; tracks the document being parsed. 
*/
+    /** Marks a top-level parse in progress; tracks the documents being 
parsed, outermost last. */
     public static final class Run implements TransientParseState {
-        private Metadata current;
+        private final Deque<Metadata> open = new ArrayDeque<>();
 
         /** Enters a document; returns the one whose parser embedded it, null 
at the top. */
         public Metadata enter(Metadata metadata) {
-            Metadata parent = current;
-            current = metadata;
+            Metadata parent = open.peek();
+            open.push(metadata);
             return parent;
         }
 
-        public void exit(Metadata parent) {
-            current = parent;
+        public void exit() {
+            open.pop();
+        }
+
+        /** The document whose parser is running; null between documents. */
+        public Metadata current() {
+            return open.peek();
+        }
+
+        /** The document the current one is embedded in; null at the top. */
+        public Metadata parent() {
+            Iterator<Metadata> it = open.iterator();
+            if (!it.hasNext()) {
+                return null;
+            }
+            it.next();
+            return it.hasNext() ? it.next() : null;
         }
     }
 
@@ -109,6 +127,41 @@ public final class ParseHooks implements 
TransientParseState {
         }
     }
 
+    /** Whether any hook wants the current document's pages rendered as images 
of this type. */
+    public boolean wantsPages(MediaType renderType, ParseContext context) 
throws TikaException {
+        Run run = context.get(Run.class);
+        Metadata document = run == null ? null : run.current();
+        if (document == null) {
+            return false;
+        }
+        for (ParseHook hook : hooks) {
+            if (hook.wantsPages(renderType, document, context)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** A rendered page of the current document, from its own parser; never 
fails it. */
+    public void offerPage(MediaType type, int page, Path bytes, ParseContext 
context) {
+        Run run = context.get(Run.class);
+        Metadata document = run == null ? null : run.current();
+        if (document == null) {
+            return;
+        }
+        Metadata parent = run.parent();
+        for (ParseHook hook : hooks) {
+            try {
+                hook.offerPage(type, document, parent, page, bytes, context);
+            } catch (IOException | TikaException | RuntimeException e) {
+                LOG.warn("parse hook {} failed on page {}", 
hook.getClass().getName(), page, e);
+                document.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                        "parse hook " + hook.getClass().getSimpleName() + " on 
page " + page
+                                + ": " + e.getMessage());
+            }
+        }
+    }
+
     public void end(Metadata root, boolean failed, ParseContext context) {
         for (ParseHook hook : hooks) {
             try {
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 a2d5147c49..c5a7e5c5a9 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
@@ -86,14 +86,38 @@ public final class InferenceDispatcher implements 
ParseHook, TransientParseState
     @Override
     public boolean wants(MediaType type, Metadata metadata, ParseContext 
context)
             throws TikaException {
-        InputKind kind = InputKind.of(type);
+        InputKind kind = kindOf(type, metadata);
         return kind != null && wants(kind, type, context);
     }
 
     @Override
     public void offer(MediaType type, Metadata metadata, Metadata parent, Path 
bytes,
                       ParseContext context) throws IOException, TikaException {
-        offer(InputKind.of(type), type, metadata, parent, bytes, context);
+        InputKind kind = kindOf(type, metadata);
+        if (kind != null) {
+            offer(kind, type, metadata, parent, bytes, context);
+        }
+    }
+
+    @Override
+    public boolean wantsPages(MediaType renderType, Metadata document, 
ParseContext context)
+            throws TikaException {
+        return wants(InputKind.PAGES, renderType, context);
+    }
+
+    @Override
+    public void offerPage(MediaType type, Metadata document, Metadata parent, 
int page,
+                          Path bytes, ParseContext context) throws 
IOException, TikaException {
+        offer(InputKind.PAGES, type, document, parent, bytes, page, context);
+    }
+
+    /** A page render emitted as an embedded document is a page, not an image: 
PAGES only. */
+    private static InputKind kindOf(MediaType type, Metadata metadata) {
+        if (TikaCoreProperties.EmbeddedResourceType.RENDERING.name()
+                
.equals(metadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE))) {
+            return null;
+        }
+        return InputKind.of(type);
     }
 
     /** Runs the buffered units, or drops them when the parse failed: no 
engine call for a document nobody gets. */
@@ -123,6 +147,12 @@ public final class InferenceDispatcher implements 
ParseHook, TransientParseState
      */
     public void offer(InputKind kind, MediaType type, Metadata target, 
Metadata parent,
                       Path source, ParseContext context) throws IOException, 
TikaException {
+        offer(kind, type, target, parent, source, -1, context);
+    }
+
+    private void offer(InputKind kind, MediaType type, Metadata target, 
Metadata parent,
+                       Path source, int page, ParseContext context)
+            throws IOException, TikaException {
         State state = state(context);
         long size = Files.size(source);
         InferenceUnit unit = null;
@@ -144,7 +174,7 @@ 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);
+                unit = new InferenceUnit(kind, type, target, parent, copy, 
page);
             }
             units.add(unit);
         }
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 96d4b085b5..0ab0a76934 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
@@ -41,16 +41,24 @@ public final class InferenceUnit {
     private final String parentIdPath;
     private final Path path;
     private final long size;
+    private final int page;
 
     public InferenceUnit(InputKind kind, MediaType type, Metadata target, 
Metadata parent,
                          Path path) throws IOException {
+        this(kind, type, target, parent, path, -1);
+    }
+
+    /** A unit that is one page of its target: {@code page} is 1-based. */
+    public InferenceUnit(InputKind kind, MediaType type, Metadata target, 
Metadata parent,
+                         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,
-                Files.size(path));
+                Files.size(path), page);
     }
 
     private InferenceUnit(InputKind kind, MediaType type, Metadata target, 
Metadata parent,
-                          String targetIdPath, String parentIdPath, Path path, 
long size) {
+                          String targetIdPath, String parentIdPath, Path path, 
long size,
+                          int page) {
         this.kind = kind;
         this.type = type;
         this.target = target;
@@ -59,12 +67,18 @@ public final class InferenceUnit {
         this.parentIdPath = parentIdPath;
         this.path = path;
         this.size = size;
+        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,
-                size);
+                size, page);
+    }
+
+    /** The 1-based page this unit renders, for {@link InputKind#PAGES}; -1 
otherwise. */
+    public int getPage() {
+        return page;
     }
 
     public InputKind getKind() {
diff --git 
a/tika-core/src/test/java/org/apache/tika/parser/hook/ParseHooksTest.java 
b/tika-core/src/test/java/org/apache/tika/parser/hook/ParseHooksTest.java
index 046e66fd17..52a0d6491d 100644
--- a/tika-core/src/test/java/org/apache/tika/parser/hook/ParseHooksTest.java
+++ b/tika-core/src/test/java/org/apache/tika/parser/hook/ParseHooksTest.java
@@ -58,6 +58,7 @@ public class ParseHooksTest {
 
     private static final MediaType PNG = MediaType.image("png");
     private static final MediaType CONTAINER = 
MediaType.application("x-test-container");
+    private static final MediaType PAGED = 
MediaType.application("x-test-paged");
 
     /** Detection by the declared type: these tests are about the seam, not 
the detector. */
     private static final Detector DECLARED = (tis, metadata, ctx) ->
@@ -67,7 +68,11 @@ public class ParseHooksTest {
         final List<String> events = new ArrayList<>();
         final List<Metadata> offeredParents = new ArrayList<>();
         final List<byte[]> offeredBytes = new ArrayList<>();
+        final List<Metadata> pageDocuments = new ArrayList<>();
+        final List<Metadata> pageParents = new ArrayList<>();
+        final List<String> pageBytes = new ArrayList<>();
         Set<MediaType> wanted = Set.of(PNG);
+        boolean wantsPages;
         boolean failOffer;
         boolean failStart;
 
@@ -95,12 +100,58 @@ public class ParseHooksTest {
             offeredBytes.add(Files.readAllBytes(bytes));
         }
 
+        @Override
+        public boolean wantsPages(MediaType renderType, Metadata document, 
ParseContext context) {
+            events.add("wantsPages " + renderType);
+            return wantsPages;
+        }
+
+        @Override
+        public void offerPage(MediaType type, Metadata document, Metadata 
parent, int page,
+                              Path bytes, ParseContext context) throws 
IOException {
+            if (failOffer) {
+                throw new IOException("no page");
+            }
+            events.add("page " + page);
+            pageDocuments.add(document);
+            pageParents.add(parent);
+            pageBytes.add(Files.readString(bytes));
+        }
+
         @Override
         public void end(Metadata root, boolean failed, ParseContext context) {
             events.add(failed ? "end failed" : "end");
         }
     }
 
+    /** A parser that renders two pages for the hooks, the way the PDF parser 
does. */
+    static final class PagedParser implements Parser {
+        @Override
+        public Set<MediaType> getSupportedTypes(ParseContext context) {
+            return Collections.singleton(PAGED);
+        }
+
+        @Override
+        public void parse(TikaInputStream tis, ContentHandler handler, 
Metadata metadata,
+                          ParseContext context) throws IOException, 
SAXException, TikaException {
+            XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata, context);
+            xhtml.startDocument();
+            ParseHooks hooks = context.get(ParseHooks.class);
+            if (hooks != null && hooks.wantsPages(PNG, context)) {
+                for (int page = 1; page <= 2; page++) {
+                    Path render = Files.createTempFile("page", ".png");
+                    try {
+                        Files.writeString(render, "PAGE-" + page);
+                        hooks.offerPage(PNG, page, render, context);
+                    } finally {
+                        Files.delete(render);
+                    }
+                }
+            }
+            xhtml.endDocument();
+        }
+    }
+
     static final class TypedParser implements Parser {
         private final MediaType type;
         private final boolean fail;
@@ -129,6 +180,16 @@ public class ParseHooksTest {
 
     /** A container whose one child is a PNG, parsed through the embedded 
extractor. */
     static final class ContainerParser implements Parser {
+        private final MediaType childType;
+
+        ContainerParser() {
+            this(PNG);
+        }
+
+        ContainerParser(MediaType childType) {
+            this.childType = childType;
+        }
+
         @Override
         public Set<MediaType> getSupportedTypes(ParseContext context) {
             return Collections.singleton(CONTAINER);
@@ -140,7 +201,7 @@ public class ParseHooksTest {
             XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata, context);
             xhtml.startDocument();
             Metadata child = new Metadata();
-            child.set(HttpHeaders.CONTENT_TYPE, PNG.toString());
+            child.set(HttpHeaders.CONTENT_TYPE, childType.toString());
             child.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "INLINE");
             try (TikaInputStream childStream = 
TikaInputStream.get("PNG-BYTES".getBytes(UTF_8))) {
                 EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context)
@@ -250,6 +311,52 @@ public class ParseHooksTest {
         assertNull(context.get(ParseHooks.Run.class));
     }
 
+    @Test
+    public void testPagesAreOfferedWithTheirDocumentAndItsParent() throws 
Exception {
+        RecordingHook hook = new RecordingHook();
+        hook.wantsPages = true;
+        hook.wanted = Set.of();
+        Metadata root = typed(CONTAINER);
+        ParseContext context = new ParseContext();
+        try (TikaInputStream tis = 
TikaInputStream.get("CONTAINER".getBytes(UTF_8))) {
+            parser(hook, new ContainerParser(PAGED), new PagedParser())
+                    .parse(tis, new DefaultHandler(), root, context);
+        }
+        assertEquals(List.of("start", "wantsPages image/png", "page 1", "page 
2", "end"),
+                hook.events);
+        assertEquals(List.of("PAGE-1", "PAGE-2"), hook.pageBytes);
+        assertEquals(PAGED.toString(), 
hook.pageDocuments.get(0).get(HttpHeaders.CONTENT_TYPE),
+                "the page belongs to the paged child");
+        assertSame(hook.pageDocuments.get(0), hook.pageDocuments.get(1));
+        assertSame(root, hook.pageParents.get(0), "and the child's parent 
rides along");
+    }
+
+    @Test
+    public void testNoPagesWhenNoHookWantsThem() throws Exception {
+        RecordingHook hook = new RecordingHook();
+        try (TikaInputStream tis = 
TikaInputStream.get("PAGED".getBytes(UTF_8))) {
+            parser(hook, new PagedParser()).parse(tis, new DefaultHandler(), 
typed(PAGED),
+                    new ParseContext());
+        }
+        assertEquals(List.of("start", "wantsPages image/png", "end"), 
hook.events);
+    }
+
+    @Test
+    public void testPageOfferFailureIsAWarningOnTheDocument() throws Exception 
{
+        RecordingHook hook = new RecordingHook();
+        hook.wantsPages = true;
+        hook.failOffer = true;
+        Metadata metadata = typed(PAGED);
+        try (TikaInputStream tis = 
TikaInputStream.get("PAGED".getBytes(UTF_8))) {
+            parser(hook, new PagedParser()).parse(tis, new DefaultHandler(), 
metadata,
+                    new ParseContext());
+        }
+        assertEquals(List.of("start", "wantsPages image/png", "end"), 
hook.events);
+        String[] warnings = 
metadata.getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING);
+        assertEquals(2, warnings.length);
+        assertTrue(warnings[0].contains("on page 1: no page"), warnings[0]);
+    }
+
     @Test
     public void testNoHooksNoTrace() throws Exception {
         AutoDetectParser adp = new AutoDetectParser(DECLARED, new 
TypedParser(PNG, false));
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 36e68b03b3..179ccb452f 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
@@ -347,6 +347,51 @@ public class InferenceDispatcherTest {
         assertEquals(2, handler.getMetadataList().size());
     }
 
+    @Test
+    public void testPagesComeFromTheRendererOnly() throws Exception {
+        RecordingTask pageTask = new RecordingTask();
+        RecordingTask imageTask = new RecordingTask();
+        RecordingEngine engine = new RecordingEngine();
+        InferenceDispatcher dispatcher = new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(binding("pages", 
InputKind.PAGES, null, 1),
+                        engine, List.of(pageTask)),
+                new InferenceDispatcher.Bound(binding("images", 
InputKind.IMAGES, null, -1),
+                        engine, List.of(imageTask))));
+        ParseContext context = new ParseContext();
+        Metadata pdf = new Metadata();
+        assertTrue(dispatcher.wantsPages(PNG, pdf, context));
+        Metadata rendering = new Metadata();
+        rendering.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "RENDERING");
+        assertFalse(dispatcher.wants(PNG, rendering, context),
+                "a render emitted as an embedded document is a page, not an 
image");
+        Metadata inline = new Metadata();
+        inline.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "INLINE");
+        assertTrue(dispatcher.wants(PNG, inline, context));
+
+        dispatcher.offerPage(PNG, pdf, null, 1, file("p1"), context);
+        dispatcher.offerPage(PNG, pdf, null, 2, file("p2"), context);
+        dispatcher.offer(PNG, rendering, pdf, file("r"), context);
+        Metadata root = new Metadata();
+        dispatcher.flush(root, context);
+
+        assertEquals(1, pageTask.runs.size());
+        List<InferenceUnit> units = pageTask.runs.get(0);
+        assertEquals(1, units.size(), "maxChunks 1 is one page");
+        assertEquals(InputKind.PAGES, units.get(0).getKind());
+        assertEquals(1, units.get(0).getPage());
+        assertSame(pdf, units.get(0).getTarget());
+        assertEquals("p1", new String(pageTask.bytes.get(0).get(0), UTF_8));
+        assertTrue(imageTask.runs.isEmpty(), "the rendering child reached no 
IMAGES binding");
+        assertTrue(root.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)
+                .contains("pages over maxChunks: skipped 1 units"));
+    }
+
+    @Test
+    public void testNoPagesBindingWantsNoPages() throws Exception {
+        InferenceDispatcher dispatcher = pngDispatcher(new RecordingTask());
+        assertFalse(dispatcher.wantsPages(PNG, new Metadata(), new 
ParseContext()));
+    }
+
     @Test
     public void testFailedParseRunsNothingAndCleansUp() throws Exception {
         RecordingTask task = new RecordingTask();
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 333dde4a22..9fab518a83 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
@@ -37,7 +37,7 @@ import org.apache.tika.parser.inference.InputKind;
 /**
  * The {@code embed} task: 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 has a page. Units go to the engine in batches of its 
size.
+ * locator when the unit is a page or has one. Units go to the engine in 
batches of its size.
  */
 @TikaComponent(name = "embed", spi = false)
 public class EmbedTask implements InferenceTask {
@@ -48,8 +48,8 @@ public class EmbedTask implements InferenceTask {
             throw new TikaConfigException("task \"embed\" needs an embedding 
engine; \""
                     + binding.getEngine() + "\" is not one");
         }
-        if (binding.getInput() != InputKind.IMAGES) {
-            throw new TikaConfigException("task \"embed\" takes IMAGES; 
binding \""
+        if (binding.getInput() != InputKind.IMAGES && binding.getInput() != 
InputKind.PAGES) {
+            throw new TikaConfigException("task \"embed\" takes IMAGES or 
PAGES; binding \""
                     + binding.getId() + "\" is on " + binding.getInput());
         }
     }
@@ -97,7 +97,9 @@ public class EmbedTask implements InferenceTask {
             InferenceUnit unit = batch.get(i);
             Locators locators = new Locators();
             String page = unit.getTarget().get(TikaPagedText.PAGE_NUMBER);
-            if (page != null) {
+            if (unit.getPage() > 0) {
+                locators.addPaginated(new PaginatedLocator(unit.getPage()));
+            } else if (page != null) {
                 locators.addPaginated(new 
PaginatedLocator(Integer.parseInt(page)));
             }
             Chunk chunk = new Chunk(null, locators);
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
index 49d3015748..0980bd4edd 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
@@ -135,6 +135,32 @@ public class EmbedTaskTest {
                 unit.getTarget().get(TikaCoreProperties.TIKA_CHUNKS)).size());
     }
 
+    /** A page unit lands on its own document with the page as its locator. */
+    @Test
+    public void testPageUnitsCarryThePage() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0, 
1)));
+        Metadata pdf = new Metadata();
+        pdf.set(TikaCoreProperties.EMBEDDED_ID_PATH, "/1");
+        pdf.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "ATTACHMENT");
+        Path p1 = Files.createTempFile(tmp, "p1", ".png");
+        Path p2 = Files.createTempFile(tmp, "p2", ".png");
+        Files.write(p1, "one".getBytes(StandardCharsets.UTF_8));
+        Files.write(p2, "two".getBytes(StandardCharsets.UTF_8));
+        InferenceBinding pages = new InferenceBinding("pages", "clip", 
InputKind.PAGES,
+                List.of("embed"), null, null, -1, -1, true);
+        EmbedTask task = new EmbedTask();
+        task.validate(pages, engine);
+        task.run(pages, List.of(
+                new InferenceUnit(InputKind.PAGES, MediaType.image("png"), 
pdf, new Metadata(), p1, 3),
+                new InferenceUnit(InputKind.PAGES, MediaType.image("png"), 
pdf, new Metadata(), p2, 4)),
+                engine, new ParseContext());
+        List<Chunk> chunks = 
ChunkSerializer.fromJson(pdf.get(TikaCoreProperties.TIKA_CHUNKS));
+        assertEquals(2, chunks.size());
+        assertEquals(3, 
chunks.get(0).getLocators().getPaginated().get(0).getPage());
+        assertEquals(4, 
chunks.get(1).getLocators().getPaginated().get(0).getPage());
+        assertNull(chunks.get(0).getLocators().getEmbedded(), "the pdf keeps 
its own pages");
+    }
+
     @Test
     public void testValidateRejectsANonEmbeddingEngine() {
         assertThrows(TikaConfigException.class,
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/pdf-parser-full.json
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/pdf-parser-full.json
index e48cd08540..55650f2a40 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/pdf-parser-full.json
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/config-examples/pdf-parser-full.json
@@ -48,6 +48,9 @@
         // How to render page images; NONE renders nothing.
         // Options: NONE, RAW_IMAGES, RENDER_PAGES_BEFORE_PARSE, 
RENDER_PAGES_AT_PAGE_END
         "imageStrategy": "NONE",
+        // What the parser releases to the inference bindings (see the 
inference page):
+        // TEXT is the extracted text; PAGES renders every page for a PAGES 
binding.
+        "inference": { "input": ["TEXT"] },
         // Max incremental updates to parse when parseIncrementalUpdates is 
true.
         "maxIncrementalUpdates": 10,
         // Max memory to load a PDF before buffering to a temp file (default 
512MB).
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 f8e41ba0c2..961f3fc967 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
@@ -121,6 +121,7 @@ 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.hook.ParseHooks;
 import org.apache.tika.parser.pdf.updates.IncrementalUpdateRecord;
 import org.apache.tika.parser.pdf.updates.IsIncrementalUpdate;
 import org.apache.tika.parser.pdf.updates.StartXRefOffset;
@@ -177,6 +178,8 @@ class AbstractPDF2XHTML extends PDFTextStripper {
     private final Parser pageAnnotators;
     final MediaType ocrImageMediaType;
     private PageText pageDecision = PageText.UNDECIDED;
+    /** What the OCR step did with the current page; SKIPPED until it runs. */
+    private PageOcr currentPageOcr = PageOcr.SKIPPED;
     // Text held back from an OCR_WANTED page; replayed if OCR does not run.
     private List<PageTextBuffer.SaxEvent> pendingText = 
Collections.emptyList();
     final ParseContext context;
@@ -185,6 +188,12 @@ class AbstractPDF2XHTML extends PDFTextStripper {
     final PDFParserConfig config;
     final Renderer renderer;
     final CompositeContentEnricher contentEnrichers;
+    private final ParseHooks hooks;
+    /** Pages are rendered for inference: the config says PAGES and a hook 
wants them. */
+    private final boolean pagesForInference;
+    /** The current page's render, made once for OCR, annotators and inference 
alike. */
+    private RenderResult pageRender;
+    private TemporaryResources pageRenderResources;
     /**
      * Format used for signature dates
      * TODO Make this thread-safe
@@ -258,6 +267,16 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         this.renderer = renderer;
         this.contentEnrichers = contentEnrichers;
         embeddedDocumentExtractor = 
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+        this.hooks = context.get(ParseHooks.class);
+        boolean wantsPages = false;
+        if (hooks != null && 
config.getInference().getInput().contains(InferenceConfig.Input.PAGES)) {
+            try {
+                wantsPages = hooks.wantsPages(ocrImageMediaType, context);
+            } catch (TikaException e) {
+                throw new IOException(e);
+            }
+        }
+        this.pagesForInference = wantsPages;
     }
 
     private static void addNonNullAttribute(String name, String value, 
AttributesImpl attributes) {
@@ -664,6 +683,13 @@ class AbstractPDF2XHTML extends PDFTextStripper {
 
     PageOcr doOCROnCurrentPage(PDPage pdPage, OcrConfig.Strategy ocrStrategy)
             throws IOException, TikaException, SAXException {
+        PageOcr result = dispatchOcr(pdPage, ocrStrategy);
+        currentPageOcr = result;
+        return result;
+    }
+
+    private PageOcr dispatchOcr(PDPage pdPage, OcrConfig.Strategy ocrStrategy)
+            throws IOException, TikaException, SAXException {
         if (ocrStrategy.equals(NO_OCR)) {
             return PageOcr.SKIPPED;
         }
@@ -702,30 +728,29 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         }
     }
 
-    /** Renders the page and runs the engine on it; true if the engine wrote 
text. */
+    /** Runs the engine on the page's render; true if the engine wrote text. */
     private boolean enrichCurrentPage(PDPage pdPage, Parser engine)
             throws IOException, TikaException, SAXException {
-        try (TemporaryResources tmp = new TemporaryResources()) {
-            try (RenderResult renderResult = renderCurrentPage(pdPage, tmp)) {
-                Metadata renderMetadata = renderResult.getMetadata();
-                TextCounter counter = new TextCounter(xhtml);
-                try (TikaInputStream tis = renderResult.getInputStream()) {
-                    renderMetadata.set(HttpHeaders.CONTENT_TYPE, 
ocrImageMediaType.toString());
-                    engine.parse(tis,
-                            new EmbeddedContentHandler(new 
BodyContentHandler(counter)),
-                            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.set(TikaCoreProperties.TIKA_CHUNKS,
-                            
mergeChunkArrays(metadata.get(TikaCoreProperties.TIKA_CHUNKS),
-                                    renderChunks));
-                }
-                return counter.sawText();
-            }
+        try {
+            RenderResult renderResult = currentPageRender(pdPage);
+            Metadata renderMetadata = renderResult.getMetadata();
+            TextCounter counter = new TextCounter(xhtml);
+            try (TikaInputStream tis = renderResult.getInputStream()) {
+                renderMetadata.set(HttpHeaders.CONTENT_TYPE, 
ocrImageMediaType.toString());
+                engine.parse(tis,
+                        new EmbeddedContentHandler(new 
BodyContentHandler(counter)),
+                        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.set(TikaCoreProperties.TIKA_CHUNKS,
+                        
mergeChunkArrays(metadata.get(TikaCoreProperties.TIKA_CHUNKS),
+                                renderChunks));
+            }
+            return counter.sawText();
         } catch (IOException e) {
             handleCatchableIOE(e);
         } catch (TikaTimeoutException e) {
@@ -736,6 +761,41 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         return false;
     }
 
+    /** The page's render, made on first use and closed at page end. */
+    private RenderResult currentPageRender(PDPage pdPage) throws IOException, 
TikaException {
+        if (pageRender == null) {
+            pageRenderResources = new TemporaryResources();
+            pageRender = renderCurrentPage(pdPage, pageRenderResources);
+        }
+        return pageRender;
+    }
+
+    private void closePageRender() {
+        RenderResult render = pageRender;
+        TemporaryResources resources = pageRenderResources;
+        pageRender = null;
+        pageRenderResources = null;
+        try (resources; render) {
+            // closing deletes the render file
+        } catch (IOException e) {
+            EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, 
context);
+        }
+    }
+
+    /** Hands the page's render to the hooks when a PAGES binding wants it. */
+    private void offerCurrentPageToInference(PDPage pdPage) throws 
IOException, TikaException {
+        if (!pagesForInference) {
+            return;
+        }
+        RenderResult render = currentPageRender(pdPage);
+        if (render.getStatus() != RenderResult.STATUS.SUCCESS) {
+            return;
+        }
+        try (TikaInputStream tis = render.getInputStream()) {
+            hooks.offerPage(ocrImageMediaType, getCurrentPageNo(), 
tis.getPath(), context);
+        }
+    }
+
     /**
      * 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.
@@ -913,18 +973,18 @@ class AbstractPDF2XHTML extends PDFTextStripper {
             for (PDAnnotation annotation : page.getAnnotations()) {
                 processPageAnnotation(annotation);
             }
-            PageOcr ocr = PageOcr.SKIPPED;
             if (config.getOcr().getStrategy() == OCR_AND_TEXT_EXTRACTION) {
-                ocr = doOCROnCurrentPage(page, OCR_AND_TEXT_EXTRACTION);
+                doOCROnCurrentPage(page, OCR_AND_TEXT_EXTRACTION);
             } else if (pageDecision == PageText.OCR_WANTED) {
-                ocr = doOCROnCurrentPage(page, AUTO);
-                if (ocr != PageOcr.TEXT) {
+                if (doOCROnCurrentPage(page, AUTO) != PageOcr.TEXT) {
                     replay(pendingText);
                 }
             }
-            if (ocr == PageOcr.SKIPPED) {
+            // the engine composite already ran the annotators on a page it 
OCR'd
+            if (currentPageOcr == PageOcr.SKIPPED) {
                 annotateCurrentPage(page);
             }
+            offerCurrentPageToInference(page);
 
             PDPageAdditionalActions pageActions = page.getActions();
             if (pageActions != null) {
@@ -937,6 +997,8 @@ class AbstractPDF2XHTML extends PDFTextStripper {
         } catch (IOException e) {
             handleCatchableIOE(e);
         } finally {
+            closePageRender();
+            currentPageOcr = PageOcr.SKIPPED;
             totalCharsPerPage = 0;
             unmappedUnicodeCharsPerPage = 0;
             pendingText = Collections.emptyList();
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/InferenceConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/InferenceConfig.java
new file mode 100644
index 0000000000..1997615b0c
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/InferenceConfig.java
@@ -0,0 +1,41 @@
+/*
+ * 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.pdf;
+
+import java.io.Serializable;
+import java.util.List;
+
+/** What the PDF parser releases to the inference bindings; per request like 
every PDF setting. */
+public class InferenceConfig implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** {@code TEXT}: the extracted text. {@code PAGES}: every page rendered, 
when a binding wants pages. */
+    public enum Input {
+        TEXT, PAGES
+    }
+
+    private List<Input> input = List.of(Input.TEXT);
+
+    public List<Input> getInput() {
+        return input;
+    }
+
+    public void setInput(List<Input> input) {
+        this.input = input == null ? List.of() : List.copyOf(input);
+    }
+}
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 ae23a5e6e2..c3de03490b 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
@@ -97,9 +97,8 @@ class OCR2XHTML extends AbstractPDF2XHTML {
     public void processPage(PDPage pdPage) throws IOException {
         try {
             startPage(pdPage);
-            if (doOCROnCurrentPage(pdPage, OcrConfig.Strategy.OCR_ONLY) == 
PageOcr.SKIPPED) {
-                annotateCurrentPage(pdPage);
-            }
+            doOCROnCurrentPage(pdPage, OcrConfig.Strategy.OCR_ONLY);
+            // endPage annotates the page when no engine ran, and offers it to 
inference
             endPage(pdPage);
         } catch (TikaException | SAXException e) {
             throw new IOException(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/PDFParserConfig.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
index b83f449cad..239f19196e 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java
@@ -119,6 +119,8 @@ public class PDFParserConfig implements Serializable {
 
     private OcrConfig ocr = new OcrConfig();
 
+    private InferenceConfig inference = new InferenceConfig();
+
     /**
      * Should the entire document be rendered?
      */
@@ -522,6 +524,15 @@ public class PDFParserConfig implements Serializable {
         this.ocr = ocr;
     }
 
+    /** What this parser releases to the inference bindings. */
+    public InferenceConfig getInference() {
+        return inference;
+    }
+
+    public void setInference(InferenceConfig inference) {
+        this.inference = inference == null ? new InferenceConfig() : inference;
+    }
+
     // OCR settings are configured through the nested OcrConfig 
(getOcr()/setOcr()).
     // The flat ocr* convenience accessors (getOcrStrategy/setOcrDPI/...) were 
removed in
     // 4.x so that "ocr" is the single JSON spelling; use 
getOcr().setStrategy(...) etc.
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 2852ce22cd..56333f89bd 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
@@ -21,13 +21,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -81,6 +84,9 @@ import 
org.apache.tika.parser.enricher.CompositeContentEnricher;
 import org.apache.tika.parser.enricher.ContentEnricher;
 import org.apache.tika.parser.enricher.ContentEnrichers;
 import org.apache.tika.parser.enricher.TextRecognizer;
+import org.apache.tika.parser.hook.ParseHook;
+import org.apache.tika.parser.hook.ParseHooks;
+import org.apache.tika.renderer.RenderingTracker;
 import org.apache.tika.sax.BodyContentHandler;
 import org.apache.tika.sax.ContentHandlerDecorator;
 import org.apache.tika.sax.ToXMLContentHandler;
@@ -1238,6 +1244,8 @@ public class PDFParserTest extends TikaTest {
         assertEquals(true, pdfParserConfig.isExtractInlineImages());
         assertEquals(false, pdfParserConfig.isExtractUniqueInlineImagesOnly());
         assertEquals(314, pdfParserConfig.getOcr().getDpi());
+        assertEquals(List.of(InferenceConfig.Input.PAGES),
+                pdfParserConfig.getInference().getInput());
         assertEquals(2.1f, pdfParserConfig.getOcr().getImageQuality(), .01f);
         assertEquals(OcrConfig.ImageFormat.JPEG, 
pdfParserConfig.getOcr().getImageFormat());
         assertEquals(524288000, pdfParserConfig.getMaxMainMemoryBytes());
@@ -1817,6 +1825,127 @@ public class PDFParserTest extends TikaTest {
         assertContainsCount("MOCK_OCR_CONTENT", xml, 1);
     }
 
+    /** OCR_ONLY with the budget spent still annotates a rendered page once. */
+    @Test
+    public void testOcrOnlyBudgetAnnotatesOnce() throws Exception {
+        PageEnricher recognizer = new PageEnricher("MOCK_OCR_CONTENT", true);
+        PageAnnotator annotator = new PageAnnotator();
+        CompositeContentEnricher enrichers =
+                new CompositeContentEnricher(List.of(recognizer, annotator));
+        PDFParser parser = new PDFParser();
+        parser.setContentEnrichers(enrichers);
+        PDFParserConfig config = new PDFParserConfig();
+        config.getOcr().setStrategy(OcrConfig.Strategy.OCR_ONLY);
+        config.getOcr().setMaxPagesToOcr(1);
+        
config.setImageStrategy(PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_AT_PAGE_END);
+        config.getOcr().setDpi(20);
+        ParseContext context = new ParseContext();
+        context.set(PDFParserConfig.class, config);
+        Metadata metadata = new Metadata();
+
+        String xml = parsePdfToXml(parser, "testPDF_bookmarks.pdf", metadata, 
context);
+
+        assertEquals(1, recognizer.calls);
+        assertEquals(2, annotator.calls);
+        assertContainsCount("MOCK_OCR_CONTENT", xml, 1);
+    }
+
+    /** Records what the parse hooks are offered; wants pages when told to. */
+    private static class PageHook implements ParseHook {
+        final List<Integer> pages = new ArrayList<>();
+        final List<Long> sizes = new ArrayList<>();
+        Metadata document;
+        boolean wantsPages;
+        int wantsPagesCalls = 0;
+
+        @Override
+        public boolean wantsPages(MediaType renderType, Metadata document, 
ParseContext context) {
+            wantsPagesCalls++;
+            return wantsPages;
+        }
+
+        @Override
+        public void offerPage(MediaType type, Metadata document, Metadata 
parent, int page,
+                              Path bytes, ParseContext context) throws 
IOException {
+            assertEquals("image/png", type.toString());
+            pages.add(page);
+            sizes.add(Files.size(bytes));
+            this.document = document;
+        }
+    }
+
+    /** The PDF parser under the auto-detect parser, which seeds the hooks. */
+    private static AutoDetectParser hooked(PDFParser parser, PageHook hook) {
+        AutoDetectParser adp = new AutoDetectParser(parser);
+        adp.setParseHooks(new ParseHooks(List.of(hook)));
+        return adp;
+    }
+
+    private static PDFParserConfig pagesForInference(OcrConfig.Strategy 
strategy) {
+        PDFParserConfig config = new PDFParserConfig();
+        config.getOcr().setStrategy(strategy);
+        config.getOcr().setDpi(20);
+        config.getInference().setInput(List.of(InferenceConfig.Input.PAGES));
+        return config;
+    }
+
+    /** One render per page feeds OCR and inference alike; every page is 
offered once. */
+    @Test
+    public void testPagesOfferedOncePerPageFromOneRender() throws Exception {
+        for (OcrConfig.Strategy strategy : 
List.of(OcrConfig.Strategy.OCR_AND_TEXT_EXTRACTION,
+                OcrConfig.Strategy.OCR_ONLY, OcrConfig.Strategy.NO_OCR)) {
+            PageEnricher recognizer = new PageEnricher("MOCK_OCR_CONTENT", 
true);
+            PDFParser parser = new PDFParser();
+            parser.setContentEnrichers(new 
CompositeContentEnricher(List.of(recognizer)));
+            PageHook hook = new PageHook();
+            hook.wantsPages = true;
+            ParseContext context = new ParseContext();
+            context.set(PDFParserConfig.class, pagesForInference(strategy));
+            RenderingTracker tracker = new RenderingTracker();
+            context.set(RenderingTracker.class, tracker);
+            Metadata metadata = new Metadata();
+            try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testPDF_bookmarks.pdf")) {
+                hooked(parser, hook).parse(tis, new ToXMLContentHandler(), 
metadata, context);
+            }
+            String label = strategy.name();
+            assertEquals(List.of(1, 2), hook.pages, label);
+            assertSame(metadata, hook.document, label + ": the pages belong to 
the PDF");
+            assertTrue(hook.sizes.get(0) > 0, label + ": the render has 
bytes");
+            assertEquals(2, tracker.getNextId() - 1, label + ": one render per 
page");
+            assertEquals(strategy == OcrConfig.Strategy.NO_OCR ? 0 : 2, 
recognizer.calls, label);
+        }
+    }
+
+    /** Nothing renders for inference unless the config says PAGES and a hook 
wants them. */
+    @Test
+    public void testPagesNotRenderedUnlessWanted() throws Exception {
+        PageHook unasked = new PageHook();
+        ParseContext context = new ParseContext();
+        PDFParserConfig config = new PDFParserConfig();
+        config.getOcr().setStrategy(OcrConfig.Strategy.NO_OCR);
+        context.set(PDFParserConfig.class, config);
+        RenderingTracker tracker = new RenderingTracker();
+        context.set(RenderingTracker.class, tracker);
+        try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testPDF_bookmarks.pdf")) {
+            hooked(new PDFParser(), unasked).parse(tis, new 
ToXMLContentHandler(),
+                    new Metadata(), context);
+        }
+        assertEquals(0, unasked.wantsPagesCalls, "the default input is TEXT: 
hooks are not asked");
+        assertTrue(unasked.pages.isEmpty());
+
+        PageHook unwilling = new PageHook();
+        context = new ParseContext();
+        context.set(PDFParserConfig.class, 
pagesForInference(OcrConfig.Strategy.NO_OCR));
+        context.set(RenderingTracker.class, tracker);
+        try (TikaInputStream tis = 
getResourceAsStream("/test-documents/testPDF_bookmarks.pdf")) {
+            hooked(new PDFParser(), unwilling).parse(tis, new 
ToXMLContentHandler(),
+                    new Metadata(), context);
+        }
+        assertEquals(1, unwilling.wantsPagesCalls, "asked once per document");
+        assertTrue(unwilling.pages.isEmpty());
+        assertEquals(0, tracker.getNextId() - 1, "no binding wants pages: 
nothing is rendered");
+    }
+
     /** Annotating enricher for image/png: writes one chunk per render, never 
text. */
     private static class PageAnnotator implements Parser, ContentEnricher {
         private static final long serialVersionUID = 1L;
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/resources/org/apache/tika/parser/pdf/tika-inline-config.json
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/resources/org/apache/tika/parser/pdf/tika-inline-config.json
index 0e326e2fdf..65690be7ed 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/resources/org/apache/tika/parser/pdf/tika-inline-config.json
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/resources/org/apache/tika/parser/pdf/tika-inline-config.json
@@ -14,6 +14,7 @@
           "imageQuality": 2.1,
           "imageFormat": "JPEG"
         },
+        "inference": { "input": ["PAGES"] },
         "maxMainMemoryBytes": 524288000
       }
     }
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
index 22a2cf2c7e..167e16d39f 100644
--- 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
@@ -32,9 +32,12 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
 import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.metadata.Metadata;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.inference.EngineRegistry;
+import org.apache.tika.parser.inference.InferenceBinding;
 import org.apache.tika.parser.inference.InferenceDispatcher;
 import org.apache.tika.parser.inference.InferenceSelection;
 import org.apache.tika.parser.inference.InputKind;
@@ -87,6 +90,21 @@ public class InferenceLoaderTest {
                 "the dispatcher rides every parse as a hook");
     }
 
+    @Test
+    public void testPagesBindingLoads() throws Exception {
+        TikaLoader loader = load("{" + ENGINES + ", \"inference\": ["
+                + " { \"id\": \"page-vectors\", \"engine\": \"one\", 
\"input\": \"PAGES\","
+                + "   \"tasks\": [\"test-task\"], \"maxChunks\": 50 } ] }");
+        InferenceDispatcher dispatcher = loader.get(InferenceDispatcher.class);
+        InferenceBinding pages = dispatcher.getBound().get(0).binding();
+        assertEquals(InputKind.PAGES, pages.getInput());
+        assertTrue(pages.accepts(InputKind.PAGES, MediaType.image("png")));
+        assertFalse(pages.accepts(InputKind.IMAGES, MediaType.image("png")),
+                "a PAGES binding never takes an image document");
+        assertTrue(dispatcher.wantsPages(MediaType.image("png"), new 
Metadata(),
+                new ParseContext()));
+    }
+
     @Test
     public void testPerRequestSelectionResolves() throws Exception {
         TikaLoader loader = load("{" + ENGINES + ", \"inference\": ["
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 0b04515ea4..ca674fe06f 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
@@ -20,18 +20,30 @@ import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.Reader;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import javax.imageio.ImageIO;
 
 import jakarta.ws.rs.core.Response;
 import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
 import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.ext.multipart.Attachment;
+import org.apache.cxf.jaxrs.ext.multipart.ContentDisposition;
+import org.apache.cxf.jaxrs.ext.multipart.MultipartBody;
 import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+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.Metadata;
@@ -65,6 +77,11 @@ public class InferenceBindingsWireTest extends CXFTestBase {
         return 
getClass().getResourceAsStream("/configs/tika-config-inference-bindings.json");
     }
 
+    @Override
+    protected boolean isAllowPerRequestConfig() {
+        return true;
+    }
+
     @Test
     public void testBindingOutputSurvivesFork() throws Exception {
         ByteArrayOutputStream png = new ByteArrayOutputStream();
@@ -82,4 +99,53 @@ public class InferenceBindingsWireTest extends CXFTestBase {
         assertEquals("mock-images", metadata.get(MockTask.MARKER_KEY));
         assertEquals("1", metadata.get(MockTask.UNITS_KEY));
     }
+
+    /** A request asks for a PDF's pages; the PAGES binding's output lands on 
the PDF. */
+    @Test
+    public void testPagesPerRequest() throws Exception {
+        String config = """
+                { "parse-context": {
+                    "pdf-parser": { "ocr": { "strategy": "NO_OCR", "dpi": 20 },
+                                    "inference": { "input": ["PAGES"] } },
+                    "inference": { "bindings": ["mock-pages"] } } }
+                """;
+        ContentDisposition fileCd = new ContentDisposition(
+                "form-data; name=\"file\"; filename=\"two-pages.pdf\"");
+        Attachment fileAtt = new Attachment("file", new 
ByteArrayInputStream(twoPagePdf()), fileCd);
+        Attachment configAtt = new Attachment("config", "application/json",
+                new ByteArrayInputStream(config.getBytes(UTF_8)));
+
+        Response response = WebClient
+                .create(endPoint + META_PATH + "/config")
+                .type("multipart/form-data")
+                .accept("application/json")
+                .post(new MultipartBody(Arrays.asList(fileAtt, configAtt)));
+
+        assertEquals(200, response.getStatus());
+        Reader reader = new InputStreamReader((InputStream) 
response.getEntity(), UTF_8);
+        List<Metadata> metadataList = JsonMetadataList.fromJson(reader);
+        assertEquals(1, metadataList.size());
+        Metadata metadata = metadataList.get(0);
+        assertEquals("mock-pages", metadata.get(MockTask.MARKER_KEY));
+        assertEquals("2", metadata.get(MockTask.UNITS_KEY));
+    }
+
+    private static byte[] twoPagePdf() throws IOException {
+        try (PDDocument document = new PDDocument()) {
+            for (int i = 1; i <= 2; i++) {
+                PDPage page = new PDPage(PDRectangle.LETTER);
+                document.addPage(page);
+                try (PDPageContentStream content = new 
PDPageContentStream(document, page)) {
+                    content.beginText();
+                    content.setFont(new 
PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
+                    content.newLineAtOffset(72, 700);
+                    content.showText("This is page " + i + " of the test 
document.");
+                    content.endText();
+                }
+            }
+            ByteArrayOutputStream out = new ByteArrayOutputStream();
+            document.save(out);
+            return out.toByteArray();
+        }
+    }
 }
diff --git 
a/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
 
b/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
index 445057c367..601646c106 100644
--- 
a/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
+++ 
b/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
@@ -1,6 +1,9 @@
 {
   "engines": { "mock": { "mock-engine": {} } },
-  "inference": [ { "id": "mock-images", "engine": "mock", "input": "IMAGES", 
"tasks": ["mock-task"] } ],
+  "inference": [
+    { "id": "mock-images", "engine": "mock", "input": "IMAGES", "tasks": 
["mock-task"] },
+    { "id": "mock-pages", "engine": "mock", "input": "PAGES", "tasks": 
["mock-task"] }
+  ],
   "auto-detect-parser": { "throwOnZeroBytes": false },
   "parse-context": { "timeout-limits": { "progressTimeoutMillis": 30000 } }
 }

Reply via email to