dschmidt commented on code in PR #3096: URL: https://github.com/apache/tika/pull/3096#discussion_r3890247299
########## tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/UnpackerThumbnailTest.java: ########## @@ -0,0 +1,208 @@ +/* + * 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.cxf.jaxrs.JAXRSServerFactoryBean; +import org.apache.cxf.jaxrs.client.WebClient; +import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider; +import org.junit.jupiter.api.Test; + +import org.apache.tika.metadata.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<Object> 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<String, Object> 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: raw camera formats are + * detected by their extension. + */ Review Comment: Updated, the comment predates TIKA-4861. ########## tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java: ########## @@ -215,15 +273,181 @@ public Response unpackAll(InputStream is, @Context HttpHeaders httpHeaders, @Con @POST @Consumes("multipart/form-data") @Produces("application/zip") - public Response unpackAllWithConfig(List<Attachment> attachments, @Context HttpHeaders httpHeaders, @Context UriInfo info) throws Exception { + public Response unpackAllWithConfig(List<Attachment> 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<Attachment> 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; + + /** + * 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<String, Metadata> 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<String, Metadata> e : extracted.entrySet()) { + if (e.getValue() == thumbnail) { + entryName = e.getKey(); + } + } + 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"); + ThumbnailDefaults noOcr = ThumbnailDefaults.none() + .with("{\"pdf-parser\": {\"ocr\": {\"strategy\": \"NO_OCR\"}}, " + + "\"tesseract-ocr-parser\": {\"skipOcr\": true}}"); + (renderThumbnails ? tikaResource.getThumbnailDefaults().with(noOcr) : noOcr).applyTo(pc); Review Comment: Made it a constant. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
