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:
parent
32e29dfc07
commit
4db2c97a30
12 changed files with 871 additions and 93 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
356
app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
Normal file
356
app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue