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

@ -17,6 +17,10 @@ if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { localProperties.load(it) } localPropertiesFile.inputStream().use { localProperties.load(it) }
} }
kotlin {
jvmToolchain(21)
}
android { android {
namespace = "com.aryan.reader" namespace = "com.aryan.reader"
compileSdk = 36 compileSdk = 36
@ -102,11 +106,11 @@ android {
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_21
} }
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "21"
} }
buildFeatures { buildFeatures {
compose = true compose = true
@ -196,6 +200,7 @@ dependencies {
implementation("com.jakewharton.timber:timber:5.0.1") implementation("com.jakewharton.timber:timber:5.0.1")
implementation("com.tom-roush:pdfbox-android:2.0.27.0") 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-runtime-ktx:3.3.6")
implementation("androidx.paging:paging-compose:3.3.6") implementation("androidx.paging:paging-compose:3.3.6")

View file

@ -90,6 +90,37 @@
<data android:pathPattern=".*\\.azw3" /> <data android:pathPattern=".*\\.azw3" />
</intent-filter> </intent-filter>
<!-- FB2 Filter 1: Catch by specific and common MIME types -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/x-fictionbook+xml" />
<data android:mimeType="application/x-fictionbook" />
<data android:mimeType="application/fb2+xml" />
<data android:mimeType="application/x-fb2" />
<data android:mimeType="application/fb2" />
<data android:mimeType="application/x-zip-compressed-fb2" />
<data android:mimeType="application/zip" />
</intent-filter>
<!-- FB2 Filter 2: Catch by Extension when MIME is unknown (octet-stream) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="text/xml" />
<data android:mimeType="text/plain" />
<data android:host="*" />
<data android:pathPattern=".*\\.fb2" />
<data android:pathPattern=".*\\.fb2\\.zip" />
</intent-filter>
<!-- Markdown and Plain Text Filter --> <!-- Markdown and Plain Text Filter -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View file

@ -73,7 +73,7 @@ fun AppNavigation(
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) { if (!uiState.isLoading) {
when (uiState.selectedFileType) { when (uiState.selectedFileType) {
FileType.PDF -> { FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
if (uiState.selectedPdfUri != null) { if (uiState.selectedPdfUri != null) {
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) { if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
navController.navigate(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 (uiState.selectedEpubBook != null) {
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
navController.navigate(AppDestinations.EPUB_READER_ROUTE) { navController.navigate(AppDestinations.EPUB_READER_ROUTE) {

View file

@ -607,7 +607,7 @@ fun RecentFileCard(
val context = LocalContext.current val context = LocalContext.current
val placeholder = when (item.type) { val placeholder = when (item.type) {
FileType.PDF -> R.drawable.pdf_placeholder 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) { val imageModel = remember(item.coverImagePath) {
item.coverImagePath?.let { File(it) } ?: placeholder item.coverImagePath?.let { File(it) } ?: placeholder
@ -706,7 +706,7 @@ fun RecentFileCard(
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( 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 item.title
} else { } else {
item.displayName item.displayName

View file

@ -1251,7 +1251,7 @@ private fun LibraryListItem(
val context = LocalContext.current val context = LocalContext.current
val placeholder = when (item.type) { val placeholder = when (item.type) {
FileType.PDF -> R.drawable.pdf_placeholder 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) { val imageModel = remember(item.coverImagePath) {
item.coverImagePath?.let { File(it) } ?: placeholder item.coverImagePath?.let { File(it) } ?: placeholder

View file

@ -111,6 +111,7 @@ import java.util.Date
import java.util.UUID import java.util.UUID
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import androidx.core.graphics.createBitmap
private const val KEY_RENDER_MODE = "render_mode" private const val KEY_RENDER_MODE = "render_mode"
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
@ -134,7 +135,7 @@ enum class AddBooksSource(val displayName: String) {
} }
enum class FileType { enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7
} }
enum class RenderMode { enum class RenderMode {
@ -239,6 +240,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val bookCacheDao = BookCacheDatabase.getDatabase(application).bookCacheDao() private val bookCacheDao = BookCacheDatabase.getDatabase(application).bookCacheDao()
private val epubParser = EpubParser(appContext) private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext) private val mobiParser = MobiParser(appContext)
private val fb2Parser = com.aryan.reader.epub.Fb2Parser(appContext)
private val singleFileImporter = SingleFileImporter(appContext) private val singleFileImporter = SingleFileImporter(appContext)
private val bookImporter = BookImporter(appContext) private val bookImporter = BookImporter(appContext)
private val prefs: SharedPreferences = private val prefs: SharedPreferences =
@ -2327,7 +2329,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
var author: String? = null var author: String? = null
var bookForMetadata = epubBook 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.d("Parsing downloaded book for cover/metadata: $displayName")
Timber.tag("FileOpenPerf") Timber.tag("FileOpenPerf")
.d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)") .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 -> { else -> {
singleFileImporter.importSingleFile( singleFileImporter.importSingleFile(
inputStream, inputStream,
@ -2379,7 +2388,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val finalBookMetadata = bookForMetadata 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 = title =
finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName
@ -2390,12 +2399,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
finalBookMetadata.coverImage?.let { cover -> finalBookMetadata.coverImage?.let { cover ->
coverPath = recentFilesRepository.saveCoverToCache(cover, uri) 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 title = displayName
val pdfCoverGenerator = PdfCoverGenerator(appContext)
val coverBitmap = pdfCoverGenerator.generateCover(uri) if (type == FileType.PDF) {
if (coverBitmap != null) { val pdfCoverGenerator = PdfCoverGenerator(appContext)
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) 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 type = item.type
val bookId = item.bookId val bookId = item.bookId
if (type == FileType.PDF) { if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
_internalState.update { _internalState.update {
it.copy( it.copy(
selectedEpubUri = null, 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 { viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId) val recentItem = recentFilesRepository.getFileByBookId(bookId)
@ -2779,7 +2821,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
sourceFolderUri = null 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 { viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId) val recentItem = recentFilesRepository.getFileByBookId(bookId)
if (recentItem?.sourceFolderUri != null) { if (recentItem?.sourceFolderUri != null) {
@ -2818,6 +2860,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
loadMobi(uri, bookId, customDisplayName = originalDisplayName) loadMobi(uri, bookId, customDisplayName = originalDisplayName)
} }
FileType.FB2 -> {
loadFb2(uri, bookId, customDisplayName = originalDisplayName)
}
else -> { else -> {
loadSingleFile( loadSingleFile(
uri, bookId, type, customDisplayName = originalDisplayName 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( private fun loadSingleFile(
uri: Uri, uri: Uri,
bookId: String, bookId: String,
@ -2896,8 +2977,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName") Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName")
return when (mimeType) { 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/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB "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 "application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
"text/markdown", "text/x-markdown" -> FileType.MD "text/markdown", "text/x-markdown" -> FileType.MD
"text/html", "application/xhtml+xml" -> FileType.HTML "text/html", "application/xhtml+xml" -> FileType.HTML
@ -2915,6 +3006,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
else -> { else -> {
when { 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(".pdf", ignoreCase = true) == true -> FileType.PDF
fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
fileName?.endsWith( fileName?.endsWith(
@ -2937,6 +3031,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) == true -> FileType.MD ) == true -> FileType.MD
fileName?.endsWith(".txt", ignoreCase = true) == true -> FileType.TXT 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( fileName?.endsWith(
".html", ".html",
ignoreCase = true ignoreCase = true

View file

@ -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<EpubChapter>()
val images = mutableListOf<EpubImage>() // 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 <p> tags inside <title>
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("\"", "&quot;")}</title>
<style>${cssStyle}</style>
</head>
<body>
$currentChapterHtml
</body>
</html>
""".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("<h2>")
}
"p" -> if (!inTitle) currentChapterHtml.append("<p>")
"v" -> if (!inTitle) currentChapterHtml.append("<p style='text-indent: 0;'>")
"subtitle" -> currentChapterHtml.append("<h3>")
"empty-line" -> currentChapterHtml.append("<div class='empty-line'></div>")
"strong" -> currentChapterHtml.append("<b>")
"emphasis" -> currentChapterHtml.append("<i>")
"strikethrough" -> currentChapterHtml.append("<s>")
"sup" -> currentChapterHtml.append("<sup>")
"sub" -> currentChapterHtml.append("<sub>")
"epigraph" -> currentChapterHtml.append("<div class='epigraph'>")
"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("<img src=\"$id\" />")
}
}
}
"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("&", "&amp;")?.replace("<", "&lt;")?.replace(">", "&gt;")
if (!text.isNullOrBlank()) {
if (inTitle) {
titleBuilder.append(text) // Append to buffer since it could be split by <p> 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("</h2>\n")
}
"p", "v" -> if (!inTitle) currentChapterHtml.append("</p>\n")
"subtitle" -> currentChapterHtml.append("</h3>\n")
"strong" -> currentChapterHtml.append("</b>")
"emphasis" -> currentChapterHtml.append("</i>")
"strikethrough" -> currentChapterHtml.append("</s>")
"sup" -> currentChapterHtml.append("</sup>")
"sub" -> currentChapterHtml.append("</sub>")
"epigraph" -> currentChapterHtml.append("</div>\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()
)
}
}

View file

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

View file

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

View file

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