--- /dev/null
+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
+ }
+}
--- /dev/null
+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()
+ }
+}
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 }
}
}
- 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
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
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
}