]> git.otsuka.systems Git - alexandria/commitdiff
fix library cover images master
authorCameron Otsuka <cameron@otsuka.haus>
Tue, 18 Aug 2026 00:56:46 +0000 (17:56 -0700)
committerCameron Otsuka <cameron@otsuka.haus>
Tue, 18 Aug 2026 00:56:46 +0000 (17:56 -0700)
app/src/androidTest/java/com/alexandria/reader/CoverImageDecoderInstrumentedTest.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/CoverImageDecoder.kt [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/EpubParser.kt
app/src/main/java/com/alexandria/reader/LibraryScreen.kt

diff --git a/app/src/androidTest/java/com/alexandria/reader/CoverImageDecoderInstrumentedTest.kt b/app/src/androidTest/java/com/alexandria/reader/CoverImageDecoderInstrumentedTest.kt
new file mode 100644 (file)
index 0000000..f768edf
--- /dev/null
@@ -0,0 +1,58 @@
+package com.alexandria.reader
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+
+@RunWith(AndroidJUnit4::class)
+class CoverImageDecoderInstrumentedTest {
+    @Test
+    fun decodesProvidedEpubCoversAtLibraryThumbnailSize() {
+        val context = ApplicationProvider.getApplicationContext<Context>()
+        val testRoot = File(context.cacheDir, "cover-decoder-test").apply {
+            deleteRecursively()
+            mkdirs()
+        }
+
+        try {
+            listOf("odyssey.epub", "the_prize.epub", "money_promises.epub").forEachIndexed { index, fixture ->
+                val source = File(testRoot, fixture)
+                InstrumentationRegistry.getInstrumentation().context.assets.open(fixture).use { input ->
+                    source.outputStream().use(input::copyTo)
+                }
+                val book = LibraryBook(
+                    id = (index + 1).toString().repeat(24),
+                    title = "Test book",
+                    author = "Test author",
+                    fileName = fixture,
+                )
+                val publication = EpubParser.parse(source, File(testRoot, "content-$index"), book)
+                val cover = publication.book.coverPath?.let(::File)
+                assertNotNull("$fixture should identify a cover", cover)
+                assertTrue("$fixture cover should exist", cover?.isFile == true)
+
+                val bitmap = CoverImageDecoder.decode(requireNotNull(cover), TARGET_WIDTH, TARGET_HEIGHT)
+                assertNotNull("$fixture cover should decode", bitmap)
+                requireNotNull(bitmap).run {
+                    assertEquals(TARGET_WIDTH, width)
+                    assertEquals(TARGET_HEIGHT, height)
+                    recycle()
+                }
+            }
+        } finally {
+            testRoot.deleteRecursively()
+        }
+    }
+
+    private companion object {
+        const val TARGET_WIDTH = 93
+        const val TARGET_HEIGHT = 132
+    }
+}
diff --git a/app/src/main/java/com/alexandria/reader/CoverImageDecoder.kt b/app/src/main/java/com/alexandria/reader/CoverImageDecoder.kt
new file mode 100644 (file)
index 0000000..c96b0bd
--- /dev/null
@@ -0,0 +1,46 @@
+package com.alexandria.reader
+
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.graphics.Rect
+import java.io.File
+
+/** Decodes a center-cropped cover at its displayed size to keep library rows lightweight. */
+internal object CoverImageDecoder {
+    fun decode(file: File, targetWidth: Int, targetHeight: Int): Bitmap? {
+        if (!file.isFile || targetWidth <= 0 || targetHeight <= 0) return null
+        return runCatching {
+            ImageDecoder.decodeBitmap(ImageDecoder.createSource(file)) { decoder, info, _ ->
+                val sourceWidth = info.size.width
+                val sourceHeight = info.size.height
+                require(sourceWidth > 0 && sourceHeight > 0) { "Invalid cover image dimensions." }
+
+                val resizeWidth: Int
+                val resizeHeight: Int
+                val crop: Rect
+                if (sourceWidth.toLong() * targetHeight > sourceHeight.toLong() * targetWidth) {
+                    resizeWidth = scaledDimension(sourceWidth, targetHeight, sourceHeight)
+                    resizeHeight = targetHeight
+                    val left = (resizeWidth - targetWidth) / 2
+                    crop = Rect(left, 0, left + targetWidth, targetHeight)
+                } else {
+                    resizeWidth = targetWidth
+                    resizeHeight = scaledDimension(sourceHeight, targetWidth, sourceWidth)
+                    val top = (resizeHeight - targetHeight) / 2
+                    crop = Rect(0, top, targetWidth, top + targetHeight)
+                }
+
+                decoder.setTargetSize(resizeWidth, resizeHeight)
+                decoder.crop = crop
+                decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+            }
+        }.getOrNull()
+    }
+
+    private fun scaledDimension(source: Int, target: Int, sourceDivisor: Int): Int {
+        val numerator = source.toLong() * target
+        val result = (numerator + sourceDivisor - 1L) / sourceDivisor
+        require(result in 1..Int.MAX_VALUE.toLong()) { "Invalid scaled cover dimensions." }
+        return result.toInt()
+    }
+}
index 07ab7afb90bcef190f7ec89e73da6e663f189465..43be092cb4c728c95d25890c087d81a9a4401508 100644 (file)
@@ -114,26 +114,22 @@ internal object EpubParser {
         require(parsedSpine.isNotEmpty()) { "The EPUB reading order is empty." }
         val spine = parsedSpine.map(ParsedSpineItem::item)
 
-        if (packageVersion == 3) {
-            manifest.values.firstOrNull { "cover-image" in it.properties }?.let { item ->
-                val candidate = safeFile(output, item.href)
-                if (candidate.isFile && item.mediaType.startsWith("image/")) book.coverPath = candidate.absolutePath
-            }
-        } else {
-            val coverId = metadata.allByLocalName("meta").firstOrNull {
-                it.attr("name").equals("cover", ignoreCase = true)
-            }?.attr("content")?.trim()
-            val metadataCover = coverId?.let(manifest::get)?.takeIf { it.mediaType.startsWith("image/") }
-            val guideCover = opf.allByLocalName("guide").firstOrNull()?.children()
-                ?.firstOrNull { reference ->
-                    reference.localName() == "reference" &&
-                        reference.attr("type").equals("cover", ignoreCase = true)
-                }?.attr("href")?.takeIf(String::isNotBlank)?.let { href ->
-                val coverPath = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?'))
-                manifest.values.firstOrNull { it.href == coverPath }
-            }
-            setEpub2Cover(output, metadataCover ?: guideCover, book)
+        val propertyCover = manifest.values.firstOrNull { "cover-image" in it.properties }
+        // Some EPUB 3 publications retain the EPUB 2 cover metadata instead of
+        // adding the cover-image manifest property. Accept both conventions.
+        val coverId = metadata.allByLocalName("meta").firstOrNull {
+            it.attr("name").equals("cover", ignoreCase = true)
+        }?.attr("content")?.trim()
+        val metadataCover = coverId?.let(manifest::get)
+        val guideCover = opf.allByLocalName("guide").firstOrNull()?.children()
+            ?.firstOrNull { reference ->
+                reference.localName() == "reference" &&
+                    reference.attr("type").equals("cover", ignoreCase = true)
+            }?.attr("href")?.takeIf(String::isNotBlank)?.let { href ->
+            val coverPath = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?'))
+            manifest.values.firstOrNull { it.href == coverPath }
         }
+        setCover(output, propertyCover ?: metadataCover ?: guideCover, book)
 
         val toc = if (packageVersion == 3) {
             val navItem = manifest.values.singleOrNull { "nav" in it.properties }
@@ -221,7 +217,7 @@ internal object EpubParser {
         }
     }
 
-    private fun setEpub2Cover(root: File, item: ManifestItem?, book: LibraryBook) {
+    private fun setCover(root: File, item: ManifestItem?, book: LibraryBook) {
         if (item == null) return
         val candidate = safeFile(root, item.href)
         if (!candidate.isFile) return
index d8f4c897fa16a3a5c3650560134a95345dac11d6..5d025995c36e73f01b52947c50ba53b0096ba5d6 100644 (file)
@@ -4,8 +4,6 @@ import android.annotation.SuppressLint
 import android.app.Activity
 import android.app.AlertDialog
 import android.graphics.Bitmap
-import android.graphics.ImageDecoder
-import android.graphics.Rect
 import android.graphics.Typeface
 import android.graphics.drawable.ColorDrawable
 import android.text.Editable
@@ -294,25 +292,7 @@ internal class LibraryScreen(
         coverCache.get(path)?.let { return it }
         val targetWidth = dp(62)
         val targetHeight = dp(88)
-        val bitmap = runCatching {
-            ImageDecoder.decodeBitmap(ImageDecoder.createSource(File(path))) { decoder, info, _ ->
-                val width = info.size.width
-                val height = info.size.height
-                require(width > 0 && height > 0) { "Invalid cover image dimensions." }
-                val crop = if (width.toLong() * targetHeight > height.toLong() * targetWidth) {
-                    val cropWidth = (height.toLong() * targetWidth / targetHeight).toInt().coerceAtLeast(1)
-                    val left = (width - cropWidth) / 2
-                    Rect(left, 0, left + cropWidth, height)
-                } else {
-                    val cropHeight = (width.toLong() * targetHeight / targetWidth).toInt().coerceAtLeast(1)
-                    val top = (height - cropHeight) / 2
-                    Rect(0, top, width, top + cropHeight)
-                }
-                decoder.crop = crop
-                decoder.setTargetSize(targetWidth, targetHeight)
-                decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
-            }
-        }.getOrNull() ?: return null
+        val bitmap = CoverImageDecoder.decode(File(path), targetWidth, targetHeight) ?: return null
         coverCache.put(path, bitmap)
         return bitmap
     }