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())