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 bfe1a42704 TIKA-4856 presets (#3118)
bfe1a42704 is described below

commit bfe1a42704b177f5b8a574fc452b6f7b97a537e7
Author: Tim Allison <[email protected]>
AuthorDate: Thu Sep 3 17:33:29 2026 -0400

    TIKA-4856 presets (#3118)
---
 CHANGES.txt                                        |   2 +
 docs/modules/ROOT/pages/pipes/configuration.adoc   |   2 +-
 docs/modules/ROOT/pages/pipes/plugins/json.adoc    |   2 +-
 .../ROOT/pages/using-tika/server/index.adoc        |  56 +++++
 .../org/apache/tika/pipes/api/FetchEmitTuple.java  |  24 +-
 .../org/apache/tika/pipes/api/PipesResult.java     |   1 +
 .../serialization/FetchEmitTupleDeserializer.java  |  17 +-
 .../serialization/FetchEmitTupleSerializer.java    |   4 +
 .../pipes/core/serialization/PipesRequest.java     |   2 +-
 .../tika/pipes/core/server/ConnectionHandler.java  |   8 +-
 .../apache/tika/pipes/core/server/PipesServer.java |  41 +++-
 .../pipes/core/server/PresetNotFoundException.java |  32 +++
 .../pipes/core/server/SharedServerResources.java   |  21 +-
 .../core/serialization/JsonFetchEmitTupleTest.java |  27 +++
 .../tika/pipes/core/server/PresetMergeTest.java    |  90 +++++++
 .../apache/tika/config/loader/PresetRegistry.java  | 259 +++++++++++++++++++++
 .../apache/tika/config/loader/TikaJsonConfig.java  |   1 +
 .../tika/config/loader/PresetRegistryTest.java     | 230 ++++++++++++++++++
 .../META-INF/tika/preset-catalog.properties        |  19 ++
 .../resources/test-presets/builtin-sample.json     |   4 +
 .../server/core/resource/PipesParsingHelper.java   |  24 +-
 .../tika/server/core/resource/PresetSelection.java |  28 +++
 .../core/resource/RecursiveMetadataResource.java   |  39 ++++
 .../tika/server/core/resource/TikaResource.java    | 159 ++++++++++++-
 .../server/core/resource/UnpackerResource.java     |  51 ++++
 .../org/apache/tika/server/core/CXFTestBase.java   |   6 +
 .../tika/server/core/PresetEndpointsTest.java      | 192 +++++++++++++++
 .../core/resource/TikaResourcePresetTest.java      |  96 ++++++++
 28 files changed, 1414 insertions(+), 23 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index fe2cc602cb..c24adef94e 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,7 @@
 Release 4.1.0 - unreleased
 
+   * tika-server: named configuration presets (TIKA-4856).
+
    * Temp files follow -Djava.io.tmpdir on the parent JVM (Tika, its
      libraries, and forks all honor it); TikaLoader fails at config load
      if it is unusable. pipes.tempDirectory is deprecated for removal in
diff --git a/docs/modules/ROOT/pages/pipes/configuration.adoc 
b/docs/modules/ROOT/pages/pipes/configuration.adoc
index d8691bf7a8..6e65a53d82 100644
--- a/docs/modules/ROOT/pages/pipes/configuration.adoc
+++ b/docs/modules/ROOT/pages/pipes/configuration.adoc
@@ -136,7 +136,7 @@ See also xref:pipes/timeouts.adoc[Timeouts] for the full 
timeout model.
 
 |`maxTotalTaskTimeoutMillis`
 |`3600000`
-|Ceiling for *request-supplied* timeout limits: a per-request `timeout-limits` 
override may lower its timeouts freely but can never raise 
`totalTaskTimeoutMillis` or `progressTimeoutMillis` above this value (values 
over the cap are clamped with a warning). Limits set in the server's own 
`parse-context` are trusted and not subject to this cap.
+|Ceiling for *request-supplied* timeout limits: a per-request `timeout-limits` 
override may lower its timeouts freely but can never raise 
`totalTaskTimeoutMillis` or `progressTimeoutMillis` above this value (values 
over the cap are clamped with a warning). Limits set in the server's own 
`parse-context` — or in a preset a request selects — are trusted and not 
subject to this cap.
 
 |`maxWaitForClientMillis`
 |`60000`
diff --git a/docs/modules/ROOT/pages/pipes/plugins/json.adoc 
b/docs/modules/ROOT/pages/pipes/plugins/json.adoc
index 62301adc8f..52b1ebba9d 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/json.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/json.adoc
@@ -57,6 +57,6 @@ Unlike the other iterators, this one takes no `fetcherId` / 
`emitterId`: each li
 == Notes
 
 * The file format is JSON-lines (also called NDJSON) — one valid JSON object 
per line, no surrounding array brackets.
-* Each line must be a serialized `FetchEmitTuple`: `id`, `fetcher`, 
`fetchKey`, `emitter`, `emitKey`, and optionally `fetchRangeStart`, 
`fetchRangeEnd`, `metadata`, `parse-context`, `onParseException`. Any other
+* Each line must be a serialized `FetchEmitTuple`: `id`, `fetcher`, 
`fetchKey`, `emitter`, `emitKey`, and optionally `fetchRangeStart`, 
`fetchRangeEnd`, `metadata`, `parse-context`, `onParseException`, `preset`. Any 
other
   field is rejected — the deserializer does not tolerate unknown keys.
 * For columnar work items in a CSV, use the xref:pipes/plugins/csv.adoc[CSV 
iterator] instead.
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc 
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index 4c0048228a..3ad3f58cc1 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -105,6 +105,62 @@ WARNING: Enable this only behind network controls 
(firewalls, private subnets),
 or xref:using-tika/server/tls.adoc[2-way TLS authentication]. Per-request 
configuration lets
 callers change how documents are parsed, widening what anyone who can reach 
the server can do.
 
+=== Presets — named configuration without `/config`
+
+A *preset* is a named, vetted parse-context fragment: parser and component 
configurations keyed
+by friendly name, defined once in the server config (or shipped with Tika) and 
selected whole by
+name. Callers apply one by inserting `preset/{name}` directly after the 
resource root:
+`/tika/preset/{name}[/text|/html|/xml|/md|/json[/{handlerType}]]`,
+`/rmeta/preset/{name}[/{handlerType}]`, `/unpack/preset/{name}[/all]`.
+
+[source,bash]
+----
+curl -T document.pdf http://localhost:9998/tika/preset/my-preset/text
+curl -T document.pdf http://localhost:9998/rmeta/preset/my-preset/text
+curl -T document.pdf http://localhost:9998/unpack/preset/my-preset
+----
+
+Nothing is active unless the top-level `presets` key names it. `true` 
activates a preset from
+the classpath *catalog* (definitions shipped with Tika, so their content 
tracks the Tika
+version); an object defines a preset in place, replacing any same-named 
catalog definition
+wholesale; `false`/`null` is an explicit no-op. Catalog jars can never 
activate themselves —
+every active preset is a visible line in the operator's config, and `true` 
naming nothing in
+the catalog fails startup:
+
+[source,json]
+----
+{
+  "presets": {
+    "some-catalog-preset": true,
+    "no-ocr": { "pdf-parser": { "ocr": { "strategy": "NO_OCR" } } }
+  }
+}
+----
+
+Presets are deliberately narrow: a request selects exactly one, the preset 
routes take no
+`config` part, and a preset never combines with request-supplied configuration 
— a variant is
+another preset. Because preset content is operator- or Tika-vetted, the preset 
routes do *not*
+require `allowPerRequestConfig`, and the `preset/{name}` path segment gives 
network controls an
+addressable surface — a reverse proxy can allow `/rmeta/preset/` while 
blocking `/rmeta/config`
+entirely. An unknown preset name answers `404`. Names are letters, digits, 
`.`, `_`, `-`
+(max 100 chars) and may not start with `config` (that path fragment gates the 
`/config`
+endpoints).
+
+Only the preset's *name* travels with a request: the forked parse worker 
resolves the content
+from its own copy of the server config, with the same trust as the config's own
+`parse-context` block. A preset can therefore bind components per-request 
`/config` input may
+not (detectors, embedded-document extraction, exception reporting, ...) and 
raise timeout
+limits above the per-request clamp. Every active preset resolves at startup, 
so a bad preset
+fails the server, not its first request.
+
+Output format on the preset routes: an explicit format segment 
(`/tika/preset/{name}/text`)
+always wins; without one, a `ContentHandlerFactory` the preset binds decides, 
then the
+config's, then the endpoint default (Markdown).
+
+On the batch surfaces, a `FetchEmitTuple` submitted to `/pipes` or `/async` 
may carry a
+top-level `"preset": "name"` field, resolved the same way beneath the tuple's 
own
+`parseContext`; an unknown name there answers the `PRESET_NOT_FOUND` status 
(HTTP `400`).
+
 === `allowPipes` — the `/pipes` and `/async` endpoints
 
 `/pipes` and `/async` drive process-isolated batch parsing through your 
configured fetchers and
diff --git 
a/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/FetchEmitTuple.java
 
b/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/FetchEmitTuple.java
index fbb2b94af9..2e9440112f 100644
--- 
a/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/FetchEmitTuple.java
+++ 
b/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/FetchEmitTuple.java
@@ -38,6 +38,8 @@ public class FetchEmitTuple implements Serializable {
     private final Metadata metadata;
     private final ParseContext parseContext;
     private final ON_PARSE_EXCEPTION onParseException;
+    // Preset selector only: the server resolves the content from its own 
config
+    private final String presetName;
 
     public FetchEmitTuple(String id, FetchKey fetchKey, EmitKey emitKey) {
         this(id, fetchKey, emitKey, new Metadata());
@@ -52,12 +54,19 @@ public class FetchEmitTuple implements Serializable {
 
     public FetchEmitTuple(String id, FetchKey fetchKey, EmitKey emitKey, 
Metadata metadata, ParseContext parseContext,
                           ON_PARSE_EXCEPTION onParseException) {
+        this(id, fetchKey, emitKey, metadata, parseContext, onParseException, 
null);
+    }
+
+    /** @since Apache Tika 4.1.0 */
+    public FetchEmitTuple(String id, FetchKey fetchKey, EmitKey emitKey, 
Metadata metadata, ParseContext parseContext,
+                          ON_PARSE_EXCEPTION onParseException, String 
presetName) {
         this.id = id;
         this.fetchKey = fetchKey;
         this.emitKey = emitKey;
         this.metadata = metadata;
         this.parseContext = parseContext;
         this.onParseException = onParseException;
+        this.presetName = presetName;
     }
 
     public String getId() {
@@ -86,6 +95,15 @@ public class FetchEmitTuple implements Serializable {
         return onParseException;
     }
 
+    /**
+     * The selected preset's name, or null for none.
+     *
+     * @since Apache Tika 4.1.0
+     */
+    public String getPresetName() {
+        return presetName;
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) {
@@ -98,7 +116,8 @@ public class FetchEmitTuple implements Serializable {
         FetchEmitTuple that = (FetchEmitTuple) o;
         return Objects.equals(id, that.id) && Objects.equals(fetchKey, 
that.fetchKey) && Objects.equals(emitKey, that.emitKey)
                 && Objects.equals(metadata, that.metadata) &&
-                Objects.equals(parseContext, that.parseContext) && 
onParseException == that.onParseException;
+                Objects.equals(parseContext, that.parseContext) && 
onParseException == that.onParseException &&
+                Objects.equals(presetName, that.presetName);
     }
 
     @Override
@@ -109,6 +128,7 @@ public class FetchEmitTuple implements Serializable {
         result = 31 * result + Objects.hashCode(metadata);
         result = 31 * result + Objects.hashCode(parseContext);
         result = 31 * result + Objects.hashCode(onParseException);
+        result = 31 * result + Objects.hashCode(presetName);
         return result;
     }
 
@@ -116,6 +136,6 @@ public class FetchEmitTuple implements Serializable {
     public String toString() {
         return "FetchEmitTuple{" + "id='" + id + '\'' + ", fetchKey=" + 
fetchKey + ", emitKey=" + emitKey +
                 ", metadata=" + metadata + ", parseContext=" + parseContext +
-                ", onParseException=" + onParseException + '}';
+                ", onParseException=" + onParseException + ", presetName='" + 
presetName + "'}";
     }
 }
diff --git 
a/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/PipesResult.java
 
b/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/PipesResult.java
index fabbcd25ca..adf844b4a6 100644
--- 
a/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/PipesResult.java
+++ 
b/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/PipesResult.java
@@ -66,6 +66,7 @@ public record PipesResult(RESULT_STATUS status, EmitData 
emitData, String messag
         EMIT_EXCEPTION(CATEGORY.TASK_EXCEPTION),
         FETCHER_NOT_FOUND(CATEGORY.TASK_EXCEPTION),
         EMITTER_NOT_FOUND(CATEGORY.TASK_EXCEPTION),
+        PRESET_NOT_FOUND(CATEGORY.TASK_EXCEPTION),
         PAYLOAD_LIMIT_EXCEEDED(CATEGORY.TASK_EXCEPTION),
 
         // Process crashes - forked process died, auto-restart
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
index 0b656dda94..90df40149f 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
@@ -26,6 +26,7 @@ import static 
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.
 import static 
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.ID;
 import static 
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.METADATA_KEY;
 import static 
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.ON_PARSE_EXCEPTION;
+import static 
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.PRESET;
 import static 
org.apache.tika.serialization.serdes.ParseContextSerializer.PARSE_CONTEXT;
 
 import java.io.IOException;
@@ -40,6 +41,7 @@ import com.fasterxml.jackson.databind.DeserializationContext;
 import com.fasterxml.jackson.databind.JsonDeserializer;
 import com.fasterxml.jackson.databind.JsonNode;
 
+import org.apache.tika.config.loader.PresetRegistry;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.pipes.api.ComponentIds;
@@ -55,7 +57,7 @@ public class FetchEmitTupleDeserializer extends 
JsonDeserializer<FetchEmitTuple>
 
     private static final Set<String> KNOWN_KEYS = Set.of(
             ID, FETCHER, FETCH_KEY, EMITTER, EMIT_KEY, FETCH_RANGE_START, 
FETCH_RANGE_END,
-            METADATA_KEY, PARSE_CONTEXT, ON_PARSE_EXCEPTION);
+            METADATA_KEY, PARSE_CONTEXT, ON_PARSE_EXCEPTION, PRESET);
 
     private final boolean restricted;
 
@@ -114,10 +116,11 @@ public class FetchEmitTupleDeserializer extends 
JsonDeserializer<FetchEmitTuple>
         ParseContext parseContext = parseContextNode == null ? new 
ParseContext()
                 : ParseContextDeserializer.readParseContext(parseContextNode, 
true);
         FetchEmitTuple.ON_PARSE_EXCEPTION onParseException = 
readOnParseException(root);
+        String presetName = readPresetName(root);
 
         return new FetchEmitTuple(id, new FetchKey(fetcherId, fetchKey, 
fetchRangeStart, fetchRangeEnd),
                 new EmitKey(emitterName, emitKey), metadata, parseContext,
-                onParseException);
+                onParseException, presetName);
     }
 
     /**
@@ -147,6 +150,16 @@ public class FetchEmitTupleDeserializer extends 
JsonDeserializer<FetchEmitTuple>
         }
     }
 
+    // A preset name is only a selector; the shared syntax rule also bounds 
its length.
+    private static String readPresetName(JsonNode root) throws IOException {
+        String presetName = readVal(PRESET, root, null, false);
+        if (presetName != null && !PresetRegistry.isValidName(presetName)) {
+            throw new IOException("invalid preset name (letters, digits, '.', 
'_', '-'; " +
+                    "max 100 chars; may not start with 'config')");
+        }
+        return presetName;
+    }
+
     private static FetchEmitTuple.ON_PARSE_EXCEPTION 
readOnParseException(JsonNode root) throws IOException {
         JsonNode onParseExNode = root.get(ON_PARSE_EXCEPTION);
         if (onParseExNode == null) {
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
index 608ab15f37..9667aa22c8 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
@@ -40,6 +40,7 @@ public class FetchEmitTupleSerializer extends 
JsonSerializer<FetchEmitTuple> {
     public static final String EMIT_KEY = "emitKey";
     public static final String METADATA_KEY = "metadata";
     public static final String ON_PARSE_EXCEPTION = "onParseException";
+    public static final String PRESET = "preset";
 
     public void serialize(FetchEmitTuple t, JsonGenerator jsonGenerator, 
SerializerProvider serializerProvider) throws IOException {
 
@@ -59,6 +60,9 @@ public class FetchEmitTupleSerializer extends 
JsonSerializer<FetchEmitTuple> {
             jsonGenerator.writeObjectField(METADATA_KEY, t.getMetadata());
         }
         jsonGenerator.writeStringField(ON_PARSE_EXCEPTION, 
t.getOnParseException().name().toLowerCase(Locale.US));
+        if (t.getPresetName() != null) {
+            jsonGenerator.writeStringField(PRESET, t.getPresetName());
+        }
         ParseContext parseContext = t.getParseContext();
         // Tailored: ParseContextSerializer's generic refusal suggests 
registering the
         // component -- for InlineBytes, exactly the forbidden fix.
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
index 15745cb41a..4a6e41887b 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
@@ -60,7 +60,7 @@ public final class PipesRequest {
         copy.copyFrom(ctx);
         copy.set(InlineBytes.class, null);
         FetchEmitTuple stripped = new FetchEmitTuple(t.getId(), 
t.getFetchKey(), t.getEmitKey(),
-                t.getMetadata(), copy, t.getOnParseException());
+                t.getMetadata(), copy, t.getOnParseException(), 
t.getPresetName());
         return new PipesRequest(stripped, inline.getBytes());
     }
 
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
index a211829b15..8b16047e33 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
@@ -216,7 +216,8 @@ public class ConnectionHandler implements Runnable, 
Closeable {
                         ParseContext mergedContext = null;
                         try {
                             long ctxStart = System.nanoTime();
-                            mergedContext = 
resources.createMergedParseContext(fetchEmitTuple.getParseContext());
+                            mergedContext = resources.createMergedParseContext(
+                                    fetchEmitTuple.getParseContext(), 
fetchEmitTuple.getPresetName());
                             ParseContextUtils.resolveAll(mergedContext, 
getClass().getClassLoader());
                             
ServerProtocolIO.validateParseContext(mergedContext);
                             ServerProtocolIO.clampRequestTimeoutLimits(
@@ -239,6 +240,11 @@ public class ConnectionHandler implements Runnable, 
Closeable {
 
                             loopUntilDone(fetchEmitTuple, mergedContext, 
intermediateResult, countDownLatch, parseTimeout);
                             logTiming(fetchEmitTuple.getId());
+                        } catch (PresetNotFoundException e) {
+                            // caller error, not a server fault: answer it and 
keep serving
+                            LOG.warn("handlerId={}: id={}: {}", handlerId, 
fetchEmitTuple.getId(), e.getMessage());
+                            protocolIO.writeFinished(new PipesResult(
+                                    
PipesResult.RESULT_STATUS.PRESET_NOT_FOUND, e.getMessage()));
                         } catch (TikaConfigException e) {
                             LOG.error("handlerId={}: config error processing 
request", handlerId, e);
                             handleCrash(PipesMessageType.UNSPECIFIED_CRASH, 
fetchEmitTuple.getId(), e);
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
index a8a0a2d277..df8fbb5816 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
@@ -49,6 +49,7 @@ import org.xml.sax.SAXException;
 import org.apache.tika.config.ExceptionReporting;
 import org.apache.tika.config.ParseTimeout;
 import org.apache.tika.config.TimeoutLimits;
+import org.apache.tika.config.loader.PresetRegistry;
 import org.apache.tika.config.loader.TikaJsonConfig;
 import org.apache.tika.config.loader.TikaLoader;
 import org.apache.tika.detect.Detector;
@@ -190,6 +191,7 @@ public class PipesServer implements AutoCloseable {
     private RecursiveParserWrapper rMetaParser;
     private FetcherManager fetcherManager;
     private EmitterManager emitterManager;
+    private PresetRegistry presetRegistry;
     private ConfigStore configStore;
     private final ExecutorService executorService = 
Executors.newSingleThreadExecutor();
     private final ExecutorCompletionService<PipesResult> 
executorCompletionService = new ExecutorCompletionService<>(executorService);
@@ -462,7 +464,8 @@ public class PipesServer implements AutoCloseable {
                         ParseTimeout parseTimeout;
                         long ctxStart = System.nanoTime();
                         try {
-                            mergedContext = 
createMergedParseContext(fetchEmitTuple.getParseContext());
+                            mergedContext = createMergedParseContext(
+                                    fetchEmitTuple.getParseContext(), 
fetchEmitTuple.getPresetName());
                             ParseContextUtils.resolveAll(mergedContext, 
getClass().getClassLoader());
                             
ServerProtocolIO.validateParseContext(mergedContext);
                             ServerProtocolIO.clampRequestTimeoutLimits(
@@ -476,6 +479,12 @@ public class PipesServer implements AutoCloseable {
                             // sees this instance rather than racing to 
install its own.
                             parseTimeout = 
ParseTimeout.getOrCreate(mergedContext);
                             tCtxMergeNanos = System.nanoTime() - ctxStart;
+                        } catch (PresetNotFoundException e) {
+                            // caller error, not a server fault: answer it and 
keep serving
+                            LOG.warn("id={}: {}", fetchEmitTuple.getId(), 
e.getMessage());
+                            writeFinished(new PipesResult(
+                                    
PipesResult.RESULT_STATUS.PRESET_NOT_FOUND, e.getMessage()));
+                            break;
                         } catch (Exception e) {
                             // write the reason to the client instead of a 
bare exit code
                             handleCrash(PipesMessageType.UNSPECIFIED_CRASH, 
fetchEmitTuple.getId(), e);
@@ -812,7 +821,8 @@ public class PipesServer implements AutoCloseable {
         this.autoDetectParser = (AutoDetectParser) 
tikaLoader.loadAutoDetectParser();
         this.detector = this.autoDetectParser.getDetector();
         this.rMetaParser = new RecursiveParserWrapper(autoDetectParser);
-
+        // fails startup on an unresolvable preset, mirroring the front-end's 
own load
+        this.presetRegistry = PresetRegistry.load(tikaJsonConfig, 
tikaLoader.getClassLoader());
     }
 
     /**
@@ -822,9 +832,11 @@ public class PipesServer implements AutoCloseable {
      * Creates a fresh context each time to avoid shared state between 
requests.
      *
      * @param requestContext the ParseContext from FetchEmitTuple
-     * @return a new ParseContext with defaults + request overrides
+     * @param presetName name of the preset to overlay at config-tier trust, 
or null
+     * @return a new ParseContext with defaults + preset + request overrides
      */
-    private ParseContext createMergedParseContext(ParseContext requestContext) 
throws TikaConfigException {
+    private ParseContext createMergedParseContext(ParseContext requestContext, 
String presetName)
+            throws TikaConfigException {
         // Create fresh context with defaults from tika-config (e.g., 
DigesterFactory)
         ParseContext mergedContext = tikaLoader.loadParseContext();
         // EmbeddedDocumentExtractor is deliberately left unset here: setting 
a default (even
@@ -833,12 +845,31 @@ public class PipesServer implements AutoCloseable {
         // no-ops whenever one is already bound), silently disabling embedded 
content
         // extraction for every non-UNPACK parse mode. UNPACK mode sets its own
         // EmbeddedDocumentExtractor + UnpackedByteCount in PipesWorker's 
UNPACK-mode setup.
-        // Request-level values override config defaults
+        mergePreset(presetRegistry, presetName, mergedContext);
+        // Request-level values override config defaults and the preset
         mergedContext.copyFrom(requestContext);
         seedCacheMemoryBudget(mergedContext);
         return mergedContext;
     }
 
+    /**
+     * Overlays the named preset, resolved from this server's own config at 
config-tier
+     * trust; only the name arrived on the wire. The caller's untrusted delta 
is copied
+     * on top afterwards and stays subject to wire screening and timeout 
clamping.
+     */
+    static void mergePreset(PresetRegistry registry, String presetName, 
ParseContext merged)
+            throws TikaConfigException {
+        if (presetName == null) {
+            return;
+        }
+        ParseContext presetContext = registry.newParseContext(presetName);
+        if (presetContext == null) {
+            throw new PresetNotFoundException(
+                    "No preset named '" + presetName + "' is active in this 
server's config");
+        }
+        merged.copyFrom(presetContext);
+    }
+
     private ConfigStore createConfigStore(PipesConfig pipesConfig, 
TikaPluginManager tikaPluginManager) throws TikaException {
         String configStoreType = pipesConfig.getConfigStoreType();
         String configStoreParams = pipesConfig.getConfigStoreParams();
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PresetNotFoundException.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PresetNotFoundException.java
new file mode 100644
index 0000000000..6d03da7b0f
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PresetNotFoundException.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.pipes.core.server;
+
+import org.apache.tika.exception.TikaConfigException;
+
+/**
+ * A request named a preset this server's config does not activate: a caller 
error,
+ * answered with a {@code PRESET_NOT_FOUND} result rather than the crash path.
+ *
+ * @since Apache Tika 4.1.0
+ */
+public class PresetNotFoundException extends TikaConfigException {
+
+    public PresetNotFoundException(String msg) {
+        super(msg);
+    }
+}
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
index 21371d2ca9..5c97818673 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
@@ -21,6 +21,7 @@ import java.io.IOException;
 import org.xml.sax.SAXException;
 
 import org.apache.tika.config.ExceptionReporting;
+import org.apache.tika.config.loader.PresetRegistry;
 import org.apache.tika.config.loader.TikaJsonConfig;
 import org.apache.tika.config.loader.TikaLoader;
 import org.apache.tika.detect.Detector;
@@ -66,6 +67,7 @@ public class SharedServerResources {
     private final EmitStrategy emitStrategy;
     private final ConfigStore configStore;
     private final ExceptionReporting exceptionReporting;
+    private final PresetRegistry presetRegistry;
 
     private SharedServerResources(TikaLoader tikaLoader, PipesConfig 
pipesConfig,
                                   AutoDetectParser autoDetectParser, Detector 
detector,
@@ -74,7 +76,8 @@ public class SharedServerResources {
                                   ContentHandlerFactory 
defaultContentHandlerFactory,
                                   MetadataWriteLimiterFactory 
defaultMetadataWriteLimiterFactory,
                                   EmitStrategy emitStrategy, ConfigStore 
configStore,
-                                  ExceptionReporting exceptionReporting) {
+                                  ExceptionReporting exceptionReporting,
+                                  PresetRegistry presetRegistry) {
         this.tikaLoader = tikaLoader;
         this.pipesConfig = pipesConfig;
         this.autoDetectParser = autoDetectParser;
@@ -88,6 +91,7 @@ public class SharedServerResources {
         this.emitStrategy = emitStrategy;
         this.configStore = configStore;
         this.exceptionReporting = exceptionReporting;
+        this.presetRegistry = presetRegistry;
     }
 
     /**
@@ -127,10 +131,14 @@ public class SharedServerResources {
 
         EmitStrategy emitStrategy = pipesConfig.getEmitStrategy().getType();
 
+        // fails startup on an unresolvable preset, mirroring the front-end's 
own load
+        PresetRegistry presetRegistry =
+                PresetRegistry.load(tikaJsonConfig, 
tikaLoader.getClassLoader());
+
         return new SharedServerResources(tikaLoader, pipesConfig, 
autoDetectParser, detector,
                 rMetaParser, fetcherManager, emitterManager, metadataFilter, 
contentHandlerFactory,
                 metadataWriteLimiterFactory, emitStrategy, configStore,
-                ExceptionReporting.get(configContext));
+                ExceptionReporting.get(configContext), presetRegistry);
     }
 
     private static ConfigStore createConfigStore(PipesConfig pipesConfig, 
TikaPluginManager tikaPluginManager)
@@ -155,14 +163,17 @@ public class SharedServerResources {
      * Creates a merged ParseContext with defaults from tika-config overlaid 
with request values.
      *
      * @param requestContext the ParseContext from FetchEmitTuple
-     * @return a new ParseContext with defaults + request overrides
+     * @param presetName name of the preset to overlay at config-tier trust, 
or null
+     * @return a new ParseContext with defaults + preset + request overrides
      */
-    public ParseContext createMergedParseContext(ParseContext requestContext) 
throws TikaConfigException {
+    public ParseContext createMergedParseContext(ParseContext requestContext, 
String presetName)
+            throws TikaConfigException {
         ParseContext mergedContext = tikaLoader.loadParseContext();
         // EmbeddedDocumentExtractor is deliberately left unset here -- see 
PipesServer's
         // createMergedParseContext for why defaulting it would silently 
disable embedded
         // content extraction for every non-UNPACK parse mode.
-        // Request-level values override config defaults
+        PipesServer.mergePreset(presetRegistry, presetName, mergedContext);
+        // Request-level values override config defaults and the preset
         mergedContext.copyFrom(requestContext);
         PipesServer.seedCacheMemoryBudget(mergedContext);
         return mergedContext;
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/JsonFetchEmitTupleTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/JsonFetchEmitTupleTest.java
index f029c3e4a0..abba24cb22 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/JsonFetchEmitTupleTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/JsonFetchEmitTupleTest.java
@@ -18,6 +18,8 @@ package org.apache.tika.pipes.core.serialization;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.io.Reader;
 import java.io.StringReader;
@@ -157,4 +159,29 @@ public class JsonFetchEmitTupleTest {
         assertEquals(unpackConfig.getSuffixStrategy(), 
deserializedConfig.getSuffixStrategy(),
                 "suffixStrategy should be preserved");
     }
+
+    @Test
+    public void testPresetNameRoundTrips() throws Exception {
+        FetchEmitTuple t = new FetchEmitTuple("my_id", new 
FetchKey("my_fetcher", "k"),
+                new EmitKey("my_emitter", "e"), new Metadata(), new 
ParseContext(),
+                FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT, "ocr-heavy");
+        FetchEmitTuple deserialized =
+                JsonFetchEmitTuple.fromJson(new 
StringReader(JsonFetchEmitTuple.toJson(t)));
+        assertEquals("ocr-heavy", deserialized.getPresetName());
+
+        FetchEmitTuple noPreset = new FetchEmitTuple("my_id", new 
FetchKey("my_fetcher", "k"),
+                new EmitKey("my_emitter", "e"));
+        assertNull(JsonFetchEmitTuple
+                .fromJson(new 
StringReader(JsonFetchEmitTuple.toJson(noPreset)))
+                .getPresetName());
+    }
+
+    @Test
+    public void testInvalidPresetNameRefused() {
+        // the name is only a selector, but it must obey the shared syntax 
bound
+        assertThrows(Exception.class, () -> JsonFetchEmitTuple.fromJson(new 
StringReader(
+                
"{\"id\":\"i\",\"fetcher\":\"f\",\"fetchKey\":\"k\",\"preset\":\"../etc\"}")));
+        assertThrows(Exception.class, () -> JsonFetchEmitTuple.fromJson(new 
StringReader(
+                
"{\"id\":\"i\",\"fetcher\":\"f\",\"fetchKey\":\"k\",\"preset\":\"config-x\"}")));
+    }
 }
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
new file mode 100644
index 0000000000..6be2dbf8fe
--- /dev/null
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.pipes.core.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.config.TimeoutLimits;
+import org.apache.tika.config.loader.PresetRegistry;
+import org.apache.tika.config.loader.TikaJsonConfig;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.serialization.ParseContextUtils;
+
+/**
+ * Preset content is operator config resolved at config-tier trust in the 
worker:
+ * a preset's timeout limits must survive {@code clampRequestTimeoutLimits} 
(only
+ * request-supplied limits are clamped), and an unknown name is a task-level 
error,
+ * not a crash.
+ */
+public class PresetMergeTest {
+
+    @TempDir
+    Path tmp;
+
+    private PresetRegistry registry(String configJson) throws Exception {
+        Path p = tmp.resolve("config-" + configJson.hashCode() + ".json");
+        Files.writeString(p, configJson);
+        return PresetRegistry.load(TikaJsonConfig.load(p), 
getClass().getClassLoader());
+    }
+
+    @Test
+    public void testPresetTimeoutLimitsAreNotClamped() throws Exception {
+        PresetRegistry registry = registry("""
+                {"presets": {"slow-ocr": {"timeout-limits": 
{"totalTaskTimeoutMillis": 3600000}}}}
+                """);
+        ParseContext merged = new ParseContext();
+        PipesServer.mergePreset(registry, "slow-ocr", merged);
+        ParseContext requestContext = new ParseContext();
+        merged.copyFrom(requestContext);
+        ParseContextUtils.resolveAll(merged, getClass().getClassLoader());
+
+        // the clamp fires only on request-supplied limits; the preset's ride 
at config tier
+        ServerProtocolIO.clampRequestTimeoutLimits(requestContext, merged, 
60_000);
+        assertEquals(3600000, 
TimeoutLimits.get(merged).getTotalTaskTimeoutMillis());
+    }
+
+    @Test
+    public void testRequestLimitsStillClampedOverPreset() throws Exception {
+        PresetRegistry registry = registry("""
+                {"presets": {"slow-ocr": {"timeout-limits": 
{"totalTaskTimeoutMillis": 3600000}}}}
+                """);
+        ParseContext merged = new ParseContext();
+        PipesServer.mergePreset(registry, "slow-ocr", merged);
+        ParseContext requestContext = new ParseContext();
+        requestContext.setJsonConfig("timeout-limits",
+                "{\"totalTaskTimeoutMillis\": 7200000}");
+        merged.copyFrom(requestContext);
+        ParseContextUtils.resolveAll(merged, getClass().getClassLoader());
+
+        ServerProtocolIO.clampRequestTimeoutLimits(requestContext, merged, 
60_000);
+        assertEquals(60_000, 
TimeoutLimits.get(merged).getTotalTaskTimeoutMillis());
+    }
+
+    @Test
+    public void testUnknownPresetIsTaskLevelError() throws Exception {
+        PresetRegistry registry = registry("{}");
+        assertThrows(PresetNotFoundException.class,
+                () -> PipesServer.mergePreset(registry, "nope", new 
ParseContext()));
+    }
+}
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/PresetRegistry.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/PresetRegistry.java
new file mode 100644
index 0000000000..5d9cc33459
--- /dev/null
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/PresetRegistry.java
@@ -0,0 +1,259 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.ContentHandlerFactory;
+import org.apache.tika.serialization.ParseContextUtils;
+import org.apache.tika.serialization.serdes.ParseContextDeserializer;
+
+/**
+ * Named, vetted parse-context fragments a caller selects whole, by name only 
("presets").
+ * A preset is operator config, not caller input: resolved with the same trust 
as the
+ * config's own {@code parse-context} block, and fully resolved at load so a 
bad preset
+ * fails startup, not its first request.
+ * <p>
+ * Nothing is active unless the config's {@code presets} block names it: 
{@code true}
+ * activates the classpath-catalog definition of that name (error if absent), 
an object
+ * defines the preset in place (replacing any catalog definition wholesale),
+ * {@code false}/{@code null} is an explicit no-op. Catalog jars can never 
activate
+ * themselves. Presets do not compose.
+ * <p>
+ * The catalog is discovered from {@code 
META-INF/tika/preset-catalog.properties}
+ * resources (hand-authored, unlike the annotation processor's generated
+ * {@code META-INF/tika/*.idx}), each line {@code 
name=/classpath/resource.json};
+ * blank lines and {@code #} comments ignored.
+ * <pre>
+ * "presets": {
+ *   "some-catalog-preset": true,
+ *   "ocr-heavy": { "pdf-parser": { "ocr": { "strategy": 
"OCR_AND_TEXT_EXTRACTION" } } }
+ * }
+ * </pre>
+ *
+ * @since Apache Tika 4.1.0
+ */
+public final class PresetRegistry {
+
+    public static final String CONFIG_KEY = "presets";
+
+    private static final String INDEX_RESOURCE = 
"META-INF/tika/preset-catalog.properties";
+
+    // Names ride in URL paths and config keys
+    private static final Pattern NAME = 
Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,99}");
+
+    private final Map<String, JsonNode> presets;
+    private final Set<String> withContentHandlerFactory;
+    private final ClassLoader classLoader;
+
+    private PresetRegistry(Map<String, JsonNode> presets, Set<String> 
withContentHandlerFactory,
+                           ClassLoader classLoader) {
+        this.presets = presets;
+        this.withContentHandlerFactory = withContentHandlerFactory;
+        this.classLoader = classLoader;
+    }
+
+    /**
+     * Builds the active roster from the config's {@code presets} block (see 
the class
+     * javadoc for the value semantics), resolving every active preset.
+     *
+     * @param config the loaded config, may be null (empty roster)
+     * @param classLoader for catalog scanning and component resolution; null 
for the
+     *                    thread context loader
+     */
+    public static PresetRegistry load(TikaJsonConfig config, ClassLoader 
classLoader)
+            throws TikaConfigException {
+        ClassLoader loader = classLoader != null ? classLoader
+                : Thread.currentThread().getContextClassLoader();
+        Map<String, JsonNode> presets = new LinkedHashMap<>();
+        if (config != null && config.hasKey(CONFIG_KEY)) {
+            JsonNode block = config.getRootNode().get(CONFIG_KEY);
+            if (block == null || !block.isObject()) {
+                throw new TikaConfigException(
+                        "'" + CONFIG_KEY + "' must be an object of preset 
definitions");
+            }
+            // load the inert catalog only when the config can reference it
+            Map<String, JsonNode> catalog = loadCatalog(loader);
+            Iterator<Map.Entry<String, JsonNode>> fields = block.fields();
+            while (fields.hasNext()) {
+                Map.Entry<String, JsonNode> e = fields.next();
+                String name = e.getKey();
+                JsonNode value = e.getValue();
+                if (value.isNull() || (value.isBoolean() && 
!value.asBoolean())) {
+                    continue; // explicit no-op
+                } else if (value.isBoolean()) {
+                    JsonNode content = catalog.get(name);
+                    if (content == null) {
+                        throw new TikaConfigException("preset '" + name +
+                                "': true activates a catalog preset, but no 
catalog " +
+                                "on the classpath defines that name");
+                    }
+                    presets.put(validName(name), content);
+                } else if (value.isObject()) {
+                    presets.put(validName(name), value);
+                } else {
+                    throw new TikaConfigException("preset '" + name + "' must 
be an " +
+                            "object, true (activate catalog definition), or 
false/null");
+                }
+            }
+        }
+        Set<String> withContentHandlerFactory = new HashSet<>();
+        for (Map.Entry<String, JsonNode> e : presets.entrySet()) {
+            ParseContext resolved = resolve(e.getKey(), e.getValue(), loader);
+            if (resolved.get(ContentHandlerFactory.class) != null) {
+                withContentHandlerFactory.add(e.getKey());
+            }
+        }
+        return new PresetRegistry(presets, withContentHandlerFactory, loader);
+    }
+
+    // Trusted-tier resolution: operator config, so no wire-block screening.
+    private static ParseContext resolve(String name, JsonNode content, 
ClassLoader loader)
+            throws TikaConfigException {
+        try {
+            ParseContext context = 
ParseContextDeserializer.readParseContext(content, false);
+            ParseContextUtils.resolveAll(context, loader);
+            return context;
+        } catch (IOException | TikaConfigException e) {
+            throw new TikaConfigException(
+                    "preset '" + name + "' failed to resolve: " + 
e.getMessage(), e);
+        }
+    }
+
+    private static Map<String, JsonNode> loadCatalog(ClassLoader loader)
+            throws TikaConfigException {
+        Map<String, JsonNode> presets = new LinkedHashMap<>();
+        Map<String, URL> sources = new LinkedHashMap<>();
+        // Same JSON dialect as the config itself (comments allowed, duplicate 
keys refused)
+        ObjectMapper mapper = TikaObjectMapperFactory.getMapper();
+        try {
+            Enumeration<URL> indexes = loader.getResources(INDEX_RESOURCE);
+            while (indexes.hasMoreElements()) {
+                URL index = indexes.nextElement();
+                String indexContent;
+                try (InputStream is = index.openStream()) {
+                    indexContent = new String(is.readAllBytes(), 
StandardCharsets.UTF_8);
+                }
+                for (String line : indexContent.split("\n")) {
+                    line = line.trim();
+                    if (line.isEmpty() || line.startsWith("#")) {
+                        continue;
+                    }
+                    int eq = line.indexOf('=');
+                    if (eq <= 0) {
+                        throw new TikaConfigException(
+                                "bad line in " + index + ": " + line);
+                    }
+                    String name = validName(line.substring(0, eq).trim());
+                    String resource = line.substring(eq + 1).trim();
+                    try (InputStream is = loader.getResourceAsStream(
+                            stripLeadingSlash(resource))) {
+                        if (is == null) {
+                            throw new TikaConfigException("preset '" + name +
+                                    "' names a missing resource: " + resource);
+                        }
+                        JsonNode content = mapper.readTree(is);
+                        if (!content.isObject()) {
+                            throw new TikaConfigException("preset '" + name +
+                                    "' must contain a JSON object: " + 
resource);
+                        }
+                        JsonNode previous = presets.put(name, content);
+                        // classpath order is not a config statement: refuse a 
silent last-wins
+                        if (previous != null && !previous.equals(content)) {
+                            throw new TikaConfigException("catalog preset '" + 
name +
+                                    "' is defined with different content by " +
+                                    sources.get(name) + " and " + index);
+                        }
+                        sources.put(name, index);
+                    }
+                }
+            }
+        } catch (IOException e) {
+            throw new TikaConfigException("failed to load the preset catalog", 
e);
+        }
+        return presets;
+    }
+
+    private static String stripLeadingSlash(String resource) {
+        return resource.startsWith("/") ? resource.substring(1) : resource;
+    }
+
+    private static String validName(String name) throws TikaConfigException {
+        if (!isValidName(name)) {
+            throw new TikaConfigException("invalid preset name (letters, 
digits, " +
+                    "'.', '_', '-'; max 100 chars; may not start with 
'config', which is " +
+                    "reserved so preset URL routes stay distinct from /config 
endpoint " +
+                    "gating): '" + name + "'");
+        }
+        return name;
+    }
+
+    /**
+     * Legal preset name: the NAME rule, and not "config"-prefixed 
(tika-server gates
+     * {@code /config} endpoints on that path fragment). Public so wire 
deserializers
+     * can bound a preset-name field with the same rule.
+     */
+    public static boolean isValidName(String name) {
+        return name != null && NAME.matcher(name).matches()
+                && !name.regionMatches(true, 0, "config", 0, 6);
+    }
+
+    public Set<String> names() {
+        return Collections.unmodifiableSet(presets.keySet());
+    }
+
+    public boolean hasPreset(String name) {
+        return name != null && presets.containsKey(name);
+    }
+
+    /** The preset's {@code parse-context}-shaped JSON, or null for an unknown 
name. */
+    public String parseContextJson(String name) {
+        JsonNode node = name == null ? null : presets.get(name);
+        return node == null ? null : node.toString();
+    }
+
+    /**
+     * A fully resolved ParseContext for the preset, or null for an unknown 
name.
+     * Fresh per call: callers mutate the result per request.
+     */
+    public ParseContext newParseContext(String name) throws 
TikaConfigException {
+        JsonNode content = name == null ? null : presets.get(name);
+        return content == null ? null : resolve(name, content, classLoader);
+    }
+
+    /** True if the preset binds a {@link ContentHandlerFactory} (it then owns 
the
+     * output format on routes with no explicit format segment). */
+    public boolean suppliesContentHandlerFactory(String name) {
+        return name != null && withContentHandlerFactory.contains(name);
+    }
+}
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
index 04af3fb1c9..fe8b0351b5 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
@@ -117,6 +117,7 @@ public class TikaJsonConfig {
             "translator",
             "auto-detect-parser",
             "parse-context",
+            "presets",
             "server",
             "grpc",
 
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/PresetRegistryTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/PresetRegistryTest.java
new file mode 100644
index 0000000000..6c545e35ce
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/PresetRegistryTest.java
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.config.ExceptionReporting;
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.ParseContext;
+
+public class PresetRegistryTest {
+
+    @TempDir
+    Path tmp;
+
+    private TikaJsonConfig config(String json) throws Exception {
+        Path p = tmp.resolve("config-" + json.hashCode() + ".json");
+        Files.writeString(p, json);
+        return TikaJsonConfig.load(p);
+    }
+
+    private PresetRegistry load(String json) throws Exception {
+        return PresetRegistry.load(config(json), getClass().getClassLoader());
+    }
+
+    @Test
+    public void testCatalogPresetInertUntilActivated() throws Exception {
+        // src/test/resources/META-INF/tika/preset-catalog.properties 
contributes builtin-sample,
+        // but a catalog jar must never activate itself
+        PresetRegistry registry = PresetRegistry.load(null, 
getClass().getClassLoader());
+        assertTrue(registry.names().isEmpty());
+        assertNull(registry.parseContextJson("builtin-sample"));
+
+        assertNull(load("{}").parseContextJson("builtin-sample"));
+    }
+
+    @Test
+    public void testTrueActivatesCatalogDefinition() throws Exception {
+        PresetRegistry registry = load("""
+                {"presets": {"builtin-sample": true}}
+                """);
+        JsonNode content =
+                new 
ObjectMapper().readTree(registry.parseContextJson("builtin-sample"));
+        assertEquals("TEXT", 
content.get("basic-content-handler-factory").get("type").asText());
+    }
+
+    @Test
+    public void testTrueWithoutCatalogDefinitionFailsStartup() {
+        assertThrows(TikaConfigException.class, () -> load("""
+                {"presets": {"no-such-catalog-entry": true}}
+                """));
+    }
+
+    @Test
+    public void testConfigDefinesPreset() throws Exception {
+        PresetRegistry registry = load("""
+                {"presets": {"my-preset": {"basic-content-handler-factory": 
{"type": "XML"}}}}
+                """);
+        JsonNode content = new 
ObjectMapper().readTree(registry.parseContextJson("my-preset"));
+        assertEquals("XML", 
content.get("basic-content-handler-factory").get("type").asText());
+    }
+
+    @Test
+    public void testConfigOverridesCatalogDefinitionWholesale() throws 
Exception {
+        PresetRegistry registry = load("""
+                {"presets": {"builtin-sample": {"embedded-limits": 
{"maxDepth": 2}}}}
+                """);
+        JsonNode content =
+                new 
ObjectMapper().readTree(registry.parseContextJson("builtin-sample"));
+        assertNull(content.get("basic-content-handler-factory"),
+                "an override replaces the whole preset, it does not merge");
+        assertEquals(2, 
content.get("embedded-limits").get("maxDepth").asInt());
+    }
+
+    @Test
+    public void testFalseAndNullAreExplicitNoOps() throws Exception {
+        PresetRegistry registry = load("""
+                {"presets": {"builtin-sample": false, "other": null}}
+                """);
+        assertFalse(registry.names().contains("builtin-sample"));
+        assertNull(registry.parseContextJson("builtin-sample"));
+        assertNull(registry.parseContextJson("other"));
+    }
+
+    @Test
+    public void testUnknownPresetIsNull() throws Exception {
+        assertNull(load("{}").parseContextJson("nope"));
+        assertNull(load("{}").parseContextJson(null));
+    }
+
+    @Test
+    public void testInvalidNameRejected() {
+        assertThrows(TikaConfigException.class, () -> load("""
+                {"presets": {"bad/name": {}}}
+                """));
+    }
+
+    @Test
+    public void testConfigPrefixedNameRejected() {
+        // tika-server gates /config endpoints on the path fragment, so such a
+        // preset would be unreachable there; refuse it at definition time
+        assertThrows(TikaConfigException.class, () -> load("""
+                {"presets": {"config-fast": {}}}
+                """));
+        assertThrows(TikaConfigException.class, () -> load("""
+                {"presets": {"CONFIGX": {}}}
+                """));
+        assertTrue(PresetRegistry.isValidName("fast-config"));
+    }
+
+    @Test
+    public void testUnresolvablePresetFailsLoad() {
+        // a known component with malformed content must fail at load, not 
first use
+        assertThrows(TikaConfigException.class, () -> load("""
+                {"presets": {"bad": {"basic-content-handler-factory": {"type": 
"NO_SUCH_TYPE"}}}}
+                """));
+    }
+
+    @Test
+    public void testNewParseContextIsTrustedAndFresh() throws Exception {
+        // exception-reporting is wire-blocked for caller-supplied contexts; a 
preset is
+        // operator config and must be able to bind it
+        PresetRegistry registry = load("""
+                {"presets": {"reporting": {"exception-reporting": 
{"maxLength": 512}}}}
+                """);
+        ParseContext first = registry.newParseContext("reporting");
+        assertTrue(first.get(ExceptionReporting.class) != null);
+        // fresh per call: callers mutate the result per request
+        assertTrue(first != registry.newParseContext("reporting"));
+        assertNull(registry.newParseContext("nope"));
+        assertNull(registry.newParseContext(null));
+    }
+
+    @Test
+    public void testSuppliesContentHandlerFactory() throws Exception {
+        PresetRegistry registry = load("""
+                {"presets": {
+                  "with-chf": {"basic-content-handler-factory": {"type": 
"XML"}},
+                  "without-chf": {"embedded-limits": {"maxDepth": 2}}}}
+                """);
+        assertTrue(registry.suppliesContentHandlerFactory("with-chf"));
+        assertFalse(registry.suppliesContentHandlerFactory("without-chf"));
+        assertFalse(registry.suppliesContentHandlerFactory("nope"));
+        assertFalse(registry.suppliesContentHandlerFactory(null));
+    }
+
+    @Test
+    public void testCatalogNameCollisionAcrossJarsFails() throws Exception {
+        Path dirA = catalogDir("a", "colliding", "{\"embedded-limits\": 
{\"maxDepth\": 1}}");
+        Path dirB = catalogDir("b", "colliding", "{\"embedded-limits\": 
{\"maxDepth\": 2}}");
+        // parent is the test loader, so component classes still resolve; its 
own
+        // catalog contributes only the distinct builtin-sample name
+        try (URLClassLoader loader = new URLClassLoader(
+                new URL[]{dirA.toUri().toURL(), dirB.toUri().toURL()},
+                getClass().getClassLoader())) {
+            assertThrows(TikaConfigException.class, () -> PresetRegistry.load(
+                    config("{\"presets\": {\"colliding\": true}}"), loader));
+        }
+    }
+
+    @Test
+    public void testCatalogIdenticalDuplicateTolerated() throws Exception {
+        // the same jar visible twice on a classpath is noise, not a conflict
+        Path dirA = catalogDir("a2", "dup", "{\"embedded-limits\": 
{\"maxDepth\": 3}}");
+        Path dirB = catalogDir("b2", "dup", "{\"embedded-limits\": 
{\"maxDepth\": 3}}");
+        try (URLClassLoader loader = new URLClassLoader(
+                new URL[]{dirA.toUri().toURL(), dirB.toUri().toURL()},
+                getClass().getClassLoader())) {
+            PresetRegistry registry = PresetRegistry.load(
+                    config("{\"presets\": {\"dup\": true}}"), loader);
+            assertTrue(registry.hasPreset("dup"));
+        }
+    }
+
+    private Path catalogDir(String dirName, String presetName, String json) 
throws Exception {
+        Path dir = tmp.resolve(dirName);
+        Files.createDirectories(dir.resolve("META-INF/tika"));
+        // resource path unique per dir: identical paths would shadow on the 
classpath
+        
Files.writeString(dir.resolve("META-INF/tika/preset-catalog.properties"),
+                presetName + "=/presets-" + dirName + "/" + presetName + 
".json\n");
+        Files.createDirectories(dir.resolve("presets-" + dirName));
+        Files.writeString(dir.resolve("presets-" + dirName + "/" + presetName 
+ ".json"), json);
+        return dir;
+    }
+
+    @Test
+    public void testNonObjectPresetRejected() {
+        assertThrows(TikaConfigException.class, () -> load("""
+                {"presets": {"bad": "a string"}}
+                """));
+        assertThrows(TikaConfigException.class, () -> load("""
+                {"presets": {"bad": 42}}
+                """));
+    }
+
+    @Test
+    public void testNonObjectPresetsBlockRejected() {
+        assertThrows(TikaConfigException.class, () -> load("""
+                {"presets": ["not", "an", "object"]}
+                """));
+    }
+}
diff --git 
a/tika-serialization/src/test/resources/META-INF/tika/preset-catalog.properties 
b/tika-serialization/src/test/resources/META-INF/tika/preset-catalog.properties
new file mode 100644
index 0000000000..2a6a4d3316
--- /dev/null
+++ 
b/tika-serialization/src/test/resources/META-INF/tika/preset-catalog.properties
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+
+# test built-in preset
+builtin-sample=/test-presets/builtin-sample.json
diff --git 
a/tika-serialization/src/test/resources/test-presets/builtin-sample.json 
b/tika-serialization/src/test/resources/test-presets/builtin-sample.json
new file mode 100644
index 0000000000..938e7a78d5
--- /dev/null
+++ b/tika-serialization/src/test/resources/test-presets/builtin-sample.json
@@ -0,0 +1,4 @@
+{
+  // catalog files use the same JSON dialect as the config: comments allowed
+  "basic-content-handler-factory": {"type": "TEXT"}
+}
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
index 967aabbca7..8d886b7a9b 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
@@ -238,6 +238,8 @@ public class PipesParsingHelper {
                 parseContext.set(ContentBytesConfig.class, new 
ContentBytesConfig());
             }
 
+            String presetName = liftPresetSelection(parseContext);
+
             // This parser is shared with /pipes, whose own default is 
EMIT_ALL. No
             // emitter is configured for /tika/rmeta/unpack requests 
(EmitKey.NO_EMIT
             // below) -- results must come back over the socket, so set 
PASSBACK_ALL
@@ -249,7 +251,9 @@ public class PipesParsingHelper {
                     fetchKey,
                     EmitKey.NO_EMIT,
                     metadata,
-                    parseContext
+                    parseContext,
+                    FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT,
+                    presetName
             );
 
             // Execute parse via pipes - results will be passed back through 
socket
@@ -285,6 +289,16 @@ public class PipesParsingHelper {
         }
     }
 
+    // Only the name travels: the worker resolves the preset from its own 
config.
+    private static String liftPresetSelection(ParseContext parseContext) {
+        PresetSelection preset = parseContext.get(PresetSelection.class);
+        if (preset == null) {
+            return null;
+        }
+        parseContext.set(PresetSelection.class, null);
+        return preset.name();
+    }
+
     /**
      * One line per request on {@code org.apache.tika.pipes.timing.server}, 
microseconds.
      * {@code route_us} covers reading the request body and deciding 
inline-vs-spool;
@@ -481,7 +495,7 @@ public class PipesParsingHelper {
             // The caller named a fetcher/emitter this server does not have. 
Nothing failed
             // on our side, and retrying the same request will never succeed 
-- 500 told
             // clients to retry a request that is permanently malformed.
-            case FETCHER_NOT_FOUND, EMITTER_NOT_FOUND ->
+            case FETCHER_NOT_FOUND, EMITTER_NOT_FOUND, PRESET_NOT_FOUND ->
                     Response.Status.BAD_REQUEST;
             case PAYLOAD_LIMIT_EXCEEDED ->
                     Response.Status.REQUEST_ENTITY_TOO_LARGE;
@@ -629,6 +643,8 @@ public class PipesParsingHelper {
             // Set parse mode to UNPACK
             parseContext.set(ParseMode.class, ParseMode.UNPACK);
 
+            String presetName = liftPresetSelection(parseContext);
+
             // Shared parser (see parse() above) -- PASSBACK_ALL is also 
required here
             // for correctness: with UNPACK mode, EmitHandler.shouldEmit() 
only skips
             // re-emitting metadata (already emitted as part of the zip) when 
the
@@ -665,7 +681,9 @@ public class PipesParsingHelper {
                 fetchKey,
                 emitKey,
                 metadata,
-                parseContext
+                parseContext,
+                FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT,
+                presetName
         );
 
             // Execute parse via pipes
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PresetSelection.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PresetSelection.java
new file mode 100644
index 0000000000..f1360a73ad
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PresetSelection.java
@@ -0,0 +1,28 @@
+/*
+ * 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.server.core.resource;
+
+/**
+ * In-process carrier for a request's selected preset name: rides the request
+ * ParseContext until {@link PipesParsingHelper} lifts it onto the tuple's 
preset
+ * field. Never serialized (the wire serializer refuses unregistered entries, 
so a
+ * leak fails loudly).
+ *
+ * @since Apache Tika 4.1.0
+ */
+public record PresetSelection(String name) {
+}
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
index 4bd0b95f21..4d8091d85e 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
@@ -137,6 +137,45 @@ public class RecursiveMetadataResource {
         }
     }
 
+    /** As the bare {@code /rmeta} endpoint, with the named preset applied. */
+    @PUT
+    @Produces("application/json")
+    @Path("preset/{presetName}")
+    public Response getMetadataWithPresetDefaultHandler(InputStream is,
+                                                        @Context HttpHeaders 
httpHeaders,
+                                                        
@PathParam("presetName") String presetName)
+            throws Exception {
+        return getMetadataWithPreset(is, httpHeaders, presetName, null);
+    }
+
+    /**
+     * As {@code /rmeta/{handlerType}}, with the named preset's parse-context
+     * fragment applied. Takes no config part -- a preset never combines with
+     * request-supplied configuration.
+     */
+    @PUT
+    @Produces("application/json")
+    @Path("preset/{presetName}/{" + HANDLER_TYPE_PARAM + " : (\\w+)?}")
+    public Response getMetadataWithPreset(InputStream is, @Context HttpHeaders 
httpHeaders,
+                                          @PathParam("presetName") String 
presetName,
+                                          @PathParam(HANDLER_TYPE_PARAM) 
String handlerTypeName)
+            throws Exception {
+        ParseContext context = tikaResource.createPresetContext(presetName);
+        // Explicit format segment wins over the preset's own factory; without 
one,
+        // parseMetadataWithContext defers preset -> config -> default.
+        if (handlerTypeName != null && !handlerTypeName.isBlank()) {
+            tikaResource.setupContentHandlerFactory(context, handlerTypeName);
+        }
+        Metadata metadata = tikaResource.newRequestMetadata();
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+            TikaResource.logRequest(LOG, "/rmeta", metadata);
+            return Response
+                    .ok(parseMetadataWithContext(tis, metadata, 
handlerTypeName, context))
+                    .build();
+        }
+    }
+
     private MetadataList parseMetadataWithContext(TikaInputStream tis, 
Metadata metadata,
                                                   String handlerTypeName, 
ParseContext context) throws Exception {
         tikaResource.setupContentHandlerFactoryIfNeeded(context, 
handlerTypeName);
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
index b955bde571..3b4e9b01d3 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
@@ -31,6 +31,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import jakarta.ws.rs.BadRequestException;
 import jakarta.ws.rs.Consumes;
 import jakarta.ws.rs.GET;
+import jakarta.ws.rs.NotFoundException;
 import jakarta.ws.rs.POST;
 import jakarta.ws.rs.PUT;
 import jakarta.ws.rs.Path;
@@ -52,6 +53,7 @@ import org.apache.tika.Tika;
 import org.apache.tika.config.ExceptionReporting;
 import org.apache.tika.config.JsonConfig;
 import org.apache.tika.config.OutputLimits;
+import org.apache.tika.config.loader.PresetRegistry;
 import org.apache.tika.config.loader.TikaLoader;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.io.TikaInputStream;
@@ -94,6 +96,9 @@ public class TikaResource {
     private final ExceptionReporting configExceptionReporting;
     private final boolean configSuppliesContentHandlerFactory;
 
+    // Named, vetted parse-context fragments; requests select one whole by name
+    private final PresetRegistry presetRegistry;
+
     /**
      * @param tikaLoader the Tika loader
      * @param serverStatus server status tracker
@@ -113,6 +118,31 @@ public class TikaResource {
         this.configExceptionReporting = ExceptionReporting.get(configDefaults);
         this.configSuppliesContentHandlerFactory =
                 configDefaults.get(ContentHandlerFactory.class) != null;
+        try {
+            this.presetRegistry = PresetRegistry.load(tikaLoader.getConfig(),
+                    tikaLoader.getClassLoader());
+        } catch (TikaConfigException e) {
+            // config error (including a preset that cannot resolve): fail 
startup,
+            // not the first preset request
+            throw new IllegalStateException("Invalid 'presets' configuration", 
e);
+        }
+    }
+
+    /**
+     * A request context carrying only the preset selection: the forked worker 
resolves
+     * the content from its own copy of this config at config-tier trust, so a 
preset is
+     * never treated as caller-supplied wire data (which would screen out 
wire-blocked
+     * components and clamp its timeouts).
+     *
+     * @throws NotFoundException if no preset has this name
+     */
+    public ParseContext createPresetContext(String presetName) {
+        if (!presetRegistry.hasPreset(presetName)) {
+            throw new NotFoundException("No such preset: " + presetName);
+        }
+        ParseContext context = createRequestContext();
+        context.set(PresetSelection.class, new PresetSelection(presetName));
+        return context;
     }
 
     /**
@@ -477,11 +507,19 @@ public class TikaResource {
      * @param handlerTypeName the handler type name
      */
     public void setupContentHandlerFactoryIfNeeded(ParseContext context, 
String handlerTypeName) {
+        if (context.get(ContentHandlerFactory.class) != null) {
+            return;
+        }
+        // A selected preset that binds its own factory decides the format on 
routes with no
+        // explicit format segment; the worker resolves it from the preset at 
config tier.
+        PresetSelection preset = context.get(PresetSelection.class);
+        if (preset != null && 
presetRegistry.suppliesContentHandlerFactory(preset.name())) {
+            return;
+        }
         // A config-declared factory still takes precedence; it is no longer 
visible in the
         // request context, so leaving the context untouched lets the worker 
resolve it from
         // the same config.
-        if (context.get(ContentHandlerFactory.class) == null
-                && !configSuppliesContentHandlerFactory) {
+        if (!configSuppliesContentHandlerFactory) {
             setupContentHandlerFactory(context, handlerTypeName);
         }
     }
@@ -592,6 +630,123 @@ public class TikaResource {
         return putJson(is, httpHeaders, handlerTypeName);
     }
 
+    // ==================== PUT preset endpoints ====================
+
+    // Mirrors of the PUT endpoints above: 
/tika/preset/{name}[/text|/html|/xml|/md|
+    // /json[/{handlerType}]]. The preset segment sits directly after the 
resource root
+    // so network-layer rules can address /tika/preset/* independently of 
/tika/config*.
+    // These routes take no config part; a preset never combines with request 
config.
+    // explicitHandlerType: non-null (an explicit format segment) wins over 
everything,
+    // including the preset's own factory; null defers preset -> config -> 
default.
+
+    private Response putRawPreset(InputStream is, HttpHeaders httpHeaders, 
String presetName,
+                                  String explicitHandlerType) throws 
IOException {
+        ParseContext context = createPresetContext(presetName);
+        Metadata metadata = newRequestMetadata();
+        fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+        if (explicitHandlerType != null) {
+            setupContentHandlerFactory(context, explicitHandlerType);
+        }
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            return produceRawOutputWithContext(tis, metadata, context, 
explicitHandlerType);
+        }
+    }
+
+    private Metadata putJsonPreset(InputStream is, HttpHeaders httpHeaders, 
String presetName,
+                                   String explicitHandlerType) throws 
IOException {
+        ParseContext context = createPresetContext(presetName);
+        Metadata metadata = newRequestMetadata();
+        fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+        if (explicitHandlerType != null) {
+            setupContentHandlerFactory(context, explicitHandlerType);
+        }
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            return produceJsonWithContext(tis, metadata, context, 
explicitHandlerType);
+        }
+    }
+
+    /**
+     * As the bare /tika endpoint, with the named preset applied. A factory 
the preset
+     * binds decides the output format here; without one the Markdown default 
applies.
+     */
+    @PUT
+    @Consumes("*/*")
+    @Produces("text/plain;charset=UTF-8")
+    @Path("preset/{presetName}")
+    public Response getDefaultWithPreset(final InputStream is, @Context 
HttpHeaders httpHeaders,
+                                         @PathParam("presetName") String 
presetName)
+            throws IOException {
+        return putRawPreset(is, httpHeaders, presetName, null);
+    }
+
+    /** As /tika/text, with the named preset applied. */
+    @PUT
+    @Consumes("*/*")
+    @Produces("text/plain;charset=UTF-8")
+    @Path("preset/{presetName}/text")
+    public Response getTextWithPreset(final InputStream is, @Context 
HttpHeaders httpHeaders,
+                                      @PathParam("presetName") String 
presetName)
+            throws IOException {
+        return putRawPreset(is, httpHeaders, presetName, "body");
+    }
+
+    /** As /tika/html, with the named preset applied. */
+    @PUT
+    @Consumes("*/*")
+    @Produces("text/html;charset=UTF-8")
+    @Path("preset/{presetName}/html")
+    public Response getHtmlWithPreset(final InputStream is, @Context 
HttpHeaders httpHeaders,
+                                      @PathParam("presetName") String 
presetName)
+            throws IOException {
+        return putRawPreset(is, httpHeaders, presetName, "html");
+    }
+
+    /** As /tika/xml, with the named preset applied. */
+    @PUT
+    @Consumes("*/*")
+    @Produces("text/xml;charset=UTF-8")
+    @Path("preset/{presetName}/xml")
+    public Response getXmlWithPreset(final InputStream is, @Context 
HttpHeaders httpHeaders,
+                                     @PathParam("presetName") String 
presetName)
+            throws IOException {
+        return putRawPreset(is, httpHeaders, presetName, "xml");
+    }
+
+    /** As /tika/md, with the named preset applied. */
+    @PUT
+    @Consumes("*/*")
+    @Produces("text/plain;charset=UTF-8")
+    @Path("preset/{presetName}/md")
+    public Response getMarkdownWithPreset(final InputStream is, @Context 
HttpHeaders httpHeaders,
+                                          @PathParam("presetName") String 
presetName)
+            throws IOException {
+        return putRawPreset(is, httpHeaders, presetName, "md");
+    }
+
+    /** As /tika/json, with the named preset applied. */
+    @PUT
+    @Consumes("*/*")
+    @Produces("application/json")
+    @Path("preset/{presetName}/json")
+    public Metadata getJsonDefaultWithPreset(final InputStream is,
+                                             @Context HttpHeaders httpHeaders,
+                                             @PathParam("presetName") String 
presetName)
+            throws IOException {
+        return putJsonPreset(is, httpHeaders, presetName, null);
+    }
+
+    /** As /tika/json/{handlerType}, with the named preset applied. */
+    @PUT
+    @Consumes("*/*")
+    @Produces("application/json")
+    @Path("preset/{presetName}/json/{" + HANDLER_TYPE_PARAM + "}")
+    public Metadata getJsonWithPreset(final InputStream is, @Context 
HttpHeaders httpHeaders,
+                                      @PathParam("presetName") String 
presetName,
+                                      @PathParam(HANDLER_TYPE_PARAM) String 
handlerTypeName)
+            throws IOException {
+        return putJsonPreset(is, httpHeaders, presetName, handlerTypeName);
+    }
+
     // ==================== POST endpoints (multipart with optional config) 
====================
 
     // All /tika/config* endpoints take a required "file" part and an optional 
"config"
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
index 1180a62b90..f47243795e 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
@@ -145,10 +145,21 @@ public class UnpackerResource {
      * @param info URI info
      * @return streaming zip response
      */
+    // The wildcard id would otherwise absorb a transposed preset URL 
(/unpack/all/preset/x)
+    // and silently run with no preset applied.
+    private static void rejectPresetInWildcard(UriInfo info) {
+        String id = info.getPathParameters().getFirst("id");
+        if (id != null && (id.equals("/preset") || id.startsWith("/preset/"))) 
{
+            throw new jakarta.ws.rs.NotFoundException(
+                    "preset routes are PUT /unpack/preset/{name}[/all]");
+        }
+    }
+
     @jakarta.ws.rs.Path("/{id:(/.*)?}")
     @PUT
     @Produces("application/zip")
     public Response unpack(InputStream is, @Context HttpHeaders httpHeaders, 
@Context UriInfo info) throws Exception {
+        rejectPresetInWildcard(info);
         ParseContext pc = tikaResource.createRequestContext();
         Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = TikaInputStream.get(is)) {
@@ -172,6 +183,7 @@ public class UnpackerResource {
     @Consumes("multipart/form-data")
     @Produces("application/zip")
     public Response unpackWithConfig(List<Attachment> attachments, @Context 
HttpHeaders httpHeaders, @Context UriInfo info) throws Exception {
+        rejectPresetInWildcard(info);
         ParseContext pc = tikaResource.createRequestContext();
         Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = 
tikaResource.setupMultipartConfig(attachments, metadata, pc)) {
@@ -193,6 +205,7 @@ public class UnpackerResource {
     @PUT
     @Produces("application/zip")
     public Response unpackAll(InputStream is, @Context HttpHeaders 
httpHeaders, @Context UriInfo info) throws Exception {
+        rejectPresetInWildcard(info);
         ParseContext pc = tikaResource.createRequestContext();
         Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = TikaInputStream.get(is)) {
@@ -216,6 +229,7 @@ public class UnpackerResource {
     @Consumes("multipart/form-data")
     @Produces("application/zip")
     public Response unpackAllWithConfig(List<Attachment> attachments, @Context 
HttpHeaders httpHeaders, @Context UriInfo info) throws Exception {
+        rejectPresetInWildcard(info);
         ParseContext pc = tikaResource.createRequestContext();
         Metadata metadata = tikaResource.newRequestMetadata();
         try (TikaInputStream tis = 
tikaResource.setupMultipartConfig(attachments, metadata, pc)) {
@@ -224,6 +238,43 @@ public class UnpackerResource {
         }
     }
 
+    /**
+     * As {@code /unpack}, with the named preset's parse-context fragment 
applied.
+     * Takes no config part -- a preset never combines with request 
configuration.
+     */
+    @jakarta.ws.rs.Path("/preset/{presetName}")
+    @PUT
+    @Produces("application/zip")
+    public Response unpackWithPreset(InputStream is, @Context HttpHeaders 
httpHeaders,
+                                     @jakarta.ws.rs.PathParam("presetName") 
String presetName)
+            throws Exception {
+        ParseContext pc = tikaResource.createPresetContext(presetName);
+        Metadata metadata = tikaResource.newRequestMetadata();
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+            TikaResource.logRequest(LOG, "/unpack", metadata);
+            return doUnpack(tis, metadata, pc, false);
+        }
+    }
+
+    /**
+     * As {@code /unpack/all}, with the named preset's parse-context fragment 
applied.
+     */
+    @jakarta.ws.rs.Path("/preset/{presetName}/all")
+    @PUT
+    @Produces("application/zip")
+    public Response unpackAllWithPreset(InputStream is, @Context HttpHeaders 
httpHeaders,
+                                        @jakarta.ws.rs.PathParam("presetName") 
String presetName)
+            throws Exception {
+        ParseContext pc = tikaResource.createPresetContext(presetName);
+        Metadata metadata = tikaResource.newRequestMetadata();
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+            TikaResource.logRequest(LOG, "/unpack/all", metadata);
+            return doUnpack(tis, metadata, pc, true);
+        }
+    }
+
     /**
      * Core unpack logic using pipes-based parsing.
      * The child process creates the zip file, and we stream it directly back.
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
index b7a158363f..155b7dcc87 100644
--- 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
@@ -357,6 +357,12 @@ public abstract class CXFTestBase {
             if (metadataFilters != null && !metadataFilters.isEmpty()) {
                 root.set("metadata-filters", metadataFilters);
             }
+            // The worker resolves preset names from its own config, so 
presets must be
+            // visible there just like the parse-context defaults above.
+            JsonNode presets = tikaConfig.get("presets");
+            if (presets != null && !presets.isEmpty()) {
+                root.set("presets", presets);
+            }
         } catch (Exception e) {
             LOG.debug("Could not carry config into the worker config: {}", 
e.getMessage());
         }
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PresetEndpointsTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PresetEndpointsTest.java
new file mode 100644
index 0000000000..dc703751e0
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PresetEndpointsTest.java
@@ -0,0 +1,192 @@
+/*
+ * 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.server.core;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.ws.rs.core.Response;
+import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.serialization.JsonMetadataList;
+import org.apache.tika.server.core.resource.RecursiveMetadataResource;
+import org.apache.tika.server.core.resource.TikaResource;
+import org.apache.tika.server.core.resource.UnpackerResource;
+import org.apache.tika.server.core.writer.MetadataListMessageBodyWriter;
+
+/**
+ * Routing and behavior of the {@code /preset/{name}} endpoints: the preset
+ * segment sits directly after the resource root, must win over the wildcard
+ * routes ({@code /rmeta/{handlerType}}, {@code /unpack/{id}}), and an unknown
+ * name answers 404.
+ */
+public class PresetEndpointsTest extends CXFTestBase {
+
+    private static final String HELLO_WORLD = 
"test-documents/mock/hello_world.xml";
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    @Override
+    protected InputStream getTikaConfigInputStream() throws 
java.io.IOException {
+        ObjectNode config = (ObjectNode) MAPPER.readTree(BASIC_CONFIG);
+        ObjectNode presets = config.putObject("presets");
+        presets.putObject("xml-content")
+                .putObject("basic-content-handler-factory").put("type", "XML");
+        // exception-reporting is wire-blocked for caller-supplied config; a 
preset is
+        // operator config and must be able to bind it (resolved worker-side)
+        presets.putObject("reporting")
+                .putObject("exception-reporting").put("maxLength", 512);
+        return new ByteArrayInputStream(
+                MAPPER.writeValueAsString(config).getBytes(UTF_8));
+    }
+
+    @Override
+    protected void setUpResources(JAXRSServerFactoryBean sf) {
+        sf.setResourceClasses(RecursiveMetadataResource.class, 
UnpackerResource.class,
+                TikaResource.class);
+        sf.setResourceProvider(RecursiveMetadataResource.class,
+                new SingletonResourceProvider(new 
RecursiveMetadataResource(tikaResource)));
+        sf.setResourceProvider(UnpackerResource.class,
+                new SingletonResourceProvider(new 
UnpackerResource(tikaResource)));
+        sf.setResourceProvider(TikaResource.class,
+                new SingletonResourceProvider(tikaResource));
+    }
+
+    @Override
+    protected void setUpProviders(JAXRSServerFactoryBean sf) {
+        List<Object> providers = new ArrayList<>();
+        providers.add(new MetadataListMessageBodyWriter());
+        sf.setProviders(providers);
+    }
+
+    @Test
+    public void testRmetaPresetApplies() throws Exception {
+        Response response = WebClient
+                .create(endPoint + "/rmeta/preset/xml-content")
+                .accept("application/json")
+                .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+        assertEquals(200, response.getStatus());
+        Reader reader = new InputStreamReader((InputStream) 
response.getEntity(), UTF_8);
+        List<Metadata> metadataList = JsonMetadataList.fromJson(reader);
+        Metadata metadata = metadataList.get(0);
+        assertEquals("Nikolai Lobachevsky", metadata.get("author"));
+        // markup in the content proves the preset's XML handler replaced the
+        // markdown default, which emits plain "hello world"
+        String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
+        assertContains("<body><p>hello world</p>", content);
+    }
+
+    @Test
+    public void testRmetaUnknownPresetIs404() throws Exception {
+        Response response = WebClient
+                .create(endPoint + "/rmeta/preset/nope")
+                .accept("application/json")
+                .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+        assertEquals(404, response.getStatus());
+    }
+
+    @Test
+    public void testUnpackPresetRouteBeatsWildcard() throws Exception {
+        // /unpack/{id:(/.*)?} is a catch-all; the preset literal must win, so 
an
+        // unknown preset answers 404 from the preset route, not the wildcard
+        Response response = WebClient
+                .create(endPoint + "/unpack/preset/nope")
+                .accept("application/zip")
+                .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+        assertEquals(404, response.getStatus());
+    }
+
+    @Test
+    public void testTikaPresetApplies() throws Exception {
+        Response response = WebClient
+                .create(endPoint + "/tika/preset/xml-content")
+                .accept("text/plain")
+                .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+        assertEquals(200, response.getStatus());
+        String content = getStringFromInputStream((InputStream) 
response.getEntity());
+        // the preset's XML content handler wins over the endpoint's markdown 
default
+        assertContains("<body><p>hello world</p>", content);
+    }
+
+    @Test
+    public void testExplicitFormatSegmentWinsOverPresetFactory() throws 
Exception {
+        // /tika/preset/xml-content/text: the URL's own format segment beats 
the XML
+        // factory the preset binds -- it rides the request delta, which the 
worker
+        // overlays on top of the preset
+        Response response = WebClient
+                .create(endPoint + "/tika/preset/xml-content/text")
+                .accept("text/plain")
+                .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+        assertEquals(200, response.getStatus());
+        String content = getStringFromInputStream((InputStream) 
response.getEntity());
+        assertContains("hello world", content);
+        assertFalse(content.contains("<body>"), "explicit /text segment must 
win: " + content);
+    }
+
+    @Test
+    public void testWireBlockedComponentWorksInPreset() throws Exception {
+        // previously this 500'd: the preset was pushed through the untrusted 
wire
+        // deserializer, which refuses exception-reporting
+        Response response = WebClient
+                .create(endPoint + "/tika/preset/reporting")
+                .accept("text/plain")
+                .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+        assertEquals(200, response.getStatus());
+        assertContains("hello world",
+                getStringFromInputStream((InputStream) response.getEntity()));
+    }
+
+    @Test
+    public void testTransposedUnpackPresetUrlIs404() throws Exception {
+        // /unpack/all/preset/{name} would otherwise fall into the /all{id} 
wildcard
+        // and run with no preset applied -- a silent wrong-config success
+        Response response = WebClient
+                .create(endPoint + "/unpack/all/preset/xml-content")
+                .accept("application/zip")
+                .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+        assertEquals(404, response.getStatus());
+
+        response = WebClient
+                .create(endPoint + "/unpack/preset")
+                .accept("application/zip")
+                .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+        assertEquals(404, response.getStatus());
+    }
+
+    @Test
+    public void testTikaUnknownPresetIs404() throws Exception {
+        Response response = WebClient
+                .create(endPoint + "/tika/preset/nope")
+                .accept("text/plain")
+                .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+        assertEquals(404, response.getStatus());
+    }
+}
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/TikaResourcePresetTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/TikaResourcePresetTest.java
new file mode 100644
index 0000000000..61363e86d7
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/TikaResourcePresetTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.server.core.resource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import jakarta.ws.rs.NotFoundException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.ContentHandlerFactory;
+import org.apache.tika.server.core.ServerStatus;
+
+public class TikaResourcePresetTest {
+
+    private static final String CONFIG = """
+            {
+              "presets": {
+                "xml-content": {"basic-content-handler-factory": {"type": 
"XML"}}
+              }
+            }
+            """;
+
+    @TempDir
+    Path tmp;
+
+    private TikaResource newTikaResource(String configJson, boolean 
allowPerRequestConfig)
+            throws Exception {
+        Path configPath = tmp.resolve("tika-config-" + configJson.hashCode() + 
".json");
+        Files.writeString(configPath, configJson);
+        return new TikaResource(TikaLoader.load(configPath), new 
ServerStatus(), null,
+                allowPerRequestConfig);
+    }
+
+    @Test
+    public void testPresetSelectionRidesRequestContextByNameOnly() throws 
Exception {
+        // only the name is recorded: the forked worker resolves the content 
from its
+        // own config at config-tier trust, so nothing preset-shaped may enter 
the
+        // request (caller-tier) context here
+        ParseContext context =
+                newTikaResource(CONFIG, 
true).createPresetContext("xml-content");
+        assertEquals("xml-content", context.get(PresetSelection.class).name());
+        assertNull(context.get(ContentHandlerFactory.class));
+    }
+
+    @Test
+    public void testPresetWorksWithPerRequestConfigDisabled() throws Exception 
{
+        // presets are admin/Tika-vetted: selecting one must not require the
+        // free-form per-request-config privilege
+        ParseContext context =
+                newTikaResource(CONFIG, 
false).createPresetContext("xml-content");
+        assertEquals("xml-content", context.get(PresetSelection.class).name());
+    }
+
+    @Test
+    public void testUnknownPresetIs404() throws Exception {
+        TikaResource resource = newTikaResource(CONFIG, true);
+        assertThrows(NotFoundException.class, () -> 
resource.createPresetContext("nope"));
+    }
+
+    @Test
+    public void testInvalidPresetsConfigFailsStartup() {
+        assertThrows(IllegalStateException.class,
+                () -> newTikaResource("{\"presets\": {\"bad\": \"a 
string\"}}", true));
+    }
+
+    @Test
+    public void testUnresolvablePresetFailsStartup() {
+        // preset content is resolved at load: a malformed component fails 
startup,
+        // not the first preset request
+        assertThrows(IllegalStateException.class, () -> newTikaResource("""
+                {"presets": {"bad": {"basic-content-handler-factory": {"type": 
"NOPE"}}}}
+                """, true));
+    }
+}

Reply via email to