* Centralize library management logic and introduce support for plain text and HTML formats

* Centralize library management logic and introduce support for plain text and HTML formats

* Expand unit test coverage for library state management, UI models, and MainViewModel features.

* Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence

* Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges.

* Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin

* Add comprehensive unit tests

* Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic.

* Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version

* Folder import support for desktop app

* Introduce Smart Shelves with rule-based filtering in desktop version

* Implement shared EPUB annotation serialization and highlight rendering

* Centralize file type capabilities and platform-specific support logic

* Refactor reader state management to use a central reducer

* Implement customizable reader toolbar and advanced formatting settings in shared

* Implement locator-based navigation and customizable highlight palette for desktop app

* Enhance reader customization and expand search functionality in desktop app

* Redesign reader settings and tools into a tabbed control panel in desktop app

* Enhance reader navigation and highlight precision in desktop app

* Implement bidirectional position synchronization and dynamic highlights in the desktop reader

* Implement shared state management and enhanced search for the PDF reader in desktop app

* Add vertical scroll support to the desktop PDF reader

* Implement ink, text, and eraser annotation support in desktop PDF viewer

* Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app

* Implement link handling and navigation for PDF and EPUB readers in desktop app

* Implement PDF jump history for navigation in desktop app

* Enhance PDF ink rendering and annotation capabilities in desktop app

* Implement advanced PDF text annotations with inline editing and rich styling in desktop app

* Add move handle and movement logic for PDF text annotations in desktop app

* Implement local folder synchronization and metadata sidecar support in desktop app

* Implement book metadata extraction and drag-and-drop import for Desktop

* Implement dynamic and custom app theme management for desktop

* Introduce canonical PDF annotation codec and support for multi-segment highlights

* Implement rich text editing and pagination support for the PDF reader in desktop app

* Improve PDF rich text pagination, synchronization, and observability in desktop

* Hide trailing structural page breaks in rich text editor

* Implement a unified JVM book loader and expand supported formats on Desktop

* Add comic archive support for Desktop and enhance MOBI parsing

* Implement shared OPDS catalog support and UI for Android and Desktop

* Improve native WebView lifecycle and surface transition management on Desktop

* Enable Compose Swing interop blending and simplify Desktop WebView management

* Integrate BYOK AI features and Cloud TTS for desktop

* Enhance Desktop TTS with streaming audio and improved secure storage for AI key

* Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app

* Implement custom font management and utility screens in desktop app

* Implement PDFium-based PDF annotation export

* Remove PdfBox dependency and standardize PDF export via Pdfium

* Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app

* Implement reader themes and custom texture support in desktop app

* Redesign non-reader UI with responsive navigation and enhanced library management in desktop app

* Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app

* Exclude manual-only files from automated sync and import

* Implement customizable Text-to-Speech (TTS) word replacements

* Optimize reader performance with persistent layout caching and decoupled theme rendering

* Improve position restoration during reader reconfiguration in epub pagination

* Use independent thickness for eraser tool and stylus override
This commit is contained in:
Aryan 2026-05-10 10:07:37 +05:30 committed by GitHub
parent 88c7fa7b5c
commit 8366d76dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 53372 additions and 4702 deletions

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,26 @@ private val codeOrDataExtensions = setOf(
"go"
)
private val manualOnlyReaderMimeTypes = setOf(
"text/csv",
"text/comma-separated-values",
"text/tab-separated-values",
"application/json",
"application/xml",
"text/xml",
"text/x-java-source",
"text/x-python",
"text/x-kotlin",
"text/javascript",
"application/javascript",
"text/x-c",
"text/x-c++",
"text/x-csharp",
"text/x-ruby",
"text/x-go",
"text/x-log"
)
internal fun resolveFileTypeFromName(fileName: String?): FileType? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val effectiveName = lowerName.withTransparentTextSuffix()
@ -44,6 +64,22 @@ internal fun isCodeOrDataFileName(fileName: String): Boolean {
return fileName.lowercase().withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
}
internal fun isManualOnlyReaderFileName(fileName: String?): Boolean {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return false
return lowerName.withTransparentTextSuffix().extensionAfterLastDot() in codeOrDataExtensions
}
internal fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
val normalized = mimeType?.lowercase() ?: return false
return normalized in manualOnlyReaderMimeTypes
}
internal fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
if (isManualOnlyReaderFileName(name)) return false
if (resolveFileTypeFromName(name) != null) return true
return !isManualOnlyReaderMimeType(mimeType)
}
internal fun resolveFileExtensionSuffixFromName(fileName: String?): String? {
val lowerName = fileName?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val effectiveName = lowerName.withTransparentTextSuffix()

View file

@ -40,7 +40,6 @@ import com.aryan.reader.data.LocalSyncUtils
import com.aryan.reader.data.FolderBookMetadata
import java.io.File
import android.provider.DocumentsContract
import java.security.MessageDigest
class FolderSyncWorker(
private val appContext: Context,
@ -316,7 +315,13 @@ class FolderSyncWorker(
val lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
val type = getFileType(name, mimeType)
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
if (
type != null &&
type in allowedFileTypes &&
isLocalFolderSyncEligibleFile(name, mimeType) &&
!name.endsWith(".json") &&
!name.startsWith(".")
) {
supportedBooksSeen++
val stableId = buildStableBookId(name, rootDocId, docId)
foundBookIds.add(stableId)
@ -554,11 +559,14 @@ class FolderSyncWorker(
val sidecarData = preloadedSidecars[book.bookId] ?: continue
val (remoteTs, jsonPayload) = sidecarData
val safeSlashBookId = book.bookId.replace("/", "_")
val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_")
val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
File(appContext.filesDir, "page_layouts/layout_${book.bookId}.json"),
File(appContext.filesDir, "pdf_text_boxes/boxes_${book.bookId}.json")
File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"),
File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"),
File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"),
File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"),
File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json")
)
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
@ -607,10 +615,7 @@ class FolderSyncWorker(
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
val relativePath = buildRelativePath(rootDocId, docId, name)
if (relativePath.equals(name, ignoreCase = true)) {
return "local_$name"
}
return "local_${name}_${shortHash(relativePath.lowercase())}"
return com.aryan.reader.shared.LocalFolderSyncEngine.buildStableBookId(name, relativePath)
}
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
@ -625,11 +630,6 @@ class FolderSyncWorker(
return relative.ifBlank { fallbackName }
}
private fun shortHash(value: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }.take(12)
}
private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? {
val uriString = item.uriString ?: return null
return try {

View file

@ -3147,11 +3147,12 @@ fun OpdsBookDetailsSheet(
}
}
if (!entry.summary.isNullOrBlank()) {
val summary = entry.summary
if (!summary.isNullOrBlank()) {
Text(stringResource(R.string.synopsis), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
val cleanSummary = remember(entry.summary) {
val preProcessed = entry.summary
val cleanSummary = remember(summary) {
val preProcessed = summary
.replace("<br>", "\n")
.replace("</p>", "\n\n")
Jsoup.parse(preProcessed).text().trim()

View file

@ -4,8 +4,8 @@ import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.SmartCollectionEngine
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.SmartCollectionEngine
fun interface FolderPathResolver {
fun relativeFolderSegments(item: RecentFileItem): List<String>
@ -183,7 +183,7 @@ class LibraryStateProjector(
if (shelfEntity.isSmart && shelfEntity.smartRulesJson != null) {
val rules = SmartCollectionEngine.fromJson(shelfEntity.smartRulesJson)
if (rules != null) {
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it, rules) }
val matchingBooks = allLibraryFiles.filter { SmartCollectionEngine.evaluate(it.toSharedBookItem(), rules) }
allShelves.add(Shelf(shelfEntity.id, shelfEntity.name, ShelfType.SMART, sortFiles(matchingBooks, sortOrder)))
shelvedBookIds.addAll(matchingBooks.map { it.bookId })
}

View file

@ -53,6 +53,7 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.aryan.reader.data.BookMetadata
import com.aryan.reader.data.CloudflareRepository
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.data.FeedbackRepository
@ -83,8 +84,8 @@ import com.aryan.reader.paginatedreader.Locator
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.PdfUserHighlight
import com.aryan.reader.pdf.PdfiumAnnotationExporter
import com.aryan.reader.pdf.ReflowWorker
import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfAnnotation
@ -94,7 +95,9 @@ import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.VirtualPage
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.aryan.reader.shared.SharedLibraryEditor
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import io.legere.pdfiumandroid.PdfiumCore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
@ -709,27 +712,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun createAndAssignTag(name: String, bookIds: Set<String>) {
val trimmedName = name.trim()
if (trimmedName.isBlank() || bookIds.isEmpty()) return
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
if (sanitizedBookIds.isEmpty()) return
viewModelScope.launch {
val tagId = UUID.randomUUID().toString()
val colors = listOf(0xFFE57373, 0xFFF06292, 0xFFBA68C8, 0xFF9575CD, 0xFF7986CB, 0xFF64B5F6, 0xFF4FC3F7, 0xFF4DD0E1, 0xFF4DB6AC, 0xFF81C784, 0xFFAED581, 0xFFFF8A65, 0xFFA1887F, 0xFF90A4AE)
val color = colors.random().toInt()
val tag = TagEntity(tagId, trimmedName, color, System.currentTimeMillis())
val now = System.currentTimeMillis()
val tag = SharedLibraryEditor.createTag(name, tagId, color)?.toTagEntity(now) ?: return@launch
recentFilesRepository.createTag(tag)
bookIds.forEach { bookId ->
sanitizedBookIds.forEach { bookId ->
recentFilesRepository.assignTagToBook(bookId, tagId)
}
}
}
fun toggleTagForBooks(tagId: String, bookIds: Set<String>, assign: Boolean) {
if (tagId.isBlank() || bookIds.isEmpty()) return
val sanitizedBookIds = SharedLibraryEditor.cleanBookIds(bookIds)
if (tagId.isBlank() || sanitizedBookIds.isEmpty()) return
viewModelScope.launch {
bookIds.forEach { bookId ->
sanitizedBookIds.forEach { bookId ->
if (assign) {
recentFilesRepository.assignTagToBook(bookId, tagId)
} else {
@ -1094,9 +1098,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
prefs.edit { putBoolean(KEY_DEFAULT_TAGS_SEEDED, true) }
}
}
viewModelScope.launch(Dispatchers.IO) {
PDFBoxResourceLoader.init(getApplication())
}
val currentOpenCount = prefs.getInt(KEY_APP_OPEN_COUNT, 0)
prefs.edit { putInt(KEY_APP_OPEN_COUNT, currentOpenCount + 1) }
@ -1779,7 +1780,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val virtualPages = pageLayoutRepository.getLayoutOrNull(bookId)
val outputStream = appContext.contentResolver.openOutputStream(destUri)
if (outputStream != null) {
PdfExporter.exportAnnotatedPdf(
PdfiumAnnotationExporter.exportAnnotatedPdf(
context = appContext,
sourceUri = sourceUri,
destStream = outputStream,
@ -1889,24 +1890,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
val destFile = File(shareDir, filename)
val outputStream = FileOutputStream(destFile)
FileOutputStream(destFile).use { outputStream ->
if (includeAnnotations) {
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
if (includeAnnotations) {
val virtualPages = pageLayoutRepository.getLayoutOrNull(resolvedBookId)
PdfExporter.exportAnnotatedPdf(
context = appContext,
sourceUri = sourceUri,
destStream = outputStream,
virtualPages = virtualPages,
inkAnnotations = annotations,
richTextPageLayouts = richTextPageLayouts,
textBoxes = textBoxes,
highlights = highlights
)
} else {
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
input.copyTo(outputStream)
PdfiumAnnotationExporter.exportAnnotatedPdf(
context = appContext,
sourceUri = sourceUri,
destStream = outputStream,
virtualPages = virtualPages,
inkAnnotations = annotations,
richTextPageLayouts = richTextPageLayouts,
textBoxes = textBoxes,
highlights = highlights
)
} else {
appContext.contentResolver.openInputStream(sourceUri)?.use { input ->
input.copyTo(outputStream)
}
}
}
@ -1953,6 +1954,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
return
}
if (book.isManualOnlyReaderFile()) {
Timber.d("Skipping metadata sync for manual-only reader file: ${book.displayName}")
return
}
val currentUser = uiState.value.currentUser ?: return
viewModelScope.launch {
@ -1972,6 +1978,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val hasTextBoxes = textBoxFile.exists()
val hasHighlights = highlightFile.exists()
val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " +
"richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData"
)
if (hasAnyData) {
if (googleDriveRepository.hasDrivePermissions(appContext)) {
@ -1985,12 +1995,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (file == null || !file.exists()) return
try {
val content = file.readText().trim()
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.export.readRichText book=${book.bookId} rawLen=${content.length} " +
"file=${file.absolutePath}"
)
}
if (content.startsWith("[")) {
bundleJson.put(key, JSONArray(content))
} else if (content.startsWith("{")) {
bundleJson.put(key, JSONObject(content))
}
} catch (e: Exception) {
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
.e(e, "android.cloud.export.richTextParseFailed book=${book.bookId}")
}
Timber.e(e, "Failed to parse local $key file")
}
}
@ -2003,7 +2023,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val bundleFile =
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
bundleFile.writeText(bundleJson.toString())
val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
bundleFile.writeText(canonicalBundle)
if (hasRichText) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " +
"bundleFile=${bundleFile.absolutePath}"
)
}
val uploaded = googleDriveRepository.uploadAnnotationFile(
accessToken, book.bookId, bundleFile
@ -2011,9 +2038,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bundleFile.delete()
if (uploaded != null) {
if (hasRichText) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
.d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}")
}
Timber.tag("AnnotationSync")
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
} else {
if (hasRichText) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
.e("android.cloud.export.uploadFailed book=${book.bookId}")
}
Timber.tag("AnnotationSync")
.e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
return@launch
@ -2204,7 +2239,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
while (nextIdx < totalChapters) {
Timber.tag("TTS_BG_ADVANCE").d("Trying chapter $nextIdx natively.")
val nativeChunks = locatorConverter.getTtsChunksForChapter(book, nextIdx)
val nativeChunks = locatorConverter.getTtsChunksForChapter(book, nextIdx, bookId)
if (!nativeChunks.isNullOrEmpty()) {
val token = getAuthToken()
@ -2231,7 +2266,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
// Save reading position locally
val cfi = nativeChunks.firstOrNull()?.sourceCfi
if (cfi != null) {
val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi)
val locator = locatorConverter.getLocatorFromCfi(book, nextIdx, cfi, bookId)
if (locator != null) {
recentFilesRepository.getFileByBookId(bookId)?.uriString?.let { uriString ->
recentFilesRepository.updateEpubReadingPosition(uriString, locator, cfi, 0f)
@ -2872,25 +2907,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else {
allFiles.filter { it.sourceFolderUri == null }
}
filtered.filterNot { it.uriString?.startsWith("opds-pse") == true }
filtered
.filterNot { it.uriString?.startsWith("opds-pse") == true }
.filterNot { it.isManualOnlyReaderFile() }
}
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
val remoteBooks = remoteBooksDeferred.await()
.filterNot { it.isManualOnlyReaderFile() }
val remoteShelves = remoteShelvesDeferred.await()
val syncableBookIds = (localBooks.map { it.bookId } + remoteBooks.map { it.bookId }).toSet()
val allKnownShelfNames =
(localShelfNames + remoteShelvesDeferred.await().map { it.name }).toSet()
(localShelfNames + remoteShelves.map { it.name }).toSet()
val localShelves = allKnownShelfNames.mapNotNull { name ->
val timestamp = prefs.getLong("$KEY_SHELF_TIMESTAMP_PREFIX$name", 0L)
if (timestamp == 0L && name !in localShelfNames) return@mapNotNull null
val bookIds = prefs.getStringSet(
"$KEY_SHELF_CONTENT_PREFIX$name", emptySet()
).orEmpty().toList()
).orEmpty().filter { it in syncableBookIds }
val isDeleted = prefs.getBoolean("$KEY_SHELF_DELETED_PREFIX$name", false)
ShelfMetadata(name, bookIds, timestamp, isDeleted)
}
val remoteBooks = remoteBooksDeferred.await()
val remoteShelves = remoteShelvesDeferred.await()
// 3. Merge Books
val localBooksMap = localBooks.associateBy { it.bookId }
val remoteBooksMap = remoteBooks.associateBy { it.bookId }
@ -2985,7 +3023,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentShelves.add(remote.name)
putStringSet(
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
remote.bookIds.toSet()
remote.bookIds.filter { it in syncableBookIds }.toSet()
)
}
putStringSet(KEY_SHELVES, currentShelves)
@ -3014,7 +3052,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
currentShelves.add(remote.name)
putStringSet(
"$KEY_SHELF_CONTENT_PREFIX${remote.name}",
remote.bookIds.toSet()
remote.bookIds.filter { it in syncableBookIds }.toSet()
)
}
putStringSet(KEY_SHELVES, currentShelves)
@ -3033,7 +3071,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val finalMergedBooks = withContext(Dispatchers.IO) {
recentFilesRepository.getAllFilesForSync()
}
}.filterNot { it.isManualOnlyReaderFile() }
val remoteFiles = withContext(Dispatchers.IO) {
googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name }
}
@ -3100,11 +3138,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
try {
val jsonString = tempDownloadFile.readText()
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}"
)
// Determine format
val isBundle = try {
val obj = JSONObject(jsonString)
obj.has("version") || obj.has("ink") || obj.has("text") || obj.has("layout")
obj.has("version") ||
obj.has(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
obj.has("ink") ||
obj.has("text") ||
obj.has("layout") ||
obj.has("textBoxes") ||
obj.has("highlights")
} catch (_: Exception) {
false
}
@ -3124,13 +3171,29 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
highlightFile.parentFile?.mkdirs()
if (isBundle) {
val bundle = JSONObject(jsonString)
val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
)
fun writeSafe(key: String, file: File) {
if (bundle.has(key)) {
file.parentFile?.mkdirs()
file.writeText(bundle.get(key).toString())
val content = bundle.get(key).toString()
file.writeText(content)
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
)
}
} else {
if (key == "text" && file.exists()) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}"
)
}
if (file.exists()) file.delete()
}
}
@ -4592,21 +4655,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun createShelf(name: String) {
if (name.isNotBlank()) {
viewModelScope.launch {
val shelfId = UUID.randomUUID().toString()
val shelf = com.aryan.reader.data.ShelfEntity(
id = shelfId,
name = name,
isSmart = false,
smartRulesJson = null,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
recentFilesRepository.addShelf(shelf)
dismissCreateShelfDialog()
syncShelfChangeToFirestore(shelfId)
}
val shelfId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val shelf = SharedLibraryEditor.createShelfRecord(name, shelfId)?.toShelfEntity(now) ?: return
viewModelScope.launch {
recentFilesRepository.addShelf(shelf)
dismissCreateShelfDialog()
syncShelfChangeToFirestore(shelfId)
}
}
@ -4651,12 +4706,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun renameShelf(shelfId: String, newName: String) {
if (shelfId.isBlank() || newName.isBlank()) {
val cleanName = SharedLibraryEditor.cleanShelfName(newName)
if (!SharedLibraryEditor.canMutateShelf(shelfId) || cleanName == null) {
dismissRenameShelfDialog()
return
}
viewModelScope.launch {
recentFilesRepository.renameShelf(shelfId, newName)
recentFilesRepository.renameShelf(shelfId, cleanName)
syncShelfChangeToFirestore(shelfId)
_internalState.update { it.copy(viewingShelfId = shelfId) }
persistLibraryLandingState()
@ -4665,7 +4721,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun deleteShelf(shelfId: String) {
if (shelfId.isBlank() || shelfId == "unshelved") {
if (!SharedLibraryEditor.canMutateShelf(shelfId)) {
dismissDeleteShelfDialog()
return
}
@ -4697,21 +4753,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun removeContextualItemsFromShelf() {
val shelfId = _internalState.value.viewingShelfId
if (shelfId.isNullOrBlank() || shelfId == "unshelved") {
if (!SharedLibraryEditor.canMutateShelf(shelfId)) {
clearContextualAction()
return
}
val targetShelfId = shelfId ?: return
val bookIdsToRemove = _internalState.value.contextualActionItems.map { it.bookId }
val bookIdsToRemove = SharedLibraryEditor.cleanBookIds(_internalState.value.contextualActionItems.map { it.bookId })
if (bookIdsToRemove.isEmpty()) {
clearContextualAction()
return
}
viewModelScope.launch {
recentFilesRepository.removeBooksFromShelf(shelfId, bookIdsToRemove)
recentFilesRepository.removeBooksFromShelf(targetShelfId, bookIdsToRemove.toList())
clearContextualAction()
syncShelfChangeToFirestore(shelfId)
syncShelfChangeToFirestore(targetShelfId)
}
}
@ -4755,6 +4812,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun deleteSelectedShelves() {
val shelvesToDelete = _internalState.value.contextualActionShelfIds
.filterTo(mutableSetOf()) { SharedLibraryEditor.canMutateShelf(it) }
if (shelvesToDelete.isEmpty()) {
clearShelfContextualAction()
return
@ -4788,7 +4846,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val db = com.aryan.reader.data.AppDatabase.getDatabase(appContext)
val shelf = db.shelfDao().getShelfById(shelfId) ?: return@launch
val crossRefs = db.shelfDao().getCrossRefsForShelf(shelfId)
val manualOnlyBookIds = recentFilesRepository.getAllFilesForSync()
.filter { it.isManualOnlyReaderFile() }
.mapTo(mutableSetOf()) { it.bookId }
val bookIds = crossRefs.map { it.bookId }
.filterNot { it in manualOnlyBookIds }
val shelfMetadata = ShelfMetadata(
name = shelf.name,
@ -4814,8 +4876,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun addBooksToShelf(shelfId: String) {
val bookIdsToAdd = _internalState.value.booksSelectedForAdding
if (bookIdsToAdd.isEmpty()) {
val bookIdsToAdd = SharedLibraryEditor.cleanBookIds(_internalState.value.booksSelectedForAdding)
if (!SharedLibraryEditor.canMutateShelf(shelfId) || bookIdsToAdd.isEmpty()) {
dismissAddBooksToShelf()
return
}
@ -4943,6 +5005,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
.associateBy { it.name }
for (item in managedBooks) {
if (item.isManualOnlyReaderFile()) {
cleanupBookDataLocally(item.bookId)
recentFilesRepository.deleteFilePermanently(listOf(item.bookId))
continue
}
recentFilesRepository.markAsDeleted(listOf(item.bookId))
cleanupBookDataLocally(item.bookId)
@ -5462,3 +5530,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
}
private fun RecentFileItem.isManualOnlyReaderFile(): Boolean {
return isManualOnlyReaderFileName(displayName)
}
private fun BookMetadata.isManualOnlyReaderFile(): Boolean {
return isManualOnlyReaderFileName(displayName)
}

View file

@ -12,6 +12,7 @@ import com.aryan.reader.shared.BannerMessage as SharedBannerMessage
import com.aryan.reader.shared.BookItem as SharedBookItem
import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import com.aryan.reader.shared.CustomAppTheme as SharedCustomAppTheme
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
import com.aryan.reader.shared.ReadStatusFilter as SharedReadStatusFilter
@ -30,15 +31,18 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
type = type.toSharedFileType(),
displayName = customName ?: displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
progressPercentage = progressPercentage,
isRecent = isRecent,
fileSize = fileSize,
sourceFolder = sourceFolderUri,
folderTextMetadataParsed = folderTextMetadataParsed,
seriesName = seriesName,
seriesIndex = seriesIndex,
tags = tags.map { it.toSharedTag() }
tags = tags.map { it.toSharedTag() },
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
)
}
@ -50,6 +54,15 @@ fun TagEntity.toSharedTag(): SharedTag {
)
}
fun SharedTag.toTagEntity(createdAt: Long): TagEntity {
return TagEntity(
id = id,
name = name,
color = color,
createdAt = createdAt
)
}
fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
return ShelfRecord(
id = id,
@ -59,6 +72,17 @@ fun ShelfEntity.toSharedShelfRecord(): ShelfRecord {
)
}
fun ShelfRecord.toShelfEntity(createdAt: Long, updatedAt: Long = createdAt): ShelfEntity {
return ShelfEntity(
id = id,
name = name,
isSmart = isSmart,
smartRulesJson = smartRulesJson,
createdAt = createdAt,
updatedAt = updatedAt
)
}
fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
return SharedBookShelfRef(
bookId = bookId,

View file

@ -0,0 +1,43 @@
package com.aryan.reader
import android.content.Context
import androidx.core.content.edit
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.shared.ReaderTtsReplacementEngine
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferencesJson
private const val READER_PREFS_NAME = "reader_prefs"
private const val TTS_REPLACEMENTS_KEY = "tts_word_replacements_json"
fun loadTtsReplacementPreferences(context: Context): ReaderTtsReplacementPreferences {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
return ReaderTtsReplacementPreferencesJson.decodeOrEmpty(prefs.getString(TTS_REPLACEMENTS_KEY, null))
}
fun saveTtsReplacementPreferences(
context: Context,
preferences: ReaderTtsReplacementPreferences,
) {
val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putString(TTS_REPLACEMENTS_KEY, ReaderTtsReplacementPreferencesJson.encode(preferences))
}
}
fun TtsChunk.withTtsReplacements(
preferences: ReaderTtsReplacementPreferences,
bookId: String?,
): TtsChunk {
val spoken = ReaderTtsReplacementEngine.apply(
text = text,
preferences = preferences,
bookId = bookId,
).text
return copy(spokenText = spoken.ifBlank { text })
}
fun List<TtsChunk>.withTtsReplacements(
preferences: ReaderTtsReplacementPreferences,
bookId: String?,
): List<TtsChunk> = map { it.withTtsReplacements(preferences, bookId) }

View file

@ -0,0 +1,667 @@
package com.aryan.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.shared.ReaderTtsReplacementBookSettings
import com.aryan.reader.shared.ReaderTtsReplacementEngine
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementRule
import com.aryan.reader.shared.ReaderTtsReplacementSuggestions
private enum class TtsReplacementScope {
Global,
Book
}
private data class RuleEditTarget(
val scope: TtsReplacementScope,
val ruleId: String? = null,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TtsWordReplacementsSheet(
isVisible: Boolean,
bookId: String,
bookTitle: String?,
preferences: ReaderTtsReplacementPreferences,
onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
onDismiss: () -> Unit,
) {
if (!isVisible) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var selectedTab by remember { mutableIntStateOf(0) }
var editTarget by remember { mutableStateOf<RuleEditTarget?>(null) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 720.dp)
.imePadding()
.padding(horizontal = 20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "TTS Word Replacements",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = bookTitle?.takeIf { it.isNotBlank() } ?: "Current book",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
Spacer(modifier = Modifier.height(12.dp))
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = {
selectedTab = 0
editTarget = null
},
text = { Text("Global") },
)
Tab(
selected = selectedTab == 1,
onClick = {
selectedTab = 1
editTarget = null
},
text = { Text("This book") },
)
}
Spacer(modifier = Modifier.height(12.dp))
when (selectedTab) {
0 -> GlobalReplacementTab(
preferences = preferences,
editTarget = editTarget?.takeIf { it.scope == TtsReplacementScope.Global },
onEditTargetChange = { editTarget = it },
onPreferencesChange = onPreferencesChange,
)
else -> BookReplacementTab(
bookId = bookId,
preferences = preferences,
editTarget = editTarget?.takeIf { it.scope == TtsReplacementScope.Book },
onEditTargetChange = { editTarget = it },
onPreferencesChange = onPreferencesChange,
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun GlobalReplacementTab(
preferences: ReaderTtsReplacementPreferences,
editTarget: RuleEditTarget?,
onEditTargetChange: (RuleEditTarget?) -> Unit,
onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
) {
val editingRule = editTarget?.ruleId?.let { id -> preferences.globalRules.firstOrNull { it.id == id } }
LazyColumn(
modifier = Modifier.heightIn(max = 560.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
ListItem(
headlineContent = { Text("Enable replacements") },
supportingContent = { Text("Rules here apply to every book unless disabled for a specific title.") },
trailingContent = {
Switch(
checked = preferences.isEnabled,
onCheckedChange = { onPreferencesChange(preferences.copy(isEnabled = it)) },
)
},
)
}
item {
SuggestionChips(
onSuggestionClick = { suggestion ->
onPreferencesChange(
preferences.copy(
globalRules = preferences.globalRules + suggestion.asEditableRule("global"),
),
)
},
)
}
item {
TextButton(
onClick = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Global)) },
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Add rule")
}
}
if (editTarget != null) {
item {
RuleEditorCard(
seedRule = editingRule,
onCancel = { onEditTargetChange(null) },
onSave = { rule ->
val updatedRules = if (editingRule == null) {
preferences.globalRules + rule
} else {
preferences.globalRules.map { if (it.id == editingRule.id) rule else it }
}
onPreferencesChange(preferences.copy(globalRules = updatedRules))
onEditTargetChange(null)
},
)
}
}
item {
ReplacementRuleList(
rules = preferences.globalRules,
emptyText = "No global replacement rules yet.",
onToggle = { rule, enabled ->
onPreferencesChange(
preferences.copy(
globalRules = preferences.globalRules.map {
if (it.id == rule.id) it.copy(enabled = enabled) else it
},
),
)
},
onEdit = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Global, it.id)) },
onDelete = { rule ->
onPreferencesChange(
preferences.copy(globalRules = preferences.globalRules.filterNot { it.id == rule.id }),
)
},
)
}
}
}
@Composable
private fun BookReplacementTab(
bookId: String,
preferences: ReaderTtsReplacementPreferences,
editTarget: RuleEditTarget?,
onEditTargetChange: (RuleEditTarget?) -> Unit,
onPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
) {
val settings = preferences.settingsForBook(bookId)
val localRules = preferences.rulesForBook(bookId)
val editingRule = editTarget?.ruleId?.let { id -> localRules.firstOrNull { it.id == id } }
LazyColumn(
modifier = Modifier.heightIn(max = 560.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
BookSettingsSwitches(
settings = settings,
onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) },
)
}
item {
InheritedGlobalRules(
globalRules = preferences.globalRules,
settings = settings,
onSettingsChange = { onPreferencesChange(preferences.withBookSettings(bookId, it)) },
)
}
item {
SuggestionChips(
onSuggestionClick = { suggestion ->
onPreferencesChange(
preferences.withBookRules(
bookId,
localRules + suggestion.asEditableRule("book"),
),
)
},
)
}
item {
TextButton(
onClick = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Book)) },
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Add book rule")
}
}
if (editTarget != null) {
item {
RuleEditorCard(
seedRule = editingRule,
onCancel = { onEditTargetChange(null) },
onSave = { rule ->
val updatedRules = if (editingRule == null) {
localRules + rule
} else {
localRules.map { if (it.id == editingRule.id) rule else it }
}
onPreferencesChange(preferences.withBookRules(bookId, updatedRules))
onEditTargetChange(null)
},
)
}
}
item {
ReplacementRuleList(
rules = localRules,
emptyText = "No book-specific rules yet.",
onToggle = { rule, enabled ->
onPreferencesChange(
preferences.withBookRules(
bookId,
localRules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it },
),
)
},
onEdit = { onEditTargetChange(RuleEditTarget(TtsReplacementScope.Book, it.id)) },
onDelete = { rule ->
onPreferencesChange(preferences.withBookRules(bookId, localRules.filterNot { it.id == rule.id }))
},
)
}
}
}
@Composable
private fun BookSettingsSwitches(
settings: ReaderTtsReplacementBookSettings,
onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit,
) {
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
Column(modifier = Modifier.fillMaxWidth()) {
ListItem(
headlineContent = { Text("Use global rules here") },
supportingContent = { Text("Turn this off when a book needs its own pronunciation choices.") },
trailingContent = {
Switch(
checked = settings.globalRulesEnabled,
onCheckedChange = { onSettingsChange(settings.copy(globalRulesEnabled = it)) },
)
},
)
HorizontalDivider()
ListItem(
headlineContent = { Text("Enable book rules") },
supportingContent = { Text("Local rules run after global rules.") },
trailingContent = {
Switch(
checked = settings.localRulesEnabled,
onCheckedChange = { onSettingsChange(settings.copy(localRulesEnabled = it)) },
)
},
)
}
}
}
@Composable
private fun InheritedGlobalRules(
globalRules: List<ReaderTtsReplacementRule>,
settings: ReaderTtsReplacementBookSettings,
onSettingsChange: (ReaderTtsReplacementBookSettings) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Inherited global rules",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (globalRules.isEmpty()) {
Text(
text = "No global rules to inherit.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
globalRules.forEach { rule ->
val enabledHere = rule.id !in settings.disabledGlobalRuleIds
ListItem(
headlineContent = { Text(rule.summaryText()) },
supportingContent = { Text(if (enabledHere) "Allowed in this book" else "Disabled for this book") },
trailingContent = {
Switch(
checked = enabledHere,
onCheckedChange = { checked ->
val disabledIds = if (checked) {
settings.disabledGlobalRuleIds - rule.id
} else {
settings.disabledGlobalRuleIds + rule.id
}
onSettingsChange(settings.copy(disabledGlobalRuleIds = disabledIds))
},
)
},
)
}
}
}
@Composable
private fun SuggestionChips(
onSuggestionClick: (ReaderTtsReplacementRule) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Suggestions",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(ReaderTtsReplacementSuggestions.presets) { suggestion ->
AssistChip(
onClick = { onSuggestionClick(suggestion) },
label = { Text(suggestion.summaryText(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) },
)
}
}
}
}
@Composable
private fun RuleEditorCard(
seedRule: ReaderTtsReplacementRule?,
onCancel: () -> Unit,
onSave: (ReaderTtsReplacementRule) -> Unit,
) {
val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newReplacementRuleId() }
val initial = seedRule ?: ReaderTtsReplacementRule(
id = draftRuleId,
from = "",
to = "",
)
var from by remember(initial.id) { mutableStateOf(initial.from) }
var to by remember(initial.id) { mutableStateOf(initial.to) }
var enabled by remember(initial.id) { mutableStateOf(initial.enabled) }
var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
var previewInput by remember(initial.id) {
mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: "Dr. Smith met NASA at 5 p.m.")
}
val draft = ReaderTtsReplacementRule(
id = initial.id,
from = from,
to = to,
enabled = enabled,
isRegex = isRegex,
matchCase = matchCase,
wholeWord = wholeWord,
)
val validation = ReaderTtsReplacementEngine.validate(draft)
val previewOutput = if (validation.isValid) {
ReaderTtsReplacementEngine.apply(
text = previewInput,
preferences = ReaderTtsReplacementPreferences(globalRules = listOf(draft.copy(enabled = true))),
).text
} else {
previewInput
}
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f)),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = if (seedRule == null) "New replacement" else "Edit replacement",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
OutlinedTextField(
value = from,
onValueChange = { from = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Replace") },
singleLine = !isRegex,
isError = !validation.isValid,
supportingText = if (validation.message != null) {
{ Text(validation.message.orEmpty()) }
} else {
null
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Text,
),
)
OutlinedTextField(
value = to,
onValueChange = { to = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Speak as") },
singleLine = !isRegex,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item {
FilterChip(
selected = enabled,
onClick = { enabled = !enabled },
label = { Text("Enabled") },
leadingIcon = if (enabled) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else {
null
},
)
}
item {
FilterChip(
selected = isRegex,
onClick = { isRegex = !isRegex },
label = { Text("Regex") },
)
}
item {
FilterChip(
selected = wholeWord,
onClick = { wholeWord = !wholeWord },
label = { Text("Whole word") },
)
}
item {
FilterChip(
selected = matchCase,
onClick = { matchCase = !matchCase },
label = { Text("Match case") },
)
}
}
OutlinedTextField(
value = previewInput,
onValueChange = { previewInput = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Preview input") },
minLines = 2,
)
Text(
text = previewOutput,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onCancel) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { onSave(draft) },
enabled = validation.isValid,
) {
Text("Save")
}
}
}
}
}
@Composable
private fun ReplacementRuleList(
rules: List<ReaderTtsReplacementRule>,
emptyText: String,
onToggle: (ReaderTtsReplacementRule, Boolean) -> Unit,
onEdit: (ReaderTtsReplacementRule) -> Unit,
onDelete: (ReaderTtsReplacementRule) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Rules",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (rules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = emptyText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
rules.forEach { rule ->
ListItem(
headlineContent = {
Text(
text = rule.summaryText(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(rule.optionSummary())
},
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = rule.enabled,
onCheckedChange = { onToggle(rule, it) },
)
IconButton(onClick = { onEdit(rule) }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
IconButton(onClick = { onDelete(rule) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
},
)
}
}
}
private fun ReaderTtsReplacementRule.asEditableRule(scope: String): ReaderTtsReplacementRule {
return copy(id = "${scope}_${System.currentTimeMillis()}_${id}", enabled = true)
}
private fun ReaderTtsReplacementRule.summaryText(): String {
val replacement = to.ifBlank { "silence" }
return "$from -> $replacement"
}
private fun ReaderTtsReplacementRule.optionSummary(): String {
val parts = buildList {
add(if (isRegex) "Regex" else "Plain text")
if (wholeWord) add("whole word")
if (matchCase) add("case-sensitive")
}
return parts.joinToString(" - ")
}
private fun newReplacementRuleId(): String {
return "rule_${System.currentTimeMillis()}"
}

View file

@ -40,6 +40,8 @@ import java.io.FileOutputStream
import com.aryan.reader.pdf.data.PdfAnnotationRepository
import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
import org.json.JSONObject
import org.json.JSONArray
import java.util.UUID
@ -273,6 +275,10 @@ class RecentFilesRepository(private val context: Context) {
val hasHighlights = highlightFile.exists()
Timber.tag("FolderAnnotationSync").d("File checks -> hasInk: $hasInk, hasRichText: $hasRichText, hasLayout: $hasLayout, hasTextBoxes: $hasTextBoxes, hasHighlights: $hasHighlights")
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.export candidates book=$bookId hasRichText=$hasRichText " +
"richBytes=${if (hasRichText) richTextFile.length() else 0L} folder=$folderUriString"
)
if (!hasInk && !hasRichText && !hasLayout && !hasTextBoxes && !hasHighlights) {
Timber.tag("FolderAnnotationSync").d("No annotations found locally for bookId: $bookId. Aborting sync.")
@ -284,12 +290,21 @@ class RecentFilesRepository(private val context: Context) {
fun putJsonSafe(key: String, file: File) {
try {
val content = file.readText().trim()
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.export.readRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
)
}
if (content.startsWith("[")) {
bundleJson.put(key, JSONArray(content))
} else if (content.startsWith("{")) {
bundleJson.put(key, JSONObject(content))
}
} catch (e: Exception) {
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG)
.e(e, "android.folder.export.richTextParseFailed book=$bookId")
}
Timber.tag("FolderAnnotationSync").e(e, "Error parsing $key file")
}
}
@ -311,11 +326,18 @@ class RecentFilesRepository(private val context: Context) {
Timber.tag("FolderAnnotationSync").d("Pushing annotation bundle for $bookId to folder. finalTs=$finalTs")
val canonicalBundleJson = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
if (hasRichText) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.export.saveSidecar book=$bookId timestamp=$finalTs canonicalLen=${canonicalBundleJson.length}"
)
}
LocalSyncUtils.saveAnnotationSidecar(
context = context,
sourceFolderUri = folderUriString.toUri(),
bookId = bookId,
jsonPayload = bundleJson.toString(),
jsonPayload = canonicalBundleJson,
timestamp = finalTs
)
}
@ -323,13 +345,24 @@ class RecentFilesRepository(private val context: Context) {
suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) {
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
try {
val bundle = JSONObject(jsonString)
val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
)
fun writeSafe(key: String, file: File?) {
if (file != null && bundle.has(key)) {
file.parentFile?.mkdirs()
val contentStr = bundle.get(key).toString()
file.writeText(contentStr)
if (key == "text") {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
)
}
Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)")
}
}

View file

@ -1,81 +1,20 @@
package com.aryan.reader.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import com.aryan.reader.toSharedBookItem
import com.aryan.reader.shared.SmartCollectionEngine as SharedSmartCollectionEngine
@Serializable
enum class SmartField { TITLE, AUTHOR, PROGRESS, FILE_TYPE, FOLDER, TAG }
@Serializable
enum class SmartOperator { EQUALS, CONTAINS, GREATER_THAN, LESS_THAN }
@Serializable
data class SmartRule(
val field: SmartField,
val operator: SmartOperator,
val value: String
)
@Serializable
data class SmartCollectionDefinition(
val matchAll: Boolean = true,
val rules: List<SmartRule> = emptyList()
)
typealias SmartField = com.aryan.reader.shared.SmartField
typealias SmartOperator = com.aryan.reader.shared.SmartOperator
typealias SmartRule = com.aryan.reader.shared.SmartRule
typealias SmartCollectionDefinition = com.aryan.reader.shared.SmartCollectionDefinition
object SmartCollectionEngine {
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
fun toJson(definition: SmartCollectionDefinition): String =
SharedSmartCollectionEngine.toJson(definition)
fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
fun fromJson(json: String?): SmartCollectionDefinition? =
SharedSmartCollectionEngine.fromJson(json)
fun fromJson(json: String?): SmartCollectionDefinition? {
if (json.isNullOrBlank()) return null
return try {
this.json.decodeFromString<SmartCollectionDefinition>(json)
} catch (_: Exception) { null }
}
fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean {
if (definition.rules.isEmpty()) return false
val results = definition.rules.map { rule ->
when (rule.field) {
SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule)
SmartField.AUTHOR -> evaluateString(book.author ?: "", rule)
SmartField.FILE_TYPE -> evaluateString(book.type.name, rule)
SmartField.FOLDER -> evaluateString(book.sourceFolderUri ?: "", rule)
SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule)
SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule)
}
}
return if (definition.matchAll) results.all { it } else results.any { it }
}
private fun evaluateString(target: String, rule: SmartRule): Boolean {
return when (rule.operator) {
SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true)
SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true)
else -> false
}
}
private fun evaluateNumber(target: Float, rule: SmartRule): Boolean {
val ruleValue = rule.value.toFloatOrNull() ?: return false
return when (rule.operator) {
SmartOperator.EQUALS -> target == ruleValue
SmartOperator.GREATER_THAN -> target > ruleValue
SmartOperator.LESS_THAN -> target < ruleValue
else -> false
}
}
private fun evaluateTags(tags: List<String>, rule: SmartRule): Boolean {
return when (rule.operator) {
SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) }
SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) }
else -> false
}
}
fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean =
SharedSmartCollectionEngine.evaluate(book.toSharedBookItem(), definition)
}

View file

@ -26,6 +26,9 @@ import timber.log.Timber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup
import org.w3c.dom.Element
import org.w3c.dom.Node
@ -42,6 +45,8 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class EpubParser(private val context: Context) {
private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
data class EpubDocument(
val metadata: Node, val manifest: Node, val spine: Node, val opfFilePath: String
)
@ -67,6 +72,15 @@ class EpubParser(private val context: Context) {
val depth: Int
)
@Serializable
private data class EpubExtractionCacheManifest(
val bookId: String,
val originalBookNameHint: String,
val parserVersion: Int,
val parseContent: Boolean,
val shouldUseToc: Boolean
)
// EpubFile can still represent in-memory file data during initial parsing before extraction
data class EpubFile(val absPath: String, val data: ByteArray) {
override fun equals(other: Any?): Boolean {
@ -92,6 +106,9 @@ class EpubParser(private val context: Context) {
companion object {
const val TAG = "EpubParser"
private const val BOOK_METADATA_FILE = "book_metadata.json"
private const val CACHE_MANIFEST_FILE = "epub_cache_manifest.json"
private const val EPUB_EXTRACTION_CACHE_VERSION = 1
}
internal val String.decodedURL: String
@ -173,8 +190,24 @@ class EpubParser(private val context: Context) {
return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream for bookId: $bookId")
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
val shouldDeleteExtractionDir = !parseContent && extractionDirOverride == null
val extractionDir = if (extractionDirOverride != null) {
ImportedFileCache.prepareDirectory(extractionDirOverride)
} else if (!parseContent) {
ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
} else {
val activeDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
readCachedEpubBook(
extractionDir = activeDir,
bookId = bookId,
originalBookNameHint = originalBookNameHint,
shouldUseToc = shouldUseToc
)?.let { cachedBook ->
Timber.tag("FileOpenPerf").d("[EPUB] Loaded extracted book from cache | bookId=$bookId")
return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
}
val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir)
val filesMap: Map<String, EpubFile>
@ -190,10 +223,80 @@ class EpubParser(private val context: Context) {
val document = createEpubDocument(filesMap)
val book = parseAndCreateEbook(filesMap, document, shouldUseToc, extractionDir.absolutePath,
originalBookNameHint, parseContent)
if (parseContent && extractionDirOverride == null) {
writeCachedEpubBook(
extractionDir = extractionDir,
bookId = bookId,
originalBookNameHint = originalBookNameHint,
shouldUseToc = shouldUseToc,
book = book
)
}
if (shouldDeleteExtractionDir) {
extractionDir.deleteRecursively()
}
return@withContext book
}
}
private fun readCachedEpubBook(
extractionDir: File,
bookId: String,
originalBookNameHint: String,
shouldUseToc: Boolean
): EpubBook? {
val metadataFile = File(extractionDir, BOOK_METADATA_FILE)
val manifestFile = File(extractionDir, CACHE_MANIFEST_FILE)
if (!metadataFile.isFile || !manifestFile.isFile) return null
return try {
val manifest = jsonSerializer.decodeFromString<EpubExtractionCacheManifest>(manifestFile.readText())
val isCompatible = manifest.bookId == bookId &&
manifest.originalBookNameHint == originalBookNameHint &&
manifest.parserVersion == EPUB_EXTRACTION_CACHE_VERSION &&
manifest.parseContent &&
manifest.shouldUseToc == shouldUseToc
if (!isCompatible) {
Timber.d("EPUB extraction cache manifest is stale for bookId=$bookId")
return null
}
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
cachedBook.takeIf { it.hasReadableExtractedContent() }
} catch (e: Exception) {
Timber.e(e, "Failed to read EPUB extraction cache for bookId=$bookId")
null
}
}
private fun writeCachedEpubBook(
extractionDir: File,
bookId: String,
originalBookNameHint: String,
shouldUseToc: Boolean,
book: EpubBook
) {
try {
File(extractionDir, BOOK_METADATA_FILE).writeText(jsonSerializer.encodeToString(book))
File(extractionDir, CACHE_MANIFEST_FILE).writeText(
jsonSerializer.encodeToString(
EpubExtractionCacheManifest(
bookId = bookId,
originalBookNameHint = originalBookNameHint,
parserVersion = EPUB_EXTRACTION_CACHE_VERSION,
parseContent = true,
shouldUseToc = shouldUseToc
)
)
)
} catch (e: Exception) {
Timber.e(e, "Failed to write EPUB extraction cache for bookId=$bookId")
}
}
private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map<String, EpubFile> {
val filesMap = mutableMapOf<String, EpubFile>()
zipFile.use { zf ->

View file

@ -24,7 +24,11 @@ class Fb2Parser(private val context: Context) {
extractionDirOverride: File? = null
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
?: if (parseContent) {
ImportedFileCache.prepareActiveBookDir(context, bookId)
} else {
ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
}
var streamToParse = inputStream
try {

View file

@ -17,10 +17,18 @@ object ImportedFileCache {
return File(context.cacheDir, activeBookDirName(bookId))
}
fun prepareActiveBookDir(context: Context, bookId: String): File {
fun ensureActiveBookDir(context: Context, bookId: String): File {
return activeBookDir(context, bookId).also { it.mkdirs() }
}
fun resetActiveBookDir(context: Context, bookId: String): File {
return prepareDirectory(activeBookDir(context, bookId))
}
fun prepareActiveBookDir(context: Context, bookId: String): File {
return resetActiveBookDir(context, bookId)
}
fun createTemporaryBookDir(context: Context, bookId: String, purpose: String): File {
val dirName = buildString {
append(TEMP_PREFIX)

View file

@ -169,7 +169,11 @@ class MobiParser(private val context: Context) {
val bookAuthor = parsedData.author ?: "Unknown Author"
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
?: if (parseContent) {
ImportedFileCache.prepareActiveBookDir(context, bookId)
} else {
ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
}
val sequentialImageMap = parsedData.resources
.filter { it.mediaType.startsWith("image/") }

View file

@ -33,7 +33,11 @@ class OdtParser(private val context: Context) {
extractionDirOverride: File? = null
): EpubBook = withContext(Dispatchers.IO) {
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
?: if (parseContent) {
ImportedFileCache.prepareActiveBookDir(context, bookId)
} else {
ImportedFileCache.createTemporaryBookDir(context, bookId, "metadata")
}
val mathJaxFileName = "tex-mml-chtml.js"
val mathJaxFile = File(extractionDir, mathJaxFileName)

View file

@ -101,7 +101,7 @@ class SingleFileImporter(private val context: Context) {
try {
FileOutputStream(tempFile).bufferedWriter().use { writer ->
writer.write("<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n<title>${originalBookNameHint}</title>\n")
writer.write("<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n<title>${generatedHtmlTitle(originalBookNameHint)}</title>\n")
if (isCsv) {
writer.write("<style>\ntable { border-collapse: collapse; width: 100%; font-family: sans-serif; }\nth, td { border: 1px solid currentColor; padding: 8px; }\n</style>\n")
@ -189,18 +189,20 @@ class SingleFileImporter(private val context: Context) {
)
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached MD, parsing again")
}
}
ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[MD] parseMarkdown START | file=$originalBookNameHint")
@ -323,18 +325,20 @@ class SingleFileImporter(private val context: Context) {
)
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached TXT, parsing again")
}
}
ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[TXT] parsePlainText START | file=$originalBookNameHint")
@ -484,18 +488,20 @@ class SingleFileImporter(private val context: Context) {
)
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached HTML, parsing again")
}
}
ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[HTML] parseHtml START | file=$originalBookNameHint")
@ -511,6 +517,7 @@ class SingleFileImporter(private val context: Context) {
var inStyle = false
var inBody = false
var pageNum = 1
val headBuilder = java.lang.StringBuilder()
val currentChapterBuilder = java.lang.StringBuilder()
var line: String?
@ -531,19 +538,9 @@ class SingleFileImporter(private val context: Context) {
}
if (!inBody) {
if (trimmed.startsWith("<title", ignoreCase = true)) {
val t = trimmed.substringAfter(">").substringBefore("</title>")
if (t.isNotBlank()) title = t
}
val authorMatch = Regex("<meta[^>]+name=\"author\"[^>]+content=\"([^\"]+)\"").find(
line
)
?: Regex("<meta[^>]+property=\"article:author\"[^>]+content=\"([^\"]+)\"").find(
line
)
if (authorMatch != null) {
author = authorMatch.groupValues[1]
}
headBuilder.append(line).append('\n')
extractHtmlTitle(headBuilder.toString())?.let { title = it }
extractHtmlAuthor(headBuilder.toString())?.let { author = it }
if (trimmed.startsWith("<style", ignoreCase = true)) {
inStyle = true
@ -576,7 +573,7 @@ class SingleFileImporter(private val context: Context) {
}
if (trimmed.startsWith("<p") || trimmed.startsWith("<div") ||
trimmed.startsWith("<h") || trimmed.startsWith("<section") ||
trimmed.startsWithHtmlHeadingTag() || trimmed.startsWith("<section") ||
trimmed.contains("<page-break>") ||
(trimmed.isNotBlank() && !trimmed.startsWith("<") && !trimmed.startsWith("<!"))) {
inBody = true
@ -646,6 +643,54 @@ class SingleFileImporter(private val context: Context) {
return@withContext book
}
private fun String.startsWithHtmlHeadingTag(): Boolean {
return length >= 3 &&
this[0] == '<' &&
this[1].lowercaseChar() == 'h' &&
this[2] in '1'..'6'
}
private fun generatedHtmlTitle(originalBookNameHint: String): String {
if (!originalBookNameHint.endsWith(".txt", ignoreCase = true)) return originalBookNameHint
val innerName = originalBookNameHint.dropLast(4)
return if (innerName.contains('.') && com.aryan.reader.isCodeOrDataFileName(innerName)) {
innerName
} else {
originalBookNameHint
}
}
private fun extractHtmlTitle(line: String): String? {
val match = Regex(
pattern = "<\\s*title\\b[^>]*>(.*?)<\\s*/\\s*title\\s*>",
options = setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
).find(line) ?: return null
return Jsoup.parse(match.groupValues[1]).text().takeIf { it.isNotBlank() }
}
private fun extractHtmlAuthor(line: String): String? {
val metaTag = Regex(
pattern = "<\\s*meta\\b[^>]*>",
options = setOf(RegexOption.IGNORE_CASE)
).find(line)?.value ?: return null
val name = Regex(
pattern = "\\b(?:name|property)\\s*=\\s*['\"]([^'\"]+)['\"]",
options = setOf(RegexOption.IGNORE_CASE)
).find(metaTag)?.groupValues?.get(1) ?: return null
if (!name.equals("author", ignoreCase = true) && !name.equals("article:author", ignoreCase = true)) {
return null
}
return Regex(
pattern = "\\bcontent\\s*=\\s*['\"]([^'\"]+)['\"]",
options = setOf(RegexOption.IGNORE_CASE)
).find(metaTag)?.groupValues?.get(1)?.takeIf { it.isNotBlank() }
}
private fun sanitizeHtmlFragment(html: String): String {
return Jsoup.clean(html, "", htmlSafelist, htmlOutputSettings)
}
@ -672,18 +717,20 @@ class SingleFileImporter(private val context: Context) {
)
}
val extractionDir = ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
val metadataFile = File(extractionDir, "book_metadata.json")
if (metadataFile.exists()) {
try {
val cachedBook = jsonSerializer.decodeFromString<EpubBook>(metadataFile.readText())
.copy(extractionBasePath = extractionDir.absolutePath)
Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
return@withContext cachedBook
} catch (e: Exception) {
Timber.e(e, "Failed to load cached DOCX, parsing again")
}
}
ImportedFileCache.resetActiveBookDir(context, bookId)
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")

View file

@ -70,58 +70,16 @@ import androidx.core.content.edit
import androidx.core.text.HtmlCompat
import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
import com.aryan.reader.shared.EpubAnnotationSerializer
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
data class Bookmark(
val cfi: String,
val chapterTitle: String,
val label: String? = null,
val snippet: String,
val pageInChapter: Int?,
val totalPagesInChapter: Int?,
val chapterIndex: Int
)
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"),
GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"),
RED("red", Color(0xFFD32F2F), "user-highlight-red"),
PURPLE("purple", Color(0xFF7B1FA2), "user-highlight-purple"),
ORANGE("orange", Color(0xFFF57C00), "user-highlight-orange"),
CYAN("cyan", Color(0xFF0097A7), "user-highlight-cyan"),
MAGENTA("magenta", Color(0xFFC2185B), "user-highlight-magenta"),
LIME("lime", Color(0xFFAFB42B), "user-highlight-lime"),
PINK("pink", Color(0xFFE91E63), "user-highlight-pink"),
TEAL("teal", Color(0xFF00796B), "user-highlight-teal"),
INDIGO("indigo", Color(0xFF303F9F), "user-highlight-indigo"),
BLACK("black", Color(0xFF424242), "user-highlight-black"),
WHITE("white", Color(0xFFF5F5F5), "user-highlight-white");
}
data class UserHighlight(
val id: String = UUID.randomUUID().toString(),
val cfi: String,
val text: String,
val color: HighlightColor,
val chapterIndex: Int,
val note: String? = null
)
typealias Bookmark = com.aryan.reader.shared.EpubBookmark
typealias HighlightColor = com.aryan.reader.shared.HighlightColor
typealias UserHighlight = com.aryan.reader.shared.UserHighlight
fun escapeJsString(value: String): String {
return value
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
return com.aryan.reader.shared.escapeJsString(value)
}
fun saveHighlightPalette(context: Context, palette: List<HighlightColor>) {
@ -146,60 +104,21 @@ fun loadHighlightPalette(context: Context): List<HighlightColor> {
fun loadBookmarks(context: Context, bookTitle: String, chapters: List<EpubChapter>, bookmarksJson: String?): Set<Bookmark> {
val stringSetToParse: Collection<String> = if (bookmarksJson != null) {
try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).map { jsonArray.getString(it) }
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from ViewModel")
emptyList()
}
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson, chapters.map { it.title })
} else {
val prefs = context.getSharedPreferences(BOOKMARK_PREFS_NAME, Context.MODE_PRIVATE)
val key = "bookmarks_cfi_${bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")}"
prefs.getStringSet(key, emptySet()) ?: emptySet()
}
return stringSetToParse.mapNotNull { jsonString ->
try {
val json = JSONObject(jsonString)
val chapterIndex = if (json.has("chapterIndex")) {
json.getInt("chapterIndex")
} else {
val chapterTitle = json.getString("chapterTitle")
chapters.indexOfFirst { it.title == chapterTitle }.coerceAtLeast(0)
}
Bookmark(
cfi = json.getString("cfi"),
chapterTitle = json.getString("chapterTitle"),
label = if (json.has("label")) json.getString("label") else null,
snippet = json.getString("snippet"),
pageInChapter = if (json.has("pageInChapter")) json.optInt("pageInChapter") else null,
totalPagesInChapter = if (json.has("totalPagesInChapter")) json.optInt("totalPagesInChapter") else null,
chapterIndex = chapterIndex
)
} catch (_: Exception) {
null
}
}.toSet()
return EpubAnnotationSerializer.parseBookmarkEntries(stringSetToParse, chapters.map { it.title })
}
fun saveHighlightsToPrefs(context: Context, bookTitle: String, highlights: List<UserHighlight>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonArray = JSONArray()
highlights.forEach { h ->
val obj = JSONObject().apply {
put("id", h.id)
put("cfi", h.cfi)
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
put("note", h.note ?: "")
}
jsonArray.put(obj)
}
prefs.edit { putString(key, jsonArray.toString()) }
prefs.edit { putString(key, EpubAnnotationSerializer.highlightsToJson(highlights)) }
}
fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighlight> {
@ -207,72 +126,19 @@ fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighl
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonString = prefs.getString(key, "[]") ?: "[]"
val list = mutableListOf<UserHighlight>()
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val noteStr = obj.optString("note", "")
list.add(
UserHighlight(
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex"),
note = noteStr.takeIf { it.isNotBlank() }
)
)
}
} catch (e: Exception) {
Timber.e(e, "Error loading highlights")
}
return list
return EpubAnnotationSerializer.parseHighlightsJson(jsonString)
}
fun parseHighlightsJson(jsonString: String?): List<UserHighlight> {
if (jsonString.isNullOrBlank()) return emptyList()
val list = mutableListOf<UserHighlight>()
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val noteStr = obj.optString("note", "")
list.add(
UserHighlight(
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex"),
note = noteStr.takeIf { it.isNotBlank() }
)
)
}
} catch (e: Exception) {
Timber.e(e, "Error parsing highlights JSON")
}
return list
return EpubAnnotationSerializer.parseHighlightsJson(jsonString)
}
fun highlightsToJson(highlights: List<UserHighlight>): String {
val jsonArray = JSONArray()
highlights.forEach { h ->
val obj = JSONObject().apply {
put("id", h.id)
put("cfi", h.cfi)
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
put("note", h.note ?: "")
}
jsonArray.put(obj)
}
return jsonArray.toString()
return EpubAnnotationSerializer.highlightsToJson(highlights)
}
fun bookmarksToJson(bookmarks: Collection<Bookmark>): String {
return EpubAnnotationSerializer.bookmarksToJson(bookmarks)
}
fun clearHighlightsFromPrefs(context: Context, bookTitle: String) {
@ -291,28 +157,13 @@ fun processAndAddHighlight(
chapterIndex: Int,
currentList: MutableList<UserHighlight>
): String {
// Scenario: Exact match -> Update color and text instead of stacking identical spans
val exactMatchIndex = currentList.indexOfFirst {
it.chapterIndex == chapterIndex && it.cfi == newCfi
}
if (exactMatchIndex != -1) {
val existing = currentList[exactMatchIndex]
currentList[exactMatchIndex] = existing.copy(color = newColor, text = newText)
return existing.cfi
}
// Scenarios: Partial overlap or subsumption -> Add independently
currentList.add(
UserHighlight(
cfi = newCfi,
text = newText,
color = newColor,
chapterIndex = chapterIndex,
note = null
)
return EpubAnnotationSerializer.processAndAddHighlight(
newCfi = newCfi,
newText = newText,
newColor = newColor,
chapterIndex = chapterIndex,
currentList = currentList
)
return newCfi
}
// --- UI Components ---

View file

@ -167,7 +167,8 @@ enum class ReaderTool(val title: String, val category: String) {
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu")
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
}
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
@ -282,6 +283,7 @@ fun EpubReaderTopBar(
onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenTtsReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit,
@ -678,6 +680,23 @@ fun EpubReaderTopBar(
)
}
)
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = {
showMoreMenu = false
onOpenTtsReplacements()
},
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
}
}

View file

@ -791,7 +791,8 @@ private fun HighlightsList(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (!highlight.note.isNullOrBlank()) {
val note = highlight.note
if (!note.isNullOrBlank()) {
Spacer(Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(8.dp),
@ -799,7 +800,7 @@ private fun HighlightsList(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = highlight.note,
text = note,
style = MaterialTheme.typography.bodySmall.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic),
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant

View file

@ -166,6 +166,7 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.TtsWordReplacementsSheet
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.countWords
import com.aryan.reader.isByokCloudTtsAvailable
@ -177,6 +178,7 @@ import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock
@ -195,12 +197,16 @@ import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
@ -570,6 +576,7 @@ fun EpubReaderScreen(
}
}
} else null,
stableBookId = uiState.selectedBookId,
viewModel = viewModel
)
}
@ -600,6 +607,7 @@ fun EpubReaderHost(
onImportFont: (Uri) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null,
stableBookId: String? = null,
viewModel: MainViewModel
) {
val view = LocalView.current
@ -668,11 +676,16 @@ fun EpubReaderHost(
)
}
val locatorConverter = remember(context) {
val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) {
stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
val locatorConverter = remember(context, readerCacheBookId) {
LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
proto = ProtoBuf { serializersModule = semanticBlockModule },
context = context
context = context,
stableBookId = readerCacheBookId
)
}
@ -698,9 +711,7 @@ fun EpubReaderHost(
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
val bookId = remember(epubBook.title, epubBook.fileName) {
if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
val bookId = readerCacheBookId
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
val initialSettings = remember(isAutoScrollLocal) {
@ -895,6 +906,8 @@ fun EpubReaderHost(
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
var chapterToLoadOnSwitch by remember { mutableStateOf<Int?>(null) }
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
var paginatedReconfigurationAnchor by remember { mutableStateOf<Locator?>(null) }
var isPaginatedReconfigurationRestoring by remember { mutableStateOf(false) }
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
@ -910,18 +923,7 @@ fun EpubReaderHost(
LaunchedEffect(bookmarks) {
Timber.d("Bookmarks changed, saving...")
val stringSet = bookmarks.map { bookmark ->
JSONObject().apply {
put("cfi", bookmark.cfi)
put("chapterTitle", bookmark.chapterTitle)
put("label", bookmark.label)
put("snippet", bookmark.snippet)
bookmark.pageInChapter?.let { put("pageInChapter", it) }
bookmark.totalPagesInChapter?.let { put("totalPagesInChapter", it) }
put("chapterIndex", bookmark.chapterIndex)
}.toString()
}
onBookmarksChanged(JSONArray(stringSet).toString())
onBookmarksChanged(bookmarksToJson(bookmarks))
}
var activeBookmarkInVerticalView by remember { mutableStateOf<Bookmark?>(null) }
@ -1209,9 +1211,15 @@ fun EpubReaderHost(
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showTtsReplacementsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) }
var showThemePanel by remember { mutableStateOf(false) }
var showPaletteManager by remember { mutableStateOf(false) }
var ttsReplacementPreferences by remember { mutableStateOf(loadTtsReplacementPreferences(context)) }
val updateTtsReplacementPreferences: (ReaderTtsReplacementPreferences) -> Unit = { next ->
ttsReplacementPreferences = next
saveTtsReplacementPreferences(context, next)
}
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
@ -1350,7 +1358,7 @@ fun EpubReaderHost(
}
Timber.tag("TTS_LOCATE")
.d("Saving locator from TTS. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress")
.d("Saving resolved locator position. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress")
onSavePosition(locator, cfiForWebView, progress)
}
@ -1535,7 +1543,7 @@ fun EpubReaderHost(
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsChapterIndex = chapterIndex
ttsController.start(
chunks = ttsChunks,
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
@ -1588,7 +1596,11 @@ fun EpubReaderHost(
val relativeOffset = startOffset - target.startOffsetInSource
val safeRelativeOffset = relativeOffset.coerceIn(0, target.text.length)
val slicedText = target.text.substring(safeRelativeOffset)
val newChunk = target.copy(text = slicedText, startOffsetInSource = startOffset)
val newChunk = target.copy(
text = slicedText,
startOffsetInSource = startOffset,
spokenText = slicedText,
)
val remainingChunks = mutableListOf(newChunk)
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size))
@ -1599,7 +1611,7 @@ fun EpubReaderHost(
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsController.start(
chunks = remainingChunks,
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
@ -1662,7 +1674,9 @@ fun EpubReaderHost(
currentTtsMode = currentTtsMode,
getAuthToken = { viewModel.getAuthToken() },
locatorConverter = locatorConverter,
epubBook = epubBook
epubBook = epubBook,
ttsReplacementPreferences = ttsReplacementPreferences,
ttsReplacementBookId = bookId
)
TtsHighlightHandler(
@ -2045,8 +2059,26 @@ fun EpubReaderHost(
}
}
LaunchedEffect(paginatedPagerState.currentPage, paginator) {
if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) {
LaunchedEffect(paginatedPagerState, paginator, currentRenderMode, isPagerInitialized, isPaginatedReconfigurationRestoring) {
if (currentRenderMode != RenderMode.PAGINATED || paginator == null || !isPagerInitialized) {
return@LaunchedEffect
}
snapshotFlow { paginatedPagerState.currentPage }
.collectLatest { page ->
if (!isPaginatedReconfigurationRestoring) {
(paginator as? BookPaginator)?.getLocatorForPage(page)?.let { locator ->
lastKnownLocator = locator
}
}
}
}
LaunchedEffect(paginatedPagerState.currentPage, paginator, isPaginatedReconfigurationRestoring) {
if (currentRenderMode == RenderMode.PAGINATED &&
paginator != null &&
isPagerInitialized &&
!isPaginatedReconfigurationRestoring
) {
delay(1500L)
val pageToSave = paginatedPagerState.currentPage
@ -2164,12 +2196,21 @@ fun EpubReaderHost(
RenderMode.PAGINATED -> {
scope.launch {
val pageToSave = paginatedPagerState.currentPage
val locator = (paginator as? BookPaginator)?.getLocatorForPage(pageToSave)
val pageLocator = if (isPaginatedReconfigurationRestoring) {
null
} else {
(paginator as? BookPaginator)?.getLocatorForPage(pageToSave)
}
val locator = pageLocator ?: paginatedReconfigurationAnchor ?: lastKnownLocator
val chapterIndex = paginator?.findChapterIndexForPage(pageToSave)
if (locator != null && chapterIndex != null) {
if (locator != null) {
val bookPaginator = paginator as? BookPaginator
val progress = if (totalBookLengthChars > 0 && bookPaginator != null) {
val progress = if (pageLocator == null || chapterIndex == null) {
saveResolvedLocatorPosition(locator, null)
onNavigateBack()
return@launch
} else if (totalBookLengthChars > 0 && bookPaginator != null) {
val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() }
val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it }
val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter)
@ -2186,7 +2227,7 @@ fun EpubReaderHost(
)
onSavePosition(locator, null, progress)
} else {
Timber.w("Final save for paginated view failed. Locator or chapter index is null."
Timber.w("Final save for paginated view failed. Locator is null."
)
}
onNavigateBack()
@ -3570,7 +3611,7 @@ fun EpubReaderHost(
ttsChapterIndex = targetChapterIndex
ttsController.start(
chunks = ttsChunks,
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
@ -3886,6 +3927,7 @@ fun EpubReaderHost(
) {
PaginatedReaderScreen(
book = epubBook,
bookId = readerCacheBookId,
isDarkTheme = isDarkTheme,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
@ -3910,7 +3952,18 @@ fun EpubReaderHost(
activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha,
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
fallbackLocatorForReconfiguration = paginatedReconfigurationAnchor ?: lastKnownLocator,
onReconfigurationAnchorCaptured = { locator ->
paginatedReconfigurationAnchor = locator
lastKnownLocator = locator
},
onReconfigurationRestoreActiveChanged = { isActive ->
isPaginatedReconfigurationRestoring = isActive
if (!isActive) {
paginatedReconfigurationAnchor = null
}
},
modifier = Modifier.alpha(if (isPagerInitialized && !isPaginatedReconfigurationRestoring) 1f else 0f),
onPaginatorReady = { newPaginator ->
paginator = newPaginator
},
@ -4624,6 +4677,7 @@ fun EpubReaderHost(
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenTtsReplacements = { showTtsReplacementsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenVisualOptions = { showVisualOptionsSheet = true },
@ -5259,6 +5313,15 @@ fun EpubReaderHost(
)
}
TtsWordReplacementsSheet(
isVisible = showTtsReplacementsSheet,
bookId = bookId,
bookTitle = epubBook.title,
preferences = ttsReplacementPreferences,
onPreferencesChange = updateTtsReplacementPreferences,
onDismiss = { showTtsReplacementsSheet = false },
)
if (showCustomizeToolsSheet) {
CustomizeToolsSheet(
hiddenTools = hiddenTools,

View file

@ -37,9 +37,11 @@ import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import com.aryan.reader.withTtsReplacements
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -115,7 +117,9 @@ fun TtsSessionObserver(
currentTtsMode: TtsMode,
getAuthToken: suspend () -> String?,
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, // NEW
epubBook: com.aryan.reader.epub.EpubBook // NEW
epubBook: com.aryan.reader.epub.EpubBook, // NEW
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?
) {
val currentRenderModeState = rememberUpdatedState(currentRenderMode)
val loadedChunkCountState = rememberUpdatedState(loadedChunkCount)
@ -131,6 +135,8 @@ fun TtsSessionObserver(
val onTtsChapterIndexChangeState = rememberUpdatedState(onTtsChapterIndexChange)
val locatorConverterState = rememberUpdatedState(locatorConverter) // NEW
val epubBookState = rememberUpdatedState(epubBook) // NEW
val ttsReplacementPreferencesState = rememberUpdatedState(ttsReplacementPreferences)
val ttsReplacementBookIdState = rememberUpdatedState(ttsReplacementBookId)
DisposableEffect(ttsController) {
val job = scope.launch {
@ -168,7 +174,9 @@ fun TtsSessionObserver(
ttsController = ttsController,
scope = this,
locatorConverter = locatorConverterState.value,
epubBook = epubBookState.value
epubBook = epubBookState.value,
ttsReplacementPreferences = ttsReplacementPreferencesState.value,
ttsReplacementBookId = ttsReplacementBookIdState.value
)
} else if (currentRenderModeState.value == RenderMode.PAGINATED) {
handlePaginatedAutoAdvance(
@ -182,7 +190,9 @@ fun TtsSessionObserver(
onUpdateTtsChapter = onTtsChapterIndexChangeState.value,
scope = this,
ttsMode = currentTtsMode,
getAuthToken = getAuthToken
getAuthToken = getAuthToken,
ttsReplacementPreferences = ttsReplacementPreferencesState.value,
ttsReplacementBookId = ttsReplacementBookIdState.value
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
@ -303,7 +313,9 @@ private fun handleVerticalAutoAdvance(
ttsController: TtsController,
scope: CoroutineScope,
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter,
epubBook: com.aryan.reader.epub.EpubBook
epubBook: com.aryan.reader.epub.EpubBook,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?
) {
if (currentTtsChapterIndex == null) return
@ -323,7 +335,7 @@ private fun handleVerticalAutoAdvance(
val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size)
val token = getAuthToken()
ttsController.start(
chunks = remainingChunks,
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
@ -355,7 +367,7 @@ private fun handleVerticalAutoAdvance(
onUpdateTtsChapter(nextIdx)
ttsController.start(
chunks = nativeChunks,
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
@ -399,7 +411,9 @@ private fun handlePaginatedAutoAdvance(
onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope,
ttsMode: TtsMode,
getAuthToken: suspend () -> String?
getAuthToken: suspend () -> String?,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?
) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Searching for next TTS content...")
@ -432,7 +446,7 @@ private fun handlePaginatedAutoAdvance(
val token = getAuthToken()
ttsController.start(
chunks = nextChapterChunks,
chunks = nextChapterChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,

View file

@ -1,96 +1,9 @@
// OpdsModels.kt
package com.aryan.reader.opds
data class OpdsCatalog(
val id: String,
val title: String,
val url: String,
val isDefault: Boolean = false,
val username: String? = null,
val password: String? = null
)
data class OpdsFacet(
val title: String,
val group: String,
val url: String,
val isActive: Boolean
)
data class OpdsFeed(
val title: String,
val entries: List<OpdsEntry>,
val nextUrl: String?,
val searchUrl: String? = null,
val facets: List<OpdsFacet> = emptyList()
)
data class OpdsAuthor(
val name: String,
val url: String?
)
data class OpdsAcquisition(
val url: String,
val mimeType: String
) {
val formatName: String
get() = when {
mimeType.contains("epub") -> "EPUB"
mimeType.contains("pdf") -> "PDF"
mimeType.contains("markdown") || mimeType.contains("text/x-markdown") -> "MD"
mimeType.contains("html") || mimeType.contains("xhtml") -> "HTML"
mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
mimeType.contains("cbr") || mimeType.contains("rar") -> "CBR"
mimeType.contains("txt") || mimeType.contains("text/plain") -> "TXT"
else -> mimeType.substringAfterLast("/").uppercase()
}
val priority: Int
get() = when (formatName) {
"EPUB" -> 5
"PDF" -> 4
"MOBI" -> 3
"FB2" -> 2
"MD", "HTML" -> 2
"CBZ" -> 1
"TXT" -> 0
else -> -1
}
}
data class OpdsEntry(
val id: String,
val title: String,
val summary: String?,
val authors: List<OpdsAuthor> = emptyList(),
val coverUrl: String?,
val acquisitions: List<OpdsAcquisition> = emptyList(),
val navigationUrl: String?,
val publisher: String? = null,
val published: String? = null,
val language: String? = null,
val series: String? = null,
val seriesIndex: String? = null,
val categories: List<String> = emptyList(),
// ADD THESE:
val pseCount: Int? = null,
val pseUrlTemplate: String? = null
) {
val author: String?
get() = authors.firstOrNull()?.name
val bestAcquisition: OpdsAcquisition?
get() = acquisitions.maxByOrNull { it.priority }
val isAcquisition: Boolean
get() = acquisitions.isNotEmpty()
val isNavigation: Boolean
get() = navigationUrl != null && acquisitions.isEmpty()
val isStreamable: Boolean
get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
}
typealias OpdsCatalog = com.aryan.reader.shared.opds.OpdsCatalog
typealias OpdsFacet = com.aryan.reader.shared.opds.OpdsFacet
typealias OpdsFeed = com.aryan.reader.shared.opds.OpdsFeed
typealias OpdsAuthor = com.aryan.reader.shared.opds.OpdsAuthor
typealias OpdsAcquisition = com.aryan.reader.shared.opds.OpdsAcquisition
typealias OpdsEntry = com.aryan.reader.shared.opds.OpdsEntry
typealias OpdsScreenState = com.aryan.reader.shared.opds.SharedOpdsScreenState

View file

@ -1,486 +1,3 @@
// OpdsParser.kt
package com.aryan.reader.opds
import android.util.Xml
import org.json.JSONArray
import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
import java.io.InputStream
import java.util.UUID
class OpdsParser {
fun parse(bodyString: String, baseUrl: String): OpdsFeed {
val trimmed = bodyString.trimStart()
return if (trimmed.startsWith("{")) {
Timber.tag("OpdsDebug").d("Detected OPDS 2.0 (JSON) feed")
parseOpds2(trimmed, baseUrl)
} else {
Timber.tag("OpdsDebug").d("Detected OPDS 1.x (XML) feed")
parseOpds1(trimmed.byteInputStream(), baseUrl)
}
}
// --- OPDS 2.0 (JSON) Parsing ---
private fun parseOpds2(jsonString: String, baseUrl: String): OpdsFeed {
val root = JSONObject(jsonString)
val metadata = root.optJSONObject("metadata")
val title = metadata?.optString("title") ?: "OPDS 2.0 Feed"
var nextUrl: String? = null
var searchUrl: String? = null
val facets = mutableListOf<OpdsFacet>()
// Root Links
val links = root.optJSONArray("links")
if (links != null) {
for (i in 0 until links.length()) {
val link = links.getJSONObject(i)
val relArray = link.optJSONArray("rel")
val rels = mutableListOf<String>()
if (relArray != null) {
for (j in 0 until relArray.length()) rels.add(relArray.getString(j))
} else if (link.has("rel")) {
val rel = link.optString("rel")
if (rel.isNotBlank()) rels.add(rel)
}
val href = link.optString("href")
if (href.isNotEmpty()) {
val resolvedHref = resolveUrl(baseUrl, href)
if (rels.contains("next")) {
nextUrl = resolvedHref
} else if (rels.contains("search")) {
searchUrl = resolvedHref
}
}
}
}
// Facets
val facetsArray = root.optJSONArray("facets")
if (facetsArray != null) {
for (i in 0 until facetsArray.length()) {
val facetObj = facetsArray.getJSONObject(i)
val group = facetObj.optJSONObject("metadata")?.optString("title") ?: "Filter"
val facetLinks = facetObj.optJSONArray("links")
if (facetLinks != null) {
for (j in 0 until facetLinks.length()) {
val link = facetLinks.getJSONObject(j)
val href = link.optString("href")
if (href.isNotEmpty()) {
val titleFacet = link.optString("title", "Facet")
val properties = link.optJSONObject("properties")
val active = properties?.optBoolean("active", false) ?: false
facets.add(OpdsFacet(titleFacet, group, resolveUrl(baseUrl, href), active))
}
}
}
}
}
val entries = mutableListOf<OpdsEntry>()
// Publications
val publications = root.optJSONArray("publications")
if (publications != null) {
for (i in 0 until publications.length()) {
entries.add(parseOpds2Publication(publications.getJSONObject(i), baseUrl))
}
}
// Navigation
val navigation = root.optJSONArray("navigation")
if (navigation != null) {
for (i in 0 until navigation.length()) {
entries.add(parseOpds2Navigation(navigation.getJSONObject(i), baseUrl))
}
}
// Groups (Collections containing sub-navigation or sub-publications)
val groups = root.optJSONArray("groups")
if (groups != null) {
for (i in 0 until groups.length()) {
val group = groups.getJSONObject(i)
val groupTitle = group.optJSONObject("metadata")?.optString("title") ?: ""
val groupNav = group.optJSONArray("navigation")
if (groupNav != null) {
for (j in 0 until groupNav.length()) {
entries.add(parseOpds2Navigation(groupNav.getJSONObject(j), baseUrl))
}
}
val groupPubs = group.optJSONArray("publications")
if (groupPubs != null) {
for (j in 0 until groupPubs.length()) {
entries.add(parseOpds2Publication(groupPubs.getJSONObject(j), baseUrl))
}
}
val groupLinks = group.optJSONArray("links")
if (groupLinks != null) {
for (j in 0 until groupLinks.length()) {
val link = groupLinks.getJSONObject(j)
val href = link.optString("href")
if (href.isNotEmpty()) {
val linkTitle = link.optString("title", groupTitle)
entries.add(OpdsEntry(
id = href,
title = linkTitle,
summary = null,
authors = emptyList(),
coverUrl = null,
acquisitions = emptyList(),
navigationUrl = resolveUrl(baseUrl, href)
))
}
}
}
}
}
return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
}
private fun parseOpds2Publication(pub: JSONObject, baseUrl: String): OpdsEntry {
val metadata = pub.optJSONObject("metadata")
val title = metadata?.optString("title") ?: "Unknown Title"
val id = metadata?.optString("identifier") ?: pub.optString("id", UUID.randomUUID().toString())
val summary = metadata?.optString("description") ?: metadata?.optString("summary")
val language = metadata?.optString("language")
val publisher = metadata?.optString("publisher")
val published = metadata?.optString("published")
val authors = mutableListOf<OpdsAuthor>()
val authorObj = metadata?.opt("author")
if (authorObj is String) {
authors.add(OpdsAuthor(authorObj, null))
} else if (authorObj is JSONArray) {
for (i in 0 until authorObj.length()) {
val item = authorObj.get(i)
if (item is String) authors.add(OpdsAuthor(item, null))
else if (item is JSONObject) {
val name = item.optString("name")
var uri: String? = null
val links = item.optJSONArray("links")
if (links != null && links.length() > 0) {
uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href"))
}
if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri))
}
}
} else if (authorObj is JSONObject) {
val name = authorObj.optString("name")
var uri: String? = null
val links = authorObj.optJSONArray("links")
if (links != null && links.length() > 0) {
uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href"))
}
if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri))
}
val categories = mutableListOf<String>()
when (val subjectObj = metadata?.opt("subject")) {
is String -> categories.add(subjectObj)
is JSONArray -> {
for (i in 0 until subjectObj.length()) {
val subj = subjectObj.get(i)
if (subj is String) categories.add(subj)
else if (subj is JSONObject) categories.add(subj.optString("name"))
}
}
is JSONObject -> {
categories.add(subjectObj.optString("name"))
}
}
var series: String? = null
var seriesIndex: String? = null
val belongsTo = metadata?.optJSONObject("belongsTo")
if (belongsTo != null) {
val seriesObj = belongsTo.opt("series")
if (seriesObj is String) {
series = seriesObj
} else if (seriesObj is JSONObject) {
series = seriesObj.optString("name")
if (seriesObj.has("position")) {
seriesIndex = seriesObj.optDouble("position").toString().removeSuffix(".0")
}
} else if (seriesObj is JSONArray && seriesObj.length() > 0) {
val firstSeries = seriesObj.get(0)
if (firstSeries is String) {
series = firstSeries
} else if (firstSeries is JSONObject) {
series = firstSeries.optString("name")
if (firstSeries.has("position")) {
seriesIndex = firstSeries.optDouble("position").toString().removeSuffix(".0")
}
}
}
}
var coverUrl: String? = null
val images = pub.optJSONArray("images")
if (images != null && images.length() > 0) {
for (i in 0 until images.length()) {
val image = images.getJSONObject(i)
val href = image.optString("href")
if (href.isNotEmpty()) {
val resolvedHref = resolveUrl(baseUrl, href)
if (coverUrl == null) coverUrl = resolvedHref
val rels = image.opt("rel")
var isCover = false
if (rels is String && rels == "cover") isCover = true
else if (rels is JSONArray) {
for (j in 0 until rels.length()) if (rels.optString(j) == "cover") isCover = true
}
if (isCover) {
coverUrl = resolvedHref
break
}
}
}
}
val acquisitions = mutableListOf<OpdsAcquisition>()
var pseCount: Int? = null
var pseUrlTemplate: String? = null
val links = pub.optJSONArray("links")
if (links != null) {
for (i in 0 until links.length()) {
val link = links.getJSONObject(i)
val href = link.optString("href")
if (href.isNotEmpty()) {
val rels = link.opt("rel")
var isStream = false
if (rels is String && rels == "http://vaemendis.net/opds-pse/stream") isStream = true
else if (rels is JSONArray) {
for (j in 0 until rels.length()) if (rels.optString(j) == "http://vaemendis.net/opds-pse/stream") isStream = true
}
if (isStream) {
pseUrlTemplate = resolveUrl(baseUrl, href)
val properties = link.optJSONObject("properties")
pseCount = properties?.optInt("numberOfItems")?.takeIf { it > 0 }
}
var isAcquisition = false
if (rels is String && rels.contains("acquisition")) isAcquisition = true
else if (rels is JSONArray) {
for (j in 0 until rels.length()) if (rels.optString(j).contains("acquisition")) isAcquisition = true
}
if (isAcquisition) {
val type = link.optString("type") ?: ""
acquisitions.add(OpdsAcquisition(resolveUrl(baseUrl, href), type))
}
}
}
}
return OpdsEntry(
id = id, title = title, summary = summary, authors = authors,
coverUrl = coverUrl, acquisitions = acquisitions,
navigationUrl = null, publisher = publisher, published = published,
language = language, series = series, seriesIndex = seriesIndex, categories = categories,
pseCount = pseCount, pseUrlTemplate = pseUrlTemplate
)
}
private fun parseOpds2Navigation(nav: JSONObject, baseUrl: String): OpdsEntry {
val title = nav.optString("title", "Unknown")
val href = nav.optString("href")
val summary = nav.optString("description")
val navigationUrl = if (href.isNotEmpty()) resolveUrl(baseUrl, href) else null
return OpdsEntry(
id = href, title = title, summary = summary, authors = emptyList(),
coverUrl = null, acquisitions = emptyList(),
navigationUrl = navigationUrl
)
}
// --- OPDS 1.x (XML) Parsing ---
private fun parseOpds1(inputStream: InputStream, baseUrl: String): OpdsFeed {
return inputStream.use {
val parser: XmlPullParser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(it, null)
parser.nextTag()
Timber.tag("OpdsDebug").d($$"Parser started at root tag: <${parser.name}>")
readFeed(parser, baseUrl)
}
}
private fun readFeed(parser: XmlPullParser, baseUrl: String): OpdsFeed {
var title = ""
var nextUrl: String? = null
var searchUrl: String? = null
val entries = mutableListOf<OpdsEntry>()
val facets = mutableListOf<OpdsFacet>()
parser.require(XmlPullParser.START_TAG, null, "feed")
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name.substringAfter(":")) {
"title" -> title = readText(parser)
"entry" -> entries.add(readEntry(parser, baseUrl))
"link" -> {
val rel = parser.getAttributeValue(null, "rel")
val href = parser.getAttributeValue(null, "href")
val linkTitle = parser.getAttributeValue(null, "title")
val facetGroup = parser.getAttributeValue(null, "opds:facetGroup") ?: "Filter"
val activeFacet = parser.getAttributeValue(null, "opds:activeFacet") == "true"
if (rel == "next") {
nextUrl = resolveUrl(baseUrl, href ?: "")
} else if (rel == "search") {
searchUrl = resolveUrl(baseUrl, href ?: "")
} else if (rel == "facet" || rel == "http://opds-spec.org/facet") {
if (href != null && linkTitle != null) {
facets.add(OpdsFacet(linkTitle, facetGroup, resolveUrl(baseUrl, href), activeFacet))
}
}
skip(parser)
}
else -> skip(parser)
}
}
return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
}
private fun readEntry(parser: XmlPullParser, baseUrl: String): OpdsEntry {
parser.require(XmlPullParser.START_TAG, null, "entry")
var id = ""; var title = ""; var summary: String? = null
var coverUrl: String? = null; var navigationUrl: String? = null
var publisher: String? = null; var published: String? = null; var language: String? = null
var series: String? = null; var seriesIndex: String? = null
var pseCount: Int? = null
var pseUrlTemplate: String? = null
val authors = mutableListOf<OpdsAuthor>()
val categories = mutableListOf<String>()
val acquisitions = mutableListOf<OpdsAcquisition>()
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (val tagName = parser.name.substringAfter(":")) {
"id" -> id = readText(parser)
"title" -> title = readText(parser)
"summary", "content" -> summary = readText(parser)
"author" -> authors.add(readAuthor(parser, baseUrl))
"publisher" -> publisher = readText(parser)
"language" -> language = language ?: readText(parser)
"issued", "published", "updated" -> {
val date = readText(parser)
if (published == null || tagName != "updated") published = date
}
"category" -> {
val label = parser.getAttributeValue(null, "label")
val term = parser.getAttributeValue(null, "term")
val cat = label ?: term
if (!cat.isNullOrBlank()) categories.add(cat)
skip(parser)
}
"meta" -> {
val property = parser.getAttributeValue(null, "property") ?: parser.getAttributeValue(null, "name")
val content = parser.getAttributeValue(null, "content")
val textContent = readText(parser)
if (property == "calibre:series") series = content ?: textContent.takeIf { it.isNotBlank() }
else if (property == "calibre:series_index") seriesIndex = content ?: textContent.takeIf { it.isNotBlank() }
}
"link" -> {
val rel = parser.getAttributeValue(null, "rel") ?: ""
val href = parser.getAttributeValue(null, "href") ?: ""
val type = parser.getAttributeValue(null, "type") ?: ""
val linkTitle = parser.getAttributeValue(null, "title")
if (rel == "http://vaemendis.net/opds-pse/stream") {
pseUrlTemplate = resolveUrl(baseUrl, href)
val countStr = parser.getAttributeValue(null, "pse:count")
pseCount = countStr?.toIntOrNull()
}
if (rel == "http://calibre-ebook.com/opds/series") {
if (series == null) series = linkTitle
}
if (href.isNotEmpty()) {
val absoluteUrl = resolveUrl(baseUrl, href)
if (rel.contains("http://opds-spec.org/image")) {
if (coverUrl == null || rel.contains("thumbnail")) coverUrl = absoluteUrl
} else if (rel.contains("http://opds-spec.org/acquisition")) {
acquisitions.add(OpdsAcquisition(absoluteUrl, type))
} else if (type.contains("profile=opds-catalog") || type.contains("application/atom+xml")) {
if (navigationUrl == null) navigationUrl = absoluteUrl
} else if (rel == "subsection" || rel == "collection" || rel == "start") {
if (navigationUrl == null) navigationUrl = absoluteUrl
}
}
skip(parser)
}
else -> skip(parser)
}
}
return OpdsEntry(id, title, summary, authors, coverUrl, acquisitions, navigationUrl, publisher, published, language, series, seriesIndex, categories, pseCount, pseUrlTemplate)
}
private fun readAuthor(parser: XmlPullParser, baseUrl: String): OpdsAuthor {
var name = ""
var uri: String? = null
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name.substringAfter(":")) {
"name" -> name = readText(parser)
"uri" -> uri = resolveUrl(baseUrl, readText(parser))
else -> skip(parser)
}
}
return OpdsAuthor(name, uri)
}
private fun readText(parser: XmlPullParser): String {
val result = StringBuilder()
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.TEXT, XmlPullParser.CDSECT, XmlPullParser.ENTITY_REF -> {
result.append(parser.text)
}
XmlPullParser.START_TAG -> depth++
XmlPullParser.END_TAG -> depth--
}
}
return result.toString().trim()
}
private fun skip(parser: XmlPullParser) {
if (parser.eventType != XmlPullParser.START_TAG) throw java.lang.IllegalStateException()
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
private fun resolveUrl(baseUrl: String, href: String): String {
return try {
val resolved = java.net.URL(java.net.URL(baseUrl), href).toString()
resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
.replace("http://www.gutenberg.org", "https://www.gutenberg.org")
} catch (_: Exception) {
href
}
}
}
typealias OpdsParser = com.aryan.reader.shared.opds.SharedOpdsParser

View file

@ -3,12 +3,11 @@ package com.aryan.reader.opds
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.aryan.reader.shared.opds.SharedOpdsCatalogs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.security.MessageDigest
import java.util.UUID
@ -37,76 +36,26 @@ class OpdsRepository(context: Context) {
fun getCatalogs(): List<OpdsCatalog> {
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
val catalogs = mutableListOf<OpdsCatalog>()
if (jsonString != null) {
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
catalogs.add(
OpdsCatalog(
id = obj.getString("id"),
title = obj.getString("title"),
url = obj.getString("url"),
isDefault = obj.optBoolean("isDefault", false),
username = obj.optString("username", "").takeIf { it.isNotBlank() },
password = obj.optString("password", "").takeIf { it.isNotBlank() }
)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
val catalogs = decodedCatalogs.ifEmpty {
SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() }
}
if (catalogs.isEmpty()) {
catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Project Gutenberg", "https://m.gutenberg.org/ebooks.opds/", isDefault = true))
catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Standard Ebooks", "https://standardebooks.org/feeds/opds", isDefault = true))
if (decodedCatalogs.isEmpty()) {
saveCatalogs(catalogs)
}
return catalogs
}
private fun resolveUrl(baseUrl: String, href: String): String {
return try {
val resolved = java.net.URL(java.net.URL(baseUrl), href).toString()
resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
.replace("http://www.gutenberg.org", "https://www.gutenberg.org")
} catch (_: Exception) {
href
}
}
suspend fun getSearchTemplate(openSearchUrl: String): String? = withContext(Dispatchers.IO) {
suspend fun getSearchTemplate(
openSearchUrl: String,
username: String? = null,
password: String? = null
): String? = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(openSearchUrl).build()
val response = httpClient.newCall(request).execute()
val response = getAuthenticatedClient(username, password).newCall(request).execute()
val body = response.body?.string() ?: return@withContext null
val parser = android.util.Xml.newPullParser()
parser.setFeature(org.xmlpull.v1.XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(body.byteInputStream(), null)
var eventType = parser.eventType
while (eventType != org.xmlpull.v1.XmlPullParser.END_DOCUMENT) {
if (eventType == org.xmlpull.v1.XmlPullParser.START_TAG && parser.name.equals("Url", ignoreCase = true)) {
val type = parser.getAttributeValue(null, "type")
if (type != null && (type.contains("atom+xml") || type.contains("opds+xml"))) {
val template = parser.getAttributeValue(null, "template")
if (template != null) {
val resolvedTemplate = resolveUrl(openSearchUrl, template)
Timber.tag("OpdsDebug").d("Resolved search template: $resolvedTemplate")
return@withContext resolvedTemplate
}
}
}
eventType = parser.next()
}
null
parser.extractOpenSearchTemplate(body, openSearchUrl)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch OpenSearch template")
null
@ -114,48 +63,28 @@ class OpdsRepository(context: Context) {
}
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
val current = getCatalogs().toMutableList()
current.add(OpdsCatalog(UUID.randomUUID().toString(), title, url, username = username, password = password))
saveCatalogs(current)
saveCatalogs(
SharedOpdsCatalogs.addCatalog(
catalogs = getCatalogs(),
title = title,
url = url,
username = username,
password = password,
idFactory = { UUID.randomUUID().toString() }
)
)
}
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
val current = getCatalogs().toMutableList()
val index = current.indexOfFirst { it.id == id }
if (index != -1 && !current[index].isDefault) {
current[index] = current[index].copy(
title = title.trim(),
url = url.trim(),
username = username?.trim().takeIf { !it.isNullOrBlank() },
password = password?.trim().takeIf { !it.isNullOrBlank() }
)
saveCatalogs(current)
}
saveCatalogs(SharedOpdsCatalogs.updateCatalog(getCatalogs(), id, title, url, username, password))
}
fun removeCatalog(id: String) {
val current = getCatalogs().toMutableList()
val toRemove = current.find { it.id == id }
if (toRemove?.isDefault == true) {
return
}
current.removeAll { it.id == id }
saveCatalogs(current)
saveCatalogs(SharedOpdsCatalogs.removeCatalog(getCatalogs(), id))
}
private fun saveCatalogs(catalogs: List<OpdsCatalog>) {
val jsonArray = JSONArray()
catalogs.forEach { catalog ->
val obj = JSONObject()
obj.put("id", catalog.id)
obj.put("title", catalog.title)
obj.put("url", catalog.url)
obj.put("isDefault", catalog.isDefault)
if (catalog.username != null) obj.put("username", catalog.username)
if (catalog.password != null) obj.put("password", catalog.password)
jsonArray.put(obj)
}
prefs.edit { putString(KEY_CATALOGS_JSON, jsonArray.toString()) }
prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
}
fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {
@ -272,4 +201,4 @@ class OpdsRepository(context: Context) {
Result.failure(e)
}
}
}
}

View file

@ -5,7 +5,8 @@ import android.content.Context
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.aryan.reader.resolveFileExtensionSuffixFromName
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
import com.aryan.reader.shared.opds.SharedOpdsSearch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -18,16 +19,6 @@ import okhttp3.Request
import timber.log.Timber
import java.io.File
data class OpdsScreenState(
val catalogs: List<OpdsCatalog> = emptyList(),
val currentCatalog: OpdsCatalog? = null,
val currentFeed: OpdsFeed? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isViewingCatalog: Boolean = false,
val searchUrlTemplate: String? = null
)
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = OpdsRepository(application)
@ -142,42 +133,11 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
}
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
val candidates = listOfNotNull(
response.header("Content-Disposition")?.let(::extractContentDispositionFilename),
Uri.parse(acquisition.url).lastPathSegment
return SharedOpdsDownloadNamer.resolveExtension(
acquisition = acquisition,
contentDisposition = response.header("Content-Disposition"),
urlPathSegment = Uri.parse(acquisition.url).lastPathSegment
)
candidates.forEach { candidate ->
resolveFileExtensionSuffixFromName(Uri.decode(candidate))?.let { return it }
}
return when (acquisition.formatName) {
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"
"CBR" -> ".cbr"
"MD" -> ".md"
"HTML" -> ".html"
"TXT" -> ".txt"
else -> ".epub"
}
}
private fun extractContentDispositionFilename(contentDisposition: String): String? {
val encodedFilename = Regex("filename\\*=UTF-8''([^;]+)", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
return Regex("filename=\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
.find(contentDisposition)
?.groupValues
?.getOrNull(1)
?.trim()
?.trim('"')
}
init {
@ -233,17 +193,9 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val template = if (!searchLink.contains("{searchTerms}")) {
repository.getSearchTemplate(searchLink) ?: searchLink
} else {
searchLink
}
val finalUrl = if (template.contains("{searchTerms}")) {
template.replace("{searchTerms}", Uri.encode(query))
} else {
val separator = if (template.contains("?")) "&" else "?"
"$template${separator}query=${Uri.encode(query)}"
val finalUrl = SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
val catalog = _uiState.value.currentCatalog
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
}
openFeedUrl(finalUrl)

View file

@ -62,7 +62,8 @@ fun androidHtmlToSemanticBlocks(
fontFamilyMap: Map<String, FontFamily>,
constraints: androidx.compose.ui.unit.Constraints,
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
mathSvgCache: Map<String, String> = emptyMap()
mathSvgCache: Map<String, String> = emptyMap(),
adaptThemeColors: Boolean = false
): List<SemanticBlock> {
return htmlToSemanticBlocks(
html = html,
@ -76,6 +77,7 @@ fun androidHtmlToSemanticBlocks(
imageDimensionsCache = imageDimensionsCache,
mathSvgCache = mathSvgCache,
resourceResolver = AndroidHtmlResourceResolver,
fontFamilyLoader = AndroidHtmlFontFamilyLoader
fontFamilyLoader = AndroidHtmlFontFamilyLoader,
adaptThemeColors = adaptThemeColors
)
}

View file

@ -42,7 +42,10 @@ import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookProcessingInput
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.aryan.reader.paginatedreader.data.ConfigurationCache
import com.aryan.reader.paginatedreader.data.LATEST_PAGE_CACHE_VERSION
import com.aryan.reader.paginatedreader.data.LATEST_PROCESSING_VERSION
import com.aryan.reader.paginatedreader.data.PageCacheEntry
import com.aryan.reader.paginatedreader.data.PageIndexEntry
import com.aryan.reader.paginatedreader.data.ProcessedBook
import com.aryan.reader.paginatedreader.data.ProcessedChapter
import com.aryan.reader.paginatedreader.data.SerializableEpubChapter
@ -80,7 +83,8 @@ data class TtsChunk(
val text: String,
val sourceCfi: String,
val startOffsetInSource: Int,
val timedWords: List<TimedWord> = emptyList()
val timedWords: List<TimedWord> = emptyList(),
val spokenText: String = text
)
private data class PaginationRequest(val chapterIndex: Int, val priority: Int) : Comparable<PaginationRequest> {
@ -94,6 +98,26 @@ private data class PaginationRequest(val chapterIndex: Int, val priority: Int) :
}
}
private const val PAGE_INDEX_ANCHOR_SEPARATOR = "\u001F"
private data class TextRangeIndex(
val pageInChapter: Int,
val blockIndex: Int,
val startOffset: Int,
val endOffset: Int
)
private data class PageNavigationEntry(
val pageInChapter: Int,
val firstBlockIndex: Int,
val lastBlockIndex: Int,
val firstTextBlockIndex: Int?,
val firstTextCharOffset: Int,
val firstTextEndOffset: Int,
val firstCfi: String?,
val anchors: Set<String>
)
@OptIn(ExperimentalSerializationApi::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Stable
@ -138,7 +162,7 @@ class BookPaginator(
internal val chapterPageCounts = ConcurrentHashMap<Int, Int>()
val chapterStartPageIndices = ConcurrentHashMap<Int, Int>()
private val pageCache = object : LruCache<Int, List<Page>>(6) {
private val pageCache = object : LruCache<Int, List<Page>>(12) {
override fun entryRemoved(evicted: Boolean, key: Int, oldValue: List<Page>, newValue: List<Page>?) {
Timber.d("Chapter $key pages removed from cache. Evicted: $evicted")
}
@ -151,10 +175,15 @@ class BookPaginator(
}
private val chapterCharacterIndex = ConcurrentHashMap<Int, List<PageCharacterRange>>()
private val chapterCumulativeChars = ConcurrentHashMap<Int, List<Long>>()
private val chapterTextRangeIndex = ConcurrentHashMap<Int, List<TextRangeIndex>>()
private val chapterPageNavigationIndex = ConcurrentHashMap<Int, List<PageNavigationEntry>>()
private val chapterAnchorPageIndex = ConcurrentHashMap<Int, Map<String, Int>>()
private var pageCountsAreAccurate by mutableStateOf(false)
private val finalizedChapterCounts = ConcurrentHashMap.newKeySet<Int>()
private var currentConfigHash: Int = 0
@Volatile
private var chapterStartSnapshot: IntArray = IntArray(0)
private val paginationQueue = PriorityBlockingQueue<PaginationRequest>()
private val chaptersBeingProcessed = ConcurrentHashMap.newKeySet<Int>()
@ -198,6 +227,7 @@ class BookPaginator(
val bookRecord = bookCacheDao.getProcessedBook(bookId)
if (bookRecord == null || bookRecord.processingVersion < LATEST_PROCESSING_VERSION) {
Timber.i("Book cache is new or stale. Creating initial record.")
bookCacheDao.deleteEntireBookCache(bookId)
val initialBook = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, 0) // Temp 0
bookCacheDao.insertProcessedBook(initialBook)
enqueueBookProcessingWork()
@ -229,8 +259,10 @@ class BookPaginator(
triggerPagination(startChapter, PRIORITY_HIGHEST)
// Queue neighbors with lower priority
if (startChapter + 1 < chapters.size) triggerPagination(startChapter + 1, PRIORITY_LOW)
if (startChapter - 1 >= 0) triggerPagination(startChapter - 1, PRIORITY_LOW)
for (offset in 1..2) {
if (startChapter + offset < chapters.size) triggerPagination(startChapter + offset, PRIORITY_LOW)
if (startChapter - offset >= 0) triggerPagination(startChapter - offset, PRIORITY_LOW)
}
isLoading = false
Timber.i("Paginator initialized. UI is ready.")
@ -238,10 +270,8 @@ class BookPaginator(
}
}
// [ADD this new function]
private fun runEstimator() {
var runningTotal = 0
val tempCounts = mutableMapOf<Int, Int>()
// This loop is extremely fast (math only)
chapters.forEachIndexed { index, chapter ->
@ -255,12 +285,12 @@ class BookPaginator(
chapterPageCounts[index] = estimatedCount
chapterStartPageIndices[index] = runningTotal
tempCounts[index] = estimatedCount
runningTotal += estimatedCount
}
totalPageCount = runningTotal
pageCountsAreAccurate = false
rebuildChapterStartSnapshot()
Timber.i("Estimator finished. Estimated total pages: $totalPageCount")
}
@ -287,6 +317,11 @@ class BookPaginator(
append("-pg:$paragraphGapMultiplier")
append("-img:$imageSizeMultiplier")
append("-vm:$verticalMarginMultiplier")
append("-proc:$LATEST_PROCESSING_VERSION")
append("-pageCache:$LATEST_PAGE_CACHE_VERSION")
append("-ua:${userAgentStylesheet.hashCode()}")
append("-css:${bookCss.hashCode()}")
append("-fonts:${allFontFaces.hashCode()}")
}
val hash = configString.hashCode()
return hash
@ -325,6 +360,7 @@ class BookPaginator(
}
totalPageCount = runningTotal
pageCountsAreAccurate = countsMap.size == chapters.size
rebuildChapterStartSnapshot()
}
private suspend fun updateAndSaveConfigurationCache() {
@ -340,12 +376,204 @@ class BookPaginator(
bookCacheDao.insertConfigurationCache(newCache)
bookCacheDao.cleanupOldConfigurations(bookId)
bookCacheDao.cleanupOldPageCaches(bookId)
if (finalizedChapterCounts.size >= chapters.size) {
pageCountsAreAccurate = true
}
}
private fun rebuildChapterStartSnapshot() {
chapterStartSnapshot = IntArray(chapters.size) { index ->
chapterStartPageIndices[index] ?: 0
}
}
private fun chapterContentVersion(chapter: EpubChapter): Int {
val backingFile = java.io.File(extractionBasePath, chapter.htmlFilePath)
return buildString {
append(chapter.absPath)
append('|')
append(chapter.htmlFilePath)
append('|')
append(chapter.htmlContent.length)
append('|')
append(chapter.htmlContent.hashCode())
append('|')
append(chapter.plainTextContent.length)
append('|')
append(chapter.plainTextContent.hashCode())
append('|')
if (backingFile.exists()) {
append(backingFile.length())
append('|')
append(backingFile.lastModified())
}
}.hashCode()
}
private suspend fun loadCachedPagesForChapter(chapter: EpubChapter, chapterIndex: Int): List<Page>? {
val cachedPages = bookCacheDao.getPageCache(bookId, currentConfigHash, chapterIndex) ?: return null
val expectedContentVersion = chapterContentVersion(chapter)
val isCompatible = cachedPages.processingVersion == LATEST_PROCESSING_VERSION &&
cachedPages.pageCacheVersion == LATEST_PAGE_CACHE_VERSION &&
cachedPages.contentVersion == expectedContentVersion
if (!isCompatible) {
Timber.d("Page cache stale for chapter $chapterIndex. Ignoring cached pages.")
return null
}
return try {
val pages = proto.decodeFromByteArray<List<Page>>(cachedPages.pagesProto)
if (pages.size != cachedPages.pageCount) {
Timber.w("Page cache count mismatch for chapter $chapterIndex. Ignoring cached pages.")
null
} else {
pageCache.put(chapterIndex, pages)
applyPageRuntimeIndexes(chapterIndex, pages)
updatePageCountsOnMain(chapterIndex, pages.size)
Timber.i("Page cache HIT for chapter $chapterIndex. Loaded ${pages.size} measured pages.")
pages
}
} catch (e: Exception) {
Timber.e(e, "Failed to deserialize page cache for chapter $chapterIndex")
null
}
}
private fun savePageCacheAsync(chapter: EpubChapter, chapterIndex: Int, pages: List<Page>) {
coroutineScope.launch(Dispatchers.IO) {
try {
val pageIndexEntries = buildPersistentPageIndexEntries(chapterIndex, pages)
val cacheEntry = PageCacheEntry(
bookId = bookId,
configHash = currentConfigHash,
chapterIndex = chapterIndex,
processingVersion = LATEST_PROCESSING_VERSION,
pageCacheVersion = LATEST_PAGE_CACHE_VERSION,
contentVersion = chapterContentVersion(chapter),
pageCount = pages.size,
pagesProto = proto.encodeToByteArray(pages)
)
bookCacheDao.insertPageCache(cacheEntry, pageIndexEntries)
Timber.d("Saved measured page cache for chapter $chapterIndex (${pages.size} pages).")
} catch (e: Exception) {
Timber.e(e, "Failed to persist page cache for chapter $chapterIndex")
}
}
}
private fun getAllBlocks(blocks: List<ContentBlock>): List<ContentBlock> {
return blocks.flatMap { block ->
when (block) {
is WrappingContentBlock -> listOf(block, block.floatedImage) + getAllBlocks(block.paragraphsToWrap)
is FlexContainerBlock -> listOf(block) + getAllBlocks(block.children)
is TableBlock -> listOf(block) + block.rows.flatten().flatMap { getAllBlocks(it.content) }
else -> listOf(block)
}
}
}
private fun applyPageRuntimeIndexes(chapterIndex: Int, pages: List<Page>) {
val characterIndex = mutableListOf<PageCharacterRange>()
val textRangeIndex = mutableListOf<TextRangeIndex>()
val navigationEntries = mutableListOf<PageNavigationEntry>()
val anchorPageMap = linkedMapOf<String, Int>()
val cumulativeCharsPerPage = mutableListOf<Long>()
var runningTotalChars = 0L
pages.forEachIndexed { pageInChapterIndex, page ->
val allBlocksOnPage = getAllBlocks(page.content)
val allTextBlocksOnPage = getAllTextBlocks(page.content)
val anchors = allBlocksOnPage.flatMap { findAllIds(it) }.toSet()
anchors.forEach { anchorPageMap.putIfAbsent(it, pageInChapterIndex) }
allTextBlocksOnPage.forEach { block ->
if (block.cfi != null && block.startCharOffsetInSource >= 0 && block.content.isNotEmpty()) {
val startOffset = block.startCharOffsetInSource
val endOffset = startOffset + block.content.text.length
characterIndex.add(
PageCharacterRange(
pageInChapter = pageInChapterIndex,
cfi = block.cfi!!,
startOffset = startOffset,
endOffset = endOffset
)
)
textRangeIndex.add(
TextRangeIndex(
pageInChapter = pageInChapterIndex,
blockIndex = block.blockIndex,
startOffset = startOffset,
endOffset = endOffset
)
)
}
}
val firstTextBlock = allTextBlocksOnPage.firstOrNull { it.content.text.isNotBlank() }
?: allTextBlocksOnPage.firstOrNull()
val firstBlock = allBlocksOnPage.firstOrNull()
val blockIndices = allBlocksOnPage.map { it.blockIndex }
navigationEntries.add(
PageNavigationEntry(
pageInChapter = pageInChapterIndex,
firstBlockIndex = blockIndices.minOrNull() ?: firstBlock?.blockIndex ?: -1,
lastBlockIndex = blockIndices.maxOrNull() ?: firstBlock?.blockIndex ?: -1,
firstTextBlockIndex = firstTextBlock?.blockIndex,
firstTextCharOffset = firstTextBlock?.startCharOffsetInSource ?: 0,
firstTextEndOffset = firstTextBlock?.let { it.startCharOffsetInSource + it.content.text.length } ?: 0,
firstCfi = firstTextBlock?.cfi ?: firstBlock?.cfi,
anchors = anchors
)
)
runningTotalChars += allTextBlocksOnPage.sumOf { it.content.text.length.toLong() }
cumulativeCharsPerPage.add(runningTotalChars)
}
chapterCharacterIndex[chapterIndex] = characterIndex
chapterTextRangeIndex[chapterIndex] = textRangeIndex
chapterPageNavigationIndex[chapterIndex] = navigationEntries
chapterAnchorPageIndex[chapterIndex] = anchorPageMap
chapterCumulativeChars[chapterIndex] = cumulativeCharsPerPage
}
private fun buildPersistentPageIndexEntries(chapterIndex: Int, pages: List<Page>): List<PageIndexEntry> {
val entries = chapterPageNavigationIndex[chapterIndex] ?: run {
applyPageRuntimeIndexes(chapterIndex, pages)
chapterPageNavigationIndex[chapterIndex].orEmpty()
}
return entries.map { entry ->
PageIndexEntry(
bookId = bookId,
configHash = currentConfigHash,
chapterIndex = chapterIndex,
pageInChapter = entry.pageInChapter,
firstBlockIndex = entry.firstBlockIndex,
lastBlockIndex = entry.lastBlockIndex,
firstTextBlockIndex = entry.firstTextBlockIndex,
firstTextCharOffset = entry.firstTextCharOffset,
firstTextEndOffset = entry.firstTextEndOffset,
firstCfi = entry.firstCfi,
anchors = entry.anchors.sorted().joinToString(PAGE_INDEX_ANCHOR_SEPARATOR)
)
}
}
private suspend fun updatePageCountsOnMain(chapterIndex: Int, actualPageCount: Int) {
withContext(Dispatchers.Main) {
if (chapterPageCounts[chapterIndex] != actualPageCount) {
updatePageCounts(chapterIndex, actualPageCount)
} else if (finalizedChapterCounts.add(chapterIndex)) {
coroutineScope.launch(Dispatchers.IO) { updateAndSaveConfigurationCache() }
}
generation++
}
}
suspend fun getTtsChunksForChapter(chapterIndex: Int, startingFromPageInChapter: Int = 0): List<TtsChunk>? {
val pages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
if (pages.isNullOrEmpty()) {
@ -401,7 +629,12 @@ class BookPaginator(
private fun enqueueBookProcessingWork() {
val serializableChapters = chapters.map {
SerializableEpubChapter(it.htmlContent, it.title, it.absPath)
SerializableEpubChapter(
htmlContent = it.htmlContent,
title = it.title,
absPath = it.absPath,
htmlFilePath = it.htmlFilePath
)
}
val input = BookProcessingInput(
@ -437,13 +670,12 @@ class BookPaginator(
chapterAbsPath = chapter.absPath,
extractionBasePath = extractionBasePath,
userTextAlign = userTextAlign,
paragraphGapMultiplier = paragraphGapMultiplier
paragraphGapMultiplier = paragraphGapMultiplier,
adaptThemeColors = false
)
bookCacheDao.getProcessedChapter(bookId, chapterIndex)?.let { cachedChapter ->
if (cachedChapter.estimatedPageCount == 0) {
Timber.d("getBlocksForChapter: Found 'lite' cache for chapter $chapterIndex. Reprocessing for full fidelity.")
} else {
if (cachedChapter.contentBlocksProto.isNotEmpty()) {
try {
val semanticBlocks = proto.decodeFromByteArray<List<SemanticBlock>>(cachedChapter.contentBlocksProto)
@ -466,10 +698,12 @@ class BookPaginator(
} catch (e: Exception) {
Timber.e(e, "Failed to deserialize/style chapter $chapterIndex from DB. Reprocessing for this session.")
}
} else {
Timber.d("getBlocksForChapter: Cached chapter $chapterIndex had no semantic payload. Reprocessing.")
}
}
Timber.d("getBlocksForChapter: Cache MISS or 'lite' version found for chapter $chapterIndex. Parsing to Semantic IR.")
Timber.d("getBlocksForChapter: Cache MISS for chapter $chapterIndex. Parsing to Semantic IR.")
var htmlToParse = chapter.htmlContent
if (htmlToParse.isEmpty()) {
@ -506,10 +740,10 @@ class BookPaginator(
val processedHtml = document.outerHtml()
var parsingCssRules = OptimizedCssRules()
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, adaptThemeColors = false)
parsingCssRules = parsingCssRules.merge(uaResult.rules)
bookCss.forEach { (path, content) ->
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, adaptThemeColors = false)
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
}
@ -522,7 +756,8 @@ class BookPaginator(
density = density,
fontFamilyMap = fontFamilyMap,
constraints = constraints,
mathSvgCache = svgResults
mathSvgCache = svgResults,
adaptThemeColors = false
)
coroutineScope.launch(Dispatchers.IO) {
@ -617,6 +852,7 @@ class BookPaginator(
for (i in (chapterIndex + 1) until chapters.size) {
chapterStartPageIndices[i] = (chapterStartPageIndices[i] ?: 0) + difference
}
rebuildChapterStartSnapshot()
if (chapterIndex < currentUserChapterIndex.value) {
pageShiftRequest.tryEmit(difference)
@ -640,6 +876,14 @@ class BookPaginator(
val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0
val currentPageInChapter = pageIndex - chapterStart
chapterAnchorPageIndex[chapterIndex]?.let { anchorPages ->
val anchorSet = tocAnchors.toSet()
return anchorPages
.filter { (anchor, anchorPage) -> anchor in anchorSet && anchorPage <= currentPageInChapter }
.maxByOrNull { it.value }
?.key
}
var lastFoundAnchor: String? = null
val anchorSet = tocAnchors.toSet()
@ -685,6 +929,19 @@ class BookPaginator(
)
return null
}
val starts = chapterStartSnapshot
if (starts.isNotEmpty()) {
val exactOrInsertionPoint = starts.binarySearch(pageIndex)
val index = if (exactOrInsertionPoint >= 0) {
exactOrInsertionPoint
} else {
-exactOrInsertionPoint - 2
}
if (index in chapters.indices) {
return index
}
}
val entry = chapterStartPageIndices.entries
.filter { it.value <= pageIndex }
.maxWithOrNull(compareBy({ it.value }, { it.key }))
@ -698,12 +955,24 @@ class BookPaginator(
override fun getCfiForPage(pageIndex: Int): String? {
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
val chapterStart = chapterStartPageIndices[chapterIndex] ?: 0
val pageInChapterIndex = pageIndex - chapterStart
chapterPageNavigationIndex[chapterIndex]
?.getOrNull(pageInChapterIndex)
?.firstCfi
?.let { cfi ->
val offset = chapterPageNavigationIndex[chapterIndex]
?.getOrNull(pageInChapterIndex)
?.firstTextCharOffset
?: 0
return if (offset > 0 && !cfi.contains(':')) "$cfi:$offset" else cfi
}
val chapterPages = pageCache[chapterIndex]
if (chapterPages == null) {
Timber.w("getCfiForPage: Chapter $chapterIndex not in cache for page $pageIndex.")
return null
}
val pageInChapterIndex = pageIndex - (chapterStartPageIndices[chapterIndex] ?: 0)
val pageContent = chapterPages.getOrNull(pageInChapterIndex)?.content ?: return null
val firstTextBlock = pageContent.firstOrNull { it is TextContentBlock } as? TextContentBlock
@ -729,6 +998,11 @@ class BookPaginator(
return null
}
loadCachedPagesForChapter(chapter, chapterIndex)?.let {
Timber.d("paginateChapter: Persistent page cache HIT for chapter $chapterIndex.")
return it
}
val blocks = blockCache[chapterIndex] ?: run {
Timber.d("paginateChapter: L2 Cache MISS for chapter $chapterIndex. Loading from DB.")
val blocksFromDb = getBlocksForChapter(chapter, chapterIndex)
@ -757,47 +1031,10 @@ class BookPaginator(
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
pageCache.put(chapterIndex, pages)
Timber.d("paginateChapter: Chapter $chapterIndex pages stored in L1 pageCache.")
applyPageRuntimeIndexes(chapterIndex, pages)
savePageCacheAsync(chapter, chapterIndex, pages)
val characterIndex = mutableListOf<PageCharacterRange>()
pages.forEachIndexed { pageInChapterIndex, page ->
var totalCharsOnPage = 0L
val allTextBlocksOnPage = getAllTextBlocks(page.content)
allTextBlocksOnPage.forEach { block ->
if (block.cfi != null && block.startCharOffsetInSource >= 0 && block.content.isNotEmpty()) {
val startOffset = block.startCharOffsetInSource
val endOffset = startOffset + block.content.text.length
totalCharsOnPage += block.content.text.length
characterIndex.add(
PageCharacterRange(
pageInChapter = pageInChapterIndex,
cfi = block.cfi!!,
startOffset = startOffset,
endOffset = endOffset
)
)
}
}
}
chapterCharacterIndex[chapterIndex] = characterIndex
val cumulativeCharsPerPage = mutableListOf<Long>()
var runningTotalChars = 0L
pages.forEachIndexed { _, page ->
val charsOnPage = getAllTextBlocks(page.content).sumOf { it.content.text.length.toLong() }
runningTotalChars += charsOnPage
cumulativeCharsPerPage.add(runningTotalChars)
}
chapterCumulativeChars[chapterIndex] = cumulativeCharsPerPage
withContext(Dispatchers.Main) {
if (chapterPageCounts[chapterIndex] != pages.size) {
updatePageCounts(chapterIndex, pages.size)
}
generation++
}
updatePageCountsOnMain(chapterIndex, pages.size)
return pages
}
@ -835,14 +1072,16 @@ class BookPaginator(
private fun prefetchChapters(currentChapterIndex: Int) {
Timber.v("Prefetching chapters around index $currentChapterIndex.")
val nextChapterIndex = currentChapterIndex + 1
if (nextChapterIndex < chapters.size) {
triggerPagination(nextChapterIndex, PRIORITY_MEDIUM)
}
for (offset in 1..2) {
val nextChapterIndex = currentChapterIndex + offset
if (nextChapterIndex < chapters.size) {
triggerPagination(nextChapterIndex, PRIORITY_MEDIUM)
}
val prevChapterIndex = currentChapterIndex - 1
if (prevChapterIndex >= 0) {
triggerPagination(prevChapterIndex, PRIORITY_MEDIUM)
val prevChapterIndex = currentChapterIndex - offset
if (prevChapterIndex >= 0) {
triggerPagination(prevChapterIndex, PRIORITY_MEDIUM)
}
}
}
@ -908,6 +1147,19 @@ class BookPaginator(
return@launch
}
val indexedPageInChapter = targetBlock?.let { blockIndex ->
chapterPageNavigationIndex[targetChapter]
?.firstOrNull { blockIndex in it.firstBlockIndex..it.lastBlockIndex }
?.pageInChapter
} ?: chapterAnchorPageIndex[targetChapter]?.get(anchor)
if (indexedPageInChapter != null) {
val finalPage = chapterStartPage + indexedPageInChapter
Timber.tag("TOC_NAV_DEBUG").d("Navigation resolved from page index to Absolute Page: $finalPage")
withContext(Dispatchers.Main) { onResult(finalPage) }
return@launch
}
// 3. FIND PAGE
var targetPageInChapter = 0
var found = false
@ -1093,6 +1345,26 @@ class BookPaginator(
return null
}
chapterTextRangeIndex[targetChapterIndex]
?.firstOrNull { range ->
range.blockIndex == locator.blockIndex &&
(locator.charOffset in range.startOffset..<range.endOffset ||
(range.startOffset == range.endOffset && locator.charOffset == range.startOffset))
}
?.let { range ->
val finalPageIndex = chapterStartPage + range.pageInChapter
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND via runtime index on absolute page $finalPageIndex")
return finalPageIndex
}
chapterPageNavigationIndex[targetChapterIndex]
?.firstOrNull { locator.blockIndex in it.firstBlockIndex..it.lastBlockIndex }
?.let { entry ->
val finalPageIndex = chapterStartPage + entry.pageInChapter
Timber.tag("POS_DIAG").w("findPageForLocator: Using block-range fallback page $finalPageIndex")
return finalPageIndex
}
var fallbackPageInChapter = -1
for ((pageIndex, page) in chapterPages.withIndex()) {
@ -1150,6 +1422,23 @@ class BookPaginator(
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
Timber.tag("POS_DIAG").d("getLocatorForPage: Request pageIndex=$pageIndex. Resolved chapterIndex=$chapterIndex (starts at $chStart). PageInChapter=${pageIndex - chStart}")
chapterPageNavigationIndex[chapterIndex]?.getOrNull(pageIndex - chStart)?.let { entry ->
entry.firstTextBlockIndex?.let { blockIndex ->
return Locator(
chapterIndex = chapterIndex,
blockIndex = blockIndex,
charOffset = entry.firstTextCharOffset
)
}
if (entry.firstBlockIndex >= 0) {
return Locator(
chapterIndex = chapterIndex,
blockIndex = entry.firstBlockIndex,
charOffset = 0
)
}
}
val pageContent = getPageContent(pageIndex) ?: return null
Timber.tag("POS_DIAG").d("getLocatorForPage: Inspecting page $pageIndex (chapter=$chapterIndex). Total top-level blocks=${pageContent.content.size}")

View file

@ -46,6 +46,8 @@ import org.jsoup.Jsoup
import java.io.File
import java.net.URLDecoder
private const val DEBUG_CONTENT_STYLING = false
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class ContentStyler(
private val baseTextStyle: TextStyle,
@ -57,7 +59,8 @@ class ContentStyler(
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float
private val paragraphGapMultiplier: Float,
private val adaptThemeColors: Boolean = true
) {
fun style(semanticBlocks: List<SemanticBlock>): List<ContentBlock> {
@ -167,6 +170,7 @@ class ContentStyler(
val nonBlankSvgContent = svgContent?.takeIf { it.isNotBlank() }
val finalSvgContent = when {
block.isFromMathJax || nonBlankSvgContent == null -> svgContent
!adaptThemeColors -> embedImagesInSvg(nonBlankSvgContent)
else -> {
val themedSvg = applyThemeToSvg(nonBlankSvgContent)
embedImagesInSvg(themedSvg)
@ -223,6 +227,10 @@ class ContentStyler(
}
private fun applyThemeToStyle(style: CssStyle): CssStyle {
if (!adaptThemeColors) {
return style
}
val newSpanStyle = style.spanStyle.let { original ->
val newColor = if (original.color.isSpecified) {
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
@ -346,7 +354,9 @@ class ContentStyler(
block: SemanticTextBlock,
blockStyle: CssStyle
): AnnotatedString {
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
if (DEBUG_CONTENT_STYLING) {
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
}
val builtString = buildAnnotatedString {
val rootFontFamily = findFirstAvailableFontFamily(blockStyle.fontFamilies, fontFamilyMap)
@ -394,7 +404,9 @@ class ContentStyler(
.merge(blockStyle.spanStyle)
.copy(fontFamily = effectiveBlockFontFamily)
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
if (DEBUG_CONTENT_STYLING) {
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
}
withStyle(finalParagraphStyle) {
withStyle(initialSpanStyle) {

View file

@ -48,10 +48,20 @@ data class Locator(
class LocatorConverter(
private val bookCacheDao: BookCacheDao,
private val proto: ProtoBuf,
private val context: Context
private val context: Context,
private val stableBookId: String? = null
) {
private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("processAndCacheChapter: Processing for bookId='${book.title}' index=$chapterIndex")
private fun cacheBookId(book: EpubBook, overrideBookId: String? = null): String {
return overrideBookId ?: stableBookId ?: book.title
}
private suspend fun processAndCacheChapter(
book: EpubBook,
chapterIndex: Int,
explicitBookId: String? = null
): List<SemanticBlock>? = withContext(Dispatchers.IO) {
val cacheBookId = cacheBookId(book, explicitBookId)
Timber.tag("POS_DIAG").d("processAndCacheChapter: Processing for bookId='$cacheBookId' index=$chapterIndex")
try {
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
@ -98,7 +108,8 @@ class LocatorConverter(
baseFontSizeSp = 16f,
density = density.density,
constraints = constraints,
isDarkTheme = false
isDarkTheme = false,
adaptThemeColors = false
)
val rules = bookCssResult.rules
@ -123,16 +134,17 @@ class LocatorConverter(
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = emptyMap(),
constraints = constraints
constraints = constraints,
adaptThemeColors = false
)
val protoBytes = proto.encodeToByteArray(semanticBlocks)
val newCacheEntry = ProcessedChapter(
bookId = book.title,
bookId = cacheBookId,
chapterIndex = chapterIndex,
contentBlocksProto = protoBytes,
estimatedPageCount = 0
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
)
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
semanticBlocks
@ -141,9 +153,9 @@ class LocatorConverter(
}
}
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) {
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String, bookId: String? = null): Locator? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
@ -154,7 +166,7 @@ class LocatorConverter(
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex)
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
}
if (allBlocks.isNullOrEmpty()) {
@ -224,8 +236,8 @@ class LocatorConverter(
return bestMatch
}
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int): List<TtsChunk>? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
suspend fun getTtsChunksForChapter(book: EpubBook, chapterIndex: Int, bookId: String? = null): List<TtsChunk>? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
@ -235,7 +247,7 @@ class LocatorConverter(
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, chapterIndex)
allBlocks = processAndCacheChapter(book, chapterIndex, bookId)
}
if (allBlocks.isNullOrEmpty()) return@withContext null
@ -279,9 +291,9 @@ class LocatorConverter(
chunks
}
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) {
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator, bookId: String? = null): String? = withContext(Dispatchers.IO) {
Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
var blocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
@ -291,7 +303,7 @@ class LocatorConverter(
}
if (blocks.isNullOrEmpty()) {
blocks = processAndCacheChapter(book, locator.chapterIndex)
blocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
}
if (blocks.isNullOrEmpty()) {
@ -331,8 +343,26 @@ class LocatorConverter(
return null
}
suspend fun getTextOffset(book: EpubBook, locator: Locator): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
private fun estimateSemanticPageCount(blocks: List<SemanticBlock>): Int {
var charCount = 0
fun walk(block: SemanticBlock) {
when (block) {
is SemanticTextBlock -> charCount += block.text.length
is SemanticFlexContainer -> block.children.forEach(::walk)
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::walk) } }
is SemanticList -> block.items.forEach(::walk)
is SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk)
else -> Unit
}
}
blocks.forEach(::walk)
return ((charCount + 2_499) / 2_500).coerceAtLeast(1)
}
suspend fun getTextOffset(book: EpubBook, locator: Locator, bookId: String? = null): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = cacheBookId(book, bookId), chapterIndex = locator.chapterIndex)
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
@ -342,7 +372,7 @@ class LocatorConverter(
}
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, locator.chapterIndex)
allBlocks = processAndCacheChapter(book, locator.chapterIndex, bookId)
}
if (allBlocks.isNullOrEmpty()) return@withContext null

View file

@ -723,6 +723,7 @@ private fun WrappingContentLayout(
fun PaginatedReaderScreen(
modifier: Modifier = Modifier,
book: EpubBook,
bookId: String? = null,
isDarkTheme: Boolean,
effectiveBg: Color,
effectiveText: Color,
@ -739,6 +740,9 @@ fun PaginatedReaderScreen(
textAlign: ReaderTextAlign,
ttsHighlightInfo: TtsHighlightInfo?,
initialChapterIndexInBook: Int?,
fallbackLocatorForReconfiguration: Locator? = null,
onReconfigurationAnchorCaptured: (Locator) -> Unit = {},
onReconfigurationRestoreActiveChanged: (Boolean) -> Unit = {},
onPaginatorReady: (IPaginator) -> Unit,
onTap: (Offset?) -> Unit,
isProUser: Boolean,
@ -796,37 +800,32 @@ fun PaginatedReaderScreen(
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
val latestFallbackLocatorForReconfiguration by rememberUpdatedState(fallbackLocatorForReconfiguration)
val previousState = remember {
arrayOf<Any>(this.constraints, isDarkTheme, effectiveBg, effectiveText)
var previousConstraints by remember {
mutableStateOf(this.constraints)
}
if (previousState[0] != this.constraints ||
previousState[1] != isDarkTheme ||
previousState[2] != effectiveBg ||
previousState[3] != effectiveText
) {
if (previousConstraints != this.constraints) {
val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(currentPage)
anchorLocatorForReconfig = locator
val currentPage = pagerState.currentPage
val locator = resolvePaginatedReconfigurationAnchor(
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
fallbackLocator = fallbackLocatorForReconfiguration
)
anchorLocatorForReconfig = locator
Timber.tag("ThemeReconfig").d("""
Timber.tag("ThemeReconfig").d("""
RECONFIG DETECTED
- Reason: ${if (previousState[0] != this.constraints) "Constraints" else "Theme/Colors"}
- Reason: Constraints
- Current Page: $currentPage
- Saved Locator: $locator
""".trimIndent())
}
previousState[0] = this.constraints
previousState[1] = isDarkTheme
previousState[2] = effectiveBg
previousState[3] = effectiveText
previousConstraints = this.constraints
}
val textStyle = remember(
baseTextStyle, effectiveText,
val layoutTextStyle = remember(
baseTextStyle,
debouncedFontSizeMult,
debouncedLineHeightMult,
debouncedFontFamily
@ -835,7 +834,7 @@ fun PaginatedReaderScreen(
val adjustedLineHeight = adjustedFontSize * paginationLineHeightMultiplierForWebViewSetting(debouncedLineHeightMult)
baseTextStyle.copy(
color = effectiveText,
color = Color.Unspecified,
fontSize = adjustedFontSize,
lineHeight = adjustedLineHeight,
fontFamily = debouncedFontFamily,
@ -848,6 +847,9 @@ fun PaginatedReaderScreen(
)
)
}
val textStyle = remember(layoutTextStyle, effectiveText) {
layoutTextStyle.copy(color = effectiveText)
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
@ -875,12 +877,13 @@ fun PaginatedReaderScreen(
delay(400L)
val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(currentPage)
if (locator != null) {
anchorLocatorForReconfig = locator
}
val currentPage = pagerState.currentPage
val locator = resolvePaginatedReconfigurationAnchor(
currentPageLocator = (activePaginator as? BookPaginator)?.getLocatorForPage(currentPage),
fallbackLocator = fallbackLocatorForReconfiguration
)
if (locator != null) {
anchorLocatorForReconfig = locator
}
debouncedFontSizeMult = fontSizeMultiplier
@ -955,7 +958,15 @@ fun PaginatedReaderScreen(
remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0
}
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign, effectiveBg, effectiveText, debouncedParagraphGapMult) {
LaunchedEffect(anchorLocatorForReconfig) {
anchorLocatorForReconfig?.let { locator ->
onReconfigurationAnchorCaptured(locator)
onReconfigurationRestoreActiveChanged(true)
}
}
val paginator = remember(book, bookId, textConstraints, layoutTextStyle, userTextAlign, debouncedParagraphGapMult, debouncedImageSizeMult, debouncedVerticalMarginMult) {
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
@ -963,12 +974,11 @@ fun PaginatedReaderScreen(
val uaResult = CssParser.parse(
cssContent = userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces)
@ -977,12 +987,11 @@ fun PaginatedReaderScreen(
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces)
@ -990,12 +999,11 @@ fun PaginatedReaderScreen(
val fontFamilyMap = loadFontFamilies(
fontFaces = allFontFaces, extractionPath = book.extractionBasePath
)
book.title
val bookCacheDao =
BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val proto = ProtoBuf { serializersModule = semanticBlockModule }
val uniqueBookId = if (book.fileName.length > 20) book.fileName else book.title
val uniqueBookId = bookId ?: 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")
@ -1005,7 +1013,7 @@ fun PaginatedReaderScreen(
chapters = book.chaptersForPagination,
textMeasurer = textMeasurer,
constraints = textConstraints,
textStyle = textStyle,
textStyle = layoutTextStyle,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
@ -1037,25 +1045,32 @@ fun PaginatedReaderScreen(
if (anchorLocatorForReconfig != null) {
Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig")
snapshotFlow { paginator.isLoading }.filter { !it }.first()
try {
onReconfigurationRestoreActiveChanged(true)
snapshotFlow { paginator.isLoading }.filter { !it }.first()
val targetLocator = anchorLocatorForReconfig
if (targetLocator != null) {
val page = paginator.findPageForLocator(targetLocator)
val targetLocator = anchorLocatorForReconfig
if (targetLocator != null) {
val page = paginator.findPageForLocator(targetLocator)
Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page")
Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page")
if (page != null) {
pagerState.scrollToPage(page)
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
} else {
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) {
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
pagerState.scrollToPage(startPage)
if (page != null) {
pagerState.scrollToPage(page)
paginator.onUserScrolledTo(page)
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
} else {
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) {
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
pagerState.scrollToPage(startPage)
paginator.onUserScrolledTo(startPage)
}
}
anchorLocatorForReconfig = null
}
anchorLocatorForReconfig = null
} finally {
onReconfigurationRestoreActiveChanged(false)
}
}
}
@ -1083,13 +1098,31 @@ fun PaginatedReaderScreen(
LaunchedEffect(pagerState, paginator) {
snapshotFlow { pagerState.currentPage }.debounce(500)
.collectLatest { page -> paginator.onUserScrolledTo(page) }
.collectLatest { page ->
if (anchorLocatorForReconfig == null) {
paginator.onUserScrolledTo(page)
}
}
}
LaunchedEffect(paginator, pagerState) {
paginator.pageShiftRequest.collect { shiftAmount ->
val newPage = pagerState.currentPage + shiftAmount
pagerState.scrollToPage(newPage)
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = anchorLocatorForReconfig,
fallbackLocator = latestFallbackLocatorForReconfiguration
)
val resolvedPage = anchor?.let { locator ->
(paginator as? BookPaginator)?.findPageForLocator(locator)
}
if (resolvedPage != null) {
pagerState.scrollToPage(resolvedPage)
paginator.onUserScrolledTo(resolvedPage)
} else {
val newPage = pagerState.currentPage + shiftAmount
pagerState.scrollToPage(newPage)
paginator.onUserScrolledTo(newPage)
}
}
}
@ -2218,6 +2251,13 @@ internal fun PaginatedReaderContent(
var pageContent by remember { mutableStateOf<Page?>(null) }
var currentChapterPath by remember { mutableStateOf<String?>(null) }
val themedPageContent = remember(pageContent, isDarkTheme, effectiveBg, effectiveText) {
pageContent?.applyReaderThemeForDisplay(
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
)
}
LaunchedEffect(pageIndex, uiState.generation) {
val fetchStartTime = System.currentTimeMillis()
@ -2236,7 +2276,7 @@ internal fun PaginatedReaderContent(
}
val textBlocksOnPage =
pageContent?.content?.extractTextBlocks()
themedPageContent?.content?.extractTextBlocks()
?.filter { it.cfi != null } ?: emptyList()
val lastTextBlock = textBlocksOnPage.lastOrNull()
val lastBlockAbs = lastTextBlock?.let {
@ -2379,7 +2419,8 @@ internal fun PaginatedReaderContent(
horizontal = horizontalPadding,
vertical = verticalPadding
), contentAlignment = Alignment.TopStart) {
if (pageContent != null) {
if (themedPageContent != null) {
val displayPage = themedPageContent
val onGeneralTapCallback: (Offset) -> Unit = { offset ->
activeSelection = null
onTap(offset)
@ -2405,7 +2446,7 @@ internal fun PaginatedReaderContent(
val ttsHighlightColor =
MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)
pageContent!!.content.forEach { block ->
displayPage.content.forEach { block ->
val marginModifier = Modifier.padding(
top = block.style.margin.top.coerceAtLeast(0.dp),
bottom = block.style.margin.bottom.coerceAtLeast(

View file

@ -24,6 +24,7 @@ import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
@ -77,7 +78,8 @@ class PaginatedReaderViewModel : ViewModel() {
context: Context,
initialChapterToPaginate: Int?,
mathMLRenderer: MathMLRenderer,
paragraphGapMultiplier: Float
paragraphGapMultiplier: Float,
bookId: String? = null
) {
if (paginator != null) return
@ -88,16 +90,16 @@ class PaginatedReaderViewModel : ViewModel() {
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
val layoutTextStyle = textStyle.copy(color = Color.Unspecified)
val uaResult = CssParser.parse(
cssContent = userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces)
@ -106,12 +108,11 @@ class PaginatedReaderViewModel : ViewModel() {
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
baseFontSizeSp = layoutTextStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
isDarkTheme = false,
adaptThemeColors = false
)
allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces)
@ -120,21 +121,21 @@ class PaginatedReaderViewModel : ViewModel() {
fontFaces = allFontFaces,
extractionPath = book.extractionBasePath
)
val bookId = book.title
val cacheBookId = bookId ?: if (book.fileName.length > 20) book.fileName else book.title
val bookCacheDao = BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val newPaginator = BookPaginator(
coroutineScope = viewModelScope,
chapters = book.chaptersForPagination,
textMeasurer = textMeasurer,
constraints = textConstraints,
textStyle = textStyle,
textStyle = layoutTextStyle,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
bookId = bookId,
bookId = cacheBookId,
bookCacheDao = bookCacheDao,
proto = proto,
initialChapterToPaginate = initialChapterToPaginate ?: 0,

View file

@ -0,0 +1,6 @@
package com.aryan.reader.paginatedreader
internal fun resolvePaginatedReconfigurationAnchor(
currentPageLocator: Locator?,
fallbackLocator: Locator?
): Locator? = currentPageLocator ?: fallbackLocator

View file

@ -35,8 +35,11 @@ import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.roundToInt
private const val DEBUG_PAGINATION_LOGS = false
interface BlockMeasurementProvider {
suspend fun measure(block: ContentBlock): Int
suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>?
@ -53,9 +56,13 @@ class SuspendingAndroidBlockMeasurementProvider(
private val density: Density,
private val imageSizeMultiplier: Float
) : BlockMeasurementProvider {
private val measurementCache = ConcurrentHashMap<Int, Int>()
override suspend fun measure(block: ContentBlock): Int {
return measureBlockHeight(
val cacheKey = blockMeasurementCacheKey(block)
measurementCache[cacheKey]?.let { return it }
val measured = measureBlockHeight(
block = block,
textMeasurer = textMeasurer,
constraints = constraints,
@ -64,6 +71,17 @@ class SuspendingAndroidBlockMeasurementProvider(
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
measurementCache[cacheKey] = measured
return measured
}
private fun blockMeasurementCacheKey(block: ContentBlock): Int {
var result = block.hashCode()
result = 31 * result + constraints.maxWidth
result = 31 * result + constraints.maxHeight
result = 31 * result + textStyle.hashCode()
result = 31 * result + imageSizeMultiplier.hashCode()
return result
}
override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>? {
@ -280,7 +298,9 @@ class SuspendingAndroidBlockMeasurementProvider(
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
}.roundToInt()
Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").d("SplitTable: avail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom")
}
currentHeight += decorationTop
for (i in block.rows.indices) {
@ -305,7 +325,9 @@ class SuspendingAndroidBlockMeasurementProvider(
}
if (currentHeight + maxRowHeight + decorationBottom > availableHeight) {
Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").d("SplitTable: Breaking at row $i. currentH=$currentHeight, rowH=$maxRowHeight")
}
splitRowIndex = i
break
}
@ -410,7 +432,9 @@ suspend fun paginate(
if (blocks.isEmpty()) {
return emptyList()
}
Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Starting pagination for ${blocks.size} blocks with page height $pageHeight.")
}
val pages = mutableListOf<Page>()
var currentPageContent = mutableListOf<ContentBlock>()
@ -437,8 +461,10 @@ suspend fun paginate(
val spaceRequired = blockHeightWithSafetyMargin + spaceBetweenBlocks
Timber.tag("PAGINATION_DEBUG")
.d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG")
.d("Processing ${block::class.simpleName}: req=$spaceRequired, remaining=$remainingHeight, margin=$spaceBetweenBlocks, heightOnly=$blockHeight")
}
if (spaceRequired <= remainingHeight) {
var blockToAdd = block
@ -608,23 +634,31 @@ suspend fun paginate(
}
else -> {
Timber.d("Page ${pageIndex + 1}: Block type is not splittable.")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Page ${pageIndex + 1}: Block type is not splittable.")
}
}
}
} else {
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Page ${pageIndex + 1}: Not enough height for splitting ($heightForSplitting <= 50).")
}
}
if (!wasSplit) {
if (currentPageContent.isEmpty()) {
Timber.tag("PAGINATION_DEBUG")
.w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG")
.w("FORCING block ${block::class.simpleName} onto page because it is the first block, even though req($spaceRequired) > remaining($remainingHeight)")
}
val forcedHeight = blockHeight + spaceBetweenBlocks
val blockToAdd = setBlockExpectedHeight(block, forcedHeight)
currentPageContent.add(blockToAdd)
} else {
Timber.tag("PAGINATION_DEBUG")
.d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG")
.d("Block ${block::class.simpleName} did not fit and was not split. Moving to next page.")
}
remainingBlocks.add(0, block)
}
}
@ -643,7 +677,9 @@ suspend fun paginate(
pages.add(Page(content = currentPageContent.toList()))
}
Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.")
if (DEBUG_PAGINATION_LOGS) {
Timber.i("Pagination complete. Produced ${pages.size} pages from ${blocks.size} initial blocks.")
}
return pages
}
@ -906,7 +942,9 @@ private suspend fun measureBlockHeight(
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
}
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").v("Measure result for ${block::class.simpleName}: content=$contentHeight, paddingV=$verticalPaddingPx, borderV=$verticalBorderPx, total=$finalHeight")
}
return finalHeight
}
@ -935,10 +973,14 @@ private suspend fun splitParagraphBlock(
val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
}
if (availableTextHeight <= 0) {
Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: availableTextHeight <= 0")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").w("SplitPara aborted: availableTextHeight <= 0")
}
return null
}
@ -969,7 +1011,9 @@ private suspend fun splitParagraphBlock(
}
if (lastVisibleLine == 0) {
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Orphan control: Preventing split that would leave one line at the bottom of the page.")
}
return null
}
@ -985,7 +1029,9 @@ private suspend fun splitParagraphBlock(
)
}
if (part2Layout.lineCount == 1) {
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Widow control: Adjusting split to prevent a single line at the top of the next page.")
}
lastVisibleLine--
splitOffset = layoutResult.getLineEnd(lastVisibleLine, visibleEnd = true)
}
@ -1046,7 +1092,9 @@ private suspend fun splitParagraphBlock(
endCharOffsetInSource = block.endCharOffsetInSource
)
Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}")
if (DEBUG_PAGINATION_LOGS) {
Timber.d("Split block at offset $splitOffset. Part 1 len: ${part1.content.length}, Part 2 len: ${part2.content.length}")
}
return part1 to part2
}
@ -1090,7 +1138,9 @@ private suspend fun calculateContentHeightWithMargins(
}
}.roundToInt()
totalHeight += (childHeight + margin)
Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight")
if (DEBUG_PAGINATION_LOGS) {
Timber.tag("PAGINATION_DEBUG").v(" Internal Child ${child::class.simpleName}: h=$childHeight, margin=$margin, runningTotal=$totalHeight")
}
}
if (children.isNotEmpty()) {
totalHeight += with(density) { children.last().style.margin.bottom.toPx().roundToInt() }

View file

@ -0,0 +1,265 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import org.jsoup.Jsoup
internal fun Page.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): Page {
return copy(
content = content.map {
it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
}
)
}
private fun ContentBlock.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): ContentBlock {
val themedStyle = style.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
return when (this) {
is ParagraphBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is HeaderBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is QuoteBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is ListItemBlock -> copy(
content = content.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
style = themedStyle
)
is ImageBlock -> copy(style = themedStyle)
is SpacerBlock -> copy(style = themedStyle)
is MathBlock -> copy(
svgContent = if (isFromMathJax) {
svgContent
} else {
svgContent?.applyReaderThemeToSvgText(themeTextColor)
},
style = themedStyle
)
is WrappingContentBlock -> copy(
floatedImage = floatedImage.applyReaderThemeForDisplay(
isDarkTheme,
themeBackgroundColor,
themeTextColor
) as ImageBlock,
paragraphsToWrap = paragraphsToWrap.map {
it.applyReaderThemeForDisplay(
isDarkTheme,
themeBackgroundColor,
themeTextColor
) as ParagraphBlock
},
style = themedStyle
)
is TableBlock -> copy(
rows = rows.map { row ->
row.map { cell ->
cell.copy(
content = cell.content.map {
it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
},
style = cell.style.applyReaderThemeForDisplay(
isDarkTheme,
themeBackgroundColor,
themeTextColor
)
)
}
},
style = themedStyle
)
is FlexContainerBlock -> copy(
children = children.map {
it.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
},
style = themedStyle
)
}
}
private fun AnnotatedString.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): AnnotatedString {
return buildAnnotatedString {
append(this@applyReaderThemeForDisplay.text)
this@applyReaderThemeForDisplay.spanStyles.forEach { range ->
addStyle(
range.item.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
range.start,
range.end
)
}
this@applyReaderThemeForDisplay.paragraphStyles.forEach { range ->
addStyle(range.item, range.start, range.end)
}
this@applyReaderThemeForDisplay.getStringAnnotations(0, this@applyReaderThemeForDisplay.length).forEach { range ->
val item = if (range.tag == "CustomUnderline") {
range.item.applyReaderThemeToUnderlineAnnotation(isDarkTheme, themeBackgroundColor, themeTextColor)
} else {
range.item
}
addStringAnnotation(range.tag, item, range.start, range.end)
}
}
}
private fun CssStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): CssStyle {
val emphasis = textEmphasis
return copy(
spanStyle = spanStyle.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
blockStyle = blockStyle.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
textDecorationColor = textDecorationColor.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
textEmphasis = emphasis?.copy(
color = emphasis.color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
)
)
}
private fun SpanStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): SpanStyle {
return copy(
color = color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
background = background.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = true,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
)
}
private fun BlockStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): BlockStyle {
return copy(
backgroundColor = backgroundColor.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = true,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
),
borderTop = borderTop?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
borderRight = borderRight?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
borderBottom = borderBottom?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor),
borderLeft = borderLeft?.applyReaderThemeForDisplay(isDarkTheme, themeBackgroundColor, themeTextColor)
)
}
private fun BorderStyle.applyReaderThemeForDisplay(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): BorderStyle {
return copy(
color = color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
)
}
private fun Color.applyReaderThemeColor(
isDarkTheme: Boolean,
isBackground: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): Color {
if (!isSpecified) return this
return CssParser.adaptColorForTheme(
color = this,
isDarkTheme = isDarkTheme,
isBackground = isBackground,
themeBackground = themeBackgroundColor,
themeText = themeTextColor
)
}
private fun String.applyReaderThemeToUnderlineAnnotation(
isDarkTheme: Boolean,
themeBackgroundColor: Color,
themeTextColor: Color
): String {
val parts = split('|').toMutableList()
val colorPart = parts.getOrNull(1) ?: return this
if (colorPart == "Unspecified") return this
val color = colorPart.toULongOrNull()?.let { Color(it) } ?: return this
parts[1] = color.applyReaderThemeColor(
isDarkTheme = isDarkTheme,
isBackground = false,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
).value.toString()
return parts.joinToString("|")
}
private fun String.applyReaderThemeToSvgText(themeTextColor: Color): String {
if (!themeTextColor.isSpecified || isBlank()) return this
return try {
val textColorHex = themeTextColor.toCssHexString()
val svgDocument = Jsoup.parseBodyFragment(this)
val svgElement = svgDocument.body().children().firstOrNull() ?: return this
svgElement.select("text").forEach { textElement ->
val existingStyle = textElement.attr("style")
val styleWithoutFill = existingStyle.replace(Regex("""\bfill\s*:\s*[^;]+;?"""), "")
val newStyle = "fill:$textColorHex; $styleWithoutFill".trim()
textElement.attr("style", newStyle)
textElement.removeAttr("fill")
}
svgElement.outerHtml()
} catch (_: Exception) {
this
}
}
private fun Color.toCssHexString(): String {
val red = (this.red * 255).toInt()
val green = (this.green * 255).toInt()
val blue = (this.blue * 255).toInt()
return "#%02X%02X%02X".format(red, green, blue)
}

View file

@ -28,6 +28,8 @@ import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Transaction
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Dao
abstract class BookCacheDao {
@ -151,12 +153,19 @@ abstract class BookCacheDao {
@Query("DELETE FROM configuration_cache WHERE bookId = :bookId")
abstract suspend fun deleteConfigurationCacheForBook(bookId: String)
@Query("DELETE FROM page_cache_metadata WHERE book_id = :bookId")
protected abstract suspend fun deletePageCacheMetadataForBook(bookId: String)
@Query("DELETE FROM page_cache_metadata WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex")
protected abstract suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int)
@Transaction
open suspend fun deleteEntireBookCache(bookId: String) {
deleteBook(bookId)
deleteChaptersForBook(bookId)
deleteAnchorsForBook(bookId)
deleteConfigurationCacheForBook(bookId)
deletePageCacheMetadataForBook(bookId)
}
@Query("DELETE FROM anchor_index")
@ -165,12 +174,16 @@ abstract class BookCacheDao {
@Query("DELETE FROM configuration_cache")
abstract suspend fun clearConfigurationCache()
@Query("DELETE FROM page_cache_metadata")
protected abstract suspend fun clearPageCacheMetadata()
@Transaction
open suspend fun clearAllCache() {
clearProcessedBooks()
clearProcessedChapters()
clearAnchors()
clearConfigurationCache()
clearPageCacheMetadata()
}
@Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash")
@ -188,6 +201,101 @@ abstract class BookCacheDao {
)
""")
abstract suspend fun cleanupOldConfigurations(bookId: String)
@Query("SELECT * FROM page_cache_metadata WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex")
protected abstract suspend fun getPageCacheMetadata(bookId: String, configHash: Int, chapterIndex: Int): PageCacheMetadata?
@Query("SELECT chunk_data FROM page_cache_chunks WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex ORDER BY chunk_index ASC")
protected abstract suspend fun getPageCacheChunks(bookId: String, configHash: Int, chapterIndex: Int): List<ByteArray>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertPageCacheMetadata(metadata: PageCacheMetadata)
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertPageCacheChunks(chunks: List<PageCacheChunk>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertPageIndexEntries(entries: List<PageIndexEntry>)
@Query("SELECT * FROM page_index_entries WHERE book_id = :bookId AND config_hash = :configHash AND chapter_index = :chapterIndex ORDER BY page_in_chapter ASC")
abstract suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List<PageIndexEntry>
@Transaction
open suspend fun getPageCache(bookId: String, configHash: Int, chapterIndex: Int): PageCacheEntry? {
val metadata = getPageCacheMetadata(bookId, configHash, chapterIndex) ?: return null
val chunks = getPageCacheChunks(bookId, configHash, chapterIndex)
if (chunks.isEmpty()) return null
val totalSize = chunks.sumOf { it.size }
val mergedData = ByteArray(totalSize)
var offset = 0
for (chunk in chunks) {
System.arraycopy(chunk, 0, mergedData, offset, chunk.size)
offset += chunk.size
}
return PageCacheEntry(
bookId = metadata.bookId,
configHash = metadata.configHash,
chapterIndex = metadata.chapterIndex,
processingVersion = metadata.processingVersion,
pageCacheVersion = metadata.pageCacheVersion,
contentVersion = metadata.contentVersion,
pageCount = metadata.pageCount,
pagesProto = mergedData
)
}
@Transaction
open suspend fun insertPageCache(entry: PageCacheEntry, pageIndexEntries: List<PageIndexEntry>) {
@Suppress("LocalVariableName") val CHUNK_SIZE = 900 * 1024
deletePageCacheMetadataForChapter(entry.bookId, entry.configHash, entry.chapterIndex)
insertPageCacheMetadata(
PageCacheMetadata(
bookId = entry.bookId,
configHash = entry.configHash,
chapterIndex = entry.chapterIndex,
processingVersion = entry.processingVersion,
pageCacheVersion = entry.pageCacheVersion,
contentVersion = entry.contentVersion,
pageCount = entry.pageCount
)
)
val chunks = ArrayList<PageCacheChunk>()
var offset = 0
var chunkIndex = 0
while (offset < entry.pagesProto.size) {
val end = (offset + CHUNK_SIZE).coerceAtMost(entry.pagesProto.size)
chunks.add(
PageCacheChunk(
bookId = entry.bookId,
configHash = entry.configHash,
chapterIndex = entry.chapterIndex,
chunkIndex = chunkIndex,
chunkData = entry.pagesProto.copyOfRange(offset, end)
)
)
offset = end
chunkIndex++
}
insertPageCacheChunks(chunks)
if (pageIndexEntries.isNotEmpty()) {
insertPageIndexEntries(pageIndexEntries)
}
}
@Query("""
DELETE FROM page_cache_metadata
WHERE book_id = :bookId AND config_hash NOT IN (
SELECT configHash FROM configuration_cache
WHERE bookId = :bookId
ORDER BY rowid DESC LIMIT 3
)
""")
abstract suspend fun cleanupOldPageCaches(bookId: String)
}
@Database(
@ -196,9 +304,12 @@ abstract class BookCacheDao {
ProcessedChapterMetadata::class,
ProcessedChapterChunk::class,
ConfigurationCache::class,
AnchorIndexEntry::class
AnchorIndexEntry::class,
PageCacheMetadata::class,
PageCacheChunk::class,
PageIndexEntry::class
],
version = 10,
version = 11,
exportSchema = false
)
abstract class BookCacheDatabase : RoomDatabase() {
@ -215,11 +326,73 @@ abstract class BookCacheDatabase : RoomDatabase() {
BookCacheDatabase::class.java,
"book_cache_database"
)
.addMigrations(MIGRATION_10_11)
.fallbackToDestructiveMigration(true)
.build()
INSTANCE = instance
instance
}
}
private val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `page_cache_metadata` (
`book_id` TEXT NOT NULL,
`config_hash` INTEGER NOT NULL,
`chapter_index` INTEGER NOT NULL,
`processing_version` INTEGER NOT NULL,
`page_cache_version` INTEGER NOT NULL,
`content_version` INTEGER NOT NULL,
`page_count` INTEGER NOT NULL,
PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`)
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `page_cache_chunks` (
`book_id` TEXT NOT NULL,
`config_hash` INTEGER NOT NULL,
`chapter_index` INTEGER NOT NULL,
`chunk_index` INTEGER NOT NULL,
`chunk_data` BLOB NOT NULL,
PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`, `chunk_index`),
FOREIGN KEY(`book_id`, `config_hash`, `chapter_index`)
REFERENCES `page_cache_metadata`(`book_id`, `config_hash`, `chapter_index`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_page_cache_chunks_book_id_config_hash_chapter_index` ON `page_cache_chunks` (`book_id`, `config_hash`, `chapter_index`)"
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `page_index_entries` (
`book_id` TEXT NOT NULL,
`config_hash` INTEGER NOT NULL,
`chapter_index` INTEGER NOT NULL,
`page_in_chapter` INTEGER NOT NULL,
`first_block_index` INTEGER NOT NULL,
`last_block_index` INTEGER NOT NULL,
`first_text_block_index` INTEGER,
`first_text_char_offset` INTEGER NOT NULL,
`first_text_end_offset` INTEGER NOT NULL,
`first_cfi` TEXT,
`anchors` TEXT NOT NULL,
PRIMARY KEY(`book_id`, `config_hash`, `chapter_index`, `page_in_chapter`),
FOREIGN KEY(`book_id`, `config_hash`, `chapter_index`)
REFERENCES `page_cache_metadata`(`book_id`, `config_hash`, `chapter_index`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_page_index_entries_book_id_config_hash_chapter_index` ON `page_index_entries` (`book_id`, `config_hash`, `chapter_index`)"
)
}
}
}
}

View file

@ -25,7 +25,8 @@ import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 10
const val LATEST_PROCESSING_VERSION = 11
const val LATEST_PAGE_CACHE_VERSION = 3
@Entity(tableName = "processed_books")
data class ProcessedBook(
@ -131,3 +132,121 @@ data class ConfigurationCache(
val configHash: Int,
val chapterPageCounts: String
)
data class PageCacheEntry(
val bookId: String,
val configHash: Int,
val chapterIndex: Int,
val processingVersion: Int,
val pageCacheVersion: Int,
val contentVersion: Int,
val pageCount: Int,
val pagesProto: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PageCacheEntry
if (bookId != other.bookId) return false
if (configHash != other.configHash) return false
if (chapterIndex != other.chapterIndex) return false
if (processingVersion != other.processingVersion) return false
if (pageCacheVersion != other.pageCacheVersion) return false
if (contentVersion != other.contentVersion) return false
if (pageCount != other.pageCount) return false
if (!pagesProto.contentEquals(other.pagesProto)) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + configHash
result = 31 * result + chapterIndex
result = 31 * result + processingVersion
result = 31 * result + pageCacheVersion
result = 31 * result + contentVersion
result = 31 * result + pageCount
result = 31 * result + pagesProto.contentHashCode()
return result
}
}
@Entity(tableName = "page_cache_metadata", primaryKeys = ["book_id", "config_hash", "chapter_index"])
data class PageCacheMetadata(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "config_hash") val configHash: Int,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "processing_version") val processingVersion: Int,
@ColumnInfo(name = "page_cache_version") val pageCacheVersion: Int,
@ColumnInfo(name = "content_version") val contentVersion: Int,
@ColumnInfo(name = "page_count") val pageCount: Int
)
@Entity(
tableName = "page_cache_chunks",
primaryKeys = ["book_id", "config_hash", "chapter_index", "chunk_index"],
foreignKeys = [
ForeignKey(
entity = PageCacheMetadata::class,
parentColumns = ["book_id", "config_hash", "chapter_index"],
childColumns = ["book_id", "config_hash", "chapter_index"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["book_id", "config_hash", "chapter_index"])]
)
data class PageCacheChunk(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "config_hash") val configHash: Int,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "chunk_index") val chunkIndex: Int,
@ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PageCacheChunk
if (bookId != other.bookId) return false
if (configHash != other.configHash) return false
if (chapterIndex != other.chapterIndex) return false
if (chunkIndex != other.chunkIndex) return false
if (!chunkData.contentEquals(other.chunkData)) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + configHash
result = 31 * result + chapterIndex
result = 31 * result + chunkIndex
result = 31 * result + chunkData.contentHashCode()
return result
}
}
@Entity(
tableName = "page_index_entries",
primaryKeys = ["book_id", "config_hash", "chapter_index", "page_in_chapter"],
foreignKeys = [
ForeignKey(
entity = PageCacheMetadata::class,
parentColumns = ["book_id", "config_hash", "chapter_index"],
childColumns = ["book_id", "config_hash", "chapter_index"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["book_id", "config_hash", "chapter_index"])]
)
data class PageIndexEntry(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "config_hash") val configHash: Int,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "page_in_chapter") val pageInChapter: Int,
@ColumnInfo(name = "first_block_index") val firstBlockIndex: Int,
@ColumnInfo(name = "last_block_index") val lastBlockIndex: Int,
@ColumnInfo(name = "first_text_block_index") val firstTextBlockIndex: Int?,
@ColumnInfo(name = "first_text_char_offset") val firstTextCharOffset: Int,
@ColumnInfo(name = "first_text_end_offset") val firstTextEndOffset: Int,
@ColumnInfo(name = "first_cfi") val firstCfi: String?,
@ColumnInfo(name = "anchors") val anchors: String
)

View file

@ -63,7 +63,8 @@ import kotlin.math.abs
data class SerializableEpubChapter(
@ProtoNumber(1) val htmlContent: String,
@ProtoNumber(2) val title: String,
@ProtoNumber(3) val absPath: String
@ProtoNumber(3) val absPath: String,
@ProtoNumber(4) val htmlFilePath: String = absPath
)
@OptIn(ExperimentalSerializationApi::class)
@ -208,7 +209,8 @@ class BookProcessingWorker(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
isDarkTheme = false,
adaptThemeColors = false
)
lightThemeCssRules = lightThemeCssRules.merge(uaResult.rules)
@ -219,7 +221,8 @@ class BookProcessingWorker(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
isDarkTheme = false,
adaptThemeColors = false
)
lightThemeCssRules = lightThemeCssRules.merge(bookCssResult.rules)
}
@ -242,7 +245,20 @@ class BookProcessingWorker(
Timber.d("Async task started for chapter index $index.")
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
val document = Jsoup.parse(chapter.htmlContent, chapter.absPath)
val htmlToParse = chapter.htmlContent.ifBlank {
val backingFile = File(extractionBasePath, chapter.htmlFilePath)
if (backingFile.exists()) {
backingFile.readText()
} else {
""
}
}
if (htmlToParse.isBlank()) {
Timber.w("[BG_PROC] Skipping chapter $index because no HTML content was available.")
return@async null
}
val document = Jsoup.parse(htmlToParse, chapter.absPath)
val mathElements = document.select("math")
val svgResults = mutableMapOf<String, String>()
@ -294,7 +310,7 @@ class BookProcessingWorker(
bookId = bookId,
chapterIndex = index,
contentBlocksProto = protoBytes,
estimatedPageCount = 0
estimatedPageCount = estimateSemanticPageCount(semanticBlocks)
)
} else {
Timber.d("Chapter $index was already in the database. Skipping.")
@ -371,4 +387,28 @@ class BookProcessingWorker(
blocks.forEach { walk(it) }
return anchors
}
private fun estimateSemanticPageCount(
blocks: List<com.aryan.reader.paginatedreader.SemanticBlock>
): Int {
var charCount = 0
fun walk(block: com.aryan.reader.paginatedreader.SemanticBlock) {
when (block) {
is com.aryan.reader.paginatedreader.SemanticTextBlock -> {
charCount += block.text.length
}
is com.aryan.reader.paginatedreader.SemanticFlexContainer -> block.children.forEach(::walk)
is com.aryan.reader.paginatedreader.SemanticTable -> {
block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::walk) } }
}
is com.aryan.reader.paginatedreader.SemanticList -> block.items.forEach(::walk)
is com.aryan.reader.paginatedreader.SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::walk)
else -> Unit
}
}
blocks.forEach(::walk)
return ((charCount + 2_499) / 2_500).coerceAtLeast(1)
}
}

View file

@ -32,6 +32,38 @@ object NativePdfiumBridge {
@JvmStatic external fun getAnnotSubtypeAtPoint(pagePtr: Long, x: Double, y: Double): Int
@JvmStatic external fun getAnnotRectAtPoint(pagePtr: Long, x: Double, y: Double): FloatArray?
@JvmStatic external fun checkActionSupport(): Boolean
@JvmStatic external fun exportAnnotatedPdf(
sourcePath: String,
destPath: String,
inkPageIndices: IntArray,
inkTypes: IntArray,
inkColors: IntArray,
inkStrokeWidths: FloatArray,
inkPointOffsets: IntArray,
inkPointCounts: IntArray,
inkPoints: FloatArray,
textPageIndices: IntArray,
textBounds: FloatArray,
textColors: IntArray,
textBackgroundColors: IntArray,
textFontSizes: FloatArray,
textFlags: IntArray,
textValues: Array<String>,
textFontPaths: Array<String>,
textFontNames: Array<String>,
rasterPageIndices: IntArray,
rasterBounds: FloatArray,
rasterWidths: IntArray,
rasterHeights: IntArray,
rasterPixelOffsets: IntArray,
rasterPixels: IntArray,
highlightPageIndices: IntArray,
highlightColors: IntArray,
highlightRectOffsets: IntArray,
highlightRectCounts: IntArray,
highlightRects: FloatArray,
highlightContents: Array<String>
): Boolean
const val ANNOT_TEXT = PdfiumAnnotationSubtype.TEXT
const val ANNOT_LINK = PdfiumAnnotationSubtype.LINK

File diff suppressed because it is too large Load diff

View file

@ -33,4 +33,4 @@ internal enum class DisplayMode {
internal fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
}

View file

@ -549,6 +549,7 @@ internal fun PdfPageComposable(
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
eraserToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
@ -4270,6 +4271,7 @@ internal fun PdfPageComposable(
eraserPosition = eraserPosition,
isStylusEraserOverride = isStylusEraserOverride,
activeToolThickness = activeToolThickness,
eraserToolThickness = eraserToolThickness,
richTextController = richTextController,
textBoxes = textBoxes,
selectedTextBoxId = selectedTextBoxId,
@ -5106,6 +5108,7 @@ private fun PdfPageRenderer(
onHighlightDelete: (String) -> Unit,
onTts: (Int, Int) -> Unit,
activeToolThickness: Float,
eraserToolThickness: Float,
onNote: (String?) -> Unit,
isBubbleZoomModeActive: Boolean = false,
isActivePage: Boolean = true,
@ -5173,7 +5176,7 @@ private fun PdfPageRenderer(
val isEditable = isEditMode && selectedTool == InkType.TEXT
val hasContent = richTextController.pageLayouts.any {
it.pageIndex == selectionData.pageIndex
}
} || richTextController.hasRenderableText
if (isEditable || hasContent) {
PdfRichTextLayer(
@ -5323,8 +5326,13 @@ private fun PdfPageRenderer(
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
val eraserStrokeWidth = resolveEraserStrokeWidth(
isStylusEraserOverride,
activeToolThickness,
eraserToolThickness
)
val radiusPx = if (eraserStrokeWidth > 0f && staticData.targetWidth > 0) {
eraserStrokeWidth * staticData.targetWidth * scale
} else {
8.dp.toPx()
}
@ -5798,7 +5806,7 @@ fun PdfRichTextLayer(
val textToRender = if (controller.activePageIndex == pageIndex) {
controller.localTextFieldValue.annotatedString
} else {
pageLayout?.visibleText
pageLayout?.visibleText?.withoutTrailingPdfPageBreakForRender()
}
if (textToRender != null) {
@ -5871,6 +5879,14 @@ fun PdfRichTextLayer(
}
}
private fun AnnotatedString.withoutTrailingPdfPageBreakForRender(): AnnotatedString {
return if (text.lastOrNull() == PAGE_BREAK_CHAR) {
subSequence(0, length - 1)
} else {
this
}
}
private fun getNativePointer(obj: Any): Long {
val priorityFields = listOf("pagePtr", "mNativePage", "page")

View file

@ -39,10 +39,10 @@ private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private const val PDF_THEME_KEY = "pdf_reader_theme"
private const val PDF_KEEP_SCREEN_ON_KEY = "pdf_keep_screen_on_enabled"
private const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
private const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
private const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
private const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_HIDDEN_TOOLS_KEY = "pdf_hidden_tools"
internal const val PDF_TOOL_ORDER_KEY = "pdf_tool_order"
internal const val PDF_BOTTOM_TOOLS_KEY = "pdf_bottom_tools"
internal const val PDF_SYSTEM_UI_MODE_KEY = "pdf_system_ui_mode"
internal const val PDF_LAYOUT_DEBUG_TAG = "PdfLayoutDebug"
enum class PdfReaderTool(val title: String, val category: String) {
@ -64,6 +64,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu"),
BOOKMARK("Bookmark", "Overflow Menu"),
PAGE_MANAGEMENT("Page Management", "Overflow Menu"),
REFLOW("Text View (Reflow)", "Overflow Menu"),

View file

@ -117,6 +117,7 @@ internal fun PdfTopBar(
onToggleKeepScreenOn: () -> Unit,
onStartAutoScroll: () -> Unit,
onShowTtsSettings: () -> Unit,
onShowTtsReplacements: () -> Unit,
onToggleBookmark: () -> Unit,
onInsertPage: () -> Unit,
onDeletePage: () -> Unit,
@ -425,6 +426,15 @@ internal fun PdfTopBar(
onClick = { showMoreMenu = false; onShowTtsSettings() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.TTS_REPLACEMENTS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = { showMoreMenu = false; onShowTtsReplacements() },
leadingIcon = { Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp)) }
)
}
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {

View file

@ -251,6 +251,7 @@ internal fun PdfVerticalReader(
onNoteRequested: (String?) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
activeToolThickness: Float = 0f,
eraserToolThickness: Float = 0f,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: () -> Unit = {},
lockedState: Triple<Float, Float, Float>? = null,
@ -1744,6 +1745,7 @@ internal fun PdfVerticalReader(
onNoteRequested = onNoteRequested,
onTts = onTts,
activeToolThickness = activeToolThickness,
eraserToolThickness = eraserToolThickness,
customHighlightColors = customHighlightColors,
onPaletteClick = onPaletteClick,
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
@ -2151,8 +2153,13 @@ internal fun PdfVerticalReader(
if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) {
val pos = globalEraserPosition!!
val radiusPx = if (activeToolThickness > 0f) {
activeToolThickness * screenWidth * zoomAnimatable.value
val eraserStrokeWidth = resolveEraserStrokeWidth(
isStylusEraserOverride,
activeToolThickness,
eraserToolThickness
)
val radiusPx = if (eraserStrokeWidth > 0f) {
eraserStrokeWidth * screenWidth * zoomAnimatable.value
} else {
8.dp.toPx()
}

View file

@ -210,6 +210,7 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.TtsWordReplacementsSheet
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
@ -224,6 +225,7 @@ import com.aryan.reader.callByokGeminiInlineAi
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.PdfAnnotation
@ -238,11 +240,14 @@ import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -277,6 +282,12 @@ import androidx.compose.ui.input.pointer.isTertiaryPressed
import androidx.compose.ui.input.pointer.isBackPressed
import androidx.compose.ui.input.pointer.isForwardPressed
internal fun resolveEraserStrokeWidth(
isEraserOverride: Boolean,
activeToolThickness: Float,
eraserToolThickness: Float
): Float = if (isEraserOverride) eraserToolThickness else activeToolThickness
@Suppress("KotlinConstantConditions")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
@ExperimentalMaterial3Api
@ -441,6 +452,12 @@ fun PdfViewerScreen(
)
}
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showTtsReplacementsSheet by remember { mutableStateOf(false) }
var ttsReplacementPreferences by remember { mutableStateOf(loadTtsReplacementPreferences(context)) }
val updateTtsReplacementPreferences: (ReaderTtsReplacementPreferences) -> Unit = { next ->
ttsReplacementPreferences = next
saveTtsReplacementPreferences(context, next)
}
DisposableEffect(isKeepScreenOn) {
view.keepScreenOn = isKeepScreenOn
@ -805,6 +822,7 @@ fun PdfViewerScreen(
val activeToolColor = toolSettings.getToolColor(selectedTool)
val activeToolThickness = toolSettings.getToolThickness(selectedTool)
val eraserToolThickness = toolSettings.getToolThickness(InkType.ERASER)
val fountainPenColor = toolSettings.getToolColor(InkType.FOUNTAIN_PEN)
val markerColor = toolSettings.getToolColor(InkType.PEN)
@ -824,6 +842,7 @@ fun PdfViewerScreen(
val currentStrokeColor by remember(activeToolColor) { derivedStateOf { activeToolColor } }
val currentStrokeWidth by remember(activeToolThickness) { derivedStateOf { activeToolThickness } }
val currentEraserStrokeWidth by remember(eraserToolThickness) { derivedStateOf { eraserToolThickness } }
val pdfTextRepository = remember(context) { PdfTextRepository(context) }
val annotationRepository = remember(context) { PdfAnnotationRepository(context) }
@ -1342,6 +1361,30 @@ fun PdfViewerScreen(
Timber.d("Derived currentPage recomposed. New value: $currentPage (Mode: $displayMode)")
suspend fun rebuildMissingHighlightBounds(
document: ReaderDocument,
highlights: List<PdfUserHighlight>
): List<PdfUserHighlight> = withContext(Dispatchers.IO) {
highlights.map { highlight ->
if (highlight.bounds.isNotEmpty()) return@map highlight
val start = highlight.range.first
val end = highlight.range.second
if (highlight.pageIndex < 0 || end <= start) return@map highlight
runCatching {
document.openPage(highlight.pageIndex)?.use { page ->
page.openTextPage().use { textPage ->
val rects = textPage.textPageGetRectsForRanges(intArrayOf(start, end - start))
?.map { it.rect }
.orEmpty()
val merged = mergePdfRectsIntoLines(rects)
if (merged.isEmpty()) highlight else highlight.copy(bounds = merged)
}
} ?: highlight
}.getOrDefault(highlight)
}
}
val onHighlightAdd = remember(pdfDocument, currentBookId) {
{ pageIndex: Int, range: Pair<Int, Int>, text: String, color: PdfHighlightColor ->
Timber.tag("PdfExportDebug").i("onHighlightAdd: Adding persistent highlight. Page: $pageIndex, Text: ${text.take(20)}...")
@ -2042,6 +2085,25 @@ fun PdfViewerScreen(
}
}
var isRebuildingSyncedHighlightBounds by remember(currentBookId) { mutableStateOf(false) }
LaunchedEffect(pdfDocument, currentBookId, userHighlights.toList()) {
val document = pdfDocument ?: return@LaunchedEffect
if (currentBookId == null || isRebuildingSyncedHighlightBounds) return@LaunchedEffect
val snapshot = userHighlights.toList()
if (snapshot.none { it.bounds.isEmpty() && it.range.second > it.range.first }) return@LaunchedEffect
isRebuildingSyncedHighlightBounds = true
try {
val rebuilt = rebuildMissingHighlightBounds(document, snapshot)
if (rebuilt != snapshot) {
userHighlights.clear()
userHighlights.addAll(rebuilt)
}
} finally {
isRebuildingSyncedHighlightBounds = false
}
}
var pendingSaveMode by remember { mutableStateOf<SaveMode?>(null) }
val saveLauncher = rememberLauncherForActivityResult(
@ -2076,7 +2138,7 @@ fun PdfViewerScreen(
viewModel.saveOriginalPdf(effectivePdfUri, uri)
}
else -> {}
null -> Unit
}
}
pendingSaveMode = null
@ -2618,7 +2680,7 @@ fun PdfViewerScreen(
val ttsChunks = chunks.mapIndexed { index, text -> TtsChunk(text, "", index) }
ttsController.start(
chunks = ttsChunks,
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = bookTitle,
chapterTitle = pageTitle,
coverImageUri = null,
@ -3434,6 +3496,7 @@ fun PdfViewerScreen(
}
showTtsSettingsSheet -> showTtsSettingsSheet = false
showTtsReplacementsSheet -> showTtsReplacementsSheet = false
showThemePanel -> showThemePanel = false
else -> {
@ -3712,6 +3775,9 @@ fun PdfViewerScreen(
val currentStrokeWidthState by rememberUpdatedState(
currentStrokeWidth
)
val currentEraserStrokeWidthState by rememberUpdatedState(
currentEraserStrokeWidth
)
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
remember(pageIndex) {
@ -3719,10 +3785,15 @@ fun PdfViewerScreen(
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
lastEraserPoint = point
if (toRemove.isNotEmpty()) {
@ -3762,10 +3833,15 @@ fun PdfViewerScreen(
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
if (toRemove.isNotEmpty()) {
val batch =
@ -3892,6 +3968,7 @@ fun PdfViewerScreen(
onNoteRequested = onNoteRequested,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
activeToolThickness = currentStrokeWidthState,
eraserToolThickness = currentEraserStrokeWidthState,
lockedState = lockedState,
onZoomAndPanChanged = { newScale, newOffset ->
if (pagerState.currentPage == pageIndex) {
@ -4152,6 +4229,9 @@ fun PdfViewerScreen(
val currentStrokeWidthState by rememberUpdatedState(
currentStrokeWidth
)
val currentEraserStrokeWidthState by rememberUpdatedState(
currentEraserStrokeWidth
)
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
remember {
@ -4164,11 +4244,16 @@ fun PdfViewerScreen(
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
if (toRemove.isNotEmpty()) {
val batch =
@ -4204,10 +4289,15 @@ fun PdfViewerScreen(
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.ERASER) {
val eraserStrokeWidth = resolveEraserStrokeWidth(
isEraserOverride,
currentStrokeWidthState,
currentEraserStrokeWidthState
)
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, currentStrokeWidthState)
isAnnotationHit(it, point, lastEraserPoint, aspectRatio, eraserStrokeWidth)
}
lastEraserPoint = point
if (toRemove.isNotEmpty()) {
@ -4281,6 +4371,7 @@ fun PdfViewerScreen(
onNoteRequested = onNoteRequested,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
activeToolThickness = currentStrokeWidthState,
eraserToolThickness = currentEraserStrokeWidthState,
onLinkClicked = onLinkClickedStable,
onInternalLinkClicked = onInternalLinkNavStable,
bookmarks = bookmarksHolder,
@ -5019,6 +5110,7 @@ fun PdfViewerScreen(
showBars = !isMusicianMode
},
onShowTtsSettings = { showTtsSettingsSheet = true },
onShowTtsReplacements = { showTtsReplacementsSheet = true },
onToggleBookmark = onBookmarkClick,
onInsertPage = onInsertPage,
onDeletePage = onDeletePage,
@ -6511,6 +6603,15 @@ fun PdfViewerScreen(
)
}
TtsWordReplacementsSheet(
isVisible = showTtsReplacementsSheet,
bookId = bookId,
bookTitle = documentMetadataTitle ?: originalFileName,
preferences = ttsReplacementPreferences,
onPreferencesChange = updateTtsReplacementPreferences,
onDismiss = { showTtsReplacementsSheet = false },
)
if (showDictionarySettingsSheet) {
DictionarySettingsDialog(
isVisible = true,
@ -6684,15 +6785,18 @@ fun PdfViewerScreen(
title = { Text(stringResource(R.string.title_save_to_device)) },
text = { Text(stringResource(R.string.desc_choose_format_save)) },
confirmButton = {
TextButton(
onClick = {
showSaveDialog = false
pendingSaveMode = SaveMode.ANNOTATED
val suggestedName = getSuggestedFilename(
originalFileName, isAnnotated = true
)
saveLauncher.launch(suggestedName)
}) { Text(stringResource(R.string.action_with_annotations)) }
Column(horizontalAlignment = Alignment.End) {
TextButton(
onClick = {
showSaveDialog = false
pendingSaveMode = SaveMode.ANNOTATED
val suggestedName = getSuggestedFilename(
originalFileName, isAnnotated = true
)
saveLauncher.launch(suggestedName)
}) { Text(stringResource(R.string.action_with_annotations)) }
}
},
dismissButton = {
Row {
@ -6723,31 +6827,34 @@ fun PdfViewerScreen(
title = { Text(stringResource(R.string.share_chooser_title)) },
text = { Text(stringResource(R.string.desc_choose_format_share)) },
confirmButton = {
TextButton(
onClick = {
showShareDialog = false
isShareLoading = true
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
val filename = getSuggestedFilename(
originalFileName, isAnnotated = true
)
coroutineScope.launch {
val currentRichTextLayouts = richTextController?.pageLayouts
viewModel.sharePdf(
activityContext = context,
sourceUri = effectivePdfUri,
annotations = allAnnotations,
richTextPageLayouts = currentRichTextLayouts,
textBoxes = textBoxes.toList(),
highlights = userHighlights.toList(),
includeAnnotations = true,
filename = filename,
bookId = currentBookId
Column(horizontalAlignment = Alignment.End) {
TextButton(
onClick = {
showShareDialog = false
isShareLoading = true
Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${userHighlights.size}")
val filename = getSuggestedFilename(
originalFileName, isAnnotated = true
)
isShareLoading = false
}
}) { Text(stringResource(R.string.action_with_annotations)) }
coroutineScope.launch {
val currentRichTextLayouts = richTextController?.pageLayouts
viewModel.sharePdf(
activityContext = context,
sourceUri = effectivePdfUri,
annotations = allAnnotations,
richTextPageLayouts = currentRichTextLayouts,
textBoxes = textBoxes.toList(),
highlights = userHighlights.toList(),
includeAnnotations = true,
filename = filename,
bookId = currentBookId
)
isShareLoading = false
}
}) { Text(stringResource(R.string.action_with_annotations)) }
}
},
dismissButton = {
Row {

View file

@ -0,0 +1,768 @@
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.text.Layout
import android.text.SpannableString
import android.text.Spanned
import android.text.StaticLayout
import android.text.TextPaint
import android.text.style.AbsoluteSizeSpan
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.MetricAffectingSpan
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import android.util.TypedValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.ceil
import kotlin.math.roundToInt
internal object PdfiumAnnotationExporter {
internal const val TEXT_FLAG_BOLD = 1
internal const val TEXT_FLAG_ITALIC = 1 shl 1
internal const val TEXT_FLAG_UNDERLINE = 1 shl 2
internal const val TEXT_FLAG_STRIKE_THROUGH = 1 shl 3
internal const val TEXT_FLAG_ABSOLUTE_LINE = 1 shl 4
private const val TEXT_BOX_PADDING_DP = 8f
private const val TEXT_RASTER_PDF_POINT_SCALE = 3f
private const val TEXT_RASTER_MIN_PAGE_HEIGHT_PX = 1200f
private const val TEXT_RASTER_MAX_PAGE_HEIGHT_PX = 3600f
private const val RICH_TEXT_MARGIN_X = 0.1f
private const val RICH_TEXT_MARGIN_Y = 0.08f
suspend fun exportAnnotatedPdf(
context: Context,
sourceUri: Uri,
destStream: OutputStream,
virtualPages: List<VirtualPage>?,
inkAnnotations: Map<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null,
highlights: List<PdfUserHighlight>? = null
) {
withContext(Dispatchers.IO) {
if (!supportsOriginalPageOrder(virtualPages)) {
destStream.close()
throw UnsupportedOperationException(
"PDFium annotation export currently supports only the original PDF page order."
)
}
val exportDir = File(context.cacheDir, "pdfium_annotation_export")
if (!exportDir.exists() && !exportDir.mkdirs()) {
destStream.close()
throw IOException("Unable to create PDFium export cache directory.")
}
val sourceFile: File
val destFile: File
try {
sourceFile = File.createTempFile("source_", ".pdf", exportDir)
destFile = File.createTempFile("annotated_", ".pdf", exportDir)
} catch (e: IOException) {
destStream.close()
throw e
}
try {
context.contentResolver.openInputStream(sourceUri)?.use { input ->
FileOutputStream(sourceFile).use { output -> input.copyTo(output) }
} ?: throw IOException("Unable to open source PDF for PDFium export.")
val pageSizes = runCatching { readPdfPageSizes(sourceFile) }
.onFailure { Timber.tag("PdfExportDebug").w(it, "Unable to read page sizes for text raster export.") }
.getOrDefault(emptyList())
val rasterOverlays = buildTextRasterOverlays(
context = context,
textBoxes = textBoxes.orEmpty(),
richTextPageLayouts = richTextPageLayouts.orEmpty(),
pageSizes = pageSizes
)
val payload = buildPayload(
inkAnnotations = inkAnnotations,
textBoxes = emptyList(),
highlights = highlights.orEmpty(),
richTextPageLayouts = emptyList(),
rasterOverlays = rasterOverlays
)
if (!payload.hasAnnotations()) {
FileInputStream(sourceFile).use { input -> input.copyTo(destStream) }
return@withContext
}
val exported = NativePdfiumBridge.exportAnnotatedPdf(
sourcePath = sourceFile.absolutePath,
destPath = destFile.absolutePath,
inkPageIndices = payload.inkPageIndices,
inkTypes = payload.inkTypes,
inkColors = payload.inkColors,
inkStrokeWidths = payload.inkStrokeWidths,
inkPointOffsets = payload.inkPointOffsets,
inkPointCounts = payload.inkPointCounts,
inkPoints = payload.inkPoints,
textPageIndices = payload.textPageIndices,
textBounds = payload.textBounds,
textColors = payload.textColors,
textBackgroundColors = payload.textBackgroundColors,
textFontSizes = payload.textFontSizes,
textFlags = payload.textFlags,
textValues = payload.textValues,
textFontPaths = payload.textFontPaths,
textFontNames = payload.textFontNames,
rasterPageIndices = payload.rasterPageIndices,
rasterBounds = payload.rasterBounds,
rasterWidths = payload.rasterWidths,
rasterHeights = payload.rasterHeights,
rasterPixelOffsets = payload.rasterPixelOffsets,
rasterPixels = payload.rasterPixels,
highlightPageIndices = payload.highlightPageIndices,
highlightColors = payload.highlightColors,
highlightRectOffsets = payload.highlightRectOffsets,
highlightRectCounts = payload.highlightRectCounts,
highlightRects = payload.highlightRects,
highlightContents = payload.highlightContents
)
if (!exported) {
throw IOException("PDFium failed to write annotated PDF.")
}
FileInputStream(destFile).use { input -> input.copyTo(destStream) }
Timber.tag("PdfExportDebug").i(
"PDFium export saved ${payload.inkPageIndices.size} ink, " +
"${payload.highlightPageIndices.size} highlight, " +
"${payload.rasterPageIndices.size} raster text overlays."
)
} finally {
destStream.close()
sourceFile.delete()
destFile.delete()
}
}
}
internal fun supportsOriginalPageOrder(virtualPages: List<VirtualPage>?): Boolean {
return virtualPages == null || virtualPages.withIndex().all { (index, page) ->
page is VirtualPage.PdfPage && page.pdfIndex == index
}
}
@Suppress("UNUSED_PARAMETER")
internal fun buildPayload(
inkAnnotations: Map<Int, List<PdfAnnotation>>,
textBoxes: List<PdfTextBox>,
highlights: List<PdfUserHighlight>,
richTextPageLayouts: List<PageTextLayout> = emptyList(),
fontPathResolver: (String?) -> String? = { it },
rasterOverlays: List<PdfiumRasterOverlay> = emptyList()
): PdfiumAnnotationExportPayload {
val inkItems = inkAnnotations.entries
.flatMap { (pageIndex, annotations) -> annotations.map { pageIndex to it } }
.filter { (_, annotation) ->
annotation.points.size >= 2 &&
annotation.inkType != InkType.ERASER &&
annotation.inkType != InkType.TEXT
}
val inkPageIndices = IntArray(inkItems.size)
val inkTypes = IntArray(inkItems.size)
val inkColors = IntArray(inkItems.size)
val inkStrokeWidths = FloatArray(inkItems.size)
val inkPointOffsets = IntArray(inkItems.size)
val inkPointCounts = IntArray(inkItems.size)
val inkPoints = FloatArray(inkItems.sumOf { it.second.points.size } * 2)
var inkPointCursor = 0
inkItems.forEachIndexed { index, (pageIndex, annotation) ->
inkPageIndices[index] = pageIndex
inkTypes[index] = annotation.inkType.ordinal
inkColors[index] = annotation.color.toArgb()
inkStrokeWidths[index] = annotation.strokeWidth
inkPointOffsets[index] = inkPointCursor / 2
inkPointCounts[index] = annotation.points.size
annotation.points.forEach { point ->
inkPoints[inkPointCursor++] = point.x
inkPoints[inkPointCursor++] = point.y
}
}
val textPageIndices = IntArray(0)
val textBounds = FloatArray(0)
val textColors = IntArray(0)
val textBackgroundColors = IntArray(0)
val textFontSizes = FloatArray(0)
val textFlags = IntArray(0)
val textValues = emptyArray<String>()
val textFontPaths = emptyArray<String>()
val textFontNames = emptyArray<String>()
val rasterPageIndices = IntArray(rasterOverlays.size)
val rasterBounds = FloatArray(rasterOverlays.size * 4)
val rasterWidths = IntArray(rasterOverlays.size)
val rasterHeights = IntArray(rasterOverlays.size)
val rasterPixelOffsets = IntArray(rasterOverlays.size)
val rasterPixels = IntArray(rasterOverlays.sumOf { it.pixels.size })
var rasterPixelCursor = 0
rasterOverlays.forEachIndexed { index, overlay ->
rasterPageIndices[index] = overlay.pageIndex
rasterBounds[index * 4] = overlay.left
rasterBounds[index * 4 + 1] = overlay.top
rasterBounds[index * 4 + 2] = overlay.right
rasterBounds[index * 4 + 3] = overlay.bottom
rasterWidths[index] = overlay.width
rasterHeights[index] = overlay.height
rasterPixelOffsets[index] = rasterPixelCursor
overlay.pixels.copyInto(rasterPixels, rasterPixelCursor)
rasterPixelCursor += overlay.pixels.size
}
val boundedHighlights = highlights.filter { it.bounds.isNotEmpty() }
val highlightPageIndices = IntArray(boundedHighlights.size)
val highlightColors = IntArray(boundedHighlights.size)
val highlightRectOffsets = IntArray(boundedHighlights.size)
val highlightRectCounts = IntArray(boundedHighlights.size)
val highlightRects = FloatArray(boundedHighlights.sumOf { it.bounds.size } * 4)
val highlightContents = Array(boundedHighlights.size) { "" }
var highlightRectCursor = 0
boundedHighlights.forEachIndexed { index, highlight ->
highlightPageIndices[index] = highlight.pageIndex
highlightColors[index] = highlight.color.color.toArgb()
highlightRectOffsets[index] = highlightRectCursor / 4
highlightRectCounts[index] = highlight.bounds.size
highlightContents[index] = highlight.note?.takeIf { it.isNotBlank() } ?: highlight.text
highlight.bounds.forEach { rect ->
highlightRects[highlightRectCursor++] = rect.left
highlightRects[highlightRectCursor++] = rect.top
highlightRects[highlightRectCursor++] = rect.right
highlightRects[highlightRectCursor++] = rect.bottom
}
}
return PdfiumAnnotationExportPayload(
inkPageIndices = inkPageIndices,
inkTypes = inkTypes,
inkColors = inkColors,
inkStrokeWidths = inkStrokeWidths,
inkPointOffsets = inkPointOffsets,
inkPointCounts = inkPointCounts,
inkPoints = inkPoints,
textPageIndices = textPageIndices,
textBounds = textBounds,
textColors = textColors,
textBackgroundColors = textBackgroundColors,
textFontSizes = textFontSizes,
textFlags = textFlags,
textValues = textValues,
textFontPaths = textFontPaths,
textFontNames = textFontNames,
rasterPageIndices = rasterPageIndices,
rasterBounds = rasterBounds,
rasterWidths = rasterWidths,
rasterHeights = rasterHeights,
rasterPixelOffsets = rasterPixelOffsets,
rasterPixels = rasterPixels,
highlightPageIndices = highlightPageIndices,
highlightColors = highlightColors,
highlightRectOffsets = highlightRectOffsets,
highlightRectCounts = highlightRectCounts,
highlightRects = highlightRects,
highlightContents = highlightContents
)
}
private fun buildTextRasterOverlays(
context: Context,
textBoxes: List<PdfTextBox>,
richTextPageLayouts: List<PageTextLayout>,
pageSizes: List<PdfiumPageSize>
): List<PdfiumRasterOverlay> {
val overlays = mutableListOf<PdfiumRasterOverlay>()
textBoxes.mapNotNullTo(overlays) { box ->
renderTextBoxOverlay(context, box, pageSizeFor(pageSizes, box.pageIndex))
}
richTextPageLayouts.mapNotNullTo(overlays) { layout ->
renderRichTextOverlay(context, layout, pageSizeFor(pageSizes, layout.pageIndex))
}
return overlays
}
private fun renderTextBoxOverlay(
context: Context,
box: PdfTextBox,
pageSize: PdfiumPageSize
): PdfiumRasterOverlay? {
val text = box.text.sanitizeRasterText()
if (box.pageIndex < 0 || text.isBlank()) return null
val bounds = box.relativeBounds
val left = bounds.left.coerceIn(0f, 1f)
val top = bounds.top.coerceIn(0f, 1f)
val right = bounds.right.coerceIn(left, 1f)
val bottom = bounds.bottom.coerceIn(top, 1f)
if (right - left <= 0f || bottom - top <= 0f) return null
val pageHeightPx = pageSize.exportHeightPx()
val pageWidthPx = pageHeightPx * pageSize.aspect
val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1)
val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1)
val paddingPx = dpToPx(context, TEXT_BOX_PADDING_DP)
.coerceAtMost((minOf(bitmapWidth, bitmapHeight) / 2f).coerceAtLeast(0f))
val contentWidth = (bitmapWidth - paddingPx * 2f).roundToInt().coerceAtLeast(1)
val fontSizePx = (box.fontSize * pageHeightPx).coerceAtLeast(1f)
val typeface = resolveTypeface(context, box.fontPath, box.fontName, box.isBold, box.isItalic)
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
return try {
val paint = textPaint(
colorArgb = box.color.toArgb(),
textSizePx = fontSizePx,
typeface = typeface
)
val spannable = SpannableString(text)
applyTextBoxSpans(
text = spannable,
colorArgb = box.color.toArgb(),
backgroundArgb = box.backgroundColor.toArgb(),
fontSizePx = fontSizePx,
isBold = box.isBold,
isItalic = box.isItalic,
isUnderline = box.isUnderline,
isStrikeThrough = box.isStrikeThrough,
typeface = typeface
)
drawStaticLayout(
bitmap = bitmap,
text = spannable,
paint = paint,
width = contentWidth,
translateX = paddingPx,
translateY = paddingPx
)
bitmap.toRasterOverlay(box.pageIndex, left, top, right, bottom)
} finally {
bitmap.recycle()
}
}
private fun renderRichTextOverlay(
context: Context,
layout: PageTextLayout,
pageSize: PdfiumPageSize
): PdfiumRasterOverlay? {
val visibleText = layout.visibleText.withoutTrailingPdfiumPageBreak()
if (layout.pageIndex < 0 || visibleText.text.isBlank()) return null
val pageHeightPx = layout.pageHeightPx.takeIf { it > 0f } ?: pageSize.exportHeightPx()
val pageWidthPx = pageHeightPx * pageSize.aspect
val left = RICH_TEXT_MARGIN_X
val top = RICH_TEXT_MARGIN_Y
val right = 1f - RICH_TEXT_MARGIN_X
val bottom = 1f - RICH_TEXT_MARGIN_Y
val bitmapWidth = ceil((right - left) * pageWidthPx).toInt().coerceAtLeast(1)
val bitmapHeight = ceil((bottom - top) * pageHeightPx).toInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
return try {
val paint = textPaint(
colorArgb = Color.Black.toArgb(),
textSizePx = spToPx(context, 16f),
typeface = Typeface.DEFAULT
)
val spannable = visibleText.toAndroidSpannable(context)
drawStaticLayout(
bitmap = bitmap,
text = spannable,
paint = paint,
width = bitmapWidth,
translateX = 0f,
translateY = 0f
)
bitmap.toRasterOverlay(layout.pageIndex, left, top, right, bottom)
} finally {
bitmap.recycle()
}
}
private fun applyTextBoxSpans(
text: SpannableString,
colorArgb: Int,
backgroundArgb: Int,
fontSizePx: Float,
isBold: Boolean,
isItalic: Boolean,
isUnderline: Boolean,
isStrikeThrough: Boolean,
typeface: Typeface
) {
if (text.isEmpty()) return
val end = text.length
text.setSpan(ForegroundColorSpan(colorArgb), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if ((backgroundArgb ushr 24) != 0) {
text.setSpan(BackgroundColorSpan(backgroundArgb), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
text.setSpan(AbsoluteSizeSpan(fontSizePx.roundToInt().coerceAtLeast(1), false), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
text.setSpan(TypefaceSpanCompat(typeface), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if (!hasStyle(typeface, isBold, isItalic)) {
text.setSpan(StyleSpan(typefaceStyle(isBold, isItalic)), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (isUnderline) {
text.setSpan(UnderlineSpan(), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (isStrikeThrough) {
text.setSpan(StrikethroughSpan(), 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun AnnotatedString.toAndroidSpannable(context: Context): SpannableString {
val spannable = SpannableString(text.sanitizeRasterTextPreservingLength())
spanStyles.forEach { range ->
applySpanStyle(context, spannable, range.item, range.start, range.end)
}
return spannable
}
private fun applySpanStyle(
context: Context,
spannable: SpannableString,
style: SpanStyle,
rawStart: Int,
rawEnd: Int
) {
val start = rawStart.coerceIn(0, spannable.length)
val end = rawEnd.coerceIn(start, spannable.length)
if (start >= end) return
val color = style.color
if (color != Color.Unspecified) {
spannable.setSpan(ForegroundColorSpan(color.toArgb()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
val background = style.background
if (background != Color.Unspecified && background.alpha > 0f) {
spannable.setSpan(BackgroundColorSpan(background.toArgb()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (style.fontSize.isSpecified) {
val textSizePx = spToPx(context, style.fontSize.value)
spannable.setSpan(
AbsoluteSizeSpan(textSizePx.roundToInt().coerceAtLeast(1), false),
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
val isBold = isBold(style.fontWeight)
val isItalic = style.fontStyle == FontStyle.Italic
val fontPath = PdfFontCache.getPath(style.fontFamily)
val fontName = standardFontName(style.fontFamily)
val typeface = resolveTypeface(context, fontPath, fontName, isBold, isItalic)
if (fontPath != null || fontName != null) {
spannable.setSpan(TypefaceSpanCompat(typeface), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
} else if (isBold || isItalic) {
spannable.setSpan(StyleSpan(typefaceStyle(isBold, isItalic)), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
val decoration = style.textDecoration ?: TextDecoration.None
if (decoration.contains(TextDecoration.Underline)) {
spannable.setSpan(UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (decoration.contains(TextDecoration.LineThrough)) {
spannable.setSpan(StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun drawStaticLayout(
bitmap: Bitmap,
text: CharSequence,
paint: TextPaint,
width: Int,
translateX: Float,
translateY: Float
) {
val canvas = Canvas(bitmap)
canvas.save()
canvas.clipRect(0, 0, bitmap.width, bitmap.height)
canvas.translate(translateX, translateY)
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(false)
.setLineSpacing(0f, 1f)
.build()
.draw(canvas)
canvas.restore()
}
private fun textPaint(
colorArgb: Int,
textSizePx: Float,
typeface: Typeface
): TextPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
color = colorArgb
textSize = textSizePx
this.typeface = typeface
}
private fun Bitmap.toRasterOverlay(
pageIndex: Int,
boundsLeft: Float,
boundsTop: Float,
boundsRight: Float,
boundsBottom: Float
): PdfiumRasterOverlay? {
val allPixels = IntArray(width * height)
getPixels(allPixels, 0, width, 0, 0, width, height)
var minX = width
var minY = height
var maxX = -1
var maxY = -1
for (y in 0 until height) {
val rowOffset = y * width
for (x in 0 until width) {
if ((allPixels[rowOffset + x] ushr 24) != 0) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
}
if (maxX < minX || maxY < minY) return null
val cropWidth = maxX - minX + 1
val cropHeight = maxY - minY + 1
val cropped = IntArray(cropWidth * cropHeight)
for (row in 0 until cropHeight) {
System.arraycopy(
allPixels,
(minY + row) * width + minX,
cropped,
row * cropWidth,
cropWidth
)
}
val boundsWidth = boundsRight - boundsLeft
val boundsHeight = boundsBottom - boundsTop
return PdfiumRasterOverlay(
pageIndex = pageIndex,
left = boundsLeft + boundsWidth * (minX.toFloat() / width),
top = boundsTop + boundsHeight * (minY.toFloat() / height),
right = boundsLeft + boundsWidth * ((maxX + 1).toFloat() / width),
bottom = boundsTop + boundsHeight * ((maxY + 1).toFloat() / height),
width = cropWidth,
height = cropHeight,
pixels = cropped
)
}
private fun readPdfPageSizes(sourceFile: File): List<PdfiumPageSize> {
return ParcelFileDescriptor.open(sourceFile, ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor ->
PdfRenderer(descriptor).use { renderer ->
List(renderer.pageCount) { index ->
val page = renderer.openPage(index)
try {
PdfiumPageSize(page.width, page.height)
} finally {
page.close()
}
}
}
}
}
private fun pageSizeFor(pageSizes: List<PdfiumPageSize>, pageIndex: Int): PdfiumPageSize =
pageSizes.getOrNull(pageIndex) ?: PdfiumPageSize.Default
private fun PdfiumPageSize.exportHeightPx(): Float =
(height * TEXT_RASTER_PDF_POINT_SCALE)
.coerceIn(TEXT_RASTER_MIN_PAGE_HEIGHT_PX, TEXT_RASTER_MAX_PAGE_HEIGHT_PX)
private fun resolveTypeface(
context: Context,
fontPath: String?,
fontName: String?,
isBold: Boolean,
isItalic: Boolean
): Typeface {
val base = try {
when {
!fontPath.isNullOrBlank() && fontPath.startsWith("asset:") ->
Typeface.createFromAsset(context.assets, fontPath.removePrefix("asset:"))
!fontPath.isNullOrBlank() ->
Typeface.createFromFile(fontPath)
else -> when (fontName?.lowercase(Locale.US)) {
"serif" -> Typeface.SERIF
"monospace" -> Typeface.MONOSPACE
"cursive" -> Typeface.create("casual", Typeface.NORMAL)
"sans", "sansserif", "sans-serif" -> Typeface.SANS_SERIF
else -> Typeface.DEFAULT
}
}
} catch (e: Exception) {
Timber.tag("PdfFontDebug").w(e, "Falling back while rasterizing fontPath=$fontPath fontName=$fontName")
Typeface.DEFAULT
}
return Typeface.create(base, typefaceStyle(isBold, isItalic))
}
private fun typefaceStyle(isBold: Boolean, isItalic: Boolean): Int =
when {
isBold && isItalic -> Typeface.BOLD_ITALIC
isBold -> Typeface.BOLD
isItalic -> Typeface.ITALIC
else -> Typeface.NORMAL
}
private fun hasStyle(typeface: Typeface, isBold: Boolean, isItalic: Boolean): Boolean {
val style = typeface.style
return (!isBold || style and Typeface.BOLD != 0) &&
(!isItalic || style and Typeface.ITALIC != 0)
}
private fun isBold(weight: FontWeight?): Boolean =
(weight?.weight ?: FontWeight.Normal.weight) >= FontWeight.SemiBold.weight
private fun standardFontName(fontFamily: FontFamily?): String? =
when (fontFamily) {
FontFamily.Serif -> "Serif"
FontFamily.Monospace -> "Monospace"
FontFamily.SansSerif -> "Sans"
FontFamily.Cursive -> "Cursive"
else -> null
}
private fun dpToPx(context: Context, value: Float): Float =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, context.resources.displayMetrics)
private fun spToPx(context: Context, value: Float): Float =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, context.resources.displayMetrics)
private fun AnnotatedString.withoutTrailingPdfiumPageBreak(): AnnotatedString =
if (text.lastOrNull() == PAGE_BREAK_CHAR) subSequence(0, length - 1) else this
private fun String.sanitizeRasterText(): String =
replace(PAGE_BREAK_CHAR, '\n')
.replace("\u200B", "")
.replace('\r', ' ')
private fun String.sanitizeRasterTextPreservingLength(): String =
replace(PAGE_BREAK_CHAR, '\n')
.replace('\r', ' ')
}
internal data class PdfiumRasterOverlay(
val pageIndex: Int,
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
val width: Int,
val height: Int,
val pixels: IntArray
)
private data class PdfiumPageSize(
val width: Int,
val height: Int
) {
val aspect: Float
get() = if (width > 0 && height > 0) width.toFloat() / height.toFloat() else Default.aspect
companion object {
val Default = PdfiumPageSize(612, 792)
}
}
private class TypefaceSpanCompat(
private val typeface: Typeface
) : MetricAffectingSpan() {
override fun updateDrawState(tp: TextPaint) {
apply(tp)
}
override fun updateMeasureState(tp: TextPaint) {
apply(tp)
}
private fun apply(paint: Paint) {
val oldStyle = paint.typeface?.style ?: Typeface.NORMAL
val missingStyles = oldStyle and typeface.style.inv()
if (missingStyles and Typeface.BOLD != 0) {
paint.isFakeBoldText = true
}
if (missingStyles and Typeface.ITALIC != 0) {
paint.textSkewX = -0.25f
}
paint.typeface = typeface
}
}
internal data class PdfiumAnnotationExportPayload(
val inkPageIndices: IntArray,
val inkTypes: IntArray,
val inkColors: IntArray,
val inkStrokeWidths: FloatArray,
val inkPointOffsets: IntArray,
val inkPointCounts: IntArray,
val inkPoints: FloatArray,
val textPageIndices: IntArray,
val textBounds: FloatArray,
val textColors: IntArray,
val textBackgroundColors: IntArray,
val textFontSizes: FloatArray,
val textFlags: IntArray,
val textValues: Array<String>,
val textFontPaths: Array<String>,
val textFontNames: Array<String>,
val rasterPageIndices: IntArray,
val rasterBounds: FloatArray,
val rasterWidths: IntArray,
val rasterHeights: IntArray,
val rasterPixelOffsets: IntArray,
val rasterPixels: IntArray,
val highlightPageIndices: IntArray,
val highlightColors: IntArray,
val highlightRectOffsets: IntArray,
val highlightRectCounts: IntArray,
val highlightRects: FloatArray,
val highlightContents: Array<String>
) {
fun hasAnnotations(): Boolean =
inkPageIndices.isNotEmpty() ||
textPageIndices.isNotEmpty() ||
rasterPageIndices.isNotEmpty() ||
highlightPageIndices.isNotEmpty()
}

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
@ -56,12 +57,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
import timber.log.Timber
import java.io.File
const val PAGE_BREAK_CHAR = '\u000C'
private const val ZWSP = "\u200B"
internal fun String.hasRenderableRichText(): Boolean =
any { it != PAGE_BREAK_CHAR && !it.isWhitespace() }
object PdfFontCache {
private val cache = ConcurrentHashMap<String, FontFamily>()
private var assetManager: android.content.res.AssetManager? = null
@ -262,126 +267,200 @@ class TextPaginationEngine {
dirtyGlobalIndex: Int = 0
): List<PageTextLayout> {
val totalLen = globalText.length
if (totalLen == 0) return listOf(
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
)
if (pageWidthPx <= 0 || pageHeightPx <= 0) return emptyList()
val validPages = if (dirtyGlobalIndex > 0 && previousLayouts.isNotEmpty()) {
previousLayouts.takeWhile { it.globalEndIndex < dirtyGlobalIndex }
} else {
emptyList()
if (totalLen == 0) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0")
return listOf(
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
)
}
if (pageWidthPx <= 0 || pageHeightPx <= 0) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size")
return emptyList()
}
val startPageIndex = validPages.size
val measurementStartIndex = validPages.lastOrNull()?.globalEndIndex ?: 0
if (measurementStartIndex >= totalLen) return validPages
val textToMeasure = globalText.subSequence(measurementStartIndex, totalLen)
val fullString = textToMeasure.text
val editorWidth = (pageWidthPx - (marginX * 2)).coerceAtLeast(10f)
val editorHeight = (pageHeightPx - (marginY * 2)).coerceAtLeast(10f)
val measureResult = textMeasurer.measure(
text = textToMeasure,
style = TextStyle(fontSize = 16.sp, color = Color.Black),
constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity),
density = density
)
val newPages = mutableListOf<PageTextLayout>()
var currentPageIndex = startPageIndex
var currentPageStartRel = 0
var currentPageAccumulatedHeight = 0f
var currentPageIndex = 0
var segmentStart = 0
val rawText = globalText.text
var currentLineIndex = 0
val totalLines = measureResult.lineCount
while (segmentStart < totalLen) {
val breakIndex = rawText.indexOf(PAGE_BREAK_CHAR, startIndex = segmentStart)
val hasExplicitBreak = breakIndex != -1
val contentEnd = if (hasExplicitBreak) breakIndex else totalLen
val segmentEnd = if (hasExplicitBreak) breakIndex + 1 else totalLen
Timber.tag("RichTextFlow").d("Pagination: Measuring ${fullString.length} chars from Global $measurementStartIndex. Lines: $totalLines")
while (currentLineIndex < totalLines) {
val lineTop = measureResult.getLineTop(currentLineIndex)
val lineBottom = measureResult.getLineBottom(currentLineIndex)
val lineHeight = lineBottom - lineTop
val lineStartRel = measureResult.getLineStart(currentLineIndex)
val lineEndRel = measureResult.getLineEnd(currentLineIndex)
val localStartOffset = (currentPageStartRel - lineStartRel).coerceAtLeast(0)
if (lineStartRel + localStartOffset >= lineEndRel && currentLineIndex < totalLines - 1) {
currentLineIndex++
continue
}
val safeEndRel = lineEndRel.coerceAtMost(fullString.length)
val lineContent = fullString.substring(lineStartRel, safeEndRel)
val breakIndexInLine = lineContent.indexOf(PAGE_BREAK_CHAR, localStartOffset)
val hasPageBreak = breakIndexInLine != -1
val isStartOfPage = (currentPageAccumulatedHeight == 0f)
val willOverflow = !isStartOfPage && (currentPageAccumulatedHeight + lineHeight > editorHeight)
if (hasPageBreak) {
val splitRelIndex = lineStartRel + breakIndexInLine + 1
val globalStart = measurementStartIndex + currentPageStartRel
val globalEnd = measurementStartIndex + splitRelIndex
Timber.tag("RichTextMigration").v("PaginationEngine: Found PAGE_BREAK_CHAR at relative ${breakIndexInLine}. Breaking Page $currentPageIndex at Global Index $globalEnd")
if (globalEnd > globalStart) {
val visibleText = globalText.subSequence(globalStart, globalEnd)
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
currentPageIndex++
}
currentPageStartRel = splitRelIndex
currentPageAccumulatedHeight = 0f
continue
}
else if (willOverflow) {
val globalStart = measurementStartIndex + currentPageStartRel
val globalEnd = measurementStartIndex + lineStartRel
if (globalEnd > globalStart) {
val visibleText = globalText.subSequence(globalStart, globalEnd)
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
Timber.tag("RichTextFlow").v("Page $currentPageIndex Created (Overflow): $globalStart -> $globalEnd")
currentPageIndex++
}
currentPageStartRel = lineStartRel
currentPageAccumulatedHeight = 0f
continue
}
currentPageAccumulatedHeight += lineHeight
currentLineIndex++
currentPageIndex = newPages.appendMeasuredAndroidRichTextSegment(
globalText = globalText,
segmentStart = segmentStart,
contentEnd = contentEnd,
explicitBreakEnd = if (hasExplicitBreak) segmentEnd else null,
pageIndex = currentPageIndex,
pageHeightPx = pageHeightPx,
editorWidth = editorWidth,
editorHeight = editorHeight,
textMeasurer = textMeasurer,
density = density
)
segmentStart = segmentEnd
}
if (currentPageStartRel < fullString.length) {
val globalStart = measurementStartIndex + currentPageStartRel
val globalEnd = measurementStartIndex + fullString.length
val visibleText = globalText.subSequence(globalStart, globalEnd)
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
}
val resultLayouts = validPages + newPages
val resultLayouts = newPages.withTrailingAndroidBlankRichTextPageIfNeeded(
globalText = globalText,
pageHeightPx = pageHeightPx
)
val mapLog = resultLayouts.joinToString("\n") {
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
}
Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog")
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
return resultLayouts
}
}
private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
globalText: AnnotatedString,
segmentStart: Int,
contentEnd: Int,
explicitBreakEnd: Int?,
pageIndex: Int,
pageHeightPx: Float,
editorWidth: Float,
editorHeight: Float,
textMeasurer: TextMeasurer,
density: Density
): Int {
var nextPageIndex = pageIndex
if (segmentStart >= contentEnd) {
val breakEnd = explicitBreakEnd ?: return nextPageIndex
add(
PageTextLayout(
pageIndex = nextPageIndex,
visibleText = globalText.subSequence(segmentStart, breakEnd),
globalStartIndex = segmentStart,
globalEndIndex = breakEnd,
pageHeightPx = pageHeightPx
)
)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd"
)
return nextPageIndex + 1
}
val contentLength = contentEnd - segmentStart
var relativeStart = 0
while (relativeStart < contentLength) {
val globalStart = segmentStart + relativeStart
val remainingText = globalText.subSequence(globalStart, contentEnd)
val measureResult = textMeasurer.measure(
text = remainingText,
style = TextStyle(fontSize = 16.sp, color = Color.Black),
constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity),
density = density
)
val fitsOnPage = measureResult.size.height.toFloat() <= editorHeight || measureResult.lineCount <= 1
var overflowLineIndex: Int? = null
val relativeEnd = if (fitsOnPage) {
contentLength
} else {
val lineIndex = measureResult.richAndroidLastFittingLineIndex(editorHeight)
overflowLineIndex = lineIndex
val localEnd = measureResult.getLineEnd(lineIndex)
.coerceIn(0, remainingText.length)
.coerceAtLeast(1)
(relativeStart + localEnd)
.coerceAtLeast(relativeStart + 1)
.coerceAtMost(contentLength)
}
val isLastContentPage = relativeEnd >= contentLength
val globalEnd = if (isLastContentPage && explicitBreakEnd != null) {
explicitBreakEnd
} else {
segmentStart + relativeEnd
}
add(
PageTextLayout(
pageIndex = nextPageIndex,
visibleText = globalText.subSequence(globalStart, globalEnd),
globalStartIndex = globalStart,
globalEndIndex = globalEnd,
pageHeightPx = pageHeightPx
)
)
if (isLastContentPage && explicitBreakEnd != null) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd"
)
} else if (!fitsOnPage) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex"
)
}
nextPageIndex++
relativeStart = relativeEnd
}
return nextPageIndex
}
private fun TextLayoutResult.richAndroidLastFittingLineIndex(editorHeight: Float): Int {
var lastFitting = 0
for (lineIndex in 0 until lineCount) {
if (lineIndex == 0 || getLineBottom(lineIndex) <= editorHeight) {
lastFitting = lineIndex
} else {
break
}
}
return lastFitting.coerceIn(0, (lineCount - 1).coerceAtLeast(0))
}
private fun List<PageTextLayout>.withTrailingAndroidBlankRichTextPageIfNeeded(
globalText: AnnotatedString,
pageHeightPx: Float
): List<PageTextLayout> {
if (globalText.text.lastOrNull() != PAGE_BREAK_CHAR) return this
val lastLayout = lastOrNull()
val trailingStart = globalText.length
if (lastLayout != null &&
lastLayout.globalStartIndex == trailingStart &&
lastLayout.globalEndIndex == trailingStart
) {
return this
}
return this + PageTextLayout(
pageIndex = (lastLayout?.pageIndex ?: -1) + 1,
visibleText = AnnotatedString(""),
globalStartIndex = trailingStart,
globalEndIndex = trailingStart,
pageHeightPx = pageHeightPx
)
}
private fun AnnotatedString.withoutTrailingAndroidPageBreak(): AnnotatedString {
return if (text.lastOrNull() == PAGE_BREAK_CHAR) {
subSequence(0, length - 1)
} else {
this
}
}
private fun AnnotatedString.withRestoredTrailingAndroidPageBreak(shouldRestore: Boolean): AnnotatedString {
if (!shouldRestore) return this
if (text.lastOrNull() == PAGE_BREAK_CHAR) return this
return this + AnnotatedString(PAGE_BREAK_CHAR.toString())
}
class PdfRichTextRepository(private val context: Context) {
private val _document = MutableStateFlow<GlobalRichDocument?>(null)
val document = _document.asStateFlow()
@ -396,8 +475,12 @@ class PdfRichTextRepository(private val context: Context) {
suspend fun load(bookId: String) {
withContext(Dispatchers.IO) {
val file = getFile(bookId)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}"
)
if (!file.exists()) {
_document.value = GlobalRichDocument("", emptyList())
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId")
return@withContext
}
try {
@ -425,7 +508,11 @@ class PdfRichTextRepository(private val context: Context) {
)
}
_document.value = GlobalRichDocument(text, spans)
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}"
)
} catch (e: Exception) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId")
Timber.e(e, "Failed to load rich text doc")
_document.value = GlobalRichDocument("", emptyList())
}
@ -436,6 +523,9 @@ class PdfRichTextRepository(private val context: Context) {
_document.value = document
withContext(Dispatchers.IO) {
try {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}"
)
val obj = JSONObject().apply {
put("text", document.text)
val spansArray = JSONArray()
@ -456,14 +546,35 @@ class PdfRichTextRepository(private val context: Context) {
}
put("spans", spansArray)
}
getFile(bookId).writeText(obj.toString())
val file = getFile(bookId)
file.writeText(obj.toString())
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
"android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}"
)
} catch (e: Exception) {
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.save failed book=$bookId")
Timber.e(e, "Failed to save rich text doc")
}
}
}
}
private fun Float.richAndroidLogFloat(): String {
return if (isFinite()) {
val rounded = kotlin.math.round(this * 10f) / 10f
rounded.toString()
} else {
toString()
}
}
private fun List<PageTextLayout>.richAndroidLayoutSummary(): String {
if (isEmpty()) return "[]"
return joinToString(prefix = "[", postfix = "]", limit = 8, truncated = "...") { layout ->
"p${layout.pageIndex}:${layout.globalStartIndex}-${layout.globalEndIndex}/len${layout.visibleText.length}"
}
}
@Stable
class RichTextController(
private val repository: PdfRichTextRepository,
@ -485,6 +596,9 @@ class RichTextController(
var pageLayouts by mutableStateOf(emptyList<PageTextLayout>())
private set
val hasRenderableText: Boolean
get() = globalTextFieldValue.text.hasRenderableRichText()
var currentStyle: SpanStyle by mutableStateOf(SpanStyle(color = Color.Black, fontSize = 16.sp))
private set
@ -720,9 +834,11 @@ class RichTextController(
val currentGlobal = globalTextFieldValue.annotatedString
// FIX: Strip ZWSP (index 0) from local text
val localText = if (localTextFieldValue.annotatedString.isNotEmpty()) {
val localEditableText = if (localTextFieldValue.annotatedString.isNotEmpty()) {
localTextFieldValue.annotatedString.subSequence(1, localTextFieldValue.annotatedString.length)
} else AnnotatedString("")
val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR
val localText = localEditableText.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak)
Timber.tag("RichTextFlow").d("Sync: Page $activePageIndex, GlobalRange [$globalStart..$globalEnd], LocalLen ${localText.length}")
@ -776,7 +892,7 @@ class RichTextController(
activePageIndex = newActiveLayout.pageIndex
val reExtractedText = newGlobalAnnotated.subSequence(
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtractedText
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1)
.coerceIn(0, textWithZwsp.length)
@ -865,28 +981,26 @@ class RichTextController(
val editorWidth = (lastPageWidth - (margin * 2)).coerceAtLeast(10f)
val vText = currentLayout.visibleText
val editableText = vText.withoutTrailingAndroidPageBreak()
// FIX: Prepend ZWSP to the visible text
val textWithZwsp = AnnotatedString(ZWSP) + vText
val safeLen = if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR) vText.length - 1 else vText.length
val textWithZwsp = AnnotatedString(ZWSP) + editableText
val safeLen = editableText.length
// FIX: Adjust initial selection by +1 because of ZWSP
localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(safeLen + 1))
val measureResult = measurer.measure(
text = currentLayout.visibleText, // We measure the original for layout tap calc
text = editableText, // We measure editable text, not the hidden page-break sentinel
style = TextStyle(fontSize = 16.sp, color = Color.Black),
constraints = Constraints(maxWidth = editorWidth.toInt()),
density = density
)
val textHeight = measureResult.size.height.toFloat()
val textHeight = if (editableText.isEmpty()) 0f else measureResult.size.height.toFloat()
if (localTapOffset.y <= textHeight) {
if (editableText.isNotEmpty() && localTapOffset.y <= textHeight) {
var localIndex = measureResult.getOffsetForPosition(localTapOffset)
if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR && localIndex >= vText.length) {
localIndex = vText.length - 1
}
localIndex = localIndex.coerceIn(0, editableText.length)
localTextFieldValue = localTextFieldValue.copy(selection = TextRange(localIndex + 1))
} else {
val gap = localTapOffset.y - textHeight
@ -1132,7 +1246,9 @@ class RichTextController(
val currentGlobal = globalTextFieldValue.annotatedString
val localAnnotatedRaw = localTextFieldValue.annotatedString
val localAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else AnnotatedString("")
val localEditableAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else AnnotatedString("")
val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR
val localAnnotated = localEditableAnnotated.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak)
val charBeforeSync = if (globalStart > 0) currentGlobal.text[globalStart - 1] else "START"
val charAfterSync = if (globalEnd < currentGlobal.length) currentGlobal.text[globalEnd] else "END"
@ -1197,7 +1313,7 @@ class RichTextController(
val reExtracted = newGlobalAnnotated.subSequence(
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
@ -1301,7 +1417,7 @@ class RichTextController(
activePageIndex = finalActiveLayout.pageIndex
val reExtracted = intermediateGlobal.subSequence(
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
@ -1351,7 +1467,7 @@ class RichTextController(
val reExtracted = newGlobalText.subSequence(
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
)
).withoutTrailingAndroidPageBreak()
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
@ -1400,4 +1516,4 @@ class RichTextController(
isSaving = false
}
}
}
}

View file

@ -545,8 +545,11 @@ class OpdsStreamDocumentWrapper(
private val client = com.aryan.reader.opds.OpdsRepository.sharedHttpClient.newBuilder()
.apply {
if (!catalog?.username.isNullOrBlank() && !catalog.password.isNullOrBlank()) {
authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(catalog.username, catalog.password))
val streamCatalog = catalog
val username = streamCatalog?.username
val password = streamCatalog?.password
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(username, password))
}
}
.build()
@ -580,10 +583,11 @@ class OpdsStreamDocumentWrapper(
}
}
val finalUrlTemplate = if (catalog != null && urlTemplate.startsWith("http")) {
val streamCatalog = catalog
val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) {
try {
val oldUrl = java.net.URL(urlTemplate)
val newUrl = java.net.URL(catalog.url)
val newUrl = java.net.URL(streamCatalog.url)
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
val newBase = "${newUrl.protocol}://${newUrl.authority}"
urlTemplate.replace(oldBase, newBase)

View file

@ -186,11 +186,13 @@ class TtsController(context: Context) : Player.Listener {
)
val textList = ArrayList(chunks.map { it.text })
val spokenTextList = ArrayList(chunks.map { it.spokenText.ifBlank { it.text } })
val cfiList = ArrayList(chunks.map { it.sourceCfi })
val offsetList = ArrayList(chunks.map { it.startOffsetInSource })
val args = Bundle().apply {
putStringArrayList(KEY_TEXT_CHUNKS, textList)
putStringArrayList(KEY_SPOKEN_TEXT_CHUNKS, spokenTextList)
putStringArrayList(KEY_SOURCE_CFIS, cfiList)
putIntegerArrayList(KEY_START_OFFSETS, offsetList)
putString(KEY_SPEAKER_ID, _ttsState.value.speakerId)

View file

@ -61,6 +61,7 @@ val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYB
const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SPOKEN_TEXT_CHUNKS = "KEY_SPOKEN_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
const val KEY_START_OFFSETS = "KEY_START_OFFSETS"
const val KEY_SPEAKER_ID = "KEY_SPEAKER_ID"
@ -205,6 +206,7 @@ class TtsPlaybackManager(
)
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
val spokenTexts = args.getStringArrayList(KEY_SPOKEN_TEXT_CHUNKS)
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
val bookTitle = args.getString(KEY_BOOK_TITLE)
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
@ -218,10 +220,23 @@ class TtsPlaybackManager(
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
chunks.mapIndexed { index, text ->
val safeOffset = offsets.getOrNull(index) ?: -1
TtsChunk(text, cfis[index], safeOffset)
val spokenText = spokenTexts?.getOrNull(index)?.ifBlank { text } ?: text
TtsChunk(
text = text,
sourceCfi = cfis[index],
startOffsetInSource = safeOffset,
spokenText = spokenText,
)
}
} else {
chunks.map { TtsChunk(it, "", -1) }
chunks.mapIndexed { index, text ->
TtsChunk(
text = text,
sourceCfi = "",
startOffsetInSource = -1,
spokenText = spokenTexts?.getOrNull(index)?.ifBlank { text } ?: text,
)
}
}
val authToken = args.getString(KEY_AUTH_TOKEN)
@ -337,7 +352,11 @@ class TtsPlaybackManager(
}
val slicedText = currentChunk.text.substring(relativeOffset)
val newChunk = currentChunk.copy(text = slicedText, startOffsetInSource = offset)
val newChunk = currentChunk.copy(
text = slicedText,
startOffsetInSource = offset,
spokenText = slicedText,
)
val mutableChunks = textChunks.toMutableList()
mutableChunks[currentIdx] = newChunk
@ -540,7 +559,8 @@ class TtsPlaybackManager(
"Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady"
)
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
val spokenText = firstChunk.spokenText.ifBlank { firstChunk.text }
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
@ -572,7 +592,7 @@ class TtsPlaybackManager(
if (id != null) chunkStreamIds[startAtIndex] = id
}
val pathToUse = streamUri ?: audioFile!!.absolutePath
val mediaItem = createMediaItem(serverText, pathToUse, startAtIndex, updatedChunk)
val mediaItem = createMediaItem(updatedChunk.text, pathToUse, startAtIndex, updatedChunk)
withContext(Dispatchers.Main) {
val prepStartTime = System.currentTimeMillis()
@ -589,7 +609,7 @@ class TtsPlaybackManager(
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = playWhenReady,
currentText = serverText,
currentText = updatedChunk.text,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
@ -618,6 +638,9 @@ class TtsPlaybackManager(
if (wordTimings.isNullOrEmpty()) {
return originalChunk
}
if (originalChunk.spokenText != originalChunk.text) {
return originalChunk.copy(timedWords = emptyList())
}
val timedWords = mutableListOf<TimedWord>()
var currentSearchIndex = 0
@ -823,7 +846,8 @@ class TtsPlaybackManager(
val prefetchStartTime = System.currentTimeMillis()
Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex")
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, nextChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
val spokenText = nextChunk.spokenText.ifBlank { nextChunk.text }
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms")
@ -842,7 +866,7 @@ class TtsPlaybackManager(
if ((audioFile != null || streamUri != null) && serverText != null) {
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
val pathToUse = streamUri ?: audioFile!!.absolutePath
val nextMediaItem = createMediaItem(serverText, pathToUse, targetIndex, updatedChunk)
val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk)
withContext(Dispatchers.Main) {
if (audioFile != null) {

View file

@ -707,6 +707,8 @@
<string name="menu_auto_scroll">Auto Scroll</string>
<!-- TTS = Text-to-Speech. -->
<string name="menu_tts_voice_settings">TTS Voice Settings</string>
<!-- TTS = Text-to-Speech. -->
<string name="menu_tts_word_replacements">TTS Word Replacements</string>
<!-- TTS = Text-to-Speech. Debug-only menu item. -->
<string name="menu_tts_settings_debug">TTS Settings (Debug)</string>
<string name="content_desc_navigate_slider">Navigate with slider</string>