Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
12a8c1fc30
214 changed files with 53372 additions and 4702 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
43
app/src/main/java/com/aryan/reader/TtsReplacementStore.kt
Normal file
43
app/src/main/java/com/aryan/reader/TtsReplacementStore.kt
Normal 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) }
|
||||
667
app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt
Normal file
667
app/src/main/java/com/aryan/reader/TtsWordReplacementsSheet.kt
Normal 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()}"
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/") }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
internal fun resolvePaginatedReconfigurationAnchor(
|
||||
currentPageLocator: Locator?,
|
||||
fallbackLocator: Locator?
|
||||
): Locator? = currentPageLocator ?: fallbackLocator
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue