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));
+ }
+}