Pdf text reflow (#36)

* Implemented PDF reflow mode by introducing a mechanism to convert PDF content to Markdown/HTML for viewing in the EPUB reader.

Specific changes include:
- Added `PdfReflowGenerator` and `PdfToMarkdownGenerator` to handle PDF text extraction and conversion to reflowable formats.
- Updated `MainViewModel` with `toggleReflowMode` logic to switch between original PDF and reflowed views.
- Modified `RecentFileEntity` and `RecentFileDao` to persist user reflow preferences, including a Room database migration (v12 to v13).
- Updated `PdfViewerScreen` and `EpubReaderControls` to include UI options for toggling reflow mode.
- Integrated reflow preference check into the book opening workflow to automatically load the preferred view.
- Updated `AppNavigation` and `EpubReaderScreen` to support the new view switching state.

* Implemented background processing and incremental loading for PDF reflow mode.

- Added `reflowProgress` to `MainViewModel` to track and display PDF-to-Markdown conversion progress in the UI.
- Refactored `PdfToMarkdownGenerator` to generate a skeleton EPUB structure immediately while processing page content (text and images) asynchronously.
- Switched PDF text extraction to use `PDFBox` with optimized memory settings and JPEG compression for images.
- Implemented priority page processing in reflow mode, starting with the user's current page.
- Added "Clear Reflow Cache" debug option to the Home Screen.
- Enhanced `BookPaginator` to support lazy loading of chapter content from disk and improved cache hit detection.

* Refactored PDF Reflow Mode to generate standalone Markdown files instead of temporary EPUB books.

* perf(reflow): optimize PDF-to-Markdown conversion and fix viewing lag

- Re-architected PdfToMarkdownGenerator to use a single-pass stream (O(N) complexity), fixing performance bottlenecks and timeouts on large PDFs.
- Implemented "Virtual Chaptering" in SingleFileImporter for Markdown files to split content into page-level HTML files, eliminating UI lag during reading.
- Simplified ReflowWorker to delegate progress tracking and looping to the generator.
- Enhanced PdfViewerScreen with a prominent top-bar progress indicator and a completion snackbar with an "OPEN" action.

* Optimized EPUB parsing performance and fixed PDF viewer UI layout.

- Optimized `EpubParser` by implementing parallel chapter parsing using coroutines and a semaphore to limit concurrency.
- Reduced memory usage in `EpubParser` and `SingleFileImporter` by no longer storing full HTML content in memory for chapters.
- Updated `EpubXMLFileParser` to support an existing `Document` object to avoid redundant Jsoup parsing.
- Fixed an issue in `PdfViewerScreen` where the snackbar was appearing under the bottom app bar.
This commit is contained in:
Aryan 2026-03-07 16:10:34 +05:30 committed by GitHub
parent 8f52549c19
commit 1e879eb604
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 936 additions and 165 deletions

View file

@ -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 -> {

View file

@ -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 = {

View file

@ -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<WorkInfo?> = 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<ReflowWorker>()
.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"

View file

@ -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()

View file

@ -40,6 +40,9 @@ interface RecentFileDao {
@Query("SELECT * FROM recent_files")
suspend fun getAllFiles(): List<RecentFileEntity>
@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<RecentFileEntity>

View file

@ -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
)

View file

@ -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
)
}

View file

@ -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.")

View file

@ -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<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,
ncxMetadataMap: Map<String, NcxMetadata>
): List<EpubChapter> {
var chapterCounter = 0
val tempChapters = mutableListOf<TempEpubChapter>()
): List<EpubChapter> = 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 = """
<!DOCTYPE html><html style="margin:0;padding:0;height:100%;"><head><title>Image</title></head><body style="margin:0;padding:0;height:100%;text-align:center;"><img src="${item.absPath}" alt="Image from spine" style="object-fit:contain;width:100%;height:100%;"/></body></html>
<!DOCTYPE html><html style="margin:0;padding:0;height:100%;"><head><title>Image</title></head><body style="margin:0;padding:0;height:100%;text-align:center;"><img src="$absPath" alt="Image from spine" style="object-fit:contain;width:100%;height:100%;"/></body></html>
""".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<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,

View file

@ -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,

View file

@ -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<EpubChapter>()
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 = """
<!DOCTYPE html>
<html>
<head>
<title>$chapterTitle</title>
<style>$style</style>
</head>
<body>
$htmlBody
</body>
</html>
""".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
)

View file

@ -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,

View file

@ -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<CustomFontEntity>,
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<CustomFontEntity>,
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(

View file

@ -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<List<SemanticBlock>>(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<String, String>()

View file

@ -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<String?>(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 }
}

View file

@ -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<EpubChapter>()
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 = """
<!DOCTYPE html>
<html>
<head>
<title>Page ${i + 1}</title>
<style>$css</style>
</head>
<body>
$cleanedHtml
</body>
</html>
""".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 "<p><i>(No text on this page)</i></p>"
val lines = rawText.split('\n')
val sb = StringBuilder()
sb.append("<div class='page-marker'>Page $pageNumber</div>")
var currentParagraph = StringBuilder()
for (line in lines) {
val trimmed = line.trim()
if (trimmed.isEmpty()) {
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
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("<p>${currentParagraph.toString()}</p>")
currentParagraph.clear()
}
sb.append("<h2>$trimmed</h2>")
continue
}
if (currentParagraph.isNotEmpty()) {
currentParagraph.append(" ")
}
currentParagraph.append(trimmed)
if (trimmed.endsWith(".") || trimmed.endsWith("?") || trimmed.endsWith("!") || trimmed.endsWith(":")) {
}
}
if (currentParagraph.isNotEmpty()) {
sb.append("<p>${currentParagraph.toString()}</p>")
}
return sb.toString()
}
}

View file

@ -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<TextPosition>) {
val sizeCounts = mutableMapOf<Float, Int>()
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<TextPosition>?) {
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())
}
}
}

View file

@ -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<String?>(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<String?>(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"

View file

@ -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"
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M360,500L400,500L400,420L440,420Q457,420 468.5,408.5Q480,397 480,380L480,340Q480,323 468.5,311.5Q457,300 440,300L360,300L360,500ZM400,380L400,340L440,340L440,380L400,380ZM520,500L600,500Q617,500 628.5,488.5Q640,477 640,460L640,340Q640,323 628.5,311.5Q617,300 600,300L520,300L520,500ZM560,460L560,340L600,340L600,460L560,460ZM680,500L720,500L720,420L760,420L760,380L720,380L720,340L760,340L760,300L680,300L680,500ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z"/>
</vector>