Recently Written · git

subread-android

SubRead for Android: times an audiobook against its ebook on the device

git clone https://github.com/equwal/subread-android

Log | Files | Refs


commit 1d4a390c79ea2a0270cc933886b5d3a6cb2d5257
equwal <truex@equwal.com>
2026-09-21 01:15:58 -0700

Find the cover of an epub

The cover is the image with "cover" in its name, or else the largest
image. The package file is not used, so malformed epubs work too.

 core/src/main/kotlin/space/subread/core/BookText.kt | 21 +++++++++++++++++++++
 core/src/test/kotlin/space/subread/core/BookTest.kt | 17 +++++++++++++++++
 2 files changed, 38 insertions(+)
diff --git a/core/src/main/kotlin/space/subread/core/BookText.kt b/core/src/main/kotlin/space/subread/core/BookText.kt
index f8a89df..092df00 100644
--- a/core/src/main/kotlin/space/subread/core/BookText.kt
+++ b/core/src/main/kotlin/space/subread/core/BookText.kt
@@ -41,6 +41,27 @@ object BookText {
         return order.flatMap { path -> entries[path]?.let(::page) ?: emptyList() }
     }
 
+    /**
+     * The cover of an epub: the image with "cover" in its name, or else the
+     * largest image. This works on malformed epubs too, where the package file
+     * does not name a cover. Null for a book that is not an epub.
+     */
+    fun cover(input: InputStream): ByteArray? {
+        var best: ByteArray? = null
+        var named: ByteArray? = null
+        ZipInputStream(input).use { zip ->
+            while (true) {
+                val e = zip.nextEntry ?: break
+                val name = e.name.lowercase()
+                if (e.isDirectory || !(name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".webp"))) continue
+                val bytes = zip.readBytes()
+                if (bytes.size > (best?.size ?: 1023)) best = bytes
+                if ("cover" in name.substringAfterLast('/') && bytes.size > (named?.size ?: 1023)) named = bytes
+            }
+        }
+        return named ?: best
+    }
+
     private fun isPage(name: String) =
         name.endsWith(".xhtml", true) || name.endsWith(".html", true) || name.endsWith(".htm", true)
 
diff --git a/core/src/test/kotlin/space/subread/core/BookTest.kt b/core/src/test/kotlin/space/subread/core/BookTest.kt
index ae75837..ddb324f 100644
--- a/core/src/test/kotlin/space/subread/core/BookTest.kt
+++ b/core/src/test/kotlin/space/subread/core/BookTest.kt
@@ -3,6 +3,7 @@ package space.subread.core
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.json.jsonObject
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
@@ -152,6 +153,22 @@ class BookTest {
         assertEquals(listOf("one", "two"), BookText.read(ByteArrayInputStream(bytes), "x.epub"))
     }
 
+    @Test
+    fun theCoverIsTheImageNamedCoverOrElseTheLargestImage() {
+        val small = "c".repeat(2000)
+        val large = "p".repeat(9000)
+        val named = epub("OEBPS/images/plate.jpg" to large, "OEBPS/images/Cover.jpg" to small, "OEBPS/a.xhtml" to page("<p>x</p>"))
+        assertEquals(small, BookText.cover(named.inputStream())!!.decodeToString())
+        val unnamed = epub("OEBPS/images/1.png" to small, "OEBPS/images/2.png" to large, "OEBPS/images/dot.gif" to "g".repeat(99999))
+        assertEquals(large, BookText.cover(unnamed.inputStream())!!.decodeToString())
+    }
+
+    @Test
+    fun aBookWithNoPictureHasNoCover() {
+        assertNull(BookText.cover(epub("a.xhtml" to page("<p>x</p>"), "bullet.png" to "tiny").inputStream()))
+        assertNull(BookText.cover("plain text, not a zip".byteInputStream()))
+    }
+
     @Test
     fun aozoraMarkupIsStripped() {
         val zip = File(javaClass.classLoader.getResource("aozora/789_ruby_5639.zip")!!.toURI())