Support more formats (#112)

* Added support for FB2 book format

* Add support for CBZ files by introducing a `ReaderDocument` abstraction.

Key changes:
- Defined `ReaderDocument`, `ReaderPage`, and `ReaderTextPage` interfaces to provide a unified API for different document types.
- Implemented `PdfDocumentWrapper` and `CbzDocumentWrapper` to handle PDF and CBZ files respectively.
- Updated `PdfViewerScreen`, `PdfPageComposable`, and `MainViewModel` to use the new unified document interfaces.
- Added CBZ file type detection and cover generation logic.

* Add support for CBR and CB7 comic book formats

- Add `me.zhanghai.android.libarchive` dependency to support RAR and 7z archives.
- Implement `ArchiveDocumentWrapper` using `libarchive` to handle CBZ, CBR, and CB7 files uniformly, replacing the previous CBZ-only implementation.
- Update `MainViewModel` and `DocumentFactory` to recognize and process `.cbr` and `.cb7` extensions and their associated MIME types.
- Add support for extracting and caching cover images from CBR and CB7 archives.
- Update UI components (`HomeScreen`, `LibraryScreen`, `AppNavigation`) to handle the new comic book file types.
This commit is contained in:
Aryan 2026-03-26 12:27:56 +05:30 committed by GitHub
parent 32e29dfc07
commit 4db2c97a30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 871 additions and 93 deletions

View file

@ -143,19 +143,19 @@ internal object OcrHelper {
}
internal suspend fun findWordBoundaries(
textPage: PdfTextPageKt,
textPage: ReaderTextPage,
initialCharIndex: Int,
pageCharCount: Int
): Pair<Int, Int>? {
if (initialCharIndex !in 0..<pageCharCount) return null
val initialChar = textPage.textPageGetUnicode(initialCharIndex)
val initialChar = textPage.textPageGetUnicode(initialCharIndex).toChar()
if (!initialChar.isLetterOrDigit()) {
Timber.d("Initial char '$initialChar' at index $initialCharIndex is not letter/digit.")
return null
}
var wordStartIndex = initialCharIndex
while (wordStartIndex > 0) {
val char = textPage.textPageGetUnicode(wordStartIndex - 1)
val char = textPage.textPageGetUnicode(wordStartIndex - 1).toChar()
if (!char.isLetterOrDigit()) {
break
}
@ -163,7 +163,7 @@ internal suspend fun findWordBoundaries(
}
var wordEndIndex = initialCharIndex
while (wordEndIndex < pageCharCount) {
val char = textPage.textPageGetUnicode(wordEndIndex)
val char = textPage.textPageGetUnicode(wordEndIndex).toChar()
if (!char.isLetterOrDigit()) {
break
}

View file

@ -119,9 +119,6 @@ import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrResult
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfPageKt
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
@ -374,7 +371,7 @@ data class PageSelectionData(
@Suppress("unused")
@Composable
internal fun PdfPageComposable(
pdfDocument: StableHolder<PdfDocumentKt>,
pdfDocument: StableHolder<ReaderDocument>,
pageIndex: Int,
totalPages: Int,
modifier: Modifier = Modifier,
@ -747,7 +744,7 @@ internal fun PdfPageComposable(
var rects: List<Rect> = emptyList()
var pdfiumSucceeded = false
var tempPage: PdfPageKt? = null
var tempPage: ReaderPage? = null
try {
withContext(Dispatchers.IO) {
@ -847,7 +844,7 @@ internal fun PdfPageComposable(
// 1. Extract Links (Method 1: Annotations)
try {
val annotationLinks = pageWrapper.getPageLinks()
val annotationLinks = pageWrapper.getLinks()
if (annotationLinks.isNotEmpty()) {
val mappedAnnotationLinks = annotationLinks.mapNotNull { link ->
val uri = link.uri
@ -909,7 +906,7 @@ internal fun PdfPageComposable(
// 3. Extract Embedded Annotations
try {
val pagePtr = getNativePointer(pageWrapper)
val pagePtr = pageWrapper.getNativePointer()
if (pagePtr != 0L) {
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
@ -1068,7 +1065,7 @@ internal fun PdfPageComposable(
if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0 || screenWidth == 0f || screenHeight == 0f) return@LaunchedEffect
var page: PdfPageKt? = null
var page: ReaderPage? = null
if (!isPdfPage) {
if (tiles.isNotEmpty()) {
@ -1416,14 +1413,14 @@ internal fun PdfPageComposable(
}
suspend fun updateSelectionVisuals(
doc: PdfDocumentKt,
doc: ReaderDocument,
pageIdx: Int,
charRange: Pair<Int, Int>?,
currentBitmapWidth: Int,
currentBitmapHeight: Int,
rotation: Int,
providedPage: PdfPageKt? = null,
providedTextPage: PdfTextPageKt? = null
providedPage: ReaderPage? = null,
providedTextPage: ReaderTextPage? = null
) {
if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) {
withContext(Dispatchers.Main) {
@ -1435,12 +1432,12 @@ internal fun PdfPageComposable(
}
withContext(Dispatchers.IO) {
var localPage: PdfPageKt? = null
var localTextPage: PdfTextPageKt? = null
var localPage: ReaderPage? = null
var localTextPage: ReaderTextPage? = null
try {
val pageToUse: PdfPageKt
val textPageToUse: PdfTextPageKt
val pageToUse: ReaderPage
val textPageToUse: ReaderTextPage
if (providedPage != null && providedTextPage != null) {
pageToUse = providedPage
@ -1675,8 +1672,8 @@ internal fun PdfPageComposable(
val dragEventChannel = Channel<Offset>(Channel.CONFLATED)
coroutineScope.launch(Dispatchers.IO) {
var pageForDrag: PdfPageKt? = null
var textPageForDrag: PdfTextPageKt? = null
var pageForDrag: ReaderPage? = null
var textPageForDrag: ReaderTextPage? = null
try {
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
@ -2013,8 +2010,8 @@ internal fun PdfPageComposable(
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
val currentRange = selectionCharRange.value!!
coroutineScope.launch {
var pageForMenu: PdfPageKt? = null
var textPageForMenu: PdfTextPageKt? = null
var pageForMenu: ReaderPage? = null
var textPageForMenu: ReaderTextPage? = null
try {
val text = withContext(Dispatchers.IO) {
pageForMenu = pdfDocumentItem.openPage(pdfPageIndex)
@ -2125,8 +2122,8 @@ internal fun PdfPageComposable(
showMagnifier = false
coroutineScope.launch {
var tempPage: PdfPageKt? = null
var tempTextPage: PdfTextPageKt? = null
var tempPage: ReaderPage? = null
var tempTextPage: ReaderTextPage? = null
var ocrAttemptedForThisPress = false
try {
if (!isPdfPage) return@launch
@ -2420,7 +2417,7 @@ internal fun PdfPageComposable(
val wasHandled = withContext(Dispatchers.IO) {
try {
pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
val pagePtr = getNativePointer(page)
val pagePtr = page.getNativePointer()
if (pagePtr == 0L) {
Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex")
@ -3337,8 +3334,8 @@ internal fun PdfPageComposable(
val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
val rotation = page.getPageRotation()
val screenDpi = (density.density * 160).roundToInt()
val originalWidthPdfUnits = page.getPageWidth(screenDpi)
val originalHeightPdfUnits = page.getPageHeight(screenDpi)
val originalWidthPdfUnits = page.getPageWidthPoint()
val originalHeightPdfUnits = page.getPageHeightPoint()
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
page.close()
@ -3678,8 +3675,8 @@ internal fun PdfPageComposable(
if (!isPdfPage) return@launch
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
var page: PdfPageKt? = null
var textPage: PdfTextPageKt? = null
var page: ReaderPage? = null
var textPage: ReaderTextPage? = null
try {
val charCount = withContext(Dispatchers.IO) {
page = pdfDocumentItem.openPage(pdfPageIndex)

View file

@ -176,7 +176,7 @@ private data class DividerLayout(val y: Float, val width: Float, val height: Flo
@Composable
internal fun PdfVerticalReader(
state: VerticalPdfReaderState,
pdfDocument: StableHolder<PdfDocumentKt>,
pdfDocument: StableHolder<ReaderDocument>,
isDarkMode: Boolean,
totalPages: Int,
modifier: Modifier = Modifier,

View file

@ -256,6 +256,7 @@ import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.FileType
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.SearchResult
@ -289,7 +290,6 @@ import com.aryan.reader.tts.splitTextIntoChunks
import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfPageKt
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -972,9 +972,9 @@ private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitmap? {
private suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bitmap? {
return withContext(Dispatchers.IO) {
var page: PdfPageKt? = null
var page: ReaderPage? = null
try {
page = doc.openPage(pageIndex)
if (page == null) return@withContext null
@ -1425,7 +1425,7 @@ fun PdfViewerScreen(
)
else null
}
var pdfDocument by remember { mutableStateOf<PdfDocumentKt?>(null) }
var pdfDocument by remember { mutableStateOf<ReaderDocument?>(null) }
var pfdState by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
var totalPages by remember { mutableIntStateOf(0) }
var currentPageScale by remember { mutableFloatStateOf(1f) }
@ -2276,10 +2276,13 @@ fun PdfViewerScreen(
if (extractedText.isBlank() && currentBookId != null && pdfDocument != null) {
Timber.d("Extracted text is blank. Attempting repository/OCR fallback...")
try {
extractedText = pdfTextRepository.getOrExtractText(
currentBookId!!, pdfDocument!!, pageIndex
)
Timber.d("Repository: Extracted text length: ${extractedText.length}")
val pdfDocKt = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument
if (pdfDocKt != null) {
extractedText = pdfTextRepository.getOrExtractText(
currentBookId!!, pdfDocKt, pageIndex
)
Timber.d("Repository: Extracted text length: ${extractedText.length}")
}
} catch (e: Exception) {
Timber.w(e, "Bookmark: Repository extraction failed")
}
@ -2571,11 +2574,12 @@ fun PdfViewerScreen(
val onGetOcrSearchRectsStable = remember(pdfTextRepository, pdfDocument) {
val callback: suspend (Int, String) -> List<RectF> = { page, query ->
if (pdfDocument != null) {
val hasNative = pdfTextRepository.hasNativeText(pdfDocument!!, page)
val pdfDocKt = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument
if (pdfDocKt != null) {
val hasNative = pdfTextRepository.hasNativeText(pdfDocKt, page)
if (!hasNative) {
pdfTextRepository.getOcrSearchRects(
document = pdfDocument!!,
document = pdfDocKt,
pageIndex = page,
query = query,
onModelDownloading = { isOcrModelDownloading = true })
@ -2759,9 +2763,9 @@ fun PdfViewerScreen(
coroutineScope.launch {
val pageToRead = pageToReadOverride ?: currentPage
var rawPageText: String? = null
var tempPage: PdfPageKt? = null
var tempTextPage: PdfTextPageKt? = null
var ocrAttempted = false
var tempPage: ReaderPage? = null
var tempTextPage: ReaderTextPage? = null
@Suppress("CanBeVal") var ocrAttempted = false
try {
withContext(Dispatchers.IO) {
@ -2816,8 +2820,8 @@ fun PdfViewerScreen(
val chunks = splitTextIntoChunks(textToChunk)
val bookTitle = pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
?: pdfUri.lastPathSegment ?: "PDF Document"
val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
?: pdfUri.lastPathSegment ?: "Document"
val pageTitle = "Page ${pageToRead + 1}"
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
@ -3035,7 +3039,7 @@ fun PdfViewerScreen(
currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r")
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
val doc = pdfiumCore.newDocument(currentPfdOpened, documentPassword)
val doc = DocumentFactory.loadDocument(context, pdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore)
if (!isActive) {
doc.close()
@ -3049,7 +3053,7 @@ fun PdfViewerScreen(
if (pagesCount > 0) {
try {
val tableOfContents = doc.getFixedTableOfContents()
val tableOfContents = doc.getTableOfContents()
val flattened = flattenToc(tableOfContents)
withContext(Dispatchers.Main) { flatTableOfContents = flattened }
} catch (e: Exception) {
@ -3264,8 +3268,8 @@ fun PdfViewerScreen(
LaunchedEffect(pdfUri, currentBookId, totalPages) {
if (currentBookId == null || totalPages == 0) return@LaunchedEffect
if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect
if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect
withContext(Dispatchers.IO) {
val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!)
@ -4110,9 +4114,10 @@ fun PdfViewerScreen(
Timber.d(
"LaunchedEffect triggered for Page $pageIndex. Checking Native..."
)
val hasNative = pdfTextRepository.hasNativeText(
pdfDocument!!, pageIndex
)
val pdfDocKt = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument
val hasNative = if (pdfDocKt != null) pdfTextRepository.hasNativeText(
pdfDocKt, pageIndex
) else false
Timber.d(
"Page $pageIndex Has Native Text: $hasNative"
)
@ -4121,13 +4126,13 @@ fun PdfViewerScreen(
Timber.d(
"Fetching OCR rects for query: '${target.query}'"
)
val rects = pdfTextRepository.getOcrSearchRects(
document = pdfDocument!!,
val rects = if (pdfDocKt != null) pdfTextRepository.getOcrSearchRects(
document = pdfDocKt,
pageIndex = pageIndex,
query = target.query,
onModelDownloading = {
isOcrModelDownloading = true
})
}) else emptyList()
Timber.d(
"Received ${rects.size} rects from Repository."
)
@ -5533,31 +5538,32 @@ fun PdfViewerScreen(
Icons.Default.Share, contentDescription = null
)
})
DropdownMenuItem(
text = { Text("Save copy to device") },
onClick = {
showMoreMenu = false
showSaveDialog = true
},
leadingIcon = {
Icon(
Icons.Default.Save, contentDescription = null
)
})
DropdownMenuItem(
text = { Text("Print") },
onClick = {
if (uiState.selectedFileType == FileType.PDF) {
DropdownMenuItem(
text = { Text("Save copy to device") },
onClick = {
showMoreMenu = false
showSaveDialog = true
},
leadingIcon = {
Icon(
Icons.Default.Save,
contentDescription = null
)
})
}
if (uiState.selectedFileType == FileType.PDF) {
DropdownMenuItem(text = { Text("Print") }, onClick = {
showMoreMenu = false
onPrintDocument()
},
leadingIcon = {
}, leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.print),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
})
}
}
}
}

View file

@ -0,0 +1,356 @@
// UniversalDocument.kt
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PointF
import android.graphics.Rect
import android.graphics.RectF
import android.net.Uri
import android.os.Build
import com.aryan.reader.FileType
import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import io.legere.pdfiumandroid.suspend.PdfPageKt
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import me.zhanghai.android.libarchive.Archive
import me.zhanghai.android.libarchive.ArchiveEntry
import me.zhanghai.android.libarchive.ArchiveException
import timber.log.Timber
import java.util.UUID
import java.util.zip.ZipFile
interface ReaderDocument : AutoCloseable {
suspend fun getPageCount(): Int
suspend fun openPage(pageIndex: Int): ReaderPage?
suspend fun getTableOfContents(): List<Bookmark>
}
interface ReaderPage : AutoCloseable {
suspend fun getPageWidthPoint(): Int
suspend fun getPageHeightPoint(): Int
suspend fun getPageRotation(): Int
suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean)
suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF): Rect
suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int): PointF
suspend fun openTextPage(): ReaderTextPage
suspend fun getLinks(): List<ReaderLink>
fun getNativePointer(): Long
}
interface ReaderTextPage : AutoCloseable {
suspend fun textPageCountChars(): Int
suspend fun textPageGetText(startIndex: Int, count: Int): String?
suspend fun textPageGetRectsForRanges(ranges: IntArray): List<ReaderTextRect>?
suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double): Int
suspend fun textPageGetCharBox(index: Int): RectF?
suspend fun textPageGetUnicode(index: Int): Int
suspend fun loadWebLink(): ReaderWebLinks?
}
data class ReaderLink(val uri: String?, val destPageIdx: Int?, val bounds: RectF)
data class ReaderTextRect(val rect: RectF)
interface ReaderWebLinks : AutoCloseable {
suspend fun countWebLinks(): Int
suspend fun getURL(linkIndex: Int, maxLength: Int): String?
suspend fun countRects(linkIndex: Int): Int
suspend fun getRect(linkIndex: Int, rectIndex: Int): RectF
}
object DocumentFactory {
suspend fun loadDocument(context: Context, uri: Uri, type: FileType, password: String?, pdfiumCore: PdfiumCoreKt): ReaderDocument {
return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { input ->
cacheFile.outputStream().use { output -> input.copyTo(output) }
}
}
ArchiveDocumentWrapper(cacheFile)
} else {
val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF")
PdfDocumentWrapper(pdfiumCore.newDocument(pfd, password))
}
}
}
// ================= PDF IMPLEMENTATION =================
class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
override suspend fun getPageCount() = pdfDocument.getPageCount()
override suspend fun openPage(pageIndex: Int): ReaderPage? {
val page = pdfDocument.openPage(pageIndex) ?: return null
return PdfPageWrapper(page)
}
override suspend fun getTableOfContents() = pdfDocument.getFixedTableOfContents()
override fun close() { pdfDocument.close() }
}
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
override suspend fun getPageWidthPoint() = pdfPage.getPageWidthPoint()
override suspend fun getPageHeightPoint() = pdfPage.getPageHeightPoint()
override suspend fun getPageRotation() = pdfPage.getPageRotation()
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
}
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) =
pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
override suspend fun openTextPage(): ReaderTextPage = PdfTextPageWrapper(pdfPage.openTextPage())
override suspend fun getLinks(): List<ReaderLink> {
return pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
}
override fun getNativePointer(): Long {
return try {
val field = pdfPage.javaClass.getDeclaredField("mNativePagePtr")
field.isAccessible = true
field.get(pdfPage) as? Long ?: 0L
} catch (_: Exception) {
0L
}
}
override fun close() { pdfPage.close() }
}
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
override suspend fun textPageCountChars() = textPage.textPageCountChars()
override suspend fun textPageGetText(startIndex: Int, count: Int) = textPage.textPageGetText(startIndex, count)
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
override suspend fun textPageGetCharBox(index: Int) = textPage.textPageGetCharBox(index)
override suspend fun textPageGetUnicode(index: Int): Int {
return textPage.textPageGetUnicode(index).code
}
override suspend fun loadWebLink(): ReaderWebLinks? {
val links = textPage.loadWebLink() ?: return null
return object : ReaderWebLinks {
override suspend fun countWebLinks() = links.countWebLinks()
override suspend fun getURL(linkIndex: Int, maxLength: Int) = links.getURL(linkIndex, maxLength)
override suspend fun countRects(linkIndex: Int) = links.countRects(linkIndex)
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = links.getRect(linkIndex, rectIndex)
override fun close() { links.close() }
}
}
override fun close() { textPage.close() }
}
// ================= CBZ, CBR, CB7 IMPLEMENTATION =================
class DummyTextPage : ReaderTextPage {
override suspend fun textPageCountChars() = 0
override suspend fun textPageGetText(startIndex: Int, count: Int) = null
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = null
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = -1
override suspend fun textPageGetCharBox(index: Int) = null
override suspend fun textPageGetUnicode(index: Int) = 0
override suspend fun loadWebLink() = null
override fun close() {}
}
class ArchiveDocumentWrapper(private val file: File) : ReaderDocument {
private val imageEntries = mutableListOf<String>()
private var zipFile: ZipFile? = null
private var extractedDir: File? = null
init {
// Try reading as ZIP first for instant O(1) random access (Handles .cbz efficiently)
try {
val zf = ZipFile(file)
val entries = zf.entries()
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (!entry.isDirectory && entry.name.matches(Regex(".*\\.(jpg|jpeg|png|webp|bmp)$", RegexOption.IGNORE_CASE))) {
imageEntries.add(entry.name)
}
}
if (imageEntries.isNotEmpty()) {
zipFile = zf
imageEntries.sort()
} else {
zf.close()
}
} catch (_: Exception) {
zipFile = null
}
if (zipFile == null) {
imageEntries.clear()
extractedDir = File(file.parentFile, "extracted_${file.name}_${System.currentTimeMillis()}")
extractedDir?.mkdirs()
var archive = 0L
try {
archive = Archive.readNew()
Archive.readSupportFilterAll(archive)
Archive.readSupportFormatAll(archive)
Archive.readOpenFileName(archive, file.absolutePath.toByteArray(), 10240)
val tempEntries = mutableListOf<Pair<String, File>>()
while (true) {
val entry = try {
Archive.readNextHeader(archive)
} catch (e: ArchiveException) {
if (e.code == Archive.ERRNO_EOF) break
throw e
}
if (entry == 0L) break
val path = ArchiveEntry.pathnameUtf8(entry)
if (path != null && path.matches(Regex(".*\\.(jpg|jpeg|png|webp|bmp)$", RegexOption.IGNORE_CASE))) {
val extractedFile = File(extractedDir, UUID.randomUUID().toString() + ".img")
tempEntries.add(Pair(path, extractedFile))
var pfd: android.os.ParcelFileDescriptor? = null
try {
// Extract seamlessly using fd to avoid ByteBuffer's state sync bug
pfd = android.os.ParcelFileDescriptor.open(extractedFile, android.os.ParcelFileDescriptor.MODE_READ_WRITE or android.os.ParcelFileDescriptor.MODE_CREATE)
Archive.readDataIntoFd(archive, pfd.fd)
} finally {
pfd?.close()
}
} else {
Archive.readDataSkip(archive)
}
}
tempEntries.sortBy { it.first } // Natural sorting order based on the filename inside the archive
tempEntries.forEach { imageEntries.add(it.second.absolutePath) }
} catch (e: Exception) {
Timber.e(e, "Failed to extract archive entries")
} finally {
if (archive != 0L) Archive.readFree(archive)
}
}
}
override suspend fun getPageCount() = imageEntries.size
override suspend fun openPage(pageIndex: Int): ReaderPage? = withContext(Dispatchers.IO) {
if (pageIndex !in imageEntries.indices) return@withContext null
val targetPath = imageEntries[pageIndex]
var imageBytes: ByteArray? = null
if (zipFile != null) {
try {
val entry = zipFile!!.getEntry(targetPath)
if (entry != null) {
zipFile!!.getInputStream(entry).use { imageBytes = it.readBytes() }
}
} catch (e: Exception) {
Timber.e(e, "Failed to extract page from ZIP")
}
} else {
try {
val extractedFile = File(targetPath)
if (extractedFile.exists()) {
imageBytes = extractedFile.readBytes()
}
} catch (e: Exception) {
Timber.e(e, "Failed to read extracted page")
}
}
if (imageBytes != null && imageBytes!!.isNotEmpty()) ArchivePageWrapper(imageBytes!!) else null
}
override suspend fun getTableOfContents() = emptyList<Bookmark>()
override fun close() {
try { zipFile?.close() } catch (_: Exception) {}
try { extractedDir?.deleteRecursively() } catch (_: Exception) {}
try { file.delete() } catch (_: Exception) {}
}
}
class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
private val decoder = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(imageBytes, 0, imageBytes.size)
} else {
@Suppress("DEPRECATION")
BitmapRegionDecoder.newInstance(imageBytes, 0, imageBytes.size, false)
}
} catch (_: Exception) {
null
}
private val originalWidth = decoder?.width ?: 1
private val originalHeight = decoder?.height ?: 1
override suspend fun getPageWidthPoint() = originalWidth
override suspend fun getPageHeightPoint() = originalHeight
override suspend fun getPageRotation() = 0
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
if (decoder == null || decoder.isRecycled) return
val scaleX = drawSizeX.toFloat() / originalWidth
val scaleY = drawSizeY.toFloat() / originalHeight
val srcLeft = (-startX / scaleX).toInt().coerceAtLeast(0)
val srcTop = (-startY / scaleY).toInt().coerceAtLeast(0)
val srcRight = (srcLeft + (bitmap.width / scaleX).toInt()).coerceAtMost(originalWidth)
val srcBottom = (srcTop + (bitmap.height / scaleY).toInt()).coerceAtMost(originalHeight)
val rect = Rect(srcLeft, srcTop, srcRight, srcBottom)
if (rect.width() <= 0 || rect.height() <= 0) return
val options = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
val region = try {
decoder.decodeRegion(rect, options)
} catch (_: Exception) {
null
}
if (region != null) {
val canvas = Canvas(bitmap)
val destRect = Rect(0, 0, bitmap.width, bitmap.height)
canvas.drawBitmap(region, null, destRect, Paint(Paint.FILTER_BITMAP_FLAG))
region.recycle()
}
}
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF): Rect {
val scaleX = sizeX.toFloat() / originalWidth
val scaleY = sizeY.toFloat() / originalHeight
return Rect(
(startX + coords.left * scaleX).toInt(),
(startY + coords.top * scaleY).toInt(),
(startX + coords.right * scaleX).toInt(),
(startY + coords.bottom * scaleY).toInt()
)
}
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int): PointF {
val scaleX = sizeX.toFloat() / originalWidth
val scaleY = sizeY.toFloat() / originalHeight
return PointF((deviceX - startX) / scaleX, (deviceY - startY) / scaleY)
}
override suspend fun openTextPage() = DummyTextPage()
override suspend fun getLinks() = emptyList<ReaderLink>()
override fun getNativePointer() = 0L
override fun close() {
decoder?.recycle()
}
}