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

Reply via email to