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

@ -31,7 +31,7 @@ class EpubTestActivity : ComponentActivity() {
coverImagePath = null, coverImagePath = null,
onRenderModeChange = {}, onRenderModeChange = {},
customFonts = TODO(), customFonts = TODO(),
onImportFont = TODO() onImportFont = TODO(), viewModel = TODO()
) )
} }
} }

View file

@ -169,7 +169,7 @@ fun AppNavigation(
Timber.i("Displaying EPUB Reader for Book: ${epubBook.title}, initialLocator: $initialLocator") Timber.i("Displaying EPUB Reader for Book: ${epubBook.title}, initialLocator: $initialLocator")
val coverPath = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.coverImagePath val coverPath = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.coverImagePath
val epubUri = uiState.selectedEpubUri 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() val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
EpubReaderScreen( EpubReaderScreen(
@ -204,6 +204,7 @@ fun AppNavigation(
onRenderModeChange = viewModel::setRenderMode, onRenderModeChange = viewModel::setRenderMode,
customFonts = customFonts, customFonts = customFonts,
onImportFont = viewModel::importFont, onImportFont = viewModel::importFont,
viewModel = viewModel
) )
} }
isLoading -> { isLoading -> {

View file

@ -277,7 +277,8 @@ fun HomeScreen(
} }
}, },
onShowDeviceManagement = viewModel::showDeviceManagementForDebug, onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
onFolderSyncToggle = viewModel::setFolderSyncEnabled onFolderSyncToggle = viewModel::setFolderSyncEnabled,
onClearReflowCache = viewModel::clearReflowCache
) )
} else { } else {
ContextualTopAppBar( ContextualTopAppBar(
@ -643,6 +644,7 @@ fun DefaultTopAppBar(
onRenderModeChange: (RenderMode) -> Unit, onRenderModeChange: (RenderMode) -> Unit,
onClearCache: () -> Unit, onClearCache: () -> Unit,
onClearCloudData: () -> Unit, onClearCloudData: () -> Unit,
onClearReflowCache: () -> Unit, // Add this parameter
onDrawerClick: () -> Unit, onDrawerClick: () -> Unit,
onAboutClick: () -> Unit, onAboutClick: () -> Unit,
onShowDeviceManagement: () -> Unit, onShowDeviceManagement: () -> Unit,
@ -684,6 +686,10 @@ fun DefaultTopAppBar(
onClearCache() onClearCache()
showOptionsMenu = false showOptionsMenu = false
}) })
DropdownMenuItem(text = { Text("[Debug] Clear Reflow Cache") }, onClick = {
onClearReflowCache()
showOptionsMenu = false
})
DropdownMenuItem( DropdownMenuItem(
text = { Text("[Debug] Clear Cloud & Local Data") }, text = { Text("[Debug] Clear Cloud & Local Data") },
onClick = { 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.paginatedreader.data.BookProcessingWorker
import com.aryan.reader.pdf.PdfCoverGenerator import com.aryan.reader.pdf.PdfCoverGenerator
import com.aryan.reader.pdf.PdfExporter 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.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfAnnotationRepository import com.aryan.reader.pdf.data.PdfAnnotationRepository
@ -80,10 +81,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -188,6 +191,7 @@ data class ReaderScreenState(
val searchQuery: String = "", val searchQuery: String = "",
val showFolderMigrationDialog: Boolean = false, val showFolderMigrationDialog: Boolean = false,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val reflowProgress: Float? = null
) )
open class MainViewModel(application: Application) : AndroidViewModel(application) { 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( private fun openBook(
uri: Uri, bookId: String, type: FileType, originalDisplayName: String? = null 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 { viewModelScope.launch {
it.copy( _internalState.update {
selectedPdfUri = null, it.copy(
selectedEpubUri = null, selectedPdfUri = null,
selectedBookId = bookId, selectedEpubUri = null,
selectedEpubBook = null, selectedBookId = bookId,
selectedFileType = type, selectedEpubBook = null,
isLoading = true, selectedFileType = type,
errorMessage = null, isLoading = true,
initialLocator = null, errorMessage = null,
initialPageInBook = 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
) )
} }
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) {
viewModelScope.launch { if (type == FileType.PDF) {
val recentItem = recentFilesRepository.getFileByBookId(bookId) viewModelScope.launch {
if (recentItem?.sourceFolderUri != null) { val recentItem = recentFilesRepository.getFileByBookId(bookId)
launch(Dispatchers.IO) {
recentFilesRepository.syncLocalMetadataToFolder(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
} }
_internalState.update { Timber.d("openBook: Loading PDF. bookId=$bookId ...")
it.copy( _internalState.update {
selectedEpubUri = uri, it.copy(
initialLocator = locator, selectedPdfUri = uri,
initialCfi = recentItem?.lastPositionCfi, initialPageInBook = recentItem?.lastPage,
initialBookmarksJson = recentItem?.bookmarksJson 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) { _internalState.update {
FileType.EPUB -> { it.copy(
loadEpub(uri, bookId, customDisplayName = originalDisplayName) selectedEpubUri = uri,
initialLocator = locator,
initialCfi = recentItem?.lastPositionCfi,
initialBookmarksJson = recentItem?.bookmarksJson
)
} }
FileType.MOBI -> {
loadMobi(uri, bookId, customDisplayName = originalDisplayName) when (type) {
} FileType.EPUB -> {
else -> { loadEpub(uri, bookId, customDisplayName = originalDisplayName)
loadSingleFile(uri, bookId, type, 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 { companion object {
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
internal const val KEY_SHELVES = "shelf_names" internal const val KEY_SHELVES = "shelf_names"

View file

@ -27,7 +27,7 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase 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) @TypeConverters(FileTypeConverter::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun recentFileDao(): RecentFileDao 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 { fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
@ -174,11 +180,11 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"reader_database" "reader_database"
) )
// 4. Add migration to builder
.addMigrations( .addMigrations(
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, 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_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) .fallbackToDestructiveMigration(false)
.build() .build()

View file

@ -40,6 +40,9 @@ interface RecentFileDao {
@Query("SELECT * FROM recent_files") @Query("SELECT * FROM recent_files")
suspend fun getAllFiles(): List<RecentFileEntity> 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") @Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List<RecentFileEntity> fun getRecentFilesList(limit: Int): List<RecentFileEntity>

View file

@ -47,5 +47,6 @@ data class RecentFileEntity(
val locatorBlockIndex: Int?, val locatorBlockIndex: Int?,
val locatorCharOffset: Int?, val locatorCharOffset: Int?,
val bookmarks: String?, 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 lastModifiedTimestamp: Long = 0L,
val isDeleted: Boolean = false, val isDeleted: Boolean = false,
val bookmarksJson: String? = null, val bookmarksJson: String? = null,
val sourceFolderUri: String? = null val sourceFolderUri: String? = null,
val isReflowPreferred: Boolean = false
) { ) {
fun getUri(): Uri? = uriString?.toUri() fun getUri(): Uri? = uriString?.toUri()
} }
@ -69,7 +70,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
lastModifiedTimestamp = this.lastModifiedTimestamp, lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = this.isDeleted, isDeleted = this.isDeleted,
bookmarksJson = this.bookmarks, bookmarksJson = this.bookmarks,
sourceFolderUri = this.sourceFolderUri sourceFolderUri = this.sourceFolderUri,
isReflowPreferred = this.isReflowPreferred
) )
} }
@ -94,7 +96,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
lastModifiedTimestamp = this.lastModifiedTimestamp, lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = this.isDeleted, isDeleted = this.isDeleted,
bookmarks = this.bookmarksJson, 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() } 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) { suspend fun detachAllFolderBooks() = withContext(Dispatchers.IO) {
recentFileDao.detachAllFolderBooks() recentFileDao.detachAllFolderBooks()
Timber.d("Detached all folder books. They are now standard local files.") 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.nio.file.Paths
import java.util.UUID import java.util.UUID
import java.util.zip.ZipFile 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) { class EpubParser(private val context: Context) {
data class EpubDocument( data class EpubDocument(
@ -472,94 +476,102 @@ class EpubParser(private val context: Context) {
return UUID.randomUUID().toString() return UUID.randomUUID().toString()
} }
private fun parseUsingSpine( private suspend fun parseUsingSpine(
spine: Node, spine: Node,
manifestItems: Map<String, EpubManifestItem>, manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>, filesContentMap: Map<String, EpubFile>,
ncxMetadataMap: Map<String, NcxMetadata> ncxMetadataMap: Map<String, NcxMetadata>
): List<EpubChapter> { ): List<EpubChapter> = withContext(Dispatchers.Default) {
var chapterCounter = 0 val parsingSemaphore = Semaphore(6)
val tempChapters = mutableListOf<TempEpubChapter>()
spine.selectChildTag("itemref") val spineItems = spine.selectChildTag("itemref")
.ifEmpty { spine.selectChildTag("opf:itemref") } .ifEmpty { spine.selectChildTag("opf:itemref") }
.mapNotNull { manifestItems[it.getAttribute("idref")] }
.forEach { item -> val deferredChapters = spineItems.mapIndexed { index, itemRef ->
val fileBytes = filesContentMap[item.absPath]?.data async {
if (fileBytes != null) { parsingSemaphore.withPermit {
if (item.mediaType.startsWith("application/xhtml+xml") || val idRef = itemRef.getAttribute("idref")
item.mediaType.startsWith("text/html") || val item = manifestItems[idRef] ?: return@withPermit null
item.absPath.endsWith(".html", ignoreCase = true) ||
item.absPath.endsWith(".xhtml", ignoreCase = true) || val fileBytes = filesContentMap[item.absPath]?.data ?: return@withPermit null
item.absPath.endsWith(".xml", ignoreCase = true)
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 rawHtml = String(fileBytes, Charsets.UTF_8)
val plainText = Jsoup.parse(rawHtml).text() val document = Jsoup.parse(rawHtml)
val plainText = document.text()
val parser = EpubXMLFileParser( val parser = EpubXMLFileParser(
fileRelativePath = item.absPath, fileRelativePath = absPath,
data = fileBytes, data = fileBytes,
fragmentId = null fragmentId = null
) )
val res = parser.parseForTitleAndPath() val res = parser.parseForTitleAndPath(document)
val chapterTitleFromHtml = res.title val chapterTitleFromHtml = res.title
val ncxKey = item.absPath.substringBefore('#') val ncxKey = absPath.substringBefore('#')
val ncxData = ncxMetadataMap[ncxKey] val ncxData = ncxMetadataMap[ncxKey]
val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) { val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) {
ncxData != null ncxData != null
} else { } else {
true true
} }
val finalChapterTitle = if (ncxData != null && ncxData.title.isNotBlank()) { val finalChapterTitle = if (ncxData != null && ncxData.title.isNotBlank()) {
ncxData.title ncxData.title
} else { } else {
Timber.d("No NCX title for ${item.absPath}, using HTML title: '$chapterTitleFromHtml'")
chapterTitleFromHtml chapterTitleFromHtml
} }
val finalDepth = ncxData?.depth ?: 0 val finalDepth = ncxData?.depth ?: 0
chapterCounter++ TempEpubChapter(
url = absPath,
tempChapters.add( title = finalChapterTitle,
TempEpubChapter( htmlFilePath = res.effectiveHtmlPath,
url = item.absPath, chapterIndex = index + 1,
title = finalChapterTitle, plainTextContent = plainText,
htmlFilePath = res.effectiveHtmlPath, htmlContent = "", // OPTIMIZATION: Don't store HTML in memory, it's on disk
chapterIndex = chapterCounter, depth = finalDepth,
plainTextContent = plainText, isInToc = isEffectiveInToc
htmlContent = rawHtml,
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 = """ 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() """.trimIndent()
val ncxKey = item.absPath.substringBefore('#') val ncxKey = absPath.substringBefore('#')
val ncxData = ncxMetadataMap[ncxKey] val ncxData = ncxMetadataMap[ncxKey]
val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) ncxData != null else true val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) ncxData != null else true
chapterCounter++ TempEpubChapter(
url = absPath,
tempChapters.add( title = ncxData?.title ?: "Image",
TempEpubChapter( htmlFilePath = absPath,
url = item.absPath, chapterIndex = index + 1,
title = ncxData?.title ?: "Image", plainTextContent = "[Image]",
htmlFilePath = item.absPath, htmlContent = htmlContent,
chapterIndex = chapterCounter, depth = ncxData?.depth ?: 0,
plainTextContent = "[Image]", isInToc = isEffectiveInToc
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( EpubChapter(
chapterId = generateId(), chapterId = generateId(),
absPath = tempChapter.url, absPath = tempChapter.url,
@ -573,7 +585,6 @@ class EpubParser(private val context: Context) {
}.filter { it.htmlFilePath.isNotBlank() } }.filter { it.htmlFilePath.isNotBlank() }
} }
private fun parseEpubImages( private fun parseEpubImages(
manifestItems: Map<String, EpubManifestItem>, manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>, filesContentMap: Map<String, EpubFile>,

View file

@ -19,8 +19,8 @@
*/ */
package com.aryan.reader.epub package com.aryan.reader.epub
import timber.log.Timber
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document
/** /**
* Parses an XML/HTML file from an EPUB archive, primarily to extract a title * 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. * @return [Output] The title and effective HTML path.
*/ */
fun parseForTitleAndPath(): Output { fun parseForTitleAndPath(): Output {
Timber.d("Parsing for title and path: $fileRelativePath, fragment: $fragmentId")
val document = Jsoup.parse(data.inputStream(), "UTF-8", "") 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 extractedTitle = document.selectFirst("h1, h2, h3, h4, h5, h6")?.text()?.trim()
val pathWithFragment = if (fragmentId != null) { val pathWithFragment = if (fragmentId != null) {
@ -64,7 +71,6 @@ class EpubXMLFileParser(
} else { } else {
fileRelativePath fileRelativePath
} }
Timber.d("Effective HTML path: $pathWithFragment for file: $fileRelativePath")
return Output( return Output(
title = extractedTitle, title = extractedTitle,

View file

@ -21,6 +21,7 @@ package com.aryan.reader.epub
import android.content.Context import android.content.Context
import com.aryan.reader.FileType import com.aryan.reader.FileType
import com.aryan.reader.pdf.PdfToMarkdownGenerator
import com.vladsch.flexmark.ext.autolink.AutolinkExtension import com.vladsch.flexmark.ext.autolink.AutolinkExtension
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
@ -56,10 +57,13 @@ class SingleFileImporter(private val context: Context) {
inputStream: InputStream, inputStream: InputStream,
originalBookNameHint: String originalBookNameHint: String
): EpubBook = withContext(Dispatchers.IO) { ): EpubBook = withContext(Dispatchers.IO) {
Timber.d("Parsing Markdown: $originalBookNameHint") Timber.d("Parsing Markdown with Page-Level Chaptering: $originalBookNameHint")
val title = originalBookNameHint.substringBeforeLast(".") val title = originalBookNameHint.substringBeforeLast(".")
// Read the full markdown content
val markdownContent = inputStream.bufferedReader().use { it.readText() } val markdownContent = inputStream.bufferedReader().use { it.readText() }
// Flexmark Setup
val options = MutableDataSet().apply { val options = MutableDataSet().apply {
set(Parser.EXTENSIONS, listOf( set(Parser.EXTENSIONS, listOf(
TablesExtension.create(), TablesExtension.create(),
@ -70,13 +74,10 @@ class SingleFileImporter(private val context: Context) {
set(HtmlRenderer.GENERATE_HEADER_ID, true) set(HtmlRenderer.GENERATE_HEADER_ID, true)
set(HtmlRenderer.RENDER_HEADER_ID, true) set(HtmlRenderer.RENDER_HEADER_ID, true)
} }
val parser = Parser.builder(options).build() val parser = Parser.builder(options).build()
val renderer = HtmlRenderer.builder(options).build() val renderer = HtmlRenderer.builder(options).build()
val document = parser.parse(markdownContent) // Shared CSS
val htmlBody = renderer.render(document)
val style = """ val style = """
body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; } 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; } 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; } 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; } pre { overflow-x: auto; background: rgba(127,127,127,0.1); padding: 1em; border-radius: 4px; }
img { max-width: 100%; height: auto; } img { max-width: 100%; height: auto; }
hr { border: 0; border-top: 1px solid #ccc; margin: 2em 0; }
""".trimIndent() """.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( private suspend fun parsePlainText(
@ -143,7 +212,7 @@ class SingleFileImporter(private val context: Context) {
title = chapterTitle, title = chapterTitle,
htmlFilePath = fileName, htmlFilePath = fileName,
plainTextContent = plainText, plainTextContent = plainText,
htmlContent = fullHtml, htmlContent = "",
depth = 0, depth = 0,
isInToc = true isInToc = true
) )
@ -282,7 +351,7 @@ class SingleFileImporter(private val context: Context) {
title = title, title = title,
htmlFilePath = "content.html", htmlFilePath = "content.html",
plainTextContent = plainText, plainTextContent = plainText,
htmlContent = fullHtml, htmlContent = "",
depth = 0, depth = 0,
isInToc = true isInToc = true
) )

View file

@ -151,7 +151,8 @@ fun EpubReaderTopBar(
onOpenTtsSettings: () -> Unit, onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit, onOpenDeviceVoiceSettings: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester, searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null,
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = isVisible,
@ -201,6 +202,24 @@ fun EpubReaderTopBar(
expanded = showMoreMenu, expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false } 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( DropdownMenuItem(
text = { Text("Reading Mode: Vertical") }, text = { Text("Reading Mode: Vertical") },
enabled = !isTtsActive, enabled = !isTtsActive,

View file

@ -128,6 +128,7 @@ import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.CustomTopBanner import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel
import com.aryan.reader.RenderMode import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
@ -291,8 +292,25 @@ fun EpubReaderScreen(
coverImagePath: String?, coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit, onRenderModeChange: (RenderMode) -> Unit,
customFonts: List<CustomFontEntity>, 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( EpubReaderHost(
epubBook = epubBook, epubBook = epubBook,
renderMode = renderMode, renderMode = renderMode,
@ -307,7 +325,8 @@ fun EpubReaderScreen(
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
onRenderModeChange = onRenderModeChange, onRenderModeChange = onRenderModeChange,
customFonts = customFonts, customFonts = customFonts,
onImportFont = onImportFont onImportFont = onImportFont,
onToggleReflow = onOpenOriginal
) )
} }
@ -330,7 +349,8 @@ fun EpubReaderHost(
coverImagePath: String?, coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit, onRenderModeChange: (RenderMode) -> Unit,
customFonts: List<CustomFontEntity>, customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit onImportFont: (Uri) -> Unit,
onToggleReflow: (() -> Unit)? = null
) { ) {
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current val context = LocalContext.current
@ -389,7 +409,9 @@ fun EpubReaderHost(
var isAutoScrollCollapsed by remember { mutableStateOf(false) } 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)) } var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
val initialSettings = remember(isAutoScrollLocal) { val initialSettings = remember(isAutoScrollLocal) {
@ -1008,6 +1030,7 @@ fun EpubReaderHost(
chapterChunks = result.chunks chapterChunks = result.chunks
isChapterParsing = false isChapterParsing = false
loadUpToChunkIndex = result.startChunkIndex loadUpToChunkIndex = result.startChunkIndex
Timber.tag("ReflowPaginationDiag").d("EpubReaderScreen: loadChapterContent finished. chapterChunks.size=${chapterChunks.size}, isChapterParsing=$isChapterParsing")
if (chunkTargetOverride != null) { if (chunkTargetOverride != null) {
chunkTargetOverride = null chunkTargetOverride = null
@ -1030,6 +1053,7 @@ fun EpubReaderHost(
var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) } var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) }
LaunchedEffect(paginator, currentRenderMode, isPagerInitialized) { 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) { if (currentRenderMode == RenderMode.PAGINATED && paginator != null && !isPagerInitialized) {
scope.launch { scope.launch {
val bookPaginator = paginator as? BookPaginator val bookPaginator = paginator as? BookPaginator
@ -2880,7 +2904,8 @@ fun EpubReaderHost(
searchFocusRequester = searchFocusRequester, searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter), modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true } onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
onToggleReflow = onToggleReflow,
) )
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState( val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(

View file

@ -413,10 +413,25 @@ class BookPaginator(
if (cachedChapter.estimatedPageCount == 0) { if (cachedChapter.estimatedPageCount == 0) {
Timber.d("getBlocksForChapter: Found 'lite' cache for chapter $chapterIndex. Reprocessing for full fidelity.") Timber.d("getBlocksForChapter: Found 'lite' cache for chapter $chapterIndex. Reprocessing for full fidelity.")
} else { } else {
Timber.d("getBlocksForChapter: Cache HIT for chapter $chapterIndex in DATABASE.")
try { try {
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto) 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) { } catch (e: Exception) {
Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.") 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.") 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 mathElements = document.select("math")
val svgResults = mutableMapOf<String, String>() val svgResults = mutableMapOf<String, String>()

View file

@ -655,7 +655,10 @@ fun PaginatedReaderScreen(
BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao() BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val proto = ProtoBuf { serializersModule = semanticBlockModule } 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( BookPaginator(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
@ -667,7 +670,7 @@ fun PaginatedReaderScreen(
density = density, density = density,
fontFamilyMap = fontFamilyMap, fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
bookId = book.title, bookId = uniqueBookId,
bookCacheDao = bookCacheDao, bookCacheDao = bookCacheDao,
proto = proto, proto = proto,
initialChapterToPaginate = effectiveInitialChapter, 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 isLoading by remember { mutableStateOf(true) }
var totalPageCount by remember { mutableIntStateOf(0) } var totalPageCount by remember { mutableIntStateOf(0) }
var generation by remember { mutableIntStateOf(0) } var generation by remember { mutableIntStateOf(0) }
LaunchedEffect(paginator) { 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 { launch {
snapshotFlow { paginator.totalPageCount }.collect { newTotalPageCount -> snapshotFlow { paginator.totalPageCount }.collect { newTotalPageCount ->
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderScreen: paginator.totalPageCount=$newTotalPageCount")
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) { LaunchedEffect(pagerState, paginator) {
snapshotFlow { pagerState.currentPage }.debounce(500) // Wait for scrolling to settle snapshotFlow { pagerState.currentPage }.debounce(500)
.collectLatest { page -> paginator.onUserScrolledTo(page) } .collectLatest { page -> paginator.onUserScrolledTo(page) }
} }
@ -1432,6 +1441,7 @@ internal fun PaginatedReaderContent(
CircularProgressIndicator() CircularProgressIndicator()
} }
} else { } else {
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: isLoading=false, totalPageCount=${uiState.totalPageCount}")
if (uiState.totalPageCount > 0) { if (uiState.totalPageCount > 0) {
uiState.generation uiState.generation
@ -1508,7 +1518,9 @@ internal fun PaginatedReaderContent(
var currentChapterPath by remember { mutableStateOf<String?>(null) } var currentChapterPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(pageIndex, uiState.generation) { LaunchedEffect(pageIndex, uiState.generation) {
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetching page $pageIndex content. generation=${uiState.generation}")
pageContent = onGetPage(pageIndex) pageContent = onGetPage(pageIndex)
Timber.tag("ReflowPaginationDiag").d("PaginatedReaderContent: Fetched page $pageIndex content. isNull=${pageContent == null}, blocks=${pageContent?.content?.size}")
onGetChapterPath(pageIndex)?.let { currentChapterPath = it } 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.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.RectF import android.graphics.RectF
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
@ -220,6 +222,7 @@ import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import androidx.work.WorkInfo
import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
@ -673,7 +676,16 @@ fun PdfViewerScreen(
var isBackgroundIndexing by remember { mutableStateOf(false) } var isBackgroundIndexing by remember { mutableStateOf(false) }
var backgroundIndexingProgress by remember { mutableFloatStateOf(0f) } 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 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) { val originalFileName by remember(uiState.recentFiles, pdfUri) {
derivedStateOf { derivedStateOf {
uiState.recentFiles.find { it.uriString == pdfUri.toString() }?.displayName uiState.recentFiles.find { it.uriString == pdfUri.toString() }?.displayName
@ -737,9 +749,6 @@ fun PdfViewerScreen(
derivedStateOf { isEditMode && !isDockMinimized } 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)) } var isAutoScrollLocal by remember { mutableStateOf(loadPdfAutoScrollLocalMode(context, bookId)) }
LaunchedEffect(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 onBookmarkClick: () -> Unit = {
val currentPage = if (displayMode == DisplayMode.PAGINATION) { val currentPage = if (displayMode == DisplayMode.PAGINATION) {
pagerState.currentPage pagerState.currentPage
@ -1611,6 +1637,25 @@ fun PdfViewerScreen(
onToggleBookmark(currentPage) 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(pdfUri) { debugPdfLinks(context, pdfUri, pdfiumCore, this) }
LaunchedEffect(currentBookId) { 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( ModalNavigationDrawer(
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { 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 BoxWithConstraints(modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)) { .padding(paddingValues)) {
@ -4083,8 +4141,6 @@ fun PdfViewerScreen(
} }
} }
val showStandardBars = showBars && !isEditMode
// Custom Top Bar // Custom Top Bar
AnimatedVisibility( AnimatedVisibility(
visible = showStandardBars, visible = showStandardBars,
@ -4298,7 +4354,7 @@ fun PdfViewerScreen(
) )
} }
) )
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("TTS Settings (Debug)") }, 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() HorizontalDivider()
DropdownMenuItem(text = { Text("Share") }, onClick = { 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 // Search Results Panel
AnimatedVisibility( AnimatedVisibility(
visible = searchState.isSearchActive && searchState.showSearchResultsPanel, visible = searchState.isSearchActive && searchState.showSearchResultsPanel,
@ -5772,6 +5910,7 @@ fun PdfViewerScreen(
} }
} }
} }
val autoScrollPadding by animateDpAsState( val autoScrollPadding by animateDpAsState(
targetValue = if (showBars) (56.dp + 16.dp) else 16.dp, targetValue = if (showBars) (56.dp + 16.dp) else 16.dp,
label = "AutoScrollPadding" 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>