diff --git a/CHANGES.txt b/CHANGES.txt index 80e898aa721..9e12a8de8bb 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,14 @@ Release 4.1.0 - unreleased + * tika-server: /unpack/thumbnail returns the document thumbnail as JSON + (its metadata and the image as base64). renderThumbnails=true on /rmeta, + /unpack, /unpack/all and /unpack/thumbnail lays the thumbnail defaults + under a request (first PDF page rendered, EMF/WMF thumbnail rendered); + without it only stored thumbnails are found. The defaults can + be replaced per parser with a thumbnail-defaults block in the server + config. PDFParserConfig gains maxRenderedPages, bounding the page + rendering independently of maxPages (TIKA-4856). + * Raster previews for the vector thumbnails of Office documents: the new poi-metafile-renderer draws EMF and WMF images through POI (a PNG of a configurable width; Word's bitmap-in-WMF thumbnails from the bitmap diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc b/docs/modules/ROOT/pages/using-tika/server/index.adoc index 4c0048228ad..25d142b0107 100644 --- a/docs/modules/ROOT/pages/using-tika/server/index.adoc +++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc @@ -269,6 +269,34 @@ curl -T document.docx http://localhost:9998/rmeta/markdown # or /md curl -T document.pdf http://localhost:9998/rmeta/ignore # metadata only ---- +The thumbnail of a document is one of its embedded documents, typed +`tk:embedded-resource-type=THUMBNAIL`. Most formats store one (Office, iWork, EPUB, DWG, the +cover art of audio files, the preview of a raw camera file) and it is listed without further +ado. Where a raster image only exists after rendering (the first page of a PDF, the EMF/WMF +thumbnail of an Office document), `?renderThumbnails=true` lays the *thumbnail defaults* +under the request: the first PDF page rendered at 96 dpi, the EMF/WMF thumbnail rendered (that +one only, not the pictures of embedded objects). The text is extracted as usual; the rendering +appears as an embedded document with its dimensions. The same switch works on `/unpack`, +`/unpack/all` and `/unpack/thumbnail`; without it only stored thumbnails are found. + +[source,bash] +---- +curl -T document.pdf "http://localhost:9998/rmeta/text?renderThumbnails=true" +---- + +The built-in defaults can be replaced per parser in the server config, with the same shape a +request config has; a request's own `config` part still wins over both: + +[source,json] +---- +{ + "thumbnail-defaults": { + "pdf-parser": {"imageStrategy": "RENDER_PAGES_AT_PAGE_END", "maxRenderedPages": 1, + "ocr": {"dpi": 150}} + } +} +---- + === Metadata only (`/meta`) Returns container-document metadata only — no recursive embedded list, no content. With no @@ -310,9 +338,12 @@ enables everything below except `status`, which is opt-in and must be listed exp |=== |Path |Config name |Notes -|`/unpack` +|`/unpack`, `/unpack/all`, `/unpack/thumbnail` |`unpack` -|Returns embedded files as a zip. Forked. +|Returns embedded files as a zip; `/all` adds the container's text and metadata; +`/thumbnail` returns the document thumbnail as JSON (metadata plus the image as base64), or +204 if there is none. `?renderThumbnails=true` applies the thumbnail defaults on all three, see +<<_recursive_metadata_rmeta,`/rmeta`>>. Forked. |`/detect` |`detect` diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDF2XHTML.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDF2XHTML.java index bbf4e0fbd7f..e4b70aa5079 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDF2XHTML.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDF2XHTML.java @@ -162,6 +162,10 @@ private void renderPage(PDPage page) throws IOException { if (config.getImageStrategy() != PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_AT_PAGE_END) { return; } + int maxRenderedPages = config.getMaxRenderedPages(); + if (maxRenderedPages > 0 && getCurrentPageNo() > maxRenderedPages) { + return; + } PDFRenderingState state = context.get(PDFRenderingState.class); //this is the document's inputstream/PDDocument //TODO: figure out if we can send in the PDPage in the TikaInputStream diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java index 4ad0e954017..77312325696 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParser.java @@ -484,8 +484,10 @@ private RenderResults renderPDF(TikaInputStream tstream, throws IOException, TikaException { Metadata metadata = Metadata.newInstance(parseContext); metadata.set(TikaCoreProperties.TYPE, MEDIA_TYPE.toString()); - return renderer.render( - tstream, metadata, parseContext, PageRangeRequest.RENDER_ALL); + int maxRenderedPages = localConfig.getMaxRenderedPages(); + PageRangeRequest pages = maxRenderedPages > 0 + ? new PageRangeRequest(1, maxRenderedPages) : PageRangeRequest.RENDER_ALL; + return renderer.render(tstream, metadata, parseContext, pages); } protected PDDocument getPDDocument(TikaInputStream tis, String password, diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java index b83f449cada..bceb30179b7 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFParserConfig.java @@ -149,6 +149,8 @@ public enum AccessCheckMode { private int maxPages = -1; + private int maxRenderedPages = -1; + private boolean throwOnEncryptedPayload = false; /** @@ -681,6 +683,32 @@ public void setMaxPages(int maxPages) { this.maxPages = maxPages; } + /** + * @return maximum number of pages to render with the + * {@code RENDER_PAGES_BEFORE_PARSE} and {@code RENDER_PAGES_AT_PAGE_END} + * image strategies, or -1 for no limit + */ + public int getMaxRenderedPages() { + return maxRenderedPages; + } + + /** + * Set the maximum number of pages to render, counted from the first + * page, independent of {@link #setMaxPages(int)}: text extraction can + * cover the whole document while only its first page is rendered, as + * for a thumbnail. Use -1 (the default) for no limit. + * + * @param maxRenderedPages must be -1 or >= 1 + * @throws IllegalArgumentException if the value is 0 or less than -1 + */ + public void setMaxRenderedPages(int maxRenderedPages) { + if (maxRenderedPages != -1 && maxRenderedPages < 1) { + throw new IllegalArgumentException( + "maxRenderedPages must be -1 (no limit) or >= 1, got: " + maxRenderedPages); + } + this.maxRenderedPages = maxRenderedPages; + } + public void setThrowOnEncryptedPayload(boolean throwOnEncryptedPayload) { this.throwOnEncryptedPayload = throwOnEncryptedPayload; } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFMaxRenderedPagesTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFMaxRenderedPagesTest.java new file mode 100644 index 00000000000..41c0f9a56b6 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFMaxRenderedPagesTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.pdf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import org.apache.tika.TikaTest; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.PagedText; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.parser.ParseContext; + +/** + * {@code maxRenderedPages} bounds the rendering without bounding the text: + * a thumbnail wants the first page rendered and the whole document read. + */ +public class PDFMaxRenderedPagesTest extends TikaTest { + + private static final String TWO_PAGES = "testPDF_bookmarks.pdf"; + + @ParameterizedTest + @EnumSource(value = PDFParserConfig.IMAGE_STRATEGY.class, + names = {"RENDER_PAGES_BEFORE_PARSE", "RENDER_PAGES_AT_PAGE_END"}) + public void testOnlyTheFirstPageIsRendered(PDFParserConfig.IMAGE_STRATEGY strategy) + throws Exception { + PDFParserConfig config = new PDFParserConfig(); + config.setImageStrategy(strategy); + config.setMaxRenderedPages(1); + ParseContext context = new ParseContext(); + context.set(PDFParserConfig.class, config); + + List metadataList = getRecursiveMetadata(TWO_PAGES, context); + assertEquals(2, (int) metadataList.get(0).getInt(PagedText.N_PAGES)); + assertEquals(1, renderings(metadataList), "one rendering, the first page"); + } + + @Test + public void testJsonConfig() throws Exception { + ParseContext context = new ParseContext(); + context.setJsonConfig("pdf-parser", + "{\"imageStrategy\": \"RENDER_PAGES_AT_PAGE_END\", \"maxRenderedPages\": 1}"); + assertEquals(1, renderings(getRecursiveMetadata(TWO_PAGES, context))); + } + + @Test + public void testZeroIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> new PDFParserConfig().setMaxRenderedPages(0)); + } + + private static long renderings(List metadataList) { + return metadataList.stream() + .filter(m -> TikaCoreProperties.EmbeddedResourceType.RENDERING.name() + .equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE))) + .count(); + } +} diff --git a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java index d15815b4ac5..2dd74d49e3a 100644 --- a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java +++ b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java @@ -117,6 +117,7 @@ public class TikaJsonConfig { "auto-detect-parser", "parse-context", "server", + "thumbnail-defaults", "grpc", // Pipes/plugin keys diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java index 4bd0b95f21f..fdd9d67730b 100644 --- a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java @@ -27,6 +27,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MultivaluedMap; @@ -63,8 +64,23 @@ public List parseMetadata(TikaInputStream tis, Metadata metadata, MultivaluedMap httpHeaders, String handlerTypeName) throws Exception { + return parseMetadata(tis, metadata, httpHeaders, handlerTypeName, false); + } + + /** + * @param renderThumbnails whether to lay the {@link ThumbnailDefaults} under the + * request, so the parse yields the document thumbnail as + * a raster image among the embedded documents + */ + public List parseMetadata(TikaInputStream tis, Metadata metadata, + MultivaluedMap httpHeaders, + String handlerTypeName, boolean renderThumbnails) + throws Exception { final ParseContext context = tikaResource.createRequestContext(); + if (renderThumbnails) { + tikaResource.getThumbnailDefaults().applyTo(context); + } fillMetadata(null, metadata, httpHeaders); TikaResource.logRequest(LOG, "/rmeta", metadata); @@ -104,10 +120,11 @@ public List parseMetadata(TikaInputStream tis, Metadata metadata, @Consumes("multipart/form-data") @Produces({"application/json"}) @Path("form{" + HANDLER_TYPE_PARAM + " : (\\w+)?}") - public Response getMetadataFromMultipart(Attachment att, @PathParam(HANDLER_TYPE_PARAM) String handlerTypeName) throws Exception { + public Response getMetadataFromMultipart(Attachment att, @PathParam(HANDLER_TYPE_PARAM) String handlerTypeName, + @QueryParam("renderThumbnails") boolean renderThumbnails) throws Exception { try (TikaInputStream tis = TikaInputStream.get(att.getObject(InputStream.class))) { List metadataList = parseMetadata(tis, tikaResource.newRequestMetadata(), att.getHeaders(), - handlerTypeName); + handlerTypeName, renderThumbnails); return Response.ok(new MetadataList(metadataList)).build(); } } @@ -123,11 +140,16 @@ public Response getMetadataFromMultipart(Attachment att, @PathParam(HANDLER_TYPE @Path("config") public Response getMetadataWithConfig( List attachments, - @Context HttpHeaders httpHeaders) throws Exception { + @Context HttpHeaders httpHeaders, + @QueryParam("renderThumbnails") boolean renderThumbnails) throws Exception { ParseContext context = tikaResource.createRequestContext(); Metadata metadata = tikaResource.newRequestMetadata(); try (TikaInputStream tis = tikaResource.setupMultipartConfig(attachments, metadata, context)) { + if (renderThumbnails) { + //under the request's config, which setupMultipartConfig has already merged + tikaResource.getThumbnailDefaults().applyTo(context); + } TikaResource.logRequest(LOG, "/rmeta/config", metadata); @@ -164,6 +186,11 @@ private MetadataList parseMetadataWithContext(TikaInputStream tis, Metadata meta * /rmeta/text (store the content as text)
* /rmeta/markdown (store the content as markdown)
* /rmeta/ignore (don't record any content)
+ *

+ * With {@code ?renderThumbnails=true} the {@link ThumbnailDefaults} are laid + * under the request, so the list also holds the document thumbnail as a raster + * image where rendering is needed for that (the first PDF page, the EMF/WMF + * thumbnail of an Office document). * * @param handlerTypeName which type of handler to use * @return InputStream that can be deserialized as a list of {@link Metadata} objects @@ -173,11 +200,12 @@ private MetadataList parseMetadataWithContext(TikaInputStream tis, Metadata meta @PUT @Produces("application/json") @Path("{" + HANDLER_TYPE_PARAM + " : (\\w+)?}") - public Response getMetadata(InputStream is, @Context HttpHeaders httpHeaders, @PathParam(HANDLER_TYPE_PARAM) String handlerTypeName) throws Exception { + public Response getMetadata(InputStream is, @Context HttpHeaders httpHeaders, @PathParam(HANDLER_TYPE_PARAM) String handlerTypeName, + @QueryParam("renderThumbnails") boolean renderThumbnails) throws Exception { Metadata metadata = tikaResource.newRequestMetadata(); try (TikaInputStream tis = TikaInputStream.get(is)) { List metadataList = parseMetadata(tis, metadata, httpHeaders.getRequestHeaders(), - handlerTypeName); + handlerTypeName, renderThumbnails); return Response.ok(new MetadataList(metadataList)).build(); } } diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/ThumbnailDefaults.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/ThumbnailDefaults.java new file mode 100644 index 00000000000..6b833fee2fc --- /dev/null +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/ThumbnailDefaults.java @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.resource; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.apache.tika.config.loader.TikaJsonConfig; +import org.apache.tika.parser.ParseContext; + +/** + * The parser configuration that makes a parse yield the document thumbnail + * as a raster image: the first PDF page rendered, the EMF/WMF thumbnail of + * an Office document rendered (that one only, not the pictures of embedded + * objects), in colour: the renderer's default is the grayscale OCR wants. + * The stored thumbnails of the other formats need no configuration. + *

+ * Applied by {@code renderThumbnails=true} on {@code /rmeta}, {@code /unpack} + * and {@code /unpack/all}, and by {@code /unpack/thumbnail}. Three layers, + * each overriding the one before: the built-in defaults below, a + * {@code thumbnail-defaults} block in the server config with the same shape + * as a request config (parser configurations keyed by component name), and + * the request's own config part. + *

+ * "thumbnail-defaults": {
+ *   "pdf-parser": {"imageStrategy": "RENDER_PAGES_AT_PAGE_END", "maxRenderedPages": 1,
+ *                  "ocr": {"dpi": 150}}
+ * }
+ * 
+ * A configuration for a parser that is not installed is never read, so the + * defaults are harmless on a server without that parser. + */ +public final class ThumbnailDefaults { + + public static final String CONFIG_KEY = "thumbnail-defaults"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String BUILT_IN = """ + { + "pdf-parser": { + "imageStrategy": "RENDER_PAGES_AT_PAGE_END", + "maxRenderedPages": 1, + "ocr": {"dpi": 96, "imageType": "RGB"} + }, + "emf-parser": {"renderImage": true, "renderOnlyEmbeddedResourceTypes": ["THUMBNAIL"]}, + "wmf-parser": {"renderImage": true, "renderOnlyEmbeddedResourceTypes": ["THUMBNAIL"]} + } + """; + + /** + * Parser configurations keyed by component name, in application order. + */ + private final Map components; + + private ThumbnailDefaults(Map components) { + this.components = components; + } + + /** + * No defaults at all, a base to {@link #with(String)} settings on. + */ + public static ThumbnailDefaults none() { + return new ThumbnailDefaults(new LinkedHashMap<>()); + } + + /** + * These defaults with another set merged in, field by field. + */ + public ThumbnailDefaults with(ThumbnailDefaults other) { + return with(other.components); + } + + public static ThumbnailDefaults builtIn() { + return new ThumbnailDefaults(readComponents(parse(BUILT_IN))); + } + + /** + * The built-in defaults, with every component the server config's + * {@code thumbnail-defaults} block names replaced by the config's version. + * + * @param config the server config, may be null + */ + public static ThumbnailDefaults fromConfig(TikaJsonConfig config) { + Map components = readComponents(parse(BUILT_IN)); + if (config != null && config.hasKey(CONFIG_KEY)) { + JsonNode block = config.getRootNode().get(CONFIG_KEY); + if (!block.isObject()) { + throw new IllegalArgumentException( + CONFIG_KEY + " must be an object of parser configurations"); + } + components.putAll(readComponents(block)); + } + return new ThumbnailDefaults(components); + } + + /** + * Sets every component the context does not configure itself, so a + * request's own configuration wins over the defaults. + */ + public void applyTo(ParseContext context) { + for (Map.Entry component : components.entrySet()) { + if (context.getJsonConfig(component.getKey()) == null) { + context.setJsonConfig(component.getKey(), component.getValue().toString()); + } + } + } + + /** + * These defaults with the given settings merged in, field by field; + * for {@code /unpack/thumbnail}, which does not want the OCR the + * indexing request would run on the rendering. + */ + public ThumbnailDefaults with(String json) { + return with(readComponents(parse(json))); + } + + private ThumbnailDefaults with(Map other) { + Map merged = new LinkedHashMap<>(); + for (Map.Entry component : components.entrySet()) { + merged.put(component.getKey(), component.getValue().deepCopy()); + } + for (Map.Entry component : other.entrySet()) { + ObjectNode existing = merged.get(component.getKey()); + if (existing == null) { + //a copy, so the two sets of defaults do not share a node + merged.put(component.getKey(), component.getValue().deepCopy()); + } else { + deepMerge(existing, component.getValue()); + } + } + return new ThumbnailDefaults(merged); + } + + /** + * The configuration of one component as JSON, or null if the defaults + * do not cover it. + */ + String get(String component) { + ObjectNode node = components.get(component); + return node == null ? null : node.toString(); + } + + private static void deepMerge(ObjectNode target, ObjectNode source) { + Iterator> fields = source.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + JsonNode existing = target.get(field.getKey()); + if (existing != null && existing.isObject() && field.getValue().isObject()) { + deepMerge((ObjectNode) existing, (ObjectNode) field.getValue()); + } else { + target.set(field.getKey(), field.getValue()); + } + } + } + + private static Map readComponents(JsonNode block) { + Map components = new LinkedHashMap<>(); + Iterator> fields = block.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + if (!field.getValue().isObject()) { + throw new IllegalArgumentException(CONFIG_KEY + ": the configuration of " + + field.getKey() + " must be an object"); + } + components.put(field.getKey(), (ObjectNode) field.getValue()); + } + return components; + } + + private static JsonNode parse(String json) { + try { + return MAPPER.readTree(json); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/ThumbnailSelector.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/ThumbnailSelector.java new file mode 100644 index 00000000000..b6b3d66781d --- /dev/null +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/ThumbnailSelector.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.resource; + +import java.util.List; +import java.util.Locale; + +import org.apache.tika.metadata.HttpHeaders; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; + +/** + * Picks the document thumbnail among the embedded documents of a parse, for + * {@code /unpack/thumbnail}. In order of preference: + *
    + *
  1. a raster {@code THUMBNAIL} directly below the document: the stored + * thumbnail of most formats;
  2. + *
  3. a raster image directly below a {@code THUMBNAIL} of the document: + * the rendering of an EMF/WMF thumbnail, which the metafile parsers + * emit as a {@code THUMBNAIL} as well;
  4. + *
  5. a raster {@code RENDERING} directly below the document: the first + * page of a PDF.
  6. + *
+ * Only the document's own children count, so the thumbnail of a document + * inside an archive is not the archive's, and the picture of an embedded + * object is never mistaken for the document's rendering. + */ +final class ThumbnailSelector { + + private ThumbnailSelector() { + } + + /** + * @param embedded the metadata of the embedded documents, in the order + * the parser emitted them + * @return the metadata of the thumbnail, or null if there is none + */ + static Metadata select(List embedded) { + for (Metadata m : embedded) { + if (depth(m) == 1 && isThumbnail(m) && isRaster(m)) { + return m; + } + } + for (Metadata thumbnail : embedded) { + if (depth(thumbnail) != 1 || !isThumbnail(thumbnail)) { + continue; + } + String path = thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH); + if (path == null) { + continue; + } + for (Metadata m : embedded) { + String candidatePath = m.get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH); + if (depth(m) == 2 && isRaster(m) && candidatePath != null + && candidatePath.startsWith(path + "/") + && (isThumbnail(m) || isRendering(m))) { + return m; + } + } + } + for (Metadata m : embedded) { + if (depth(m) == 1 && isRendering(m) && isRaster(m)) { + return m; + } + } + return null; + } + + private static int depth(Metadata m) { + Integer depth = m.getInt(TikaCoreProperties.EMBEDDED_DEPTH); + return depth == null ? -1 : depth; + } + + private static boolean isThumbnail(Metadata m) { + return TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name() + .equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)); + } + + private static boolean isRendering(Metadata m) { + return TikaCoreProperties.EmbeddedResourceType.RENDERING.name() + .equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)); + } + + /** + * An image a client can display without a rasterizer: PNG, JPEG, GIF, + * WebP, ..., but not a metafile or SVG. + */ + private static boolean isRaster(Metadata m) { + String contentType = m.get(HttpHeaders.CONTENT_TYPE); + if (contentType == null) { + return false; + } + int semicolon = contentType.indexOf(';'); + String type = (semicolon > 0 ? contentType.substring(0, semicolon) : contentType) + .trim().toLowerCase(Locale.ROOT); + return type.startsWith("image/") && !type.equals("image/emf") + && !type.equals("image/x-emf") && !type.equals("image/wmf") + && !type.equals("image/x-wmf") && !type.equals("image/svg+xml"); + } +} diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java index c590a183d6b..31498e8353c 100644 --- a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java @@ -95,6 +95,8 @@ public class TikaResource { private final OutputLimits configOutputLimits; private final ExceptionReporting configExceptionReporting; private final boolean configSuppliesContentHandlerFactory; + // What renderThumbnails=true and /unpack/thumbnail lay under a request. + private final ThumbnailDefaults thumbnailDefaults; /** * @param tikaLoader the Tika loader @@ -115,6 +117,18 @@ public TikaResource(TikaLoader tikaLoader, ServerStatus serverStatus, this.configExceptionReporting = ExceptionReporting.get(configDefaults); this.configSuppliesContentHandlerFactory = configDefaults.get(ContentHandlerFactory.class) != null; + this.thumbnailDefaults = ThumbnailDefaults.fromConfig( + tikaLoader == null ? null : tikaLoader.getConfig()); + } + + /** + * The parser configuration that makes a parse yield the document thumbnail + * as a raster image: the built-in defaults, overridden by the config's + * {@code thumbnail-defaults}. Applied under a request's own config where + * the request asks for {@code renderThumbnails=true}. + */ + public ThumbnailDefaults getThumbnailDefaults() { + return thumbnailDefaults; } /** diff --git a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java index 1180a62b903..4a2f9e1b970 100644 --- a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java +++ b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java @@ -18,15 +18,31 @@ import static org.apache.tika.server.core.resource.TikaResource.fillMetadata; +import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.POST; import jakarta.ws.rs.PUT; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.HttpHeaders; @@ -37,10 +53,15 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.tika.config.EmbeddedLimits; +import org.apache.tika.extractor.UnpackSelector; import org.apache.tika.io.TikaInputStream; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.ParseContext; +import org.apache.tika.pipes.core.extractor.StandardUnpackSelector; import org.apache.tika.pipes.core.extractor.UnpackConfig; +import org.apache.tika.serialization.JsonMetadata; /** * JAX-RS resource for unpacking embedded documents from container files. @@ -54,8 +75,32 @@ *
  • POST /unpack - Extract with config (multipart: file + optional JSON config)
  • *
  • PUT /unpack/all - Extract embedded + container text/metadata
  • *
  • POST /unpack/all - Extract all with config (multipart)
  • + *
  • PUT /unpack/thumbnail - Return the document thumbnail with its metadata
  • + *
  • POST /unpack/thumbnail - The same, multipart
  • * *

    + * Thumbnail: + *

    + * {@code /unpack/thumbnail} returns the document's thumbnail as JSON: the + * {@code /rmeta} metadata object of the embedded document that is the thumbnail + * and the image as base64, or {@code 204} if the document has none. It parses + * without text extraction or OCR, with the {@link ThumbnailDefaults} (the first + * PDF page rendered, the EMF/WMF thumbnail rendered) when asked to, and picks, + * in this order, the raster THUMBNAIL directly below the document, the rendering of a + * vector THUMBNAIL, or the RENDERING of the first page. It extracts what the + * document carries; it does not resize or convert. + *

    + * {@code ?renderThumbnails=true} applies the {@link ThumbnailDefaults} under + * the request's own config, on {@code /unpack/thumbnail} as on {@code /unpack} + * and {@code /unpack/all}; without it, only stored thumbnails are found and + * a document that needs rendering answers 204. + *

    + * {
    + *   "metadata": { "Content-Type": "image/png", "tiff:ImageWidth": "800", ... },
    + *   "image": "iVBORw0KGgo..."
    + * }
    + * 
    + *

    * Configuration: *

    * None required. The server wires up its own {@code __}-prefixed fetcher and emitter against @@ -148,12 +193,16 @@ public UnpackerResource(TikaResource tikaResource) { @jakarta.ws.rs.Path("/{id:(/.*)?}") @PUT @Produces("application/zip") - public Response unpack(InputStream is, @Context HttpHeaders httpHeaders, @Context UriInfo info) throws Exception { + public Response unpack(InputStream is, @Context HttpHeaders httpHeaders, @Context UriInfo info, + @QueryParam("renderThumbnails") boolean renderThumbnails) throws Exception { ParseContext pc = tikaResource.createRequestContext(); Metadata metadata = tikaResource.newRequestMetadata(); try (TikaInputStream tis = TikaInputStream.get(is)) { fillMetadata(null, metadata, httpHeaders.getRequestHeaders()); TikaResource.logRequest(LOG, "/unpack", metadata); + if (renderThumbnails) { + tikaResource.getThumbnailDefaults().applyTo(pc); + } return doUnpack(tis, metadata, pc, false); } } @@ -171,11 +220,16 @@ public Response unpack(InputStream is, @Context HttpHeaders httpHeaders, @Contex @POST @Consumes("multipart/form-data") @Produces("application/zip") - public Response unpackWithConfig(List attachments, @Context HttpHeaders httpHeaders, @Context UriInfo info) throws Exception { + public Response unpackWithConfig(List attachments, @Context HttpHeaders httpHeaders, @Context UriInfo info, + @QueryParam("renderThumbnails") boolean renderThumbnails) throws Exception { ParseContext pc = tikaResource.createRequestContext(); Metadata metadata = tikaResource.newRequestMetadata(); try (TikaInputStream tis = tikaResource.setupMultipartConfig(attachments, metadata, pc)) { TikaResource.logRequest(LOG, "/unpack", metadata); + if (renderThumbnails) { + //under the request's config, which setupMultipartConfig has already merged + tikaResource.getThumbnailDefaults().applyTo(pc); + } return doUnpack(tis, metadata, pc, false); } } @@ -192,12 +246,16 @@ public Response unpackWithConfig(List attachments, @Context HttpHead @jakarta.ws.rs.Path("/all{id:(/.*)?}") @PUT @Produces("application/zip") - public Response unpackAll(InputStream is, @Context HttpHeaders httpHeaders, @Context UriInfo info) throws Exception { + public Response unpackAll(InputStream is, @Context HttpHeaders httpHeaders, @Context UriInfo info, + @QueryParam("renderThumbnails") boolean renderThumbnails) throws Exception { ParseContext pc = tikaResource.createRequestContext(); Metadata metadata = tikaResource.newRequestMetadata(); try (TikaInputStream tis = TikaInputStream.get(is)) { fillMetadata(null, metadata, httpHeaders.getRequestHeaders()); TikaResource.logRequest(LOG, "/unpack/all", metadata); + if (renderThumbnails) { + tikaResource.getThumbnailDefaults().applyTo(pc); + } return doUnpack(tis, metadata, pc, true); } } @@ -215,15 +273,186 @@ public Response unpackAll(InputStream is, @Context HttpHeaders httpHeaders, @Con @POST @Consumes("multipart/form-data") @Produces("application/zip") - public Response unpackAllWithConfig(List attachments, @Context HttpHeaders httpHeaders, @Context UriInfo info) throws Exception { + public Response unpackAllWithConfig(List attachments, @Context HttpHeaders httpHeaders, @Context UriInfo info, + @QueryParam("renderThumbnails") boolean renderThumbnails) throws Exception { ParseContext pc = tikaResource.createRequestContext(); Metadata metadata = tikaResource.newRequestMetadata(); try (TikaInputStream tis = tikaResource.setupMultipartConfig(attachments, metadata, pc)) { TikaResource.logRequest(LOG, "/unpack/all", metadata); + if (renderThumbnails) { + //under the request's config, which setupMultipartConfig has already merged + tikaResource.getThumbnailDefaults().applyTo(pc); + } return doUnpack(tis, metadata, pc, true); } } + /** + * Returns the document thumbnail with its metadata (simple PUT). + */ + @jakarta.ws.rs.Path("/thumbnail") + @PUT + @Produces("application/json") + public Response unpackThumbnail(InputStream is, @Context HttpHeaders httpHeaders, + @QueryParam("renderThumbnails") boolean renderThumbnails) throws Exception { + ParseContext pc = tikaResource.createRequestContext(); + Metadata metadata = tikaResource.newRequestMetadata(); + try (TikaInputStream tis = TikaInputStream.get(is)) { + fillMetadata(null, metadata, httpHeaders.getRequestHeaders()); + TikaResource.logRequest(LOG, "/unpack/thumbnail", metadata); + return doUnpackThumbnail(tis, metadata, pc, renderThumbnails); + } + } + + /** + * Returns the document thumbnail with its metadata (multipart POST, "file" part). + */ + @jakarta.ws.rs.Path("/thumbnail") + @POST + @Consumes("multipart/form-data") + @Produces("application/json") + public Response unpackThumbnailMultipart(List attachments, @Context HttpHeaders httpHeaders, + @QueryParam("renderThumbnails") boolean renderThumbnails) + throws Exception { + ParseContext pc = tikaResource.createRequestContext(); + Metadata metadata = tikaResource.newRequestMetadata(); + try (TikaInputStream tis = tikaResource.setupMultipartConfig(attachments, metadata, pc)) { + TikaResource.logRequest(LOG, "/unpack/thumbnail", metadata); + return doUnpackThumbnail(tis, metadata, pc, renderThumbnails); + } + } + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String METADATA_SUFFIX = ".metadata.json"; + /** + * A thumbnail travels base64-encoded inside a JSON object, so it is + * bounded here regardless of the unpack limits; camera previews and + * page renderings are a few MB at most. + */ + static final long MAX_THUMBNAIL_BYTES = 32L * 1024 * 1024; + /** + * What {@code /unpack/thumbnail} adds regardless of rendering: the text + * of the images is not wanted. + */ + private static final ThumbnailDefaults NO_OCR = ThumbnailDefaults.none() + .with("{\"pdf-parser\": {\"ocr\": {\"strategy\": \"NO_OCR\"}}, " + + "\"tesseract-ocr-parser\": {\"skipOcr\": true}}"); + + /** + * Parses in unpack mode with the thumbnail configuration, then selects + * the thumbnail among the extracted embedded documents. + */ + private Response doUnpackThumbnail(TikaInputStream tis, Metadata metadata, ParseContext pc, + boolean renderThumbnails) throws Exception { + PipesParsingHelper helper = tikaResource.getPipesParsingHelper(); + if (helper == null) { + throw new WebApplicationException("Pipes-based parsing is not enabled", Response.Status.SERVICE_UNAVAILABLE); + } + configureThumbnailParse(pc, renderThumbnails); + + PipesParsingHelper.UnpackResult result = helper.parseUnpack(tis, metadata, pc, false); + if (result.zipFile() == null) { + throw new WebApplicationException(Response.Status.NO_CONTENT); + } + try (ZipFile zip = new ZipFile(result.zipFile().toFile())) { + Map extracted = readExtractedMetadata(zip); + Metadata thumbnail = ThumbnailSelector.select(new ArrayList<>(extracted.values())); + if (thumbnail == null) { + throw new WebApplicationException(Response.Status.NO_CONTENT); + } + String entryName = null; + for (Map.Entry e : extracted.entrySet()) { + //the selector returns one of these very objects + if (e.getValue() == thumbnail) { + entryName = e.getKey(); + break; + } + } + ZipEntry imageEntry = entryName == null ? null : zip.getEntry(entryName); + if (imageEntry == null) { + throw new WebApplicationException(Response.Status.NO_CONTENT); + } + if (imageEntry.getSize() > MAX_THUMBNAIL_BYTES) { + throw new WebApplicationException("thumbnail larger than " + MAX_THUMBNAIL_BYTES + " bytes", + Response.Status.REQUEST_ENTITY_TOO_LARGE); + } + byte[] image; + try (InputStream is = zip.getInputStream(imageEntry)) { + //the entry size is a claim; read one byte past the limit to know + image = is.readNBytes((int) MAX_THUMBNAIL_BYTES + 1); + } + if (image.length > MAX_THUMBNAIL_BYTES) { + throw new WebApplicationException("thumbnail larger than " + MAX_THUMBNAIL_BYTES + " bytes", + Response.Status.REQUEST_ENTITY_TOO_LARGE); + } + StringWriter metadataJson = new StringWriter(); + JsonMetadata.toJson(thumbnail, metadataJson); + ObjectNode root = MAPPER.createObjectNode(); + root.set("metadata", MAPPER.readTree(metadataJson.toString())); + root.put("image", Base64.getEncoder().encodeToString(image)); + return Response.ok(MAPPER.writeValueAsString(root)).type("application/json").build(); + } finally { + result.cleanup(); + } + } + + /** + * What only makes sense when the thumbnail is all the caller wants: no + * text, no OCR, only THUMBNAIL and RENDERING embedded documents extracted, + * together with their metadata, down to the rendering of a thumbnail + * (depth 2). With {@code renderThumbnails} the {@link ThumbnailDefaults} + * are laid under that, the same switch as on the other endpoints; without + * it only stored thumbnails are found. The request's own parser + * configuration wins where present. + */ + private void configureThumbnailParse(ParseContext pc, boolean renderThumbnails) { + //the text is not part of the answer: do not extract it + tikaResource.setupContentHandlerFactory(pc, "ignore"); + (renderThumbnails ? tikaResource.getThumbnailDefaults().with(NO_OCR) : NO_OCR).applyTo(pc); + StandardUnpackSelector selector = new StandardUnpackSelector(); + selector.setIncludeEmbeddedResourceTypes(new HashSet<>(Arrays.asList( + TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name(), + TikaCoreProperties.EmbeddedResourceType.RENDERING.name()))); + pc.set(UnpackSelector.class, selector); + if (pc.get(EmbeddedLimits.class) == null) { + EmbeddedLimits limits = new EmbeddedLimits(); + //the thumbnail is at depth 1, its rendering at depth 2 + limits.setMaxDepth(2); + pc.set(EmbeddedLimits.class, limits); + } + UnpackConfig unpackConfig = pc.get(UnpackConfig.class); + if (unpackConfig == null) { + unpackConfig = tikaResource.newConfigUnpackConfig(); + if (unpackConfig == null) { + unpackConfig = new UnpackConfig(); + } + } + unpackConfig.setIncludeMetadataInZip(true); + pc.set(UnpackConfig.class, unpackConfig); + } + + /** + * Reads the {@code *.metadata.json} entries of the unpack zip, keyed by + * the name of the file they describe, in zip order. + */ + private static Map readExtractedMetadata(ZipFile zip) throws IOException { + Map extracted = new LinkedHashMap<>(); + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if (!name.endsWith(METADATA_SUFFIX)) { + continue; + } + try (InputStreamReader reader = new InputStreamReader(zip.getInputStream(entry), + StandardCharsets.UTF_8)) { + extracted.put(name.substring(0, name.length() - METADATA_SUFFIX.length()), + JsonMetadata.fromJson(reader)); + } + } + return extracted; + } + /** * Core unpack logic using pipes-based parsing. * The child process creates the zip file, and we stream it directly back. diff --git a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/ThumbnailDefaultsTest.java b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/ThumbnailDefaultsTest.java new file mode 100644 index 00000000000..95280e81759 --- /dev/null +++ b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/ThumbnailDefaultsTest.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.apache.tika.config.loader.TikaJsonConfig; +import org.apache.tika.parser.ParseContext; + +public class ThumbnailDefaultsTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + public void testBuiltIn() throws Exception { + ParseContext context = new ParseContext(); + ThumbnailDefaults.builtIn().applyTo(context); + JsonNode pdf = json(context, "pdf-parser"); + assertEquals("RENDER_PAGES_AT_PAGE_END", pdf.get("imageStrategy").asText()); + assertEquals(1, pdf.get("maxRenderedPages").asInt()); + assertEquals(96, pdf.get("ocr").get("dpi").asInt()); + //a thumbnail is in colour; the renderer defaults to the grayscale OCR wants + assertEquals("RGB", pdf.get("ocr").get("imageType").asText()); + //no OCR setting: the indexing request keeps whatever the server does + assertNull(pdf.get("ocr").get("strategy")); + assertEquals("THUMBNAIL", + json(context, "emf-parser").get("renderOnlyEmbeddedResourceTypes").get(0).asText()); + assertEquals(true, json(context, "wmf-parser").get("renderImage").asBoolean()); + } + + @Test + public void testServerConfigReplacesAComponent() throws Exception { + TikaJsonConfig config = config("{\"thumbnail-defaults\": {\"pdf-parser\": " + + "{\"imageStrategy\": \"RENDER_PAGES_AT_PAGE_END\", \"maxRenderedPages\": 1, " + + "\"ocr\": {\"dpi\": 150}}}}"); + ParseContext context = new ParseContext(); + ThumbnailDefaults.fromConfig(config).applyTo(context); + assertEquals(150, json(context, "pdf-parser").get("ocr").get("dpi").asInt()); + //the components the config does not mention keep the built-in defaults + assertEquals(true, json(context, "emf-parser").get("renderImage").asBoolean()); + } + + @Test + public void testNoConfigBlockMeansBuiltIn() throws Exception { + ParseContext context = new ParseContext(); + ThumbnailDefaults.fromConfig(config("{\"parsers\": []}")).applyTo(context); + assertEquals(96, json(context, "pdf-parser").get("ocr").get("dpi").asInt()); + } + + @Test + public void testRequestConfigWins() throws Exception { + ParseContext context = new ParseContext(); + context.setJsonConfig("pdf-parser", "{\"imageStrategy\": \"NONE\"}"); + ThumbnailDefaults.builtIn().applyTo(context); + assertEquals("NONE", json(context, "pdf-parser").get("imageStrategy").asText()); + assertEquals(true, json(context, "emf-parser").get("renderImage").asBoolean()); + } + + @Test + public void testWithMergesFieldByField() throws Exception { + ParseContext context = new ParseContext(); + ThumbnailDefaults.builtIn() + .with("{\"pdf-parser\": {\"ocr\": {\"strategy\": \"NO_OCR\"}}, " + + "\"tesseract-ocr-parser\": {\"skipOcr\": true}}") + .applyTo(context); + JsonNode pdf = json(context, "pdf-parser"); + assertEquals("NO_OCR", pdf.get("ocr").get("strategy").asText()); + assertEquals(96, pdf.get("ocr").get("dpi").asInt()); + assertEquals(1, pdf.get("maxRenderedPages").asInt()); + assertEquals(true, json(context, "tesseract-ocr-parser").get("skipOcr").asBoolean()); + //the original is untouched + assertNull(MAPPER.readTree(ThumbnailDefaults.builtIn().get("pdf-parser")) + .get("ocr").get("strategy")); + } + + @Test + public void testMalformedBlockIsRejected() throws Exception { + TikaJsonConfig config = config("{\"thumbnail-defaults\": {\"pdf-parser\": \"yes\"}}"); + assertThrows(IllegalArgumentException.class, () -> ThumbnailDefaults.fromConfig(config)); + } + + private static TikaJsonConfig config(String json) throws Exception { + return TikaJsonConfig.load(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + } + + private static JsonNode json(ParseContext context, String component) throws Exception { + return MAPPER.readTree(context.getJsonConfig(component).json()); + } +} diff --git a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/ThumbnailSelectorTest.java b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/ThumbnailSelectorTest.java new file mode 100644 index 00000000000..705f126d0d6 --- /dev/null +++ b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/ThumbnailSelectorTest.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.core.resource; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import org.apache.tika.metadata.HttpHeaders; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; + +public class ThumbnailSelectorTest { + + @Test + public void testRasterThumbnailWins() { + Metadata inline = embedded("INLINE", "image/png", 1, "/image1.png"); + Metadata thumbnail = embedded("THUMBNAIL", "image/jpeg", 1, "/thumbnail.jpeg"); + Metadata rendering = embedded("RENDERING", "image/png", 1, "/page-1.png"); + assertSame(thumbnail, ThumbnailSelector.select(Arrays.asList(inline, rendering, thumbnail))); + } + + @Test + public void testVectorThumbnailFallsBackToItsRendering() { + Metadata emf = embedded("THUMBNAIL", "image/emf", 1, "/thumbnail.emf"); + Metadata wmfInside = embedded("ATTACHMENT", "image/wmf", 2, "/thumbnail.emf/embedded-1.wmf"); + //the metafile parsers emit the rendering of a THUMBNAIL as a THUMBNAIL + Metadata rendering = embedded("THUMBNAIL", "image/png", 2, "/thumbnail.emf/thumbnail.png"); + Metadata otherRendering = embedded("RENDERING", "image/png", 2, "/embedded-1.emf/rendering.png"); + assertSame(rendering, + ThumbnailSelector.select(Arrays.asList(emf, wmfInside, otherRendering, rendering))); + } + + @Test + public void testRenderingTypedRenderingUnderTheThumbnailIsAccepted() { + //an injected renderer may type the rendering as RENDERING + Metadata emf = embedded("THUMBNAIL", "image/emf", 1, "/thumbnail.emf"); + Metadata rendering = embedded("RENDERING", "image/png", 2, "/thumbnail.emf/thumbnail.png"); + assertSame(rendering, ThumbnailSelector.select(Arrays.asList(emf, rendering))); + } + + @Test + public void testVectorThumbnailWithoutRenderingIsNotReturned() { + Metadata emf = embedded("THUMBNAIL", "image/emf", 1, "/thumbnail.emf"); + assertNull(ThumbnailSelector.select(Collections.singletonList(emf))); + } + + @Test + public void testPageRenderingAsLastResort() { + Metadata page = embedded("RENDERING", "image/png", 1, "/page-1.png"); + Metadata objectPicture = embedded("RENDERING", "image/png", 2, "/embedded-1.emf/rendering.png"); + assertSame(page, ThumbnailSelector.select(Arrays.asList(objectPicture, page))); + } + + @Test + public void testNestedThumbnailIsNotTheContainers() { + //the thumbnail of a document inside a zip + Metadata nested = embedded("THUMBNAIL", "image/jpeg", 2, "/doc.docx/thumbnail.jpeg"); + assertNull(ThumbnailSelector.select(Collections.singletonList(nested))); + } + + @Test + public void testNothingSuitable() { + Metadata attachment = embedded("ATTACHMENT", "application/pdf", 1, "/a.pdf"); + Metadata inline = embedded("INLINE", "image/png", 1, "/image1.png"); + assertNull(ThumbnailSelector.select(Arrays.asList(attachment, inline))); + assertNull(ThumbnailSelector.select(Collections.emptyList())); + } + + private static Metadata embedded(String type, String contentType, int depth, String path) { + Metadata m = new Metadata(); + m.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, type); + m.set(HttpHeaders.CONTENT_TYPE, contentType); + m.set(TikaCoreProperties.EMBEDDED_DEPTH, depth); + m.set(TikaCoreProperties.EMBEDDED_RESOURCE_PATH, path); + return m; + } +} diff --git a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/UnpackerThumbnailTest.java b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/UnpackerThumbnailTest.java new file mode 100644 index 00000000000..3ec4b778ed0 --- /dev/null +++ b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/UnpackerThumbnailTest.java @@ -0,0 +1,219 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.server.standard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.imageio.ImageIO; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.core.Response; +import org.apache.commons.io.FileUtils; +import org.apache.cxf.jaxrs.JAXRSServerFactoryBean; +import org.apache.cxf.jaxrs.client.WebClient; +import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.serialization.config.JsonConfigHelper; +import org.apache.tika.server.core.CXFTestBase; +import org.apache.tika.server.core.TikaServerParseExceptionMapper; +import org.apache.tika.server.core.resource.RecursiveMetadataResource; +import org.apache.tika.server.core.resource.UnpackerResource; +import org.apache.tika.server.core.writer.MetadataListMessageBodyWriter; + +/** + * {@code /unpack/thumbnail} end to end: the document thumbnail comes back as + * JSON with its metadata and the image as base64. + */ +public class UnpackerThumbnailTest extends CXFTestBase { + + private static final String THUMBNAIL_PATH = "/unpack/thumbnail"; + private static final String UNPACK_CONFIG_TEMPLATE = "/configs/cxf-unpack-test-template.json"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private Path unpackTempDir; + + @Override + protected void setUpResources(JAXRSServerFactoryBean sf) { + sf.setResourceClasses(UnpackerResource.class, RecursiveMetadataResource.class); + sf.setResourceProvider(UnpackerResource.class, + new SingletonResourceProvider(new UnpackerResource(tikaResource))); + sf.setResourceProvider(RecursiveMetadataResource.class, + new SingletonResourceProvider(new RecursiveMetadataResource(tikaResource))); + } + + @Override + protected void setUpProviders(JAXRSServerFactoryBean sf) { + List providers = new ArrayList<>(); + providers.add(new TikaServerParseExceptionMapper()); + providers.add(new MetadataListMessageBodyWriter()); + sf.setProviders(providers); + } + + @Override + protected InputStream getPipesConfigInputStream() throws IOException { + unpackTempDir = Files.createTempDirectory("tika-unpack-thumbnail-test-"); + Path pluginsDir = Paths.get("target/plugins").toAbsolutePath(); + Map replacements = new HashMap<>(); + replacements.put("UNPACK_EMITTER_BASE_PATH", unpackTempDir.toAbsolutePath().toString()); + replacements.put("PLUGINS_PATHS", pluginsDir.toString().replace("\\", "/")); + replacements.put("TIMEOUT_MILLIS", 60000L); + JsonNode config = JsonConfigHelper.loadFromResource(UNPACK_CONFIG_TEMPLATE, + CXFTestBase.class, replacements); + return new ByteArrayInputStream( + MAPPER.writeValueAsString(config).getBytes(StandardCharsets.UTF_8)); + } + + @Override + protected Path getUnpackEmitterBasePath() { + return unpackTempDir; + } + + /** + * A stored thumbnail (the docProps thumbnail of a presentation). + */ + @Test + public void testStoredThumbnail() throws Exception { + JsonNode json = thumbnail("test-documents/testPPTX_Thumbnail.pptx"); + JsonNode metadata = json.get("metadata"); + assertEquals("image/jpeg", metadata.get("Content-Type").asText()); + assertEquals("THUMBNAIL", metadata.get("tk:embedded-resource-type").asText()); + assertEquals("1", metadata.get("tk:embedded-depth").asText()); + BufferedImage image = decode(json); + assertEquals(metadata.get("tiff:ImageWidth").asInt(), image.getWidth()); + } + + /** + * A camera raw file: the largest embedded JPEG preview. + */ + @Test + public void testRawPreview() throws Exception { + JsonNode json = thumbnail("test-documents/testNEF.nef"); + JsonNode metadata = json.get("metadata"); + assertEquals("image/jpeg", metadata.get("Content-Type").asText()); + assertEquals("THUMBNAIL", metadata.get("tk:embedded-resource-type").asText()); + assertEquals(64, decode(json).getWidth()); + } + + /** + * A PDF has no thumbnail; with renderThumbnails the rendering of its first + * page stands in, without it there is nothing. + */ + @Test + public void testPdfPageRendering() throws Exception { + Response plain = WebClient.create(endPoint + THUMBNAIL_PATH) + .put(ClassLoader.getSystemResourceAsStream("test-documents/testPDFTwoTextBoxes.pdf")); + assertEquals(204, plain.getStatus()); + + JsonNode json = thumbnail("test-documents/testPDFTwoTextBoxes.pdf?renderThumbnails=true"); + JsonNode metadata = json.get("metadata"); + assertEquals("image/png", metadata.get("Content-Type").asText()); + assertEquals("RENDERING", metadata.get("tk:embedded-resource-type").asText()); + assertEquals("1", metadata.get("tk:page:number").asText()); + assertTrue(decode(json).getWidth() > 100); + } + + /** + * A document without a thumbnail: no content, no error. + */ + @Test + public void testNoThumbnail() throws Exception { + Response response = WebClient.create(endPoint + THUMBNAIL_PATH) + .put(ClassLoader.getSystemResourceAsStream("test-documents/2pic.docx")); + assertEquals(204, response.getStatus()); + } + + /** + * {@code /rmeta?renderThumbnails=true} lays the thumbnail defaults under a + * normal metadata request: the first page rendering joins the list, the + * text is still extracted. Without the switch nothing is rendered. + */ + @Test + public void testRmetaRenderThumbnails() throws Exception { + JsonNode plain = rmeta("test-documents/testPDFTwoTextBoxes.pdf", false); + assertEquals(1, plain.size()); + + JsonNode rendered = rmeta("test-documents/testPDFTwoTextBoxes.pdf", true); + assertEquals(2, rendered.size()); + assertTrue(rendered.get(0).get(TikaCoreProperties.TIKA_CONTENT.getName()).asText() + .contains("Left column"), rendered.get(0).toString()); + JsonNode rendering = rendered.get(1); + assertEquals("image/png", rendering.get("Content-Type").asText()); + assertEquals("RENDERING", rendering.get("tk:embedded-resource-type").asText()); + assertEquals("1", rendering.get("tk:page:number").asText()); + assertTrue(rendering.get("tiff:ImageWidth").asInt() > 100); + } + + private JsonNode rmeta(String resource, boolean renderThumbnails) throws Exception { + Response response = WebClient.create(endPoint + "/rmeta/text" + + (renderThumbnails ? "?renderThumbnails=true" : "")) + .put(ClassLoader.getSystemResourceAsStream(resource)); + assertEquals(200, response.getStatus()); + return MAPPER.readTree((InputStream) response.getEntity()); + } + + /** + * Sends the file name along, as a client would; since TIKA-4861 the raw + * camera formats are detected by content as well. + */ + private JsonNode thumbnail(String resource) throws Exception { + String query = ""; + int q = resource.indexOf('?'); + if (q >= 0) { + query = resource.substring(q); + resource = resource.substring(0, q); + } + String fileName = resource.substring(resource.lastIndexOf('/') + 1); + Response response = WebClient.create(endPoint + THUMBNAIL_PATH + query) + .header("Content-Disposition", "attachment; filename=" + fileName) + .put(ClassLoader.getSystemResourceAsStream(resource)); + assertEquals(200, response.getStatus()); + assertEquals("application/json", response.getMediaType().toString()); + return MAPPER.readTree((InputStream) response.getEntity()); + } + + private static BufferedImage decode(JsonNode json) throws IOException { + byte[] bytes = Base64.getDecoder().decode(json.get("image").asText()); + return ImageIO.read(new ByteArrayInputStream(bytes)); + } + + @Override + @AfterAll + public void tearDown() throws Exception { + super.tearDown(); + if (unpackTempDir != null && Files.exists(unpackTempDir)) { + FileUtils.deleteDirectory(unpackTempDir.toFile()); + } + } +} diff --git a/tika-server/tika-server-standard/src/test/resources/test-documents/testNEF.nef b/tika-server/tika-server-standard/src/test/resources/test-documents/testNEF.nef new file mode 100644 index 00000000000..35eb669bcac Binary files /dev/null and b/tika-server/tika-server-standard/src/test/resources/test-documents/testNEF.nef differ diff --git a/tika-server/tika-server-standard/src/test/resources/test-documents/testPPTX_Thumbnail.pptx b/tika-server/tika-server-standard/src/test/resources/test-documents/testPPTX_Thumbnail.pptx new file mode 100644 index 00000000000..ab4c62ad562 Binary files /dev/null and b/tika-server/tika-server-standard/src/test/resources/test-documents/testPPTX_Thumbnail.pptx differ