Linux support (#381)
* Add desktop release CI and support for Arch Linux packaging * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Configure Gradle and update Java environment in desktop-release workflow * Update Java setup and AUR packaging in desktop release workflow * Update Java setup and AUR packaging in desktop release workflow * Add MSIX packaging support for Windows desktop distribution * Update AUR packaging metadata and validation * Use spine toc attribute for NCX resolution * crash fixes * Implement automatic discovery and injection of EPUB font face siblings * Enhance custom font support with family grouping and variable font handling * Optimize metadata loading and improve TTS highlighting * Add keyboard navigation support for EPUB reader * Refine PDF spread page sizing to respect aspect ratios * Implement responsive maximum height for reader popups and sheets * Handle TTS generation failures by skipping problematic chunks * Refactor PDF tile rendering logic and zoom indicator behavior * Prefer block and offset locators over page index in native vertical flow * Implement save and share actions for original book files * Add Estonian language support * Implement temporary viewing mode for external files * Implement direct opening for temporary external files without library persistence * fix failing tests * Import SharedFileCapabilities in DesktopLibraryUi * Improve native vertical reader progress, persistence, and image support * Center target in viewport for native vertical reader and support animated scrolling
This commit is contained in:
parent
a13d6599d1
commit
625a4d5d2e
102 changed files with 6012 additions and 687 deletions
|
|
@ -1,20 +1,40 @@
|
|||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
id("com.android.library")
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kover)
|
||||
}
|
||||
|
||||
fun isDesktopOnlyBuild(): Boolean {
|
||||
providers.gradleProperty("desktopOnly").orNull
|
||||
?.let { return it.equals("true", ignoreCase = true) }
|
||||
|
||||
val requestedTasks = gradle.startParameter.taskNames
|
||||
return requestedTasks.isNotEmpty() && requestedTasks.all { taskName ->
|
||||
val normalized = taskName.removePrefix(":")
|
||||
normalized.startsWith("desktopApp:")
|
||||
}
|
||||
}
|
||||
|
||||
val desktopOnlyBuild = isDesktopOnlyBuild()
|
||||
|
||||
if (!desktopOnlyBuild) {
|
||||
apply(plugin = "com.android.library")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidTarget()
|
||||
if (!desktopOnlyBuild) {
|
||||
androidTarget()
|
||||
}
|
||||
jvm("desktop")
|
||||
jvmToolchain(21)
|
||||
|
||||
sourceSets {
|
||||
val commonMain by getting
|
||||
val androidMain by getting
|
||||
val desktopMain by getting
|
||||
val readerJvmMain by creating {
|
||||
dependsOn(commonMain)
|
||||
|
|
@ -22,7 +42,10 @@ kotlin {
|
|||
implementation("org.jsoup:jsoup:1.17.2")
|
||||
}
|
||||
}
|
||||
androidMain.dependsOn(readerJvmMain)
|
||||
if (!desktopOnlyBuild) {
|
||||
val androidMain by getting
|
||||
androidMain.dependsOn(readerJvmMain)
|
||||
}
|
||||
desktopMain.dependsOn(readerJvmMain)
|
||||
|
||||
commonMain.dependencies {
|
||||
|
|
@ -40,15 +63,17 @@ kotlin {
|
|||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.aryan.reader.shared"
|
||||
compileSdk = 36
|
||||
if (!desktopOnlyBuild) {
|
||||
extensions.configure<LibraryExtension>("android") {
|
||||
namespace = "com.aryan.reader.shared"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
data class CustomFontItem(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
|
|
@ -10,3 +13,76 @@ data class CustomFontItem(
|
|||
val isDeleted: Boolean = false
|
||||
)
|
||||
|
||||
data class CustomFontVariantItem(
|
||||
val font: CustomFontItem,
|
||||
val variant: FontVariant?
|
||||
)
|
||||
|
||||
data class CustomFontFamilyItem(
|
||||
val familyName: String,
|
||||
val variants: List<CustomFontVariantItem>
|
||||
)
|
||||
|
||||
fun List<CustomFontItem>.groupByFamily(): List<CustomFontFamilyItem> {
|
||||
val families = this.groupBy {
|
||||
it.displayName.familyFilenameSignature().takeIf { s -> s.isNotBlank() } ?: it.displayName
|
||||
}
|
||||
|
||||
return families.map { (familyName, fonts) ->
|
||||
val variants = fonts.map { font ->
|
||||
CustomFontVariantItem(
|
||||
font = font,
|
||||
variant = font.displayName.detectFontVariant()
|
||||
)
|
||||
}
|
||||
CustomFontFamilyItem(
|
||||
familyName = familyName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() },
|
||||
variants = variants
|
||||
)
|
||||
}.sortedBy { it.familyName }
|
||||
}
|
||||
|
||||
fun CustomFontVariantItem.fontFaceLabel(): String {
|
||||
val variant = variant ?: return "Regular"
|
||||
return when {
|
||||
variant.weight.weight >= FontWeight.Bold.weight && variant.style == FontStyle.Italic -> "Bold Italic"
|
||||
variant.weight.weight >= FontWeight.Bold.weight -> "Bold"
|
||||
variant.style == FontStyle.Italic -> "Italic"
|
||||
variant.weight == FontWeight.Normal -> "Regular"
|
||||
variant.weight.weight < FontWeight.Normal.weight -> variant.weight.fontWeightLabel()
|
||||
else -> variant.weight.fontWeightLabel()
|
||||
}
|
||||
}
|
||||
|
||||
fun CustomFontFamilyItem.fontFaceSummary(): String {
|
||||
return variants
|
||||
.sortedWith(compareBy<CustomFontVariantItem> {
|
||||
it.variant?.style == FontStyle.Italic
|
||||
}.thenBy {
|
||||
it.variant?.weight?.weight ?: FontWeight.Normal.weight
|
||||
})
|
||||
.map { it.fontFaceLabel() }
|
||||
.distinct()
|
||||
.joinToString()
|
||||
}
|
||||
|
||||
fun CustomFontFamilyItem.hasVariableWeightFace(): Boolean {
|
||||
return variants.any { variant ->
|
||||
variant.font.displayName.supportsVariableWeightAxis() || variant.font.fileName.supportsVariableWeightAxis()
|
||||
}
|
||||
}
|
||||
|
||||
private fun FontWeight.fontWeightLabel(): String {
|
||||
return when (this) {
|
||||
FontWeight.Thin -> "Thin"
|
||||
FontWeight.ExtraLight -> "Extra Light"
|
||||
FontWeight.Light -> "Light"
|
||||
FontWeight.Normal -> "Regular"
|
||||
FontWeight.Medium -> "Medium"
|
||||
FontWeight.SemiBold -> "Semi Bold"
|
||||
FontWeight.Bold -> "Bold"
|
||||
FontWeight.ExtraBold -> "Extra Bold"
|
||||
FontWeight.Black -> "Black"
|
||||
else -> weight.toString()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
data class FontVariant(
|
||||
val weight: FontWeight,
|
||||
val style: FontStyle
|
||||
)
|
||||
|
||||
private val filenameSeparatorsRegex = Regex("""[\s._,-]+""")
|
||||
|
||||
fun String.familyFilenameSignature(): String {
|
||||
val tokens = filenameTokens()
|
||||
val variantIndexes = tokens.variantTokenIndexes()
|
||||
val signatureTokens = tokens.filterIndexed { index, token ->
|
||||
token.isNotBlank() && index !in variantIndexes
|
||||
}
|
||||
return signatureTokens.joinToString(separator = " ")
|
||||
}
|
||||
|
||||
fun String.supportsVariableWeightAxis(): Boolean {
|
||||
return filenameTokens().any { it == "wght" }
|
||||
}
|
||||
|
||||
fun String.fontWeightCssDescriptor(fallbackWeight: FontWeight = FontWeight.Normal): String {
|
||||
return if (supportsVariableWeightAxis()) {
|
||||
"100 900"
|
||||
} else {
|
||||
fallbackWeight.weight.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.variantTokenIndexes(): Set<Int> {
|
||||
val indexes = mutableSetOf<Int>()
|
||||
forEachIndexed { index, token ->
|
||||
if (token in singleVariantTokens || token.toIntOrNull()?.isCssFontWeight() == true) {
|
||||
indexes += index
|
||||
}
|
||||
val next = getOrNull(index + 1) ?: return@forEachIndexed
|
||||
if ("$token$next" in compoundVariantTokens) {
|
||||
indexes += index
|
||||
indexes += index + 1
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
private fun Int.isCssFontWeight(): Boolean = this in 100..900 && this % 100 == 0
|
||||
|
||||
private fun List<String>.containsCompoundToken(compoundTokens: Set<String>): Boolean {
|
||||
return windowed(size = 2, step = 1, partialWindows = false)
|
||||
.any { (first, second) -> "$first$second" in compoundTokens }
|
||||
}
|
||||
|
||||
private fun List<String>.detectedWeight(): FontWeight {
|
||||
val compactTokens = buildList {
|
||||
addAll(this@detectedWeight)
|
||||
this@detectedWeight.windowed(size = 2, step = 1, partialWindows = false)
|
||||
.forEach { (first, second) -> add("$first$second") }
|
||||
}
|
||||
return compactTokens.asSequence()
|
||||
.mapNotNull { token ->
|
||||
tokenWeightMap[token]
|
||||
?: compoundTokenWeightMap[token]
|
||||
?: token.toIntOrNull()?.takeIf { it.isCssFontWeight() }?.let(::FontWeight)
|
||||
}
|
||||
.maxByOrNull { it.weight }
|
||||
?: FontWeight.Normal
|
||||
}
|
||||
|
||||
private fun List<String>.detectedStyle(): FontStyle {
|
||||
return if (any { it in italicTokens } || containsCompoundToken(compoundItalicTokens)) {
|
||||
FontStyle.Italic
|
||||
} else {
|
||||
FontStyle.Normal
|
||||
}
|
||||
}
|
||||
|
||||
fun String.detectFontVariant(): FontVariant? {
|
||||
val tokens = filenameTokens()
|
||||
.filter { it.isNotBlank() }
|
||||
if (tokens.isEmpty()) return null
|
||||
|
||||
return FontVariant(weight = tokens.detectedWeight(), style = tokens.detectedStyle())
|
||||
}
|
||||
|
||||
private fun String.filenameTokens(): List<String> {
|
||||
return this
|
||||
.replace(Regex("""(?i)variablefont"""), " variablefont ")
|
||||
.replace(Regex("""(?<=[a-z])(?=[A-Z])"""), "-")
|
||||
.lowercase()
|
||||
.split(filenameSeparatorsRegex)
|
||||
}
|
||||
|
||||
private val italicTokens = setOf("italic", "ital", "oblique", "obliq", "it", "itallic", "italics", "slanted", "slant")
|
||||
|
||||
private val tokenWeightMap = mapOf(
|
||||
"thin" to FontWeight.Thin,
|
||||
"hairline" to FontWeight.Thin,
|
||||
"extralight" to FontWeight.ExtraLight,
|
||||
"ultralight" to FontWeight.ExtraLight,
|
||||
"light" to FontWeight.Light,
|
||||
"regular" to FontWeight.Normal,
|
||||
"normal" to FontWeight.Normal,
|
||||
"roman" to FontWeight.Normal,
|
||||
"book" to FontWeight.Normal,
|
||||
"medium" to FontWeight.Medium,
|
||||
"semibold" to FontWeight.SemiBold,
|
||||
"demibold" to FontWeight.SemiBold,
|
||||
"bold" to FontWeight.Bold,
|
||||
"extrabold" to FontWeight.ExtraBold,
|
||||
"ultrabold" to FontWeight.ExtraBold,
|
||||
"black" to FontWeight.Black,
|
||||
"heavy" to FontWeight.Black
|
||||
)
|
||||
|
||||
private val variableFontTokens = setOf(
|
||||
"variablefont",
|
||||
"vf",
|
||||
"variable",
|
||||
"wght",
|
||||
"wdth",
|
||||
"opsz",
|
||||
"slnt",
|
||||
"grad",
|
||||
"xtra",
|
||||
"xopq",
|
||||
"yopq",
|
||||
"ytlc",
|
||||
"ytuc",
|
||||
"ytas",
|
||||
"ytde"
|
||||
)
|
||||
|
||||
private val compoundItalicTokens = setOf("bolditalic", "boldital", "boldoblique", "boldobliq")
|
||||
private val compoundWeightTokens = mapOf(
|
||||
"extralight" to FontWeight.ExtraLight,
|
||||
"ultralight" to FontWeight.ExtraLight,
|
||||
"semibold" to FontWeight.SemiBold,
|
||||
"demibold" to FontWeight.SemiBold,
|
||||
"extrabold" to FontWeight.ExtraBold,
|
||||
"ultrabold" to FontWeight.ExtraBold
|
||||
)
|
||||
private val compoundVariableFontTokens = setOf("variablefont")
|
||||
private val compoundTokenWeightMap = compoundItalicTokens.associateWith { FontWeight.Bold } + compoundWeightTokens
|
||||
|
||||
private val singleVariantTokens = italicTokens + tokenWeightMap.keys + variableFontTokens
|
||||
private val compoundVariantTokens = compoundItalicTokens + compoundWeightTokens.keys + compoundVariableFontTokens
|
||||
|
|
@ -239,7 +239,9 @@ internal enum class NonReaderLibraryPrimaryAction {
|
|||
}
|
||||
|
||||
internal enum class NonReaderBookOverflowAction {
|
||||
ADD_TO_SHELF
|
||||
ADD_TO_SHELF,
|
||||
SAVE_ORIGINAL,
|
||||
SHARE_ORIGINAL
|
||||
}
|
||||
|
||||
internal fun visibleNonReaderLibraryTabs(
|
||||
|
|
@ -266,8 +268,14 @@ internal fun bookOverflowActionsForPlatform(
|
|||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): Set<NonReaderBookOverflowAction> {
|
||||
return when (platform) {
|
||||
ReaderPlatform.DESKTOP -> setOf(NonReaderBookOverflowAction.ADD_TO_SHELF)
|
||||
ReaderPlatform.ANDROID -> emptySet()
|
||||
ReaderPlatform.DESKTOP -> setOf(
|
||||
NonReaderBookOverflowAction.ADD_TO_SHELF,
|
||||
NonReaderBookOverflowAction.SAVE_ORIGINAL
|
||||
)
|
||||
ReaderPlatform.ANDROID -> setOf(
|
||||
NonReaderBookOverflowAction.SAVE_ORIGINAL,
|
||||
NonReaderBookOverflowAction.SHARE_ORIGINAL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ import androidx.compose.material.icons.filled.MoreVert
|
|||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.AssistChip
|
||||
|
|
@ -165,6 +167,8 @@ fun SharedHomeScreen(
|
|||
onRemoveSelected: () -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit = {},
|
||||
onEditBook: (BookItem) -> Unit = {},
|
||||
onSaveOriginalFile: (BookItem) -> Unit = {},
|
||||
onShareOriginalFile: (BookItem) -> Unit = {},
|
||||
onTagSelectedBooks: () -> Unit = {},
|
||||
onAddSelectedBooksToShelf: () -> Unit = {},
|
||||
onOpenTab: (BookItem) -> Unit = onOpenBook,
|
||||
|
|
@ -173,6 +177,7 @@ fun SharedHomeScreen(
|
|||
onRecentLimitChange: (Int) -> Unit = {},
|
||||
onTogglePinned: (BookItem) -> Unit = {},
|
||||
onOpenSettings: () -> Unit = {},
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID,
|
||||
showActiveTabs: Boolean = true,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
@ -188,6 +193,16 @@ fun SharedHomeScreen(
|
|||
) {
|
||||
state.toNonReaderHomeLayoutModel()
|
||||
}
|
||||
val saveOriginalFileAction = if (NonReaderBookOverflowAction.SAVE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
|
||||
onSaveOriginalFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val shareOriginalFileAction = if (NonReaderBookOverflowAction.SHARE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
|
||||
onShareOriginalFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
NonReaderScreenScaffold(
|
||||
title = readerString("nav_home", "Home"),
|
||||
subtitle = readerString("desktop_home_subtitle", "Continue reading and recent books"),
|
||||
|
|
@ -268,6 +283,8 @@ fun SharedHomeScreen(
|
|||
onOpenBook = { onOpenBook(book) },
|
||||
onShowBookInfo = { onShowBookInfo(book) },
|
||||
onEditBook = { onEditBook(book) },
|
||||
onSaveOriginalFile = saveOriginalFileAction?.let { save -> { save(book) } },
|
||||
onShareOriginalFile = shareOriginalFileAction?.let { share -> { share(book) } },
|
||||
onTogglePinned = { onTogglePinned(book) }
|
||||
)
|
||||
}
|
||||
|
|
@ -294,6 +311,8 @@ fun SharedHomeScreen(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = saveOriginalFileAction,
|
||||
onShareOriginalFile = shareOriginalFileAction,
|
||||
onTogglePinned = onTogglePinned
|
||||
)
|
||||
}
|
||||
|
|
@ -309,6 +328,8 @@ fun SharedHomeScreen(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = saveOriginalFileAction,
|
||||
onShareOriginalFile = shareOriginalFileAction,
|
||||
onTogglePinned = onTogglePinned
|
||||
)
|
||||
}
|
||||
|
|
@ -331,6 +352,8 @@ fun SharedLibraryScreen(
|
|||
onRemoveSelected: () -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit = {},
|
||||
onEditBook: (BookItem) -> Unit = {},
|
||||
onSaveOriginalFile: (BookItem) -> Unit = {},
|
||||
onShareOriginalFile: (BookItem) -> Unit = {},
|
||||
onCreateShelf: () -> Unit = {},
|
||||
onCreateShelfWithBooks: (String, Set<String>) -> Unit = { _, _ -> },
|
||||
onCreateSmartShelf: () -> Unit = {},
|
||||
|
|
@ -446,6 +469,8 @@ fun SharedLibraryScreen(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = onSaveOriginalFile,
|
||||
onShareOriginalFile = onShareOriginalFile,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
|
|
@ -495,6 +520,8 @@ fun SharedLibraryScreen(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = onSaveOriginalFile,
|
||||
onShareOriginalFile = onShareOriginalFile,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
|
|
@ -601,8 +628,11 @@ private fun ContinueReadingCard(
|
|||
onOpenBook: () -> Unit,
|
||||
onShowBookInfo: () -> Unit,
|
||||
onEditBook: () -> Unit,
|
||||
onSaveOriginalFile: (() -> Unit)?,
|
||||
onShareOriginalFile: (() -> Unit)?,
|
||||
onTogglePinned: () -> Unit
|
||||
) {
|
||||
val canUseOriginalFileActions = !book.isOpdsStream() && book.path != null
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(SharedUiTokens.surfaceRadius),
|
||||
|
|
@ -643,6 +673,16 @@ private fun ContinueReadingCard(
|
|||
IconButton(onClick = onEditBook) {
|
||||
Icon(Icons.Default.Edit, contentDescription = readerString("action_edit", "Edit"))
|
||||
}
|
||||
if (canUseOriginalFileActions && onSaveOriginalFile != null) {
|
||||
IconButton(onClick = onSaveOriginalFile) {
|
||||
Icon(Icons.Default.Save, contentDescription = readerString("action_save_copy_to_device", "Save copy to device"))
|
||||
}
|
||||
}
|
||||
if (canUseOriginalFileActions && onShareOriginalFile != null) {
|
||||
IconButton(onClick = onShareOriginalFile) {
|
||||
Icon(Icons.Default.Share, contentDescription = readerString("action_share", "Share"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -659,6 +699,8 @@ private fun HomeBookShelf(
|
|||
onToggleSelection: (String) -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onSaveOriginalFile: ((BookItem) -> Unit)?,
|
||||
onShareOriginalFile: ((BookItem) -> Unit)?,
|
||||
onTogglePinned: (BookItem) -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
|
|
@ -674,6 +716,8 @@ private fun HomeBookShelf(
|
|||
onToggleSelection = { onToggleSelection(book.id) },
|
||||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
modifier = Modifier.width(168.dp)
|
||||
)
|
||||
|
|
@ -1226,6 +1270,8 @@ private fun LibraryContent(
|
|||
onToggleSelection: (String) -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onSaveOriginalFile: (BookItem) -> Unit,
|
||||
onShareOriginalFile: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddBooksToShelf: (Set<String>) -> Unit,
|
||||
onManageShelfBooks: ((Shelf) -> Unit)?,
|
||||
|
|
@ -1262,6 +1308,16 @@ private fun LibraryContent(
|
|||
} else {
|
||||
null
|
||||
}
|
||||
val saveOriginalFileAction = if (NonReaderBookOverflowAction.SAVE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
|
||||
onSaveOriginalFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val shareOriginalFileAction = if (NonReaderBookOverflowAction.SHARE_ORIGINAL in bookOverflowActionsForPlatform(platform)) {
|
||||
onShareOriginalFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val manageShelfBooksAction = if (platform == ReaderPlatform.DESKTOP) onManageShelfBooks else null
|
||||
val showNewShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
|
||||
primaryLibraryActionsForTab(selectedTab, platform)
|
||||
|
|
@ -1314,6 +1370,8 @@ private fun LibraryContent(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = saveOriginalFileAction,
|
||||
onShareOriginalFile = shareOriginalFileAction,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddToShelf = addToShelfFromBookAction?.let { addToShelf -> { book -> addToShelf(setOf(book.id)) } },
|
||||
modifier = Modifier.weight(1f)
|
||||
|
|
@ -1347,6 +1405,8 @@ private fun LibraryContent(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = saveOriginalFileAction,
|
||||
onShareOriginalFile = shareOriginalFileAction,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onManageShelfBooks = manageShelfBooksAction,
|
||||
|
|
@ -1370,6 +1430,8 @@ private fun LibraryContent(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = saveOriginalFileAction,
|
||||
onShareOriginalFile = shareOriginalFileAction,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onRenameShelf = onRenameShelf,
|
||||
|
|
@ -1387,6 +1449,8 @@ private fun LibraryContent(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = saveOriginalFileAction,
|
||||
onShareOriginalFile = shareOriginalFileAction,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
emptyTitle = readerString("desktop_no_tags_yet", "No tags yet"),
|
||||
|
|
@ -1409,6 +1473,8 @@ private fun LibraryContent(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = saveOriginalFileAction,
|
||||
onShareOriginalFile = shareOriginalFileAction,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
|
||||
|
|
@ -1431,6 +1497,8 @@ private fun LibraryContent(
|
|||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onSaveOriginalFile = saveOriginalFileAction,
|
||||
onShareOriginalFile = shareOriginalFileAction,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
|
|
@ -1690,6 +1758,8 @@ private fun BookGrid(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onSaveOriginalFile: ((BookItem) -> Unit)? = null,
|
||||
onShareOriginalFile: ((BookItem) -> Unit)? = null,
|
||||
onAddToShelf: ((BookItem) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
@ -1710,6 +1780,8 @@ private fun BookGrid(
|
|||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
||||
)
|
||||
}
|
||||
|
|
@ -1733,6 +1805,8 @@ private fun BookGrid(
|
|||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
||||
)
|
||||
}
|
||||
|
|
@ -1752,6 +1826,8 @@ private fun BookTile(
|
|||
onShowInfo: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onSaveOriginalFile: (() -> Unit)? = null,
|
||||
onShareOriginalFile: (() -> Unit)? = null,
|
||||
onAddToShelf: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
@ -1803,6 +1879,8 @@ private fun BookTile(
|
|||
onShowInfo = onShowInfo,
|
||||
onEdit = onEdit,
|
||||
onToggleSelection = onToggleSelection,
|
||||
onSaveOriginalFile = onSaveOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
|
||||
onShareOriginalFile = onShareOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
|
||||
onAddToShelf = onAddToShelf
|
||||
)
|
||||
}
|
||||
|
|
@ -1839,6 +1917,8 @@ private fun BookListItem(
|
|||
onShowInfo: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onSaveOriginalFile: (() -> Unit)? = null,
|
||||
onShareOriginalFile: (() -> Unit)? = null,
|
||||
onAddToShelf: (() -> Unit)? = null
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
|
@ -1881,6 +1961,8 @@ private fun BookListItem(
|
|||
onShowInfo = onShowInfo,
|
||||
onEdit = onEdit,
|
||||
onToggleSelection = onToggleSelection,
|
||||
onSaveOriginalFile = onSaveOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
|
||||
onShareOriginalFile = onShareOriginalFile.takeIf { !book.isOpdsStream() && book.path != null },
|
||||
onAddToShelf = onAddToShelf
|
||||
)
|
||||
}
|
||||
|
|
@ -1898,6 +1980,8 @@ private fun BookActionMenu(
|
|||
onShowInfo: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onToggleSelection: () -> Unit,
|
||||
onSaveOriginalFile: (() -> Unit)? = null,
|
||||
onShareOriginalFile: (() -> Unit)? = null,
|
||||
onAddToShelf: (() -> Unit)? = null
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
|
|
@ -1925,6 +2009,26 @@ private fun BookActionMenu(
|
|||
onEdit()
|
||||
}
|
||||
)
|
||||
if (onSaveOriginalFile != null) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Save, contentDescription = null) },
|
||||
text = { Text(readerString("action_save_copy_to_device", "Save copy to device")) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onSaveOriginalFile()
|
||||
}
|
||||
)
|
||||
}
|
||||
if (onShareOriginalFile != null) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
|
||||
text = { Text(readerString("action_share", "Share")) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onShareOriginalFile()
|
||||
}
|
||||
)
|
||||
}
|
||||
if (onAddToShelf != null) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
|
||||
|
|
@ -2112,6 +2216,8 @@ private fun ShelfCollection(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onSaveOriginalFile: ((BookItem) -> Unit)? = null,
|
||||
onShareOriginalFile: ((BookItem) -> Unit)? = null,
|
||||
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
||||
onManageShelfBooks: ((Shelf) -> Unit)? = null,
|
||||
onRenameShelf: (Shelf) -> Unit = {},
|
||||
|
|
@ -2151,6 +2257,8 @@ private fun ShelfCollection(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onSaveOriginalFile = onSaveOriginalFile,
|
||||
onShareOriginalFile = onShareOriginalFile,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
onRenameShelf = onRenameShelf,
|
||||
|
|
@ -2172,6 +2280,8 @@ private fun ShelfSection(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onSaveOriginalFile: ((BookItem) -> Unit)?,
|
||||
onShareOriginalFile: ((BookItem) -> Unit)?,
|
||||
onAddBooksToShelf: ((Set<String>) -> Unit)?,
|
||||
onManageShelfBooks: ((Shelf) -> Unit)?,
|
||||
onRenameShelf: (Shelf) -> Unit,
|
||||
|
|
@ -2258,6 +2368,8 @@ private fun ShelfSection(
|
|||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } },
|
||||
modifier = Modifier.width(148.dp)
|
||||
)
|
||||
|
|
@ -2279,6 +2391,8 @@ private fun FolderShelfDetail(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onSaveOriginalFile: ((BookItem) -> Unit)? = null,
|
||||
onShareOriginalFile: ((BookItem) -> Unit)? = null,
|
||||
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
||||
onOpenShelf: (Shelf) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
|
|
@ -2345,6 +2459,8 @@ private fun FolderShelfDetail(
|
|||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onSaveOriginalFile = onSaveOriginalFile?.let { save -> { save(book) } },
|
||||
onShareOriginalFile = onShareOriginalFile?.let { share -> { share(book) } },
|
||||
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1663,13 +1663,9 @@ private fun List<ReaderPage>.findSharedNativeVerticalPageIndexForBlock(block: Se
|
|||
return firstOrNull()?.pageIndex
|
||||
}
|
||||
|
||||
private fun List<SharedNativeVerticalFlowItem>.sharedNativeVerticalItemIndexForLocator(
|
||||
internal fun List<SharedNativeVerticalFlowItem>.sharedNativeVerticalItemIndexForLocator(
|
||||
locator: ReaderLocator
|
||||
): Int? {
|
||||
locator.pageIndex?.let { pageIndex ->
|
||||
val samePage = indexOfFirst { item -> item.page.pageIndex == pageIndex }
|
||||
if (samePage >= 0) return samePage
|
||||
}
|
||||
val chapterIndex = locator.chapterIndex
|
||||
if (chapterIndex != null) {
|
||||
locator.blockIndex?.let { blockIndex ->
|
||||
|
|
@ -1687,6 +1683,12 @@ private fun List<SharedNativeVerticalFlowItem>.sharedNativeVerticalItemIndexForL
|
|||
}
|
||||
if (sameOffset >= 0) return sameOffset
|
||||
}
|
||||
}
|
||||
locator.pageIndex?.let { pageIndex ->
|
||||
val samePage = indexOfFirst { item -> item.page.pageIndex == pageIndex }
|
||||
if (samePage >= 0) return samePage
|
||||
}
|
||||
if (chapterIndex != null) {
|
||||
val sameChapter = indexOfFirst { item -> item.page.chapterIndex == chapterIndex }
|
||||
if (sameChapter >= 0) return sameChapter
|
||||
}
|
||||
|
|
@ -3170,7 +3172,7 @@ private fun SemanticTextBlock.renderedTextStyle(
|
|||
).takeIf { it.isSpecified } ?: foreground,
|
||||
fontSize = fontSize,
|
||||
lineHeight = lineHeight,
|
||||
fontFamily = fallbackFontFamily,
|
||||
fontFamily = style.spanStyle.fontFamily ?: fallbackFontFamily,
|
||||
fontWeight = fontWeight
|
||||
?: style.spanStyle.fontWeight
|
||||
?: if (this is SemanticHeader) FontWeight.Bold else MaterialTheme.typography.bodyLarge.fontWeight,
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.zIndex
|
||||
import com.aryan.reader.shared.BuiltInReaderThemes
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.fontFaceSummary
|
||||
import com.aryan.reader.shared.groupByFamily
|
||||
import com.aryan.reader.shared.hasVariableWeightFace
|
||||
import com.aryan.reader.shared.HighlightColor
|
||||
import com.aryan.reader.shared.PageInfoMode
|
||||
import com.aryan.reader.shared.PageInfoPosition
|
||||
|
|
@ -1740,28 +1743,46 @@ fun SharedReaderFormatControls(
|
|||
}
|
||||
}
|
||||
|
||||
val activeCustomFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }
|
||||
if (activeCustomFonts.isNotEmpty()) {
|
||||
val activeCustomFontFamilies = customFonts.filterNot { it.isDeleted }.groupByFamily()
|
||||
if (activeCustomFontFamilies.isNotEmpty()) {
|
||||
Text(
|
||||
readerString("desktop_imported_fonts", "Imported fonts"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
SharedReaderChoiceRow {
|
||||
activeCustomFonts.forEach { font ->
|
||||
activeCustomFontFamilies.forEach { family ->
|
||||
val isSelected = family.variants.any { it.font.path == settings.customFontPath }
|
||||
FilterChip(
|
||||
selected = settings.customFontPath == font.path,
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
val baseFont = family.variants.firstOrNull { it.variant?.weight == FontWeight.Normal && it.variant?.style == androidx.compose.ui.text.font.FontStyle.Normal }?.font ?: family.variants.first().font
|
||||
onReaderAction(
|
||||
ReaderAction.SettingsChanged(
|
||||
settings.copy(
|
||||
fontFamily = font.displayName,
|
||||
customFontPath = font.path
|
||||
fontFamily = family.familyName,
|
||||
customFontPath = baseFont.path
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
label = { Text(font.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
label = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(family.familyName, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val variantsStr = buildString {
|
||||
append(family.fontFaceSummary())
|
||||
if (family.hasVariableWeightFace()) append(" - Variable weight")
|
||||
}
|
||||
if (variantsStr.isNotBlank()) {
|
||||
Text(
|
||||
"($variantsStr)",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FontVariantInferenceTest {
|
||||
@Test
|
||||
fun variableRegularAndItalicFilesShareFamilySignature() {
|
||||
val regular = "Pliant-VariableFont_wdth,wght"
|
||||
val italic = "Pliant-Italic-VariableFont_wdth,wght"
|
||||
|
||||
assertEquals(regular.familyFilenameSignature(), italic.familyFilenameSignature())
|
||||
assertEquals("pliant", regular.familyFilenameSignature())
|
||||
assertEquals(FontStyle.Italic, italic.detectFontVariant()?.style)
|
||||
assertTrue(regular.supportsVariableWeightAxis())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun familyGroupingUsesBaseFamilyForVariableFontVariants() {
|
||||
val fonts = listOf(
|
||||
fontItem("1", "Pliant-VariableFont_wdth,wght.ttf"),
|
||||
fontItem("2", "Pliant-Italic-VariableFont_wdth,wght.ttf")
|
||||
)
|
||||
|
||||
val family = fonts.groupByFamily().single()
|
||||
|
||||
assertEquals("Pliant", family.familyName)
|
||||
assertEquals(2, family.variants.size)
|
||||
assertTrue(family.variants.any { it.variant?.style == FontStyle.Italic })
|
||||
assertTrue(family.variants.any { it.variant?.weight == FontWeight.Normal })
|
||||
assertEquals("Regular, Italic", family.fontFaceSummary())
|
||||
assertTrue(family.hasVariableWeightFace())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun variableWeightAxisEmitsCssWeightRange() {
|
||||
assertEquals(
|
||||
"100 900",
|
||||
"Pliant-VariableFont_wdth,wght".fontWeightCssDescriptor(FontWeight.Normal)
|
||||
)
|
||||
assertEquals(
|
||||
"700",
|
||||
"Literata-Bold".fontWeightCssDescriptor(FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
|
||||
private fun fontItem(id: String, fileName: String): CustomFontItem {
|
||||
return CustomFontItem(
|
||||
id = id,
|
||||
displayName = fileName.substringBeforeLast('.'),
|
||||
fileName = fileName,
|
||||
fileExtension = fileName.substringAfterLast('.'),
|
||||
path = "/fonts/$fileName",
|
||||
timestamp = id.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,12 +74,21 @@ class NonReaderLayoutModelsTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `desktop book overflow exposes add to shelf action without changing android`() {
|
||||
fun `book overflow exposes platform save and share actions`() {
|
||||
assertEquals(
|
||||
setOf(NonReaderBookOverflowAction.ADD_TO_SHELF),
|
||||
setOf(
|
||||
NonReaderBookOverflowAction.ADD_TO_SHELF,
|
||||
NonReaderBookOverflowAction.SAVE_ORIGINAL
|
||||
),
|
||||
bookOverflowActionsForPlatform(ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(emptySet<NonReaderBookOverflowAction>(), bookOverflowActionsForPlatform(ReaderPlatform.ANDROID))
|
||||
assertEquals(
|
||||
setOf(
|
||||
NonReaderBookOverflowAction.SAVE_ORIGINAL,
|
||||
NonReaderBookOverflowAction.SHARE_ORIGINAL
|
||||
),
|
||||
bookOverflowActionsForPlatform(ReaderPlatform.ANDROID)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.aryan.reader.paginatedreader.SemanticImage
|
|||
import com.aryan.reader.paginatedreader.SemanticMath
|
||||
import com.aryan.reader.paginatedreader.SemanticParagraph
|
||||
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
|
|
@ -192,6 +193,57 @@ class SharedNativeVerticalReaderFlowTest {
|
|||
assertEquals(Color.Red, paragraphItem.style.blockStyle.borderTop?.color)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared native vertical restore prefers block locator before compat page`() {
|
||||
val first = SemanticParagraph(
|
||||
text = "First paragraph",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "p1",
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 1
|
||||
)
|
||||
val second = SemanticParagraph(
|
||||
text = "Second paragraph",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "p2",
|
||||
cfi = "/4/4",
|
||||
startCharOffsetInSource = 16,
|
||||
blockIndex = 2
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = "Chapter",
|
||||
plainText = "First paragraph\nSecond paragraph",
|
||||
semanticBlocks = listOf(first, second)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
|
||||
val restoredIndex = items.sharedNativeVerticalItemIndexForLocator(
|
||||
ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = 16,
|
||||
endOffset = 32,
|
||||
blockIndex = 2,
|
||||
charOffset = 16,
|
||||
cfi = "/4/4:0"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, restoredIndex)
|
||||
assertEquals(2, items[restoredIndex!!].block?.blockIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `svg math blocks stay in native vertical flow`() {
|
||||
val math = SemanticMath(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -96,15 +98,53 @@ class HtmlParserLinkTest {
|
|||
})
|
||||
}
|
||||
|
||||
private fun parse(html: String): List<SemanticBlock> {
|
||||
@Test
|
||||
fun `css font family resolves onto block and inline span styles`() {
|
||||
val cssRules = CssParser.parse(
|
||||
cssContent = """
|
||||
p { font-family: "BodyFace"; }
|
||||
i { font-style: italic; }
|
||||
""".trimIndent(),
|
||||
cssPath = null,
|
||||
baseFontSizeSp = 16f,
|
||||
density = 1f,
|
||||
constraints = Constraints(maxWidth = 400, maxHeight = 800),
|
||||
isDarkTheme = false
|
||||
).rules
|
||||
|
||||
val blocks = parse(
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<p>plain <i>italic</i></p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent(),
|
||||
cssRules = cssRules,
|
||||
fontFamilyMap = mapOf("bodyface" to FontFamily.Serif)
|
||||
)
|
||||
|
||||
val paragraph = blocks.single() as SemanticParagraph
|
||||
val italicSpan = paragraph.spans.single { it.tag == "i" }
|
||||
|
||||
assertEquals(FontFamily.Serif, paragraph.style.spanStyle.fontFamily)
|
||||
assertEquals(FontFamily.Serif, italicSpan.style.spanStyle.fontFamily)
|
||||
assertEquals(FontStyle.Italic, italicSpan.style.spanStyle.fontStyle)
|
||||
}
|
||||
|
||||
private fun parse(
|
||||
html: String,
|
||||
cssRules: OptimizedCssRules = OptimizedCssRules(),
|
||||
fontFamilyMap: Map<String, FontFamily> = emptyMap()
|
||||
): List<SemanticBlock> {
|
||||
return htmlToSemanticBlocks(
|
||||
html = html,
|
||||
cssRules = OptimizedCssRules(),
|
||||
cssRules = cssRules,
|
||||
textStyle = TextStyle(fontSize = 16.sp),
|
||||
chapterAbsPath = "OEBPS/chapter1.xhtml",
|
||||
extractionBasePath = "",
|
||||
density = Density(1f),
|
||||
fontFamilyMap = emptyMap(),
|
||||
fontFamilyMap = fontFamilyMap,
|
||||
constraints = Constraints(maxWidth = 400, maxHeight = 800)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,6 +218,48 @@ class SharedEpubPaginationCacheTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saving more than three configurations removes oldest page cache`() = runBlocking {
|
||||
val root = Files.createTempDirectory("reader-page-cache").toFile()
|
||||
try {
|
||||
val book = cacheBook()
|
||||
val settings = ReaderSettings()
|
||||
val pages = listOf(
|
||||
ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "Cached page",
|
||||
startOffset = 0,
|
||||
endOffset = 11
|
||||
)
|
||||
)
|
||||
val viewports = listOf(
|
||||
ReaderViewportSpec(widthPx = 900, heightPx = 700),
|
||||
ReaderViewportSpec(widthPx = 901, heightPx = 700),
|
||||
ReaderViewportSpec(widthPx = 902, heightPx = 700),
|
||||
ReaderViewportSpec(widthPx = 903, heightPx = 700)
|
||||
)
|
||||
val writer = SharedEpubPaginationCache(root)
|
||||
|
||||
viewports.take(3).forEachIndexed { index, viewport ->
|
||||
writer.save(book, settings, viewport, pages)
|
||||
val key = writer.keyFor(book, settings, viewport)
|
||||
val file = root
|
||||
.resolve(key.bookHash)
|
||||
.resolve("${key.configHash.toUInt().toString(16)}.pages.pb")
|
||||
file.setLastModified((index + 1) * 1_000L)
|
||||
}
|
||||
writer.save(book, settings, viewports.last(), pages)
|
||||
val reader = SharedEpubPaginationCache(root)
|
||||
|
||||
assertNull(reader.load(book, settings, viewports.first()))
|
||||
assertNotNull(reader.load(book, settings, viewports.last()))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "book-id",
|
||||
|
|
|
|||
|
|
@ -223,6 +223,22 @@ class SharedJvmBookLoaderTest {
|
|||
assertTrue(!css.contains("data:font/woff2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub loader uses spine toc id when manifest contains volume ncx files first`() = withTempDir { dir ->
|
||||
val file = File(dir, "merged-volumes.epub")
|
||||
writeMergedVolumeTocEpub(file)
|
||||
|
||||
val book = SharedJvmBookLoader.loadEpub(file)
|
||||
|
||||
assertEquals(
|
||||
listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"),
|
||||
book.tableOfContents.map { it.label }
|
||||
)
|
||||
assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth })
|
||||
assertEquals("2/title.xhtml", book.tableOfContents[2].href)
|
||||
assertEquals(4, book.chapters.size)
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (File) -> Unit) {
|
||||
val dir = Files.createTempDirectory("reader-shared-loader").toFile()
|
||||
try {
|
||||
|
|
@ -305,6 +321,70 @@ class SharedJvmBookLoaderTest {
|
|||
}
|
||||
}
|
||||
|
||||
private fun writeMergedVolumeTocEpub(file: File) {
|
||||
writeZip(file) {
|
||||
text(
|
||||
"META-INF/container.xml",
|
||||
"""
|
||||
<container>
|
||||
<rootfiles>
|
||||
<rootfile full-path="content.opf"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"content.opf",
|
||||
"""
|
||||
<package>
|
||||
<metadata>
|
||||
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Merged Volumes</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="v1title" href="1/title.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v1c1" href="1/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v2title" href="2/title.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v2c1" href="2/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v1ncx" href="1/toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="v1title"/>
|
||||
<itemref idref="v1c1"/>
|
||||
<itemref idref="v2title"/>
|
||||
<itemref idref="v2c1"/>
|
||||
</spine>
|
||||
</package>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"1/toc.ncx",
|
||||
"""
|
||||
<ncx><navMap>
|
||||
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="title.xhtml"/></navPoint>
|
||||
</navMap></ncx>
|
||||
""".trimIndent()
|
||||
)
|
||||
text(
|
||||
"toc.ncx",
|
||||
"""
|
||||
<ncx><navMap>
|
||||
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="1/title.xhtml"/>
|
||||
<navPoint><navLabel><text>Chapter 1</text></navLabel><content src="1/chapter1.xhtml"/></navPoint>
|
||||
</navPoint>
|
||||
<navPoint><navLabel><text>Volume 2</text></navLabel><content src="2/title.xhtml"/>
|
||||
<navPoint><navLabel><text>Chapter 2</text></navLabel><content src="2/chapter1.xhtml"/></navPoint>
|
||||
</navPoint>
|
||||
</navMap></ncx>
|
||||
""".trimIndent()
|
||||
)
|
||||
text("1/title.xhtml", "<html><body><h1>Volume 1</h1><p>Volume one.</p></body></html>")
|
||||
text("1/chapter1.xhtml", "<html><body><h1>Chapter 1</h1><p>Chapter one.</p></body></html>")
|
||||
text("2/title.xhtml", "<html><body><h1>Volume 2</h1><p>Volume two.</p></body></html>")
|
||||
text("2/chapter1.xhtml", "<html><body><h1>Chapter 2</h1><p>Chapter two.</p></body></html>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeTwoChapterEpub(file: File) {
|
||||
writeZip(file) {
|
||||
text(
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ private class SemanticHtmlParser(
|
|||
}
|
||||
|
||||
val body = document.body()
|
||||
return parseContainer(body, getElementStyle(body))
|
||||
return parseContainer(body, getElementStyle(body).withResolvedFontFamily())
|
||||
}
|
||||
|
||||
private inline fun Element.anyChildElement(predicate: (Element) -> Boolean): Boolean {
|
||||
|
|
@ -295,7 +295,7 @@ private class SemanticHtmlParser(
|
|||
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis,
|
||||
whiteSpace = elementOwnStyle.whiteSpace ?: inheritedStyle.whiteSpace,
|
||||
customProperties = inheritedStyle.customProperties + elementOwnStyle.customProperties
|
||||
)
|
||||
).withResolvedFontFamily()
|
||||
|
||||
if (finalStyle.display == "none") return emptyList()
|
||||
|
||||
|
|
@ -364,7 +364,19 @@ private class SemanticHtmlParser(
|
|||
val pseudoStyle = rulesForElement(element, pseudoElement).fold(CssStyle()) { acc, rule ->
|
||||
acc.merge(rule.style)
|
||||
}
|
||||
return inheritedStyle.merge(pseudoStyle)
|
||||
return inheritedStyle.merge(pseudoStyle).withResolvedFontFamily()
|
||||
}
|
||||
|
||||
private fun CssStyle.withResolvedFontFamily(): CssStyle {
|
||||
if (spanStyle.fontFamily != null) return this
|
||||
val resolvedFontFamily = fontFamilies.asSequence()
|
||||
.mapNotNull { name ->
|
||||
val normalized = name.trim().lowercase()
|
||||
currentFontFamilyMap[normalized] ?: FontFamilyMapper.nameToFontFamily(normalized)
|
||||
}
|
||||
.firstOrNull()
|
||||
?: return this
|
||||
return copy(spanStyle = spanStyle.copy(fontFamily = resolvedFontFamily))
|
||||
}
|
||||
|
||||
private fun firstCssUrl(value: String): String? {
|
||||
|
|
@ -805,7 +817,7 @@ private class SemanticHtmlParser(
|
|||
appendText("\n"); return
|
||||
}
|
||||
val currentElementStyle = getElementStyle(node, inheritedStyle.customProperties)
|
||||
val newStyle = inheritedStyle.merge(currentElementStyle)
|
||||
val newStyle = inheritedStyle.merge(currentElementStyle).withResolvedFontFamily()
|
||||
if (newStyle.display == "none") return
|
||||
val tag = node.tagName().lowercase()
|
||||
val href = node.linkHrefOrNull()
|
||||
|
|
@ -969,7 +981,10 @@ private class SemanticHtmlParser(
|
|||
val isOrdered = listElement.tagName().lowercase() == "ol"
|
||||
val items = listElement.children().mapNotNull { child ->
|
||||
if (child.tagName().lowercase() != "li") return@mapNotNull null
|
||||
val itemStyle = listStyle.merge(getElementStyle(child, listStyle.customProperties)).withResolvedBlockResources()
|
||||
val itemStyle = listStyle
|
||||
.merge(getElementStyle(child, listStyle.customProperties))
|
||||
.withResolvedFontFamily()
|
||||
.withResolvedBlockResources()
|
||||
val (text, spans) = buildSemanticTextAndSpans(child, itemStyle, inheritedLinkHref)
|
||||
val imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it) }
|
||||
SemanticListItem(text, spans, itemStyle, child.id().ifBlank { null }, child.getCfiPath(), 0, imageSrc, blockIndex = nextBlockIndex++)
|
||||
|
|
@ -990,7 +1005,9 @@ private class SemanticHtmlParser(
|
|||
val tagName = cellElement.tagName().lowercase()
|
||||
if (tagName !in listOf("td", "th")) return@mapNotNull null
|
||||
|
||||
var cellCssStyle = getElementStyle(cellElement, rowStyle.customProperties).withResolvedBlockResources()
|
||||
var cellCssStyle = getElementStyle(cellElement, rowStyle.customProperties)
|
||||
.withResolvedFontFamily()
|
||||
.withResolvedBlockResources()
|
||||
if (cellCssStyle.display == "none") return@mapNotNull null
|
||||
|
||||
if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) {
|
||||
|
|
|
|||
|
|
@ -383,20 +383,29 @@ class SharedEpubPaginationCache(
|
|||
|
||||
private fun cleanupOldConfigurations(bookHash: String) {
|
||||
val bookDir = File(cacheRoot, bookHash)
|
||||
val files = bookDir.listFiles { file -> file.isFile && file.name.endsWith(".pages.pb") }
|
||||
?.sortedByDescending { it.lastModified() }
|
||||
.orEmpty()
|
||||
val files = pageCacheFiles(bookDir)
|
||||
files.drop(3).forEach { file ->
|
||||
file.delete()
|
||||
File(bookDir, file.name.removeSuffix(".pages.pb") + ".chapters").deleteRecursively()
|
||||
}
|
||||
val activeConfigNames = files.take(3).map { it.name.removeSuffix(".pages.pb") }.toSet()
|
||||
bookDir.listFiles { file -> file.isDirectory && file.name.endsWith(".chapters") }
|
||||
.orEmpty()
|
||||
chapterCacheDirs(bookDir)
|
||||
.filterNot { dir -> dir.name.removeSuffix(".chapters") in activeConfigNames }
|
||||
.forEach { it.deleteRecursively() }
|
||||
}
|
||||
|
||||
private fun pageCacheFiles(bookDir: File): List<File> {
|
||||
val files = bookDir.listFiles() ?: return emptyList()
|
||||
return files
|
||||
.filter { file -> file.isFile && file.name.endsWith(".pages.pb") }
|
||||
.sortedByDescending { file -> file.lastModified() }
|
||||
}
|
||||
|
||||
private fun chapterCacheDirs(bookDir: File): List<File> {
|
||||
val files = bookDir.listFiles() ?: return emptyList()
|
||||
return files.filter { file -> file.isDirectory && file.name.endsWith(".chapters") }
|
||||
}
|
||||
|
||||
private fun CachedReaderPages.matches(key: SharedEpubPaginationCacheKey): Boolean {
|
||||
return schemaVersion == SharedEpubPaginationCacheSchemaVersion &&
|
||||
processingVersion == SharedEpubPaginationProcessingVersion &&
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ object SharedJvmBookLoader {
|
|||
"skipped=${!parseSemanticBlocks}"
|
||||
}
|
||||
val tocStartedAt = System.nanoTime()
|
||||
val tableOfContents = parseEpubTableOfContents(zip, manifest, basePath)
|
||||
val tableOfContents = parseEpubTableOfContents(zip, opf, manifest, basePath)
|
||||
logJvmBookOpenTrace {
|
||||
"event=epub_toc_loaded file=\"${file.name.jvmBookOpenTracePreview(120)}\" " +
|
||||
"durationMs=${tocStartedAt.jvmBookOpenTraceElapsedMs()} entries=${tableOfContents.size}"
|
||||
|
|
@ -1302,10 +1302,11 @@ object SharedJvmBookLoader {
|
|||
|
||||
private fun parseEpubTableOfContents(
|
||||
zip: ZipFile,
|
||||
opf: String,
|
||||
manifest: Map<String, String>,
|
||||
basePath: String
|
||||
): List<SharedEpubTocEntry> {
|
||||
val manifestNcxHref = manifest.values.firstOrNull { it.endsWith(".ncx", ignoreCase = true) }
|
||||
val manifestNcxHref = resolveEpubNcxHref(opf, manifest)
|
||||
val ncxPath = manifestNcxHref
|
||||
?.let { normalizeZipPath(basePath + it) }
|
||||
?: zip.entries().asSequence()
|
||||
|
|
@ -1356,6 +1357,18 @@ object SharedJvmBookLoader {
|
|||
return entries
|
||||
}
|
||||
|
||||
private fun resolveEpubNcxHref(opf: String, manifest: Map<String, String>): String? {
|
||||
Regex("<(?:[^:>]+:)?spine\\b[^>]*>", RegexOption.IGNORE_CASE)
|
||||
.find(opf)
|
||||
?.value
|
||||
?.attr("toc")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { tocId -> manifest[tocId] }
|
||||
?.let { return it }
|
||||
|
||||
return manifest.values.firstOrNull { it.endsWith(".ncx", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun loadEpubCss(zip: ZipFile, manifest: Map<String, String>, basePath: String): Map<String, String> {
|
||||
return manifest.values
|
||||
.filter { it.endsWith(".css", ignoreCase = true) }
|
||||
|
|
|
|||
|
|
@ -1158,6 +1158,7 @@ private fun SemanticTextBlock.textStyle(baseStyle: TextStyle, settings: ReaderSe
|
|||
return baseStyle.copy(
|
||||
fontSize = fontSize,
|
||||
lineHeight = lineHeight,
|
||||
fontFamily = style.spanStyle.fontFamily ?: baseStyle.fontFamily,
|
||||
fontWeight = if (this is SemanticHeader) FontWeight.Bold else baseStyle.fontWeight,
|
||||
textAlign = resolveSharedReaderTextAlign(
|
||||
cssTextAlign = style.paragraphStyle.textAlign,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue