Support more formats (#112)
* Added support for FB2 book format * Add support for CBZ files by introducing a `ReaderDocument` abstraction. Key changes: - Defined `ReaderDocument`, `ReaderPage`, and `ReaderTextPage` interfaces to provide a unified API for different document types. - Implemented `PdfDocumentWrapper` and `CbzDocumentWrapper` to handle PDF and CBZ files respectively. - Updated `PdfViewerScreen`, `PdfPageComposable`, and `MainViewModel` to use the new unified document interfaces. - Added CBZ file type detection and cover generation logic. * Add support for CBR and CB7 comic book formats - Add `me.zhanghai.android.libarchive` dependency to support RAR and 7z archives. - Implement `ArchiveDocumentWrapper` using `libarchive` to handle CBZ, CBR, and CB7 files uniformly, replacing the previous CBZ-only implementation. - Update `MainViewModel` and `DocumentFactory` to recognize and process `.cbr` and `.cb7` extensions and their associated MIME types. - Add support for extracting and caching cover images from CBR and CB7 archives. - Update UI components (`HomeScreen`, `LibraryScreen`, `AppNavigation`) to handle the new comic book file types.
This commit is contained in:
parent
32e29dfc07
commit
4db2c97a30
12 changed files with 871 additions and 93 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
282
app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt
Normal file
282
app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt
Normal 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("\"", """)}</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("&", "&")?.replace("<", "<")?.replace(">", ">")
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -143,19 +143,19 @@ internal object OcrHelper {
|
|||
}
|
||||
|
||||
internal suspend fun findWordBoundaries(
|
||||
textPage: PdfTextPageKt,
|
||||
textPage: ReaderTextPage,
|
||||
initialCharIndex: Int,
|
||||
pageCharCount: Int
|
||||
): Pair<Int, Int>? {
|
||||
if (initialCharIndex !in 0..<pageCharCount) return null
|
||||
val initialChar = textPage.textPageGetUnicode(initialCharIndex)
|
||||
val initialChar = textPage.textPageGetUnicode(initialCharIndex).toChar()
|
||||
if (!initialChar.isLetterOrDigit()) {
|
||||
Timber.d("Initial char '$initialChar' at index $initialCharIndex is not letter/digit.")
|
||||
return null
|
||||
}
|
||||
var wordStartIndex = initialCharIndex
|
||||
while (wordStartIndex > 0) {
|
||||
val char = textPage.textPageGetUnicode(wordStartIndex - 1)
|
||||
val char = textPage.textPageGetUnicode(wordStartIndex - 1).toChar()
|
||||
if (!char.isLetterOrDigit()) {
|
||||
break
|
||||
}
|
||||
|
|
@ -163,7 +163,7 @@ internal suspend fun findWordBoundaries(
|
|||
}
|
||||
var wordEndIndex = initialCharIndex
|
||||
while (wordEndIndex < pageCharCount) {
|
||||
val char = textPage.textPageGetUnicode(wordEndIndex)
|
||||
val char = textPage.textPageGetUnicode(wordEndIndex).toChar()
|
||||
if (!char.isLetterOrDigit()) {
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,9 +119,6 @@ import com.aryan.reader.pdf.data.PdfTextBox
|
|||
import com.aryan.reader.pdf.data.VirtualPage
|
||||
import com.aryan.reader.pdf.ocr.OcrElement
|
||||
import com.aryan.reader.pdf.ocr.OcrResult
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfPageKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
|
@ -374,7 +371,7 @@ data class PageSelectionData(
|
|||
@Suppress("unused")
|
||||
@Composable
|
||||
internal fun PdfPageComposable(
|
||||
pdfDocument: StableHolder<PdfDocumentKt>,
|
||||
pdfDocument: StableHolder<ReaderDocument>,
|
||||
pageIndex: Int,
|
||||
totalPages: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -747,7 +744,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
var rects: List<Rect> = emptyList()
|
||||
var pdfiumSucceeded = false
|
||||
var tempPage: PdfPageKt? = null
|
||||
var tempPage: ReaderPage? = null
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
@ -847,7 +844,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
// 1. Extract Links (Method 1: Annotations)
|
||||
try {
|
||||
val annotationLinks = pageWrapper.getPageLinks()
|
||||
val annotationLinks = pageWrapper.getLinks()
|
||||
if (annotationLinks.isNotEmpty()) {
|
||||
val mappedAnnotationLinks = annotationLinks.mapNotNull { link ->
|
||||
val uri = link.uri
|
||||
|
|
@ -909,7 +906,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
// 3. Extract Embedded Annotations
|
||||
try {
|
||||
val pagePtr = getNativePointer(pageWrapper)
|
||||
val pagePtr = pageWrapper.getNativePointer()
|
||||
|
||||
if (pagePtr != 0L) {
|
||||
val count = NativePdfiumBridge.getAnnotCount(pagePtr)
|
||||
|
|
@ -1068,7 +1065,7 @@ internal fun PdfPageComposable(
|
|||
|
||||
if (actualBitmapWidthPx == 0 || actualBitmapHeightPx == 0 || screenWidth == 0f || screenHeight == 0f) return@LaunchedEffect
|
||||
|
||||
var page: PdfPageKt? = null
|
||||
var page: ReaderPage? = null
|
||||
|
||||
if (!isPdfPage) {
|
||||
if (tiles.isNotEmpty()) {
|
||||
|
|
@ -1416,14 +1413,14 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
|
||||
suspend fun updateSelectionVisuals(
|
||||
doc: PdfDocumentKt,
|
||||
doc: ReaderDocument,
|
||||
pageIdx: Int,
|
||||
charRange: Pair<Int, Int>?,
|
||||
currentBitmapWidth: Int,
|
||||
currentBitmapHeight: Int,
|
||||
rotation: Int,
|
||||
providedPage: PdfPageKt? = null,
|
||||
providedTextPage: PdfTextPageKt? = null
|
||||
providedPage: ReaderPage? = null,
|
||||
providedTextPage: ReaderTextPage? = null
|
||||
) {
|
||||
if (charRange == null || currentBitmapWidth == 0 || currentBitmapHeight == 0) {
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
@ -1435,12 +1432,12 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
var localPage: PdfPageKt? = null
|
||||
var localTextPage: PdfTextPageKt? = null
|
||||
var localPage: ReaderPage? = null
|
||||
var localTextPage: ReaderTextPage? = null
|
||||
|
||||
try {
|
||||
val pageToUse: PdfPageKt
|
||||
val textPageToUse: PdfTextPageKt
|
||||
val pageToUse: ReaderPage
|
||||
val textPageToUse: ReaderTextPage
|
||||
|
||||
if (providedPage != null && providedTextPage != null) {
|
||||
pageToUse = providedPage
|
||||
|
|
@ -1675,8 +1672,8 @@ internal fun PdfPageComposable(
|
|||
val dragEventChannel = Channel<Offset>(Channel.CONFLATED)
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
var pageForDrag: PdfPageKt? = null
|
||||
var textPageForDrag: PdfTextPageKt? = null
|
||||
var pageForDrag: ReaderPage? = null
|
||||
var textPageForDrag: ReaderTextPage? = null
|
||||
|
||||
try {
|
||||
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
|
||||
|
|
@ -2013,8 +2010,8 @@ internal fun PdfPageComposable(
|
|||
if (selectionCharRange.value != null && selectedWordScreenRects.isNotEmpty()) {
|
||||
val currentRange = selectionCharRange.value!!
|
||||
coroutineScope.launch {
|
||||
var pageForMenu: PdfPageKt? = null
|
||||
var textPageForMenu: PdfTextPageKt? = null
|
||||
var pageForMenu: ReaderPage? = null
|
||||
var textPageForMenu: ReaderTextPage? = null
|
||||
try {
|
||||
val text = withContext(Dispatchers.IO) {
|
||||
pageForMenu = pdfDocumentItem.openPage(pdfPageIndex)
|
||||
|
|
@ -2125,8 +2122,8 @@ internal fun PdfPageComposable(
|
|||
showMagnifier = false
|
||||
|
||||
coroutineScope.launch {
|
||||
var tempPage: PdfPageKt? = null
|
||||
var tempTextPage: PdfTextPageKt? = null
|
||||
var tempPage: ReaderPage? = null
|
||||
var tempTextPage: ReaderTextPage? = null
|
||||
var ocrAttemptedForThisPress = false
|
||||
try {
|
||||
if (!isPdfPage) return@launch
|
||||
|
|
@ -2420,7 +2417,7 @@ internal fun PdfPageComposable(
|
|||
val wasHandled = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
pdfDocumentItem.openPage(pdfPageIndex)?.use { page ->
|
||||
val pagePtr = getNativePointer(page)
|
||||
val pagePtr = page.getNativePointer()
|
||||
|
||||
if (pagePtr == 0L) {
|
||||
Timber.tag("PdfInteraction").e("Could not find native pointer for page $pdfPageIndex")
|
||||
|
|
@ -3337,8 +3334,8 @@ internal fun PdfPageComposable(
|
|||
val page = pdfDocumentItem.openPage(pdfPageIndex) ?: return@withContext null
|
||||
val rotation = page.getPageRotation()
|
||||
val screenDpi = (density.density * 160).roundToInt()
|
||||
val originalWidthPdfUnits = page.getPageWidth(screenDpi)
|
||||
val originalHeightPdfUnits = page.getPageHeight(screenDpi)
|
||||
val originalWidthPdfUnits = page.getPageWidthPoint()
|
||||
val originalHeightPdfUnits = page.getPageHeightPoint()
|
||||
|
||||
if (originalWidthPdfUnits <= 0 || originalHeightPdfUnits <= 0) {
|
||||
page.close()
|
||||
|
|
@ -3678,8 +3675,8 @@ internal fun PdfPageComposable(
|
|||
if (!isPdfPage) return@launch
|
||||
|
||||
if (selectionMethodUsed == PdfSelectionMethod.PDFIUM) {
|
||||
var page: PdfPageKt? = null
|
||||
var textPage: PdfTextPageKt? = null
|
||||
var page: ReaderPage? = null
|
||||
var textPage: ReaderTextPage? = null
|
||||
try {
|
||||
val charCount = withContext(Dispatchers.IO) {
|
||||
page = pdfDocumentItem.openPage(pdfPageIndex)
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ private data class DividerLayout(val y: Float, val width: Float, val height: Flo
|
|||
@Composable
|
||||
internal fun PdfVerticalReader(
|
||||
state: VerticalPdfReaderState,
|
||||
pdfDocument: StableHolder<PdfDocumentKt>,
|
||||
pdfDocument: StableHolder<ReaderDocument>,
|
||||
isDarkMode: Boolean,
|
||||
totalPages: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ import com.aryan.reader.AiDefinitionPopup
|
|||
import com.aryan.reader.AiDefinitionResult
|
||||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.DeviceVoiceSettingsSheet
|
||||
import com.aryan.reader.FileType
|
||||
import com.aryan.reader.MainViewModel
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.SearchResult
|
||||
|
|
@ -289,7 +290,6 @@ import com.aryan.reader.tts.splitTextIntoChunks
|
|||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfPageKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -972,9 +972,9 @@ private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
|
|||
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
|
||||
}
|
||||
|
||||
private suspend fun renderPageToBitmap(doc: PdfDocumentKt, pageIndex: Int): Bitmap? {
|
||||
private suspend fun renderPageToBitmap(doc: ReaderDocument, pageIndex: Int): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
var page: PdfPageKt? = null
|
||||
var page: ReaderPage? = null
|
||||
try {
|
||||
page = doc.openPage(pageIndex)
|
||||
if (page == null) return@withContext null
|
||||
|
|
@ -1425,7 +1425,7 @@ fun PdfViewerScreen(
|
|||
)
|
||||
else null
|
||||
}
|
||||
var pdfDocument by remember { mutableStateOf<PdfDocumentKt?>(null) }
|
||||
var pdfDocument by remember { mutableStateOf<ReaderDocument?>(null) }
|
||||
var pfdState by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
|
||||
var totalPages by remember { mutableIntStateOf(0) }
|
||||
var currentPageScale by remember { mutableFloatStateOf(1f) }
|
||||
|
|
@ -2276,10 +2276,13 @@ fun PdfViewerScreen(
|
|||
if (extractedText.isBlank() && currentBookId != null && pdfDocument != null) {
|
||||
Timber.d("Extracted text is blank. Attempting repository/OCR fallback...")
|
||||
try {
|
||||
extractedText = pdfTextRepository.getOrExtractText(
|
||||
currentBookId!!, pdfDocument!!, pageIndex
|
||||
)
|
||||
Timber.d("Repository: Extracted text length: ${extractedText.length}")
|
||||
val pdfDocKt = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument
|
||||
if (pdfDocKt != null) {
|
||||
extractedText = pdfTextRepository.getOrExtractText(
|
||||
currentBookId!!, pdfDocKt, pageIndex
|
||||
)
|
||||
Timber.d("Repository: Extracted text length: ${extractedText.length}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Bookmark: Repository extraction failed")
|
||||
}
|
||||
|
|
@ -2571,11 +2574,12 @@ fun PdfViewerScreen(
|
|||
|
||||
val onGetOcrSearchRectsStable = remember(pdfTextRepository, pdfDocument) {
|
||||
val callback: suspend (Int, String) -> List<RectF> = { page, query ->
|
||||
if (pdfDocument != null) {
|
||||
val hasNative = pdfTextRepository.hasNativeText(pdfDocument!!, page)
|
||||
val pdfDocKt = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument
|
||||
if (pdfDocKt != null) {
|
||||
val hasNative = pdfTextRepository.hasNativeText(pdfDocKt, page)
|
||||
if (!hasNative) {
|
||||
pdfTextRepository.getOcrSearchRects(
|
||||
document = pdfDocument!!,
|
||||
document = pdfDocKt,
|
||||
pageIndex = page,
|
||||
query = query,
|
||||
onModelDownloading = { isOcrModelDownloading = true })
|
||||
|
|
@ -2759,9 +2763,9 @@ fun PdfViewerScreen(
|
|||
coroutineScope.launch {
|
||||
val pageToRead = pageToReadOverride ?: currentPage
|
||||
var rawPageText: String? = null
|
||||
var tempPage: PdfPageKt? = null
|
||||
var tempTextPage: PdfTextPageKt? = null
|
||||
var ocrAttempted = false
|
||||
var tempPage: ReaderPage? = null
|
||||
var tempTextPage: ReaderTextPage? = null
|
||||
@Suppress("CanBeVal") var ocrAttempted = false
|
||||
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
@ -2816,8 +2820,8 @@ fun PdfViewerScreen(
|
|||
|
||||
val chunks = splitTextIntoChunks(textToChunk)
|
||||
|
||||
val bookTitle = pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
|
||||
?: pdfUri.lastPathSegment ?: "PDF Document"
|
||||
val bookTitle = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
|
||||
?: pdfUri.lastPathSegment ?: "Document"
|
||||
val pageTitle = "Page ${pageToRead + 1}"
|
||||
|
||||
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
|
||||
|
|
@ -3035,7 +3039,7 @@ fun PdfViewerScreen(
|
|||
currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r")
|
||||
if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor")
|
||||
|
||||
val doc = pdfiumCore.newDocument(currentPfdOpened, documentPassword)
|
||||
val doc = DocumentFactory.loadDocument(context, pdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore)
|
||||
|
||||
if (!isActive) {
|
||||
doc.close()
|
||||
|
|
@ -3049,7 +3053,7 @@ fun PdfViewerScreen(
|
|||
|
||||
if (pagesCount > 0) {
|
||||
try {
|
||||
val tableOfContents = doc.getFixedTableOfContents()
|
||||
val tableOfContents = doc.getTableOfContents()
|
||||
val flattened = flattenToc(tableOfContents)
|
||||
withContext(Dispatchers.Main) { flatTableOfContents = flattened }
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -3264,8 +3268,8 @@ fun PdfViewerScreen(
|
|||
|
||||
LaunchedEffect(pdfUri, currentBookId, totalPages) {
|
||||
if (currentBookId == null || totalPages == 0) return@LaunchedEffect
|
||||
|
||||
if (isBackgroundIndexing && backgroundIndexingProgress > 0f) return@LaunchedEffect
|
||||
if (uiState.selectedFileType != FileType.PDF) return@LaunchedEffect
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val storedLang = pdfTextRepository.getBookLanguage(currentBookId!!)
|
||||
|
|
@ -4110,9 +4114,10 @@ fun PdfViewerScreen(
|
|||
Timber.d(
|
||||
"LaunchedEffect triggered for Page $pageIndex. Checking Native..."
|
||||
)
|
||||
val hasNative = pdfTextRepository.hasNativeText(
|
||||
pdfDocument!!, pageIndex
|
||||
)
|
||||
val pdfDocKt = (pdfDocument as? PdfDocumentWrapper)?.pdfDocument
|
||||
val hasNative = if (pdfDocKt != null) pdfTextRepository.hasNativeText(
|
||||
pdfDocKt, pageIndex
|
||||
) else false
|
||||
Timber.d(
|
||||
"Page $pageIndex Has Native Text: $hasNative"
|
||||
)
|
||||
|
|
@ -4121,13 +4126,13 @@ fun PdfViewerScreen(
|
|||
Timber.d(
|
||||
"Fetching OCR rects for query: '${target.query}'"
|
||||
)
|
||||
val rects = pdfTextRepository.getOcrSearchRects(
|
||||
document = pdfDocument!!,
|
||||
val rects = if (pdfDocKt != null) pdfTextRepository.getOcrSearchRects(
|
||||
document = pdfDocKt,
|
||||
pageIndex = pageIndex,
|
||||
query = target.query,
|
||||
onModelDownloading = {
|
||||
isOcrModelDownloading = true
|
||||
})
|
||||
}) else emptyList()
|
||||
Timber.d(
|
||||
"Received ${rects.size} rects from Repository."
|
||||
)
|
||||
|
|
@ -5533,31 +5538,32 @@ fun PdfViewerScreen(
|
|||
Icons.Default.Share, contentDescription = null
|
||||
)
|
||||
})
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save copy to device") },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
showSaveDialog = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Save, contentDescription = null
|
||||
)
|
||||
})
|
||||
DropdownMenuItem(
|
||||
text = { Text("Print") },
|
||||
onClick = {
|
||||
if (uiState.selectedFileType == FileType.PDF) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save copy to device") },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
showSaveDialog = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null
|
||||
)
|
||||
})
|
||||
}
|
||||
if (uiState.selectedFileType == FileType.PDF) {
|
||||
DropdownMenuItem(text = { Text("Print") }, onClick = {
|
||||
showMoreMenu = false
|
||||
onPrintDocument()
|
||||
},
|
||||
leadingIcon = {
|
||||
}, leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.print),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
356
app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
Normal file
356
app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
// UniversalDocument.kt
|
||||
package com.aryan.reader.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PointF
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import com.aryan.reader.FileType
|
||||
import io.legere.pdfiumandroid.api.Bookmark
|
||||
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfPageKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
|
||||
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import me.zhanghai.android.libarchive.Archive
|
||||
import me.zhanghai.android.libarchive.ArchiveEntry
|
||||
import me.zhanghai.android.libarchive.ArchiveException
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
interface ReaderDocument : AutoCloseable {
|
||||
suspend fun getPageCount(): Int
|
||||
suspend fun openPage(pageIndex: Int): ReaderPage?
|
||||
suspend fun getTableOfContents(): List<Bookmark>
|
||||
}
|
||||
|
||||
interface ReaderPage : AutoCloseable {
|
||||
suspend fun getPageWidthPoint(): Int
|
||||
suspend fun getPageHeightPoint(): Int
|
||||
suspend fun getPageRotation(): Int
|
||||
suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean)
|
||||
suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF): Rect
|
||||
suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int): PointF
|
||||
suspend fun openTextPage(): ReaderTextPage
|
||||
suspend fun getLinks(): List<ReaderLink>
|
||||
fun getNativePointer(): Long
|
||||
}
|
||||
|
||||
interface ReaderTextPage : AutoCloseable {
|
||||
suspend fun textPageCountChars(): Int
|
||||
suspend fun textPageGetText(startIndex: Int, count: Int): String?
|
||||
suspend fun textPageGetRectsForRanges(ranges: IntArray): List<ReaderTextRect>?
|
||||
suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double): Int
|
||||
suspend fun textPageGetCharBox(index: Int): RectF?
|
||||
suspend fun textPageGetUnicode(index: Int): Int
|
||||
suspend fun loadWebLink(): ReaderWebLinks?
|
||||
}
|
||||
|
||||
data class ReaderLink(val uri: String?, val destPageIdx: Int?, val bounds: RectF)
|
||||
data class ReaderTextRect(val rect: RectF)
|
||||
|
||||
interface ReaderWebLinks : AutoCloseable {
|
||||
suspend fun countWebLinks(): Int
|
||||
suspend fun getURL(linkIndex: Int, maxLength: Int): String?
|
||||
suspend fun countRects(linkIndex: Int): Int
|
||||
suspend fun getRect(linkIndex: Int, rectIndex: Int): RectF
|
||||
}
|
||||
|
||||
object DocumentFactory {
|
||||
suspend fun loadDocument(context: Context, uri: Uri, type: FileType, password: String?, pdfiumCore: PdfiumCoreKt): ReaderDocument {
|
||||
return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
|
||||
val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}")
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
cacheFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
}
|
||||
ArchiveDocumentWrapper(cacheFile)
|
||||
} else {
|
||||
val pfd = context.contentResolver.openFileDescriptor(uri, "r") ?: throw Exception("Failed to open PDF")
|
||||
PdfDocumentWrapper(pdfiumCore.newDocument(pfd, password))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================= PDF IMPLEMENTATION =================
|
||||
|
||||
class PdfDocumentWrapper(val pdfDocument: PdfDocumentKt) : ReaderDocument {
|
||||
override suspend fun getPageCount() = pdfDocument.getPageCount()
|
||||
override suspend fun openPage(pageIndex: Int): ReaderPage? {
|
||||
val page = pdfDocument.openPage(pageIndex) ?: return null
|
||||
return PdfPageWrapper(page)
|
||||
}
|
||||
override suspend fun getTableOfContents() = pdfDocument.getFixedTableOfContents()
|
||||
override fun close() { pdfDocument.close() }
|
||||
}
|
||||
|
||||
class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage {
|
||||
override suspend fun getPageWidthPoint() = pdfPage.getPageWidthPoint()
|
||||
override suspend fun getPageHeightPoint() = pdfPage.getPageHeightPoint()
|
||||
override suspend fun getPageRotation() = pdfPage.getPageRotation()
|
||||
|
||||
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
|
||||
pdfPage.renderPageBitmap(bitmap, startX, startY, drawSizeX, drawSizeY, renderAnnot)
|
||||
}
|
||||
|
||||
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF) =
|
||||
pdfPage.mapRectToDevice(startX, startY, sizeX, sizeY, rotate, coords)
|
||||
|
||||
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int) =
|
||||
pdfPage.mapDeviceCoordsToPage(startX, startY, sizeX, sizeY, rotate, deviceX, deviceY)
|
||||
|
||||
override suspend fun openTextPage(): ReaderTextPage = PdfTextPageWrapper(pdfPage.openTextPage())
|
||||
|
||||
override suspend fun getLinks(): List<ReaderLink> {
|
||||
return pdfPage.getPageLinks().map { ReaderLink(it.uri, it.destPageIdx, it.bounds) }
|
||||
}
|
||||
|
||||
override fun getNativePointer(): Long {
|
||||
return try {
|
||||
val field = pdfPage.javaClass.getDeclaredField("mNativePagePtr")
|
||||
field.isAccessible = true
|
||||
field.get(pdfPage) as? Long ?: 0L
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() { pdfPage.close() }
|
||||
}
|
||||
|
||||
class PdfTextPageWrapper(private val textPage: PdfTextPageKt) : ReaderTextPage {
|
||||
override suspend fun textPageCountChars() = textPage.textPageCountChars()
|
||||
override suspend fun textPageGetText(startIndex: Int, count: Int) = textPage.textPageGetText(startIndex, count)
|
||||
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = textPage.textPageGetRectsForRanges(ranges)?.map { ReaderTextRect(it.rect) }
|
||||
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = textPage.textPageGetCharIndexAtPos(x, y, xTolerance, yTolerance)
|
||||
override suspend fun textPageGetCharBox(index: Int) = textPage.textPageGetCharBox(index)
|
||||
override suspend fun textPageGetUnicode(index: Int): Int {
|
||||
return textPage.textPageGetUnicode(index).code
|
||||
}
|
||||
override suspend fun loadWebLink(): ReaderWebLinks? {
|
||||
val links = textPage.loadWebLink() ?: return null
|
||||
return object : ReaderWebLinks {
|
||||
override suspend fun countWebLinks() = links.countWebLinks()
|
||||
override suspend fun getURL(linkIndex: Int, maxLength: Int) = links.getURL(linkIndex, maxLength)
|
||||
override suspend fun countRects(linkIndex: Int) = links.countRects(linkIndex)
|
||||
override suspend fun getRect(linkIndex: Int, rectIndex: Int) = links.getRect(linkIndex, rectIndex)
|
||||
override fun close() { links.close() }
|
||||
}
|
||||
}
|
||||
override fun close() { textPage.close() }
|
||||
}
|
||||
|
||||
// ================= CBZ, CBR, CB7 IMPLEMENTATION =================
|
||||
|
||||
class DummyTextPage : ReaderTextPage {
|
||||
override suspend fun textPageCountChars() = 0
|
||||
override suspend fun textPageGetText(startIndex: Int, count: Int) = null
|
||||
override suspend fun textPageGetRectsForRanges(ranges: IntArray) = null
|
||||
override suspend fun textPageGetCharIndexAtPos(x: Double, y: Double, xTolerance: Double, yTolerance: Double) = -1
|
||||
override suspend fun textPageGetCharBox(index: Int) = null
|
||||
override suspend fun textPageGetUnicode(index: Int) = 0
|
||||
override suspend fun loadWebLink() = null
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
class ArchiveDocumentWrapper(private val file: File) : ReaderDocument {
|
||||
private val imageEntries = mutableListOf<String>()
|
||||
private var zipFile: ZipFile? = null
|
||||
private var extractedDir: File? = null
|
||||
|
||||
init {
|
||||
// Try reading as ZIP first for instant O(1) random access (Handles .cbz efficiently)
|
||||
try {
|
||||
val zf = ZipFile(file)
|
||||
val entries = zf.entries()
|
||||
while (entries.hasMoreElements()) {
|
||||
val entry = entries.nextElement()
|
||||
if (!entry.isDirectory && entry.name.matches(Regex(".*\\.(jpg|jpeg|png|webp|bmp)$", RegexOption.IGNORE_CASE))) {
|
||||
imageEntries.add(entry.name)
|
||||
}
|
||||
}
|
||||
if (imageEntries.isNotEmpty()) {
|
||||
zipFile = zf
|
||||
imageEntries.sort()
|
||||
} else {
|
||||
zf.close()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
zipFile = null
|
||||
}
|
||||
|
||||
if (zipFile == null) {
|
||||
imageEntries.clear()
|
||||
extractedDir = File(file.parentFile, "extracted_${file.name}_${System.currentTimeMillis()}")
|
||||
extractedDir?.mkdirs()
|
||||
|
||||
var archive = 0L
|
||||
try {
|
||||
archive = Archive.readNew()
|
||||
Archive.readSupportFilterAll(archive)
|
||||
Archive.readSupportFormatAll(archive)
|
||||
Archive.readOpenFileName(archive, file.absolutePath.toByteArray(), 10240)
|
||||
|
||||
val tempEntries = mutableListOf<Pair<String, File>>()
|
||||
|
||||
while (true) {
|
||||
val entry = try {
|
||||
Archive.readNextHeader(archive)
|
||||
} catch (e: ArchiveException) {
|
||||
if (e.code == Archive.ERRNO_EOF) break
|
||||
throw e
|
||||
}
|
||||
if (entry == 0L) break
|
||||
|
||||
val path = ArchiveEntry.pathnameUtf8(entry)
|
||||
if (path != null && path.matches(Regex(".*\\.(jpg|jpeg|png|webp|bmp)$", RegexOption.IGNORE_CASE))) {
|
||||
val extractedFile = File(extractedDir, UUID.randomUUID().toString() + ".img")
|
||||
tempEntries.add(Pair(path, extractedFile))
|
||||
|
||||
var pfd: android.os.ParcelFileDescriptor? = null
|
||||
try {
|
||||
// Extract seamlessly using fd to avoid ByteBuffer's state sync bug
|
||||
pfd = android.os.ParcelFileDescriptor.open(extractedFile, android.os.ParcelFileDescriptor.MODE_READ_WRITE or android.os.ParcelFileDescriptor.MODE_CREATE)
|
||||
Archive.readDataIntoFd(archive, pfd.fd)
|
||||
} finally {
|
||||
pfd?.close()
|
||||
}
|
||||
} else {
|
||||
Archive.readDataSkip(archive)
|
||||
}
|
||||
}
|
||||
|
||||
tempEntries.sortBy { it.first } // Natural sorting order based on the filename inside the archive
|
||||
tempEntries.forEach { imageEntries.add(it.second.absolutePath) }
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to extract archive entries")
|
||||
} finally {
|
||||
if (archive != 0L) Archive.readFree(archive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPageCount() = imageEntries.size
|
||||
|
||||
override suspend fun openPage(pageIndex: Int): ReaderPage? = withContext(Dispatchers.IO) {
|
||||
if (pageIndex !in imageEntries.indices) return@withContext null
|
||||
val targetPath = imageEntries[pageIndex]
|
||||
|
||||
var imageBytes: ByteArray? = null
|
||||
|
||||
if (zipFile != null) {
|
||||
try {
|
||||
val entry = zipFile!!.getEntry(targetPath)
|
||||
if (entry != null) {
|
||||
zipFile!!.getInputStream(entry).use { imageBytes = it.readBytes() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to extract page from ZIP")
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
val extractedFile = File(targetPath)
|
||||
if (extractedFile.exists()) {
|
||||
imageBytes = extractedFile.readBytes()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to read extracted page")
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBytes != null && imageBytes!!.isNotEmpty()) ArchivePageWrapper(imageBytes!!) else null
|
||||
}
|
||||
|
||||
override suspend fun getTableOfContents() = emptyList<Bookmark>()
|
||||
|
||||
override fun close() {
|
||||
try { zipFile?.close() } catch (_: Exception) {}
|
||||
try { extractedDir?.deleteRecursively() } catch (_: Exception) {}
|
||||
try { file.delete() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage {
|
||||
private val decoder = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(imageBytes, 0, imageBytes.size)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
BitmapRegionDecoder.newInstance(imageBytes, 0, imageBytes.size, false)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private val originalWidth = decoder?.width ?: 1
|
||||
private val originalHeight = decoder?.height ?: 1
|
||||
|
||||
override suspend fun getPageWidthPoint() = originalWidth
|
||||
override suspend fun getPageHeightPoint() = originalHeight
|
||||
override suspend fun getPageRotation() = 0
|
||||
|
||||
override suspend fun renderPageBitmap(bitmap: Bitmap, startX: Int, startY: Int, drawSizeX: Int, drawSizeY: Int, renderAnnot: Boolean) {
|
||||
if (decoder == null || decoder.isRecycled) return
|
||||
val scaleX = drawSizeX.toFloat() / originalWidth
|
||||
val scaleY = drawSizeY.toFloat() / originalHeight
|
||||
|
||||
val srcLeft = (-startX / scaleX).toInt().coerceAtLeast(0)
|
||||
val srcTop = (-startY / scaleY).toInt().coerceAtLeast(0)
|
||||
val srcRight = (srcLeft + (bitmap.width / scaleX).toInt()).coerceAtMost(originalWidth)
|
||||
val srcBottom = (srcTop + (bitmap.height / scaleY).toInt()).coerceAtMost(originalHeight)
|
||||
|
||||
val rect = Rect(srcLeft, srcTop, srcRight, srcBottom)
|
||||
if (rect.width() <= 0 || rect.height() <= 0) return
|
||||
|
||||
val options = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
|
||||
val region = try {
|
||||
decoder.decodeRegion(rect, options)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (region != null) {
|
||||
val canvas = Canvas(bitmap)
|
||||
val destRect = Rect(0, 0, bitmap.width, bitmap.height)
|
||||
canvas.drawBitmap(region, null, destRect, Paint(Paint.FILTER_BITMAP_FLAG))
|
||||
region.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun mapRectToDevice(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, coords: RectF): Rect {
|
||||
val scaleX = sizeX.toFloat() / originalWidth
|
||||
val scaleY = sizeY.toFloat() / originalHeight
|
||||
return Rect(
|
||||
(startX + coords.left * scaleX).toInt(),
|
||||
(startY + coords.top * scaleY).toInt(),
|
||||
(startX + coords.right * scaleX).toInt(),
|
||||
(startY + coords.bottom * scaleY).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun mapDeviceCoordsToPage(startX: Int, startY: Int, sizeX: Int, sizeY: Int, rotate: Int, deviceX: Int, deviceY: Int): PointF {
|
||||
val scaleX = sizeX.toFloat() / originalWidth
|
||||
val scaleY = sizeY.toFloat() / originalHeight
|
||||
return PointF((deviceX - startX) / scaleX, (deviceY - startY) / scaleY)
|
||||
}
|
||||
|
||||
override suspend fun openTextPage() = DummyTextPage()
|
||||
override suspend fun getLinks() = emptyList<ReaderLink>()
|
||||
override fun getNativePointer() = 0L
|
||||
|
||||
override fun close() {
|
||||
decoder?.recycle()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue