book-reader/app/src/main/java/com/aryan/reader/epub/EpubParser.kt
Aryan d16bdd70d4
V1.0.46 oss (#253)
* add a UI trigger for demo annotations

* Implement internal link navigation

* Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode

* Add support for Calibre metadata

* Implement advanced library management with Room-backed collections, tags, and smart rules.

* Display book series information in book details and remove tags from home screen

* Implement zoom reset functionality in PDF viewer

* Add a "Pages" thumbnail view to the PDF navigation drawer

* Implement jump-back navigation in PDF viewer

* MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging

* Replace Gson with Kotlin Serialization in SmartCollectionEngine

* Implement ONNX-based speech bubble detection

* Implement speech bubble detection in PDF viewer

* Implement "Smart Comic Zoom" feature for PDF manga/comic reading

* Implement reader session persistence and restoration in `MainViewModel`

* Add tap-to-turn page feature to PDF viewer

* Improve book cache management and recovery

* Refactor top overlay padding logic in `PdfViewerScreen`

* Implement stylus eraser support in PDF reader

* Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls

* Introduce a customizable horizontal margin setting for the EPUB reader

* Refine folder sync and metadata handling for better conflict resolution and stability.

* Introduce user-adjustable image scaling for the EPUB reader

* Use maxWidthPx as default width fallback for blocks

* Improve cross-page text selection and header styling in the paginated reader

* Refactor speech bubble detection and UI to support segmentation masks and interactive scaling

* Improve speech bubble detection masking and rendering quality

* Update ONNX speech bubble detector to use `.ort` model and optimize inference

* Improve pagination accuracy

* Implement hierarchical folder navigation for the library.

* Refactor library item layout for improved space efficiency

* Add support for browsing and downloading Google Fonts

* Persist library landing state and add shelf search functionality

* Fix base tts sample.

* Move SpeechBubbleDetector to main source set and update ONNX dependency

* Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers.

* Refine TTS synchronization and voice management in the EPUB reader

* Adjust OCR checks for OSS flavor and optimize external dictionary intent flags

* Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail

* fix various crashes

* Refactor SpeechBubbleDetector into product flavors

* Implement speech bubble detection caching and background prefetching

* Implement on-demand download for Bubble Zoom ML model

* Add smooth animation for PDF speech bubble expansion

* fixes #252

* hide Google Fonts option, in FontsScreen, for offline variant

* Bump version to 1.0.46(46)
2026-04-29 16:37:53 +05:30

709 lines
30 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.epub
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import timber.log.Timber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import org.w3c.dom.Element
import org.w3c.dom.Node
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.net.URLDecoder
import java.nio.file.Paths
import java.util.UUID
import java.util.zip.ZipFile
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class EpubParser(private val context: Context) {
data class EpubDocument(
val metadata: Node, val manifest: Node, val spine: Node, val opfFilePath: String
)
data class EpubManifestItem(
val id: String, val absPath: String, val mediaType: String, val properties: String
)
data class TempEpubChapter(
val url: String,
val title: String?,
val htmlFilePath: String,
val chapterIndex: Int,
val plainTextContent: String,
val htmlContent: String,
val depth: Int,
val isInToc: Boolean
)
// Helper class for NCX parsing results
data class NcxMetadata(
val title: String,
val depth: Int
)
// EpubFile can still represent in-memory file data during initial parsing before extraction
data class EpubFile(val absPath: String, val data: ByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as EpubFile
if (absPath != other.absPath) return false
return data.contentEquals(other.data)
}
override fun hashCode(): Int {
var result = absPath.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
@Serializable
data class EpubPageTarget(
val id: String?,
val value: String?,
val label: String?,
val contentSrc: String
)
companion object {
const val TAG = "EpubParser"
}
internal val String.decodedURL: String
get() = try {
URLDecoder.decode(this, "UTF-8")
} catch (e: Exception) {
Timber.w(e, "Failed to decode URL: $this")
this
}
private fun parsePageList(pageListElement: Element?, ncxFileParentDir: File): List<EpubPageTarget> {
if (pageListElement == null) {
Timber.d("No <pageList> element found in NCX.")
return emptyList()
}
val pageTargets = mutableListOf<EpubPageTarget>()
pageListElement.selectChildTag("pageTarget").forEach { ptElement ->
val contentSrcRaw = ptElement.selectFirstChildTag("content")?.getAttributeValue("src")?.decodedURL
if (contentSrcRaw != null) {
val contentPathRelativeToEpubRoot = Paths.get(ncxFileParentDir.path, contentSrcRaw)
.normalize().toString().replace(File.separatorChar, '/')
pageTargets.add(
EpubPageTarget(
id = ptElement.getAttributeValue("id"),
value = ptElement.getAttributeValue("value"),
label = ptElement.selectFirstChildTag("navLabel")?.selectFirstChildTag("text")?.textContent,
contentSrc = contentPathRelativeToEpubRoot
)
)
} else {
Timber.w("PageTarget found with no content src: ${ptElement.getAttributeValue("id")}")
}
}
Timber.d("Parsed ${pageTargets.size} page targets from NCX.")
return pageTargets
}
private fun parseEpubCss(
manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,
extractionRoot: File
): Map<String, String> {
val listedCss = manifestItems.values
.filter { it.mediaType == "text/css" }
.mapNotNull { manifestItem ->
val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
bytes?.let { manifestItem.absPath to String(it, Charsets.UTF_8) }
}
val listedCssPaths = listedCss.map { it.first }.toSet()
val unlistedCss = filesContentMap.asSequence()
.filter { (path, _) -> path.endsWith(".css", ignoreCase = true) }
.filterNot { (path, _) -> listedCssPaths.contains(path) }
.mapNotNull { (path, file) ->
val bytes = file.data.takeIf { it.isNotEmpty() }
?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
bytes?.let { path to String(it, Charsets.UTF_8) }
}
.toList()
val allCss = (listedCss + unlistedCss).toMap()
Timber.d("Parsed ${allCss.size} CSS files (listed: ${listedCss.size}, unlisted: ${unlistedCss.size}): ${allCss.keys.joinToString()}")
return allCss
}
suspend fun createEpubBook(
inputStream: InputStream,
bookId: String,
shouldUseToc: Boolean = true,
originalBookNameHint: String = "streamed_book",
parseContent: Boolean = true,
extractionDirOverride: File? = null
): EpubBook {
return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream for bookId: $bookId")
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir)
val filesMap: Map<String, EpubFile>
try {
tempFile.outputStream().use { output ->
inputStream.copyTo(output)
}
filesMap = extractEpubContents(ZipFile(tempFile), extractionDir, parseContent)
} finally {
tempFile.delete()
}
val document = createEpubDocument(filesMap)
val book = parseAndCreateEbook(filesMap, document, shouldUseToc, extractionDir.absolutePath,
originalBookNameHint, parseContent)
return@withContext book
}
}
private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map<String, EpubFile> {
val filesMap = mutableMapOf<String, EpubFile>()
zipFile.use { zf ->
zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry ->
val isEssential = isEssentialFile(entry.name, parseContent)
val isImage = entry.name.matches(Regex(".*\\.(png|jpg|jpeg|gif|webp|svg)$", RegexOption.IGNORE_CASE))
if (!parseContent) {
if (isEssential || isImage) {
val data = zf.getInputStream(entry).readBytes()
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
}
return@forEach
}
val outputFile = File(extractionDir, entry.name)
outputFile.parentFile?.mkdirs()
zf.getInputStream(entry).use { input ->
FileOutputStream(outputFile).use { output ->
input.copyTo(output)
}
}
val lowerName = entry.name.lowercase()
val isContainerOrOpf = lowerName.endsWith("container.xml") || lowerName.endsWith(".opf")
val data = if (isContainerOrOpf) outputFile.readBytes() else ByteArray(0)
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
}
}
return filesMap
}
private suspend fun parseAndCreateEbook(
filesContentMap: Map<String, EpubFile>,
document: EpubDocument,
shouldUseToc: Boolean,
extractionBasePath: String,
originalFilePathOrKey: String,
parseContent: Boolean = true
): EpubBook = withContext(Dispatchers.IO) {
val metadataTitle =
document.metadata.selectFirstChildTag("dc:title")?.textContent ?: File(originalFilePathOrKey).nameWithoutExtension
val metadataAuthor =
document.metadata.selectFirstChildTag("dc:creator")?.textContent ?: "Unknown Author"
val metadataLanguage =
document.metadata.selectFirstChildTag("dc:language")?.textContent ?: "en"
val metadataCoverId = getMetadataCoverId(document.metadata)
val metadataDescription =
document.metadata.selectFirstChildTag("dc:description")?.textContent
Timber.d("EpubParser: Extracted OPF metadata: title='$metadataTitle', author='$metadataAuthor'")
var metadataSeriesName: String? = null
var metadataSeriesIndex: Double? = null
document.metadata.selectChildTag("meta")
.ifEmpty { document.metadata.selectChildTag("opf:meta") }
.forEach { meta ->
val nameAttr = meta.getAttributeValue("name")
val contentAttr = meta.getAttributeValue("content")
if (nameAttr == "calibre:series") metadataSeriesName = contentAttr
if (nameAttr == "calibre:series_index") metadataSeriesIndex = contentAttr?.toDoubleOrNull()
}
val opfRelativePath = document.opfFilePath
val opfParentDir = File(opfRelativePath).parentFile ?: File("")
val manifestItems = getManifestItems(document.manifest, opfParentDir)
var pageTargets: List<EpubPageTarget> = emptyList()
val ncxMetadataMap = mutableMapOf<String, NcxMetadata>()
val extractionRoot = File(extractionBasePath)
if (shouldUseToc) {
Timber.d("shouldUseToc is true. Attempting to parse NCX.")
val tocFileItem = manifestItems.values.firstOrNull {
it.absPath.endsWith(".ncx", ignoreCase = true)
}
if (tocFileItem != null) {
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
if (tocDocumentNode != null) {
Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}")
val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element?
pageTargets = parsePageList(pageListElement, ncxParentDir)
val navMapElement = tocDocumentNode.selectFirstTag("navMap") as Element?
if (navMapElement != null) {
// Recursively parse navMap
ncxMetadataMap.putAll(parseNavMapRecursive(navMapElement, ncxParentDir))
} else {
Timber.d("No <navMap> element found in NCX.")
}
} else {
Timber.w("NCX file item '${tocFileItem.absPath}' found in manifest but could not be parsed.")
}
} else {
Timber.d("No NCX file found in manifest. Skipping NCX-based PageList/NavMap.")
}
} else {
Timber.d("shouldUseToc is false. Skipping NCX parsing for PageList/NavMap.")
}
Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.")
val tableOfContents = if (shouldUseToc) {
val tocFileItem = manifestItems.values.firstOrNull {
it.absPath.endsWith(".ncx", ignoreCase = true)
}
if (tocFileItem != null) {
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
val navMapElement = tocDocumentNode?.selectFirstTag("navMap") as Element?
if (navMapElement != null) {
parseTableOfContents(navMapElement, ncxParentDir)
} else {
emptyList()
}
} else {
emptyList()
}
} else {
emptyList()
}
val chaptersFromSpine = if (parseContent) {
parseUsingSpine(document.spine, manifestItems, filesContentMap, ncxMetadataMap, extractionRoot)
} else {
emptyList()
}
Timber.d("Parsing images (for cover and general access)")
val images = if (parseContent) {
parseEpubImages(manifestItems, filesContentMap, extractionRoot)
} else {
emptyList()
}
Timber.d("Parsing cover image")
val coverImage = parseCoverImage(metadataCoverId, manifestItems, filesContentMap, extractionRoot)
val cssContent = if (parseContent) {
parseEpubCss(manifestItems, filesContentMap, extractionRoot)
} else {
emptyMap()
}
Timber.d("EpubBook created with ${chaptersFromSpine.size} spine chapters.")
return@withContext EpubBook(
fileName = metadataTitle.asFileName(),
title = metadataTitle,
author = metadataAuthor,
language = metadataLanguage,
coverImage = coverImage,
chapters = chaptersFromSpine, chaptersForPagination = chaptersFromSpine,
images = images,
pageList = pageTargets,
tableOfContents = tableOfContents,
extractionBasePath = extractionBasePath,
css = cssContent,
seriesName = metadataSeriesName,
seriesIndex = metadataSeriesIndex,
description = metadataDescription
)
}
private fun parseTableOfContents(
navMapElement: Element,
ncxParentDir: File
): List<EpubTocEntry> {
val result = mutableListOf<EpubTocEntry>()
fun recurse(element: Element, currentDepth: Int) {
val navPoints = element.childElements.filter { it.tagName == "navPoint" }
for (navPoint in navPoints) {
val label = navPoint.selectFirstChildTag("navLabel")
?.selectFirstChildTag("text")?.textContent?.trim() ?: "Untitled"
val contentSrc = navPoint.selectFirstChildTag("content")
?.getAttributeValue("src")?.decodedURL
if (contentSrc != null) {
val fullPathRaw = Paths.get(ncxParentDir.path, contentSrc)
.normalize().toString().replace(File.separatorChar, '/')
val parts = fullPathRaw.split("#", limit = 2)
val absolutePath = parts[0]
val fragmentId = if (parts.size > 1) parts[1] else null
result.add(EpubTocEntry(label, absolutePath, fragmentId, currentDepth))
recurse(navPoint, currentDepth + 1)
}
}
}
recurse(navMapElement, 0)
return result
}
@Throws(EpubParserException::class)
private fun createEpubDocument(files: Map<String, EpubFile>): EpubDocument {
val containerFile = files["META-INF/container.xml"]
?: throw EpubParserException("META-INF/container.xml file missing")
val rawOpfPath = parseXMLFile(containerFile.data)?.selectFirstTag("rootfile")
?.getAttributeValue("full-path")?.decodedURL
?: throw EpubParserException("Invalid container.xml: Could not find rootfile full-path.")
val opfFilePath = rawOpfPath.trimStart('/')
val opfFile = files[opfFilePath]
?: throw EpubParserException(".opf file missing at normalized path '$opfFilePath'.")
val document = parseXMLFile(opfFile.data)
?: throw EpubParserException(".opf file failed to parse data from '$opfFilePath'")
val metadata = document.selectFirstTag("metadata")
?: document.selectFirstTag("opf:metadata")
?: throw EpubParserException(".opf file metadata section missing in '$opfFilePath'")
val manifest = document.selectFirstTag("manifest")
?: document.selectFirstTag("opf:manifest")
?: throw EpubParserException(".opf file manifest section missing in '$opfFilePath'")
val spine = document.selectFirstTag("spine")
?: document.selectFirstTag("opf:spine")
?: throw EpubParserException(".opf file spine section missing in '$opfFilePath'")
return EpubDocument(metadata, manifest, spine, opfFilePath)
}
private fun getMetadataCoverId(metadata: Node): String? {
return metadata.selectChildTag("meta")
.ifEmpty { metadata.selectChildTag("opf:meta") }
.find { it.getAttributeValue("name") == "cover" }?.getAttributeValue("content")
}
private fun getManifestItems(
manifest: Node,
opfParentDir: File
): Map<String, EpubManifestItem> {
return manifest.selectChildTag("item")
.ifEmpty { manifest.selectChildTag("opf:item") }
.mapNotNull { itemElement ->
val href = itemElement.getAttribute("href")?.decodedURL ?: return@mapNotNull null
val pathRelativeToEpubRoot = Paths.get(opfParentDir.path, href)
.normalize().toString().replace(File.separatorChar, '/')
EpubManifestItem(
id = itemElement.getAttribute("id"),
absPath = pathRelativeToEpubRoot,
mediaType = itemElement.getAttribute("media-type"),
properties = itemElement.getAttribute("properties")
)
}.associateBy { it.id }
}
private fun parseNavMapRecursive(
element: Element,
ncxFileParentDir: File,
depth: Int = 0
): Map<String, NcxMetadata> {
val result = mutableMapOf<String, NcxMetadata>()
val navPoints = element.childElements.filter { it.tagName == "navPoint" }
for (navPoint in navPoints) {
val navLabelText = navPoint.selectFirstChildTag("navLabel")
?.selectFirstChildTag("text")?.textContent?.trim()
val contentSrcRaw = navPoint.selectFirstChildTag("content")
?.getAttributeValue("src")?.decodedURL
if (navLabelText != null && contentSrcRaw != null && navLabelText.isNotEmpty()) {
val contentPathRelativeToEpubRoot = Paths.get(ncxFileParentDir.path, contentSrcRaw)
.normalize().toString().replace(File.separatorChar, '/')
.substringBefore('#')
if (!result.containsKey(contentPathRelativeToEpubRoot)) {
result[contentPathRelativeToEpubRoot] = NcxMetadata(navLabelText, depth)
Timber.d("NCX Map: '$contentPathRelativeToEpubRoot' -> '$navLabelText' (Depth $depth)")
}
}
result.putAll(parseNavMapRecursive(navPoint, ncxFileParentDir, depth + 1))
}
return result
}
private fun generateId(): String {
return UUID.randomUUID().toString()
}
private suspend fun parseUsingSpine(
spine: Node,
manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,
ncxMetadataMap: Map<String, NcxMetadata>,
extractionRoot: File
): List<EpubChapter> = withContext(Dispatchers.Default) {
val parsingSemaphore = Semaphore(6)
val spineItems = spine.selectChildTag("itemref")
.ifEmpty { spine.selectChildTag("opf:itemref") }
val deferredChapters = spineItems.mapIndexed { index, itemRef ->
async {
parsingSemaphore.withPermit {
val idRef = itemRef.getAttribute("idref")
val item = manifestItems[idRef] ?: return@withPermit null
val fileBytes = filesContentMap[item.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, item.absPath).takeIf { it.exists() }?.readBytes()
?: return@withPermit null
val mediaType = item.mediaType
val absPath = item.absPath
if (mediaType.startsWith("application/xhtml+xml") ||
mediaType.startsWith("text/html") ||
absPath.endsWith(".html", ignoreCase = true) ||
absPath.endsWith(".xhtml", ignoreCase = true) ||
absPath.endsWith(".xml", ignoreCase = true)
) {
val rawHtml = String(fileBytes, Charsets.UTF_8)
val document = Jsoup.parse(rawHtml)
val plainText = document.text()
val parser = EpubXMLFileParser(
fileRelativePath = absPath,
data = fileBytes,
fragmentId = null
)
val res = parser.parseForTitleAndPath(document)
val chapterTitleFromHtml = res.title
val ncxKey = absPath.substringBefore('#')
val ncxData = ncxMetadataMap[ncxKey]
val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) {
ncxData != null
} else {
true
}
val finalChapterTitle = if (ncxData != null && ncxData.title.isNotBlank()) {
ncxData.title
} else {
chapterTitleFromHtml
}
val finalDepth = ncxData?.depth ?: 0
TempEpubChapter(
url = absPath,
title = finalChapterTitle,
htmlFilePath = res.effectiveHtmlPath,
chapterIndex = index + 1,
plainTextContent = plainText,
htmlContent = "", // OPTIMIZATION: Don't store HTML in memory, it's on disk
depth = finalDepth,
isInToc = isEffectiveInToc
)
} else if (mediaType.startsWith("image/")) {
// Image handling remains similar, but usually small enough
val htmlContent = """
<!DOCTYPE html><html style="margin:0;padding:0;height:100%;"><head><title>Image</title></head><body style="margin:0;padding:0;height:100%;text-align:center;"><img src="$absPath" alt="Image from spine" style="object-fit:contain;width:100%;height:100%;"/></body></html>
""".trimIndent()
val ncxKey = absPath.substringBefore('#')
val ncxData = ncxMetadataMap[ncxKey]
val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) ncxData != null else true
TempEpubChapter(
url = absPath,
title = ncxData?.title ?: "Image",
htmlFilePath = absPath,
chapterIndex = index + 1,
plainTextContent = "[Image]",
htmlContent = htmlContent,
depth = ncxData?.depth ?: 0,
isInToc = isEffectiveInToc
)
} else {
null
}
}
}
}
val tempChapters = deferredChapters.toList().awaitAll().filterNotNull()
return@withContext tempChapters.map { tempChapter ->
EpubChapter(
chapterId = generateId(),
absPath = tempChapter.url,
title = tempChapter.title?.takeIf { it.isNotBlank() } ?: "Chapter ${tempChapter.chapterIndex}",
htmlFilePath = tempChapter.htmlFilePath,
plainTextContent = tempChapter.plainTextContent,
htmlContent = tempChapter.htmlContent,
depth = tempChapter.depth,
isInToc = tempChapter.isInToc
)
}.filter { it.htmlFilePath.isNotBlank() }
}
private fun parseEpubImages(
manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,
@Suppress("UNUSED_PARAMETER") extractionRoot: File
): List<EpubImage> {
val imageExtensions = setOf(".png", ".gif", ".jpg", ".jpeg", ".webp", ".svg")
val listedImages = manifestItems.values
.filter { it.mediaType.startsWith("image/") }
.map { manifestItem ->
EpubImage(absPath = manifestItem.absPath)
}
val listedPaths = listedImages.map { it.absPath }.toSet()
val unlistedImages = filesContentMap.keys
.filter { path ->
val lowerPath = path.lowercase()
imageExtensions.any { lowerPath.endsWith(it) } && !listedPaths.contains(path)
}
.map { path ->
EpubImage(absPath = path)
}
Timber.d("Identified ${listedImages.size + unlistedImages.size} images (content not loaded into memory).")
return (listedImages + unlistedImages).distinctBy { it.absPath }
}
private fun parseCoverImage(
metadataCoverId: String?,
manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,
extractionRoot: File
): Bitmap? {
val coverManifestItem = manifestItems[metadataCoverId]
if (coverManifestItem != null) {
val coverImageBytes = filesContentMap[coverManifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, coverManifestItem.absPath).takeIf { it.exists() }?.readBytes()
if (coverImageBytes != null) {
return BitmapFactory.decodeByteArray(coverImageBytes, 0, coverImageBytes.size)
} else {
Timber.e("Cover image file content not found for path: ${coverManifestItem.absPath}")
}
} else {
if (metadataCoverId != null) {
Timber.w("Cover image ID '$metadataCoverId' not found in manifest.")
} else {
Timber.d("No cover image ID specified in metadata.")
}
}
val commonCoverNames = listOf("cover.jpg", "cover.jpeg", "cover.png")
for (name in commonCoverNames) {
val possiblePaths = listOf(
name, "images/$name", "Images/$name", "image/$name", "Image/$name",
"OEBPS/images/$name", "OEBPS/Images/$name", "OEBPS/image/$name", "OEBPS/Image/$name",
"OPS/images/$name", "OPS/Images/$name", "OPS/image/$name", "OPS/Image/$name"
)
for (path in possiblePaths) {
if (filesContentMap.containsKey(path)) {
val bytes = filesContentMap[path]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
bytes?.let {
Timber.d("Found fallback cover image at $path")
return BitmapFactory.decodeByteArray(it, 0, it.size)
}
}
manifestItems.values.find { item -> item.absPath.equals(path, ignoreCase = true) && item.mediaType.startsWith("image/") }?.let { manifestItem ->
val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
bytes?.let {
Timber.d("Found fallback cover image via manifest item (case-insensitive) at ${manifestItem.absPath}")
return BitmapFactory.decodeByteArray(it, 0, it.size)
}
}
}
}
Timber.d("Cover image could not be loaded from metadata or common fallbacks.")
return null
}
private fun isEssentialFile(fileName: String, parseContent: Boolean): Boolean {
val lowerName = fileName.lowercase()
if (lowerName.endsWith("container.xml") || lowerName.endsWith(".opf")) {
return true
}
if (!parseContent) {
return false
}
return lowerName.endsWith(".xml") ||
lowerName.endsWith(".ncx") ||
lowerName.endsWith(".html") ||
lowerName.endsWith(".xhtml") ||
lowerName.endsWith(".htm") ||
lowerName.endsWith(".css")
}
}