allMetadata, String markdownBody,
+ String docId, String pipesStatus, long fetchParseTimeMs) {
+ if (primary == null) {
+ ParseStatus.Builder statusBuilder = ParseStatus.newBuilder()
+ .setStatus(mapPipesStatus(pipesStatus))
+ .setFetchParseTimeMs(fetchParseTimeMs);
+ if (statusBuilder.getStatus() != ParseStatus.Status.SUCCESS) {
+ statusBuilder.addErrors("No metadata returned from parse");
+ }
+ Document.Builder document = Document.newBuilder().setStatus(statusBuilder.build());
+ if (docId != null && !docId.isEmpty()) {
+ document.setId(docId);
+ }
+ return document.build();
+ }
+
+ Document.Builder document = buildOne(primary, markdownBody, docId);
+
+ String parserClass = primary.get(TikaCoreProperties.TIKA_PARSED_BY);
+ ParseStatus.Builder statusBuilder = ParseStatus.newBuilder()
+ .setStatus(mapPipesStatus(pipesStatus))
+ .setFetchParseTimeMs(fetchParseTimeMs);
+ if (parserClass != null && !parserClass.isEmpty()) {
+ statusBuilder.setParserUsed(parserClass);
+ }
+ String parsedByFull = primary.get(TikaCoreProperties.TIKA_PARSED_BY_FULL_SET);
+ if (parsedByFull != null && !parsedByFull.isEmpty()) {
+ for (String p : parsedByFull.split(",")) {
+ if (!p.trim().isEmpty()) {
+ statusBuilder.addParsersUsed(p.trim());
+ }
+ }
+ }
+ document.setStatus(statusBuilder.build());
+
+ if (allMetadata != null && allMetadata.size() > 1) {
+ for (int i = 1; i < allMetadata.size(); i++) {
+ Metadata embedded = allMetadata.get(i);
+ String embeddedBody = embedded.get(TikaCoreProperties.TIKA_CONTENT);
+ document.addEmbedded(buildOne(embedded, embeddedBody, docId + "#" + i).build());
+ }
+ }
+
+ return document.build();
+ }
+
+ private static Document.Builder buildOne(Metadata tika, String markdownBody, String docId) {
+ Document.Builder document = Document.newBuilder();
+ if (docId != null && !docId.isEmpty()) {
+ document.setId(docId);
+ }
+
+ String contentType = tika.get(Metadata.CONTENT_TYPE);
+ if (contentType != null && !contentType.isBlank()) {
+ document.setContentType(contentType.trim());
+ }
+ document.setFormatCategory(FormatCategoryDetector.detect(tika));
+
+ Instant now = Instant.now();
+ document.setParsedAt(com.google.protobuf.Timestamp.newBuilder()
+ .setSeconds(now.getEpochSecond())
+ .setNanos(now.getNano())
+ .build());
+
+ SourceOrigin.Builder origin = SourceOrigin.newBuilder();
+ String resourceName = tika.get(TikaCoreProperties.RESOURCE_NAME_KEY);
+ if (resourceName != null && !resourceName.isBlank()) {
+ origin.setFilename(resourceName.trim());
+ }
+ String parserClass = tika.get(TikaCoreProperties.TIKA_PARSED_BY);
+ if (parserClass != null && !parserClass.isBlank()) {
+ origin.setParser(parserClass.trim());
+ }
+ document.setOrigin(origin.build());
+
+ if (markdownBody != null && !markdownBody.isEmpty()) {
+ document.setMarkdown(markdownBody);
+ document.addAllBlocks(MarkdownBlockTreeBuilder.toBlocks(markdownBody));
+ }
+
+ TRANSFORMERS.transform(tika, document);
+
+ return document;
+ }
+
+ /**
+ * Maps {@code org.apache.tika.pipes.api.PipesResult.RESULT_STATUS.name()} to
+ * {@link ParseStatus.Status}. Values are named after the real enum constants (not
+ * guessed) since this module intentionally has no compile dependency on tika-pipes-api:
+ * a clean success (e.g. {@code PARSE_SUCCESS}, {@code EMIT_SUCCESS}) is {@code SUCCESS};
+ * a success that happened alongside a caught exception (e.g.
+ * {@code PARSE_SUCCESS_WITH_EXCEPTION}) is {@code PARTIAL}; everything else -- including
+ * process crashes like {@code TIMEOUT} and {@code OOM}, which are not partial successes --
+ * is {@code FAILED}.
+ */
+ private static ParseStatus.Status mapPipesStatus(String pipesStatus) {
+ if (pipesStatus == null || pipesStatus.isEmpty()) {
+ return ParseStatus.Status.UNSPECIFIED;
+ }
+ return switch (pipesStatus) {
+ case "EMPTY_OUTPUT", "PARSE_SUCCESS", "EMIT_SUCCESS", "EMIT_SUCCESS_PASSBACK" ->
+ ParseStatus.Status.SUCCESS;
+ case "PARSE_SUCCESS_WITH_EXCEPTION", "PARSE_EXCEPTION_NO_EMIT", "EMIT_SUCCESS_PARSE_EXCEPTION" ->
+ ParseStatus.Status.PARTIAL;
+ default -> ParseStatus.Status.FAILED;
+ };
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/content/MarkdownBlockTreeBuilder.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/content/MarkdownBlockTreeBuilder.java
new file mode 100644
index 00000000000..fadbb3be7d9
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/content/MarkdownBlockTreeBuilder.java
@@ -0,0 +1,333 @@
+/*
+ * 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.grpc.mapper.content;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.commonmark.Extension;
+import org.commonmark.ext.gfm.strikethrough.Strikethrough;
+import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
+import org.commonmark.ext.gfm.tables.TableBlock;
+import org.commonmark.ext.gfm.tables.TableBody;
+import org.commonmark.ext.gfm.tables.TableCell;
+import org.commonmark.ext.gfm.tables.TableHead;
+import org.commonmark.ext.gfm.tables.TableRow;
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.node.BlockQuote;
+import org.commonmark.node.BulletList;
+import org.commonmark.node.Code;
+import org.commonmark.node.Emphasis;
+import org.commonmark.node.FencedCodeBlock;
+import org.commonmark.node.HardLineBreak;
+import org.commonmark.node.Heading;
+import org.commonmark.node.HtmlBlock;
+import org.commonmark.node.HtmlInline;
+import org.commonmark.node.Image;
+import org.commonmark.node.IndentedCodeBlock;
+import org.commonmark.node.Link;
+import org.commonmark.node.ListItem;
+import org.commonmark.node.Node;
+import org.commonmark.node.OrderedList;
+import org.commonmark.node.Paragraph;
+import org.commonmark.node.SoftLineBreak;
+import org.commonmark.node.StrongEmphasis;
+import org.commonmark.node.Text;
+import org.commonmark.node.ThematicBreak;
+import org.commonmark.parser.Parser;
+
+/**
+ * Converts a markdown string into the {@code Document} proto's structured
+ * {@code Block}/{@code Inline} tree.
+ *
+ * Every Tika parser's SAX output already reaches this module as markdown text (the
+ * pipes default content handler renders {@code TikaCoreProperties.TIKA_CONTENT} as
+ * markdown via {@code ToMarkdownContentHandler}). Reusing that single rendering path
+ * plus commonmark-java's parser means the content tree is built once, generically, for
+ * every source format -- no per-format content handling is needed, only per-format
+ * metadata handling ({@link org.apache.tika.grpc.mapper.transform.DocumentTransformer}).
+ *
+ * Naming note: every {@code org.apache.tika.grpc.v1.*} proto type is referenced fully
+ * qualified in this file. Many proto message names (Heading, Paragraph, BlockQuote,
+ * Emphasis, Code, Link, Image, TableRow, TableCell, ...) collide with commonmark-java's
+ * own node class names, which are imported normally; fully qualifying one side avoids
+ * any ambiguity.
+ */
+public final class MarkdownBlockTreeBuilder {
+
+ private static final List EXTENSIONS =
+ List.of(TablesExtension.create(), StrikethroughExtension.create());
+ private static final Parser COMMONMARK = Parser.builder().extensions(EXTENSIONS).build();
+
+ private MarkdownBlockTreeBuilder() {
+ }
+
+ /** Parses {@code markdown} into a flat list of top-level {@code Block}s. */
+ public static List toBlocks(String markdown) {
+ if (markdown == null || markdown.isEmpty()) {
+ return List.of();
+ }
+ return blockChildren(COMMONMARK.parse(markdown));
+ }
+
+ private static List blockChildren(Node parent) {
+ List blocks = new ArrayList<>();
+ for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
+ org.apache.tika.grpc.v1.Block block = toBlock(child);
+ if (block != null) {
+ blocks.add(block);
+ }
+ }
+ return blocks;
+ }
+
+ private static org.apache.tika.grpc.v1.Block toBlock(Node node) {
+ if (node instanceof Heading) {
+ Heading heading = (Heading) node;
+ return org.apache.tika.grpc.v1.Block.newBuilder()
+ .setHeading(org.apache.tika.grpc.v1.Heading.newBuilder()
+ .setLevel(heading.getLevel())
+ .addAllContent(inlineChildren(heading)))
+ .build();
+ }
+ if (node instanceof Paragraph) {
+ return org.apache.tika.grpc.v1.Block.newBuilder()
+ .setParagraph(org.apache.tika.grpc.v1.Paragraph.newBuilder()
+ .addAllContent(inlineChildren(node)))
+ .build();
+ }
+ if (node instanceof BlockQuote) {
+ return org.apache.tika.grpc.v1.Block.newBuilder()
+ .setBlockQuote(org.apache.tika.grpc.v1.BlockQuote.newBuilder()
+ .addAllBlocks(blockChildren(node)))
+ .build();
+ }
+ if (node instanceof BulletList) {
+ BulletList bulletList = (BulletList) node;
+ return org.apache.tika.grpc.v1.Block.newBuilder()
+ .setBulletList(org.apache.tika.grpc.v1.BulletList.newBuilder()
+ .setMarker(String.valueOf(bulletList.getBulletMarker()))
+ .setTight(bulletList.isTight())
+ .addAllItems(listItems(bulletList)))
+ .build();
+ }
+ if (node instanceof OrderedList) {
+ OrderedList orderedList = (OrderedList) node;
+ return org.apache.tika.grpc.v1.Block.newBuilder()
+ .setOrderedList(org.apache.tika.grpc.v1.OrderedList.newBuilder()
+ .setStart(orderedList.getStartNumber())
+ .setDelimiter(String.valueOf(orderedList.getDelimiter()))
+ .setTight(orderedList.isTight())
+ .addAllItems(listItems(orderedList)))
+ .build();
+ }
+ if (node instanceof FencedCodeBlock) {
+ FencedCodeBlock codeBlock = (FencedCodeBlock) node;
+ return org.apache.tika.grpc.v1.Block.newBuilder()
+ .setCodeBlock(org.apache.tika.grpc.v1.CodeBlock.newBuilder()
+ .setFenced(true)
+ .setInfo(nullToEmpty(codeBlock.getInfo()))
+ .setLiteral(nullToEmpty(codeBlock.getLiteral())))
+ .build();
+ }
+ if (node instanceof IndentedCodeBlock) {
+ return org.apache.tika.grpc.v1.Block.newBuilder()
+ .setCodeBlock(org.apache.tika.grpc.v1.CodeBlock.newBuilder()
+ .setFenced(false)
+ .setLiteral(nullToEmpty(((IndentedCodeBlock) node).getLiteral())))
+ .build();
+ }
+ if (node instanceof HtmlBlock) {
+ return org.apache.tika.grpc.v1.Block.newBuilder()
+ .setHtmlBlock(org.apache.tika.grpc.v1.HtmlBlock.newBuilder()
+ .setLiteral(nullToEmpty(((HtmlBlock) node).getLiteral())))
+ .build();
+ }
+ if (node instanceof ThematicBreak) {
+ return org.apache.tika.grpc.v1.Block.newBuilder()
+ .setThematicBreak(org.apache.tika.grpc.v1.ThematicBreak.getDefaultInstance())
+ .build();
+ }
+ if (node instanceof TableBlock) {
+ return org.apache.tika.grpc.v1.Block.newBuilder().setTable(toTable(node)).build();
+ }
+ // Link reference definitions and any other unmodeled block produce no rendered output.
+ return null;
+ }
+
+ private static List listItems(Node list) {
+ List items = new ArrayList<>();
+ for (Node child = list.getFirstChild(); child != null; child = child.getNext()) {
+ if (child instanceof ListItem) {
+ items.add(org.apache.tika.grpc.v1.ListItem.newBuilder()
+ .addAllBlocks(blockChildren(child))
+ .build());
+ }
+ }
+ return items;
+ }
+
+ private static org.apache.tika.grpc.v1.Table toTable(Node tableBlock) {
+ org.apache.tika.grpc.v1.Table.Builder table = org.apache.tika.grpc.v1.Table.newBuilder();
+ for (Node section = tableBlock.getFirstChild(); section != null; section = section.getNext()) {
+ if (section instanceof TableHead) {
+ for (Node row = section.getFirstChild(); row != null; row = row.getNext()) {
+ if (!(row instanceof TableRow)) {
+ continue;
+ }
+ for (Node cell = row.getFirstChild(); cell != null; cell = cell.getNext()) {
+ if (cell instanceof TableCell) {
+ table.addHeader(toTableCell((TableCell) cell));
+ }
+ }
+ }
+ } else if (section instanceof TableBody) {
+ for (Node row = section.getFirstChild(); row != null; row = row.getNext()) {
+ if (!(row instanceof TableRow)) {
+ continue;
+ }
+ org.apache.tika.grpc.v1.TableRow.Builder rowBuilder = org.apache.tika.grpc.v1.TableRow.newBuilder();
+ for (Node cell = row.getFirstChild(); cell != null; cell = cell.getNext()) {
+ if (cell instanceof TableCell) {
+ rowBuilder.addCells(toTableCell((TableCell) cell));
+ }
+ }
+ table.addRows(rowBuilder.build());
+ }
+ }
+ }
+ return table.build();
+ }
+
+ private static org.apache.tika.grpc.v1.TableCell toTableCell(TableCell cell) {
+ return org.apache.tika.grpc.v1.TableCell.newBuilder()
+ .setAlignment(toAlignment(cell.getAlignment()))
+ .addAllContent(inlineChildren(cell))
+ .build();
+ }
+
+ private static org.apache.tika.grpc.v1.Alignment toAlignment(TableCell.Alignment alignment) {
+ if (alignment == null) {
+ return org.apache.tika.grpc.v1.Alignment.ALIGN_NONE;
+ }
+ switch (alignment) {
+ case LEFT:
+ return org.apache.tika.grpc.v1.Alignment.ALIGN_LEFT;
+ case CENTER:
+ return org.apache.tika.grpc.v1.Alignment.ALIGN_CENTER;
+ case RIGHT:
+ return org.apache.tika.grpc.v1.Alignment.ALIGN_RIGHT;
+ default:
+ return org.apache.tika.grpc.v1.Alignment.ALIGN_NONE;
+ }
+ }
+
+ private static List inlineChildren(Node parent) {
+ List inlines = new ArrayList<>();
+ for (Node child = parent.getFirstChild(); child != null; child = child.getNext()) {
+ org.apache.tika.grpc.v1.Inline inline = toInline(child);
+ if (inline != null) {
+ inlines.add(inline);
+ }
+ }
+ return inlines;
+ }
+
+ private static org.apache.tika.grpc.v1.Inline toInline(Node node) {
+ if (node instanceof Text) {
+ return org.apache.tika.grpc.v1.Inline.newBuilder().setText(((Text) node).getLiteral()).build();
+ }
+ if (node instanceof Emphasis) {
+ return org.apache.tika.grpc.v1.Inline.newBuilder()
+ .setEmphasis(org.apache.tika.grpc.v1.Emphasis.newBuilder().addAllContent(inlineChildren(node)))
+ .build();
+ }
+ if (node instanceof StrongEmphasis) {
+ return org.apache.tika.grpc.v1.Inline.newBuilder()
+ .setStrong(org.apache.tika.grpc.v1.Strong.newBuilder().addAllContent(inlineChildren(node)))
+ .build();
+ }
+ if (node instanceof Strikethrough) {
+ return org.apache.tika.grpc.v1.Inline.newBuilder()
+ .setStrikethrough(org.apache.tika.grpc.v1.Strikethrough.newBuilder().addAllContent(inlineChildren(node)))
+ .build();
+ }
+ if (node instanceof Code) {
+ return org.apache.tika.grpc.v1.Inline.newBuilder()
+ .setCode(org.apache.tika.grpc.v1.Code.newBuilder().setLiteral(((Code) node).getLiteral()))
+ .build();
+ }
+ if (node instanceof Link) {
+ Link link = (Link) node;
+ org.apache.tika.grpc.v1.Link.Builder linkBuilder = org.apache.tika.grpc.v1.Link.newBuilder()
+ .setDestination(nullToEmpty(link.getDestination()))
+ .addAllContent(inlineChildren(link));
+ if (link.getTitle() != null) {
+ linkBuilder.setTitle(link.getTitle());
+ }
+ return org.apache.tika.grpc.v1.Inline.newBuilder().setLink(linkBuilder).build();
+ }
+ if (node instanceof Image) {
+ Image image = (Image) node;
+ org.apache.tika.grpc.v1.Image.Builder imageBuilder = org.apache.tika.grpc.v1.Image.newBuilder()
+ .setDestination(nullToEmpty(image.getDestination()))
+ .setAlt(collectText(image));
+ if (image.getTitle() != null) {
+ imageBuilder.setTitle(image.getTitle());
+ }
+ return org.apache.tika.grpc.v1.Inline.newBuilder().setImage(imageBuilder).build();
+ }
+ if (node instanceof HardLineBreak) {
+ return org.apache.tika.grpc.v1.Inline.newBuilder()
+ .setLineBreak(org.apache.tika.grpc.v1.LineBreak.newBuilder().setHard(true))
+ .build();
+ }
+ if (node instanceof SoftLineBreak) {
+ return org.apache.tika.grpc.v1.Inline.newBuilder()
+ .setLineBreak(org.apache.tika.grpc.v1.LineBreak.newBuilder().setHard(false))
+ .build();
+ }
+ if (node instanceof HtmlInline) {
+ return org.apache.tika.grpc.v1.Inline.newBuilder().setHtml(((HtmlInline) node).getLiteral()).build();
+ }
+ // Reference definitions and any other unmodeled inline node produce no rendered output.
+ return null;
+ }
+
+ /** Flattens inline content to plain text (for image alt), losing no literals. */
+ private static String collectText(Node node) {
+ StringBuilder sb = new StringBuilder();
+ for (Node child = node.getFirstChild(); child != null; child = child.getNext()) {
+ if (child instanceof Text) {
+ sb.append(((Text) child).getLiteral());
+ } else if (child instanceof Code) {
+ sb.append(((Code) child).getLiteral());
+ } else if (child instanceof HtmlInline) {
+ sb.append(((HtmlInline) child).getLiteral());
+ } else if (child instanceof SoftLineBreak || child instanceof HardLineBreak) {
+ sb.append(' ');
+ } else {
+ sb.append(collectText(child));
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String nullToEmpty(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/CreativeCommonsDocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/CreativeCommonsDocumentTransformer.java
new file mode 100644
index 00000000000..ef5f9133390
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/CreativeCommonsDocumentTransformer.java
@@ -0,0 +1,95 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.DocumentMetadata;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.XMPRights;
+
+/**
+ * Creative Commons / XMP rights detection. Unlike the format transformers, this one is
+ * cross-cutting: Creative Commons or other rights-management metadata can ride along with
+ * any document format (a PDF, a JPEG, an Office doc, ...), so it does not key off
+ * content-type at all. Instead it inspects the full metadata for CC/license-shaped field
+ * names and values, or for any populated XMP rights-management property.
+ *
+ * Rights-management specifics (marked, owner, usage terms, certificate, ...) are NOT
+ * given their own proto fields. They flow into the tagged tail, typed where Tika declares
+ * the type and string otherwise - same philosophy as the format transformers.
+ */
+public final class CreativeCommonsDocumentTransformer implements DocumentTransformer {
+
+ private static final String[] NAME_HINTS = {"license", "creative", "cc:", "rights"};
+
+ private static final Property[] XMP_RIGHTS_PROPERTIES = {
+ XMPRights.MARKED,
+ XMPRights.OWNER,
+ XMPRights.USAGE_TERMS,
+ XMPRights.WEB_STATEMENT,
+ XMPRights.CERTIFICATE
+ };
+
+ @Override
+ public boolean isCrossCutting() {
+ return true;
+ }
+
+ @Override
+ public boolean appliesTo(Metadata tika) {
+ for (String name : tika.names()) {
+ String lowerName = name.toLowerCase(Locale.ROOT);
+ for (String hint : NAME_HINTS) {
+ if (lowerName.contains(hint)) {
+ String value = tika.get(name);
+ if (value != null && value.toLowerCase(Locale.ROOT).contains("creative")) {
+ return true;
+ }
+ break;
+ }
+ }
+ }
+
+ for (Property property : XMP_RIGHTS_PROPERTIES) {
+ String value = tika.get(property);
+ if (value != null && !value.trim().isEmpty()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public void transform(Metadata tika, Document.Builder document, Set consumed) {
+ DocumentMetadata.Builder meta = document.getMetadataBuilder();
+
+ TransformSupport.setString(tika, TikaCoreProperties.RIGHTS, meta::setRights, consumed);
+ // A CC license URL, e.g. https://creativecommons.org/licenses/by/4.0/, typically
+ // lives in the XMP rights "web statement" property.
+ TransformSupport.setString(tika, XMPRights.WEB_STATEMENT, meta::setLicenseUrl, consumed);
+
+ // Everything else (xmpRights:Marked, xmpRights:Owner, xmpRights:UsageTerms,
+ // xmpRights:Certificate, ...) lands in the tagged tail, appended once by
+ // DocumentTransformers.
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/DocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/DocumentTransformer.java
new file mode 100644
index 00000000000..80bacf815ca
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/DocumentTransformer.java
@@ -0,0 +1,59 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * Maps one Tika {@link Metadata} (a document, or an embedded part) onto the typed
+ * {@link Document}. Transformers are code, not schema: adding a parser adds a
+ * transformer, and the wire contract never changes. More than one transformer may
+ * apply to a single document (a format transformer plus cross-cutting ones, such as
+ * Creative Commons rights detection alongside a PDF or Office transformer).
+ */
+public interface DocumentTransformer {
+
+ /**
+ * True if this transformer applies to the given document. Most transformers only
+ * look at {@code tika.get(Metadata.CONTENT_TYPE)}; cross-cutting ones (e.g. Creative
+ * Commons rights detection) may inspect the full metadata instead.
+ */
+ boolean appliesTo(Metadata tika);
+
+ /**
+ * Populate typed fields on the builder, and mark every Tika key consumed in
+ * {@code consumed}. {@code consumed} is shared across every transformer that applies to
+ * this document (not private to this transformer), so the tagged tail -- appended once,
+ * after every applicable transformer has run -- never duplicates a key that a different
+ * transformer already mapped to a typed field.
+ */
+ void transform(Metadata tika, Document.Builder document, Set consumed);
+
+ /**
+ * True if this transformer is cross-cutting (may apply alongside, not instead of, a
+ * format transformer -- e.g. Creative Commons rights detection). Cross-cutting
+ * transformers do not count toward whether a document "matched" a format, so they never
+ * suppress the generic fallback: a plain-text document with CC rights metadata still
+ * gets the universal title/author/description mapping.
+ */
+ default boolean isCrossCutting() {
+ return false;
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/DocumentTransformers.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/DocumentTransformers.java
new file mode 100644
index 00000000000..d97e740c0bc
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/DocumentTransformers.java
@@ -0,0 +1,74 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * Picks and runs the applicable transformer(s) for a parsed document. Multiple may
+ * apply (e.g. a format transformer plus the Creative Commons rights transformer); the
+ * generic fallback runs whenever no format-specific transformer matched, so an unknown
+ * format still yields a useful, lossless Document.
+ *
+ * Adding support for a new format means adding a transformer to {@link #defaults()}.
+ * The proto does not change, so clients never rebuild for it.
+ */
+public final class DocumentTransformers {
+
+ private final List transformers;
+ private final DocumentTransformer fallback = new GenericDocumentTransformer();
+
+ public DocumentTransformers(List transformers) {
+ this.transformers = transformers;
+ }
+
+ public static DocumentTransformers defaults() {
+ return new DocumentTransformers(List.of(
+ new PdfDocumentTransformer(),
+ new OfficeDocumentTransformer(),
+ new ImageDocumentTransformer(),
+ new HtmlDocumentTransformer(),
+ new RtfDocumentTransformer(),
+ new EpubDocumentTransformer(),
+ new WarcDocumentTransformer(),
+ new CreativeCommonsDocumentTransformer()
+ ));
+ }
+
+ /** Populate the typed metadata/tagged-tail portion of {@code document} for {@code tika}. */
+ public void transform(Metadata tika, Document.Builder document) {
+ // Shared across every applicable transformer so the tagged tail, appended exactly
+ // once below, never duplicates a key that a different transformer already typed.
+ Set consumed = new HashSet<>();
+ boolean matchedFormat = false;
+ for (DocumentTransformer transformer : transformers) {
+ if (transformer.appliesTo(tika)) {
+ transformer.transform(tika, document, consumed);
+ matchedFormat |= !transformer.isCrossCutting();
+ }
+ }
+ if (!matchedFormat) {
+ fallback.transform(tika, document, consumed);
+ }
+ MetadataTagger.appendTail(tika, consumed, document);
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/EpubDocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/EpubDocumentTransformer.java
new file mode 100644
index 00000000000..0a302d9d241
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/EpubDocumentTransformer.java
@@ -0,0 +1,50 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * EPUB. Maps the common, cross-format facts into typed DocumentMetadata.
+ *
+ * EPUB-specific properties (rendition layout, spec version, OPF/NAV structural fields, ...)
+ * are NOT given their own proto fields. They flow into the tagged tail, typed where Tika
+ * declares the type and string otherwise. That is the whole point: format richness lives
+ * in code plus the tail, not in the wire contract - so the schema stays small and stable
+ * and clients never rebuild when we add or change an EPUB property.
+ */
+public final class EpubDocumentTransformer implements DocumentTransformer {
+
+ @Override
+ public boolean appliesTo(Metadata tika) {
+ String contentType = tika.get(Metadata.CONTENT_TYPE);
+ return contentType != null && contentType.toLowerCase(Locale.ROOT).contains("epub");
+ }
+
+ @Override
+ public void transform(Metadata tika, Document.Builder document, Set consumed) {
+ // Common, cross-format fields -> typed (a date is a Timestamp, a count is an int).
+ TransformSupport.mapCommonFields(tika, document.getMetadataBuilder(), consumed);
+
+ // Everything EPUB-specific (epub:*, dc:identifier, OPF/NAV structural fields, ...)
+ // lands in the tagged tail, appended once by DocumentTransformers.
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/FormatCategoryDetector.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/FormatCategoryDetector.java
new file mode 100644
index 00000000000..0e570edde82
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/FormatCategoryDetector.java
@@ -0,0 +1,100 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Locale;
+
+import org.apache.tika.grpc.v1.FormatCategory;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+/**
+ * Detects the coarse {@link FormatCategory} routing hint from Tika metadata. This is
+ * independent of which {@link DocumentTransformer}s actually run -- transformers
+ * self-select via {@link DocumentTransformer#appliesTo(String)} and are not mutually
+ * exclusive (e.g. Creative Commons rights can coexist with any category here), so this
+ * enum exists purely as a cheap client-side routing hint.
+ */
+public final class FormatCategoryDetector {
+
+ private FormatCategoryDetector() {
+ }
+
+ public static FormatCategory detect(Metadata metadata) {
+ String mimeType = metadata.get(Metadata.CONTENT_TYPE);
+ String resourceName = metadata.get(TikaCoreProperties.RESOURCE_NAME_KEY);
+
+ if (mimeType != null) {
+ String mime = mimeType.toLowerCase(Locale.ROOT);
+ if (mime.contains("pdf")) {
+ return FormatCategory.FORMAT_CATEGORY_PDF;
+ }
+ if (mime.contains("officedocument") || mime.contains("msword")
+ || mime.contains("ms-excel") || mime.contains("ms-powerpoint")
+ || mime.contains("opendocument") || mime.contains("vnd.ms-")
+ || mime.contains("vnd.openxmlformats")) {
+ return FormatCategory.FORMAT_CATEGORY_OFFICE;
+ }
+ if (mime.startsWith("image/")) {
+ return FormatCategory.FORMAT_CATEGORY_IMAGE;
+ }
+ if (mime.contains("text/html") || mime.contains("application/xhtml")) {
+ return FormatCategory.FORMAT_CATEGORY_HTML;
+ }
+ if (mime.contains("rtf")) {
+ return FormatCategory.FORMAT_CATEGORY_RTF;
+ }
+ if (mime.contains("epub")) {
+ return FormatCategory.FORMAT_CATEGORY_EPUB;
+ }
+ if (mime.contains("warc")) {
+ return FormatCategory.FORMAT_CATEGORY_WARC;
+ }
+ }
+
+ if (resourceName != null) {
+ String name = resourceName.toLowerCase(Locale.ROOT);
+ if (name.endsWith(".pdf")) {
+ return FormatCategory.FORMAT_CATEGORY_PDF;
+ }
+ if (name.endsWith(".doc") || name.endsWith(".docx") || name.endsWith(".xls")
+ || name.endsWith(".xlsx") || name.endsWith(".ppt") || name.endsWith(".pptx")
+ || name.endsWith(".odt") || name.endsWith(".ods") || name.endsWith(".odp")) {
+ return FormatCategory.FORMAT_CATEGORY_OFFICE;
+ }
+ if (name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png")
+ || name.endsWith(".gif") || name.endsWith(".tiff") || name.endsWith(".tif")
+ || name.endsWith(".bmp")) {
+ return FormatCategory.FORMAT_CATEGORY_IMAGE;
+ }
+ if (name.endsWith(".html") || name.endsWith(".htm") || name.endsWith(".xhtml")) {
+ return FormatCategory.FORMAT_CATEGORY_HTML;
+ }
+ if (name.endsWith(".rtf")) {
+ return FormatCategory.FORMAT_CATEGORY_RTF;
+ }
+ if (name.endsWith(".epub")) {
+ return FormatCategory.FORMAT_CATEGORY_EPUB;
+ }
+ if (name.endsWith(".warc") || name.endsWith(".arc")) {
+ return FormatCategory.FORMAT_CATEGORY_WARC;
+ }
+ }
+
+ return FormatCategory.FORMAT_CATEGORY_GENERIC;
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/GenericDocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/GenericDocumentTransformer.java
new file mode 100644
index 00000000000..0a435028439
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/GenericDocumentTransformer.java
@@ -0,0 +1,40 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * Always-applicable fallback. Pulls the universal Dublin Core fields into typed metadata
+ * and routes everything else to the tagged tail, so an unknown or unsupported format
+ * still produces a useful, lossless Document.
+ */
+public final class GenericDocumentTransformer implements DocumentTransformer {
+
+ @Override
+ public boolean appliesTo(Metadata tika) {
+ return true;
+ }
+
+ @Override
+ public void transform(Metadata tika, Document.Builder document, Set consumed) {
+ TransformSupport.mapCommonFields(tika, document.getMetadataBuilder(), consumed);
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/HtmlDocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/HtmlDocumentTransformer.java
new file mode 100644
index 00000000000..7d7bd41bcea
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/HtmlDocumentTransformer.java
@@ -0,0 +1,55 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * HTML / XHTML. Maps the common, cross-format facts into typed DocumentMetadata.
+ *
+ * HTML-specific properties (html:meta:*, Open Graph og:*, Twitter Card twitter:*,
+ * html:link:*, ICBM geo, encoding-detector fields, ...) are NOT given their own proto
+ * fields. They flow into the tagged tail, typed where Tika declares the type and string
+ * otherwise. That is the whole point: format richness lives in code plus the tail, not
+ * in the wire contract - so the schema stays small and stable and clients never rebuild
+ * when we add or change an HTML property.
+ */
+public final class HtmlDocumentTransformer implements DocumentTransformer {
+
+ @Override
+ public boolean appliesTo(Metadata tika) {
+ String contentType = tika.get(Metadata.CONTENT_TYPE);
+ if (contentType == null) {
+ return false;
+ }
+ String lower = contentType.toLowerCase(Locale.ROOT);
+ return lower.contains("text/html") || lower.contains("application/xhtml");
+ }
+
+ @Override
+ public void transform(Metadata tika, Document.Builder document, Set consumed) {
+ // Common, cross-format fields -> typed (a date is a Timestamp, a count is an int).
+ TransformSupport.mapCommonFields(tika, document.getMetadataBuilder(), consumed);
+
+ // Everything HTML-specific (html:meta:*, og:*, twitter:*, html:link:*, ICBM, ...)
+ // lands in the tagged tail, appended once by DocumentTransformers.
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/ImageDocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/ImageDocumentTransformer.java
new file mode 100644
index 00000000000..e9ce47d8974
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/ImageDocumentTransformer.java
@@ -0,0 +1,67 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.DocumentMetadata;
+import org.apache.tika.metadata.IPTC;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TIFF;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+/**
+ * Images. Maps the common, cross-format facts into typed DocumentMetadata.
+ *
+ * Image-specific properties (EXIF exposure/f-number/focal-length/flash/ISO, GPS
+ * coordinates, IPTC headline/category/credit-line/copyright-notice, Photoshop
+ * city/country/state, equipment make/model, resolution, orientation, ...) are NOT
+ * given their own proto fields. They flow into the tagged tail, typed where Tika
+ * declares the type and string otherwise. That is the whole point: format richness
+ * lives in code plus the tail, not in the wire contract - so the schema stays small
+ * and stable and clients never rebuild when we add or change an image property.
+ */
+public final class ImageDocumentTransformer implements DocumentTransformer {
+
+ @Override
+ public boolean appliesTo(Metadata tika) {
+ String contentType = tika.get(Metadata.CONTENT_TYPE);
+ return contentType != null && contentType.toLowerCase(Locale.ROOT).startsWith("image/");
+ }
+
+ @Override
+ public void transform(Metadata tika, Document.Builder document, Set consumed) {
+ DocumentMetadata.Builder meta = document.getMetadataBuilder();
+
+ // Images deliberately do NOT use TransformSupport.mapCommonFields: their common
+ // facts come from image-native properties (IPTC keywords, EXIF/TIFF original date),
+ // and dc:creator / dc:language / dcterms:created rarely mean for a photo what they
+ // mean for a document, so those stay in the tagged tail untouched.
+ TransformSupport.setString(tika, TikaCoreProperties.TITLE, meta::setTitle, consumed);
+ TransformSupport.setString(tika, TikaCoreProperties.DESCRIPTION, meta::setDescription, consumed);
+ TransformSupport.addStrings(tika, IPTC.KEYWORDS, meta::addAllKeywords, consumed);
+ TransformSupport.setTimestamp(tika, TIFF.ORIGINAL_DATE, meta::setCreated, consumed);
+ TransformSupport.setTimestamp(tika, TikaCoreProperties.MODIFIED, meta::setModified, consumed);
+ TransformSupport.setInt(tika, TIFF.IMAGE_WIDTH, meta::setWidth, consumed);
+ TransformSupport.setInt(tika, TIFF.IMAGE_LENGTH, meta::setHeight, consumed);
+
+ // Everything image-specific (exif:*, tiff:*, IPTC-*, photoshop:*, geo:*, ...) lands
+ // in the tagged tail, appended once by DocumentTransformers.
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/MetadataTagger.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/MetadataTagger.java
new file mode 100644
index 00000000000..330469ac0f4
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/MetadataTagger.java
@@ -0,0 +1,180 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.time.Instant;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Set;
+
+import com.google.protobuf.Timestamp;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.MetadataField;
+import org.apache.tika.grpc.v1.MetadataValue;
+import org.apache.tika.grpc.v1.StringList;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
+
+/**
+ * Builds the tagged metadata tail. The tag comes from Tika's declared
+ * {@link Property} value type. Keys with no declared type are emitted as strings,
+ * never guessed - so we never turn "8 8 8 8" into a broken integer.
+ */
+public final class MetadataTagger {
+
+ /**
+ * {@link Property#get(String)} can only see Property constants whose declaring class
+ * has already been initialized by the JVM. In the gRPC server the parse runs in a
+ * forked pipes process, so nothing in the server JVM necessarily touches e.g.
+ * {@code org.apache.tika.metadata.PDF} before mapping -- and declared types
+ * ({@code pdf:encrypted} is a boolean, ...) would silently degrade to plain strings
+ * depending on class-loading order. Force-initialize the tika-core property
+ * vocabularies once so tagging is deterministic.
+ */
+ private static final Class>[] PROPERTY_VOCABULARIES = {
+ org.apache.tika.metadata.AccessPermissions.class,
+ org.apache.tika.metadata.ClimateForcast.class,
+ org.apache.tika.metadata.CreativeCommons.class,
+ org.apache.tika.metadata.Database.class,
+ org.apache.tika.metadata.DublinCore.class,
+ org.apache.tika.metadata.DWG.class,
+ org.apache.tika.metadata.Epub.class,
+ org.apache.tika.metadata.ExternalProcess.class,
+ org.apache.tika.metadata.FileSystem.class,
+ org.apache.tika.metadata.Font.class,
+ org.apache.tika.metadata.Geographic.class,
+ org.apache.tika.metadata.HTML.class,
+ org.apache.tika.metadata.HttpHeaders.class,
+ org.apache.tika.metadata.IPTC.class,
+ org.apache.tika.metadata.MachineMetadata.class,
+ org.apache.tika.metadata.MAPI.class,
+ org.apache.tika.metadata.Message.class,
+ org.apache.tika.metadata.Office.class,
+ org.apache.tika.metadata.OfficeOpenXMLCore.class,
+ org.apache.tika.metadata.OfficeOpenXMLExtended.class,
+ org.apache.tika.metadata.PageAnchoring.class,
+ org.apache.tika.metadata.PagedText.class,
+ org.apache.tika.metadata.PDF.class,
+ org.apache.tika.metadata.Photoshop.class,
+ org.apache.tika.metadata.PST.class,
+ org.apache.tika.metadata.QuattroPro.class,
+ org.apache.tika.metadata.Rendering.class,
+ org.apache.tika.metadata.RTFMetadata.class,
+ org.apache.tika.metadata.TIFF.class,
+ org.apache.tika.metadata.TikaCoreProperties.class,
+ org.apache.tika.metadata.TikaPagedText.class,
+ org.apache.tika.metadata.WARC.class,
+ org.apache.tika.metadata.WordPerfect.class,
+ org.apache.tika.metadata.XMP.class,
+ org.apache.tika.metadata.XMPDC.class,
+ org.apache.tika.metadata.XMPDM.class,
+ org.apache.tika.metadata.XMPIdq.class,
+ org.apache.tika.metadata.XMPMM.class,
+ org.apache.tika.metadata.XMPPDF.class,
+ org.apache.tika.metadata.XMPRights.class,
+ org.apache.tika.metadata.Zip.class,
+ };
+
+ static {
+ for (Class> vocabulary : PROPERTY_VOCABULARIES) {
+ try {
+ // A class literal alone does NOT initialize the class; forName(…, true, …) does.
+ Class.forName(vocabulary.getName(), true, vocabulary.getClassLoader());
+ } catch (ClassNotFoundException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+ }
+
+ private MetadataTagger() {
+ }
+
+ /** Append every key not already consumed by a typed field into {@code Document.extra}. */
+ public static void appendTail(Metadata tika, Set consumed, Document.Builder document) {
+ for (String key : tika.names()) {
+ if (consumed.contains(key)) {
+ continue;
+ }
+ MetadataValue value = tag(tika, key);
+ if (value != null) {
+ document.addExtra(MetadataField.newBuilder().setKey(key).setValue(value).build());
+ }
+ }
+ }
+
+ /** Tag one key by its declared Property type; fall back to multivalue strings. */
+ public static MetadataValue tag(Metadata tika, String key) {
+ String[] values = tika.getValues(key);
+ if (values == null || values.length == 0) {
+ return null;
+ }
+ Property property = Property.get(key);
+ if (values.length == 1 && property != null) {
+ MetadataValue typed = tagByDeclaredType(tika, property, values[0].trim());
+ if (typed != null) {
+ return typed;
+ }
+ }
+ StringList.Builder strings = StringList.newBuilder();
+ for (String v : values) {
+ if (v != null && !v.trim().isEmpty()) {
+ strings.addValues(v.trim());
+ }
+ }
+ return strings.getValuesCount() == 0
+ ? null
+ : MetadataValue.newBuilder().setStrings(strings).build();
+ }
+
+ private static MetadataValue tagByDeclaredType(Metadata tika, Property property, String raw) {
+ switch (property.getPrimaryProperty().getValueType()) {
+ case INTEGER:
+ try {
+ return MetadataValue.newBuilder().setInteger(Long.parseLong(raw)).build();
+ } catch (NumberFormatException ignored) {
+ return null;
+ }
+ case REAL:
+ case RATIONAL:
+ try {
+ return MetadataValue.newBuilder().setNumber(Double.parseDouble(raw)).build();
+ } catch (NumberFormatException ignored) {
+ return null;
+ }
+ case BOOLEAN:
+ return MetadataValue.newBuilder().setBoolean(parseBoolean(raw)).build();
+ case DATE:
+ Date date = tika.getDate(property);
+ return date == null
+ ? null
+ : MetadataValue.newBuilder().setTimestamp(toTimestamp(date)).build();
+ default:
+ return null; // not a declared scalar -> caller falls back to strings
+ }
+ }
+
+ static boolean parseBoolean(String v) {
+ String s = v.trim().toLowerCase(Locale.ROOT);
+ return "true".equals(s) || "yes".equals(s) || "1".equals(s);
+ }
+
+ static Timestamp toTimestamp(Date date) {
+ Instant i = date.toInstant();
+ return Timestamp.newBuilder().setSeconds(i.getEpochSecond()).setNanos(i.getNano()).build();
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/OfficeDocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/OfficeDocumentTransformer.java
new file mode 100644
index 00000000000..1dd66fb33ec
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/OfficeDocumentTransformer.java
@@ -0,0 +1,71 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.DocumentMetadata;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Office;
+
+/**
+ * Office (MS Word/Excel/PowerPoint, OOXML, OpenDocument). Maps the common, cross-format
+ * facts into typed DocumentMetadata.
+ *
+ * Office-specific properties (slide/paragraph/line/table/image/object counts, OOXML
+ * core/extended properties, hidden sheets/slides, comments, track changes, ...) are NOT
+ * given their own proto fields. They flow into the tagged tail, typed where Tika declares
+ * the type and string otherwise. That is the whole point: format richness lives in code
+ * plus the tail, not in the wire contract - so the schema stays small and stable and
+ * clients never rebuild when we add or change an Office property.
+ */
+public final class OfficeDocumentTransformer implements DocumentTransformer {
+
+ @Override
+ public boolean appliesTo(Metadata tika) {
+ String contentType = tika.get(Metadata.CONTENT_TYPE);
+ if (contentType == null) {
+ return false;
+ }
+ String lower = contentType.toLowerCase(Locale.ROOT);
+ return lower.contains("officedocument")
+ || lower.contains("msword")
+ || lower.contains("ms-excel")
+ || lower.contains("ms-powerpoint")
+ || lower.contains("opendocument")
+ || lower.contains("vnd.ms-")
+ || lower.contains("vnd.openxmlformats");
+ }
+
+ @Override
+ public void transform(Metadata tika, Document.Builder document, Set consumed) {
+ DocumentMetadata.Builder meta = document.getMetadataBuilder();
+
+ // Common, cross-format fields -> typed (a date is a Timestamp, a count is an int).
+ // Office keywords live under meta:keyword, not dc:subject.
+ TransformSupport.mapCommonFields(tika, meta, consumed, Office.KEYWORDS);
+ TransformSupport.setInt(tika, Office.PAGE_COUNT, meta::setPageCount, consumed);
+ TransformSupport.setLong(tika, Office.WORD_COUNT, meta::setWordCount, consumed);
+ TransformSupport.setLong(tika, Office.CHARACTER_COUNT, meta::setCharacterCount, consumed);
+
+ // Everything Office-specific (slide/paragraph/line/table/image/object counts,
+ // OOXML core/extended properties, hidden sheets/slides, comments, track changes, ...)
+ // lands in the tagged tail, appended once by DocumentTransformers.
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/PdfDocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/PdfDocumentTransformer.java
new file mode 100644
index 00000000000..0439a0cc595
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/PdfDocumentTransformer.java
@@ -0,0 +1,58 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.DocumentMetadata;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Office;
+import org.apache.tika.metadata.PagedText;
+
+/**
+ * PDF. Maps the common, cross-format facts into typed DocumentMetadata.
+ *
+ * PDF-specific properties (encryption flags, XMP, permissions, incremental updates, ...)
+ * are NOT given their own proto fields. They flow into the tagged tail, typed where Tika
+ * declares the type and string otherwise. That is the whole point: format richness lives
+ * in code plus the tail, not in the wire contract - so the schema stays small and stable
+ * and clients never rebuild when we add or change a PDF property.
+ */
+public final class PdfDocumentTransformer implements DocumentTransformer {
+
+ @Override
+ public boolean appliesTo(Metadata tika) {
+ String contentType = tika.get(Metadata.CONTENT_TYPE);
+ return contentType != null && contentType.toLowerCase(Locale.ROOT).contains("pdf");
+ }
+
+ @Override
+ public void transform(Metadata tika, Document.Builder document, Set consumed) {
+ DocumentMetadata.Builder meta = document.getMetadataBuilder();
+
+ // Common, cross-format fields -> typed (a date is a Timestamp, a count is an int).
+ TransformSupport.mapCommonFields(tika, meta, consumed);
+ TransformSupport.setInt(tika, PagedText.N_PAGES, meta::setPageCount, consumed);
+ TransformSupport.setLong(tika, Office.WORD_COUNT, meta::setWordCount, consumed);
+
+ // Everything PDF-specific (pdf:*, xmpPDF:*, access_permission:*, ...) lands in the
+ // tagged tail, appended once by DocumentTransformers after every applicable
+ // transformer has contributed to `consumed`.
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/RtfDocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/RtfDocumentTransformer.java
new file mode 100644
index 00000000000..5d298356149
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/RtfDocumentTransformer.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.grpc.mapper.transform;
+
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.DocumentMetadata;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Office;
+
+/**
+ * RTF. Maps the common, cross-format facts into typed DocumentMetadata.
+ *
+ * RTF-specific properties (encapsulated HTML flags, embedded-object thumbnails and
+ * application/class/topic/item strings, OOXML core/extended fields such as category,
+ * manager, company, template, ...) are NOT given their own proto fields. They flow into
+ * the tagged tail, typed where Tika declares the type and string otherwise. That is the
+ * whole point: format richness lives in code plus the tail, not in the wire contract - so
+ * the schema stays small and stable and clients never rebuild when we add or change an
+ * RTF property.
+ */
+public final class RtfDocumentTransformer implements DocumentTransformer {
+
+ @Override
+ public boolean appliesTo(Metadata tika) {
+ String contentType = tika.get(Metadata.CONTENT_TYPE);
+ return contentType != null && contentType.toLowerCase(Locale.ROOT).contains("rtf");
+ }
+
+ @Override
+ public void transform(Metadata tika, Document.Builder document, Set consumed) {
+ DocumentMetadata.Builder meta = document.getMetadataBuilder();
+
+ // Common, cross-format fields -> typed (a date is a Timestamp, a count is an int).
+ // RTF keywords ride on the Office property set (meta:keyword), not dc:subject.
+ TransformSupport.mapCommonFields(tika, meta, consumed, Office.KEYWORDS);
+ TransformSupport.setInt(tika, Office.PAGE_COUNT, meta::setPageCount, consumed);
+ TransformSupport.setLong(tika, Office.WORD_COUNT, meta::setWordCount, consumed);
+ TransformSupport.setLong(tika, Office.CHARACTER_COUNT, meta::setCharacterCount, consumed);
+
+ // Everything RTF-specific (rtf_meta:*, extended-properties:*, ...) lands in the
+ // tagged tail, appended once by DocumentTransformers.
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/TransformSupport.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/TransformSupport.java
new file mode 100644
index 00000000000..660ccbe5dbd
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/TransformSupport.java
@@ -0,0 +1,126 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import com.google.protobuf.Timestamp;
+
+import org.apache.tika.grpc.v1.DocumentMetadata;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.Property;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+/**
+ * Helpers for transformers: set a typed Document field from a Tika Property and mark the
+ * key consumed so it is not duplicated into the tagged tail.
+ */
+public final class TransformSupport {
+
+ private TransformSupport() {
+ }
+
+ /**
+ * Maps the common, cross-format fields -- title, description, authors, keywords,
+ * languages, created, modified -- into typed {@link DocumentMetadata}. Keywords come
+ * from {@link TikaCoreProperties#SUBJECT} (Dublin Core). Kept in one place so the
+ * common mapping cannot drift between format transformers.
+ */
+ public static void mapCommonFields(Metadata md, DocumentMetadata.Builder meta, Set consumed) {
+ mapCommonFields(md, meta, consumed, TikaCoreProperties.SUBJECT);
+ }
+
+ /**
+ * Same as {@link #mapCommonFields(Metadata, DocumentMetadata.Builder, Set)} but with
+ * the keyword source overridden, for formats whose keyword bag lives under a
+ * different property (e.g. Office and RTF use {@code meta:keyword}); {@code dc:subject}
+ * then stays in the tagged tail because those formats treat it as a distinct field.
+ */
+ public static void mapCommonFields(Metadata md, DocumentMetadata.Builder meta, Set consumed,
+ Property keywordsProperty) {
+ setString(md, TikaCoreProperties.TITLE, meta::setTitle, consumed);
+ setString(md, TikaCoreProperties.DESCRIPTION, meta::setDescription, consumed);
+ addStrings(md, TikaCoreProperties.CREATOR, meta::addAllAuthors, consumed);
+ addStrings(md, keywordsProperty, meta::addAllKeywords, consumed);
+ addStrings(md, TikaCoreProperties.LANGUAGE, meta::addAllLanguages, consumed);
+ setTimestamp(md, TikaCoreProperties.CREATED, meta::setCreated, consumed);
+ setTimestamp(md, TikaCoreProperties.MODIFIED, meta::setModified, consumed);
+ }
+
+ public static void setString(Metadata md, Property key, Consumer setter, Set consumed) {
+ String v = md.get(key);
+ if (v != null && !v.trim().isEmpty()) {
+ setter.accept(v.trim());
+ consumed.add(key.getName());
+ }
+ }
+
+ public static void addStrings(Metadata md, Property key, Consumer> setter, Set consumed) {
+ String[] values = md.getValues(key);
+ if (values != null && values.length > 0) {
+ List list = Arrays.stream(values)
+ .filter(s -> s != null && !s.trim().isEmpty())
+ .map(String::trim)
+ .collect(Collectors.toList());
+ if (!list.isEmpty()) {
+ setter.accept(list);
+ consumed.add(key.getName());
+ }
+ }
+ }
+
+ public static void setInt(Metadata md, Property key, Consumer setter, Set consumed) {
+ Integer v = md.getInt(key);
+ if (v != null) {
+ setter.accept(v);
+ consumed.add(key.getName());
+ }
+ }
+
+ public static void setLong(Metadata md, Property key, Consumer setter, Set consumed) {
+ Integer v = md.getInt(key);
+ if (v != null) {
+ setter.accept(v.longValue());
+ consumed.add(key.getName());
+ }
+ }
+
+ public static void setDouble(Metadata md, Property key, Consumer setter, Set consumed) {
+ String v = md.get(key);
+ if (v != null && !v.trim().isEmpty()) {
+ try {
+ setter.accept(Double.parseDouble(v.trim()));
+ consumed.add(key.getName());
+ } catch (NumberFormatException ignored) {
+ // leave unconsumed; falls through to the tagged tail
+ }
+ }
+ }
+
+ public static void setTimestamp(Metadata md, Property key, Consumer setter, Set consumed) {
+ Date d = md.getDate(key);
+ if (d != null) {
+ setter.accept(MetadataTagger.toTimestamp(d));
+ consumed.add(key.getName());
+ }
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/WarcDocumentTransformer.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/WarcDocumentTransformer.java
new file mode 100644
index 00000000000..195e39f7793
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/WarcDocumentTransformer.java
@@ -0,0 +1,58 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * WARC (Web ARChive) containers. Unlike most formats, WARC's useful facts - WARC-Date,
+ * WARC-Target-URI, WARC-Record-ID, HTTP status, HTTP response headers, ... - are not
+ * normalized by {@code org.apache.tika.parser.warc.WARCParser} onto declared
+ * {@link org.apache.tika.metadata.Property} constants. The parser adds them under literal
+ * string keys instead ({@code warc:WARC-Date}, {@code warc:WARC-Target-URI},
+ * {@code warc:http:status}, {@code warc:http:*}, ...). {@code org.apache.tika.metadata.WARC}
+ * only declares a handful of properties (WARC_RECORD_CONTENT_TYPE, WARC_PAYLOAD_CONTENT_TYPE,
+ * WARC_RECORD_ID, WARC_WARNING), and none of the common, cross-format facts that this
+ * transformer could map (title, created, ...) are actually populated by the parser for a
+ * WARC record.
+ *
+ * There is therefore nothing here that fairly maps onto the small set of typed
+ * DocumentMetadata fields - forcing one would be dishonest, not useful. That is fine: the
+ * tagged tail already handles arbitrary string keys, typed by Tika's declared Property type
+ * where one exists and as plain strings otherwise, so none of the WARC/HTTP richness is
+ * lost - it just does not need its own proto fields.
+ */
+public final class WarcDocumentTransformer implements DocumentTransformer {
+
+ @Override
+ public boolean appliesTo(Metadata tika) {
+ String contentType = tika.get(Metadata.CONTENT_TYPE);
+ return contentType != null && contentType.toLowerCase(Locale.ROOT).contains("warc");
+ }
+
+ @Override
+ public void transform(Metadata tika, Document.Builder document, Set consumed) {
+ // No common field fairly maps to the typed DocumentMetadata for a WARC record (see
+ // class javadoc). Everything (warc:WARC-Date, warc:WARC-Target-URI,
+ // warc:WARC-Record-ID, warc:http:status, warc:http:*, ...) lands in the tagged tail,
+ // appended once by DocumentTransformers.
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/ClasspathTestDocuments.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/ClasspathTestDocuments.java
new file mode 100644
index 00000000000..fba712e0a1e
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/ClasspathTestDocuments.java
@@ -0,0 +1,111 @@
+/*
+ * 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.grpc.mapper;
+
+import java.io.IOException;
+import java.net.JarURLConnection;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Discovers {@code test-documents/*} fixtures contributed by parser module test-jars on the classpath.
+ */
+final class ClasspathTestDocuments {
+
+ private static final String TEST_DOCUMENTS_DIR = "test-documents";
+
+ private ClasspathTestDocuments() {
+ }
+
+ static List listByExtension(String extension) throws IOException {
+ String suffix = extension.startsWith(".") ? extension : "." + extension;
+ Set found = new TreeSet<>();
+ ClassLoader classLoader = ClasspathTestDocuments.class.getClassLoader();
+ Enumeration roots = classLoader.getResources(TEST_DOCUMENTS_DIR);
+ while (roots.hasMoreElements()) {
+ collectFromUrl(roots.nextElement(), suffix, found);
+ }
+ return new ArrayList<>(found);
+ }
+
+ static List listByExtensions(String... extensions) throws IOException {
+ Set found = new TreeSet<>();
+ for (String extension : extensions) {
+ found.addAll(listByExtension(extension));
+ }
+ return new ArrayList<>(found);
+ }
+
+ private static void collectFromUrl(URL url, String suffix, Set found) throws IOException {
+ if ("file".equals(url.getProtocol())) {
+ try {
+ Path dir = Paths.get(url.toURI());
+ if (Files.isDirectory(dir)) {
+ collectFromDirectory(dir, suffix, found);
+ }
+ } catch (java.net.URISyntaxException e) {
+ throw new IOException("Bad test-documents URL: " + url, e);
+ }
+ return;
+ }
+ if ("jar".equals(url.getProtocol())) {
+ collectFromJarUrl(url, suffix, found);
+ }
+ }
+
+ private static void collectFromDirectory(Path dir, String suffix, Set found) throws IOException {
+ try (var paths = Files.walk(dir)) {
+ paths.filter(Files::isRegularFile)
+ .filter(path -> path.getFileName().toString().endsWith(suffix))
+ .forEach(path -> found.add(path.getFileName().toString()));
+ }
+ }
+
+ private static void collectFromJarUrl(URL url, String suffix, Set found) throws IOException {
+ JarURLConnection connection = (JarURLConnection) url.openConnection();
+ String prefix = connection.getEntryName();
+ if (prefix == null) {
+ prefix = TEST_DOCUMENTS_DIR;
+ }
+ if (!prefix.endsWith("/")) {
+ prefix = prefix + "/";
+ }
+ JarFile jarFile = connection.getJarFile();
+ final String entryPrefix = prefix;
+ for (JarEntry entry : jarFile.stream().toList()) {
+ if (entry.isDirectory()) {
+ continue;
+ }
+ String name = entry.getName();
+ if (!name.startsWith(entryPrefix) || !name.endsWith(suffix)) {
+ continue;
+ }
+ int slash = name.lastIndexOf('/');
+ found.add(slash >= 0 ? name.substring(slash + 1) : name);
+ }
+ }
+
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/DocumentBuilderTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/DocumentBuilderTest.java
new file mode 100644
index 00000000000..7cff0800695
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/DocumentBuilderTest.java
@@ -0,0 +1,132 @@
+/*
+ * 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.grpc.mapper;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.FormatCategory;
+import org.apache.tika.grpc.v1.ParseStatus;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+/**
+ * End-to-end coverage of {@link DocumentBuilder}: the content tree (parsed from the
+ * pipes-default markdown rendering), typed/tagged metadata (via {@link
+ * org.apache.tika.grpc.mapper.transform.DocumentTransformers}), format-category routing,
+ * status, and embedded-document recursion.
+ */
+class DocumentBuilderTest extends ParseFixtureSupport {
+
+ @Test
+ void buildsContentTreeAndMetadataFromRealPdf() throws Exception {
+ Document document = map(parseBody("testPDF.pdf"), "doc-1", 42L);
+
+ assertTrue(document.getMarkdown().contains("Tika"));
+ assertFalse(document.getBlocksList().isEmpty());
+ assertEquals(FormatCategory.FORMAT_CATEGORY_PDF, document.getFormatCategory());
+ assertEquals("application/pdf", document.getContentType());
+ assertEquals("Apache Tika - Apache Tika", document.getMetadata().getTitle());
+ assertTrue(document.getExtraCount() > 0);
+
+ assertEquals(ParseStatus.Status.SUCCESS, document.getStatus().getStatus());
+ assertEquals(42L, document.getStatus().getFetchParseTimeMs());
+ }
+
+ @Test
+ void recursesIntoEmbeddedDocuments() {
+ Metadata primary = new Metadata();
+ primary.set(Metadata.CONTENT_TYPE, "application/pdf");
+ primary.set(TikaCoreProperties.TITLE, "Container");
+
+ Metadata embedded = new Metadata();
+ embedded.set(Metadata.CONTENT_TYPE, "image/jpeg");
+ embedded.set(TikaCoreProperties.RESOURCE_NAME_KEY, "photo.jpg");
+ embedded.set(TikaCoreProperties.TIKA_CONTENT, "an embedded photo");
+
+ Document document = DocumentBuilder.build(
+ primary, List.of(primary, embedded), "# Container\n", "doc-1", "PARSE_SUCCESS", 5L);
+
+ assertEquals(1, document.getEmbeddedCount());
+ Document child = document.getEmbedded(0);
+ assertEquals("image/jpeg", child.getContentType());
+ assertEquals(FormatCategory.FORMAT_CATEGORY_IMAGE, child.getFormatCategory());
+ assertEquals("photo.jpg", child.getOrigin().getFilename());
+ assertEquals("an embedded photo", child.getMarkdown());
+ }
+
+ /**
+ * {@code primary == null} is the server's signal that the pipes result carried no
+ * metadata at all (see TikaGrpcServerImpl). On a failure status that surfaces as an
+ * explicit error; on {@code EMPTY_OUTPUT} -- a success with nothing to map -- it must
+ * NOT be reported as a failure.
+ */
+ @Test
+ void reportsFailureWhenNoMetadata() {
+ Document document = DocumentBuilder.build(null, null, null, "doc-1", "FETCH_EXCEPTION", 1L);
+
+ assertEquals("doc-1", document.getId());
+ assertEquals(ParseStatus.Status.FAILED, document.getStatus().getStatus());
+ assertFalse(document.getStatus().getErrorsList().isEmpty());
+ }
+
+ @Test
+ void emptyOutputWithNoMetadataIsSuccessNotFailure() {
+ Document document = DocumentBuilder.build(null, null, null, "doc-1", "EMPTY_OUTPUT", 1L);
+
+ assertEquals(ParseStatus.Status.SUCCESS, document.getStatus().getStatus());
+ assertTrue(document.getStatus().getErrorsList().isEmpty());
+ }
+
+ /**
+ * {@code pipesStatus} is populated from {@code PipesResult.RESULT_STATUS.name()} (see
+ * org.apache.tika.pipes.api.PipesResult), never from a plain "OK" -- there is no such
+ * enum constant. A real successful parse reports as e.g. "PARSE_SUCCESS" or
+ * "EMIT_SUCCESS", and a real timeout reports as "TIMEOUT" (categorized as a process
+ * crash, not a partial success). The status mapping must recognize the real names.
+ */
+ @Test
+ void mapsRealPipesResultStatusNames() {
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "text/plain");
+
+ assertEquals(ParseStatus.Status.SUCCESS,
+ DocumentBuilder.build(metadata, null, null, "d", "PARSE_SUCCESS", 1L).getStatus().getStatus(),
+ "PARSE_SUCCESS is a real, clean success status");
+ assertEquals(ParseStatus.Status.SUCCESS,
+ DocumentBuilder.build(metadata, null, null, "d", "EMIT_SUCCESS", 1L).getStatus().getStatus());
+ assertEquals(ParseStatus.Status.SUCCESS,
+ DocumentBuilder.build(metadata, null, null, "d", "EMPTY_OUTPUT", 1L).getStatus().getStatus());
+
+ assertEquals(ParseStatus.Status.PARTIAL,
+ DocumentBuilder.build(metadata, null, null, "d", "PARSE_SUCCESS_WITH_EXCEPTION", 1L)
+ .getStatus().getStatus(),
+ "succeeded, but with a caveat along the way, is a partial success not a clean one");
+
+ assertEquals(ParseStatus.Status.FAILED,
+ DocumentBuilder.build(metadata, null, null, "d", "TIMEOUT", 1L).getStatus().getStatus(),
+ "TIMEOUT is categorized as a process crash, not a partial success");
+ assertEquals(ParseStatus.Status.FAILED,
+ DocumentBuilder.build(metadata, null, null, "d", "OOM", 1L).getStatus().getStatus());
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/ParseFixtureSupport.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/ParseFixtureSupport.java
new file mode 100644
index 00000000000..e6fdf1eeb20
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/ParseFixtureSupport.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.grpc.mapper;
+
+import java.util.List;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.ToMarkdownContentHandler;
+
+/**
+ * Shared helpers: parse Tika test fixtures and map them through {@link DocumentBuilder}.
+ */
+public abstract class ParseFixtureSupport extends TikaTest {
+
+ /**
+ * Result of parsing a fixture with markdown body extraction enabled.
+ */
+ public record ParseFixture(Metadata primary, List allMetadata, String body) {
+ }
+
+ protected ParseFixture parseBody(String fileName) throws Exception {
+ java.io.StringWriter writer = new java.io.StringWriter();
+ ToMarkdownContentHandler handler = new ToMarkdownContentHandler(writer);
+ Metadata metadata = new Metadata();
+ try (TikaInputStream input = getResourceAsStream("/test-documents/" + fileName)) {
+ AUTO_DETECT_PARSER.parse(input, handler, metadata, new ParseContext());
+ }
+ return new ParseFixture(metadata, List.of(metadata), writer.toString());
+ }
+
+ protected Document map(ParseFixture fixture, String docId, long fetchParseTimeMs) {
+ // "PARSE_SUCCESS" mirrors a real org.apache.tika.pipes.api.PipesResult.RESULT_STATUS
+ // name -- there is no "OK" constant on that enum.
+ return DocumentBuilder.build(
+ fixture.primary(), fixture.allMetadata(), fixture.body(), docId, "PARSE_SUCCESS", fetchParseTimeMs);
+ }
+
+ protected Document map(ParseFixture fixture, String docId) {
+ return map(fixture, docId, 1L);
+ }
+
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/content/MarkdownBlockTreeBuilderTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/content/MarkdownBlockTreeBuilderTest.java
new file mode 100644
index 00000000000..39c9c2ee4e9
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/content/MarkdownBlockTreeBuilderTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.grpc.mapper.content;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.grpc.v1.Alignment;
+import org.apache.tika.grpc.v1.Block;
+import org.apache.tika.grpc.v1.Inline;
+
+class MarkdownBlockTreeBuilderTest {
+
+ @Test
+ void emptyOrNullMarkdownYieldsNoBlocks() {
+ assertTrue(MarkdownBlockTreeBuilder.toBlocks(null).isEmpty());
+ assertTrue(MarkdownBlockTreeBuilder.toBlocks("").isEmpty());
+ }
+
+ @Test
+ void nestedBulletListInsideAnOrderedListItemIsPreserved() {
+ String markdown = "1. first\n"
+ + " - nested a\n"
+ + " - nested b\n"
+ + "2. second\n";
+
+ List blocks = MarkdownBlockTreeBuilder.toBlocks(markdown);
+ assertEquals(1, blocks.size());
+ Block ordered = blocks.get(0);
+ assertTrue(ordered.hasOrderedList());
+ assertEquals(2, ordered.getOrderedList().getItemsCount());
+
+ org.apache.tika.grpc.v1.ListItem first = ordered.getOrderedList().getItems(0);
+ boolean sawNestedBulletList = first.getBlocksList().stream().anyMatch(Block::hasBulletList);
+ assertTrue(sawNestedBulletList, "the nested bullet list under item 1 must survive the recursive conversion");
+
+ org.apache.tika.grpc.v1.BulletList nested = first.getBlocksList().stream()
+ .filter(Block::hasBulletList)
+ .map(Block::getBulletList)
+ .findFirst()
+ .orElseThrow();
+ assertEquals(2, nested.getItemsCount());
+ }
+
+ @Test
+ void gfmTableWithAlignmentAndMultipleRows() {
+ String markdown = "| Left | Center | Right |\n"
+ + "|:---|:---:|---:|\n"
+ + "| a | b | c |\n"
+ + "| d | e | f |\n";
+
+ List blocks = MarkdownBlockTreeBuilder.toBlocks(markdown);
+ assertEquals(1, blocks.size());
+ assertTrue(blocks.get(0).hasTable());
+
+ org.apache.tika.grpc.v1.Table table = blocks.get(0).getTable();
+ assertEquals(3, table.getHeaderCount());
+ assertEquals(Alignment.ALIGN_LEFT, table.getHeader(0).getAlignment());
+ assertEquals(Alignment.ALIGN_CENTER, table.getHeader(1).getAlignment());
+ assertEquals(Alignment.ALIGN_RIGHT, table.getHeader(2).getAlignment());
+
+ assertEquals(2, table.getRowsCount());
+ assertEquals(3, table.getRows(0).getCellsCount());
+ assertEquals(3, table.getRows(1).getCellsCount());
+ }
+
+ @Test
+ void linkAndImageCarryDestinationAndOptionalTitle() {
+ String markdown = "[link text](https://example.com/page \"a title\")\n\n"
+ + "\n";
+
+ List blocks = MarkdownBlockTreeBuilder.toBlocks(markdown);
+ assertEquals(2, blocks.size());
+
+ Inline linkInline = blocks.get(0).getParagraph().getContent(0);
+ assertTrue(linkInline.hasLink());
+ assertEquals("https://example.com/page", linkInline.getLink().getDestination());
+ assertEquals("a title", linkInline.getLink().getTitle());
+
+ Inline imageInline = blocks.get(1).getParagraph().getContent(0);
+ assertTrue(imageInline.hasImage());
+ assertEquals("https://example.com/img.png", imageInline.getImage().getDestination());
+ assertEquals("alt text", imageInline.getImage().getAlt());
+ assertEquals("", imageInline.getImage().getTitle(), "no title given in the markdown -> unset, not null-ish garbage");
+ }
+
+ @Test
+ void imageAltTextKeepsCodeSpansAndLineBreaks() {
+ String markdown = "\n";
+
+ List blocks = MarkdownBlockTreeBuilder.toBlocks(markdown);
+ Inline imageInline = blocks.get(0).getParagraph().getContent(0);
+ assertTrue(imageInline.hasImage());
+ assertEquals("see config.json for details", imageInline.getImage().getAlt(),
+ "code-span literals and soft breaks inside alt must not be dropped");
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/CreativeCommonsDocumentTransformerTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/CreativeCommonsDocumentTransformerTest.java
new file mode 100644
index 00000000000..60aad02f587
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/CreativeCommonsDocumentTransformerTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.XMPRights;
+
+/**
+ * Verifies {@link CreativeCommonsDocumentTransformer} against a synthetic XMP rights
+ * metadata set (this module's test-document corpus has no dedicated CC-licensed fixture),
+ * and confirms it does not fire on documents with no rights-related metadata at all.
+ */
+class CreativeCommonsDocumentTransformerTest {
+
+ @Test
+ void mapsRightsAndLicenseUrlAndTagsTheRest() {
+ Metadata metadata = new Metadata();
+ metadata.set(XMPRights.MARKED, "True");
+ metadata.set(XMPRights.WEB_STATEMENT, "https://creativecommons.org/licenses/by/4.0/");
+ metadata.set(TikaCoreProperties.RIGHTS, "Creative Commons Attribution 4.0");
+
+ CreativeCommonsDocumentTransformer transformer = new CreativeCommonsDocumentTransformer();
+ assertTrue(transformer.appliesTo(metadata));
+
+ Document.Builder builder = Document.newBuilder();
+ java.util.Set consumed = new java.util.HashSet<>();
+ transformer.transform(metadata, builder, consumed);
+ MetadataTagger.appendTail(metadata, consumed, builder);
+
+ assertEquals("https://creativecommons.org/licenses/by/4.0/", builder.getMetadata().getLicenseUrl());
+ assertEquals("Creative Commons Attribution 4.0", builder.getMetadata().getRights());
+ assertTrue(builder.getExtraCount() > 0);
+ }
+
+ @Test
+ void doesNotApplyToDocumentsWithNoRightsMetadata() {
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "text/plain");
+
+ CreativeCommonsDocumentTransformer transformer = new CreativeCommonsDocumentTransformer();
+ assertFalse(transformer.appliesTo(metadata));
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/DocumentTransformersTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/DocumentTransformersTest.java
new file mode 100644
index 00000000000..ab0165b68e2
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/DocumentTransformersTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.XMPRights;
+
+class DocumentTransformersTest {
+
+ /**
+ * A plain-text document (no format transformer applies) that also happens to carry
+ * Creative Commons rights metadata must still get the universal Dublin-Core-ish fields
+ * (title, authors, ...) mapped by the generic fallback. Before the fix, the
+ * cross-cutting {@link CreativeCommonsDocumentTransformer} matching on its own set
+ * {@code matched = true} and suppressed the fallback entirely, silently dropping title/
+ * author/description/keywords/languages/dates for any generic document that happens to
+ * carry rights metadata.
+ */
+ @Test
+ void crossCuttingTransformerDoesNotSuppressGenericFallback() {
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "text/plain");
+ metadata.set(TikaCoreProperties.TITLE, "A Generic Document");
+ metadata.add(TikaCoreProperties.CREATOR, "Jane Doe");
+ metadata.set(XMPRights.MARKED, "True");
+
+ Document.Builder builder = Document.newBuilder();
+ DocumentTransformers.defaults().transform(metadata, builder);
+
+ assertEquals("A Generic Document", builder.getMetadata().getTitle(),
+ "generic fallback must still run alongside a matching cross-cutting transformer");
+ assertTrue(builder.getMetadata().getAuthorsList().contains("Jane Doe"));
+ // the cross-cutting transformer's own mapping must also still apply
+ assertTrue(builder.getExtraList().stream().anyMatch(f -> f.getKey().equals(XMPRights.MARKED.getName())));
+ }
+
+ /**
+ * When two transformers both apply to the same document (e.g. a PDF that also carries
+ * Creative Commons rights metadata -- an explicitly supported, documented scenario), each
+ * transformer must not re-tag a key the other one already mapped to a typed field. Every
+ * key should appear in the tagged tail at most once.
+ */
+ @Test
+ void doesNotDuplicateTaggedTailWhenMultipleTransformersApply() {
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "application/pdf");
+ metadata.set(TikaCoreProperties.TITLE, "Report");
+ metadata.set(XMPRights.MARKED, "True");
+
+ Document.Builder builder = Document.newBuilder();
+ DocumentTransformers.defaults().transform(metadata, builder);
+
+ long titleTagCount = builder.getExtraList().stream()
+ .filter(f -> f.getKey().equals(TikaCoreProperties.TITLE.getName()))
+ .count();
+ assertEquals(0, titleTagCount,
+ "title is a typed field (already on document.metadata.title); it must not also "
+ + "be duplicated into the tagged tail by a second transformer");
+
+ long markedTagCount = builder.getExtraList().stream()
+ .filter(f -> f.getKey().equals(XMPRights.MARKED.getName()))
+ .count();
+ assertEquals(1, markedTagCount, "every tail key should appear at most once");
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/EpubDocumentTransformerTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/EpubDocumentTransformerTest.java
new file mode 100644
index 00000000000..06d3932c3cb
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/EpubDocumentTransformerTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+
+/**
+ * Verifies {@link EpubDocumentTransformer} against a real EPUB fixture: typed common
+ * fields land on {@link org.apache.tika.grpc.v1.DocumentMetadata}, and the many
+ * EPUB-specific properties (epub:*, dc:identifier, OPF/NAV structural fields, ...) land
+ * in the tagged tail.
+ */
+class EpubDocumentTransformerTest extends TikaTest {
+
+ @Test
+ void transformsRealEpubFixture() throws Exception {
+ Metadata metadata = new Metadata();
+ try (TikaInputStream tis = getResourceAsStream("/test-documents/testEPUB.epub")) {
+ AUTO_DETECT_PARSER.parse(tis, new BodyContentHandler(-1), metadata, new ParseContext());
+ }
+
+ EpubDocumentTransformer transformer = new EpubDocumentTransformer();
+ assertTrue(transformer.appliesTo(metadata));
+
+ Document.Builder builder = Document.newBuilder();
+ java.util.Set consumed = new java.util.HashSet<>();
+ transformer.transform(metadata, builder, consumed);
+ MetadataTagger.appendTail(metadata, consumed, builder);
+
+ assertFalse(builder.getMetadata().getTitle().isBlank());
+ assertTrue(builder.getExtraCount() > 0);
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/HtmlDocumentTransformerTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/HtmlDocumentTransformerTest.java
new file mode 100644
index 00000000000..26a3486053c
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/HtmlDocumentTransformerTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+
+/**
+ * Verifies {@link HtmlDocumentTransformer} against a real HTML fixture: typed common
+ * fields land on {@link org.apache.tika.grpc.v1.DocumentMetadata}, and the many
+ * HTML-specific properties (html:meta:*, Open Graph, Twitter Card, ...) land in the
+ * tagged tail.
+ */
+class HtmlDocumentTransformerTest extends TikaTest {
+
+ @Test
+ void transformsRealHtmlFixture() throws Exception {
+ Metadata metadata = new Metadata();
+ try (TikaInputStream tis = getResourceAsStream("/test-documents/testHTML_metadata.html")) {
+ AUTO_DETECT_PARSER.parse(tis, new BodyContentHandler(-1), metadata, new ParseContext());
+ }
+
+ HtmlDocumentTransformer transformer = new HtmlDocumentTransformer();
+ assertTrue(transformer.appliesTo(metadata));
+
+ Document.Builder builder = Document.newBuilder();
+ java.util.Set consumed = new java.util.HashSet<>();
+ transformer.transform(metadata, builder, consumed);
+ MetadataTagger.appendTail(metadata, consumed, builder);
+
+ assertFalse(builder.getMetadata().getTitle().isBlank());
+ assertTrue(builder.getExtraCount() > 0);
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/ImageDocumentTransformerTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/ImageDocumentTransformerTest.java
new file mode 100644
index 00000000000..02dc464bb98
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/ImageDocumentTransformerTest.java
@@ -0,0 +1,63 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.image.JpegParser;
+import org.apache.tika.sax.BodyContentHandler;
+
+/**
+ * Verifies {@link ImageDocumentTransformer} against a real JPEG fixture carrying EXIF, GPS,
+ * and IPTC metadata. Uses {@link JpegParser} directly rather than the auto-detecting parser:
+ * on a machine with GDAL command-line tools installed, MIME-based auto-detection can route
+ * {@code image/jpeg} to the (unrelated) GDAL raster parser instead, which does not extract
+ * EXIF/TIFF metadata at all. Targeting the parser this transformer actually cares about keeps
+ * the test deterministic regardless of what else is on the host.
+ */
+class ImageDocumentTransformerTest extends TikaTest {
+
+ @Test
+ void mapsTiffDimensionsAndTagsTheRest() throws Exception {
+ Metadata metadata = new Metadata();
+ // Calling JpegParser directly skips detection, so set Content-Type the way
+ // AutoDetectParser normally would before delegating.
+ metadata.set(Metadata.CONTENT_TYPE, "image/jpeg");
+ try (TikaInputStream tis = getResourceAsStream("/test-documents/testJPEG_EXIF.jpg")) {
+ new JpegParser().parse(tis, new BodyContentHandler(-1), metadata, new ParseContext());
+ }
+
+ ImageDocumentTransformer transformer = new ImageDocumentTransformer();
+ assertTrue(transformer.appliesTo(metadata));
+
+ Document.Builder builder = Document.newBuilder();
+ java.util.Set consumed = new java.util.HashSet<>();
+ transformer.transform(metadata, builder, consumed);
+ MetadataTagger.appendTail(metadata, consumed, builder);
+
+ assertTrue(builder.getMetadata().getWidth() > 0);
+ assertTrue(builder.getMetadata().getHeight() > 0);
+ assertTrue(builder.getExtraCount() > 0);
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/OfficeDocumentTransformerTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/OfficeDocumentTransformerTest.java
new file mode 100644
index 00000000000..62de41b102c
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/OfficeDocumentTransformerTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.DocumentMetadata;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+
+class OfficeDocumentTransformerTest extends TikaTest {
+
+ @Test
+ void mapsTypedFieldsAndTagsTheRest() throws Exception {
+ Metadata metadata = new Metadata();
+ try (TikaInputStream tis = getResourceAsStream("/test-documents/testWORD.doc")) {
+ AUTO_DETECT_PARSER.parse(tis, new BodyContentHandler(-1), metadata, new ParseContext());
+ }
+
+ OfficeDocumentTransformer transformer = new OfficeDocumentTransformer();
+ assertTrue(transformer.appliesTo(metadata), "should apply to application/msword");
+
+ Document.Builder builder = Document.newBuilder();
+ java.util.Set consumed = new java.util.HashSet<>();
+ transformer.transform(metadata, builder, consumed);
+ MetadataTagger.appendTail(metadata, consumed, builder);
+
+ DocumentMetadata meta = builder.getMetadata();
+ assertFalse(meta.getTitle().isBlank(), "title should be populated");
+ assertEquals("Sample Word Document", meta.getTitle());
+ assertEquals(1, meta.getAuthorsCount());
+ assertEquals("Keith Bennett", meta.getAuthors(0));
+ assertTrue(meta.hasCreated(), "created should be populated");
+ assertTrue(meta.hasModified(), "modified should be populated");
+ assertEquals(2, meta.getPageCount());
+ assertEquals(122L, meta.getWordCount());
+ assertEquals(699L, meta.getCharacterCount());
+
+ // Everything not mapped to a typed field (revision, extended properties,
+ // last-author, comment info, hidden-text flag, etc.) lands in the tagged tail.
+ assertTrue(builder.getExtraCount() > 0, "tagged tail should be populated");
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/PdfDocumentTransformerTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/PdfDocumentTransformerTest.java
new file mode 100644
index 00000000000..e88db845ff7
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/PdfDocumentTransformerTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+
+/**
+ * Verifies {@link PdfDocumentTransformer} against the real {@code testPDF.pdf} fixture.
+ */
+class PdfDocumentTransformerTest extends TikaTest {
+
+ @Test
+ void mapsTypedFieldsAndTagsTheRest() throws Exception {
+ Metadata metadata = new Metadata();
+ try (TikaInputStream tis = getResourceAsStream("/test-documents/testPDF.pdf")) {
+ AUTO_DETECT_PARSER.parse(tis, new BodyContentHandler(-1), metadata, new ParseContext());
+ }
+
+ PdfDocumentTransformer transformer = new PdfDocumentTransformer();
+ assertTrue(transformer.appliesTo(metadata));
+
+ Document.Builder builder = Document.newBuilder();
+ java.util.Set consumed = new java.util.HashSet<>();
+ transformer.transform(metadata, builder, consumed);
+ MetadataTagger.appendTail(metadata, consumed, builder);
+
+ assertEquals("Apache Tika - Apache Tika", builder.getMetadata().getTitle());
+ assertEquals(1, builder.getMetadata().getPageCount());
+ assertTrue(builder.getExtraCount() > 0);
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/RtfDocumentTransformerTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/RtfDocumentTransformerTest.java
new file mode 100644
index 00000000000..8a0506e6503
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/RtfDocumentTransformerTest.java
@@ -0,0 +1,63 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+
+class RtfDocumentTransformerTest extends TikaTest {
+
+ @Test
+ void mapsCommonFieldsAndTagsTheRest() throws Exception {
+ Metadata metadata = new Metadata();
+ try (TikaInputStream tis = getResourceAsStream("/test-documents/testRTF.rtf")) {
+ AUTO_DETECT_PARSER.parse(tis, new BodyContentHandler(-1), metadata, new ParseContext());
+ }
+
+ RtfDocumentTransformer transformer = new RtfDocumentTransformer();
+ assertTrue(transformer.appliesTo(metadata));
+
+ Document.Builder builder = Document.newBuilder();
+ java.util.Set consumed = new java.util.HashSet<>();
+ transformer.transform(metadata, builder, consumed);
+ MetadataTagger.appendTail(metadata, consumed, builder);
+
+ // testRTF.rtf carries a title, an author, a creation date and word/page/character
+ // counts. Assert on what this real fixture actually has, not on every field the
+ // transformer is capable of mapping.
+ assertEquals("Test d’indexation Word", builder.getMetadata().getTitle());
+ assertEquals(1, builder.getMetadata().getAuthorsCount());
+ assertEquals("Bibliotheque", builder.getMetadata().getAuthors(0));
+ assertTrue(builder.getMetadata().hasCreated());
+ assertEquals(1, builder.getMetadata().getPageCount());
+ assertEquals(3, builder.getMetadata().getWordCount());
+ assertEquals(21, builder.getMetadata().getCharacterCount());
+
+ // RTF/OOXML-specific properties (e.g. extended-properties:Company) are not typed
+ // fields; they must still show up in the tagged tail.
+ assertTrue(builder.getExtraCount() > 0);
+ }
+}
diff --git a/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/WarcDocumentTransformerTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/WarcDocumentTransformerTest.java
new file mode 100644
index 00000000000..3f3e16e4beb
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/transform/WarcDocumentTransformerTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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.grpc.mapper.transform;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+
+/**
+ * Verifies {@link WarcDocumentTransformer} against a real WARC fixture: it applies to
+ * WARC content types and routes the WARC/HTTP header fields into the tagged tail.
+ */
+class WarcDocumentTransformerTest extends TikaTest {
+
+ @Test
+ void appliesToWarcAndTagsTheTail() throws Exception {
+ Metadata metadata = new Metadata();
+ try (TikaInputStream tis = getResourceAsStream("/test-documents/testWARC_multiple.warc")) {
+ AUTO_DETECT_PARSER.parse(tis, new BodyContentHandler(-1), metadata, new ParseContext());
+ }
+
+ WarcDocumentTransformer transformer = new WarcDocumentTransformer();
+ assertTrue(transformer.appliesTo(metadata));
+
+ Document.Builder builder = Document.newBuilder();
+ java.util.Set consumed = new java.util.HashSet<>();
+ transformer.transform(metadata, builder, consumed);
+ MetadataTagger.appendTail(metadata, consumed, builder);
+
+ assertTrue(builder.getExtraCount() > 0);
+ }
+}
diff --git a/tika-grpc-mapper/src/test/resources/test-documents/sample.txt b/tika-grpc-mapper/src/test/resources/test-documents/sample.txt
new file mode 100644
index 00000000000..1461a4ea78a
--- /dev/null
+++ b/tika-grpc-mapper/src/test/resources/test-documents/sample.txt
@@ -0,0 +1 @@
+Sample plain text document for generic metadata mapping tests.
diff --git a/tika-grpc/README.md b/tika-grpc/README.md
index 2ebea44c002..2268b995898 100644
--- a/tika-grpc/README.md
+++ b/tika-grpc/README.md
@@ -1,8 +1,8 @@
-# Tika Pipes GRPC Server
+# Tika Pipes gRPC Server
-The following is the Tika Pipes GRPC Server.
-
-This server will manage a pool of Tika Pipes clients.
+The Tika Pipes gRPC server exposes fetcher and iterator management and document
+fetch-and-parse over gRPC. It runs a pool of Tika Pipes worker processes and routes
+requests through the configured fetchers.
* Tika Pipes Fetcher CRUD operations
* Create
@@ -19,6 +19,59 @@ This server will manage a pool of Tika Pipes clients.
> the config. See the
> [Tika gRPC security configuration docs](../docs/modules/ROOT/pages/using-tika/grpc/index.adoc).
+## Typed parse output
+
+Parse results are returned as `org.apache.tika.grpc.v1.Document` on
+`FetchAndParseReply.document`. The previous `FetchAndParseReply.fields`
+(`map`) has been removed.
+
+Rather than one proto message per source format, `Document` models content and
+metadata by concern:
+
+1. **Content tree**: `markdown` (the authoritative render) and `blocks` — the same
+ content parsed into a structured tree of headings/paragraphs/lists/tables/code
+ blocks/inline runs. This is format-agnostic: every Tika parser's output reaches
+ this shape via markdown, so content handling does not vary per format.
+2. **Typed metadata**: a small, bounded set of common fields on `DocumentMetadata`
+ (title, authors, description, keywords, languages, dates, counts, dimensions,
+ rights) — the cross-format facts every consumer wants, typed.
+3. **Tagged tail**: `extra` (`repeated MetadataField`) carries everything
+ format-specific (PDF permissions, EXIF/GPS, OOXML core properties, …), typed where
+ Tika's own `Property` declares a type and a string otherwise — never guessed. This
+ is the lossless catch-all; nothing is dropped.
+4. **`format_category`**: a cheap routing hint, not mutually exclusive with `extra` —
+ a PDF can still carry Creative Commons rights metadata, for example.
+5. **`embedded`**: recursive — a PDF with an embedded image is one `Document` whose
+ `embedded` list holds a fully-typed child `Document` for the image.
+
+Supporting artifacts live in sibling Maven modules (also listed in `tika-bom`):
+
+| Module | Role |
+|--------|------|
+| `tika-grpc-api` | Protobuf definitions (`org.apache.tika.grpc.v1`: `document.proto`), generated Java stubs, bundled `FileDescriptorSet` under `META-INF/` |
+| `tika-grpc-mapper` | Maps Tika `Metadata` to `Document` via per-format `DocumentTransformer`s (code, not schema) plus a format-agnostic markdown-to-block-tree builder |
+| `tika-grpc` | gRPC service implementation (this module) |
+
+Client migration (summary):
+
+| Before | After |
+|--------|-------|
+| `FetchAndParseReply.fields["content"]` | `document.markdown` |
+| `FetchAndParseReply.fields["Some-Key"]` | `document.extra` (find by key; typed by Tika's declared `Property` type, string otherwise) |
+| `FetchAndParseReply.fields["Content-Type"]` | `document.content_type` |
+| Flat string keys for PDF/Office/etc. | `document.metadata` (common fields) plus `document.extra` (format-specific) |
+| Ad hoc title/author strings | `document.metadata.title` / `document.metadata.authors` |
+
+See [tika-grpc-api/README.md](../tika-grpc-api/README.md) for the full shape and
+[tika-grpc-mapper/docs/EXTENSIONS.md](../tika-grpc-mapper/docs/EXTENSIONS.md) for how
+to add a new format. Transformer tests live under `tika-grpc-mapper/src/test/java`.
+
+Build the API and mapper with the rest of the reactor:
+
+```bash
+./mvnw -pl tika-grpc-api,tika-grpc-mapper,tika-grpc test
+```
+
## Distribution and Maven Artifact
**tika-grpc is designed to be run via Docker — it is not a standalone runnable artifact published to Maven Central.**
diff --git a/tika-grpc/pom.xml b/tika-grpc/pom.xml
index 7d021a11e65..f791489bfdc 100644
--- a/tika-grpc/pom.xml
+++ b/tika-grpc/pom.xml
@@ -135,6 +135,16 @@
j2objc-annotations
${j2objc-annotations.version}