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
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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(
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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