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

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