From: Cameron Otsuka Date: Tue, 18 Aug 2026 00:56:46 +0000 (-0700) Subject: fix library cover images X-Git-Url: https://git.otsuka.systems/?a=commitdiff_plain;h=289a64645238a5471ee405405825c421d39822ac;p=alexandria fix library cover images --- 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 index 0000000..f768edf --- /dev/null +++ b/app/src/androidTest/java/com/alexandria/reader/CoverImageDecoderInstrumentedTest.kt @@ -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() + 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 index 0000000..c96b0bd --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/CoverImageDecoder.kt @@ -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() + } +} diff --git a/app/src/main/java/com/alexandria/reader/EpubParser.kt b/app/src/main/java/com/alexandria/reader/EpubParser.kt index 07ab7af..43be092 100644 --- a/app/src/main/java/com/alexandria/reader/EpubParser.kt +++ b/app/src/main/java/com/alexandria/reader/EpubParser.kt @@ -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 diff --git a/app/src/main/java/com/alexandria/reader/LibraryScreen.kt b/app/src/main/java/com/alexandria/reader/LibraryScreen.kt index d8f4c89..5d02599 100644 --- a/app/src/main/java/com/alexandria/reader/LibraryScreen.kt +++ b/app/src/main/java/com/alexandria/reader/LibraryScreen.kt @@ -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 }