diff --git a/CHANGES.txt b/CHANGES.txt index ce1baa8f55e..80e898aa721 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,18 @@ Release 4.1.0 - unreleased + * 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 + directly), EMFParser and WMFParser are RenderingParsers that emit the + rendering as a RENDERING embedded document with "emf-parser" / + "wmf-parser": {"renderImage": true, "renderWidth": 800}, off by + default and restrictable to e.g. THUMBNAIL embedded documents with + "renderOnlyEmbeddedResourceTypes", and OfficeParser emits the + SummaryInformation thumbnail of the OLE2 formats (a WMF) as a THUMBNAIL + embedded document, as the OOXML parsers do with the docProps thumbnail, + switchable with "office-parser": {"extractThumbnail": false} + (TIKA-4855). + * Add "exception-reporting" parse-context config to redact and bound exception text in metadata, tika-server error bodies and pipes/grpc messages; FileSystemEmitter writes atomically (TIKA-4848). diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/microsoft/POIContainerExtractionTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/microsoft/POIContainerExtractionTest.java index cae0d633101..38624d0f196 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/microsoft/POIContainerExtractionTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/microsoft/POIContainerExtractionTest.java @@ -160,10 +160,10 @@ public void testEmbeddedOfficeFiles() throws Exception { assertEquals(TYPE_PNG, handler.mediaTypes.get(15)); // PNG inside .xls - // PowerPoint with excel and word + // PowerPoint with excel and word; its SummaryInformation thumbnail comes last handler = process("testPPT_embeded.ppt", extractor, false); - assertEquals(7, handler.filenames.size()); - assertEquals(7, handler.mediaTypes.size()); + assertEquals(8, handler.filenames.size()); + assertEquals(8, handler.mediaTypes.size()); // Embedded objects get OLE IDs, slide images now get generated names assertEquals("1", handler.filenames.get(0)); @@ -171,6 +171,7 @@ public void testEmbeddedOfficeFiles() throws Exception { for (int i = 2; i < 7; i++) { assertNotNull(handler.filenames.get(i)); } + assertEquals("thumbnail.wmf", handler.filenames.get(7)); // But we do know their types assertEquals(TYPE_XLS, handler.mediaTypes.get(0)); // Embedded office doc assertEquals(TYPE_DOC, handler.mediaTypes.get(1)); // Embedded office doc @@ -179,11 +180,12 @@ public void testEmbeddedOfficeFiles() throws Exception { assertEquals(TYPE_PNG, handler.mediaTypes.get(4)); // Embedded image assertEquals(TYPE_PNG, handler.mediaTypes.get(5)); // Embedded image assertEquals(TYPE_PNG, handler.mediaTypes.get(6)); // Embedded image + assertEquals(TYPE_WMF, handler.mediaTypes.get(7)); // Document thumbnail // Run again on PowerPoint but with recursion handler = process("testPPT_embeded.ppt", extractor, true); - assertEquals(11, handler.filenames.size()); - assertEquals(11, handler.mediaTypes.size()); + assertEquals(12, handler.filenames.size()); + assertEquals(12, handler.mediaTypes.size()); assertEquals("1", handler.filenames.get(0)); assertEquals(null, handler.filenames.get(1)); @@ -195,6 +197,7 @@ public void testEmbeddedOfficeFiles() throws Exception { for (int i = 6; i < 11; i++) { assertNotNull(handler.filenames.get(i)); } + assertEquals("thumbnail.wmf", handler.filenames.get(11)); assertEquals(TYPE_XLS, handler.mediaTypes.get(0)); // Embedded office doc assertEquals(TYPE_PNG, handler.mediaTypes.get(1)); // PNG inside .xls @@ -207,6 +210,7 @@ public void testEmbeddedOfficeFiles() throws Exception { assertEquals(TYPE_PNG, handler.mediaTypes.get(8)); // Embedded image assertEquals(TYPE_PNG, handler.mediaTypes.get(9)); // Embedded image assertEquals(TYPE_PNG, handler.mediaTypes.get(10)); // Embedded image + assertEquals(TYPE_WMF, handler.mediaTypes.get(11)); // Document thumbnail // Word, with a non-office file (PDF) diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractMetafileParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractMetafileParser.java new file mode 100644 index 00000000000..2c73b16a075 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractMetafileParser.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.parser.microsoft; + +import java.io.IOException; + +import org.apache.tika.config.ParseContextConfig; +import org.apache.tika.exception.TikaException; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.parser.Parser; +import org.apache.tika.parser.RenderingParser; +import org.apache.tika.renderer.Renderer; + +/** + * What {@link EMFParser} and {@link WMFParser} share: the configuration of + * the metafile parsers and the renderer they may be handed. + */ +abstract class AbstractMetafileParser implements Parser, RenderingParser { + + private final MetafileParserConfig defaultConfig; + private Renderer renderer; + + AbstractMetafileParser(MetafileParserConfig defaultConfig) { + this.defaultConfig = defaultConfig; + } + + /** + * The component name this parser reads its configuration from, + * {@code emf-parser} or {@code wmf-parser}. + */ + abstract String componentName(); + + MetafileParserConfig getConfig(ParseContext context) throws TikaException, IOException { + return ParseContextConfig.getConfig(context, componentName(), MetafileParserConfig.class, + defaultConfig); + } + + /** + * Spools the stream when the image is going to be rendered, so the + * renderer can read the metafile itself rather than only the parsed + * picture. + */ + static void prepareForRendering(TikaInputStream tis, MetafileParserConfig config, + Metadata metadata) throws IOException { + if (config.shouldRender(metadata)) { + tis.getFile(); + } + } + + Renderer getRenderer() { + return renderer; + } + + @Override + public void setRenderer(Renderer renderer) { + this.renderer = renderer; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/EMFParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/EMFParser.java index ba7bc8efc65..dc6c57c6c23 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/EMFParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/EMFParser.java @@ -35,6 +35,8 @@ import org.xml.sax.SAXException; import org.apache.tika.annotation.TikaComponent; +import org.apache.tika.config.ConfigDeserializer; +import org.apache.tika.config.JsonConfig; import org.apache.tika.exception.TikaException; import org.apache.tika.extractor.EmbeddedDocumentExtractor; import org.apache.tika.extractor.EmbeddedDocumentUtil; @@ -42,9 +44,10 @@ import org.apache.tika.metadata.HttpHeaders; import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.Property; +import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.mime.MediaType; import org.apache.tika.parser.ParseContext; -import org.apache.tika.parser.Parser; +import org.apache.tika.renderer.Renderer; import org.apache.tika.sax.EmbeddedContentHandler; import org.apache.tika.sax.XHTMLContentHandler; @@ -61,13 +64,40 @@ * We're also relying on storage order for text order, which isn't great. * We'd have to do something like what PDFBox or XPS do to sort the * runs and then put the cow back together from the hamburger...lol... + *

+ * With {@link MetafileParserConfig#setRenderImage(boolean)} + * ("emf-parser": {"renderImage": true}) the image is rendered through the + * configured {@link Renderer}, the + * {@link org.apache.tika.renderer.microsoft.POIMetafileRenderer} by + * default, and emitted as a + * {@link TikaCoreProperties.EmbeddedResourceType#RENDERING} embedded document, + * the way the PDF parser emits page renderings, so a client can obtain a + * raster preview of a vector thumbnail such as the docProps thumbnail of a + * Word document. */ @TikaComponent -public class EMFParser implements Parser { +public class EMFParser extends AbstractMetafileParser { public static Property EMF_ICON_ONLY = Property.internalBoolean("emf:icon-only"); public static Property EMF_ICON_STRING = Property.internalText("emf:icon-string"); + public EMFParser() { + this(new MetafileParserConfig()); + } + + public EMFParser(MetafileParserConfig config) { + super(config); + } + + public EMFParser(JsonConfig jsonConfig) { + this(ConfigDeserializer.buildConfig(jsonConfig, MetafileParserConfig.class)); + } + + @Override + String componentName() { + return "emf-parser"; + } + private static String ICON_ONLY = "IconOnly"; private static final MediaType MEDIA_TYPE = MediaType.image("emf"); @@ -102,6 +132,8 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context); xhtml.startDocument(); try { + MetafileParserConfig config = getConfig(context); + prepareForRendering(tis, config, metadata); HemfPicture ex = new HemfPicture(tis); ParseState parseState = new ParseState(); long fudgeFactorX = 10;//derive this from the font or frame/bounds information @@ -139,6 +171,10 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata xhtml.characters(buffer.toString()); xhtml.endElement("p"); } + if (config.shouldRender(metadata)) { + MetafileRendering.render(getRenderer(), config, MEDIA_TYPE, tis, ex, xhtml, + metadata, context); + } } catch (RecordFormatException e) { //POI's hemfparser can throw these for "parse // exceptions" diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/MetafileParserConfig.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/MetafileParserConfig.java new file mode 100644 index 00000000000..2db698f672a --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/MetafileParserConfig.java @@ -0,0 +1,104 @@ +/* + * 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.microsoft; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.Set; + +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.renderer.microsoft.POIMetafileRenderer; + +/** + * Configuration of the {@link EMFParser} ("emf-parser") and the + * {@link WMFParser} ("wmf-parser"). + */ +public class MetafileParserConfig implements Serializable { + + private static final long serialVersionUID = -6371049153052164071L; + + private boolean renderImage = false; + private int renderWidth = 800; + private Set renderOnlyEmbeddedResourceTypes = new HashSet<>(); + + /** + * Whether to render the image and emit the rendering as a RENDERING + * embedded document. Off by default. + */ + public boolean isRenderImage() { + return renderImage; + } + + public void setRenderImage(boolean renderImage) { + this.renderImage = renderImage; + } + + /** + * Width of the rendering in pixels when the default + * {@link POIMetafileRenderer} is used; the height follows the image's + * aspect ratio. Default 800. + */ + public int getRenderWidth() { + return renderWidth; + } + + /** + * Restricts the rendering to images that are embedded documents of one + * of these {@code tk:embedded-resource-type}s, e.g. {@code ["THUMBNAIL"]} + * to render the thumbnail of an Office document but not the pictures of + * its embedded objects. Empty (the default) renders every image. + */ + public Set getRenderOnlyEmbeddedResourceTypes() { + return renderOnlyEmbeddedResourceTypes; + } + + public void setRenderOnlyEmbeddedResourceTypes(Set renderOnlyEmbeddedResourceTypes) { + if (renderOnlyEmbeddedResourceTypes == null) { + this.renderOnlyEmbeddedResourceTypes = new HashSet<>(); + return; + } + Set types = new HashSet<>(); + for (String type : renderOnlyEmbeddedResourceTypes) { + //a typo would silently disable rendering + types.add(TikaCoreProperties.EmbeddedResourceType.valueOf(type).name()); + } + this.renderOnlyEmbeddedResourceTypes = types; + } + + /** + * Whether an image with this metadata is to be rendered. + */ + public boolean shouldRender(Metadata metadata) { + if (!renderImage) { + return false; + } + if (renderOnlyEmbeddedResourceTypes.isEmpty()) { + return true; + } + String type = metadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE); + return type != null && renderOnlyEmbeddedResourceTypes.contains(type); + } + + public void setRenderWidth(int renderWidth) { + if (renderWidth < 1 || renderWidth > 10000) { + throw new IllegalArgumentException( + "renderWidth must be between 1 and 10000, got: " + renderWidth); + } + this.renderWidth = renderWidth; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/MetafileRendering.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/MetafileRendering.java new file mode 100644 index 00000000000..d4efd0a12cd --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/MetafileRendering.java @@ -0,0 +1,183 @@ +/* + * 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.microsoft; + +import java.io.IOException; + +import org.xml.sax.SAXException; + +import org.apache.tika.exception.TikaException; +import org.apache.tika.extractor.EmbeddedDocumentExtractor; +import org.apache.tika.extractor.EmbeddedDocumentUtil; +import org.apache.tika.io.FilenameUtils; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.HttpHeaders; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.mime.MediaType; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.renderer.RenderResult; +import org.apache.tika.renderer.RenderResults; +import org.apache.tika.renderer.Renderer; +import org.apache.tika.renderer.microsoft.POIMetafileRenderer; +import org.apache.tika.sax.EmbeddedContentHandler; +import org.apache.tika.sax.XHTMLContentHandler; + +/** + * Shared by {@link EMFParser} and {@link WMFParser}: renders a parsed + * metafile through a {@link Renderer} and emits the result as an embedded + * document, the way the PDF parser emits page renderings. The rendering of + * a {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL} is itself a + * THUMBNAIL (it is the same picture in a form a client can display); any + * other rendering is a {@link TikaCoreProperties.EmbeddedResourceType#RENDERING}. + */ +final class MetafileRendering { + + private MetafileRendering() { + } + + /** + * @param injected the renderer set on the parser, or null + * @param picture the parsed {@code HemfPicture} or {@code HwmfPicture} + */ + static void render(Renderer injected, MetafileParserConfig config, MediaType type, + TikaInputStream source, Object picture, XHTMLContentHandler xhtml, + Metadata metadata, ParseContext context) throws IOException, SAXException { + //like the PDF parser: the injected renderer if it handles the type, + //the default one otherwise + Renderer renderer = injected != null && injected.getSupportedTypes(context).contains(type) + ? injected : defaultRenderer(config); + Metadata renderMetadata = Metadata.newInstance(context); + renderMetadata.set(TikaCoreProperties.TYPE, type.toString()); + EmbeddedDocumentExtractor extractor = + EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context); + //rendering is the expensive part: ask before paying for it. The name + //assumes the renderer's default format; the result's own metadata + //replaces it below. + Metadata gate = Metadata.newInstance(context); + gate.set(TikaCoreProperties.RESOURCE_NAME_KEY, renderingName(metadata, "png")); + gate.set(HttpHeaders.CONTENT_TYPE, "image/png"); + gate.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, renderingType(metadata).name()); + if (!extractor.shouldParseEmbedded(gate, context)) { + return; + } + try (TikaInputStream pictureStream = pictureStream(source); + RenderResults results = render(renderer, pictureStream, picture, renderMetadata, + metadata, context)) { + if (results == null) { + return; + } + for (RenderResult result : results.getResults()) { + if (result.getStatus() != RenderResult.STATUS.SUCCESS) { + //carry the renderer's diagnostics over to the metafile + String[] warnings = result.getMetadata() + .getValues(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING); + if (warnings.length == 0) { + EmbeddedDocumentUtil.recordException( + new TikaException("metafile rendering failed"), metadata, context); + } + for (String warning : warnings) { + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, warning); + } + continue; + } + Metadata renderingMetadata = result.getMetadata(); + renderingMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, + renderingName(metadata, extension(renderingMetadata))); + renderingMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, + renderingType(metadata).name()); + if (extractor.shouldParseEmbedded(renderingMetadata, context)) { + try (TikaInputStream tis = result.getInputStream()) { + extractor.parseEmbedded(tis, new EmbeddedContentHandler(xhtml), + renderingMetadata, context, false); + } + } + } + } + } + + /** + * The stream the renderer reads the picture from: the metafile itself + * where the parser spooled it, so a renderer that does not know the + * open-container shortcut still sees the bytes. + */ + private static TikaInputStream pictureStream(TikaInputStream source) throws IOException { + if (source != null && source.hasFile()) { + return TikaInputStream.get(source.getPath()); + } + return TikaInputStream.get(new byte[0]); + } + + /** + * The rendering of a THUMBNAIL is itself a THUMBNAIL: it is the same + * picture in a form a client can display. Any other rendering is a + * RENDERING. + */ + private static TikaCoreProperties.EmbeddedResourceType renderingType(Metadata metadata) { + return TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name() + .equals(metadata.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)) + ? TikaCoreProperties.EmbeddedResourceType.THUMBNAIL + : TikaCoreProperties.EmbeddedResourceType.RENDERING; + } + + private static String extension(Metadata renderingMetadata) { + String contentType = renderingMetadata.get(HttpHeaders.CONTENT_TYPE); + return contentType != null && contentType.startsWith("image/") + ? contentType.substring("image/".length()) : "png"; + } + + /** + * @param renderMetadata what the renderer is told about the picture (its + * type, so a composite renderer can route it) + * @param parentMetadata the metafile's metadata; a rendering failure is + * recorded there, so it is not silently swallowed + */ + private static RenderResults render(Renderer renderer, TikaInputStream pictureStream, + Object picture, Metadata renderMetadata, + Metadata parentMetadata, ParseContext context) + throws IOException { + //hand the parsed picture over instead of re-reading the stream + pictureStream.setOpenContainer(picture); + try { + return renderer.render(pictureStream, renderMetadata, context); + } catch (SecurityException e) { + throw e; + } catch (Exception e) { + EmbeddedDocumentUtil.recordException(e, parentMetadata, context); + return null; + } + } + + private static Renderer defaultRenderer(MetafileParserConfig config) { + POIMetafileRenderer renderer = new POIMetafileRenderer(); + renderer.setWidth(config.getRenderWidth()); + return renderer; + } + + /** + * The rendering is named after the image, with the rendering's format as + * its extension. + */ + private static String renderingName(Metadata metadata, String extension) { + String name = FilenameUtils.getName(metadata.get(TikaCoreProperties.RESOURCE_NAME_KEY)); + if (name == null || name.isEmpty()) { + return "rendering." + extension; + } + int dot = name.lastIndexOf('.'); + return (dot > 0 ? name.substring(0, dot) : name) + "." + extension; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParser.java index a88bf609a46..423a51f86be 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParser.java @@ -214,7 +214,8 @@ protected void parse(DirectoryNode root, ParseContext context, Metadata metadata throws IOException, SAXException, TikaException { // Parse summary entries first, to make metadata available early - new SummaryExtractor(metadata).parseSummaries(root); + SummaryExtractor summaryExtractor = new SummaryExtractor(metadata); + summaryExtractor.parseSummaries(root); // Parse remaining document entries POIFSDocumentType type = POIFSDocumentType.detectType(root); @@ -306,6 +307,36 @@ protected void parse(DirectoryNode root, ParseContext context, Metadata metadata // is extracted, which happened above break; } + OfficeParserConfig config = context.get(OfficeParserConfig.class); + if (config == null || config.isExtractThumbnail()) { + handleThumbnail(summaryExtractor.getThumbnailWmf(), xhtml, context); + } + } + + /** + * Emits the document thumbnail from the SummaryInformation, a WMF, as a + * {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL} embedded + * document, as the OOXML parsers do with the docProps thumbnail. Switched + * off with {@code "office-parser": {"extractThumbnail": false}}. + */ + private void handleThumbnail(byte[] wmf, XHTMLContentHandler xhtml, ParseContext context) + throws IOException, SAXException { + if (wmf == null) { + return; + } + Metadata embeddedMetadata = Metadata.newInstance(context); + embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, "thumbnail.wmf"); + embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, + TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name()); + embeddedMetadata.set(HttpHeaders.CONTENT_TYPE, "image/wmf"); + EmbeddedDocumentExtractor extractor = + EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context); + if (extractor.shouldParseEmbedded(embeddedMetadata, context)) { + try (TikaInputStream tis = TikaInputStream.get(wmf)) { + extractor.parseEmbedded(tis, new EmbeddedContentHandler(xhtml), embeddedMetadata, + context, false); + } + } } private void setType(Metadata metadata, MediaType type) { diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java index bb2ff73af57..5b174caaaa4 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java @@ -36,6 +36,8 @@ public class OfficeParserConfig implements Serializable { private boolean writeSelectHeadersInBody = false; + private boolean extractThumbnail = true; + /** * Maximum bytes per embedded object/pict when extracting from RTF within * MSG files. Since embedded data is streamed to disk (not held in memory), @@ -302,4 +304,17 @@ public int getRtfEmbeddedMaxBytesInKb() { public void setRtfEmbeddedMaxBytesInKb(int rtfEmbeddedMaxBytesInKb) { this.rtfEmbeddedMaxBytesInKb = rtfEmbeddedMaxBytesInKb; } + + /** + * Whether to emit the document thumbnail an OLE2 file stores in its + * SummaryInformation (a WMF) as a THUMBNAIL embedded document, the way + * the OOXML parsers emit the docProps thumbnail. On by default. + */ + public boolean isExtractThumbnail() { + return extractThumbnail; + } + + public void setExtractThumbnail(boolean extractThumbnail) { + this.extractThumbnail = extractThumbnail; + } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/SummaryExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/SummaryExtractor.java index 238974eec84..eeeabdc7e7d 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/SummaryExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/SummaryExtractor.java @@ -28,6 +28,7 @@ import org.apache.poi.hpsf.NoPropertySetStreamException; import org.apache.poi.hpsf.PropertySet; import org.apache.poi.hpsf.SummaryInformation; +import org.apache.poi.hpsf.Thumbnail; import org.apache.poi.hpsf.UnexpectedPropertySetTypeException; import org.apache.poi.poifs.filesystem.DirectoryNode; import org.apache.poi.poifs.filesystem.DocumentEntry; @@ -61,6 +62,8 @@ public class SummaryExtractor { private final Metadata metadata; + private byte[] thumbnailWmf; + public SummaryExtractor(Metadata metadata) { this.metadata = metadata; } @@ -131,7 +134,16 @@ private void parseSummaryEntryIfExists(DirectoryNode root, String entryName) } } + /** + * The document thumbnail stored in the SummaryInformation as a WMF, or + * null if there is none or it is not a metafile picture. + */ + public byte[] getThumbnailWmf() { + return thumbnailWmf; + } + private void parse(SummaryInformation summary) { + thumbnailWmf = extractThumbnailWmf(summary); set(TikaCoreProperties.TITLE, summary.getTitle()); addMulti(metadata, TikaCoreProperties.CREATOR, summary.getAuthor()); //make sure these are retrievable specifically @@ -158,6 +170,25 @@ private void parse(SummaryInformation summary) { } } + /** + * PIDSI_THUMBNAIL is a clipboard object; Office stores the thumbnail as + * CF_METAFILEPICT, a WMF. Anything else, or a malformed one, yields null. + */ + private static byte[] extractThumbnailWmf(SummaryInformation summary) { + byte[] raw = summary.getThumbnail(); + if (raw == null || raw.length == 0) { + return null; + } + try { + byte[] wmf = new Thumbnail(raw).getThumbnailAsWMF(); + return wmf != null && wmf.length > 0 ? wmf : null; + } catch (SecurityException e) { + throw e; + } catch (Exception e) { + return null; + } + } + private void parse(DocumentSummaryInformation summary) { set(OfficeOpenXMLExtended.COMPANY, summary.getCompany()); addMulti(metadata, OfficeOpenXMLExtended.MANAGER, summary.getManager()); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WMFParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WMFParser.java index 41b9ecd2c25..9bbf578d11b 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WMFParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/WMFParser.java @@ -32,25 +32,53 @@ import org.xml.sax.SAXException; import org.apache.tika.annotation.TikaComponent; +import org.apache.tika.config.ConfigDeserializer; +import org.apache.tika.config.JsonConfig; import org.apache.tika.exception.TikaException; import org.apache.tika.io.TikaInputStream; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.mime.MediaType; import org.apache.tika.parser.ParseContext; -import org.apache.tika.parser.Parser; +import org.apache.tika.renderer.Renderer; import org.apache.tika.sax.XHTMLContentHandler; /** * This parser offers a very rough capability to extract text if there * is text stored in the WMF files. + *

+ * With {@link MetafileParserConfig#setRenderImage(boolean)} + * ("wmf-parser": {"renderImage": true}) the image is also rendered through the + * configured {@link Renderer}, the + * {@link org.apache.tika.renderer.microsoft.POIMetafileRenderer} by + * default, and emitted as a + * {@link TikaCoreProperties.EmbeddedResourceType#RENDERING} embedded document, + * the way the PDF parser emits page renderings. */ @TikaComponent -public class WMFParser implements Parser { +public class WMFParser extends AbstractMetafileParser { private static final MediaType MEDIA_TYPE = MediaType.image("wmf"); private static final Set SUPPORTED_TYPES = Collections.singleton(MEDIA_TYPE); + public WMFParser() { + this(new MetafileParserConfig()); + } + + public WMFParser(MetafileParserConfig config) { + super(config); + } + + public WMFParser(JsonConfig jsonConfig) { + this(ConfigDeserializer.buildConfig(jsonConfig, MetafileParserConfig.class)); + } + + @Override + String componentName() { + return "wmf-parser"; + } + @Override public Set getSupportedTypes(ParseContext context) { return SUPPORTED_TYPES; @@ -63,6 +91,8 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata xhtml.startDocument(); tis.setCloseShield(); try { + MetafileParserConfig config = getConfig(context); + prepareForRendering(tis, config, metadata); HwmfPicture picture = null; try { picture = new HwmfPicture(tis); @@ -95,6 +125,10 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata xhtml.endElement("p"); } } + if (config.shouldRender(metadata)) { + MetafileRendering.render(getRenderer(), config, MEDIA_TYPE, tis, picture, xhtml, + metadata, context); + } } catch (RecordFormatException e) { //POI's hwmfparser can \ throw these for "parse // exceptions" throw new TikaException(e.getMessage(), e); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/renderer/microsoft/POIMetafileRenderer.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/renderer/microsoft/POIMetafileRenderer.java new file mode 100644 index 00000000000..c2fde311e25 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/renderer/microsoft/POIMetafileRenderer.java @@ -0,0 +1,311 @@ +/* + * 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.renderer.microsoft; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.Dimension2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import javax.imageio.ImageIO; + +import org.apache.poi.hemf.usermodel.HemfPicture; +import org.apache.poi.hwmf.record.HwmfFill; +import org.apache.poi.hwmf.record.HwmfRecord; +import org.apache.poi.hwmf.usermodel.HwmfPicture; + +import org.apache.tika.annotation.TikaComponent; +import org.apache.tika.config.ParseContextConfig; +import org.apache.tika.exception.TikaException; +import org.apache.tika.extractor.EmbeddedDocumentUtil; +import org.apache.tika.io.TemporaryResources; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.HttpHeaders; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Rendering; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.mime.MediaType; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.parser.microsoft.MetafileParserConfig; +import org.apache.tika.renderer.RenderRequest; +import org.apache.tika.renderer.RenderResult; +import org.apache.tika.renderer.RenderResults; +import org.apache.tika.renderer.Renderer; +import org.apache.tika.renderer.RenderingTracker; + +/** + * Renders EMF and WMF images to a raster image through POI's HEMF and HWMF, + * the way {@code PDFBoxRenderer} renders PDF pages. The rendering has the + * configured width, its height follows the image's aspect ratio, and it is + * drawn on a white canvas. Metafiles have no pages, so the render requests + * are ignored and a single result is returned. + *

+ * The WMF thumbnails that Word stores in the SummaryInformation of a .doc + * consist of a window extent and a single {@code dibStretchBlt} record, for + * which POI cannot compute bounds; those are rendered from the record's + * bitmap directly. + */ +@TikaComponent(name = "poi-metafile-renderer") +public class POIMetafileRenderer implements Renderer { + + public static final String RENDERED_BY = "poi-metafile-renderer"; + + public static final MediaType EMF = MediaType.image("emf"); + public static final MediaType WMF = MediaType.image("wmf"); + + private static final Set SUPPORTED_TYPES = + Collections.unmodifiableSet(new HashSet<>(Arrays.asList(EMF, WMF))); + + private static final int MAX_WIDTH = 10000; + + /** + * A metafile declares its own aspect ratio, so a hostile one could ask + * for an arbitrarily tall canvas at any width. The renderer refuses + * beyond this height; an OutOfMemoryError would escape every catch in + * the parse. + */ + private static final int MAX_HEIGHT = 10000; + + private int width = 800; + private String imageFormatName = "png"; + + @Override + public Set getSupportedTypes(ParseContext context) { + return SUPPORTED_TYPES; + } + + /** + * Renders the metafile in the stream, or the {@link HemfPicture} or + * {@link HwmfPicture} set as the stream's open container. The metadata's + * {@link TikaCoreProperties#TYPE} tells EMF from WMF when a stream is + * parsed; it defaults to EMF. + */ + @Override + public RenderResults render(TikaInputStream tis, Metadata metadata, ParseContext parseContext, + RenderRequest... requests) throws IOException, TikaException { + Object picture = tis.getOpenContainer(); + if (!(picture instanceof HemfPicture) && !(picture instanceof HwmfPicture)) { + picture = WMF.toString().equals(metadata.get(TikaCoreProperties.TYPE)) + ? new HwmfPicture(tis) : new HemfPicture(tis); + } + RenderingTracker tracker = parseContext.get(RenderingTracker.class); + if (tracker == null) { + tracker = new RenderingTracker(); + parseContext.set(RenderingTracker.class, tracker); + } + int id = tracker.getNextId(); + int width = width(parseContext, metadata); + Metadata renderingMetadata = Metadata.newInstance(parseContext); + renderingMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, + TikaCoreProperties.EmbeddedResourceType.RENDERING.name()); + RenderResults results = new RenderResults(new TemporaryResources()); + try { + long start = System.currentTimeMillis(); + BufferedImage image = picture instanceof HemfPicture + ? draw((HemfPicture) picture, width) : draw((HwmfPicture) picture, width); + Path tmpFile = write(image, id); + renderingMetadata.set(Rendering.RENDERED_MS, System.currentTimeMillis() - start); + renderingMetadata.add(Rendering.RENDERED_BY, RENDERED_BY); + renderingMetadata.set(HttpHeaders.CONTENT_TYPE, "image/" + imageFormatName); + results.add(new RenderResult(RenderResult.STATUS.SUCCESS, id, tmpFile, + renderingMetadata)); + } catch (SecurityException e) { + throw e; + } catch (Exception e) { + //record the cause, as PDFBoxRenderer does, so the failure is diagnosable + EmbeddedDocumentUtil.recordException(e, renderingMetadata, parseContext); + results.add(new RenderResult(RenderResult.STATUS.EXCEPTION, id, null, + renderingMetadata)); + } + return results; + } + + private BufferedImage draw(HemfPicture picture, int width) throws IOException { + Dimension2D size = picture.getSize(); + BufferedImage image = canvas(size, width); + Graphics2D graphics = image.createGraphics(); + try { + picture.draw(graphics, new Rectangle2D.Double(0, 0, image.getWidth(), + image.getHeight())); + } finally { + graphics.dispose(); + } + return image; + } + + private BufferedImage draw(HwmfPicture picture, int width) throws IOException { + Dimension2D size; + try { + size = picture.getSize(); + } catch (IllegalStateException e) { + //POI throws this for "window records are incomplete": a bitmap + //wrapped in a metafile, as Word's .doc thumbnails are + BufferedImage bitmap = firstBitmap(picture); + if (bitmap == null) { + throw new IOException("WMF without bounds and without a bitmap", e); + } + return scale(bitmap, width); + } + BufferedImage image = canvas(size, width); + Graphics2D graphics = image.createGraphics(); + try { + picture.draw(graphics, new Rectangle2D.Double(0, 0, image.getWidth(), + image.getHeight())); + } finally { + graphics.dispose(); + } + return image; + } + + private static BufferedImage firstBitmap(HwmfPicture picture) { + for (HwmfRecord record : picture.getRecords()) { + if (record instanceof HwmfFill.HwmfImageRecord) { + BufferedImage image = ((HwmfFill.HwmfImageRecord) record).getImage(); + if (image != null) { + return image; + } + } + } + return null; + } + + /** + * The width of the rendering: the {@code renderWidth} of the metafile + * parser's configuration where the parse has one, else this renderer's + * own {@link #setWidth(int)}. The parser configuration wins so that a + * request configuring {@code "emf-parser": {"renderWidth": N}} reaches + * the renderer the parser was handed, which need not be this instance. + */ + private int width(ParseContext parseContext, Metadata metadata) + throws IOException, TikaException { + MetafileParserConfig config = parseContext.get(MetafileParserConfig.class); + if (config == null) { + String component = WMF.toString().equals(metadata.get(TikaCoreProperties.TYPE)) + ? "wmf-parser" : "emf-parser"; + if (parseContext.getJsonConfig(component) == null) { + return width; + } + config = ParseContextConfig.getConfig(parseContext, component, + MetafileParserConfig.class, new MetafileParserConfig()); + } + return config.getRenderWidth(); + } + + private BufferedImage canvas(Dimension2D size, int width) throws IOException { + if (size == null || size.getWidth() <= 0 || size.getHeight() <= 0) { + throw new IOException("metafile without a usable size: " + size); + } + BufferedImage image = new BufferedImage(width, height(size.getWidth(), size.getHeight(), + width), BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + graphics.setRenderingHint(RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_QUALITY); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, image.getWidth(), image.getHeight()); + } finally { + graphics.dispose(); + } + return image; + } + + /** + * The height that keeps the aspect ratio at the rendering's width. + * + * @throws IOException if the ratio asks for an image taller than + * {@link #MAX_HEIGHT} + */ + private static int height(double sourceWidth, double sourceHeight, int width) + throws IOException { + long height = Math.max(1, Math.round(sourceHeight * width / sourceWidth)); + if (height > MAX_HEIGHT) { + throw new IOException("metafile aspect ratio asks for a " + height + + " pixel high rendering at width " + width + ", the maximum is " + MAX_HEIGHT); + } + return (int) height; + } + + private BufferedImage scale(BufferedImage bitmap, int width) throws IOException { + int height = height(bitmap.getWidth(), bitmap.getHeight(), width); + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, width, height); + graphics.drawImage(bitmap, 0, 0, width, height, null); + } finally { + graphics.dispose(); + } + return image; + } + + private Path write(BufferedImage image, int id) throws IOException { + Path tmpFile = Files.createTempFile("tika-metafile-rendering-", + "-" + id + "." + imageFormatName); + try (OutputStream os = Files.newOutputStream(tmpFile)) { + if (!ImageIO.write(image, imageFormatName, os)) { + throw new IOException("no ImageIO writer for " + imageFormatName); + } + } catch (IOException | RuntimeException e) { + Files.deleteIfExists(tmpFile); + throw e; + } + return tmpFile; + } + + public int getWidth() { + return width; + } + + /** + * @param width the rendering's width in pixels, 1 to 10000; the height + * follows the image's aspect ratio. Default 800. + */ + public void setWidth(int width) { + if (width < 1 || width > MAX_WIDTH) { + throw new IllegalArgumentException( + "width must be between 1 and " + MAX_WIDTH + ", got: " + width); + } + this.width = width; + } + + public String getImageFormatName() { + return imageFormatName; + } + + /** + * @param imageFormatName an ImageIO format name, "png" (default) or "jpeg" + */ + public void setImageFormatName(String imageFormatName) { + this.imageFormatName = imageFormatName; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/EMFParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/EMFParserTest.java index e296c5ef2df..60bb779a214 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/EMFParserTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/EMFParserTest.java @@ -17,15 +17,31 @@ package org.apache.tika.parser.microsoft; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.xml.sax.ContentHandler; import org.apache.tika.TikaTest; +import org.apache.tika.config.loader.TikaLoader; +import org.apache.tika.extractor.EmbeddedDocumentExtractor; +import org.apache.tika.io.TikaInputStream; import org.apache.tika.metadata.HttpHeaders; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Rendering; import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.parser.Parser; +import org.apache.tika.sax.BodyContentHandler; public class EMFParserTest extends TikaTest { @@ -54,6 +70,151 @@ public void testIconOnly() throws Exception { metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT)); } + /** + * Rendering is off by default: an EMF yields no embedded document of + * its own. + */ + @Test + public void testNoRenderingByDefault() throws Exception { + List metadataList = getRecursiveMetadata("testEMF.emf"); + assertEquals(1, metadataList.size()); + } + + @Test + public void testRenderingFromConfig() throws Exception { + Parser parser = TikaLoader + .load(getConfigPath(EMFParserTest.class, "tika-config-emf-render.json")) + .loadParsers(); + Metadata metadata = new Metadata(); + metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, "testEMF.emf"); + metadata.set(HttpHeaders.CONTENT_TYPE, "image/emf"); + List metadataList = + getRecursiveMetadata("testEMF.emf", parser, metadata, new ParseContext(), false); + assertEquals(2, metadataList.size()); + assertRendering(metadataList.get(1), "testEMF.png", + TikaCoreProperties.EmbeddedResourceType.RENDERING); + } + + /** + * The per-request form: the parser config is supplied through the + * ParseContext, as tika-server does for a multipart config part. + */ + @Test + public void testRenderingFromParseContext() throws Exception { + ParseContext context = new ParseContext(); + context.setJsonConfig("emf-parser", "{\"renderImage\": true}"); + List metadataList = getRecursiveMetadata("testEMF.emf", context); + assertEquals(2, metadataList.size()); + assertRendering(metadataList.get(1), "testEMF.png", + TikaCoreProperties.EmbeddedResourceType.RENDERING); + assertEquals("1", metadataList.get(1).get(TikaCoreProperties.EMBEDDED_DEPTH)); + } + + /** + * The docProps thumbnail of a Word document is an EMF; with rendering on + * its PNG rendering follows it, one level deeper. + */ + @Test + public void testDocxThumbnailRendering() throws Exception { + ParseContext context = new ParseContext(); + context.setJsonConfig("emf-parser", "{\"renderImage\": true, \"renderWidth\": 200}"); + List metadataList = getRecursiveMetadata("testDOCX_Thumbnail.docx", context); + //the document, its thumbnail, the WMF picture inside the thumbnail's + //EMF and the thumbnail's rendering + assertEquals(4, metadataList.size()); + Metadata thumbnail = byName(metadataList, "thumbnail.emf"); + assertEquals("image/emf", thumbnail.get(HttpHeaders.CONTENT_TYPE)); + assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name(), + thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)); + assertEquals("1", thumbnail.get(TikaCoreProperties.EMBEDDED_DEPTH)); + Metadata rendering = byName(metadataList, "thumbnail.png"); + assertRendering(rendering, "thumbnail.png", + TikaCoreProperties.EmbeddedResourceType.THUMBNAIL); + assertEquals("2", rendering.get(TikaCoreProperties.EMBEDDED_DEPTH)); + assertEquals("/thumbnail.emf/thumbnail.png", + rendering.get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH)); + } + + /** + * Restricted to THUMBNAIL embedded documents, the parser renders the + * document's thumbnail but not a picture that is merely embedded. + */ + @Test + public void testRenderOnlyThumbnails() throws Exception { + ParseContext context = new ParseContext(); + context.setJsonConfig("emf-parser", + "{\"renderImage\": true, \"renderOnlyEmbeddedResourceTypes\": [\"THUMBNAIL\"]}"); + List metadataList = getRecursiveMetadata("testDOCX_Thumbnail.docx", context); + assertRendering(byName(metadataList, "thumbnail.png"), "thumbnail.png", + TikaCoreProperties.EmbeddedResourceType.THUMBNAIL); + + //a bare EMF is the document itself, not a THUMBNAIL: no rendering + metadataList = getRecursiveMetadata("testEMF.emf", context); + assertEquals(1, metadataList.size()); + } + + /** + * The configured width reaches the renderer the parser was handed, which + * is the SPI-injected one in a default setup, not the parser's own + * instance. + */ + @ParameterizedTest + @ValueSource(ints = {200, 400}) + public void testRenderWidth(int width) throws Exception { + ParseContext context = new ParseContext(); + context.setJsonConfig("emf-parser", + "{\"renderImage\": true, \"renderWidth\": " + width + "}"); + List renderings = new ArrayList<>(); + context.set(EmbeddedDocumentExtractor.class, collector(renderings)); + try (InputStream is = getResourceAsStream("/test-documents/testEMF.emf")) { + AUTO_DETECT_PARSER.parse(TikaInputStream.get(is), new BodyContentHandler(-1), + new Metadata(), context); + } + assertEquals(1, renderings.size()); + //the PNG header carries the width at offset 16 + assertEquals(width, ByteBuffer.wrap(renderings.get(0), 16, 4).getInt()); + } + + private static EmbeddedDocumentExtractor collector(List renderings) { + return new EmbeddedDocumentExtractor() { + @Override + public boolean shouldParseEmbedded(Metadata metadata, ParseContext parseContext) { + return true; + } + + @Override + public void parseEmbedded(TikaInputStream stream, ContentHandler handler, + Metadata metadata, ParseContext parseContext, + boolean outputHtml) throws IOException { + renderings.add(stream.readAllBytes()); + } + }; + } + + private static Metadata byName(List metadataList, String name) { + for (Metadata m : metadataList) { + if (name.equals(m.get(TikaCoreProperties.RESOURCE_NAME_KEY))) { + return m; + } + } + throw new AssertionError("no embedded document named " + name); + } + + /** + * There is no image parser on this module's test classpath, so the PNG + * is checked by its type, name and size rather than its dimensions. + * The rendering of a THUMBNAIL is a THUMBNAIL, any other a RENDERING. + */ + private static void assertRendering(Metadata rendering, String name, + TikaCoreProperties.EmbeddedResourceType type) { + assertEquals("image/png", rendering.get(HttpHeaders.CONTENT_TYPE)); + assertEquals(type.name(), rendering.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)); + assertEquals(name, rendering.get(TikaCoreProperties.RESOURCE_NAME_KEY)); + assertEquals("poi-metafile-renderer", rendering.get(Rendering.RENDERED_BY)); + assertTrue(Long.parseLong(rendering.get(HttpHeaders.CONTENT_LENGTH)) > 100); + assertNull(rendering.get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM)); + } + @Test public void testMissingCoords() throws Exception { //TIKA-4432 diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/OLE2ThumbnailTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/OLE2ThumbnailTest.java new file mode 100644 index 00000000000..c91ff74dcc2 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/OLE2ThumbnailTest.java @@ -0,0 +1,146 @@ +/* + * 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.microsoft; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.apache.tika.TikaTest; +import org.apache.tika.metadata.HttpHeaders; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.parser.ParseContext; + +/** + * The thumbnail that Office stores in the SummaryInformation of the OLE2 + * formats (PIDSI_THUMBNAIL, a WMF) is emitted as a THUMBNAIL embedded + * document, as the docProps thumbnail of the OOXML formats is. + */ +public class OLE2ThumbnailTest extends TikaTest { + + @Test + public void testPptThumbnail() throws Exception { + List metadataList = getRecursiveMetadata("testPPT_various.ppt"); + Metadata thumbnail = byTypeAndContentType(metadataList, + TikaCoreProperties.EmbeddedResourceType.THUMBNAIL, "image/wmf"); + assertNotNull(thumbnail); + assertEquals("image/wmf", thumbnail.get(HttpHeaders.CONTENT_TYPE)); + assertEquals("thumbnail.wmf", thumbnail.get(TikaCoreProperties.RESOURCE_NAME_KEY)); + assertEquals("1", thumbnail.get(TikaCoreProperties.EMBEDDED_DEPTH)); + //exactly one document thumbnail + assertEquals(1, count(metadataList, TikaCoreProperties.EmbeddedResourceType.THUMBNAIL)); + } + + @Test + public void testPptThumbnailRendering() throws Exception { + ParseContext context = new ParseContext(); + context.setJsonConfig("wmf-parser", "{\"renderImage\": true, \"renderWidth\": 400}"); + List metadataList = getRecursiveMetadata("testPPT_various.ppt", context); + //the rendering of the thumbnail is a THUMBNAIL as well + Metadata rendering = byTypeAndContentType(metadataList, + TikaCoreProperties.EmbeddedResourceType.THUMBNAIL, "image/png"); + assertNotNull(rendering); + assertEquals("thumbnail.png", rendering.get(TikaCoreProperties.RESOURCE_NAME_KEY)); + assertEquals("2", rendering.get(TikaCoreProperties.EMBEDDED_DEPTH)); + assertTrue(Long.parseLong(rendering.get(HttpHeaders.CONTENT_LENGTH)) > 100); + } + + /** + * Word wraps its thumbnail bitmap in a WMF with a window extent and a + * single dibStretchBlt record, which has no bounds POI can compute; the + * renderer falls back to the bitmap. + */ + @Test + public void testDocThumbnailRendering() throws Exception { + ParseContext context = new ParseContext(); + context.setJsonConfig("wmf-parser", "{\"renderImage\": true}"); + List metadataList = getRecursiveMetadata("testControlCharacters.doc", context); + Metadata thumbnail = byTypeAndContentType(metadataList, + TikaCoreProperties.EmbeddedResourceType.THUMBNAIL, "image/wmf"); + assertNotNull(thumbnail); + assertEquals("image/wmf", thumbnail.get(HttpHeaders.CONTENT_TYPE)); + Metadata rendering = byTypeAndContentType(metadataList, + TikaCoreProperties.EmbeddedResourceType.THUMBNAIL, "image/png"); + assertNotNull(rendering); + assertNull(thumbnail.get(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM)); + } + + /** + * A thumbnail that is not a metafile picture is left alone. + */ + @Test + public void testUnusableThumbnailIsSkipped() throws Exception { + List metadataList = getRecursiveMetadata("testEXCEL_embeddedPDF_mac.xls"); + assertEquals(0, count(metadataList, TikaCoreProperties.EmbeddedResourceType.THUMBNAIL)); + } + + /** + * The thumbnail can be switched off for callers that do not want the + * extra embedded document. + */ + @Test + public void testThumbnailCanBeSwitchedOff() throws Exception { + OfficeParserConfig config = new OfficeParserConfig(); + config.setExtractThumbnail(false); + ParseContext context = new ParseContext(); + context.set(OfficeParserConfig.class, config); + List metadataList = getRecursiveMetadata("testPPT_various.ppt", context); + assertEquals(0, count(metadataList, TikaCoreProperties.EmbeddedResourceType.THUMBNAIL)); + } + + /** + * A misspelt resource type would silently disable rendering. + */ + @Test + public void testRenderOnlyTypesAreValidated() { + MetafileParserConfig config = new MetafileParserConfig(); + assertThrows(IllegalArgumentException.class, + () -> config.setRenderOnlyEmbeddedResourceTypes( + Collections.singleton("THUMBNAILS"))); + } + + private static Metadata byTypeAndContentType(List metadataList, + TikaCoreProperties.EmbeddedResourceType type, + String contentType) { + for (Metadata m : metadataList) { + if (type.name().equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)) + && contentType.equals(m.get(HttpHeaders.CONTENT_TYPE))) { + return m; + } + } + return null; + } + + private static int count(List metadataList, + TikaCoreProperties.EmbeddedResourceType type) { + int n = 0; + for (Metadata m : metadataList) { + if (type.name().equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE))) { + n++; + } + } + return n; + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/PowerPointParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/PowerPointParserTest.java index d2ec1f4718a..87e6c4b2053 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/PowerPointParserTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/PowerPointParserTest.java @@ -331,7 +331,8 @@ public void testEncrypted() throws Exception { @Test public void testGroups() throws Exception { List metadataList = getRecursiveMetadata("testPPT_groups.ppt"); - assertEquals(3, metadataList.size()); + //two pictures and the SummaryInformation thumbnail + assertEquals(4, metadataList.size()); String content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT); //this tests that we're ignoring text shapes at depth=0 //i.e. POI has already included them in the slide's getTextParagraphs() @@ -379,7 +380,8 @@ public void testHyperlinksInTextBoxes() throws Exception { @Test public void testEmbeddedXLSInOLEObject() throws Exception { List metadataList = getRecursiveMetadata("testPPT_oleWorkbook.ppt"); - assertEquals(3, metadataList.size()); + //the workbook, its picture and the SummaryInformation thumbnail + assertEquals(4, metadataList.size()); Metadata xlsx = metadataList.get(1); assertContains("

Sheet1

", xlsx.get(TikaCoreProperties.TIKA_CONTENT)); assertContains("1", xlsx.get(TikaCoreProperties.TIKA_CONTENT)); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WMFParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WMFParserTest.java index 3317c0bae03..5aca34707cb 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WMFParserTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/WMFParserTest.java @@ -17,6 +17,8 @@ package org.apache.tika.parser.microsoft; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -25,7 +27,9 @@ import org.apache.tika.TikaTest; import org.apache.tika.metadata.HttpHeaders; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Rendering; import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.parser.ParseContext; public class WMFParserTest extends TikaTest { @@ -40,6 +44,58 @@ public void testTextExtractionShiftJISencoding() throws Exception { testTextExtraction("testWMF_charset.wmf", 0, "普林斯"); } + /** + * Rendering is off by default; with "wmf-parser": {"renderImage": true} + * the rendering follows the image as a RENDERING embedded document. + */ + @Test + public void testRendering() throws Exception { + assertEquals(1, getRecursiveMetadata("testWMF.wmf").size()); + + ParseContext context = new ParseContext(); + context.setJsonConfig("wmf-parser", "{\"renderImage\": true, \"renderWidth\": 300}"); + List metadataList = getRecursiveMetadata("testWMF.wmf", context); + assertEquals(2, metadataList.size()); + Metadata rendering = metadataList.get(1); + assertEquals("image/png", rendering.get(HttpHeaders.CONTENT_TYPE)); + assertEquals(TikaCoreProperties.EmbeddedResourceType.RENDERING.name(), + rendering.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)); + assertEquals("testWMF.png", rendering.get(TikaCoreProperties.RESOURCE_NAME_KEY)); + assertEquals("poi-metafile-renderer", rendering.get(Rendering.RENDERED_BY)); + assertTrue(Long.parseLong(rendering.get(HttpHeaders.CONTENT_LENGTH)) > 100); + } + + /** + * The docProps thumbnail of this workbook is a WMF; with rendering on its + * PNG rendering follows it, one level deeper, as a THUMBNAIL as well. + */ + @Test + public void testXlsxThumbnailRendering() throws Exception { + ParseContext context = new ParseContext(); + context.setJsonConfig("wmf-parser", "{\"renderImage\": true}"); + List metadataList = getRecursiveMetadata("testXLSX_Thumbnail.xlsx", context); + Metadata thumbnail = null; + Metadata rendering = null; + for (Metadata m : metadataList) { + if (!TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.name() + .equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE))) { + continue; + } + if ("image/wmf".equals(m.get(HttpHeaders.CONTENT_TYPE))) { + thumbnail = m; + } else { + rendering = m; + } + } + assertNotNull(thumbnail); + assertEquals("image/wmf", thumbnail.get(HttpHeaders.CONTENT_TYPE)); + assertEquals("1", thumbnail.get(TikaCoreProperties.EMBEDDED_DEPTH)); + assertNotNull(rendering); + assertEquals("image/png", rendering.get(HttpHeaders.CONTENT_TYPE)); + assertEquals("2", rendering.get(TikaCoreProperties.EMBEDDED_DEPTH)); + assertEquals("thumbnail.png", rendering.get(TikaCoreProperties.RESOURCE_NAME_KEY)); + } + private void testTextExtraction(String fileName, int metaDataItemIndex, String expectedText) throws Exception { List metadataList = getRecursiveMetadata(fileName); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/configs/tika-config-emf-render.json b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/configs/tika-config-emf-render.json new file mode 100644 index 00000000000..a5f99414689 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/resources/configs/tika-config-emf-render.json @@ -0,0 +1,11 @@ +{ + "parsers": [ + "default-parser", + { + "emf-parser": { + "renderImage": true, + "renderWidth": 400 + } + } + ] +}