From 4db2c97a30edacf9af4c2f2835fc194834c84023 Mon Sep 17 00:00:00 2001 From: Aryan Date: Thu, 26 Mar 2026 12:27:56 +0530 Subject: [PATCH] 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. --- app/build.gradle.kts | 11 +- app/src/main/AndroidManifest.xml | 31 ++ .../java/com/aryan/reader/AppNavigation.kt | 4 +- .../main/java/com/aryan/reader/HomeScreen.kt | 4 +- .../java/com/aryan/reader/LibraryScreen.kt | 2 +- .../java/com/aryan/reader/MainViewModel.kt | 123 +++++- .../java/com/aryan/reader/epub/Fb2Parser.kt | 282 ++++++++++++++ .../java/com/aryan/reader/pdf/PdfHelper.kt | 8 +- .../com/aryan/reader/pdf/PdfPageComposable.kt | 49 ++- .../com/aryan/reader/pdf/PdfVerticalReader.kt | 2 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 92 ++--- .../com/aryan/reader/pdf/UniversalDocument.kt | 356 ++++++++++++++++++ 12 files changed, 871 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt create mode 100644 app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9cf647a..5ff3d3d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,6 +17,10 @@ if (localPropertiesFile.exists()) { localPropertiesFile.inputStream().use { localProperties.load(it) } } +kotlin { + jvmToolchain(21) +} + android { namespace = "com.aryan.reader" compileSdk = 36 @@ -102,11 +106,11 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = "17" + jvmTarget = "21" } buildFeatures { compose = true @@ -196,6 +200,7 @@ dependencies { implementation("com.jakewharton.timber:timber:5.0.1") implementation("com.tom-roush:pdfbox-android:2.0.27.0") + implementation("me.zhanghai.android.libarchive:library:1.1.6") implementation("androidx.paging:paging-runtime-ktx:3.3.6") implementation("androidx.paging:paging-compose:3.3.6") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ecd4ad6..da02503 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -90,6 +90,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index c3c7f04..ac6e093 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -73,7 +73,7 @@ fun AppNavigation( LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { if (!uiState.isLoading) { when (uiState.selectedFileType) { - FileType.PDF -> { + FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { if (uiState.selectedPdfUri != null) { if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) { navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) { @@ -82,7 +82,7 @@ fun AppNavigation( } } } - FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> { + FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2 -> { if (uiState.selectedEpubBook != null) { if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { navController.navigate(AppDestinations.EPUB_READER_ROUTE) { diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 44e2a02..e56b1dc 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -607,7 +607,7 @@ fun RecentFileCard( val context = LocalContext.current val placeholder = when (item.type) { FileType.PDF -> R.drawable.pdf_placeholder - FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> R.drawable.epub_placeholder + FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7 -> R.drawable.epub_placeholder } val imageModel = remember(item.coverImagePath) { item.coverImagePath?.let { File(it) } ?: placeholder @@ -706,7 +706,7 @@ fun RecentFileCard( verticalArrangement = Arrangement.Center ) { Text( - text = item.customName ?: if ((item.type == FileType.EPUB || item.type == FileType.MOBI) && !item.title.isNullOrBlank()) { + text = item.customName ?: if ((item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.FB2) && !item.title.isNullOrBlank()) { item.title } else { item.displayName diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 9317bb5..68fc298 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -1251,7 +1251,7 @@ private fun LibraryListItem( val context = LocalContext.current val placeholder = when (item.type) { FileType.PDF -> R.drawable.pdf_placeholder - FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> R.drawable.epub_placeholder + FileType.EPUB, FileType.MOBI, FileType.FB2, FileType.MD, FileType.TXT, FileType.HTML, FileType.CBZ, FileType.CBR, FileType.CB7 -> R.drawable.epub_placeholder } val imageModel = remember(item.coverImagePath) { item.coverImagePath?.let { File(it) } ?: placeholder diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 77a36f8..515f02e 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -111,6 +111,7 @@ import java.util.Date import java.util.UUID import java.util.concurrent.CancellationException import java.util.concurrent.TimeUnit +import androidx.core.graphics.createBitmap private const val KEY_RENDER_MODE = "render_mode" private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" @@ -134,7 +135,7 @@ enum class AddBooksSource(val displayName: String) { } enum class FileType { - PDF, EPUB, MOBI, MD, TXT, HTML + PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7 } enum class RenderMode { @@ -239,6 +240,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val bookCacheDao = BookCacheDatabase.getDatabase(application).bookCacheDao() private val epubParser = EpubParser(appContext) private val mobiParser = MobiParser(appContext) + private val fb2Parser = com.aryan.reader.epub.Fb2Parser(appContext) private val singleFileImporter = SingleFileImporter(appContext) private val bookImporter = BookImporter(appContext) private val prefs: SharedPreferences = @@ -2327,7 +2329,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio var author: String? = null var bookForMetadata = epubBook - if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) { + if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) { Timber.d("Parsing downloaded book for cover/metadata: $displayName") Timber.tag("FileOpenPerf") .d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)") @@ -2352,6 +2354,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } + FileType.FB2 -> { + fb2Parser.createFb2Book( + inputStream = inputStream, + originalBookNameHint = displayName + ) + } + else -> { singleFileImporter.importSingleFile( inputStream, @@ -2379,7 +2388,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val finalBookMetadata = bookForMetadata - if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) { + if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) { title = finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName @@ -2390,12 +2399,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio finalBookMetadata.coverImage?.let { cover -> coverPath = recentFilesRepository.saveCoverToCache(cover, uri) } - } else if (type == FileType.PDF) { + } else if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { title = displayName - val pdfCoverGenerator = PdfCoverGenerator(appContext) - val coverBitmap = pdfCoverGenerator.generateCover(uri) - if (coverBitmap != null) { - coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) + + if (type == FileType.PDF) { + val pdfCoverGenerator = PdfCoverGenerator(appContext) + val coverBitmap = pdfCoverGenerator.generateCover(uri) + if (coverBitmap != null) { + coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) + } + } else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { + try { + val cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}") + withContext(Dispatchers.IO) { + appContext.contentResolver.openInputStream(uri)?.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } + } + val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile) + if (archiveDoc.getPageCount() > 0) { + val page = archiveDoc.openPage(0) + if (page != null) { + val w = page.getPageWidthPoint() + val h = page.getPageHeightPoint() + if (w > 0 && h > 0) { + val targetHeight = 800 + val targetWidth = (targetHeight * (w.toFloat() / h.toFloat())).toInt() + if (targetWidth > 0) { + val bitmap = createBitmap(targetWidth, targetHeight) + page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false) + coverPath = recentFilesRepository.saveCoverToCache(bitmap, uri) + } + } + page.close() + } + } + archiveDoc.close() + } catch (e: Exception) { + Timber.e(e, "Error generating CBZ cover") + } } } @@ -2553,7 +2595,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val type = item.type val bookId = item.bookId - if (type == FileType.PDF) { + if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { _internalState.update { it.copy( selectedEpubUri = null, @@ -2750,7 +2792,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) } - if (type == FileType.PDF) { + if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { viewModelScope.launch { val recentItem = recentFilesRepository.getFileByBookId(bookId) @@ -2779,7 +2821,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio sourceFolderUri = null ) } - } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) { + } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) { viewModelScope.launch { val recentItem = recentFilesRepository.getFileByBookId(bookId) if (recentItem?.sourceFolderUri != null) { @@ -2818,6 +2860,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio loadMobi(uri, bookId, customDisplayName = originalDisplayName) } + FileType.FB2 -> { + loadFb2(uri, bookId, customDisplayName = originalDisplayName) + } + else -> { loadSingleFile( uri, bookId, type, customDisplayName = originalDisplayName @@ -2829,6 +2875,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null) { + val loadStart = System.currentTimeMillis() + Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START") + viewModelScope.launch { + if (!_internalState.value.isLoading) { + _internalState.update { it.copy(isLoading = true, errorMessage = null) } + } + Timber.d("Starting FB2 parsing for URI: $uri") + try { + val fb2Book = withContext(Dispatchers.IO) { + appContext.contentResolver.openInputStream(uri).use { inputStream -> + if (inputStream == null) throw Exception("Could not open input stream") + fb2Parser.createFb2Book( + inputStream, + originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.fb2" + ) + } + } + Timber.i("FB2 parsing successful. Title: ${fb2Book.title}") + Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms") + + addFileToRecent( + uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null + ) + + _internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) } + } catch (e: Exception) { + Timber.e(e, "Error parsing FB2 for URI: $uri") + _internalState.update { + it.copy(errorMessage = "Failed to load FB2: ${e.message}", isLoading = false) + } + } + } + } + private fun loadSingleFile( uri: Uri, bookId: String, @@ -2896,8 +2977,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName") return when (mimeType) { + "application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> { + if (fileName?.endsWith(".cbz", ignoreCase = true) == true) FileType.CBZ else null + } + "application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> { + if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null + } + "application/x-cb7", "application/x-7z-compressed" -> { + if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null + } "application/pdf" -> FileType.PDF "application/epub+zip" -> FileType.EPUB + "application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2 "application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI "text/markdown", "text/x-markdown" -> FileType.MD "text/html", "application/xhtml+xml" -> FileType.HTML @@ -2915,6 +3006,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio else -> { when { + fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ + fileName?.endsWith(".cbr", ignoreCase = true) == true -> FileType.CBR + fileName?.endsWith(".cb7", ignoreCase = true) == true -> FileType.CB7 fileName?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB fileName?.endsWith( @@ -2937,6 +3031,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) == true -> FileType.MD fileName?.endsWith(".txt", ignoreCase = true) == true -> FileType.TXT + fileName?.endsWith( + ".fb2", + ignoreCase = true + ) == true || fileName?.endsWith( + ".fb2.zip", + ignoreCase = true + ) == true -> FileType.FB2 fileName?.endsWith( ".html", ignoreCase = true diff --git a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt new file mode 100644 index 0000000..8c9b4d6 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt @@ -0,0 +1,282 @@ +package com.aryan.reader.epub + +import android.content.Context +import android.graphics.BitmapFactory +import android.util.Base64 +import android.util.Xml +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jsoup.Jsoup +import org.xmlpull.v1.XmlPullParser +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.util.zip.ZipInputStream + +class Fb2Parser(private val context: Context) { + + suspend fun createFb2Book( + inputStream: InputStream, + originalBookNameHint: String + ): EpubBook { + val bookId = originalBookNameHint.hashCode().toString() + val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply { + if (!exists()) mkdirs() + } + + // Seamless ZIP extraction for .fb2.zip extensions + var streamToParse = inputStream + if (originalBookNameHint.endsWith(".zip", ignoreCase = true)) { + val zis = ZipInputStream(inputStream) + var entry = zis.nextEntry + while (entry != null) { + if (entry.name.endsWith(".fb2", ignoreCase = true)) { + break + } + entry = zis.nextEntry + } + if (entry != null) { + streamToParse = zis + } else { + throw Exception("No .fb2 file found inside the ZIP archive.") + } + } + + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(streamToParse, null) + + var title = originalBookNameHint.substringBeforeLast(".") + var author = "Unknown" + var coverImageId: String? = null + var coverBytes: ByteArray? = null + + val chapters = mutableListOf() + val images = mutableListOf() // Keep track of extracted images + + var currentChapterHtml = StringBuilder() + var currentChapterTitle = "Chapter" + var chapterCount = 0 + var inSection = false + var inBody = false + var inTitle = false + var skipElement = false + val titleBuilder = java.lang.StringBuilder() // Buffer to handle

tags inside + + val cssStyle = """ + body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; } + p { margin-bottom: 1em; text-indent: 1.5em; text-align: justify; } + h1, h2, h3, h4 { text-align: center; margin-top: 1.5em; margin-bottom: 1em; } + .empty-line { height: 1.5em; } + img { max-width: 100%; height: auto; display: block; margin: 1em auto; } + .epigraph { margin-left: 2em; font-style: italic; margin-bottom: 1.5em; } + """.trimIndent() + + fun saveChapter() { + if (currentChapterHtml.isEmpty()) return + chapterCount++ + val fileName = "chapter_$chapterCount.html" + val file = File(extractionDir, fileName) + + val fullHtml = """ + <!DOCTYPE html> + <html> + <head> + <title>${currentChapterTitle.replace("\"", """)} + + + + $currentChapterHtml + + + """.trimIndent() + + FileOutputStream(file).use { it.write(fullHtml.toByteArray()) } + val plainText = Jsoup.parse(fullHtml).text() + + chapters.add( + EpubChapter( + chapterId = "${bookId}_${chapterCount}", + absPath = fileName, + title = currentChapterTitle, + htmlFilePath = fileName, + plainTextContent = plainText, + htmlContent = "", + depth = 0, + isInToc = true + ) + ) + currentChapterHtml.clear() + currentChapterTitle = "Chapter ${chapterCount + 1}" + } + + var eventType = parser.eventType + + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_TAG -> { + val name = parser.name.lowercase() + when (name) { + "book-title" -> { + title = parser.nextText().trim() + } + "first-name", "last-name", "middle-name" -> { + val namePart = parser.nextText().trim() + if (namePart.isNotBlank()) { + if (author == "Unknown") author = namePart else author += " $namePart" + } + } + "body" -> { + val nameAttr = parser.getAttributeValue(null, "name") + if (nameAttr == "notes" || nameAttr == "comments") { + skipElement = true + } else { + inBody = true + } + } + "section" -> { + if (inBody && !skipElement) { + if (currentChapterHtml.isNotBlank()) { + saveChapter() + } + inSection = true + } + } + "title" -> { + if (inSection && currentChapterHtml.isEmpty()) { + inTitle = true + titleBuilder.clear() + } + currentChapterHtml.append("

") + } + "p" -> if (!inTitle) currentChapterHtml.append("

") + "v" -> if (!inTitle) currentChapterHtml.append("

") + "subtitle" -> currentChapterHtml.append("

") + "empty-line" -> currentChapterHtml.append("
") + "strong" -> currentChapterHtml.append("") + "emphasis" -> currentChapterHtml.append("") + "strikethrough" -> currentChapterHtml.append("") + "sup" -> currentChapterHtml.append("") + "sub" -> currentChapterHtml.append("") + "epigraph" -> currentChapterHtml.append("
") + "image" -> { + // Safely extract href checking all possible namespace stripped versions + val href = parser.getAttributeValue(null, "l:href") + ?: parser.getAttributeValue(null, "xlink:href") + ?: parser.getAttributeValue("http://www.w3.org/1999/xlink", "href") + ?: parser.getAttributeValue(null, "href") + + if (href != null) { + val id = href.removePrefix("#") + if (!inBody) { + coverImageId = id + } else { + currentChapterHtml.append("") + } + } + } + "binary" -> { + val id = parser.getAttributeValue(null, "id") + if (id != null) { + val base64Data = parser.nextText() + try { + val bytes = Base64.decode(base64Data, Base64.DEFAULT) + val imgFile = File(extractionDir, id) + withContext(Dispatchers.IO) { + FileOutputStream(imgFile).use { it.write(bytes) } + } + + // Add the image to the EpubBook image index + images.add(EpubImage(absPath = id)) + + if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) { + coverBytes = bytes + coverImageId = id + } + } catch (e: Exception) { + Timber.e(e, "Failed to decode binary image $id") + } + } + } + } + } + XmlPullParser.TEXT -> { + val text = parser.text?.replace("&", "&")?.replace("<", "<")?.replace(">", ">") + if (!text.isNullOrBlank()) { + if (inTitle) { + titleBuilder.append(text) // Append to buffer since it could be split by

tags + currentChapterHtml.append(text) + } else if (inBody && !skipElement) { + currentChapterHtml.append(text) + } + } + } + XmlPullParser.END_TAG -> { + val name = parser.name.lowercase() + when (name) { + "body" -> { + skipElement = false + inBody = false + } + "title" -> { + if (inTitle) { + currentChapterTitle = titleBuilder.toString().trim() + inTitle = false + } + currentChapterHtml.append("

\n") + } + "p", "v" -> if (!inTitle) currentChapterHtml.append("

\n") + "subtitle" -> currentChapterHtml.append("\n") + "strong" -> currentChapterHtml.append("
") + "emphasis" -> currentChapterHtml.append("") + "strikethrough" -> currentChapterHtml.append("") + "sup" -> currentChapterHtml.append("") + "sub" -> currentChapterHtml.append("") + "epigraph" -> currentChapterHtml.append("\n") + } + } + } + + // Calling nextText() moves the parser directly to END_TAG. + // We ensure we don't accidentally read past the EOF. + if (eventType != XmlPullParser.END_DOCUMENT) { + eventType = parser.next() + } + } + + saveChapter() // Save the final chunk of content + + if (chapters.isEmpty()) { + if (currentChapterHtml.isNotBlank()) { + saveChapter() + } else { + throw Exception("No valid content found in FB2 file.") + } + } + + val coverBitmap = coverBytes?.let { + try { + BitmapFactory.decodeByteArray(it, 0, it.size) + } catch (e: Exception) { + Timber.e(e, "Failed to decode cover bitmap for FB2") + null + } + } + + return EpubBook( + fileName = originalBookNameHint, + title = title, + author = author, + language = "en", + coverImage = coverBitmap, + chapters = chapters, + chaptersForPagination = chapters, + images = images, // Extracted images attached! + pageList = emptyList(), + tableOfContents = emptyList(), + extractionBasePath = extractionDir.absolutePath, + css = emptyMap() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index 845e5c2..01cdcab 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -143,19 +143,19 @@ internal object OcrHelper { } internal suspend fun findWordBoundaries( - textPage: PdfTextPageKt, + textPage: ReaderTextPage, initialCharIndex: Int, pageCharCount: Int ): Pair? { if (initialCharIndex !in 0.. 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 } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index 8c47d16..4c9f00d 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -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, + pdfDocument: StableHolder, pageIndex: Int, totalPages: Int, modifier: Modifier = Modifier, @@ -747,7 +744,7 @@ internal fun PdfPageComposable( var rects: List = 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?, 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(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) diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index 80557e0..1f15f47 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -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, + pdfDocument: StableHolder, isDarkMode: Boolean, totalPages: Int, modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index c4da76b..afaa385 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -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(null) } + var pdfDocument by remember { mutableStateOf(null) } var pfdState by remember { mutableStateOf(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 = { 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) ) - } - ) + }) + } } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt new file mode 100644 index 0000000..ef64deb --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt @@ -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 +} + +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 + fun getNativePointer(): Long +} + +interface ReaderTextPage : AutoCloseable { + suspend fun textPageCountChars(): Int + suspend fun textPageGetText(startIndex: Int, count: Int): String? + suspend fun textPageGetRectsForRanges(ranges: IntArray): List? + 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 { + 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() + 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>() + + 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() + + 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() + override fun getNativePointer() = 0L + + override fun close() { + decoder?.recycle() + } +} \ No newline at end of file