diff --git a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt b/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt index c72a3e1..1bfd9d1 100644 --- a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt +++ b/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt @@ -31,7 +31,7 @@ class EpubTestActivity : ComponentActivity() { coverImagePath = null, onRenderModeChange = {}, customFonts = TODO(), - onImportFont = TODO() + onImportFont = TODO(), viewModel = TODO() ) } } diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index faa30d1..f060607 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -169,7 +169,7 @@ fun AppNavigation( Timber.i("Displaying EPUB Reader for Book: ${epubBook.title}, initialLocator: $initialLocator") val coverPath = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.coverImagePath val epubUri = uiState.selectedEpubUri - val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId + uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId val customFonts by viewModel.customFonts.collectAsStateWithLifecycle() EpubReaderScreen( @@ -204,6 +204,7 @@ fun AppNavigation( onRenderModeChange = viewModel::setRenderMode, customFonts = customFonts, onImportFont = viewModel::importFont, + viewModel = viewModel ) } isLoading -> { diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 1270f8b..a933be4 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -277,7 +277,8 @@ fun HomeScreen( } }, onShowDeviceManagement = viewModel::showDeviceManagementForDebug, - onFolderSyncToggle = viewModel::setFolderSyncEnabled + onFolderSyncToggle = viewModel::setFolderSyncEnabled, + onClearReflowCache = viewModel::clearReflowCache ) } else { ContextualTopAppBar( @@ -643,6 +644,7 @@ fun DefaultTopAppBar( onRenderModeChange: (RenderMode) -> Unit, onClearCache: () -> Unit, onClearCloudData: () -> Unit, + onClearReflowCache: () -> Unit, // Add this parameter onDrawerClick: () -> Unit, onAboutClick: () -> Unit, onShowDeviceManagement: () -> Unit, @@ -684,6 +686,10 @@ fun DefaultTopAppBar( onClearCache() showOptionsMenu = false }) + DropdownMenuItem(text = { Text("[Debug] Clear Reflow Cache") }, onClick = { + onClearReflowCache() + showOptionsMenu = false + }) DropdownMenuItem( text = { Text("[Debug] Clear Cloud & Local Data") }, onClick = { diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 27c3534..e4b0bb9 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -68,6 +68,7 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase import com.aryan.reader.paginatedreader.data.BookProcessingWorker import com.aryan.reader.pdf.PdfCoverGenerator import com.aryan.reader.pdf.PdfExporter +import com.aryan.reader.pdf.ReflowWorker import com.aryan.reader.pdf.data.PageLayoutRepository import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotationRepository @@ -80,10 +81,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -188,6 +191,7 @@ data class ReaderScreenState( val searchQuery: String = "", val showFolderMigrationDialog: Boolean = false, val isRefreshing: Boolean = false, + val reflowProgress: Float? = null ) open class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -2252,90 +2256,141 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + val reflowWorkInfo: Flow = WorkManager.getInstance(appContext) + .getWorkInfosByTagFlow(ReflowWorker.WORK_NAME) + .map { list -> + list.find { !it.state.isFinished } ?: list.firstOrNull() + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + fun generateAndImportReflowFile(pdfBookId: String, pdfUri: Uri, originalTitle: String) { + val reflowBookId = "${pdfBookId}_reflow" + + viewModelScope.launch { + val existing = recentFilesRepository.getFileByBookId(reflowBookId) + if (existing != null) { + showBanner("Opening existing text view...") + onRecentFileClicked(existing) + return@launch + } + + val workManager = WorkManager.getInstance(appContext) + + val inputData = androidx.work.Data.Builder() + .putString(ReflowWorker.KEY_BOOK_ID, pdfBookId) + .putString(ReflowWorker.KEY_PDF_URI, pdfUri.toString()) + .putString(ReflowWorker.KEY_ORIGINAL_TITLE, originalTitle) + .build() + + val request = OneTimeWorkRequestBuilder() + .setInputData(inputData) + .addTag(ReflowWorker.WORK_NAME) + .addTag("book_$pdfBookId") + .build() + + workManager.enqueueUniqueWork( + "reflow_$pdfBookId", + ExistingWorkPolicy.KEEP, + request + ) + + showBanner("Text view generation started in background.") + } + } + private fun openBook( uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null ) { - Timber.d("Opening book with determined type: $type for bookId: $bookId") + Timber.d("Opening book type: $type for bookId: $bookId") - _internalState.update { - it.copy( - selectedPdfUri = null, - selectedEpubUri = null, - selectedBookId = bookId, - selectedEpubBook = null, - selectedFileType = type, - isLoading = true, - errorMessage = null, - initialLocator = null, - initialPageInBook = null - ) - } - - if (type == FileType.PDF) { - viewModelScope.launch { - val recentItem = recentFilesRepository.getFileByBookId(bookId) - - if (recentItem?.sourceFolderUri != null) { - launch(Dispatchers.IO) { - recentFilesRepository.syncLocalMetadataToFolder(bookId) - } - } - - Timber.d("openBook: Loading PDF. bookId=$bookId ...") - _internalState.update { - it.copy( - selectedPdfUri = uri, - initialPageInBook = recentItem?.lastPage, - initialBookmarksJson = recentItem?.bookmarksJson, - isLoading = false - ) - } - addFileToRecent( - uri, - type, - bookId, - customDisplayName = originalDisplayName, - isRecent = true, - sourceFolderUri = null + viewModelScope.launch { + _internalState.update { + it.copy( + selectedPdfUri = null, + selectedEpubUri = null, + selectedBookId = bookId, + selectedEpubBook = null, + selectedFileType = type, + isLoading = true, + errorMessage = null, + initialLocator = null, + initialPageInBook = null ) } - } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) { - viewModelScope.launch { - val recentItem = recentFilesRepository.getFileByBookId(bookId) - if (recentItem?.sourceFolderUri != null) { - launch(Dispatchers.IO) { - recentFilesRepository.syncLocalMetadataToFolder(bookId) - } - } - val locator = - if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { - Locator( - chapterIndex = recentItem.lastChapterIndex, - blockIndex = recentItem.locatorBlockIndex, - charOffset = recentItem.locatorCharOffset - ) - } else { - null + + if (type == FileType.PDF) { + viewModelScope.launch { + val recentItem = recentFilesRepository.getFileByBookId(bookId) + + if (recentItem?.sourceFolderUri != null) { + launch(Dispatchers.IO) { + recentFilesRepository.syncLocalMetadataToFolder(bookId) + } } - _internalState.update { - it.copy( - selectedEpubUri = uri, - initialLocator = locator, - initialCfi = recentItem?.lastPositionCfi, - initialBookmarksJson = recentItem?.bookmarksJson + Timber.d("openBook: Loading PDF. bookId=$bookId ...") + _internalState.update { + it.copy( + selectedPdfUri = uri, + initialPageInBook = recentItem?.lastPage, + initialBookmarksJson = recentItem?.bookmarksJson, + isLoading = false + ) + } + addFileToRecent( + uri, + type, + bookId, + customDisplayName = originalDisplayName, + isRecent = true, + sourceFolderUri = null ) } + } else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) { + viewModelScope.launch { + val recentItem = recentFilesRepository.getFileByBookId(bookId) + if (recentItem?.sourceFolderUri != null) { + launch(Dispatchers.IO) { + recentFilesRepository.syncLocalMetadataToFolder(bookId) + } + } + val locator = + if (recentItem?.lastChapterIndex != null && recentItem.locatorBlockIndex != null && recentItem.locatorCharOffset != null) { + Locator( + chapterIndex = recentItem.lastChapterIndex, + blockIndex = recentItem.locatorBlockIndex, + charOffset = recentItem.locatorCharOffset + ) + } else { + null + } - when (type) { - FileType.EPUB -> { - loadEpub(uri, bookId, customDisplayName = originalDisplayName) + _internalState.update { + it.copy( + selectedEpubUri = uri, + initialLocator = locator, + initialCfi = recentItem?.lastPositionCfi, + initialBookmarksJson = recentItem?.bookmarksJson + ) } - FileType.MOBI -> { - loadMobi(uri, bookId, customDisplayName = originalDisplayName) - } - else -> { - loadSingleFile(uri, bookId, type, customDisplayName = originalDisplayName) + + when (type) { + FileType.EPUB -> { + loadEpub(uri, bookId, customDisplayName = originalDisplayName) + } + + FileType.MOBI -> { + loadMobi(uri, bookId, customDisplayName = originalDisplayName) + } + + else -> { + loadSingleFile( + uri, + bookId, + type, + customDisplayName = originalDisplayName + ) + } } } } @@ -3263,6 +3318,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun clearReflowCache() { + viewModelScope.launch(Dispatchers.IO) { + val reflowDir = File(appContext.cacheDir, "reflow_cache") + if (reflowDir.exists()) { + reflowDir.deleteRecursively() + } + val imagesDir = File(appContext.cacheDir, "reflow_images") + if (imagesDir.exists()) { + imagesDir.deleteRecursively() + } + + withContext(Dispatchers.Main) { + showBanner("Reflow cache & images cleared.") + } + } + } + companion object { private const val KEY_SORT_ORDER = "sort_order" internal const val KEY_SHELVES = "shelf_names" diff --git a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt index e758fbb..4818ebd 100644 --- a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt +++ b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt @@ -27,7 +27,7 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -@Database(entities = [RecentFileEntity::class, CustomFontEntity::class], version = 12, exportSchema = false) +@Database(entities = [RecentFileEntity::class, CustomFontEntity::class], version = 13, exportSchema = false) @TypeConverters(FileTypeConverter::class) abstract class AppDatabase : RoomDatabase() { abstract fun recentFileDao(): RecentFileDao @@ -167,6 +167,12 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recent_files ADD COLUMN isReflowPreferred INTEGER NOT NULL DEFAULT 0") + } + } + fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( @@ -174,11 +180,11 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "reader_database" ) - // 4. Add migration to builder .addMigrations( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, - MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12 + MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, + MIGRATION_12_13 ) .fallbackToDestructiveMigration(false) .build() diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt index 10087b1..8e7a1b7 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt @@ -40,6 +40,9 @@ interface RecentFileDao { @Query("SELECT * FROM recent_files") suspend fun getAllFiles(): List + @Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId") + suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) + @Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") fun getRecentFilesList(limit: Int): List diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt index a9e1cbd..b434389 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt @@ -47,5 +47,6 @@ data class RecentFileEntity( val locatorBlockIndex: Int?, val locatorCharOffset: Int?, val bookmarks: String?, - @ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String? + @ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?, + @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean ) \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt index 24659f6..759260f 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt @@ -43,7 +43,8 @@ data class RecentFileItem( val lastModifiedTimestamp: Long = 0L, val isDeleted: Boolean = false, val bookmarksJson: String? = null, - val sourceFolderUri: String? = null + val sourceFolderUri: String? = null, + val isReflowPreferred: Boolean = false ) { fun getUri(): Uri? = uriString?.toUri() } @@ -69,7 +70,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem { lastModifiedTimestamp = this.lastModifiedTimestamp, isDeleted = this.isDeleted, bookmarksJson = this.bookmarks, - sourceFolderUri = this.sourceFolderUri + sourceFolderUri = this.sourceFolderUri, + isReflowPreferred = this.isReflowPreferred ) } @@ -94,7 +96,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity { lastModifiedTimestamp = this.lastModifiedTimestamp, isDeleted = this.isDeleted, bookmarks = this.bookmarksJson, - sourceFolderUri = this.sourceFolderUri + sourceFolderUri = this.sourceFolderUri, + isReflowPreferred = this.isReflowPreferred ) } diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index 91d8011..e4a4423 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -294,6 +294,10 @@ class RecentFilesRepository(private val context: Context) { return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() } } + suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) = withContext(Dispatchers.IO) { + recentFileDao.updateReflowPreference(bookId, isPreferred) + } + suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) { recentFileDao.detachAllFolderBooks() Timber.d("Detached all folder books. They are now standard local files.") diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt index dfcee56..2e4c979 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt @@ -36,6 +36,10 @@ import java.net.URLDecoder import java.nio.file.Paths import java.util.UUID import java.util.zip.ZipFile +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit class EpubParser(private val context: Context) { data class EpubDocument( @@ -472,94 +476,102 @@ class EpubParser(private val context: Context) { return UUID.randomUUID().toString() } - private fun parseUsingSpine( + private suspend fun parseUsingSpine( spine: Node, manifestItems: Map, filesContentMap: Map, ncxMetadataMap: Map - ): List { - var chapterCounter = 0 - val tempChapters = mutableListOf() + ): List = withContext(Dispatchers.Default) { + val parsingSemaphore = Semaphore(6) - spine.selectChildTag("itemref") + val spineItems = spine.selectChildTag("itemref") .ifEmpty { spine.selectChildTag("opf:itemref") } - .mapNotNull { manifestItems[it.getAttribute("idref")] } - .forEach { item -> - val fileBytes = filesContentMap[item.absPath]?.data - if (fileBytes != null) { - if (item.mediaType.startsWith("application/xhtml+xml") || - item.mediaType.startsWith("text/html") || - item.absPath.endsWith(".html", ignoreCase = true) || - item.absPath.endsWith(".xhtml", ignoreCase = true) || - item.absPath.endsWith(".xml", ignoreCase = true) + + val deferredChapters = spineItems.mapIndexed { index, itemRef -> + async { + parsingSemaphore.withPermit { + val idRef = itemRef.getAttribute("idref") + val item = manifestItems[idRef] ?: return@withPermit null + + val fileBytes = filesContentMap[item.absPath]?.data ?: return@withPermit null + + val mediaType = item.mediaType + val absPath = item.absPath + + if (mediaType.startsWith("application/xhtml+xml") || + mediaType.startsWith("text/html") || + absPath.endsWith(".html", ignoreCase = true) || + absPath.endsWith(".xhtml", ignoreCase = true) || + absPath.endsWith(".xml", ignoreCase = true) ) { val rawHtml = String(fileBytes, Charsets.UTF_8) - val plainText = Jsoup.parse(rawHtml).text() + val document = Jsoup.parse(rawHtml) + val plainText = document.text() val parser = EpubXMLFileParser( - fileRelativePath = item.absPath, + fileRelativePath = absPath, data = fileBytes, fragmentId = null ) - val res = parser.parseForTitleAndPath() + val res = parser.parseForTitleAndPath(document) + val chapterTitleFromHtml = res.title - val ncxKey = item.absPath.substringBefore('#') + val ncxKey = absPath.substringBefore('#') val ncxData = ncxMetadataMap[ncxKey] + val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) { ncxData != null } else { true } + val finalChapterTitle = if (ncxData != null && ncxData.title.isNotBlank()) { ncxData.title } else { - Timber.d("No NCX title for ${item.absPath}, using HTML title: '$chapterTitleFromHtml'") chapterTitleFromHtml } val finalDepth = ncxData?.depth ?: 0 - chapterCounter++ - - tempChapters.add( - TempEpubChapter( - url = item.absPath, - title = finalChapterTitle, - htmlFilePath = res.effectiveHtmlPath, - chapterIndex = chapterCounter, - plainTextContent = plainText, - htmlContent = rawHtml, - depth = finalDepth, - isInToc = isEffectiveInToc - ) + TempEpubChapter( + url = absPath, + title = finalChapterTitle, + htmlFilePath = res.effectiveHtmlPath, + chapterIndex = index + 1, + plainTextContent = plainText, + htmlContent = "", // OPTIMIZATION: Don't store HTML in memory, it's on disk + depth = finalDepth, + isInToc = isEffectiveInToc ) - } else if (item.mediaType.startsWith("image/")) { + } else if (mediaType.startsWith("image/")) { + // Image handling remains similar, but usually small enough val htmlContent = """ - ImageImage from spine + ImageImage from spine """.trimIndent() - val ncxKey = item.absPath.substringBefore('#') + val ncxKey = absPath.substringBefore('#') val ncxData = ncxMetadataMap[ncxKey] val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) ncxData != null else true - chapterCounter++ - - tempChapters.add( - TempEpubChapter( - url = item.absPath, - title = ncxData?.title ?: "Image", - htmlFilePath = item.absPath, - chapterIndex = chapterCounter, - plainTextContent = "[Image]", - htmlContent = htmlContent, - depth = ncxData?.depth ?: 0, - isInToc = isEffectiveInToc - ) + TempEpubChapter( + url = absPath, + title = ncxData?.title ?: "Image", + htmlFilePath = absPath, + chapterIndex = index + 1, + plainTextContent = "[Image]", + htmlContent = htmlContent, + depth = ncxData?.depth ?: 0, + isInToc = isEffectiveInToc ) + } else { + null } } } + } - return tempChapters.map { tempChapter -> + val tempChapters = deferredChapters.toList().awaitAll().filterNotNull() + + return@withContext tempChapters.map { tempChapter -> EpubChapter( chapterId = generateId(), absPath = tempChapter.url, @@ -573,7 +585,6 @@ class EpubParser(private val context: Context) { }.filter { it.htmlFilePath.isNotBlank() } } - private fun parseEpubImages( manifestItems: Map, filesContentMap: Map, diff --git a/app/src/main/java/com/aryan/reader/epub/EpubXMLFileParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubXMLFileParser.kt index 15ca433..0fb9764 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubXMLFileParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubXMLFileParser.kt @@ -19,8 +19,8 @@ */ package com.aryan.reader.epub -import timber.log.Timber import org.jsoup.Jsoup +import org.jsoup.nodes.Document /** * Parses an XML/HTML file from an EPUB archive, primarily to extract a title @@ -55,8 +55,15 @@ class EpubXMLFileParser( * @return [Output] The title and effective HTML path. */ fun parseForTitleAndPath(): Output { - Timber.d("Parsing for title and path: $fileRelativePath, fragment: $fragmentId") val document = Jsoup.parse(data.inputStream(), "UTF-8", "") + return parseForTitleAndPath(document) + } + + /** + * Overload to use an existing Document to avoid double parsing. + */ + fun parseForTitleAndPath(document: Document): Output { + val extractedTitle = document.selectFirst("h1, h2, h3, h4, h5, h6")?.text()?.trim() val pathWithFragment = if (fragmentId != null) { @@ -64,7 +71,6 @@ class EpubXMLFileParser( } else { fileRelativePath } - Timber.d("Effective HTML path: $pathWithFragment for file: $fileRelativePath") return Output( title = extractedTitle, diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index e7f3f6b..3a015e9 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -21,6 +21,7 @@ package com.aryan.reader.epub import android.content.Context import com.aryan.reader.FileType +import com.aryan.reader.pdf.PdfToMarkdownGenerator import com.vladsch.flexmark.ext.autolink.AutolinkExtension import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension @@ -56,10 +57,13 @@ class SingleFileImporter(private val context: Context) { inputStream: InputStream, originalBookNameHint: String ): EpubBook = withContext(Dispatchers.IO) { - Timber.d("Parsing Markdown: $originalBookNameHint") + Timber.d("Parsing Markdown with Page-Level Chaptering: $originalBookNameHint") val title = originalBookNameHint.substringBeforeLast(".") + + // Read the full markdown content val markdownContent = inputStream.bufferedReader().use { it.readText() } + // Flexmark Setup val options = MutableDataSet().apply { set(Parser.EXTENSIONS, listOf( TablesExtension.create(), @@ -70,13 +74,10 @@ class SingleFileImporter(private val context: Context) { set(HtmlRenderer.GENERATE_HEADER_ID, true) set(HtmlRenderer.RENDER_HEADER_ID, true) } - val parser = Parser.builder(options).build() val renderer = HtmlRenderer.builder(options).build() - val document = parser.parse(markdownContent) - val htmlBody = renderer.render(document) - + // Shared CSS val style = """ body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; } table { border-collapse: collapse; width: 100%; margin: 1em 0; } @@ -84,9 +85,77 @@ class SingleFileImporter(private val context: Context) { blockquote { border-left: 4px solid currentColor; padding-left: 1em; margin-left: 0; opacity: 0.8; } pre { overflow-x: auto; background: rgba(127,127,127,0.1); padding: 1em; border-radius: 4px; } img { max-width: 100%; height: auto; } + hr { border: 0; border-top: 1px solid #ccc; margin: 2em 0; } """.trimIndent() - return@withContext createBookFromHtmlBody(title, htmlBody, style, originalBookNameHint, author = null) + val delimiter = PdfToMarkdownGenerator.PAGE_DELIMITER.trim() + val rawChapters = if (markdownContent.contains(delimiter)) { + markdownContent.split(delimiter) + } else { + markdownContent.split("\n\n---\n\n") + } + + val bookId = UUID.randomUUID().toString() + val extractionDir = File(context.cacheDir, "imported_md_$bookId").apply { + if (!exists()) mkdirs() + } + + val chapters = mutableListOf() + + rawChapters.forEachIndexed { index, rawText -> + if (rawText.isBlank()) return@forEachIndexed + + val pageNum = index + 1 + val chapterTitle = "Page $pageNum" + + val document = parser.parse(rawText) + val htmlBody = renderer.render(document) + + val fileName = "page_$pageNum.html" + val file = File(extractionDir, fileName) + + val fullHtml = """ + + + + $chapterTitle + + + + $htmlBody + + + """.trimIndent() + + file.writeText(fullHtml) + + chapters.add(EpubChapter( + chapterId = "${bookId}_$pageNum", + absPath = fileName, + title = chapterTitle, + htmlFilePath = fileName, + plainTextContent = Jsoup.parse(htmlBody).text(), + htmlContent = "", + depth = 0, + isInToc = true + )) + } + + Timber.d("Markdown import complete. Created ${chapters.size} chapters (one per page).") + + return@withContext EpubBook( + fileName = originalBookNameHint, + title = title, + author = "Unknown", + language = "en", + coverImage = null, + chapters = chapters, + chaptersForPagination = chapters, + images = emptyList(), + pageList = emptyList(), + extractionBasePath = extractionDir.absolutePath, + css = emptyMap() + ) } private suspend fun parsePlainText( @@ -143,7 +212,7 @@ class SingleFileImporter(private val context: Context) { title = chapterTitle, htmlFilePath = fileName, plainTextContent = plainText, - htmlContent = fullHtml, + htmlContent = "", depth = 0, isInToc = true ) @@ -282,7 +351,7 @@ class SingleFileImporter(private val context: Context) { title = title, htmlFilePath = "content.html", plainTextContent = plainText, - htmlContent = fullHtml, + htmlContent = "", depth = 0, isInToc = true ) diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index 8c8d67a..1a09ccb 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -151,7 +151,8 @@ fun EpubReaderTopBar( onOpenTtsSettings: () -> Unit, onOpenDeviceVoiceSettings: () -> Unit, searchFocusRequester: androidx.compose.ui.focus.FocusRequester, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onToggleReflow: (() -> Unit)? = null, ) { AnimatedVisibility( visible = isVisible, @@ -201,6 +202,24 @@ fun EpubReaderTopBar( expanded = showMoreMenu, onDismissRequest = { showMoreMenu = false } ) { + if (onToggleReflow != null) { + DropdownMenuItem( + text = { Text("View Original PDF") }, + onClick = { + showMoreMenu = false + onToggleReflow() + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.picture_as_pdf), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + HorizontalDivider() + } + DropdownMenuItem( text = { Text("Reading Mode: Vertical") }, enabled = !isTtsActive, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 57bb784..3a836c6 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -128,6 +128,7 @@ import com.aryan.reader.BannerMessage import com.aryan.reader.BuildConfig import com.aryan.reader.CustomTopBanner import com.aryan.reader.DeviceVoiceSettingsSheet +import com.aryan.reader.MainViewModel import com.aryan.reader.RenderMode import com.aryan.reader.SearchResult import com.aryan.reader.SummarizationResult @@ -291,8 +292,25 @@ fun EpubReaderScreen( coverImagePath: String?, onRenderModeChange: (RenderMode) -> Unit, customFonts: List, - onImportFont: (Uri) -> Unit + onImportFont: (Uri) -> Unit, + viewModel: MainViewModel ) { + val uiState by viewModel.uiState.collectAsState() + + val isReflowFile = uiState.selectedBookId?.endsWith("_reflow") == true + val originalBookId = if (isReflowFile) uiState.selectedBookId!!.removeSuffix("_reflow") else null + + val onOpenOriginal: (() -> Unit)? = if (originalBookId != null) { + { + val originalItem = uiState.recentFiles.find { it.bookId == originalBookId } + if (originalItem != null) { + viewModel.onRecentFileClicked(originalItem) + } else { + viewModel.showBanner("Original PDF not found.", true) + } + } + } else null + EpubReaderHost( epubBook = epubBook, renderMode = renderMode, @@ -307,7 +325,8 @@ fun EpubReaderScreen( coverImagePath = coverImagePath, onRenderModeChange = onRenderModeChange, customFonts = customFonts, - onImportFont = onImportFont + onImportFont = onImportFont, + onToggleReflow = onOpenOriginal ) } @@ -330,7 +349,8 @@ fun EpubReaderHost( coverImagePath: String?, onRenderModeChange: (RenderMode) -> Unit, customFonts: List, - onImportFont: (Uri) -> Unit + onImportFont: (Uri) -> Unit, + onToggleReflow: (() -> Unit)? = null ) { val view = LocalView.current val context = LocalContext.current @@ -389,7 +409,9 @@ fun EpubReaderHost( var isAutoScrollCollapsed by remember { mutableStateOf(false) } - val bookId = remember(epubBook.title) { getBookIdForPrefs(epubBook.title) } + val bookId = remember(epubBook.title, epubBook.fileName) { + if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title) + } var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) } val initialSettings = remember(isAutoScrollLocal) { @@ -1008,6 +1030,7 @@ fun EpubReaderHost( chapterChunks = result.chunks isChapterParsing = false loadUpToChunkIndex = result.startChunkIndex + Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: loadChapterContent finished. chapterChunks.size=${chapterChunks.size}, isChapterParsing=$isChapterParsing") if (chunkTargetOverride != null) { chunkTargetOverride = null @@ -1030,6 +1053,7 @@ fun EpubReaderHost( var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) } LaunchedEffect(paginator, currentRenderMode, isPagerInitialized) { + Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: Checking paginator init. currentRenderMode=$currentRenderMode, paginator=${paginator != null}, isPagerInitialized=$isPagerInitialized") if (currentRenderMode == RenderMode.PAGINATED && paginator != null && !isPagerInitialized) { scope.launch { val bookPaginator = paginator as? BookPaginator @@ -2880,7 +2904,8 @@ fun EpubReaderHost( searchFocusRequester = searchFocusRequester, modifier = Modifier.align(Alignment.TopCenter), onOpenTtsSettings = { showTtsSettingsSheet = true }, - onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true } + onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }, + onToggleReflow = onToggleReflow, ) val autoScrollPadding by androidx.compose.animation.core.animateDpAsState( diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt index d138096..62a3d87 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt @@ -413,10 +413,25 @@ class BookPaginator( if (cachedChapter.estimatedPageCount == 0) { Timber.d("getBlocksForChapter: Found 'lite' cache for chapter $chapterIndex. Reprocessing for full fidelity.") } else { - Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.") try { val semanticBlocks = proto.decodeFromByteArray>(cachedChapter.contentBlocksProto) - return styler.style(semanticBlocks) + + val isCacheEmpty = semanticBlocks.isEmpty() + val isLazyChapter = chapter.htmlContent.isEmpty() + + var shouldIgnoreCache = false + if (isCacheEmpty && isLazyChapter) { + val file = java.io.File(extractionBasePath, chapter.htmlFilePath) + if (file.exists() && file.length() > 0) { + Timber.tag("ReflowPaginationDiag").w("getBlocksForChapter: Cache HIT but empty for lazy chapter $chapterIndex. Backing file exists (${file.length()} bytes). Ignoring cache.") + shouldIgnoreCache = true + } + } + + if (!shouldIgnoreCache) { + Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.") + return styler.style(semanticBlocks) + } } catch (e: Exception) { Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.") } @@ -424,7 +439,23 @@ class BookPaginator( } Timber.d("getBlocksForChapter: Cache MISS or 'lite' version found for chapter $chapterIndex. Parsing to Semantic IR.") - val document = Jsoup.parse(chapter.htmlContent, chapter.absPath) + + var htmlToParse = chapter.htmlContent + if (htmlToParse.isEmpty()) { + val file = java.io.File(extractionBasePath, chapter.htmlFilePath) + if (file.exists()) { + Timber.tag("ReflowPaginationDiag").d("getBlocksForChapter: Lazy loading content from disk for chapter $chapterIndex: ${file.name} (${file.length()} bytes)") + try { + htmlToParse = file.readText() + } catch (e: Exception) { + Timber.tag("ReflowPaginationDiag").e(e, "Failed to read lazy HTML file") + } + } else { + Timber.tag("ReflowPaginationDiag").w("getBlocksForChapter: htmlContent is empty and file not found: ${file.absolutePath}") + } + } + + val document = Jsoup.parse(htmlToParse, chapter.absPath) val mathElements = document.select("math") val svgResults = mutableMapOf() diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index 09f34b1..b04d1ce 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -655,7 +655,10 @@ fun PaginatedReaderScreen( BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao() val proto = ProtoBuf { serializersModule = semanticBlockModule } - Timber.d("Recreating BookPaginator. TextAlign: $userTextAlign") + val uniqueBookId = if (book.fileName.length > 20) book.fileName else book.title + + Timber.d("Recreating BookPaginator for ID: $uniqueBookId. TextAlign: $userTextAlign") + Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: Instantiating BookPaginator. book.chaptersForPagination.size=${book.chaptersForPagination.size}, initialChapter=$effectiveInitialChapter") BookPaginator( coroutineScope = coroutineScope, @@ -667,7 +670,7 @@ fun PaginatedReaderScreen( density = density, fontFamilyMap = fontFamilyMap, isDarkTheme = isDarkTheme, - bookId = book.title, + bookId = uniqueBookId, bookCacheDao = bookCacheDao, proto = proto, initialChapterToPaginate = effectiveInitialChapter, @@ -719,23 +722,29 @@ fun PaginatedReaderScreen( } } - // FIX 2: Replace property delegates with local state and a LaunchedEffect observer. var isLoading by remember { mutableStateOf(true) } var totalPageCount by remember { mutableIntStateOf(0) } var generation by remember { mutableIntStateOf(0) } LaunchedEffect(paginator) { - launch { snapshotFlow { paginator.isLoading }.collect { isLoading = it } } + launch { snapshotFlow { paginator.isLoading }.collect { + Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.isLoading=$it") + isLoading = it + } } launch { snapshotFlow { paginator.totalPageCount }.collect { newTotalPageCount -> + Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.totalPageCount=$newTotalPageCount") totalPageCount = newTotalPageCount } } - launch { snapshotFlow { paginator.generation }.collect { generation = it } } + launch { snapshotFlow { paginator.generation }.collect { + Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.generation=$it") + generation = it + } } } LaunchedEffect(pagerState, paginator) { - snapshotFlow { pagerState.currentPage }.debounce(500) // Wait for scrolling to settle + snapshotFlow { pagerState.currentPage }.debounce(500) .collectLatest { page -> paginator.onUserScrolledTo(page) } } @@ -1432,6 +1441,7 @@ internal fun PaginatedReaderContent( CircularProgressIndicator() } } else { + Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: isLoading=false, totalPageCount=${uiState.totalPageCount}") if (uiState.totalPageCount > 0) { uiState.generation @@ -1508,7 +1518,9 @@ internal fun PaginatedReaderContent( var currentChapterPath by remember { mutableStateOf(null) } LaunchedEffect(pageIndex, uiState.generation) { + Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetching page $pageIndex content. generation=${uiState.generation}") pageContent = onGetPage(pageIndex) + Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetched page $pageIndex content. isNull=${pageContent == null}, blocks=${pageContent?.content?.size}") onGetChapterPath(pageIndex)?.let { currentChapterPath = it } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfReflowGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfReflowGenerator.kt new file mode 100644 index 0000000..337b18d --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfReflowGenerator.kt @@ -0,0 +1,135 @@ +package com.aryan.reader.pdf + +import android.content.Context +import com.aryan.reader.epub.EpubBook +import com.aryan.reader.epub.EpubChapter +import com.aryan.reader.pdf.data.PdfTextRepository +import io.legere.pdfiumandroid.suspend.PdfDocumentKt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID + +object PdfReflowGenerator { + + suspend fun generateReflowBook( + context: Context, + bookId: String, + document: PdfDocumentKt, + repository: PdfTextRepository, + totalPages: Int + ): EpubBook = withContext(Dispatchers.Default) { + val cacheDir = File(context.cacheDir, "reflow_cache/$bookId") + if (cacheDir.exists()) { + cacheDir.deleteRecursively() + } + cacheDir.mkdirs() + + val chapters = mutableListOf() + val css = """ + body { font-family: sans-serif; line-height: 1.6; padding: 1em; } + p { margin-bottom: 1em; } + h1, h2 { color: #333; margin-top: 1.5em; } + .page-marker { color: #888; font-size: 0.8em; margin-bottom: 2em; border-bottom: 1px solid #eee; } + """.trimIndent() + + // We generate a chapter for every page to keep sync simple + for (i in 0 until totalPages) { + val rawText = repository.getOrExtractText(bookId, document, i) + val cleanedHtml = processTextToHtml(rawText, i + 1) + + val fileName = "page_$i.html" + val file = File(cacheDir, fileName) + + val fullHtml = """ + + + + Page ${i + 1} + + + + $cleanedHtml + + + """.trimIndent() + + file.writeText(fullHtml) + + chapters.add( + EpubChapter( + chapterId = "${bookId}_page_$i", + absPath = fileName, + title = "Page ${i + 1}", + htmlFilePath = fileName, + plainTextContent = rawText, // Raw text for search/TTS + htmlContent = fullHtml, + depth = 0, + isInToc = true + ) + ) + } + + EpubBook( + fileName = "Reflow_Session", + title = document.getDocumentMeta().title ?: "Reflow View", + author = document.getDocumentMeta().author ?: "", + language = "en", + coverImage = null, + chapters = chapters, + chaptersForPagination = chapters, + images = emptyList(), + pageList = emptyList(), + extractionBasePath = cacheDir.absolutePath, + css = emptyMap() + ) + } + + private fun processTextToHtml(rawText: String, pageNumber: Int): String { + if (rawText.isBlank()) return "

(No text on this page)

" + + val lines = rawText.split('\n') + val sb = StringBuilder() + + sb.append("
Page $pageNumber
") + + var currentParagraph = StringBuilder() + + for (line in lines) { + val trimmed = line.trim() + if (trimmed.isEmpty()) { + if (currentParagraph.isNotEmpty()) { + sb.append("

${currentParagraph.toString()}

") + currentParagraph.clear() + } + continue + } + + // Heuristic: Header detection (All caps, short line, no punctuation at end) + val isHeader = trimmed.length < 50 && trimmed.all { it.isUpperCase() || !it.isLetter() } && !trimmed.endsWith(".") + + if (isHeader) { + if (currentParagraph.isNotEmpty()) { + sb.append("

${currentParagraph.toString()}

") + currentParagraph.clear() + } + sb.append("

$trimmed

") + continue + } + + if (currentParagraph.isNotEmpty()) { + currentParagraph.append(" ") + } + currentParagraph.append(trimmed) + + if (trimmed.endsWith(".") || trimmed.endsWith("?") || trimmed.endsWith("!") || trimmed.endsWith(":")) { + } + } + + if (currentParagraph.isNotEmpty()) { + sb.append("

${currentParagraph.toString()}

") + } + + return sb.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt b/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt new file mode 100644 index 0000000..4c21a4b --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt @@ -0,0 +1,134 @@ +// PdfToMarkdownGenerator.kt +package com.aryan.reader.pdf + +import android.content.Context +import android.net.Uri +import com.tom_roush.pdfbox.io.MemoryUsageSetting +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.pdmodel.PDPage +import com.tom_roush.pdfbox.text.PDFTextStripper +import com.tom_roush.pdfbox.text.TextPosition +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import kotlin.math.roundToInt + +object PdfToMarkdownGenerator { + + // Unique delimiter to split pages reliably + const val PAGE_DELIMITER = "\n\n[[PAGE_BREAK]]\n\n" + + suspend fun generateMarkdownFile( + context: Context, + pdfUri: Uri, + destFile: File, + startPage: Int = 1, + onProgress: (Float) -> Unit + ): Boolean = withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(pdfUri)?.use { inputStream -> + // Setup mixed memory usage to handle larger files without OOM + PDDocument.load(inputStream, MemoryUsageSetting.setupMixed(50 * 1024 * 1024)).use { doc -> + val totalPages = doc.numberOfPages + + // Configure stripper for linear processing + val stripper = MarkdownStripper(totalPages, onProgress) + stripper.startPage = startPage + stripper.endPage = totalPages + + // Write directly to file stream (O(N) complexity) + destFile.bufferedWriter().use { writer -> + stripper.writeText(doc, writer) + } + } + } + return@withContext true + } catch (e: Exception) { + Timber.e(e, "Failed to generate Markdown from PDF") + return@withContext false + } + } + + private class MarkdownStripper( + private val totalPages: Int, + private val onProgress: (Float) -> Unit + ) : PDFTextStripper() { + private var currentPageBaseFontSize = 0f + + init { + sortByPosition = true + suppressDuplicateOverlappingText = true + paragraphStart = "" + paragraphEnd = "\n\n" + } + + // Override endPage to update progress and insert delimiter + override fun endPage(page: PDPage?) { + super.endPage(page) + + try { + // Insert our custom delimiter so importer can split chapters + output.write(PAGE_DELIMITER) + + // Update progress + val current = currentPageNo // inherited from PDFTextStripper + if (totalPages > 0) { + onProgress(current.toFloat() / totalPages.toFloat()) + } + } catch (e: Exception) { + Timber.e(e, "Error writing page delimiter") + } + } + + override fun startPage(page: PDPage?) { + currentPageBaseFontSize = 0f + super.startPage(page) + } + + private fun calculateBaseFontSize(textPositions: List) { + val sizeCounts = mutableMapOf() + textPositions.forEach { pos -> + val size = pos.fontSizeInPt.roundToInt().toFloat() + sizeCounts[size] = (sizeCounts[size] ?: 0) + 1 + } + currentPageBaseFontSize = sizeCounts.maxByOrNull { it.value }?.key ?: 12f + } + + override fun writeString(text: String?, textPositions: MutableList?) { + if (text.isNullOrBlank() || textPositions.isNullOrEmpty()) return + + if (currentPageBaseFontSize == 0f) { + calculateBaseFontSize(textPositions) + } + + val firstPos = textPositions[0] + val fontSize = firstPos.fontSizeInPt + val fontDescriptor = firstPos.font?.fontDescriptor + + val isBold = fontDescriptor?.isForceBold == true || + (firstPos.font?.name?.contains("Bold", ignoreCase = true) == true) + val isItalic = fontDescriptor?.isItalic == true || + (firstPos.font?.name?.contains("Italic", ignoreCase = true) == true) + + // Header detection logic + val isHeader = fontSize > currentPageBaseFontSize * 1.2 + val isBigHeader = fontSize > currentPageBaseFontSize * 1.5 + + val sb = StringBuilder() + + if (isBigHeader) sb.append("## ") + else if (isHeader) sb.append("### ") + + if (isBold && !isHeader) sb.append("**") + if (isItalic) sb.append("*") + + text.forEach { char -> sb.append(char) } + + if (isItalic) sb.append("*") + if (isBold && !isHeader) sb.append("**") + + writeString(sb.toString()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 9b82eed..cfd87e3 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -29,6 +29,8 @@ import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.RectF +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarResult import android.net.Uri import android.os.Build import android.os.ParcelFileDescriptor @@ -220,6 +222,7 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey +import androidx.work.WorkInfo import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionResult import com.aryan.reader.BuildConfig @@ -673,7 +676,16 @@ fun PdfViewerScreen( var isBackgroundIndexing by remember { mutableStateOf(false) } var backgroundIndexingProgress by remember { mutableFloatStateOf(0f) } + var currentBookId by remember { mutableStateOf(null) } + val bookId = currentBookId ?: pdfUri.toString().hashCode().toString() + val uiState by viewModel.uiState.collectAsState() + val reflowBookId = remember(bookId) { "${bookId}_reflow" } + val hasReflowFile by remember(uiState.recentFiles, reflowBookId) { + derivedStateOf { + uiState.recentFiles.any { it.bookId == reflowBookId && !it.isDeleted } + } + } val originalFileName by remember(uiState.recentFiles, pdfUri) { derivedStateOf { uiState.recentFiles.find { it.uriString == pdfUri.toString() }?.displayName @@ -737,9 +749,6 @@ fun PdfViewerScreen( derivedStateOf { isEditMode && !isDockMinimized } } - var currentBookId by remember { mutableStateOf(null) } - val bookId = currentBookId ?: pdfUri.toString().hashCode().toString() - var isAutoScrollLocal by remember { mutableStateOf(loadPdfAutoScrollLocalMode(context, bookId)) } LaunchedEffect(bookId) { @@ -1602,6 +1611,23 @@ fun PdfViewerScreen( } } + val reflowInfo by viewModel.reflowWorkInfo.collectAsState(initial = null) + + val isReflowingThisBook by remember(reflowInfo, bookId) { + derivedStateOf { + reflowInfo?.tags?.contains("book_$bookId") == true && + (reflowInfo?.state == WorkInfo.State.RUNNING || reflowInfo?.state == WorkInfo.State.ENQUEUED) + } + } + + val reflowProgressValue by remember(reflowInfo, isReflowingThisBook) { + derivedStateOf { + if (isReflowingThisBook) { + reflowInfo?.progress?.getFloat(ReflowWorker.KEY_PROGRESS, 0f) ?: 0f + } else 0f + } + } + val onBookmarkClick: () -> Unit = { val currentPage = if (displayMode == DisplayMode.PAGINATION) { pagerState.currentPage @@ -1611,6 +1637,25 @@ fun PdfViewerScreen( onToggleBookmark(currentPage) } + LaunchedEffect(reflowInfo) { + if (reflowInfo?.state == WorkInfo.State.SUCCEEDED && + reflowInfo?.tags?.contains("book_$bookId") == true) { + + val result = snackbarHostState.showSnackbar( + message = "Text View generation complete!", + actionLabel = "OPEN", + duration = SnackbarDuration.Long + ) + + if (result == SnackbarResult.ActionPerformed) { + val item = uiState.recentFiles.find { it.bookId == reflowBookId } + if (item != null) { + viewModel.onRecentFileClicked(item) + } + } + } + } + LaunchedEffect(pdfUri) { debugPdfLinks(context, pdfUri, pdfiumCore, this) } LaunchedEffect(currentBookId) { @@ -2837,6 +2882,12 @@ fun PdfViewerScreen( } } + val showStandardBars = showBars && !isEditMode + val snackbarPadding by animateDpAsState( + targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp else 0.dp, + label = "SnackbarPadding" + ) + ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { @@ -3100,7 +3151,14 @@ fun PdfViewerScreen( } } }) { - Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues -> + Scaffold( + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = snackbarPadding) + ) + } + ) { paddingValues -> BoxWithConstraints(modifier = Modifier .fillMaxSize() .padding(paddingValues)) { @@ -4083,8 +4141,6 @@ fun PdfViewerScreen( } } - val showStandardBars = showBars && !isEditMode - // Custom Top Bar AnimatedVisibility( visible = showStandardBars, @@ -4298,7 +4354,7 @@ fun PdfViewerScreen( ) } ) - + if (BuildConfig.DEBUG) { DropdownMenuItem( text = { Text("TTS Settings (Debug)") }, @@ -4345,6 +4401,44 @@ fun PdfViewerScreen( ) ) } + + HorizontalDivider() + + DropdownMenuItem( + text = { + Text( + when { + isReflowingThisBook -> "Generating... ${(reflowProgressValue * 100).toInt()}%" + hasReflowFile -> "Open Text View" + else -> "Generate Text View" + } + ) + }, + enabled = pdfDocument != null && !isReflowingThisBook, + onClick = { + showMoreMenu = false + if (hasReflowFile) { + val item = uiState.recentFiles.find { it.bookId == reflowBookId } + if (item != null) { + viewModel.onRecentFileClicked(item) + } + } else { + viewModel.generateAndImportReflowFile( + pdfBookId = bookId, + pdfUri = pdfUri, + originalTitle = originalFileName + ) + } + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.format_size), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + ) + HorizontalDivider() DropdownMenuItem(text = { Text("Share") }, onClick = { @@ -4373,6 +4467,50 @@ fun PdfViewerScreen( } } + AnimatedVisibility( + visible = showStandardBars && isReflowingThisBook, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically(), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 56.dp) + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), + shadowElevation = 4.dp + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Generating Text View...", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = "${(reflowProgressValue * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(Modifier.height(8.dp)) + androidx.compose.material3.LinearProgressIndicator( + progress = { reflowProgressValue }, + modifier = Modifier.fillMaxWidth().height(6.dp), + trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) + } + } + } + // Search Results Panel AnimatedVisibility( visible = searchState.isSearchActive && searchState.showSearchResultsPanel, @@ -5772,6 +5910,7 @@ fun PdfViewerScreen( } } } + val autoScrollPadding by animateDpAsState( targetValue = if (showBars) (56.dp + 16.dp) else 16.dp, label = "AutoScrollPadding" diff --git a/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt b/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt new file mode 100644 index 0000000..09c7d0d --- /dev/null +++ b/app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt @@ -0,0 +1,84 @@ +// ReflowWorker.kt +package com.aryan.reader.pdf + +import android.content.Context +import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.aryan.reader.FileType +import com.aryan.reader.data.RecentFileItem +import com.aryan.reader.data.RecentFilesRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File + +class ReflowWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val bookId = inputData.getString(KEY_BOOK_ID) ?: return@withContext Result.failure() + val pdfUriString = inputData.getString(KEY_PDF_URI) ?: return@withContext Result.failure() + val originalTitle = inputData.getString(KEY_ORIGINAL_TITLE) ?: "Document" + val reflowBookId = "${bookId}_reflow" + + val destFile = File(applicationContext.filesDir, "${bookId}_reflow.md") + val pdfUri = pdfUriString.toUri() + + Timber.tag("ReflowWorker").d("Starting background reflow for $originalTitle.") + + // Delegate entire process to Generator (it now handles the loop and progress) + val success = PdfToMarkdownGenerator.generateMarkdownFile( + applicationContext, + pdfUri, + destFile, + startPage = 1 // Always start from beginning for full regeneration + ) { progress -> + // Report progress + setProgressAsync(workDataOf(KEY_PROGRESS to progress)) + } + + if (success && destFile.exists()) { + Timber.tag("ReflowWorker").d("Reflow complete. Importing to database.") + + val repo = RecentFilesRepository(applicationContext) + + val newItem = RecentFileItem( + bookId = reflowBookId, + uriString = destFile.toUri().toString(), + type = FileType.MD, + displayName = "$originalTitle (Text View)", + timestamp = System.currentTimeMillis(), + coverImagePath = null, + title = "$originalTitle (Reflow)", + author = "Generated", + isAvailable = true, + isRecent = true, + lastModifiedTimestamp = System.currentTimeMillis(), + isDeleted = false, + sourceFolderUri = null + ) + + repo.addRecentFile(newItem) + + // 100% Progress + setProgressAsync(workDataOf(KEY_PROGRESS to 1.0f)) + + return@withContext Result.success() + } else { + Timber.e("Reflow failed or was incomplete.") + return@withContext Result.failure() + } + } + + companion object { + const val WORK_NAME = "reflow_work" + const val KEY_BOOK_ID = "book_id" + const val KEY_PDF_URI = "pdf_uri" + const val KEY_ORIGINAL_TITLE = "original_title" + const val KEY_PROGRESS = "progress" + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/picture_as_pdf.xml b/app/src/main/res/drawable-nodpi/picture_as_pdf.xml new file mode 100644 index 0000000..86be6ef --- /dev/null +++ b/app/src/main/res/drawable-nodpi/picture_as_pdf.xml @@ -0,0 +1,10 @@ + + +