The block tree is the canonical content and is always populated. The flat
+ * {@code markdown} field is a rendering of the same content and is only carried when
+ * the caller asks for it, so a reply does not ship the content twice.
+ */
+public final class DocumentBuilder {
+
+ private static final DocumentTransformers TRANSFORMERS = DocumentTransformers.defaults();
+
+ // The metadata key DigestDef.metadataKey() produces for SHA-256 with the default hex
+ // encoding; the parse pipeline records the source digest there when a digester is
+ // configured.
+ private static final String SHA256_DIGEST_KEY = TikaCoreProperties.TIKA_META_PREFIX
+ + "digest" + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "SHA256";
+
+ private DocumentBuilder() {
+ }
+
+ /**
+ * Maps Tika parse output (primary metadata plus optional embedded metadata list) to
+ * {@link Document}. Pass {@code primary == null} when the pipes result carried no
+ * metadata at all (e.g. a fetch failure, or a crash before the parse produced
+ * anything): the returned Document then has only an id and a status. The status still
+ * derives from {@code pipesStatus} -- {@code EMPTY_OUTPUT} with no metadata is a
+ * legitimate success -- and an explicit error is recorded for the non-success cases.
+ *
+ * @param renderMarkdown whether to also carry the flat markdown rendering in
+ * {@code Document.markdown} (the block tree is populated either
+ * way)
+ */
+ public static Document build(Metadata primary, List allMetadata, String markdownBody,
+ String docId, String pipesStatus, long fetchParseTimeMs,
+ boolean renderMarkdown) {
+ if (primary == null) {
+ ParseStatus.Builder statusBuilder = ParseStatus.newBuilder()
+ .setStatus(mapPipesStatus(pipesStatus))
+ .setFetchParseTimeMs(fetchParseTimeMs)
+ .setTikaVersion(Tika.getString());
+ if (pipesStatus != null && !pipesStatus.isEmpty()) {
+ statusBuilder.setPipesStatus(pipesStatus);
+ }
+ 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, renderMarkdown);
+
+ String parserClass = primary.get(TikaCoreProperties.TIKA_PARSED_BY);
+ ParseStatus.Builder statusBuilder = ParseStatus.newBuilder()
+ .setStatus(mapPipesStatus(pipesStatus))
+ .setFetchParseTimeMs(fetchParseTimeMs)
+ .setTikaVersion(Tika.getString());
+ if (pipesStatus != null && !pipesStatus.isEmpty()) {
+ statusBuilder.setPipesStatus(pipesStatus);
+ }
+ 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, renderMarkdown).build());
+ }
+ }
+
+ return document.build();
+ }
+
+ private static Document.Builder buildOne(Metadata tika, String markdownBody, String docId,
+ boolean renderMarkdown) {
+ 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());
+ }
+ String sha256 = tika.get(SHA256_DIGEST_KEY);
+ if (sha256 != null && !sha256.isBlank()) {
+ origin.setSha256(sha256.trim());
+ }
+ document.setOrigin(origin.build());
+
+ if (markdownBody != null && !markdownBody.isEmpty()) {
+ document.addAllBlocks(MarkdownBlockTreeBuilder.toBlocks(markdownBody));
+ if (renderMarkdown) {
+ document.setMarkdown(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/text/DocumentTextFlattener.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/text/DocumentTextFlattener.java
new file mode 100644
index 00000000000..bb3d64ec778
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/text/DocumentTextFlattener.java
@@ -0,0 +1,189 @@
+/*
+ * 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.text;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.tika.grpc.v1.Block;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.Inline;
+import org.apache.tika.grpc.v1.Table;
+import org.apache.tika.grpc.v1.TableCell;
+import org.apache.tika.grpc.v1.TableRow;
+
+/**
+ * Flattens a Tika Document's canonical block tree into analyzable text, constructing the
+ * exact block-path offset map as it walks (see DESIGN.md for the per-type policy). The
+ * mapping is exact by construction: every text-bearing block records the range it
+ * contributed while contributing it, so no offsets are required from, or stamped into,
+ * the Tika contract.
+ *
+ * Blocks are separated by a blank line so sentence detection treats block boundaries
+ * as hard boundaries. Code blocks and raw html are skipped for analysis and reported in
+ * {@link FlattenedText#skippedPaths()}. Embedded documents are flattened separately
+ * by the caller ({@link #flatten(Document)} handles one document's own blocks only).
+ */
+public final class DocumentTextFlattener {
+
+ /**
+ * Offsets are only meaningful against the exact flattening that produced them; store
+ * this next to any persisted annotation, and bump it on any policy change.
+ */
+ public static final String FLATTEN_VERSION = "tika-block-flatten/1";
+
+ private static final String BLOCK_SEPARATOR = "\n\n";
+ private static final String CELL_SEPARATOR = " | ";
+
+ private final StringBuilder text = new StringBuilder();
+ private final List ranges = new ArrayList<>();
+ private final List skipped = new ArrayList<>();
+
+ private DocumentTextFlattener() {
+ }
+
+ /**
+ * Flattens one document's block tree.
+ *
+ * @param document the parsed document; must not be null
+ * @return the flattened text with anchors and provenance carried over
+ */
+ public static FlattenedText flatten(Document document) {
+ DocumentTextFlattener flattener = new DocumentTextFlattener();
+ List blocks = document.getBlocksList();
+ for (int i = 0; i < blocks.size(); i++) {
+ flattener.block(blocks.get(i), String.valueOf(i));
+ }
+ return new FlattenedText(
+ document.getId(),
+ document.getOrigin().getSha256(),
+ FLATTEN_VERSION,
+ flattener.text.toString(),
+ List.copyOf(flattener.ranges),
+ List.copyOf(flattener.skipped));
+ }
+
+ private void block(Block block, String path) {
+ switch (block.getBlockCase()) {
+ case HEADING -> anchored(path, () -> inlines(block.getHeading().getContentList()));
+ case PARAGRAPH -> anchored(path, () -> inlines(block.getParagraph().getContentList()));
+ case BLOCK_QUOTE -> children(block.getBlockQuote().getBlocksList(), path);
+ case BULLET_LIST -> items(block.getBulletList().getItemsList().stream()
+ .map(item -> item.getBlocksList()).toList(), path);
+ case ORDERED_LIST -> items(block.getOrderedList().getItemsList().stream()
+ .map(item -> item.getBlocksList()).toList(), path);
+ case TABLE -> table(block.getTable(), path);
+ case CODE_BLOCK, HTML_BLOCK -> skipped.add(path);
+ default -> {
+ // ThematicBreak and future block types carry no analyzable text.
+ }
+ }
+ }
+
+ private void children(List blocks, String parentPath) {
+ for (int i = 0; i < blocks.size(); i++) {
+ block(blocks.get(i), parentPath + "." + i);
+ }
+ }
+
+ private void items(List> itemBlocks, String parentPath) {
+ for (int i = 0; i < itemBlocks.size(); i++) {
+ children(itemBlocks.get(i), parentPath + "." + i);
+ }
+ }
+
+ private void table(Table table, String path) {
+ if (!table.getHeaderList().isEmpty()) {
+ row(table.getHeaderList(), path + ".header");
+ }
+ List rows = table.getRowsList();
+ for (int r = 0; r < rows.size(); r++) {
+ row(rows.get(r).getCellsList(), path + ".rows." + r);
+ }
+ }
+
+ // One line per row; every cell gets its own anchor so table-fact consumers can point
+ // at the exact cell, and the row line as a whole reads as one statement.
+ private void row(List cells, String rowPath) {
+ boolean wroteAny = false;
+ for (int c = 0; c < cells.size(); c++) {
+ String cellText = renderInlines(cells.get(c).getContentList());
+ if (cellText.isEmpty()) {
+ continue;
+ }
+ if (wroteAny) {
+ text.append(CELL_SEPARATOR);
+ } else {
+ separateFromPrevious();
+ }
+ int start = text.length();
+ text.append(cellText);
+ ranges.add(new FlattenedText.BlockRange(rowPath + ".cells." + c, start,
+ text.length()));
+ wroteAny = true;
+ }
+ }
+
+ // Runs the emitter between separator handling and anchor recording; empty output
+ // leaves no anchor and no separator.
+ private void anchored(String path, Runnable emitter) {
+ int lengthBefore = text.length();
+ separateFromPrevious();
+ int start = text.length();
+ emitter.run();
+ if (text.length() == start) {
+ text.setLength(lengthBefore);
+ return;
+ }
+ ranges.add(new FlattenedText.BlockRange(path, start, text.length()));
+ }
+
+ private void separateFromPrevious() {
+ if (!text.isEmpty()) {
+ text.append(BLOCK_SEPARATOR);
+ }
+ }
+
+ private void inlines(List content) {
+ text.append(renderInlines(content));
+ }
+
+ private static String renderInlines(List content) {
+ StringBuilder out = new StringBuilder();
+ renderInto(content, out);
+ return out.toString();
+ }
+
+ private static void renderInto(List content, StringBuilder out) {
+ for (Inline inline : content) {
+ switch (inline.getInlineCase()) {
+ case TEXT -> out.append(inline.getText());
+ case EMPHASIS -> renderInto(inline.getEmphasis().getContentList(), out);
+ case STRONG -> renderInto(inline.getStrong().getContentList(), out);
+ case STRIKETHROUGH ->
+ renderInto(inline.getStrikethrough().getContentList(), out);
+ case CODE -> out.append(inline.getCode().getLiteral());
+ case LINK -> renderInto(inline.getLink().getContentList(), out);
+ case IMAGE -> out.append(inline.getImage().getAlt());
+ case LINE_BREAK -> out.append(inline.getLineBreak().getHard() ? "\n" : " ");
+ default -> {
+ // raw inline html contributes nothing analyzable
+ }
+ }
+ }
+ }
+}
diff --git a/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/text/FlattenedText.java b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/text/FlattenedText.java
new file mode 100644
index 00000000000..d9872a89b3e
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/text/FlattenedText.java
@@ -0,0 +1,75 @@
+/*
+ * 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.text;
+
+import java.util.List;
+
+/**
+ * The result of flattening one Tika Document (or one embedded child): the analyzable
+ * text, the exact block-path anchors constructed during the walk, and the provenance
+ * needed to tie downstream annotations back to the parsed bytes.
+ *
+ * @param documentId the Tika Document id ("" when the source carried none)
+ * @param sourceSha256 SourceOrigin.sha256 ("" when the parse pipeline had no digester)
+ * @param flattenVersion the flattening algorithm version the offsets are valid against
+ * @param text the flattened text OpenNLP analyzes
+ * @param ranges text-bearing anchors in ascending start order, non-overlapping
+ * @param skippedPaths block paths deliberately not analyzed (code blocks, raw html)
+ */
+public record FlattenedText(String documentId, String sourceSha256, String flattenVersion,
+ String text, List ranges,
+ List skippedPaths) {
+
+ /**
+ * One text-bearing anchor: the block (or table cell) at {@code path} produced
+ * {@code text[start, end)}.
+ */
+ public record BlockRange(String path, int start, int end) {
+ }
+
+ /**
+ * Projects a span in flattened coordinates onto the block anchors it overlaps, in
+ * order. The common case is a single anchor; a span crossing a block boundary (rare,
+ * since blocks are separated by blank lines) reports every anchor it touches with
+ * the local range inside each.
+ *
+ * @param start inclusive start in flattened coordinates
+ * @param end exclusive end; must be greater than {@code start} and within the text
+ * @return the overlapped anchors with span-local ranges, block order
+ * @throws IllegalArgumentException if the span is empty, inverted, or out of bounds
+ */
+ public List project(int start, int end) {
+ if (start < 0 || end <= start || end > text.length()) {
+ throw new IllegalArgumentException(
+ "Span [" + start + ", " + end + ") is not a valid range of a text of length "
+ + text.length());
+ }
+ return ranges.stream()
+ .filter(r -> r.start() < end && start < r.end())
+ .map(r -> new Projection(r.path(),
+ Math.max(start, r.start()) - r.start(),
+ Math.min(end, r.end()) - r.start()))
+ .toList();
+ }
+
+ /**
+ * A span projected into one block's local coordinates: {@code [localStart, localEnd)}
+ * within the text that block contributed.
+ */
+ public record Projection(String path, int localStart, int localEnd) {
+ }
+}
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..0a116a3189c
--- /dev/null
+++ b/tika-grpc-mapper/src/main/java/org/apache/tika/grpc/mapper/transform/TransformSupport.java
@@ -0,0 +1,129 @@
+/*
+ * 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, publishers, identifiers, created, modified -- into typed
+ * {@link DocumentMetadata}. These are the Dublin Core descriptive elements; keywords
+ * come from {@link TikaCoreProperties#SUBJECT}. 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);
+ addStrings(md, TikaCoreProperties.PUBLISHER, meta::addAllPublishers, consumed);
+ addStrings(md, TikaCoreProperties.IDENTIFIER, meta::addAllIdentifiers, 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..96c3fcd98ad
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/DocumentBuilderTest.java
@@ -0,0 +1,213 @@
+/*
+ * 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.Property;
+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);
+
+ assertFalse(document.getBlocksList().isEmpty());
+ assertTrue(blockText(document).contains("Tika"));
+ 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("PARSE_SUCCESS", document.getStatus().getPipesStatus());
+ assertEquals(42L, document.getStatus().getFetchParseTimeMs());
+ }
+
+ /**
+ * The block tree is the canonical content and is always populated; the flat markdown
+ * string is a rendering of the same tree and is only carried on request, so a reply
+ * does not ship the content twice.
+ */
+ @Test
+ void markdownRenderingIsOnlyCarriedOnRequest() {
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "text/plain");
+ String body = "# Title\n\nSome text.\n";
+
+ Document withoutRender = DocumentBuilder.build(
+ metadata, List.of(metadata), body, "d", "PARSE_SUCCESS", 1L, false);
+ assertFalse(withoutRender.getBlocksList().isEmpty());
+ assertTrue(withoutRender.getMarkdown().isEmpty());
+
+ Document withRender = DocumentBuilder.build(
+ metadata, List.of(metadata), body, "d", "PARSE_SUCCESS", 1L, true);
+ assertFalse(withRender.getBlocksList().isEmpty());
+ assertEquals(body, withRender.getMarkdown());
+ assertEquals(withoutRender.getBlocksList(), withRender.getBlocksList());
+ }
+
+ @Test
+ void mapsSourceDigestWhenThePipelineRecordedOne() {
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "text/plain");
+ // Reserved X-TIKA keys only accept Property writes (TIKA-4769); this mirrors how
+ // InputStreamDigester records the digest.
+ metadata.set(Property.internalText(TikaCoreProperties.TIKA_META_PREFIX + "digest"
+ + TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER + "SHA256"), "0a1b2c3d");
+
+ Document document = DocumentBuilder.build(
+ metadata, List.of(metadata), "text", "d", "PARSE_SUCCESS", 1L, false);
+ assertEquals("0a1b2c3d", document.getOrigin().getSha256());
+
+ Metadata undigested = new Metadata();
+ undigested.set(Metadata.CONTENT_TYPE, "text/plain");
+ Document withoutDigest = DocumentBuilder.build(
+ undigested, List.of(undigested), "text", "d", "PARSE_SUCCESS", 1L, false);
+ assertTrue(withoutDigest.getOrigin().getSha256().isEmpty());
+ }
+
+ @Test
+ void mapsDublinCorePublishersAndIdentifiers() {
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "application/pdf");
+ metadata.add(TikaCoreProperties.PUBLISHER, "Apache Software Foundation");
+ metadata.add(TikaCoreProperties.IDENTIFIER, "urn:isbn:9780000000000");
+
+ Document document = DocumentBuilder.build(
+ metadata, List.of(metadata), "text", "d", "PARSE_SUCCESS", 1L, false);
+
+ assertEquals(List.of("Apache Software Foundation"),
+ document.getMetadata().getPublishersList());
+ assertEquals(List.of("urn:isbn:9780000000000"),
+ document.getMetadata().getIdentifiersList());
+ // consumed into the typed fields, so the keys must not duplicate into the tail
+ assertTrue(document.getExtraList().stream()
+ .noneMatch(f -> f.getKey().equals(TikaCoreProperties.PUBLISHER.getName())
+ || f.getKey().equals(TikaCoreProperties.IDENTIFIER.getName())));
+ }
+
+ @Test
+ void recordsProducerVersion() {
+ Metadata metadata = new Metadata();
+ metadata.set(Metadata.CONTENT_TYPE, "text/plain");
+
+ Document document = DocumentBuilder.build(
+ metadata, List.of(metadata), "text", "d", "PARSE_SUCCESS", 1L, false);
+ assertTrue(document.getStatus().getTikaVersion().startsWith("Apache Tika"));
+
+ // Provenance is recorded even when the parse produced no metadata at all.
+ Document failed = DocumentBuilder.build(null, null, null, "d", "FETCH_EXCEPTION", 1L, false);
+ assertTrue(failed.getStatus().getTikaVersion().startsWith("Apache Tika"));
+ }
+
+ @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,
+ false);
+
+ 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());
+ assertTrue(blockText(child).contains("an embedded photo"));
+ }
+
+ /**
+ * {@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, false);
+
+ assertEquals("doc-1", document.getId());
+ assertEquals(ParseStatus.Status.FAILED, document.getStatus().getStatus());
+ assertEquals("FETCH_EXCEPTION", document.getStatus().getPipesStatus());
+ assertFalse(document.getStatus().getErrorsList().isEmpty());
+ }
+
+ @Test
+ void emptyOutputWithNoMetadataIsSuccessNotFailure() {
+ Document document = DocumentBuilder.build(null, null, null, "doc-1", "EMPTY_OUTPUT", 1L, false);
+
+ 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, false).getStatus().getStatus(),
+ "PARSE_SUCCESS is a real, clean success status");
+ assertEquals(ParseStatus.Status.SUCCESS,
+ DocumentBuilder.build(metadata, null, null, "d", "EMIT_SUCCESS", 1L, false).getStatus().getStatus());
+ assertEquals(ParseStatus.Status.SUCCESS,
+ DocumentBuilder.build(metadata, null, null, "d", "EMPTY_OUTPUT", 1L, false).getStatus().getStatus());
+
+ assertEquals(ParseStatus.Status.PARTIAL,
+ DocumentBuilder.build(metadata, null, null, "d", "PARSE_SUCCESS_WITH_EXCEPTION", 1L, false)
+ .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, false).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, false).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..91120d7f41a
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/ParseFixtureSupport.java
@@ -0,0 +1,117 @@
+/*
+ * 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.Block;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.Inline;
+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, map them through {@link DocumentBuilder},
+ * and read plain text back out of the block tree for content assertions.
+ */
+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, false);
+ }
+
+ protected Document map(ParseFixture fixture, String docId) {
+ return map(fixture, docId, 1L);
+ }
+
+ /**
+ * Concatenates the plain text carried by a document's block tree, so content
+ * assertions can read the canonical representation instead of the optional flat
+ * rendering. Formatting wrappers (emphasis, links, ...) contribute their inner text;
+ * structural nodes recurse.
+ */
+ protected static String blockText(Document document) {
+ StringBuilder text = new StringBuilder();
+ for (Block block : document.getBlocksList()) {
+ appendBlockText(block, text);
+ }
+ return text.toString();
+ }
+
+ private static void appendBlockText(Block block, StringBuilder text) {
+ switch (block.getBlockCase()) {
+ case HEADING -> appendInlineText(block.getHeading().getContentList(), text);
+ case PARAGRAPH -> appendInlineText(block.getParagraph().getContentList(), text);
+ case BLOCK_QUOTE -> block.getBlockQuote().getBlocksList()
+ .forEach(b -> appendBlockText(b, text));
+ case BULLET_LIST -> block.getBulletList().getItemsList().forEach(
+ item -> item.getBlocksList().forEach(b -> appendBlockText(b, text)));
+ case ORDERED_LIST -> block.getOrderedList().getItemsList().forEach(
+ item -> item.getBlocksList().forEach(b -> appendBlockText(b, text)));
+ case CODE_BLOCK -> text.append(block.getCodeBlock().getLiteral());
+ case TABLE -> {
+ block.getTable().getHeaderList()
+ .forEach(cell -> appendInlineText(cell.getContentList(), text));
+ block.getTable().getRowsList().forEach(row -> row.getCellsList()
+ .forEach(cell -> appendInlineText(cell.getContentList(), text)));
+ }
+ default -> {
+ }
+ }
+ text.append('\n');
+ }
+
+ private static void appendInlineText(List inlines, StringBuilder text) {
+ for (Inline inline : inlines) {
+ switch (inline.getInlineCase()) {
+ case TEXT -> text.append(inline.getText());
+ case EMPHASIS -> appendInlineText(inline.getEmphasis().getContentList(), text);
+ case STRONG -> appendInlineText(inline.getStrong().getContentList(), text);
+ case STRIKETHROUGH ->
+ appendInlineText(inline.getStrikethrough().getContentList(), text);
+ case CODE -> text.append(inline.getCode().getLiteral());
+ case LINK -> appendInlineText(inline.getLink().getContentList(), text);
+ default -> {
+ }
+ }
+ }
+ }
+
+}
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/text/DocumentTextFlattenerTest.java b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/text/DocumentTextFlattenerTest.java
new file mode 100644
index 00000000000..aaa781e484b
--- /dev/null
+++ b/tika-grpc-mapper/src/test/java/org/apache/tika/grpc/mapper/text/DocumentTextFlattenerTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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.text;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.grpc.v1.Block;
+import org.apache.tika.grpc.v1.BlockQuote;
+import org.apache.tika.grpc.v1.CodeBlock;
+import org.apache.tika.grpc.v1.Document;
+import org.apache.tika.grpc.v1.Emphasis;
+import org.apache.tika.grpc.v1.Heading;
+import org.apache.tika.grpc.v1.Inline;
+import org.apache.tika.grpc.v1.Paragraph;
+import org.apache.tika.grpc.v1.SourceOrigin;
+import org.apache.tika.grpc.v1.Table;
+import org.apache.tika.grpc.v1.TableCell;
+import org.apache.tika.grpc.v1.TableRow;
+
+class DocumentTextFlattenerTest {
+
+ private static Inline text(String value) {
+ return Inline.newBuilder().setText(value).build();
+ }
+
+ private static Block paragraph(String value) {
+ return Block.newBuilder()
+ .setParagraph(Paragraph.newBuilder().addContent(text(value))).build();
+ }
+
+ private static TableCell cell(String value) {
+ return TableCell.newBuilder().addContent(text(value)).build();
+ }
+
+ @Test
+ void anchorsAreExactByConstruction() {
+ Document document = Document.newBuilder()
+ .setId("doc-1")
+ .setOrigin(SourceOrigin.newBuilder().setSha256("abc123"))
+ .addBlocks(Block.newBuilder().setHeading(Heading.newBuilder()
+ .setLevel(1).addContent(text("Quarterly Report"))))
+ .addBlocks(paragraph("Revenue grew in EMEA."))
+ .build();
+
+ FlattenedText flat = DocumentTextFlattener.flatten(document);
+
+ assertEquals("Quarterly Report\n\nRevenue grew in EMEA.", flat.text());
+ assertEquals("doc-1", flat.documentId());
+ assertEquals("abc123", flat.sourceSha256());
+ assertEquals(DocumentTextFlattener.FLATTEN_VERSION, flat.flattenVersion());
+ // every anchor's range must reproduce its block's text exactly
+ assertEquals(2, flat.ranges().size());
+ for (FlattenedText.BlockRange range : flat.ranges()) {
+ String slice = flat.text().substring(range.start(), range.end());
+ assertTrue(!slice.isBlank() && !slice.contains("\n\n"),
+ "anchor " + range.path() + " must cover exactly one block: " + slice);
+ }
+ assertEquals("0", flat.ranges().get(0).path());
+ assertEquals("1", flat.ranges().get(1).path());
+ }
+
+ @Test
+ void formattingWrappersContributeInnerTextOnly() {
+ Document document = Document.newBuilder()
+ .addBlocks(Block.newBuilder().setParagraph(Paragraph.newBuilder()
+ .addContent(text("The "))
+ .addContent(Inline.newBuilder().setEmphasis(
+ Emphasis.newBuilder().addContent(text("quick"))))
+ .addContent(text(" fox"))))
+ .build();
+
+ assertEquals("The quick fox", DocumentTextFlattener.flatten(document).text());
+ }
+
+ @Test
+ void nestedBlocksGetDottedPaths() {
+ Document document = Document.newBuilder()
+ .addBlocks(paragraph("Intro."))
+ .addBlocks(Block.newBuilder().setBlockQuote(BlockQuote.newBuilder()
+ .addBlocks(paragraph("Quoted line one."))
+ .addBlocks(paragraph("Quoted line two."))))
+ .build();
+
+ FlattenedText flat = DocumentTextFlattener.flatten(document);
+
+ assertEquals(List.of("0", "1.0", "1.1"),
+ flat.ranges().stream().map(FlattenedText.BlockRange::path).toList());
+ }
+
+ @Test
+ void tablesLinearizeRowWiseWithCellAnchors() {
+ Table table = Table.newBuilder()
+ .addHeader(cell("Region")).addHeader(cell("Revenue"))
+ .addRows(TableRow.newBuilder().addCells(cell("EMEA")).addCells(cell("4.2M")))
+ .build();
+ Document document = Document.newBuilder()
+ .addBlocks(Block.newBuilder().setTable(table))
+ .build();
+
+ FlattenedText flat = DocumentTextFlattener.flatten(document);
+
+ assertEquals("Region | Revenue\n\nEMEA | 4.2M", flat.text());
+ assertEquals(List.of("0.header.cells.0", "0.header.cells.1",
+ "0.rows.0.cells.0", "0.rows.0.cells.1"),
+ flat.ranges().stream().map(FlattenedText.BlockRange::path).toList());
+ // the revenue figure's anchor points at exactly its cell text
+ FlattenedText.BlockRange revenue = flat.ranges().get(3);
+ assertEquals("4.2M", flat.text().substring(revenue.start(), revenue.end()));
+ }
+
+ @Test
+ void codeAndHtmlBlocksAreSkippedAndReported() {
+ Document document = Document.newBuilder()
+ .addBlocks(paragraph("Before."))
+ .addBlocks(Block.newBuilder().setCodeBlock(
+ CodeBlock.newBuilder().setLiteral("int x = 1;")))
+ .addBlocks(paragraph("After."))
+ .build();
+
+ FlattenedText flat = DocumentTextFlattener.flatten(document);
+
+ assertEquals("Before.\n\nAfter.", flat.text());
+ assertEquals(List.of("1"), flat.skippedPaths());
+ assertEquals(List.of("0", "2"),
+ flat.ranges().stream().map(FlattenedText.BlockRange::path).toList());
+ }
+
+ @Test
+ void projectionMapsSpansBackToBlockLocalCoordinates() {
+ Document document = Document.newBuilder()
+ .addBlocks(paragraph("Alice went to Paris."))
+ .addBlocks(paragraph("Bob stayed home."))
+ .build();
+ FlattenedText flat = DocumentTextFlattener.flatten(document);
+
+ int paris = flat.text().indexOf("Paris");
+ List projections = flat.project(paris, paris + 5);
+
+ assertEquals(1, projections.size());
+ assertEquals("0", projections.get(0).path());
+ assertEquals("Alice went to Paris.".indexOf("Paris"), projections.get(0).localStart());
+ assertEquals(projections.get(0).localStart() + 5, projections.get(0).localEnd());
+
+ // a second-block span projects into the second block's local coordinates
+ int bob = flat.text().indexOf("Bob");
+ List second = flat.project(bob, bob + 3);
+ assertEquals("1", second.get(0).path());
+ assertEquals(0, second.get(0).localStart());
+
+ assertThrows(IllegalArgumentException.class, () -> flat.project(5, 5));
+ assertThrows(IllegalArgumentException.class,
+ () -> flat.project(0, flat.text().length() + 1));
+ }
+
+ @Test
+ void emptyBlocksLeaveNoAnchorAndNoSeparator() {
+ Document document = Document.newBuilder()
+ .addBlocks(paragraph("Only real content."))
+ .addBlocks(Block.newBuilder().setParagraph(Paragraph.newBuilder()))
+ .build();
+
+ FlattenedText flat = DocumentTextFlattener.flatten(document);
+
+ assertEquals("Only real content.", flat.text());
+ assertEquals(1, flat.ranges().size());
+ }
+}
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}