This is an automated email from the ASF dual-hosted git repository.
tballison 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 9e9ed9eb3c TIKA-5032: Emit the stored preview of zip-based formats as
THUMBNAIL (#3530)
9e9ed9eb3c is described below
commit 9e9ed9eb3c8f0fc7f87a747835627e05cd8b52ba
Author: Dominik Schmidt <[email protected]>
AuthorDate: Thu Nov 5 15:27:39 2026 +0100
TIKA-5032: Emit the stored preview of zip-based formats as THUMBNAIL (#3530)
* TIKA-5032: emit the first page of a comic book archive as THUMBNAIL
* TIKA-5032: emit the stored preview of Krita, OpenRaster, FreeCAD and
Procreate files
* TIKA-5032: name every preview type inline in the table
* TIKA-5032: emit the stored preview of Sketch files
* TIKA-5032: list cbz, Krita and other zip-based formats among the
thumbnail sources
* TIKA-5032: mark only the first preview entry on duplicate names; add
x-cbz alias
---
CHANGES.txt | 8 ++
docs/modules/ROOT/pages/formats.adoc | 4 +
.../ROOT/pages/using-tika/server/index.adoc | 9 +-
.../org/apache/tika/mime/tika-mimetypes.xml | 44 ++++++++
.../org/apache/tika/parser/pkg/ZipParserTest.java | 117 +++++++++++++++++++++
.../java/org/apache/tika/parser/pkg/ZipParser.java | 20 +++-
.../org/apache/tika/parser/pkg/ZipPreview.java | 105 ++++++++++++++++++
7 files changed, 300 insertions(+), 7 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index e3ce13d60c..afe22f053a 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -66,6 +66,14 @@ Release 4.3.0 - ???
EXTRACTION AND FORMATS
+ * Zip-based formats that carry a preview image have it emitted as the
THUMBNAIL, so
+ the thumbnail preset serves it: comic book archives (.cbz, new type
+ application/vnd.comicbook+zip, the first page in name order), Krita (.kra,
+ preview.png), OpenRaster (.ora, Thumbnails/thumbnail.png), FreeCAD
(.FCStd,
+ thumbnails/Thumbnail.png), Procreate (.procreate,
QuickLook/Thumbnail.png) and
+ Sketch (.sketch, previews/preview.png). All six are new types and
sub-types of
+ application/zip (TIKA-5032).
+
* PSD files emit the JPEG thumbnail Photoshop stores in image resource
0x040C as a
THUMBNAIL embedded resource, so the thumbnail preset serves it; one over
maxDataLengthBytes is skipped with a warning (TIKA-5033).
diff --git a/docs/modules/ROOT/pages/formats.adoc
b/docs/modules/ROOT/pages/formats.adoc
index 2a6c0d6fe7..b1d58f000a 100644
--- a/docs/modules/ROOT/pages/formats.adoc
+++ b/docs/modules/ROOT/pages/formats.adoc
@@ -119,6 +119,10 @@
link:{api}/org/apache/tika/parser/epub/EpubParser.html[EpubParser] supports the
EPUB format used for many digital books.
link:{api}/org/apache/tika/parser/xml/FictionBookParser.html[FictionBookParser]
supports the XML-based FictionBook (`.fb2`) publishing format.
+Comic book archives (`.cbz`) are read by the zip parser, which emits the
+first page in name order as the cover thumbnail. The zip parser also emits the
+stored preview of Krita (`.kra`), OpenRaster (`.ora`), FreeCAD (`.FCStd`),
+Procreate (`.procreate`) and Sketch (`.sketch`) files as their thumbnail.
== Rich Text Format
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index fe41bc690e..9fd9584443 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -201,10 +201,11 @@ renders, so the presets do not make them. Both name the
standard parsers, so the
`/unpack/preset/thumbnail` — "just give me the thumbnail". The zip holds at
most one raster
image per request and nothing else: the stored thumbnail (Office docProps,
audio cover art,
-raw camera previews, epub, PSD, iWork), the rasterized EMF/WMF thumbnail, or
the PDF page; `204`
-when the document has none. An image file is its own thumbnail: it has no
embedded documents,
-so it answers `204` too. Text is read from the first PDF page only, no OCR or
inference runs,
-and embedded documents are parsed two levels deep at most, so the parse is
cheap.
+raw camera previews, epub, PSD, iWork, the cover of a cbz, the preview of
Krita and other
+zip-based formats), the rasterized EMF/WMF thumbnail, or the PDF page; `204`
when the document
+has none. An image file is its own thumbnail: it has no embedded documents, so
it answers `204`
+too. Text is read from the first PDF page only, no OCR or inference runs, and
embedded documents
+are parsed two levels deep at most, so the parse is cheap.
`/rmeta/preset/render-thumbnail` — "give me everything, thumbnail included". A
full parse
plus the two renders above, so an index gets the thumbnail's type and
dimensions from the same
diff --git
a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
index d271b21db9..e496a2c04f 100644
--- a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
+++ b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
@@ -1342,6 +1342,15 @@
<glob pattern="*.c4p"/>
<glob pattern="*.c4u"/>
</mime-type>
+ <mime-type type="application/vnd.comicbook+zip">
+ <alias type="application/x-cbz"/>
+ <acronym>CBZ</acronym>
+ <_comment>Comic Book Archive</_comment>
+ <tika:link>https://en.wikipedia.org/wiki/Comic_book_archive</tika:link>
+ <sub-class-of type="application/zip"/>
+ <glob pattern="*.cbz"/>
+ </mime-type>
+
<mime-type type="application/vnd.commerce-battelle"/>
<mime-type type="application/vnd.commonspace">
<glob pattern="*.csp"/>
@@ -2286,6 +2295,12 @@
<sub-class-of type="application/x-tika-msoffice"/>
</mime-type>
+ <mime-type type="application/x-procreate">
+ <_comment>Procreate artwork</_comment>
+ <sub-class-of type="application/zip"/>
+ <glob pattern="*.procreate"/>
+ </mime-type>
+
<mime-type type="application/x-project">
<glob pattern="*.mpx"/>
<magic priority="50">
@@ -4099,6 +4114,13 @@
<glob pattern="*.kil"/>
</mime-type>
+ <mime-type type="application/x-krita">
+ <_comment>Krita document</_comment>
+
<tika:link>https://docs.krita.org/en/general_concepts/file_formats/file_kra.html</tika:link>
+ <sub-class-of type="application/zip"/>
+ <glob pattern="*.kra"/>
+ </mime-type>
+
<mime-type type="application/x-object">
<sub-class-of type="application/x-elf"/>
<magic priority="50">
@@ -4113,6 +4135,13 @@
<glob pattern="*.scad"/>
<sub-class-of type="text/plain"/>
</mime-type>
+ <mime-type type="application/x-extension-fcstd">
+ <_comment>FreeCAD document</_comment>
+ <tika:link>https://wiki.freecad.org/File_Format_FCStd</tika:link>
+ <sub-class-of type="application/zip"/>
+ <glob pattern="*.fcstd"/>
+ </mime-type>
+
<mime-type type="application/x-executable">
<sub-class-of type="application/x-elf"/>
<magic priority="50">
@@ -5246,6 +5275,13 @@
<sub-class-of type="text/plain"/>
</mime-type>
+ <mime-type type="application/x-sketch">
+ <_comment>Sketch document</_comment>
+ <tika:link>https://developer.sketch.com/file-format/</tika:link>
+ <sub-class-of type="application/zip"/>
+ <glob pattern="*.sketch"/>
+ </mime-type>
+
<mime-type type="application/x-shar">
<glob pattern="*.shar"/>
</mime-type>
@@ -6793,6 +6829,14 @@
<glob pattern="*.nitf"/>
</mime-type>
+ <mime-type type="image/openraster">
+ <acronym>ORA</acronym>
+ <_comment>OpenRaster image</_comment>
+
<tika:link>https://www.freedesktop.org/wiki/Specifications/OpenRaster/</tika:link>
+ <sub-class-of type="application/zip"/>
+ <glob pattern="*.ora"/>
+ </mime-type>
+
<mime-type type="image/png">
<acronym>PNG</acronym>
<_comment>Portable Network Graphics</_comment>
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
index 7f0f0917a4..4211d59e1b 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/pkg/ZipParserTest.java
@@ -16,15 +16,26 @@
*/
package org.apache.tika.parser.pkg;
+import static java.nio.charset.StandardCharsets.UTF_8;
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.assertTrue;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import javax.imageio.ImageIO;
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.junit.jupiter.api.Test;
import org.xml.sax.ContentHandler;
@@ -155,4 +166,110 @@ public class ZipParserTest extends AbstractPkgTest {
throw new UnsupportedOperationException("should never be called");
}
}
+
+ @Test
+ public void testComicBookCoverIsThumbnail() throws Exception {
+ byte[] png = png();
+ byte[] cbz = zip(new String[]{"002.png", "ComicInfo.xml",
"__MACOSX/._000.png",
+ ".000.png", "001.png"}, png);
+ Map<String, Metadata> entries = entriesByName(cbz, "comic.cbz");
+ assertEquals("application/vnd.comicbook+zip",
+ entries.get("").get(HttpHeaders.CONTENT_TYPE));
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+
entries.get("001.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+
assertNull(entries.get("002.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ @Test
+ public void testFixedPathPreviewIsThumbnail() throws Exception {
+ byte[] png = png();
+ String[][] cases = {
+ {"art.kra", "application/x-krita", "preview.png",
"mergedimage.png"},
+ {"art.ora", "image/openraster", "Thumbnails/thumbnail.png",
"data/layer000.png"},
+ {"part.FCStd", "application/x-extension-fcstd",
"thumbnails/Thumbnail.png",
+ "Document.xml"},
+ {"art.procreate", "application/x-procreate",
"QuickLook/Thumbnail.png",
+ "Signature/Signature.png"},
+ {"design.sketch", "application/x-sketch",
"previews/preview.png",
+ "images/0.png"},
+ };
+ for (String[] c : cases) {
+ Map<String, Metadata> entries = entriesByName(zip(new
String[]{c[3], c[2]}, png), c[0]);
+ assertEquals(c[1], entries.get("").get(HttpHeaders.CONTENT_TYPE),
c[0]);
+
assertEquals(TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString(),
+
entries.get(c[2]).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE), c[0]);
+
assertNull(entries.get(c[3]).get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE),
c[0]);
+ }
+ }
+
+ @Test
+ public void testFixedPathPreviewMissing() throws Exception {
+ Map<String, Metadata> entries =
+ entriesByName(zip(new String[]{"mergedimage.png"}, png()),
"art.kra");
+ assertEquals("application/x-krita",
entries.get("").get(HttpHeaders.CONTENT_TYPE));
+
assertNull(entries.get("mergedimage.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ @Test
+ public void testDuplicatePreviewNameMarkedOnce() throws Exception {
+ byte[] png = png();
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ZipArchiveOutputStream zos = new ZipArchiveOutputStream(bos)) {
+ for (int i = 0; i < 3; i++) {
+ zos.putArchiveEntry(new ZipArchiveEntry("preview.png"));
+ zos.write(png);
+ zos.closeArchiveEntry();
+ }
+ }
+ Metadata metadata = new Metadata();
+ metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, "art.kra");
+ try (TikaInputStream tis = TikaInputStream.get(bos.toByteArray())) {
+ List<Metadata> list = getRecursiveMetadata(tis, metadata, new
ParseContext(), false);
+ assertEquals(4, list.size());
+ long thumbnails = list.stream()
+ .filter(m ->
TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString()
+
.equals(m.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE)))
+ .count();
+ assertEquals(1, thumbnails);
+ }
+ }
+
+ @Test
+ public void testPlainZipHasNoThumbnail() throws Exception {
+ Map<String, Metadata> entries =
+ entriesByName(zip(new String[]{"001.png"}, png()),
"images.zip");
+
assertNull(entries.get("001.png").get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE));
+ }
+
+ private Map<String, Metadata> entriesByName(byte[] zip, String fileName)
throws Exception {
+ Metadata metadata = new Metadata();
+ metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);
+ Map<String, Metadata> byName = new HashMap<>();
+ try (TikaInputStream tis = TikaInputStream.get(zip)) {
+ List<Metadata> list = getRecursiveMetadata(tis, metadata, new
ParseContext(), false);
+ byName.put("", list.get(0));
+ for (Metadata m : list.subList(1, list.size())) {
+ byName.put(m.get(TikaCoreProperties.RESOURCE_NAME_KEY), m);
+ }
+ }
+ return byName;
+ }
+
+ private static byte[] zip(String[] names, byte[] png) throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ZipOutputStream zos = new ZipOutputStream(bos)) {
+ for (String name : names) {
+ zos.putNextEntry(new ZipEntry(name));
+ zos.write(name.endsWith(".xml") ?
"<ComicInfo/>".getBytes(UTF_8) : png);
+ zos.closeEntry();
+ }
+ }
+ return bos.toByteArray();
+ }
+
+ private static byte[] png() throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ ImageIO.write(new BufferedImage(4, 6, BufferedImage.TYPE_INT_RGB),
"png", bos);
+ return bos.toByteArray();
+ }
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
index 87604f5cae..a893078332 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipParser.java
@@ -138,6 +138,7 @@ public class ZipParser extends AbstractArchiveParser {
"application/vnd.android.package-archive",
"application/vnd.apple.iwork",
"application/vnd.apple.keynote",
"application/vnd.apple.numbers",
"application/vnd.apple.pages",
"application/vnd.apple.unknown.13",
+ "application/vnd.comicbook+zip",
"application/vnd.etsi.asic-e+zip",
"application/vnd.etsi.asic-s+zip",
"application/vnd.geogebra.file",
"application/vnd.geogebra.slides",
"application/vnd.geogebra.tool",
@@ -193,7 +194,9 @@ public class ZipParser extends AbstractArchiveParser {
"application/vnd.oasis.opendocument.chart-template",
"application/vnd.oasis.opendocument.text-master",
"application/vnd.adobe.indesign-idml-package",
- "application/x-wacz", "application/x-vnd.datapackage+zip"
+ "application/x-wacz", "application/x-vnd.datapackage+zip",
+ "application/x-extension-fcstd", "application/x-krita",
+ "application/x-procreate", "application/x-sketch",
"image/openraster"
}) {
zipSpecializations.add(MediaType.parse(mediaTypeString));
}
@@ -281,6 +284,7 @@ public class ZipParser extends AbstractArchiveParser {
// Don't close the ZipFile - it was passed from the detector and will
be closed
// when TikaInputStream is closed (it's set as the openContainer)
updateMediaType(zipFile, metadata);
+ String thumbnailEntry = ZipPreview.thumbnailEntry(metadata, zipFile);
EmbeddedDocumentExtractor extractor =
EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
@@ -297,7 +301,13 @@ public class ZipParser extends AbstractArchiveParser {
centralDirectoryEntries.add(entry.getName());
}
if (!entry.isDirectory()) {
- parseZipFileEntry(zipFile, headers, entry, extractor,
metadata, xhtml, context, config);
+ boolean thumbnail = entry.getName().equals(thumbnailEntry);
+ if (thumbnail) {
+ // duplicate entry names: mark only the first
+ thumbnailEntry = null;
+ }
+ parseZipFileEntry(zipFile, headers, entry, extractor,
metadata, xhtml, context, config,
+ thumbnail);
}
}
} finally {
@@ -478,7 +488,7 @@ public class ZipParser extends AbstractArchiveParser {
private void parseZipFileEntry(ZipFile zipFile, SeekableByteChannel
headers, ZipArchiveEntry entry,
EmbeddedDocumentExtractor extractor,
Metadata parentMetadata,
XHTMLContentHandler xhtml, ParseContext
context,
- ZipParserConfig config)
+ ZipParserConfig config, boolean thumbnail)
throws SAXException, IOException, TikaException {
String name = detectEntryName(entry, context, config);
@@ -504,6 +514,10 @@ public class ZipParser extends AbstractArchiveParser {
}
Metadata entryMetadata = buildEntryMetadata(entry, name, context,
readDosTime(headers, entry));
+ if (thumbnail) {
+ entryMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
+
TikaCoreProperties.EmbeddedResourceType.THUMBNAIL.toString());
+ }
writeEntryXhtml(name, xhtml);
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipPreview.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipPreview.java
new file mode 100644
index 0000000000..4cc8464f7c
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pkg-module/src/main/java/org/apache/tika/parser/pkg/ZipPreview.java
@@ -0,0 +1,105 @@
+/*
+ * 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.pkg;
+
+import java.util.Enumeration;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipFile;
+
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.mime.MediaType;
+
+/**
+ * Picks the entry of a zip-based format that is its preview image, so
+ * {@link ZipParser} can emit it as the
+ * {@link
org.apache.tika.metadata.TikaCoreProperties.EmbeddedResourceType#THUMBNAIL}.
+ * Only the {@link ZipFile} path picks one: some formats need the whole central
+ * directory to decide, which a streamed zip does not have up front.
+ */
+final class ZipPreview {
+
+ private static final Set<String> IMAGE_EXTENSIONS =
+ Set.of("jpg", "jpeg", "png", "gif", "webp", "bmp", "avif", "jxl");
+
+ private static final Map<MediaType, Function<ZipFile, String>> SELECTORS =
Map.of(
+ MediaType.application("vnd.comicbook+zip"),
ZipPreview::firstImageByName,
+ MediaType.application("x-krita"), entry("preview.png"),
+ MediaType.image("openraster"), entry("Thumbnails/thumbnail.png"),
+ MediaType.application("x-extension-fcstd"),
entry("thumbnails/Thumbnail.png"),
+ MediaType.application("x-procreate"),
entry("QuickLook/Thumbnail.png"),
+ MediaType.application("x-sketch"), entry("previews/preview.png"));
+
+ private ZipPreview() {
+ }
+
+ /**
+ * @return the raw name of the preview entry, or null when the type has
+ * no preview or the package does not carry one
+ */
+ static String thumbnailEntry(Metadata metadata, ZipFile zipFile) {
+ MediaType type =
MediaType.parse(metadata.get(HttpHeaders.CONTENT_TYPE));
+ if (type == null) {
+ return null;
+ }
+ Function<ZipFile, String> selector = SELECTORS.get(type.getBaseType());
+ return selector == null ? null : selector.apply(zipFile);
+ }
+
+ private static Function<ZipFile, String> entry(String name) {
+ return zipFile -> zipFile.getEntry(name) == null ? null : name;
+ }
+
+ /**
+ * A comic's cover is its first page, and the pages are ordered by name.
+ */
+ private static String firstImageByName(ZipFile zipFile) {
+ String first = null;
+ String firstKey = null;
+ Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
+ while (entries.hasMoreElements()) {
+ ZipArchiveEntry entry = entries.nextElement();
+ String name = entry.getName();
+ if (entry.isDirectory() || !isPage(name)) {
+ continue;
+ }
+ String key = name.toLowerCase(Locale.ROOT);
+ if (firstKey == null || key.compareTo(firstKey) < 0) {
+ first = name;
+ firstKey = key;
+ }
+ }
+ return first;
+ }
+
+ private static boolean isPage(String name) {
+ if (name.startsWith("__MACOSX/")) {
+ return false;
+ }
+ String base = name.substring(name.lastIndexOf('/') + 1);
+ int dot = base.lastIndexOf('.');
+ if (base.startsWith(".") || dot < 0) {
+ return false;
+ }
+ return IMAGE_EXTENSIONS.contains(base.substring(dot +
1).toLowerCase(Locale.ROOT));
+ }
+}