Update v1.0.49 (#330)

* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE

* Refactored desktop reader screens and state management into specialized components

* Added image gallery to reader sidebar and refactored desktop PDF UI components

* Implemented EPUB image gallery

* Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic

* Standardized UI styling and refactored app navigation layout

* Implemented auto-hiding reader chrome and activity tracking in desktop

* Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support.

* Added PPTX support for desktop and refactored parsing into a shared module

* Implement paid AI features and account management for the desktop application.

* Implement AI Hub and enhanced Cloud TTS integration for Desktop

* Implement streaming support for AI definition and summarization features

* Implement support for password-protected PDFs and file actions in the desktop reader.

* Implement cloud synchronization for desktop using Firestore and Google Drive

* Implement PDF reflow and "Text View" for the desktop reader

* Refactor OPDS logic to use SharedOpdsController

* Optimize PDF tile rendering performance

* Implement two-page spread support for PDF pagination

* Implement two-page spread support for the PDF viewer

* Improved shared spread zoom in PDF viewer

* Improve PDF spread navigation with fling support and configurable page gaps

* Add brightness control to PDF and EPUB readers

* Refactor folder synchronization to use shared logic engine

* Implement safe string formatting and validation for localized resources

* Implement TTS chunk skip navigation

* Implement deep-linking and playback controls for TTS media sessions

* Implement start index for TTS playback

* Improve TTS navigation, prefetching, and notification duration reporting

* Implement TTS mini playback bar for background reading

* Implement multi-window reader support for the desktop application

* Improve desktop modal window management and visibility syncing

* Implement localized string support for Desktop and shared UI

* Implement language selection and persistence for Desktop

* Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml

* Implement localized banner messages and UI strings using resource-backed SharedText

* Implement compact badge styling for small book covers

* Refactor PDF native interaction and improve HTML import memory safety

* fix language persistence

* Refactor reader overflow menus to use section-based logic

* Refactor PDF layout remapping and improve text box interaction

* Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets

* Centralize PDF annotation export mapping and improve metadata handling

* Add support for threaded comments in PDF highlight annotations

* Flatten highlight comments into a single thread for PDF export and allow author editing

* Integrate page slider into reader chrome and persist toggle state

* Handle fragments and queries in EPUB chapter paths

* Implement dynamic, theme-aware coloring for the reader slider

* Implement customizable app-wide font preference

* Implement one-hand zoom gestures in the PDF viewer

* Implement File Information dialog for PDF and EPUB readers

* Bump version to 1.0.49 (53)

* Refactor PDF reader logic into modular components

* Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens

* Add option to use PDF filenames as display names

* Fix preservation of PDF filename display preference in library projection
This commit is contained in:
Aryan 2026-05-20 22:14:01 +05:30 committed by GitHub
parent dc5196526f
commit 9510293ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 37538 additions and 12460 deletions

View file

@ -17,3 +17,15 @@ internal actual fun SharedReaderModalLayer(
content()
}
}
internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean {
return false
}
@Composable
actual fun SharedReaderModalOwnerWindowProvider(
ownerWindow: Any?,
content: @Composable () -> Unit
) {
content()
}

View file

@ -73,6 +73,7 @@ sealed interface AppAction {
data class AppTextDimFactorLightChanged(val factor: Float) : AppAction
data class AppTextDimFactorDarkChanged(val factor: Float) : AppAction
data class AppSeedColorChanged(val color: Color?) : AppAction
data class AppFontPreferenceChanged(val preference: AppFontPreference) : AppAction
data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction
data class CustomAppThemeDeleted(val themeId: String) : AppAction
data class SyncEnabledChanged(val enabled: Boolean) : AppAction

View file

@ -4,11 +4,124 @@ import androidx.compose.ui.graphics.Color
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
import com.aryan.reader.shared.reader.ReaderSettings
data class SharedText(
val name: String,
val fallback: String,
val args: List<Any?> = emptyList(),
val quantity: Int? = null,
val fallbackOther: String = fallback
) {
fun fallbackMessage(): String {
val template = if (quantity == null || quantity == 1) fallback else fallbackOther
return formatSharedTextFallback(template, args)
}
companion object {
fun string(name: String, fallback: String, vararg args: Any?): SharedText {
return SharedText(name = name, fallback = fallback, args = args.toList())
}
fun quantity(
name: String,
quantity: Int,
fallbackOne: String,
fallbackOther: String,
vararg args: Any?
): SharedText {
return SharedText(
name = name,
fallback = fallbackOne,
args = args.toList(),
quantity = quantity,
fallbackOther = fallbackOther
)
}
}
}
data class BannerMessage(
val message: String,
val isError: Boolean = false,
val isPersistent: Boolean = false
)
val isPersistent: Boolean = false,
val text: SharedText? = null
) {
companion object {
fun localized(
text: SharedText,
isError: Boolean = false,
isPersistent: Boolean = false
): BannerMessage {
return BannerMessage(
message = text.fallbackMessage(),
isError = isError,
isPersistent = isPersistent,
text = text
)
}
fun string(
name: String,
fallback: String,
vararg args: Any?,
isError: Boolean = false,
isPersistent: Boolean = false
): BannerMessage {
return localized(
text = SharedText.string(name, fallback, *args),
isError = isError,
isPersistent = isPersistent
)
}
fun quantity(
name: String,
quantity: Int,
fallbackOne: String,
fallbackOther: String,
vararg args: Any?,
isError: Boolean = false,
isPersistent: Boolean = false
): BannerMessage {
return localized(
text = SharedText.quantity(name, quantity, fallbackOne, fallbackOther, *args),
isError = isError,
isPersistent = isPersistent
)
}
}
}
internal fun formatSharedTextFallback(template: String, args: List<Any?>): String {
if (args.isEmpty()) return template.replace("%%", "%")
val percentPlaceholder = "\u0000PERCENT\u0000"
var sequentialIndex = 0
var formatted = template.replace("%%", percentPlaceholder)
formatted = Regex("%(\\d+)\\$[-+#, .(]*\\d*(?:\\.\\d+)?[a-zA-Z]").replace(formatted) { match ->
val argIndex = match.groupValues[1].toIntOrNull()?.minus(1)
args.getOrNull(argIndex ?: -1).toSharedTextArgument()
}
formatted = Regex("%[-+#, .(]*\\d*(?:\\.\\d+)?[a-zA-Z]").replace(formatted) {
args.getOrNull(sequentialIndex++).toSharedTextArgument()
}
return formatted.replace(percentPlaceholder, "%")
}
private fun Any?.toSharedTextArgument(): String {
return when (this) {
null -> ""
is Float -> trimSharedTextTrailingZeroDecimal(toString())
is Double -> trimSharedTextTrailingZeroDecimal(toString())
else -> toString()
}
}
private fun trimSharedTextTrailingZeroDecimal(value: String): String {
return value.removeSuffix(".0")
}
data class ImportResult(
val uriString: String,
@ -42,6 +155,44 @@ enum class AppContrastOption(val value: Double) {
HIGH(1.0)
}
enum class AppFontPreferenceKind {
SYSTEM,
SERIF,
SANS_SERIF,
MONOSPACE,
CUSTOM
}
data class AppFontPreference(
val kind: AppFontPreferenceKind = AppFontPreferenceKind.SYSTEM,
val customFontId: String? = null
) {
fun sanitized(): AppFontPreference {
return when (kind) {
AppFontPreferenceKind.CUSTOM -> customFontId
?.takeIf { it.isNotBlank() }
?.let { copy(customFontId = it) }
?: System
else -> copy(customFontId = null)
}
}
fun referencesCustomFont(fontId: String): Boolean {
return kind == AppFontPreferenceKind.CUSTOM && customFontId == fontId
}
companion object {
val System = AppFontPreference(AppFontPreferenceKind.SYSTEM)
val Serif = AppFontPreference(AppFontPreferenceKind.SERIF)
val SansSerif = AppFontPreference(AppFontPreferenceKind.SANS_SERIF)
val Monospace = AppFontPreference(AppFontPreferenceKind.MONOSPACE)
fun custom(customFontId: String): AppFontPreference {
return AppFontPreference(AppFontPreferenceKind.CUSTOM, customFontId).sanitized()
}
}
}
data class CustomAppTheme(
val id: String,
val name: String,
@ -113,11 +264,13 @@ data class SharedReaderScreenState(
val showExternalFileSavePromptFor: String? = null,
val externalFileBehavior: String = "ASK",
val useStrictFileFilter: Boolean = false,
val usePdfFileNameAsDisplayName: Boolean = false,
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
val appTextDimFactorLight: Float = 1.0f,
val appTextDimFactorDark: Float = 1.0f,
val appSeedColor: Color? = null,
val appFontPreference: AppFontPreference = AppFontPreference.System,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),

View file

@ -68,6 +68,33 @@ object SharedFileCapabilities {
"text/x-log"
)
val androidFilePickerMimeTypes: List<String> = listOf(
"application/pdf",
"application/epub+zip",
"application/x-mobipocket-ebook",
"application/vnd.amazon.ebook",
"application/vnd.amazon.mobi8-ebook",
"text/markdown",
"text/x-markdown",
"text/plain",
"text/html",
"application/xhtml+xml",
"application/x-fictionbook+xml",
"application/x-zip-compressed-fb2",
"application/zip",
"application/vnd.comicbook+zip",
"application/x-cbz",
"application/vnd.comicbook-rar",
"application/x-cbr",
"application/x-rar-compressed",
"application/x-cb7",
"application/x-7z-compressed",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/x-vnd.oasis.opendocument.text-flat-xml"
) + manualOnlyReaderMimeTypes
val all: List<FileTypeCapability> = listOf(
FileTypeCapability(
type = FileType.EPUB,
@ -165,7 +192,7 @@ object SharedFileCapabilities {
displayName = "PPTX",
extensions = setOf("pptx"),
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
desktopSurface = null
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
)
)
@ -217,6 +244,48 @@ object SharedFileCapabilities {
return fileTypeForEffectiveName(effectiveName)
}
fun resolveFileTypeForMetadata(fileName: String?, mimeType: String?): FileType? {
val normalizedMimeType = mimeType
?.substringBefore(';')
?.trim()
?.lowercase()
return when (normalizedMimeType) {
"application/vnd.oasis.opendocument.text" -> FileType.ODT
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
when {
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2
else -> null
}
}
"application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> {
if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null
}
"application/x-cb7", "application/x-7z-compressed" -> {
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
}
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
"application/x-mobipocket-ebook",
"application/vnd.amazon.ebook",
"application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
"text/markdown", "text/x-markdown" -> FileType.MD
"text/html", "application/xhtml+xml" -> FileType.HTML
"text/plain" -> resolveFileTypeForName(fileName) ?: FileType.TXT
else -> {
if (normalizedMimeType != null && normalizedMimeType in manualOnlyReaderMimeTypes) {
FileType.HTML
} else {
resolveFileTypeForName(fileName)
}
}
}
}
fun isCodeOrDataFileName(fileName: String): Boolean {
return fileName.normalizedFileName()
.withTransparentTextSuffix()
@ -228,7 +297,11 @@ object SharedFileCapabilities {
}
fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
val normalized = mimeType?.lowercase() ?: return false
val normalized = mimeType
?.substringBefore(';')
?.trim()
?.lowercase()
?: return false
return normalized in manualOnlyReaderMimeTypes
}
@ -272,6 +345,12 @@ object SharedFileCapabilities {
}
}
fun readableTypesFor(platform: ReaderPlatform, surface: ReaderFeatureSurface): Set<FileType> {
return all.mapNotNullTo(mutableSetOf()) { capability ->
capability.type.takeIf { capability.surfaceFor(platform) == surface }
}
}
fun syncableTypesFor(platform: ReaderPlatform): Set<FileType> {
return all.mapNotNullTo(mutableSetOf()) { capability ->
capability.type.takeIf { capability.syncEligible && capability.surfaceFor(platform) != null }

View file

@ -8,19 +8,17 @@ enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, PPTX, UNKNOWN
}
val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX)
val PDF_VIEWER_FILE_TYPES: Set<FileType>
get() = SharedFileCapabilities.readableTypesFor(
ReaderPlatform.ANDROID,
ReaderFeatureSurface.PDF_VIEWER
)
val EPUB_READER_FILE_TYPES = setOf(
FileType.EPUB,
FileType.MOBI,
FileType.MD,
FileType.TXT,
FileType.HTML,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
)
val EPUB_READER_FILE_TYPES: Set<FileType>
get() = SharedFileCapabilities.readableTypesFor(
ReaderPlatform.ANDROID,
ReaderFeatureSurface.EPUB_READER
)
enum class AddBooksSource {
UNSHELVED,
@ -141,7 +139,8 @@ data class LibraryState(
val filters: LibraryFilters = LibraryFilters(),
val selectedBookIds: Set<String> = emptySet(),
val recentLimit: Int = 12,
val message: String? = null
val message: String? = null,
val messageText: SharedText? = null
)
data class HomeScreenModel(

View file

@ -65,7 +65,13 @@ object SharedLibraryEditor {
state = state.copy(
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in selected },
selectedBookIds = emptySet(),
bannerMessage = BannerMessage("Removed ${selected.size} book(s) from the library.")
bannerMessage = BannerMessage.quantity(
"banner_books_removed_library",
selected.size,
"%1\$d book removed from library.",
"%1\$d books removed from library.",
selected.size
)
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs.filterNot { it.bookId in selected }
@ -81,7 +87,13 @@ object SharedLibraryEditor {
): SharedLibraryMutationResult? {
val trimmed = cleanShelfName(name) ?: return null
return SharedLibraryMutationResult(
state = state.copy(bannerMessage = BannerMessage("Created shelf \"$trimmed\".")),
state = state.copy(
bannerMessage = BannerMessage.string(
"banner_shelf_created",
"Created shelf \"%1\$s\".",
trimmed
)
),
shelfRecords = shelfRecords + ShelfRecord(id = "shelf_$nowMillis", name = trimmed),
shelfRefs = shelfRefs
)
@ -102,7 +114,13 @@ object SharedLibraryEditor {
if (cleanedRules.isEmpty()) return null
val cleanedDefinition = definition.copy(rules = cleanedRules)
return SharedLibraryMutationResult(
state = state.copy(bannerMessage = BannerMessage("Created smart shelf \"$trimmed\".")),
state = state.copy(
bannerMessage = BannerMessage.string(
"banner_smart_shelf_created",
"Created smart shelf \"%1\$s\".",
trimmed
)
),
shelfRecords = shelfRecords + ShelfRecord(
id = "smart_$nowMillis",
name = trimmed,
@ -122,7 +140,13 @@ object SharedLibraryEditor {
): SharedLibraryMutationResult? {
val trimmed = cleanShelfName(name) ?: return null
return SharedLibraryMutationResult(
state = state.copy(bannerMessage = BannerMessage("Renamed shelf to \"$trimmed\".")),
state = state.copy(
bannerMessage = BannerMessage.string(
"banner_shelf_renamed",
"Renamed shelf to \"%1\$s\".",
trimmed
)
),
shelfRecords = shelfRecords.map { if (it.id == shelf.id) it.copy(name = trimmed) else it },
shelfRefs = shelfRefs
)
@ -135,7 +159,13 @@ object SharedLibraryEditor {
shelf: Shelf
): SharedLibraryMutationResult {
return SharedLibraryMutationResult(
state = state.copy(bannerMessage = BannerMessage("Deleted shelf \"${shelf.name}\".")),
state = state.copy(
bannerMessage = BannerMessage.string(
"banner_shelf_deleted",
"Deleted shelf \"%1\$s\".",
shelf.name
)
),
shelfRecords = shelfRecords.filterNot { it.id == shelf.id },
shelfRefs = shelfRefs.filterNot { it.shelfId == shelf.id }
)
@ -170,7 +200,14 @@ object SharedLibraryEditor {
} else {
state.libraryFilters
},
bannerMessage = BannerMessage("Removed folder \"${folder.name}\" and ${folderBookIds.size} book(s) from the app.")
bannerMessage = BannerMessage.quantity(
"banner_folder_removed_with_book_count",
folderBookIds.size,
"Removed folder \"%1\$s\" and %2\$d book from the app.",
"Removed folder \"%1\$s\" and %2\$d books from the app.",
folder.name,
folderBookIds.size
)
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs.filterNot { it.bookId in folderBookIds }
@ -211,7 +248,13 @@ object SharedLibraryEditor {
return SharedLibraryMutationResult(
state = state.copy(
selectedBookIds = emptySet(),
bannerMessage = BannerMessage("Added ${additions.size} book(s) to shelf.")
bannerMessage = BannerMessage.quantity(
"banner_books_added_to_shelf",
additions.size,
"%1\$d book added to shelf.",
"%1\$d books added to shelf.",
additions.size
)
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs + additions
@ -247,7 +290,14 @@ object SharedLibraryEditor {
rawLibraryBooks = books,
allTags = allTags,
selectedBookIds = emptySet(),
bannerMessage = BannerMessage("Tagged ${selected.size} book(s) with \"${tag.name}\".")
bannerMessage = BannerMessage.quantity(
"banner_books_tagged_with_tag",
selected.size,
"%1\$d book tagged with \"%2\$s\".",
"%1\$d books tagged with \"%2\$s\".",
selected.size,
tag.name
)
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs
@ -265,7 +315,11 @@ object SharedLibraryEditor {
state = state.copy(
rawLibraryBooks = state.rawLibraryBooks.map { if (it.id == updated.id) updated.copy(timestamp = nowMillis) else it },
allTags = (state.allTags + updated.tags).distinctBy { it.id }.sortedBy { it.name.lowercase() },
bannerMessage = BannerMessage("Updated \"${updated.cardTitle()}\".")
bannerMessage = BannerMessage.string(
"banner_book_updated",
"Updated \"%1\$s\".",
updated.cardTitle()
)
),
shelfRecords = shelfRecords,
shelfRefs = shelfRefs

View file

@ -33,13 +33,27 @@ class LibraryProjector {
existingBookIds = state.books.mapTo(mutableSetOf()) { it.id },
platform = ReaderPlatform.DESKTOP
)
val messageText = when {
plan.importedCount > 0 -> SharedText.quantity(
"desktop_imported_file_count_reader_support_later",
plan.importedCount,
"Imported %1\$d file. Reader support comes later.",
"Imported %1\$d files. Reader support comes later.",
plan.importedCount
)
plan.unsupportedCount > 0 -> SharedText.string(
"desktop_no_supported_files_imported",
"No supported files were imported."
)
else -> SharedText.string(
"banner_duplicate_files_already_in_library",
"Those files are already in the desktop library."
)
}
return state.copy(
books = plan.importedBooks + state.books,
message = when {
plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s). Reader support comes later."
plan.unsupportedCount > 0 -> "No supported files were imported."
else -> "Those files are already in the desktop library."
}
message = messageText.fallbackMessage(),
messageText = messageText
)
}

View file

@ -326,15 +326,26 @@ fun SharedReaderScreenState.withImportedFiles(
platform = ReaderPlatform.DESKTOP,
nowMillis = now
)
val banner = when {
plan.importedCount > 0 -> BannerMessage.quantity(
"desktop_imported_file_count",
plan.importedCount,
"Imported %1\$d file.",
"Imported %1\$d files.",
plan.importedCount
)
plan.unsupportedCount > 0 -> BannerMessage.string(
"desktop_no_supported_files_imported",
"No supported files were imported."
)
else -> BannerMessage.string(
"banner_duplicate_files_already_in_library",
"Those files are already in the library."
)
}
return copy(
rawLibraryBooks = plan.importedBooks + rawLibraryBooks,
bannerMessage = BannerMessage(
when {
plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s)."
plan.unsupportedCount > 0 -> "No supported files were imported."
else -> "Those files are already in the library."
}
)
bannerMessage = banner
)
}

View file

@ -301,6 +301,10 @@ object LocalFolderSyncEngine {
.filter { it.sourceFolder == folderRoot && !it.path.isNullOrBlank() }
.associateBy { it.path.orEmpty() }
.toMutableMap()
val scannedFilesByPath = files
.asSequence()
.filter { it.type in allowedTypes && it.path.isNotBlank() }
.associateBy { it.path }
val legacyItemsByName = booksById.values
.asSequence()
.filter { it.sourceFolder == folderRoot }
@ -318,6 +322,28 @@ object LocalFolderSyncEngine {
foundBookIds += stableId
var existing = booksById[stableId]?.takeIf { it.sourceFolder == folderRoot }
if (existing != null && existing.path != file.path) {
val collidedFile = existing.path?.let(scannedFilesByPath::get)
val collidedStableId = collidedFile?.stableBookId
if (
collidedFile != null &&
collidedStableId != null &&
collidedStableId != stableId &&
collidedStableId != existing.id &&
collidedStableId !in booksById
) {
val oldId = existing.id
val migratedBook = existing.copy(id = collidedStableId).withScannedFile(collidedFile)
booksById.remove(oldId)
booksById[collidedStableId] = migratedBook
folderBooksByPath[collidedFile.path] = migratedBook
idMigrations[oldId] = collidedStableId
legacyItemsByName[existing.displayName]?.remove(existing)
existing = booksById[stableId]?.takeIf { it.sourceFolder == folderRoot }
stats = stats.copy(migratedBooks = stats.migratedBooks + 1)
}
}
if (existing == null) {
val migrated = folderBooksByPath[file.path]?.takeIf { it.id != stableId }
?: legacyItemsByName[file.name]?.firstOrNull { it.id != stableId }

View file

@ -70,6 +70,27 @@ enum class ReaderTexture(val id: String, val displayName: String, val assetPath:
const val ReaderTextureFilePrefix = "file:"
val ReaderTextureImportExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp")
fun normalizeReaderTextureExtension(extension: String?): String? {
val normalized = extension?.trim()?.lowercase() ?: return null
return when (normalized) {
"jpeg", "jpg" -> "jpg"
"png", "webp", "gif", "bmp" -> normalized
else -> null
}
}
fun readerTextureMimeTypeForExtension(extension: String): String {
return when (extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"webp" -> "image/webp"
"gif" -> "image/gif"
"bmp" -> "image/bmp"
else -> "image/png"
}
}
data class ReaderTheme(
val id: String,
val name: String,

View file

@ -46,7 +46,9 @@ data class ReaderAiByokSettings(
val recapModel: String = "",
val ttsModel: String = "",
val hideReaderAiFeatures: Boolean = false,
val ttsSpeakerId: String = DEFAULT_CLOUD_TTS_SPEAKER_ID
val ttsSpeakerId: String = DEFAULT_CLOUD_TTS_SPEAKER_ID,
val serverBackedReaderAiFeatures: Boolean = false,
val serverBackedCloudTts: Boolean = false
) {
fun sanitized(): ReaderAiByokSettings {
val knownTextModelIds = ReaderAiModelOptions.mapTo(mutableSetOf()) { it.id }
@ -83,8 +85,9 @@ data class ReaderAiByokSettings(
}
val hasAnyAiKey: Boolean get() = geminiKey.isNotBlank() || groqKey.isNotBlank()
val areReaderAiFeaturesAvailable: Boolean get() = !hideReaderAiFeatures && hasAnyAiKey
val isCloudTtsAvailable: Boolean get() = geminiKey.isNotBlank() && ttsModel == GEMINI_CLOUD_TTS_MODEL_ID
val areReaderAiFeaturesAvailable: Boolean get() = !hideReaderAiFeatures && (serverBackedReaderAiFeatures || hasAnyAiKey)
val isByokCloudTtsAvailable: Boolean get() = geminiKey.isNotBlank() && ttsModel == GEMINI_CLOUD_TTS_MODEL_ID
val isCloudTtsAvailable: Boolean get() = serverBackedCloudTts || isByokCloudTtsAvailable
}
val ReaderAiModelOptions = listOf(

View file

@ -18,7 +18,8 @@ data class SummarizationResult(
val summary: String? = null,
val error: String? = null,
val cost: Double? = null,
val freeRemaining: Int? = null
val freeRemaining: Int? = null,
val isCacheHit: Boolean = false
)
data class RecapResult(

View file

@ -49,7 +49,30 @@ interface SyncAdapter {
interface AiAdapter {
val isAvailable: Boolean
suspend fun define(text: String, context: String? = null): AiDefinitionResult
suspend fun defineStreaming(
text: String,
context: String? = null,
onUpdate: (String) -> Unit
): AiDefinitionResult {
val result = define(text, context)
result.definition?.takeIf { it.isNotBlank() }?.let(onUpdate)
return result
}
suspend fun summarize(text: String): SummarizationResult
suspend fun summarizeStreaming(
text: String,
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
onUpdate: (String) -> Unit
): SummarizationResult {
val result = summarize(text)
if (result.cost != null || result.freeRemaining != null) {
onUsageReceived(result.cost, result.freeRemaining)
}
result.summary?.takeIf { it.isNotBlank() }?.let(onUpdate)
return result
}
suspend fun recap(textBeforeCurrentLocation: String): RecapResult
}

View file

@ -143,6 +143,7 @@ enum class SharedSettingsAction {
TABS_TOGGLE,
RECENT_LIMIT,
STRICT_FILE_FILTER,
PDF_FILENAME_DISPLAY_NAME,
EXTERNAL_FILE_BEHAVIOR,
SCREEN_CAPTURE_PROTECTION,
CUSTOM_FONTS,
@ -579,6 +580,7 @@ data class SharedSettingsHubModel(
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION,
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
SharedSettingsAction.STRICT_FILE_FILTER,
SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME,
SharedSettingsAction.TABS_TOGGLE,
SharedSettingsAction.HIDE_READER_AI,
SharedSettingsAction.CLEAR_BOOK_CACHE,
@ -643,6 +645,7 @@ data class SharedSettingsHubInput(
val isDebugBuild: Boolean = false,
val isSignedIn: Boolean = false,
val isProUser: Boolean = false,
val accountAvailable: Boolean = true,
val syncAvailable: Boolean = true,
val folderSyncAvailable: Boolean = true,
val aiSettingsAvailable: Boolean = true,
@ -655,14 +658,18 @@ data class SharedSettingsHubInput(
val includeRecentLimit: Boolean = true,
val includeCustomFonts: Boolean = true,
val includeStrictFileFilter: Boolean = true,
val includePdfFileNameDisplayName: Boolean = false,
val includeReaderTabs: Boolean = true,
val includeHideReaderAi: Boolean = true,
val includeCloudLocalDataClear: Boolean = false,
val supportProjectAvailable: Boolean = true,
val languageTitle: String = "Language",
val languageSummary: String = "Choose the app language",
val isTabsEnabled: Boolean = true,
val isSyncEnabled: Boolean = false,
val isFolderSyncEnabled: Boolean = false,
val useStrictFileFilter: Boolean = false,
val usePdfFileNameAsDisplayName: Boolean = false,
val isScreenCaptureProtectionEnabled: Boolean = false,
val hideReaderAi: Boolean = false
)
@ -743,7 +750,7 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
SharedSettingsSectionModel(
section = SharedSettingsSection.SYNC_ACCOUNTS,
items = buildList {
if (input.syncAvailable && input.featurePolicy.aiAndCloud) {
if (input.accountAvailable && input.featurePolicy.aiAndCloud) {
if (input.isSignedIn) {
add(
SharedSettingsItemModel(
@ -762,6 +769,8 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
)
)
}
}
if (input.syncAvailable && input.featurePolicy.aiAndCloud) {
add(
SharedSettingsItemModel(
action = SharedSettingsAction.CLOUD_SYNC,
@ -888,8 +897,8 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
add(
SharedSettingsItemModel(
action = SharedSettingsAction.LANGUAGE,
title = "Language",
summary = "Choose the app language"
title = input.languageTitle,
summary = input.languageSummary
)
)
}
@ -924,6 +933,21 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
)
)
}
if (input.includePdfFileNameDisplayName) {
add(
SharedSettingsItemModel(
action = SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME,
title = "Use PDF filenames",
summary = if (input.usePdfFileNameAsDisplayName) {
"PDF lists and tabs show filenames instead of embedded titles."
} else {
"PDF lists and tabs prefer embedded titles when available."
},
kind = SharedSettingsItemKind.TOGGLE,
checked = input.usePdfFileNameAsDisplayName
)
)
}
if (input.includeReaderTabs) {
add(
SharedSettingsItemModel(

View file

@ -4,6 +4,7 @@ data class SharedFeaturePolicy(
val networkAccess: Boolean = true,
val opdsCatalogs: Boolean = networkAccess,
val aiAndCloud: Boolean = networkAccess,
val byokAi: Boolean = false,
val externalLookup: Boolean = networkAccess,
val projectLinks: Boolean = networkAccess,
val googleFontsDownload: Boolean = networkAccess
@ -14,6 +15,7 @@ data class SharedFeaturePolicy(
networkAccess = false,
opdsCatalogs = false,
aiAndCloud = false,
byokAi = true,
externalLookup = false,
projectLinks = false,
googleFontsDownload = false

View file

@ -43,6 +43,7 @@ data class SharedLibrarySnapshot(
val appTextDimFactorLight: Float = 1.0f,
val appTextDimFactorDark: Float = 1.0f,
val appSeedColor: Color? = null,
val appFontPreference: AppFontPreference = AppFontPreference.System,
val customAppThemes: List<CustomAppTheme> = emptyList(),
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
@ -53,7 +54,7 @@ data class SharedLibrarySnapshot(
)
object SharedLibrarySnapshotJson {
private const val SCHEMA_VERSION = 19
private const val SCHEMA_VERSION = 20
private val json = Json {
prettyPrint = true
@ -100,6 +101,10 @@ object SharedLibrarySnapshotJson {
?: root.float("appTextDimFactor")
?: 1.0f,
appSeedColor = root.int("appSeedColor")?.let { Color(it) },
appFontPreference = root["appFontPreference"]
?.takeUnless { it is JsonNull }
?.asAppFontPreferenceOrNull()
?: AppFontPreference.System,
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion),
pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"]
@ -147,6 +152,7 @@ object SharedLibrarySnapshotJson {
"appTextDimFactorLight" to JsonPrimitive(snapshot.appTextDimFactorLight),
"appTextDimFactorDark" to JsonPrimitive(snapshot.appTextDimFactorDark),
"appSeedColor" to snapshot.appSeedColor.asJson(),
"appFontPreference" to snapshot.appFontPreference.sanitized().toJsonObject(),
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
"readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(),
"pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(),
@ -343,6 +349,17 @@ private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? {
)
}
private fun JsonElement.asAppFontPreferenceOrNull(): AppFontPreference? {
val obj = runCatching { jsonObject }.getOrNull() ?: return null
val kind = obj.string("kind")
?.let { runCatching { AppFontPreferenceKind.valueOf(it) }.getOrNull() }
?: return null
return AppFontPreference(
kind = kind,
customFontId = obj.string("customFontId")
).sanitized()
}
private fun BookItem.toJsonObject(): JsonObject {
return JsonObject(
mapOf(
@ -449,6 +466,16 @@ private fun CustomAppTheme.toJsonObject(): JsonObject {
)
}
private fun AppFontPreference.toJsonObject(): JsonObject {
val sanitized = sanitized()
return JsonObject(
mapOf(
"kind" to JsonPrimitive(sanitized.kind.name),
"customFontId" to sanitized.customFontId.asJson()
)
)
}
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
@ -510,6 +537,10 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
"pdfPageNumberOverlayVisible",
defaults.pdfPageNumberOverlayVisible
),
pdfFirstPageStandaloneInSpread = obj.boolean(
"pdfFirstPageStandaloneInSpread",
defaults.pdfFirstPageStandaloneInSpread
),
seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation),
chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier
)
@ -652,6 +683,7 @@ private fun ReaderSettings?.asJson(): JsonElement {
"pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name),
"pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible),
"pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible),
"pdfFirstPageStandaloneInSpread" to JsonPrimitive(settings.pdfFirstPageStandaloneInSpread),
"seamlessChapterNavigation" to JsonPrimitive(settings.seamlessChapterNavigation),
"chapterTurnDragMultiplier" to JsonPrimitive(settings.chapterTurnDragMultiplier)
)

View file

@ -76,6 +76,7 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
is AppAction.AppTextDimFactorLightChanged -> copy(appTextDimFactorLight = action.factor.coerceIn(0.3f, 1.0f))
is AppAction.AppTextDimFactorDarkChanged -> copy(appTextDimFactorDark = action.factor.coerceIn(0.3f, 1.0f))
is AppAction.AppSeedColorChanged -> copy(appSeedColor = action.color)
is AppAction.AppFontPreferenceChanged -> copy(appFontPreference = action.preference.sanitized())
is AppAction.CustomAppThemeAdded -> {
val updatedThemes = customAppThemes.filterNot { it.id == action.theme.id } + action.theme
copy(customAppThemes = updatedThemes, appSeedColor = action.theme.seedColor)

View file

@ -2,6 +2,12 @@ package com.aryan.reader.shared.opds
class SharedOpdsController(
private val repository: SharedOpdsRepository,
private val feedLoadErrorMessage: (Throwable) -> String = { error ->
"Failed to load feed: ${error.message ?: "unknown error"}"
},
private val searchErrorMessage: (Throwable) -> String = { error ->
"Failed to search catalog: ${error.message ?: "unknown error"}"
},
private val idFactory: () -> String
) {
private val urlStack = mutableListOf<String>()
@ -9,6 +15,8 @@ class SharedOpdsController(
var state: SharedOpdsScreenState = SharedOpdsScreenState(catalogs = repository.loadCatalogs())
private set
fun hasFeedHistory(): Boolean = urlStack.size > 1
fun reloadCatalogs(): SharedOpdsScreenState {
state = state.copy(catalogs = repository.loadCatalogs())
return state
@ -100,7 +108,7 @@ class SharedOpdsController(
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
}
}.getOrElse { error ->
state = state.copy(isLoading = false, errorMessage = "Failed to search catalog: ${error.message}")
state = state.copy(isLoading = false, errorMessage = searchErrorMessage(error))
emit(state)
return
}
@ -112,6 +120,11 @@ class SharedOpdsController(
return state
}
fun setErrorMessage(errorMessage: String?): SharedOpdsScreenState {
state = state.copy(errorMessage = errorMessage)
return state
}
fun updateDownloadState(entryId: String, downloadState: SharedOpdsDownloadState?): SharedOpdsScreenState {
val nextMap = if (downloadState == null) {
state.downloadingState - entryId
@ -154,7 +167,7 @@ class SharedOpdsController(
}.onFailure { error ->
state = state.copy(
isLoading = false,
errorMessage = "Failed to load feed: ${error.message ?: "unknown error"}"
errorMessage = feedLoadErrorMessage(error)
)
}
emit(state)

View file

@ -38,6 +38,16 @@ data class PdfPageBounds(
val bottom: Float
)
@Serializable
data class SharedPdfAnnotationComment(
val id: String,
val parentId: String? = null,
val author: String = "",
val contents: String = "",
val createdAt: Long = 0L,
val modifiedAt: Long = 0L
)
@Serializable
data class SharedPdfAnnotation(
val id: String,
@ -49,6 +59,7 @@ data class SharedPdfAnnotation(
val boundsList: List<PdfPageBounds> = emptyList(),
val text: String = "",
val note: String? = null,
val comments: List<SharedPdfAnnotationComment> = emptyList(),
val colorArgb: Int,
val backgroundArgb: Int = 0x00FFFFFF,
val strokeWidth: Float = 2f,

View file

@ -0,0 +1,131 @@
package com.aryan.reader.shared.pdf
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderSettings
object PdfSpreadLayout {
fun isTwoPageSpreadEnabled(settings: ReaderSettings): Boolean {
return settings.pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE
}
fun normalizePageIndex(
pageIndex: Int,
pageCount: Int,
settings: ReaderSettings
): Int {
if (pageCount <= 0) return 0
val clamped = pageIndex.coerceIn(0, pageCount - 1)
if (!isTwoPageSpreadEnabled(settings)) return clamped
if (!settings.pdfFirstPageStandaloneInSpread) {
return (clamped - (clamped % 2)).coerceIn(0, pageCount - 1)
}
if (clamped == 0) return 0
val adjusted = clamped - 1
return (1 + adjusted - (adjusted % 2)).coerceIn(0, pageCount - 1)
}
fun visiblePageIndices(
pageIndex: Int,
pageCount: Int,
settings: ReaderSettings
): List<Int> {
if (pageCount <= 0) return emptyList()
val start = normalizePageIndex(pageIndex, pageCount, settings)
if (!isTwoPageSpreadEnabled(settings)) return listOf(start)
if (settings.pdfFirstPageStandaloneInSpread && start == 0) return listOf(0)
return listOf(start, start + 1).filter { it in 0 until pageCount }
}
fun spreadStartPageIndices(
pageCount: Int,
settings: ReaderSettings
): List<Int> {
if (pageCount <= 0) return emptyList()
if (!isTwoPageSpreadEnabled(settings)) return (0 until pageCount).toList()
val starts = mutableListOf<Int>()
var current = 0
while (current in 0 until pageCount && current !in starts) {
starts += current
val next = nextPageIndex(current, pageCount, settings)
if (next <= current) break
current = next
}
return starts
}
fun canGoPrevious(
pageIndex: Int,
pageCount: Int,
settings: ReaderSettings
): Boolean {
if (pageCount <= 1) return false
return previousPageIndex(pageIndex, pageCount, settings) < normalizePageIndex(pageIndex, pageCount, settings)
}
fun canGoNext(
pageIndex: Int,
pageCount: Int,
settings: ReaderSettings
): Boolean {
if (pageCount <= 1) return false
return nextPageIndex(pageIndex, pageCount, settings) > normalizePageIndex(pageIndex, pageCount, settings)
}
fun previousPageIndex(
pageIndex: Int,
pageCount: Int,
settings: ReaderSettings
): Int {
if (pageCount <= 0) return 0
val current = normalizePageIndex(pageIndex, pageCount, settings)
if (!isTwoPageSpreadEnabled(settings)) {
return (current - 1).coerceIn(0, pageCount - 1)
}
val target = if (settings.pdfFirstPageStandaloneInSpread && current <= 1) {
0
} else {
current - 2
}
return normalizePageIndex(target, pageCount, settings)
}
fun nextPageIndex(
pageIndex: Int,
pageCount: Int,
settings: ReaderSettings
): Int {
if (pageCount <= 0) return 0
val current = normalizePageIndex(pageIndex, pageCount, settings)
if (!isTwoPageSpreadEnabled(settings)) {
return (current + 1).coerceIn(0, pageCount - 1)
}
val target = if (settings.pdfFirstPageStandaloneInSpread && current == 0) {
1
} else {
current + 2
}
return normalizePageIndex(target, pageCount, settings)
}
fun pageRangeLabel(
pageIndex: Int,
pageCount: Int,
settings: ReaderSettings
): String {
val pages = visiblePageIndices(pageIndex, pageCount.coerceAtLeast(1), settings).ifEmpty { listOf(0) }
val first = pages.first() + 1
val last = pages.last() + 1
return if (first == last) "$first" else "$first-$last"
}
fun progressPercent(
pageIndex: Int,
pageCount: Int,
settings: ReaderSettings
): Float {
if (pageCount <= 0) return 0f
val visibleEnd = visiblePageIndices(pageIndex, pageCount, settings).lastOrNull()
?: normalizePageIndex(pageIndex, pageCount, settings)
return ((visibleEnd + 1).toFloat() / pageCount.coerceAtLeast(1)) * 100f
}
}

View file

@ -0,0 +1,321 @@
package com.aryan.reader.shared.pdf
import kotlin.math.sqrt
private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader"
data class SharedPdfAnnotationExportPayload(
val inkAnnotations: List<SharedPdfInkAnnotationExport> = emptyList(),
val highlightAnnotations: List<SharedPdfHighlightAnnotationExport> = emptyList()
) {
val hasPdfAnnotations: Boolean
get() = inkAnnotations.isNotEmpty() || highlightAnnotations.isNotEmpty()
}
data class SharedPdfInkAnnotationExport(
val id: String,
val pageIndex: Int,
val tool: PdfInkTool,
val points: List<PdfPagePoint>,
val colorArgb: Int,
val strokeWidth: Float,
val contents: String
)
data class SharedPdfHighlightAnnotationExport(
val id: String,
val pageIndex: Int,
val boundsList: List<PdfPageBounds>,
val colorArgb: Int,
val contents: String,
val comments: List<SharedPdfHighlightCommentExport> = emptyList()
)
data class SharedPdfHighlightCommentExport(
val id: String,
val parentId: String?,
val author: String,
val contents: String,
val createdAt: Long,
val modifiedAt: Long
)
object SharedPdfAnnotationExportMapper {
fun build(
annotations: List<SharedPdfAnnotation>,
resolveHighlightBounds: (SharedPdfAnnotation) -> List<PdfPageBounds> = { emptyList() }
): SharedPdfAnnotationExportPayload {
return SharedPdfAnnotationExportPayload(
inkAnnotations = annotations.mapNotNull { it.toInkExportOrNull() },
highlightAnnotations = annotations.mapNotNull { it.toHighlightExportOrNull(resolveHighlightBounds) }
)
}
private fun SharedPdfAnnotation.toInkExportOrNull(): SharedPdfInkAnnotationExport? {
if (kind != PdfAnnotationKind.INK) return null
if (tool == PdfInkTool.NONE ||
tool == PdfInkTool.ERASER ||
tool == PdfInkTool.TEXT ||
points.size < 2
) return null
return SharedPdfInkAnnotationExport(
id = id,
pageIndex = pageIndex,
tool = tool,
points = points,
colorArgb = colorArgb,
strokeWidth = strokeWidth,
contents = note?.trim().orEmpty()
)
}
private fun SharedPdfAnnotation.toHighlightExportOrNull(
resolveHighlightBounds: (SharedPdfAnnotation) -> List<PdfPageBounds>
): SharedPdfHighlightAnnotationExport? {
if (kind != PdfAnnotationKind.HIGHLIGHT) return null
val storedBounds = boundsList.ifEmpty { listOfNotNull(bounds) }
.mapNotNull { it.normalizedForExportOrNull() }
val exportBounds = storedBounds.ifEmpty {
resolveHighlightBounds(this).mapNotNull { it.normalizedForExportOrNull() }
}
if (exportBounds.isEmpty()) return null
return SharedPdfHighlightAnnotationExport(
id = id,
pageIndex = pageIndex,
boundsList = exportBounds,
colorArgb = colorArgb,
contents = note?.trim().orEmpty(),
comments = comments.toHighlightCommentExports(highlightId = id)
)
}
private fun List<SharedPdfAnnotationComment>.toHighlightCommentExports(
highlightId: String
): List<SharedPdfHighlightCommentExport> {
val sourceItems = mapIndexedNotNull { index, comment ->
val contents = comment.contents.trim()
if (contents.isBlank()) return@mapIndexedNotNull null
val sourceId = comment.id.takeIf { it.isNotBlank() }
val exportId = sourceId ?: "${highlightId}_comment_$index"
IndexedExportComment(
sourceId = sourceId,
sourceParentId = comment.parentId?.takeIf { it.isNotBlank() },
export = SharedPdfHighlightCommentExport(
id = exportId,
parentId = null,
author = comment.author.trim(),
contents = contents,
createdAt = comment.createdAt,
modifiedAt = comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt
)
)
}
if (sourceItems.isEmpty()) return emptyList()
val stableIds = mutableSetOf<String>()
val sourceIdToExportId = mutableMapOf<String, String>()
val uniqueItems = sourceItems.mapIndexed { index, item ->
val uniqueId = item.export.id.uniqueCommentId(stableIds, index)
item.sourceId?.let { sourceIdToExportId.putIfAbsent(it, uniqueId) }
item.copy(export = item.export.copy(id = uniqueId))
}
val parentAwareItems = uniqueItems.map { item ->
val parentId = item.sourceParentId?.let(sourceIdToExportId::get)
item.copy(export = item.export.copy(parentId = parentId))
}
val byParent = parentAwareItems.groupBy { it.export.parentId }
val result = mutableListOf<SharedPdfHighlightCommentExport>()
val emittedIds = mutableSetOf<String>()
fun appendThread(parentId: String?, visitedIds: Set<String>) {
byParent[parentId].orEmpty().forEach { item ->
val export = item.export
if (export.id in emittedIds || export.id in visitedIds) return@forEach
emittedIds += export.id
result += export
appendThread(export.id, visitedIds + export.id)
}
}
appendThread(parentId = null, visitedIds = emptySet())
parentAwareItems.forEach { item ->
if (item.export.id !in emittedIds) {
val root = item.export.copy(parentId = null)
emittedIds += root.id
result += root
appendThread(root.id, setOf(root.id))
}
}
return result.toSingleVisiblePdfCommentThread(highlightId)
}
private fun PdfPageBounds.normalizedForExportOrNull(): PdfPageBounds? {
val normalized = PdfPageBounds(
left = minOf(left, right),
top = minOf(top, bottom),
right = maxOf(left, right),
bottom = maxOf(top, bottom)
)
return normalized.takeIf {
it.left in 0f..1f &&
it.top in 0f..1f &&
it.right in 0f..1f &&
it.bottom in 0f..1f &&
it.right > it.left &&
it.bottom > it.top
}
}
}
private data class IndexedExportComment(
val sourceId: String?,
val sourceParentId: String?,
val export: SharedPdfHighlightCommentExport
)
private fun String.uniqueCommentId(usedIds: MutableSet<String>, index: Int): String {
val base = ifBlank { "comment_$index" }
var candidate = base
var suffix = 2
while (!usedIds.add(candidate)) {
candidate = "${base}_$suffix"
suffix += 1
}
return candidate
}
private fun List<SharedPdfHighlightCommentExport>.toSingleVisiblePdfCommentThread(
highlightId: String
): List<SharedPdfHighlightCommentExport> {
if (isEmpty()) return emptyList()
val threadContents = formatAsPdfCommentThread()
if (threadContents.isBlank()) return emptyList()
val createdAt = mapNotNull { it.createdAt.takeIf { timestamp -> timestamp > 0L } }
.minOrNull()
?: 0L
val modifiedAt = mapNotNull { comment ->
(comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt).takeIf { it > 0L }
}.maxOrNull() ?: createdAt
val root = firstOrNull { it.parentId == null } ?: first()
return listOf(
SharedPdfHighlightCommentExport(
id = "${highlightId}_comments",
parentId = null,
author = root.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
contents = threadContents,
createdAt = createdAt,
modifiedAt = modifiedAt
)
)
}
private fun List<SharedPdfHighlightCommentExport>.formatAsPdfCommentThread(): String {
val commentsByParent = groupBy { it.parentId }
val ids = map { it.id }.toSet()
val roots = filter { it.parentId == null || it.parentId !in ids }
val visitedIds = mutableSetOf<String>()
val lines = mutableListOf<String>()
fun appendComment(comment: SharedPdfHighlightCommentExport, depth: Int) {
if (!visitedIds.add(comment.id)) return
if (lines.isNotEmpty()) lines += ""
val indent = " ".repeat(depth)
val author = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
lines += "$indent$author:"
comment.contents.lines().forEach { line ->
lines += "$indent$line"
}
commentsByParent[comment.id].orEmpty().forEach { child ->
appendComment(child, depth + 1)
}
}
roots.forEach { appendComment(it, 0) }
forEach { comment ->
if (comment.id !in visitedIds) appendComment(comment, 0)
}
return lines.joinToString("\n").trim()
}
fun SharedPdfInkAnnotationExport.pdfInkAppearancePoints(pageWidth: Float, pageHeight: Float): List<PdfPagePoint> {
if (tool != PdfInkTool.HIGHLIGHTER || points.size < 2 || pageWidth <= 0f || pageHeight <= 0f) return points
// PDF ink annotations are usually rendered with rounded caps; trim chisel highlighter endpoints to match our butt-cap UI.
val trimPdfUnits = (strokeWidth * pageWidth) * 0.65f
if (trimPdfUnits <= 0f) return points
val firstTargetIndex = points.firstDistinctIndexAfter(index = 0, pageWidth = pageWidth, pageHeight = pageHeight)
?: return points
val lastIndex = points.lastIndex
val lastTargetIndex = points.lastDistinctIndexBefore(index = lastIndex, pageWidth = pageWidth, pageHeight = pageHeight)
?: return points
val adjusted = points.toMutableList()
val adjustedStart = adjusted[0].movedToward(points[firstTargetIndex], trimPdfUnits, pageWidth, pageHeight)
for (pointIndex in 0 until firstTargetIndex) {
adjusted[pointIndex] = adjustedStart
}
val adjustedEnd = adjusted[lastIndex].movedToward(points[lastTargetIndex], trimPdfUnits, pageWidth, pageHeight)
for (pointIndex in lastTargetIndex + 1..lastIndex) {
adjusted[pointIndex] = adjustedEnd
}
return adjusted
}
private fun List<PdfPagePoint>.firstDistinctIndexAfter(
index: Int,
pageWidth: Float,
pageHeight: Float
): Int? {
val source = getOrNull(index) ?: return null
for (targetIndex in index + 1..lastIndex) {
val target = this[targetIndex]
if (source.pdfDistanceTo(target, pageWidth, pageHeight) > 0f) return targetIndex
}
return null
}
private fun List<PdfPagePoint>.lastDistinctIndexBefore(
index: Int,
pageWidth: Float,
pageHeight: Float
): Int? {
val source = getOrNull(index) ?: return null
for (targetIndex in index - 1 downTo 0) {
val target = this[targetIndex]
if (source.pdfDistanceTo(target, pageWidth, pageHeight) > 0f) return targetIndex
}
return null
}
private fun PdfPagePoint.movedToward(
target: PdfPagePoint,
distancePdfUnits: Float,
pageWidth: Float,
pageHeight: Float
): PdfPagePoint {
val dx = (target.x - x) * pageWidth
val dy = (target.y - y) * pageHeight
val length = pdfDistanceTo(target, pageWidth, pageHeight)
if (length <= 0f) return this
val trim = minOf(distancePdfUnits, length * 0.49f)
return copy(
x = x + (dx / length) * (trim / pageWidth),
y = y + (dy / length) * (trim / pageHeight)
)
}
private fun PdfPagePoint.pdfDistanceTo(target: PdfPagePoint, pageWidth: Float, pageHeight: Float): Float {
val dx = (target.x - x) * pageWidth
val dy = (target.y - y) * pageHeight
return sqrt((dx * dx + dy * dy).toDouble()).toFloat()
}

View file

@ -189,6 +189,7 @@ object SharedPdfAnnotationSidecarCodec {
boundsList = boundsList,
text = obj.string("text").orEmpty(),
note = obj.string("note"),
comments = obj.array("comments").toSharedPdfAnnotationComments(),
colorArgb = SharedPdfAndroidHighlightColors.argbForName(colorName),
rangeStartIndex = rangeStart,
rangeEndIndex = inclusiveRangeEnd
@ -270,6 +271,7 @@ object SharedPdfAnnotationSidecarCodec {
put("rangeStart", JsonPrimitive(rangeStart))
put("rangeEnd", JsonPrimitive(rangeEnd))
annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) }
annotation.comments.toJsonArrayOrNull()?.let { put("comments", it) }
put("bounds", JsonArray(emptyList()))
}
)
@ -326,6 +328,47 @@ object SharedPdfAnnotationSidecarCodec {
private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull()
private fun JsonArray?.toSharedPdfAnnotationComments(): List<SharedPdfAnnotationComment> {
return this?.mapNotNull { element ->
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
val contents = obj.string("contents")
?: obj.string("text")
?: obj.string("comment")
?: return@mapNotNull null
SharedPdfAnnotationComment(
id = obj.string("id") ?: stableAnnotationId("comment", element),
parentId = obj.string("parentId") ?: obj.string("inReplyTo"),
author = obj.string("author").orEmpty(),
contents = contents,
createdAt = obj.long("createdAt") ?: obj.long("created") ?: 0L,
modifiedAt = obj.long("modifiedAt")
?: obj.long("modified")
?: obj.long("createdAt")
?: obj.long("created")
?: 0L
)
}.orEmpty()
}
private fun List<SharedPdfAnnotationComment>.toJsonArrayOrNull(): JsonArray? {
val comments = mapNotNull { comment ->
val contents = comment.contents.trim()
if (contents.isBlank()) return@mapNotNull null
JsonObject(
buildMap {
put("id", JsonPrimitive(comment.id))
comment.parentId?.takeIf { it.isNotBlank() }?.let { put("parentId", JsonPrimitive(it)) }
comment.author.takeIf { it.isNotBlank() }?.let { put("author", JsonPrimitive(it)) }
put("contents", JsonPrimitive(contents))
if (comment.createdAt > 0L) put("createdAt", JsonPrimitive(comment.createdAt))
val modifiedAt = comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt
if (modifiedAt > 0L) put("modifiedAt", JsonPrimitive(modifiedAt))
}
)
}
return JsonArray(comments).takeIf { comments.isNotEmpty() }
}
private fun JsonObject.string(name: String): String? {
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }
.getOrNull()

View file

@ -0,0 +1,397 @@
package com.aryan.reader.shared.pdf
import kotlin.math.roundToInt
data class SharedPdfReflowTextSpan(
val text: String,
val size: Float,
val isBold: Boolean,
val isItalic: Boolean
)
sealed interface SharedPdfReflowPageElement {
val yPos: Float
}
data class SharedPdfReflowTextElement(
val line: SharedPdfReflowTextLine,
override val yPos: Float = line.yPos
) : SharedPdfReflowPageElement
data class SharedPdfReflowImageElement(
val base64Data: String,
val width: Int,
val height: Int,
override val yPos: Float,
val mimeType: String = "image/jpeg"
) : SharedPdfReflowPageElement
data class SharedPdfReflowTextLine(
val spans: List<SharedPdfReflowTextSpan>,
val yPos: Float,
val charCount: Int
)
data class SharedPdfReflowPage(
val pageNumber: Int,
val elements: List<SharedPdfReflowPageElement>
)
object SharedPdfReflowHtml {
fun buildGlobalHtmlHeader(): String = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: sans-serif; line-height: 1.65; padding: 1em; max-width: 100%; margin: 0; }
h1 { font-size: 1.9em; font-weight: bold; margin: 1.2em 0 0.4em; }
h2 { font-size: 1.55em; font-weight: bold; margin: 1.1em 0 0.35em; }
h3 { font-size: 1.3em; font-weight: bold; margin: 1.0em 0 0.3em; }
h4 { font-size: 1.1em; font-weight: bold; margin: 0.9em 0 0.25em; }
p { margin: 0.5em 0; }
ul, ol { padding-left: 1.5em; margin: 0.5em 0; }
li { margin-bottom: 0.2em; }
hr { border: none; border-top: 1px solid currentColor; opacity: 0.25; margin: 1.4em 0; }
.page-section { margin-bottom: 0.5em; }
.page-marker { opacity: 0.4; font-size: 0.72em; margin-bottom: 1.2em; letter-spacing: 0.04em; }
.page-divider { border: none; border-top: 1px solid currentColor; opacity: 0.12; margin: 2em 0 1.5em; }
</style>
</head>
<body>
""".trimIndent() + "\n"
fun buildGlobalHtmlFooter(): String = "\n</body>\n</html>\n"
fun buildPageHtml(
page: SharedPdfReflowPage,
headerFooterStrings: Set<String> = emptySet()
): String {
if (page.elements.isEmpty()) return buildEmptyPageSection(page.pageNumber)
val textElements = page.elements.filterIsInstance<SharedPdfReflowTextElement>()
if (textElements.isEmpty()) {
return buildPageHtmlFromElements(
pageNumber = page.pageNumber,
elements = page.elements,
headerFooterStrings = headerFooterStrings,
baseSize = 12f,
wrapThreshold = 64
)
}
val sizeFreq = HashMap<Int, Int>()
textElements.forEach { textElement ->
textElement.line.spans.forEach { span ->
val size = span.size.roundToInt().coerceAtLeast(1)
sizeFreq[size] = (sizeFreq[size] ?: 0) + span.text.length
}
}
val baseSize = sizeFreq.maxByOrNull { it.value }?.key?.toFloat() ?: 12f
val lineLengths = textElements
.filter { it.line.charCount > 10 }
.map { it.line.charCount }
.sorted()
val typicalLineLen = if (lineLengths.isNotEmpty()) {
lineLengths[(lineLengths.size * 0.80).toInt().coerceAtMost(lineLengths.size - 1)]
} else {
80
}
val wrapThreshold = (typicalLineLen * 0.80).toInt()
return buildPageHtmlFromElements(
pageNumber = page.pageNumber,
elements = page.elements,
headerFooterStrings = headerFooterStrings,
baseSize = baseSize,
wrapThreshold = wrapThreshold
)
}
fun buildEmptyPageSection(pageNumber: Int): String =
"<section class=\"page-section\">\n" +
"<p class=\"page-marker\">-- Page $pageNumber --</p>\n" +
"<p><em>(No text on this page)</em></p>\n</section>\n"
fun buildFallbackPageSection(pageNumber: Int, rawText: String): String =
"<section class=\"page-section\">\n" +
"<p class=\"page-marker\">-- Page $pageNumber --</p>\n" +
"<p>${rawText.escapeSharedPdfReflowHtml()}</p>\n</section>\n"
fun detectRepeatingHeaderFooter(samplePageLines: List<List<String>>): Set<String> {
if (samplePageLines.size < 5) return emptySet()
val frequency = HashMap<String, Int>()
samplePageLines.forEach { lines ->
val edgeLines = lines
.map { it.trim() }
.filter { it.length > 2 }
.let { cleanLines -> cleanLines.take(2) + cleanLines.takeLast(2) }
edgeLines.forEach { line ->
frequency[line] = (frequency[line] ?: 0) + 1
}
}
return frequency.filter { it.value >= 3 }.keys
}
private fun buildPageHtmlFromElements(
pageNumber: Int,
elements: List<SharedPdfReflowPageElement>,
headerFooterStrings: Set<String>,
baseSize: Float,
wrapThreshold: Int
): String {
val sb = StringBuilder()
sb.append("<section class=\"page-section\">\n")
sb.append("<p class=\"page-marker\">-- Page $pageNumber --</p>\n")
var inParagraph = false
var inUl = false
var inOl = false
var inLi = false
fun closeParagraph() {
if (inParagraph) {
sb.append("</p>\n")
inParagraph = false
}
}
fun closeLi() {
if (inLi) {
sb.append("</li>\n")
inLi = false
}
}
fun closeList() {
closeLi()
if (inUl) {
sb.append("</ul>\n")
inUl = false
}
if (inOl) {
sb.append("</ol>\n")
inOl = false
}
}
for ((index, element) in elements.withIndex()) {
when (element) {
is SharedPdfReflowImageElement -> {
closeParagraph()
closeList()
sb.append("<div style=\"text-align:center; margin: 1.5em 0;\">\n")
sb.append(
"<img src=\"data:${element.mimeType};base64,${element.base64Data}\" " +
"style=\"max-width:100%; height:auto; border-radius: 6px;\"/>\n"
)
sb.append("</div>\n")
}
is SharedPdfReflowTextElement -> {
val line = element.line
val lineText = line.spans.joinToString("") { it.text }
val trimmed = lineText.trim()
if (trimmed.isEmpty() || headerFooterStrings.any { it.equals(trimmed, ignoreCase = true) }) {
closeParagraph()
continue
}
val maxSize = line.spans
.filter { it.text.isNotBlank() }
.maxOfOrNull { it.size }
?: baseSize
val headingLevel = when {
maxSize > baseSize * 1.6f -> 1
maxSize > baseSize * 1.28f -> 2
maxSize > baseSize * 1.10f -> 3
maxSize > baseSize * 1.04f -> 4
else -> 0
}
val lineLen = trimmed.length
val isShort = lineLen < 60
val isAllCaps = isShort &&
lineLen >= 3 &&
trimmed.any { it.isLetter() } &&
trimmed.all { it.isUpperCase() || !it.isLetter() } &&
!trimmed.endsWith(".")
val isBullet = trimmed.startsWith("* ") ||
trimmed.startsWith("- ") && trimmed.length > 2 && !trimmed.startsWith("--") ||
trimmed.startsWith("\u2022") ||
trimmed.startsWith("\u25AA") ||
trimmed.startsWith("\u25E6") ||
trimmed.startsWith("\u2013")
val numberedMatch = Regex("""^(\d{1,3}[.)]\s|\p{L}[.)]\s)""").containsMatchIn(trimmed)
val isHr = isShort &&
trimmed.length >= 3 &&
trimmed.all { it == '-' || it == '=' || it == '_' || it.isWhitespace() }
val effectiveHeading = when {
headingLevel > 0 -> headingLevel
isAllCaps && !isBullet && !numberedMatch -> 2
else -> 0
}
val nextTextElement = elements
.drop(index + 1)
.firstOrNull {
it is SharedPdfReflowTextElement &&
it.line.spans.joinToString("") { span -> span.text }.isNotBlank()
} as? SharedPdfReflowTextElement
val nextLineStartsNewThought = nextTextElement != null &&
nextTextElement.line.spans.joinToString("") { it.text }
.trimStart()
.let { it.startsWith("\"") || it.startsWith("-") || it.startsWith("\u201C") }
val shouldBreakParagraph = effectiveHeading > 0 ||
isBullet ||
numberedMatch ||
isHr ||
lineLen < wrapThreshold ||
trimmed.last().let { it == '.' || it == '!' || it == '?' || it == ':' || it == '"' || it == '\u201D' } ||
nextLineStartsNewThought
when {
isHr -> {
closeParagraph()
closeList()
sb.append("<hr>\n")
}
effectiveHeading > 0 -> {
closeParagraph()
closeList()
val tag = "h${effectiveHeading.coerceIn(1, 4)}"
sb.append("<$tag>${renderSpans(line.spans, insideHeading = true)}</$tag>\n")
}
isBullet -> {
closeParagraph()
closeLi()
if (inOl) {
sb.append("</ol>\n")
inOl = false
}
if (!inUl) {
sb.append("<ul>\n")
inUl = true
}
val content = trimmed
.removePrefix("* ")
.removePrefix("\u2022")
.removePrefix("\u25AA")
.removePrefix("\u25E6")
.removePrefix("\u2013")
.removePrefix("- ")
.trim()
sb.append("<li>${content.escapeSharedPdfReflowHtml()}")
inLi = true
}
numberedMatch -> {
closeParagraph()
closeLi()
if (inUl) {
sb.append("</ul>\n")
inUl = false
}
if (!inOl) {
sb.append("<ol>\n")
inOl = true
}
val content = trimmed.substringAfter(" ").trim()
sb.append("<li>${content.escapeSharedPdfReflowHtml()}")
inLi = true
}
shouldBreakParagraph -> {
if (inLi) {
sb.append(" ").append(renderSpans(line.spans))
closeLi()
} else {
closeList()
if (!inParagraph) {
sb.append("<p>")
inParagraph = true
}
sb.append(renderSpans(line.spans))
closeParagraph()
}
}
else -> {
if (inLi) {
sb.append(" ").append(renderSpans(line.spans))
} else {
closeList()
if (!inParagraph) {
sb.append("<p>")
inParagraph = true
} else {
sb.append(" ")
}
sb.append(renderSpans(line.spans))
}
}
}
}
}
}
closeParagraph()
closeList()
sb.append("</section>\n")
return sb.toString()
}
private fun renderSpans(spans: List<SharedPdfReflowTextSpan>, insideHeading: Boolean = false): String {
val sb = StringBuilder()
for (span in spans) {
val escaped = span.text.escapeSharedPdfReflowHtml()
if (escaped.isBlank()) {
sb.append(escaped)
continue
}
val leadCount = escaped.length - escaped.trimStart().length
val trailCount = escaped.length - escaped.trimEnd().length
val pre = escaped.take(leadCount)
val post = if (trailCount > 0) escaped.takeLast(trailCount) else ""
val mid = escaped.substring(leadCount, escaped.length - trailCount)
if (mid.isEmpty()) {
sb.append(escaped)
continue
}
sb.append(pre)
if (!insideHeading) {
when {
span.isBold && span.isItalic -> sb.append("<strong><em>")
span.isBold -> sb.append("<strong>")
span.isItalic -> sb.append("<em>")
}
}
sb.append(mid)
if (!insideHeading) {
when {
span.isBold && span.isItalic -> sb.append("</em></strong>")
span.isBold -> sb.append("</strong>")
span.isItalic -> sb.append("</em>")
}
}
sb.append(post)
}
return sb.toString()
}
}
private fun String.escapeSharedPdfReflowHtml(): String = this
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")

View file

@ -515,6 +515,28 @@ private fun AnnotatedString.withRestoredTrailingSharedPdfPageBreak(shouldRestore
return this + AnnotatedString(SHARED_PDF_PAGE_BREAK_CHAR.toString())
}
internal fun sharedPdfRichTextInsertionIndexForPage(
insertPageIndex: Int,
pageLayouts: List<SharedPdfRichPageLayout>,
textLength: Int
): Int {
val rawIndex = if (insertPageIndex <= 0) {
0
} else {
pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: textLength
}
return rawIndex.coerceIn(0, textLength)
}
internal fun sharedPdfRichTextBlankInsertBreakCount(text: String, insertionCharIndex: Int): Int {
val safeIndex = insertionCharIndex.coerceIn(0, text.length)
if (safeIndex == 0 || safeIndex == text.length) return 1
val hasBoundaryBreakBefore = text.getOrNull(safeIndex - 1) == SHARED_PDF_PAGE_BREAK_CHAR
val hasBoundaryBreakAfter = text.getOrNull(safeIndex) == SHARED_PDF_PAGE_BREAK_CHAR
return if (hasBoundaryBreakBefore || hasBoundaryBreakAfter) 1 else 2
}
internal fun List<SharedPdfRichPageLayout>.withTrailingBlankRichTextPageIfNeeded(
globalText: AnnotatedString,
pageHeightPx: Float
@ -933,25 +955,55 @@ class SharedPdfRichTextController(
scope.launch {
forceSyncAndClear()
val original = globalTextFieldValue.annotatedString
val insertionCharIndex = if (insertPageIndex == 0) {
0
} else {
pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: original.length
}
val safeIndex = insertionCharIndex.coerceIn(0, original.length)
val builder = AnnotatedString.Builder()
builder.append(original.subSequence(0, safeIndex))
repeat(count) { builder.append(SHARED_PDF_PAGE_BREAK_CHAR.toString()) }
builder.append(original.subSequence(safeIndex, original.length))
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(safeIndex + count))
debouncedSave(globalTextFieldValue)
repaginate(dirtyStartIndex = safeIndex)
val safeIndex = sharedPdfRichTextInsertionIndexForPage(
insertPageIndex = insertPageIndex,
pageLayouts = pageLayouts,
textLength = original.length
)
insertPageBreaksIntoGlobalText(original, safeIndex, count)
SharedPdfRichTextLog.d(
"controller.insertPageBreak inserted index=$safeIndex newLen=${globalTextFieldValue.text.length}"
)
}
}
fun insertBlankPageAt(insertPageIndex: Int) {
SharedPdfRichTextLog.d("controller.insertBlankPage requested page=$insertPageIndex")
scope.launch {
forceSyncAndClear()
val original = globalTextFieldValue.annotatedString
val safeIndex = sharedPdfRichTextInsertionIndexForPage(
insertPageIndex = insertPageIndex,
pageLayouts = pageLayouts,
textLength = original.length
)
val requiredBreaks = sharedPdfRichTextBlankInsertBreakCount(
text = original.text,
insertionCharIndex = safeIndex
)
insertPageBreaksIntoGlobalText(original, safeIndex, requiredBreaks)
SharedPdfRichTextLog.d(
"controller.insertBlankPage inserted index=$safeIndex breaks=$requiredBreaks newLen=${globalTextFieldValue.text.length}"
)
}
}
private fun insertPageBreaksIntoGlobalText(
original: AnnotatedString,
safeIndex: Int,
count: Int
) {
val safeCount = count.coerceAtLeast(0)
if (safeCount == 0) return
val builder = AnnotatedString.Builder()
builder.append(original.subSequence(0, safeIndex))
repeat(safeCount) { builder.append(SHARED_PDF_PAGE_BREAK_CHAR.toString()) }
builder.append(original.subSequence(safeIndex, original.length))
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(safeIndex + safeCount))
debouncedSave(globalTextFieldValue)
repaginate(dirtyStartIndex = safeIndex)
}
fun deleteTextOnPage(pageIndex: Int) {
SharedPdfRichTextLog.d("controller.deleteTextOnPage requested page=$pageIndex")
scope.launch {

View file

@ -774,8 +774,20 @@ object ReaderHtmlDocumentBuilder {
if (activeStart === undefined || activeStart === null) {
activeStart = numberAttribute(document.body, 'data-reader-active-start-offset', null);
}
var chapter = readerHostForLocator(chapterIndex, activeStart, locator.endOffset);
var requestedPageIndex = parseInt(locator.pageIndex, 10);
var chapter = Number.isFinite(requestedPageIndex)
? document.querySelector('[data-reader-page-index="' + selectorValue(requestedPageIndex) + '"]')
: null;
if (!chapter) chapter = readerHostForLocator(chapterIndex, activeStart, locator.endOffset);
if (!chapter) return;
var exactCfi = locator.cfi
? chapter.querySelector('[data-reader-cfi="' + selectorValue(locator.cfi) + '"]')
: null;
if (exactCfi) {
var cfiRect = exactCfi.getBoundingClientRect();
window.scrollTo({ top: Math.max(0, cfiRect.top + window.scrollY - 24), left: 0, behavior: 'auto' });
return;
}
var exact = activeStart === null
? null
: chapter.querySelector('[data-reader-start-offset="' + selectorValue(activeStart) + '"]');
@ -2767,7 +2779,7 @@ object ReaderHtmlDocumentBuilder {
val tag = if (isOrdered) "ol" else "ul"
"<$tag${styleAttribute()}>${items.joinToString("") { it.toHtml(searchQuery, searchOptions) }}</$tag>"
}
is SemanticImage -> "<figure${styleAttribute()}><img src=\"${path.escapeHtml()}\" alt=\"${altText.orEmpty().escapeHtml()}\"${imageSizeAttribute()}></figure>"
is SemanticImage -> "<figure${imageAnchorAttributes()}${styleAttribute()}><img src=\"${path.escapeHtml()}\" alt=\"${altText.orEmpty().escapeHtml()}\"${imageSizeAttribute()}></figure>"
is SemanticMath -> svgContent ?: "<pre${styleAttribute()}>${altText.orEmpty().highlightAndEscape(searchQuery, searchOptions)}</pre>"
is SemanticSpacer -> if (isExplicitLineBreak) "<br>" else "<div${styleAttribute("height:1em")}></div>"
is SemanticTable -> rows.joinToString("", "<table${styleAttribute()}><tbody>", "</tbody></table>") { row ->
@ -2852,6 +2864,18 @@ object ReaderHtmlDocumentBuilder {
}
}
private fun SemanticImage.imageAnchorAttributes(): String {
return buildString {
append(" data-reader-block-index=\"$blockIndex\"")
elementId?.takeIf { it.isNotBlank() }?.let {
append(" id=\"${it.escapeHtml()}\" data-reader-element-id=\"${it.escapeHtml()}\"")
}
cfi?.takeIf { it.isNotBlank() }?.let {
append(" data-reader-cfi=\"${it.escapeHtml()}\"")
}
}
}
private fun SemanticTextBlock.textHtml(
searchQuery: String,
searchOptions: ReaderSearchOptions

View file

@ -0,0 +1,205 @@
package com.aryan.reader.shared.reader
import com.aryan.reader.paginatedreader.SemanticBlock
import com.aryan.reader.paginatedreader.SemanticFlexContainer
import com.aryan.reader.paginatedreader.SemanticImage
import com.aryan.reader.paginatedreader.SemanticList
import com.aryan.reader.paginatedreader.SemanticTable
import com.aryan.reader.paginatedreader.SemanticTextBlock
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
import com.aryan.reader.shared.ReaderLocator
data class ReaderImageReference(
val id: String,
val index: Int,
val source: String,
val altText: String?,
val chapterIndex: Int,
val chapterTitle: String,
val blockIndex: Int,
val cfi: String?,
val intrinsicWidth: Float?,
val intrinsicHeight: Float?,
val locator: ReaderLocator
) {
val displayTitle: String
get() = altText?.trim()?.takeIf { it.isNotBlank() }
?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
?: "Image ${index + 1}"
val dimensionLabel: String?
get() {
val width = intrinsicWidth?.takeIf { it > 0f }?.toInt()
val height = intrinsicHeight?.takeIf { it > 0f }?.toInt()
return if (width != null && height != null) "${width}x$height" else null
}
fun sourceName(): String? {
if (source.startsWith("data:", ignoreCase = true)) return null
return source
.substringBefore('#')
.substringBefore('?')
.replace('\\', '/')
.substringAfterLast('/')
.takeIf { it.isNotBlank() }
}
fun suggestedDownloadFileName(): String {
val extension = source.readerImageExtension()
val sourceName = sourceName()
val base = altText?.trim()?.takeIf { it.isNotBlank() }
?: sourceName?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
?: "image-${index + 1}"
val safeBase = base.sanitizedReaderImageFileBase().ifBlank { "image-${index + 1}" }
val safeExtension = extension?.takeIf { it.isNotBlank() } ?: "png"
return "$safeBase.$safeExtension"
}
}
fun SharedEpubBook.readerImageReferences(pages: List<ReaderPage> = emptyList()): List<ReaderImageReference> {
val references = mutableListOf<ReaderImageReference>()
chapters.forEachIndexed { chapterIndex, chapter ->
val markers = chapter.semanticBlocks.readerImageMarkers()
markers.forEachIndexed { markerIndex, marker ->
if (marker !is ReaderImageMarker.Image) return@forEachIndexed
val image = marker.image
val anchorOffset = markers.nearestTextOffsetFor(markerIndex)
val pageIndex = pages.findPageIndexForImage(chapterIndex, image)
?: anchorOffset?.let { offset ->
pages.firstOrNull { page ->
page.chapterIndex == chapterIndex && offset in page.startOffset..page.endOffset
}?.pageIndex
}
?: pages.firstOrNull { it.chapterIndex == chapterIndex }?.pageIndex
val startOffset = if (pageIndex == null) anchorOffset else null
val endOffset = startOffset
val locator = ReaderLocator(
chapterIndex = chapterIndex,
chapterId = chapter.id,
href = chapter.baseHref,
pageIndex = pageIndex,
startOffset = startOffset,
endOffset = endOffset,
textQuote = image.altText?.takeIf { it.isNotBlank() },
cfi = image.cfi
)
val index = references.size
references += ReaderImageReference(
id = "image:$chapterIndex:${image.blockIndex}:${image.cfi.orEmpty()}:${image.path.hashCode()}:$index",
index = index,
source = image.path,
altText = image.altText,
chapterIndex = chapterIndex,
chapterTitle = chapter.title,
blockIndex = image.blockIndex,
cfi = image.cfi,
intrinsicWidth = image.intrinsicWidth,
intrinsicHeight = image.intrinsicHeight,
locator = locator
)
}
}
return references
}
private sealed interface ReaderImageMarker {
data class Text(val startOffset: Int, val endOffset: Int) : ReaderImageMarker
data class Image(val image: SemanticImage) : ReaderImageMarker
}
private fun List<SemanticBlock>.readerImageMarkers(): List<ReaderImageMarker> {
return flatMap { it.readerImageMarkers() }
}
private fun SemanticBlock.readerImageMarkers(): List<ReaderImageMarker> {
return when (this) {
is SemanticTextBlock -> listOf(
ReaderImageMarker.Text(
startOffset = startCharOffsetInSource,
endOffset = startCharOffsetInSource + text.length
)
)
is SemanticImage -> listOf(ReaderImageMarker.Image(this))
is SemanticList -> items.flatMap { it.readerImageMarkers() }
is SemanticTable -> rows.flatMap { row -> row.flatMap { cell -> cell.content.readerImageMarkers() } }
is SemanticFlexContainer -> children.readerImageMarkers()
is SemanticWrappingBlock -> listOf(ReaderImageMarker.Image(floatedImage)) +
paragraphsToWrap.flatMap { it.readerImageMarkers() }
else -> emptyList()
}
}
private fun List<ReaderImageMarker>.nearestTextOffsetFor(index: Int): Int? {
val previous = asSequence()
.take(index)
.filterIsInstance<ReaderImageMarker.Text>()
.lastOrNull()
?.endOffset
if (previous != null) return previous
return asSequence()
.drop(index + 1)
.filterIsInstance<ReaderImageMarker.Text>()
.firstOrNull()
?.startOffset
}
private fun List<ReaderPage>.findPageIndexForImage(chapterIndex: Int, image: SemanticImage): Int? {
return firstOrNull { page ->
page.chapterIndex == chapterIndex && page.semanticBlocks.any { it.containsReaderImage(image) }
}?.pageIndex
}
private fun SemanticBlock.containsReaderImage(target: SemanticImage): Boolean {
return when (this) {
is SemanticImage -> sameReaderImageAs(target)
is SemanticList -> items.any { it.containsReaderImage(target) }
is SemanticTable -> rows.any { row -> row.any { cell -> cell.content.any { it.containsReaderImage(target) } } }
is SemanticFlexContainer -> children.any { it.containsReaderImage(target) }
is SemanticWrappingBlock -> floatedImage.sameReaderImageAs(target) ||
paragraphsToWrap.any { it.containsReaderImage(target) }
else -> false
}
}
private fun SemanticImage.sameReaderImageAs(other: SemanticImage): Boolean {
val thisCfi = cfi?.takeIf { it.isNotBlank() }
val otherCfi = other.cfi?.takeIf { it.isNotBlank() }
if (thisCfi != null && otherCfi != null) return thisCfi == otherCfi
val thisElementId = elementId?.takeIf { it.isNotBlank() }
val otherElementId = other.elementId?.takeIf { it.isNotBlank() }
if (thisElementId != null && otherElementId != null) return thisElementId == otherElementId
return blockIndex == other.blockIndex && path == other.path
}
private fun String.readerImageExtension(): String? {
val dataMime = Regex("""^data:([^;,]+)""", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.getOrNull(1)
?.lowercase()
val extensionFromMime = when (dataMime) {
"image/jpeg" -> "jpg"
"image/png" -> "png"
"image/gif" -> "gif"
"image/webp" -> "webp"
"image/bmp" -> "bmp"
"image/svg+xml" -> "svg"
else -> null
}
if (extensionFromMime != null) return extensionFromMime
return substringBefore('#')
.substringBefore('?')
.substringAfterLast('.', "")
.lowercase()
.takeIf { it in setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg") }
}
private fun String.sanitizedReaderImageFileBase(): String {
return replace(Regex("""[\\/:*?"<>|]+"""), "_")
.replace(Regex("""\s+"""), " ")
.trim()
.trim('.')
.take(80)
}

View file

@ -75,6 +75,7 @@ data class ReaderSettings(
val pageSpreadMode: ReaderPageSpreadMode = ReaderPageSpreadMode.SINGLE,
val pdfVerticalPageGapVisible: Boolean = true,
val pdfPageNumberOverlayVisible: Boolean = true,
val pdfFirstPageStandaloneInSpread: Boolean = false,
val seamlessChapterNavigation: Boolean = true,
val chapterTurnDragMultiplier: Float = 1.0f
) {

View file

@ -14,10 +14,12 @@ import com.aryan.reader.shared.progressPercentValue
import com.aryan.reader.shared.toHomeScreenModel
enum class SharedAppToolAction {
SETTINGS,
IMPORT_FILES,
IMPORT_FOLDER,
SYNC,
APP_THEME,
PRO,
AI_SETTINGS,
CUSTOM_FONTS,
HELP_FEEDBACK,
@ -39,24 +41,27 @@ fun sharedAppShellModel(
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
): SharedAppShellModel {
val primaryTabs = buildList {
add(SharedAppTab.HOME)
add(SharedAppTab.LIBRARY)
if (featurePolicy.opdsCatalogs) add(SharedAppTab.CATALOGS)
}
val selectedPrimaryTab = when (selectedTab) {
SharedAppTab.HOME -> SharedAppTab.LIBRARY
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
SharedAppTab.SETTINGS,
SharedAppTab.PRO,
SharedAppTab.CUSTOM_FONTS,
SharedAppTab.SUPPORT,
SharedAppTab.FEEDBACK,
SharedAppTab.ABOUT -> SharedAppTab.HOME
SharedAppTab.ABOUT -> SharedAppTab.LIBRARY
else -> selectedTab
}.takeIf { it in primaryTabs } ?: SharedAppTab.HOME
}.takeIf { it in primaryTabs } ?: SharedAppTab.LIBRARY
val toolActions = buildList {
add(SharedAppToolAction.SETTINGS)
add(SharedAppToolAction.IMPORT_FILES)
add(SharedAppToolAction.IMPORT_FOLDER)
add(SharedAppToolAction.SYNC)
add(SharedAppToolAction.APP_THEME)
if (featurePolicy.aiAndCloud) add(SharedAppToolAction.PRO)
if (aiSettingsAvailable && featurePolicy.aiAndCloud) add(SharedAppToolAction.AI_SETTINGS)
add(SharedAppToolAction.CUSTOM_FONTS)
if (featurePolicy.projectLinks) {
@ -124,29 +129,73 @@ data class NonReaderLibraryOrganizationModel(
)
internal data class NonReaderLibraryFileTypeGroup(
val title: String,
val titleKey: String,
val titleFallback: String,
val fileTypes: List<FileType>
)
private val LibraryFileTypeGroupTemplates = listOf(
NonReaderLibraryFileTypeGroup(
title = "Books",
titleKey = "desktop_file_type_group_books",
titleFallback = "Books",
fileTypes = listOf(FileType.EPUB, FileType.MOBI, FileType.FB2)
),
NonReaderLibraryFileTypeGroup(
title = "Documents",
titleKey = "desktop_file_type_group_documents",
titleFallback = "Documents",
fileTypes = listOf(FileType.PDF, FileType.PPTX, FileType.DOCX, FileType.ODT, FileType.FODT)
),
NonReaderLibraryFileTypeGroup(
title = "Text and web",
titleKey = "desktop_file_type_group_text_web",
titleFallback = "Text and web",
fileTypes = listOf(FileType.MD, FileType.TXT, FileType.HTML)
),
NonReaderLibraryFileTypeGroup(
title = "Comics",
titleKey = "desktop_file_type_group_comics",
titleFallback = "Comics",
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7)
)
)
private val AndroidLibraryTabs = listOf(
NonReaderLibraryTab.BOOKS,
NonReaderLibraryTab.SHELVES,
NonReaderLibraryTab.FOLDERS
)
private val DesktopLibraryTabs = listOf(
NonReaderLibraryTab.BOOKS,
NonReaderLibraryTab.SHELVES,
NonReaderLibraryTab.FOLDERS
)
internal fun visibleNonReaderLibraryTabs(
platform: ReaderPlatform = ReaderPlatform.ANDROID
): List<NonReaderLibraryTab> {
return when (platform) {
ReaderPlatform.ANDROID -> AndroidLibraryTabs
ReaderPlatform.DESKTOP -> DesktopLibraryTabs
}
}
internal fun NonReaderLibraryTab.visibleLibraryTab(
platform: ReaderPlatform = ReaderPlatform.ANDROID
): NonReaderLibraryTab {
return takeIf { it in visibleNonReaderLibraryTabs(platform) } ?: NonReaderLibraryTab.BOOKS
}
internal fun SharedReaderScreenState.booksForNonReaderLibraryTab(
tab: NonReaderLibraryTab,
platform: ReaderPlatform = ReaderPlatform.ANDROID
): List<BookItem> {
return when (tab.visibleLibraryTab(platform)) {
NonReaderLibraryTab.UNREAD -> libraryBooks.filter { progressPercentValue(it.progressPercentage) == 0 }
NonReaderLibraryTab.IN_PROGRESS -> libraryBooks.filter { progressPercentValue(it.progressPercentage) in 1..99 }
NonReaderLibraryTab.COMPLETED -> libraryBooks.filter { progressPercentValue(it.progressPercentage) >= 100 }
else -> libraryBooks
}
}
internal fun nonReaderLibraryFileTypeGroups(
platform: ReaderPlatform = ReaderPlatform.DESKTOP
): List<NonReaderLibraryFileTypeGroup> {
@ -163,7 +212,11 @@ internal fun nonReaderLibraryFileTypeGroups(
return if (otherTypes.isEmpty()) {
grouped
} else {
grouped + NonReaderLibraryFileTypeGroup("Other", otherTypes)
grouped + NonReaderLibraryFileTypeGroup(
titleKey = "desktop_file_type_group_other",
titleFallback = "Other",
fileTypes = otherTypes
)
}
}

View file

@ -16,6 +16,7 @@ enum class ReaderWorkspaceKind {
enum class ReaderWorkspaceLeftSection(val title: String) {
CONTENTS("Contents"),
IMAGES("Images"),
SEARCH("Search"),
BOOKMARKS("Bookmarks"),
NOTES("Annotations"),
@ -33,6 +34,7 @@ enum class ReaderWorkspaceTopAction {
CONTENTS,
SEARCH,
BOOKMARK,
FILE_ACTIONS,
FULL_SCREEN,
APPEARANCE,
READ_ALOUD,
@ -53,6 +55,23 @@ data class ReaderWorkspaceChromeModel(
val forceVisibleReasons: Set<String> = emptySet()
)
data class ReaderWorkspaceFileActionState(
val canShare: Boolean = false,
val canSaveCopy: Boolean = false,
val canPrint: Boolean = false,
val canGenerateTextView: Boolean = false,
val hasGeneratedTextView: Boolean = false,
val isGeneratingTextView: Boolean = false
) {
val hasAnyAction: Boolean
get() = canShare ||
canSaveCopy ||
canPrint ||
canGenerateTextView ||
hasGeneratedTextView ||
isGeneratingTextView
}
data class ReaderWorkspacePanelDefaults(
val leftOpen: Boolean = false,
val inspectorOpen: Boolean = false
@ -81,7 +100,8 @@ fun epubReaderWorkspaceModel(
val leftSections = listOf(
ReaderWorkspaceLeftSection.CONTENTS,
ReaderWorkspaceLeftSection.NOTES,
ReaderWorkspaceLeftSection.BOOKMARKS
ReaderWorkspaceLeftSection.BOOKMARKS,
ReaderWorkspaceLeftSection.IMAGES
)
val inspectorSections = buildList {
if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) {
@ -121,7 +141,7 @@ fun epubReaderWorkspaceModel(
topActions = topActions,
bottomActions = bottomActions,
chrome = readerWorkspaceChromeModel(
preferAutoHide = false,
preferAutoHide = true,
searchActive = session.isSearchActive,
leftPanelOpen = false,
inspectorOpen = false,
@ -186,6 +206,7 @@ fun pdfReaderWorkspaceModel(
add(ReaderWorkspaceTopAction.CONTENTS)
add(ReaderWorkspaceTopAction.SEARCH)
add(ReaderWorkspaceTopAction.BOOKMARK)
add(ReaderWorkspaceTopAction.FILE_ACTIONS)
add(ReaderWorkspaceTopAction.FULL_SCREEN)
add(ReaderWorkspaceTopAction.APPEARANCE)
if (cloudTtsAvailable) add(ReaderWorkspaceTopAction.READ_ALOUD)
@ -205,7 +226,7 @@ fun pdfReaderWorkspaceModel(
),
defaultPdfInteractionMode = null,
chrome = readerWorkspaceChromeModel(
preferAutoHide = false,
preferAutoHide = true,
searchActive = searchActive || state.searchQuery.isNotBlank(),
leftPanelOpen = false,
inspectorOpen = false,

View file

@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.Button
@ -77,6 +78,7 @@ enum class SharedAppTab {
CATALOGS,
READER,
SETTINGS,
PRO,
CUSTOM_FONTS,
SUPPORT,
FEEDBACK,
@ -140,14 +142,14 @@ fun SharedAppShell(
selectedTab = shellModel.selectedPrimaryTab,
primaryTabs = shellModel.primaryTabs,
onTabSelected = onTabSelected,
onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) }
onToolsClick = { showToolsPanel = true }
)
} else {
SharedAppCompactRail(
selectedTab = shellModel.selectedPrimaryTab,
primaryTabs = shellModel.primaryTabs,
onTabSelected = onTabSelected,
onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) }
onToolsClick = { showToolsPanel = true }
)
}
}
@ -242,20 +244,20 @@ private fun SharedAppSidebar(
) {
Surface(
modifier = Modifier
.width(244.dp)
.width(SharedUiTokens.sidebarWidth)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
color = MaterialTheme.colorScheme.surfaceContainerLow,
tonalElevation = 0.dp
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(SharedUiTokens.compactGap)
) {
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
Text("Episteme", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Desktop library", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(readerString("desktop_library_and_reader", "Library and reader"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
primaryTabs.forEach { tab ->
SharedSidebarNavItem(
@ -267,7 +269,7 @@ private fun SharedAppSidebar(
Spacer(Modifier.weight(1f))
HorizontalDivider()
SharedSidebarButton(
label = "Settings",
label = readerString("desktop_tools", "Tools"),
icon = Icons.Default.Settings,
onClick = onToolsClick
)
@ -282,18 +284,18 @@ private fun SharedAppCompactRail(
onTabSelected: (SharedAppTab) -> Unit,
onToolsClick: () -> Unit
) {
NavigationRail(containerColor = MaterialTheme.colorScheme.surface) {
NavigationRail(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) {
primaryTabs.forEach { tab ->
NavigationRailItem(
selected = selectedTab == tab,
onClick = { onTabSelected(tab) },
icon = { Icon(tab.icon, contentDescription = null) },
label = { Text(tab.label) }
label = { Text(tab.localizedLabel()) }
)
}
Spacer(Modifier.weight(1f))
IconButton(onClick = onToolsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
Icon(Icons.Default.Settings, contentDescription = readerString("desktop_tools", "Tools"))
}
}
}
@ -327,7 +329,7 @@ private fun SharedSidebarNavItem(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(tab.icon, contentDescription = null, modifier = Modifier.size(21.dp))
Text(tab.label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal)
Text(tab.localizedLabel(), style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal)
}
}
}
@ -371,10 +373,14 @@ private fun SharedToolsPanel(
onOpenTab: (SharedAppTab) -> Unit,
onTabsEnabledChange: (Boolean) -> Unit
) {
val hasWorkspaceActions = SharedAppToolAction.SETTINGS in toolActions ||
SharedAppToolAction.APP_THEME in toolActions ||
SharedAppToolAction.TABS_TOGGLE in toolActions
val hasLibraryActions = SharedAppToolAction.IMPORT_FILES in toolActions ||
SharedAppToolAction.IMPORT_FOLDER in toolActions ||
SharedAppToolAction.SYNC in toolActions
val hasSettingsActions = SharedAppToolAction.AI_SETTINGS in toolActions ||
val hasSettingsActions = SharedAppToolAction.PRO in toolActions ||
SharedAppToolAction.AI_SETTINGS in toolActions ||
SharedAppToolAction.CUSTOM_FONTS in toolActions
val hasProjectActions = SharedAppToolAction.HELP_FEEDBACK in toolActions ||
SharedAppToolAction.SUPPORT in toolActions ||
@ -395,29 +401,65 @@ private fun SharedToolsPanel(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Tools", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("Import, sync, and app settings", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(readerString("desktop_tools", "Tools"), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(readerString("desktop_tools_desc", "Import, sync, and app settings"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Close tools")
Icon(Icons.Default.Close, contentDescription = readerString("desktop_close_tools", "Close tools"))
}
}
if (hasWorkspaceActions) {
SharedToolsSection(readerString("desktop_workspace", "Workspace")) {
if (SharedAppToolAction.SETTINGS in toolActions) {
SharedToolRow(Icons.Default.Settings, readerString("desktop_settings_hub", "Settings hub")) { onOpenTab(SharedAppTab.SETTINGS) }
}
if (SharedAppToolAction.APP_THEME in toolActions) {
SharedToolRow(
icon = Icons.Default.Palette,
title = readerString("app_theme_title", "App theme"),
onClick = onAppThemeRequested
)
}
if (SharedAppToolAction.TABS_TOGGLE in toolActions) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(readerString("desktop_open_readers", "Open readers"), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
Text(
if (isTabsEnabled) readerString("content_desc_enabled", "Enabled") else readerString("desktop_disabled", "Disabled"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = isTabsEnabled,
onCheckedChange = onTabsEnabledChange
)
}
}
}
}
if (hasLibraryActions) {
SharedToolsSection("Library") {
SharedToolsSection(readerString("library_title", "Library")) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
if (SharedAppToolAction.IMPORT_FILES in toolActions) {
Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) {
Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import files")
Text(readerString("desktop_import_files", "Import files"))
}
}
if (SharedAppToolAction.IMPORT_FOLDER in toolActions) {
OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) {
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Add folder")
Text(readerString("fab_add_folder", "Add folder"))
}
}
}
@ -426,11 +468,11 @@ private fun SharedToolsPanel(
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Sync folders")
Text(readerString("desktop_sync_folders", "Sync folders"))
}
} else {
SharedToolRow(Icons.Default.Sync, "Sync metadata", onFolderMetadataSyncRequested)
SharedToolRow(Icons.Default.Search, "Full scan") {
SharedToolRow(Icons.Default.Sync, readerString("desktop_sync_metadata", "Sync metadata"), onFolderMetadataSyncRequested)
SharedToolRow(Icons.Default.Search, readerString("desktop_full_scan", "Full scan")) {
onSyncRequested()
}
}
@ -438,54 +480,30 @@ private fun SharedToolsPanel(
}
}
SharedToolsSection("Appearance") {
if (SharedAppToolAction.APP_THEME in toolActions) {
SharedToolRow(
icon = Icons.Default.Palette,
title = "App theme",
onClick = onAppThemeRequested
)
}
if (SharedAppToolAction.TABS_TOGGLE in toolActions) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(
checked = isTabsEnabled,
onCheckedChange = onTabsEnabledChange
)
}
}
}
if (hasSettingsActions) {
SharedToolsSection("Settings") {
SharedToolsSection(readerString("settings", "Settings")) {
if (SharedAppToolAction.PRO in toolActions) {
SharedToolRow(Icons.Default.Star, readerString("desktop_pro_and_credits", "Pro and credits")) { onOpenTab(SharedAppTab.PRO) }
}
if (SharedAppToolAction.AI_SETTINGS in toolActions) {
SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested)
SharedToolRow(Icons.Default.Settings, readerString("ai_settings_title", "AI keys and models"), onAiSettingsRequested)
}
if (SharedAppToolAction.CUSTOM_FONTS in toolActions) {
SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
SharedToolRow(Icons.Default.TextFields, readerString("custom_fonts", "Custom fonts")) { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
}
}
}
if (hasProjectActions) {
SharedToolsSection("Project") {
SharedToolsSection(readerString("desktop_project", "Project")) {
if (SharedAppToolAction.HELP_FEEDBACK in toolActions) {
SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) }
SharedToolRow(Icons.Default.Feedback, readerString("drawer_help_feedback", "Help & feedback")) { onOpenTab(SharedAppTab.FEEDBACK) }
}
if (SharedAppToolAction.SUPPORT in toolActions) {
SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) }
SharedToolRow(Icons.Default.Favorite, readerString("drawer_support_project", "Support project")) { onOpenTab(SharedAppTab.SUPPORT) }
}
if (SharedAppToolAction.ABOUT in toolActions) {
SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) }
SharedToolRow(Icons.Default.Info, readerString("about_title", "About Episteme")) { onOpenTab(SharedAppTab.ABOUT) }
}
}
}
@ -529,19 +547,22 @@ private fun SharedToolRow(
}
}
private val SharedAppTab.label: String
get() = when (this) {
SharedAppTab.HOME -> "Home"
SharedAppTab.LIBRARY -> "Library"
SharedAppTab.SHELVES -> "Shelves"
SharedAppTab.CATALOGS -> "OPDS"
SharedAppTab.READER -> "Reader"
SharedAppTab.SETTINGS -> "Settings"
SharedAppTab.CUSTOM_FONTS -> "Custom fonts"
SharedAppTab.SUPPORT -> "Support"
SharedAppTab.FEEDBACK -> "Feedback"
SharedAppTab.ABOUT -> "About"
@Composable
private fun SharedAppTab.localizedLabel(): String {
return when (this) {
SharedAppTab.HOME -> readerString("nav_home", "Home")
SharedAppTab.LIBRARY -> readerString("library_title", "Library")
SharedAppTab.SHELVES -> readerString("tab_shelves", "Shelves")
SharedAppTab.CATALOGS -> readerString("opds_stream", "OPDS")
SharedAppTab.READER -> readerString("desktop_reader", "Reader")
SharedAppTab.SETTINGS -> readerString("settings", "Settings")
SharedAppTab.PRO -> readerString("desktop_pro", "Pro")
SharedAppTab.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts")
SharedAppTab.SUPPORT -> readerString("desktop_support", "Support")
SharedAppTab.FEEDBACK -> readerString("desktop_feedback", "Feedback")
SharedAppTab.ABOUT -> readerString("desktop_about", "About")
}
}
private val SharedAppTab.icon: ImageVector
get() = when (this) {
@ -551,6 +572,7 @@ private val SharedAppTab.icon: ImageVector
SharedAppTab.CATALOGS -> Icons.Default.Cloud
SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook
SharedAppTab.SETTINGS -> Icons.Default.Settings
SharedAppTab.PRO -> Icons.Default.Star
SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields
SharedAppTab.SUPPORT -> Icons.Default.Favorite
SharedAppTab.FEEDBACK -> Icons.Default.Feedback

View file

@ -67,6 +67,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
@ -170,6 +171,7 @@ fun SharedAppTheme(
appTextDimFactorLight: Float,
appTextDimFactorDark: Float,
appSeedColor: Color?,
appFontFamily: FontFamily? = null,
content: @Composable () -> Unit
) {
val darkTheme = resolveSharedAppDarkTheme(appThemeMode, isSystemInDarkTheme())
@ -185,11 +187,31 @@ fun SharedAppTheme(
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(),
typography = appFontFamily?.let { Typography().withAppFontFamily(it) } ?: Typography(),
content = content
)
}
fun Typography.withAppFontFamily(fontFamily: FontFamily): Typography {
return copy(
displayLarge = displayLarge.copy(fontFamily = fontFamily),
displayMedium = displayMedium.copy(fontFamily = fontFamily),
displaySmall = displaySmall.copy(fontFamily = fontFamily),
headlineLarge = headlineLarge.copy(fontFamily = fontFamily),
headlineMedium = headlineMedium.copy(fontFamily = fontFamily),
headlineSmall = headlineSmall.copy(fontFamily = fontFamily),
titleLarge = titleLarge.copy(fontFamily = fontFamily),
titleMedium = titleMedium.copy(fontFamily = fontFamily),
titleSmall = titleSmall.copy(fontFamily = fontFamily),
bodyLarge = bodyLarge.copy(fontFamily = fontFamily),
bodyMedium = bodyMedium.copy(fontFamily = fontFamily),
bodySmall = bodySmall.copy(fontFamily = fontFamily),
labelLarge = labelLarge.copy(fontFamily = fontFamily),
labelMedium = labelMedium.copy(fontFamily = fontFamily),
labelSmall = labelSmall.copy(fontFamily = fontFamily)
)
}
fun resolveSharedAppDarkTheme(mode: AppThemeMode, isSystemDark: Boolean): Boolean {
return when (mode) {
AppThemeMode.LIGHT -> false
@ -246,10 +268,11 @@ fun SharedAppThemeSettingsDialog(
onDismiss: () -> Unit
) {
var showCreateDialog by remember { mutableStateOf(false) }
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("App theme", fontWeight = FontWeight.Bold) },
title = { Text(readerString("app_theme_title", "App theme"), fontWeight = FontWeight.Bold) },
text = {
Column(
modifier = Modifier
@ -258,42 +281,42 @@ fun SharedAppThemeSettingsDialog(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
SettingsLabel("Appearance")
SettingsLabel(readerString("app_theme_appearance", "Appearance"))
SegmentedControl(
values = AppThemeMode.entries,
selectedValue = appThemeMode,
label = { it.label },
label = { it.localizedLabel() },
onValueSelected = onThemeModeChanged
)
SettingsLabel("Contrast")
SettingsLabel(readerString("app_theme_contrast", "Contrast"))
SegmentedControl(
values = AppContrastOption.entries,
selectedValue = appContrastOption,
label = { it.label },
label = { it.localizedLabel() },
onValueSelected = onContrastOptionChanged
)
if (appThemeMode == AppThemeMode.SYSTEM) {
TextBrightnessSlider(
label = "Text brightness (Light)",
label = readerString("app_theme_text_brightness_light", "Text brightness (Light)"),
value = appTextDimFactorLight,
onValueChange = onTextDimFactorLightChanged
)
TextBrightnessSlider(
label = "Text brightness (Dark)",
label = readerString("app_theme_text_brightness_dark", "Text brightness (Dark)"),
value = appTextDimFactorDark,
onValueChange = onTextDimFactorDarkChanged
)
} else {
TextBrightnessSlider(
label = "Text brightness",
label = readerString("app_theme_text_brightness", "Text brightness"),
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
)
}
SettingsLabel("Color scheme")
SettingsLabel(readerString("app_theme_color_scheme", "Color scheme"))
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp)
@ -301,14 +324,14 @@ fun SharedAppThemeSettingsDialog(
ThemeSwatch(
color = MaterialTheme.colorScheme.primary,
selected = appSeedColor == null,
label = "Dynamic",
label = readerString("app_theme_dynamic", "Dynamic"),
onClick = { onSeedColorChanged(null) }
)
AppThemePresets.forEach { preset ->
ThemeSwatch(
color = preset.color,
selected = appSeedColor == preset.color,
label = preset.name,
label = readerString(preset.nameKey, preset.nameFallback),
onClick = { onSeedColorChanged(preset.color) }
)
}
@ -321,15 +344,15 @@ fun SharedAppThemeSettingsDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SettingsLabel("My themes")
SettingsLabel(readerString("theme_my_themes", "My themes"))
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Add, contentDescription = "Add custom theme")
Icon(Icons.Default.Add, contentDescription = readerString("content_desc_add_custom_theme", "Add custom theme"))
}
}
if (customAppThemes.isEmpty()) {
Text(
"No custom themes yet",
readerString("desktop_no_custom_themes_yet", "No custom themes yet"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -353,7 +376,7 @@ fun SharedAppThemeSettingsDialog(
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Done")
Text(readerString("action_done", "Done"))
}
}
)
@ -365,7 +388,7 @@ fun SharedAppThemeSettingsDialog(
onCustomThemeAdded(
CustomAppTheme(
id = Random.nextLong().toString(),
name = name.ifBlank { "Custom" },
name = name.ifBlank { defaultCustomThemeName },
seedColor = color
)
)
@ -389,7 +412,7 @@ private fun SettingsLabel(label: String) {
private fun <T> SegmentedControl(
values: List<T>,
selectedValue: T,
label: (T) -> String,
label: @Composable (T) -> String,
onValueSelected: (T) -> Unit
) {
Row(
@ -401,6 +424,7 @@ private fun <T> SegmentedControl(
) {
values.forEach { value ->
val selected = selectedValue == value
val valueLabel = label(value)
Box(
modifier = Modifier
.weight(1f)
@ -411,7 +435,7 @@ private fun <T> SegmentedControl(
contentAlignment = Alignment.Center
) {
Text(
text = label(value),
text = valueLabel,
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
@ -501,7 +525,7 @@ private fun ThemeSwatch(
if (onDelete != null) {
Icon(
Icons.Default.Close,
contentDescription = "Delete",
contentDescription = readerString("action_delete", "Delete"),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp).clickable(onClick = onDelete)
)
@ -519,6 +543,7 @@ private fun SharedCreateAppThemeDialog(
var name by remember { mutableStateOf("") }
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
val color = hsv.toComposeColor()
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
fun updateFromColor(nextColor: Color) {
hsv = nextColor.toSharedHsvColor()
@ -526,7 +551,7 @@ private fun SharedCreateAppThemeDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create theme") },
title = { Text(readerString("desktop_create_theme", "Create theme")) },
text = {
Column(
modifier = Modifier.widthIn(max = 560.dp).verticalScroll(rememberScrollState()),
@ -536,7 +561,7 @@ private fun SharedCreateAppThemeDialog(
SharedStableOutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Theme name") },
label = { Text(readerString("theme_name", "Theme name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
@ -574,7 +599,7 @@ private fun SharedCreateAppThemeDialog(
modifier = Modifier.weight(1.6f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Text(readerString("theme_color_hex", "Hex"), color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Spacer(Modifier.height(4.dp))
SharedHexInput(color = color, onHexChanged = { updateFromColor(it) })
}
@ -607,18 +632,18 @@ private fun SharedCreateAppThemeDialog(
},
confirmButton = {
Button(
onClick = { onSave(name.trim().ifBlank { "Custom" }, color) },
onClick = { onSave(name.trim().ifBlank { defaultCustomThemeName }, color) },
colors = ButtonDefaults.buttonColors(
containerColor = color,
contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White
)
) {
Text("Save", fontWeight = FontWeight.Bold)
Text(readerString("action_save", "Save"), fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
}
)
@ -664,7 +689,7 @@ fun SharedHsvColorPickerDialog(
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f))
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
Icon(Icons.Default.Close, contentDescription = readerString("action_close", "Close"))
}
}
Column(
@ -710,7 +735,7 @@ fun SharedHsvColorPickerDialog(
modifier = Modifier.weight(1.6f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Text(readerString("theme_color_hex", "Hex"), color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Spacer(Modifier.height(4.dp))
SharedHexInput(color = color, onHexChanged = { updateFromColor(it) })
}
@ -746,7 +771,7 @@ fun SharedHsvColorPickerDialog(
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
Button(
onClick = { onSave(color) },
@ -755,7 +780,7 @@ fun SharedHsvColorPickerDialog(
contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White
)
) {
Text("Save", fontWeight = FontWeight.Bold)
Text(readerString("action_save", "Save"), fontWeight = FontWeight.Bold)
}
}
}
@ -1161,33 +1186,38 @@ private fun ColorScheme.withTextDimFactor(factor: Float): ColorScheme {
}
private data class AppThemePreset(
val name: String,
val nameKey: String,
val nameFallback: String,
val color: Color
)
private val AppThemePresets = listOf(
AppThemePreset("Ocean", Color(0xFF00668B)),
AppThemePreset("Mint", Color(0xFF006C4C)),
AppThemePreset("Rose", Color(0xFF9C4146)),
AppThemePreset("Sepia", Color(0xFF705D49)),
AppThemePreset("Amethyst", Color(0xFF9B59B6)),
AppThemePreset("Amber", Color(0xFFFFC107)),
AppThemePreset("Sapphire", Color(0xFF0F52BA))
AppThemePreset("desktop_theme_preset_ocean", "Ocean", Color(0xFF00668B)),
AppThemePreset("desktop_theme_preset_mint", "Mint", Color(0xFF006C4C)),
AppThemePreset("desktop_theme_preset_rose", "Rose", Color(0xFF9C4146)),
AppThemePreset("desktop_theme_preset_sepia", "Sepia", Color(0xFF705D49)),
AppThemePreset("desktop_theme_preset_amethyst", "Amethyst", Color(0xFF9B59B6)),
AppThemePreset("desktop_theme_preset_amber", "Amber", Color(0xFFFFC107)),
AppThemePreset("desktop_theme_preset_sapphire", "Sapphire", Color(0xFF0F52BA))
)
private val AppThemeMode.label: String
get() = when (this) {
AppThemeMode.SYSTEM -> "System"
AppThemeMode.LIGHT -> "Light"
AppThemeMode.DARK -> "Dark"
@Composable
private fun AppThemeMode.localizedLabel(): String {
return when (this) {
AppThemeMode.SYSTEM -> readerString("language_system_default", "System")
AppThemeMode.LIGHT -> readerString("app_theme_mode_light", "Light")
AppThemeMode.DARK -> readerString("app_theme_mode_dark", "Dark")
}
}
private val AppContrastOption.label: String
get() = when (this) {
AppContrastOption.STANDARD -> "Standard"
AppContrastOption.MEDIUM -> "Medium"
AppContrastOption.HIGH -> "High"
@Composable
private fun AppContrastOption.localizedLabel(): String {
return when (this) {
AppContrastOption.STANDARD -> readerString("app_contrast_standard", "Standard")
AppContrastOption.MEDIUM -> readerString("app_contrast_medium", "Medium")
AppContrastOption.HIGH -> readerString("app_contrast_high", "High")
}
}
internal data class SharedHsvColor(
val hue: Float,

View file

@ -95,7 +95,7 @@ fun SharedTextInputDialog(
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
}
)
@ -120,7 +120,7 @@ fun SharedConfirmDialog(
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
}
)
@ -135,10 +135,10 @@ fun SharedAddToShelfDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add to shelf") },
title = { Text(readerString("desktop_add_to_shelf", "Add to shelf")) },
text = {
if (shelves.isEmpty()) {
Text("Create a shelf first, then add selected books to it.")
Text(readerString("desktop_create_shelf_first", "Create a shelf first, then add selected books to it."))
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(shelves, key = { it.id }) { shelf ->
@ -165,12 +165,12 @@ fun SharedAddToShelfDialog(
},
confirmButton = {
TextButton(onClick = onCreateShelf) {
Text("New shelf")
Text(readerString("fab_new_shelf", "New shelf"))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
}
)
@ -221,9 +221,13 @@ fun SharedBookInfoDialog(
Column(Modifier.fillMaxSize()) {
SharedBookInfoTopBar(
title = if (isEditing) {
if (canEditEmbeddedMetadata) "Edit EPUB metadata" else "Rename in app"
if (canEditEmbeddedMetadata) {
readerString("desktop_edit_epub_metadata", "Edit EPUB metadata")
} else {
readerString("desktop_rename_in_app", "Rename in app")
}
} else {
"Book information"
readerString("file_information", "Book information")
},
subtitle = book.cardTitle(),
onClose = {
@ -287,7 +291,7 @@ fun SharedBookInfoDialog(
isEditing = isEditing,
canEdit = canEditEmbeddedMetadata || canRenameDisplayName,
canRestore = canRestoreEmbeddedMetadata && hasOriginalMetadata && (hasMetadataChanges || isEditing),
editLabel = if (canEditEmbeddedMetadata) "Edit metadata" else "Rename",
editLabel = if (canEditEmbeddedMetadata) readerString("action_edit", "Edit") else readerString("action_rename", "Rename"),
onCancel = {
if (isEditing) {
isEditing = false
@ -331,10 +335,13 @@ fun SharedBookInfoDialog(
AlertDialog(
onDismissRequest = { showRestoreConfirmation = false },
icon = { Icon(Icons.Default.Restore, contentDescription = null) },
title = { Text("Restore original metadata?") },
title = { Text(readerString("dialog_restore_original_metadata", "Restore original metadata?")) },
text = {
Text(
"This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
readerString(
"dialog_restore_original_metadata_desc",
"This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
)
)
},
confirmButton = {
@ -345,12 +352,12 @@ fun SharedBookInfoDialog(
onDismiss()
}
) {
Text("Restore")
Text(readerString("action_restore", "Restore"))
}
},
dismissButton = {
TextButton(onClick = { showRestoreConfirmation = false }) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
}
)
@ -370,7 +377,7 @@ private fun SharedBookInfoTopBar(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Close")
Icon(Icons.Default.Close, contentDescription = readerString("action_close", "Close"))
}
Column(
modifier = Modifier
@ -415,10 +422,10 @@ private fun SharedBookMetadataInfoContent(
)
}
val provenance = when {
book.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited"
book.type == FileType.EPUB -> "Metadata from EPUB file"
hasMetadataChanges -> "Display name changed in app"
else -> "Metadata from file"
book.type == FileType.EPUB && hasMetadataChanges -> readerString("metadata_provenance_epub_edited", "EPUB metadata edited")
book.type == FileType.EPUB -> readerString("metadata_provenance_from_epub", "Metadata from EPUB file")
hasMetadataChanges -> readerString("metadata_provenance_display_name_changed", "Display name changed in app")
else -> readerString("metadata_provenance_from_file", "Metadata from file")
}
Text(
provenance,
@ -427,37 +434,37 @@ private fun SharedBookMetadataInfoContent(
)
}
SharedInfoSection(title = "Metadata") {
SharedInfoRowDetailed("Title", book.title?.takeIf { it.isNotBlank() } ?: book.displayName, maxLines = 3)
SharedInfoSection(title = readerString("section_metadata", "Metadata")) {
SharedInfoRowDetailed(readerString("label_title", "Title"), book.title?.takeIf { it.isNotBlank() } ?: book.displayName, maxLines = 3)
book.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
SharedInfoRowDetailed("Author", it, maxLines = 2)
SharedInfoRowDetailed(readerString("author", "Author"), it, maxLines = 2)
}
book.seriesLabel()?.let {
SharedInfoRowDetailed("Series", it, maxLines = 2)
SharedInfoRowDetailed(readerString("label_series", "Series"), it, maxLines = 2)
}
SharedInfoRowDetailed("Format", book.type.name)
SharedInfoRowDetailed("Size", formatFileSize(book.fileSize))
SharedInfoRowDetailed("Reading", book.readingProgressText(), maxLines = 2)
SharedInfoRowDetailed(readerString("format", "Format"), book.type.name)
SharedInfoRowDetailed(readerString("size", "Size"), formatFileSize(book.fileSize))
SharedInfoRowDetailed(readerString("label_reading", "Reading"), book.readingProgressText(), maxLines = 2)
}
SharedInfoSection(title = "File") {
SharedInfoRowDetailed("File name", book.displayName, maxLines = 2)
SharedInfoRowDetailed("Location", book.path.orEmpty().ifBlank { "Not available" }, maxLines = 4, onCopy = onCopyPath)
SharedInfoSection(title = readerString("section_file", "File")) {
SharedInfoRowDetailed(readerString("label_file_name_simple", "File name"), book.displayName, maxLines = 2)
SharedInfoRowDetailed(readerString("location", "Location"), book.path.orEmpty().ifBlank { readerString("not_available_locally", "Not available") }, maxLines = 4, onCopy = onCopyPath)
book.sourceFolder?.takeIf { it.isNotBlank() }?.let {
SharedInfoRowDetailed("Source folder", it, maxLines = 3)
SharedInfoRowDetailed(readerString("filter_source_folder", "Source folder"), it, maxLines = 3)
}
}
book.description?.takeIf { it.isNotBlank() }?.let { summary ->
SharedInfoSection(title = "Summary") {
SharedInfoSection(title = readerString("label_summary", "Summary")) {
SharedExpandableSummaryText(summary, collapsedMaxLines = 4)
}
}
SharedInfoSection(title = "Tags") {
SharedInfoSection(title = readerString("section_tags", "Tags")) {
if (book.tags.isEmpty()) {
Text(
"No tags assigned",
readerString("msg_no_tags_assigned", "No tags assigned."),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -490,11 +497,11 @@ private fun SharedBookMetadataEditContent(
onTagChange: (String) -> Unit,
knownTags: List<Tag>
) {
SharedInfoSection(title = "Editable metadata") {
SharedInfoSection(title = readerString("label_editable_metadata", "Editable metadata")) {
SharedStableOutlinedTextField(
value = titleInput,
onValueChange = onTitleChange,
label = { Text("Title") },
label = { Text(readerString("label_title", "Title")) },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
selectionKey = "title"
@ -502,7 +509,7 @@ private fun SharedBookMetadataEditContent(
SharedStableOutlinedTextField(
value = authorInput,
onValueChange = onAuthorChange,
label = { Text("Author") },
label = { Text(readerString("author", "Author")) },
modifier = Modifier.fillMaxWidth(),
maxLines = 2,
selectionKey = "author"
@ -511,7 +518,7 @@ private fun SharedBookMetadataEditContent(
SharedStableOutlinedTextField(
value = seriesInput,
onValueChange = onSeriesChange,
label = { Text("Series") },
label = { Text(readerString("label_series", "Series")) },
modifier = Modifier.weight(1f),
maxLines = 2,
selectionKey = "series"
@ -529,7 +536,7 @@ private fun SharedBookMetadataEditContent(
SharedStableOutlinedTextField(
value = descriptionInput,
onValueChange = onDescriptionChange,
label = { Text("Summary") },
label = { Text(readerString("label_summary", "Summary")) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp),
@ -539,18 +546,18 @@ private fun SharedBookMetadataEditContent(
)
}
SharedInfoSection(title = "Library tags") {
SharedInfoSection(title = readerString("label_library_tags", "Library tags")) {
SharedStableOutlinedTextField(
value = tagInput,
onValueChange = onTagChange,
label = { Text("Tags, comma separated") },
label = { Text(readerString("desktop_tags_comma_separated", "Tags, comma separated")) },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
selectionKey = "tags"
)
if (knownTags.isNotEmpty()) {
Text(
"Existing: ${knownTags.joinToString { it.name }}",
readerString("desktop_existing_tags_format", "Existing: %1\$s", knownTags.joinToString { it.name }),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
@ -568,29 +575,29 @@ private fun SharedBookDisplayNameEditContent(
onTagChange: (String) -> Unit,
knownTags: List<Tag>
) {
SharedInfoSection(title = "Display name") {
SharedInfoSection(title = readerString("label_display_name", "Display name")) {
SharedStableOutlinedTextField(
value = displayNameInput,
onValueChange = onDisplayNameChange,
label = { Text("Name shown in Reader") },
label = { Text(readerString("label_name_shown_in_reader", "Name shown in Reader")) },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
selectionKey = "displayName"
)
}
SharedInfoSection(title = "Library tags") {
SharedInfoSection(title = readerString("label_library_tags", "Library tags")) {
SharedStableOutlinedTextField(
value = tagInput,
onValueChange = onTagChange,
label = { Text("Tags, comma separated") },
label = { Text(readerString("desktop_tags_comma_separated", "Tags, comma separated")) },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
selectionKey = "renameTags"
)
if (knownTags.isNotEmpty()) {
Text(
"Existing: ${knownTags.joinToString { it.name }}",
readerString("desktop_existing_tags_format", "Existing: %1\$s", knownTags.joinToString { it.name }),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
@ -626,18 +633,18 @@ private fun SharedBookInfoBottomBar(
) {
Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Restore")
Text(readerString("action_restore", "Restore"))
}
}
TextButton(onClick = onCancel) {
Text(if (isEditing) "Cancel" else "Close")
Text(if (isEditing) readerString("action_cancel", "Cancel") else readerString("action_close", "Close"))
}
Spacer(Modifier.width(8.dp))
if (isEditing) {
Button(onClick = onSave) {
Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Save")
Text(readerString("action_save", "Save"))
}
} else if (canEdit) {
Button(onClick = onEdit) {
@ -697,13 +704,13 @@ private fun SharedInfoRowDetailed(
Column(modifier = Modifier.weight(1f)) {
SharedExpandableValueText(value, collapsedMaxLines = maxLines)
}
if (onCopy != null && value != "Not available") {
if (onCopy != null && value != readerString("not_available_locally", "Not available")) {
TextButton(
onClick = onCopy,
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
modifier = Modifier.height(30.dp)
) {
Text("Copy")
Text(readerString("action_copy", "Copy"))
}
}
}
@ -771,7 +778,7 @@ private fun SharedMoreButton(
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text(if (expanded) "Less" else "...more")
Text(if (expanded) readerString("desktop_less", "Less") else readerString("desktop_more", "...more"))
Spacer(Modifier.width(2.dp))
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,

View file

@ -275,7 +275,7 @@ fun SharedNativePaginatedReader(
if (visiblePages.isEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Text("No page content", color = renderPlan.foreground.copy(alpha = 0.68f))
Text(readerString("desktop_no_page_content", "No page content"), color = renderPlan.foreground.copy(alpha = 0.68f))
}
return
}

View file

@ -146,7 +146,7 @@ fun SharedOpdsScreen(
modifier = Modifier.weight(1f)
)
TextButton(onClick = onClearError) {
Text("Dismiss")
Text(readerString("action_dismiss", "Dismiss"))
}
}
}
@ -176,8 +176,8 @@ fun SharedOpdsScreen(
catalogToDelete?.let { catalog ->
AlertDialog(
onDismissRequest = { catalogToDelete = null },
title = { Text("Delete catalog") },
text = { Text("Delete \"${catalog.title}\"? Streamed books from this catalog may stop opening if credentials change later.") },
title = { Text(readerString("delete_catalog", "Delete catalog")) },
text = { Text(readerString("desktop_opds_delete_catalog_desc", "Delete \"%1\$s\"? Streamed books from this catalog may stop opening if credentials change later.", catalog.title)) },
confirmButton = {
TextButton(
onClick = {
@ -185,12 +185,12 @@ fun SharedOpdsScreen(
catalogToDelete = null
}
) {
Text("Delete")
Text(readerString("action_delete", "Delete"))
}
},
dismissButton = {
TextButton(onClick = { catalogToDelete = null }) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
}
)
@ -232,12 +232,12 @@ private fun SharedOpdsCatalogList(
Column(Modifier.fillMaxSize()) {
SharedScreenScaffold(
title = "OPDS",
subtitle = "Browse catalogs, streams, and downloads",
subtitle = readerString("desktop_opds_subtitle", "Browse catalogs, streams, and downloads"),
trailing = {
Button(onClick = onAddCatalog) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Catalog")
Text(readerString("desktop_opds_catalog", "Catalog"))
}
}
) {
@ -299,13 +299,13 @@ private fun SharedOpdsFeedView(
onNavigateBack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = readerString("action_back", "Back"))
}
if (showSearch) {
SharedStableOutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search catalog") },
placeholder = { Text(readerString("search_catalog_placeholder", "Search catalog")) },
singleLine = true,
modifier = Modifier.weight(1f),
trailingIcon = {
@ -316,14 +316,14 @@ private fun SharedOpdsFeedView(
showSearch = false
}
}) {
Icon(Icons.Default.Search, contentDescription = "Search")
Icon(Icons.Default.Search, contentDescription = readerString("action_search", "Search"))
}
}
)
} else {
Column(Modifier.weight(1f)) {
Text(
text = state.currentFeed?.title ?: "Loading",
text = state.currentFeed?.title ?: readerString("status_loading", "Loading"),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
@ -341,7 +341,7 @@ private fun SharedOpdsFeedView(
}
if (state.searchUrlTemplate != null) {
IconButton(onClick = { showSearch = true }) {
Icon(Icons.Default.Search, contentDescription = "Search")
Icon(Icons.Default.Search, contentDescription = readerString("action_search", "Search"))
}
}
}
@ -375,7 +375,7 @@ private fun SharedOpdsFeedView(
val entries = state.currentFeed?.entries.orEmpty()
if (entries.isEmpty() && !state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("This feed is empty.")
Text(readerString("feed_empty", "This feed is empty."))
}
} else {
LazyColumn(
@ -463,7 +463,7 @@ private fun SharedOpdsCatalogCard(
shape = RoundedCornerShape(6.dp)
) {
Text(
"Preset",
readerString("preset_label", "Preset"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
@ -474,10 +474,10 @@ private fun SharedOpdsCatalogCard(
Spacer(Modifier.weight(1f))
if (!catalog.isDefault) {
IconButton(onClick = onEditCatalog) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
Icon(Icons.Default.Edit, contentDescription = readerString("action_edit", "Edit"))
}
IconButton(onClick = onDeleteCatalog) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
Icon(Icons.Default.Delete, contentDescription = readerString("action_delete", "Delete"))
}
}
}
@ -496,12 +496,12 @@ private fun SharedOpdsEmptyState(onAddCatalog: () -> Unit, modifier: Modifier =
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary)
Text("No catalogs", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Add an OPDS catalog to browse remote books.", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(readerString("desktop_opds_no_catalogs", "No catalogs"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(readerString("desktop_opds_no_catalogs_desc", "Add an OPDS catalog to browse remote books."), color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onAddCatalog) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Add catalog")
Text(readerString("fab_add_catalog", "Add catalog"))
}
}
}
@ -520,7 +520,16 @@ private fun SharedOpdsFacetMenu(
FilterChip(
selected = activeFacet?.isActive == true,
onClick = { expanded = true },
label = { Text("$groupName: ${activeFacet?.title ?: "Select"}") },
label = {
Text(
readerString(
"filter_facet",
"%1\$s: %2\$s",
groupName,
activeFacet?.title ?: readerString("action_select", "Select")
)
)
},
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
@ -610,7 +619,7 @@ private fun SharedOpdsBookCard(
OutlinedButton(onClick = { onReadBook(localLibraryBook) }, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Read")
Text(readerString("action_read", "Read"))
}
}
isDownloading -> SharedOpdsDownloadProgress(downloadState)
@ -619,7 +628,7 @@ private fun SharedOpdsBookCard(
FilledTonalButton(onClick = onStreamBook, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Stream")
Text(readerString("action_stream", "Stream"))
}
}
Box {
@ -640,7 +649,13 @@ private fun SharedOpdsBookCard(
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(if (uniqueAcquisitions.isEmpty()) "Unavailable" else "Download")
Text(
if (uniqueAcquisitions.isEmpty()) {
readerString("action_unavailable", "Unavailable")
} else {
readerString("action_download", "Download")
}
)
}
DropdownMenu(expanded = showFormatMenu, onDismissRequest = { showFormatMenu = false }) {
uniqueAcquisitions.forEach { acquisition ->
@ -666,7 +681,7 @@ private fun SharedOpdsDownloadProgress(downloadState: SharedOpdsDownloadState?)
val progress = downloadState?.progress
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Downloading", style = MaterialTheme.typography.labelMedium)
Text(readerString("status_downloading", "Downloading"), style = MaterialTheme.typography.labelMedium)
Spacer(Modifier.weight(1f))
if (progress != null) {
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
@ -725,9 +740,15 @@ private fun SharedOpdsEntryDetailsDialog(
)
}
val secondary = listOfNotNull(
entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" },
entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" },
entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" }
entry.publisher?.takeIf { it.isNotBlank() }?.let {
readerString("filter_facet", "%1\$s: %2\$s", readerString("publisher", "Publisher"), it)
},
entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let {
readerString("filter_facet", "%1\$s: %2\$s", readerString("published", "Published"), it)
},
entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let {
readerString("filter_facet", "%1\$s: %2\$s", readerString("language", "Language"), it)
}
)
secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
@ -736,7 +757,7 @@ private fun SharedOpdsEntryDetailsDialog(
Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Read")
Text(readerString("action_read", "Read"))
}
}
if (downloadState?.isDownloading == true) {
@ -746,11 +767,11 @@ private fun SharedOpdsEntryDetailsDialog(
Button(onClick = onStreamBook, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Cloud, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Stream now")
Text(readerString("action_stream_now", "Stream now"))
}
}
if (uniqueAcquisitions.isNotEmpty()) {
Text("Download format", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(readerString("download_format", "Download format"), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
uniqueAcquisitions.take(4).forEach { acquisition ->
FilledTonalButton(onClick = { onDownloadBook(acquisition) }) {
@ -762,7 +783,7 @@ private fun SharedOpdsEntryDetailsDialog(
}
if (entry.authors.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Authors", style = MaterialTheme.typography.labelLarge)
Text(readerString("desktop_authors", "Authors"), style = MaterialTheme.typography.labelLarge)
entry.authors.forEach { author ->
TextButton(
onClick = {
@ -775,7 +796,7 @@ private fun SharedOpdsEntryDetailsDialog(
}
}
if (entry.categories.isNotEmpty()) {
Text("Categories", style = MaterialTheme.typography.labelLarge)
Text(readerString("desktop_categories", "Categories"), style = MaterialTheme.typography.labelLarge)
entry.categories.distinct().take(8).forEach { category ->
TextButton(onClick = { onSearch(category) }) {
Text(category)
@ -784,14 +805,14 @@ private fun SharedOpdsEntryDetailsDialog(
}
val summary = SharedOpdsText.cleanSummary(entry.summary)
if (summary.isNotBlank()) {
Text("Synopsis", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Text(readerString("synopsis", "Synopsis"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Text(summary, style = MaterialTheme.typography.bodyMedium)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Close")
Text(readerString("action_close", "Close"))
}
}
)
@ -811,17 +832,17 @@ private fun SharedOpdsCatalogDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (isEditMode) "Edit catalog" else "Add OPDS catalog") },
title = { Text(if (isEditMode) readerString("edit_catalog", "Edit catalog") else readerString("add_opds_catalog", "Add OPDS catalog")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SharedStableOutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true, selectionKey = catalog?.id ?: "new:title")
SharedStableOutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true, selectionKey = catalog?.id ?: "new:url")
Text("Authentication optional", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
SharedStableOutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true, selectionKey = catalog?.id ?: "new:username")
SharedStableOutlinedTextField(value = title, onValueChange = { title = it }, label = { Text(readerString("catalog_name", "Catalog name")) }, singleLine = true, selectionKey = catalog?.id ?: "new:title")
SharedStableOutlinedTextField(value = url, onValueChange = { url = it }, label = { Text(readerString("url", "URL")) }, singleLine = true, selectionKey = catalog?.id ?: "new:url")
Text(readerString("auth_optional", "Authentication optional"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
SharedStableOutlinedTextField(value = username, onValueChange = { username = it }, label = { Text(readerString("username", "Username")) }, singleLine = true, selectionKey = catalog?.id ?: "new:username")
SharedStableOutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
label = { Text(readerString("password", "Password")) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
selectionKey = catalog?.id ?: "new:password"
@ -833,12 +854,12 @@ private fun SharedOpdsCatalogDialog(
onClick = { onSave(title, url, username, password) },
enabled = title.isNotBlank() && url.isNotBlank()
) {
Text("Save")
Text(readerString("action_save", "Save"))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
}
)

View file

@ -240,7 +240,7 @@ fun SharedPdfAnnotationToolDock(
DockCircleButton(onClick = onUndo) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Undo,
contentDescription = "Undo annotation",
contentDescription = readerString("desktop_undo_annotation", "Undo annotation"),
tint = Color.White,
modifier = Modifier.size(18.dp)
)
@ -248,7 +248,7 @@ fun SharedPdfAnnotationToolDock(
DockCircleButton(onClick = onClearPage) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Clear page annotations",
contentDescription = readerString("desktop_clear_page_annotations", "Clear page annotations"),
tint = Color.White,
modifier = Modifier.size(18.dp)
)
@ -543,7 +543,7 @@ fun SharedPdfHighlighterPaletteEditor(
val initialColor = Color(sanitized.colors.getOrElse(slot) { SharedPdfHighlighterPalette.defaultColors.first() }).copy(alpha = 1f)
SharedHsvColorPickerDialog(
initialColor = initialColor,
title = "Highlight color ${slot + 1}",
title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1),
onDismiss = { editingSlot = null },
onSave = { color ->
onPaletteChange(
@ -575,9 +575,9 @@ fun SharedPdfHighlighterPaletteEditor(
)
}
Column(modifier = Modifier.weight(1f)) {
Text("PDF highlighter", fontWeight = FontWeight.SemiBold)
Text(readerString("desktop_pdf_highlighter", "PDF highlighter"), fontWeight = FontWeight.SemiBold)
Text(
"Saved with reader highlight transparency.",
readerString("desktop_pdf_highlighter_alpha_desc", "Saved with reader highlight transparency."),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
@ -860,7 +860,7 @@ fun SharedPdfTextStyleControls(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Font", color = labelColor, style = MaterialTheme.typography.labelMedium)
Text(readerString("select_font", "Font"), color = labelColor, style = MaterialTheme.typography.labelMedium)
Box {
TextButton(onClick = { fontMenuExpanded = true }) {
Text(
@ -944,7 +944,7 @@ fun SharedPdfTextStyleControls(
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Text", color = labelColor, style = MaterialTheme.typography.labelMedium)
Text(readerString("content_desc_text", "Text"), color = labelColor, style = MaterialTheme.typography.labelMedium)
SharedTextColorSwatches(
palette = SharedPdfTextAnnotationDefaults.textColorPalette,
selectedArgb = style.colorArgb,
@ -955,7 +955,7 @@ fun SharedPdfTextStyleControls(
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Fill", color = labelColor, style = MaterialTheme.typography.labelMedium)
Text(readerString("desktop_fill", "Fill"), color = labelColor, style = MaterialTheme.typography.labelMedium)
SharedTextColorSwatches(
palette = SharedPdfTextAnnotationDefaults.backgroundColorPalette,
selectedArgb = style.backgroundColorArgb,
@ -1154,13 +1154,13 @@ private fun SharedPdfToolButton(
when (tool) {
PdfInkTool.TEXT -> Icon(
imageVector = Icons.Default.TextFields,
contentDescription = "text",
contentDescription = readerString("content_desc_text", "text"),
tint = Color.White,
modifier = Modifier.size(20.dp)
)
PdfInkTool.ERASER -> Icon(
imageVector = Icons.Default.Remove,
contentDescription = "eraser",
contentDescription = readerString("content_desc_eraser", "eraser"),
tint = Color.White,
modifier = Modifier.size(20.dp)
)

View file

@ -16,7 +16,11 @@ internal val LocalSharedReaderModalAnchorBounds = compositionLocalOf<SharedReade
internal enum class SharedReaderModalLevel {
Panel,
Popup
PanelLeft,
PanelRight,
Popup,
ChromeTop,
ChromeBottom
}
val SharedReaderPopupDefaultMaxWidth = 440.dp
@ -42,6 +46,14 @@ internal expect fun SharedReaderModalLayer(
content: @Composable () -> Unit
)
internal expect fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean
@Composable
expect fun SharedReaderModalOwnerWindowProvider(
ownerWindow: Any?,
content: @Composable () -> Unit
)
@Composable
fun SharedReaderPopupLayer(
onDismiss: () -> Unit,

View file

@ -134,7 +134,7 @@ fun SharedSettingsHub(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
label = { Text("Search settings") }
label = { Text(readerString("desktop_search_settings", "Search settings")) }
)
when {
@ -185,7 +185,7 @@ private fun SharedSettingsHeader(
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (canNavigateUp) {
TextButton(onClick = onNavigateUp) {
Text("Back")
Text(readerString("action_back", "Back"))
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
@ -340,7 +340,7 @@ private fun SharedSettingsEmptySearch(modifier: Modifier) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(36.dp))
Text("No settings found", fontWeight = FontWeight.SemiBold)
Text(readerString("desktop_no_settings_found", "No settings found"), fontWeight = FontWeight.SemiBold)
}
}
}
@ -536,9 +536,12 @@ private fun SharedSettingsDetailPage(
}
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS -> {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Fixed-layout appearance", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(readerString("desktop_fixed_layout_appearance", "Fixed-layout appearance"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
"These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader.",
readerString(
"desktop_pdf_appearance_defaults_desc",
"These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader."
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
SharedReaderThemeControls(
@ -549,18 +552,18 @@ private fun SharedSettingsDetailPage(
onSettingsChange = onPdfSettingsChange
)
HorizontalDivider()
Text("Visual options", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(readerString("visual_options_title", "Visual options"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
SharedPdfVisualOptionDefaultsSwitch(
title = "Remove gap between pages",
summary = "Applies to vertical PDF reading mode.",
title = readerString("visual_options_remove_page_gap", "Remove gap between pages"),
summary = readerString("desktop_remove_gap_between_pages_desc", "Applies to vertical reading mode."),
checked = !pdfSettings.pdfVerticalPageGapVisible,
onCheckedChange = { removeGap ->
onPdfSettingsChange(pdfSettings.copy(pdfVerticalPageGapVisible = !removeGap))
}
)
SharedPdfVisualOptionDefaultsSwitch(
title = "Hide page number overlay",
summary = "Removes the small page count label from each PDF page.",
title = readerString("visual_options_hide_page_number_overlay", "Hide page number overlay"),
summary = readerString("visual_options_hide_page_number_overlay_desc", "Removes the small page count label from each page."),
checked = !pdfSettings.pdfPageNumberOverlayVisible,
onCheckedChange = { hideOverlay ->
onPdfSettingsChange(pdfSettings.copy(pdfPageNumberOverlayVisible = !hideOverlay))
@ -570,16 +573,19 @@ private fun SharedSettingsDetailPage(
}
SharedSettingsDestination.PDF_READER_TOOLS -> {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Reader-managed PDF tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(readerString("desktop_reader_managed_pdf_tools", "Reader-managed PDF tools"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
"Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader.",
readerString(
"desktop_reader_managed_pdf_tools_desc",
"Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader."
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS -> {
if (toolbarPreferences == null) {
Text("Reader toolbar defaults are managed from the reader on this platform.")
Text(readerString("desktop_reader_toolbar_managed_in_reader", "Reader toolbar defaults are managed from the reader on this platform."))
} else {
SharedReaderToolbarControls(
toolbarPreferences = toolbarPreferences,
@ -717,6 +723,7 @@ private fun SharedSettingsAction.iconForSettings(): ImageVector {
SharedSettingsAction.TABS_TOGGLE,
SharedSettingsAction.RECENT_LIMIT,
SharedSettingsAction.STRICT_FILE_FILTER,
SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME,
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> Icons.Default.Settings
}

View file

@ -0,0 +1,100 @@
package com.aryan.reader.shared.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import com.aryan.reader.shared.BannerMessage
import com.aryan.reader.shared.SharedText
@Immutable
class SharedStringResolver(
private val resolve: (name: String) -> String? = { null },
private val resolveQuantity: (name: String, quantity: Int) -> String? = { _, _ -> null }
) {
fun string(name: String, fallback: String, vararg args: Any?): String {
val template = resolve(name).takeUnless { it.isNullOrBlank() } ?: fallback
return formatAndroidString(template, args.toList())
}
fun quantityString(
name: String,
quantity: Int,
fallbackOne: String,
fallbackOther: String,
vararg args: Any?
): String {
val fallback = if (quantity == 1) fallbackOne else fallbackOther
val template = resolveQuantity(name, quantity).takeUnless { it.isNullOrBlank() } ?: fallback
return formatAndroidString(template, args.toList())
}
fun sharedText(text: SharedText): String {
val quantity = text.quantity
return if (quantity == null) {
string(text.name, text.fallback, *text.args.toTypedArray())
} else {
quantityString(text.name, quantity, text.fallback, text.fallbackOther, *text.args.toTypedArray())
}
}
}
val LocalSharedStringResolver = staticCompositionLocalOf { SharedStringResolver() }
@Composable
fun readerString(name: String, fallback: String, vararg args: Any?): String {
return LocalSharedStringResolver.current.string(name, fallback, *args)
}
@Composable
fun readerQuantityString(
name: String,
quantity: Int,
fallbackOne: String,
fallbackOther: String,
vararg args: Any?
): String {
return LocalSharedStringResolver.current.quantityString(name, quantity, fallbackOne, fallbackOther, *args)
}
@Composable
fun readerSharedText(text: SharedText): String {
return LocalSharedStringResolver.current.sharedText(text)
}
@Composable
fun readerBannerMessage(message: BannerMessage?): String {
val text = message?.text ?: return message?.message.orEmpty()
return readerSharedText(text)
}
internal fun formatAndroidString(template: String, args: List<Any?>): String {
if (args.isEmpty()) return template.replace("%%", "%")
val percentPlaceholder = "\u0000PERCENT\u0000"
var sequentialIndex = 0
var formatted = template.replace("%%", percentPlaceholder)
formatted = Regex("%(\\d+)\\$[-+#, .(]*\\d*(?:\\.\\d+)?[a-zA-Z]").replace(formatted) { match ->
val argIndex = match.groupValues[1].toIntOrNull()?.minus(1)
args.getOrNull(argIndex ?: -1).toAndroidStringArgument()
}
formatted = Regex("%[-+#, .(]*\\d*(?:\\.\\d+)?[a-zA-Z]").replace(formatted) {
args.getOrNull(sequentialIndex++).toAndroidStringArgument()
}
return formatted.replace(percentPlaceholder, "%")
}
private fun Any?.toAndroidStringArgument(): String {
return when (this) {
null -> ""
is Float -> trimTrailingZeroDecimal(toString())
is Double -> trimTrailingZeroDecimal(toString())
else -> toString()
}
}
private fun trimTrailingZeroDecimal(value: String): String {
return value.removeSuffix(".0")
}

View file

@ -0,0 +1,21 @@
package com.aryan.reader.shared.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
internal object SharedUiTokens {
val chromeRadius = 6.dp
val surfaceRadius = 8.dp
val screenPadding = 20.dp
val panelPadding = 14.dp
val compactGap = 8.dp
val contentGap = 12.dp
val sidebarWidth = 220.dp
}
@Composable
internal fun sharedSubtleBorder(alpha: Float = 0.38f): BorderStroke {
return BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = alpha))
}

View file

@ -16,9 +16,11 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.OpenInNew
@ -39,11 +41,14 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@ -59,11 +64,14 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aryan.reader.shared.AppFontPreference
import com.aryan.reader.shared.CustomFontItem
@Composable
fun SharedCustomFontsScreen(
fonts: List<CustomFontItem>,
appFontPreference: AppFontPreference = AppFontPreference.System,
onAppFontPreferenceChange: (AppFontPreference) -> Unit = {},
onImportFont: () -> Unit,
onDeleteFont: (CustomFontItem) -> Unit,
googleFontsAvailable: Boolean = false,
@ -74,52 +82,74 @@ fun SharedCustomFontsScreen(
) {
var fontPendingDelete by remember { mutableStateOf<CustomFontItem?>(null) }
var showGoogleFontsDialog by remember { mutableStateOf(false) }
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
SharedScreenScaffold(
title = "Custom Fonts",
subtitle = "Imported fonts for the reader",
title = readerString("custom_fonts", "Custom fonts"),
subtitle = readerString("desktop_custom_fonts_desc", "Imported fonts for the reader"),
modifier = modifier,
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
if (googleFontsAvailable) {
Button(onClick = { showGoogleFontsDialog = true }) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Google Fonts")
if (selectedSection == SharedFontSettingsSection.READER_FONTS) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
if (googleFontsAvailable) {
Button(onClick = { showGoogleFontsDialog = true }) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("google_fonts", "Google Fonts"))
}
}
Button(onClick = onImportFont) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(readerString("action_import", "Import"))
}
}
Button(onClick = onImportFont) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import")
}
}
}
) {
val activeFonts = fonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }
if (activeFonts.isEmpty()) {
SharedUtilityEmptyState(
icon = { Icon(Icons.Default.TextFields, contentDescription = null, modifier = Modifier.size(56.dp)) },
title = "No custom fonts",
body = "Import TTF, OTF, or WOFF2 files to use them in books.",
actionLabel = "Import font",
onAction = onImportFont,
modifier = Modifier.weight(1f)
)
} else {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(activeFonts, key = { it.id }) { font ->
SharedFontListItem(
font = font,
onDelete = { fontPendingDelete = font },
fontFamilyForPreview = fontFamilyForPreview
SharedFontSettingsTabs(
selectedSection = selectedSection,
onSectionChange = { selectedSection = it }
)
when (selectedSection) {
SharedFontSettingsSection.READER_FONTS -> {
if (activeFonts.isEmpty()) {
SharedUtilityEmptyState(
icon = { Icon(Icons.Default.TextFields, contentDescription = null, modifier = Modifier.size(56.dp)) },
title = readerString("no_custom_fonts", "No custom fonts"),
body = readerString("desktop_no_custom_fonts_desc", "Import TTF, OTF, or WOFF2 files to use them in books."),
actionLabel = readerString("import_font", "Import font"),
onAction = onImportFont,
modifier = Modifier.weight(1f)
)
} else {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(activeFonts, key = { it.id }) { font ->
SharedFontListItem(
font = font,
onDelete = { fontPendingDelete = font },
fontFamilyForPreview = fontFamilyForPreview
)
}
}
}
}
SharedFontSettingsSection.APP_TEXT -> {
SharedAppFontSelector(
preference = appFontPreference,
customFonts = activeFonts,
onPreferenceChange = onAppFontPreferenceChange,
fontFamilyForPreview = fontFamilyForPreview,
modifier = Modifier.weight(1f)
)
}
}
}
@ -135,8 +165,8 @@ fun SharedCustomFontsScreen(
fontPendingDelete?.let { font ->
AlertDialog(
onDismissRequest = { fontPendingDelete = null },
title = { Text("Delete font?") },
text = { Text("Delete ${font.displayName}? Books using it will fall back to the default font.") },
title = { Text(readerString("dialog_delete_font", "Delete font?")) },
text = { Text(readerString("desktop_delete_font_desc", "Delete %1\$s? Books using it will fall back to the default font.", font.displayName)) },
confirmButton = {
TextButton(
onClick = {
@ -144,18 +174,46 @@ fun SharedCustomFontsScreen(
fontPendingDelete = null
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { fontPendingDelete = null }) {
Text("Cancel")
Text(readerString("action_cancel", "Cancel"))
}
}
)
}
}
enum class SharedFontSettingsSection {
READER_FONTS,
APP_TEXT
}
@Composable
fun SharedFontSettingsTabs(
selectedSection: SharedFontSettingsSection,
onSectionChange: (SharedFontSettingsSection) -> Unit,
modifier: Modifier = Modifier
) {
TabRow(
selectedTabIndex = selectedSection.ordinal,
modifier = modifier.fillMaxWidth()
) {
Tab(
selected = selectedSection == SharedFontSettingsSection.READER_FONTS,
onClick = { onSectionChange(SharedFontSettingsSection.READER_FONTS) },
text = { Text(readerString("reader_fonts", "Reader fonts")) }
)
Tab(
selected = selectedSection == SharedFontSettingsSection.APP_TEXT,
onClick = { onSectionChange(SharedFontSettingsSection.APP_TEXT) },
text = { Text(readerString("app_font_title", "App text font")) }
)
}
}
@Composable
private fun SharedGoogleFontsDialog(
existingFonts: List<CustomFontItem>,
@ -199,7 +257,7 @@ private fun SharedGoogleFontsDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Browse Google Fonts", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(readerString("action_browse_google_fonts", "Browse Google Fonts"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
},
text = {
Column(
@ -210,7 +268,7 @@ private fun SharedGoogleFontsDialog(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search 1900+ fonts...") },
placeholder = { Text(readerString("google_fonts_search_placeholder", "Search 1900+ fonts...")) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(8.dp)
@ -224,7 +282,7 @@ private fun SharedGoogleFontsDialog(
if (searchQuery.isBlank()) {
item {
Text(
text = "Popular choices",
text = readerString("google_fonts_popular_choices", "Popular choices"),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
@ -232,7 +290,7 @@ private fun SharedGoogleFontsDialog(
} else if (displayList.isEmpty()) {
item {
Text(
text = "No fonts found matching '$searchQuery'",
text = readerString("desktop_no_fonts_matching", "No fonts found matching '%1\$s'", searchQuery),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
@ -275,9 +333,9 @@ private fun SharedGoogleFontsDialog(
contentAlignment = Alignment.Center
) {
when {
isDownloaded -> Icon(Icons.Default.Check, contentDescription = "Already downloaded", tint = MaterialTheme.colorScheme.primary)
isDownloaded -> Icon(Icons.Default.Check, contentDescription = readerString("content_desc_already_downloaded", "Already downloaded"), tint = MaterialTheme.colorScheme.primary)
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
else -> Icon(Icons.Default.CloudDownload, contentDescription = "Download")
else -> Icon(Icons.Default.CloudDownload, contentDescription = readerString("action_download", "Download"))
}
}
}
@ -287,7 +345,7 @@ private fun SharedGoogleFontsDialog(
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Close")
Text(readerString("action_close", "Close"))
}
}
)
@ -348,7 +406,7 @@ private fun SharedFontListItem(
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Delete font", tint = MaterialTheme.colorScheme.error)
Icon(Icons.Default.Delete, contentDescription = readerString("desktop_delete_font", "Delete font"), tint = MaterialTheme.colorScheme.error)
}
}
Box(
@ -358,7 +416,7 @@ private fun SharedFontListItem(
.padding(12.dp)
) {
Text(
text = "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:",
text = readerString("font_preview_text", "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:"),
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 18.sp),
fontFamily = previewFontFamily,
color = MaterialTheme.colorScheme.onSurface
@ -375,24 +433,24 @@ fun SharedHelpFeedbackScreen(
modifier: Modifier = Modifier
) {
SharedScreenScaffold(
title = "Help & Feedback",
subtitle = "Bug reports, feature requests, and support",
title = readerString("drawer_help_feedback", "Help & Feedback"),
subtitle = readerString("desktop_help_feedback_desc", "Bug reports, feature requests, and support"),
modifier = modifier
) {
SharedUtilityHeader(
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(52.dp)) },
title = "Get in touch",
body = "Report bugs, request features, or contact support directly."
title = readerString("get_in_touch", "Get in touch"),
body = readerString("desktop_get_in_touch_desc", "Report bugs, request features, or contact support directly.")
)
SharedUtilityOptionCard(
title = "GitHub Issues",
body = "Report bugs, request features, and track development progress.",
title = readerString("github_issues", "GitHub Issues"),
body = readerString("github_issues_desc", "Report bugs, request features, and track development progress."),
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenGitHubIssues
)
SharedUtilityOptionCard(
title = "Email Support",
body = "Contact us directly by email for anything else.",
title = readerString("email_support", "Email support"),
body = readerString("desktop_email_support_desc", "Contact us directly by email for anything else."),
icon = { Icon(Icons.Default.Email, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onEmailSupport
)
@ -406,24 +464,24 @@ fun SharedSupportProjectScreen(
modifier: Modifier = Modifier
) {
SharedScreenScaffold(
title = "Support Project",
subtitle = "Ways to support Episteme development",
title = readerString("drawer_support_project", "Support project"),
subtitle = readerString("desktop_support_project_desc", "Ways to support Episteme development"),
modifier = modifier
) {
SharedUtilityHeader(
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(52.dp)) },
title = "Support Episteme",
body = "Contributions help keep the reader improving across Android and desktop."
title = readerString("desktop_support_episteme", "Support Episteme"),
body = readerString("desktop_support_episteme_desc", "Contributions help keep the reader improving across Android and desktop.")
)
SharedUtilityOptionCard(
title = "GitHub Sponsors",
body = "Support development through GitHub Sponsors.",
title = readerString("desktop_github_sponsors", "GitHub Sponsors"),
body = readerString("desktop_github_sponsors_desc", "Support development through GitHub Sponsors."),
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenGitHubSponsors
)
SharedUtilityOptionCard(
title = "Patreon",
body = "Support the project on Patreon.",
title = readerString("desktop_patreon", "Patreon"),
body = readerString("desktop_patreon_desc", "Support the project on Patreon."),
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenPatreon
)
@ -439,8 +497,8 @@ fun SharedAboutScreen(
modifier: Modifier = Modifier
) {
SharedScreenScaffold(
title = "About Episteme",
subtitle = "Desktop reader",
title = readerString("about_title", "About Episteme"),
subtitle = readerString("desktop_about_subtitle", "Desktop reader"),
modifier = modifier
) {
Surface(
@ -464,7 +522,7 @@ fun SharedAboutScreen(
}
}
Column {
Text("Episteme", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(versionName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(buildLabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@ -472,16 +530,16 @@ fun SharedAboutScreen(
}
if (onOpenSource != null) {
SharedUtilityOptionCard(
title = "Source Code",
body = "Browse the project source on GitHub.",
title = readerString("desktop_source_code", "Source code"),
body = readerString("desktop_source_code_desc", "Browse the project source on GitHub."),
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenSource
)
}
if (onOpenIssues != null) {
SharedUtilityOptionCard(
title = "Issues",
body = "Open the issue tracker for bugs and feature requests.",
title = readerString("desktop_issues", "Issues"),
body = readerString("desktop_issues_desc", "Open the issue tracker for bugs and feature requests."),
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) },
onClick = onOpenIssues
)
@ -557,7 +615,181 @@ private fun SharedUtilityOptionCard(
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open")
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = readerString("action_open", "Open"))
}
}
}
@Composable
fun SharedAppFontSelector(
preference: AppFontPreference,
customFonts: List<CustomFontItem>,
onPreferenceChange: (AppFontPreference) -> Unit,
fontFamilyForPreview: (CustomFontItem) -> FontFamily? = { null },
modifier: Modifier = Modifier
) {
val sanitizedPreference = preference.sanitized()
val activeFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }
OutlinedCard(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = readerString("app_font_title", "App text font"),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = readerString("app_font_desc", "Applies to app navigation, settings, lists, dialogs, and reader chrome."),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
listOf(
SharedAppFontOption(
label = readerString("app_font_system", "System"),
summary = readerString("app_font_system_desc", "Use the platform default"),
preference = AppFontPreference.System,
fontFamily = FontFamily.Default
),
SharedAppFontOption(
label = readerString("app_font_serif", "Serif"),
summary = readerString("app_font_serif_desc", "Classic reading-style letterforms"),
preference = AppFontPreference.Serif,
fontFamily = FontFamily.Serif
),
SharedAppFontOption(
label = readerString("app_font_sans", "Sans"),
summary = readerString("app_font_sans_desc", "Clean interface-style letterforms"),
preference = AppFontPreference.SansSerif,
fontFamily = FontFamily.SansSerif
),
SharedAppFontOption(
label = readerString("app_font_monospace", "Monospace"),
summary = readerString("app_font_monospace_desc", "Fixed-width text"),
preference = AppFontPreference.Monospace,
fontFamily = FontFamily.Monospace
)
).forEach { option ->
SharedAppFontOptionRow(
label = option.label,
summary = option.summary,
selected = sanitizedPreference == option.preference,
fontFamily = option.fontFamily,
onClick = { onPreferenceChange(option.preference) }
)
}
HorizontalDivider()
Text(
text = readerString("app_font_imported_fonts", "Imported fonts"),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
if (activeFonts.isEmpty()) {
Text(
text = readerString("app_font_no_imported_fonts", "Import a font to use it for app text."),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 220.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
activeFonts.forEach { font ->
val previewFontFamily = remember(font.path) { fontFamilyForPreview(font) }
val option = AppFontPreference.custom(font.id)
SharedAppFontOptionRow(
label = font.displayName,
summary = font.fileExtension.uppercase(),
selected = sanitizedPreference == option,
fontFamily = previewFontFamily,
onClick = { onPreferenceChange(option) }
)
}
}
}
}
}
}
private data class SharedAppFontOption(
val label: String,
val summary: String,
val preference: AppFontPreference,
val fontFamily: FontFamily?
)
@Composable
private fun SharedAppFontOptionRow(
label: String,
summary: String,
selected: Boolean,
fontFamily: FontFamily?,
onClick: () -> Unit
) {
val containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f)
} else {
MaterialTheme.colorScheme.surfaceContainerLow
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(8.dp),
color = containerColor,
border = BorderStroke(
width = 1.dp,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)
)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Box(Modifier.size(38.dp), contentAlignment = Alignment.Center) {
Text("Aa", fontFamily = fontFamily, fontWeight = FontWeight.Bold)
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = fontFamily),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (selected) {
Icon(Icons.Default.Check, contentDescription = readerString("content_desc_selected", "Selected"), tint = MaterialTheme.colorScheme.primary)
}
}
}
}

View file

@ -14,6 +14,14 @@ class FileCapabilitiesTest {
PDF_VIEWER_FILE_TYPES + EPUB_READER_FILE_TYPES,
SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
)
assertEquals(
PDF_VIEWER_FILE_TYPES,
SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID, ReaderFeatureSurface.PDF_VIEWER)
)
assertEquals(
EPUB_READER_FILE_TYPES,
SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID, ReaderFeatureSurface.EPUB_READER)
)
assertFalse(FileType.UNKNOWN in SharedFileCapabilities.knownFileTypes)
assertFalse(FileType.UNKNOWN in SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID))
assertNull(SharedFileCapabilities.primaryExtensionFor(FileType.UNKNOWN))
@ -25,6 +33,9 @@ class FileCapabilitiesTest {
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
SharedFileCapabilities.mimeTypeFor(FileType.PPTX)
)
assertTrue("application/pdf" in SharedFileCapabilities.androidFilePickerMimeTypes)
assertTrue("text/x-kotlin" in SharedFileCapabilities.androidFilePickerMimeTypes)
assertFalse("*/*" in SharedFileCapabilities.androidFilePickerMimeTypes)
assertEquals(
setOf(
FileType.EPUB,
@ -38,6 +49,7 @@ class FileCapabilitiesTest {
FileType.CBR,
FileType.CB7,
FileType.DOCX,
FileType.PPTX,
FileType.ODT,
FileType.FODT
),
@ -59,7 +71,10 @@ class FileCapabilitiesTest {
ReaderFeatureSurface.PDF_VIEWER,
SharedFileCapabilities.surfaceFor(FileType.PPTX, ReaderPlatform.ANDROID)
)
assertNull(SharedFileCapabilities.surfaceFor(FileType.PPTX, ReaderPlatform.DESKTOP))
assertEquals(
ReaderFeatureSurface.PDF_VIEWER,
SharedFileCapabilities.surfaceFor(FileType.PPTX, ReaderPlatform.DESKTOP)
)
assertEquals(
ReaderFeatureSurface.TEXT_READER,
SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.DESKTOP)
@ -93,13 +108,33 @@ class FileCapabilitiesTest {
assertEquals(FileType.UNKNOWN, SharedFileCapabilities.fileTypeForName("archive.zip"))
}
@Test
fun `shared metadata resolver handles provider mime types and guarded archive fallbacks`() {
assertEquals(FileType.PDF, SharedFileCapabilities.resolveFileTypeForMetadata("download", "application/pdf"))
assertEquals(
FileType.DOCX,
SharedFileCapabilities.resolveFileTypeForMetadata(
"download",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
)
assertEquals(
FileType.MD,
SharedFileCapabilities.resolveFileTypeForMetadata("notes.md.txt", "text/plain; charset=utf-8")
)
assertEquals(FileType.HTML, SharedFileCapabilities.resolveFileTypeForMetadata("payload", "application/json"))
assertEquals(FileType.CBZ, SharedFileCapabilities.resolveFileTypeForMetadata("comic.cbz", "application/zip"))
assertEquals(FileType.FB2, SharedFileCapabilities.resolveFileTypeForMetadata("book.fb2.zip", "application/zip"))
assertNull(SharedFileCapabilities.resolveFileTypeForMetadata("archive.zip", "application/zip"))
}
@Test
fun `shared file name policy detects manual only files and suffixes`() {
assertTrue(SharedFileCapabilities.isCodeOrDataFileName("table.csv"))
assertTrue(SharedFileCapabilities.isManualOnlyReaderFileName("script.kt.txt"))
assertFalse(SharedFileCapabilities.isManualOnlyReaderFileName("chapter.html"))
assertFalse(SharedFileCapabilities.isLocalFolderSyncEligibleFile("table.csv", "text/csv"))
assertFalse(SharedFileCapabilities.isLocalFolderSyncEligibleFile("payload", "application/json"))
assertFalse(SharedFileCapabilities.isLocalFolderSyncEligibleFile("payload", "application/json; charset=utf-8"))
assertTrue(SharedFileCapabilities.isLocalFolderSyncEligibleFile("book.fodt", "text/xml"))
assertEquals(".md.txt", SharedFileCapabilities.fileExtensionSuffixForName("notes.md.txt"))
assertEquals(".fb2.zip.txt", SharedFileCapabilities.fileExtensionSuffixForName("book.fb2.zip.txt"))
@ -107,6 +142,6 @@ class FileCapabilitiesTest {
@Test
fun `desktop parity gaps list Android readable formats not yet available on desktop`() {
assertEquals(listOf(FileType.PPTX), SharedFileCapabilities.desktopParityGaps())
assertEquals(emptyList(), SharedFileCapabilities.desktopParityGaps())
}
}

View file

@ -178,6 +178,43 @@ class LocalFolderSyncEngineTest {
assertEquals(mapOf(oldId to newId), result.idMigrations)
}
@Test
fun `sync resolves legacy root id collision before migrating subfolder book`() {
val oldId = "local_Book.pdf"
val state = SharedReaderScreenState(
rawLibraryBooks = listOf(
book(
id = oldId,
path = "C:/Library/Series/Book.pdf",
displayName = "Book.pdf",
sourceFolder = "C:/Library"
)
),
selectedBookIds = setOf(oldId)
)
val result = LocalFolderSyncEngine.syncFolder(
state = state,
folder = syncedFolder(),
files = listOf(
scannedFile("Book.pdf", "Book.pdf"),
scannedFile("Book.pdf", "Series/Book.pdf")
),
remoteMetadata = emptyMap(),
nowMillis = 1_000L
)
val migratedId = "local_Book.pdf_488206341973"
assertEquals(
listOf("local_Book.pdf", migratedId),
result.state.rawLibraryBooks.map { it.id }.sorted()
)
assertEquals(setOf(migratedId), result.state.selectedBookIds)
assertEquals(mapOf(oldId to migratedId), result.idMigrations)
assertEquals(1, result.stats.newBooks)
assertEquals(1, result.stats.migratedBooks)
}
@Test
fun `sync removes missing books from linked folder only`() {
val missing = book(id = "local_Missing.pdf", path = "C:/Library/Missing.pdf")

View file

@ -4,6 +4,7 @@ import androidx.compose.ui.graphics.toArgb
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ReaderAppearanceModelsTest {
@ -42,6 +43,17 @@ class ReaderAppearanceModelsTest {
assertEquals("custom-paper", readerTextureDisplayName("${ReaderTextureFilePrefix}C:\\textures\\custom-paper.png"))
}
@Test
fun `reader texture helpers normalize extensions and resolve mime types`() {
assertEquals("jpg", normalizeReaderTextureExtension("JPEG"))
assertEquals("webp", normalizeReaderTextureExtension(" webp "))
assertNull(normalizeReaderTextureExtension("svg"))
assertEquals("image/jpeg", readerTextureMimeTypeForExtension("jpg"))
assertEquals("image/webp", readerTextureMimeTypeForExtension("webp"))
assertEquals("image/png", readerTextureMimeTypeForExtension("unknown"))
}
@Test
fun `pdf textured theme maps into reader settings`() {
val theme = BuiltInPdfReaderThemes.first { it.id == "pdf_fabric_texture" }

View file

@ -31,7 +31,10 @@ class ReaderDefaultSettingsStateTest {
@Test
fun `pdf reader default settings reducer updates separate shared state`() {
val epubDefaults = ReaderSettings(themeId = "sepia")
val pdfDefaults = ReaderSettings(themeId = "reverse")
val pdfDefaults = ReaderSettings(
themeId = "reverse",
pdfFirstPageStandaloneInSpread = true
)
val state = SharedReaderScreenState(readerDefaultSettings = epubDefaults)
.reduce(AppAction.PdfReaderDefaultSettingsChanged(pdfDefaults))
@ -67,7 +70,10 @@ class ReaderDefaultSettingsStateTest {
@Test
fun `pdf reader default settings persist separately in shared snapshot json`() {
val epubDefaults = ReaderSettings(themeId = "sepia")
val pdfDefaults = ReaderSettings(themeId = "reverse")
val pdfDefaults = ReaderSettings(
themeId = "reverse",
pdfFirstPageStandaloneInSpread = true
)
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
SharedLibrarySnapshotJson.encode(

View file

@ -49,7 +49,7 @@ class ReaderExtrasModelsTest {
}
@Test
fun `cloud tts is available only with gemini key and cloud tts model`() {
fun `BYOK cloud tts is available only with gemini key and cloud tts model`() {
assertFalse(ReaderAiByokSettings(geminiKey = "key").isCloudTtsAvailable)
assertFalse(ReaderAiByokSettings(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID).isCloudTtsAvailable)
@ -61,6 +61,19 @@ class ReaderExtrasModelsTest {
)
}
@Test
fun `server backed reader AI and cloud tts availability do not require BYOK keys`() {
val serverBacked = ReaderAiByokSettings(
serverBackedReaderAiFeatures = true,
serverBackedCloudTts = true
)
assertTrue(serverBacked.areReaderAiFeaturesAvailable)
assertTrue(serverBacked.isCloudTtsAvailable)
assertFalse(serverBacked.isByokCloudTtsAvailable)
assertFalse(serverBacked.copy(hideReaderAiFeatures = true).areReaderAiFeaturesAvailable)
}
@Test
fun `shared cloud tts voices mirror android voice catalog`() {
assertEquals("Aoede", DEFAULT_CLOUD_TTS_SPEAKER_ID)

View file

@ -48,7 +48,7 @@ class SettingsHubModelsTest {
}
@Test
fun `sync unavailable hides account rows while preserving folder sync`() {
fun `sync unavailable hides cloud sync while preserving account and folder sync`() {
val model = sharedSettingsHubModel(
SharedSettingsHubInput(
platform = SharedSettingsPlatform.DESKTOP,
@ -60,6 +60,26 @@ class SettingsHubModelsTest {
)
val actions = model.visibleNestedActions()
assertFalse(SharedSettingsAction.SIGN_IN in actions)
assertTrue(SharedSettingsAction.SIGN_OUT in actions)
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
assertTrue(SharedSettingsAction.FOLDER_SYNC in actions)
}
@Test
fun `account unavailable hides sign-in rows independently from cloud sync`() {
val model = sharedSettingsHubModel(
SharedSettingsHubInput(
platform = SharedSettingsPlatform.DESKTOP,
accountAvailable = false,
syncAvailable = false,
folderSyncAvailable = true,
isSignedIn = true,
isProUser = true
)
)
val actions = model.visibleNestedActions()
assertFalse(SharedSettingsAction.SIGN_IN in actions)
assertFalse(SharedSettingsAction.SIGN_OUT in actions)
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
@ -78,6 +98,25 @@ class SettingsHubModelsTest {
assertFalse(SharedSettingsAction.TABS_TOGGLE in model.visibleNestedActions())
}
@Test
fun `language setting can show current platform selection`() {
val model = sharedSettingsHubModel(
SharedSettingsHubInput(
platform = SharedSettingsPlatform.DESKTOP,
includeLanguage = true,
languageTitle = "App language",
languageSummary = "Deutsch"
)
)
val item = model.page(SharedSettingsDestination.EXTRA)
.items
.single { it.action == SharedSettingsAction.LANGUAGE }
assertEquals("App language", item.title)
assertEquals("Deutsch", item.summary)
}
@Test
fun `local override note appears on reader detail pages only`() {
val model = sharedSettingsHubModel(

View file

@ -17,12 +17,14 @@ class SharedAppThemeReducerTest {
.reduce(AppAction.AppTextDimFactorLightChanged(0.75f))
.reduce(AppAction.AppTextDimFactorDarkChanged(0.65f))
.reduce(AppAction.AppSeedColorChanged(seedColor))
.reduce(AppAction.AppFontPreferenceChanged(AppFontPreference.Monospace))
assertEquals(AppThemeMode.DARK, state.appThemeMode)
assertEquals(AppContrastOption.HIGH, state.appContrastOption)
assertEquals(0.75f, state.appTextDimFactorLight)
assertEquals(0.65f, state.appTextDimFactorDark)
assertEquals(seedColor, state.appSeedColor)
assertEquals(AppFontPreference.Monospace, state.appFontPreference)
}
@Test
@ -58,4 +60,12 @@ class SharedAppThemeReducerTest {
assertEquals(0.3f, state.appTextDimFactorLight)
assertEquals(1.0f, state.appTextDimFactorDark)
}
@Test
fun `custom app font preference without an id falls back to system`() {
val state = SharedReaderScreenState()
.reduce(AppAction.AppFontPreferenceChanged(AppFontPreference(AppFontPreferenceKind.CUSTOM)))
assertEquals(AppFontPreference.System, state.appFontPreference)
}
}

View file

@ -47,7 +47,8 @@ class SharedLibraryEditorTest {
assertEquals(listOf("keep"), result.state.rawLibraryBooks.ids())
assertTrue(result.state.selectedBookIds.isEmpty())
assertEquals(listOf("keep"), result.shelfRefs.map { it.bookId })
assertEquals("Removed 1 book(s) from the library.", result.state.bannerMessage?.message)
assertEquals("1 book removed from library.", result.state.bannerMessage?.message)
assertEquals("banner_books_removed_library", result.state.bannerMessage?.text?.name)
}
@Test
@ -72,7 +73,8 @@ class SharedLibraryEditorTest {
),
result.shelfRefs
)
assertEquals("Added 1 book(s) to shelf.", result.state.bannerMessage?.message)
assertEquals("1 book added to shelf.", result.state.bannerMessage?.message)
assertEquals("banner_books_added_to_shelf", result.state.bannerMessage?.text?.name)
}
@Test
@ -99,6 +101,7 @@ class SharedLibraryEditorTest {
assertEquals(ShelfRecord("smart_7", "Smart Picks", isSmart = true, smartRulesJson = shelf.smartRulesJson), shelf)
assertEquals(listOf(SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune")), decoded?.rules)
assertEquals("Created smart shelf \"Smart Picks\".", result.state.bannerMessage?.message)
assertEquals("banner_smart_shelf_created", result.state.bannerMessage?.text?.name)
assertNull(
SharedLibraryEditor.createSmartShelf(
state = SharedReaderScreenState(),
@ -192,6 +195,8 @@ class SharedLibraryEditorTest {
assertTrue(result.state.syncedFolders.isEmpty())
assertTrue(result.state.libraryFilters.sourceFolders.isEmpty())
assertEquals(listOf("other"), result.shelfRefs.map { it.bookId })
assertEquals("Removed folder \"Books\" and 1 book from the app.", result.state.bannerMessage?.message)
assertEquals("banner_folder_removed_with_book_count", result.state.bannerMessage?.text?.name)
}
@Test

View file

@ -85,7 +85,8 @@ class SharedLibraryProjectorTest {
assertEquals("C:/books", result.books[0].sourceFolder)
assertFalse(result.books[0].isRecent)
assertTrue(projector.home(result).recentBooks.isEmpty())
assertEquals("Imported 1 file(s). Reader support comes later.", result.message)
assertEquals("Imported 1 file. Reader support comes later.", result.message)
assertEquals("desktop_imported_file_count_reader_support_later", result.messageText?.name)
val unsupportedOnly = projector.withImportedFiles(
state,
@ -297,7 +298,8 @@ class SharedLibraryProjectorTest {
)
)
assertTrue(projected.recentBooks.isEmpty())
assertEquals("Imported 1 file(s).", imported.bannerMessage?.message)
assertEquals("Imported 1 file.", imported.bannerMessage?.message)
assertEquals("desktop_imported_file_count", imported.bannerMessage?.text?.name)
assertEquals("Those files are already in the library.", duplicateOnly.bannerMessage?.message)
assertEquals(imported.rawLibraryBooks.ids(), unsupportedOnly.rawLibraryBooks.ids())
assertEquals("No supported files were imported.", unsupportedOnly.bannerMessage?.message)

View file

@ -76,6 +76,7 @@ class SharedLibrarySnapshotJsonTest {
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
pdfVerticalPageGapVisible = false,
pdfPageNumberOverlayVisible = false,
pdfFirstPageStandaloneInSpread = true,
seamlessChapterNavigation = false,
chapterTurnDragMultiplier = 1.6f
),
@ -149,6 +150,7 @@ class SharedLibrarySnapshotJsonTest {
appTextDimFactorLight = 0.75f,
appTextDimFactorDark = 0.65f,
appSeedColor = Color(0xFF006C4C),
appFontPreference = AppFontPreference.custom("font"),
customAppThemes = listOf(
CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C))
),
@ -198,6 +200,7 @@ class SharedLibrarySnapshotJsonTest {
assertTrue(SharedLibrarySnapshotJson.decodeOrEmpty("not json").books.isEmpty())
assertTrue(decoded.books.isEmpty())
assertEquals(AppFontPreference.System, decoded.appFontPreference)
}
@Test

View file

@ -0,0 +1,122 @@
package com.aryan.reader.shared.opds
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class SharedOpdsControllerTest {
@Test
fun `controller opens paginates and navigates shared feed state`() = runBlocking {
val catalog = OpdsCatalog(id = "catalog", title = "Catalog", url = "root")
val repository = FakeOpdsRepository(
catalogs = listOf(catalog),
feeds = mapOf(
"root" to feed("Root", entry("one"), nextUrl = "next"),
"next" to feed("Next", entry("two")),
"child" to feed("Child", entry("child"))
)
)
val controller = SharedOpdsController(
repository = repository,
idFactory = { "generated" }
)
val emissions = mutableListOf<SharedOpdsScreenState>()
controller.openCatalog(catalog, emissions::add)
assertEquals("Root", controller.state.currentFeed?.title)
assertEquals(listOf("one"), controller.state.currentFeed?.entries?.map { it.id })
assertFalse(controller.hasFeedHistory())
controller.loadNextPage(emissions::add)
assertEquals(listOf("one", "two"), controller.state.currentFeed?.entries?.map { it.id })
controller.openFeedUrl("child", emissions::add)
assertEquals("Child", controller.state.currentFeed?.title)
assertTrue(controller.hasFeedHistory())
assertTrue(controller.navigateBack(emissions::add))
assertEquals("Root", controller.state.currentFeed?.title)
assertFalse(controller.hasFeedHistory())
assertFalse(controller.navigateBack(emissions::add))
assertFalse(controller.state.isViewingCatalog)
assertNull(controller.state.currentFeed)
assertEquals(listOf("root", "next", "child", "root"), repository.requestedUrls)
}
@Test
fun `controller search fetches expanded query through repository`() = runBlocking {
val catalog = OpdsCatalog(id = "catalog", title = "Catalog", url = "root")
val repository = FakeOpdsRepository(
catalogs = listOf(catalog),
feeds = mapOf(
"root" to feed("Root", entry("one"), searchUrl = "https://example.org/search{?query}"),
"https://example.org/search?query=ada%20lovelace" to feed("Search", entry("result"))
)
)
val controller = SharedOpdsController(
repository = repository,
idFactory = { "generated" }
)
val emissions = mutableListOf<SharedOpdsScreenState>()
controller.openCatalog(catalog, emissions::add)
controller.search("ada lovelace", emissions::add)
assertEquals("Search", controller.state.currentFeed?.title)
assertEquals(listOf("root", "https://example.org/search?query=ada%20lovelace"), repository.requestedUrls)
}
private fun feed(
title: String,
vararg entries: OpdsEntry,
nextUrl: String? = null,
searchUrl: String? = null
): OpdsFeed {
return OpdsFeed(
title = title,
entries = entries.toList(),
nextUrl = nextUrl,
searchUrl = searchUrl
)
}
private fun entry(id: String): OpdsEntry {
return OpdsEntry(
id = id,
title = id,
summary = null,
coverUrl = null,
navigationUrl = null
)
}
private class FakeOpdsRepository(
catalogs: List<OpdsCatalog>,
private val feeds: Map<String, OpdsFeed>
) : SharedOpdsRepository {
private var storedCatalogs = catalogs
val requestedUrls = mutableListOf<String>()
override fun loadCatalogs(): List<OpdsCatalog> = storedCatalogs
override fun saveCatalogs(catalogs: List<OpdsCatalog>) {
storedCatalogs = catalogs
}
override suspend fun fetchFeed(url: String, username: String?, password: String?): Result<OpdsFeed> {
requestedUrls += url
return feeds[url]?.let { Result.success(it) }
?: Result.failure(IllegalArgumentException("Missing feed: $url"))
}
override suspend fun getSearchTemplate(
openSearchUrl: String,
username: String?,
password: String?
): String? = null
}
}

View file

@ -0,0 +1,73 @@
package com.aryan.reader.shared.pdf
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderSettings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PdfSpreadLayoutTest {
@Test
fun `single page mode keeps direct page indexes`() {
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.SINGLE)
assertEquals(3, PdfSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
assertEquals(listOf(3), PdfSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
assertEquals("4", PdfSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings))
}
@Test
fun `two page mode pairs pages from the first page by default`() {
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
assertEquals(2, PdfSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
assertEquals(listOf(2, 3), PdfSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
assertEquals("3-4", PdfSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings))
assertEquals(listOf(4), PdfSpreadLayout.visiblePageIndices(4, pageCount = 5, settings = settings))
assertEquals("5", PdfSpreadLayout.pageRangeLabel(4, pageCount = 5, settings = settings))
assertEquals(listOf(0, 2, 4), PdfSpreadLayout.spreadStartPageIndices(pageCount = 5, settings = settings))
}
@Test
fun `two page mode can keep the first page alone`() {
val settings = ReaderSettings(
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
pdfFirstPageStandaloneInSpread = true
)
assertEquals(listOf(0), PdfSpreadLayout.visiblePageIndices(0, pageCount = 6, settings = settings))
assertEquals(listOf(1, 2), PdfSpreadLayout.visiblePageIndices(2, pageCount = 6, settings = settings))
assertEquals(listOf(3, 4), PdfSpreadLayout.visiblePageIndices(4, pageCount = 6, settings = settings))
assertEquals(listOf(5), PdfSpreadLayout.visiblePageIndices(5, pageCount = 6, settings = settings))
assertEquals(listOf(0, 1, 3, 5), PdfSpreadLayout.spreadStartPageIndices(pageCount = 6, settings = settings))
}
@Test
fun `two page navigation follows spread boundaries`() {
val normal = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
val firstStandalone = ReaderSettings(
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
pdfFirstPageStandaloneInSpread = true
)
assertEquals(2, PdfSpreadLayout.nextPageIndex(0, pageCount = 5, settings = normal))
assertEquals(4, PdfSpreadLayout.nextPageIndex(2, pageCount = 5, settings = normal))
assertEquals(2, PdfSpreadLayout.previousPageIndex(4, pageCount = 5, settings = normal))
assertFalse(PdfSpreadLayout.canGoNext(4, pageCount = 5, settings = normal))
assertEquals(1, PdfSpreadLayout.nextPageIndex(0, pageCount = 6, settings = firstStandalone))
assertEquals(3, PdfSpreadLayout.nextPageIndex(1, pageCount = 6, settings = firstStandalone))
assertEquals(1, PdfSpreadLayout.previousPageIndex(3, pageCount = 6, settings = firstStandalone))
assertTrue(PdfSpreadLayout.canGoPrevious(1, pageCount = 6, settings = firstStandalone))
}
@Test
fun `spread progress uses the visible spread end`() {
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
assertEquals(80f, PdfSpreadLayout.progressPercent(2, pageCount = 5, settings = settings))
assertEquals(100f, PdfSpreadLayout.progressPercent(4, pageCount = 5, settings = settings))
}
}

View file

@ -0,0 +1,238 @@
package com.aryan.reader.shared.pdf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SharedPdfAnnotationExportMapperTest {
@Test
fun `mapper exports ink annotations and skips non-drawing tools`() {
val ink = SharedPdfAnnotation(
id = "ink-1",
pageIndex = 2,
kind = PdfAnnotationKind.INK,
tool = PdfInkTool.PEN,
points = listOf(PdfPagePoint(0.1f, 0.2f), PdfPagePoint(0.3f, 0.4f)),
note = "Check curve",
colorArgb = 0xFF336699.toInt(),
strokeWidth = 0.0125f
)
val noneTool = ink.copy(id = "none", tool = PdfInkTool.NONE)
val eraser = ink.copy(id = "eraser", tool = PdfInkTool.ERASER)
val textTool = ink.copy(id = "text-tool", tool = PdfInkTool.TEXT)
val tooShort = ink.copy(id = "short", points = listOf(PdfPagePoint(0.1f, 0.2f)))
val payload = SharedPdfAnnotationExportMapper.build(listOf(ink, noneTool, eraser, textTool, tooShort))
assertTrue(payload.hasPdfAnnotations)
assertEquals(listOf("ink-1"), payload.inkAnnotations.map { it.id })
assertEquals(PdfInkTool.PEN, payload.inkAnnotations.single().tool)
assertEquals("Check curve", payload.inkAnnotations.single().contents)
}
@Test
fun `mapper preserves highlight bounds order without adding fake contents`() {
val highlight = SharedPdfAnnotation(
id = "highlight-1",
pageIndex = 1,
kind = PdfAnnotationKind.HIGHLIGHT,
tool = PdfInkTool.HIGHLIGHTER,
boundsList = listOf(
PdfPageBounds(0.1f, 0.2f, 0.4f, 0.25f),
PdfPageBounds(0.5f, 0.3f, 0.8f, 0.35f)
),
text = "Selected text",
colorArgb = 0x8C64B5F6.toInt()
)
val payload = SharedPdfAnnotationExportMapper.build(listOf(highlight))
assertEquals(listOf("highlight-1"), payload.highlightAnnotations.map { it.id })
assertEquals(highlight.boundsList, payload.highlightAnnotations.single().boundsList)
assertEquals("", payload.highlightAnnotations.single().contents)
assertEquals(
"Actual note",
SharedPdfAnnotationExportMapper.build(listOf(highlight.copy(note = " Actual note ")))
.highlightAnnotations
.single()
.contents
)
}
@Test
fun `mapper exports text highlight comment threads with stable ids and parent order`() {
val highlight = SharedPdfAnnotation(
id = "highlight-1",
pageIndex = 1,
kind = PdfAnnotationKind.HIGHLIGHT,
tool = PdfInkTool.HIGHLIGHTER,
boundsList = listOf(PdfPageBounds(0.1f, 0.2f, 0.4f, 0.25f)),
text = "Selected text",
comments = listOf(
SharedPdfAnnotationComment(
id = "reply-1",
parentId = "root-1",
author = "Bea",
contents = " Reply body ",
createdAt = 20L
),
SharedPdfAnnotationComment(
id = "blank",
author = "Nope",
contents = " "
),
SharedPdfAnnotationComment(
id = "root-1",
author = "Ada",
contents = "Root body",
createdAt = 10L,
modifiedAt = 15L
)
),
colorArgb = 0x8C64B5F6.toInt()
)
val comments = SharedPdfAnnotationExportMapper.build(listOf(highlight))
.highlightAnnotations
.single()
.comments
val comment = comments.single()
assertEquals("highlight-1_comments", comment.id)
assertEquals(null, comment.parentId)
assertEquals("Ada", comment.author)
assertEquals(
"Ada:\nRoot body\n\n Bea:\n Reply body",
comment.contents
)
assertEquals(10L, comment.createdAt)
assertEquals(20L, comment.modifiedAt)
}
@Test
fun `mapper folds multiple top level highlight comments into one export thread`() {
val highlight = SharedPdfAnnotation(
id = "highlight-1",
pageIndex = 1,
kind = PdfAnnotationKind.HIGHLIGHT,
tool = PdfInkTool.HIGHLIGHTER,
boundsList = listOf(PdfPageBounds(0.1f, 0.2f, 0.4f, 0.25f)),
text = "Selected text",
comments = listOf(
SharedPdfAnnotationComment(id = "root-1", contents = "First"),
SharedPdfAnnotationComment(id = "root-2", contents = "Second")
),
colorArgb = 0x8C64B5F6.toInt()
)
val comments = SharedPdfAnnotationExportMapper.build(listOf(highlight))
.highlightAnnotations
.single()
.comments
val comment = comments.single()
assertEquals("highlight-1_comments", comment.id)
assertEquals(null, comment.parentId)
assertEquals("Reader", comment.author)
assertEquals("Reader:\nFirst\n\nReader:\nSecond", comment.contents)
}
@Test
fun `mapper uses resolver bounds and skips malformed highlights`() {
val missingBounds = SharedPdfAnnotation(
id = "resolved",
pageIndex = 0,
kind = PdfAnnotationKind.HIGHLIGHT,
text = "Resolved",
colorArgb = 0x8CFFEB3B.toInt(),
rangeStartIndex = 2,
rangeEndIndex = 9
)
val malformed = missingBounds.copy(
id = "bad",
bounds = PdfPageBounds(1.1f, 0.2f, 1.2f, 0.3f)
)
val malformedWithRange = missingBounds.copy(
id = "fallback",
bounds = PdfPageBounds(1.1f, 0.2f, 1.2f, 0.3f)
)
val textAnnotation = missingBounds.copy(
id = "text",
kind = PdfAnnotationKind.TEXT,
bounds = PdfPageBounds(0.1f, 0.2f, 0.2f, 0.3f)
)
val payload = SharedPdfAnnotationExportMapper.build(
listOf(missingBounds, malformed, malformedWithRange, textAnnotation)
) { annotation ->
when (annotation.id) {
"resolved" -> listOf(PdfPageBounds(0.2f, 0.4f, 0.6f, 0.45f))
"fallback" -> listOf(PdfPageBounds(0.3f, 0.5f, 0.7f, 0.55f))
else -> emptyList()
}
}
assertEquals(listOf("resolved", "fallback"), payload.highlightAnnotations.map { it.id })
assertEquals(
listOf(PdfPageBounds(0.2f, 0.4f, 0.6f, 0.45f)),
payload.highlightAnnotations.first().boundsList
)
assertEquals(
listOf(PdfPageBounds(0.3f, 0.5f, 0.7f, 0.55f)),
payload.highlightAnnotations.last().boundsList
)
assertFalse(SharedPdfAnnotationExportMapper.build(listOf(malformed, textAnnotation)).hasPdfAnnotations)
}
@Test
fun `mapper normalizes reversed highlight bounds`() {
val highlight = SharedPdfAnnotation(
id = "highlight",
pageIndex = 0,
kind = PdfAnnotationKind.HIGHLIGHT,
bounds = PdfPageBounds(0.6f, 0.4f, 0.2f, 0.3f),
text = "Selected",
colorArgb = 0x8CFFEB3B.toInt()
)
val payload = SharedPdfAnnotationExportMapper.build(listOf(highlight))
assertEquals(
PdfPageBounds(0.2f, 0.3f, 0.6f, 0.4f),
payload.highlightAnnotations.single().boundsList.single()
)
}
@Test
fun `ink appearance trims chisel highlighter endpoints only`() {
val chisel = SharedPdfInkAnnotationExport(
id = "highlight",
pageIndex = 0,
tool = PdfInkTool.HIGHLIGHTER,
points = listOf(PdfPagePoint(0.1f, 0.2f), PdfPagePoint(0.9f, 0.2f)),
colorArgb = 0x8CFFEB3B.toInt(),
strokeWidth = 0.1f,
contents = ""
)
val round = chisel.copy(tool = PdfInkTool.HIGHLIGHTER_ROUND)
val trimmed = chisel.pdfInkAppearancePoints(pageWidth = 100f, pageHeight = 100f)
val duplicatedEndpointTrimmed = chisel.copy(
points = listOf(
PdfPagePoint(0.1f, 0.2f),
PdfPagePoint(0.1f, 0.2f),
PdfPagePoint(0.9f, 0.2f),
PdfPagePoint(0.9f, 0.2f)
)
).pdfInkAppearancePoints(pageWidth = 100f, pageHeight = 100f)
assertEquals(0.165f, trimmed.first().x, 0.0001f)
assertEquals(0.2f, trimmed.first().y, 0.0001f)
assertEquals(0.835f, trimmed.last().x, 0.0001f)
assertEquals(0.2f, trimmed.last().y, 0.0001f)
assertEquals(0.165f, duplicatedEndpointTrimmed[1].x, 0.0001f)
assertEquals(0.835f, duplicatedEndpointTrimmed[2].x, 0.0001f)
assertEquals(round.points, round.pdfInkAppearancePoints(pageWidth = 100f, pageHeight = 100f))
}
}

View file

@ -71,6 +71,22 @@ class SharedPdfAnnotationSerializerTest {
"rangeStart": 4,
"rangeEnd": 18,
"note": "Keep this",
"comments": [
{
"id": "comment-1",
"author": "Ada",
"contents": "First comment",
"createdAt": 100,
"modifiedAt": 120
},
{
"id": "comment-2",
"parentId": "comment-1",
"author": "Bea",
"contents": "Reply",
"createdAt": 130
}
],
"bounds": []
}
]
@ -90,6 +106,10 @@ class SharedPdfAnnotationSerializerTest {
assertEquals(0.032f, annotations[1].pageRelativeFontSize ?: 0f, 0.0001f)
assertTrue(annotations[1].isBold)
assertEquals("Keep this", annotations[2].note)
assertEquals(listOf("comment-1", "comment-2"), annotations[2].comments.map { it.id })
assertEquals("comment-1", annotations[2].comments[1].parentId)
assertEquals("Ada", annotations[2].comments[0].author)
assertEquals(120L, annotations[2].comments[0].modifiedAt)
assertEquals(4, annotations[2].rangeStartIndex)
assertEquals(17, annotations[2].rangeEndIndex)
}
@ -125,6 +145,22 @@ class SharedPdfAnnotationSerializerTest {
tool = PdfInkTool.HIGHLIGHTER,
text = "Desktop highlight",
note = "Synced note",
comments = listOf(
SharedPdfAnnotationComment(
id = "comment-1",
author = "Ada",
contents = "Sidecar comment",
createdAt = 100L,
modifiedAt = 110L
),
SharedPdfAnnotationComment(
id = "comment-2",
parentId = "comment-1",
author = "Bea",
contents = "Nested reply",
createdAt = 120L
)
),
colorArgb = 0x8C64B5F6.toInt(),
rangeStartIndex = 7,
rangeEndIndex = 21
@ -155,6 +191,11 @@ class SharedPdfAnnotationSerializerTest {
assertEquals("BLUE", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("color").jsonPrimitive.content)
assertEquals("Synced note", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("note").jsonPrimitive.content)
assertEquals(22, legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("rangeEnd").jsonPrimitive.content.toInt())
val comments = legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("comments").jsonArray
assertEquals(2, comments.size)
assertEquals("comment-1", comments[0].jsonObject.getValue("id").jsonPrimitive.content)
assertEquals("comment-1", comments[1].jsonObject.getValue("parentId").jsonPrimitive.content)
assertEquals("Nested reply", comments[1].jsonObject.getValue("contents").jsonPrimitive.content)
}
@Test

View file

@ -0,0 +1,89 @@
package com.aryan.reader.shared.pdf
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SharedPdfReflowTest {
@Test
fun `detectRepeatingHeaderFooter returns edge text repeated across sampled pages`() {
val samples = listOf(
listOf("Book Title", "Chapter", "Body one", "12"),
listOf("Book Title", "Chapter", "Body two", "13"),
listOf("Book Title", "Chapter", "Body three", "14"),
listOf("Book Title", "Chapter", "Body four", "15"),
listOf("Other Title", "Chapter", "Body five", "16")
)
val result = SharedPdfReflowHtml.detectRepeatingHeaderFooter(samples)
assertTrue("Book Title" in result)
assertTrue("Chapter" in result)
assertFalse("Body one" in result)
}
@Test
fun `buildPageHtml maps Android-style spans headings and lists`() {
val page = SharedPdfReflowPage(
pageNumber = 3,
elements = listOf(
textLine("Repeated Header", size = 10f),
textLine("Chapter Heading", size = 22f, bold = true),
textLine("This is a paragraph that should keep bold and italic words.", size = 12f, bold = true, italic = true),
textLine("- first item", size = 12f),
textLine("2. second item", size = 12f)
)
)
val html = SharedPdfReflowHtml.buildPageHtml(
page = page,
headerFooterStrings = setOf("Repeated Header")
)
assertTrue("<p class=\"page-marker\">-- Page 3 --</p>" in html)
assertFalse("Repeated Header" in html)
assertTrue("<h1>Chapter Heading</h1>" in html)
assertTrue("<strong><em>This is a paragraph" in html)
assertTrue("<ul>" in html)
assertTrue("<li>first item</li>" in html)
assertTrue("<ol>" in html)
assertTrue("<li>second item</li>" in html)
}
@Test
fun `empty page renders fallback section`() {
val html = SharedPdfReflowHtml.buildPageHtml(
SharedPdfReflowPage(pageNumber = 1, elements = emptyList())
)
assertEquals(
"<section class=\"page-section\">\n" +
"<p class=\"page-marker\">-- Page 1 --</p>\n" +
"<p><em>(No text on this page)</em></p>\n</section>\n",
html
)
}
private fun textLine(
text: String,
size: Float,
bold: Boolean = false,
italic: Boolean = false
): SharedPdfReflowTextElement {
return SharedPdfReflowTextElement(
SharedPdfReflowTextLine(
spans = listOf(
SharedPdfReflowTextSpan(
text = text,
size = size,
isBold = bold,
isItalic = italic
)
),
yPos = 0f,
charCount = text.length
)
)
}
}

View file

@ -272,4 +272,45 @@ class SharedPdfRichTextTest {
assertEquals("Body", editable.text)
}
@Test
fun `blank page insertion uses one page break at explicit rich text boundaries`() {
val text = "Page 1$SHARED_PDF_PAGE_BREAK_CHARPage 2"
val insertionIndex = "Page 1$SHARED_PDF_PAGE_BREAK_CHAR".length
assertEquals(1, sharedPdfRichTextBlankInsertBreakCount(text, insertionIndex))
assertEquals(1, sharedPdfRichTextBlankInsertBreakCount("Page 1", "Page 1".length))
assertEquals(1, sharedPdfRichTextBlankInsertBreakCount("Page 1", 0))
}
@Test
fun `blank page insertion uses two page breaks only for measured text boundaries with following content`() {
val text = "Page 1Page 2"
val insertionIndex = "Page 1".length
assertEquals(2, sharedPdfRichTextBlankInsertBreakCount(text, insertionIndex))
assertEquals(
insertionIndex,
sharedPdfRichTextInsertionIndexForPage(
insertPageIndex = 1,
pageLayouts = listOf(
SharedPdfRichPageLayout(
pageIndex = 0,
visibleText = AnnotatedString("Page 1"),
globalStartIndex = 0,
globalEndIndex = insertionIndex,
pageHeightPx = 1_000f
),
SharedPdfRichPageLayout(
pageIndex = 1,
visibleText = AnnotatedString("Page 2"),
globalStartIndex = insertionIndex,
globalEndIndex = text.length,
pageHeightPx = 1_000f
)
),
textLength = text.length
)
)
}
}

View file

@ -478,7 +478,7 @@ class ReaderHtmlDocumentBuilderTest {
plainText = "Before image after image.",
semanticBlocks = listOf(
SemanticParagraph("Before image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 0),
SemanticImage("data:image/png;base64,abc", "Cover", null, null, CssStyle(), null, null),
SemanticImage("data:image/png;base64,abc", "Cover", null, null, CssStyle(), "cover-image", "/4/2"),
SemanticParagraph("after image", emptyList(), CssStyle(), null, null, startCharOffsetInSource = 13)
)
)
@ -492,6 +492,8 @@ class ReaderHtmlDocumentBuilderTest {
)
assertTrue(html.contains("""<img src="data:image/png;base64,abc" alt="Cover""""))
assertTrue(html.contains("""data-reader-cfi="/4/2""""))
assertTrue(html.contains("""data-reader-block-index="0""""))
}
@Test

View file

@ -0,0 +1,108 @@
package com.aryan.reader.shared.reader
import com.aryan.reader.paginatedreader.CssStyle
import com.aryan.reader.paginatedreader.SemanticFlexContainer
import com.aryan.reader.paginatedreader.SemanticImage
import com.aryan.reader.paginatedreader.SemanticParagraph
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ReaderImageModelsTest {
@Test
fun `reader image references keep chapter order and page locators`() {
val style = CssStyle()
val image = SemanticImage(
path = "data:image/png;base64,abc",
altText = "Cover art",
intrinsicWidth = 320f,
intrinsicHeight = 240f,
style = style,
elementId = "cover",
cfi = "/4/2",
blockIndex = 2
)
val book = SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "chapter-one",
title = "One",
plainText = "Before after",
semanticBlocks = listOf(
SemanticParagraph("Before", emptyList(), style, null, "/4/1", startCharOffsetInSource = 0, blockIndex = 1),
image,
SemanticParagraph("after", emptyList(), style, null, "/4/3", startCharOffsetInSource = 7, blockIndex = 3)
),
baseHref = "one.xhtml"
)
)
)
val pages = listOf(
ReaderPage(
pageIndex = 4,
chapterIndex = 0,
chapterTitle = "One",
text = "Before after",
startOffset = 0,
endOffset = 12,
semanticBlocks = book.chapters.first().semanticBlocks
)
)
val references = book.readerImageReferences(pages)
assertEquals(1, references.size)
assertEquals("Cover art", references.first().displayTitle)
assertEquals("320x240", references.first().dimensionLabel)
assertEquals(4, references.first().locator.pageIndex)
assertNull(references.first().locator.startOffset)
assertEquals("/4/2", references.first().locator.cfi)
assertEquals("Cover art.png", references.first().suggestedDownloadFileName())
}
@Test
fun `reader image references include nested images`() {
val style = CssStyle()
val nestedImage = SemanticImage(
path = "OPS/images/chart.webp",
altText = null,
intrinsicWidth = null,
intrinsicHeight = null,
style = style,
elementId = null,
cfi = null,
blockIndex = 9
)
val book = SharedEpubBook(
id = "book",
fileName = "book.epub",
title = "Book",
chapters = listOf(
SharedEpubChapter(
id = "chapter-one",
title = "One",
plainText = "",
semanticBlocks = listOf(
SemanticFlexContainer(
children = listOf(nestedImage),
style = style,
elementId = null,
cfi = null,
blockIndex = 8
)
)
)
)
)
val references = book.readerImageReferences()
assertEquals(1, references.size)
assertEquals("chart", references.first().displayTitle)
assertEquals("chart.webp", references.first().suggestedDownloadFileName())
}
}

View file

@ -20,8 +20,27 @@ import kotlin.test.assertTrue
class NonReaderLayoutModelsTest {
@Test
fun `desktop library exposes the same top level organization tabs as Android`() {
val visibleTabs = visibleNonReaderLibraryTabs()
fun `android library keeps the simple top level organization tabs`() {
val visibleTabs = visibleNonReaderLibraryTabs(ReaderPlatform.ANDROID)
assertEquals(
listOf(
NonReaderLibraryTab.BOOKS,
NonReaderLibraryTab.SHELVES,
NonReaderLibraryTab.FOLDERS
),
visibleTabs
)
assertFalse(NonReaderLibraryTab.SMART_SHELVES in visibleTabs)
assertFalse(NonReaderLibraryTab.TAGS in visibleTabs)
assertFalse(NonReaderLibraryTab.UNREAD in visibleTabs)
assertFalse(NonReaderLibraryTab.IN_PROGRESS in visibleTabs)
assertFalse(NonReaderLibraryTab.COMPLETED in visibleTabs)
}
@Test
fun `desktop library keeps browse focused on books shelves and folders`() {
val visibleTabs = visibleNonReaderLibraryTabs(ReaderPlatform.DESKTOP)
assertEquals(
listOf(
@ -49,7 +68,7 @@ class NonReaderLayoutModelsTest {
assertEquals(groupedTypes.size, groupedTypes.toSet().size)
assertTrue(FileType.DOCX in groupedTypes)
assertTrue(FileType.FODT in groupedTypes)
assertFalse(FileType.PPTX in groupedTypes)
assertTrue(FileType.PPTX in groupedTypes)
assertTrue(
nonReaderLibraryFileTypeGroups()
.any { it.title == "Comics" && FileType.CBR in it.fileTypes && FileType.CB7 in it.fileTypes }
@ -156,6 +175,30 @@ class NonReaderLayoutModelsTest {
assertEquals(1, organization.folderCount)
}
@Test
fun `hidden desktop status tabs fall back to all books`() {
val unread = book("unread", type = FileType.EPUB, progress = 0f)
val inProgress = book("progress", type = FileType.PDF, progress = 44f)
val complete = book("complete", type = FileType.CBZ, progress = 100f)
val state = SharedReaderScreenState(
rawLibraryBooks = listOf(unread, inProgress, complete),
libraryBooks = listOf(unread, inProgress, complete)
)
assertEquals(
listOf("unread", "progress", "complete"),
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.UNREAD, ReaderPlatform.DESKTOP).map { it.id }
)
assertEquals(
listOf("unread", "progress", "complete"),
state.visibleBooksForLibrarySelection(NonReaderLibraryTab.IN_PROGRESS, ReaderPlatform.DESKTOP).map { it.id }
)
assertEquals(
listOf("unread", "progress", "complete"),
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.COMPLETED, ReaderPlatform.DESKTOP).map { it.id }
)
}
@Test
fun `library visible selection follows folder shelf navigation`() {
val rootBook = book("root", sourceFolder = "/sync")
@ -214,14 +257,16 @@ class NonReaderLayoutModelsTest {
)
assertEquals(
listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY, SharedAppTab.CATALOGS),
listOf(SharedAppTab.LIBRARY, SharedAppTab.CATALOGS),
model.primaryTabs
)
assertEquals(SharedAppTab.HOME, model.selectedPrimaryTab)
assertEquals(SharedAppTab.LIBRARY, model.selectedPrimaryTab)
assertTrue(SharedAppToolAction.IMPORT_FILES in model.toolActions)
assertTrue(SharedAppToolAction.SETTINGS in model.toolActions)
assertTrue(SharedAppToolAction.IMPORT_FOLDER in model.toolActions)
assertTrue(SharedAppToolAction.SYNC in model.toolActions)
assertTrue(SharedAppToolAction.APP_THEME in model.toolActions)
assertTrue(SharedAppToolAction.PRO in model.toolActions)
assertTrue(SharedAppToolAction.AI_SETTINGS in model.toolActions)
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
assertTrue(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
@ -258,11 +303,13 @@ class NonReaderLayoutModelsTest {
featurePolicy = SharedFeaturePolicy.OssOffline
)
assertEquals(listOf(SharedAppTab.HOME, SharedAppTab.LIBRARY), model.primaryTabs)
assertEquals(SharedAppTab.HOME, model.selectedPrimaryTab)
assertEquals(listOf(SharedAppTab.LIBRARY), model.primaryTabs)
assertEquals(SharedAppTab.LIBRARY, model.selectedPrimaryTab)
assertFalse(SharedAppToolAction.PRO in model.toolActions)
assertFalse(SharedAppToolAction.AI_SETTINGS in model.toolActions)
assertFalse(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
assertFalse(SharedAppToolAction.SUPPORT in model.toolActions)
assertTrue(SharedAppToolAction.SETTINGS in model.toolActions)
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
assertTrue(model.showPrimaryNavigation)

View file

@ -38,7 +38,8 @@ class ReaderWorkspaceModelsTest {
listOf(
ReaderWorkspaceLeftSection.CONTENTS,
ReaderWorkspaceLeftSection.NOTES,
ReaderWorkspaceLeftSection.BOOKMARKS
ReaderWorkspaceLeftSection.BOOKMARKS,
ReaderWorkspaceLeftSection.IMAGES
),
model.leftSections
)
@ -53,7 +54,7 @@ class ReaderWorkspaceModelsTest {
assertTrue(ReaderWorkspaceBottomAction.PAGE_SLIDER in model.bottomActions)
assertFalse(model.panelDefaults.leftOpen)
assertFalse(model.panelDefaults.inspectorOpen)
assertFalse(model.chrome.preferAutoHide)
assertTrue(model.chrome.preferAutoHide)
}
@Test
@ -225,7 +226,7 @@ class ReaderWorkspaceModelsTest {
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
assertFalse(model.panelDefaults.leftOpen)
assertFalse(model.panelDefaults.inspectorOpen)
assertFalse(model.chrome.preferAutoHide)
assertTrue(model.chrome.preferAutoHide)
}
@Test
@ -250,7 +251,7 @@ class ReaderWorkspaceModelsTest {
)
assertTrue(model.chrome.forceVisible)
assertFalse(model.chrome.preferAutoHide)
assertTrue(model.chrome.preferAutoHide)
assertTrue("search" in model.chrome.forceVisibleReasons)
assertTrue("annotation" in model.chrome.forceVisibleReasons)
assertTrue("error" in model.chrome.forceVisibleReasons)

View file

@ -0,0 +1,73 @@
package com.aryan.reader.shared.ui
import com.aryan.reader.shared.SharedText
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedStringsTest {
@Test
fun formatsAndroidIndexedPlaceholders() {
val formatted = formatAndroidString(
"Remove \"%1\$s\" and its %2\$d books?",
listOf("Downloads", 3)
)
assertEquals("Remove \"Downloads\" and its 3 books?", formatted)
}
@Test
fun preservesEscapedPercentLiterals() {
val formatted = formatAndroidString("Preparing %1\$d%%", listOf(42))
assertEquals("Preparing 42%", formatted)
}
@Test
fun resolvesQuantityStringsBeforeFallingBack() {
val resolver = SharedStringResolver(
resolveQuantity = { name, quantity ->
when {
name == "book_count" && quantity == 1 -> "%1\$d localized book"
name == "book_count" -> "%1\$d localized books"
else -> null
}
}
)
assertEquals(
"2 localized books",
resolver.quantityString("book_count", 2, "%1\$d book", "%1\$d books", 2)
)
assertEquals(
"1 fallback book",
resolver.quantityString("missing_count", 1, "%1\$d fallback book", "%1\$d fallback books", 1)
)
}
@Test
fun resolvesSharedTextResources() {
val resolver = SharedStringResolver(
resolve = { name -> if (name == "banner_shelf_created") "Localized shelf %1\$s" else null },
resolveQuantity = { name, quantity ->
if (name == "banner_books_added_to_shelf" && quantity > 1) "%1\$d localized books" else null
}
)
assertEquals(
"Localized shelf Favorites",
resolver.sharedText(SharedText.string("banner_shelf_created", "Created shelf %1\$s", "Favorites"))
)
assertEquals(
"2 localized books",
resolver.sharedText(
SharedText.quantity(
"banner_books_added_to_shelf",
2,
"%1\$d fallback book",
"%1\$d fallback books",
2
)
)
)
}
}

View file

@ -1,11 +1,17 @@
package com.aryan.reader.shared.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.WindowPosition
@ -14,7 +20,24 @@ import androidx.compose.ui.window.rememberWindowState
import kotlinx.coroutines.delay
import java.awt.EventQueue
import java.awt.KeyboardFocusManager
import java.awt.Point
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import java.awt.Window as AwtWindow
import javax.swing.RootPaneContainer
private val LocalSharedReaderModalOwnerWindow = compositionLocalOf<AwtWindow?> { null }
@Composable
actual fun SharedReaderModalOwnerWindowProvider(
ownerWindow: Any?,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalSharedReaderModalOwnerWindow provides (ownerWindow as? AwtWindow),
content = content
)
}
@Composable
internal actual fun SharedReaderModalLayer(
@ -24,72 +47,225 @@ internal actual fun SharedReaderModalLayer(
) {
val anchor = LocalSharedReaderModalAnchorBounds.current
val density = LocalDensity.current
val ownerWindow = remember { currentNonModalOwnerWindow() }
val explicitOwnerWindow = LocalSharedReaderModalOwnerWindow.current
val fallbackOwnerWindow = remember { currentNonModalOwnerWindow() }
val ownerWindow = explicitOwnerWindow ?: fallbackOwnerWindow
val dialogSize = with(density) {
anchor?.let {
DpSize(
width = it.widthPx.toDp().coerceAtLeast(360.dp),
height = it.heightPx.toDp().coerceAtLeast(360.dp)
)
when {
level.isChromeLayer() -> {
DpSize(
width = it.widthPx.toDp().coerceAtLeast(360.dp),
height = level.chromeLayerHeight().coerceAtMost(it.heightPx.toDp().coerceAtLeast(1.dp))
)
}
level.isEdgePanelLayer() -> {
DpSize(
width = level.edgePanelLayerWidth(it.widthPx.toDp()),
height = it.heightPx.toDp().coerceAtLeast(360.dp)
)
}
else -> {
DpSize(
width = it.widthPx.toDp().coerceAtLeast(360.dp),
height = it.heightPx.toDp().coerceAtLeast(360.dp)
)
}
}
} ?: DpSize(720.dp, 620.dp)
}
val dialogPosition = with(density) {
val dialogPosition = sharedReaderModalLayerPosition(
anchor = anchor,
ownerWindow = ownerWindow,
dialogSize = dialogSize,
level = level,
density = density
)
val state = rememberWindowState(position = dialogPosition, size = dialogSize)
val windowTitle = when (level) {
SharedReaderModalLevel.Panel -> "Reader Panel"
SharedReaderModalLevel.PanelLeft -> "Reader Navigation"
SharedReaderModalLevel.PanelRight -> "Reader Tools"
SharedReaderModalLevel.Popup -> "Reader Popup"
SharedReaderModalLevel.ChromeTop -> "Reader Chrome Top"
SharedReaderModalLevel.ChromeBottom -> "Reader Chrome Bottom"
}
var modalVisible by remember(ownerWindow, explicitOwnerWindow, level) {
mutableStateOf(sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, level))
}
LaunchedEffect(dialogPosition, dialogSize) {
if (state.position != dialogPosition) {
state.position = dialogPosition
}
if (state.size != dialogSize) {
state.size = dialogSize
}
}
if (!level.isChromeLayer()) {
DisposableEffect(ownerWindow) {
onDispose {
ownerWindow?.restoreFocusAfterSharedReaderModal()
}
}
}
DisposableEffect(ownerWindow, explicitOwnerWindow, level) {
if (ownerWindow == null || explicitOwnerWindow == null || !level.isChromeLayer()) {
modalVisible = true
onDispose {}
} else {
fun syncVisibility() {
modalVisible = sharedReaderModalLayerVisible(ownerWindow, explicitOwnerWindow, level)
}
val listener = object : WindowAdapter() {
override fun windowActivated(e: WindowEvent?) = syncVisibility()
override fun windowDeactivated(e: WindowEvent?) = syncVisibility()
override fun windowGainedFocus(e: WindowEvent?) = syncVisibility()
override fun windowLostFocus(e: WindowEvent?) = syncVisibility()
override fun windowIconified(e: WindowEvent?) = syncVisibility()
override fun windowDeiconified(e: WindowEvent?) = syncVisibility()
override fun windowClosed(e: WindowEvent?) = syncVisibility()
}
ownerWindow.addWindowListener(listener)
ownerWindow.addWindowFocusListener(listener)
syncVisibility()
onDispose {
ownerWindow.removeWindowListener(listener)
ownerWindow.removeWindowFocusListener(listener)
}
}
}
if (modalVisible) {
ComposeWindow(
onCloseRequest = onDismiss,
state = state,
title = windowTitle,
undecorated = true,
transparent = true,
resizable = false,
alwaysOnTop = true,
focusable = !level.isChromeLayer()
) {
val modalWindow = window
LaunchedEffect(modalWindow, level) {
modalWindow.name = SharedReaderModalWindowNamePrefix + level.name
modalWindow.isAlwaysOnTop = true
val frontAttempts = when (level) {
SharedReaderModalLevel.Popup -> 4
SharedReaderModalLevel.Panel,
SharedReaderModalLevel.PanelLeft,
SharedReaderModalLevel.PanelRight -> 3
SharedReaderModalLevel.ChromeTop,
SharedReaderModalLevel.ChromeBottom -> 1
}
repeat(frontAttempts) { attempt ->
delay(if (attempt == 0) 30L else 80L)
modalWindow.isAlwaysOnTop = true
modalWindow.toFront()
if (!level.isChromeLayer()) {
modalWindow.requestFocus()
modalWindow.requestFocusInWindow()
}
}
}
content()
}
}
}
internal actual fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean {
return level.isEdgePanelLayer()
}
private const val SharedReaderModalWindowNamePrefix = "shared-reader-modal:"
private val SharedReaderChromeTopLayerHeight = 104.dp
private val SharedReaderChromeBottomLayerHeight = 164.dp
private val SharedReaderLeftPanelWidth = 340.dp
private val SharedReaderRightPanelWidth = 380.dp
private val SharedReaderLeftNarrowPanelMaxWidth = 320.dp
private val SharedReaderRightNarrowPanelMaxWidth = 360.dp
private val SharedReaderNarrowPanelFraction = 0.92f
private val SharedReaderWidePanelBreakpoint = 1120.dp
private fun sharedReaderModalLayerVisible(
ownerWindow: AwtWindow?,
explicitOwnerWindow: AwtWindow?,
level: SharedReaderModalLevel
): Boolean {
if (explicitOwnerWindow == null || !level.isChromeLayer()) return true
return ownerWindow?.let { window ->
window.isShowing && window.isDisplayable && (window.isActive || window.isFocused)
} == true
}
private fun sharedReaderModalLayerPosition(
anchor: SharedReaderModalAnchorBounds?,
ownerWindow: AwtWindow?,
dialogSize: DpSize,
level: SharedReaderModalLevel,
density: Density
): WindowPosition {
return with(density) {
val ownerLocation = ownerWindow?.let { window ->
runCatching { window.locationOnScreen }.getOrNull()
runCatching { window.sharedReaderModalContentLocationOnScreen() }.getOrNull()
}
if (anchor != null && ownerLocation != null) {
val topPx = when (level) {
SharedReaderModalLevel.ChromeBottom -> anchor.topPx + anchor.heightPx - dialogSize.height.toPx()
else -> anchor.topPx
}
val leftPx = when (level) {
SharedReaderModalLevel.PanelRight -> anchor.leftPx + anchor.widthPx - dialogSize.width.toPx()
else -> anchor.leftPx
}
WindowPosition(
(ownerLocation.x + anchor.leftPx).toDp(),
(ownerLocation.y + anchor.topPx).toDp()
(ownerLocation.x + leftPx).toDp(),
(ownerLocation.y + topPx).toDp()
)
} else {
WindowPosition(Alignment.Center)
}
}
val state = rememberWindowState(position = dialogPosition, size = dialogSize)
val windowTitle = when (level) {
SharedReaderModalLevel.Panel -> "Reader Panel"
SharedReaderModalLevel.Popup -> "Reader Popup"
}
LaunchedEffect(dialogPosition, dialogSize) {
state.position = dialogPosition
state.size = dialogSize
}
DisposableEffect(ownerWindow) {
onDispose {
ownerWindow?.restoreFocusAfterSharedReaderModal()
}
}
ComposeWindow(
onCloseRequest = onDismiss,
state = state,
title = windowTitle,
undecorated = true,
transparent = true,
resizable = false,
alwaysOnTop = true,
focusable = true
) {
val modalWindow = window
LaunchedEffect(modalWindow, level) {
modalWindow.name = SharedReaderModalWindowNamePrefix + level.name
modalWindow.isAlwaysOnTop = true
val frontAttempts = if (level == SharedReaderModalLevel.Popup) 4 else 3
repeat(frontAttempts) { attempt ->
delay(if (attempt == 0) 30L else 80L)
modalWindow.isAlwaysOnTop = true
modalWindow.toFront()
modalWindow.requestFocus()
modalWindow.requestFocusInWindow()
}
}
content()
}
}
private const val SharedReaderModalWindowNamePrefix = "shared-reader-modal:"
private fun AwtWindow.sharedReaderModalContentLocationOnScreen(): Point {
val contentPane = (this as? RootPaneContainer)?.contentPane
if (contentPane != null && contentPane.isShowing) {
return contentPane.locationOnScreen
}
return locationOnScreen
}
private fun SharedReaderModalLevel.isChromeLayer(): Boolean {
return this == SharedReaderModalLevel.ChromeTop || this == SharedReaderModalLevel.ChromeBottom
}
private fun SharedReaderModalLevel.isEdgePanelLayer(): Boolean {
return this == SharedReaderModalLevel.PanelLeft || this == SharedReaderModalLevel.PanelRight
}
private fun SharedReaderModalLevel.chromeLayerHeight() = when (this) {
SharedReaderModalLevel.ChromeTop -> SharedReaderChromeTopLayerHeight
SharedReaderModalLevel.ChromeBottom -> SharedReaderChromeBottomLayerHeight
else -> 0.dp
}
private fun SharedReaderModalLevel.edgePanelLayerWidth(anchorWidth: androidx.compose.ui.unit.Dp): androidx.compose.ui.unit.Dp {
val preferredWideWidth = when (this) {
SharedReaderModalLevel.PanelRight -> SharedReaderRightPanelWidth
else -> SharedReaderLeftPanelWidth
}
val preferredNarrowWidth = when (this) {
SharedReaderModalLevel.PanelRight -> minOf(SharedReaderRightNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
else -> minOf(SharedReaderLeftNarrowPanelMaxWidth, anchorWidth * SharedReaderNarrowPanelFraction)
}
return if (anchorWidth >= SharedReaderWidePanelBreakpoint) {
preferredWideWidth.coerceAtMost(anchorWidth)
} else {
preferredNarrowWidth.coerceAtMost(anchorWidth)
}.coerceAtLeast(1.dp)
}
private fun AwtWindow.restoreFocusAfterSharedReaderModal() {
EventQueue.invokeLater {
@ -129,5 +305,6 @@ private fun AwtWindow.isSharedReaderModalWindow(): Boolean {
}
return name?.startsWith(SharedReaderModalWindowNamePrefix) == true ||
windowTitle.startsWith("Reader Panel") ||
windowTitle.startsWith("Reader Popup")
windowTitle.startsWith("Reader Popup") ||
windowTitle.startsWith("Reader Chrome")
}

View file

@ -141,6 +141,33 @@ class SharedJvmBookLoaderTest {
assertTrue(book.chapters.joinToString("\n") { it.plainText }.length > 100)
}
@Test
fun `html loader splits generated pdf reflow page breaks`() = withTempDir { dir ->
val file = File(dir, "source_reflow.html").apply {
writeText(
"""
<!DOCTYPE html>
<html>
<head><title>Source (Reflow)</title></head>
<body>
<section><p class="page-marker">-- Page 1 --</p><p>First page text.</p></section>
<page-break></page-break>
<section><p class="page-marker">-- Page 2 --</p><p>Second page text.</p></section>
</body>
</html>
""".trimIndent()
)
}
val book = SharedJvmBookLoader.load(file, FileType.HTML)
assertEquals("Source (Reflow)", book.title)
assertEquals(2, book.chapters.size)
assertTrue(book.chapters[0].plainText.contains("First page text."))
assertEquals("Page 2", book.chapters[1].title)
assertTrue(book.chapters[1].plainText.contains("Second page text."))
}
@Test
fun `epub loader keeps embedded images in semantic pagination blocks`() = withTempDir { dir ->
val file = File(dir, "image-book.epub")

View file

@ -38,6 +38,7 @@ import javax.imageio.ImageIO
object SharedJvmBookLoader {
private val persistentBookCache = SharedJvmBookLoadCache()
private val loadedBookCache = SharedJvmLruMemoryCache<SharedJvmBookLoadCacheKey, SharedEpubBook>(maxEntries = 12)
private val htmlPageBreakRegex = Regex("(?is)<page-break\\b[^>]*>(?:\\s*</page-break>)?")
fun load(
file: File,
@ -167,6 +168,13 @@ object SharedJvmBookLoader {
val html = file.readTextLenient()
val sanitized = html.sanitizeReaderHtml()
val title = sanitized.tagText("title").ifBlank { sanitized.tagText("h1") }.ifBlank { file.nameWithoutExtension }
val pageChapters = sanitized.splitHtmlPageBreakChapters(title)
if (pageChapters.size > 1) {
return ParsedDocument(
title = title,
chapters = pageChapters
).toBook(file, parseCssRules(emptyMap()))
}
return SharedEpubBook(
id = file.absolutePath,
fileName = file.name,
@ -1036,6 +1044,27 @@ object SharedJvmBookLoader {
}
}
private fun String.splitHtmlPageBreakChapters(fallbackTitle: String): List<ParsedChapter> {
if (!contains("<page-break", ignoreCase = true)) return emptyList()
val parts = htmlPageBreakRegex.split(this)
.map { it.trim() }
.filter { it.htmlToText().isNotBlank() }
if (parts.size <= 1) return emptyList()
return parts.mapIndexed { index, chapterHtml ->
ParsedChapter(
title = if (index == 0) {
chapterHtml.tagText("h1")
.ifBlank { chapterHtml.tagText("h2") }
.ifBlank { fallbackTitle }
} else {
"Page ${index + 1}"
},
html = chapterHtml,
plainText = chapterHtml.htmlToText()
)
}
}
private fun decompressPalmDoc(input: ByteArray): ByteArray {
val output = ArrayList<Byte>(input.size * 2)
var i = 0