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:
parent
8f52549c19
commit
1e879eb604
21 changed files with 936 additions and 165 deletions
|
|
@ -31,7 +31,7 @@ class EpubTestActivity : ComponentActivity() {
|
|||
coverImagePath = null,
|
||||
onRenderModeChange = {},
|
||||
customFonts = TODO(),
|
||||
onImportFont = TODO()
|
||||
onImportFont = TODO(), viewModel = TODO()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
135
app/src/main/java/com/aryan/reader/pdf/PdfReflowGenerator.kt
Normal file
135
app/src/main/java/com/aryan/reader/pdf/PdfReflowGenerator.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
134
app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt
Normal file
134
app/src/main/java/com/aryan/reader/pdf/PdfToMarkdownGenerator.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
84
app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt
Normal file
84
app/src/main/java/com/aryan/reader/pdf/ReflowWorker.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable-nodpi/picture_as_pdf.xml
Normal file
10
app/src/main/res/drawable-nodpi/picture_as_pdf.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue