This is an automated email from the ASF dual-hosted git repository.

THausherr pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new f1bf714243 TIKA-4854: Emit the iWork '09 and '18 preview as a 
THUMBNAIL embedded document (#3094)
f1bf714243 is described below

commit f1bf7142434a12cb49e6395e4317dda0041f019a
Author: Dominik Schmidt <[email protected]>
AuthorDate: Sun Aug 30 20:01:59 2026 +0200

    TIKA-4854: Emit the iWork '09 and '18 preview as a THUMBNAIL embedded 
document (#3094)
    
    * TIKA-4854 - emit the iWork '09 and '18 preview as a THUMBNAIL embedded 
document
    
    iWork '13 already emits preview.jpg as the THUMBNAIL. The '09 packages
    carry QuickLook/Thumbnail.jpg; as they are read as a stream the preview is
    held back until the content is written, then emitted. The '18 parser only
    detected the type so far; it now emits the package's preview.jpg as well.
    
    * TIKA-4854 - start the document once, test with a well-formed JPEG preview
    
    * TIKA-4854 - buffer the preview only when the embedded document extractor 
wants it
    
    * TIKA-4854 - skip an oversized thumbnail instead of emitting it truncated
    
    * TIKA-4854 - close the ZipFile the parser opens with the stream
    
    * TIKA-4854 - tests: assert the JPEG write succeeded, check the Numbers 
thumbnail's name and type
    
    ---------
    
    Co-authored-by: Tilman Hausherr <[email protected]>
---
 CHANGES.txt                                        |   4 +
 .../tika/parser/iwork/IWorkPackageParser.java      |  72 +++++++++++++-
 .../parser/iwork/iwana/IWork18PackageParser.java   |  58 +++++++++++-
 .../apache/tika/parser/iwork/IWorkParserTest.java  |  42 +++++++++
 .../tika/parser/iwork/iwana/IWork18ParserTest.java | 104 +++++++++++++++++++++
 5 files changed, 272 insertions(+), 8 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index b8eb0cc611..43f9e90c7f 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,9 @@
 Release 4.1.0 - unreleased
 
+   * The preview image of iWork '09 packages (QuickLook/Thumbnail.jpg) and of
+     iWork '18 packages (preview.jpg) is emitted as a THUMBNAIL embedded
+     document, as it already was for iWork '13 (TIKA-4854).
+
    * Raw camera formats are detected by content: RawTiffDetector tells
      Nikon NEF/NRW, Pentax PEF/PTX, Sony ARW/SRF/SR2, Samsung SRW and Adobe
      DNG from a plain TIFF by their image directory (DNGVersion, the vendor
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/IWorkPackageParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/IWorkPackageParser.java
index 5a25863eb1..7df30e5f37 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/IWorkPackageParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/IWorkPackageParser.java
@@ -30,6 +30,7 @@ import 
org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
 import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
 import org.apache.commons.compress.archivers.zip.ZipFile;
 import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.BoundedInputStream;
 import org.apache.commons.io.input.CloseShieldInputStream;
 import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream;
 import org.xml.sax.ContentHandler;
@@ -38,9 +39,12 @@ import org.xml.sax.SAXException;
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.detect.XmlRootExtractor;
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
 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.parser.Parser;
@@ -87,12 +91,48 @@ public class IWorkPackageParser implements Parser {
         return supportedTypes;
     }
 
+    /**
+     * The document preview of an iWork '09 package.
+     */
+    public final static String IWORK_THUMBNAIL_ENTRY = 
"QuickLook/Thumbnail.jpg";
+
+    /**
+     * Bound on the preview held in memory until the content has been
+     * parsed; a real one is well under a megabyte.
+     */
+    private static final long MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024;
+
     public void parse(TikaInputStream tis, ContentHandler handler, Metadata 
metadata,
                       ParseContext context) throws IOException, SAXException, 
TikaException {
         ZipArchiveInputStream zip = new ZipArchiveInputStream(tis);
         ZipArchiveEntry entry = zip.getNextEntry();
+        //the package is read as a stream, so the preview may come before the
+        //content: hold it back and emit it once the content is written, and
+        //only when the embedded document extractor wants it at all
+        EmbeddedDocumentExtractor extractor =
+                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+        Metadata thumbnailMetadata = null;
+        byte[] thumbnail = null;
+        XHTMLContentHandler xhtml = null;
 
         while (entry != null) {
+            if (IWORK_THUMBNAIL_ENTRY.equals(entry.getName()) && 
zip.canReadEntryData(entry)) {
+                thumbnailMetadata = thumbnailMetadata(context);
+                if (extractor.shouldParseEmbedded(thumbnailMetadata, context)) 
{
+                    //read one byte past the limit so an oversized entry is
+                    //recognized and skipped instead of emitted truncated
+                    thumbnail = 
BoundedInputStream.builder().setInputStream(zip)
+                            .setMaxCount(MAX_THUMBNAIL_BYTES + 
1).get().readAllBytes();
+                    if (thumbnail.length > MAX_THUMBNAIL_BYTES) {
+                        thumbnail = null;
+                        
metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                                IWORK_THUMBNAIL_ENTRY + " exceeds " + 
MAX_THUMBNAIL_BYTES
+                                        + " bytes and was skipped");
+                    }
+                }
+                entry = zip.getNextEntry();
+                continue;
+            }
             if (!IWORK_CONTENT_ENTRIES.contains(entry.getName())) {
                 entry = zip.getNextEntry();
                 continue;
@@ -104,7 +144,12 @@ public class IWorkPackageParser implements Parser {
             entryStream.reset(); // 4096 fails on github
 
             if (type != null) {
-                XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, 
metadata, context);
+                if (xhtml == null) {
+                    //a package carries one content entry; guard against a
+                    //crafted one with several so the document is started once
+                    xhtml = new XHTMLContentHandler(handler, metadata, 
context);
+                    xhtml.startDocument();
+                }
                 ContentHandler contentHandler;
 
                 switch (type) {
@@ -126,19 +171,40 @@ public class IWorkPackageParser implements Parser {
                 }
 
                 metadata.set(HttpHeaders.CONTENT_TYPE, 
type.getType().toString());
-                xhtml.startDocument();
                 if (contentHandler != null) {
                     
XMLReaderUtils.parseSAX(CloseShieldInputStream.wrap(entryStream),
                             contentHandler, context);
                 }
-                xhtml.endDocument();
             }
 
             entry = zip.getNextEntry();
         }
+        if (xhtml != null) {
+            if (thumbnail != null) {
+                try (TikaInputStream thumbnailStream = 
TikaInputStream.get(thumbnail)) {
+                    extractor.parseEmbedded(thumbnailStream, xhtml, 
thumbnailMetadata, context,
+                            true);
+                }
+            }
+            xhtml.endDocument();
+        }
         // Don't close the zip InputStream (TIKA-1117).
     }
 
+    /**
+     * The metadata of the document preview, a
+     * {@link TikaCoreProperties.EmbeddedResourceType#THUMBNAIL} embedded
+     * document.
+     */
+    private static Metadata thumbnailMetadata(ParseContext context) {
+        Metadata embeddedMetadata = Metadata.newInstance(context);
+        embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString());
+        embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, 
IWORK_THUMBNAIL_ENTRY);
+        embeddedMetadata.set(HttpHeaders.CONTENT_TYPE, "image/jpeg");
+        return embeddedMetadata;
+    }
+
     private IWORKDocumentType detectType(InputStream entryStream, int 
markLimit) throws IOException {
         byte[] bytes = new byte[markLimit];
         try {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork18PackageParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork18PackageParser.java
index 69520f14a3..a98566855d 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork18PackageParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/iwana/IWork18PackageParser.java
@@ -25,22 +25,28 @@ import java.util.Set;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
 import org.apache.commons.compress.archivers.zip.ZipFile;
+import org.apache.commons.io.input.CloseShieldInputStream;
 import org.xml.sax.ContentHandler;
 import org.xml.sax.SAXException;
 
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
 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.parser.Parser;
+import org.apache.tika.sax.XHTMLContentHandler;
 
 /**
- * For now, this parser isn't even registered.  It contains
- * code that will detect the newer 2018 .keynote, .numbers, .pages files.
+ * Detects the newer 2018 .keynote, .numbers, .pages files and emits their
+ * preview image as a thumbnail; the content itself is not parsed yet.
  */
 @TikaComponent
 public class IWork18PackageParser implements Parser {
@@ -67,19 +73,30 @@ public class IWork18PackageParser implements Parser {
             zipFile = (ZipFile) container;
         } else if (tis.hasFile()) {
             zipFile = ZipFile.builder().setFile(tis.getFile()).get();
+            //closed with the stream, as the zip container detector does it
+            tis.setOpenContainer(zipFile);
         } else {
             zipStream = new ZipInputStream(tis);
         }
 
-        // For now, just detect
+        // Detect the type, and emit the document preview as the thumbnail;
+        // the content itself is not parsed yet
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, 
context);
+        xhtml.startDocument();
         MediaType type = null;
         if (zipFile != null) {
-            Enumeration<? extends ZipEntry> entries = zipFile.getEntries();
+            Enumeration<? extends ZipArchiveEntry> entries = 
zipFile.getEntries();
             while (entries.hasMoreElements()) {
-                ZipEntry entry = entries.nextElement();
+                ZipArchiveEntry entry = entries.nextElement();
                 if (type == null) {
                     type = IWork18DocumentType.detectIfPossible(entry);
                 }
+                if (isPreview(entry) && zipFile.canReadEntryData(entry)) {
+                    try (TikaInputStream previewStream =
+                                 
TikaInputStream.get(zipFile.getInputStream(entry))) {
+                        handleThumbnail(entry, previewStream, xhtml, context);
+                    }
+                }
             }
         } else {
             ZipEntry entry = zipStream.getNextEntry();
@@ -87,12 +104,43 @@ public class IWork18PackageParser implements Parser {
                 if (type == null) {
                     type = IWork18DocumentType.detectIfPossible(entry);
                 }
+                if (isPreview(entry)) {
+                    try (TikaInputStream previewStream =
+                                 
TikaInputStream.get(CloseShieldInputStream.wrap(zipStream))) {
+                        handleThumbnail(entry, previewStream, xhtml, context);
+                    }
+                }
                 entry = zipStream.getNextEntry();
             }
         }
         if (type != null) {
             metadata.set(HttpHeaders.CONTENT_TYPE, type.toString());
         }
+        xhtml.endDocument();
+    }
+
+    /**
+     * The document preview, {@code preview.jpg} inside the package's
+     * document directory (e.g. {@code Presentation.key/preview.jpg}).
+     */
+    private static boolean isPreview(ZipEntry entry) {
+        String name = entry.getName();
+        return name.equals("preview.jpg") || name.endsWith("/preview.jpg");
+    }
+
+    private static void handleThumbnail(ZipEntry entry, TikaInputStream 
previewStream,
+                                        XHTMLContentHandler xhtml, 
ParseContext context)
+            throws IOException, SAXException {
+        EmbeddedDocumentExtractor extractor =
+                EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
+        Metadata embeddedMetadata = Metadata.newInstance(context);
+        embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+                TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString());
+        embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, 
entry.getName());
+        embeddedMetadata.set(HttpHeaders.CONTENT_TYPE, "image/jpeg");
+        if (extractor.shouldParseEmbedded(embeddedMetadata, context)) {
+            extractor.parseEmbedded(previewStream, xhtml, embeddedMetadata, 
context, true);
+        }
     }
 
     public enum IWork18DocumentType {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/iwork/IWorkParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/iwork/IWorkParserTest.java
index d84e2f5658..305626b04b 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/iwork/IWorkParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/iwork/IWorkParserTest.java
@@ -55,6 +55,48 @@ public class IWorkParserTest extends TikaTest {
         iWorkParser = new IWorkPackageParser();
     }
 
+    /**
+     * The QuickLook preview of a Pages '09 package is the document's
+     * THUMBNAIL embedded document, emitted after the content.
+     */
+    @Test
+    public void testPagesThumbnail() throws Exception {
+        List<Metadata> metadataList = getRecursiveMetadata("testPages.pages");
+        assertEquals(2, metadataList.size());
+        assertEquals("application/vnd.apple.pages",
+                metadataList.get(0).get(HttpHeaders.CONTENT_TYPE));
+        Metadata thumbnail = metadataList.get(1);
+        assertEquals("QuickLook/Thumbnail.jpg", 
thumbnail.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("image/jpeg", thumbnail.get(HttpHeaders.CONTENT_TYPE));
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    @Test
+    public void testNumbersThumbnail() throws Exception {
+        List<Metadata> metadataList = 
getRecursiveMetadata("testNumbers.numbers");
+        assertEquals(2, metadataList.size());
+        Metadata thumbnail = metadataList.get(1);
+        assertEquals("QuickLook/Thumbnail.jpg", 
thumbnail.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        assertEquals("image/jpeg", thumbnail.get(HttpHeaders.CONTENT_TYPE));
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    /**
+     * Keynote '09 also carries per-slide thumbnails under thumbs/; only the
+     * QuickLook document preview is the thumbnail
+     */
+    @Test
+    public void testKeynoteThumbnail() throws Exception {
+        List<Metadata> metadataList = getRecursiveMetadata("testKeynote.key");
+        assertEquals(2, metadataList.size());
+        assertEquals("QuickLook/Thumbnail.jpg",
+                metadataList.get(1).get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                
metadataList.get(1).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
     /**
      * Check the given InputStream is not closed by the Parser (TIKA-1117).
      *
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/iwork/iwana/IWork18ParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/iwork/iwana/IWork18ParserTest.java
new file mode 100644
index 0000000000..540b5eb4dc
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/test/java/org/apache/tika/parser/iwork/iwana/IWork18ParserTest.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.iwork.iwana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+import javax.imageio.ImageIO;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.TikaTest;
+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.parser.ParseContext;
+
+public class IWork18ParserTest extends TikaTest {
+
+    /**
+     * The package's preview.jpg is the document's THUMBNAIL embedded
+     * document. The test fixture was saved without one, so a preview is
+     * added to a copy of it.
+     */
+    @Test
+    public void testPreviewIsTheThumbnail() throws Exception {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        try (InputStream is = 
getResourceAsStream("/test-documents/testKeynote2018.key");
+             ZipInputStream in = new ZipInputStream(is);
+             ZipOutputStream out = new ZipOutputStream(bos)) {
+            ZipEntry entry;
+            while ((entry = in.getNextEntry()) != null) {
+                out.putNextEntry(new ZipEntry(entry.getName()));
+                in.transferTo(out);
+                out.closeEntry();
+            }
+            out.putNextEntry(new ZipEntry("Presentation.key/preview.jpg"));
+            out.write(jpeg());
+            out.closeEntry();
+        }
+        List<Metadata> metadataList;
+        try (TikaInputStream tis = TikaInputStream.get(bos.toByteArray())) {
+            metadataList = getRecursiveMetadata(tis, new 
IWork18PackageParser(), new Metadata(),
+                    new ParseContext(), false);
+        }
+        assertEquals(2, metadataList.size());
+        assertEquals("application/vnd.apple.keynote.18",
+                metadataList.get(0).get(HttpHeaders.CONTENT_TYPE));
+        Metadata thumbnail = metadataList.get(1);
+        assertEquals("Presentation.key/preview.jpg",
+                thumbnail.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+        
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+                thumbnail.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+    }
+
+    /**
+     * A small but well-formed JPEG, so the preview parses like a real one.
+     */
+    private static byte[] jpeg() throws Exception {
+        BufferedImage image = new BufferedImage(8, 8, 
BufferedImage.TYPE_INT_RGB);
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        assertTrue(ImageIO.write(image, "jpeg", bos), "no JPEG writer 
available");
+        return bos.toByteArray();
+    }
+
+    /**
+     * Without a preview the parser still detects the type and emits no
+     * embedded document.
+     */
+    @Test
+    public void testNoPreview() throws Exception {
+        List<Metadata> metadataList;
+        try (TikaInputStream tis = TikaInputStream.get(
+                getResourceAsStream("/test-documents/testKeynote2018.key"))) {
+            metadataList = getRecursiveMetadata(tis, new 
IWork18PackageParser(), new Metadata(),
+                    new ParseContext(), false);
+        }
+        assertEquals(1, metadataList.size());
+        assertEquals("application/vnd.apple.keynote.18",
+                metadataList.get(0).get(HttpHeaders.CONTENT_TYPE));
+    }
+}

Reply via email to